@lizard-build/cli 4.0.58 → 4.0.60
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/dist/commands/git-hosting.d.ts +2 -0
- package/dist/commands/git-hosting.js +1110 -0
- package/dist/commands/git-hosting.js.map +1 -0
- package/dist/commands/git.js +3 -1
- package/dist/commands/git.js.map +1 -1
- package/dist/commands/sandbox.js +2 -2
- package/dist/commands/sandbox.js.map +1 -1
- package/dist/commands/volume.js +2 -2
- package/dist/commands/volume.js.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/lizard-git.d.ts +39 -0
- package/dist/lib/lizard-git.js +135 -0
- package/dist/lib/lizard-git.js.map +1 -0
- package/dist/lib/skills-data.generated.js +3 -3
- 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 +11 -0
- package/skill-data/core/references/sandboxes.md +5 -5
- package/skill-data/core/references/sdk.md +10 -0
- package/src/commands/git-hosting.ts +1073 -0
- package/src/commands/git.ts +3 -1
- package/src/commands/sandbox.ts +2 -2
- package/src/commands/volume.ts +2 -2
- package/src/index.ts +3 -0
- package/src/lib/lizard-git.ts +138 -0
- package/src/lib/updater.ts +1 -1
- package/test/unit/lizard-git.test.ts +48 -0
package/src/commands/git.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { api, getBaseURL, streamSSE, withScope } from "../lib/api.js";
|
|
|
7
7
|
import { openURL } from "../lib/auth.js";
|
|
8
8
|
import { resolveProjectScope, resolveService } from "../lib/resolve.js";
|
|
9
9
|
import { success, error, info, fail, isJSONMode, printJSON, isTTY, link } from "../lib/format.js";
|
|
10
|
+
import { registerLizardGit } from "./git-hosting.js";
|
|
10
11
|
|
|
11
12
|
interface GitHubInstallation {
|
|
12
13
|
id: number;
|
|
@@ -170,7 +171,7 @@ export function registerGit(program: Command) {
|
|
|
170
171
|
// lizard git status
|
|
171
172
|
git
|
|
172
173
|
.command("status")
|
|
173
|
-
.description("
|
|
174
|
+
.description("GitHub: connection and the repositories this project's services deploy from")
|
|
174
175
|
.option("-p, --project <id>", "Project name, slug, or ID")
|
|
175
176
|
.action(async (opts) => {
|
|
176
177
|
const { projectId, scope } = await resolveProjectScope(opts.project);
|
|
@@ -232,6 +233,7 @@ export function registerGit(program: Command) {
|
|
|
232
233
|
);
|
|
233
234
|
}
|
|
234
235
|
});
|
|
236
|
+
registerLizardGit(git);
|
|
235
237
|
}
|
|
236
238
|
|
|
237
239
|
function pressEnter(question: string): Promise<void> {
|
package/src/commands/sandbox.ts
CHANGED
|
@@ -558,7 +558,7 @@ Examples:
|
|
|
558
558
|
for (const operation of ["pause", "resume"] as const) {
|
|
559
559
|
sb.command(`snapshot-${operation}`, { hidden: true })
|
|
560
560
|
.argument("<snapshot-id>", "Saved snapshot ID")
|
|
561
|
-
.description(operation === "pause" ? "Release
|
|
561
|
+
.description(operation === "pause" ? "Release an older snapshot's warm copies and keep its saved state" : "Refill a paused older snapshot's warm pool")
|
|
562
562
|
.action(async (id: string) => {
|
|
563
563
|
const snapshot = await api.post<SnapshotRecord>(`/api/sandbox-snapshots/${id}/${operation}`, {});
|
|
564
564
|
if (isJSONMode()) printJSON(snapshot);
|
|
@@ -569,7 +569,7 @@ Examples:
|
|
|
569
569
|
sb.command("snapshot-warm", { hidden: true })
|
|
570
570
|
.argument("<snapshot-id>", "Saved snapshot ID")
|
|
571
571
|
.argument("<count>", "Copies to keep ready (1–10)", parseWarmCount)
|
|
572
|
-
.description("Change
|
|
572
|
+
.description("Change an older snapshot's warm pool size (Firecracker snapshots have none)")
|
|
573
573
|
.action(async (id: string, count: number) => {
|
|
574
574
|
const snapshot = await api.patch<SnapshotRecord>(`/api/sandbox-snapshots/${id}`, { poolSize: count });
|
|
575
575
|
if (isJSONMode()) printJSON(snapshot);
|
package/src/commands/volume.ts
CHANGED
|
@@ -127,8 +127,8 @@ export function registerVolume(program: Command) {
|
|
|
127
127
|
.argument("<volume>", "Volume name or ID")
|
|
128
128
|
.description(
|
|
129
129
|
"Grow a volume in place. Online: no data is copied, and an attached sandbox keeps running " +
|
|
130
|
-
"and sees the new size.
|
|
131
|
-
"
|
|
130
|
+
"and sees the new size. A volume cannot shrink, except an older one, which can if at " +
|
|
131
|
+
"least 10% of the new size stays free.",
|
|
132
132
|
)
|
|
133
133
|
.requiredOption("--size <gb>", "New size in GB (whole number; the server enforces the project's min/max)", parseSizeGbOption)
|
|
134
134
|
.option("-p, --project <id>", "Project name, slug, or ID")
|
package/src/index.ts
CHANGED
|
@@ -146,6 +146,9 @@ program
|
|
|
146
146
|
topLevel = topLevel.parent;
|
|
147
147
|
}
|
|
148
148
|
if (noAuth.has(topLevel.name())) return;
|
|
149
|
+
// git runs `lizard git credential get` as its credential helper: it answers from the
|
|
150
|
+
// stored sign-in or nothing, and must never start an interactive login inside git.
|
|
151
|
+
if (topLevel.name() === "git" && actionCommand.name() === "credential") return;
|
|
149
152
|
|
|
150
153
|
// Require auth — auto-triggers login flow if not logged in
|
|
151
154
|
const creds = await requireAuth();
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { apiErrorFrom, clientHeaders, getRequestToken, APIError } from "./api.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Lizard Git (git.lizard.build) with the CLI's own sign-in: the session from `lizard login`,
|
|
6
|
+
* or LIZARD_TOKEN / LIZARD_API_KEY. Lizard Git asks the platform whose it is and acts as that
|
|
7
|
+
* account's user. Scoped API keys are refused there (they don't say which repositories they
|
|
8
|
+
* may reach); a Lizard Git token (lzg_..., `lizard git token create`) in LIZARD_GIT_TOKEN
|
|
9
|
+
* is used instead when set.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_GIT_URL = "https://git.lizard.build";
|
|
13
|
+
|
|
14
|
+
export function gitURL(): string {
|
|
15
|
+
return (process.env.LIZARD_GIT_URL || DEFAULT_GIT_URL).replace(/\/+$/, "");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The credential Lizard Git gets: LIZARD_GIT_TOKEN, else the CLI's sign-in. */
|
|
19
|
+
export function gitToken(): string | null {
|
|
20
|
+
return process.env.LIZARD_GIT_TOKEN || getRequestToken();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function send(method: string, path: string, body?: BodyInit, headers: Record<string, string> = {}): Promise<Response> {
|
|
24
|
+
const token = gitToken();
|
|
25
|
+
const h: Record<string, string> = { ...clientHeaders(), ...headers };
|
|
26
|
+
if (token) h["Authorization"] = `Bearer ${token}`;
|
|
27
|
+
const res = await fetch(gitURL() + path, { method, headers: h, body });
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
let parsed: unknown = null;
|
|
30
|
+
try {
|
|
31
|
+
parsed = await res.json();
|
|
32
|
+
} catch {}
|
|
33
|
+
throw apiErrorFrom(res.status, res.statusText, parsed);
|
|
34
|
+
}
|
|
35
|
+
return res;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function json<T>(method: string, path: string, body?: unknown, headers: Record<string, string> = {}): Promise<T> {
|
|
39
|
+
const res = await send(method, path, body === undefined ? undefined : JSON.stringify(body),
|
|
40
|
+
body === undefined ? headers : { "Content-Type": "application/json", ...headers });
|
|
41
|
+
const text = await res.text();
|
|
42
|
+
return (text ? JSON.parse(text) : undefined) as T;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const lg = {
|
|
46
|
+
get: <T = any>(path: string) => json<T>("GET", path),
|
|
47
|
+
post: <T = any>(path: string, body: unknown = {}, headers?: Record<string, string>) => json<T>("POST", path, body, headers),
|
|
48
|
+
put: <T = any>(path: string, body: unknown = {}) => json<T>("PUT", path, body),
|
|
49
|
+
patch: <T = any>(path: string, body: unknown) => json<T>("PATCH", path, body),
|
|
50
|
+
delete: <T = any>(path: string) => json<T>("DELETE", path),
|
|
51
|
+
bytes: async (path: string): Promise<Buffer> => Buffer.from(await (await send("GET", path)).arrayBuffer()),
|
|
52
|
+
upload: async <T = any>(path: string, data: Buffer, contentType: string): Promise<T> =>
|
|
53
|
+
(await send("POST", path, new Uint8Array(data), { "Content-Type": contentType })).json() as Promise<T>,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export function qs(path: string, values: Record<string, string | number | boolean | undefined | null>): string {
|
|
57
|
+
const q = new URLSearchParams();
|
|
58
|
+
for (const [k, v] of Object.entries(values)) if (v !== undefined && v !== null && v !== "" && v !== false) q.set(k, String(v));
|
|
59
|
+
const s = q.toString();
|
|
60
|
+
return s ? `${path}?${s}` : path;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const seg = encodeURIComponent;
|
|
64
|
+
export const segs = (p: string) => p.split("/").map(seg).join("/");
|
|
65
|
+
|
|
66
|
+
let me: { login: string } | null = null;
|
|
67
|
+
/** Your Lizard Git login. */
|
|
68
|
+
export async function myLogin(): Promise<string> {
|
|
69
|
+
if (!me) me = await lg.get<{ login: string }>("/api/session");
|
|
70
|
+
return me.login;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** owner/name from a Lizard Git remote URL (https or ssh-style), or null. */
|
|
74
|
+
export function repoFromRemote(url: string, base = gitURL()): string | null {
|
|
75
|
+
const host = new URL(base).host;
|
|
76
|
+
const m = url.trim().match(/^(?:https?:\/\/)(?:[^@/]+@)?([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
77
|
+
if (!m || m[1] !== host) return null;
|
|
78
|
+
return `${m[2]}/${m[3]}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function originRepo(): string | null {
|
|
82
|
+
try {
|
|
83
|
+
const remotes = execFileSync("git", ["remote", "-v"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
84
|
+
const lines = remotes.split("\n").filter((l) => l.includes("(fetch)"));
|
|
85
|
+
const ordered = [...lines.filter((l) => l.startsWith("origin\t")), ...lines];
|
|
86
|
+
for (const l of ordered) {
|
|
87
|
+
const r = repoFromRemote(l.split(/\s+/)[1] ?? "");
|
|
88
|
+
if (r) return r;
|
|
89
|
+
}
|
|
90
|
+
} catch {}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The repository a command is about: `--repo owner/name` (or just `name`: yours), else the
|
|
96
|
+
* Lizard Git remote of the git checkout you're in.
|
|
97
|
+
*/
|
|
98
|
+
export async function resolveRepo(flag?: string): Promise<string> {
|
|
99
|
+
if (flag) {
|
|
100
|
+
const f = flag.replace(/\.git$/, "");
|
|
101
|
+
if (/^[^/\s]+\/[^/\s]+$/.test(f)) return f;
|
|
102
|
+
if (/^[^/\s]+$/.test(f)) return `${await myLogin()}/${f}`;
|
|
103
|
+
throw new APIError(400, `--repo is owner/name or name, got "${flag}"`);
|
|
104
|
+
}
|
|
105
|
+
const r = originRepo();
|
|
106
|
+
if (r) return r;
|
|
107
|
+
throw new APIError(400, `Which repository? Pass --repo owner/name, or run this inside a clone of a Lizard Git repository (${gitURL()}).`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function repoPath(repo: string): string {
|
|
111
|
+
const [owner, name] = repo.split("/");
|
|
112
|
+
return `/api/repos/${seg(owner)}/${seg(name)}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function cloneURL(repo: string): string {
|
|
116
|
+
return `${gitURL()}/${repo}.git`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** How git should run this CLI as its credential helper. */
|
|
120
|
+
export function helperCommand(): string {
|
|
121
|
+
const script = process.argv[1] ?? "";
|
|
122
|
+
const q = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
123
|
+
// npm install: node + the script. Standalone binary: the binary itself.
|
|
124
|
+
if (/\.(c|m)?js$/.test(script)) return `!${q(process.execPath)} ${q(script)} git credential`;
|
|
125
|
+
return `!${q(process.execPath)} git credential`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Seconds (Lizard Git) to "3m ago". */
|
|
129
|
+
export function ago(sec: number | null | undefined): string {
|
|
130
|
+
if (!sec) return "";
|
|
131
|
+
const s = Math.max(0, Math.floor(Date.now() / 1000 - sec));
|
|
132
|
+
if (s < 60) return `${s}s ago`;
|
|
133
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
134
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
135
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const ACTIVE_RUN = new Set(["queued", "running", "waiting", "pending"]);
|
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.60";
|
|
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,48 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
2
|
+
import { repoFromRemote, qs, gitURL, helperCommand, resolveRepo, lg } from "../../src/lib/lizard-git.js";
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.unstubAllGlobals();
|
|
6
|
+
vi.unstubAllEnvs();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe("lizard git helpers", () => {
|
|
10
|
+
it("reads owner/name from Lizard Git remotes only", () => {
|
|
11
|
+
expect(repoFromRemote("https://git.lizard.build/alice/app.git")).toBe("alice/app");
|
|
12
|
+
expect(repoFromRemote("https://lizard:liz_x@git.lizard.build/alice/app")).toBe("alice/app");
|
|
13
|
+
expect(repoFromRemote("https://github.com/alice/app.git")).toBeNull();
|
|
14
|
+
expect(repoFromRemote("git@github.com:alice/app.git")).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("drops empty query values", () => {
|
|
18
|
+
expect(qs("/x", { a: "1", b: undefined, c: "", d: false, e: 2 })).toBe("/x?a=1&e=2");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("honours LIZARD_GIT_URL", () => {
|
|
22
|
+
vi.stubEnv("LIZARD_GIT_URL", "http://localhost:8080/");
|
|
23
|
+
expect(gitURL()).toBe("http://localhost:8080");
|
|
24
|
+
expect(repoFromRemote("http://localhost:8080/a/b.git", gitURL())).toBe("a/b");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("runs itself as git's credential helper", () => {
|
|
28
|
+
expect(helperCommand()).toMatch(/^!'.+' .*git credential$/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("takes owner/name or a bare name (yours) for --repo", async () => {
|
|
32
|
+
vi.stubEnv("LIZARD_TOKEN", "liz_test");
|
|
33
|
+
const calls: string[] = [];
|
|
34
|
+
vi.stubGlobal("fetch", vi.fn(async (url: string, init: any) => {
|
|
35
|
+
calls.push(`${init.method} ${url} ${init.headers.Authorization}`);
|
|
36
|
+
return new Response(JSON.stringify({ login: "me" }), { status: 200 });
|
|
37
|
+
}));
|
|
38
|
+
expect(await resolveRepo("alice/app")).toBe("alice/app");
|
|
39
|
+
expect(await resolveRepo("app")).toBe("me/app");
|
|
40
|
+
expect(calls).toEqual(["GET https://git.lizard.build/api/session Bearer liz_test"]);
|
|
41
|
+
await expect(resolveRepo("a/b/c")).rejects.toThrow(/owner\/name/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("turns error bodies into APIError with the server's message", async () => {
|
|
45
|
+
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ error: "no such repository" }), { status: 404 })));
|
|
46
|
+
await expect(lg.get("/api/repos/a/b")).rejects.toMatchObject({ status: 404, message: "no such repository" });
|
|
47
|
+
});
|
|
48
|
+
});
|