@lizard-build/cli 0.1.0 → 0.3.29

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.
Files changed (178) hide show
  1. package/.github/workflows/release.yml +90 -0
  2. package/README.md +41 -0
  3. package/dist/commands/add.js +318 -45
  4. package/dist/commands/add.js.map +1 -1
  5. package/dist/commands/config.d.ts +2 -0
  6. package/dist/commands/config.js +68 -0
  7. package/dist/commands/config.js.map +1 -0
  8. package/dist/commands/docs.d.ts +2 -0
  9. package/dist/commands/docs.js +13 -0
  10. package/dist/commands/docs.js.map +1 -0
  11. package/dist/commands/domain.d.ts +9 -0
  12. package/dist/commands/domain.js +195 -0
  13. package/dist/commands/domain.js.map +1 -0
  14. package/dist/commands/git.js +175 -36
  15. package/dist/commands/git.js.map +1 -1
  16. package/dist/commands/init.d.ts +24 -0
  17. package/dist/commands/init.js +128 -86
  18. package/dist/commands/init.js.map +1 -1
  19. package/dist/commands/link.d.ts +7 -0
  20. package/dist/commands/link.js +104 -33
  21. package/dist/commands/link.js.map +1 -1
  22. package/dist/commands/login.js +4 -3
  23. package/dist/commands/login.js.map +1 -1
  24. package/dist/commands/logs.js +223 -30
  25. package/dist/commands/logs.js.map +1 -1
  26. package/dist/commands/open.js +3 -2
  27. package/dist/commands/open.js.map +1 -1
  28. package/dist/commands/port.d.ts +7 -0
  29. package/dist/commands/port.js +49 -0
  30. package/dist/commands/port.js.map +1 -0
  31. package/dist/commands/projects.js +36 -6
  32. package/dist/commands/projects.js.map +1 -1
  33. package/dist/commands/ps.js +32 -39
  34. package/dist/commands/ps.js.map +1 -1
  35. package/dist/commands/redeploy.js +48 -8
  36. package/dist/commands/redeploy.js.map +1 -1
  37. package/dist/commands/regions.js +2 -5
  38. package/dist/commands/regions.js.map +1 -1
  39. package/dist/commands/restart.js +84 -10
  40. package/dist/commands/restart.js.map +1 -1
  41. package/dist/commands/run.d.ts +9 -0
  42. package/dist/commands/run.js +61 -22
  43. package/dist/commands/run.js.map +1 -1
  44. package/dist/commands/scale.d.ts +10 -0
  45. package/dist/commands/scale.js +166 -0
  46. package/dist/commands/scale.js.map +1 -0
  47. package/dist/commands/secrets.js +200 -89
  48. package/dist/commands/secrets.js.map +1 -1
  49. package/dist/commands/service-set.d.ts +49 -0
  50. package/dist/commands/service-set.js +552 -0
  51. package/dist/commands/service-set.js.map +1 -0
  52. package/dist/commands/service-show.d.ts +11 -0
  53. package/dist/commands/service-show.js +44 -0
  54. package/dist/commands/service-show.js.map +1 -0
  55. package/dist/commands/service.d.ts +8 -0
  56. package/dist/commands/service.js +262 -0
  57. package/dist/commands/service.js.map +1 -0
  58. package/dist/commands/ssh.d.ts +2 -0
  59. package/dist/commands/ssh.js +161 -0
  60. package/dist/commands/ssh.js.map +1 -0
  61. package/dist/commands/status.d.ts +7 -0
  62. package/dist/commands/status.js +49 -38
  63. package/dist/commands/status.js.map +1 -1
  64. package/dist/commands/unlink.d.ts +5 -0
  65. package/dist/commands/unlink.js +18 -0
  66. package/dist/commands/unlink.js.map +1 -0
  67. package/dist/commands/up.d.ts +9 -0
  68. package/dist/commands/up.js +417 -0
  69. package/dist/commands/up.js.map +1 -0
  70. package/dist/commands/upgrade.d.ts +2 -0
  71. package/dist/commands/upgrade.js +79 -0
  72. package/dist/commands/upgrade.js.map +1 -0
  73. package/dist/commands/whoami.js +26 -6
  74. package/dist/commands/whoami.js.map +1 -1
  75. package/dist/commands/workspace.d.ts +8 -0
  76. package/dist/commands/workspace.js +36 -0
  77. package/dist/commands/workspace.js.map +1 -0
  78. package/dist/index.js +204 -82
  79. package/dist/index.js.map +1 -1
  80. package/dist/lib/api.d.ts +17 -2
  81. package/dist/lib/api.js +85 -51
  82. package/dist/lib/api.js.map +1 -1
  83. package/dist/lib/auth.d.ts +3 -11
  84. package/dist/lib/auth.js +16 -36
  85. package/dist/lib/auth.js.map +1 -1
  86. package/dist/lib/config.d.ts +36 -15
  87. package/dist/lib/config.js +71 -58
  88. package/dist/lib/config.js.map +1 -1
  89. package/dist/lib/format.d.ts +1 -0
  90. package/dist/lib/format.js +17 -4
  91. package/dist/lib/format.js.map +1 -1
  92. package/dist/lib/name.d.ts +11 -0
  93. package/dist/lib/name.js +26 -0
  94. package/dist/lib/name.js.map +1 -0
  95. package/dist/lib/picker.d.ts +32 -0
  96. package/dist/lib/picker.js +91 -0
  97. package/dist/lib/picker.js.map +1 -0
  98. package/dist/lib/resolve.d.ts +85 -0
  99. package/dist/lib/resolve.js +203 -0
  100. package/dist/lib/resolve.js.map +1 -0
  101. package/dist/lib/updater.d.ts +16 -0
  102. package/dist/lib/updater.js +102 -0
  103. package/dist/lib/updater.js.map +1 -0
  104. package/lizard-wrapper.sh +2 -0
  105. package/package.json +11 -3
  106. package/src/commands/add.ts +388 -56
  107. package/src/commands/config.ts +80 -0
  108. package/src/commands/docs.ts +15 -0
  109. package/src/commands/domain.ts +248 -0
  110. package/src/commands/git.ts +201 -40
  111. package/src/commands/init.ts +149 -100
  112. package/src/commands/link.ts +127 -35
  113. package/src/commands/login.ts +4 -3
  114. package/src/commands/logs.ts +283 -27
  115. package/src/commands/open.ts +3 -2
  116. package/src/commands/port.ts +57 -0
  117. package/src/commands/projects.ts +43 -6
  118. package/src/commands/ps.ts +39 -60
  119. package/src/commands/redeploy.ts +51 -10
  120. package/src/commands/regions.ts +2 -6
  121. package/src/commands/restart.ts +84 -10
  122. package/src/commands/run.ts +68 -24
  123. package/src/commands/scale.ts +216 -0
  124. package/src/commands/secrets.ts +277 -100
  125. package/src/commands/service-set.ts +669 -0
  126. package/src/commands/service-show.ts +52 -0
  127. package/src/commands/service.ts +298 -0
  128. package/src/commands/ssh.ts +176 -0
  129. package/src/commands/status.ts +51 -46
  130. package/src/commands/unlink.ts +17 -0
  131. package/src/commands/up.ts +461 -0
  132. package/src/commands/upgrade.ts +87 -0
  133. package/src/commands/whoami.ts +34 -6
  134. package/src/commands/workspace.ts +44 -0
  135. package/src/index.ts +214 -85
  136. package/src/lib/api.ts +114 -51
  137. package/src/lib/auth.ts +22 -46
  138. package/src/lib/config.ts +100 -65
  139. package/src/lib/format.ts +18 -4
  140. package/src/lib/name.ts +27 -0
  141. package/src/lib/picker.ts +133 -0
  142. package/src/lib/resolve.ts +285 -0
  143. package/src/lib/updater.ts +106 -0
  144. package/test/cli.test.ts +491 -0
  145. package/test/fixtures/hello-app/Dockerfile +5 -0
  146. package/test/fixtures/hello-app/index.js +5 -0
  147. package/test/unit/api.test.ts +66 -0
  148. package/test/unit/config.test.ts +94 -0
  149. package/test/unit/init.test.ts +211 -0
  150. package/test/unit/json.test.ts +208 -0
  151. package/test/unit/picker.test.ts +161 -0
  152. package/test/unit/resolve.test.ts +124 -0
  153. package/test/unit/service-set.test.ts +355 -0
  154. package/vitest.config.ts +10 -0
  155. package/dist/commands/connect.d.ts +0 -2
  156. package/dist/commands/connect.js +0 -117
  157. package/dist/commands/connect.js.map +0 -1
  158. package/dist/commands/context.d.ts +0 -2
  159. package/dist/commands/context.js +0 -71
  160. package/dist/commands/context.js.map +0 -1
  161. package/dist/commands/deploy.d.ts +0 -2
  162. package/dist/commands/deploy.js +0 -120
  163. package/dist/commands/deploy.js.map +0 -1
  164. package/dist/commands/destroy.d.ts +0 -2
  165. package/dist/commands/destroy.js +0 -51
  166. package/dist/commands/destroy.js.map +0 -1
  167. package/dist/commands/update.d.ts +0 -2
  168. package/dist/commands/update.js +0 -41
  169. package/dist/commands/update.js.map +0 -1
  170. package/dist/commands/version.d.ts +0 -2
  171. package/dist/commands/version.js +0 -37
  172. package/dist/commands/version.js.map +0 -1
  173. package/src/commands/connect.ts +0 -145
  174. package/src/commands/context.ts +0 -93
  175. package/src/commands/deploy.ts +0 -153
  176. package/src/commands/destroy.ts +0 -51
  177. package/src/commands/update.ts +0 -44
  178. package/src/commands/version.ts +0 -37
