@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,248 @@
1
+ import chalk from "chalk";
2
+ import * as p from "@clack/prompts";
3
+ import { Command } from "commander";
4
+ import { api } from "../lib/api.js";
5
+ import { resolveProjectId } from "../lib/config.js";
6
+ import { getActiveService } from "../lib/resolve.js";
7
+ import { success, isJSONMode, printJSON, table, isTTY } from "../lib/format.js";
8
+
9
+ interface DomainResponse {
10
+ ok?: boolean;
11
+ hostname: string;
12
+ generated?: boolean;
13
+ verified?: boolean;
14
+ txtRecord?: string;
15
+ txtValue?: string;
16
+ cnameTarget?: string;
17
+ }
18
+
19
+ interface AppLite {
20
+ id: string;
21
+ name: string;
22
+ domain?: string | null;
23
+ containerPort?: number | null;
24
+ }
25
+
26
+ /**
27
+ * `lizard domain` — domain management.
28
+ * bare → if no domain, auto-generate one; otherwise show current
29
+ * <hostname> → attach a custom domain
30
+ * delete <h> → remove a domain
31
+ * generate → force-generate a fresh *.onlizard.com subdomain
32
+ */
33
+ export function registerDomain(program: Command) {
34
+ const dom = program
35
+ .command("domain")
36
+ .alias("domains")
37
+ .argument("[hostname]", "Custom domain to attach (e.g. app.example.com)")
38
+ .description("Manage service domains")
39
+ .option("-s, --service <name>", "Service name or ID")
40
+ .option("-p, --project <id>", "Project name, slug, or ID")
41
+ .option("--port <n>", "Port to expose", parseIntOption)
42
+ .action(async (hostname: string | undefined, opts, _cmd) => {
43
+ const projectId = await resolveProjectId(opts.project);
44
+ const service = await getActiveService(opts.service, projectId);
45
+
46
+ if (!hostname) {
47
+ // Bare `lizard domain` — show or auto-generate.
48
+ const appRow = await api
49
+ .get<AppLite>(`/api/apps/${service.id}`)
50
+ .catch(() => null);
51
+ const existing = appRow?.domain;
52
+
53
+ if (existing) {
54
+ if (isJSONMode()) {
55
+ printJSON({ hostname: existing, generated: false });
56
+ } else {
57
+ console.log(chalk.cyan(`https://${existing}`));
58
+ console.log(
59
+ chalk.dim(
60
+ ` Reference from other services: ${chalk.cyan(`\${{${service.name}.LIZARD_PUBLIC_DOMAIN}}`)}`,
61
+ ),
62
+ );
63
+ }
64
+ return;
65
+ }
66
+
67
+ const result = await api
68
+ .post<DomainResponse>(`/api/apps/${service.id}/domains`, { generate: true })
69
+ .catch((err: any) => {
70
+ if (err?.status === 404) {
71
+ throw new Error(
72
+ "Domain endpoint not yet implemented. The API needs " +
73
+ "`POST /api/apps/{id}/domains` with body { generate: true }.",
74
+ );
75
+ }
76
+ throw err;
77
+ });
78
+
79
+ if (isJSONMode()) {
80
+ printJSON(result);
81
+ } else {
82
+ success(`Domain generated: ${chalk.cyan(`https://${result.hostname}`)}`);
83
+ console.log(
84
+ chalk.dim(
85
+ ` Reference from other services: ${chalk.cyan(`\${{${service.name}.LIZARD_PUBLIC_DOMAIN}}`)}`,
86
+ ),
87
+ );
88
+ }
89
+ return;
90
+ }
91
+
92
+ // Attach custom hostname
93
+ const result = await api
94
+ .post<DomainResponse>(`/api/apps/${service.id}/domains`, {
95
+ hostname,
96
+ port: opts.port,
97
+ })
98
+ .catch((err: any) => {
99
+ if (err?.status === 404) {
100
+ throw new Error(
101
+ "Domain endpoint not yet implemented. The API needs " +
102
+ "`POST /api/apps/{id}/domains` with body { hostname }.",
103
+ );
104
+ }
105
+ throw err;
106
+ });
107
+
108
+ if (isJSONMode()) {
109
+ printJSON(result);
110
+ return;
111
+ }
112
+
113
+ // Custom domain — print verification + DNS instructions
114
+ success(`Custom domain ${chalk.cyan(hostname)} registered (pending verification)`);
115
+ console.log();
116
+ console.log(chalk.bold("1) Verify ownership — add this TXT record at your DNS provider:"));
117
+ console.log(` ${chalk.dim("Name: ")}${chalk.cyan(result.txtRecord || `_lizard-verify.${hostname}`)}`);
118
+ console.log(` ${chalk.dim("Value:")} ${chalk.cyan(result.txtValue || "")}`);
119
+ console.log();
120
+ if (result.cnameTarget) {
121
+ console.log(chalk.bold("2) Point traffic — add this CNAME record:"));
122
+ console.log(` ${chalk.dim("Name: ")}${chalk.cyan(hostname)}`);
123
+ console.log(` ${chalk.dim("Value:")} ${chalk.cyan(result.cnameTarget)}`);
124
+ console.log(chalk.dim(` (${result.cnameTarget} is a multi-A record across all load balancers — no IP to track.)`));
125
+ console.log();
126
+ }
127
+ console.log(chalk.bold("3) Once both records propagate, run:"));
128
+ console.log(` ${chalk.cyan(`lizard domain verify ${hostname}`)}`);
129
+ console.log();
130
+ console.log(chalk.dim("HTTPS certificate will be issued automatically by Let's Encrypt on first request."));
131
+ });
132
+
133
+ // Subcommands intentionally don't redeclare -s/-p: Commander 14 binds a
134
+ // duplicate short flag to the parent, leaving the subcommand action with
135
+ // `opts.service === undefined`. Read parent values via optsWithGlobals().
136
+ dom
137
+ .command("generate")
138
+ .description("Generate a fresh *.onlizard.com subdomain")
139
+ .action(async (_opts, sub) => {
140
+ const opts = sub.optsWithGlobals();
141
+ const projectId = await resolveProjectId(opts.project);
142
+ const service = await getActiveService(opts.service, projectId);
143
+
144
+ const result = await api
145
+ .post<DomainResponse>(`/api/apps/${service.id}/domains`, { generate: true })
146
+ .catch((err: any) => {
147
+ if (err?.status === 404) {
148
+ throw new Error(
149
+ "Domain endpoint not yet implemented. The API needs " +
150
+ "`POST /api/apps/{id}/domains` with body { generate: true }.",
151
+ );
152
+ }
153
+ throw err;
154
+ });
155
+
156
+ if (isJSONMode()) {
157
+ printJSON(result);
158
+ } else {
159
+ success(`Domain generated: ${chalk.cyan(`https://${result.hostname}`)}`);
160
+ console.log(
161
+ chalk.dim(
162
+ ` Reference from other services: ${chalk.cyan(`\${{${service.name}.LIZARD_PUBLIC_DOMAIN}}`)}`,
163
+ ),
164
+ );
165
+ }
166
+ });
167
+
168
+ dom
169
+ .command("verify")
170
+ .argument("<hostname>", "Custom domain to verify")
171
+ .description("Check the TXT record and activate the domain")
172
+ .action(async (hostname: string, _opts, sub) => {
173
+ const opts = sub.optsWithGlobals();
174
+ const projectId = await resolveProjectId(opts.project);
175
+ const service = await getActiveService(opts.service, projectId);
176
+
177
+ const result = await api
178
+ .post<{ ok: boolean; verified: boolean; hostname?: string; message?: string }>(
179
+ `/api/apps/${service.id}/domains/verify`,
180
+ { hostname },
181
+ )
182
+ .catch((err: any) => {
183
+ if (err?.status === 404) {
184
+ throw new Error(
185
+ `No pending verification for ${hostname}. Run \`lizard domain ${hostname}\` first.`,
186
+ );
187
+ }
188
+ throw err;
189
+ });
190
+
191
+ if (isJSONMode()) {
192
+ printJSON(result);
193
+ return;
194
+ }
195
+
196
+ if (result.verified) {
197
+ success(`Domain ${chalk.cyan(hostname)} verified and active`);
198
+ console.log(chalk.dim(`https://${hostname} — TLS issues on first HTTPS request.`));
199
+ } else {
200
+ console.log(chalk.yellow(`Not verified yet.`));
201
+ if (result.message) console.log(chalk.dim(` ${result.message}`));
202
+ }
203
+ });
204
+
205
+ dom
206
+ .command("delete")
207
+ .alias("rm")
208
+ .argument("<hostname>", "Domain to remove")
209
+ .description("Remove a domain")
210
+ .option("-y, --yes", "Skip confirmation")
211
+ .action(async (hostname: string, _opts, sub) => {
212
+ const opts = sub.optsWithGlobals();
213
+ const projectId = await resolveProjectId(opts.project);
214
+ const service = await getActiveService(opts.service, projectId);
215
+
216
+ if (!opts.yes) {
217
+ if (!isTTY()) throw new Error("Use -y to confirm in non-interactive mode");
218
+ const confirm = await p.confirm({
219
+ message: `Remove domain ${chalk.bold(hostname)} from ${chalk.bold(service.name)}?`,
220
+ });
221
+ if (p.isCancel(confirm) || !confirm) process.exit(5);
222
+ }
223
+
224
+ await api
225
+ .delete(`/api/apps/${service.id}/domains/${encodeURIComponent(hostname)}`)
226
+ .catch((err: any) => {
227
+ if (err?.status === 404) {
228
+ throw new Error(
229
+ "Domain delete endpoint not yet implemented. The API needs " +
230
+ "`DELETE /api/apps/{id}/domains/{hostname}`.",
231
+ );
232
+ }
233
+ throw err;
234
+ });
235
+
236
+ if (isJSONMode()) {
237
+ printJSON({ domain: hostname, status: "deleted" });
238
+ } else {
239
+ success(`Domain ${chalk.cyan(hostname)} removed`);
240
+ }
241
+ });
242
+ }
243
+
244
+ function parseIntOption(v: string): number {
245
+ const n = parseInt(v, 10);
246
+ if (Number.isNaN(n)) throw new Error(`Invalid number: ${v}`);
247
+ return n;
248
+ }
@@ -1,80 +1,241 @@
1
1
  import chalk from "chalk";
2
+ import ora from "ora";
3
+ import * as readline from "node:readline";
4
+ import * as p from "@clack/prompts";
2
5
  import { Command } from "commander";
3
- import { api } from "../lib/api.js";
4
- import { resolveProjectId } from "../lib/config.js";
5
- import { success, info, isJSONMode, printJSON } from "../lib/format.js";
6
+ import { api, getBaseURL, streamSSE, withScope } from "../lib/api.js";
7
+ import { openURL } from "../lib/auth.js";
8
+ import { resolveProjectScope, resolveService } from "../lib/resolve.js";
9
+ import { success, error, info, isJSONMode, printJSON, isTTY, link } from "../lib/format.js";
10
+
11
+ interface GitHubInstallation {
12
+ id: number;
13
+ account: { login: string; type: string };
14
+ htmlUrl: string;
15
+ repoCount: number | null;
16
+ privateCount: number | null;
17
+ }
18
+
19
+ interface GitHubStatus {
20
+ installed: boolean;
21
+ installationId: number | null;
22
+ installations?: GitHubInstallation[];
23
+ manageUrl?: string;
24
+ error?: string;
25
+ }
6
26
 
7
27
  export function registerGit(program: Command) {
8
28
  const git = program
9
29
  .command("git")
10
- .description("Git integration");
30
+ .description("Git and GitHub integration");
11
31
 
32
+ // lizard git connect — install GitHub App for private repo access
12
33
  git
13
34
  .command("connect")
14
- .argument("<repo>", "GitHub repository (user/repo)")
15
- .description("Connect Git repository for auto-deploy")
16
- .option("--branch <name>", "Branch for auto-deploy", "main")
17
- .action(async (repo: string, opts) => {
18
- const projectId = resolveProjectId(program.opts().project);
19
-
20
- // This requires a server endpoint for programmatic webhook setup
21
- // For now, guide the user to use the dashboard
22
- if (isJSONMode()) {
23
- printJSON({
24
- error: "not_implemented",
25
- message: "Git connect via CLI requires server endpoint. Use the dashboard.",
26
- });
35
+ .description("Connect GitHub App to access private repositories")
36
+ .action(async () => {
37
+ // Check current status
38
+ const status = await api.get<GitHubStatus>("/api/github/status");
39
+
40
+ if (status.installed) {
41
+ if (isJSONMode()) {
42
+ printJSON({
43
+ installed: true,
44
+ installationId: status.installationId,
45
+ alreadyConnected: true,
46
+ });
47
+ return;
48
+ }
49
+ success("GitHub App is already connected.");
50
+ info(chalk.dim(" Use `lizard git status` to see connected repositories."));
51
+ return;
52
+ }
53
+
54
+ const installUrl = `${getBaseURL()}/api/auth/github/install`;
55
+
56
+ // Non-interactive callers (--json, piped) can't press Enter; return
57
+ // the install URL so they can drive the flow themselves and re-run.
58
+ if (isJSONMode() || !isTTY()) {
59
+ if (isJSONMode()) {
60
+ printJSON({ installed: false, installUrl });
61
+ return;
62
+ }
63
+ info(`Open this URL to connect GitHub:\n ${chalk.cyan(installUrl)}`);
64
+ return;
65
+ }
66
+
67
+ const opened = await openURL(installUrl);
68
+ if (opened) {
69
+ info("Opening GitHub to install the Lizard GitHub App...");
27
70
  } else {
28
- info(`To connect ${chalk.cyan(repo)} for auto-deploy:`);
29
- info(` 1. Open your project on lizard.build`);
30
- info(` 2. Go to Settings → Git Integration`);
31
- info(` 3. Connect ${repo} (branch: ${opts.branch})`);
32
- info("");
33
- info(chalk.dim("CLI git connect will be available in a future update."));
71
+ info(`Open this URL to connect GitHub:\n ${chalk.cyan(installUrl)}`);
72
+ }
73
+
74
+ // Wait for user to complete installation in browser
75
+ await pressEnter(chalk.dim("\nPress Enter after completing GitHub installation..."));
76
+
77
+ // Verify
78
+ const spinner = ora("Verifying GitHub connection...").start();
79
+ const newStatus = await api.get<GitHubStatus>("/api/github/status");
80
+ spinner.stop();
81
+
82
+ if (newStatus.installed) {
83
+ success("GitHub connected! You can now deploy private repositories.");
84
+ info(chalk.dim(" Run `lizard deploy` to deploy your project."));
85
+ } else {
86
+ error("GitHub App not detected. Please try again or connect via the dashboard.");
87
+ process.exit(1);
34
88
  }
35
89
  });
36
90
 
91
+ // lizard git checkout <service> <branch> — switch branch and redeploy
37
92
  git
38
- .command("disconnect")
39
- .description("Disconnect Git auto-deploy")
40
- .action(async () => {
41
- info(chalk.dim("Git disconnect via CLI will be available in a future update."));
93
+ .command("checkout")
94
+ .description("Switch a service to a different branch and redeploy")
95
+ .argument("<service>", "Service name (as shown in the project)")
96
+ .argument("<branch>", "Branch name to switch to")
97
+ .option("--detach", "Start redeploy and exit without streaming logs")
98
+ .option("-p, --project <id>", "Project name, slug, or ID")
99
+ .action(async (serviceArg: string, branch: string, opts) => {
100
+ const { projectId, scope } = await resolveProjectScope(opts.project);
101
+
102
+ // Resolve service by name
103
+ const svc = await resolveService(projectId, serviceArg);
104
+ if (svc.kind !== "app") throw new Error(`"${serviceArg}" is not an app`);
105
+ const serviceId = svc.id;
106
+ const serviceName = svc.name ?? serviceArg;
107
+
108
+ // Patch the branch
109
+ const spinner = ora(`Switching ${chalk.bold(serviceName)} to branch ${chalk.cyan(branch)}...`).start();
110
+ await api.post(withScope(`/api/projects/${projectId}/config:apply`, scope), {
111
+ services: [{ name: serviceName, branch }],
112
+ });
113
+ spinner.succeed(`Branch set to ${chalk.cyan(branch)}`);
114
+
115
+ // Trigger redeploy
116
+ const deploySpinner = ora("Starting redeploy...").start();
117
+ await api.post(withScope(`/api/apps/${serviceId}/redeploy`, scope));
118
+ deploySpinner.stop();
119
+
120
+ if (opts.detach || isJSONMode()) {
121
+ if (isJSONMode()) printJSON({ id: serviceId, branch, status: "deploying" });
122
+ else success(`Redeploy started on branch ${chalk.cyan(branch)}`);
123
+ return;
124
+ }
125
+
126
+ info(`Redeploying ${chalk.bold(serviceName)} on ${chalk.cyan(branch)}...`);
127
+
128
+ // Wait for build to appear
129
+ let buildId: string | null = null;
130
+ for (let i = 0; i < 30; i++) {
131
+ await new Promise((r) => setTimeout(r, 2000));
132
+ try {
133
+ const app = await api.get<{ builds?: Array<{ id: string; status: string }> }>(`/api/apps/${serviceId}`);
134
+ const latest = app.builds?.[0];
135
+ if (latest && ["building", "deploying", "running", "failed"].includes(latest.status)) {
136
+ buildId = latest.id;
137
+ break;
138
+ }
139
+ } catch {}
140
+ }
141
+
142
+ if (buildId) {
143
+ await streamSSE(`/api/builds/${buildId}/logs`, (event, data) => {
144
+ if (event === "done" || event === "error") {
145
+ if (event === "error") error(`Build failed: ${data}`);
146
+ return false;
147
+ }
148
+ try {
149
+ const parsed = JSON.parse(data);
150
+ process.stdout.write((typeof parsed === "string" ? parsed : (parsed.line ?? data)) + "\n");
151
+ } catch {
152
+ process.stdout.write(data + "\n");
153
+ }
154
+ return true;
155
+ });
156
+ }
157
+
158
+ const app = await api.get<{ status: string; domain?: string }>(`/api/apps/${serviceId}`);
159
+ if (app.status === "running") {
160
+ success(`Deployed! ${app.domain ? chalk.cyan(`https://${app.domain}`) : ""}`);
161
+ } else {
162
+ error("Deploy failed");
163
+ }
42
164
  });
43
165
 
166
+ // lizard git status
44
167
  git
45
168
  .command("status")
46
- .description("Show Git integration status")
47
- .action(async () => {
48
- const projectId = resolveProjectId(program.opts().project);
169
+ .description("Show GitHub connection and repository status")
170
+ .option("-p, --project <id>", "Project name, slug, or ID")
171
+ .action(async (opts) => {
172
+ const { projectId, scope } = await resolveProjectScope(opts.project);
49
173
 
50
- // Get apps to check for repo info
51
- const services = await api.get<{ apps: any[] }>(
52
- `/api/projects/${projectId}/services`,
53
- );
174
+ const [githubStatus, services] = await Promise.all([
175
+ api.get<GitHubStatus>("/api/github/status"),
176
+ api.get<{ apps: any[] }>(withScope(`/api/projects/${projectId}/services`, scope)),
177
+ ]);
54
178
 
55
- const appsWithRepo = (services.apps || []).filter((a: any) => a.repo);
179
+ const appsWithRepo = (services.apps || []).filter((a: any) => a.repo || a.repoUrl);
56
180
 
57
181
  if (isJSONMode()) {
58
182
  printJSON({
59
- connected: appsWithRepo.length > 0,
183
+ github: {
184
+ installed: githubStatus.installed,
185
+ installationId: githubStatus.installationId,
186
+ installations: githubStatus.installations ?? [],
187
+ manageUrl: githubStatus.manageUrl,
188
+ },
60
189
  apps: appsWithRepo.map((a: any) => ({
61
190
  name: a.name,
62
- repo: a.repo,
191
+ repo: a.repo || a.repoUrl,
63
192
  branch: a.branch,
64
193
  })),
65
194
  });
66
195
  return;
67
196
  }
68
197
 
198
+ // GitHub App status
199
+ if (githubStatus.installed) {
200
+ info(`GitHub App: ${chalk.green("connected")}`);
201
+ const installs = githubStatus.installations ?? [];
202
+ for (const inst of installs) {
203
+ const typeLabel = inst.account.type === "Organization" ? "org" : "user";
204
+ const repoLabel = inst.repoCount !== null
205
+ ? chalk.dim(`${inst.repoCount} repos${inst.privateCount ? `, ${inst.privateCount} private` : ""}`)
206
+ : "";
207
+ info(` ${chalk.bold(inst.account.login)} ${chalk.dim(`(${typeLabel}, installation #${inst.id})`)} ${repoLabel}`);
208
+ }
209
+ const manageUrl = githubStatus.manageUrl ?? `https://github.com/apps/lizard-app/installations/select_target`;
210
+ info(chalk.dim(` Manage: ${link(manageUrl)}`));
211
+ } else {
212
+ info(`GitHub App: ${chalk.yellow("not connected")} ${chalk.dim("→ run `lizard git connect`")}`);
213
+ if (githubStatus.error) {
214
+ info(chalk.dim(` (${githubStatus.error})`));
215
+ }
216
+ }
217
+
218
+ // Connected repos
69
219
  if (appsWithRepo.length === 0) {
70
- console.log("No Git repositories connected.");
220
+ info(chalk.dim("\nNo repositories connected to this project."));
71
221
  return;
72
222
  }
73
223
 
224
+ info("");
74
225
  for (const app of appsWithRepo) {
75
- console.log(
76
- `${chalk.bold(app.name)}: ${chalk.cyan(app.repo)} (${app.branch || "main"})`,
226
+ info(
227
+ `${chalk.bold(app.name)}: ${chalk.cyan(app.repo || app.repoUrl)} ${chalk.dim(`(${app.branch || "main"})`)}`,
77
228
  );
78
229
  }
79
230
  });
80
231
  }
232
+
233
+ function pressEnter(question: string): Promise<void> {
234
+ return new Promise((resolve) => {
235
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
236
+ rl.question(question, () => {
237
+ rl.close();
238
+ resolve();
239
+ });
240
+ });
241
+ }