@elmundi/ship-cli 0.14.2 → 0.15.4

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.
Files changed (39) hide show
  1. package/README.md +17 -16
  2. package/bin/shipctl.mjs +4 -80
  3. package/lib/commands/feedback.mjs +1 -1
  4. package/lib/commands/help.mjs +47 -131
  5. package/lib/commands/init.mjs +17 -250
  6. package/lib/commands/knowledge.mjs +25 -328
  7. package/lib/commands/preflight.mjs +213 -0
  8. package/lib/commands/run.mjs +298 -119
  9. package/lib/commands/trigger.mjs +95 -10
  10. package/lib/config/schema.mjs +73 -11
  11. package/lib/http.mjs +0 -2
  12. package/lib/runtime/routines.mjs +39 -0
  13. package/lib/templates.mjs +2 -2
  14. package/lib/verify/checks/agents-on-disk.mjs +5 -28
  15. package/lib/verify/registry.mjs +7 -8
  16. package/package.json +1 -1
  17. package/lib/artifacts/fs-index.mjs +0 -230
  18. package/lib/cache/store.mjs +0 -422
  19. package/lib/commands/bootstrap.mjs +0 -4
  20. package/lib/commands/callback.mjs +0 -742
  21. package/lib/commands/docs.mjs +0 -90
  22. package/lib/commands/kickoff.mjs +0 -192
  23. package/lib/commands/lanes.mjs +0 -566
  24. package/lib/commands/manifest-catalog.mjs +0 -251
  25. package/lib/commands/migrate.mjs +0 -204
  26. package/lib/commands/new.mjs +0 -452
  27. package/lib/commands/patterns.mjs +0 -160
  28. package/lib/commands/process.mjs +0 -388
  29. package/lib/commands/search.mjs +0 -43
  30. package/lib/commands/sync.mjs +0 -824
  31. package/lib/config/migrate.mjs +0 -223
  32. package/lib/find-ship-root.mjs +0 -75
  33. package/lib/process/specialist-prompt-contract.mjs +0 -171
  34. package/lib/state/lockfile.mjs +0 -180
  35. package/lib/vendor/run-agent.workflow.yml +0 -254
  36. package/lib/verify/checks/artifacts-up-to-date.mjs +0 -78
  37. package/lib/verify/checks/cache-integrity.mjs +0 -51
  38. package/lib/verify/checks/gitignore-cache.mjs +0 -51
  39. package/lib/verify/checks/rules-markers.mjs +0 -135
