@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,223 +0,0 @@
1
- /**
2
- * `shipctl migrate` — convert `.ship/config.yml` from v1 to v2.
3
- *
4
- * v2 introduces the `lanes` map, `agent.default/overrides`, and deprecates
5
- * the `workflow` artifact kind. The migration is deliberately conservative:
6
- *
7
- * - Every v1 field we still recognise is copied verbatim, not rewritten.
8
- * - `stack.agent.provider` is lifted into `agent.default.provider`; we
9
- * leave `stack.agent` intact so rolling back to an old shipctl still
10
- * finds the field where v1 expected it.
11
- * - The legacy `lanes:` list-of-strings (a shape customers wrote by
12
- * hand between 0.9.x and 0.11.x — not a formal v1 field but widely
13
- * present on disk) is translated into the v2 `lanes:` map using the
14
- * preset defaults table below.
15
- * - Unknown keys survive at the top level; v2 validation emits a
16
- * warning but does not drop them.
17
- *
18
- * The migration is idempotent: running it against a v2 config returns
19
- * the input untouched.
20
- */
21
-
22
- import {
23
- CONFIG_SCHEMA_VERSION,
24
- DEFAULT_PROCESS_CONFIG,
25
- LEGACY_CONFIG_SCHEMA_VERSION,
26
- } from "./schema.mjs";
27
-
28
- /**
29
- * Default lane translations for the well-known v1 `lanes:` list entries.
30
- * Each entry maps the v1 string id to a v2 lane body. These were the
31
- * four lane ids the `monorepo`, `web-app`, and `api-backend` presets
32
- * bundled; anything outside this table is left for the caller to fill
33
- * in manually (the migrator warns and adds a stub).
34
- *
35
- * Keep these aligned with `artifacts/collections/preset-*` and with
36
- * RFC-0007 §"Lane-id reservations".
37
- */
38
- const V1_LANE_DEFAULTS = Object.freeze({
39
- pr_review: {
40
- kind: "event",
41
- pattern: "flow-pr-self-review",
42
- on: "pull_request",
43
- permissions: { contents: "read", "pull-requests": "write" },
44
- },
45
- daily_standup: {
46
- kind: "schedule",
47
- pattern: "flow-daily-retro",
48
- cron: "0 9 * * 1-5",
49
- },
50
- tech_debt: {
51
- kind: "schedule",
52
- pattern: "flow-learning-capture",
53
- cron: "0 10 * * 1",
54
- },
55
- self_heal: {
56
- kind: "event",
57
- pattern: "op-workflow-self-heal",
58
- on: "workflow_run",
59
- when: { conclusion: "failure" },
60
- permissions: { contents: "read", actions: "read", "pull-requests": "write" },
61
- },
62
- });
63
-
64
- /**
65
- * @typedef {{
66
- * migrated: boolean,
67
- * config: object,
68
- * warnings: string[],
69
- * stub_lanes: string[],
70
- * }} MigrationResult
71
- */
72
-
73
- /**
74
- * Migrate a parsed config object from v1 to v2. Returns a fresh config
75
- * object (input is not mutated) plus any non-fatal warnings.
76
- *
77
- * @param {object} input
78
- * @returns {MigrationResult}
79
- */
80
- export function migrateV1ToV2(input) {
81
- if (!input || typeof input !== "object" || Array.isArray(input)) {
82
- throw new Error("migrate: config must be a mapping");
83
- }
84
-
85
- if (input.version === CONFIG_SCHEMA_VERSION) {
86
- return {
87
- migrated: false,
88
- config: input,
89
- warnings: ["config already at v2; nothing to do"],
90
- stub_lanes: [],
91
- };
92
- }
93
- if (input.version !== LEGACY_CONFIG_SCHEMA_VERSION) {
94
- throw new Error(
95
- `migrate: unsupported source version ${JSON.stringify(input.version)}; only v${LEGACY_CONFIG_SCHEMA_VERSION} is supported`,
96
- );
97
- }
98
-
99
- /* Deep-clone first — never touch the caller's object. YAML parse
100
- * output is pure JSON so JSON round-trip is safe. */
101
- const src = JSON.parse(JSON.stringify(input));
102
- const warnings = [];
103
- const stubLanes = [];
104
-
105
- const out = {
106
- version: CONFIG_SCHEMA_VERSION,
107
- /* Bump the hard floor to the release that introduces v2. Anyone
108
- * stuck on <0.12 will fail loudly on read instead of silently
109
- * pretending v2 is v1. */
110
- shipctl_min: bumpFloor(src.shipctl_min, "0.12.0"),
111
- };
112
-
113
- /* api / stack / cache / telemetry / artifacts survive verbatim — v2
114
- * only added new top-level siblings. Preserve unknown keys too so a
115
- * future field added by a newer shipctl on the same config doesn't
116
- * get eaten by an older migrator. */
117
- for (const k of Object.keys(src)) {
118
- if (k === "version" || k === "shipctl_min") continue;
119
- if (k === "lanes") continue; /* handled below */
120
- out[k] = src[k];
121
- }
122
-
123
- /* agent.default / agent.overrides */
124
- out.agent = out.agent && typeof out.agent === "object" ? out.agent : {};
125
- if (!out.agent.default || typeof out.agent.default !== "object") {
126
- out.agent.default = { provider: null };
127
- }
128
- if (!out.agent.overrides || typeof out.agent.overrides !== "object") {
129
- out.agent.overrides = {};
130
- }
131
- /* Lift stack.agent.provider into agent.default.provider if unset.
132
- * We intentionally leave the original value in place so v1 readers
133
- * keep working; v2 readers prefer agent.default.provider anyway. */
134
- const liftedProvider = src.stack?.agent?.provider ?? null;
135
- if (liftedProvider && !out.agent.default.provider) {
136
- out.agent.default.provider = liftedProvider;
137
- }
138
-
139
- if (!out.process || typeof out.process !== "object" || Array.isArray(out.process)) {
140
- out.process = cloneDefault(DEFAULT_PROCESS_CONFIG());
141
- }
142
-
143
- /* lanes: translate from the legacy list-of-strings shape. */
144
- out.lanes = {};
145
- const srcLanes = src.lanes;
146
- if (Array.isArray(srcLanes)) {
147
- for (const laneId of srcLanes) {
148
- if (typeof laneId !== "string") {
149
- warnings.push(`lanes: skipped non-string entry ${JSON.stringify(laneId)}`);
150
- continue;
151
- }
152
- const normalised = laneId.trim();
153
- if (!normalised) continue;
154
- const def = V1_LANE_DEFAULTS[normalised];
155
- if (def) {
156
- out.lanes[normalised] = cloneDefault(def);
157
- } else {
158
- /* Unknown v1 lane — emit a stub so the customer sees exactly
159
- * which fields need attention on the next `shipctl doctor`. */
160
- out.lanes[normalised] = {
161
- kind: "schedule",
162
- pattern: `TODO-pattern-for-${normalised}`,
163
- cron: "TODO",
164
- };
165
- stubLanes.push(normalised);
166
- warnings.push(
167
- `lanes.${normalised}: no preset mapping; wrote a stub (fill in kind/pattern/cron before shipping)`,
168
- );
169
- }
170
- }
171
- } else if (srcLanes && typeof srcLanes === "object") {
172
- /* Already a map (e.g. someone hand-edited partway). Copy as-is;
173
- * the v2 validator will flag any malformed lanes on the next
174
- * `shipctl doctor` or write. */
175
- out.lanes = JSON.parse(JSON.stringify(srcLanes));
176
- } else if (srcLanes !== undefined) {
177
- warnings.push(
178
- `lanes: unexpected v1 shape ${typeof srcLanes}; dropped. Add lanes manually or rerun 'shipctl init'.`,
179
- );
180
- }
181
-
182
- return {
183
- migrated: true,
184
- config: out,
185
- warnings,
186
- stub_lanes: stubLanes,
187
- };
188
- }
189
-
190
- function cloneDefault(laneBody) {
191
- return JSON.parse(JSON.stringify(laneBody));
192
- }
193
-
194
- /**
195
- * Parse a semver-ish "X.Y.Z" and return the higher of the current floor
196
- * and the minimum the caller wants. If `current` is not a string or
197
- * doesn't parse, we fall back to the minimum unconditionally — an
198
- * unreadable floor is no floor.
199
- *
200
- * @param {unknown} current
201
- * @param {string} minimum
202
- * @returns {string}
203
- */
204
- function bumpFloor(current, minimum) {
205
- if (typeof current !== "string") return minimum;
206
- const cur = parseSemver(current);
207
- const min = parseSemver(minimum);
208
- if (!cur || !min) return minimum;
209
- return compareSemver(cur, min) >= 0 ? current : minimum;
210
- }
211
-
212
- function parseSemver(s) {
213
- const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(s).trim());
214
- if (!m) return null;
215
- return [Number(m[1]), Number(m[2]), Number(m[3])];
216
- }
217
-
218
- function compareSemver(a, b) {
219
- for (let i = 0; i < 3; i += 1) {
220
- if (a[i] !== b[i]) return a[i] - b[i];
221
- }
222
- return 0;
223
- }
@@ -1,75 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
-
4
- const MARKER_DIRS = [
5
- "artifacts/patterns",
6
- "artifacts/tools",
7
- "artifacts/collections",
8
- ];
9
-
10
- function markersOk(dir) {
11
- return MARKER_DIRS.every((rel) => {
12
- const abs = path.join(dir, rel);
13
- try {
14
- return fs.statSync(abs).isDirectory();
15
- } catch {
16
- return false;
17
- }
18
- });
19
- }
20
-
21
- /**
22
- * Walk parents from cwd for a directory containing the v2 artifacts/ tree.
23
- * @returns {string | null}
24
- */
25
- export function tryFindShipRepoRootFromWalk() {
26
- let dir = path.resolve(process.cwd());
27
- for (;;) {
28
- if (markersOk(dir)) return dir;
29
- const parent = path.dirname(dir);
30
- if (parent === dir) break;
31
- dir = parent;
32
- }
33
- return null;
34
- }
35
-
36
- /**
37
- * Root of the Ship monorepo (artifacts/<plural>/<id>/ARTIFACT.md present).
38
- * Set `SHIP_REPO` to an absolute path when not running from inside the tree.
39
- */
40
- export function findShipRepoRoot() {
41
- const env = process.env.SHIP_REPO?.trim();
42
- if (env) {
43
- const r = path.resolve(env);
44
- if (!markersOk(r)) {
45
- throw new Error(
46
- `SHIP_REPO=${r} is not the Ship monorepo (expected artifacts/{patterns,tools,collections}/ at repo root).`,
47
- );
48
- }
49
- return r;
50
- }
51
- const walked = tryFindShipRepoRootFromWalk();
52
- if (walked) return walked;
53
- throw new Error(
54
- "Ship repo root not found: run from inside the ship clone, or set SHIP_REPO to the repository root.",
55
- );
56
- }
57
-
58
- /**
59
- * When `SHIP_REPO` is unset, returns repo root only if cwd is inside the tree; otherwise `null` (use hosted catalog).
60
- * When `SHIP_REPO` is set, validates and returns that path or throws.
61
- * @returns {string | null}
62
- */
63
- export function resolveShipRepoRootForCatalog() {
64
- const env = process.env.SHIP_REPO?.trim();
65
- if (env) {
66
- const r = path.resolve(env);
67
- if (!markersOk(r)) {
68
- throw new Error(
69
- `SHIP_REPO=${r} is not the Ship monorepo (expected artifacts/{patterns,tools,collections}/ at repo root).`,
70
- );
71
- }
72
- return r;
73
- }
74
- return tryFindShipRepoRootFromWalk();
75
- }
@@ -1,171 +0,0 @@
1
- export const SPECIALIST_PROMPT_GUARDRAILS = `# Ship specialist execution guardrails
2
-
3
- These rules apply to specialist prompts assembled from process configuration,
4
- ticket context, workspace policies, and knowledge buckets.
5
-
6
- ## Knowledge First
7
-
8
- Before inventing a solution or procedure, search Ship knowledge for relevant
9
- recipes, patterns, policies, and technical context. Use repository-specific
10
- knowledge first when a repo is known, then workspace knowledge. If no relevant
11
- article is found, say that explicitly before proposing a new approach.
12
-
13
- Knowledge articles can also answer clarifying technical questions when the
14
- ticket lacks implementation detail.
15
-
16
- ## Allowed Exits
17
-
18
- A specialist cycle may end only through a Ship-controlled outcome:
19
-
20
- - Ask for clarification: return a clarification intent for Ship to post as a
21
- tracker comment and mirror into Inbox.
22
- - Handoff: request one of the transitions explicitly configured in the process
23
- FSM. Ship validates the transition before any side effect.
24
- - Complete with result: produce the final result or PR reference for Ship to
25
- record. Repository changes must be delivered through pull requests only.
26
-
27
- ## Boundaries
28
-
29
- Do not perform direct ticket-system mutations. Do not execute transitions that
30
- are not declared in the process configuration. Do not push directly to protected
31
- branches or bypass review. All material actions must be represented in Ship
32
- audit data. Workspace policies are mandatory and override recipe guidance.`;
33
-
34
- export function renderSpecialistPromptGuardrails() {
35
- return `${SPECIALIST_PROMPT_GUARDRAILS.trim()}\n`;
36
- }
37
-
38
- export function buildSpecialistPromptBundle({
39
- process,
40
- state,
41
- allowedTransitions = [],
42
- ticket = null,
43
- policies = null,
44
- }) {
45
- const specialist = normalizeSpecialist(state.specialist);
46
- const agentProfile =
47
- state.agent_profile || specialist.agent_profile || "auto";
48
- return {
49
- process: {
50
- id: process.id,
51
- name: process.name || process.id,
52
- primary: process.primary === true,
53
- },
54
- state: {
55
- id: state.id,
56
- name: state.name || state.id,
57
- instructions: typeof state.instructions === "string" ? state.instructions : "",
58
- triggers: Array.isArray(state.triggers) ? state.triggers : [],
59
- exit_conditions: Array.isArray(state.exit_conditions) ? state.exit_conditions : [],
60
- block_conditions: Array.isArray(state.block_conditions) ? state.block_conditions : [],
61
- },
62
- specialist,
63
- agent_profile: agentProfile,
64
- ticket,
65
- policies,
66
- allowed_transitions: allowedTransitions.map((transition) => ({
67
- from: transition.from,
68
- to: transition.to,
69
- condition: transition.condition || null,
70
- })),
71
- guardrails: renderSpecialistPromptGuardrails(),
72
- };
73
- }
74
-
75
- export function renderSpecialistPromptBundleMarkdown(bundle) {
76
- const lines = [
77
- "# Ship Specialist Prompt Bundle",
78
- "",
79
- "## Process",
80
- "",
81
- `- Process: ${bundle.process.name} (\`${bundle.process.id}\`)`,
82
- `- Primary: ${bundle.process.primary ? "yes" : "no"}`,
83
- `- State: ${bundle.state.name} (\`${bundle.state.id}\`)`,
84
- "",
85
- "## Specialist",
86
- "",
87
- `- Specialist: ${bundle.specialist.name} (\`${bundle.specialist.id}\`)`,
88
- `- Agent profile: \`${bundle.agent_profile}\``,
89
- "",
90
- bundle.specialist.role || "No specialist role description configured.",
91
- "",
92
- ];
93
-
94
- if (bundle.state.instructions) {
95
- lines.push("## State Instructions", "", bundle.state.instructions, "");
96
- }
97
-
98
- lines.push("## Ticket Context", "");
99
- if (bundle.ticket) {
100
- lines.push(...renderTicketLines(bundle.ticket), "");
101
- } else {
102
- lines.push("No ticket context was supplied. Use the Ship tracker picker before starting this specialist cycle.", "");
103
- }
104
-
105
- lines.push("## Workspace Policies", "");
106
- if (bundle.policies && bundle.policies.trim()) {
107
- lines.push(bundle.policies.trim(), "");
108
- } else {
109
- lines.push("Workspace policies were not supplied locally. In managed runs, Ship must inject enabled workspace policies before the agent starts.", "");
110
- }
111
-
112
- lines.push("## Allowed Transitions", "");
113
- if (bundle.allowed_transitions.length) {
114
- for (const transition of bundle.allowed_transitions) {
115
- const condition = transition.condition ? ` when ${transition.condition}` : "";
116
- lines.push(`- \`${transition.from}\` -> \`${transition.to}\`${condition}`);
117
- }
118
- } else {
119
- lines.push("- No outgoing transitions are configured for this state. The agent may only ask for clarification or complete with a result.");
120
- }
121
-
122
- lines.push(
123
- "",
124
- "## Required Guardrails",
125
- "",
126
- bundle.guardrails.trim(),
127
- "",
128
- );
129
- return `${lines.join("\n").trim()}\n`;
130
- }
131
-
132
- function normalizeSpecialist(value) {
133
- if (typeof value === "string") {
134
- return { id: value, name: value, role: "", agent_profile: null };
135
- }
136
- if (value && typeof value === "object" && !Array.isArray(value)) {
137
- return {
138
- id: String(value.id || value.name || "specialist"),
139
- name: String(value.name || value.id || "Specialist"),
140
- role: typeof value.role === "string" ? value.role : "",
141
- agent_profile: typeof value.agent_profile === "string" ? value.agent_profile : null,
142
- };
143
- }
144
- return { id: "specialist", name: "Specialist", role: "", agent_profile: null };
145
- }
146
-
147
- function renderTicketLines(ticket) {
148
- const lines = [];
149
- for (const key of ["id", "key", "title", "url", "status", "description"]) {
150
- const value = ticket[key];
151
- if (typeof value === "string" && value.trim()) {
152
- lines.push(`- ${titleCase(key)}: ${value.trim()}`);
153
- }
154
- }
155
- const extra = Object.entries(ticket).filter(
156
- ([key, value]) =>
157
- !["id", "key", "title", "url", "status", "description"].includes(key) &&
158
- value != null &&
159
- typeof value !== "object",
160
- );
161
- for (const [key, value] of extra) {
162
- lines.push(`- ${titleCase(key)}: ${String(value)}`);
163
- }
164
- return lines.length ? lines : ["- Ticket context object was empty."];
165
- }
166
-
167
- function titleCase(value) {
168
- return value
169
- .replace(/[_-]+/g, " ")
170
- .replace(/\b\w/g, (char) => char.toUpperCase());
171
- }
@@ -1,180 +0,0 @@
1
- /**
2
- * `shipctl.lock.json` — reproducible-build anchor for Ship lanes (RFC-0007
3
- * Phase 4). Records the exact `(kind, id, version, content_sha256)` of
4
- * every artifact a lane depends on, plus where that body is materialised on
5
- * disk. Without it, `shipctl run --offline` has no way to decide whether
6
- * a cached pattern is "the one the lane expects" — the lockfile gives a
7
- * positive, auditable answer.
8
- *
9
- * Only patterns are locked today (lanes only reference patterns via
10
- * `lane.pattern`). The schema is forward-compatible: tools and collections
11
- * can be added to the same `artifacts` map with no version bump.
12
- *
13
- * Concurrency: the writer does an atomic rename (write to `.tmp` in the
14
- * same directory, rename over the target). Readers always open a read-only
15
- * handle so a race doesn't yield a truncated parse.
16
- */
17
-
18
- import fs from "node:fs";
19
- import path from "node:path";
20
-
21
- import { artifactSha256 } from "../cache/store.mjs";
22
-
23
- const LOCKFILE_REL = path.join(".ship", "shipctl.lock.json");
24
- export const LOCKFILE_SCHEMA_VERSION = 1;
25
-
26
- export function lockfilePath(shipRoot) {
27
- return path.join(shipRoot, LOCKFILE_REL);
28
- }
29
-
30
- /**
31
- * @typedef {Object} LockfileEntry
32
- * @property {string} version Resolved version of the artifact.
33
- * @property {string} content_sha256 Hex digest (RFC-0005 normalisation).
34
- * @property {string} cached_path Relative path inside the ship root.
35
- * @property {"http" | "monorepo" | "inline"} source
36
- * @property {boolean} pinned Whether a config pin constrained this.
37
- * @property {string} [channel] Manifest channel at time of lock.
38
- *
39
- * @typedef {Object} Lockfile
40
- * @property {1} version
41
- * @property {string} generated_at ISO-8601 UTC.
42
- * @property {string} shipctl_version
43
- * @property {{ base_url: string, channel: string } | null} source
44
- * @property {Record<string, LockfileEntry>} artifacts Key: "<kind>/<id>".
45
- * @property {string[]} [notes] Free-form operator hints.
46
- */
47
-
48
- /**
49
- * @param {string} shipRoot
50
- * @returns {Lockfile | null}
51
- */
52
- export function readLockfile(shipRoot) {
53
- const file = lockfilePath(shipRoot);
54
- if (!fs.existsSync(file)) return null;
55
- const raw = fs.readFileSync(file, "utf8");
56
- let parsed;
57
- try {
58
- parsed = JSON.parse(raw);
59
- } catch (err) {
60
- throw new Error(`shipctl.lock.json: invalid JSON (${err instanceof Error ? err.message : err})`);
61
- }
62
- if (!parsed || typeof parsed !== "object") {
63
- throw new Error("shipctl.lock.json: root must be an object");
64
- }
65
- if (parsed.version !== LOCKFILE_SCHEMA_VERSION) {
66
- throw new Error(
67
- `shipctl.lock.json: unsupported version ${parsed.version} (shipctl supports v${LOCKFILE_SCHEMA_VERSION}).`,
68
- );
69
- }
70
- if (!parsed.artifacts || typeof parsed.artifacts !== "object") {
71
- throw new Error("shipctl.lock.json: missing `artifacts` map");
72
- }
73
- return parsed;
74
- }
75
-
76
- /**
77
- * @param {string} shipRoot
78
- * @param {Lockfile} data
79
- */
80
- export function writeLockfile(shipRoot, data) {
81
- const file = lockfilePath(shipRoot);
82
- fs.mkdirSync(path.dirname(file), { recursive: true });
83
-
84
- /* Sort the artifacts map so diffs stay minimal between runs. We can't
85
- * rely on JSON object key order, but `JSON.stringify` honours insertion
86
- * order on v8, and every modern engine we care about follows suit. */
87
- const sorted = {};
88
- const keys = Object.keys(data.artifacts || {}).sort();
89
- for (const k of keys) sorted[k] = data.artifacts[k];
90
-
91
- const normalised = {
92
- version: LOCKFILE_SCHEMA_VERSION,
93
- generated_at: data.generated_at,
94
- shipctl_version: data.shipctl_version,
95
- source: data.source || null,
96
- artifacts: sorted,
97
- notes: Array.isArray(data.notes) ? [...data.notes] : [],
98
- };
99
-
100
- const tmp = `${file}.tmp`;
101
- fs.writeFileSync(tmp, `${JSON.stringify(normalised, null, 2)}\n`, "utf8");
102
- fs.renameSync(tmp, file);
103
- }
104
-
105
- /**
106
- * Build the lookup key the rest of shipctl uses.
107
- * @param {string} kind
108
- * @param {string} id
109
- */
110
- export function lockKey(kind, id) {
111
- return `${kind}/${id}`;
112
- }
113
-
114
- /**
115
- * @param {Lockfile} lock
116
- * @param {string} kind
117
- * @param {string} id
118
- * @returns {LockfileEntry | null}
119
- */
120
- export function lookupLock(lock, kind, id) {
121
- if (!lock) return null;
122
- const key = lockKey(kind, id);
123
- const entry = lock.artifacts?.[key];
124
- return entry || null;
125
- }
126
-
127
- /**
128
- * Compute the lockfile entry for a freshly-materialised artifact body.
129
- * Keeping the signature narrow — just `body` and a bit of provenance —
130
- * means the caller can't accidentally smuggle transient fields into the
131
- * lock.
132
- *
133
- * @param {Object} params
134
- * @param {string} params.body Artifact text (markdown + frontmatter).
135
- * @param {string} params.version Resolved version string.
136
- * @param {string} params.cachedPath Path relative to the ship root.
137
- * @param {"http" | "monorepo" | "inline"} params.source
138
- * @param {boolean} [params.pinned=false]
139
- * @param {string} [params.channel]
140
- * @returns {LockfileEntry}
141
- */
142
- export function entryFromBody({
143
- body,
144
- version,
145
- cachedPath,
146
- source,
147
- pinned = false,
148
- channel,
149
- }) {
150
- return {
151
- version,
152
- content_sha256: artifactSha256(body),
153
- cached_path: String(cachedPath).replace(/\\/g, "/"),
154
- source,
155
- pinned: Boolean(pinned),
156
- ...(channel ? { channel: String(channel) } : {}),
157
- };
158
- }
159
-
160
- /**
161
- * Decide whether a body matches a lockfile entry. Returns a structured
162
- * `{ ok, reason }` so callers can emit specific diagnostics.
163
- *
164
- * @param {LockfileEntry | null} entry
165
- * @param {string} body
166
- * @returns {{ ok: boolean, reason?: string, expected?: string, actual?: string }}
167
- */
168
- export function verifyBody(entry, body) {
169
- if (!entry) return { ok: false, reason: "missing-entry" };
170
- const actual = artifactSha256(body);
171
- if (actual !== entry.content_sha256) {
172
- return {
173
- ok: false,
174
- reason: "sha-mismatch",
175
- expected: entry.content_sha256,
176
- actual,
177
- };
178
- }
179
- return { ok: true };
180
- }