@lizard-build/cli 0.3.93 → 0.3.94

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.
@@ -3,13 +3,16 @@ import ora from "ora";
3
3
  import * as p from "@clack/prompts";
4
4
  import { api, streamSSE, withScope } from "../lib/api.js";
5
5
  import { resolveProjectScope, resolveService } from "../lib/resolve.js";
6
- import { success, info, error, isJSONMode, printJSON, isTTY } from "../lib/format.js";
6
+ import { success, info, error, isJSONMode, printJSON, isTTY, fail } from "../lib/format.js";
7
+ import { waitForAppReady } from "../lib/wait-ready.js";
7
8
  export function registerRedeploy(program) {
8
9
  program
9
10
  .command("redeploy")
10
11
  .argument("[nameOrId]", "App name or ID to redeploy")
11
12
  .description("Trigger a fresh build (latest commit / last upload) with current vars")
12
13
  .option("--detach", "Run in background")
14
+ .option("--wait", "Wait for the new build to deploy and become ready before exiting (works in --json mode too)")
15
+ .option("--timeout <seconds>", "Max time to wait with --wait, in seconds", "120")
13
16
  .option("-s, --service <name>", "App name or ID (alias for positional)")
14
17
  .option("-p, --project <id>", "Project name, slug, or ID")
15
18
  .action(async (nameOrId, opts) => {
@@ -52,12 +55,17 @@ export function registerRedeploy(program) {
52
55
  throw new Error("Multiple apps — provide an app name or ID, or run interactively");
53
56
  }
54
57
  }
58
+ const timeoutSeconds = parseInt(opts.timeout, 10);
59
+ if (!(timeoutSeconds > 0)) {
60
+ fail(`--timeout must be a positive number of seconds, got ${JSON.stringify(opts.timeout)}`, 1, "INVALID_ARGUMENT");
61
+ }
62
+ const timeoutMs = timeoutSeconds * 1000;
55
63
  const spinner = ora("Starting redeploy...").start();
56
64
  // The endpoint pre-creates and returns the Build record — use its id
57
65
  // instead of polling builds[0], which races against a previous build.
58
66
  const build = await api.post(`/api/apps/${id}/redeploy`, undefined, { "X-Deploy-Source": "cli" });
59
67
  spinner.stop();
60
- if (opts.detach || isJSONMode()) {
68
+ if (!opts.wait && (opts.detach || isJSONMode())) {
61
69
  if (isJSONMode()) {
62
70
  printJSON({ id, buildId: build?.id, status: "deploying" });
63
71
  }
@@ -67,7 +75,9 @@ export function registerRedeploy(program) {
67
75
  }
68
76
  return;
69
77
  }
70
- info("Redeploying...");
78
+ const jsonWait = opts.wait && isJSONMode();
79
+ if (!jsonWait)
80
+ info("Redeploying...");
71
81
  let buildId = build?.id ?? null;
72
82
  // Fallback for older servers that respond without a Build record.
73
83
  for (let i = 0; !buildId && i < 30; i++) {
@@ -84,13 +94,21 @@ export function registerRedeploy(program) {
84
94
  }
85
95
  catch { }
86
96
  }
97
+ let buildFailed = false;
98
+ let buildFailReason;
87
99
  if (buildId) {
88
100
  await streamSSE(`/api/builds/${buildId}/logs`, (event, data) => {
89
101
  if (event === "done" || event === "error") {
90
- if (event === "error")
91
- error(`Build failed: ${data}`);
102
+ if (event === "error") {
103
+ buildFailed = true;
104
+ buildFailReason = data;
105
+ if (!jsonWait)
106
+ error(`Build failed: ${data}`);
107
+ }
92
108
  return false;
93
109
  }
110
+ if (jsonWait)
111
+ return true; // suppress raw log lines in JSON mode
94
112
  try {
95
113
  const parsed = JSON.parse(data);
96
114
  const line = typeof parsed === "string" ? parsed : (parsed.line ?? data);
@@ -102,12 +120,46 @@ export function registerRedeploy(program) {
102
120
  return true;
103
121
  });
104
122
  }
123
+ if (opts.wait) {
124
+ if (buildFailed) {
125
+ if (isJSONMode()) {
126
+ printJSON({ id, buildId, ok: false, status: "build_failed", reason: buildFailReason });
127
+ }
128
+ else {
129
+ error(`Redeploy failed: ${buildFailReason ?? "build failed"}`);
130
+ }
131
+ process.exitCode = 1;
132
+ return;
133
+ }
134
+ // Build succeeded — deployStatus is already deploying/idle by this point
135
+ // (streamSSE only returns after the build's done/error frame), so trust
136
+ // status/deployStatus transitions directly rather than gating on
137
+ // restartedAt (redeploy never touches it — see waitForAppReady's doc).
138
+ const waitSpinner = isJSONMode() ? null : ora("Waiting for the new build to become ready...").start();
139
+ const result = await waitForAppReady(id, undefined, { timeoutMs });
140
+ waitSpinner?.stop();
141
+ if (isJSONMode()) {
142
+ printJSON({ id, buildId, ...result });
143
+ }
144
+ else if (result.ok) {
145
+ success(`Redeployed! ${result.domain ? chalk.cyan(`https://${result.domain}`) : ""}`);
146
+ }
147
+ else {
148
+ error(result.status === "timeout"
149
+ ? `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the deploy to become ready`
150
+ : `Redeploy failed (${result.status})`);
151
+ }
152
+ if (!result.ok)
153
+ process.exitCode = 1;
154
+ return;
155
+ }
105
156
  const app = await api.get(`/api/apps/${id}`);
106
157
  if (app.status === "running") {
107
158
  success(`Redeployed! ${app.domain ? chalk.cyan(`https://${app.domain}`) : ""}`);
108
159
  }
109
160
  else {
110
161
  error("Redeploy failed");
162
+ process.exitCode = 1;
111
163
  }
112
164
  });
113
165
  }
@@ -1 +1 @@
1
- {"version":3,"file":"redeploy.js","sourceRoot":"","sources":["../../src/commands/redeploy.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"redeploy.js","sourceRoot":"","sources":["../../src/commands/redeploy.ts"],"names":[],"mappings":"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"}
@@ -3,13 +3,16 @@ import ora from "ora";
3
3
  import * as p from "@clack/prompts";
4
4
  import { api, withScope } from "../lib/api.js";
5
5
  import { resolveProjectScope, resolveService } from "../lib/resolve.js";
6
- import { success, info, error, isJSONMode, printJSON, isTTY } from "../lib/format.js";
6
+ import { success, info, error, isJSONMode, printJSON, isTTY, fail } from "../lib/format.js";
7
+ import { waitForAppReady } from "../lib/wait-ready.js";
7
8
  export function registerRestart(program) {
8
9
  program
9
10
  .command("restart")
10
11
  .argument("[nameOrId]", "App name or ID to restart")
11
12
  .description("Restart an app")
12
13
  .option("--detach", "Run in background")
14
+ .option("--wait", "Wait for this specific restart attempt to become ready before exiting (works in --json mode too) — an old, already-running read from before the restart never counts as success")
15
+ .option("--timeout <seconds>", "Max time to wait with --wait, in seconds", "120")
13
16
  .option("-s, --service <name>", "App name or ID (alias for positional)")
14
17
  .option("-p, --project <id>", "Project name, slug, or ID")
15
18
  .action(async (nameOrId, opts) => {
@@ -51,9 +54,45 @@ export function registerRestart(program) {
51
54
  throw new Error("Multiple apps — provide an app name or ID, or run interactively");
52
55
  }
53
56
  }
57
+ const timeoutSeconds = parseInt(opts.timeout, 10);
58
+ if (!(timeoutSeconds > 0)) {
59
+ fail(`--timeout must be a positive number of seconds, got ${JSON.stringify(opts.timeout)}`, 1, "INVALID_ARGUMENT");
60
+ }
61
+ const timeoutMs = timeoutSeconds * 1000;
62
+ // Captured before triggering — waitForAppReady uses this baseline to tell
63
+ // this attempt apart from the app simply already being "running" from
64
+ // before the restart. Not fatal if it fails: undefined baseline still
65
+ // works (any non-null restartedAt reported afterwards counts as new).
66
+ let baselineRestartedAt = null;
67
+ if (opts.wait) {
68
+ try {
69
+ const before = await api.get(`/api/apps/${id}`);
70
+ baselineRestartedAt = before.restartedAt ?? null;
71
+ }
72
+ catch { }
73
+ }
54
74
  const spinner = ora("Starting restart...").start();
55
75
  await api.post(`/api/apps/${id}/restart`, undefined, { "X-Deploy-Source": "cli" });
56
76
  spinner.stop();
77
+ if (opts.wait) {
78
+ const waitSpinner = isJSONMode() ? null : ora("Waiting for the new attempt to become ready...").start();
79
+ const result = await waitForAppReady(id, baselineRestartedAt, { timeoutMs });
80
+ waitSpinner?.stop();
81
+ if (isJSONMode()) {
82
+ printJSON({ id, ...result });
83
+ }
84
+ else if (result.ok) {
85
+ success(`Restarted! ${result.domain ? chalk.cyan(`https://${result.domain}`) : ""}`);
86
+ }
87
+ else {
88
+ error(result.status === "timeout"
89
+ ? `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the restart to become ready`
90
+ : `Restart failed (${result.status})`);
91
+ }
92
+ if (!result.ok)
93
+ process.exitCode = 1;
94
+ return;
95
+ }
57
96
  if (opts.detach || isJSONMode()) {
58
97
  if (isJSONMode()) {
59
98
  printJSON({ id, status: "restarting" });
@@ -1 +1 @@
1
- {"version":3,"file":"restart.js","sourceRoot":"","sources":["../../src/commands/restart.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"restart.js","sourceRoot":"","sources":["../../src/commands/restart.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- export declare const CURRENT_VERSION = "0.3.93";
1
+ export declare const CURRENT_VERSION = "0.3.94";
2
2
  /**
3
3
  * True only when running as the Bun-compiled standalone binary. Under
4
4
  * npm/node, `process.execPath` is the *node* executable — self-update would
@@ -4,7 +4,7 @@ import { Readable } from "node:stream";
4
4
  import { join, dirname } from "node:path";
5
5
  import os from "node:os";
6
6
  import { spawn } from "node:child_process";
7
- export const CURRENT_VERSION = "0.3.93";
7
+ export const CURRENT_VERSION = "0.3.94";
8
8
  const RELEASES_API = "https://api.github.com/repos/lizard-build/lizard-cli/releases/latest";
9
9
  const RELEASE_BASE = "https://github.com/lizard-build/lizard-cli/releases/latest/download";
10
10
  /** Minimum gap between background update checks. */
@@ -0,0 +1,38 @@
1
+ export interface AppSnapshot {
2
+ status?: string;
3
+ deployStatus?: string | null;
4
+ restartedAt?: number | string | null;
5
+ domain?: string;
6
+ }
7
+ export interface WaitResult {
8
+ ok: boolean;
9
+ /** "running" on success; "failed" | "crashed" on a reported failure; "timeout" if the
10
+ * wait ran out before a terminal state for this attempt was reached. */
11
+ status: string;
12
+ /** Identifies the specific restart/deploy attempt that was waited on (the app's
13
+ * restartedAt value once it changed from the pre-trigger baseline) — an old,
14
+ * already-`running` read from before the trigger is never mistaken for success. */
15
+ attemptId: number | string | null;
16
+ domain?: string;
17
+ waitedMs: number;
18
+ }
19
+ /**
20
+ * Waits for a *specific* restart/deploy attempt to become ready, distinguishing
21
+ * it from the app simply already being `running` from before the attempt was
22
+ * triggered — the exact gap LIZARD-174 reports: `lizard restart --json` returned
23
+ * as soon as the restart was accepted, and the very next request could still hit
24
+ * the outgoing old process (200) or a mid-handover 502/503.
25
+ *
26
+ * `baselineRestartedAt` must be captured from GET /api/apps/:id *before* the
27
+ * restart call is made — readiness is only trusted once `restartedAt` has moved
28
+ * past that baseline, so an unrelated, still-`running` read from the old attempt
29
+ * never short-circuits the wait. Pass `undefined` (not `null`) for callers whose
30
+ * trigger doesn't touch restartedAt at all (e.g. redeploy, which is tracked by
31
+ * its own buildId instead) — this skips the restartedAt gate entirely and trusts
32
+ * deployStatus/status transitions directly, since deployStatus is flipped to
33
+ * building/deploying synchronously by those triggers.
34
+ */
35
+ export declare function waitForAppReady(id: string, baselineRestartedAt: number | string | null | undefined, opts?: {
36
+ timeoutMs?: number;
37
+ healthCheck?: boolean;
38
+ }): Promise<WaitResult>;
@@ -0,0 +1,101 @@
1
+ import { api } from "./api.js";
2
+ function sleep(ms) {
3
+ return new Promise((r) => setTimeout(r, ms));
4
+ }
5
+ /**
6
+ * Two consecutive successful checks, spaced apart, against the app's own domain.
7
+ * A single 200 isn't enough during a restart handover — LIZARD-174's reproduction
8
+ * found one healthy response immediately followed by a 502 while the old process
9
+ * was still finishing its shutdown. Not run for apps with no domain (workers) —
10
+ * platform-reported readiness is the only signal available for those.
11
+ */
12
+ async function debounceHealthCheck(domain) {
13
+ for (let i = 0; i < 2; i++) {
14
+ try {
15
+ const res = await fetch(`https://${domain}/`, { signal: AbortSignal.timeout(5000) });
16
+ if (res.status >= 500)
17
+ return false;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ if (i === 0)
23
+ await sleep(1500);
24
+ }
25
+ return true;
26
+ }
27
+ /**
28
+ * Waits for a *specific* restart/deploy attempt to become ready, distinguishing
29
+ * it from the app simply already being `running` from before the attempt was
30
+ * triggered — the exact gap LIZARD-174 reports: `lizard restart --json` returned
31
+ * as soon as the restart was accepted, and the very next request could still hit
32
+ * the outgoing old process (200) or a mid-handover 502/503.
33
+ *
34
+ * `baselineRestartedAt` must be captured from GET /api/apps/:id *before* the
35
+ * restart call is made — readiness is only trusted once `restartedAt` has moved
36
+ * past that baseline, so an unrelated, still-`running` read from the old attempt
37
+ * never short-circuits the wait. Pass `undefined` (not `null`) for callers whose
38
+ * trigger doesn't touch restartedAt at all (e.g. redeploy, which is tracked by
39
+ * its own buildId instead) — this skips the restartedAt gate entirely and trusts
40
+ * deployStatus/status transitions directly, since deployStatus is flipped to
41
+ * building/deploying synchronously by those triggers.
42
+ */
43
+ export async function waitForAppReady(id, baselineRestartedAt, opts = {}) {
44
+ const timeoutMs = opts.timeoutMs ?? 120_000;
45
+ const healthCheck = opts.healthCheck ?? true;
46
+ const start = Date.now();
47
+ let attemptId = null;
48
+ let sawNewAttempt = baselineRestartedAt === undefined;
49
+ while (Date.now() - start < timeoutMs) {
50
+ let app;
51
+ try {
52
+ app = await api.get(`/api/apps/${id}`);
53
+ }
54
+ catch {
55
+ await sleep(1500);
56
+ continue;
57
+ }
58
+ if (!sawNewAttempt) {
59
+ const restartedAt = app.restartedAt ?? null;
60
+ if (restartedAt !== null && restartedAt !== baselineRestartedAt) {
61
+ sawNewAttempt = true;
62
+ attemptId = restartedAt;
63
+ }
64
+ else {
65
+ // The platform hasn't picked up this attempt yet — deployStatus/status
66
+ // here still describe whatever was true *before* it was triggered.
67
+ // Reporting "running" at this point would be exactly the false
68
+ // positive this function exists to prevent.
69
+ await sleep(1000);
70
+ continue;
71
+ }
72
+ }
73
+ else if (app.restartedAt != null && app.restartedAt !== attemptId) {
74
+ // A newer attempt interleaved (e.g. someone else restarted it again)
75
+ // — track that one instead; it's the one that will actually land.
76
+ attemptId = app.restartedAt;
77
+ }
78
+ if (app.deployStatus === "restarting" || app.deployStatus === "building" || app.deployStatus === "deploying") {
79
+ await sleep(1000);
80
+ continue;
81
+ }
82
+ if (app.status === "failed" || app.status === "crashed") {
83
+ return { ok: false, status: app.status, attemptId, domain: app.domain, waitedMs: Date.now() - start };
84
+ }
85
+ if (app.status === "running") {
86
+ if (healthCheck && app.domain) {
87
+ const healthy = await debounceHealthCheck(app.domain);
88
+ if (!healthy) {
89
+ // Platform says running, but the URL isn't consistently healthy yet
90
+ // (mid-handover) — keep polling rather than declaring success early.
91
+ await sleep(1500);
92
+ continue;
93
+ }
94
+ }
95
+ return { ok: true, status: "running", attemptId, domain: app.domain, waitedMs: Date.now() - start };
96
+ }
97
+ await sleep(1000);
98
+ }
99
+ return { ok: false, status: "timeout", attemptId, waitedMs: Date.now() - start };
100
+ }
101
+ //# sourceMappingURL=wait-ready.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wait-ready.js","sourceRoot":"","sources":["../../src/lib/wait-ready.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lizard-build/cli",
3
- "version": "0.3.93",
3
+ "version": "0.3.94",
4
4
  "description": "Lizard CLI — deploy apps, databases and sandboxes to Lizard (lizard.build) from your terminal or your coding agent. Detects Go, Node, Python, Rust, Ruby, PHP, Java and static sites; adds Managed Postgres, Managed Redis and Managed Object Storage in one command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,7 +4,8 @@ import * as p from "@clack/prompts";
4
4
  import { Command } from "commander";
5
5
  import { api, streamSSE, withScope } from "../lib/api.js";
6
6
  import { resolveProjectScope, resolveService } from "../lib/resolve.js";
7
- import { success, info, error, isJSONMode, printJSON, isTTY } from "../lib/format.js";
7
+ import { success, info, error, isJSONMode, printJSON, isTTY, fail } from "../lib/format.js";
8
+ import { waitForAppReady } from "../lib/wait-ready.js";
8
9
 
9
10
  export function registerRedeploy(program: Command) {
10
11
  program
@@ -12,6 +13,11 @@ export function registerRedeploy(program: Command) {
12
13
  .argument("[nameOrId]", "App name or ID to redeploy")
13
14
  .description("Trigger a fresh build (latest commit / last upload) with current vars")
14
15
  .option("--detach", "Run in background")
16
+ .option(
17
+ "--wait",
18
+ "Wait for the new build to deploy and become ready before exiting (works in --json mode too)",
19
+ )
20
+ .option("--timeout <seconds>", "Max time to wait with --wait, in seconds", "120")
15
21
  .option("-s, --service <name>", "App name or ID (alias for positional)")
16
22
  .option("-p, --project <id>", "Project name, slug, or ID")
17
23
  .action(async (nameOrId: string | undefined, opts) => {
@@ -55,6 +61,12 @@ export function registerRedeploy(program: Command) {
55
61
  }
56
62
  }
57
63
 
64
+ const timeoutSeconds = parseInt(opts.timeout, 10);
65
+ if (!(timeoutSeconds > 0)) {
66
+ fail(`--timeout must be a positive number of seconds, got ${JSON.stringify(opts.timeout)}`, 1, "INVALID_ARGUMENT");
67
+ }
68
+ const timeoutMs = timeoutSeconds * 1000;
69
+
58
70
  const spinner = ora("Starting redeploy...").start();
59
71
  // The endpoint pre-creates and returns the Build record — use its id
60
72
  // instead of polling builds[0], which races against a previous build.
@@ -65,7 +77,7 @@ export function registerRedeploy(program: Command) {
65
77
  );
66
78
  spinner.stop();
67
79
 
68
- if (opts.detach || isJSONMode()) {
80
+ if (!opts.wait && (opts.detach || isJSONMode())) {
69
81
  if (isJSONMode()) {
70
82
  printJSON({ id, buildId: build?.id, status: "deploying" });
71
83
  } else {
@@ -75,7 +87,8 @@ export function registerRedeploy(program: Command) {
75
87
  return;
76
88
  }
77
89
 
78
- info("Redeploying...");
90
+ const jsonWait = opts.wait && isJSONMode();
91
+ if (!jsonWait) info("Redeploying...");
79
92
 
80
93
  let buildId: string | null = build?.id ?? null;
81
94
  // Fallback for older servers that respond without a Build record.
@@ -95,12 +108,19 @@ export function registerRedeploy(program: Command) {
95
108
  } catch {}
96
109
  }
97
110
 
111
+ let buildFailed = false;
112
+ let buildFailReason: string | undefined;
98
113
  if (buildId) {
99
114
  await streamSSE(`/api/builds/${buildId}/logs`, (event, data) => {
100
115
  if (event === "done" || event === "error") {
101
- if (event === "error") error(`Build failed: ${data}`);
116
+ if (event === "error") {
117
+ buildFailed = true;
118
+ buildFailReason = data;
119
+ if (!jsonWait) error(`Build failed: ${data}`);
120
+ }
102
121
  return false;
103
122
  }
123
+ if (jsonWait) return true; // suppress raw log lines in JSON mode
104
124
  try {
105
125
  const parsed = JSON.parse(data);
106
126
  const line =
@@ -113,11 +133,45 @@ export function registerRedeploy(program: Command) {
113
133
  });
114
134
  }
115
135
 
136
+ if (opts.wait) {
137
+ if (buildFailed) {
138
+ if (isJSONMode()) {
139
+ printJSON({ id, buildId, ok: false, status: "build_failed", reason: buildFailReason });
140
+ } else {
141
+ error(`Redeploy failed: ${buildFailReason ?? "build failed"}`);
142
+ }
143
+ process.exitCode = 1;
144
+ return;
145
+ }
146
+ // Build succeeded — deployStatus is already deploying/idle by this point
147
+ // (streamSSE only returns after the build's done/error frame), so trust
148
+ // status/deployStatus transitions directly rather than gating on
149
+ // restartedAt (redeploy never touches it — see waitForAppReady's doc).
150
+ const waitSpinner = isJSONMode() ? null : ora("Waiting for the new build to become ready...").start();
151
+ const result = await waitForAppReady(id!, undefined, { timeoutMs });
152
+ waitSpinner?.stop();
153
+
154
+ if (isJSONMode()) {
155
+ printJSON({ id, buildId, ...result });
156
+ } else if (result.ok) {
157
+ success(`Redeployed! ${result.domain ? chalk.cyan(`https://${result.domain}`) : ""}`);
158
+ } else {
159
+ error(
160
+ result.status === "timeout"
161
+ ? `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the deploy to become ready`
162
+ : `Redeploy failed (${result.status})`,
163
+ );
164
+ }
165
+ if (!result.ok) process.exitCode = 1;
166
+ return;
167
+ }
168
+
116
169
  const app = await api.get<{ status: string; domain?: string }>(`/api/apps/${id}`);
117
170
  if (app.status === "running") {
118
171
  success(`Redeployed! ${app.domain ? chalk.cyan(`https://${app.domain}`) : ""}`);
119
172
  } else {
120
173
  error("Redeploy failed");
174
+ process.exitCode = 1;
121
175
  }
122
176
  });
123
177
  }
@@ -4,7 +4,8 @@ import * as p from "@clack/prompts";
4
4
  import { Command } from "commander";
5
5
  import { api, withScope } from "../lib/api.js";
6
6
  import { resolveProjectScope, resolveService } from "../lib/resolve.js";
7
- import { success, info, error, isJSONMode, printJSON, isTTY } from "../lib/format.js";
7
+ import { success, info, error, isJSONMode, printJSON, isTTY, fail } from "../lib/format.js";
8
+ import { waitForAppReady, AppSnapshot } from "../lib/wait-ready.js";
8
9
 
9
10
  export function registerRestart(program: Command) {
10
11
  program
@@ -12,6 +13,11 @@ export function registerRestart(program: Command) {
12
13
  .argument("[nameOrId]", "App name or ID to restart")
13
14
  .description("Restart an app")
14
15
  .option("--detach", "Run in background")
16
+ .option(
17
+ "--wait",
18
+ "Wait for this specific restart attempt to become ready before exiting (works in --json mode too) — an old, already-running read from before the restart never counts as success",
19
+ )
20
+ .option("--timeout <seconds>", "Max time to wait with --wait, in seconds", "120")
15
21
  .option("-s, --service <name>", "App name or ID (alias for positional)")
16
22
  .option("-p, --project <id>", "Project name, slug, or ID")
17
23
  .action(async (nameOrId: string | undefined, opts) => {
@@ -51,10 +57,48 @@ export function registerRestart(program: Command) {
51
57
  }
52
58
  }
53
59
 
60
+ const timeoutSeconds = parseInt(opts.timeout, 10);
61
+ if (!(timeoutSeconds > 0)) {
62
+ fail(`--timeout must be a positive number of seconds, got ${JSON.stringify(opts.timeout)}`, 1, "INVALID_ARGUMENT");
63
+ }
64
+ const timeoutMs = timeoutSeconds * 1000;
65
+
66
+ // Captured before triggering — waitForAppReady uses this baseline to tell
67
+ // this attempt apart from the app simply already being "running" from
68
+ // before the restart. Not fatal if it fails: undefined baseline still
69
+ // works (any non-null restartedAt reported afterwards counts as new).
70
+ let baselineRestartedAt: AppSnapshot["restartedAt"] = null;
71
+ if (opts.wait) {
72
+ try {
73
+ const before = await api.get<AppSnapshot>(`/api/apps/${id}`);
74
+ baselineRestartedAt = before.restartedAt ?? null;
75
+ } catch {}
76
+ }
77
+
54
78
  const spinner = ora("Starting restart...").start();
55
79
  await api.post(`/api/apps/${id}/restart`, undefined, { "X-Deploy-Source": "cli" });
56
80
  spinner.stop();
57
81
 
82
+ if (opts.wait) {
83
+ const waitSpinner = isJSONMode() ? null : ora("Waiting for the new attempt to become ready...").start();
84
+ const result = await waitForAppReady(id!, baselineRestartedAt, { timeoutMs });
85
+ waitSpinner?.stop();
86
+
87
+ if (isJSONMode()) {
88
+ printJSON({ id, ...result });
89
+ } else if (result.ok) {
90
+ success(`Restarted! ${result.domain ? chalk.cyan(`https://${result.domain}`) : ""}`);
91
+ } else {
92
+ error(
93
+ result.status === "timeout"
94
+ ? `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the restart to become ready`
95
+ : `Restart failed (${result.status})`,
96
+ );
97
+ }
98
+ if (!result.ok) process.exitCode = 1;
99
+ return;
100
+ }
101
+
58
102
  if (opts.detach || isJSONMode()) {
59
103
  if (isJSONMode()) {
60
104
  printJSON({ id, status: "restarting" });
@@ -5,7 +5,7 @@ import { join, dirname } from "node:path";
5
5
  import os from "node:os";
6
6
  import { spawn } from "node:child_process";
7
7
 
8
- export const CURRENT_VERSION = "0.3.93";
8
+ export const CURRENT_VERSION = "0.3.94";
9
9
  const RELEASES_API = "https://api.github.com/repos/lizard-build/lizard-cli/releases/latest";
10
10
  const RELEASE_BASE = "https://github.com/lizard-build/lizard-cli/releases/latest/download";
11
11
 
@@ -0,0 +1,127 @@
1
+ import { api } from "./api.js";
2
+
3
+ export interface AppSnapshot {
4
+ status?: string;
5
+ deployStatus?: string | null;
6
+ restartedAt?: number | string | null;
7
+ domain?: string;
8
+ }
9
+
10
+ export interface WaitResult {
11
+ ok: boolean;
12
+ /** "running" on success; "failed" | "crashed" on a reported failure; "timeout" if the
13
+ * wait ran out before a terminal state for this attempt was reached. */
14
+ status: string;
15
+ /** Identifies the specific restart/deploy attempt that was waited on (the app's
16
+ * restartedAt value once it changed from the pre-trigger baseline) — an old,
17
+ * already-`running` read from before the trigger is never mistaken for success. */
18
+ attemptId: number | string | null;
19
+ domain?: string;
20
+ waitedMs: number;
21
+ }
22
+
23
+ function sleep(ms: number): Promise<void> {
24
+ return new Promise((r) => setTimeout(r, ms));
25
+ }
26
+
27
+ /**
28
+ * Two consecutive successful checks, spaced apart, against the app's own domain.
29
+ * A single 200 isn't enough during a restart handover — LIZARD-174's reproduction
30
+ * found one healthy response immediately followed by a 502 while the old process
31
+ * was still finishing its shutdown. Not run for apps with no domain (workers) —
32
+ * platform-reported readiness is the only signal available for those.
33
+ */
34
+ async function debounceHealthCheck(domain: string): Promise<boolean> {
35
+ for (let i = 0; i < 2; i++) {
36
+ try {
37
+ const res = await fetch(`https://${domain}/`, { signal: AbortSignal.timeout(5000) });
38
+ if (res.status >= 500) return false;
39
+ } catch {
40
+ return false;
41
+ }
42
+ if (i === 0) await sleep(1500);
43
+ }
44
+ return true;
45
+ }
46
+
47
+ /**
48
+ * Waits for a *specific* restart/deploy attempt to become ready, distinguishing
49
+ * it from the app simply already being `running` from before the attempt was
50
+ * triggered — the exact gap LIZARD-174 reports: `lizard restart --json` returned
51
+ * as soon as the restart was accepted, and the very next request could still hit
52
+ * the outgoing old process (200) or a mid-handover 502/503.
53
+ *
54
+ * `baselineRestartedAt` must be captured from GET /api/apps/:id *before* the
55
+ * restart call is made — readiness is only trusted once `restartedAt` has moved
56
+ * past that baseline, so an unrelated, still-`running` read from the old attempt
57
+ * never short-circuits the wait. Pass `undefined` (not `null`) for callers whose
58
+ * trigger doesn't touch restartedAt at all (e.g. redeploy, which is tracked by
59
+ * its own buildId instead) — this skips the restartedAt gate entirely and trusts
60
+ * deployStatus/status transitions directly, since deployStatus is flipped to
61
+ * building/deploying synchronously by those triggers.
62
+ */
63
+ export async function waitForAppReady(
64
+ id: string,
65
+ baselineRestartedAt: number | string | null | undefined,
66
+ opts: { timeoutMs?: number; healthCheck?: boolean } = {},
67
+ ): Promise<WaitResult> {
68
+ const timeoutMs = opts.timeoutMs ?? 120_000;
69
+ const healthCheck = opts.healthCheck ?? true;
70
+ const start = Date.now();
71
+
72
+ let attemptId: number | string | null = null;
73
+ let sawNewAttempt = baselineRestartedAt === undefined;
74
+
75
+ while (Date.now() - start < timeoutMs) {
76
+ let app: AppSnapshot;
77
+ try {
78
+ app = await api.get<AppSnapshot>(`/api/apps/${id}`);
79
+ } catch {
80
+ await sleep(1500);
81
+ continue;
82
+ }
83
+
84
+ if (!sawNewAttempt) {
85
+ const restartedAt = app.restartedAt ?? null;
86
+ if (restartedAt !== null && restartedAt !== baselineRestartedAt) {
87
+ sawNewAttempt = true;
88
+ attemptId = restartedAt;
89
+ } else {
90
+ // The platform hasn't picked up this attempt yet — deployStatus/status
91
+ // here still describe whatever was true *before* it was triggered.
92
+ // Reporting "running" at this point would be exactly the false
93
+ // positive this function exists to prevent.
94
+ await sleep(1000);
95
+ continue;
96
+ }
97
+ } else if (app.restartedAt != null && app.restartedAt !== attemptId) {
98
+ // A newer attempt interleaved (e.g. someone else restarted it again)
99
+ // — track that one instead; it's the one that will actually land.
100
+ attemptId = app.restartedAt;
101
+ }
102
+
103
+ if (app.deployStatus === "restarting" || app.deployStatus === "building" || app.deployStatus === "deploying") {
104
+ await sleep(1000);
105
+ continue;
106
+ }
107
+ if (app.status === "failed" || app.status === "crashed") {
108
+ return { ok: false, status: app.status, attemptId, domain: app.domain, waitedMs: Date.now() - start };
109
+ }
110
+ if (app.status === "running") {
111
+ if (healthCheck && app.domain) {
112
+ const healthy = await debounceHealthCheck(app.domain);
113
+ if (!healthy) {
114
+ // Platform says running, but the URL isn't consistently healthy yet
115
+ // (mid-handover) — keep polling rather than declaring success early.
116
+ await sleep(1500);
117
+ continue;
118
+ }
119
+ }
120
+ return { ok: true, status: "running", attemptId, domain: app.domain, waitedMs: Date.now() - start };
121
+ }
122
+
123
+ await sleep(1000);
124
+ }
125
+
126
+ return { ok: false, status: "timeout", attemptId, waitedMs: Date.now() - start };
127
+ }
@@ -0,0 +1,133 @@
1
+ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import * as http from "node:http";
3
+ import type { AddressInfo } from "node:net";
4
+ import { setBaseURL, getBaseURL } from "../../src/lib/api.js";
5
+ import { waitForAppReady } from "../../src/lib/wait-ready.js";
6
+
7
+ type Handler = (req: http.IncomingMessage, res: http.ServerResponse) => void;
8
+
9
+ let server: http.Server;
10
+ let handle: Handler;
11
+ const originalBaseURL = getBaseURL();
12
+
13
+ beforeEach(async () => {
14
+ server = http.createServer((req, res) => handle(req, res));
15
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
16
+ const { port } = server.address() as AddressInfo;
17
+ setBaseURL(`http://127.0.0.1:${port}`);
18
+ });
19
+
20
+ afterEach(async () => {
21
+ setBaseURL(originalBaseURL);
22
+ await new Promise<void>((resolve) => server.close(() => resolve()));
23
+ });
24
+
25
+ function json(res: http.ServerResponse, body: unknown) {
26
+ res.writeHead(200, { "Content-Type": "application/json" });
27
+ res.end(JSON.stringify(body));
28
+ }
29
+
30
+ // A queue of app snapshots, one served per GET /api/apps/:id — the last entry
31
+ // repeats once the queue is drained, so a test only has to describe the
32
+ // interesting transitions.
33
+ function serveSequence(snapshots: any[]) {
34
+ let i = 0;
35
+ handle = (_req, res) => {
36
+ const snap = snapshots[Math.min(i, snapshots.length - 1)];
37
+ i++;
38
+ json(res, snap);
39
+ };
40
+ }
41
+
42
+ describe("waitForAppReady", () => {
43
+ test("LIZARD-174: a stale already-running read from before the restart is not mistaken for success", async () => {
44
+ // First poll still reflects the OLD attempt (status running, restartedAt
45
+ // unchanged from baseline) — this is exactly the false positive the
46
+ // baseline-diff exists to prevent. Only once restartedAt actually moves
47
+ // and settles back to running should the wait resolve.
48
+ serveSequence([
49
+ { status: "running", deployStatus: "idle", restartedAt: 1000 }, // stale — must be ignored
50
+ { status: "running", deployStatus: "idle", restartedAt: 1000 }, // still stale
51
+ { status: "running", deployStatus: "restarting", restartedAt: 2000 }, // new attempt begins
52
+ { status: "running", deployStatus: "restarting", restartedAt: 2000 },
53
+ { status: "running", deployStatus: "idle", restartedAt: 2000 }, // new attempt ready
54
+ ]);
55
+
56
+ const result = await waitForAppReady("app1", 1000, { timeoutMs: 15_000, healthCheck: false });
57
+ expect(result.ok).toBe(true);
58
+ expect(result.status).toBe("running");
59
+ expect(result.attemptId).toBe(2000);
60
+ });
61
+
62
+ test("reports failure for the new attempt (Pending -> CrashLoopBackOff)", async () => {
63
+ serveSequence([
64
+ { status: "running", deployStatus: "idle", restartedAt: 1000 },
65
+ { status: "crashed", deployStatus: "restarting", restartedAt: 2000 },
66
+ { status: "crashed", deployStatus: "idle", restartedAt: 2000 },
67
+ ]);
68
+
69
+ const result = await waitForAppReady("app1", 1000, { timeoutMs: 15_000, healthCheck: false });
70
+ expect(result.ok).toBe(false);
71
+ expect(result.status).toBe("crashed");
72
+ expect(result.attemptId).toBe(2000);
73
+ });
74
+
75
+ test("times out with a non-ok result if the new attempt never settles", async () => {
76
+ serveSequence([{ status: "running", deployStatus: "restarting", restartedAt: 2000 }]);
77
+
78
+ const result = await waitForAppReady("app1", 1000, { timeoutMs: 2500, healthCheck: false });
79
+ expect(result.ok).toBe(false);
80
+ expect(result.status).toBe("timeout");
81
+ });
82
+
83
+ test("late start: keeps waiting through several restarting polls before success", async () => {
84
+ serveSequence([
85
+ { status: "running", deployStatus: "restarting", restartedAt: 2000 },
86
+ { status: "running", deployStatus: "restarting", restartedAt: 2000 },
87
+ { status: "running", deployStatus: "restarting", restartedAt: 2000 },
88
+ { status: "running", deployStatus: "restarting", restartedAt: 2000 },
89
+ { status: "running", deployStatus: "idle", restartedAt: 2000 },
90
+ ]);
91
+
92
+ const result = await waitForAppReady("app1", null, { timeoutMs: 15_000, healthCheck: false });
93
+ expect(result.ok).toBe(true);
94
+ });
95
+
96
+ test("undefined baseline (redeploy) trusts deployStatus/status directly, no restartedAt gate", async () => {
97
+ // redeploy never touches restartedAt — it stays null throughout, and that
98
+ // must not block success the way a real restart's stale baseline would.
99
+ serveSequence([
100
+ { status: "running", deployStatus: "deploying", restartedAt: null },
101
+ { status: "running", deployStatus: "idle", restartedAt: null },
102
+ ]);
103
+
104
+ const result = await waitForAppReady("app1", undefined, { timeoutMs: 15_000, healthCheck: false });
105
+ expect(result.ok).toBe(true);
106
+ });
107
+
108
+ test("health check debounce: a single 200 mid-handover does not count as ready", async () => {
109
+ serveSequence([{ status: "running", deployStatus: "idle", restartedAt: 2000, domain: "app.example.test" }]);
110
+
111
+ const realFetch = globalThis.fetch;
112
+ let call = 0;
113
+ vi.stubGlobal("fetch", (url: string, opts?: any) => {
114
+ if (typeof url === "string" && url.includes("app.example.test")) {
115
+ call++;
116
+ // First health check: healthy. Second (1.5s later, per the debounce):
117
+ // 502 — the exact LIZARD-174 reproduction (old process still finishing
118
+ // shutdown). Must not be reported as ready on the strength of check #1 alone.
119
+ return Promise.resolve(new Response(null, { status: call === 1 ? 200 : 502 }));
120
+ }
121
+ return realFetch(url, opts);
122
+ });
123
+
124
+ try {
125
+ const result = await waitForAppReady("app1", 1000, { timeoutMs: 4000, healthCheck: true });
126
+ expect(result.ok).toBe(false);
127
+ expect(result.status).toBe("timeout");
128
+ expect(call).toBeGreaterThanOrEqual(2);
129
+ } finally {
130
+ vi.unstubAllGlobals();
131
+ }
132
+ });
133
+ });