@sdods/cli 0.2.0 → 0.2.2

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.
@@ -76,7 +76,7 @@ export function register(program) {
76
76
  });
77
77
  browsers
78
78
  .command('list')
79
- .description('Show which browsers are installed and where')
79
+ .description('Show which browsers are installed and where; exits 1 if any is missing')
80
80
  .option('-p, --project <slug>', 'limit to the browsers declared by a project')
81
81
  .action(async (opts, cmd) => {
82
82
  const ctx = createContext(cmd);
@@ -4,7 +4,7 @@ import { heading, json, out, table } from '../ui.js';
4
4
  export function register(program) {
5
5
  program
6
6
  .command('coverage')
7
- .description('Route, endpoint and role coverage by scenarios, split by suite tag')
7
+ .description('Route, endpoint and role coverage by scenarios, split by suite tag; exits 1 if any module has no scenarios')
8
8
  .requiredOption('-p, --project <slug>', 'project slug')
9
9
  .option('-e, --env <name>', 'environment (for the OpenAPI spec and API base URL)')
10
10
  .option('--openapi [file]', 'include endpoints from the OpenAPI spec (env api.openapi or a file)')
@@ -1,4 +1,5 @@
1
- import { relative } from 'node:path';
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
2
3
  import pc from 'picocolors';
3
4
  import { SdodsError } from '@sdods/core';
4
5
  import { createContext } from '../context.js';
@@ -73,6 +74,10 @@ export function register(program) {
73
74
  browser: opts.browser.length ? opts.browser : ['chromium'],
74
75
  harUpdate: true,
75
76
  }), cmd);
77
+ // Playwright writes the browser HAR itself, verbatim, including the Cookie, Set-Cookie and
78
+ // Authorization headers of the application under test. HARs are meant to be committed, so
79
+ // strip the credentials before anyone can commit a live session.
80
+ await scrubRecordedHars(ctx, opts.project, opts.env);
76
81
  });
77
82
  har
78
83
  .command('replay')
@@ -121,4 +126,33 @@ export function register(program) {
121
126
  table(rows, ['env', 'name', 'tag', 'size', 'api', 'glob', 'recorded', 'scenarios']);
122
127
  });
123
128
  }
129
+ /**
130
+ * Replace credential values in every HAR under the project's har/<env> directory after a
131
+ * recording run. Reports what it touched: a silent rewrite of a file the user is about to commit
132
+ * would be worse than the leak it prevents.
133
+ */
134
+ async function scrubRecordedHars(ctx, project, env) {
135
+ if (!project)
136
+ return;
137
+ const { scrubHarFile } = await import('@sdods/core/har');
138
+ const entry = ctx.registry.entriesList().find((e) => e.slug === project);
139
+ if (!entry)
140
+ return;
141
+ const dir = join(entry.root, 'har', env ?? '');
142
+ if (!existsSync(dir))
143
+ return;
144
+ let files = 0;
145
+ let values = 0;
146
+ for (const name of readdirSync(dir)) {
147
+ if (!name.endsWith('.har'))
148
+ continue;
149
+ const n = scrubHarFile(join(dir, name));
150
+ if (n > 0) {
151
+ files++;
152
+ values += n;
153
+ }
154
+ }
155
+ if (files > 0)
156
+ out(pc.dim(`scrubbed ${values} credential value(s) from ${files} HAR file(s)`));
157
+ }
124
158
  //# sourceMappingURL=har.js.map
