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