@lizard-build/cli 0.3.38 → 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 (47) hide show
  1. package/dist/commands/add.js +2 -2
  2. package/dist/commands/add.js.map +1 -1
  3. package/dist/commands/git.js +7 -6
  4. package/dist/commands/git.js.map +1 -1
  5. package/dist/commands/login.js +5 -1
  6. package/dist/commands/login.js.map +1 -1
  7. package/dist/commands/logs.js +4 -1
  8. package/dist/commands/logs.js.map +1 -1
  9. package/dist/commands/redeploy.js +8 -6
  10. package/dist/commands/redeploy.js.map +1 -1
  11. package/dist/commands/secrets.js +5 -0
  12. package/dist/commands/secrets.js.map +1 -1
  13. package/dist/commands/ssh.js +24 -18
  14. package/dist/commands/ssh.js.map +1 -1
  15. package/dist/commands/up.js +26 -16
  16. package/dist/commands/up.js.map +1 -1
  17. package/dist/commands/upgrade.js +20 -1
  18. package/dist/commands/upgrade.js.map +1 -1
  19. package/dist/index.js +10 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/lib/api.d.ts +8 -2
  22. package/dist/lib/api.js +28 -5
  23. package/dist/lib/api.js.map +1 -1
  24. package/dist/lib/auth.d.ts +8 -1
  25. package/dist/lib/auth.js +33 -3
  26. package/dist/lib/auth.js.map +1 -1
  27. package/dist/lib/config.js +14 -2
  28. package/dist/lib/config.js.map +1 -1
  29. package/dist/lib/updater.d.ts +22 -4
  30. package/dist/lib/updater.js +137 -49
  31. package/dist/lib/updater.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/commands/add.ts +2 -2
  34. package/src/commands/git.ts +11 -6
  35. package/src/commands/login.ts +5 -0
  36. package/src/commands/logs.ts +12 -5
  37. package/src/commands/redeploy.ts +12 -6
  38. package/src/commands/secrets.ts +8 -0
  39. package/src/commands/ssh.ts +24 -25
  40. package/src/commands/up.ts +26 -17
  41. package/src/commands/upgrade.ts +21 -1
  42. package/src/index.ts +12 -1
  43. package/src/lib/api.ts +25 -4
  44. package/src/lib/auth.ts +33 -3
  45. package/src/lib/config.ts +13 -2
  46. package/src/lib/updater.ts +130 -45
  47. package/test/unit/config.test.ts +29 -0
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@ import chalk from "chalk";
5
5
  import { setJSONMode, isJSONMode, error } from "./lib/format.js";
6
6
  import { requireAuth, isLoggedIn } from "./lib/auth.js";
7
7
  import { setBaseURL, setAccessToken, APIError } from "./lib/api.js";
8
- import { checkForUpdateInBackground, CURRENT_VERSION } from "./lib/updater.js";
8
+ import { checkForUpdateInBackground, runBackgroundUpdate, CURRENT_VERSION } from "./lib/updater.js";
9
9
 
