Compare commits

...

19 Commits
0.4 ... 0.6

Author SHA1 Message Date
cupcakearmy
06ce8180fb support for absolute paths 2019-10-26 21:50:48 +02:00
81f513d77b Update README.md 2019-10-26 21:49:25 +02:00
e32521e6ea Update README.md 2019-10-26 21:49:06 +02:00
f5c5b39b30 Update README.md 2019-10-26 21:48:32 +02:00
e016c8defc Update README.md 2019-10-26 21:40:15 +02:00
a2e0a0c9cc Update README.md 2019-10-26 21:34:02 +02:00
cupcakearmy
f9b04ea342 remove sample 2019-10-26 21:31:33 +02:00
770c9dd7d4 Update README.md 2019-10-26 21:30:47 +02:00
cupcakearmy
851bbe5776 sketch 2019-10-26 21:21:56 +02:00
cupcakearmy
8fb6bdb3c6 version bump 2019-10-26 21:03:22 +02:00
cupcakearmy
47f5d91e89 version as normal command 2019-10-26 21:03:08 +02:00
cupcakearmy
de27034b94 config optional if not required for current operation 2019-10-26 20:52:17 +02:00
cupcakearmy
9dafe9d36a wrong version bump 2019-10-26 20:09:19 +02:00
cupcakearmy
d47e7d0912 directories are now relative to its config file location 2019-10-26 20:07:52 +02:00
cupcakearmy
e47d6be854 small bugs 2019-10-26 20:07:41 +02:00
cupcakearmy
993fe072e2 also check for default file in the current directory 2019-10-26 20:07:36 +02:00
cupcakearmy
3d1e28e574 typos 2019-10-26 20:07:19 +02:00
cupcakearmy
3c0ebdfb4a prettier and ignore yarn 2019-10-26 20:06:48 +02:00
cupcakearmy
2653633c91 target only macos and linux 2019-06-21 13:32:30 +02:00
13 changed files with 589 additions and 399 deletions

4
.gitignore vendored
View File

@@ -1,8 +1,10 @@
node_modules/ node_modules/
package-lock.json package-lock.json
.idea .idea
yarn.lock
config.yml
bin bin
lib lib
data data
.autorestic.yml

3
.prettierrc Normal file
View File

@@ -0,0 +1,3 @@
semi: false
singleQuote: true
trailingComma: 'es5'

122
README.md
View File

