From 6d5f8c5b998f896a2a0f48b7d25633690b671f5f Mon Sep 17 00:00:00 2001 From: Onyeka Nwamba Date: Sat, 23 May 2026 12:22:05 +0100 Subject: [PATCH] fix telemetry install identity churn --- docs/posthog-identity-repair.md | 59 ++++++++++++ src/cli/agentrail-home.ts | 9 ++ src/cli/index.ts | 56 +++++++++++ src/cli/setup-wizard.ts | 18 +++- src/cli/telemetry-identity.ts | 78 +++++++++++++++ src/cli/update-check.ts | 164 ++++++++++++++++++++++++++++++++ src/package-version.ts | 2 +- test/package-version.test.ts | 11 +++ test/setup-config.test.ts | 37 +++++++ test/setup-wizard.test.ts | 50 ++++++++++ test/telemetry-identity.test.ts | 56 +++++++++++ test/update-check.test.ts | 146 ++++++++++++++++++++++++++++ 12 files changed, 682 insertions(+), 4 deletions(-) create mode 100644 docs/posthog-identity-repair.md create mode 100644 src/cli/telemetry-identity.ts create mode 100644 src/cli/update-check.ts create mode 100644 test/package-version.test.ts create mode 100644 test/telemetry-identity.test.ts create mode 100644 test/update-check.test.ts diff --git a/docs/posthog-identity-repair.md b/docs/posthog-identity-repair.md new file mode 100644 index 0000000..b9febc8 --- /dev/null +++ b/docs/posthog-identity-repair.md @@ -0,0 +1,59 @@ +# PostHog Identity Repair + +AgentRail telemetry uses an anonymous `inst_<24 hex chars>` install ID as the PostHog `distinct_id`. +Older CLI builds regenerated that ID during repeated setup flows, so one real install could appear as many +PostHog people. + +## Forward Fix + +Release a CLI build that: + +- persists a home-scoped `telemetry-identity.json` file under the AgentRail home directory; +- imports a valid existing `config.json.telemetry.installId` into that identity file; +- reuses that identity for later `agentrail init` runs; +- reports the package version from the current package metadata. + +Users must update before their future telemetry becomes stable: + +```sh +npm install -g @agentrail-core/cli@latest +``` + +## Historical Repair + +PostHog can merge the known duplicate distinct IDs for a real install into one person by sending a +`$merge_dangerously` capture event for each duplicate ID pair. Use this only after grouping IDs with +strong evidence, such as the same time window, platform, AgentRail version, and geo/IP-derived cluster. + +Example payload shape: + +```json +{ + "event": "$merge_dangerously", + "distinct_id": "inst_canonical0000000000000000", + "properties": { + "$distinct_ids": [ + "inst_canonical0000000000000000", + "inst_duplicate0000000000000000" + ] + } +} +``` + +After merging, verify against person resolution, not a raw event export label. Historical event rows can +still display their original `distinct_id`/person-looking label even after the person now owns many +distinct IDs. + +Recommended verification: + +- In PostHog Persons, check the canonical person has all expected distinct IDs. +- In HogQL, group by geo cluster and count distinct current person IDs for recent AgentRail events. +- Treat exported `Person.display_name` values from old raw rows as historical labels, not proof that the +merge failed. + +## Operational Notes + +- Keep the old-to-canonical mapping in a private incident note so the merge can be audited. +- Do not merge across weak clusters. A bad merge is harder to recover from than leaving duplicate people. +- Once users update, do not keep repairing new `inst_*` churn manually. New churn means the CLI identity +fix is not working or users are still on an older build. diff --git a/src/cli/agentrail-home.ts b/src/cli/agentrail-home.ts index 6d2609c..a128cc7 100644 --- a/src/cli/agentrail-home.ts +++ b/src/cli/agentrail-home.ts @@ -6,6 +6,7 @@ import { DEFAULT_ROUTING_CLASSIFIER_TIMEOUT_MS } from "../routing-classifier-con import { normalizeRunnerExecutionPolicy, type RunnerExecutionPolicyLike } from "../runner-execution-policy.ts"; import { normalizeCodeReviewPolicy, type CodeReviewPolicy } from "../code-review-policy.ts"; import { normalizeTelemetryHost, normalizeTelemetryInstallId } from "./setup-config.ts"; +import { syncTelemetryIdentityFromConfig } from "./telemetry-identity.ts"; export interface ConnectedRepo { path: string; @@ -169,6 +170,14 @@ export async function readSetupConfigFromHome(homePath: string): Promise createPromptSession()))(); @@ -241,6 +260,8 @@ export async function runCli(argv: string[], options: RunCliOptions = {}): Promi cwd, flags, detectedRepo: repo, + existingConfig, + telemetryInstallId: telemetryIdentity.installId, prompt, writeLine(line) { stdout.write(`${line}\n`); @@ -289,6 +310,8 @@ export async function runCli(argv: string[], options: RunCliOptions = {}): Promi detectedRepo: repo, interactionMode: flags.printOnly ? "print_only" : "non_interactive", acceptedDefaults: flags.yes ? true : acceptedDefaultsFromFlags(flags), + existingConfig, + telemetryInstallId: telemetryIdentity.installId, }); if (flags.yes) { @@ -329,6 +352,39 @@ export async function runCli(argv: string[], options: RunCliOptions = {}): Promi } } +async function maybeRenderUpdateNotice({ + argv, + homePath, + stderr, + stdoutIsTTY, + injectedIo, + fetch, +}: { + argv: string[]; + homePath: string; + stderr: Writer; + stdoutIsTTY: boolean; + injectedIo: boolean; + fetch?: typeof globalThis.fetch; +}): Promise { + if (!stdoutIsTTY || argv.includes("--json") || (injectedIo && !fetch)) { + return; + } + + const notice = await checkForPackageUpdate({ + homePath, + currentVersion: PACKAGE_VERSION, + packageName: "@agentrail-core/cli", + fetch, + }); + if (!notice) { + return; + } + + stderr.write(`New AgentRail version available: ${notice.latestVersion} (current ${notice.currentVersion}).\n`); + stderr.write(`Run: ${notice.command}\n`); +} + function cliTelemetryAsClient(telemetry: CliTelemetryHook | undefined): TelemetryClient | undefined { if (!telemetry) { return undefined; diff --git a/src/cli/setup-wizard.ts b/src/cli/setup-wizard.ts index e6ed55f..08ba80d 100644 --- a/src/cli/setup-wizard.ts +++ b/src/cli/setup-wizard.ts @@ -12,7 +12,7 @@ import { type SetupMode, } from "./setup-config.ts"; import { describeCodeReviewPolicy, normalizeCodeReviewPolicy, type CodeReviewPolicy } from "../code-review-policy.ts"; -import { resolveAgentRailHome } from "./agentrail-home.ts"; +import { resolveAgentRailHome, type SetupConfigLike } from "./agentrail-home.ts"; import { PromptCancelledError, type PromptSession } from "./prompt.ts"; import { normalizeRunnerExecutionPolicy, type RunnerPolicyPreset } from "../runner-execution-policy.ts"; @@ -52,6 +52,8 @@ export interface RunSetupWizardOptions { cwd: string; flags: InitFlags; detectedRepo: DetectedRepoContext; + existingConfig?: SetupConfigLike | null; + telemetryInstallId?: string; prompt: PromptSession; writeLine(line: string): void; } @@ -60,6 +62,8 @@ export async function runSetupWizard({ cwd, flags, detectedRepo, + existingConfig, + telemetryInstallId, prompt, writeLine, }: RunSetupWizardOptions): Promise { @@ -142,7 +146,9 @@ export async function runSetupWizard({ routingFallbackBehavior, codeReviewPolicy, runnerPolicy: normalizeRunnerExecutionPolicy({ preset: runnerPolicyPreset }), - telemetryEnabled: flags.telemetryEnabled, + telemetryEnabled: flags.telemetryEnabled ?? existingConfig?.telemetry?.enabled, + telemetryInstallId: telemetryInstallId ?? existingConfig?.telemetry?.installId, + telemetryHost: existingConfig?.telemetry?.host, repoPath, repoAllowlist, defaultBranch, @@ -440,12 +446,16 @@ export function createSetupConfigFromFlags({ detectedRepo, interactionMode, acceptedDefaults, + existingConfig, + telemetryInstallId, }: { cwd: string; flags: InitFlags; detectedRepo: DetectedRepoContext; interactionMode: CreateSetupConfigOptions["interactionMode"]; acceptedDefaults: boolean; + existingConfig?: SetupConfigLike | null; + telemetryInstallId?: string; }): SetupConfig { return createSetupConfig({ cwd, @@ -471,6 +481,8 @@ export function createSetupConfigFromFlags({ runnerPolicy: flags.runnerPolicyPreset ? normalizeRunnerExecutionPolicy({ preset: flags.runnerPolicyPreset }) : undefined, - telemetryEnabled: flags.telemetryEnabled, + telemetryEnabled: flags.telemetryEnabled ?? existingConfig?.telemetry?.enabled, + telemetryInstallId: telemetryInstallId ?? existingConfig?.telemetry?.installId, + telemetryHost: existingConfig?.telemetry?.host, }); } diff --git a/src/cli/telemetry-identity.ts b/src/cli/telemetry-identity.ts new file mode 100644 index 0000000..311c57c --- /dev/null +++ b/src/cli/telemetry-identity.ts @@ -0,0 +1,78 @@ +import crypto from "node:crypto"; +import path from "node:path"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; + +const TELEMETRY_IDENTITY_FILE = "telemetry-identity.json"; +const INSTALL_ID_PATTERN = /^inst_[a-f0-9]{24}$/u; + +export interface TelemetryIdentity { + installId: string; +} + +export function telemetryIdentityPathForHome(homePath: string): string { + return path.join(homePath, TELEMETRY_IDENTITY_FILE); +} + +export async function readOrCreateTelemetryIdentity({ + homePath, + existingInstallId, +}: { + homePath: string; + existingInstallId?: string | null; +}): Promise { + const importedInstallId = normalizeTelemetryIdentityInstallId(existingInstallId); + if (importedInstallId) { + await writeTelemetryIdentity(homePath, importedInstallId); + return { installId: importedInstallId }; + } + + const persistedInstallId = await readTelemetryIdentity(homePath); + if (persistedInstallId) { + return { installId: persistedInstallId }; + } + + const installId = createTelemetryIdentityInstallId(); + await writeTelemetryIdentity(homePath, installId); + return { installId }; +} + +export async function syncTelemetryIdentityFromConfig({ + homePath, + installId, +}: { + homePath: string; + installId?: string | null; +}): Promise { + const normalized = normalizeTelemetryIdentityInstallId(installId); + if (!normalized) { + return; + } + await writeTelemetryIdentity(homePath, normalized); +} + +function createTelemetryIdentityInstallId(): string { + return `inst_${crypto.randomBytes(12).toString("hex")}`; +} + +function normalizeTelemetryIdentityInstallId(value: unknown): string | null { + return typeof value === "string" && INSTALL_ID_PATTERN.test(value) ? value : null; +} + +async function readTelemetryIdentity(homePath: string): Promise { + try { + const raw = await readFile(telemetryIdentityPathForHome(homePath), "utf8"); + const parsed = JSON.parse(raw) as { installId?: unknown }; + return normalizeTelemetryIdentityInstallId(parsed.installId); + } catch { + return null; + } +} + +async function writeTelemetryIdentity(homePath: string, installId: string): Promise { + await mkdir(homePath, { recursive: true }); + await writeFile( + telemetryIdentityPathForHome(homePath), + `${JSON.stringify({ installId }, null, 2)}\n`, + "utf8", + ); +} diff --git a/src/cli/update-check.ts b/src/cli/update-check.ts new file mode 100644 index 0000000..b4974ec --- /dev/null +++ b/src/cli/update-check.ts @@ -0,0 +1,164 @@ +import path from "node:path"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; + +const UPDATE_CHECK_CACHE_FILE = "update-check.json"; +const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const DEFAULT_TIMEOUT_MS = 750; +const DISABLED_ENV_VALUES = new Set(["1", "true", "yes", "on"]); + +export interface PackageUpdateNotice { + currentVersion: string; + latestVersion: string; + command: string; +} + +export interface CheckForPackageUpdateOptions { + homePath: string; + currentVersion: string; + packageName: string; + fetch?: typeof globalThis.fetch; + env?: Record; + now?: () => Date; + cacheTtlMs?: number; + timeoutMs?: number; + registryUrl?: string; +} + +export async function checkForPackageUpdate({ + homePath, + currentVersion, + packageName, + fetch = globalThis.fetch, + env = process.env, + now = () => new Date(), + cacheTtlMs = DEFAULT_CACHE_TTL_MS, + timeoutMs = DEFAULT_TIMEOUT_MS, + registryUrl, +}: CheckForPackageUpdateOptions): Promise { + if (isUpdateCheckDisabled(env) || typeof fetch !== "function") { + return null; + } + + const checkedAt = now(); + if (await hasFreshUpdateCheckCache(homePath, checkedAt, cacheTtlMs)) { + return null; + } + + try { + const response = await fetchLatestPackageMetadata({ + fetch, + packageName, + registryUrl, + timeoutMs, + }); + await writeUpdateCheckCache(homePath, { + checkedAt: checkedAt.toISOString(), + latestVersion: response.version, + }); + + if (compareSemver(response.version, currentVersion) <= 0) { + return null; + } + + return { + currentVersion, + latestVersion: response.version, + command: `npm install -g ${packageName}@latest`, + }; + } catch { + await writeUpdateCheckCache(homePath, { + checkedAt: checkedAt.toISOString(), + }); + return null; + } +} + +export function updateCheckCachePathForHome(homePath: string): string { + return path.join(homePath, UPDATE_CHECK_CACHE_FILE); +} + +function isUpdateCheckDisabled(env: Record): boolean { + const value = env.AGENTRAIL_UPDATE_CHECK_DISABLED?.trim().toLowerCase(); + return value !== undefined && DISABLED_ENV_VALUES.has(value); +} + +async function hasFreshUpdateCheckCache(homePath: string, now: Date, cacheTtlMs: number): Promise { + try { + const raw = await readFile(updateCheckCachePathForHome(homePath), "utf8"); + const parsed = JSON.parse(raw) as { checkedAt?: unknown }; + const checkedAt = typeof parsed.checkedAt === "string" ? new Date(parsed.checkedAt) : null; + return Boolean(checkedAt && Number.isFinite(checkedAt.getTime()) && now.getTime() - checkedAt.getTime() < cacheTtlMs); + } catch { + return false; + } +} + +async function writeUpdateCheckCache( + homePath: string, + cache: { checkedAt: string; latestVersion?: string }, +): Promise { + try { + await mkdir(homePath, { recursive: true }); + await writeFile(updateCheckCachePathForHome(homePath), `${JSON.stringify(cache, null, 2)}\n`, "utf8"); + } catch { + // Version checks must never affect the command the user actually ran. + } +} + +async function fetchLatestPackageMetadata({ + fetch, + packageName, + registryUrl, + timeoutMs, +}: { + fetch: typeof globalThis.fetch; + packageName: string; + registryUrl?: string; + timeoutMs: number; +}): Promise<{ version: string }> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(registryUrl ?? npmLatestUrl(packageName), { + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`Package registry returned ${response.status}.`); + } + const body = await response.json() as { version?: unknown }; + if (typeof body.version !== "string" || !parseSemver(body.version)) { + throw new Error("Package registry response did not include a valid semver version."); + } + return { version: body.version }; + } finally { + clearTimeout(timeout); + } +} + +function npmLatestUrl(packageName: string): string { + return `https://registry.npmjs.org/${packageName.replace("/", "%2f")}/latest`; +} + +function compareSemver(left: string, right: string): number { + const parsedLeft = parseSemver(left); + const parsedRight = parseSemver(right); + if (!parsedLeft || !parsedRight) { + return 0; + } + + for (let index = 0; index < parsedLeft.length; index += 1) { + const diff = parsedLeft[index] - parsedRight[index]; + if (diff !== 0) { + return diff; + } + } + return 0; +} + +function parseSemver(version: string): [number, number, number] | null { + const match = /^(\d+)\.(\d+)\.(\d+)$/u.exec(version); + if (!match) { + return null; + } + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} diff --git a/src/package-version.ts b/src/package-version.ts index 7e675a5..8cb71b8 100644 --- a/src/package-version.ts +++ b/src/package-version.ts @@ -1 +1 @@ -export const PACKAGE_VERSION = "0.1.6"; +export const PACKAGE_VERSION = "0.1.8"; diff --git a/test/package-version.test.ts b/test/package-version.test.ts new file mode 100644 index 0000000..aaaf9fa --- /dev/null +++ b/test/package-version.test.ts @@ -0,0 +1,11 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { PACKAGE_VERSION } from "../src/package-version.ts"; + +test("runtime package version matches package.json", async () => { + const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")); + + assert.equal(PACKAGE_VERSION, packageJson.version); +}); diff --git a/test/setup-config.test.ts b/test/setup-config.test.ts index 745907a..a8997c4 100644 --- a/test/setup-config.test.ts +++ b/test/setup-config.test.ts @@ -11,6 +11,7 @@ import { type DetectedRepoContext, } from "../src/cli/setup-config.ts"; import { configPathForHome, normalizeSetupConfigLike, readSetupConfigFromHome } from "../src/cli/agentrail-home.ts"; +import { telemetryIdentityPathForHome } from "../src/cli/telemetry-identity.ts"; const detectedRepo: DetectedRepoContext = { repoPath: "/tmp/agentrail", @@ -87,6 +88,18 @@ test("createSetupConfig regenerates malformed explicit telemetry install ids", ( assert.notEqual(config.telemetry.installId, "not-an-install-id"); }); +test("createSetupConfig preserves valid explicit telemetry install ids", () => { + const config = createSetupConfig({ + cwd: detectedRepo.repoPath, + detectedRepo, + interactionMode: "interactive", + acceptedDefaults: true, + telemetryInstallId: "inst_0123456789abcdef01234567", + }); + + assert.equal(config.telemetry.installId, "inst_0123456789abcdef01234567"); +}); + test("buildInitCommand preserves telemetry opt-out", () => { const config = createSetupConfig({ cwd: detectedRepo.repoPath, @@ -190,6 +203,30 @@ test("readSetupConfigFromHome persists normalized telemetry install ids", async assert.equal(persisted.telemetry.installId, config?.telemetry?.installId); }); +test("readSetupConfigFromHome syncs the durable telemetry identity file", async (t) => { + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-config-")); + t.after(async () => { + await rm(homePath, { recursive: true, force: true }); + }); + await writeFile(configPathForHome(homePath), JSON.stringify({ + version: 2, + providers: {}, + repos: [], + telemetry: { + enabled: true, + provider: "posthog", + host: "https://eu.i.posthog.com", + installId: "inst_abcdefabcdefabcdefabcdef", + }, + }), "utf8"); + + const config = await readSetupConfigFromHome(homePath); + const identity = JSON.parse(await readFile(telemetryIdentityPathForHome(homePath), "utf8")); + + assert.equal(config?.telemetry?.installId, "inst_abcdefabcdefabcdefabcdef"); + assert.equal(identity.installId, "inst_abcdefabcdefabcdefabcdef"); +}); + test("createSetupConfig supports explicit code review policy", () => { const config = createSetupConfig({ cwd: detectedRepo.repoPath, diff --git a/test/setup-wizard.test.ts b/test/setup-wizard.test.ts index 27f7b94..2b8de40 100644 --- a/test/setup-wizard.test.ts +++ b/test/setup-wizard.test.ts @@ -663,6 +663,56 @@ test("runCli non-interactive init preserves telemetry opt-out", async () => { assert.equal(config.telemetry.enabled, false); }); +test("runCli non-interactive init preserves the existing telemetry install id", async () => { + const agentrailHome = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const previousHome = process.env.AGENTRAIL_HOME; + process.env.AGENTRAIL_HOME = agentrailHome; + const stdout = createMemoryWriter(); + const stderr = createMemoryWriter(); + const writes: Array<{ config: SetupConfig }> = []; + await writeFile(path.join(agentrailHome, "config.json"), JSON.stringify({ + version: 2, + providers: {}, + repos: [], + telemetry: { + enabled: true, + provider: "posthog", + host: "https://eu.i.posthog.com", + installId: "inst_0123456789abcdef01234567", + }, + }), "utf8"); + + const exitCode = await runCli([ + "init", + "--mode", + "server", + "--provider-mode", + "disabled", + "--repo", + detectedRepo.repoPath, + ], { + cwd: detectedRepo.repoPath, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout, + stderr, + detectRepoContext: async () => detectedRepo, + writeSetupFiles: async ({ config }) => { + writes.push({ config }); + return { writtenPaths: [] }; + }, + }); + + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + await rm(agentrailHome, { recursive: true, force: true }); + + assert.equal(exitCode, 0, stderr.toString()); + assert.equal(stderr.toString(), ""); + assert.equal(writes.length, 1); + assert.equal(writes[0]?.config.telemetry.installId, "inst_0123456789abcdef01234567"); +}); + test("runCli init captures start and completion around setup file writes", async () => { const agentrailHome = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); const previousHome = process.env.AGENTRAIL_HOME; diff --git a/test/telemetry-identity.test.ts b/test/telemetry-identity.test.ts new file mode 100644 index 0000000..7773a90 --- /dev/null +++ b/test/telemetry-identity.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import test from "node:test"; + +import { + readOrCreateTelemetryIdentity, + telemetryIdentityPathForHome, +} from "../src/cli/telemetry-identity.ts"; + +test("readOrCreateTelemetryIdentity writes and reuses a home-scoped install id", async (t) => { + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-identity-")); + t.after(async () => { + await rm(homePath, { recursive: true, force: true }); + }); + + const first = await readOrCreateTelemetryIdentity({ homePath }); + const second = await readOrCreateTelemetryIdentity({ homePath }); + const persisted = JSON.parse(await readFile(telemetryIdentityPathForHome(homePath), "utf8")); + + assert.match(first.installId, /^inst_[a-f0-9]{24}$/u); + assert.equal(second.installId, first.installId); + assert.equal(persisted.installId, first.installId); +}); + +test("readOrCreateTelemetryIdentity imports an existing config install id", async (t) => { + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-identity-")); + t.after(async () => { + await rm(homePath, { recursive: true, force: true }); + }); + + const identity = await readOrCreateTelemetryIdentity({ + homePath, + existingInstallId: "inst_0123456789abcdef01234567", + }); + + assert.equal(identity.installId, "inst_0123456789abcdef01234567"); + assert.equal( + JSON.parse(await readFile(telemetryIdentityPathForHome(homePath), "utf8")).installId, + "inst_0123456789abcdef01234567", + ); +}); + +test("readOrCreateTelemetryIdentity replaces malformed identity files", async (t) => { + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-identity-")); + t.after(async () => { + await rm(homePath, { recursive: true, force: true }); + }); + await writeFile(telemetryIdentityPathForHome(homePath), JSON.stringify({ installId: "bad" }), "utf8"); + + const identity = await readOrCreateTelemetryIdentity({ homePath }); + + assert.match(identity.installId, /^inst_[a-f0-9]{24}$/u); + assert.notEqual(identity.installId, "bad"); +}); diff --git a/test/update-check.test.ts b/test/update-check.test.ts new file mode 100644 index 0000000..e8e5a36 --- /dev/null +++ b/test/update-check.test.ts @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { Writable } from "node:stream"; +import test from "node:test"; + +import { runCli } from "../src/cli/index.ts"; +import { checkForPackageUpdate } from "../src/cli/update-check.ts"; + +test("checkForPackageUpdate returns a tame npm install command for newer versions", async (t) => { + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-update-")); + t.after(async () => { + await rm(homePath, { recursive: true, force: true }); + }); + + const notice = await checkForPackageUpdate({ + homePath, + currentVersion: "0.1.8", + packageName: "@agentrail-core/cli", + fetch: async () => new Response(JSON.stringify({ version: "0.1.9" }), { status: 200 }), + }); + + assert.deepEqual(notice, { + currentVersion: "0.1.8", + latestVersion: "0.1.9", + command: "npm install -g @agentrail-core/cli@latest", + }); +}); + +test("checkForPackageUpdate suppresses repeated checks while the cache is fresh", async (t) => { + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-update-")); + t.after(async () => { + await rm(homePath, { recursive: true, force: true }); + }); + let calls = 0; + const fetch = async () => { + calls += 1; + return new Response(JSON.stringify({ version: "0.1.9" }), { status: 200 }); + }; + + assert.ok(await checkForPackageUpdate({ + homePath, + currentVersion: "0.1.8", + packageName: "@agentrail-core/cli", + fetch, + now: () => new Date("2026-05-23T12:00:00.000Z"), + })); + assert.equal(await checkForPackageUpdate({ + homePath, + currentVersion: "0.1.8", + packageName: "@agentrail-core/cli", + fetch, + now: () => new Date("2026-05-23T13:00:00.000Z"), + }), null); + assert.equal(calls, 1); +}); + +test("checkForPackageUpdate ignores disabled, equal, older, and malformed versions", async (t) => { + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-update-")); + t.after(async () => { + await rm(homePath, { recursive: true, force: true }); + }); + + assert.equal(await checkForPackageUpdate({ + homePath, + currentVersion: "0.1.8", + packageName: "@agentrail-core/cli", + env: { AGENTRAIL_UPDATE_CHECK_DISABLED: "1" }, + fetch: async () => { + throw new Error("fetch should not run when disabled"); + }, + }), null); + + for (const version of ["0.1.8", "0.1.7", "next"]) { + const isolatedHome = await mkdtemp(path.join(os.tmpdir(), "agentrail-update-")); + t.after(async () => { + await rm(isolatedHome, { recursive: true, force: true }); + }); + assert.equal(await checkForPackageUpdate({ + homePath: isolatedHome, + currentVersion: "0.1.8", + packageName: "@agentrail-core/cli", + fetch: async () => new Response(JSON.stringify({ version }), { status: 200 }), + }), null); + } +}); + +test("runCli prints a tame update notice for interactive commands", async (t) => { + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-update-home-")); + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-update-repo-")); + const previousHome = process.env.AGENTRAIL_HOME; + const previousTelemetryDisabled = process.env.AGENTRAIL_TELEMETRY_DISABLED; + process.env.AGENTRAIL_HOME = homePath; + delete process.env.AGENTRAIL_TELEMETRY_DISABLED; + t.after(async () => { + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + if (previousTelemetryDisabled === undefined) delete process.env.AGENTRAIL_TELEMETRY_DISABLED; + else process.env.AGENTRAIL_TELEMETRY_DISABLED = previousTelemetryDisabled; + await rm(homePath, { recursive: true, force: true }); + await rm(repoRoot, { recursive: true, force: true }); + }); + await writeFile(path.join(homePath, "config.json"), JSON.stringify({ + version: 2, + providers: {}, + repos: [], + telemetry: { + enabled: true, + provider: "posthog", + host: "https://eu.i.posthog.com", + installId: "inst_0123456789abcdef01234567", + }, + }), "utf8"); + const stdout = createMemoryWriter(); + const stderr = createMemoryWriter(); + + const exitCode = await runCli(["telemetry", "status"], { + cwd: repoRoot, + stdoutIsTTY: true, + stdout, + stderr, + updateFetch: async () => new Response(JSON.stringify({ version: "0.1.9" }), { status: 200 }), + }); + + assert.equal(exitCode, 0, stderr.toString()); + assert.match(stderr.toString(), /New AgentRail version available: 0\.1\.9 \(current 0\.1\.8\)\./u); + assert.match(stderr.toString(), /Run: npm install -g @agentrail-core\/cli@latest/u); + assert.match(stdout.toString(), /Telemetry: enabled/u); +}); + +function createMemoryWriter(): Writable & { toString(): string } { + const chunks: string[] = []; + const writer = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + callback(); + }, + }); + + return Object.assign(writer, { + toString() { + return chunks.join(""); + }, + }); +}