Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,18 +110,18 @@ Specs are written ahead of the code: a new component's spec starts as a full des

`scripts/spec-lint.mjs` (`pnpm lint:specs`, the first step of the root `pnpm test`) enforces the mechanically checkable conventions above — its header comment lists the checks — and ratchets size: every spec, this file, `SECURITY.md`, and `SELF_HOST.md` carry a word budget in `scripts/spec-word-budgets.json`, its size rounded up to the nearest 50. Rationale files carry none; evidence may grow without limit. Over budget: cut to fit, or re-baseline with `node scripts/spec-lint.mjs --ratchet <spec>` in the same PR. `SECURITY.md` and `SELF_HOST.md` ride the same checks. Advisory prose reviews follow `docs/prose-audit.md` (`pnpm audit:prose`).

Six sibling lints run in `pnpm test`. Five enforce one invariant a spec states in prose, each naming the line it enforces and failing if that line is gone; `ps1-cmdlet-lint` guards the one shipped file nothing else can parse:
Six sibling lints run in `pnpm test`. Five enforce one invariant a spec states in prose and name the line they enforce; only `public-docs-lint` and `e2e-lint` read that prose and fail when the line is gone. `ps1-cmdlet-lint` guards the one shipped file nothing else can parse:

| Lint | Enforces |
|---|---|
| `scripts/public-docs-lint.mjs` (`pnpm lint:public-docs`) | The public-doc contracts in `docs/specs/website-docs.md`, every inventory derived from the file that owns it. |
| `scripts/xterm-lint.mjs` (`pnpm lint:xterm`) | The `@xterm/*` version lockstep in `docs/specs/webgl-text.md`. |
| `scripts/loopback-lint.mjs` (`pnpm lint:loopback`) | `docs/specs/security-local.md` -> "Loopback Listeners": a loopback bind is not an access control — a new listener references a guard module or is allowlisted with a reason. |
| `scripts/deploy-lint.mjs` (`pnpm lint:deploy`) | `docs/specs/security-remote.md` -> "Credentials at rest" and "Network posture (self-hosted)": the installer controls binding all three of `deploy/local/install-{macos,windows,linux}`. |
| `scripts/ps1-cmdlet-lint.mjs` (`pnpm lint:deploy`) | Every `Verb-Noun` call in `deploy/local/install-windows.ps1` uses an approved verb and a noun that is not this project's vocabulary. No job has a PowerShell, so this is the Windows installer's only syntax gate — a repo-wide rename once turned all 147 `Write-Host` calls into `Write-Burrow`. |
| `scripts/ps1-cmdlet-lint.mjs` (`pnpm lint:deploy`) | Every `Verb-Noun` call in `deploy/local/install-windows.ps1` uses an approved verb and a noun that is not this project's vocabulary. No job has a PowerShell, so this is the Windows installer's only syntax gate. |
| `scripts/e2e-lint.mjs` (`pnpm lint:e2e`) | The structural half of `docs/specs/security-remote.md` -> "Remote Control": one Noise suite with no selector, no JavaScript curve, no legacy relay discriminant, no Relay-side protocol-v1 type, no checked-in service worker, no optional field on a ciphertext or transcript. |

