@lizard-build/cli 4.0.37 → 4.0.39
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/init.d.ts +2 -0
- package/dist/commands/init.js +18 -0
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/login.d.ts +5 -2
- package/dist/commands/login.js +17 -10
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/sandbox.js +52 -10
- package/dist/commands/sandbox.js.map +1 -1
- package/dist/commands/up.js +2 -2
- package/dist/commands/up.js.map +1 -1
- package/dist/lib/config.js +7 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/default-project.d.ts +17 -0
- package/dist/lib/default-project.js +31 -0
- package/dist/lib/default-project.js.map +1 -0
- package/dist/lib/skills-data.generated.js +1 -1
- package/dist/lib/skills-data.generated.js.map +1 -1
- package/dist/lib/updater.d.ts +1 -1
- package/dist/lib/updater.js +1 -1
- package/package.json +1 -1
- package/skill-data/core/SKILL.md +13 -5
- package/src/commands/init.ts +21 -0
- package/src/commands/login.ts +18 -10
- package/src/commands/sandbox.ts +56 -15
- package/src/commands/up.ts +2 -2
- package/src/lib/config.ts +7 -0
- package/src/lib/default-project.ts +47 -0
- package/src/lib/updater.ts +1 -1
- package/test/unit/default-project.test.ts +104 -0
- package/test/unit/login.test.ts +7 -10
- package/test/unit/sandbox.test.ts +38 -2
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { api, APIError } from "./api.js";
|
|
3
|
+
import { info } from "./format.js";
|
|
4
|
+
|
|
5
|
+
// The project a command uses when the folder is not linked and no --project is
|
|
6
|
+
// given: the platform's default for this account (GET /api/projects/default),
|
|
7
|
+
// the oldest project in the personal workspace. Every account gets one at
|
|
8
|
+
// sign-up, so `lizard sandbox create` or `lizard up` from any folder works
|
|
9
|
+
// without `lizard init` first.
|
|
10
|
+
|
|
11
|
+
export interface DefaultProject {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
slug: string;
|
|
15
|
+
workspaceId: string;
|
|
16
|
+
workspaceName: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let lookup: Promise<DefaultProject | null> | undefined;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The default project, asked once per run. Null when the account has none, or
|
|
23
|
+
* when the platform predates the route; callers then fail as they always did.
|
|
24
|
+
* 401 still throws: that is a sign-in problem, not a missing project.
|
|
25
|
+
*/
|
|
26
|
+
export function defaultProject(): Promise<DefaultProject | null> {
|
|
27
|
+
lookup ??= api.get<DefaultProject>("/api/projects/default").catch((err) => {
|
|
28
|
+
if (err instanceof APIError && err.status !== 401 && err.status >= 400 && err.status < 500) return null;
|
|
29
|
+
throw err;
|
|
30
|
+
});
|
|
31
|
+
return lookup;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let noted = false;
|
|
35
|
+
|
|
36
|
+
/** Say once per run which project an unlinked folder ended up in, and how to choose another. */
|
|
37
|
+
export function noteDefaultProject(name: string): void {
|
|
38
|
+
if (noted) return;
|
|
39
|
+
noted = true;
|
|
40
|
+
info(chalk.dim(`Project: ${name} (your default). Pass --project for another, or run \`lizard init\` to link this folder.`));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** For tests: forget the cached answer. */
|
|
44
|
+
export function resetDefaultProject(): void {
|
|
45
|
+
lookup = undefined;
|
|
46
|
+
noted = false;
|
|
47
|
+
}
|
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.39";
|
|
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,104 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { resolveProjectId, getProjectLink, setProjectLink } from "../../src/lib/config.js";
|
|
6
|
+
import { resetDefaultProject } from "../../src/lib/default-project.js";
|
|
7
|
+
import { ensureLinked } from "../../src/commands/init.js";
|
|
8
|
+
import { setBaseURL, setAccessToken } from "../../src/lib/api.js";
|
|
9
|
+
import { setJSONMode } from "../../src/lib/format.js";
|
|
10
|
+
|
|
11
|
+
const fetchMock = vi.fn();
|
|
12
|
+
let tmpHome: string;
|
|
13
|
+
let tmpCwd: string;
|
|
14
|
+
let cwd: string;
|
|
15
|
+
const savedHome = process.env.LIZARD_HOME;
|
|
16
|
+
|
|
17
|
+
const project = { id: "p-default", name: "ada's Project", slug: "ada", workspaceId: "ws-1", workspaceName: "ada" };
|
|
18
|
+
|
|
19
|
+
function reply(status: number, body: unknown) {
|
|
20
|
+
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function routes(table: Record<string, () => Response>) {
|
|
24
|
+
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
|
|
25
|
+
const key = `${init?.method ?? "GET"} ${new URL(url).pathname}`;
|
|
26
|
+
return table[key]?.() ?? reply(404, { error: `no route ${key}` });
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const requested = () => fetchMock.mock.calls.map(([url, init]) => `${init?.method ?? "GET"} ${new URL(url).pathname}`);
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "lizard-default-home-"));
|
|
34
|
+
tmpCwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "lizard-default-cwd-")));
|
|
35
|
+
process.env.LIZARD_HOME = tmpHome;
|
|
36
|
+
cwd = process.cwd();
|
|
37
|
+
process.chdir(tmpCwd);
|
|
38
|
+
fetchMock.mockReset();
|
|
39
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
40
|
+
setBaseURL("https://lizard.build");
|
|
41
|
+
setAccessToken("tok");
|
|
42
|
+
setJSONMode(true);
|
|
43
|
+
resetDefaultProject();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
process.chdir(cwd);
|
|
48
|
+
if (savedHome === undefined) delete process.env.LIZARD_HOME;
|
|
49
|
+
else process.env.LIZARD_HOME = savedHome;
|
|
50
|
+
vi.unstubAllGlobals();
|
|
51
|
+
setJSONMode(false);
|
|
52
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
53
|
+
fs.rmSync(tmpCwd, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("resolveProjectId in an unlinked folder", () => {
|
|
57
|
+
test("uses the account's default project, asking the platform once per run", async () => {
|
|
58
|
+
routes({ "GET /api/projects/default": () => reply(200, project) });
|
|
59
|
+
expect(await resolveProjectId()).toBe("p-default");
|
|
60
|
+
expect(await resolveProjectId()).toBe("p-default");
|
|
61
|
+
expect(requested()).toEqual(["GET /api/projects/default"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a linked folder or --project never asks for it", async () => {
|
|
65
|
+
setProjectLink({ projectId: "p-linked" });
|
|
66
|
+
expect(await resolveProjectId()).toBe("p-linked");
|
|
67
|
+
expect(await resolveProjectId("abcdefghijklmnopqrstu")).toBe("abcdefghijklmnopqrstu");
|
|
68
|
+
expect(requested()).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("no default project, or a platform without the route: the old error", async () => {
|
|
72
|
+
routes({ "GET /api/projects/default": () => reply(404, { error: "none", code: "NO_DEFAULT_PROJECT" }) });
|
|
73
|
+
await expect(resolveProjectId()).rejects.toThrow("No project linked. Run `lizard init` or pass --project <id>.");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a sign-in problem is not mistaken for a missing project", async () => {
|
|
77
|
+
routes({ "GET /api/projects/default": () => reply(401, { error: "Unauthorized" }) });
|
|
78
|
+
await expect(resolveProjectId()).rejects.toMatchObject({ status: 401 });
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("lizard up in an unlinked folder", () => {
|
|
83
|
+
test("links the folder to the default project without asking", async () => {
|
|
84
|
+
routes({ "GET /api/projects/default": () => reply(200, project) });
|
|
85
|
+
const link = await ensureLinked({ useDefault: true });
|
|
86
|
+
|
|
87
|
+
expect(link).toEqual({ projectId: "p-default", projectName: "ada's Project", workspaceId: "ws-1", workspaceName: "ada" });
|
|
88
|
+
expect(getProjectLink()).toMatchObject({ projectId: "p-default" });
|
|
89
|
+
expect(requested()).toEqual(["GET /api/projects/default"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("--project, or lizard init itself, still goes through the old flow", async () => {
|
|
93
|
+
routes({});
|
|
94
|
+
await expect(ensureLinked({ useDefault: true, projectName: "api" })).rejects.toThrow();
|
|
95
|
+
await expect(ensureLinked({})).rejects.toThrow();
|
|
96
|
+
expect(requested()).not.toContain("GET /api/projects/default");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("an already linked folder stays as it is", async () => {
|
|
100
|
+
setProjectLink({ projectId: "p-linked" });
|
|
101
|
+
expect((await ensureLinked({ useDefault: true })).projectId).toBe("p-linked");
|
|
102
|
+
expect(requested()).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
});
|
package/test/unit/login.test.ts
CHANGED
|
@@ -186,20 +186,17 @@ describe("account state", () => {
|
|
|
186
186
|
|
|
187
187
|
test("the closing note leads with sandboxes, aligned in two columns", () => {
|
|
188
188
|
const plain = (t: string) => t.replace(/\x1b\[[0-9;]*m/g, "");
|
|
189
|
-
expect(plain(nextStepsNote(
|
|
190
|
-
"lizard
|
|
191
|
-
"lizard sandbox create Start a sandbox in it",
|
|
192
|
-
"lizard up Or deploy this folder",
|
|
193
|
-
]);
|
|
194
|
-
expect(plain(nextStepsNote(true)).split("\n")).toEqual([
|
|
195
|
-
"lizard sandbox create Start a sandbox in this project",
|
|
189
|
+
expect(plain(nextStepsNote()).split("\n")).toEqual([
|
|
190
|
+
"lizard sandbox create Start a sandbox",
|
|
196
191
|
"lizard up Or deploy this folder",
|
|
197
192
|
]);
|
|
198
193
|
});
|
|
199
194
|
|
|
200
|
-
test("an unlinked folder
|
|
201
|
-
expect(nextSteps(false).map((s) => s.command)).toEqual(["lizard skills get core", "lizard
|
|
195
|
+
test("no lizard init needed: an unlinked folder uses the default project", () => {
|
|
196
|
+
expect(nextSteps(false).map((s) => s.command)).toEqual(["lizard skills get core", "lizard sandbox create", "lizard up"]);
|
|
197
|
+
expect(nextSteps(false)[1].why).toContain("default project");
|
|
202
198
|
expect(nextSteps(true).map((s) => s.command)).toEqual(["lizard skills get core", "lizard sandbox create", "lizard up"]);
|
|
199
|
+
expect(nextSteps(true)[1].why).toContain("linked project");
|
|
203
200
|
});
|
|
204
201
|
});
|
|
205
202
|
|
|
@@ -239,7 +236,7 @@ describe("lizard login --json", () => {
|
|
|
239
236
|
expect(out).toMatchObject({ status: "complete", username: "ada", email: "ada@example.com", source: "browser" });
|
|
240
237
|
expect(out.account.state).toBe("trial_available");
|
|
241
238
|
expect(out.account.billingUrl).toBe("https://lizard.build/profile/account-billing");
|
|
242
|
-
expect(out.next.map((s: { command: string }) => s.command)).toContain("lizard
|
|
239
|
+
expect(out.next.map((s: { command: string }) => s.command)).toContain("lizard sandbox create");
|
|
243
240
|
expect(loadConfig().pendingAuth).toBeUndefined();
|
|
244
241
|
expect(loadConfig().credentials?.username).toBe("ada");
|
|
245
242
|
});
|
|
@@ -3,6 +3,7 @@ import { Command } from "commander";
|
|
|
3
3
|
import { registerSandbox } from "../../src/commands/sandbox.js";
|
|
4
4
|
import { api, APIError } from "../../src/lib/api.js";
|
|
5
5
|
import { printJSON } from "../../src/lib/format.js";
|
|
6
|
+
import { getProjectLink, resolveProjectId } from "../../src/lib/config.js";
|
|
6
7
|
|
|
7
8
|
vi.mock("../../src/lib/api.js", () => {
|
|
8
9
|
class APIError extends Error {
|
|
@@ -13,8 +14,8 @@ vi.mock("../../src/lib/api.js", () => {
|
|
|
13
14
|
vi.mock("open", () => ({ default: vi.fn().mockResolvedValue(undefined) }));
|
|
14
15
|
vi.mock("../../src/lib/config.js", () => ({
|
|
15
16
|
resolveProjectId: vi.fn().mockResolvedValue("project-test"),
|
|
17
|
+
getProjectLink: vi.fn(() => ({ projectId: "project-test" })),
|
|
16
18
|
}));
|
|
17
|
-
vi.mock("../../src/lib/config.js", () => ({ resolveProjectId: vi.fn().mockResolvedValue("project-test") }));
|
|
18
19
|
vi.mock("../../src/lib/format.js", () => ({ isJSONMode: () => true, printJSON: vi.fn() }));
|
|
19
20
|
|
|
20
21
|
function create(args: string[]) {
|
|
@@ -46,6 +47,41 @@ describe("sandbox create timeout", () => {
|
|
|
46
47
|
});
|
|
47
48
|
|
|
48
49
|
|
|
50
|
+
describe("sandbox create without a project", () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.clearAllMocks();
|
|
53
|
+
vi.mocked(api.post).mockResolvedValue({ id: "sandbox-test" });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("an unlinked folder sends no project and leaves the choice to the platform", async () => {
|
|
57
|
+
vi.mocked(getProjectLink).mockReturnValueOnce(null);
|
|
58
|
+
await create([]);
|
|
59
|
+
expect(resolveProjectId).not.toHaveBeenCalled();
|
|
60
|
+
expect(api.post).toHaveBeenCalledWith("/api/sandboxes", expect.objectContaining({ projectId: undefined }));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("a linked folder or --project still sends its project", async () => {
|
|
64
|
+
await create([]);
|
|
65
|
+
expect(api.post).toHaveBeenCalledWith("/api/sandboxes", expect.objectContaining({ projectId: "project-test" }));
|
|
66
|
+
vi.mocked(getProjectLink).mockReturnValueOnce(null);
|
|
67
|
+
await create(["--project", "web"]);
|
|
68
|
+
expect(resolveProjectId).toHaveBeenLastCalledWith("web");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("an unlinked --volume still resolves a project, since a volume name lives in one", async () => {
|
|
72
|
+
vi.mocked(getProjectLink).mockReturnValueOnce(null);
|
|
73
|
+
await create(["--volume", "data"]);
|
|
74
|
+
expect(resolveProjectId).toHaveBeenCalledWith(undefined);
|
|
75
|
+
expect(api.post).toHaveBeenCalledWith("/api/sandboxes", expect.objectContaining({ projectId: "project-test", volumeName: "data" }));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("a platform that still wants a project gets the old message", async () => {
|
|
79
|
+
vi.mocked(getProjectLink).mockReturnValueOnce(null);
|
|
80
|
+
vi.mocked(api.post).mockRejectedValueOnce(new APIError(400, "A project is required", "PROJECT_REQUIRED"));
|
|
81
|
+
await expect(create([])).rejects.toThrow("No project linked. Run `lizard init` or pass --project <id>.");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
49
85
|
describe("unsupported sandbox commands", () => {
|
|
50
86
|
it.each(["fork", "snapshot-fork", "logs"])(
|
|
51
87
|
"rejects %s without making an API request", async (name) => {
|
|
@@ -66,7 +102,7 @@ describe("private sandbox snapshots", () => {
|
|
|
66
102
|
it("creates a snapshot with five warm copies by default", async () => {
|
|
67
103
|
const program = new Command().exitOverride(); registerSandbox(program);
|
|
68
104
|
await program.parseAsync(['sandbox', 'snapshot', 'sandbox-test', '--name', 'My app', '--no-wait'], { from: 'user' });
|
|
69
|
-
expect(api.post).toHaveBeenCalledWith('/api/sandboxes/sandbox-test/snapshot', { name: 'My app', poolSize: 5 });
|
|
105
|
+
expect(api.post).toHaveBeenCalledWith('/api/sandboxes/sandbox-test/snapshot', { name: 'My app', poolSize: 5, scope: 'user' });
|
|
70
106
|
});
|
|
71
107
|
it.each(['0', '11', '1.5', '5junk'])("rejects invalid warm count %s", async count => {
|
|
72
108
|
const program = new Command().exitOverride(); registerSandbox(program);
|