@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,460 @@
1
+ /**
2
+ * deploy-client — transport legs for `yolo deploy` (managed hosting, Phase 1).
3
+ *
4
+ * Implements the two-phase manifest-then-missing-files ship protocol from
5
+ * `docs/MANAGED_HOSTING_CLI_SPEC.md` §4 (THE contract) against the
6
+ * `/v1/deploy/*` routes (`docs/MANAGED_HOSTING_BACKEND_SPEC.md` §3 routes
7
+ * 1–8), plus thin wrappers for createProject / status / rollback / logs.
8
+ *
9
+ * Transport notes:
10
+ * - Deploy routes are user-facing `/v1/deploy/*` — the user access JWT is
11
+ * the credential (Bearer), same as `userRouteRequest` in work-client.ts.
12
+ * No MCP-token mint is needed.
13
+ * - The token is RE-RESOLVED from the rotation file (~/.config/yolo/token,
14
+ * rewritten every ~10 min by yolo-token-refresh) before EVERY leg, so a
15
+ * long upload can't ride a token that expired mid-ship.
16
+ * - 4xx refusal envelopes ({ok:false, reason, message, detail?, hint?} or
17
+ * the legacy {error, code}) pass through VERBATIM as structured failures
18
+ * — the CLI and backend reason taxonomies are the same identifiers
19
+ * (spec §5).
20
+ * - 401 → kind 'auth' (exit 78); 5xx and transport errors → kind 'network'
21
+ * (exit 4, transient); other 4xx → the backend reason (exit 2).
22
+ * - finalize is the only non-JSON leg: worker modules go up as multipart
23
+ * FormData (Node 20 global FormData/Blob); pure-static sends empty JSON
24
+ * and the server attaches the canonical assets-only shim.
25
+ *
26
+ * All legs take an injectable `fetchImpl` (and the context an injectable
27
+ * `readFileImpl` for the token file) so unit tests never touch real network
28
+ * or the real FS — the work-client.test.ts convention.
29
+ */
30
+ import { resolveUserToken } from './auth-context.js';
31
+ import { userRouteRequest } from './work-client.js';
32
+ /**
33
+ * Resolve the deploy transport context. Checks the env trio once up front
34
+ * (fail fast with a single auth message); the token itself is still
35
+ * re-resolved per leg by every client function.
36
+ */
37
+ export function resolveDeployContext(env = process.env, readFileImpl, fetchImpl) {
38
+ const commonApiUrl = env.YOLO_COMMON_API_URL || env.YOLO_API_URL;
39
+ if (!commonApiUrl) {
40
+ return { ok: false, kind: 'auth', message: 'YOLO_COMMON_API_URL (or YOLO_API_URL) env var is required' };
41
+ }
42
+ const token = currentTokenFrom(env, readFileImpl);
43
+ if (!token) {
44
+ return {
45
+ ok: false,
46
+ kind: 'auth',
47
+ message: 'no user token available: expected a user access JWT in ~/.config/yolo/token or the YOLO_API_TOKEN env var',
48
+ };
49
+ }
50
+ return { ok: true, context: { commonApiUrl, env, readFileImpl, fetchImpl } };
51
+ }
52
+ /** Leg 1 — POST /v1/deploy/projects/:id/ship/start */
53
+ export async function startShip(ctx, projectId, request) {
54
+ return withRetry(ctx, 'ship/start', async () => {
55
+ const result = await jsonLeg(ctx, `/deploy/projects/${enc(projectId)}/ship/start`, {
56
+ method: 'POST',
57
+ jsonBody: request,
58
+ });
59
+ if (!result.ok)
60
+ return result;
61
+ const value = result.value;
62
+ if (!value || typeof value.shipId !== 'string' || !Array.isArray(value.missing)) {
63
+ return {
64
+ ok: false,
65
+ kind: 'invalid-response',
66
+ message: 'ship/start response missing shipId / missing buckets',
67
+ };
68
+ }
69
+ return {
70
+ ok: true,
71
+ value: {
72
+ shipId: value.shipId,
73
+ missing: value.missing,
74
+ caps: (value.caps ?? { maxFileBytes: 0, maxTotalBytes: 0, maxFiles: 0 }),
75
+ },
76
+ };
77
+ });
78
+ }
79
+ /** Leg 2 — POST /v1/deploy/projects/:id/ship/:shipId/assets (base64 bucket). */
80
+ export async function uploadAssetBucket(ctx, projectId, shipId, files) {
81
+ return withRetry(ctx, 'assets', () => jsonLeg(ctx, `/deploy/projects/${enc(projectId)}/ship/${enc(shipId)}/assets`, {
82
+ method: 'POST',
83
+ jsonBody: { files },
84
+ }));
85
+ }
86
+ /**
87
+ * Leg 3 — POST /v1/deploy/projects/:id/ship/:shipId/finalize.
88
+ *
89
+ * Worker modules go as multipart FormData (Node 20 global FormData/Blob,
90
+ * busboy on the server route); pure-static sends empty JSON and the server
91
+ * attaches the canonical assets-only shim. Distinguished outcomes:
92
+ * success | 409 awaiting-approval (T3 prod gate — NOT an error)
93
+ * | 410 upload-expired (ship session lapsed → rerun)
94
+ */
95
+ export async function finalizeShip(ctx, projectId, shipId, modules = []) {
96
+ // NOT auto-retried (unlike ship/start + assets). finalize is single-shot per
97
+ // ship session: if the first request reached the server and created the
98
+ // release but the response was lost to a transient 5xx/522, a blind retry
99
+ // hits the open→finalized CAS and returns a deterministic 410 upload-expired
100
+ // — masking a deploy that actually SUCCEEDED. So a transient finalize fault
101
+ // surfaces as kind 'network' (exit 4); the operator checks `yolo deploy
102
+ // status` before rerunning rather than auto-looping into a misleading error.
103
+ const token = currentToken(ctx);
104
+ if (!token)
105
+ return missingTokenFailure();
106
+ const fetchImpl = resolveFetch(ctx);
107
+ if (!fetchImpl)
108
+ return noFetchFailure();
109
+ const url = `${stripTrailingSlash(ctx.commonApiUrl)}/v1/deploy/projects/${enc(projectId)}/ship/${enc(shipId)}/finalize`;
110
+ let response;
111
+ try {
112
+ if (modules.length > 0) {
113
+ const form = new FormData();
114
+ for (const module of modules) {
115
+ form.append(module.name, new Blob([module.contents], { type: 'application/javascript+module' }), module.name);
116
+ }
117
+ // No explicit Content-Type — fetch sets the multipart boundary itself.
118
+ response = await fetchImpl(url, {
119
+ method: 'POST',
120
+ headers: { Authorization: `Bearer ${token}` },
121
+ body: form,
122
+ });
123
+ }
124
+ else {
125
+ response = await fetchImpl(url, {
126
+ method: 'POST',
127
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
128
+ body: JSON.stringify({}),
129
+ });
130
+ }
131
+ }
132
+ catch (err) {
133
+ return networkFailure(err);
134
+ }
135
+ if (response.ok) {
136
+ let value = {};
137
+ try {
138
+ value = (await response.json());
139
+ }
140
+ catch {
141
+ // empty/non-JSON success body — tolerate
142
+ }
143
+ const release = (value.release ?? {});
144
+ const releaseId = str(value.releaseId) ?? str(release.releaseId) ?? str(release.id);
145
+ if (!releaseId) {
146
+ return { ok: false, kind: 'invalid-response', message: 'finalize response missing releaseId' };
147
+ }
148
+ return {
149
+ ok: true,
150
+ value: { ...value, releaseId, url: str(value.url) ?? str(release.url), status: str(value.status) ?? str(release.status) },
151
+ };
152
+ }
153
+ const failure = await mapErrorResponse(response);
154
+ // T3 prod gate: 409 awaiting-approval is a distinct, NOT-an-error outcome
155
+ // (spec §5 — exit 3, PENDING prefix, never blind-retried). The backend may
156
+ // spell the reason 'awaiting-approval' (CLI spec §4) or 'approval-required'
157
+ // (backend spec route 4c) — normalize to 'awaiting-approval'.
158
+ if (failure.status === 409 && (failure.kind === 'awaiting-approval' || failure.kind === 'approval-required')) {
159
+ const body = failure.body ?? {};
160
+ const detail = (failure.detail ?? {});
161
+ const approvalId = str(body.approvalId) ?? str(detail.approvalId);
162
+ if (approvalId) {
163
+ return {
164
+ ok: false,
165
+ kind: 'awaiting-approval',
166
+ approvalId,
167
+ approvalUrl: str(body.approvalUrl) ?? str(detail.approvalUrl),
168
+ statement: str(body.statement) ?? str(detail.statement),
169
+ expiresAt: str(body.expiresAt) ?? str(detail.expiresAt),
170
+ releaseId: str(body.releaseId) ?? str(detail.releaseId),
171
+ message: failure.message,
172
+ status: 409,
173
+ };
174
+ }
175
+ // 409 claiming approval but without an approvalId — fall through as a
176
+ // plain backend failure so the caller doesn't print a broken PENDING.
177
+ }
178
+ return failure;
179
+ }
180
+ /** POST /v1/deploy/projects */
181
+ export async function createProject(ctx, request) {
182
+ return jsonLeg(ctx, '/deploy/projects', { method: 'POST', jsonBody: request });
183
+ }
184
+ /** GET /v1/deploy/projects/:id — project + currentRelease + recentReleases + usage/caps. */
185
+ export async function getProjectStatus(ctx, projectId) {
186
+ return jsonLeg(ctx, `/deploy/projects/${enc(projectId)}`, { method: 'GET' });
187
+ }
188
+ /**
189
+ * GET /v1/deploy/projects — the caller's OWN hosting projects (owner-scoped
190
+ * server-side). Used by `yolo deploy init`/`link` to reconcile a slug-taken
191
+ * error: if the taken slug is one the caller already owns, link to it instead
192
+ * of failing.
193
+ */
194
+ export async function listProjects(ctx) {
195
+ const result = await jsonLeg(ctx, '/deploy/projects', { method: 'GET' });
196
+ if (!result.ok)
197
+ return result;
198
+ const raw = (result.value && typeof result.value === 'object' ? result.value : {});
199
+ const projects = Array.isArray(result.value)
200
+ ? result.value
201
+ : Array.isArray(raw.projects)
202
+ ? raw.projects
203
+ : [];
204
+ return { ok: true, value: projects };
205
+ }
206
+ /** POST /v1/deploy/projects/:id/rollback */
207
+ export async function rollbackProject(ctx, projectId, request = {}) {
208
+ return jsonLeg(ctx, `/deploy/projects/${enc(projectId)}/rollback`, { method: 'POST', jsonBody: request });
209
+ }
210
+ /**
211
+ * POST /v1/deploy/projects/:id/resources/:rid/query (Phase 2).
212
+ *
213
+ * The CLI does NOT know the resourceId — it targets the project's SOLE
214
+ * D1 by sending the `default` segment (the route's `resourceId?`
215
+ * contract; the server resolves the lone D1). Writes need
216
+ * `allowWrite:true`; DDL/PRAGMA/ATTACH are blocked server-side
217
+ * (`sql-not-allowed`). 4xx refusal reasons pass through verbatim as the
218
+ * structured failure `kind`.
219
+ */
220
+ export async function queryD1(ctx, projectId, sql, options = {}) {
221
+ const result = await jsonLeg(ctx, `/deploy/projects/${enc(projectId)}/resources/default/query`, {
222
+ method: 'POST',
223
+ jsonBody: {
224
+ sql,
225
+ ...(options.params !== undefined ? { params: options.params } : {}),
226
+ allowWrite: options.allowWrite ?? false,
227
+ },
228
+ });
229
+ if (!result.ok)
230
+ return result;
231
+ const value = (result.value && typeof result.value === 'object' ? result.value : {});
232
+ const results = Array.isArray(value.results)
233
+ ? value.results
234
+ : Array.isArray(value.rows)
235
+ ? value.rows
236
+ : [];
237
+ return {
238
+ ok: true,
239
+ value: { ...value, results, meta: value.meta && typeof value.meta === 'object' ? value.meta : undefined },
240
+ };
241
+ }
242
+ /**
243
+ * PUT /v1/deploy/projects/:id/slug — rename the project's primary slug.
244
+ * `keepOldAsRedirect` (default true) makes the old slug 308 → the new URL.
245
+ */
246
+ export async function renameProject(ctx, projectId, request) {
247
+ return jsonLeg(ctx, `/deploy/projects/${enc(projectId)}/slug`, { method: 'PUT', jsonBody: request });
248
+ }
249
+ /** POST /v1/deploy/projects/:id/aliases — add an alias hostname (slug). */
250
+ export async function addAlias(ctx, projectId, slug) {
251
+ return jsonLeg(ctx, `/deploy/projects/${enc(projectId)}/aliases`, { method: 'POST', jsonBody: { slug } });
252
+ }
253
+ /** DELETE /v1/deploy/projects/:id/aliases/:slug — remove an alias/redirect binding. */
254
+ export async function removeAlias(ctx, projectId, slug) {
255
+ return jsonLeg(ctx, `/deploy/projects/${enc(projectId)}/aliases/${enc(slug)}`, { method: 'DELETE' });
256
+ }
257
+ /** PUT /v1/deploy/projects/:id/redirects/:slug — 308 a slug to an external URL. */
258
+ export async function setRedirect(ctx, projectId, slug, target) {
259
+ return jsonLeg(ctx, `/deploy/projects/${enc(projectId)}/redirects/${enc(slug)}`, {
260
+ method: 'PUT',
261
+ jsonBody: { target },
262
+ });
263
+ }
264
+ /**
265
+ * DELETE /v1/deploy/projects/:id — delete the project. A live site is refused
266
+ * server-side (a `not-confirmed` refusal with a hint) unless the caller passes
267
+ * the current slug as `confirmSlug`; a throwaway/never-shipped project deletes
268
+ * without confirmation.
269
+ */
270
+ export async function deleteProject(ctx, projectId, request = {}) {
271
+ return jsonLeg(ctx, `/deploy/projects/${enc(projectId)}`, {
272
+ method: 'DELETE',
273
+ ...(request.confirmSlug !== undefined ? { jsonBody: { confirmSlug: request.confirmSlug } } : {}),
274
+ });
275
+ }
276
+ /**
277
+ * POST /v1/deploy/projects/:id/clone — duplicate a project's config (env +
278
+ * secrets + fresh D1/KV/R2 bindings) into a new EMPTY project. No release is
279
+ * copied — the clone must be re-shipped to populate it. Optional `name`/`slug`
280
+ * name the clone; the backend derives defaults otherwise. Returns
281
+ * `{ project, sourceSlug, resourcesCloned, secretsCloned }`. 4xx refusal
282
+ * reasons (slug-taken, hosting-disabled, …) pass through as the failure `kind`.
283
+ */
284
+ export async function cloneProject(ctx, projectId, request = {}) {
285
+ return jsonLeg(ctx, `/deploy/projects/${enc(projectId)}/clone`, {
286
+ method: 'POST',
287
+ jsonBody: {
288
+ ...(request.name !== undefined ? { name: request.name } : {}),
289
+ ...(request.slug !== undefined ? { slug: request.slug } : {}),
290
+ },
291
+ });
292
+ }
293
+ /** GET /v1/deploy/projects/:id/logs (buffered variant). */
294
+ export async function getLogs(ctx, projectId, options = {}) {
295
+ const params = new URLSearchParams();
296
+ if (options.sinceMinutes !== undefined)
297
+ params.set('sinceMinutes', String(options.sinceMinutes));
298
+ if (options.limit !== undefined)
299
+ params.set('limit', String(options.limit));
300
+ const qs = params.toString();
301
+ return jsonLeg(ctx, `/deploy/projects/${enc(projectId)}/logs${qs ? `?${qs}` : ''}`, { method: 'GET' });
302
+ }
303
+ /**
304
+ * GET /v1/deploy/projects/:id/tail — ONE long-poll over the live-tail buffer
305
+ * (signal C). The server waits up to ~`waitMs` for new lines and returns them
306
+ * plus a `cursor`; the `--tail` loop calls this repeatedly, threading the cursor
307
+ * so there are no gaps or duplicates. Long-poll (not an infinite stream) so it
308
+ * survives proxy/ingress idle timeouts.
309
+ */
310
+ export async function pollTail(ctx, projectId, options = {}) {
311
+ const params = new URLSearchParams();
312
+ if (options.releaseId !== undefined)
313
+ params.set('releaseId', options.releaseId);
314
+ if (options.cursor !== undefined)
315
+ params.set('cursor', options.cursor);
316
+ if (options.waitMs !== undefined)
317
+ params.set('waitMs', String(options.waitMs));
318
+ const qs = params.toString();
319
+ return jsonLeg(ctx, `/deploy/projects/${enc(projectId)}/tail${qs ? `?${qs}` : ''}`, {
320
+ method: 'GET',
321
+ });
322
+ }
323
+ // ─── Internals ────────────────────────────────────────────────────────────
324
+ /**
325
+ * Run a ship leg with bounded-backoff retry on TRANSIENT transport faults.
326
+ *
327
+ * Retries only `{ ok:false, kind:'network' }` (5xx / CF 522 / transport error
328
+ * — see `mapErrorResponse`). Every other result — success, a 4xx refusal,
329
+ * auth, invalid-response, or awaiting-approval — returns immediately. The leg
330
+ * `fn` re-resolves the token per call, so a retry can't ride a token that
331
+ * expired mid-ship.
332
+ */
333
+ async function withRetry(ctx, leg, fn) {
334
+ const cfg = ctx.retry;
335
+ // Opt-in: a context with no `retry` config runs exactly once (no retry).
336
+ if (!cfg)
337
+ return fn();
338
+ const maxAttempts = Math.max(1, cfg.maxAttempts ?? 3);
339
+ const baseDelayMs = cfg.baseDelayMs ?? 600;
340
+ const sleep = cfg.sleepImpl ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
341
+ let attempt = 0;
342
+ for (;;) {
343
+ attempt++;
344
+ const result = await fn();
345
+ if (result.ok || result.kind !== 'network' || attempt >= maxAttempts)
346
+ return result;
347
+ const delayMs = baseDelayMs * 2 ** (attempt - 1);
348
+ cfg.onRetry?.({
349
+ leg,
350
+ attempt,
351
+ maxAttempts,
352
+ delayMs,
353
+ failure: result,
354
+ });
355
+ await sleep(delayMs);
356
+ }
357
+ }
358
+ /**
359
+ * Re-resolve the user token (rotation file → YOLO_API_TOKEN env). Called at
360
+ * the top of EVERY leg — never cache across legs (spec §0: the ~10-min
361
+ * rotation must not expire a multi-leg ship mid-flight).
362
+ */
363
+ function currentToken(ctx) {
364
+ return currentTokenFrom(ctx.env, ctx.readFileImpl);
365
+ }
366
+ function currentTokenFrom(env, readFileImpl) {
367
+ return readFileImpl ? resolveUserToken(env, readFileImpl) : resolveUserToken(env);
368
+ }
369
+ function resolveFetch(ctx) {
370
+ return ctx.fetchImpl ?? globalThis.fetch;
371
+ }
372
+ /** Shared JSON leg: re-resolve token, hit /v1<path> via userRouteRequest, map outcomes. */
373
+ async function jsonLeg(ctx, routePath, init) {
374
+ const token = currentToken(ctx);
375
+ if (!token)
376
+ return missingTokenFailure();
377
+ let response;
378
+ try {
379
+ response = await userRouteRequest({ commonApiUrl: ctx.commonApiUrl, userToken: token, fetchImpl: ctx.fetchImpl }, routePath, init);
380
+ }
381
+ catch (err) {
382
+ return networkFailure(err);
383
+ }
384
+ if (response.ok) {
385
+ try {
386
+ return { ok: true, value: await response.json() };
387
+ }
388
+ catch {
389
+ return { ok: true, value: {} };
390
+ }
391
+ }
392
+ return mapErrorResponse(response);
393
+ }
394
+ /**
395
+ * Map a non-2xx response onto the structured failure envelope. The backend's
396
+ * 4xx refusal body ({ok:false, reason, message, detail?, hint?} — or the
397
+ * legacy {error, code}) passes through verbatim: `kind` IS the reason.
398
+ */
399
+ async function mapErrorResponse(response) {
400
+ const status = response.status;
401
+ let body = {};
402
+ try {
403
+ const parsed = await response.json();
404
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
405
+ body = parsed;
406
+ }
407
+ }
408
+ catch {
409
+ try {
410
+ const text = await response.text();
411
+ if (text)
412
+ body = { message: text };
413
+ }
414
+ catch {
415
+ // no readable body
416
+ }
417
+ }
418
+ const message = str(body.message) ?? str(body.error) ?? `HTTP ${status}`;
419
+ if (status === 401) {
420
+ return { ok: false, kind: 'auth', message: `authentication failed: ${message}`, status, body };
421
+ }
422
+ if (status >= 500) {
423
+ // Genuine server/upstream fault — transient, retry with backoff (exit 4).
424
+ return { ok: false, kind: 'network', message: `server error (HTTP ${status}): ${message}`, status, body };
425
+ }
426
+ let reason = str(body.reason) ?? str(body.code);
427
+ if (!reason && status === 410)
428
+ reason = 'upload-expired';
429
+ return {
430
+ ok: false,
431
+ kind: reason ?? `http-${status}`,
432
+ message,
433
+ status,
434
+ detail: body.detail && typeof body.detail === 'object' ? body.detail : undefined,
435
+ hint: str(body.hint),
436
+ body,
437
+ };
438
+ }
439
+ function missingTokenFailure() {
440
+ return {
441
+ ok: false,
442
+ kind: 'auth',
443
+ message: 'no user token available: expected a user access JWT in ~/.config/yolo/token or the YOLO_API_TOKEN env var',
444
+ };
445
+ }
446
+ function noFetchFailure() {
447
+ return { ok: false, kind: 'network', message: 'fetch is not available; the substrate CLI requires Node 20+' };
448
+ }
449
+ function networkFailure(err) {
450
+ return { ok: false, kind: 'network', message: `request failed: ${err instanceof Error ? err.message : String(err)}` };
451
+ }
452
+ function str(value) {
453
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
454
+ }
455
+ function enc(value) {
456
+ return encodeURIComponent(value);
457
+ }
458
+ function stripTrailingSlash(url) {
459
+ return url.endsWith('/') ? url.slice(0, -1) : url;
460
+ }