From 698efdcef8a4ff936ca31a4a9c30dbb180164eee Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Sat, 6 Jun 2026 10:42:10 +0200 Subject: [PATCH] feat(cli): add channel-aware update notifier (#265) * feat(cli): show cached update notices after commands * docs(cli): describe update notices * fix(cli): type update check environment * fix(cli): decouple update notice display from refresh and harden suppression Display a cached "update available" notice based solely on the lastNoticeAt 24h throttle, independent of checkedAt refresh freshness, matching the design's independent display/refresh decisions. Suppress the check unconditionally under --json, CI, and non-TTY before consulting output-mode preferences, so a KTX_OUTPUT=pretty override can no longer make CI/non-TTY contexts phone npm. --- docs-site/content/docs/cli-reference/ktx.mdx | 38 ++ packages/cli/package.json | 2 + packages/cli/src/clack.ts | 28 +- packages/cli/src/cli-program.ts | 28 +- packages/cli/src/update-check/cache.ts | 45 +++ packages/cli/src/update-check/channel.ts | 43 +++ packages/cli/src/update-check/registry.ts | 52 +++ packages/cli/src/update-check/update-check.ts | 187 ++++++++++ packages/cli/test/update-check/cache.test.ts | 95 +++++ .../cli/test/update-check/channel.test.ts | 57 +++ .../cli/test/update-check/cli-program.test.ts | 152 ++++++++ .../cli/test/update-check/registry.test.ts | 80 +++++ .../test/update-check/update-check.test.ts | 332 ++++++++++++++++++ pnpm-lock.yaml | 18 + 14 files changed, 1153 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/update-check/cache.ts create mode 100644 packages/cli/src/update-check/channel.ts create mode 100644 packages/cli/src/update-check/registry.ts create mode 100644 packages/cli/src/update-check/update-check.ts create mode 100644 packages/cli/test/update-check/cache.test.ts create mode 100644 packages/cli/test/update-check/channel.test.ts create mode 100644 packages/cli/test/update-check/cli-program.test.ts create mode 100644 packages/cli/test/update-check/registry.test.ts create mode 100644 packages/cli/test/update-check/update-check.test.ts diff --git a/docs-site/content/docs/cli-reference/ktx.mdx b/docs-site/content/docs/cli-reference/ktx.mdx index 8b9a2cc5..ebdeb1c6 100644 --- a/docs-site/content/docs/cli-reference/ktx.mdx +++ b/docs-site/content/docs/cli-reference/ktx.mdx @@ -74,6 +74,44 @@ The public context-build entrypoint is `ktx ingest [connectionId]` or | `-v`, `--version` | Show the CLI package name and version. | | `-h`, `--help` | Show help for the current command. | +## Update notices + +> **Note:** The update notifier writes only to stderr and keeps command stdout +> unchanged. + +When a newer package is available on your installed release channel, `ktx` +prints a short notice after the command finishes: + +```text +↑ Update available: ktx 0.9.0 → 0.10.0 + npm i -g @kaelio/ktx +``` + +Stable installs compare against the npm `latest` dist-tag. +Release-candidate installs compare against the `next` dist-tag and show: + +```text +npm i -g @kaelio/ktx@next +``` + +The check is skipped for JSON output, CI, non-TTY stdout, and hidden completion +commands. To opt out explicitly, set any of these environment variables: + +```bash +KTX_NO_UPDATE_CHECK=1 +NO_UPDATE_NOTIFIER=1 +DO_NOT_TRACK=1 +``` + +The `ktx` CLI prints one npm command because globally installed binaries don't +expose a reliable runtime package-manager signal. If you prefer another global +package manager, use the equivalent command: + +```bash +pnpm add -g @kaelio/ktx +yarn global add @kaelio/ktx +``` + ## Project resolution Most commands are project-aware. Pass `--project-dir ` when scripting or diff --git a/packages/cli/package.json b/packages/cli/package.json index ba769d58..3255d4c2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -73,6 +73,7 @@ "pg": "^8.21.0", "posthog-node": "^5.34.9", "react": "^19.2.6", + "semver": "^7.8.1", "simple-git": "3.36.0", "snowflake-sdk": "^2.4.2", "yaml": "^2.9.0", @@ -86,6 +87,7 @@ "@types/node": "^25.9.1", "@types/pg": "^8.20.0", "@types/react": "^19.2.15", + "@types/semver": "^7.7.1", "@vitest/coverage-v8": "^4.1.7", "ajv": "8.20.0", "ink-testing-library": "^4.0.0", diff --git a/packages/cli/src/clack.ts b/packages/cli/src/clack.ts index 2ad51e6c..31be2e1b 100644 --- a/packages/cli/src/clack.ts +++ b/packages/cli/src/clack.ts @@ -3,6 +3,30 @@ import type { KtxCliIo } from './cli-runtime.js'; const ESC = String.fromCharCode(0x1b); +export interface CliStyleEnv { + NO_COLOR?: string; + TERM?: string; +} + +function ansiEnabled(env: CliStyleEnv = process.env): boolean { + return !env.NO_COLOR && env.TERM !== 'dumb'; +} + +function ansiColor(text: string, open: number, close: number, env?: CliStyleEnv): string { + if (!ansiEnabled(env)) { + return text; + } + return `${ESC}[${open}m${text}${ESC}[${close}m`; +} + +export function dim(text: string, env?: CliStyleEnv): string { + return ansiColor(text, 2, 22, env); +} + +export function cyan(text: string, env?: CliStyleEnv): string { + return ansiColor(text, 36, 39, env); +} + export interface RailBufferedSource { stdoutText(): string; stderrText(): string; @@ -61,11 +85,11 @@ export function createClackSpinner(): KtxCliSpinner { } function magenta(text: string): string { - return `${ESC}[35m${text}${ESC}[39m`; + return ansiColor(text, 35, 39); } function red(text: string): string { - return `${ESC}[31m${text}${ESC}[39m`; + return ansiColor(text, 31, 39); } export function createStaticCliSpinner(io: KtxCliSpinnerIo): KtxCliSpinner { diff --git a/packages/cli/src/cli-program.ts b/packages/cli/src/cli-program.ts index 3f1b27e4..6359d897 100644 --- a/packages/cli/src/cli-program.ts +++ b/packages/cli/src/cli-program.ts @@ -16,6 +16,7 @@ import { renderMissingProjectMessage } from './doctor.js'; import { findNearestKtxProjectDir, resolveKtxProjectDir } from './project-resolver.js'; import { profileMark, profileSpan } from './startup-profile.js'; import type { CommandOutcome } from './telemetry/index.js'; +import { prepareUpdateCheckNotice, type PrepareUpdateCheckNoticeOptions } from './update-check/update-check.js'; profileMark('module:cli-program'); @@ -39,6 +40,8 @@ interface KtxCommanderProgramOptions { runInit: (args: { projectDir: string; force: boolean }, io: KtxCliIo) => Promise; } +type KtxCliUpdateCheckOptions = Pick; + export interface BuildKtxProgramOptions { io: KtxCliIo; deps: KtxCliDeps; @@ -47,6 +50,7 @@ export interface BuildKtxProgramOptions { setExitCode?: (code: number) => void; argv?: string[]; setTelemetryModule?: (telemetry: typeof import('./telemetry/index.js')) => void; + updateCheck?: KtxCliUpdateCheckOptions; } type CommanderExitLike = { exitCode: number; code: string; message: string }; @@ -431,16 +435,29 @@ export function collectCommandFlagsPresent(command: CommandUnknownOpts): Record< export function buildKtxProgram(options: BuildKtxProgramOptions): Command { const program = createBaseProgram(options.packageInfo, options.io); + let pendingUpdateNotice: string | null = null; + program.hook('preAction', async (_thisCommand, actionCommand) => { // The hidden completion command must stay silent and side-effect free: skip - // the telemetry notice, command span, and project checks entirely. + // the telemetry notice, command span, project checks, and update checks entirely. if (commandPath(actionCommand as CommandPathNode).includes('__complete')) { return; } + const commandNode = actionCommand as CommandPathNode; + const updateCheck = await prepareUpdateCheckNotice({ + io: options.io, + env: options.updateCheck?.env, + fetchDistTags: options.updateCheck?.fetchDistTags, + homeDir: options.updateCheck?.homeDir, + installedVersion: options.packageInfo.version, + now: options.updateCheck?.now, + commandOptions: commandOptions(commandNode), + }); + pendingUpdateNotice = updateCheck.notice; + const telemetry = await import('./telemetry/index.js'); options.setTelemetryModule?.(telemetry); await telemetry.showTelemetryNoticeIfNeeded(options.io, options.packageInfo); - const commandNode = actionCommand as CommandPathNode; const path = commandPath(commandNode); const projectDir = resolveCommandProjectDir(commandNode); const hasProject = ktxYamlExists(projectDir); @@ -457,6 +474,13 @@ export function buildKtxProgram(options: BuildKtxProgramOptions): Command { ensureProjectAvailable(options.io, commandNode); }); + program.hook('postAction', () => { + if (pendingUpdateNotice) { + options.io.stderr.write(pendingUpdateNotice); + pendingUpdateNotice = null; + } + }); + const context: KtxCliCommandContext = { io: options.io, deps: options.deps, diff --git a/packages/cli/src/update-check/cache.ts b/packages/cli/src/update-check/cache.ts new file mode 100644 index 00000000..19ebf07a --- /dev/null +++ b/packages/cli/src/update-check/cache.ts @@ -0,0 +1,45 @@ +import { renameSync, writeFileSync } from 'node:fs'; +import { mkdir, readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { z } from 'zod'; + +const updateCheckCacheSchema = z + .object({ + checkedAt: z.string(), + channel: z.enum(['latest', 'next']), + installedVersion: z.string(), + latestForChannel: z.string(), + lastNoticeAt: z.string().optional(), + }) + .strict(); + +export type UpdateCheckCache = z.infer; + +/** @internal */ +export function updateCheckCachePath(homeDir = homedir()): string { + return join(homeDir, '.ktx', 'update-check.json'); +} + +export async function readUpdateCheckCache(options: { homeDir?: string } = {}): Promise { + try { + return updateCheckCacheSchema.parse(JSON.parse(await readFile(updateCheckCachePath(options.homeDir), 'utf-8'))); + } catch { + return null; + } +} + +export async function writeUpdateCheckCache( + value: UpdateCheckCache, + options: { homeDir?: string } = {}, +): Promise { + try { + const path = updateCheckCachePath(options.homeDir); + await mkdir(dirname(path), { recursive: true }); + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf-8'); + renameSync(tempPath, path); + } catch { + return; + } +} diff --git a/packages/cli/src/update-check/channel.ts b/packages/cli/src/update-check/channel.ts new file mode 100644 index 00000000..d8251021 --- /dev/null +++ b/packages/cli/src/update-check/channel.ts @@ -0,0 +1,43 @@ +import semver from 'semver'; + +export type UpdateChannel = 'latest' | 'next'; + +export type UpdateDecision = + | { status: 'skip' } + | { status: 'upToDate'; channel: UpdateChannel; target: string } + | { status: 'available'; channel: UpdateChannel; target: string }; + +/** @internal */ +export function inferUpdateChannel(installed: string): UpdateChannel | null { + const parsed = semver.parse(installed); + if (!parsed || installed === '0.0.0') { + return null; + } + + const [prereleaseId] = parsed.prerelease; + if (prereleaseId === undefined) { + return 'latest'; + } + if (prereleaseId === 'rc') { + return 'next'; + } + return null; +} + +export function decideUpdate(installed: string, distTags: Record): UpdateDecision { + const channel = inferUpdateChannel(installed); + if (!channel || !semver.valid(installed)) { + return { status: 'skip' }; + } + + const target = distTags[channel]; + if (!target || !semver.valid(target)) { + return { status: 'skip' }; + } + + if (semver.gt(target, installed)) { + return { status: 'available', channel, target }; + } + + return { status: 'upToDate', channel, target }; +} diff --git a/packages/cli/src/update-check/registry.ts b/packages/cli/src/update-check/registry.ts new file mode 100644 index 00000000..f0934933 --- /dev/null +++ b/packages/cli/src/update-check/registry.ts @@ -0,0 +1,52 @@ +import { request as httpsRequest } from 'node:https'; +import { URL } from 'node:url'; +import { z } from 'zod'; + +const DIST_TAGS_URL = new URL('https://registry.npmjs.org/-/package/@kaelio/ktx/dist-tags'); +const distTagsSchema = z.record(z.string(), z.string()); + +function parseDistTags(raw: string): Record { + return distTagsSchema.parse(JSON.parse(raw)); +} + +export function fetchDistTags(): Promise> { + return new Promise((resolve, reject) => { + const request = httpsRequest( + DIST_TAGS_URL, + { + method: 'GET', + headers: { + accept: 'application/json', + }, + }, + (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + response.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + const statusCode = response.statusCode ?? 0; + if (statusCode < 200 || statusCode >= 300) { + reject(new Error(`npm dist-tags request failed with ${statusCode}: ${text}`)); + return; + } + try { + resolve(parseDistTags(text)); + } catch (error) { + reject(error); + } + }); + }, + ); + + request.on('socket', (socket) => { + socket.unref(); + }); + request.on('error', reject); + request.setTimeout(5000, () => { + request.destroy(new Error('npm dist-tags request timed out')); + }); + request.end(); + }); +} diff --git a/packages/cli/src/update-check/update-check.ts b/packages/cli/src/update-check/update-check.ts new file mode 100644 index 00000000..611a43a3 --- /dev/null +++ b/packages/cli/src/update-check/update-check.ts @@ -0,0 +1,187 @@ +import type { KtxCliIo } from '../cli-runtime.js'; +import { cyan, dim, type CliStyleEnv } from '../clack.js'; +import { resolveOutputMode } from '../io/mode.js'; +import { type UpdateCheckCache, readUpdateCheckCache, writeUpdateCheckCache } from './cache.js'; +import { decideUpdate, inferUpdateChannel, type UpdateChannel } from './channel.js'; +import { fetchDistTags as defaultFetchDistTags } from './registry.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** @internal */ +export interface UpdateCheckEnv extends NodeJS.ProcessEnv, CliStyleEnv { + CI?: string; + DO_NOT_TRACK?: string; + KTX_NO_UPDATE_CHECK?: string; + KTX_OUTPUT?: string; + NO_UPDATE_NOTIFIER?: string; +} + +/** @internal */ +export interface UpdateCheckCommandOptions { + format?: unknown; + json?: unknown; + output?: unknown; +} + +export interface PrepareUpdateCheckNoticeOptions { + commandOptions?: UpdateCheckCommandOptions; + env?: UpdateCheckEnv; + fetchDistTags?: () => Promise>; + homeDir?: string; + installedVersion: string; + io: KtxCliIo; + now?: () => Date; +} + +export interface PreparedUpdateCheckNotice { + notice: string | null; +} + +function truthy(value: string | undefined): boolean { + return value !== undefined && value !== '' && value !== '0' && value !== 'false'; +} + +function commandRequestsJson(options: UpdateCheckCommandOptions | undefined): boolean { + return options?.json === true || options?.output === 'json' || options?.format === 'json'; +} + +/** @internal */ +export function shouldSuppressUpdateCheck(args: { + commandOptions?: UpdateCheckCommandOptions; + env?: UpdateCheckEnv; + io: KtxCliIo; +}): boolean { + const env = args.env ?? process.env; + if (truthy(env.KTX_NO_UPDATE_CHECK) || truthy(env.NO_UPDATE_NOTIFIER) || truthy(env.DO_NOT_TRACK)) { + return true; + } + + if (commandRequestsJson(args.commandOptions) || truthy(env.CI) || args.io.stdout.isTTY !== true) { + return true; + } + + try { + const mode = resolveOutputMode({ + json: false, + io: args.io, + env, + }); + return mode !== 'pretty'; + } catch { + return true; + } +} + +/** @internal */ +export function renderUpdateNotice(args: { + channel: UpdateChannel; + env?: CliStyleEnv; + installedVersion: string; + targetVersion: string; +}): string { + const command = args.channel === 'next' ? 'npm i -g @kaelio/ktx@next' : 'npm i -g @kaelio/ktx'; + return `${cyan('↑', args.env)} Update available: ktx ${args.installedVersion} → ${args.targetVersion}\n ${dim(command, args.env)}\n`; +} + +function timestampMs(value: string | undefined): number | null { + if (!value) { + return null; + } + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +} + +function elapsedAtLeast(value: string | undefined, now: Date, intervalMs: number): boolean { + const previous = timestampMs(value); + if (previous === null) { + return true; + } + return now.getTime() - previous >= intervalMs; +} + +function shouldRefreshCache(cache: UpdateCheckCache | null, installedVersion: string, now: Date): boolean { + if (!cache || cache.installedVersion !== installedVersion) { + return true; + } + return elapsedAtLeast(cache.checkedAt, now, DAY_MS); +} + +async function refreshUpdateCache(args: { + cache: UpdateCheckCache | null; + fetchDistTags: () => Promise>; + homeDir?: string; + installedVersion: string; + now: Date; +}): Promise { + const distTags = await args.fetchDistTags(); + const decision = decideUpdate(args.installedVersion, distTags); + if (decision.status === 'skip') { + return; + } + + await writeUpdateCheckCache( + { + checkedAt: args.now.toISOString(), + channel: decision.channel, + installedVersion: args.installedVersion, + latestForChannel: decision.target, + ...(args.cache?.installedVersion === args.installedVersion && args.cache.channel === decision.channel + ? { lastNoticeAt: args.cache.lastNoticeAt } + : {}), + }, + { homeDir: args.homeDir }, + ); +} + +export async function prepareUpdateCheckNotice( + options: PrepareUpdateCheckNoticeOptions, +): Promise { + const env = options.env ?? process.env; + const now = (options.now ?? (() => new Date()))(); + const fetchDistTags = options.fetchDistTags ?? defaultFetchDistTags; + + if ( + shouldSuppressUpdateCheck({ + commandOptions: options.commandOptions, + env, + io: options.io, + }) + ) { + return { notice: null }; + } + + if (!inferUpdateChannel(options.installedVersion)) { + return { notice: null }; + } + + let cache = await readUpdateCheckCache({ homeDir: options.homeDir }); + let notice: string | null = null; + + if (cache?.installedVersion === options.installedVersion) { + const decision = decideUpdate(options.installedVersion, { + [cache.channel]: cache.latestForChannel, + }); + if (decision.status === 'available' && elapsedAtLeast(cache.lastNoticeAt, now, DAY_MS)) { + notice = renderUpdateNotice({ + channel: decision.channel, + env, + installedVersion: options.installedVersion, + targetVersion: decision.target, + }); + cache = { ...cache, lastNoticeAt: now.toISOString() }; + await writeUpdateCheckCache(cache, { homeDir: options.homeDir }); + } + } + + if (shouldRefreshCache(cache, options.installedVersion, now)) { + void refreshUpdateCache({ + cache, + fetchDistTags, + homeDir: options.homeDir, + installedVersion: options.installedVersion, + now, + }).catch(() => {}); + } + + return { notice }; +} diff --git a/packages/cli/test/update-check/cache.test.ts b/packages/cli/test/update-check/cache.test.ts new file mode 100644 index 00000000..446a62be --- /dev/null +++ b/packages/cli/test/update-check/cache.test.ts @@ -0,0 +1,95 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + readUpdateCheckCache, + updateCheckCachePath, + writeUpdateCheckCache, +} from '../../src/update-check/cache.js'; + +describe('update-check cache', () => { + let homeDir: string; + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'ktx-update-check-cache-')); + }); + + afterEach(async () => { + await rm(homeDir, { recursive: true, force: true }); + }); + + it('uses ~/.ktx/update-check.json', () => { + expect(updateCheckCachePath(homeDir)).toBe(join(homeDir, '.ktx', 'update-check.json')); + }); + + it('round-trips strict cache data', async () => { + await writeUpdateCheckCache( + { + checkedAt: '2026-06-06T10:00:00.000Z', + channel: 'latest', + installedVersion: '0.9.0', + latestForChannel: '0.10.0', + lastNoticeAt: '2026-06-06T11:00:00.000Z', + }, + { homeDir }, + ); + + await expect(readUpdateCheckCache({ homeDir })).resolves.toEqual({ + checkedAt: '2026-06-06T10:00:00.000Z', + channel: 'latest', + installedVersion: '0.9.0', + latestForChannel: '0.10.0', + lastNoticeAt: '2026-06-06T11:00:00.000Z', + }); + }); + + it('returns null when the cache file is missing', async () => { + await expect(readUpdateCheckCache({ homeDir })).resolves.toBeNull(); + }); + + it('returns null when the cache file is corrupt JSON', async () => { + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + await writeFile(updateCheckCachePath(homeDir), '{bad json', 'utf-8'); + + await expect(readUpdateCheckCache({ homeDir })).resolves.toBeNull(); + }); + + it('returns null when the cache has unknown fields', async () => { + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + await writeFile( + updateCheckCachePath(homeDir), + JSON.stringify( + { + checkedAt: '2026-06-06T10:00:00.000Z', + channel: 'latest', + installedVersion: '0.9.0', + latestForChannel: '0.10.0', + unexpected: true, + }, + null, + 2, + ), + 'utf-8', + ); + + await expect(readUpdateCheckCache({ homeDir })).resolves.toBeNull(); + }); + + it('writes formatted JSON with a trailing newline', async () => { + await writeUpdateCheckCache( + { + checkedAt: '2026-06-06T10:00:00.000Z', + channel: 'next', + installedVersion: '0.10.0-rc.1', + latestForChannel: '0.10.0-rc.2', + }, + { homeDir }, + ); + + const raw = await readFile(updateCheckCachePath(homeDir), 'utf-8'); + expect(raw).toContain('"channel": "next"'); + expect(raw.endsWith('\n')).toBe(true); + }); +}); diff --git a/packages/cli/test/update-check/channel.test.ts b/packages/cli/test/update-check/channel.test.ts new file mode 100644 index 00000000..f7b4a1e6 --- /dev/null +++ b/packages/cli/test/update-check/channel.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { decideUpdate, inferUpdateChannel } from '../../src/update-check/channel.js'; + +describe('inferUpdateChannel', () => { + it.each([ + ['0.9.0', 'latest'], + ['0.10.0-rc.3', 'next'], + ['0.10.0-myfeat.2', null], + ['0.0.0', null], + ['not-a-version', null], + ])('maps %s to %s', (installed, expected) => { + expect(inferUpdateChannel(installed)).toBe(expected); + }); +}); + +describe('decideUpdate', () => { + it.each([ + [ + 'stable behind', + '0.9.0', + { latest: '0.10.0', next: '0.11.0-rc.1' }, + { status: 'available', channel: 'latest', target: '0.10.0' }, + ], + [ + 'stable equal', + '0.10.0', + { latest: '0.10.0', next: '0.11.0-rc.1' }, + { status: 'upToDate', channel: 'latest', target: '0.10.0' }, + ], + [ + 'stable ahead', + '0.11.0', + { latest: '0.10.0', next: '0.11.0-rc.1' }, + { status: 'upToDate', channel: 'latest', target: '0.10.0' }, + ], + [ + 'rc behind', + '0.11.0-rc.1', + { latest: '0.10.0', next: '0.11.0-rc.2' }, + { status: 'available', channel: 'next', target: '0.11.0-rc.2' }, + ], + [ + 'rc equal', + '0.11.0-rc.2', + { latest: '0.10.0', next: '0.11.0-rc.2' }, + { status: 'upToDate', channel: 'next', target: '0.11.0-rc.2' }, + ], + ['branch prerelease', '0.11.0-myfeat.1', { latest: '0.10.0', next: '0.11.0-rc.2' }, { status: 'skip' }], + ['missing channel tag', '0.9.0', { next: '0.11.0-rc.2' }, { status: 'skip' }], + ['invalid installed version', 'bad', { latest: '0.10.0' }, { status: 'skip' }], + ['invalid target version', '0.9.0', { latest: 'bad' }, { status: 'skip' }], + ['local development version', '0.0.0', { latest: '0.10.0' }, { status: 'skip' }], + ])('%s', (_name, installed, distTags, expected) => { + expect(decideUpdate(installed, distTags)).toEqual(expected); + }); +}); diff --git a/packages/cli/test/update-check/cli-program.test.ts b/packages/cli/test/update-check/cli-program.test.ts new file mode 100644 index 00000000..78116f97 --- /dev/null +++ b/packages/cli/test/update-check/cli-program.test.ts @@ -0,0 +1,152 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { buildKtxProgram } from '../../src/cli-program.js'; +import type { KtxCliDeps, KtxCliIo, KtxCliPackageInfo } from '../../src/cli-runtime.js'; +import { updateCheckCachePath } from '../../src/update-check/cache.js'; + +function makeIo(stdoutIsTTY = true): { io: KtxCliIo; stdout: () => string; stderr: () => string } { + let stdout = ''; + let stderr = ''; + return { + io: { + stdout: { + isTTY: stdoutIsTTY, + write: (chunk) => { + stdout += chunk; + }, + }, + stderr: { + write: (chunk) => { + stderr += chunk; + }, + }, + }, + stdout: () => stdout, + stderr: () => stderr, + }; +} + +describe('cli-program update check hooks', () => { + let projectDir: string; + let homeDir: string; + const info: KtxCliPackageInfo = { name: '@kaelio/ktx', version: '0.9.0' }; + + beforeEach(async () => { + projectDir = await mkdtemp(join(tmpdir(), 'ktx-update-project-')); + homeDir = await mkdtemp(join(tmpdir(), 'ktx-update-home-')); + await writeFile(join(projectDir, 'ktx.yaml'), '{}\n', 'utf-8'); + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + vi.stubEnv('KTX_TELEMETRY_DISABLED', '1'); + vi.stubEnv('CI', ''); + vi.stubEnv('DO_NOT_TRACK', ''); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await rm(projectDir, { recursive: true, force: true }); + await rm(homeDir, { recursive: true, force: true }); + }); + + it('prints a stale-cache notice without awaiting the background refresh', async () => { + await writeFile( + updateCheckCachePath(homeDir), + JSON.stringify( + { + checkedAt: '2026-06-05T10:00:00.000Z', + channel: 'latest', + installedVersion: '0.9.0', + latestForChannel: '0.10.0', + }, + null, + 2, + ), + 'utf-8', + ); + const io = makeIo(true); + const deps: KtxCliDeps = { doctor: async () => 0 }; + const fetchDistTags = vi.fn( + () => + new Promise>(() => { + return; + }), + ); + const program = buildKtxProgram({ + io: io.io, + deps, + packageInfo: info, + runInit: async () => 0, + updateCheck: { + env: { NO_COLOR: '1' }, + fetchDistTags, + homeDir, + now: () => new Date('2026-06-06T12:00:00.000Z'), + }, + }); + + await program.parseAsync(['--project-dir', projectDir, 'status'], { from: 'user' }); + + expect(fetchDistTags).toHaveBeenCalledTimes(1); + expect(io.stderr()).toContain('↑ Update available: ktx 0.9.0 → 0.10.0\n npm i -g @kaelio/ktx\n'); + }); + + it('prints a queued fresh-cache notice after the action', async () => { + await writeFile( + updateCheckCachePath(homeDir), + JSON.stringify( + { + checkedAt: '2026-06-06T11:00:00.000Z', + channel: 'latest', + installedVersion: '0.9.0', + latestForChannel: '0.10.0', + }, + null, + 2, + ), + 'utf-8', + ); + const io = makeIo(true); + const fetchDistTags = vi.fn(async () => ({ latest: '0.10.0' })); + const program = buildKtxProgram({ + io: io.io, + deps: { doctor: async () => 0 }, + packageInfo: info, + runInit: async () => 0, + updateCheck: { + env: { NO_COLOR: '1' }, + fetchDistTags, + homeDir, + now: () => new Date('2026-06-06T12:00:00.000Z'), + }, + }); + + await program.parseAsync(['--project-dir', projectDir, 'status'], { from: 'user' }); + + expect(fetchDistTags).not.toHaveBeenCalled(); + expect(io.stderr()).toContain('↑ Update available: ktx 0.9.0 → 0.10.0\n npm i -g @kaelio/ktx\n'); + }); + + it('does not run update checks for the hidden completion command', async () => { + const io = makeIo(true); + const fetchDistTags = vi.fn(async () => ({ latest: '0.10.0' })); + const program = buildKtxProgram({ + io: io.io, + deps: {}, + packageInfo: info, + runInit: async () => 0, + updateCheck: { + env: { NO_COLOR: '1' }, + fetchDistTags, + homeDir, + now: () => new Date('2026-06-06T12:00:00.000Z'), + }, + }); + + await program.parseAsync(['__complete', '--', 'ktx', 'co'], { from: 'user' }); + + expect(fetchDistTags).not.toHaveBeenCalled(); + expect(io.stderr()).not.toContain('Update available'); + }); +}); diff --git a/packages/cli/test/update-check/registry.test.ts b/packages/cli/test/update-check/registry.test.ts new file mode 100644 index 00000000..a83d360d --- /dev/null +++ b/packages/cli/test/update-check/registry.test.ts @@ -0,0 +1,80 @@ +import { EventEmitter } from 'node:events'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const requestMock = vi.hoisted(() => vi.fn()); + +vi.mock('node:https', () => ({ + request: requestMock, +})); + +type MockResponse = EventEmitter & { statusCode?: number }; +type MockRequest = EventEmitter & { + destroy: ReturnType; + end: () => void; + setTimeout: ReturnType; +}; + +function mockHttpsResponse(statusCode: number, body: string): { socket: { unref: ReturnType } } { + const socket = { unref: vi.fn() }; + requestMock.mockImplementation((_url: unknown, _options: unknown, callback: (response: MockResponse) => void) => { + const request = new EventEmitter() as MockRequest; + request.destroy = vi.fn(); + request.setTimeout = vi.fn(); + request.end = () => { + request.emit('socket', socket); + const response = new EventEmitter() as MockResponse; + response.statusCode = statusCode; + callback(response); + response.emit('data', Buffer.from(body)); + response.emit('end'); + }; + return request; + }); + return { socket }; +} + +describe('fetchDistTags', () => { + beforeEach(() => { + requestMock.mockReset(); + }); + + it('fetches @kaelio/ktx npm dist-tags and unrefs the socket', async () => { + const { socket } = mockHttpsResponse(200, JSON.stringify({ latest: '0.10.0', next: '0.11.0-rc.1' })); + const { fetchDistTags } = await import('../../src/update-check/registry.js'); + + await expect(fetchDistTags()).resolves.toEqual({ latest: '0.10.0', next: '0.11.0-rc.1' }); + + expect(requestMock).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ accept: 'application/json' }), + }), + expect.any(Function), + ); + const [url] = requestMock.mock.calls[0] as [URL]; + expect(url.toString()).toBe('https://registry.npmjs.org/-/package/@kaelio/ktx/dist-tags'); + expect(socket.unref).toHaveBeenCalledTimes(1); + }); + + it('rejects non-2xx responses', async () => { + mockHttpsResponse(503, 'registry unavailable'); + const { fetchDistTags } = await import('../../src/update-check/registry.js'); + + await expect(fetchDistTags()).rejects.toThrow('npm dist-tags request failed with 503'); + }); + + it('rejects invalid JSON payloads', async () => { + mockHttpsResponse(200, '{bad json'); + const { fetchDistTags } = await import('../../src/update-check/registry.js'); + + await expect(fetchDistTags()).rejects.toThrow(); + }); + + it('rejects payloads that are not string dist-tag maps', async () => { + mockHttpsResponse(200, JSON.stringify({ latest: 123 })); + const { fetchDistTags } = await import('../../src/update-check/registry.js'); + + await expect(fetchDistTags()).rejects.toThrow(); + }); +}); diff --git a/packages/cli/test/update-check/update-check.test.ts b/packages/cli/test/update-check/update-check.test.ts new file mode 100644 index 00000000..a19b35bf --- /dev/null +++ b/packages/cli/test/update-check/update-check.test.ts @@ -0,0 +1,332 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { updateCheckCachePath } from '../../src/update-check/cache.js'; +import { + prepareUpdateCheckNotice, + renderUpdateNotice, + shouldSuppressUpdateCheck, +} from '../../src/update-check/update-check.js'; + +function makeIo(stdoutIsTTY = true) { + let stderr = ''; + return { + io: { + stdout: { + isTTY: stdoutIsTTY, + write: () => {}, + }, + stderr: { + write: (chunk: string) => { + stderr += chunk; + }, + }, + }, + stderr: () => stderr, + }; +} + +async function flushAsyncWork(): Promise { + await new Promise((resolve) => { + setImmediate(resolve); + }); + await new Promise((resolve) => { + setImmediate(resolve); + }); +} + +describe('update-check orchestration', () => { + let homeDir: string; + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'ktx-update-check-')); + }); + + afterEach(async () => { + await rm(homeDir, { recursive: true, force: true }); + }); + + it.each([ + ['json option', true, {}, { json: true }], + ['json output option', true, {}, { output: 'json' }], + ['json format option', true, {}, { format: 'json' }], + ['CI', true, { CI: '1' }, {}], + ['non-TTY stdout', false, {}, {}], + ['KTX_NO_UPDATE_CHECK', true, { KTX_NO_UPDATE_CHECK: '1' }, {}], + ['NO_UPDATE_NOTIFIER', true, { NO_UPDATE_NOTIFIER: '1' }, {}], + ['DO_NOT_TRACK', true, { DO_NOT_TRACK: '1' }, {}], + ])('suppresses cache and network work for %s', async (_name, stdoutIsTTY, env, commandOptions) => { + const fetchDistTags = vi.fn(async () => ({ latest: '0.10.0' })); + + const result = await prepareUpdateCheckNotice({ + io: makeIo(stdoutIsTTY).io, + env, + homeDir, + installedVersion: '0.9.0', + commandOptions, + now: () => new Date('2026-06-06T12:00:00.000Z'), + fetchDistTags, + }); + + expect(result.notice).toBeNull(); + expect(fetchDistTags).not.toHaveBeenCalled(); + await expect(readFile(updateCheckCachePath(homeDir), 'utf-8')).rejects.toThrow(); + }); + + it.each([ + ['CI', true, { CI: '1', KTX_OUTPUT: 'pretty' }], + ['non-TTY stdout', false, { KTX_OUTPUT: 'pretty' }], + ])('suppresses cache and network work for %s even when pretty output is forced', async (_name, stdoutIsTTY, env) => { + const fetchDistTags = vi.fn(async () => ({ latest: '0.10.0' })); + + const result = await prepareUpdateCheckNotice({ + io: makeIo(stdoutIsTTY).io, + env, + homeDir, + installedVersion: '0.9.0', + commandOptions: {}, + now: () => new Date('2026-06-06T12:00:00.000Z'), + fetchDistTags, + }); + + expect(result.notice).toBeNull(); + expect(fetchDistTags).not.toHaveBeenCalled(); + await expect(readFile(updateCheckCachePath(homeDir), 'utf-8')).rejects.toThrow(); + }); + + it('does not suppress when only KTX_TELEMETRY_DISABLED is set', () => { + expect( + shouldSuppressUpdateCheck({ + io: makeIo(true).io, + env: { KTX_TELEMETRY_DISABLED: '1' } as NodeJS.ProcessEnv, + commandOptions: {}, + }), + ).toBe(false); + }); + + it('renders a compact no-color stable notice', () => { + expect( + renderUpdateNotice({ + installedVersion: '0.9.0', + targetVersion: '0.10.0', + channel: 'latest', + env: { NO_COLOR: '1' }, + }), + ).toBe('↑ Update available: ktx 0.9.0 → 0.10.0\n npm i -g @kaelio/ktx\n'); + }); + + it('renders the next-channel install command', () => { + expect( + renderUpdateNotice({ + installedVersion: '0.10.0-rc.1', + targetVersion: '0.10.0-rc.2', + channel: 'next', + env: { NO_COLOR: '1' }, + }), + ).toBe('↑ Update available: ktx 0.10.0-rc.1 → 0.10.0-rc.2\n npm i -g @kaelio/ktx@next\n'); + }); + + it('queues a cached notice and stamps lastNoticeAt', async () => { + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + await writeFile( + updateCheckCachePath(homeDir), + JSON.stringify( + { + checkedAt: '2026-06-06T11:00:00.000Z', + channel: 'latest', + installedVersion: '0.9.0', + latestForChannel: '0.10.0', + }, + null, + 2, + ), + 'utf-8', + ); + const fetchDistTags = vi.fn(async () => ({ latest: '0.10.0' })); + + const result = await prepareUpdateCheckNotice({ + io: makeIo(true).io, + env: { NO_COLOR: '1' }, + homeDir, + installedVersion: '0.9.0', + now: () => new Date('2026-06-06T12:00:00.000Z'), + fetchDistTags, + }); + + expect(result.notice).toBe('↑ Update available: ktx 0.9.0 → 0.10.0\n npm i -g @kaelio/ktx\n'); + expect(fetchDistTags).not.toHaveBeenCalled(); + const stored = JSON.parse(await readFile(updateCheckCachePath(homeDir), 'utf-8')) as { lastNoticeAt?: string }; + expect(stored.lastNoticeAt).toBe('2026-06-06T12:00:00.000Z'); + }); + + it('queues a stale cached notice and still refreshes in the background', async () => { + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + await writeFile( + updateCheckCachePath(homeDir), + JSON.stringify( + { + checkedAt: '2026-06-05T10:00:00.000Z', + channel: 'latest', + installedVersion: '0.9.0', + latestForChannel: '0.10.0', + lastNoticeAt: '2026-06-05T11:00:00.000Z', + }, + null, + 2, + ), + 'utf-8', + ); + const fetchDistTags = vi.fn(async () => ({ latest: '0.11.0' })); + + const result = await prepareUpdateCheckNotice({ + io: makeIo(true).io, + env: { NO_COLOR: '1' }, + homeDir, + installedVersion: '0.9.0', + now: () => new Date('2026-06-06T12:00:00.000Z'), + fetchDistTags, + }); + + expect(result.notice).toBe('↑ Update available: ktx 0.9.0 → 0.10.0\n npm i -g @kaelio/ktx\n'); + expect(fetchDistTags).toHaveBeenCalledTimes(1); + + await flushAsyncWork(); + await vi.waitFor(async () => { + const stored = JSON.parse(await readFile(updateCheckCachePath(homeDir), 'utf-8')) as { + latestForChannel: string; + lastNoticeAt?: string; + }; + expect(stored.latestForChannel).toBe('0.11.0'); + expect(stored.lastNoticeAt).toBe('2026-06-06T12:00:00.000Z'); + }); + }); + + it('throttles a cached notice for 24 hours', async () => { + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + await writeFile( + updateCheckCachePath(homeDir), + JSON.stringify( + { + checkedAt: '2026-06-06T11:00:00.000Z', + channel: 'latest', + installedVersion: '0.9.0', + latestForChannel: '0.10.0', + lastNoticeAt: '2026-06-06T11:30:00.000Z', + }, + null, + 2, + ), + 'utf-8', + ); + + await expect( + prepareUpdateCheckNotice({ + io: makeIo(true).io, + env: { NO_COLOR: '1' }, + homeDir, + installedVersion: '0.9.0', + now: () => new Date('2026-06-06T12:00:00.000Z'), + fetchDistTags: vi.fn(async () => ({ latest: '0.10.0' })), + }), + ).resolves.toEqual({ notice: null }); + }); + + it('does not show stale cache after the installed version changes and schedules a refresh', async () => { + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + await writeFile( + updateCheckCachePath(homeDir), + JSON.stringify( + { + checkedAt: '2026-06-06T11:00:00.000Z', + channel: 'latest', + installedVersion: '0.9.0', + latestForChannel: '0.10.0', + }, + null, + 2, + ), + 'utf-8', + ); + const fetchDistTags = vi.fn(async () => ({ latest: '0.10.0' })); + + const result = await prepareUpdateCheckNotice({ + io: makeIo(true).io, + env: { NO_COLOR: '1' }, + homeDir, + installedVersion: '0.10.0', + now: () => new Date('2026-06-06T12:00:00.000Z'), + fetchDistTags, + }); + + expect(result.notice).toBeNull(); + expect(fetchDistTags).toHaveBeenCalledTimes(1); + }); + + it('refreshes stale cache in the background and preserves lastNoticeAt for the same install', async () => { + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + await writeFile( + updateCheckCachePath(homeDir), + JSON.stringify( + { + checkedAt: '2026-06-05T10:00:00.000Z', + channel: 'latest', + installedVersion: '0.9.0', + latestForChannel: '0.10.0', + lastNoticeAt: '2026-06-06T09:00:00.000Z', + }, + null, + 2, + ), + 'utf-8', + ); + + await prepareUpdateCheckNotice({ + io: makeIo(true).io, + env: { NO_COLOR: '1' }, + homeDir, + installedVersion: '0.9.0', + now: () => new Date('2026-06-06T12:00:00.000Z'), + fetchDistTags: vi.fn(async () => ({ latest: '0.11.0' })), + }); + await flushAsyncWork(); + + await vi.waitFor(async () => { + const stored = JSON.parse(await readFile(updateCheckCachePath(homeDir), 'utf-8')) as { + checkedAt: string; + latestForChannel: string; + lastNoticeAt?: string; + }; + expect(stored.checkedAt).toBe('2026-06-06T12:00:00.000Z'); + expect(stored.latestForChannel).toBe('0.11.0'); + expect(stored.lastNoticeAt).toBe('2026-06-06T09:00:00.000Z'); + }); + }); + + it('swallows refresh failures and leaves existing cache untouched', async () => { + await mkdir(join(homeDir, '.ktx'), { recursive: true }); + const originalCache = { + checkedAt: '2026-06-05T10:00:00.000Z', + channel: 'latest', + installedVersion: '0.9.0', + latestForChannel: '0.10.0', + lastNoticeAt: '2026-06-06T09:00:00.000Z', + }; + await writeFile(updateCheckCachePath(homeDir), JSON.stringify(originalCache, null, 2), 'utf-8'); + + await prepareUpdateCheckNotice({ + io: makeIo(true).io, + env: { NO_COLOR: '1' }, + homeDir, + installedVersion: '0.9.0', + now: () => new Date('2026-06-06T12:00:00.000Z'), + fetchDistTags: vi.fn(async () => { + throw new Error('offline'); + }), + }); + await flushAsyncWork(); + + await expect(readFile(updateCheckCachePath(homeDir), 'utf-8')).resolves.toBe(JSON.stringify(originalCache, null, 2)); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 871931c0..cc2fb3d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,6 +209,9 @@ importers: react: specifier: ^19.2.6 version: 19.2.6 + semver: + specifier: ^7.8.1 + version: 7.8.1 simple-git: specifier: 3.36.0 version: 3.36.0 @@ -243,6 +246,9 @@ importers: '@types/react': specifier: ^19.2.15 version: 19.2.15 + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 '@vitest/coverage-v8': specifier: ^4.1.7 version: 4.1.7(vitest@4.1.7) @@ -2501,6 +2507,9 @@ packages: '@types/readable-stream@4.0.23': resolution: {integrity: sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==} + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} @@ -5219,6 +5228,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -8321,6 +8335,8 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/semver@7.7.1': {} + '@types/triple-beam@1.3.5': {} '@types/unist@2.0.11': {} @@ -11433,6 +11449,8 @@ snapshots: semver@7.8.0: {} + semver@7.8.1: {} + send@1.2.1: dependencies: debug: 4.4.3