Compare commits

..

27 Commits
0.6 ... 0.7

Author SHA1 Message Date
cupcakearmy
a8f4c23254 version bump 2019-12-04 20:53:06 +01:00
cupcakearmy
1c9f6d7d91 Merge remote-tracking branch 'origin/master' 2019-12-04 20:50:41 +01:00
cupcakearmy
18c3f4a06f use a simpler restore flag 2019-12-04 20:50:32 +01:00
632062a23f Update README.md 2019-12-04 20:50:12 +01:00
3d1d7ba256 Update README.md 2019-12-04 20:45:58 +01:00
cupcakearmy
417c54db4d cleanup 2019-12-04 20:38:59 +01:00
cupcakearmy
a9696bbc0c parse the flags in the config file to an array for the exec command 2019-12-04 20:38:48 +01:00
cupcakearmy
45f7506478 added options to the location type 2019-12-04 20:38:27 +01:00
cupcakearmy
d7cdeafe60 moved around params 2019-12-04 20:38:14 +01:00
cupcakearmy
cf09cdbb30 cleanup and support for exclusion 2019-12-04 20:38:04 +01:00
cupcakearmy
88059fe405 method to get all the backends from a list of locations 2019-12-04 20:37:50 +01:00
cupcakearmy
cdf18430b6 remove old todo 2019-12-03 23:38:49 +01:00
cupcakearmy
352754dad9 formatting & trailing commas 2019-12-03 23:37:55 +01:00
cupcakearmy
b68dc75053 removed unused import 2019-12-03 23:31:20 +01:00
cupcakearmy
6a055d3114 moved path resolver into utils 2019-12-03 23:31:13 +01:00
cupcakearmy
b5daff07eb replace indead of adding 2019-12-03 23:30:53 +01:00
b2d01d77d9 Update README.md 2019-12-03 09:52:11 +01:00
f41c042fce Merge pull request #6 from EliotBerriot/2-forget
Fix #2: support pruning and forget via snapshot policies
2019-12-03 09:43:38 +01:00
a81498ac42 Merge branch 'master' into 2-forget 2019-12-03 09:43:28 +01:00
1731ee30b3 Update README.md 2019-12-03 09:39:51 +01:00
1f4f1a1855 Merge pull request #5 from EliotBerriot/1-s3
Fix #1: fixed broken initialization check under S3
2019-12-03 09:39:25 +01:00
13cb764067 Update README.md 2019-12-03 09:19:26 +01:00
8058f37368 Merge pull request #3 from ChanceM/patch-1
Update README.md spelling correction.
2019-12-03 09:16:02 +01:00
Eliot Berriot
57ffa1e3fa Fix #2: support pruning and forget via snapshot policies 2019-12-02 14:57:10 +01:00
Eliot Berriot
671542cd30 Fix #1: fixed broken initialization check under S3 2019-12-02 11:14:02 +01:00
Gregory Moore
322df9f0bd Update README.md spelling correction.
Habe to have.
2019-11-30 09:53:41 -08:00
cupcakearmy
652158d1ed use bash 2019-11-27 19:30:01 +01:00
11 changed files with 651 additions and 436 deletions

View File

