@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.
@@ -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
+ }
@@ -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.37";
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
+ });
@@ -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(false)).split("\n")).toEqual([
190
- "lizard init Create a project and link this folder",
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 gets lizard init before lizard up", () => {
201
- expect(nextSteps(false).map((s) => s.command)).toEqual(["lizard skills get core", "lizard init", "lizard sandbox create", "lizard up"]);
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 init");
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);