diff --git a/README.md b/README.md index d7fb4a5..a4adcf8 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,10 @@ diskpush fleet upgrade --on tag:production --sudo # Or run anything, anywhere diskpush fleet run "systemctl reload nginx" --on 'web-*' --sudo + +# Describe a folder of photos, and sort them into folders by what they show +diskpush mediaanalyzer login +diskpush mediaanalyzer analyze ~/Pictures/2024 --sort ``` ## Safety @@ -120,8 +124,12 @@ to make that flag hard to trigger by accident. through verbatim. - **Host keys are verified.** A changed host key blocks the connection. There is no global setting to turn that off. -- **No credentials on disk.** The local database holds no passwords or - passphrases. +- **No SSH credentials on disk.** The local database holds no passwords or + passphrases. A plugin's sign-in (such as MediaAnalyzer's token) is kept in + it, which is why DiskPush keeps that file owner-only (`0600`). +- **Plugins stay out of the renderer.** Plugin code runs in the CLI or the + desktop's main process, and the renderer can only name a plugin action and + entries inside a local directory. See [docs/plugins.md](docs/plugins.md). ## Documentation @@ -136,6 +144,7 @@ to make that flag hard to trigger by accident. | [docs/file-browser.md](docs/file-browser.md) | Why browsing is SFTP and transfers are rsync | | [docs/profiles.md](docs/profiles.md) | Saved, repeatable directory pairs | | [docs/fleet.md](docs/fleet.md) | Running one command, or an upgrade, across many servers | +| [docs/plugins.md](docs/plugins.md) | Plugins, MediaAnalyzer, writing one, and the security model | | [docs/security.md](docs/security.md) | Threat model and the decisions that follow from it | | [docs/architecture.md](docs/architecture.md) | Packages, processes and boundaries | | [docs/troubleshooting.md](docs/troubleshooting.md) | What the errors mean | @@ -152,6 +161,8 @@ packages/rsync-core the transfer engine, with no Electron in it packages/ssh-core SSH sessions, SFTP browsing, host keys, preflight packages/fleet-core one command across many servers, with no Electron in it packages/database the local store shared by desktop and CLI +packages/plugin-api the plugin contract, registry and external loader +packages/plugin-mediaanalyzer the built-in MediaAnalyzer plugin ``` `rsync-core` deliberately has no dependency on Electron or on the CLI, so the diff --git a/apps/cli/package.json b/apps/cli/package.json index 2fba316..dfc64b5 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -18,6 +18,8 @@ "dependencies": { "@diskpush/database": "workspace:*", "@diskpush/fleet-core": "workspace:*", + "@diskpush/plugin-api": "workspace:*", + "@diskpush/plugin-mediaanalyzer": "workspace:*", "@diskpush/rsync-core": "workspace:*", "@diskpush/schemas": "workspace:*", "@diskpush/ssh-core": "workspace:*", diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index c23a600..e35261c 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -14,11 +14,51 @@ import { runTransfer, TRANSFER_ALIASES } from './commands/transfer.js' import { EXIT } from './exit-codes.js' import { HELP, VERSION } from './help.js' import { Output } from './output.js' -import { ArgvError, hasFlag, looksLikeEndpoint, parseArgv } from './parse-argv.js' +import { ArgvError, hasFlag, isKnownCommand, looksLikeEndpoint, parseArgv } from './parse-argv.js' import { autoUpdate, reexec } from './self-update.js' +import { findPluginCall, runPluginCommand, runPlugins } from './commands/plugins.js' +import { pluginHelp } from './help.js' +import { loadPlugins } from './plugins.js' +import { PluginError } from '@diskpush/plugin-api' import { existsSync } from 'node:fs' +/** A first word that is neither a command nor a path might be a plugin: `diskpush mediaanalyzer login`. */ +function mightBePlugin(argv: readonly string[]): boolean { + const head = argv.find((token) => !token.startsWith('-')) + return head !== undefined && /^[a-z][a-z0-9-]*$/.test(head) && !isKnownCommand(head) && !looksLikeEndpoint(head, existsSync) +} + +async function runPlugin(argv: readonly string[]): Promise { + const store = await DiskPushStore.open() + try { + const { registry, failures } = await loadPlugins(store) + const call = findPluginCall(argv, registry) + if (!call) return null + const output = new Output({ + json: argv.includes('--json'), + quiet: argv.includes('--quiet') || argv.includes('-q'), + progress: !argv.includes('--no-progress'), + }) + for (const { name, error } of failures) output.warn(`plugin ${name} did not load: ${error}`) + if (await autoUpdate(call.pluginId, output) === 'updated') reexec() + return await runPluginCommand(registry, call.pluginId, call.args, output) + } catch (error) { + if (error instanceof PluginError) { + process.stderr.write(`${error.message}\n`) + return EXIT.configuration + } + throw error + } finally { + await store.close() + } +} + async function main(argv: readonly string[]): Promise { + if (mightBePlugin(argv)) { + const code = await runPlugin(argv) + if (code !== null) return code + } + let parsed try { parsed = parseArgv(argv) @@ -34,7 +74,7 @@ async function main(argv: readonly string[]): Promise { }) if (hasFlag(parsed, '--help') || parsed.command === 'help') { - process.stdout.write(HELP) + process.stdout.write(HELP + pluginHelp((await loadPlugins(null)).registry.all())) return EXIT.ok } if (hasFlag(parsed, '--version') || parsed.command === 'version') { @@ -99,6 +139,9 @@ async function main(argv: readonly string[]): Promise { return await runFleetCommand(parsed, store, output) case 'ls': return await runLs(parsed, store, output) + case 'plugins': + case 'plugin': + return await runPlugins(parsed, store, output) default: output.error(`Unknown command ${JSON.stringify(command)}. Run \`diskpush --help\`.`) return EXIT.usage @@ -111,6 +154,7 @@ async function main(argv: readonly string[]): Promise { function describeError(error: unknown): { message: string; code: number } { if (error instanceof ArgvError) return { message: error.message, code: EXIT.usage } if (error instanceof EndpointParseError) return { message: error.message, code: EXIT.usage } + if (error instanceof PluginError) return { message: error.message, code: EXIT.configuration } if (error instanceof ZodError) { const first = error.issues[0] return { diff --git a/apps/cli/src/commands/plugins.test.ts b/apps/cli/src/commands/plugins.test.ts new file mode 100644 index 0000000..1688c62 --- /dev/null +++ b/apps/cli/src/commands/plugins.test.ts @@ -0,0 +1,147 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DiskPushStore } from '@diskpush/database' +import { PluginRegistry, definePlugin, memoryBackend, type CommandContext } from '@diskpush/plugin-api' +import { Output } from '../output.js' +import { parseArgv } from '../parse-argv.js' +import { pluginHelp } from '../help.js' +import { assignKeys, loadPlugins } from '../plugins.js' +import { findPluginCall, runPluginCommand, runPlugins, stripGlobalFlags } from './plugins.js' + +const seen: { args: string[]; ctx: CommandContext }[] = [] + +const echo = definePlugin({ + id: 'echo', + name: 'Echo', + version: '1.0.0', + description: 'repeats itself', + commands: [ + { + name: 'say', + summary: 'print the arguments', + usage: 'say WORDS... [--loud]', + async run(args, ctx) { + seen.push({ args, ctx }) + await ctx.settings.set('last', args) + if (ctx.json) ctx.printJson({ said: args }) + else ctx.print(args.join(' ')) + return args.includes('--fail') ? 3 : 0 + }, + }, + ], +}) + +let stdout: string[] +let stderr: string[] + +beforeEach(() => { + seen.length = 0 + stdout = [] + stderr = [] + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout.push(String(chunk)) + return true + }) + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr.push(String(chunk)) + return true + }) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() +}) + +const output = (json = false) => new Output({ json, quiet: false, progress: false }) + +function registry() { + const r = new PluginRegistry(memoryBackend()) + r.register(echo) + return r +} + +describe('dispatch to a plugin', () => { + it('finds `diskpush ...` on raw argv, flags before the id included', () => { + const r = registry() + expect(findPluginCall(['echo', 'say', 'hi'], r)).toEqual({ pluginId: 'echo', args: ['say', 'hi'] }) + expect(findPluginCall(['--json', 'echo', 'say', '--timeout'], r)).toEqual({ + pluginId: 'echo', + args: ['--json', 'say', '--timeout'], + }) + expect(findPluginCall(['sync', './a', './b'], r)).toBeNull() + expect(findPluginCall(['--help'], r)).toBeNull() + expect(stripGlobalFlags(['--json', 'say', '-q', 'x'])).toEqual(['say', 'x']) + }) + + it('runs the command with its own arguments and a context bound to the plugin', async () => { + const r = registry() + const code = await runPluginCommand(r, 'echo', ['say', 'hello', 'world', '--loud'], output()) + expect(code).toBe(0) + expect(seen[0]!.args).toEqual(['hello', 'world', '--loud']) + expect(seen[0]!.ctx.surface).toBe('cli') + expect(stdout.join('')).toBe('hello world --loud\n') + expect(await r.backend.getSetting('plugin:echo:last', null)).toEqual(['hello', 'world', '--loud']) + }) + + it('passes the exit code through, and --json reaches the plugin as ctx.json', async () => { + const r = registry() + expect(await runPluginCommand(r, 'echo', ['--json', 'say', 'x', '--fail'], output(true))).toBe(3) + expect(seen[0]!.ctx.json).toBe(true) + expect(JSON.parse(stdout.join(''))).toEqual({ said: ['x', '--fail'] }) + }) + + it('prints the plugin help for no command, and refuses an unknown one', async () => { + const r = registry() + expect(await runPluginCommand(r, 'echo', [], output())).toBe(64) + expect(stdout.join('')).toContain('diskpush echo say WORDS... [--loud]') + expect(await runPluginCommand(r, 'echo', ['shout'], output())).toBe(64) + expect(stderr.join('')).toContain('Echo has no command "shout"') + }) + + it('will not run a disabled plugin', async () => { + const r = registry() + await r.disable('echo') + await expect(runPluginCommand(r, 'echo', ['say'], output())).rejects.toThrow(/disabled.*diskpush plugins enable echo/) + }) + + it('lists plugin commands in the help text', () => { + expect(pluginHelp([echo])).toContain('PLUGIN COMMANDS\n echo say WORDS... [--loud] print the arguments') + expect(pluginHelp([])).toBe('') + }) +}) + +describe('diskpush plugins', () => { + it('lists the built-in plugins, and enable/disable persist in the store', async () => { + vi.stubEnv('DISKPUSH_HOME', mkdtempSync(join(tmpdir(), 'dp-home-'))) + const store = await DiskPushStore.open({ path: ':memory:' }) + try { + expect(await runPlugins(parseArgv(['plugins']), store, output())).toBe(0) + expect(stdout.join('')).toMatch(/mediaanalyzer\s+enabled\s+builtin/) + expect(stdout.join('')).toContain('diskpush mediaanalyzer analyze DIR [--sort]') + + expect(await runPlugins(parseArgv(['plugins', 'disable', 'mediaanalyzer']), store, output())).toBe(0) + expect(await store.getSetting('plugins.disabled', [])).toEqual(['mediaanalyzer']) + const { registry: again } = await loadPlugins(store) + expect(await again.isEnabled('mediaanalyzer')).toBe(false) + + expect(await runPlugins(parseArgv(['plugins', 'enable', 'mediaanalyzer']), store, output())).toBe(0) + expect(await store.getSetting('plugins.disabled', [])).toEqual([]) + + expect(await runPlugins(parseArgv(['plugins', 'enable', 'nope']), store, output())).toBe(65) + expect(await runPlugins(parseArgv(['plugins', 'remove', 'mediaanalyzer']), store, output())).toBe(66) + } finally { + await store.close() + } + }) +}) + +describe('action keys', () => { + it('keeps each hinted letter unless the menu or an earlier action has it', () => { + const choice = (label: string) => ({ pluginId: 'p', pluginName: 'P', actionId: label, label, description: '' }) + const keys = assignKeys([choice('a'), choice('b'), choice('c'), choice('d')], ['m', 'm', 'j', undefined]).map((c) => c.key) + expect(keys).toEqual(['m', null, null, null]) + }) +}) diff --git a/apps/cli/src/commands/plugins.ts b/apps/cli/src/commands/plugins.ts new file mode 100644 index 0000000..6c0cc21 --- /dev/null +++ b/apps/cli/src/commands/plugins.ts @@ -0,0 +1,224 @@ +import { createInterface } from 'node:readline/promises' +import type { DiskPushStore } from '@diskpush/database' +import { diskpushHome } from '@diskpush/database' +import { + addExternalPlugin, + pluginsDirectory, + removeExternalPlugin, + type CommandContext, + type DiskpushPlugin, + type PluginRegistry, + type ProgressSink, +} from '@diskpush/plugin-api' +import { EXIT } from '../exit-codes.js' +import { failure, type Output } from '../output.js' +import { type ParsedArgv } from '../parse-argv.js' +import { loadPlugins, openExternal } from '../plugins.js' + +/** + * `diskpush plugins` — what is installed, and turning it on and off. + * + * diskpush plugins list, with each plugin's commands + * diskpush plugins enable ID + * diskpush plugins disable ID + * diskpush plugins add NPM-PACKAGE install an external plugin (runs with your privileges) + * diskpush plugins remove ID + */ +export async function runPlugins(parsed: ParsedArgv, store: DiskPushStore, output: Output): Promise { + const [sub = 'list', target] = parsed.positionals + const { registry, failures } = await loadPlugins(store) + + switch (sub) { + case 'list': + case 'ls': { + const list = await registry.list() + if (output.isJson) { + output.json({ + plugins: list.map(({ plugin, source, enabled, location }) => ({ + id: plugin.id, + name: plugin.name, + version: plugin.version, + description: plugin.description, + source, + enabled, + ...(location ? { location } : {}), + commands: (plugin.commands ?? []).map((command) => command.name), + actions: (plugin.actions ?? []).map((action) => action.id), + })), + failures, + }) + return EXIT.ok + } + for (const { plugin, source, enabled } of list) { + output.line(`${plugin.id.padEnd(16)} ${enabled ? 'enabled ' : 'disabled'} ${source.padEnd(8)} ${plugin.name} ${plugin.version}`) + output.line(`${''.padEnd(16)} ${plugin.description}`) + for (const command of plugin.commands ?? []) output.line(`${''.padEnd(18)}diskpush ${plugin.id} ${command.usage}`) + } + for (const { name, error } of failures) output.warn(`could not load ${name}: ${error}`) + return EXIT.ok + } + case 'enable': + case 'disable': { + if (!target) return failure(output, `usage: diskpush plugins ${sub} ID`, EXIT.usage) + if (!registry.has(target)) return failure(output, `No plugin called "${target}". See: diskpush plugins`, EXIT.configuration) + await registry.setEnabled(target, sub === 'enable') + if (output.isJson) output.json({ id: target, enabled: sub === 'enable' }) + else output.line(`${target} ${sub}d.`) + return EXIT.ok + } + case 'add': + case 'install': { + if (!target) return failure(output, 'usage: diskpush plugins add NPM-PACKAGE', EXIT.usage) + output.warn( + `A plugin runs inside DiskPush with your full privileges: it can read, change and send any file you can.\n` + + `Only add one you would run as a program. Installing ${target}…`, + ) + const added = await addExternalPlugin(pluginsDirectory(diskpushHome()), target) + if (output.isJson) output.json({ package: added.name, id: added.plugin.id }) + else output.line(`Added ${added.plugin.name} (${added.plugin.id}) from ${added.name}.`) + return EXIT.ok + } + case 'remove': + case 'uninstall': { + if (!target) return failure(output, 'usage: diskpush plugins remove ID', EXIT.usage) + const info = (await registry.list()).find((entry) => entry.plugin.id === target) + if (info?.source === 'builtin') { + return failure(output, `${target} is built in; turn it off with: diskpush plugins disable ${target}`, EXIT.refused) + } + // By plugin id when it loaded, else by the package name it was added as. + const packageName = info?.location?.split(/node_modules[\\/]/).at(-1)?.replaceAll('\\', '/') ?? target + await removeExternalPlugin(pluginsDirectory(diskpushHome()), packageName) + output.line(`Removed ${packageName}.`) + return EXIT.ok + } + default: + return failure(output, `Unknown plugins command "${sub}". Use list, enable, disable, add or remove.`, EXIT.usage) + } +} + +/** A plugin's own help: its commands, one per line. */ +export function pluginUsage(plugin: DiskpushPlugin): string { + const lines = [`${plugin.name} ${plugin.version} - ${plugin.description}`, '', 'COMMANDS'] + const width = Math.max(0, ...(plugin.commands ?? []).map((command) => command.usage.length)) + for (const command of plugin.commands ?? []) { + lines.push(` diskpush ${plugin.id} ${command.usage.padEnd(width)} ${command.summary}`) + } + return `${lines.join('\n')}\n` +} + +/** Draws plugin progress on the one status line the CLI has. */ +export function outputProgress(output: Output): ProgressSink & { finish(): void } { + let total: number | undefined + let last = '' + return { + start(count) { + total = count + }, + update({ done, total: newTotal, message, currentFile }) { + if (newTotal !== undefined) total = newTotal + const counter = done !== undefined && total ? `[${done}/${total}] ` : '' + last = message ?? last + output.status(`${counter}${currentFile ?? last}`) + }, + log(level, message) { + output.clearStatus() + output.warn(level === 'info' ? message : `${level}: ${message}`) + }, + finish() { + output.clearStatus() + }, + } +} + +async function ask(question: string): Promise { + if (!process.stdin.isTTY) return null + const rl = createInterface({ input: process.stdin, output: process.stderr }) + try { + return await rl.question(question) + } finally { + rl.close() + } +} + +/** + * Whether argv is `diskpush [flags] ...`, and if so which plugin + * and what follows it. Decided on raw argv, before the CLI's own parser, so a + * plugin's flags are the plugin's and never trip DiskPush's value flags. + */ +export function findPluginCall( + argv: readonly string[], + registry: Pick, +): { pluginId: string; args: string[] } | null { + const at = argv.findIndex((token) => !token.startsWith('-')) + if (at === -1) return null + const head = argv[at]! + if (!registry.has(head)) return null + return { pluginId: head, args: [...argv.slice(0, at), ...argv.slice(at + 1)] } +} + +/** Global flags the CLI owns; everything else belongs to the plugin. */ +const GLOBAL_FLAGS = new Set(['--json', '--quiet', '-q', '--no-progress']) + +export function stripGlobalFlags(args: readonly string[]): string[] { + return args.filter((arg) => !GLOBAL_FLAGS.has(arg)) +} + +/** + * `diskpush [args...]`. + * + * Ctrl+C cancels through the context's signal, so a plugin can stop cleanly + * and save its state; a second Ctrl+C exits at once. + */ +export async function runPluginCommand( + registry: PluginRegistry, + pluginId: string, + argv: readonly string[], + output: Output, +): Promise { + const plugin = await registry.require(pluginId) + const args = stripGlobalFlags(argv) + const [name, ...rest] = args + if (!name || name === 'help' || name === '--help' || name === '-h') { + process.stdout.write(pluginUsage(plugin)) + return name ? EXIT.ok : EXIT.usage + } + const { command } = await registry.command(pluginId, name) + if (!command) { + output.error(`${plugin.name} has no command "${name}".\n`) + process.stderr.write(pluginUsage(plugin)) + return EXIT.usage + } + + const controller = new AbortController() + let interrupts = 0 + const onInterrupt = () => { + interrupts += 1 + if (interrupts > 1) process.exit(130) + output.clearStatus() + output.warn('Cancelling… (Ctrl+C again to quit now)') + controller.abort() + } + process.on('SIGINT', onInterrupt) + const progress = outputProgress(output) + const ctx: CommandContext = { + settings: registry.settingsFor(pluginId), + secrets: registry.secretsFor(pluginId), + progress, + openUrl: (url) => openExternal(url), + signal: controller.signal, + surface: 'cli', + env: process.env, + cwd: process.cwd(), + json: output.isJson, + print: (text) => output.line(text), + warn: (text) => output.warn(text), + printJson: (value) => output.json(value), + prompt: ask, + } + try { + return await command.run(rest, ctx) + } finally { + progress.finish() + process.off('SIGINT', onInterrupt) + } +} diff --git a/apps/cli/src/commands/tui.ts b/apps/cli/src/commands/tui.ts index a8b768f..eecb116 100644 --- a/apps/cli/src/commands/tui.ts +++ b/apps/cli/src/commands/tui.ts @@ -5,7 +5,8 @@ import { failure, type Output } from '../output.js' import { type ParsedArgv } from '../parse-argv.js' import { resolveEndpoint, sshConfigHosts } from '../resolve.js' import { blankPane, buildEndpointChoices, defaultLocalPath, Tui } from '../tui/app.js' -import { enableTmuxPassthrough, runInherited } from '../tui/launch.js' +import { enableTmuxPassthrough, runInherited, systemLauncher } from '../tui/launch.js' +import { loadPlugins, registryHost } from '../plugins.js' /** * `diskpush tui` — the two-pane browser, in a terminal. @@ -40,7 +41,9 @@ export async function runTui(parsed: ParsedArgv, store: DiskPushStore, output: O } const choices = buildEndpointChoices(await store.listConnections(), sshConfigHosts(), defaultLocalPath()) - const tui = new Tui(panes[0]!, panes[1]!, choices) + // Plugins that failed to load are left out of `a`; `diskpush plugins` says why. + const { registry } = await loadPlugins(store) + const tui = new Tui(panes[0]!, panes[1]!, choices, systemLauncher(), undefined, registryHost(registry)) // Images ride on escape sequences tmux drops by default. enableTmuxPassthrough() diff --git a/apps/cli/src/help.ts b/apps/cli/src/help.ts index 6b28cdc..9c5830d 100644 --- a/apps/cli/src/help.ts +++ b/apps/cli/src/help.ts @@ -60,6 +60,14 @@ COMMANDS uninstall remove DiskPush, keeping your connections and profiles [alias: remove] + plugins installed plugins and their commands + plugins enable|disable ID + plugins add NPM-PACKAGE install an external plugin (it runs with your + privileges; see docs/plugins.md) + plugins remove ID + COMMAND run a plugin's command, e.g. + diskpush mediaanalyzer analyze ./photos --sort + DEFAULTS Every transfer runs with archive metadata, resumable partial files, incremental skipping of unchanged files, and no destination deletes: @@ -140,3 +148,16 @@ EXAMPLES diskpush fleet run "systemctl reload nginx" --on web-* --sudo diskpush fleet script ./rotate-keys.sh --on all '!db-01' ` + +/** The plugin commands, appended to the help text. */ +export function pluginHelp(plugins: readonly { id: string; commands?: readonly { usage: string; summary: string }[] }[]): string { + const rows = plugins.flatMap((plugin) => + (plugin.commands ?? []).map((command) => [`${plugin.id} ${command.usage}`, command.summary] as const), + ) + if (rows.length === 0) return '' + const width = Math.min(34, Math.max(...rows.map(([usage]) => usage.length))) + const lines = rows.map(([usage, summary]) => + usage.length > width ? ` ${usage}\n ${''.padEnd(width)} ${summary}` : ` ${usage.padEnd(width)} ${summary}`, + ) + return `\nPLUGIN COMMANDS\n${lines.join('\n')}\n` +} diff --git a/apps/cli/src/parse-argv.ts b/apps/cli/src/parse-argv.ts index f31497a..4e4b212 100644 --- a/apps/cli/src/parse-argv.ts +++ b/apps/cli/src/parse-argv.ts @@ -101,6 +101,8 @@ const KNOWN_COMMANDS = new Set([ 'desktop', 'tui', 'fleet', + 'plugins', + 'plugin', 'help', 'version', ]) diff --git a/apps/cli/src/plugins.ts b/apps/cli/src/plugins.ts new file mode 100644 index 0000000..c3edd67 --- /dev/null +++ b/apps/cli/src/plugins.ts @@ -0,0 +1,130 @@ +/** + * Plugins, as the CLI and the TUI host them. + * + * Built-in plugins are imported here, so they ship inside the CLI bundle with + * nothing to install. External ones are loaded from `/plugins` + * (see @diskpush/plugin-api's external.ts); they run in this process, with + * the user's privileges, exactly like the CLI itself. + */ +import { spawn } from 'node:child_process' +import { diskpushHome } from '@diskpush/database' +import { + PluginRegistry, + loadExternalPlugins, + memoryBackend, + pluginsDirectory, + runAction, + type ActionResult, + type EntryRef, + type LoadFailure, + type ProgressSink, + type SettingsBackend, +} from '@diskpush/plugin-api' +import { mediaAnalyzerPlugin } from '@diskpush/plugin-mediaanalyzer' + +/** Plugins that ship with DiskPush. */ +export const BUILTIN_PLUGINS = [mediaAnalyzerPlugin] + +export type LoadedRegistry = { registry: PluginRegistry; failures: LoadFailure[] } + +/** + * Every plugin this process can run. `backend` is the store; without one (the + * help text) enabled state is not known and everything reads as enabled. + */ +export async function loadPlugins( + backend: SettingsBackend | null, + options: { external?: boolean; env?: NodeJS.ProcessEnv } = {}, +): Promise { + const registry = new PluginRegistry(backend ?? memoryBackend()) + for (const plugin of BUILTIN_PLUGINS) registry.register(plugin, 'builtin') + const failures = + options.external === false ? [] : await loadExternalPlugins(registry, pluginsDirectory(diskpushHome(options.env))) + return { registry, failures } +} + +/** + * Opens a URL in the browser. http(s) only: a plugin must not be able to + * hand the desktop opener a file path or a custom scheme. + */ +export async function openExternal(url: string, platform: NodeJS.Platform = process.platform): Promise { + if (!/^https?:\/\//i.test(url)) throw new Error('Only http and https URLs can be opened.') + const argv = + platform === 'darwin' ? ['open', url] : platform === 'win32' ? ['cmd', '/c', 'start', '""', url] : ['xdg-open', url] + await new Promise((resolve) => { + const child = spawn(argv[0]!, argv.slice(1), { detached: true, stdio: 'ignore' }) + // No opener is not fatal: every caller prints the URL too. + child.on('error', () => resolve()) + child.on('spawn', () => { + child.unref() + resolve() + }) + }) +} + +/** One action a menu can offer, flattened for drawing. */ +export type ActionChoice = { + pluginId: string + pluginName: string + actionId: string + label: string + description: string + key: string | null +} + +/** What the TUI needs from plugins, so the TUI can be tested with a fake. */ +export interface PluginHost { + actionsFor(dir: string, entries: readonly EntryRef[]): Promise + run( + choice: ActionChoice, + dir: string, + names: readonly string[], + progress: ProgressSink, + signal: AbortSignal, + ): Promise +} + +/** The keys the actions menu keeps for itself. */ +const MENU_KEYS = new Set(['j', 'k', 'q']) + +/** + * The menu's letters: each action's `tuiKey` hint, when it is free. A clash + * with the menu's own keys or an earlier action loses its letter, not its row. + */ +export function assignKeys(choices: Omit[], hints: (string | undefined)[]): ActionChoice[] { + const used = new Set(MENU_KEYS) + return choices.map((choice, index) => { + const hint = hints[index] + const key = hint && !used.has(hint) ? hint : null + if (key) used.add(key) + return { ...choice, key } + }) +} + +export function registryHost(registry: PluginRegistry, env: NodeJS.ProcessEnv = process.env): PluginHost { + return { + async actionsFor(dir, entries) { + const matches = await registry.actionsFor(entries, dir) + return assignKeys( + matches.map(({ plugin, action }) => ({ + pluginId: plugin.id, + pluginName: plugin.name, + actionId: action.id, + label: action.label, + description: action.description ?? '', + })), + matches.map(({ action }) => action.tuiKey), + ) + }, + run(choice, dir, names, progress, signal) { + return runAction(registry, choice.pluginId, choice.actionId, { + dir, + names, + progress, + signal, + surface: 'tui', + env, + openUrl: (url) => openExternal(url), + }) + }, + } +} diff --git a/apps/cli/src/tui/actions.test.ts b/apps/cli/src/tui/actions.test.ts new file mode 100644 index 0000000..4579310 --- /dev/null +++ b/apps/cli/src/tui/actions.test.ts @@ -0,0 +1,253 @@ +/** + * Plugin actions in the TUI: the `a` menu and the job panel. + * + * Frames are asserted as rendered text, and the keyboard and the mouse are + * driven through the real `Tui` with a fake plugin host, so these are the + * code paths a keystroke or a click takes in a terminal. + */ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { renderToScreen, renderToText } from '@profullstack/hqtui/testing' +import type { ActionResult, ProgressSink } from '@diskpush/plugin-api' +import type { ActionChoice, PluginHost } from '../plugins.js' +import { key } from './keys.fixture.js' +import { blankJob, blankPane, pushJobLog, type ActionsOverlay, type Entry } from './model.js' +import { type Action, type ViewState, draw } from './view.js' + +vi.mock('@diskpush/ssh-core', () => ({ + SshSession: { connect: async () => ({ close: () => {} }) }, + SftpBrowser: { open: async () => ({ list: async () => [], close: () => {} }) }, +})) +vi.mock('@diskpush/database', () => ({ knownHostsPath: () => '/tmp/known_hosts.test' })) + +const { Tui, listLocal } = await import('./app.js') + +const CHOICES: ActionChoice[] = [ + { pluginId: 'mediaanalyzer', pluginName: 'MediaAnalyzer', actionId: 'analyze', label: 'Analyze media', description: 'Describe each photo and video.', key: 'm' }, + { pluginId: 'mediaanalyzer', pluginName: 'MediaAnalyzer', actionId: 'analyze-sort', label: 'Analyze and sort into folders', description: 'Moves files. Undoable.', key: 'f' }, +] + +const entry = (name: string, over: Partial = {}): Entry => ({ name, isDirectory: false, size: 0, modifiedAt: null, ...over }) + +function state(over: Partial = {}): ViewState { + const left = blankPane('Local', '/home/me/photos') + left.entries = [entry('2024', { isDirectory: true }), entry('cat.jpg', { size: 2048 })] + return { + panes: { left, right: blankPane('prod', '/srv') }, + active: 'left', + overlay: null, + transfer: null, + document: null, + filtering: null, + status: null, + choices: [], + now: new Date('2026-09-24T12:00:00.000Z'), + plugins: true, + ...over, + } +} + +const actionsOverlay = (over: Partial = {}): ActionsOverlay => ({ + kind: 'actions', + side: 'left', + dir: '/home/me/photos', + names: ['cat.jpg'], + what: 'cat.jpg', + choices: CHOICES, + index: 0, + hover: null, + ...over, +}) + +const text = (s: ViewState, width = 100, height = 30) => + renderToText(({ ui, theme }) => draw(ui, theme, width, height, s), { width, height, collapseBorders: true }) + +describe('the actions menu frame', () => { + it('lists the actions with their letters and says what the highlighted one does', () => { + const screen = text(state({ overlay: actionsOverlay() })) + expect(screen).toContain('Actions: cat.jpg') + expect(screen).toMatch(/m\s+Analyze media\s+MediaAnalyzer/) + expect(screen).toMatch(/f\s+Analyze and sort into folders/) + expect(screen).toContain('MediaAnalyzer') + expect(screen).toContain('Describe each photo and video.') + expect(screen).toMatch(/⏎\s*run/) + }) + + it('describes the row under the pointer rather than the cursor', () => { + expect(text(state({ overlay: actionsOverlay({ hover: 1 }) }))).toContain('Moves files. Undoable.') + }) + + it('offers `a` in the key bar only when plugins are loaded, and the cap is a button', () => { + expect(text(state({ plugins: false }))).not.toMatch(/\ba\s+actions/) + // Quit keeps its place in a 100-column terminal; the extra cap goes last. + expect(text(state()).trimEnd().split('\n').at(-1)).toContain('q quit') + expect(text(state(), 120).trimEnd().split('\n').at(-1)).toMatch(/q quit\s+a actions/) + const actions: Action[] = [] + const rendered = renderToScreen( + ({ ui, theme }) => draw(ui, theme, 120, 30, state(), { onAction: (action) => actions.push(action) }), + { width: 120, height: 30, collapseBorders: true }, + ) + const cap = rendered.find('a actions')! + expect(cap).not.toBeNull() + rendered.click(cap.x, cap.y) + expect(actions).toEqual(['actions']) + }) +}) + +describe('the job panel frame', () => { + it('shows a running action: its name, the file, the count and cancel', () => { + const job = blankJob('Analyze media', 'photos/2024', 'left', () => {}) + job.startedAt = Date.parse('2026-09-24T11:59:30.000Z') + job.total = 40 + job.done = 12 + job.currentFile = '2024/beach.jpg' + pushJobLog(job, 'warn', 'clip.mp4: skipped, videos need ffmpeg') + const screen = text(state({ job })) + expect(screen).toContain('Analyze media') + expect(screen).toContain('photos/2024') + expect(screen).toContain('12/40') + expect(screen).toContain('2024/beach.jpg') + expect(screen).toContain('videos need ffmpeg') + expect(screen).toContain('esc cancel') + expect(screen).toContain('0:30') + }) + + it('shows how it ended', () => { + const job = blankJob('Analyze media', 'cat.jpg', 'left', () => {}) + job.running = false + job.endedAt = job.startedAt + 5000 + job.outcome = { ok: false, message: 'Out of credit: 3 files not uploaded.' } + const screen = text(state({ job })) + expect(screen).toContain('Analyze media: failed') + expect(screen).toContain('Out of credit') + expect(screen).toContain('esc dismiss') + }) +}) + +/** A plugin host that records what it was asked and finishes when told to. */ +function fakeHost() { + const runs: { choice: ActionChoice; dir: string; names: readonly string[] }[] = [] + let finish!: (result: ActionResult) => void + let progress!: ProgressSink + let signal!: AbortSignal + const host: PluginHost = { + async actionsFor(_dir, entries) { + return entries.some((e) => e.name.endsWith('.jpg') || e.isDirectory) ? CHOICES : [] + }, + run(choice, dir, names, sink, abort) { + runs.push({ choice, dir, names }) + progress = sink + signal = abort + return new Promise((resolve) => { + finish = resolve + abort.addEventListener('abort', () => resolve({ ok: false, message: 'stopped' })) + }) + }, + } + return { host, runs, finish: (result: ActionResult) => finish(result), progress: () => progress, signal: () => signal } +} + +function app(host: PluginHost | null) { + const root = mkdtempSync(join(tmpdir(), 'diskpush-actions-')) + mkdirSync(join(root, 'album')) + writeFileSync(join(root, 'album', 'dog.jpg'), 'x') + writeFileSync(join(root, 'cat.jpg'), 'x') + writeFileSync(join(root, 'notes.txt'), 'x') + const left = blankPane('Local', root) + left.entries = listLocal(root) + const tui = new Tui(left, blankPane('prod', '/srv', { id: 'c1', name: 'prod' } as never), [], undefined, undefined, host) + return { tui, root } +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) +const frame = (tui: InstanceType) => + renderToScreen(({ ui, theme, width, height }) => tui.view(ui, theme, width, height), { width: 100, height: 30, collapseBorders: true }) + +describe('running an action from the TUI', () => { + it('opens on `a`, runs on enter with the directory and the bare name, and shows the result', async () => { + const fake = fakeHost() + const { tui, root } = app(fake.host) + // Rows: album/, cat.jpg, notes.txt; the cursor to cat.jpg. + await tui.onKey(key('down')) + await tui.onKey(key('a')) + expect(tui.snapshot().overlay).toMatchObject({ kind: 'actions', dir: root, names: ['cat.jpg'] }) + + await tui.onKey(key('enter')) + await settle() + expect(fake.runs).toEqual([{ choice: CHOICES[0], dir: root, names: ['cat.jpg'] }]) + expect(tui.snapshot().job?.running).toBe(true) + + fake.progress().start(1) + fake.progress().update({ done: 1, currentFile: 'cat.jpg' }) + expect(tui.snapshot().job).toMatchObject({ done: 1, total: 1, currentFile: 'cat.jpg' }) + + fake.finish({ ok: true, message: 'Described 1 of 1 file.' }) + await settle() + expect(tui.snapshot().job).toMatchObject({ running: false, outcome: { ok: true, message: 'Described 1 of 1 file.' } }) + expect(tui.snapshot().status?.text).toBe('Described 1 of 1 file.') + + await tui.onKey(key('escape')) + expect(tui.snapshot().job).toBeNull() + }) + + it('runs an action from its letter, and on a nested row hands over the folder that holds it', async () => { + const fake = fakeHost() + const { tui, root } = app(fake.host) + await tui.onKey(key('enter')) // unfold album/ + await settle() + await tui.onKey(key('down')) // album/dog.jpg + await tui.onKey(key('a')) + await tui.onKey(key('f')) + await settle() + expect(fake.runs[0]).toMatchObject({ dir: join(root, 'album'), names: ['dog.jpg'], choice: { actionId: 'analyze-sort' } }) + }) + + it('runs the action under a single click, and lights the row under the pointer', async () => { + const fake = fakeHost() + const { tui } = app(fake.host) + await tui.onKey(key('down')) + await tui.onKey(key('a')) + let screen = frame(tui) + const row = screen.find('Analyze and sort into folders')! + screen.hover(row.x, row.y) + expect(tui.snapshot().overlay).toMatchObject({ kind: 'actions', hover: 1 }) + screen = frame(tui) + expect(screen.text()).toContain('Moves files. Undoable.') + screen.click(row.x, row.y) + await settle() + expect(fake.runs.map((run) => run.choice.actionId)).toEqual(['analyze-sort']) + expect(tui.snapshot().overlay).toBeNull() + }) + + it('cancels a running action with escape through its signal', async () => { + const fake = fakeHost() + const { tui } = app(fake.host) + await tui.onKey(key('down')) + await tui.onKey(key('a')) + await tui.onKey(key('enter')) + await settle() + await tui.onKey(key('escape')) + await settle() + expect(fake.signal().aborted).toBe(true) + expect(tui.snapshot().job?.outcome).toMatchObject({ ok: false, cancelled: true }) + }) + + it('says why there is nothing to offer: no plugin fits, a server pane, or no plugins', async () => { + const fake = fakeHost() + const { tui } = app(fake.host) + await tui.onKey(key('end')) // notes.txt + await tui.onKey(key('a')) + expect(tui.snapshot().overlay).toBeNull() + expect(tui.snapshot().status?.text).toBe('No plugin action applies to notes.txt.') + + await tui.onKey(key('tab')) + await tui.onKey(key('a')) + expect(tui.snapshot().status?.text).toMatch(/local files/) + + const bare = app(null).tui + await bare.onKey(key('a')) + expect(bare.snapshot().status?.text).toMatch(/No plugins/) + }) +}) diff --git a/apps/cli/src/tui/app.ts b/apps/cli/src/tui/app.ts index 624373b..74ebb66 100644 --- a/apps/cli/src/tui/app.ts +++ b/apps/cli/src/tui/app.ts @@ -10,7 +10,7 @@ * shape is `model.ts`, and neither of those touches a terminal — so the whole * screen can be rendered and asserted on in a test with no pty. */ -import { join, posix } from 'node:path' +import { dirname, join, posix, resolve } from 'node:path' import type { App, Container, KeyEvent, MouseEvent, Rect, Theme } from '@profullstack/hqtui' import { detectCapabilities } from '@profullstack/hqtui' import { knownHostsPath } from '@diskpush/database' @@ -18,11 +18,13 @@ import { SftpBrowser, SshSession } from '@diskpush/ssh-core' import { defaultRsyncOptions, type Change, type Connection } from '@diskpush/schemas' import { parseEndpoint, planTransfer, runToCompletion } from '@diskpush/rsync-core' import { + type ActionsOverlay, type Document, type EndpointChoice, type Entry, type Overlay, type Pane, + type PluginJob, type Row, type Side, type SortKey, @@ -31,6 +33,8 @@ import { DOCUMENT_LIMIT, IMAGE_LIMIT, blankDocument, + blankJob, + jobProgress, blankPane, clampIndex, endpointString, @@ -52,6 +56,7 @@ import { type Action, type Tone, type ViewState, draw, filterChoices } from './v import { maxScroll, readsAsMarkdown } from './document.js' import { type Launch, type Launcher, type Target, editLaunch, openLaunch, systemLauncher } from './launch.js' import { type Protocol, chooseProtocol, deleteImage, encodeImage, placeAt, tmuxPassthrough } from './graphics.js' +import type { ActionChoice, PluginHost } from '../plugins.js' export { blankPane, @@ -72,6 +77,8 @@ export class Tui { /** Whatever is on screen instead of the panes, and owns the keyboard while it is. */ private overlay: Overlay | null = null private transfer: Transfer | null = null + /** A plugin action in flight, or the last one that ran. Shares the transfer panel's rows. */ + private job: PluginJob | null = null /** The file open under the panes, if any. */ private document: Document | null = null /** What the last frame drew of it: its line count and the rows it had, so a scroll can be clamped. */ @@ -102,6 +109,8 @@ export class Tui { private readonly choices: readonly EndpointChoice[] = [], private readonly launcher: Launcher = systemLauncher(), protocol?: Protocol, + /** Plugin actions for `a`. Null when no plugins are loaded, and then `a` is not offered. */ + private readonly plugins: PluginHost | null = null, ) { this.panes = { left, right } this.protocol = protocol ?? chooseProtocol(detectCapabilities({}, launcher.env).program) @@ -135,6 +144,8 @@ export class Tui { status: this.status, choices: this.choices, now: new Date(), + job: this.job, + plugins: this.plugins !== null, } } @@ -190,7 +201,21 @@ export class Tui { onDismissOverlay: () => { // The host-key question is not on this list on purpose: it is only // ever answered, never waved away. - if (this.overlay?.kind === 'picker' || this.overlay?.kind === 'help') this.overlay = null + if (this.overlay?.kind === 'picker' || this.overlay?.kind === 'help' || this.overlay?.kind === 'actions') { + this.overlay = null + } + this.invalidate() + }, + onPickAction: (choice) => { + if (this.overlay?.kind !== 'actions') return + const overlay = this.overlay + this.overlay = null + void this.runPluginAction(overlay, choice) + }, + onHoverAction: (index) => { + this.hoverSeen = true + if (this.overlay?.kind !== 'actions' || this.overlay.hover === index) return + this.overlay.hover = index this.invalidate() }, onHostKeyDecide: (trust) => { @@ -212,6 +237,10 @@ export class Tui { * rows, so nothing should stay lit. */ onMouse(event: MouseEvent): void { + if (event.action === 'move' && !this.hoverSeen && this.overlay?.kind === 'actions' && this.overlay.hover !== null) { + this.overlay.hover = null + this.invalidate() + } if (event.action === 'move' && !this.hoverSeen) { if (this.panes.left.hover !== null || this.panes.right.hover !== null) { this.panes.left.hover = null @@ -233,7 +262,9 @@ export class Tui { return } if (action === 'closeOverlay') { - if (this.overlay?.kind === 'picker' || this.overlay?.kind === 'help') this.overlay = null + if (this.overlay?.kind === 'picker' || this.overlay?.kind === 'help' || this.overlay?.kind === 'actions') { + this.overlay = null + } this.invalidate() return } @@ -282,6 +313,9 @@ export class Tui { case 'sort': if (!this.busy) this.cycleSort() break + case 'actions': + if (!this.busy) await this.openActions() + break default: break } @@ -426,6 +460,7 @@ export class Tui { return true } if (this.overlay?.kind === 'picker') return this.onPickerKey(key, this.overlay) + if (this.overlay?.kind === 'actions') return this.onActionsKey(key, this.overlay) if (this.filtering) return this.onFilterKey(key) // A message is about the last thing that happened; the next key starts @@ -435,7 +470,7 @@ export class Tui { if (key.name === 'escape') { // Escape belongs to the transfer while there is one: cancelling a sync in // flight, or clearing the panel a finished one left behind. - if (this.transfer) { + if (this.transfer || this.job) { this.dismissTransfer() return true } @@ -524,6 +559,9 @@ export class Tui { case key.name === 's': await this.transferTo(false) break + case key.char === 'a': + await this.openActions() + break case key.name === '?': this.overlay = { kind: 'help' } break @@ -658,6 +696,7 @@ export class Tui { this.document = doc // A finished transfer's panel and the document want the same rows. if (this.transfer && !this.transfer.running) this.transfer = null + if (this.job && !this.job.running) this.job = null this.invalidate() // An image is handed to the terminal whole, so it is read whole. const limit = isImageName(row.entry.name) ? IMAGE_LIMIT : DOCUMENT_LIMIT @@ -959,6 +998,17 @@ export class Tui { // -------------------------------------------------------------- transfers private dismissTransfer(): void { + // A plugin job and a transfer never run together (both hold `busy`), so + // whichever is on screen is the one escape means. + if (this.job) { + if (this.job.running) { + this.job.cancel() + this.say('Cancelling…', 'warn') + return + } + this.job = null + return + } if (!this.transfer) return if (this.transfer.running) { this.transfer.cancel() @@ -990,8 +1040,9 @@ export class Tui { cancel: () => controller.abort(), } this.transfer = transfer - // The transfer panel takes the rows the document had. + // The transfer panel takes the rows the document had, and a finished job's. this.document = null + this.job = null this.busy = true this.invalidate() // rsync can be silent for a long time while it walks a tree; the clock in @@ -1060,7 +1111,116 @@ export class Tui { } } + // ---------------------------------------------------------------- plugins + + /** + * `a`: the plugin actions that apply to the row under the cursor. + * + * Local panes only. A plugin works on files this machine can open, and a + * row in a server pane is a path on the server. + */ + private async openActions(): Promise { + if (!this.plugins) { + this.say('No plugins are loaded. See: diskpush plugins', 'warn') + return + } + const pane = this.current + if (pane.connection) { + this.say(`Plugin actions work on local files; this pane is ${pane.label}.`, 'warn') + return + } + const row = selectedRow(pane) + if (!row) { + this.say('Nothing under the cursor.', 'warn') + return + } + const parent = dirname(row.rel) + const dir = resolve(pane.path, parent === '.' ? '' : parent) + const entries = [{ name: row.entry.name, isDirectory: row.entry.isDirectory, size: row.entry.size }] + let choices: ActionChoice[] + try { + choices = await this.plugins.actionsFor(dir, entries) + } catch (error) { + this.say(error instanceof Error ? error.message : String(error), 'error') + return + } + if (choices.length === 0) { + this.say(`No plugin action applies to ${row.entry.name}.`, 'warn') + return + } + this.overlay = { kind: 'actions', side: this.active, dir, names: [row.entry.name], what: row.rel, choices, index: 0, hover: null } + } + + private async onActionsKey(key: KeyEvent, overlay: ActionsOverlay): Promise { + if (key.name === 'q') return false + if (key.name === 'escape') { + this.overlay = null + return true + } + if (key.name === 'up' || key.char === 'k') { + overlay.index = Math.max(0, overlay.index - 1) + return true + } + if (key.name === 'down' || key.char === 'j') { + overlay.index = Math.min(overlay.choices.length - 1, overlay.index + 1) + return true + } + const choice = + key.name === 'enter' + ? overlay.choices[overlay.index] + : key.char && !key.ctrl && !key.alt + ? overlay.choices.find((candidate) => candidate.key === key.char) + : undefined + if (!choice) return true + this.overlay = null + // Not awaited: the action runs for as long as it runs, reporting into + // the job panel, and the keyboard stays live for escape. + void this.runPluginAction(overlay, choice) + return true + } + + private async runPluginAction(overlay: ActionsOverlay, choice: ActionChoice): Promise { + if (!this.plugins || this.busy) return + const controller = new AbortController() + const job = blankJob(choice.label, overlay.what, overlay.side, () => controller.abort()) + this.job = job + // The job panel takes the rows the document and a finished transfer had. + this.document = null + if (this.transfer && !this.transfer.running) this.transfer = null + this.busy = true + this.invalidate() + const clock = setInterval(() => this.invalidate(), 1000) + try { + const result = await this.plugins.run( + choice, + overlay.dir, + overlay.names, + jobProgress(job, () => this.invalidate()), + controller.signal, + ) + if (controller.signal.aborted) { + job.outcome = { ok: false, cancelled: true, message: `${choice.label} was cancelled. What finished before esc is kept.` } + this.say(`${choice.label} cancelled`, 'warn') + } else { + job.outcome = { ok: result.ok, message: result.message } + this.say(result.message, result.ok ? 'ok' : 'error') + } + if (result.changed) await this.refresh(overlay.side) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + job.outcome = { ok: false, message } + this.say(message, 'error') + } finally { + clearInterval(clock) + job.running = false + job.endedAt = Date.now() + this.busy = false + this.invalidate() + } + } + close(): void { + this.job?.cancel() this.transfer?.cancel() for (const session of this.sessions.values()) session.close() } diff --git a/apps/cli/src/tui/model.ts b/apps/cli/src/tui/model.ts index 4222fed..ac1ae0c 100644 --- a/apps/cli/src/tui/model.ts +++ b/apps/cli/src/tui/model.ts @@ -9,6 +9,8 @@ import { closeSync, fstatSync, openSync, readSync, readdirSync, statSync } from import { homedir } from 'node:os' import { dirname, join, posix } from 'node:path' import type { Change, ChangeSummary, Connection, RsyncProgress } from '@diskpush/schemas' +import type { LogLevel, ProgressSink } from '@diskpush/plugin-api' +import type { ActionChoice } from '../plugins.js' export type Entry = { name: string @@ -281,6 +283,94 @@ export type Overlay = | { kind: 'picker'; query: string; index: number } | { kind: 'help' } | { kind: 'hostKey'; host: string; fingerprint: string; keyType: string; decide: (trust: boolean) => void } + | ActionsOverlay + +/** + * The plugin actions that apply to the row under the cursor. + * + * `dir` and `names` are what the action will be handed: the absolute local + * directory holding the row, and the row's own name in it. + */ +export type ActionsOverlay = { + kind: 'actions' + side: Side + dir: string + names: string[] + /** The row, for a person: `photos/2024`. */ + what: string + choices: ActionChoice[] + index: number + /** The choice under the mouse, or null. */ + hover: number | null +} + +/** + * A plugin action in flight, or the last one that ran. Drawn where a + * transfer's panel goes, and dismissed the same way. + */ +export type PluginJob = { + title: string + what: string + side: Side + running: boolean + startedAt: number + endedAt: number | null + done: number + total: number | null + message: string + currentFile: string + /** Most recent warnings and errors, newest last. Capped by `pushJobLog`. */ + log: { level: LogLevel; message: string }[] + outcome: { ok: boolean; message: string; cancelled?: boolean } | null + cancel: () => void +} + +export const JOB_LOG_LIMIT = 200 + +export function blankJob(title: string, what: string, side: Side, cancel: () => void): PluginJob { + return { + title, + what, + side, + running: true, + startedAt: Date.now(), + endedAt: null, + done: 0, + total: null, + message: '', + currentFile: '', + log: [], + outcome: null, + cancel, + } +} + +export function pushJobLog(job: PluginJob, level: LogLevel, message: string): void { + job.log.push({ level, message }) + if (job.log.length > JOB_LOG_LIMIT) job.log.splice(0, job.log.length - JOB_LOG_LIMIT) +} + +/** The progress sink a plugin reports into: it writes the job, and asks for a frame. */ +export function jobProgress(job: PluginJob, redraw: () => void): ProgressSink { + return { + start(total) { + job.total = total ?? null + job.done = 0 + redraw() + }, + update({ done, total, message, currentFile }) { + if (done !== undefined) job.done = done + if (total !== undefined) job.total = total + if (message !== undefined) job.message = message + if (currentFile !== undefined) job.currentFile = currentFile + redraw() + }, + log(level, message) { + pushJobLog(job, level, message) + redraw() + }, + } +} /** A transfer in flight, or the last one that ran. */ export type Transfer = { diff --git a/apps/cli/src/tui/view.ts b/apps/cli/src/tui/view.ts index f61c819..b144354 100644 --- a/apps/cli/src/tui/view.ts +++ b/apps/cli/src/tui/view.ts @@ -9,10 +9,12 @@ import type { Color, Container, Rect, Theme } from '@profullstack/hqtui' import { stringWidth, truncate, widgets } from '@profullstack/hqtui' import type { Change } from '@diskpush/schemas' import { + type ActionsOverlay, type Document, type EndpointChoice, type Overlay, type Pane, + type PluginJob, type Row, type Side, type Transfer, @@ -27,6 +29,7 @@ import { visibleEntries, } from './model.js' import { documentLines, maxScroll } from './document.js' +import type { ActionChoice } from '../plugins.js' /** What hqtui's tree draws: the model's `Row`, spelled the widget's way. */ type TreeNode = { @@ -51,6 +54,10 @@ export type ViewState = { status: { text: string; tone: Tone } | null choices: readonly EndpointChoice[] now: Date + /** A plugin action in flight, or the last one that ran. */ + job?: PluginJob | null + /** Whether any plugin is loaded, so `a` is only offered when it can do something. */ + plugins?: boolean } /** @@ -75,6 +82,7 @@ export type Action = | 'cancelTransfer' | 'dismissTransfer' | 'closeOverlay' + | 'actions' /** * Mouse wiring. Optional so a test can render a frame without any. @@ -114,6 +122,10 @@ export type ViewHandlers = { * out, because an image is not a cell and no framebuffer can hold it. */ onImageRect?: (rect: Rect) => void + /** A click on a plugin action in the actions menu: run it. One click, no confirm step. */ + onPickAction?: (choice: ActionChoice) => void + /** The pointer is over an action (its index), or left the list (null). */ + onHoverAction?: (index: number | null) => void } /** Rows the transfer panel takes when one is on screen. */ @@ -206,12 +218,14 @@ export function draw( // A short terminal gives its rows to the panes; the transfer is still // readable from the status line, and half a panel is worse than none. else if (state.transfer && height >= TRANSFER_HEIGHT + 8) drawTransfer(ui, theme, state, state.transfer, handlers) + else if (state.job && height >= TRANSFER_HEIGHT + 8) drawJob(ui, theme, state, state.job, handlers) drawFooter(ui, theme, state, width, handlers) if (state.overlay?.kind === 'picker') drawPicker(ui, theme, state, state.overlay, height, handlers) if (state.overlay?.kind === 'help') drawHelp(ui, theme, handlers) if (state.overlay?.kind === 'hostKey') drawHostKey(ui, theme, state.overlay, width, handlers) + if (state.overlay?.kind === 'actions') drawActions(ui, theme, state.overlay, width, height, handlers) } function drawHeader(ui: Container, theme: Theme, state: ViewState, handlers: ViewHandlers): void { @@ -648,7 +662,13 @@ function drawFooter(ui: Container, theme: Theme, state: ViewState, width: number ] : overlay === 'help' ? [{ key: 'esc', label: 'close' }] - : state.transfer?.running + : overlay === 'actions' + ? [ + { key: '↑↓', label: 'move' }, + { key: '⏎', label: 'run' }, + { key: 'esc', label: 'cancel' }, + ] + : state.transfer?.running || state.job?.running ? [ // Every other key is ignored while a transfer runs, so the bar // says so instead of listing keys that would do nothing. @@ -680,6 +700,9 @@ function drawFooter(ui: Container, theme: Theme, state: ViewState, width: number { key: '/', label: 'filter', onPress: act('filter') }, { key: 'o', label: 'sort', onPress: act('sort') }, { key: 'q', label: 'quit', onPress: act('quit') }, + // Last, so a narrow terminal loses it before it loses quit; + // `?` lists it whatever the width. + ...(state.plugins ? [{ key: 'a', label: 'actions', onPress: act('actions') }] : []), ] ui.statusBar({ height: 1, keyStyle: 'caps', items }) @@ -744,7 +767,7 @@ function drawHelp(ui: Container, theme: Theme, handlers: ViewHandlers): void { { title: ' Keys ', width: 66, - height: 28, + height: 29, buttons: [{ label: 'esc close', variant: 'ghost', onPress: close }], onDismiss: close, }, @@ -769,6 +792,7 @@ function drawHelp(ui: Container, theme: Theme, handlers: ViewHandlers): void { { label: 'r', value: 'reload' }, { label: 'p', value: 'preview syncing this to the other pane' }, { label: 's', value: 'sync it, into the same place over there' }, + { label: 'a', value: 'plugin actions for a local file or folder' }, { label: 'esc', value: 'cancel a transfer, close a file or this' }, { label: 'q', value: 'quit' }, ], @@ -810,3 +834,123 @@ function drawHostKey( }, ) } + +/** + * The plugin actions for the row under the cursor. + * + * A menu, so one click runs the action under it and the pointer lights the + * row it is over; the keyboard moves and presses enter, or presses the + * action's own letter. The line under the list says what the highlighted + * action will do, because "Analyze and sort into folders" moves files and + * that should be read before it is clicked, not after. + */ +function drawActions( + ui: Container, + theme: Theme, + overlay: ActionsOverlay, + width: number, + height: number, + handlers: ViewHandlers, +): void { + const rows = overlay.choices.map((choice) => ({ + key: choice.key ?? ' ', + label: choice.label, + plugin: choice.pluginName, + })) + const focus = overlay.choices[overlay.hover ?? overlay.index] + const listRows = Math.max(1, Math.min(rows.length, height - 12)) + let first = 0 + ui.modal( + { + title: ` Actions: ${truncatePath(overlay.what, Math.max(12, Math.min(60, width - 30)))} `, + width: Math.min(72, Math.max(44, width - 8)), + height: listRows + 7, + onDismiss: () => handlers.onDismissOverlay?.(), + }, + (modal) => { + modal.table({ + rows, + selected: overlay.index, + ...(overlay.hover !== null ? { hovered: overlay.hover } : {}), + followSelection: true, + header: false, + height: listRows, + onSelectRow: (visibleRow) => { + const choice = overlay.choices[first + visibleRow] + if (choice) handlers.onPickAction?.(choice) + }, + onHoverRow: (visibleRow) => handlers.onHoverAction?.(visibleRow === null ? null : first + visibleRow), + onRow: (_row, index, y) => { + first = index - y + }, + columns: [ + { key: 'key', width: 3, color: theme.accent }, + { key: 'label', width: '1fr', color: theme.foreground }, + { key: 'plugin', align: 'right', color: theme.muted }, + ], + }) + modal.divider({ height: 1, color: theme.border }) + modal.text(focus?.description || ' ', { fg: theme.muted, wrap: true, height: 'fill' }) + }, + ) +} + +const JOB_LEVEL: Record<'info' | 'warn' | 'error', string> = { info: 'INFO', warn: 'WARN', error: 'ERR' } + +/** A plugin action's progress, in the transfer panel's place. */ +function drawJob(ui: Container, theme: Theme, state: ViewState, job: PluginJob, handlers: ViewHandlers): void { + const cancelled = job.outcome?.cancelled === true + const failed = job.outcome !== null && !job.outcome.ok && !cancelled + const elapsed = Math.max(0, ((job.endedAt ?? state.now.getTime()) - job.startedAt) / 1000) + const title = job.running + ? ` ${job.title} ` + : cancelled + ? ' Cancelled ' + : job.outcome?.ok + ? ` ${job.title}: done ` + : ` ${job.title}: failed ` + const titleColor = job.running || cancelled ? theme.warning : job.outcome?.ok ? theme.success : theme.danger + const value = job.outcome?.ok ? 1 : job.total ? job.done / job.total : 0 + + ui.panel( + { + height: TRANSFER_HEIGHT, + title, + titleColor, + subtitle: truncatePath(job.what, 60), + subtitleColor: theme.muted, + borderColor: titleColor, + footer: job.running ? ' esc cancel ' : ' esc dismiss ', + // Same rule as a transfer: a click dismisses a finished job, never + // cancels a running one. + ...(job.running ? {} : { onClick: () => handlers.onAction?.('dismissTransfer') }), + }, + (panel) => { + panel.meter({ + height: 1, + value: Math.max(0, Math.min(1, value)), + label: 'files', + text: job.total ? `${job.outcome?.ok ? job.total : job.done}/${job.total}` : job.running ? 'working…' : '', + heat: false, + color: failed ? theme.danger : cancelled ? theme.warning : theme.primary, + }) + panel.row({ height: 1, gap: 1 }, (row) => { + row.text(` ${truncate(job.currentFile || job.message || '', 70)}`, { fg: theme.muted }) + row.text(`${formatDuration(elapsed)} `, { fg: theme.muted, align: 'right' }) + }) + if (job.outcome) { + panel.text(job.outcome.message, { + fg: failed ? theme.danger : cancelled ? theme.warning : theme.success, + wrap: true, + height: 2, + }) + } + panel.log({ + height: 'fill', + follow: true, + entries: job.log.map((entry) => ({ level: JOB_LEVEL[entry.level], message: entry.message, meta: '' })), + levelColors: { INFO: theme.muted, WARN: theme.warning, ERR: theme.danger }, + }) + }, + ) +} diff --git a/apps/desktop/electron/main/ipc.ts b/apps/desktop/electron/main/ipc.ts index 22ff55e..ecc1fde 100644 --- a/apps/desktop/electron/main/ipc.ts +++ b/apps/desktop/electron/main/ipc.ts @@ -19,6 +19,11 @@ import { JobIdSchema, OpenSeriesRequestSchema, OpenWithRequestSchema, + PluginActionRequestSchema, + PluginIdSchema, + PluginSelectionSchema, + PluginSettingsSetSchema, + PluginTaskRequestSchema, PreviewRequestSchema, PathSchema, RemotePathRequestSchema, @@ -47,6 +52,16 @@ import { browserFor, dropSession, sessionFor } from './services/sessions.js' import { store } from './services/store.js' import { advanceSeries, handlersFor, openSeries, openWith, stopSeries } from './services/open-with.js' import { cancelPreview, cancelTransfer, previewTransfer, saveProfile, startTransfer } from './services/transfers.js' +import { + cancelPlugin, + getPluginSettings, + listPlugins, + pluginActionsFor, + runPluginAction, + runPluginTask, + setPluginEnabled, + setPluginSettings, +} from './services/plugins.js' /** * Every handler validates its input with Zod before doing anything, and every @@ -385,6 +400,28 @@ export function registerIpc(): void { removeFleetList(name), ) + // --- plugins ------------------------------------------------------------- + // Plugin code runs here, in the main process. The renderer names a plugin + // and an action by id, and the files by a local directory and bare names. + + handle(IPC.pluginsList, z.undefined(), async () => listPlugins()) + + handle(IPC.pluginsActionsFor, PluginSelectionSchema, async (input) => pluginActionsFor(input)) + + handle(IPC.pluginsRunAction, PluginActionRequestSchema, async (request, event) => runPluginAction(request, event.sender)) + + handle(IPC.pluginsRunTask, PluginTaskRequestSchema, async (request, event) => runPluginTask(request, event.sender)) + + handle(IPC.pluginsCancel, z.object({ jobId: JobIdSchema }), async ({ jobId }) => cancelPlugin(jobId)) + + handle(IPC.pluginsGetSettings, z.object({ pluginId: PluginIdSchema }), async ({ pluginId }) => getPluginSettings(pluginId)) + + handle(IPC.pluginsSetSettings, PluginSettingsSetSchema, async ({ pluginId, values }) => setPluginSettings(pluginId, values)) + + handle(IPC.pluginsSetEnabled, z.object({ pluginId: PluginIdSchema, enabled: z.boolean() }), async ({ pluginId, enabled }) => + setPluginEnabled(pluginId, enabled), + ) + // --- shell --------------------------------------------------------------- handle(IPC.shellOpenExternal, z.object({ url: ExternalUrlSchema }), async ({ url }) => { diff --git a/apps/desktop/electron/main/services/plugins.ts b/apps/desktop/electron/main/services/plugins.ts new file mode 100644 index 0000000..56d289b --- /dev/null +++ b/apps/desktop/electron/main/services/plugins.ts @@ -0,0 +1,255 @@ +/** + * Plugins, hosted in the main process. + * + * Plugin code never runs in the renderer. The renderer asks, by id, for an + * action on a directory plus bare entry names (validated by the contract); + * this module runs it here and streams its progress back over + * `plugins:progress`. A plugin gets the same context the CLI gives it, with + * `openUrl` wired to the system browser for http(s) only. + */ +import { homedir } from 'node:os' +import { isAbsolute, join, resolve } from 'node:path' +import { shell, type WebContents } from 'electron' +import { diskpushHome } from '@diskpush/database' +import { + PluginError, + PluginRegistry, + describeEntries, + loadExternalPlugins, + pluginsDirectory, + runAction, + runTask, + type ActionResult, + type HostContext, + type LoadFailure, + type LogLevel, + type ProgressSink, + type SettingDef, +} from '@diskpush/plugin-api' +import { mediaAnalyzerPlugin } from '@diskpush/plugin-mediaanalyzer' +import { ExternalUrlSchema, IPC, type PluginActionRequest } from '../../shared/contract.js' +import { store } from './store.js' + +/** The same built-in list as the CLI's (apps/cli/src/plugins.ts). */ +const BUILTIN_PLUGINS = [mediaAnalyzerPlugin] + +let loaded: Promise<{ registry: PluginRegistry; failures: LoadFailure[] }> | null = null + +export function pluginRegistry(): Promise<{ registry: PluginRegistry; failures: LoadFailure[] }> { + loaded ??= (async () => { + const registry = new PluginRegistry(await store()) + for (const plugin of BUILTIN_PLUGINS) registry.register(plugin, 'builtin') + const failures = await loadExternalPlugins(registry, pluginsDirectory(diskpushHome())) + for (const failure of failures) console.warn(`plugin ${failure.name} did not load: ${failure.error}`) + return { registry, failures } + })() + return loaded +} + +/** What the renderer is told about a plugin. Code stays here. */ +export type PluginSummary = { + id: string + name: string + version: string + description: string + source: 'builtin' | 'external' + enabled: boolean + settings: SettingDef[] + tasks: { id: string; label: string; description: string }[] + actions: { id: string; label: string; description: string }[] +} + +export async function listPlugins(): Promise<{ plugins: PluginSummary[]; failures: LoadFailure[] }> { + const { registry, failures } = await pluginRegistry() + const plugins = (await registry.list()).map(({ plugin, source, enabled }) => ({ + id: plugin.id, + name: plugin.name, + version: plugin.version, + description: plugin.description, + source, + enabled, + settings: plugin.settings ?? [], + tasks: (plugin.tasks ?? []).map(({ id, label, description }) => ({ id, label, description: description ?? '' })), + actions: (plugin.actions ?? []).map(({ id, label, description }) => ({ id, label, description: description ?? '' })), + })) + return { plugins, failures } +} + +/** `~` expanded here, never trusted from the renderer; the result must be absolute. */ +function localDirectory(input: string): string { + const expanded = input.startsWith('~') ? join(homedir(), input.slice(1)) : input + const dir = resolve(expanded) + if (!isAbsolute(dir)) throw new PluginError('A plugin works on an absolute local directory.') + return dir +} + +export type PluginActionChoice = { pluginId: string; pluginName: string; actionId: string; label: string; description: string } + +export async function pluginActionsFor(input: { dir: string; names: string[] }): Promise { + const { registry } = await pluginRegistry() + const dir = localDirectory(input.dir) + const entries = await describeEntries(dir, input.names) + return (await registry.actionsFor(entries, dir)).map(({ plugin, action }) => ({ + pluginId: plugin.id, + pluginName: plugin.name, + actionId: action.id, + label: action.label, + description: action.description ?? '', + })) +} + +/** Every event on `plugins:progress`. */ +export type PluginEvent = + | { type: 'start'; total: number | null } + | { type: 'update'; done?: number; total?: number; message?: string; currentFile?: string } + | { type: 'log'; level: LogLevel; message: string } + | { type: 'exit'; ok: boolean; message: string; changed: boolean; cancelled: boolean } + +const running = new Map() + +function sink(jobId: string, sender: WebContents): ProgressSink { + // The window can close mid-run; the plugin carries on and its files are + // still written. + const send = (event: PluginEvent) => { + if (!sender.isDestroyed()) sender.send(IPC.eventPlugin, { jobId, event }) + } + return { + start: (total) => send({ type: 'start', total: total ?? null }), + update: (update) => send({ type: 'update', ...update }), + log: (level, message) => send({ type: 'log', level, message }), + } +} + +async function openUrl(url: string): Promise { + await shell.openExternal(ExternalUrlSchema.parse(url)) +} + +/** Starts `work` under a fresh AbortController, reports its outcome, and returns at once. */ +function launch(jobId: string, sender: WebContents, work: (host: HostContext) => Promise): { jobId: string } { + if (running.has(jobId)) throw new PluginError('That job id is already running.') + const controller = new AbortController() + running.set(jobId, controller) + const progress = sink(jobId, sender) + const host: HostContext = { progress, openUrl, signal: controller.signal, surface: 'desktop', env: process.env } + void (async () => { + let result: ActionResult + try { + result = await work(host) + } catch (error) { + result = { ok: false, message: error instanceof Error ? error.message : String(error) } + } finally { + running.delete(jobId) + } + const cancelled = controller.signal.aborted + if (!sender.isDestroyed()) { + sender.send(IPC.eventPlugin, { + jobId, + event: { + type: 'exit', + ok: result.ok && !cancelled, + message: cancelled ? 'Cancelled. What finished before that is kept.' : result.message, + changed: result.changed === true, + cancelled, + } satisfies PluginEvent, + }) + } + })() + return { jobId } +} + +export async function runPluginAction(request: PluginActionRequest, sender: WebContents): Promise<{ jobId: string }> { + const { registry } = await pluginRegistry() + // Checked before returning, so an unknown or disabled plugin is an error + // on the call rather than an event the renderer has to be waiting for. + await registry.action(request.pluginId, request.actionId) + const dir = localDirectory(request.dir) + return launch(request.jobId, sender, (host) => + runAction(registry, request.pluginId, request.actionId, { ...host, dir, names: request.names }), + ) +} + +export async function runPluginTask( + request: { jobId: string; pluginId: string; taskId: string }, + sender: WebContents, +): Promise<{ jobId: string }> { + const { registry } = await pluginRegistry() + await registry.task(request.pluginId, request.taskId) + return launch(request.jobId, sender, (host) => runTask(registry, request.pluginId, request.taskId, host)) +} + +export function cancelPlugin(jobId: string): boolean { + const controller = running.get(jobId) + if (!controller) return false + controller.abort() + return true +} + +/** + * A plugin's settings for the dialog. Secrets are reported as set or not, + * never returned: the renderer is the one place a token must not reach. + */ +export async function getPluginSettings(pluginId: string): Promise<{ + status: string | null + values: Record + secrets: Record +}> { + const { registry } = await pluginRegistry() + const plugin = registry.get(pluginId) + if (!plugin) throw new PluginError(`No plugin called "${pluginId}".`) + const settings = registry.settingsFor(pluginId) + const secrets = registry.secretsFor(pluginId) + const values: Record = {} + const set: Record = {} + for (const def of plugin.settings ?? []) { + if (def.type === 'secret') set[def.key] = (await secrets.get(def.key)) !== null + else values[def.key] = await settings.get(def.key, def.default ?? (def.type === 'boolean' ? false : '')) + } + let status: string | null = null + if (plugin.status && (await registry.isEnabled(pluginId))) { + try { + status = await plugin.status({ + settings, + secrets, + progress: { start() {}, update() {}, log() {} }, + openUrl, + signal: AbortSignal.timeout(10_000), + surface: 'desktop', + env: process.env, + }) + } catch (error) { + status = error instanceof Error ? error.message : String(error) + } + } + return { status, values, secrets: set } +} + +/** Writes declared settings only, each checked against its declared type. */ +export async function setPluginSettings(pluginId: string, values: Record): Promise { + const { registry } = await pluginRegistry() + const plugin = registry.get(pluginId) + if (!plugin) throw new PluginError(`No plugin called "${pluginId}".`) + const defs = new Map((plugin.settings ?? []).map((def) => [def.key, def])) + for (const [key, value] of Object.entries(values)) { + const def = defs.get(key) + if (!def) throw new PluginError(`${plugin.name} has no setting "${key}".`) + if (def.type === 'secret') { + if (value !== null && typeof value !== 'string') throw new PluginError(`${def.label} must be text.`) + await registry.secretsFor(pluginId).set(key, value === '' ? null : value) + continue + } + if (def.type === 'boolean' ? typeof value !== 'boolean' : typeof value !== 'string') { + throw new PluginError(`${def.label} has the wrong type.`) + } + if (def.type === 'enum' && !(def.options ?? []).includes(value as string)) { + throw new PluginError(`${def.label} must be one of: ${(def.options ?? []).filter(Boolean).join(', ')}.`) + } + await registry.settingsFor(pluginId).set(key, value) + } + return true +} + +export async function setPluginEnabled(pluginId: string, enabled: boolean): Promise { + const { registry } = await pluginRegistry() + await registry.setEnabled(pluginId, enabled) + return enabled +} diff --git a/apps/desktop/electron/preload/index.ts b/apps/desktop/electron/preload/index.ts index 7817622..3b2170b 100644 --- a/apps/desktop/electron/preload/index.ts +++ b/apps/desktop/electron/preload/index.ts @@ -79,6 +79,19 @@ const api = { shell: { openExternal: (url: string) => call(IPC.shellOpenExternal, { url }), }, + // Plugin code runs in the main process; these only name what to run. + plugins: { + list: () => call(IPC.pluginsList), + actionsFor: (dir: string, names: string[]) => call(IPC.pluginsActionsFor, { dir, names }), + runAction: (jobId: string, pluginId: string, actionId: string, dir: string, names: string[]) => + call(IPC.pluginsRunAction, { jobId, pluginId, actionId, dir, names }), + runTask: (jobId: string, pluginId: string, taskId: string) => call(IPC.pluginsRunTask, { jobId, pluginId, taskId }), + cancel: (jobId: string) => call(IPC.pluginsCancel, { jobId }), + getSettings: (pluginId: string) => call(IPC.pluginsGetSettings, { pluginId }), + setSettings: (pluginId: string, values: Record) => + call(IPC.pluginsSetSettings, { pluginId, values }), + setEnabled: (pluginId: string, enabled: boolean) => call(IPC.pluginsSetEnabled, { pluginId, enabled }), + }, events: { /** * Returns an unsubscribe function. The listener is wrapped so the @@ -100,6 +113,11 @@ const api = { ipcRenderer.on(IPC.eventOpenSeries, wrapped) return () => ipcRenderer.off(IPC.eventOpenSeries, wrapped) }, + onPluginProgress(listener: (payload: { jobId: string; event: unknown }) => void): () => void { + const wrapped = (_event: unknown, payload: { jobId: string; event: unknown }) => listener(payload) + ipcRenderer.on(IPC.eventPlugin, wrapped) + return () => ipcRenderer.off(IPC.eventPlugin, wrapped) + }, onPreview(listener: (payload: { previewId: string; progress: unknown }) => void): () => void { const wrapped = (_event: unknown, payload: { previewId: string; progress: unknown }) => listener(payload) ipcRenderer.on(IPC.eventPreview, wrapped) diff --git a/apps/desktop/electron/shared/contract.ts b/apps/desktop/electron/shared/contract.ts index 3deaf70..a263088 100644 --- a/apps/desktop/electron/shared/contract.ts +++ b/apps/desktop/electron/shared/contract.ts @@ -61,6 +61,18 @@ export const IPC = { shellOpenExternal: 'shell:open-external', + pluginsList: 'plugins:list', + pluginsActionsFor: 'plugins:actions-for', + pluginsRunAction: 'plugins:run-action', + pluginsRunTask: 'plugins:run-task', + pluginsCancel: 'plugins:cancel', + pluginsGetSettings: 'plugins:get-settings', + pluginsSetSettings: 'plugins:set-settings', + pluginsSetEnabled: 'plugins:set-enabled', + + /** Main -> renderer, progress and the outcome of a plugin action or task. */ + eventPlugin: 'plugins:progress', + /** Main -> renderer, one channel carrying every job event. */ eventTransfer: 'event:transfer', /** Main -> renderer, progress through a one-at-a-time open. */ @@ -372,6 +384,56 @@ export const FleetListRenameSchema = z.object({ to: FleetListNameSchema, }) +/** + * Plugins. + * + * Plugin code runs in the main process, never here in the renderer's reach, + * so these requests name a plugin and an action by id and the files by a + * directory plus bare entry names -- the same rule every file operation + * follows. A renderer cannot hand a plugin `../`, an absolute path of its own + * choosing, or a remote path: the directory must be an absolute local one and + * each name a single entry inside it. + */ +export const PluginIdSchema = z.string().regex(/^[a-z][a-z0-9-]{0,39}$/, 'That is not a plugin id.') +export const PluginPartIdSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/, 'That is not an action id.') + +/** An absolute local directory: `/home/me/photos`, `C:\\Users\\me`, or `~/photos`. */ +export const LocalDirectorySchema = PathSchema.refine( + (path) => path.startsWith('/') || path.startsWith('~') || /^[A-Za-z]:[\\/]/.test(path), + { message: 'A plugin works on an absolute local directory.' }, +).refine((path) => !path.includes('\0'), { message: 'A path cannot contain NUL.' }) + +export const PluginSelectionSchema = z.object({ + dir: LocalDirectorySchema, + names: z.array(EntryNameSchema).min(1).max(10000), +}) + +export const PluginActionRequestSchema = PluginSelectionSchema.extend({ + /** Chosen by the renderer, like a preview's, so events are never ahead of the id they belong to. */ + jobId: JobIdSchema, + pluginId: PluginIdSchema, + actionId: PluginPartIdSchema, +}) +export type PluginActionRequest = z.infer + +export const PluginTaskRequestSchema = z.object({ + jobId: JobIdSchema, + pluginId: PluginIdSchema, + taskId: PluginPartIdSchema, +}) + +/** + * Settings the renderer may write. Only keys the plugin declared are + * accepted (checked in the main process against the plugin's own list), and + * a secret is write-only: it can be set or cleared, never read back. + */ +export const PluginSettingsSetSchema = z.object({ + pluginId: PluginIdSchema, + values: z + .record(z.string().regex(/^[A-Za-z][A-Za-z0-9_.-]{0,63}$/), z.union([z.string().max(4096), z.boolean(), z.null()])) + .refine((values) => Object.keys(values).length <= 64, { message: 'Too many settings at once.' }), +}) + /** Only http(s) may be handed to the system browser. */ export const ExternalUrlSchema = z.string().url().refine((value) => /^https?:\/\//i.test(value), { message: 'Only http and https URLs can be opened externally.', diff --git a/apps/desktop/electron/shared/plugin-contract.test.ts b/apps/desktop/electron/shared/plugin-contract.test.ts new file mode 100644 index 0000000..f6035de --- /dev/null +++ b/apps/desktop/electron/shared/plugin-contract.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { + IPC, + LocalDirectorySchema, + PluginActionRequestSchema, + PluginSelectionSchema, + PluginSettingsSetSchema, + PluginTaskRequestSchema, +} from './contract.js' + +/** + * The plugin channels, as a compromised renderer would probe them. Plugin + * code runs in the main process with the user's privileges, so what the + * renderer can name here is exactly what it could make a plugin touch. + */ + +const JOB = '6f1c2f6e-3b1a-4c1e-9a55-0d2b7b0c8a11' +const request = (over: Record = {}) => ({ + jobId: JOB, + pluginId: 'mediaanalyzer', + actionId: 'analyze-sort', + dir: '/home/me/photos', + names: ['cat.jpg', '2024'], + ...over, +}) + +describe('plugin action requests', () => { + it('accept a local directory and bare entry names', () => { + expect(PluginActionRequestSchema.parse(request())).toEqual(request()) + }) + + it('reject a name that is really a path', () => { + for (const name of ['../etc/passwd', 'a/b', '..', '.', 'a\\b', 'x\0y', ' padded', '']) { + expect(PluginActionRequestSchema.safeParse(request({ names: [name] })).success, JSON.stringify(name)).toBe(false) + } + }) + + it('reject a relative or remote directory', () => { + for (const dir of ['photos', './photos', 'prod:/srv/photos', 'deploy@host:/srv', '', '/tmp/\0x']) { + expect(PluginActionRequestSchema.safeParse(request({ dir })).success, dir).toBe(false) + } + expect(LocalDirectorySchema.safeParse('C:\\Users\\me').success).toBe(true) + expect(LocalDirectorySchema.safeParse('~/photos').success).toBe(true) + }) + + it('reject an empty selection, a made-up id shape, and a job id that is not a uuid', () => { + expect(PluginActionRequestSchema.safeParse(request({ names: [] })).success).toBe(false) + expect(PluginActionRequestSchema.safeParse(request({ pluginId: '../x' })).success).toBe(false) + expect(PluginActionRequestSchema.safeParse(request({ actionId: 'Run Anything' })).success).toBe(false) + expect(PluginActionRequestSchema.safeParse(request({ jobId: 'job-1' })).success).toBe(false) + expect(PluginTaskRequestSchema.safeParse({ jobId: JOB, pluginId: 'mediaanalyzer', taskId: 'login' }).success).toBe(true) + }) + + it('drops fields the contract does not have: no command line, no env, no path list', () => { + const parsed = PluginActionRequestSchema.parse(request({ command: 'rm -rf ~', env: { X: '1' }, paths: ['/etc'] })) + expect(Object.keys(parsed).sort()).toEqual(['actionId', 'dir', 'jobId', 'names', 'pluginId']) + expect(Object.keys(PluginSelectionSchema.parse({ dir: '/a', names: ['b'], extra: 1 })).sort()).toEqual(['dir', 'names']) + }) +}) + +describe('plugin settings', () => { + it('take plain values under setting-shaped keys only', () => { + expect(PluginSettingsSetSchema.safeParse({ pluginId: 'mediaanalyzer', values: { tier: 'premium', api_key: null } }).success).toBe(true) + expect(PluginSettingsSetSchema.safeParse({ pluginId: 'mediaanalyzer', values: { '../x': 'y' } }).success).toBe(false) + expect(PluginSettingsSetSchema.safeParse({ pluginId: 'mediaanalyzer', values: { tier: { nested: true } } }).success).toBe(false) + expect(PluginSettingsSetSchema.safeParse({ pluginId: 'mediaanalyzer', values: { tier: 'x'.repeat(5000) } }).success).toBe(false) + }) +}) + +describe('channels', () => { + it('are the plugins: namespace the preload exposes by name', () => { + expect(IPC.pluginsList).toBe('plugins:list') + expect(IPC.pluginsRunAction).toBe('plugins:run-action') + expect(IPC.pluginsCancel).toBe('plugins:cancel') + expect(IPC.pluginsGetSettings).toBe('plugins:get-settings') + expect(IPC.pluginsSetSettings).toBe('plugins:set-settings') + expect(IPC.eventPlugin).toBe('plugins:progress') + }) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8d34c9c..38a229f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -17,6 +17,8 @@ "@base-ui/react": "^1.7.0", "@diskpush/database": "workspace:*", "@diskpush/fleet-core": "workspace:*", + "@diskpush/plugin-api": "workspace:*", + "@diskpush/plugin-mediaanalyzer": "workspace:*", "@diskpush/rsync-core": "workspace:*", "@diskpush/schemas": "workspace:*", "@diskpush/ssh-core": "workspace:*", diff --git a/apps/desktop/src/app/page.tsx b/apps/desktop/src/app/page.tsx index c8cf488..aed4f1a 100644 --- a/apps/desktop/src/app/page.tsx +++ b/apps/desktop/src/app/page.tsx @@ -10,6 +10,7 @@ import { FileDown, MonitorOff, Plus, + Puzzle, Server, Settings, Users, @@ -17,6 +18,7 @@ import { } from 'lucide-react' import { ConnectionDialog } from '@/components/connection-dialog' import { FleetView } from '@/components/fleet-view' +import { PluginsDialog } from '@/components/plugins-dialog' import { ProfileBar } from '@/components/profile-bar' import { ServerManager } from '@/components/server-manager' import { endpointLabel, loadPane, Pane, type PaneEndpoint, type PaneState } from '@/components/pane' @@ -28,6 +30,8 @@ import { api, unwrap, type Connection, + type PluginActionChoice, + type PluginEvent, type PreviewProgress, type PreviewResult, type SyncProfile, @@ -127,6 +131,9 @@ export default function Workspace() { const [error, setError] = useState(null) const [showConnection, setShowConnection] = useState(false) const [showServers, setShowServers] = useState(false) + const [showPlugins, setShowPlugins] = useState(false) + /** Which pane a plugin job is working in, so the pane is re-read when it changes files. */ + const pluginSideRef = useRef<{ jobId: string; side: 'left' | 'right' } | null>(null) const [tab, setTab] = useState<'transfer' | 'fleet'>('transfer') const [profiles, setProfiles] = useState([]) const [outsideShell, setOutsideShell] = useState(false) @@ -218,6 +225,63 @@ export default function Workspace() { return bridge.events.onTransfer(({ jobId, event }) => setJob((current) => reduceJob(current, jobId, event))) }, []) + // The panes as of the last render, for an event handler that outlives it. + const panesRef = useRef({ left, right }) + panesRef.current = { left, right } + + useEffect(() => { + const bridge = api() + if (!bridge) return + return bridge.events.onPluginProgress(({ jobId, event }) => { + setJob((current) => reducePluginJob(current, jobId, event)) + const running = pluginSideRef.current + if (event.type === 'exit' && running?.jobId === jobId) { + pluginSideRef.current = null + if (event.changed) { + const pane = panesRef.current[running.side] + void navigate(running.side, pane.endpoint, pane.path) + } + } + }) + }, [navigate]) + + /** + * Runs a plugin action from a pane's right-click menu. The work happens in + * the main process; its progress comes back as events and takes the + * transfer band, the one place in the window that already shows a job. + */ + const runPluginAction = useCallback( + async (side: 'left' | 'right', choice: PluginActionChoice, dir: string, names: string[]) => { + const jobId = crypto.randomUUID() + pluginSideRef.current = { jobId, side } + setError(null) + setJob({ + jobId, + kind: 'plugin', + title: choice.label, + subject: `${names.length === 1 ? names[0] : `${names.length} items`} in ${dir}`, + total: null, + percent: 0, + bytesTransferred: 0, + bytesPerSecond: 0, + files: 0, + currentFile: '', + elapsedSeconds: 0, + finished: false, + resumable: false, + message: '', + }) + try { + await unwrap(api()?.plugins.runAction(jobId, choice.pluginId, choice.actionId, dir, names)) + } catch (caught) { + pluginSideRef.current = null + setJob(null) + setError(caught instanceof Error ? caught.message : String(caught)) + } + }, + [], + ) + useEffect(() => { const bridge = api() if (!bridge) return @@ -550,6 +614,7 @@ export default function Workspace() { onClick={() => void importSshConfig()} label="Import from ~/.ssh/config" /> + } onClick={() => setShowPlugins(true)} label="Plugins…" />
} @@ -612,6 +677,7 @@ export default function Workspace() { onNavigate={(path) => void navigate('left', left.endpoint, path)} onEndpointChange={(endpoint) => setLeft(blankPane(endpoint, defaultPathFor(endpoint, allConnections)))} onAddServer={() => setShowConnection(true)} + onPluginAction={(choice, dir, names) => void runPluginAction('left', choice, dir, names)} /> void navigate('right', right.endpoint, path)} onEndpointChange={(endpoint) => setRight(blankPane(endpoint, defaultPathFor(endpoint, allConnections)))} onAddServer={() => setShowConnection(true)} + onPluginAction={(choice, dir, names) => void runPluginAction('right', choice, dir, names)} />
{ - if (job) void api()?.transfers.cancel(job.jobId) + if (job?.kind === 'plugin') void api()?.plugins.cancel(job.jobId) + else if (job) void api()?.transfers.cancel(job.jobId) }} /> @@ -686,6 +754,8 @@ export default function Workspace() { onChanged={() => void refreshConnections()} /> + setShowPlugins(false)} /> + setShowConnection(false)} onSaved={() => void refreshConnections()} /> ({ + files: done, + total: total ?? null, + percent: total ? Math.min(100, Math.round((done / total) * 100)) : current.percent, + }) + switch (event.type) { + case 'start': + return { ...current, ...withCount(0, event.total) } + case 'update': + return { + ...current, + ...withCount(event.done ?? current.files, event.total ?? current.total), + currentFile: event.currentFile ?? event.message ?? current.currentFile, + } + case 'exit': + return { + ...current, + finished: true, + ok: event.ok, + percent: event.ok ? 100 : current.percent, + message: event.message, + } + default: + return current + } +} + function reduceJob(current: ActiveJob | null, jobId: string, event: TransferEvent): ActiveJob | null { if (!current || current.jobId !== jobId) return current switch (event.type) { diff --git a/apps/desktop/src/components/pane.tsx b/apps/desktop/src/components/pane.tsx index 77bf9d2..0ba9520 100644 --- a/apps/desktop/src/components/pane.tsx +++ b/apps/desktop/src/components/pane.tsx @@ -14,13 +14,14 @@ import { FolderPlus, Link2, PenLine, + Puzzle, RefreshCw, Search, ServerCrash, SearchX, Trash2, } from 'lucide-react' -import { api, unwrap, type Connection, type FileEntry } from '@/lib/api' +import { api, unwrap, type Connection, type FileEntry, type PluginActionChoice } from '@/lib/api' import { DEFAULT_SORT, isNavigable, @@ -272,6 +273,7 @@ export function Pane({ onEndpointChange, onAddServer, onRefreshHosts, + onPluginAction, }: { role: 'Source' | 'Destination' state: PaneState @@ -284,6 +286,8 @@ export function Pane({ onEndpointChange: (endpoint: PaneEndpoint) => void onAddServer: () => void onRefreshHosts?: () => void + /** Runs a plugin action on these entries of this (local) directory. */ + onPluginAction?: (choice: PluginActionChoice, dir: string, names: string[]) => void }) { const [filter, setFilter] = useState('') const [showHidden, setShowHidden] = useState(false) @@ -324,6 +328,33 @@ export function Pane({ const canOpenWith = openWithFiles.length > 0 const [opError, setOpError] = useState(null) + /* + * Plugin actions for what the menu was opened on: the selection when the + * row is part of it, else that row, exactly as Open with decides. Asked of + * the main process as the menu opens, because whether an action applies is + * the plugin's call and plugin code never runs in here. Local panes only: + * a plugin works on files this machine has. + */ + const [pluginMenu, setPluginMenu] = useState<{ names: string[]; choices: PluginActionChoice[] } | null>(null) + const pluginRequest = useRef(0) + const askPlugins = useCallback( + (entry: FileEntry | null) => { + const ticket = ++pluginRequest.current + setPluginMenu(null) + if (!onPluginAction || state.endpoint.kind !== 'local' || !entry) return + const names = + state.selected.has(entry.name) && state.selected.size > 1 ? [...state.selected] : [entry.name] + void unwrap(api()?.plugins.actionsFor(state.path, names)) + .then((choices) => { + if (pluginRequest.current === ticket) setPluginMenu({ names, choices }) + }) + .catch(() => { + // No plugin menu is a smaller problem than an error over a right-click. + }) + }, + [onPluginAction, state.endpoint.kind, state.path, state.selected], + ) + useEffect(() => { setFilter('') setCursor(0) @@ -434,11 +465,12 @@ export function Pane({ const aimAt = useCallback( (entry: FileEntry | null, index: number) => { setTarget(entry) + askPlugins(entry) if (!entry) return setCursor(index) if (!state.selected.has(entry.name)) onChange({ selected: new Set([entry.name]) }) }, - [onChange, state.selected], + [askPlugins, onChange, state.selected], ) /** @@ -601,7 +633,10 @@ export function Pane({ // unconditionally left Rename and Delete greyed out on every // row, which is exactly how it shipped in the first draft. onContextMenu={(event: React.MouseEvent) => { - if (!(event.target as HTMLElement).closest('[data-row]')) setTarget(null) + if (!(event.target as HTMLElement).closest('[data-row]')) { + setTarget(null) + askPlugins(null) + } }} className="focus-ring h-full outline-none" /> @@ -722,6 +757,24 @@ export function Pane({ {/* Says how many, so a menu opened over a selection is not a guess. */} {openWithFiles.length > 1 ? `Open ${openWithFiles.length} files with…` : 'Open with…'} + {pluginMenu && pluginMenu.choices.length > 0 ? ( + <> + +
+ Plugins +
+ {pluginMenu.choices.map((choice) => ( + onPluginAction?.(choice, state.path, pluginMenu.names)} + > + + {pluginMenu.names.length > 1 ? `${choice.label} (${pluginMenu.names.length})` : choice.label} + + ))} + + ) : null} onNavigate(state.path)}> diff --git a/apps/desktop/src/components/plugins-dialog.tsx b/apps/desktop/src/components/plugins-dialog.tsx new file mode 100644 index 0000000..99574ba --- /dev/null +++ b/apps/desktop/src/components/plugins-dialog.tsx @@ -0,0 +1,313 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' +import { CircleAlert, Puzzle, ShieldAlert } from 'lucide-react' +import { + api, + unwrap, + type PluginEvent, + type PluginSettingDef, + type PluginSettingsState, + type PluginSummary, +} from '@/lib/api' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { ScrollArea } from '@/components/ui/scroll-area' +import { cn } from '@/lib/utils' + +type Draft = Record + +/** One setting's control. A secret is write-only: typed in, never shown back. */ +function SettingField({ + def, + value, + secretSet, + onChange, +}: { + def: PluginSettingDef + value: string | boolean | undefined + secretSet: boolean + onChange: (value: string | boolean) => void +}) { + const id = `plugin-setting-${def.key}` + if (def.type === 'boolean') { + return ( + + ) + } + return ( +
+ + {def.type === 'enum' ? ( + + ) : ( + onChange(event.target.value)} + className="h-[var(--control)] text-[12px]" + /> + )} + {def.description ?

{def.description}

: null} +
+ ) +} + +/** + * One plugin: on or off, what it says about itself (signed in as whom), its + * tasks as buttons, and its settings. + */ +function PluginCard({ plugin, onToggled }: { plugin: PluginSummary; onToggled: () => void }) { + const [state, setState] = useState(null) + const [draft, setDraft] = useState({}) + const [touched, setTouched] = useState>(new Set()) + const [message, setMessage] = useState<{ text: string; tone: 'ok' | 'error' | 'info' } | null>(null) + const [task, setTask] = useState<{ jobId: string; label: string } | null>(null) + const taskRef = useRef(null) + + const load = useCallback(async () => { + try { + const next = await unwrap(api()?.plugins.getSettings(plugin.id)) + setState(next) + setDraft(next.values) + setTouched(new Set()) + } catch (caught) { + setMessage({ text: caught instanceof Error ? caught.message : String(caught), tone: 'error' }) + } + }, [plugin.id]) + + useEffect(() => { + void load() + }, [load, plugin.enabled]) + + // A task (signing in) runs in the main process and ends with an exit event. + useEffect(() => { + const bridge = api() + if (!bridge) return + return bridge.events.onPluginProgress(({ jobId, event }: { jobId: string; event: PluginEvent }) => { + if (jobId !== taskRef.current) return + if (event.type === 'update' && event.message) setMessage({ text: event.message, tone: 'info' }) + if (event.type === 'exit') { + taskRef.current = null + setTask(null) + setMessage({ text: event.message, tone: event.ok ? 'ok' : 'error' }) + void load() + } + }) + }, [load]) + + const runTask = async (taskId: string, label: string) => { + const jobId = crypto.randomUUID() + taskRef.current = jobId + setTask({ jobId, label }) + setMessage(null) + try { + await unwrap(api()?.plugins.runTask(jobId, plugin.id, taskId)) + } catch (caught) { + taskRef.current = null + setTask(null) + setMessage({ text: caught instanceof Error ? caught.message : String(caught), tone: 'error' }) + } + } + + const save = async () => { + const values: Record = {} + for (const key of touched) { + const def = plugin.settings.find((candidate) => candidate.key === key) + const value = draft[key] + if (!def || value === undefined) continue + values[key] = def.type === 'secret' && value === '' ? null : value + } + try { + await unwrap(api()?.plugins.setSettings(plugin.id, values)) + setMessage({ text: 'Saved.', tone: 'ok' }) + await load() + } catch (caught) { + setMessage({ text: caught instanceof Error ? caught.message : String(caught), tone: 'error' }) + } + } + + const toggle = async (enabled: boolean) => { + try { + await unwrap(api()?.plugins.setEnabled(plugin.id, enabled)) + onToggled() + } catch (caught) { + setMessage({ text: caught instanceof Error ? caught.message : String(caught), tone: 'error' }) + } + } + + return ( +
+
+ + + +
+

+ {plugin.name} + {plugin.version} + {plugin.source === 'external' ? ( + external + ) : null} +

+

{plugin.description}

+ {plugin.enabled && state?.status ?

{state.status}

: null} +
+ +
+ + {plugin.enabled ? ( + <> + {plugin.tasks.length > 0 ? ( +
+ {plugin.tasks.map((item, index) => ( + + ))} + {task ? ( + + ) : null} +
+ ) : null} + + {plugin.settings.length > 0 ? ( +
+ {plugin.settings.map((def) => ( + { + setDraft((current) => ({ ...current, [def.key]: value })) + setTouched((current) => new Set(current).add(def.key)) + }} + /> + ))} +
+ +
+
+ ) : null} + + ) : null} + + {message ? ( +

+ {message.text} +

+ ) : null} +
+ ) +} + +/** + * Plugins…: what is installed, on and off, and each one's settings. + * + * Plugins run in the main process with your privileges; the note at the top + * says so, because an external plugin is a program, not a theme. + */ +export function PluginsDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const [plugins, setPlugins] = useState([]) + const [failures, setFailures] = useState<{ name: string; error: string }[]>([]) + const [error, setError] = useState(null) + + const refresh = useCallback(async () => { + try { + const result = await unwrap(api()?.plugins.list()) + setPlugins(result.plugins) + setFailures(result.failures) + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)) + } + }, []) + + useEffect(() => { + if (open) void refresh() + }, [open, refresh]) + + return ( + (next ? undefined : onClose())}> + + + + + + Plugins + + +
+
+ + + Plugins run inside DiskPush with your privileges. Their actions are in a local file's right-click + menu, and as diskpush <plugin> commands in the CLI. + +
+ {error ? ( +

+ {error} +

+ ) : null} + {plugins.map((plugin) => ( + void refresh()} /> + ))} + {failures.map((failure) => ( +

+ + + {failure.name} did not load: {failure.error} + +

+ ))} +
+
+
+
+ ) +} diff --git a/apps/desktop/src/components/transfer-panel.tsx b/apps/desktop/src/components/transfer-panel.tsx index 1cbb329..b930263 100644 --- a/apps/desktop/src/components/transfer-panel.tsx +++ b/apps/desktop/src/components/transfer-panel.tsx @@ -21,6 +21,17 @@ export type ActiveJob = { finished: boolean resumable: boolean message: string + /** + * A plugin action rather than an rsync transfer. It has no bytes or rate + * to show; `files` counts files done out of `total`, and `ok` is how it + * ended. + */ + kind?: 'transfer' | 'plugin' + title?: string + /** What a plugin job is working on, shown where a transfer shows its route. */ + subject?: string + total?: number | null + ok?: boolean } /** @@ -400,8 +411,9 @@ export function TransferBand({ job.percent > 0 && job.percent < 100 && job.elapsedSeconds > 0 ? (job.elapsedSeconds / job.percent) * (100 - job.percent) : null - const failed = job.finished && job.resumable - const done = job.finished && !job.resumable + const plugin = job.kind === 'plugin' + const failed = job.finished && (plugin ? job.ok === false : job.resumable) + const done = job.finished && !failed return (
@@ -421,14 +433,14 @@ export function TransferBand({ )} - {done ? 'Finished' : failed ? 'Interrupted' : 'Transferring'} + {done ? 'Finished' : failed ? (plugin ? 'Failed' : 'Interrupted') : plugin ? (job.title ?? 'Working') : 'Transferring'} {route}
- {formatBytes(job.bytesTransferred)} - {formatRate(job.bytesPerSecond)} - {remaining !== null ? ( + {plugin ? null : {formatBytes(job.bytesTransferred)}} + {plugin ? null : {formatRate(job.bytesPerSecond)}} + {remaining !== null && !plugin ? ( ETA {formatDuration(remaining)} @@ -468,7 +480,9 @@ export function TransferBand({ {job.finished ? job.message : job.currentFile || 'scanning…'} - {job.files.toLocaleString()} files + + {plugin && job.total ? `${job.files.toLocaleString()} of ${job.total.toLocaleString()}` : job.files.toLocaleString()} files +
) diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 5c19e99..b0dc831 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -267,6 +267,50 @@ export type FleetRequest = { label: string } +/** A setting a plugin declared, as its settings dialog draws it. */ +export type PluginSettingDef = { + key: string + label: string + description?: string + type: 'string' | 'enum' | 'boolean' | 'secret' + options?: string[] + default?: string | boolean +} + +export type PluginSummary = { + id: string + name: string + version: string + description: string + source: 'builtin' | 'external' + enabled: boolean + settings: PluginSettingDef[] + tasks: { id: string; label: string; description: string }[] + actions: { id: string; label: string; description: string }[] +} + +/** A plugin action that applies to the selection a menu was opened on. */ +export type PluginActionChoice = { + pluginId: string + pluginName: string + actionId: string + label: string + description: string +} + +/** A plugin's settings as the main process reports them: secrets as set or not, never their values. */ +export type PluginSettingsState = { + status: string | null + values: Record + secrets: Record +} + +export type PluginEvent = + | { type: 'start'; total: number | null } + | { type: 'update'; done?: number; total?: number; message?: string; currentFile?: string } + | { type: 'log'; level: 'info' | 'warn' | 'error'; message: string } + | { type: 'exit'; ok: boolean; message: string; changed: boolean; cancelled: boolean } + type Api = { connections: { list(): Promise> @@ -334,11 +378,22 @@ type Api = { removeList(name: string): Promise> } shell: { openExternal(url: string): Promise> } + plugins: { + list(): Promise> + actionsFor(dir: string, names: string[]): Promise> + runAction(jobId: string, pluginId: string, actionId: string, dir: string, names: string[]): Promise> + runTask(jobId: string, pluginId: string, taskId: string): Promise> + cancel(jobId: string): Promise> + getSettings(pluginId: string): Promise> + setSettings(pluginId: string, values: Record): Promise> + setEnabled(pluginId: string, enabled: boolean): Promise> + } events: { onTransfer(listener: (payload: { jobId: string; event: TransferEvent }) => void): () => void onFleet(listener: (payload: { runId: string; event: FleetEvent }) => void): () => void onPreview(listener: (payload: { previewId: string; progress: PreviewProgress }) => void): () => void onOpenSeries(listener: (payload: { seriesId: string; event: SeriesEvent }) => void): () => void + onPluginProgress(listener: (payload: { jobId: string; event: PluginEvent }) => void): () => void } } diff --git a/docs/architecture.md b/docs/architecture.md index 62f17ef..a2c58e0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,6 +8,8 @@ packages/rsync-core Argument builder, execution planner, parsers, runner. packages/ssh-core SSH sessions, SFTP browsing, host keys, preflight. packages/fleet-core One command across many servers: selection, guard, runner. packages/database The local store, shared by every surface. +packages/plugin-api The plugin contract, registry and external-plugin loader. +packages/plugin-mediaanalyzer The built-in MediaAnalyzer plugin. apps/cli The `diskpush` command. apps/desktop Electron main, preload, and the renderer. apps/web diskpush.com. @@ -18,6 +20,10 @@ takes endpoints and options and produces a command and a stream of events. That boundary is what lets the same engine back a daemon, an HTTP API or an MCP tool later without being rewritten. +Plugins are hosted by the CLI process and by the desktop's main process, never +by the renderer; `plugin-api` depends on nothing else in the repository, so a +plugin is written against the contract alone. See [plugins.md](plugins.md). + `fleet-core` is the same idea one layer across: it takes connections and a script and produces a stream of per-host events. It knows how to open a session only through a `connect` function the caller supplies, which is why diff --git a/docs/cli.md b/docs/cli.md index c794cac..3c6c6bd 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -175,6 +175,22 @@ There is no `diskpush cancel`. A CLI transfer runs in the foreground, where Ctrl+C stops it and leaves the partial data intact; cancelling someone else's job would need a background daemon, which does not exist yet. +### Plugins + +```bash +diskpush plugins [--json] # installed plugins and their commands +diskpush plugins enable|disable ID +diskpush plugins add NPM-PACKAGE # external; runs with your privileges +diskpush plugins remove ID +diskpush COMMAND [ARGS] # e.g. diskpush mediaanalyzer analyze ./photos --sort +``` + +A first word that is neither a command nor a path is looked up as a plugin id. +Everything after the id is the plugin's own: DiskPush's flags are not parsed +there, except `--json`, `--quiet` and `--no-progress`, which keep their usual +meaning. Ctrl+C cancels a plugin command cleanly; a second Ctrl+C exits at +once. A disabled plugin's commands exit 65. See [plugins.md](plugins.md). + ## Options | Option | Effect | diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..0847dab --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,226 @@ +# Plugins + +A plugin adds things DiskPush does to files: a command in the CLI, an entry +in the TUI's `a` menu and in the desktop app's right-click menu, and settings +of its own. The first one is **MediaAnalyzer**, which describes photos and +videos and can sort them into folders by what they show. + +```bash +diskpush plugins # what is installed, and its commands +diskpush plugins disable mediaanalyzer # and back on with: enable +diskpush mediaanalyzer login # a plugin's own commands +diskpush mediaanalyzer analyze ~/Pictures/2024 --sort +``` + +| Surface | Where plugin actions are | +| --- | --- | +| CLI | `diskpush ...` | +| TUI | `a` on a local file or folder, then enter, the action's letter, or one click | +| Desktop | right-click a local selection → **Plugins**; settings and sign-in under the header menu → **Plugins…** | + +Actions work on local files only. A pane pointed at a server has nothing on +this machine to hand a plugin; sync the files down first. + +## MediaAnalyzer + +[MediaAnalyzer](https://mediaanalyzer.pro) looks at each photo (and, with +ffmpeg installed, each video) and returns a description, tags and a category. +DiskPush writes those beside the file and can file it into a folder named for +the category. + +```bash +diskpush mediaanalyzer login # opens your browser; approve DiskPush +diskpush mediaanalyzer whoami # who, how much credit, which tiers are online +diskpush mediaanalyzer analyze DIR # describe everything under DIR +diskpush mediaanalyzer analyze DIR --sort +diskpush mediaanalyzer undo DIR # put back what the last --sort moved +diskpush mediaanalyzer logout +``` + +**What it writes.** For `beach.jpg`, a `beach.jpg.description.txt`: + +```text +Two children building a sandcastle at low tide. + +Folder: Beach +Tags: beach, children, sandcastle +Described by MediaAnalyzer (mediaanalyzer.pro) +``` + +The last line is the signature. A `.description.txt` without it is yours, and +is never overwritten; one with it is, because DiskPush wrote it. + +**Sorting.** *Analyze and sort into folders* (`--sort`) moves each file and +its description into `DIR//`. Nothing is ever overwritten: a name +that is taken becomes `beach (2).jpg`. Every move is written to a journal in +`DIR/.mediaanalyzer/undo-