@@ -1,7 +1,7 @@
# autorestic
High backup level CLI utility for [restic](https://restic.net/).
Autorestic is a wrapper around the amazing [restic](https://restic.net/). While being amazing the restic cli can be a bit overwhelming and difficoult to manage if you habe many different location that you want to backup to multiple locations. This utility is aimed at making this easier 🙂
Autorestic is a wrapper around the amazing [restic](https://restic.net/). While being amazing the restic cli can be a bit overwhelming and difficoult to manage if you have many different location that you want to backup to multiple locations. This utility is aimed at making this easier 🙂
![Sketch](./docs/Sketch.png)
@@ -10,13 +10,14 @@ Autorestic is a wrapper around the amazing [restic](https://restic.net/). While
- Config files, no CLI
- Predictable
- Backup locations to multiple backends
- Snapshot policies and pruning
- Simple interface
- Fully encrypted
## Installation
```
curl -s https://raw.githubusercontent.com/CupCakeArmy/autorestic/master/install.sh | sh
curl -s https://raw.githubusercontent.com/CupCakeArmy/autorestic/master/install.sh | bash
```
## 🚀 Quickstart
@@ -53,8 +54,10 @@ backends:
Then we check if everything is correct by running the `check` command. We will pass the `-a` (or `--all`) to tell autorestic to check all the locations.
Lets see a more realistic example (from the config above)
```
autorestic check -a
autorestic check -l important
```
If we would check only one location we could run the following: `autorestic -l home check`.
@@ -68,9 +71,17 @@ autorestic backup -a
### Restore
```
autorestic restore -a -- --target /path/where/to/restore
autorestic restore -a --to /path/where/to/restore
```
This will restore all the locations to the selected target. If for one location there are more than one backends specified autorestic will take the first one.
```
autorestic restore -l home --from hdd --to /path/where/to/restore
```
This will restore the location `home` to the `/path/where/to/restore` folder and taking the data from the backend `hdd`
## 🗂 Locations
@@ -121,9 +132,62 @@ backends:
B2_ACCOUNT_KEY: backblaze_account_key
```
## Pruning and snapshot policies
Autorestic supports declaring snapshot policies for location to avoid keeping old snapshot around if you don't need them.
This is based on [Restic's snapshots policies](https://restic.readthedocs.io/en/latest/060_forget.html#removing-snapshots-according-to-a-policy), and can be enabled for each location as shown below:
```yaml
locations:
etc:
from: /etc
to: local
keep:
# options matches the --keep-* options used in the restic forget CLI
# cf https://restic.readthedocs.io/en/latest/060_forget.html#removing-snapshots-according-to-a-policy
last: 5 # always keep at least 5 snapshots
hourly: 3 # keep 3 last hourly shapshots
daily: 4 # keep 4 last daily shapshots
weekly: 1 # keep 1 last weekly shapshots
monthly: 12 # keep 12 last monthly shapshots
yearly: 7 # keep 7 last yearly shapshots
within: "2w" # keep snapshots from the last 2 weeks
```
Pruning can be triggered using `autorestic forget -a`, for all locations, or selectively with `autorestic forget -l <location>`. **please note that contrary to the restic CLI, `restic forget` will call `restic prune` internally.**
Run with the `--dry-run` flag to only print information about the process without actually pruning the snapshots. This is especially useful for debugging or testing policies:
```
$ autorestic forget -a --dry-run --verbose
Configuring Backends
local : Done ✓
Removing old shapshots according to policy
etc ▶ local : Removing old spnapshots… ⏳
etc ▶ local : Running in dry-run mode, not touching data
etc ▶ local : Forgeting old snapshots… ⏳Applying Policy: all snapshots within 2d of the newest
keep 3 snapshots:
ID Time Host Tags Reasons Paths
-----------------------------------------------------------------------------
531b692a 2019-12-02 12:07:28 computer within 2w /etc
51659674 2019-12-02 12:08:46 computer within 2w /etc
f8f8f976 2019-12-02 12:11:08 computer within 2w /etc
-----------------------------------------------------------------------------
3 snapshots
```
##### Note
Note that the data is automatically encrypted on the server. The key will be generated and added to your config file. Every backend will have a separate key. You should keep a copy of the keys somewhere in case your server dies. Otherwise DATA IS LOST!
## Contributors
This amazing people helped the project!
- @ChanceM [Docs]
- @EliotBerriot [Docs, Pruning, S3]

View File

@@ -1,4 +1,4 @@
#!/bin/sh
#!/bin/bash
OUT_FILE=/usr/local/bin/autorestic

View File

@@ -7,7 +7,6 @@
"bin": "npm run build && pkg lib/autorestic.js --targets latest-macos-x64,latest-linux-x64 --out-path bin"
},
"devDependencies": {
"@types/decompress": "^4.2.3",
"@types/js-yaml": "^3.12.1",
"@types/minimist": "^1.2.0",
"@types/node": "^12.11.7",

View File

@@ -5,6 +5,8 @@ import { init } from './config'
import handlers, { error, help } from './handlers'
import { Config } from './types'
process.on('uncaughtException', err => {
console.log(err.message)
process.exit(1)
@@ -18,17 +20,19 @@ export const { _: commands, ...flags } = minimist(process.argv.slice(2), {
a: 'all',
l: 'location',
b: 'backend',
d: 'dry-run',
},
boolean: ['a'],
boolean: ['a', 'd'],
string: ['l', 'b'],
})
export const VERSION = '0.6'
export const VERSION = '0.7'
export const INSTALL_DIR = '/usr/local/bin'
export const VERBOSE = flags.verbose
export const config = init()
function main() {
if (commands.length < 1) return help()
@@ -37,4 +41,5 @@ function main() {
;(handlers[command] || error)(args, flags)
}
main()

View File

@@ -1,10 +1,12 @@
import { Writer } from 'clitastic'
import { config, VERBOSE } from './autorestic'
import { Backend, Backends } from './types'
import { Backend, Backends, Locations } from './types'
import { exec, ConfigError } from './utils'
const ALREADY_EXISTS = /(?=.*exists)(?=.*already)(?=.*config).*/
const ALREADY_EXISTS = /(?=.*already)(?=.*config).*/
export const getPathFromBackend = (backend: Backend): string => {
switch (backend.type) {
@@ -32,6 +34,13 @@ export const getEnvFromBackend = (backend: Backend) => {
}
}
export const getBackendsFromLocations = (locations: Locations): string[] => {
const backends = new Set<string>()
for (const to of Object.values(locations).map(location => location.to))
Array.isArray(to) ? to.forEach(t => backends.add(t)) : backends.add(to)
return Array.from(backends)
}
export const checkAndConfigureBackend = (name: string, backend: Backend) => {
const writer = new Writer(name.blue + ' : ' + 'Configuring... ⏳')
const env = getEnvFromBackend(backend)

View File

@@ -3,49 +3,46 @@ import { Writer } from 'clitastic'
import { config, VERBOSE } from './autorestic'
import { getEnvFromBackend } from './backend'
import { Locations, Location } from './types'
import { exec, ConfigError } from './utils'
import { CONFIG_FILE } from './config'
import { resolve, dirname, isAbsolute } from 'path'
import { exec, ConfigError, pathRelativeToConfigFile, getFlagsFromLocation } from './utils'
export const backupSingle = (name: string, from: string, to: string) => {
export const backupSingle = (name: string, to: string, location: Location) => {
if (!config) throw ConfigError
const writer = new Writer(name + to.blue + ' : ' + 'Backing up... ⏳')
const backend = config.backends[to]
// Check if is an absolute path, otherwise get the path relative to the config file
const pathRelativeToConfigFile = isAbsolute(from)
? from
: resolve(dirname(CONFIG_FILE), from)
const path = pathRelativeToConfigFile(location.from)
const cmd = exec('restic', ['backup', pathRelativeToConfigFile], {
env: getEnvFromBackend(backend),
})
const cmd = exec(
'restic',
['backup', path, ...getFlagsFromLocation(location, 'backup')],
{ env: getEnvFromBackend(backend) },
)
if (VERBOSE) console.log(cmd.out, cmd.err)
writer.done(name + to.blue + ' : ' + 'Done ✓'.green)
}
export const backupLocation = (name: string, backup: Location) => {
export const backupLocation = (name: string, location: Location) => {
const display = name.yellow + ' ▶ '
if (Array.isArray(backup.to)) {
const filler = new Array(name.length + 3).fill(' ').join('')
if (Array.isArray(location.to)) {
let first = true
for (const t of backup.to) {
const nameOrBlankSpaces: string = first
? display
: new Array(name.length + 3).fill(' ').join('')
backupSingle(nameOrBlankSpaces, backup.from, t)
for (const t of location.to) {
backupSingle(first ? display : filler, t, location)
if (first) first = false
}
} else backupSingle(display, backup.from, backup.to)
} else backupSingle(display, location.from, location)
}
export const backupAll = (backups?: Locations) => {
if (!backups) {
export const backupAll = (locations?: Locations) => {
if (!locations) {
if (!config) throw ConfigError
backups = config.locations
locations = config.locations
}
console.log('\nBacking Up'.underline.grey)
for (const [name, backup] of Object.entries(backups))
backupLocation(name, backup)
for (const [name, location] of Object.entries(locations))
backupLocation(name, location)
}

View File

@@ -6,15 +6,17 @@ import { Backend, Config } from './types'
import { makeObjectKeysLowercase, rand } from './utils'
import { homedir } from 'os'
export const normalizeAndCheckBackends = (config: Config) => {
config.backends = makeObjectKeysLowercase(config.backends)
for (const [name, { type, path, key, ...rest }] of Object.entries(
config.backends
config.backends,
)) {
if (!type || !path)
throw new Error(
`The backend "${name}" is missing some required attributes`
`The backend "${name}" is missing some required attributes`,
)
const tmp: any = {
@@ -39,11 +41,11 @@ export const normalizeAndCheckBackups = (config: Config) => {
}
for (const [name, { from, to, ...rest }] of Object.entries(
config.locations
config.locations,
)) {
if (!from || !to)
throw new Error(
`The backup "${name}" is missing some required attributes`
`The backup "${name}" is missing some required attributes`,
)
if (Array.isArray(to)) for (const t of to) checkDestination(t, name)
@@ -62,7 +64,8 @@ const findConfigFile = (): string | undefined => {
try {
const file = statSync(path)
if (file.isFile()) return path
} catch (e) {}
} catch (e) {
}
}
}
@@ -74,7 +77,7 @@ export const init = (): Config | undefined => {
else return
const raw: Config = makeObjectKeysLowercase(
yaml.safeLoad(readFileSync(CONFIG_FILE).toString())
yaml.safeLoad(readFileSync(CONFIG_FILE).toString()),
)
normalizeAndCheckBackends(raw)

62
src/forget.ts Normal file
View File

@@ -0,0 +1,62 @@
import { Writer } from 'clitastic'
import { config, VERBOSE } from './autorestic'
import { getEnvFromBackend } from './backend'
import { Locations, Location, ForgetPolicy, Flags } from './types'
import { exec, ConfigError } from './utils'
export const forgetSingle = (dryRun: boolean, name: string, from: string, to: string, policy: ForgetPolicy) => {
if (!config) throw ConfigError
const writer = new Writer(name + to.blue + ' : ' + 'Removing old spnapshots… ⏳')
const backend = config.backends[to]
const flags = [] as any[]
for (const [name, value] of Object.entries(policy)) {
flags.push(`--keep-${name}`)
flags.push(value)
}
if (dryRun) {
flags.push('--dry-run')
}
const env = getEnvFromBackend(backend)
writer.replaceLn(name + to.blue + ' : ' + 'Forgeting old snapshots… ⏳')
const cmd = exec('restic', ['forget', '--path', from, '--prune', ...flags], { env })
if (VERBOSE) console.log(cmd.out, cmd.err)
writer.done(name + to.blue + ' : ' + 'Done ✓'.green)
}
export const forgetLocation = (dryRun: boolean, name: string, backup: Location, policy?: ForgetPolicy) => {
const display = name.yellow + ' ▶ '
if (!policy) {
console.log(display + 'skipping, no policy declared')
} else {
if (Array.isArray(backup.to)) {
let first = true
for (const t of backup.to) {
const nameOrBlankSpaces: string = first
? display
: new Array(name.length + 3).fill(' ').join('')
forgetSingle(dryRun, nameOrBlankSpaces, backup.from, t, policy)
if (first) first = false
}
} else forgetSingle(dryRun, display, backup.from, backup.to, policy)
}
}
export const forgetAll = (backups?: Locations, flags?: Flags) => {
if (!config) throw ConfigError
if (!backups) {
backups = config.locations
}
console.log('\nRemoving old shapshots according to policy'.underline.grey)
const dryRun = flags ? flags['dry-run'] : false
if (dryRun) console.log('Running in dry-run mode, not touching data\n'.yellow)
for (const [name, backup] of Object.entries(backups)) {
const policy = config.locations[name].keep
forgetLocation(dryRun, name, backup, policy)
}
}

View File

@@ -5,9 +5,10 @@ import { tmpdir } from 'os'
import { join, resolve } from 'path'
import { config, INSTALL_DIR, VERSION } from './autorestic'
import { checkAndConfigureBackends, getEnvFromBackend } from './backend'
import { checkAndConfigureBackends, getBackendsFromLocations, getEnvFromBackend } from './backend'
import { backupAll } from './backup'
import { Backends, Flags, Locations } from './types'
import { forgetAll } from './forget'
import { Backend, Backends, Flags, Locations } from './types'
import {
checkIfCommandIsAvailable,
checkIfResticIsAvailable,
@@ -18,6 +19,8 @@ import {
ConfigError,
} from './utils'
export type Handlers = {
[command: string]: (args: string[], flags: Flags) => void
}
@@ -28,7 +31,7 @@ const parseBackend = (flags: Flags): Backends => {
throw new Error(
'No backends specified.'.red +
'\n--all [-a]\t\t\t\tCheck all.' +
'\n--backend [-b] myBackend\t\tSpecify one or more backend'
'\n--backend [-b] myBackend\t\tSpecify one or more backend',
)
if (flags.all) return config.backends
else {
@@ -46,7 +49,7 @@ const parseLocations = (flags: Flags): Locations => {
throw new Error(
'No locations specified.'.red +
'\n--all [-a]\t\t\t\tBackup all.' +
'\n--location [-l] site1\t\t\tSpecify one or more locations'
'\n--location [-l] site1\t\t\tSpecify one or more locations',
)
if (flags.all) {
@@ -71,12 +74,8 @@ const handlers: Handlers = {
checkIfResticIsAvailable()
const locations: Locations = parseLocations(flags)
const backends = new Set<string>()
for (const to of Object.values(locations).map(location => location.to))
Array.isArray(to) ? to.forEach(t => backends.add(t)) : backends.add(to)
checkAndConfigureBackends(
filterObjectByKey(config.backends, Array.from(backends))
filterObjectByKey(config.backends, getBackendsFromLocations(locations)),
)
backupAll(locations)
@@ -85,23 +84,50 @@ const handlers: Handlers = {
restore(args, flags) {
if (!config) throw ConfigError
checkIfResticIsAvailable()
if (!flags.to) {
console.log(`You need to specify the restore path with --to`.red)
return
}
const locations = parseLocations(flags)
for (const [name, location] of Object.entries(locations)) {
const w = new Writer(name.green + `\t\tRestoring... ⏳`)
const env = getEnvFromBackend(
config.backends[
Array.isArray(location.to) ? location.to[0] : location.to
]
)
const baseText = name.green + '\t\t'
const w = new Writer(baseText + `Starting...`)
let backend: string = Array.isArray(location.to) ? location.to[0] : location.to
if (flags.from) {
if (!location.to.includes(flags.from)) {
w.done(baseText + `Backend ${flags.from} is not a valid location for ${name}`.red)
continue
}
backend = flags.from
w.replaceLn(baseText + `Restoring from ${backend.blue}...`)
} else if (Array.isArray(location.to) && location.to.length > 1) {
w.replaceLn(baseText + `Restoring from ${backend.blue}...\tTo select a specific backend pass the ${'--from'.blue} flag`)
}
const env = getEnvFromBackend(config.backends[backend])
exec(
'restic',
['restore', 'latest', '--path', resolve(location.from), ...args],
{ env }
['restore', 'latest', '--path', resolve(location.from), '--target', flags.to],
{ env },
)
w.done(name.green + '\t\tDone 🎉')
}
},
forget(args, flags) {
if (!config) throw ConfigError
checkIfResticIsAvailable()
const locations: Locations = parseLocations(flags)
checkAndConfigureBackends(
filterObjectByKey(config.backends, getBackendsFromLocations(locations)),
)
forgetAll(locations, flags)
console.log('\nFinished!'.underline + ' 🎉')
},
exec(args, flags) {
checkIfResticIsAvailable()
const backends = parseBackend(flags)
@@ -118,7 +144,8 @@ const handlers: Handlers = {
checkIfResticIsAvailable()
console.log('Restic is already installed')
return
} catch (e) {}
} catch (e) {
}
const w = new Writer('Checking latest version... ⏳')
checkIfCommandIsAvailable('bzip2')
@@ -141,7 +168,7 @@ const handlers: Handlers = {
if (!dl)
return console.log(
'Cannot get the right binary.'.red,
'Please see https://bit.ly/2Y1Rzai'
'Please see https://bit.ly/2Y1Rzai',
)
const tmp = join(tmpdir(), name)
@@ -149,8 +176,6 @@ const handlers: Handlers = {
await downloadFile(dl.browser_download_url, tmp)
// TODO: Native bz2
// Decompress
w.replaceLn('Decompressing binary... 📦')
exec('bzip2', ['-dk', tmp])
unlinkSync(tmp)
@@ -160,7 +185,7 @@ const handlers: Handlers = {
exec('mv', [extracted, INSTALL_DIR + '/restic'])
w.done(
`\nFinished! restic is installed under: ${INSTALL_DIR}`.underline + ' 🎉'
`\nFinished! restic is installed under: ${INSTALL_DIR}`.underline + ' 🎉',
)
},
uninstall() {
@@ -219,7 +244,8 @@ export const help = () => {
'\nCommands:'.yellow +
'\n check [-b, --backend] [-a, --all] Check backends' +
'\n backup [-l, --location] [-a, --all] Backup all or specified locations' +
'\n restore [-l, --location] [-- --target <out dir>] Restore all or specified locations' +
'\n forget [-l, --location] [-a, --all] [--dry-run] Forget old snapshots according to declared policies' +
'\n restore [-l, --location] [--from backend] [--to <out dir>] Restore all or specified locations' +
'\n' +
'\n exec [-b, --backend] [-a, --all] <command> -- [native options] Execute native restic command' +
'\n' +
@@ -230,14 +256,15 @@ export const help = () => {
'\n' +
'\nExamples: '.yellow +
'https://git.io/fjVbg' +
'\n'
'\n',
)
}
export const error = () => {
help()
console.log(
`Invalid Command:`.red.underline,
`${process.argv.slice(2).join(' ')}`
`${process.argv.slice(2).join(' ')}`,
)
}

View File

@@ -62,9 +62,26 @@ export type Backend =
export type Backends = { [name: string]: Backend }
export type ForgetPolicy = {
last?: number,
hourly?: number,
daily?: number,
weekly?: number,
monthly?: number,
yearly?: number,
within?: string,
tags?: string[],
}
export type Location = {
from: string
to: string | string[]
keep?: ForgetPolicy
options?: {
[key: string]: {
[key: string]: string | string[]
}
}
}
export type Locations = { [name: string]: Location }

View File

@@ -2,11 +2,16 @@ import axios from 'axios'
import { spawnSync, SpawnSyncOptions } from 'child_process'
import { randomBytes } from 'crypto'
import { createWriteStream } from 'fs'
import { isAbsolute, resolve, dirname } from 'path'
import { CONFIG_FILE } from './config'
import { Location } from './types'
export const exec = (
command: string,
args: string[],
{ env, ...rest }: SpawnSyncOptions = {}
{ env, ...rest }: SpawnSyncOptions = {},
) => {
const cmd = spawnSync(command, args, {
...rest,
@@ -26,7 +31,7 @@ export const checkIfResticIsAvailable = () =>
checkIfCommandIsAvailable(
'restic',
'Restic is not installed'.red +
' https://restic.readthedocs.io/en/latest/020_installation.html#stable-releases'
' https://restic.readthedocs.io/en/latest/020_installation.html#stable-releases',
)
export const checkIfCommandIsAvailable = (cmd: string, errorMsg?: string) => {
@@ -36,25 +41,27 @@ export const checkIfCommandIsAvailable = (cmd: string, errorMsg?: string) => {
export const makeObjectKeysLowercase = (object: Object): any =>
Object.fromEntries(
Object.entries(object).map(([key, value]) => [key.toLowerCase(), value])
Object.entries(object).map(([key, value]) => [key.toLowerCase(), value]),
)
export function rand(length = 32): string {
return randomBytes(length / 2).toString('hex')
}
export const singleToArray = <T>(singleOrArray: T | T[]): T[] =>
Array.isArray(singleOrArray) ? singleOrArray : [singleOrArray]
export const filterObject = <T>(
obj: { [key: string]: T },
filter: (item: [string, T]) => boolean
filter: (item: [string, T]) => boolean,
): { [key: string]: T } =>
Object.fromEntries(Object.entries(obj).filter(filter))
export const filterObjectByKey = <T>(
obj: { [key: string]: T },
keys: string[]
keys: string[],
) => filterObject(obj, ([key]) => keys.includes(key))
export const downloadFile = async (url: string, to: string) =>
@@ -74,4 +81,29 @@ export const downloadFile = async (url: string, to: string) =>
})
})
// Check if is an absolute path, otherwise get the path relative to the config file
export const pathRelativeToConfigFile = (path: string): string => isAbsolute(path)
? path
: resolve(dirname(CONFIG_FILE), path)
export const ConfigError = new Error('Config file not found')
export const getFlagsFromLocation = (location: Location, command?: string): string[] => {
if (!location.options) return []
const all = {
...location.options.global,
...(location.options[command || ''] || {}),
}
let flags: string[] = []
// Map the flags to an array for the exec function.
for (let [flag, values] of Object.entries(all)) {
if (!Array.isArray(values))
values = [values]
for (const value of values)
flags = [...flags, `--${flag}`, value]
}
return flags
}