aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
236 lines (217 loc) • 7.63 kB
JavaScript
/**
* AIWG Update Notifier
*
* Non-blocking update check for the AIWG CLI. Follows the
* `update-notifier` / `simple-update-notifier` pattern:
*
* 1. On CLI invocation, `scheduleBackgroundCheck()` is called from
* `bin/aiwg.mjs`. It spawns a **detached, unref()'d** child Node
* process that runs this file with `--check` and writes the result
* to `~/.aiwg/update-notifier.json`. The child exits immediately
* after writing; the parent never waits on it.
*
* 2. On the *next* invocation, `maybePrintNotice()` reads the cache
* and prints a single-line "update available" message to stderr.
* The user's current command is never delayed by a network call.
*
* The update check honours `NO_UPDATE_NOTIFIER`, `CI`, the isInteractive()
* helper, and `--no-update-check`. In all of those cases the check is
* skipped entirely.
*
* Phase 3 of the CLI Stabilization Epic (#920). Replaces the synchronous
* `checkForUpdates()` + hand-rolled prompt in `checker.mjs` that held the
* event loop open after the command completed.
*
* @module src/update/notifier
*/
import https from 'https';
import path from 'path';
import os from 'os';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { spawn } from 'child_process';
import { fileURLToPath } from 'url';
const CACHE_DIR = path.join(os.homedir(), '.aiwg');
const CACHE_FILE = path.join(CACHE_DIR, 'update-notifier.json');
/**
* Minimum time between update checks (24 hours). Checked via the
* `lastCheckAt` timestamp in the cache file.
*/
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
/**
* Hard timeout for the npm registry HTTPS request. Keeps the spawned
* background child from lingering on slow networks.
*/
const NETWORK_TIMEOUT_MS = 10_000;
/**
* Read an env var and return true iff it's set to a truthy value.
*/
function envFlag(name) {
const v = process.env[name];
if (!v) return false;
return v !== '0' && v.toLowerCase() !== 'false';
}
/**
* True iff update checking is currently disabled by env / CI conventions.
*/
function isDisabled() {
if (envFlag('NO_UPDATE_NOTIFIER')) return true;
if (envFlag('AIWG_NO_UPDATE_CHECK')) return true;
if (envFlag('CI')) return true;
if (envFlag('GITHUB_ACTIONS')) return true;
if (envFlag('GITLAB_CI')) return true;
// Non-interactive terminals shouldn't get a notice they can't act on.
if (!process.stdout.isTTY) return true;
return false;
}
/**
* Read the update-notifier cache. Returns null if absent or malformed.
*/
function readCache() {
if (!existsSync(CACHE_FILE)) return null;
try {
return JSON.parse(readFileSync(CACHE_FILE, 'utf-8'));
} catch {
return null;
}
}
function writeCache(data) {
try {
mkdirSync(CACHE_DIR, { recursive: true });
writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2) + '\n', 'utf-8');
} catch {
// Best-effort — update-notifier failures are never fatal.
}
}
/**
* Compare two CalVer/semver-style versions. Returns true iff `latest` is
* newer than `current`. Cheap, no full semver parsing.
*/
function isNewer(current, latest) {
if (!current || !latest || current === latest) return false;
const parse = (v) => {
const match = String(v).match(/^(\d+)\.(\d+)\.(\d+)/);
if (!match) return [0, 0, 0];
return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)];
};
const [cy, cm, cp] = parse(current);
const [ly, lm, lp] = parse(latest);
if (ly > cy) return true;
if (ly === cy && lm > cm) return true;
if (ly === cy && lm === cm && lp > cp) return true;
return false;
}
/**
* Fetch the latest published version for the `latest` dist-tag from npm.
* Resolves to `null` on error or timeout.
*/
function fetchLatestVersion() {
return new Promise((resolve) => {
const req = https.get(
'https://registry.npmjs.org/aiwg',
{ timeout: NETWORK_TIMEOUT_MS },
(res) => {
if (res.statusCode !== 200) {
resolve(null);
return;
}
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const pkg = JSON.parse(data);
resolve(pkg['dist-tags']?.latest ?? null);
} catch {
resolve(null);
}
});
},
);
req.on('error', () => resolve(null));
req.on('timeout', () => {
req.destroy();
resolve(null);
});
});
}
/**
* Read current CLI version from the package.json at `packageRoot`.
*/
function readCurrentVersion(packageRoot) {
try {
const pkgPath = path.join(packageRoot, 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
return pkg.version;
} catch {
return null;
}
}
/**
* Run the actual update check and write the cache file. Invoked by the
* spawned background child via `node src/update/notifier.mjs --check`.
*/
async function runCheck(packageRoot) {
const currentVersion = readCurrentVersion(packageRoot);
const latestVersion = await fetchLatestVersion();
writeCache({
lastCheckAt: new Date().toISOString(),
current: currentVersion,
latest: latestVersion,
hasUpdate: !!(currentVersion && latestVersion && isNewer(currentVersion, latestVersion)),
});
}
/**
* Schedule a background update check if one hasn't run in the last
* CHECK_INTERVAL_MS. Non-blocking: spawns a detached child process and
* returns immediately. The parent must not wait on the child.
*
* Called exactly once per CLI invocation from `bin/aiwg.mjs`.
*/
export function scheduleBackgroundCheck(packageRoot) {
if (isDisabled()) return;
const cache = readCache();
if (cache?.lastCheckAt) {
const age = Date.now() - new Date(cache.lastCheckAt).getTime();
if (age < CHECK_INTERVAL_MS) return; // recent enough — skip
}
try {
const entry = fileURLToPath(import.meta.url);
const child = spawn(
process.execPath,
[entry, '--check', '--package-root', packageRoot],
{
detached: true,
stdio: 'ignore',
env: { ...process.env, AIWG_UPDATE_NOTIFIER_CHILD: '1' },
},
);
// Unref so the parent can exit without waiting for the child. This is
// the crucial detail that prevents the "command hangs for 5 minutes"
// symptom we had before #918.
child.unref();
} catch {
// Spawn failures are non-fatal; the next invocation will retry.
}
}
/**
* If the cache indicates an update is available AND the current terminal
* is interactive, print a single-line notice to stderr. Never prompts.
* Safe to call unconditionally from `bin/aiwg.mjs` — gated by isDisabled().
*/
export function maybePrintNotice() {
if (isDisabled()) return;
const cache = readCache();
if (!cache?.hasUpdate || !cache.current || !cache.latest) return;
// One-line, non-intrusive. Users who want to update run `aiwg update`.
const msg = `aiwg: update available ${cache.current} → ${cache.latest} (run: npm install -g aiwg)`;
process.stderr.write(`\n${msg}\n`);
}
// ── Child-process entry ────────────────────────────────────────────────
// If invoked with `--check`, act as the background-child worker.
if (process.argv.includes('--check')) {
const rootIdx = process.argv.indexOf('--package-root');
const packageRoot = rootIdx >= 0 ? process.argv[rootIdx + 1] : process.cwd();
runCheck(packageRoot).catch(() => {
// Swallow all errors — the background child must never write to stderr.
process.exit(0);
});
}