@elmundi/ship-cli 0.8.0 → 0.11.2

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