@sdods/cli 0.2.1 → 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.
@@ -6,6 +6,8 @@ export interface InitFromAppOptions {
6
6
  slug?: string;
7
7
  name?: string;
8
8
  openapi?: string;
9
+ maxFiles?: number;
10
+ maxDepth?: number;
9
11
  force?: boolean;
10
12
  importSpecs?: boolean;
11
13
  browsers?: string[];
@@ -8,7 +8,11 @@ import { projectTemplateFiles } from '../templates/project.js';
8
8
  /** Programmatic entry used by `sdods init --from <app>`: analyze → propose → apply. */
9
9
  export async function initFromApp(opts) {
10
10
  const { analyzeProject, proposeProject, applyProposal } = await import('@sdods/core/analyze');
11
- const report = analyzeProject(opts.appPath, { openapi: opts.openapi });
11
+ const report = analyzeProject(opts.appPath, {
12
+ openapi: opts.openapi,
13
+ maxFiles: opts.maxFiles,
14
+ maxDepth: opts.maxDepth,
15
+ });
12
16
  const proposal = proposeProject(report, {
13
17
  slug: opts.slug,
14
18
  name: opts.name,
@@ -52,12 +56,18 @@ export function register(program) {
52
56
  .option('--force', 'overwrite an existing project when applying')
53
57
  .option('--no-import-specs', 'do not copy existing Playwright specs into recorded/imported')
54
58
  .option('--report-only', 'print the analysis, skip the proposal')
59
+ .option('--max-files <n>', 'file budget for the scan (default 25000); raise it on a large monorepo', (v) => Number.parseInt(v, 10))
60
+ .option('--max-depth <n>', 'directory depth budget for the scan (default 12)', (v) => Number.parseInt(v, 10))
55
61
  .action(async (path, opts, cmd) => {
56
62
  const ctx = createContext(cmd);
57
63
  // the app path is relative to where the command was typed, not to --cwd (the SDODS repo)
58
64
  const appPath = resolvePath(process.cwd(), path ?? '.');
59
65
  const { analyzeProject, proposeProject, applyProposal } = await import('@sdods/core/analyze');
60
- const report = analyzeProject(appPath, { openapi: opts.openapi });
66
+ const report = analyzeProject(appPath, {
67
+ openapi: opts.openapi,
68
+ maxFiles: opts.maxFiles,
69
+ maxDepth: opts.maxDepth,
70
+ });
61
71
  const slug = opts.project ?? opts.slug;
62
72
  const proposal = opts.reportOnly
63
73
  ? undefined
@@ -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
@@ -13,6 +13,7 @@ export interface RunFlags {
13
13
  retries?: number;
14
14
  grep?: string;
15
15
  feature?: string;
16
+ since?: string;
16
17
  scenario?: string;
17
18
  runId?: string;
18
19
  artifactsDir?: string;
@@ -4,6 +4,7 @@ import { execa } from 'execa';
4
4
  import pc from 'picocolors';
5
5
  import { newRunId, runFiles, } from '@sdods/contracts';
6
6
  import { SdodsError, CLI_OVERRIDES_ENV, VERSION, formatFindings, lintProject, listGeneratedProjects, moduleByName, moduleDir, normalizeTagExpr, serializeCliOverrides, } from '@sdods/core';
7
+ import { analyzeChangeImpact } from '@sdods/mcp';
7
8
  import { createContext } from '../context.js';
8
9
  import { collect, json, out, parseIntFlag, warn } from '../ui.js';
9
10
  function addRunOptions(cmd) {
@@ -23,6 +24,7 @@ function addRunOptions(cmd) {
23
24
  .option('--retries <n>', 'retries per test', parseIntFlag('retries'))
24
25
  .option('--grep <pattern>', 'filter tests by title (regular expression)')
25
26
  .option('--feature <path>', 'only this feature file (relative to features/)')
27
+ .option('--since <range>', 'only features impacted by a git range, e.g. main..HEAD (test-impact analysis)')
26
28
  .option('--scenario <name>', 'only scenarios whose title contains this text')
27
29
  .option('--run-id <id>', 'run id (default: uuid v7)')
28
30
  .option('--artifacts-dir <dir>', 'artifacts root (default: .sdods/runs)')
@@ -249,9 +251,34 @@ export async function runCommand(flags, cmd) {
249
251
  const rel = relative(join(cfg.project.root, 'features'), moduleDir(cfg.project.root, moduleByName(projectCfg, m))).replace(/\\/g, '/');
250
252
  filters.push(`\\.sdods/generated/${escapeRe(runId)}/${escapeRe(entry.slug)}/[^/]+/${escapeRe(rel)}/`);
251
253
  }
254
+ const featureFilter = (rel) => escapeRe(rel.replace(/^features\//, '').replace(/\.feature$/, '')) + '\\.feature\\.spec';
252
255
  if (flags.feature)
253
- filters.push(escapeRe(flags.feature.replace(/^features\//, '').replace(/\.feature$/, '')) +
254
- '\\.feature\\.spec');
256
+ filters.push(featureFilter(flags.feature));
257
+ // --since <range>: run only what the change could have broken.
258
+ //
259
+ // `analyzeChangeImpact` has existed for a while and was reachable only
260
+ // through MCP, so it could advise a human and could not select a run. This is
261
+ // the seam that was missing; the mapping itself is unchanged.
262
+ if (flags.since) {
263
+ const impact = analyzeChangeImpact(cfg.project.root, ctx.rootDir, flags.since);
264
+ if (impact.impacted.length === 0) {
265
+ // Deliberately NOT "run everything" and deliberately not a silent empty
266
+ // run. A run that registered zero scenarios exits 0 and looks identical
267
+ // to a green run, which is the single most dangerous outcome a test
268
+ // runner has. Say so, in words, and stop.
269
+ out(pc.bold(`No features impacted by ${flags.since}.`));
270
+ out(` ${impact.changedFiles.length} changed file(s), none reaching a feature.`);
271
+ out(pc.dim(' Nothing was run. This is not a pass — re-run without --since to verify.'));
272
+ // Exit 0: selecting nothing is a correct outcome for this flag, not a
273
+ // failure. The wording above is what stops it being read as a pass.
274
+ return 0;
275
+ }
276
+ out(pc.bold(`--since ${flags.since}: ${impact.impacted.length} impacted feature(s)`));
277
+ for (const row of impact.impacted)
278
+ out(` ${row.feature} ${pc.dim(row.reasons.join('; '))}`);
279
+ for (const row of impact.impacted)
280
+ filters.push(featureFilter(row.feature));
281
+ }
255
282
  // positional filters must precede `--project` (variadic in the runner CLI)
256
283
  args.splice(4, 0, ...filters);
257
284
  if (flags.list) {
@@ -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')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdods/cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
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.1",
27
- "@sdods/core": "0.2.1",
26
+ "@sdods/contracts": "0.3.0",
27
+ "@sdods/core": "0.3.0",
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.1",
37
- "@sdods/mcp": "0.2.1",
38
- "@sdods/agents": "0.2.1",
39
- "@sdods/db": "0.2.1",
36
+ "@sdods/integrations": "0.3.0",
37
+ "@sdods/mcp": "0.3.0",
38
+ "@sdods/agents": "0.3.0",
39
+ "@sdods/db": "0.3.0",
40
40
  "csv-parse": "^7.0.2",
41
- "@sdods/server": "0.2.1"
41
+ "@sdods/server": "0.3.0"
42
42
  },
43
43
  "homepage": "https://sdods.com",
44
44
  "bugs": {
@@ -0,0 +1,185 @@
1
+ ---
2
+ name: sdods-release-channels
3
+ description: Publish SDODS through the package managers — Homebrew, Scoop, winget, npm, Docker/GHCR and the apt repository — and keep the install page honest about which of them actually work. Use when asked to add or fix a distribution channel, publish to a package manager, push the tap or bucket, submit the winget manifest, build the apt repo, or when a channel is missing from sdods.com/install.
4
+ ---
5
+
6
+ # Distribution channels
7
+
8
+ SDODS ships through six package managers on top of the two install scripts. They are **not one
9
+ release**: they publish on three schedules, from two repositories, and two of them are published by
10
+ someone who is not you.
11
+
12
+ | Channel | Publishes | Where the manifest lives | Who publishes it |
13
+ | --------- | ----------------- | ----------------------------- | ------------------------- |
14
+ | npm | the CLI | — (`release.yml`) | changesets, on `main` |
15
+ | Docker | the server image | — (`release.yml`) | `release.yml`, after npm |
16
+ | Homebrew | the CLI | `siri1410/homebrew-sdods` | you, `git push` |
17
+ | Scoop | the desktop app | `siri1410/scoop-sdods` | you, `git push` |
18
+ | winget | the desktop app | `microsoft/winget-pkgs` | **Microsoft's reviewers** |
19
+ | apt | the desktop app | Pages on `sdods-releases` | `apt.yml`, run by hand |
20
+
21
+ ## The one rule
22
+
23
+ **A channel goes live when its registry answers, not when its manifest is committed here.**
24
+
25
+ `bun run channels:sync` probes each one — npm's registry, GHCR's anonymous pull token, the tap and
26
+ bucket contents API, winget-pkgs, and the apt `InRelease` file — and writes the result into
27
+ `apps/www/lib/install-channels.ts`. The install page renders live channels only, so a tab whose
28
+ command would 404 cannot ship. Never flip a `live:` flag by hand; the flag is an observation.
29
+
30
+ ```bash
31
+ bun run channels:sync
32
+ ```
33
+
34
+ ```bash
35
+ bun run channels:sync -- --check # non-zero if the committed state has drifted
36
+ ```
37
+
38
+ Every SHA-256 in `packaging/` is computed from bytes the script downloaded. Do not transcribe one
39
+ from a release page — it is the single field where being wrong means the package manager refuses
40
+ to install at all, after every check here has passed.
41
+
42
+ ## The order things must happen in
43
+
44
+ Two channels install the CLI from npm, and three install the desktop app from a release. Neither
45
+ can be published before the thing it points at.
46
+
47
+ ```
48
+ changesets publishes @sdods/cli → release.yml builds the GHCR image → channels:sync
49
+ → Homebrew formula (npm tarball) → push the tap
50
+ desktop-v* tag → workflow drafts → a PERSON publishes the release → channels:sync
51
+ → Scoop, winget, apt manifests → push / submit / run apt.yml
52
+ ```
53
+
54
+ ## Per channel
55
+
56
+ ### npm — automatic
57
+
58
+ `release.yml` runs changesets on every push to `main`. `scripts/publish-npm.sh` prints a
59
+ `New tag: <pkg>@<version>` line per package, which is the **only** signal changesets/action reads
60
+ to set its `published` output. Do not remove it: without it a successful publish reads as "nothing
61
+ published" and the image job below never runs. That was the actual bug.
62
+
63
+ ### Docker — automatic, after npm
64
+
65
+ Gated on `needs.changesets.outputs.published`, not on a `v*` tag. It used to gate on the tag and
66
+ nothing in this repository creates one, so the job had never run and the image did not exist.
67
+ Do not "fix" it by pushing a tag from CI: a tag pushed with `GITHUB_TOKEN` does not trigger
68
+ workflows, so the job still would not run.
69
+
70
+ **The image is `linux/amd64` only, and Docker does not emulate a missing architecture** — it
71
+ refuses the pull with `no matching manifest for linux/arm64/v8`. Apple silicon needs
72
+ `--platform linux/amd64`. Building arm64 in CI was tried and abandoned: under QEMU on an amd64
73
+ runner the arm64 stage did not finish in 90 minutes, because `bun install`, the native module
74
+ builds and the web bundle all run emulated. Fixing it properly means either a native arm64 runner
75
+ (a paid tier for a private repository) or splitting the Dockerfile so the arch-independent web
76
+ bundle builds on `$BUILDPLATFORM`. `channels:sync` reads the architectures out of the manifest and
77
+ writes the note from them, so the page cannot claim an arch the image does not carry.
78
+
79
+ **GHCR packages default to private, and visibility is a UI-only setting.** After the first push:
80
+ Profile → Packages → `sdods-server` → Package settings → Change visibility → Public. Until then
81
+ `channels:sync` reports `no anonymous pull token (403)` and the Docker tab stays hidden.
82
+
83
+ ### Homebrew — you push the tap
84
+
85
+ ```bash
86
+ gh repo create siri1410/homebrew-sdods --public -d 'Homebrew tap for SDODS'
87
+ ```
88
+
89
+ Copy the rendered formula into the tap as `Formula/sdods.rb`, then verify it locally before anyone
90
+ installs from it. `--new` implies `--strict` and `--online` and is the check that catches a bad
91
+ `url`, a redundant `version` or a foreign-architecture binary (it is `--new`, not `--new-formula`,
92
+ which current Homebrew rejects):
93
+
94
+ ```bash
95
+ brew audit --strict --new siri1410/sdods/sdods
96
+ ```
97
+
98
+ ```bash
99
+ brew install --build-from-source siri1410/sdods/sdods && brew test sdods
100
+ ```
101
+
102
+ You can do all of that without publishing anything: `brew tap-new siri1410/sdods --no-git` makes a
103
+ local tap, and `brew untap siri1410/sdods` removes it.
104
+
105
+ The formula builds from the **npm tarball**, not from a clone, which is why the private source
106
+ repository is not a problem here.
107
+
108
+ Three things about it are load-bearing, and each was found by running it rather than reading it:
109
+
110
+ - **You cannot publish the formula the same day you publish the packages.** Homebrew's
111
+ `std_npm_args` passes `--min-release-age=`, so npm refuses any dependency published inside that
112
+ window: `No matching version found for @sdods/agents@X with a date before <date>`. The formula is
113
+ correct; it is too new. Wait for the packages to age out, then install.
114
+ - **Foreign prebuilds must be deleted.** `better-sqlite3` ships prebuilt binaries for every
115
+ platform and npm installs all of them; audit then fails with *"Binaries built for a non-native
116
+ architecture were installed into sdods's prefix"*. The `install` block removes every
117
+ `prebuilds/*` directory but this machine's.
118
+ - **`brew test` runs in an empty directory, so `sdods doctor` exits 1** — there are no projects to
119
+ find, which is the right answer for a fresh install. The test asserts exit `1` deliberately;
120
+ asserting `0` fails on a perfectly good install. It runs against Homebrew's `node`, which is
121
+ well ahead of 22 — that path is tested, and the native modules resolve on it.
122
+
123
+ ### Scoop — you push the bucket
124
+
125
+ ```bash
126
+ gh repo create siri1410/scoop-sdods --public -d 'Scoop bucket for SDODS'
127
+ ```
128
+
129
+ The manifest goes in as `bucket/sdods.json`. It installs the NSIS desktop installer silently with
130
+ `/S /D=$dir`; `/D` must be last and unquoted, which is an NSIS rule — quote it and the install
131
+ silently lands in the default directory instead of Scoop's.
132
+
133
+ ### winget — you can open the PR, not merge it
134
+
135
+ ```bash
136
+ wingetcreate submit --token <PAT> packaging/winget
137
+ ```
138
+
139
+ Then it sits in Microsoft's review queue. Expect friction: the installers are **unsigned**, and
140
+ that is a thing their validation flags. See the `code-signing` skill — winget is the channel that
141
+ most wants the certificate.
142
+
143
+ ### apt — a person runs the workflow
144
+
145
+ `.github/workflows/apt.yml`, triggered manually. It needs `SDODS_APT_GPG_KEY` (the armoured
146
+ private key of the **project** signing key, as a repository secret) and `DESKTOP_RELEASE_TOKEN`.
147
+ It refuses to build unsigned: an unsigned repo can only be added with `[trusted=yes]`, which
148
+ disables signature checking for everything else that machine installs.
149
+
150
+ The workflow proves the repo works before publishing it — it adds the built repo as a local apt
151
+ source and asserts `apt-cache policy sdods` reports an installable candidate. A repo that merely
152
+ looks right has been verified zero times. The `apt · flat repo layout` job in `ci.yml` runs the
153
+ same proof on every PR against a synthetic package, so the layout is exercised without a key.
154
+
155
+ **It is a standard `dists/` repository, not a flat one, and that is load-bearing.** A flat repo is
156
+ addressed with a `./` distribution, which puts a `./` segment into every path apt builds:
157
+
158
+ ```
159
+ https://sdods.com/apt/./InRelease
160
+ ```
161
+
162
+ Firebase Hosting answers that with a 302 to an internal origin host that 404s. The result is a
163
+ repository where every file is served correctly to `curl` on the normalised path, and every real
164
+ `apt update` fails with *"does not have a Release file"*. It shipped that way, and only an actual
165
+ `apt install` in a container found it — checking that the files were reachable was not the same
166
+ question.
167
+
168
+ `build-repo.sh` also scans the pool directory by name rather than `.`, because `apt-ftparchive
169
+ packages .` writes `Filename: ./pool/...` and that is the same broken segment one level down. And
170
+ it splits the index per architecture with awk rather than `apt-ftparchive --arch`, which matches
171
+ nothing here and silently writes an empty `Packages`.
172
+
173
+ **GitHub Pages has to be enabled on the releases repository** — Settings → Pages → source
174
+ `gh-pages` — or the workflow pushes the branch and the URL keeps 404ing.
175
+
176
+ ## Adding a seventh channel
177
+
178
+ 1. Add it to `INSTALL_CHANNELS` in `apps/www/lib/install-channels.ts` with `live: false`. Give it
179
+ `under:` if it belongs inside another tab's panel rather than owning a tab — Scoop and winget
180
+ sit under Windows because a visitor choosing how to install is choosing once, not three times.
181
+ 2. Add a probe in `scripts/sync-channels.ts` and wire it into the `live` map. A channel with no
182
+ probe can never go live, which is the correct failure.
183
+ 3. Put its manifest template in `packaging/<manager>/` with `{{PLACEHOLDER}}` fields.
184
+ 4. Add the command to the docs table in `getting-started/installation.mdx`.
185
+ 5. `bunx vitest run tests/install-channels.test.ts`.