@lizard-build/cli 0.1.0 → 0.3.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/.github/workflows/release.yml +90 -0
  2. package/AGENTS.md +113 -0
  3. package/README.md +41 -0
  4. package/dist/commands/add.js +318 -45
  5. package/dist/commands/add.js.map +1 -1
  6. package/dist/commands/config.d.ts +2 -0
  7. package/dist/commands/config.js +68 -0
  8. package/dist/commands/config.js.map +1 -0
  9. package/dist/commands/docs.d.ts +2 -0
  10. package/dist/commands/docs.js +13 -0
  11. package/dist/commands/docs.js.map +1 -0
  12. package/dist/commands/domain.d.ts +9 -0
  13. package/dist/commands/domain.js +195 -0
  14. package/dist/commands/domain.js.map +1 -0
  15. package/dist/commands/git.js +175 -36
  16. package/dist/commands/git.js.map +1 -1
  17. package/dist/commands/init.d.ts +24 -0
  18. package/dist/commands/init.js +128 -86
  19. package/dist/commands/init.js.map +1 -1
  20. package/dist/commands/link.d.ts +7 -0
  21. package/dist/commands/link.js +104 -33
  22. package/dist/commands/link.js.map +1 -1
  23. package/dist/commands/login.js +4 -3
  24. package/dist/commands/login.js.map +1 -1
  25. package/dist/commands/logs.js +223 -30
  26. package/dist/commands/logs.js.map +1 -1
  27. package/dist/commands/open.js +3 -2
  28. package/dist/commands/open.js.map +1 -1
  29. package/dist/commands/port.d.ts +7 -0
  30. package/dist/commands/port.js +49 -0
  31. package/dist/commands/port.js.map +1 -0
  32. package/dist/commands/projects.js +36 -6
  33. package/dist/commands/projects.js.map +1 -1
  34. package/dist/commands/ps.js +32 -39
  35. package/dist/commands/ps.js.map +1 -1
  36. package/dist/commands/redeploy.js +48 -8
  37. package/dist/commands/redeploy.js.map +1 -1
  38. package/dist/commands/regions.js +2 -5
  39. package/dist/commands/regions.js.map +1 -1
  40. package/dist/commands/restart.js +84 -10
  41. package/dist/commands/restart.js.map +1 -1
  42. package/dist/commands/run.d.ts +9 -0
  43. package/dist/commands/run.js +61 -22
  44. package/dist/commands/run.js.map +1 -1
  45. package/dist/commands/scale.d.ts +10 -0
  46. package/dist/commands/scale.js +166 -0
  47. package/dist/commands/scale.js.map +1 -0
  48. package/dist/commands/secrets.js +200 -89
  49. package/dist/commands/secrets.js.map +1 -1
  50. package/dist/commands/service-set.d.ts +49 -0
  51. package/dist/commands/service-set.js +552 -0
  52. package/dist/commands/service-set.js.map +1 -0
  53. package/dist/commands/service-show.d.ts +11 -0
  54. package/dist/commands/service-show.js +44 -0
  55. package/dist/commands/service-show.js.map +1 -0
  56. package/dist/commands/service.d.ts +8 -0
  57. package/dist/commands/service.js +262 -0
  58. package/dist/commands/service.js.map +1 -0
  59. package/dist/commands/skill.d.ts +2 -0
  60. package/dist/commands/skill.js +146 -0
  61. package/dist/commands/skill.js.map +1 -0
  62. package/dist/commands/ssh.d.ts +2 -0
  63. package/dist/commands/ssh.js +161 -0
  64. package/dist/commands/ssh.js.map +1 -0
  65. package/dist/commands/status.d.ts +7 -0
  66. package/dist/commands/status.js +49 -38
  67. package/dist/commands/status.js.map +1 -1
  68. package/dist/commands/unlink.d.ts +5 -0
  69. package/dist/commands/unlink.js +18 -0
  70. package/dist/commands/unlink.js.map +1 -0
  71. package/dist/commands/up.d.ts +9 -0
  72. package/dist/commands/up.js +417 -0
  73. package/dist/commands/up.js.map +1 -0
  74. package/dist/commands/upgrade.d.ts +2 -0
  75. package/dist/commands/upgrade.js +79 -0
  76. package/dist/commands/upgrade.js.map +1 -0
  77. package/dist/commands/whoami.js +26 -6
  78. package/dist/commands/whoami.js.map +1 -1
  79. package/dist/commands/workspace.d.ts +8 -0
  80. package/dist/commands/workspace.js +36 -0
  81. package/dist/commands/workspace.js.map +1 -0
  82. package/dist/index.js +209 -82
  83. package/dist/index.js.map +1 -1
  84. package/dist/lib/api.d.ts +17 -2
  85. package/dist/lib/api.js +85 -51
  86. package/dist/lib/api.js.map +1 -1
  87. package/dist/lib/auth.d.ts +3 -11
  88. package/dist/lib/auth.js +16 -36
  89. package/dist/lib/auth.js.map +1 -1
  90. package/dist/lib/config.d.ts +36 -15
  91. package/dist/lib/config.js +71 -58
  92. package/dist/lib/config.js.map +1 -1
  93. package/dist/lib/format.d.ts +1 -0
  94. package/dist/lib/format.js +17 -4
  95. package/dist/lib/format.js.map +1 -1
  96. package/dist/lib/name.d.ts +11 -0
  97. package/dist/lib/name.js +26 -0
  98. package/dist/lib/name.js.map +1 -0
  99. package/dist/lib/picker.d.ts +32 -0
  100. package/dist/lib/picker.js +91 -0
  101. package/dist/lib/picker.js.map +1 -0
  102. package/dist/lib/resolve.d.ts +85 -0
  103. package/dist/lib/resolve.js +203 -0
  104. package/dist/lib/resolve.js.map +1 -0
  105. package/dist/lib/updater.d.ts +16 -0
  106. package/dist/lib/updater.js +102 -0
  107. package/dist/lib/updater.js.map +1 -0
  108. package/lizard-wrapper.sh +2 -0
  109. package/package.json +11 -3
  110. package/skill-data/core/SKILL.md +239 -0
  111. package/src/commands/add.ts +388 -56
  112. package/src/commands/config.ts +80 -0
  113. package/src/commands/docs.ts +15 -0
  114. package/src/commands/domain.ts +248 -0
  115. package/src/commands/git.ts +201 -40
  116. package/src/commands/init.ts +149 -100
  117. package/src/commands/link.ts +127 -35
  118. package/src/commands/login.ts +4 -3
  119. package/src/commands/logs.ts +283 -27
  120. package/src/commands/open.ts +3 -2
  121. package/src/commands/port.ts +57 -0
  122. package/src/commands/projects.ts +43 -6
  123. package/src/commands/ps.ts +39 -60
  124. package/src/commands/redeploy.ts +51 -10
  125. package/src/commands/regions.ts +2 -6
  126. package/src/commands/restart.ts +84 -10
  127. package/src/commands/run.ts +68 -24
  128. package/src/commands/scale.ts +216 -0
  129. package/src/commands/secrets.ts +277 -100
  130. package/src/commands/service-set.ts +669 -0
  131. package/src/commands/service-show.ts +52 -0
  132. package/src/commands/service.ts +298 -0
  133. package/src/commands/skill.ts +157 -0
  134. package/src/commands/ssh.ts +176 -0
  135. package/src/commands/status.ts +51 -46
  136. package/src/commands/unlink.ts +17 -0
  137. package/src/commands/up.ts +461 -0
  138. package/src/commands/upgrade.ts +87 -0
  139. package/src/commands/whoami.ts +34 -6
  140. package/src/commands/workspace.ts +44 -0
  141. package/src/index.ts +219 -85
  142. package/src/lib/api.ts +114 -51
  143. package/src/lib/auth.ts +22 -46
  144. package/src/lib/config.ts +100 -65
  145. package/src/lib/format.ts +18 -4
  146. package/src/lib/name.ts +27 -0
  147. package/src/lib/picker.ts +133 -0
  148. package/src/lib/resolve.ts +285 -0
  149. package/src/lib/updater.ts +106 -0
  150. package/test/cli.test.ts +491 -0
  151. package/test/fixtures/hello-app/Dockerfile +5 -0
  152. package/test/fixtures/hello-app/index.js +5 -0
  153. package/test/unit/api.test.ts +66 -0
  154. package/test/unit/config.test.ts +94 -0
  155. package/test/unit/init.test.ts +211 -0
  156. package/test/unit/json.test.ts +208 -0
  157. package/test/unit/picker.test.ts +161 -0
  158. package/test/unit/resolve.test.ts +124 -0
  159. package/test/unit/service-set.test.ts +355 -0
  160. package/vitest.config.ts +10 -0
  161. package/dist/commands/connect.d.ts +0 -2
  162. package/dist/commands/connect.js +0 -117
  163. package/dist/commands/connect.js.map +0 -1
  164. package/dist/commands/context.d.ts +0 -2
  165. package/dist/commands/context.js +0 -71
  166. package/dist/commands/context.js.map +0 -1
  167. package/dist/commands/deploy.d.ts +0 -2
  168. package/dist/commands/deploy.js +0 -120
  169. package/dist/commands/deploy.js.map +0 -1
  170. package/dist/commands/destroy.d.ts +0 -2
  171. package/dist/commands/destroy.js +0 -51
  172. package/dist/commands/destroy.js.map +0 -1
  173. package/dist/commands/update.d.ts +0 -2
  174. package/dist/commands/update.js +0 -41
  175. package/dist/commands/update.js.map +0 -1
  176. package/dist/commands/version.d.ts +0 -2
  177. package/dist/commands/version.js +0 -37
  178. package/dist/commands/version.js.map +0 -1
  179. package/src/commands/connect.ts +0 -145
  180. package/src/commands/context.ts +0 -93
  181. package/src/commands/deploy.ts +0 -153
  182. package/src/commands/destroy.ts +0 -51
  183. package/src/commands/update.ts +0 -44
  184. package/src/commands/version.ts +0 -37
