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" ;
26import { promisify } from "node:util" ;
37
48import boxen from "boxen" ;
59import chalk from "chalk" ;
610import 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
1114import { CliError } from "./errors.js" ;
1215import { getSpinner } from "./utils/cli-env.js" ;
@@ -20,65 +23,132 @@ import {
2023
2124const 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
2598let 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 */
43110export 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