@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/add.ts
CHANGED
|
@@ -273,7 +273,6 @@ async function runAdd(input: AddInput): Promise<void> {
|
|
|
273
273
|
if (opts.variables?.length) {
|
|
274
274
|
info(chalk.yellow("Warning: --variables is ignored for managed addons"));
|
|
275
275
|
}
|
|
276
|
-
const isSingle = databases.length === 1;
|
|
277
276
|
for (const db of databases) {
|
|
278
277
|
const cat = CATALOG.find((c) => c.name === db)!;
|
|
279
278
|
info(`Adding ${chalk.cyan(cat.label)}...`);
|
|
@@ -303,11 +302,8 @@ async function runAdd(input: AddInput): Promise<void> {
|
|
|
303
302
|
}
|
|
304
303
|
}
|
|
305
304
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
updateProjectLink({ serviceId: addon.id, serviceName: addon.name });
|
|
309
|
-
} catch {}
|
|
310
|
-
}
|
|
305
|
+
// The folder keeps its service link: linking it to the addon sent the next
|
|
306
|
+
// `lizard up` / `secrets set` / `logs --build` to a database ("App not found").
|
|
311
307
|
}
|
|
312
308
|
return;
|
|
313
309
|
}
|
package/src/commands/agents.ts
CHANGED
|
@@ -3,7 +3,7 @@ import ora from "ora";
|
|
|
3
3
|
import open from "open";
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
import * as p from "@clack/prompts";
|
|
6
|
-
import { api } from "../lib/api.js";
|
|
6
|
+
import { api, APIError } from "../lib/api.js";
|
|
7
7
|
import { success, info, isJSONMode, printJSON, table, timeAgo } from "../lib/format.js";
|
|
8
8
|
|
|
9
9
|
// Built-in coding agents (platform: server/src/routes/agents.ts), Boat-style.
|
|
@@ -185,6 +185,24 @@ function render(e: AgentEvent, verbose: boolean) {
|
|
|
185
185
|
}
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
/**
|
|
189
|
+
* One page of a conversation's events. The agent keeps working in the sandbox while we
|
|
190
|
+
* watch, so a dropped connection or a 5xx must not end the watch: retry for about two
|
|
191
|
+
* minutes before giving up.
|
|
192
|
+
*/
|
|
193
|
+
async function pollEvents(id: string, conversationId: string, after: number) {
|
|
194
|
+
const path = `/api/sandboxes/${id}/events?after=${after}&conversationId=${conversationId}&limit=500`;
|
|
195
|
+
for (let attempt = 1; ; attempt++) {
|
|
196
|
+
try {
|
|
197
|
+
return await api.get<{ events: AgentEvent[]; next: number }>(path);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
const transient = !(e instanceof APIError) || e.status === 429 || e.status >= 500;
|
|
200
|
+
if (!transient || attempt >= 24) throw e;
|
|
201
|
+
await sleep(Math.min(1000 * attempt, 5000));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
188
206
|
export function registerSandboxAgentCommands(sb: Command) {
|
|
189
207
|
sb.command("prompt")
|
|
190
208
|
.argument("<id>", "Sandbox ID")
|
|
@@ -223,9 +241,7 @@ Examples:
|
|
|
223
241
|
});
|
|
224
242
|
let after = 0;
|
|
225
243
|
for (;;) {
|
|
226
|
-
const r = await
|
|
227
|
-
`/api/sandboxes/${id}/events?after=${after}&conversationId=${started.conversationId}&limit=500`,
|
|
228
|
-
);
|
|
244
|
+
const r = await pollEvents(id, started.conversationId, after);
|
|
229
245
|
after = r.next ?? after;
|
|
230
246
|
for (const e of r.events) {
|
|
231
247
|
if (e.promptId !== started.promptId) continue;
|
package/src/commands/domain.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { Command } from "commander";
|
|
|
4
4
|
import { api } from "../lib/api.js";
|
|
5
5
|
import { resolveProjectId } from "../lib/config.js";
|
|
6
6
|
import { getActiveService } from "../lib/resolve.js";
|
|
7
|
-
import { success, isJSONMode, printJSON, table, isTTY } from "../lib/format.js";
|
|
7
|
+
import { success, isJSONMode, printJSON, table, isTTY, info } from "../lib/format.js";
|
|
8
8
|
|
|
9
9
|
interface DomainResponse {
|
|
10
10
|
ok?: boolean;
|
|
@@ -92,7 +92,14 @@ export function registerDomain(program: Command) {
|
|
|
92
92
|
return;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
// Attach custom hostname
|
|
95
|
+
// Attach custom hostname. Anything without a dot is a typo or a missing subcommand
|
|
96
|
+
// (`lizard domain list` once attached a domain literally named "list").
|
|
97
|
+
if (!/^(?=.{4,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$/i.test(hostname)) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`"${hostname}" is not a domain name. Attach a full hostname such as app.example.com; ` +
|
|
100
|
+
"`lizard domain list` shows the service's domains.",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
96
103
|
const result = await api
|
|
97
104
|
.post<DomainResponse>(`/api/apps/${service.id}/domains`, {
|
|
98
105
|
hostname,
|
|
@@ -250,6 +257,33 @@ export function registerDomain(program: Command) {
|
|
|
250
257
|
}
|
|
251
258
|
});
|
|
252
259
|
|
|
260
|
+
dom
|
|
261
|
+
.command("list")
|
|
262
|
+
.alias("ls")
|
|
263
|
+
.description("List the service's domains, and custom domains still waiting for DNS verification")
|
|
264
|
+
.action(async (_opts, sub) => {
|
|
265
|
+
const opts = sub.optsWithGlobals();
|
|
266
|
+
const projectId = await resolveProjectId(opts.project);
|
|
267
|
+
const service = await getActiveService(opts.service, projectId);
|
|
268
|
+
const r = await api.get<{
|
|
269
|
+
domains?: string[]; generatedDomain?: string | null; cnameTarget?: string;
|
|
270
|
+
pending?: { hostname: string; txtRecord: string; txtValue: string; cnameTarget: string }[];
|
|
271
|
+
}>(`/api/apps/${service.id}/domains`);
|
|
272
|
+
if (isJSONMode()) return printJSON({ service: service.name, ...r });
|
|
273
|
+
const active = r.domains ?? [];
|
|
274
|
+
if (!active.length && !(r.pending ?? []).length) {
|
|
275
|
+
info(`${service.name} has no domain yet. Generate one: lizard domain --service ${service.name}`);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
for (const d of active) console.log(`${chalk.green("●")} https://${d}`);
|
|
279
|
+
for (const p of r.pending ?? []) {
|
|
280
|
+
console.log(`${chalk.yellow("○")} ${p.hostname} ${chalk.dim("(waiting for DNS)")}`);
|
|
281
|
+
console.log(chalk.dim(` TXT ${p.txtRecord} ${p.txtValue}`));
|
|
282
|
+
console.log(chalk.dim(` CNAME ${p.hostname} ${p.cnameTarget}`));
|
|
283
|
+
console.log(chalk.dim(` then: lizard domain verify ${p.hostname} --service ${service.name}`));
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
253
287
|
dom
|
|
254
288
|
.command("verify")
|
|
255
289
|
.argument("<hostname>", "Custom domain to verify")
|
package/src/commands/init.ts
CHANGED
|
@@ -83,6 +83,24 @@ export async function ensureLinked(opts: {
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// A named project may live in any workspace. Look it up everywhere first: the flow
|
|
87
|
+
// below searches one workspace only and creates the project when that workspace has
|
|
88
|
+
// no match, which made a duplicate of a project in another workspace (and, for a key
|
|
89
|
+
// limited to that project, failed with 404 "Project not found").
|
|
90
|
+
if (opts.projectName && !opts.workspaceFlag) {
|
|
91
|
+
const lower = opts.projectName.toLowerCase();
|
|
92
|
+
const all = await api.get<Array<Project & { workspaceId?: string; workspaceName?: string }>>("/api/projects");
|
|
93
|
+
const matches = all.filter(
|
|
94
|
+
(pr) => pr.id.toLowerCase() === lower || pr.slug?.toLowerCase() === lower || pr.name.toLowerCase() === lower,
|
|
95
|
+
);
|
|
96
|
+
if (matches.length === 1) {
|
|
97
|
+
const m = matches[0];
|
|
98
|
+
const link: ProjectLink = { projectId: m.id, projectName: m.name, workspaceId: m.workspaceId, workspaceName: m.workspaceName };
|
|
99
|
+
setProjectLink(link);
|
|
100
|
+
return link;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
86
104
|
// 1. Workspace
|
|
87
105
|
const workspaces = await fetchWorkspaces();
|
|
88
106
|
const workspace = await pickWorkspace({
|
package/src/commands/metrics.ts
CHANGED
|
@@ -2,7 +2,7 @@ import chalk from "chalk";
|
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import { api, withScope, withQuery, APIError, type ResourceScope } from "../lib/api.js";
|
|
4
4
|
import { getProjectLink } from "../lib/config.js";
|
|
5
|
-
import { resolveProjectScope, getActiveServiceWithKind } from "../lib/resolve.js";
|
|
5
|
+
import { resolveProjectScope, getActiveServiceWithKind, lookupProjectWorkspace } from "../lib/resolve.js";
|
|
6
6
|
import { info, fail, isJSONMode, printJSON, table, timeAgo } from "../lib/format.js";
|
|
7
7
|
|
|
8
8
|
// Shapes returned by /api/apps/:id/metrics and /api/projects/:id/metrics.
|
|
@@ -584,6 +584,11 @@ function fmtPeriodDate(ms: number): string {
|
|
|
584
584
|
}
|
|
585
585
|
|
|
586
586
|
async function showCost(projectId: string, scope: ResourceScope) {
|
|
587
|
+
// Scopes for other commands skip the workspace lookup (their routes ignore it), so an
|
|
588
|
+
// unlinked folder or --project arrives here without one: fetch it, cost needs it.
|
|
589
|
+
if (!scope.workspaceId) {
|
|
590
|
+
scope = { ...scope, workspaceId: (await lookupProjectWorkspace(projectId))?.workspaceId ?? null };
|
|
591
|
+
}
|
|
587
592
|
if (!scope.workspaceId) {
|
|
588
593
|
fail("Could not resolve the workspace for this project. Run `lizard link` first.");
|
|
589
594
|
}
|
|
@@ -599,7 +604,11 @@ async function showCost(projectId: string, scope: ResourceScope) {
|
|
|
599
604
|
]);
|
|
600
605
|
} catch (e) {
|
|
601
606
|
if (e instanceof APIError && e.status === 403) {
|
|
602
|
-
|
|
607
|
+
// A key limited to some projects can't read the workspace's billing at all; only
|
|
608
|
+
// a plain 403 means "not the owner".
|
|
609
|
+
fail(/API key/i.test(e.message)
|
|
610
|
+
? `${e.message} Costs need a key that covers the whole workspace, or a full sign-in (lizard login).`
|
|
611
|
+
: "Billing is only visible to the workspace owner.", 2);
|
|
603
612
|
}
|
|
604
613
|
throw e;
|
|
605
614
|
}
|
package/src/commands/s3.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { createHash, createHmac } from "node:crypto";
|
|
3
4
|
import chalk from "chalk";
|
|
4
5
|
import { Command } from "commander";
|
|
5
6
|
import { api, getBaseURL, withScope, APIError, type ResourceScope } from "../lib/api.js";
|
|
@@ -12,6 +13,41 @@ interface AddonLite {
|
|
|
12
13
|
name: string;
|
|
13
14
|
type?: string;
|
|
14
15
|
status?: string;
|
|
16
|
+
connection?: { endpoint?: string; accessKeyId?: string; secretKey?: string; region?: string } | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const rfc3986 = (v: string) => encodeURIComponent(v).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
|
|
20
|
+
const sha256hex = (v: string) => createHash("sha256").update(v).digest("hex");
|
|
21
|
+
const hmac = (key: Buffer | string, v: string) => createHmac("sha256", key).update(v).digest();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A SigV4 presigned GET for one object (path style, the way the S3 gateway expects it).
|
|
25
|
+
* The link works for `expiresIn` seconds without any credentials, so it is safe to hand
|
|
26
|
+
* to someone who must not get the addon's keys.
|
|
27
|
+
*/
|
|
28
|
+
export function presignGet(o: {
|
|
29
|
+
endpoint: string; accessKeyId: string; secretKey: string; region: string;
|
|
30
|
+
bucket: string; key: string; expiresIn: number; now?: Date;
|
|
31
|
+
}): string {
|
|
32
|
+
const base = new URL(o.endpoint);
|
|
33
|
+
const segments = [...base.pathname.split("/").filter(Boolean), o.bucket, ...o.key.split("/")];
|
|
34
|
+
const canonicalUri = "/" + segments.map(rfc3986).join("/");
|
|
35
|
+
const iso = (o.now ?? new Date()).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
|
|
36
|
+
const date = iso.slice(0, 8);
|
|
37
|
+
const scope = `${date}/${o.region}/s3/aws4_request`;
|
|
38
|
+
const query: Record<string, string> = {
|
|
39
|
+
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
|
|
40
|
+
"X-Amz-Credential": `${o.accessKeyId}/${scope}`,
|
|
41
|
+
"X-Amz-Date": iso,
|
|
42
|
+
"X-Amz-Expires": String(o.expiresIn),
|
|
43
|
+
"X-Amz-SignedHeaders": "host",
|
|
44
|
+
};
|
|
45
|
+
const canonicalQuery = Object.keys(query).sort().map((k) => `${rfc3986(k)}=${rfc3986(query[k])}`).join("&");
|
|
46
|
+
const canonicalRequest = ["GET", canonicalUri, canonicalQuery, `host:${base.host}\n`, "host", "UNSIGNED-PAYLOAD"].join("\n");
|
|
47
|
+
const toSign = ["AWS4-HMAC-SHA256", iso, scope, sha256hex(canonicalRequest)].join("\n");
|
|
48
|
+
const key = hmac(hmac(hmac(hmac("AWS4" + o.secretKey, date), o.region), "s3"), "aws4_request");
|
|
49
|
+
const signature = createHmac("sha256", key).update(toSign).digest("hex");
|
|
50
|
+
return `${base.protocol}//${base.host}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`;
|
|
15
51
|
}
|
|
16
52
|
|
|
17
53
|
interface ServicesResponse {
|
|
@@ -177,6 +213,35 @@ export function registerS3(program: Command) {
|
|
|
177
213
|
}
|
|
178
214
|
});
|
|
179
215
|
|
|
216
|
+
s3.command("url")
|
|
217
|
+
.description("Print a temporary download link for an object (works without credentials until it expires)")
|
|
218
|
+
.argument("<key>", "Object key, e.g. report.txt")
|
|
219
|
+
.option("--addon <name>", "S3 addon name or ID (default: the project's only S3 addon)")
|
|
220
|
+
.option("--bucket <name>", "Bucket", "default")
|
|
221
|
+
.option("--expires <seconds>", "How long the link works, in seconds (max 604800 = 7 days)", "3600")
|
|
222
|
+
.option("-p, --project <id>", "Project name, slug, or ID")
|
|
223
|
+
.action(async (key: string, opts) => {
|
|
224
|
+
const expiresIn = Number(opts.expires);
|
|
225
|
+
if (!Number.isInteger(expiresIn) || expiresIn < 1 || expiresIn > 604800) {
|
|
226
|
+
throw new Error(`--expires must be a whole number of seconds from 1 to 604800 (got ${opts.expires}).`);
|
|
227
|
+
}
|
|
228
|
+
const { projectId, scope } = await resolveProjectScope(opts.project);
|
|
229
|
+
const addon = await resolveS3AddonId(projectId, scope, opts.addon);
|
|
230
|
+
const data = await api.get<ServicesResponse>(withScope(`/api/projects/${projectId}/services`, scope));
|
|
231
|
+
const c = (data.addons ?? []).find((a) => a.id === addon.id)?.connection;
|
|
232
|
+
if (!c?.endpoint || !c.accessKeyId || !c.secretKey) {
|
|
233
|
+
throw new Error(`S3 addon ${addon.name} has no credentials yet; wait until it is running (lizard ps).`);
|
|
234
|
+
}
|
|
235
|
+
const url = presignGet({
|
|
236
|
+
endpoint: c.endpoint, accessKeyId: c.accessKeyId, secretKey: c.secretKey, region: c.region || "us-east-1",
|
|
237
|
+
bucket: opts.bucket, key, expiresIn,
|
|
238
|
+
});
|
|
239
|
+
const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
|
|
240
|
+
if (isJSONMode()) return printJSON({ url, key, bucket: opts.bucket, expiresIn, expiresAt });
|
|
241
|
+
console.log(url);
|
|
242
|
+
info(chalk.dim(`Works until ${expiresAt}. Anyone with the link can download ${key}.`));
|
|
243
|
+
});
|
|
244
|
+
|
|
180
245
|
s3.command("list")
|
|
181
246
|
.alias("ls")
|
|
182
247
|
.description("List objects in an S3 addon bucket")
|
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/skill.ts
CHANGED
|
@@ -11,11 +11,16 @@ import { EMBEDDED_SKILLS } from "../lib/skills-data.generated.js";
|
|
|
11
11
|
//
|
|
12
12
|
// LIZARD_SKILLS_DIR overrides the embedded source with a real directory — useful
|
|
13
13
|
// for authoring new skills locally without a rebuild.
|
|
14
|
+
//
|
|
15
|
+
// A skill is SKILL.md plus optional references/<ref>.md files that go deeper on
|
|
16
|
+
// one part of the platform; `lizard skills get <name> --ref <ref>` prints one.
|
|
14
17
|
|
|
15
18
|
interface Source {
|
|
16
19
|
list(): string[];
|
|
17
20
|
read(name: string): string | null;
|
|
18
|
-
|
|
21
|
+
refs(name: string): string[];
|
|
22
|
+
readRef(name: string, ref: string): string | null;
|
|
23
|
+
pathOf(name?: string, ref?: string): string;
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
function fsSource(dir: string): Source {
|
|
@@ -42,7 +47,29 @@ function fsSource(dir: string): Source {
|
|
|
42
47
|
return null;
|
|
43
48
|
}
|
|
44
49
|
},
|
|
45
|
-
|
|
50
|
+
refs: (name) => {
|
|
51
|
+
try {
|
|
52
|
+
return readdirSync(path.join(dir, name, "references"))
|
|
53
|
+
.filter((f) => f.endsWith(".md"))
|
|
54
|
+
.map((f) => f.slice(0, -3))
|
|
55
|
+
.sort();
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
readRef: (name, ref) => {
|
|
61
|
+
try {
|
|
62
|
+
return readFileSync(path.join(dir, name, "references", `${ref}.md`), "utf8");
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
pathOf: (name, ref) => {
|
|
68
|
+
if (!name) return dir;
|
|
69
|
+
return ref
|
|
70
|
+
? path.join(dir, name, "references", `${ref}.md`)
|
|
71
|
+
: path.join(dir, name, "SKILL.md");
|
|
72
|
+
},
|
|
46
73
|
};
|
|
47
74
|
}
|
|
48
75
|
|
|
@@ -50,8 +77,14 @@ function embeddedSource(): Source {
|
|
|
50
77
|
return {
|
|
51
78
|
list: () => Object.keys(EMBEDDED_SKILLS).sort(),
|
|
52
79
|
read: (name) => EMBEDDED_SKILLS[name]?.content ?? null,
|
|
53
|
-
|
|
54
|
-
|
|
80
|
+
refs: (name) => Object.keys(EMBEDDED_SKILLS[name]?.references ?? {}).sort(),
|
|
81
|
+
readRef: (name, ref) => EMBEDDED_SKILLS[name]?.references[ref] ?? null,
|
|
82
|
+
pathOf: (name, ref) => {
|
|
83
|
+
if (!name) return "embedded://skills";
|
|
84
|
+
return ref
|
|
85
|
+
? `embedded://skills/${name}/references/${ref}.md`
|
|
86
|
+
: `embedded://skills/${name}/SKILL.md`;
|
|
87
|
+
},
|
|
55
88
|
};
|
|
56
89
|
}
|
|
57
90
|
|
|
@@ -97,6 +130,29 @@ function readSkill(
|
|
|
97
130
|
return { md, meta: parseFrontmatter(md) };
|
|
98
131
|
}
|
|
99
132
|
|
|
133
|
+
function readRef(src: Source, name: string, ref: string): string {
|
|
134
|
+
readSkill(src, name);
|
|
135
|
+
const md = src.readRef(name, ref);
|
|
136
|
+
if (md == null) {
|
|
137
|
+
const refs = src.refs(name);
|
|
138
|
+
throw new Error(
|
|
139
|
+
`Reference '${ref}' not found in skill '${name}'. ` +
|
|
140
|
+
(refs.length
|
|
141
|
+
? `Available: ${refs.join(", ")}.`
|
|
142
|
+
: `This skill has no references.`),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return md;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function refEntries(src: Source, name: string) {
|
|
149
|
+
return src.refs(name).map((ref) => ({
|
|
150
|
+
name: ref,
|
|
151
|
+
path: src.pathOf(name, ref),
|
|
152
|
+
command: `lizard skills get ${name} --ref ${ref}`,
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
|
|
100
156
|
function shortDesc(desc: string, max = 100): string {
|
|
101
157
|
const oneLine = desc.replace(/\s+/g, " ").trim();
|
|
102
158
|
return oneLine.length > max ? oneLine.slice(0, max - 1) + "…" : oneLine;
|
|
@@ -118,12 +174,13 @@ export function registerSkill(program: Command) {
|
|
|
118
174
|
printJSON({
|
|
119
175
|
dir: src.pathOf(),
|
|
120
176
|
skills: names.map((n) => {
|
|
177
|
+
let description = "";
|
|
121
178
|
try {
|
|
122
|
-
|
|
123
|
-
return { name: n, description: meta.description || "" };
|
|
179
|
+
description = readSkill(src, n).meta.description || "";
|
|
124
180
|
} catch {
|
|
125
|
-
|
|
181
|
+
// unreadable SKILL.md: list it without a description
|
|
126
182
|
}
|
|
183
|
+
return { name: n, description, references: src.refs(n) };
|
|
127
184
|
}),
|
|
128
185
|
});
|
|
129
186
|
return;
|
|
@@ -133,38 +190,54 @@ export function registerSkill(program: Command) {
|
|
|
133
190
|
return;
|
|
134
191
|
}
|
|
135
192
|
const rows = names.map((n) => {
|
|
193
|
+
let description = "";
|
|
136
194
|
try {
|
|
137
|
-
|
|
138
|
-
return [n, shortDesc(meta.description || "")];
|
|
195
|
+
description = shortDesc(readSkill(src, n).meta.description || "");
|
|
139
196
|
} catch {
|
|
140
|
-
|
|
197
|
+
// unreadable SKILL.md: list it without a description
|
|
141
198
|
}
|
|
199
|
+
return [n, description, src.refs(n).join(", ")];
|
|
142
200
|
});
|
|
143
|
-
table(["name", "description"], rows);
|
|
201
|
+
table(["name", "description", "references"], rows);
|
|
144
202
|
});
|
|
145
203
|
|
|
146
204
|
skill
|
|
147
205
|
.command("get")
|
|
148
|
-
.description("Print a skill's
|
|
206
|
+
.description("Print a skill's content (markdown), or one of its references")
|
|
149
207
|
.argument("<name>", "skill name (e.g. core)")
|
|
150
|
-
.option(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
)
|
|
154
|
-
.action((name: string, _opts: { full?: boolean }) => {
|
|
208
|
+
.option("--ref <ref>", "print one reference instead (e.g. sandboxes, deploys, sdk)")
|
|
209
|
+
.option("--full", "append every reference after the skill")
|
|
210
|
+
.action((name: string, opts: { ref?: string; full?: boolean }) => {
|
|
155
211
|
const src = getSource();
|
|
156
212
|
try {
|
|
213
|
+
if (opts.ref) {
|
|
214
|
+
const md = readRef(src, name, opts.ref);
|
|
215
|
+
if (isJSONMode()) {
|
|
216
|
+
printJSON({ name, ref: opts.ref, path: src.pathOf(name, opts.ref), content: md });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
process.stdout.write(md.endsWith("\n") ? md : md + "\n");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
157
222
|
const { md, meta } = readSkill(src, name);
|
|
223
|
+
let content = md;
|
|
224
|
+
if (opts.full) {
|
|
225
|
+
for (const ref of src.refs(name)) {
|
|
226
|
+
const body = src.readRef(name, ref) ?? "";
|
|
227
|
+
content = `${content.trimEnd()}\n\n---\n<!-- references/${ref}.md -->\n\n${body}`;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
158
230
|
if (isJSONMode()) {
|
|
159
231
|
printJSON({
|
|
160
232
|
name,
|
|
161
233
|
path: src.pathOf(name),
|
|
162
234
|
frontmatter: meta,
|
|
163
|
-
content
|
|
235
|
+
content,
|
|
236
|
+
references: refEntries(src, name),
|
|
164
237
|
});
|
|
165
238
|
return;
|
|
166
239
|
}
|
|
167
|
-
process.stdout.write(
|
|
240
|
+
process.stdout.write(content.endsWith("\n") ? content : content + "\n");
|
|
168
241
|
} catch (e: any) {
|
|
169
242
|
fail(e.message, 3);
|
|
170
243
|
}
|
|
@@ -174,9 +247,10 @@ export function registerSkill(program: Command) {
|
|
|
174
247
|
.command("path")
|
|
175
248
|
.description("Print the filesystem path of a skill (or the skills root)")
|
|
176
249
|
.argument("[name]", "skill name; omit to print the skills directory")
|
|
177
|
-
.
|
|
250
|
+
.option("--ref <ref>", "print the path of one reference instead")
|
|
251
|
+
.action((name: string | undefined, opts: { ref?: string }) => {
|
|
178
252
|
const src = getSource();
|
|
179
|
-
const target = src.pathOf(name);
|
|
253
|
+
const target = src.pathOf(name, name ? opts.ref : undefined);
|
|
180
254
|
if (isJSONMode()) {
|
|
181
255
|
printJSON({ path: target });
|
|
182
256
|
return;
|
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
|
}
|