Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { pluginCmd } from './commands/plugin.js';
import { selfCmd } from './commands/self.js';
import { extractJsonFlag, setJsonMode } from './json-output.js';
import { extractAgentHelpFlag, printAgentHelp } from './agent-help.js';
import { getUpdateNotice } from './update-check.js';
import packageJson from '../../package.json';

const app = conciseSubcommands({
Expand All @@ -27,9 +28,22 @@ const { args: argsNoJson, json } = extractJsonFlag(rawArgs);
const { args: finalArgs, agentHelp } = extractAgentHelpFlag(argsNoJson);
setJsonMode(json);

// Kick off update check for non-json, non-agent-help invocations.
// Reads from local cache (fast), spawns a detached child to refresh if stale.
// The notice is printed on process exit so it appears after command output,
// even if the command calls process.exit() directly.
const isWizard = finalArgs.length === 0 && process.stdout.isTTY && !json;
let updateNotice: string | null = null;
if (!agentHelp && !json && !isWizard) {
process.on('exit', () => {
if (updateNotice) process.stderr.write(`\n${updateNotice}\n`);
});
getUpdateNotice(packageJson.version).then((n) => { updateNotice = n; });
}

if (agentHelp) {
printAgentHelp(finalArgs, packageJson.version);
} else if (finalArgs.length === 0 && process.stdout.isTTY && !json) {
} else if (isWizard) {
// Interactive wizard when no args and running in a terminal
const { runWizard } = await import('./tui/wizard.js');
await runWizard();
Expand Down
86 changes: 34 additions & 52 deletions src/cli/update-check.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import https from 'node:https';
import { spawn } from 'node:child_process';
import { getHomeDir, CONFIG_DIR } from '../constants.js';

const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
Expand Down Expand Up @@ -72,62 +72,44 @@ export function buildNotice(
}

/**
* Fetch the latest version from the npm registry.
* Uses raw https to avoid adding dependencies.
* Fire-and-forget background check. Spawns a detached child process that
* fetches latest version from npm and updates the cache file. The child
* survives even if the parent calls process.exit().
*/
function fetchLatestVersion(): Promise<string | null> {
return new Promise((resolve) => {
const req = https.get(NPM_REGISTRY_URL, { timeout: 5000 }, (res) => {
if (res.statusCode !== 200) {
res.resume(); // drain
resolve(null);
return;
}
export function backgroundUpdateCheck(): void {
const dir = join(getHomeDir(), CONFIG_DIR);
const filePath = join(dir, CACHE_FILE);

const script = `
const https = require('https');
const fs = require('fs');
const dir = ${JSON.stringify(dir)};
const filePath = ${JSON.stringify(filePath)};
https.get(${JSON.stringify(NPM_REGISTRY_URL)}, { timeout: 5000 }, (res) => {
if (res.statusCode !== 200) { res.resume(); process.exit(); }
let body = '';
res.on('data', (chunk: Buffer) => {
body += chunk.toString();
});
res.on('data', (c) => body += c);
res.on('end', () => {
try {
const data = JSON.parse(body);
resolve(typeof data.version === 'string' ? data.version : null);
} catch {
resolve(null);
}
const v = JSON.parse(body).version;
if (typeof v === 'string') {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, JSON.stringify({ latestVersion: v, lastCheckedAt: new Date().toISOString() }, null, 2));
}
} catch {}
process.exit();
});
});
req.on('error', () => resolve(null));
req.on('timeout', () => {
req.destroy();
resolve(null);
});
});
}
}).on('error', () => process.exit()).on('timeout', function() { this.destroy(); process.exit(); });
`;

/**
* Write the cache file.
*/
async function writeCache(cache: UpdateCache): Promise<void> {
const dir = join(getHomeDir(), CONFIG_DIR);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, CACHE_FILE), JSON.stringify(cache, null, 2));
}

/**
* Fire-and-forget background check. Fetches latest version from npm and
* updates the cache file. Errors are silently swallowed.
*/
export function backgroundUpdateCheck(): void {
fetchLatestVersion()
.then(async (version) => {
if (version) {
await writeCache({
latestVersion: version,
lastCheckedAt: new Date().toISOString(),
});
}
})
.catch(() => {});
try {
const child = spawn(process.execPath, ['-e', script], {
detached: true,
stdio: 'ignore',
windowsHide: true,
});
child.unref();
} catch {}
}

/**
Expand Down