@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,1341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deploy-cli — arg parsing + subcommand dispatch for `yolo deploy`
|
|
3
|
+
* (docs/MANAGED_HOSTING_CLI_SPEC.md §1).
|
|
4
|
+
*
|
|
5
|
+
* yolo deploy [--env <staging|prod>] [--dry-run] [--json] ← bare = ship
|
|
6
|
+
* yolo deploy init [--slug <slug>] [--type <static|worker>]
|
|
7
|
+
* yolo deploy status [--json]
|
|
8
|
+
* yolo deploy logs [--tail] [--since <dur>] [--json]
|
|
9
|
+
* yolo deploy rollback [releaseId] [--json]
|
|
10
|
+
* (yolo deploy db query — Phase 2, deliberately NOT wired)
|
|
11
|
+
*
|
|
12
|
+
* Output contract (spec §4/§5):
|
|
13
|
+
* - Progress lines `deploy: …` go to stdout, or stderr under --json (one
|
|
14
|
+
* final JSON object on stdout is the machine-readable result).
|
|
15
|
+
* - Failures: single structured stderr line `FAIL [<reason>]: <msg> | hint: …`
|
|
16
|
+
* - awaiting-approval: `PENDING [awaiting-approval]: …` (NOT FAIL) + exit 3
|
|
17
|
+
* + the approval URL + the "do NOT rerun" hint. Two independent signals
|
|
18
|
+
* so neither prefix-matching nor exit-code-matching agents retry-loop.
|
|
19
|
+
*
|
|
20
|
+
* Exit codes are EXACTLY spec §5 via deploy-ship's `exitCodeForFailure`:
|
|
21
|
+
* 0 ok / 64 usage / 78 env / 1 local / 2 backend-rejected / 3 pending / 4 network.
|
|
22
|
+
*/
|
|
23
|
+
import * as path from 'node:path';
|
|
24
|
+
import { statSync } from 'node:fs';
|
|
25
|
+
function defaultStatPath(p) {
|
|
26
|
+
try {
|
|
27
|
+
const s = statSync(p);
|
|
28
|
+
return s.isFile() ? 'file' : s.isDirectory() ? 'dir' : 'other';
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return 'missing';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
import { runDeployShip, exitCodeForFailure, formatShipSuccess, formatPending, formatFail, } from './deploy-ship.js';
|
|
35
|
+
import { resolveDeployContext, createProject, getProjectStatus, listProjects, rollbackProject, renameProject, addAlias, removeAlias, setRedirect, deleteProject, cloneProject, getLogs, pollTail, queryD1, } from './deploy-client.js';
|
|
36
|
+
import { readDeployConfig, writeDeployConfig } from './deploy-config.js';
|
|
37
|
+
import { adaptWrangler, detectProjectShape } from './deploy-detect.js';
|
|
38
|
+
import { defaultReadFile } from './auth-context.js';
|
|
39
|
+
const USAGE = [
|
|
40
|
+
'Usage: yolo deploy [--env <staging|prod>] [--dry-run] [--json]',
|
|
41
|
+
' yolo deploy init [--slug <slug>] [--type <static|worker>]',
|
|
42
|
+
' yolo deploy link (--project-id <id> | --slug <slug>) [--type <static|worker>]',
|
|
43
|
+
' yolo deploy validate [--json]',
|
|
44
|
+
' yolo deploy status [--json]',
|
|
45
|
+
' yolo deploy logs [--tail] [--since <dur>] [--json]',
|
|
46
|
+
' yolo deploy rollback [releaseId] [--json]',
|
|
47
|
+
' yolo deploy rename <newslug> [--no-redirect] [--json]',
|
|
48
|
+
' yolo deploy alias <slug> [--json]',
|
|
49
|
+
' yolo deploy alias rm <slug> [--json]',
|
|
50
|
+
' yolo deploy redirect <slug> <url> [--json]',
|
|
51
|
+
' yolo deploy delete [--confirm <slug>] [--json]',
|
|
52
|
+
' yolo deploy clone [--name <name>] [--slug <slug>] [--json]',
|
|
53
|
+
' yolo deploy db query "<sql>" [--json]',
|
|
54
|
+
].join('\n');
|
|
55
|
+
// ─── Entry ────────────────────────────────────────────────────────────────
|
|
56
|
+
/** `args` = everything after the `deploy` verb. */
|
|
57
|
+
export async function runDeployCmd(args, deps = {}) {
|
|
58
|
+
const io = deps.io ?? defaultIo();
|
|
59
|
+
const sub = args[0];
|
|
60
|
+
if (sub === '--help' || sub === '-h') {
|
|
61
|
+
io.out(`${USAGE}\n`);
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
if (sub === 'init')
|
|
65
|
+
return runInitCmd(args.slice(1), deps, io);
|
|
66
|
+
if (sub === 'link')
|
|
67
|
+
return runLinkCmd(args.slice(1), deps, io);
|
|
68
|
+
if (sub === 'validate')
|
|
69
|
+
return runValidateCmd(args.slice(1), deps, io);
|
|
70
|
+
if (sub === 'status')
|
|
71
|
+
return runStatusCmd(args.slice(1), deps, io);
|
|
72
|
+
if (sub === 'logs')
|
|
73
|
+
return runLogsCmd(args.slice(1), deps, io);
|
|
74
|
+
if (sub === 'rollback')
|
|
75
|
+
return runRollbackCmd(args.slice(1), deps, io);
|
|
76
|
+
if (sub === 'rename')
|
|
77
|
+
return runRenameCmd(args.slice(1), deps, io);
|
|
78
|
+
if (sub === 'alias')
|
|
79
|
+
return runAliasCmd(args.slice(1), deps, io);
|
|
80
|
+
if (sub === 'redirect')
|
|
81
|
+
return runRedirectCmd(args.slice(1), deps, io);
|
|
82
|
+
if (sub === 'delete')
|
|
83
|
+
return runDeleteCmd(args.slice(1), deps, io);
|
|
84
|
+
if (sub === 'clone')
|
|
85
|
+
return runCloneCmd(args.slice(1), deps, io);
|
|
86
|
+
if (sub === 'db')
|
|
87
|
+
return runDbCmd(args.slice(1), deps, io);
|
|
88
|
+
if (sub && !sub.startsWith('--')) {
|
|
89
|
+
io.err(`yolo deploy: unknown subcommand '${sub}'\n${USAGE}\n`);
|
|
90
|
+
return 64;
|
|
91
|
+
}
|
|
92
|
+
return runShipCmd(args, deps, io);
|
|
93
|
+
}
|
|
94
|
+
export function parseShipArgs(args) {
|
|
95
|
+
let envFlag = 'staging';
|
|
96
|
+
let dryRun = false;
|
|
97
|
+
let jsonOutput = false;
|
|
98
|
+
for (let i = 0; i < args.length; i++) {
|
|
99
|
+
const a = args[i];
|
|
100
|
+
if (a === '--env') {
|
|
101
|
+
const v = args[++i];
|
|
102
|
+
if (!v || v.startsWith('--'))
|
|
103
|
+
return { ok: false, message: '--env requires a value (staging|prod)' };
|
|
104
|
+
if (v !== 'staging' && v !== 'prod')
|
|
105
|
+
return { ok: false, message: `--env must be 'staging' or 'prod' (got '${v}')` };
|
|
106
|
+
envFlag = v;
|
|
107
|
+
}
|
|
108
|
+
else if (a.startsWith('--env=')) {
|
|
109
|
+
const v = a.slice('--env='.length);
|
|
110
|
+
if (v !== 'staging' && v !== 'prod')
|
|
111
|
+
return { ok: false, message: `--env must be 'staging' or 'prod' (got '${v}')` };
|
|
112
|
+
envFlag = v;
|
|
113
|
+
}
|
|
114
|
+
else if (a === '--dry-run') {
|
|
115
|
+
dryRun = true;
|
|
116
|
+
}
|
|
117
|
+
else if (a === '--json') {
|
|
118
|
+
jsonOutput = true;
|
|
119
|
+
}
|
|
120
|
+
else if (a.startsWith('--')) {
|
|
121
|
+
return { ok: false, message: `unknown option: ${a}` };
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
return { ok: false, message: `unexpected positional argument: ${a}` };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { ok: true, envFlag, dryRun, jsonOutput };
|
|
128
|
+
}
|
|
129
|
+
async function runShipCmd(args, deps, io) {
|
|
130
|
+
const parsed = parseShipArgs(args);
|
|
131
|
+
if (!parsed.ok) {
|
|
132
|
+
io.err(`yolo deploy: ${parsed.message}\n${USAGE}\n`);
|
|
133
|
+
return 64;
|
|
134
|
+
}
|
|
135
|
+
// Under --json, progress lines route to stderr; the single final JSON
|
|
136
|
+
// object on stdout is the machine-readable result (spec §4).
|
|
137
|
+
const progressSink = parsed.jsonOutput ? io.err : io.out;
|
|
138
|
+
const ship = deps.runShipImpl ?? runDeployShip;
|
|
139
|
+
const result = await ship({
|
|
140
|
+
cwd: deps.cwd,
|
|
141
|
+
envFlag: parsed.envFlag,
|
|
142
|
+
dryRun: parsed.dryRun,
|
|
143
|
+
env: deps.env,
|
|
144
|
+
readFileImpl: deps.readFileImpl,
|
|
145
|
+
fetchImpl: deps.fetchImpl,
|
|
146
|
+
progress: (line) => progressSink(`${line}\n`),
|
|
147
|
+
deps: deps.shipDeps,
|
|
148
|
+
});
|
|
149
|
+
if (result.ok) {
|
|
150
|
+
io.out(parsed.jsonOutput ? `${formatJsonResult(result)}\n` : `${formatShipSuccess(result)}\n`);
|
|
151
|
+
// The ship SUCCEEDED, but the backend's post-ship probe found the live Worker
|
|
152
|
+
// didn't boot (522). Surface it (stderr, human mode) so a "deployed but dead"
|
|
153
|
+
// release isn't mistaken for a clean ship — `--json` already carries bootCheck.
|
|
154
|
+
if (!parsed.jsonOutput && result.bootCheck) {
|
|
155
|
+
io.err(`warn: ${result.bootCheck.detail}\n`);
|
|
156
|
+
}
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
if (result.kind === 'awaiting-approval' && 'approvalId' in result) {
|
|
160
|
+
// NOT an error — PENDING prefix + exit 3, never FAIL (spec §5).
|
|
161
|
+
if (parsed.jsonOutput) {
|
|
162
|
+
io.out(`${formatJsonResult({
|
|
163
|
+
...result,
|
|
164
|
+
hint: "poll 'yolo deploy status'; do NOT rerun 'yolo deploy' — the bundle is already staged",
|
|
165
|
+
})}\n`);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
io.err(`${formatPending(result)}\n`);
|
|
169
|
+
}
|
|
170
|
+
return 3;
|
|
171
|
+
}
|
|
172
|
+
io.err(parsed.jsonOutput ? `${formatJsonResult(result)}\n` : `${formatFail(result)}\n`);
|
|
173
|
+
return exitCodeForFailure(result.kind);
|
|
174
|
+
}
|
|
175
|
+
export function parseInitArgs(args) {
|
|
176
|
+
let slug;
|
|
177
|
+
let type;
|
|
178
|
+
for (let i = 0; i < args.length; i++) {
|
|
179
|
+
const a = args[i];
|
|
180
|
+
if (a === '--slug') {
|
|
181
|
+
const v = args[++i];
|
|
182
|
+
if (!v || v.startsWith('--'))
|
|
183
|
+
return { ok: false, message: '--slug requires a value' };
|
|
184
|
+
slug = v;
|
|
185
|
+
}
|
|
186
|
+
else if (a.startsWith('--slug=')) {
|
|
187
|
+
slug = a.slice('--slug='.length);
|
|
188
|
+
}
|
|
189
|
+
else if (a === '--type') {
|
|
190
|
+
const v = args[++i];
|
|
191
|
+
if (!v || v.startsWith('--'))
|
|
192
|
+
return { ok: false, message: '--type requires a value (static|worker)' };
|
|
193
|
+
if (v !== 'static' && v !== 'worker')
|
|
194
|
+
return { ok: false, message: `--type must be 'static' or 'worker' (got '${v}')` };
|
|
195
|
+
type = v;
|
|
196
|
+
}
|
|
197
|
+
else if (a.startsWith('--type=')) {
|
|
198
|
+
const v = a.slice('--type='.length);
|
|
199
|
+
if (v !== 'static' && v !== 'worker')
|
|
200
|
+
return { ok: false, message: `--type must be 'static' or 'worker' (got '${v}')` };
|
|
201
|
+
type = v;
|
|
202
|
+
}
|
|
203
|
+
else if (a.startsWith('--')) {
|
|
204
|
+
return { ok: false, message: `unknown option: ${a}` };
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
return { ok: false, message: `unexpected positional argument: ${a}` };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { ok: true, slug, type };
|
|
211
|
+
}
|
|
212
|
+
async function runInitCmd(args, deps, io) {
|
|
213
|
+
const parsed = parseInitArgs(args);
|
|
214
|
+
if (!parsed.ok) {
|
|
215
|
+
io.err(`yolo deploy init: ${parsed.message}\nUsage: yolo deploy init [--slug <slug>] [--type <static|worker>]\n`);
|
|
216
|
+
return 64;
|
|
217
|
+
}
|
|
218
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
219
|
+
const readConfig = deps.readDeployConfigImpl ?? readDeployConfig;
|
|
220
|
+
const writeConfig = deps.writeDeployConfigImpl ?? writeDeployConfig;
|
|
221
|
+
const readResult = readConfig(cwd);
|
|
222
|
+
if (!readResult.ok) {
|
|
223
|
+
// Never silently clobber a malformed/invalid link file.
|
|
224
|
+
io.err(`${formatFail({ kind: readResult.kind, message: readResult.message })}\n`);
|
|
225
|
+
return exitCodeForFailure(readResult.kind);
|
|
226
|
+
}
|
|
227
|
+
const existing = readResult.config;
|
|
228
|
+
if (existing?.projectId) {
|
|
229
|
+
// Idempotent: a committed link means future sessions ship to the SAME
|
|
230
|
+
// project instead of forking a new slug — never silently re-create.
|
|
231
|
+
io.out(`OK: already linked to project ${existing.projectId}${existing.slug ? ` (slug ${existing.slug})` : ''} — .yolo/deploy.json left unchanged\n`);
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
// Fresh init in a project that has a wrangler config but no .yolo/deploy.json:
|
|
235
|
+
// migrate it onto the canonical format (init-only adaptation — the recommended
|
|
236
|
+
// setup move; the CLI reads wrangler only as a fallback). A pre-existing
|
|
237
|
+
// (shape-bearing) deploy.json is respected, so we only adapt when none exists.
|
|
238
|
+
const adapted = existing == null ? adaptWrangler(cwd, deps.readFileImpl) : undefined;
|
|
239
|
+
const auth = resolveAuth(deps);
|
|
240
|
+
if (!auth.ok) {
|
|
241
|
+
io.err(`${formatFail({ kind: 'auth', message: auth.message })}\n`);
|
|
242
|
+
return 78;
|
|
243
|
+
}
|
|
244
|
+
const create = deps.createProjectImpl ?? createProject;
|
|
245
|
+
const created = await create(auth.context, {
|
|
246
|
+
name: parsed.slug ?? path.basename(cwd),
|
|
247
|
+
...(parsed.slug ? { slug: parsed.slug } : {}),
|
|
248
|
+
});
|
|
249
|
+
if (!created.ok) {
|
|
250
|
+
// create-or-LINK: a `slug-taken` on a slug the caller ALREADY OWNS means a
|
|
251
|
+
// prior create (commonly via the deploy_create_project MCP tool, which
|
|
252
|
+
// doesn't write the link file). Reconcile by linking to it instead of
|
|
253
|
+
// dead-ending — `init` should never strand an owned project. The slug is
|
|
254
|
+
// the explicit `--slug` OR (when omitted) the server-derived slug, which
|
|
255
|
+
// the refusal carries in `detail.slug` — so bare `yolo deploy init` also
|
|
256
|
+
// reconciles.
|
|
257
|
+
if (created.kind === 'slug-taken') {
|
|
258
|
+
const takenSlug = parsed.slug ?? str(created.detail?.slug);
|
|
259
|
+
if (takenSlug) {
|
|
260
|
+
// Best-effort: if the lookup itself fails (auth/network), don't mask
|
|
261
|
+
// the genuine slug-taken — fall through to it below.
|
|
262
|
+
const lookup = await findOwnedProjectBySlug(deps, auth.context, takenSlug);
|
|
263
|
+
const owned = lookup.ok ? lookup.project : undefined;
|
|
264
|
+
const projectId = owned ? resolveProjectId(owned) : undefined;
|
|
265
|
+
if (projectId) {
|
|
266
|
+
return writeLinkFile(cwd, writeConfig, io, { projectId, slug: str(owned.slug) ?? takenSlug, type: parsed.type, existing, adapted }, `OK: slug '${takenSlug}' was already yours — linked existing project ${projectId} — wrote .yolo/deploy.json`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
io.err(`${formatFail(created)}\n`);
|
|
271
|
+
return exitCodeForFailure(created.kind);
|
|
272
|
+
}
|
|
273
|
+
const raw = created.value;
|
|
274
|
+
const project = (raw.project && typeof raw.project === 'object' ? raw.project : raw);
|
|
275
|
+
const projectId = resolveProjectId(project);
|
|
276
|
+
const slug = str(project.slug) ?? parsed.slug;
|
|
277
|
+
if (!projectId) {
|
|
278
|
+
io.err(`${formatFail({ kind: 'invalid-response', message: 'create-project response missing a project id' })}\n`);
|
|
279
|
+
return 2;
|
|
280
|
+
}
|
|
281
|
+
return writeLinkFile(cwd, writeConfig, io, { projectId, slug, type: parsed.type, existing, adapted }, `OK: linked project ${projectId}${slug ? ` (slug ${slug})` : ''} — wrote .yolo/deploy.json`);
|
|
282
|
+
}
|
|
283
|
+
export function parseLinkArgs(args) {
|
|
284
|
+
let projectId;
|
|
285
|
+
let slug;
|
|
286
|
+
let type;
|
|
287
|
+
for (let i = 0; i < args.length; i++) {
|
|
288
|
+
const a = args[i];
|
|
289
|
+
if (a === '--project-id') {
|
|
290
|
+
const v = args[++i];
|
|
291
|
+
if (!v || v.startsWith('--'))
|
|
292
|
+
return { ok: false, message: '--project-id requires a value' };
|
|
293
|
+
projectId = v;
|
|
294
|
+
}
|
|
295
|
+
else if (a.startsWith('--project-id=')) {
|
|
296
|
+
projectId = a.slice('--project-id='.length);
|
|
297
|
+
}
|
|
298
|
+
else if (a === '--slug') {
|
|
299
|
+
const v = args[++i];
|
|
300
|
+
if (!v || v.startsWith('--'))
|
|
301
|
+
return { ok: false, message: '--slug requires a value' };
|
|
302
|
+
slug = v;
|
|
303
|
+
}
|
|
304
|
+
else if (a.startsWith('--slug=')) {
|
|
305
|
+
slug = a.slice('--slug='.length);
|
|
306
|
+
}
|
|
307
|
+
else if (a === '--type') {
|
|
308
|
+
const v = args[++i];
|
|
309
|
+
if (!v || v.startsWith('--'))
|
|
310
|
+
return { ok: false, message: '--type requires a value (static|worker)' };
|
|
311
|
+
if (v !== 'static' && v !== 'worker')
|
|
312
|
+
return { ok: false, message: `--type must be 'static' or 'worker' (got '${v}')` };
|
|
313
|
+
type = v;
|
|
314
|
+
}
|
|
315
|
+
else if (a.startsWith('--type=')) {
|
|
316
|
+
const v = a.slice('--type='.length);
|
|
317
|
+
if (v !== 'static' && v !== 'worker')
|
|
318
|
+
return { ok: false, message: `--type must be 'static' or 'worker' (got '${v}')` };
|
|
319
|
+
type = v;
|
|
320
|
+
}
|
|
321
|
+
else if (a.startsWith('--')) {
|
|
322
|
+
return { ok: false, message: `unknown option: ${a}` };
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
return { ok: false, message: `unexpected positional argument: ${a}` };
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (!projectId && !slug) {
|
|
329
|
+
return { ok: false, message: 'provide --project-id <id> or --slug <slug> to identify the existing project' };
|
|
330
|
+
}
|
|
331
|
+
if (projectId && slug) {
|
|
332
|
+
// Either/or: accepting both would let a mismatched --slug write misleading
|
|
333
|
+
// link metadata that disagrees with the project the id resolves to.
|
|
334
|
+
return { ok: false, message: 'pass either --project-id or --slug, not both (the project\'s slug is resolved server-side)' };
|
|
335
|
+
}
|
|
336
|
+
return { ok: true, projectId, slug, type };
|
|
337
|
+
}
|
|
338
|
+
async function runLinkCmd(args, deps, io) {
|
|
339
|
+
const parsed = parseLinkArgs(args);
|
|
340
|
+
if (!parsed.ok) {
|
|
341
|
+
io.err(`yolo deploy link: ${parsed.message}\nUsage: yolo deploy link (--project-id <id> | --slug <slug>) [--type <static|worker>]\n`);
|
|
342
|
+
return 64;
|
|
343
|
+
}
|
|
344
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
345
|
+
const readConfig = deps.readDeployConfigImpl ?? readDeployConfig;
|
|
346
|
+
const writeConfig = deps.writeDeployConfigImpl ?? writeDeployConfig;
|
|
347
|
+
const readResult = readConfig(cwd);
|
|
348
|
+
if (!readResult.ok) {
|
|
349
|
+
io.err(`${formatFail({ kind: readResult.kind, message: readResult.message })}\n`);
|
|
350
|
+
return exitCodeForFailure(readResult.kind);
|
|
351
|
+
}
|
|
352
|
+
const existing = readResult.config;
|
|
353
|
+
const auth = resolveAuth(deps);
|
|
354
|
+
if (!auth.ok) {
|
|
355
|
+
io.err(`${formatFail({ kind: 'auth', message: auth.message })}\n`);
|
|
356
|
+
return 78;
|
|
357
|
+
}
|
|
358
|
+
// Resolve the target project — verify ownership server-side either way so we
|
|
359
|
+
// never write a link to a project the caller can't actually reach.
|
|
360
|
+
let projectId;
|
|
361
|
+
let slug = parsed.slug;
|
|
362
|
+
if (parsed.projectId) {
|
|
363
|
+
const status = deps.getProjectStatusImpl ?? getProjectStatus;
|
|
364
|
+
const result = await status(auth.context, parsed.projectId);
|
|
365
|
+
if (!result.ok) {
|
|
366
|
+
io.err(`${formatFail(result)}\n`);
|
|
367
|
+
return exitCodeForFailure(result.kind);
|
|
368
|
+
}
|
|
369
|
+
const raw = (result.value && typeof result.value === 'object' ? result.value : {});
|
|
370
|
+
const project = (raw.project && typeof raw.project === 'object' ? raw.project : raw);
|
|
371
|
+
projectId = resolveProjectId(project) ?? parsed.projectId;
|
|
372
|
+
slug = parsed.slug ?? str(project.slug);
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
const lookup = await findOwnedProjectBySlug(deps, auth.context, parsed.slug);
|
|
376
|
+
if (!lookup.ok) {
|
|
377
|
+
// A list failure (auth/network) is NOT "not found" — surface the real
|
|
378
|
+
// error + its exit code so the caller can retry or re-auth.
|
|
379
|
+
io.err(`${formatFail(lookup)}\n`);
|
|
380
|
+
return exitCodeForFailure(lookup.kind);
|
|
381
|
+
}
|
|
382
|
+
const owned = lookup.project;
|
|
383
|
+
projectId = owned ? resolveProjectId(owned) : undefined;
|
|
384
|
+
if (!projectId) {
|
|
385
|
+
io.err(`${formatFail({
|
|
386
|
+
kind: 'not-found',
|
|
387
|
+
message: `no hosting project you own has slug '${parsed.slug}'`,
|
|
388
|
+
hint: "run 'yolo deploy status' from a linked dir, or pass --project-id",
|
|
389
|
+
})}\n`);
|
|
390
|
+
return exitCodeForFailure('not-found');
|
|
391
|
+
}
|
|
392
|
+
slug = str(owned.slug) ?? parsed.slug;
|
|
393
|
+
}
|
|
394
|
+
// Refuse to silently repoint an existing link at a DIFFERENT project.
|
|
395
|
+
if (existing?.projectId && existing.projectId !== projectId) {
|
|
396
|
+
io.err(`${formatFail({
|
|
397
|
+
kind: 'already-linked',
|
|
398
|
+
message: `this directory is already linked to project ${existing.projectId}; refusing to repoint to ${projectId}`,
|
|
399
|
+
hint: 'edit or remove .yolo/deploy.json by hand if the repoint is intentional',
|
|
400
|
+
})}\n`);
|
|
401
|
+
return exitCodeForFailure('already-linked');
|
|
402
|
+
}
|
|
403
|
+
if (existing?.projectId === projectId) {
|
|
404
|
+
// Already linked to this project. Still honor a requested --type that isn't
|
|
405
|
+
// already set (the deploy_create_project link block has no `type`, so the
|
|
406
|
+
// advertised `link --type …` must be able to pin detection here). Pure
|
|
407
|
+
// no-op only when --type adds nothing.
|
|
408
|
+
if (!parsed.type || existing.type === parsed.type) {
|
|
409
|
+
io.out(`OK: already linked to project ${projectId}${slug ? ` (slug ${slug})` : ''} — .yolo/deploy.json left unchanged\n`);
|
|
410
|
+
return 0;
|
|
411
|
+
}
|
|
412
|
+
return writeLinkFile(cwd, writeConfig, io, { projectId: projectId, slug: slug ?? existing.slug, type: parsed.type, existing }, `OK: already linked to project ${projectId} — set type ${parsed.type} in .yolo/deploy.json`);
|
|
413
|
+
}
|
|
414
|
+
return writeLinkFile(cwd, writeConfig, io, { projectId: projectId, slug, type: parsed.type, existing }, `OK: linked project ${projectId}${slug ? ` (slug ${slug})` : ''} — wrote .yolo/deploy.json`);
|
|
415
|
+
}
|
|
416
|
+
// ─── validate ─────────────────────────────────────────────────────────────
|
|
417
|
+
/**
|
|
418
|
+
* `yolo deploy validate [--json]` — check `.yolo/deploy.json` + the resolved
|
|
419
|
+
* project shape WITHOUT bundling, uploading, or any network call. Catches config
|
|
420
|
+
* problems (bad `$version`/`type`, malformed JSON, missing `worker.entry`, a
|
|
421
|
+
* static project with no assets dir, a `prebuilt` entry that doesn't exist, …)
|
|
422
|
+
* before a real `yolo deploy` would fail mid-ship. Exit 0 = valid, 1 = invalid.
|
|
423
|
+
*/
|
|
424
|
+
async function runValidateCmd(args, deps, io) {
|
|
425
|
+
const jsonOutput = args.includes('--json');
|
|
426
|
+
// Reject unknown flags/positionals (consistent with the other subcommands).
|
|
427
|
+
const unexpected = args.filter((a) => a !== '--json');
|
|
428
|
+
if (unexpected.length > 0) {
|
|
429
|
+
io.err(`yolo deploy validate: unexpected argument(s): ${unexpected.join(' ')}\nUsage: yolo deploy validate [--json]\n`);
|
|
430
|
+
return 64;
|
|
431
|
+
}
|
|
432
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
433
|
+
const readFileImpl = deps.readFileImpl;
|
|
434
|
+
const readConfig = deps.readDeployConfigImpl ?? readDeployConfig;
|
|
435
|
+
const issues = [];
|
|
436
|
+
// Non-fatal advisories — e.g. an unknown/ignored deploy.json field. These do
|
|
437
|
+
// NOT fail validation (forward compat), but `validate` echoes them so a
|
|
438
|
+
// silently-dropped field (the `compatibilityDate` field-report case) surfaces.
|
|
439
|
+
const warnings = [];
|
|
440
|
+
// 1. Parse + schema-validate .yolo/deploy.json (absent is allowed — detection
|
|
441
|
+
// can still infer the shape; surfaced as a hint, not an error).
|
|
442
|
+
const readResult = readConfig(cwd, readFileImpl);
|
|
443
|
+
let config = null;
|
|
444
|
+
let configMissing = false;
|
|
445
|
+
if (!readResult.ok) {
|
|
446
|
+
if (readResult.errors && readResult.errors.length > 0) {
|
|
447
|
+
for (const e of readResult.errors)
|
|
448
|
+
issues.push({ path: e.path, message: e.message });
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
issues.push({ message: readResult.message });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
config = readResult.config;
|
|
456
|
+
configMissing = config === null;
|
|
457
|
+
for (const w of readResult.warnings)
|
|
458
|
+
warnings.push({ path: w.path, message: w.message });
|
|
459
|
+
}
|
|
460
|
+
// 2. Resolve the project shape (static-vs-worker, entry/assets), only if the
|
|
461
|
+
// config itself parsed.
|
|
462
|
+
let shape = null;
|
|
463
|
+
if (readResult.ok) {
|
|
464
|
+
const det = detectProjectShape({ cwd, config: config ?? null, readFileImpl });
|
|
465
|
+
if (!det.ok)
|
|
466
|
+
issues.push({ message: det.message });
|
|
467
|
+
else
|
|
468
|
+
shape = det.shape;
|
|
469
|
+
}
|
|
470
|
+
// 3. Verify the resolved entry / assets exist AND are the right kind on disk —
|
|
471
|
+
// detection trusts an EXPLICIT worker.entry without checking it, so a
|
|
472
|
+
// typo'd or wrong-kind path (e.g. entry pointing at a directory) would
|
|
473
|
+
// otherwise only fail mid-ship. BUT a missing path is fine when a build
|
|
474
|
+
// command will PRODUCE it (clean checkout, output dir not committed) —
|
|
475
|
+
// `yolo deploy` runs the build before bundling — so that's a note, not an
|
|
476
|
+
// error (codex P2). A present-but-wrong-kind path is always an error.
|
|
477
|
+
const notes = [];
|
|
478
|
+
// Prefer the shape's resolved buildCommand — `detectProjectShape` infers one
|
|
479
|
+
// from package.json even when `.yolo/deploy.json` has no explicit
|
|
480
|
+
// `build.command` (both static and worker shapes carry it) — and fall back to
|
|
481
|
+
// the raw config command when there's no detected shape.
|
|
482
|
+
const buildCmd = shape?.buildCommand ?? config?.build?.command;
|
|
483
|
+
const statPath = deps.statPathImpl ?? defaultStatPath;
|
|
484
|
+
// `producedByBuild` = is this path an OUTPUT the build command creates (a
|
|
485
|
+
// prebuilt worker module, a built assets dir) vs a SOURCE input esbuild
|
|
486
|
+
// bundles in place (a `src/index.ts` worker entry)? A missing OUTPUT with a
|
|
487
|
+
// build command pending is a note; a missing SOURCE entry is always an error
|
|
488
|
+
// — the build does not create it, so a typo must still fail validate (codex).
|
|
489
|
+
const checkPath = (rel, field, want, missingMsg, producedByBuild) => {
|
|
490
|
+
const kind = statPath(path.resolve(cwd, rel));
|
|
491
|
+
if (kind === 'missing') {
|
|
492
|
+
if (buildCmd && producedByBuild) {
|
|
493
|
+
notes.push(`'${rel}' not present yet — produced by the build (\`${buildCmd}\`) at deploy time`);
|
|
494
|
+
}
|
|
495
|
+
else {
|
|
496
|
+
issues.push({ path: field, message: missingMsg });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
else if ((want === 'file' && kind !== 'file') || (want === 'dir' && kind !== 'dir')) {
|
|
500
|
+
issues.push({ path: field, message: `'${rel}' is not a ${want === 'file' ? 'file' : 'directory'} (it's a ${kind})` });
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
if (shape) {
|
|
504
|
+
if (shape.type === 'worker') {
|
|
505
|
+
checkPath(shape.entry, 'worker.entry', 'file', `entry '${shape.entry}' does not exist${shape.prebuilt ? ' (prebuilt entries must be built before deploy)' : ' — build it or fix the path'}`,
|
|
506
|
+
// Only a PREBUILT worker entry is a build output; a source entry is
|
|
507
|
+
// bundled by esbuild, so a missing source entry stays a hard error.
|
|
508
|
+
shape.prebuilt === true);
|
|
509
|
+
if (shape.assetsDir)
|
|
510
|
+
checkPath(shape.assetsDir, 'worker.assetsDir', 'dir', `assets dir '${shape.assetsDir}' does not exist`, true);
|
|
511
|
+
}
|
|
512
|
+
else if (shape.type === 'static') {
|
|
513
|
+
checkPath(shape.assetsDir, 'build.outputDir', 'dir', `static assets dir '${shape.assetsDir}' does not exist — run the build first`, true);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
// 4. wrangler.toml is read only by a minimal line-based extractor (top-level
|
|
517
|
+
// `main` + `[assets].directory`); real-world toml — e.g. `main` sitting
|
|
518
|
+
// under a `[table]` — is silently missed. `.yolo/deploy.json` is the
|
|
519
|
+
// canonical format. Surface a NOTE so a present-but-unread toml isn't
|
|
520
|
+
// silent (the retro gotcha: "wrangler.toml silently ignored").
|
|
521
|
+
if ((readFileImpl ?? defaultReadFile)(path.join(cwd, 'wrangler.toml')) !== undefined) {
|
|
522
|
+
notes.push('wrangler.toml found — YOLO Host reads only top-level `main` + `[assets].directory` from toml; ' +
|
|
523
|
+
'run `yolo deploy init` to adapt it into `.yolo/deploy.json` (the canonical format)');
|
|
524
|
+
}
|
|
525
|
+
const ok = issues.length === 0;
|
|
526
|
+
if (jsonOutput) {
|
|
527
|
+
io.out(`${JSON.stringify({ ok, configMissing, config, shape, issues, warnings, notes })}\n`);
|
|
528
|
+
return ok ? 0 : 1;
|
|
529
|
+
}
|
|
530
|
+
if (!ok) {
|
|
531
|
+
io.err('FAIL: deploy config is not valid\n');
|
|
532
|
+
for (const it of issues)
|
|
533
|
+
io.err(` - ${it.path ? `${it.path}: ` : ''}${it.message}\n`);
|
|
534
|
+
for (const w of warnings)
|
|
535
|
+
io.err(` warn: ${w.path ? `${w.path}: ` : ''}${w.message}\n`);
|
|
536
|
+
if (configMissing)
|
|
537
|
+
io.err(" hint: run 'yolo deploy init' to create .yolo/deploy.json\n");
|
|
538
|
+
return 1;
|
|
539
|
+
}
|
|
540
|
+
for (const w of warnings)
|
|
541
|
+
io.out(`warn: ${w.path ? `${w.path}: ` : ''}${w.message}\n`);
|
|
542
|
+
io.out('OK: deploy config is valid\n');
|
|
543
|
+
if (config) {
|
|
544
|
+
const link = config.projectId
|
|
545
|
+
? `${config.projectId}${config.slug ? ` (${config.slug})` : ''}`
|
|
546
|
+
: '(unlinked — run `yolo deploy link`)';
|
|
547
|
+
io.out(` project: ${link}\n`);
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
io.out(' project: no .yolo/deploy.json — shape auto-detected\n');
|
|
551
|
+
}
|
|
552
|
+
if (shape) {
|
|
553
|
+
if (shape.type === 'static') {
|
|
554
|
+
io.out(` type: static · assets: ${shape.assetsDir}\n`);
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
const bits = [`entry: ${shape.entry}`];
|
|
558
|
+
if (shape.prebuilt)
|
|
559
|
+
bits.push('prebuilt');
|
|
560
|
+
if (shape.assetsDir)
|
|
561
|
+
bits.push(`assets: ${shape.assetsDir}`);
|
|
562
|
+
if (shape.buildCommand)
|
|
563
|
+
bits.push(`build: ${shape.buildCommand}`);
|
|
564
|
+
io.out(` type: worker · ${bits.join(' · ')}\n`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (config?.compatibilityFlags?.length) {
|
|
568
|
+
io.out(` compatibilityFlags: ${config.compatibilityFlags.join(', ')}\n`);
|
|
569
|
+
}
|
|
570
|
+
for (const n of notes)
|
|
571
|
+
io.out(` note: ${n}\n`);
|
|
572
|
+
return 0;
|
|
573
|
+
}
|
|
574
|
+
// ─── status ───────────────────────────────────────────────────────────────
|
|
575
|
+
async function runStatusCmd(args, deps, io) {
|
|
576
|
+
const parsed = parseFlagOnlyArgs(args, 'status');
|
|
577
|
+
if (!parsed.ok) {
|
|
578
|
+
io.err(`yolo deploy status: ${parsed.message}\nUsage: yolo deploy status [--json]\n`);
|
|
579
|
+
return 64;
|
|
580
|
+
}
|
|
581
|
+
const linked = requireLink(deps, io);
|
|
582
|
+
if (!linked.ok)
|
|
583
|
+
return linked.exitCode;
|
|
584
|
+
const auth = resolveAuth(deps);
|
|
585
|
+
if (!auth.ok) {
|
|
586
|
+
io.err(`${formatFail({ kind: 'auth', message: auth.message })}\n`);
|
|
587
|
+
return 78;
|
|
588
|
+
}
|
|
589
|
+
const status = deps.getProjectStatusImpl ?? getProjectStatus;
|
|
590
|
+
const result = await status(auth.context, linked.projectId);
|
|
591
|
+
if (!result.ok) {
|
|
592
|
+
io.err(`${formatFail(result)}\n`);
|
|
593
|
+
return exitCodeForFailure(result.kind);
|
|
594
|
+
}
|
|
595
|
+
io.out(parsed.jsonOutput ? `${JSON.stringify(result.value, null, 2)}\n` : `${formatStatusSummary(result.value, linked.projectId)}\n`);
|
|
596
|
+
return 0;
|
|
597
|
+
}
|
|
598
|
+
export function formatStatusSummary(value, projectId) {
|
|
599
|
+
const raw = (value && typeof value === 'object' ? value : {});
|
|
600
|
+
const project = (raw.project && typeof raw.project === 'object' ? raw.project : raw);
|
|
601
|
+
const lines = [];
|
|
602
|
+
const name = str(project.slug) ?? str(project.name) ?? projectId;
|
|
603
|
+
lines.push(`project ${name} (${str(project.status) ?? 'unknown'})`);
|
|
604
|
+
const hostname = str(project.hostname) ?? str(project.url);
|
|
605
|
+
if (hostname)
|
|
606
|
+
lines.push(`url: ${hostname.startsWith('http') ? hostname : `https://${hostname}`}`);
|
|
607
|
+
const current = (raw.currentRelease && typeof raw.currentRelease === 'object' ? raw.currentRelease : undefined);
|
|
608
|
+
if (current) {
|
|
609
|
+
lines.push(`release ${releaseLabel(current)}: ${str(current.status) ?? 'unknown'} (current)`);
|
|
610
|
+
}
|
|
611
|
+
const recent = Array.isArray(raw.recentReleases) ? raw.recentReleases : Array.isArray(raw.releases) ? raw.releases : [];
|
|
612
|
+
for (const entry of recent) {
|
|
613
|
+
if (!entry || typeof entry !== 'object')
|
|
614
|
+
continue;
|
|
615
|
+
const release = entry;
|
|
616
|
+
if (current && releaseLabel(release) === releaseLabel(current))
|
|
617
|
+
continue;
|
|
618
|
+
lines.push(`release ${releaseLabel(release)}: ${str(release.status) ?? 'unknown'}`);
|
|
619
|
+
}
|
|
620
|
+
return lines.join('\n');
|
|
621
|
+
}
|
|
622
|
+
function releaseLabel(release) {
|
|
623
|
+
return str(release.releaseId) ?? str(release.id) ?? str(release._id) ?? (release.seq !== undefined ? `r${String(release.seq)}` : '(unknown)');
|
|
624
|
+
}
|
|
625
|
+
export function parseLogsArgs(args) {
|
|
626
|
+
let tail = false;
|
|
627
|
+
let sinceMinutes;
|
|
628
|
+
let jsonOutput = false;
|
|
629
|
+
for (let i = 0; i < args.length; i++) {
|
|
630
|
+
const a = args[i];
|
|
631
|
+
if (a === '--tail') {
|
|
632
|
+
tail = true;
|
|
633
|
+
}
|
|
634
|
+
else if (a === '--since') {
|
|
635
|
+
const v = args[++i];
|
|
636
|
+
if (!v || v.startsWith('--'))
|
|
637
|
+
return { ok: false, message: '--since requires a duration value (e.g. 30m, 2h, 1d)' };
|
|
638
|
+
const minutes = parseSinceMinutes(v);
|
|
639
|
+
if (minutes === undefined)
|
|
640
|
+
return { ok: false, message: `invalid --since duration '${v}' (expected e.g. 30m, 2h, 1d)` };
|
|
641
|
+
sinceMinutes = minutes;
|
|
642
|
+
}
|
|
643
|
+
else if (a.startsWith('--since=')) {
|
|
644
|
+
const v = a.slice('--since='.length);
|
|
645
|
+
const minutes = parseSinceMinutes(v);
|
|
646
|
+
if (minutes === undefined)
|
|
647
|
+
return { ok: false, message: `invalid --since duration '${v}' (expected e.g. 30m, 2h, 1d)` };
|
|
648
|
+
sinceMinutes = minutes;
|
|
649
|
+
}
|
|
650
|
+
else if (a === '--json') {
|
|
651
|
+
jsonOutput = true;
|
|
652
|
+
}
|
|
653
|
+
else if (a.startsWith('--')) {
|
|
654
|
+
return { ok: false, message: `unknown option: ${a}` };
|
|
655
|
+
}
|
|
656
|
+
else {
|
|
657
|
+
return { ok: false, message: `unexpected positional argument: ${a}` };
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return { ok: true, tail, sinceMinutes, jsonOutput };
|
|
661
|
+
}
|
|
662
|
+
/** '30m' | '2h' | '1d' | bare minutes → minutes. */
|
|
663
|
+
export function parseSinceMinutes(raw) {
|
|
664
|
+
const match = /^(\d+)\s*(m|min|mins|h|hr|hrs|d)?$/.exec(raw.trim());
|
|
665
|
+
if (!match)
|
|
666
|
+
return undefined;
|
|
667
|
+
const n = Number(match[1]);
|
|
668
|
+
if (!Number.isInteger(n) || n <= 0)
|
|
669
|
+
return undefined;
|
|
670
|
+
const unit = match[2] ?? 'm';
|
|
671
|
+
if (unit.startsWith('h'))
|
|
672
|
+
return n * 60;
|
|
673
|
+
if (unit === 'd')
|
|
674
|
+
return n * 1440;
|
|
675
|
+
return n;
|
|
676
|
+
}
|
|
677
|
+
async function runLogsCmd(args, deps, io) {
|
|
678
|
+
const parsed = parseLogsArgs(args);
|
|
679
|
+
if (!parsed.ok) {
|
|
680
|
+
io.err(`yolo deploy logs: ${parsed.message}\nUsage: yolo deploy logs [--tail] [--since <dur>] [--json]\n`);
|
|
681
|
+
return 64;
|
|
682
|
+
}
|
|
683
|
+
const linked = requireLink(deps, io);
|
|
684
|
+
if (!linked.ok)
|
|
685
|
+
return linked.exitCode;
|
|
686
|
+
const auth = resolveAuth(deps);
|
|
687
|
+
if (!auth.ok) {
|
|
688
|
+
io.err(`${formatFail({ kind: 'auth', message: auth.message })}\n`);
|
|
689
|
+
return 78;
|
|
690
|
+
}
|
|
691
|
+
if (parsed.tail) {
|
|
692
|
+
// Live tail (signal C): long-poll the /tail buffer, threading the cursor so
|
|
693
|
+
// there are no gaps. Loops until killed (Ctrl-C) — or `tailMaxIterations` in
|
|
694
|
+
// tests. Transient failures stop with the mapped exit code.
|
|
695
|
+
const pollImpl = deps.pollTailImpl ?? pollTail;
|
|
696
|
+
const maxIters = deps.tailMaxIterations ?? Infinity;
|
|
697
|
+
io.err('deploy: tailing live logs (Ctrl-C to stop)…\n');
|
|
698
|
+
let cursor;
|
|
699
|
+
let fellBack = false;
|
|
700
|
+
for (let i = 0; i < maxIters; i++) {
|
|
701
|
+
const res = await pollImpl(auth.context, linked.projectId, cursor !== undefined ? { cursor } : {});
|
|
702
|
+
if (!res.ok) {
|
|
703
|
+
io.err(`${formatFail(res)}\n`);
|
|
704
|
+
return exitCodeForFailure(res.kind);
|
|
705
|
+
}
|
|
706
|
+
const { events, cursor: next, note } = res.value;
|
|
707
|
+
// Under --json, stdout stays machine-readable: one JSON object per line
|
|
708
|
+
// (NDJSON — the natural streaming shape). Notices already go to stderr.
|
|
709
|
+
for (const entry of events) {
|
|
710
|
+
io.out(parsed.jsonOutput ? `${JSON.stringify(entry)}\n` : `${formatLogEntry(entry)}\n`);
|
|
711
|
+
}
|
|
712
|
+
if (next !== null && next !== undefined)
|
|
713
|
+
cursor = next;
|
|
714
|
+
if (note === 'tail-disabled') {
|
|
715
|
+
// Live tail isn't enabled on this deployment — DON'T leave the user with
|
|
716
|
+
// nothing; fall back to the buffered fetch below (the pre-5c behavior).
|
|
717
|
+
io.err('deploy: live tail is not enabled on this deployment; showing recent buffered logs instead\n');
|
|
718
|
+
fellBack = true;
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
if (note === 'no-live-release' || note === 'release-not-found') {
|
|
722
|
+
io.err(`deploy: tail stopped — ${note}\n`);
|
|
723
|
+
return 0;
|
|
724
|
+
}
|
|
725
|
+
// 'no-new-events' just means the server's wait elapsed quietly — keep going.
|
|
726
|
+
}
|
|
727
|
+
// Normal tail completion (loop exhausted / killed) returns here; only a
|
|
728
|
+
// `tail-disabled` fall-back continues to the buffered logs path below.
|
|
729
|
+
if (!fellBack)
|
|
730
|
+
return 0;
|
|
731
|
+
}
|
|
732
|
+
const logsImpl = deps.getLogsImpl ?? getLogs;
|
|
733
|
+
const result = await logsImpl(auth.context, linked.projectId, { sinceMinutes: parsed.sinceMinutes });
|
|
734
|
+
if (!result.ok) {
|
|
735
|
+
io.err(`${formatFail(result)}\n`);
|
|
736
|
+
return exitCodeForFailure(result.kind);
|
|
737
|
+
}
|
|
738
|
+
if (parsed.jsonOutput) {
|
|
739
|
+
io.out(`${JSON.stringify(result.value, null, 2)}\n`);
|
|
740
|
+
return 0;
|
|
741
|
+
}
|
|
742
|
+
const raw = (result.value && typeof result.value === 'object' ? result.value : {});
|
|
743
|
+
const entries = Array.isArray(result.value)
|
|
744
|
+
? result.value
|
|
745
|
+
: Array.isArray(raw.logs)
|
|
746
|
+
? raw.logs
|
|
747
|
+
: Array.isArray(raw.entries)
|
|
748
|
+
? raw.entries
|
|
749
|
+
: [];
|
|
750
|
+
if (entries.length === 0) {
|
|
751
|
+
io.out('(no log entries)\n');
|
|
752
|
+
return 0;
|
|
753
|
+
}
|
|
754
|
+
for (const entry of entries)
|
|
755
|
+
io.out(`${formatLogEntry(entry)}\n`);
|
|
756
|
+
return 0;
|
|
757
|
+
}
|
|
758
|
+
function formatLogLine(rawLine) {
|
|
759
|
+
try {
|
|
760
|
+
return formatLogEntry(JSON.parse(rawLine));
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
return rawLine;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
function formatLogEntry(entry) {
|
|
767
|
+
if (typeof entry === 'string')
|
|
768
|
+
return entry;
|
|
769
|
+
if (!entry || typeof entry !== 'object')
|
|
770
|
+
return JSON.stringify(entry);
|
|
771
|
+
const e = entry;
|
|
772
|
+
const ts = str(e.timestamp) ?? str(e.ts) ?? str(e.time);
|
|
773
|
+
const level = str(e.level);
|
|
774
|
+
const message = str(e.message) ?? str(e.msg) ?? JSON.stringify(entry);
|
|
775
|
+
return [ts ? `[${ts}]` : undefined, level, message].filter(Boolean).join(' ');
|
|
776
|
+
}
|
|
777
|
+
export function parseRollbackArgs(args) {
|
|
778
|
+
let releaseId;
|
|
779
|
+
let jsonOutput = false;
|
|
780
|
+
for (let i = 0; i < args.length; i++) {
|
|
781
|
+
const a = args[i];
|
|
782
|
+
if (a === '--json') {
|
|
783
|
+
jsonOutput = true;
|
|
784
|
+
}
|
|
785
|
+
else if (a.startsWith('--')) {
|
|
786
|
+
return { ok: false, message: `unknown option: ${a}` };
|
|
787
|
+
}
|
|
788
|
+
else if (!releaseId) {
|
|
789
|
+
releaseId = a;
|
|
790
|
+
}
|
|
791
|
+
else {
|
|
792
|
+
return { ok: false, message: `unexpected positional argument: ${a}` };
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return { ok: true, releaseId, jsonOutput };
|
|
796
|
+
}
|
|
797
|
+
async function runRollbackCmd(args, deps, io) {
|
|
798
|
+
const parsed = parseRollbackArgs(args);
|
|
799
|
+
if (!parsed.ok) {
|
|
800
|
+
io.err(`yolo deploy rollback: ${parsed.message}\nUsage: yolo deploy rollback [releaseId] [--json]\n`);
|
|
801
|
+
return 64;
|
|
802
|
+
}
|
|
803
|
+
const linked = requireLink(deps, io);
|
|
804
|
+
if (!linked.ok)
|
|
805
|
+
return linked.exitCode;
|
|
806
|
+
const auth = resolveAuth(deps);
|
|
807
|
+
if (!auth.ok) {
|
|
808
|
+
io.err(`${formatFail({ kind: 'auth', message: auth.message })}\n`);
|
|
809
|
+
return 78;
|
|
810
|
+
}
|
|
811
|
+
const rollback = deps.rollbackProjectImpl ?? rollbackProject;
|
|
812
|
+
const result = await rollback(auth.context, linked.projectId, parsed.releaseId ? { releaseId: parsed.releaseId } : {});
|
|
813
|
+
if (!result.ok) {
|
|
814
|
+
io.err(`${formatFail(result)}\n`);
|
|
815
|
+
return exitCodeForFailure(result.kind);
|
|
816
|
+
}
|
|
817
|
+
if (parsed.jsonOutput) {
|
|
818
|
+
io.out(`${JSON.stringify(result.value, null, 2)}\n`);
|
|
819
|
+
return 0;
|
|
820
|
+
}
|
|
821
|
+
const raw = (result.value && typeof result.value === 'object' ? result.value : {});
|
|
822
|
+
const release = (raw.release && typeof raw.release === 'object' ? raw.release : raw);
|
|
823
|
+
const releaseId = str(release.releaseId) ?? str(release.id) ?? parsed.releaseId ?? '(previous live)';
|
|
824
|
+
const url = str(raw.url) ?? str(release.url);
|
|
825
|
+
io.out(`OK: rolled back ${linked.slug ?? linked.projectId} to release ${releaseId}${url ? ` → ${url}` : ''}\n`);
|
|
826
|
+
return 0;
|
|
827
|
+
}
|
|
828
|
+
export function parseRenameArgs(args) {
|
|
829
|
+
let slug;
|
|
830
|
+
let keepOldAsRedirect = true;
|
|
831
|
+
let jsonOutput = false;
|
|
832
|
+
for (let i = 0; i < args.length; i++) {
|
|
833
|
+
const a = args[i];
|
|
834
|
+
if (a === '--json') {
|
|
835
|
+
jsonOutput = true;
|
|
836
|
+
}
|
|
837
|
+
else if (a === '--no-redirect') {
|
|
838
|
+
keepOldAsRedirect = false;
|
|
839
|
+
}
|
|
840
|
+
else if (a.startsWith('--')) {
|
|
841
|
+
return { ok: false, message: `unknown option: ${a}` };
|
|
842
|
+
}
|
|
843
|
+
else if (slug === undefined) {
|
|
844
|
+
slug = a;
|
|
845
|
+
}
|
|
846
|
+
else {
|
|
847
|
+
return { ok: false, message: `unexpected positional argument: ${a}` };
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
if (slug === undefined || slug.trim() === '') {
|
|
851
|
+
return { ok: false, message: 'a new slug is required: yolo deploy rename <newslug>' };
|
|
852
|
+
}
|
|
853
|
+
return { ok: true, slug, keepOldAsRedirect, jsonOutput };
|
|
854
|
+
}
|
|
855
|
+
async function runRenameCmd(args, deps, io) {
|
|
856
|
+
const parsed = parseRenameArgs(args);
|
|
857
|
+
if (!parsed.ok) {
|
|
858
|
+
io.err(`yolo deploy rename: ${parsed.message}\nUsage: yolo deploy rename <newslug> [--no-redirect] [--json]\n`);
|
|
859
|
+
return 64;
|
|
860
|
+
}
|
|
861
|
+
const linked = requireLink(deps, io);
|
|
862
|
+
if (!linked.ok)
|
|
863
|
+
return linked.exitCode;
|
|
864
|
+
const auth = resolveAuth(deps);
|
|
865
|
+
if (!auth.ok) {
|
|
866
|
+
io.err(`${formatFail({ kind: 'auth', message: auth.message })}\n`);
|
|
867
|
+
return 78;
|
|
868
|
+
}
|
|
869
|
+
const rename = deps.renameProjectImpl ?? renameProject;
|
|
870
|
+
const result = await rename(auth.context, linked.projectId, {
|
|
871
|
+
slug: parsed.slug,
|
|
872
|
+
keepOldAsRedirect: parsed.keepOldAsRedirect,
|
|
873
|
+
});
|
|
874
|
+
if (!result.ok) {
|
|
875
|
+
io.err(`${formatFail(result)}\n`);
|
|
876
|
+
return exitCodeForFailure(result.kind);
|
|
877
|
+
}
|
|
878
|
+
if (parsed.jsonOutput) {
|
|
879
|
+
io.out(`${JSON.stringify(result.value, null, 2)}\n`);
|
|
880
|
+
return 0;
|
|
881
|
+
}
|
|
882
|
+
const raw = (result.value && typeof result.value === 'object' ? result.value : {});
|
|
883
|
+
const project = (raw.project && typeof raw.project === 'object' ? raw.project : raw);
|
|
884
|
+
const newSlug = str(project.slug) ?? parsed.slug;
|
|
885
|
+
const url = str(project.hostname) ?? str(project.url) ?? str(raw.url);
|
|
886
|
+
const newUrl = url ? (url.startsWith('http') ? url : `https://${url}`) : `https://${newSlug}`;
|
|
887
|
+
io.out(`OK: renamed to slug ${newSlug} → ${newUrl}\n`);
|
|
888
|
+
if (parsed.keepOldAsRedirect && linked.slug) {
|
|
889
|
+
io.out(` old slug '${linked.slug}' now 308 → ${newUrl}\n`);
|
|
890
|
+
}
|
|
891
|
+
return 0;
|
|
892
|
+
}
|
|
893
|
+
export function parseAliasArgs(args) {
|
|
894
|
+
let remove = false;
|
|
895
|
+
let slug;
|
|
896
|
+
let jsonOutput = false;
|
|
897
|
+
// `alias rm <slug>` — a leading `rm` positional flips to removal.
|
|
898
|
+
let rest = args;
|
|
899
|
+
if (rest[0] === 'rm') {
|
|
900
|
+
remove = true;
|
|
901
|
+
rest = rest.slice(1);
|
|
902
|
+
}
|
|
903
|
+
for (let i = 0; i < rest.length; i++) {
|
|
904
|
+
const a = rest[i];
|
|
905
|
+
if (a === '--json') {
|
|
906
|
+
jsonOutput = true;
|
|
907
|
+
}
|
|
908
|
+
else if (a.startsWith('--')) {
|
|
909
|
+
return { ok: false, message: `unknown option: ${a}` };
|
|
910
|
+
}
|
|
911
|
+
else if (slug === undefined) {
|
|
912
|
+
slug = a;
|
|
913
|
+
}
|
|
914
|
+
else {
|
|
915
|
+
return { ok: false, message: `unexpected positional argument: ${a}` };
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
if (slug === undefined || slug.trim() === '') {
|
|
919
|
+
return {
|
|
920
|
+
ok: false,
|
|
921
|
+
message: remove ? 'a slug is required: yolo deploy alias rm <slug>' : 'a slug is required: yolo deploy alias <slug>',
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
return { ok: true, remove, slug, jsonOutput };
|
|
925
|
+
}
|
|
926
|
+
async function runAliasCmd(args, deps, io) {
|
|
927
|
+
const parsed = parseAliasArgs(args);
|
|
928
|
+
if (!parsed.ok) {
|
|
929
|
+
io.err(`yolo deploy alias: ${parsed.message}\nUsage: yolo deploy alias <slug> [--json]\n yolo deploy alias rm <slug> [--json]\n`);
|
|
930
|
+
return 64;
|
|
931
|
+
}
|
|
932
|
+
const linked = requireLink(deps, io);
|
|
933
|
+
if (!linked.ok)
|
|
934
|
+
return linked.exitCode;
|
|
935
|
+
const auth = resolveAuth(deps);
|
|
936
|
+
if (!auth.ok) {
|
|
937
|
+
io.err(`${formatFail({ kind: 'auth', message: auth.message })}\n`);
|
|
938
|
+
return 78;
|
|
939
|
+
}
|
|
940
|
+
const result = parsed.remove
|
|
941
|
+
? await (deps.removeAliasImpl ?? removeAlias)(auth.context, linked.projectId, parsed.slug)
|
|
942
|
+
: await (deps.addAliasImpl ?? addAlias)(auth.context, linked.projectId, parsed.slug);
|
|
943
|
+
if (!result.ok) {
|
|
944
|
+
io.err(`${formatFail(result)}\n`);
|
|
945
|
+
return exitCodeForFailure(result.kind);
|
|
946
|
+
}
|
|
947
|
+
if (parsed.jsonOutput) {
|
|
948
|
+
io.out(`${JSON.stringify(result.value, null, 2)}\n`);
|
|
949
|
+
return 0;
|
|
950
|
+
}
|
|
951
|
+
if (parsed.remove) {
|
|
952
|
+
io.out(`OK: removed alias '${parsed.slug}' from ${linked.slug ?? linked.projectId}\n`);
|
|
953
|
+
}
|
|
954
|
+
else {
|
|
955
|
+
io.out(`OK: added alias '${parsed.slug}' to ${linked.slug ?? linked.projectId}\n`);
|
|
956
|
+
}
|
|
957
|
+
return 0;
|
|
958
|
+
}
|
|
959
|
+
export function parseRedirectArgs(args) {
|
|
960
|
+
const positionals = [];
|
|
961
|
+
let jsonOutput = false;
|
|
962
|
+
for (let i = 0; i < args.length; i++) {
|
|
963
|
+
const a = args[i];
|
|
964
|
+
if (a === '--json') {
|
|
965
|
+
jsonOutput = true;
|
|
966
|
+
}
|
|
967
|
+
else if (a.startsWith('--')) {
|
|
968
|
+
return { ok: false, message: `unknown option: ${a}` };
|
|
969
|
+
}
|
|
970
|
+
else {
|
|
971
|
+
positionals.push(a);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
if (positionals.length < 2) {
|
|
975
|
+
return { ok: false, message: 'a slug and a target URL are required: yolo deploy redirect <slug> <url>' };
|
|
976
|
+
}
|
|
977
|
+
if (positionals.length > 2) {
|
|
978
|
+
return { ok: false, message: `unexpected positional argument: ${positionals[2]}` };
|
|
979
|
+
}
|
|
980
|
+
return { ok: true, slug: positionals[0], url: positionals[1], jsonOutput };
|
|
981
|
+
}
|
|
982
|
+
async function runRedirectCmd(args, deps, io) {
|
|
983
|
+
const parsed = parseRedirectArgs(args);
|
|
984
|
+
if (!parsed.ok) {
|
|
985
|
+
io.err(`yolo deploy redirect: ${parsed.message}\nUsage: yolo deploy redirect <slug> <url> [--json]\n`);
|
|
986
|
+
return 64;
|
|
987
|
+
}
|
|
988
|
+
const linked = requireLink(deps, io);
|
|
989
|
+
if (!linked.ok)
|
|
990
|
+
return linked.exitCode;
|
|
991
|
+
const auth = resolveAuth(deps);
|
|
992
|
+
if (!auth.ok) {
|
|
993
|
+
io.err(`${formatFail({ kind: 'auth', message: auth.message })}\n`);
|
|
994
|
+
return 78;
|
|
995
|
+
}
|
|
996
|
+
const redirect = deps.setRedirectImpl ?? setRedirect;
|
|
997
|
+
const result = await redirect(auth.context, linked.projectId, parsed.slug, parsed.url);
|
|
998
|
+
if (!result.ok) {
|
|
999
|
+
io.err(`${formatFail(result)}\n`);
|
|
1000
|
+
return exitCodeForFailure(result.kind);
|
|
1001
|
+
}
|
|
1002
|
+
if (parsed.jsonOutput) {
|
|
1003
|
+
io.out(`${JSON.stringify(result.value, null, 2)}\n`);
|
|
1004
|
+
return 0;
|
|
1005
|
+
}
|
|
1006
|
+
io.out(`OK: '${parsed.slug}' now 308 → ${parsed.url}\n`);
|
|
1007
|
+
return 0;
|
|
1008
|
+
}
|
|
1009
|
+
export function parseDeleteArgs(args) {
|
|
1010
|
+
let confirmSlug;
|
|
1011
|
+
let jsonOutput = false;
|
|
1012
|
+
for (let i = 0; i < args.length; i++) {
|
|
1013
|
+
const a = args[i];
|
|
1014
|
+
if (a === '--json') {
|
|
1015
|
+
jsonOutput = true;
|
|
1016
|
+
}
|
|
1017
|
+
else if (a === '--confirm') {
|
|
1018
|
+
const v = args[++i];
|
|
1019
|
+
if (!v || v.startsWith('--'))
|
|
1020
|
+
return { ok: false, message: '--confirm requires the current slug as its value' };
|
|
1021
|
+
confirmSlug = v;
|
|
1022
|
+
}
|
|
1023
|
+
else if (a.startsWith('--confirm=')) {
|
|
1024
|
+
confirmSlug = a.slice('--confirm='.length);
|
|
1025
|
+
}
|
|
1026
|
+
else if (a.startsWith('--')) {
|
|
1027
|
+
return { ok: false, message: `unknown option: ${a}` };
|
|
1028
|
+
}
|
|
1029
|
+
else {
|
|
1030
|
+
return { ok: false, message: `unexpected positional argument: ${a}` };
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
return { ok: true, confirmSlug, jsonOutput };
|
|
1034
|
+
}
|
|
1035
|
+
async function runDeleteCmd(args, deps, io) {
|
|
1036
|
+
const parsed = parseDeleteArgs(args);
|
|
1037
|
+
if (!parsed.ok) {
|
|
1038
|
+
io.err(`yolo deploy delete: ${parsed.message}\nUsage: yolo deploy delete [--confirm <slug>] [--json]\n`);
|
|
1039
|
+
return 64;
|
|
1040
|
+
}
|
|
1041
|
+
const linked = requireLink(deps, io);
|
|
1042
|
+
if (!linked.ok)
|
|
1043
|
+
return linked.exitCode;
|
|
1044
|
+
const auth = resolveAuth(deps);
|
|
1045
|
+
if (!auth.ok) {
|
|
1046
|
+
io.err(`${formatFail({ kind: 'auth', message: auth.message })}\n`);
|
|
1047
|
+
return 78;
|
|
1048
|
+
}
|
|
1049
|
+
const del = deps.deleteProjectImpl ?? deleteProject;
|
|
1050
|
+
const result = await del(auth.context, linked.projectId, parsed.confirmSlug !== undefined ? { confirmSlug: parsed.confirmSlug } : {});
|
|
1051
|
+
if (!result.ok) {
|
|
1052
|
+
// A live site is refused with `not-confirmed` + a hint — surfaced verbatim
|
|
1053
|
+
// by formatFail (the backend's message tells the operator to pass --confirm).
|
|
1054
|
+
io.err(`${formatFail(result)}\n`);
|
|
1055
|
+
return exitCodeForFailure(result.kind);
|
|
1056
|
+
}
|
|
1057
|
+
if (parsed.jsonOutput) {
|
|
1058
|
+
io.out(`${JSON.stringify(result.value, null, 2)}\n`);
|
|
1059
|
+
return 0;
|
|
1060
|
+
}
|
|
1061
|
+
io.out(`OK: deleted project ${linked.slug ?? linked.projectId}\n`);
|
|
1062
|
+
return 0;
|
|
1063
|
+
}
|
|
1064
|
+
export function parseCloneArgs(args) {
|
|
1065
|
+
let name;
|
|
1066
|
+
let slug;
|
|
1067
|
+
let jsonOutput = false;
|
|
1068
|
+
for (let i = 0; i < args.length; i++) {
|
|
1069
|
+
const a = args[i];
|
|
1070
|
+
if (a === '--json') {
|
|
1071
|
+
jsonOutput = true;
|
|
1072
|
+
}
|
|
1073
|
+
else if (a === '--name') {
|
|
1074
|
+
const v = args[++i];
|
|
1075
|
+
if (!v || v.startsWith('--'))
|
|
1076
|
+
return { ok: false, message: '--name requires a value' };
|
|
1077
|
+
name = v;
|
|
1078
|
+
}
|
|
1079
|
+
else if (a.startsWith('--name=')) {
|
|
1080
|
+
name = a.slice('--name='.length);
|
|
1081
|
+
}
|
|
1082
|
+
else if (a === '--slug') {
|
|
1083
|
+
const v = args[++i];
|
|
1084
|
+
if (!v || v.startsWith('--'))
|
|
1085
|
+
return { ok: false, message: '--slug requires a value' };
|
|
1086
|
+
slug = v;
|
|
1087
|
+
}
|
|
1088
|
+
else if (a.startsWith('--slug=')) {
|
|
1089
|
+
slug = a.slice('--slug='.length);
|
|
1090
|
+
}
|
|
1091
|
+
else if (a.startsWith('--')) {
|
|
1092
|
+
return { ok: false, message: `unknown option: ${a}` };
|
|
1093
|
+
}
|
|
1094
|
+
else {
|
|
1095
|
+
return { ok: false, message: `unexpected positional argument: ${a}` };
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
return { ok: true, name, slug, jsonOutput };
|
|
1099
|
+
}
|
|
1100
|
+
async function runCloneCmd(args, deps, io) {
|
|
1101
|
+
const parsed = parseCloneArgs(args);
|
|
1102
|
+
if (!parsed.ok) {
|
|
1103
|
+
io.err(`yolo deploy clone: ${parsed.message}\nUsage: yolo deploy clone [--name <name>] [--slug <slug>] [--json]\n`);
|
|
1104
|
+
return 64;
|
|
1105
|
+
}
|
|
1106
|
+
const linked = requireLink(deps, io);
|
|
1107
|
+
if (!linked.ok)
|
|
1108
|
+
return linked.exitCode;
|
|
1109
|
+
const auth = resolveAuth(deps);
|
|
1110
|
+
if (!auth.ok) {
|
|
1111
|
+
io.err(`${formatFail({ kind: 'auth', message: auth.message })}\n`);
|
|
1112
|
+
return 78;
|
|
1113
|
+
}
|
|
1114
|
+
const clone = deps.cloneProjectImpl ?? cloneProject;
|
|
1115
|
+
const result = await clone(auth.context, linked.projectId, {
|
|
1116
|
+
...(parsed.name !== undefined ? { name: parsed.name } : {}),
|
|
1117
|
+
...(parsed.slug !== undefined ? { slug: parsed.slug } : {}),
|
|
1118
|
+
});
|
|
1119
|
+
if (!result.ok) {
|
|
1120
|
+
io.err(`${formatFail(result)}\n`);
|
|
1121
|
+
return exitCodeForFailure(result.kind);
|
|
1122
|
+
}
|
|
1123
|
+
if (parsed.jsonOutput) {
|
|
1124
|
+
io.out(`${JSON.stringify(result.value, null, 2)}\n`);
|
|
1125
|
+
return 0;
|
|
1126
|
+
}
|
|
1127
|
+
const raw = (result.value && typeof result.value === 'object' ? result.value : {});
|
|
1128
|
+
const project = (raw.project && typeof raw.project === 'object' ? raw.project : raw);
|
|
1129
|
+
const newSlug = str(project.slug) ?? parsed.slug;
|
|
1130
|
+
const newId = str(project.id) ?? str(project.projectId);
|
|
1131
|
+
const url = str(project.hostname) ?? str(project.url);
|
|
1132
|
+
const newUrl = url ? (url.startsWith('http') ? url : `https://${url}`) : newSlug ? `https://${newSlug}` : undefined;
|
|
1133
|
+
io.out(`OK: cloned ${linked.slug ?? linked.projectId} → ${newSlug ?? '(new project)'}${newUrl ? ` (${newUrl})` : ''}\n`);
|
|
1134
|
+
// The clone is a separate, EMPTY project (no release). This directory is still
|
|
1135
|
+
// linked to the SOURCE in .yolo/deploy.json, so a plain `yolo deploy` here would
|
|
1136
|
+
// ship to the source, not the clone — and `yolo deploy link` refuses to repoint
|
|
1137
|
+
// an existing link. So don't say "re-ship"; print the explicit relink step
|
|
1138
|
+
// (codex P2 r5) the user must run to start shipping code into the clone.
|
|
1139
|
+
io.out(`The clone is empty. To ship into it, link a checkout to ${newId ?? 'the new project'}:\n` +
|
|
1140
|
+
` yolo deploy link --project-id ${newId ?? '<clone-id>'}` +
|
|
1141
|
+
` (in a fresh directory, or after removing this directory's .yolo/deploy.json)\n`);
|
|
1142
|
+
return 0;
|
|
1143
|
+
}
|
|
1144
|
+
export function parseDbQueryArgs(args) {
|
|
1145
|
+
let sql;
|
|
1146
|
+
let jsonOutput = false;
|
|
1147
|
+
for (let i = 0; i < args.length; i++) {
|
|
1148
|
+
const a = args[i];
|
|
1149
|
+
if (a === '--json') {
|
|
1150
|
+
jsonOutput = true;
|
|
1151
|
+
}
|
|
1152
|
+
else if (a.startsWith('--')) {
|
|
1153
|
+
return { ok: false, message: `unknown option: ${a}` };
|
|
1154
|
+
}
|
|
1155
|
+
else if (sql === undefined) {
|
|
1156
|
+
sql = a;
|
|
1157
|
+
}
|
|
1158
|
+
else {
|
|
1159
|
+
return { ok: false, message: `unexpected positional argument: ${a} (quote the whole SQL statement as one argument)` };
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
if (sql === undefined || sql.trim() === '') {
|
|
1163
|
+
return { ok: false, message: 'a SQL statement is required: yolo deploy db query "<sql>"' };
|
|
1164
|
+
}
|
|
1165
|
+
return { ok: true, sql, jsonOutput };
|
|
1166
|
+
}
|
|
1167
|
+
async function runDbCmd(args, deps, io) {
|
|
1168
|
+
const verb = args[0];
|
|
1169
|
+
// Only `db query` is a CLI verb. Provisioning/env/secret are MCP-only;
|
|
1170
|
+
// reject other db subcommands with usage + exit 64.
|
|
1171
|
+
if (verb !== 'query') {
|
|
1172
|
+
io.err(`yolo deploy db: unknown subcommand '${verb ?? ''}'\n` +
|
|
1173
|
+
'Usage: yolo deploy db query "<sql>" [--json]\n' +
|
|
1174
|
+
'note: provisioning (db_provision/kv_create/bucket_create), env_set and set_secret are MCP-only (no CLI verb).\n');
|
|
1175
|
+
return 64;
|
|
1176
|
+
}
|
|
1177
|
+
const parsed = parseDbQueryArgs(args.slice(1));
|
|
1178
|
+
if (!parsed.ok) {
|
|
1179
|
+
io.err(`yolo deploy db query: ${parsed.message}\nUsage: yolo deploy db query "<sql>" [--json]\n`);
|
|
1180
|
+
return 64;
|
|
1181
|
+
}
|
|
1182
|
+
const linked = requireLink(deps, io);
|
|
1183
|
+
if (!linked.ok)
|
|
1184
|
+
return linked.exitCode;
|
|
1185
|
+
const auth = resolveAuth(deps);
|
|
1186
|
+
if (!auth.ok) {
|
|
1187
|
+
io.err(`${formatFail({ kind: 'auth', message: auth.message })}\n`);
|
|
1188
|
+
return 78;
|
|
1189
|
+
}
|
|
1190
|
+
const query = deps.queryD1Impl ?? queryD1;
|
|
1191
|
+
const result = await query(auth.context, linked.projectId, parsed.sql);
|
|
1192
|
+
if (!result.ok) {
|
|
1193
|
+
io.err(`${formatFail(result)}\n`);
|
|
1194
|
+
return exitCodeForFailure(result.kind);
|
|
1195
|
+
}
|
|
1196
|
+
if (parsed.jsonOutput) {
|
|
1197
|
+
io.out(`${JSON.stringify(result.value, null, 2)}\n`);
|
|
1198
|
+
return 0;
|
|
1199
|
+
}
|
|
1200
|
+
io.out(`${formatRowsTable(result.value.results)}\n`);
|
|
1201
|
+
return 0;
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Render D1 rows as a simple aligned text table. Columns are the union of
|
|
1205
|
+
* keys across rows (first-seen order). Empty result → a friendly "(0 rows)".
|
|
1206
|
+
*/
|
|
1207
|
+
export function formatRowsTable(rows) {
|
|
1208
|
+
if (!rows || rows.length === 0)
|
|
1209
|
+
return '(0 rows)';
|
|
1210
|
+
const columns = [];
|
|
1211
|
+
for (const row of rows) {
|
|
1212
|
+
for (const key of Object.keys(row)) {
|
|
1213
|
+
if (!columns.includes(key))
|
|
1214
|
+
columns.push(key);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
if (columns.length === 0)
|
|
1218
|
+
return `(${rows.length} row${rows.length === 1 ? '' : 's'}, no columns)`;
|
|
1219
|
+
const cell = (value) => {
|
|
1220
|
+
if (value === null || value === undefined)
|
|
1221
|
+
return 'NULL';
|
|
1222
|
+
if (typeof value === 'object')
|
|
1223
|
+
return JSON.stringify(value);
|
|
1224
|
+
return String(value);
|
|
1225
|
+
};
|
|
1226
|
+
const widths = columns.map((col) => Math.max(col.length, ...rows.map((row) => cell(row[col]).length)));
|
|
1227
|
+
const pad = (text, width) => text + ' '.repeat(Math.max(0, width - text.length));
|
|
1228
|
+
const header = columns.map((col, i) => pad(col, widths[i])).join(' | ');
|
|
1229
|
+
const separator = widths.map((w) => '-'.repeat(w)).join('-+-');
|
|
1230
|
+
const body = rows.map((row) => columns.map((col, i) => pad(cell(row[col]), widths[i])).join(' | '));
|
|
1231
|
+
const footer = `(${rows.length} row${rows.length === 1 ? '' : 's'})`;
|
|
1232
|
+
return [header, separator, ...body, footer].join('\n');
|
|
1233
|
+
}
|
|
1234
|
+
// ─── Shared helpers ───────────────────────────────────────────────────────
|
|
1235
|
+
function parseFlagOnlyArgs(args, _name) {
|
|
1236
|
+
let jsonOutput = false;
|
|
1237
|
+
for (const a of args) {
|
|
1238
|
+
if (a === '--json')
|
|
1239
|
+
jsonOutput = true;
|
|
1240
|
+
else if (a.startsWith('--'))
|
|
1241
|
+
return { ok: false, message: `unknown option: ${a}` };
|
|
1242
|
+
else
|
|
1243
|
+
return { ok: false, message: `unexpected positional argument: ${a}` };
|
|
1244
|
+
}
|
|
1245
|
+
return { ok: true, jsonOutput };
|
|
1246
|
+
}
|
|
1247
|
+
function requireLink(deps, io) {
|
|
1248
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
1249
|
+
const readConfig = deps.readDeployConfigImpl ?? readDeployConfig;
|
|
1250
|
+
const readResult = readConfig(cwd);
|
|
1251
|
+
if (!readResult.ok) {
|
|
1252
|
+
io.err(`${formatFail({ kind: readResult.kind, message: readResult.message })}\n`);
|
|
1253
|
+
return { ok: false, exitCode: exitCodeForFailure(readResult.kind) };
|
|
1254
|
+
}
|
|
1255
|
+
const config = readResult.config;
|
|
1256
|
+
if (!config?.projectId) {
|
|
1257
|
+
io.err(`${formatFail({
|
|
1258
|
+
kind: 'not-linked',
|
|
1259
|
+
message: 'this directory is not linked to a hosting project (.yolo/deploy.json missing or has no projectId)',
|
|
1260
|
+
hint: "run 'yolo deploy init' to create or link a hosting project",
|
|
1261
|
+
})}\n`);
|
|
1262
|
+
return { ok: false, exitCode: exitCodeForFailure('not-linked') };
|
|
1263
|
+
}
|
|
1264
|
+
return { ok: true, projectId: config.projectId, slug: config.slug };
|
|
1265
|
+
}
|
|
1266
|
+
/** projectId from a serialized project (server emits `id`; tolerate variants). */
|
|
1267
|
+
function resolveProjectId(project) {
|
|
1268
|
+
return str(project.projectId) ?? str(project.id) ?? str(project._id);
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1271
|
+
* Find one of the caller's OWN projects by slug (for init/link reconcile).
|
|
1272
|
+
* Distinguishes a list FAILURE (auth/network — propagated so the caller can
|
|
1273
|
+
* surface the real, possibly-retryable error) from a clean not-found
|
|
1274
|
+
* (`{ ok:true, project: undefined }`).
|
|
1275
|
+
*/
|
|
1276
|
+
async function findOwnedProjectBySlug(deps, ctx, slug) {
|
|
1277
|
+
const list = deps.listProjectsImpl ?? listProjects;
|
|
1278
|
+
const result = await list(ctx);
|
|
1279
|
+
if (!result.ok)
|
|
1280
|
+
return result;
|
|
1281
|
+
return { ok: true, project: result.value.find((p) => str(p.slug) === slug) };
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Write `.yolo/deploy.json` in canonical form, preserving any existing fields,
|
|
1285
|
+
* and print the success message + the commit note. Shared by init + link.
|
|
1286
|
+
*/
|
|
1287
|
+
function writeLinkFile(cwd, writeConfig, io, params, message) {
|
|
1288
|
+
const config = {
|
|
1289
|
+
...(params.existing ?? {}),
|
|
1290
|
+
// Wrangler-adapted shape (worker/build/type/compatibilityFlags) goes BELOW
|
|
1291
|
+
// existing — there is no existing on a fresh init — and ABOVE the explicit
|
|
1292
|
+
// --slug/--type so an operator's explicit `--type` still wins.
|
|
1293
|
+
...(params.adapted?.config ?? {}),
|
|
1294
|
+
$version: 1,
|
|
1295
|
+
projectId: params.projectId,
|
|
1296
|
+
...(params.slug ? { slug: params.slug } : {}),
|
|
1297
|
+
...(params.type ? { type: params.type } : {}),
|
|
1298
|
+
};
|
|
1299
|
+
try {
|
|
1300
|
+
writeConfig(cwd, config);
|
|
1301
|
+
}
|
|
1302
|
+
catch (err) {
|
|
1303
|
+
io.err(`${formatFail({ kind: 'config-write-failed', message: `failed to write .yolo/deploy.json: ${describeError(err)}` })}\n`);
|
|
1304
|
+
return 1;
|
|
1305
|
+
}
|
|
1306
|
+
io.out(`${message}\n`);
|
|
1307
|
+
if (params.adapted) {
|
|
1308
|
+
io.out(`note: adapted ${params.adapted.sourceFile} → .yolo/deploy.json (${params.adapted.migrated.join(', ')})\n`);
|
|
1309
|
+
for (const w of params.adapted.warnings)
|
|
1310
|
+
io.out(`warn: ${w}\n`);
|
|
1311
|
+
}
|
|
1312
|
+
io.out('note: .yolo/deploy.json is committed by design; it contains no secrets — commit it so future sessions, teammates, and CI ship to the same project.\n');
|
|
1313
|
+
return 0;
|
|
1314
|
+
}
|
|
1315
|
+
function resolveAuth(deps) {
|
|
1316
|
+
const resolved = resolveDeployContext(deps.env ?? process.env, deps.readFileImpl, deps.fetchImpl);
|
|
1317
|
+
if (!resolved.ok)
|
|
1318
|
+
return { ok: false, message: resolved.message };
|
|
1319
|
+
return { ok: true, context: resolved.context };
|
|
1320
|
+
}
|
|
1321
|
+
/**
|
|
1322
|
+
* Serialize a result for --json output: strip the internal `ok` discriminant
|
|
1323
|
+
* (the exit code conveys success/failure; `kind` is enough for branching) —
|
|
1324
|
+
* the cli.ts `formatJsonResult` convention.
|
|
1325
|
+
*/
|
|
1326
|
+
function formatJsonResult(result) {
|
|
1327
|
+
const { ok: _ok, ...payload } = result;
|
|
1328
|
+
return JSON.stringify(payload, null, 2);
|
|
1329
|
+
}
|
|
1330
|
+
function defaultIo() {
|
|
1331
|
+
return {
|
|
1332
|
+
out: (text) => process.stdout.write(text),
|
|
1333
|
+
err: (text) => process.stderr.write(text),
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
function str(value) {
|
|
1337
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
1338
|
+
}
|
|
1339
|
+
function describeError(err) {
|
|
1340
|
+
return err instanceof Error ? err.message : String(err);
|
|
1341
|
+
}
|