@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.
Files changed (58) hide show
  1. package/dist/commands/add.js +2 -7
  2. package/dist/commands/add.js.map +1 -1
  3. package/dist/commands/agents.js +21 -2
  4. package/dist/commands/agents.js.map +1 -1
  5. package/dist/commands/domain.js +32 -2
  6. package/dist/commands/domain.js.map +1 -1
  7. package/dist/commands/init.js +15 -0
  8. package/dist/commands/init.js.map +1 -1
  9. package/dist/commands/metrics.js +11 -2
  10. package/dist/commands/metrics.js.map +1 -1
  11. package/dist/commands/s3.d.ts +15 -0
  12. package/dist/commands/s3.js +59 -0
  13. package/dist/commands/s3.js.map +1 -1
  14. package/dist/commands/sandbox.js +13 -5
  15. package/dist/commands/sandbox.js.map +1 -1
  16. package/dist/commands/up.js +23 -6
  17. package/dist/commands/up.js.map +1 -1
  18. package/dist/commands/upgrade.js +17 -3
  19. package/dist/commands/upgrade.js.map +1 -1
  20. package/dist/commands/volume.js +38 -4
  21. package/dist/commands/volume.js.map +1 -1
  22. package/dist/commands/workspace.js +4 -7
  23. package/dist/commands/workspace.js.map +1 -1
  24. package/dist/lib/api.js +21 -6
  25. package/dist/lib/api.js.map +1 -1
  26. package/dist/lib/config.d.ts +7 -0
  27. package/dist/lib/config.js +10 -1
  28. package/dist/lib/config.js.map +1 -1
  29. package/dist/lib/default-project.js +7 -2
  30. package/dist/lib/default-project.js.map +1 -1
  31. package/dist/lib/skills-data.generated.js +4 -4
  32. package/dist/lib/skills-data.generated.js.map +1 -1
  33. package/dist/lib/updater.d.ts +1 -1
  34. package/dist/lib/updater.js +1 -1
  35. package/package.json +1 -1
  36. package/skill-data/core/SKILL.md +95 -74
  37. package/skill-data/core/references/deploys.md +41 -30
  38. package/skill-data/core/references/sandboxes.md +43 -18
  39. package/skill-data/core/references/sdk.md +33 -21
  40. package/src/commands/add.ts +2 -6
  41. package/src/commands/agents.ts +20 -4
  42. package/src/commands/domain.ts +36 -2
  43. package/src/commands/init.ts +18 -0
  44. package/src/commands/metrics.ts +11 -2
  45. package/src/commands/s3.ts +65 -0
  46. package/src/commands/sandbox.ts +15 -5
  47. package/src/commands/up.ts +20 -6
  48. package/src/commands/upgrade.ts +14 -3
  49. package/src/commands/volume.ts +35 -4
  50. package/src/commands/workspace.ts +4 -9
  51. package/src/lib/api.ts +21 -6
  52. package/src/lib/config.ts +11 -1
  53. package/src/lib/default-project.ts +5 -2
  54. package/src/lib/updater.ts +1 -1
  55. package/test/unit/default-project.test.ts +8 -2
  56. package/test/unit/s3-presign.test.ts +32 -0
  57. package/test/unit/sandbox-transport.test.ts +5 -1
  58. package/test/unit/sandbox.test.ts +1 -0
@@ -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, interpreter, desktop";
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]", "Install this liz_ key inside the sandbox so `lizard` works there (defaults to the key you are using)")
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 (/^[A-Za-z0-9_-]{21}$/.test(opts.volume)) volumeId = opts.volume;
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-|^[A-Za-z0-9_-]{21}$/.test(opts.volume) ? { volumeId: opts.volume } : { volumeName: opts.volume }) : {};
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
  }
@@ -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
- const ctx = await resolveContext({
67
- projectFlag,
68
- serviceFlag,
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
 
@@ -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. Point at npm instead.
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(`Update available: v${CURRENT_VERSION} → ${chalk.green("v" + latest)}`);
92
- info(`This copy was installed via npm. Upgrade with:`);
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
  }
@@ -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. Check here for a clear message; the backend
79
- // enforces the same via ?requireEmpty=true and also refuses if any
80
- // sandbox remains (which the CLI can't count on its own).
81
- const projectCount = workspace.projectCount ?? 0;
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 res = await fetch(url, {
211
- method,
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 fetch(url, { method: "GET", headers });
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 (/^[A-Za-z0-9_-]{21}$/.test(flagValue)) return flagValue;
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
- info(chalk.dim(`Project: ${name} (your default). Pass --project for another, or run \`lizard init\` to link this folder.`));
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. */
@@ -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.45";
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("abcdefghijklmnopqrstu")).toBe("abcdefghijklmnopqrstu");
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, -1, 256, 1.5, "0"])("rejects invalid exit code %s", async exitCode => {
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