@lizard-build/cli 0.3.39 → 0.3.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/commands/git.js +7 -6
  2. package/dist/commands/git.js.map +1 -1
  3. package/dist/commands/login.js +5 -1
  4. package/dist/commands/login.js.map +1 -1
  5. package/dist/commands/logs.js +4 -1
  6. package/dist/commands/logs.js.map +1 -1
  7. package/dist/commands/redeploy.js +8 -6
  8. package/dist/commands/redeploy.js.map +1 -1
  9. package/dist/commands/secrets.js +5 -0
  10. package/dist/commands/secrets.js.map +1 -1
  11. package/dist/commands/ssh.js +24 -18
  12. package/dist/commands/ssh.js.map +1 -1
  13. package/dist/commands/up.js +26 -16
  14. package/dist/commands/up.js.map +1 -1
  15. package/dist/commands/upgrade.js +20 -1
  16. package/dist/commands/upgrade.js.map +1 -1
  17. package/dist/index.js +10 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/lib/api.d.ts +8 -2
  20. package/dist/lib/api.js +28 -5
  21. package/dist/lib/api.js.map +1 -1
  22. package/dist/lib/auth.d.ts +8 -1
  23. package/dist/lib/auth.js +33 -3
  24. package/dist/lib/auth.js.map +1 -1
  25. package/dist/lib/config.js +14 -2
  26. package/dist/lib/config.js.map +1 -1
  27. package/dist/lib/updater.d.ts +22 -4
  28. package/dist/lib/updater.js +137 -49
  29. package/dist/lib/updater.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/commands/git.ts +11 -6
  32. package/src/commands/login.ts +5 -0
  33. package/src/commands/logs.ts +12 -5
  34. package/src/commands/redeploy.ts +12 -6
  35. package/src/commands/secrets.ts +8 -0
  36. package/src/commands/ssh.ts +24 -25
  37. package/src/commands/up.ts +26 -17
  38. package/src/commands/upgrade.ts +21 -1
  39. package/src/index.ts +12 -1
  40. package/src/lib/api.ts +25 -4
  41. package/src/lib/auth.ts +33 -3
  42. package/src/lib/config.ts +13 -2
  43. package/src/lib/updater.ts +130 -45
  44. package/test/unit/config.test.ts +29 -0
