@lizard-build/cli 0.3.95 → 0.3.97

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.
@@ -0,0 +1,29 @@
1
+ import { type ResourceScope } from "./api.js";
2
+ export interface VolumeRecord {
3
+ id: string;
4
+ name: string;
5
+ sizeGb: number;
6
+ status: string;
7
+ attachedTo?: string | null;
8
+ createdAt?: number;
9
+ }
10
+ /**
11
+ * A volume's name is its key inside a project (LIZARD-161), so it has to be a shape
12
+ * that survives a URL, a mount path and a shell argument. Validated here as well as
13
+ * on the server so a typo is reported before the request goes out.
14
+ */
15
+ export declare const VOLUME_NAME_RE: RegExp;
16
+ export declare function assertValidVolumeName(name: string): void;
17
+ /**
18
+ * Resolve a volume by name or ID through the server.
19
+ *
20
+ * This used to be a client-side `.find()` over the project's volume list, duplicated
21
+ * in `volume.ts` and `sandbox.ts`. Nothing enforced name uniqueness back then, so with
22
+ * duplicates present it silently picked whichever row came back first — you asked for
23
+ * your volume and mounted someone else's. The server now resolves a name itself and
24
+ * names are unique per project.
25
+ *
26
+ * The list fallback is only for talking to a server older than that change; it refuses
27
+ * to guess when a name is ambiguous rather than repeating the original bug.
28
+ */
29
+ export declare function resolveVolume(projectId: string, scope: ResourceScope, nameOrId: string): Promise<VolumeRecord>;
@@ -0,0 +1,46 @@
1
+ import { api, withScope } from "./api.js";
2
+ /**
3
+ * A volume's name is its key inside a project (LIZARD-161), so it has to be a shape
4
+ * that survives a URL, a mount path and a shell argument. Validated here as well as
5
+ * on the server so a typo is reported before the request goes out.
6
+ */
7
+ export const VOLUME_NAME_RE = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/;
8
+ export function assertValidVolumeName(name) {
9
+ if (VOLUME_NAME_RE.test(name))
10
+ return;
11
+ throw new Error(`Invalid volume name "${name}". Use lowercase letters, digits and dashes, ` +
12
+ `starting and ending with a letter or digit (max 64 chars) — e.g. "build-cache".`);
13
+ }
14
+ /**
15
+ * Resolve a volume by name or ID through the server.
16
+ *
17
+ * This used to be a client-side `.find()` over the project's volume list, duplicated
18
+ * in `volume.ts` and `sandbox.ts`. Nothing enforced name uniqueness back then, so with
19
+ * duplicates present it silently picked whichever row came back first — you asked for
20
+ * your volume and mounted someone else's. The server now resolves a name itself and
21
+ * names are unique per project.
22
+ *
23
+ * The list fallback is only for talking to a server older than that change; it refuses
24
+ * to guess when a name is ambiguous rather than repeating the original bug.
25
+ */
26
+ export async function resolveVolume(projectId, scope, nameOrId) {
27
+ try {
28
+ return await api.get(withScope(`/api/projects/${projectId}/volumes/${encodeURIComponent(nameOrId)}`, scope));
29
+ }
30
+ catch (e) {
31
+ if (e?.status && e.status !== 404)
32
+ throw e;
33
+ }
34
+ const volumes = await api.get(withScope(`/api/projects/${projectId}/volumes`, scope));
35
+ const lower = nameOrId.toLowerCase();
36
+ const matches = volumes.filter((v) => v.id.toLowerCase() === lower || v.name.toLowerCase() === lower);
37
+ if (matches.length > 1) {
38
+ throw new Error(`Volume "${nameOrId}" is ambiguous — ${matches.length} volumes in this project share that name ` +
39
+ `(${matches.map((v) => v.id).join(", ")}). Delete the duplicates, or pass the ID you want.`);
40
+ }
41
+ if (matches.length === 0) {
42
+ throw new Error(`Volume "${nameOrId}" not found. Available: ${volumes.map((v) => v.name).join(", ") || "(none)"}`);
43
+ }
44
+ return matches[0];
45
+ }
46
+ //# sourceMappingURL=volume.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"volume.js","sourceRoot":"","sources":["../../src/lib/volume.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,SAAS,EAAsB,MAAM,UAAU,CAAC;AAW9D;;;;GAIG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,sCAAsC,CAAC;AAErE,MAAM,UAAU,qBAAqB,CAAC,IAAY;IAChD,IAAI,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO;IACtC,MAAM,IAAI,KAAK,CACb,wBAAwB,IAAI,+CAA+C;QACzE,iFAAiF,CACpF,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,SAAiB,EACjB,KAAoB,EACpB,QAAgB;IAEhB,IAAI,CAAC;QACH,OAAO,MAAM,GAAG,CAAC,GAAG,CAClB,SAAS,CAAC,iBAAiB,SAAS,YAAY,kBAAkB,CAAC,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC,CACvF,CAAC;IACJ,CAAC;IAAC,OAAO,CAAM,EAAE,CAAC;QAChB,IAAI,CAAC,EAAE,MAAM,IAAI,CAAC,CAAC,MAAM,KAAK,GAAG;YAAE,MAAM,CAAC,CAAC;IAC7C,CAAC;IAED,MAAM,OAAO,GAAG,MAAM,GAAG,CAAC,GAAG,CAC3B,SAAS,CAAC,iBAAiB,SAAS,UAAU,EAAE,KAAK,CAAC,CACvD,CAAC;IACF,MAAM,KAAK,GAAG,QAAQ,CAAC,WAAW,EAAE,CAAC;IACrC,MAAM,OAAO,GAAG,OAAO,CAAC,MAAM,CAC5B,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,WAAW,EAAE,KAAK,KAAK,IAAI,CAAC,CAAC,IAAI,CAAC,WAAW,EAAE,KAAK,KAAK,CACtE,CAAC;IACF,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACvB,MAAM,IAAI,KAAK,CACb,WAAW,QAAQ,oBAAoB,OAAO,CAAC,MAAM,2CAA2C;YAC9F,IAAI,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,oDAAoD,CAC9F,CAAC;IACJ,CAAC;IACD,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACzB,MAAM,IAAI,KAAK,CACb,WAAW,QAAQ,2BAA2B,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,QAAQ,EAAE,CAClG,CAAC;IACJ,CAAC;IACD,OAAO,OAAO,CAAC,CAAC,CAAC,CAAC;AACpB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lizard-build/cli",
3
- "version": "0.3.95",
3
+ "version": "0.3.97",
4
4
  "description": "Lizard CLI — deploy apps, databases and sandboxes to Lizard (lizard.build) from your terminal or your coding agent. Detects Go, Node, Python, Rust, Ruby, PHP, Java and static sites; adds Managed Postgres, Managed Redis and Managed Object Storage in one command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,343 @@
