@sdods/cli 0.20.0 → 0.22.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.
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
3
3
  import { basename, dirname, extname, join, resolve } from 'node:path';
4
4
  import { execa } from 'execa';
5
5
  import pc from 'picocolors';
6
- import { legacyRunFiles, runFiles, } from '@sdods/contracts';
6
+ import { legacyRunFiles, parseVersioned, runFiles, } from '@sdods/contracts';
7
7
  import { SdodsError, collectGateEvidence, evaluateGates, gateReportsFromRunnerJson, gateTotalsFromRunnerStats, hasGates, ingestGateJudge, } from '@sdods/core';
8
8
  import { createContext } from '../context.js';
9
9
  import { gateFailedError, printGates } from '../gates.js';
@@ -168,11 +168,12 @@ export function register(program) {
168
168
  });
169
169
  report
170
170
  .command('ingest [files...]')
171
- .description('Ingest cucumber messages NDJSON and runner JSON results into the database')
171
+ .description('Ingest cucumber messages NDJSON, runner JSON and JUnit XML results into the database')
172
172
  .requiredOption('--run-id <id>', 'run id (directory name under the artifacts root)')
173
173
  .option('-p, --project <slug>', 'project slug when run.json is missing')
174
174
  .option('--manifest <file>', 'explicit run.json path')
175
- .option('--format <fmt>', 'auto|cucumber|runner-json (alias: pw-json)', 'auto')
175
+ .option('--format <fmt>', 'auto|cucumber|runner-json (alias: pw-json)|junit', 'auto')
176
+ .option('--approved-skips <file>', 'approved-skip registry (approved-skips/v1); a skip it does not approve makes the run INFRASTRUCTURE_ERROR')
176
177
  .option('--artifacts-dir <dir>', 'artifacts root (default .sdods/runs)')
177
178
  .option('--replace', 'delete previously ingested rows of this run first')
178
179
  .option('-e, --env <name>', 'environment name when run.json is missing')
