@lizard-build/cli 0.1.0 → 0.3.30

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 (184) hide show
  1. package/.github/workflows/release.yml +90 -0
  2. package/AGENTS.md +113 -0
  3. package/README.md +41 -0
  4. package/dist/commands/add.js +318 -45
  5. package/dist/commands/add.js.map +1 -1
  6. package/dist/commands/config.d.ts +2 -0
  7. package/dist/commands/config.js +68 -0
  8. package/dist/commands/config.js.map +1 -0
  9. package/dist/commands/docs.d.ts +2 -0
  10. package/dist/commands/docs.js +13 -0
  11. package/dist/commands/docs.js.map +1 -0
  12. package/dist/commands/domain.d.ts +9 -0
  13. package/dist/commands/domain.js +195 -0
  14. package/dist/commands/domain.js.map +1 -0
  15. package/dist/commands/git.js +175 -36
  16. package/dist/commands/git.js.map +1 -1
  17. package/dist/commands/init.d.ts +24 -0
  18. package/dist/commands/init.js +128 -86
  19. package/dist/commands/init.js.map +1 -1
  20. package/dist/commands/link.d.ts +7 -0
  21. package/dist/commands/link.js +104 -33
  22. package/dist/commands/link.js.map +1 -1
  23. package/dist/commands/login.js +4 -3
  24. package/dist/commands/login.js.map +1 -1
  25. package/dist/commands/logs.js +223 -30
  26. package/dist/commands/logs.js.map +1 -1
  27. package/dist/commands/open.js +3 -2
  28. package/dist/commands/open.js.map +1 -1
  29. package/dist/commands/port.d.ts +7 -0
  30. package/dist/commands/port.js +49 -0
  31. package/dist/commands/port.js.map +1 -0
  32. package/dist/commands/projects.js +36 -6
  33. package/dist/commands/projects.js.map +1 -1
  34. package/dist/commands/ps.js +32 -39
  35. package/dist/commands/ps.js.map +1 -1
  36. package/dist/commands/redeploy.js +48 -8
  37. package/dist/commands/redeploy.js.map +1 -1
  38. package/dist/commands/regions.js +2 -5
  39. package/dist/commands/regions.js.map +1 -1
  40. package/dist/commands/restart.js +84 -10
  41. package/dist/commands/restart.js.map +1 -1
  42. package/dist/commands/run.d.ts +9 -0
  43. package/dist/commands/run.js +61 -22
  44. package/dist/commands/run.js.map +1 -1
  45. package/dist/commands/scale.d.ts +10 -0
  46. package/dist/commands/scale.js +166 -0
  47. package/dist/commands/scale.js.map +1 -0
  48. package/dist/commands/secrets.js +200 -89
  49. package/dist/commands/secrets.js.map +1 -1
  50. package/dist/commands/service-set.d.ts +49 -0
  51. package/dist/commands/service-set.js +552 -0
  52. package/dist/commands/service-set.js.map +1 -0
  53. package/dist/commands/service-show.d.ts +11 -0
  54. package/dist/commands/service-show.js +44 -0
  55. package/dist/commands/service-show.js.map +1 -0
  56. package/dist/commands/service.d.ts +8 -0
  57. package/dist/commands/service.js +262 -0
  58. package/dist/commands/service.js.map +1 -0
  59. package/dist/commands/skill.d.ts +2 -0
  60. package/dist/commands/skill.js +146 -0
  61. package/dist/commands/skill.js.map +1 -0
  62. package/dist/commands/ssh.d.ts +2 -0
  63. package/dist/commands/ssh.js +161 -0
  64. package/dist/commands/ssh.js.map +1 -0
  65. package/dist/commands/status.d.ts +7 -0
  66. package/dist/commands/status.js +49 -38
  67. package/dist/commands/status.js.map +1 -1
  68. package/dist/commands/unlink.d.ts +5 -0
  69. package/dist/commands/unlink.js +18 -0
  70. package/dist/commands/unlink.js.map +1 -0
  71. package/dist/commands/up.d.ts +9 -0
  72. package/dist/commands/up.js +417 -0
  73. package/dist/commands/up.js.map +1 -0
  74. package/dist/commands/upgrade.d.ts +2 -0
  75. package/dist/commands/upgrade.js +79 -0
  76. package/dist/commands/upgrade.js.map +1 -0
  77. package/dist/commands/whoami.js +26 -6
  78. package/dist/commands/whoami.js.map +1 -1
  79. package/dist/commands/workspace.d.ts +8 -0
  80. package/dist/commands/workspace.js +36 -0
  81. package/dist/commands/workspace.js.map +1 -0
  82. package/dist/index.js +209 -82
  83. package/dist/index.js.map +1 -1
  84. package/dist/lib/api.d.ts +17 -2
  85. package/dist/lib/api.js +85 -51
  86. package/dist/lib/api.js.map +1 -1
  87. package/dist/lib/auth.d.ts +3 -11
  88. package/dist/lib/auth.js +16 -36
  89. package/dist/lib/auth.js.map +1 -1
  90. package/dist/lib/config.d.ts +36 -15
  91. package/dist/lib/config.js +71 -58
  92. package/dist/lib/config.js.map +1 -1
  93. package/dist/lib/format.d.ts +1 -0
  94. package/dist/lib/format.js +17 -4
  95. package/dist/lib/format.js.map +1 -1
  96. package/dist/lib/name.d.ts +11 -0
  97. package/dist/lib/name.js +26 -0
  98. package/dist/lib/name.js.map +1 -0
  99. package/dist/lib/picker.d.ts +32 -0
  100. package/dist/lib/picker.js +91 -0
  101. package/dist/lib/picker.js.map +1 -0
  102. package/dist/lib/resolve.d.ts +85 -0
  103. package/dist/lib/resolve.js +203 -0
  104. package/dist/lib/resolve.js.map +1 -0
  105. package/dist/lib/updater.d.ts +16 -0
  106. package/dist/lib/updater.js +102 -0
  107. package/dist/lib/updater.js.map +1 -0
  108. package/lizard-wrapper.sh +2 -0
  109. package/package.json +11 -3
  110. package/skill-data/core/SKILL.md +239 -0
  111. package/src/commands/add.ts +388 -56
  112. package/src/commands/config.ts +80 -0
  113. package/src/commands/docs.ts +15 -0
  114. package/src/commands/domain.ts +248 -0
  115. package/src/commands/git.ts +201 -40
  116. package/src/commands/init.ts +149 -100
  117. package/src/commands/link.ts +127 -35
  118. package/src/commands/login.ts +4 -3
  119. package/src/commands/logs.ts +283 -27
  120. package/src/commands/open.ts +3 -2
  121. package/src/commands/port.ts +57 -0
  122. package/src/commands/projects.ts +43 -6
  123. package/src/commands/ps.ts +39 -60
  124. package/src/commands/redeploy.ts +51 -10
  125. package/src/commands/regions.ts +2 -6
  126. package/src/commands/restart.ts +84 -10
  127. package/src/commands/run.ts +68 -24
  128. package/src/commands/scale.ts +216 -0
  129. package/src/commands/secrets.ts +277 -100
  130. package/src/commands/service-set.ts +669 -0
  131. package/src/commands/service-show.ts +52 -0
  132. package/src/commands/service.ts +298 -0
  133. package/src/commands/skill.ts +157 -0
  134. package/src/commands/ssh.ts +176 -0
  135. package/src/commands/status.ts +51 -46
  136. package/src/commands/unlink.ts +17 -0
  137. package/src/commands/up.ts +461 -0
  138. package/src/commands/upgrade.ts +87 -0
  139. package/src/commands/whoami.ts +34 -6
  140. package/src/commands/workspace.ts +44 -0
  141. package/src/index.ts +219 -85
  142. package/src/lib/api.ts +114 -51
  143. package/src/lib/auth.ts +22 -46
  144. package/src/lib/config.ts +100 -65
  145. package/src/lib/format.ts +18 -4
  146. package/src/lib/name.ts +27 -0
  147. package/src/lib/picker.ts +133 -0
  148. package/src/lib/resolve.ts +285 -0
  149. package/src/lib/updater.ts +106 -0
  150. package/test/cli.test.ts +491 -0
  151. package/test/fixtures/hello-app/Dockerfile +5 -0
  152. package/test/fixtures/hello-app/index.js +5 -0
  153. package/test/unit/api.test.ts +66 -0
  154. package/test/unit/config.test.ts +94 -0
  155. package/test/unit/init.test.ts +211 -0
  156. package/test/unit/json.test.ts +208 -0
  157. package/test/unit/picker.test.ts +161 -0
  158. package/test/unit/resolve.test.ts +124 -0
  159. package/test/unit/service-set.test.ts +355 -0
  160. package/vitest.config.ts +10 -0
  161. package/dist/commands/connect.d.ts +0 -2
  162. package/dist/commands/connect.js +0 -117
  163. package/dist/commands/connect.js.map +0 -1
  164. package/dist/commands/context.d.ts +0 -2
  165. package/dist/commands/context.js +0 -71
  166. package/dist/commands/context.js.map +0 -1
  167. package/dist/commands/deploy.d.ts +0 -2
  168. package/dist/commands/deploy.js +0 -120
  169. package/dist/commands/deploy.js.map +0 -1
  170. package/dist/commands/destroy.d.ts +0 -2
  171. package/dist/commands/destroy.js +0 -51
  172. package/dist/commands/destroy.js.map +0 -1
  173. package/dist/commands/update.d.ts +0 -2
  174. package/dist/commands/update.js +0 -41
  175. package/dist/commands/update.js.map +0 -1
  176. package/dist/commands/version.d.ts +0 -2
  177. package/dist/commands/version.js +0 -37
  178. package/dist/commands/version.js.map +0 -1
  179. package/src/commands/connect.ts +0 -145
  180. package/src/commands/context.ts +0 -93
  181. package/src/commands/deploy.ts +0 -153
  182. package/src/commands/destroy.ts +0 -51
  183. package/src/commands/update.ts +0 -44
  184. package/src/commands/version.ts +0 -37
