Plugin architecture, with MediaAnalyzer as the first plugin - #46
Merged
Merged
Conversation
@diskpush/plugin-api is the contract: a plugin is an object with commands (diskpush <id> ...), 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 <diskpush home>/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) <noreply@anthropic.com>
CLI: `diskpush <plugin-id> <command> ...` 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ralyodio
marked this pull request as ready for review
September 24, 2026 06:37
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
DiskPush gets a plugin architecture, and MediaAnalyzer ships as its first, built-in plugin. With it you can describe the photos and videos in a local folder, and optionally sort them into folders by what they show, from the CLI, the TUI or the desktop app.
Architecture
@diskpush/plugin-api(new, 0.11.0, inrelease.mjsMANIFESTS). This package is the contract.{ id, name, version, description, commands?, actions?, tasks?, settings?, status? }.definePlugin()validates it when it is defined, rejecting bad or reserved ids, duplicates and a mismatchedapiVersion.dir/names/entries(actions only), namespacedsettings(plugin:<id>:*) andsecrets(plugin-secret:<id>:*) in the settings table the CLI and the desktop share, aprogresssink,openUrl(http/https only),signal,surfaceandenv.PluginRegistryhandles register, list, enable/disable (persisted asplugins.disabled),actionsFor(entries, dir), and require/action/task/command lookups.runActionvalidates the selection: an absolute dir, bare names only, symlinks skipped. It also turns a throw into a failed result.<diskpush home>/plugins/node_modules, and only if the package is listed in that directory'spackage.jsonand has a"diskpush"field.diskpush plugins addinstalls with--ignore-scriptsand uninstalls again if the package does not load as a plugin. One broken plugin is reported and skipped.Surfaces
diskpush <plugin-id> ...is decided on raw argv, before DiskPush's own parser, so a plugin's flags never trip DiskPush's value flags.diskpush plugins list|enable|disable|add|remove.--helplists plugin commands. Progress uses Output's status line. Ctrl+C cancels through the signal.aopens an Actions overlay for the local row under the cursor. Keys: ↑↓/enter, or the action's letter. One click runs the row under the pointer, hover lights it, and the footer line describes the lit action. A running action gets a job panel in the transfer panel's place: esc cancels it, and a finished one is dismissed like a transfer. Theacap goes last in the key bar soq quitstill fits at 100 columns. Frames stay pure and are asserted withrenderToText.plugins:list,actions-for,run-action,run-task,cancel,get-settings,set-settings,set-enabled, plus theplugins:progressevent. The preload has named methods (plugins.*,events.onPluginProgress), still with no generic invoke. A local pane's right-click menu has a Plugins section. A plugin job reusesActiveJob/TransferBand, which learned a job with no bytes or rate. Plugins… in the header menu can enable/disable plugins, edit their declared settings (secrets are write-only) and run tasks, e.g. Sign in to MediaAnalyzer.@diskpush/plugin-mediaanalyzer(new, 0.11.0, in MANIFESTS)diskpush. It uses a loopback127.0.0.1:<port>/callback, S256 PKCE,stateanddevice_name. Refresh tokens rotate: the new pair is stored the moment it arrives, only one refresh runs at a time, and the refresh token is re-read from the store before each refresh, because the other surface may have rotated it. A 401 triggers one retry after a refresh. Logout revokes.login --pasteuses${server}/oauth/codefor headless machines. Anma_key_…key can come fromDISKPUSH_MEDIAANALYZER_KEYor theapi_keysecret.client_refuses the MediaAnalyzer CLI's formula, so the two tools never double-charge each other. Videos become a 3×3 ffmpeg contact sheet when ffmpeg/ffprobe are on PATH, and are skipped with a note otherwise. On 402 the upload stops, accepted files are kept, and the result says how many were skipped and where to add credit. 503tier_offlinefails with a clear message. A cancelled scan resets.finished_afterevery 2.5s and dedupes the inclusive cursor byclient_ref. It writes<file>.description.txtwith the signature line and never overwrites a sidecar that lacks it. State lives in<dir>/.mediaanalyzer/diskpush-state.jsonand is saved after every batch and every poll, so a re-run resumes and never re-uploads. A file that already has a signed sidecar is skipped.<dir>/<Category>/via link+unlink, which is no-clobber and atomic, with a check+rename fallback for FAT/exFAT. Clashes become(2). Each move is journaled toundo-*.jsonbefore it happens. Undo last sort walks the latest journal backwards, skips anything the user has since replaced, and removes folders the sort created if they are empty.Security model (please read)
plugins addwarns before installing.docs/plugins.mdsays this plainly.EntryNameSchemanames, validated by zod. Tests show it rejects../,a/b, relative,host:/pathand NUL, and strips extra fields. Only declared settings can be written, and each is type-checked. Secret values are never sent to the renderer.docs/security.mdsaid persisted secrets belong in OS secure storage. Plugin sign-ins (the MediaAnalyzer refresh token) are stored in the settings table instead, because it is the only store both the CLI and the desktop can read, and a sign-in has to work in both. To offset that,diskpush.dbis now chmod0600on open; it was 0644 under the default umask on this machine.security.mdand the README now say so, and also that a plugin you run may upload the files you pick. ASecretCodechook exists for a keychain; see the follow-ups.Tests
Clean run in CI order:
pnpm install --frozen-lockfile,pnpm build,pnpm typecheck,pnpm test,pnpm --filter @diskpush/web build,pnpm --filter @diskpush/desktop build,pnpm smoke:desktop. All exit 0.pnpm test: 59 files, 779 tests passed (baseline onorigin/main3d92cc2: 53 / 723).pnpm smoke:desktop: the three bundle/scheme/CSP checks pass. The Electron launch step was skipped locally (libatk-1.0.so.0is missing on this box), so CI's run of it is the real check.pnpm test:integrationwas not run; it needs the SSH test container and nothing it covers changed.New tests:
plugin-api: registry enable/disable persistence,actionsFor, a brokenappliesToisolated, namespacing, therunActionpath guard, external loading (listed + marked only, bad entry point refused,adduses--ignore-scriptsand rolls back a non-plugin).plugin-mediaanalyzer, against a real local fake HTTP server: PKCE exchange (client id, redirect, verifier), three rounds of refresh rotation with concurrent calls sharing one refresh and no replay, 401 retry, revoke on logout, batching 120 →[50, 50, 20], 402 mid-run ([50, 50], 70 skipped), resume with no re-upload,finished_afterwith delayed results and an error file, sidecar format, a foreign sidecar left alone, a signed sidecar not re-charged, videos with and without ffmpeg, sort with clashes plus undo restoring the exact tree byte for byte, undo skipping a replaced file.--json, help, unknown command, disabled refusal),diskpush pluginslist/enable/disable against a real store, key assignment.renderToTextframes for the overlay (with hover description) and the job panel (running and failed), key-bar fit at 100/120 columns, and a fake-host interaction:a→ enter, letter on a nested row, one click runs, esc cancels through the signal, and the refusal messages.0600.Manual CLI smoke on the built binary with a throwaway
DISKPUSH_HOME: help,plugins, plugin help, not-signed-in, a disabled plugin exits 65,--json plugins enable, and unknown command/subcommand all behave as intended.Follow-ups / risks
mediaanalyzer.pro. The first real run should be a small folder.plugins addneedsnpmon PATH (the desktop bundle has none); there is no desktop UI for adding plugins yet.busy, so they do not run at the same time.🤖 Generated with Claude Code