@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,52 @@
1
+ import { Command } from "commander";
2
+ import { api, withScope } from "../lib/api.js";
3
+ import { resolveProjectScope, resolveService } from "../lib/resolve.js";
4
+ import { printJSON } from "../lib/format.js";
5
+
6
+ /**
7
+ * `lizard service show` — print the current service configuration as JSON.
8
+ *
9
+ * Works for both apps and addons. Without `-s` shows the whole project
10
+ * (`{ apps, addons }`). With `-s <name>` shows just that service.
11
+ *
12
+ * Useful for diff-ing against a `lizard.config.json`, seeding a new file,
13
+ * or feeding into `lizard service set` to roll back.
14
+ */
15
+ export function registerServiceShow(svc: Command) {
16
+ svc
17
+ .command("show")
18
+ .description("Show the current service configuration as JSON")
19
+ .argument("[service]", "Service name or ID (omit for whole project)")
20
+ .option("-s, --service <name>", "Limit output to one service")
21
+ .option("-p, --project <id>", "Project name, slug, or ID")
22
+ .action(async (serviceArg: string | undefined, opts) => {
23
+ const { projectId, scope } = await resolveProjectScope(opts.project);
24
+
25
+ const ref = serviceArg || opts.service;
26
+ if (ref) {
27
+ const svcInfo = await resolveService(projectId, ref);
28
+ if (svcInfo.kind === "app") {
29
+ const detail = await api.get<unknown>(
30
+ withScope(`/api/apps/${svcInfo.id}`, scope),
31
+ );
32
+ printJSON(detail);
33
+ return;
34
+ }
35
+ // Addons: pluck from the project /services endpoint, which already
36
+ // returns rich addon objects (connection, config, volume, region).
37
+ // There is no per-addon GET route on the backend.
38
+ const data = await api.get<{ addons?: any[] }>(
39
+ withScope(`/api/projects/${projectId}/services`, scope),
40
+ );
41
+ const addon = (data.addons || []).find((a: any) => a.id === svcInfo.id);
42
+ if (!addon) throw new Error(`Addon ${svcInfo.name} not found`);
43
+ printJSON(addon);
44
+ return;
45
+ }
46
+
47
+ const data = await api.get<unknown>(
48
+ withScope(`/api/projects/${projectId}/services`, scope),
49
+ );
50
+ printJSON(data);
51
+ });
52
+ }
@@ -0,0 +1,298 @@
1
+ import chalk from "chalk";
2
+ import * as p from "@clack/prompts";
3
+ import { Command } from "commander";
4
+ import { api, withScope, withQuery } from "../lib/api.js";
5
+ import { updateProjectLink, getProjectLink } from "../lib/config.js";
6
+ import { resolveProjectScope, resolveService } from "../lib/resolve.js";
7
+ import { validateName } from "../lib/name.js";
8
+ import { registerServiceSet } from "./service-set.js";
9
+ import { registerServiceShow } from "./service-show.js";
10
+ import {
11
+ success,
12
+ info,
13
+ isJSONMode,
14
+ printJSON,
15
+ isTTY,
16
+ } from "../lib/format.js";
17
+
18
+ interface ServicesResponse {
19
+ apps?: any[];
20
+ addons?: any[];
21
+ }
22
+
23
+ /**
24
+ * `lizard service` — service group:
25
+ * - bare: link a service to cwd
26
+ * - list / link / status / delete / rename / set / show / logs
27
+ * (scale / redeploy / restart live on the top-level commands of the same name)
28
+ */
29
+ export function registerService(program: Command) {
30
+ const svc = program
31
+ .command("service")
32
+ .argument(
33
+ "[name]",
34
+ "Service name to link (legacy form for `service link <name>`)",
35
+ )
36
+ .description("Manage services")
37
+ .action(async (name: string | undefined, _opts, cmd) => {
38
+ // No subcommand → behave like `service link`
39
+ if (!name && cmd.args.length === 0) {
40
+ await linkInteractive(cmd);
41
+ return;
42
+ }
43
+ if (name) {
44
+ await linkByName(cmd, name);
45
+ }
46
+ });
47
+
48
+ // `service set` and `service show` — per-service configuration patches.
49
+ // Live in their own files because the apply logic is substantial.
50
+ registerServiceSet(svc);
51
+ registerServiceShow(svc);
52
+
53
+ svc
54
+ .command("link")
55
+ .argument("[name]", "Service name or ID")
56
+ .description("Link a service to the current directory")
57
+ .action(async (name: string | undefined, _opts, sub) => {
58
+ if (name) {
59
+ await linkByName(sub, name);
60
+ } else {
61
+ await linkInteractive(sub);
62
+ }
63
+ });
64
+
65
+ svc
66
+ .command("delete")
67
+ .alias("rm")
68
+ .description("Delete a service")
69
+ .argument("[service]", "Service name or ID (defaults to linked)")
70
+ .option("-s, --service <name>", "Service name or ID")
71
+ .option("-p, --project <id>", "Project name, slug, or ID")
72
+ .option("-y, --yes", "Skip confirmation")
73
+ .action(async (serviceArg: string | undefined, opts) => {
74
+ const { projectId, scope } = await resolveProjectScope(opts.project);
75
+ const target = serviceArg || opts.service || getProjectLink()?.serviceId;
76
+ if (!target) throw new Error("No service specified or linked.");
77
+ const svcInfo = await resolveService(projectId, target);
78
+
79
+ const yes = opts.yes;
80
+ if (!yes) {
81
+ if (!isTTY()) throw new Error("Use -y to confirm in non-interactive mode");
82
+ const confirm = await p.confirm({
83
+ message: `Delete ${chalk.bold(svcInfo.name)}? This is irreversible.`,
84
+ });
85
+ if (p.isCancel(confirm) || !confirm) process.exit(5);
86
+ }
87
+
88
+ if (svcInfo.kind === "app") {
89
+ await api.delete(withScope(`/api/apps/${svcInfo.id}`, scope));
90
+ } else {
91
+ await api.delete(withScope(`/api/projects/${projectId}/addons/${svcInfo.id}`, scope));
92
+ }
93
+
94
+ // Clear link if we just deleted the linked service
95
+ const link = getProjectLink();
96
+ if (link?.serviceId === svcInfo.id) {
97
+ updateProjectLink({ serviceId: undefined, serviceName: undefined });
98
+ }
99
+
100
+ if (isJSONMode()) {
101
+ printJSON({ id: svcInfo.id, name: svcInfo.name, status: "deleted" });
102
+ } else {
103
+ success(`Service ${chalk.bold(svcInfo.name)} deleted`);
104
+ }
105
+ });
106
+
107
+ svc
108
+ .command("rename")
109
+ .argument("<new-name>", "New name for the service")
110
+ .description("Rename a service (apps and addons)")
111
+ .option("-s, --service <name>", "Service name or ID (defaults to linked service)")
112
+ .option("-p, --project <id>", "Project name, slug, or ID")
113
+ .action(async (newName: string, opts) => {
114
+ const nameErr = validateName(newName);
115
+ if (nameErr) throw new Error(`Invalid name: ${nameErr}`);
116
+ const { projectId, scope } = await resolveProjectScope(opts.project);
117
+ const target = opts.service || getProjectLink()?.serviceId;
118
+ if (!target) throw new Error("No service specified or linked.");
119
+ const svcInfo = await resolveService(projectId, target);
120
+
121
+ if (svcInfo.kind === "app") {
122
+ // PATCH /api/apps/:id is 410-Gone; renames go through config:apply.
123
+ // Backend treats name as a rename only when id is also in the patch
124
+ // (otherwise name is a lookup key).
125
+ await api.post(
126
+ withScope(`/api/projects/${projectId}/config:apply`, scope),
127
+ { services: [{ id: svcInfo.id, name: newName }] },
128
+ );
129
+ } else {
130
+ await api.patch(
131
+ withScope(`/api/projects/${projectId}/addons/${svcInfo.id}`, scope),
132
+ { name: newName },
133
+ );
134
+ }
135
+
136
+ // Keep the cwd link's cached name in sync when we just renamed the linked one.
137
+ const link = getProjectLink();
138
+ if (link?.serviceId === svcInfo.id) {
139
+ updateProjectLink({ serviceId: svcInfo.id, serviceName: newName });
140
+ }
141
+
142
+ if (isJSONMode()) {
143
+ printJSON({ id: svcInfo.id, oldName: svcInfo.name, name: newName });
144
+ } else {
145
+ success(`Renamed ${chalk.bold(svcInfo.name)} → ${chalk.bold(newName)}`);
146
+ }
147
+ });
148
+
149
+ svc
150
+ .command("logs")
151
+ .description("Stream logs of a service")
152
+ .argument("[service]", "Service name or ID (defaults to linked)")
153
+ .option("-s, --service <name>", "Service name or ID")
154
+ .option("-p, --project <id>", "Project name, slug, or ID")
155
+ .option("--build", "Show build logs instead of runtime")
156
+ .option("-n, --tail <n>", "Print last N lines and exit (no follow); use --tail all for full history")
157
+ .option("--page <n>", "Page of historical logs (1=most recent, 2=older, …); implies --tail 200")
158
+ .action(async (serviceArg: string | undefined, opts) => {
159
+ const { projectId, scope } = await resolveProjectScope(opts.project);
160
+ const target = serviceArg || opts.service || getProjectLink()?.serviceId;
161
+ if (!target) throw new Error("No service specified or linked.");
162
+ const svcInfo = await resolveService(projectId, target);
163
+
164
+ const { streamSSE } = await import("../lib/api.js");
165
+
166
+ if (opts.build) {
167
+ const app = await api.get<{ builds?: Array<{ id: string }> }>(
168
+ withScope(`/api/apps/${svcInfo.id}`, scope),
169
+ );
170
+ if (!app.builds?.length) throw new Error("No builds found");
171
+ info(chalk.dim(`Streaming build logs for ${svcInfo.name}...\n`));
172
+ await streamSSE(`/api/builds/${app.builds[0].id}/logs`, (event, data) => {
173
+ if (event === "done" || event === "error") return false;
174
+ process.stdout.write(safeLogLine(data) + "\n");
175
+ return true;
176
+ });
177
+ return;
178
+ }
179
+
180
+ // --page: paginate through historical logs without streaming
181
+ if (opts.page !== undefined) {
182
+ const pageSize = 200;
183
+ const pageNum = Math.max(1, parseInt(opts.page, 10) || 1);
184
+ // Fetch pages iteratively: page 1 = most recent, page 2 = one step older, etc.
185
+ let before: string | undefined;
186
+ for (let p = 1; p <= pageNum; p++) {
187
+ const result = await api.get<{ entries: any[]; oldest: string | null }>(
188
+ withScope(
189
+ withQuery(`/api/apps/${svcInfo.id}/logs/history`, {
190
+ limit: pageSize,
191
+ before,
192
+ }),
193
+ scope,
194
+ ),
195
+ );
196
+ if (p === pageNum) {
197
+ if (!result.entries.length) {
198
+ info(chalk.dim("No more logs."));
199
+ } else {
200
+ info(chalk.dim(`Page ${pageNum} (${result.entries.length} lines):\n`));
201
+ for (const e of result.entries) process.stdout.write(safeLogLine(JSON.stringify(e)) + "\n");
202
+ if (result.oldest) info(chalk.dim(`\n --page ${pageNum + 1} for older logs`));
203
+ }
204
+ } else {
205
+ if (!result.entries.length || !result.entries[0]) break;
206
+ before = result.entries[0].id; // oldest entry of this page → upper bound for next
207
+ }
208
+ }
209
+ return;
210
+ }
211
+
212
+ // --tail N: fetch N historical lines and exit (no follow)
213
+ if (opts.tail !== undefined) {
214
+ const rawTail = String(opts.tail);
215
+ const limit = rawTail === "all" ? 2000 : Math.min(Math.max(1, parseInt(rawTail, 10) || 200), 2000);
216
+ const result = await api.get<{ entries: any[]; oldest: string | null }>(
217
+ withScope(
218
+ withQuery(`/api/apps/${svcInfo.id}/logs/history`, { limit }),
219
+ scope,
220
+ ),
221
+ );
222
+ for (const e of result.entries) process.stdout.write(safeLogLine(JSON.stringify(e)) + "\n");
223
+ if (result.oldest && result.entries.length === limit) {
224
+ info(chalk.dim(`\n Showing last ${limit} lines. Use --tail all or --page 2 to see older logs.`));
225
+ }
226
+ return;
227
+ }
228
+
229
+ info(chalk.dim(`Streaming logs for ${svcInfo.name}... (Ctrl+C to stop)\n`));
230
+ await streamSSE(
231
+ withScope(
232
+ withQuery(`/api/apps/${svcInfo.id}/logs`, { limit: 500 }),
233
+ scope,
234
+ ),
235
+ (event, data) => {
236
+ if (event === "error") return false;
237
+ process.stdout.write(safeLogLine(data) + "\n");
238
+ return true;
239
+ },
240
+ );
241
+ });
242
+
243
+ // Helpers in scope of registerService
244
+ async function linkByName(_cmd: Command, name: string) {
245
+ const { projectId } = await resolveProjectScope(undefined);
246
+ const svcInfo = await resolveService(projectId, name);
247
+ updateProjectLink({ serviceId: svcInfo.id, serviceName: svcInfo.name });
248
+ if (isJSONMode()) {
249
+ printJSON({ serviceId: svcInfo.id, serviceName: svcInfo.name });
250
+ } else {
251
+ success(`Linked service ${chalk.bold(svcInfo.name)}`);
252
+ }
253
+ }
254
+
255
+ async function linkInteractive(_cmd: Command) {
256
+ const { projectId, scope } = await resolveProjectScope(undefined);
257
+ const data = await api.get<ServicesResponse>(
258
+ withScope(`/api/projects/${projectId}/services`, scope),
259
+ );
260
+ const services = [...(data.apps || []), ...(data.addons || [])];
261
+ if (services.length === 0) {
262
+ throw new Error("No services in project. Use `lizard add` first.");
263
+ }
264
+ if (!isTTY()) {
265
+ throw new Error(
266
+ "Service name required in non-interactive mode. Usage: `lizard service link <name>`",
267
+ );
268
+ }
269
+ const sel = await p.select({
270
+ message: "Select a service",
271
+ options: services.map((s: any) => ({
272
+ value: s.id,
273
+ label: s.name || s.addonType,
274
+ hint: s.status,
275
+ })),
276
+ });
277
+ if (p.isCancel(sel)) process.exit(5);
278
+ const svcInfo = services.find((s: any) => s.id === sel)!;
279
+ updateProjectLink({ serviceId: svcInfo.id, serviceName: svcInfo.name });
280
+ if (isJSONMode()) {
281
+ printJSON({ serviceId: svcInfo.id, serviceName: svcInfo.name });
282
+ } else {
283
+ success(`Linked service ${chalk.bold(svcInfo.name)}`);
284
+ }
285
+ }
286
+ }
287
+
288
+ function safeLogLine(data: string): string {
289
+ try {
290
+ const parsed = JSON.parse(data);
291
+ if (parsed.line) return parsed.line;
292
+ if (parsed.message) return parsed.message;
293
+ if (typeof parsed === "string") return parsed;
294
+ return data;
295
+ } catch {
296
+ return data;
297
+ }
298
+ }
@@ -0,0 +1,176 @@
1
+ import chalk from "chalk";
2
+ import * as p from "@clack/prompts";
3
+ import { Command } from "commander";
4
+ import { api, getBaseURL, streamSSE, withScope } from "../lib/api.js";
5
+ import { resolveProjectScope } from "../lib/resolve.js";
6
+ import { error, isTTY } from "../lib/format.js";
7
+ import { getToken } from "../lib/auth.js";
8
+ import * as https from "node:https";
9
+ import * as http from "node:http";
10
+
11
+ export function registerSSH(program: Command) {
12
+ program
13
+ .command("ssh")
14
+ .description("Execute a command inside a running service VM")
15
+ .argument("[cmd...]", "Command and args to run inside the VM (required; pass after `--` to stop flag parsing, e.g. `-- ls -la /app`)")
16
+ .option("-s, --service <id>", "Service name or ID")
17
+ .option("-p, --project <id>", "Project name, slug, or ID")
18
+ .addHelpText("after", `
19
+ Examples:
20
+ lizard ssh -s my-app -- ls -la /app
21
+ lizard ssh -s my-app -- printenv
22
+ lizard ssh -s my-app -- bash -c "ps aux | head"`)
23
+ .action(async (cmdArgs: string[], opts) => {
24
+ const { projectId, scope } = await resolveProjectScope(opts.project);
25
+ let serviceId = opts.service as string | undefined;
26
+
27
+ // Resolve service interactively if not given
28
+ if (!serviceId) {
29
+ const data = await api.get<{ apps: Array<{ id: string; name: string; status: string }> }>(
30
+ withScope(`/api/projects/${projectId}/services`, scope),
31
+ );
32
+ const running = (data.apps || []).filter((a) => a.status === "running");
33
+ if (running.length === 0) {
34
+ error("No running services in this project.");
35
+ process.exit(1);
36
+ }
37
+ if (running.length === 1) {
38
+ serviceId = running[0].id;
39
+ } else if (isTTY()) {
40
+ const selected = await p.select({
41
+ message: "Select a service",
42
+ options: running.map((a) => ({ value: a.id, label: a.name || a.id, hint: a.status })),
43
+ });
44
+ if (p.isCancel(selected)) process.exit(5);
45
+ serviceId = selected as string;
46
+ } else {
47
+ error("Multiple services — pass -s <service>");
48
+ process.exit(1);
49
+ }
50
+ }
51
+
52
+ // Resolve service ID if a name was given (lookup by name)
53
+ if (serviceId && !serviceId.match(/^[A-Za-z0-9_-]{20,}$/)) {
54
+ const data = await api.get<{ apps: Array<{ id: string; name: string; serviceName?: string }> }>(
55
+ withScope(`/api/projects/${projectId}/services`, scope),
56
+ );
57
+ const match = (data.apps || []).find(
58
+ (a) => a.name === serviceId || a.serviceName === serviceId || a.id === serviceId,
59
+ );
60
+ if (match) serviceId = match.id;
61
+ }
62
+
63
+ if (cmdArgs.length === 0) {
64
+ error("No command given. Usage: lizard ssh -s <service> -- <cmd> [args...]");
65
+ process.exit(1);
66
+ }
67
+
68
+ // The server executes `cmd` via shell on the VM, so each arg must be
69
+ // shell-quoted before being joined — otherwise `bash -c "ps | head"`
70
+ // collapses to `bash -c ps | head` (quotes lost, `|` becomes a real pipe).
71
+ const cmd = cmdArgs.map(shellQuote).join(" ");
72
+ process.stdout.write(chalk.dim(`$ ${cmd}\n`));
73
+
74
+ let exitCode = 0;
75
+
76
+ await execStream(serviceId!, cmd, (stream, line) => {
77
+ if (stream === "stderr") {
78
+ process.stderr.write(line + "\n");
79
+ } else {
80
+ process.stdout.write(line + "\n");
81
+ }
82
+ }, (code) => {
83
+ exitCode = code;
84
+ });
85
+
86
+ process.exit(exitCode);
87
+ });
88
+ }
89
+
90
+ function execStream(
91
+ appId: string,
92
+ cmd: string,
93
+ onLine: (stream: string, line: string) => void,
94
+ onExit: (code: number) => void,
95
+ ): Promise<void> {
96
+ return new Promise((resolve, reject) => {
97
+ const baseURL = getBaseURL();
98
+ const url = new URL(`${baseURL}/api/apps/${appId}/exec`);
99
+ const token = getToken();
100
+ const body = JSON.stringify({ cmd });
101
+
102
+ const reqHeaders: Record<string, string> = {
103
+ "Content-Type": "application/json",
104
+ "Content-Length": String(Buffer.byteLength(body)),
105
+ "Accept": "text/event-stream",
106
+ };
107
+ if (token) reqHeaders["Authorization"] = `Bearer ${token}`;
108
+
109
+ const transport = url.protocol === "https:" ? https : http;
110
+ const req = transport.request(
111
+ {
112
+ hostname: url.hostname,
113
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
114
+ path: url.pathname,
115
+ method: "POST",
116
+ headers: reqHeaders,
117
+ },
118
+ (res) => {
119
+ if (res.statusCode && res.statusCode >= 400) {
120
+ let body = "";
121
+ res.on("data", (c: Buffer) => (body += c.toString()));
122
+ res.on("end", () => reject(new Error(`exec failed ${res.statusCode}: ${body}`)));
123
+ return;
124
+ }
125
+
126
+ let buf = "";
127
+ let currentEvent = "";
128
+
129
+ res.setEncoding("utf8");
130
+ res.on("data", (chunk: string) => {
131
+ buf += chunk;
132
+ const lines = buf.split("\n");
133
+ buf = lines.pop() ?? "";
134
+
135
+ for (const line of lines) {
136
+ const trimmed = line.replace(/\r$/, "");
137
+ if (trimmed === "") {
138
+ currentEvent = "";
139
+ } else if (trimmed.startsWith("event:")) {
140
+ currentEvent = trimmed.slice(6).trim();
141
+ } else if (trimmed.startsWith("data:")) {
142
+ const data = trimmed.slice(5).trimStart();
143
+ if (currentEvent === "exit") {
144
+ try { onExit(JSON.parse(data).exitCode ?? 0); } catch {}
145
+ } else if (currentEvent === "error") {
146
+ error(data);
147
+ } else {
148
+ try {
149
+ const parsed = JSON.parse(data);
150
+ onLine(parsed.stream ?? "stdout", parsed.line ?? data);
151
+ } catch {
152
+ onLine("stdout", data);
153
+ }
154
+ }
155
+ }
156
+ }
157
+ });
158
+
159
+ res.on("end", resolve);
160
+ res.on("error", reject);
161
+ },
162
+ );
163
+
164
+ req.on("error", reject);
165
+ req.write(body);
166
+ req.end();
167
+ });
168
+ }
169
+
170
+ /** POSIX single-quote escaping. Safe-token chars pass through verbatim;
171
+ * anything else gets wrapped in '…' with embedded `'` rewritten as `'\''`. */
172
+ function shellQuote(arg: string): string {
173
+ if (arg === "") return "''";
174
+ if (/^[A-Za-z0-9_./:=@%+,-]+$/.test(arg)) return arg;
175
+ return "'" + arg.replace(/'/g, "'\\''") + "'";
176
+ }
@@ -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
+ }