Skip to content

feat(cli): diffbro open and diffbro backup - #18

Merged
mindaugaskasp merged 7 commits into
mainfrom
feat/cli-open-and-backup
Aug 3, 2026
Merged

feat(cli): diffbro open and diffbro backup#18
mindaugaskasp merged 7 commits into
mainfrom
feat/cli-open-and-backup

Conversation

@mindaugaskasp

Copy link
Copy Markdown
Owner

Two terminal commands, spec: specs/2026-08-02-cli-open-and-backup/plan.md (12/12 steps).

diffbro open [<file>]

Raises the app, starting it if it is not running. With one path it reuses the existing compare command — which already means "fill the left slot and wait" — so there is one renderer path rather than two that can drift. A second path is refused, since that is compare.

Bare diffbro always raised the app; this is the spelling that says so in diffbro help.

diffbro backup <path>

Seals the stores into a zip at a path you name.

The passphrase is asked for in the app, not the terminal, because the bundle is assembled in the renderer: snippets and kept diffs are decrypted there and re-sealed in main under the passphrase. That is what makes the archive open on another machine — the vault key never travels, so a copy of the ciphertext would be useless elsewhere. The rejected headless alternative is recorded in the plan's Solution table.

Contents: snippets, kept saved diffs, settings, session, plus the identity keypair and trusted hosts. Expiring diffs are left out, for the reason autoBackup already leaves them out.

Security

  • Rule 4sealBundle calls getIdentity()/readTrusted() inside main and passes them straight to sealConfig; config:backupTo returns only {ok, path} or {error}. No key material crosses the boundary in either direction.
  • Rule 6 — the destination is a string typed into a shell. backupZip.checkDestination refuses a directory, a missing parent, an existing file, an empty string, and anything inside the app's own data directory, before a byte is written. isInside uses relative() + isAbsolute(), so a sibling sharing a name prefix (data vs database.zip) and a different Windows drive are both handled.
  • No new dependency — fflate was already a production dep.

Verification

  • npm run check: 1894 passed, coverage floors unchanged.
  • Docker e2e (Linux/Xvfb): cli.spec.mjs + config-backup.spec.mjs, 10 passed. The config-backup three matter because this change altered the config:backup IPC signature. They fail on a macOS host for an unrelated reason (openMenu drives MenuBar.vue, which only exists on Windows/Linux) — verified against a stashed tree.
  • Every behaviour change has a test written first and watched failing: 6 parser cases, 10 destination/zip cases, 7 vault-bundle cases, 1 session round-trip, 3 e2e.

Worth a reviewer's attention

/validate found one defect, fixed in this branch: session was sealed into the archive but silently dropped on restore. It now round-trips, written to persistence rather than applied live — replacing the comparisons on screen mid-restore is not what "restore my backup" asked for.

One finding left open and deliberately not fixed here: writeBackupZip uses zipSync + writeFileSync, so a large vault blocks the main process. Async zip()/fs.promises is the fix; raised for discussion rather than picked silently.

🤖 Generated with Claude Code

mindaugaskasp and others added 4 commits August 2, 2026 23:14
`open` with no argument delivers a `raise` command that stops in main: the
window IS the answer, so it never reaches the renderer or deliver's pending
queue. With one path it reuses the existing `compare` command, which already
means "fill the left slot and wait", so there is one renderer path rather than
two that can drift. A second path is refused, since that is `compare`.

Bare `diffbro` still raises the app as it always did; `open` is the spelling
that says so in `diffbro help`.

Tests written first and watched failing: six parser cases red, then green.
Two e2e prove the single-instance argv round trip — that `open` alone leaves
the comparison untouched, and `open <file>` fills only the left side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The half of the backup command that is not crypto: where it is allowed to
write, and what the file looks like. Both are pure enough to unit-test, so
everything they need — the destination and the data directory — is passed in.

A destination is a path typed into a shell, so it is refused rather than
trusted: a directory, a missing parent, an existing file, an empty string, and
anything inside the app's own data directory, which would otherwise fold each
backup into the next one. The inside-check uses relative()+isAbsolute() so a
sibling sharing a name prefix ("data" vs "database.zip") is not mistaken for a
child, and a different Windows drive is not either.

The payload is sealed before it arrives, so the zip is a container; level-9
deflate still pays for itself because a sealed envelope is base64.

Nothing calls this yet — the sealed-bundle extension is the remaining work and
is recorded in the plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The passphrase is asked for in the app, not the terminal, because the bundle is
assembled in the renderer: snippets and kept diffs are decrypted there and
re-sealed in main under the passphrase. That is what makes the archive open on
another machine — the vault key never travels, so a copy of the ciphertext
would be useless anywhere else.

