@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
@@ -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,157 @@
1
+ import { Command } from "commander";
2
+ import { readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import path from "node:path";
5
+ import { error, info, isJSONMode, printJSON, table } from "../lib/format.js";
6
+
7
+ const DEFAULT_SKILLS_DIR = path.resolve(
8
+ path.dirname(fileURLToPath(import.meta.url)),
9
+ "..",
10
+ "..",
11
+ "skill-data",
12
+ );
13
+
14
+ function skillsDir(): string {
15
+ return process.env.LIZARD_SKILLS_DIR || DEFAULT_SKILLS_DIR;
16
+ }
17
+
18
+ function listSkills(): string[] {
19
+ const dir = skillsDir();
20
+ try {
21
+ return readdirSync(dir)
22
+ .filter((name) => {
23
+ const p = path.join(dir, name);
24
+ try {
25
+ return statSync(p).isDirectory();
26
+ } catch {
27
+ return false;
28
+ }
29
+ })
30
+ .sort();
31
+ } catch {
32
+ return [];
33
+ }
34
+ }
35
+
36
+ function skillFile(name: string): string {
37
+ return path.join(skillsDir(), name, "SKILL.md");
38
+ }
39
+
40
+ // Minimal YAML-ish frontmatter parser. Pulls top-level scalar keys (name,
41
+ // description, etc.) — enough to render `lizard skill list`. Full YAML parsing
42
+ // would add a dependency for no real win.
43
+ function parseFrontmatter(md: string): Record<string, string> {
44
+ if (!md.startsWith("---")) return {};
45
+ const end = md.indexOf("\n---", 3);
46
+ if (end === -1) return {};
47
+ const body = md.slice(3, end).replace(/^\r?\n/, "");
48
+ const out: Record<string, string> = {};
49
+ for (const raw of body.split(/\r?\n/)) {
50
+ const m = raw.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
51
+ if (!m) continue;
52
+ let val = m[2].trim();
53
+ if (
54
+ (val.startsWith('"') && val.endsWith('"')) ||
55
+ (val.startsWith("'") && val.endsWith("'"))
56
+ ) {
57
+ val = val.slice(1, -1);
58
+ }
59
+ out[m[1]] = val;
60
+ }
61
+ return out;
62
+ }
63
+
64
+ function readSkill(name: string): { md: string; meta: Record<string, string> } {
65
+ const file = skillFile(name);
66
+ let md: string;
67
+ try {
68
+ md = readFileSync(file, "utf8");
69
+ } catch {
70
+ throw new Error(
71
+ `Skill '${name}' not found. Run \`lizard skill list\` to see available skills.`,
72
+ );
73
+ }
74
+ return { md, meta: parseFrontmatter(md) };
75
+ }
76
+
77
+ function shortDesc(desc: string, max = 100): string {
78
+ const oneLine = desc.replace(/\s+/g, " ").trim();
79
+ return oneLine.length > max ? oneLine.slice(0, max - 1) + "…" : oneLine;
80
+ }
81
+
82
+ export function registerSkill(program: Command) {
83
+ const skill = program
84
+ .command("skill")
85
+ .description("Read embedded agent skills shipped with this CLI version");
86
+
87
+ skill
88
+ .command("list")
89
+ .description("List available skills with descriptions")
90
+ .action(() => {
91
+ const names = listSkills();
92
+ if (isJSONMode()) {
93
+ printJSON({
94
+ dir: skillsDir(),
95
+ skills: names.map((n) => {
96
+ try {
97
+ const { meta } = readSkill(n);
98
+ return { name: n, description: meta.description || "" };
99
+ } catch {
100
+ return { name: n, description: "" };
101
+ }
102
+ }),
103
+ });
104
+ return;
105
+ }
106
+ if (names.length === 0) {
107
+ info("No skills found.");
108
+ return;
109
+ }
110
+ const rows = names.map((n) => {
111
+ try {
112
+ const { meta } = readSkill(n);
113
+ return [n, shortDesc(meta.description || "")];
114
+ } catch {
115
+ return [n, ""];
116
+ }
117
+ });
118
+ table(["name", "description"], rows);
119
+ });
120
+
121
+ skill
122
+ .command("get")
123
+ .description("Print a skill's full content (markdown)")
124
+ .argument("<name>", "skill name (e.g. core)")
125
+ .option("--full", "Include reference files (reserved; currently equivalent to default)")
126
+ .action((name: string, _opts: { full?: boolean }) => {
127
+ try {
128
+ const { md, meta } = readSkill(name);
129
+ if (isJSONMode()) {
130
+ printJSON({
131
+ name,
132
+ path: skillFile(name),
133
+ frontmatter: meta,
134
+ content: md,
135
+ });
136
+ return;
137
+ }
138
+ process.stdout.write(md.endsWith("\n") ? md : md + "\n");
139
+ } catch (e: any) {
140
+ error(e.message);
141
+ process.exit(3);
142
+ }
143
+ });
144
+
145
+ skill
146
+ .command("path")
147
+ .description("Print the filesystem path of a skill (or the skills root)")
148
+ .argument("[name]", "skill name; omit to print the skills directory")
149
+ .action((name?: string) => {
150
+ const target = name ? skillFile(name) : skillsDir();
151
+ if (isJSONMode()) {
152
+ printJSON({ path: target });
153
+ return;
154
+ }
155
+ console.log(target);
156
+ });
157
+ }
@@ -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
+ }