@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,461 @@
1
+ import chalk from "chalk";
2
+ import ora from "ora";
3
+ import { Command } from "commander";
4
+ import { execSync, spawn } from "child_process";
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import * as readline from "node:readline";
8
+ import { api, streamSSE, getBaseURL, type ResourceScope } from "../lib/api.js";
9
+ import { updateProjectLink, DEFAULT_REGION } from "../lib/config.js";
10
+ import { resolveContext, getScope } from "../lib/resolve.js";
11
+ import { ensureLinked } from "./init.js";
12
+ import {
13
+ success,
14
+ info,
15
+ error,
16
+ isJSONMode,
17
+ isTTY,
18
+ printJSON,
19
+ statusColor,
20
+ } from "../lib/format.js";
21
+
22
+ interface App {
23
+ id: string;
24
+ name: string;
25
+ status: string;
26
+ domain?: string;
27
+ repoUrl?: string;
28
+ branch?: string;
29
+ sourceType?: string;
30
+ builds?: Array<{ id: string; status: string }>;
31
+ }
32
+
33
+ /**
34
+ * Builds the `up` command:
35
+ * - upload local code (or `[path]`) as a tarball
36
+ * - target a service via --service / linked / first-in-project
37
+ * - --ci streams build logs only and exits when build finishes
38
+ * - --detach returns immediately after upload
39
+ */
40
+ export function registerUp(program: Command) {
41
+ const up = program
42
+ .command("up")
43
+ .description("Upload and deploy code to Lizard")
44
+ .argument("[path]", "Path to deploy (default: current directory)")
45
+ .option("-d, --detach", "Don't attach to the log stream")
46
+ .option("-c, --ci", "Stream build logs only, exit on completion")
47
+ .option("-s, --service <name>", "Service to deploy to (defaults to linked)")
48
+ .option("--no-gitignore", "Don't ignore paths from .gitignore")
49
+ .option("--region <code>", "Region to create the service in (new services only)")
50
+ .option("--build-command <cmd>", "Build command to run (e.g. 'npm run build')")
51
+ .option("--start-command <cmd>", "Start command to run (e.g. 'node dist/index.js')")
52
+ .option("--pre-deploy-command <cmd>", "Pre-deploy command (e.g. 'node dist/migrate.js')")
53
+ .option("--port <number>", "Container port (default: 3000)")
54
+ .action(async (pathArg: string | undefined, opts, cmd) => {
55
+ const merged = cmd.optsWithGlobals();
56
+ const serviceFlag = merged.service ?? opts.service;
57
+ const projectFlag = merged.project;
58
+ const region: string | undefined = opts.region;
59
+
60
+ // Run init flow if cwd isn't linked yet
61
+ await ensureLinked({ projectName: projectFlag });
62
+
63
+ // Resolve target service: --service flag → linked → first-in-project → prompt-or-fail
64
+ const ctx = await resolveContext({
65
+ projectFlag,
66
+ serviceFlag,
67
+ });
68
+ const projectId = ctx.projectId;
69
+ const scope = getScope(ctx);
70
+
71
+ const targetPath = pathArg ? path.resolve(pathArg) : process.cwd();
72
+
73
+ // `up` always uploads a local tarball. For redeploy of an existing
74
+ // build without re-uploading, use `lizard redeploy`.
75
+ await deployFromLocal({
76
+ projectId,
77
+ scope,
78
+ targetPath,
79
+ useGitignore: opts.gitignore !== false,
80
+ serviceFlag,
81
+ existingServiceId: ctx.service?.id,
82
+ region,
83
+ buildCommand: opts.buildCommand,
84
+ startCommand: opts.startCommand,
85
+ preDeployCommand: opts.preDeployCommand,
86
+ port: opts.port ? parseInt(opts.port, 10) : undefined,
87
+ opts,
88
+ });
89
+ });
90
+
91
+ // `lizard up status <id>` — show build/deploy status
92
+ up
93
+ .command("status")
94
+ .argument("<id>", "App or deploy ID")
95
+ .description("Show deployment status")
96
+ .action(async (id: string) => {
97
+ const app = await api.get<App>(`/api/apps/${id}`);
98
+ if (isJSONMode()) {
99
+ printJSON(app);
100
+ return;
101
+ }
102
+ console.log(`${chalk.bold(app.name)} ${statusColor(app.status)}`);
103
+ if (app.domain) console.log(` URL: ${chalk.cyan(`https://${app.domain}`)}`);
104
+ if (app.builds?.length)
105
+ console.log(` Latest build: ${statusColor(app.builds[0].status)}`);
106
+ });
107
+ }
108
+
109
+ // ── deploy strategies ────────────────────────────────────────────────────────
110
+
111
+ async function deployFromLocal(args: {
112
+ projectId: string;
113
+ scope: ResourceScope;
114
+ targetPath: string;
115
+ buildCommand?: string;
116
+ startCommand?: string;
117
+ preDeployCommand?: string;
118
+ port?: number;
119
+ useGitignore: boolean;
120
+ serviceFlag: string | undefined;
121
+ existingServiceId: string | undefined;
122
+ region: string | undefined;
123
+ opts: any;
124
+ }) {
125
+ const defaultName = args.serviceFlag || getDefaultAppName(args.targetPath);
126
+ info(
127
+ `${args.existingServiceId ? "Uploading" : "Creating service from"} ${chalk.dim(args.targetPath)}`,
128
+ );
129
+
130
+ let appName = args.serviceFlag || defaultName;
131
+ if (!args.existingServiceId && !args.serviceFlag && isTTY()) {
132
+ const nameInput = await prompt(`Service name [${defaultName}]: `);
133
+ appName = nameInput || defaultName;
134
+ }
135
+
136
+ const files = getUploadFiles(args.targetPath, args.useGitignore);
137
+ if (files.length === 0) {
138
+ throw new Error(
139
+ "No files to upload. Run from a directory with files, or pass a path: `lizard up <path>`.",
140
+ );
141
+ }
142
+ info(chalk.dim(` ${files.length} files selected`));
143
+
144
+ const detectedPort = args.port === undefined ? detectLocalPort(args.targetPath) : undefined;
145
+ if (detectedPort) info(`Detected port ${chalk.bold(detectedPort)} from Dockerfile`);
146
+
147
+ const tarball = await createTarball(files, args.targetPath);
148
+ info(chalk.dim(` Tarball: ${(tarball.length / 1024 / 1024).toFixed(1)} MB`));
149
+
150
+ const spinner = ora("Uploading...").start();
151
+ let newApp: App & { buildId?: string };
152
+ try {
153
+ const resolvedPort = args.port ?? detectedPort;
154
+ const qs = new URLSearchParams();
155
+ // Only send port when explicitly given or detected — lets server keep the stored
156
+ // containerPort on redeploy instead of overwriting it with the 3000 default.
157
+ if (resolvedPort !== undefined) qs.set("port", String(resolvedPort));
158
+ if (!args.existingServiceId) {
159
+ qs.set("name", appName);
160
+ if (args.region) qs.set("region", args.region);
161
+ // New services with no detected port default to 3000
162
+ if (resolvedPort === undefined) qs.set("port", "3000");
163
+ }
164
+ if (args.existingServiceId) qs.set("appId", args.existingServiceId);
165
+ if (args.buildCommand) qs.set("buildCommand", args.buildCommand);
166
+ if (args.startCommand) qs.set("startCommand", args.startCommand);
167
+ if (args.preDeployCommand) qs.set("preDeployCommand", args.preDeployCommand);
168
+ if (args.scope.workspaceId) qs.set("workspaceId", args.scope.workspaceId);
169
+
170
+ const url = `${getBaseURL()}/api/projects/${args.projectId}/apps/upload?${qs.toString()}`;
171
+ const res = await fetch(url, {
172
+ method: "POST",
173
+ headers: {
174
+ "Content-Type": "application/octet-stream",
175
+ Authorization: `Bearer ${(await import("../lib/auth.js")).getToken()}`,
176
+ },
177
+ body: tarball.buffer as ArrayBuffer,
178
+ });
179
+ if (!res.ok) {
180
+ const text = await res.text();
181
+ throw new Error(
182
+ `Upload failed (${res.status}): ${text || res.statusText}`,
183
+ );
184
+ }
185
+ newApp = (await res.json()) as App & { buildId?: string };
186
+ spinner.succeed(`Service ${chalk.bold(newApp.name)} ${args.existingServiceId ? "updated" : "created"}`);
187
+ } catch (err: any) {
188
+ spinner.fail("Upload failed");
189
+ throw err;
190
+ }
191
+
192
+ saveServiceToConfig(args.projectId, newApp.id, newApp.name);
193
+
194
+ if (args.opts.detach) {
195
+ isJSONMode()
196
+ ? printJSON({ appId: newApp.id, version: 1, status: "deploying" })
197
+ : success(`Deploy started ${chalk.dim(`lizard up status ${newApp.id}`)}`);
198
+ return;
199
+ }
200
+ await streamBuildLogs(newApp.id, args.opts.ci);
201
+ }
202
+
203
+ // ── helpers ──────────────────────────────────────────────────────────────────
204
+
205
+ function saveServiceToConfig(_projectId: string, serviceId: string, serviceName: string) {
206
+ try {
207
+ updateProjectLink({ serviceId, serviceName });
208
+ } catch {}
209
+ }
210
+
211
+ function getDefaultAppName(cwd: string = process.cwd()): string {
212
+ try {
213
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"));
214
+ if (pkg.name) return pkg.name.replace(/^@[^/]+\//, "");
215
+ } catch {}
216
+ return path.basename(cwd);
217
+ }
218
+
219
+ function getUploadFiles(cwd: string, useGitignore: boolean): string[] {
220
+ if (useGitignore) {
221
+ try {
222
+ // `-z` outputs NUL-separated paths and disables git's default behaviour
223
+ // of double-quoting names with unusual characters. Without it, a file
224
+ // called `weird\nname.txt` would either get quoted (and never matched
225
+ // by tar) or split the listing across newlines.
226
+ const tracked = execSync("git ls-files -z", {
227
+ cwd,
228
+ stdio: ["pipe", "pipe", "pipe"],
229
+ })
230
+ .toString("utf8")
231
+ .split("\0")
232
+ .filter(Boolean);
233
+ const untracked = execSync("git ls-files --others --exclude-standard -z", {
234
+ cwd,
235
+ stdio: ["pipe", "pipe", "pipe"],
236
+ })
237
+ .toString("utf8")
238
+ .split("\0")
239
+ .filter(Boolean);
240
+ return [...new Set([...tracked, ...untracked])];
241
+ } catch {
242
+ // fall through to manual collection
243
+ }
244
+ }
245
+ return collectFilesManually(cwd, cwd);
246
+ }
247
+
248
+ const EXCLUDE_DIRS = new Set([
249
+ "node_modules",
250
+ ".git",
251
+ "dist",
252
+ ".next",
253
+ "build",
254
+ "__pycache__",
255
+ ".venv",
256
+ "venv",
257
+ ".cache",
258
+ "coverage",
259
+ ".turbo",
260
+ ".vercel",
261
+ ]);
262
+ const EXCLUDE_EXT = new Set([".pyc", ".pyo", ".log", ".DS_Store"]);
263
+
264
+ function collectFilesManually(root: string, dir: string): string[] {
265
+ const results: string[] = [];
266
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
267
+ if (EXCLUDE_DIRS.has(entry.name)) continue;
268
+ if (EXCLUDE_EXT.has(path.extname(entry.name))) continue;
269
+ const full = path.join(dir, entry.name);
270
+ if (entry.isDirectory()) results.push(...collectFilesManually(root, full));
271
+ else results.push(path.relative(root, full));
272
+ }
273
+ return results;
274
+ }
275
+
276
+ function createTarball(files: string[], cwd: string): Promise<Uint8Array> {
277
+ return new Promise((resolve, reject) => {
278
+ const chunks: Uint8Array[] = [];
279
+ // `--null` makes tar read NUL-separated paths from stdin, matching what
280
+ // `git ls-files -z` writes. Newline-separated input would split filenames
281
+ // containing `\n` across multiple entries. Both bsdtar (macOS) and GNU
282
+ // tar accept `--null` before `-T -`.
283
+ const tar = spawn("tar", ["--null", "-czf", "-", "-T", "-"], { cwd });
284
+ tar.stdout.on("data", (c: Buffer) => chunks.push(c));
285
+ tar.stderr.on("data", () => {});
286
+ tar.on("close", (code: number) => {
287
+ if (code === 0) {
288
+ const total = chunks.reduce((n, c) => n + c.length, 0);
289
+ const out = new Uint8Array(total);
290
+ let off = 0;
291
+ for (const c of chunks) {
292
+ out.set(c, off);
293
+ off += c.length;
294
+ }
295
+ resolve(out);
296
+ } else {
297
+ reject(new Error(`tar exited ${code}`));
298
+ }
299
+ });
300
+ if (files.length > 0) tar.stdin.write(files.join("\0") + "\0");
301
+ tar.stdin.end();
302
+ });
303
+ }
304
+
305
+ function detectLocalPort(dir: string): number | undefined {
306
+ for (const name of ["Dockerfile", "dockerfile", "Dockerfile.production"]) {
307
+ try {
308
+ const text = fs.readFileSync(path.join(dir, name), "utf8");
309
+ const match = text.match(/^EXPOSE\s+(\d+)/m);
310
+ if (match) return parseInt(match[1], 10);
311
+ } catch {}
312
+ }
313
+ return undefined;
314
+ }
315
+
316
+ function prompt(question: string): Promise<string> {
317
+ return new Promise((resolve) => {
318
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
319
+ rl.question(question, (answer) => {
320
+ rl.close();
321
+ resolve(answer.trim());
322
+ });
323
+ });
324
+ }
325
+
326
+ async function streamBuildLogs(appId: string, ciMode: boolean = false) {
327
+ const spinner = ora("Waiting for build...").start();
328
+ let buildId: string | null = null;
329
+ for (let i = 0; i < 30; i++) {
330
+ await sleep(2000);
331
+ try {
332
+ const app = await api.get<App>(`/api/apps/${appId}`);
333
+ if (app.builds?.length) {
334
+ const latest = app.builds[0];
335
+ if (["building", "deploying", "running", "failed"].includes(latest.status)) {
336
+ buildId = latest.id;
337
+ break;
338
+ }
339
+ }
340
+ } catch {}
341
+ }
342
+ spinner.stop();
343
+ if (!buildId) {
344
+ info(chalk.dim("No build found. Check `lizard up status <id>`."));
345
+ return;
346
+ }
347
+ info(chalk.dim("Streaming build logs...\n"));
348
+
349
+ // Stream with auto-reconnect — connections can drop mid-build (Cloudflare
350
+ // idle timeout, network blips). Reconnect until the build itself reports
351
+ // a terminal status, with a hard cap so we don't loop forever.
352
+ const deadline = Date.now() + 15 * 60 * 1000; // 15 min max
353
+ while (Date.now() < deadline) {
354
+ let dropped = false;
355
+ try {
356
+ await streamSSE(`/api/builds/${buildId}/logs`, (event, data) => {
357
+ if (event === "done" || event === "error") {
358
+ if (event === "error") emitBuildError(data);
359
+ else emitBuildDone();
360
+ return false;
361
+ }
362
+ emitBuildLine(data);
363
+ return true;
364
+ });
365
+ } catch {
366
+ dropped = true;
367
+ }
368
+
369
+ // Whether we got a clean SSE end or a dropped connection, check the
370
+ // build state — terminal status means we stop reconnecting.
371
+ try {
372
+ const build = await api.get<{ status: string }>(`/api/builds/${buildId}`);
373
+ if (build.status === "done" || build.status === "failed") break;
374
+ } catch {}
375
+
376
+ if (!dropped) break; // clean SSE end — don't reconnect
377
+ await sleep(2000);
378
+ }
379
+
380
+ if (ciMode) return;
381
+
382
+ const app = await api.get<App>(`/api/apps/${appId}`);
383
+ if (app.status === "running") {
384
+ if (isJSONMode()) {
385
+ process.stdout.write(
386
+ JSON.stringify({
387
+ event: "deployed",
388
+ status: "running",
389
+ url: app.domain ? `https://${app.domain}` : null,
390
+ }) + "\n",
391
+ );
392
+ } else {
393
+ success(`Deployed! ${app.domain ? chalk.cyan(`https://${app.domain}`) : ""}`);
394
+ }
395
+ } else if (app.status === "failed") {
396
+ if (isJSONMode()) {
397
+ process.stdout.write(
398
+ JSON.stringify({ event: "failed", status: "failed" }) + "\n",
399
+ );
400
+ } else {
401
+ error("Deploy failed. Check logs with `lizard logs --build`");
402
+ }
403
+ } else if (app.status === "deploying") {
404
+ if (isJSONMode()) {
405
+ process.stdout.write(
406
+ JSON.stringify({ event: "deploying", status: "deploying" }) + "\n",
407
+ );
408
+ } else {
409
+ info(chalk.dim("Still deploying... check status with `lizard ps`"));
410
+ }
411
+ }
412
+ }
413
+
414
+ // ── build stream emitters ────────────────────────────────────────────────────
415
+
416
+ function emitBuildLine(data: string) {
417
+ let parsed: any;
418
+ try {
419
+ parsed = JSON.parse(data);
420
+ } catch {
421
+ parsed = { line: data };
422
+ }
423
+ if (typeof parsed === "string") parsed = { line: parsed };
424
+ const line = parsed.line ?? parsed.message ?? "";
425
+
426
+ if (isJSONMode()) {
427
+ process.stdout.write(
428
+ JSON.stringify({ event: "log", line, ...stripLine(parsed) }) + "\n",
429
+ );
430
+ return;
431
+ }
432
+ if (line) process.stdout.write(line + "\n");
433
+ else process.stdout.write(data + "\n");
434
+ }
435
+
436
+ function stripLine(obj: any): Record<string, unknown> {
437
+ const { line: _l, message: _m, ...rest } = obj;
438
+ return rest;
439
+ }
440
+
441
+ function emitBuildDone() {
442
+ if (isJSONMode()) {
443
+ process.stdout.write(JSON.stringify({ event: "done" }) + "\n");
444
+ } else {
445
+ success("Build complete");
446
+ }
447
+ }
448
+
449
+ function emitBuildError(data: string) {
450
+ if (isJSONMode()) {
451
+ process.stdout.write(
452
+ JSON.stringify({ event: "error", message: data }) + "\n",
453
+ );
454
+ } else {
455
+ error(`Build failed: ${data}`);
456
+ }
457
+ }
458
+
459
+ function sleep(ms: number) {
460
+ return new Promise((r) => setTimeout(r, ms));
461
+ }
@@ -0,0 +1,87 @@
1
+ import chalk from "chalk";
2
+ import { Command } from "commander";
3
+ import { info, success, isJSONMode, printJSON } from "../lib/format.js";
4
+ import { CURRENT_VERSION, getLatestVersion, selfUpdate } from "../lib/updater.js";
5
+
6
+ export function registerUpgrade(program: Command) {
7
+ program
8
+ .command("upgrade")
9
+ .description("Upgrade Lizard CLI to latest version")
10
+ .option("--check", "Only check for updates without installing")
11
+ .action(async (opts) => {
12
+ const result = await getLatestVersion();
13
+
14
+ if (result.kind === "rate-limited") {
15
+ if (isJSONMode()) {
16
+ printJSON({
17
+ currentVersion: CURRENT_VERSION,
18
+ error: { code: "rate_limited", resetAt: result.resetAt },
19
+ });
20
+ return;
21
+ }
22
+ const eta =
23
+ result.resetAt > 0
24
+ ? ` Try again in ~${Math.max(1, Math.ceil((result.resetAt * 1000 - Date.now()) / 60_000))} min.`
25
+ : "";
26
+ info(`GitHub API rate-limited (60 req/h per IP).${eta}`);
27
+ return;
28
+ }
29
+
30
+ if (result.kind === "error") {
31
+ if (isJSONMode()) {
32
+ printJSON({
33
+ currentVersion: CURRENT_VERSION,
34
+ error: { code: "check_failed" },
35
+ });
36
+ return;
37
+ }
38
+ info("Could not check for updates. Check your internet connection.");
39
+ return;
40
+ }
41
+
42
+ const latest = result.version;
43
+ const updateAvailable = latest !== CURRENT_VERSION;
44
+
45
+ if (isJSONMode() && opts.check) {
46
+ printJSON({ currentVersion: CURRENT_VERSION, latestVersion: latest, updateAvailable });
47
+ return;
48
+ }
49
+
50
+ if (!updateAvailable) {
51
+ if (isJSONMode()) {
52
+ printJSON({
53
+ currentVersion: CURRENT_VERSION,
54
+ latestVersion: latest,
55
+ updateAvailable: false,
56
+ upgraded: false,
57
+ });
58
+ return;
59
+ }
60
+ info(`Already up to date (v${CURRENT_VERSION})`);
61
+ return;
62
+ }
63
+
64
+ if (opts.check) {
65
+ info(`Update available: v${CURRENT_VERSION} → ${chalk.green("v" + latest)}`);
66
+ info(chalk.dim(`Run \`lizard upgrade\` to install`));
67
+ return;
68
+ }
69
+
70
+ info(`Upgrading v${CURRENT_VERSION} → ${chalk.green("v" + latest)}...`);
71
+
72
+ try {
73
+ await selfUpdate((msg) => info(chalk.dim(msg)));
74
+ if (isJSONMode()) {
75
+ printJSON({
76
+ previousVersion: CURRENT_VERSION,
77
+ latestVersion: latest,
78
+ upgraded: true,
79
+ });
80
+ } else {
81
+ success(`Upgraded to v${latest}`);
82
+ }
83
+ } catch (e: any) {
84
+ throw new Error(`Upgrade failed: ${e.message}`);
85
+ }
86
+ });
87
+ }
@@ -1,27 +1,55 @@
1
1
  import chalk from "chalk";
2
2
  import { Command } from "commander";
3
3
  import { api } from "../lib/api.js";
4
+ import { getProjectLink } from "../lib/config.js";
4
5
  import { isJSONMode, printJSON } from "../lib/format.js";
5
6
 
6
7
  export function registerWhoami(program: Command) {
7
8
  program
8
9
  .command("whoami")
9
- .description("Show current user")
10
+ .description("Show current user, active workspace, and linked project")
10
11
  .action(async () => {
11
12
  const user = await api.get<{
12
13
  id: string;
13
14
  username: string;
14
15
  avatarUrl?: string;
15
16
  hasGithubApp?: boolean;
17
+ activeWorkspaceId?: string | null;
18
+ activeWorkspaceName?: string | null;
19
+ defaultWorkspaceId?: string | null;
16
20
  }>("/api/auth/me");
17
21
 
22
+ const link = getProjectLink();
23
+ const project = link
24
+ ? {
25
+ id: link.projectId,
26
+ name: link.projectName,
27
+ workspaceId: link.workspaceId ?? null,
28
+ workspaceName: link.workspaceName ?? null,
29
+ }
30
+ : null;
31
+
18
32
  if (isJSONMode()) {
19
- printJSON(user);
33
+ printJSON({ ...user, project });
34
+ return;
35
+ }
36
+
37
+ console.log(chalk.bold(user.username));
38
+ if (user.hasGithubApp) {
39
+ console.log(chalk.dim("GitHub App: connected"));
40
+ }
41
+ if (user.activeWorkspaceName) {
42
+ console.log(chalk.dim("Workspace: ") + user.activeWorkspaceName);
43
+ }
44
+
45
+ if (project) {
46
+ const label = project.name || project.id;
47
+ const wsTag = project.workspaceName ? chalk.dim(` (${project.workspaceName})`) : "";
48
+ console.log(chalk.dim("Project: ") + label + wsTag + chalk.dim(" (linked here)"));
20
49
  } else {
21
- console.log(chalk.bold(user.username));
22
- if (user.hasGithubApp) {
23
- console.log(chalk.dim("GitHub App: connected"));
24
- }
50
+ console.log(
51
+ chalk.dim("Project: none — run `lizard init` in a project directory"),
52
+ );
25
53
  }
26
54
  });
27
55
  }
@@ -0,0 +1,44 @@
1
+ import chalk from "chalk";
2
+ import { Command } from "commander";
3
+ import { fetchWorkspaces } from "../lib/picker.js";
4
+ import { isJSONMode, printJSON, table } from "../lib/format.js";
5
+
6
+ /**
7
+ * `lizard workspace` — workspace info.
8
+ *
9
+ * Member management (invite/remove/rename) intentionally lives in the
10
+ * dashboard, not here, to keep CLI surface narrow (Railway model).
11
+ */
12
+ export function registerWorkspace(program: Command) {
13
+ const ws = program
14
+ .command("workspace")
15
+ .description("Workspace info");
16
+
17
+ ws.command("list")
18
+ .alias("ls")
19
+ .description("List workspaces you belong to")
20
+ .action(async () => {
21
+ const list = await fetchWorkspaces();
22
+
23
+ if (isJSONMode()) {
24
+ printJSON(list);
25
+ return;
26
+ }
27
+
28
+ if (list.length === 0) {
29
+ console.log("No workspaces. The backend should always return a personal workspace.");
30
+ return;
31
+ }
32
+
33
+ table(
34
+ ["Name", "Slug", "Role", "Projects", "Personal"],
35
+ list.map((w) => [
36
+ w.name,
37
+ w.slug,
38
+ w.role,
39
+ String(w.projectCount ?? 0),
40
+ w.isPersonal ? chalk.green("✓") : "",
41
+ ]),
42
+ );
43
+ });
44
+ }