@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,301 @@
1
+ /**
2
+ * `.yolo/deploy.json` — the durable project↔hosting link (spec
3
+ * `docs/MANAGED_HOSTING_CLI_SPEC.md` §3).
4
+ *
5
+ * COMMITTED by design (the wrangler-config analog): gitignored `.yolo/`
6
+ * entries are per-machine state; `deploy.json` is durable shared
7
+ * identity so future sessions/teammates/CI ship to the SAME project
8
+ * instead of forking a new slug. No secrets live in it — the credential
9
+ * is the session JWT; `projectId` is non-secret and ownership-checked
10
+ * server-side on every call.
11
+ *
12
+ * Canonical write shape (mirrors `lockfile.ts` write discipline):
13
+ * - JSON, 2-space indent, single trailing `\n`.
14
+ * - Fixed key order per the spec example: $version, projectId, slug,
15
+ * type, build{command,outputDir}, worker{entry,assetsDir},
16
+ * compatibilityFlags, bindings. Absent optional fields are omitted.
17
+ * - Same input → byte-identical output (idempotent re-write).
18
+ *
19
+ * Validation is lenient on PRESENCE (only `$version` is required —
20
+ * `init` may write a partial link before detection fills the rest) but
21
+ * strict on TYPES: a present field with the wrong shape is a structured
22
+ * error, never silently coerced. Unknown keys are tolerated (forward
23
+ * compat — a newer deploy.json must not be rejected by an older CLI) and
24
+ * dropped by the canonical writer, but they are NO LONGER SILENT: each is
25
+ * returned as a non-fatal `warning` so a misspelled or unsupported field
26
+ * (e.g. `compatibilityDate`, which the platform fixes and does not read)
27
+ * surfaces in `yolo deploy validate` instead of misleading the author.
28
+ */
29
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
30
+ import path from 'node:path';
31
+ export class DeployConfigError extends Error {
32
+ code;
33
+ errors;
34
+ constructor(message, code, errors) {
35
+ super(message);
36
+ this.name = 'DeployConfigError';
37
+ this.code = code;
38
+ this.errors = errors;
39
+ }
40
+ }
41
+ // ─── Public API ───────────────────────────────────────────────────────────
42
+ /** Resolve the deploy.json path for a project root. */
43
+ export function deployConfigPath(cwd) {
44
+ return path.join(cwd, '.yolo', 'deploy.json');
45
+ }
46
+ /**
47
+ * Read `.yolo/deploy.json` from a project root. Absent file is the
48
+ * non-error `{ ok: true, config: null }` case (detection falls through
49
+ * to heuristics); malformed JSON or schema violations are structured
50
+ * failures so the CLI can print `FAIL [<reason>]` with field paths.
51
+ */
52
+ export function readDeployConfig(cwd, readFileImpl = defaultReadFile) {
53
+ const filePath = deployConfigPath(cwd);
54
+ const text = readFileImpl(filePath);
55
+ if (text === undefined)
56
+ return { ok: true, config: null, path: filePath, warnings: [] };
57
+ if (text.trim() === '')
58
+ return { ok: true, config: null, path: filePath, warnings: [] };
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(text);
62
+ }
63
+ catch (err) {
64
+ const msg = err instanceof Error ? err.message : String(err);
65
+ return {
66
+ ok: false,
67
+ kind: 'malformed',
68
+ path: filePath,
69
+ message: `malformed JSON in ${filePath}: ${msg}`,
70
+ };
71
+ }
72
+ const validated = validateDeployConfig(parsed);
73
+ if (!validated.ok) {
74
+ return {
75
+ ok: false,
76
+ kind: 'invalid',
77
+ path: filePath,
78
+ message: `invalid deploy config at ${filePath}: ` +
79
+ validated.errors.map((e) => `${e.path}: ${e.message}`).join('; '),
80
+ errors: validated.errors,
81
+ };
82
+ }
83
+ return { ok: true, config: validated.config, path: filePath, warnings: validated.warnings };
84
+ }
85
+ /**
86
+ * Write `.yolo/deploy.json` in canonical form (2-space JSON, fixed key
87
+ * order, trailing newline). Creates `.yolo/` if missing. Validates
88
+ * before touching disk and throws `DeployConfigError` on an invalid
89
+ * config or fs failure — a half-written link file is worse than none.
90
+ */
91
+ export function writeDeployConfig(cwd, config) {
92
+ const validated = validateDeployConfig(config);
93
+ if (!validated.ok) {
94
+ throw new DeployConfigError('refusing to write invalid deploy config: ' +
95
+ validated.errors.map((e) => `${e.path}: ${e.message}`).join('; '), 'invalid', validated.errors);
96
+ }
97
+ const filePath = deployConfigPath(cwd);
98
+ const dir = path.dirname(filePath);
99
+ try {
100
+ mkdirSync(dir, { recursive: true });
101
+ }
102
+ catch (err) {
103
+ const msg = err instanceof Error ? err.message : String(err);
104
+ throw new DeployConfigError(`could not create ${dir}: ${msg}`, 'unwritable');
105
+ }
106
+ const text = JSON.stringify(canonicalize(validated.config), null, 2) + '\n';
107
+ try {
108
+ writeFileSync(filePath, text, 'utf8');
109
+ }
110
+ catch (err) {
111
+ const msg = err instanceof Error ? err.message : String(err);
112
+ throw new DeployConfigError(`could not write ${filePath}: ${msg}`, 'unwritable');
113
+ }
114
+ return filePath;
115
+ }
116
+ /**
117
+ * Schema validation per spec §3. Returns the value typed as
118
+ * `DeployConfig` on success or a full list of structured errors (all
119
+ * violations, not just the first) on failure. Pure — no I/O.
120
+ */
121
+ export function validateDeployConfig(value) {
122
+ const errors = [];
123
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
124
+ return { ok: false, errors: [{ path: '$', message: `must be a JSON object, got ${describe(value)}` }] };
125
+ }
126
+ const obj = value;
127
+ if (obj.$version !== 1) {
128
+ errors.push({ path: '$version', message: `must be the number 1, got ${describe(obj.$version)}` });
129
+ }
130
+ checkOptionalString(obj, 'projectId', errors);
131
+ checkOptionalString(obj, 'slug', errors);
132
+ if (obj.type !== undefined && obj.type !== 'static' && obj.type !== 'worker') {
133
+ errors.push({ path: 'type', message: `must be 'static' or 'worker', got ${describe(obj.type)}` });
134
+ }
135
+ if (obj.build !== undefined) {
136
+ if (!isPlainObject(obj.build)) {
137
+ errors.push({ path: 'build', message: `must be an object, got ${describe(obj.build)}` });
138
+ }
139
+ else {
140
+ checkOptionalString(obj.build, 'command', errors, 'build.');
141
+ checkOptionalString(obj.build, 'outputDir', errors, 'build.');
142
+ }
143
+ }
144
+ if (obj.worker !== undefined) {
145
+ if (!isPlainObject(obj.worker)) {
146
+ errors.push({ path: 'worker', message: `must be an object, got ${describe(obj.worker)}` });
147
+ }
148
+ else {
149
+ checkOptionalString(obj.worker, 'entry', errors, 'worker.');
150
+ checkOptionalString(obj.worker, 'assetsDir', errors, 'worker.');
151
+ if (obj.worker.prebuilt !== undefined && typeof obj.worker.prebuilt !== 'boolean') {
152
+ errors.push({ path: 'worker.prebuilt', message: `must be a boolean, got ${describe(obj.worker.prebuilt)}` });
153
+ }
154
+ }
155
+ }
156
+ if (obj.compatibilityFlags !== undefined) {
157
+ if (!Array.isArray(obj.compatibilityFlags)) {
158
+ errors.push({
159
+ path: 'compatibilityFlags',
160
+ message: `must be an array of strings, got ${describe(obj.compatibilityFlags)}`,
161
+ });
162
+ }
163
+ else {
164
+ obj.compatibilityFlags.forEach((flag, i) => {
165
+ if (typeof flag !== 'string' || flag.trim() === '') {
166
+ errors.push({ path: `compatibilityFlags[${i}]`, message: `must be a non-empty string, got ${describe(flag)}` });
167
+ }
168
+ });
169
+ }
170
+ }
171
+ if (obj.bindings !== undefined) {
172
+ if (!Array.isArray(obj.bindings)) {
173
+ errors.push({ path: 'bindings', message: `must be an array, got ${describe(obj.bindings)}` });
174
+ }
175
+ else {
176
+ obj.bindings.forEach((b, i) => {
177
+ if (!isPlainObject(b)) {
178
+ errors.push({ path: `bindings[${i}]`, message: `must be an object, got ${describe(b)}` });
179
+ return;
180
+ }
181
+ if (typeof b.kind !== 'string' || b.kind.trim() === '') {
182
+ errors.push({ path: `bindings[${i}].kind`, message: `must be a non-empty string, got ${describe(b.kind)}` });
183
+ }
184
+ if (typeof b.binding !== 'string' || b.binding.trim() === '') {
185
+ errors.push({ path: `bindings[${i}].binding`, message: `must be a non-empty string, got ${describe(b.binding)}` });
186
+ }
187
+ });
188
+ }
189
+ }
190
+ if (errors.length > 0)
191
+ return { ok: false, errors };
192
+ return { ok: true, config: obj, warnings: collectUnknownKeyWarnings(obj) };
193
+ }
194
+ // Known schema keys per level. Unknown keys are tolerated (forward compat) but
195
+ // surfaced as warnings so a silently-ignored field doesn't mislead the author.
196
+ // `bindings[]` entries intentionally allow extra keys (provisioning hints), so
197
+ // they're excluded here.
198
+ const KNOWN_TOP_KEYS = new Set(['$version', 'projectId', 'slug', 'type', 'build', 'worker', 'compatibilityFlags', 'bindings']);
199
+ const KNOWN_BUILD_KEYS = new Set(['command', 'outputDir']);
200
+ const KNOWN_WORKER_KEYS = new Set(['entry', 'prebuilt', 'assetsDir']);
201
+ function collectUnknownKeyWarnings(obj) {
202
+ const warnings = [];
203
+ const note = (path, key) => {
204
+ // A targeted hint for the field a field report saw silently swallowed: the
205
+ // Worker compatibility DATE is platform-fixed and not read from deploy.json
206
+ // (only compatibilityFlags is) — see release-service `COMPATIBILITY_DATE`.
207
+ const message = key === 'compatibilityDate'
208
+ ? "unknown field 'compatibilityDate' — ignored. The Worker compatibility date is fixed by the platform and is not configurable via deploy.json; use 'compatibilityFlags' for runtime flags (e.g. [\"nodejs_compat\"])."
209
+ : `unknown field '${key}' — ignored (not part of the deploy.json schema; check for a typo)`;
210
+ warnings.push({ path, message });
211
+ };
212
+ for (const k of Object.keys(obj))
213
+ if (!KNOWN_TOP_KEYS.has(k))
214
+ note(k, k);
215
+ if (isPlainObject(obj.build))
216
+ for (const k of Object.keys(obj.build))
217
+ if (!KNOWN_BUILD_KEYS.has(k))
218
+ note(`build.${k}`, k);
219
+ if (isPlainObject(obj.worker))
220
+ for (const k of Object.keys(obj.worker))
221
+ if (!KNOWN_WORKER_KEYS.has(k))
222
+ note(`worker.${k}`, k);
223
+ return warnings;
224
+ }
225
+ // ─── Internals ────────────────────────────────────────────────────────────
226
+ /**
227
+ * Reorder to the spec's fixed key order, omitting absent optionals.
228
+ * Binding entries keep `kind`, `binding` first; extra hint keys follow
229
+ * lex-sorted (same rule the plan-file canonicalizer applies to
230
+ * free-form maps). Pure — does not mutate input.
231
+ */
232
+ function canonicalize(config) {
233
+ const out = { $version: 1 };
234
+ if (config.projectId !== undefined)
235
+ out.projectId = config.projectId;
236
+ if (config.slug !== undefined)
237
+ out.slug = config.slug;
238
+ if (config.type !== undefined)
239
+ out.type = config.type;
240
+ if (config.build !== undefined) {
241
+ const build = {};
242
+ if (config.build.command !== undefined)
243
+ build.command = config.build.command;
244
+ if (config.build.outputDir !== undefined)
245
+ build.outputDir = config.build.outputDir;
246
+ out.build = build;
247
+ }
248
+ if (config.worker !== undefined) {
249
+ const worker = {};
250
+ if (config.worker.entry !== undefined)
251
+ worker.entry = config.worker.entry;
252
+ if (config.worker.prebuilt !== undefined)
253
+ worker.prebuilt = config.worker.prebuilt;
254
+ if (config.worker.assetsDir !== undefined)
255
+ worker.assetsDir = config.worker.assetsDir;
256
+ out.worker = worker;
257
+ }
258
+ if (config.compatibilityFlags !== undefined)
259
+ out.compatibilityFlags = [...config.compatibilityFlags];
260
+ if (config.bindings !== undefined) {
261
+ out.bindings = config.bindings.map((b) => {
262
+ const entry = { kind: b.kind, binding: b.binding };
263
+ for (const key of Object.keys(b).sort()) {
264
+ if (key !== 'kind' && key !== 'binding')
265
+ entry[key] = b[key];
266
+ }
267
+ return entry;
268
+ });
269
+ }
270
+ return out;
271
+ }
272
+ function checkOptionalString(obj, key, errors, prefix = '') {
273
+ const v = obj[key];
274
+ if (v === undefined)
275
+ return;
276
+ if (typeof v !== 'string' || v.trim() === '') {
277
+ errors.push({ path: `${prefix}${key}`, message: `must be a non-empty string, got ${describe(v)}` });
278
+ }
279
+ }
280
+ function isPlainObject(value) {
281
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
282
+ }
283
+ function describe(value) {
284
+ if (value === null)
285
+ return 'null';
286
+ if (Array.isArray(value))
287
+ return 'array';
288
+ if (typeof value === 'string')
289
+ return `'${value}'`;
290
+ if (typeof value === 'number' || typeof value === 'boolean')
291
+ return String(value);
292
+ return typeof value;
293
+ }
294
+ function defaultReadFile(filePath) {
295
+ try {
296
+ return readFileSync(filePath, 'utf8');
297
+ }
298
+ catch {
299
+ return undefined;
300
+ }
301
+ }