From 452bc58902b4e038638d381ceae4e146babe0d58 Mon Sep 17 00:00:00 2001 From: eimexdev Date: Fri, 11 Sep 2026 16:29:26 -0700 Subject: [PATCH 1/6] prototype: add interactive setup wizard for flow review --- docs/setup-wizard-prototype.md | 31 +++ package-lock.json | 65 +++++ package.json | 4 +- scripts/setup-wizard.prototype.ts | 414 ++++++++++++++++++++++++++++++ 4 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 docs/setup-wizard-prototype.md create mode 100644 scripts/setup-wizard.prototype.ts diff --git a/docs/setup-wizard-prototype.md b/docs/setup-wizard-prototype.md new file mode 100644 index 0000000..1d8fa14 --- /dev/null +++ b/docs/setup-wizard-prototype.md @@ -0,0 +1,31 @@ +# Setup wizard prototype + +This throwaway terminal prototype lets you try the setup flow before we build discovery, runtime installation, credential management, or config writes. + +```sh +npm ci +npm run setup:mock +``` + +Use the arrow keys and Enter. Ctrl+C closes the wizard. It requires an interactive terminal and the project's supported Node.js version (24.10 or newer). + +Choose a sample situation: first setup, multiple T3 instances, an existing setup, missing prerequisites, or a launch argument conflict. To skip that prototype-only menu: + +```sh +npm run setup:mock -- --scenario multiple +``` + +Other scenario names are `fresh`, `existing`, `missing`, and `conflict`. + +The flow covers T3 instance selection, a Codex provider configuration, T3-only scope, Stable/Nightly channels, advanced paths, review and editing, proposed changes, and a simulated connection check. The default path uses detected sample settings to keep the number of questions small. Paths adapt to Windows or Unix conventions. + +All data is invented and lives in memory. The script does not read your configuration, call GitHub or T3, launch processes, create credentials, install the runtime, or write files. The proposed changes describe intent; they are not a finalized implementation contract. The production CLI is unchanged, and this script is not included in its build. + +## What to decide by trying it + +- Is the default flow short enough? +- Are scope and release channels clear? +- Does the review screen make it easy to spot and correct a wrong choice? +- Do the prerequisite and launch override detours explain how to continue? + +Implementation: `scripts/setup-wizard.prototype.ts`. Keep this on the prototype branch until the interaction is approved; backend work should follow that decision. diff --git a/package-lock.json b/package-lock.json index 1b3cfb4..226a351 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "t3poll": "dist/cli.js" }, "devDependencies": { + "@clack/prompts": "1.8.0", "@types/node": "24.13.4", "prettier": "3.9.6", "tsx": "4.23.13", @@ -26,6 +27,36 @@ "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==", + "dev": true, + "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==", + "dev": true, + "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", @@ -1581,6 +1612,23 @@ "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==", + "dev": true, + "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==", + "dev": true, + "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 +1645,16 @@ ], "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==", + "dev": true, + "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 +2344,13 @@ "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==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index c7225ca..0ef81d7 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "check": "tsc --noEmit", "test": "npm run build && node --import tsx --test tests/*.test.ts", "format": "prettier --write src tests scripts README.md docs package.json tsconfig.json", - "prepack": "npm run build" + "prepack": "npm run build", + "setup:mock": "tsx scripts/setup-wizard.prototype.ts" }, "dependencies": { "@modelcontextprotocol/sdk": "1.30.0", @@ -33,6 +34,7 @@ "zod": "4.6.2" }, "devDependencies": { + "@clack/prompts": "1.8.0", "@types/node": "24.13.4", "prettier": "3.9.6", "tsx": "4.23.13", diff --git a/scripts/setup-wizard.prototype.ts b/scripts/setup-wizard.prototype.ts new file mode 100644 index 0000000..0f8e504 --- /dev/null +++ b/scripts/setup-wizard.prototype.ts @@ -0,0 +1,414 @@ +/** Throwaway interaction prototype. No discovery, persistence, or subprocesses. */ +import * as p from "@clack/prompts"; +import { setTimeout as delay } from "node:timers/promises"; + +const scenarios = [ + { + value: "fresh", + label: "First setup", + hint: "one T3 instance, ready to go", + }, + { + value: "multiple", + label: "Multiple instances", + hint: "choose T3 and a Codex configuration", + }, + { + value: "existing", + label: "Already configured", + hint: "change channel or repair setup", + }, + { + value: "missing", + label: "Missing prerequisites", + hint: "GitHub sign-in and T3 startup", + }, + { + value: "conflict", + label: "Launch argument conflict", + hint: "an environment override is present", + }, +] as const; +type Scenario = (typeof scenarios)[number]["value"]; +const args = process.argv.slice(2); +if (args.includes("--help")) { + console.log( + "Usage: npm run setup:mock -- [--scenario fresh|multiple|existing|missing|conflict]\n\nInteractive prototype using sample data. No configuration is read or written.", + ); + process.exit(0); +} +const requested = + args[0] === "--scenario" && args.length === 2 ? args[1] : undefined; +if (args.length && !scenarios.some((s) => s.value === requested)) { + console.error("Unknown arguments. Run npm run setup:mock -- --help"); + process.exit(1); +} +if (!process.stdin.isTTY || !process.stdout.isTTY) { + console.error("Open an interactive terminal and run: npm run setup:mock"); + process.exit(1); +} + +async function answer(prompt: Promise): Promise { + const value = await prompt; + if (p.isCancel(value)) { + p.cancel("Prototype closed. Nothing was changed."); + process.exit(0); + } + return value as T; +} +async function step(message: string) { + const spinner = p.spinner(); + spinner.start(message); + await delay(250); + spinner.stop(message); +} +const windows = process.platform === "win32"; +const home = windows ? "C:\\Users\\Alex" : "/home/alex"; +const path = (...parts: string[]) => + [home, ...parts].join(windows ? "\\" : "/"); +const instances = [ + { + value: "desktop", + label: "T3 Desktop", + hint: path(".t3"), + directory: path(".t3"), + }, + { + value: "dev", + label: "T3 development", + hint: path(".t3-dev"), + directory: path(".t3-dev"), + }, + { + value: "custom", + label: "Enter a T3 data directory", + hint: "another local instance", + directory: "", + }, +]; + +p.intro("t3poll / setup"); +p.note( + "This is a flow prototype with sample data.\nNo files, credentials, or running applications are read or changed.\nUse ↑ ↓ and Enter. Ctrl+C exits at any point.", + "Try the setup", +); +let scenario: Scenario = + (requested as Scenario) ?? + (await answer( + p.select({ + message: "Which situation would you like to try?", + options: [...scenarios], + }), + )); + +while (true) { + let instance = "desktop"; + let directory = path(".t3"); + let provider = "default"; + let channel = "latest"; + let codexHome = path(".codex"); + let stateHome = path(".local", "share", "t3poll"); + if (windows) stateHome = path("AppData", "Local", "t3poll"); + let codexExecutable = "codex"; + let launchMode = "Update the selected T3 instance’s saved launch arguments"; + + async function chooseInstance() { + instance = await answer( + p.select({ + message: "Which T3 instance should use t3poll?", + initialValue: instance, + options: instances, + }), + ); + directory = + instance === "custom" + ? await answer( + p.text({ + message: "T3 data directory", + placeholder: path(".t3-work"), + validate: (v) => + !v?.trim() + ? "Enter a directory to use in this mock." + : undefined, + }), + ) + : instances.find((i) => i.value === instance)!.directory; + } + async function chooseProvider() { + provider = await answer( + p.select({ + message: "Which Codex configuration in this T3 instance?", + initialValue: provider, + options: [ + { value: "default", label: "Codex — default", hint: "codex" }, + { + value: "work", + label: "Codex — work", + hint: "a separate provider configuration", + }, + ], + }), + ); + } + async function chooseChannel() { + channel = await answer( + p.select({ + message: "Which release channel?", + initialValue: channel, + options: [ + { + value: "latest", + label: "Stable", + hint: "recommended · follows latest", + }, + { + value: "nightly", + label: "Nightly", + hint: "early changes · may be less reliable", + }, + ], + }), + ); + p.log.info( + "The runtime follows your channel when it starts. Running watches are not hot-swapped.", + ); + } + async function advanced() { + codexHome = await answer( + p.text({ + message: "Codex configuration directory", + initialValue: codexHome, + validate: (v) => (!v?.trim() ? "Enter a directory." : undefined), + }), + ); + stateHome = await answer( + p.text({ + message: "t3poll state directory", + initialValue: stateHome, + validate: (v) => (!v?.trim() ? "Enter a directory." : undefined), + }), + ); + codexExecutable = await answer( + p.text({ + message: "Codex executable", + initialValue: codexExecutable, + validate: (v) => + !v?.trim() ? "Enter an executable name or path." : undefined, + }), + ); + } + + await step("Checking sample prerequisites"); + p.log.success("Node.js and GitHub CLI are available."); + if (scenario === "missing") { + p.note( + "GitHub CLI is not signed in. T3 is not running.\n\nIn a real setup:\n 1. Run gh auth login in another terminal.\n 2. Open T3 and enable its external API.\n\nThis prototype cannot perform or verify those steps.", + "Before continuing", + ); + const action = await answer( + p.select({ + message: "What would you like to do?", + options: [ + { value: "retry", label: "Simulate fixing these and check again" }, + { value: "exit", label: "Exit setup" }, + ], + }), + ); + if (action === "exit") { + p.outro("Prototype closed. Nothing was changed."); + break; + } + await step("Rechecking sample prerequisites"); + } + p.log.success( + "GitHub signed in as alex-example. T3 external API is available.", + ); + if (scenario === "multiple") { + await chooseInstance(); + await chooseProvider(); + } else p.log.info(`Found T3 Desktop · ${directory}\nCodex — default`); + p.log.info( + "T3-only scope: tools will be enabled for the selected Codex configuration in this T3 instance.\nStandalone Codex sessions keep t3poll disabled. Codex is the only supported client in this first version.", + ); + + if (scenario === "existing") { + const action = await answer( + p.select({ + message: "t3poll is already set up on Stable. What next?", + options: [ + { + value: "change", + label: "Change setup", + hint: "review settings and choose a channel", + }, + { + value: "repair", + label: "Repair setup", + hint: "reapply settings and check connection", + }, + { value: "exit", label: "Leave as is" }, + ], + }), + ); + if (action === "exit") { + p.outro("Prototype closed. Nothing was changed."); + break; + } + if (action === "change") await chooseChannel(); + } else await chooseChannel(); + + if (scenario === "conflict") { + p.note( + "T3CODE_CODEX_LAUNCH_ARGS overrides T3’s saved launch arguments.\nChanging the saved setting alone would have no effect.\nYour existing arguments would be preserved.", + "Launch override detected", + ); + const action = await answer( + p.select({ + message: "How would you like to handle the override?", + options: [ + { + value: "instructions", + label: "Show launch instructions", + hint: "update the environment where you start T3", + }, + { + value: "retry", + label: "Simulate removing the override", + hint: "then use T3’s saved settings", + }, + { value: "exit", label: "Exit setup" }, + ], + }), + ); + if (action === "exit") { + p.outro("Prototype closed. Nothing was changed."); + break; + } + if (action === "instructions") { + launchMode = "Manual launch environment update required"; + p.note( + "The finished wizard would show the merged launch arguments here,\nincluding your existing arguments and the t3poll enablement.\nYou would apply them to T3CODE_CODEX_LAUNCH_ARGS and restart T3.\n\nThis prototype has no real launch arguments to merge.", + "Manual step", + ); + } + } + + let finished = false; + while (!finished) { + p.note( + [ + `T3: ${directory}`, + `Codex: ${provider} (${codexExecutable})`, + `Scope: Selected T3 instance only`, + `Channel: ${channel === "latest" ? "Stable (latest)" : "Nightly"}`, + `Config: ${codexHome}`, + `State: ${stateHome}`, + `Launch: ${launchMode}`, + `Credentials: Managed automatically for this T3 instance`, + ].join("\n"), + "Review your setup", + ); + const action = await answer( + p.select({ + message: "Ready to try it?", + options: [ + { + value: "apply", + label: "Simulate setup", + hint: "no changes will be made", + }, + { value: "edit", label: "Edit choices" }, + { value: "preview", label: "View proposed changes" }, + { value: "exit", label: "Finish without applying" }, + ], + }), + ); + if (action === "preview") { + p.note( + [ + "Illustrative plan; runtime installation details are not implemented.", + "", + `1. Back up the affected configuration in ${codexHome}.`, + `2. Register the MCP runtime on the ${channel} channel, disabled by default.`, + `3. Bind its connection and state to ${directory}.`, + `4. ${launchMode}.`, + " Enable t3poll only in this T3 Codex configuration.", + " Preserve unrelated launch arguments and MCP entries.", + "5. Create a managed credential and check MCP connectivity.", + "", + "No GitHub token entry is needed; the runtime uses GitHub CLI authentication.", + ].join("\n"), + "What setup would do", + ); + } else if (action === "edit") { + const setting = await answer( + p.select({ + message: "What would you like to change?", + options: [ + { value: "instance", label: "T3 instance" }, + { value: "provider", label: "Codex configuration" }, + { value: "channel", label: "Release channel" }, + { + value: "advanced", + label: "Advanced paths", + hint: "Codex config, state, executable", + }, + { value: "back", label: "Back to review" }, + ], + }), + ); + if (setting === "instance") { + await chooseInstance(); + provider = "default"; + } + if (setting === "provider") await chooseProvider(); + if (setting === "channel") await chooseChannel(); + if (setting === "advanced") await advanced(); + } else if (action === "exit") { + p.log.info("Finished without applying. Nothing was changed."); + finished = true; + } else { + for (const message of [ + "Simulating configuration backup", + "Simulating MCP registration and instance binding", + "Simulating managed credential creation", + ]) + await step(message); + if (launchMode.startsWith("Manual")) { + p.log.warn( + "Simulation paused before verification: the launch environment still needs updating.\nThe real wizard would ask you to restart T3, then recheck the connection.", + ); + await answer( + p.select({ + message: "Continue the simulation?", + options: [ + { + value: "continue", + label: "Simulate updating launch arguments and restarting T3", + }, + ], + }), + ); + } else await step("Simulating T3 launch argument update"); + await step("Simulating MCP connection check"); + p.note( + "Simulation complete. Nothing was installed or changed.\n\nAfter a real setup, open a fresh Codex session in the selected T3 instance.\nTry: “Watch this PR and let me know when it needs attention.”\nThe PR and destination thread are chosen when you start a watch.", + "Ready for a first watch", + ); + finished = true; + } + } + const again = await answer( + p.confirm({ message: "Try another scenario?", initialValue: false }), + ); + if (!again) { + p.outro("Thanks for trying the setup flow. Nothing was changed."); + break; + } + scenario = await answer( + p.select({ + message: "Which situation would you like to try?", + options: [...scenarios], + }), + ); +} From 7af391ff343650af7f59537767058aba2b6ea40b Mon Sep 17 00:00:00 2001 From: eimexdev Date: Fri, 11 Sep 2026 16:36:09 -0700 Subject: [PATCH 2/6] prototype: inherit release channel and simplify setup copy --- docs/setup-wizard-prototype.md | 6 +- scripts/setup-wizard.prototype.ts | 93 ++++++++++--------------------- 2 files changed, 33 insertions(+), 66 deletions(-) diff --git a/docs/setup-wizard-prototype.md b/docs/setup-wizard-prototype.md index 1d8fa14..a179bd6 100644 --- a/docs/setup-wizard-prototype.md +++ b/docs/setup-wizard-prototype.md @@ -17,15 +17,17 @@ npm run setup:mock -- --scenario multiple Other scenario names are `fresh`, `existing`, `missing`, and `conflict`. -The flow covers T3 instance selection, a Codex provider configuration, T3-only scope, Stable/Nightly channels, advanced paths, review and editing, proposed changes, and a simulated connection check. The default path uses detected sample settings to keep the number of questions small. Paths adapt to Windows or Unix conventions. +The flow covers T3 instance selection, a Codex provider configuration, T3-only scope, the release channel inherited from the invoked package, advanced paths, review and editing, proposed changes, and a simulated connection check. The default path uses detected sample settings to keep the number of questions small. Paths adapt to Windows or Unix conventions. All data is invented and lives in memory. The script does not read your configuration, call GitHub or T3, launch processes, create credentials, install the runtime, or write files. The proposed changes describe intent; they are not a finalized implementation contract. The production CLI is unchanged, and this script is not included in its build. ## What to decide by trying it - Is the default flow short enough? -- Are scope and release channels clear? +- Is the T3-only scope clear? - Does the review screen make it easy to spot and correct a wrong choice? - Do the prerequisite and launch override detours explain how to continue? Implementation: `scripts/setup-wizard.prototype.ts`. Keep this on the prototype branch until the interaction is approved; backend work should follow that decision. + +The channel is not a setup question: `npx t3poll@nightly setup` selects Nightly, and `npx t3poll@latest setup` selects Stable. This sample represents the Stable invocation. The terminal copy uses the intended product wording; its actions remain in-memory simulations. diff --git a/scripts/setup-wizard.prototype.ts b/scripts/setup-wizard.prototype.ts index 0f8e504..b8b09b0 100644 --- a/scripts/setup-wizard.prototype.ts +++ b/scripts/setup-wizard.prototype.ts @@ -16,7 +16,7 @@ const scenarios = [ { value: "existing", label: "Already configured", - hint: "change channel or repair setup", + hint: "review or repair setup", }, { value: "missing", @@ -51,7 +51,7 @@ if (!process.stdin.isTTY || !process.stdout.isTTY) { async function answer(prompt: Promise): Promise { const value = await prompt; if (p.isCancel(value)) { - p.cancel("Prototype closed. Nothing was changed."); + p.cancel("Setup cancelled."); process.exit(0); } return value as T; @@ -88,10 +88,6 @@ const instances = [ ]; p.intro("t3poll / setup"); -p.note( - "This is a flow prototype with sample data.\nNo files, credentials, or running applications are read or changed.\nUse ↑ ↓ and Enter. Ctrl+C exits at any point.", - "Try the setup", -); let scenario: Scenario = (requested as Scenario) ?? (await answer( @@ -105,7 +101,8 @@ while (true) { let instance = "desktop"; let directory = path(".t3"); let provider = "default"; - let channel = "latest"; + // Sample invocation: npx t3poll@latest setup. The packaged CLI will use its build channel. + const channel = "latest"; let codexHome = path(".codex"); let stateHome = path(".local", "share", "t3poll"); if (windows) stateHome = path("AppData", "Local", "t3poll"); @@ -126,10 +123,7 @@ while (true) { p.text({ message: "T3 data directory", placeholder: path(".t3-work"), - validate: (v) => - !v?.trim() - ? "Enter a directory to use in this mock." - : undefined, + validate: (v) => (!v?.trim() ? "Enter a directory." : undefined), }), ) : instances.find((i) => i.value === instance)!.directory; @@ -150,29 +144,6 @@ while (true) { }), ); } - async function chooseChannel() { - channel = await answer( - p.select({ - message: "Which release channel?", - initialValue: channel, - options: [ - { - value: "latest", - label: "Stable", - hint: "recommended · follows latest", - }, - { - value: "nightly", - label: "Nightly", - hint: "early changes · may be less reliable", - }, - ], - }), - ); - p.log.info( - "The runtime follows your channel when it starts. Running watches are not hot-swapped.", - ); - } async function advanced() { codexHome = await answer( p.text({ @@ -198,27 +169,27 @@ while (true) { ); } - await step("Checking sample prerequisites"); + await step("Checking prerequisites"); p.log.success("Node.js and GitHub CLI are available."); if (scenario === "missing") { p.note( - "GitHub CLI is not signed in. T3 is not running.\n\nIn a real setup:\n 1. Run gh auth login in another terminal.\n 2. Open T3 and enable its external API.\n\nThis prototype cannot perform or verify those steps.", + "GitHub CLI is not signed in. T3 is not running.\n\n 1. Run gh auth login in another terminal.\n 2. Open T3.\n\nThen check again to continue.", "Before continuing", ); const action = await answer( p.select({ message: "What would you like to do?", options: [ - { value: "retry", label: "Simulate fixing these and check again" }, + { value: "retry", label: "Check again" }, { value: "exit", label: "Exit setup" }, ], }), ); if (action === "exit") { - p.outro("Prototype closed. Nothing was changed."); + p.outro("Setup cancelled."); break; } - await step("Rechecking sample prerequisites"); + await step("Rechecking prerequisites"); } p.log.success( "GitHub signed in as alex-example. T3 external API is available.", @@ -239,7 +210,7 @@ while (true) { { value: "change", label: "Change setup", - hint: "review settings and choose a channel", + hint: "review settings", }, { value: "repair", @@ -251,11 +222,10 @@ while (true) { }), ); if (action === "exit") { - p.outro("Prototype closed. Nothing was changed."); + p.outro("Setup cancelled."); break; } - if (action === "change") await chooseChannel(); - } else await chooseChannel(); + } if (scenario === "conflict") { p.note( @@ -273,7 +243,7 @@ while (true) { }, { value: "retry", - label: "Simulate removing the override", + label: "I removed the override — check again", hint: "then use T3’s saved settings", }, { value: "exit", label: "Exit setup" }, @@ -281,13 +251,13 @@ while (true) { }), ); if (action === "exit") { - p.outro("Prototype closed. Nothing was changed."); + p.outro("Setup cancelled."); break; } if (action === "instructions") { launchMode = "Manual launch environment update required"; p.note( - "The finished wizard would show the merged launch arguments here,\nincluding your existing arguments and the t3poll enablement.\nYou would apply them to T3CODE_CODEX_LAUNCH_ARGS and restart T3.\n\nThis prototype has no real launch arguments to merge.", + "Add -c mcp_servers.t3poll.enabled=true to your existing\nT3CODE_CODEX_LAUNCH_ARGS, then restart T3.", "Manual step", ); } @@ -300,7 +270,7 @@ while (true) { `T3: ${directory}`, `Codex: ${provider} (${codexExecutable})`, `Scope: Selected T3 instance only`, - `Channel: ${channel === "latest" ? "Stable (latest)" : "Nightly"}`, + `Channel: ${channel} (from invoked package)`, `Config: ${codexHome}`, `State: ${stateHome}`, `Launch: ${launchMode}`, @@ -310,12 +280,11 @@ while (true) { ); const action = await answer( p.select({ - message: "Ready to try it?", + message: "Ready to set up t3poll?", options: [ { value: "apply", - label: "Simulate setup", - hint: "no changes will be made", + label: "Set up t3poll", }, { value: "edit", label: "Edit choices" }, { value: "preview", label: "View proposed changes" }, @@ -326,8 +295,6 @@ while (true) { if (action === "preview") { p.note( [ - "Illustrative plan; runtime installation details are not implemented.", - "", `1. Back up the affected configuration in ${codexHome}.`, `2. Register the MCP runtime on the ${channel} channel, disabled by default.`, `3. Bind its connection and state to ${directory}.`, @@ -347,7 +314,6 @@ while (true) { options: [ { value: "instance", label: "T3 instance" }, { value: "provider", label: "Codex configuration" }, - { value: "channel", label: "Release channel" }, { value: "advanced", label: "Advanced paths", @@ -362,37 +328,36 @@ while (true) { provider = "default"; } if (setting === "provider") await chooseProvider(); - if (setting === "channel") await chooseChannel(); if (setting === "advanced") await advanced(); } else if (action === "exit") { p.log.info("Finished without applying. Nothing was changed."); finished = true; } else { for (const message of [ - "Simulating configuration backup", - "Simulating MCP registration and instance binding", - "Simulating managed credential creation", + "Backing up configuration", + "Registering MCP for this instance", + "Creating managed credential", ]) await step(message); if (launchMode.startsWith("Manual")) { p.log.warn( - "Simulation paused before verification: the launch environment still needs updating.\nThe real wizard would ask you to restart T3, then recheck the connection.", + "Update the launch environment and restart T3 before checking the connection.", ); await answer( p.select({ - message: "Continue the simulation?", + message: "Ready to check the connection?", options: [ { value: "continue", - label: "Simulate updating launch arguments and restarting T3", + label: "I updated the launch arguments and restarted T3", }, ], }), ); - } else await step("Simulating T3 launch argument update"); - await step("Simulating MCP connection check"); + } else await step("Updating T3 launch arguments"); + await step("Checking MCP connection"); p.note( - "Simulation complete. Nothing was installed or changed.\n\nAfter a real setup, open a fresh Codex session in the selected T3 instance.\nTry: “Watch this PR and let me know when it needs attention.”\nThe PR and destination thread are chosen when you start a watch.", + "Open a fresh Codex session in the selected T3 instance.\nTry: “Watch this PR and let me know when it needs attention.”\nThe PR and destination thread are chosen when you start a watch.", "Ready for a first watch", ); finished = true; @@ -402,7 +367,7 @@ while (true) { p.confirm({ message: "Try another scenario?", initialValue: false }), ); if (!again) { - p.outro("Thanks for trying the setup flow. Nothing was changed."); + p.outro("Done."); break; } scenario = await answer( From 97e009adb8ce5b7f2c99fab7af4a6280d19062d5 Mon Sep 17 00:00:00 2001 From: eimexdev Date: Fri, 11 Sep 2026 16:54:44 -0700 Subject: [PATCH 3/6] feat: install T3-scoped Codex MCP through setup wizard --- .plans/setup-and-release.md | 4 +- README.md | 12 + docs/compatibility.md | 10 + docs/installer.md | 59 ++++ docs/setup-wizard-prototype.md | 33 -- package-lock.json | 21 +- package.json | 6 +- scripts/prove-codex.mjs | 73 ++-- scripts/prove-installer.mjs | 172 ++++++++++ scripts/setup-wizard.prototype.ts | 379 --------------------- src/cli.ts | 6 + src/install-plan.ts | 440 ++++++++++++++++++++++++ src/installer.ts | 548 ++++++++++++++++++++++++++++++ src/setup.ts | 2 +- tests/fixtures/local-t3.mjs | 1 + tests/install-plan.test.ts | 266 +++++++++++++++ tests/setup.test.ts | 105 ++++++ 17 files changed, 1685 insertions(+), 452 deletions(-) create mode 100644 docs/installer.md delete mode 100644 docs/setup-wizard-prototype.md create mode 100644 scripts/prove-installer.mjs delete mode 100644 scripts/setup-wizard.prototype.ts create mode 100644 src/install-plan.ts create mode 100644 src/installer.ts create mode 100644 tests/install-plan.test.ts 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..8e0a591 --- /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. + +Review the T3 instance, Codex configuration, paths, runtime, and scope before applying. The review screen lets you change the instance, provider, or paths. 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. A legacy globally enabled `mcp_servers.t3poll` entry must be disabled before installing T3-only scope. + +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/docs/setup-wizard-prototype.md b/docs/setup-wizard-prototype.md deleted file mode 100644 index a179bd6..0000000 --- a/docs/setup-wizard-prototype.md +++ /dev/null @@ -1,33 +0,0 @@ -# Setup wizard prototype - -This throwaway terminal prototype lets you try the setup flow before we build discovery, runtime installation, credential management, or config writes. - -```sh -npm ci -npm run setup:mock -``` - -Use the arrow keys and Enter. Ctrl+C closes the wizard. It requires an interactive terminal and the project's supported Node.js version (24.10 or newer). - -Choose a sample situation: first setup, multiple T3 instances, an existing setup, missing prerequisites, or a launch argument conflict. To skip that prototype-only menu: - -```sh -npm run setup:mock -- --scenario multiple -``` - -Other scenario names are `fresh`, `existing`, `missing`, and `conflict`. - -The flow covers T3 instance selection, a Codex provider configuration, T3-only scope, the release channel inherited from the invoked package, advanced paths, review and editing, proposed changes, and a simulated connection check. The default path uses detected sample settings to keep the number of questions small. Paths adapt to Windows or Unix conventions. - -All data is invented and lives in memory. The script does not read your configuration, call GitHub or T3, launch processes, create credentials, install the runtime, or write files. The proposed changes describe intent; they are not a finalized implementation contract. The production CLI is unchanged, and this script is not included in its build. - -## What to decide by trying it - -- Is the default flow short enough? -- Is the T3-only scope clear? -- Does the review screen make it easy to spot and correct a wrong choice? -- Do the prerequisite and launch override detours explain how to continue? - -Implementation: `scripts/setup-wizard.prototype.ts`. Keep this on the prototype branch until the interaction is approved; backend work should follow that decision. - -The channel is not a setup question: `npx t3poll@nightly setup` selects Nightly, and `npx t3poll@latest setup` selects Stable. This sample represents the Stable invocation. The terminal copy uses the intended product wording; its actions remain in-memory simulations. diff --git a/package-lock.json b/package-lock.json index 226a351..e00d231 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,15 +9,16 @@ "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", "zod": "4.6.2" }, "bin": { "t3poll": "dist/cli.js" }, "devDependencies": { - "@clack/prompts": "1.8.0", "@types/node": "24.13.4", "prettier": "3.9.6", "tsx": "4.23.13", @@ -31,7 +32,6 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.0.tgz", "integrity": "sha512-zNikCcd8BbcEvzzG1sbXFrRHFk5kHPrpwZwksPvf9qyQO1Teb7JaXaOAxXZei9nZLDW0gaZawiuTCji88bTBhw==", - "dev": true, "license": "MIT", "dependencies": { "fast-wrap-ansi": "^0.2.0", @@ -45,7 +45,6 @@ "version": "1.8.0", "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.0.tgz", "integrity": "sha512-PXzLZ8N34rxmuo4dJg3xtOXhcBse94qGjDqsteoEYrFrrZ5FSjIGwMAuOcv64ln8rHVBBD06XeVGr+/JX+plcA==", - "dev": true, "license": "MIT", "dependencies": { "@clack/core": "1.5.0", @@ -1616,14 +1615,12 @@ "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==", - "dev": true, "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==", - "dev": true, "license": "MIT", "dependencies": { "fast-string-truncated-width": "^3.0.2" @@ -1649,7 +1646,6 @@ "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==", - "dev": true, "license": "MIT", "dependencies": { "fast-string-width": "^3.0.2" @@ -2348,9 +2344,20 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, "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", diff --git a/package.json b/package.json index 0ef81d7..d0c282d 100644 --- a/package.json +++ b/package.json @@ -25,16 +25,16 @@ "check": "tsc --noEmit", "test": "npm run build && node --import tsx --test tests/*.test.ts", "format": "prettier --write src tests scripts README.md docs package.json tsconfig.json", - "prepack": "npm run build", - "setup:mock": "tsx scripts/setup-wizard.prototype.ts" + "prepack": "npm run build" }, "dependencies": { + "@clack/prompts": "1.8.0", "@modelcontextprotocol/sdk": "1.30.0", "koffi": "3.2.1", + "smol-toml": "1.8.0", "zod": "4.6.2" }, "devDependencies": { - "@clack/prompts": "1.8.0", "@types/node": "24.13.4", "prettier": "3.9.6", "tsx": "4.23.13", 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/scripts/setup-wizard.prototype.ts b/scripts/setup-wizard.prototype.ts deleted file mode 100644 index b8b09b0..0000000 --- a/scripts/setup-wizard.prototype.ts +++ /dev/null @@ -1,379 +0,0 @@ -/** Throwaway interaction prototype. No discovery, persistence, or subprocesses. */ -import * as p from "@clack/prompts"; -import { setTimeout as delay } from "node:timers/promises"; - -const scenarios = [ - { - value: "fresh", - label: "First setup", - hint: "one T3 instance, ready to go", - }, - { - value: "multiple", - label: "Multiple instances", - hint: "choose T3 and a Codex configuration", - }, - { - value: "existing", - label: "Already configured", - hint: "review or repair setup", - }, - { - value: "missing", - label: "Missing prerequisites", - hint: "GitHub sign-in and T3 startup", - }, - { - value: "conflict", - label: "Launch argument conflict", - hint: "an environment override is present", - }, -] as const; -type Scenario = (typeof scenarios)[number]["value"]; -const args = process.argv.slice(2); -if (args.includes("--help")) { - console.log( - "Usage: npm run setup:mock -- [--scenario fresh|multiple|existing|missing|conflict]\n\nInteractive prototype using sample data. No configuration is read or written.", - ); - process.exit(0); -} -const requested = - args[0] === "--scenario" && args.length === 2 ? args[1] : undefined; -if (args.length && !scenarios.some((s) => s.value === requested)) { - console.error("Unknown arguments. Run npm run setup:mock -- --help"); - process.exit(1); -} -if (!process.stdin.isTTY || !process.stdout.isTTY) { - console.error("Open an interactive terminal and run: npm run setup:mock"); - process.exit(1); -} - -async function answer(prompt: Promise): Promise { - const value = await prompt; - if (p.isCancel(value)) { - p.cancel("Setup cancelled."); - process.exit(0); - } - return value as T; -} -async function step(message: string) { - const spinner = p.spinner(); - spinner.start(message); - await delay(250); - spinner.stop(message); -} -const windows = process.platform === "win32"; -const home = windows ? "C:\\Users\\Alex" : "/home/alex"; -const path = (...parts: string[]) => - [home, ...parts].join(windows ? "\\" : "/"); -const instances = [ - { - value: "desktop", - label: "T3 Desktop", - hint: path(".t3"), - directory: path(".t3"), - }, - { - value: "dev", - label: "T3 development", - hint: path(".t3-dev"), - directory: path(".t3-dev"), - }, - { - value: "custom", - label: "Enter a T3 data directory", - hint: "another local instance", - directory: "", - }, -]; - -p.intro("t3poll / setup"); -let scenario: Scenario = - (requested as Scenario) ?? - (await answer( - p.select({ - message: "Which situation would you like to try?", - options: [...scenarios], - }), - )); - -while (true) { - let instance = "desktop"; - let directory = path(".t3"); - let provider = "default"; - // Sample invocation: npx t3poll@latest setup. The packaged CLI will use its build channel. - const channel = "latest"; - let codexHome = path(".codex"); - let stateHome = path(".local", "share", "t3poll"); - if (windows) stateHome = path("AppData", "Local", "t3poll"); - let codexExecutable = "codex"; - let launchMode = "Update the selected T3 instance’s saved launch arguments"; - - async function chooseInstance() { - instance = await answer( - p.select({ - message: "Which T3 instance should use t3poll?", - initialValue: instance, - options: instances, - }), - ); - directory = - instance === "custom" - ? await answer( - p.text({ - message: "T3 data directory", - placeholder: path(".t3-work"), - validate: (v) => (!v?.trim() ? "Enter a directory." : undefined), - }), - ) - : instances.find((i) => i.value === instance)!.directory; - } - async function chooseProvider() { - provider = await answer( - p.select({ - message: "Which Codex configuration in this T3 instance?", - initialValue: provider, - options: [ - { value: "default", label: "Codex — default", hint: "codex" }, - { - value: "work", - label: "Codex — work", - hint: "a separate provider configuration", - }, - ], - }), - ); - } - async function advanced() { - codexHome = await answer( - p.text({ - message: "Codex configuration directory", - initialValue: codexHome, - validate: (v) => (!v?.trim() ? "Enter a directory." : undefined), - }), - ); - stateHome = await answer( - p.text({ - message: "t3poll state directory", - initialValue: stateHome, - validate: (v) => (!v?.trim() ? "Enter a directory." : undefined), - }), - ); - codexExecutable = await answer( - p.text({ - message: "Codex executable", - initialValue: codexExecutable, - validate: (v) => - !v?.trim() ? "Enter an executable name or path." : undefined, - }), - ); - } - - await step("Checking prerequisites"); - p.log.success("Node.js and GitHub CLI are available."); - if (scenario === "missing") { - p.note( - "GitHub CLI is not signed in. T3 is not running.\n\n 1. Run gh auth login in another terminal.\n 2. Open T3.\n\nThen check again to continue.", - "Before continuing", - ); - const action = await answer( - p.select({ - message: "What would you like to do?", - options: [ - { value: "retry", label: "Check again" }, - { value: "exit", label: "Exit setup" }, - ], - }), - ); - if (action === "exit") { - p.outro("Setup cancelled."); - break; - } - await step("Rechecking prerequisites"); - } - p.log.success( - "GitHub signed in as alex-example. T3 external API is available.", - ); - if (scenario === "multiple") { - await chooseInstance(); - await chooseProvider(); - } else p.log.info(`Found T3 Desktop · ${directory}\nCodex — default`); - p.log.info( - "T3-only scope: tools will be enabled for the selected Codex configuration in this T3 instance.\nStandalone Codex sessions keep t3poll disabled. Codex is the only supported client in this first version.", - ); - - if (scenario === "existing") { - const action = await answer( - p.select({ - message: "t3poll is already set up on Stable. What next?", - options: [ - { - value: "change", - label: "Change setup", - hint: "review settings", - }, - { - value: "repair", - label: "Repair setup", - hint: "reapply settings and check connection", - }, - { value: "exit", label: "Leave as is" }, - ], - }), - ); - if (action === "exit") { - p.outro("Setup cancelled."); - break; - } - } - - if (scenario === "conflict") { - p.note( - "T3CODE_CODEX_LAUNCH_ARGS overrides T3’s saved launch arguments.\nChanging the saved setting alone would have no effect.\nYour existing arguments would be preserved.", - "Launch override detected", - ); - const action = await answer( - p.select({ - message: "How would you like to handle the override?", - options: [ - { - value: "instructions", - label: "Show launch instructions", - hint: "update the environment where you start T3", - }, - { - value: "retry", - label: "I removed the override — check again", - hint: "then use T3’s saved settings", - }, - { value: "exit", label: "Exit setup" }, - ], - }), - ); - if (action === "exit") { - p.outro("Setup cancelled."); - break; - } - if (action === "instructions") { - launchMode = "Manual launch environment update required"; - p.note( - "Add -c mcp_servers.t3poll.enabled=true to your existing\nT3CODE_CODEX_LAUNCH_ARGS, then restart T3.", - "Manual step", - ); - } - } - - let finished = false; - while (!finished) { - p.note( - [ - `T3: ${directory}`, - `Codex: ${provider} (${codexExecutable})`, - `Scope: Selected T3 instance only`, - `Channel: ${channel} (from invoked package)`, - `Config: ${codexHome}`, - `State: ${stateHome}`, - `Launch: ${launchMode}`, - `Credentials: Managed automatically for this T3 instance`, - ].join("\n"), - "Review your setup", - ); - const action = await answer( - p.select({ - message: "Ready to set up t3poll?", - options: [ - { - value: "apply", - label: "Set up t3poll", - }, - { value: "edit", label: "Edit choices" }, - { value: "preview", label: "View proposed changes" }, - { value: "exit", label: "Finish without applying" }, - ], - }), - ); - if (action === "preview") { - p.note( - [ - `1. Back up the affected configuration in ${codexHome}.`, - `2. Register the MCP runtime on the ${channel} channel, disabled by default.`, - `3. Bind its connection and state to ${directory}.`, - `4. ${launchMode}.`, - " Enable t3poll only in this T3 Codex configuration.", - " Preserve unrelated launch arguments and MCP entries.", - "5. Create a managed credential and check MCP connectivity.", - "", - "No GitHub token entry is needed; the runtime uses GitHub CLI authentication.", - ].join("\n"), - "What setup would do", - ); - } else if (action === "edit") { - const setting = await answer( - p.select({ - message: "What would you like to change?", - options: [ - { value: "instance", label: "T3 instance" }, - { value: "provider", label: "Codex configuration" }, - { - value: "advanced", - label: "Advanced paths", - hint: "Codex config, state, executable", - }, - { value: "back", label: "Back to review" }, - ], - }), - ); - if (setting === "instance") { - await chooseInstance(); - provider = "default"; - } - if (setting === "provider") await chooseProvider(); - if (setting === "advanced") await advanced(); - } else if (action === "exit") { - p.log.info("Finished without applying. Nothing was changed."); - finished = true; - } else { - for (const message of [ - "Backing up configuration", - "Registering MCP for this instance", - "Creating managed credential", - ]) - await step(message); - if (launchMode.startsWith("Manual")) { - p.log.warn( - "Update the launch environment and restart T3 before checking the connection.", - ); - await answer( - p.select({ - message: "Ready to check the connection?", - options: [ - { - value: "continue", - label: "I updated the launch arguments and restarted T3", - }, - ], - }), - ); - } else await step("Updating T3 launch arguments"); - await step("Checking MCP connection"); - p.note( - "Open a fresh Codex session in the selected T3 instance.\nTry: “Watch this PR and let me know when it needs attention.”\nThe PR and destination thread are chosen when you start a watch.", - "Ready for a first watch", - ); - finished = true; - } - } - const again = await answer( - p.confirm({ message: "Try another scenario?", initialValue: false }), - ); - if (!again) { - p.outro("Done."); - break; - } - scenario = await answer( - p.select({ - message: "Which situation would you like to try?", - options: [...scenarios], - }), - ); -} 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/install-plan.ts b/src/install-plan.ts new file mode 100644 index 0000000..20fb205 --- /dev/null +++ b/src/install-plan.ts @@ -0,0 +1,440 @@ +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; + 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; +}): 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 source = configBefore ?? ""; + 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.`, + ); + // Reject legacy globally enabled t3poll rather than silently leave tools exposed. + for (const [name, value] of Object.entries(entries)) { + if (name === "t3poll" && object.parse(value).enabled !== false) + throw new Error( + "The existing global mcp_servers.t3poll entry is enabled. Set enabled = false in Codex config before T3-only 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, + 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..98efb9d --- /dev/null +++ b/src/installer.ts @@ -0,0 +1,548 @@ +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" }, + 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]\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--yes accepts the 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("t3poll / setup"); + 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); + let 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, + })), + }), + ); + } + let codexHome = values["codex-home"]; + let stateHome = expandPath(values["state-home"] ?? configFromEnv().home); + const launch = runtime(values["runtime-path"]); + let plan: InstallPlan; + while (true) { + const live = liveEnvironment(server); + plan = planInstall({ + baseDir: server.baseDir, + providerId, + stateHome, + version: pkg.version, + ...launch, + processEnv: live.env, + processCwd: live.cwd, + ...(codexHome ? { codexHome } : {}), + }); + const summary = [ + `T3: ${plan.baseDir}`, + `Codex: ${plan.provider.label}`, + `Config: ${plan.codexHome}`, + `State: ${plan.stateHome}`, + `Runtime: ${values["runtime-path"] ? "local build" : `t3poll@${plan.channel}`}`, + `Scope: this T3 Codex configuration only`, + `MCP entry: ${plan.server}`, + ...plan.edits.map( + (e) => `${e.before === e.after ? "Keep" : "Update"}: ${e.path}`, + ), + ].join("\n"); + if (interactive) p.note(summary, "Review your setup"); + else console.log(summary); + 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) break; + const action = await answer( + p.select({ + message: "Ready to set up t3poll?", + options: [ + { value: "apply", label: "Set up t3poll" }, + { value: "edit", label: "Edit choices" }, + { value: "preview", label: "View proposed changes" }, + { value: "cancel", label: "Finish without applying" }, + ], + }), + ); + if (action === "cancel") throw new Cancelled(); + if (action === "apply") break; + if (action === "preview") { + p.note( + `Back up changed configuration files.\nRegister ${plan.server} disabled by default.\nBind its connection to ${plan.baseDir}.\nPreserve existing launch arguments and enable only this MCP entry.\nCreate or reuse a managed credential and verify the connection.\nExisting conversations and watches keep running.`, + "Proposed changes", + ); + } else { + const choice = await answer( + p.select({ + message: "What would you like to change?", + options: [ + { value: "instance", label: "T3 instance" }, + { value: "provider", label: "Codex configuration" }, + { value: "paths", label: "Configuration and state directories" }, + { value: "back", label: "Back to review" }, + ], + }), + ); + if (choice === "instance") { + const directory = expandPath( + await answer( + p.text({ + message: "T3 data directory", + initialValue: server.baseDir, + validate: (v) => + !v?.trim() ? "Enter a directory." : undefined, + }), + ), + ); + const next = inspectLocal(directory); + if (!next) { + p.log.warn("No supported running T3 instance at that directory."); + continue; + } + const nextProviders = providers( + JSON.parse( + readOptional(join(next.baseDir, "userdata/settings.json")) ?? + "{}", + ), + ); + if (!nextProviders.length) { + p.log.warn("That instance has no enabled Codex configuration."); + continue; + } + await checkT3Support(next); + server = next; + available = nextProviders; + codexHome = undefined; + providerId = + available.length === 1 + ? available[0]!.id + : await answer( + p.select({ + message: "Which Codex configuration?", + options: available.map((p) => ({ + value: p.id, + label: p.label, + })), + }), + ); + } + if (choice === "provider") { + providerId = await answer( + p.select({ + message: "Codex configuration", + initialValue: providerId, + options: available.map((p) => ({ value: p.id, label: p.label })), + }), + ); + codexHome = undefined; + } + if (choice === "paths") { + codexHome = await answer( + p.text({ + message: "Codex configuration directory", + initialValue: plan.codexHome, + validate: (v) => (!v?.trim() ? "Enter a directory." : undefined), + }), + ); + stateHome = expandPath( + await answer( + p.text({ + message: "t3poll state directory", + initialValue: stateHome, + validate: (v) => + !v?.trim() ? "Enter a directory." : undefined, + }), + ), + ); + } + } + } + // 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, + ...(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 = + "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..72ab686 --- /dev/null +++ b/tests/install-plan.test.ts @@ -0,0 +1,266 @@ +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, global legacy registrations and unmanaged collisions are not overwritten", (t) => { + const f = fixture(t), + plan = planInstall(f.input); + for (const source of [ + "invalid = [", + '[mcp_servers.t3poll]\ncommand="old"\n', + `[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); +}); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index dd798bb..449669e 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -312,3 +312,108 @@ 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); + 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(Object.values(servers)[0]!.enabled, false); + 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, + ); +}); From ba3d42396b084c9454307e1f187067d5cf73678d Mon Sep 17 00:00:00 2001 From: eimexdev Date: Fri, 11 Sep 2026 17:01:17 -0700 Subject: [PATCH 4/6] fix: offer migration of existing global MCP registration --- docs/installer.md | 2 +- package-lock.json | 28 ++++++++ package.json | 1 + src/codex-config.ts | 140 +++++++++++++++++++++++++++++++++++++ src/install-plan.ts | 23 +++--- src/installer.ts | 47 ++++++++++++- tests/install-plan.test.ts | 64 ++++++++++++++++- tests/setup.test.ts | 15 +++- 8 files changed, 306 insertions(+), 14 deletions(-) create mode 100644 src/codex-config.ts diff --git a/docs/installer.md b/docs/installer.md index 8e0a591..42abfa1 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -34,7 +34,7 @@ Other options are `--codex-home`, `--state-home`, and `--yes`. `--yes` accepts t 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. A legacy globally enabled `mcp_servers.t3poll` entry must be disabled before installing T3-only scope. +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. diff --git a/package-lock.json b/package-lock.json index e00d231..4fbf726 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@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": { @@ -1513,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", @@ -2376,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 d0c282d..c84f950 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@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/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 index 20fb205..abe5cce 100644 --- a/src/install-plan.ts +++ b/src/install-plan.ts @@ -1,3 +1,4 @@ +import { disableGlobalEntry, expandInlineServers } from "./codex-config.js"; import { createHash, randomUUID } from "node:crypto"; import { existsSync, @@ -221,6 +222,8 @@ export type InstallPlan = { launchArgs: string; environmentBlocked: boolean; globalEnvironmentScope: boolean; + legacyGlobalEnabled: boolean; + disablesLegacy: boolean; command: string; args: string[]; baseDir: string; @@ -235,6 +238,7 @@ export function planInstall(input: { processEnv?: Record; processCwd?: string; codexHome?: string; + disableLegacy?: boolean; }): InstallPlan { const baseDir = realpathSync(input.baseDir); const settingsPath = join(baseDir, "userdata", "settings.json"); @@ -293,7 +297,15 @@ export function planInstall(input: { let configPath = join(codexHome, "config.toml"); if (existsSync(configPath)) configPath = realpathSync(configPath); const configBefore = readOptional(configPath); - const source = configBefore ?? ""; + 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`; @@ -331,13 +343,6 @@ export function planInstall(input: { throw new Error( `Codex already has an unmanaged ${server} entry. Rename or remove it before setup.`, ); - // Reject legacy globally enabled t3poll rather than silently leave tools exposed. - for (const [name, value] of Object.entries(entries)) { - if (name === "t3poll" && object.parse(value).enabled !== false) - throw new Error( - "The existing global mcp_servers.t3poll entry is enabled. Set enabled = false in Codex config before T3-only 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 = @@ -365,6 +370,8 @@ export function planInstall(input: { launchArgs, environmentBlocked, globalEnvironmentScope, + legacyGlobalEnabled, + disablesLegacy, command: input.command, args: input.args, baseDir, diff --git a/src/installer.ts b/src/installer.ts index 98efb9d..84f9486 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -212,6 +212,7 @@ export async function runSetup(args: string[]): Promise { "state-home": { type: "string" }, "runtime-path": { type: "string" }, "dry-run": { type: "boolean" }, + "keep-global": { type: "boolean" }, yes: { type: "boolean" }, help: { type: "boolean", short: "h" }, }, @@ -219,7 +220,7 @@ export async function runSetup(args: string[]): Promise { 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]\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--yes accepts the plan; ambiguous instances still require explicit selection.`, + `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; } @@ -349,6 +350,8 @@ export async function runSetup(args: string[]): Promise { let stateHome = expandPath(values["state-home"] ?? configFromEnv().home); const launch = runtime(values["runtime-path"]); let plan: InstallPlan; + let disableLegacy = !values["keep-global"]; + const globalDecisions = new Map(); while (true) { const live = liveEnvironment(server); plan = planInstall({ @@ -359,15 +362,51 @@ export async function runSetup(args: string[]): Promise { ...launch, processEnv: live.env, processCwd: live.cwd, + disableLegacy, ...(codexHome ? { codexHome } : {}), }); + if ( + interactive && + plan.legacyGlobalEnabled && + !values["keep-global"] && + !globalDecisions.has(plan.codexHome) + ) { + 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? Recommended for T3-only setup.", + initialValue: true, + }), + ); + globalDecisions.set(plan.codexHome, disableLegacy); + continue; + } + const decision = + globalDecisions.get(plan.codexHome) ?? !values["keep-global"]; + if (decision !== disableLegacy) { + disableLegacy = decision; + continue; + } const summary = [ `T3: ${plan.baseDir}`, `Codex: ${plan.provider.label}`, `Config: ${plan.codexHome}`, `State: ${plan.stateHome}`, `Runtime: ${values["runtime-path"] ? "local build" : `t3poll@${plan.channel}`}`, - `Scope: this T3 Codex configuration only`, + plan.legacyGlobalEnabled && !plan.disablesLegacy + ? "Scope: global t3poll remains available outside T3" + : "Scope: this T3 Codex configuration only", + ...(plan.legacyGlobalEnabled + ? [ + plan.disablesLegacy + ? "Disable the existing global t3poll entry" + : "Keep the existing global t3poll entry enabled", + ] + : []), `MCP entry: ${plan.server}`, ...plan.edits.map( (e) => `${e.before === e.after ? "Keep" : "Update"}: ${e.path}`, @@ -516,6 +555,7 @@ export async function runSetup(args: string[]): Promise { ...launch, processEnv: latestEnv.env, processCwd: latestEnv.cwd, + disableLegacy, ...(codexHome ? { codexHome } : {}), }); if (JSON.stringify(rechecked) !== JSON.stringify(plan)) @@ -535,6 +575,9 @@ export async function runSetup(args: string[]): Promise { } 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); diff --git a/tests/install-plan.test.ts b/tests/install-plan.test.ts index 72ab686..d0db0e5 100644 --- a/tests/install-plan.test.ts +++ b/tests/install-plan.test.ts @@ -210,12 +210,11 @@ test("stale review and concurrent rollback cannot overwrite another edit", (t) = assert.throws(() => transaction.rollback(), /changed during setup/); assert.equal(readFileSync(f.settings, "utf8"), '{"newerEdit":true}'); }); -test("invalid TOML, global legacy registrations and unmanaged collisions are not overwritten", (t) => { +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.t3poll]\ncommand="old"\n', `[mcp_servers.${plan.server}]\ncommand="mine"\n`, ]) { writeFileSync(f.config, source); @@ -264,3 +263,64 @@ test("unrelated settings inserted into a managed block cannot be deleted on repa 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 449669e..34a509c 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -349,6 +349,17 @@ test("setup CLI previews without side effects, installs a real MCP runtime, and 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); @@ -357,7 +368,9 @@ test("setup CLI previews without side effects, installs a real MCP runtime, and string, { enabled: boolean } >; - assert.equal(Object.values(servers)[0]!.enabled, false); + 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); From c9f3f8a2667ba26d3c380bc4b7029712f2f6e703 Mon Sep 17 00:00:00 2001 From: eimexdev Date: Fri, 11 Sep 2026 18:51:53 -0700 Subject: [PATCH 5/6] fix: make setup a linear installer flow --- docs/installer.md | 2 +- src/installer.ts | 242 +++++++++++++--------------------------------- 2 files changed, 70 insertions(+), 174 deletions(-) diff --git a/docs/installer.md b/docs/installer.md index 42abfa1..1874125 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -22,7 +22,7 @@ There is no release-channel question. A stable package configures `latest`; a pa 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. -Review the T3 instance, Codex configuration, paths, runtime, and scope before applying. The review screen lets you change the instance, provider, or paths. To preview from a script without changing files or credentials: +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. A brief setup summary is followed by an Install confirmation. 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 diff --git a/src/installer.ts b/src/installer.ts index 84f9486..0c86fb9 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -231,7 +231,18 @@ export async function runSetup(args: string[]): Promise { "Run setup in a terminal, or use --yes with explicit selections. Use --dry-run to preview.", ); try { - if (interactive) p.intro("t3poll / setup"); + 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"], { @@ -320,7 +331,7 @@ export async function runSetup(args: string[]): Promise { } } await checkT3Support(server); - let available = providers( + const available = providers( JSON.parse( readOptional(join(server.baseDir, "userdata/settings.json")) ?? "{}", ), @@ -346,15 +357,13 @@ export async function runSetup(args: string[]): Promise { }), ); } - let codexHome = values["codex-home"]; - let stateHome = expandPath(values["state-home"] ?? configFromEnv().home); + const codexHome = values["codex-home"]; + const stateHome = expandPath(values["state-home"] ?? configFromEnv().home); const launch = runtime(values["runtime-path"]); - let plan: InstallPlan; let disableLegacy = !values["keep-global"]; - const globalDecisions = new Map(); - while (true) { - const live = liveEnvironment(server); - plan = planInstall({ + const live = liveEnvironment(server); + const makePlan = () => + planInstall({ baseDir: server.baseDir, providerId, stateHome, @@ -365,174 +374,61 @@ export async function runSetup(args: string[]): Promise { disableLegacy, ...(codexHome ? { codexHome } : {}), }); - if ( - interactive && - plan.legacyGlobalEnabled && - !values["keep-global"] && - !globalDecisions.has(plan.codexHome) - ) { - 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? Recommended for T3-only setup.", - initialValue: true, - }), - ); - globalDecisions.set(plan.codexHome, disableLegacy); - continue; - } - const decision = - globalDecisions.get(plan.codexHome) ?? !values["keep-global"]; - if (decision !== disableLegacy) { - disableLegacy = decision; - continue; - } - const summary = [ - `T3: ${plan.baseDir}`, - `Codex: ${plan.provider.label}`, - `Config: ${plan.codexHome}`, - `State: ${plan.stateHome}`, - `Runtime: ${values["runtime-path"] ? "local build" : `t3poll@${plan.channel}`}`, - plan.legacyGlobalEnabled && !plan.disablesLegacy - ? "Scope: global t3poll remains available outside T3" - : "Scope: this T3 Codex configuration only", - ...(plan.legacyGlobalEnabled - ? [ - plan.disablesLegacy - ? "Disable the existing global t3poll entry" - : "Keep the existing global t3poll entry enabled", - ] - : []), - `MCP entry: ${plan.server}`, - ...plan.edits.map( - (e) => `${e.before === e.after ? "Keep" : "Update"}: ${e.path}`, - ), - ].join("\n"); - if (interactive) p.note(summary, "Review your setup"); - else console.log(summary); - 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) break; - const action = await answer( - p.select({ - message: "Ready to set up t3poll?", - options: [ - { value: "apply", label: "Set up t3poll" }, - { value: "edit", label: "Edit choices" }, - { value: "preview", label: "View proposed changes" }, - { value: "cancel", label: "Finish without applying" }, - ], + 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, }), ); - if (action === "cancel") throw new Cancelled(); - if (action === "apply") break; - if (action === "preview") { - p.note( - `Back up changed configuration files.\nRegister ${plan.server} disabled by default.\nBind its connection to ${plan.baseDir}.\nPreserve existing launch arguments and enable only this MCP entry.\nCreate or reuse a managed credential and verify the connection.\nExisting conversations and watches keep running.`, - "Proposed changes", - ); - } else { - const choice = await answer( - p.select({ - message: "What would you like to change?", - options: [ - { value: "instance", label: "T3 instance" }, - { value: "provider", label: "Codex configuration" }, - { value: "paths", label: "Configuration and state directories" }, - { value: "back", label: "Back to review" }, - ], - }), - ); - if (choice === "instance") { - const directory = expandPath( - await answer( - p.text({ - message: "T3 data directory", - initialValue: server.baseDir, - validate: (v) => - !v?.trim() ? "Enter a directory." : undefined, - }), - ), - ); - const next = inspectLocal(directory); - if (!next) { - p.log.warn("No supported running T3 instance at that directory."); - continue; - } - const nextProviders = providers( - JSON.parse( - readOptional(join(next.baseDir, "userdata/settings.json")) ?? - "{}", - ), - ); - if (!nextProviders.length) { - p.log.warn("That instance has no enabled Codex configuration."); - continue; - } - await checkT3Support(next); - server = next; - available = nextProviders; - codexHome = undefined; - providerId = - available.length === 1 - ? available[0]!.id - : await answer( - p.select({ - message: "Which Codex configuration?", - options: available.map((p) => ({ - value: p.id, - label: p.label, - })), - }), - ); - } - if (choice === "provider") { - providerId = await answer( - p.select({ - message: "Codex configuration", - initialValue: providerId, - options: available.map((p) => ({ value: p.id, label: p.label })), - }), - ); - codexHome = undefined; - } - if (choice === "paths") { - codexHome = await answer( - p.text({ - message: "Codex configuration directory", - initialValue: plan.codexHome, - validate: (v) => (!v?.trim() ? "Enter a directory." : undefined), - }), - ); - stateHome = expandPath( - await answer( - p.text({ - message: "t3poll state directory", - initialValue: stateHome, - validate: (v) => - !v?.trim() ? "Enter a directory." : undefined, - }), - ), - ); - } + plan = makePlan(); + } + const summary = [ + `T3: ${plan.baseDir}`, + `Codex: ${plan.provider.label}`, + `Runtime: ${values["runtime-path"] ? "local build" : `t3poll@${plan.channel}`}`, + plan.legacyGlobalEnabled && !plan.disablesLegacy + ? "Scope: global t3poll remains available outside T3" + : "Scope: this T3 Codex configuration only", + ...(plan.legacyGlobalEnabled + ? [ + plan.disablesLegacy + ? "Disable the existing global t3poll entry" + : "Keep the existing global t3poll entry enabled", + ] + : []), + ...plan.edits.map( + (e) => `${e.before === e.after ? "Keep" : "Update"}: ${e.path}`, + ), + ].join("\n"); + if (interactive) p.note(summary, "Setup summary"); + else console.log(summary); + 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"); From dc1e742aa33c35835a266b166da001bb033e2acb Mon Sep 17 00:00:00 2001 From: eimexdev Date: Fri, 11 Sep 2026 18:55:19 -0700 Subject: [PATCH 6/6] fix: explain setup actions and show the T3 instance URL --- docs/installer.md | 2 +- src/installer.ts | 50 ++++++++++++++++++++++++++++++++++------------- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/docs/installer.md b/docs/installer.md index 1874125..821044e 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -22,7 +22,7 @@ There is no release-channel question. A stable package configures `latest`; a pa 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. A brief setup summary is followed by an Install confirmation. Custom paths can be supplied through command-line options. To preview from a script without changing files or credentials: +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 diff --git a/src/installer.ts b/src/installer.ts index 0c86fb9..5735641 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -388,26 +388,48 @@ export async function runSetup(args: string[]): Promise { ); plan = makePlan(); } - const summary = [ - `T3: ${plan.baseDir}`, - `Codex: ${plan.provider.label}`, - `Runtime: ${values["runtime-path"] ? "local build" : `t3poll@${plan.channel}`}`, - plan.legacyGlobalEnabled && !plan.disablesLegacy - ? "Scope: global t3poll remains available outside T3" - : "Scope: this T3 Codex configuration only", + 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", + ? "Disable the existing global t3poll entry." + : "Keep the existing global t3poll entry enabled.", ] : []), - ...plan.edits.map( - (e) => `${e.before === e.after ? "Keep" : "Update"}: ${e.path}`, - ), + 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(summary, "Setup summary"); - else console.log(summary); + 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."