@sdods/cli 0.3.2 → 0.5.1

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.
@@ -1,17 +1,31 @@
1
1
  import type { Command } from 'commander';
2
+ type Engine = 'chromium' | 'firefox' | 'webkit';
2
3
  export interface BrowserStatus {
3
4
  name: string;
4
- engine: 'chromium' | 'firefox' | 'webkit';
5
+ engine: Engine;
6
+ /** The branded channel this browser launches, when it is not the bundled build. */
7
+ channel: string | null;
5
8
  installed: boolean;
6
9
  executable: string | null;
7
10
  playwrightVersion: string;
8
11
  }
9
12
  export declare function browserStatuses(names?: string[]): Promise<BrowserStatus[]>;
10
- /** Install browser engines (used by `browsers install`, `doctor --fix`, `init`). */
13
+ /** Install browser engines and channels (used by `browsers install`, `doctor --fix`, `init`). */
11
14
  export declare function installBrowsers(opts?: {
12
15
  browsers?: string[];
13
16
  withDeps?: boolean;
14
17
  cwd?: string;
15
18
  }): Promise<void>;
19
+ /**
20
+ * Opens and closes each browser once.
21
+ *
22
+ * A browser that has never run sets up its profile on first launch, and an automation run started
23
+ * before that finishes dies with "Target page, context or browser has been closed" — seen on a
24
+ * freshly installed Edge, where the first `sdods run -b edge` failed every scenario and the
25
+ * identical rerun passed every one. Anywhere a browser is provisioned and used in the same job,
26
+ * this makes the first run deterministic.
27
+ */
28
+ export declare function warmupBrowsers(names: string[]): Promise<string[]>;
16
29
  export declare function register(program: Command): void;
30
+ export {};
17
31
  //# sourceMappingURL=browsers.d.ts.map
@@ -1,19 +1,60 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
+ import { join } from 'node:path';
3
4
  import { execa } from 'execa';
4
5
  import pc from 'picocolors';
5
6
  import { BrowserSchema } from '@sdods/contracts';
6
7
  import { SdodsError } from '@sdods/core';
7
8
  import { createContext } from '../context.js';
8
9
  import { collect, json, ok, table } from '../ui.js';
10
+ // Typed against BrowserName (not `string`) so a new browser is a compile error here rather than a
11
+ // silent fall-through to chromium.
9
12
  const ENGINE_OF = {
10
13
  chromium: 'chromium',
14
+ edge: 'chromium',
11
15
  firefox: 'firefox',
12
16
  webkit: 'webkit',
13
17
  'mobile-chrome': 'chromium',
14
18
  'mobile-safari': 'webkit',
15
19
  };
