@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,742 +0,0 @@
1
- /**
2
- * `shipctl callback` — report the terminal status of a pipeline run back
3
- * to Ship.
4
- *
5
- * The customer's GitHub Actions workflow runs this in an `if: always()`
6
- * step at the end of the job. It replaces the 12-line `curl + HEREDOC`
7
- * boilerplate the previous starter workflows shipped with, so adopters
8
- * get a one-liner and a versioned CLI instead of hand-rolled JSON that
9
- * has silently broken every time Ship evolves the callback contract.
10
- *
11
- * URL resolution (first hit wins):
12
- * 1. `--callback-url <url>` flag
13
- * 2. `SHIP_CALLBACK_URL` env (what the existing workflow.yml injects)
14
- * 3. `--base-url <https://api.ship.example.com>` + `--run-id <uuid>`
15
- * (or `SHIP_API_BASE` + `SHIP_RUN_ID` envs) → constructed as
16
- * `{base}/v1/pipelines/runs/{run_id}/result`.
17
- *
18
- * Auth: exclusively the bearer token minted by Ship at dispatch time.
19
- * - Required env: `SHIP_RUN_TOKEN`. We refuse to fall back to
20
- * `SHIP_API_TOKEN` (the long-lived operator token used elsewhere in
21
- * this CLI) because a workflow-context callback must *only* use the
22
- * short-lived, run-scoped JWT. Cross-auth would silently hide bugs.
23
- *
24
- * This is intentionally **not** mounted under the `base-url` global flag
25
- * (which defaults to the public methodology host); run callbacks hit the
26
- * orchestration API (`api.ship.elmundi.com`), a different origin, so we
27
- * take the URL directly from the run context Ship injected.
28
- *
29
- * RFC-0010 Phase 3 (P3-03) extended this command to emit the new
30
- * `RunSummary` contract on top of the existing `status`/`summary`/
31
- * `metrics` body. Two equivalent code paths feed the same outcome:
32
- *
33
- * - Per-field flags: `--outcome-text`, `--severity SEV=N`,
34
- * `--artifact TYPE:TITLE[:REF]`, `--requires-approval`,
35
- * `--approval-payload @file.json`, `--escalation TYPE:REASON`,
36
- * `--findings-count N`. Composes well with bash heredoc / per-step
37
- * authoring inside a workflow.
38
- * - Bulk env input: `SHIP_RUN_OUTCOME` (inline JSON object) or
39
- * `SHIP_RUN_OUTCOME_FILE` (path to JSON file). Suits agents that
40
- * emit a full RunSummary blob on stdout.
41
- *
42
- * If both env and flags are present we MERGE — flags win on collision
43
- * (per-field shallow override at the top, per-key inside
44
- * `findings_by_severity`). Backwards compat is guaranteed: when none of
45
- * the new inputs are present, the request body is byte-identical to the
46
- * pre-P3 contract (no `outcome` key emitted).
47
- *
48
- * Outcome shape validation (severity vocabulary, escalation type enum
49
- * beyond what we accept on the CLI surface, etc.) is the BACKEND's job
50
- * (see RFC-0010 §RunSummary + the P3-01 Pydantic model). `shipctl` only
51
- * enforces well-formedness at the wire boundary: object-not-array,
52
- * field types, file readability, JSON parseability.
53
- */
54
-
55
- import { readFileSync } from "node:fs";
56
-
57
- /** @typedef {"succeeded"|"failed"|"cancelled"} TerminalStatus */
58
-
59
- const STATUS_ALIASES = {
60
- ok: "succeeded",
61
- succeeded: "succeeded",
62
- success: "succeeded",
63
- pass: "succeeded",
64
- green: "succeeded",
65
- fail: "failed",
66
- failed: "failed",
67
- failure: "failed",
68
- red: "failed",
69
- cancelled: "cancelled",
70
- canceled: "cancelled",
71
- cancel: "cancelled",
72
- };
73
-
74
- /* RFC-0010 §RunSummary — the five inbox/escalation types Ship knows
75
- * how to route. We reject anything else at the CLI boundary so the
76
- * misspelled `--escalation aproval:...` fails fast with a usage hint
77
- * instead of being silently dropped by the backend's enum validator. */
78
- const VALID_ESCALATION_TYPES = new Set([
79
- "clarification",
80
- "improvement",
81
- "failure",
82
- "approval",
83
- "exception",
84
- ]);
85
-
86
- const VALID_SEVERITIES = new Set(["low", "medium", "high", "critical"]);
87
-
88
- const OUTCOME_TEXT_MAX = 500;
89
-
90
- const EXIT_USAGE = 2;
91
- const EXIT_AUTH = 10;
92
- const EXIT_CONFIG = 11;
93
- const EXIT_HTTP = 3;
94
-
95
- function die(code, msg) {
96
- console.error(msg);
97
- process.exit(code);
98
- }
99
-
100
- function printCallbackHelp() {
101
- console.log(`shipctl callback — what a Play's pattern calls when its work is done so Ship can render an outcome-first row in the Run list and route any escalations into the Inbox.
102
-
103
- USAGE
104
- shipctl callback --status <ok|fail|cancelled> [--summary "..."] [--metric k=v]...
105
- [--outcome-text "..."] [--severity SEV=N]... [--artifact TYPE:TITLE[:REF]]...
106
- [--requires-approval] [--approval-payload <@file|JSON>]
107
- [--escalation TYPE:REASON]... [--findings-count N]
108
-
109
- Identity
110
- --run-id Pipeline run UUID (usually set by SHIP_RUN_ID env).
111
- --callback-url Full callback URL (usually set by SHIP_CALLBACK_URL env).
112
- --base-url Orchestration API base (default: SHIP_API_BASE env). Combined
113
- with --run-id to construct the URL when --callback-url absent.
114
- Env:
115
- SHIP_RUN_TOKEN (required) Short-lived bearer Ship issued for this run.
116
- SHIP_CALLBACK_URL (preferred) Full URL of the result endpoint.
117
- SHIP_RUN_ID Fallback input for --run-id.
118
- SHIP_API_BASE Fallback input for --base-url.
119
-
120
- Status & summary
121
- --status Terminal status. Aliases: ok|success|succeeded, fail|failed,
122
- cancelled|canceled. Required.
123
- --summary One-line human summary (≤1024 chars). Optional.
124
- --metric k=v Structured metric to attach. Repeatable. Values coerced:
125
- numbers, booleans (true|false), JSON (prefix { or [), else string.
126
- Example: --metric tickets_processed=3 --metric dry_run=true
127
- --json Print the Ship response JSON on success.
128
- --help, -h Show this help.
129
-
130
- RunSummary outcome (RFC-0010 §RunSummary — emitted as the request body's "outcome" object)
131
- --outcome-text "..." Pattern-authored single-line UI sentence (≤${OUTCOME_TEXT_MAX} chars,
132
- leading/trailing whitespace trimmed). This is what operators
133
- see in /runs — keep it concrete, no "completed successfully"
134
- filler. Example:
135
- "3 issues found · 1 PR opened"
136
- --findings-count N Non-negative integer total. If omitted but --severity
137
- flags are present, derived from their sum.
138
- --severity SEV=N Aggregated into outcome.findings_by_severity. SEV is one of
139
- low|medium|high|critical. N is non-negative int. Repeatable;
140
- order doesn't matter; last write wins per severity.
141
- --artifact TYPE:TITLE[:REF]
142
- Repeatable. Parsed to {type, title, ref?}. Use \\: to embed
143
- a literal colon inside TITLE. REF (URL or external id) is
144
- optional. Example:
145
- --artifact pr:"Fix null check":https://github.com/o/r/pull/42
146
- --requires-approval Flag (no value). Sets outcome.requires_approval=true.
147
- --approval-payload PAYLOAD
148
- JSON object to attach as outcome.approval_payload. Either
149
- inline JSON or "@path/to/file.json" to load from disk.
150
- Must parse to an object (not array/scalar).
151
- --escalation TYPE:REASON Repeatable. Aggregated into outcome.escalations[]. Each
152
- escalation lands in the Inbox with that type. TYPE must
153
- be one of:
154
- clarification | improvement | failure | approval | exception
155
- Env (alternative to per-field flags):
156
- SHIP_RUN_OUTCOME Inline JSON object — used as the base outcome (CLI flags
157
- merge on top, flag values win on per-field collision).
158
- SHIP_RUN_OUTCOME_FILE Path to a JSON file with the same semantics. Useful when
159
- an agent emits a full RunSummary blob to stdout.
160
-
161
- EXAMPLES
162
- # Terminal status only (legacy contract):
163
- shipctl callback --status ok --summary "3 PRs scanned"
164
-
165
- # Canonical pattern-authored outcome (mirrors flow-pr-self-review's "## Reporting"):
166
- shipctl callback --status ok \\
167
- --outcome-text "Reviewed PR · 3 suggestions · 1 fix applied" \\
168
- --findings-count 3 \\
169
- --severity high=1 --severity medium=2 \\
170
- --artifact comment:"PR self-review summary":"https://github.com/o/r/pull/42#issuecomment-1" \\
171
- --artifact pr:"Auto-fix: null guard":"https://github.com/o/r/pull/42/commits/abc" \\
172
- --escalation clarification:"Need owner sign-off on test rewrite"
173
-
174
- # Bulk input from agent stdout:
175
- SHIP_RUN_OUTCOME_FILE=./summary.json shipctl callback --status ok
176
- `);
177
- }
178
-
179
- /* Parse --metric k=v pairs with sensible coercion. We deliberately keep
180
- * this small — Ship's callback ``metrics`` blob is a free-form JSON bag,
181
- * so the CLI should offer the common shorthand (numbers, booleans, JSON
182
- * literals) without growing a tiny DSL. Strings are the fallback. */
183
- function coerceMetricValue(raw) {
184
- if (raw === "") return "";
185
- if (raw === "true") return true;
186
- if (raw === "false") return false;
187
- if (raw === "null") return null;
188
- if (/^-?\d+$/.test(raw)) {
189
- const n = Number(raw);
190
- if (Number.isSafeInteger(n)) return n;
191
- }
192
- if (/^-?\d+\.\d+$/.test(raw)) {
193
- const n = Number(raw);
194
- if (Number.isFinite(n)) return n;
195
- }
196
- const first = raw[0];
197
- if (first === "{" || first === "[") {
198
- try {
199
- return JSON.parse(raw);
200
- } catch {
201
- /* fall through to string */
202
- }
203
- }
204
- return raw;
205
- }
206
-
207
- function parseMetricArg(tok) {
208
- const eq = tok.indexOf("=");
209
- if (eq <= 0) {
210
- die(EXIT_USAGE, `--metric expects key=value; got: ${tok}`);
211
- }
212
- const key = tok.slice(0, eq).trim();
213
- const value = tok.slice(eq + 1);
214
- if (!key) die(EXIT_USAGE, `--metric key cannot be empty: ${tok}`);
215
- return { key, value: coerceMetricValue(value) };
216
- }
217
-
218
- /* --severity SEV=N. Mirrors --metric's k=v shape but with a fixed
219
- * vocabulary and integer values. We validate at the CLI surface so the
220
- * `--severity hi=1` typo dies before we waste an HTTP round-trip. */
221
- export function parseSeverityArg(tok) {
222
- const eq = tok.indexOf("=");
223
- if (eq <= 0) {
224
- die(EXIT_USAGE, `--severity expects SEV=N; got: ${tok}`);
225
- }
226
- const key = tok.slice(0, eq).trim().toLowerCase();
227
- const raw = tok.slice(eq + 1).trim();
228
- if (!VALID_SEVERITIES.has(key)) {
229
- die(
230
- EXIT_USAGE,
231
- `--severity SEV must be one of low|medium|high|critical; got: ${key}`,
232
- );
233
- }
234
- if (!/^\d+$/.test(raw)) {
235
- die(
236
- EXIT_USAGE,
237
- `--severity ${key}=N expects a non-negative integer; got: ${raw}`,
238
- );
239
- }
240
- const n = Number(raw);
241
- if (!Number.isSafeInteger(n) || n < 0) {
242
- die(
243
- EXIT_USAGE,
244
- `--severity ${key}=N expects a non-negative integer; got: ${raw}`,
245
- );
246
- }
247
- return { key, value: n };
248
- }
249
-
250
- /* --artifact TYPE:TITLE[:REF]. The annoying parser-y bit: TITLE may
251
- * embed colons via `\:`, REF is taken verbatim after the second
252
- * unescaped colon (so URLs like https://… survive the round-trip
253
- * without further escaping — only the post-TYPE post-TITLE colons
254
- * matter as separators). */
255
- export function parseArtifactArg(tok) {
256
- const findUnescapedColon = (s, start = 0) => {
257
- for (let i = start; i < s.length; i++) {
258
- if (s[i] === ":" && s[i - 1] !== "\\") return i;
259
- }
260
- return -1;
261
- };
262
- const firstColon = findUnescapedColon(tok);
263
- if (firstColon <= 0) {
264
- die(EXIT_USAGE, `--artifact expects TYPE:TITLE[:REF]; got: ${tok}`);
265
- }
266
- const type = tok.slice(0, firstColon).trim();
267
- const rest = tok.slice(firstColon + 1);
268
- const secondColon = findUnescapedColon(rest);
269
- let titleRaw;
270
- let ref = null;
271
- if (secondColon < 0) {
272
- titleRaw = rest;
273
- } else {
274
- titleRaw = rest.slice(0, secondColon);
275
- ref = rest.slice(secondColon + 1);
276
- }
277
- /* Resolve the only escape we recognise. Other backslash sequences
278
- * pass through untouched — kept deliberately narrow so we don't grow
279
- * a DSL. */
280
- const title = titleRaw.replace(/\\:/g, ":").trim();
281
- if (!type) die(EXIT_USAGE, `--artifact TYPE cannot be empty: ${tok}`);
282
- if (!title) die(EXIT_USAGE, `--artifact TITLE cannot be empty: ${tok}`);
283
- /** @type {{type: string, title: string, ref?: string}} */
284
- const out = { type, title };
285
- if (ref !== null && ref !== "") out.ref = ref;
286
- return out;
287
- }
288
-
289
- /* --escalation TYPE:REASON. REASON is everything after the first
290
- * colon (so it can contain colons / URLs / punctuation freely). */
291
- export function parseEscalationArg(tok) {
292
- const firstColon = tok.indexOf(":");
293
- if (firstColon <= 0) {
294
- die(EXIT_USAGE, `--escalation expects TYPE:REASON; got: ${tok}`);
295
- }
296
- const type = tok.slice(0, firstColon).trim().toLowerCase();
297
- const reason = tok.slice(firstColon + 1).trim();
298
- if (!VALID_ESCALATION_TYPES.has(type)) {
299
- die(
300
- EXIT_USAGE,
301
- `--escalation TYPE must be one of ${[...VALID_ESCALATION_TYPES].join("|")}; got: ${type}`,
302
- );
303
- }
304
- if (!reason) die(EXIT_USAGE, `--escalation REASON cannot be empty: ${tok}`);
305
- return { type, reason };
306
- }
307
-
308
- /* --approval-payload @path | inline JSON. Distinguishes by the leading
309
- * `@` because that's what bash/curl users already expect for
310
- * load-from-file semantics. We always require an object (not a top-level
311
- * array or scalar) so the backend's `Record<string, unknown>` slot lands
312
- * something usable. */
313
- export function parseApprovalPayload(raw) {
314
- let source = raw;
315
- let origin = "inline JSON";
316
- if (typeof raw === "string" && raw.startsWith("@")) {
317
- const path = raw.slice(1);
318
- if (!path) die(EXIT_USAGE, "--approval-payload @ requires a file path");
319
- try {
320
- source = readFileSync(path, "utf8");
321
- } catch (err) {
322
- die(
323
- EXIT_CONFIG,
324
- `--approval-payload could not read ${path}: ${err instanceof Error ? err.message : err}`,
325
- );
326
- }
327
- origin = `file ${path}`;
328
- }
329
- let parsed;
330
- try {
331
- parsed = JSON.parse(source);
332
- } catch (err) {
333
- die(
334
- EXIT_CONFIG,
335
- `--approval-payload (${origin}) is not valid JSON: ${err instanceof Error ? err.message : err}`,
336
- );
337
- return null;
338
- }
339
- if (
340
- parsed === null ||
341
- typeof parsed !== "object" ||
342
- Array.isArray(parsed)
343
- ) {
344
- die(
345
- EXIT_CONFIG,
346
- `--approval-payload must be a JSON object (got ${Array.isArray(parsed) ? "array" : typeof parsed}).`,
347
- );
348
- }
349
- return parsed;
350
- }
351
-
352
- /* SHIP_RUN_OUTCOME / SHIP_RUN_OUTCOME_FILE → object. Returns null when
353
- * neither env is set. We do *not* validate the inner shape (severities,
354
- * escalation types, etc.) here — that's the backend's responsibility
355
- * per RFC-0010. We only enforce well-formedness so a typo in JSON
356
- * surfaces as an EXIT_CONFIG with a useful message rather than a 422
357
- * round-trip with the bearer already burned. */
358
- export function loadEnvOutcome(env = process.env) {
359
- const inline = env.SHIP_RUN_OUTCOME;
360
- const file = env.SHIP_RUN_OUTCOME_FILE;
361
- if (!inline && !file) return null;
362
- let source;
363
- let origin;
364
- if (file) {
365
- try {
366
- source = readFileSync(file, "utf8");
367
- } catch (err) {
368
- die(
369
- EXIT_CONFIG,
370
- `SHIP_RUN_OUTCOME_FILE could not read ${file}: ${err instanceof Error ? err.message : err}`,
371
- );
372
- return null;
373
- }
374
- origin = `SHIP_RUN_OUTCOME_FILE=${file}`;
375
- } else {
376
- source = inline;
377
- origin = "SHIP_RUN_OUTCOME";
378
- }
379
- let parsed;
380
- try {
381
- parsed = JSON.parse(source);
382
- } catch (err) {
383
- die(
384
- EXIT_CONFIG,
385
- `${origin} is not valid JSON: ${err instanceof Error ? err.message : err}`,
386
- );
387
- return null;
388
- }
389
- if (
390
- parsed === null ||
391
- typeof parsed !== "object" ||
392
- Array.isArray(parsed)
393
- ) {
394
- die(
395
- EXIT_CONFIG,
396
- `${origin} must be a JSON object (got ${Array.isArray(parsed) ? "array" : typeof parsed}).`,
397
- );
398
- }
399
- return parsed;
400
- }
401
-
402
- export function parseCallbackArgs(rest) {
403
- const out = {
404
- status: null,
405
- summary: null,
406
- metrics: {},
407
- runId: null,
408
- callbackUrl: null,
409
- baseUrl: null,
410
- json: false,
411
- help: false,
412
- /* Outcome accumulator. We track presence of *any* outcome flag with
413
- * `outcomeFlagsSeen` so backwards-compat (no outcome key) is a
414
- * deterministic check rather than a "did everything end up empty"
415
- * heuristic. */
416
- outcome: {
417
- outcome_text: null,
418
- findings_count: null,
419
- findings_by_severity: {},
420
- artifacts: [],
421
- requires_approval: false,
422
- approval_payload: null,
423
- escalations: [],
424
- },
425
- outcomeFlagsSeen: false,
426
- };
427
- const copy = [...rest];
428
- const markOutcome = () => {
429
- out.outcomeFlagsSeen = true;
430
- };
431
- /* Tiny arg-munger kept inline rather than pulling a dependency —
432
- * matches the style of feedback.mjs / patterns.mjs and keeps this CLI
433
- * zero-prod-deps apart from `yaml`. */
434
- const strFlag = (name, key) => {
435
- if (copy[0] === name && copy[1] !== undefined) {
436
- copy.shift();
437
- out[key] = String(copy.shift());
438
- return true;
439
- }
440
- const p = `${name}=`;
441
- if (copy[0] && copy[0].startsWith(p)) {
442
- out[key] = copy[0].slice(p.length);
443
- copy.shift();
444
- return true;
445
- }
446
- return false;
447
- };
448
- /* Same as strFlag but routes the captured value into a callback so
449
- * we can validate / mutate (outcome flags don't live as plain keys
450
- * on `out`). */
451
- const handleArgFlag = (name, take) => {
452
- if (copy[0] === name && copy[1] !== undefined) {
453
- copy.shift();
454
- take(String(copy.shift()));
455
- return true;
456
- }
457
- const p = `${name}=`;
458
- if (copy[0] && copy[0].startsWith(p)) {
459
- const raw = copy[0].slice(p.length);
460
- copy.shift();
461
- take(raw);
462
- return true;
463
- }
464
- return false;
465
- };
466
- while (copy.length) {
467
- const a = copy[0];
468
- if (a === "--help" || a === "-h") {
469
- out.help = true;
470
- copy.shift();
471
- continue;
472
- }
473
- if (a === "--json") {
474
- out.json = true;
475
- copy.shift();
476
- continue;
477
- }
478
- if (strFlag("--status", "status")) continue;
479
- if (strFlag("--summary", "summary")) continue;
480
- if (strFlag("--run-id", "runId")) continue;
481
- if (strFlag("--callback-url", "callbackUrl")) continue;
482
- if (strFlag("--base-url", "baseUrl")) continue;
483
- if (a === "--metric" && copy[1] !== undefined) {
484
- copy.shift();
485
- const { key, value } = parseMetricArg(String(copy.shift()));
486
- out.metrics[key] = value;
487
- continue;
488
- }
489
- if (a && a.startsWith("--metric=")) {
490
- const raw = a.slice("--metric=".length);
491
- copy.shift();
492
- const { key, value } = parseMetricArg(raw);
493
- out.metrics[key] = value;
494
- continue;
495
- }
496
- if (
497
- handleArgFlag("--outcome-text", (raw) => {
498
- markOutcome();
499
- const trimmed = String(raw).trim();
500
- if (trimmed.length > OUTCOME_TEXT_MAX) {
501
- die(
502
- EXIT_USAGE,
503
- `--outcome-text exceeds ${OUTCOME_TEXT_MAX} chars (got ${trimmed.length}).`,
504
- );
505
- }
506
- out.outcome.outcome_text = trimmed;
507
- })
508
- )
509
- continue;
510
- if (
511
- handleArgFlag("--findings-count", (raw) => {
512
- markOutcome();
513
- const r = String(raw).trim();
514
- if (!/^\d+$/.test(r)) {
515
- die(
516
- EXIT_USAGE,
517
- `--findings-count expects a non-negative integer; got: ${r}`,
518
- );
519
- }
520
- out.outcome.findings_count = Number(r);
521
- })
522
- )
523
- continue;
524
- if (
525
- handleArgFlag("--severity", (raw) => {
526
- markOutcome();
527
- const { key, value } = parseSeverityArg(String(raw));
528
- out.outcome.findings_by_severity[key] = value;
529
- })
530
- )
531
- continue;
532
- if (
533
- handleArgFlag("--artifact", (raw) => {
534
- markOutcome();
535
- out.outcome.artifacts.push(parseArtifactArg(String(raw)));
536
- })
537
- )
538
- continue;
539
- if (a === "--requires-approval") {
540
- markOutcome();
541
- out.outcome.requires_approval = true;
542
- copy.shift();
543
- continue;
544
- }
545
- if (
546
- handleArgFlag("--approval-payload", (raw) => {
547
- markOutcome();
548
- out.outcome.approval_payload = parseApprovalPayload(String(raw));
549
- })
550
- )
551
- continue;
552
- if (
553
- handleArgFlag("--escalation", (raw) => {
554
- markOutcome();
555
- out.outcome.escalations.push(parseEscalationArg(String(raw)));
556
- })
557
- )
558
- continue;
559
- die(EXIT_USAGE, `unknown argument: ${a}\nRun: shipctl callback --help`);
560
- }
561
- return out;
562
- }
563
-
564
- export function normaliseStatus(raw) {
565
- if (!raw) return null;
566
- const lower = String(raw).toLowerCase().trim();
567
- return STATUS_ALIASES[lower] ?? null;
568
- }
569
-
570
- export function resolveCallbackUrl(args, env = process.env) {
571
- if (args.callbackUrl) return args.callbackUrl;
572
- if (env.SHIP_CALLBACK_URL) return env.SHIP_CALLBACK_URL;
573
- const runId = args.runId || env.SHIP_RUN_ID || null;
574
- const base = args.baseUrl || env.SHIP_API_BASE || null;
575
- if (runId && base) {
576
- return `${base.replace(/\/$/, "")}/v1/pipelines/runs/${runId}/result`;
577
- }
578
- return null;
579
- }
580
-
581
- /* Compose the final outcome from env (base) + CLI flags (overlay).
582
- *
583
- * Merge semantics — codified here so the test names stay terse and the
584
- * spec deviation (if any) is grep-able:
585
- *
586
- * - Top-level keys: shallow override (CLI wins on collision).
587
- * - `findings_by_severity`: per-key merge (env's `low: 1` survives a
588
- * CLI `--severity high=2`; CLI overrides env when severities collide).
589
- * - Arrays (`artifacts`, `escalations`): if CLI contributed any,
590
- * CLI replaces env. Otherwise env's array passes through. We
591
- * intentionally do NOT concat — appending env+CLI would be too
592
- * surprising for an agent that emits a complete blob and then a
593
- * human refines a single field via flag.
594
- * - `findings_count`: when neither env nor flag provides one but
595
- * `findings_by_severity` is present, we derive a sum so the
596
- * pattern doesn't need to compute it by hand.
597
- */
598
- export function buildOutcome(args, env = process.env) {
599
- const envOutcome = loadEnvOutcome(env);
600
- if (!envOutcome && !args.outcomeFlagsSeen) return null;
601
-
602
- /** @type {Record<string, unknown>} */
603
- const merged = envOutcome ? { ...envOutcome } : {};
604
-
605
- const cli = args.outcome;
606
-
607
- if (cli.outcome_text !== null) merged.outcome_text = cli.outcome_text;
608
- if (cli.findings_count !== null) merged.findings_count = cli.findings_count;
609
-
610
- const cliSeverities = cli.findings_by_severity;
611
- if (Object.keys(cliSeverities).length > 0) {
612
- const baseSev =
613
- merged.findings_by_severity &&
614
- typeof merged.findings_by_severity === "object" &&
615
- !Array.isArray(merged.findings_by_severity)
616
- ? { ...merged.findings_by_severity }
617
- : {};
618
- merged.findings_by_severity = { ...baseSev, ...cliSeverities };
619
- }
620
-
621
- if (cli.artifacts.length > 0) merged.artifacts = cli.artifacts;
622
- if (cli.requires_approval) merged.requires_approval = true;
623
- if (cli.approval_payload !== null)
624
- merged.approval_payload = cli.approval_payload;
625
- if (cli.escalations.length > 0) merged.escalations = cli.escalations;
626
-
627
- /* Derive `findings_count` from severity totals when neither side
628
- * specified one explicitly. Catches the common case where the
629
- * pattern emits per-severity counts and forgets the rollup. */
630
- if (
631
- merged.findings_count === undefined &&
632
- merged.findings_by_severity &&
633
- typeof merged.findings_by_severity === "object"
634
- ) {
635
- let sum = 0;
636
- let any = false;
637
- for (const v of Object.values(merged.findings_by_severity)) {
638
- if (typeof v === "number" && Number.isFinite(v)) {
639
- sum += v;
640
- any = true;
641
- }
642
- }
643
- if (any) merged.findings_count = sum;
644
- }
645
-
646
- return merged;
647
- }
648
-
649
- export function buildCallbackBody(args, env = process.env) {
650
- /** @type {Record<string, unknown>} */
651
- const body = { status: args.status };
652
- if (args.summary) body.summary = String(args.summary).slice(0, 1024);
653
- if (Object.keys(args.metrics).length > 0) body.metrics = args.metrics;
654
- const outcome = buildOutcome(args, env);
655
- if (outcome !== null) body.outcome = outcome;
656
- return body;
657
- }
658
-
659
- export async function callbackCommand(_ctx, rest) {
660
- const args = parseCallbackArgs(rest);
661
- if (args.help) {
662
- printCallbackHelp();
663
- return;
664
- }
665
-
666
- const status = normaliseStatus(args.status);
667
- if (!status) {
668
- die(
669
- EXIT_USAGE,
670
- `--status is required (ok|fail|cancelled). Got: ${args.status ?? "<missing>"}\nRun: shipctl callback --help`,
671
- );
672
- }
673
- args.status = status;
674
-
675
- const token = process.env.SHIP_RUN_TOKEN;
676
- if (!token) {
677
- die(
678
- EXIT_AUTH,
679
- "SHIP_RUN_TOKEN env var is required. Ship injects it into workflow_dispatch inputs; set it in the callback step's env block.",
680
- );
681
- }
682
-
683
- const url = resolveCallbackUrl(args);
684
- if (!url) {
685
- die(
686
- EXIT_CONFIG,
687
- "Cannot resolve callback URL. Set SHIP_CALLBACK_URL (preferred — Ship injects it), or pass --callback-url, or combine SHIP_API_BASE + SHIP_RUN_ID.",
688
- );
689
- }
690
-
691
- const body = buildCallbackBody(args);
692
-
693
- let res;
694
- try {
695
- res = await fetch(url, {
696
- method: "POST",
697
- headers: {
698
- "Content-Type": "application/json",
699
- Accept: "application/json",
700
- Authorization: `Bearer ${token}`,
701
- "User-Agent": await getUA(),
702
- },
703
- body: JSON.stringify(body),
704
- });
705
- } catch (err) {
706
- die(EXIT_HTTP, `callback POST failed: ${err instanceof Error ? err.message : err}`);
707
- return;
708
- }
709
-
710
- const text = await res.text();
711
- if (!res.ok) {
712
- const hint =
713
- res.status === 401
714
- ? " (check SHIP_RUN_TOKEN matches the run Ship dispatched)"
715
- : res.status === 404
716
- ? " (check SHIP_RUN_ID — the run may not exist)"
717
- : res.status === 422
718
- ? " (check --status is one of succeeded|failed|cancelled)"
719
- : "";
720
- die(
721
- EXIT_HTTP,
722
- `Ship rejected callback: HTTP ${res.status} ${res.statusText}${hint}\n${text}`,
723
- );
724
- return;
725
- }
726
-
727
- if (args.json) {
728
- console.log(text);
729
- } else {
730
- console.log(`callback accepted: ${status}${args.summary ? ` — ${args.summary}` : ""}`);
731
- }
732
- }
733
-
734
- /* Lazy import to keep the helper self-contained & testable. */
735
- async function getUA() {
736
- try {
737
- const { getUserAgent } = await import("../version.mjs");
738
- return getUserAgent();
739
- } catch {
740
- return "shipctl-callback";
741
- }
742
- }