@@ -1,65 +1,70 @@
1
1
  import chalk from "chalk";
2
2
  import { Command } from "commander";
3
- import { api } from "../lib/api.js";
4
- import { resolveProjectId, findProjectConfig } from "../lib/config.js";
5
- import { isJSONMode, printJSON, statusColor, table } from "../lib/format.js";
3
+ import { getProjectLink, updateProjectLink } from "../lib/config.js";
4
+ import { lookupProjectWorkspace } from "../lib/resolve.js";
5
+ import { isJSONMode, printJSON, info } from "../lib/format.js";
6
6
 
7
+ /**
8
+ * `lizard status` — print the linked workspace / project / service for
9
+ * the current working directory. Mirrors `railway status`.
10
+ *
11
+ * Lazy-fills workspaceId into the link when missing so legacy configs
12
+ * surface their workspace too.
13
+ */
7
14
  export function registerStatus(program: Command) {
8
15
  program
9
16
  .command("status")
10
- .description("Show project status")
17
+ .description("Show linked workspace, project, and service")
11
18
  .action(async () => {
12
- const projectId = resolveProjectId(program.opts().project);
13
- const config = findProjectConfig();
14
-
15
- const [project, services] = await Promise.all([
16
- api.get<{ id: string; name: string; slug: string }>(
17
- `/api/projects/${projectId}`,
18
- ),
19
- api.get<{ apps: any[]; addons: any[] }>(
20
- `/api/projects/${projectId}/services`,
21
- ),
22
- ]);
23
-
24
- if (isJSONMode()) {
25
- printJSON({ project, services, environment: config?.environment || "production" });
19
+ const link = getProjectLink();
20
+ if (!link) {
21
+ if (isJSONMode()) {
22
+ printJSON({ cwd: process.cwd(), linked: false });
23
+ } else {
24
+ info("Not linked. Run `lizard init` to create or link a project.");
25
+ }
26
26
  return;
27
27
  }
28
28
 
29
- console.log(chalk.bold(project.name) + chalk.dim(` (${project.id})`));
30
- if (config?.environment) {
31
- console.log(chalk.dim(`Environment: ${config.environment}`));
29
+ // Backfill workspace info for legacy links (saved before workspaces existed)
30
+ let workspaceName = link.workspaceName;
31
+ if (!link.workspaceId) {
32
+ const fetched = await lookupProjectWorkspace(link.projectId);
33
+ if (fetched?.workspaceId) {
34
+ workspaceName = fetched.workspaceName ?? undefined;
35
+ try {
36
+ updateProjectLink({
37
+ workspaceId: fetched.workspaceId,
38
+ workspaceName,
39
+ });
40
+ } catch {}
41
+ }
32
42
  }
33
- console.log();
34
43
 
35
- const allServices = [
36
- ...(services.apps || []).map((a: any) => ({
37
- name: a.name,
38
- type: "app",
39
- status: a.status,
40
- url: a.domain ? `https://${a.domain}` : "",
41
- })),
42
- ...(services.addons || []).map((a: any) => ({
43
- name: a.name || a.addonType,
44
- type: a.addonType || "addon",
45
- status: a.status,
46
- url: a.hostname || "",
47
- })),
48
- ];
44
+ const out = {
45
+ cwd: process.cwd(),
46
+ linked: true,
47
+ workspace: workspaceName ?? null,
48
+ workspaceId: link.workspaceId ?? null,
49
+ project: link.projectName ?? null,
50
+ projectId: link.projectId,
51
+ service: link.serviceName ?? null,
52
+ serviceId: link.serviceId ?? null,
53
+ };
49
54
 
50
- if (allServices.length === 0) {
51
- console.log(chalk.dim("No services"));
55
+ if (isJSONMode()) {
56
+ printJSON(out);
52
57
  return;
53
58
  }
54
59
 
55
- table(
56
- ["Name", "Type", "Status", "URL"],
57
- allServices.map((s) => [
58
- s.name,
59
- s.type,
60
- statusColor(s.status),
61
- s.url || chalk.dim("—"),
62
- ]),
60
+ const fmt = (v: string | null) => v ?? chalk.dim("—");
61
+
62
+ console.log(` ${chalk.dim("Workspace:")} ${fmt(out.workspace)}`);
63
+ console.log(` ${chalk.dim("Project:")} ${chalk.bold(out.project ?? link.projectId)}`);
64
+ console.log(
65
+ ` ${chalk.dim("Service:")} ${
66
+ out.service ? chalk.bold(out.service) : chalk.dim("(none `lizard service link`)")
67
+ }`,
63
68
  );
64
69
  });
65
70
  }
@@ -0,0 +1,17 @@
1
+ import { Command } from "commander";
2
+ import { clearProjectLink } from "../lib/config.js";
3
+ import { isJSONMode, printJSON, success } from "../lib/format.js";
4
+
5
+ /**
6
+ * `lizard unlink` — drops the cwd↔project mapping.
7
+ */
8
+ export function registerUnlink(program: Command) {
9
+ program
10
+ .command("unlink")
11
+ .description("Disassociate the current directory from any project")
12
+ .action(() => {
13
+ clearProjectLink();
14
+ if (isJSONMode()) printJSON({ status: "unlinked" });
15
+ else success("Directory unlinked");
16
+ });
17
+ }
@@ -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
+ }