@lizard-build/cli 0.3.73 → 0.3.75
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/dist/commands/domain.js +19 -0
- package/dist/commands/domain.js.map +1 -1
- package/dist/commands/sandbox.d.ts +2 -0
- package/dist/commands/sandbox.js +404 -0
- package/dist/commands/sandbox.js.map +1 -0
- package/dist/commands/volume.d.ts +2 -0
- package/dist/commands/volume.js +103 -0
- package/dist/commands/volume.js.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/api.d.ts +4 -1
- package/dist/lib/api.js +25 -1
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/skills-data.generated.js +1 -1
- package/dist/lib/skills-data.generated.js.map +1 -1
- package/dist/lib/updater.d.ts +1 -1
- package/dist/lib/updater.js +1 -1
- package/package.json +1 -1
- package/skill-data/core/SKILL.md +2 -1
- package/src/commands/domain.ts +23 -0
- package/src/commands/sandbox.ts +456 -0
- package/src/commands/volume.ts +143 -0
- package/src/index.ts +26 -0
- package/src/lib/api.ts +25 -1
- package/src/lib/updater.ts +1 -1
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import * as https from "node:https";
|
|
5
|
+
import * as http from "node:http";
|
|
6
|
+
import * as p from "@clack/prompts";
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import { api, getBaseURL, getRawText, streamSSE, withQuery, withScope, type ResourceScope } from "../lib/api.js";
|
|
9
|
+
import { getToken } from "../lib/auth.js";
|
|
10
|
+
import { resolveProjectScope } from "../lib/resolve.js";
|
|
11
|
+
import { resolveWorkspace } from "../lib/picker.js";
|
|
12
|
+
import { success, info, error, isJSONMode, printJSON, table, statusColor, timeAgo, isTTY } from "../lib/format.js";
|
|
13
|
+
|
|
14
|
+
const VALID_TEMPLATES = ["base", "code-interpreter-v1"] as const;
|
|
15
|
+
|
|
16
|
+
interface SandboxRecord {
|
|
17
|
+
sandboxId: string;
|
|
18
|
+
id: string;
|
|
19
|
+
template: string;
|
|
20
|
+
status: string;
|
|
21
|
+
region: string;
|
|
22
|
+
cpus: number;
|
|
23
|
+
memoryMb: number;
|
|
24
|
+
guestIp?: string;
|
|
25
|
+
startedAt: number | string;
|
|
26
|
+
endAt?: number | string;
|
|
27
|
+
expiresAt?: number | string;
|
|
28
|
+
projectId?: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface VolumeRecord {
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseIntOption(v: string): number {
|
|
37
|
+
const n = parseInt(v, 10);
|
|
38
|
+
if (Number.isNaN(n)) throw new Error(`Invalid number: ${v}`);
|
|
39
|
+
return n;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Resolve a volume by name or ID. Requires a project — volumes are project-scoped. */
|
|
43
|
+
async function resolveVolumeId(
|
|
44
|
+
projectId: string,
|
|
45
|
+
scope: ResourceScope,
|
|
46
|
+
nameOrId: string,
|
|
47
|
+
): Promise<string> {
|
|
48
|
+
const volumes = await api.get<VolumeRecord[]>(
|
|
49
|
+
withScope(`/api/projects/${projectId}/volumes`, scope),
|
|
50
|
+
);
|
|
51
|
+
const lower = nameOrId.toLowerCase();
|
|
52
|
+
const match = volumes.find(
|
|
53
|
+
(v) => v.id.toLowerCase() === lower || v.name.toLowerCase() === lower,
|
|
54
|
+
);
|
|
55
|
+
if (!match) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Volume "${nameOrId}" not found. Available: ${volumes.map((v) => v.name).join(", ") || "(none)"}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return match.id;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function printSandboxList(sandboxes: SandboxRecord[]) {
|
|
64
|
+
if (isJSONMode()) {
|
|
65
|
+
printJSON(sandboxes);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (sandboxes.length === 0) {
|
|
69
|
+
console.log("No sandboxes. Use `lizard sandbox create`.");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
table(
|
|
73
|
+
["ID", "Template", "Status", "Region", "CPU/Mem", "Created"],
|
|
74
|
+
sandboxes.map((s) => [
|
|
75
|
+
s.id,
|
|
76
|
+
s.template,
|
|
77
|
+
statusColor(s.status),
|
|
78
|
+
s.region,
|
|
79
|
+
`${s.cpus} vCPU / ${s.memoryMb} MB`,
|
|
80
|
+
timeAgo(s.startedAt as any),
|
|
81
|
+
]),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function registerSandbox(program: Command) {
|
|
86
|
+
const sb = program
|
|
87
|
+
.command("sandbox")
|
|
88
|
+
.alias("sb")
|
|
89
|
+
.description("Manage ephemeral compute sandboxes");
|
|
90
|
+
|
|
91
|
+
sb.command("create")
|
|
92
|
+
.description("Create a sandbox")
|
|
93
|
+
.option("-t, --template <name>", `Template (${VALID_TEMPLATES.join(", ")})`, "base")
|
|
94
|
+
.option("--cpus <n>", "vCPUs (1-8, default 1)", parseIntOption)
|
|
95
|
+
.option("--memory <mb>", "Memory in MB (128-8192, default 2048)", parseIntOption)
|
|
96
|
+
.option("--timeout <ms>", "Idle timeout in ms before auto-stop (default 300000)", parseIntOption)
|
|
97
|
+
.option("--region <code>", "Region to create the sandbox in")
|
|
98
|
+
.option("--volume <name-or-id>", "Attach a persistent volume (requires --project or a linked project)")
|
|
99
|
+
.option("-p, --project <id>", "Associate with a project (name, slug, or ID)")
|
|
100
|
+
.option("-w, --workspace <ws>", "Workspace to create the sandbox in")
|
|
101
|
+
.action(async (opts) => {
|
|
102
|
+
if (opts.template && !(VALID_TEMPLATES as readonly string[]).includes(opts.template)) {
|
|
103
|
+
throw new Error(`Unknown template "${opts.template}". Available: ${VALID_TEMPLATES.join(", ")}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let projectId: string | undefined;
|
|
107
|
+
let workspaceId: string | undefined;
|
|
108
|
+
let scope: ResourceScope | undefined;
|
|
109
|
+
|
|
110
|
+
if (opts.project || opts.volume) {
|
|
111
|
+
const resolved = await resolveProjectScope(opts.project);
|
|
112
|
+
projectId = resolved.projectId;
|
|
113
|
+
scope = resolved.scope;
|
|
114
|
+
workspaceId = resolved.scope.workspaceId ?? undefined;
|
|
115
|
+
} else if (opts.workspace) {
|
|
116
|
+
workspaceId = (await resolveWorkspace(opts.workspace)).id;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let volumeId: string | undefined;
|
|
120
|
+
if (opts.volume) {
|
|
121
|
+
volumeId = await resolveVolumeId(projectId!, scope!, opts.volume);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const spinner = isJSONMode() ? null : ora("Creating sandbox...").start();
|
|
125
|
+
let sandbox: SandboxRecord;
|
|
126
|
+
try {
|
|
127
|
+
sandbox = await api.post<SandboxRecord>("/api/sandboxes", {
|
|
128
|
+
template: opts.template,
|
|
129
|
+
cpus: opts.cpus,
|
|
130
|
+
memoryMb: opts.memory,
|
|
131
|
+
timeoutMs: opts.timeout,
|
|
132
|
+
region: opts.region,
|
|
133
|
+
volumeId,
|
|
134
|
+
workspaceId,
|
|
135
|
+
projectId,
|
|
136
|
+
});
|
|
137
|
+
} catch (e) {
|
|
138
|
+
spinner?.stop();
|
|
139
|
+
throw e;
|
|
140
|
+
}
|
|
141
|
+
spinner?.stop();
|
|
142
|
+
|
|
143
|
+
if (isJSONMode()) {
|
|
144
|
+
printJSON(sandbox);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
success(`Sandbox ${chalk.bold(sandbox.id)} created`);
|
|
148
|
+
info(chalk.dim(` Template: ${sandbox.template} Region: ${sandbox.region}`));
|
|
149
|
+
info(chalk.dim(` Exec: lizard sandbox exec ${sandbox.id} -- <cmd>`));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
sb.command("list")
|
|
153
|
+
.alias("ls")
|
|
154
|
+
.description("List sandboxes")
|
|
155
|
+
.option("-p, --project <id>", "Only list sandboxes for this project")
|
|
156
|
+
.action(async (opts) => {
|
|
157
|
+
if (opts.project) {
|
|
158
|
+
const { projectId, scope } = await resolveProjectScope(opts.project);
|
|
159
|
+
const sandboxes = await api.get<SandboxRecord[]>(
|
|
160
|
+
withScope(`/api/projects/${projectId}/sandboxes`, scope),
|
|
161
|
+
);
|
|
162
|
+
printSandboxList(sandboxes);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const sandboxes = await api.get<SandboxRecord[]>("/api/sandboxes");
|
|
166
|
+
printSandboxList(sandboxes);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
sb.command("rm")
|
|
170
|
+
.alias("delete")
|
|
171
|
+
.argument("<id>", "Sandbox ID")
|
|
172
|
+
.description("Delete a sandbox")
|
|
173
|
+
.option("-y, --yes", "Skip confirmation")
|
|
174
|
+
.action(async (id: string, opts) => {
|
|
175
|
+
if (!opts.yes && isTTY() && !isJSONMode()) {
|
|
176
|
+
const ok = await p.confirm({ message: `Delete sandbox ${chalk.bold(id)}?` });
|
|
177
|
+
if (p.isCancel(ok) || !ok) process.exit(5);
|
|
178
|
+
}
|
|
179
|
+
await api.delete(`/api/sandboxes/${id}`);
|
|
180
|
+
if (isJSONMode()) printJSON({ id, status: "deleted" });
|
|
181
|
+
else success(`Sandbox ${chalk.bold(id)} deleted`);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
sb.command("pause")
|
|
185
|
+
.argument("<id>", "Sandbox ID")
|
|
186
|
+
.description("Pause a sandbox")
|
|
187
|
+
.action(async (id: string) => {
|
|
188
|
+
const updated = await api.post<SandboxRecord>(`/api/sandboxes/${id}/pause`);
|
|
189
|
+
if (isJSONMode()) printJSON(updated);
|
|
190
|
+
else success(`Sandbox ${chalk.bold(id)} paused`);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
sb.command("resume")
|
|
194
|
+
.argument("<id>", "Sandbox ID")
|
|
195
|
+
.description("Resume a paused sandbox")
|
|
196
|
+
.action(async (id: string) => {
|
|
197
|
+
const updated = await api.post<SandboxRecord>(`/api/sandboxes/${id}/resume`);
|
|
198
|
+
if (isJSONMode()) printJSON(updated);
|
|
199
|
+
else success(`Sandbox ${chalk.bold(id)} resumed`);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
sb.command("timeout")
|
|
203
|
+
.argument("<id>", "Sandbox ID")
|
|
204
|
+
.argument("<ms>", "New idle timeout in milliseconds", parseIntOption)
|
|
205
|
+
.description("Update a sandbox's idle timeout")
|
|
206
|
+
.action(async (id: string, ms: number) => {
|
|
207
|
+
const updated = await api.post<SandboxRecord>(`/api/sandboxes/${id}/timeout`, { timeoutMs: ms });
|
|
208
|
+
if (isJSONMode()) printJSON(updated);
|
|
209
|
+
else success(`Sandbox ${chalk.bold(id)} timeout set to ${ms}ms`);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
sb.command("exec")
|
|
213
|
+
.argument("<id>", "Sandbox ID")
|
|
214
|
+
.argument("[cmd...]", "Command and args to run (pass after `--`, e.g. `-- ls -la /tmp`)")
|
|
215
|
+
.description("Execute a command inside a sandbox, streaming output")
|
|
216
|
+
.addHelpText(
|
|
217
|
+
"after",
|
|
218
|
+
`
|
|
219
|
+
Examples:
|
|
220
|
+
lizard sandbox exec sb_abc123 -- ls -la /tmp
|
|
221
|
+
lizard sandbox exec sb_abc123 -- python3 script.py`,
|
|
222
|
+
)
|
|
223
|
+
.action(async (id: string, cmdArgs: string[]) => {
|
|
224
|
+
if (cmdArgs.length === 0) {
|
|
225
|
+
throw new Error("No command given. Usage: lizard sandbox exec <id> -- <cmd> [args...]");
|
|
226
|
+
}
|
|
227
|
+
// The server runs `cmd` via `/bin/sh -c` only when it's a string — an
|
|
228
|
+
// array execs the binary directly with no PATH lookup or shell
|
|
229
|
+
// features (pipes, globs). Shell-quote and join so `sh -c` sees it.
|
|
230
|
+
const cmd = cmdArgs.map(shellQuote).join(" ");
|
|
231
|
+
if (!isJSONMode()) {
|
|
232
|
+
process.stdout.write(chalk.dim(`$ ${cmd}\n`));
|
|
233
|
+
}
|
|
234
|
+
const exitCode = await execStream(id, cmd, (stream, line) => {
|
|
235
|
+
if (stream === "stderr") process.stderr.write(line + "\n");
|
|
236
|
+
else process.stdout.write(line + "\n");
|
|
237
|
+
});
|
|
238
|
+
process.exit(exitCode);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
sb.command("logs")
|
|
242
|
+
.argument("<id>", "Sandbox ID")
|
|
243
|
+
.description("Stream sandbox vm-agent logs")
|
|
244
|
+
.option("--tail <n>", "Number of historical lines to include before following", "200")
|
|
245
|
+
.action(async (id: string, opts) => {
|
|
246
|
+
info(chalk.dim("Streaming logs... (Ctrl+C to stop)\n"));
|
|
247
|
+
await streamSSE(withQuery(`/api/sandboxes/${id}/logs`, { tail: opts.tail }), (_event, data) => {
|
|
248
|
+
let parsed: any;
|
|
249
|
+
try {
|
|
250
|
+
parsed = JSON.parse(data);
|
|
251
|
+
} catch {
|
|
252
|
+
parsed = { message: data };
|
|
253
|
+
}
|
|
254
|
+
if (isJSONMode()) {
|
|
255
|
+
process.stdout.write(JSON.stringify(parsed) + "\n");
|
|
256
|
+
} else {
|
|
257
|
+
process.stdout.write((parsed.message ?? data) + "\n");
|
|
258
|
+
}
|
|
259
|
+
return true;
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
sb.command("expose")
|
|
264
|
+
.argument("<id>", "Sandbox ID")
|
|
265
|
+
.argument("<port>", "Port to expose", parseIntOption)
|
|
266
|
+
.description("Expose a sandbox port over HTTPS")
|
|
267
|
+
.action(async (id: string, port: number) => {
|
|
268
|
+
const result = await api.post<{ hostname: string; url: string; port: number }>(
|
|
269
|
+
`/api/sandboxes/${id}/expose/${port}`,
|
|
270
|
+
);
|
|
271
|
+
if (isJSONMode()) {
|
|
272
|
+
printJSON(result);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
success(`Port ${port} exposed`);
|
|
276
|
+
info(` ${chalk.cyan(result.url)}`);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
sb.command("unexpose")
|
|
280
|
+
.argument("<id>", "Sandbox ID")
|
|
281
|
+
.argument("<port>", "Port to unexpose", parseIntOption)
|
|
282
|
+
.description("Remove an exposed sandbox port")
|
|
283
|
+
.action(async (id: string, port: number) => {
|
|
284
|
+
await api.delete(`/api/sandboxes/${id}/expose/${port}`);
|
|
285
|
+
if (isJSONMode()) printJSON({ id, port, status: "unexposed" });
|
|
286
|
+
else success(`Port ${port} unexposed`);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
registerSandboxFiles(sb);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function registerSandboxFiles(sb: Command) {
|
|
293
|
+
const files = sb.command("files").description("Manage files inside a sandbox");
|
|
294
|
+
|
|
295
|
+
files
|
|
296
|
+
.command("ls")
|
|
297
|
+
.argument("<id>", "Sandbox ID")
|
|
298
|
+
.argument("[path]", "Directory to list", "/")
|
|
299
|
+
.description("List a directory inside a sandbox")
|
|
300
|
+
.action(async (id: string, path: string) => {
|
|
301
|
+
const entries = await api.get<Array<{ type: string; name: string; path: string; size: number }>>(
|
|
302
|
+
withQuery(`/api/sandboxes/${id}/files/list`, { path }),
|
|
303
|
+
);
|
|
304
|
+
if (isJSONMode()) {
|
|
305
|
+
printJSON(entries);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (entries.length === 0) {
|
|
309
|
+
console.log("(empty)");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
table(
|
|
313
|
+
["Type", "Name", "Size"],
|
|
314
|
+
entries.map((e) => [e.type, e.name, e.type === "dir" ? "" : `${e.size}B`]),
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
files
|
|
319
|
+
.command("cat")
|
|
320
|
+
.argument("<id>", "Sandbox ID")
|
|
321
|
+
.argument("<path>", "File path inside the sandbox")
|
|
322
|
+
.description("Print a file from inside a sandbox")
|
|
323
|
+
.action(async (id: string, path: string) => {
|
|
324
|
+
const content = await getRawText(withQuery(`/api/sandboxes/${id}/files`, { path }));
|
|
325
|
+
process.stdout.write(content);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
files
|
|
329
|
+
.command("put")
|
|
330
|
+
.argument("<id>", "Sandbox ID")
|
|
331
|
+
.argument("<local>", "Local file path")
|
|
332
|
+
.argument("<remote>", "Destination path inside the sandbox")
|
|
333
|
+
.description("Upload a local file into a sandbox")
|
|
334
|
+
.action(async (id: string, local: string, remote: string) => {
|
|
335
|
+
const content = fs.readFileSync(local, "utf-8");
|
|
336
|
+
await api.post(`/api/sandboxes/${id}/files`, { path: remote, content });
|
|
337
|
+
if (isJSONMode()) printJSON({ id, path: remote, status: "written" });
|
|
338
|
+
else success(`Wrote ${chalk.bold(remote)} in sandbox ${id}`);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
files
|
|
342
|
+
.command("get")
|
|
343
|
+
.argument("<id>", "Sandbox ID")
|
|
344
|
+
.argument("<remote>", "File path inside the sandbox")
|
|
345
|
+
.argument("<local>", "Local destination path")
|
|
346
|
+
.description("Download a file from a sandbox")
|
|
347
|
+
.action(async (id: string, remote: string, local: string) => {
|
|
348
|
+
const content = await getRawText(withQuery(`/api/sandboxes/${id}/files`, { path: remote }));
|
|
349
|
+
fs.writeFileSync(local, content);
|
|
350
|
+
if (isJSONMode()) printJSON({ id, path: remote, local, status: "downloaded" });
|
|
351
|
+
else success(`Downloaded ${chalk.bold(remote)} to ${local}`);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
files
|
|
355
|
+
.command("rm")
|
|
356
|
+
.argument("<id>", "Sandbox ID")
|
|
357
|
+
.argument("<path>", "Path inside the sandbox")
|
|
358
|
+
.description("Delete a file or directory inside a sandbox")
|
|
359
|
+
.action(async (id: string, path: string) => {
|
|
360
|
+
await api.delete(`/api/sandboxes/${id}/files`, { path });
|
|
361
|
+
if (isJSONMode()) printJSON({ id, path, status: "deleted" });
|
|
362
|
+
else success(`Deleted ${chalk.bold(path)} in sandbox ${id}`);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Run a command inside a sandbox, streaming output. Resolves with the exit
|
|
367
|
+
* code: the remote command's code from the `exit` event, or 1 when the
|
|
368
|
+
* server reported an `error` event without one. Mirrors ssh.ts's parser. */
|
|
369
|
+
function execStream(
|
|
370
|
+
sandboxId: string,
|
|
371
|
+
cmd: string,
|
|
372
|
+
onLine: (stream: string, line: string) => void,
|
|
373
|
+
): Promise<number> {
|
|
374
|
+
return new Promise((resolve, reject) => {
|
|
375
|
+
let exitCode: number | null = null;
|
|
376
|
+
let sawError = false;
|
|
377
|
+
const baseURL = getBaseURL();
|
|
378
|
+
const url = new URL(`${baseURL}/api/sandboxes/${sandboxId}/exec`);
|
|
379
|
+
const token = getToken();
|
|
380
|
+
const body = JSON.stringify({ cmd });
|
|
381
|
+
|
|
382
|
+
const reqHeaders: Record<string, string> = {
|
|
383
|
+
"Content-Type": "application/json",
|
|
384
|
+
"Content-Length": String(Buffer.byteLength(body)),
|
|
385
|
+
Accept: "text/event-stream",
|
|
386
|
+
};
|
|
387
|
+
if (token) reqHeaders["Authorization"] = `Bearer ${token}`;
|
|
388
|
+
|
|
389
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
390
|
+
const req = transport.request(
|
|
391
|
+
{
|
|
392
|
+
hostname: url.hostname,
|
|
393
|
+
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
394
|
+
path: url.pathname,
|
|
395
|
+
method: "POST",
|
|
396
|
+
headers: reqHeaders,
|
|
397
|
+
},
|
|
398
|
+
(res) => {
|
|
399
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
400
|
+
let errBody = "";
|
|
401
|
+
res.on("data", (c: Buffer) => (errBody += c.toString()));
|
|
402
|
+
res.on("end", () => reject(new Error(`exec failed ${res.statusCode}: ${errBody}`)));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
let buf = "";
|
|
407
|
+
let currentEvent = "";
|
|
408
|
+
res.setEncoding("utf8");
|
|
409
|
+
res.on("data", (chunk: string) => {
|
|
410
|
+
buf += chunk;
|
|
411
|
+
const lines = buf.split("\n");
|
|
412
|
+
buf = lines.pop() ?? "";
|
|
413
|
+
|
|
414
|
+
for (const line of lines) {
|
|
415
|
+
const trimmed = line.replace(/\r$/, "");
|
|
416
|
+
if (trimmed === "") {
|
|
417
|
+
currentEvent = "";
|
|
418
|
+
} else if (trimmed.startsWith("event:")) {
|
|
419
|
+
currentEvent = trimmed.slice(6).trim();
|
|
420
|
+
} else if (trimmed.startsWith("data:")) {
|
|
421
|
+
const data = trimmed.slice(5).trimStart();
|
|
422
|
+
if (currentEvent === "exit") {
|
|
423
|
+
try { exitCode = JSON.parse(data).exitCode ?? 0; } catch {}
|
|
424
|
+
} else if (currentEvent === "error") {
|
|
425
|
+
sawError = true;
|
|
426
|
+
error(data);
|
|
427
|
+
} else {
|
|
428
|
+
try {
|
|
429
|
+
const parsed = JSON.parse(data);
|
|
430
|
+
onLine(parsed.stream ?? "stdout", parsed.line ?? data);
|
|
431
|
+
} catch {
|
|
432
|
+
onLine("stdout", data);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
res.on("end", () => resolve(exitCode ?? (sawError ? 1 : 0)));
|
|
440
|
+
res.on("error", reject);
|
|
441
|
+
},
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
req.on("error", reject);
|
|
445
|
+
req.write(body);
|
|
446
|
+
req.end();
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** POSIX single-quote escaping. Safe-token chars pass through verbatim;
|
|
451
|
+
* anything else gets wrapped in '…' with embedded `'` rewritten as `'\''`. */
|
|
452
|
+
function shellQuote(arg: string): string {
|
|
453
|
+
if (arg === "") return "''";
|
|
454
|
+
if (/^[A-Za-z0-9_./:=@%+,-]+$/.test(arg)) return arg;
|
|
455
|
+
return "'" + arg.replace(/'/g, "'\\''") + "'";
|
|
456
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import * as p from "@clack/prompts";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { api, withScope, type ResourceScope } from "../lib/api.js";
|
|
5
|
+
import { resolveProjectScope } from "../lib/resolve.js";
|
|
6
|
+
import { success, info, isJSONMode, printJSON, table, isTTY } from "../lib/format.js";
|
|
7
|
+
|
|
8
|
+
interface VolumeRecord {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
sizeGb: number;
|
|
12
|
+
status: string;
|
|
13
|
+
attachedTo?: string | null;
|
|
14
|
+
createdAt?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Resolve a volume by name or ID within a project. Mirrors resolveService. */
|
|
18
|
+
async function resolveVolume(
|
|
19
|
+
projectId: string,
|
|
20
|
+
scope: ResourceScope,
|
|
21
|
+
nameOrId: string,
|
|
22
|
+
): Promise<VolumeRecord> {
|
|
23
|
+
const volumes = await api.get<VolumeRecord[]>(
|
|
24
|
+
withScope(`/api/projects/${projectId}/volumes`, scope),
|
|
25
|
+
);
|
|
26
|
+
const lower = nameOrId.toLowerCase();
|
|
27
|
+
const match = volumes.find(
|
|
28
|
+
(v) => v.id.toLowerCase() === lower || v.name.toLowerCase() === lower,
|
|
29
|
+
);
|
|
30
|
+
if (!match) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Volume "${nameOrId}" not found. Available: ${volumes.map((v) => v.name).join(", ") || "(none)"}`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return match;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseIntOption(v: string): number {
|
|
39
|
+
const n = parseInt(v, 10);
|
|
40
|
+
if (Number.isNaN(n)) throw new Error(`Invalid number: ${v}`);
|
|
41
|
+
return n;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function registerVolume(program: Command) {
|
|
45
|
+
const vol = program
|
|
46
|
+
.command("volume")
|
|
47
|
+
.alias("vol")
|
|
48
|
+
.description("Manage persistent volumes for sandboxes");
|
|
49
|
+
|
|
50
|
+
vol
|
|
51
|
+
.command("list")
|
|
52
|
+
.alias("ls")
|
|
53
|
+
.description("List volumes in a project")
|
|
54
|
+
.option("-p, --project <id>", "Project name, slug, or ID")
|
|
55
|
+
.action(async (opts) => {
|
|
56
|
+
const { projectId, scope } = await resolveProjectScope(opts.project);
|
|
57
|
+
const volumes = await api.get<VolumeRecord[]>(
|
|
58
|
+
withScope(`/api/projects/${projectId}/volumes`, scope),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (isJSONMode()) {
|
|
62
|
+
printJSON(volumes);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (volumes.length === 0) {
|
|
67
|
+
console.log("No volumes. Use `lizard volume create <name>`.");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
table(
|
|
72
|
+
["Name", "ID", "Size", "Status", "Attached to"],
|
|
73
|
+
volumes.map((v) => [
|
|
74
|
+
v.name,
|
|
75
|
+
chalk.dim(v.id),
|
|
76
|
+
`${v.sizeGb} GB`,
|
|
77
|
+
v.status,
|
|
78
|
+
v.attachedTo ? chalk.dim(v.attachedTo) : chalk.dim("—"),
|
|
79
|
+
]),
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
vol
|
|
84
|
+
.command("create")
|
|
85
|
+
.argument("<name>", "Volume name")
|
|
86
|
+
.description("Create a persistent volume")
|
|
87
|
+
.option("--size <gb>", "Size in GB (1-100, default 5)", parseIntOption)
|
|
88
|
+
.option("-p, --project <id>", "Project name, slug, or ID")
|
|
89
|
+
.action(async (name: string, opts) => {
|
|
90
|
+
const { projectId, scope } = await resolveProjectScope(opts.project);
|
|
91
|
+
const sizeGb = opts.size ?? 5;
|
|
92
|
+
if (sizeGb < 1 || sizeGb > 100) {
|
|
93
|
+
throw new Error(`--size must be between 1 and 100 (got ${sizeGb}).`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!isJSONMode()) info(`Creating volume ${chalk.cyan(name)}...`);
|
|
97
|
+
const created = await api.post<VolumeRecord>(
|
|
98
|
+
withScope(`/api/projects/${projectId}/volumes`, scope),
|
|
99
|
+
{ name, sizeGb },
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (isJSONMode()) {
|
|
103
|
+
printJSON(created);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
success(`Volume ${chalk.bold(created.name)} created (${created.sizeGb} GB)`);
|
|
107
|
+
info(chalk.dim(` ID: ${created.id}`));
|
|
108
|
+
info(chalk.dim(` Attach it to a sandbox: lizard sandbox create --volume ${created.id}`));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
vol
|
|
112
|
+
.command("rm")
|
|
113
|
+
.alias("delete")
|
|
114
|
+
.argument("<volume>", "Volume name or ID")
|
|
115
|
+
.description("Delete a volume")
|
|
116
|
+
.option("-p, --project <id>", "Project name, slug, or ID")
|
|
117
|
+
.option("-y, --yes", "Skip confirmation")
|
|
118
|
+
.action(async (nameOrId: string, opts) => {
|
|
119
|
+
const { projectId, scope } = await resolveProjectScope(opts.project);
|
|
120
|
+
const volume = await resolveVolume(projectId, scope, nameOrId);
|
|
121
|
+
|
|
122
|
+
if (volume.attachedTo) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Volume "${volume.name}" is attached to sandbox ${volume.attachedTo}. Delete the sandbox first.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!opts.yes && isTTY() && !isJSONMode()) {
|
|
129
|
+
const ok = await p.confirm({
|
|
130
|
+
message: `Delete volume ${chalk.bold(volume.name)}? This cannot be undone.`,
|
|
131
|
+
});
|
|
132
|
+
if (p.isCancel(ok) || !ok) process.exit(5);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
await api.delete(withScope(`/api/projects/${projectId}/volumes/${volume.id}`, scope));
|
|
136
|
+
|
|
137
|
+
if (isJSONMode()) {
|
|
138
|
+
printJSON({ id: volume.id, status: "deleted" });
|
|
139
|
+
} else {
|
|
140
|
+
success(`Volume ${chalk.bold(volume.name)} deleted`);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -40,6 +40,24 @@ const AGENTS_HELP = [
|
|
|
40
40
|
),
|
|
41
41
|
].join("\n");
|
|
42
42
|
|
|
43
|
+
// Compact form of the same pointer, shown on every subcommand's `--help` so the
|
|
44
|
+
// per-command flag docs never read as the whole story. Mirrors the `agents`
|
|
45
|
+
// block that `--help --json` already emits for every command; one tight line so
|
|
46
|
+
// it sits above a subcommand's Usage without crowding it.
|
|
47
|
+
const AGENTS_HELP_COMPACT =
|
|
48
|
+
chalk.bold("For AI agents:") +
|
|
49
|
+
" run " +
|
|
50
|
+
chalk.cyan("lizard skills get core") +
|
|
51
|
+
chalk.dim(" for version-matched usage and examples.");
|
|
52
|
+
|
|
53
|
+
/** Attach the compact agents pointer to every (nested) subcommand's help. */
|
|
54
|
+
function addAgentsPointerToSubcommands(cmd: Command) {
|
|
55
|
+
for (const sub of cmd.commands) {
|
|
56
|
+
sub.addHelpText("before", AGENTS_HELP_COMPACT + "\n");
|
|
57
|
+
addAgentsPointerToSubcommands(sub);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
43
61
|
// Commands (alphabetical by command name)
|
|
44
62
|
import { registerAdd } from "./commands/add.js";
|
|
45
63
|
import { registerConfig } from "./commands/config.js";
|
|
@@ -61,6 +79,7 @@ import { registerRedeploy } from "./commands/redeploy.js";
|
|
|
61
79
|
import { registerRegions } from "./commands/regions.js";
|
|
62
80
|
import { registerRestart } from "./commands/restart.js";
|
|
63
81
|
import { registerRun } from "./commands/run.js";
|
|
82
|
+
import { registerSandbox } from "./commands/sandbox.js";
|
|
64
83
|
import { registerScale } from "./commands/scale.js";
|
|
65
84
|
import { registerSecrets } from "./commands/secrets.js";
|
|
66
85
|
import { registerService } from "./commands/service.js";
|
|
@@ -70,6 +89,7 @@ import { registerStatus } from "./commands/status.js";
|
|
|
70
89
|
import { registerUnlink } from "./commands/unlink.js";
|
|
71
90
|
import { registerUp } from "./commands/up.js";
|
|
72
91
|
import { registerUpgrade } from "./commands/upgrade.js";
|
|
92
|
+
import { registerVolume } from "./commands/volume.js";
|
|
73
93
|
import { registerWhoami } from "./commands/whoami.js";
|
|
74
94
|
import { registerWorkspace } from "./commands/workspace.js";
|
|
75
95
|
|
|
@@ -151,6 +171,7 @@ registerRedeploy(program);
|
|
|
151
171
|
registerRegions(program);
|
|
152
172
|
registerRestart(program);
|
|
153
173
|
registerRun(program);
|
|
174
|
+
registerSandbox(program);
|
|
154
175
|
registerScale(program);
|
|
155
176
|
registerSecrets(program);
|
|
156
177
|
registerService(program);
|
|
@@ -160,9 +181,14 @@ registerStatus(program);
|
|
|
160
181
|
registerUnlink(program);
|
|
161
182
|
registerUp(program);
|
|
162
183
|
registerUpgrade(program);
|
|
184
|
+
registerVolume(program);
|
|
163
185
|
registerWhoami(program);
|
|
164
186
|
registerWorkspace(program);
|
|
165
187
|
|
|
188
|
+
// Root help already carries the full AGENTS_HELP block; mirror a compact pointer
|
|
189
|
+
// onto every subcommand so `lizard <cmd> --help` matches `--help --json` parity.
|
|
190
|
+
addAgentsPointerToSubcommands(program);
|
|
191
|
+
|
|
166
192
|
const EXIT_CODES: Record<string, string> = {
|
|
167
193
|
"0": "success",
|
|
168
194
|
"1": "generic error",
|