Skip to content

Commit 57ad676

Browse files
committed
improve updater
1 parent 0f6c9cd commit 57ad676

7 files changed

Lines changed: 338 additions & 397 deletions

File tree

jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,6 @@ export default {
2828
"^#supports-color$": "<rootDir>/node_modules/chalk/source/vendor/supports-color/index.js",
2929
},
3030
transformIgnorePatterns: [
31-
"node_modules/(?!.*(@iterable|chalk))"
31+
"node_modules/(?!.*(@iterable|chalk|boxen|semver|camelcase|string-width|get-east-asian-width|emoji-regex|widest-line|ansi-align|wrap-ansi|strip-ansi|ansi-regex|ansi-styles|cli-boxes|type-fest))"
3232
],
3333
};

package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@
6969
"ora": "9.3.0",
7070
"package-manager-detector": "1.6.0",
7171
"semver": "7.7.4",
72-
"update-notifier": "7.3.1",
7372
"zod": "4.3.6",
7473
"zod-opts": "1.0.0"
7574
},
@@ -83,7 +82,6 @@
8382
"@types/node": "25.5.0",
8483
"@types/omelette": "0.4.5",
8584
"@types/semver": "7.7.1",
86-
"@types/update-notifier": "6.0.8",
8785
"@typescript-eslint/eslint-plugin": "8.57.2",
8886
"@typescript-eslint/parser": "8.57.2",
8987
"eslint": "10.1.0",

pnpm-lock.yaml

Lines changed: 0 additions & 293 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/index.ts

Lines changed: 56 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -197,66 +197,68 @@ async function main(): Promise<void> {
197197
}
198198
}
199199