`scripts/spec-lint-selftest.mjs` plants one defect per finding check in the spec lint. The `deploy` and `e2e` lints carry self-tests that mutate each rule in whichever direction it points: a present-control rule has its control deleted (and, for exact-count rules, a copy added), a `forbidden` rule has the banned text appended. `scripts/e2e-lint-selftest.mjs` is wholly the second kind; `scripts/deploy-lint-selftest.mjs` is mostly the first. Either way the lint must go red. **A rule added to one of these lints without its self-test case is not enforced** — it is a claim that something is checked. They share plumbing, and only that, through `scripts/lint-kit.mjs`. `scripts/installer-verify-test.mjs` (also `pnpm lint:deploy`) runs the installer shell helpers lint can only read, extracted from the shipped files; `scripts/ps1-cmdlet-lint-selftest.mjs` carries the `ps1-cmdlet` lint's mutations. `pnpm test` also runs `scripts/clamp-issue-body-selftest.mjs`, the test for `scripts/clamp-issue-body.mjs` (the helper the audit workflows use to keep an issue body postable); it lives at the repo root because its callers do.
`scripts/spec-lint-selftest.mjs` plants one defect per finding check in the spec lint. The `deploy`, `e2e`, and `loopback` lints carry self-tests that mutate each rule in whichever direction it points: a present-control rule has its control deleted (and, for exact-count rules, a copy added), a `forbidden` rule has the banned text appended. `scripts/e2e-lint-selftest.mjs` is mostly the second kind; `scripts/deploy-lint-selftest.mjs` mostly the first. Either way the lint must go red. **A rule added to one of these lints without its self-test case is not enforced** — it is a claim that something is checked. They share plumbing, and only that, through `scripts/lint-kit.mjs`. `scripts/installer-verify-test.mjs` (also `pnpm lint:deploy`) runs the installer shell helpers lint can only read, extracted from the shipped files; `scripts/ps1-cmdlet-lint-selftest.mjs` carries the `ps1-cmdlet` lint's mutations. `pnpm test` also runs `scripts/clamp-issue-body-selftest.mjs`, the test for `scripts/clamp-issue-body.mjs` (the helper the audit workflows use to keep an issue body postable); it lives at the repo root because its callers do.


## Design
Expand All @@ -137,11 +137,8 @@ See [PRODUCT.md](PRODUCT.md) for users, brand personality, and aesthetic directi
The concrete type scale, color strategy (surfaces, foregrounds, header palette, dynamic door bg, selection ring), and shared chrome constants live in
[`lib/src/components/design.tsx`](lib/src/components/design.tsx) — read it
before adding or changing any `text-*`, `bg-*`, `text-color-*`, or border
class anywhere in `lib/src/`. The `@theme` token definitions are split: colors
in [`lib/src/theme-colors.css`](lib/src/theme-colors.css), which a host can
import on its own, and the type scale, fonts, and animation tokens in
[`lib/src/theme.css`](lib/src/theme.css). When adding or removing a color
token, update `theme-colors.css` and `design.tsx` together.
class anywhere in `lib/src/`. `docs/specs/theme.md` → "Runtime model" owns how
the token files are split; `DESIGN.md` → "Don't" owns what adding one costs.

<!-- dor-skill:begin — managed by `dor skill --install`; edits inside are overwritten -->
## Running inside Dormouse
Expand Down
1 change: 1 addition & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ This system has no "primary" accent in the brand sense. The closest analogue is
Every literal color the Host-Theme-Only Rule below permits, in full. Each is here because the surface it paints is not read as part of the theme; a literal anywhere else is a bug.
- **Window Close Hover** (`#b92a1b`): native OS close-button hover on Windows/Linux chrome buttons; matches the platform convention across themes.
- **Setup QR** (`#ffffff` ground, `#000000` modules, in `lib/src/components/QrCode.tsx`): a phone camera reads this control, not a person. Scanners expect dark-on-light and many refuse an inverted code, and no theme token promises either the polarity or the contrast ratio in both light and dark.
- **Terminal last resort** (`#1e1e1e` background, `#cccccc` foreground, in `lib/src/lib/terminal-theme.ts`): xterm.js needs two readable colors to render at all, and `pnpm dev:lib` runs with no resolver and no applied theme (`docs/specs/theme.md` front matter). The rest of the terminal palette carries no literal — an unset key is omitted and xterm uses its own default, except the cursor, which derives from the resolved foreground.