Contents are the stores minus the vault key: snippets, kept saved diffs,
settings, session, plus the identity keypair and trusted hosts, which are read
inside main and go straight into the envelope (rule 4 — the renderer never sees
them). Expiring diffs are left out for the reason autoBackup already leaves them
out: the reader asked them to die.

The CLI destination is a string typed into a shell, so backupZip refuses a
directory, a missing parent, an existing file, and anything inside the app's own
data directory before a byte is written (rule 6).

/validate found one defect and it is fixed here: session was sealed into the
archive but silently dropped on the way back. It now round-trips, written to
persistence rather than applied live — replacing the comparisons on screen
mid-restore is not what "restore my backup" asked for. Test watched failing
first.

npm run check: 1894 passed. Docker e2e: cli + config-backup, 10 passed — the
config-backup three matter because this changed the config:backup IPC signature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mindaugaskasp

Copy link
Copy Markdown
Owner Author

🤖 Code review — diffbro open / diffbro backup

18 files, +589/−63. Reviewed the PR diff only. (Posted as a comment rather than a formal Request changes — GitHub does not allow requesting changes on your own PR.)

Verdict: two changes wanted before merge.

The shape is good: open genuinely reuses compare rather than forking a second renderer path, sealBundle keeps identity/trusted reads inside main so rule 4 holds, and the destination validation is thorough and well tested.


🔴 1 · The restored vault bundle has no main-side validation or caps

applyRestoredConfig passes vault straight back to the renderer, while snippets beside it goes through validateSnippetBundle in main first with hard caps — snippets: 5000, nameBytes: 512, totalTags: 500 (snippetSealing.js:22-30).

docs/security.md states the reason in its own words:

a malformed-but-decryptable file can't half-write state or blow the localStorage quota. The same check guards the config-restore path.

The new vault field bypasses that. vaultStore.restoreBundle skips individually malformed entries, but nothing caps how many entries or how large a payload is. A corrupt or hostile .diffbroconf carrying {diffs: [...100k entries]} drives an unbounded loop of vaultEncrypt IPC round-trips in the renderer, each writing to the store.

This is reachable by design of the threat model: the passphrase gates confidentiality, not the file's honesty — exactly what validateSnippetBundle exists for.

Fix: extend validateRestoredConfig (shareCore.js:61) to vet vault with count/size caps mirroring validateSnippetBundle, plus the negative tests the standards require on a restore path. Renderer-side skipping is a good second layer, not the first.

🟠 2 · diffbro help backup points at the wrong menu

passphrase and the archive cannot be opened. Restore it from Settings.

Restore lives at Security → Configuration → Restore (menu.js:226-230) — this PR's own e2e drives exactly that path. Shipped user-facing copy sending someone to the wrong menu.


🟡 Non-blocking

  • A bad path is only reported in the GUI. checkDestination runs after the round-trip into the renderer, so diffbro backup /nonexistent/x.zip exits 0 at the shell and surfaces as an in-app toast. routeCliArgv already holds the path and could check it before delivering, writing to stderr like every other CLI error. Matters for scripting.
  • writeBackupZip blocks the main process (zipSync + writeFileSync) — a large vault freezes every window. zip() + fs/promises is the drop-in. Already flagged as open in the description.
  • The dialog closes even when the write failedsubmit() calls close() unconditionally, so a refused destination discards the typed passphrase and the CLI must be re-run.
  • Loose e2e selector{ name: /Back up|Save|Confirm/ } keeps passing if the button is relabelled to something unintended.
  • diffbro open " " silently raises where compare " " errors. Defensible and pinned by a test — flagged so it is a decision, not an accident.

✅ Verified clean

  • Rule 4sealBundle reads identity/trusted inside main; config:backupTo returns only {ok, path} / {error}. No key material crosses either way.
  • Rule 6 — destination refusals tested across 10 cases; isInside correctly handles the data vs database.zip prefix collision and cross-drive paths.
  • Rules 3 / 8 — no Node/Electron import in the renderer, no injection sinks.
  • Tests — behaviour changes carry tests written first; the config-backup e2e that the IPC signature change could have broken was re-run in Docker.
  • Docs — README, security.md, and the ipc-security.md guard table all updated.

🤖 Generated with Claude Code

