@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.
- package/README.md +17 -16
- package/bin/shipctl.mjs +4 -80
- package/lib/commands/feedback.mjs +1 -1
- package/lib/commands/help.mjs +47 -131
- package/lib/commands/init.mjs +17 -250
- package/lib/commands/knowledge.mjs +25 -328
- package/lib/commands/preflight.mjs +213 -0
- package/lib/commands/run.mjs +298 -119
- package/lib/commands/trigger.mjs +95 -10
- package/lib/config/schema.mjs +73 -11
- package/lib/http.mjs +0 -2
- package/lib/runtime/routines.mjs +39 -0
- package/lib/templates.mjs +2 -2
- package/lib/verify/checks/agents-on-disk.mjs +5 -28
- package/lib/verify/registry.mjs +7 -8
- package/package.json +1 -1
- package/lib/artifacts/fs-index.mjs +0 -230
- package/lib/cache/store.mjs +0 -422
- package/lib/commands/bootstrap.mjs +0 -4
- package/lib/commands/callback.mjs +0 -742
- package/lib/commands/docs.mjs +0 -90
- package/lib/commands/kickoff.mjs +0 -192
- package/lib/commands/lanes.mjs +0 -566
- package/lib/commands/manifest-catalog.mjs +0 -251
- package/lib/commands/migrate.mjs +0 -204
- package/lib/commands/new.mjs +0 -452
- package/lib/commands/patterns.mjs +0 -160
- package/lib/commands/process.mjs +0 -388
- package/lib/commands/search.mjs +0 -43
- package/lib/commands/sync.mjs +0 -824
- package/lib/config/migrate.mjs +0 -223
- package/lib/find-ship-root.mjs +0 -75
- package/lib/process/specialist-prompt-contract.mjs +0 -171
- package/lib/state/lockfile.mjs +0 -180
- package/lib/vendor/run-agent.workflow.yml +0 -254
- package/lib/verify/checks/artifacts-up-to-date.mjs +0 -78
- package/lib/verify/checks/cache-integrity.mjs +0 -51
- package/lib/verify/checks/gitignore-cache.mjs +0 -51
- 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
|
-
}
|