@@ -1,14 +1,17 @@
1
- import { createWriteStream, existsSync, renameSync, chmodSync } from "node:fs";
1
+ import { createWriteStream, existsSync, renameSync, chmodSync, unlinkSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
2
  import { pipeline } from "node:stream/promises";
3
3
  import { Readable } from "node:stream";
4
- import { tmpdir } from "node:os";
5
- import { join } from "node:path";
6
- import { execFileSync } from "node:child_process";
4
+ import { join, dirname } from "node:path";
5
+ import os from "node:os";
6
+ import { spawn } from "node:child_process";
7
7
 
8
- export const CURRENT_VERSION = "0.3.39";
8
+ export const CURRENT_VERSION = "0.3.40";
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
 
12
+ /** Minimum gap between background update checks. */
13
+ const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h
14
+
12
15
  function getBinaryName(): string | null {
13
16
  const os = process.platform;
14
17
  const arch = process.arch;
@@ -19,6 +22,23 @@ function getBinaryName(): string | null {
19
22
  return null;
20
23
  }
21
24
 
25
+ /**
26
+ * True only when running as the Bun-compiled standalone binary. Under
27
+ * npm/node, `process.execPath` is the *node* executable — self-update would
28
+ * overwrite the user's Node.js install with the lizard binary.
29
+ */
30
+ export function isStandaloneBinary(): boolean {
31
+ return typeof (globalThis as any).Bun !== "undefined";
32
+ }
33
+
34
+ function stateDir(): string {
35
+ return process.env.LIZARD_HOME
36
+ ? join(process.env.LIZARD_HOME, ".lizard")
37
+ : join(os.homedir(), ".lizard");
38
+ }
39
+ const checkStampFile = () => join(stateDir(), "update-check.json");
40
+ const updateNoticeFile = () => join(stateDir(), "update-notice.json");
41
+
22
42
  export type LatestVersionResult =
23
43
  | { kind: "ok"; version: string }
24
44
  | { kind: "rate-limited"; resetAt: number }
@@ -43,64 +63,129 @@ export async function getLatestVersion(): Promise<LatestVersionResult> {
43
63
  }
44
64
  }
45
65
 
66
+ export function isNewerVersion(latest: string, current: string): boolean {
67
+ const [maj, min, pat] = latest.split(".").map(Number);
68
+ const [cmaj, cmin, cpat] = current.split(".").map(Number);
69
+ if (![maj, min, pat, cmaj, cmin, cpat].every(Number.isFinite)) return false;
70
+ return maj > cmaj || (maj === cmaj && min > cmin) || (maj === cmaj && min === cmin && pat > cpat);
71
+ }
72
+
46
73
  export async function selfUpdate(onProgress?: (msg: string) => void): Promise<boolean> {
47
74
  const binaryName = getBinaryName();
48
75
  if (!binaryName) return false;
49
76
 
50
- // Find current executable path
77
+ // Refuse to replace anything that isn't the standalone lizard binary —
78
+ // under npm the execPath is the user's node executable.
79
+ if (!isStandaloneBinary()) return false;
80
+
51
81
  const currentBin = process.execPath;
52
82
  if (!existsSync(currentBin)) return false;
53
83
 
54
84
  const url = `${RELEASE_BASE}/${binaryName}`;
55
- const tmp = join(tmpdir(), `lizard-update-${Date.now()}`);
85
+ // Download next to the target binary: rename() must stay on one filesystem
86
+ // (tmpdir is often tmpfs on Linux → EXDEV).
87
+ const tmp = join(dirname(currentBin), `.lizard-update-${process.pid}`);
56
88
 
57
89
  onProgress?.(`Downloading ${binaryName}...`);
58
90
 
59
- const res = await fetch(url, { signal: AbortSignal.timeout(60000) });
60
- if (!res.ok) throw new Error(`Download failed: ${res.status}`);
91
+ try {
92
+ const res = await fetch(url, { signal: AbortSignal.timeout(60000) });
93
+ if (!res.ok) throw new Error(`Download failed: ${res.status}`);
94
+
95
+ const writer = createWriteStream(tmp);
96
+ await pipeline(Readable.fromWeb(res.body as any), writer);
97
+ chmodSync(tmp, 0o755);
98
+
99
+ onProgress?.("Installing...");
100
+ renameSync(tmp, currentBin);
101
+ return true;
102
+ } catch (err) {
103
+ try { unlinkSync(tmp); } catch {}
104
+ throw err;
105
+ }
106
+ }
107
+
108
+ function readJSON(file: string): any {
109
+ try {
110
+ return JSON.parse(readFileSync(file, "utf8"));
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
61
115
 
62
- const writer = createWriteStream(tmp);
63
- await pipeline(Readable.fromWeb(res.body as any), writer);
64
- chmodSync(tmp, 0o755);
116
+ function writeJSON(file: string, data: unknown) {
117
+ try {
118
+ mkdirSync(stateDir(), { recursive: true });
119
+ writeFileSync(file, JSON.stringify(data));
120
+ } catch {}
121
+ }
65
122
 
66
- onProgress?.("Installing...");
67
- renameSync(tmp, currentBin);
68
- return true;
123
+ function autoUpdateDisabled(): boolean {
124
+ return Boolean(process.env.LIZARD_NO_UPDATE || process.env.CI);
69
125
  }
70
126
 
71
- /** Check for a newer version and auto-install it in the background.
72
- * Prints a one-line notice on exit — either "Updated to vX.Y.Z" or nothing on failure.
73
- * Never blocks or crashes the current command. */
127
+ /** Print (once) the notice left behind by a completed background update. */
128
+ function flushUpdateNotice(): void {
129
+ const notice = readJSON(updateNoticeFile());
130
+ if (!notice?.to) return;
131
+ try { unlinkSync(updateNoticeFile()); } catch {}
132
+ // We are already running the replaced binary, so notice.to should match.
133
+ if (notice.to === CURRENT_VERSION && notice.from !== CURRENT_VERSION) {
134
+ process.stderr.write(` lizard auto-updated: v${notice.from} → v${notice.to}\n`);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Kick off an update check without delaying the current command.
140
+ *
141
+ * The check+download runs in a *detached child process* (`lizard
142
+ * __lizard-update`): an in-process fetch would keep the event loop alive and
143
+ * make every command linger until GitHub answers. Checks are throttled via a
144
+ * stamp file (6h), disabled with LIZARD_NO_UPDATE/CI, and only run for the
145
+ * standalone binary — npm installs upgrade through npm.
146
+ */
74
147
  export function checkForUpdateInBackground(): void {
75
- // Only auto-update in TTY; skip CI / piped output
76
148
  if (!process.stdout.isTTY) return;
149
+ if (autoUpdateDisabled()) return;
77
150
 
78
- let updateMessage: string | null = null;
79
-
80
- const promise = getLatestVersion().then(async (r) => {
81
- if (r.kind !== "ok") return;
82
- const latest = r.version;
83
- if (latest === CURRENT_VERSION) return;
84
- const [maj, min, pat] = latest.split(".").map(Number);
85
- const [cmaj, cmin, cpat] = CURRENT_VERSION.split(".").map(Number);
86
- const isNewer = maj > cmaj || (maj === cmaj && min > cmin) || (maj === cmaj && min === cmin && pat > cpat);
87
- if (!isNewer) return;
88
- try {
89
- const ok = await selfUpdate();
90
- if (ok) {
91
- updateMessage =
92
- `\n Updating lizard v${CURRENT_VERSION} → v${latest}...\n` +
93
- ` lizard updated to v${latest}\n`;
94
- }
95
- } catch {
96
- // silent — don't interrupt the current command
97
- }
98
- }).catch(() => {});
151
+ flushUpdateNotice();
152
+
153
+ if (!isStandaloneBinary()) return;
99
154
 
100
- process.on("exit", () => {
101
- if (updateMessage) process.stderr.write(updateMessage);
102
- });
155
+ const stamp = readJSON(checkStampFile());
156
+ if (stamp?.lastCheckAt && Date.now() - stamp.lastCheckAt < CHECK_INTERVAL_MS) return;
157
+ // Stamp before spawning so parallel commands don't pile up children.
158
+ writeJSON(checkStampFile(), { lastCheckAt: Date.now(), lastVersion: CURRENT_VERSION });
103
159
 
104
- // Don't block process exit
105
- if (typeof (promise as any).unref === "function") (promise as any).unref();
160
+ try {
161
+ const child = spawn(process.execPath, ["__lizard-update"], {
162
+ detached: true,
163
+ stdio: "ignore",
164
+ });
165
+ child.unref();
166
+ } catch {
167
+ // never break the actual command over an update check
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Body of the hidden `__lizard-update` command: check the latest release and
173
+ * install it, leaving a notice file for the next foreground run.
174
+ */
175
+ export async function runBackgroundUpdate(): Promise<void> {
176
+ if (autoUpdateDisabled() || !isStandaloneBinary()) return;
177
+
178
+ const r = await getLatestVersion();
179
+ writeJSON(checkStampFile(), { lastCheckAt: Date.now(), lastVersion: r.kind === "ok" ? r.version : CURRENT_VERSION });
180
+ if (r.kind !== "ok") return;
181
+ if (!isNewerVersion(r.version, CURRENT_VERSION)) return;
182
+
183
+ try {
184
+ const ok = await selfUpdate();
185
+ if (ok) {
186
+ writeJSON(updateNoticeFile(), { from: CURRENT_VERSION, to: r.version, at: Date.now() });
187
+ }
188
+ } catch {
189
+ // silent — retried after the next throttle window
190
+ }
106
191
  }
@@ -75,6 +75,35 @@ describe("ProjectLink schema", () => {
75
75
  expect(got?.workspaceName).toBe("filled");
76
76
  });
77
77
 
78
+ test("updateProjectLink with explicit undefined clears the service (no appId resurrection)", () => {
79
+ setProjectLink(
80
+ { projectId: "proj_1", serviceId: "svc_1", serviceName: "api" },
81
+ tmpDir,
82
+ );
83
+ // Simulates `lizard service delete` of the linked service.
84
+ updateProjectLink({ serviceId: undefined, serviceName: undefined }, tmpDir);
85
+
86
+ const got = getProjectLink(tmpDir);
87
+ expect(got?.projectId).toBe("proj_1");
88
+ expect(got?.serviceId).toBeUndefined();
89
+ expect(got?.serviceName).toBeUndefined();
90
+ expect(got?.appId).toBeUndefined();
91
+ expect(got?.appName).toBeUndefined();
92
+ });
93
+
94
+ test("updateProjectLink with a new serviceId also updates the legacy mirror", () => {
95
+ setProjectLink(
96
+ { projectId: "proj_1", serviceId: "svc_old", serviceName: "old" },
97
+ tmpDir,
98
+ );
99
+ updateProjectLink({ serviceId: "svc_new", serviceName: "new" }, tmpDir);
100
+
101
+ const got = getProjectLink(tmpDir);
102
+ expect(got?.serviceId).toBe("svc_new");
103
+ expect(got?.appId).toBe("svc_new");
104
+ expect(got?.appName).toBe("new");
105
+ });
106
+
78
107
  test("config.json without workspaceId still loads (legacy compat)", () => {
79
108
  const cfgFile = path.join(tmpDir, ".lizard", "config.json");
80
109
  fs.mkdirSync(path.dirname(cfgFile), { recursive: true });