@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,749 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import {
5
+ readConfig,
6
+ readState,
7
+ writeState,
8
+ findShipRoot,
9
+ } from "../config/io.mjs";
10
+ import { validateConfig } from "../config/schema.mjs";
11
+ import { fetchManifest, fetchArtifact } from "../http.mjs";
12
+ import {
13
+ readCached,
14
+ writeCached,
15
+ listCached,
16
+ cachePath,
17
+ verifyCachedOnDisk,
18
+ } from "../cache/store.mjs";
19
+ import { resolveShipRepoRootForCatalog } from "../find-ship-root.mjs";
20
+ import { readArtifactFile, scanArtifacts } from "../artifacts/fs-index.mjs";
21
+ import {
22
+ writeLockfile,
23
+ entryFromBody,
24
+ lockKey,
25
+ LOCKFILE_SCHEMA_VERSION,
26
+ } from "../state/lockfile.mjs";
27
+ import { getCliVersion } from "../version.mjs";
28
+
29
+ function parseSyncArgs(rest) {
30
+ const out = {
31
+ cwd: process.cwd(),
32
+ checkOnly: false,
33
+ dryRun: false,
34
+ forceUnpin: false,
35
+ channel: null,
36
+ only: [],
37
+ lock: false,
38
+ json: false,
39
+ };
40
+ const copy = [...rest];
41
+ while (copy.length) {
42
+ const a = copy[0];
43
+ if (a === "--check-only") {
44
+ out.checkOnly = true;
45
+ copy.shift();
46
+ continue;
47
+ }
48
+ if (a === "--dry-run") {
49
+ out.dryRun = true;
50
+ copy.shift();
51
+ continue;
52
+ }
53
+ if (a === "--force-unpin") {
54
+ out.forceUnpin = true;
55
+ copy.shift();
56
+ continue;
57
+ }
58
+ if (a === "--lock") {
59
+ out.lock = true;
60
+ copy.shift();
61
+ continue;
62
+ }
63
+ if (a === "--json") {
64
+ out.json = true;
65
+ copy.shift();
66
+ continue;
67
+ }
68
+ if (a === "--channel" && copy[1]) {
69
+ copy.shift();
70
+ out.channel = copy.shift();
71
+ continue;
72
+ }
73
+ if (a.startsWith("--channel=")) {
74
+ out.channel = a.slice("--channel=".length);
75
+ copy.shift();
76
+ continue;
77
+ }
78
+ if (a === "--only" && copy[1]) {
79
+ copy.shift();
80
+ out.only.push(copy.shift());
81
+ continue;
82
+ }
83
+ if (a.startsWith("--only=")) {
84
+ out.only.push(a.slice("--only=".length));
85
+ copy.shift();
86
+ continue;
87
+ }
88
+ if (a === "--cwd" && copy[1]) {
89
+ copy.shift();
90
+ out.cwd = copy.shift();
91
+ continue;
92
+ }
93
+ if (a.startsWith("--cwd=")) {
94
+ out.cwd = a.slice("--cwd=".length);
95
+ copy.shift();
96
+ continue;
97
+ }
98
+ copy.shift();
99
+ }
100
+ return out;
101
+ }
102
+
103
+ function parseOnlySpec(spec) {
104
+ const idx = spec.indexOf(":");
105
+ if (idx <= 0) return null;
106
+ return { kind: spec.slice(0, idx), id: spec.slice(idx + 1) };
107
+ }
108
+
109
+ function pinSatisfies(pin, version) {
110
+ if (!pin) return true;
111
+ const p = pin.trim();
112
+ if (p === version) return true;
113
+ if (/^\d+(\.\d+){0,2}$/.test(p)) return version.startsWith(p);
114
+ // Caret / tilde / comparators: conservative match on major.minor for ranges.
115
+ const m = p.match(/^(\^|~)(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
116
+ if (m) {
117
+ const [, op, maj, min] = m;
118
+ const parts = version.split(".");
119
+ if (parts[0] !== maj) return false;
120
+ if (op === "~" && min !== undefined && parts[1] !== min) return false;
121
+ return true;
122
+ }
123
+ return false;
124
+ }
125
+
126
+ function hoursSince(iso) {
127
+ if (!iso) return Infinity;
128
+ const ts = Date.parse(iso);
129
+ if (Number.isNaN(ts)) return Infinity;
130
+ return (Date.now() - ts) / (1000 * 60 * 60);
131
+ }
132
+
133
+ function manifestHash(entries) {
134
+ const canonical = JSON.stringify(
135
+ entries.map((e) => ({
136
+ kind: e.kind,
137
+ id: e.id,
138
+ version: e.version,
139
+ content_sha256: e.content_sha256,
140
+ })),
141
+ );
142
+ return crypto.createHash("sha256").update(canonical).digest("hex");
143
+ }
144
+
145
+ function appendTelemetryEvent(shipRoot, config, event) {
146
+ if (!config.telemetry || config.telemetry.share !== true) return;
147
+ const file = path.join(shipRoot, ".ship", "telemetry-outbox.jsonl");
148
+ fs.mkdirSync(path.dirname(file), { recursive: true });
149
+ const envelope = {
150
+ event: event.event,
151
+ ts: new Date().toISOString(),
152
+ anonymous_id: config.telemetry.anonymous_id,
153
+ shipctl_version: "0.9.0",
154
+ stack_preset: config.stack?.preset || null,
155
+ payload: event.payload,
156
+ };
157
+ fs.appendFileSync(file, `${JSON.stringify(envelope)}\n`, "utf8");
158
+ }
159
+
160
+ /**
161
+ * Build the set of desired {kind,id} targets given config + cache + manifest.
162
+ */
163
+ function computeDesired(config, manifestEntries, cached, onlySpecs) {
164
+ if (onlySpecs.length > 0) {
165
+ const specs = onlySpecs.map(parseOnlySpec).filter(Boolean);
166
+ return manifestEntries.filter((e) =>
167
+ specs.some((s) => s.kind === e.kind && s.id === e.id),
168
+ );
169
+ }
170
+
171
+ const wanted = new Map();
172
+ const add = (e) => {
173
+ const key = `${e.kind}:${e.id}`;
174
+ if (!wanted.has(key)) wanted.set(key, e);
175
+ };
176
+
177
+ const pins = config.artifacts?.pins || {};
178
+ for (const pinKey of Object.keys(pins)) {
179
+ const slash = pinKey.indexOf("/");
180
+ if (slash < 0) continue;
181
+ const kind = pinKey.slice(0, slash);
182
+ const id = pinKey.slice(slash + 1);
183
+ const entry = manifestEntries.find((e) => e.kind === kind && e.id === id);
184
+ if (entry) add(entry);
185
+ }
186
+
187
+ for (const c of cached) {
188
+ const entry = manifestEntries.find((e) => e.kind === c.kind && e.id === c.id);
189
+ if (entry) add(entry);
190
+ }
191
+
192
+ const preset = config.stack?.preset;
193
+ if (preset) {
194
+ const presetCollectionId = `preset-${preset}`;
195
+ const e = manifestEntries.find(
196
+ (m) => m.kind === "collection" && m.id === presetCollectionId,
197
+ );
198
+ if (e) add(e);
199
+ }
200
+
201
+ for (const agent of config.stack?.agents || []) {
202
+ const e = manifestEntries.find(
203
+ (m) => m.kind === "collection" && m.id === `agent-rules/${agent}`,
204
+ );
205
+ if (e) add(e);
206
+ }
207
+
208
+ return [...wanted.values()];
209
+ }
210
+
211
+ /**
212
+ * Reusable sync implementation, suitable for embedding from other CLI
213
+ * commands (notably `shipctl init`). Returns a structured summary instead
214
+ * of calling `process.exit`.
215
+ *
216
+ * @typedef {Object} SyncOptions
217
+ * @property {string} [cwd]
218
+ * @property {string} [baseUrl]
219
+ * @property {string} [channel]
220
+ * @property {boolean} [dryRun]
221
+ * @property {boolean} [checkOnly]
222
+ * @property {boolean} [forceUnpin]
223
+ * @property {string[]} [only] "kind:id" specs; overrides config-derived desired set
224
+ * @property {Array<string|{kind:string,id:string}>} [include]
225
+ * Additional specs merged with `only` (kept separate so callers
226
+ * can reason about them independently).
227
+ * @property {string[]} [onlyKinds] Optional post-filter on manifest entries (kind whitelist)
228
+ * @property {boolean} [verbose] When true, write human progress to stdout (default: CLI only)
229
+ *
230
+ * @returns {Promise<{
231
+ * up_to_date:number, updated:number, skipped_pin:number,
232
+ * deprecated:number, yanked:number, failed:number,
233
+ * notes:string[], entries:Array<{kind:string,id:string,version:string,action:string}>
234
+ * }>}
235
+ */
236
+ export async function syncArtifacts(options = {}) {
237
+ const {
238
+ cwd = process.cwd(),
239
+ baseUrl: baseUrlOpt,
240
+ channel: channelOpt,
241
+ dryRun = false,
242
+ checkOnly = false,
243
+ forceUnpin = false,
244
+ only = [],
245
+ include = [],
246
+ onlyKinds = null,
247
+ verbose = false,
248
+ } = options;
249
+
250
+ const root = findShipRoot(cwd);
251
+ if (!root) {
252
+ const err = new Error(".ship/ not found. Run 'shipctl config init' first.");
253
+ err.exitCode = 10;
254
+ throw err;
255
+ }
256
+
257
+ const { config } = readConfig(root);
258
+ const valid = validateConfig(config);
259
+ if (!valid.ok) {
260
+ const err = new Error(valid.errors.join("\n"));
261
+ err.exitCode = 10;
262
+ throw err;
263
+ }
264
+ if (verbose) for (const w of valid.warnings) console.error(`warn: ${w}`);
265
+
266
+ const baseUrl = (
267
+ baseUrlOpt ||
268
+ process.env.SHIP_API_BASE ||
269
+ config.api?.base_url ||
270
+ "https://ship.elmundi.com"
271
+ ).replace(/\/$/, "");
272
+ const channel = channelOpt || process.env.SHIP_CHANNEL || config.api?.channel || "stable";
273
+ const ttlHours =
274
+ typeof config.api?.ttl_hours === "number" ? config.api.ttl_hours : 24;
275
+
276
+ if (dryRun && verbose) {
277
+ console.log(
278
+ `plan: GET ${baseUrl}/{patterns,tools,collections} (channel=${channel})`,
279
+ );
280
+ }
281
+
282
+ let manifestEntries;
283
+ try {
284
+ manifestEntries = await fetchManifest(baseUrl, { channel });
285
+ } catch (e) {
286
+ const err = new Error(e.message);
287
+ err.exitCode = 20;
288
+ throw err;
289
+ }
290
+
291
+ if (Array.isArray(onlyKinds) && onlyKinds.length) {
292
+ manifestEntries = manifestEntries.filter((m) => onlyKinds.includes(m.kind));
293
+ }
294
+
295
+ const { state } = readState(root);
296
+ const cached = listCached(root);
297
+
298
+ // Normalise "include" into "kind:id" strings, merged with `only`.
299
+ const mergedOnly = [
300
+ ...only,
301
+ ...include
302
+ .map((e) => (typeof e === "string" ? e : e && e.kind && e.id ? `${e.kind}:${e.id}` : null))
303
+ .filter(Boolean),
304
+ ];
305
+
306
+ const desired = computeDesired(config, manifestEntries, cached, mergedOnly);
307
+
308
+ const summary = {
309
+ up_to_date: 0,
310
+ updated: 0,
311
+ skipped_pin: 0,
312
+ deprecated: 0,
313
+ yanked: 0,
314
+ failed: 0,
315
+ };
316
+ /** @type {string[]} */
317
+ const notes = [];
318
+ /** @type {Array<{kind:string,id:string,version:string,action:string}>} */
319
+ const entries = [];
320
+ const pins = config.artifacts?.pins || {};
321
+
322
+ for (const entry of desired) {
323
+ const key = `${entry.kind}/${entry.id}`;
324
+ if (entry.yanked === true) {
325
+ summary.yanked += 1;
326
+ notes.push(`yanked: ${key}@${entry.version}`);
327
+ entries.push({ kind: entry.kind, id: entry.id, version: entry.version, action: "yanked" });
328
+ continue;
329
+ }
330
+ if (entry.deprecated === true) {
331
+ const isPinned = Object.prototype.hasOwnProperty.call(pins, key);
332
+ summary.deprecated += 1;
333
+ notes.push(
334
+ `deprecated: ${key}@${entry.version}${entry.replaced_by ? ` → ${entry.replaced_by}` : ""}`,
335
+ );
336
+ if (!isPinned) {
337
+ entries.push({ kind: entry.kind, id: entry.id, version: entry.version, action: "deprecated" });
338
+ continue;
339
+ }
340
+ }
341
+
342
+ const pin = pins[key];
343
+ if (pin && !pinSatisfies(pin, entry.version) && !forceUnpin) {
344
+ summary.skipped_pin += 1;
345
+ notes.push(`skipped_pin: ${key} pinned=${pin} upstream=${entry.version}`);
346
+ entries.push({ kind: entry.kind, id: entry.id, version: entry.version, action: "skipped_pin" });
347
+ continue;
348
+ }
349
+
350
+ const localAll = cached.filter((c) => c.kind === entry.kind && c.id === entry.id);
351
+ const sameVersion = localAll.find((c) => c.version === entry.version);
352
+ if (sameVersion) {
353
+ const existingHash = sameVersion.sha256 === entry.content_sha256;
354
+ const age = hoursSince(sameVersion.fetched_at);
355
+ if (existingHash && age < ttlHours) {
356
+ // Physical-presence & integrity guard: meta.json alone is not enough
357
+ // — the rendered body may have been deleted or edited in place, in
358
+ // which case we must force a re-fetch so the cache matches disk.
359
+ const onDisk = verifyCachedOnDisk(root, entry.kind, entry.id, entry.version);
360
+ if (onDisk.ok) {
361
+ summary.up_to_date += 1;
362
+ entries.push({ kind: entry.kind, id: entry.id, version: entry.version, action: "up_to_date" });
363
+ continue;
364
+ }
365
+ notes.push(
366
+ `refetch: ${key}@${entry.version} (${onDisk.reason === "missing_body" ? "missing" : onDisk.reason === "drift" ? "drifted" : onDisk.reason || "invalid"})`,
367
+ );
368
+ // Fall through to the fetch branch below (respect checkOnly/dryRun).
369
+ }
370
+ }
371
+
372
+ if (checkOnly || dryRun) {
373
+ summary.updated += 1;
374
+ entries.push({ kind: entry.kind, id: entry.id, version: entry.version, action: "would_update" });
375
+ if (dryRun && verbose) {
376
+ console.log(`plan: POST ${baseUrl}/fetch ${JSON.stringify({ kind: entry.kind, id: entry.id, version: entry.version })}`);
377
+ }
378
+ continue;
379
+ }
380
+
381
+ try {
382
+ const { content, meta } = await fetchArtifact(baseUrl, entry.kind, entry.id, entry.version);
383
+ if (entry.content_sha256 && meta.content_sha256 !== entry.content_sha256) {
384
+ summary.failed += 1;
385
+ notes.push(
386
+ `failed: ${key}@${entry.version} content_sha256 mismatch (manifest=${entry.content_sha256} got=${meta.content_sha256})`,
387
+ );
388
+ entries.push({ kind: entry.kind, id: entry.id, version: entry.version, action: "failed" });
389
+ continue;
390
+ }
391
+ writeCached(root, entry.kind, entry.id, entry.version, content, {
392
+ ...meta,
393
+ updated_at: entry.updated_at || meta.updated_at,
394
+ channel: entry.channel || meta.channel,
395
+ });
396
+ summary.updated += 1;
397
+ entries.push({ kind: entry.kind, id: entry.id, version: entry.version, action: "updated" });
398
+ } catch (e) {
399
+ summary.failed += 1;
400
+ notes.push(`failed: ${key}@${entry.version}: ${e.message}`);
401
+ entries.push({ kind: entry.kind, id: entry.id, version: entry.version, action: "failed" });
402
+ }
403
+ }
404
+
405
+ if (!checkOnly && !dryRun) {
406
+ const newState = {
407
+ ...state,
408
+ last_sync_at: new Date().toISOString(),
409
+ last_manifest_hash: manifestHash(manifestEntries),
410
+ };
411
+ writeState(root, newState);
412
+
413
+ appendTelemetryEvent(root, config, {
414
+ event: "artifact.sync",
415
+ payload: {
416
+ categories: [...new Set(desired.map((e) => e.kind))].sort(),
417
+ updates_count: summary.updated,
418
+ failures_count: summary.failed,
419
+ },
420
+ });
421
+ }
422
+
423
+ return { ...summary, notes, entries };
424
+ }
425
+
426
+ /**
427
+ * Produce a lockfile covering every pattern the config's lanes depend on,
428
+ * plus any pattern the config pins explicitly. Other artifact kinds are
429
+ * out of scope today — lanes only reference patterns, and pins for tools
430
+ * or collections don't need reproducibility guarantees at run-time (yet).
431
+ *
432
+ * Resolution order per pattern:
433
+ * 1. `.ship/cache/pattern/<id>@<v>/ARTIFACT.md` (materialised by sync).
434
+ * 2. Ship monorepo fallback (`artifacts/patterns/<id>/ARTIFACT.md`).
435
+ * 3. One-shot POST /fetch to the methodology API.
436
+ *
437
+ * Returns a structured report instead of writing to disk directly so the
438
+ * caller can roll it into the overall sync summary and fail the job on
439
+ * unresolved patterns.
440
+ *
441
+ * @param {Object} opts
442
+ * @param {string} opts.shipRoot
443
+ * @param {Object} opts.config
444
+ * @param {string} opts.baseUrl
445
+ * @param {string} opts.channel
446
+ * @param {boolean} [opts.verbose]
447
+ * @returns {Promise<{ lockfile:object, resolved:Array<object>, unresolved:Array<object>, notes:string[] }>}
448
+ */
449
+ export async function buildLockfile({ shipRoot, config, baseUrl, channel, verbose = false }) {
450
+ /** @type {Record<string, object>} */
451
+ const artifacts = {};
452
+ /** @type {Array<{kind:string,id:string,version:string,source:string,pinned:boolean}>} */
453
+ const resolved = [];
454
+ /** @type {Array<{kind:string,id:string,reason:string}>} */
455
+ const unresolved = [];
456
+ /** @type {string[]} */
457
+ const notes = [];
458
+
459
+ const pins = config.artifacts?.pins || {};
460
+ const lanePatterns = Object.entries(config.lanes || {})
461
+ .map(([laneId, lane]) => ({ laneId, lane }))
462
+ .filter((r) => r.lane && typeof r.lane.pattern === "string");
463
+ const pinPatterns = Object.keys(pins)
464
+ .filter((k) => k.startsWith("pattern/"))
465
+ .map((k) => ({ laneId: null, lane: { pattern: k.slice("pattern/".length) } }));
466
+
467
+ /* De-duplicate on pattern id while preserving lane provenance (useful
468
+ * for the `notes` field — operators want to know which lane pinned a
469
+ * given pattern when they read the diff). */
470
+ const seen = new Map();
471
+ for (const row of [...lanePatterns, ...pinPatterns]) {
472
+ const pid = row.lane.pattern;
473
+ if (!seen.has(pid)) seen.set(pid, { id: pid, by: [] });
474
+ seen.get(pid).by.push(row.laneId || "config.artifacts.pins");
475
+ }
476
+
477
+ const shipRepo = resolveShipRepoRootForCatalog();
478
+ const cached = listCached(shipRoot);
479
+
480
+ for (const [patternId, ctx] of seen) {
481
+ const pinKey = `pattern/${patternId}`;
482
+ const isPinned = Object.prototype.hasOwnProperty.call(pins, pinKey);
483
+
484
+ /* 1) Look for an already-cached copy. */
485
+ const localAll = cached.filter((c) => c.kind === "pattern" && c.id === patternId);
486
+ if (localAll.length) {
487
+ localAll.sort((a, b) => cmpSemver(b.version, a.version));
488
+ const latest = localAll[0];
489
+ const body = readCached(shipRoot, "pattern", patternId, latest.version);
490
+ if (body && body.content) {
491
+ artifacts[lockKey("pattern", patternId)] = entryFromBody({
492
+ body: body.content,
493
+ version: latest.version,
494
+ cachedPath: path.relative(
495
+ shipRoot,
496
+ cachePath(shipRoot, "pattern", patternId, latest.version),
497
+ ),
498
+ source: "http",
499
+ pinned: isPinned,
500
+ channel: body.meta?.channel || channel,
501
+ });
502
+ resolved.push({
503
+ kind: "pattern",
504
+ id: patternId,
505
+ version: latest.version,
506
+ source: "cache",
507
+ pinned: isPinned,
508
+ lanes: ctx.by,
509
+ });
510
+ continue;
511
+ }
512
+ }
513
+
514
+ /* 2) Running inside the Ship monorepo — read from artifacts/ and
515
+ * materialise the body into the customer's local cache so the
516
+ * lockfile's `cached_path` is always inside ship_root. This keeps
517
+ * `shipctl run --offline` working without SHIP_REPO set at run
518
+ * time (important for `act`-style local CI reproductions and
519
+ * enterprise forks where the monorepo isn't on the runner). */
520
+ if (shipRepo) {
521
+ const file = readArtifactFile(shipRepo, "pattern", patternId);
522
+ if (file) {
523
+ const version = parseVersionFromFrontmatter(file.content) || "0.0.0-monorepo";
524
+ writeCached(shipRoot, "pattern", patternId, version, file.content, {
525
+ kind: "pattern",
526
+ id: patternId,
527
+ version,
528
+ channel,
529
+ });
530
+ const cachedAbs = cachePath(shipRoot, "pattern", patternId, version);
531
+ artifacts[lockKey("pattern", patternId)] = entryFromBody({
532
+ body: file.content,
533
+ version,
534
+ cachedPath: path.relative(shipRoot, cachedAbs).replace(/\\/g, "/"),
535
+ source: "monorepo",
536
+ pinned: isPinned,
537
+ channel,
538
+ });
539
+ resolved.push({
540
+ kind: "pattern",
541
+ id: patternId,
542
+ version,
543
+ source: "monorepo",
544
+ pinned: isPinned,
545
+ lanes: ctx.by,
546
+ });
547
+ continue;
548
+ }
549
+ }
550
+
551
+ /* 3) Last resort — fetch fresh from the API. We can't cache-write
552
+ * without a known version, so only the sha256 + body go into the
553
+ * lockfile; subsequent `shipctl sync --lock` runs will promote it
554
+ * into the cache on the normal sync pass. */
555
+ try {
556
+ const pin = pins[pinKey];
557
+ const { content, meta } = await fetchArtifact(baseUrl, "pattern", patternId, pin || undefined);
558
+ const version = meta.version || "0.0.0";
559
+ // Promote into cache immediately so subsequent --offline runs find it.
560
+ writeCached(shipRoot, "pattern", patternId, version, content, {
561
+ ...meta,
562
+ channel: meta.channel || channel,
563
+ });
564
+ artifacts[lockKey("pattern", patternId)] = entryFromBody({
565
+ body: content,
566
+ version,
567
+ cachedPath: path.relative(
568
+ shipRoot,
569
+ cachePath(shipRoot, "pattern", patternId, version),
570
+ ),
571
+ source: "http",
572
+ pinned: isPinned,
573
+ channel: meta.channel || channel,
574
+ });
575
+ resolved.push({
576
+ kind: "pattern",
577
+ id: patternId,
578
+ version,
579
+ source: "http",
580
+ pinned: isPinned,
581
+ lanes: ctx.by,
582
+ });
583
+ } catch (err) {
584
+ unresolved.push({
585
+ kind: "pattern",
586
+ id: patternId,
587
+ reason: err instanceof Error ? err.message : String(err),
588
+ });
589
+ notes.push(`unresolved: pattern/${patternId}: ${err instanceof Error ? err.message : err}`);
590
+ if (verbose) {
591
+ console.error(
592
+ `warn: lock: could not resolve pattern/${patternId}: ${err instanceof Error ? err.message : err}`,
593
+ );
594
+ }
595
+ }
596
+ }
597
+
598
+ const lockfile = {
599
+ version: LOCKFILE_SCHEMA_VERSION,
600
+ generated_at: new Date().toISOString(),
601
+ shipctl_version: getCliVersion(),
602
+ source: { base_url: baseUrl, channel },
603
+ artifacts,
604
+ notes: notes.slice(),
605
+ };
606
+
607
+ return { lockfile, resolved, unresolved, notes };
608
+ }
609
+
610
+ function parseVersionFromFrontmatter(content) {
611
+ if (!content.startsWith("---")) return null;
612
+ const end = content.indexOf("\n---", 3);
613
+ if (end < 0) return null;
614
+ const header = content.slice(3, end);
615
+ const m = header.match(/^version:\s*['"]?([^'"\n]+)['"]?/m);
616
+ return m ? m[1].trim() : null;
617
+ }
618
+
619
+ function cmpSemver(a, b) {
620
+ const parts = (s) =>
621
+ String(s)
622
+ .split(/[.-]/)
623
+ .map((x) => (Number.isNaN(Number(x)) ? x : Number(x)));
624
+ const pa = parts(a);
625
+ const pb = parts(b);
626
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
627
+ const xa = pa[i];
628
+ const xb = pb[i];
629
+ if (xa === undefined) return -1;
630
+ if (xb === undefined) return 1;
631
+ if (xa === xb) continue;
632
+ if (typeof xa === typeof xb) return xa < xb ? -1 : 1;
633
+ return typeof xa === "number" ? -1 : 1;
634
+ }
635
+ return 0;
636
+ }
637
+
638
+ export async function syncCommand(ctx, rest) {
639
+ const args = parseSyncArgs(rest);
640
+ if (ctx?.dryRun) args.dryRun = true;
641
+ if (ctx?.json) args.json = true;
642
+
643
+ let result;
644
+ try {
645
+ result = await syncArtifacts({
646
+ cwd: args.cwd,
647
+ baseUrl: ctx?.baseUrl,
648
+ channel: args.channel,
649
+ dryRun: args.dryRun,
650
+ checkOnly: args.checkOnly,
651
+ forceUnpin: args.forceUnpin,
652
+ only: args.only,
653
+ verbose: !args.json,
654
+ });
655
+ } catch (e) {
656
+ /* When `--lock` is requested we treat manifest failures as soft:
657
+ * the lockfile build has its own resolution chain (cache → monorepo
658
+ * → HTTP) and will report its own unresolved entries. This keeps
659
+ * `shipctl sync --lock` useful for customers who only run Ship-
660
+ * locally (e.g. internal forks) or are offline with a mirrored
661
+ * monorepo on SHIP_REPO. */
662
+ if (!args.lock) {
663
+ const code = typeof e.exitCode === "number" ? e.exitCode : 1;
664
+ console.error(e.message);
665
+ process.exit(code);
666
+ }
667
+ if (!args.json) console.error(`warn: manifest sync skipped (${e.message || e})`);
668
+ result = {
669
+ up_to_date: 0,
670
+ updated: 0,
671
+ skipped_pin: 0,
672
+ deprecated: 0,
673
+ yanked: 0,
674
+ failed: 0,
675
+ notes: [`manifest sync skipped (${e.message || e})`],
676
+ entries: [],
677
+ };
678
+ }
679
+
680
+ /* --lock: walk the lane patterns, make sure every body is materialised
681
+ * under .ship/cache, and dump a lockfile so `shipctl run --offline` has
682
+ * a content-sha to compare against. Only runs after a successful-ish
683
+ * normal sync (we don't care if individual artifacts failed upstream —
684
+ * lockfile generation has its own fallback chain). */
685
+ let lockResult = null;
686
+ if (args.lock && !args.dryRun && !args.checkOnly) {
687
+ const shipRoot = findShipRoot(args.cwd);
688
+ if (!shipRoot) {
689
+ console.error("--lock: .ship/ not found; run 'shipctl init' first.");
690
+ process.exit(10);
691
+ }
692
+ const { config } = readConfig(shipRoot);
693
+ const baseUrl = (
694
+ ctx?.baseUrl ||
695
+ process.env.SHIP_API_BASE ||
696
+ config.api?.base_url ||
697
+ "https://ship.elmundi.com"
698
+ ).replace(/\/$/, "");
699
+ const channel = args.channel || process.env.SHIP_CHANNEL || config.api?.channel || "stable";
700
+ try {
701
+ lockResult = await buildLockfile({
702
+ shipRoot,
703
+ config,
704
+ baseUrl,
705
+ channel,
706
+ verbose: !args.json,
707
+ });
708
+ writeLockfile(shipRoot, lockResult.lockfile);
709
+ } catch (err) {
710
+ console.error(`--lock: ${err instanceof Error ? err.message : err}`);
711
+ process.exit(20);
712
+ }
713
+ }
714
+
715
+ if (args.json) {
716
+ const payload = { ...result };
717
+ if (lockResult) {
718
+ payload.lock = {
719
+ path: path.join(".ship", "shipctl.lock.json"),
720
+ entries: Object.keys(lockResult.lockfile.artifacts).length,
721
+ resolved: lockResult.resolved,
722
+ unresolved: lockResult.unresolved,
723
+ };
724
+ }
725
+ console.log(JSON.stringify(payload, null, 2));
726
+ } else {
727
+ const lines = [
728
+ `up_to_date: ${result.up_to_date}`,
729
+ `updated: ${result.updated}`,
730
+ `skipped_pin:${result.skipped_pin}`,
731
+ `deprecated: ${result.deprecated}${result.deprecated ? " (…)" : ""}`,
732
+ `yanked: ${result.yanked}`,
733
+ `failed: ${result.failed}`,
734
+ ];
735
+ for (const l of lines) console.log(l);
736
+ for (const n of result.notes) console.log(` - ${n}`);
737
+
738
+ if (lockResult) {
739
+ const entryCount = Object.keys(lockResult.lockfile.artifacts).length;
740
+ console.log(
741
+ `lock: wrote .ship/shipctl.lock.json (${entryCount} entries, ${lockResult.unresolved.length} unresolved)`,
742
+ );
743
+ for (const n of lockResult.notes) console.log(` - ${n}`);
744
+ }
745
+ }
746
+
747
+ if (result.failed > 0) process.exit(20);
748
+ if (lockResult && lockResult.unresolved.length > 0) process.exit(20);
749
+ }