@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.
- package/README.md +19 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/commands/browsers.js +1 -1
- package/dist/commands/coverage.js +1 -1
- package/dist/commands/har.js +35 -1
- package/dist/commands/tokens.js +17 -10
- package/dist/commands/users.js +4 -3
- package/dist/context.js +7 -0
- package/package.json +8 -8
- package/templates/.claude/skills/code-signing/SKILL.md +238 -0
- package/templates/.claude/skills/sdods-desktop-release/SKILL.md +170 -0
- package/templates/projects/demo-shop/har/staging/cart.har +2787 -1
- package/templates/projects/demo-shop/har/staging/login.har +2787 -1
- package/templates/projects/demo-shop/har/staging/products.har +2787 -1
|
@@ -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)')
|
package/dist/commands/har.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
package/dist/commands/tokens.js
CHANGED
|
@@ -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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
103
|
-
|
|
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();
|
package/dist/commands/users.js
CHANGED
|
@@ -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>',
|
|
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.
|
|
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.
|
|
27
|
-
"@sdods/core": "0.2.
|
|
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.
|
|
37
|
-
"@sdods/mcp": "0.2.
|
|
38
|
-
"@sdods/agents": "0.2.
|
|
39
|
-
"@sdods/db": "0.2.
|
|
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.
|
|
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. |
|