@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,20 +1,60 @@
1
1
  import chalk from "chalk";
2
2
  import ora from "ora";
3
+ import * as p from "@clack/prompts";
3
4
  import { Command } from "commander";
4
- import { api, streamSSE } from "../lib/api.js";
5
- import { success, info, error, isJSONMode, printJSON } from "../lib/format.js";
5
+ import { api, streamSSE, withScope } from "../lib/api.js";
6
+ import { resolveProjectScope, resolveService } from "../lib/resolve.js";
7
+ import { success, info, error, isJSONMode, printJSON, isTTY } from "../lib/format.js";
6
8
 
7
9
  export function registerRedeploy(program: Command) {
8
10
  program
9
11
  .command("redeploy")
10
- .argument("<id>", "Service ID to redeploy")
11
- .description("Redeploy a service from latest build with current secrets")
12
+ .argument("[nameOrId]", "App name or ID to redeploy")
13
+ .description("Trigger a fresh build (latest commit / last upload) with current vars")
12
14
  .option("--detach", "Run in background")
13
- .action(async (id: string, opts) => {
14
- const spinner = ora("Starting redeploy...").start();
15
+ .option("-s, --service <name>", "App name or ID (alias for positional)")
16
+ .option("-p, --project <id>", "Project name, slug, or ID")
17
+ .action(async (nameOrId: string | undefined, opts) => {
18
+ const ref = nameOrId || opts.service;
19
+ let id: string | undefined;
20
+ if (ref) {
21
+ const { projectId } = await resolveProjectScope(opts.project);
22
+ const resolved = await resolveService(projectId, ref);
23
+ if (resolved.kind !== "app") {
24
+ throw new Error(`"${ref}" is not an app`);
25
+ }
26
+ id = resolved.id;
27
+ } else {
28
+ if (!isTTY()) throw new Error("Provide an app name or ID, or run interactively");
15
29
 
16
- await api.post(`/api/apps/${id}/redeploy`);
30
+ const { projectId, scope } = await resolveProjectScope(opts.project);
31
+ const data = await api.get<{ apps: any[] }>(withScope(`/api/projects/${projectId}/services`, scope));
32
+ const apps = data.apps || [];
33
+
34
+ if (apps.length === 0) {
35
+ throw new Error(
36
+ "No apps in project. Create one with `lizard up` or `lizard add`.",
37
+ );
38
+ }
17
39
 
40
+ if (apps.length === 1) {
41
+ id = apps[0].id;
42
+ } else {
43
+ const selected = await p.select({
44
+ message: "Select app to redeploy",
45
+ options: apps.map((a: any) => ({
46
+ value: a.id,
47
+ label: a.name || a.id,
48
+ hint: a.status,
49
+ })),
50
+ });
51
+ if (p.isCancel(selected)) process.exit(5);
52
+ id = selected as string;
53
+ }
54
+ }
55
+
56
+ const spinner = ora("Starting redeploy...").start();
57
+ await api.post(`/api/apps/${id}/redeploy`);
18
58
  spinner.stop();
19
59
 
20
60
  if (opts.detach || isJSONMode()) {
@@ -22,12 +62,11 @@ export function registerRedeploy(program: Command) {
22
62
  printJSON({ id, status: "deploying" });
23
63
  } else {
24
64
  success("Redeploy started");
25
- info(chalk.dim(` Check status: lizard deploy-status ${id}`));
65
+ info(chalk.dim(` Check status: lizard deploy status ${id}`));
26
66
  }
27
67
  return;
28
68
  }
29
69
 
30
- // Stream build logs if not detached
31
70
  info("Redeploying...");
32
71
 
33
72
  // Poll for build
@@ -56,7 +95,9 @@ export function registerRedeploy(program: Command) {
56
95
  }
57
96
  try {
58
97
  const parsed = JSON.parse(data);
59
- process.stdout.write((parsed.line || data) + "\n");
98
+ const line =
99
+ typeof parsed === "string" ? parsed : (parsed.line ?? data);
100
+ process.stdout.write(line + "\n");
60
101
  } catch {
61
102
  process.stdout.write(data + "\n");
62
103
  }
@@ -10,12 +10,8 @@ interface Region {
10
10
  }
11
11
 
12
12
  export function registerRegions(program: Command) {
13
- const region = program
14
- .command("region")
15
- .description("Region management");
16
-
17
- region
18
- .command("list")
13
+ program
14
+ .command("regions")
19
15
  .description("List available regions")
20
16
  .action(async () => {
21
17
  const regions = await api.get<Region[]>("/api/regions");
@@ -1,24 +1,98 @@
1
1
  import chalk from "chalk";
2
2
  import ora from "ora";
3
+ import * as p from "@clack/prompts";
3
4
  import { Command } from "commander";
4
- import { api } from "../lib/api.js";
5
- import { success, isJSONMode, printJSON } from "../lib/format.js";
5
+ import { api, withScope } from "../lib/api.js";
6
+ import { resolveProjectScope, resolveService } from "../lib/resolve.js";
7
+ import { success, info, error, isJSONMode, printJSON, isTTY } from "../lib/format.js";
6
8
 
7
9
  export function registerRestart(program: Command) {
8
10
  program
9
11
  .command("restart")
10
- .argument("<id>", "Service ID to restart")
11
- .description("Restart a service")
12
- .action(async (id: string) => {
13
- const spinner = ora("Restarting...").start();
12
+ .argument("[nameOrId]", "App name or ID to restart")
13
+ .description("Restart an app")
14
+ .option("--detach", "Run in background")
15
+ .option("-s, --service <name>", "App name or ID (alias for positional)")
16
+ .option("-p, --project <id>", "Project name, slug, or ID")
17
+ .action(async (nameOrId: string | undefined, opts) => {
18
+ const ref = nameOrId || opts.service;
19
+ let id: string | undefined;
20
+ if (ref) {
21
+ const { projectId } = await resolveProjectScope(opts.project);
22
+ const resolved = await resolveService(projectId, ref);
23
+ if (resolved.kind !== "app") {
24
+ throw new Error(`"${ref}" is not an app`);
25
+ }
26
+ id = resolved.id;
27
+ } else {
28
+ if (!isTTY()) throw new Error("Provide an app name or ID, or run interactively");
29
+
30
+ const { projectId, scope } = await resolveProjectScope(opts.project);
31
+ const data = await api.get<{ apps: any[] }>(withScope(`/api/projects/${projectId}/services`, scope));
32
+ const apps = data.apps || [];
33
+
34
+ if (apps.length === 0) throw new Error("No apps in project");
14
35
 
15
- await api.post(`/api/apps/${id}/restart`);
36
+ if (apps.length === 1) {
37
+ id = apps[0].id;
38
+ } else {
39
+ const selected = await p.select({
40
+ message: "Select app to restart",
41
+ options: apps.map((a: any) => ({
42
+ value: a.id,
43
+ label: a.name || a.id,
44
+ hint: a.status,
45
+ })),
46
+ });
47
+ if (p.isCancel(selected)) process.exit(5);
48
+ id = selected as string;
49
+ }
50
+ }
16
51
 
52
+ const spinner = ora("Starting restart...").start();
53
+ await api.post(`/api/apps/${id}/restart`, undefined, { "X-Deploy-Source": "cli" });
17
54
  spinner.stop();
18
- if (isJSONMode()) {
19
- printJSON({ id, status: "restarting" });
55
+
56
+ if (opts.detach || isJSONMode()) {
57
+ if (isJSONMode()) {
58
+ printJSON({ id, status: "restarting" });
59
+ } else {
60
+ success("Restart started");
61
+ info(chalk.dim(` Check status: lizard ps ${id}`));
62
+ }
63
+ return;
64
+ }
65
+
66
+ const waitSpinner = ora("Restarting...").start();
67
+
68
+ let finalStatus: string | undefined;
69
+ let domain: string | undefined;
70
+ for (let i = 0; i < 60; i++) {
71
+ await new Promise((r) => setTimeout(r, 2000));
72
+ try {
73
+ const app = await api.get<{ status: string; domain?: string }>(
74
+ `/api/apps/${id}`,
75
+ );
76
+ domain = app.domain;
77
+ if (app.status === "running") {
78
+ finalStatus = "running";
79
+ break;
80
+ }
81
+ if (app.status === "failed" || app.status === "error") {
82
+ finalStatus = app.status;
83
+ break;
84
+ }
85
+ } catch {}
86
+ }
87
+
88
+ waitSpinner.stop();
89
+
90
+ if (finalStatus === "running") {
91
+ success(`Restarted! ${domain ? chalk.cyan(`https://${domain}`) : ""}`);
92
+ } else if (finalStatus) {
93
+ error("Restart failed");
20
94
  } else {
21
- success(`Service ${id} restarting`);
95
+ info(chalk.dim("Still restarting — check status with: lizard ps " + id));
22
96
  }
23
97
  });
24
98
  }
@@ -1,43 +1,87 @@
1
- import { execSync } from "node:child_process";
1
+ import { spawnSync } from "node:child_process";
2
2
  import { Command } from "commander";
3
- import { api } from "../lib/api.js";
4
- import { resolveProjectId } from "../lib/config.js";
3
+ import { api, withScope } from "../lib/api.js";
4
+ import { getProjectLink } from "../lib/config.js";
5
+ import { resolveProjectScope, resolveService } from "../lib/resolve.js";
5
6
 
6
7
  interface Secret {
7
8
  key: string;
8
9
  value: string;
9
10
  }
10
11
 
12
+ /**
13
+ * `lizard run <command...>` — run a local command with platform secrets in
14
+ * the environment. Project-scope secrets are loaded first, then the linked
15
+ * (or `-s <service>`) service overrides on key collisions, mirroring the
16
+ * order the platform applies them on the server.
17
+ *
18
+ * Pass `-s <service>` to switch service without touching the link.
19
+ * Pass `--no-service` to load only project-scope secrets.
20
+ */
11
21
  export function registerRun(program: Command) {
12
22
  program
13
23
  .command("run")
14
- .argument("<command...>", "Command to run with project env vars")
15
- .description("Run a command with project secrets as env vars")
24
+ .argument("<command...>", "Command to run with platform env vars")
25
+ .description("Run a command with project + service secrets injected")
26
+ .option("-s, --service <name>", "Service to pull secrets from (defaults to linked)")
27
+ .option("--no-service", "Skip service-scope secrets, project only")
28
+ .option("-p, --project <id>", "Project ID (defaults to linked)")
16
29
  .allowUnknownOption()
17
- .action(async (args: string[]) => {
18
- const projectId = resolveProjectId(program.opts().project);
30
+ .action(async (args: string[], opts: any) => {
31
+ const { projectId, scope } = await resolveProjectScope(opts.project);
19
32
 
20
- // Fetch secrets
21
- const secrets = await api.get<Secret[]>(
22
- `/api/projects/${projectId}/secrets`,
33
+ const env: Record<string, string> = { ...(process.env as Record<string, string>) };
34
+
35
+ // 1. project secrets
36
+ const projectSecrets = await api.get<Secret[]>(
37
+ withScope(`/api/projects/${projectId}/secrets`, scope),
23
38
  );
39
+ for (const s of projectSecrets) env[s.key] = s.value;
40
+
41
+ // 2. service / addon secrets (override project)
42
+ if (opts.service !== false) {
43
+ const serviceRef =
44
+ typeof opts.service === "string" ? opts.service : getProjectLink()?.serviceId;
24
45
 
25
- // Build env
26
- const env: Record<string, string> = { ...process.env as Record<string, string> };
27
- for (const s of secrets) {
28
- env[s.key] = s.value;
46
+ if (serviceRef) {
47
+ const svc = await resolveService(projectId, serviceRef);
48
+ const path =
49
+ svc.kind === "app"
50
+ ? withScope(`/api/apps/${svc.id}/secrets`, scope)
51
+ : withScope(`/api/projects/${projectId}/addons/${svc.id}/secrets`, scope);
52
+ const serviceSecrets = await api.get<Secret[]>(path).catch((err: any) => {
53
+ if (err?.status === 404) {
54
+ if (svc.kind === "addon") {
55
+ console.warn(
56
+ `warning: addon "${svc.name}" exposes no secrets endpoint yet ` +
57
+ `(needs GET ${path}). Falling back to project-only env.`,
58
+ );
59
+ }
60
+ return [] as Secret[];
61
+ }
62
+ throw err;
63
+ });
64
+ for (const s of serviceSecrets) env[s.key] = s.value;
65
+ }
29
66
  }
30
67
 
31
- // Run command
32
- const cmd = args.join(" ");
33
- try {
34
- execSync(cmd, {
35
- env,
36
- stdio: "inherit",
37
- shell: process.env.SHELL || "/bin/sh",
38
- });
39
- } catch (err: any) {
40
- process.exit(err.status || 1);
68
+ // argv-style spawn — no shell, so quoting / pipes / `;` in user-supplied
69
+ // args are passed verbatim to the target program instead of being parsed
70
+ // by /bin/sh. If the user wants a shell, they can invoke one explicitly:
71
+ // `lizard run sh -c 'foo | bar'`.
72
+ const result = spawnSync(args[0], args.slice(1), {
73
+ env,
74
+ stdio: "inherit",
75
+ });
76
+ if (result.error) {
77
+ const code = (result.error as NodeJS.ErrnoException).code;
78
+ if (code === "ENOENT") {
79
+ console.error(`lizard run: command not found: ${args[0]}`);
80
+ process.exit(127);
81
+ }
82
+ console.error(`lizard run: ${result.error.message}`);
83
+ process.exit(1);
41
84
  }
85
+ process.exit(result.status ?? 1);
42
86
  });
43
87
  }
@@ -0,0 +1,216 @@
1
+ import chalk from "chalk";
2
+ import { Command } from "commander";
3
+ import { api, withScope, type ResourceScope } from "../lib/api.js";
4
+ import { getActiveServiceWithKind, resolveProjectScope } from "../lib/resolve.js";
5
+ import { success, isJSONMode, printJSON } from "../lib/format.js";
6
+
7
+ // Discrete tiers the dashboard slider exposes (CPU_OPTIONS / MEMORY_OPTIONS in
8
+ // lizard-client). The API and node-agent accept other values, but anything off
9
+ // the tier list lands in DB as a string the UI slider can't snap to — the row
10
+ // shows raw "3000m" and the slider falls to index 0. Mirror the UI tiers so the
11
+ // CLI and dashboard stay in lockstep.
12
+ const ALLOWED_CPU_CORES = [1, 2, 4] as const;
13
+ const ALLOWED_MEMORY_MB = [512, 1024, 2048, 4096] as const;
14
+ // Storage tiers match the addon size selector on the dashboard. Addon-only —
15
+ // apps don't have a resizable data volume on this path.
16
+ const ALLOWED_STORAGE_MB = [512, 1024, 2048, 4096, 8192, 16384] as const;
17
+
18
+ /**
19
+ * `lizard scale` — service scaling.
20
+ * --replicas <n> change replica count (1-10) — apps only
21
+ * --cpu <cores> CPU cap; allowed: 1, 2, 4
22
+ * --memory <mb> memory cap; allowed: 512, 1024, 2048, 4096
23
+ * --storage <mb> data volume size; addons only, grow-only;
24
+ * allowed: 512, 1024, 2048, 4096, 8192, 16384
25
+ */
26
+ export function registerScale(program: Command) {
27
+ program
28
+ .command("scale")
29
+ .description("Scale a service (replicas / CPU / memory / storage)")
30
+ .argument("[service]", "Service name or ID (defaults to linked)")
31
+ .option("-s, --service <name>", "Service name or ID")
32
+ .option("-p, --project <id>", "Project name, slug, or ID")
33
+ .option("--replicas <n>", "Number of replicas (1-10), apps only", parseIntOption)
34
+ .option("--cpu <cores>", `CPU cap, whole cores (allowed: ${ALLOWED_CPU_CORES.join(", ")})`, parseIntOption)
35
+ .option("--memory <mb>", `Memory cap in MB (allowed: ${ALLOWED_MEMORY_MB.join(", ")})`, parseIntOption)
36
+ .option("--storage <mb>", `Data volume size in MB, addons only, grow-only (allowed: ${ALLOWED_STORAGE_MB.join(", ")})`, parseIntOption)
37
+ .action(async (serviceArg: string | undefined, opts, _cmd) => {
38
+ const { projectId, scope } = await resolveProjectScope(opts.project);
39
+ const service = await getActiveServiceWithKind(serviceArg || opts.service, projectId);
40
+
41
+ if (
42
+ opts.replicas === undefined &&
43
+ opts.cpu === undefined &&
44
+ opts.memory === undefined &&
45
+ opts.storage === undefined
46
+ ) {
47
+ throw new Error("Pass at least one of: --replicas, --cpu, --memory, --storage.");
48
+ }
49
+
50
+ if (opts.replicas !== undefined && (opts.replicas < 1 || opts.replicas > 10)) {
51
+ throw new Error(`--replicas must be between 1 and 10 (got ${opts.replicas}).`);
52
+ }
53
+ if (opts.cpu !== undefined && !(ALLOWED_CPU_CORES as readonly number[]).includes(opts.cpu)) {
54
+ throw new Error(
55
+ `--cpu ${opts.cpu} not supported. Allowed: ${ALLOWED_CPU_CORES.join(", ")} cores.`,
56
+ );
57
+ }
58
+ if (opts.memory !== undefined && !(ALLOWED_MEMORY_MB as readonly number[]).includes(opts.memory)) {
59
+ throw new Error(
60
+ `--memory ${opts.memory} not supported. Allowed: ${ALLOWED_MEMORY_MB.join(", ")} MB.`,
61
+ );
62
+ }
63
+ if (opts.storage !== undefined && !(ALLOWED_STORAGE_MB as readonly number[]).includes(opts.storage)) {
64
+ throw new Error(
65
+ `--storage ${opts.storage} not supported. Allowed: ${ALLOWED_STORAGE_MB.join(", ")} MB.`,
66
+ );
67
+ }
68
+
69
+ if (service.kind === "addon") {
70
+ if (opts.replicas !== undefined) {
71
+ throw new Error("Addons run as a single VM and don't support --replicas.");
72
+ }
73
+ return scaleAddon(projectId, scope, service, opts.cpu, opts.memory, opts.storage);
74
+ }
75
+
76
+ if (opts.storage !== undefined) {
77
+ throw new Error("--storage is only supported for addons (postgres, redis, mongo, mysql).");
78
+ }
79
+
80
+ // App path — replicas go to /scale; cpu/memory go through config:apply
81
+ // (PATCH /api/apps/:id was retired with 410 in favour of config:apply).
82
+ const calls: Promise<unknown>[] = [];
83
+
84
+ if (opts.replicas !== undefined) {
85
+ calls.push(api.patch(`/api/apps/${service.id}/scale`, { replicas: opts.replicas }));
86
+ }
87
+
88
+ let configApplyResult: ConfigApplyResult | undefined;
89
+ if (opts.cpu !== undefined || opts.memory !== undefined) {
90
+ const serviceEntry: Record<string, unknown> = { id: service.id, name: service.name };
91
+ if (opts.cpu !== undefined) serviceEntry.cpuLimit = `${opts.cpu * 1000}m`;
92
+ if (opts.memory !== undefined) serviceEntry.memoryLimit = mbToK8s(opts.memory);
93
+ calls.push(
94
+ api.post<ConfigApplyResult>(
95
+ withScope(`/api/projects/${projectId}/config:apply`, scope),
96
+ { services: [serviceEntry] },
97
+ ).then((r) => { configApplyResult = r; return r; }),
98
+ );
99
+ }
100
+
101
+ await Promise.all(calls);
102
+ if (configApplyResult) warnSideEffects(configApplyResult);
103
+
104
+ if (isJSONMode()) {
105
+ printJSON({
106
+ id: service.id,
107
+ ...(opts.replicas !== undefined ? { replicas: opts.replicas } : {}),
108
+ ...(opts.cpu !== undefined ? { cpuLimit: `${opts.cpu * 1000}m` } : {}),
109
+ ...(opts.memory !== undefined ? { memoryLimit: mbToK8s(opts.memory) } : {}),
110
+ });
111
+ } else {
112
+ success(`Scaled ${chalk.bold(service.name)}`);
113
+ }
114
+ });
115
+ }
116
+
117
+ interface ConfigApplyResult {
118
+ revision?: number;
119
+ sideEffectFailures?: { op: string; error: string }[];
120
+ }
121
+
122
+ interface AddonRecord {
123
+ id: string;
124
+ status?: string;
125
+ config?: {
126
+ vcpu?: number;
127
+ vcpuLimit?: number;
128
+ memoryMb?: number;
129
+ memoryMbLimit?: number;
130
+ storageSize?: string;
131
+ };
132
+ }
133
+
134
+ /** Warn the user about any deferred side-effect failures from a config:apply response. */
135
+ function warnSideEffects(result: ConfigApplyResult): void {
136
+ if (!result.sideEffectFailures?.length) return;
137
+ for (const f of result.sideEffectFailures) {
138
+ process.stderr.write(chalk.yellow(`⚠ Side effect failed (${f.op}): ${f.error}\n`));
139
+ }
140
+ }
141
+
142
+ async function scaleAddon(
143
+ projectId: string,
144
+ scope: ResourceScope,
145
+ service: { id: string; name: string },
146
+ cpu: number | undefined,
147
+ memory: number | undefined,
148
+ storageMb: number | undefined,
149
+ ): Promise<void> {
150
+ // Only pre-fetch addon list when --storage is passed — needed for the
151
+ // grow-only check. cpu/memory go through config:apply which accepts partial
152
+ // deltas, so no pre-fetch is needed for those axes.
153
+ if (storageMb !== undefined) {
154
+ const addons = await api.get<AddonRecord[]>(
155
+ withScope(`/api/projects/${projectId}/addons`, scope),
156
+ );
157
+ const current = addons.find((a) => a.id === service.id);
158
+ if (!current) throw new Error(`Addon "${service.name}" not found in project.`);
159
+ const currentMb = current.config?.storageSize ? parseStorageToMb(current.config.storageSize) : 0;
160
+ if (currentMb > 0 && storageMb <= currentMb) {
161
+ throw new Error(
162
+ `--storage ${storageMb} MB is not larger than current ${currentMb} MB. Storage is grow-only.`,
163
+ );
164
+ }
165
+ }
166
+
167
+ // Build the config:apply addon patch. All three axes are optional and can
168
+ // be combined freely — the server writes only the fields present.
169
+ const addonPatch: Record<string, unknown> = { id: service.id };
170
+ if (cpu !== undefined || memory !== undefined) {
171
+ const limits: Record<string, number> = {};
172
+ if (cpu !== undefined) limits.vcpu = cpu;
173
+ if (memory !== undefined) limits.memoryMb = memory;
174
+ addonPatch.limits = limits;
175
+ }
176
+ if (storageMb !== undefined) {
177
+ addonPatch.storageSize = mbToK8s(storageMb);
178
+ }
179
+
180
+ const result = await api.post<ConfigApplyResult>(
181
+ withScope(`/api/projects/${projectId}/config:apply`, scope),
182
+ { addons: [addonPatch] },
183
+ );
184
+ warnSideEffects(result);
185
+
186
+ if (isJSONMode()) {
187
+ printJSON({
188
+ id: service.id,
189
+ kind: "addon",
190
+ ...(cpu !== undefined ? { vcpu: cpu } : {}),
191
+ ...(memory !== undefined ? { memoryMb: memory } : {}),
192
+ ...(storageMb !== undefined ? { storageSize: mbToK8s(storageMb) } : {}),
193
+ });
194
+ } else {
195
+ success(`Scaled ${chalk.bold(service.name)} (addon)`);
196
+ }
197
+ }
198
+
199
+ /** 1024 → "1Gi", 512 → "512Mi". Matches the dashboard size tiers (memory + addon storage). */
200
+ function mbToK8s(mb: number): string {
201
+ return mb % 1024 === 0 ? `${mb / 1024}Gi` : `${mb}Mi`;
202
+ }
203
+
204
+ /** Inverse of mbToK8s for grow-only comparison; supports Gi/Mi/Ti. */
205
+ function parseStorageToMb(size: string): number {
206
+ if (size.endsWith("Gi")) return parseFloat(size) * 1024;
207
+ if (size.endsWith("Mi")) return parseFloat(size);
208
+ if (size.endsWith("Ti")) return parseFloat(size) * 1024 * 1024;
209
+ return 0;
210
+ }
211
+
212
+ function parseIntOption(v: string): number {
213
+ const n = parseInt(v, 10);
214
+ if (Number.isNaN(n)) throw new Error(`Invalid number: ${v}`);
215
+ return n;
216
+ }