@elmundi/ship-cli 0.8.1 → 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 +415 -22
  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,192 @@
1
+ /**
2
+ * `shipctl kickoff` — print the markdown body of the `kickoff` pattern
3
+ * (or another pattern id) for piping into the customer’s agent in CI.
4
+ *
5
+ * Resolution order for the methodology host:
6
+ * 1. Global `--base-url` (methodology API root, same as `pattern fetch`).
7
+ * 2. `.ship/config.yml` → `api.base_url` with `/api/methodology` appended
8
+ * when absent.
9
+ * 3. `SHIP_API_BASE` / default public host.
10
+ *
11
+ * When the process cwd (or `--cwd`) is inside the Ship monorepo, we read
12
+ * `artifacts/patterns/<id>/ARTIFACT.md` from disk so local dev matches prod.
13
+ */
14
+
15
+ import path from "node:path";
16
+ import { fetchArtifact } from "../http.mjs";
17
+ import { readConfig, findShipRoot } from "../config/io.mjs";
18
+ import { resolveShipRepoRootForCatalog } from "../find-ship-root.mjs";
19
+ import { readArtifactFile } from "../artifacts/fs-index.mjs";
20
+
21
+ function printKickoffHelp() {
22
+ console.log(`shipctl kickoff — print a pattern body for piping into your agent (CI).
23
+
24
+ USAGE
25
+ shipctl kickoff [--pattern <id>] [--version <semver>] [--raw] [--json] [--cwd <dir>]
26
+
27
+ DEFAULTS
28
+ --pattern kickoff
29
+
30
+ FLAGS
31
+ --pattern Catalog pattern id (folder under artifacts/patterns/).
32
+ --version Optional pinned version (POST /fetch).
33
+ --raw Print the full ARTIFACT.md including YAML front matter.
34
+ --json Emit { pattern_id, body, agent_provider? } JSON.
35
+ --cwd Repo root to find .ship/config.yml (default: search upward).
36
+
37
+ The default output is markdown body only (front matter stripped) on stdout.
38
+ When .ship/config.yml sets stack.agent.provider, a one-line hint is written
39
+ to stderr so logs show which agent the repo is wired for — unless --json.
40
+
41
+ EXAMPLE (workflow step)
42
+ shipctl kickoff --pattern kickoff > kickoff.md
43
+ # …concatenate workload pattern + kickoff.md into your agent invocation…
44
+ `);
45
+ }
46
+
47
+ function stripFrontmatter(full) {
48
+ if (!full || !full.startsWith("---\n")) return full;
49
+ const end = full.indexOf("\n---\n", 4);
50
+ if (end === -1) return full;
51
+ return full.slice(end + "\n---\n".length);
52
+ }
53
+
54
+ /** @param {string} methodologyBase */
55
+ function resolveMethodologyBase(ctx, config) {
56
+ const fromFlag = ctx.baseUrl;
57
+ const raw = config?.api?.base_url;
58
+ if (typeof raw === "string" && raw.trim()) {
59
+ const u = raw.replace(/\/$/, "");
60
+ return u.includes("/api/methodology") ? u : `${u}/api/methodology`;
61
+ }
62
+ return fromFlag;
63
+ }
64
+
65
+ function parseKickoffArgs(rest) {
66
+ const out = {
67
+ patternId: "kickoff",
68
+ version: null,
69
+ raw: false,
70
+ json: false,
71
+ help: false,
72
+ cwd: process.cwd(),
73
+ };
74
+ const copy = [...rest];
75
+ while (copy.length) {
76
+ const a = copy[0];
77
+ if (a === "--help" || a === "-h") {
78
+ out.help = true;
79
+ copy.shift();
80
+ continue;
81
+ }
82
+ if (a === "--raw") {
83
+ out.raw = true;
84
+ copy.shift();
85
+ continue;
86
+ }
87
+ if (a === "--json") {
88
+ out.json = true;
89
+ copy.shift();
90
+ continue;
91
+ }
92
+ if (a === "--pattern" && copy[1]) {
93
+ copy.shift();
94
+ out.patternId = String(copy.shift());
95
+ continue;
96
+ }
97
+ if (a.startsWith("--pattern=")) {
98
+ out.patternId = a.slice("--pattern=".length);
99
+ copy.shift();
100
+ continue;
101
+ }
102
+ if (a === "--version" && copy[1]) {
103
+ copy.shift();
104
+ out.version = String(copy.shift());
105
+ continue;
106
+ }
107
+ if (a.startsWith("--version=")) {
108
+ out.version = a.slice("--version=".length);
109
+ copy.shift();
110
+ continue;
111
+ }
112
+ if (a === "--cwd" && copy[1]) {
113
+ copy.shift();
114
+ out.cwd = path.resolve(String(copy.shift()));
115
+ continue;
116
+ }
117
+ if (a.startsWith("--cwd=")) {
118
+ out.cwd = path.resolve(a.slice("--cwd=".length));
119
+ copy.shift();
120
+ continue;
121
+ }
122
+ console.error(`unknown argument: ${a}\nRun: shipctl kickoff --help`);
123
+ process.exit(2);
124
+ }
125
+ return out;
126
+ }
127
+
128
+ export async function kickoffCommand(ctx, rest) {
129
+ const args = parseKickoffArgs(rest);
130
+ if (args.help) {
131
+ printKickoffHelp();
132
+ return;
133
+ }
134
+
135
+ const ctx2 = ctx;
136
+
137
+ /** @type {object|null} */
138
+ let config = null;
139
+ const root = findShipRoot(args.cwd);
140
+ if (root) {
141
+ try {
142
+ config = readConfig(root).config;
143
+ } catch {
144
+ config = null;
145
+ }
146
+ }
147
+
148
+ const methodologyBase = resolveMethodologyBase(ctx2, config);
149
+ const agentProvider =
150
+ config?.stack?.agent && typeof config.stack.agent === "object"
151
+ ? config.stack.agent.provider
152
+ : null;
153
+
154
+ /** @type {string|undefined} */
155
+ let fullText;
156
+ const shipRepo = resolveShipRepoRootForCatalog();
157
+ if (shipRepo) {
158
+ const file = readArtifactFile(shipRepo, "pattern", args.patternId);
159
+ if (file) fullText = file.content;
160
+ }
161
+ if (fullText === undefined) {
162
+ const { content } = await fetchArtifact(
163
+ methodologyBase,
164
+ "pattern",
165
+ args.patternId,
166
+ args.version || undefined,
167
+ );
168
+ fullText = content;
169
+ }
170
+
171
+ const body = args.raw ? fullText : stripFrontmatter(fullText);
172
+
173
+ if (args.json) {
174
+ console.log(
175
+ JSON.stringify(
176
+ {
177
+ pattern_id: args.patternId,
178
+ body,
179
+ agent_provider: agentProvider || null,
180
+ },
181
+ null,
182
+ 2,
183
+ ),
184
+ );
185
+ return;
186
+ }
187
+
188
+ if (agentProvider && typeof agentProvider === "string") {
189
+ console.error(`# ship: stack.agent.provider=${agentProvider}`);
190
+ }
191
+ process.stdout.write(body.endsWith("\n") ? body : `${body}\n`);
192
+ }
@@ -0,0 +1,368 @@
1
+ /**
2
+ * `shipctl knowledge` — manage workspace knowledge buckets.
3
+ *
4
+ * Today this is a thin wrapper around the backend's
5
+ * ``POST /v1/workspaces/{ws}/repos/{repo}/knowledge_seed`` endpoint —
6
+ * the same one the onboarding wizard's step 4 hits. It opens a single
7
+ * PR in the tenant repo that drops starter markdown under
8
+ * ``.ship/knowledge/``:
9
+ *
10
+ * - ``code-style.md`` — languages, naming, imports, tests, review checklist
11
+ * - ``ui-runbook.md`` — design-system usage, states, perf budgets
12
+ *
13
+ * The CLI exists so CI pipelines (and re-adoption flows where the UI
14
+ * wizard isn't the natural entry point) can wire the buckets without a
15
+ * browser round-trip.
16
+ *
17
+ * Usage:
18
+ *
19
+ * shipctl knowledge init [--workspace <id>] [--repo <id>]
20
+ * [--only code-style,ui-runbook]
21
+ * [--base-url https://api.ship.example.com]
22
+ * [--json]
23
+ *
24
+ * Auth: bearer token from ``SHIP_API_TOKEN`` (the same env var the
25
+ * console docs describe for CLI sessions minted under Settings →
26
+ * "Mint a CLI token"). We deliberately don't read ``SHIP_RUN_TOKEN`` —
27
+ * that's a short-lived pipeline handle, not a user PAT.
28
+ *
29
+ * Base URL resolution:
30
+ *
31
+ * 1. ``--base-url`` flag (explicit wins)
32
+ * 2. ``SHIP_WORKSPACE_API_BASE`` (workspace control plane)
33
+ * 3. ``SHIP_API_BASE`` (methodology API; only usable if the caller
34
+ * ran their own reverse-proxy that co-locates both)
35
+ * 4. ``https://api.ship.elmundi.com`` as the canonical production
36
+ * workspace API.
37
+ *
38
+ * Workspace + repo resolution:
39
+ *
40
+ * - ``--workspace`` pins a workspace id; otherwise we fetch
41
+ * ``GET /v1/workspaces`` and pick the only row. If there are
42
+ * multiple rows we abort with a helpful message so the caller
43
+ * either supplies ``--workspace`` or narrows their PAT.
44
+ * - ``--repo`` pins a repo id (uuid) or a full_name like
45
+ * ``owner/name``; otherwise we fetch
46
+ * ``GET /v1/workspaces/{ws}/repos`` and pick the most-recently
47
+ * activated row — the same heuristic the wizard uses, so
48
+ * ``shipctl knowledge init`` on a freshly-onboarded workspace
49
+ * seeds the repo the user just activated.
50
+ */
51
+
52
+ const VERSION = "v1";
53
+
54
+ /** Ship ships two starter buckets today — keep in lockstep with
55
+ * ``backend.app.services.catalog.KNOWLEDGE_STARTERS`` and
56
+ * ``console/src/lib/api/client.ts#KNOWLEDGE_STARTERS``. */
57
+ const KNOWN_SLUGS = ["code-style", "ui-runbook"];
58
+
59
+ /**
60
+ * @param {{baseUrl?: string, json?: boolean}} ctx
61
+ * @param {string[]} rest
62
+ */
63
+ export async function knowledgeCommand(ctx, rest) {
64
+ const [sub, ...args] = rest;
65
+ if (!sub || sub === "help" || sub === "-h" || sub === "--help") {
66
+ printKnowledgeHelp();
67
+ return;
68
+ }
69
+ if (sub === "init") {
70
+ await knowledgeInitCommand(ctx, args);
71
+ return;
72
+ }
73
+ console.error(
74
+ `Unknown 'shipctl knowledge' subcommand: ${sub}\nRun: shipctl knowledge --help`,
75
+ );
76
+ process.exit(1);
77
+ }
78
+
79
+ function printKnowledgeHelp() {
80
+ console.log(`shipctl knowledge — manage workspace knowledge buckets (${VERSION})
81
+
82
+ SUBCOMMANDS
83
+ shipctl knowledge init [--workspace <id>] [--repo <id|owner/name>]
84
+ [--only <csv>] [--json]
85
+
86
+ INIT FLAGS
87
+ --workspace <id> Workspace UUID. Defaults to the only workspace
88
+ the caller's PAT can see.
89
+ --repo <ref> Workspace repo UUID, or GitHub 'owner/name'.
90
+ Defaults to the most-recently activated repo in
91
+ the resolved workspace.
92
+ --only <csv> Comma-separated starter slugs. Defaults to the
93
+ full catalog (${KNOWN_SLUGS.join(", ")}).
94
+ --base-url URL Workspace control-plane API. See env fallbacks.
95
+ --json Emit a machine-readable JSON summary.
96
+
97
+ ENV
98
+ SHIP_API_TOKEN Required. Bearer PAT minted at /settings.
99
+ SHIP_WORKSPACE_API_BASE Optional override for the control plane.
100
+ SHIP_API_BASE Fallback only (co-located proxies).
101
+
102
+ EXIT
103
+ 0 PR opened (or idempotently already present)
104
+ 1 arg / config error
105
+ 2 auth error (401)
106
+ 3 network / HTTP 5xx
107
+ `);
108
+ }
109
+
110
+ /**
111
+ * @param {{baseUrl?: string, json?: boolean}} ctx
112
+ * @param {string[]} args
113
+ */
114
+ async function knowledgeInitCommand(ctx, args) {
115
+ const opts = parseInitArgs(args);
116
+ const baseUrl = resolveBaseUrl(opts.baseUrl || ctx.baseUrl);
117
+ const token = process.env.SHIP_API_TOKEN || "";
118
+ if (!token) {
119
+ console.error(
120
+ "SHIP_API_TOKEN is required. Mint one at /settings in the Ship console.",
121
+ );
122
+ process.exit(1);
123
+ }
124
+
125
+ const selection = opts.only;
126
+ if (selection !== null) {
127
+ const unknown = selection.filter((s) => !KNOWN_SLUGS.includes(s));
128
+ if (unknown.length) {
129
+ console.error(
130
+ `Unknown knowledge slug(s): ${unknown.join(", ")}\nKnown: ${KNOWN_SLUGS.join(", ")}`,
131
+ );
132
+ process.exit(1);
133
+ }
134
+ }
135
+
136
+ let workspaceId = opts.workspace;
137
+ if (!workspaceId) {
138
+ workspaceId = await resolveSoleWorkspace(baseUrl, token);
139
+ }
140
+
141
+ const repoId = await resolveRepoId(baseUrl, token, workspaceId, opts.repo);
142
+
143
+ const body = selection === null ? {} : { selection };
144
+ const result = await apiPostJson(
145
+ baseUrl,
146
+ `/v1/workspaces/${encodeURIComponent(workspaceId)}/repos/${encodeURIComponent(repoId)}/knowledge_seed`,
147
+ body,
148
+ token,
149
+ );
150
+
151
+ if (ctx.json || opts.json) {
152
+ console.log(JSON.stringify(result, null, 2));
153
+ return;
154
+ }
155
+ const files = Array.isArray(result.files) ? result.files : [];
156
+ console.log(
157
+ `Seeded knowledge buckets for workspace ${workspaceId} / repo ${repoId}:\n` +
158
+ ` PR #${result.pr_number}: ${result.pr_url}\n` +
159
+ ` Branch: ${result.branch}\n` +
160
+ ` Files: ${files.join(", ") || "(none)"}`,
161
+ );
162
+ }
163
+
164
+ /**
165
+ * @param {string[]} args
166
+ * @returns {{
167
+ * workspace: string|null,
168
+ * repo: string|null,
169
+ * only: string[]|null,
170
+ * baseUrl: string|null,
171
+ * json: boolean,
172
+ * }}
173
+ */
174
+ function parseInitArgs(args) {
175
+ const out = {
176
+ workspace: null,
177
+ repo: null,
178
+ only: null,
179
+ baseUrl: null,
180
+ json: false,
181
+ };
182
+ const copy = [...args];
183
+ const consume = (flag, key) => {
184
+ if (copy[0] === flag && copy[1] !== undefined) {
185
+ copy.shift();
186
+ out[key] = String(copy.shift());
187
+ return true;
188
+ }
189
+ const p = `${flag}=`;
190
+ if (copy[0] && copy[0].startsWith(p)) {
191
+ out[key] = copy[0].slice(p.length);
192
+ copy.shift();
193
+ return true;
194
+ }
195
+ return false;
196
+ };
197
+ while (copy.length) {
198
+ if (
199
+ consume("--workspace", "workspace") ||
200
+ consume("--repo", "repo") ||
201
+ consume("--only", "only") ||
202
+ consume("--base-url", "baseUrl")
203
+ ) {
204
+ continue;
205
+ }
206
+ if (copy[0] === "--json") {
207
+ out.json = true;
208
+ copy.shift();
209
+ continue;
210
+ }
211
+ if (copy[0] === "--help" || copy[0] === "-h") {
212
+ printKnowledgeHelp();
213
+ process.exit(0);
214
+ }
215
+ console.error(`Unknown flag: ${copy[0]}`);
216
+ process.exit(1);
217
+ }
218
+ if (out.only !== null) {
219
+ out.only = String(out.only)
220
+ .split(",")
221
+ .map((s) => s.trim())
222
+ .filter(Boolean);
223
+ }
224
+ return out;
225
+ }
226
+
227
+ /**
228
+ * @param {string|null|undefined} explicit
229
+ * @returns {string}
230
+ */
231
+ function resolveBaseUrl(explicit) {
232
+ if (explicit) return explicit.replace(/\/+$/, "");
233
+ const envWorkspace = process.env.SHIP_WORKSPACE_API_BASE;
234
+ if (envWorkspace) return envWorkspace.replace(/\/+$/, "");
235
+ const envGeneric = process.env.SHIP_API_BASE;
236
+ if (envGeneric) return envGeneric.replace(/\/+$/, "");
237
+ return "https://api.ship.elmundi.com";
238
+ }
239
+
240
+ /**
241
+ * @param {string} baseUrl
242
+ * @param {string} token
243
+ * @returns {Promise<string>}
244
+ */
245
+ async function resolveSoleWorkspace(baseUrl, token) {
246
+ const rows = await apiGetJson(baseUrl, "/v1/workspaces", token);
247
+ if (!Array.isArray(rows) || rows.length === 0) {
248
+ console.error("No workspaces visible to this token.");
249
+ process.exit(1);
250
+ }
251
+ if (rows.length > 1) {
252
+ const ids = rows.map((r) => `${r.id} (${r.name ?? "?"})`).join("\n ");
253
+ console.error(
254
+ `Token has access to more than one workspace; pass --workspace <id>.\n ${ids}`,
255
+ );
256
+ process.exit(1);
257
+ }
258
+ return String(rows[0].id);
259
+ }
260
+
261
+ /**
262
+ * @param {string} baseUrl
263
+ * @param {string} token
264
+ * @param {string} workspaceId
265
+ * @param {string|null} hint
266
+ * @returns {Promise<string>}
267
+ */
268
+ async function resolveRepoId(baseUrl, token, workspaceId, hint) {
269
+ // Direct UUID? Accept it verbatim — avoids a list call.
270
+ if (hint && /^[0-9a-fA-F-]{32,36}$/.test(hint) && hint.includes("-")) {
271
+ return hint;
272
+ }
273
+ const rows = await apiGetJson(
274
+ baseUrl,
275
+ `/v1/workspaces/${encodeURIComponent(workspaceId)}/repos`,
276
+ token,
277
+ );
278
+ if (!Array.isArray(rows) || rows.length === 0) {
279
+ console.error(
280
+ `Workspace ${workspaceId} has no activated repos. Activate one in the console first.`,
281
+ );
282
+ process.exit(1);
283
+ }
284
+ if (hint) {
285
+ const match = rows.find(
286
+ (r) =>
287
+ r.full_name === hint ||
288
+ `${r.owner ?? ""}/${r.name ?? ""}` === hint ||
289
+ r.id === hint,
290
+ );
291
+ if (!match) {
292
+ const known = rows.map((r) => r.full_name ?? r.id).join(", ");
293
+ console.error(
294
+ `--repo ${hint} doesn't match any activated repo in workspace ${workspaceId}.\nKnown: ${known}`,
295
+ );
296
+ process.exit(1);
297
+ }
298
+ return String(match.id);
299
+ }
300
+ const sorted = [...rows].sort((a, b) => {
301
+ const ax = a.activated_at ? Date.parse(a.activated_at) : 0;
302
+ const bx = b.activated_at ? Date.parse(b.activated_at) : 0;
303
+ return bx - ax;
304
+ });
305
+ return String(sorted[0].id);
306
+ }
307
+
308
+ /**
309
+ * @param {string} baseUrl
310
+ * @param {string} path
311
+ * @param {string} token
312
+ */
313
+ async function apiGetJson(baseUrl, path, token) {
314
+ return apiRequest(baseUrl, path, "GET", token, null);
315
+ }
316
+
317
+ /**
318
+ * @param {string} baseUrl
319
+ * @param {string} path
320
+ * @param {Record<string, unknown>} body
321
+ * @param {string} token
322
+ */
323
+ async function apiPostJson(baseUrl, path, body, token) {
324
+ return apiRequest(baseUrl, path, "POST", token, body);
325
+ }
326
+
327
+ /**
328
+ * @param {string} baseUrl
329
+ * @param {string} path
330
+ * @param {string} method
331
+ * @param {string} token
332
+ * @param {Record<string, unknown>|null} body
333
+ */
334
+ async function apiRequest(baseUrl, path, method, token, body) {
335
+ const url = `${baseUrl}${path}`;
336
+ let res;
337
+ try {
338
+ res = await fetch(url, {
339
+ method,
340
+ headers: {
341
+ "Content-Type": "application/json",
342
+ Accept: "application/json",
343
+ Authorization: `Bearer ${token}`,
344
+ },
345
+ body: body === null ? undefined : JSON.stringify(body),
346
+ });
347
+ } catch (err) {
348
+ console.error(`Network error calling ${url}: ${err instanceof Error ? err.message : err}`);
349
+ process.exit(3);
350
+ }
351
+ const text = await res.text();
352
+ let data = null;
353
+ try {
354
+ data = text ? JSON.parse(text) : null;
355
+ } catch {
356
+ data = text;
357
+ }
358
+ if (res.ok) return data;
359
+ if (res.status === 401) {
360
+ console.error(
361
+ `HTTP 401 on ${method} ${url} — SHIP_API_TOKEN is missing, expired, or lacks workspace access.`,
362
+ );
363
+ process.exit(2);
364
+ }
365
+ const msg = typeof data === "string" ? data : JSON.stringify(data);
366
+ console.error(`HTTP ${res.status} ${res.statusText} on ${method} ${url}\n${msg}`);
367
+ process.exit(res.status >= 500 ? 3 : 1);
368
+ }