@rightkit/release 0.2.173 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +188 -5
  2. package/bin/darwin/rightkit-swap +0 -0
  3. package/bin/darwin/rightkit-swap.sign.json +1 -1
  4. package/cli/right-release.mjs +34 -6
  5. package/command-spec.mjs +80 -0
  6. package/deps-cargo.mjs +55 -0
  7. package/deps.mjs +7 -0
  8. package/development-signing.mjs +135 -4
  9. package/development.mjs +12 -11
  10. package/dist-scan.mjs +102 -0
  11. package/dmg-build.mjs +53 -4
  12. package/icon-pack.mjs +117 -0
  13. package/in-place-install.mjs +32 -7
  14. package/init-command.mjs +116 -0
  15. package/install-app-windows.mjs +108 -17
  16. package/install-app.mjs +68 -11
  17. package/install-tauri-devid.mjs +106 -47
  18. package/local-publish.mjs +317 -29
  19. package/mac-bundle-signing.mjs +143 -1
  20. package/mac-signing-matrix.mjs +73 -6
  21. package/macho-approval-hash.mjs +181 -0
  22. package/macos-signing-session.mjs +12 -2
  23. package/managed-cargo.mjs +49 -1
  24. package/media-sidecar.mjs +20 -4
  25. package/mirror-root-artifact.mjs +69 -21
  26. package/npm-license-notices.mjs +159 -0
  27. package/ownership-dependency-allowlist.json +1 -0
  28. package/ownership.mjs +37 -4
  29. package/package-recipe-targets.mjs +180 -16
  30. package/package.json +1 -1
  31. package/pe-content.mjs +133 -0
  32. package/portable-development.mjs +9 -9
  33. package/publication-gate.mjs +70 -3
  34. package/publish-cargo.mjs +26 -7
  35. package/qa-build-features.mjs +7 -1
  36. package/recipe-build.mjs +346 -0
  37. package/release-dependencies-command.mjs +45 -0
  38. package/release-dependencies.mjs +60 -0
  39. package/release-switch-scan.mjs +17 -7
  40. package/release.mjs +98 -12
  41. package/rightkit-ownership.json +46 -2
  42. package/rightkit-signing-agent.mjs +10 -4
  43. package/rightkit-versions.json +11 -6
  44. package/runtime-budgets.mjs +28 -12
  45. package/stage-binary.mjs +1 -1
  46. package/stage-sidecar-command.mjs +36 -4
  47. package/stage-sidecar.mjs +926 -74
  48. package/test-command.mjs +190 -0
  49. package/toolchain-swift.mjs +239 -0
  50. package/web-build.mjs +67 -0
package/README.md CHANGED
@@ -65,7 +65,7 @@ Every Right app has absolutely NO access to DevTools; the single exception is Vi
65
65
 
66
66
  | Group | Subcommands |
67
67
  |---|---|
68
- | Build | `release`, `build`, `package` |
68
+ | Build | `release`, `build`, `package`, `build --web` / `web-build` (web frontend only) |
69
69
  | Build (SwiftPM apps) | `swift-package` (`windows-stage`, `msix`, `mac-app`) (config schema 1: `windows.{exe,out,searchDirs,include,dependencies,publisher,assets}`, `mac.{executable,bundleId,out,frameworks,resources,entitlements}`) |
70
70
  | Install | `install-app` |
71
71
  | Publish | `publish`, `upload`, `publish-update`, `github`, `mirror-root-artifact` |
@@ -75,8 +75,86 @@ Every Right app has absolutely NO access to DevTools; the single exception is Vi
75
75
  | Dev artifact signing | `sign-dev-artifact --artifact <dir> --manifest <sign.json> [--metadata <payload.json>] [--evidence <path>] [--dry-run]`: Developer ID signs an existing CI dev artifact per file with fixed identifiers (macOS). It verifies the recorded hashes in the payload metadata (default `payload.json`, `{ schema: 1, files: [{ path, sha256 }] }`) first, signs each sign-manifest entry (`file` relative to the artifact, `identifier` required, nested code before its bundle) through the dev-signing identity (missing Developer ID: `DEV_MAC_SIGNING_DEVELOPER_ID_REQUIRED`, no ad hoc fallback), verifies with `codesign --verify --strict`, reads back Identifier and TeamIdentifier, refreshes the metadata hashes, and writes evidence JSON. Never builds, notarizes or publishes. The payload metadata must sit outside every signed entry (`SIGN_DEV_ARTIFACT_METADATA_IN_SIGNED_ENTRY`). An optional declarative `refresh` in the sign manifest recomputes digests that other JSON files record about signed bytes: `{ "refreshRoot": "<dir inside the artifact>", "refresh": [{ "file", "pointer" (JSON pointer to an existing string), "compute": "sha256-file" \| "sha256-tree-v1" \| "verify-sha256-file", "of", "format"?, "currentMatches"? }] }` (`file` and `of` relative to `refreshRoot`; `sha256-tree-v1` = sha256 over the sorted relative paths, each as path, NUL, 8-byte big-endian length, bytes; `verify-sha256-file` writes nothing and refuses a digest that changed; `format` embeds the digest in a locator string through one `{value}`; `currentMatches` refuses to overwrite a value of another kind, such as a signed attestation locator). Order is enforced: operations run after the entries they read are signed and before the first entry that contains a refreshed file, so that bundle (the wrapper) signs last over the final bytes; an operation may not read what a later one writes. JSON is rewritten in place with key order kept, and a file the writer would have to reformat is refused. Evidence gains a `refresh` list |
76
76
  | Checks | `deps`, `hardening`, `harden`, `hardeningscan`, `cargo-target` |
77
77
  | Ownership | `ownership`, `ownership-approval`, `ownership-report` |