@@ -192,13 +193,18 @@ export function register(program) {
192
193
  const { basename } = await import('node:path');
193
194
  const form = new FormData();
194
195
  let count = 0;
195
- for (const f of files) {
196
+ for (const [i, f] of files.entries()) {
196
197
  const abs = resolve(ctx.rootDir, f);
197
198
  if (!existsSync(abs))
198
199
  continue;
199
- form.append('files', new Blob([readFileSync(abs)]), basename(abs));
200
+ // The server accepts result files by name; a JUnit file goes up under the name it reads.
201
+ const junit = ingestFormat(opts.format, abs) === 'junit' &&
202
+ !/^junit(\.shard-\d+)?\.xml$/.test(basename(abs));
203
+ form.append('files', new Blob([readFileSync(abs)]), junit ? `junit.shard-${i}.xml` : basename(abs));
200
204
  count++;
201
205
  }
206
+ if (opts.approvedSkips)
207
+ form.append('files', new Blob([readFileSync(resolve(ctx.rootDir, opts.approvedSkips))]), 'approved-skips.json');
202
208
  if (!count)
203
209
  throw new SdodsError('CONFIG_INVALID', 'No existing files to upload.', { exitCode: 2 });
204
210
  const url = `${serverUrl.replace(/\/$/, '')}/api/runs/${encodeURIComponent(opts.runId)}/ingest`;
@@ -223,19 +229,15 @@ export function register(program) {
223
229
  const root = opts.artifactsDir
224
230
  ? resolve(ctx.rootDir, opts.artifactsDir)
225
231
  : artifactsRoot(ctx.rootDir);
226
- const ndjson = [];
227
- const runnerJson = [];
232
+ const paths = { cucumber: [], 'runner-json': [], junit: [] };
228
233
  for (const f of files) {
229
234
  const abs = resolve(ctx.rootDir, f);
230
- const fmt = opts.format === 'auto'
231
- ? abs.endsWith('.ndjson')
232
- ? 'cucumber'
233
- : 'runner-json'
234
- : // `pw-json` is the pre-1.0 name, still accepted
235
- opts.format === 'pw-json'
236
- ? 'runner-json'
237
- : opts.format;
238
- (fmt === 'cucumber' ? ndjson : runnerJson).push(abs);
235
+ const fmt = ingestFormat(opts.format, abs);
236
+ if (!paths[fmt])
237
+ throw new SdodsError('CONFIG_INVALID', `Unknown --format "${opts.format}".`, {
238
+ exitCode: 2,
239
+ });
240
+ paths[fmt].push(abs);
239
241
  }
240
242
  const manifest = opts.manifest
241
243
  ? readJsonFile(resolve(ctx.rootDir, opts.manifest))
@@ -244,8 +246,12 @@ export function register(program) {
244
246
  runId: opts.runId,
245
247
  projectSlug: opts.project,
246
248
  manifest,
247
- ndjsonPaths: ndjson,
248
- runnerJsonPaths: runnerJson,
249
+ ndjsonPaths: paths.cucumber,
250
+ runnerJsonPaths: paths['runner-json'],
251
+ junitPaths: paths.junit,
252
+ approvedSkips: opts.approvedSkips
253
+ ? parseVersioned('approved-skips', readJsonFile(resolve(ctx.rootDir, opts.approvedSkips))).skips
254
+ : undefined,
249
255
  artifactsRoot: root,
250
256
  replace: Boolean(opts.replace),
251
257
  env: opts.env,
@@ -260,7 +266,9 @@ export function register(program) {
260
266
  return json(res);
261
267
  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)`);
262
268
  const t = res.totals;
263
- out(`${pc.green(`${t.passed} passed`)} ${pc.red(`${t.failed} failed`)} ${pc.yellow(`${t.flaky} flaky`)} ${pc.dim(`${t.skipped} skipped`)} → ${res.status}`);
269
+ out(`${pc.green(`${t.passed} passed`)} ${pc.red(`${t.failed} failed`)} ${pc.yellow(`${t.flaky} flaky`)} ${pc.dim(`${t.skipped} skipped`)} → ${res.status} (${res.outcome})`);
270
+ for (const r of res.outcomeReasons)
271
+ out(pc.yellow(` ${r.code}${r.test ? ` · ${r.test}` : ''}${r.detail ? `: ${r.detail}` : ''}`));
264
272
  if (res.gates)
265
273
  out(`gates · process ${res.gates.process}: ${res.gates.passed
266
274
  ? pc.green('passed')
@@ -478,4 +486,11 @@ export function register(program) {
478
486
  await execa(file, argv, { stdio: 'inherit', cwd: ctx.rootDir });
479
487
  });
480
488
  }
489
+ /** The format of one `report ingest` file: `--format`, or its extension under `auto`. */
490
+ function ingestFormat(format, file) {
491
+ if (format === 'auto')
492
+ return file.endsWith('.ndjson') ? 'cucumber' : file.endsWith('.xml') ? 'junit' : 'runner-json';
493
+ // `pw-json` is the pre-1.0 name, still accepted
494
+ return format === 'pw-json' ? 'runner-json' : format;
495
+ }
481
496
  //# sourceMappingURL=report.js.map
@@ -1,5 +1,10 @@
1
1
  import type { Command } from 'commander';
2
2
  import type { StepDef } from '@sdods/contracts';
3
+ /**
4
+ * The line of a failed `bddgen export` that says why, so the error names the cause rather than
5
+ * only "could not list" (SDO-71): the first `Error:` line of a stack, else the last line printed.
6
+ */
7
+ export declare function failureReason(output: string): string | undefined;
3
8
  /** Parse `bddgen export` output (`* Given ...` lines) into StepDef[]. */
4
9
  export declare function listSteps(rootDir: string, slug: string, opts?: {
5
10
  unused?: boolean;
@@ -4,6 +4,17 @@ import { SdodsError, coreStepsDir } from '@sdods/core';
4
4
  import { createContext } from '../context.js';
5
5
  import { json, table } from '../ui.js';
6
6
  import { toolCommand } from '../workspace-bin.js';
7
+ /**
8
+ * The line of a failed `bddgen export` that says why, so the error names the cause rather than
9
+ * only "could not list" (SDO-71): the first `Error:` line of a stack, else the last line printed.
10
+ */
11
+ export function failureReason(output) {
12
+ const lines = output
13
+ .split('\n')
14
+ .map((l) => l.trim())
15
+ .filter(Boolean);
16
+ return lines.find((l) => /^\w*Error\b/.test(l)) ?? lines.at(-1);
17
+ }
7
18
  /** Parse `bddgen export` output (`* Given ...` lines) into StepDef[]. */
8
19
  export async function listSteps(rootDir, slug, opts = {}) {
9
20
  const env = {
@@ -19,6 +30,7 @@ export async function listSteps(rootDir, slug, opts = {}) {
19
30
  const result = await execa(file, argv, { cwd: rootDir, env, reject: false, all: true });
20
31
  if (result.exitCode !== 0) {
21
32
  throw new SdodsError('RUN_FAILED', `Could not list the step definitions of ${slug}.`, {
33
+ hint: failureReason(result.all ?? ''),
22
34
  details: { output: result.all?.slice(-2000) },
23
35
  });
24
36
  }
@@ -1,14 +1,59 @@
1
- import type { Command } from 'commander';
1
+ import { type Command } from 'commander';
2
+ import { type InstallInfo } from '../install-method.js';
2
3
  export declare const SDODS_PACKAGES: readonly ["@sdods/cli", "@sdods/core", "@sdods/contracts", "@sdods/db", "@sdods/mcp", "@sdods/agents", "@sdods/integrations", "@sdods/server"];
4
+ export type UpgradeStatus = 'up-to-date' | 'outdated' | 'not-installed' | 'unavailable';
3
5
  export interface UpgradeRow {
4
6
  package: string;
5
7
  installed: string | null;
6
8
  latest: string | null;
7
- status: 'up-to-date' | 'outdated' | 'not-installed' | 'unavailable';
9
+ status: UpgradeStatus;
8
10
  }
9
11
  export declare function installedVersion(rootDir: string, pkg: string): string | null;
10
12
  export declare function latestVersion(pkg: string, fetchImpl?: typeof fetch, registry?: string): Promise<string | null>;
13
+ export declare function compare(installed: string | null, latest: string | null): UpgradeStatus;
14
+ /** The @sdods/* packages a project depends on, compared with the registry. */
11
15
  export declare function checkUpgrades(rootDir: string, fetchImpl?: typeof fetch): Promise<UpgradeRow[]>;
16
+ export interface CliStatus extends InstallInfo {
17
+ installed: string;
18
+ latest: string | null;
19
+ status: UpgradeStatus;
20
+ }
21
+ /** The running CLI: how it was installed and whether npm has a newer one. */
22
+ export declare function checkCli(rootDir: string, fetchImpl?: typeof fetch, packageDir?: string): Promise<CliStatus>;
23
+ export interface RefreshReport {
24
+ skills: Array<{
25
+ base: string;
26
+ targets: string[];
27
+ replaced: number;
28
+ }>;
29
+ /** Generated role agent files that differ from what this version writes. */
30
+ agents: {
31
+ stale: string[];
32
+ replaced: string[];
33
+ };
34
+ }
35
+ /**
36
+ * Bring the files SDODS put into a project and the home directory up to this version: the bundled
37
+ * skills, wherever they already are, and (with `force`) the generated `sdods-<role>` agent files.
38
+ * CLAUDE.md, AGENTS.md and other hand-editable files are never touched.
39
+ */
40
+ export declare function refreshAssets(rootDir: string, o?: {
41
+ force?: boolean;
42
+ home?: string;
43
+ dryRun?: boolean;
44
+ }): Promise<RefreshReport>;
45
+ export interface PluginUpdate {
46
+ client: 'claude' | 'copilot';
47
+ installed: boolean;
48
+ command: string[];
49
+ }
50
+ /** The SDODS plugin in Claude Code (`sdods@sdods`) and Copilot CLI (`sdods` from marketplace `sdods`). */
51
+ export declare function detectPlugins(): Promise<PluginUpdate[]>;
52
+ /**
53
+ * The sdods to re-run after an upgrade, so the new version refreshes the assets. npm, bun and pnpm
54
+ * replace the package in place; a Homebrew upgrade moves to a new keg that `brew` links as `sdods`.
55
+ */
56
+ export declare function upgradedBin(rootDir: string, cli: InstallInfo): string[];
12
57
  export declare function register(program: Command): void;
13
58
  export declare function detectPm(rootDir: string): 'bun' | 'pnpm' | 'npm';
14
59
  //# sourceMappingURL=upgrade.d.ts.map
@@ -1,10 +1,14 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join, relative } from 'node:path';
4
+ import { Option } from 'commander';
3
5
  import { execa } from 'execa';
4
6
  import pc from 'picocolors';
5
7
  import semver from 'semver';
6
8
  import { VERSION } from '@sdods/core';
7
9
  import { createContext } from '../context.js';
10
+ import { detectInstallMethod, runningPackageDir } from '../install-method.js';
11
+ import { installSkills, installedSkillTargets } from '../skills-catalog.js';
8
12
  import { json, ok, out, table, warn } from '../ui.js';
9
13
  export const SDODS_PACKAGES = [
10
14
  '@sdods/cli',
@@ -42,67 +46,238 @@ export async function latestVersion(pkg, fetchImpl = fetch, registry = process.e
42
46
  return null;
43
47
  }
44
48
  }
49
+ export function compare(installed, latest) {
50
+ if (!latest)
51
+ return 'unavailable';
52
+ if (!installed)
53
+ return 'not-installed';
54
+ return semver.valid(installed) && semver.lt(installed, latest) ? 'outdated' : 'up-to-date';
55
+ }
56
+ /** The @sdods/* packages a project depends on, compared with the registry. */
45
57
  export async function checkUpgrades(rootDir, fetchImpl) {
46
- const rows = await Promise.all(SDODS_PACKAGES.map(async (pkg) => {
47
- const installed = installedVersion(rootDir, pkg) ?? (pkg === '@sdods/cli' ? VERSION : null);
58
+ return Promise.all(SDODS_PACKAGES.map(async (pkg) => {
59
+ const installed = installedVersion(rootDir, pkg);
48
60
  const latest = await latestVersion(pkg, fetchImpl);
49
- let status;
50
- if (!latest)
51
- status = 'unavailable';
52
- else if (!installed)
53
- status = 'not-installed';
54
- else
55
- status =
56
- semver.valid(installed) && semver.lt(installed, latest) ? 'outdated' : 'up-to-date';
57
- return { package: pkg, installed, latest, status };
61
+ return { package: pkg, installed, latest, status: compare(installed, latest) };
58
62
  }));
59
- return rows;
63
+ }
64
+ /** The running CLI: how it was installed and whether npm has a newer one. */
65
+ export async function checkCli(rootDir, fetchImpl, packageDir = runningPackageDir()) {
66
+ const info = detectInstallMethod(packageDir, { rootDir });
67
+ const latest = await latestVersion('@sdods/cli', fetchImpl);
68
+ return { ...info, installed: VERSION, latest, status: compare(VERSION, latest) };
69
+ }
70
+ /**
71
+ * Bring the files SDODS put into a project and the home directory up to this version: the bundled
72
+ * skills, wherever they already are, and (with `force`) the generated `sdods-<role>` agent files.
73
+ * CLAUDE.md, AGENTS.md and other hand-editable files are never touched.
74
+ */
75
+ export async function refreshAssets(rootDir, o = {}) {
76
+ const home = o.home ?? homedir();
77
+ const report = { skills: [], agents: { stale: [], replaced: [] } };
78
+ for (const [base, global] of [
79
+ [rootDir, false],
80
+ [home, true],
81
+ ]) {
82
+ if (global && base === rootDir)
83
+ continue;
84
+ const targets = installedSkillTargets(base, global);
85
+ if (!targets.length)
86
+ continue;
87
+ const done = o.dryRun ? [] : installSkills({ rootDir, home, global, targets, force: true });
88
+ report.skills.push({
89
+ base,
90
+ targets,
91
+ replaced: done.filter((d) => d.status !== 'skipped').length,
92
+ });
93
+ }
94
+ const { codingAgentFiles } = await import('@sdods/agents');
95
+ for (const { file, content } of codingAgentFiles(rootDir)) {
96
+ if (!existsSync(file) || readFileSync(file, 'utf8') === content)
97
+ continue;
98
+ if (o.force && !o.dryRun) {
99
+ writeFileSync(file, content);
100
+ report.agents.replaced.push(file);
101
+ }
102
+ else
103
+ report.agents.stale.push(file);
104
+ }
105
+ return report;
106
+ }
107
+ /** The SDODS plugin in Claude Code (`sdods@sdods`) and Copilot CLI (`sdods` from marketplace `sdods`). */
108
+ export async function detectPlugins() {
109
+ const listJson = async (bin, args) => {
110
+ try {
111
+ const r = await execa(bin, args, { timeout: 20_000, reject: false, stdin: 'ignore' });
112
+ return r.exitCode === 0 ? JSON.parse(r.stdout) : null;
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ };
118
+ const claude = (await listJson('claude', ['plugin', 'list', '--json']));
119
+ const copilot = (await listJson('copilot', ['plugin', 'list', '--json']));
120
+ return [
121
+ {
122
+ client: 'claude',
123
+ installed: Array.isArray(claude) && claude.some((p) => p.id === 'sdods@sdods'),
124
+ command: ['claude', 'plugin', 'update', 'sdods@sdods'],
125
+ },
126
+ {
127
+ client: 'copilot',
128
+ installed: Array.isArray(copilot) &&
129
+ copilot.some((p) => p.name === 'sdods' && p.marketplace === 'sdods'),
130
+ command: ['copilot', 'plugin', 'update', 'sdods'],
131
+ },
132
+ ];
133
+ }
134
+ const statusColor = (s) => s === 'outdated' ? pc.yellow(s) : s === 'up-to-date' ? pc.green(s) : pc.dim(s);
135
+ /**
136
+ * The sdods to re-run after an upgrade, so the new version refreshes the assets. npm, bun and pnpm
137
+ * replace the package in place; a Homebrew upgrade moves to a new keg that `brew` links as `sdods`.
138
+ */
139
+ export function upgradedBin(rootDir, cli) {
140
+ if (cli.method === 'homebrew')
141
+ return ['sdods'];
142
+ const dir = cli.method === 'project' ? join(rootDir, 'node_modules', '@sdods', 'cli') : cli.packageDir;
143
+ return [process.execPath, join(dir, 'bin', 'sdods.js')];
144
+ }
145
+ async function runRefresh(rootDir, o) {
146
+ const rel = (p) => relative(rootDir, p) || '.';
147
+ const report = await refreshAssets(rootDir, { force: o.force });
148
+ const plugins = await detectPlugins();
149
+ const ran = [];
150
+ for (const p of plugins.filter((x) => x.installed)) {
151
+ const r = await execa(p.command[0], p.command.slice(1), {
152
+ reject: false,
153
+ stdin: 'ignore',
154
+ timeout: 120_000,
155
+ }).catch(() => ({ exitCode: 1 }));
156
+ ran.push({ client: p.client, ok: r.exitCode === 0, command: p.command.join(' ') });
157
+ }
158
+ if (o.json) {
159
+ json({ version: VERSION, ...report, plugins: ran });
160
+ return ran.every((r) => r.ok);
161
+ }
162
+ for (const s of report.skills)
163
+ ok(`Skills refreshed in ${s.targets.join(', ')} (${s.base === rootDir ? 'project' : 'home directory'})`);
164
+ for (const f of report.agents.replaced)
165
+ ok(`Replaced ${rel(f)}`);
166
+ if (report.agents.stale.length)
167
+ out(pc.dim(`${report.agents.stale.length} generated agent file(s) differ from ${VERSION}: ${report.agents.stale.map(rel).join(', ')}. \`sdods upgrade --apply --force\` replaces them, edits included.`));
168
+ for (const r of ran) {
169
+ if (r.ok)
170
+ ok(`Updated the SDODS plugin in ${r.client === 'claude' ? 'Claude Code (restart it to load)' : 'Copilot CLI'}`);
171
+ else
172
+ warn(`Could not update the ${r.client} plugin; run: ${r.command}`);
173
+ }
174
+ return ran.every((r) => r.ok);
60
175
  }
61
176
  export function register(program) {
62
177
  program
63
178
  .command('upgrade')
64
- .description('Check the npm registry for newer @sdods/* packages')
65
- .option('--apply', 'install the latest versions with the package manager')
66
- .option('--pm <manager>', 'bun | pnpm | npm (default: detected from lockfile)')
179
+ .description('Upgrade the SDODS CLI the way it was installed (Homebrew, npm, bun, pnpm, project) and refresh the skills and plugins it set up')
180
+ .option('--apply', 'run the upgrades and refresh skills and plugins (default: only report)')
181
+ .option('--pm <manager>', 'bun | pnpm | npm for project packages (default: from the lockfile)')
182
+ .option('--force', 'also replace generated sdods-<role> agent files that differ')
183
+ .option('--no-refresh', 'skip refreshing skills, agent files and plugins')
184
+ .addOption(new Option('--refresh-assets', 'refresh with this version only').hideHelp())
67
185
  .action(async (opts, cmd) => {
68
186
  const ctx = createContext(cmd);
69
- const rows = await checkUpgrades(ctx.rootDir);
70
- if (ctx.opts.json)
71
- return json(rows);
72
- table(rows.map((r) => ({
73
- package: r.package,
74
- installed: r.installed ?? pc.dim('—'),
75
- latest: r.latest ?? pc.dim('?'),
76
- status: r.status === 'outdated'
77
- ? pc.yellow(r.status)
78
- : r.status === 'up-to-date'
79
- ? pc.green(r.status)
80
- : pc.dim(r.status),
81
- })));
82
- const outdated = rows.filter((r) => r.status === 'outdated');
83
- if (!outdated.length) {
84
- if (rows.every((r) => r.status === 'unavailable'))
85
- warn('Registry unreachable (offline?) — nothing compared.');
86
- else
87
- ok('Everything installed is up to date.');
187
+ // Second half of `--apply`: runs in the freshly installed binary.
188
+ if (opts.refreshAssets) {
189
+ if (!(await runRefresh(ctx.rootDir, { force: opts.force, json: ctx.opts.json })))
190
+ process.exitCode = 1;
88
191
  return;
89
192
  }
193
+ const [cli, rows] = await Promise.all([checkCli(ctx.rootDir), checkUpgrades(ctx.rootDir)]);
194
+ const projectRows = rows.filter((r) => r.installed);
195
+ const outdated = projectRows.filter((r) => r.status === 'outdated');
196
+ const cliOutdated = cli.status === 'outdated' && cli.method !== 'project';
197
+ if (ctx.opts.json && !opts.apply) {
198
+ const [assets, plugins] = await Promise.all([
199
+ opts.refresh === false ? null : refreshAssets(ctx.rootDir, { dryRun: true }),
200
+ opts.refresh === false ? null : detectPlugins(),
201
+ ]);
202
+ return json({ cli, packages: projectRows, assets, plugins });
203
+ }
204
+ out(`${pc.bold('sdods')} ${cli.installed} ${pc.dim(`(${cli.method})`)} → ${cli.latest ?? pc.dim('?')} ${statusColor(cli.status)}`);
205
+ if (projectRows.length)
206
+ table(projectRows.map((r) => ({
207
+ package: r.package,
208
+ installed: r.installed ?? pc.dim('—'),
209
+ latest: r.latest ?? pc.dim('?'),
210
+ status: statusColor(r.status),
211
+ })));
212
+ if (cli.status === 'unavailable' && rows.every((r) => r.status === 'unavailable'))
213
+ warn('Registry unreachable (offline?): nothing compared.');
90
214
  if (!opts.apply) {
91
- out(pc.dim(`Run \`sdods upgrade --apply\` to install ${outdated.map((r) => r.package).join(', ')}.`));
215
+ const todo = [];
216
+ if (cliOutdated)
217
+ todo.push(cli.upgrade ? cli.upgrade.join(' ') : cli.hint);
218
+ if (outdated.length)
219
+ todo.push(`project: ${outdated.map((r) => r.package).join(', ')}`);
220
+ if (todo.length)
221
+ out(pc.dim(`\`sdods upgrade --apply\` will run: ${todo.join('; ')}`));
222
+ else
223
+ ok('Everything installed is up to date.');
224
+ if (opts.refresh !== false)
225
+ out(pc.dim('`--apply` also refreshes the SDODS skills and plugins you installed.'));
92
226
  return;
93
227
  }
94
- const pm = opts.pm ?? detectPm(ctx.rootDir);
95
- const specs = outdated.map((r) => `${r.package}@${r.latest}`);
96
- const args = pm === 'bun'
97
- ? ['add', ...specs]
98
- : pm === 'pnpm'
99
- ? ['add', ...specs]
100
- : ['install', ...specs];
101
- const res = await execa(pm, args, { cwd: ctx.rootDir, stdio: 'inherit', reject: false });
102
- if (res.exitCode !== 0)
228
+ let failed = false;
229
+ if (cliOutdated) {
230
+ if (cli.upgrade) {
231
+ out(pc.dim(`$ ${cli.upgrade.join(' ')}`));
232
+ const r = await execa(cli.upgrade[0], cli.upgrade.slice(1), {
233
+ stdio: 'inherit',
234
+ reject: false,
235
+ });
236
+ if (r.exitCode !== 0) {
237
+ failed = true;
238
+ warn(`The CLI upgrade failed; run it yourself: ${cli.upgrade.join(' ')}`);
239
+ }
240
+ else
241
+ ok(`Upgraded the CLI (${cli.method}).`);
242
+ }
243
+ else
244
+ warn(cli.hint);
245
+ }
246
+ if (outdated.length) {
247
+ const pm = opts.pm ?? detectPm(ctx.rootDir);
248
+ const specs = outdated.map((r) => `${r.package}@${r.latest}`);
249
+ const args = pm === 'npm' ? ['install', ...specs] : ['add', ...specs];
250
+ const r = await execa(pm, args, { cwd: ctx.rootDir, stdio: 'inherit', reject: false });
251
+ if (r.exitCode !== 0)
252
+ failed = true;
253
+ else
254
+ ok(`Upgraded ${specs.length} project package(s) with ${pm}.`);
255
+ }
256
+ if (opts.refresh !== false) {
257
+ const upgraded = (cliOutdated && cli.upgrade && !failed) || outdated.length > 0;
258
+ if (!upgraded) {
259
+ if (!(await runRefresh(ctx.rootDir, { force: opts.force })))
260
+ failed = true;
261
+ }
262
+ else {
263
+ // The running process is the old version; the new one knows the new skills.
264
+ const [bin, ...pre] = upgradedBin(ctx.rootDir, cli);
265
+ const args = [...pre, 'upgrade', '--refresh-assets', ...(opts.force ? ['--force'] : [])];
266
+ const r = await execa(bin, args, {
267
+ cwd: ctx.rootDir,
268
+ stdio: 'inherit',
269
+ reject: false,
270
+ }).catch(() => ({ exitCode: 1 }));
271
+ if (r.exitCode !== 0) {
272
+ failed = true;
273
+ warn('Could not refresh skills and plugins; run: sdods upgrade --apply');
274
+ }
275
+ }
276
+ }
277
+ if (failed)
103
278
  process.exitCode = 1;
104
- else
105
- ok(`Upgraded ${specs.length} package(s) with ${pm}.`);
279
+ else if (!cliOutdated && !outdated.length)
280
+ ok('Everything installed is up to date.');
106
281
  });
107
282
  }
108
283
  export function detectPm(rootDir) {
@@ -0,0 +1,29 @@
1
+ /**
2
+ * How the running `sdods` got onto this machine. `sdods upgrade` uses it to upgrade the CLI the same
3
+ * way it was installed, instead of only looking at a project's node_modules.
4
+ */
5
+ export type InstallMethod = 'project' | 'homebrew' | 'npm' | 'bun' | 'pnpm' | 'npx' | 'installer-git' | 'checkout';
6
+ export interface InstallInfo {
7
+ method: InstallMethod;
8
+ /** Directory of the installed @sdods/cli package (packages/cli in a checkout). */
9
+ packageDir: string;
10
+ /** The command that upgrades this install, or null when there is nothing to run. */
11
+ upgrade: string[] | null;
12
+ /** One line for people: what to do when `upgrade` is null, or what `--apply` will run. */
13
+ hint: string;
14
+ }
15
+ export interface DetectOptions {
16
+ rootDir: string;
17
+ /** $SDODS_HOME, default ~/.sdods (where the install script keeps a git checkout). */
18
+ sdodsHome?: string;
19
+ /** Test hook: does `<checkout>/.git` exist. */
20
+ isGitCheckout?: (dir: string) => boolean;
21
+ }
22
+ /** Directory of the @sdods/cli package this code is running from (src/ and dist/ sit one below it). */
23
+ export declare function runningPackageDir(): string;
24
+ /**
25
+ * Classify an installed package directory. Specific markers are checked before generic ones: a
26
+ * Homebrew keg and an npx cache both contain `node_modules/@sdods/cli` too.
27
+ */
28
+ export declare function detectInstallMethod(packageDir: string, o: DetectOptions): InstallInfo;
29
+ //# sourceMappingURL=install-method.d.ts.map
@@ -0,0 +1,62 @@
1
+ import { existsSync, realpathSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join, resolve, sep } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ const INSTALLER_GIT_HINT = 'Re-run the install script, which updates its checkout in place: curl -fsSL https://sdods.com/install.sh | sh -s -- --source git';
6
+ /** Directory of the @sdods/cli package this code is running from (src/ and dist/ sit one below it). */
7
+ export function runningPackageDir() {
8
+ const here = dirname(fileURLToPath(import.meta.url));
9
+ const dir = resolve(here, '..');
10
+ try {
11
+ return realpathSync(dir);
12
+ }
13
+ catch {
14
+ return dir;
15
+ }
16
+ }
17
+ /** Forward slashes, so one set of markers works on macOS, Linux and Windows paths. */
18
+ function norm(p) {
19
+ return p.split(sep).join('/').replace(/\\/g, '/');
20
+ }
21
+ function within(child, parent) {
22
+ const c = norm(child).replace(/\/+$/, '');
23
+ const p = norm(parent).replace(/\/+$/, '');
24
+ return c === p || c.startsWith(`${p}/`);
25
+ }
26
+ /**
27
+ * Classify an installed package directory. Specific markers are checked before generic ones: a
28
+ * Homebrew keg and an npx cache both contain `node_modules/@sdods/cli` too.
29
+ */
30
+ export function detectInstallMethod(packageDir, o) {
31
+ const p = norm(packageDir);
32
+ const home = o.sdodsHome ?? process.env.SDODS_HOME ?? join(homedir(), '.sdods');
33
+ const isGit = o.isGitCheckout ?? ((dir) => existsSync(join(dir, '.git')));
34
+ const make = (method, upgrade, hint) => ({
35
+ method,
36
+ packageDir,
37
+ upgrade,
38
+ hint,
39
+ });
40
+ if (within(packageDir, join(o.rootDir, 'node_modules')))
41
+ return make('project', null, 'Upgraded with the project package step below.');
42
+ if (/\/_npx\//.test(p))
43
+ return make('npx', null, 'npx fetches @sdods/cli on each run; use `npx -y @sdods/cli@latest`.');
44
+ if (/\/Cellar\/sdods\//i.test(p) || /\/(?:homebrew|linuxbrew)\/.*\/libexec\//i.test(p))
45
+ return make('homebrew', ['brew', 'upgrade', 'sdods'], 'brew upgrade sdods');
46
+ if (/\/\.bun\/install\/global\//.test(p))
47
+ return make('bun', ['bun', 'add', '-g', '@sdods/cli@latest'], 'bun add -g @sdods/cli@latest');
48
+ if (/\/pnpm\/global\//i.test(p) || /\/\.pnpm\/@sdods\+cli@/.test(p))
49
+ return make('pnpm', ['pnpm', 'add', '-g', '@sdods/cli@latest'], 'pnpm add -g @sdods/cli@latest');
50
+ if (/\/node_modules\/@sdods\/cli$/.test(p.replace(/\/+$/, '')))
51
+ return make('npm', ['npm', 'install', '-g', '@sdods/cli@latest'], 'npm install -g @sdods/cli@latest');
52
+ // packages/cli inside a clone of the monorepo
53
+ const checkout = resolve(packageDir, '..', '..');
54
+ if (/\/packages\/cli$/.test(p.replace(/\/+$/, ''))) {
55
+ if (within(checkout, join(home, 'app')))
56
+ return make('installer-git', null, INSTALLER_GIT_HINT);
57
+ if (isGit(checkout))
58
+ return make('checkout', null, `Development checkout: git -C ${checkout} pull, then install.`);
59
+ }
60
+ return make('npm', ['npm', 'install', '-g', '@sdods/cli@latest'], 'npm install -g @sdods/cli@latest');
61
+ }
62
+ //# sourceMappingURL=install-method.js.map
@@ -27,6 +27,13 @@ export declare const SKILL_TARGETS: {
27
27
  };
28
28
  export type SkillTarget = keyof typeof SKILL_TARGETS;
29
29
  export declare const DEFAULT_SKILL_TARGETS: SkillTarget[];
30
+ /** The directory a target resolves to, under a project root or (with `global`) the home directory. */
31
+ export declare function skillTargetDir(base: string, target: SkillTarget, global?: boolean): string;
32
+ /**
33
+ * Targets that already hold at least one bundled SDODS skill. `sdods upgrade` refreshes exactly
34
+ * these, so it updates what someone installed without spreading skills into new folders.
35
+ */
36
+ export declare function installedSkillTargets(base: string, global?: boolean, source?: string): SkillTarget[];
30
37
  export interface InstallSkillsOptions {
31
38
  rootDir: string;
32
39
  targets?: SkillTarget[];
@@ -49,6 +49,18 @@ export const SKILL_TARGETS = {
49
49
  export const DEFAULT_SKILL_TARGETS = ['claude', 'agents'];
50
50
  /** GitHub Copilot's user-level directory is `~/.copilot/skills`, not `~/.github/skills`. */
51
51
  const GLOBAL_OVERRIDES = { copilot: '.copilot/skills' };
52
+ /** The directory a target resolves to, under a project root or (with `global`) the home directory. */
53
+ export function skillTargetDir(base, target, global = false) {
54
+ return join(base, (global && GLOBAL_OVERRIDES[target]) || SKILL_TARGETS[target]);
55
+ }
56
+ /**
57
+ * Targets that already hold at least one bundled SDODS skill. `sdods upgrade` refreshes exactly
58
+ * these, so it updates what someone installed without spreading skills into new folders.
59
+ */
60
+ export function installedSkillTargets(base, global = false, source) {
61
+ const names = listBundledSkills(source).map((s) => s.name);
62
+ return Object.keys(SKILL_TARGETS).filter((t) => names.some((n) => existsSync(join(skillTargetDir(base, t, global), n, 'SKILL.md'))));
63
+ }
52
64
  export function installSkills(o) {
53
65
  const available = listBundledSkills(o.source);
54
66
  const wanted = o.skills?.length ? o.skills : available.map((s) => s.name);
@@ -58,9 +70,9 @@ export function installSkills(o) {
58
70
  const base = o.global ? (o.home ?? homedir()) : o.rootDir;
59
71
  const results = [];
60
72
  for (const target of o.targets?.length ? o.targets : DEFAULT_SKILL_TARGETS) {
61
- const rel = (o.global && GLOBAL_OVERRIDES[target]) || SKILL_TARGETS[target];
73
+ const dir = skillTargetDir(base, target, o.global);
62
74
  for (const skill of available.filter((s) => wanted.includes(s.name))) {
63
- const path = join(base, rel, skill.name);
75
+ const path = join(dir, skill.name);
64
76
  const exists = existsSync(join(path, 'SKILL.md'));
65
77
  if (exists && !o.force) {
66
78
  results.push({ skill: skill.name, target, path, status: 'skipped' });