200-
main().catch(async (error: unknown) => {
201-
const {
202-
IterableApiError,
203-
IterableNetworkError,
204-
IterableRawError,
205-
IterableResponseValidationError,
206-
} = await import("@iterable/api");
207-
208-
const err = (msg: string) => console.error(chalk.red(`✖ ${msg}`)); // eslint-disable-line no-console
209-
const hint = (msg: string) => console.error(chalk.dim(` ${msg}`)); // eslint-disable-line no-console
210-
211-
const helpHint = (): void => {
212-
try {
213-
const { category, action } = parseArgs(process.argv.slice(2));
214-
if (category && action && findCommand(category, action)) {
215-
hint(
216-
`Run '${COMMAND_NAME} ${category} ${action} --help' for usage details.`
217-
);
218-
return;
200+
main()
201+
.then(() => process.exit(0))
202+
.catch(async (error: unknown) => {
203+
const {
204+
IterableApiError,
205+
IterableNetworkError,
206+
IterableRawError,
207+
IterableResponseValidationError,
208+
} = await import("@iterable/api");
209+
210+
const err = (msg: string) => console.error(chalk.red(`✖ ${msg}`)); // eslint-disable-line no-console
211+
const hint = (msg: string) => console.error(chalk.dim(` ${msg}`)); // eslint-disable-line no-console
212+
213+
const helpHint = (): void => {
214+
try {
215+
const { category, action } = parseArgs(process.argv.slice(2));
216+
if (category && action && findCommand(category, action)) {
217+
hint(
218+
`Run '${COMMAND_NAME} ${category} ${action} --help' for usage details.`
219+
);
220+
return;
221+
}
222+
} catch {
223+
// Fall through to generic hint
219224
}
220-
} catch {
221-
// Fall through to generic hint
225+
hint(`Run '${COMMAND_NAME} --help' for usage details.`);
226+
};
227+
228+
if (error instanceof CliError) {
229+
err(error.message);
230+
helpHint();
231+
process.exit(error.exitCode);
222232
}
223-
hint(`Run '${COMMAND_NAME} --help' for usage details.`);
224-
};
225233

226-
if (error instanceof CliError) {
227-
err(error.message);
228-
helpHint();
229-
process.exit(error.exitCode);
230-
}
234+
if (error instanceof z.ZodError) {
235+
err("Validation error");
236+
for (const issue of error.issues) {
237+
hint(`${issue.path.join(".")}: ${issue.message}`);
238+
}
239+
helpHint();
240+
process.exit(2);
241+
}
231242

232-
if (error instanceof z.ZodError) {
233-
err("Validation error");
234-
for (const issue of error.issues) {
235-
hint(`${issue.path.join(".")}: ${issue.message}`);
243+
if (
244+
error instanceof IterableApiError ||
245+
error instanceof IterableRawError ||
246+
error instanceof IterableResponseValidationError
247+
) {
248+
err(`${error.message} (${error.statusCode})`);
249+
if (error.endpoint) hint(`Endpoint: ${error.endpoint}`);
250+
if (error instanceof IterableApiError && error.statusCode === 401) {
251+
hint(`Run '${COMMAND_NAME} keys add' to configure your API key`);
252+
}
253+
process.exit(1);
236254
}
237-
helpHint();
238-
process.exit(2);
239-
}
240255

241-
if (
242-
error instanceof IterableApiError ||
243-
error instanceof IterableRawError ||
244-
error instanceof IterableResponseValidationError
245-
) {
246-
err(`${error.message} (${error.statusCode})`);
247-
if (error.endpoint) hint(`Endpoint: ${error.endpoint}`);
248-
if (error instanceof IterableApiError && error.statusCode === 401) {
249-
hint(`Run '${COMMAND_NAME} keys add' to configure your API key`);
256+
if (error instanceof IterableNetworkError) {
257+
err(error.message);
258+
process.exit(1);
250259
}
251-
process.exit(1);
252-
}
253260

254-
if (error instanceof IterableNetworkError) {
255-
err(error.message);
261+
const msg = error instanceof Error ? error.message : String(error);
262+
err(msg);
256263
process.exit(1);
257-
}
258-
259-
const msg = error instanceof Error ? error.message : String(error);
260-
err(msg);
261-
process.exit(1);
262-
});
264+
});

src/update-check-worker.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Detached background worker that fetches the latest package version from the
5+
* npm registry and writes it to the update cache. Spawned by checkForUpdate()
6+
* so the main CLI process can exit immediately.
7+
*
8+
* Uses only Node built-ins (fetch + fs) — no node_modules imports.
9+
*
10+
* Arguments: <registryUrl> <cachePath> <timeoutMs>
11+
*/
12+
13+
import { mkdirSync, writeFileSync } from "node:fs";
14+
import { dirname } from "node:path";
15+
16+
const [registryUrl, cachePath, timeoutStr] = process.argv.slice(2);
17+
if (!registryUrl || !cachePath) process.exit(1);
18+
19+
try {
20+
const resp = await fetch(registryUrl, {
21+
signal: AbortSignal.timeout(Number(timeoutStr) || 5_000),
22+
});
23+
if (!resp.ok) process.exit();
24+
25+
const data = (await resp.json()) as Record<string, unknown>;
26+
const version = data.version;
27+
if (typeof version !== "string") process.exit();
28+
29+
mkdirSync(dirname(cachePath), { recursive: true, mode: 0o700 });
30+
writeFileSync(
31+
cachePath,
32+
JSON.stringify({ latest: version, checkedAt: Date.now() }),
33+
{ mode: 0o600 }
34+
);
35+
} catch {
36+
// Silent failure — a missed cache write just delays the notification
37+
}

src/update.ts

Lines changed: 117 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1-
import { execFile } from "node:child_process";
1+
import { execFile, spawn } from "node:child_process";
2+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3+
import { homedir } from "node:os";
4+
import { dirname, join } from "node:path";
5+
import { fileURLToPath } from "node:url";
26
import { promisify } from "node:util";
37

48
import boxen from "boxen";
59
import chalk from "chalk";
610
import semverGt from "semver/functions/gt.js";
7-
// @types/update-notifier@6 targets v6 but the API surface we use is unchanged
8-
// in v7. No v7-aligned types exist on DefinitelyTyped as of 2026-03.
9-
import updateNotifier from "update-notifier";
11+
import semverValid from "semver/functions/valid.js";
12+
import { z } from "zod";
1013

1114
import { CliError } from "./errors.js";
1215
import { getSpinner } from "./utils/cli-env.js";
@@ -20,65 +23,132 @@ import {
2023

2124
const execFileAsync = promisify(execFile);
2225

23-
const ONE_DAY_MS = 86_400_000;
26+
const envMs = Number(process.env.ITERABLE_UPDATE_INTERVAL_MS);
27+
const UPDATE_CHECK_INTERVAL_MS = Number.isFinite(envMs) ? envMs : 86_400_000; // default: 1 day
28+
29+
const REGISTRY_TIMEOUT_MS = 5_000;
30+
31+
export const DEFAULT_CACHE_PATH = join(
32+
homedir(),
33+
".iterable",
34+
"update-cache.json"
35+
);
36+
37+
const UpdateCacheSchema = z.object({
38+
latest: z.string().refine((v) => semverValid(v) !== null),
39+
checkedAt: z.number(),
40+
});
41+
42+
export type UpdateCache = z.infer<typeof UpdateCacheSchema>;
43+
44+
export function readCache(
45+
cachePath: string = DEFAULT_CACHE_PATH
46+
): UpdateCache | undefined {
47+
try {
48+
const raw: unknown = JSON.parse(readFileSync(cachePath, "utf-8"));
49+
return UpdateCacheSchema.parse(raw);
50+
} catch {
51+
return undefined;
52+
}
53+
}
54+
55+
export function writeCache(
56+
cache: UpdateCache,
57+
cachePath: string = DEFAULT_CACHE_PATH
58+
): void {
59+
try {
60+
mkdirSync(dirname(cachePath), { recursive: true, mode: 0o700 });
61+
writeFileSync(cachePath, JSON.stringify(cache), { mode: 0o600 });
62+
} catch {
63+
// Best-effort: a failed cache write just delays the next notification
64+
}
65+
}
66+
67+
const RegistryResponseSchema = z.object({ version: z.string() });
68+
69+
export async function fetchLatestVersion(
70+
pkgName: string = PACKAGE_NAME
71+
): Promise<string | undefined> {
72+
try {
73+
const url = `https://registry.npmjs.org/${encodeURIComponent(pkgName)}/latest`;
74+
const resp = await fetch(url, {
75+
signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
76+
});
77+
if (!resp.ok) return undefined;
78+
const data = RegistryResponseSchema.parse(await resp.json());
79+
return data.version;
80+
} catch {
81+
return undefined;
82+
}
83+
}
84+
85+
function spawnBackgroundCheck(): void {
86+
const registryUrl = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`;
87+
const workerPath = join(
88+
dirname(fileURLToPath(import.meta.url)),
89+
"update-check-worker.js"
90+
);
91+
spawn(
92+
process.execPath,
93+
[workerPath, registryUrl, DEFAULT_CACHE_PATH, String(REGISTRY_TIMEOUT_MS)],
94+
{ detached: true, stdio: "ignore" }
95+
).unref();
96+
}
2497

2598
let updateCheckDone = false;
2699

27100
/**
28-
* Check for a newer version in the background and register an on-exit
29-
* notification to stderr. Fully fire-and-forget: errors are silently ignored
30-
* so normal CLI operation is never affected.
101+
* Show a cached update notification (if available) and fire-and-forget a
102+
* background refresh of the cache.
31103
*
32-
* The constructor creates a configstore and the background check process.
33-
* `check()` reads the cached result into `notifier.update` and, if the check
34-
* interval has elapsed, spawns a new background process for next time.
35-
*
36-
* We write to stderr (not stdout) so piped output stays clean, and we check
37-
* `process.stderr.isTTY` rather than stdout because notifications should
38-
* still appear when stdout is piped (e.g. `iterable users list | jq .`).
39-
*
40-
* CI, NO_UPDATE_NOTIFIER, and NODE_ENV=test suppression are handled
41-
* internally by update-notifier (notifier.config will be undefined).
104+
* - Errors never affect normal CLI operation.
105+
* - Notification goes to stderr so piped stdout stays clean.
106+
* - Suppressed for npx, non-TTY stderr, CI, and NO_UPDATE_NOTIFIER.
107+
* - The first notification appears after the check interval (default: 1 day)
108+
* because the cache must be populated by a previous invocation.
42109
*/
43110
export function checkForUpdate(): void {
44111
if (updateCheckDone) return;
45112
updateCheckDone = true;
46113

47114
try {
48115
if (IS_NPX) return;
116+
if (process.env.CI || process.env.NO_UPDATE_NOTIFIER) return;
49117

50-
const notifier = updateNotifier({
51-
pkg: { name: PACKAGE_NAME, version: PACKAGE_VERSION },
52-
updateCheckInterval: ONE_DAY_MS,
53-
});
54-
55-
// Always run check() so the background process is spawned and the cache
56-
// stays fresh, even when stderr is not a TTY. Only gate the display.
57-
notifier.check();
118+
const cache = readCache();
58119

59-
if (!process.stderr.isTTY) return;
60-
if (!notifier.update) return;
61-
if (!semverGt(notifier.update.latest, PACKAGE_VERSION)) return;
62-
63-
const message =
64-
`Update available: ${chalk.dim(notifier.update.current)} ${chalk.reset("→")} ${chalk.green(notifier.update.latest)}\n` +
65-
`Run ${chalk.cyan(`${COMMAND_NAME} update`)} to update`;
120+
if (
121+
cache &&
122+
process.stderr.isTTY &&
123+
semverGt(cache.latest, PACKAGE_VERSION)
124+
) {
125+
const message =
126+
`Update available: ${chalk.dim(PACKAGE_VERSION)} ${chalk.reset("→")} ${chalk.green(cache.latest)}\n` +
127+
`Run ${chalk.cyan(`${COMMAND_NAME} update`)} to update`;
128+
129+
const box = boxen(message, {
130+
padding: 1,
131+
margin: { top: 1, bottom: 0 },
132+
borderStyle: "round",
133+
borderColor: "yellow",
134+
textAlignment: "center",
135+
});
66136

67-
const box = boxen(message, {
68-
padding: 1,
69-
margin: { top: 1, bottom: 0 },
70-
borderStyle: "round",
71-
borderColor: "yellow",
72-
textAlignment: "center",
73-
});
137+
process.on("exit", (code) => {
138+
if (code !== 0) return;
139+
try {
140+
process.stderr.write(`${box}\n`);
141+
} catch {
142+
// Swallow EPIPE / write errors at exit
143+
}
144+
});
145+
}
74146

75-
process.on("exit", () => {
76-
try {
77-
process.stderr.write(`${box}\n`);
78-
} catch {
79-
// Best-effort; swallow write errors at exit (e.g. EPIPE)
80-
}
81-
});
147+
// Refresh cache in a detached child process so the CLI can exit immediately.
148+
// Uses only Node built-ins (fetch + fs) to avoid module resolution issues.
149+
if (!cache || Date.now() - cache.checkedAt >= UPDATE_CHECK_INTERVAL_MS) {
150+
spawnBackgroundCheck();
151+
}
82152
} catch {
83153
// Never let the update check interfere with normal operation
84154
}

0 commit comments

Comments
 (0)