@elmundi/ship-cli 0.14.1 → 0.15.3

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