10
10
  const BANNER = chalk.rgb(16, 185, 129)(
11
11
  [
@@ -223,6 +223,13 @@ function dumpCommand(cmd: Command): any {
223
223
  }
224
224
 
225
225
  async function main() {
226
+ // Hidden entry point: detached child spawned by checkForUpdateInBackground.
227
+ // Handled before commander so it never shows up in help or telemetry.
228
+ if (process.argv.includes("__lizard-update")) {
229
+ await runBackgroundUpdate();
230
+ process.exit(0);
231
+ }
232
+
226
233
  // Set JSON mode from argv *before* parseAsync so the catch block below
227
234
  // honors --json even when commander rejects before our preAction hook
228
235
  // fires (e.g. unknown command, malformed global flag). Non-TTY auto-mode
@@ -280,10 +287,14 @@ async function main() {
280
287
  );
281
288
  } else {
282
289
  error(msg);
290
+ if (status === 401) {
291
+ process.stderr.write("Run `lizard login` to re-authenticate.\n");
292
+ }
283
293
  }
284
294
 
285
295
  // Exit codes derived from APIError.status (or tagged error codes), not message text
286
296
  const isAuth = status === 401 || status === 403 || code === "NOT_AUTHENTICATED";
297
+
287
298
  const isNotFound = status === 404;
288
299
  const isTimeout =
289
300
  status === 408 ||
package/src/lib/api.ts CHANGED
@@ -129,10 +129,15 @@ export const api = {
129
129
  delete: <T = any>(path: string) => request<T>("DELETE", path),
130
130
  };
131
131
 
132
- /** Stream SSE and call handler for each data line. Return false to stop. */
132
+ /** Stream SSE and call handler for each data line. Return false to stop.
133
+ *
134
+ * `opts.idleTimeoutMs` — stop (resolve) when no *event* arrives for that
135
+ * long. Heartbeat comments don't reset the timer. Used by `--tail`-style
136
+ * snapshot reads that must not follow a live stream forever. */
133
137
  export function streamSSE(
134
138
  path: string,
135
139
  handler: (event: string, data: string) => boolean | void,
140
+ opts: { idleTimeoutMs?: number } = {},
136
141
  ): Promise<void> {
137
142
  return new Promise((resolve, reject) => {
138
143
  const url = new URL(baseURL + path);
@@ -155,6 +160,19 @@ export function streamSSE(
155
160
  return;
156
161
  }
157
162
 
163
+ let idleTimer: NodeJS.Timeout | undefined;
164
+ const finish = () => {
165
+ if (idleTimer) clearTimeout(idleTimer);
166
+ req.destroy();
167
+ resolve();
168
+ };
169
+ const armIdleTimer = () => {
170
+ if (!opts.idleTimeoutMs) return;
171
+ if (idleTimer) clearTimeout(idleTimer);
172
+ idleTimer = setTimeout(finish, opts.idleTimeoutMs);
173
+ };
174
+ armIdleTimer();
175
+
158
176
  let buffer = "";
159
177
  let currentEvent = "";
160
178
  let currentData = "";
@@ -169,10 +187,10 @@ export function streamSSE(
169
187
  const trimmed = line.replace(/\r$/, "");
170
188
  if (trimmed === "") {
171
189
  if (currentData) {
190
+ armIdleTimer();
172
191
  const cont = handler(currentEvent, currentData);
173
192
  if (cont === false) {
174
- req.destroy();
175
- resolve();
193
+ finish();
176
194
  return;
177
195
  }
178
196
  }
@@ -186,7 +204,10 @@ export function streamSSE(
186
204
  }
187
205
  });
188
206
 
189
- res.on("end", resolve);
207
+ res.on("end", () => {
208
+ if (idleTimer) clearTimeout(idleTimer);
209
+ resolve();
210
+ });
190
211
  res.on("error", reject);
191
212
  },
192
213
  );
package/src/lib/auth.ts CHANGED
@@ -38,7 +38,32 @@ function isTTY(): boolean {
38
38
  }
39
39
 
40
40
  /**
41
- * Ensure the user is authenticated. If not logged in and TTY, auto-login.
41
+ * Expiry of a JWT in epoch-ms, decoded from the `exp` claim. Returns null
42
+ * for opaque/undecodable tokens — those are treated as valid and left for
43
+ * the server to reject.
44
+ */
45
+ export function jwtExpiryMs(token: string): number | null {
46
+ try {
47
+ const payload = token.split(".")[1];
48
+ if (!payload) return null;
49
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
50
+ return typeof decoded.exp === "number" ? decoded.exp * 1000 : null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function isExpired(creds: Credentials): boolean {
57
+ const expMs =
58
+ jwtExpiryMs(creds.accessToken) ??
59
+ (creds.expiresAt ? Date.parse(creds.expiresAt) : null);
60
+ if (expMs === null || Number.isNaN(expMs)) return false;
61
+ return Date.now() > expMs - 60_000; // 60s margin
62
+ }
63
+
64
+ /**
65
+ * Ensure the user is authenticated. If not logged in (or the saved token
66
+ * has expired — there is no refresh endpoint) and TTY, auto-login.
42
67
  * Returns credentials or throws.
43
68
  */
44
69
  export async function requireAuth(): Promise<Credentials> {
@@ -51,16 +76,21 @@ export async function requireAuth(): Promise<Credentials> {
51
76
  }
52
77
 
53
78
  const creds = loadCredentials();
54
- if (creds) return creds;
79
+ if (creds && !isExpired(creds)) return creds;
55
80
 
56
81
  if (!isTTY()) {
57
82
  const err = new Error(
58
- "Not authenticated. Set LIZARD_TOKEN or run `lizard login` first.",
83
+ creds
84
+ ? "Session expired. Run `lizard login` again or set LIZARD_TOKEN."
85
+ : "Not authenticated. Set LIZARD_TOKEN or run `lizard login` first.",
59
86
  ) as Error & { code: string };
60
87
  err.code = "NOT_AUTHENTICATED";
61
88
  throw err;
62
89
  }
63
90
 
91
+ if (creds) {
92
+ process.stderr.write("Session expired — logging in again...\n");
93
+ }
64
94
  const { performLogin } = await import("../commands/login.js");
65
95
  return performLogin();
66
96
  }
package/src/lib/config.ts CHANGED
@@ -96,7 +96,13 @@ export function updateProjectLink(
96
96
  ) {
97
97
  const existing = getProjectLink(cwd);
98
98
  if (!existing) return;
99
- setProjectLink({ ...existing, ...patch }, cwd);
99
+ const merged = { ...existing, ...patch };
100
+ // An explicit serviceId/serviceName in the patch (including `undefined`,
101
+ // i.e. "clear it") must override the legacy mirror too — otherwise
102
+ // setProjectLink resurrects the old value from appId/appName.
103
+ if ("serviceId" in patch) merged.appId = patch.serviceId;
104
+ if ("serviceName" in patch) merged.appName = patch.serviceName;
105
+ setProjectLink(merged, cwd);
100
106
  }
101
107
 
102
108
  export function clearProjectLink(cwd: string = process.cwd()) {
@@ -120,8 +126,13 @@ export async function resolveProjectId(flagValue?: string): Promise<string> {
120
126
  }
121
127
  const { api } = await import("./api.js");
122
128
  const projects = await api.get<Array<{ id: string; name: string; slug: string }>>("/api/projects");
129
+ // Case-insensitive, matching `lizard link` / `lizard init` behaviour.
130
+ const lower = flagValue.toLowerCase();
123
131
  const match = projects.find(
124
- (p) => p.id === flagValue || p.slug === flagValue || p.name === flagValue,
132
+ (p) =>
133
+ p.id.toLowerCase() === lower ||
134
+ p.slug?.toLowerCase() === lower ||
135
+ p.name?.toLowerCase() === lower,
125
136
  );
126
137
  if (!match) throw new Error(`Project "${flagValue}" not found.`);
127
138
  return match.id;
@@ -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.38";
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 });