@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,562 @@
1
+ /**
2
+ * `shipctl knowledge` — manage workspace knowledge buckets.
3
+ *
4
+ * The canonical knowledge surface is now Ship-owned:
5
+ * ``knowledge_buckets`` contain ``bucket_articles`` and
6
+ * ``knowledge_sources`` records where each article came from. The
7
+ * historical ``init`` command remains as a compatibility wrapper for
8
+ * starter PRs, while ``bootstrap`` is the GitHub Actions entry point
9
+ * that opens the generated knowledge PR after wizard seed merge.
10
+ *
11
+ * Usage:
12
+ *
13
+ * shipctl knowledge fetch repository-context --workspace <id>
14
+ * shipctl knowledge bootstrap --workspace <id> --repo <id|owner/name>
15
+ * shipctl knowledge refresh-intel --workspace <id> --repo <id|owner/name>
16
+ *
17
+ * Auth: bearer token from ``SHIP_API_TOKEN`` (the same env var the
18
+ * console docs describe for CLI sessions minted under Settings →
19
+ * "Mint a CLI token"). We deliberately don't read ``SHIP_RUN_TOKEN`` —
20
+ * that's a short-lived pipeline handle, not a user PAT.
21
+ *
22
+ * Base URL resolution:
23
+ *
24
+ * 1. ``--base-url`` flag (explicit wins)
25
+ * 2. ``SHIP_WORKSPACE_API_BASE`` (workspace control plane)
26
+ * 3. ``SHIP_API_BASE`` (methodology API; only usable if the caller
27
+ * ran their own reverse-proxy that co-locates both)
28
+ * 4. ``https://api.ship.elmundi.com`` as the canonical production
29
+ * workspace API.
30
+ *
31
+ * Workspace + repo resolution:
32
+ *
33
+ * - ``--workspace`` pins a workspace id; otherwise we fetch
34
+ * ``GET /v1/workspaces`` and pick the only row. If there are
35
+ * multiple rows we abort with a helpful message so the caller
36
+ * either supplies ``--workspace`` or narrows their PAT.
37
+ * - ``--repo`` pins a repo id (uuid) or a full_name like
38
+ * ``owner/name``; otherwise we fetch
39
+ * ``GET /v1/workspaces/{ws}/repos`` and pick the most-recently
40
+ * activated row — the same heuristic the wizard uses, so
41
+ * ``shipctl knowledge init`` on a freshly-onboarded workspace
42
+ * seeds the repo the user just activated.
43
+ */
44
+
45
+ const VERSION = "v1";
46
+
47
+ /** Ship ships two starter buckets today — keep in lockstep with
48
+ * ``backend.app.services.catalog.KNOWLEDGE_STARTERS`` and
49
+ * ``console/src/lib/api/client.ts#KNOWLEDGE_STARTERS``. */
50
+ const KNOWN_SLUGS = ["code-style", "ui-runbook"];
51
+
52
+ /**
53
+ * @param {{baseUrl?: string, json?: boolean}} ctx
54
+ * @param {string[]} rest
55
+ */
56
+ export async function knowledgeCommand(ctx, rest) {
57
+ const [sub, ...args] = rest;
58
+ if (!sub || sub === "help" || sub === "-h" || sub === "--help") {
59
+ printKnowledgeHelp();
60
+ return;
61
+ }
62
+ if (sub === "init") {
63
+ await knowledgeInitCommand(ctx, args);
64
+ return;
65
+ }
66
+ if (sub === "fetch") {
67
+ await knowledgeFetchCommand(ctx, args);
68
+ return;
69
+ }
70
+ if (sub === "bootstrap") {
71
+ await knowledgeBootstrapCommand(ctx, args);
72
+ return;
73
+ }
74
+ if (sub === "refresh-intel" || sub === "refresh-context") {
75
+ await knowledgeRefreshIntelCommand(ctx, args);
76
+ return;
77
+ }
78
+ console.error(
79
+ `Unknown 'shipctl knowledge' subcommand: ${sub}\nRun: shipctl knowledge --help`,
80
+ );
81
+ process.exit(1);
82
+ }
83
+
84
+ function printKnowledgeHelp() {
85
+ console.log(`shipctl knowledge — manage workspace knowledge buckets (${VERSION})
86
+
87
+ SUBCOMMANDS
88
+ shipctl knowledge init [--workspace <id>] [--repo <id|owner/name>]
89
+ [--only <csv>] [--json]
90
+ shipctl knowledge fetch <bucket-slug> [--workspace <id>] [--json]
91
+ shipctl knowledge bootstrap [--workspace <id>] [--repo <id|owner/name>]
92
+ [--json]
93
+ shipctl knowledge refresh-intel [--workspace <id>] [--repo <id|owner/name>]
94
+ [--json]
95
+
96
+ INIT FLAGS
97
+ --workspace <id> Workspace UUID. Defaults to the only workspace
98
+ the caller's PAT can see.
99
+ --repo <ref> Workspace repo UUID, or GitHub 'owner/name'.
100
+ Defaults to the most-recently activated repo in
101
+ the resolved workspace.
102
+ --only <csv> Comma-separated starter slugs. Defaults to the
103
+ full catalog (${KNOWN_SLUGS.join(", ")}).
104
+ --base-url URL Workspace control-plane API. See env fallbacks.
105
+ --json Emit a machine-readable JSON summary.
106
+
107
+ ENV
108
+ SHIP_API_TOKEN Required. Bearer PAT minted at /settings.
109
+ SHIP_WORKSPACE_API_BASE Optional override for the control plane.
110
+ SHIP_API_BASE Fallback only (co-located proxies).
111
+
112
+ EXIT
113
+ 0 PR opened (or idempotently already present)
114
+ 1 arg / config error
115
+ 2 auth error (401)
116
+ 3 network / HTTP 5xx
117
+ `);
118
+ }
119
+
120
+ /**
121
+ * @param {{baseUrl?: string, json?: boolean}} ctx
122
+ * @param {string[]} args
123
+ */
124
+ async function knowledgeInitCommand(ctx, args) {
125
+ const opts = parseInitArgs(args);
126
+ const baseUrl = resolveBaseUrl(opts.baseUrl || ctx.baseUrl);
127
+ const token = process.env.SHIP_API_TOKEN || "";
128
+ if (!token) {
129
+ console.error(
130
+ "SHIP_API_TOKEN is required. Mint one at /settings in the Ship console.",
131
+ );
132
+ process.exit(1);
133
+ }
134
+
135
+ const selection = opts.only;
136
+ if (selection !== null) {
137
+ const unknown = selection.filter((s) => !KNOWN_SLUGS.includes(s));
138
+ if (unknown.length) {
139
+ console.error(
140
+ `Unknown knowledge slug(s): ${unknown.join(", ")}\nKnown: ${KNOWN_SLUGS.join(", ")}`,
141
+ );
142
+ process.exit(1);
143
+ }
144
+ }
145
+
146
+ let workspaceId = opts.workspace;
147
+ if (!workspaceId) {
148
+ workspaceId = await resolveSoleWorkspace(baseUrl, token);
149
+ }
150
+
151
+ const repoId = await resolveRepoId(baseUrl, token, workspaceId, opts.repo);
152
+
153
+ const body = selection === null ? {} : { selection };
154
+ const result = await apiPostJson(
155
+ baseUrl,
156
+ `/v1/workspaces/${encodeURIComponent(workspaceId)}/repos/${encodeURIComponent(repoId)}/knowledge_seed`,
157
+ body,
158
+ token,
159
+ );
160
+
161
+ if (ctx.json || opts.json) {
162
+ console.log(JSON.stringify(result, null, 2));
163
+ return;
164
+ }
165
+ const files = Array.isArray(result.files) ? result.files : [];
166
+ console.log(
167
+ `Seeded compatibility knowledge files for workspace ${workspaceId} / repo ${repoId}:\n` +
168
+ ` PR #${result.pr_number}: ${result.pr_url}\n` +
169
+ ` Branch: ${result.branch}\n` +
170
+ ` Files: ${files.join(", ") || "(none)"}\n` +
171
+ `\nShip-owned repository context is refreshed separately with:\n` +
172
+ ` shipctl knowledge refresh-intel --workspace ${workspaceId} --repo ${repoId}`,
173
+ );
174
+ }
175
+
176
+ async function knowledgeFetchCommand(ctx, args) {
177
+ const opts = parseFetchArgs(args);
178
+ const baseUrl = resolveBaseUrl(opts.baseUrl || ctx.baseUrl);
179
+ const token = requireToken();
180
+ let workspaceId = opts.workspace;
181
+ if (!workspaceId) {
182
+ workspaceId = await resolveSoleWorkspace(baseUrl, token);
183
+ }
184
+
185
+ const [bucket, articles, sources] = await Promise.all([
186
+ apiGetJson(
187
+ baseUrl,
188
+ `/v1/workspaces/${encodeURIComponent(workspaceId)}/buckets/${encodeURIComponent(opts.slug)}`,
189
+ token,
190
+ ),
191
+ apiGetJson(
192
+ baseUrl,
193
+ `/v1/workspaces/${encodeURIComponent(workspaceId)}/buckets/${encodeURIComponent(opts.slug)}/articles`,
194
+ token,
195
+ ),
196
+ apiGetJson(
197
+ baseUrl,
198
+ `/v1/workspaces/${encodeURIComponent(workspaceId)}/buckets/${encodeURIComponent(opts.slug)}/sources`,
199
+ token,
200
+ ),
201
+ ]);
202
+
203
+ const result = { bucket, articles, sources };
204
+ if (ctx.json || opts.json) {
205
+ console.log(JSON.stringify(result, null, 2));
206
+ return;
207
+ }
208
+
209
+ console.log(`${bucket.name} (${bucket.slug})`);
210
+ console.log(` scope: ${bucket.scope_kind} source: ${bucket.source_kind}`);
211
+ console.log(` articles: ${Array.isArray(articles) ? articles.length : 0}`);
212
+ console.log(` sources: ${Array.isArray(sources) ? sources.length : 0}`);
213
+ for (const article of Array.isArray(articles) ? articles : []) {
214
+ console.log(`\n## ${article.title} (${article.slug})`);
215
+ console.log(String(article.body_md || "").trim());
216
+ }
217
+ }
218
+
219
+ async function knowledgeRefreshIntelCommand(ctx, args) {
220
+ const opts = parseRefreshArgs(args);
221
+ const baseUrl = resolveBaseUrl(opts.baseUrl || ctx.baseUrl);
222
+ const token = requireToken();
223
+ let workspaceId = opts.workspace;
224
+ if (!workspaceId) {
225
+ workspaceId = await resolveSoleWorkspace(baseUrl, token);
226
+ }
227
+ const repoId = await resolveRepoId(baseUrl, token, workspaceId, opts.repo);
228
+ const result = await apiPostJson(
229
+ baseUrl,
230
+ `/v1/workspaces/${encodeURIComponent(workspaceId)}/repos/${encodeURIComponent(repoId)}/intel/harvest`,
231
+ {},
232
+ token,
233
+ );
234
+ if (ctx.json || opts.json) {
235
+ console.log(JSON.stringify(result, null, 2));
236
+ return;
237
+ }
238
+ const where = result.enqueued
239
+ ? `queued as job ${result.job_id || "(unknown)"}`
240
+ : `completed inline, intel_id=${result.intel_id || "(none)"}`;
241
+ console.log(
242
+ `Repository context refresh for workspace ${workspaceId} / repo ${repoId}: ${where}\n` +
243
+ `Fetch it with: shipctl knowledge fetch repository-context --workspace ${workspaceId}`,
244
+ );
245
+ }
246
+
247
+ async function knowledgeBootstrapCommand(ctx, args) {
248
+ const opts = parseBootstrapArgs(args);
249
+ const baseUrl = resolveBaseUrl(opts.baseUrl || ctx.baseUrl);
250
+ const token = requireToken();
251
+ let workspaceId = opts.workspace;
252
+ if (!workspaceId) {
253
+ workspaceId = await resolveSoleWorkspace(baseUrl, token);
254
+ }
255
+ const repoId = await resolveRepoId(baseUrl, token, workspaceId, opts.repo);
256
+ const result = await apiPostJson(
257
+ baseUrl,
258
+ `/v1/workspaces/${encodeURIComponent(workspaceId)}/repos/${encodeURIComponent(repoId)}/knowledge/bootstrap`,
259
+ {},
260
+ token,
261
+ );
262
+ if (ctx.json || opts.json) {
263
+ console.log(JSON.stringify(result, null, 2));
264
+ return;
265
+ }
266
+ const files = Array.isArray(result.files) ? result.files : [];
267
+ if (result.status === "already_done") {
268
+ console.log(
269
+ `Knowledge bootstrap already completed for workspace ${workspaceId} / repo ${repoId}:\n` +
270
+ ` PR #${result.pr_number || "?"}: ${result.pr_url || "(unknown)"}`,
271
+ );
272
+ return;
273
+ }
274
+ console.log(
275
+ `Knowledge bootstrap opened PR #${result.pr_number} for workspace ${workspaceId} / repo ${repoId}:\n` +
276
+ ` ${result.pr_url}\n` +
277
+ ` Files: ${files.join(", ") || "(none)"}`,
278
+ );
279
+ }
280
+
281
+ /**
282
+ * @param {string[]} args
283
+ * @returns {{
284
+ * workspace: string|null,
285
+ * repo: string|null,
286
+ * only: string[]|null,
287
+ * baseUrl: string|null,
288
+ * json: boolean,
289
+ * }}
290
+ */
291
+ function parseInitArgs(args) {
292
+ const out = {
293
+ workspace: null,
294
+ repo: null,
295
+ only: null,
296
+ baseUrl: null,
297
+ json: false,
298
+ };
299
+ const copy = [...args];
300
+ const consume = (flag, key) => {
301
+ if (copy[0] === flag && copy[1] !== undefined) {
302
+ copy.shift();
303
+ out[key] = String(copy.shift());
304
+ return true;
305
+ }
306
+ const p = `${flag}=`;
307
+ if (copy[0] && copy[0].startsWith(p)) {
308
+ out[key] = copy[0].slice(p.length);
309
+ copy.shift();
310
+ return true;
311
+ }
312
+ return false;
313
+ };
314
+ while (copy.length) {
315
+ if (
316
+ consume("--workspace", "workspace") ||
317
+ consume("--repo", "repo") ||
318
+ consume("--only", "only") ||
319
+ consume("--base-url", "baseUrl")
320
+ ) {
321
+ continue;
322
+ }
323
+ if (copy[0] === "--json") {
324
+ out.json = true;
325
+ copy.shift();
326
+ continue;
327
+ }
328
+ if (copy[0] === "--help" || copy[0] === "-h") {
329
+ printKnowledgeHelp();
330
+ process.exit(0);
331
+ }
332
+ console.error(`Unknown flag: ${copy[0]}`);
333
+ process.exit(1);
334
+ }
335
+ if (out.only !== null) {
336
+ out.only = String(out.only)
337
+ .split(",")
338
+ .map((s) => s.trim())
339
+ .filter(Boolean);
340
+ }
341
+ return out;
342
+ }
343
+
344
+ function parseFetchArgs(args) {
345
+ const out = parseCommonArgs(args, { slug: null });
346
+ if (!out.slug) {
347
+ console.error("Usage: shipctl knowledge fetch <bucket-slug> [--workspace <id>] [--json]");
348
+ process.exit(1);
349
+ }
350
+ return out;
351
+ }
352
+
353
+ function parseRefreshArgs(args) {
354
+ return parseCommonArgs(args, { repo: null });
355
+ }
356
+
357
+ function parseBootstrapArgs(args) {
358
+ return parseCommonArgs(args, { repo: null });
359
+ }
360
+
361
+ function parseCommonArgs(args, extra) {
362
+ const out = {
363
+ workspace: null,
364
+ baseUrl: null,
365
+ json: false,
366
+ ...extra,
367
+ };
368
+ const copy = [...args];
369
+ const consume = (flag, key) => {
370
+ if (copy[0] === flag && copy[1] !== undefined) {
371
+ copy.shift();
372
+ out[key] = String(copy.shift());
373
+ return true;
374
+ }
375
+ const p = `${flag}=`;
376
+ if (copy[0] && copy[0].startsWith(p)) {
377
+ out[key] = copy[0].slice(p.length);
378
+ copy.shift();
379
+ return true;
380
+ }
381
+ return false;
382
+ };
383
+ while (copy.length) {
384
+ if (
385
+ consume("--workspace", "workspace") ||
386
+ consume("--repo", "repo") ||
387
+ consume("--base-url", "baseUrl")
388
+ ) {
389
+ continue;
390
+ }
391
+ if (copy[0] === "--json") {
392
+ out.json = true;
393
+ copy.shift();
394
+ continue;
395
+ }
396
+ if (!String(copy[0]).startsWith("-") && "slug" in out && out.slug === null) {
397
+ out.slug = String(copy.shift());
398
+ continue;
399
+ }
400
+ if (copy[0] === "--help" || copy[0] === "-h") {
401
+ printKnowledgeHelp();
402
+ process.exit(0);
403
+ }
404
+ console.error(`Unknown flag: ${copy[0]}`);
405
+ process.exit(1);
406
+ }
407
+ return out;
408
+ }
409
+
410
+ function requireToken() {
411
+ const token = process.env.SHIP_API_TOKEN || "";
412
+ if (!token) {
413
+ console.error(
414
+ "SHIP_API_TOKEN is required. Mint one at /settings in the Ship console.",
415
+ );
416
+ process.exit(1);
417
+ }
418
+ return token;
419
+ }
420
+
421
+ /**
422
+ * @param {string|null|undefined} explicit
423
+ * @returns {string}
424
+ */
425
+ function resolveBaseUrl(explicit) {
426
+ if (explicit) return explicit.replace(/\/+$/, "");
427
+ const envWorkspace = process.env.SHIP_WORKSPACE_API_BASE;
428
+ if (envWorkspace) return envWorkspace.replace(/\/+$/, "");
429
+ const envGeneric = process.env.SHIP_API_BASE;
430
+ if (envGeneric) return envGeneric.replace(/\/+$/, "");
431
+ return "https://api.ship.elmundi.com";
432
+ }
433
+
434
+ /**
435
+ * @param {string} baseUrl
436
+ * @param {string} token
437
+ * @returns {Promise<string>}
438
+ */
439
+ async function resolveSoleWorkspace(baseUrl, token) {
440
+ const rows = await apiGetJson(baseUrl, "/v1/workspaces", token);
441
+ if (!Array.isArray(rows) || rows.length === 0) {
442
+ console.error("No workspaces visible to this token.");
443
+ process.exit(1);
444
+ }
445
+ if (rows.length > 1) {
446
+ const ids = rows.map((r) => `${r.id} (${r.name ?? "?"})`).join("\n ");
447
+ console.error(
448
+ `Token has access to more than one workspace; pass --workspace <id>.\n ${ids}`,
449
+ );
450
+ process.exit(1);
451
+ }
452
+ return String(rows[0].id);
453
+ }
454
+
455
+ /**
456
+ * @param {string} baseUrl
457
+ * @param {string} token
458
+ * @param {string} workspaceId
459
+ * @param {string|null} hint
460
+ * @returns {Promise<string>}
461
+ */
462
+ async function resolveRepoId(baseUrl, token, workspaceId, hint) {
463
+ // Direct UUID? Accept it verbatim — avoids a list call.
464
+ if (hint && /^[0-9a-fA-F-]{32,36}$/.test(hint) && hint.includes("-")) {
465
+ return hint;
466
+ }
467
+ const rows = await apiGetJson(
468
+ baseUrl,
469
+ `/v1/workspaces/${encodeURIComponent(workspaceId)}/repos`,
470
+ token,
471
+ );
472
+ if (!Array.isArray(rows) || rows.length === 0) {
473
+ console.error(
474
+ `Workspace ${workspaceId} has no activated repos. Activate one in the console first.`,
475
+ );
476
+ process.exit(1);
477
+ }
478
+ if (hint) {
479
+ const match = rows.find(
480
+ (r) =>
481
+ r.full_name === hint ||
482
+ `${r.owner ?? ""}/${r.name ?? ""}` === hint ||
483
+ r.id === hint,
484
+ );
485
+ if (!match) {
486
+ const known = rows.map((r) => r.full_name ?? r.id).join(", ");
487
+ console.error(
488
+ `--repo ${hint} doesn't match any activated repo in workspace ${workspaceId}.\nKnown: ${known}`,
489
+ );
490
+ process.exit(1);
491
+ }
492
+ return String(match.id);
493
+ }
494
+ const sorted = [...rows].sort((a, b) => {
495
+ const ax = a.activated_at ? Date.parse(a.activated_at) : 0;
496
+ const bx = b.activated_at ? Date.parse(b.activated_at) : 0;
497
+ return bx - ax;
498
+ });
499
+ return String(sorted[0].id);
500
+ }
501
+
502
+ /**
503
+ * @param {string} baseUrl
504
+ * @param {string} path
505
+ * @param {string} token
506
+ */
507
+ async function apiGetJson(baseUrl, path, token) {
508
+ return apiRequest(baseUrl, path, "GET", token, null);
509
+ }
510
+
511
+ /**
512
+ * @param {string} baseUrl
513
+ * @param {string} path
514
+ * @param {Record<string, unknown>} body
515
+ * @param {string} token
516
+ */
517
+ async function apiPostJson(baseUrl, path, body, token) {
518
+ return apiRequest(baseUrl, path, "POST", token, body);
519
+ }
520
+
521
+ /**
522
+ * @param {string} baseUrl
523
+ * @param {string} path
524
+ * @param {string} method
525
+ * @param {string} token
526
+ * @param {Record<string, unknown>|null} body
527
+ */
528
+ async function apiRequest(baseUrl, path, method, token, body) {
529
+ const url = `${baseUrl}${path}`;
530
+ let res;
531
+ try {
532
+ res = await fetch(url, {
533
+ method,
534
+ headers: {
535
+ "Content-Type": "application/json",
536
+ Accept: "application/json",
537
+ Authorization: `Bearer ${token}`,
538
+ },
539
+ body: body === null ? undefined : JSON.stringify(body),
540
+ });
541
+ } catch (err) {
542
+ console.error(`Network error calling ${url}: ${err instanceof Error ? err.message : err}`);
543
+ process.exit(3);
544
+ }
545
+ const text = await res.text();
546
+ let data = null;
547
+ try {
548
+ data = text ? JSON.parse(text) : null;
549
+ } catch {
550
+ data = text;
551
+ }
552
+ if (res.ok) return data;
553
+ if (res.status === 401) {
554
+ console.error(
555
+ `HTTP 401 on ${method} ${url} — SHIP_API_TOKEN is missing, expired, or lacks workspace access.`,
556
+ );
557
+ process.exit(2);
558
+ }
559
+ const msg = typeof data === "string" ? data : JSON.stringify(data);
560
+ console.error(`HTTP ${res.status} ${res.statusText} on ${method} ${url}\n${msg}`);
561
+ process.exit(res.status >= 500 ? 3 : 1);
562
+ }