@@ -0,0 +1,211 @@
1
+ import { describe, test, expect, beforeEach, afterEach, 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 type { Workspace } from "../../src/lib/api.js";
6
+
7
+ const personalWs: Workspace = {
8
+ id: "ws_personal",
9
+ name: "personal",
10
+ slug: "personal",
11
+ role: "owner",
12
+ isPersonal: true,
13
+ projectCount: 0,
14
+ };
15
+ const teamWs: Workspace = {
16
+ id: "ws_team",
17
+ name: "acme-team",
18
+ slug: "acme-team",
19
+ role: "member",
20
+ isPersonal: false,
21
+ projectCount: 2,
22
+ };
23
+
24
+ let tmpDir: string;
25
+ let originalCwd: string;
26
+ let originalIsTTY: boolean | undefined;
27
+ let originalLizardHome: string | undefined;
28
+
29
+ beforeEach(() => {
30
+ originalCwd = process.cwd();
31
+ originalIsTTY = process.stdout.isTTY;
32
+ originalLizardHome = process.env.LIZARD_HOME;
33
+
34
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lizard-init-test-"));
35
+ process.env.LIZARD_HOME = tmpDir;
36
+ process.chdir(tmpDir);
37
+ Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true });
38
+ });
39
+
40
+ afterEach(() => {
41
+ process.chdir(originalCwd);
42
+ if (originalLizardHome === undefined) delete process.env.LIZARD_HOME;
43
+ else process.env.LIZARD_HOME = originalLizardHome;
44
+ Object.defineProperty(process.stdout, "isTTY", {
45
+ value: originalIsTTY,
46
+ configurable: true,
47
+ });
48
+ fs.rmSync(tmpDir, { recursive: true, force: true });
49
+ vi.resetModules();
50
+ vi.restoreAllMocks();
51
+ });
52
+
53
+ /**
54
+ * Mock the API for one test. Returns the init module loaded fresh against
55
+ * those mocks so each test gets a clean import.
56
+ */
57
+ async function withMockedApi(handlers: {
58
+ get?: (path: string) => unknown;
59
+ post?: (path: string, body?: unknown) => unknown;
60
+ }) {
61
+ vi.resetModules();
62
+ const calls: Array<{ method: string; path: string; body?: unknown }> = [];
63
+ vi.doMock("../../src/lib/api.js", async () => {
64
+ const actual = await vi.importActual<typeof import("../../src/lib/api.js")>(
65
+ "../../src/lib/api.js",
66
+ );
67
+ return {
68
+ ...actual,
69
+ api: {
70
+ get: (p: string) => {
71
+ calls.push({ method: "GET", path: p });
72
+ return Promise.resolve(handlers.get?.(p));
73
+ },
74
+ post: (p: string, body?: unknown) => {
75
+ calls.push({ method: "POST", path: p, body });
76
+ return Promise.resolve(handlers.post?.(p, body));
77
+ },
78
+ put: () => Promise.reject(new Error("unexpected PUT")),
79
+ patch: () => Promise.reject(new Error("unexpected PATCH")),
80
+ delete: () => Promise.reject(new Error("unexpected DELETE")),
81
+ },
82
+ };
83
+ });
84
+
85
+ const init = await import("../../src/commands/init.js");
86
+ return { init, calls };
87
+ }
88
+
89
+ describe("ensureLinked — Railway-style workspace flow", () => {
90
+ test("non-TTY, single workspace, no flags → creates project in personal", async () => {
91
+ const { init, calls } = await withMockedApi({
92
+ get: (p) => (p === "/api/workspaces" ? [personalWs] : []),
93
+ post: (p) =>
94
+ p === "/api/projects"
95
+ ? { id: "proj_new", name: path.basename(tmpDir), slug: "demo" }
96
+ : null,
97
+ });
98
+ const link = await init.ensureLinked({});
99
+
100
+ expect(link.workspaceId).toBe("ws_personal");
101
+ expect(link.workspaceName).toBe("personal");
102
+ expect(link.projectId).toBe("proj_new");
103
+
104
+ // Backend got workspaceId in the POST body.
105
+ const post = calls.find((c) => c.method === "POST" && c.path === "/api/projects");
106
+ expect(post).toBeDefined();
107
+ expect((post!.body as any).workspaceId).toBe("ws_personal");
108
+ });
109
+
110
+ test("non-TTY with --name and multiple workspaces: unique hint auto-resolves ws", async () => {
111
+ const projects = [
112
+ { id: "p1", name: "api-backend", slug: "api-backend", workspaceId: "ws_team" },
113
+ ];
114
+ const { init, calls } = await withMockedApi({
115
+ get: (p) => {
116
+ if (p === "/api/workspaces") return [personalWs, teamWs];
117
+ if (p === "/api/projects") return projects;
118
+ if (p.startsWith("/api/projects?workspaceId=ws_team")) return projects;
119
+ return [];
120
+ },
121
+ });
122
+
123
+ const link = await init.ensureLinked({ projectName: "api-backend" });
124
+
125
+ expect(link.workspaceId).toBe("ws_team");
126
+ expect(link.projectId).toBe("p1");
127
+ // No POST — existing project was matched.
128
+ expect(calls.find((c) => c.method === "POST")).toBeUndefined();
129
+ });
130
+
131
+ test("--workspace + --name on a not-yet-existing project → creates in chosen ws", async () => {
132
+ const { init, calls } = await withMockedApi({
133
+ get: (p) => {
134
+ if (p === "/api/workspaces") return [personalWs, teamWs];
135
+ // Project lookup inside the chosen workspace: empty
136
+ return [];
137
+ },
138
+ post: (p, body: any) =>
139
+ p === "/api/projects"
140
+ ? { id: "proj_made", name: body.name, slug: "new-thing" }
141
+ : null,
142
+ });
143
+
144
+ const link = await init.ensureLinked({
145
+ workspaceFlag: "acme-team",
146
+ projectName: "new-thing",
147
+ });
148
+
149
+ expect(link.workspaceId).toBe("ws_team");
150
+ expect(link.projectId).toBe("proj_made");
151
+
152
+ const post = calls.find((c) => c.method === "POST" && c.path === "/api/projects");
153
+ expect((post!.body as any).workspaceId).toBe("ws_team");
154
+ expect((post!.body as any).name).toBe("new-thing");
155
+ });
156
+
157
+ test("--name with collisions across workspaces → fails", async () => {
158
+ const projects = [
159
+ { id: "p1", name: "api", slug: "api", workspaceId: "ws_personal" },
160
+ { id: "p2", name: "api", slug: "api", workspaceId: "ws_team" },
161
+ ];
162
+ const { init } = await withMockedApi({
163
+ get: (p) =>
164
+ p === "/api/workspaces"
165
+ ? [personalWs, teamWs]
166
+ : p === "/api/projects"
167
+ ? projects
168
+ : [],
169
+ });
170
+
171
+ await expect(init.ensureLinked({ projectName: "api" })).rejects.toThrow(
172
+ /Multiple projects named "api"/,
173
+ );
174
+ });
175
+
176
+ test("existing link, no --force → returns existing without API calls", async () => {
177
+ // Key the link under process.cwd() — macOS resolves /var → /private/var,
178
+ // so the symlink path used to seed the file would otherwise not match.
179
+ const cwd = process.cwd();
180
+ fs.mkdirSync(path.join(tmpDir, ".lizard"), { recursive: true });
181
+ fs.writeFileSync(
182
+ path.join(tmpDir, ".lizard", "config.json"),
183
+ JSON.stringify({
184
+ projects: {
185
+ [cwd]: {
186
+ projectId: "proj_existing",
187
+ projectName: "demo",
188
+ workspaceId: "ws_existing",
189
+ workspaceName: "old-ws",
190
+ },
191
+ },
192
+ }),
193
+ );
194
+
195
+ const { init, calls } = await withMockedApi({});
196
+ const link = await init.ensureLinked({});
197
+
198
+ expect(link.projectId).toBe("proj_existing");
199
+ expect(link.workspaceId).toBe("ws_existing");
200
+ expect(calls).toHaveLength(0);
201
+ });
202
+
203
+ test("unknown --workspace flag → clear error", async () => {
204
+ const { init } = await withMockedApi({
205
+ get: (p) => (p === "/api/workspaces" ? [personalWs, teamWs] : []),
206
+ });
207
+ await expect(
208
+ init.ensureLinked({ workspaceFlag: "ghost", projectName: "x" }),
209
+ ).rejects.toThrow(/Workspace "ghost" not found/);
210
+ });
211
+ });
@@ -0,0 +1,208 @@
1
+ /**
2
+ * --json flag tests. Runs the built CLI (dist/index.js) and asserts the
3
+ * output is parseable JSON for every command path that promises it.
4
+ *
5
+ * No network, no auth — every test runs with LIZARD_HOME pointed at a fresh
6
+ * tmp dir so credentials and link state are empty. Commands that *would*
7
+ * hit the network are tested only via their pre-network failure paths
8
+ * (auth errors, validation errors) which still surface JSON to stdout.
9
+ *
10
+ * Prereq: `npm run build` (these tests run dist/index.js, not src).
11
+ */
12
+
13
+ import { execa } from "execa";
14
+ import { describe, test, expect, beforeAll } from "vitest";
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import * as os from "node:os";
18
+
19
+ const CLI = path.resolve(import.meta.dirname, "../../dist/index.js");
20
+ const NODE = process.execPath;
21
+
22
+ let TMP_HOME: string;
23
+ let TMP_CWD: string;
24
+
25
+ beforeAll(() => {
26
+ if (!fs.existsSync(CLI)) {
27
+ throw new Error(
28
+ `dist/index.js missing — run \`npm run build\` before this suite.`,
29
+ );
30
+ }
31
+ // realpathSync because /var/folders -> /private/var/folders on macOS; the
32
+ // child process resolves the link too and we want cwd assertions to match.
33
+ TMP_HOME = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "lizard-json-home-")));
34
+ TMP_CWD = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "lizard-json-cwd-")));
35
+ });
36
+
37
+ function run(args: string[], extra: { cwd?: string; env?: Record<string, string> } = {}) {
38
+ return execa(NODE, [CLI, ...args], {
39
+ reject: false,
40
+ cwd: extra.cwd ?? TMP_CWD,
41
+ env: {
42
+ ...process.env,
43
+ LIZARD_HOME: TMP_HOME,
44
+ // Belt-and-suspenders: even if a leaked LIZARD_TOKEN sits in the
45
+ // parent env, drop it so unauth tests can't pass by accident.
46
+ LIZARD_TOKEN: "",
47
+ ...(extra.env ?? {}),
48
+ },
49
+ });
50
+ }
51
+
52
+ function parseJSON(s: string): any {
53
+ return JSON.parse(s);
54
+ }
55
+
56
+ // Top-level commands that should appear in the help dump. Used both to
57
+ // assert the dump is complete and to drill into each subcommand's --help
58
+ // --json output.
59
+ const TOP_LEVEL_COMMANDS = [
60
+ "add", "config", "docs", "domain", "git", "init", "link", "list", "login",
61
+ "logout", "logs", "open", "port", "project", "ps", "redeploy", "regions",
62
+ "restart", "run", "scale", "secrets", "service", "ssh", "status", "unlink",
63
+ "up", "upgrade", "whoami", "workspace",
64
+ ];
65
+
66
+ // ── help dump ────────────────────────────────────────────────────────────────
67
+
68
+ describe("--help --json dump", () => {
69
+ test("root help dumps the full command tree", async () => {
70
+ const { stdout, exitCode } = await run(["--help", "--json"]);
71
+ expect(exitCode).toBe(0);
72
+ const out = parseJSON(stdout);
73
+ expect(out).toMatchObject({ cli: "lizard" });
74
+ expect(typeof out.version).toBe("string");
75
+ expect(Array.isArray(out.command.subcommands)).toBe(true);
76
+ const names = out.command.subcommands.map((c: any) => c.name);
77
+ for (const cmd of TOP_LEVEL_COMMANDS) {
78
+ expect(names).toContain(cmd);
79
+ }
80
+ // Global --json option must appear at the root.
81
+ expect(out.command.options.some((o: any) => o.long === "--json")).toBe(true);
82
+ });
83
+
84
+ test.each(TOP_LEVEL_COMMANDS)(
85
+ "`%s --help --json` is valid JSON and names the command",
86
+ async (cmd) => {
87
+ const { stdout, exitCode } = await run([cmd, "--help", "--json"]);
88
+ expect(exitCode).toBe(0);
89
+ const out = parseJSON(stdout);
90
+ expect(out.command.name).toBe(cmd);
91
+ // globalOptions are exposed once we're inside a subcommand so the
92
+ // dump knows --json/--region/etc. are inherited.
93
+ expect(Array.isArray(out.globalOptions)).toBe(true);
94
+ },
95
+ );
96
+
97
+ test("nested subcommand drills correctly (`secrets set --help --json`)", async () => {
98
+ const { stdout, exitCode } = await run(["secrets", "set", "--help", "--json"]);
99
+ expect(exitCode).toBe(0);
100
+ const out = parseJSON(stdout);
101
+ expect(out.command.name).toBe("set");
102
+ expect(out.command.arguments.some((a: any) => a.name === "pairs")).toBe(true);
103
+ });
104
+
105
+ test("exitCodes is included in the dump", async () => {
106
+ const { stdout } = await run(["--help", "--json"]);
107
+ const out = parseJSON(stdout);
108
+ expect(out.exitCodes).toMatchObject({
109
+ "0": expect.any(String),
110
+ "2": expect.any(String),
111
+ });
112
+ });
113
+ });
114
+
115
+ // ── status (no auth, no network) ─────────────────────────────────────────────
116
+
117
+ describe("status --json", () => {
118
+ test("explicit --json from an unlinked cwd returns {linked:false}", async () => {
119
+ const { stdout, exitCode } = await run(["status", "--json"]);
120
+ expect(exitCode).toBe(0);
121
+ const out = parseJSON(stdout);
122
+ expect(out).toMatchObject({ linked: false });
123
+ expect(out.cwd).toBe(TMP_CWD);
124
+ });
125
+
126
+ test("non-TTY auto-JSON: piped `status` (no flag) also returns JSON", async () => {
127
+ // execa pipes stdout → process.stdout.isTTY === false in the child →
128
+ // preAction enables JSON mode automatically.
129
+ const { stdout, exitCode } = await run(["status"]);
130
+ expect(exitCode).toBe(0);
131
+ const out = parseJSON(stdout);
132
+ expect(out).toHaveProperty("linked");
133
+ expect(out).toHaveProperty("cwd");
134
+ });
135
+ });
136
+
137
+ // ── upgrade (no auth) ────────────────────────────────────────────────────────
138
+
139
+ describe("upgrade --json", () => {
140
+ // Network reachability isn't guaranteed in every CI; we just assert that
141
+ // *some* JSON object comes out for both `--check` and the bare form,
142
+ // regardless of which branch fires (rate-limited / error / up-to-date /
143
+ // update-available). All four branches now emit JSON.
144
+ test("--check --json always emits a JSON payload", async () => {
145
+ const { stdout, exitCode } = await run(["upgrade", "--check", "--json"]);
146
+ expect(exitCode).toBe(0);
147
+ const out = parseJSON(stdout);
148
+ expect(out).toHaveProperty("currentVersion");
149
+ }, 20_000);
150
+ });
151
+
152
+ // ── error path: auth ────────────────────────────────────────────────────────
153
+
154
+ describe("error JSON to stdout", () => {
155
+ test("whoami with no credentials emits JSON error + exit 2", async () => {
156
+ // LIZARD_HOME points at an empty tmp dir → no creds → non-TTY (execa
157
+ // pipes) so requireAuth throws NOT_AUTHENTICATED instead of prompting.
158
+ const { stdout, exitCode } = await run(["whoami", "--json"]);
159
+ expect(exitCode).toBe(2);
160
+ const out = parseJSON(stdout);
161
+ expect(out.error).toBeDefined();
162
+ expect(out.error.code).toBe("NOT_AUTHENTICATED");
163
+ expect(typeof out.error.message).toBe("string");
164
+ });
165
+
166
+ test("auto-JSON: non-TTY whoami without --json still emits JSON error", async () => {
167
+ const { stdout, exitCode } = await run(["whoami"]);
168
+ expect(exitCode).toBe(2);
169
+ const out = parseJSON(stdout);
170
+ expect(out.error.code).toBe("NOT_AUTHENTICATED");
171
+ });
172
+
173
+ test("unknown command with --json doesn't write JSON-corrupting text to stdout", async () => {
174
+ // Commander rejects unknown commands before preAction. Our early
175
+ // setJSONMode(true) in main() lets the catch block still know --json
176
+ // was requested. The error message itself goes through commander's
177
+ // own path (stderr) — we just need stdout to stay parseable-or-empty.
178
+ const { stdout, exitCode } = await run(["totally-nonexistent-cmd", "--json"]);
179
+ expect(exitCode).not.toBe(0);
180
+ // stdout may legitimately be empty (commander prints to stderr) or
181
+ // contain a JSON error object — never plain text.
182
+ const trimmed = stdout.trim();
183
+ if (trimmed) {
184
+ expect(() => parseJSON(trimmed)).not.toThrow();
185
+ }
186
+ });
187
+ });
188
+
189
+ // ── stdout cleanliness ───────────────────────────────────────────────────────
190
+
191
+ describe("stdout cleanliness in JSON mode", () => {
192
+ test("status --json stdout parses end-to-end (no spinner/info leak)", async () => {
193
+ const { stdout } = await run(["status", "--json"]);
194
+ // The whole stdout must be exactly one JSON value with optional
195
+ // trailing newline — no ANSI escapes, no leading spinner frames.
196
+ expect(stdout.trim().endsWith("}")).toBe(true);
197
+ expect(() => parseJSON(stdout)).not.toThrow();
198
+ // No ANSI escape sequences from chalk should leak into stdout.
199
+ // eslint-disable-next-line no-control-regex
200
+ expect(stdout).not.toMatch(/\x1b\[/);
201
+ });
202
+
203
+ test("help dump JSON contains no ANSI escapes either", async () => {
204
+ const { stdout } = await run(["--help", "--json"]);
205
+ // eslint-disable-next-line no-control-regex
206
+ expect(stdout).not.toMatch(/\x1b\[/);
207
+ });
208
+ });
@@ -0,0 +1,161 @@
1
+ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import type { Workspace } from "../../src/lib/api.js";
3
+
4
+ const personalWs: Workspace = {
5
+ id: "ws_personal",
6
+ name: "personal",
7
+ slug: "personal",
8
+ role: "owner",
9
+ isPersonal: true,
10
+ projectCount: 1,
11
+ };
12
+
13
+ const teamWs: Workspace = {
14
+ id: "ws_team",
15
+ name: "acme-team",
16
+ slug: "acme-team",
17
+ role: "member",
18
+ isPersonal: false,
19
+ projectCount: 3,
20
+ };
21
+
22
+ let originalIsTTY: boolean | undefined;
23
+
24
+ beforeEach(() => {
25
+ originalIsTTY = process.stdout.isTTY;
26
+ // Default to non-TTY so prompts don't hang the test runner.
27
+ Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true });
28
+ });
29
+
30
+ afterEach(() => {
31
+ Object.defineProperty(process.stdout, "isTTY", {
32
+ value: originalIsTTY,
33
+ configurable: true,
34
+ });
35
+ vi.resetModules();
36
+ vi.restoreAllMocks();
37
+ });
38
+
39
+ async function freshPicker(mockApi: { get: (path: string) => unknown }) {
40
+ vi.resetModules();
41
+ vi.doMock("../../src/lib/api.js", async () => {
42
+ const actual = await vi.importActual<typeof import("../../src/lib/api.js")>(
43
+ "../../src/lib/api.js",
44
+ );
45
+ return { ...actual, api: mockApi };
46
+ });
47
+ return await import("../../src/lib/picker.js");
48
+ }
49
+
50
+ describe("matchWorkspace", () => {
51
+ test("matches by id, slug, and name (case-insensitive)", async () => {
52
+ const picker = await freshPicker({ get: () => [] });
53
+ const list = [personalWs, teamWs];
54
+ expect(picker.matchWorkspace(list, "ws_team")?.id).toBe("ws_team");
55
+ expect(picker.matchWorkspace(list, "acme-team")?.id).toBe("ws_team");
56
+ expect(picker.matchWorkspace(list, "ACME-TEAM")?.id).toBe("ws_team");
57
+ expect(picker.matchWorkspace(list, "Personal")?.id).toBe("ws_personal");
58
+ expect(picker.matchWorkspace(list, "nope")).toBeUndefined();
59
+ });
60
+ });
61
+
62
+ describe("resolveWorkspace", () => {
63
+ test("returns the workspace when the flag matches", async () => {
64
+ const picker = await freshPicker({
65
+ get: () => [personalWs, teamWs],
66
+ });
67
+ const result = await picker.resolveWorkspace("acme-team");
68
+ expect(result.id).toBe("ws_team");
69
+ });
70
+
71
+ test("throws with the available list when nothing matches", async () => {
72
+ const picker = await freshPicker({
73
+ get: () => [personalWs, teamWs],
74
+ });
75
+ await expect(picker.resolveWorkspace("ghost")).rejects.toThrow(
76
+ /not found.*personal.*acme-team/,
77
+ );
78
+ });
79
+ });
80
+
81
+ describe("pickWorkspace", () => {
82
+ test("uses --workspace flag when provided", async () => {
83
+ const picker = await freshPicker({
84
+ get: () => [personalWs, teamWs],
85
+ });
86
+ const ws = await picker.pickWorkspace({
87
+ flag: "acme-team",
88
+ workspaces: [personalWs, teamWs],
89
+ });
90
+ expect(ws.id).toBe("ws_team");
91
+ });
92
+
93
+ test("auto-selects the only workspace", async () => {
94
+ const picker = await freshPicker({
95
+ get: () => [personalWs],
96
+ });
97
+ const ws = await picker.pickWorkspace({ workspaces: [personalWs] });
98
+ expect(ws.id).toBe("ws_personal");
99
+ });
100
+
101
+ test("non-TTY with multiple workspaces falls back to personal", async () => {
102
+ const picker = await freshPicker({
103
+ get: () => [personalWs, teamWs],
104
+ });
105
+ const ws = await picker.pickWorkspace({
106
+ workspaces: [personalWs, teamWs],
107
+ });
108
+ expect(ws.id).toBe("ws_personal");
109
+ });
110
+
111
+ test("non-TTY with multiple non-personal workspaces falls back to first", async () => {
112
+ const other: Workspace = { ...teamWs, id: "ws_other", name: "other-team", slug: "other-team" };
113
+ const picker = await freshPicker({
114
+ get: () => [teamWs, other],
115
+ });
116
+ const ws = await picker.pickWorkspace({
117
+ workspaces: [teamWs, other],
118
+ });
119
+ expect(ws.id).toBe("ws_team");
120
+ });
121
+
122
+ test("projectNameHint with unique match auto-resolves the workspace", async () => {
123
+ // pickWorkspace will call api.get('/api/projects') to find which ws has the
124
+ // project with name "api-backend" — return one match only.
125
+ const projects = [
126
+ { id: "p1", name: "api-backend", slug: "api-backend", workspaceId: "ws_team" },
127
+ { id: "p2", name: "other", slug: "other", workspaceId: "ws_personal" },
128
+ ];
129
+ const picker = await freshPicker({
130
+ get: (p: string) => (p === "/api/projects" ? projects : []),
131
+ });
132
+ const ws = await picker.pickWorkspace({
133
+ projectNameHint: "api-backend",
134
+ workspaces: [personalWs, teamWs],
135
+ });
136
+ expect(ws.id).toBe("ws_team");
137
+ });
138
+
139
+ test("projectNameHint with collisions fails with helpful message", async () => {
140
+ const projects = [
141
+ { id: "p1", name: "api", slug: "api", workspaceId: "ws_personal" },
142
+ { id: "p2", name: "api", slug: "api", workspaceId: "ws_team" },
143
+ ];
144
+ const picker = await freshPicker({
145
+ get: (p: string) => (p === "/api/projects" ? projects : []),
146
+ });
147
+ await expect(
148
+ picker.pickWorkspace({
149
+ projectNameHint: "api",
150
+ workspaces: [personalWs, teamWs],
151
+ }),
152
+ ).rejects.toThrow(/Multiple projects named "api"/);
153
+ });
154
+
155
+ test("no workspaces throws a clear error", async () => {
156
+ const picker = await freshPicker({ get: () => [] });
157
+ await expect(
158
+ picker.pickWorkspace({ workspaces: [] }),
159
+ ).rejects.toThrow(/No workspaces available/);
160
+ });
161
+ });