From ec7f41d365a0a0271acfbc139b8f93e7dbfffeab Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 06:17:51 +0000 Subject: [PATCH 1/6] Plugin API, and MediaAnalyzer as the first plugin @diskpush/plugin-api is the contract: a plugin is an object with commands (diskpush ...), file actions (TUI and desktop menus), tasks (sign in), and settings, all handed one context whatever the surface. The registry tracks which are disabled in the shared settings table (plugins.disabled), namespaces each plugin's settings and secrets, and validates entry names so a host cannot hand a plugin a path outside the directory. External plugins load from /plugins, only when listed there and marked as DiskPush plugins; `add` installs with --ignore-scripts. @diskpush/plugin-mediaanalyzer signs in with OAuth 2.1 + PKCE over a loopback redirect as the `diskpush` client (rotating refresh tokens, stored the moment they arrive, one refresh at a time), uploads originals in batches of at most 50, syncs results by finished_after, writes signed .description.txt sidecars, and optionally sorts into category folders with an undo journal. Resumable from a state file beside the media, so a retry never pays twice. Both are in release.mjs MANIFESTS at 0.11.0. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/plugin-api/package.json | 20 + packages/plugin-api/src/external.test.ts | 100 +++++ packages/plugin-api/src/external.ts | 173 ++++++++ packages/plugin-api/src/index.ts | 3 + packages/plugin-api/src/registry.test.ts | 178 ++++++++ packages/plugin-api/src/registry.ts | 386 ++++++++++++++++++ packages/plugin-api/src/types.ts | 196 +++++++++ packages/plugin-api/tsconfig.json | 6 + packages/plugin-mediaanalyzer/package.json | 23 ++ packages/plugin-mediaanalyzer/src/analyze.ts | 373 +++++++++++++++++ packages/plugin-mediaanalyzer/src/apply.ts | 231 +++++++++++ packages/plugin-mediaanalyzer/src/client.ts | 223 ++++++++++ .../src/fake-server.fixture.ts | 264 ++++++++++++ packages/plugin-mediaanalyzer/src/index.ts | 338 +++++++++++++++ packages/plugin-mediaanalyzer/src/library.ts | 121 ++++++ packages/plugin-mediaanalyzer/src/media.ts | 103 +++++ packages/plugin-mediaanalyzer/src/oauth.ts | 238 +++++++++++ .../plugin-mediaanalyzer/src/plugin.test.ts | 315 ++++++++++++++ packages/plugin-mediaanalyzer/tsconfig.json | 6 + pnpm-lock.yaml | 8 + scripts/release.mjs | 2 + 21 files changed, 3307 insertions(+) create mode 100644 packages/plugin-api/package.json create mode 100644 packages/plugin-api/src/external.test.ts create mode 100644 packages/plugin-api/src/external.ts create mode 100644 packages/plugin-api/src/index.ts create mode 100644 packages/plugin-api/src/registry.test.ts create mode 100644 packages/plugin-api/src/registry.ts create mode 100644 packages/plugin-api/src/types.ts create mode 100644 packages/plugin-api/tsconfig.json create mode 100644 packages/plugin-mediaanalyzer/package.json create mode 100644 packages/plugin-mediaanalyzer/src/analyze.ts create mode 100644 packages/plugin-mediaanalyzer/src/apply.ts create mode 100644 packages/plugin-mediaanalyzer/src/client.ts create mode 100644 packages/plugin-mediaanalyzer/src/fake-server.fixture.ts create mode 100644 packages/plugin-mediaanalyzer/src/index.ts create mode 100644 packages/plugin-mediaanalyzer/src/library.ts create mode 100644 packages/plugin-mediaanalyzer/src/media.ts create mode 100644 packages/plugin-mediaanalyzer/src/oauth.ts create mode 100644 packages/plugin-mediaanalyzer/src/plugin.test.ts create mode 100644 packages/plugin-mediaanalyzer/tsconfig.json diff --git a/packages/plugin-api/package.json b/packages/plugin-api/package.json new file mode 100644 index 0000000..2abb80b --- /dev/null +++ b/packages/plugin-api/package.json @@ -0,0 +1,20 @@ +{ + "name": "@diskpush/plugin-api", + "version": "0.11.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + } +} diff --git a/packages/plugin-api/src/external.test.ts b/packages/plugin-api/src/external.test.ts new file mode 100644 index 0000000..3991fcd --- /dev/null +++ b/packages/plugin-api/src/external.test.ts @@ -0,0 +1,100 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + PluginRegistry, + addExternalPlugin, + entryOf, + installedPackages, + loadExternalPlugins, + removeExternalPlugin, + type Npm, +} from './index.js' + +const PLUGIN_SOURCE = (id: string) => `export default { + id: ${JSON.stringify(id)}, + name: 'Hello', + version: '0.1.0', + description: 'says hello', + commands: [{ name: 'hi', summary: 'hi', usage: 'hi', run: async () => 0 }], +} +` + +function writePackage(dir: string, name: string, manifest: Record, source: string) { + const packageDir = join(dir, 'node_modules', name) + mkdirSync(packageDir, { recursive: true }) + writeFileSync(join(packageDir, 'package.json'), JSON.stringify({ name, version: '0.1.0', type: 'module', ...manifest })) + writeFileSync(join(packageDir, 'index.js'), source) +} + +function project(dependencies: Record) { + const dir = mkdtempSync(join(tmpdir(), 'dp-plugins-')) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ private: true, dependencies })) + return dir +} + +/** npm, as far as these tests need it: edits package.json and node_modules. */ +function fakeNpm(install: (dir: string, spec: string) => void): { npm: Npm; calls: string[][] } { + const calls: string[][] = [] + const npm: Npm = async (args, cwd) => { + calls.push(args) + const manifest = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8')) + const spec = args.at(-1)! + if (args[0] === 'install') { + install(cwd, spec) + manifest.dependencies = { ...manifest.dependencies, [spec]: '^0.1.0' } + } else { + delete manifest.dependencies[spec] + rmSync(join(cwd, 'node_modules', spec), { recursive: true, force: true }) + } + writeFileSync(join(cwd, 'package.json'), JSON.stringify(manifest)) + } + return { npm, calls } +} + +describe('external plugins', () => { + it('loads only listed packages that declare themselves plugins, and reports the rest', async () => { + const dir = project({ 'dp-hello': '^0.1.0', 'left-pad': '^1.0.0', missing: '^1.0.0' }) + writePackage(dir, 'dp-hello', { diskpush: { apiVersion: 1 } }, PLUGIN_SOURCE('hello')) + writePackage(dir, 'left-pad', {}, 'export default {}') + // Not in package.json, so never loaded however plugin-shaped it is. + writePackage(dir, 'stray', { diskpush: {} }, PLUGIN_SOURCE('stray')) + + const registry = new PluginRegistry() + const failures = await loadExternalPlugins(registry, dir) + expect(registry.all().map((plugin) => plugin.id)).toEqual(['hello']) + expect((await registry.list())[0]?.source).toBe('external') + expect(failures.map((failure) => failure.name).sort()).toEqual(['left-pad', 'missing']) + expect(failures.find((failure) => failure.name === 'left-pad')?.error).toMatch(/no "diskpush" field/) + }) + + it('is a no-op when nothing was ever installed', async () => { + const registry = new PluginRegistry() + expect(await loadExternalPlugins(registry, join(tmpdir(), 'dp-never-created'))).toEqual([]) + }) + + it('refuses an entry point outside the package', () => { + expect(() => entryOf('/p/node_modules/x', { name: 'x', main: '../../evil.js' })).toThrow(/leaves the package/) + expect(entryOf('/p/node_modules/x', { exports: { '.': { import: './dist/i.js' } } })).toBe('/p/node_modules/x/dist/i.js') + }) + + it('adds with --ignore-scripts, and uninstalls a package that turns out not to be a plugin', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dp-plugins-')) + const good = fakeNpm((cwd, spec) => writePackage(cwd, spec, { diskpush: {} }, PLUGIN_SOURCE('hello'))) + const added = await addExternalPlugin(join(dir, 'plugins'), 'dp-hello', good.npm) + expect(added.plugin.id).toBe('hello') + expect(good.calls[0]).toContain('--ignore-scripts') + expect(await installedPackages(join(dir, 'plugins'))).toEqual(['dp-hello']) + + const bad = fakeNpm((cwd, spec) => writePackage(cwd, spec, {}, 'export default {}')) + await expect(addExternalPlugin(join(dir, 'plugins'), 'not-a-plugin', bad.npm)).rejects.toThrow(/not a DiskPush plugin/) + expect(bad.calls.map((call) => call[0])).toEqual(['install', 'uninstall']) + expect(await installedPackages(join(dir, 'plugins'))).toEqual(['dp-hello']) + + await removeExternalPlugin(join(dir, 'plugins'), 'dp-hello', good.npm) + expect(await installedPackages(join(dir, 'plugins'))).toEqual([]) + await expect(addExternalPlugin(dir, '--global', good.npm)).rejects.toThrow(/cannot start/) + }) +}) diff --git a/packages/plugin-api/src/external.ts b/packages/plugin-api/src/external.ts new file mode 100644 index 0000000..c5a4d5f --- /dev/null +++ b/packages/plugin-api/src/external.ts @@ -0,0 +1,173 @@ +/** + * Plugins that are not built in: npm packages installed into + * `/plugins`, which is a tiny npm project of its own. + * + * ~/.config/diskpush/plugins/package.json dependencies = installed plugins + * ~/.config/diskpush/plugins/node_modules/ the code + * + * An external plugin is ordinary JavaScript running inside the CLI process or + * the desktop app's main process, with every privilege the user has. Nothing + * here sandboxes it, and nothing could: installing one is the same decision as + * `npm install -g`. What this module does guarantee is narrower: + * + * - only packages listed in that package.json are loaded, never a stray + * directory under node_modules; + * - a package must say it is a DiskPush plugin (`"diskpush": {...}` in its + * package.json), so installing a dependency by mistake loads nothing; + * - install runs with `--ignore-scripts`, so adding a plugin does not run + * code until DiskPush itself loads it; + * - one plugin failing to load is reported and skipped, never fatal. + */ +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join, relative, resolve, sep } from 'node:path' +import { pathToFileURL } from 'node:url' +import { PluginError, type PluginRegistry } from './registry.js' +import type { DiskpushPlugin } from './types.js' + +export function pluginsDirectory(diskpushHome: string): string { + return join(diskpushHome, 'plugins') +} + +type Manifest = { + name?: string + version?: string + main?: string + exports?: unknown + dependencies?: Record + diskpush?: unknown +} + +async function readManifest(path: string): Promise { + try { + return JSON.parse(await readFile(path, 'utf8')) as Manifest + } catch { + return null + } +} + +/** The names installed, from the plugins project's own package.json. */ +export async function installedPackages(dir: string): Promise { + const manifest = await readManifest(join(dir, 'package.json')) + return Object.keys(manifest?.dependencies ?? {}).sort() +} + +/** The ESM entry of a package, from `exports` then `main`; refused if it points outside the package. */ +export function entryOf(packageDir: string, manifest: Manifest): string { + const pick = (value: unknown): string | null => { + if (typeof value === 'string') return value + if (value && typeof value === 'object') { + const record = value as Record + for (const key of ['.', 'import', 'node', 'default']) { + const found = key in record ? pick(record[key]) : null + if (found) return found + } + } + return null + } + const target = pick(manifest.exports) ?? manifest.main ?? 'index.js' + const full = resolve(packageDir, target) + const rel = relative(packageDir, full) + if (rel.startsWith('..') || rel.startsWith(sep) || resolve(full) === resolve(packageDir)) { + throw new PluginError(`${manifest.name ?? packageDir}: its entry point leaves the package.`) + } + return full +} + +export type LoadFailure = { name: string; error: string } + +/** + * Imports every installed plugin and registers it. Returns what failed, for + * the caller to report however its surface reports things. + */ +export async function loadExternalPlugins(registry: PluginRegistry, dir: string): Promise { + if (!existsSync(join(dir, 'package.json'))) return [] + const failures: LoadFailure[] = [] + for (const name of await installedPackages(dir)) { + try { + const plugin = await importPlugin(dir, name) + registry.register(plugin, 'external', join(dir, 'node_modules', name)) + } catch (error) { + failures.push({ name, error: error instanceof Error ? error.message : String(error) }) + } + } + return failures +} + +export async function importPlugin(dir: string, name: string): Promise { + if (!/^(@[a-z0-9._~-]+\/)?[a-z0-9._~-]+$/i.test(name)) throw new PluginError(`${name} is not a package name.`) + const packageDir = join(dir, 'node_modules', name) + const manifest = await readManifest(join(packageDir, 'package.json')) + if (!manifest) throw new PluginError(`${name} is listed but not installed. Run: diskpush plugins add ${name}`) + if (!manifest.diskpush || typeof manifest.diskpush !== 'object') { + throw new PluginError(`${name} is not a DiskPush plugin: its package.json has no "diskpush" field.`) + } + const module = (await import(pathToFileURL(entryOf(packageDir, manifest)).href)) as { + default?: unknown + plugin?: unknown + } + const plugin = (module.default ?? module.plugin) as DiskpushPlugin | undefined + if (!plugin || typeof plugin !== 'object') { + throw new PluginError(`${name} does not export a plugin (default export, or \`export const plugin\`).`) + } + return plugin +} + +export type Npm = (args: string[], cwd: string) => Promise + +/** Runs the npm on PATH. The desktop bundle carries no npm, so this is the CLI's. */ +export const systemNpm: Npm = (args, cwd) => + new Promise((resolvePromise, reject) => { + execFile( + process.platform === 'win32' ? 'npm.cmd' : 'npm', + args, + { cwd, maxBuffer: 16 * 1024 * 1024, shell: process.platform === 'win32' }, + (error, _stdout, stderr) => { + if (!error) return resolvePromise() + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT') reject(new PluginError('Installing a plugin needs npm on your PATH.')) + else reject(new PluginError(stderr.trim().split('\n').slice(-3).join('\n') || error.message)) + }, + ) + }) + +async function ensureProject(dir: string): Promise { + await mkdir(dir, { recursive: true }) + const path = join(dir, 'package.json') + if (!existsSync(path)) { + await writeFile( + path, + `${JSON.stringify({ name: 'diskpush-plugins', private: true, description: 'Plugins installed by `diskpush plugins add`.', dependencies: {} }, null, 2)}\n`, + ) + } +} + +/** + * `diskpush plugins add `: installs a package and checks it is a plugin + * that loads. One that does not is uninstalled again rather than left behind + * to fail on every start. + */ +export async function addExternalPlugin( + dir: string, + spec: string, + npm: Npm = systemNpm, +): Promise<{ name: string; plugin: DiskpushPlugin }> { + if (spec.startsWith('-')) throw new PluginError('A package name cannot start with "-".') + await ensureProject(dir) + const before = new Set(await installedPackages(dir)) + await npm(['install', '--save', '--ignore-scripts', '--no-audit', '--no-fund', spec], dir) + const added = (await installedPackages(dir)).filter((name) => !before.has(name)) + const name = added[0] ?? spec.replace(/@[^@/]*$/, '') + try { + return { name, plugin: await importPlugin(dir, name) } + } catch (error) { + if (added.length > 0) await npm(['uninstall', '--save', name], dir).catch(() => {}) + throw error + } +} + +export async function removeExternalPlugin(dir: string, name: string, npm: Npm = systemNpm): Promise { + if (!(await installedPackages(dir)).includes(name)) throw new PluginError(`${name} is not an installed plugin.`) + await npm(['uninstall', '--save', name], dir) +} diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts new file mode 100644 index 0000000..76a1d37 --- /dev/null +++ b/packages/plugin-api/src/index.ts @@ -0,0 +1,3 @@ +export * from './types.js' +export * from './registry.js' +export * from './external.js' diff --git a/packages/plugin-api/src/registry.test.ts b/packages/plugin-api/src/registry.test.ts new file mode 100644 index 0000000..6c3cf7e --- /dev/null +++ b/packages/plugin-api/src/registry.test.ts @@ -0,0 +1,178 @@ +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + DISABLED_KEY, + PluginRegistry, + definePlugin, + describeEntries, + memoryBackend, + runAction, + silentProgress, + type EntryRef, +} from './index.js' + +const images = (entries: readonly EntryRef[]) => entries.every((entry) => entry.isDirectory || entry.name.endsWith('.jpg')) + +function fake(id = 'fake', extra: Partial[0]> = {}) { + return definePlugin({ + id, + name: 'Fake', + version: '1.0.0', + description: 'for tests', + actions: [ + { + id: 'count', + label: 'Count', + appliesTo: images, + async run(ctx) { + await ctx.settings.set('last', ctx.names) + return { ok: true, message: `${ctx.entries.length} entries in ${ctx.dir}` } + }, + }, + { id: 'everything', label: 'Everything', appliesTo: () => true, run: async () => ({ ok: true, message: 'ok' }) }, + ], + ...extra, + }) +} + +const host = () => ({ + progress: silentProgress, + openUrl: async () => {}, + signal: new AbortController().signal, + surface: 'cli' as const, + env: {}, +}) + +describe('definePlugin', () => { + it('refuses ids that could never be reached or would collide', () => { + expect(() => fake('Bad Id')).toThrow(/must match/) + expect(() => fake('sync')).toThrow(/DiskPush command/) + expect(() => fake('plugins')).toThrow(/DiskPush command/) + expect(() => + fake('dupe', { + actions: [ + { id: 'a', label: 'A', appliesTo: () => true, run: async () => ({ ok: true, message: '' }) }, + { id: 'a', label: 'A', appliesTo: () => true, run: async () => ({ ok: true, message: '' }) }, + ], + }), + ).toThrow(/two actions/) + }) + + it('refuses a plugin written for another API version', () => { + expect(() => fake('old', { apiVersion: 99 })).toThrow(/plugin API 99/) + }) +}) + +describe('PluginRegistry', () => { + it('lists plugins as enabled until one is disabled, and persists that in plugins.disabled', async () => { + const backend = memoryBackend() + const registry = new PluginRegistry(backend) + registry.register(fake('one')) + registry.register(fake('two')) + expect((await registry.list()).map((info) => [info.plugin.id, info.enabled])).toEqual([ + ['one', true], + ['two', true], + ]) + + await registry.disable('two') + expect(await backend.getSetting(DISABLED_KEY, [])).toEqual(['two']) + expect(await registry.isEnabled('two')).toBe(false) + + // A second registry over the same store sees the same state. + const again = new PluginRegistry(backend) + again.register(fake('two')) + expect(await again.isEnabled('two')).toBe(false) + + await registry.enable('two') + expect(await registry.isEnabled('two')).toBe(true) + await expect(registry.disable('nope')).rejects.toThrow(/No plugin/) + }) + + it('refuses two plugins with one id', () => { + const registry = new PluginRegistry() + registry.register(fake()) + expect(() => registry.register(fake())).toThrow(/already loaded/) + }) + + it('offers only the actions that apply, from enabled plugins only', async () => { + const registry = new PluginRegistry() + registry.register(fake('one')) + const jpg = [{ name: 'a.jpg', isDirectory: false, size: 1 }] + const txt = [{ name: 'a.txt', isDirectory: false, size: 1 }] + expect((await registry.actionsFor(jpg)).map((m) => m.action.id)).toEqual(['count', 'everything']) + expect((await registry.actionsFor(txt)).map((m) => m.action.id)).toEqual(['everything']) + expect(await registry.actionsFor([])).toEqual([]) + await registry.disable('one') + expect(await registry.actionsFor(jpg)).toEqual([]) + await expect(registry.action('one', 'count')).rejects.toThrow(/disabled/) + }) + + it('leaves a plugin whose appliesTo throws out of the menu instead of failing it', async () => { + const registry = new PluginRegistry() + registry.register( + fake('broken', { + actions: [{ id: 'boom', label: 'Boom', appliesTo: () => { throw new Error('bug') }, run: async () => ({ ok: true, message: '' }) }], + }), + ) + registry.register(fake('fine')) + const matches = await registry.actionsFor([{ name: 'a.jpg', isDirectory: false, size: 1 }]) + expect(matches.map((m) => m.plugin.id)).toEqual(['fine', 'fine']) + }) + + it('namespaces settings and secrets per plugin', async () => { + const backend = memoryBackend() + const registry = new PluginRegistry(backend) + registry.register(fake('one')) + await registry.settingsFor('one').set('server', 'https://x') + await registry.secretsFor('one').set('token', 't') + expect(await backend.getSetting('plugin:one:server', null)).toBe('https://x') + expect(await backend.getSetting('plugin-secret:one:token', null)).toBe('t') + expect(await registry.settingsFor('two').get('server', 'none')).toBe('none') + await registry.secretsFor('one').set('token', null) + expect(await registry.secretsFor('one').get('token')).toBeNull() + }) +}) + +describe('runAction', () => { + const tree = () => { + const dir = mkdtempSync(join(tmpdir(), 'dp-plugin-')) + writeFileSync(join(dir, 'a.jpg'), 'x') + mkdirSync(join(dir, 'album')) + symlinkSync(join(dir, 'a.jpg'), join(dir, 'link.jpg')) + return dir + } + + it('describes the entries from disk and hands the plugin only bare names', async () => { + const dir = tree() + const registry = new PluginRegistry() + registry.register(fake()) + const result = await runAction(registry, 'fake', 'count', { ...host(), dir, names: ['a.jpg', 'album', 'gone.jpg'] }) + expect(result).toEqual({ ok: true, message: `2 entries in ${dir}` }) + expect(await registry.settingsFor('fake').get('last', null)).toEqual(['a.jpg', 'album']) + }) + + it('refuses paths, parents and relative directories', async () => { + const dir = tree() + await expect(describeEntries(dir, ['../etc'])).rejects.toThrow(/not a name/) + await expect(describeEntries(dir, ['a/b'])).rejects.toThrow(/not a name/) + await expect(describeEntries('relative', ['a.jpg'])).rejects.toThrow(/absolute/) + // Symlinks are skipped: an action must not follow one out of the directory. + expect((await describeEntries(dir, ['link.jpg'])).length).toBe(0) + }) + + it('turns a throwing action into a failed result', async () => { + const dir = tree() + const registry = new PluginRegistry() + registry.register( + fake('thrower', { + actions: [{ id: 'x', label: 'X', appliesTo: () => true, run: async () => { throw new Error('nope') } }], + }), + ) + expect(await runAction(registry, 'thrower', 'x', { ...host(), dir, names: ['a.jpg'] })).toEqual({ + ok: false, + message: 'nope', + }) + }) +}) diff --git a/packages/plugin-api/src/registry.ts b/packages/plugin-api/src/registry.ts new file mode 100644 index 0000000..0c18a39 --- /dev/null +++ b/packages/plugin-api/src/registry.ts @@ -0,0 +1,386 @@ +import { lstat } from 'node:fs/promises' +import { isAbsolute, join } from 'node:path' +import { + PLUGIN_API_VERSION, + type ActionContext, + type ActionResult, + type BaseContext, + type DiskpushPlugin, + type EntryRef, + type FileAction, + type PluginCommand, + type PluginSecrets, + type PluginSettings, + type PluginTask, +} from './types.js' + +const PLUGIN_ID = /^[a-z][a-z0-9-]{0,39}$/ +const PART_ID = /^[a-z0-9][a-z0-9-]{0,63}$/ +const SETTING_KEY = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/ + +/** Ids the CLI already answers to; a plugin taking one could never be reached. */ +export const RESERVED_IDS = new Set([ + 'sync', 'push', 'pull', 'publish', 'deploy', 'backup', 'mirror', 'rsync', 'ls', 'connections', 'profiles', + 'profile', 'jobs', 'job', 'retry', 'update', 'upgrade', 'uninstall', 'remove', 'doctor', 'desktop', 'tui', + 'fleet', 'help', 'version', 'plugins', 'plugin', +]) + +export class PluginError extends Error { + constructor(message: string) { + super(message) + this.name = 'PluginError' + } +} + +/** + * Checks a plugin's shape and returns it unchanged. + * + * Checked when it is defined rather than when it is first used, so a plugin + * with a typo in an id fails its own tests instead of a user's menu. + */ +export function definePlugin

(plugin: P): P { + validatePlugin(plugin) + return plugin +} + +export function validatePlugin(plugin: DiskpushPlugin): void { + if (!plugin || typeof plugin !== 'object') throw new PluginError('A plugin must be an object.') + if (typeof plugin.id !== 'string' || !PLUGIN_ID.test(plugin.id)) { + throw new PluginError(`Plugin id ${JSON.stringify(plugin.id)} must match ${PLUGIN_ID}.`) + } + if (RESERVED_IDS.has(plugin.id)) throw new PluginError(`Plugin id "${plugin.id}" is a DiskPush command.`) + for (const field of ['name', 'version', 'description'] as const) { + if (typeof plugin[field] !== 'string') throw new PluginError(`Plugin ${plugin.id} is missing ${field}.`) + } + const api = plugin.apiVersion ?? PLUGIN_API_VERSION + if (api !== PLUGIN_API_VERSION) { + throw new PluginError( + `Plugin ${plugin.id} was written for plugin API ${api}; this DiskPush speaks ${PLUGIN_API_VERSION}.`, + ) + } + const unique = (kind: string, ids: string[], pattern = PART_ID) => { + const seen = new Set() + for (const id of ids) { + if (!pattern.test(id)) throw new PluginError(`Plugin ${plugin.id}: ${kind} id ${JSON.stringify(id)} is not valid.`) + if (seen.has(id)) throw new PluginError(`Plugin ${plugin.id}: two ${kind}s are called "${id}".`) + seen.add(id) + } + } + unique('action', (plugin.actions ?? []).map((action) => action.id)) + unique('command', (plugin.commands ?? []).map((command) => command.name)) + unique('task', (plugin.tasks ?? []).map((task) => task.id)) + unique('setting', (plugin.settings ?? []).map((setting) => setting.key), SETTING_KEY) + for (const action of plugin.actions ?? []) { + if (typeof action.run !== 'function' || typeof action.appliesTo !== 'function') { + throw new PluginError(`Plugin ${plugin.id}: action ${action.id} needs appliesTo() and run().`) + } + if (action.tuiKey !== undefined && !/^[a-z0-9]$/.test(action.tuiKey)) { + throw new PluginError(`Plugin ${plugin.id}: action ${action.id} tuiKey must be one lowercase letter or digit.`) + } + } +} + +/** + * The part of the DiskPush store the registry needs. `DiskPushStore` + * satisfies it as it is; a test can hand in a Map. + */ +export interface SettingsBackend { + getSetting(key: string, fallback: T): Promise + setSetting(key: string, value: unknown): Promise +} + +/** A backend that forgets everything: for listing plugins where no store is open. */ +export function memoryBackend(initial: Record = {}): SettingsBackend { + const values = new Map(Object.entries(initial)) + return { + async getSetting(key: string, fallback: T): Promise { + return values.has(key) ? (structuredClone(values.get(key)) as T) : fallback + }, + async setSetting(key: string, value: unknown): Promise { + if (value === undefined) values.delete(key) + else values.set(key, structuredClone(value)) + }, + } +} + +/** Where a plugin's settings live in the shared table. */ +export function settingKey(pluginId: string, key: string): string { + return `plugin:${pluginId}:${key}` +} + +export function secretKey(pluginId: string, key: string): string { + return `plugin-secret:${pluginId}:${key}` +} + +export function namespacedSettings(backend: SettingsBackend, pluginId: string): PluginSettings { + return { + get: (key, fallback) => backend.getSetting(settingKey(pluginId, key), fallback), + set: (key, value) => backend.setSetting(settingKey(pluginId, key), value ?? null), + } +} + +/** + * How a host protects secrets at rest. The desktop app could pass one backed + * by Electron's safeStorage; the default stores them as they are, which is + * what lets a sign-in made in the desktop app work in the CLI. + */ +export interface SecretCodec { + encode(plain: string): string + /** Null when the value cannot be read here: another machine's keychain, say. */ + decode(stored: string): string | null +} + +export function namespacedSecrets(backend: SettingsBackend, pluginId: string, codec?: SecretCodec): PluginSecrets { + return { + async get(key) { + const stored = await backend.getSetting(secretKey(pluginId, key), null) + if (typeof stored !== 'string' || stored === '') return null + return codec ? codec.decode(stored) : stored + }, + async set(key, value) { + await backend.setSetting(secretKey(pluginId, key), value === null ? null : codec ? codec.encode(value) : value) + }, + } +} + +export type PluginSource = 'builtin' | 'external' + +export type PluginInfo = { + plugin: DiskpushPlugin + source: PluginSource + enabled: boolean + /** Where an external plugin was loaded from. */ + location?: string +} + +export type ActionMatch = { plugin: DiskpushPlugin; action: FileAction } + +/** The settings key holding the ids of disabled plugins. */ +export const DISABLED_KEY = 'plugins.disabled' + +/** + * Every plugin this process knows, and which of them the user has turned off. + * + * Built-in plugins are registered from code; external ones by + * `loadExternalPlugins`. Disabled is the stored state rather than enabled, so + * a newly installed plugin is on without a second step. + */ +export class PluginRegistry { + private readonly plugins = new Map() + + constructor( + readonly backend: SettingsBackend = memoryBackend(), + private readonly codec?: SecretCodec, + ) {} + + register(plugin: DiskpushPlugin, source: PluginSource = 'builtin', location?: string): void { + validatePlugin(plugin) + if (this.plugins.has(plugin.id)) throw new PluginError(`A plugin called "${plugin.id}" is already loaded.`) + this.plugins.set(plugin.id, { plugin, source, ...(location ? { location } : {}) }) + } + + has(id: string): boolean { + return this.plugins.has(id) + } + + get(id: string): DiskpushPlugin | null { + return this.plugins.get(id)?.plugin ?? null + } + + /** Every plugin, enabled or not, in registration order. */ + all(): DiskpushPlugin[] { + return [...this.plugins.values()].map((entry) => entry.plugin) + } + + async disabledIds(): Promise> { + const stored = await this.backend.getSetting(DISABLED_KEY, []) + return new Set(Array.isArray(stored) ? stored.filter((id): id is string => typeof id === 'string') : []) + } + + async list(): Promise { + const disabled = await this.disabledIds() + return [...this.plugins.values()].map(({ plugin, source, location }) => ({ + plugin, + source, + enabled: !disabled.has(plugin.id), + ...(location ? { location } : {}), + })) + } + + async isEnabled(id: string): Promise { + return this.plugins.has(id) && !(await this.disabledIds()).has(id) + } + + async setEnabled(id: string, enabled: boolean): Promise { + if (!this.plugins.has(id)) throw new PluginError(`No plugin called "${id}".`) + const disabled = await this.disabledIds() + if (enabled) disabled.delete(id) + else disabled.add(id) + await this.backend.setSetting(DISABLED_KEY, [...disabled].sort()) + } + + enable(id: string): Promise { + return this.setEnabled(id, true) + } + + disable(id: string): Promise { + return this.setEnabled(id, false) + } + + /** The enabled plugin, or a PluginError saying why there is none. */ + async require(id: string): Promise { + const plugin = this.get(id) + if (!plugin) throw new PluginError(`No plugin called "${id}". See: diskpush plugins list`) + if (!(await this.isEnabled(id))) throw new PluginError(`The ${id} plugin is disabled. Turn it on with: diskpush plugins enable ${id}`) + return plugin + } + + /** + * The actions of enabled plugins that apply to these entries. + * + * A plugin whose `appliesTo` throws is left out of the menu rather than + * taking the menu down with it. + */ + async actionsFor(entries: readonly EntryRef[], dir = ''): Promise { + if (entries.length === 0) return [] + const disabled = await this.disabledIds() + const matches: ActionMatch[] = [] + for (const { plugin } of this.plugins.values()) { + if (disabled.has(plugin.id)) continue + for (const action of plugin.actions ?? []) { + try { + if (action.appliesTo(entries, { dir })) matches.push({ plugin, action }) + } catch { + // A broken plugin loses its menu item, not the user's menu. + } + } + } + return matches + } + + async action(pluginId: string, actionId: string): Promise<{ plugin: DiskpushPlugin; action: FileAction }> { + const plugin = await this.require(pluginId) + const action = plugin.actions?.find((candidate) => candidate.id === actionId) + if (!action) throw new PluginError(`The ${pluginId} plugin has no action "${actionId}".`) + return { plugin, action } + } + + async task(pluginId: string, taskId: string): Promise<{ plugin: DiskpushPlugin; task: PluginTask }> { + const plugin = await this.require(pluginId) + const task = plugin.tasks?.find((candidate) => candidate.id === taskId) + if (!task) throw new PluginError(`The ${pluginId} plugin has no task "${taskId}".`) + return { plugin, task } + } + + async command(pluginId: string, name: string): Promise<{ plugin: DiskpushPlugin; command: PluginCommand | null }> { + const plugin = await this.require(pluginId) + return { plugin, command: plugin.commands?.find((candidate) => candidate.name === name) ?? null } + } + + settingsFor(pluginId: string): PluginSettings { + return namespacedSettings(this.backend, pluginId) + } + + secretsFor(pluginId: string): PluginSecrets { + return namespacedSecrets(this.backend, pluginId, this.codec) + } +} + +/** What a host supplies to build a context; the registry supplies settings and secrets. */ +export type HostContext = Omit + +export function baseContext(registry: PluginRegistry, pluginId: string, host: HostContext): BaseContext { + return { ...host, settings: registry.settingsFor(pluginId), secrets: registry.secretsFor(pluginId) } +} + +/** + * A bare entry name: what the renderer and the TUI are allowed to hand over. + * + * The same rule as the desktop contract's EntryNameSchema, repeated here so a + * host that forgets to validate still cannot walk a plugin out of `dir`. + */ +export function isEntryName(name: string): boolean { + return ( + typeof name === 'string' && + name.length > 0 && + name.length <= 255 && + !name.includes('/') && + !name.includes('\\') && + !name.includes('\0') && + name !== '.' && + name !== '..' + ) +} + +/** + * The entries as they are on disk. Refuses anything but an absolute directory + * and bare names, and drops names that no longer exist. + */ +export async function describeEntries(dir: string, names: readonly string[]): Promise { + if (!isAbsolute(dir)) throw new PluginError('A plugin action needs an absolute directory.') + const out: EntryRef[] = [] + for (const name of names) { + if (!isEntryName(name)) throw new PluginError(`${JSON.stringify(name)} is not a name inside ${dir}.`) + try { + const stats = await lstat(join(dir, name)) + if (stats.isSymbolicLink()) continue + out.push({ name, isDirectory: stats.isDirectory(), size: stats.isDirectory() ? 0 : stats.size }) + } catch { + // Gone since the listing was drawn. + } + } + return out +} + +export type RunActionOptions = HostContext & { dir: string; names: readonly string[] } + +/** + * Runs one action the way every host does: validate, describe, build the + * context, and turn a throw into a failed result. + */ +export async function runAction( + registry: PluginRegistry, + pluginId: string, + actionId: string, + options: RunActionOptions, +): Promise { + const { action } = await registry.action(pluginId, actionId) + const { dir, names, ...host } = options + const entries = await describeEntries(dir, names) + if (entries.length === 0) return { ok: false, message: 'Nothing selected exists any more.' } + if (!action.appliesTo(entries, { dir })) return { ok: false, message: `${action.label} does not apply to that selection.` } + const ctx: ActionContext = { + ...baseContext(registry, pluginId, host), + dir, + names: entries.map((entry) => entry.name), + entries, + } + try { + return await action.run(ctx) + } catch (error) { + if (options.signal.aborted) return { ok: false, message: 'Cancelled.' } + return { ok: false, message: error instanceof Error ? error.message : String(error) } + } +} + +export async function runTask( + registry: PluginRegistry, + pluginId: string, + taskId: string, + host: HostContext, +): Promise { + const { task } = await registry.task(pluginId, taskId) + try { + return await task.run(baseContext(registry, pluginId, host)) + } catch (error) { + if (host.signal.aborted) return { ok: false, message: 'Cancelled.' } + return { ok: false, message: error instanceof Error ? error.message : String(error) } + } +} + +/** A progress sink that drops everything, for hosts that have nowhere to draw. */ +export const silentProgress = { + start() {}, + update() {}, + log() {}, +} diff --git a/packages/plugin-api/src/types.ts b/packages/plugin-api/src/types.ts new file mode 100644 index 0000000..52d5990 --- /dev/null +++ b/packages/plugin-api/src/types.ts @@ -0,0 +1,196 @@ +/** + * The plugin contract. + * + * A plugin is a plain object: an id, some metadata, and any of three kinds of + * contribution -- commands for the CLI, file actions for the three file + * browsers (the TUI, the desktop app, and `diskpush ...` scripts), + * and account tasks such as signing in. The host decides where each one is + * drawn; the plugin only ever sees a context object, so the same code runs + * under all three surfaces. + * + * Plugins run with the full privileges of the process that loads them: the + * CLI process, or the desktop app's MAIN process. Never the renderer. See + * docs/plugins.md for the security model. + */ + +/** Bumped only on a breaking change to anything in this file. */ +export const PLUGIN_API_VERSION = 1 + +export type Surface = 'cli' | 'tui' | 'desktop' + +/** One entry the user picked, as the host found it on disk. */ +export type EntryRef = { + /** A bare name inside `ActionContext.dir`. Never a path. */ + name: string + isDirectory: boolean + /** Bytes, for a file. 0 for a directory. */ + size: number +} + +/** + * Where a plugin keeps its own configuration. + * + * Namespaced by the host under `plugin::`, in the same settings table the + * CLI and the desktop app share, so a setting made in one surface is in force + * in the other. + */ +export interface PluginSettings { + get(key: string, fallback: T): Promise + set(key: string, value: unknown): Promise +} + +/** + * Credentials: tokens, API keys. + * + * The same shape as settings but a separate store, so a host can protect it + * differently and so a settings screen never has a reason to read one back. + * `null` deletes. + */ +export interface PluginSecrets { + get(key: string): Promise + set(key: string, value: string | null): Promise +} + +export type LogLevel = 'info' | 'warn' | 'error' + +export type ProgressUpdate = { + done?: number + total?: number + message?: string + /** The file being worked on, relative to the action's directory. */ + currentFile?: string +} + +/** + * How a long action reports. The CLI draws it as a status line, the TUI in the + * transfer panel, the desktop app in the transfer band. + */ +export interface ProgressSink { + start(total?: number): void + update(update: ProgressUpdate): void + log(level: LogLevel, message: string): void +} + +/** Everything any plugin code is handed, whichever surface is running it. */ +export interface BaseContext { + settings: PluginSettings + secrets: PluginSecrets + progress: ProgressSink + /** + * Opens a URL in the user's browser. http and https only; anything else is + * refused by the host. + */ + openUrl(url: string): Promise + /** Aborted when the user cancels. Long work must watch it. */ + signal: AbortSignal + surface: Surface + /** Environment variables, so a plugin can take an API key from one. */ + env: Readonly> +} + +export interface ActionContext extends BaseContext { + /** The absolute local directory the entries are in. */ + dir: string + /** Bare entry names inside `dir`, validated by the host: no separators, no `..`. */ + names: string[] + /** The same entries, as the host found them on disk. */ + entries: EntryRef[] +} + +export type ActionResult = { + ok: boolean + /** One line, for a person: what happened. */ + message: string + /** True when files under `dir` changed, so the listing should be read again. */ + changed?: boolean +} + +/** Something a user does to selected local files. */ +export interface FileAction { + /** Unique within the plugin: `[a-z0-9-]+`. */ + id: string + label: string + description?: string + /** + * A single letter the TUI's actions menu answers to. Only a hint: the host + * drops one that clashes with the menu's own keys or another action's. + */ + tuiKey?: string + /** + * Whether this action makes sense for these entries, in `where.dir`. Must be + * cheap and synchronous: it runs as a menu opens. + */ + appliesTo(entries: readonly EntryRef[], where: { dir: string }): boolean + run(ctx: ActionContext): Promise +} + +/** + * Something that takes no files: signing in, signing out. + * + * The desktop app draws these as buttons in the plugin's settings. The CLI has + * commands for the same things, so tasks are for the surfaces without a shell. + */ +export interface PluginTask { + id: string + label: string + description?: string + run(ctx: BaseContext): Promise +} + +export interface CommandContext extends BaseContext { + surface: 'cli' + /** The directory the command was run from. */ + cwd: string + /** `--json` was given: print one JSON document to stdout and nothing else there. */ + json: boolean + /** Normal output. Suppressed by `--quiet` and `--json`. */ + print(text: string): void + /** Diagnostics, on stderr. */ + warn(text: string): void + /** The machine-readable result. */ + printJson(value: unknown): void + /** Asks a question on the terminal; null when there is no terminal to ask on. */ + prompt(question: string): Promise +} + +/** `diskpush [args...]` */ +export interface PluginCommand { + name: string + summary: string + /** One line: `analyze DIR [--sort]`. */ + usage: string + /** Returns the process exit code. `args` excludes the plugin id and the command name. */ + run(args: string[], ctx: CommandContext): Promise +} + +/** + * A setting a host can draw a control for. + * + * `secret` settings are write-only from a settings screen: the host stores them + * with `PluginSecrets` and only ever reports whether one is set. + */ +export type SettingDef = { + key: string + label: string + description?: string + type: 'string' | 'enum' | 'boolean' | 'secret' + /** For `enum`. */ + options?: string[] + default?: string | boolean +} + +export interface DiskpushPlugin { + /** `[a-z][a-z0-9-]*`. It is also the CLI command: `diskpush ...`. */ + id: string + name: string + version: string + description: string + /** The PLUGIN_API_VERSION this plugin was written against. Defaults to the current one. */ + apiVersion?: number + commands?: PluginCommand[] + actions?: FileAction[] + tasks?: PluginTask[] + settings?: SettingDef[] + /** One line for a settings screen: "Signed in as ada@example.com". Null when there is nothing to say. */ + status?(ctx: BaseContext): Promise +} diff --git a/packages/plugin-api/tsconfig.json b/packages/plugin-api/tsconfig.json new file mode 100644 index 0000000..6926b0f --- /dev/null +++ b/packages/plugin-api/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/plugin-mediaanalyzer/package.json b/packages/plugin-mediaanalyzer/package.json new file mode 100644 index 0000000..8c72e9e --- /dev/null +++ b/packages/plugin-mediaanalyzer/package.json @@ -0,0 +1,23 @@ +{ + "name": "@diskpush/plugin-mediaanalyzer", + "version": "0.11.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@diskpush/plugin-api": "workspace:*" + } +} diff --git a/packages/plugin-mediaanalyzer/src/analyze.ts b/packages/plugin-mediaanalyzer/src/analyze.ts new file mode 100644 index 0000000..6e206cf --- /dev/null +++ b/packages/plugin-mediaanalyzer/src/analyze.ts @@ -0,0 +1,373 @@ +/** + * One analysis run: collect, upload what has not been, wait for results, + * write sidecars, and optionally sort. + * + * Resumable at every step. The state file records the scan, which files went + * up and every result, and it is saved after each batch and each poll, so an + * interrupted run (a closed laptop, a cancel, a crash) picks up where it + * stopped the next time the action runs, without uploading or paying again. + */ +import { openAsBlob } from 'node:fs' +import { basename } from 'node:path' +import type { PluginSettings, ProgressSink } from '@diskpush/plugin-api' +import { SIGNATURE, readOurSidecar, sortIntoFolders, writeSidecar, type SortItem, type SortReport } from './apply.js' +import { chooseTier, type FileResult, type MediaAnalyzerClient, type UploadMeta } from './client.js' +import { collectMedia, loadState, saveState, type MediaFile, type Result, type State } from './library.js' +import { MAX_UPLOAD_BYTES, findFfmpeg, mimeType, type Ffmpeg } from './media.js' +import { ApiError } from './oauth.js' +import type { EntryRef } from '@diskpush/plugin-api' + +export const MAX_FILES_PER_REQUEST = 50 +/** Keeps one request, and the memory behind it, bounded. A single file up to the server's 30 MB always fits. */ +export const MAX_BYTES_PER_REQUEST = 64 * 1024 * 1024 +export const POLL_INTERVAL_MS = 2500 + +export type AnalyzeOptions = { + dir: string + entries: readonly EntryRef[] + sort: boolean + client: MediaAnalyzerClient + settings: PluginSettings + progress: ProgressSink + signal: AbortSignal + /** Undefined looks on PATH; null means "there is none". */ + ffmpeg?: Ffmpeg | null + pollIntervalMs?: number +} + +export type AnalyzeReport = { + ok: boolean + message: string + files: number + described: number + failed: number + /** Never uploaded: no credit, too large, no ffmpeg. */ + notUploaded: number + sidecarsWritten: number + sidecarsKept: number + sort: SortReport | null + chargedUsd: number | null + changed: boolean +} + +const sleep = (ms: number, signal: AbortSignal) => + new Promise((resolve, reject) => { + if (signal.aborted) return reject(signal.reason) + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, ms) + const onAbort = () => { + clearTimeout(timer) + reject(signal.reason) + } + signal.addEventListener('abort', onAbort, { once: true }) + }) + +function toResult(file: FileResult): Result { + return { + status: file.status === 'done' ? 'done' : 'error', + category: file.category ?? null, + description: file.description ?? null, + tags: Array.isArray(file.tags) ? file.tags : [], + error: file.error ?? null, + } +} + +/** What is sent for one file: its bytes, and the metadata that describes them. */ +type Prepared = { file: MediaFile; blob: Blob; meta: UploadMeta } + +async function prepare(file: MediaFile, ffmpeg: Ffmpeg | null, signal: AbortSignal): Promise { + const meta: UploadMeta = { client_ref: file.ref, name: file.name, rel_path: file.rel, kind: file.kind, bytes: file.bytes } + if (file.kind === 'photo') { + if (file.bytes > MAX_UPLOAD_BYTES) return 'larger than 30 MB' + return { file, meta, blob: await openAsBlob(file.abs, { type: mimeType(file.name) }) } + } + if (!ffmpeg) return 'videos need ffmpeg and ffprobe on PATH' + try { + const duration = await ffmpeg.duration(file.abs, signal) + const sheet = await ffmpeg.contactSheet(file.abs, duration, signal) + if (sheet.length === 0) return 'ffmpeg produced no frames' + return { + file, + meta: { ...meta, frames: 9, duration_seconds: Math.round(duration * 10) / 10 }, + blob: new Blob([new Uint8Array(sheet)], { type: 'image/jpeg' }), + } + } catch (error) { + if (signal.aborted) throw error + return `could not read the video: ${error instanceof Error ? error.message : String(error)}` + } +} + +/** Splits files into requests of at most 50 files and 64 MB. */ +export function batches(items: readonly T[]): T[][] { + const out: T[][] = [] + let current: T[] = [] + let size = 0 + for (const item of items) { + if (current.length > 0 && (current.length >= MAX_FILES_PER_REQUEST || size + item.blob.size > MAX_BYTES_PER_REQUEST)) { + out.push(current) + current = [] + size = 0 + } + current.push(item) + size += item.blob.size + } + if (current.length > 0) out.push(current) + return out +} + +export async function analyze(options: AnalyzeOptions): Promise { + const { dir, client, progress, signal } = options + const report: AnalyzeReport = { + ok: true, + message: '', + files: 0, + described: 0, + failed: 0, + notUploaded: 0, + sidecarsWritten: 0, + sidecarsKept: 0, + sort: null, + chargedUsd: null, + changed: false, + } + + progress.update({ message: 'Looking for photos and videos…' }) + const files = await collectMedia(dir, options.entries, signal) + report.files = files.length + if (files.length === 0) { + return { ...report, message: 'No photos or videos in that selection.' } + } + progress.start(files.length) + + const server = await client.server() + const state: State = await loadState(dir) + if (state.server !== server) { + // Results are per server; a state file from another one is a fresh start. + Object.assign(state, { server, scanId: null, tier: null, cursor: '', files: {} }) + } + + const byRef = new Map(files.map((file) => [file.ref, file])) + const sortItems = new Map() + let finished = 0 + const tick = (file: MediaFile | null, message?: string) => + progress.update({ done: finished, total: files.length, ...(file ? { currentFile: file.rel } : {}), ...(message ? { message } : {}) }) + + const record = async (file: MediaFile, result: Result) => { + state.files[file.ref] = { ...(state.files[file.ref] ?? { rel: file.rel, uploaded: true }), result } + finished += 1 + if (result.status === 'done') { + report.described += 1 + const outcome = await writeSidecar(file.abs, result) + if (outcome === 'written') { + report.sidecarsWritten += 1 + report.changed = true + } else { + report.sidecarsKept += 1 + progress.log('warn', `${file.rel}.description.txt is not ours; left it as it was.`) + } + sortItems.set(file.ref, { rel: file.rel, category: result.category }) + } else { + report.failed += 1 + progress.log('error', `${file.rel}: ${result.error ?? 'failed'}`) + } + tick(file) + } + + // --- what is already known -------------------------------------------------- + const toUpload: MediaFile[] = [] + for (const file of files) { + const known = state.files[file.ref] + if (known?.result) { + finished += 1 + if (known.result.status === 'done') { + report.described += 1 + if ((await writeSidecar(file.abs, known.result)) === 'written') report.sidecarsWritten += 1 + sortItems.set(file.ref, { rel: file.rel, category: known.result.category }) + } else report.failed += 1 + continue + } + if (known?.uploaded) continue + // A sidecar we wrote on an earlier run, or that the MediaAnalyzer CLI + // wrote: the file has been described, and describing it again costs money. + const sidecar = await readOurSidecar(file.abs) + if (sidecar) { + finished += 1 + report.described += 1 + sortItems.set(file.ref, { rel: file.rel, category: sidecar.category }) + continue + } + toUpload.push(file) + } + tick(null) + + const ffmpeg = options.ffmpeg === undefined ? (toUpload.some((file) => file.kind === 'video') ? await findFfmpeg() : null) : options.ffmpeg + + // --- upload ----------------------------------------------------------------- + if (toUpload.length > 0) { + if (!state.scanId) { + progress.update({ message: 'Starting a MediaAnalyzer scan…' }) + const me = await client.me() + const choice = chooseTier(me, { + tier: await options.settings.get('tier', ''), + providerId: await options.settings.get('providerId', ''), + }) + const folders = String(await options.settings.get('folders', '')) + .split(',') + .map((folder) => folder.trim()) + .filter(Boolean) + const scan = await client.createScan({ + name: `DiskPush: ${basename(dir)}`, + ...choice, + ...(folders.length > 0 ? { categories: folders } : {}), + }) + state.scanId = scan.id + state.tier = choice.tier + await saveState(dir, state) + } + + const prepared: Prepared[] = [] + for (const file of toUpload) { + const ready = await prepare(file, ffmpeg, signal) + if (typeof ready === 'string') { + report.notUploaded += 1 + finished += 1 + progress.log('warn', `${file.rel}: skipped, ${ready}`) + continue + } + prepared.push(ready) + } + + let outOfCredit = false + for (const batch of batches(prepared)) { + signal.throwIfAborted() + if (outOfCredit) { + report.notUploaded += batch.length + finished += batch.length + continue + } + tick(batch[0]!.file, `Uploading ${batch.length} file${batch.length === 1 ? '' : 's'}…`) + const form = new FormData() + form.append('meta', JSON.stringify(batch.map((item) => item.meta))) + batch.forEach((item, index) => form.append(`file${index}`, item.blob, item.file.name)) + try { + const result = await client.uploadFiles(state.scanId!, form) + const rejected = new Map(result.rejected.map((entry) => [entry.client_ref, entry.reason])) + for (const item of batch) { + const reason = rejected.get(item.file.ref) + state.files[item.file.ref] = reason + ? { rel: item.file.rel, uploaded: false, skipped: reason } + : { rel: item.file.rel, uploaded: true } + if (reason) { + report.notUploaded += 1 + finished += 1 + progress.log('warn', `${item.file.rel}: the server would not take it (${reason})`) + } + } + await saveState(dir, state) + } catch (error) { + if (error instanceof ApiError && error.code === 'insufficient_credit') { + outOfCredit = true + report.ok = false + report.notUploaded += batch.length + finished += batch.length + progress.log('error', `Out of MediaAnalyzer credit: ${error.message}`) + continue + } + if (error instanceof ApiError && error.code === 'tier_offline') { + throw new Error(`The ${state.tier ?? 'chosen'} tier is offline right now. Try again later, or pick another tier in the plugin's settings.`) + } + if (error instanceof ApiError && (error.code === 'scan_canceled' || error.status === 404)) { + // The scan was cancelled on the website. The next run starts a new one. + state.scanId = null + state.cursor = '' + await saveState(dir, state) + throw new Error('That MediaAnalyzer scan was cancelled. Run the action again to start a new one.') + } + throw error + } + } + if (outOfCredit) report.message = `Out of credit: ${report.notUploaded} file${report.notUploaded === 1 ? '' : 's'} not uploaded. Add credit at ${server}/billing and run it again.` + } + + // --- results ---------------------------------------------------------------- + const waiting = new Set( + Object.entries(state.files) + .filter(([ref, entry]) => entry.uploaded && !entry.result && byRef.has(ref)) + .map(([ref]) => ref), + ) + if (waiting.size > 0 && state.scanId) { + let quiet = 0 + tick(null, `Waiting for ${waiting.size} description${waiting.size === 1 ? '' : 's'}…`) + while (waiting.size > 0) { + signal.throwIfAborted() + const page = await client.files(state.scanId, state.cursor) + let fresh = 0 + for (const remote of page.files) { + // The cursor is inclusive, so the last file of one page is the first + // of the next: only a ref still waiting counts. + if (!waiting.has(remote.client_ref) || (remote.status !== 'done' && remote.status !== 'error')) continue + waiting.delete(remote.client_ref) + fresh += 1 + await record(byRef.get(remote.client_ref)!, toResult(remote)) + } + if (page.next_finished_after) state.cursor = page.next_finished_after + if (fresh > 0) await saveState(dir, state) + if (waiting.size === 0) break + + quiet = fresh > 0 ? 0 : quiet + 1 + // Long silence: ask the scan whether anything is still coming. + if (quiet > 0 && quiet % 8 === 0) { + const scan = await client.scan(state.scanId) + if (scan.done_count + scan.error_count >= scan.file_count && scan.file_count > 0) { + const settled = await client.files(state.scanId, '') + for (const remote of settled.files) { + if (!waiting.has(remote.client_ref)) continue + waiting.delete(remote.client_ref) + await record(byRef.get(remote.client_ref)!, toResult(remote)) + } + for (const ref of waiting) { + await record(byRef.get(ref)!, { status: 'error', category: null, description: null, tags: [], error: 'the scan finished without it' }) + } + waiting.clear() + await saveState(dir, state) + break + } + } + await sleep(options.pollIntervalMs ?? POLL_INTERVAL_MS, signal) + } + } + + if (state.scanId) { + try { + report.chargedUsd = (await client.scan(state.scanId)).charged_usd + } catch { + // A figure for the summary line, not worth failing the run over. + } + } + + // --- sort ------------------------------------------------------------------- + if (options.sort && sortItems.size > 0) { + progress.update({ message: 'Sorting into folders…' }) + report.sort = await sortIntoFolders(dir, [...sortItems.values()], { signal }) + if (report.sort.moved > 0) report.changed = true + for (const failure of report.sort.failed) progress.log('warn', failure) + } + + progress.update({ done: files.length, total: files.length }) + if (!report.message) report.message = summarize(report) + else report.message = `${summarize(report)} ${report.message}` + return report +} + +function summarize(report: AnalyzeReport): string { + const parts = [`Described ${report.described} of ${report.files} file${report.files === 1 ? '' : 's'}`] + if (report.failed > 0) parts.push(`${report.failed} failed`) + if (report.notUploaded > 0) parts.push(`${report.notUploaded} skipped`) + if (report.sort) parts.push(`${report.sort.moved} moved into folders`) + if (report.chargedUsd !== null) parts.push(`$${report.chargedUsd.toFixed(2)} charged to this scan`) + return `${parts.join(', ')}.` +} + +export { SIGNATURE } diff --git a/packages/plugin-mediaanalyzer/src/apply.ts b/packages/plugin-mediaanalyzer/src/apply.ts new file mode 100644 index 0000000..26de797 --- /dev/null +++ b/packages/plugin-mediaanalyzer/src/apply.ts @@ -0,0 +1,231 @@ +/** + * What an analysis does to the folder: sidecars, and (when asked) sorting. + * + * Two rules hold everywhere in here: + * + * - Nothing of the user's is overwritten. A sidecar is only replaced when it + * carries our signature line, and a move never lands on an existing name: + * a clash becomes `name (2).jpg`. + * - Every sort can be undone. Each move is written to a journal under + * `

/.mediaanalyzer/` before the next one starts, and "Undo last sort" + * walks it backwards, putting back exactly the tree that was there. + */ +import { link, lstat, mkdir, readFile, readdir, rename, rmdir, unlink, writeFile } from 'node:fs/promises' +import { dirname, extname, join, posix } from 'node:path' +import { SIDECAR_SUFFIX, STATE_DIR, type Result } from './library.js' + +export const SIGNATURE = 'Described by MediaAnalyzer (mediaanalyzer.pro)' + +export function sidecarText(result: Pick): string { + const lines = [result.description ?? '', '', `Folder: ${result.category ?? ''}`] + if (result.tags.length > 0) lines.push(`Tags: ${result.tags.join(', ')}`) + lines.push(SIGNATURE) + return `${lines.join('\n')}\n` +} + +/** The category and tags back out of a sidecar we wrote; null for one we did not. */ +export function parseSidecar(text: string): { category: string | null; tags: string[] } | null { + if (!text.includes(SIGNATURE)) return null + const category = /^Folder: (.*)$/m.exec(text)?.[1]?.trim() || null + const tags = /^Tags: (.*)$/m.exec(text)?.[1]?.split(',').map((tag) => tag.trim()).filter(Boolean) ?? [] + return { category, tags } +} + +async function readIfExists(path: string): Promise { + try { + return await readFile(path, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null + throw error + } +} + +export async function readOurSidecar(file: string): Promise<{ category: string | null; tags: string[] } | null> { + const text = await readIfExists(`${file}${SIDECAR_SUFFIX}`) + return text === null ? null : parseSidecar(text) +} + +/** + * Writes `.description.txt`. Returns `kept` when a sidecar is already + * there that we did not write: that one is the user's, and stays. + */ +export async function writeSidecar(file: string, result: Result): Promise<'written' | 'kept'> { + const path = `${file}${SIDECAR_SUFFIX}` + const existing = await readIfExists(path) + if (existing !== null && !existing.includes(SIGNATURE)) return 'kept' + await writeFile(path, sidecarText(result)) + return 'written' +} + +/** + * A folder name from a category: one path segment, nothing that climbs or + * hides. "Pets/Dogs" is "Pets-Dogs", not a directory two deep. + */ +export function folderName(category: string | null): string { + const cleaned = (category ?? '') + .replace(/[\\/:*?"<>|\0-\u001f]+/g, '-') + .replace(/^[.\s-]+|[.\s]+$/g, '') + .trim() + .slice(0, 80) + return cleaned || 'Uncategorized' +} + +async function exists(path: string): Promise { + try { + await lstat(path) + return true + } catch { + return false + } +} + +/** + * Moves a file without ever replacing what is at the destination. + * + * A hard link then an unlink is atomic about that: the link fails with EEXIST + * if the name is taken. Filesystems without hard links (FAT and exFAT, which + * is most memory cards) fall back to checking first and renaming. + */ +export async function moveNoClobber(from: string, to: string): Promise { + try { + await link(from, to) + await unlink(from) + return + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'EEXIST') throw new Error(`${to} already exists`) + if (code === 'ENOENT') throw error + } + if (await exists(to)) throw new Error(`${to} already exists`) + await rename(from, to) +} + +export type Move = { from: string; to: string } + +export type Journal = { + version: 1 + createdAt: string + /** Relative to the action's directory. */ + moves: Move[] + /** Folders the sort created, removed again by undo when they are empty. */ + createdDirs: string[] +} + +async function saveJournal(dir: string, name: string, journal: Journal): Promise { + await mkdir(join(dir, STATE_DIR), { recursive: true }) + await writeFile(join(dir, STATE_DIR, name), `${JSON.stringify(journal, null, 2)}\n`) +} + +const abs = (dir: string, rel: string) => join(dir, ...rel.split('/')) + +/** `photo.jpg`, `photo (2).jpg`, ... until the file AND its sidecar name are both free. */ +async function freeName(dir: string, folder: string, name: string, taken: Set): Promise { + const extension = extname(name) + const stem = name.slice(0, name.length - extension.length) + for (let n = 1; ; n += 1) { + const candidate = posix.join(folder, n === 1 ? name : `${stem} (${n})${extension}`) + if (taken.has(candidate)) continue + if (!(await exists(abs(dir, candidate))) && !(await exists(abs(dir, `${candidate}${SIDECAR_SUFFIX}`)))) return candidate + } +} + +export type SortItem = { rel: string; category: string | null } + +export type SortReport = { moved: number; alreadyInPlace: number; failed: string[]; journal: string | null } + +/** + * Moves each file, and its sidecar, into `//`. + * Files already in their category's folder stay where they are. + */ +export async function sortIntoFolders( + dir: string, + items: readonly SortItem[], + options: { signal?: AbortSignal; now?: Date; onMove?: (move: Move) => void } = {}, +): Promise { + const stamp = (options.now ?? new Date()).toISOString().replace(/[:.]/g, '-') + const journalName = `undo-${stamp}.json` + const journal: Journal = { version: 1, createdAt: new Date().toISOString(), moves: [], createdDirs: [] } + const taken = new Set() + const report: SortReport = { moved: 0, alreadyInPlace: 0, failed: [], journal: null } + + for (const item of items) { + options.signal?.throwIfAborted() + const folder = folderName(item.category) + if (posix.dirname(item.rel) === folder) { + report.alreadyInPlace += 1 + continue + } + try { + if (!(await exists(abs(dir, folder)))) { + await mkdir(abs(dir, folder)) + journal.createdDirs.push(folder) + } + const to = await freeName(dir, folder, posix.basename(item.rel), taken) + taken.add(to) + const moves: Move[] = [{ from: item.rel, to }] + if (await exists(abs(dir, `${item.rel}${SIDECAR_SUFFIX}`))) { + moves.push({ from: `${item.rel}${SIDECAR_SUFFIX}`, to: `${to}${SIDECAR_SUFFIX}` }) + } + for (const move of moves) { + // Journaled first: a crash between the two leaves a journal entry for + // a move that did not happen, which undo skips, rather than a moved + // file nothing remembers. + journal.moves.push(move) + await saveJournal(dir, journalName, journal) + report.journal = join(dir, STATE_DIR, journalName) + try { + await moveNoClobber(abs(dir, move.from), abs(dir, move.to)) + } catch (error) { + journal.moves.pop() + await saveJournal(dir, journalName, journal) + throw error + } + options.onMove?.(move) + } + report.moved += 1 + } catch (error) { + report.failed.push(`${item.rel}: ${error instanceof Error ? error.message : String(error)}`) + } + } + if (journal.moves.length === 0 && report.journal === null && journal.createdDirs.length > 0) { + await saveJournal(dir, journalName, journal) + report.journal = join(dir, STATE_DIR, journalName) + } + return report +} + +export type UndoReport = { restored: number; skipped: string[]; journal: string | null } + +/** Puts back the most recent sort that has not been undone. */ +export async function undoLastSort(dir: string): Promise { + const folder = join(dir, STATE_DIR) + const names = (await readdir(folder).catch(() => [] as string[])) + .filter((name) => /^undo-.*\.json$/.test(name) && !name.endsWith('.undone.json')) + .sort() + const latest = names.at(-1) + if (!latest) return { restored: 0, skipped: [], journal: null } + + const journal = JSON.parse(await readFile(join(folder, latest), 'utf8')) as Journal + const report: UndoReport = { restored: 0, skipped: [], journal: join(folder, latest) } + for (const move of [...journal.moves].reverse()) { + const from = abs(dir, move.from) + const to = abs(dir, move.to) + if (!(await exists(to))) { + report.skipped.push(`${move.to}: no longer there`) + continue + } + if (await exists(from)) { + report.skipped.push(`${move.from}: something is there now`) + continue + } + await mkdir(dirname(from), { recursive: true }) + await moveNoClobber(to, from) + report.restored += 1 + } + for (const created of [...journal.createdDirs].reverse()) { + // Only if empty: anything the user put there since stays, and so does the folder. + await rmdir(abs(dir, created)).catch(() => {}) + } + await rename(join(folder, latest), join(folder, latest.replace(/\.json$/, '.undone.json'))) + return report +} diff --git a/packages/plugin-mediaanalyzer/src/client.ts b/packages/plugin-mediaanalyzer/src/client.ts new file mode 100644 index 0000000..bf3499c --- /dev/null +++ b/packages/plugin-mediaanalyzer/src/client.ts @@ -0,0 +1,223 @@ +/** + * The MediaAnalyzer API, as DiskPush uses it. + * + * Credentials, in order of precedence: + * 1. DISKPUSH_MEDIAANALYZER_KEY in the environment (an `ma_key_…` API key) + * 2. an API key saved as the `api_key` secret + * 3. the OAuth login: `access_token` + `refresh_token` secrets + */ +import type { PluginSecrets, PluginSettings } from '@diskpush/plugin-api' +import { ApiError, DEFAULT_SERVER, apiError, normalizeServer, refreshTokens, type Fetch, type Tokens } from './oauth.js' + +export const ENV_KEY = 'DISKPUSH_MEDIAANALYZER_KEY' + +/** Refresh this long before the access token actually expires. */ +const EXPIRY_MARGIN_MS = 60_000 + +export type Tier = { id: string; name: string; usd_per_file: number; model: string; online: boolean } +export type Provider = { id: string; label: string; kind: string; model: string } + +export type Me = { + user: { email: string } + balance_usd: number + available_usd: number + categories: string[] + tiers: Tier[] + providers: Provider[] +} + +export type UploadMeta = { + client_ref: string + name: string + rel_path: string + kind: 'photo' | 'video' + bytes: number + frames?: number + duration_seconds?: number +} + +export type UploadResult = { + accepted: number + duplicates: number + rejected: { client_ref: string; reason: string }[] +} + +export type FileResult = { + client_ref: string + status: 'done' | 'error' | string + category: string | null + description: string | null + tags: string[] | null + confidence?: number | null + error?: string | null +} + +export type ScanSummary = { + id: string + status: string + file_count: number + done_count: number + error_count: number + charged_usd: number +} + +export type ClientOptions = { + settings: PluginSettings + secrets: PluginSecrets + env: Readonly> + signal?: AbortSignal + fetchImpl?: Fetch +} + +export class NotSignedIn extends Error { + constructor() { + super('Not signed in to MediaAnalyzer. Run `diskpush mediaanalyzer login`, or sign in from Plugins in the desktop app.') + this.name = 'NotSignedIn' + } +} + +/** Stores a fresh token pair. Called the moment one arrives: see oauth.ts on rotation. */ +export async function saveTokens(secrets: PluginSecrets, tokens: Tokens, now = Date.now()): Promise { + await secrets.set('refresh_token', tokens.refresh_token) + await secrets.set('access_token', tokens.access_token) + await secrets.set('access_expires_at', String(now + Math.max(0, tokens.expires_in) * 1000)) +} + +export async function clearTokens(secrets: PluginSecrets): Promise { + await secrets.set('access_token', null) + await secrets.set('access_expires_at', null) + await secrets.set('refresh_token', null) +} + +export class MediaAnalyzerClient { + private readonly fetchImpl: Fetch + private refreshing: Promise | null = null + + constructor(private readonly options: ClientOptions) { + this.fetchImpl = options.fetchImpl ?? fetch + } + + async server(): Promise { + return normalizeServer(await this.options.settings.get('server', DEFAULT_SERVER)) + } + + /** How this client authenticates, without revealing the credential. */ + async credential(): Promise<'env-key' | 'api-key' | 'oauth' | null> { + if (this.options.env[ENV_KEY]) return 'env-key' + if (await this.options.secrets.get('api_key')) return 'api-key' + if (await this.options.secrets.get('refresh_token')) return 'oauth' + return null + } + + private async bearer(forceRefresh = false): Promise { + const envKey = this.options.env[ENV_KEY] + if (envKey) return envKey + const apiKey = await this.options.secrets.get('api_key') + if (apiKey) return apiKey + + const access = await this.options.secrets.get('access_token') + const expiresAt = Number(await this.options.secrets.get('access_expires_at')) + if (!forceRefresh && access && Number.isFinite(expiresAt) && expiresAt - EXPIRY_MARGIN_MS > Date.now()) return access + return this.refresh() + } + + /** One refresh at a time: two concurrent ones would present the same token twice and revoke the login. */ + private refresh(): Promise { + this.refreshing ??= (async () => { + try { + // Read again rather than trusting memory: the other surface may have + // rotated it since this one started. + const refreshToken = await this.options.secrets.get('refresh_token') + if (!refreshToken) throw new NotSignedIn() + let tokens: Tokens + try { + tokens = await refreshTokens(await this.server(), refreshToken, this.fetchImpl) + } catch (error) { + if (error instanceof ApiError && (error.status === 400 || error.status === 401)) { + await clearTokens(this.options.secrets) + throw new NotSignedIn() + } + throw error + } + await saveTokens(this.options.secrets, tokens) + return tokens.access_token + } finally { + this.refreshing = null + } + })() + return this.refreshing + } + + /** One API call, retried once with a refreshed token if the access token was turned away. */ + async request(method: string, path: string, body?: { json?: unknown; form?: FormData }): Promise { + const send = async (token: string) => { + const headers: Record = { authorization: `Bearer ${token}`, accept: 'application/json' } + let payload: string | FormData | undefined + if (body?.json !== undefined) { + headers['content-type'] = 'application/json' + payload = JSON.stringify(body.json) + } else if (body?.form) { + payload = body.form + } + return this.fetchImpl(new URL(path, await this.server()), { + method, + headers, + ...(payload !== undefined ? { body: payload } : {}), + ...(this.options.signal ? { signal: this.options.signal } : {}), + }) + } + + let response = await send(await this.bearer()) + if (response.status === 401 && (await this.credential()) === 'oauth') { + response = await send(await this.bearer(true)) + } + if (!response.ok) throw await apiError(response) + return (await response.json()) as T + } + + me(): Promise { + return this.request('GET', '/api/v1/me') + } + + async createScan(input: { name: string; tier: string; provider_key_id?: string; categories?: string[] }): Promise<{ id: string }> { + const result = await this.request<{ scan: { id: string } }>('POST', '/api/v1/scans', { + json: { ...input, source: 'api' }, + }) + return result.scan + } + + uploadFiles(scanId: string, form: FormData): Promise { + return this.request('POST', `/api/v1/scans/${encodeURIComponent(scanId)}/files`, { form }) + } + + files(scanId: string, finishedAfter: string): Promise<{ files: FileResult[]; next_finished_after: string | null }> { + const query = new URLSearchParams({ finished_after: finishedAfter }) + return this.request('GET', `/api/v1/scans/${encodeURIComponent(scanId)}/files?${query}`) + } + + async scan(scanId: string): Promise { + return (await this.request<{ scan: ScanSummary }>('GET', `/api/v1/scans/${encodeURIComponent(scanId)}`)).scan + } +} + +/** + * The tier to scan with: the setting if there is one, else the first tier the + * server says is online, else bring-your-own-key with the first provider. + */ +export function chooseTier( + me: Me, + setting: { tier: string; providerId: string }, +): { tier: string; provider_key_id?: string } { + const providerId = setting.providerId || me.providers[0]?.id + if (setting.tier) { + if (setting.tier === 'byok') { + if (!providerId) throw new Error('Tier "byok" needs a provider key; add one at MediaAnalyzer first.') + return { tier: 'byok', provider_key_id: providerId } + } + return { tier: setting.tier } + } + const online = me.tiers.find((tier) => tier.online) + if (online) return { tier: online.id } + if (providerId) return { tier: 'byok', provider_key_id: providerId } + throw new Error('No MediaAnalyzer tier is online right now, and you have no provider key for bring-your-own-key.') +} diff --git a/packages/plugin-mediaanalyzer/src/fake-server.fixture.ts b/packages/plugin-mediaanalyzer/src/fake-server.fixture.ts new file mode 100644 index 0000000..599fe62 --- /dev/null +++ b/packages/plugin-mediaanalyzer/src/fake-server.fixture.ts @@ -0,0 +1,264 @@ +/** + * A MediaAnalyzer server small enough to read, on a real local port. + * + * It implements what DiskPush uses and checks what the real one checks: the + * PKCE verifier against the challenge, the client id, the redirect URI, that a + * refresh token works exactly once (and that replaying one revokes the whole + * login), at most 50 files per upload, and credit. Tests drive the "browser" + * through `approve`, which does what a person clicking Allow would. + */ +import { createHash } from 'node:crypto' +import { createServer, type IncomingMessage, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' + +type Pending = { challenge: string; redirectUri: string; clientId: string } +type Uploaded = { + client_ref: string + name: string + rel_path: string + kind: string + bytes: number + frames?: number + status: 'queued' | 'done' | 'error' + category: string | null + description: string | null + tags: string[] + error: string | null + finished_at: string | null + partSize: number +} + +export type FakeOptions = { + /** Files the account can pay for. */ + credit?: number + /** Results appear only after this many polls of the files endpoint. */ + pollsBeforeResults?: number + tiers?: { id: string; name: string; usd_per_file: number; model: string; online: boolean }[] + categorize?: (name: string) => string +} + +export class FakeMediaAnalyzer { + server!: Server + url = '' + readonly codes = new Map() + readonly access = new Set() + readonly refresh = new Map() + readonly scans = new Map() + readonly uploadBatches: number[] = [] + readonly tokenRequests: Record[] = [] + revoked: string[] = [] + familyRevoked = false + apiKey = 'ma_key_test' + credit: number + polls = 0 + private counter = 0 + private clock = Date.parse('2026-09-24T00:00:00.000Z') + + constructor(private readonly options: FakeOptions = {}) { + this.credit = options.credit ?? Infinity + } + + async start(): Promise { + this.server = createServer((req, res) => { + void this.adapt(req).then(async (response) => { + res.writeHead(response.status, Object.fromEntries(response.headers)) + res.end(Buffer.from(await response.arrayBuffer())) + }) + }) + await new Promise((resolve) => this.server.listen(0, '127.0.0.1', resolve)) + this.url = `http://127.0.0.1:${(this.server.address() as AddressInfo).port}` + return this + } + + async stop(): Promise { + this.server.closeAllConnections() + await new Promise((resolve) => this.server.close(resolve)) + } + + private async adapt(req: IncomingMessage): Promise { + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(chunk as Buffer) + const body = chunks.length > 0 ? Buffer.concat(chunks) : undefined + const headers = Object.fromEntries( + Object.entries(req.headers).filter( + ([name, value]) => typeof value === 'string' && !['host', 'connection', 'content-length', 'transfer-encoding', 'keep-alive'].includes(name), + ), + ) as Record + const request = new Request(new URL(req.url ?? '/', this.url), { + method: req.method ?? 'GET', + headers, + ...(body && req.method !== 'GET' ? { body } : {}), + }) + try { + return await this.handle(request) + } catch (error) { + return json(500, { error: { code: 'internal', message: String(error) } }) + } + } + + /** What the browser does when the user approves: the server issues a code and redirects. */ + async approve(authorizeUrl: string): Promise { + const url = new URL(authorizeUrl) + const redirectUri = url.searchParams.get('redirect_uri')! + const code = `code_${++this.counter}` + this.codes.set(code, { + challenge: url.searchParams.get('code_challenge')!, + redirectUri, + clientId: url.searchParams.get('client_id')!, + }) + const back = new URL(redirectUri) + back.searchParams.set('code', code) + back.searchParams.set('state', url.searchParams.get('state')!) + const response = await fetch(back) + await response.text() + } + + /** Makes every live access token stale, as an hour passing would. */ + expireAccessTokens(): void { + this.access.clear() + } + + private issue(): Response { + const pair = { access_token: `ma_at_${++this.counter}`, refresh_token: `ma_rt_${++this.counter}` } + this.access.add(pair.access_token) + this.refresh.set(pair.refresh_token, 'live') + return json(200, { ...pair, token_type: 'Bearer', expires_in: 3600 }) + } + + private authorized(request: Request): boolean { + const token = request.headers.get('authorization')?.replace(/^Bearer /, '') ?? '' + return token === this.apiKey || this.access.has(token) + } + + private tick(): string { + this.clock += 1000 + return new Date(this.clock).toISOString() + } + + async handle(request: Request): Promise { + const url = new URL(request.url) + const path = url.pathname + + if (path === '/api/v1/oauth/token' && request.method === 'POST') { + const body = (await request.json()) as Record + this.tokenRequests.push(body) + if (body.client_id !== 'diskpush') return json(400, { error: 'invalid_client', error_description: 'unknown client' }) + if (body.grant_type === 'authorization_code') { + const pending = this.codes.get(body.code ?? '') + this.codes.delete(body.code ?? '') + const challenge = createHash('sha256').update(body.code_verifier ?? '').digest('base64url') + if (!pending || pending.challenge !== challenge || pending.redirectUri !== body.redirect_uri || !body.device_name) { + return json(400, { error: 'invalid_grant', error_description: 'bad code or verifier' }) + } + return this.issue() + } + if (body.grant_type === 'refresh_token') { + const status = this.refresh.get(body.refresh_token ?? '') + if (status !== 'live' || this.familyRevoked) { + // Replaying a spent token is theft as far as the server knows. + if (status === 'spent') this.familyRevoked = true + return json(400, { error: 'invalid_grant', error_description: 'refresh token already used' }) + } + this.refresh.set(body.refresh_token!, 'spent') + return this.issue() + } + return json(400, { error: 'unsupported_grant_type' }) + } + + if (path === '/api/v1/oauth/revoke' && request.method === 'POST') { + const body = (await request.json()) as { token: string } + this.revoked.push(body.token) + this.refresh.set(body.token, 'spent') + return json(200, {}) + } + + if (!this.authorized(request)) return json(401, { error: { code: 'unauthorized', message: 'Sign in again.' } }) + + if (path === '/api/v1/me') { + return json(200, { + user: { email: 'ada@example.com' }, + balance_usd: 5, + available_usd: 4.5, + categories: ['Pets', 'Landscapes'], + tiers: this.options.tiers ?? [ + { id: 'standard', name: 'Standard', usd_per_file: 0.002, model: 'm', online: false }, + { id: 'premium', name: 'Premium', usd_per_file: 0.01, model: 'm2', online: true }, + ], + providers: [{ id: 'pk_1', label: 'My key', kind: 'openai', model: 'gpt' }], + }) + } + + if (path === '/api/v1/scans' && request.method === 'POST') { + const body = (await request.json()) as { name: string; tier: string; provider_key_id?: string; categories?: string[]; source: string } + if (body.source !== 'api') return json(400, { error: { code: 'bad_source', message: 'source' } }) + const id = `scan_${++this.counter}` + this.scans.set(id, { id, tier: body.tier, ...(body.provider_key_id ? { provider_key_id: body.provider_key_id } : {}), ...(body.categories ? { categories: body.categories } : {}), files: [] }) + return json(201, { scan: { id, name: body.name, tier: body.tier } }) + } + + const scanMatch = /^\/api\/v1\/scans\/([^/]+)(\/files)?$/.exec(path) + const scan = scanMatch ? this.scans.get(decodeURIComponent(scanMatch[1]!)) : undefined + if (scanMatch && !scan) return json(404, { error: { code: 'not_found', message: 'No such scan.' } }) + + if (scan && scanMatch?.[2] && request.method === 'POST') { + const form = await request.formData() + const meta = JSON.parse(String(form.get('meta'))) as Omit[] + if (meta.length > 50) return json(413, { error: { code: 'too_many_files', message: 'at most 50' } }) + this.uploadBatches.push(meta.length) + const fresh = meta.filter((m) => !scan.files.some((f) => f.client_ref === m.client_ref)) + if (fresh.length > this.credit) { + return json(402, { error: { code: 'insufficient_credit', message: 'Not enough credit for these files. Add credit under Billing.' } }) + } + this.credit -= fresh.length + const rejected: { client_ref: string; reason: string }[] = [] + meta.forEach((m, index) => { + const part = form.get(`file${index}`) + if (!(part instanceof Blob)) rejected.push({ client_ref: m.client_ref, reason: 'missing file part' }) + else if (fresh.includes(m)) { + scan.files.push({ ...m, status: 'queued', category: null, description: null, tags: [], error: null, finished_at: null, partSize: part.size }) + } + }) + return json(202, { accepted: fresh.length - rejected.length, duplicates: meta.length - fresh.length, rejected }) + } + + if (scan && scanMatch?.[2]) { + this.polls += 1 + if (this.polls > (this.options.pollsBeforeResults ?? 0)) { + for (const file of scan.files) { + if (file.status !== 'queued') continue + const failing = file.name.includes('broken') + file.status = failing ? 'error' : 'done' + file.error = failing ? 'not a readable image' : null + file.category = failing ? null : (this.options.categorize ?? defaultCategory)(file.name) + file.description = failing ? null : `A picture called ${file.name}.` + file.tags = failing ? [] : ['test', file.kind] + file.finished_at = this.tick() + } + } + const raw = url.searchParams.get('finished_after') ?? '' + const since = raw ? Date.parse(raw) : -Infinity + const rows = scan.files + .filter((file) => file.finished_at && Date.parse(file.finished_at) >= since) + .sort((a, b) => a.finished_at!.localeCompare(b.finished_at!)) + return json(200, { files: rows, next_finished_after: rows.at(-1)?.finished_at ?? (raw || null) }) + } + + if (scan) { + const done = scan.files.filter((file) => file.status === 'done').length + const failed = scan.files.filter((file) => file.status === 'error').length + return json(200, { + scan: { id: scan.id, status: 'running', file_count: scan.files.length, done_count: done, error_count: failed, charged_usd: done * 0.01 }, + }) + } + + return json(404, { error: { code: 'not_found', message: path } }) + } +} + +function defaultCategory(name: string): string { + return /cat|dog/i.test(name) ? 'Pets' : 'Landscapes' +} + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) +} diff --git a/packages/plugin-mediaanalyzer/src/index.ts b/packages/plugin-mediaanalyzer/src/index.ts new file mode 100644 index 0000000..d1925fb --- /dev/null +++ b/packages/plugin-mediaanalyzer/src/index.ts @@ -0,0 +1,338 @@ +/** + * MediaAnalyzer for DiskPush: describe photos and videos with + * https://mediaanalyzer.pro, write the descriptions next to the files, and + * optionally sort the files into folders by what they show. + * + * Surfaces: + * CLI diskpush mediaanalyzer login | logout | whoami | analyze DIR [--sort] | undo DIR + * TUI `a` on a local file or folder + * desktop right-click a local selection → Plugins; sign in under Plugins… + */ +import { existsSync } from 'node:fs' +import { readdir } from 'node:fs/promises' +import { hostname } from 'node:os' +import { join, resolve } from 'node:path' +import { + definePlugin, + describeEntries, + type ActionResult, + type BaseContext, + type CommandContext, + type DiskpushPlugin, + type EntryRef, +} from '@diskpush/plugin-api' +import { analyze, type AnalyzeReport } from './analyze.js' +import { undoLastSort } from './apply.js' +import { ENV_KEY, MediaAnalyzerClient, NotSignedIn, clearTokens, saveTokens } from './client.js' +import { STATE_DIR } from './library.js' +import { mediaKind, type Ffmpeg } from './media.js' +import { DEFAULT_SERVER, loopbackLogin, normalizeServer, pasteLogin, revokeToken, type Fetch } from './oauth.js' + +export { analyze, batches, MAX_FILES_PER_REQUEST } from './analyze.js' +export { SIGNATURE, folderName, parseSidecar, sidecarText, sortIntoFolders, undoLastSort } from './apply.js' +export { MediaAnalyzerClient, chooseTier, ENV_KEY } from './client.js' +export { collectMedia, fileRef, loadState, STATE_DIR, STATE_FILE } from './library.js' +export { CLIENT_ID, DEFAULT_SERVER, authorizeUrl, pkcePair } from './oauth.js' + +export type MediaAnalyzerOptions = { + fetchImpl?: Fetch + /** Undefined looks for ffmpeg on PATH; null pretends there is none. */ + ffmpeg?: Ffmpeg | null + pollIntervalMs?: number + deviceName?: string + loginTimeoutMs?: number +} + +const appliesToMedia = (entries: readonly EntryRef[]) => + entries.some((entry) => entry.isDirectory || mediaKind(entry.name) !== null) + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export function createMediaAnalyzerPlugin(options: MediaAnalyzerOptions = {}): DiskpushPlugin { + const clientFor = (ctx: BaseContext) => + new MediaAnalyzerClient({ + settings: ctx.settings, + secrets: ctx.secrets, + env: ctx.env, + signal: ctx.signal, + ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}), + }) + + const deviceName = () => options.deviceName ?? `DiskPush on ${hostname()}` + + async function signIn(ctx: BaseContext, show?: (url: string) => void): Promise { + const client = clientFor(ctx) + const server = await client.server() + ctx.progress.update({ message: 'Waiting for you to approve DiskPush in the browser…' }) + const tokens = await loopbackLogin({ + server, + deviceName: deviceName(), + openUrl: ctx.openUrl, + signal: ctx.signal, + ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}), + ...(options.loginTimeoutMs ? { timeoutMs: options.loginTimeoutMs } : {}), + ...(show ? { onUrl: show } : {}), + }) + await saveTokens(ctx.secrets, tokens) + const me = await client.me() + return { ok: true, message: `Signed in to MediaAnalyzer as ${me.user.email}.` } + } + + async function signOut(ctx: BaseContext): Promise { + const refreshToken = await ctx.secrets.get('refresh_token') + if (refreshToken) { + try { + await revokeToken(await clientFor(ctx).server(), refreshToken, options.fetchImpl) + } catch (error) { + ctx.progress.log('warn', `Could not revoke the login on the server: ${describeError(error)}`) + } + } + await clearTokens(ctx.secrets) + await ctx.secrets.set('api_key', null) + return { ok: true, message: 'Signed out of MediaAnalyzer.' } + } + + const runAnalysis = async ( + ctx: BaseContext & { dir: string; entries: readonly EntryRef[] }, + sort: boolean, + ): Promise => { + const client = clientFor(ctx) + if (!(await client.credential())) throw new NotSignedIn() + return analyze({ + dir: ctx.dir, + entries: ctx.entries, + sort, + client, + settings: ctx.settings, + progress: ctx.progress, + signal: ctx.signal, + ...(options.ffmpeg !== undefined ? { ffmpeg: options.ffmpeg } : {}), + ...(options.pollIntervalMs !== undefined ? { pollIntervalMs: options.pollIntervalMs } : {}), + }) + } + + /** `analyze DIR` in the CLI means everything in DIR, as the folder's own contents. */ + const wholeDirectory = async (dir: string): Promise => + describeEntries(dir, (await readdir(dir)).filter((name) => !name.startsWith('.'))) + + const flag = (args: string[], name: string) => args.includes(name) + const value = (args: string[], name: string) => { + const at = args.indexOf(name) + return at === -1 ? undefined : args[at + 1] + } + const positional = (args: string[]) => args.filter((arg, index) => !arg.startsWith('-') && !['--server'].includes(args[index - 1] ?? '')) + + const fail = (ctx: CommandContext, message: string, code = 1) => { + if (ctx.json) ctx.printJson({ ok: false, message }) + else ctx.warn(message) + return code + } + + return definePlugin({ + id: 'mediaanalyzer', + name: 'MediaAnalyzer', + version: '0.11.0', + description: 'Describe photos and videos with mediaanalyzer.pro, and sort them into folders by what they show.', + settings: [ + { key: 'server', label: 'Server', type: 'string', default: DEFAULT_SERVER, description: 'The MediaAnalyzer server.' }, + { + key: 'tier', + label: 'Tier', + type: 'enum', + options: ['', 'standard', 'premium', 'byok'], + default: '', + description: 'Empty picks the first tier that is online, else your own provider key.', + }, + { key: 'providerId', label: 'Provider key id', type: 'string', default: '', description: 'For the byok tier. Empty uses your first key.' }, + { key: 'folders', label: 'Folders', type: 'string', default: '', description: 'Comma-separated categories to sort into. Empty uses the server defaults.' }, + { key: 'api_key', label: 'API key', type: 'secret', description: `An ma_key_… key, instead of signing in. ${ENV_KEY} overrides it.` }, + ], + async status(ctx) { + const client = clientFor(ctx) + const credential = await client.credential() + if (!credential) return 'Not signed in.' + try { + const me = await client.me() + return `Signed in as ${me.user.email} · $${me.available_usd.toFixed(2)} available` + } catch (error) { + if (error instanceof NotSignedIn) return 'Not signed in.' + return `Signed in (${describeError(error)})` + } + }, + tasks: [ + { id: 'login', label: 'Sign in to MediaAnalyzer', run: (ctx) => signIn(ctx) }, + { id: 'logout', label: 'Sign out', run: (ctx) => signOut(ctx) }, + ], + actions: [ + { + id: 'analyze', + label: 'Analyze media', + description: 'Describe each photo and video, in a .description.txt beside it.', + tuiKey: 'm', + appliesTo: appliesToMedia, + async run(ctx) { + const report = await runAnalysis(ctx, false) + return { ok: report.ok, message: report.message, changed: report.changed } + }, + }, + { + id: 'analyze-sort', + label: 'Analyze and sort into folders', + description: 'Describe each file, then move it and its description into a folder named for what it shows. Undoable.', + tuiKey: 'f', + appliesTo: appliesToMedia, + async run(ctx) { + const report = await runAnalysis(ctx, true) + return { ok: report.ok, message: report.message, changed: report.changed } + }, + }, + { + id: 'undo-sort', + label: 'Undo last sort', + description: 'Put back the files the last sort in this folder moved.', + tuiKey: 'u', + appliesTo: (_entries, { dir }) => dir !== '' && existsSync(join(dir, STATE_DIR)), + async run(ctx) { + const report = await undoLastSort(ctx.dir) + if (!report.journal) return { ok: false, message: 'No sort to undo in this folder.' } + for (const skipped of report.skipped) ctx.progress.log('warn', skipped) + return { + ok: report.skipped.length === 0, + message: `Put back ${report.restored} file${report.restored === 1 ? '' : 's'}${report.skipped.length ? `; ${report.skipped.length} could not be` : ''}.`, + changed: report.restored > 0, + } + }, + }, + ], + commands: [ + { + name: 'login', + summary: 'sign in with your browser (OAuth, PKCE)', + usage: 'login [--paste] [--server URL]', + async run(args, ctx) { + const server = value(args, '--server') + if (server) await ctx.settings.set('server', normalizeServer(server)) + try { + const headless = flag(args, '--paste') || (process.platform === 'linux' && !ctx.env.DISPLAY && !ctx.env.WAYLAND_DISPLAY) + if (headless) { + const client = clientFor(ctx) + const tokens = await pasteLogin({ + server: await client.server(), + deviceName: deviceName(), + show: (url) => ctx.warn(`Open this in a browser, approve DiskPush, and paste the code it shows:\n\n ${url}\n`), + readCode: () => ctx.prompt('Code: '), + ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}), + }) + await saveTokens(ctx.secrets, tokens) + const me = await client.me() + ctx.print(`Signed in to MediaAnalyzer as ${me.user.email}.`) + return 0 + } + const result = await signIn(ctx, (url) => ctx.warn(`Opening your browser. If it does not open, visit:\n ${url}`)) + if (ctx.json) ctx.printJson(result) + else ctx.print(result.message) + return 0 + } catch (error) { + return fail(ctx, describeError(error)) + } + }, + }, + { + name: 'logout', + summary: 'sign out, and revoke this login on the server', + usage: 'logout', + async run(_args, ctx) { + const result = await signOut(ctx) + if (ctx.json) ctx.printJson(result) + else ctx.print(result.message) + return 0 + }, + }, + { + name: 'whoami', + summary: 'who you are signed in as, your credit, and the tiers', + usage: 'whoami', + async run(_args, ctx) { + try { + const client = clientFor(ctx) + if (!(await client.credential())) throw new NotSignedIn() + const me = await client.me() + if (ctx.json) { + ctx.printJson(me) + return 0 + } + ctx.print(`${me.user.email} on ${await client.server()}`) + ctx.print(` balance $${me.balance_usd.toFixed(2)} ($${me.available_usd.toFixed(2)} available)`) + for (const tier of me.tiers) { + ctx.print(` tier ${tier.id.padEnd(10)} $${tier.usd_per_file.toFixed(4)}/file ${tier.online ? 'online' : 'offline'}`) + } + for (const provider of me.providers) ctx.print(` provider ${provider.id} ${provider.label} (${provider.model})`) + return 0 + } catch (error) { + return fail(ctx, describeError(error)) + } + }, + }, + { + name: 'analyze', + summary: 'describe every photo and video in DIR (recursively); --sort also files them into folders', + usage: 'analyze DIR [--sort]', + async run(args, ctx) { + const [target] = positional(args) + if (!target) return fail(ctx, 'usage: diskpush mediaanalyzer analyze DIR [--sort]', 64) + const dir = resolve(ctx.cwd, target) + try { + const entries = await wholeDirectory(dir) + const report = await runAnalysis({ ...ctx, dir, entries }, flag(args, '--sort')) + if (ctx.json) ctx.printJson(report) + else ctx.print(report.message) + return report.ok ? 0 : 1 + } catch (error) { + return fail(ctx, describeError(error)) + } + }, + }, + { + name: 'undo', + summary: 'put back the files the last --sort in DIR moved', + usage: 'undo DIR', + async run(args, ctx) { + const [target] = positional(args) + if (!target) return fail(ctx, 'usage: diskpush mediaanalyzer undo DIR', 64) + const report = await undoLastSort(resolve(ctx.cwd, target)) + if (ctx.json) ctx.printJson(report) + else if (!report.journal) ctx.print('No sort to undo there.') + else { + ctx.print(`Put back ${report.restored} file${report.restored === 1 ? '' : 's'}.`) + for (const skipped of report.skipped) ctx.warn(` skipped ${skipped}`) + } + return report.skipped.length === 0 ? 0 : 1 + }, + }, + { + name: 'key', + summary: `use an API key (ma_key_…) instead of signing in; --clear forgets it`, + usage: 'key [KEY] | key --clear', + async run(args, ctx) { + if (flag(args, '--clear')) { + await ctx.secrets.set('api_key', null) + ctx.print('API key forgotten.') + return 0 + } + // Asked for rather than taken from argv when it can be, so it stays + // out of shell history and `ps`. + const key = positional(args)[0] ?? (await ctx.prompt('API key (ma_key_…): '))?.trim() + if (!key || !key.startsWith('ma_key_')) return fail(ctx, 'usage: diskpush mediaanalyzer key ma_key_…', 64) + await ctx.secrets.set('api_key', key) + ctx.print('API key saved.') + return 0 + }, + }, + ], + }) +} + +export const mediaAnalyzerPlugin = createMediaAnalyzerPlugin() +export default mediaAnalyzerPlugin diff --git a/packages/plugin-mediaanalyzer/src/library.ts b/packages/plugin-mediaanalyzer/src/library.ts new file mode 100644 index 0000000..9820338 --- /dev/null +++ b/packages/plugin-mediaanalyzer/src/library.ts @@ -0,0 +1,121 @@ +/** + * The files an action covers, and what DiskPush remembers about them. + * + * A selection is walked into a flat list of media files, each with a + * `client_ref` that is stable for as long as the file is: the same formula + * the MediaAnalyzer CLI uses (sha256 of relative path, size and mtime), so a + * retry, a resumed run or a run from the other tool never pays twice. + * + * The state file lives beside the media, in `/.mediaanalyzer/`, so it + * travels with the folder and a second machine picks up where the first left. + */ +import { createHash } from 'node:crypto' +import { lstat, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises' +import { join, posix } from 'node:path' +import type { EntryRef } from '@diskpush/plugin-api' +import { mediaKind, type MediaKind } from './media.js' + +export const STATE_DIR = '.mediaanalyzer' +export const STATE_FILE = 'diskpush-state.json' +export const SIDECAR_SUFFIX = '.description.txt' + +export type MediaFile = { + abs: string + /** Relative to the action's directory, with forward slashes. */ + rel: string + name: string + kind: MediaKind + bytes: number + mtimeMs: number + ref: string +} + +export function fileRef(rel: string, bytes: number, mtimeMs: number): string { + return createHash('sha256').update(`${rel}\0${bytes}\0${Math.floor(mtimeMs)}`).digest('hex').slice(0, 32) +} + +/** Skipped while walking: hidden entries, our own state, and sidecars. */ +function ignored(name: string): boolean { + return name.startsWith('.') || name === STATE_DIR || name.endsWith(SIDECAR_SUFFIX) +} + +/** + * Every media file under the selection, folders walked recursively. Symbolic + * links are not followed: a link can point anywhere, and an action on "this + * folder" must not read or move things outside it. + */ +export async function collectMedia(dir: string, entries: readonly EntryRef[], signal?: AbortSignal): Promise { + const out: MediaFile[] = [] + const visit = async (rel: string): Promise => { + signal?.throwIfAborted() + const abs = join(dir, ...rel.split('/')) + const stats = await lstat(abs).catch(() => null) + if (!stats || stats.isSymbolicLink()) return + if (stats.isDirectory()) { + const names = (await readdir(abs).catch(() => [] as string[])).sort() + for (const name of names) if (!ignored(name)) await visit(posix.join(rel, name)) + return + } + if (!stats.isFile()) return + const name = posix.basename(rel) + const kind = mediaKind(name) + if (!kind || name.endsWith(SIDECAR_SUFFIX)) return + out.push({ abs, rel, name, kind, bytes: stats.size, mtimeMs: stats.mtimeMs, ref: fileRef(rel, stats.size, stats.mtimeMs) }) + } + for (const entry of entries) { + // A hidden entry the user picked explicitly is still theirs to analyse. + if (entry.name === STATE_DIR) continue + await visit(entry.name) + } + return out +} + +export type Result = { + status: 'done' | 'error' + category: string | null + description: string | null + tags: string[] + error: string | null +} + +export type FileState = { + rel: string + uploaded: boolean + /** Why the server or DiskPush would not take it. */ + skipped?: string + result?: Result +} + +export type State = { + version: 1 + server: string | null + scanId: string | null + tier: string | null + /** `next_finished_after` from the last poll. Inclusive, so results are deduped by ref. */ + cursor: string + files: Record +} + +export function blankState(): State { + return { version: 1, server: null, scanId: null, tier: null, cursor: '', files: {} } +} + +export async function loadState(dir: string): Promise { + try { + const parsed = JSON.parse(await readFile(join(dir, STATE_DIR, STATE_FILE), 'utf8')) as Partial + if (parsed.version !== 1 || typeof parsed.files !== 'object' || parsed.files === null) return blankState() + return { ...blankState(), ...parsed, files: parsed.files } + } catch { + return blankState() + } +} + +/** Written to a temporary name and renamed, so an interrupted write never leaves half a file. */ +export async function saveState(dir: string, state: State): Promise { + const folder = join(dir, STATE_DIR) + await mkdir(folder, { recursive: true }) + const path = join(folder, STATE_FILE) + const temporary = `${path}.${process.pid}.tmp` + await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`) + await rename(temporary, path) +} diff --git a/packages/plugin-mediaanalyzer/src/media.ts b/packages/plugin-mediaanalyzer/src/media.ts new file mode 100644 index 0000000..ed9cc72 --- /dev/null +++ b/packages/plugin-mediaanalyzer/src/media.ts @@ -0,0 +1,103 @@ +/** + * Which files are media, and how each is sent. + * + * A photo goes up as its original bytes: the server re-encodes to 768px and + * strips EXIF itself, so there is no image library here (and so no native + * addon for the desktop bundle to carry). A video goes up as one JPEG contact + * sheet of nine frames, which needs ffmpeg; without it videos are skipped and + * the run says so. + */ +import { execFile, spawn } from 'node:child_process' +import { extname } from 'node:path' + +export const PHOTO_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'heic', 'heif', 'avif', 'gif', 'bmp', 'tif', 'tiff']) +export const VIDEO_EXTENSIONS = new Set(['mp4', 'mov', 'm4v', 'webm', 'mkv', 'avi']) + +export type MediaKind = 'photo' | 'video' + +/** The server's per-file ceiling. */ +export const MAX_UPLOAD_BYTES = 30 * 1024 * 1024 + +export function extensionOf(name: string): string { + return extname(name).slice(1).toLowerCase() +} + +export function mediaKind(name: string): MediaKind | null { + const extension = extensionOf(name) + if (PHOTO_EXTENSIONS.has(extension)) return 'photo' + if (VIDEO_EXTENSIONS.has(extension)) return 'video' + return null +} + +const MIME: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', + heic: 'image/heic', + heif: 'image/heif', + avif: 'image/avif', + gif: 'image/gif', + bmp: 'image/bmp', + tif: 'image/tiff', + tiff: 'image/tiff', +} + +export function mimeType(name: string): string { + return MIME[extensionOf(name)] ?? 'application/octet-stream' +} + +/** Runs a program to completion and returns stdout as bytes. */ +function capture(command: string, args: string[], signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], ...(signal ? { signal } : {}) }) + const out: Buffer[] = [] + let err = '' + child.stdout.on('data', (chunk: Buffer) => out.push(chunk)) + child.stderr.on('data', (chunk: Buffer) => { + err += chunk.toString() + }) + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) resolve(Buffer.concat(out)) + else reject(new Error(`${command} exited ${code}: ${err.trim().split('\n').at(-1) ?? ''}`)) + }) + }) +} + +export type Ffmpeg = { + /** Seconds, from ffprobe. */ + duration(path: string, signal?: AbortSignal): Promise + /** A 3×3 JPEG contact sheet of the whole video. */ + contactSheet(path: string, duration: number, signal?: AbortSignal): Promise +} + +/** ffmpeg and ffprobe from PATH, or null when either is missing. */ +export async function findFfmpeg(): Promise { + const present = (command: string) => + new Promise((resolve) => { + execFile(command, ['-version'], (error) => resolve(!error)) + }) + if (!(await present('ffmpeg')) || !(await present('ffprobe'))) return null + return { + async duration(path, signal) { + const out = await capture( + 'ffprobe', + ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', path], + signal, + ) + const seconds = Number.parseFloat(out.toString().trim()) + if (!Number.isFinite(seconds) || seconds <= 0) throw new Error('ffprobe could not read a duration') + return seconds + }, + contactSheet(path, duration, signal) { + // Nine frames spread over the whole video, tiled 3×3 at 256px wide. + const fps = `fps=9/${Math.max(duration, 0.1).toFixed(3)},scale=256:-2,tile=3x3` + return capture( + 'ffmpeg', + ['-v', 'error', '-i', path, '-vf', fps, '-frames:v', '1', '-f', 'image2', '-vcodec', 'mjpeg', '-q:v', '4', 'pipe:1'], + signal, + ) + }, + } +} diff --git a/packages/plugin-mediaanalyzer/src/oauth.ts b/packages/plugin-mediaanalyzer/src/oauth.ts new file mode 100644 index 0000000..63027e1 --- /dev/null +++ b/packages/plugin-mediaanalyzer/src/oauth.ts @@ -0,0 +1,238 @@ +/** + * Signing in: OAuth 2.1 authorization code with PKCE (S256) and a loopback + * redirect, the same flow the MediaAnalyzer CLI uses, as the `diskpush` client. + * + * 1. listen on 127.0.0.1: + * 2. open /oauth/authorize in the browser with a code challenge and a state + * 3. the browser comes back to http://127.0.0.1:/callback?code&state + * 4. POST the code and the verifier to /api/v1/oauth/token + * + * Refresh tokens rotate: each one works exactly once, and presenting a spent + * one revokes the whole login. So the new pair is stored the moment it + * arrives, before anything else can fail. + * + * Where no browser can reach this machine (an ssh session), the headless + * variant uses `${server}/oauth/code` as the redirect, which shows the code on + * the page for the user to paste. + */ +import { createHash, randomBytes } from 'node:crypto' +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' + +export const CLIENT_ID = 'diskpush' +export const DEFAULT_SERVER = 'https://mediaanalyzer.pro' + +export type Tokens = { + access_token: string + refresh_token: string + token_type: string + expires_in: number +} + +export type Fetch = typeof fetch + +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly code: string | null, + ) { + super(message) + this.name = 'ApiError' + } +} + +const base64url = (bytes: Buffer) => bytes.toString('base64url') + +export function pkcePair(): { verifier: string; challenge: string } { + const verifier = base64url(randomBytes(32)) + const challenge = base64url(createHash('sha256').update(verifier).digest()) + return { verifier, challenge } +} + +export function normalizeServer(server: string): string { + const url = new URL(server) + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))) { + throw new Error('The MediaAnalyzer server must be https (or http on localhost).') + } + return url.origin +} + +export function authorizeUrl( + server: string, + options: { redirectUri: string; challenge: string; state: string; deviceName: string }, +): string { + const url = new URL('/oauth/authorize', server) + url.search = new URLSearchParams({ + response_type: 'code', + client_id: CLIENT_ID, + redirect_uri: options.redirectUri, + code_challenge: options.challenge, + code_challenge_method: 'S256', + state: options.state, + device_name: options.deviceName, + }).toString() + return url.toString() +} + +/** Parses an error envelope, `{error:{code,message}}`, into an ApiError. */ +export async function apiError(response: Response): Promise { + let code: string | null = null + let message = `${response.status} ${response.statusText}`.trim() + try { + const body = (await response.json()) as { error?: { code?: string; message?: string } | string; error_description?: string } + if (body.error && typeof body.error === 'object') { + code = body.error.code ?? null + message = body.error.message ?? message + } else if (typeof body.error === 'string') { + // RFC 6749 token errors: {error: "invalid_grant", error_description}. + code = body.error + message = body.error_description ?? body.error + } + } catch { + // Not JSON: the status line is all there is. + } + return new ApiError(message, response.status, code) +} + +async function tokenRequest(server: string, body: Record, fetchImpl: Fetch): Promise { + const response = await fetchImpl(new URL('/api/v1/oauth/token', server), { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify(body), + }) + if (!response.ok) throw await apiError(response) + const tokens = (await response.json()) as Tokens + if (!tokens.access_token || !tokens.refresh_token) throw new Error('The token endpoint returned no tokens.') + return tokens +} + +export function exchangeCode( + server: string, + options: { code: string; verifier: string; redirectUri: string; deviceName: string }, + fetchImpl: Fetch = fetch, +): Promise { + return tokenRequest( + server, + { + grant_type: 'authorization_code', + code: options.code, + code_verifier: options.verifier, + client_id: CLIENT_ID, + redirect_uri: options.redirectUri, + device_name: options.deviceName, + }, + fetchImpl, + ) +} + +export function refreshTokens(server: string, refreshToken: string, fetchImpl: Fetch = fetch): Promise { + return tokenRequest(server, { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT_ID }, fetchImpl) +} + +export async function revokeToken(server: string, token: string, fetchImpl: Fetch = fetch): Promise { + const response = await fetchImpl(new URL('/api/v1/oauth/revoke', server), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token }), + }) + // Revoking something already gone is still signed out. + if (!response.ok && response.status !== 400 && response.status !== 404) throw await apiError(response) +} + +const PAGE = (title: string, text: string) => + `${title}` + + `` + + `

${title}

${text}

` + +export type LoopbackOptions = { + server: string + deviceName: string + openUrl: (url: string) => Promise + signal: AbortSignal + /** How long to wait for the browser. */ + timeoutMs?: number + fetchImpl?: Fetch + /** Told the URL too, for a surface that can print it in case the browser did not open. */ + onUrl?: (url: string) => void +} + +/** The loopback flow, end to end. Resolves with the tokens. */ +export async function loopbackLogin(options: LoopbackOptions): Promise { + const { verifier, challenge } = pkcePair() + const state = base64url(randomBytes(16)) + + let settle!: { resolve: (code: string) => void; reject: (error: Error) => void } + const codePromise = new Promise((resolve, reject) => { + settle = { resolve, reject } + }) + + const server = createServer((request, response) => { + const url = new URL(request.url ?? '/', 'http://127.0.0.1') + if (url.pathname !== '/callback') { + response.writeHead(404).end() + return + } + const error = url.searchParams.get('error') + const code = url.searchParams.get('code') + // A state that does not match is somebody else's redirect; it answers + // nothing and ends nothing. + if (url.searchParams.get('state') !== state) { + response.writeHead(400, { 'content-type': 'text/html' }).end(PAGE('Sign-in failed', 'That link was not for this sign-in.')) + return + } + if (error || !code) { + response.writeHead(400, { 'content-type': 'text/html' }).end(PAGE('Sign-in cancelled', 'You can close this tab.')) + settle.reject(new Error(error === 'access_denied' ? 'Sign-in was declined.' : `Sign-in failed: ${error ?? 'no code'}`)) + return + } + response + .writeHead(200, { 'content-type': 'text/html' }) + .end(PAGE('DiskPush is signed in to MediaAnalyzer', 'You can close this tab and go back to DiskPush.')) + settle.resolve(code) + }) + + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => resolve()) + }) + const port = (server.address() as AddressInfo).port + const redirectUri = `http://127.0.0.1:${port}/callback` + + const onAbort = () => settle.reject(new Error('Sign-in cancelled.')) + options.signal.addEventListener('abort', onAbort, { once: true }) + const timer = setTimeout(() => settle.reject(new Error('Timed out waiting for the browser.')), options.timeoutMs ?? 5 * 60_000) + + try { + const url = authorizeUrl(options.server, { redirectUri, challenge, state, deviceName: options.deviceName }) + options.onUrl?.(url) + await options.openUrl(url) + const code = await codePromise + return await exchangeCode(options.server, { code, verifier, redirectUri, deviceName: options.deviceName }, options.fetchImpl) + } finally { + clearTimeout(timer) + options.signal.removeEventListener('abort', onAbort) + server.closeAllConnections() + server.close() + } +} + +/** + * The headless flow: the server shows the code instead of redirecting to this + * machine. `readCode` asks the user for it. + */ +export async function pasteLogin(options: { + server: string + deviceName: string + show: (url: string) => void + readCode: () => Promise + fetchImpl?: Fetch +}): Promise { + const { verifier, challenge } = pkcePair() + const state = base64url(randomBytes(16)) + const redirectUri = new URL('/oauth/code', options.server).toString() + options.show(authorizeUrl(options.server, { redirectUri, challenge, state, deviceName: options.deviceName })) + const code = (await options.readCode())?.trim() + if (!code) throw new Error('No code entered.') + return exchangeCode(options.server, { code, verifier, redirectUri, deviceName: options.deviceName }, options.fetchImpl) +} diff --git a/packages/plugin-mediaanalyzer/src/plugin.test.ts b/packages/plugin-mediaanalyzer/src/plugin.test.ts new file mode 100644 index 0000000..b0cfbf6 --- /dev/null +++ b/packages/plugin-mediaanalyzer/src/plugin.test.ts @@ -0,0 +1,315 @@ +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { PluginRegistry, memoryBackend, runAction, runTask, silentProgress, type LogLevel } from '@diskpush/plugin-api' +import { FakeMediaAnalyzer, type FakeOptions } from './fake-server.fixture.js' +import { MediaAnalyzerClient, chooseTier } from './client.js' +import { SIGNATURE, batches, createMediaAnalyzerPlugin, loadState, sidecarText } from './index.js' + +const servers: FakeMediaAnalyzer[] = [] +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.stop())) +}) + +async function setup(fakeOptions: FakeOptions = {}, env: Record = { DISKPUSH_MEDIAANALYZER_KEY: 'ma_key_test' }) { + const fake = await new FakeMediaAnalyzer(fakeOptions).start() + servers.push(fake) + const registry = new PluginRegistry(memoryBackend()) + registry.register(createMediaAnalyzerPlugin({ ffmpeg: null, pollIntervalMs: 5, deviceName: 'test box', loginTimeoutMs: 5000 })) + await registry.settingsFor('mediaanalyzer').set('server', fake.url) + const logs: { level: LogLevel; message: string }[] = [] + const host = (overrides: Partial<{ openUrl: (url: string) => Promise; signal: AbortSignal }> = {}) => ({ + progress: { ...silentProgress, log: (level: LogLevel, message: string) => logs.push({ level, message }) }, + openUrl: async () => {}, + signal: new AbortController().signal, + surface: 'desktop' as const, + env, + ...overrides, + }) + return { fake, registry, logs, host } +} + +function photos(count: number, names = (i: number) => `img${String(i).padStart(3, '0')}.jpg`): string { + const dir = mkdtempSync(join(tmpdir(), 'dp-ma-')) + for (let i = 0; i < count; i += 1) writeFileSync(join(dir, names(i)), `jpeg bytes ${i}`) + return dir +} + +/** Every file under dir, relative, with its content: the "exact tree" undo must restore. */ +function tree(dir: string, skip = (rel: string) => rel.startsWith('.mediaanalyzer')): Record { + const out: Record = {} + const walk = (at: string) => { + for (const name of readdirSync(at)) { + const abs = join(at, name) + const rel = relative(dir, abs) + if (skip(rel)) continue + if (statSync(abs).isDirectory()) walk(abs) + else out[rel] = readFileSync(abs, 'utf8') + } + } + walk(dir) + return out +} + +describe('sign-in', () => { + it('runs the loopback PKCE flow as the diskpush client and stores the tokens', async () => { + const { fake, registry, host } = await setup({}, {}) + let opened = '' + const result = await runTask(registry, 'mediaanalyzer', 'login', host({ + openUrl: async (url) => { + opened = url + await fake.approve(url) + }, + })) + expect(result).toEqual({ ok: true, message: 'Signed in to MediaAnalyzer as ada@example.com.' }) + + const url = new URL(opened) + expect(url.pathname).toBe('/oauth/authorize') + expect(url.searchParams.get('client_id')).toBe('diskpush') + expect(url.searchParams.get('code_challenge_method')).toBe('S256') + expect(url.searchParams.get('redirect_uri')).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/callback$/) + expect(url.searchParams.get('device_name')).toBe('test box') + + const exchange = fake.tokenRequests[0]! + expect(exchange).toMatchObject({ grant_type: 'authorization_code', client_id: 'diskpush', device_name: 'test box' }) + expect(exchange.redirect_uri).toBe(url.searchParams.get('redirect_uri')) + + const secrets = registry.secretsFor('mediaanalyzer') + expect(await secrets.get('refresh_token')).toMatch(/^ma_rt_/) + expect(await secrets.get('access_token')).toMatch(/^ma_at_/) + }) + + it('rotates refresh tokens: stores each new one at once and never presents a spent one', async () => { + const { fake, registry, host } = await setup({}, {}) + await runTask(registry, 'mediaanalyzer', 'login', host({ openUrl: (url) => fake.approve(url) })) + const secrets = registry.secretsFor('mediaanalyzer') + const client = new MediaAnalyzerClient({ settings: registry.settingsFor('mediaanalyzer'), secrets, env: {} }) + + for (let round = 0; round < 3; round += 1) { + const before = await secrets.get('refresh_token') + fake.expireAccessTokens() + await secrets.set('access_expires_at', '0') + // Two calls at once must share one refresh: two would replay a token. + await Promise.all([client.me(), client.me()]) + const after = await secrets.get('refresh_token') + expect(after).not.toBe(before) + expect(fake.refresh.get(before!)).toBe('spent') + } + expect(fake.familyRevoked).toBe(false) + expect(fake.tokenRequests.filter((request) => request.grant_type === 'refresh_token')).toHaveLength(3) + }) + + it('retries once with a fresh token when the server turns the access token away', async () => { + const { fake, registry, host } = await setup({}, {}) + await runTask(registry, 'mediaanalyzer', 'login', host({ openUrl: (url) => fake.approve(url) })) + fake.expireAccessTokens() // expired early, from the server's point of view + const client = new MediaAnalyzerClient({ + settings: registry.settingsFor('mediaanalyzer'), + secrets: registry.secretsFor('mediaanalyzer'), + env: {}, + }) + expect((await client.me()).user.email).toBe('ada@example.com') + }) + + it('signs out by revoking the refresh token', async () => { + const { fake, registry, host } = await setup({}, {}) + await runTask(registry, 'mediaanalyzer', 'login', host({ openUrl: (url) => fake.approve(url) })) + const refresh = await registry.secretsFor('mediaanalyzer').get('refresh_token') + await runTask(registry, 'mediaanalyzer', 'logout', host()) + expect(fake.revoked).toEqual([refresh]) + expect(await registry.secretsFor('mediaanalyzer').get('refresh_token')).toBeNull() + }) + + it('says how to sign in when there is no credential', async () => { + const { registry, host } = await setup({}, {}) + const dir = photos(1) + const result = await runAction(registry, 'mediaanalyzer', 'analyze', { ...host(), dir, names: ['img000.jpg'] }) + expect(result.ok).toBe(false) + expect(result.message).toMatch(/diskpush mediaanalyzer login/) + }) +}) + +describe('analyze', () => { + it('uploads at most 50 files per request and writes a signed sidecar for each', async () => { + const { fake, registry, host } = await setup() + const dir = photos(120) + const names = readdirSync(dir) + const result = await runAction(registry, 'mediaanalyzer', 'analyze', { ...host(), dir, names }) + + expect(result.ok).toBe(true) + expect(result.changed).toBe(true) + expect(result.message).toMatch(/^Described 120 of 120 files/) + expect(fake.uploadBatches).toEqual([50, 50, 20]) + // The first tier that is online, since none was chosen. + expect([...fake.scans.values()][0]!.tier).toBe('premium') + + const sidecar = readFileSync(join(dir, 'img007.jpg.description.txt'), 'utf8') + expect(sidecar).toBe('A picture called img007.jpg.\n\nFolder: Landscapes\nTags: test, photo\n' + SIGNATURE + '\n') + }) + + it('never uploads the same file twice: a second run resumes from the state file', async () => { + const { fake, registry, host } = await setup() + const dir = photos(3) + await runAction(registry, 'mediaanalyzer', 'analyze', { ...host(), dir, names: readdirSync(dir) }) + const state = await loadState(dir) + expect(Object.keys(state.files)).toHaveLength(3) + expect(Object.keys(state.files)[0]).toMatch(/^[0-9a-f]{32}$/) + + const again = await runAction(registry, 'mediaanalyzer', 'analyze', { ...host(), dir, names: readdirSync(dir) }) + expect(again.ok).toBe(true) + expect(fake.uploadBatches).toEqual([3]) + }) + + it('stops uploading when credit runs out, keeps what was accepted, and says so', async () => { + const { fake, registry, host, logs } = await setup({ credit: 60 }) + const dir = photos(120) + const result = await runAction(registry, 'mediaanalyzer', 'analyze', { ...host(), dir, names: readdirSync(dir) }) + expect(result.ok).toBe(false) + expect(result.message).toMatch(/Described 50 of 120 files, 70 skipped/) + expect(result.message).toMatch(/Out of credit/) + // The first batch went up; the second was refused; the third was never tried. + expect(fake.uploadBatches).toEqual([50, 50]) + expect(logs.some((log) => log.level === 'error' && /credit/.test(log.message))).toBe(true) + }) + + it('syncs results by finished_after, deduping the inclusive cursor', async () => { + const { fake, registry, host } = await setup({ pollsBeforeResults: 2 }) + const dir = photos(4, (i) => (i === 3 ? 'broken.jpg' : `ok${i}.jpg`)) + const result = await runAction(registry, 'mediaanalyzer', 'analyze', { ...host(), dir, names: readdirSync(dir) }) + expect(result.message).toMatch(/Described 3 of 4 files, 1 failed/) + expect(fake.polls).toBeGreaterThanOrEqual(3) + const state = await loadState(dir) + expect(state.cursor).toMatch(/^2026-09-24T/) + }) + + it('leaves a sidecar it did not write exactly as it was', async () => { + const { registry, host, logs } = await setup() + const dir = photos(1) + writeFileSync(join(dir, 'img000.jpg.description.txt'), 'my own notes\n') + await runAction(registry, 'mediaanalyzer', 'analyze', { ...host(), dir, names: ['img000.jpg'] }) + expect(readFileSync(join(dir, 'img000.jpg.description.txt'), 'utf8')).toBe('my own notes\n') + expect(logs.some((log) => log.level === 'warn' && /not ours/.test(log.message))).toBe(true) + }) + + it('does not pay again for a file that already has our sidecar', async () => { + const { fake, registry, host } = await setup() + const dir = photos(2) + writeFileSync(join(dir, 'img000.jpg.description.txt'), sidecarText({ description: 'x', category: 'Pets', tags: [] })) + await runAction(registry, 'mediaanalyzer', 'analyze', { ...host(), dir, names: readdirSync(dir) }) + expect(fake.uploadBatches).toEqual([1]) + }) + + it('skips videos when there is no ffmpeg, and sends a contact sheet when there is', async () => { + const { fake, registry, host, logs } = await setup() + const dir = photos(1) + writeFileSync(join(dir, 'clip.mp4'), 'not really a video') + const without = await runAction(registry, 'mediaanalyzer', 'analyze', { ...host(), dir, names: ['clip.mp4'] }) + expect(without.message).toMatch(/1 skipped/) + expect(logs.some((log) => /ffmpeg/.test(log.message))).toBe(true) + + const withFfmpeg = new PluginRegistry(memoryBackend()) + withFfmpeg.register( + createMediaAnalyzerPlugin({ + pollIntervalMs: 5, + ffmpeg: { duration: async () => 12.34, contactSheet: async () => Buffer.from('sheet') }, + }), + ) + await withFfmpeg.settingsFor('mediaanalyzer').set('server', fake.url) + await runAction(withFfmpeg, 'mediaanalyzer', 'analyze', { ...host(), dir, names: ['clip.mp4'] }) + const video = [...fake.scans.values()].flatMap((scan) => scan.files).find((file) => file.name === 'clip.mp4')! + expect(video).toMatchObject({ kind: 'video', frames: 9, partSize: 5, bytes: 18 }) + }) +}) + +describe('sort and undo', () => { + function album() { + const dir = mkdtempSync(join(tmpdir(), 'dp-ma-sort-')) + mkdirSync(join(dir, 'trip')) + mkdirSync(join(dir, 'home')) + writeFileSync(join(dir, 'trip', 'cat.jpg'), 'trip cat') + writeFileSync(join(dir, 'home', 'cat.jpg'), 'home cat') + writeFileSync(join(dir, 'home', 'hill.png'), 'hill') + writeFileSync(join(dir, 'notes.txt'), 'not media') + // Already taken in the destination: the sort must go around it. + mkdirSync(join(dir, 'Landscapes')) + writeFileSync(join(dir, 'Landscapes', 'hill.png'), 'a different hill') + return dir + } + + it('files everything into category folders without overwriting, and undo restores the exact tree', async () => { + const { registry, host } = await setup() + const dir = album() + const before = tree(dir) + + const result = await runAction(registry, 'mediaanalyzer', 'analyze-sort', { + ...host(), + dir, + names: ['home', 'notes.txt', 'trip'], + }) + expect(result.ok).toBe(true) + expect(result.message).toMatch(/3 moved into folders/) + + const sorted = tree(dir) + expect(Object.keys(sorted).sort()).toEqual([ + 'Landscapes/hill (2).png', + 'Landscapes/hill (2).png.description.txt', + 'Landscapes/hill.png', + 'Pets/cat (2).jpg', + 'Pets/cat (2).jpg.description.txt', + 'Pets/cat.jpg', + 'Pets/cat.jpg.description.txt', + 'notes.txt', + ]) + expect(sorted['Landscapes/hill.png']).toBe('a different hill') + expect(sorted['Landscapes/hill (2).png']).toBe('hill') + + // Undo is offered here, because a sort ran here. + const offered = await registry.actionsFor([{ name: 'notes.txt', isDirectory: false, size: 9 }], dir) + expect(offered.map((match) => match.action.id)).toContain('undo-sort') + + const undone = await runAction(registry, 'mediaanalyzer', 'undo-sort', { ...host(), dir, names: ['notes.txt'] }) + expect(undone).toMatchObject({ ok: true, message: 'Put back 6 files.' }) + + const restored = tree(dir, (rel) => rel.startsWith('.mediaanalyzer') || rel.endsWith('.description.txt')) + expect(restored).toEqual(before) + // The descriptions came back beside the files they describe. + expect(readFileSync(join(dir, 'trip', 'cat.jpg.description.txt'), 'utf8')).toContain(SIGNATURE) + // Pets/ was made by the sort and is empty again, so it is gone; Landscapes/ was the user's. + expect(readdirSync(dir).sort()).toEqual(['.mediaanalyzer', 'Landscapes', 'home', 'notes.txt', 'trip']) + }) + + it('undo skips a file the user has since put something in place of', async () => { + const { registry, host } = await setup() + const dir = album() + await runAction(registry, 'mediaanalyzer', 'analyze-sort', { ...host(), dir, names: ['home', 'notes.txt', 'trip'] }) + writeFileSync(join(dir, 'home', 'cat.jpg'), 'a new cat') + const undone = await runAction(registry, 'mediaanalyzer', 'undo-sort', { ...host(), dir, names: ['notes.txt'] }) + expect(undone.ok).toBe(false) + expect(readFileSync(join(dir, 'home', 'cat.jpg'), 'utf8')).toBe('a new cat') + expect(readFileSync(join(dir, 'Pets', 'cat.jpg'), 'utf8')).toBe('home cat') + }) +}) + +describe('pieces', () => { + it('batches by count and by bytes', () => { + const item = (bytes: number) => ({ meta: { bytes }, blob: new Blob([new Uint8Array(bytes)]) }) + expect(batches(Array.from({ length: 101 }, () => item(1))).map((batch) => batch.length)).toEqual([50, 50, 1]) + expect(batches([item(40e6), item(30e6), item(1)]).map((batch) => batch.length)).toEqual([1, 2]) + }) + + it('chooses the first online tier, else byok with the first provider', () => { + const me = { + user: { email: '' }, + balance_usd: 0, + available_usd: 0, + categories: [], + tiers: [{ id: 'standard', name: '', usd_per_file: 0, model: '', online: false }], + providers: [{ id: 'pk_1', label: '', kind: '', model: '' }], + } + expect(chooseTier(me, { tier: '', providerId: '' })).toEqual({ tier: 'byok', provider_key_id: 'pk_1' }) + expect(chooseTier({ ...me, tiers: [{ ...me.tiers[0]!, online: true }] }, { tier: '', providerId: '' })).toEqual({ tier: 'standard' }) + expect(chooseTier(me, { tier: 'premium', providerId: '' })).toEqual({ tier: 'premium' }) + expect(() => chooseTier({ ...me, providers: [] }, { tier: '', providerId: '' })).toThrow(/No MediaAnalyzer tier/) + }) +}) diff --git a/packages/plugin-mediaanalyzer/tsconfig.json b/packages/plugin-mediaanalyzer/tsconfig.json new file mode 100644 index 0000000..7940fdc --- /dev/null +++ b/packages/plugin-mediaanalyzer/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/**/*.fixture.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94566a7..a942ee3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -194,6 +194,14 @@ importers: specifier: ^3.24.1 version: 3.25.76 + packages/plugin-api: {} + + packages/plugin-mediaanalyzer: + dependencies: + '@diskpush/plugin-api': + specifier: workspace:* + version: link:../plugin-api + packages/rsync-core: dependencies: '@diskpush/schemas': diff --git a/scripts/release.mjs b/scripts/release.mjs index fdc94c8..0daec4d 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -29,6 +29,8 @@ const MANIFESTS = [ 'packages/ssh-core/package.json', 'packages/fleet-core/package.json', 'packages/database/package.json', + 'packages/plugin-api/package.json', + 'packages/plugin-mediaanalyzer/package.json', ] const TAG_SOURCE = 'apps/cli/package.json' From 8e58501694fab70480b696e27b6d38fdd2d09dd2 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 06:23:53 +0000 Subject: [PATCH 2/6] CLI and TUI host plugins CLI: `diskpush ...` falls through to the registry, decided on raw argv so a plugin's flags never trip DiskPush's own value flags. `diskpush plugins` lists, enables, disables, adds and removes; the help text lists every plugin command. Plugin progress uses the one status line Output already draws; Ctrl+C cancels through the context's signal. TUI: `a` opens an Actions menu for the local row under the cursor, with each action's letter. One click runs the action under the pointer, the pointer lights the row, and the line under the list says what the lit action does. A running action reports into a job panel in the transfer panel's place: esc cancels it, and a finished one is dismissed like a transfer. The key cap goes last in the bar so a 100-column terminal keeps `q quit`. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/cli/package.json | 2 + apps/cli/src/bin.ts | 48 ++++- apps/cli/src/commands/plugins.test.ts | 147 +++++++++++++++ apps/cli/src/commands/plugins.ts | 224 +++++++++++++++++++++++ apps/cli/src/commands/tui.ts | 7 +- apps/cli/src/help.ts | 21 +++ apps/cli/src/parse-argv.ts | 2 + apps/cli/src/plugins.ts | 130 +++++++++++++ apps/cli/src/tui/actions.test.ts | 253 ++++++++++++++++++++++++++ apps/cli/src/tui/app.ts | 170 ++++++++++++++++- apps/cli/src/tui/model.ts | 90 +++++++++ apps/cli/src/tui/view.ts | 148 ++++++++++++++- pnpm-lock.yaml | 6 + 13 files changed, 1237 insertions(+), 11 deletions(-) create mode 100644 apps/cli/src/commands/plugins.test.ts create mode 100644 apps/cli/src/commands/plugins.ts create mode 100644 apps/cli/src/plugins.ts create mode 100644 apps/cli/src/tui/actions.test.ts 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..c2714af --- /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) ?? 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/pnpm-lock.yaml b/pnpm-lock.yaml index a942ee3..50a163e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,12 @@ importers: '@diskpush/fleet-core': specifier: workspace:* version: link:../../packages/fleet-core + '@diskpush/plugin-api': + specifier: workspace:* + version: link:../../packages/plugin-api + '@diskpush/plugin-mediaanalyzer': + specifier: workspace:* + version: link:../../packages/plugin-mediaanalyzer '@diskpush/rsync-core': specifier: workspace:* version: link:../../packages/rsync-core From ed9d3350b44134ee955fae4235837316207ea189 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 06:29:18 +0000 Subject: [PATCH 3/6] Desktop hosts plugins in the main process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin code runs in Electron's main process, never the renderer. New IPC channels (plugins:list, actions-for, run-action, run-task, cancel, get-settings, set-settings, set-enabled, and the plugins:progress event) are validated like every other: a plugin action names its files as an absolute local directory plus bare EntryNameSchema names, so a renderer cannot point a plugin at ../, a relative path or a server path. The preload exposes named methods only; there is still no generic invoke. A local pane's right-click menu gets a Plugins section with the actions that apply to the selection (asked of the main process as the menu opens). A running action takes the transfer band, which learned a plugin job with no bytes or rate, and cancels through plugins:cancel. Plugins… in the header menu turns plugins on and off, edits their declared settings (secrets are write-only and never sent to the renderer), and runs their tasks, such as Sign in to MediaAnalyzer. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/desktop/electron/main/ipc.ts | 37 +++ .../desktop/electron/main/services/plugins.ts | 255 ++++++++++++++ apps/desktop/electron/preload/index.ts | 18 + apps/desktop/electron/shared/contract.ts | 62 ++++ .../electron/shared/plugin-contract.test.ts | 79 +++++ apps/desktop/package.json | 2 + apps/desktop/src/app/page.tsx | 104 +++++- apps/desktop/src/components/pane.tsx | 59 +++- .../desktop/src/components/plugins-dialog.tsx | 313 ++++++++++++++++++ .../desktop/src/components/transfer-panel.tsx | 28 +- apps/desktop/src/lib/api.ts | 55 +++ pnpm-lock.yaml | 6 + 12 files changed, 1006 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/electron/main/services/plugins.ts create mode 100644 apps/desktop/electron/shared/plugin-contract.test.ts create mode 100644 apps/desktop/src/components/plugins-dialog.tsx 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/pnpm-lock.yaml b/pnpm-lock.yaml index 50a163e..13d96f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,12 @@ importers: '@diskpush/fleet-core': specifier: workspace:* version: link:../../packages/fleet-core + '@diskpush/plugin-api': + specifier: workspace:* + version: link:../../packages/plugin-api + '@diskpush/plugin-mediaanalyzer': + specifier: workspace:* + version: link:../../packages/plugin-mediaanalyzer '@diskpush/rsync-core': specifier: workspace:* version: link:../../packages/rsync-core From 3013552a7666f9bbfa18f6814c43d4efe4596e62 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 06:31:05 +0000 Subject: [PATCH 4/6] Keep the local database owner-only Plugins keep their sign-ins in the settings table (a MediaAnalyzer refresh token), and the default umask left diskpush.db readable by every local user (0644 on this machine). The store now sets it to 0600 when it opens it, best effort, like ~/.ssh. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/database/src/store.test.ts | 15 +++++++++++++++ packages/database/src/store.ts | 17 ++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/database/src/store.test.ts b/packages/database/src/store.test.ts index b87d6f6..262eb64 100644 --- a/packages/database/src/store.test.ts +++ b/packages/database/src/store.test.ts @@ -25,6 +25,21 @@ const connectionInput = { notes: '', } +describe('the database file', () => { + it.skipIf(process.platform === 'win32')('is readable by its owner only, since plugins keep sign-ins in it', async () => { + const { chmodSync, mkdtempSync, statSync, writeFileSync } = await import('node:fs') + const { tmpdir } = await import('node:os') + const { join } = await import('node:path') + const path = join(mkdtempSync(join(tmpdir(), 'dp-db-')), 'diskpush.db') + // An existing store from before this rule, left world-readable by the umask. + writeFileSync(path, '') + chmodSync(path, 0o644) + const db = await DiskPushStore.open({ path }) + await db.close() + expect(statSync(path).mode & 0o777).toBe(0o600) + }) +}) + describe('paths', () => { it('honours DISKPUSH_HOME', () => { expect(diskpushHome({ DISKPUSH_HOME: '/tmp/dp' })).toBe('/tmp/dp') diff --git a/packages/database/src/store.ts b/packages/database/src/store.ts index a4a2d1e..1b49897 100644 --- a/packages/database/src/store.ts +++ b/packages/database/src/store.ts @@ -1,4 +1,4 @@ -import { mkdirSync } from 'node:fs' +import { chmodSync, mkdirSync, statSync } from 'node:fs' import { dirname } from 'node:path' import { randomUUID } from 'node:crypto' import { createClient, type Client, type InValue } from '@libsql/client' @@ -39,6 +39,7 @@ export class DiskPushStore { const client = createClient({ url: path === ':memory:' ? ':memory:' : `file:${path}` }) const store = new DiskPushStore(client, path) await store.migrate() + if (path !== ':memory:') restrictToOwner(path) return store } @@ -540,6 +541,20 @@ export class DiskPushStore { } } +/** + * Owner-only, like ~/.ssh. The store holds no SSH secrets, but plugins keep + * their sign-ins in its settings table (a MediaAnalyzer refresh token, say), + * and the default umask leaves a new file readable by every local user. + * Best effort: a filesystem without POSIX modes keeps whatever it has. + */ +function restrictToOwner(path: string): void { + try { + if ((statSync(path).mode & 0o077) !== 0) chmodSync(path, 0o600) + } catch { + // Not fatal: the store still works, it is just not tightened. + } +} + type Row = Record function rowToConnection(row: Row): Connection { From de47630fdb189a3283f64e6fb3eba71e9f74f2cc Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 06:31:05 +0000 Subject: [PATCH 5/6] Docs: plugins, MediaAnalyzer, and the security model docs/plugins.md covers using plugins on each surface, MediaAnalyzer (sidecars, sorting and undo, paying once, what is uploaded, settings, sign-in), writing a plugin and its context API, installing external ones, and the security model. security.md now says plainly where plugins qualify its rules: sign-ins are in the owner-only settings table for now, and a plugin you run may upload the files you pick. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 15 ++- docs/architecture.md | 6 ++ docs/cli.md | 16 +++ docs/plugins.md | 226 +++++++++++++++++++++++++++++++++++++++++++ docs/security.md | 16 ++- 5 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 docs/plugins.md 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/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-