### Named Rules
**The Host-Theme-Only Rule.** Never write a hex value or `oklch()` literal into `theme-colors.css`, `theme.css`, or a component. Never use `var(..., fallback)` chains. Every color must resolve through `--vscode-*` or one of the body-published runtime picks (`--color-door-*`, `--color-focus-ring`, `--color-alarm-vs-*`). The only exceptions are the ones rostered under Fixed Exceptions above, and adding one means adding it there.
Expand Down
5 changes: 3 additions & 2 deletions canopy/src/GlTerminal.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { useEffect, useRef, useState } from 'react';
import { Terminal } from '@xterm/xterm';
import { WebglAddon } from '@diffplug/xterm-addon-webgl-sdf';
// The pristine upstream addon, pinned to the exact commit the fork's sdf branch is based on
// (addon 0.20.0-beta.300 and core 6.1.0-beta.304 share gitHead c58ea363) — the regression
// baseline for the UpstreamVsFork story. canopy/README.md records the same triple.
// (addon 0.20.0-beta.300 == core 6.1.0-beta.304 == commit c58ea363) — the regression
// baseline for the UpstreamVsFork story. canopy/README.md records the same triple, and
// scripts/xterm-lint.mjs check 4 holds both against canopy/package.json's pins.
import { WebglAddon as UpstreamWebglAddon } from '@xterm/addon-webgl';
// Read the two versions rather than restating them, so the on-screen labels cannot drift from
// the pins the way a hand-typed version does.
Expand Down
27 changes: 13 additions & 14 deletions docs/specs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Source of truth: `create_release` in `scripts/sign-and-deploy.sh`; `publish-vsco
Human-driven, in order:

1. **Update dependency snapshots** — run `node website/scripts/generate-deps.js`, review the diffs in `website/src/data/dependencies-{npm,cargo,runtime}.json`, commit if changed.
2. **Draft release notes and bump version** — run `/release-notes` at the repo root ([.claude/commands/release-notes.md](../../.claude/commands/release-notes.md)): it recommends a `breaking.added.bugfix` bump from the merge commits and squash-merged PRs since the last tag, runs `./scripts/bump-version.sh X.Y.Z`, and edits `CHANGELOG.md` for that version. Review the diff.
2. **Draft release notes and bump version** — run `/release-notes` at the repo root ([.claude/commands/release-notes.md](../../.claude/commands/release-notes.md)), which owns the steps. Review the diff.
3. **Commit and tag** — `git commit -am "Release vX.Y.Z"` then `git tag vX.Y.Z`.
4. **Push** — `git push && git push origin vX.Y.Z`, which triggers CI (Stage 1).
5. **Run local signing** — plug in the PIV USB key, then `./scripts/sign-and-deploy.sh all X.Y.Z`: it waits for CI, verifies and signs artifacts, writes the website update manifest, and creates the GitHub Release. Secrets follow [Environment / secrets](#environment--secrets). **Must reject a dirty tree, untracked files, or commits ahead of the configured upstream**; without an upstream the script warns that push status is unknown.
Expand All @@ -36,7 +36,7 @@ Human-driven, in order:

## Versioning

**Must synchronize the version files and Cargo.lock's `dormouse` entry with `scripts/bump-version.sh`** (`cargo check --offline`).
**Must synchronize the four version files — `lib/package.json`, `vscode-ext/package.json`, `standalone/src-tauri/Cargo.toml`, `standalone/src-tauri/tauri.conf.json` — and Cargo.lock's `dormouse` entry with `scripts/bump-version.sh`** (`cargo check --offline`). **A version is `X.Y.Z`**: both the bump script and `sign-and-deploy.sh` reject a prerelease suffix, rather than one of them discovering it after the tag is pushed.

**A release is triggered by pushing one tag (`v0.1.0`)** — never separate `vscode-ext/v*` and `standalone/v*` tags, because one changelog entry covers both.

Expand All @@ -46,20 +46,20 @@ Source of truth: `scripts/bump-version.sh`; `on.push.tags` in `.github/workflows

**Both signing steps must run locally** — Windows code signing requires a physical USB hardware key (EV cert via PIV), macOS a local Developer ID cert.

