@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.
- package/README.md +57 -0
- package/dist/artifact-get.js +131 -0
- package/dist/artifact-list.js +133 -0
- package/dist/auth-context.js +60 -0
- package/dist/canonicalizer.js +208 -0
- package/dist/cli.js +1403 -0
- package/dist/context.js +58 -0
- package/dist/deploy-bundle.js +394 -0
- package/dist/deploy-cli.js +1341 -0
- package/dist/deploy-client.js +460 -0
- package/dist/deploy-config.js +301 -0
- package/dist/deploy-detect.js +498 -0
- package/dist/deploy-ship.js +389 -0
- package/dist/lockfile.js +203 -0
- package/dist/plan-diff.js +162 -0
- package/dist/plan-export.js +261 -0
- package/dist/plan-frontmatter.schema.json +184 -0
- package/dist/plan-get.js +244 -0
- package/dist/plan-import.js +420 -0
- package/dist/plan-list.js +153 -0
- package/dist/plan-open.js +100 -0
- package/dist/plan-state.js +228 -0
- package/dist/plan-validate.js +270 -0
- package/dist/revision.js +43 -0
- package/dist/run-get.js +130 -0
- package/dist/run-lifecycle.js +133 -0
- package/dist/run-list.js +148 -0
- package/dist/run-start.js +110 -0
- package/dist/run-transfer.js +128 -0
- package/dist/serve.js +227 -0
- package/dist/tileapp-developer.js +222 -0
- package/dist/tileapp-oci-assembler.js +591 -0
- package/dist/tileapp-personal.js +461 -0
- package/dist/tileapp-publisher.js +134 -0
- package/dist/tileapp-validator.js +239 -0
- package/dist/vendored/flexdb.mjs +1087 -0
- package/dist/webapp-url.js +61 -0
- package/dist/work-client.js +195 -0
- package/package.json +39 -0
|
@@ -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
|
+
}
|
package/dist/lockfile.js
ADDED
|
@@ -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
|
+
}
|