@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/commands/report.js +34 -19
- package/dist/commands/steps.d.ts +5 -0
- package/dist/commands/steps.js +12 -0
- package/dist/commands/upgrade.d.ts +47 -2
- package/dist/commands/upgrade.js +223 -48
- package/dist/install-method.d.ts +29 -0
- package/dist/install-method.js +62 -0
- package/dist/skills-catalog.d.ts +7 -0
- package/dist/skills-catalog.js +14 -2
- package/package.json +10 -10
- package/skills/sdods-start-ui/SKILL.md +4 -9
package/dist/commands/report.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
:
|
|
234
|
-
|
|
235
|
-
|
|
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:
|
|
248
|
-
runnerJsonPaths:
|
|
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
|
package/dist/commands/steps.d.ts
CHANGED
|
@@ -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;
|
package/dist/commands/steps.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
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
|
-
|
|
47
|
-
const installed = installedVersion(rootDir, pkg)
|
|
58
|
+
return Promise.all(SDODS_PACKAGES.map(async (pkg) => {
|
|
59
|
+
const installed = installedVersion(rootDir, pkg);
|
|
48
60
|
const latest = await latestVersion(pkg, fetchImpl);
|
|
49
|
-
|
|
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
|
-
|
|
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('
|
|
65
|
-
.option('--apply', '
|
|
66
|
-
.option('--pm <manager>', 'bun | pnpm | npm (default:
|
|
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
|
-
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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(
|
|
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
|
package/dist/skills-catalog.d.ts
CHANGED
|
@@ -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[];
|
package/dist/skills-catalog.js
CHANGED
|
@@ -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
|
|
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(
|
|
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' });
|