1
+ import chalk from "chalk";
2
+ import open from "open";
3
+ import * as p from "@clack/prompts";
4
+ import { Command } from "commander";
5
+ import { api, withQuery, APIError } from "../lib/api.js";
6
+ import { success, isJSONMode, printJSON, table, isTTY, timeAgo } from "../lib/format.js";
7
+
8
+ interface BalanceView {
9
+ plan: string;
10
+ status: "active" | "grace" | "frozen";
11
+ balanceCents: number;
12
+ overdraftLimitCents: number | null;
13
+ availableCents: number | null;
14
+ expiringCents: number;
15
+ expiringAt: number | null;
16
+ expiringCredits: { amountCents: number; expiresAt: number }[];
17
+ hourlyRateCents: number;
18
+ runwayHours: number | null;
19
+ graceSince: number | null;
20
+ graceHours: number;
21
+ neverFreeze: boolean;
22
+ invoicedMonthly: boolean;
23
+ email: string | null;
24
+ autoTopup: {
25
+ enabled: boolean; thresholdCents: number; amountCents: number;
26
+ paymentMethodIds: string[]; disabledReason: string | null;
27
+ lastAttemptAt: number | null; lastSuccessAt: number | null; consecutiveFailures: number;
28
+ };
29
+ pendingPromo: { code: string; creditCents: number; expiresAt: number | null } | null;
30
+ topupUrl: string;
31
+ purchase: { minCents: number; maxCents: number; feeBps: number; feeMinCents: number; feeFixedCents: number; feeInternationalBps: number; cryptoEnabled: boolean };
32
+ }
33
+
34
+ // `lizard credits` — account-scoped prepaid balance (LIZARD-177). Unlike
35
+ // most commands this never takes --project/--service: billing belongs to
36
+ // the user, not a workspace. See docs/credits-api.md on the platform repo
37
+ // for the contract this is built against.
38
+
39
+ function fmtCents(cents: number): string {
40
+ const sign = cents < 0 ? "-" : "";
41
+ return `${sign}$${(Math.abs(cents) / 100).toFixed(2)}`;
42
+ }
43
+
44
+ /** Accepts "10", "10.5", "$10.50" -> cents. Throws on anything else. */
45
+ function parseDollarsToCents(input: string): number {
46
+ const cleaned = input.trim().replace(/^\$/, "");
47
+ const value = Number(cleaned);
48
+ if (!Number.isFinite(value) || value <= 0) {
49
+ throw new Error(`Invalid amount: "${input}". Use a positive dollar amount, e.g. 10 or 10.50.`);
50
+ }
51
+ return Math.round(value * 100);
52
+ }
53
+
54
+ function statusLabel(status: string): string {
55
+ switch (status) {
56
+ case "active": return chalk.green(status);
57
+ case "grace": return chalk.yellow(status);
58
+ case "frozen": return chalk.red(status);
59
+ default: return status;
60
+ }
61
+ }
62
+
63
+ async function printBalance(): Promise<BalanceView> {
64
+ const view = await api.get<BalanceView>("/api/billing/balance");
65
+ if (isJSONMode()) {
66
+ printJSON(view);
67
+ return view;
68
+ }
69
+
70
+ console.log(`${chalk.bold(fmtCents(view.balanceCents))} ${statusLabel(view.status)} ${chalk.dim(`(${view.plan})`)}`);
71
+
72
+ if (view.neverFreeze) {
73
+ console.log(chalk.dim("Exempt from billing freeze — balance can go negative with no enforcement."));
74
+ } else {
75
+ if (view.overdraftLimitCents != null) {
76
+ console.log(chalk.dim(`Overdraft limit: `) + fmtCents(-view.overdraftLimitCents));
77
+ }
78
+ if (view.availableCents != null) {
79
+ console.log(chalk.dim(`Available before freeze: `) + fmtCents(view.availableCents));
80
+ }
81
+ if (view.graceSince) {
82
+ const deadline = view.graceSince + view.graceHours * 3_600_000;
83
+ const hoursLeft = Math.max(0, Math.round((deadline - Date.now()) / 3_600_000));
84
+ console.log(chalk.yellow(`In grace since ${timeAgo(view.graceSince)} — freezes in ~${hoursLeft}h if unpaid.`));
85
+ }
86
+ }
87
+
88
+ if (view.expiringCents > 0 && view.expiringAt) {
89
+ console.log(chalk.dim(`Expiring: `) + `${fmtCents(view.expiringCents)} by ${new Date(view.expiringAt).toLocaleDateString()}`);
90
+ }
91
+
92
+ if (view.hourlyRateCents > 0) {
93
+ const rate = chalk.dim(`(~${fmtCents(view.hourlyRateCents)}/hr)`);
94
+ if (view.runwayHours != null) {
95
+ const days = (view.runwayHours / 24).toFixed(1);
96
+ console.log(chalk.dim(`Runway: `) + `~${view.runwayHours}h (${days}d) at current usage ${rate}`);
97
+ } else {
98
+ console.log(chalk.dim(`Current usage rate: `) + rate);
99
+ }
100
+ }
101
+
102
+ console.log(
103
+ chalk.dim("Auto top-up: ") +
104
+ (view.autoTopup.enabled
105
+ ? `on — tops up ${fmtCents(view.autoTopup.amountCents)} when balance drops below ${fmtCents(view.autoTopup.thresholdCents)}`
106
+ : "off"),
107
+ );
108
+ if (view.autoTopup.disabledReason) {
109
+ console.log(chalk.yellow(` disabled: ${view.autoTopup.disabledReason}`));
110
+ }
111
+
112
+ if (view.pendingPromo) {
113
+ console.log(chalk.dim("Pending promo: ") + `${view.pendingPromo.code} (${fmtCents(view.pendingPromo.creditCents)})`);
114
+ }
115
+
116
+ return view;
117
+ }
118
+
119
+ interface Transaction {
120
+ id: string; kind: string; amountCents: number; balanceAfterCents: number | null;
121
+ description: string | null; createdAt: number; externalRef: string | null;
122
+ receiptUrl: string | null; invoiceUrl: string | null; expiresAt: number | null;
123
+ }
124
+
125
+ interface PaymentMethod {
126
+ id: string; brand: string; last4: string; expMonth: number; expYear: number; isDefault?: boolean;
127
+ }
128
+
129
+ export function registerCredits(program: Command) {
130
+ const cmd = program
131
+ .command("credits")
132
+ .alias("billing")
133
+ .description("Account balance, usage, and top-ups (prepaid credits)")
134
+ .action(async () => {
135
+ await printBalance();
136
+ });
137
+
138
+ cmd
139
+ .command("balance")
140
+ .description("Show current balance, status, and runway")
141
+ .action(async () => {
142
+ await printBalance();
143
+ });
144
+
145
+ cmd
146
+ .command("transactions")
147
+ .alias("ledger")
148
+ .description("List recent balance transactions")
149
+ .option("-l, --limit <n>", "Max results (1-100)", "20")
150
+ .option("-c, --cursor <cursor>", "Pagination cursor from a previous call")
151
+ .option("-u, --usage", "Include daily usage-deduction rows")
152
+ .action(async (opts) => {
153
+ const page = await api.get<{ items: Transaction[]; nextCursor: string | null }>(
154
+ withQuery("/api/billing/transactions", {
155
+ limit: opts.limit,
156
+ cursor: opts.cursor,
157
+ includeUsage: opts.usage ? "1" : undefined,
158
+ }),
159
+ );
160
+
161
+ if (isJSONMode()) {
162
+ printJSON(page);
163
+ return;
164
+ }
165
+
166
+ if (page.items.length === 0) {
167
+ console.log("No transactions.");
168
+ return;
169
+ }
170
+
171
+ table(
172
+ ["When", "Kind", "Amount", "Balance after", "Description"],
173
+ page.items.map((t) => [
174
+ timeAgo(t.createdAt),
175
+ t.kind,
176
+ t.amountCents >= 0 ? chalk.green(fmtCents(t.amountCents)) : chalk.red(fmtCents(t.amountCents)),
177
+ t.balanceAfterCents != null ? fmtCents(t.balanceAfterCents) : "—",
178
+ t.description ?? "",
179
+ ]),
180
+ );
181
+
182
+ if (page.nextCursor) {
183
+ console.log(chalk.dim(`\nMore results: lizard credits transactions --cursor ${page.nextCursor}`));
184
+ }
185
+ });
186
+
187
+ // ── Payment methods ───────────────────────────────────────────────
188
+ const pm = cmd.command("payment-methods").alias("pm").description("Manage saved payment methods");
189
+
190
+ pm.command("list")
191
+ .description("List saved payment methods")
192
+ .action(async () => {
193
+ const { items } = await api.get<{ items: PaymentMethod[] }>("/api/billing/payment-methods");
194
+ if (isJSONMode()) {
195
+ printJSON(items);
196
+ return;
197
+ }
198
+ if (items.length === 0) {
199
+ console.log("No saved payment methods. Add one with `lizard credits payment-methods add`.");
200
+ return;
201
+ }
202
+ table(
203
+ ["ID", "Card", "Expires", "Default"],
204
+ items.map((m) => [m.id, `${m.brand} •••• ${m.last4}`, `${m.expMonth}/${m.expYear}`, m.isDefault ? "yes" : ""]),
205
+ );
206
+ });
207
+
208
+ pm.command("add")
209
+ .description("Save a new card via a Stripe Checkout link")
210
+ .option("--return-url <url>", "Where to redirect after saving the card")
211
+ .option("--no-open", "Print the link instead of opening a browser")
212
+ .action(async (opts) => {
213
+ const { url } = await api.post<{ url: string }>("/api/billing/payment-methods/setup", {
214
+ returnUrl: opts.returnUrl,
215
+ });
216
+ if (isJSONMode()) {
217
+ printJSON({ url });
218
+ return;
219
+ }
220
+ console.log(`Open this link to save a card:\n${chalk.cyan(url)}`);
221
+ if (opts.open !== false && isTTY()) await open(url).catch(() => {});
222
+ });
223
+
224
+ pm.command("remove <id>")
225
+ .alias("rm")
226
+ .description("Remove a saved payment method")
227
+ .option("-y, --yes", "Skip confirmation")
228
+ .action(async (id: string, opts) => {
229
+ if (!opts.yes) {
230
+ if (!isTTY()) throw new Error("Use -y to confirm in non-interactive mode");
231
+ const confirm = await p.confirm({ message: `Remove payment method ${chalk.bold(id)}?` });
232
+ if (p.isCancel(confirm) || !confirm) process.exit(5);
233
+ }
234
+ const out = await api.delete<{ ok: boolean }>(`/api/billing/payment-methods/${encodeURIComponent(id)}`);
235
+ if (isJSONMode()) {
236
+ printJSON(out);
237
+ } else {
238
+ success("Payment method removed");
239
+ }
240
+ });
241
+
242
+ // ── Top up ────────────────────────────────────────────────────────
243
+ cmd
244
+ .command("topup <amount>")
245
+ .alias("purchase")
246
+ .description("Buy credits (amount in dollars, e.g. `lizard credits topup 20`)")
247
+ .option("--method <method>", "card or crypto", "card")
248
+ .option("--return-url <url>", "Where to redirect after payment")
249
+ .option("--no-open", "Print the checkout link instead of opening a browser")
250
+ .action(async (amount: string, opts) => {
251
+ const creditCents = parseDollarsToCents(amount);
252
+ if (opts.method !== "card" && opts.method !== "crypto") {
253
+ throw new Error(`--method must be "card" or "crypto", got "${opts.method}"`);
254
+ }
255
+ let out: { url?: string; sessionId?: string };
256
+ try {
257
+ out = await api.post("/api/billing/purchase", {
258
+ creditCents,
259
+ paymentMethod: opts.method,
260
+ returnUrl: opts.returnUrl,
261
+ });
262
+ } catch (e) {
263
+ if (e instanceof APIError && e.status === 409 && e.message === "CREDITS_NOT_ENABLED") {
264
+ throw new Error("Credits purchases aren't available yet on this account.");
265
+ }
266
+ throw e;
267
+ }
268
+ if (isJSONMode()) {
269
+ printJSON(out);
270
+ return;
271
+ }
272
+ if (!out.url) throw new Error("No checkout URL returned.");
273
+ console.log(`Complete payment (${fmtCents(creditCents)}) at:\n${chalk.cyan(out.url)}`);
274
+ if (opts.open !== false && isTTY()) await open(out.url).catch(() => {});
275
+ });
276
+
277
+ // ── Auto top-up ───────────────────────────────────────────────────
278
+ const auto = cmd.command("auto-topup").description("Show or configure automatic top-up");
279
+
280
+ auto.action(async () => {
281
+ const settings = await api.get("/api/billing/auto-topup");
282
+ if (isJSONMode()) { printJSON(settings); return; }
283
+ console.log(JSON.stringify(settings, null, 2));
284
+ });
285
+
286
+ auto
287
+ .command("set")
288
+ .description("Configure automatic top-up")
289
+ .requiredOption("--payment-method <id...>", "Payment method ID(s) to charge, in priority order")
290
+ .option("--threshold <amount>", "Top up when balance drops below this (dollars)", "5")
291
+ .option("--amount <amount>", "How much to add per top-up (dollars)", "20")
292
+ .option("--disable", "Turn auto top-up off instead of configuring it")
293
+ .action(async (opts) => {
294
+ const body = {
295
+ enabled: !opts.disable,
296
+ thresholdCents: parseDollarsToCents(opts.threshold),
297
+ amountCents: parseDollarsToCents(opts.amount),
298
+ paymentMethodIds: opts.paymentMethod,
299
+ };
300
+ const out = await api.put("/api/billing/auto-topup", body);
301
+ if (isJSONMode()) { printJSON(out); return; }
302
+ success(opts.disable ? "Auto top-up disabled" : "Auto top-up configured");
303
+ });
304
+
305
+ auto
306
+ .command("run")
307
+ .description("Manually trigger an auto top-up check now")
308
+ .action(async () => {
309
+ try {
310
+ const out = await api.post<{ ok: boolean; creditedCents?: number; balanceCents?: number }>(
311
+ "/api/billing/auto-topup/run",
312
+ {},
313
+ );
314
+ if (isJSONMode()) { printJSON(out); return; }
315
+ if (out.ok) {
316
+ success(`Topped up ${fmtCents(out.creditedCents ?? 0)} — new balance ${fmtCents(out.balanceCents ?? 0)}`);
317
+ }
318
+ } catch (e) {
319
+ if (e instanceof APIError && e.status === 429) {
320
+ throw new Error(`Auto top-up rate-limited: ${(e.body as any)?.reason ?? e.message}`);
321
+ }
322
+ throw e;
323
+ }
324
+ });
325
+
326
+ // ── Promo codes ───────────────────────────────────────────────────
327
+ cmd
328
+ .command("promo <code>")
329
+ .description("Redeem a promo code")
330
+ .action(async (code: string) => {
331
+ try {
332
+ const out = await api.post<{ status: string; creditCents: number; expiresAt: number | null; balanceCents: number }>(
333
+ "/api/billing/promo/redeem",
334
+ { code },
335
+ );
336
+ if (isJSONMode()) { printJSON(out); return; }
337
+ success(`Redeemed ${fmtCents(out.creditCents)} — new balance ${fmtCents(out.balanceCents)}`);
338
+ } catch (e) {
339
+ if (e instanceof APIError) throw new Error((e.body as any)?.message ?? e.message);
340
+ throw e;
341
+ }
342
+ });
343
+ }
@@ -8,6 +8,7 @@ import { Command } from "commander";
8
8
  import { api, getBaseURL, getRawText, streamSSE, withQuery, withScope, type ResourceScope } from "../lib/api.js";
