@yolo-labs/yolo-cli 0.23.0

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.
@@ -0,0 +1,389 @@
1
+ /**
2
+ * deploy-ship — orchestration for bare `yolo deploy` (the one-move ship).
3
+ *
4
+ * Pipeline (docs/MANAGED_HOSTING_CLI_SPEC.md §4):
5
+ * link (.yolo/deploy.json) → detect → build (if buildCommand) → bundle
6
+ * → ship/start (manifest) → upload ONLY the missing-hash buckets
7
+ * → finalize → live release (or 409 awaiting-approval on the T3 prod gate).
8
+ *
9
+ * `--dry-run` stops after bundle and is fully offline (no auth resolution,
10
+ * no network) — it prints the manifest summary and returns success.
11
+ *
12
+ * Progress is line-oriented and agent-parseable: every line starts with
13
+ * `deploy: ` and goes through the injected `progress` sink (the CLI layer
14
+ * routes it to stdout, or stderr under --json). The final OK / PENDING /
15
+ * FAIL line is the CLI layer's job (formatters exported from here).
16
+ *
17
+ * Exit-code mapping (spec §5) lives in `exitCodeForFailure` — backend
18
+ * refusal reasons pass through verbatim as failure kinds and default to
19
+ * exit 2 (backend rejected — don't blind-retry).
20
+ */
21
+ import * as path from 'node:path';
22
+ import * as fs from 'node:fs';
23
+ import { spawn, execFileSync } from 'node:child_process';
24
+ import { readDeployConfig } from './deploy-config.js';
25
+ import { detectProjectShape } from './deploy-detect.js';
26
+ import { bundleProject } from './deploy-bundle.js';
27
+ import { resolveDeployContext, startShip, uploadAssetBucket, finalizeShip, } from './deploy-client.js';
28
+ const NOT_LINKED_HINT = "run 'yolo deploy init' to create or link a hosting project";
29
+ // ─── Orchestration ────────────────────────────────────────────────────────
30
+ export async function runDeployShip(options) {
31
+ const cwd = options.cwd ?? process.cwd();
32
+ const envFlag = options.envFlag ?? 'staging';
33
+ const channel = envFlag === 'prod' ? 'prod' : 'preview';
34
+ const dryRun = options.dryRun ?? false;
35
+ const progress = options.progress ?? ((line) => process.stdout.write(`${line}\n`));
36
+ const deps = options.deps ?? {};
37
+ const readConfig = deps.readDeployConfigImpl ?? readDeployConfig;
38
+ const detect = deps.detectProjectShapeImpl ?? detectProjectShape;
39
+ const bundle = deps.bundleProjectImpl ?? bundleProject;
40
+ const startShipLeg = deps.startShipImpl ?? startShip;
41
+ const uploadLeg = deps.uploadAssetBucketImpl ?? uploadAssetBucket;
42
+ const finalizeLeg = deps.finalizeShipImpl ?? finalizeShip;
43
+ const runBuild = deps.runBuildImpl ?? defaultRunBuild;
44
+ const readAssetFile = deps.readAssetFileImpl ?? defaultReadAssetFile;
45
+ const resolveGitSha = deps.resolveGitShaImpl ?? defaultResolveGitSha;
46
+ // 1. Link — .yolo/deploy.json with a projectId is the durable bond.
47
+ const readResult = readConfig(cwd);
48
+ if (!readResult.ok) {
49
+ // 'malformed' | 'invalid' — a broken link file is a local failure
50
+ // (exit 1), distinct from the not-linked first-ship case.
51
+ return fail(readResult.kind, readResult.message);
52
+ }
53
+ const config = readResult.config;
54
+ if (!config || !config.projectId) {
55
+ return fail('not-linked', 'this directory is not linked to a hosting project (.yolo/deploy.json missing or has no projectId)', NOT_LINKED_HINT);
56
+ }
57
+ const projectId = config.projectId;
58
+ progress(`deploy: link ok (project ${projectId}${config.slug ? `, slug ${config.slug}` : ''}${config.type ? `, type ${config.type}` : ''})`);
59
+ // 2. Detect.
60
+ const detected = detect({ cwd, config });
61
+ if (!detected.ok)
62
+ return fail('detect-failed', detected.message);
63
+ const shape = detected.shape;
64
+ // 3. Build (if the shape carries a build command).
65
+ if (shape.buildCommand) {
66
+ const startedAt = Date.now();
67
+ const buildResult = await runBuild(shape.buildCommand, cwd, (chunk) => {
68
+ for (const line of chunk.split(/\r?\n/)) {
69
+ if (line.trim())
70
+ progress(`deploy: build> ${line}`);
71
+ }
72
+ });
73
+ if (buildResult.code !== 0) {
74
+ return fail('build-failed', `build command \`${shape.buildCommand}\` exited with code ${buildResult.code}`);
75
+ }
76
+ progress(`deploy: build \`${shape.buildCommand}\` ... ok (${((Date.now() - startedAt) / 1000).toFixed(1)}s)`);
77
+ }
78
+ // 4. Bundle — all size/count ceilings fire locally in here, pre-network.
79
+ const bundled = await bundle(shape, cwd);
80
+ if (!bundled.ok) {
81
+ return { ok: false, kind: bundled.kind, message: bundled.message, hint: bundled.hint, detail: bundled.detail };
82
+ }
83
+ const fileCount = bundled.fileCount;
84
+ for (const warning of bundled.warnings)
85
+ progress(`deploy: warn ${warning}`);
86
+ progress(`deploy: bundle ok (${bundleLabel(shape)}, ${fileCount} files, ${formatMiB(bundled.totalAssetBytes)}, digest ${shortDigest(bundled.bundleDigest)})`);
87
+ const base = {
88
+ projectId,
89
+ slug: config.slug,
90
+ env: envFlag,
91
+ channel,
92
+ type: shape.type,
93
+ fileCount,
94
+ totalAssetBytes: bundled.totalAssetBytes,
95
+ bundleDigest: bundled.bundleDigest,
96
+ };
97
+ // 5. Dry-run stops here — fully offline by construction (auth + network
98
+ // resolution happen below this line).
99
+ if (dryRun)
100
+ return { ok: true, dryRun: true, ...base };
101
+ const auth = resolveDeployContext(options.env ?? process.env, options.readFileImpl, options.fetchImpl);
102
+ if (!auth.ok)
103
+ return fail('auth', auth.message);
104
+ // Bounded auto-retry for transient edge/origin 5xx (incl. CF 522) on the
105
+ // ship legs — a single transient blip shouldn't hard-fail a prod deploy.
106
+ const ctx = {
107
+ ...auth.context,
108
+ retry: {
109
+ onRetry: ({ leg, attempt, maxAttempts, delayMs, failure }) => progress(`deploy: ${leg} transient error (${failure.message}); retrying ${attempt}/${maxAttempts - 1} in ${Math.round(delayMs)}ms`),
110
+ },
111
+ };
112
+ // 6. ship/start — manifest up, missing-hash buckets back.
113
+ const manifest = bundled.manifest;
114
+ const request = {
115
+ env: channel,
116
+ type: shape.type,
117
+ manifest,
118
+ bundleDigest: bundled.bundleDigest,
119
+ };
120
+ const workerModules = bundled.workerModules;
121
+ if (workerModules.length > 0) {
122
+ request.worker = {
123
+ mainModule: workerModules[0].name,
124
+ sizeBytes: workerModules.reduce((total, module) => total + module.contents.byteLength, 0),
125
+ };
126
+ }
127
+ if (config.compatibilityFlags && config.compatibilityFlags.length > 0) {
128
+ request.compatibilityFlags = config.compatibilityFlags;
129
+ }
130
+ const gitSha = resolveGitSha(cwd);
131
+ if (gitSha)
132
+ request.gitSha = gitSha;
133
+ const started = await startShipLeg(ctx, projectId, request);
134
+ if (!started.ok)
135
+ return clientFail(started);
136
+ const { shipId, missing } = started.value;
137
+ const assetsByHash = new Map(bundled.assets.map((asset) => [asset.hash, asset]));
138
+ const missingHashes = new Set(missing.flat());
139
+ let missingBytes = 0;
140
+ for (const hash of missingHashes)
141
+ missingBytes += assetsByHash.get(hash)?.size ?? 0;
142
+ progress(`deploy: ship/start ok (shipId ${shipId}, ${missingHashes.size}/${fileCount} assets missing, ${formatMiB(missingBytes)} to upload)`);
143
+ // 7. Upload ONLY the missing buckets — hash-matched unchanged assets never
144
+ // leave the pod (the incremental-redeploy hot loop).
145
+ const buckets = missing.filter((bucket) => bucket.length > 0);
146
+ if (buckets.length === 0) {
147
+ progress('deploy: assets 0/0 buckets ok (0.0 MiB)');
148
+ }
149
+ else {
150
+ let uploadedBytes = 0;
151
+ for (let i = 0; i < buckets.length; i++) {
152
+ const files = readBucketFiles(buckets[i], assetsByHash, readAssetFile);
153
+ if (!files.ok)
154
+ return files;
155
+ const uploaded = await uploadLeg(ctx, projectId, shipId, files.files);
156
+ if (!uploaded.ok)
157
+ return clientFail(uploaded);
158
+ uploadedBytes += files.bytes;
159
+ progress(`deploy: assets ${i + 1}/${buckets.length} buckets ok (${formatMiB(uploadedBytes)})`);
160
+ }
161
+ }
162
+ // 8. Finalize — worker modules multipart / empty JSON for pure-static.
163
+ const finalized = await finalizeLeg(ctx, projectId, shipId, workerModules);
164
+ if (!finalized.ok) {
165
+ if (finalized.kind === 'awaiting-approval' && 'approvalId' in finalized) {
166
+ progress('deploy: finalize → pending operator approval (T3 prod ship)');
167
+ return {
168
+ ok: false,
169
+ kind: 'awaiting-approval',
170
+ projectId,
171
+ slug: config.slug,
172
+ approvalId: finalized.approvalId,
173
+ approvalUrl: finalized.approvalUrl,
174
+ releaseId: finalized.releaseId,
175
+ statement: finalized.statement,
176
+ expiresAt: finalized.expiresAt,
177
+ message: finalized.message,
178
+ };
179
+ }
180
+ return clientFail(finalized);
181
+ }
182
+ progress('deploy: finalize ok');
183
+ const bootCheck = parseBootCheck(finalized.value.bootCheck);
184
+ if (bootCheck)
185
+ progress(`deploy: warn — deployed Worker failed to boot (HTTP ${bootCheck.status})`);
186
+ return {
187
+ ok: true,
188
+ dryRun: false,
189
+ ...base,
190
+ shipId,
191
+ releaseId: finalized.value.releaseId,
192
+ url: finalized.value.url,
193
+ ...(bootCheck ? { bootCheck } : {}),
194
+ };
195
+ }
196
+ /** Narrow the backend's optional `bootCheck` envelope; ignore anything malformed. */
197
+ function parseBootCheck(raw) {
198
+ if (!raw || typeof raw !== 'object')
199
+ return undefined;
200
+ const r = raw;
201
+ if (typeof r.status !== 'number' || typeof r.detail !== 'string')
202
+ return undefined;
203
+ return { status: r.status, detail: r.detail };
204
+ }
205
+ // ─── Exit codes (spec §5 — EXACT) ─────────────────────────────────────────
206
+ /**
207
+ * | Exit | Meaning |
208
+ * |------|------------------------------------------|
209
+ * | 0 | success |
210
+ * | 64 | usage (bad flags/args) |
211
+ * | 78 | environment (session-required, auth) |
212
+ * | 1 | local failure (fix project, retry) |
213
+ * | 2 | backend rejected (don't blind-retry) |
214
+ * | 3 | awaiting-approval (T3 — NOT an error) |
215
+ * | 4 | transient transport (retry w/ backoff) |
216
+ *
217
+ * Backend refusal reasons not listed locally (slug-taken, quota-exceeded,
218
+ * upload-expired, …) fall through to 2 — backend rejected.
219
+ */
220
+ export function exitCodeForFailure(kind) {
221
+ switch (kind) {
222
+ case 'usage':
223
+ return 64;
224
+ case 'session-required':
225
+ case 'auth':
226
+ return 78;
227
+ case 'not-linked':
228
+ case 'detect-failed':
229
+ case 'build-failed':
230
+ case 'file-too-large':
231
+ case 'too-many-files':
232
+ case 'bundle-too-large':
233
+ case 'asset-read-failed':
234
+ case 'config-write-failed':
235
+ case 'malformed': // .yolo/deploy.json unparseable (deploy-config kind)
236
+ case 'invalid': // .yolo/deploy.json schema-invalid (deploy-config kind)
237
+ return 1;
238
+ case 'awaiting-approval':
239
+ return 3;
240
+ case 'network':
241
+ return 4;
242
+ default:
243
+ return 2;
244
+ }
245
+ }
246
+ // ─── Final-line formatters (rendered by the CLI layer) ────────────────────
247
+ export function formatShipSuccess(result) {
248
+ const name = result.slug ?? result.projectId;
249
+ if (result.dryRun) {
250
+ return (`OK: dry-run — would ship ${name} (${result.type}, ${result.fileCount} files, ` +
251
+ `${formatMiB(result.totalAssetBytes)}, digest ${shortDigest(result.bundleDigest)}) to ${result.env}; nothing was uploaded`);
252
+ }
253
+ return `OK: shipped ${name} release ${result.releaseId ?? '(unknown)'} → ${result.url ?? '(no url reported)'} (${result.env})`;
254
+ }
255
+ /**
256
+ * Two independent "do not retry" signals: the PENDING prefix (not FAIL) and
257
+ * exit 3 — so neither prefix-matching nor exit-code-matching agents loop.
258
+ */
259
+ export function formatPending(result) {
260
+ const name = result.slug ?? result.projectId;
261
+ const releaseTag = result.releaseId ? ` (release-candidate ${result.releaseId})` : '';
262
+ const approveLine = result.approvalUrl
263
+ ? ` approve: ${result.approvalUrl} (single-use, states action+project)`
264
+ : ` approve: approval ${result.approvalId} in the Studio approvals panel (single-use, states action+project)`;
265
+ return [
266
+ `PENDING [awaiting-approval]: prod ship of ${name}${releaseTag} needs operator confirmation.`,
267
+ approveLine,
268
+ ' then: poll `yolo deploy status` — do NOT rerun `yolo deploy`; the bundle is already staged.',
269
+ ].join('\n');
270
+ }
271
+ export function formatFail(result) {
272
+ return `FAIL [${result.kind}]: ${result.message}${result.hint ? ` | hint: ${result.hint}` : ''}`;
273
+ }
274
+ // ─── Helpers ──────────────────────────────────────────────────────────────
275
+ function fail(kind, message, hint) {
276
+ return { ok: false, kind, message, hint };
277
+ }
278
+ /** Pass a deploy-client failure through verbatim (reason taxonomy is shared). */
279
+ function clientFail(failure) {
280
+ return {
281
+ ok: false,
282
+ kind: failure.kind,
283
+ message: failure.message,
284
+ hint: failure.hint,
285
+ status: failure.status,
286
+ detail: failure.detail,
287
+ };
288
+ }
289
+ function readBucketFiles(bucket, assetsByHash, readAssetFile) {
290
+ const files = [];
291
+ let bytes = 0;
292
+ for (const hash of bucket) {
293
+ const asset = assetsByHash.get(hash);
294
+ if (!asset) {
295
+ return fail('bundle-rejected', `server requested an asset hash not in the local manifest: ${hash}`);
296
+ }
297
+ let contents;
298
+ try {
299
+ contents = readAssetFile(asset.absPath);
300
+ }
301
+ catch (err) {
302
+ return fail('asset-read-failed', `failed to read asset ${asset.path}: ${describeError(err)}`);
303
+ }
304
+ bytes += contents.byteLength;
305
+ files.push({
306
+ hash,
307
+ base64: Buffer.from(contents).toString('base64'),
308
+ contentType: contentTypeFor(asset.path),
309
+ });
310
+ }
311
+ return { ok: true, files, bytes };
312
+ }
313
+ function bundleLabel(shape) {
314
+ if (shape.type === 'static')
315
+ return `${shape.assetsDir.replace(/\/+$/, '')}/`;
316
+ return shape.entry;
317
+ }
318
+ export function formatMiB(bytes) {
319
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
320
+ }
321
+ export function shortDigest(digest) {
322
+ const hex = digest.startsWith('sha256:') ? digest.slice('sha256:'.length) : digest;
323
+ return `sha256:${hex.slice(0, 8)}…`;
324
+ }
325
+ function describeError(err) {
326
+ return err instanceof Error ? err.message : String(err);
327
+ }
328
+ function defaultRunBuild(command, cwd, onOutput) {
329
+ return new Promise((resolve) => {
330
+ const child = spawn(command, { cwd, shell: true, stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
331
+ child.stdout?.on('data', (data) => onOutput(String(data)));
332
+ child.stderr?.on('data', (data) => onOutput(String(data)));
333
+ child.on('error', (err) => {
334
+ onOutput(`spawn failed: ${err.message}`);
335
+ resolve({ code: 127 });
336
+ });
337
+ child.on('close', (code) => resolve({ code: code ?? 1 }));
338
+ });
339
+ }
340
+ function defaultReadAssetFile(absolutePath) {
341
+ return fs.readFileSync(absolutePath);
342
+ }
343
+ function defaultResolveGitSha(cwd) {
344
+ try {
345
+ const out = execFileSync('git', ['rev-parse', 'HEAD'], {
346
+ cwd,
347
+ stdio: ['ignore', 'pipe', 'ignore'],
348
+ timeout: 5000,
349
+ })
350
+ .toString()
351
+ .trim();
352
+ return /^[0-9a-f]{40}$/.test(out) ? out : undefined;
353
+ }
354
+ catch {
355
+ return undefined;
356
+ }
357
+ }
358
+ const CONTENT_TYPES = {
359
+ '.html': 'text/html',
360
+ '.htm': 'text/html',
361
+ '.css': 'text/css',
362
+ '.js': 'text/javascript',
363
+ '.mjs': 'text/javascript',
364
+ '.json': 'application/json',
365
+ '.svg': 'image/svg+xml',
366
+ '.png': 'image/png',
367
+ '.jpg': 'image/jpeg',
368
+ '.jpeg': 'image/jpeg',
369
+ '.gif': 'image/gif',
370
+ '.webp': 'image/webp',
371
+ '.avif': 'image/avif',
372
+ '.ico': 'image/x-icon',
373
+ '.txt': 'text/plain',
374
+ '.xml': 'application/xml',
375
+ '.pdf': 'application/pdf',
376
+ '.wasm': 'application/wasm',
377
+ '.woff': 'font/woff',
378
+ '.woff2': 'font/woff2',
379
+ '.ttf': 'font/ttf',
380
+ '.otf': 'font/otf',
381
+ '.map': 'application/json',
382
+ '.webmanifest': 'application/manifest+json',
383
+ '.mp4': 'video/mp4',
384
+ '.webm': 'video/webm',
385
+ '.mp3': 'audio/mpeg',
386
+ };
387
+ function contentTypeFor(assetPath) {
388
+ return CONTENT_TYPES[path.extname(assetPath).toLowerCase()] ?? 'application/octet-stream';
389
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Plan-import lockfile (Phase 8c.2).
3
+ *
4
+ * Tracks `(workspaceId, planId) → { lastImportedRevision,
5
+ * lastImportedVersion, lastImportedAt }` so `yolo plan import` (8c.3)
6
+ * can detect drift between the file and the DB. The lockfile is NOT
7
+ * an identity remap — `planId` IS the workspace-scoped DB identifier
8
+ * (substrate enforces `(workspaceId, planId)` uniqueness directly).
9
+ *
10
+ * Two locations, per design F8.2:
11
+ * - **Default** (`.yolo/plans/.imports.json`) — gitignored. Per-developer
12
+ * state. Each engineer's local Plan Run / staging workspace lands here.
13
+ * - **Env-named** (`.yolo/plans/.imports.<env>.json`) — opt-in checked-in.
14
+ * Use for shared stable workspaces (e.g., the team's prod workspace)
15
+ * so reproducible imports work across machines. Selected via
16
+ * `--env <name>` on the CLI.
17
+ *
18
+ * Canonical write shape:
19
+ * - JSON, 2-space indent, single trailing `\n`.
20
+ * - Workspace IDs sorted lexicographically; planIds within each
21
+ * workspace also sorted; entry keys in fixed order
22
+ * (lastImportedAt, lastImportedRevision, lastImportedVersion —
23
+ * alphabetical, mirroring how the plan-file canonicalizer treats
24
+ * free-form maps).
25
+ * - Same input → byte-identical output (idempotent re-write).
26
+ *
27
+ * Out of scope here (lands in 8c.3 import):
28
+ * - The actual import flow (mint token → call work.create_plan /
29
+ * work.update_plan → update lockfile).
30
+ * - Divergence-policy decisions (`--force` to override DB version
31
+ * drift, etc.) — `lockfile.ts` is just storage.
32
+ *
33
+ * Out of scope (8c.5 / future):
34
+ * - Lockfile schema evolution. v1 has no version field; if the shape
35
+ * ever changes, add a `$version` top-level field then.
36
+ */
37
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
38
+ import path from 'node:path';
39
+ import { isWellFormedRevision } from './revision.js';
40
+ const LOCKFILE_DEFAULT_BASENAME = '.imports.json';
41
+ export class LockfileError extends Error {
42
+ code;
43
+ constructor(message, code) {
44
+ super(message);
45
+ this.name = 'LockfileError';
46
+ this.code = code;
47
+ }
48
+ }
49
+ /**
50
+ * Resolve the lockfile path for a given plans directory and optional
51
+ * env name.
52
+ *
53
+ * @param plansDir - the absolute or relative path to `.yolo/plans/`.
54
+ * @param env - if set, picks `.imports.<env>.json` (the opt-in
55
+ * checked-in variant). If unset, picks the gitignored default.
56
+ */
57
+ export function resolveLockfilePath(plansDir, env) {
58
+ if (env !== undefined) {
59
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(env)) {
60
+ throw new LockfileError(`--env name must match /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/, got '${env}'`, 'malformed');
61
+ }
62
+ return path.join(plansDir, `.imports.${env}.json`);
63
+ }
64
+ return path.join(plansDir, LOCKFILE_DEFAULT_BASENAME);
65
+ }
66
+ /**
67
+ * Read a lockfile from disk. Returns an empty object if the file
68
+ * doesn't exist (first-import case). Throws `LockfileError` if the
69
+ * file is unreadable or malformed.
70
+ */
71
+ export function readLockfile(filePath) {
72
+ if (!existsSync(filePath))
73
+ return {};
74
+ let text;
75
+ try {
76
+ text = readFileSync(filePath, 'utf8');
77
+ }
78
+ catch (err) {
79
+ const msg = err instanceof Error ? err.message : String(err);
80
+ throw new LockfileError(`could not read lockfile at ${filePath}: ${msg}`, 'unreadable');
81
+ }
82
+ if (text.trim() === '')
83
+ return {};
84
+ let parsed;
85
+ try {
86
+ parsed = JSON.parse(text);
87
+ }
88
+ catch (err) {
89
+ const msg = err instanceof Error ? err.message : String(err);
90
+ throw new LockfileError(`malformed JSON in lockfile at ${filePath}: ${msg}`, 'malformed');
91
+ }
92
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
93
+ throw new LockfileError(`malformed lockfile at ${filePath}: top-level must be a JSON object, got ${describe(parsed)}`, 'malformed');
94
+ }
95
+ return validateShape(parsed, filePath);
96
+ }
97
+ /**
98
+ * Write a lockfile to disk in canonical form. Creates the parent
99
+ * directory if missing. Idempotent: same lockfile contents always
100
+ * produce byte-identical output.
101
+ */
102
+ export function writeLockfile(filePath, lockfile) {
103
+ const dir = path.dirname(filePath);
104
+ try {
105
+ mkdirSync(dir, { recursive: true });
106
+ }
107
+ catch (err) {
108
+ const msg = err instanceof Error ? err.message : String(err);
109
+ throw new LockfileError(`could not create lockfile dir ${dir}: ${msg}`, 'unwritable');
110
+ }
111
+ const sorted = canonicalize(lockfile);
112
+ const text = JSON.stringify(sorted, null, 2) + '\n';
113
+ try {
114
+ writeFileSync(filePath, text, 'utf8');
115
+ }
116
+ catch (err) {
117
+ const msg = err instanceof Error ? err.message : String(err);
118
+ throw new LockfileError(`could not write lockfile at ${filePath}: ${msg}`, 'unwritable');
119
+ }
120
+ }
121
+ /**
122
+ * Lookup helper. Returns the entry for a `(workspaceId, planId)` pair,
123
+ * or `null` if either key is missing. Pure read — does not mutate the
124
+ * lockfile.
125
+ */
126
+ export function getEntry(lockfile, workspaceId, planId) {
127
+ return lockfile[workspaceId]?.[planId] ?? null;
128
+ }
129
+ /**
130
+ * Mutator. Inserts or replaces the entry for a `(workspaceId, planId)`
131
+ * pair. Mutates `lockfile` in place AND returns it (for chaining /
132
+ * test ergonomics).
133
+ */
134
+ export function setEntry(lockfile, workspaceId, planId, entry) {
135
+ if (!lockfile[workspaceId])
136
+ lockfile[workspaceId] = {};
137
+ lockfile[workspaceId][planId] = entry;
138
+ return lockfile;
139
+ }
140
+ // ─── Internals ────────────────────────────────────────────────────────────
141
+ /**
142
+ * Reorder the lockfile to canonical shape: workspace IDs sorted,
143
+ * planIds within each sorted, entry fields in fixed alphabetical
144
+ * order. Same canonicalization rule as the plan-file canonicalizer's
145
+ * `sortNestedKeys`: lex sort because the schema doesn't dictate
146
+ * order. Pure — does not mutate input.
147
+ */
148
+ function canonicalize(lockfile) {
149
+ const out = {};
150
+ for (const ws of Object.keys(lockfile).sort()) {
151
+ out[ws] = {};
152
+ for (const planId of Object.keys(lockfile[ws]).sort()) {
153
+ const e = lockfile[ws][planId];
154
+ // Fixed alphabetical entry-field order (matches a future
155
+ // round-trip test: read → write → read produces same object).
156
+ out[ws][planId] = {
157
+ lastImportedAt: e.lastImportedAt,
158
+ lastImportedRevision: e.lastImportedRevision,
159
+ lastImportedVersion: e.lastImportedVersion,
160
+ };
161
+ }
162
+ }
163
+ return out;
164
+ }
165
+ /**
166
+ * Validate the shape of a parsed lockfile. Throws `LockfileError`
167
+ * with `code: 'malformed_entry'` if any entry is missing required
168
+ * fields or has wrong types. Returns the validated object cast to
169
+ * `Lockfile`.
170
+ */
171
+ function validateShape(parsed, filePath) {
172
+ for (const [ws, plans] of Object.entries(parsed)) {
173
+ if (plans === null || typeof plans !== 'object' || Array.isArray(plans)) {
174
+ throw new LockfileError(`malformed lockfile at ${filePath}: entry for workspace '${ws}' must be an object, got ${describe(plans)}`, 'malformed_entry');
175
+ }
176
+ for (const [planId, entry] of Object.entries(plans)) {
177
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
178
+ throw new LockfileError(`malformed lockfile at ${filePath}: entry for ${ws}/${planId} must be an object, got ${describe(entry)}`, 'malformed_entry');
179
+ }
180
+ const e = entry;
181
+ if (typeof e.lastImportedRevision !== 'string') {
182
+ throw new LockfileError(`malformed lockfile at ${filePath}: ${ws}/${planId}.lastImportedRevision must be a string, got ${describe(e.lastImportedRevision)}`, 'malformed_entry');
183
+ }
184
+ if (!isWellFormedRevision(e.lastImportedRevision)) {
185
+ throw new LockfileError(`malformed lockfile at ${filePath}: ${ws}/${planId}.lastImportedRevision must match 'sha256:<64-hex>', got '${e.lastImportedRevision}'`, 'malformed_entry');
186
+ }
187
+ if (typeof e.lastImportedVersion !== 'number' || !Number.isInteger(e.lastImportedVersion) || e.lastImportedVersion < 1) {
188
+ throw new LockfileError(`malformed lockfile at ${filePath}: ${ws}/${planId}.lastImportedVersion must be a positive integer, got ${describe(e.lastImportedVersion)}`, 'malformed_entry');
189
+ }
190
+ if (typeof e.lastImportedAt !== 'string' || Number.isNaN(Date.parse(e.lastImportedAt))) {
191
+ throw new LockfileError(`malformed lockfile at ${filePath}: ${ws}/${planId}.lastImportedAt must be an ISO-8601 timestamp, got ${describe(e.lastImportedAt)}`, 'malformed_entry');
192
+ }
193
+ }
194
+ }
195
+ return parsed;
196
+ }
197
+ function describe(value) {
198
+ if (value === null)
199
+ return 'null';
200
+ if (Array.isArray(value))
201
+ return 'array';
202
+ return typeof value;
203
+ }