From 466069fde45e69efbf45669d1e9aee9b1d59b208 Mon Sep 17 00:00:00 2001 From: Pedro Filho Date: Tue, 8 Sep 2026 11:12:46 -0300 Subject: [PATCH 1/2] Add optional macOS login startup to setup --- README.md | 50 ++++++- src/cli.test.ts | 51 +++++++ src/cli.ts | 68 +++++++++- src/launch-agent.test.ts | 225 +++++++++++++++++++++++++++++++ src/launch-agent.ts | 280 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 668 insertions(+), 6 deletions(-) create mode 100644 src/launch-agent.test.ts create mode 100644 src/launch-agent.ts diff --git a/README.md b/README.md index 4354428..9f4ed57 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,52 @@ bun add -g tokenmaxx tokenmaxx #starts the dashboard ``` +### Run in the background and start at login + +The dashboard connects to a separate background manager. You can close the dashboard or its terminal and keep using your AI clients. To start the manager without opening the dashboard: + +```bash +tokenmaxx daemon start +``` + +To route your clients and have macOS start the manager automatically whenever you log in: + +```bash +tokenmaxx install --autostart +``` + +For pi, use `tokenmaxx install pi --autostart`. If your clients are already configured, add login startup on its own: + +```bash +tokenmaxx daemon install +``` + +Run this as your normal macOS user, without `sudo`. It starts the manager immediately and installs a per-user LaunchAgent that restarts it if it exits. After restarting your Mac, it starts when you log in, when your login Keychain is available. It does not run while the Mac is asleep or shut down. + +The background item is named **tokenmaxx** in System Settings → General → Login Items & Extensions. A small launcher app provides that name; launching Bun directly can make macOS display Bun's signing-certificate owner, such as “Jarred Sumner,” instead. + +```bash +tokenmaxx daemon status # manager health and login startup configuration +tokenmaxx daemon stop # stop now; startup remains installed for the next login +tokenmaxx daemon start # start again under macOS supervision +tokenmaxx daemon uninstall # remove login startup; keep a standalone manager running +``` + +Commands that need the manager, including opening the dashboard, start it again after `daemon stop`. To remove login startup and stop the manager entirely, run `tokenmaxx daemon uninstall` followed by `tokenmaxx daemon stop`. Client routing remains configured; run `tokenmaxx uninstall` (and `tokenmaxx uninstall pi` if applicable) to restore direct provider connections. + +The installer creates: + +- `~/Library/LaunchAgents/sh.tokenmaxx.daemon.plist` +- `~/Applications/tokenmaxx.app` (the background launcher) + +Logs remain in `~/.tokenmaxx/runtime/daemon.log`. `tokenmaxx doctor` also reports whether login startup is installed. + +Set `TOKENMAXX_HOME`, `TOKENMAXX_PROXY_PORT`, and any custom `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, or `PI_CODING_AGENT_DIR` before installing startup. These settings and the executable search path are saved for the background service; unrelated environment variables and API keys are not copied. One startup configuration is supported per macOS user. Use the same `TOKENMAXX_HOME` when managing it, and remove the previous startup configuration before installing one for a different directory. + +The launcher uses absolute paths to Bun and the installed tokenmaxx entrypoint. Re-run `tokenmaxx daemon install` after moving or reinstalling either tool to refresh those paths. Install from a permanent package location, rather than a temporary `bunx` download or development checkout that you intend to delete. + +If startup fails, check `tokenmaxx daemon status`, `tokenmaxx doctor`, and the daemon log. Confirm that the tokenmaxx background item is allowed in System Settings. Running `tokenmaxx daemon install` again refreshes the configuration and retries startup; it may briefly interrupt requests while restarting the manager. + ## What it does You run a fleet of coding agents using multiple Codex or Claude accounts: @@ -75,8 +121,10 @@ A single loopback proxy on `127.0.0.1:8459`, and the clients you already use. ```text tokenmaxx live dashboard tokenmaxx login sign in; isolated, idempotent -tokenmaxx install route native codex & claude +tokenmaxx install [pi] [--autostart] route clients; optionally start at login tokenmaxx uninstall restore native config +tokenmaxx daemon start | stop | status manage the background manager +tokenmaxx daemon install | uninstall add or remove macOS login startup tokenmaxx switch make an account active tokenmaxx logout [codex|claude] sign out; the credential is deleted tokenmaxx auto [--threshold N] diff --git a/src/cli.test.ts b/src/cli.test.ts index 85341a9..b3da33a 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { stripTerminalNoise } from './cli.ts' +import { managerAvailable } from './ipc.ts' +import { applicationPaths } from './paths.ts' describe('stripTerminalNoise', () => { test('terminal chatter never survives into a typed answer', () => { @@ -9,3 +14,49 @@ describe('stripTerminalNoise', () => { expect(stripTerminalNoise(' plain-key-42 ')).toBe('plain-key-42') }) }) + +test('status and invalid setup arguments do not start a manager or configure clients', async () => { + const directory = await mkdtemp(join(tmpdir(), 'tmx-cli-')) + const reservation = Bun.listen({ hostname: '127.0.0.1', port: 0, socket: { data() {} } }) + const paths = applicationPaths({ + TOKENMAXX_HOME: join(directory, 'state'), + TOKENMAXX_PROXY_PORT: String(reservation.port) + }) + reservation.stop() + try { + for (const args of [ + ['daemon', 'status'], + ['install', 'unknown', '--autostart'] + ]) { + const child = Bun.spawn([process.execPath, join(import.meta.dir, 'index.ts'), ...args], { + env: { + ...process.env, + CLAUDE_CONFIG_DIR: join(directory, 'claude'), + CODEX_HOME: join(directory, 'codex'), + TOKENMAXX_HOME: paths.root, + TOKENMAXX_PROXY_PORT: String(paths.proxyPort) + }, + stderr: 'pipe', + stdout: 'pipe' + }) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited + ]) + if (args[0] === 'daemon') { + expect(exitCode).toBe(0) + expect(stdout).toContain('Login startup: not installed') + expect(stdout).toContain('stopped') + } else { + expect(exitCode).toBe(1) + expect(stderr).toContain('Usage: tokenmaxx install [pi] [--autostart]') + } + expect(await managerAvailable(paths.managerSocket)).toBe(false) + expect(await Bun.file(join(directory, 'codex', 'config.toml')).exists()).toBe(false) + expect(await Bun.file(join(directory, 'claude', 'settings.json')).exists()).toBe(false) + } + } finally { + await rm(directory, { force: true, recursive: true }) + } +}) diff --git a/src/cli.ts b/src/cli.ts index 356b520..61e988a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -31,6 +31,7 @@ import { requestSwitch, startManagerServer } from './ipc.ts' +import { LaunchAgent } from './launch-agent.ts' import { AccountManager } from './manager.ts' import { type ApplicationPaths, applicationPaths, ensureApplicationPaths } from './paths.ts' import { proxyIdentity } from './proxy.ts' @@ -192,6 +193,7 @@ const EmptyResultSchema = z.unknown() interface ApplicationContext { paths: ApplicationPaths store: StateStore + launchAgent: LaunchAgent } function providerFromCli(value: string): 'openai' | 'anthropic' { @@ -244,7 +246,7 @@ function help(): string { 'sign in an account · re-run to re-auth', 'add --api-key to use an API key instead' ), - row('install [pi]', 'route codex & claude, or pi, through tokenmaxx'), + row('install [pi] [--autostart]', 'route clients; optionally start at macOS login'), row('uninstall [pi]', 'restore your original config'), '', head('Everyday'), @@ -262,6 +264,7 @@ function help(): string { row('refresh', 're-probe usage now'), row('doctor', 'check tools, proxy, and config'), row('daemon ', 'the background manager'), + row('daemon ', 'add or remove macOS login startup'), '', head('Auto-rotation'), dim(" The threshold is measured against the active account's fullest rate-limit"), @@ -282,7 +285,7 @@ function help(): string { async function createContext(): Promise { const paths = applicationPaths() await ensureApplicationPaths(paths) - return { paths, store: createStateStore(paths.database) } + return { launchAgent: new LaunchAgent(paths), paths, store: createStateStore(paths.database) } } async function runDaemon(context: ApplicationContext): Promise { @@ -355,8 +358,22 @@ async function startDaemon(context: ApplicationContext): Promise { if (await managerAvailable(context.paths.managerSocket)) { return } + const managed = await context.launchAgent.installed() + if (managed && (await context.launchAgent.loaded())) await stopDaemon(context) await replacePortOccupant(context.paths.proxyPort) await mkdir(context.paths.runtime, { mode: 0o700, recursive: true }) + if (managed) { + await context.launchAgent.start() + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + if (await managerAvailable(context.paths.managerSocket)) return + await Bun.sleep(100) + } + throw new ApplicationError( + 'DAEMON_START_FAILED', + `Login startup loaded, but the manager did not respond. Check ${join(context.paths.runtime, 'daemon.log')} and System Settings → General → Login Items & Extensions.` + ) + } const entrypoint = process.argv[1] if (entrypoint === undefined) { throw new ApplicationError('ENTRYPOINT_MISSING', 'Cannot locate the CLI entrypoint') @@ -424,6 +441,7 @@ async function forceStopDaemon(context: ApplicationContext): Promise { } async function stopDaemon(context: ApplicationContext): Promise { + if (await context.launchAgent.installed()) await context.launchAgent.stop() await managerRequest({ method: 'manager/stop', schema: EmptyResultSchema, @@ -447,6 +465,28 @@ async function stopDaemon(context: ApplicationContext): Promise { process.stdout.write('Manager daemon stopped.\n') } +async function installLoginStartup(context: ApplicationContext): Promise { + await context.launchAgent.install() + await stopDaemon(context) + await startDaemon(context) + process.stdout.write( + 'Login startup installed. macOS keeps tokenmaxx running in the background; you can close the terminal.\n' + ) +} + +async function uninstallLoginStartup(context: ApplicationContext): Promise { + if (!(await context.launchAgent.installed())) { + process.stdout.write('Login startup is not installed for this TOKENMAXX_HOME.\n') + return + } + await stopDaemon(context) + await context.launchAgent.uninstall() + await startDaemon(context) + process.stdout.write( + 'Login startup removed. The manager is still running until logout; use tokenmaxx daemon stop to stop it now.\n' + ) +} + async function ensureDaemon(context: ApplicationContext): Promise { if (!(await managerAvailable(context.paths.managerSocket))) { await startDaemon(context) @@ -869,6 +909,9 @@ async function doctor(context: ApplicationContext): Promise { process.stdout.write(`${Bun.which('security') === null ? 'missing' : 'ok '} security\n`) const running = await managerAvailable(context.paths.managerSocket) const daemonVersion = running ? await managerVersion(context.paths.managerSocket) : null + process.stdout.write( + `${(await context.launchAgent.installed()) ? 'installed' : 'not installed'} macOS login startup (tokenmaxx daemon install)\n` + ) const unreachable = !running && (await proxyIdentity(context.paths.proxyPort)) === 'tokenmaxx' if (unreachable) { const processId = await portOwnerProcessId(context.paths.proxyPort) @@ -1063,14 +1106,26 @@ export async function runCli(rawArguments: readonly string[]): Promise { case 'list': listAccounts(context) return 0 - case 'install': - await installConfig(context, arguments_[1]) + case 'install': { + const targets = arguments_.slice(1).filter(argument => argument !== '--autostart') + if (targets.length > 1 || (targets[0] !== undefined && targets[0] !== 'pi')) { + throw new ApplicationError('USAGE', 'Usage: tokenmaxx install [pi] [--autostart]') + } + if (arguments_.includes('--autostart')) await installLoginStartup(context) + await installConfig(context, targets[0]) return 0 + } case 'uninstall': await uninstallConfig(arguments_[1]) return 0 case 'daemon': switch (arguments_[1]) { + case 'install': + await installLoginStartup(context) + return 0 + case 'uninstall': + await uninstallLoginStartup(context) + return 0 case 'run': await runDaemon(context) return 0 @@ -1082,6 +1137,9 @@ export async function runCli(rawArguments: readonly string[]): Promise { await stopDaemon(context) return 0 case 'status': { + process.stdout.write( + `Login startup: ${(await context.launchAgent.installed()) ? 'installed' : 'not installed'}\n` + ) if (await managerAvailable(context.paths.managerSocket)) { process.stdout.write('running\n') return 0 @@ -1098,7 +1156,7 @@ export async function runCli(rawArguments: readonly string[]): Promise { return 0 } default: - throw new ApplicationError('USAGE', 'Usage: daemon ') + throw new ApplicationError('USAGE', 'Usage: daemon ') } case 'doctor': await doctor(context) diff --git a/src/launch-agent.test.ts b/src/launch-agent.test.ts new file mode 100644 index 0000000..d3e10df --- /dev/null +++ b/src/launch-agent.test.ts @@ -0,0 +1,225 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { z } from 'zod' +import { managerAvailable, managerRequest } from './ipc.ts' +import { LaunchAgent, type LaunchAgentOptions, launchAgentFiles } from './launch-agent.ts' +import { applicationPaths, ensureApplicationPaths } from './paths.ts' + +const directories: string[] = [] + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'tmx-')) + directories.push(directory) + return directory +} + +afterEach(async () => { + for (const directory of directories.splice(0)) + await rm(directory, { force: true, recursive: true }) +}) + +async function options(): Promise { + const directory = await temporaryDirectory() + return { + bunPath: process.execPath, + entrypoint: join(import.meta.dir, 'index.ts'), + environment: { + ANTHROPIC_API_KEY: 'must-not-be-written', + CLAUDE_CONFIG_DIR: join(directory, 'claude'), + CODEX_HOME: join(directory, 'codex'), + OPENAI_API_KEY: 'must-not-be-written', + PATH: '/usr/bin:/bin', + PI_CODING_AGENT_DIR: join(directory, 'pi'), + TOKENMAXX_FIXTURE: 'must-not-be-written' + }, + paths: applicationPaths({ + TOKENMAXX_HOME: join(directory, 'state'), + TOKENMAXX_PROXY_PORT: '18459' + }), + userDirectory: directory + } +} + +async function parsePlist(content: string): Promise> { + const child = Bun.spawn(['/usr/bin/plutil', '-convert', 'json', '-o', '-', '--', '-'], { + stderr: 'pipe', + stdin: new Blob([content]), + stdout: 'pipe' + }) + const result = (await new Response(child.stdout).json()) as Record + expect(await child.exited).toBe(0) + return result +} + +describe('login startup files', () => { + test('only the settings needed by the daemon are persisted', async () => { + const input = await options() + const plan = launchAgentFiles(input) + const launchAgent = plan.files.find(file => file.path === plan.plistPath) + expect(launchAgent?.content).toContain(input.paths.root) + expect(launchAgent?.content).toContain('18459') + expect(launchAgent?.content).toContain(input.environment.CODEX_HOME ?? '') + expect(plan.files.map(file => file.content).join('\n')).not.toContain('must-not-be-written') + }) + + test('the launcher preserves executable paths and arguments without evaluating shell characters', async () => { + const input = await options() + const special = join( + input.userDirectory, + "space & ' $(touch unexpected) `touch unexpected2`" + ) + await mkdir(special) + const bunPath = join(special, 'bun') + await symlink(process.execPath, bunPath) + const entrypoint = join(special, 'entry.ts') + await writeFile(entrypoint, 'process.stdout.write(JSON.stringify(process.argv.slice(2)))') + const plan = launchAgentFiles({ ...input, bunPath, entrypoint }) + const launcher = plan.files.find(file => file.mode === 0o755) + if (launcher === undefined) throw new Error('Missing launcher') + await mkdir(dirname(launcher.path), { recursive: true }) + await writeFile(launcher.path, launcher.content) + await chmod(launcher.path, launcher.mode) + const args = ['daemon', 'run', "an argument's spaces", '$(touch injected)'] + const child = Bun.spawn([launcher.path, ...args], { + cwd: input.userDirectory, + stderr: 'pipe', + stdout: 'pipe' + }) + expect(await new Response(child.stdout).json()).toEqual(args) + expect(await child.exited).toBe(0) + for (const name of ['unexpected', 'unexpected2', 'injected']) { + expect(await Bun.file(join(input.userDirectory, name)).exists()).toBe(false) + } + }) + + test.skipIf(process.platform !== 'darwin')( + 'macOS parses the plists and resolves the intended service settings', + async () => { + const input = await options() + input.userDirectory = join(input.userDirectory, "space & ' characters") + const plan = launchAgentFiles(input) + const launchAgent = await parsePlist(plan.files[2]?.content ?? '') + expect(launchAgent).toMatchObject({ + EnvironmentVariables: { + TOKENMAXX_HOME: input.paths.root, + TOKENMAXX_PROXY_PORT: '18459' + }, + KeepAlive: true, + Label: 'sh.tokenmaxx.daemon', + RunAtLoad: true, + WorkingDirectory: input.userDirectory + }) + expect(launchAgent.ProgramArguments).toEqual([ + join(plan.appPath, 'Contents', 'MacOS', 'tokenmaxx'), + 'daemon', + 'run' + ]) + expect(await parsePlist(plan.files[0]?.content ?? '')).toMatchObject({ + CFBundleDisplayName: 'tokenmaxx', + CFBundleName: 'tokenmaxx', + LSUIElement: true + }) + } + ) + + test.skipIf(process.platform === 'darwin')( + 'other platforms reject installation without writing files', + async () => { + const input = await options() + const agent = new LaunchAgent(input.paths, input) + expect(await agent.installed()).toBe(false) + await expect(agent.install()).rejects.toThrow('macOS user') + expect(await Bun.file(launchAgentFiles(input).plistPath).exists()).toBe(false) + } + ) +}) + +async function waitFor( + read: () => Promise, + ready: (value: Result) => boolean +): Promise { + const deadline = Date.now() + 20_000 + for (;;) { + const value = await read() + if (ready(value)) return value + if (Date.now() >= deadline) + throw new Error(`Timed out waiting for launchd: ${JSON.stringify(value)}`) + await Bun.sleep(100) + } +} + +test.skipIf(process.platform !== 'darwin' || process.env.TOKENMAXX_TEST_LAUNCHD !== '1')( + 'launchd runs one manager, restarts it after exit, and supports stop, reinstall, and removal', + async () => { + const input = await options() + input.label = `sh.tokenmaxx.test.${crypto.randomUUID()}` + const reservation = Bun.listen({ hostname: '127.0.0.1', port: 0, socket: { data() {} } }) + input.paths.proxyPort = reservation.port + reservation.stop() + await ensureApplicationPaths(input.paths) + const agent = new LaunchAgent(input.paths, input) + const ping = () => + managerRequest({ + method: 'manager/ping', + schema: z.object({ processId: z.number() }), + socketPath: input.paths.managerSocket, + timeoutMilliseconds: 500 + }) + .then(result => result.processId) + .catch(() => null) + try { + const appPath = launchAgentFiles(input).appPath + await mkdir(appPath, { recursive: true }) + await expect(agent.install()).rejects.toThrow('existing app') + await rm(appPath, { recursive: true }) + await agent.install() + expect(await agent.installed()).toBe(true) + await agent.start() + const firstPid = await waitFor(ping, pid => pid !== null) + await agent.start() + expect(await ping()).toBe(firstPid) + const proxy = await fetch(`http://127.0.0.1:${input.paths.proxyPort}/`) + expect(await proxy.text()).toStartWith('tokenmaxx proxy') + if (firstPid === null) throw new Error('Missing manager process') + process.kill(firstPid, 'SIGTERM') + await waitFor(ping, pid => pid !== null && pid !== firstPid) + await agent.stop() + await waitFor( + () => managerAvailable(input.paths.managerSocket), + running => !running + ) + expect(await agent.loaded()).toBe(false) + expect(await agent.installed()).toBe(true) + await waitFor( + () => + stat(input.paths.managerLock).then( + () => true, + () => false + ), + held => !held + ) + await agent.install() + await agent.start() + await waitFor(ping, pid => pid !== null) + const otherPaths = applicationPaths({ TOKENMAXX_HOME: join(input.userDirectory, 'other-state') }) + const other = new LaunchAgent(otherPaths, { ...input, paths: otherPaths }) + expect(await other.installed()).toBe(false) + await expect(other.install()).rejects.toThrow('different startup configuration') + await other.uninstall() + expect(await agent.installed()).toBe(true) + } finally { + await agent.uninstall() + await waitFor( + () => managerAvailable(input.paths.managerSocket), + running => !running + ) + } + expect(await agent.installed()).toBe(false) + expect(await agent.loaded()).toBe(false) + expect(await readFile(input.paths.database)).toBeDefined() + await agent.uninstall() + }, + 60_000 +) diff --git a/src/launch-agent.ts b/src/launch-agent.ts new file mode 100644 index 0000000..3a9924e --- /dev/null +++ b/src/launch-agent.ts @@ -0,0 +1,280 @@ +import { constants } from 'node:fs' +import { access, chmod, lstat, mkdir, rm, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { ApplicationError } from './errors.ts' +import type { ApplicationPaths } from './paths.ts' + +type PlistValue = string | number | boolean | PlistValue[] | { [key: string]: PlistValue } + +function xml(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') +} + +function plistValue(value: PlistValue): string { + if (typeof value === 'string') return `${xml(value)}` + if (typeof value === 'number') return `${value}` + if (typeof value === 'boolean') return value ? '' : '' + if (Array.isArray(value)) return `${value.map(plistValue).join('')}` + return `${Object.entries(value) + .map(([key, item]) => `${xml(key)}${plistValue(item)}`) + .join('')}` +} + +function plist(value: PlistValue): string { + return ` + +${plistValue(value)} +` +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'` +} + +export interface LaunchAgentOptions { + paths: ApplicationPaths + bunPath: string + entrypoint: string + userDirectory: string + environment: NodeJS.ProcessEnv + label?: string +} + +export function launchAgentFiles(input: LaunchAgentOptions) { + const label = input.label ?? 'sh.tokenmaxx.daemon' + const bundleId = label.replace(/\.daemon$/, '') + const appPath = join(input.userDirectory, 'Applications', 'tokenmaxx.app') + const executable = join(appPath, 'Contents', 'MacOS', 'tokenmaxx') + const plistPath = join(input.userDirectory, 'Library', 'LaunchAgents', `${label}.plist`) + const environment: Record = { + PATH: [ + ...new Set([ + dirname(input.bunPath), + join(input.userDirectory, '.bun', 'bin'), + join(input.userDirectory, '.local', 'bin'), + '/opt/homebrew/bin', + '/usr/local/bin', + '/usr/bin', + '/bin', + '/usr/sbin', + '/sbin', + ...(input.environment.PATH ?? '').split(':').filter(Boolean) + ]) + ].join(':'), + TOKENMAXX_HOME: input.paths.root, + TOKENMAXX_PROXY_PORT: String(input.paths.proxyPort) + } + for (const key of ['CODEX_HOME', 'CLAUDE_CONFIG_DIR', 'PI_CODING_AGENT_DIR']) { + const value = input.environment[key] + if (value !== undefined) environment[key] = resolve(value) + } + const logPath = join(input.paths.runtime, 'daemon.log') + return { + appPath, + bundleId, + files: [ + { + content: plist({ + CFBundleDisplayName: 'tokenmaxx', + CFBundleExecutable: 'tokenmaxx', + CFBundleIdentifier: bundleId, + CFBundleInfoDictionaryVersion: '6.0', + CFBundleName: 'tokenmaxx', + CFBundlePackageType: 'APPL', + CFBundleVersion: '1', + LSUIElement: true + }), + mode: 0o644, + path: join(appPath, 'Contents', 'Info.plist') + }, + { + content: `#!/bin/sh\nexec ${shellQuote(input.bunPath)} ${shellQuote(resolve(input.entrypoint))} "$@"\n`, + mode: 0o755, + path: executable + }, + { + content: plist({ + AssociatedBundleIdentifiers: [bundleId], + EnvironmentVariables: environment, + ExitTimeOut: 10, + KeepAlive: true, + Label: label, + ProgramArguments: [executable, 'daemon', 'run'], + RunAtLoad: true, + StandardErrorPath: logPath, + StandardOutPath: logPath, + ThrottleInterval: 10, + WorkingDirectory: input.userDirectory + }), + mode: 0o644, + path: plistPath + } + ], + label, + plistPath + } +} + +async function run( + command: string[] +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const child = Bun.spawn(command, { stderr: 'pipe', stdin: 'ignore', stdout: 'pipe' }) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited + ]) + return { exitCode, stderr, stdout } +} + +export class LaunchAgent { + readonly #options: LaunchAgentOptions + readonly #configuration: ReturnType + readonly #domain = `gui/${process.getuid?.()}` + + public constructor(paths: ApplicationPaths, options?: LaunchAgentOptions) { + this.#options = options ?? { + bunPath: Bun.which('bun') ?? process.execPath, + entrypoint: process.argv[1] ?? '', + environment: process.env, + paths, + userDirectory: homedir() + } + this.#configuration = launchAgentFiles(this.#options) + } + + get #service(): string { + return `${this.#domain}/${this.#configuration.label}` + } + + public async installed(): Promise { + if (process.platform !== 'darwin' || !(await Bun.file(this.#configuration.plistPath).exists())) { + return false + } + const result = await run([ + '/usr/bin/plutil', + '-extract', + 'EnvironmentVariables.TOKENMAXX_HOME', + 'raw', + '-o', + '-', + this.#configuration.plistPath + ]) + return result.exitCode === 0 && result.stdout.replace(/\n$/, '') === this.#options.paths.root + } + + async #checkAppOwnership(): Promise { + const existing = await lstat(this.#configuration.appPath).catch(error => { + if (error.code === 'ENOENT') return null + throw error + }) + if (existing === null) return + const identity = await run([ + '/usr/bin/plutil', + '-extract', + 'CFBundleIdentifier', + 'raw', + '-o', + '-', + join(this.#configuration.appPath, 'Contents', 'Info.plist') + ]) + if ( + existing.isSymbolicLink() || + identity.exitCode !== 0 || + identity.stdout.trim() !== this.#configuration.bundleId + ) { + throw new ApplicationError( + 'AUTOSTART_CONFLICT', + `An existing app at ${this.#configuration.appPath} is not the tokenmaxx background launcher` + ) + } + } + + public async install(): Promise { + if (process.platform !== 'darwin' || process.getuid?.() === 0) { + throw new ApplicationError( + 'AUTOSTART_UNSUPPORTED', + 'Install login startup as your macOS user, without sudo' + ) + } + await this.#launchctl(['print', this.#domain]) + if ((await Bun.file(this.#configuration.plistPath).exists()) && !(await this.installed())) { + throw new ApplicationError( + 'AUTOSTART_CONFLICT', + `A different startup configuration exists at ${this.#configuration.plistPath}; remove it before installing for this TOKENMAXX_HOME` + ) + } + if (!(await Bun.file(this.#options.entrypoint).exists())) { + throw new ApplicationError('ENTRYPOINT_MISSING', 'Cannot locate the CLI entrypoint') + } + await access(this.#options.bunPath, constants.X_OK) + await this.#checkAppOwnership() + for (const file of this.#configuration.files) { + await mkdir(dirname(file.path), { recursive: true }) + await writeFile(file.path, file.content, { mode: file.mode }) + await chmod(file.path, file.mode) + } + } + + async #launchctl(arguments_: string[]): Promise { + const result = await run(['/bin/launchctl', ...arguments_]) + if (result.exitCode !== 0) { + throw new ApplicationError( + 'LAUNCHCTL_FAILED', + `launchctl ${arguments_[0]} failed (${result.exitCode}): ${result.stderr.trim() || result.stdout.trim()}` + ) + } + } + + public async loaded(): Promise { + return (await run(['/bin/launchctl', 'print', this.#service])).exitCode === 0 + } + + public async start(): Promise { + await this.#launchctl(['enable', this.#service]) + if (!(await this.loaded())) { + const deadline = Date.now() + 10_000 + for (;;) { + const result = await run([ + '/bin/launchctl', + 'bootstrap', + this.#domain, + this.#configuration.plistPath + ]) + if (result.exitCode === 0 || (await this.loaded())) break + if (result.exitCode !== 5 || Date.now() >= deadline) { + throw new ApplicationError( + 'LAUNCHCTL_FAILED', + `Could not load login startup: ${result.stderr.trim()}. Check System Settings → General → Login Items & Extensions and ${join(this.#options.paths.runtime, 'daemon.log')}` + ) + } + await Bun.sleep(200) + } + } + await this.#launchctl(['kickstart', this.#service]) + } + + public async stop(): Promise { + if (!(await this.loaded())) return + await this.#launchctl(['bootout', this.#service]) + const deadline = Date.now() + 15_000 + while (await this.loaded()) { + if (Date.now() >= deadline) { + throw new ApplicationError( + 'LAUNCHCTL_FAILED', + 'Timed out waiting for macOS to unload tokenmaxx' + ) + } + await Bun.sleep(100) + } + } + + public async uninstall(): Promise { + if (!(await this.installed())) return + await this.#checkAppOwnership() + await this.stop() + await rm(this.#configuration.plistPath) + await rm(this.#configuration.appPath, { force: true, recursive: true }) + } +} From 945bb371489ae8dbf7c547c175ad8bf7c53cf412 Mon Sep 17 00:00:00 2001 From: Pedro Filho Date: Tue, 8 Sep 2026 11:39:34 -0300 Subject: [PATCH 2/2] Make uninstall remove the complete setup and global package --- README.md | 25 +++- src/claude.ts | 8 +- src/cli.ts | 68 ++++++--- src/config-backup.ts | 131 +++++++++++++++++ src/config-install.test.ts | 12 +- src/config-install.ts | 96 +++++++++---- src/launch-agent.test.ts | 25 +++- src/launch-agent.ts | 32 ++++- src/package-uninstall.test.ts | 44 ++++++ src/package-uninstall.ts | 61 ++++++++ src/uninstall.test.ts | 258 ++++++++++++++++++++++++++++++++++ src/uninstall.ts | 115 +++++++++++++++ src/vault.test.ts | 28 ++++ src/vault.ts | 17 +++ 14 files changed, 856 insertions(+), 64 deletions(-) create mode 100644 src/config-backup.ts create mode 100644 src/package-uninstall.test.ts create mode 100644 src/package-uninstall.ts create mode 100644 src/uninstall.test.ts create mode 100644 src/uninstall.ts create mode 100644 src/vault.test.ts diff --git a/README.md b/README.md index 9f4ed57..20b64de 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,26 @@ The background item is named **tokenmaxx** in System Settings → General → Lo tokenmaxx daemon status # manager health and login startup configuration tokenmaxx daemon stop # stop now; startup remains installed for the next login tokenmaxx daemon start # start again under macOS supervision -tokenmaxx daemon uninstall # remove login startup; keep a standalone manager running +tokenmaxx daemon disable # remove login startup and stop the manager ``` -Commands that need the manager, including opening the dashboard, start it again after `daemon stop`. To remove login startup and stop the manager entirely, run `tokenmaxx daemon uninstall` followed by `tokenmaxx daemon stop`. Client routing remains configured; run `tokenmaxx uninstall` (and `tokenmaxx uninstall pi` if applicable) to restore direct provider connections. +Commands that need the manager, including opening the dashboard, start it again after `daemon stop`. `daemon disable` removes automatic startup while retaining your accounts and data for manual use. + +### Uninstall completely + +```bash +tokenmaxx uninstall +``` + +This stops the manager, restores Codex, Claude, and pi routing, removes the LaunchAgent and launcher app, deletes tokenmaxx's Keychain credentials (including orphaned entries), and removes its account database, usage history, preferences, logs, isolated profiles, and saved setup files. It then asks the owning global package manager—Bun, npm, pnpm, or Yarn—to remove the CLI package. The manager is not restarted. + +Setup records the client settings it replaces. Uninstall restores the original files when they are unchanged, removes files and empty client directories created by setup, and preserves unrelated settings and subsequent user edits. Older installations without these records can have their managed routing removed, but previously overwritten settings cannot be recovered. Native client logins, unrelated files, and other packages are preserved. When run from a source checkout, the checkout is kept. + +To restore routing while keeping tokenmaxx installed, use `tokenmaxx uninstall routing`. `tokenmaxx uninstall pi` restores only pi routing. These commands do not delete saved accounts or usage history. + +If cleanup fails, the command reports the failed step and keeps the remaining recovery data for a retry. Package removal happens only after setup cleanup succeeds. If the package manager cannot be identified, the command reports that the package still needs removal. + +### Startup files and troubleshooting The installer creates: @@ -122,9 +138,10 @@ A single loopback proxy on `127.0.0.1:8459`, and the clients you already use. tokenmaxx live dashboard tokenmaxx login sign in; isolated, idempotent tokenmaxx install [pi] [--autostart] route clients; optionally start at login -tokenmaxx uninstall restore native config +tokenmaxx uninstall remove all setup, data, credentials, and the global package +tokenmaxx uninstall restore routing while keeping tokenmaxx installed tokenmaxx daemon start | stop | status manage the background manager -tokenmaxx daemon install | uninstall add or remove macOS login startup +tokenmaxx daemon install | disable add or remove macOS login startup tokenmaxx switch make an account active tokenmaxx logout [codex|claude] sign out; the credential is deleted tokenmaxx auto [--threshold N] diff --git a/src/claude.ts b/src/claude.ts index 4fec5bd..f1b16d1 100644 --- a/src/claude.ts +++ b/src/claude.ts @@ -181,7 +181,7 @@ export async function removeClaudeProfile( profilePath: string, dependencies: ClaudeLoginDependencies = defaultClaudeLoginDependencies() ): Promise { - await dependencies.captured([ + const result = await dependencies.captured([ 'security', 'delete-generic-password', '-a', @@ -189,6 +189,12 @@ export async function removeClaudeProfile( '-s', cliKeychainService(profilePath) ]) + if (result.exitCode !== 0 && result.exitCode !== 44) { + throw new ApplicationError( + 'KEYCHAIN_DELETE_FAILED', + 'Could not remove the isolated Claude profile credential' + ) + } await rm(profilePath, { force: true, recursive: true }) } diff --git a/src/cli.ts b/src/cli.ts index 61e988a..15f3ab4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -33,10 +33,12 @@ import { } from './ipc.ts' import { LaunchAgent } from './launch-agent.ts' import { AccountManager } from './manager.ts' +import { removeGlobalPackage } from './package-uninstall.ts' import { type ApplicationPaths, applicationPaths, ensureApplicationPaths } from './paths.ts' import { proxyIdentity } from './proxy.ts' import { createStateStore, type StateStore } from './storage.ts' import { renderDashboard } from './ui.ts' +import { uninstallTokenmaxx } from './uninstall.ts' import { createMacOsKeychainVault } from './vault.ts' import { availableUpdate, installedVersion, VERSION } from './version.ts' @@ -247,7 +249,8 @@ function help(): string { 'add --api-key to use an API key instead' ), row('install [pi] [--autostart]', 'route clients; optionally start at macOS login'), - row('uninstall [pi]', 'restore your original config'), + row('uninstall', 'remove setup, saved credentials/data, and the global package'), + row('uninstall ', 'restore client routing without deleting accounts'), '', head('Everyday'), row('list', 'accounts, health, and live usage'), @@ -264,7 +267,7 @@ function help(): string { row('refresh', 're-probe usage now'), row('doctor', 'check tools, proxy, and config'), row('daemon ', 'the background manager'), - row('daemon ', 'add or remove macOS login startup'), + row('daemon ', 'add or remove macOS login startup'), '', head('Auto-rotation'), dim(" The threshold is measured against the active account's fullest rate-limit"), @@ -410,7 +413,7 @@ async function startDaemon(context: ApplicationContext): Promise { 'DAEMON_START_FAILED', `Manager did not start${lastError === '' ? '' : ` — ${lastError.replace(/^tokenmaxx: /, '')}`}\n` + 'Your clients still route through tokenmaxx while it is down.\n' + - 'Escape hatch: tokenmaxx uninstall (codex and claude talk straight to the providers again)\n' + + 'Escape hatch: tokenmaxx uninstall routing (clients talk straight to the providers again)\n' + `Then check tokenmaxx doctor, or the full log: ${logPath}` ) } finally { @@ -418,7 +421,7 @@ async function startDaemon(context: ApplicationContext): Promise { } } -async function forceStopDaemon(context: ApplicationContext): Promise { +async function forceStopDaemon(context: Pick): Promise { const ownerPid = await readFile(context.paths.managerLock, 'utf8').then( raw => { try { @@ -440,7 +443,9 @@ async function forceStopDaemon(context: ApplicationContext): Promise { await rm(context.paths.managerSocket, { force: true }) } -async function stopDaemon(context: ApplicationContext): Promise { +async function stopDaemon( + context: Pick +): Promise { if (await context.launchAgent.installed()) await context.launchAgent.stop() await managerRequest({ method: 'manager/stop', @@ -474,16 +479,15 @@ async function installLoginStartup(context: ApplicationContext): Promise { ) } -async function uninstallLoginStartup(context: ApplicationContext): Promise { +async function disableLoginStartup(context: ApplicationContext): Promise { if (!(await context.launchAgent.installed())) { process.stdout.write('Login startup is not installed for this TOKENMAXX_HOME.\n') return } await stopDaemon(context) await context.launchAgent.uninstall() - await startDaemon(context) process.stdout.write( - 'Login startup removed. The manager is still running until logout; use tokenmaxx daemon stop to stop it now.\n' + 'Login startup removed and manager stopped. Run tokenmaxx daemon start to use it manually.\n' ) } @@ -851,13 +855,13 @@ async function installConfig(context: ApplicationContext, targetArgument?: strin process.stdout.write( 'Native codex and claude now route through tokenmaxx.\n' + 'Just run `codex` or `claude` as usual — tokenmaxx injects the active account.\n' + - 'Undo any time with: tokenmaxx uninstall\n' + 'Undo routing any time with: tokenmaxx uninstall routing\n' ) } async function uninstallConfig(targetArgument?: string): Promise { - if (targetArgument !== undefined && targetArgument !== 'pi') { - throw new ApplicationError('USAGE', 'Usage: tokenmaxx uninstall [pi]') + if (targetArgument !== 'routing' && targetArgument !== 'pi') { + throw new ApplicationError('USAGE', 'Usage: tokenmaxx uninstall [routing|pi]') } if (targetArgument === 'pi') { const result = await uninstallPiConfig() @@ -870,13 +874,15 @@ async function uninstallConfig(targetArgument?: string): Promise { } const codex = await uninstallCodexConfig() const claude = await uninstallClaudeConfig() - if (codex === null && claude === null) { + const pi = await uninstallPiConfig() + if (pi.manual !== null) + throw new ApplicationError('CONFIG_UNINSTALL_FAILED', `${pi.path}: ${pi.manual}`) + if (codex === null && claude === null && !pi.applied) { process.stdout.write('tokenmaxx was not installed; nothing to restore.\n') return } process.stdout.write( - 'Restored your original codex and claude config.\n' + - 'Native clients no longer route through tokenmaxx. Re-enable with: tokenmaxx install\n' + 'Restored client routing. Saved accounts and data are unchanged. Re-enable with: tokenmaxx install\n' ) } @@ -978,6 +984,32 @@ async function doctor(context: ApplicationContext): Promise { export async function runCli(rawArguments: readonly string[]): Promise { const arguments_ = CommandSchema.parse(rawArguments) + if (arguments_[0] === 'uninstall' && arguments_.length === 1) { + const initialPaths = applicationPaths() + const saved = await new LaunchAgent(initialPaths).environment() + const paths = saved === null ? initialPaths : applicationPaths({ ...process.env, ...saved }) + if (process.env.TOKENMAXX_HOME !== undefined && initialPaths.root !== paths.root) { + throw new ApplicationError( + 'AUTOSTART_CONFLICT', + `Startup uses ${paths.root}; use that TOKENMAXX_HOME to uninstall` + ) + } + const launchAgent = new LaunchAgent(paths) + await uninstallTokenmaxx({ + environment: { ...process.env, ...saved }, + paths, + removeStartup: () => launchAgent.uninstall(), + stopDaemon: () => stopDaemon({ launchAgent, paths }) + }) + const entrypoint = process.argv[1] + if (entrypoint === undefined) + throw new ApplicationError('ENTRYPOINT_MISSING', 'Cannot locate the CLI entrypoint') + const removed = await removeGlobalPackage(entrypoint) + process.stdout.write( + `Removed tokenmaxx setup, accounts, credentials, local data, and startup files.${removed ? ' The global package was removed.' : ' This source checkout was kept.'}\n` + ) + return 0 + } const context = await createContext() try { const command = arguments_[0] @@ -1116,6 +1148,8 @@ export async function runCli(rawArguments: readonly string[]): Promise { return 0 } case 'uninstall': + if (arguments_.length !== 2) + throw new ApplicationError('USAGE', 'Usage: tokenmaxx uninstall [routing|pi]') await uninstallConfig(arguments_[1]) return 0 case 'daemon': @@ -1123,8 +1157,8 @@ export async function runCli(rawArguments: readonly string[]): Promise { case 'install': await installLoginStartup(context) return 0 - case 'uninstall': - await uninstallLoginStartup(context) + case 'disable': + await disableLoginStartup(context) return 0 case 'run': await runDaemon(context) @@ -1156,7 +1190,7 @@ export async function runCli(rawArguments: readonly string[]): Promise { return 0 } default: - throw new ApplicationError('USAGE', 'Usage: daemon ') + throw new ApplicationError('USAGE', 'Usage: daemon ') } case 'doctor': await doctor(context) diff --git a/src/config-backup.ts b/src/config-backup.ts new file mode 100644 index 0000000..1c4f7a0 --- /dev/null +++ b/src/config-backup.ts @@ -0,0 +1,131 @@ +import { mkdir, readFile, rm, rmdir, stat, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { isDeepStrictEqual } from 'node:util' +import { z } from 'zod' +import type { ApplicationPaths } from './paths.ts' + +const BackupSchema = z.object({ + directories: z.array(z.string()), + installed: z.string(), + original: z.string().nullable(), + path: z.string() +}) +const BackupsSchema = z.array(BackupSchema) +type Backup = z.infer + +async function backups(paths: ApplicationPaths): Promise { + try { + return BackupsSchema.parse( + JSON.parse(await readFile(join(paths.root, 'config-backups.json'), 'utf8')) + ) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return [] + throw error + } +} + +export async function saveConfigBackup( + paths: ApplicationPaths, + path: string, + installed: string, + originalOverride?: string +): Promise { + const records = await backups(paths) + let record = records.find(item => item.path === path) + if (record === undefined) { + const original = + originalOverride ?? + (await readFile(path, 'utf8').catch(error => { + if (error.code === 'ENOENT') return null + throw error + })) + const directories: string[] = [] + for ( + let directory = dirname(path); + !(await stat(directory).then( + () => true, + error => { + if (error.code === 'ENOENT') return false + throw error + } + )); + directory = dirname(directory) + ) + directories.push(directory) + record = { directories, installed, original, path } + records.push(record) + } else record.installed = installed + await mkdir(paths.root, { mode: 0o700, recursive: true }) + await writeFile(join(paths.root, 'config-backups.json'), JSON.stringify(records), { mode: 0o600 }) +} + +function object(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function undoJson(original: unknown, installed: unknown, current: unknown): unknown { + if (isDeepStrictEqual(current, installed)) return original + if (!object(current) || !object(installed)) return current + const restored = { ...current } + for (const key of new Set([ + ...Object.keys(installed), + ...Object.keys(object(original) ? original : {}) + ])) { + const value = undoJson(object(original) ? original[key] : undefined, installed[key], current[key]) + if (value === undefined) delete restored[key] + else restored[key] = value + } + return restored +} + +export async function restoreConfigBackup(paths: ApplicationPaths, path: string): Promise { + const record = (await backups(paths)).find(item => item.path === path) + if (record === undefined) return false + const current = await readFile(path, 'utf8').catch(error => { + if (error.code === 'ENOENT') return null + throw error + }) + const removeCreatedFile = async () => { + await rm(path) + for (const directory of record.directories) { + await rmdir(directory).catch(error => { + if (error.code !== 'ENOTEMPTY' && error.code !== 'ENOENT') throw error + }) + } + } + if (current !== null && current !== record.original) { + if (current === record.installed) { + if (record.original === null) await removeCreatedFile() + else await writeFile(path, record.original, { mode: 0o600 }) + } else { + if (!path.endsWith('.json')) return false + const restored = undoJson( + record.original === null + ? undefined + : record.original.trim() === '' + ? {} + : JSON.parse(record.original), + JSON.parse(record.installed), + JSON.parse(current) + ) + if (restored === undefined) await removeCreatedFile() + else await writeFile(path, `${JSON.stringify(restored, null, 2)}\n`, { mode: 0o600 }) + } + } + await forgetConfigBackup(paths, path) + return true +} + +export async function forgetConfigBackup(paths: ApplicationPaths, path: string): Promise { + const records = await backups(paths) + if (!records.some(record => record.path === path)) return + await writeFile( + join(paths.root, 'config-backups.json'), + JSON.stringify(records.filter(record => record.path !== path)), + { mode: 0o600 } + ) +} + +export async function recordedConfigPaths(paths: ApplicationPaths): Promise { + return (await backups(paths)).map(record => record.path) +} diff --git a/src/config-install.test.ts b/src/config-install.test.ts index a7da7c3..cf39ed7 100644 --- a/src/config-install.test.ts +++ b/src/config-install.test.ts @@ -100,7 +100,7 @@ describe('installCodexConfig', () => { test('uninstall restores the user config without managed blocks', async () => { await writeCodexConfig(legacyBrokenConfig) await installCodexConfig(paths()) - await uninstallCodexConfig() + await uninstallCodexConfig(paths()) const restored = await readCodexConfig() expect(restored).not.toContain('tokenmaxx') expect(restored).not.toContain('tokmax') @@ -181,7 +181,7 @@ describe('installClaudeConfig', () => { model: 'fable[1m]' }) await installClaudeConfig(paths()) - await uninstallClaudeConfig() + await uninstallClaudeConfig(paths()) const settings = await readClaudeSettings() expect(settings.env?.ANTHROPIC_BASE_URL).toBeUndefined() expect(settings.env?.ANTHROPIC_AUTH_TOKEN).toBe('users-own-token') @@ -195,7 +195,7 @@ describe('installClaudeConfig', () => { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8459/anthropic' } }) - await uninstallClaudeConfig() + await uninstallClaudeConfig(paths()) const settings = await readClaudeSettings() expect(settings.env).toBeUndefined() }) @@ -252,7 +252,7 @@ describe('pi install', () => { expect(config.providers['tokenmaxx-anthropic'].api).toBe('anthropic-messages') expect(config.providers['tokenmaxx-openai'].baseUrl).toContain('/openai') expect(config.providers.mine.baseUrl).toBe('https://example.com') - const removed = await uninstallPiConfig() + const removed = await uninstallPiConfig(paths()) expect(removed.applied).toBe(true) const restored = JSON.parse(await readFile(modelsPath, 'utf8')) expect(restored.providers['tokenmaxx-anthropic']).toBeUndefined() @@ -263,7 +263,7 @@ describe('pi install', () => { test('a missing models.json is created on install and reported clean on uninstall', async () => { process.env.PI_CODING_AGENT_DIR = join(home, 'pi-agent') - const removed = await uninstallPiConfig() + const removed = await uninstallPiConfig(paths()) expect(removed.applied).toBe(false) expect(removed.manual).toBeNull() const installed = await installPiConfig(applicationPaths()) @@ -343,7 +343,7 @@ describe('codex-normalized configs', () => { 'hide_rate_limit_model_nudge = true' ].join('\n') ) - expect(await uninstallCodexConfig()).not.toBeNull() + expect(await uninstallCodexConfig(paths())).not.toBeNull() const written = await readFile(configPath, 'utf8') expect(written).not.toContain('tokenmaxx') const parsed = Bun.TOML.parse(written) as { notice?: { hide_rate_limit_model_nudge?: boolean } } diff --git a/src/config-install.ts b/src/config-install.ts index fb10149..8a5046f 100644 --- a/src/config-install.ts +++ b/src/config-install.ts @@ -1,8 +1,9 @@ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' -import { dirname, join } from 'node:path' +import { dirname, join, resolve } from 'node:path' +import { forgetConfigBackup, restoreConfigBackup, saveConfigBackup } from './config-backup.ts' import type { ApplicationPaths } from './paths.ts' -import { proxyBaseUrl } from './paths.ts' +import { applicationPaths, proxyBaseUrl } from './paths.ts' import { VERSION } from './version.ts' const providerName = 'tokenmaxx' @@ -16,12 +17,20 @@ const legacyEndMarkers = [topEndMarker, '# <<< tokmax managed <<<'] const legacyDummyTokens = [dummyAuthToken, 'managed-by-tokmax'] const disabledPrefix = /^#\s*(?:tokenmaxx|tokmax)-disabled:\s*/ +export function clientConfigPaths(environment: NodeJS.ProcessEnv = process.env) { + return { + claude: resolve(environment.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), 'settings.json'), + codex: resolve(environment.CODEX_HOME ?? join(homedir(), '.codex'), 'config.toml'), + pi: resolve(environment.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent'), 'models.json') + } +} + function codexConfigPath(): string { - return join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'config.toml') + return clientConfigPaths().codex } function claudeSettingsPath(): string { - return join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), 'settings.json') + return clientConfigPaths().claude } async function readFileOrEmpty(path: string): Promise { @@ -92,16 +101,27 @@ function buildCodexManagedConfig(paths: ApplicationPaths): { top: string; table: export async function installCodexConfig(paths: ApplicationPaths): Promise { const path = codexConfigPath() - const base = stripCodexManagedBlocks(await readFileOrEmpty(path)) + const existing = await readFileOrEmpty(path) + const base = stripCodexManagedBlocks(existing) const managed = buildCodexManagedConfig(paths) const body = base.length === 0 ? '' : `${base}\n\n` + const installed = `${managed.top}\n\n${body}${managed.table}\n` + await saveConfigBackup( + paths, + path, + installed, + /tokenmaxx|tokmax/.test(existing) ? restoreCodexContent(existing) : undefined + ) await mkdir(dirname(path), { recursive: true }) - await writeFile(path, `${managed.top}\n\n${body}${managed.table}\n`, { mode: 0o600 }) + await writeFile(path, installed, { mode: 0o600 }) return path } -export async function uninstallCodexConfig(): Promise { - const path = codexConfigPath() +export async function uninstallCodexConfig( + paths = applicationPaths(), + path = codexConfigPath() +): Promise { + if (await restoreConfigBackup(paths, path)) return path const existing = await readFile(path, 'utf8').catch(() => null) const carriesOurConfig = (content: string): boolean => [...legacyBeginMarkers, tableBeginMarker].some(marker => content.includes(marker)) || @@ -111,6 +131,7 @@ export async function uninstallCodexConfig(): Promise { return null } await writeFile(path, restoreCodexContent(existing), { mode: 0o600 }) + await forgetConfigBackup(paths, path) return path } @@ -124,25 +145,35 @@ export async function installClaudeConfig(paths: ApplicationPaths): Promise 0) { - try { - settings = JSON.parse(raw) as ClaudeSettings - } catch { - settings = {} - } + settings = JSON.parse(raw) as ClaudeSettings } + const original = structuredClone(settings) + if (original.env?.ANTHROPIC_BASE_URL === proxyBaseUrl(paths, 'anthropic')) + delete original.env.ANTHROPIC_BASE_URL + if (legacyDummyTokens.includes(original.env?.ANTHROPIC_AUTH_TOKEN ?? '')) + delete original.env?.ANTHROPIC_AUTH_TOKEN + const sanitized = + JSON.stringify(original) !== JSON.stringify(settings) + ? `${JSON.stringify(original, null, 2)}\n` + : undefined // Base URL only: any set ANTHROPIC_AUTH_TOKEN switches Claude Code off its // claude.ai login, losing connectors and MCP; the proxy injects credentials itself. settings.env = { ...settings.env, ANTHROPIC_BASE_URL: proxyBaseUrl(paths, 'anthropic') } if (legacyDummyTokens.includes(settings.env.ANTHROPIC_AUTH_TOKEN ?? '')) { delete settings.env.ANTHROPIC_AUTH_TOKEN } + const installed = `${JSON.stringify(settings, null, 2)}\n` + await saveConfigBackup(paths, path, installed, sanitized) await mkdir(dirname(path), { recursive: true }) - await writeFile(path, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 }) + await writeFile(path, installed, { mode: 0o600 }) return path } -export async function uninstallClaudeConfig(): Promise { - const path = claudeSettingsPath() +export async function uninstallClaudeConfig( + paths = applicationPaths(), + path = claudeSettingsPath() +): Promise { + if (await restoreConfigBackup(paths, path)) return path const raw = await readFile(path, 'utf8').catch(() => null) if (raw === null) { return null @@ -159,7 +190,7 @@ export async function uninstallClaudeConfig(): Promise { const { ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, ...rest } = settings.env const managed = (ANTHROPIC_AUTH_TOKEN !== undefined && legacyDummyTokens.includes(ANTHROPIC_AUTH_TOKEN)) || - (ANTHROPIC_BASE_URL?.includes('127.0.0.1') ?? false) + ANTHROPIC_BASE_URL === proxyBaseUrl(paths, 'anthropic') if (!managed) { return null } @@ -243,7 +274,7 @@ export interface PiResult { } function piModelsPath(): string { - return join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent'), 'models.json') + return clientConfigPaths().pi } const piProviderKeys = ['tokenmaxx-anthropic', 'tokenmaxx-openai'] @@ -296,23 +327,28 @@ function ensureObject(parent: Record, key: string): Record | null, - manual: string + manual: string, + paths?: ApplicationPaths, + path = piModelsPath() ): Promise { - const path = piModelsPath() const raw = await readFileOrEmpty(path) const config = parseJsonObject(raw) if (config === null) { return { applied: false, manual, path } } const bucket = ensureObject(config, 'providers') + const hadManaged = piProviderKeys.some(key => key in bucket) for (const key of piProviderKeys) { delete bucket[key] } + const sanitized = hadManaged ? `${JSON.stringify(config, null, 2)}\n` : undefined if (providers !== null) { Object.assign(bucket, providers) } + const installed = `${JSON.stringify(config, null, 2)}\n` + if (paths !== undefined) await saveConfigBackup(paths, path, installed, sanitized) await mkdir(dirname(path), { recursive: true }) - await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }) + await writeFile(path, installed, { mode: 0o600 }) return { applied: true, manual: null, path } } @@ -320,18 +356,26 @@ async function writePiProviders( export async function installPiConfig(paths: ApplicationPaths): Promise { return writePiProviders( piProviders(paths), - `could not parse it as JSON — add this under providers yourself:\n${JSON.stringify(piProviders(paths), null, 2)}` + `could not parse it as JSON — add this under providers yourself:\n${JSON.stringify(piProviders(paths), null, 2)}`, + paths ) } -export async function uninstallPiConfig(): Promise { - const raw = await readFile(piModelsPath(), 'utf8').catch(() => null) +export async function uninstallPiConfig( + paths = applicationPaths(), + path = piModelsPath() +): Promise { + if (await restoreConfigBackup(paths, path)) return { applied: true, manual: null, path } + const raw = await readFile(path, 'utf8').catch(() => null) if (raw === null) { - return { applied: false, manual: null, path: piModelsPath() } + return { applied: false, manual: null, path } } + if (!piProviderKeys.some(key => raw.includes(key))) return { applied: false, manual: null, path } return writePiProviders( null, - 'could not parse it as JSON — remove the tokenmaxx-anthropic and tokenmaxx-openai providers yourself' + 'could not parse it as JSON — remove the tokenmaxx-anthropic and tokenmaxx-openai providers yourself', + undefined, + path ) } diff --git a/src/launch-agent.test.ts b/src/launch-agent.test.ts index d3e10df..95ba2d0 100644 --- a/src/launch-agent.test.ts +++ b/src/launch-agent.test.ts @@ -1,11 +1,12 @@ import { afterEach, describe, expect, test } from 'bun:test' -import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { z } from 'zod' import { managerAvailable, managerRequest } from './ipc.ts' import { LaunchAgent, type LaunchAgentOptions, launchAgentFiles } from './launch-agent.ts' import { applicationPaths, ensureApplicationPaths } from './paths.ts' +import { uninstallTokenmaxx } from './uninstall.ts' const directories: string[] = [] @@ -151,7 +152,7 @@ async function waitFor( } test.skipIf(process.platform !== 'darwin' || process.env.TOKENMAXX_TEST_LAUNCHD !== '1')( - 'launchd runs one manager, restarts it after exit, and supports stop, reinstall, and removal', + 'launchd runs one manager, restarts it after exit, and supports stop, reinstall, and full uninstall', async () => { const input = await options() input.label = `sh.tokenmaxx.test.${crypto.randomUUID()}` @@ -209,6 +210,13 @@ test.skipIf(process.platform !== 'darwin' || process.env.TOKENMAXX_TEST_LAUNCHD await expect(other.install()).rejects.toThrow('different startup configuration') await other.uninstall() expect(await agent.installed()).toBe(true) + await uninstallTokenmaxx({ + environment: input.environment, + paths: input.paths, + removeCredentials: async () => {}, + removeStartup: () => agent.uninstall(), + stopDaemon: () => agent.stop() + }) } finally { await agent.uninstall() await waitFor( @@ -218,7 +226,18 @@ test.skipIf(process.platform !== 'darwin' || process.env.TOKENMAXX_TEST_LAUNCHD } expect(await agent.installed()).toBe(false) expect(await agent.loaded()).toBe(false) - expect(await readFile(input.paths.database)).toBeDefined() + for (const path of [ + input.paths.root, + launchAgentFiles(input).appPath, + launchAgentFiles(input).plistPath + ]) { + expect( + await stat(path).then( + () => true, + () => false + ) + ).toBe(false) + } await agent.uninstall() }, 60_000 diff --git a/src/launch-agent.ts b/src/launch-agent.ts index 3a9924e..9e5f395 100644 --- a/src/launch-agent.ts +++ b/src/launch-agent.ts @@ -3,7 +3,7 @@ import { access, chmod, lstat, mkdir, rm, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { ApplicationError } from './errors.ts' -import type { ApplicationPaths } from './paths.ts' +import { type ApplicationPaths, applicationPaths } from './paths.ts' type PlistValue = string | number | boolean | PlistValue[] | { [key: string]: PlistValue } @@ -149,19 +149,37 @@ export class LaunchAgent { } public async installed(): Promise { + const environment = await this.environment() + return environment !== null && applicationPaths(environment).root === this.#options.paths.root + } + + public async environment(): Promise { if (process.platform !== 'darwin' || !(await Bun.file(this.#configuration.plistPath).exists())) { - return false + return null } const result = await run([ '/usr/bin/plutil', - '-extract', - 'EnvironmentVariables.TOKENMAXX_HOME', - 'raw', + '-convert', + 'json', '-o', '-', this.#configuration.plistPath ]) - return result.exitCode === 0 && result.stdout.replace(/\n$/, '') === this.#options.paths.root + if (result.exitCode !== 0) + throw new ApplicationError( + 'AUTOSTART_CONFLICT', + 'Cannot read the existing startup configuration' + ) + const config = JSON.parse(result.stdout) as { + Label?: string + EnvironmentVariables?: NodeJS.ProcessEnv + } + if (config.Label !== this.#configuration.label) + throw new ApplicationError( + 'AUTOSTART_CONFLICT', + 'The startup configuration belongs to another service' + ) + return config.EnvironmentVariables ?? {} } async #checkAppOwnership(): Promise { @@ -274,7 +292,7 @@ export class LaunchAgent { if (!(await this.installed())) return await this.#checkAppOwnership() await this.stop() - await rm(this.#configuration.plistPath) await rm(this.#configuration.appPath, { force: true, recursive: true }) + await rm(this.#configuration.plistPath) } } diff --git a/src/package-uninstall.test.ts b/src/package-uninstall.test.ts new file mode 100644 index 0000000..31c771e --- /dev/null +++ b/src/package-uninstall.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from 'bun:test' +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { removeGlobalPackage } from './package-uninstall.ts' + +test('the owning package manager removes the global CLI and leaves other packages alone', async () => { + const directory = await mkdtemp(join(tmpdir(), 'tmx-package-')) + const previousPath = process.env.PATH + try { + const globalDirectory = join(directory, 'global') + const packageRoot = join(globalDirectory, 'node_modules', 'tokenmaxx') + const otherPackage = join(globalDirectory, 'node_modules', 'other') + const executable = join(directory, 'bin', 'bun') + await mkdir(join(packageRoot, 'dist'), { recursive: true }) + await mkdir(otherPackage) + await mkdir(join(directory, 'bin')) + await writeFile(join(packageRoot, 'package.json'), '{"name":"tokenmaxx"}') + await writeFile(join(packageRoot, 'dist', 'index.js'), '') + await writeFile(join(otherPackage, 'keep'), 'unrelated package') + await writeFile( + executable, + `#!${process.execPath} +import { rm } from 'node:fs/promises' +const arguments_ = process.argv.slice(2) +if (JSON.stringify(arguments_) === JSON.stringify(['pm', '-g', 'ls'])) { + process.stdout.write(${JSON.stringify(`${globalDirectory} node_modules (2 installed)\n`)}) +} else if (JSON.stringify(arguments_) === JSON.stringify(['remove', '-g', 'tokenmaxx'])) { + await rm(${JSON.stringify(packageRoot)}, { recursive: true }) +} else process.exit(1) +` + ) + await chmod(executable, 0o755) + process.env.PATH = `${join(directory, 'bin')}:${previousPath ?? ''}` + expect(await removeGlobalPackage(join(packageRoot, 'dist', 'index.js'))).toBe(true) + expect(await Bun.file(join(packageRoot, 'package.json')).exists()).toBe(false) + expect(await Bun.file(join(otherPackage, 'keep')).exists()).toBe(true) + expect(await removeGlobalPackage(join(import.meta.dir, 'index.ts'))).toBe(false) + } finally { + if (previousPath === undefined) delete process.env.PATH + else process.env.PATH = previousPath + await rm(directory, { force: true, recursive: true }) + } +}) diff --git a/src/package-uninstall.ts b/src/package-uninstall.ts new file mode 100644 index 0000000..487bcc3 --- /dev/null +++ b/src/package-uninstall.ts @@ -0,0 +1,61 @@ +import { access, realpath } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { ApplicationError } from './errors.ts' + +export async function removeGlobalPackage(entrypoint: string): Promise { + const packageRoot = await realpath(join(dirname(entrypoint), '..')) + if ( + await access(join(packageRoot, '.git')).then( + () => true, + () => false + ) + ) + return false + const packageJson = await Bun.file(join(packageRoot, 'package.json')).json() + if (packageJson.name !== 'tokenmaxx') + throw new ApplicationError( + 'PACKAGE_UNINSTALL_FAILED', + 'Cannot identify the tokenmaxx package directory' + ) + const managers = [ + { name: 'bun', query: ['pm', '-g', 'ls'], remove: ['remove', '-g', 'tokenmaxx'] }, + { name: 'npm', query: ['root', '-g'], remove: ['uninstall', '-g', 'tokenmaxx'] }, + { name: 'pnpm', query: ['root', '-g'], remove: ['remove', '-g', 'tokenmaxx'] }, + { name: 'yarn', query: ['global', 'dir'], remove: ['global', 'remove', 'tokenmaxx'] } + ] + for (const manager of managers) { + const binary = Bun.which(manager.name, { PATH: process.env.PATH }) + if (binary === null) continue + const query = Bun.spawn([binary, ...manager.query], { + stderr: 'ignore', + stdin: 'ignore', + stdout: 'pipe' + }) + const output = (await new Response(query.stdout).text()).trim() + if ((await query.exited) !== 0) continue + const directory = + manager.name === 'bun' ? output.split('\n')[0]?.replace(/ node_modules.*$/, '') : output + if (!directory) continue + const candidate = join( + directory, + ...(['bun', 'yarn'].includes(manager.name) ? ['node_modules'] : []), + 'tokenmaxx' + ) + if ((await realpath(candidate).catch(() => null)) !== packageRoot) continue + const removal = Bun.spawn([binary, ...manager.remove], { + stderr: 'inherit', + stdin: 'ignore', + stdout: 'inherit' + }) + if ((await removal.exited) !== 0) + throw new ApplicationError( + 'PACKAGE_UNINSTALL_FAILED', + `${manager.name} could not remove tokenmaxx; retry ${manager.name} ${manager.remove.join(' ')}` + ) + return true + } + throw new ApplicationError( + 'PACKAGE_UNINSTALL_FAILED', + 'Setup was removed, but the package manager could not be identified. Remove tokenmaxx with the package manager that installed it.' + ) +} diff --git a/src/uninstall.test.ts b/src/uninstall.test.ts new file mode 100644 index 0000000..c1d3177 --- /dev/null +++ b/src/uninstall.test.ts @@ -0,0 +1,258 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { + clientConfigPaths, + installClaudeConfig, + installCodexConfig, + installPiConfig, + uninstallClaudeConfig +} from './config-install.ts' +import { applicationPaths, ensureApplicationPaths } from './paths.ts' +import { createStateStore } from './storage.ts' +import { uninstallTokenmaxx } from './uninstall.ts' + +let directory: string +let environment: NodeJS.ProcessEnv +let savedEnvironment: NodeJS.ProcessEnv + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'tmx-clean-')) + environment = { + CLAUDE_CONFIG_DIR: join(directory, 'claude'), + CODEX_HOME: join(directory, 'codex'), + PI_CODING_AGENT_DIR: join(directory, 'pi'), + TOKENMAXX_HOME: join(directory, 'state') + } + savedEnvironment = { ...process.env } + Object.assign(process.env, environment) +}) + +afterEach(async () => { + for (const key of Object.keys(environment)) { + if (savedEnvironment[key] === undefined) delete process.env[key] + else process.env[key] = savedEnvironment[key] + } + await rm(directory, { force: true, recursive: true }) +}) + +async function setup() { + const paths = applicationPaths(environment) + await ensureApplicationPaths(paths) + const store = createStateStore(paths.database) + store.close() + await installCodexConfig(paths) + await installClaudeConfig(paths) + await installPiConfig(paths) + await writeFile(join(paths.runtime, 'daemon.log'), 'usage log') + await writeFile(join(paths.root, 'preferences.json'), '{"theme":"dark"}') + return paths +} + +test('complete cleanup restores original files and removes startup, credentials, profiles, and state without restarting', async () => { + const configs = clientConfigPaths(environment) + const originals = { + claude: + '{"theme":"light","env":{"ANTHROPIC_BASE_URL":"https://previous.example","ANTHROPIC_AUTH_TOKEN":"native-token"}}\n', + codex: 'model_provider = "my-provider"\n\n[projects."/work"]\ntrust_level = "trusted"\n', + pi: '{"providers":{"personal":{"baseUrl":"https://personal.example"}}}\n' + } + for (const key of ['codex', 'claude', 'pi'] as const) { + await mkdir(join(directory, key)) + await writeFile(configs[key], originals[key]) + } + const nativeAuth = join(directory, 'codex', 'auth.json') + await writeFile(nativeAuth, 'native login stays') + const paths = await setup() + await installClaudeConfig(paths) + const profile = join(paths.claudeProfiles, 'isolated-account') + await mkdir(profile) + await writeFile(join(profile, '.credentials.json'), 'isolated credentials') + const app = join(directory, 'tokenmaxx.app') + const agent = join(directory, 'sh.tokenmaxx.daemon.plist') + await mkdir(app) + await writeFile(agent, 'startup') + const events: string[] = [] + const credentials = new Map([ + ['account', 'key'], + ['account:0', 'chunk'], + ['orphan', 'old-key'] + ]) + const input = { + environment, + paths, + removeCredentials: async () => { + expect(await readFile(configs.claude, 'utf8')).toBe(originals.claude) + events.push('credentials') + credentials.clear() + }, + removeProfile: async (path: string) => { + events.push('profile') + await rm(path, { recursive: true }) + }, + removeStartup: async () => { + events.push('startup') + await rm(app, { force: true, recursive: true }) + await rm(agent, { force: true }) + }, + stopDaemon: async () => { + events.push('stop') + } + } + await uninstallTokenmaxx(input) + expect(events).toEqual(['stop', 'profile', 'credentials', 'startup']) + expect(credentials.size).toBe(0) + for (const key of ['codex', 'claude', 'pi'] as const) + expect(await readFile(configs[key], 'utf8')).toBe(originals[key]) + expect(await readFile(nativeAuth, 'utf8')).toBe('native login stays') + for (const path of [paths.root, app, agent]) + expect( + await stat(path).then( + () => true, + () => false + ) + ).toBe(false) + await uninstallTokenmaxx(input) + expect( + await stat(paths.root).then( + () => true, + () => false + ) + ).toBe(false) +}) + +test('files and empty client directories created by setup disappear on uninstall', async () => { + const paths = await setup() + await uninstallTokenmaxx({ + environment, + paths, + removeCredentials: async () => {}, + removeStartup: async () => {}, + stopDaemon: async () => {} + }) + for (const path of Object.values(clientConfigPaths(environment))) { + expect(await Bun.file(path).exists()).toBe(false) + } + for (const name of ['codex', 'claude', 'pi', 'state']) + expect( + await stat(join(directory, name)).then( + () => true, + () => false + ) + ).toBe(false) +}) + +test('edits made after installation survive while overwritten client settings are restored', async () => { + const configs = clientConfigPaths(environment) + await mkdir(join(directory, 'claude')) + await writeFile( + configs.claude, + JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://previous.example' }, theme: 'light' }) + ) + const paths = await setup() + const claude = JSON.parse(await readFile(configs.claude, 'utf8')) + claude.theme = 'dark' + claude.env.NEW_SETTING = 'keep' + await writeFile(configs.claude, JSON.stringify(claude)) + await writeFile( + configs.codex, + `${await readFile(configs.codex, 'utf8')}\n[projects."/new"]\ntrust_level = "trusted"\n` + ) + await uninstallTokenmaxx({ + environment, + paths, + removeCredentials: async () => {}, + removeStartup: async () => {}, + stopDaemon: async () => {} + }) + expect(JSON.parse(await readFile(configs.claude, 'utf8'))).toEqual({ + env: { ANTHROPIC_BASE_URL: 'https://previous.example', NEW_SETTING: 'keep' }, + theme: 'dark' + }) + const codex = await readFile(configs.codex, 'utf8') + expect(codex).toContain('[projects."/new"]') + expect(codex).not.toContain('tokenmaxx') +}) + +test('failed credential cleanup keeps local recovery data and can be retried', async () => { + const paths = await setup() + let removedStartup = false + const input = { + environment, + paths, + removeStartup: async () => { + removedStartup = true + }, + stopDaemon: async () => {} + } + await expect( + uninstallTokenmaxx({ + ...input, + removeCredentials: async () => { + throw new Error('Keychain locked') + } + }) + ).rejects.toThrow('Keychain locked') + expect(removedStartup).toBe(false) + expect(await Bun.file(paths.database).exists()).toBe(true) + expect(await Bun.file(join(paths.root, 'config-backups.json')).exists()).toBe(true) + await uninstallTokenmaxx({ ...input, removeCredentials: async () => {} }) + expect(removedStartup).toBe(true) + expect(await Bun.file(paths.database).exists()).toBe(false) +}) + +test('a home directory cannot be purged as tokenmaxx data', async () => { + let stopped = false + await expect( + uninstallTokenmaxx({ + environment, + paths: applicationPaths({ TOKENMAXX_HOME: homedir() }), + removeCredentials: async () => {}, + removeStartup: async () => {}, + stopDaemon: async () => { + stopped = true + } + }) + ).rejects.toThrow('dedicated data directory') + expect(stopped).toBe(false) +}) + +test('unrelated files in a custom data directory are preserved', async () => { + const paths = await setup() + const unrelated = join(paths.root, 'keep.txt') + await writeFile(unrelated, 'not tokenmaxx data') + await uninstallTokenmaxx({ + environment, + paths, + removeCredentials: async () => {}, + removeStartup: async () => {}, + stopDaemon: async () => {} + }) + expect(await readFile(unrelated, 'utf8')).toBe('not tokenmaxx data') + expect(await Bun.file(paths.database).exists()).toBe(false) + expect( + await stat(paths.runtime).then( + () => true, + () => false + ) + ).toBe(false) +}) + +test('reinstall after restoring routing records the latest native settings', async () => { + const paths = await setup() + const path = clientConfigPaths(environment).claude + await uninstallClaudeConfig(paths, path) + await mkdir(join(directory, 'claude'), { recursive: true }) + const native = '{"env":{"ANTHROPIC_BASE_URL":"https://new-native.example"},"theme":"dark"}\n' + await writeFile(path, native) + await installClaudeConfig(paths) + await uninstallTokenmaxx({ + environment, + paths, + removeCredentials: async () => {}, + removeStartup: async () => {}, + stopDaemon: async () => {} + }) + expect(await readFile(path, 'utf8')).toBe(native) +}) diff --git a/src/uninstall.ts b/src/uninstall.ts new file mode 100644 index 0000000..03c59e8 --- /dev/null +++ b/src/uninstall.ts @@ -0,0 +1,115 @@ +import { Database } from 'bun:sqlite' +import { lstat, readdir, realpath, rm, rmdir } from 'node:fs/promises' +import { homedir } from 'node:os' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { removeClaudeProfile } from './claude.ts' +import { recordedConfigPaths, restoreConfigBackup } from './config-backup.ts' +import { + clientConfigPaths, + uninstallClaudeConfig, + uninstallCodexConfig, + uninstallPiConfig +} from './config-install.ts' +import { ApplicationError } from './errors.ts' +import type { ApplicationPaths } from './paths.ts' +import { removeMacOsKeychainCredentials } from './vault.ts' + +function contains(parent: string, child: string): boolean { + const path = relative(parent, child) + return path === '' || (!path.startsWith('..') && !isAbsolute(path)) +} + +export async function uninstallTokenmaxx(input: { + paths: ApplicationPaths + environment?: NodeJS.ProcessEnv + stopDaemon: () => Promise + removeStartup: () => Promise + removeCredentials?: () => Promise + removeProfile?: (path: string) => Promise +}): Promise { + const root = await realpath(input.paths.root).catch(error => { + if (error.code === 'ENOENT') return resolve(input.paths.root) + throw error + }) + if ([homedir(), process.cwd()].some(directory => contains(root, directory))) { + throw new ApplicationError( + 'UNSAFE_DATA_DIRECTORY', + 'TOKENMAXX_HOME must be a dedicated data directory to uninstall it' + ) + } + await input.stopDaemon() + const profiles = new Set() + if (await Bun.file(input.paths.database).exists()) { + const database = new Database(input.paths.database, { readonly: true }) + try { + for (const row of database + .query<{ payload: string }, []>('SELECT payload FROM accounts') + .all()) { + const account = JSON.parse(row.payload) as { profilePath?: unknown } + if (typeof account.profilePath === 'string') profiles.add(account.profilePath) + } + } finally { + database.close() + } + } + for (const entry of await readdir(input.paths.claudeProfiles, { withFileTypes: true }).catch( + error => { + if (error.code === 'ENOENT') return [] + throw error + } + )) + if (entry.isDirectory()) profiles.add(join(input.paths.claudeProfiles, entry.name)) + for (const profile of profiles) { + const canonical = await realpath(profile).catch(error => { + if (error.code === 'ENOENT') return resolve(profile) + throw error + }) + if (!contains(root, canonical) || canonical === root) + throw new ApplicationError( + 'UNSAFE_PROFILE_PATH', + `An account profile is outside TOKENMAXX_HOME: ${profile}` + ) + } + for (const path of await recordedConfigPaths(input.paths)) { + if (!(await restoreConfigBackup(input.paths, path))) await uninstallCodexConfig(input.paths, path) + } + const configs = clientConfigPaths(input.environment) + await uninstallCodexConfig(input.paths, configs.codex) + await uninstallClaudeConfig(input.paths, configs.claude) + const pi = await uninstallPiConfig(input.paths, configs.pi) + if (pi.manual !== null) + throw new ApplicationError('CONFIG_UNINSTALL_FAILED', `${pi.path}: ${pi.manual}`) + for (const profile of profiles) await (input.removeProfile ?? removeClaudeProfile)(profile) + await (input.removeCredentials ?? removeMacOsKeychainCredentials)() + await input.removeStartup() + for (const path of [ + input.paths.database, + `${input.paths.database}-wal`, + `${input.paths.database}-shm`, + `${input.paths.database}-journal`, + input.paths.managerSocket, + input.paths.managerLock, + join(input.paths.runtime, 'daemon.log'), + join(input.paths.root, 'preferences.json'), + join(input.paths.root, 'healed-version'), + join(input.paths.root, 'config-backups.json') + ]) + await rm(path, { force: true }) + for (const directory of [ + input.paths.claudeProfiles, + dirname(input.paths.claudeProfiles), + input.paths.runtime, + root + ]) { + await rmdir(directory).catch(error => { + if (error.code !== 'ENOENT' && error.code !== 'ENOTEMPTY') throw error + }) + } + if ( + await lstat(input.paths.root).then( + info => info.isSymbolicLink(), + () => false + ) + ) + await rm(input.paths.root) +} diff --git a/src/vault.test.ts b/src/vault.test.ts new file mode 100644 index 0000000..9779b32 --- /dev/null +++ b/src/vault.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from 'bun:test' +import { removeMacOsKeychainCredentials } from './vault.ts' + +test('uninstall deletes every tokenmaxx Keychain item, including chunks and orphaned credentials', async () => { + const items = new Set(['codex:account', 'codex:account:0', 'codex:account:1', 'orphan']) + const commands: string[][] = [] + await removeMacOsKeychainCredentials('test.tokenmaxx', { + async run(command) { + commands.push([...command]) + const item = items.values().next().value + if (item === undefined) return { exitCode: 44, stderr: '', stdout: '' } + items.delete(item) + return { exitCode: 0, stderr: '', stdout: '' } + } + }) + expect(items.size).toBe(0) + expect(commands.length).toBe(5) + for (const command of commands) + expect(command).toEqual(['security', 'delete-generic-password', '-s', 'test.tokenmaxx']) +}) + +test('Keychain access failures are reported instead of claiming successful cleanup', async () => { + await expect( + removeMacOsKeychainCredentials('test.tokenmaxx', { + run: async () => ({ exitCode: 36, stderr: 'Keychain locked', stdout: '' }) + }) + ).rejects.toThrow('Keychain locked') +}) diff --git a/src/vault.ts b/src/vault.ts index aa2a8e7..3c55708 100644 --- a/src/vault.ts +++ b/src/vault.ts @@ -86,6 +86,23 @@ function requireSafeIdentifier(kind: string, value: string): string { return value } +export async function removeMacOsKeychainCredentials( + service = defaultService, + runner: KeychainCommandRunner = defaultKeychainCommandRunner() +): Promise { + requireSafeIdentifier('service', service) + for (;;) { + const result = await runner.run(['security', 'delete-generic-password', '-s', service]) + if (result.exitCode === 44) return + if (result.exitCode !== 0) { + throw new ApplicationError( + 'KEYCHAIN_DELETE_FAILED', + redactSecrets(result.stderr) || 'Keychain cleanup failed' + ) + } + } +} + export function createMacOsKeychainVault( service = defaultService, runner: KeychainCommandRunner = defaultKeychainCommandRunner()