@@ -86,21 +86,28 @@ export function register(program) {
86
86
  tokens
87
87
  .command('revoke <id>')
88
88
  .description('Revoke a token by id')
89
- .action(async (id, cmd) => {
89
+ .action(async (id, _opts, cmd) => {
90
90
  const ctx = createContext(cmd);
91
91
  const db = await import('@sdods/db');
92
92
  const adb = await db.openDb();
93
93
  try {
94
- await db.revokeApiToken(adb.db, id);
95
- await db.audit(adb.db, {
96
- actorType: 'cli',
97
- action: 'token.revoke',
98
- targetType: 'api_token',
99
- targetId: id,
100
- });
94
+ // Reporting success for an id that does not exist reads as "the token is gone" when
95
+ // nothing was checked at all — an operator revoking a leaked token needs the difference.
96
+ const revoked = await db.revokeApiToken(adb.db, id);
97
+ if (revoked) {
98
+ await db.audit(adb.db, {
99
+ actorType: 'cli',
100
+ action: 'token.revoke',
101
+ targetType: 'api_token',
102
+ targetId: id,
103
+ });
104
+ }
101
105
  if (ctx.opts.json)
102
- return json({ id, revoked: true });
103
- ok(`Revoked ${id}`);
106
+ return json({ id, revoked });
107
+ if (revoked)
108
+ ok(`Revoked ${id}`);
109
+ else
110
+ warn(`No live token with id ${id}; nothing to revoke.`);
104
111
  }
105
112
  finally {
106
113
  await adb.close();
@@ -1,4 +1,5 @@
1
1
  import { SdodsError } from '@sdods/core';
2
+ import { MIN_PASSWORD_LENGTH } from '@sdods/contracts/names';
2
3
  import { createContext } from '../context.js';
3
4
  import { json, ok, table } from '../ui.js';
4
5
  async function openDb() {
@@ -13,7 +14,7 @@ export function register(program) {
13
14
  .command('create')
14
15
  .description('Create a user; --admin makes a platform admin and organization owner')
15
16
  .requiredOption('--username <name>', 'login name')
16
- .requiredOption('--password <password>', 'password (min 8 chars)')
17
+ .requiredOption('--password <password>', `password (min ${MIN_PASSWORD_LENGTH} chars)`)
17
18
  .option('--admin', 'platform admin + owner of every organization without an owner')
18
19
  .option('--role <role>', 'viewer | editor | admin', 'viewer')
19
20
  .option('--email <email>', 'email')
@@ -88,7 +89,7 @@ export function register(program) {
88
89
  users
89
90
  .command('set-role <username> <role>')
90
91
  .description('Change a platform role (viewer | editor | admin)')
91
- .action(async (username, role, cmd) => {
92
+ .action(async (username, role, _opts, cmd) => {
92
93
  const ctx = createContext(cmd);
93
94
  const adb = await openDb();
94
95
  try {
@@ -112,7 +113,7 @@ export function register(program) {
112
113
  users
113
114
  .command('deactivate <username>')
114
115
  .description('Deactivate a user (sessions and tokens stop working)')
115
- .action(async (username, cmd) => {
116
+ .action(async (username, _opts, cmd) => {
116
117
  const ctx = createContext(cmd);
117
118
  const adb = await openDb();
118
119
  try {
package/dist/context.js CHANGED
@@ -1,5 +1,12 @@
1
1
  import { ProjectRegistry, setLogJson, setLogLevel } from '@sdods/core';
2
2
  export function globalOptions(cmd) {
3
+ // Commander calls an action with (...positionalArgs, options, command). A callback that forgets
4
+ // the options parameter receives the options object here instead of the Command, and the failure
5
+ // surfaced as an unhelpful "root.opts is not a function".
6
+ if (typeof cmd?.opts !== 'function' || !('parent' in cmd)) {
7
+ throw new Error('createContext expected the Commander Command. An action callback takes ' +
8
+ '(...args, options, command) — the options parameter is probably missing.');
9
+ }
3
10
  let root = cmd;
4
11
  while (root.parent)
5
12
  root = root.parent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdods/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "SDODS command line: run, record, analyze, agents, MCP server, scheduler and the web server.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SDODS <admin@sdods.com>",
@@ -23,8 +23,8 @@
23
23
  "node": ">=22"
24
24
  },
25
25
  "dependencies": {
26
- "@sdods/contracts": "0.2.0",
27
- "@sdods/core": "0.2.0",
26
+ "@sdods/contracts": "0.2.2",
27
+ "@sdods/core": "0.2.2",
28
28
  "commander": "^15.0.0",
29
29
  "execa": "^10.0.1",
30
30
  "picocolors": "^1.1.1",
@@ -33,12 +33,12 @@
33
33
  "zod": "^4.5.4",
34
34
  "@playwright/test": "^1.62.1",
35
35
  "playwright-core": "^1.62.1",
36
- "@sdods/integrations": "0.2.0",
37
- "@sdods/mcp": "0.2.0",
38
- "@sdods/agents": "0.2.0",
39
- "@sdods/db": "0.2.0",
36
+ "@sdods/integrations": "0.2.2",
37
+ "@sdods/mcp": "0.2.2",
38
+ "@sdods/agents": "0.2.2",
39
+ "@sdods/db": "0.2.2",
40
40
  "csv-parse": "^7.0.2",
41
- "@sdods/server": "0.2.0"
41
+ "@sdods/server": "0.2.2"
42
42
  },
43
43
  "homepage": "https://sdods.com",
44
44
  "bugs": {
@@ -0,0 +1,238 @@
1
+ ---
2
+ name: code-signing
3
+ description: Obtain and wire code-signing certificates for the SDODS desktop app so macOS Gatekeeper and Windows SmartScreen stop blocking it. Use when asked about signing, notarization, Gatekeeper, SmartScreen, "app is damaged", "unidentified developer", Developer ID certificates, Azure Artifact Signing, or why the installers show security warnings.
4
+ ---
5
+
6
+ # Signing the SDODS desktop installers
7
+
8
+ Unsigned installers work, but every user is told not to run them. macOS refuses a double-click
9
+ ("SDODS is damaged" or "unidentified developer") and Windows SmartScreen interrupts the install.
10
+ That undercuts the one-click promise more than any technical problem in the app.
11
+
12
+ **Everything in the build is already wired.** Supply the credentials as CI secrets and signing turns
13
+ on with no code change. What cannot be automated is acquiring the certificates: both require a
14
+ person's legal identity and a payment.
15
+
16
+ Facts below were verified against Microsoft and Apple documentation in September 2026. Two pieces
17
+ of widely repeated advice are now **wrong** — see the corrections at the end.
18
+
19
+ ---
20
+
21
+ ## macOS — Gatekeeper
22
+
23
+ ### What to buy
24
+
25
+ **Apple Developer Program, $99/year.** There is no free path that satisfies Gatekeeper. Enrol at
26
+ <https://developer.apple.com/programs/>. Individual enrolment needs a legal name and payment;
27
+ organisation enrolment additionally needs a D-U-N-S number and takes longer.
28
+
29
+ ### What to create
30
+
31
+ A **Developer ID Application** certificate — *not* "Apple Distribution", which is for the App
32
+ Store and will not satisfy Gatekeeper for direct download.
33
+
34
+ 1. Xcode → Settings → Accounts → Manage Certificates → **+** → Developer ID Application.
35
+ (Or Certificates, Identifiers & Profiles on the developer portal with a CSR.)
36
+ 2. Export it from Keychain Access as a `.p12` with a strong password.
37
+ 3. Base64 it for CI: `base64 -i cert.p12 | pbcopy`.
38
+
39
+ ### Notarization credentials
40
+
41
+ Notarization is a separate step: Apple scans the signed app and issues a ticket. It needs an
42
+ **app-specific password**, not your Apple ID password — create one at <https://appleid.apple.com>
43
+ under Sign-In and Security. You also need your **Team ID** (developer portal → Membership).
44
+
45
+ Locally, store credentials in the keychain once so the secret never reaches an env var or a log:
46
+
47
+ ```bash
48
+ xcrun notarytool store-credentials sdods-notary \
49
+ --apple-id you@example.com --team-id ABCDE12345 --password <app-specific-password>
50
+ ```
51
+
52
+ ### CI secrets
53
+
54
+ | Secret | Value |
55
+ |---|---|
56
+ | `MAC_CSC_LINK` | base64 of the `.p12` |
57
+ | `MAC_CSC_KEY_PASSWORD` | the `.p12` password |
58
+ | `APPLE_ID` | the Apple ID email |
59
+ | `APPLE_APP_SPECIFIC_PASSWORD` | app-specific password |
60
+ | `APPLE_TEAM_ID` | 10-character Team ID |
61
+
62
+ `.github/workflows/desktop.yml` already passes all five. electron-builder signs and notarizes when
63
+ they are present and silently skips when they are not, so unsigned builds keep working.
64
+
65
+ ### What is already correct in this repo
66
+
67
+ - `hardenedRuntime: true` — required for notarization.
68
+ - `entitlements` **and** `entitlementsInherit` both point at `build/entitlements.mac.plist`.
69
+ - That plist sets `com.apple.security.cs.disable-library-validation`. **Do not remove it.** The app
70
+ runs `.node` native modules that npm downloads at runtime; to the hardened runtime those are
71
+ unsigned code, so without this the app notarizes successfully and then dies at first database
72
+ open with an opaque dyld error.
73
+ - `mac.binaries` lists `Contents/Resources/node/bin/node`, so the bundled Node runtime is signed
74
+ too. A second executable inside the bundle is not signed automatically.
75
+
76
+ ### Timing
77
+
78
+ `notarytool` typically returns in 2–10 minutes for a 100–200 MB bundle, ~15 at the 95th percentile.
79
+ Around major macOS releases it can take 30–60. Budget for it in the release process; it is not a
80
+ sign that something is wrong.
81
+
82
+ ### Verifying
83
+
84
+ ```bash
85
+ codesign --verify --deep --strict --verbose=2 /Applications/SDODS.app
86
+ spctl -a -vvv -t install /Applications/SDODS.app # expect "accepted / Notarized Developer ID"
87
+ xcrun stapler validate /Applications/SDODS.app
88
+ ```
89
+
90
+ ### Until certificates exist
91
+
92
+ Users can bypass Gatekeeper themselves — right-click SDODS in Applications → **Open** → confirm.
93
+ macOS remembers the choice. The download page prints this automatically while
94
+ `DESKTOP_RELEASE.signed` is false. `xattr -dr com.apple.quarantine /Applications/SDODS.app` also
95
+ works but is worse advice to give strangers.
96
+
97
+ ---
98
+
99
+ ## Windows — SmartScreen
100
+
101
+ ### The gating question: where are you?
102
+
103
+ **Azure Artifact Signing** (formerly Azure Trusted Signing) is the best option, but it is
104
+ geographically restricted:
105
+
106
+ - **Individual developers: USA and Canada only.**
107
+ - Organisations: USA, Canada, EU, UK.
108
+
109
+ If you are an individual outside the US/Canada, this route is closed and an OV certificate is the
110
+ answer. Settle this before spending time on Azure — it determines the whole path.
111
+
112
+ ### Option A — Azure Artifact Signing (preferred where available)
113
+
114
+ Generally available since April 2026. ~**$9.99/month** for 5,000 signatures and one certificate
115
+ profile ($99.99/month for 100,000 and ten profiles) — cheaper than any traditional certificate,
116
+ and **no hardware token**, which is what makes it work in CI at all.
117
+
118
+ Individuals may now apply as self-employed; the 3-years-of-history requirement from the preview
119
+ was dropped at GA. Identity validation runs through a third party (au10tix) and takes a few
120
+ business days.
121
+
122
+ Setup:
123
+
124
+ 1. Azure subscription → create an **Artifact Signing** account (pick the region nearest your CI).
125
+ 2. Complete identity validation. Assign yourself the **Identity Verifier** role — validation
126
+ cannot be completed without it, which is the usual place people get stuck.
127
+ 3. Create a **certificate profile** (type: Public Trust).
128
+ 4. Create a service principal for CI and grant it **Code Signing Certificate Profile Signer** on
129
+ the account.
130
+
131
+ Then add to `apps/desktop/electron-builder.yml` under `win:`:
132
+
133
+ ```yaml
134
+ win:
135
+ azureSignOptions:
136
+ publisherName: '<exact name on the certificate>'
137
+ endpoint: 'https://<region>.codesigning.azure.net/'
138
+ codeSigningAccountName: '<account>'
139
+ certificateProfileName: '<profile>'
140
+ ```
141
+
142
+ and set `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` as CI secrets.
143
+
144
+ **Version note:** the block above is electron-builder v26 syntax, which is what this repo pins
145
+ (26.15.3, the current release). v27 collapses Windows signing into a single `win.sign`
146
+ discriminated union (`type: 'signtool' | 'hsm' | 'pkcs11' | 'azure'`) and removes
147
+ `win.azureSignOptions` / `win.signtoolOptions`; `electron-builder migrate-schema` rewrites it.
148
+ Check which major version is installed before copying config from a blog post.
149
+
150
+ ### Option B — OV certificate
151
+
152
+ From DigiCert, Sectigo, GlobalSign and similar. **$150–300/year.**
153
+
154
+ Since June 2023 the CA/Browser Forum requires the private key to live on an HSM or hardware token.
155
+ That is the real cost: a USB token cannot be plugged into a GitHub-hosted runner, so you either use
156
+ the CA's cloud HSM option or sign on a self-hosted runner. Choose a cloud-HSM product if you want
157
+ CI signing at all.
158
+
159
+ Wire it through the existing `WIN_CSC_LINK` / `WIN_CSC_KEY_PASSWORD` secrets, or the CA's own
160
+ signing tool via a custom `sign` hook.
161
+
162
+ ### Option C — self-signed
163
+
164
+ Testing and enterprise-managed fleets only. Windows does not trust it, so public users get a
165
+ **stronger** block than with no signature at all. Never ship this publicly.
166
+
167
+ ### Reputation
168
+
169
+ Signing does not remove SmartScreen warnings on day one. Reputation accrues to the publisher
170
+ identity as releases are downloaded and run without incident. What matters is signing **every**
171
+ release with the **same** identity — switching certificates resets the accumulated trust.
172
+
173
+ ---
174
+
175
+ ## Linux
176
+
177
+ No signing is required: AppImage and `.deb` install without any equivalent of Gatekeeper. Optional
178
+ hardening if it becomes useful:
179
+
180
+ - GPG-sign the `.deb` and publish the public key.
181
+ - Ship a `.zsync` file beside the AppImage for delta updates.
182
+
183
+ `SHA256SUMS.txt` already ships with every release, which is the verification most Linux users
184
+ expect.
185
+
186
+ ---
187
+
188
+ ## Two corrections to common advice
189
+
190
+ **EV certificates no longer bypass SmartScreen.** They did — instantly, on first download — which
191
+ is why almost every older guide recommends paying the EV premium for a new app. **Microsoft removed
192
+ that behaviour in 2024.** EV-signed files now build reputation exactly like OV-signed ones. An
193
+ existing EV certificate is still perfectly valid; buying one *specifically* to skip SmartScreen is
194
+ no longer justified. (I gave this outdated advice earlier in this project.)
195
+
196
+ **"Azure Trusted Signing" is now "Azure Artifact Signing."** Same service, renamed. Search results
197
+ and documentation are split across both names, and the individual-developer eligibility rules
198
+ changed at GA — preview-era pages saying individuals cannot sign up are out of date.
199
+
200
+ ---
201
+
202
+ ## Is there a free option?
203
+
204
+ **Windows: yes, but only for open source.** [SignPath Foundation](https://signpath.org/terms)
205
+ gives qualifying projects free OV-level signing through a managed pipeline. Their conditions:
206
+
207
+ - an **OSI-approved licence with no commercial dual-licensing** — Apache-2.0 qualifies;
208
+ - **no proprietary or non-open-source components**, including code from the maintainer;
209
+ - actively maintained, already released in the form to be signed, and functionality described on
210
+ the download page.
211
+
212
+ SDODS is Apache-2.0, so the licence is fine — but the repository is **private**, and the programme
213
+ is for open-source projects. Today it does not qualify. Making the source public would unlock it,
214
+ and would also remove the need for the separate public releases repo and let the
215
+ `NEXT_PUBLIC_REPO_PUBLIC` flags across both sites switch on. Applications take days to weeks.
216
+
217
+ **macOS: no.** There is no free path to a Developer ID certificate or to notarization. A free Apple
218
+ ID signs for local development only; the result still fails Gatekeeper on anyone else's Mac. The
219
+ $99/year membership is unavoidable for direct distribution.
220
+
221
+ **Linux: already free** — nothing to sign.
222
+
223
+ So the realistic floors are **$99/year** (Apple, plus SignPath for Windows if the source goes
224
+ public) or **~$219/year** (Apple plus Azure Artifact Signing at $9.99/month) with the source
225
+ staying private.
226
+
227
+ ---
228
+
229
+ ## After signing works
230
+
231
+ 1. Verify a real download on a machine that has never seen the app, not the build machine.
232
+ 2. Sync the download page with `--signed`, which removes the Gatekeeper/SmartScreen instructions:
233
+ ```bash
234
+ bun run desktop:sync-release desktop-v0.1.0 --signed
235
+ ```
236
+ 3. Enable `electron-updater` for the app shell. It was left off deliberately: Squirrel.Mac
237
+ **refuses to install an unsigned update**, so auto-update only becomes real once macOS signing
238
+ is in place. See the `sdods-desktop-release` skill.
@@ -0,0 +1,170 @@
1
+ ---
2
+ name: sdods-desktop-release
3
+ description: Build, test, release and publish the SDODS desktop app (apps/desktop) for macOS, Windows and Linux, and update the download page on sdods.com. Use when asked to build the desktop app, cut a desktop release, tag desktop-v*, publish installers, refresh the download page, or debug a packaged build that will not start.
4
+ ---
5
+
6
+ # Releasing the SDODS desktop app
7
+
8
+ The desktop app is an Electron **supervisor**: it owns a private SDODS workspace, installs
9
+ `@sdods/cli` from npm into it, and runs `sdods serve` as a child of a **bundled Node runtime**. It
10
+ does not reimplement SDODS and it does not host the server in-process.
11
+
12
+ Read this before changing anything in `apps/desktop` — most of it is here because something failed
13
+ in a way that produced no error message.
14
+
15
+ ## The loop
16
+
17
+ ```bash
18
+ # 1. Prove the runtime contract (no Electron involved). Catches npm/init/native-module problems.
19
+ bun run --cwd apps/desktop probe
20
+
21
+ # 2. Develop
22
+ bun run desktop:dev
23
+
24
+ # 3. Package for this machine
25
+ bun run --cwd apps/desktop dist:mac # dist:win · dist:linux
26
+
27
+ # 4. Release
28
+ git tag desktop-v0.1.0 && git push origin desktop-v0.1.0 # triggers .github/workflows/desktop.yml
29
+ # ... workflow drafts a release in the PUBLIC releases repo; publish it by hand ...
30
+ bun run desktop:sync-release desktop-v0.1.0 # writes apps/www/lib/desktop-release.ts
31
+ git commit -am 'chore(www): desktop 0.1.0 downloads' && merge to main # www workflow deploys
32
+ ```
33
+
34
+ **Order matters.** Sync the manifest only after the release is *published*, not while it is a
35
+ draft — the download page links straight at the asset URLs, and draft assets are not downloadable.
36
+ The sync script refuses to run against a draft for exactly this reason.
37
+
38
+ ## Where the binaries live, and why not in this repo
39
+
40
+ `siri1410/SDODS` is **private**, and **release assets on a private repo are private too** — a
41
+ GitHub download link would 404 for every visitor. So installers are published to a separate
42
+ **public** repository that holds nothing but releases:
43
+
44
+ ```bash
45
+ gh repo create siri1410/sdods-releases --public -d 'SDODS desktop installers'
46
+ ```
47
+
48
+ Then, on the source repo: a `DESKTOP_RELEASE_TOKEN` secret (a fine-grained PAT with
49
+ `Contents: read+write` on the releases repo **only** — `GITHUB_TOKEN` cannot write across
50
+ repositories), and optionally a `DESKTOP_RELEASE_REPO` variable to point somewhere else.
51
+
52
+ Source stays closed; only the built installers are public. This also keeps `electron-updater`
53
+ straightforward later, since it reads GitHub releases natively.
54
+
55
+ **Beware:** `git ls-remote https://github.com/siri1410/SDODS.git` **succeeds** on a machine with
56
+ `gh auth` configured, because its credential helper is global. That is not an anonymous probe and
57
+ it has produced the wrong conclusion here twice. Check visibility with
58
+ `gh api repos/<slug> --jq .private`.
59
+
60
+ ## Naming and versioning
61
+
62
+ - **SemVer** on `apps/desktop/package.json`; the tag is `desktop-v<version>`, kept separate from
63
+ the `@sdods/*` npm versions because the app ships on its own cadence.
64
+ - **Every artifact says what it is**: `<product>-<version>-<platform>-<arch>.<ext>` —
65
+ `SDODS-0.1.0-mac-arm64.dmg`, `SDODS-Setup-0.1.0-win-x64.exe`,
66
+ `SDODS-0.1.0-linux-x64.AppImage`. electron-builder's defaults omit the platform on macOS, so
67
+ `SDODS-0.1.0-arm64.dmg` could equally be a Linux build in a listing of six files.
68
+ - **`SHA256SUMS.txt`** ships with every release. It matters more than usual while builds are
69
+ unsigned: it is the only way a user can verify what they downloaded.
70
+ `sha256sum -c SHA256SUMS.txt --ignore-missing`
71
+
72
+ ## Verifying a build actually works
73
+
74
+ A packaged build that starts is not the same as one that works. The real test is a run started
75
+ from the app's own Runs page — that exercises `process.execPath`, the workspace layout and the
76
+ browser cache at once.
77
+
78
+ ```bash
79
+ # Install like a user, then launch with NOTHING on PATH. This is the zero-prerequisite promise.
80
+ env -i HOME="$HOME" USER="$USER" TMPDIR="$TMPDIR" PATH="/usr/bin:/bin:/usr/sbin:/sbin" \
81
+ SDODS_DESKTOP_WORKSPACE=/tmp/sdods-test \
82
+ /Applications/SDODS.app/Contents/MacOS/SDODS
83
+ ```
84
+
85
+ Then: Runs → Start run → layer `ui`, browser `chromium`, tags `@smoke` → Run. It should pass 4/0/0.
86
+
87
+ Useful env vars:
88
+
89
+ | Variable | Purpose |
90
+ |---|---|
91
+ | `SDODS_DESKTOP_WORKSPACE` | Override the `~/SDODS` default. Always set this when testing, or you litter the real home directory. |
92
+ | `SDODS_DESKTOP_NODE` | Point at a specific `node` binary instead of the staged/system one. |
93
+
94
+ Logs: `~/Library/Application Support/SDODS/logs/desktop.log` (menu → Open Logs Folder). App state
95
+ lives beside it; `rm -rf` that directory for a clean first-run test.
96
+
97
+ ## Traps — each one cost a debugging session
98
+
99
+ **Never spawn `process.execPath`.** Under Electron that is the Electron binary, so spawning it
100
+ launches a second copy of the app, which hits the single-instance lock and dies **silently**. Use
101
+ `nodeBin()` from `src/main/runtime.ts`. Same reason the server is a child process rather than
102
+ in-process: `packages/server/src/services/cli.ts` builds every child argv from `process.execPath`.
103
+
104
+ **Never spawn `npm` by bare name.** Windows has `npm.cmd`, not `npm`, and a GUI app launched from
105
+ the Dock inherits a minimal PATH. Use `npmCli()`, which resolves npm's own entry point.
106
+
107
+ **A missing `extraResources` source is only a warning.** It once produced an x64 `.dmg` with no
108
+ Node runtime and a zero exit code. `scripts/before-pack.mjs` stages the runtime per arch and fails
109
+ hard; `desktop.yml` re-checks every packaged app. Do not remove either.
110
+
111
+ **Signals do not fire in the packaged app.** A SIGTERM runs none of `before-quit`, `will-quit`,
112
+ `exit`, or `process.on('SIGTERM')` — Electron terminates natively — so the detached server child
113
+ outlives the app. The guarantee is the pidfile: the server's pid is recorded and the next launch
114
+ reaps it. Test it with `kill -9` on the app, not `kill`.
115
+
116
+ **Browsers are needed for every layer, not just UI.** SDODS merges one BDD fixture set across
117
+ layers, so an api-layer run against an empty `PLAYWRIGHT_BROWSERS_PATH` fails with
118
+ `browserType.launch: Executable doesn't exist`. Chromium is fetched after the dashboard loads.
119
+
120
+ **`sdods init` needs `--force --no-install --no-browsers`.** It refuses a non-empty directory,
121
+ its `--pm` accepts only `bun|pnpm` (neither is on a user machine), and it would pull browsers. It
122
+ overwrites `package.json` on purpose — its manifest declares `@playwright/test` and
123
+ `playwright-bdd`, which `sdods run` needs — so npm install runs again afterwards.
124
+
125
+ **`@sdods/server@0.2.1` hardcodes `cliBin: resolve(rootDir, 'packages/cli/src/bin.ts')`**, a
126
+ monorepo-only path, so UI-triggered runs die with ERR_MODULE_NOT_FOUND. `bootstrap.ts` writes a
127
+ bridge at that path, but only while the installed server still contains that string. **Publishing a
128
+ server newer than 0.2.1 removes the need for it** and fixes `sdods serve` for every npm user.
129
+
130
+ **Do not build the macOS `universal` target.** It lipo-merges two packs that each want a different
131
+ `node` binary at one path. `before-pack.mjs` rejects it. Ship separate arm64 and x64 artifacts.
132
+
133
+ **`apps/desktop` must declare no production dependencies.** electron-builder's dependency collector
134
+ has no handling for bun's `node_modules/.bun` symlink layout. Everything is bundled by rollup and
135
+ `files` excludes `node_modules` outright. Adding a runtime dependency will break packaging.
136
+
137
+ ## Browser architecture detection on the download page
138
+
139
+ An Apple silicon Mac reports `Intel Mac OS X` in its user agent. Parsing the UA alone recommends
140
+ the Intel build to nearly every modern Mac. `detectArch()` uses
141
+ `navigator.userAgentData.getHighEntropyValues(['architecture'])`, which is truthful on Chromium;
142
+ Safari and Firefox return nothing, so macOS defaults to Apple silicon deliberately. Every other
143
+ build is listed underneath, and the full list is server-rendered so no-JS visitors lose nothing.
144
+
145
+ ## Signing
146
+
147
+ Builds are unsigned today, so macOS shows "damaged / unidentified developer" and Windows shows
148
+ SmartScreen. The download page prints the per-OS workaround automatically while
149
+ `DESKTOP_RELEASE.signed` is false.
150
+
151
+ Everything is wired behind CI secrets already — supply them and signing turns on with no code
152
+ change: `CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`
153
+ for macOS; `WIN_CSC_LINK`, `WIN_CSC_KEY_PASSWORD` for Windows. Obtaining the certificates requires
154
+ a person: Apple Developer Program ($99/yr), and for Windows **Azure Trusted Signing** (~$10/month)
155
+ rather than a traditional OV certificate, which has required an FIPS hardware token since June 2023
156
+ and does not fit CI. After signing lands, pass `--signed` to `desktop:sync-release`.
157
+
158
+ ## Files
159
+
160
+ | Path | What it is |
161
+ |---|---|
162
+ | `apps/desktop/src/main/index.ts` | Lifecycle: bootstrap → serve → authenticate → load. Pidfile and orphan reaping. |
163
+ | `apps/desktop/src/main/runtime.ts` | Bundled Node resolution and the child environment. |
164
+ | `apps/desktop/src/main/bootstrap.ts` | The five-step first-run install, and the 0.2.1 CLI bridge. |
165
+ | `apps/desktop/src/main/server.ts` | Port choice, `sdods serve` child, health poll, setup-token capture. |
166
+ | `apps/desktop/src/main/auth.ts` | Admin creation, safeStorage vault, cookie injection. |
167
+ | `apps/desktop/scripts/probe.ts` | The runtime contract, provable without Electron. |
168
+ | `apps/desktop/scripts/fetch-node-runtime.ts` | Downloads + SHASUMS-verifies + prunes Node. |
169
+ | `scripts/sync-desktop-release.ts` | GitHub release → `apps/www/lib/desktop-release.ts`. |
170
+ | `.github/workflows/desktop.yml` | Tag-gated matrix build, artifact verification, draft release. |