@hyperspace/cli
Version:
A CLI for the hyper:// space network.
214 lines (185 loc) • 6.78 kB
JavaScript
import { join as joinPath } from 'path'
import chokidar from 'chokidar'
import mkdirp from 'mkdirp'
import dft from 'diff-file-tree'
import chalk from 'chalk'
import debounce from 'p-debounce'
import yesno from 'yesno'
import { getMirroringClient } from '../../hyper/index.js'
import * as HyperStruct from '../../hyper/struct.js'
import { HyperStructInfoTracker } from '../../hyper/info-tracker.js'
import { statusLogger } from '../../status-logger.js'
import { parseHyperUrl } from '../../urls.js'
const SYNC_INTERVAL = 1000
const FULL_USAGE = `
Options:
--no-add - Don't include additions to the target location.
--no-overwrite - Don't include overwrites to the target location.
--no-delete - Don't include deletions to the target location.
-w/--watch/--live - Continuously sync changes.
-y/--yes - Do not ask for confirmation
Examples:
hyp drive sync hyper://1234..af/ ./local-folder
hyp drive sync ./local-folder hyper://1234..af/remote-folder --no-delete
hyp drive sync hyper://1234..af/ hyper://fedc..21/
hyp drive sync hyper://1234...af/
hyp drive sync ./local-folder
`
export default {
name: 'drive sync',
description: 'Continuously sync changes between two folders in your local filesystem or in hyperdrives.',
usage: {
simple: '{source_path_or_url} [target_path_or_url]',
full: FULL_USAGE
},
options: [
{name: 'add', default: true, boolean: true},
{name: 'overwrite', default: true, boolean: true},
{name: 'delete', default: true, boolean: true},
{name: 'live', default: false, boolean: true},
{name: 'watch', abbr: 'w', default: false, boolean: true},
{name: 'yes', abbr: 'y', default: false, boolean: true}
],
command: async function (args) {
if (!args._[0]) throw new Error('A source path or URL is required')
var live = args.watch || args.live
var leftArgs = await parseArgs(args._[0])
var rightArgs = args._[1] ? await parseArgs(args._[1]) : await createTarget(leftArgs)
if (!args._[1]) console.error('Creating new hyperdrive...')
console.error(chalk.bold(`Source: ${leftArgs.raw}`))
console.error(chalk.bold(`Target: ${rightArgs.raw}`))
var ok = true
if(!args.yes){
ok = await yesno({
question: `Begin sync? [y/N]`,
defaultValue: false
})
}
if (!rightArgs.isHyper) mkdirp.sync(rightArgs.path)
if (!ok) process.exit(0)
console.error(live ? 'Live syncing (Ctrl+c to exit)...' : 'Syncing...')
var statusLines = ['']
var statusLog = statusLogger(statusLines)
if (leftArgs.isHyper) await setupTracker(leftArgs.fs.key.toString('hex'), statusLines, statusLog)
if (rightArgs.isHyper) await setupTracker(rightArgs.fs.key.toString('hex'), statusLines, statusLog)
statusLog.print(statusLines)
var left = toDftParam(leftArgs)
var right = toDftParam(rightArgs)
await sync(left, right, args, {statusLines, statusLog})
if (!live) return process.exit(0)
const watcher = watch(left, debounce(() => sync(left, right, args, {statusLines, statusLog}), SYNC_INTERVAL))
let exiting = false
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
async function cleanup () {
if (exiting) return
console.error('Exiting...')
exiting = true
if (watcher.close) await watcher.close()
else if (watcher.destroy) watcher.destroy()
process.exit(0)
}
}
}
function watch (source, onchange) {
if (source.fs) return source.fs.watch(source.path, onchange)
const watcher = chokidar.watch(source, {
persistent: true,
ignoreInitial: true,
awaitWriteFinished: true
})
watcher.on('add', onchange)
watcher.on('change', onchange)
watcher.on('unlink', onchange)
return watcher
}
async function sync (left, right, opts = {}, {statusLines, statusLog}) {
var si = statusLines.length - 1
statusLines[si] = 'Comparing...'
statusLog.print(statusLines)
var diff = await dft.diff(left, right, {
compareContent: false
})
if (!opts.add || !opts.overwrite || !opts.delete) {
diff = diff.filter(item => {
if (item.change === 'add') return opts.add
if (item.change === 'mod') return opts.overwrite
if (item.change === 'del') return opts.delete
return false
})
}
if (diff.length !== 0) {
await new Promise(resolve => {
var progress = 0
var total = diff.length
var s = dft.applyRightStream(left, right, diff)
s.on('data', action => {
progress++
let pct = Math.round(progress/total * 100)
let op = ({
'rmdir': 'Deleted',
'unlink': 'Deleted',
'writeFile': 'Wrote',
'mkdir': 'Created'
})[action.op]
statusLines[si] = `${pct}% - ${op} ${action.path}`
statusLog.print(statusLines)
})
s.on('error', err => {
console.error(err.toString())
process.exit(1)
})
s.on('end', resolve)
})
}
statusLines[si] = 'Synced'
statusLog.print(statusLines)
}
function isUrl (str) {
return str.startsWith('hyper://') || /^[0-9a-f]{64}/.test(str)
}
async function parseArgs (str) {
if (isUrl(str)) {
let urlp = parseHyperUrl(str)
let drive = await HyperStruct.get(urlp.hostname, {expect: 'hyperdrive'})
return { fs: drive.api, path: urlp.pathname, url: urlp, raw: str, isHyper: true }
}
return { path: ''+str, raw: str, isHyper: false }
}
async function createTarget (source) {
if (source.url) {
// If the source is a drive, create a new output directory with the drive key's name.
let target = joinPath(process.cwd(), source.url.hostname)
return {path: target, raw: target}
}
// If the source is a local directory, create a new drive to copy into.
let drive = await HyperStruct.create('hyperdrive')
await getMirroringClient().mirror(drive.key, drive.type)
return {fs: drive.api, path: '/', raw: drive.url + '/'}
}
function toDftParam ({fs, path}) {
return fs ? {fs, path} : path
}
async function setupTracker (key, statusLines, statusLog) {
var statusLineIndex = statusLines.length - 1
statusLines[statusLineIndex] = `${chalk.bold(`${key.slice(0, 6)}..${key.slice(-2)}`)}: Loading...`
statusLines.push('')
var tracker = new HyperStructInfoTracker(key)
// periodically update stdout with the status-line
const output = () => {
statusLines[statusLineIndex] = tracker.genStatusLine()
statusLog.print(statusLines)
}
setInterval(output, 1e3).unref()
// periodically calculate the size of the hyper structure
var firstRun = true
const updateState = async () => {
await tracker.fetchState().catch(console.error)
if (firstRun) {
output()
firstRun = false
}
setTimeout(updateState, 1e3).unref()
}
await updateState()
}