@mindaugaskasp mindaugaskasp left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tooling note: reviews on this repo now go through scripts/pr-review.mjs, which posts as a dedicated review identity when one is configured (DIFFBRO_REVIEW_TOKEN / DIFFBRO_REVIEW_USER).

This message was posted by that script with no review identity configured — so it correctly detected that the reviewer and the PR author are the same account and downgraded REQUEST_CHANGES to a comment, rather than failing the way the earlier attempt did.

@diff-bro-reviewer diff-bro-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested

Re-posting the earlier review as a blocking one, now that the reviewer has an identity of its own. Two findings stand.

🔴 1 · The restored vault bundle has no main-side validation or caps

applyRestoredConfig hands vault straight back to the renderer, while snippets beside it goes through validateSnippetBundle in main first, with hard caps — snippets: 5000, nameBytes: 512, totalTags: 500 (snippetSealing.js:22-30).

docs/security.md gives the reason in its own words:

a malformed-but-decryptable file can't half-write state or blow the localStorage quota. The same check guards the config-restore path.

The new vault field walks around that. vaultStore.restoreBundle skips individually malformed entries, but nothing caps how many entries or how large a payload is. A corrupt or hostile .diffbroconf carrying {diffs: [...100k entries]} drives an unbounded loop of vaultEncrypt IPC round-trips in the renderer, each writing to the store.

Reachable by the project's own threat model: the passphrase gates confidentiality, not the file's honesty.

Fix: extend validateRestoredConfig (shareCore.js:61) to vet vault with count/size caps mirroring validateSnippetBundle, plus the negative tests the standards require on a restore path.

🟠 2 · diffbro help backup points at the wrong menu

passphrase and the archive cannot be opened. Restore it from Settings.

Restore is Security → Configuration → Restore (menu.js:226-230) — this PR's own e2e drives that exact path.


Non-blocking notes are in the earlier comment. Blocking on 1; 2 is a one-line copy fix.

@diff-bro-reviewer diff-bro-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verifying the reviewer posts cleanly — this approval is the tooling check, not a verdict on the code (finding 1 still stands).

@diff-bro-reviewer diff-bro-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verifying the reviewer posts cleanly — this approval is the tooling check, not a verdict on the code (finding 1 still stands).

mindaugaskasp added a commit that referenced this pull request Aug 2, 2026
Running the reviewer for real found two things the first pass could not.

`identityOf` asked /user for the reviewer's login to check it was not the PR
author. An App installation token cannot call /user at all, so the check threw
and the script exited before posting anything. A 403 there is the expected
answer, not a failure: an installation token is a bot by construction and is
never the human author, so it now falls back to that rather than crashing.

gh's stderr was also inherited, so the 403 it prints on that expected failure
appeared on the console beside a successful review — which reads as the review
having gone wrong. Captured instead.

Verified against PR #18: diff-bro-reviewer[bot] posted CHANGES_REQUESTED and
the PR's reviewDecision is CHANGES_REQUESTED, so the gate is real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@mindaugaskasp mindaugaskasp left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verifying the reviewer posts cleanly — this approval is the tooling check, not a verdict on the code (finding 1 still stands).

mindaugaskasp and others added 2 commits August 3, 2026 00:17
Review finding 1. `applyRestoredConfig` handed `vault` straight back to the
renderer while `snippets` beside it went through validateSnippetBundle in main
first. docs/security.md states the reason for that check in its own words — a
malformed-but-decryptable file must not be able to half-write state — and the
new field walked around it. vaultStore.restoreBundle skipped individually
malformed entries but nothing capped how many there were, so a crafted
.diffbroconf carrying 100k diffs drove an unbounded loop of vault:encrypt
round-trips in the renderer.

validateRestoredConfig now vets the bundle and returns the vetted copy, which is
what travels: VAULT_LIMITS caps it at 5000 diffs, 512-byte names and 20 tags
each, mirroring SNIPPET_LIMITS. Renderer-side skipping stays as the second
layer. Six negative tests written first and watched failing.

Review finding 2: `diffbro help backup` said restore lives in Settings. It is
Security → Configuration → Restore, which this PR's own e2e already drove.

npm run check: 1901 passed. Docker e2e: 10 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@diff-bro-reviewer diff-bro-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both findings addressed — 916bd56

🔴 1 · vault bundle validation — fixed. validateRestoredConfig now vets the bundle and returns the vetted copy, which is what travels onward:

export const VAULT_LIMITS = { diffs: 5000, nameBytes: 512, tagsPerDiff: 20 }

