@lizard-build/cli 4.0.44 → 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/AGENTS.md +3 -0
- 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/skill.js +89 -16
- package/dist/commands/skill.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/index.js +2 -1
- package/dist/index.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.d.ts +2 -0
- package/dist/lib/skills-data.generated.js +6 -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/scripts/gen-skills.mjs +24 -5
- package/skill-data/core/SKILL.md +122 -377
- package/skill-data/core/references/deploys.md +201 -0
- package/skill-data/core/references/sandboxes.md +169 -0
- package/skill-data/core/references/sdk.md +227 -0
- 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/skill.ts +95 -21
- 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/index.ts +4 -1
- 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/test/unit/skills.test.ts +31 -12
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/index.ts
CHANGED
|
@@ -38,6 +38,9 @@ const AGENTS_HELP = [
|
|
|
38
38
|
chalk.dim(
|
|
39
39
|
" copy-paste examples. Prefer this over guessing commands from flag docs alone.",
|
|
40
40
|
),
|
|
41
|
+
chalk.dim(
|
|
42
|
+
" For more depth: lizard skills get core --ref sandboxes | deploys | sdk",
|
|
43
|
+
),
|
|
41
44
|
].join("\n");
|
|
42
45
|
|
|
43
46
|
// Compact form of the same pointer, shown on every subcommand's `--help` so the
|
|
@@ -387,7 +390,7 @@ async function main() {
|
|
|
387
390
|
version: CURRENT_VERSION,
|
|
388
391
|
agents: {
|
|
389
392
|
start: "lizard skills get core",
|
|
390
|
-
note: "Skills ship with this CLI version and cover sandboxes and the full app lifecycle (deploy, link, addons, logs, scaling, secrets, domains) with copy-paste examples. Prefer this over guessing commands from flag docs alone.",
|
|
393
|
+
note: "Skills ship with this CLI version and cover sandboxes and the full app lifecycle (deploy, link, addons, logs, scaling, secrets, domains) with copy-paste examples. Prefer this over guessing commands from flag docs alone. For more depth: `lizard skills get core --ref sandboxes|deploys|sdk`.",
|
|
391
394
|
},
|
|
392
395
|
command: dumpCommand(target),
|
|
393
396
|
globalOptions: isRoot
|
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
|
|
package/test/unit/skills.test.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
2
|
import { EMBEDDED_SKILLS } from "../../src/lib/skills-data.generated.js";
|
|
3
3
|
|
|
4
|
-
const core = EMBEDDED_SKILLS.core
|
|
4
|
+
const core = EMBEDDED_SKILLS.core;
|
|
5
|
+
const files: Record<string, string> = {
|
|
6
|
+
"SKILL.md": core.content,
|
|
7
|
+
...Object.fromEntries(Object.entries(core.references).map(([ref, md]) => [`references/${ref}.md`, md])),
|
|
8
|
+
};
|
|
5
9
|
|
|
6
10
|
/** GitHub-style heading slug, which the skill's own links use. */
|
|
7
11
|
const slug = (h: string) => h.toLowerCase().replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-");
|
|
@@ -11,25 +15,40 @@ describe("the core skill", () => {
|
|
|
11
15
|
expect(Object.keys(EMBEDDED_SKILLS)).toEqual(["core"]);
|
|
12
16
|
});
|
|
13
17
|
|
|
14
|
-
test("
|
|
15
|
-
expect(core).
|
|
16
|
-
for (const
|
|
17
|
-
expect(core).toContain(
|
|
18
|
+
test("ships the sandboxes, deploys and sdk references, and names each one", () => {
|
|
19
|
+
expect(Object.keys(core.references).sort()).toEqual(["deploys", "sandboxes", "sdk"]);
|
|
20
|
+
for (const ref of Object.keys(core.references)) {
|
|
21
|
+
expect(core.content).toContain(`--ref ${ref}`);
|
|
18
22
|
}
|
|
19
|
-
|
|
20
|
-
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("covers the whole platform in SKILL.md", () => {
|
|
26
|
+
for (const h of ["Sign-in", "Exit codes", "Default project", "Sandboxes", "Deploys", "Databases and storage", "Secrets and config", "Domains", "Billing"]) {
|
|
27
|
+
expect(core.content).toContain(`\n## ${h}\n`);
|
|
21
28
|
}
|
|
22
|
-
expect(core).not.toContain("lizard skills get sandbox");
|
|
29
|
+
expect(core.content).not.toContain("lizard skills get sandbox ");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("keeps SKILL.md under 500 lines", () => {
|
|
33
|
+
const body = core.content.replace(/^---\n[\s\S]*?\n---\n/, "");
|
|
34
|
+
expect(body.split("\n").length).toBeLessThan(500);
|
|
23
35
|
});
|
|
24
36
|
|
|
25
|
-
test("every #link points at a heading in the
|
|
26
|
-
const
|
|
27
|
-
const
|
|
37
|
+
test.each(Object.keys(files))("every #link in %s points at a heading in the same file", (file) => {
|
|
38
|
+
const md = files[file];
|
|
39
|
+
const headings = new Set([...md.matchAll(/^#{1,4} (.+)$/gm)].map((m) => slug(m[1])));
|
|
40
|
+
const links = [...md.matchAll(/\]\(#([\w-]+)\)/g)].map((m) => m[1]);
|
|
28
41
|
expect(links.filter((l) => !headings.has(l))).toEqual([]);
|
|
29
42
|
});
|
|
30
43
|
|
|
44
|
+
test("references never link to other files", () => {
|
|
45
|
+
for (const md of Object.values(core.references)) {
|
|
46
|
+
expect(md).not.toMatch(/\]\((?!https?:|#)[^)]+\)/);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
31
50
|
test("its description fits the 1024-character limit", () => {
|
|
32
|
-
const description = core.match(/^description: "(.*)"$/m)![1];
|
|
51
|
+
const description = core.content.match(/^description: "(.*)"$/m)![1];
|
|
33
52
|
expect(description.length).toBeLessThanOrEqual(1024);
|
|
34
53
|
expect(description).toMatch(/^Creates cloud sandboxes and deploys apps/);
|
|
35
54
|
});
|