diff --git a/.plans/setup-and-release.md b/.plans/setup-and-release.md index 952aec4..bd083e8 100644 --- a/.plans/setup-and-release.md +++ b/.plans/setup-and-release.md @@ -1,6 +1,6 @@ # Setup and release work order -Status: Windows x64 support is implemented and tested on Gideon; see [verification](../docs/compatibility.md#windows-verification). The wizard, automatic update handoff, and release workflow remain planned from the September 11 setup discussion. +Status: Windows x64 support is implemented and tested on Gideon; see [verification](../docs/compatibility.md#windows-verification). The setup installer is implemented; see [installer usage](../docs/installer.md). Automatic update handoff and the release workflow remain planned. ## Order of work @@ -24,7 +24,7 @@ Provide instance selection and custom-location fallback, resolve the selected Co Default to T3-only tool availability. Update both Codex MCP registration and the selected T3 provider's launch arguments, checking environment overrides. Keep each installation's destination separate when multiple T3 instances share Codex configuration. See [T3 scoping](t3-only-mcp.md). -Default to the stable update channel, with explicit nightly opt-in. Persist the selected channel independently of the exact package version used to run setup. Do not pin ordinary installations to the setup version. An explicit version may remain an advanced troubleshooting option. +Inherit the release channel from the invoked package version; do not ask a channel question. Stable packages configure latest and nightly packages configure nightly. Do not pin ordinary installations to the setup version. A local runtime path supports checkout use before publication. Verification must not send messages or restart existing watches as a side effect. Preserve running T3 conversations and state which checks require a fresh provider session. diff --git a/README.md b/README.md index 85b85b7..5a0be78 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,18 @@ Watch a GitHub PR and notify an existing T3 Code conversation when something cha Three MCP tools: `watch`, `list`, and `stop`. `watch` automatically starts a background worker that polls every 60 seconds without invoking a model. It survives MCP disconnection and exits when there is nothing left to watch. No skill or service installation is required. +## Setup wizard + +With T3 running, build this checkout and run the installer: + +```sh +npm ci +npm run build +node dist/cli.js setup --runtime-path ./dist/cli.js +``` + +It discovers T3, configures Codex with T3-only tools, preserves existing launch arguments, backs up changed files, and verifies the connection. Add `--dry-run` to preview. The local runtime option works before npm publication; after release, use `npx t3poll@latest setup` or `npx t3poll@nightly setup`. See the [installer guide](docs/installer.md). + ## Agent setup Copy this into your coding agent: diff --git a/docs/compatibility.md b/docs/compatibility.md index 889e362..ab4a3e2 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -81,3 +81,13 @@ node scripts/prove-t3.mjs ``` For an npm-installed T3 CLI, set only `T3POLL_TEST_T3_BIN` to its `dist/bin.mjs`. To check real Codex tool scoping without a model call, set `T3POLL_TEST_CODEX_BIN` to the installed native `codex.exe` and run `node scripts/prove-codex.mjs`. This creates a disposable Codex home, verifies no t3poll tools are exposed by default, then verifies `list`, `stop`, and `watch` with `-c mcp_servers.t3poll.enabled=true`. It does not edit the user's Codex or T3 configuration. Both proof scripts accept `T3POLL_TEST_CLI` to exercise a separately installed tarball's `dist/cli.js` instead of the checkout build. + +## Setup installer verification + +Verified September 11, 2026 with 45 tests: all passed on Gideon Windows x64; 43 passed on Linux with the two Windows-only checks skipped. Installer coverage includes no-write dry runs, separate instance destinations, quoted launch arguments, environment precedence, private backups, stale reviews, rollback after failed credential verification, and idempotent reruns. Type checking passes. + +The installer completed against isolated copies of T3 `0.0.41-nightly.20260911.1551` on Linux and the packaged Windows desktop app, using real Codex for the executable check. It created managed credentials, verified MCP connectivity, and reran without duplicate config. No threads or messages were created. The real Codex proof now invokes the installer instead of writing MCP config by hand; it confirms the generated entry is disabled by default and exposes all three tools through the generated T3 launch arguments. + +Packed installs in paths containing spaces and non-ASCII characters passed the installer and real Codex scoping proof on Linux and Windows. These checks use `--runtime-path` to exercise the packed CLI before npm publication. Public npm channel resolution and automatic worker handoff remain release work; the installer does not claim to hot-update running workers. + +To repeat the stock installer proof, set `T3POLL_TEST_T3_BIN` and `T3POLL_TEST_CODEX_BIN`, optionally `T3POLL_TEST_T3_RUNTIME` for Electron, and run `node scripts/prove-installer.mjs`. All configuration is disposable. `scripts/prove-codex.mjs` separately verifies actual tool availability without model calls. diff --git a/docs/installer.md b/docs/installer.md new file mode 100644 index 0000000..821044e --- /dev/null +++ b/docs/installer.md @@ -0,0 +1,59 @@ +# Setup installer + +Build the checkout and run setup with T3 open and GitHub CLI signed in: + +```sh +npm ci +npm run build +node dist/cli.js setup --runtime-path ./dist/cli.js +``` + +Until the first npm release, `--runtime-path` uses this checkout's built CLI. Keep the checkout in place. After publication, use: + +```sh +npx t3poll@latest setup +# or +npx t3poll@nightly setup +``` + +There is no release-channel question. A stable package configures `latest`; a package whose version contains the nightly prerelease identifier configures `nightly`. npm does not pass its original tag to the program, so setup derives the channel from the running package's version. Unknown prerelease versions are rejected. An explicit local runtime opts out of npm updates. + +## Flow + +Setup checks GitHub sign-in and finds supported running local T3 instances from `T3CODE_HOME`, the default home, and ancestor `.t3` directories. It asks you to choose when more than one is found. A custom directory can identify other installations. It reads both legacy `providers.codex` and named `providerInstances` settings, with explicit instances taking precedence. + +Setup opens with a welcome and a Yes/No prompt to proceed. It asks for an instance or provider only when selection is needed, and offers migration if an existing global entry is enabled. The summary identifies the T3 instance by URL and names the Codex configuration. It explains the tool registration, launch enablement, managed credential, connection checks, and backups before the Install confirmation. File paths are included in `--dry-run` details. Custom paths can be supplied through command-line options. To preview from a script without changing files or credentials: + +```sh +node dist/cli.js setup --base-dir /path/to/t3 --provider codex --dry-run +``` + +Other options are `--codex-home`, `--state-home`, and `--yes`. `--yes` accepts the plan but does not guess when multiple instances or providers are available. `--help` lists all options. Setup does not start T3, sign into GitHub, or alter login credentials. If GitHub sign-in is needed, run `gh auth login` in another terminal, then retry. + +## Configuration and scope + +Each T3 home and Codex provider gets a deterministic `t3poll_` MCP entry, disabled by default. Its environment binds the selected T3 home and t3poll state directory. Multiple installations can share Codex configuration without replacing each other's destinations. + +The installer appends the corresponding `-c mcp_servers.t3poll_.enabled=true` to the selected T3 provider's launch arguments. It preserves unrelated arguments, Codex text and comments, and other T3 settings. T3 JSON formatting may change. A rerun updates the owned MCP block and does not duplicate launch flags. Unknown entries and damaged managed blocks require correction rather than being overwritten. If a global `mcp_servers.t3poll` entry is enabled, setup asks whether to disable it and recommends Yes. Yes includes the change in the reviewed plan, preserves its other settings and comments, and backs it up. No preserves it and clearly notes that t3poll remains available outside T3. `--yes` accepts the recommended migration; add `--keep-global` to preserve the global entry when scripting setup. + +Configuration files receive adjacent private `.t3poll-.bak` backups before changes. The installer compares the files with the reviewed snapshot and refuses stale writes. If credential verification fails after applying, it restores the previous configuration unless another process has edited it. Backups remain for manual recovery. Adjacent `.t3poll-lock` files prevent overlapping installer writes; after a crashed installer, verify it is no longer running before removing a leftover lock. T3 itself does not participate in those locks, so review-time comparisons and rollback checks also protect against its edits. + +The installer checks the selected Codex executable, starts the exact configured MCP command, and verifies the tool catalog. It then creates or reuses a managed T3 credential and reads the thread list to verify connectivity. It never calls `watch`, sends a message, or restarts a worker. Runtime checks can create t3poll state files, and failed attempts can leave those and credential metadata for recovery. Dry runs do not run these checks or write state. + +Open a fresh Codex session in the selected T3 instance after setup. Existing sessions keep their current tool catalog. Scoping controls tool availability, not security isolation; users can deliberately enable the MCP entry elsewhere. Project-level or administratively managed Codex configuration can impose additional overrides outside this installer's control. + +## Launch-argument conflicts + +Having existing arguments is not a conflict. For example, `--enable some_feature -c model_reasoning_effort=high` is retained and the MCP flag is appended. + +Setup stops when arguments override the same managed MCP entry, replace its parent `mcp_servers` table, have an unclosed quote or malformed config assignment, or contain `--` after which appending configuration flags is unsafe. The existing exact `enabled=true` override is reused. Other MCP entries are left alone. + +`T3CODE_CODEX_LAUNCH_ARGS` is a precedence issue: a nonempty value overrides the saved launch arguments. Setup reads the live T3 process environment and the selected provider's environment, not just the terminal running setup. If the override already enables this entry, it can remain. Otherwise the wizard gives the exact flag to add and asks you to restart T3 and rerun setup. A global override affecting multiple Codex providers must be removed or moved into the selected provider's environment to preserve provider scoping. It never silently edits shell profiles, shortcuts, or another running process's environment. + +T3 builds must contain support for `T3CODE_CODEX_LAUNCH_ARGS` in their server bundle. Unrecognized builds are rejected rather than silently installing an ignored setting. Shadow Codex homes use the shared configuration directory; setup follows an existing config symlink without replacing it. + +## Updates + +The npm launch command uses Node to run npm's CLI directly, including on Windows, avoiding batch-file quoting. It resolves the package channel at MCP startup and needs npm/network access. Verification must succeed before config is changed; there is no cached-version fallback during a failed install. + +Running MCP processes and detached workers keep their loaded code. Automatic worker handoff is a separate planned change; this installer does not implement hot updates. Before upgrading a runtime with active watches, follow the existing update guidance in the README. diff --git a/package-lock.json b/package-lock.json index 1b3cfb4..4fbf726 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,11 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@clack/prompts": "1.8.0", "@modelcontextprotocol/sdk": "1.30.0", "koffi": "3.2.1", + "smol-toml": "1.8.0", + "toml-eslint-parser": "1.0.3", "zod": "4.6.2" }, "bin": { @@ -26,6 +29,34 @@ "node": ">=24.10.0" } }, + "node_modules/@clack/core": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.0.tgz", + "integrity": "sha512-zNikCcd8BbcEvzzG1sbXFrRHFk5kHPrpwZwksPvf9qyQO1Teb7JaXaOAxXZei9nZLDW0gaZawiuTCji88bTBhw==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.0.tgz", + "integrity": "sha512-PXzLZ8N34rxmuo4dJg3xtOXhcBse94qGjDqsteoEYrFrrZ5FSjIGwMAuOcv64ln8rHVBBD06XeVGr+/JX+plcA==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.5.0", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -1483,6 +1514,18 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1581,6 +1624,21 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-uri": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", @@ -1597,6 +1655,15 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -2286,6 +2353,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/smol-toml": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2304,6 +2389,21 @@ "node": ">=0.6" } }, + "node_modules/toml-eslint-parser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/toml-eslint-parser/-/toml-eslint-parser-1.0.3.tgz", + "integrity": "sha512-A5F0cM6+mDleacLIEUkmfpkBbnHJFV1d2rprHU2MXNk7mlxHq2zGojA+SRvQD1RoMo9gqjZPWEaKG4v1BQ48lw==", + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + } + }, "node_modules/tsx": { "version": "4.23.13", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", diff --git a/package.json b/package.json index c7225ca..c84f950 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,11 @@ "prepack": "npm run build" }, "dependencies": { + "@clack/prompts": "1.8.0", "@modelcontextprotocol/sdk": "1.30.0", "koffi": "3.2.1", + "smol-toml": "1.8.0", + "toml-eslint-parser": "1.0.3", "zod": "4.6.2" }, "devDependencies": { diff --git a/scripts/prove-codex.mjs b/scripts/prove-codex.mjs index 3d040a6..ec72856 100644 --- a/scripts/prove-codex.mjs +++ b/scripts/prove-codex.mjs @@ -8,11 +8,12 @@ import { mkdtempSync, mkdirSync, writeFileSync, + readFileSync, copyFileSync, rmSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join, resolve, delimiter } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import assert from "node:assert/strict"; @@ -36,31 +37,14 @@ const server = spawn(process.execPath, [join(pkg, "dist/bin.mjs"), "serve"], { env: { ...process.env, T3CODE_HOME: base }, stdio: ["ignore", "pipe", "pipe"], }); -writeFileSync( - join(home, "config.toml"), - ` -[mcp_servers.t3poll] -enabled = false -command = ${JSON.stringify(process.execPath)} -args = [${JSON.stringify(resolve(process.env.T3POLL_TEST_CLI ?? "dist/cli.js"))}, "mcp"] -[mcp_servers.t3poll.env] -T3POLL_HOME = ${JSON.stringify(join(root, "poll"))} -T3POLL_BASE_DIR = ${JSON.stringify(base)} -`, -); +let mcpName; +let launchArgs; async function check(enabled) { - const child = spawn( - binary, - [ - "app-server", - ...(enabled ? ["-c", "mcp_servers.t3poll.enabled=true"] : []), - ], - { - env: { ...process.env, CODEX_HOME: home }, - cwd: root, - stdio: ["pipe", "pipe", "pipe"], - }, - ); + const child = spawn(binary, ["app-server", ...(enabled ? launchArgs : [])], { + env: { ...process.env, CODEX_HOME: home }, + cwd: root, + stdio: ["pipe", "pipe", "pipe"], + }); let errors = ""; child.stderr.on("data", (chunk) => { errors = (errors + chunk).slice(-3000); @@ -99,7 +83,7 @@ async function check(enabled) { ); for (let attempt = 0; attempt < 40; attempt++) { const result = await rpc("mcpServerStatus/list", {}); - const status = result.data.find((item) => item.name === "t3poll"); + const status = result.data.find((item) => item.name === mcpName); if (!enabled) { assert.deepEqual(Object.keys(status?.tools ?? {}), []); return; @@ -133,10 +117,45 @@ async function check(enabled) { } try { await once(server.stdout, "data"); + const { executable } = await import("../tests/fixtures/executable.mjs"); + const { tokenize } = await import("../dist/install-plan.js"); + const bin = join(root, "bin"); + mkdirSync(bin); + executable(bin, "gh", "process.exit(0)"); + writeFileSync( + join(base, "userdata/settings.json"), + JSON.stringify({ providers: { codex: { binaryPath: binary } } }), + ); + const cli = resolve(process.env.T3POLL_TEST_CLI ?? "dist/cli.js"); + await promisify(execFile)( + process.execPath, + [ + cli, + "setup", + "--yes", + "--base-dir", + base, + "--codex-home", + home, + "--state-home", + join(root, "poll"), + "--runtime-path", + cli, + ], + { + env: { ...process.env, PATH: `${bin}${delimiter}${process.env.PATH}` }, + timeout: 60000, + }, + ); + launchArgs = tokenize( + JSON.parse(readFileSync(join(base, "userdata/settings.json"), "utf8")) + .providers.codex.launchArgs, + ); + mcpName = launchArgs.at(-1).split(".")[1]; await check(false); await check(true); console.log( - "PASS: real Codex hides t3poll by default and exposes list/stop/watch with the T3 launch override. No model calls or user configuration changes.", + "PASS: real installer completed; real Codex hides t3poll by default and exposes list/stop/watch with the T3 launch override. No model calls or user configuration changes.", ); } finally { const exit = once(server, "exit"); diff --git a/scripts/prove-installer.mjs b/scripts/prove-installer.mjs new file mode 100644 index 0000000..ed46d5d --- /dev/null +++ b/scripts/prove-installer.mjs @@ -0,0 +1,172 @@ +// Exercise setup against a disposable stock T3 instance. No threads/model calls. +// T3POLL_TEST_T3_BIN and T3POLL_TEST_CODEX_BIN select installed executables. +import { spawn, execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { once } from "node:events"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + existsSync, + rmSync, +} from "node:fs"; +import { join, resolve, delimiter } from "node:path"; +import { tmpdir } from "node:os"; +import { createServer } from "node:net"; +import { setTimeout as delay } from "node:timers/promises"; +import assert from "node:assert/strict"; +import { executable } from "../tests/fixtures/executable.mjs"; +const exec = promisify(execFile); +const binary = process.env.T3POLL_TEST_T3_BIN, + codex = process.env.T3POLL_TEST_CODEX_BIN; +if (!binary || !codex) + throw new Error("Set T3POLL_TEST_T3_BIN and T3POLL_TEST_CODEX_BIN."); +const runtime = process.env.T3POLL_TEST_T3_RUNTIME || process.execPath; +const root = mkdtempSync(join(tmpdir(), "t3poll installer café space-")); +const base = join(root, "t3"), + home = join(root, "codex"), + state = join(root, "state"), + bin = join(root, "bin"); +mkdirSync(join(base, "userdata"), { recursive: true }); +mkdirSync(bin); +executable(bin, "gh", "process.exit(0)"); +writeFileSync( + join(base, "userdata/settings.json"), + JSON.stringify({ + providers: { codex: { binaryPath: codex, homePath: home } }, + }), +); +const portServer = createServer(); +await new Promise((r) => portServer.listen(0, "127.0.0.1", r)); +const port = portServer.address().port; +await new Promise((r) => portServer.close(r)); +const env = { + PATH: `${bin}${delimiter}${process.env.PATH}`, + HOME: root, + USERPROFILE: root, + CODEX_HOME: home, + T3CODE_HOME: base, + ...(process.platform === "win32" + ? { + SystemRoot: process.env.SystemRoot, + WINDIR: process.env.WINDIR, + ComSpec: process.env.ComSpec, + TEMP: root, + TMP: root, + APPDATA: join(root, "AppData/Roaming"), + LOCALAPPDATA: join(root, "AppData/Local"), + } + : {}), +}; +await exec( + runtime, + [ + binary, + "auth", + "session", + "issue", + "--base-dir", + base, + "--ttl", + "1h", + "--token-only", + ], + { + cwd: root, + env: { + ...env, + ...(runtime !== process.execPath ? { ELECTRON_RUN_AS_NODE: "1" } : {}), + }, + timeout: 30000, + }, +); +const server = spawn( + runtime, + [ + binary, + "--base-dir", + base, + "--host", + "127.0.0.1", + "--port", + String(port), + "--no-browser", + ], + { + cwd: root, + env: { + ...env, + ...(runtime !== process.execPath ? { ELECTRON_RUN_AS_NODE: "1" } : {}), + }, + stdio: ["ignore", "pipe", "pipe"], + }, +); +let errors = ""; +server.stdout.on("data", (chunk) => { + errors = (errors + chunk).slice(-4000); +}); +server.stderr.on("data", (chunk) => { + errors = (errors + chunk).slice(-2000); +}); +try { + for ( + let i = 0; + !existsSync(join(base, "userdata/server-runtime.json")); + i++ + ) { + if (i > 300 || server.exitCode !== null) + throw new Error(`T3 did not start: ${errors}`); + await delay(100); + } + const cli = resolve(process.env.T3POLL_TEST_CLI || "dist/cli.js"); + const args = [ + cli, + "setup", + "--base-dir", + base, + "--state-home", + state, + "--runtime-path", + cli, + ]; + const before = readFileSync(join(base, "userdata/settings.json"), "utf8"); + const preview = await exec(process.execPath, [...args, "--dry-run"], { + env, + timeout: 60000, + }); + assert.match(preview.stdout, /Dry run complete/); + assert.equal(existsSync(state), false); + assert.equal( + readFileSync(join(base, "userdata/settings.json"), "utf8"), + before, + ); + const result = await exec(process.execPath, [...args, "--yes"], { + env, + timeout: 60000, + }); + assert.match(result.stdout, /Setup complete/); + const config = readFileSync(join(home, "config.toml"), "utf8"); + assert.match(config, /enabled = false/); + const again = await exec(process.execPath, [...args, "--yes"], { + env, + timeout: 60000, + }); + assert.match(again.stdout, /Setup complete/); + assert.equal(readFileSync(join(home, "config.toml"), "utf8"), config); + console.log( + "PASS: stock T3 installer dry run, config writes, real Codex prerequisite, managed credentials, MCP verification, and idempotent rerun. No threads or messages created.", + ); +} finally { + if (server.exitCode === null) { + const exited = once(server, "exit"); + server.kill(); + await exited; + } + rmSync(root, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 200, + }); +} diff --git a/src/cli.ts b/src/cli.ts index 3d96a37..fc02e8f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,6 +6,7 @@ import { runWorker } from "./worker.js"; const help = `t3poll — watch a GitHub PR and wake a T3 thread + t3poll setup Configure Codex inside T3 t3poll mcp Start the stdio MCP server t3poll watch --thread Watch for new activity t3poll list [--threads] Show watches; optionally list T3 threads @@ -21,6 +22,11 @@ Watches stop on merge/closure, cancellation, or after 24 hours. `; async function main(): Promise { + if (process.argv[2] === "setup") { + const { runSetup } = await import("./installer.js"); + await runSetup(process.argv.slice(3)); + return; + } const { values, positionals } = parseArgs({ allowPositionals: true, options: { diff --git a/src/codex-config.ts b/src/codex-config.ts new file mode 100644 index 0000000..7ab8eac --- /dev/null +++ b/src/codex-config.ts @@ -0,0 +1,140 @@ +import { isDeepStrictEqual } from "node:util"; +import { parse } from "smol-toml"; +import { parseTOML, type AST } from "toml-eslint-parser"; + +// Patch the specific value by syntax ranges, preserving comments and formatting. +// For an implicit default, try the nearest containing table. Parsing the result +// verifies that the insertion changes exactly the intended semantic value. +export function disableGlobalEntry(source: string): string { + const expected = parse(source); + const servers = expected.mcp_servers as + Record> | undefined; + if (!servers?.t3poll || servers.t3poll.enabled === false) return source; + servers.t3poll.enabled = false; + const ast = parseTOML(source); + const target = ["mcp_servers", "t3poll", "enabled"]; + const candidates: string[] = []; + const newline = source.includes("\r\n") ? "\r\n" : "\n"; + function visit( + container: AST.TOMLTopLevelTable | AST.TOMLTable | AST.TOMLInlineTable, + path: (string | number)[], + ) { + if ( + path.length < target.length && + path.every((key, i) => key === target[i]) + ) { + const assignment = `${target.slice(path.length).join(".")} = false`; + if (container.type === "TOMLInlineTable") { + const end = container.range[1] - 1; + const last = container.body.at(-1); + const trailingComma = + last && + ast.tokens.some( + (t) => + t.range[0] >= last.range[1] && + t.range[1] <= end && + t.value === ",", + ); + candidates.push( + source.slice(0, end) + + (last && !trailingComma ? ", " : " ") + + assignment + + source.slice(end), + ); + } else { + const headerEnd = + container.type === "TOMLTable" + ? source.indexOf("\n", container.key.range[1]) + : -1; + const offset = + container.type === "TOMLTopLevelTable" + ? 0 + : headerEnd < 0 + ? source.length + : headerEnd + 1; + candidates.push( + source.slice(0, offset) + + (offset && source[offset - 1] !== "\n" ? newline : "") + + assignment + + newline + + source.slice(offset), + ); + } + } + for (const item of container.body) { + if (item.type === "TOMLTable") { + visit(item, item.resolvedKey); + continue; + } + const key = [ + ...path, + ...item.key.keys.map((k) => (k.type === "TOMLBare" ? k.name : k.value)), + ]; + if (isDeepStrictEqual(key, target)) + candidates.unshift( + source.slice(0, item.value.range[0]) + + "false" + + source.slice(item.value.range[1]), + ); + if (item.value.type === "TOMLInlineTable") visit(item.value, key); + } + } + visit(ast.body[0], []); + for (const candidate of candidates) { + try { + if (isDeepStrictEqual(parse(candidate), expected)) return candidate; + } catch { + /* Try a more specific table. */ + } + } + throw new Error( + "Could not update the global t3poll entry without changing other settings.", + ); +} + +// An inline mcp_servers table is sealed in TOML: a managed sibling section +// cannot be appended. Expand just that assignment into dotted assignments, +// retaining the existing values and comments rather than rewriting the file. +export function expandInlineServers(source: string): string { + const ast = parseTOML(source); + for (const item of ast.body[0].body) { + if (item.type !== "TOMLKeyValue" || item.key.keys.length !== 1) continue; + const key = item.key.keys[0]!; + if ( + (key.type === "TOMLBare" ? key.name : key.value) !== "mcp_servers" || + item.value.type !== "TOMLInlineTable" + ) + continue; + const newline = source.includes("\r\n") ? "\r\n" : "\n"; + const body = item.value.body; + const comments = ast.comments.filter( + (c) => + c.range[0] >= item.range[0] && + c.range[1] <= item.range[1] && + !body.some( + (kv) => + c.range[0] >= kv.value.range[0] && c.range[1] <= kv.value.range[1], + ), + ); + const prefix = source.slice(...item.key.range); + const replacement = [ + ...comments.map((c) => source.slice(...c.range)), + ...body.map( + (kv) => + `${prefix}.${source.slice(...kv.key.range)} = ${source.slice(...kv.value.range)}`, + ), + ].join(newline); + const result = + source.slice(0, item.range[0]) + + replacement + + source.slice(item.range[1]); + const expected = parse(source); + if (!body.length) delete expected.mcp_servers; + if (!isDeepStrictEqual(parse(result), expected)) + throw new Error( + "Could not expand MCP configuration without changing existing entries.", + ); + return result; + } + return source; +} diff --git a/src/install-plan.ts b/src/install-plan.ts new file mode 100644 index 0000000..abe5cce --- /dev/null +++ b/src/install-plan.ts @@ -0,0 +1,447 @@ +import { disableGlobalEntry, expandInlineServers } from "./codex-config.js"; +import { createHash, randomUUID } from "node:crypto"; +import { + existsSync, + readFileSync, + writeFileSync, + renameSync, + rmSync, + realpathSync, + lstatSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { homedir } from "node:os"; +import { parse, stringify } from "smol-toml"; +import { z } from "zod"; +import { privateDirectory, protectFile } from "./private-files.js"; + +const object = z.record(z.string(), z.unknown()); +const configSchema = z + .object({ + launchArgs: z.string().optional(), + homePath: z.string().optional(), + shadowHomePath: z.string().optional(), + binaryPath: z.string().optional(), + enabled: z.boolean().optional(), + }) + .passthrough(); +export type Provider = { + id: string; + label: string; + config: z.infer; + modern: boolean; + environment: Record; +}; +export function providers(settings: Record): Provider[] { + const instances = object.parse(settings.providerInstances ?? {}); + const result: Provider[] = []; + for (const [id, raw] of Object.entries(instances)) { + const entry = object.parse(raw); + if (entry.driver !== "codex" || entry.enabled === false) continue; + const config = configSchema.parse(entry.config ?? {}); + const environment: Record = {}; + for (const item of z + .array( + z.object({ + name: z.string(), + value: z.string(), + sensitive: z.boolean().optional(), + valueRedacted: z.boolean().optional(), + }), + ) + .parse(entry.environment ?? [])) { + if ( + ["CODEX_HOME", "T3CODE_CODEX_LAUNCH_ARGS"].includes(item.name) && + (item.sensitive || item.valueRedacted) + ) + throw new Error( + `Provider ${id} has a protected ${item.name} override. Resolve it in T3 settings before setup.`, + ); + if (!item.sensitive && !item.valueRedacted) + environment[item.name] = item.value; + } + result.push({ + id, + label: typeof entry.displayName === "string" ? entry.displayName : id, + config, + modern: true, + environment, + }); + } + if (!("codex" in instances)) { + const legacy = object.parse(settings.providers ?? {}); + const config = configSchema.parse(legacy.codex ?? {}); + if (config.enabled !== false) + result.unshift({ + id: "codex", + label: "Codex", + config, + modern: false, + environment: {}, + }); + } + return result; +} + +// Matches T3's quote/escape rules, but rejects unclosed quotes rather than +// allowing an appended argument to become part of the preceding value. +export function tokenize(input: string): string[] { + const result: string[] = []; + let word = "", + quote = "", + quoted = false; + for (let i = 0; i < input.length; i++) { + const c = input[i]!; + if (quote) { + if (c === quote) { + quote = ""; + quoted = true; + } else if ( + c === "\\" && + quote === '"' && + input[i + 1] && + ['"', "\\", "$", "`"].includes(input[i + 1]!) + ) + word += input[++i]; + else word += c; + } else if (c === "'" || c === '"') { + quote = c; + quoted = true; + } else if (/\s/.test(c)) { + if (word || quoted) result.push(word); + word = ""; + quoted = false; + } else if (c === "\\" && input[i + 1] && /\s/.test(input[i + 1]!)) + word += input[++i]; + else word += c; + } + if (quote) + throw new Error( + "Launch arguments contain an unclosed quote. Fix it in T3 before setup.", + ); + if (word || quoted) result.push(word); + return result; +} +export function mergeLaunchArgs(input: string, server: string): string { + const args = tokenize(input); + const root = `mcp_servers.${server}`; + let enabled = false; + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (arg === "--") + throw new Error( + "Launch arguments contain --; configuration flags cannot safely be appended after it.", + ); + let setting: string | undefined; + if (arg === "-c" || arg === "--config") { + setting = args[++i]; + if (!setting || setting.startsWith("-")) + throw new Error( + "Launch arguments have a configuration flag without a value.", + ); + } else if (arg.startsWith("--config=")) setting = arg.slice(9); + else if (arg.startsWith("-c=")) setting = arg.slice(3); + else if (arg.startsWith("-c") && arg.length > 2) setting = arg.slice(2); + if (!setting) continue; + // TOML parsing canonicalizes quoted/dotted keys and detects ancestor overrides. + const equal = setting.indexOf("="); + if (equal < 1) + throw new Error( + "Launch arguments contain a configuration override without key=value.", + ); + let keys: string[] = []; + let node: unknown; + try { + node = parse(`${setting.slice(0, equal)} = true`); + while (node && typeof node === "object" && !Array.isArray(node)) { + const entries = Object.entries(node); + if (entries.length !== 1) break; + keys.push(entries[0]![0]); + node = entries[0]![1]; + } + } catch { + throw new Error("Launch arguments contain an invalid configuration key."); + } + if (keys[0] !== "mcp_servers") continue; + if ( + keys.length === 1 || + (keys[1] === server && + !( + keys.length === 3 && + keys[2] === "enabled" && + setting.slice(equal + 1).trim() === "true" + )) + ) { + throw new Error( + `Launch arguments override ${root} or its parent. Remove that override before setup; unrelated arguments can stay.`, + ); + } + if (keys[1] === server) enabled = true; + } + return enabled + ? input + : `${input.trimEnd()}${input.trim() ? " " : ""}-c ${root}.enabled=true`; +} +export function releaseChannel(version: string): "latest" | "nightly" { + if (/^\d+\.\d+\.\d+$/.test(version)) return "latest"; + if (/^\d+\.\d+\.\d+-nightly[.\d-]*$/.test(version)) return "nightly"; + throw new Error( + `No release channel is defined for package version ${version}.`, + ); +} +export function expandPath( + path: string, + cwd = process.cwd(), + home = homedir(), +): string { + return resolve( + cwd, + path === "~" + ? home + : /^~[\\/]/.test(path) + ? join(home, path.slice(2)) + : path, + ); +} +export function readOptional(path: string): string | undefined { + try { + return readFileSync(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } +} +export type Edit = { path: string; before: string | undefined; after: string }; +export type InstallPlan = { + server: string; + channel: string; + provider: Provider; + codexHome: string; + stateHome: string; + edits: Edit[]; + launchArgs: string; + environmentBlocked: boolean; + globalEnvironmentScope: boolean; + legacyGlobalEnabled: boolean; + disablesLegacy: boolean; + command: string; + args: string[]; + baseDir: string; +}; +export function planInstall(input: { + baseDir: string; + providerId: string; + stateHome: string; + version: string; + command: string; + args: string[]; + processEnv?: Record; + processCwd?: string; + codexHome?: string; + disableLegacy?: boolean; +}): InstallPlan { + const baseDir = realpathSync(input.baseDir); + const settingsPath = join(baseDir, "userdata", "settings.json"); + const before = readOptional(settingsPath); + const settings = object.parse(JSON.parse(before ?? "{}")); + const provider = providers(settings).find((p) => p.id === input.providerId); + if (!provider) + throw new Error( + "The selected Codex configuration is no longer available. Run setup again.", + ); + const environment = { ...input.processEnv, ...provider.environment }; + const userHome = environment.USERPROFILE || environment.HOME || homedir(); + const cwd = input.processCwd ?? baseDir; + const codexHome = expandPath( + input.codexHome || + provider.config.homePath || + (provider.config.shadowHomePath ? undefined : environment.CODEX_HOME) || + join(userHome, ".codex"), + cwd, + userHome, + ); + const stateHome = resolve(input.stateHome); + const server = `t3poll_${createHash("sha256").update(`${baseDir}\n${provider.id}`).digest("hex").slice(0, 12)}`; + const saved = provider.config.launchArgs ?? ""; + const launchArgs = mergeLaunchArgs(saved, server); + const override = environment.T3CODE_CODEX_LAUNCH_ARGS?.trim(); + const globalEnvironmentScope = + !!override && + !provider.environment.T3CODE_CODEX_LAUNCH_ARGS?.trim() && + providers(settings).length > 1; + const environmentBlocked = + globalEnvironmentScope || + (!!override && mergeLaunchArgs(override, server) !== override); + if (provider.modern) { + const instances = object.parse(settings.providerInstances); + const entry = object.parse(instances[provider.id]); + instances[provider.id] = { + ...entry, + config: { + ...provider.config, + launchArgs, + ...(input.codexHome ? { homePath: codexHome } : {}), + }, + }; + settings.providerInstances = instances; + } else { + settings.providers = { + ...object.parse(settings.providers ?? {}), + codex: { + ...provider.config, + launchArgs, + ...(input.codexHome ? { homePath: codexHome } : {}), + }, + }; + } + let configPath = join(codexHome, "config.toml"); + if (existsSync(configPath)) configPath = realpathSync(configPath); + const configBefore = readOptional(configPath); + const originalSource = configBefore ?? ""; + const originalServers = object.parse(parse(originalSource).mcp_servers ?? {}); + const legacyGlobalEnabled = + !!originalServers.t3poll && + object.parse(originalServers.t3poll).enabled !== false; + const disablesLegacy = legacyGlobalEnabled && input.disableLegacy !== false; + const source = expandInlineServers( + disablesLegacy ? disableGlobalEntry(originalSource) : originalSource, + ); + const parsed = parse(source); + const start = `# t3poll managed ${server} begin`; + const end = `# t3poll managed ${server} end`; + const startIndex = source.indexOf(start), + endIndex = source.indexOf(end); + const entries = object.parse(parsed.mcp_servers ?? {}); + if (startIndex >= 0 && endIndex > startIndex) { + if ( + (startIndex > 0 && source[startIndex - 1] !== "\n") || + (endIndex > 0 && source[endIndex - 1] !== "\n") + ) + throw new Error("Invalid t3poll config block boundaries."); + const managed = parse(source.slice(startIndex + start.length, endIndex)); + const ownEntries = object.parse(managed.mcp_servers ?? {}); + if ( + Object.keys(managed).some((key) => key !== "mcp_servers") || + Object.keys(ownEntries).some((key) => key !== server) + ) + throw new Error( + "The t3poll managed block contains unrelated settings. Move them outside the block before setup.", + ); + } + + if ( + startIndex < 0 !== endIndex < 0 || + (startIndex >= 0 && + (endIndex < startIndex || + source.indexOf(start, startIndex + 1) >= 0 || + source.indexOf(end, endIndex + 1) >= 0)) + ) + throw new Error( + "The t3poll managed config block is damaged. Restore its backup before setup.", + ); + if (entries[server] && startIndex < 0) + throw new Error( + `Codex already has an unmanaged ${server} entry. Rename or remove it before setup.`, + ); + const channel = releaseChannel(input.version); + const block = `${start}\n${stringify({ mcp_servers: { [server]: { enabled: false, command: input.command, args: input.args, startup_timeout_sec: 120, env: { T3POLL_BASE_DIR: baseDir, T3POLL_HOME: stateHome, T3POLL_URL: "", T3POLL_TOKEN_FILE: "", T3POLL_THREAD_ID: "" } } } })}${end}\n`; + const configAfter = + startIndex >= 0 + ? source.slice(0, startIndex) + + block + + source.slice(endIndex + end.length).replace(/^\r?\n/, "") + : source + (source.endsWith("\n") || !source ? "" : "\n") + "\n" + block; + parse(configAfter); + const edits = [ + { path: configPath, before: configBefore, after: configAfter }, + { + path: settingsPath, + before, + after: `${JSON.stringify(settings, null, 2)}\n`, + }, + ]; + return { + server, + channel, + provider, + codexHome, + stateHome, + edits, + launchArgs, + environmentBlocked, + globalEnvironmentScope, + legacyGlobalEnabled, + disablesLegacy, + command: input.command, + args: input.args, + baseDir, + }; +} + +function writeAtomic(path: string, text: string) { + const tmp = `${path}.${randomUUID()}.tmp`; + try { + writeFileSync(tmp, text, { flag: "wx", mode: 0o600 }); + protectFile(tmp); + renameSync(tmp, path); + } finally { + rmSync(tmp, { force: true }); + } +} +// Compare against the reviewed snapshot, back up originals, and roll back only +// our own writes. Never overwrite a concurrent user/T3 edit during rollback. +export function applyPlan(plan: InstallPlan): { + backups: string[]; + rollback: () => void; +} { + if (plan.environmentBlocked) + throw new Error( + "T3's launch environment overrides its saved settings. Update or remove T3CODE_CODEX_LAUNCH_ARGS and restart T3, then rerun setup.", + ); + const changed = plan.edits.filter((e) => e.before !== e.after); + const written: Edit[] = [], + backups: string[] = []; + const locks: string[] = []; + const rollback = () => { + for (const e of [...written].reverse()) { + if (readOptional(e.path) !== e.after) + throw new Error( + `Configuration changed during setup. Restore manually using the backup for ${e.path}.`, + ); + if (e.before === undefined) rmSync(e.path); + else writeAtomic(e.path, e.before); + } + }; + try { + for (const e of [...changed].sort((a, b) => a.path.localeCompare(b.path))) { + privateDirectory(dirname(e.path)); + const lock = `${e.path}.t3poll-lock`; + writeFileSync(lock, String(process.pid), { flag: "wx", mode: 0o600 }); + locks.push(lock); + if (existsSync(e.path) && lstatSync(e.path).isSymbolicLink()) + throw new Error(`Configuration path changed to a symlink: ${e.path}`); + if (readOptional(e.path) !== e.before) + throw new Error( + "Configuration changed while reviewing setup. Run setup again.", + ); + if (e.before !== undefined) { + const backup = `${e.path}.t3poll-${randomUUID()}.bak`; + writeFileSync(backup, e.before, { flag: "wx", mode: 0o600 }); + protectFile(backup); + backups.push(backup); + } + } + for (const e of changed) { + if (readOptional(e.path) !== e.before) + throw new Error("Configuration changed during setup. Run setup again."); + writeAtomic(e.path, e.after); + written.push(e); + } + } catch (error) { + rollback(); + throw error; + } finally { + for (const lock of locks) rmSync(lock, { force: true }); + } + return { backups, rollback }; +} diff --git a/src/installer.ts b/src/installer.ts new file mode 100644 index 0000000..5735641 --- /dev/null +++ b/src/installer.ts @@ -0,0 +1,509 @@ +import * as p from "@clack/prompts"; +import { parseArgs, promisify } from "node:util"; +import { execFile } from "node:child_process"; +import { readFileSync, existsSync, realpathSync } from "node:fs"; +import { dirname, join, resolve, delimiter } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { configFromEnv } from "./config.js"; +import { + candidateHomes, + inspectLocal, + connection, + type LocalT3, +} from "./setup.js"; +import { readLocalProcess } from "./local-process.js"; +import { T3 } from "./t3.js"; +import { + applyPlan, + planInstall, + providers, + readOptional, + releaseChannel, + expandPath, + type InstallPlan, +} from "./install-plan.js"; + +const exec = promisify(execFile); +const pkg = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +) as { version: string }; +class Cancelled extends Error {} +async function answer(prompt: Promise): Promise { + const value = await prompt; + if (p.isCancel(value)) throw new Cancelled(); + return value as T; +} +function npmCli(): string { + const candidates = [ + process.env.npm_execpath, + join(dirname(process.execPath), "node_modules/npm/bin/npm-cli.js"), + join(dirname(process.execPath), "../lib/node_modules/npm/bin/npm-cli.js"), + "/usr/share/nodejs/npm/bin/npm-cli.js", + ]; + for (const dir of (process.env.PATH ?? "").split(delimiter)) { + const command = join(dir, process.platform === "win32" ? "npm.cmd" : "npm"); + if (existsSync(command)) { + candidates.push(realpathSync(command)); + candidates.push(join(dir, "node_modules/npm/bin/npm-cli.js")); + } + } + const found = candidates.find( + (path) => path?.endsWith("npm-cli.js") && existsSync(path), + ); + if (!found) + throw new Error( + "Cannot locate npm's npm-cli.js. Install Node.js with npm, then run setup again.", + ); + return realpathSync(found); +} +function runtime(local: string | undefined): { + command: string; + args: string[]; +} { + if (local) { + const cli = realpathSync(expandPath(local)); + return { command: process.execPath, args: [cli, "mcp"] }; + } + return { + command: process.execPath, + args: [ + npmCli(), + "exec", + "--yes", + `--package=t3poll@${releaseChannel(pkg.version)}`, + "--", + "t3poll", + "mcp", + ], + }; +} +async function checkCodex( + plan: InstallPlan, + environment: Record, + cwd: string, +): Promise { + const binary = plan.provider.config.binaryPath?.trim() || "codex"; + const expanded = + binary.includes("/") || binary.includes("\\") || binary.startsWith("~") + ? expandPath(binary, cwd, environment.USERPROFILE || environment.HOME) + : binary; + let command = expanded; + let args = ["--version"]; + if (process.platform === "win32") { + // npm's Windows Codex shim is a batch file. Launch its JS entry through + // Node directly so neither spaces nor shell metacharacters need escaping. + const dirs = + expanded === "codex" + ? ( + environment.PATH || + environment.Path || + process.env.PATH || + "" + ).split(delimiter) + : [dirname(expanded)]; + for (const dir of dirs) { + const script = join(dir, "node_modules/@openai/codex/bin/codex.js"); + if (existsSync(script)) { + command = process.execPath; + args = [script, "--version"]; + break; + } + const native = join(dir, "codex.exe"); + if (expanded === "codex" && existsSync(native)) { + command = native; + break; + } + } + } + try { + await exec(command, args, { + cwd, + env: { ...process.env, ...environment }, + timeout: 15_000, + windowsHide: true, + }); + } catch { + throw new Error( + "Cannot run the selected Codex executable. Install Codex or correct its binary path in T3 settings, then rerun setup.", + ); + } +} +async function checkT3Support(server: LocalT3): Promise { + let supported: boolean; + if (server.electron) { + const { stdout } = await exec( + server.node, + [ + "-e", + 'process.stdout.write(String(require("node:fs").readFileSync(process.argv[1],"utf8").includes("T3CODE_CODEX_LAUNCH_ARGS")))', + server.cli, + ], + { + env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, + timeout: 15000, + windowsHide: true, + }, + ); + supported = stdout.trim() === "true"; + } else + supported = readFileSync(server.cli, "utf8").includes( + "T3CODE_CODEX_LAUNCH_ARGS", + ); + if (!supported) + throw new Error( + "This T3 build could not be verified to support Codex launch arguments. Update T3 before T3-only setup.", + ); +} +function liveEnvironment(server: LocalT3) { + const state = JSON.parse( + readFileSync(join(server.baseDir, "userdata/server-runtime.json"), "utf8"), + ); + return readLocalProcess(state.pid); +} +export async function verifyRuntime(plan: InstallPlan): Promise { + const env = Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ), + ); + Object.assign(env, { + T3POLL_BASE_DIR: plan.baseDir, + T3POLL_HOME: plan.stateHome, + T3POLL_URL: "", + T3POLL_TOKEN_FILE: "", + T3POLL_THREAD_ID: "", + }); + const client = new Client({ name: "t3poll-setup", version: pkg.version }); + const transport = new StdioClientTransport({ + command: plan.command, + args: plan.args, + env, + stderr: "pipe", + }); + // Drain child stderr without printing possible inherited configuration secrets. + transport.stderr?.on("data", () => {}); + const timeout = setTimeout(() => { + void transport.close(); + }, 120_000); + try { + await client.connect(transport); + const result = await client.listTools(); + for (const name of ["watch", "list", "stop"]) + if (!result.tools.some((t) => t.name === name)) + throw new Error(`MCP runtime is missing ${name}.`); + } catch { + throw new Error( + "MCP runtime verification failed. Check npm/network access, or use --runtime-path with a local build before the first npm release.", + ); + } finally { + clearTimeout(timeout); + await client.close(); + } +} + +export async function runSetup(args: string[]): Promise { + const { values, positionals } = parseArgs({ + args, + options: { + "base-dir": { type: "string" }, + provider: { type: "string" }, + "codex-home": { type: "string" }, + "state-home": { type: "string" }, + "runtime-path": { type: "string" }, + "dry-run": { type: "boolean" }, + "keep-global": { type: "boolean" }, + yes: { type: "boolean" }, + help: { type: "boolean", short: "h" }, + }, + }); + if (positionals.length) throw new Error("Unexpected setup arguments."); + if (values.help) { + console.log( + `t3poll setup [--base-dir ] [--provider ] [--codex-home ]\n [--state-home ] [--dry-run] [--yes] [--keep-global]\n [--runtime-path ]\n\nThe invoked package determines Stable or Nightly. --dry-run writes nothing.\n--runtime-path uses a local build instead of npm channel updates.\n--keep-global preserves an existing enabled global t3poll entry.\n--yes accepts the recommended plan; ambiguous instances still require explicit selection.`, + ); + return; + } + const interactive = + !!process.stdin.isTTY && !!process.stdout.isTTY && !values.yes; + if (!interactive && !values.yes && !values["dry-run"]) + throw new Error( + "Run setup in a terminal, or use --yes with explicit selections. Use --dry-run to preview.", + ); + try { + if (interactive) { + p.intro("Welcome to t3poll"); + p.log.info( + "Set up PR notifications for Codex sessions running inside T3 Code.", + ); + if ( + !(await answer( + p.confirm({ message: "Proceed with setup?", initialValue: true }), + )) + ) + throw new Cancelled(); + } + while (true) { + try { + await exec("gh", ["auth", "status"], { + timeout: 15_000, + windowsHide: true, + }); + break; + } catch { + if (!interactive || values["dry-run"]) { + if (values["dry-run"]) { + console.log( + "GitHub CLI is unavailable or not signed in. Run gh auth login before applying.", + ); + break; + } + throw new Error( + "GitHub CLI is unavailable or not signed in. Install gh, then run gh auth login in another terminal.", + ); + } + p.log.warn( + "GitHub CLI is unavailable or not signed in. Install gh if needed, then run gh auth login in another terminal.", + ); + if ( + !(await answer( + p.confirm({ message: "Check again?", initialValue: true }), + )) + ) + throw new Cancelled(); + } + } + let selectedHome = values["base-dir"] + ? expandPath(values["base-dir"]) + : undefined; + let server: LocalT3 | undefined; + while (!server) { + const homes = selectedHome + ? [selectedHome] + : candidateHomes(configFromEnv()); + const found = [ + ...new Map( + homes + .map((h) => inspectLocal(h)) + .filter((s): s is LocalT3 => !!s) + .map((s) => [s.baseDir, s]), + ).values(), + ]; + if (found.length === 1) server = found[0]; + else if (!interactive) + throw new Error( + found.length + ? "Multiple T3 instances found. Pass --base-dir." + : "No supported running T3 instance found. Open T3 and pass --base-dir if needed.", + ); + else { + if (found.length > 1) { + const picked = await answer( + p.select({ + message: "Which T3 instance?", + options: [ + ...found.map((s) => ({ + value: s.baseDir, + label: s.baseDir, + hint: s.origin, + })), + { value: "custom", label: "Enter another T3 data directory" }, + ], + }), + ); + if (picked !== "custom") { + server = found.find((s) => s.baseDir === picked); + continue; + } + } else + p.log.warn( + "No supported running T3 instance found. Open T3, then enter its data directory.", + ); + selectedHome = expandPath( + await answer( + p.text({ + message: "T3 data directory", + initialValue: selectedHome ?? homes[0], + validate: (v) => (!v?.trim() ? "Enter a directory." : undefined), + }), + ), + ); + } + } + await checkT3Support(server); + const available = providers( + JSON.parse( + readOptional(join(server.baseDir, "userdata/settings.json")) ?? "{}", + ), + ); + if (!available.length) + throw new Error("Enable a Codex provider in T3 before running setup."); + let providerId = values.provider; + if (!providerId) { + if (available.length === 1) providerId = available[0]!.id; + else if (!interactive) + throw new Error( + `Choose a Codex configuration with --provider: ${available.map((p) => p.id).join(", ")}`, + ); + else + providerId = await answer( + p.select({ + message: "Which Codex configuration?", + options: available.map((p) => ({ + value: p.id, + label: p.label, + hint: p.id, + })), + }), + ); + } + const codexHome = values["codex-home"]; + const stateHome = expandPath(values["state-home"] ?? configFromEnv().home); + const launch = runtime(values["runtime-path"]); + let disableLegacy = !values["keep-global"]; + const live = liveEnvironment(server); + const makePlan = () => + planInstall({ + baseDir: server.baseDir, + providerId, + stateHome, + version: pkg.version, + ...launch, + processEnv: live.env, + processCwd: live.cwd, + disableLegacy, + ...(codexHome ? { codexHome } : {}), + }); + let plan = makePlan(); + if (interactive && plan.legacyGlobalEnabled && !values["keep-global"]) { + p.note( + "t3poll is designed for Codex sessions running inside T3 Code. We recommend keeping the global MCP entry disabled and enabling it through T3 Code's launch arguments, so its tools appear in the sessions where they work as intended.", + "Keep t3poll scoped to T3 Code", + ); + disableLegacy = await answer( + p.confirm({ + message: "Disable the existing global t3poll entry?", + initialValue: true, + }), + ); + plan = makePlan(); + } + const configuration = + plan.provider.label === "Codex" ? "Default" : plan.provider.label; + const destination = [ + `T3 Code instance: ${server.origin}`, + `Codex configuration: ${configuration}`, + `Runtime: ${values["runtime-path"] ? "Local build (no npm updates)" : `t3poll@${plan.channel}`}`, + ].join("\n"); + const actions = [ + "1. Configure t3poll's PR-watching tools in Codex.", + "2. Enable them through this T3 instance's launch arguments.", + "3. Set up a managed credential for this T3 instance.", + "4. Verify the MCP tools and the connection to T3.", + "", + ...(plan.legacyGlobalEnabled + ? [ + plan.disablesLegacy + ? "Disable the existing global t3poll entry." + : "Keep the existing global t3poll entry enabled.", + ] + : []), + plan.legacyGlobalEnabled && !plan.disablesLegacy + ? "The global tools will remain available outside T3 Code." + : "The tools will be available only in this T3 Codex configuration.", + "Changed configuration files will be backed up before writing.", + "", + "After setup, open a fresh Codex session in T3 to watch a PR.", + ].join("\n"); + if (interactive) { + p.note(destination, "Your connection"); + p.note(actions, "What setup will do"); + } else console.log(`${destination}\n\n${actions}`); + if (values["dry-run"]) { + const details = [ + `T3 data directory: ${plan.baseDir}`, + `t3poll state directory: ${plan.stateHome}`, + ...plan.edits.map( + (e) => `${e.before === e.after ? "Keep" : "Update"}: ${e.path}`, + ), + ].join("\n"); + if (interactive) p.note(details, "File details"); + else console.log(details); + } + if (plan.environmentBlocked) { + const message = plan.globalEnvironmentScope + ? "T3CODE_CODEX_LAUNCH_ARGS applies to multiple Codex configurations. Remove it from T3’s launch environment, or move it into the selected provider’s environment settings. Restart T3 and rerun setup." + : `T3CODE_CODEX_LAUNCH_ARGS overrides saved launch arguments.\nKeep your existing arguments and add:\n-c mcp_servers.${plan.server}.enabled=true\nOr remove the environment override. Restart T3, then rerun setup.`; + if (values["dry-run"]) { + console.log(message); + return; + } + throw new Error(message); + } + if (values["dry-run"]) { + console.log("Dry run complete. No files or credentials changed."); + return; + } + if ( + interactive && + !(await answer( + p.confirm({ message: "Install t3poll?", initialValue: true }), + )) + ) + throw new Cancelled(); + // Verify the exact runtime command before modifying configuration. No watch/list + // calls: list can restart workers, so setup uses only the MCP tool catalog. + if (interactive) p.log.step("Checking MCP runtime"); + const codexProcess = liveEnvironment(server); + await checkCodex( + plan, + { ...codexProcess.env, ...plan.provider.environment }, + codexProcess.cwd, + ); + await verifyRuntime(plan); + const current = inspectLocal(server.baseDir); + if (!current || current.origin !== server.origin) + throw new Error("T3 changed during setup. Run setup again."); + const latestEnv = liveEnvironment(current); + const rechecked = planInstall({ + baseDir: server.baseDir, + providerId, + stateHome, + version: pkg.version, + ...launch, + processEnv: latestEnv.env, + processCwd: latestEnv.cwd, + disableLegacy, + ...(codexHome ? { codexHome } : {}), + }); + if (JSON.stringify(rechecked) !== JSON.stringify(plan)) + throw new Error( + "Configuration changed during setup. Review it again by rerunning setup.", + ); + const transaction = applyPlan(plan); + try { + const credential = await connection({ + home: plan.stateHome, + baseDir: plan.baseDir, + }); + await new T3(credential.origin, credential.tokenFile).threads(); + } catch (error) { + transaction.rollback(); + throw error; + } + for (const backup of transaction.backups) console.log(`Backup: ${backup}`); + const done = + (plan.legacyGlobalEnabled && !plan.disablesLegacy + ? "The existing global t3poll entry remains enabled outside T3. " + : "") + + "Setup complete. Open a fresh Codex session in the selected T3 instance to use t3poll. Existing sessions keep their current tools."; + if (interactive) p.outro(done); + else console.log(done); + } catch (error) { + if (error instanceof Cancelled) { + p.cancel("Setup cancelled."); + return; + } + throw error; + } +} diff --git a/src/setup.ts b/src/setup.ts index 84999cf..0001d69 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -50,7 +50,7 @@ function canReuse(metadata: Managed): boolean { ); } -function candidateHomes(config: Config): string[] { +export function candidateHomes(config: Config): string[] { if (config.baseDir) return [config.baseDir]; const homes = new Set([ resolve(process.env.T3CODE_HOME ?? join(homedir(), ".t3")), diff --git a/tests/fixtures/local-t3.mjs b/tests/fixtures/local-t3.mjs index 4f27979..9e0462c 100644 --- a/tests/fixtures/local-t3.mjs +++ b/tests/fixtures/local-t3.mjs @@ -1,3 +1,4 @@ +// Installer capability fixture: T3CODE_CODEX_LAUNCH_ARGS // Local process/credential fixture. Copied into a temporary t3 package by setup tests. import { createServer } from "node:http"; import { diff --git a/tests/install-plan.test.ts b/tests/install-plan.test.ts new file mode 100644 index 0000000..d0db0e5 --- /dev/null +++ b/tests/install-plan.test.ts @@ -0,0 +1,326 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + rmSync, + existsSync, +} from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { parse } from "smol-toml"; +import { + applyPlan, + planInstall, + mergeLaunchArgs, + providers, + releaseChannel, + tokenize, +} from "../src/install-plan.js"; +import { assertPrivateFile } from "../src/private-files.js"; + +function fixture(t: { after: (fn: () => void) => void }) { + const root = mkdtempSync(join(tmpdir(), "t3poll install café space-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const base = join(root, "t3"), + codex = join(root, "codex"); + mkdirSync(join(base, "userdata"), { recursive: true }); + mkdirSync(codex); + const settings = join(base, "userdata/settings.json"), + config = join(codex, "config.toml"); + const original = + '# keep my comment\nmodel = "example"\n[mcp_servers.other]\ncommand = "other"\n'; + writeFileSync(config, original); + writeFileSync( + settings, + JSON.stringify({ + unrelated: { keep: true }, + providers: { + codex: { + homePath: codex, + launchArgs: '-c model_reasoning_effort="high"', + customModels: [], + }, + }, + }), + ); + const input = { + baseDir: base, + providerId: "codex", + stateHome: join(root, "state"), + version: "0.1.0", + command: process.execPath, + args: [join(root, "cli.js"), "mcp"], + }; + return { root, base, codex, settings, config, original, input }; +} + +test("ordinary arguments and quoting survive; reruns do not append duplicates", () => { + const source = String.raw`--enable search -c 'model="some model"' --config=features.foo=true -c windows_path='C:\Users\Alex Name'`; + const next = mergeLaunchArgs(source, "t3poll_abc"); + assert.equal(next, source + " -c mcp_servers.t3poll_abc.enabled=true"); + assert.equal(mergeLaunchArgs(next, "t3poll_abc"), next); + assert.deepEqual(tokenize(next).slice(0, -2), tokenize(source)); + assert.equal( + mergeLaunchArgs( + `--config='mcp_servers."t3poll_abc".enabled=true'`, + "t3poll_abc", + ), + `--config='mcp_servers."t3poll_abc".enabled=true'`, + ); +}); +test("same-entry, parent, and unappendable overrides are conflicts", () => { + for (const arg of [ + "-c mcp_servers.t3poll_abc.enabled=false", + "-cmcp_servers.t3poll_abc.enabled=false", + "-c=mcp_servers.t3poll_abc.command=other", + "--config=mcp_servers={}", + "-c mcp_servers.t3poll_abc={}", + "-c", + "--config -c foo=true", + "--", + `-c 'model=oops`, + "-c malformed", + ]) + assert.throws(() => mergeLaunchArgs(arg, "t3poll_abc"), undefined, arg); + assert.doesNotThrow(() => + mergeLaunchArgs("-c mcp_servers.other.enabled=false", "t3poll_abc"), + ); +}); +test("the package version determines its channel", () => { + assert.equal(releaseChannel("1.2.3"), "latest"); + assert.equal(releaseChannel("1.2.3-nightly.20260911.1234"), "nightly"); + assert.throws(() => releaseChannel("1.2.3-beta.1")); +}); +test("plans write nothing; apply preserves comments, backs up, scopes and reruns", (t) => { + const f = fixture(t), + before = readFileSync(f.settings, "utf8"); + const plan = planInstall(f.input); + assert.equal(readFileSync(f.settings, "utf8"), before); + assert.equal(readFileSync(f.config, "utf8"), f.original); + assert.equal(existsSync(f.input.stateHome), false); + const transaction = applyPlan(plan); + assert.equal(transaction.backups.length, 2); + for (const path of transaction.backups) assertPrivateFile(path); + assert.ok(readFileSync(f.config, "utf8").startsWith(f.original)); + const servers = parse(readFileSync(f.config, "utf8")).mcp_servers as Record< + string, + any + >; + assert.equal(servers[plan.server].enabled, false); + assert.equal(servers[plan.server].env.T3POLL_BASE_DIR, plan.baseDir); + const settings = JSON.parse(readFileSync(f.settings, "utf8")); + assert.equal(settings.unrelated.keep, true); + assert.match(settings.providers.codex.launchArgs, /model_reasoning_effort/); + assert.ok( + settings.providers.codex.launchArgs.endsWith( + `-c mcp_servers.${plan.server}.enabled=true`, + ), + ); + const second = planInstall(f.input); + assert.ok(second.edits.every((e) => e.before === e.after)); + assert.deepEqual(applyPlan(second).backups, []); + transaction.rollback(); + assert.equal(readFileSync(f.config, "utf8"), f.original); + assert.equal(readFileSync(f.settings, "utf8"), before); +}); +test("two T3 homes sharing Codex configuration have separate destinations", (t) => { + const f = fixture(t); + const first = planInstall(f.input); + applyPlan(first); + const other = join(f.root, "other"); + mkdirSync(join(other, "userdata"), { recursive: true }); + writeFileSync( + join(other, "userdata/settings.json"), + JSON.stringify({ providers: { codex: { homePath: f.codex } } }), + ); + const second = planInstall({ ...f.input, baseDir: other }); + applyPlan(second); + assert.notEqual(first.server, second.server); + const servers = parse(readFileSync(f.config, "utf8")).mcp_servers as Record< + string, + any + >; + assert.equal(servers[first.server].env.T3POLL_BASE_DIR, first.baseDir); + assert.equal(servers[second.server].env.T3POLL_BASE_DIR, second.baseDir); + assert.equal(servers[first.server].enabled, false); + assert.equal(servers[second.server].enabled, false); +}); +test("explicit provider instances win and preserve other providers", (t) => { + const f = fixture(t); + writeFileSync( + f.settings, + JSON.stringify({ + providers: { codex: { launchArgs: "legacy" } }, + providerInstances: { + codex: { driver: "codex", enabled: false }, + work: { + driver: "codex", + displayName: "Work", + config: { homePath: f.codex, launchArgs: "--enable search" }, + }, + other: { driver: "claudeAgent", config: { keep: true } }, + }, + }), + ); + const available = providers(JSON.parse(readFileSync(f.settings, "utf8"))); + assert.deepEqual( + available.map((p) => p.id), + ["work"], + ); + const plan = planInstall({ ...f.input, providerId: "work" }); + applyPlan(plan); + const after = JSON.parse(readFileSync(f.settings, "utf8")); + assert.equal(after.providers.codex.launchArgs, "legacy"); + assert.equal(after.providerInstances.other.config.keep, true); + assert.match( + after.providerInstances.work.config.launchArgs, + /^--enable search -c/, + ); +}); +test("live environment overrides block ineffective writes unless already enabled", (t) => { + const f = fixture(t); + const base = planInstall(f.input); + const blocked = planInstall({ + ...f.input, + processEnv: { T3CODE_CODEX_LAUNCH_ARGS: "--enable search" }, + }); + assert.equal(blocked.environmentBlocked, true); + assert.throws(() => applyPlan(blocked), /launch environment/); + assert.equal(readFileSync(f.config, "utf8"), f.original); + const compatible = planInstall({ + ...f.input, + processEnv: { + T3CODE_CODEX_LAUNCH_ARGS: `--enable search -c mcp_servers.${base.server}.enabled=true`, + }, + }); + assert.equal(compatible.environmentBlocked, false); +}); +test("stale review and concurrent rollback cannot overwrite another edit", (t) => { + const f = fixture(t), + plan = planInstall(f.input); + writeFileSync(f.settings, '{"userEdit":true}'); + assert.throws(() => applyPlan(plan), /changed while reviewing/); + assert.equal(readFileSync(f.config, "utf8"), f.original); + const next = planInstall({ ...f.input, codexHome: f.codex }); + const transaction = applyPlan(next); + writeFileSync(f.settings, '{"newerEdit":true}'); + assert.throws(() => transaction.rollback(), /changed during setup/); + assert.equal(readFileSync(f.settings, "utf8"), '{"newerEdit":true}'); +}); +test("invalid TOML and unmanaged collisions are not overwritten", (t) => { + const f = fixture(t), + plan = planInstall(f.input); + for (const source of [ + "invalid = [", + `[mcp_servers.${plan.server}]\ncommand="mine"\n`, + ]) { + writeFileSync(f.config, source); + assert.throws(() => planInstall(f.input)); + assert.equal(readFileSync(f.config, "utf8"), source); + } +}); + +test("a global launch override cannot silently enable multiple providers", (t) => { + const f = fixture(t); + const settings = JSON.parse(readFileSync(f.settings, "utf8")); + settings.providerInstances = { + work: { driver: "codex", config: { homePath: f.codex } }, + }; + writeFileSync(f.settings, JSON.stringify(settings)); + const initial = planInstall(f.input); + const env = { + T3CODE_CODEX_LAUNCH_ARGS: `-c mcp_servers.${initial.server}.enabled=true`, + }; + const global = planInstall({ ...f.input, processEnv: env }); + assert.equal(global.globalEnvironmentScope, true); + assert.throws(() => applyPlan(global), /launch environment/); + settings.providerInstances.codex = { + driver: "codex", + config: settings.providers.codex, + environment: [ + { name: "T3CODE_CODEX_LAUNCH_ARGS", value: env.T3CODE_CODEX_LAUNCH_ARGS }, + ], + }; + writeFileSync(f.settings, JSON.stringify(settings)); + assert.equal( + planInstall({ ...f.input, processEnv: env }).environmentBlocked, + false, + ); +}); + +test("unrelated settings inserted into a managed block cannot be deleted on repair", (t) => { + const f = fixture(t), + plan = planInstall(f.input); + applyPlan(plan); + const text = readFileSync(f.config, "utf8").replace( + `# t3poll managed ${plan.server} end`, + `[mcp_servers.personal]\ncommand = "keep"\n# t3poll managed ${plan.server} end`, + ); + writeFileSync(f.config, text); + assert.throws(() => planInstall(f.input), /unrelated settings/); + assert.equal(readFileSync(f.config, "utf8"), text); +}); + +test("existing global t3poll is disabled automatically with backup and rollback", (t) => { + const f = fixture(t); + const source = `${f.original}\n[mcp_servers.t3poll]\n# Keep the old runtime for recovery\ncommand = "old-runtime"\nenabled = true # global\nargs = ["mcp"]\n`; + writeFileSync(f.config, source); + const plan = planInstall(f.input); + assert.equal(readFileSync(f.config, "utf8"), source); + const change = applyPlan(plan); + const after = readFileSync(f.config, "utf8"); + assert.ok(after.includes("enabled = false # global")); + const servers = parse(after).mcp_servers as Record; + assert.equal(servers.t3poll.enabled, false); + assert.equal(servers.t3poll.command, "old-runtime"); + assert.equal(servers[plan.server].enabled, false); + assert.ok( + change.backups.some((path) => readFileSync(path, "utf8") === source), + ); + assert.ok( + planInstall(f.input).edits.every((edit) => edit.before === edit.after), + ); + change.rollback(); + assert.equal(readFileSync(f.config, "utf8"), source); +}); + +test("global entry migration preserves quoted keys, inline tables and implicit defaults", (t) => { + const f = fixture(t); + for (const source of [ + '[mcp_servers."t3poll"] # header\ncommand="old"\n', + "mcp_servers = { t3poll = { command = 'old', enabled = true } }\n", + "mcp_servers = { t3poll = { command = 'old' } }\n", + 'mcp_servers.t3poll.command = "old"\n', + '[mcp_servers]\nt3poll.command = "old"\n', + '[mcp_servers.t3poll.env]\nKEEP="value"\n', + '[mcp_servers.t3poll]\r\ncommand="old"\r\nenabled=true # keep\r\n', + 'description = """\n[mcp_servers.t3poll]\nenabled = true\n"""\n[mcp_servers.t3poll]\ncommand="old"\n', + ]) { + writeFileSync(f.config, source); + const before = parse(source) as any; + before.mcp_servers.t3poll.enabled = false; + const plan = planInstall(f.input); + const after = parse(plan.edits[0]!.after) as any; + delete after.mcp_servers[plan.server]; + assert.deepEqual(after, before, source); + assert.equal(readFileSync(f.config, "utf8"), source); + } +}); + +test("declining migration leaves the existing global entry intact", (t) => { + const f = fixture(t); + const source = '[mcp_servers.t3poll]\ncommand="old"\nenabled=true\n'; + writeFileSync(f.config, source); + const plan = planInstall({ ...f.input, disableLegacy: false }); + assert.equal(plan.legacyGlobalEnabled, true); + assert.equal(plan.disablesLegacy, false); + applyPlan(plan); + assert.ok(readFileSync(f.config, "utf8").startsWith(source)); + assert.equal( + (parse(readFileSync(f.config, "utf8")).mcp_servers as any).t3poll.enabled, + true, + ); +}); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index dd798bb..34a509c 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -312,3 +312,121 @@ test("failed verification revokes its session and retries failed cleanup before ); assert.equal(existsSync(`${c.tokenFile}.pending-session.json`), false); }); + +test("setup CLI previews without side effects, installs a real MCP runtime, and reruns", async (t) => { + const { executable } = await import("./fixtures/executable.mjs"); + const { delimiter } = await import("node:path"); + const { parse } = await import("smol-toml"); + const f = await fixture(t); + const bin = join(f.root, "bin"); + mkdirSync(bin); + executable(bin, "gh", "process.exit(0)"); + const codexBinary = executable( + bin, + "codex", + 'console.log("codex-cli fixture")', + ); + writeFileSync( + join(f.base, "userdata/settings.json"), + JSON.stringify({ providers: { codex: { binaryPath: codexBinary } } }), + ); + const codex = join(f.root, "codex"); + const env = { ...process.env, PATH: `${bin}${delimiter}${process.env.PATH}` }; + const args = [ + resolve("dist/cli.js"), + "setup", + "--base-dir", + f.base, + "--codex-home", + codex, + "--state-home", + f.home, + "--runtime-path", + resolve("dist/cli.js"), + ]; + const preview = await exec(process.execPath, [...args, "--dry-run"], { env }); + assert.match(preview.stdout, /Dry run complete/); + assert.equal(existsSync(codex), false); + assert.equal(existsSync(f.home), false); + assert.equal(f.issued(), 0); + mkdirSync(codex); + const legacy = + '[mcp_servers.t3poll]\ncommand="old-runtime"\nenabled=true # preserve this comment\n'; + writeFileSync(join(codex, "config.toml"), legacy); + const keep = await exec( + process.execPath, + [...args, "--dry-run", "--keep-global"], + { env }, + ); + assert.match(keep.stdout, /Keep the existing global/); + assert.equal(readFileSync(join(codex, "config.toml"), "utf8"), legacy); + const installed = await exec(process.execPath, [...args, "--yes"], { env }); + assert.match(installed.stdout, /Setup complete/); + assert.equal(f.issued(), 1); + const text = readFileSync(join(codex, "config.toml"), "utf8"); + const servers = parse(text).mcp_servers as Record< + string, + { enabled: boolean } + >; + assert.equal(servers.t3poll!.enabled, false); + assert.ok(Object.values(servers).every((server) => server.enabled === false)); + assert.match(text, /enabled=false # preserve this comment/); + await exec(process.execPath, [...args, "--yes"], { env }); + assert.equal(f.issued(), 1); + assert.equal(readFileSync(join(codex, "config.toml"), "utf8"), text); + const { Store } = await import("../src/store.js"); + const store = new Store(f.home); + assert.equal(store.worker(), undefined); + assert.equal(store.all().length, 0); + store.close(); +}); + +test("installer restores configs when credential verification fails", async (t) => { + const { executable } = await import("./fixtures/executable.mjs"); + const { delimiter } = await import("node:path"); + const f = await fixture(t), + bin = join(f.root, "bin"), + codex = join(f.root, "codex"); + mkdirSync(bin); + mkdirSync(codex); + executable(bin, "gh", "process.exit(0)"); + const codexBinary = executable( + bin, + "codex", + 'console.log("codex-cli fixture")', + ); + writeFileSync( + join(f.base, "userdata/settings.json"), + JSON.stringify({ providers: { codex: { binaryPath: codexBinary } } }), + ); + const original = '# keep\nmodel="example"\n'; + writeFileSync(join(codex, "config.toml"), original); + writeFileSync(join(f.base, "reject"), "yes"); + await assert.rejects( + exec( + process.execPath, + [ + resolve("dist/cli.js"), + "setup", + "--yes", + "--base-dir", + f.base, + "--codex-home", + codex, + "--state-home", + f.home, + "--runtime-path", + resolve("dist/cli.js"), + ], + { + env: { ...process.env, PATH: `${bin}${delimiter}${process.env.PATH}` }, + }, + ), + ); + assert.equal(readFileSync(join(codex, "config.toml"), "utf8"), original); + assert.equal( + JSON.parse(readFileSync(join(f.base, "userdata/settings.json"), "utf8")) + .providers.codex.binaryPath, + codexBinary, + ); +});