@@ -1,566 +0,0 @@
1
- /**
2
- * `shipctl lanes` — generate and manage the thin GitHub Actions caller
3
- * workflows that delegate to a **vendored** reusable `run-agent.yml` in the
4
- * same repository (RFC-0007 Phase 3).
5
- *
6
- * Each lane in `.ship/config.yml` (v2) gets one file at
7
- * .github/workflows/ship-<lane_id>.yml
8
- * whose body is the `on:` triggers (derived from the lane kind) and
9
- * `uses: ./.github/workflows/run-agent.yml`
10
- * `shipctl lanes install` also writes `.github/workflows/run-agent.yml`
11
- * from the template bundled inside `@elmundi/ship-cli`.
12
- *
13
- * Subcommands:
14
- * install — render wrappers for every declared lane (or just --only X).
15
- * list — print the lane map with the trigger each wrapper would emit.
16
- * remove — delete previously-generated ship-*.yml wrappers.
17
- *
18
- * Everything is idempotent and marker-guarded; hand-edits outside the
19
- * `<!-- ship-cli: lanes v1 -->` banner survive re-runs.
20
- */
21
-
22
- import fs from "node:fs";
23
- import path from "node:path";
24
- import { fileURLToPath } from "node:url";
25
- import YAML from "yaml";
26
-
27
- import { findShipRoot, readConfig } from "../config/io.mjs";
28
- import {
29
- validateConfig,
30
- CONFIG_SCHEMA_VERSION,
31
- lanePatterns,
32
- laneFanout,
33
- } from "../config/schema.mjs";
34
-
35
- const EXIT_OK = 0;
36
- const EXIT_USAGE = 2;
37
- const EXIT_CONFIG = 10;
38
-
39
- const WORKFLOW_DIR = path.join(".github", "workflows");
40
- const BANNER_MARKER = "# ship-cli: lanes v1 — generated by `shipctl lanes install`.";
41
- const BANNER_HEADER = `${BANNER_MARKER}
42
- # Regenerate via: shipctl lanes install
43
- # Hand edits OUTSIDE the \`ship-cli:\` markers will NOT be overwritten.`;
44
-
45
- /** Same-repo reusable workflow path (GitHub Actions). */
46
- const LOCAL_REUSABLE_USES = "./.github/workflows/run-agent.yml";
47
-
48
- const RUN_AGENT_MARKER = "# ship-cli: run-agent v1";
49
-
50
- function readRunAgentTemplate() {
51
- const dir = path.dirname(fileURLToPath(import.meta.url));
52
- const vendor = path.join(dir, "..", "vendor", "run-agent.workflow.yml");
53
- return fs.readFileSync(vendor, "utf8");
54
- }
55
-
56
- function printHelp() {
57
- console.log(`shipctl lanes — manage GitHub Actions caller workflows for the
58
- lanes declared in .ship/config.yml (each lane shows up as an
59
- Automation in the operator console; one Run is recorded per dispatch).
60
-
61
- Aliases: shipctl automations <subcmd> (operator-friendly name; both work)
62
-
63
- USAGE
64
- shipctl lanes install [--only <id,id>] [--ref <git-ref>] [--owner <gh-owner>]
65
- [--repo <repo>] [--shipctl-version <npm-tag>] [--dry-run]
66
- [--force] [--json] [--cwd <dir>]
67
- shipctl lanes list [--json] [--cwd <dir>]
68
- shipctl lanes remove [--only <id,id>] [--dry-run] [--json] [--cwd <dir>]
69
-
70
- FLAGS (install)
71
- --only <ids> Comma-separated lane ids to render (default: all).
72
- --ref, --owner, --repo Ignored (legacy); reusable workflow is always
73
- ${LOCAL_REUSABLE_USES} in the caller repo.
74
- --shipctl-version <v> Pin the @elmundi/ship-cli version the reusable
75
- workflow installs on the runner (default: latest).
76
- --force Overwrite wrappers that exist but were not generated
77
- by this tool (default: refuse and warn).
78
- --dry-run Show which files would be written without writing.
79
- --json Emit a structured summary.
80
-
81
- EXIT
82
- 0 success (including --dry-run)
83
- 2 argument / IO / config-missing
84
- 10 .ship/config.yml failed validation
85
- `);
86
- }
87
-
88
- export async function lanesCommand(ctx, rest) {
89
- const copy = [...rest];
90
- const sub = copy.shift();
91
- if (!sub || sub === "--help" || sub === "-h" || sub === "help") {
92
- printHelp();
93
- process.exit(EXIT_OK);
94
- }
95
- if (sub === "install") return installCmd(ctx, copy);
96
- if (sub === "list") return listCmd(ctx, copy);
97
- if (sub === "remove" || sub === "rm") return removeCmd(ctx, copy);
98
- console.error(`unknown lanes subcommand: ${sub}\nRun: shipctl lanes --help`);
99
- process.exit(EXIT_USAGE);
100
- }
101
-
102
- /* ─────────────────────────── install ─────────────────────────── */
103
-
104
- /**
105
- * Copy vendored `run-agent.workflow.yml` into `.github/workflows/run-agent.yml`.
106
- */
107
- function syncRunAgentTemplate({ runAgentFile, runAgentRel, dryRun, force, installed, skipped }) {
108
- let template;
109
- try {
110
- template = readRunAgentTemplate();
111
- } catch (err) {
112
- throw new Error(
113
- `cannot read bundled run-agent template: ${err instanceof Error ? err.message : err}`,
114
- );
115
- }
116
- const existed = fs.existsSync(runAgentFile);
117
- if (existed) {
118
- const cur = fs.readFileSync(runAgentFile, "utf8");
119
- if (!cur.includes(RUN_AGENT_MARKER) && !force) {
120
- skipped.push({ path: runAgentRel, reason: "run-agent-exists-without-banner" });
121
- return;
122
- }
123
- if (cur === template) {
124
- skipped.push({ path: runAgentRel, reason: "run-agent-up-to-date" });
125
- return;
126
- }
127
- }
128
- if (dryRun) {
129
- installed.push({
130
- path: runAgentRel,
131
- action: existed ? "would-update-run-agent" : "would-write-run-agent",
132
- });
133
- return;
134
- }
135
- fs.mkdirSync(path.dirname(runAgentFile), { recursive: true });
136
- fs.writeFileSync(runAgentFile, template, "utf8");
137
- installed.push({
138
- path: runAgentRel,
139
- action: existed ? "updated-run-agent" : "wrote-run-agent",
140
- });
141
- }
142
-
143
- async function installCmd(ctx, rest) {
144
- const args = parseInstallArgs(rest);
145
- args.dryRun = args.dryRun || Boolean(ctx.dryRun);
146
- args.force = args.force || Boolean(ctx.force);
147
- args.json = args.json || Boolean(ctx.json);
148
- const { cwd, config, shipRoot } = loadConfig(args.cwd);
149
-
150
- const wanted = selectLanes(config, args.only);
151
- if (wanted.length === 0) {
152
- const payload = { ok: true, installed: [], skipped: [], errors: [] };
153
- return emitSummary(ctx, args, payload, () =>
154
- console.log("No lanes declared in .ship/config.yml — nothing to install."),
155
- );
156
- }
157
-
158
- const reusable = LOCAL_REUSABLE_USES;
159
- const targetDir = path.join(shipRoot, WORKFLOW_DIR);
160
- const runAgentFile = path.join(targetDir, "run-agent.yml");
161
- const runAgentRel = path.relative(shipRoot, runAgentFile) || runAgentFile;
162
-
163
- const installed = [];
164
- const skipped = [];
165
- const errors = [];
166
-
167
- syncRunAgentTemplate({
168
- runAgentFile,
169
- runAgentRel,
170
- dryRun: args.dryRun,
171
- force: args.force,
172
- installed,
173
- skipped,
174
- });
175
-
176
- if (!args.dryRun) fs.mkdirSync(targetDir, { recursive: true });
177
-
178
- for (const [laneId, lane] of wanted) {
179
- const file = path.join(targetDir, `ship-${laneId}.yml`);
180
- const rel = path.relative(shipRoot, file) || file;
181
- const render = renderWrapper({
182
- laneId,
183
- lane,
184
- reusable,
185
- shipctlVersion: args.shipctlVersion || "latest",
186
- });
187
- if (render.error) {
188
- errors.push({ lane: laneId, error: render.error });
189
- continue;
190
- }
191
- const existed = fs.existsSync(file);
192
- if (existed) {
193
- const cur = fs.readFileSync(file, "utf8");
194
- if (!cur.includes(BANNER_MARKER) && !args.force) {
195
- skipped.push({ lane: laneId, path: rel, reason: "exists-without-banner" });
196
- continue;
197
- }
198
- if (cur === render.content) {
199
- skipped.push({ lane: laneId, path: rel, reason: "up-to-date" });
200
- continue;
201
- }
202
- }
203
- if (args.dryRun) {
204
- installed.push({ lane: laneId, path: rel, action: existed ? "would-update" : "would-write" });
205
- continue;
206
- }
207
- fs.writeFileSync(file, render.content, "utf8");
208
- installed.push({ lane: laneId, path: rel, action: existed ? "updated" : "wrote" });
209
- }
210
-
211
- const payload = {
212
- ok: errors.length === 0,
213
- reusable,
214
- dry_run: Boolean(args.dryRun),
215
- installed,
216
- skipped,
217
- errors,
218
- };
219
-
220
- emitSummary(ctx, args, payload, () => {
221
- console.log(`Ship lanes → reusable ${reusable}`);
222
- for (const row of installed) console.log(` ${row.action}: ${row.path}`);
223
- for (const row of skipped) console.log(` skipped (${row.reason}): ${row.path}`);
224
- for (const row of errors) console.log(` ERROR: ${row.lane}: ${row.error}`);
225
- if (args.dryRun) console.log("(--dry-run: no files written)");
226
- });
227
-
228
- if (errors.length) process.exit(EXIT_CONFIG);
229
- }
230
-
231
- /* ───────────────────────────── list ──────────────────────────── */
232
-
233
- function listCmd(ctx, rest) {
234
- const args = parseListArgs(rest);
235
- const { config } = loadConfig(args.cwd);
236
- const rows = Object.entries(config.lanes || {}).map(([id, lane]) => {
237
- const pats = lanePatterns(lane);
238
- return {
239
- lane: id,
240
- kind: lane.kind,
241
- // ``pattern`` keeps the single-string shape for humans/scripts
242
- // that eyeball the first pattern; ``patterns`` always lists all
243
- // so multi-pattern routines (RFC-0008 C3.1) surface correctly.
244
- pattern: pats[0] || null,
245
- patterns: pats,
246
- // ``fanout`` resolves to the runtime default (``matrix``) when
247
- // the lane doesn't declare one, so the workflow plan step
248
- // doesn't have to branch on "is this key missing?" (RFC-0008 C3.2).
249
- fanout: laneFanout(lane),
250
- on: lane.kind === "event" ? lane.on || null : null,
251
- cron: lane.kind === "schedule" ? lane.cron || null : null,
252
- idempotency_key: lane.kind === "once" ? lane.idempotency?.key || null : null,
253
- };
254
- });
255
- if (ctx.json || args.json) {
256
- console.log(JSON.stringify({ ok: true, lanes: rows }, null, 2));
257
- return;
258
- }
259
- if (!rows.length) {
260
- console.log("No lanes declared.");
261
- return;
262
- }
263
- for (const row of rows) {
264
- const trigger =
265
- row.kind === "event"
266
- ? `on ${row.on}`
267
- : row.kind === "schedule"
268
- ? `cron ${JSON.stringify(row.cron)}`
269
- : `once ${row.idempotency_key || ""}`;
270
- const patternLabel =
271
- row.patterns.length > 1
272
- ? `patterns=[${row.patterns.join(", ")}]`
273
- : `pattern=${row.pattern || "-"}`;
274
- console.log(
275
- ` ${row.lane.padEnd(28)} kind=${row.kind.padEnd(9)} ${trigger} (${patternLabel})`,
276
- );
277
- }
278
- }
279
-
280
- /* ──────────────────────────── remove ─────────────────────────── */
281
-
282
- function removeCmd(ctx, rest) {
283
- const args = parseRemoveArgs(rest);
284
- args.dryRun = args.dryRun || Boolean(ctx.dryRun);
285
- args.json = args.json || Boolean(ctx.json);
286
- const { shipRoot } = loadConfig(args.cwd, { requireValid: false });
287
- const targetDir = path.join(shipRoot, WORKFLOW_DIR);
288
- if (!fs.existsSync(targetDir)) {
289
- emitSummary(ctx, args, { ok: true, removed: [], skipped: [] }, () =>
290
- console.log("No .github/workflows directory; nothing to remove."),
291
- );
292
- return;
293
- }
294
- const wanted = args.only ? new Set(args.only) : null;
295
- const removed = [];
296
- const skipped = [];
297
- for (const entry of fs.readdirSync(targetDir)) {
298
- const m = /^ship-(.+)\.ya?ml$/.exec(entry);
299
- if (!m) continue;
300
- const laneId = m[1];
301
- if (wanted && !wanted.has(laneId)) continue;
302
- const file = path.join(targetDir, entry);
303
- const rel = path.relative(shipRoot, file) || file;
304
- const content = fs.readFileSync(file, "utf8");
305
- if (!content.includes(BANNER_MARKER)) {
306
- skipped.push({ lane: laneId, path: rel, reason: "not-generated-by-shipctl" });
307
- continue;
308
- }
309
- if (args.dryRun) {
310
- removed.push({ lane: laneId, path: rel, action: "would-remove" });
311
- continue;
312
- }
313
- fs.unlinkSync(file);
314
- removed.push({ lane: laneId, path: rel, action: "removed" });
315
- }
316
- emitSummary(ctx, args, { ok: true, dry_run: Boolean(args.dryRun), removed, skipped }, () => {
317
- for (const row of removed) console.log(` ${row.action}: ${row.path}`);
318
- for (const row of skipped) console.log(` skipped (${row.reason}): ${row.path}`);
319
- if (args.dryRun) console.log("(--dry-run: no files removed)");
320
- });
321
- }
322
-
323
- /* ─────────────────────────── helpers ─────────────────────────── */
324
-
325
- function loadConfig(cwdOverride, { requireValid = true } = {}) {
326
- const cwd = cwdOverride || process.cwd();
327
- const shipRoot = findShipRoot(cwd);
328
- if (!shipRoot) {
329
- console.error(`No .ship/config.yml found upward from ${cwd}.`);
330
- process.exit(EXIT_USAGE);
331
- }
332
- let read;
333
- try {
334
- read = readConfig(cwd);
335
- } catch (err) {
336
- console.error(err instanceof Error ? err.message : String(err));
337
- process.exit(EXIT_USAGE);
338
- }
339
- if (requireValid) {
340
- const v = validateConfig(read.config);
341
- if (!v.ok) {
342
- for (const e of v.errors) console.error(e);
343
- process.exit(EXIT_CONFIG);
344
- }
345
- for (const w of v.warnings) console.error(`warn: ${w}`);
346
- if (Number(read.config.version) !== CONFIG_SCHEMA_VERSION) {
347
- console.error(
348
- `warn: .ship/config.yml is v${read.config.version}; run 'shipctl migrate' to use lanes.`,
349
- );
350
- }
351
- }
352
- return { cwd, shipRoot, config: read.config };
353
- }
354
-
355
- function selectLanes(config, onlyCsv) {
356
- const all = Object.entries(config.lanes || {});
357
- if (!onlyCsv || onlyCsv.length === 0) return all;
358
- const wanted = new Set(onlyCsv);
359
- return all.filter(([id]) => wanted.has(id));
360
- }
361
-
362
- /**
363
- * Render the caller workflow YAML. The body is structured first as a JS
364
- * object so the output is guaranteed to be syntactically valid; we then
365
- * prepend the banner comment block.
366
- *
367
- * @returns {{content?: string, error?: string}}
368
- */
369
- export function renderWrapper({ laneId, lane, reusable, shipctlVersion }) {
370
- const kind = lane && lane.kind;
371
- const on = onBlockForLane(lane);
372
- if (on.error) return { error: on.error };
373
-
374
- const permissions = lane.permissions && Object.keys(lane.permissions).length
375
- ? { ...lane.permissions }
376
- : { contents: "read" };
377
-
378
- const doc = {
379
- name: `Ship · ${laneId}`,
380
- on: on.value,
381
- permissions,
382
- jobs: {
383
- run: {
384
- uses: reusable,
385
- with: {
386
- lane: laneId,
387
- shipctl_version: shipctlVersion,
388
- },
389
- secrets: "inherit",
390
- },
391
- },
392
- };
393
-
394
- // For kind=once lanes we expose the run_id / callback_url / etc so the
395
- // Ship dashboard can dispatch against the wrapper. We only attach these
396
- // under workflow_dispatch so cron/event triggers don't pay the cost.
397
- if (kind === "once" || (on.value && on.value.workflow_dispatch)) {
398
- doc.on = ensureDispatchInputs(on.value);
399
- doc.jobs.run.with.ship_run_id = "${{ inputs.ship_run_id }}";
400
- doc.jobs.run.with.ship_callback_url = "${{ inputs.ship_callback_url }}";
401
- }
402
-
403
- let body;
404
- try {
405
- body = YAML.stringify(doc, {
406
- lineWidth: 0,
407
- defaultStringType: "PLAIN",
408
- defaultKeyType: "PLAIN",
409
- });
410
- } catch (err) {
411
- return { error: `yaml render failed: ${err instanceof Error ? err.message : err}` };
412
- }
413
-
414
- const content = `${BANNER_HEADER}\n# lane: ${laneId} kind: ${kind}\n${body}`;
415
- return { content };
416
- }
417
-
418
- function onBlockForLane(lane) {
419
- const kind = lane && lane.kind;
420
- const dispatch = {
421
- workflow_dispatch: {
422
- inputs: {
423
- ship_run_id: { description: "Ship pipeline_run id", required: false, type: "string" },
424
- ship_callback_url: {
425
- description: "Ship callback URL",
426
- required: false,
427
- type: "string",
428
- },
429
- },
430
- },
431
- };
432
- if (kind === "once") return { value: dispatch };
433
- if (kind === "schedule") {
434
- if (!lane.cron) return { error: "schedule lane missing `cron`" };
435
- return { value: { ...dispatch, schedule: [{ cron: String(lane.cron) }] } };
436
- }
437
- if (kind === "event") {
438
- if (!lane.on) return { error: "event lane missing `on`" };
439
- const eventBlock = lane.when && Object.keys(lane.when).length
440
- ? { types: Array.isArray(lane.when.types) ? lane.when.types : undefined }
441
- : {};
442
- // We don't try to fully translate `when.conclusion` etc — those are
443
- // runtime checks in the reusable workflow; the caller just needs to
444
- // fire on the event.
445
- const event = {};
446
- event[String(lane.on)] = Object.keys(eventBlock).length ? eventBlock : null;
447
- return { value: { ...dispatch, ...event } };
448
- }
449
- return { error: `unknown lane kind: ${kind}` };
450
- }
451
-
452
- function ensureDispatchInputs(onValue) {
453
- if (!onValue || typeof onValue !== "object") return onValue;
454
- if (!onValue.workflow_dispatch) {
455
- onValue.workflow_dispatch = {
456
- inputs: {
457
- ship_run_id: { description: "Ship pipeline_run id", required: false, type: "string" },
458
- ship_callback_url: {
459
- description: "Ship callback URL",
460
- required: false,
461
- type: "string",
462
- },
463
- },
464
- };
465
- }
466
- return onValue;
467
- }
468
-
469
- /* ─────────────────────────── arg parsing ─────────────────────────── */
470
-
471
- function parseInstallArgs(rest) {
472
- const out = {
473
- cwd: null,
474
- only: null,
475
- ref: null,
476
- owner: null,
477
- repo: null,
478
- shipctlVersion: null,
479
- dryRun: false,
480
- force: false,
481
- json: false,
482
- };
483
- const copy = [...rest];
484
- while (copy.length) {
485
- const a = copy.shift();
486
- if (a === "--help" || a === "-h") {
487
- printHelp();
488
- process.exit(EXIT_OK);
489
- } else if (a === "--dry-run") out.dryRun = true;
490
- else if (a === "--force") out.force = true;
491
- else if (a === "--json") out.json = true;
492
- else if (a === "--only" && copy[0] !== undefined) out.only = csv(copy.shift());
493
- else if (a && a.startsWith("--only=")) out.only = csv(a.slice("--only=".length));
494
- else if (a === "--ref" && copy[0] !== undefined) out.ref = String(copy.shift());
495
- else if (a && a.startsWith("--ref=")) out.ref = a.slice("--ref=".length);
496
- else if (a === "--owner" && copy[0] !== undefined) out.owner = String(copy.shift());
497
- else if (a && a.startsWith("--owner=")) out.owner = a.slice("--owner=".length);
498
- else if (a === "--repo" && copy[0] !== undefined) out.repo = String(copy.shift());
499
- else if (a && a.startsWith("--repo=")) out.repo = a.slice("--repo=".length);
500
- else if (a === "--shipctl-version" && copy[0] !== undefined) out.shipctlVersion = String(copy.shift());
501
- else if (a && a.startsWith("--shipctl-version=")) out.shipctlVersion = a.slice("--shipctl-version=".length);
502
- else if (a === "--cwd" && copy[0] !== undefined) out.cwd = path.resolve(String(copy.shift()));
503
- else if (a && a.startsWith("--cwd=")) out.cwd = path.resolve(a.slice("--cwd=".length));
504
- else {
505
- console.error(`unknown argument: ${a}\nRun: shipctl lanes install --help`);
506
- process.exit(EXIT_USAGE);
507
- }
508
- }
509
- return out;
510
- }
511
-
512
- function parseListArgs(rest) {
513
- const out = { cwd: null, json: false };
514
- const copy = [...rest];
515
- while (copy.length) {
516
- const a = copy.shift();
517
- if (a === "--json") out.json = true;
518
- else if (a === "--cwd" && copy[0] !== undefined) out.cwd = path.resolve(String(copy.shift()));
519
- else if (a && a.startsWith("--cwd=")) out.cwd = path.resolve(a.slice("--cwd=".length));
520
- else if (a === "--help" || a === "-h") {
521
- printHelp();
522
- process.exit(EXIT_OK);
523
- } else {
524
- console.error(`unknown argument: ${a}`);
525
- process.exit(EXIT_USAGE);
526
- }
527
- }
528
- return out;
529
- }
530
-
531
- function parseRemoveArgs(rest) {
532
- const out = { cwd: null, only: null, dryRun: false, json: false };
533
- const copy = [...rest];
534
- while (copy.length) {
535
- const a = copy.shift();
536
- if (a === "--dry-run") out.dryRun = true;
537
- else if (a === "--json") out.json = true;
538
- else if (a === "--only" && copy[0] !== undefined) out.only = csv(copy.shift());
539
- else if (a && a.startsWith("--only=")) out.only = csv(a.slice("--only=".length));
540
- else if (a === "--cwd" && copy[0] !== undefined) out.cwd = path.resolve(String(copy.shift()));
541
- else if (a && a.startsWith("--cwd=")) out.cwd = path.resolve(a.slice("--cwd=".length));
542
- else if (a === "--help" || a === "-h") {
543
- printHelp();
544
- process.exit(EXIT_OK);
545
- } else {
546
- console.error(`unknown argument: ${a}`);
547
- process.exit(EXIT_USAGE);
548
- }
549
- }
550
- return out;
551
- }
552
-
553
- function csv(value) {
554
- return String(value || "")
555
- .split(",")
556
- .map((s) => s.trim())
557
- .filter(Boolean);
558
- }
559
-
560
- function emitSummary(ctx, args, payload, humanFn) {
561
- if (ctx.json || args.json) {
562
- console.log(JSON.stringify(payload, null, 2));
563
- } else {
564
- humanFn();
565
- }
566
- }