- **Stage 1 (CI)** — build the unsigned Tauri apps (win, mac, linux) and the VSCode extension, generate and attest their artifact manifests, upload the unsigned Tauri artifacts, publish the extension after protected-environment approval.
- **Stage 2 (local, `sign-and-deploy.sh`)** — download the CI artifacts → verify attestations and hashes → sign macOS (codesign + notarize) → sign Windows (jsign + PIV hardware key) → generate the Tauri update manifest with signatures → upload the signed artifacts to the GitHub Release.
- **Stage 1 (CI)** — build, attest, and upload the unsigned artifacts: the three Tauri bundles and the `.vsix`, which Stage 2 verifies but never signs.
- **Stage 2 (local, `sign-and-deploy.sh`)** — verify, sign, and release.

## Stage 1: CI workflow

Triggered by tag push `v*`: `build-standalone`, `build-vscode`, and `security-audit` run in parallel, then `publish-vscode` once all three succeed. Matrix targets, pnpm/Node versions, and step ordering live in [.github/workflows/release.yml](../../.github/workflows/release.yml).

Environment protection, secret placement, and token permissions follow `docs/specs/security-ci.md` → "GitHub Actions Policies", "Automated Maintainer (tend)", and "VS Code Extension Releases".

**Must create `release-attest` before referencing it in both build jobs**, with no required reviewer (rationale); GitHub otherwise auto-creates an unprotected environment.
**Must create `release-attest` before referencing it in both build jobs**, with no required reviewer (rationale).

**Never use `tauri-action`'s built-in GitHub Release creation** — the release is created locally, after signing.

**The `build-standalone` artifact upload must set `include-hidden-files: true`** — `actions/upload-artifact` v4.4+ silently drops dotfiles, and the zsh shell integration ships as ZDOTDIR dotfiles (rationale). The `vscode-extension` upload keeps the safer default — only `*.vsix` and the manifest.
**The `build-standalone` artifact upload must set `include-hidden-files: true`** (rationale). The `vscode-extension` upload keeps the safer default — only `*.vsix` and the manifest.

**Must hash `artifact-executables.txt` into each standalone artifact manifest and upload it alongside the files**, recording their executable paths before ZIP transport loses permissions (rationale).

Expand All @@ -75,6 +75,8 @@ Dispatches `security-audit.yaml` on the release tag (`gh workflow run`), polls f

Runs in CI because Marketplace publishing uses PAT tokens, no hardware key. **The `vscode-extension-publish` environment must require reviewer approval and admit deployments only from `v*` tags**, and **`VSCE_PAT` / `OVSX_PAT` must be environment secrets there**, never broad repository secrets.

Those three, and the `Tag operations` ruleset behind `v*`, are GitHub repository settings: nothing in the tree carries them and no lint can read them, so a checkout cannot prove the release path is gated. Verify them in the repository's settings when auditing it.

## Stage 2: Local script

**Never mutate `release-signed/downloads/`.** `all` and `resume` rebuild `release-signed/work/`; `sign-mac` and `sign-win` refresh only their platform, and `notarize` preserves the signed Mac copy. `--help` lists subcommands.
Expand All @@ -91,7 +93,7 @@ Downloaded CI artifacts must pass three checks before any signing step:

**Never select release artifacts with a broad `find | head`** — use strict expected paths or exactly-one matching. Release upload rejects unexpected local files or existing remote asset names.

When rebuilding the Windows installer locally, the script rewrites the CI-runner absolute paths baked into the Tauri-generated `.nsi` (via `scripts/patch-nsis-paths.pl`) and repoints the `ADDITIONALPLUGINSPATH` and `OUTFILE` defines at the local plugin directory and installer path before `makensis`.
**Must repoint the Tauri-generated `.nsi` at local paths before `makensis`** — it is baked with CI-runner absolutes; `rebuild_windows_installer` documents the rewrite.

**Runs on macOS only** — it uses `codesign` / `xcrun notarytool` / `ditto`, and its in-place `sed -i ''` edits are BSD form.

Expand All @@ -118,18 +120,15 @@ pnpm --dir standalone exec tauri signer generate # creates the Tauri update sig

**Must OS-sign the inner executable, package it, then Tauri-sign the final bundle.** Embed the generated `.sig` in the website manifest and remove the sidecar signature file before upload.