@@ -1,9 +1,129 @@
# autorestic # autorestic
High level CLI utility for restic 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 🙂
![Sketch](./docs/Sketch.png)
## 🌈 Features
- Config files, no CLI
- Predictable
- Backup locations to multiple backends
- Simple interface
- Fully encrypted
## Installation ## Installation
``` ```
curl -s https://raw.githubusercontent.com/CupCakeArmy/autorestic/master/install.sh | sh curl -s https://raw.githubusercontent.com/CupCakeArmy/autorestic/master/install.sh | sh
``` ```
## 🚀 Quickstart
### Setup
First we need to configure our locations and backends. Simply create a `.autorestic.yml` either in your home directory of in the folder from which you will execute `autorestic`.
Optionally you can specify the location of your config file by passing it as argument: `autorestic -c ../path/config.yml`
```yaml
locations:
home:
from: /home/me
to: remote
important:
from: /path/to/important/stuff
to:
- remote
- hdd
backends:
remote:
type: b2
path: 'myBucket:backup/home'
B2_ACCOUNT_ID: account_id
B2_ACCOUNT_KEY: account_key
hdd:
type: local
path: /mnt/my_external_storage
```
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.
```
autorestic check -a
```
If we would check only one location we could run the following: `autorestic -l home check`.
### Backup
```
autorestic backup -a
```
### Restore
```
autorestic restore -a -- --target /path/where/to/restore
```
## 🗂 Locations
A location simply a folder on your machine that restic will backup. The paths can be relative from the config file. A location can have multiple backends, so that the data is secured across multiple servers.
```yaml
locations:
my-location-name:
from: path/to/backup
to:
- name-of-backend
- also-backup-to-this-backend
```
## 💽 Backends
Backends are the place where you data will be saved. Backups are incremental and encrypted.
### Fields
##### `type`
Type of the backend see a list [here](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html)
Supported are:
- [Local](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#local)
- [Backblaze B2](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#backblaze-b2)
- [Amazon S3](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#amazon-s3)
- [Minio](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#minio-server)
- [Google Cloud Storage](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#google-cloud-storage)
- [Microsoft Azure Storage](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#microsoft-azure-blob-storage)
For each backend you need to specify the right variables as shown in the example below.
##### `path`
The path on the remote server.
For object storages as
##### Example
```yaml
backends:
name-of-backend:
type: b2
path: 'myAccount:myBucket/my/path'
B2_ACCOUNT_ID: backblaze_account_id
B2_ACCOUNT_KEY: backblaze_account_key
```
##### 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!

View File

@@ -1,18 +0,0 @@
locations:
home:
from: /home/myUser
to: remote
important:
from: /path/to/important/stuff
to:
- remote
- hdd
backends:
remote:
type: b2
path: 'myBucket:backup/home'
B2_ACCOUNT_ID: account_id
B2_ACCOUNT_KEY: account_key
hdd:
type: local
path: /mnt/my_external_storage

BIN
docs/Sketch.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -4,12 +4,13 @@
"build": "tsc", "build": "tsc",
"build:watch": "tsc -w", "build:watch": "tsc -w",
"dev": "tsnd --no-notify --respawn ./src/autorestic.ts", "dev": "tsnd --no-notify --respawn ./src/autorestic.ts",
"bin": "npm run build && pkg lib/autorestic.js --out-path bin" "bin": "npm run build && pkg lib/autorestic.js --targets latest-macos-x64,latest-linux-x64 --out-path bin"
}, },
"devDependencies": { "devDependencies": {
"@types/decompress": "^4.2.3", "@types/decompress": "^4.2.3",
"@types/js-yaml": "^3.12.1", "@types/js-yaml": "^3.12.1",
"@types/minimist": "^1.2.0", "@types/minimist": "^1.2.0",
"@types/node": "^12.11.7",
"pkg": "^4.4.0", "pkg": "^4.4.0",
"ts-node-dev": "^1.0.0-pre.40", "ts-node-dev": "^1.0.0-pre.40",
"typescript": "^3.5.1" "typescript": "^3.5.1"

View File

@@ -1,50 +1,40 @@
import 'colors' import 'colors'
import minimist from 'minimist' import minimist from 'minimist'
import { homedir } from 'os'
import { resolve } from 'path'
import { init } from './config' import { init } from './config'
import handlers, { error, help } from './handlers' import handlers, { error, help } from './handlers'
import { Config } from './types' import { Config } from './types'
process.on('uncaughtException', err => { process.on('uncaughtException', err => {
console.log(err.message) console.log(err.message)
process.exit(1) process.exit(1)
}) })
export const { _: commands, ...flags } = minimist(process.argv.slice(2), { export const { _: commands, ...flags } = minimist(process.argv.slice(2), {
alias: { alias: {
'c': 'config', c: 'config',
'v': 'verbose', v: 'version',
'h': 'help', h: 'help',
'a': 'all', a: 'all',
'l': 'location', l: 'location',
'b': 'backend', b: 'backend',
}, },
boolean: ['a'], boolean: ['a'],
string: ['l', 'b'], string: ['l', 'b'],
}) })
export const VERSION = '0.4' export const VERSION = '0.6'
export const DEFAULT_CONFIG = '/.autorestic.yml'
export const INSTALL_DIR = '/usr/local/bin' export const INSTALL_DIR = '/usr/local/bin'
export const CONFIG_FILE: string = resolve(flags.config || homedir() + DEFAULT_CONFIG)
export const VERBOSE = flags.verbose export const VERBOSE = flags.verbose
export const config: Config = init() export const config = init()
function main() { function main() {
if (flags.version) if (commands.length < 1) return help()
return console.log('version'.grey, VERSION)
if (commands.length < 1) const command: string = commands[0]
return help() const args: string[] = commands.slice(1)
;(handlers[command] || error)(args, flags)
const command: string = commands[0]
const args: string[] = commands.slice(1)
;(handlers[command] || error)(args, flags)
} }
main() main()

View File

@@ -2,56 +2,57 @@ import { Writer } from 'clitastic'
import { config, VERBOSE } from './autorestic' import { config, VERBOSE } from './autorestic'
import { Backend, Backends } from './types' import { Backend, Backends } from './types'
import { exec } from './utils' import { exec, ConfigError } from './utils'
const ALREADY_EXISTS = /(?=.*exists)(?=.*already)(?=.*config).*/ const ALREADY_EXISTS = /(?=.*exists)(?=.*already)(?=.*config).*/
export const getPathFromBackend = (backend: Backend): string => { export const getPathFromBackend = (backend: Backend): string => {
switch (backend.type) { switch (backend.type) {
case 'local': case 'local':
return backend.path return backend.path
case 'b2': case 'b2':
case 'azure': case 'azure':
case 'gs': case 'gs':
case 's3': case 's3':
return `${backend.type}:${backend.path}` return `${backend.type}:${backend.path}`
case 'sftp': case 'sftp':
case 'rest': case 'rest':
throw new Error(`Unsupported backend type: "${backend.type}"`) throw new Error(`Unsupported backend type: "${backend.type}"`)
default: default:
throw new Error(`Unknown backend type.`) throw new Error(`Unknown backend type.`)
} }
} }
export const getEnvFromBackend = (backend: Backend) => { export const getEnvFromBackend = (backend: Backend) => {
const { type, path, key, ...rest } = backend const { type, path, key, ...rest } = backend
return { return {
RESTIC_PASSWORD: key, RESTIC_PASSWORD: key,
RESTIC_REPOSITORY: getPathFromBackend(backend), RESTIC_REPOSITORY: getPathFromBackend(backend),
...rest, ...rest,
} }
} }
export const checkAndConfigureBackend = (name: string, backend: Backend) => { export const checkAndConfigureBackend = (name: string, backend: Backend) => {
const writer = new Writer(name.blue + ' : ' + 'Configuring... ⏳') const writer = new Writer(name.blue + ' : ' + 'Configuring... ⏳')
const env = getEnvFromBackend(backend) const env = getEnvFromBackend(backend)
const { out, err } = exec('restic', ['init'], { env }) const { out, err } = exec('restic', ['init'], { env })
if (err.length > 0 && !ALREADY_EXISTS.test(err)) if (err.length > 0 && !ALREADY_EXISTS.test(err))
throw new Error(`Could not load the backend "${name}": ${err}`) throw new Error(`Could not load the backend "${name}": ${err}`)
if (VERBOSE && out.length > 0) console.log(out) if (VERBOSE && out.length > 0) console.log(out)
writer.done(name.blue + ' : ' + 'Done ✓'.green) writer.done(name.blue + ' : ' + 'Done ✓'.green)
} }
export const checkAndConfigureBackends = (backends?: Backends) => {
if (!backends) {
if (!config) throw ConfigError
backends = config.backends
}
export const checkAndConfigureBackends = (backends: Backends = config.backends) => { console.log('\nConfiguring Backends'.grey.underline)
console.log('\nConfiguring Backends'.grey.underline) for (const [name, backend] of Object.entries(backends))
for (const [name, backend] of Object.entries(backends)) checkAndConfigureBackend(name, backend)
checkAndConfigureBackend(name, backend)
} }

View File

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

View File

@@ -1,58 +1,86 @@
import { readFileSync, writeFileSync } from 'fs' import { readFileSync, writeFileSync, statSync } from 'fs'
import { resolve } from 'path'
import yaml from 'js-yaml' import yaml from 'js-yaml'
import { CONFIG_FILE } from './autorestic' import { flags } from './autorestic'
import { Backend, Config } from './types' import { Backend, Config } from './types'
import { makeObjectKeysLowercase, rand } from './utils' import { makeObjectKeysLowercase, rand } from './utils'
import { homedir } from 'os'
export const normalizeAndCheckBackends = (config: Config) => { export const normalizeAndCheckBackends = (config: Config) => {
config.backends = makeObjectKeysLowercase(config.backends) config.backends = makeObjectKeysLowercase(config.backends)
for (const [name, { type, path, key, ...rest }] of Object.entries(config.backends)) { for (const [name, { type, path, key, ...rest }] of Object.entries(
config.backends
)) {
if (!type || !path)
throw new Error(
`The backend "${name}" is missing some required attributes`
)
if (!type || !path) throw new Error(`The backend "${name}" is missing some required attributes`) const tmp: any = {
type,
path,
key: key || rand(128),
}
for (const [key, value] of Object.entries(rest))
tmp[key.toUpperCase()] = value
const tmp: any = { config.backends[name] = tmp as Backend
type, }
path,
key: key || rand(128),
}
for (const [key, value] of Object.entries(rest))
tmp[key.toUpperCase()] = value
config.backends[name] = tmp as Backend
}
} }
export const normalizeAndCheckBackups = (config: Config) => { export const normalizeAndCheckBackups = (config: Config) => {
config.locations = makeObjectKeysLowercase(config.locations) config.locations = makeObjectKeysLowercase(config.locations)
const backends = Object.keys(config.backends) const backends = Object.keys(config.backends)
const checkDestination = (backend: string, backup: string) => { const checkDestination = (backend: string, backup: string) => {
if (!backends.includes(backend)) if (!backends.includes(backend))
throw new Error(`Cannot find the backend "${backend}" for "${backup}"`) throw new Error(`Cannot find the backend "${backend}" for "${backup}"`)
} }
for (const [name, { from, to, ...rest }] of Object.entries(config.locations)) { for (const [name, { from, to, ...rest }] of Object.entries(
if (!from || !to) throw new Error(`The backup "${name}" is missing some required attributes`) config.locations
)) {
if (!from || !to)
throw new Error(
`The backup "${name}" is missing some required attributes`
)
if (Array.isArray(to)) if (Array.isArray(to)) for (const t of to) checkDestination(t, name)
for (const t of to) else checkDestination(to, name)
checkDestination(t, name) }
else
checkDestination(to, name)
}
} }
const findConfigFile = (): string | undefined => {
export const init = (): Config => { const config = '.autorestic.yml'
const raw: Config = makeObjectKeysLowercase(yaml.safeLoad(readFileSync(CONFIG_FILE).toString())) const paths = [
resolve(flags.config || ''),
normalizeAndCheckBackends(raw) resolve('./' + config),
normalizeAndCheckBackups(raw) homedir() + '/' + config,
]
writeFileSync(CONFIG_FILE, yaml.safeDump(raw)) for (const path of paths) {
try {
return raw const file = statSync(path)
if (file.isFile()) return path
} catch (e) {}
}
}
export let CONFIG_FILE: string = ''
export const init = (): Config | undefined => {
const file = findConfigFile()
if (file) CONFIG_FILE = file
else return
const raw: Config = makeObjectKeysLowercase(
yaml.safeLoad(readFileSync(CONFIG_FILE).toString())
)
normalizeAndCheckBackends(raw)
normalizeAndCheckBackups(raw)
writeFileSync(CONFIG_FILE, yaml.safeDump(raw))
return raw
} }

View File

@@ -4,213 +4,241 @@ import { unlinkSync } from 'fs'
import { tmpdir } from 'os' import { tmpdir } from 'os'
import { join, resolve } from 'path' import { join, resolve } from 'path'
import { config, CONFIG_FILE, INSTALL_DIR, VERSION } from './autorestic' import { config, INSTALL_DIR, VERSION } from './autorestic'
import { checkAndConfigureBackends, getEnvFromBackend } from './backend' import { checkAndConfigureBackends, getEnvFromBackend } from './backend'
import { backupAll } from './backup' import { backupAll } from './backup'
import { Backends, Flags, Locations } from './types' import { Backends, Flags, Locations } from './types'
import { import {
checkIfCommandIsAvailable, checkIfCommandIsAvailable,
checkIfResticIsAvailable, checkIfResticIsAvailable,
downloadFile, downloadFile,
exec, exec,
filterObjectByKey, filterObjectByKey,
singleToArray, singleToArray,
ConfigError,
} from './utils' } from './utils'
export type Handlers = { [command: string]: (args: string[], flags: Flags) => void } export type Handlers = {
[command: string]: (args: string[], flags: Flags) => void
}
const parseBackend = (flags: Flags): Backends => { const parseBackend = (flags: Flags): Backends => {
if (!flags.all && !flags.backend) if (!config) throw ConfigError
throw new Error('No backends specified.'.red if (!flags.all && !flags.backend)
+ '\n--all [-a]\t\t\t\tCheck all.' throw new Error(
+ '\n--backend [-b] myBackend\t\tSpecify one or more backend', 'No backends specified.'.red +
) '\n--all [-a]\t\t\t\tCheck all.' +
if (flags.all) '\n--backend [-b] myBackend\t\tSpecify one or more backend'
return config.backends )
else { if (flags.all) return config.backends
const backends = singleToArray<string>(flags.backend) else {
for (const backend of backends) const backends = singleToArray<string>(flags.backend)
if (!config.backends[backend]) for (const backend of backends)
throw new Error('Invalid backend: '.red + backend) if (!config.backends[backend])
return filterObjectByKey(config.backends, backends) throw new Error('Invalid backend: '.red + backend)
} return filterObjectByKey(config.backends, backends)
}
} }
const parseLocations = (flags: Flags): Locations => { const parseLocations = (flags: Flags): Locations => {
if (!flags.all && !flags.location) if (!config) throw ConfigError
throw new Error('No locations specified.'.red if (!flags.all && !flags.location)
+ '\n--all [-a]\t\t\t\tBackup all.' throw new Error(
+ '\n--location [-l] site1\t\t\tSpecify one or more locations', 'No locations specified.'.red +
) '\n--all [-a]\t\t\t\tBackup all.' +
'\n--location [-l] site1\t\t\tSpecify one or more locations'
)
if (flags.all) { if (flags.all) {
return config.locations return config.locations
} else { } else {
const locations = singleToArray<string>(flags.location) const locations = singleToArray<string>(flags.location)
for (const location of locations) for (const location of locations)
if (!config.locations[location]) if (!config.locations[location])
throw new Error('Invalid location: '.red + location) throw new Error('Invalid location: '.red + location)
return filterObjectByKey(config.locations, locations) return filterObjectByKey(config.locations, locations)
} }
} }
const handlers: Handlers = { const handlers: Handlers = {
check(args, flags) { check(args, flags) {
checkIfResticIsAvailable() checkIfResticIsAvailable()
const backends = parseBackend(flags) const backends = parseBackend(flags)
checkAndConfigureBackends(backends) checkAndConfigureBackends(backends)
}, },
backup(args, flags) { backup(args, flags) {
checkIfResticIsAvailable() if (!config) throw ConfigError
const locations: Locations = parseLocations(flags) checkIfResticIsAvailable()
const locations: Locations = parseLocations(flags)
const backends = new Set<string>() const backends = new Set<string>()
for (const to of Object.values(locations).map(location => location.to)) for (const to of Object.values(locations).map(location => location.to))
Array.isArray(to) ? to.forEach(t => backends.add(t)) : backends.add(to) Array.isArray(to) ? to.forEach(t => backends.add(t)) : backends.add(to)
checkAndConfigureBackends(filterObjectByKey(config.backends, Array.from(backends))) checkAndConfigureBackends(
backupAll(locations) filterObjectByKey(config.backends, Array.from(backends))
)
backupAll(locations)
console.log('\nFinished!'.underline + ' 🎉') console.log('\nFinished!'.underline + ' 🎉')
}, },
restore(args, flags) { restore(args, flags) {
checkIfResticIsAvailable() if (!config) throw ConfigError
const locations = parseLocations(flags) checkIfResticIsAvailable()
for (const [name, location] of Object.entries(locations)) { const locations = parseLocations(flags)
const w = new Writer(name.green + `\t\tRestoring... ⏳`) for (const [name, location] of Object.entries(locations)) {
const env = getEnvFromBackend(config.backends[Array.isArray(location.to) ? location.to[0] : location.to]) const w = new Writer(name.green + `\t\tRestoring... ⏳`)
const env = getEnvFromBackend(
config.backends[
Array.isArray(location.to) ? location.to[0] : location.to
]
)
exec( exec(
'restic', 'restic',
['restore', 'latest', '--path', resolve(location.from), ...args], ['restore', 'latest', '--path', resolve(location.from), ...args],
{ env }, { env }
) )
w.done(name.green + '\t\tDone 🎉') w.done(name.green + '\t\tDone 🎉')
} }
}, },
exec(args, flags) { exec(args, flags) {
checkIfResticIsAvailable() checkIfResticIsAvailable()
const backends = parseBackend(flags) const backends = parseBackend(flags)
for (const [name, backend] of Object.entries(backends)) { for (const [name, backend] of Object.entries(backends)) {
console.log(`\n${name}:\n`.grey.underline) console.log(`\n${name}:\n`.grey.underline)
const env = getEnvFromBackend(backend) const env = getEnvFromBackend(backend)
const { out, err } = exec('restic', args, { env }) const { out, err } = exec('restic', args, { env })
console.log(out, err) console.log(out, err)
} }
}, },
async install() { async install() {
try { try {
checkIfResticIsAvailable() checkIfResticIsAvailable()
console.log('Restic is already installed') console.log('Restic is already installed')
return return
} catch (e) { } catch (e) {}
}
const w = new Writer('Checking latest version... ⏳') const w = new Writer('Checking latest version... ⏳')
checkIfCommandIsAvailable('bzip2') checkIfCommandIsAvailable('bzip2')
const { data: json } = await axios({ const { data: json } = await axios({
method: 'get', method: 'get',
url: 'https://api.github.com/repos/restic/restic/releases/latest', url: 'https://api.github.com/repos/restic/restic/releases/latest',
responseType: 'json', responseType: 'json',
}) })
const archMap: { [a: string]: string } = { const archMap: { [a: string]: string } = {
'x32': '386', x32: '386',
'x64': 'amd64', x64: 'amd64',
} }
w.replaceLn('Downloading binary... 🌎') w.replaceLn('Downloading binary... 🌎')
const name = `${json.name.replace(' ', '_')}_${process.platform}_${archMap[process.arch]}.bz2` const name = `${json.name.replace(' ', '_')}_${process.platform}_${
const dl = json.assets.find((asset: any) => asset.name === name) archMap[process.arch]
if (!dl) return console.log( }.bz2`
'Cannot get the right binary.'.red, const dl = json.assets.find((asset: any) => asset.name === name)
'Please see https://bit.ly/2Y1Rzai', if (!dl)
) return console.log(
'Cannot get the right binary.'.red,
'Please see https://bit.ly/2Y1Rzai'
)
const tmp = join(tmpdir(), name) const tmp = join(tmpdir(), name)
const extracted = tmp.slice(0, -4) //without the .bz2 const extracted = tmp.slice(0, -4) //without the .bz2
await downloadFile(dl.browser_download_url, tmp) await downloadFile(dl.browser_download_url, tmp)
// TODO: Native bz2 // TODO: Native bz2
// Decompress // Decompress
w.replaceLn('Decompressing binary... 📦') w.replaceLn('Decompressing binary... 📦')
exec('bzip2', ['-dk', tmp]) exec('bzip2', ['-dk', tmp])
unlinkSync(tmp) unlinkSync(tmp)
w.replaceLn(`Moving to ${INSTALL_DIR} 🚙`) w.replaceLn(`Moving to ${INSTALL_DIR} 🚙`)
exec('chmod', ['+x', extracted]) exec('chmod', ['+x', extracted])
exec('mv', [extracted, INSTALL_DIR + '/restic']) exec('mv', [extracted, INSTALL_DIR + '/restic'])
w.done(`\nFinished! restic is installed under: ${INSTALL_DIR}`.underline + ' 🎉') w.done(
}, `\nFinished! restic is installed under: ${INSTALL_DIR}`.underline + ' 🎉'
uninstall() { )
for (const bin of ['restic', 'autorestic']) },
try { uninstall() {
unlinkSync(INSTALL_DIR + '/' + bin) for (const bin of ['restic', 'autorestic'])
console.log(`Finished! ${bin} was uninstalled`) try {
} catch (e) { unlinkSync(INSTALL_DIR + '/' + bin)
console.log(`${bin} is already uninstalled`.red) console.log(`Finished! ${bin} was uninstalled`)
} } catch (e) {
}, console.log(`${bin} is already uninstalled`.red)
async update() { }
checkIfResticIsAvailable() },
const w = new Writer('Checking for latest restic version... ⏳') async update() {
exec('restic', ['self-update']) checkIfResticIsAvailable()
const w = new Writer('Checking for latest restic version... ⏳')
exec('restic', ['self-update'])
w.replaceLn('Checking for latest autorestic version... ⏳')
const { data: json } = await axios({
method: 'get',
url:
'https://api.github.com/repos/cupcakearmy/autorestic/releases/latest',
responseType: 'json',
})
w.replaceLn('Checking for latest autorestic version... ⏳') if (json.tag_name != VERSION) {
const { data: json } = await axios({ const platformMap: { [key: string]: string } = {
method: 'get', darwin: 'macos',
url: 'https://api.github.com/repos/cupcakearmy/autorestic/releases/latest', }
responseType: 'json',
})
if (json.tag_name != VERSION) { const name = `autorestic_${platformMap[process.platform] ||
const platformMap: { [key: string]: string } = { process.platform}_${process.arch}`
'darwin': 'macos', const dl = json.assets.find((asset: any) => asset.name === name)
}
const name = `autorestic_${platformMap[process.platform] || process.platform}_${process.arch}` const to = INSTALL_DIR + '/autorestic'
const dl = json.assets.find((asset: any) => asset.name === name) w.replaceLn('Downloading binary... 🌎')
await downloadFile(dl.browser_download_url, to)
const to = INSTALL_DIR + '/autorestic' exec('chmod', ['+x', to])
w.replaceLn('Downloading binary... 🌎') }
await downloadFile(dl.browser_download_url, to)
exec('chmod', ['+x', to]) w.done('All up to date! 🚀')
} },
version() {
w.done('All up to date! 🚀') console.log('version'.grey, VERSION)
}, },
} }
export const help = () => { export const help = () => {
console.log('\nAutorestic'.blue + ` - ${VERSION} - Easy Restic CLI Utility` console.log(
+ '\n' '\nAutorestic'.blue +
+ '\nOptions:'.yellow ` - ${VERSION} - Easy Restic CLI Utility` +
+ `\n -c, --config Specify config file. Default: ${CONFIG_FILE}` '\n' +
+ '\n' '\nOptions:'.yellow +
+ '\nCommands:'.yellow `\n -c, --config Specify config file. Default: .autorestic.yml` +
+ '\n check [-b, --backend] [-a, --all] Check backends' '\n' +
+ '\n backup [-l, --location] [-a, --all] Backup all or specified locations' '\nCommands:'.yellow +
+ '\n restore [-l, --location] [-- --target <out dir>] Check backends' '\n check [-b, --backend] [-a, --all] Check backends' +
+ '\n' '\n backup [-l, --location] [-a, --all] Backup all or specified locations' +
+ '\n exec [-b, --backend] [-a, --all] <command> -- [native options] Execute native restic command' '\n restore [-l, --location] [-- --target <out dir>] Restore all or specified locations' +
+ '\n' '\n' +
+ '\n install install restic' '\n exec [-b, --backend] [-a, --all] <command> -- [native options] Execute native restic command' +
+ '\n uninstall uninstall restic' '\n' +
+ '\n update update restic' '\n install install restic' +
+ '\n help Show help' '\n uninstall uninstall restic' +
+ '\n' '\n update update restic' +
+ '\nExamples: '.yellow + 'https://git.io/fjVbg' '\n help Show help' +
+ '\n', '\n' +
) '\nExamples: '.yellow +
'https://git.io/fjVbg' +
'\n'
)
} }
export const error = () => { export const error = () => {
help() help()
console.log(`Invalid Command:`.red.underline, `${process.argv.slice(2).join(' ')}`) console.log(
`Invalid Command:`.red.underline,
`${process.argv.slice(2).join(' ')}`
)
} }
export default handlers export default handlers

View File

@@ -1,70 +1,77 @@
type BackendLocal = { type BackendLocal = {
type: 'local', type: 'local'
key: string, key: string
path: string path: string
} }
type BackendSFTP = { type BackendSFTP = {
type: 'sftp', type: 'sftp'
key: string, key: string
path: string, path: string
password?: string, password?: string
} }
type BackendREST = { type BackendREST = {
type: 'rest', type: 'rest'
key: string, key: string
path: string, path: string
user?: string, user?: string
password?: string password?: string
} }
type BackendS3 = { type BackendS3 = {
type: 's3', type: 's3'
key: string, key: string
path: string, path: string
aws_access_key_id: string, aws_access_key_id: string
aws_secret_access_key: string, aws_secret_access_key: string
} }
type BackendB2 = { type BackendB2 = {
type: 'b2', type: 'b2'
key: string, key: string
path: string, path: string
b2_account_id: string, b2_account_id: string
b2_account_key: string b2_account_key: string
} }
type BackendAzure = { type BackendAzure = {
type: 'azure', type: 'azure'
key: string, key: string
path: string, path: string
azure_account_name: string, azure_account_name: string
azure_account_key: string azure_account_key: string
} }
type BackendGS = { type BackendGS = {
type: 'gs', type: 'gs'
key: string, key: string
path: string, path: string
google_project_id: string, google_project_id: string
google_application_credentials: string google_application_credentials: string
} }
export type Backend = BackendAzure | BackendB2 | BackendGS | BackendLocal | BackendREST | BackendS3 | BackendSFTP export type Backend =
| BackendAzure
| BackendB2
| BackendGS
| BackendLocal
| BackendREST
| BackendS3
| BackendSFTP
export type Backends = { [name: string]: Backend } export type Backends = { [name: string]: Backend }
export type Location = { export type Location = {
from: string, from: string
to: string | string[] to: string | string[]
} }
export type Locations = { [name: string]: Location } export type Locations = { [name: string]: Location }
export type Config = { export type Config = {
locations: Locations locations: Locations
backends: Backends backends: Backends
} }
export type Flags = { [arg: string]: any } export type Flags = { [arg: string]: any }

View File

@@ -3,61 +3,75 @@ import { spawnSync, SpawnSyncOptions } from 'child_process'
import { randomBytes } from 'crypto' import { randomBytes } from 'crypto'
import { createWriteStream } from 'fs' import { createWriteStream } from 'fs'
export const exec = (command: string, args: string[], { env, ...rest }: SpawnSyncOptions = {}) => { export const exec = (
command: string,
args: string[],
{ env, ...rest }: SpawnSyncOptions = {}
) => {
const cmd = spawnSync(command, args, {
...rest,
env: {
...process.env,
...env,
},
})
const cmd = spawnSync(command, args, { const out = cmd.stdout && cmd.stdout.toString().trim()
...rest, const err = cmd.stderr && cmd.stderr.toString().trim()
env: {
...process.env,
...env,
},
})
const out = cmd.stdout && cmd.stdout.toString().trim() return { out, err }
const err = cmd.stderr && cmd.stderr.toString().trim()
return { out, err }
} }
export const checkIfResticIsAvailable = () => checkIfCommandIsAvailable( export const checkIfResticIsAvailable = () =>
'restic', checkIfCommandIsAvailable(
'Restic is not installed'.red + ' https://restic.readthedocs.io/en/latest/020_installation.html#stable-releases', 'restic',
) 'Restic is not installed'.red +
' https://restic.readthedocs.io/en/latest/020_installation.html#stable-releases'
)
export const checkIfCommandIsAvailable = (cmd: string, errorMsg?: string) => { export const checkIfCommandIsAvailable = (cmd: string, errorMsg?: string) => {
if (require('child_process').spawnSync(cmd).error) if (require('child_process').spawnSync(cmd).error)
throw new Error(errorMsg ? errorMsg : `"${errorMsg}" is not installed`.red) throw new Error(errorMsg ? errorMsg : `"${errorMsg}" is not installed`.red)
} }
export const makeObjectKeysLowercase = (object: Object): any => export const makeObjectKeysLowercase = (object: Object): any =>
Object.fromEntries( Object.fromEntries(
Object.entries(object) Object.entries(object).map(([key, value]) => [key.toLowerCase(), value])
.map(([key, value]) => [key.toLowerCase(), value]), )
)
export function rand(length = 32): string { export function rand(length = 32): string {
return randomBytes(length / 2).toString('hex') return randomBytes(length / 2).toString('hex')
} }
export const singleToArray = <T>(singleOrArray: T | T[]): T[] => Array.isArray(singleOrArray) ? singleOrArray : [singleOrArray] 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): { [key: string]: T } => Object.fromEntries(Object.entries(obj).filter(filter)) export const filterObject = <T>(
obj: { [key: string]: T },
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[]) => filterObject(obj, ([key]) => keys.includes(key)) export const filterObjectByKey = <T>(
obj: { [key: string]: T },
keys: string[]
) => filterObject(obj, ([key]) => keys.includes(key))
export const downloadFile = async (url: string, to: string) => new Promise<void>(async res => { export const downloadFile = async (url: string, to: string) =>
const { data: file } = await axios({ new Promise<void>(async res => {
method: 'get', const { data: file } = await axios({
url: url, method: 'get',
responseType: 'stream', url: url,
}) responseType: 'stream',
})
const stream = createWriteStream(to) const stream = createWriteStream(to)
const writer = file.pipe(stream) const writer = file.pipe(stream)
writer.on('close', () => { writer.on('close', () => {
stream.close() stream.close()
res() res()
}) })
}) })
export const ConfigError = new Error('Config file not found')