@lizard-build/cli 0.3.93 → 0.3.95

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.95";
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.95";
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,39 @@
1
+ export interface AppSnapshot {
2
+ status?: string;
3
+ deployStatus?: string | null;
4
+ restartedAt?: number | string | null;
5
+ domain?: string;
6
+ containerPort?: number;
7
+ }
8
+ export interface WaitResult {
9
+ ok: boolean;
10
+ /** "running" on success; "failed" | "crashed" on a reported failure; "timeout" if the
11
+ * wait ran out before a terminal state for this attempt was reached. */
12
+ status: string;
13
+ /** Identifies the specific restart/deploy attempt that was waited on (the app's
14
+ * restartedAt value once it changed from the pre-trigger baseline) — an old,
15
+ * already-`running` read from before the trigger is never mistaken for success. */
16
+ attemptId: number | string | null;
17
+ domain?: string;
18
+ waitedMs: number;
19
+ }
20
+ /**
21
+ * Waits for a *specific* restart/deploy attempt to become ready, distinguishing
22
+ * it from the app simply already being `running` from before the attempt was
23
+ * triggered — the exact gap LIZARD-174 reports: `lizard restart --json` returned
24
+ * as soon as the restart was accepted, and the very next request could still hit
25
+ * the outgoing old process (200) or a mid-handover 502/503.
26
+ *
27
+ * `baselineRestartedAt` must be captured from GET /api/apps/:id *before* the
28
+ * restart call is made — readiness is only trusted once `restartedAt` has moved
29
+ * past that baseline, so an unrelated, still-`running` read from the old attempt
30
+ * never short-circuits the wait. Pass `undefined` (not `null`) for callers whose
31
+ * trigger doesn't touch restartedAt at all (e.g. redeploy, which is tracked by
32
+ * its own buildId instead) — this skips the restartedAt gate entirely and trusts
33
+ * deployStatus/status transitions directly, since deployStatus is flipped to
34
+ * building/deploying synchronously by those triggers.
35
+ */
36
+ export declare function waitForAppReady(id: string, baselineRestartedAt: number | string | null | undefined, opts?: {
37
+ timeoutMs?: number;
38
+ healthCheck?: boolean;
39
+ }): Promise<WaitResult>;
@@ -0,0 +1,109 @@
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 containerPort=0 workers —
10
+ * every app gets a generated domain regardless of whether it serves HTTP, so
11
+ * the caller gates this on containerPort, not domain presence (LIZARD-174).
12
+ * Platform-reported readiness is the only signal available for those.
13
+ */
14
+ async function debounceHealthCheck(domain) {
15
+ for (let i = 0; i < 2; i++) {
16
+ try {
17
+ const res = await fetch(`https://${domain}/`, { signal: AbortSignal.timeout(5000) });
18
+ if (res.status >= 500)
19
+ return false;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ if (i === 0)
25
+ await sleep(1500);
26
+ }
27
+ return true;
28
+ }
29
+ /**
30
+ * Waits for a *specific* restart/deploy attempt to become ready, distinguishing
31
+ * it from the app simply already being `running` from before the attempt was
32
+ * triggered — the exact gap LIZARD-174 reports: `lizard restart --json` returned
33
+ * as soon as the restart was accepted, and the very next request could still hit
34
+ * the outgoing old process (200) or a mid-handover 502/503.
35
+ *
36
+ * `baselineRestartedAt` must be captured from GET /api/apps/:id *before* the
37
+ * restart call is made — readiness is only trusted once `restartedAt` has moved
38
+ * past that baseline, so an unrelated, still-`running` read from the old attempt
39
+ * never short-circuits the wait. Pass `undefined` (not `null`) for callers whose
40
+ * trigger doesn't touch restartedAt at all (e.g. redeploy, which is tracked by
41
+ * its own buildId instead) — this skips the restartedAt gate entirely and trusts
42
+ * deployStatus/status transitions directly, since deployStatus is flipped to
43
+ * building/deploying synchronously by those triggers.
44
+ */
45
+ export async function waitForAppReady(id, baselineRestartedAt, opts = {}) {
46
+ const timeoutMs = opts.timeoutMs ?? 120_000;
47
+ const healthCheck = opts.healthCheck ?? true;
48
+ const start = Date.now();
49
+ let attemptId = null;
50
+ let sawNewAttempt = baselineRestartedAt === undefined;
51
+ while (Date.now() - start < timeoutMs) {
52
+ let app;
53
+ try {
54
+ app = await api.get(`/api/apps/${id}`);
55
+ }
56
+ catch {
57
+ await sleep(1500);
58
+ continue;
59
+ }
60
+ if (!sawNewAttempt) {
61
+ const restartedAt = app.restartedAt ?? null;
62
+ if (restartedAt !== null && restartedAt !== baselineRestartedAt) {
63
+ sawNewAttempt = true;
64
+ attemptId = restartedAt;
65
+ }
66
+ else {
67
+ // The platform hasn't picked up this attempt yet — deployStatus/status
68
+ // here still describe whatever was true *before* it was triggered.
69
+ // Reporting "running" at this point would be exactly the false
70
+ // positive this function exists to prevent.
71
+ await sleep(1000);
72
+ continue;
73
+ }
74
+ }
75
+ else if (app.restartedAt != null && app.restartedAt !== attemptId) {
76
+ // A newer attempt interleaved (e.g. someone else restarted it again)
77
+ // — track that one instead; it's the one that will actually land.
78
+ attemptId = app.restartedAt;
79
+ }
80
+ if (app.deployStatus === "restarting" || app.deployStatus === "building" || app.deployStatus === "deploying") {
81
+ await sleep(1000);
82
+ continue;
83
+ }
84
+ if (app.status === "failed" || app.status === "crashed") {
85
+ return { ok: false, status: app.status, attemptId, domain: app.domain, waitedMs: Date.now() - start };
86
+ }
87
+ if (app.status === "running") {
88
+ // Every app gets a generated .onlizard.com domain regardless of whether
89
+ // it actually listens for HTTP — a containerPort=0 worker has one too.
90
+ // LIZARD-174: gating on app.domain alone made restart --wait spin a
91
+ // worker until the timeout, health-checking a URL nothing ever answers,
92
+ // even though the platform had already reported it Running/Ready.
93
+ const hasHttpPort = app.containerPort !== 0;
94
+ if (healthCheck && hasHttpPort && app.domain) {
95
+ const healthy = await debounceHealthCheck(app.domain);
96
+ if (!healthy) {
97
+ // Platform says running, but the URL isn't consistently healthy yet
98
+ // (mid-handover) — keep polling rather than declaring success early.
99
+ await sleep(1500);
100
+ continue;
101
+ }
102
+ }
103
+ return { ok: true, status: "running", attemptId, domain: app.domain, waitedMs: Date.now() - start };
104
+ }
105
+ await sleep(1000);
106
+ }
107
+ return { ok: false, status: "timeout", attemptId, waitedMs: Date.now() - start };
108
+ }
109
+ //# 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.95",
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.95";
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,136 @@
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
+ containerPort?: number;
9
+ }
10
+
11
+ export interface WaitResult {
12
+ ok: boolean;
13
+ /** "running" on success; "failed" | "crashed" on a reported failure; "timeout" if the
14
+ * wait ran out before a terminal state for this attempt was reached. */
15
+ status: string;
16
+ /** Identifies the specific restart/deploy attempt that was waited on (the app's
17
+ * restartedAt value once it changed from the pre-trigger baseline) — an old,
18
+ * already-`running` read from before the trigger is never mistaken for success. */
19
+ attemptId: number | string | null;
20
+ domain?: string;
21
+ waitedMs: number;
22
+ }
23
+
24
+ function sleep(ms: number): Promise<void> {
25
+ return new Promise((r) => setTimeout(r, ms));
26
+ }
27
+
28
+ /**
29
+ * Two consecutive successful checks, spaced apart, against the app's own domain.
30
+ * A single 200 isn't enough during a restart handover — LIZARD-174's reproduction
31
+ * found one healthy response immediately followed by a 502 while the old process
32
+ * was still finishing its shutdown. Not run for containerPort=0 workers —
33
+ * every app gets a generated domain regardless of whether it serves HTTP, so
34
+ * the caller gates this on containerPort, not domain presence (LIZARD-174).
35
+ * Platform-reported readiness is the only signal available for those.
36
+ */
37
+ async function debounceHealthCheck(domain: string): Promise<boolean> {
38
+ for (let i = 0; i < 2; i++) {
39
+ try {
40
+ const res = await fetch(`https://${domain}/`, { signal: AbortSignal.timeout(5000) });
41
+ if (res.status >= 500) return false;
42
+ } catch {
43
+ return false;
44
+ }
45
+ if (i === 0) await sleep(1500);
46
+ }
47
+ return true;
48
+ }
49
+
50
+ /**
51
+ * Waits for a *specific* restart/deploy attempt to become ready, distinguishing
52
+ * it from the app simply already being `running` from before the attempt was
53
+ * triggered — the exact gap LIZARD-174 reports: `lizard restart --json` returned
54
+ * as soon as the restart was accepted, and the very next request could still hit
55
+ * the outgoing old process (200) or a mid-handover 502/503.
56
+ *
57
+ * `baselineRestartedAt` must be captured from GET /api/apps/:id *before* the
58
+ * restart call is made — readiness is only trusted once `restartedAt` has moved
59
+ * past that baseline, so an unrelated, still-`running` read from the old attempt
60
+ * never short-circuits the wait. Pass `undefined` (not `null`) for callers whose
61
+ * trigger doesn't touch restartedAt at all (e.g. redeploy, which is tracked by
62
+ * its own buildId instead) — this skips the restartedAt gate entirely and trusts
63
+ * deployStatus/status transitions directly, since deployStatus is flipped to
64
+ * building/deploying synchronously by those triggers.
65
+ */
66
+ export async function waitForAppReady(
67
+ id: string,
68
+ baselineRestartedAt: number | string | null | undefined,
69
+ opts: { timeoutMs?: number; healthCheck?: boolean } = {},
70
+ ): Promise<WaitResult> {
71
+ const timeoutMs = opts.timeoutMs ?? 120_000;
72
+ const healthCheck = opts.healthCheck ?? true;
73
+ const start = Date.now();
74
+
75
+ let attemptId: number | string | null = null;
76
+ let sawNewAttempt = baselineRestartedAt === undefined;
77
+
78
+ while (Date.now() - start < timeoutMs) {
79
+ let app: AppSnapshot;
80
+ try {
81
+ app = await api.get<AppSnapshot>(`/api/apps/${id}`);
82
+ } catch {
83
+ await sleep(1500);
84
+ continue;
85
+ }
86
+
87
+ if (!sawNewAttempt) {
88
+ const restartedAt = app.restartedAt ?? null;
89
+ if (restartedAt !== null && restartedAt !== baselineRestartedAt) {
90
+ sawNewAttempt = true;
91
+ attemptId = restartedAt;
92
+ } else {
93
+ // The platform hasn't picked up this attempt yet — deployStatus/status
94
+ // here still describe whatever was true *before* it was triggered.
95
+ // Reporting "running" at this point would be exactly the false
96
+ // positive this function exists to prevent.
97
+ await sleep(1000);
98
+ continue;
99
+ }
100
+ } else if (app.restartedAt != null && app.restartedAt !== attemptId) {
101
+ // A newer attempt interleaved (e.g. someone else restarted it again)
102
+ // — track that one instead; it's the one that will actually land.
103
+ attemptId = app.restartedAt;
104
+ }
105
+
106
+ if (app.deployStatus === "restarting" || app.deployStatus === "building" || app.deployStatus === "deploying") {
107
+ await sleep(1000);
108
+ continue;
109
+ }
110
+ if (app.status === "failed" || app.status === "crashed") {
111
+ return { ok: false, status: app.status, attemptId, domain: app.domain, waitedMs: Date.now() - start };
112
+ }
113
+ if (app.status === "running") {
114
+ // Every app gets a generated .onlizard.com domain regardless of whether
115
+ // it actually listens for HTTP — a containerPort=0 worker has one too.
116
+ // LIZARD-174: gating on app.domain alone made restart --wait spin a
117
+ // worker until the timeout, health-checking a URL nothing ever answers,
118
+ // even though the platform had already reported it Running/Ready.
119
+ const hasHttpPort = app.containerPort !== 0;
120
+ if (healthCheck && hasHttpPort && app.domain) {
121
+ const healthy = await debounceHealthCheck(app.domain);
122
+ if (!healthy) {
123
+ // Platform says running, but the URL isn't consistently healthy yet
124
+ // (mid-handover) — keep polling rather than declaring success early.
125
+ await sleep(1500);
126
+ continue;
127
+ }
128
+ }
129
+ return { ok: true, status: "running", attemptId, domain: app.domain, waitedMs: Date.now() - start };
130
+ }
131
+
132
+ await sleep(1000);
133
+ }
134
+
135
+ return { ok: false, status: "timeout", attemptId, waitedMs: Date.now() - start };
136
+ }
@@ -0,0 +1,159 @@
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("LIZARD-174: a containerPort=0 worker with a domain resolves without health-checking it", async () => {
109
+ // Every app gets a generated .onlizard.com domain regardless of whether it
110
+ // serves HTTP — a worker has one too. Before the fix, healthCheck was gated
111
+ // on domain presence alone, so this spun until timeout fetching a URL
112
+ // nothing ever answers, even though the platform already reported it ready.
113
+ serveSequence([
114
+ { status: "running", deployStatus: "idle", restartedAt: 2000, domain: "worker.example.test", containerPort: 0 },
115
+ ]);
116
+
117
+ const realFetch = globalThis.fetch;
118
+ let fetchCalled = false;
119
+ vi.stubGlobal("fetch", (url: string, opts?: any) => {
120
+ if (typeof url === "string" && url.includes("worker.example.test")) fetchCalled = true;
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(true);
127
+ expect(result.status).toBe("running");
128
+ expect(fetchCalled).toBe(false);
129
+ } finally {
130
+ vi.unstubAllGlobals();
131
+ }
132
+ });
133
+
134
+ test("health check debounce: a single 200 mid-handover does not count as ready", async () => {
135
+ serveSequence([{ status: "running", deployStatus: "idle", restartedAt: 2000, domain: "app.example.test" }]);
136
+
137
+ const realFetch = globalThis.fetch;
138
+ let call = 0;
139
+ vi.stubGlobal("fetch", (url: string, opts?: any) => {
140
+ if (typeof url === "string" && url.includes("app.example.test")) {
141
+ call++;
142
+ // First health check: healthy. Second (1.5s later, per the debounce):
143
+ // 502 — the exact LIZARD-174 reproduction (old process still finishing
144
+ // shutdown). Must not be reported as ready on the strength of check #1 alone.
145
+ return Promise.resolve(new Response(null, { status: call === 1 ? 200 : 502 }));
146
+ }
147
+ return realFetch(url, opts);
148
+ });
149
+
150
+ try {
151
+ const result = await waitForAppReady("app1", 1000, { timeoutMs: 4000, healthCheck: true });
152
+ expect(result.ok).toBe(false);
153
+ expect(result.status).toBe("timeout");
154
+ expect(call).toBeGreaterThanOrEqual(2);
155
+ } finally {
156
+ vi.unstubAllGlobals();
157
+ }
158
+ });
159
+ });