@@ -59,6 +60,8 @@ serves that context to agents at runtime.
+
+
## Use it for
Use **ktx** when agents need more than raw database access. Agents can search wiki
diff --git a/docs-site/tests/product-mechanics-content.test.mjs b/docs-site/tests/product-mechanics-content.test.mjs
index 5cce9001..d0c9471c 100644
--- a/docs-site/tests/product-mechanics-content.test.mjs
+++ b/docs-site/tests/product-mechanics-content.test.mjs
@@ -85,7 +85,7 @@ test("product mechanics component explains ingestion outputs", async () => {
"compile into SQL",
'"use client"',
"@xyflow/react",
- "
{
);
}
- assert.match(
- component,
+ // The ReactFlow canvas config lives in the shared FlowCanvas wrapper, which
+ // product-mechanics renders. Assert the static read-only behavior there.
+ const flowCanvas = await readDocsFile("components/flow-canvas.tsx");
+ for (const guard of [
/nodesDraggable=\{false\}/,
- "ReactFlow canvas should disable node dragging",
- );
- assert.match(
- component,
- /panOnDrag=\{false\}/,
- "ReactFlow canvas should disable panning",
- );
- assert.match(
- component,
+ /nodesConnectable=\{false\}/,
/zoomOnScroll=\{false\}/,
- "ReactFlow canvas should disable scroll zoom",
- );
+ /elementsSelectable=\{false\}/,
+ ]) {
+ assert.match(
+ flowCanvas,
+ guard,
+ `shared FlowCanvas should enforce static read-only behavior: ${guard}`,
+ );
+ }
assert.doesNotMatch(component, /raw-sources/);
assert.doesNotMatch(component, /\.ktx/);
diff --git a/docs-site/tests/product-runtime-content.test.mjs b/docs-site/tests/product-runtime-content.test.mjs
new file mode 100644
index 00000000..ac643faa
--- /dev/null
+++ b/docs-site/tests/product-runtime-content.test.mjs
@@ -0,0 +1,74 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import { test } from "node:test";
+import { fileURLToPath } from "node:url";
+
+const docsSiteDir = join(dirname(fileURLToPath(import.meta.url)), "..");
+
+async function readDocsFile(path) {
+ return readFile(join(docsSiteDir, path), "utf8");
+}
+
+test("docs introduction renders the serving phase after ingestion", async () => {
+ const introduction = await readDocsFile(
+ "content/docs/getting-started/introduction.mdx",
+ );
+
+ assert.match(
+ introduction,
+ /import\s+\{\s*ProductRuntime\s*\}\s+from\s+"@\/components\/product-runtime";/,
+ );
+ assert.match(introduction, //);
+
+ const mechanicsIndex = introduction.indexOf("");
+ const runtimeIndex = introduction.indexOf("");
+ const useCaseIndex = introduction.indexOf("## Use it for");
+
+ assert.ok(
+ runtimeIndex > mechanicsIndex,
+ "serving diagram should appear after the ingestion diagram",
+ );
+ assert.ok(
+ runtimeIndex < useCaseIndex,
+ "serving diagram should appear before use-case sections",
+ );
+});
+
+test("product runtime component explains the serving cycle", async () => {
+ const component = await readDocsFile("components/product-runtime.tsx");
+
+ for (const expectedText of [
+ "How serving works",
+ "Serving flow",
+ "From an agent request to a governed answer",
+ "Your agent",
+ "Claude Code",
+ "Cursor",
+ "Codex",
+ "Search wiki + semantic layer",
+ "Return approved metrics",
+ "Compile metrics → SQL",
+ "Context layer",
+ "Database",
+ "search + read",
+ "read-only",
+ "wiki/*.md",
+ "semantic-layer/*.yaml",
+ '"use client"',
+ "@xyflow/react",
+ "FlowCanvas",
+ "getSmoothStepPath",
+ "animateMotion",
+ "runtime-particle",
+ "buildCyclePath",
+ ]) {
+ assert.ok(
+ component.includes(expectedText),
+ `component should include: ${expectedText}`,
+ );
+ }
+
+ assert.doesNotMatch(component, /raw-sources/);
+ assert.doesNotMatch(component, /
Date: Sat, 6 Jun 2026 10:42:10 +0200
Subject: [PATCH 10/11] 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
From bf1fe9748e066058e94824498a5af769f5825bf5 Mon Sep 17 00:00:00 2001
From: Luca Martial <48870843+luca-martial@users.noreply.github.com>
Date: Sat, 6 Jun 2026 22:32:08 -0400
Subject: [PATCH 11/11] docs: minor README and docs-site touch-ups (#266)
- Link the Y Combinator badge and the docs "by Kaelio" label
- Add a maintainer line to the README
- Set the npm author field on @kaelio/ktx
Co-authored-by: Claude Opus 4.8
---
README.md | 6 ++-
docs-site/app/layout.config.tsx | 2 +-
docs-site/components/logo.tsx | 80 ++++++++++++++++++++-------------
packages/cli/package.json | 4 ++
4 files changed, 58 insertions(+), 34 deletions(-)
diff --git a/README.md b/README.md
index 67abe741..d286e3f1 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-
+
@@ -23,6 +23,10 @@
Slack
+
+ Built and maintained by Kaelio
+
+
---
**ktx** is a self-improving context layer that teaches agents how to query your
diff --git a/docs-site/app/layout.config.tsx b/docs-site/app/layout.config.tsx
index 3245ab09..28ba6b03 100644
--- a/docs-site/app/layout.config.tsx
+++ b/docs-site/app/layout.config.tsx
@@ -5,7 +5,7 @@ import { SlackIcon } from "@/components/slack-icon";
export const baseOptions: BaseLayoutProps = {
nav: {
- title: ,
+ title: Logo,
transparentMode: "top",
},
links: [
diff --git a/docs-site/components/logo.tsx b/docs-site/components/logo.tsx
index afc926a8..77370280 100644
--- a/docs-site/components/logo.tsx
+++ b/docs-site/components/logo.tsx
@@ -1,40 +1,56 @@
-export function Logo() {
+"use client";
+
+import Link from "next/link";
+
+const brandFont = {
+ fontFamily: "var(--font-display), var(--font-sans), sans-serif",
+} as const;
+
+export function Logo({ href = "/", className }: { href?: string; className?: string }) {
return (
-
-
-

-

-
-
+
+
+
+
+
+
+
+
+
- ktx
-
-
- by Kaelio
+ Docs
-
- Docs
-
);
}
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 3255d4c2..c08d26f2 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -2,6 +2,10 @@
"name": "@kaelio/ktx",
"version": "0.9.0",
"description": "Standalone ktx context layer for data agents",
+ "author": {
+ "name": "Kaelio",
+ "url": "https://www.kaelio.com"
+ },
"type": "module",
"engines": {
"node": ">=22.0.0"