@lizard-build/cli 4.0.45 → 4.0.46
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/add.js +2 -7
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/agents.js +21 -2
- package/dist/commands/agents.js.map +1 -1
- package/dist/commands/domain.js +32 -2
- package/dist/commands/domain.js.map +1 -1
- package/dist/commands/init.js +15 -0
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/metrics.js +11 -2
- package/dist/commands/metrics.js.map +1 -1
- package/dist/commands/s3.d.ts +15 -0
- package/dist/commands/s3.js +59 -0
- package/dist/commands/s3.js.map +1 -1
- package/dist/commands/sandbox.js +13 -5
- package/dist/commands/sandbox.js.map +1 -1
- package/dist/commands/up.js +23 -6
- package/dist/commands/up.js.map +1 -1
- package/dist/commands/upgrade.js +17 -3
- package/dist/commands/upgrade.js.map +1 -1
- package/dist/commands/volume.js +38 -4
- package/dist/commands/volume.js.map +1 -1
- package/dist/commands/workspace.js +4 -7
- package/dist/commands/workspace.js.map +1 -1
- package/dist/lib/api.js +21 -6
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/config.d.ts +7 -0
- package/dist/lib/config.js +10 -1
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/default-project.js +7 -2
- package/dist/lib/default-project.js.map +1 -1
- package/dist/lib/skills-data.generated.js +4 -4
- 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 +95 -74
- package/skill-data/core/references/deploys.md +41 -30
- package/skill-data/core/references/sandboxes.md +43 -18
- package/skill-data/core/references/sdk.md +33 -21
- package/src/commands/add.ts +2 -6
- package/src/commands/agents.ts +20 -4
- package/src/commands/domain.ts +36 -2
- package/src/commands/init.ts +18 -0
- package/src/commands/metrics.ts +11 -2
- package/src/commands/s3.ts +65 -0
- package/src/commands/sandbox.ts +15 -5
- package/src/commands/up.ts +20 -6
- package/src/commands/upgrade.ts +14 -3
- package/src/commands/volume.ts +35 -4
- package/src/commands/workspace.ts +4 -9
- package/src/lib/api.ts +21 -6
- package/src/lib/config.ts +11 -1
- package/src/lib/default-project.ts +5 -2
- package/src/lib/updater.ts +1 -1
- package/test/unit/default-project.test.ts +8 -2
- package/test/unit/s3-presign.test.ts +32 -0
- package/test/unit/sandbox-transport.test.ts +5 -1
- package/test/unit/sandbox.test.ts +1 -0
package/src/commands/sandbox.ts
CHANGED
|
@@ -9,7 +9,7 @@ import open from "open";
|
|
|
9
9
|
import { api, APIError, getBaseURL, getRawBytes, apiErrorFrom, clientHeaders, getRequestToken, withQuery, withScope, type ResourceScope } from "../lib/api.js";
|
|
10
10
|
import { getToken } from "../lib/auth.js";
|
|
11
11
|
import { resolveProjectScope } from "../lib/resolve.js";
|
|
12
|
-
import { getProjectLink, resolveProjectId } from "../lib/config.js";
|
|
12
|
+
import { getProjectLink, resolveProjectId, looksLikeId } from "../lib/config.js";
|
|
13
13
|
import { resolveVolume } from "../lib/volume.js";
|
|
14
14
|
import { sandboxShell } from "./sandbox-ssh.js";
|
|
15
15
|
import { startVncTunnel, vncTarget } from "./sandbox-vnc.js";
|
|
@@ -21,7 +21,7 @@ import { success, info, error, isJSONMode, printJSON, table, statusColor, timeAg
|
|
|
21
21
|
// still rejected before a request was ever sent — codex and interpreter both were. The
|
|
22
22
|
// server validates against the region's actual list and answers with what IS available,
|
|
23
23
|
// so let it.
|
|
24
|
-
const TEMPLATE_HINT = "base, codex,
|
|
24
|
+
const TEMPLATE_HINT = "base, interpreter, codex, claude, opencode, pi, prime, desktop";
|
|
25
25
|
|
|
26
26
|
// The three machines a sandbox can be, each priced per hour, billed per second by the server. The
|
|
27
27
|
// platform rejects anything else, so the CLI offers exactly these.
|
|
@@ -130,7 +130,7 @@ export function registerSandbox(program: Command) {
|
|
|
130
130
|
.option("--region <code>", "Region to create the sandbox in")
|
|
131
131
|
.option("--snapshot <id>", "Create from a private saved snapshot (runs on the machine it was captured on)")
|
|
132
132
|
.option("--volume <name-or-id>", "Attach a persistent volume")
|
|
133
|
-
.option("--with-token [key]", "
|
|
133
|
+
.option("--with-token [key]", "Sign in `lizard` inside the sandbox with a new key limited to the sandbox's project and deleted with it (this liz_ key, or the one you use, only authorizes the request)")
|
|
134
134
|
.option("-p, --project <id>", "Project to create the sandbox in (name, slug, or ID). Defaults to the linked project.")
|
|
135
135
|
.action(async (opts) => {
|
|
136
136
|
// A snapshot restores onto the machine it was captured on, so the server ignores a
|
|
@@ -170,7 +170,7 @@ export function registerSandbox(program: Command) {
|
|
|
170
170
|
let volumeId: string | undefined;
|
|
171
171
|
let volumeName: string | undefined;
|
|
172
172
|
if (opts.volume) {
|
|
173
|
-
if (
|
|
173
|
+
if (looksLikeId(opts.volume)) volumeId = opts.volume;
|
|
174
174
|
else volumeName = opts.volume;
|
|
175
175
|
}
|
|
176
176
|
|
|
@@ -543,7 +543,7 @@ Examples:
|
|
|
543
543
|
api.get<SnapshotRecord>(`/api/sandbox-snapshots/${snapshotId}`),
|
|
544
544
|
opts.project ? resolveProjectId(opts.project) : Promise.resolve(undefined),
|
|
545
545
|
]);
|
|
546
|
-
const volume = opts.volume ? (/^fcv
|
|
546
|
+
const volume = opts.volume ? ((/^fcv-/.test(opts.volume) || looksLikeId(opts.volume)) ? { volumeId: opts.volume } : { volumeName: opts.volume }) : {};
|
|
547
547
|
// A volume picks its own project when none is named; a name needs one to be found in.
|
|
548
548
|
const project = projectId ?? (volume.volumeId ? undefined : snapshot.projectId);
|
|
549
549
|
const sandbox = await api.post<SandboxRecord>("/api/sandboxes", { snapshotId, projectId: project, region: snapshot.region, timeoutMs: opts.timeout, ...volume });
|
|
@@ -748,6 +748,16 @@ export function execStream(
|
|
|
748
748
|
if (currentEvent === "exit") {
|
|
749
749
|
try {
|
|
750
750
|
const code: unknown = JSON.parse(data).exitCode;
|
|
751
|
+
// -1: the platform stopped waiting (150 s). Say so; the bare "invalid event"
|
|
752
|
+
// left agents guessing and retrying the same long command.
|
|
753
|
+
if (code === -1) {
|
|
754
|
+
reject(new APIError(408,
|
|
755
|
+
"The command was still running after 150 seconds, so exec stopped waiting (it may still be running in the sandbox). " +
|
|
756
|
+
`Start long jobs in the background and read a log: lizard sandbox exec ${sandboxId} -- sh -c 'nohup <cmd> > /tmp/job.log 2>&1 &', ` +
|
|
757
|
+
`then lizard sandbox exec ${sandboxId} -- tail -n 50 /tmp/job.log`,
|
|
758
|
+
"EXEC_TIMEOUT"));
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
751
761
|
if (!Number.isInteger(code) || (code as number) < 0 || (code as number) > 255) {
|
|
752
762
|
throw new Error("Invalid exec exit code");
|
|
753
763
|
}
|
package/src/commands/up.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { createTarball } from "../lib/archive.js";
|
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import * as readline from "node:readline";
|
|
9
|
-
import { api, streamSSE, getBaseURL, clientHeaders, APIError, apiErrorFrom, isPaymentRequired, type ResourceScope } from "../lib/api.js";
|
|
9
|
+
import { api, streamSSE, getBaseURL, clientHeaders, APIError, apiErrorFrom, isPaymentRequired, withScope, type ResourceScope } from "../lib/api.js";
|
|
10
10
|
import { updateProjectLink, DEFAULT_REGION } from "../lib/config.js";
|
|
11
11
|
import { resolveContext, getScope } from "../lib/resolve.js";
|
|
12
12
|
import { ensureLinked } from "./init.js";
|
|
@@ -62,11 +62,25 @@ export function registerUp(program: Command) {
|
|
|
62
62
|
// An unlinked folder goes to the default project (or --project, or init's questions)
|
|
63
63
|
await ensureLinked({ projectName: projectFlag, useDefault: true });
|
|
64
64
|
|
|
65
|
-
// Resolve target service: --service flag → linked → first-in-project → prompt-or-fail
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
65
|
+
// Resolve target service: --service flag → linked → first-in-project → prompt-or-fail.
|
|
66
|
+
// `--service <name>` that doesn't exist yet names the new service instead of failing:
|
|
67
|
+
// agents (and people) reach for it to choose the name of a first deploy.
|
|
68
|
+
let ctx: Awaited<ReturnType<typeof resolveContext>>;
|
|
69
|
+
try {
|
|
70
|
+
ctx = await resolveContext({ projectFlag, serviceFlag });
|
|
71
|
+
} catch (e: any) {
|
|
72
|
+
if (!serviceFlag || !/not found in project/.test(String(e?.message))) throw e;
|
|
73
|
+
ctx = { ...(await resolveContext({ projectFlag })), service: undefined };
|
|
74
|
+
info(chalk.dim(`Service ${serviceFlag} doesn't exist yet: creating it.`));
|
|
75
|
+
}
|
|
76
|
+
// A folder linked to an addon (`lizard add postgres` links it) must not send code there.
|
|
77
|
+
if (ctx.service) {
|
|
78
|
+
const services = await api.get<{ addons?: { id: string }[] }>(withScope(`/api/projects/${ctx.projectId}/services`, getScope(ctx)));
|
|
79
|
+
if (services.addons?.some((a) => a.id === ctx.service!.id)) {
|
|
80
|
+
if (serviceFlag) throw new Error(`${ctx.service.name} is a managed addon, not a service you deploy code to. Pass the app's --service.`);
|
|
81
|
+
ctx = { ...ctx, service: undefined };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
70
84
|
const projectId = ctx.projectId;
|
|
71
85
|
const scope = getScope(ctx);
|
|
72
86
|
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
3
4
|
import { info, success, isJSONMode, printJSON } from "../lib/format.js";
|
|
4
5
|
import {
|
|
5
6
|
CURRENT_VERSION,
|
|
@@ -75,8 +76,17 @@ export function registerUpgrade(program: Command) {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
// npm install — self-replacing process.execPath would overwrite the
|
|
78
|
-
// user's node binary
|
|
79
|
+
// user's node binary, so let npm do it. Agents on an old copy (sandbox templates
|
|
80
|
+
// ship one) need the upgrade to happen, not a hint they may not follow.
|
|
79
81
|
if (!isStandaloneBinary()) {
|
|
82
|
+
const spec = `@lizard-build/cli@${latest}`;
|
|
83
|
+
if (!isJSONMode()) info(`Upgrading v${CURRENT_VERSION} → ${chalk.green("v" + latest)} with npm...`);
|
|
84
|
+
const r = spawnSync("npm", ["install", "-g", spec], { encoding: "utf8", shell: process.platform === "win32" });
|
|
85
|
+
if (r.status === 0) {
|
|
86
|
+
if (isJSONMode()) printJSON({ previousVersion: CURRENT_VERSION, latestVersion: latest, upgraded: true, method: "npm" });
|
|
87
|
+
else success(`Upgraded to v${latest}`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
80
90
|
if (isJSONMode()) {
|
|
81
91
|
printJSON({
|
|
82
92
|
currentVersion: CURRENT_VERSION,
|
|
@@ -85,11 +95,12 @@ export function registerUpgrade(program: Command) {
|
|
|
85
95
|
upgraded: false,
|
|
86
96
|
method: "npm",
|
|
87
97
|
hint: "npm install -g @lizard-build/cli@latest",
|
|
98
|
+
error: ((r.stderr || "") + (r.error?.message ?? "")).trim().split("\n").slice(-3).join(" ") || null,
|
|
88
99
|
});
|
|
89
100
|
return;
|
|
90
101
|
}
|
|
91
|
-
info(`
|
|
92
|
-
info(`
|
|
102
|
+
info(`npm could not upgrade this copy (${(r.stderr || r.error?.message || "").trim().split("\n").pop()}).`);
|
|
103
|
+
info(`Upgrade it yourself, with the rights npm needs:`);
|
|
93
104
|
info(` ${chalk.cyan("npm install -g @lizard-build/cli@latest")}`);
|
|
94
105
|
return;
|
|
95
106
|
}
|
package/src/commands/volume.ts
CHANGED
|
@@ -3,7 +3,7 @@ import * as p from "@clack/prompts";
|
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import { api, withScope, type ResourceScope } from "../lib/api.js";
|
|
5
5
|
import { assertValidVolumeName, resolveVolume, type VolumeRecord } from "../lib/volume.js";
|
|
6
|
-
import { resolveProjectScope } from "../lib/resolve.js";
|
|
6
|
+
import { resolveProjectScope, resolveService } from "../lib/resolve.js";
|
|
7
7
|
import { success, info, warn, isJSONMode, printJSON, table, isTTY } from "../lib/format.js";
|
|
8
8
|
|
|
9
9
|
function parseIntOption(v: string): number {
|
|
@@ -19,19 +19,37 @@ function parseSizeGbOption(v: string): number {
|
|
|
19
19
|
return parseIntOption(v);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
interface ServiceVolume { id: string; name: string; sizeGb: number; mountPath?: string | null; status: string }
|
|
23
|
+
|
|
24
|
+
/** The app a --service flag names; volumes attach to apps, not addons. */
|
|
25
|
+
async function serviceApp(projectId: string, nameOrId: string) {
|
|
26
|
+
const svc = await resolveService(projectId, nameOrId);
|
|
27
|
+
if (svc.kind !== "app") throw new Error(`${svc.name} is a managed addon; its storage is managed for you.`);
|
|
28
|
+
return svc;
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
export function registerVolume(program: Command) {
|
|
23
32
|
const vol = program
|
|
24
33
|
.command("volume")
|
|
25
34
|
.alias("vol")
|
|
26
|
-
.description("Manage persistent volumes for sandboxes");
|
|
35
|
+
.description("Manage persistent volumes for sandboxes and services");
|
|
27
36
|
|
|
28
37
|
vol
|
|
29
38
|
.command("list")
|
|
30
39
|
.alias("ls")
|
|
31
|
-
.description("List volumes in a project")
|
|
40
|
+
.description("List volumes in a project (sandbox volumes), or a service's volume with --service")
|
|
41
|
+
.option("-s, --service <name>", "List the volume attached to this service")
|
|
32
42
|
.option("-p, --project <id>", "Project name, slug, or ID")
|
|
33
43
|
.action(async (opts) => {
|
|
34
44
|
const { projectId, scope } = await resolveProjectScope(opts.project);
|
|
45
|
+
if (opts.service) {
|
|
46
|
+
const app = await serviceApp(projectId, opts.service);
|
|
47
|
+
const attached = await api.get<ServiceVolume[]>(`/api/apps/${app.id}/volumes`);
|
|
48
|
+
if (isJSONMode()) return printJSON(attached);
|
|
49
|
+
if (!attached.length) return console.log(`${app.name} has no volume. Add one: lizard volume create data --service ${app.name}`);
|
|
50
|
+
table(["Name", "Size", "Mounted at", "Status"], attached.map((v) => [v.name, `${v.sizeGb} GB`, v.mountPath ?? "—", v.status]));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
35
53
|
const volumes = await api.get<VolumeRecord[]>(
|
|
36
54
|
withScope(`/api/projects/${projectId}/volumes`, scope),
|
|
37
55
|
);
|
|
@@ -62,13 +80,26 @@ export function registerVolume(program: Command) {
|
|
|
62
80
|
vol
|
|
63
81
|
.command("create")
|
|
64
82
|
.argument("<name>", "Volume name")
|
|
65
|
-
.description("Create a persistent volume")
|
|
83
|
+
.description("Create a persistent volume for sandboxes, or create one and attach it to a service with --service")
|
|
66
84
|
.option("--size <gb>", "Size in GB (server limits apply; default 5)", parseIntOption)
|
|
67
85
|
.option("--region <code>", "Region to place the volume in (must match the sandbox that will attach it)")
|
|
86
|
+
.option("-s, --service <name>", "Attach it to this service instead (one volume per service; files there survive restarts and redeploys)")
|
|
87
|
+
.option("--mount-path <path>", "With --service: where the service sees it (default /data)")
|
|
68
88
|
.option("-p, --project <id>", "Project name, slug, or ID")
|
|
69
89
|
.action(async (name: string, opts) => {
|
|
70
90
|
assertValidVolumeName(name);
|
|
71
91
|
const { projectId, scope } = await resolveProjectScope(opts.project);
|
|
92
|
+
if (opts.service) {
|
|
93
|
+
const app = await serviceApp(projectId, opts.service);
|
|
94
|
+
const mountPath: string = opts.mountPath ?? "/data";
|
|
95
|
+
if (!mountPath.startsWith("/")) throw new Error(`--mount-path must be an absolute path (got ${mountPath}).`);
|
|
96
|
+
if (!isJSONMode()) info(`Creating volume ${chalk.cyan(name)} on ${chalk.cyan(app.name)} at ${mountPath}...`);
|
|
97
|
+
const created = await api.post<ServiceVolume>(`/api/apps/${app.id}/volumes`, { name, sizeGb: opts.size ?? 5, mountPath });
|
|
98
|
+
if (isJSONMode()) return printJSON(created);
|
|
99
|
+
success(`Volume ${chalk.bold(created.name)} (${created.sizeGb} GB) attached to ${app.name} at ${created.mountPath ?? mountPath}`);
|
|
100
|
+
info(chalk.dim(" The service restarts with it mounted. Keep files you must not lose under that path."));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
72
103
|
const limits = await api.get<{ minSizeGb: number; maxSizeGb: number; defaultSizeGb: number }>(
|
|
73
104
|
withScope(`/api/projects/${projectId}/volume-limits`, scope),
|
|
74
105
|
);
|
|
@@ -75,15 +75,10 @@ export function registerWorkspace(program: Command) {
|
|
|
75
75
|
if (workspace.role !== "owner") {
|
|
76
76
|
throw new Error("Only the workspace owner can delete it.");
|
|
77
77
|
}
|
|
78
|
-
// Empty workspaces only
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
if (projectCount > 0) {
|
|
83
|
-
throw new Error(
|
|
84
|
-
`Workspace "${workspace.name}" has ${projectCount} project(s). Delete them first, or use the dashboard to remove everything.`,
|
|
85
|
-
);
|
|
86
|
-
}
|
|
78
|
+
// Empty workspaces only: the backend checks that with ?requireEmpty=true (live
|
|
79
|
+
// projects, sandboxes and volumes) and says what is left. The list's projectCount
|
|
80
|
+
// also counts projects in the 3-day trash, so a client-side check here refused a
|
|
81
|
+
// workspace whose projects had all just been deleted.
|
|
87
82
|
|
|
88
83
|
if (!opts.yes && isTTY() && !isJSONMode()) {
|
|
89
84
|
const ok = await p.confirm({ message: `Delete workspace ${chalk.bold(workspace.name)}?` });
|
package/src/lib/api.ts
CHANGED
|
@@ -187,6 +187,24 @@ export function isProjectDeletedError(err: unknown): err is APIError {
|
|
|
187
187
|
return body?.error === "Project is being deleted";
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
const DROPPED = new Set(["UND_ERR_SOCKET", "UND_ERR_CLOSED", "ECONNRESET", "EPIPE", "ECONNREFUSED"]);
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* fetch for reads: a connection the network dropped before any response is retried
|
|
194
|
+
* twice. Reads change nothing, so a retry is safe; writes are never retried here.
|
|
195
|
+
*/
|
|
196
|
+
async function fetchWithRetry(url: string, init: RequestInit): Promise<Response> {
|
|
197
|
+
for (let attempt = 1; ; attempt++) {
|
|
198
|
+
try {
|
|
199
|
+
return await fetch(url, init);
|
|
200
|
+
} catch (e: any) {
|
|
201
|
+
const code = e?.cause?.code ?? e?.code;
|
|
202
|
+
if (attempt >= 3 || !DROPPED.has(code)) throw e;
|
|
203
|
+
await new Promise((r) => setTimeout(r, 300 * attempt));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
190
208
|
async function request<T = any>(
|
|
191
209
|
method: string,
|
|
192
210
|
path: string,
|
|
@@ -207,11 +225,8 @@ async function request<T = any>(
|
|
|
207
225
|
headers["Content-Type"] = "application/json";
|
|
208
226
|
}
|
|
209
227
|
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
headers,
|
|
213
|
-
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
214
|
-
});
|
|
228
|
+
const init = { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined };
|
|
229
|
+
const res = method === "GET" ? await fetchWithRetry(url, init) : await fetch(url, init);
|
|
215
230
|
|
|
216
231
|
if (!res.ok) {
|
|
217
232
|
let body: unknown = null;
|
|
@@ -233,7 +248,7 @@ export async function getRawBytes(path: string): Promise<Buffer> {
|
|
|
233
248
|
const headers: Record<string, string> = clientHeaders();
|
|
234
249
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
235
250
|
|
|
236
|
-
const res = await
|
|
251
|
+
const res = await fetchWithRetry(url, { method: "GET", headers });
|
|
237
252
|
if (!res.ok) {
|
|
238
253
|
let body: unknown = null;
|
|
239
254
|
try {
|
package/src/lib/config.ts
CHANGED
|
@@ -148,10 +148,20 @@ export async function resolveProjectId(flagValue?: string): Promise<string> {
|
|
|
148
148
|
//
|
|
149
149
|
// A 21-character slug would be misread as an id here, so a caller that gets "project
|
|
150
150
|
// not found" from the server should retry through resolveProjectIdSlow.
|
|
151
|
-
if (
|
|
151
|
+
if (looksLikeId(flagValue)) return flagValue;
|
|
152
152
|
return resolveProjectIdSlow(flagValue);
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* A 21-character nanoid. Names and slugs use the same characters, and a 21-character
|
|
157
|
+
* name ("ev-r1-deploy-monorepo") was sent as an id and came back "Project not found".
|
|
158
|
+
* Ids almost always hold an uppercase letter (all-lowercase is about a 1-in-55,000 draw);
|
|
159
|
+
* names and slugs rarely do, so require one.
|
|
160
|
+
*/
|
|
161
|
+
export function looksLikeId(value: string): boolean {
|
|
162
|
+
return /^[A-Za-z0-9_-]{21}$/.test(value) && /[A-Z]/.test(value);
|
|
163
|
+
}
|
|
164
|
+
|
|
155
165
|
/** List projects and match on id, slug or name — for anything not shaped like an id. */
|
|
156
166
|
export async function resolveProjectIdSlow(flagValue: string): Promise<string> {
|
|
157
167
|
const { api } = await import("./api.js");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { api, APIError } from "./api.js";
|
|
3
|
-
import { info } from "./format.js";
|
|
3
|
+
import { info, isJSONMode } from "./format.js";
|
|
4
4
|
|
|
5
5
|
// The project a command uses when the folder is not linked and no --project is
|
|
6
6
|
// given: the platform's default for this account (GET /api/projects/default),
|
|
@@ -37,7 +37,10 @@ let noted = false;
|
|
|
37
37
|
export function noteDefaultProject(name: string): void {
|
|
38
38
|
if (noted) return;
|
|
39
39
|
noted = true;
|
|
40
|
-
|
|
40
|
+
const note = `Project: ${name} (your default). Pass --project for another, or run \`lizard init\` to link this folder.`;
|
|
41
|
+
// stderr, so stdout stays pure JSON; agents read both and need to know where the command landed.
|
|
42
|
+
if (isJSONMode()) process.stderr.write(note + "\n");
|
|
43
|
+
else info(chalk.dim(note));
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
/** For tests: forget the cached answer. */
|
package/src/lib/updater.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { join, dirname } from "node:path";
|
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
7
|
|
|
8
|
-
export const CURRENT_VERSION = "4.0.
|
|
8
|
+
export const CURRENT_VERSION = "4.0.46";
|
|
9
9
|
const RELEASES_API = "https://api.github.com/repos/lizard-build/lizard-cli/releases/latest";
|
|
10
10
|
const RELEASE_BASE = "https://github.com/lizard-build/lizard-cli/releases/latest/download";
|
|
11
11
|
|
|
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
-
import { resolveProjectId, getProjectLink, setProjectLink } from "../../src/lib/config.js";
|
|
5
|
+
import { resolveProjectId, getProjectLink, setProjectLink, looksLikeId } from "../../src/lib/config.js";
|
|
6
6
|
import { resetDefaultProject } from "../../src/lib/default-project.js";
|
|
7
7
|
import { ensureLinked } from "../../src/commands/init.js";
|
|
8
8
|
import { resolveProject as resolveAddProject } from "../../src/commands/add.js";
|
|
@@ -65,10 +65,16 @@ describe("resolveProjectId in an unlinked folder", () => {
|
|
|
65
65
|
test("a linked folder or --project never asks for it", async () => {
|
|
66
66
|
setProjectLink({ projectId: "p-linked" });
|
|
67
67
|
expect(await resolveProjectId()).toBe("p-linked");
|
|
68
|
-
expect(await resolveProjectId("
|
|
68
|
+
expect(await resolveProjectId("abcdefghijKlmnopqrstu")).toBe("abcdefghijKlmnopqrstu");
|
|
69
69
|
expect(requested()).toEqual([]);
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
test("a 21-character lowercase name is looked up, not sent as an id", async () => {
|
|
73
|
+
expect(looksLikeId("ev-r1-deploy-monorepo")).toBe(false);
|
|
74
|
+
expect(looksLikeId("EmKpChcrzdgwf0EV1WZaD")).toBe(true);
|
|
75
|
+
expect(looksLikeId("short")).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
72
78
|
test("no default project, or a platform without the route: the old error", async () => {
|
|
73
79
|
routes({ "GET /api/projects/default": () => reply(404, { error: "none", code: "NO_DEFAULT_PROJECT" }) });
|
|
74
80
|
await expect(resolveProjectId()).rejects.toThrow("No project linked. Run `lizard init` or pass --project <id>.");
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { presignGet } from "../../src/commands/s3.js";
|
|
3
|
+
|
|
4
|
+
describe("lizard s3 url", () => {
|
|
5
|
+
const base = {
|
|
6
|
+
endpoint: "https://s3-us-east-1.onlizard.com/addon123",
|
|
7
|
+
accessKeyId: "AKIDEXAMPLE",
|
|
8
|
+
secretKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
|
|
9
|
+
region: "us-east-1",
|
|
10
|
+
bucket: "default",
|
|
11
|
+
expiresIn: 3600,
|
|
12
|
+
now: new Date("2026-10-13T10:00:00.000Z"),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
test("signs a path-style GET under the addon's endpoint path", () => {
|
|
16
|
+
const url = new URL(presignGet({ ...base, key: "reports/q3 report.txt" }));
|
|
17
|
+
expect(url.host).toBe("s3-us-east-1.onlizard.com");
|
|
18
|
+
expect(url.pathname).toBe("/addon123/default/reports/q3%20report.txt");
|
|
19
|
+
expect(url.searchParams.get("X-Amz-Credential")).toBe("AKIDEXAMPLE/20261013/us-east-1/s3/aws4_request");
|
|
20
|
+
expect(url.searchParams.get("X-Amz-Date")).toBe("20261013T100000Z");
|
|
21
|
+
expect(url.searchParams.get("X-Amz-Expires")).toBe("3600");
|
|
22
|
+
expect(url.searchParams.get("X-Amz-SignedHeaders")).toBe("host");
|
|
23
|
+
expect(url.searchParams.get("X-Amz-Signature")).toMatch(/^[0-9a-f]{64}$/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("the signature changes with the key and the expiry, and is stable otherwise", () => {
|
|
27
|
+
const a = presignGet({ ...base, key: "a.txt" });
|
|
28
|
+
expect(presignGet({ ...base, key: "a.txt" })).toBe(a);
|
|
29
|
+
expect(presignGet({ ...base, key: "b.txt" })).not.toBe(a);
|
|
30
|
+
expect(presignGet({ ...base, key: "a.txt", expiresIn: 60 })).not.toBe(a);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -48,10 +48,14 @@ describe("sandbox exec stream", () => {
|
|
|
48
48
|
respond = (_req, res) => res.end(body);
|
|
49
49
|
await expect(execStream("sb", "test", () => {})).rejects.toMatchObject({ code: "EXEC_STREAM_INCOMPLETE" });
|
|
50
50
|
});
|
|
51
|
-
it.each([null, -
|
|
51
|
+
it.each([null, -2, 256, 1.5, "0"])("rejects invalid exit code %s", async exitCode => {
|
|
52
52
|
respond = (_req, res) => res.end(`event: exit\ndata: ${JSON.stringify({ exitCode })}\n\n`);
|
|
53
53
|
await expect(execStream("sb", "test", () => {})).rejects.toThrow("Invalid exec exit event");
|
|
54
54
|
});
|
|
55
|
+
it("reports exit code -1 as the platform's 150-second limit, with status 408", async () => {
|
|
56
|
+
respond = (_req, res) => res.end(`event: exit\ndata: {"exitCode":-1}\n\n`);
|
|
57
|
+
await expect(execStream("sb", "test", () => {})).rejects.toMatchObject({ status: 408, code: "EXEC_TIMEOUT" });
|
|
58
|
+
});
|
|
55
59
|
it.each([401, 403, 404, 408, 504])("preserves HTTP %i as an APIError", async status => {
|
|
56
60
|
respond = (_req, res) => { res.writeHead(status); res.end(JSON.stringify({ error: "test failure", code: "TEST" })); };
|
|
57
61
|
await expect(execStream("sb", "test", () => {})).rejects.toMatchObject({ status, message: "test failure", code: "TEST" });
|
|
@@ -15,6 +15,7 @@ vi.mock("open", () => ({ default: vi.fn().mockResolvedValue(undefined) }));
|
|
|
15
15
|
vi.mock("../../src/lib/config.js", () => ({
|
|
16
16
|
resolveProjectId: vi.fn().mockResolvedValue("project-test"),
|
|
17
17
|
getProjectLink: vi.fn(() => ({ projectId: "project-test" })),
|
|
18
|
+
looksLikeId: (v: string) => /^[A-Za-z0-9_-]{21}$/.test(v) && /[A-Z]/.test(v),
|
|
18
19
|
}));
|
|
19
20
|
vi.mock("../../src/lib/format.js", () => ({ isJSONMode: () => true, printJSON: vi.fn() }));
|
|
20
21
|
|