@@ -1,8 +1,10 @@
1
1
  import chalk from "chalk";
2
2
  import * as p from "@clack/prompts";
3
3
  import { Command } from "commander";
4
- import { api } from "../lib/api.js";
5
- import { resolveProjectId } from "../lib/config.js";
4
+ import { api, withQuery, withScope } from "../lib/api.js";
5
+ import { getProjectLink, updateProjectLink, DEFAULT_REGION } from "../lib/config.js";
6
+ import { scopeForProject } from "../lib/resolve.js";
7
+ import { resolveWorkspace } from "../lib/picker.js";
6
8
  import {
7
9
  success,
8
10
  info,
@@ -11,24 +13,209 @@ import {
11
13
  isTTY,
12
14
  table,
13
15
  } from "../lib/format.js";
16
+ import { validateName, addonRefName } from "../lib/name.js";
14
17
 
15
18
  const CATALOG = [
16
19
  { name: "postgres", label: "PostgreSQL", description: "Relational database" },
17
20
  { name: "redis", label: "Redis", description: "In-memory key-value store" },
18
- { name: "mysql", label: "MySQL", description: "Relational database" },
19
- { name: "mongodb", label: "MongoDB", description: "Document database" },
21
+ { name: "s3", label: "S3 Bucket", description: "S3-compatible object storage" },
20
22
  ] as const;
21
23
 
24
+ async function detectPortFromDockerfile(repo: string): Promise<number | undefined> {
25
+ const repoPath = repo.startsWith("http") ? repo.replace(/^https?:\/\/github\.com\//, "") : repo;
26
+ for (const branch of ["main", "master"]) {
27
+ try {
28
+ const res = await fetch(
29
+ `https://raw.githubusercontent.com/${repoPath}/${branch}/Dockerfile`,
30
+ { signal: AbortSignal.timeout(5000) },
31
+ );
32
+ if (!res.ok) continue;
33
+ const text = await res.text();
34
+ const match = text.match(/^EXPOSE\s+(\d+)/m);
35
+ if (match) return parseInt(match[1], 10);
36
+ } catch {}
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ /** Most-useful env var to surface as a reference example for each addon type.
42
+ * Mirrors urlKey in lizard-client/src/components/AddonPanel.tsx. */
43
+ function addonExampleVar(type: string): string {
44
+ switch (type) {
45
+ case "postgres":
46
+ case "mysql":
47
+ return "DATABASE_URL";
48
+ case "mongo":
49
+ return "MONGODB_URL";
50
+ case "redis":
51
+ return "REDIS_URL";
52
+ case "s3":
53
+ return "S3_ENDPOINT";
54
+ default:
55
+ return "KEY";
56
+ }
57
+ }
58
+
59
+ function normalizeDbName(name: string): string {
60
+ for (const c of CATALOG) {
61
+ if (c.name === name) return c.name;
62
+ if ((c as any).aliases?.includes(name)) return c.name;
63
+ }
64
+ return name;
65
+ }
66
+
67
+ interface Project {
68
+ id: string;
69
+ name: string;
70
+ slug: string;
71
+ workspaceId?: string | null;
72
+ workspaceName?: string | null;
73
+ }
74
+
75
+ /**
76
+ * Resolve a project by name/slug/id. Name-based lookup hits /api/projects and
77
+ * matches against the list. Falls back to the cwd-linked project when no
78
+ * -p/--project is supplied.
79
+ *
80
+ * When `workspaceFlag` is provided, the lookup is constrained to that
81
+ * workspace — useful for disambiguating identically-named projects.
82
+ */
83
+ async function resolveProject(
84
+ flagValue: string | undefined,
85
+ workspaceFlag?: string,
86
+ ): Promise<string> {
87
+ if (flagValue) {
88
+ let workspaceId: string | undefined;
89
+ if (workspaceFlag) {
90
+ workspaceId = (await resolveWorkspace(workspaceFlag)).id;
91
+ }
92
+ const projects = await api.get<Project[]>(
93
+ withQuery("/api/projects", { workspaceId }),
94
+ );
95
+ const matches = projects.filter(
96
+ (pr) =>
97
+ pr.id === flagValue ||
98
+ pr.slug === flagValue ||
99
+ pr.name === flagValue,
100
+ );
101
+ if (matches.length === 0) {
102
+ throw new Error(
103
+ `Project "${flagValue}" not found. Available: ${projects.map((pr) => pr.name).join(", ") || "(none)"}`,
104
+ );
105
+ }
106
+ if (matches.length > 1) {
107
+ const detail = matches
108
+ .map((m) => ` • ${m.name} in ${m.workspaceName ?? "(personal)"}`)
109
+ .join("\n");
110
+ throw new Error(
111
+ `Multiple projects named "${flagValue}" found:\n${detail}\nPass --workspace to disambiguate.`,
112
+ );
113
+ }
114
+ return matches[0].id;
115
+ }
116
+
117
+ const link = getProjectLink();
118
+ if (link?.projectId) return link.projectId;
119
+
120
+ throw new Error(
121
+ "No project linked to this directory. Pass -p <project-name> or run `lizard init`.",
122
+ );
123
+ }
124
+
125
+ function parseVariables(pairs: string[] | undefined): Record<string, string> {
126
+ if (!pairs?.length) return {};
127
+ const out: Record<string, string> = {};
128
+ for (const pair of pairs) {
129
+ const eq = pair.indexOf("=");
130
+ if (eq < 1) throw new Error(`Invalid variable: "${pair}". Use KEY=value`);
131
+ out[pair.slice(0, eq)] = pair.slice(eq + 1);
132
+ }
133
+ return out;
134
+ }
135
+
136
+ interface AddInput {
137
+ types: string[];
138
+ addon?: string[];
139
+ service?: string;
140
+ repo?: string;
141
+ variables?: string[];
142
+ name?: string;
143
+ instanceName?: string;
144
+ workspace?: string;
145
+ region?: string;
146
+ list?: boolean;
147
+ projectFlag?: string;
148
+ /** Repo-backed service only: attach the repo but skip the initial build.
149
+ * First deploy fires when `service set` provides build-affecting fields
150
+ * or when the user runs `lizard redeploy` explicitly. */
151
+ noDeploy?: boolean;
152
+ /** Set by the wizard so we don't re-enter it after the user already picked. */
153
+ skipWizard?: boolean;
154
+ }
155
+
22
156
  export function registerAdd(program: Command) {
23
157
  program
24
158
  .command("add")
25
- .argument("[name]", "Service name from catalog (postgres, redis, mysql, mongodb)")
26
- .description("Add a service to the project")
27
- .option("--list", "Show available services")
28
- .option("--region <region>", "Region for the service")
29
- .action(async (name: string | undefined, opts) => {
30
- // Show catalog
31
- if (opts.list || (!name && !isTTY())) {
159
+ .argument(
160
+ "[types...]",
161
+ "Addon type(s) to add (postgres / redis / s3). Multiple allowed: `add postgres redis s3`",
162
+ )
163
+ .description("Add a database, service, or repo to the project")
164
+ .option(
165
+ "-a, --addon <type...>",
166
+ "Add one or more managed addons (multi-add: -a postgres -a redis -a s3)",
167
+ )
168
+ .option("-s, --service <name>", "Create an empty service with this name")
169
+ .option("-r, --repo <repo>", "Create a service from a GitHub repo (owner/repo)")
170
+ .option(
171
+ "-v, --variables <kv>",
172
+ "KEY=value pair to seed the service. Repeat for multiple: -v K1=v1 -v K2=v2. Ignored for managed addons.",
173
+ (val: string, prev: string[]) => [...prev, val],
174
+ [] as string[],
175
+ )
176
+ .option("-n, --name <name>", "Name used in ${{<name>.KEY}} templates and shown in the dashboard. Renamable; refs stay stable.")
177
+ .option("--instance-name <name>", "(deprecated) alias for --name")
178
+ .option("-w, --workspace <ws>", "Disambiguate project lookup by workspace")
179
+ .option("--region <code>", "Region to provision the addon/service in")
180
+ .option("--no-deploy", "With -r: attach repo but skip the initial build. First deploy fires on next `service set` or `redeploy`.")
181
+ .option("--list", "Show available database types")
182
+ .action(async (types: string[], opts, command) => {
183
+ const merged = command.optsWithGlobals();
184
+ await runAdd({
185
+ types,
186
+ addon: opts.addon,
187
+ service: opts.service,
188
+ repo: opts.repo,
189
+ variables: opts.variables,
190
+ name: opts.name,
191
+ instanceName: opts.instanceName,
192
+ workspace: opts.workspace,
193
+ region: opts.region,
194
+ noDeploy: opts.deploy === false,
195
+ list: opts.list,
196
+ projectFlag: merged.project,
197
+ });
198
+ });
199
+ }
200
+
201
+ async function runAdd(input: AddInput): Promise<void> {
202
+ const types = input.types ?? [];
203
+ const opts = {
204
+ addon: input.addon,
205
+ service: input.service,
206
+ repo: input.repo,
207
+ variables: input.variables,
208
+ name: input.name,
209
+ instanceName: input.instanceName,
210
+ list: input.list,
211
+ };
212
+ const projectFlag = input.projectFlag;
213
+ const workspaceFlag = input.workspace;
214
+ const region = input.region ?? DEFAULT_REGION;
215
+
216
+ {
217
+ // ── --list: show DB catalog and exit ──────────────────────────────
218
+ if (opts.list || (!types.length && !opts.addon && !opts.service && !opts.repo && !isTTY())) {
32
219
  if (isJSONMode()) {
33
220
  printJSON(CATALOG);
34
221
  } else {
@@ -40,61 +227,206 @@ export function registerAdd(program: Command) {
40
227
  return;
41
228
  }
42
229
 
43
- // Interactive selection
44
- if (!name) {
45
- const selected = await p.select({
46
- message: "Select service to add",
47
- options: CATALOG.map((c) => ({
48
- value: c.name,
49
- label: c.label,
50
- hint: c.description,
51
- })),
52
- });
53
- if (p.isCancel(selected)) process.exit(5);
54
- name = selected as string;
230
+ const variables = parseVariables(opts.variables);
231
+
232
+ if (opts.instanceName && !opts.name) {
233
+ info(chalk.yellow("Warning: --instance-name is deprecated, use --name instead."));
234
+ opts.name = opts.instanceName;
235
+ }
236
+ if (opts.name) {
237
+ const err = validateName(opts.name);
238
+ if (err) throw new Error(`Invalid --name: ${err}`);
55
239
  }
56
240
 
57
- // Validate name is in catalog
58
- const catalogEntry = CATALOG.find((c) => c.name === name);
59
- if (!catalogEntry) {
60
- throw new Error(
61
- `Unknown service "${name}". Available: ${CATALOG.map((c) => c.name).join(", ")}`,
62
- );
241
+ // Resolve project up front so we fail before any wizard prompts or
242
+ // API calls instead of after the user has filled out the wizard.
243
+ const projectId = await resolveProject(projectFlag, workspaceFlag);
244
+ const scope = await scopeForProject(projectId);
245
+
246
+ // ── positional <types...> and/or -a <type...> ────────────────────
247
+ const databases: string[] = [];
248
+ const candidates = [...(opts.addon ?? []), ...types];
249
+ for (const t of candidates) {
250
+ const norm = normalizeDbName(t);
251
+ if (!CATALOG.some((c) => c.name === norm)) {
252
+ throw new Error(
253
+ `Unknown addon "${t}". Available: ${CATALOG.map((c) => c.name).join(", ")}`,
254
+ );
255
+ }
256
+ databases.push(norm);
63
257
  }
64
258
 
65
- const projectId = resolveProjectId(program.opts().project);
66
- const region = opts.region || program.opts().region;
67
-
68
- info(`Adding ${chalk.cyan(catalogEntry.label)}...`);
69
-
70
- const addon = await api.post<{
71
- id: string;
72
- name: string;
73
- addonType: string;
74
- status: string;
75
- hostname?: string;
76
- connectionString?: string;
77
- envVars?: Record<string, string>;
78
- }>(`/api/projects/${projectId}/addons`, {
79
- addonType: name,
80
- region,
81
- });
259
+ // Nudge users off the verbose single-arg `-a` form toward `lizard add <type>`.
260
+ if (opts.addon?.length === 1 && !types.length && !isJSONMode()) {
261
+ info(chalk.dim(`Tip: shorter form — \`lizard add ${opts.addon[0]}\``));
262
+ }
263
+
264
+ if (databases.length > 0) {
265
+ if (opts.variables?.length) {
266
+ info(chalk.yellow("Warning: --variables is ignored for managed addons"));
267
+ }
268
+ const isSingle = databases.length === 1;
269
+ for (const db of databases) {
270
+ const cat = CATALOG.find((c) => c.name === db)!;
271
+ info(`Adding ${chalk.cyan(cat.label)}...`);
272
+ const addon = await api.post<{
273
+ id: string;
274
+ name: string;
275
+ addonType: string;
276
+ status: string;
277
+ hostname?: string;
278
+ envVars?: Record<string, string>;
279
+ }>(withScope(`/api/projects/${projectId}/addons`, scope), {
280
+ type: db,
281
+ region,
282
+ ...(opts.name ? { name: opts.name } : {}),
283
+ });
284
+
285
+ if (isJSONMode()) printJSON(addon);
286
+ else {
287
+ success(`${cat.label} added`);
288
+ const ref = addonRefName({ name: addon.name, type: (addon as any).type, addonType: addon.addonType });
289
+ const exampleVar = addonExampleVar(db);
290
+ if (ref) info(` Name: ${chalk.bold(ref)}`);
291
+ if (ref) {
292
+ info("");
293
+ info(chalk.dim(` Reference the ${exampleVar} from other services:`));
294
+ info(` ${chalk.cyan(`\${{${ref}.${exampleVar}}}`)}`);
295
+ }
296
+ }
82
297
 
83
- if (isJSONMode()) {
84
- printJSON(addon);
298
+ if (isSingle) {
299
+ try {
300
+ updateProjectLink({ serviceId: addon.id, serviceName: addon.name });
301
+ } catch {}
302
+ }
303
+ }
85
304
  return;
86
305
  }
87
306
 
88
- success(`${catalogEntry.label} added`);
307
+ // ── -r <repo> ─────────────────────────────────────────────────────
308
+ if (opts.repo) {
309
+ const serviceName = opts.name || opts.service || opts.repo.split("/").pop() || "service";
310
+ info(`Creating service ${chalk.bold(serviceName)} from ${chalk.cyan(opts.repo)}...`);
311
+ const detectedPort = await detectPortFromDockerfile(opts.repo);
312
+ if (detectedPort) info(`Detected port ${chalk.bold(detectedPort)} from Dockerfile`);
313
+ const app = await api.post<{ id: string; name: string }>(
314
+ withScope(`/api/projects/${projectId}/apps`, scope),
315
+ {
316
+ name: serviceName,
317
+ repoUrl: opts.repo.startsWith("http")
318
+ ? opts.repo
319
+ : `https://github.com/${opts.repo}`,
320
+ region,
321
+ variables,
322
+ ...(detectedPort ? { containerPort: detectedPort } : {}),
323
+ ...(input.noDeploy ? { skipInitialDeploy: true } : {}),
324
+ },
325
+ );
326
+ if (isJSONMode()) printJSON(app);
327
+ else {
328
+ success(`Service ${chalk.bold(app.name)} created${input.noDeploy ? " (initial deploy deferred)" : ""}`);
329
+ info("");
330
+ if (input.noDeploy) {
331
+ info(chalk.dim(` Configure and trigger the first deploy:`));
332
+ info(` ${chalk.cyan(`lizard service set ${app.name} --set buildCommand='...' --set startCommand='...'`)}`);
333
+ info("");
334
+ }
335
+ info(chalk.dim(` Reference this service's private URL from other services:`));
336
+ info(` ${chalk.cyan(`\${{${app.name}.LIZARD_PRIVATE_DOMAIN}}`)}`);
337
+ }
338
+ try {
339
+ updateProjectLink({ serviceId: app.id, serviceName: app.name });
340
+ } catch {}
341
+ return;
342
+ }
89
343
 
90
- if (addon.hostname) {
91
- info(` Host: ${chalk.cyan(addon.hostname)}`);
344
+ // ── --service <name> (empty service) ──────────────────────────────
345
+ if (opts.service) {
346
+ info(`Creating empty service ${chalk.bold(opts.service)}...`);
347
+ const app = await api.post<{ id: string; name: string }>(
348
+ withScope(`/api/projects/${projectId}/apps`, scope),
349
+ {
350
+ name: opts.service,
351
+ region,
352
+ variables,
353
+ },
354
+ );
355
+ if (isJSONMode()) printJSON(app);
356
+ else {
357
+ success(`Service ${chalk.bold(app.name)} created`);
358
+ info("");
359
+ info(chalk.dim(` Reference this service's private URL from other services:`));
360
+ info(` ${chalk.cyan(`\${{${app.name}.LIZARD_PRIVATE_DOMAIN}}`)}`);
361
+ }
362
+ try {
363
+ updateProjectLink({ serviceId: app.id, serviceName: app.name });
364
+ } catch {}
365
+ return;
92
366
  }
93
- if (addon.envVars) {
94
- info(chalk.dim("\n Environment variables added to project:"));
95
- for (const [key, val] of Object.entries(addon.envVars)) {
96
- info(` ${chalk.bold(key)}=${chalk.dim(val)}`);
367
+
368
+ // ── No flags + no positional → interactive wizard ────────────────
369
+ // After the wizard collects a concrete choice we re-enter runAdd
370
+ // directly (no program.parseAsync round-trip): runAdd routes to a
371
+ // concrete branch above based on the inputs we hand it.
372
+ if (!types.length && !input.skipWizard && isTTY()) {
373
+ const kind = await p.select({
374
+ message: "What do you need?",
375
+ options: [
376
+ { value: "database", label: "Database", hint: "postgres / redis" },
377
+ { value: "s3", label: "S3 Bucket", hint: "S3-compatible object storage" },
378
+ { value: "repo", label: "GitHub Repo", hint: "create a service from a repo" },
379
+ { value: "service", label: "Empty Service", hint: "create a service to upload code into" },
380
+ ],
381
+ });
382
+ if (p.isCancel(kind)) process.exit(5);
383
+
384
+ if (kind === "database") {
385
+ const sel = await p.select({
386
+ message: "Select database",
387
+ options: CATALOG.filter((c) => c.name !== "s3").map((c) => ({
388
+ value: c.name,
389
+ label: c.label,
390
+ hint: c.description,
391
+ })),
392
+ });
393
+ if (p.isCancel(sel)) process.exit(5);
394
+ await runAdd({ ...input, types: [sel as string], skipWizard: true });
395
+ return;
396
+ }
397
+
398
+ if (kind === "s3") {
399
+ await runAdd({ ...input, types: ["s3"], skipWizard: true });
400
+ return;
401
+ }
402
+
403
+ if (kind === "repo") {
404
+ const repo = await p.text({ message: "Repo (owner/name)" });
405
+ if (p.isCancel(repo)) process.exit(5);
406
+ const svc = await p.text({ message: "Service name", placeholder: String(repo).split("/").pop() });
407
+ if (p.isCancel(svc)) process.exit(5);
408
+ await runAdd({
409
+ ...input,
410
+ repo: String(repo),
411
+ service: String(svc) || undefined,
412
+ skipWizard: true,
413
+ });
414
+ return;
415
+ }
416
+
417
+ if (kind === "service") {
418
+ const svc = await p.text({ message: "Service name" });
419
+ if (p.isCancel(svc)) process.exit(5);
420
+ await runAdd({ ...input, service: String(svc), skipWizard: true });
421
+ return;
97
422
  }
98
423
  }
99
- });
424
+
425
+ throw new Error(
426
+ "No service type specified. Examples:\n" +
427
+ " lizard add postgres Add a managed database\n" +
428
+ " lizard add -r owner/repo Create a service from a GitHub repo\n" +
429
+ " lizard add -s my-service Empty service",
430
+ );
431
+ }
100
432
  }
@@ -0,0 +1,80 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import chalk from "chalk";
4
+ import ora from "ora";
5
+ import { Command } from "commander";
6
+ import { api, withScope } from "../lib/api.js";
7
+ import { resolveProjectScope } from "../lib/resolve.js";
8
+ import { success, error, isJSONMode, printJSON } from "../lib/format.js";
9
+
10
+ export function registerConfig(program: Command) {
11
+ const config = program.command("config").description("Manage project configuration");
12
+
13
+ config
14
+ .command("apply")
15
+ .description("Apply lizard-config.json to the project")
16
+ .option("-p, --project <id>", "Project name, slug, or ID")
17
+ .option("-f, --file <path>", "Path to config file (default: lizard-config.json)")
18
+ .option("--dry-run", "Show what would change without applying")
19
+ .action(async (opts) => {
20
+ const filePath = opts.file
21
+ ? path.resolve(opts.file)
22
+ : path.join(process.cwd(), "lizard-config.json");
23
+
24
+ if (!fs.existsSync(filePath)) {
25
+ error(`Config file not found: ${filePath}`);
26
+ process.exit(1);
27
+ }
28
+
29
+ let config: any;
30
+ try {
31
+ config = JSON.parse(fs.readFileSync(filePath, "utf-8"));
32
+ } catch (e: any) {
33
+ error(`Failed to parse config file: ${e.message}`);
34
+ process.exit(1);
35
+ }
36
+
37
+ const { projectId, scope } = await resolveProjectScope(opts.project);
38
+
39
+ if (opts.dryRun) {
40
+ printJSON({ dryRun: true, projectId, config });
41
+ return;
42
+ }
43
+
44
+ const spinner = ora("Applying config...").start();
45
+ try {
46
+ const result = await api.post<{ services: any[]; addons: any[]; revision: number }>(
47
+ withScope(`/api/projects/${projectId}/config:apply`, scope),
48
+ config,
49
+ );
50
+ spinner.stop();
51
+
52
+ if (isJSONMode()) {
53
+ printJSON(result);
54
+ return;
55
+ }
56
+
57
+ const changed = [
58
+ ...(result.services || []).filter((s: any) => s.changed?.length > 0),
59
+ ...(result.addons || []).filter((a: any) => a.changed?.length > 0),
60
+ ];
61
+
62
+ if (changed.length === 0) {
63
+ success("Config applied — nothing changed");
64
+ } else {
65
+ success(`Config applied (revision ${result.revision})`);
66
+ for (const svc of result.services || []) {
67
+ if (svc.changed?.length > 0) {
68
+ console.log(
69
+ ` ${chalk.cyan(svc.name)}: ${svc.changed.join(", ")}`,
70
+ );
71
+ }
72
+ }
73
+ }
74
+ } catch (e: any) {
75
+ spinner.stop();
76
+ error(`Failed to apply config: ${e.message}`);
77
+ process.exit(1);
78
+ }
79
+ });
80
+ }
@@ -0,0 +1,15 @@
1
+ import { Command } from "commander";
2
+ import open from "open";
3
+ import { success } from "../lib/format.js";
4
+
5
+ const DOCS_URL = "https://docs.lizard.build";
6
+
7
+ export function registerDocs(program: Command) {
8
+ program
9
+ .command("docs")
10
+ .description("Open Lizard documentation in browser")
11
+ .action(async () => {
12
+ await open(DOCS_URL);
13
+ success(`Opened ${DOCS_URL}`);
14
+ });
15
+ }