16
- export async function browserStatuses(names = Object.keys(ENGINE_OF)) {
20
+ /**
21
+ * Browsers that are a system install of a branded channel rather than a Playwright download.
22
+ * `playwright install msedge` runs the vendor installer; there is nothing in the browsers cache to
23
+ * find afterwards, which is why these need their own install target and their own detection.
24
+ */
25
+ const CHANNEL_OF = { edge: 'msedge' };
26
+ /** Browsers Playwright downloads, i.e. the ones a bare `browsers list` can expect to be present. */
27
+ const DOWNLOADED_BROWSERS = Object.keys(ENGINE_OF).filter((b) => !CHANNEL_OF[b]);
28
+ /**
29
+ * Where the stable Microsoft Edge binary lives, mirroring Playwright's own channel resolution.
30
+ * `playwright-core`'s `executablePath()` cannot answer this: for a channel browser it returns the
31
+ * bundled Chromium path, which exists on nearly every machine and would report Edge as installed
32
+ * when it is not.
33
+ */
34
+ function channelExecutable(channel) {
35
+ if (channel !== 'msedge')
36
+ return null;
37
+ if (process.platform === 'darwin')
38
+ return '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge';
39
+ if (process.platform === 'linux')
40
+ return '/opt/microsoft/msedge/msedge';
41
+ if (process.platform === 'win32') {
42
+ const suffix = join('Microsoft', 'Edge', 'Application', 'msedge.exe');
43
+ const roots = [
44
+ process.env.LOCALAPPDATA,
45
+ process.env.PROGRAMFILES,
46
+ process.env['PROGRAMFILES(X86)'],
47
+ ].filter((r) => Boolean(r));
48
+ for (const root of roots) {
49
+ const candidate = join(root, suffix);
50
+ if (existsSync(candidate))
51
+ return candidate;
52
+ }
53
+ return roots.length ? join(roots[0], suffix) : null;
54
+ }
55
+ return null;
56
+ }
57
+ export async function browserStatuses(names = DOWNLOADED_BROWSERS) {
17
58
  const require = createRequire(import.meta.url);
18
59
  let pw;
19
60
  let version = 'unknown';
@@ -25,6 +66,7 @@ export async function browserStatuses(names = Object.keys(ENGINE_OF)) {
25
66
  return names.map((name) => ({
26
67
  name,
27
68
  engine: ENGINE_OF[name] ?? 'chromium',
69
+ channel: CHANNEL_OF[name] ?? null,
28
70
  installed: false,
29
71
  executable: null,
30
72
  playwrightVersion: version,
@@ -32,9 +74,10 @@ export async function browserStatuses(names = Object.keys(ENGINE_OF)) {
32
74
  }
33
75
  return names.map((name) => {
34
76
  const engine = ENGINE_OF[name] ?? 'chromium';
77
+ const channel = CHANNEL_OF[name] ?? null;
35
78
  let executable = null;
36
79
  try {
37
- executable = pw[engine].executablePath();
80
+ executable = channel ? channelExecutable(channel) : pw[engine].executablePath();
38
81
  }
39
82
  catch {
40
83
  executable = null;
@@ -42,51 +85,111 @@ export async function browserStatuses(names = Object.keys(ENGINE_OF)) {
42
85
  return {
43
86
  name,
44
87
  engine,
88
+ channel,
45
89
  installed: Boolean(executable && existsSync(executable)),
46
90
  executable,
47
91
  playwrightVersion: version,
48
92
  };
49
93
  });
50
94
  }
51
- /** Install browser engines (used by `browsers install`, `doctor --fix`, `init`). */
95
+ /** Install browser engines and channels (used by `browsers install`, `doctor --fix`, `init`). */
52
96
  export async function installBrowsers(opts = {}) {
53
- const engines = [
54
- ...new Set((opts.browsers?.length ? opts.browsers : Object.keys(ENGINE_OF)).map((b) => ENGINE_OF[b] ?? b)),
55
- ];
56
- const args = ['playwright', 'install', ...(opts.withDeps ? ['--with-deps'] : []), ...engines];
57
- await execa('npx', args, { stdio: 'inherit', cwd: opts.cwd });
97
+ const names = (opts.browsers?.length ? opts.browsers : DOWNLOADED_BROWSERS);
98
+ const engines = [...new Set(names.filter((b) => !CHANNEL_OF[b]).map((b) => ENGINE_OF[b] ?? b))];
99
+ const channels = [...new Set(names.map((b) => CHANNEL_OF[b]).filter((c) => !!c))];
100
+ if (engines.length) {
101
+ const args = ['playwright', 'install', ...(opts.withDeps ? ['--with-deps'] : []), ...engines];
102
+ await execa('npx', args, { stdio: 'inherit', cwd: opts.cwd });
103
+ }
104
+ // Channels are a system install run by the vendor's own installer, so `--with-deps` does not
105
+ // apply and they cannot share the engine invocation.
106
+ for (const channel of channels) {
107
+ await execa('npx', ['playwright', 'install', channel], { stdio: 'inherit', cwd: opts.cwd });
108
+ }
109
+ }
110
+ /**
111
+ * Opens and closes each browser once.
112
+ *
113
+ * A browser that has never run sets up its profile on first launch, and an automation run started
114
+ * before that finishes dies with "Target page, context or browser has been closed" — seen on a
115
+ * freshly installed Edge, where the first `sdods run -b edge` failed every scenario and the
116
+ * identical rerun passed every one. Anywhere a browser is provisioned and used in the same job,
117
+ * this makes the first run deterministic.
118
+ */
119
+ export async function warmupBrowsers(names) {
120
+ const pw = (await import('playwright-core'));
121
+ const warmed = [];
122
+ for (const name of names) {
123
+ const engine = ENGINE_OF[name] ?? 'chromium';
124
+ const channel = CHANNEL_OF[name];
125
+ const browser = await pw[engine].launch({ headless: true, ...(channel ? { channel } : {}) });
126
+ await browser.close();
127
+ warmed.push(name);
128
+ }
129
+ return warmed;
58
130
  }
59
131
  export function register(program) {
60
132
  const browsers = program.command('browsers').description('Install and list browser engines');
61
133
  browsers
62
134
  .command('install')
63
- .description('Install browser engines (default: chromium, firefox, webkit)')
135
+ .description('Install browsers (default: chromium, firefox, webkit)')
136
+ // The positional form reads naturally and is what the docs have always shown, but Commander
137
+ // rejects excess positionals by default, so `browsers install chromium` failed outright.
138
+ .argument('[browsers...]', 'browsers to install, e.g. chromium edge')
64
139
  .option('-b, --browser <name>', 'browser to install (repeatable)', collect, [])
65
140
  .option('--with-deps', 'also install OS dependencies (Linux CI)')
66
141
  .option('-p, --project <slug>', 'install the browsers declared by a project')
67
- .action(async (opts, cmd) => {
142
+ .action(async (positional, opts, cmd) => {
68
143
  const ctx = createContext(cmd);
69
- let list = opts.browser;
144
+ let list = [...positional, ...opts.browser];
70
145
  if (opts.project)
71
146
  list = ctx.registry.get(opts.project).browsers;
72
147
  for (const b of list)
73
148
  BrowserSchema.parse(b);
74
149
  await installBrowsers({ browsers: list, withDeps: opts.withDeps, cwd: ctx.rootDir });
75
- ok(`Installed ${list.length ? list.join(', ') : 'chromium, firefox, webkit'}`);
150
+ ok(`Installed ${list.length ? list.join(', ') : DOWNLOADED_BROWSERS.join(', ')}`);
151
+ });
152
+ browsers
153
+ .command('warmup')
154
+ .description('Launch each browser once so the first real run is not its first launch')
155
+ .option('-b, --browser <name>', 'browser to warm up (repeatable)', collect, [])
156
+ .option('-p, --project <slug>', 'warm up the browsers declared by a project')
157
+ .action(async (opts, cmd) => {
158
+ const ctx = createContext(cmd);
159
+ let list = opts.browser;
160
+ if (!list.length && opts.project)
161
+ list = ctx.registry.get(opts.project).browsers;
162
+ if (!list.length)
163
+ list = [...DOWNLOADED_BROWSERS];
164
+ for (const b of list)
165
+ BrowserSchema.parse(b);
166
+ const warmed = await warmupBrowsers(list);
167
+ ok(`Warmed up ${warmed.join(', ')}`);
76
168
  });
77
169
  browsers
78
170
  .command('list')
79
171
  .description('Show which browsers are installed and where; exits 1 if any is missing')
80
172
  .option('-p, --project <slug>', 'limit to the browsers declared by a project')
173
+ .option('-b, --browser <name>', 'limit to one browser (repeatable)', collect, [])
81
174
  .action(async (opts, cmd) => {
82
175
  const ctx = createContext(cmd);
83
- const names = opts.project ? ctx.registry.get(opts.project).browsers : Object.keys(ENGINE_OF);
176
+ // A bare `browsers list` reports the browsers Playwright downloads. Channel browsers are a
177
+ // system install that most machines will not have, and exiting 1 for a missing Edge nobody
178
+ // asked for would turn this into a permanently failing command.
179
+ const names = opts.browser.length
180
+ ? opts.browser
181
+ : opts.project
182
+ ? ctx.registry.get(opts.project).browsers
183
+ : DOWNLOADED_BROWSERS;
184
+ for (const b of names)
185
+ BrowserSchema.parse(b);
84
186
  const statuses = await browserStatuses(names);
85
187
  if (ctx.opts.json)
86
188
  return json(statuses);
87
189
  table(statuses.map((s) => ({
88
190
  browser: s.name,
89
191
  engine: s.engine,
192
+ channel: s.channel ?? '',
90
193
  installed: s.installed ? pc.green('yes') : pc.red('no'),
91
194
  playwright: s.playwrightVersion,
92
195
  executable: s.executable ?? '',
@@ -16,13 +16,17 @@ export function registerConfigCommands(program) {
16
16
  const cfg = ctx.registry.resolve(opts.project, opts.env);
17
17
  const view = opts.showSecrets ? cfg : redact(cfg);
18
18
  if (opts.explain) {
19
- const rows = explainConfig(cfg).map((r) => ({
20
- path: r.path,
21
- layer: r.layer,
22
- value: typeof r.value === 'object'
23
- ? JSON.stringify(r.value)
24
- : String(opts.showSecrets ? r.value : redactLeaf(r.path, r.value)),
25
- }));
19
+ // Values come from the same redacted tree as the other views, so --explain can never show
20
+ // what plain `config show` hides (it used to apply a weaker regex of its own).
21
+ const tree = { project: view.project, env: view.env, runtime: view.runtime };
22
+ const rows = explainConfig(cfg).map((r) => {
23
+ const value = valueAt(tree, r.path);
24
+ return {
25
+ path: r.path,
26
+ layer: r.layer,
27
+ value: typeof value === 'object' ? JSON.stringify(value) : String(value),
28
+ };
29
+ });
26
30
  if (ctx.opts.json)
27
31
  return json(rows);
28
32
  return table(rows, ['path', 'layer', 'value']);
@@ -71,9 +75,13 @@ export function registerConfigCommands(program) {
71
75
  process.exitCode = 2;
72
76
  });
73
77
  }
74
- function redactLeaf(path, value) {
75
- return /(password|secret|token|apikey|api_key)/i.test(path) && typeof value === 'string' && value
76
- ? '***'
77
- : value;
78
+ function valueAt(tree, path) {
79
+ let cur = tree;
80
+ for (const seg of path.split('.')) {
81
+ if (cur === null || typeof cur !== 'object')
82
+ return undefined;
83
+ cur = cur[seg];
84
+ }
85
+ return cur;
78
86
  }
79
87
  //# sourceMappingURL=config.js.map
@@ -4,6 +4,8 @@ import { execa } from 'execa';
4
4
  import pc from 'picocolors';
5
5
  import { collectVarRefs, loadDotEnvLayer, loadEnvFile } from '@sdods/core';
6
6
  import { createContext } from '../context.js';
7
+ import { browserStatuses } from './browsers.js';
8
+ import { MIN_PLAYWRIGHT_FOR_STEP_RESULTS, installedPlaywrightVersion, stepResultsWarning, } from '../runner-compat.js';
7
9
  import { json, out } from '../ui.js';
8
10
  export function registerDoctorCommand(program) {
9
11
  program
@@ -23,6 +25,17 @@ export function registerDoctorCommand(program) {
23
25
  });
24
26
  checks.push(await versionCheck('bun', ['--version'], 'optional: bun install/build are faster'));
25
27
  checks.push(await versionCheck('runner', ['playwright', '--version'], 'install workspace dependencies: bun install (or npm install)', 'npx'));
28
+ const runnerVersion = installedPlaywrightVersion(ctx.rootDir);
29
+ if (runnerVersion) {
30
+ checks.push({
31
+ name: 'step results',
32
+ ok: !stepResultsWarning(runnerVersion),
33
+ detail: stepResultsWarning(runnerVersion)
34
+ ? `@playwright/test ${runnerVersion} records every Gherkin step SKIPPED in messages.ndjson`
35
+ : `@playwright/test ${runnerVersion} (>= ${MIN_PLAYWRIGHT_FOR_STEP_RESULTS})`,
36
+ fix: `npm i -D @playwright/test@^${MIN_PLAYWRIGHT_FOR_STEP_RESULTS} (or bun add -d)`,
37
+ });
38
+ }
26
39
  const browsers = await browserCheck();
27
40
  checks.push(...browsers);
28
41
  if (opts.fix && browsers.some((b) => !b.ok)) {
@@ -297,27 +310,18 @@ async function versionCheck(name, args, fix, bin = name) {
297
310
  }
298
311
  }
299
312
  async function browserCheck() {
300
- const out = [];
301
- for (const b of ['chromium', 'firefox', 'webkit']) {
302
- try {
303
- const mod = await import('playwright-core');
304
- const path = mod[b].executablePath();
305
- out.push({
306
- name: `browser:${b}`,
307
- ok: existsSync(path),
308
- detail: existsSync(path) ? path : 'not installed',
309
- fix: `sdods browsers install -b ${b}`,
310
- });
311
- }
312
- catch {
313
- out.push({
314
- name: `browser:${b}`,
315
- ok: false,
316
- detail: 'browser runner not resolvable',
317
- fix: 'install workspace dependencies: bun install (or npm install)',
318
- });
319
- }
320
- }
321
- return out;
313
+ // Reuses `browsers list`'s detection so the two commands can never disagree about what is
314
+ // installed — and so channel browsers are probed by path rather than by the bundled engine's
315
+ // executable, which exists whether or not the branded browser does.
316
+ const statuses = await browserStatuses(['chromium', 'edge', 'firefox', 'webkit']);
317
+ return statuses.map((s) => ({
318
+ name: `browser:${s.name}`,
319
+ ok: s.installed,
320
+ detail: s.installed ? (s.executable ?? 'installed') : 'not installed',
321
+ fix: `sdods browsers install -b ${s.name}`,
322
+ // Edge is a system browser a project opts into. A machine that does not run Edge suites should
323
+ // not get a red doctor for not having it.
324
+ optional: Boolean(s.channel),
325
+ }));
322
326
  }
323
327
  //# sourceMappingURL=doctor.js.map
@@ -1,5 +1,5 @@
1
1
  import { relative } from 'node:path';
2
- import { listFeatureFiles, parseFeatureFile, scenariosOf } from '@sdods/core';
2
+ import { listFeatureFiles, normalizeTagExpr, parseFeatureFile, parseTagExpr, scenariosOf, } from '@sdods/core';
3
3
  import { createContext } from '../context.js';
4
4
  import { json, table } from '../ui.js';
5
5
  export function register(program) {
@@ -8,18 +8,20 @@ export function register(program) {
8
8
  .command('list')
9
9
  .description('List features with module, layer, suite, scenario count and tags')
10
10
  .requiredOption('-p, --project <slug>', 'project slug')
11
- .option('--tags <expr>', 'only scenarios carrying this tag (simple @tag filter)')
11
+ .option('--tags <expr>', 'only scenarios matching this tag expression (as in `sdods run`)')
12
12
  .option('--scenarios', 'one row per scenario instead of per feature')
13
13
  .action((opts, cmd) => {
14
14
  const ctx = createContext(cmd);
15
15
  const cfg = ctx.registry.resolve(opts.project);
16
+ const tagExpr = normalizeTagExpr(opts.tags);
17
+ const selector = tagExpr ? parseTagExpr(tagExpr) : undefined;
16
18
  const rows = [];
17
19
  for (const file of listFeatureFiles(cfg.project.root)) {
18
20
  const rel = relative(cfg.project.root, file).replace(/\\/g, '/');
19
21
  const parsed = parseFeatureFile(file);
20
22
  const mod = ctx.registry.moduleOfFeature(opts.project, rel)?.name ?? '';
21
- const scenarios = scenariosOf(parsed).filter((s) => !opts.tags || s.tags.includes(opts.tags));
22
- if (!scenarios.length && opts.tags)
23
+ const scenarios = scenariosOf(parsed).filter((s) => !selector || selector.evaluate(s.tags));
24
+ if (!scenarios.length && selector)
23
25
  continue;
24
26
  if (opts.scenarios) {
25
27
  for (const s of scenarios) {
@@ -136,7 +136,9 @@ export async function initWorkspace(target, flags) {
136
136
  '@sdods/cli': dep('@sdods/cli'),
137
137
  '@sdods/core': dep('@sdods/core'),
138
138
  '@sdods/contracts': dep('@sdods/contracts'),
139
- '@playwright/test': flags.link && flags.pm === 'bun' ? 'link:@playwright/test' : '^1.62.1',
139
+ // 1.63 is the floor, not a preference: below it an npm-installed workspace records every
140
+ // Gherkin step SKIPPED in messages.ndjson (see runner-compat.ts).
141
+ '@playwright/test': flags.link && flags.pm === 'bun' ? 'link:@playwright/test' : '^1.63.0',
140
142
  'playwright-bdd': flags.link && flags.pm === 'bun' ? 'link:playwright-bdd' : '^9.2.0',
141
143
  },
142
144
  devDependencies: {
@@ -338,7 +340,7 @@ Docs: https://docs.sdods.com
338
340
  browsersInstalled = res.exitCode === 0;
339
341
  }
340
342
  if (flags.claude) {
341
- const res = await execa('npx', ['sdods', 'agent', 'install-claude'], {
343
+ const res = await execa('npx', ['-y', '@sdods/cli', 'agent', 'install-claude'], {
342
344
  cwd: target,
343
345
  reject: false,
344
346
  stdio: 'pipe',
@@ -1,10 +1,10 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import pc from 'picocolors';
4
- import { SdodsError, DEFAULT_ARTIFACTS_DIR, Logger } from '@sdods/core';
4
+ import { DEFAULT_ARTIFACTS_DIR } from '@sdods/core';
5
5
  import { createContext } from '../context.js';
6
+ import { LINKS_FILE, notifyRun } from '../notify.js';
6
7
  import { json, ok, out, table, warn } from '../ui.js';
7
- const LINKS_FILE = ['.sdods', 'issue-links.json'];
8
8
  /** `sdods integrations test|notify|sync|links` — GitHub and Jira without the server or a database. */
9
9
  export function register(program) {
10
10
  const cmd = program
@@ -15,6 +15,7 @@ export function register(program) {
15
15
  .description('Check credentials and reachability for each enabled provider')
16
16
  .requiredOption('-p, --project <slug>', 'project slug')
17
17
  .option('--provider <name>', 'only this provider (github|jira)')
18
+ .option('--create-labels', 'create configured GitHub labels the repository is missing')
18
19
  .action(async (opts, c) => {
19
20
  const ctx = createContext(c);
20
21
  const entry = ctx.registry.entry(opts.project);
@@ -45,13 +46,14 @@ export function register(program) {
45
46
  });
46
47
  continue;
47
48
  }
48
- const res = await p.provider.test();
49
+ const res = await p.provider.test({ createMissingLabels: Boolean(opts.createLabels) });
49
50
  rows.push({
50
51
  provider: p.name,
51
52
  enabled: true,
52
53
  ok: res.ok,
53
54
  detail: res.detail,
54
55
  secrets: fmtSecrets(p.secrets),
56
+ ...(ctx.opts.json && res.labels ? { labels: res.labels } : {}),
55
57
  });
56
58
  }
57
59
  if (ctx.opts.json)
@@ -77,47 +79,16 @@ export function register(program) {
77
79
  .option('--artifacts-dir <dir>', 'artifacts root', DEFAULT_ARTIFACTS_DIR)
78
80
  .action(async (opts, c) => {
79
81
  const ctx = createContext(c);
80
- const { buildRunSummaryFromFiles, getProviders, createIntegrationContext, FileIssueLinkStore, } = await import('@sdods/integrations');
81
- const artifactsRoot = join(ctx.rootDir, opts.artifactsDir);
82
- const runDir = join(artifactsRoot, opts.runId);
83
- if (!existsSync(runDir)) {
84
- throw new SdodsError('RUN_FAILED', `Run directory not found: ${runDir}`, {
85
- hint: 'Pass --artifacts-dir or check the run id (sdods report --last).',
86
- exitCode: 2,
87
- });
88
- }
89
- const summary = buildRunSummaryFromFiles(runDir, {
82
+ const { summary, slug, actions: results, } = await notifyRun({
83
+ rootDir: ctx.rootDir,
84
+ registry: ctx.registry,
85
+ artifactsRoot: join(ctx.rootDir, opts.artifactsDir),
86
+ runId: opts.runId,
90
87
  projectSlug: opts.project,
91
88
  reportUrl: opts.reportUrl,
92
- });
93
- const slug = summary.run.projectSlug;
94
- const entry = ctx.registry.entry(slug);
95
- const providers = await getProviders(entry.config, {
96
- only: opts.provider ? [opts.provider] : undefined,
97
- projectRoot: entry.root,
98
- });
99
- const ictx = createIntegrationContext({
100
- store: new FileIssueLinkStore(join(ctx.rootDir, ...LINKS_FILE)),
101
- logger: new Logger('integrations'),
102
89
  dryRun: Boolean(opts.dryRun),
103
- artifactsRoot,
90
+ only: opts.provider ? [opts.provider] : undefined,
104
91
  });
105
- const results = [];
106
- for (const p of providers) {
107
- if (!p.enabled)
108
- continue;
109
- if (p.initError) {
110
- results.push({ provider: p.name, kind: 'skipped', detail: p.initError });
111
- continue;
112
- }
113
- try {
114
- const res = await p.provider.onRunFinished(summary, ictx);
115
- results.push(...res.actions);
116
- }
117
- catch (e) {
118
- results.push({ provider: p.name, kind: 'error', detail: e.message });
119
- }
120
- }
121
92
  if (ctx.opts.json)
122
93
  return json({ run: summary.run, totals: summary.totals, actions: results });
123
94
  const t = summary.totals;
@@ -1,11 +1,18 @@
1
1
  import pc from 'picocolors';
2
2
  import { SdodsError } from '@sdods/core';
3
+ import { ALL_CAPABILITIES, DEFAULT_CAPABILITIES } from '@sdods/mcp';
3
4
  import { createContext } from '../context.js';
4
5
  import { json, ok, out } from '../ui.js';
5
6
  const CLIENTS = ['claude', 'codex', 'cursor', 'vscode', 'windsurf'];
7
+ /**
8
+ * The default capability list, derived rather than restated. It used to be spelled out in three
9
+ * places in this file — the fallback, the flag default and the "did the user change it" sentinel —
10
+ * so adding a capability silently left the CLI handing out the old set.
11
+ */
12
+ const DEFAULT_CAPS = DEFAULT_CAPABILITIES.join(',');
6
13
  function capsOf(raw) {
7
14
  if (!raw || raw === 'all')
8
- return raw === 'all' ? 'all' : ['core', 'analyze', 'run', 'data', 'schedules'];
15
+ return raw === 'all' ? 'all' : [...DEFAULT_CAPABILITIES];
9
16
  return raw
10
17
  .split(',')
11
18
  .map((s) => s.trim())
@@ -17,7 +24,7 @@ export function register(program) {
17
24
  .description('Start the SDODS MCP server (stdio by default) or install client configuration')
18
25
  .option('-p, --project <slug>', 'default project for tools and prompts')
19
26
  .option('-e, --env <name>', 'default environment')
20
- .option('--caps <list>', 'capabilities: core,analyze,run,data,agents,issues,schedules or "all"', 'core,analyze,run,data,schedules')
27
+ .option('--caps <list>', `capabilities: ${ALL_CAPABILITIES.join(',')} or "all"`, DEFAULT_CAPS)
21
28
  .option('--http', 'serve streamable HTTP instead of stdio')
22
29
  .option('--port <n>', 'HTTP port', '4001')
23
30
  .option('--host <host>', 'HTTP host', '127.0.0.1')
@@ -79,7 +86,7 @@ export function register(program) {
79
86
  .option('--caps <list>')
80
87
  .option('--http-url <url>', 'configure the HTTP transport instead of stdio')
81
88
  .option('--token-placeholder <text>', 'placeholder written for the bearer token')
82
- .option('--no-playwright', 'do not add the bundled Playwright MCP server')
89
+ .option('--with-playwright', 'also register the raw upstream Playwright MCP server (ungoverned; the sdods server already wraps it as browser_*)')
83
90
  .option('--file', 'always write the config file instead of calling `claude mcp add` / `codex mcp add`')
84
91
  .option('--print', 'print the snippet instead of writing files')
85
92
  .action(async (client, opts, cmd) => {
@@ -96,11 +103,10 @@ export function register(program) {
96
103
  const o = {
97
104
  project: opts.project ?? parent.project,
98
105
  env: opts.env ?? parent.env,
99
- caps: opts.caps ??
100
- (parent.caps !== 'core,analyze,run,data,schedules' ? parent.caps : undefined),
106
+ caps: opts.caps ?? (parent.caps !== DEFAULT_CAPS ? parent.caps : undefined),
101
107
  httpUrl: opts.httpUrl,
102
108
  tokenPlaceholder: opts.tokenPlaceholder,
103
- withPlaywright: opts.playwright !== false,
109
+ withPlaywright: opts.withPlaywright === true,
104
110
  };
105
111
  const s = snippets(o)[client];
106
112
  if (opts.print || ctx.opts.json) {
@@ -117,7 +123,7 @@ export function register(program) {
117
123
  const done = await registerViaClientCli(client, o, ctx.rootDir);
118
124
  if (done) {
119
125
  ok(`Registered the sdods MCP server with ${client} (${cliCommands(o)[client]})`);
120
- if (o.withPlaywright && client === 'codex')
126
+ if (o.withPlaywright === true && client === 'codex')
121
127
  out(pc.dim('Also registered the bundled Playwright MCP server as "playwright".'));
122
128
  return;
123
129
  }
@@ -131,7 +137,10 @@ export function register(program) {
131
137
  /** `claude mcp add` (project scope) or `codex mcp add`; false when the CLI is unavailable. */
132
138
  async function registerViaClientCli(client, o, cwd) {
133
139
  const { execa } = await import('execa');
134
- const stdio = ['npx', 'sdods', 'mcp'];
140
+ // `npx sdods` resolves only when the cwd happens to sit inside a checkout that has the binary in
141
+ // node_modules/.bin. From anywhere else npx goes to the registry, where no package named `sdods`
142
+ // exists, and the client reports CONNECTION_CLOSED. Name the package that owns the bin instead.
143
+ const stdio = ['npx', '-y', '@sdods/cli', 'mcp'];
135
144
  if (o.project)
136
145
  stdio.push('--project', o.project);
137
146
  if (o.env)
@@ -168,7 +177,7 @@ async function registerViaClientCli(client, o, cwd) {
168
177
  const ok1 = await run(addArgs);
169
178
  if (!ok1)
170
179
  return false;
171
- if (o.withPlaywright !== false) {
180
+ if (o.withPlaywright === true) {
172
181
  const pw = client === 'claude'
173
182
  ? [
174
183
  'mcp',