mirroring SNIPPET_LIMITS. Six negative tests written first and watched failing: wrong shape, non-array diffs, a null entry, a missing payload, a non-object payload, over-count, over-length name, and tags that are neither a bounded list nor strings. Renderer-side skipping in vaultStore.restoreBundle stays as the second layer — it is no longer the first.

docs/ipc-security.md gained the guard row, so the table now names the check that stops it.

🟠 2 · help text — fixed. diffbro help backup now says Security → Configuration → Restore.

The non-blocking notes

  • Synchronous zip — deliberately not fixed here, and now recorded as the one open item below. It is a real problem for a large vault but it is a change of shape (zip() + fs/promises), not a patch, and it wants its own before/after.
  • Bad path only reported in the GUI — agreed, and worth doing: routeCliArgv already holds the path and could refuse it on stderr before raising a window. Left out to keep this PR to the two blocking findings.
  • Dialog closes on a failed write — same call.
  • Loose e2e selector and open " " — noted; the latter is pinned by a test so it is a decision rather than an accident.

Those four are follow-ups, not regressions this PR introduces.

Verification

  • npm run check1901 passed, coverage floors unchanged.
  • Docker e2e (Linux/Xvfb) — cli.spec.mjs + config-backup.spec.mjs, 10 passed, re-run after the validation change because it sits on the restore path.

diff-bro-reviewer[bot]
diff-bro-reviewer Bot previously approved these changes Aug 2, 2026

@diff-bro-reviewer diff-bro-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both blocking findings resolved — approving

Re-checked against the diff rather than the description.

1 · vault validationvalidateRestoredConfig now takes vault, and applyRestoredConfig returns vetted.vault, not the raw field. The unvetted passthrough is gone from the diff, which is the part that mattered: the cap is only worth having if the vetted copy is what travels. VAULT_LIMITS = { diffs: 5000, nameBytes: 512, tagsPerDiff: 20 } sits beside SNIPPET_LIMITS and reads consistently with it. Negative tests cover wrong shape, non-array diffs, null entry, missing and non-object payload, over-count, over-length name, and bad tags.

2 · help text — now Security → Configuration → Restore, matching menu.js:226-230 and the spec's own e2e.

docs/ipc-security.md gained the guard row, so the table names the check rather than leaving it undocumented.

Carried forward, not blocking

writeBackupZip still uses zipSync + writeFileSync, so a large vault blocks the main process. Agreed it is a change of shape rather than a patch — but it should not sit indefinitely: the archive gets bigger as the feature gets used, so the failure arrives exactly when someone has most to back up. Worth a follow-up issue rather than a TODO.

Three smaller follow-ups from the first pass also stand: a bad path is only reported in the GUI (diffbro backup /nope/x.zip exits 0), the dialog closes on a failed write, and the e2e button selector is loose.

Approved.

Status stays in-progress: under the new Landing it convention a spec is
finished when it merges, not when the branch is green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@diff-bro-reviewer diff-bro-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving at 5b23b68

The earlier approval was dismissed by the push that followed it. Only one commit landed since — 5b23b68, touching specs/…/plan.md and nothing else (39 insertions, 25 deletions, one file). It records the real validation numbers and marks the spec closed out.

No source, test, or doc change since the approved tree, so the review that cleared both blocking findings still stands.

Approved.

@mindaugaskasp
mindaugaskasp merged commit 2fafb9f into main Aug 3, 2026
2 checks passed
mindaugaskasp added a commit that referenced this pull request Aug 3, 2026
Every step ticked, every Docs-impact yes done, every Validation line answered,
PR #18 open with the agent review resolved. Under the status definitions now in
specs/README.md that is shipped: ready for a human to review, merging theirs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mindaugaskasp added a commit that referenced this pull request Aug 3, 2026
#18 landed the CLI spec, which appended a session round-trip test to the same
end of diffStore.test.js that this branch appended dropSnippets to. Both suites
are kept — they test unrelated things and neither supersedes the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mindaugaskasp added a commit that referenced this pull request Aug 3, 2026
Same collision as the snippet branch: #18 appended a session round-trip test to
the end of diffStore.test.js, where this branch appended the diagram-comparison
suites. All three are kept; they test unrelated things.

check green at 1946 passed after the merge, Docker e2e 3 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mindaugaskasp added a commit that referenced this pull request Aug 3, 2026
I marked it shipped on its branch after #18 had already merged, so main kept the
in-progress copy — the record disagreeing with the work, which is the one thing
a spec must not do.

Outcome rewritten too: it still described waiting for a merge that had already
happened, under the status rule that has since been corrected.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant