@lizard-build/cli 0.3.92 → 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.
- package/AGENTS.md +3 -2
- package/dist/commands/redeploy.js +57 -5
- package/dist/commands/redeploy.js.map +1 -1
- package/dist/commands/restart.js +40 -1
- package/dist/commands/restart.js.map +1 -1
- package/dist/commands/sandbox.js +10 -3
- package/dist/commands/sandbox.js.map +1 -1
- package/dist/commands/up.d.ts +1 -0
- package/dist/commands/up.js +14 -33
- package/dist/commands/up.js.map +1 -1
- package/dist/lib/archive.d.ts +1 -0
- package/dist/lib/archive.js +35 -0
- package/dist/lib/archive.js.map +1 -0
- package/dist/lib/skills-data.generated.js +1 -1
- package/dist/lib/skills-data.generated.js.map +1 -1
- package/dist/lib/updater.d.ts +1 -1
- package/dist/lib/updater.js +1 -1
- package/dist/lib/wait-ready.d.ts +38 -0
- package/dist/lib/wait-ready.js +101 -0
- package/dist/lib/wait-ready.js.map +1 -0
- package/package.json +3 -2
- package/skill-data/core/SKILL.md +6 -2
- package/src/commands/redeploy.ts +58 -4
- package/src/commands/restart.ts +45 -1
- package/src/commands/sandbox.ts +11 -3
- package/src/commands/up.ts +14 -31
- package/src/lib/archive.ts +34 -0
- package/src/lib/updater.ts +1 -1
- package/src/lib/wait-ready.ts +127 -0
- package/test/cli.test.ts +12 -5
- package/test/unit/archive.test.ts +28 -0
- package/test/unit/sandbox.test.ts +38 -0
- package/test/unit/up-build-status.test.ts +46 -0
- package/test/unit/wait-ready.test.ts +133 -0
package/src/commands/up.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import ora from "ora";
|
|
3
3
|
import { Command } from "commander";
|
|
4
|
-
import { execSync
|
|
4
|
+
import { execSync } from "child_process";
|
|
5
|
+
import { createTarball } from "../lib/archive.js";
|
|
5
6
|
import * as fs from "node:fs";
|
|
6
7
|
import * as path from "node:path";
|
|
7
8
|
import * as readline from "node:readline";
|
|
@@ -290,34 +291,6 @@ function collectFilesManually(root: string, dir: string): string[] {
|
|
|
290
291
|
return results;
|
|
291
292
|
}
|
|
292
293
|
|
|
293
|
-
function createTarball(files: string[], cwd: string): Promise<Uint8Array> {
|
|
294
|
-
return new Promise((resolve, reject) => {
|
|
295
|
-
const chunks: Uint8Array[] = [];
|
|
296
|
-
// `--null` makes tar read NUL-separated paths from stdin, matching what
|
|
297
|
-
// `git ls-files -z` writes. Newline-separated input would split filenames
|
|
298
|
-
// containing `\n` across multiple entries. Both bsdtar (macOS) and GNU
|
|
299
|
-
// tar accept `--null` before `-T -`.
|
|
300
|
-
const tar = spawn("tar", ["--null", "-czf", "-", "-T", "-"], { cwd });
|
|
301
|
-
tar.stdout.on("data", (c: Buffer) => chunks.push(c));
|
|
302
|
-
tar.stderr.on("data", () => {});
|
|
303
|
-
tar.on("close", (code: number) => {
|
|
304
|
-
if (code === 0) {
|
|
305
|
-
const total = chunks.reduce((n, c) => n + c.length, 0);
|
|
306
|
-
const out = new Uint8Array(total);
|
|
307
|
-
let off = 0;
|
|
308
|
-
for (const c of chunks) {
|
|
309
|
-
out.set(c, off);
|
|
310
|
-
off += c.length;
|
|
311
|
-
}
|
|
312
|
-
resolve(out);
|
|
313
|
-
} else {
|
|
314
|
-
reject(new Error(`tar exited ${code}`));
|
|
315
|
-
}
|
|
316
|
-
});
|
|
317
|
-
if (files.length > 0) tar.stdin.write(files.join("\0") + "\0");
|
|
318
|
-
tar.stdin.end();
|
|
319
|
-
});
|
|
320
|
-
}
|
|
321
294
|
|
|
322
295
|
function detectLocalPort(dir: string): number | undefined {
|
|
323
296
|
for (const name of ["Dockerfile", "dockerfile", "Dockerfile.production"]) {
|
|
@@ -340,7 +313,7 @@ function prompt(question: string): Promise<string> {
|
|
|
340
313
|
});
|
|
341
314
|
}
|
|
342
315
|
|
|
343
|
-
async function streamBuildLogs(appId: string, ciMode: boolean = false, knownBuildId?: string) {
|
|
316
|
+
export async function streamBuildLogs(appId: string, ciMode: boolean = false, knownBuildId?: string) {
|
|
344
317
|
// Prefer the buildId returned by the upload/redeploy response — polling
|
|
345
318
|
// builds[0] races against a still-running previous build and can attach
|
|
346
319
|
// to the wrong one.
|
|
@@ -372,12 +345,16 @@ async function streamBuildLogs(appId: string, ciMode: boolean = false, knownBuil
|
|
|
372
345
|
// idle timeout, network blips). Reconnect until the build itself reports
|
|
373
346
|
// a terminal status, with a hard cap so we don't loop forever.
|
|
374
347
|
const deadline = Date.now() + 15 * 60 * 1000; // 15 min max
|
|
348
|
+
let buildFailed = false;
|
|
375
349
|
while (Date.now() < deadline) {
|
|
376
350
|
let dropped = false;
|
|
377
351
|
try {
|
|
378
352
|
await streamSSE(`/api/builds/${buildId}/logs`, (event, data) => {
|
|
379
353
|
if (event === "done" || event === "error") {
|
|
380
|
-
if (event === "error")
|
|
354
|
+
if (event === "error") {
|
|
355
|
+
buildFailed = true;
|
|
356
|
+
emitBuildError(data);
|
|
357
|
+
}
|
|
381
358
|
else emitBuildDone();
|
|
382
359
|
return false;
|
|
383
360
|
}
|
|
@@ -392,6 +369,7 @@ async function streamBuildLogs(appId: string, ciMode: boolean = false, knownBuil
|
|
|
392
369
|
// build state — terminal status means we stop reconnecting.
|
|
393
370
|
try {
|
|
394
371
|
const build = await api.get<{ status: string }>(`/api/builds/${buildId}`);
|
|
372
|
+
if (build.status === "failed") buildFailed = true;
|
|
395
373
|
if (build.status === "done" || build.status === "failed") break;
|
|
396
374
|
} catch {}
|
|
397
375
|
|
|
@@ -399,6 +377,10 @@ async function streamBuildLogs(appId: string, ciMode: boolean = false, knownBuil
|
|
|
399
377
|
await sleep(2000);
|
|
400
378
|
}
|
|
401
379
|
|
|
380
|
+
if (buildFailed) {
|
|
381
|
+
process.exitCode = 1;
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
402
384
|
if (ciMode) return;
|
|
403
385
|
|
|
404
386
|
const app = await api.get<App>(`/api/apps/${appId}`);
|
|
@@ -415,6 +397,7 @@ async function streamBuildLogs(appId: string, ciMode: boolean = false, knownBuil
|
|
|
415
397
|
success(`Deployed! ${app.domain ? chalk.cyan(`https://${app.domain}`) : ""}`);
|
|
416
398
|
}
|
|
417
399
|
} else if (app.status === "failed") {
|
|
400
|
+
process.exitCode = 1;
|
|
418
401
|
if (isJSONMode()) {
|
|
419
402
|
process.stdout.write(
|
|
420
403
|
JSON.stringify({ event: "failed", status: "failed" }) + "\n",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
// macOS tar otherwise creates AppleDouble ._* entries from file metadata.
|
|
4
|
+
export function createTarball(files: string[], cwd: string): Promise<Uint8Array> {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const chunks: Uint8Array[] = [];
|
|
7
|
+
// `--null` makes tar read NUL-separated paths from stdin, matching what
|
|
8
|
+
// `git ls-files -z` writes. Newline-separated input would split filenames
|
|
9
|
+
// containing `\n` across multiple entries. Both bsdtar (macOS) and GNU
|
|
10
|
+
// tar accept `--null` before `-T -`.
|
|
11
|
+
const tar = spawn("tar", ["--null", "-czf", "-", "-T", "-"], { cwd, env: { ...process.env, COPYFILE_DISABLE: "1" } });
|
|
12
|
+
tar.on("error", reject);
|
|
13
|
+
tar.stdin.on("error", reject);
|
|
14
|
+
tar.stdout.on("data", (c: Buffer) => chunks.push(c));
|
|
15
|
+
tar.stderr.on("data", () => {});
|
|
16
|
+
tar.on("close", (code: number) => {
|
|
17
|
+
if (code === 0) {
|
|
18
|
+
const total = chunks.reduce((n, c) => n + c.length, 0);
|
|
19
|
+
const out = new Uint8Array(total);
|
|
20
|
+
let off = 0;
|
|
21
|
+
for (const c of chunks) {
|
|
22
|
+
out.set(c, off);
|
|
23
|
+
off += c.length;
|
|
24
|
+
}
|
|
25
|
+
resolve(out);
|
|
26
|
+
} else {
|
|
27
|
+
reject(new Error(`tar exited ${code}`));
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
if (files.length > 0) tar.stdin.write(files.join("\0") + "\0");
|
|
31
|
+
tar.stdin.end();
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
package/src/lib/updater.ts
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
package/test/cli.test.ts
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* mutate services (deploy, scale) are skipped so they can never touch
|
|
11
11
|
* an arbitrary real project picked from the account.
|
|
12
12
|
*
|
|
13
|
-
* Run: npm test
|
|
13
|
+
* Run: LIZARD_LIVE_TESTS=1 npm run test:integration
|
|
14
|
+
* Mutations also require LIZARD_TEST_PROJECT_ID and LIZARD_TEST_ALLOW_MUTATIONS=1.
|
|
14
15
|
*
|
|
15
16
|
* Flag-order rules (commander):
|
|
16
17
|
* - Global flags (--json, --token, --region) go BEFORE the subcommand.
|
|
@@ -39,7 +40,7 @@ import * as os from "node:os";
|
|
|
39
40
|
// fall back to whatever `lizard` is on PATH. Resolve to absolute path so we
|
|
40
41
|
// can run from any cwd (the e2e suite drops into /tmp for fixtures).
|
|
41
42
|
function resolveLizard(): string[] {
|
|
42
|
-
const raw = process.env.LIZARD_BIN ?? "
|
|
43
|
+
const raw = process.env.LIZARD_BIN ?? "dist/index.js";
|
|
43
44
|
if (raw.endsWith(".js")) {
|
|
44
45
|
return [process.execPath, path.resolve(raw)];
|
|
45
46
|
}
|
|
@@ -110,10 +111,15 @@ let projectId: string;
|
|
|
110
111
|
|
|
111
112
|
// Tracks created app IDs for afterAll cleanup
|
|
112
113
|
const createdApps: string[] = [];
|
|
114
|
+
const allowMutations = process.env.LIZARD_TEST_ALLOW_MUTATIONS === "1"
|
|
115
|
+
&& Boolean(process.env.LIZARD_TEST_PROJECT_ID);
|
|
113
116
|
|
|
114
117
|
// ── Setup: resolve project ID ─────────────────────────────────────────────────
|
|
115
118
|
|
|
116
119
|
beforeAll(async () => {
|
|
120
|
+
if (process.env.LIZARD_LIVE_TESTS !== "1") {
|
|
121
|
+
throw new Error("Live API tests require LIZARD_LIVE_TESTS=1. Run npm test for local unit tests.");
|
|
122
|
+
}
|
|
117
123
|
// Explicit override wins (CI-friendly).
|
|
118
124
|
if (process.env.LIZARD_TEST_PROJECT_ID) {
|
|
119
125
|
projectId = process.env.LIZARD_TEST_PROJECT_ID;
|
|
@@ -205,7 +211,7 @@ describe("projects", () => {
|
|
|
205
211
|
|
|
206
212
|
// ── Project-scope (global) secrets ────────────────────────────────────────────
|
|
207
213
|
|
|
208
|
-
describe("project secrets", () => {
|
|
214
|
+
describe.skipIf(!allowMutations)("project secrets", () => {
|
|
209
215
|
const KEY = `CLI_TEST_GLOBAL_${Date.now()}`;
|
|
210
216
|
|
|
211
217
|
// For listing we use the bare `secret` form (no `list` subcommand) — the
|
|
@@ -265,7 +271,7 @@ describe("ps (service inventory)", () => {
|
|
|
265
271
|
// Mutates a real app (forces replicas=1) — only safe against a dedicated,
|
|
266
272
|
// explicitly pinned test project.
|
|
267
273
|
|
|
268
|
-
describe.skipIf(!
|
|
274
|
+
describe.skipIf(!allowMutations)("scale", () => {
|
|
269
275
|
test("scale --replicas succeeds when an app exists", async () => {
|
|
270
276
|
const services = await cliJSON("ps", "--project", projectId);
|
|
271
277
|
const apps: Array<{ id: string; name: string }> = services?.apps ?? [];
|
|
@@ -313,7 +319,7 @@ describe("domain", () => {
|
|
|
313
319
|
let DEPLOY_DIR: string | undefined;
|
|
314
320
|
|
|
315
321
|
describe.skipIf(
|
|
316
|
-
process.env.LIZARD_SKIP_DEPLOY === "1" || !
|
|
322
|
+
process.env.LIZARD_SKIP_DEPLOY === "1" || !allowMutations,
|
|
317
323
|
)("deploy", () => {
|
|
318
324
|
const appName = `cli-test-${Date.now()}`;
|
|
319
325
|
let appId: string;
|
|
@@ -476,6 +482,7 @@ describe("error handling", () => {
|
|
|
476
482
|
// ── Cleanup ───────────────────────────────────────────────────────────────────
|
|
477
483
|
|
|
478
484
|
afterAll(async () => {
|
|
485
|
+
if (!allowMutations || process.env.LIZARD_LIVE_TESTS !== "1") return;
|
|
479
486
|
for (const id of createdApps) {
|
|
480
487
|
await execa(LIZARD_CMD, [
|
|
481
488
|
...LIZARD_ARGS_PREFIX,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, readFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { createTarball } from "../../src/lib/archive.js";
|
|
7
|
+
|
|
8
|
+
describe("source upload archive", () => {
|
|
9
|
+
it("preserves source bytes and unusual names through extraction", async () => {
|
|
10
|
+
const root = mkdtempSync(join(tmpdir(), "lizard-archive-"));
|
|
11
|
+
const names = ["normal.ts", "space name.ts", "line\nbreak.ts", "-flag.ts"];
|
|
12
|
+
for (const name of names) writeFileSync(join(root, name), `source: ${name}`);
|
|
13
|
+
const archive = await createTarball(names, root);
|
|
14
|
+
const output = join(root, "output");
|
|
15
|
+
mkdirSync(output);
|
|
16
|
+
execFileSync("tar", ["-xzf", "-", "-C", output], { input: archive });
|
|
17
|
+
for (const name of names) expect(readFileSync(join(output, name), "utf8")).toBe(`source: ${name}`);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it.skipIf(process.platform !== "darwin")("omits AppleDouble entries for files with macOS metadata", async () => {
|
|
21
|
+
const root = mkdtempSync(join(tmpdir(), "lizard-archive-xattr-"));
|
|
22
|
+
writeFileSync(join(root, "route.ts"), "export default 42;");
|
|
23
|
+
execFileSync("xattr", ["-w", "com.lizard.docs-test", "metadata", join(root, "route.ts")]);
|
|
24
|
+
const archive = await createTarball(["route.ts"], root);
|
|
25
|
+
const listing = execFileSync("tar", ["-tzf", "-"], { input: archive, encoding: "utf8" });
|
|
26
|
+
expect(listing.trim().split("\n")).toEqual(["route.ts"]);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { registerSandbox } from "../../src/commands/sandbox.js";
|
|
4
|
+
import { api } from "../../src/lib/api.js";
|
|
5
|
+
|
|
6
|
+
vi.mock("../../src/lib/api.js", () => ({ api: { post: vi.fn() } }));
|
|
7
|
+
vi.mock("../../src/lib/resolve.js", () => ({
|
|
8
|
+
resolveProjectScope: vi.fn().mockResolvedValue({ projectId: "project-test", scope: { workspaceId: "workspace-test" } }),
|
|
9
|
+
}));
|
|
10
|
+
vi.mock("../../src/lib/format.js", () => ({ isJSONMode: () => true, printJSON: vi.fn() }));
|
|
11
|
+
|
|
12
|
+
function create(args: string[]) {
|
|
13
|
+
const program = new Command().exitOverride();
|
|
14
|
+
registerSandbox(program);
|
|
15
|
+
return program.parseAsync(["sandbox", "create", ...args], { from: "user" });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("sandbox create timeout", () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
vi.clearAllMocks();
|
|
21
|
+
vi.mocked(api.post).mockResolvedValue({ id: "sandbox-test" });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("sends the documented five-minute default to the API", async () => {
|
|
25
|
+
await create([]);
|
|
26
|
+
expect(api.post).toHaveBeenCalledWith("/api/sandboxes", expect.objectContaining({ timeoutMs: 300_000 }));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it.each([0, 1000, 120_000, 2_147_483_647])("preserves an explicit timeout of %i", async (timeoutMs) => {
|
|
30
|
+
await create(["--timeout", String(timeoutMs)]);
|
|
31
|
+
expect(api.post).toHaveBeenCalledWith("/api/sandboxes", expect.objectContaining({ timeoutMs }));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it.each(["-1", "1.5", "1000ms", "Infinity", "2147483648"])("rejects %s before making a request", async (value) => {
|
|
35
|
+
await expect(create(["--timeout", value])).rejects.toThrow(/Timeout must be/);
|
|
36
|
+
expect(api.post).not.toHaveBeenCalled();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({ get: vi.fn(), stream: vi.fn() }));
|
|
4
|
+
vi.mock("../../src/lib/api.js", async (importOriginal) => {
|
|
5
|
+
const actual = await importOriginal<typeof import("../../src/lib/api.js")>();
|
|
6
|
+
return { ...actual, api: { ...actual.api, get: mocks.get }, streamSSE: mocks.stream };
|
|
7
|
+
});
|
|
8
|
+
import { streamBuildLogs } from "../../src/commands/up.js";
|
|
9
|
+
import { setJSONMode } from "../../src/lib/format.js";
|
|
10
|
+
|
|
11
|
+
describe("upload build result", () => {
|
|
12
|
+
let output: string;
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.clearAllMocks();
|
|
15
|
+
process.exitCode = 0;
|
|
16
|
+
setJSONMode(true);
|
|
17
|
+
output = "";
|
|
18
|
+
vi.spyOn(process.stdout, "write").mockImplementation((chunk: any) => { output += chunk; return true; });
|
|
19
|
+
});
|
|
20
|
+
afterEach(() => { process.exitCode = 0; setJSONMode(false); vi.restoreAllMocks(); });
|
|
21
|
+
|
|
22
|
+
it.each([false, true])("returns failure after an SSE build error (CI=%s)", async (ci) => {
|
|
23
|
+
mocks.stream.mockImplementation(async (_url, receive) => { receive("error", '"failed"'); });
|
|
24
|
+
mocks.get.mockResolvedValue({ status: "failed" });
|
|
25
|
+
await streamBuildLogs("app", ci, "build");
|
|
26
|
+
expect(process.exitCode).toBe(1);
|
|
27
|
+
expect(output).not.toContain('"event":"deployed"');
|
|
28
|
+
expect(mocks.get).not.toHaveBeenCalledWith("/api/apps/app");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("uses the failed build state even when an older app is still running", async () => {
|
|
32
|
+
mocks.stream.mockResolvedValue(undefined);
|
|
33
|
+
mocks.get.mockImplementation(async (url) => url === "/api/builds/build" ? { status: "failed" } : { status: "running" });
|
|
34
|
+
await streamBuildLogs("app", false, "build");
|
|
35
|
+
expect(process.exitCode).toBe(1);
|
|
36
|
+
expect(output).not.toContain("deployed");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("keeps a successful build and running app successful", async () => {
|
|
40
|
+
mocks.stream.mockImplementation(async (_url, receive) => { receive("done", ""); });
|
|
41
|
+
mocks.get.mockImplementation(async (url) => url === "/api/builds/build" ? { status: "done" } : { status: "running", domain: "example.test" });
|
|
42
|
+
await streamBuildLogs("app", false, "build");
|
|
43
|
+
expect(process.exitCode).toBe(0);
|
|
44
|
+
expect(output).toContain('"event":"deployed"');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -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
|
+
});
|