@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,617 @@
1
+ /**
2
+ * `shipctl run` — single entry-point for executing a Ship lane (RFC-0007).
3
+ *
4
+ * Today's scope (Phase 1):
5
+ * - `kind=once` lanes run end-to-end: resolve config, fetch pattern,
6
+ * check idempotency, emit the prompt to stdout, write the marker.
7
+ * - `kind=event` and `kind=schedule` lanes are recognised but emit a
8
+ * "not yet wired" exit-0 no-op. Phase 3 wires the reusable workflow
9
+ * that makes those kinds execute.
10
+ *
11
+ * The command intentionally does not fork an agent subprocess. The
12
+ * reusable workflow pipes shipctl's stdout into the customer's agent
13
+ * (Cursor Cloud, Claude Code, Codex, …) the same way `shipctl kickoff`
14
+ * does today. That keeps the CLI agnostic about which agent runtime is
15
+ * in use.
16
+ *
17
+ * Callback behaviour: if a callback URL is available via flags or env,
18
+ * `shipctl run` reports `status=ok` on success and `status=fail` on any
19
+ * failure path. Callback errors do not override the primary exit code
20
+ * (a successful lane with a flaky callback still exits 0, but prints a
21
+ * warning to stderr).
22
+ */
23
+
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+
27
+ import { readConfig, findShipRoot } from "../config/io.mjs";
28
+ import { validateConfig, CONFIG_SCHEMA_VERSION } from "../config/schema.mjs";
29
+ import { fetchArtifact } from "../http.mjs";
30
+ import { resolveShipRepoRootForCatalog } from "../find-ship-root.mjs";
31
+ import { readArtifactFile } from "../artifacts/fs-index.mjs";
32
+ import { decideRun, readMarker, writeMarker, sha256 } from "../state/idempotency.mjs";
33
+ import { readLockfile, lookupLock, verifyBody } from "../state/lockfile.mjs";
34
+
35
+ const EXIT_OK = 0;
36
+ const EXIT_USAGE = 1;
37
+ const EXIT_V1_CONFIG = 2;
38
+ const EXIT_CALLBACK = 3;
39
+ const EXIT_IDEMPOTENCY = 4;
40
+
41
+ const VALID_TRIGGERS = new Set(["event", "schedule", "manual", "once"]);
42
+
43
+ function printHelp() {
44
+ console.log(`shipctl run — execute a Ship lane end-to-end.
45
+
46
+ USAGE
47
+ shipctl run --lane <id> [--trigger <event|schedule|manual|once>]
48
+ [--dry-run] [--offline]
49
+ [--ship-run-id <uuid>] [--ship-callback-url <url>] [--ship-run-token <jwt>]
50
+ [--cwd <dir>] [--json]
51
+
52
+ FLAGS
53
+ --lane <id> Lane id declared in .ship/config.yml. Required.
54
+ --trigger <kind> Force the trigger context (event|schedule|manual|once).
55
+ If omitted, inferred from GITHUB_EVENT_NAME / SHIP_RUN_TRIGGER.
56
+ --dry-run Print the plan without touching idempotency markers or callback.
57
+ --offline Resolve patterns exclusively via .ship/shipctl.lock.json
58
+ and .ship/cache/ — never talks to the methodology API.
59
+ Fails if the lockfile or a cached body is missing.
60
+ Generate one with 'shipctl sync --lock'.
61
+ --ship-run-id <uuid> Pipeline run id. Falls back to SHIP_RUN_ID env.
62
+ --ship-callback-url <url> Full callback URL. Falls back to SHIP_CALLBACK_URL env.
63
+ --ship-run-token <jwt> Short-lived bearer. Falls back to SHIP_RUN_TOKEN env.
64
+ --cwd <dir> Repo root. Default: search upward for .ship/config.yml.
65
+ --json Emit a structured summary on stdout.
66
+ --help Show this help.
67
+
68
+ EXIT
69
+ 0 lane executed or no-op
70
+ 1 usage / config error
71
+ 2 config is v1 — run 'shipctl migrate' first
72
+ 3 callback failed (lane itself may have succeeded)
73
+ 4 idempotency marker read/write failure
74
+ 10 missing SHIP_RUN_TOKEN when a callback URL is configured
75
+
76
+ EXAMPLE (CI step emitted by the reusable workflow)
77
+ shipctl run --lane seed_knowledge_starters | feed-to-agent
78
+ `);
79
+ }
80
+
81
+ /**
82
+ * @param {{json?: boolean, dryRun?: boolean, baseUrl?: string}} ctx
83
+ * @param {string[]} rest
84
+ */
85
+ export async function runCommand(ctx, rest) {
86
+ const args = parseArgs(rest);
87
+ if (args.help) {
88
+ printHelp();
89
+ process.exit(EXIT_OK);
90
+ }
91
+ if (!args.lane) {
92
+ die(EXIT_USAGE, "`--lane <id>` is required.\nRun: shipctl run --help");
93
+ }
94
+
95
+ const cwd = args.cwd || process.cwd();
96
+ const root = findShipRoot(cwd);
97
+ if (!root) {
98
+ die(
99
+ EXIT_USAGE,
100
+ `.ship/config.yml not found (searched from ${path.resolve(cwd)} upward). Run 'shipctl init' first.`,
101
+ );
102
+ }
103
+
104
+ let config;
105
+ try {
106
+ const read = readConfig(cwd);
107
+ config = read.config;
108
+ } catch (err) {
109
+ die(EXIT_USAGE, err instanceof Error ? err.message : String(err));
110
+ }
111
+
112
+ if (config.version !== CONFIG_SCHEMA_VERSION) {
113
+ die(
114
+ EXIT_V1_CONFIG,
115
+ `.ship/config.yml is at v${config.version}; shipctl run requires v${CONFIG_SCHEMA_VERSION}.\nRun 'shipctl migrate' to upgrade.`,
116
+ );
117
+ }
118
+
119
+ const validation = validateConfig(config);
120
+ if (!validation.ok) {
121
+ const msg = [
122
+ "config is invalid:",
123
+ ...validation.errors.map((e) => ` - ${e}`),
124
+ ].join("\n");
125
+ die(EXIT_USAGE, msg);
126
+ }
127
+
128
+ const lane = config.lanes?.[args.lane];
129
+ if (!lane) {
130
+ const known = Object.keys(config.lanes || {}).sort();
131
+ die(
132
+ EXIT_USAGE,
133
+ `unknown lane '${args.lane}'. Known lanes: ${known.length ? known.join(", ") : "(none)"}`,
134
+ );
135
+ }
136
+
137
+ const effectiveTrigger = resolveTrigger(args.trigger, lane.kind);
138
+ if (!effectiveTrigger.fits) {
139
+ /* Not an error — scheduler fired us but the lane doesn't want this
140
+ * trigger. Exit 0 so parallel lanes in the same workflow don't all
141
+ * fail just because one didn't match. */
142
+ const summary = {
143
+ lane: args.lane,
144
+ kind: lane.kind,
145
+ trigger: effectiveTrigger.trigger,
146
+ status: "noop",
147
+ reason: `lane.kind=${lane.kind} does not accept trigger=${effectiveTrigger.trigger}`,
148
+ };
149
+ emitSummary(ctx, args, summary);
150
+ process.exit(EXIT_OK);
151
+ }
152
+
153
+ /* Phase 1: only `once` executes fully today. The other kinds validate
154
+ * and exit 0 with a clear reason so CI wrappers can safely wire them
155
+ * now and we flip on behaviour in Phase 3 without re-release. */
156
+ if (lane.kind !== "once") {
157
+ const summary = {
158
+ lane: args.lane,
159
+ kind: lane.kind,
160
+ trigger: effectiveTrigger.trigger,
161
+ status: "noop",
162
+ reason: `lane.kind=${lane.kind} is recognised but not yet wired in shipctl run (Phase 3).`,
163
+ };
164
+ emitSummary(ctx, args, summary);
165
+ process.exit(EXIT_OK);
166
+ }
167
+
168
+ /* --- kind=once path ------------------------------------------------ */
169
+
170
+ const patternId = String(lane.pattern);
171
+ const patternFetch = await fetchPatternBody({
172
+ patternId,
173
+ patternVersion: lane.pattern_version || null,
174
+ offline: args.offline,
175
+ root,
176
+ ctx,
177
+ config,
178
+ });
179
+ if (!patternFetch.ok) {
180
+ die(EXIT_USAGE, patternFetch.error);
181
+ }
182
+
183
+ const patternBody = patternFetch.body;
184
+ const patternSha = sha256(patternBody);
185
+
186
+ const idem = lane.idempotency;
187
+ let marker = null;
188
+ try {
189
+ marker = readMarker(cwd, idem.key);
190
+ } catch (err) {
191
+ await tryCallback(args, "fail", `idempotency read failed: ${err.message}`);
192
+ die(EXIT_IDEMPOTENCY, err instanceof Error ? err.message : String(err));
193
+ }
194
+
195
+ const decision = decideRun(marker, patternBody, idem.reset_on || "version-change");
196
+ if (!decision.run) {
197
+ const summary = {
198
+ lane: args.lane,
199
+ kind: lane.kind,
200
+ trigger: effectiveTrigger.trigger,
201
+ status: "noop",
202
+ reason: "already-done",
203
+ marker: decision.marker,
204
+ };
205
+ await tryCallback(
206
+ args,
207
+ "ok",
208
+ `lane ${args.lane}: already completed, no-op.`,
209
+ { pattern_id: patternId, pattern_sha256: patternSha, noop: true },
210
+ );
211
+ emitSummary(ctx, args, summary);
212
+ process.exit(EXIT_OK);
213
+ }
214
+
215
+ /* Dry-run stops here — no marker write, no callback, just print the
216
+ * plan. We still emit the pattern body to stdout so operators can
217
+ * eyeball what the agent would receive. */
218
+ if (args.dryRun || ctx.dryRun) {
219
+ const summary = {
220
+ lane: args.lane,
221
+ kind: lane.kind,
222
+ trigger: effectiveTrigger.trigger,
223
+ status: "dry-run",
224
+ reason: decision.reason,
225
+ pattern: { id: patternId, sha256: patternSha, source: patternFetch.source },
226
+ };
227
+ if (ctx.json || args.json) {
228
+ console.log(JSON.stringify({ ...summary, pattern_body: patternBody }, null, 2));
229
+ } else {
230
+ console.error(`# ship: lane=${args.lane} kind=${lane.kind} trigger=${effectiveTrigger.trigger} (dry-run)`);
231
+ process.stdout.write(patternBody.endsWith("\n") ? patternBody : `${patternBody}\n`);
232
+ }
233
+ process.exit(EXIT_OK);
234
+ }
235
+
236
+ /* Emit the prompt for the agent to consume (same contract as
237
+ * `shipctl kickoff`). The reusable workflow pipes this into the
238
+ * configured agent runtime. */
239
+ if (!(ctx.json || args.json)) {
240
+ const provider = resolveAgentProvider(config, args.lane);
241
+ if (provider) console.error(`# ship: lane=${args.lane} agent.provider=${provider}`);
242
+ process.stdout.write(patternBody.endsWith("\n") ? patternBody : `${patternBody}\n`);
243
+ }
244
+
245
+ try {
246
+ writeMarker(cwd, idem.key, {
247
+ lane: args.lane,
248
+ pattern_id: patternId,
249
+ pattern_sha256: patternSha,
250
+ pattern_version: lane.pattern_version || null,
251
+ });
252
+ } catch (err) {
253
+ await tryCallback(args, "fail", `idempotency write failed: ${err.message}`);
254
+ die(EXIT_IDEMPOTENCY, err instanceof Error ? err.message : String(err));
255
+ }
256
+
257
+ const callbackResult = await tryCallback(
258
+ args,
259
+ "ok",
260
+ `lane ${args.lane} completed (pattern ${patternId}@${patternSha.slice(0, 8)}).`,
261
+ { pattern_id: patternId, pattern_sha256: patternSha },
262
+ );
263
+
264
+ if (ctx.json || args.json) {
265
+ console.log(
266
+ JSON.stringify(
267
+ {
268
+ lane: args.lane,
269
+ kind: lane.kind,
270
+ trigger: effectiveTrigger.trigger,
271
+ status: "completed",
272
+ pattern: { id: patternId, sha256: patternSha, source: patternFetch.source },
273
+ callback: callbackResult,
274
+ },
275
+ null,
276
+ 2,
277
+ ),
278
+ );
279
+ }
280
+
281
+ process.exit(callbackResult.ok === false ? EXIT_CALLBACK : EXIT_OK);
282
+ }
283
+
284
+ /* ------------------------------------------------------------------ */
285
+ /* Helpers */
286
+ /* ------------------------------------------------------------------ */
287
+
288
+ function parseArgs(rest) {
289
+ const out = {
290
+ lane: null,
291
+ trigger: null,
292
+ dryRun: false,
293
+ offline: false,
294
+ runId: null,
295
+ callbackUrl: null,
296
+ runToken: null,
297
+ cwd: null,
298
+ json: false,
299
+ help: false,
300
+ };
301
+ const copy = [...rest];
302
+ const str = (flag, key) => {
303
+ if (copy[0] === flag && copy[1] !== undefined) {
304
+ copy.shift();
305
+ out[key] = String(copy.shift());
306
+ return true;
307
+ }
308
+ const p = `${flag}=`;
309
+ if (copy[0] && copy[0].startsWith(p)) {
310
+ out[key] = copy[0].slice(p.length);
311
+ copy.shift();
312
+ return true;
313
+ }
314
+ return false;
315
+ };
316
+ while (copy.length) {
317
+ const a = copy[0];
318
+ if (a === "--help" || a === "-h") {
319
+ out.help = true;
320
+ copy.shift();
321
+ continue;
322
+ }
323
+ if (a === "--dry-run") {
324
+ out.dryRun = true;
325
+ copy.shift();
326
+ continue;
327
+ }
328
+ if (a === "--offline") {
329
+ out.offline = true;
330
+ copy.shift();
331
+ continue;
332
+ }
333
+ if (a === "--json") {
334
+ out.json = true;
335
+ copy.shift();
336
+ continue;
337
+ }
338
+ if (str("--lane", "lane")) continue;
339
+ if (str("--trigger", "trigger")) continue;
340
+ if (str("--ship-run-id", "runId")) continue;
341
+ if (str("--ship-callback-url", "callbackUrl")) continue;
342
+ if (str("--ship-run-token", "runToken")) continue;
343
+ if (str("--cwd", "cwd")) {
344
+ out.cwd = path.resolve(out.cwd);
345
+ continue;
346
+ }
347
+ die(EXIT_USAGE, `unknown argument: ${a}\nRun: shipctl run --help`);
348
+ }
349
+ if (out.trigger && !VALID_TRIGGERS.has(out.trigger)) {
350
+ die(
351
+ EXIT_USAGE,
352
+ `--trigger must be one of ${[...VALID_TRIGGERS].join("|")}; got ${out.trigger}`,
353
+ );
354
+ }
355
+ return out;
356
+ }
357
+
358
+ function resolveTrigger(explicit, laneKind) {
359
+ const raw =
360
+ explicit ||
361
+ (process.env.SHIP_RUN_TRIGGER && process.env.SHIP_RUN_TRIGGER.trim()) ||
362
+ inferFromEnv();
363
+ const trigger = raw || "manual";
364
+
365
+ /* `once` lanes only run under `manual` or `once` triggers. Scheduler
366
+ * or event triggers must not accidentally repeat seeding because the
367
+ * cron happens to tick. */
368
+ if (laneKind === "once") {
369
+ return { fits: trigger === "manual" || trigger === "once", trigger };
370
+ }
371
+ if (laneKind === "schedule") {
372
+ return { fits: trigger === "schedule" || trigger === "manual", trigger };
373
+ }
374
+ if (laneKind === "event") {
375
+ return { fits: trigger === "event" || trigger === "manual", trigger };
376
+ }
377
+ return { fits: false, trigger };
378
+ }
379
+
380
+ function inferFromEnv() {
381
+ if (process.env.GITHUB_EVENT_NAME === "schedule") return "schedule";
382
+ if (process.env.GITHUB_EVENT_NAME === "workflow_dispatch") return "manual";
383
+ if (process.env.GITHUB_EVENT_NAME) return "event";
384
+ return null;
385
+ }
386
+
387
+ function resolveAgentProvider(config, laneId) {
388
+ const override = config.agent?.overrides?.[laneId]?.provider;
389
+ if (override) return override;
390
+ return config.agent?.default?.provider || null;
391
+ }
392
+
393
+ async function fetchPatternBody({ patternId, patternVersion, offline, root, ctx, config }) {
394
+ /* --offline takes precedence when requested: we MUST NOT hit the
395
+ * network or fall through to another source. The lockfile is the
396
+ * single source of truth. This makes CI runs reproducible and keeps
397
+ * air-gapped installs honest. */
398
+ if (offline) return fetchFromLockfile({ patternId, root, strict: true });
399
+
400
+ /* 1) Running inside the Ship monorepo — read from disk. */
401
+ const shipRepo = resolveShipRepoRootForCatalog();
402
+ if (shipRepo) {
403
+ const file = readArtifactFile(shipRepo, "pattern", patternId);
404
+ if (file) {
405
+ const verification = verifyAgainstLockfile({
406
+ root,
407
+ patternId,
408
+ body: file.content,
409
+ });
410
+ if (verification.warning) console.error(`warn: ${verification.warning}`);
411
+ return { ok: true, body: file.content, source: "monorepo", lock: verification };
412
+ }
413
+ }
414
+
415
+ /* 2) Network: same resolver `shipctl kickoff` uses. */
416
+ const base = resolveMethodologyBase(ctx, config);
417
+ try {
418
+ const { content } = await fetchArtifact(base, "pattern", patternId, patternVersion || undefined);
419
+ const verification = verifyAgainstLockfile({ root, patternId, body: content });
420
+ if (verification.warning) console.error(`warn: ${verification.warning}`);
421
+ return { ok: true, body: content, source: "http", lock: verification };
422
+ } catch (err) {
423
+ /* If the network call failed but we have a locked copy on disk, let
424
+ * the operator fall back with a clear warning. This mirrors the
425
+ * `npm install --offline` escape hatch when the registry is down. */
426
+ const fallback = fetchFromLockfile({ patternId, root, strict: false });
427
+ if (fallback.ok) {
428
+ console.error(
429
+ `warn: network fetch failed for pattern/${patternId}; using locked copy (${fallback.source}).`,
430
+ );
431
+ return fallback;
432
+ }
433
+ return {
434
+ ok: false,
435
+ error: `failed to fetch pattern ${patternId}: ${err instanceof Error ? err.message : err}`,
436
+ };
437
+ }
438
+ }
439
+
440
+ function verifyAgainstLockfile({ root, patternId, body }) {
441
+ let lock;
442
+ try {
443
+ lock = readLockfile(root);
444
+ } catch (err) {
445
+ return { present: false, ok: null, warning: `lockfile unreadable: ${err.message}` };
446
+ }
447
+ if (!lock) return { present: false, ok: null };
448
+ const entry = lookupLock(lock, "pattern", patternId);
449
+ if (!entry) {
450
+ return {
451
+ present: true,
452
+ ok: null,
453
+ warning: `lockfile present but has no entry for pattern/${patternId}; run 'shipctl sync --lock'.`,
454
+ };
455
+ }
456
+ const result = verifyBody(entry, body);
457
+ if (!result.ok) {
458
+ return {
459
+ present: true,
460
+ ok: false,
461
+ reason: result.reason,
462
+ expected: result.expected,
463
+ actual: result.actual,
464
+ warning: `pattern/${patternId} sha256 drift vs lockfile (${result.reason}; expected ${result.expected?.slice(0, 8)} got ${result.actual?.slice(0, 8)})`,
465
+ };
466
+ }
467
+ return { present: true, ok: true, version: entry.version };
468
+ }
469
+
470
+ function fetchFromLockfile({ patternId, root, strict }) {
471
+ let lock;
472
+ try {
473
+ lock = readLockfile(root);
474
+ } catch (err) {
475
+ return {
476
+ ok: false,
477
+ error: `lockfile unreadable: ${err.message}. Run 'shipctl sync --lock' to rebuild.`,
478
+ };
479
+ }
480
+ if (!lock) {
481
+ if (!strict) return { ok: false, error: "lockfile missing" };
482
+ return {
483
+ ok: false,
484
+ error:
485
+ "--offline requires .ship/shipctl.lock.json. Run 'shipctl sync --lock' in an online environment first.",
486
+ };
487
+ }
488
+ const entry = lookupLock(lock, "pattern", patternId);
489
+ if (!entry) {
490
+ return {
491
+ ok: false,
492
+ error:
493
+ strict
494
+ ? `--offline: pattern/${patternId} missing from .ship/shipctl.lock.json. Run 'shipctl sync --lock' to re-resolve.`
495
+ : `pattern/${patternId} not in lockfile`,
496
+ };
497
+ }
498
+ const abs = path.join(root, entry.cached_path);
499
+ let body;
500
+ try {
501
+ body = fs.readFileSync(abs, "utf8");
502
+ } catch (err) {
503
+ return {
504
+ ok: false,
505
+ error: `--offline: cached pattern body unreadable at ${entry.cached_path} (${err instanceof Error ? err.message : err}). Run 'shipctl sync --lock'.`,
506
+ };
507
+ }
508
+ const verification = verifyBody(entry, body);
509
+ if (!verification.ok) {
510
+ return {
511
+ ok: false,
512
+ error: `--offline: sha256 mismatch for pattern/${patternId} (expected ${verification.expected?.slice(0, 8)}, got ${verification.actual?.slice(0, 8)}). Re-run 'shipctl sync --lock'.`,
513
+ };
514
+ }
515
+ return {
516
+ ok: true,
517
+ body,
518
+ source: "lockfile",
519
+ lock: { present: true, ok: true, version: entry.version },
520
+ };
521
+ }
522
+
523
+ function resolveMethodologyBase(ctx, config) {
524
+ const fromFlag = ctx.baseUrl;
525
+ const raw = config?.api?.base_url;
526
+ if (typeof raw === "string" && raw.trim()) {
527
+ const u = raw.replace(/\/$/, "");
528
+ return u.includes("/api/methodology") ? u : `${u}/api/methodology`;
529
+ }
530
+ return fromFlag;
531
+ }
532
+
533
+ /*
534
+ * Assemble the callback `metrics` bag so Ship's backend can tie each
535
+ * run back to its lane + GitHub Actions run without re-parsing logs.
536
+ *
537
+ * Always-on breadcrumbs (iff we have the data):
538
+ * - lane_id — id from `.ship/config.yml`; also recoverable
539
+ * from the ship-<lane_id>.yml workflow path,
540
+ * but duplicating here costs us nothing and
541
+ * makes non-GitHub adapters (RFC-0007 Phase 8)
542
+ * cheaper because they won't have that URL.
543
+ * - gh_workflow_run_id — GITHUB_RUN_ID env (empty outside Actions).
544
+ * - gh_html_url — constructed from GITHUB_SERVER_URL / _REPOSITORY
545
+ * / _RUN_ID so the Console can deep-link the
546
+ * GH UI from a Lane detail view.
547
+ * - gh_event — GITHUB_EVENT_NAME (push / schedule / PR /…).
548
+ *
549
+ * Caller-supplied extras (pattern id / sha) stack on top. Nothing here
550
+ * is required; the backend treats unknown keys as opaque forward-compat
551
+ * payload.
552
+ */
553
+ function collectCallbackMetrics(args, extra = {}) {
554
+ const env = process.env;
555
+ const out = { ...(extra || {}) };
556
+ if (args && args.lane && !out.lane_id) out.lane_id = args.lane;
557
+ if (env.GITHUB_RUN_ID && !out.gh_workflow_run_id) {
558
+ out.gh_workflow_run_id = env.GITHUB_RUN_ID;
559
+ }
560
+ if (env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID && !out.gh_html_url) {
561
+ out.gh_html_url = `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`;
562
+ }
563
+ if (env.GITHUB_EVENT_NAME && !out.gh_event) out.gh_event = env.GITHUB_EVENT_NAME;
564
+ return out;
565
+ }
566
+
567
+ async function tryCallback(args, status, summary, extraMetrics = {}) {
568
+ const url = args.callbackUrl || process.env.SHIP_CALLBACK_URL;
569
+ if (!url) return { ok: null, skipped: "no-callback-url" };
570
+ const token = args.runToken || process.env.SHIP_RUN_TOKEN;
571
+ if (!token) {
572
+ console.error(
573
+ "warn: SHIP_RUN_TOKEN missing; skipping callback. (Set via --ship-run-token or env.)",
574
+ );
575
+ return { ok: false, skipped: "no-token" };
576
+ }
577
+ const body = { status: status === "ok" ? "succeeded" : status === "fail" ? "failed" : status };
578
+ if (summary) body.summary = String(summary).slice(0, 1024);
579
+ const metrics = collectCallbackMetrics(args, extraMetrics);
580
+ if (Object.keys(metrics).length > 0) body.metrics = metrics;
581
+
582
+ try {
583
+ const res = await fetch(url, {
584
+ method: "POST",
585
+ headers: {
586
+ "Content-Type": "application/json",
587
+ Accept: "application/json",
588
+ Authorization: `Bearer ${token}`,
589
+ },
590
+ body: JSON.stringify(body),
591
+ });
592
+ if (!res.ok) {
593
+ const text = await res.text().catch(() => "");
594
+ console.error(`warn: callback returned HTTP ${res.status} ${res.statusText}\n${text}`);
595
+ return { ok: false, status: res.status };
596
+ }
597
+ return { ok: true, status: res.status };
598
+ } catch (err) {
599
+ console.error(`warn: callback POST failed: ${err instanceof Error ? err.message : err}`);
600
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
601
+ }
602
+ }
603
+
604
+ function emitSummary(ctx, args, summary) {
605
+ if (ctx.json || args.json) {
606
+ console.log(JSON.stringify(summary, null, 2));
607
+ } else {
608
+ console.error(
609
+ `# ship: lane=${summary.lane} status=${summary.status}${summary.reason ? ` reason="${summary.reason}"` : ""}`,
610
+ );
611
+ }
612
+ }
613
+
614
+ function die(code, msg) {
615
+ console.error(msg);
616
+ process.exit(code);
617
+ }