Two macOS packaging edge cases the script enforces; each would ship a release that fails only on the user's machine:

- **Never `--deep`-sign the outer `.app`** — it would re-sign the Node sidecar and drop the hardened-runtime entitlements it needs. Nested binaries (the Node sidecar, the node-pty and node-datachannel prebuilds, `spawn-helper`) are signed individually first, and the script then launches the signed sidecar and requires both native addons from it.
- **Build the `.tar.gz` with `COPYFILE_DISABLE=1`** and re-scan the result for `._*` entries — AppleDouble resource-fork files make the Tauri updater's extraction fail with `failed to unpack ._Dormouse.app`.
Two macOS packaging edge cases the script enforces, each of which would ship a release that fails only on the user's machine: **never `--deep`-sign the outer `.app`** — nested binaries (the Node sidecar, the node-pty and node-datachannel prebuilds, `spawn-helper`) are signed individually first, and the script then launches the signed sidecar and requires both native addons from it — and **build the `.tar.gz` with `COPYFILE_DISABLE=1`**, re-scanning the result for `._*`. Both carry their reasoning at `sign_macos_app` and `notarize_macos` in the script.

### Packaged app logging

Packaged-app log paths and `DORMOUSE_LOG_FILE` follow `docs/specs/standalone.md` → Logging.

## Artifact filenames

**All release assets use stable filenames** (no version in the name), so dormouse.sh can hotlink through GitHub's `/latest/download/` redirect with no server-side logic — e.g. `https://github.com/diffplug/dormouse/releases/latest/download/Dormouse-macos-aarch64.tar.gz`. The stable names are the `FNAME_*` constants in `scripts/sign-and-deploy.sh`.
**All release assets use stable filenames** (no version in the name)the `FNAME_*` constants in `scripts/sign-and-deploy.sh` — so a bundle URL differs only in its version path segment.

## Tauri auto-updater

Expand All @@ -146,7 +145,7 @@ Config lives in [tauri.conf.json](../../standalone/src-tauri/tauri.conf.json) (`

Shape: `version`, `notes` (a link to the GitHub release tag, not the changelog body), `pub_date`, and a `platforms` map keyed `darwin-aarch64` / `windows-x86_64` / `linux-x86_64`, each with `url` and `signature` (that bundle's `.sig`, verbatim). **The script fails rather than emit a platform with an empty signature.**

Manifest URLs carry the version in the *path* (`/v0.1.0/`) while the *filenames* stay stable (rationale).
Manifest URLs carry the version in the *path* (`/v0.1.0/`) while the *filenames* stay stable. **The homepage's download buttons read their URLs from this same committed manifest**, so a release is only downloadable once step 6 has deployed it (`website/src/pages/Home.tsx`).

Source of truth: `sign_updates` in `scripts/sign-and-deploy.sh`; `plugins.updater` in `standalone/src-tauri/tauri.conf.json`.

Expand Down Expand Up @@ -180,4 +179,4 @@ See `docs/specs/hosted.md` -> "Production releases" for the Hosted pipeline and

## Future

**Analytics-backed download URLs.** The `/latest/download/` hotlinks could move to `dormouse.sh/download/...` behind Cloudflare R2. Changing website links and manifest bundle URLs needs no app update while the manifest endpoint remains stable.
**Analytics-backed download URLs.** The GitHub release URLs could move to `dormouse.sh/download/...` behind Cloudflare R2. Changing website links and manifest bundle URLs needs no app update while the manifest endpoint remains stable.
4 changes: 0 additions & 4 deletions docs/specs/deploy.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,3 @@ The 2026-09-05 audit found that standalone resume commands reset every working a
## Two signing layers

**What each layer actually proves.** OS signing proves the executable is from DiffPlug; Tauri signing proves the update bundle was not tampered with in transit.

## Update manifest (`standalone-latest.json`)

**Why two URL schemes name the same asset.** The manifest points at versioned release paths (`/v0.1.0/`) while the website hotlinks the `/latest/download/` redirect; with no version in the filenames, both resolve to the same file.
Loading
Loading