78
+ | Test | `test` (the app's declared suites) |
79
+ | Legal | `license-notices npm` |
78
80
  | Other | `dev`, `doctor`, `suite-doctor`, `qa`, `dmg`, `cache`, `apple`, `inventory`, `model`, `addon`, `hooks`, `lsclean`, `generate-dmg-background`, `publish cargo`, `publish npm`, `publish swift` |
79
81
 
82
+ ## Declared test suites, web build, npm notices and artifact mirroring
83
+
84
+ These replace per-app wrapper scripts. Each is declared in `right-release.config.mjs` and runs without a shell from the app
85
+ root (the config file's directory). A step is `{ cmd | node, args?, cwd?, env?, nodeFlags?, nodeOptions? }`: `node` is an
86
+ app-relative script run by the Node that runs `right-release`, `cmd: "node"` means the same Node, `nodeFlags` go before the
87
+ script, `nodeOptions` are merged once into `NODE_OPTIONS`. `cwd` and `node` must stay inside the app; unknown keys are refused.
88
+
89
+ ### `right-release test`
90
+
91
+ ```js
92
+ tests: {
93
+ eventPrefix: "e2e", // optional, default "test": events are <prefix>_suite_started / _completed
94
+ runners: { // optional named bases
95
+ vitest: { node: "node_modules/vitest/vitest.mjs", nodeFlags: ["--no-experimental-webstorage"], nodeOptions: ["--no-experimental-webstorage"] },
96
+ },
97
+ suites: [
98
+ { name: "tooling", runner: "vitest", args: ["run", "scripts/tooling.test.mjs", "--no-file-parallelism", "--maxWorkers=1"] },
99
+ { name: "runtime-config", node: "scripts/runtime-config.test.mjs", nodeFlags: ["--test"] },
100
+ // Expands to journeys-1, journeys-2, ...: the names in the JSON list run `chunk` at a time, one process per chunk.
101
+ { name: "journeys", node: "scripts/qa-browser-functional.mjs", args: ["--keep-going"],
102
+ journeys: { file: "scripts/qa-e2e-journeys.json", key: "journeys", chunk: 12, flag: "--journey" } },
103
+ ],
104
+ }
105
+ ```
106
+
107
+ `right-release test` runs every suite in order and stops at the first failure, exiting with that suite's code; each suite prints
108
+ a JSON line before and after (`{"event":"e2e_suite_completed","suite":"tooling","exitCode":0,"durationMs":1234}`). `--suite a,b`
109
+ (repeatable) selects suites; a group name such as `journeys` selects all its chunks. `--runner vitest -- run --project unit`
110
+ runs a runner once with the arguments after `--` (watch mode, one project); `-- <args>` after `--suite` is allowed when exactly
111
+ one suite is selected. `--list [--json]` prints the resolved commands and runs nothing. Wrap it as the repository needs, for
112
+ example `rightkit storage run --app <app> -- right-release test`.
113
+
114
+ ### `right-release build --web` (or `web-build`)
115
+
116
+ ```js
117
+ web: { build: [
118
+ { node: "node_modules/typescript/bin/tsc", nodeFlags: ["--disable-warning=DEP0205"] },
119
+ { node: "node_modules/vite/bin/vite.js", args: ["build"], nodeFlags: ["--disable-warning=DEP0205"] },
120
+ ] }
121
+ ```
122
+
123
+ Runs only the `web.build` steps: no Tauri, no signing, no packaging. `--dry-run` prints the commands. It is guarded by the same
124
+ GitHub-Actions-only execution policy as the other build commands.
125
+
126
+ ### `right-release license-notices npm`
127
+
128
+ ```js
129
+ legal: { npmNotices: { output: "legal/THIRD_PARTY_NPM_LICENSES.html", productName: "ViewRight", exclude: ["@rightkit/"] } }
130
+ ```
131
+
132
+ Writes the third-party npm license appendix for the production closure of a pnpm app (`pnpm licenses list --prod --json`),
133
+ with each package's own LICENSE / COPYING / NOTICE text. A package with no license file gets the standard published text for
134
+ its declared MIT or ISC license, labelled as a fallback; any other license without a file fails the run. The page records the
135
+ app version, the `pnpm-lock.yaml` sha256 and `generated-by=right-release license-notices npm`, so equal inputs give equal bytes.
136
+ `--check` writes nothing and exits 1 when the file differs. `--output` and `--product-name` override the config.
137
+
138
+ ### Mirroring an artifact (`mirror-root-artifact`)
139
+
140
+ ```js
141
+ targets: { win: {
142
+ mirror: [{ file: "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/App_1.0.0_x64-setup.exe", pruneSuffix: "-setup.exe" }],
143
+ postSign: { cmd: "right-release", args: ["mirror-root-artifact", "--config", "right-release.config.mjs", "--platform", "win"] },
144
+ } }
145
+ ```
146
+
147
+ Copies each `targets.<platform>.mirror` entry (an app-relative path, or `{ file, name?, pruneSuffix? }`) atomically into
148
+ `RIGHT_RELEASE_ARTIFACT_DIR`. The directory must be outside the repository and the command refuses to run without it, so it
149
+ never writes into the repository root. `pruneSuffix` then removes the other files with that suffix from that directory, so it
150
+ holds only the current installer. The one-file form (`--file <path> --package-root <dir> [--name <name>]`) is unchanged.
151
+
152
+ ### macOS updater tarball from `macPackage`
153
+
154
+ `targets.mac.macPackage.updater: true` (or an app-relative `.tar.gz` path) adds a `create-mac-updater` command at the front of
155
+ `postSign`: it packs the signed, stapled `.app` and signs the tarball. The default output is `<App>.app.tar.gz`, named after the
156
+ app in `notarize.app`. Declare the file in `updater.artifacts` as before. See `docs/MAC-PACKAGE-RECIPE.md`.
157
+
80
158
  ## QA receipts (warn-and-record)
81
159
 
82
160
  `right-release qa --stage source|installed --platform mac|win` still runs `rightkit-qa` and writes a signed receipt, and receipt verification is unchanged. A missing or invalid receipt no longer blocks a release. `build`, `upload`, `publish-update` and `github` print `[right-release] QA warning: ...` to stderr, continue, and record the outcome:
@@ -100,13 +178,13 @@ The shipped `bin/darwin/rightkit-swap` is Developer ID signed. It is added by `r
100
178
 
101
179
  Development builds (from `@rightkit/ci` `devArtifact`, Developer ID-signed but not notarized) install with `--dev` and exactly one of `--dmg`, `--zip <path.zip>` (extracted with `ditto -x -k`), or `--app <path.app>`: `right-release install-app --dev --zip dev-mac.zip`. `--dev` still requires `codesign --verify --deep --strict`, a Team ID, no Team ID change, and a bundle-id match; it skips only `spctl` and `stapler`, removes `com.apple.quarantine` from the new bundle only, prints `DEV BUILD (not notarized)`, and sets `dev: true` on every JSON event.
102
180
 
103
- ## One fixed path, one identity, one `.previous` (every app)
181
+ ## One fixed path, one identity; release keeps one `.previous`, dev keeps none (every app)
104
182
 
105
- macOS and Windows tie privacy grants (mic, screen, Files and Folders, Removable Volumes, Full Disk Access) to the path an app runs from and to its code identity. So every app, helper and dev or QA binary installs and updates IN PLACE at ONE fixed path under ONE identity; there is no per-version, per-digest, per-pid or per-build install folder or bundle id. Rollback is a persistent `<name>.previous` beside the fixed path (rename it back); nothing ever runs from `.previous` or from a staging copy.
183
+ macOS and Windows tie privacy grants (mic, screen, Files and Folders, Removable Volumes, Full Disk Access) to the path an app runs from and to its code identity. So every app, helper and dev or QA binary installs and updates IN PLACE at ONE fixed path under ONE identity; there is no per-version, per-digest, per-pid or per-build install folder or bundle id. A RELEASE install may keep one rollback `<name>.previous` beside the fixed path (rename it back). A DEV or QA install keeps NO old copy of any kind (Adrian, bogusyogi/rightkit#348: "when installing dev, old copy must never be backedup"; reinstall from an older DMG): it replaces the path in place through a short-lived staging name and removes any stale `.previous`, and a failed dev install leaves the path absent or partial with a clear error. Nothing ever runs from `.previous` or from a staging copy.
106
184
 
107
- - `install-app` (macOS) renames the replaced app to `<App>.app.previous` (never the Trash); `install-app --installer <Setup.exe> --dest <folder> --exe <App.exe>` does the same for a Windows folder install. `right-release dev` (mac) installs to `install.destination` (required) with `<dest>.previous` and fails if its designated requirement differs from the installed copy; a portable dev payload installs as `<installRoot>/current` with `current.previous`; the Windows direct installer installs to `current` with `current.previous`.
185
+ - `install-app` (macOS) renames the replaced app to `<App>.app.previous` (never the Trash) for a release install only; `install-app --dev` removes the old app and keeps nothing. `install-app --installer <Setup.exe> --dest <folder> --exe <App.exe> [--payload <file>]... [--payload-manifest <json>]` runs the NSIS installer in place. It NEVER copies or restores the whole install folder, because that folder can hold user data (CodeRight keeps `coderight.db`, its WAL, state and models there). Its backup and rollback are payload-only, from an explicit allowlist: the `--exe` file plus `--payload` entries and a `--payload-manifest` JSON of relative file paths (files, never directories); with no `--payload` it backs up and rolls back nothing. `right-release dev` (mac) installs to `install.destination` (required) with no `.previous` and fails if its designated requirement differs from the installed copy; Windows tauri dev runs the silent installer without copying `<exe>.previous`; a portable dev payload installs as `<installRoot>/current` with no `current.previous` (data beside `current` is never touched); the Windows direct installer installs to `current` with `current.previous`.
108
186
  - `right-release dev` (mac) signs with the same Developer ID Application identity as release (an app that needs a provisioning profile uses a Developer ID profile); Apple Development certificates are refused with `DEV_MAC_SIGNING_DEVELOPER_ID_REQUIRED`.
109
- - **Cargo target folders are not stable paths.** The broker's target tree is `<managed>/rightkit-build-control/ws/<hash>/g/<generation>/target/...` and the generation changes; `cargo-guard` uses a hashed `dev-targets/<project>-<hash>` folder. They are build output only, so nothing may run from them. Dev and QA binaries run from a fixed staged path: list them in `development.targets.<platform>.stage` (`[{ source } | { manifestPath, bin, targetTriple?, profile?, name? }]`) and `right-release dev` copies each in place to `<app root>/.rightkit-dev-bin/<triple-or-host>/<name>` (keeping `<name>.previous`); `rightkit-qa` launches from its `.rightkit-qa-bin` stash on every path.
187
+ - **Cargo target folders are not stable paths.** The broker's target tree is `<managed>/rightkit-build-control/ws/<hash>/g/<generation>/target/...` and the generation changes; `cargo-guard` uses a hashed `dev-targets/<project>-<hash>` folder. They are build output only, so nothing may run from them. Dev and QA binaries run from a fixed staged path: list them in `development.targets.<platform>.stage` (`[{ source } | { manifestPath, bin, targetTriple?, profile?, name? }]`) and `right-release dev` copies each in place to `<app root>/.rightkit-dev-bin/<triple-or-host>/<name>` (no backup of the old copy); `rightkit-qa` launches from its `.rightkit-qa-bin` stash on every path.
110
188
 
111
189
  ## Runtime budget gate (crates)
112
190
 
@@ -203,6 +281,10 @@ sign: {
203
281
 
204
282
  Nested code that the map does not name (frameworks, other helpers) is signed first with the defaults; the appex's own `Contents/MacOS` executable is signed before the appex, and the appex signature then carries the extension entitlements. The Finder extension's entitlements never reach the app and the app's Apple Events entitlement never reaches the extension.
205
283
 
284
+ ### Signing the built DMG (`dmg build --sign`)
285
+
286
+ `right-release dmg build ... --sign [--identifier <id>] [--receipt <path>]` Developer ID signs the finished image with the shared Developer ID Application identity (secure timestamp, no hardened runtime, as for any disk image; optional stable codesign `--identifier`; strict verification and identity/timestamp readback), so an app whose `package` step builds its own DMG needs no direct `sign-macos.mjs` call before `notarize`. `--receipt` writes the same signing receipt `sign-macos.mjs` writes. A signing failure removes the unsigned image (`DMG_SIGN_FAILED`). It never notarizes or uploads; there is no ad hoc or Apple Development fallback. `buildDmg({ ..., sign: true, identifier, signReceipt })` is the SDK form. `targets.mac.macPackage` and SwiftPM apps already sign the DMG themselves.
287
+
206
288
  ### DMG from an appdmg-style spec
207
289
 
208
290
  `right-release dmg build --app <.app> --out <dmg> --spec <appdmg.json>` (and `targets.mac.dmg.spec` for SwiftPM apps that right-release packs itself) renders an appdmg-style JSON file through the same headless hdiutil pipeline; explicit flags and `targets.mac.dmg` options win over the spec, and the spec's `title` is the volume name (on the command line, `--volname` is required when the spec has no `title`). Relative `background` and `icon` paths resolve against the spec file's directory. Pulse's `release/appdmg.json` works unchanged:
@@ -292,6 +374,22 @@ Linux has catalog and planner support only: the planner maps `linux` to the `*-u
292
374
 
293
375
  Any app that ships more than the ffmpeg sidecar (MailRight: `mailright-engine`, `mailright-core`) sets `targets.mac.externalBin` / `targets.win.externalBin`: `{ names: [...], arch?, stageDir? ("src-tauri/binaries"), bundleDir? (mac: "Contents/MacOS" | "Contents/Resources"), stage?: { cmd, args? } }`. It is the generic form of the ffmpeg staging above and reuses its target-triple naming (`<name>-<triple>[.exe]`), staged-file checks and signing evidence checks (`external-bin.mjs` over `media-sidecar.mjs`); it has no catalog, notices or manifest seal. `stage` is the app's own build of the files. Windows signs the staged files in the raw-EXE step; macOS signs `<bundleDir>/<name>` as nested code (hardened runtime) and checks the nested receipt. `external-bin.json` (state root) records pre-sign and signed digests. Names that `mediaSidecar` stages (`ffmpeg`, `ffprobe`) are refused here. Tests: `external-bin.test.mjs`.
294
376
 
377
+ ## Sidecar staging: source builds, prebuilt checks, archives, mixed variants, universal macOS
378
+
379
+ `stage-sidecar` (the `sidecars` list in `right-release.config.mjs`, receipt `stage-sidecar.json`) covers these cases without an app script. Every download is https and digest-pinned, nothing runs through a shell, and a failed check stages nothing.
380
+
381
+ - **`cmake` (Orthic-Labs/rightkit#489):** `{ kind: "cmake", name: "whisper-cli", repo: "https://github.com/ggml-org/whisper.cpp", commit: "<40 hex>", target?: "whisper-cli", output?: "bin/whisper-cli", cmakeArgs?: ["-DBUILD_SHARED_LIBS=OFF"], platformArgs?: { mac: ["-DGGML_METAL=ON"], win: [...] }, generator?, smoke?: ["--help"], sha256?, dest?, clean?, platforms? }`. It fetches exactly that commit into the fixed `.right-release/cmake/<name>/src` (`git init`, `fetch --depth 1 origin <commit>`, detach; a checkout already at the commit is reused, one with local edits or at another commit is refused with `STAGE_SIDECAR_SOURCE_MISMATCH`), configures and builds `target` in `.right-release/cmake/<name>/build` (`CMAKE_OSX_ARCHITECTURES` is set from the target; `universal-apple-darwin` builds `arm64;x86_64` and checks `lipo -archs`), finds the executable (`output`, then `Release/` layouts, `.exe` on Windows), optionally pins its sha256, stages it as `<name>-<triple>[.exe]` and smoke-runs it when the target can run on this host. `cmakeArgs` and `platformArgs` accept only `-DNAME=value` definitions.
382
+ - **`prebuilt` (#490):** verifies a host-built binary already staged at `<stageDir>/<name>-<triple>` (or copied from `from`) against an app manifest, never building or downloading: `manifest: { file, entry: "binaries.{platform}", sha256?: "sha256", approvalSha256?: "approval_sha256", approvalAlgorithm?: "approval_hash_algorithm" }` (`{platform}` is `macos-aarch64`, `windows-x86_64`, ...). The raw sha256 must match; when an approval hash is named it must match too, with `raw-sha256` or `macho-codesign-strip-linkedit-v1` (`macho-approval-hash.mjs`: the sha256 of a thin Mach-O with its code signature removed and `__LINKEDIT` normalised, so a signed and an unsigned copy of one build agree; macOS only, `codesign --remove-signature` on a private copy). A missing file, entry or digest fails closed.
383
+ - **`remote` archives (#491):** `extract: "<member file name>"` (with `memberSha256?`, `executable?`) unpacks one member of a zip or tgz after the archive digest passes, so a Windows zip needs no `command` entry.
384
+ - **`ffmpeg` mixed variants, fetch and probes (#491):** `variants: { ffmpeg: "export-ass" }` lets one entry take the export-ass ffmpeg with the decode ffprobe (a tool not named uses `variant`). `sources: { ffmpeg: { url, sha256, member }, ffprobe: {...} }` downloads the bytes the catalog only pins: the archive is digest-checked, the member is unpacked and its sha256 must equal the APPROVED catalog row's content digest, so a swapped archive can never stage. `probe: true` runs `-version` and `-encoders` on a staged binary this host can run: the configuration must say `--disable-gpl` and `--disable-nonfree` and must not enable GPL, nonfree, libx264, libx265, libxvid or libfdk-aac, and ffmpeg may not list those encoders (`STAGE_SIDECAR_FFMPEG_PROBE_FAILED`; another architecture or OS is recorded as `skipped-cross-target`). The catalog has no Windows `export-ass` row yet (only `export-ass-zimg`); that row belongs to the rightkit-ffmpeg crate.
385
+ - **`universal-apple-darwin` for `ffmpeg` and `onnxruntime` (#492):** each arch row is staged thin under `.right-release/slices/<triple>/` and the files are lipo-merged into `<stageDir>/<tool>-universal-apple-darwin` (onnxruntime: into `resourceDir`), requiring both archs and every row approved (onnxruntime also accepts `pinned`). For `ffmpeg`, `{arch}`, `{triple}` and `{stageDir}` in the `stage` command's arguments name the slice (it runs once per slice), `sources` need the per-arch form `{ ffmpeg: { aarch64: {...}, x86_64: {...} } }`, one source offer lists both slices, and the receipt rows carry `slices`. A merged file identical to the staged one is not rewritten.
386
+
387
+ ## Declared Rust audit, dist-scan and the Windows Swift toolchain
388
+
389
+ - `right-release deps --audit` also runs `cargo audit` in every folder named by `deps.cargoWorkdirs` (app-relative, each must hold a `Cargo.lock`), through the managed cargo client (the RightKit broker on managed hosts; there is no bare-cargo fallback on a managed Mac), and fails when any workdir fails or cannot start. An app with no `deps.cargoWorkdirs` audits no Rust. Orthic-Labs/rightkit#674.
390
+ - `right-release dist-scan --dist <dir> --forbid <string>... [--forbid-file <path>] [--forbid-name <regex>]... [--ext js,css,...]` fails a built web `dist/` that carries a forbidden marker string in a text file, or a chunk named `devSource-*`, `mockDataSource-*`, `fixtures-*` or matching `--forbid-name` (exit 1; exit 2 on a usage error, a missing `dist/` or a run with no markers). It replaces per-app copies of a release-bundle checker. Orthic-Labs/rightkit#484.
391
+ - `right-release toolchain swift --version 6.4.0 --sha256 <pin> [--github-env] [--json]` (Windows) downloads the swift.org installer, refuses unless its sha256 equals the pin (before anything runs; a bad download is deleted), skips the install when that Swift version is already on PATH, otherwise runs it silently (`/quiet /norestart`, exit 0 and 3010 pass), checks `swift --version`, finds the runtime bin (`Runtimes\<ver>\usr\bin` with `swiftCore.dll`) and, with `--github-env`, appends only the variables the installer added or changed (names printed, values never) to `GITHUB_ENV`, and the new PATH entries, the swift directory and the runtime bin to `GITHUB_PATH`. Orthic-Labs/rightkit#436 (the `@rightkit/ci` `swift-toolchain` step calls this and lives in the ci package).
392
+
295
393
  ## Keeping agent writes off the internal drive (`disk agent-env`, `disk probe`)
296
394
 
297
395
  No agent may write to the internal drive (C: on Windows, the internal disk on macOS); only installed apps go there.
@@ -320,3 +418,88 @@ No agent may write to the internal drive (C: on Windows, the internal disk on ma
320
418
  - `smol-toml` is pinned exactly. A consumer that depends on a different `smol-toml` version gets a second copy.
321
419
  - Apps pin this package at different versions; `docs/ADOPTION.md` lists each pin and marks the stale ones.
322
420
  - Prepublish runs the test suite, the Windows signer integration test and the registry parity check.
421
+
422
+ ## Post-sign hook chains
423
+
424
+ `target.postSign` accepts either one command object or an array of command objects. An array runs in order, for example `create-mac-updater` followed by `lsclean`, and stops at the first failing command, so the release fails closed. Every command in the array is scanned for forbidden build features and release switches, exactly like a single command. A single object behaves as before.
425
+
426
+ Each command may set `timeoutMs`. When it expires the release kills the whole process tree (`taskkill /T /F` on Windows, the process group elsewhere) and exits non-zero, so a hung child such as Tauri leaves no orphan.
427
+
428
+
429
+ ## App-specific forbidden release crates (G14)
430
+
431
+ Declare exact Cargo package names in `right-release.config.mjs`:
432
+
433
+ ```js
434
+ export default {
435
+ // Existing app, version & distribution fields...
436
+ releaseDependencies: {
437
+ manifest: "src-tauri/Cargo.toml",
438
+ forbiddenCrates: [
439
+ "rightkit-record", "rightkit-automation", "rightkit-hotkeys",
440
+ "rightkit-overlay", "rightkit-agent-tools", "rightkit-qa", "rightkit-qa-driver",
441
+ ],
442
+ // Optional extra features for build flags hidden inside opaque scripts:
443
+ features: [],
444
+ // Set true only when every release command disables default features:
445
+ noDefaultFeatures: false,
446
+ },
447
+ targets: {
448
+ mac: { cargoTarget: "aarch64-apple-darwin", /* existing packaging config */ },
449
+ win: { cargoTarget: "x86_64-pc-windows-msvc", /* existing packaging config */ },
450
+ },
451
+ };
452
+ ```
453
+
454
+ `right-release build` enforces this policy before packaging. `--skip-checks` skips only app scripts;
455
+ this gate also blocks dry runs when Cargo cannot resolve dependencies. Development builds do not run it.
456
+ Without `releaseDependencies`, existing apps retain their current behavior. An explicit empty
457
+ `forbiddenCrates: []` still verifies that Cargo can resolve its graph.
458
+
459
+ Standalone checks, run from app root:
460
+
461
+ ```sh
462
+ pnpm exec right-release check-release-dependencies
463
+ pnpm exec right-release check-release-dependencies --platform win
464
+ pnpm exec right-release check-release-dependencies --all-targets
465
+ pnpm exec right-release check-release-dependencies --config path/to/right-release.config.mjs
466
+ ```
467
+
468
+ Default selection uses host platform's release commands & host architecture's triple. `--all-targets` checks every configured
469
+ release target; non-host targets need `cargoTarget`, `targetTriple` or `rustTarget`. Each target must be
470
+ registered with managed Cargo. Unknown hosts fail unless an explicit platform or all targets is selected.
471
+
472
+ Graphs use `cargo tree --locked -e normal --prefix none --target <triple>` through normal Cargo dispatch.
473
+ Normal direct & transitive dependencies count; build-only & dev-only edges do not. Names match exactly,
474
+ including renamed dependencies' actual package names; versions, sources & duplicate tree entries do not
475
+ change matching. Checks never read Cargo.lock as evidence of reachability.
476
+
477
+ Each distinct command feature set is resolved separately. Commands, recipe steps & package scripts
478
+ (up to three nested script calls) contribute `--features`, `-F`, `-f`, `--all-features` &
479
+ `--no-default-features`; `releaseSwitches.features` & `releaseDependencies.features` add explicit features.
480
+ Declare flags hidden inside shell/Node script files in config. Manifest precedence is
481
+ `releaseDependencies.manifest`, target `cargoManifest`, `releaseSwitches.manifest`, native assembly
482
+ manifest, then `src-tauri/Cargo.toml` or `Cargo.toml`. Declared missing manifests fail.
483
+
484
+ SDK usage:
485
+
486
+ ```js
487
+ import { assertReleaseDependencies, forbiddenCrateFindings } from "@rightkit/release/release-dependencies.mjs";
488
+
489
+ const evidence = assertReleaseDependencies({ root: appRoot, config, target: config.targets.mac });
490
+ // { manifest, target, trees, forbiddenCrates }; throws with crate & target on refusal.
491
+ const names = forbiddenCrateFindings(treeText, config.releaseDependencies.forbiddenCrates);
492
+ ```
493
+
494
+ `run(command, args, options)` can inject Cargo execution for tests. Standalone CLI rejects missing policy,
495
+ invalid arguments, failed resolution & forbidden dependencies with nonzero exit status. Demo capability
496
+ files, base Tauri config content & app-specific default-feature restrictions remain separate guards.
497
+
498
+
499
+ ## Template rename, PE digest, icon packing
500
+
501
+ `right-release init --name <Product Name> --id <reverse.dns.bundle.id> [--slug <lowercase>] [--dir <root>] [--dry-run]` renames the desktop app template identity in place (see `init-command.mjs`); it runs no gate or build.
502
+
503
+ `right-release pe digest <file> [--algorithm sha256|sha384|sha512] [--json]` prints the signature-independent PE content digest (Authenticode image hash: CheckSum, certificate table entry and certificate data excluded, so signing, re-signing and timestamping do not change it). `right-release pe imports <file> [--allow dll,...] [--allow-file <path>] [--json]` lists regular and delay-load imports and exits 1 when any is outside the allow list (case-insensitive; a trailing `*` allows a prefix such as `api-ms-win-*`; no allow list refuses every import). SDK: `peContentDigest(bytes)` and `verifyImportClosure(bytes, allowedDlls)` in `pe-content.mjs`.
504
+
505
+ `right-release icons ico|icns --out <file> --frame <PNG>... [--dir <folder>]` packs per-size PNG frames, hand-tuned small ones included, into an ICO (sizes 1..256, PNG-compressed entries) or an ICNS (16, 32, 64, 128, 256, 512, 1024). Frame sizes come from each PNG's IHDR; non-square, duplicate or unsupported sizes are refused. Rendering the PNG frames from a design master stays with the app's design source; the DMG background generator is `generate-dmg-background.py`.
Binary file
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schema": 1,
3
- "sha256": "c6b47bc1767d45fc9a72a401a258d5f645a0c54185f2bf2b6c51634cff7c8d6b",
3
+ "sha256": "cbd04b2fa9af4c416c9d4983e2c63097dc0ca5f987d15eb272123df2de3d22cd",
4
4
  "identity": "Developer ID Application: Adrian D'souza (6KLGD3LLKF)",
5
5
  "codesign": "strict-valid"
6
6
  }
@@ -22,9 +22,17 @@ const commands = new Map([
22
22
  ["sign-candidate", "sign-candidate.mjs"],
23
23
  ["engine-locate", "engine-locator.mjs"],
24
24
  ["stage-sidecar", "stage-sidecar-command.mjs"],
25
+ ["init", "init-command.mjs"],
26
+ ["pe", "pe-content.mjs"],
27
+ ["icons", "icon-pack.mjs"],
28
+ ["recipe-build", "recipe-build.mjs"],
25
29
  ["signing-agent", "signing-agent-command.mjs"],
26
30
  ["create-mac-updater", "create-mac-updater.mjs"],
27
31
  ["mirror-root-artifact", "mirror-root-artifact.mjs"],
32
+ ["test", "test-command.mjs"],
33
+ ["web-build", "web-build.mjs"],
34
+ ["license-notices", "npm-license-notices.mjs"],
35
+ ["toolchain", "toolchain-swift.mjs"],
28
36
  ["github", "github-release.mjs"],
29
37
  ["cargo-target", "cargo-target.mjs"],
30
38
  ["qa", "release-qa-command.mjs"],
@@ -34,6 +42,8 @@ const commands = new Map([
34
42
  ["ownership-mandatory", "ownership-mandatory-set.mjs"],
35
43
  ["swift-package", "swift-package-command.mjs"],
36
44
  ["bundle-scan", "runtime-bundle-scan.mjs"],
45
+ ["check-release-dependencies", "release-dependencies-command.mjs"],
46
+ ["dist-scan", "dist-scan.mjs"],
37
47
  ["identity-scan", "packaged-app-identity.mjs"],
38
48
  ["apple-profile-create", "apple-profile-create.mjs"],
39
49
  ]);
@@ -46,7 +56,7 @@ if (first === "dev") {
46
56
  process.exitCode = 1;
47
57
  });
48
58
  } else {
49
- const githubBuildCommands = new Set(["release", "build", "package", "swift-package", "sign-windows", "sign-updater", "create-mac-updater", "cargo-target"]);
59
+ const githubBuildCommands = new Set(["release", "build", "package", "swift-package", "sign-windows", "sign-updater", "create-mac-updater", "cargo-target", "web-build", "test"]);
50
60
 
51
61
  try {
52
62
  if (githubBuildCommands.has(first)
@@ -123,6 +133,8 @@ if (first === "--version" || first === "-v") {
123
133
  runBinary("bash", [path.join(packageRoot, "lsclean.sh"), ...args.slice(1)]);
124
134
  } else if (first === "generate-dmg-background") {
125
135
  runBinary("python3", [path.join(packageRoot, "generate-dmg-background.py"), ...args.slice(1)]);
136
+ } else if (first === "build" && args.includes("--web")) {
137
+ run("web-build.mjs", args.slice(1).filter((arg) => arg !== "--web"));
126
138
  } else if (commands.has(first)) {
127
139
  run(commands.get(first), args.slice(1));
128
140
  } else if (first.startsWith("-")) {
@@ -182,24 +194,38 @@ function printHelp() {
182
194
  console.log(`right-release <command> [options]
183
195
 
184
196
  Commands:
197
+ pe digest <file> [--algorithm sha256|sha384|sha512] [--json] | pe imports <file> [--allow dll,...] [--allow-file <path>] Signature-independent PE content digest; import-closure check
198
+ icons ico|icns --out <file> --frame <PNG>... [--dir <folder>] Pack hand-tuned per-size PNG frames into an ICO or ICNS
199
+ init --name <Product Name> --id <reverse.dns.bundle.id> [--slug <lowercase>] [--dir <root>] [--dry-run] Rename the desktop app template into a real app, in place
185
200
  stage-sidecar [--config <file>] [--target <triple>] [--json] Stage declarative sidecars into src-tauri/binaries (Tauri externalBin naming)
201
+ recipe-build --plan <json> | --plan-file <path> [--app-root <dir>] Run a package recipe: prepare steps, the Tauri build, postBuild steps (argv, no shell, fail closed)
186
202
  dmg build --app <.app> --out <dmg> --volname <name> [--background <png>] [--spec <appdmg.json>]
187
203
  [--icon-size N] [--app-position x,y] [--applications-position x,y] [--window w,h] [--window-position x,y]
188
- Build branded UDZO image without Finder
204
+ [--sign [--identifier <codesign-identifier>] [--receipt <path>]]
205
+ Build branded UDZO image without Finder; --sign then Developer ID signs the image (secure timestamp, no hardened runtime; never notarizes)
189
206
  install-app --dmg <path.dmg> [--app-name Pulse.app] [--bundle-id <id>] [--dest /Applications]
190
207
  [--relaunch|--no-relaunch] [--json]
191
208
  Verify a signed DMG, quit, replace & relaunch hidden; no UI, no osascript (macOS)
192
- The replaced app stays beside the fixed path as <App>.app.previous
193
- install-app --installer <Setup.exe> --dest <fixed install folder> --exe <App.exe> [--json]
194
- Windows: run the installer in place at the fixed folder, keep <dest>.previous
209
+ A release install keeps the replaced app beside the fixed path as <App>.app.previous; --dev keeps NO old copy
210
+ install-app --installer <Setup.exe> --dest <fixed install folder> --exe <App.exe> [--payload <file>]... [--payload-manifest <json>] [--json]
211
+ Windows: run the installer in place at the fixed folder; payload-only rollback (never the whole folder, user data beside the app is never copied)
195
212
  build [--platform mac|win] [--skip-checks] [--resume-from <phase>]
196
213
  Resume macOS at prepackage, sign, package, post-sign, or harden
214
+ build --web [--config <file>] [--dry-run] Web frontend build only (config web.build steps); no Tauri, signing or packaging (alias: web-build)
215
+ test [--config <file>] [--suite <name>[,<name>]]... [--runner <name>] [--list] [--json] [-- <args>]
216
+ Run the app's declared test suites (config tests.suites / tests.runners) in order; stops at the first failure
217
+ license-notices npm [--config <file>] [--output <file>] [--product-name <name>] [--check]
218
+ Write the third-party npm license appendix HTML from pnpm licenses (config legal.npmNotices); --check only compares
219
+ toolchain swift --version <x.y.z> --sha256 <pin> [--url <https url>] [--github-env] [--json]
220
+ Windows: install the pinned swift.org Swift toolchain (sha256 verified before it runs), check swift --version, find the runtime bin; --github-env exports what the installer changed to GITHUB_ENV / GITHUB_PATH
197
221
  dev --platform win|mac [--config <path>] [--dry-run] [--build-only]
198
222
  Incremental unsigned build & optional local install
199
223
  swift-package <windows-stage|msix|mac-app> --config <swift-package.json> [--dry-run] [--no-sign] [--sign] [--notarize] [--profile <name>]
200
224
  Stage SwiftPM app; build+sign MSIX (Windows) or assemble+sign+notarize .app (macOS); never uploads
201
225
  bundle-scan [--allow-name <basename>]... <bundle-root>...
202
226
  Fail when a bundle carries Node/Python runtimes or script sources
227
+ dist-scan --dist <dir> --forbid <string>... [--forbid-file <path>] [--forbid-name <regex>]... [--ext js,css,...]
228
+ Fail when a built web dist/ carries a forbidden marker string or mock/fixture chunk name (dev, mock or fixture code in release)
203
229
  sign-dev-artifact --artifact <dir> --manifest <sign.json> [--metadata <payload.json>] [--evidence <path>] [--dry-run]
204
230
  Developer ID sign an existing CI dev artifact per file with fixed identifiers; never builds, notarizes or publishes (macOS)
205
231
  sign-candidate --platform win|mac --artifact <dir> --candidate-receipt <receipt.json> --out <binding.json> [--manifest <sign.json>] [--publisher <subject>] [--dry-run]
@@ -216,6 +242,7 @@ Commands:
216
242
  Upload sealed bytes to R2 & register RightApps
217
243
  release Compatibility alias for build
218
244
  publish Compatibility alias for upload
245
+ check-release-dependencies [--platform mac|win|linux | --all-targets] Check app-forbidden release crates
219
246
  publish npm --package @rightkit/<name> [--dry-run] Gate preflight for npm publish
220
247
  publish npm --package @rightkit/<name> --publish --tarball <gated.tgz>
221
248
  Publish the gated tarball with the Keychain token
@@ -266,7 +293,8 @@ Commands:
266
293
  lsclean <AppName.app> Clear macOS LaunchServices duplicates
267
294
  generate-dmg-background <options> Generate the branded multi-resolution DMG background
268
295
  mirror-root-artifact --file <path> --package-root <dir>
269
- Persist artifact in RIGHT_RELEASE_ARTIFACT_DIR
296
+ mirror-root-artifact --config <file> --platform mac|win [--package-root <dir>]
297
+ Persist artifact(s) in RIGHT_RELEASE_ARTIFACT_DIR (config form: targets.<platform>.mirror entries, optional pruneSuffix)
270
298
  github --release <id> --tier patch|update --platform mac|win [--config <file>] [--repo owner/repo] [--dry-run]
271
299
  Same checks and publisher as upload (explicit tier, github-releases provider + configured repository)
272
300
  github --addon <config> --platform mac|win [--repo owner/repo] [--dry-run]
@@ -0,0 +1,80 @@
1
+ // Shared argv command shape for the declarative `tests` and `web` config sections (`right-release test`, `web-build`).
2
+ // A step is one command run without a shell:
3
+ // { cmd, args?, cwd?, env? } any executable ("node" means this Node binary)
4
+ // { node, args?, nodeFlags?, nodeOptions? } a Node script path inside the app, run by this Node binary
5
+ // `cwd` and `node` are app-relative and may not leave the app. `nodeFlags` go before the script, `nodeOptions` are merged
6
+ // (de-duplicated) into NODE_OPTIONS for the child. Unknown keys are refused so a typo cannot silently change a run.
7
+ import path from "node:path";
8
+ import { pathToFileURL } from "node:url";
9
+
10
+ /** Load an app's `right-release.config.mjs`; the app root is the config file's directory. */
11
+ export async function loadAppConfig(configFile = "right-release.config.mjs") {
12
+ const configPath = path.resolve(configFile);
13
+ const loaded = await import(pathToFileURL(configPath).href);
14
+ return { config: loaded.default ?? loaded.config ?? loaded, appRoot: path.dirname(configPath), configPath };
15
+ }
16
+
17
+ export class CommandSpecError extends Error {
18
+ constructor(message) { super(message); this.name = "CommandSpecError"; this.code = "RK_COMMAND_SPEC"; }
19
+ }
20
+
21
+ const fail = (message) => { throw new CommandSpecError(message); };
22
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
23
+ export const STEP_KEYS = ["cmd", "node", "args", "cwd", "env", "nodeFlags", "nodeOptions"];
24
+
25
+ export function relativeInside(value, label) {
26
+ if (typeof value !== "string" || !value.trim()) fail(`${label} must be a non-empty relative path`);
27
+ const normalized = value.replaceAll("\\", "/");
28
+ if (path.isAbsolute(value) || /^[A-Za-z]:/.test(value) || normalized.split("/").includes("..")) fail(`${label} must be relative and stay inside the app: ${value}`);
29
+ return normalized;
30
+ }
31
+
32
+ const stringList = (value, label) => {
33
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) fail(`${label} must be a list of strings`);
34
+ return [...value];
35
+ };
36
+
37
+ /** Validate one step; `extraKeys` are accepted and left to the caller. Returns the step. */
38
+ export function validateStep(step, label, extraKeys = []) {
39
+ if (!isObject(step)) fail(`${label} must be an object`);
40
+ const allowed = new Set([...STEP_KEYS, ...extraKeys]);
41
+ const unknown = Object.keys(step).filter((key) => !allowed.has(key));
42
+ if (unknown.length) fail(`${label} has unknown key(s): ${unknown.join(", ")} (allowed: ${[...allowed].join(", ")})`);
43
+ if (step.cmd !== undefined && step.node !== undefined) fail(`${label} sets both cmd and node; use one`);
44
+ if (step.cmd !== undefined && (typeof step.cmd !== "string" || !step.cmd.trim())) fail(`${label}.cmd must be a non-empty string`);
45
+ if (step.node !== undefined) relativeInside(step.node, `${label}.node`);
46
+ if (step.cwd !== undefined) relativeInside(step.cwd, `${label}.cwd`);
47
+ if (step.args !== undefined) stringList(step.args, `${label}.args`);
48
+ if (step.nodeFlags !== undefined) stringList(step.nodeFlags, `${label}.nodeFlags`);
49
+ if (step.nodeOptions !== undefined) stringList(step.nodeOptions, `${label}.nodeOptions`);
50
+ if (step.env !== undefined && (!isObject(step.env) || Object.values(step.env).some((value) => typeof value !== "string"))) fail(`${label}.env must be an object of strings`);
51
+ return step;
52
+ }
53
+
54
+ /** Add flags to a NODE_OPTIONS string without repeating one already present. */
55
+ export function mergeNodeOptions(existing, flags) {
56
+ const present = (existing ?? "").trim().split(/\s+/).filter(Boolean);
57
+ for (const flag of flags) if (!present.includes(flag)) present.push(flag);
58
+ return present.join(" ");
59
+ }
60
+
61
+ /**
62
+ * Resolve a validated step to what to spawn: `{ cmd, args, cwd, env }`. `extraArgs` are appended after the step's own
63
+ * arguments (the `--` passthrough of `right-release test`). `cmd: "node"` and `node:` use `process.execPath`.
64
+ */
65
+ export function resolveInvocation(step, { appRoot, baseEnv = process.env, extraArgs = [] }) {
66
+ const args = [...(step.args ?? []), ...extraArgs];
67
+ let cmd;
68
+ let argv;
69
+ if (step.node !== undefined) {
70
+ cmd = process.execPath;
71
+ argv = [...(step.nodeFlags ?? []), path.resolve(appRoot, step.node), ...args];
72
+ } else {
73
+ if (typeof step.cmd !== "string" || !step.cmd.trim()) fail("a step needs cmd or node");
74
+ cmd = step.cmd === "node" ? process.execPath : step.cmd;
75
+ argv = [...(step.cmd === "node" ? (step.nodeFlags ?? []) : []), ...args];
76
+ }
77
+ const env = { ...baseEnv, ...(step.env ?? {}) };
78
+ if (step.nodeOptions?.length) env.NODE_OPTIONS = mergeNodeOptions(env.NODE_OPTIONS, step.nodeOptions);
79
+ return { cmd, args: argv, cwd: path.resolve(appRoot, step.cwd ?? "."), env };
80
+ }
package/deps-cargo.mjs ADDED
@@ -0,0 +1,55 @@
1
+ // `right-release deps --audit` for Cargo workdirs (Orthic-Labs/rightkit#674): replaces an app's own audit-rust script.
2
+ //
3
+ // deps: { workdirs: ["."], cargoWorkdirs: ["src-tauri"] } // app-relative folders that hold a Cargo.lock
4
+ //
5
+ // Declared only: an app with no `deps.cargoWorkdirs` audits no Rust, exactly as before. Each folder must be inside the app and
6
+ // hold a `Cargo.lock` (an unlocked workspace cannot be audited, so it is refused, never skipped). `cargo audit` runs through
7
+ // the managed cargo client (`runCargoSync`): on a managed host it goes through the RightKit broker, and a host with no
8
+ // broker or no `cargo-audit` fails closed with the reason; there is never a bare-cargo fallback on a managed Mac.
9
+ import { existsSync } from "node:fs";
10
+ import path from "node:path";
11
+ import { runCargoSync } from "./managed-cargo.mjs";
12
+
13
+ const isInside = (root, target) => {
14
+ const relative = path.relative(root, target);
15
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
16
+ };
17
+
18
+ /** The validated Cargo workdirs `[{ rel, cwd }]` of a release config (empty when none are declared). Throws on a bad declaration. */
19
+ export function cargoAuditWorkdirs(config, root, { exists = existsSync } = {}) {
20
+ const declared = config?.deps?.cargoWorkdirs;
21
+ if (declared === undefined) return [];
22
+ if (!Array.isArray(declared) || declared.some((item) => typeof item !== "string" || !item.trim())) {
23
+ throw new Error("deps.cargoWorkdirs must be a list of app-relative folders");
24
+ }
25
+ const seen = new Set();
26
+ const workdirs = [];
27
+ for (const rel of declared) {
28
+ const normalized = rel.replaceAll("\\", "/");
29
+ if (path.isAbsolute(rel) || /^[A-Za-z]:/.test(rel) || normalized.split("/").includes("..")) {
30
+ throw new Error(`deps.cargoWorkdirs entry must be relative and stay inside the app: ${rel}`);
31
+ }
32
+ const cwd = path.resolve(root, normalized);
33
+ if (!isInside(root, cwd)) throw new Error(`deps.cargoWorkdirs entry must stay inside the app: ${rel}`);
34
+ if (seen.has(cwd)) continue;
35
+ seen.add(cwd);
36
+ if (!exists(path.join(cwd, "Cargo.lock"))) throw new Error(`deps.cargoWorkdirs entry ${rel} has no Cargo.lock (${path.join(cwd, "Cargo.lock")}); an unlocked workspace cannot be audited`);
37
+ workdirs.push({ rel, cwd });
38
+ }
39
+ return workdirs;
40
+ }
41
+
42
+ /**
43
+ * Run `cargo audit` in each workdir and collect failures so every workdir reports. `run(args, options)` is the managed
44
+ * cargo runner (injectable). Returns `{ failures: string[] }`: one entry per workdir that could not start or exited non-zero.
45
+ */
46
+ export function runCargoAudits(workdirs, { run = runCargoSync, log = console.log, env = process.env } = {}) {
47
+ const failures = [];
48
+ for (const { rel, cwd } of workdirs) {
49
+ log(`\n> cargo audit (${cwd})`);
50
+ const result = run(["audit"], { cwd, env, stdio: "inherit" });
51
+ if (result?.error) failures.push(`${rel}: cargo audit could not start (${result.error.message})`);
52
+ else if (result?.status !== 0) failures.push(`${rel}: cargo audit exited ${result?.status ?? "without status"}`);
53
+ }
54
+ return { failures };
55
+ }
package/deps.mjs CHANGED
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { spawnSync } from "./background-process.mjs";
5
5
  import { existsSync } from "node:fs";
6
+ import { cargoAuditWorkdirs, runCargoAudits } from "./deps-cargo.mjs";
6
7
 
7
8
  const args = process.argv.slice(2);
8
9
  const opts = {
@@ -55,6 +56,12 @@ for (const { rel, cwd } of resolvedWorkdirs) {
55
56
  run("pnpm", ["install", ...workspaceArgs, "--lockfile-only", "--ignore-scripts"], cwd, { ignoreScripts: true });
56
57
  }
57
58
  }
59
+ if (opts.mode === "audit") {
60
+ // Rust advisories (#674): every deps.cargoWorkdirs folder, through the managed cargo client; declared only.
61
+ let cargoWorkdirs;
62
+ try { cargoWorkdirs = cargoAuditWorkdirs(config, root); } catch (error) { fail(error.message); }
63
+ failures.push(...runCargoAudits(cargoWorkdirs).failures);
64
+ }
58
65
  if (failures.length > 0) {
59
66
  for (const failure of failures) console.error(`right-deps: ${failure}`);
60
67
  fail(`${failures.length} dependency workdir(s) failed`);