diff --git a/packages/app/README.md b/packages/app/README.md index fa7a59b..e932920 100644 --- a/packages/app/README.md +++ b/packages/app/README.md @@ -58,6 +58,7 @@ treq open --port 8080 | `--host, -H` | Host to bind to (default: 127.0.0.1) | | `--web` | Open the browser dashboard | | `--expose` | Allow non-loopback binding (disables cookie auth) | +| `--auto-update` / `--no-auto-update` | Enable or disable startup auto-update checks (default: enabled) | Security: a random token is generated on every launch. `--web` and `--expose` cannot be combined (SSRF protection). @@ -323,6 +324,7 @@ treq tui --server http://localhost:8080 --token my-token |--------|-------------| | `--server, -s` | Server URL to connect to (default: http://localhost:4097) | | `--token, -t` | Bearer token for authentication | +| `--auto-update` / `--no-auto-update` | Enable or disable startup auto-update checks (default: enabled) | ### `treq upgrade` - Upgrade treq @@ -336,6 +338,16 @@ treq upgrade treq upgrade 0.3.0 ``` +### Auto-update + +Interactive commands (`treq open`, `treq tui`, `treq web`) perform startup update checks and attempt auto-upgrade for known install methods. + +- Checks are cached for 24 hours in `~/.treq/auto-update.json` +- Failed auto-upgrade attempts are backed off for 24 hours for the same target version +- Successful installs apply on the next run (current process continues) +- Use `--no-auto-update` to disable per command +- Use `TREQ_AUTO_UPDATE=0` to disable via environment variable + ### Help ```bash diff --git a/packages/app/src/cli/upgrade.ts b/packages/app/src/cli/upgrade.ts index ee5c074..82b8f30 100644 --- a/packages/app/src/cli/upgrade.ts +++ b/packages/app/src/cli/upgrade.ts @@ -1,19 +1,11 @@ -import { Installation } from '../installation'; +import { checkForAvailableUpdate } from '../update'; export interface UpdateResult { version: string; - method: Installation.Method; + method: import('../installation').Installation.Method; command: string; } export async function checkForUpdate(): Promise { - const method = await Installation.method(); - const latest = await Installation.latest(method).catch(() => undefined); - if (!latest) return undefined; - if (Installation.VERSION === latest) return undefined; - return { - version: latest, - method, - command: Installation.updateCommand(method, latest) - }; + return checkForAvailableUpdate(); } diff --git a/packages/app/src/cmd/open.ts b/packages/app/src/cmd/open.ts index da2e327..8b44c6f 100644 --- a/packages/app/src/cmd/open.ts +++ b/packages/app/src/cmd/open.ts @@ -2,6 +2,7 @@ import { resolve } from 'node:path'; import { flushPendingCookieSaves } from '@t-req/core/cookies/persistence'; import type { CommandModule } from 'yargs'; import { createApp, type ServerConfig } from '../server/app'; +import { resolveAutoUpdateEnabled } from '../update'; import { DEFAULT_HOST, DEFAULT_PORT, @@ -19,6 +20,7 @@ interface OpenOptions { host: string; web?: boolean; expose?: boolean; + autoUpdate?: boolean; } export const openCommand: CommandModule = { @@ -51,6 +53,11 @@ export const openCommand: CommandModule = { type: 'boolean', describe: 'Allow non-loopback binding (disables cookie auth for security)', default: false + }) + .option('auto-update', { + type: 'boolean', + describe: 'Automatically check and apply updates on startup', + default: true }), handler: async (argv) => { await runOpen(argv); @@ -150,7 +157,12 @@ async function runOpen(argv: OpenOptions): Promise { // Import and start TUI const { startTui } = await import('../tui'); try { - await startTui({ serverUrl, token, onExit: shutdown }); + await startTui({ + serverUrl, + token, + autoUpdate: resolveAutoUpdateEnabled(argv.autoUpdate), + onExit: shutdown + }); } finally { // Cleanup on TUI exit await shutdown(); diff --git a/packages/app/src/cmd/tui.ts b/packages/app/src/cmd/tui.ts index 8c94872..5a40358 100644 --- a/packages/app/src/cmd/tui.ts +++ b/packages/app/src/cmd/tui.ts @@ -1,8 +1,10 @@ import type { CommandModule } from 'yargs'; +import { resolveAutoUpdateEnabled } from '../update'; interface TuiOptions { server: string; token?: string; + autoUpdate?: boolean; } export const tuiCommand: CommandModule = { @@ -19,10 +21,19 @@ export const tuiCommand: CommandModule = { type: 'string', alias: 't', describe: 'Bearer token for authentication' + }, + 'auto-update': { + type: 'boolean', + default: true, + describe: 'Automatically check and apply updates on startup' } }, handler: async (argv) => { const { startTui } = await import('../tui'); - await startTui({ serverUrl: argv.server, token: argv.token }); + await startTui({ + serverUrl: argv.server, + token: argv.token, + autoUpdate: resolveAutoUpdateEnabled(argv.autoUpdate) + }); } }; diff --git a/packages/app/src/cmd/web.ts b/packages/app/src/cmd/web.ts index af49c5c..5e04ecd 100644 --- a/packages/app/src/cmd/web.ts +++ b/packages/app/src/cmd/web.ts @@ -2,6 +2,7 @@ import { resolve } from 'node:path'; import { flushPendingCookieSaves } from '@t-req/core/cookies/persistence'; import type { CommandModule } from 'yargs'; import { createApp, type ServerConfig } from '../server/app'; +import { resolveAutoUpdateEnabled, runAutoUpdate } from '../update'; import { DEFAULT_HOST, DEFAULT_PORT, @@ -16,6 +17,7 @@ interface WebOptions { workspace?: string; port?: number; host: string; + autoUpdate?: boolean; } export const webCommand: CommandModule = { @@ -38,6 +40,11 @@ export const webCommand: CommandModule = { describe: 'Host to bind to', alias: 'H', default: DEFAULT_HOST + }) + .option('auto-update', { + type: 'boolean', + describe: 'Automatically check and apply updates on startup', + default: true }), handler: async (argv) => { await runWeb(argv); @@ -114,6 +121,34 @@ async function runWeb(argv: WebOptions): Promise { console.log(`Opening browser: ${webUrl}`); openBrowser(webUrl); + void runAutoUpdate({ + enabled: resolveAutoUpdateEnabled(argv.autoUpdate), + interactive: process.stdout.isTTY === true + }).then((outcome) => { + switch (outcome.status) { + case 'updated': + console.log(`Update installed: v${outcome.latestVersion} (applies next run)`); + break; + case 'available_manual': + console.log(`Update available: v${outcome.latestVersion}. Run: ${outcome.command}`); + break; + case 'backoff_skipped': + console.warn( + `Update available: v${outcome.latestVersion}. Auto-update temporarily paused.` + ); + console.warn(`Run manually: ${outcome.command}`); + break; + case 'failed': + if (outcome.phase === 'upgrade' && outcome.command) { + console.warn('Auto-update failed. Continuing startup.'); + console.warn(`Run manually: ${outcome.command}`); + } + break; + default: + break; + } + }); + process.on('SIGTERM', () => void shutdown()); process.on('SIGINT', () => void shutdown()); diff --git a/packages/app/src/tui/context/update.tsx b/packages/app/src/tui/context/update.tsx index 62e0e8f..49f790a 100644 --- a/packages/app/src/tui/context/update.tsx +++ b/packages/app/src/tui/context/update.tsx @@ -1,12 +1,9 @@ import { createContext, createSignal, onMount, type ParentProps, useContext } from 'solid-js'; -import { Installation } from '../../installation'; +import type { UpdateInfo } from '../../update'; +import { runAutoUpdate } from '../../update'; import { useToast } from '../components/toast'; -export interface UpdateInfo { - version: string; - method: Installation.Method; - command: string; -} +export type { UpdateInfo } from '../../update'; export interface UpdateContextValue { updateInfo: () => UpdateInfo | null; @@ -15,7 +12,11 @@ export interface UpdateContextValue { const UpdateContext = createContext(); -export function UpdateProvider(props: ParentProps) { +export interface UpdateProviderProps extends ParentProps { + autoUpdateEnabled?: boolean; +} + +export function UpdateProvider(props: UpdateProviderProps) { const toast = useToast(); const [updateInfo, setUpdateInfo] = createSignal(null); const updateAvailable = () => updateInfo() !== null; @@ -25,23 +26,76 @@ export function UpdateProvider(props: ParentProps) { }); async function checkForUpdate() { - try { - const method = await Installation.method(); - const latestVersion = await Installation.latest(method); - if (!latestVersion) return; - if (Installation.VERSION === latestVersion) return; - - const command = Installation.updateCommand(method, latestVersion); - setUpdateInfo({ version: latestVersion, method, command }); - - toast.show({ - variant: 'info', - title: 'Update Available', - message: `v${Installation.VERSION} -> v${latestVersion}\nRun: ${command}`, - duration: 3000 - }); - } catch { - // Silently fail - network errors, offline, etc. + const outcome = await runAutoUpdate({ + enabled: props.autoUpdateEnabled ?? true, + interactive: process.stdout.isTTY === true + }); + + switch (outcome.status) { + case 'available_manual': { + setUpdateInfo({ + version: outcome.latestVersion, + method: outcome.method, + command: outcome.command + }); + toast.show({ + variant: 'info', + title: 'Update Available', + message: `v${outcome.currentVersion} -> v${outcome.latestVersion}\nRun: ${outcome.command}`, + duration: 3000 + }); + return; + } + + case 'backoff_skipped': { + setUpdateInfo({ + version: outcome.latestVersion, + method: outcome.method, + command: outcome.command + }); + toast.show({ + variant: 'warning', + title: 'Update Available', + message: `Auto-update paused after a recent failure.\nRun: ${outcome.command}`, + duration: 4000 + }); + return; + } + + case 'updated': { + toast.show({ + variant: 'success', + title: 'Updated', + message: `Installed v${outcome.latestVersion}. It will apply on your next run.`, + duration: 3500 + }); + return; + } + + case 'failed': { + if ( + outcome.phase === 'upgrade' && + outcome.latestVersion && + outcome.method && + outcome.command + ) { + setUpdateInfo({ + version: outcome.latestVersion, + method: outcome.method, + command: outcome.command + }); + toast.show({ + variant: 'warning', + title: 'Auto-update failed', + message: `Run manually: ${outcome.command}`, + duration: 4000 + }); + } + return; + } + + default: + return; } } diff --git a/packages/app/src/tui/index.tsx b/packages/app/src/tui/index.tsx index 6b511d0..b13073a 100644 --- a/packages/app/src/tui/index.tsx +++ b/packages/app/src/tui/index.tsx @@ -20,6 +20,7 @@ import { createStore } from './store'; export interface TuiConfig { serverUrl: string; token?: string; + autoUpdate?: boolean; onExit?: (reason?: unknown) => Promise | void; } @@ -78,7 +79,7 @@ export async function startTui(config: TuiConfig): Promise { - + diff --git a/packages/app/src/update/auto-update.ts b/packages/app/src/update/auto-update.ts new file mode 100644 index 0000000..f344336 --- /dev/null +++ b/packages/app/src/update/auto-update.ts @@ -0,0 +1,306 @@ +import { Installation } from '../installation'; +import { + AUTO_UPDATE_CHECK_TTL_MS, + AUTO_UPDATE_RETRY_BACKOFF_MS, + createAutoUpdateStateStore +} from './state'; +import type { + AutoUpdateOptions, + AutoUpdateOutcome, + AutoUpdateStateStore, + InstallationLike, + UpdateInfo +} from './types'; + +interface AutoUpdateDependencies { + installation?: InstallationLike; + stateStore?: AutoUpdateStateStore; +} + +interface ParsedVersion { + major: number; + minor: number; + patch: number; + pre?: string[]; +} + +function parseBooleanEnv(value: string | undefined): boolean | undefined { + if (!value) return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on') + return true; + if (normalized === '0' || normalized === 'false' || normalized === 'no' || normalized === 'off') + return false; + return undefined; +} + +export function resolveAutoUpdateEnabled( + optionValue: boolean | undefined, + env: NodeJS.ProcessEnv = process.env +): boolean { + const envValue = parseBooleanEnv(env.TREQ_AUTO_UPDATE); + if (envValue !== undefined) return envValue; + return optionValue ?? true; +} + +function parseVersion(version: string): ParsedVersion | undefined { + const normalized = version.trim().replace(/^v/, ''); + const match = normalized.match( + /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-.]+)?$/ + ); + if (!match) return undefined; + + const major = Number(match[1]); + const minor = Number(match[2]); + const patch = Number(match[3]); + if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch)) { + return undefined; + } + + const pre = match[4]?.split('.'); + return { major, minor, patch, pre }; +} + +function comparePrerelease(a: string[] | undefined, b: string[] | undefined): number { + if (!a && !b) return 0; + if (!a) return 1; + if (!b) return -1; + + const length = Math.max(a.length, b.length); + for (let i = 0; i < length; i += 1) { + const left = a[i]; + const right = b[i]; + if (left === undefined) return -1; + if (right === undefined) return 1; + if (left === right) continue; + + const leftNumeric = /^\d+$/.test(left); + const rightNumeric = /^\d+$/.test(right); + if (leftNumeric && rightNumeric) { + const leftNumber = Number(left); + const rightNumber = Number(right); + if (leftNumber > rightNumber) return 1; + if (leftNumber < rightNumber) return -1; + continue; + } + if (leftNumeric) return -1; + if (rightNumeric) return 1; + return left > right ? 1 : -1; + } + + return 0; +} + +function compareVersions(a: string, b: string): number | undefined { + const left = parseVersion(a); + const right = parseVersion(b); + if (!left || !right) return undefined; + + if (left.major !== right.major) return left.major > right.major ? 1 : -1; + if (left.minor !== right.minor) return left.minor > right.minor ? 1 : -1; + if (left.patch !== right.patch) return left.patch > right.patch ? 1 : -1; + return comparePrerelease(left.pre, right.pre); +} + +function isStrictlyNewer(candidate: string, current: string): boolean { + return compareVersions(candidate, current) === 1; +} + +export async function checkForAvailableUpdate( + installation: InstallationLike = Installation +): Promise { + const method = await installation.method(); + const latest = await installation.latest(method).catch(() => undefined); + if (!latest) return undefined; + if (!isStrictlyNewer(latest, installation.VERSION)) return undefined; + + return { + version: latest, + method, + command: installation.updateCommand(method, latest) + }; +} + +export async function runAutoUpdate( + options: AutoUpdateOptions, + deps: AutoUpdateDependencies = {} +): Promise { + if (!options.enabled) { + return { + status: 'disabled', + reason: 'disabled' + }; + } + + if (!options.interactive) { + return { + status: 'disabled', + reason: 'non_interactive' + }; + } + + const now = options.now?.() ?? Date.now(); + const installation = deps.installation ?? Installation; + const stateStore = deps.stateStore ?? createAutoUpdateStateStore(); + + const methodResult = await installation + .method() + .then((method) => ({ ok: true as const, method })) + .catch((error) => ({ + ok: false as const, + error: error instanceof Error ? error.message : String(error) + })); + if (!methodResult.ok) { + return { + status: 'failed', + currentVersion: installation.VERSION, + phase: 'check', + error: methodResult.error + }; + } + const method = methodResult.method; + + const state = await stateStore.read(); + const checkExpiresAt = (state.lastCheckedAt ?? 0) + AUTO_UPDATE_CHECK_TTL_MS; + const shouldUseCache = + typeof state.cachedLatestVersion === 'string' && + state.cachedLatestVersion.length > 0 && + now < checkExpiresAt; + + let latestVersion: string | undefined; + let checkedAt = now; + + if (shouldUseCache) { + latestVersion = state.cachedLatestVersion; + checkedAt = state.lastCheckedAt ?? now; + } else { + const latestResult = await installation + .latest(method) + .then((latest) => ({ ok: true as const, latest })) + .catch((error) => ({ + ok: false as const, + error: error instanceof Error ? error.message : String(error) + })); + + if (!latestResult.ok) { + return { + status: 'failed', + currentVersion: installation.VERSION, + method, + phase: 'check', + error: latestResult.error + }; + } + + latestVersion = latestResult.latest; + checkedAt = now; + await stateStore.write({ + ...state, + lastCheckedAt: checkedAt, + cachedLatestVersion: latestVersion + }); + } + + if (latestVersion === installation.VERSION) { + return { + status: 'up_to_date', + currentVersion: installation.VERSION, + method, + checkedAt + }; + } + if (!latestVersion) { + return { + status: 'failed', + currentVersion: installation.VERSION, + method, + phase: 'check', + error: 'Missing latest version' + }; + } + + if (!isStrictlyNewer(latestVersion, installation.VERSION)) { + return { + status: 'up_to_date', + currentVersion: installation.VERSION, + method, + checkedAt + }; + } + + const command = installation.updateCommand(method, latestVersion); + + if (method === 'unknown') { + return { + status: 'available_manual', + currentVersion: installation.VERSION, + latestVersion, + method, + command, + checkedAt + }; + } + + if ( + state.lastAttemptStatus === 'failed' && + state.lastAttemptedVersion === latestVersion && + typeof state.lastAttemptedAt === 'number' + ) { + const retryAfter = state.lastAttemptedAt + AUTO_UPDATE_RETRY_BACKOFF_MS; + if (now < retryAfter) { + return { + status: 'backoff_skipped', + currentVersion: installation.VERSION, + latestVersion, + method, + command, + checkedAt, + retryAfter + }; + } + } + + const upgradeError = await installation.upgrade(method, latestVersion).catch((error) => { + return error instanceof Error ? error.message : String(error); + }); + + if (typeof upgradeError === 'string') { + await stateStore.write({ + ...state, + lastCheckedAt: checkedAt, + cachedLatestVersion: latestVersion, + lastAttemptedVersion: latestVersion, + lastAttemptedAt: now, + lastAttemptStatus: 'failed' + }); + + return { + status: 'failed', + currentVersion: installation.VERSION, + latestVersion, + method, + command, + checkedAt, + phase: 'upgrade', + error: upgradeError + }; + } + + await stateStore.write({ + ...state, + lastCheckedAt: checkedAt, + cachedLatestVersion: latestVersion, + lastAttemptedVersion: latestVersion, + lastAttemptedAt: now, + lastAttemptStatus: 'success' + }); + + return { + status: 'updated', + currentVersion: installation.VERSION, + latestVersion, + method, + command, + checkedAt + }; +} diff --git a/packages/app/src/update/index.ts b/packages/app/src/update/index.ts new file mode 100644 index 0000000..5b836ba --- /dev/null +++ b/packages/app/src/update/index.ts @@ -0,0 +1,19 @@ +export { + checkForAvailableUpdate, + resolveAutoUpdateEnabled, + runAutoUpdate +} from './auto-update'; +export { + AUTO_UPDATE_CHECK_TTL_MS, + AUTO_UPDATE_RETRY_BACKOFF_MS, + createAutoUpdateStateStore, + normalizeState +} from './state'; +export type { + AutoUpdateOptions, + AutoUpdateOutcome, + AutoUpdateStateStore, + AutoUpdateStateV1, + InstallationLike, + UpdateInfo +} from './types'; diff --git a/packages/app/src/update/state.ts b/packages/app/src/update/state.ts new file mode 100644 index 0000000..8ddf3bb --- /dev/null +++ b/packages/app/src/update/state.ts @@ -0,0 +1,68 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import type { AutoUpdateStateStore, AutoUpdateStateV1 } from './types'; + +export const AUTO_UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000; +export const AUTO_UPDATE_RETRY_BACKOFF_MS = 24 * 60 * 60 * 1000; + +const STATE_VERSION = 1; +const DEFAULT_STATE_PATH = join(homedir(), '.treq', 'auto-update.json'); + +function createDefaultState(): AutoUpdateStateV1 { + return { version: STATE_VERSION }; +} + +function toOptionalNumber(value: unknown): number | undefined { + if (typeof value !== 'number') return undefined; + if (!Number.isFinite(value)) return undefined; + return value; +} + +function toOptionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function toOptionalStatus(value: unknown): 'success' | 'failed' | undefined { + return value === 'success' || value === 'failed' ? value : undefined; +} + +export function normalizeState(raw: unknown): AutoUpdateStateV1 { + if (typeof raw !== 'object' || raw === null) { + return createDefaultState(); + } + + const value = raw as Record; + + return { + version: STATE_VERSION, + lastCheckedAt: toOptionalNumber(value.lastCheckedAt), + cachedLatestVersion: toOptionalString(value.cachedLatestVersion), + lastAttemptedVersion: toOptionalString(value.lastAttemptedVersion), + lastAttemptedAt: toOptionalNumber(value.lastAttemptedAt), + lastAttemptStatus: toOptionalStatus(value.lastAttemptStatus) + }; +} + +export function createAutoUpdateStateStore(path = DEFAULT_STATE_PATH): AutoUpdateStateStore { + return { + async read(): Promise { + try { + const content = await readFile(path, 'utf8'); + const parsed = JSON.parse(content) as unknown; + return normalizeState(parsed); + } catch { + return createDefaultState(); + } + }, + async write(state: AutoUpdateStateV1): Promise { + const next = normalizeState(state); + try { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(next, null, 2)}\n`, 'utf8'); + } catch { + // Best-effort write only; update checks must never fail command execution. + } + } + }; +} diff --git a/packages/app/src/update/types.ts b/packages/app/src/update/types.ts new file mode 100644 index 0000000..53bbef5 --- /dev/null +++ b/packages/app/src/update/types.ts @@ -0,0 +1,92 @@ +import type { Installation } from '../installation'; + +export interface UpdateInfo { + version: string; + method: Installation.Method; + command: string; +} + +export interface AutoUpdateStateV1 { + version: 1; + lastCheckedAt?: number; + cachedLatestVersion?: string; + lastAttemptedVersion?: string; + lastAttemptedAt?: number; + lastAttemptStatus?: 'success' | 'failed'; +} + +export type AutoUpdateStatus = + | 'disabled' + | 'up_to_date' + | 'available_manual' + | 'updated' + | 'backoff_skipped' + | 'failed'; + +export type AutoUpdateDisabledReason = 'disabled' | 'non_interactive'; + +export interface AutoUpdateOptions { + enabled: boolean; + interactive: boolean; + now?: () => number; +} + +export interface AutoUpdateStateStore { + read(): Promise; + write(state: AutoUpdateStateV1): Promise; +} + +export interface InstallationLike { + VERSION: string; + method(): Promise; + latest(method?: Installation.Method): Promise; + updateCommand(method: Installation.Method, target?: string): string; + upgrade(method: Installation.Method, target: string): Promise; +} + +export type AutoUpdateOutcome = + | { + status: 'disabled'; + reason: AutoUpdateDisabledReason; + } + | { + status: 'up_to_date'; + currentVersion: string; + method: Installation.Method; + checkedAt: number; + } + | { + status: 'available_manual'; + currentVersion: string; + latestVersion: string; + method: Installation.Method; + command: string; + checkedAt: number; + } + | { + status: 'updated'; + currentVersion: string; + latestVersion: string; + method: Installation.Method; + command: string; + checkedAt: number; + } + | { + status: 'backoff_skipped'; + currentVersion: string; + latestVersion: string; + method: Installation.Method; + command: string; + checkedAt: number; + retryAfter: number; + } + | { + status: 'failed'; + currentVersion: string; + latestVersion?: string; + method?: Installation.Method; + command?: string; + checkedAt?: number; + phase: 'check' | 'upgrade'; + error: string; + }; diff --git a/packages/app/test/cmd/auto-update-options.test.ts b/packages/app/test/cmd/auto-update-options.test.ts new file mode 100644 index 0000000..d4ad617 --- /dev/null +++ b/packages/app/test/cmd/auto-update-options.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from 'bun:test'; +import yargs from 'yargs'; +import { openCommand } from '../../src/cmd/open'; +import { tuiCommand } from '../../src/cmd/tui'; +import { webCommand } from '../../src/cmd/web'; + +describe('interactive command auto-update options', () => { + test('tui command exposes --auto-update with default true', () => { + const builder = tuiCommand.builder as Record; + expect(builder['auto-update']?.default).toBe(true); + }); + + test('open command registers --auto-update with default true', () => { + const configured = ( + openCommand.builder as (argv: ReturnType) => ReturnType + )(yargs([])); + const defaults = ( + configured as unknown as { getOptions: () => { default: Record } } + ).getOptions().default; + expect(defaults['auto-update']).toBe(true); + }); + + test('web command registers --auto-update with default true', () => { + const configured = ( + webCommand.builder as (argv: ReturnType) => ReturnType + )(yargs([])); + const defaults = ( + configured as unknown as { getOptions: () => { default: Record } } + ).getOptions().default; + expect(defaults['auto-update']).toBe(true); + }); +}); diff --git a/packages/app/test/update/auto-update.test.ts b/packages/app/test/update/auto-update.test.ts new file mode 100644 index 0000000..e8f31b8 --- /dev/null +++ b/packages/app/test/update/auto-update.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, test } from 'bun:test'; +import type { Installation } from '../../src/installation'; +import { + AUTO_UPDATE_CHECK_TTL_MS, + AUTO_UPDATE_RETRY_BACKOFF_MS, + type AutoUpdateStateStore, + type AutoUpdateStateV1, + checkForAvailableUpdate, + type InstallationLike, + resolveAutoUpdateEnabled, + runAutoUpdate +} from '../../src/update'; + +interface InstallationStub { + installation: InstallationLike; + calls: { + latest: number; + upgrade: number; + }; +} + +function createInstallationStub(options?: { + version?: string; + method?: Installation.Method; + latest?: string; + latestError?: string; + upgradeError?: string; +}): InstallationStub { + const calls = { + latest: 0, + upgrade: 0 + }; + + const installation: InstallationLike = { + VERSION: options?.version ?? '0.1.0', + async method() { + return options?.method ?? 'npm'; + }, + async latest() { + calls.latest += 1; + if (options?.latestError) { + throw new Error(options.latestError); + } + return options?.latest ?? '0.2.0'; + }, + updateCommand(method, target) { + return `${method}:${target ?? 'latest'}`; + }, + async upgrade() { + calls.upgrade += 1; + if (options?.upgradeError) { + throw new Error(options.upgradeError); + } + } + }; + + return { installation, calls }; +} + +function createMemoryStateStore(initial?: AutoUpdateStateV1): { + store: AutoUpdateStateStore; + get: () => AutoUpdateStateV1; +} { + let state: AutoUpdateStateV1 = initial ?? { version: 1 }; + return { + store: { + async read() { + return { ...state }; + }, + async write(next) { + state = { ...next }; + } + }, + get: () => ({ ...state }) + }; +} + +describe('resolveAutoUpdateEnabled', () => { + test('defaults to true', () => { + expect(resolveAutoUpdateEnabled(undefined, {})).toBe(true); + }); + + test('respects option when env is absent', () => { + expect(resolveAutoUpdateEnabled(false, {})).toBe(false); + expect(resolveAutoUpdateEnabled(true, {})).toBe(true); + }); + + test('env override wins over option value', () => { + expect(resolveAutoUpdateEnabled(true, { TREQ_AUTO_UPDATE: '0' })).toBe(false); + expect(resolveAutoUpdateEnabled(false, { TREQ_AUTO_UPDATE: 'true' })).toBe(true); + }); +}); + +describe('checkForAvailableUpdate', () => { + test('returns undefined when latest is older than current', async () => { + const { installation } = createInstallationStub({ + version: '1.2.0', + latest: '1.1.0' + }); + const result = await checkForAvailableUpdate(installation); + expect(result).toBeUndefined(); + }); +}); + +describe('runAutoUpdate', () => { + test('returns disabled when feature is off', async () => { + const { installation } = createInstallationStub(); + const result = await runAutoUpdate( + { enabled: false, interactive: true }, + { installation, stateStore: createMemoryStateStore().store } + ); + expect(result).toEqual({ status: 'disabled', reason: 'disabled' }); + }); + + test('returns disabled when non-interactive', async () => { + const { installation } = createInstallationStub(); + const result = await runAutoUpdate( + { enabled: true, interactive: false }, + { installation, stateStore: createMemoryStateStore().store } + ); + expect(result).toEqual({ status: 'disabled', reason: 'non_interactive' }); + }); + + test('uses cached latest version within ttl', async () => { + const now = 1_000_000; + const { installation, calls } = createInstallationStub({ method: 'unknown' }); + const { store } = createMemoryStateStore({ + version: 1, + lastCheckedAt: now - 1000, + cachedLatestVersion: '0.2.0' + }); + + const result = await runAutoUpdate( + { enabled: true, interactive: true, now: () => now }, + { + installation, + stateStore: store + } + ); + + expect(result.status).toBe('available_manual'); + expect(calls.latest).toBe(0); + }); + + test('refreshes latest when cache is stale', async () => { + const now = 2_000_000; + const { installation, calls } = createInstallationStub({ method: 'unknown', latest: '0.3.0' }); + const { store } = createMemoryStateStore({ + version: 1, + lastCheckedAt: now - AUTO_UPDATE_CHECK_TTL_MS - 1, + cachedLatestVersion: '0.2.0' + }); + + const result = await runAutoUpdate( + { enabled: true, interactive: true, now: () => now }, + { + installation, + stateStore: store + } + ); + + expect(result.status).toBe('available_manual'); + expect(calls.latest).toBe(1); + }); + + test('returns available_manual for unknown install method', async () => { + const { installation } = createInstallationStub({ method: 'unknown', latest: '0.2.0' }); + const result = await runAutoUpdate( + { enabled: true, interactive: true }, + { installation, stateStore: createMemoryStateStore().store } + ); + + expect(result).toMatchObject({ + status: 'available_manual', + latestVersion: '0.2.0', + method: 'unknown', + command: 'unknown:0.2.0' + }); + }); + + test('returns updated and writes success attempt state', async () => { + const now = 3_000_000; + const { installation, calls } = createInstallationStub({ method: 'npm', latest: '0.2.0' }); + const memory = createMemoryStateStore(); + + const result = await runAutoUpdate( + { enabled: true, interactive: true, now: () => now }, + { + installation, + stateStore: memory.store + } + ); + + expect(result.status).toBe('updated'); + expect(calls.upgrade).toBe(1); + expect(memory.get()).toMatchObject({ + lastAttemptedVersion: '0.2.0', + lastAttemptedAt: now, + lastAttemptStatus: 'success' + }); + }); + + test('returns up_to_date and skips upgrade when latest is older than current', async () => { + const { installation, calls } = createInstallationStub({ + version: '1.2.0', + latest: '1.1.0', + method: 'npm' + }); + + const result = await runAutoUpdate( + { enabled: true, interactive: true }, + { installation, stateStore: createMemoryStateStore().store } + ); + + expect(result.status).toBe('up_to_date'); + expect(calls.upgrade).toBe(0); + }); + + test('returns backoff_skipped after recent failure for same target', async () => { + const now = 4_000_000; + const { installation, calls } = createInstallationStub({ method: 'npm' }); + const { store } = createMemoryStateStore({ + version: 1, + lastCheckedAt: now - 500, + cachedLatestVersion: '0.2.0', + lastAttemptedVersion: '0.2.0', + lastAttemptedAt: now - 1000, + lastAttemptStatus: 'failed' + }); + + const result = await runAutoUpdate( + { enabled: true, interactive: true, now: () => now }, + { + installation, + stateStore: store + } + ); + + expect(result.status).toBe('backoff_skipped'); + expect(calls.upgrade).toBe(0); + if (result.status === 'backoff_skipped') { + expect(result.retryAfter).toBe(now - 1000 + AUTO_UPDATE_RETRY_BACKOFF_MS); + } + }); + + test('returns failed phase=upgrade and stores failed attempt on upgrade error', async () => { + const now = 5_000_000; + const { installation, calls } = createInstallationStub({ + method: 'npm', + latest: '0.2.0', + upgradeError: 'permission denied' + }); + const memory = createMemoryStateStore(); + + const result = await runAutoUpdate( + { enabled: true, interactive: true, now: () => now }, + { + installation, + stateStore: memory.store + } + ); + + expect(result).toMatchObject({ + status: 'failed', + phase: 'upgrade', + latestVersion: '0.2.0' + }); + expect(calls.upgrade).toBe(1); + expect(memory.get()).toMatchObject({ + lastAttemptedVersion: '0.2.0', + lastAttemptStatus: 'failed' + }); + }); + + test('returns failed phase=check when latest lookup fails', async () => { + const { installation } = createInstallationStub({ + method: 'npm', + latestError: 'offline' + }); + + const result = await runAutoUpdate( + { enabled: true, interactive: true }, + { installation, stateStore: createMemoryStateStore().store } + ); + + expect(result).toMatchObject({ + status: 'failed', + phase: 'check', + error: 'offline' + }); + }); +}); diff --git a/packages/app/test/update/state.test.ts b/packages/app/test/update/state.test.ts new file mode 100644 index 0000000..d9da648 --- /dev/null +++ b/packages/app/test/update/state.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from 'bun:test'; +import { existsSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createAutoUpdateStateStore, normalizeState } from '../../src/update'; + +async function withTempDir(fn: (dir: string) => Promise): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'treq-test-')); + try { + await fn(dir); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + +describe('normalizeState', () => { + test('returns default state for invalid input', () => { + expect(normalizeState(null)).toEqual({ version: 1 }); + expect(normalizeState('invalid')).toEqual({ version: 1 }); + }); + + test('keeps only recognized fields', () => { + const state = normalizeState({ + version: 999, + lastCheckedAt: 100, + cachedLatestVersion: '0.2.0', + lastAttemptedVersion: '0.2.0', + lastAttemptedAt: 200, + lastAttemptStatus: 'failed', + extra: true + }); + + expect(state).toEqual({ + version: 1, + lastCheckedAt: 100, + cachedLatestVersion: '0.2.0', + lastAttemptedVersion: '0.2.0', + lastAttemptedAt: 200, + lastAttemptStatus: 'failed' + }); + }); +}); + +describe('createAutoUpdateStateStore', () => { + test('read returns default state when file is missing', async () => { + await withTempDir(async (dir) => { + const path = `${dir}/auto-update.json`; + const store = createAutoUpdateStateStore(path); + const state = await store.read(); + expect(state).toEqual({ version: 1 }); + }); + }); + + test('read returns default state when json is invalid', async () => { + await withTempDir(async (dir) => { + const path = `${dir}/auto-update.json`; + await Bun.write(path, '{not valid json}'); + + const store = createAutoUpdateStateStore(path); + const state = await store.read(); + expect(state).toEqual({ version: 1 }); + }); + }); + + test('write then read preserves normalized state', async () => { + await withTempDir(async (dir) => { + const path = `${dir}/state/auto-update.json`; + const store = createAutoUpdateStateStore(path); + + await store.write({ + version: 1, + lastCheckedAt: 111, + cachedLatestVersion: '0.3.0', + lastAttemptedVersion: '0.3.0', + lastAttemptedAt: 222, + lastAttemptStatus: 'success' + }); + + expect(existsSync(path)).toBe(true); + const state = await store.read(); + expect(state).toEqual({ + version: 1, + lastCheckedAt: 111, + cachedLatestVersion: '0.3.0', + lastAttemptedVersion: '0.3.0', + lastAttemptedAt: 222, + lastAttemptStatus: 'success' + }); + }); + }); + + test('write is best-effort when target path cannot be created', async () => { + await withTempDir(async (dir) => { + await Bun.write(`${dir}/blocked`, 'x'); + const path = `${dir}/blocked/auto-update.json`; + const store = createAutoUpdateStateStore(path); + + await expect( + store.write({ + version: 1, + lastCheckedAt: 1, + cachedLatestVersion: '0.2.0' + }) + ).resolves.toBeUndefined(); + }); + }); +});