@lizard-build/cli 4.0.26 → 4.0.28
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 +1 -1
- package/dist/commands/sandbox-vnc.d.ts +2 -1
- package/dist/commands/sandbox-vnc.js +8 -4
- package/dist/commands/sandbox-vnc.js.map +1 -1
- package/dist/commands/sandbox.d.ts +4 -0
- package/dist/commands/sandbox.js +86 -70
- package/dist/commands/sandbox.js.map +1 -1
- package/dist/commands/volume.js +2 -39
- package/dist/commands/volume.js.map +1 -1
- package/dist/lib/api.d.ts +2 -2
- package/dist/lib/api.js +6 -4
- 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 +3 -3
- package/src/commands/sandbox-vnc.ts +8 -4
- package/src/commands/sandbox.ts +64 -62
- package/src/commands/volume.ts +2 -47
- package/src/lib/api.ts +7 -4
- package/src/lib/updater.ts +1 -1
- package/test/unit/sandbox-transport.test.ts +112 -0
- package/test/unit/sandbox.test.ts +1 -1
- package/test/unit/volume.test.ts +10 -82
package/src/commands/volume.ts
CHANGED
|
@@ -4,7 +4,7 @@ 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
6
|
import { resolveProjectScope } from "../lib/resolve.js";
|
|
7
|
-
import { success, info,
|
|
7
|
+
import { success, info, isJSONMode, printJSON, table, isTTY } from "../lib/format.js";
|
|
8
8
|
|
|
9
9
|
function parseIntOption(v: string): number {
|
|
10
10
|
const n = Number(v);
|
|
@@ -12,8 +12,7 @@ function parseIntOption(v: string): number {
|
|
|
12
12
|
return n;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
* silently rounds a typo down is a shrink the user never asked for. */
|
|
15
|
+
/** Accept whole-number sizes without rounding or numeric suffixes. */
|
|
17
16
|
function parseSizeGbOption(v: string): number {
|
|
18
17
|
if (!/^\s*\d+\s*$/.test(v)) throw new Error(`--size must be a whole number of GB (got ${v}).`);
|
|
19
18
|
return parseIntOption(v);
|
|
@@ -91,50 +90,6 @@ export function registerVolume(program: Command) {
|
|
|
91
90
|
info(chalk.dim(` Attach it to a sandbox: lizard sandbox create --volume ${created.name}`));
|
|
92
91
|
});
|
|
93
92
|
|
|
94
|
-
vol
|
|
95
|
-
.command("resize")
|
|
96
|
-
.argument("<volume>", "Volume name or ID")
|
|
97
|
-
.description(
|
|
98
|
-
"Grow or shrink a volume in place. Online: no data is copied, it takes under a second, " +
|
|
99
|
-
"and an attached sandbox keeps running and sees the new size immediately. " +
|
|
100
|
-
"A shrink must leave at least 10% of the new size free.",
|
|
101
|
-
)
|
|
102
|
-
.requiredOption("--size <gb>", "New size in GB (whole number; the server enforces the project's min/max)", parseSizeGbOption)
|
|
103
|
-
.option("-p, --project <id>", "Project name, slug, or ID")
|
|
104
|
-
.action(async (nameOrId: string, opts) => {
|
|
105
|
-
const sizeGb: number = opts.size;
|
|
106
|
-
if (!Number.isInteger(sizeGb) || sizeGb < 1) {
|
|
107
|
-
throw new Error(`--size must be a whole number of GB, at least 1 (got ${opts.size}).`);
|
|
108
|
-
}
|
|
109
|
-
// No client-side upper bound: the limit is per-platform config (volume-limits),
|
|
110
|
-
// and the server reports it in its error when exceeded.
|
|
111
|
-
const { projectId, scope } = await resolveProjectScope(opts.project);
|
|
112
|
-
const volume = await resolveVolume(projectId, scope, nameOrId);
|
|
113
|
-
|
|
114
|
-
if (volume.sizeGb === sizeGb) {
|
|
115
|
-
if (isJSONMode()) {
|
|
116
|
-
printJSON(volume);
|
|
117
|
-
} else {
|
|
118
|
-
info(`Volume ${chalk.bold(volume.name)} is already ${sizeGb} GB — nothing to do.`);
|
|
119
|
-
}
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const resized = await api.patch<VolumeRecord & { sizeEnforced?: boolean }>(
|
|
124
|
-
withScope(`/api/projects/${projectId}/volumes/${encodeURIComponent(volume.name)}`, scope),
|
|
125
|
-
{ sizeGb },
|
|
126
|
-
);
|
|
127
|
-
|
|
128
|
-
if (isJSONMode()) {
|
|
129
|
-
printJSON(resized);
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
success(`Volume ${chalk.bold(resized.name)} resized: ${volume.sizeGb} GB → ${resized.sizeGb} GB`);
|
|
133
|
-
if (resized.sizeEnforced === false) {
|
|
134
|
-
warn("This region's storage does not enforce volume size; the new size is recorded but not a hard limit.");
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
|
|
138
93
|
vol
|
|
139
94
|
.command("rm")
|
|
140
95
|
.alias("delete")
|
package/src/lib/api.ts
CHANGED
|
@@ -226,9 +226,8 @@ async function request<T = any>(
|
|
|
226
226
|
return JSON.parse(text) as T;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
/**
|
|
230
|
-
|
|
231
|
-
export async function getRawText(path: string): Promise<string> {
|
|
229
|
+
/** Read a response as bytes, preserving binary files and text alike. */
|
|
230
|
+
export async function getRawBytes(path: string): Promise<Buffer> {
|
|
232
231
|
const url = baseURL + path;
|
|
233
232
|
const token = _accessToken || getToken();
|
|
234
233
|
const headers: Record<string, string> = clientHeaders();
|
|
@@ -242,7 +241,11 @@ export async function getRawText(path: string): Promise<string> {
|
|
|
242
241
|
} catch {}
|
|
243
242
|
throw apiErrorFrom(res.status, res.statusText, body);
|
|
244
243
|
}
|
|
245
|
-
return res.
|
|
244
|
+
return Buffer.from(await res.arrayBuffer());
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function getRawText(path: string): Promise<string> {
|
|
248
|
+
return (await getRawBytes(path)).toString("utf8");
|
|
246
249
|
}
|
|
247
250
|
|
|
248
251
|
export const api = {
|
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.28";
|
|
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
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import { once } from "node:events";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
import { WebSocketServer } from "ws";
|
|
10
|
+
import { execStream, registerSandbox } from "../../src/commands/sandbox.js";
|
|
11
|
+
import { getBaseURL, setBaseURL, setAccessToken } from "../../src/lib/api.js";
|
|
12
|
+
import { startVncTunnel, vncTarget } from "../../src/commands/sandbox-vnc.js";
|
|
13
|
+
vi.mock("../../src/lib/format.js", () => ({ isJSONMode: () => true, printJSON: vi.fn() }));
|
|
14
|
+
|
|
15
|
+
const originalURL = getBaseURL();
|
|
16
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "sandbox-transport-"));
|
|
17
|
+
let respond: (req: http.IncomingMessage, res: http.ServerResponse) => void;
|
|
18
|
+
const server = http.createServer((req, res) => respond(req, res));
|
|
19
|
+
let base: string;
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
server.listen(0, "127.0.0.1"); await once(server, "listening");
|
|
22
|
+
base = `http://127.0.0.1:${(server.address() as net.AddressInfo).port}`;
|
|
23
|
+
setBaseURL(base); setAccessToken("test-token");
|
|
24
|
+
});
|
|
25
|
+
afterAll(async () => { setBaseURL(originalURL); setAccessToken(""); await new Promise<void>(r => server.close(() => r())); fs.rmSync(tmp, { recursive: true }); });
|
|
26
|
+
beforeEach(() => { respond = (_req, res) => res.end(); });
|
|
27
|
+
function command(args: string[]) {
|
|
28
|
+
const program = new Command().exitOverride().configureOutput({ writeErr: () => {} });
|
|
29
|
+
registerSandbox(program);
|
|
30
|
+
return program.parseAsync(["sandbox", ...args], { from: "user" });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("sandbox exec stream", () => {
|
|
34
|
+
it("decodes split UTF-8, CRLF and a final exit without a newline", async () => {
|
|
35
|
+
const output: [string, string][] = [];
|
|
36
|
+
respond = (req, res) => {
|
|
37
|
+
expect(req.headers.authorization).toBe("Bearer test-token");
|
|
38
|
+
expect(req.headers["user-agent"]).toMatch(/^lizard-cli\//);
|
|
39
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
40
|
+
const bytes = Buffer.from('event: output\r\ndata: {"stream":"stderr",\r\ndata: "line":"привет"}\r\n\r\nevent: exit\ndata: {"exitCode":7}');
|
|
41
|
+
for (let i = 0; i < bytes.length; i++) res.write(bytes.subarray(i, i + 1));
|
|
42
|
+
res.end();
|
|
43
|
+
};
|
|
44
|
+
expect(await execStream("sb", "test", (s, l) => output.push([s, l]))).toBe(7);
|
|
45
|
+
expect(output).toEqual([["stderr", "привет"]]);
|
|
46
|
+
});
|
|
47
|
+
it.each(["", 'event: output\ndata: {"line":"partial"}\n\n'])("rejects an incomplete stream", async body => {
|
|
48
|
+
respond = (_req, res) => res.end(body);
|
|
49
|
+
await expect(execStream("sb", "test", () => {})).rejects.toMatchObject({ code: "EXEC_STREAM_INCOMPLETE" });
|
|
50
|
+
});
|
|
51
|
+
it.each([null, -1, 256, 1.5, "0"])("rejects invalid exit code %s", async exitCode => {
|
|
52
|
+
respond = (_req, res) => res.end(`event: exit\ndata: ${JSON.stringify({ exitCode })}\n\n`);
|
|
53
|
+
await expect(execStream("sb", "test", () => {})).rejects.toThrow("Invalid exec exit event");
|
|
54
|
+
});
|
|
55
|
+
it.each([401, 403, 404, 408, 504])("preserves HTTP %i as an APIError", async status => {
|
|
56
|
+
respond = (_req, res) => { res.writeHead(status); res.end(JSON.stringify({ error: "test failure", code: "TEST" })); };
|
|
57
|
+
await expect(execStream("sb", "test", () => {})).rejects.toMatchObject({ status, message: "test failure", code: "TEST" });
|
|
58
|
+
});
|
|
59
|
+
it("accepts a complete success", async () => {
|
|
60
|
+
respond = (_req, res) => res.end('event: exit\ndata: {"exitCode":0}\n\n');
|
|
61
|
+
expect(await execStream("sb", "true", () => {})).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("binary files", () => {
|
|
66
|
+
it.each([Buffer.from([0, 255, 128, 13, 10, 195, 40]), Buffer.alloc(0)])("round-trips arbitrary bytes", async bytes => {
|
|
67
|
+
const input = path.join(tmp, "input.bin"), output = path.join(tmp, "output.bin");
|
|
68
|
+
fs.writeFileSync(input, bytes);
|
|
69
|
+
let stored = Buffer.alloc(0);
|
|
70
|
+
respond = (req, res) => {
|
|
71
|
+
if (req.method === "POST") {
|
|
72
|
+
let body = ""; req.on("data", b => body += b); req.on("end", () => {
|
|
73
|
+
const data = JSON.parse(body); expect(data.encoding).toBe("base64");
|
|
74
|
+
expect(data.path).toBe("/workspace/binary"); stored = Buffer.from(data.content, data.encoding);
|
|
75
|
+
res.setHeader("Content-Type", "application/json"); res.end('{"status":"written"}');
|
|
76
|
+
});
|
|
77
|
+
} else { res.setHeader("Content-Type", "application/octet-stream"); res.end(stored); }
|
|
78
|
+
};
|
|
79
|
+
await command(["files", "put", "sb", input, "/workspace/binary"]);
|
|
80
|
+
await command(["files", "get", "sb", "/workspace/binary", output]);
|
|
81
|
+
expect(fs.readFileSync(output)).toEqual(bytes);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("ports", () => {
|
|
86
|
+
it.each(["3000oops", "3.5", "0", "65536", "-1"])("rejects %s before a request", async port => {
|
|
87
|
+
let requests = 0; respond = (_req, res) => { requests++; res.end('{}'); };
|
|
88
|
+
await expect(command(["vnc", "sb", "--port", port])).rejects.toThrow(/Port must be/);
|
|
89
|
+
expect(requests).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("private VNC tunnel", () => {
|
|
94
|
+
it.each(["control", "view"])("forwards both tokens for %s sessions", async token => {
|
|
95
|
+
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1", verifyClient: info => info.req.headers["x-lizard-access-token"] === "private" });
|
|
96
|
+
await once(wss, "listening");
|
|
97
|
+
const port = (wss.address() as net.AddressInfo).port;
|
|
98
|
+
const target = vncTarget(`http://127.0.0.1:${port}/stream.html?token=${token}&password=pw&lizard_token=private`);
|
|
99
|
+
let upstreamPath = "";
|
|
100
|
+
wss.on("connection", (ws, req) => { upstreamPath = req.url!; ws.send(Buffer.from("RFB 003.008\n")); });
|
|
101
|
+
const tunnel = await startVncTunnel(target.wsUrl, 19590, {}, target.headers);
|
|
102
|
+
const sock = net.connect(tunnel.port, "127.0.0.1");
|
|
103
|
+
try {
|
|
104
|
+
const [bytes] = await once(sock, "data");
|
|
105
|
+
expect(bytes.toString()).toBe("RFB 003.008\n");
|
|
106
|
+
expect(upstreamPath).toBe(`/websockify?token=${token}`);
|
|
107
|
+
} finally { sock.destroy(); tunnel.close(); for (const ws of wss.clients) ws.terminate(); await new Promise<void>(r => wss.close(() => r())); }
|
|
108
|
+
});
|
|
109
|
+
it("supports older noVNC URLs", () => {
|
|
110
|
+
expect(vncTarget("https://desktop/vnc.html?path=websockify%3Ftoken%3Da%252Bb&password=pw")).toEqual({ wsUrl: "wss://desktop/websockify?token=a%2Bb", password: "pw", headers: {} });
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -47,7 +47,7 @@ describe("sandbox create timeout", () => {
|
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
describe("unsupported sandbox commands", () => {
|
|
50
|
-
it.each(["fork", "snapshot-fork"])(
|
|
50
|
+
it.each(["fork", "snapshot-fork", "logs"])(
|
|
51
51
|
"rejects %s without making an API request", async (name) => {
|
|
52
52
|
vi.clearAllMocks();
|
|
53
53
|
const program = new Command().exitOverride().configureOutput({ writeErr: () => {} });
|
package/test/unit/volume.test.ts
CHANGED
|
@@ -1,92 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { expect, it, vi } from "vitest";
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import { registerVolume } from "../../src/commands/volume.js";
|
|
4
|
-
import { api
|
|
5
|
-
import { printJSON } from "../../src/lib/format.js";
|
|
4
|
+
import { api } from "../../src/lib/api.js";
|
|
6
5
|
|
|
7
6
|
vi.mock("../../src/lib/api.js", async (importOriginal) => {
|
|
8
7
|
const actual = await importOriginal<typeof import("../../src/lib/api.js")>();
|
|
9
8
|
return { ...actual, api: { get: vi.fn(), patch: vi.fn() } };
|
|
10
9
|
});
|
|
11
|
-
vi.mock("../../src/lib/resolve.js", () => ({
|
|
12
|
-
resolveProjectScope: vi.fn().mockResolvedValue({ projectId: "project-test", scope: { workspaceId: "workspace-test" } }),
|
|
13
|
-
}));
|
|
14
|
-
vi.mock("../../src/lib/format.js", () => ({
|
|
15
|
-
isJSONMode: () => true,
|
|
16
|
-
printJSON: vi.fn(),
|
|
17
|
-
success: vi.fn(),
|
|
18
|
-
info: vi.fn(),
|
|
19
|
-
warn: vi.fn(),
|
|
20
|
-
table: vi.fn(),
|
|
21
|
-
isTTY: () => false,
|
|
22
|
-
}));
|
|
23
10
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
function resize(args: string[]) {
|
|
27
|
-
const program = new Command().exitOverride();
|
|
11
|
+
it("rejects the removed resize command without making API requests", async () => {
|
|
12
|
+
const program = new Command().exitOverride().configureOutput({ writeErr: () => {} });
|
|
28
13
|
registerVolume(program);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
vi.mocked(api.get).mockResolvedValue(VOLUME);
|
|
36
|
-
vi.mocked(api.patch).mockResolvedValue({ ...VOLUME, sizeGb: 10, sizeEnforced: true });
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("PATCHes the volume by name with the new size and prints the result", async () => {
|
|
40
|
-
await resize(["build-cache", "--size", "10"]);
|
|
41
|
-
expect(api.patch).toHaveBeenCalledWith(
|
|
42
|
-
"/api/projects/project-test/volumes/build-cache?workspaceId=workspace-test",
|
|
43
|
-
{ sizeGb: 10 },
|
|
44
|
-
);
|
|
45
|
-
expect(printJSON).toHaveBeenCalledWith(expect.objectContaining({ sizeGb: 10, sizeEnforced: true }));
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("resolves a volume given by ID and addresses the PATCH by its name", async () => {
|
|
49
|
-
await resize([VOLUME.id, "--size", "10"]);
|
|
50
|
-
expect(api.get).toHaveBeenCalledWith(
|
|
51
|
-
`/api/projects/project-test/volumes/${VOLUME.id}?workspaceId=workspace-test`,
|
|
52
|
-
);
|
|
53
|
-
expect(api.patch).toHaveBeenCalledWith(
|
|
54
|
-
"/api/projects/project-test/volumes/build-cache?workspaceId=workspace-test",
|
|
55
|
-
{ sizeGb: 10 },
|
|
56
|
-
);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("allows a shrink", async () => {
|
|
60
|
-
vi.mocked(api.patch).mockResolvedValue({ ...VOLUME, sizeGb: 2 });
|
|
61
|
-
await resize(["build-cache", "--size", "2"]);
|
|
62
|
-
expect(api.patch).toHaveBeenCalledWith(expect.any(String), { sizeGb: 2 });
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it("makes no request when the size is unchanged", async () => {
|
|
66
|
-
await resize(["build-cache", "--size", "5"]);
|
|
67
|
-
expect(api.patch).not.toHaveBeenCalled();
|
|
68
|
-
expect(printJSON).toHaveBeenCalledWith(VOLUME);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("surfaces the server's error message", async () => {
|
|
72
|
-
vi.mocked(api.patch).mockRejectedValue(
|
|
73
|
-
new APIError(409, "Volume has 4.8 GB used; shrinking to 5 GB would leave less than 10% free", "volume_too_full_to_shrink"),
|
|
74
|
-
);
|
|
75
|
-
await expect(resize(["build-cache", "--size", "3"])).rejects.toMatchObject({
|
|
76
|
-
status: 409,
|
|
77
|
-
code: "volume_too_full_to_shrink",
|
|
78
|
-
message: expect.stringMatching(/less than 10% free/),
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it.each(["0", "-1", "1.5", "ten"])("rejects --size %s before making a request", async (value) => {
|
|
83
|
-
await expect(resize(["build-cache", "--size", value])).rejects.toThrow(/--size|Invalid number/);
|
|
84
|
-
expect(api.get).not.toHaveBeenCalled();
|
|
85
|
-
expect(api.patch).not.toHaveBeenCalled();
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("requires --size", async () => {
|
|
89
|
-
await expect(resize(["build-cache"])).rejects.toThrow();
|
|
90
|
-
expect(api.patch).not.toHaveBeenCalled();
|
|
91
|
-
});
|
|
14
|
+
const volume = program.commands.find(command => command.name() === "volume")!;
|
|
15
|
+
expect(volume.commands.flatMap(command => [command.name(), ...command.aliases()])).not.toContain("resize");
|
|
16
|
+
await expect(program.parseAsync(["volume", "resize", "build-cache", "--size", "10"], { from: "user" }))
|
|
17
|
+
.rejects.toMatchObject({ code: "commander.unknownCommand" });
|
|
18
|
+
expect(api.get).not.toHaveBeenCalled();
|
|
19
|
+
expect(api.patch).not.toHaveBeenCalled();
|
|
92
20
|
});
|