@sdods/cli 0.9.0 → 0.10.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.
@@ -75,7 +75,7 @@ export async function browserStatuses(names = DOWNLOADED_BROWSERS) {
75
75
  return names.map((name) => {
76
76
  const engine = ENGINE_OF[name] ?? 'chromium';
77
77
  const channel = CHANNEL_OF[name] ?? null;
78
- let executable = null;
78
+ let executable;
79
79
  try {
80
80
  executable = channel ? channelExecutable(channel) : pw[engine].executablePath();
81
81
  }
@@ -140,12 +140,12 @@ export async function initWorkspace(target, flags) {
140
140
  // 1.63 is the floor, not a preference: below it an npm-installed workspace records every
141
141
  // Gherkin step SKIPPED in messages.ndjson (see runner-compat.ts).
142
142
  '@playwright/test': flags.link && flags.pm === 'bun' ? 'link:@playwright/test' : '^1.63.0',
143
- 'playwright-bdd': flags.link && flags.pm === 'bun' ? 'link:playwright-bdd' : '^9.2.0',
143
+ 'playwright-bdd': flags.link && flags.pm === 'bun' ? 'link:playwright-bdd' : '^9.2.1',
144
144
  },
145
145
  devDependencies: {
146
- '@types/node': '^22.15.0',
147
- typescript: '~5.9.3',
148
- tsx: '^4.23.0',
146
+ '@types/node': '^22.20.2',
147
+ typescript: '~6.0.3',
148
+ tsx: '^4.23.13',
149
149
  },
150
150
  trustedDependencies: [
151
151
  '@playwright/test',
@@ -1,10 +1,12 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
2
- import { dirname, extname, join, resolve } from 'node:path';
1
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { basename, dirname, extname, join, resolve } from 'node:path';
3
4
  import { execa } from 'execa';
4
5
  import pc from 'picocolors';
5
- import { runFiles } from '@sdods/contracts';
6
- import { SdodsError } from '@sdods/core';
6
+ import { legacyRunFiles, runFiles, } from '@sdods/contracts';
7
+ import { SdodsError, collectGateEvidence, evaluateGates, gateReportsFromRunnerJson, gateTotalsFromRunnerStats, hasGates, ingestGateJudge, } from '@sdods/core';
7
8
  import { createContext } from '../context.js';
9
+ import { gateFailedError, printGates } from '../gates.js';
8
10
  import { json, ok, out, table, warn } from '../ui.js';
9
11
  function artifactsRoot(rootDir) {
10
12
  return resolve(rootDir, process.env.SDODS_ARTIFACTS_DIR ?? '.sdods/runs');
@@ -35,6 +37,82 @@ async function openPath(p) {
35
37
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
36
38
  await execa(cmd, [p], { shell: process.platform === 'win32' }).catch(() => undefined);
37
39
  }
40
+ /**
41
+ * The process `report merge` judges: `--process` (with `-p`, or the project in a run.json), or the
42
+ * process a run.json names when it declares gates. `undefined` when there is nothing to judge.
43
+ */
44
+ function resolveMergeProcess(ctx, input) {
45
+ const manifest = input.manifestDirs
46
+ .map((d) => readJsonFile(join(d, runFiles.manifest)))
47
+ .find(Boolean);
48
+ const name = input.process ?? manifest?.process;
49
+ if (!name)
50
+ return undefined;
51
+ const slug = input.project ?? manifest?.projectSlug;
52
+ if (!slug)
53
+ throw new SdodsError('CONFIG_INVALID', `--process ${name} needs the project that defines it, and no run.json names one.`, {
54
+ hint: `Pass -p <slug>: sdods report merge --process ${name} -p <slug> <dirs...>`,
55
+ exitCode: 2,
56
+ });
57
+ let proc;
58
+ try {
59
+ proc = ctx.registry.processOf(slug, name);
60
+ }
61
+ catch (e) {
62
+ // Named explicitly, an unknown process is an error. Inferred from a run.json this checkout
63
+ // cannot resolve, it only means there is nothing here to judge it by; the merge still runs.
64
+ if (input.process)
65
+ throw e;
66
+ warn(`run.json names process "${name}" of ${slug}, which this checkout does not define: gates not judged.`);
67
+ return undefined;
68
+ }
69
+ if (!hasGates(proc.gates)) {
70
+ // Named explicitly, a process without gates is a mistake worth saying; inferred, it is not.
71
+ if (input.process)
72
+ warn(`process "${name}" of ${slug} declares no gates, so there is nothing to judge.`);
73
+ return undefined;
74
+ }
75
+ return { slug, process: proc };
76
+ }
77
+ /**
78
+ * A merge workspace for `playwright merge-reports`: the blob reports of every directory copied into
79
+ * one (it reads exactly one directory; prefixed, because two runs' shard 1 carry the same file
80
+ * name), and a merge config that pins the root the report paths are relative to. Blobs recorded
81
+ * under different checkouts, such as CI's Playwright container (`/__w/…`) and its bare runner
82
+ * (`/home/runner/work/…`), otherwise refuse to merge ("recorded with different test directories").
83
+ */
84
+ function mergeWorkspace(dirs, rootDir) {
85
+ const dir = mkdtempSync(join(tmpdir(), 'sdods-merge-'));
86
+ const reports = join(dir, 'reports');
87
+ mkdirSync(reports);
88
+ dirs.forEach((from, i) => {
89
+ for (const name of readdirSync(from))
90
+ if (name.endsWith('.zip'))
91
+ copyFileSync(join(from, name), join(reports, `${i + 1}-${name}`));
92
+ });
93
+ const config = join(dir, 'merge.config.mjs');
94
+ writeFileSync(config, `export default ${JSON.stringify({ testDir: rootDir })};\n`);
95
+ return { reports, config, dir };
96
+ }
97
+ /** The error Playwright printed, rather than the last lines of its stack. */
98
+ function mergeFailureHint(stderr) {
99
+ const lines = (stderr ?? '').split('\n');
100
+ const at = lines.findIndex((l) => /^\s*Error\b/.test(l));
101
+ const picked = at >= 0 ? lines.slice(at, at + 12).filter((l) => !/^\s+at /.test(l)) : lines.slice(-3);
102
+ return picked.join(' ').replace(/\s+/g, ' ').trim() || undefined;
103
+ }
104
+ /** Writes the merged verdict where `sdods run --process` writes one, so ingest records it the same way. */
105
+ function recordMergedGates(runDir, gates) {
106
+ writeFileSync(join(runDir, runFiles.gates), JSON.stringify(gates, null, 2));
107
+ const manifestFile = join(runDir, runFiles.manifest);
108
+ const manifest = readJsonFile(manifestFile);
109
+ if (manifest)
110
+ writeFileSync(manifestFile, JSON.stringify({ ...manifest, gatesPassed: gates.passed }, null, 2));
111
+ const summaryFile = join(runDir, runFiles.summary);
112
+ const summary = readJsonFile(summaryFile);
113
+ if (summary)
114
+ writeFileSync(summaryFile, JSON.stringify({ ...summary, gates }, null, 2));
115
+ }
38
116
  export function register(program) {
39
117
  const report = program
40
118
  .command('report')
@@ -172,12 +250,25 @@ export function register(program) {
172
250
  artifactsRoot: root,
173
251
  replace: Boolean(opts.replace),
174
252
  env: opts.env,
253
+ // A run with no gates.json whose process declares gates is judged here, as the run would have been.
254
+ gates: ingestGateJudge({
255
+ manifest: manifest ?? m.readManifest(join(root, opts.runId)),
256
+ runDir: join(root, opts.runId),
257
+ processOf: (slug, name) => ctx.registry.processOf(slug, name),
258
+ }),
175
259
  });
176
260
  if (ctx.opts.json)
177
261
  return json(res);
178
262
  ok(`Ingested run ${res.runId} (${res.projectSlug}): ${res.scenarios} scenario(s), ${res.attempts} attempt(s), ${res.steps} step(s), ${res.artifacts} artifact(s), ${res.healEvents} heal event(s)`);
179
263
  const t = res.totals;
180
264
  out(`${pc.green(`${t.passed} passed`)} ${pc.red(`${t.failed} failed`)} ${pc.yellow(`${t.flaky} flaky`)} ${pc.dim(`${t.skipped} skipped`)} → ${res.status}`);
265
+ if (res.gates)
266
+ out(`gates · process ${res.gates.process}: ${res.gates.passed
267
+ ? pc.green('passed')
268
+ : pc.red(`FAILED (${res.gates.rows
269
+ .filter((r) => !r.passed)
270
+ .map((r) => r.gate)
271
+ .join(', ')})`)}`);
181
272
  if (res.filesSkipped.length)
182
273
  out(pc.dim(`skipped (already ingested): ${res.filesSkipped.join(', ')}`));
183
274
  if (res.parseErrors)
@@ -189,9 +280,11 @@ export function register(program) {
189
280
  });
190
281
  report
191
282
  .command('merge <dirs...>')
192
- .description('Merge sharded run reports into one HTML report and JUnit file')
283
+ .description('Merge sharded run reports into one HTML report and JUnit file, and judge process gates on the whole run')
193
284
  .option('--run <id>', 'run id to write the merged report into (default: latest)')
194
285
  .option('--reporter <list>', 'reporters for the merged output', 'html,junit')
286
+ .option('--process <name>', "evaluate this process's gates over the merged shards; exit 1 when one is not met (default: the process in run.json, when it declares gates)")
287
+ .option('-p, --project <slug>', 'project that defines --process (default: from run.json)')
195
288
  .action(async (dirs, opts, cmd) => {
196
289
  const ctx = createContext(cmd);
197
290
  const root = artifactsRoot(ctx.rootDir);
@@ -212,28 +305,82 @@ export function register(program) {
212
305
  exitCode: 2,
213
306
  });
214
307
  }
308
+ const shardDirs = dirs.map((d) => resolve(ctx.rootDir, d));
309
+ // The run directory each shard report sits in (`<run>/shard-reports`) holds that shard's
310
+ // @a11y and @perf scenario reports, which is the gate evidence the merged report lacks.
311
+ const shardRunDirs = shardDirs
312
+ .filter((d) => [runFiles.shardReports, legacyRunFiles.shardReports].includes(basename(d)))
313
+ .map((d) => dirname(d));
314
+ const gated = resolveMergeProcess(ctx, {
315
+ process: opts.process,
316
+ project: opts.project,
317
+ manifestDirs: [runDir, ...shardRunDirs],
318
+ });
319
+ const reporters = String(opts.reporter)
320
+ .split(',')
321
+ .map((r) => r.trim())
322
+ .filter(Boolean);
323
+ const mergedJson = join(runDir, runFiles.mergedResults);
324
+ if (gated && !reporters.includes('json'))
325
+ reporters.push('json');
326
+ if (gated)
327
+ mkdirSync(runDir, { recursive: true });
215
328
  // Shard reports are produced by `sdods run --reporter blob --shard i/n`; merging them
216
329
  // rebuilds one HTML report and JUnit file for the whole matrix.
330
+ const workspace = mergeWorkspace(shardDirs, ctx.rootDir);
217
331
  const args = [
218
332
  'playwright',
219
333
  'merge-reports',
334
+ '--config',
335
+ workspace.config,
220
336
  '--reporter',
221
- opts.reporter,
222
- ...dirs.map((d) => resolve(ctx.rootDir, d)),
337
+ reporters.join(','),
338
+ workspace.reports,
223
339
  ];
224
340
  const res = await execa('npx', args, {
225
341
  cwd: ctx.rootDir,
226
342
  reject: false,
227
- env: { ...process.env, PLAYWRIGHT_HTML_OUTPUT_DIR: join(runDir, runFiles.htmlReport) },
343
+ env: {
344
+ ...process.env,
345
+ PLAYWRIGHT_HTML_OUTPUT_DIR: join(runDir, runFiles.htmlReport),
346
+ ...(gated ? { PLAYWRIGHT_JSON_OUTPUT_FILE: mergedJson } : {}),
347
+ },
228
348
  });
229
- if (res.exitCode !== 0) {
230
- throw new SdodsError('RUN_FAILED', `Merging shard reports failed (exit ${res.exitCode}).`, {
231
- hint: res.stderr?.split('\n').slice(-3).join(' ') || undefined,
232
- });
349
+ let gates;
350
+ try {
351
+ if (res.exitCode !== 0) {
352
+ throw new SdodsError('RUN_FAILED', `Merging shard reports failed (exit ${res.exitCode}).`, { hint: mergeFailureHint(res.stderr) });
353
+ }
354
+ if (gated) {
355
+ const merged = readJsonFile(mergedJson);
356
+ gates = evaluateGates({
357
+ process: gated.process.name,
358
+ gates: gated.process.gates,
359
+ totals: gateTotalsFromRunnerStats(merged?.stats),
360
+ // The shards' run directories, and the reports attached in the blobs themselves: a
361
+ // download of the shard reports alone still carries its evidence.
362
+ evidence: collectGateEvidence([runDir, ...shardRunDirs], gateReportsFromRunnerJson(merged)),
363
+ });
364
+ recordMergedGates(runDir, gates);
365
+ }
366
+ }
367
+ finally {
368
+ rmSync(workspace.dir, { recursive: true, force: true });
233
369
  }
234
370
  if (ctx.opts.json)
235
- return json({ runId, merged: dirs.length, htmlReport: join(runDir, runFiles.htmlReport) });
236
- ok(`Merged ${dirs.length} shard report dir(s) into ${join(runDir, runFiles.htmlReport)}`);
371
+ json({
372
+ runId,
373
+ merged: dirs.length,
374
+ htmlReport: join(runDir, runFiles.htmlReport),
375
+ ...(gates ? { gates } : {}),
376
+ });
377
+ else {
378
+ ok(`Merged ${dirs.length} shard report dir(s) into ${join(runDir, runFiles.htmlReport)}`);
379
+ if (gates)
380
+ printGates(gates);
381
+ }
382
+ if (gates && !gates.passed)
383
+ throw gateFailedError(gates, join(runDir, runFiles.gates));
237
384
  });
238
385
  report
239
386
  .command('traceability')
@@ -7,6 +7,7 @@ import { SdodsError, CLI_OVERRIDES_ENV, VERSION, checkPoolCapacity, collectGateE
7
7
  import { analyzeChangeImpact } from '@sdods/mcp';
8
8
  import { createContext } from '../context.js';
9
9
  import { browserStatuses } from './browsers.js';
10
+ import { gateFailedError, printGates } from '../gates.js';
10
11
  import { maybeNotify, notifyRun } from '../notify.js';
11
12
  import { installedPlaywrightVersion, stepResultsWarning } from '../runner-compat.js';
12
13
  import { collect, json, out, parseIntFlag, renderError, table, warn } from '../ui.js';
@@ -447,12 +448,13 @@ export async function runCommand(flags, cmd) {
447
448
  const summary = readSummary(runDir, runId, exitCode, Date.now() - started);
448
449
  // Process gates are judged here, from the run that just finished, before anything publishes it.
449
450
  // One shard of several holds only part of the run, so its pass rate and its a11y/perf evidence
450
- // are not the run's; a sharded process is gated where the shards are merged, not per shard.
451
+ // are not the run's; a sharded process is gated where the shards are merged
452
+ // (`sdods report merge --process`), not per shard.
451
453
  const shardTotal = cfg.runtime.shard?.total ?? 1;
452
454
  let gates;
453
455
  if (proc && hasGates(proc.gates) && exitCode !== 130) {
454
456
  if (shardTotal > 1) {
455
- warn(`process "${proc.name}" gates are not evaluated on shard ${cfg.runtime.shard?.current}/${shardTotal}: one shard is not the whole run.`);
457
+ warn(`process "${proc.name}" gates are not evaluated on shard ${cfg.runtime.shard?.current}/${shardTotal}: one shard is not the whole run. Judge them on the merged shards with \`sdods report merge --process ${proc.name}\`.`);
456
458
  }
457
459
  else {
458
460
  gates = evaluateGates({
@@ -550,17 +552,8 @@ export async function runCommand(flags, cmd) {
550
552
  json({ runId, runDir, exitCode: finalExit, summary, manifest, notify, gates });
551
553
  }
552
554
  else {
553
- if (gates) {
554
- out('');
555
- out(pc.bold(`gates · process ${gates.process}`));
556
- table(gates.rows.map((r) => ({
557
- gate: r.gate,
558
- threshold: r.threshold,
559
- actual: r.actual,
560
- result: r.passed ? 'pass' : 'FAIL',
561
- detail: r.detail ?? '',
562
- })));
563
- }
555
+ if (gates)
556
+ printGates(gates);
564
557
  const t = summary?.totals;
565
558
  out('');
566
559
  out(`${finalExit === 0 ? pc.green('✔ passed') : finalExit === 130 ? pc.yellow('■ cancelled') : finalExit === 2 ? pc.yellow('■ nothing ran') : pc.red('✖ failed')} ${t ? `${t.passed} passed, ${t.failed} failed, ${t.skipped} skipped, ${t.flaky} flaky` : ''} ${pc.dim(`(${Math.round((Date.now() - started) / 1000)}s)`)}`);
@@ -568,13 +561,8 @@ export async function runCommand(flags, cmd) {
568
561
  out(pc.dim(`html report: ${join(runDir, runFiles.htmlReport, 'index.html')}`));
569
562
  out(pc.dim(`dashboard: ${join(runDir, runFiles.dashboard, 'index.html')}`));
570
563
  }
571
- if (gates && !gates.passed) {
572
- const failed = gates.rows.filter((r) => !r.passed);
573
- renderError(new SdodsError('GATE_FAILED', `Process "${gates.process}" did not meet ${failed.length} gate(s): ${failed.map((r) => `${r.gate} (${r.actual}, needs ${r.threshold})`).join('; ')}.`, {
574
- hint: `The verdict is in ${join(runDir, runFiles.gates)}. Gates are set under processes[].gates in sdods.project.yaml.`,
575
- docsPath: '/docs/guides/processes-and-testing-types',
576
- }), Boolean(ctx.opts.json));
577
- }
564
+ if (gates && !gates.passed)
565
+ renderError(gateFailedError(gates, join(runDir, runFiles.gates)), Boolean(ctx.opts.json));
578
566
  return finalExit;
579
567
  }
580
568
  /**
@@ -279,7 +279,7 @@ export function register(program) {
279
279
  if (opts.target === 'github') {
280
280
  const file = join(ctx.rootDir, '.github', 'workflows', `scheduled-${e.project}-${e.name}.yml`);
281
281
  mkdirSync(join(ctx.rootDir, '.github', 'workflows'), { recursive: true });
282
- writeFileSync(file, `name: scheduled ${e.project} ${e.name}\non:\n schedule:\n - cron: '${e.cron}'\n workflow_dispatch: {}\njobs:\n run:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: oven-sh/setup-bun@v2\n - uses: actions/setup-node@v4\n with: { node-version: 22 }\n - run: bun install\n - run: bun run sdods browsers install --with-deps\n - run: bun run sdods run ${e.args.join(' ')} --trigger schedule --run-id sched-${e.name}-\${{ github.run_id }}\n env:\n SDODS_SERVER_URL: \${{ secrets.SDODS_SERVER_URL }}\n SDODS_TOKEN: \${{ secrets.SDODS_TOKEN }}\n - uses: actions/upload-artifact@v4\n if: always()\n with: { name: sdods-${e.project}-${e.name}, path: .sdods/runs }\n`);
282
+ writeFileSync(file, `name: scheduled ${e.project} ${e.name}\non:\n schedule:\n - cron: '${e.cron}'\n workflow_dispatch: {}\njobs:\n run:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v7\n - uses: oven-sh/setup-bun@v2\n - uses: actions/setup-node@v7\n with: { node-version: 22 }\n - run: bun install\n - run: bun run sdods browsers install --with-deps\n - run: bun run sdods run ${e.args.join(' ')} --trigger schedule --run-id sched-${e.name}-\${{ github.run_id }}\n env:\n SDODS_SERVER_URL: \${{ secrets.SDODS_SERVER_URL }}\n SDODS_TOKEN: \${{ secrets.SDODS_TOKEN }}\n - uses: actions/upload-artifact@v7\n if: always()\n with: { name: sdods-${e.project}-${e.name}, path: .sdods/runs }\n`);
283
283
  written.push(file);
284
284
  }
285
285
  else if (opts.target === 'launchd') {
@@ -56,8 +56,8 @@ export function register(program) {
56
56
  .option('--list', 'list trace files instead of opening them')
57
57
  .action(async (zip, opts, cmd) => {
58
58
  const ctx = createContext(cmd);
59
- let zips = [];
60
- let source = '';
59
+ let zips;
60
+ let source;
61
61
  if (zip) {
62
62
  const p = resolvePath(ctx.opts.cwd ?? process.cwd(), zip);
63
63
  if (!existsSync(p))
@@ -0,0 +1,11 @@
1
+ import type { GateResult } from '@sdods/contracts';
2
+ import { SdodsError } from '@sdods/core';
3
+ /**
4
+ * How a process gate verdict reads on the terminal, and the error a breach fails with. Shared by
5
+ * `sdods run --process` (an unsharded run) and `sdods report merge --process` (the shards of one),
6
+ * so a gate looks the same wherever it was judged.
7
+ */
8
+ export declare function printGates(gates: GateResult): void;
9
+ /** The `GATE_FAILED` error for a verdict that did not pass. Exit 1: a gate not met is a failure. */
10
+ export declare function gateFailedError(gates: GateResult, verdictFile: string): SdodsError;
11
+ //# sourceMappingURL=gates.d.ts.map
package/dist/gates.js ADDED
@@ -0,0 +1,29 @@
1
+ import pc from 'picocolors';
2
+ import { SdodsError } from '@sdods/core';
3
+ import { out, table } from './ui.js';
4
+ /**
5
+ * How a process gate verdict reads on the terminal, and the error a breach fails with. Shared by
6
+ * `sdods run --process` (an unsharded run) and `sdods report merge --process` (the shards of one),
7
+ * so a gate looks the same wherever it was judged.
8
+ */
9
+ export function printGates(gates) {
10
+ out('');
11
+ out(pc.bold(`gates · process ${gates.process}`));
12
+ table(gates.rows.map((r) => ({
13
+ gate: r.gate,
14
+ threshold: r.threshold,
15
+ actual: r.actual,
16
+ result: r.passed ? 'pass' : 'FAIL',
17
+ detail: r.detail ?? '',
18
+ })));
19
+ }
20
+ /** The `GATE_FAILED` error for a verdict that did not pass. Exit 1: a gate not met is a failure. */
21
+ export function gateFailedError(gates, verdictFile) {
22
+ const failed = gates.rows.filter((r) => !r.passed);
23
+ return new SdodsError('GATE_FAILED', `Process "${gates.process}" did not meet ${failed.length} gate(s): ${failed.map((r) => `${r.gate} (${r.actual}, needs ${r.threshold})`).join('; ')}.`, {
24
+ hint: `The verdict is in ${verdictFile}. Gates are set under processes[].gates in sdods.project.yaml.`,
25
+ docsPath: '/docs/guides/processes-and-testing-types',
26
+ exitCode: 1,
27
+ });
28
+ }
29
+ //# sourceMappingURL=gates.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdods/cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.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>",
@@ -24,22 +24,22 @@
24
24
  "node": ">=22"
25
25
  },
26
26
  "dependencies": {
27
- "@sdods/contracts": "0.9.0",
28
- "@sdods/core": "0.9.0",
27
+ "@sdods/contracts": "0.10.0",
28
+ "@sdods/core": "0.10.0",
29
29
  "commander": "^15.0.0",
30
30
  "execa": "^10.0.1",
31
31
  "picocolors": "^1.1.1",
32
32
  "semver": "^7.8.5",
33
- "yaml": "^2.9.0",
34
- "zod": "^4.5.4",
33
+ "yaml": "^2.9.1",
34
+ "zod": "^4.6.5",
35
35
  "@playwright/test": "^1.63.0",
36
36
  "playwright-core": "^1.63.0",
37
- "@sdods/integrations": "0.9.0",
38
- "@sdods/mcp": "0.9.0",
39
- "@sdods/agents": "0.9.0",
40
- "@sdods/db": "0.9.0",
37
+ "@sdods/integrations": "0.10.0",
38
+ "@sdods/mcp": "0.10.0",
39
+ "@sdods/agents": "0.10.0",
40
+ "@sdods/db": "0.10.0",
41
41
  "csv-parse": "^7.0.2",
42
- "@sdods/server": "0.9.0"
42
+ "@sdods/server": "0.10.0"
43
43
  },
44
44
  "homepage": "https://sdods.com",
45
45
  "bugs": {
@@ -161,11 +161,12 @@ Defaults: **`http://127.0.0.1:4444`** for the CLI, **`http://localhost:8080`** f
161
161
  image binds `0.0.0.0:8080`). `--open` launches the browser (`open` / `start` / `xdg-open` by
162
162
  platform). `--host 0.0.0.0` exposes it on the LAN — only do that deliberately.
163
163
 
164
- On Apple silicon the published image is `linux/amd64` only, and Docker refuses the pull rather
165
- than emulating. Add the platform flag:
164
+ The image is published for `linux/amd64` and `linux/arm64`, so the same command runs natively on
165
+ Apple silicon and arm64 servers. Tags up to `0.9.0` carry amd64 only, and Docker refuses that pull
166
+ on arm64 rather than emulating; for those, add the platform flag:
166
167
 
167
168
  ```bash
168
- docker run --rm --platform linux/amd64 -p 8080:8080 -v sdods-data:/data ghcr.io/siri1410/sdods-server
169
+ docker run --rm --platform linux/amd64 -p 8080:8080 -v sdods-data:/data ghcr.io/siri1410/sdods-server:0.9.0
169
170
  ```
170
171
 
171
172
  If the run must come from a source checkout, `bun run web:build` is not optional — see
@@ -278,7 +279,7 @@ the API running **without** the UI bundle — that is the source-checkout case,
278
279
  | Fresh install, but `/login` says "ask an admin" | the login page does not redirect to `/setup` | open the `/setup?token=…` URL from the `serve` output, or `sdods users create --admin` |
279
280
  | Global CLI errors on a repo project (`CONFIG_INVALID`, unknown browser) | version skew between the global CLI and the checkout | use `bun run sdods` inside a checkout; upgrade the global one otherwise |
280
281
  | `sdods: command not found` after installing | the bin dir is not on PATH — `~/.local/bin`, or `%LOCALAPPDATA%\SDODS\bin` on Windows | add it, or re-run the installer with `--modify-path` / `-ModifyPath` |
281
- | Docker: `no matching manifest for linux/arm64/v8` | amd64-only image on Apple silicon | add `--platform linux/amd64` |
282
+ | Docker: `no matching manifest for linux/arm64/v8` | an amd64-only tag (`0.9.0` or earlier) on Apple silicon or arm64 | pull a newer tag, or add `--platform linux/amd64` |
282
283
  | UI loads, runs never start | browser engines missing | `sdods browsers install -b chromium` (the positional `sdods browsers install chromium` also works on current versions, and failed with `too many arguments` before). Bare `sdods browsers install` installs all engines; on Linux `--with-deps` needs root |
283
284
  | Node too old | SDODS needs Node 22+ | `nvm use 22` / `fnm use 22`. On macOS/Linux the installer also takes `--install-node`; **`install.ps1` has no such flag** — it exits and tells you to run `winget install OpenJS.NodeJS.LTS`, `scoop install nodejs-lts` or `nvm install 22` |
284
285
  | Anything else | — | `sdods doctor` first; it reports what it found, not what it expected |