9
9
  import { getToken } from "../lib/auth.js";
10
10
  import { resolveProjectScope } from "../lib/resolve.js";
11
+ import { resolveVolume } from "../lib/volume.js";
11
12
  import { success, info, error, isJSONMode, printJSON, table, statusColor, timeAgo, isTTY } from "../lib/format.js";
12
13
 
13
14
  const VALID_TEMPLATES = ["base", "code-interpreter-v1"] as const;
@@ -27,11 +28,6 @@ interface SandboxRecord {
27
28
  projectId?: string | null;
28
29
  }
29
30
 
30
- interface VolumeRecord {
31
- id: string;
32
- name: string;
33
- }
34
-
35
31
  function parseIntOption(v: string): number {
36
32
  const n = parseInt(v, 10);
37
33
  if (Number.isNaN(n)) throw new Error(`Invalid number: ${v}`);
@@ -46,27 +42,6 @@ function parseTimeoutOption(value: string): number {
46
42
  return timeout;
47
43
  }
48
44
 
49
- /** Resolve a volume by name or ID. Requires a project — volumes are project-scoped. */
50
- async function resolveVolumeId(
51
- projectId: string,
52
- scope: ResourceScope,
53
- nameOrId: string,
54
- ): Promise<string> {
55
- const volumes = await api.get<VolumeRecord[]>(
56
- withScope(`/api/projects/${projectId}/volumes`, scope),
57
- );
58
- const lower = nameOrId.toLowerCase();
59
- const match = volumes.find(
60
- (v) => v.id.toLowerCase() === lower || v.name.toLowerCase() === lower,
61
- );
62
- if (!match) {
63
- throw new Error(
64
- `Volume "${nameOrId}" not found. Available: ${volumes.map((v) => v.name).join(", ") || "(none)"}`,
65
- );
66
- }
67
- return match.id;
68
- }
69
-
70
45
  function printSandboxList(sandboxes: SandboxRecord[]) {
71
46
  if (isJSONMode()) {
72
47
  printJSON(sandboxes);
@@ -114,9 +89,11 @@ export function registerSandbox(program: Command) {
114
89
  const { projectId, scope } = await resolveProjectScope(opts.project);
115
90
  const workspaceId = scope.workspaceId ?? undefined;
116
91
 
92
+ // Resolved server-side so a name is matched against this project only — see
93
+ // lib/volume.ts for why the old client-side .find() had to go.
117
94
  let volumeId: string | undefined;
118
95
  if (opts.volume) {
119
- volumeId = await resolveVolumeId(projectId, scope, opts.volume);
96
+ volumeId = (await resolveVolume(projectId, scope, opts.volume)).id;
120
97
  }
121
98
 
122
99
  const spinner = isJSONMode() ? null : ora("Creating sandbox...").start();
@@ -2,39 +2,10 @@ import chalk from "chalk";
2
2
  import * as p from "@clack/prompts";
3
3
  import { Command } from "commander";
4
4
  import { api, withScope, type ResourceScope } from "../lib/api.js";
5
+ import { assertValidVolumeName, resolveVolume, type VolumeRecord } from "../lib/volume.js";
5
6
  import { resolveProjectScope } from "../lib/resolve.js";
6
7
  import { success, info, isJSONMode, printJSON, table, isTTY } from "../lib/format.js";
7
8
 
8
- interface VolumeRecord {
9
- id: string;
10
- name: string;
11
- sizeGb: number;
12
- status: string;
13
- attachedTo?: string | null;
14
- createdAt?: number;
15
- }
16
-
17
- /** Resolve a volume by name or ID within a project. Mirrors resolveService. */
18
- async function resolveVolume(
19
- projectId: string,
20
- scope: ResourceScope,
21
- nameOrId: string,
22
- ): Promise<VolumeRecord> {
23
- const volumes = await api.get<VolumeRecord[]>(
24
- withScope(`/api/projects/${projectId}/volumes`, scope),
25
- );
26
- const lower = nameOrId.toLowerCase();
27
- const match = volumes.find(
28
- (v) => v.id.toLowerCase() === lower || v.name.toLowerCase() === lower,
29
- );
30
- if (!match) {
31
- throw new Error(
32
- `Volume "${nameOrId}" not found. Available: ${volumes.map((v) => v.name).join(", ") || "(none)"}`,
33
- );
34
- }
35
- return match;
36
- }
37
-
38
9
  function parseIntOption(v: string): number {
39
10
  const n = parseInt(v, 10);
40
11
  if (Number.isNaN(n)) throw new Error(`Invalid number: ${v}`);
@@ -68,11 +39,12 @@ export function registerVolume(program: Command) {
68
39
  return;
69
40
  }
70
41
 
42
+ // The name is the key now — the ID is noise in a human-readable list and is
43
+ // still there in full under `--json`.
71
44
  table(
72
- ["Name", "ID", "Size", "Status", "Attached to"],
45
+ ["Name", "Size", "Status", "Attached to"],
73
46
  volumes.map((v) => [
74
47
  v.name,
75
- chalk.dim(v.id),
76
48
  `${v.sizeGb} GB`,
77
49
  v.status,
78
50
  v.attachedTo ? chalk.dim(v.attachedTo) : chalk.dim("—"),
@@ -87,6 +59,7 @@ export function registerVolume(program: Command) {
87
59
  .option("--size <gb>", "Size in GB (1-100, default 5)", parseIntOption)
88
60
  .option("-p, --project <id>", "Project name, slug, or ID")
89
61
  .action(async (name: string, opts) => {
62
+ assertValidVolumeName(name);
90
63
  const { projectId, scope } = await resolveProjectScope(opts.project);
91
64
  const sizeGb = opts.size ?? 5;
92
65
  if (sizeGb < 1 || sizeGb > 100) {
@@ -104,8 +77,7 @@ export function registerVolume(program: Command) {
104
77
  return;
105
78
  }
106
79
  success(`Volume ${chalk.bold(created.name)} created (${created.sizeGb} GB)`);
107
- info(chalk.dim(` ID: ${created.id}`));
108
- info(chalk.dim(` Attach it to a sandbox: lizard sandbox create --volume ${created.id}`));
80
+ info(chalk.dim(` Attach it to a sandbox: lizard sandbox create --volume ${created.name}`));
109
81
  });
110
82
 
111
83
  vol
@@ -132,7 +104,7 @@ export function registerVolume(program: Command) {
132
104
  if (p.isCancel(ok) || !ok) process.exit(5);
133
105
  }
134
106
 
135
- await api.delete(withScope(`/api/projects/${projectId}/volumes/${volume.id}`, scope));
107
+ await api.delete(withScope(`/api/projects/${projectId}/volumes/${encodeURIComponent(volume.name)}`, scope));
136
108
 
137
109
  if (isJSONMode()) {
138
110
  printJSON({ id: volume.id, status: "deleted" });
package/src/index.ts CHANGED
@@ -61,6 +61,7 @@ function addAgentsPointerToSubcommands(cmd: Command) {
61
61
  // Commands (alphabetical by command name)
62
62
  import { registerAdd } from "./commands/add.js";
63
63
  import { registerConfig } from "./commands/config.js";
64
+ import { registerCredits } from "./commands/credits.js";
64
65
  import { registerDocs } from "./commands/docs.js";
65
66
  import { registerDomain } from "./commands/domain.js";
66
67
  import { registerEvents } from "./commands/events.js";
@@ -155,6 +156,7 @@ program.exitOverride();
155
156
  // Register all commands (alphabetical)
156
157
  registerAdd(program);
157
158
  registerConfig(program);
159
+ registerCredits(program);
158
160
  registerDocs(program);
159
161
  registerDomain(program);
160
162
  registerEvents(program);
@@ -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 = "0.3.95";
8
+ export const CURRENT_VERSION = "0.3.97";
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,71 @@
1
+ import { api, withScope, type ResourceScope } from "./api.js";
2
+
3
+ export interface VolumeRecord {
4
+ id: string;
5
+ name: string;
6
+ sizeGb: number;
7
+ status: string;
8
+ attachedTo?: string | null;
9
+ createdAt?: number;
10
+ }
11
+
12
+ /**
13
+ * A volume's name is its key inside a project (LIZARD-161), so it has to be a shape
14
+ * that survives a URL, a mount path and a shell argument. Validated here as well as
15
+ * on the server so a typo is reported before the request goes out.
16
+ */
17
+ export const VOLUME_NAME_RE = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/;
18
+
19
+ export function assertValidVolumeName(name: string): void {
20
+ if (VOLUME_NAME_RE.test(name)) return;
21
+ throw new Error(
22
+ `Invalid volume name "${name}". Use lowercase letters, digits and dashes, ` +
23
+ `starting and ending with a letter or digit (max 64 chars) — e.g. "build-cache".`,
24
+ );
25
+ }
26
+
27
+ /**
28
+ * Resolve a volume by name or ID through the server.
29
+ *
30
+ * This used to be a client-side `.find()` over the project's volume list, duplicated
31
+ * in `volume.ts` and `sandbox.ts`. Nothing enforced name uniqueness back then, so with
32
+ * duplicates present it silently picked whichever row came back first — you asked for
33
+ * your volume and mounted someone else's. The server now resolves a name itself and
34
+ * names are unique per project.
35
+ *
36
+ * The list fallback is only for talking to a server older than that change; it refuses
37
+ * to guess when a name is ambiguous rather than repeating the original bug.
38
+ */
39
+ export async function resolveVolume(
40
+ projectId: string,
41
+ scope: ResourceScope,
42
+ nameOrId: string,
43
+ ): Promise<VolumeRecord> {
44
+ try {
45
+ return await api.get<VolumeRecord>(
46
+ withScope(`/api/projects/${projectId}/volumes/${encodeURIComponent(nameOrId)}`, scope),
47
+ );
48
+ } catch (e: any) {
49
+ if (e?.status && e.status !== 404) throw e;
50
+ }
51
+
52
+ const volumes = await api.get<VolumeRecord[]>(
53
+ withScope(`/api/projects/${projectId}/volumes`, scope),
54
+ );
55
+ const lower = nameOrId.toLowerCase();
56
+ const matches = volumes.filter(
57
+ (v) => v.id.toLowerCase() === lower || v.name.toLowerCase() === lower,
58
+ );
59
+ if (matches.length > 1) {
60
+ throw new Error(
61
+ `Volume "${nameOrId}" is ambiguous — ${matches.length} volumes in this project share that name ` +
62
+ `(${matches.map((v) => v.id).join(", ")}). Delete the duplicates, or pass the ID you want.`,
63
+ );
64
+ }
65
+ if (matches.length === 0) {
66
+ throw new Error(
67
+ `Volume "${nameOrId}" not found. Available: ${volumes.map((v) => v.name).join(", ") || "(none)"}`,
68
+ );
69
+ }
70
+ return matches[0];
71
+ }