From c003de8f1d984515fa85b53ed72e30a4cd6deec2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 8 Nov 2026 16:28:39 -0800 Subject: [PATCH 1/6] feat(cli): npx t3 offers to install the t3 command `npx t3 connect` and friends now ask once, at an interactive terminal, whether to install the `t3` command. Enter installs the version npx already downloaded, then the command runs. No, no answer within 30 seconds, or a failed install waits until the next minor release. It never asks in scripts, CI, sudo, background jobs, for help or version, or when any `t3` is already installed. `npx t3` still runs the version npm picked. - New `t3 install` links the running version into ~/.local/bin (t3.cmd on Windows), never over a `t3` it did not create, and prints a PATH command when the folder is off PATH. - `t3 uninstall` also removes the links `t3 install` and the install scripts recorded. - install.sh and install.ps1 replace only their own link or the desktop app's, print a PATH command, and say when another `t3` runs first. - Setup screens and docs show the installers, then `t3 `. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- README.md | 2 +- apps/server/src/binCli.ts | 24 +- apps/server/src/cli/install.test.ts | 284 ++++++++++++++ apps/server/src/cli/install.ts | 364 ++++++++++++++++++ apps/server/src/cli/invocation.ts | 2 +- apps/server/src/cli/uninstall.test.ts | 54 ++- apps/server/src/cli/uninstall.ts | 96 ++++- apps/server/src/cloud/pinnedRuntime.test.ts | 12 + apps/server/src/cloud/pinnedRuntime.ts | 15 + apps/web/src/components/HostedEmptyState.tsx | 8 +- apps/web/src/components/InstallT3Commands.tsx | 31 ++ .../components/onboarding/WelcomeWizard.tsx | 10 +- docs/operations/observability.md | 6 +- docs/user/faq.md | 4 +- docs/user/install.md | 15 +- docs/user/remote-access.md | 9 +- docs/user/welcome-wizard.md | 4 +- packages/shared/src/shell.test.ts | 56 +++ packages/shared/src/shell.ts | 62 +++ scripts/install.ps1 | 40 +- scripts/install.sh | 98 ++++- scripts/install.test.ts | 202 ++++++++++ 23 files changed, 1347 insertions(+), 55 deletions(-) create mode 100644 apps/server/src/cli/install.test.ts create mode 100644 apps/server/src/cli/install.ts create mode 100644 apps/web/src/components/InstallT3Commands.tsx diff --git a/AGENTS.md b/AGENTS.md index b919730ebe74..2effa629d25e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,13 +18,13 @@ Lots of apps have gotten bogged down with bad tech decisions and "slop". We have ### 3. Remote ready -The architecture of T3 Code's websocket layer (npx t3) enables a lot of awesome remote features. These have become core to the product. Whether users are connecting directly over their local network, using Tailscale, or leaning in fully with T3 Connect (our tunnel solution, also in this repo), we need to make sure new features are properly supported. +The architecture of T3 Code's websocket layer (`t3`) enables a lot of awesome remote features. These have become core to the product. Whether users are connecting directly over their local network, using Tailscale, or leaning in fully with T3 Connect (our tunnel solution, also in this repo), we need to make sure new features are properly supported. ### 4. Multi-surface T3 Code has 3 key app surfaces: **web**, **desktop**, and **mobile**. -**Web** is kind of two surfaces, as we have the public facing "app.t3.codes" as well as locally hosting the web app through the `npx t3` command. Both need to be supported by all new features where reasonable. +**Web** is kind of two surfaces, as we have the public facing "app.t3.codes" as well as locally hosting the web app through the `t3` command. Both need to be supported by all new features where reasonable. **Desktop** is the main surface most users install first. It's a full Electron app that bundles the server runner as well. The desktop app can also be used as the host server, allowing remote connections from app.t3.codes or the mobile app. diff --git a/README.md b/README.md index 1f63f3609ae0..4abbd5bd932c 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ irm https://t3.codes/install.ps1 | iex Then run `t3` to start the server and open the local web app. `t3 service install` keeps it running in the background, `t3 update` moves to a newer release, and `t3 --help` has the full reference. -To try it once without installing, run `npx t3@latest` instead. +To try it once without installing, run `npx t3@latest`. It offers to install the `t3` command for you. ### Desktop app diff --git a/apps/server/src/binCli.ts b/apps/server/src/binCli.ts index ee3ef9402f86..995b207e7c78 100644 --- a/apps/server/src/binCli.ts +++ b/apps/server/src/binCli.ts @@ -23,6 +23,12 @@ import { projectCommand } from "./cli/project.ts"; import { threadCommand } from "./cli/thread.ts"; import { runDefaultServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { updateCommand } from "./cli/update.ts"; +import { + confirmCliInstall, + inTerminalForeground, + installCommand, + offerCliInstall, +} from "./cli/install.ts"; import { uninstallCommand } from "./cli/uninstall.ts"; import { runServiceLauncherCli } from "./cli/serviceLauncher.ts"; import { speechWorkerCommand } from "./cli/speechWorker.ts"; @@ -89,6 +95,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => statusCommand, serviceCommand, updateCommand, + installCommand, uninstallCommand, // bin.ts runs the bare service invocation; this keeps flags and help working like other commands. Command.make("__service-launcher").pipe( @@ -115,7 +122,22 @@ export const cli = makeCli(); export function runCli() { extendConnectionAttemptTimeout(); keepOutboundFetchOnHttp1(); - Command.run(cli, { version: packageJson.version }).pipe( + offerCliInstall({ + args: process.argv.slice(2), + // A child started over IPC (a service launcher) has no one to answer. + interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY) && process.send === undefined, + foreground: inTerminalForeground, + confirm: confirmCliInstall, + }).pipe( + // The offer handles its own errors; even an unexpected defect must not stop the command. + Effect.catchDefect(() => Effect.succeed(true)), + Effect.flatMap((proceed) => + proceed + ? Command.run(cli, { version: packageJson.version }) + : Effect.sync(() => { + process.exitCode = 130; + }), + ), Effect.scoped, Effect.provide(layerCliRuntime), NodeRuntime.runMain, diff --git a/apps/server/src/cli/install.test.ts b/apps/server/src/cli/install.test.ts new file mode 100644 index 000000000000..3c30ed49ef73 --- /dev/null +++ b/apps/server/src/cli/install.test.ts @@ -0,0 +1,284 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as TestClock from "effect/testing/TestClock"; + +import packageJson from "../../package.json" with { type: "json" }; +import { installCli, isForegroundProcStat, isForegroundPs, offerCliInstall } from "./install.ts"; + +const VERSION = packageJson.version; + +/** A standalone runtime in a temp T3 home, started by the npm launcher from an npx cache. */ +const setup = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-install-" }); + const runtimeDir = path.join(root, ".t3/runtime"); + const executable = path.join(runtimeDir, "versions", VERSION, "t3"); + yield* fs.makeDirectory(path.dirname(executable), { recursive: true }); + yield* fs.writeFileString(executable, ""); + const npmPackage = path.join(root, ".npm/_npx/abc/node_modules/t3"); + yield* fs.makeDirectory(path.join(npmPackage, "bin"), { recursive: true }); + yield* fs.writeFileString( + path.join(npmPackage, "package.json"), + JSON.stringify({ name: "t3", version: VERSION }), + ); + const binDir = path.join(root, "bin"); + const provide = (effect: Effect.Effect, env: NodeJS.ProcessEnv = {}) => + effect.pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.HomeDirectory, root), + Effect.provideService(HostProcess.IsExecutable, true), + Effect.provideService(HostProcess.ExecutablePath, executable), + Effect.provideService(HostProcess.Environment, { + PATH: "/usr/bin", + SHELL: "/bin/zsh", + T3CODE_INSTALL_BIN_DIR: binDir, + T3CODE_NPM_LAUNCHER_PATH: path.join(npmPackage, "bin/t3.mjs"), + ...env, + }), + ); + return { root, npmPackage, runtimeDir, executable, link: path.join(binDir, "t3"), provide }; +}); + +it.layer(NodeServices.layer)("t3 install", (it) => { + it.effect("links the running runtime, records it, and never replaces another t3", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { root, runtimeDir, executable, link, provide } = yield* setup; + + const older = path.join(runtimeDir, "versions/0.0.1/t3"); + yield* fs.makeDirectory(path.dirname(link), { recursive: true }); + yield* fs.symlink(older, link); + const installed = yield* provide(installCli()); + assert.equal(yield* fs.readLink(link), executable); + assert.isFalse(installed.onPath); + assert.include(installed.pathCommand, "$HOME/bin"); + + // A link written through a symlinked T3 home is still ours. + const alias = path.join(root, "alias"); + yield* fs.symlink(path.join(root, ".t3"), alias); + yield* fs.remove(link); + yield* fs.symlink(path.join(alias, "runtime/versions", VERSION, "t3"), link); + const onPath = yield* provide(installCli(), { PATH: `/usr/bin:${path.join(root, "bin")}/` }); + assert.isTrue(onPath.onPath); + // Another t3 earlier on PATH still runs first, so `t3` would not run this one. + const otherBin = path.join(root, "other-bin"); + yield* fs.makeDirectory(otherBin); + yield* fs.writeFileString(path.join(otherBin, "t3"), ""); + const shadowed = yield* provide(installCli(), { + PATH: `${otherBin}:${path.join(root, "bin")}`, + }); + assert.isFalse(shadowed.onPath); + assert.equal(shadowed.shadowedBy, path.join(otherBin, "t3")); + assert.isUndefined(shadowed.pathCommand); + const recorded = yield* fs.readFileString(path.join(runtimeDir, "launchers")); + assert.equal(recorded, `\n${yield* fs.realPath(path.join(root, "bin"))}/t3\n`); + + yield* fs.remove(link); + yield* fs.writeFileString(link, "#!/bin/sh\n"); + const error = yield* provide(installCli()).pipe(Effect.flip); + assert.include(error.message, "Another t3 is already at"); + assert.equal(yield* fs.readFileString(link), "#!/bin/sh\n"); + + // Only `/t3` is ours, not another program inside a version. + yield* fs.remove(link); + yield* fs.symlink(path.join(runtimeDir, "versions/0.0.1/node"), link); + const sibling = yield* provide(installCli()).pipe(Effect.flip); + assert.include(sibling.message, "Another t3 is already at"); + // This home's desktop launcher is replaced, as install.sh does. + yield* fs.remove(link); + yield* fs.symlink(path.join(root, ".t3/bin/t3"), link); + yield* provide(installCli()); + assert.equal(yield* fs.readLink(link), executable); + + // A failed swap over our own link leaves a staging path it did not create alone. + yield* fs.remove(link); + yield* fs.symlink(older, link); + const staging = `${link}.${process.pid}.tmp`; + yield* fs.writeFileString(staging, "someone else's\n"); + const linkFailed = yield* provide(installCli()).pipe(Effect.flip); + assert.include(linkFailed.message, "Could not link"); + assert.equal(yield* fs.readFileString(staging), "someone else's\n"); + yield* fs.remove(staging); + + // An empty spot is created exclusively: a file that appears first is kept. + yield* fs.remove(link); + const racing = installCli().pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + symlink: (target, path) => + fs + .writeFileString(path, "#!/bin/sh\necho raced\n") + .pipe(Effect.andThen(fs.symlink(target, path))), + }), + ); + assert.include((yield* provide(racing).pipe(Effect.flip)).message, "Could not link"); + assert.equal(yield* fs.readFileString(link), "#!/bin/sh\necho raced\n"); + yield* fs.remove(link); + + // Windows writes a t3.cmd: only over its own, and only for paths cmd can run. + const shim = path.join(root, "bin", "t3.cmd"); + const windows = (effect: Effect.Effect) => + provide(effect.pipe(Effect.provideService(HostProcess.Platform, "win32"))); + const desktopShim = + '@echo off\r\nrem Written by T3 Code\r\n"C:\\T3 Code\\T3 Code.exe" "x" %*\r\n'; + yield* fs.writeFileString(shim, desktopShim); + const foreignShim = yield* windows(installCli()).pipe(Effect.flip); + assert.include(foreignShim.message, "Another t3 is already at"); + assert.equal(yield* fs.readFileString(shim), desktopShim); + // A user's wrapper that also runs a managed t3.exe is still theirs. + const wrapper = `@echo off\r\nset FOO=1\r\n"${path.join(runtimeDir, "versions/0.0.1/t3.exe")}" %*`; + yield* fs.writeFileString(shim, wrapper); + yield* windows(installCli()).pipe(Effect.flip); + assert.equal(yield* fs.readFileString(shim), wrapper); + // A dangling symlink is not free; writing through it would land outside the bin folder. + const elsewhere = path.join(root, "elsewhere.cmd"); + yield* fs.remove(shim); + yield* fs.symlink(elsewhere, shim); + yield* windows(installCli()).pipe(Effect.flip); + assert.isFalse(yield* fs.exists(elsewhere)); + yield* fs.remove(shim); + yield* fs.writeFileString( + shim, + `@echo off\r\n"${path.join(runtimeDir, "versions/0.0.1/t3.exe")}" %*`, + ); + yield* windows(installCli()); + assert.equal(yield* fs.readFileString(shim), `@echo off\r\n"${executable}" %*`); + const unrunnable = yield* windows( + installCli().pipe( + Effect.provideService( + HostProcess.ExecutablePath, + path.join(runtimeDir, "versions/100%/t3"), + ), + ), + ).pipe(Effect.flip); + assert.include(unrunnable.message, "has % or non-ASCII characters"); + }).pipe(Effect.scoped), + ); + + it.effect("asks npx users without a t3 command once per minor release and installs on yes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { root, npmPackage, runtimeDir, executable, link, provide } = yield* setup; + let asked = 0; + const offer = ( + answer: boolean | undefined | Effect.Effect, + options: { + readonly args?: ReadonlyArray; + readonly env?: NodeJS.ProcessEnv; + readonly interactive?: boolean; + readonly foreground?: boolean; + } = {}, + ) => + provide( + offerCliInstall({ + args: options.args ?? ["connect"], + interactive: options.interactive ?? true, + foreground: Effect.succeed(options.foreground ?? true), + confirm: Effect.andThen( + Effect.sync(() => (asked += 1)), + Effect.isEffect(answer) ? answer : Effect.succeed(answer), + ), + }), + options.env, + ); + + // Scripts, CI, sudo, dumb terminals, background jobs, and help never ask. + yield* offer(false, { interactive: false }); + yield* offer(false, { env: { CI: "true" } }); + yield* offer(false, { env: { SUDO_USER: "theo" } }); + yield* offer(false, { env: { TERM: "dumb" } }); + yield* offer(false, { foreground: false }); + yield* offer(false, { args: ["connect", "--help"] }); + yield* offer(false, { args: ["--log-level", "debug", "install"] }); + yield* offer(false, { args: ["status", "--json"] }); + yield* offer(false, { args: ["up", "--yes"] }); + yield* offer(false, { args: ["status", "--json=true"] }); + const globalBin = path.join(root, "global-bin"); + yield* fs.makeDirectory(globalBin); + yield* fs.writeFileString(path.join(globalBin, "t3"), ""); + yield* offer(false, { env: { PATH: globalBin } }); + assert.equal(asked, 0); + assert.isFalse(yield* fs.exists(link)); + + // A runner's own .bin on PATH is not an installed t3. + const npxBin = path.join(npmPackage, "../.bin"); + const pnpmBin = path.join(root, ".cache/pnpm/dlx/abc/node_modules/.bin"); + for (const bin of [npxBin, pnpmBin]) { + yield* fs.makeDirectory(bin, { recursive: true }); + yield* fs.writeFileString(path.join(bin, "t3"), ""); + } + assert.isFalse(yield* offer(undefined, { env: { PATH: `${npxBin}:${pnpmBin}` } })); + assert.equal(asked, 1); + assert.isFalse(yield* fs.exists(path.join(runtimeDir, "install-prompt-declined"))); + asked = 0; + + // `service install` is not `t3 install`; it still leaves the command unlinked. + yield* offer(false, { args: ["service", "install"] }); + assert.equal(asked, 1); + yield* offer(false); + assert.equal(asked, 1); + assert.isFalse(yield* fs.exists(link)); + + yield* fs.writeFileString(path.join(runtimeDir, "install-prompt-declined"), "0.0\n"); + yield* offer(true); + assert.equal(asked, 2); + assert.equal(yield* fs.readLink(link), executable); + + yield* fs.remove(path.join(runtimeDir, "install-prompt-declined")); + yield* offer(true); + assert.equal(asked, 2); + + // A yes that cannot install, and no answer at all, also wait for the next minor release. + const declined = path.join(runtimeDir, "install-prompt-declined"); + yield* fs.remove(link); + yield* fs.remove(path.join(runtimeDir, "launchers")); + yield* fs.writeFileString(link, "#!/bin/sh\necho mine\n"); + asked = 0; + assert.isTrue(yield* offer(true)); + assert.equal(yield* fs.readFileString(link), "#!/bin/sh\necho mine\n"); + assert.equal( + (yield* fs.readFileString(declined)).trim(), + VERSION.split(".").slice(0, 2).join("."), + ); + yield* offer(true); + assert.equal(asked, 1); + + yield* fs.remove(declined); + yield* fs.remove(link); + const waiting = yield* Deferred.make(); + const unanswered = yield* offer( + Effect.andThen(Deferred.succeed(waiting, undefined), Effect.never), + ).pipe(Effect.forkChild); + yield* Deferred.await(waiting); + yield* TestClock.adjust("30 seconds"); + assert.isTrue(yield* Fiber.join(unanswered)); + assert.isTrue(yield* fs.exists(declined)); + assert.isFalse(yield* fs.exists(link)); + }).pipe(Effect.scoped), + ); + + it.effect("reads the terminal's foreground group from /proc and ps", () => + Effect.sync(() => { + // The command name can hold spaces and parentheses. + const stat = (pgrp: number, tpgid: number) => + `4242 (t3 (serve) x) S 100 ${pgrp} 100 34816 ${tpgid} 4194560 0 0`; + assert.isTrue(isForegroundProcStat(stat(4242, 4242))); + assert.isFalse(isForegroundProcStat(stat(4242, 4300))); + assert.isFalse(isForegroundProcStat(stat(4242, -1))); + assert.isFalse(isForegroundProcStat("")); + assert.isTrue(isForegroundPs(" 4242 4242\n")); + assert.isFalse(isForegroundPs(" 4300 4242\n")); + assert.isFalse(isForegroundPs(" -1 4242\n")); + assert.isFalse(isForegroundPs("")); + }), + ); +}); diff --git a/apps/server/src/cli/install.ts b/apps/server/src/cli/install.ts new file mode 100644 index 000000000000..db6092fabc5f --- /dev/null +++ b/apps/server/src/cli/install.ts @@ -0,0 +1,364 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { pathSetupCommand } from "@t3tools/shared/shell"; +import * as Console from "effect/Console"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { Command, Prompt } from "effect/cli"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import packageJson from "../../package.json" with { type: "json" }; +import { + generatedWindowsShimTarget, + isVersionExecutable, + recordPinnedRuntimeLauncher, +} from "../cloud/pinnedRuntime.ts"; +import { detectCliRunner, resolveCliCommand, resolveEntryPath } from "./invocation.ts"; + +export class CliInstallError extends Schema.TaggedError()("CliInstallError", { + reason: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + return this.reason; + } +} + +/** `/runtime/versions//t3`: the layout the install scripts and npm launcher share. */ +const standaloneRuntime = Effect.gen(function* () { + const path = yield* Path.Path; + if (!(yield* HostProcess.IsExecutable)) return Option.none(); + const executable = yield* HostProcess.ExecutablePath; + const versionsDir = path.dirname(path.dirname(executable)); + const runtimeDir = path.dirname(versionsDir); + if (path.basename(versionsDir) !== "versions" || path.basename(runtimeDir) !== "runtime") { + return Option.none(); + } + return Option.some({ executable, versionsDir, runtimeDir }); +}); + +/** Links this runtime as the `t3` command the way the install scripts do, replacing only our own link. */ +export const installCli = Effect.fn("cli.install.install")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcess.Platform; + const environment = yield* HostProcess.Environment; + const home = yield* HostProcess.HomeDirectory; + const runtime = yield* standaloneRuntime; + const windows = platform === "win32"; + if (Option.isNone(runtime)) { + const script = windows + ? "irm https://t3.codes/install.ps1 | iex" + : "curl -fsSL https://t3.codes/install.sh | sh"; + return yield* new CliInstallError({ + reason: `Only the standalone t3 can install itself. Use \`${script}\`, or Settings in the desktop app.`, + }); + } + const { executable, versionsDir, runtimeDir } = runtime.value; + // cmd.exe expands `%` and reads the shim in the OEM code page, so such a path would not run. + if (windows && /[%\u0080-\uffff]/.test(executable)) { + return yield* new CliInstallError({ + reason: `A t3.cmd cannot run ${executable}, because the path has % or non-ASCII characters. Keep using npx t3.`, + }); + } + // Absolute, so the PATH hint never adds a folder that changes with the working directory. + const binDir = path.resolve( + environment.T3CODE_INSTALL_BIN_DIR?.trim() || path.join(home, ".local", "bin"), + ); + const name = windows ? "t3.cmd" : "t3"; + const link = path.join(binDir, name); + const failed = (reason: string) => (cause: unknown) => new CliInstallError({ reason, cause }); + + // Resolve fully so a link through a symlinked T3 home still counts as ours; a dangling one + // (a pruned version) falls back to its own text. + const realVersionsDir = yield* fs + .realPath(versionsDir) + .pipe(Effect.orElseSucceed(() => versionsDir)); + const current = windows + ? yield* fs.readFileString(link).pipe(Effect.map(generatedWindowsShimTarget), Effect.option) + : yield* fs.readLink(link).pipe( + Effect.flatMap((target) => + fs.realPath(link).pipe(Effect.orElseSucceed(() => path.resolve(binDir, target))), + ), + Effect.option, + ); + // A dangling symlink is occupied too: writing the shim would follow it. + const occupied = + Option.isSome(current) || + (yield* fs.exists(link)) || + Option.isSome(yield* fs.readLink(link).pipe(Effect.option)); + if ( + occupied && + !( + Option.isSome(current) && + current.value !== undefined && + (isVersionExecutable(path, versionsDir, current.value) || + isVersionExecutable(path, realVersionsDir, current.value) || + // This home's desktop launcher, which install.sh also replaces. + current.value === path.join(path.dirname(runtimeDir), "bin", name)) + ) + ) { + return yield* new CliInstallError({ + reason: `Another t3 is already at ${link}. Remove it, then run this again.`, + }); + } + + yield* fs + .makeDirectory(binDir, { recursive: true }) + .pipe(Effect.mapError(failed(`Could not create ${binDir}.`))); + // An empty spot is created exclusively, so a file that appears after the check above is kept. + if (windows) { + yield* fs + .writeFileString(link, `@echo off\r\n"${executable}" %*`, occupied ? {} : { flag: "wx" }) + .pipe(Effect.mapError(failed(`Could not write ${link}.`))); + } else if (!occupied) { + yield* fs.symlink(executable, link).pipe(Effect.mapError(failed(`Could not link ${link}.`))); + } else { + // Swap a temporary link into place so a running `t3` never sees the command missing. + const staging = `${link}.${process.pid}.tmp`; + // Clean up only a staging link this call made, never a file that was already there. + yield* fs + .symlink(executable, staging) + .pipe( + Effect.andThen( + fs + .rename(staging, link) + .pipe(Effect.tapError(() => fs.remove(staging, { force: true }).pipe(Effect.ignore))), + ), + Effect.mapError(failed(`Could not link ${link}.`)), + ); + } + + // Pruning keeps what recorded launchers point at; a background service cannot see binDir otherwise. + const recorded = path.join( + yield* fs.realPath(binDir).pipe(Effect.orElseSucceed(() => binDir)), + name, + ); + yield* recordPinnedRuntimeLauncher({ + baseDir: path.dirname(runtimeDir), + launcher: recorded, + }).pipe( + Effect.catch(() => + Console.error( + `Warning: could not record ${recorded} in ${path.join(runtimeDir, "launchers")}. Cleanup may remove the version it runs.`, + ), + ), + ); + + // resolve() drops trailing separators, so `~/.local/bin/` on PATH still matches. + const normalize = (value: string) => + windows ? path.resolve(value).toLowerCase() : path.resolve(value); + const same = (entry: string) => entry !== "" && normalize(entry) === normalize(binDir); + const entries = (environment.PATH ?? environment.Path ?? "").split(windows ? ";" : ":"); + const onPath = entries.some(same); + // A t3 earlier on PATH runs instead, and an added folder goes last. Runner caches (npx's own + // .bin) are not installs. + const firstOther = Effect.gen(function* () { + for (const entry of entries) { + if (same(entry)) return undefined; + if (!path.isAbsolute(entry) || detectCliRunner(`${entry}/`) !== null) continue; + for (const name of windows ? ["t3.cmd", "t3.exe"] : ["t3"]) { + const candidate = path.join(entry, name); + if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) return candidate; + } + } + return undefined; + }); + const shadowedBy = yield* firstOther; + return { + link, + onPath: onPath && shadowedBy === undefined, + shadowedBy, + pathCommand: + onPath || shadowedBy !== undefined + ? undefined + : pathSetupCommand({ + directory: binDir, + home, + shell: environment.SHELL, + platform, + configHome: environment.XDG_CONFIG_HOME, + zdotdir: environment.ZDOTDIR, + }), + binDir, + }; +}); + +const reportInstalled = Effect.fn("cli.install.report")(function* ( + installed: Effect.Success>, +) { + yield* Console.log(`Installed t3 ${packageJson.version} at ${installed.link}.`); + if (installed.shadowedBy !== undefined) { + return yield* Console.log( + `Another t3 at ${installed.shadowedBy} runs first, so run ${installed.link} to use this one.`, + ); + } + if (installed.onPath) return yield* Console.log("Run `t3` to get started."); + if (installed.pathCommand === undefined) { + return yield* Console.log(`Add ${installed.binDir} to your PATH, then run \`t3\`.`); + } + yield* Console.log(`${installed.binDir} is not on your PATH yet. Add it with:`); + yield* Console.log(` ${installed.pathCommand}`); + yield* Console.log("Then open a new terminal and run `t3`."); +}); + +export const installCommand = Command.make("install").pipe( + Command.withDescription("Install this version as the `t3` command."), + Command.withHandler(() => installCli().pipe(Effect.flatMap(reportInstalled))), +); + +/** Saying no lasts until the next minor release: `0.2` covers `0.2.5` and `0.2.6-nightly.…`. */ +const minorRelease = (version: string) => version.split(".").slice(0, 2).join("."); + +const SKIPPED_ARGUMENTS = new Set([ + "help", + "--help", + "-h", + "--version", + "-v", + // Scripts and machine-readable output; a prompt would stall them or corrupt their output. + "--json", + "--yes", + "-y", +]); + +/** The subcommand, past global flags that may come first (two take a value). */ +const topLevelCommand = (args: ReadonlyArray) => { + for (let index = 0; index < args.length; index++) { + const arg = args[index]!; + if (arg === "--log-level" || arg === "--completions") index++; + else if (!arg.startsWith("-")) return arg; + } + return undefined; +}; + +/** Agents can run commands in a terminal that nobody answers; no answer counts as no. */ +const PROMPT_TIMEOUT = Duration.seconds(30); + +/** `/proc//stat` fields after the parenthesized command name: state ppid pgrp session tty_nr tpgid. */ +export const isForegroundProcStat = (stat: string) => { + const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" "); + return fields[2] !== undefined && fields[2] !== "" && fields[2] === fields[5]; +}; + +/** Output of `ps -o tpgid=,pgid=`; tpgid is -1 without a controlling terminal. */ +export const isForegroundPs = (output: string) => { + const [tpgid, pgid] = output.trim().split(/\s+/); + return tpgid !== undefined && tpgid !== "" && tpgid === pgid; +}; + +/** + * A background job (`npx t3 serve &`) that reads the terminal is stopped by the shell, so only + * the terminal's foreground process group may prompt. Unknown counts as background. + */ +export const inTerminalForeground = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcess.Platform; + if (platform === "win32") return true; + if (platform === "linux") { + return isForegroundProcStat( + yield* fs.readFileString("/proc/self/stat").pipe(Effect.orElseSucceed(() => "")), + ); + } + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return isForegroundPs( + yield* spawner + .string( + // Absolute: under npx, PATH starts with a project's node_modules/.bin. + ChildProcess.make("/bin/ps", ["-o", "tpgid=,pgid=", "-p", String(process.pid)], { + stdin: "ignore", + stderr: "ignore", + }), + ) + .pipe(Effect.orElseSucceed(() => "")), + ); +}); + +/** + * Before a command run through npx, pnpm dlx, or bunx at a terminal, asks to install the `t3` + * command. Yes is the default. No, no answer, or a failed install is saved in the T3 home until + * the next minor release. Returns false when the user quits the prompt, so the command does not run. + */ +export const offerCliInstall = Effect.fn("cli.install.offer")(function* (input: { + readonly args: ReadonlyArray; + readonly interactive: boolean; + readonly foreground: Effect.Effect; + /** Undefined when the user quits the prompt. */ + readonly confirm: Effect.Effect; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcess.Platform; + const environment = yield* HostProcess.Environment; + if (!input.interactive || environment.CI || environment.SUDO_USER) return true; + if (environment.TERM === "dumb") return true; + // `--json=true` and `-y=true` are the same flags. + if (input.args.some((arg) => SKIPPED_ARGUMENTS.has(arg.split("=")[0]!))) return true; + const command = topLevelCommand(input.args); + if (command === "install" || command === "uninstall") return true; + if (detectCliRunner(yield* resolveEntryPath) === null) return true; + const runtime = yield* standaloneRuntime; + if (Option.isNone(runtime)) return true; + const { runtimeDir } = runtime.value; + + // Any t3 already installed means no prompt: ours (even when off PATH) or another on PATH. + // Runners put their own cache's node_modules/.bin on PATH, so skip those. + const windows = platform === "win32"; + const names = windows ? ["t3.cmd", "t3.exe"] : ["t3"]; + const launchers = yield* fs + .readFileString(path.join(runtimeDir, "launchers")) + .pipe(Effect.orElseSucceed(() => "")); + const candidates = [ + ...launchers.split(/\r?\n/).filter(Boolean), + path.join(path.dirname(runtimeDir), "bin", windows ? "t3.cmd" : "t3"), + ...(environment.PATH ?? environment.Path ?? "") + .split(windows ? ";" : ":") + .filter( + (directory) => path.isAbsolute(directory) && detectCliRunner(`${directory}/`) === null, + ) + .flatMap((directory) => names.map((name) => path.join(directory, name))), + ]; + for (const candidate of candidates) { + if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) return true; + } + + const declinedFile = path.join(runtimeDir, "install-prompt-declined"); + const declined = yield* fs.readFileString(declinedFile).pipe(Effect.orElseSucceed(() => "")); + const minor = minorRelease(packageJson.version); + if (declined.trim() === minor) return true; + if (!(yield* input.foreground)) return true; + + const installHint = `Run \`${yield* resolveCliCommand("install")}\` any time.`; + const saveDecline = fs.writeFileString(declinedFile, `${minor}\n`).pipe(Effect.ignore); + const answer = yield* input.confirm.pipe(Effect.timeoutOption(PROMPT_TIMEOUT)); + if (Option.isNone(answer)) { + yield* saveDecline; + yield* Console.log(`\nNo answer, so t3 was not installed. ${installHint}\n`); + return true; + } + if (answer.value === undefined) return false; + if (!answer.value) { + yield* saveDecline; + yield* Console.log(`Skipped. ${installHint}\n`); + return true; + } + yield* installCli().pipe( + Effect.flatMap(reportInstalled), + Effect.catch((error) => + Effect.andThen(saveDecline, Console.error(`Could not install t3: ${error.message}`)), + ), + ); + yield* Console.log(""); + return true; +}); + +export const confirmCliInstall = Prompt.run( + Prompt.Confirm({ + message: "Install the t3 command? Then you can run `t3` directly.", + initial: true, + }), +).pipe(Effect.catchTags({ QuitError: () => Effect.void })); diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index 283da3d041d2..b9868df34de5 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -13,7 +13,7 @@ import packageJson from "../../package.json" with { type: "json" }; export type CliRunner = "npx" | "pnpm dlx" | "bunx"; /** Detection is best-effort; callers must fail closed to a plain `t3` command. */ -function detectCliRunner(entryPath: string): CliRunner | null { +export function detectCliRunner(entryPath: string): CliRunner | null { const path = entryPath.replaceAll("\\", "/"); if (path.includes("/_npx/")) { return "npx"; diff --git a/apps/server/src/cli/uninstall.test.ts b/apps/server/src/cli/uninstall.test.ts index bf9fb85f8974..4dcdc59e30a7 100644 --- a/apps/server/src/cli/uninstall.test.ts +++ b/apps/server/src/cli/uninstall.test.ts @@ -6,14 +6,17 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { findOwnedLauncher, spawnWindowsRemoval } from "./uninstall.ts"; +import { findOwnedLauncher, findRecordedLaunchers, spawnWindowsRemoval } from "./uninstall.ts"; it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => { it.effect("claims only a launcher that points into this home's runtime tree", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-uninstall-" }); + // macOS spells the temp folder /var but resolves it to /private/var. + const root = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-uninstall-" }) + .pipe(Effect.flatMap(fs.realPath)); const versionsDir = path.join(root, "runtime/versions"); const exe = path.join(versionsDir, "1.0.0/t3"); const otherExe = path.join(root, "other/runtime/versions/1.0.0/t3"); @@ -33,6 +36,53 @@ it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => { assert.isUndefined(yield* findOwnedLauncher({ launchedAs: theirs, versionsDir })); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: copy, versionsDir })); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: undefined, versionsDir })); + + // On Windows, only the shim exactly as written is ours; a user's wrapper stays. + const shimDir = path.join(root, "win-bin"); + const shim = path.join(shimDir, "t3.cmd"); + yield* fs.makeDirectory(shimDir); + const windowsOwner = (contents: string) => + fs + .writeFileString(shim, contents) + .pipe( + Effect.andThen(findOwnedLauncher({ launchedAs: exe, versionsDir })), + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(HostProcess.Environment, { PATH: shimDir }), + ); + assert.equal(yield* windowsOwner(`@echo off\r\n"${exe}" %*`), shim); + assert.isUndefined(yield* windowsOwner(`@echo off\r\nset FOO=1\r\n"${exe}" %*`)); + + // npx can run another version than the installed one, so uninstall also reads the + // recorded launchers: through a symlinked home, and dangling after a prune. + const throughAlias = path.join(root, "bin/t3-alias"); + const pruned = path.join(root, "bin/t3-pruned"); + yield* fs.symlink(root, path.join(root, "alias")); + yield* fs.symlink(path.join(root, "alias/runtime/versions/1.0.0/t3"), throughAlias); + yield* fs.symlink(path.join(versionsDir, "0.9.0/t3"), pruned); + // Another program inside a version is not a launcher. + const sibling = path.join(root, "bin/t3-sibling"); + yield* fs.symlink(path.join(versionsDir, "1.0.0/node"), sibling); + yield* fs.writeFileString( + path.join(root, "runtime/launchers"), + [ours, theirs, copy, throughAlias, pruned, sibling, path.join(root, "missing")].join("\n") + + "\n", + ); + for (const dir of [versionsDir, path.join(root, "alias/runtime/versions")]) { + assert.deepStrictEqual(yield* findRecordedLaunchers(dir), [ours, throughAlias, pruned]); + } + + // On Windows only an unedited t3.cmd is ours; a hand-edited wrapper belongs to the user. + const cmd = path.join(root, "bin/t3.cmd"); + const edited = path.join(root, "bin/edited.cmd"); + yield* fs.writeFileString(cmd, `@echo off\r\n"${exe}" %*`); + yield* fs.writeFileString(edited, `@echo off\r\nset "T3CODE_HOME=D:\\other"\r\n"${exe}" %*`); + yield* fs.writeFileString(path.join(root, "runtime/launchers"), `${cmd}\r\n${edited}\r\n`); + assert.deepStrictEqual( + yield* findRecordedLaunchers(versionsDir).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + ), + [cmd], + ); }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), ); }); diff --git a/apps/server/src/cli/uninstall.ts b/apps/server/src/cli/uninstall.ts index 33aa63fe4798..5cf6f0c5e64c 100644 --- a/apps/server/src/cli/uninstall.ts +++ b/apps/server/src/cli/uninstall.ts @@ -13,9 +13,9 @@ import { Command, Flag, GlobalFlag, Prompt } from "effect/cli"; import * as BootService from "../cloud/bootService.ts"; import { - launcherOwnsVersionsDir, + generatedWindowsShimTarget, + isVersionExecutable, pinnedRuntimeVersionsDir, - windowsShimTarget, } from "../cloud/pinnedRuntime.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; import * as CliService from "./service.ts"; @@ -32,7 +32,8 @@ export class CliUninstallError extends Schema.TaggedError()( export interface UninstallPlan { readonly service: boolean; - readonly launcher: string | undefined; + readonly versionsDir: string; + readonly launchers: ReadonlyArray; readonly runtimeDir: string | undefined; readonly userdataDir: string; } @@ -50,15 +51,71 @@ export const findOwnedLauncher = Effect.fn("cli.uninstall.find_launcher")(functi const shimPath = yield* findWindowsShim(input.launchedAs); if (shimPath === undefined) return undefined; const contents = yield* fs.readFileString(shimPath).pipe(Effect.option); - const target = Option.isSome(contents) ? windowsShimTarget(contents.value) : undefined; - return target !== undefined && launcherOwnsVersionsDir(path, input.versionsDir, target) + const target = Option.isSome(contents) ? generatedWindowsShimTarget(contents.value) : undefined; + return target !== undefined && isVersionExecutable(path, input.versionsDir, target) ? shimPath : undefined; } const linkTarget = yield* fs.readLink(input.launchedAs).pipe(Effect.option); if (Option.isNone(linkTarget)) return undefined; const resolved = path.resolve(path.dirname(input.launchedAs), linkTarget.value); - return launcherOwnsVersionsDir(path, input.versionsDir, resolved) ? input.launchedAs : undefined; + return isVersionExecutable(path, input.versionsDir, resolved) ? input.launchedAs : undefined; +}); + +/** Whether `launcher` is a link or unedited `t3.cmd` into this home's runtime/versions. */ +const ownsLauncher = Effect.fn("cli.uninstall.owns_launcher")(function* ( + launcher: string, + versionsDir: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcess.Platform; + const realVersionsDir = yield* fs + .realPath(versionsDir) + .pipe(Effect.orElseSucceed(() => versionsDir)); + // The link's own text keeps a pruned (dangling) target; the real path sees through a + // symlinked T3 home. + const targets = + platform === "win32" + ? [ + yield* fs.readFileString(launcher).pipe( + Effect.map(generatedWindowsShimTarget), + Effect.orElseSucceed(() => undefined), + ), + ] + : [ + yield* fs.readLink(launcher).pipe( + Effect.map((link) => path.resolve(path.dirname(launcher), link)), + Effect.orElseSucceed(() => undefined), + ), + yield* fs.realPath(launcher).pipe(Effect.orElseSucceed(() => undefined)), + ]; + return targets.some( + (target) => + target !== undefined && + (isVersionExecutable(path, versionsDir, target) || + isVersionExecutable(path, realVersionsDir, target)), + ); +}); + +/** + * Launchers recorded in `runtime/launchers` by the install scripts and `t3 install` that still + * point into this home's runtime/versions. npx can run a different version than the installed + * one, so the launcher this process started from does not always name them. + */ +export const findRecordedLaunchers = Effect.fn("cli.uninstall.find_recorded_launchers")(function* ( + versionsDir: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const recorded = yield* fs + .readFileString(path.join(path.dirname(versionsDir), "launchers")) + .pipe(Effect.orElseSucceed(() => "")); + const owned: Array = []; + for (const launcher of recorded.split(/\r?\n/).filter(Boolean)) { + if (yield* ownsLauncher(launcher, versionsDir)) owned.push(launcher); + } + return owned; }); // cmd.exe needs verbatim quoting and a literal path, with delayed expansion disabled. @@ -95,9 +152,16 @@ const planUninstall = Effect.fn("cli.uninstall.plan")(function* (input: { const versionsDir = pinnedRuntimeVersionsDir(path, input.baseDir); const runtimeDir = path.dirname(versionsDir); const launchedAs = (yield* HostProcess.IsExecutable) ? yield* resolveLauncherPath : undefined; + const launchedFrom = yield* findOwnedLauncher({ launchedAs, versionsDir }); const plan: UninstallPlan = { service: status.supported && status.installed && servesThisHome, - launcher: yield* findOwnedLauncher({ launchedAs, versionsDir }), + versionsDir, + launchers: [ + ...new Set([ + ...(launchedFrom === undefined ? [] : [launchedFrom]), + ...(yield* findRecordedLaunchers(versionsDir)), + ]), + ], runtimeDir: (yield* fs.exists(runtimeDir).pipe(Effect.orElseSucceed(() => false))) ? runtimeDir : undefined, @@ -140,7 +204,7 @@ const runUninstall = Effect.fn("cli.uninstall.run")(function* (input: { const service = yield* BootService.BootService; const plan = yield* planUninstall({ baseDir: input.baseDir }); - if (!plan.service && plan.launcher === undefined && plan.runtimeDir === undefined) { + if (!plan.service && plan.launchers.length === 0 && plan.runtimeDir === undefined) { yield* Console.log(`Nothing to remove: t3 is not installed for ${input.baseDir}.`); if (!(yield* HostProcess.IsExecutable)) { yield* Console.log( @@ -152,7 +216,7 @@ const runUninstall = Effect.fn("cli.uninstall.run")(function* (input: { yield* Console.log("This will remove:"); if (plan.service) yield* Console.log(" the background service (stopping it first)"); - if (plan.launcher !== undefined) yield* Console.log(` the launcher at ${plan.launcher}`); + for (const launcher of plan.launchers) yield* Console.log(` the launcher at ${launcher}`); if (plan.runtimeDir !== undefined) { yield* Console.log(` every downloaded version under ${plan.runtimeDir}`); } @@ -180,16 +244,20 @@ const runUninstall = Effect.fn("cli.uninstall.run")(function* (input: { yield* service.uninstall; yield* Console.log("Removed the background service."); } - if (plan.launcher !== undefined) { + for (const launcher of plan.launchers) { + // The prompt can wait a long time; never delete a file that replaced the launcher meanwhile. + if (!(yield* ownsLauncher(launcher, plan.versionsDir))) { + yield* Console.log(`Left ${launcher}: it no longer runs this t3.`); + continue; + } yield* fs - .remove(plan.launcher, { force: true }) + .remove(launcher, { force: true }) .pipe( Effect.mapError( - () => - new CliUninstallError({ reason: `Could not remove the launcher at ${plan.launcher}.` }), + () => new CliUninstallError({ reason: `Could not remove the launcher at ${launcher}.` }), ), ); - yield* Console.log(`Removed ${plan.launcher}.`); + yield* Console.log(`Removed ${launcher}.`); } if (plan.runtimeDir !== undefined) { // Windows refuses to unlink this running executable; a detached shell removes it after exit. diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index 38e56b30388f..b84f3e78e136 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off - node:path.win32 stands in for a Windows host in one test. import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; @@ -6,6 +7,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; +import * as NodePath from "node:path"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; import * as ChildProcess from "effect/process/ChildProcess"; @@ -15,6 +17,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ProcessRunner from "../processRunner.ts"; import { + isVersionExecutable, ensurePinnedRuntimeInstalled, isUnpublishedRelease, pinnedRuntimeCommand, @@ -376,6 +379,15 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { ); }); +it("isVersionExecutable accepts only /t3, never another drive or program", () => { + const win32 = NodePath.win32 as unknown as Path.Path; + assert.isTrue(isVersionExecutable(win32, "C:\\t3\\versions", "C:\\t3\\versions\\1.0.0\\t3.exe")); + assert.isFalse(isVersionExecutable(win32, "C:\\t3\\versions", "D:\\t3.exe")); + assert.isFalse( + isVersionExecutable(win32, "C:\\t3\\versions", "C:\\t3\\versions\\1.0.0\\node.exe"), + ); +}); + it.layer(NodeServices.layer)("prunePinnedRuntimes", (it) => { const lsofRunner = (stdout: string) => ProcessRunner.ProcessRunner.of({ diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 4e8df29f3026..0df57f10f3bd 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -443,9 +443,24 @@ export const recordPinnedRuntimeLauncher = Effect.fn("cloud.pinned_runtime.recor }, ); +/** Whether `candidate` is a version's own executable, `//t3[.exe]`. */ +export function isVersionExecutable( + path: Path.Path, + versionsDir: string, + candidate: string, +): boolean { + const relative = path.relative(versionsDir, path.resolve(candidate)); + // On another Windows drive, relative() returns the absolute path, like `D:\t3.exe`. + return !path.isAbsolute(relative) && /^(?!\.\.)[^\\/]+[\\/]t3(?:\.exe)?$/i.test(relative); +} + /** The executable a `t3.cmd` shim written by the install script or `t3 update` runs. */ export const windowsShimTarget = (contents: string) => /^"([^"]+)"/m.exec(contents)?.[1]; +/** Like {@link windowsShimTarget}, but only for a shim exactly as written; never a user's wrapper. */ +export const generatedWindowsShimTarget = (contents: string) => + /^@echo off\r?\n"([^"]+)" %\*\s*$/.exec(contents)?.[1]; + /** Where the `t3` launchers written by the install scripts and `t3 update` point. */ const shellLauncherTargets = Effect.fn("cloud.pinned_runtime.shell_launcher_targets")(function* ( baseDir: string, diff --git a/apps/web/src/components/HostedEmptyState.tsx b/apps/web/src/components/HostedEmptyState.tsx index d7fbb02a2804..627ba0b758eb 100644 --- a/apps/web/src/components/HostedEmptyState.tsx +++ b/apps/web/src/components/HostedEmptyState.tsx @@ -9,7 +9,7 @@ import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { isElectron } from "../env"; import { isLocalEnvironmentDisabled } from "../localEnvironment"; import { useRelayEnvironmentDiscovery } from "../state/environments"; -import { CommandBlock } from "./CommandBlock"; +import { InstallT3Commands } from "./InstallT3Commands"; import { useT3ConnectAuthPrompt } from "./clerk/useT3ConnectAuthPrompt"; import { CloudEnvironmentConnectRows } from "./cloud/CloudEnvironmentConnectList"; import { Button } from "./ui/button"; @@ -88,7 +88,7 @@ function T3ConnectSteps() { )} - + {isSignedIn ? (
- +
+ ); +} diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 2eb6bcd67395..6f7dfd911c50 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -94,7 +94,7 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { T3Wordmark } from "../T3Wordmark"; import { Alert, AlertDescription } from "../ui/alert"; import { Button } from "../ui/button"; -import { CommandBlock } from "../CommandBlock"; +import { InstallT3Commands } from "../InstallT3Commands"; import { Checkbox } from "../ui/checkbox"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Input } from "../ui/input"; @@ -541,7 +541,7 @@ function ConnectAccountOption({

Run this on each computer you want to connect.

- +

Keep T3 Code running. Select the computers you want to set up above.

@@ -650,11 +650,11 @@ function PairingForm({

On the computer with your code, start T3 Code or run{" "} - npx t3 serve, then run: + t3 serve, then run:

- +

- On a tailnet, run npx t3 pair --tailscale instead. + On a tailnet, run t3 pair --tailscale instead.

diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 3edebc634cd7..18063b8e9c6e 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -156,7 +156,7 @@ You do not need any extra env vars. Just run the app normally and inspect `serve Examples: ```bash -npx t3 +t3 ``` ```bash @@ -191,7 +191,7 @@ Default Grafana login: For endpoint settings and startup precedence, see the [user telemetry guide](../user/telemetry.md#export-diagnostics-to-your-own-receiver). -In a worktree, use `vp run dev` instead of `npx t3` to keep state isolated. +In a worktree, use `vp run dev` instead of `t3` to keep state isolated. ```bash export T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces @@ -212,7 +212,7 @@ export T3CODE_TRACE_TIMING_ENABLED=true CLI: ```bash -npx t3 +t3 ``` Monorepo web/server dev: diff --git a/docs/user/faq.md b/docs/user/faq.md index 3f5c1fcf8898..8d34de5e2ea4 100644 --- a/docs/user/faq.md +++ b/docs/user/faq.md @@ -13,7 +13,7 @@ Bring your own: Claude Code, Codex, Cursor, Grok Build, OpenCode, Google Antigra We were inspired by the Codex desktop app, Conductor, Claude Desktop, and Cursor Glass, but none met our bar. What you get: - Bring your own sub. Claude, Codex, and whatever else you already have installed. -- Best in class remote. Run `npx t3 up` on any box and drive it from anywhere. +- Best in class remote. Run `t3 up` on any box and drive it from anywhere. - Best in class mobile app. - Truly 100% open source. If we go the wrong direction, fork it. @@ -37,7 +37,7 @@ T3 Code drives Claude through Anthropic's Agent SDK. I confirmed it's a real bug ## How do I use T3 Code remotely? -Your agents run wherever T3 Code runs: your desktop, a server, a VPS. You connect to it from anywhere. I do this constantly. Less than 5% of my prompts run on my laptop. Run `npx t3 up` on that machine and you get really good remote support. T3 Connect is the easy path. LAN pairing and Tailscale work too, and [app.t3.codes](https://app.t3.codes) is the web client. [Remote access](./remote-access.md) +Your agents run wherever T3 Code runs: your desktop, a server, a VPS. You connect to it from anywhere. I do this constantly. Less than 5% of my prompts run on my laptop. Run `t3 up` on that machine and you get really good remote support. T3 Connect is the easy path. LAN pairing and Tailscale work too, and [app.t3.codes](https://app.t3.codes) is the web client. [Remote access](./remote-access.md) ## How do I use T3 Code on my phone? diff --git a/docs/user/install.md b/docs/user/install.md index 4689a2965889..d26e1b13d4dc 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -20,9 +20,12 @@ On Windows, in PowerShell: irm https://t3.codes/install.ps1 | iex ``` -This installs the `t3` binary to `~/.local/bin`. If your shell reports `command not found` -afterwards, that directory is not on your `PATH` yet; the installer prints the -line to add. Set `T3CODE_CHANNEL=nightly` to install the nightly train, or +This installs the `t3` binary to `~/.local/bin`. If that folder is not on your +`PATH` yet, the installer says so and, for zsh, bash on Linux, and fish, prints +a command to add it. On Windows, `t3` works in the same PowerShell window right +away; add the folder to your `PATH` for new windows. If a `t3` that the +installer or the desktop app did not create is already there, it leaves that +one alone and stops. Set `T3CODE_CHANNEL=nightly` to install the nightly train, or `T3CODE_VERSION` to pin an exact version. | Task | Command | @@ -44,7 +47,11 @@ instead. Stop it before starting a replacement, or use a different `--base-dir` for an independent server. To try T3 Code once without installing it, run `npx t3@latest` instead (needs -Node.js for `npx`). +Node.js for `npx`). It offers to install the `t3` command first. If you say no, +it asks again at the next minor release, and `npx t3 install` installs it later. +`npx t3` always runs the version npm picked, even when a `t3` is installed. On +the nightly train, run your installed `t3` (for example `t3 connect`), not +`npx t3`, which runs the latest stable release. ### Intel Macs diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 649dfbca6378..75910d9405c3 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -288,9 +288,8 @@ command to run on the host: sudo t3 browser setup ``` -The server shows the exact line for how you started it, such as -`sudo npx t3 browser setup`, and keeps your `PATH` when Node is installed only -for your user. Where `t3` is not on your `PATH`, such as with only the +The server shows the exact line for how you started it, and keeps your `PATH` +when you started it with `npx` and Node is installed only for your user. Where `t3` is not on your `PATH`, such as with only the desktop app installed, it names the full path of the app's own `t3` instead. It allows Chrome's sandbox with an AppArmor profile and installs any missing libraries and system fonts with apt; pages that load without text can mean the host has no fonts. It is safe to run again. Without `sudo`, it only @@ -379,11 +378,11 @@ expires. To choose a token's permissions, pass `--scope` once for each scope you want: ```sh -npx t3 pair --scope orchestration:read --scope relay:read +t3 pair --scope orchestration:read --scope relay:read ``` The selected scopes replace the default permissions. The same option works with -`npx t3 auth pairing create` and `npx t3 auth session issue`; each command's +`t3 auth pairing create` and `t3 auth session issue`; each command's `--help` lists the available scopes. Without `--scope`, pairing tokens and issued bearer sessions get every permission, including managing access and T3 Connect. A terminal or a full-access agent on the host can reach those as well, so a diff --git a/docs/user/welcome-wizard.md b/docs/user/welcome-wizard.md index 2d4b589d922f..6e3a9dc12f56 100644 --- a/docs/user/welcome-wizard.md +++ b/docs/user/welcome-wizard.md @@ -4,8 +4,8 @@ T3 Code shows a setup flow when you open a new installation. Existing workspaces skip this flow. The hosted app at app.t3.codes does not open setup. With no computer connected, -it asks you to sign in to T3 Connect, then to run `npx t3 connect` on your -computer. From home, it adds compatible computers linked to your account and +it asks you to sign in to T3 Connect, then to install T3 Code and run +`t3 connect` on your computer. From home, it adds compatible computers linked to your account and opens the app. Close setup while no import is running to go straight to the app. You can connect diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 16dc69ab14c0..d4e831f2af4b 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -18,6 +18,8 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { applyShellEnvironment, + pathSetup, + pathSetupCommand, CommandAvailability, CommandResolutionCache, type CommandAvailabilityChecker, @@ -1160,3 +1162,57 @@ describe("desktop shell environment updates", () => { }, ); }); + +describe("pathSetupCommand", () => { + // Undefined where a pasted line could break things: Windows (it would flatten %VAR% entries in + // the registry PATH), macOS bash (several startup files), other shells, and unsafe paths. + it.each([ + [ + "/home/theo/.local/bin", + "/bin/bash", + "linux", + `printf '\\n%s\\n' 'export PATH="$PATH:/home/theo/.local/bin"' >> ~/.bashrc`, + ], + [ + "/Users/theo/.local/bin", + "/usr/bin/fish", + "linux", + `mkdir -p ~/.config/fish && printf '\\n%s\\n' 'fish_add_path --append --path $HOME/.local/bin' >> ~/.config/fish/config.fish`, + ], + [ + "/opt/t3/bin", + "/bin/zsh", + "linux", + `printf '\\n%s\\n' 'export PATH="$PATH:/opt/t3/bin"' >> ~/.zshrc`, + ], + ["/Users/theo/.local/bin", "/bin/tcsh", "darwin", undefined], + ["/Users/theo/.local/bin", "/bin/bash", "darwin", undefined], + ["C:\\Users\\theo\\.local\\bin", "pwsh", "win32", undefined], + ["/Users/theo/it's here", "/bin/zsh", "darwin", undefined], + ["/opt/a$PWD/bin", "/bin/zsh", "linux", undefined], + // Relative: PATH would follow the working directory into any checkout's `bin`. + ["bin", "/bin/zsh", "linux", undefined], + ] as const)("%s with %s on %s", (directory, shell, platform, expected) => { + expect(pathSetupCommand({ directory, home: "/Users/theo", shell, platform })).toBe(expected); + }); + + it("writes the startup file fish and zsh read, under XDG_CONFIG_HOME or ZDOTDIR", () => { + const setup = { directory: "/opt/t3/bin", home: "/home/theo", shell: "/usr/bin/fish" } as const; + expect(pathSetup({ ...setup, platform: "linux", configHome: "/home/theo/cfg" })?.file).toBe( + "/home/theo/cfg/fish/config.fish", + ); + expect(pathSetup({ ...setup, platform: "linux" })?.file).toBe("~/.config/fish/config.fish"); + // zsh reads .zshrc from ZDOTDIR when it is set. + expect( + pathSetup({ ...setup, shell: "/bin/zsh", platform: "darwin", zdotdir: "/Users/theo/.zsh" }) + ?.file, + ).toBe("/Users/theo/.zsh/.zshrc"); + // A set folder that cannot be written safely means no setup, never the unread default. + expect( + pathSetup({ ...setup, shell: "/bin/zsh", platform: "darwin", zdotdir: "/Users/theo/my zsh" }), + ).toBeUndefined(); + expect( + pathSetup({ ...setup, platform: "linux", configHome: "/home/theo/my cfg" }), + ).toBeUndefined(); + }); +}); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index ad30c152a07c..cee692c2dd38 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -1030,3 +1030,65 @@ export function applyShellEnvironment( else env[key] = value; } } + +interface PathSetupInput { + readonly directory: string; + readonly home: string; + readonly shell: string | undefined; + readonly platform: NodeJS.Platform; + /** `XDG_CONFIG_HOME`, where fish reads its startup file when set. */ + readonly configHome?: string | undefined; + /** `ZDOTDIR`, where zsh reads `.zshrc` when set. */ + readonly zdotdir?: string | undefined; +} + +/** + * The startup file (`~/...`, or absolute under `configHome`) and line that put `directory` last on + * PATH for new zsh, bash, or fish terminals, so it never shadows another `t3`. Undefined on Windows (writing PATH there would flatten the registry's + * `%VAR%` entries), for other shells, macOS bash, or unsafe paths. + */ +export function pathSetup( + input: PathSetupInput, +): { readonly file: string; readonly line: string } | undefined { + // A relative folder on PATH would follow the working directory into any checkout. + if (input.platform === "win32" || !input.directory.startsWith("/")) return undefined; + const underHome = input.directory.startsWith(`${input.home}/`); + const rest = underHome ? input.directory.slice(input.home.length) : input.directory; + if (!/^[\w./-]+$/.test(rest)) return undefined; + const directory = underHome ? `$HOME${rest}` : rest; + const shell = input.shell?.split("/").pop(); + // A set folder the shell reads from replaces the default; one unsafe to write means no setup, + // since the default file would never be read. + const readFrom = (value: string | undefined, fallback: string) => + !value ? fallback : value.startsWith("/") && /^[\w./-]+$/.test(value) ? value : undefined; + if (shell === "fish") { + const configHome = readFrom(input.configHome, "~/.config"); + if (configHome === undefined) return undefined; + // --path changes $PATH itself; fish_user_paths would go ahead of the system folders. + return { + file: `${configHome}/fish/config.fish`, + line: `fish_add_path --append --path ${directory}`, + }; + } + // macOS bash reads only the first of .bash_profile, .bash_login, and .profile; no file is safe. + const zdotdir = readFrom(input.zdotdir, "~"); + const file = + shell === "zsh" + ? zdotdir && `${zdotdir}/.zshrc` + : shell === "bash" && input.platform !== "darwin" + ? "~/.bashrc" + : undefined; + return file === undefined ? undefined : { file, line: `export PATH="$PATH:${directory}"` }; +} + +/** A command to paste that does what {@link pathSetup} describes. */ +export function pathSetupCommand(input: PathSetupInput): string | undefined { + const setup = pathSetup(input); + if (!setup) return undefined; + // The leading newline keeps the line out of a final comment with no newline after it. + const append = `printf '\\n%s\\n' '${setup.line}' >> ${setup.file}`; + // fish's config folder may not exist yet; `&&` works in fish 3 too. + return setup.file.endsWith(".fish") + ? `mkdir -p ${setup.file.slice(0, setup.file.lastIndexOf("/"))} && ${append}` + : append; +} diff --git a/scripts/install.ps1 b/scripts/install.ps1 index fe452674c203..c14ddf9c9553 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -20,6 +20,8 @@ $repo = "pingdotgg/t3code" $baseUrl = if ($env:T3CODE_RELEASE_BASE_URL) { $env:T3CODE_RELEASE_BASE_URL.TrimEnd("/") } else { "https://github.com/$repo/releases/download" } $t3Home = if ($env:T3CODE_HOME) { $env:T3CODE_HOME } else { Join-Path $HOME ".t3" } $binDir = if ($env:T3CODE_INSTALL_BIN_DIR) { $env:T3CODE_INSTALL_BIN_DIR } else { Join-Path $HOME ".local\bin" } +# Absolute, so PATH never gets a folder that changes with the working directory. +$binDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($binDir) function Fail([string] $message) { Write-Error "t3 install: $message" @@ -216,19 +218,49 @@ if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { Step "Setting up the t3 command..." New-Item -ItemType Directory -Force -Path $binDir | Out-Null $shim = Join-Path $binDir "t3.cmd" +# Replace only a shim that runs a version in this home; never the desktop app's or another t3. +if (Test-Path -LiteralPath $shim) { + $existingTarget = "" + # The shim is UTF-8 without a BOM; Windows PowerShell 5.1 reads files as ANSI by default. + # Only a shim exactly as written here counts; a user's wrapper that also runs t3.exe does not. + $existing = [System.IO.File]::ReadAllText($shim, [System.Text.Encoding]::UTF8) + if ($existing -match '^@echo off\r?\n"([^"]+)" %\*\s*$') { $existingTarget = $Matches[1] } + # Full paths, so a target like versions\..\other cannot pass as ours. + $ownedPrefix = [System.IO.Path]::GetFullPath($versionsDir).TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar + if ($existingTarget) { $existingTarget = [System.IO.Path]::GetFullPath($existingTarget) } + # Exactly \t3.exe, never another program inside a version. + $owned = $existingTarget.StartsWith($ownedPrefix, [System.StringComparison]::OrdinalIgnoreCase) -and + ($existingTarget.Substring($ownedPrefix.Length) -match '^[^\\/]+\\t3\.exe$') + if (-not $owned) { + Fail "another t3 is already at $shim. Remove it and run this again, or run $(Join-Path $targetDir 't3.exe') directly." + } +} # UTF-8 without a BOM: cmd.exe reads the shim as-is, and ASCII would corrupt # non-ASCII characters in the user's home path. [System.IO.File]::WriteAllText($shim, "@echo off`r`n`"$(Join-Path $targetDir 't3.exe')`" %*", (New-Object System.Text.UTF8Encoding $false)) # Pruning keeps what this shim points at; a background service cannot see $binDir otherwise. $launchers = Join-Path $t3Home "runtime\launchers" -$shimPath = (Resolve-Path $shim).Path +$shimPath = (Resolve-Path -LiteralPath $shim).Path if (-not ((Test-Path $launchers) -and ((Get-Content $launchers) -contains $shimPath))) { [System.IO.File]::AppendAllText($launchers, "$shimPath`r`n", (New-Object System.Text.UTF8Encoding $false)) } if ($interactive) { [Console]::Error.Write("`r$esc[2K") } [Console]::Error.WriteLine(" ${green}Installed T3 Code $version$reset`n") -if (($env:PATH -split ";") -notcontains $binDir) { - Write-Host " Add $binDir to your PATH, then run ${bold}t3$reset.`n" +if ($binDir.Contains(";")) { + # PATH would split the folder at the `;`, so name the command in full instead. + Write-Host " Run ${bold}& '$($shim.Replace("'", "''"))'$reset to get started.`n" } else { - Write-Host " Run ${bold}t3$reset to use T3 Code here, or ${bold}t3 up$reset to use it from your other devices.`n" + $added = ($env:PATH -split ";") -notcontains $binDir + # `irm | iex` runs in the user's own session, so t3 works in this window right away. + # Last, so it never shadows another t3. + if ($added) { $env:Path = "$env:Path;$binDir" } + # A t3 earlier on PATH runs instead, so name this one in full. + $first = Get-Command t3 -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($first -and [System.IO.Path]::GetFullPath($first.Source) -ne [System.IO.Path]::GetFullPath($shimPath)) { + Write-Host " Another t3 at $($first.Source) runs first, so run ${bold}& '$($shim.Replace("'", "''"))'$reset to use this one.`n" + } elseif ($added) { + Write-Host " Run ${bold}t3$reset to get started. For new windows, add $binDir to your PATH.`n" + } else { + Write-Host " Run ${bold}t3$reset to use T3 Code here, or ${bold}t3 up$reset to use it from your other devices.`n" + } } diff --git a/scripts/install.sh b/scripts/install.sh index ea7ec1e6c470..a5cfce5e9fa2 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -29,6 +29,11 @@ case "$t3_home" in ;; esac bin_dir="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}" +# Trailing slashes would put `//` in the paths the t3 command guard compares. +while [ "${t3_home%/}" != "$t3_home" ] && [ "$t3_home" != / ]; do t3_home="${t3_home%/}"; done +while [ "${bin_dir%/}" != "$bin_dir" ] && [ "$bin_dir" != / ]; do bin_dir="${bin_dir%/}"; done +# Absolute, so the PATH hint never adds a folder that changes with the working directory. +case "$bin_dir" in /*) ;; *) bin_dir="$PWD/$bin_dir" ;; esac fail() { printf '\nt3 install: %s\n' "$1" >&2 @@ -238,14 +243,97 @@ fi step "Setting up the t3 command..." mkdir -p "$bin_dir" -ln -sfn "${target_dir}/t3" "${bin_dir}/t3" +# Replace only this home's own t3 links: into its versions, or the desktop app's launcher. +# Anything that is not a symlink (a script, a copied binary) belongs to someone else. +# Exactly //t3 ($1 under $2), never another program inside a version. +version_t3() { + case "$1" in "$2"/*) ;; *) return 1 ;; esac + case "${1#"$2"/}" in */*/*) return 1 ;; */t3) return 0 ;; *) return 1 ;; esac +} +slot_empty=true +if [ -e "${bin_dir}/t3" ] || [ -L "${bin_dir}/t3" ]; then + slot_empty=false + owned=false + if [ -L "${bin_dir}/t3" ]; then + existing="$(readlink "${bin_dir}/t3" 2>/dev/null || true)" + case "$existing" in /*) ;; *) existing="${bin_dir}/${existing}" ;; esac + real_versions_dir="$(CDPATH='' cd -P -- "$versions_dir" && pwd)" + # Through a symlinked T3 home the link text names the alias; resolve its folder to compare. + existing_dir="$(CDPATH='' cd -P -- "$(dirname -- "$existing")" 2>/dev/null && pwd || true)" + # The text alone counts only without . or .. segments (a pruned version leaves a dangling link). + case "$existing" in + */./* | */../* | */. | */..) ;; + *) + if version_t3 "$existing" "$versions_dir" || version_t3 "$existing" "$real_versions_dir" || + [ "$existing" = "${t3_home}/bin/t3" ]; then owned=true; fi + ;; + esac + if [ -n "$existing_dir" ] && version_t3 "${existing_dir}/${existing##*/}" "$real_versions_dir"; then + owned=true + fi + fi + "$owned" || fail "another t3 is already at ${bin_dir}/t3. Remove it and run this again, or run ${target_dir}/t3 directly." +fi +# An empty spot is linked without -f, so a file that appears after the check above is kept. +if "$slot_empty"; then + ln -s "${target_dir}/t3" "${bin_dir}/t3" || fail "could not link ${bin_dir}/t3; something else was created there." +else + # Known race, accepted: a file written here after the ownership check above is overwritten. + ln -sfn "${target_dir}/t3" "${bin_dir}/t3" +fi # Pruning keeps what this launcher points at; a background service cannot see bin_dir otherwise. launcher="$(CDPATH='' cd -P -- "$bin_dir" && pwd)/t3" launchers="${t3_home}/runtime/launchers" grep -qxF "$launcher" "$launchers" 2>/dev/null || printf '%s\n' "$launcher" >> "$launchers" if "$interactive"; then printf '\r\033[2K' >&2; fi printf ' %sInstalled T3 Code %s%s\n\n' "$green" "$version" "$reset" >&2 -case ":${PATH}:" in - *":${bin_dir}:"*) printf ' Run %st3%s to use T3 Code here, or %st3 up%s to use it from your other devices.\n\n' "$bold" "$reset" "$bold" "$reset" ;; - *) printf ' Add %s to your PATH, then run %st3%s.\n\n' "$bin_dir" "$bold" "$reset" ;; -esac +# The folder a shell reads its startup file from: $1 when set and safe to paste, else $2 when +# unset. A set but unsafe folder prints nothing, since the default would never be read. +read_from() { + case "$1" in + "") printf '%s' "$2" ;; + /*) case "$1" in *[!A-Za-z0-9_./-]*) ;; *) printf '%s' "$1" ;; esac ;; + esac +} +# A command to paste for zsh, Linux bash, or fish, like `t3 install` prints. macOS bash reads only +# one of several startup files, so no single file is safe to change there. +path_command= +path_rest="$bin_dir" +path_prefix= +case "$bin_dir" in "$HOME"/*) path_rest="${bin_dir#"$HOME"}" path_prefix='$HOME' ;; esac +case "$path_rest" in *[!A-Za-z0-9_./-]*) path_rest= ;; esac +if [ -n "$path_rest" ]; then + # Last on PATH, so it never shadows another t3. + path_line="export PATH=\"\$PATH:${path_prefix}${path_rest}\"" + shell_name="${SHELL:-}" + case "${shell_name##*/}" in + zsh) + # zsh reads .zshrc from ZDOTDIR when it is set; ~/.zshrc would never be read then. + zsh_dir="$(read_from "${ZDOTDIR:-}" '~')" + [ -z "$zsh_dir" ] || path_command="printf '\\n%s\\n' '${path_line}' >> ${zsh_dir}/.zshrc" ;; + bash) [ "$platform" = darwin ] || path_command="printf '\\n%s\\n' '${path_line}' >> ~/.bashrc" ;; + fish) + # --path changes $PATH itself; fish_user_paths would go ahead of the system folders. + config_dir="$(read_from "${XDG_CONFIG_HOME:-}" '~/.config')" + fish_dir="${config_dir}/fish" + [ -z "$config_dir" ] || path_command="mkdir -p ${fish_dir} && printf '\\n%s\\n' 'fish_add_path --append --path ${path_prefix}${path_rest}' >> ${fish_dir}/config.fish" ;; + esac +fi +# A t3 earlier on PATH runs instead; the PATH command adds this folder last. +# A PATH entry with a trailing slash gives `dir//t3`. +first_t3="$(command -v t3 2>/dev/null | tr -s / || true)" +case "$first_t3" in "" | "${bin_dir}/t3") first_t3= ;; esac +if [ -n "$first_t3" ]; then + printf ' Another t3 at %s runs first, so run %s%s/t3%s to use this one.\n\n' "$first_t3" "$bold" "$bin_dir" "$reset" +else + case ":${PATH}:" in + *":${bin_dir}:"* | *":${bin_dir}/:"*) printf ' Run %st3%s to use T3 Code here, or %st3 up%s to use it from your other devices.\n\n' "$bold" "$reset" "$bold" "$reset" ;; + *) + if [ -n "$path_command" ]; then + printf ' %s is not on your PATH yet. Add it with:\n\n %s\n\n Then open a new terminal and run %st3%s.\n\n' "$bin_dir" "$path_command" "$bold" "$reset" + else + printf ' Add %s to your PATH, then run %st3%s.\n\n' "$bin_dir" "$bold" "$reset" + fi + ;; + esac +fi diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 910f780fe168..230da9eb84db 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -128,6 +128,208 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("installer chan }); }); +/** Runs an installer for a version that is already unpacked, so only the t3 command step runs. */ +async function installOver(input: { + readonly script: "install.sh" | "install.ps1"; + readonly home: string; + readonly binDir: string; + readonly env?: NodeJS.ProcessEnv; + readonly cwd?: string; +}) { + const target = NodePath.join(input.home, "runtime", "versions", "1.2.3"); + await NodeFSP.mkdir(target, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(target, ".install-complete"), "1.2.3\n"); + const installer = NodePath.resolve(import.meta.dirname, input.script); + const [command, args] = + input.script === "install.sh" + ? ["sh", [installer]] + : ["powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", installer]]; + const result = NodeChildProcess.spawnSync(command, args, { + encoding: "utf8", + ...(input.cwd === undefined ? {} : { cwd: input.cwd }), + env: { + ...process.env, + T3CODE_CHANNEL: "", + T3CODE_VERSION: "1.2.3", + T3CODE_HOME: input.home, + T3CODE_INSTALL_BIN_DIR: input.binDir, + T3CODE_RELEASE_BASE_URL: "http://127.0.0.1:9", + ...input.env, + }, + }); + return { code: result.status, output: `${result.stdout}${result.stderr}`, target }; +} + +describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 command", () => { + it("replaces only this home's own links and prints a PATH command", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-link-")); + try { + const home = NodePath.join(root, "home"); + const binDir = NodePath.join(root, "bin"); + const link = NodePath.join(binDir, "t3"); + await NodeFSP.mkdir(binDir, { recursive: true }); + + await NodeFSP.writeFile(link, "#!/bin/sh\necho mine\n"); + const foreign = await installOver({ script: "install.sh", home, binDir }); + expect(foreign.code).not.toBe(0); + expect(foreign.output).toContain(`another t3 is already at ${link}`); + expect(await NodeFSP.readFile(link, "utf8")).toBe("#!/bin/sh\necho mine\n"); + // Not even from inside the versions folder, where `.` would resolve into it. + const fromVersions = await installOver({ + script: "install.sh", + home, + binDir, + cwd: foreign.target, + }); + expect(fromVersions.code).not.toBe(0); + expect(await NodeFSP.readFile(link, "utf8")).toBe("#!/bin/sh\necho mine\n"); + + // A link that only looks like it points into versions (join() would normalize the ..). + await NodeFSP.rm(link); + await NodeFSP.mkdir(NodePath.join(home, "other"), { recursive: true }); + await NodeFSP.writeFile(NodePath.join(home, "other/t3"), "#!/bin/sh\n"); + await NodeFSP.symlink(`${home}/runtime/versions/../../other/t3`, link); + const climbing = await installOver({ script: "install.sh", home, binDir }); + expect(climbing.code).not.toBe(0); + expect(await NodeFSP.readlink(link)).toBe(`${home}/runtime/versions/../../other/t3`); + + // Only `/t3` counts, not another program inside a version. + await NodeFSP.rm(link); + await NodeFSP.symlink(NodePath.join(home, "runtime/versions/1.0.0/helper"), link); + const sibling = await installOver({ script: "install.sh", home, binDir }); + expect(sibling.code).not.toBe(0); + expect(await NodeFSP.readlink(link)).toBe( + NodePath.join(home, "runtime/versions/1.0.0/helper"), + ); + + // An upgrade over an older version's link, written through a symlinked T3 home. + await NodeFSP.rm(link); + await NodeFSP.symlink(NodePath.join(home, "runtime/versions/1.0.0/t3"), link); + await NodeFSP.symlink(home, NodePath.join(root, "alias")); + const upgrade = await installOver({ + script: "install.sh", + home: NodePath.join(root, "alias"), + binDir, + env: { HOME: root, SHELL: "/bin/zsh", PATH: "/usr/bin:/bin" }, + }); + expect(upgrade.code, upgrade.output).toBe(0); + expect(await NodeFSP.readlink(link)).toBe( + NodePath.join(root, "alias/runtime/versions/1.2.3/t3"), + ); + expect(upgrade.output).toContain( + `printf '\\n%s\\n' 'export PATH="$PATH:$HOME/bin"' >> ~/.zshrc`, + ); + + // Containers and CI often have no SHELL; `set -u` must not stop the installer there. + const noShell = await installOver({ + script: "install.sh", + home, + binDir, + env: { HOME: root, SHELL: undefined, PATH: "/usr/bin:/bin" }, + }); + expect(noShell.code, noShell.output).toBe(0); + expect(noShell.output).toContain(`Add ${binDir} to your PATH`); + + // Its folder on PATH with a trailing slash is still this t3, not another one. + const slashed = await installOver({ + script: "install.sh", + home, + binDir, + env: { HOME: root, PATH: `${binDir}/:/usr/bin:/bin` }, + }); + expect(slashed.code, slashed.output).toBe(0); + expect(slashed.output).toContain("Run t3 to use T3 Code here"); + + // A relative folder is made absolute, so the PATH command never follows the working directory. + const relative = await installOver({ + script: "install.sh", + home, + binDir: "bin", + cwd: root, + env: { HOME: root, SHELL: "/bin/zsh", PATH: "/usr/bin:/bin" }, + }); + expect(relative.code, relative.output).toBe(0); + expect(relative.output).toContain(`'export PATH="$PATH:$HOME/bin"'`); + + await NodeFSP.rm(link); + await NodeFSP.symlink(NodePath.join(home, "bin/t3"), link); + const overDesktop = await installOver({ + script: "install.sh", + home: `${home}//`, + binDir: `${binDir}/`, + }); + expect(overDesktop.code, overDesktop.output).toBe(0); + expect(await NodeFSP.readlink(link)).toBe(NodePath.join(home, "runtime/versions/1.2.3/t3")); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); +}); + +describe.skipIf(HostProcess.Platform.defaultValue() !== "win32")("install.ps1 t3 command", () => { + it("replaces only its own t3.cmd, also in non-ASCII and bracketed paths", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-ps1-")); + try { + // Windows PowerShell 5.1 reads files as ANSI by default and expands wildcards in paths. + const home = NodePath.join(root, "José", ".t3"); + const binDir = NodePath.join(root, "bin[t3]"); + const shim = NodePath.join(binDir, "t3.cmd"); + await NodeFSP.mkdir(binDir, { recursive: true }); + + const desktopShim = '@echo off\r\n"C:\\Program Files\\T3 Code\\T3 Code.exe" "x" %*\r\n'; + await NodeFSP.writeFile(shim, desktopShim); + const foreign = await installOver({ script: "install.ps1", home, binDir }); + expect(foreign.code).not.toBe(0); + expect(foreign.output).toContain("another t3 is already at"); + expect(await NodeFSP.readFile(shim, "utf8")).toBe(desktopShim); + + // A hand-edited wrapper may set its own environment; replacing it would drop that. + const edited = `@echo off\r\nset "T3CODE_HOME=D:\\other"\r\n"${NodePath.join(home, "runtime", "versions", "1.0.0", "t3.exe")}" %*`; + await NodeFSP.writeFile(shim, edited, "utf8"); + const wrapper = await installOver({ script: "install.ps1", home, binDir }); + expect(wrapper.code).not.toBe(0); + expect(await NodeFSP.readFile(shim, "utf8")).toBe(edited); + + const climbing = `@echo off\r\n"${NodePath.join(home, "runtime", "versions")}\\..\\other\\t3.exe" %*`; + await NodeFSP.writeFile(shim, climbing, "utf8"); + const outside = await installOver({ script: "install.ps1", home, binDir }); + expect(outside.code).not.toBe(0); + expect(await NodeFSP.readFile(shim, "utf8")).toBe(climbing); + + const older = NodePath.join(home, "runtime", "versions", "1.0.0", "t3.exe"); + await NodeFSP.writeFile(shim, `@echo off\r\n"${older}" %*`, "utf8"); + const upgrade = await installOver({ script: "install.ps1", home, binDir }); + expect(upgrade.code, upgrade.output).toBe(0); + expect(await NodeFSP.readFile(shim, "utf8")).toContain( + NodePath.join(upgrade.target, "t3.exe"), + ); + + const again = await installOver({ script: "install.ps1", home, binDir }); + expect(again.code, again.output).toBe(0); + + // Another t3 first on PATH still runs, so the installer names this one in full. + const otherBin = NodePath.join(root, "other"); + await NodeFSP.mkdir(otherBin); + await NodeFSP.writeFile(NodePath.join(otherBin, "t3.cmd"), "@echo off\r\n"); + // Windows spells it Path; a second PATH key would be ambiguous. + const pathKey = + Object.keys(process.env).find((key) => key.toUpperCase() === "PATH") ?? "PATH"; + const shadowed = await installOver({ + script: "install.ps1", + home, + binDir, + env: { [pathKey]: `${otherBin};${process.env[pathKey] ?? ""}` }, + }); + expect(shadowed.code, shadowed.output).toBe(0); + expect(shadowed.output).toContain( + `Another t3 at ${NodePath.join(otherBin, "t3.cmd")} runs first`, + ); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); +}); + describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("installer checksum", () => { it.each( ["home\\data", "home\ndata"].flatMap((homeName) => From 4b685fafce85dd22e7fd9e9cd55bb7fcc5ccb0b6 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 8 Nov 2026 16:46:56 -0800 Subject: [PATCH 2/6] fix(cli): no install prompt under npx --yes, and a PowerShell PATH line - `npx --yes t3 ...` asks for no prompts; npm passes that on as npm_config_yes, so the install offer now skips it. - On Windows, `t3 install` prints a PowerShell line that adds the folder to PATH in the current window, since `t3` otherwise fails there. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/install.test.ts | 5 ++++- apps/server/src/cli/install.ts | 31 +++++++++++++++++++++-------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/apps/server/src/cli/install.test.ts b/apps/server/src/cli/install.test.ts index 3c30ed49ef73..2371acbe05eb 100644 --- a/apps/server/src/cli/install.test.ts +++ b/apps/server/src/cli/install.test.ts @@ -149,8 +149,10 @@ it.layer(NodeServices.layer)("t3 install", (it) => { shim, `@echo off\r\n"${path.join(runtimeDir, "versions/0.0.1/t3.exe")}" %*`, ); - yield* windows(installCli()); + const windowsInstalled = yield* windows(installCli()); assert.equal(yield* fs.readFileString(shim), `@echo off\r\n"${executable}" %*`); + // A PowerShell command for this window; the saved PATH is left to the user. + assert.equal(windowsInstalled.pathCommand, `$env:Path += ';${path.join(root, "bin")}'`); const unrunnable = yield* windows( installCli().pipe( Effect.provideService( @@ -196,6 +198,7 @@ it.layer(NodeServices.layer)("t3 install", (it) => { yield* offer(false, { env: { CI: "true" } }); yield* offer(false, { env: { SUDO_USER: "theo" } }); yield* offer(false, { env: { TERM: "dumb" } }); + yield* offer(false, { env: { npm_config_yes: "true" } }); yield* offer(false, { foreground: false }); yield* offer(false, { args: ["connect", "--help"] }); yield* offer(false, { args: ["--log-level", "debug", "install"] }); diff --git a/apps/server/src/cli/install.ts b/apps/server/src/cli/install.ts index db6092fabc5f..cdd7ef6892f5 100644 --- a/apps/server/src/cli/install.ts +++ b/apps/server/src/cli/install.ts @@ -176,14 +176,20 @@ export const installCli = Effect.fn("cli.install.install")(function* () { pathCommand: onPath || shadowedBy !== undefined ? undefined - : pathSetupCommand({ - directory: binDir, - home, - shell: environment.SHELL, - platform, - configHome: environment.XDG_CONFIG_HOME, - zdotdir: environment.ZDOTDIR, - }), + : windows + ? // PowerShell; it covers this window only, since changing the saved PATH would flatten + // its %VAR% entries. A `;` would split the folder. + binDir.includes(";") + ? undefined + : `$env:Path += ';${binDir.replaceAll("'", "''")}'` + : pathSetupCommand({ + directory: binDir, + home, + shell: environment.SHELL, + platform, + configHome: environment.XDG_CONFIG_HOME, + zdotdir: environment.ZDOTDIR, + }), binDir, }; }); @@ -201,6 +207,13 @@ const reportInstalled = Effect.fn("cli.install.report")(function* ( if (installed.pathCommand === undefined) { return yield* Console.log(`Add ${installed.binDir} to your PATH, then run \`t3\`.`); } + if ((yield* HostProcess.Platform) === "win32") { + yield* Console.log( + `${installed.binDir} is not on your PATH yet. In this PowerShell window, run:`, + ); + yield* Console.log(` ${installed.pathCommand}`); + return yield* Console.log("Then run `t3`. For new windows, add the folder to your PATH."); + } yield* Console.log(`${installed.binDir} is not on your PATH yet. Add it with:`); yield* Console.log(` ${installed.pathCommand}`); yield* Console.log("Then open a new terminal and run `t3`."); @@ -296,6 +309,8 @@ export const offerCliInstall = Effect.fn("cli.install.offer")(function* SKIPPED_ARGUMENTS.has(arg.split("=")[0]!))) return true; const command = topLevelCommand(input.args); From fe81060c061d9c3af28d724377fd02a23da9f67e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 8 Nov 2026 17:00:16 -0800 Subject: [PATCH 3/6] fix(cli): no install prompt before --completions output Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/install.test.ts | 1 + apps/server/src/cli/install.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/server/src/cli/install.test.ts b/apps/server/src/cli/install.test.ts index 2371acbe05eb..36deef5df342 100644 --- a/apps/server/src/cli/install.test.ts +++ b/apps/server/src/cli/install.test.ts @@ -199,6 +199,7 @@ it.layer(NodeServices.layer)("t3 install", (it) => { yield* offer(false, { env: { SUDO_USER: "theo" } }); yield* offer(false, { env: { TERM: "dumb" } }); yield* offer(false, { env: { npm_config_yes: "true" } }); + yield* offer(false, { args: ["--completions", "zsh"] }); yield* offer(false, { foreground: false }); yield* offer(false, { args: ["connect", "--help"] }); yield* offer(false, { args: ["--log-level", "debug", "install"] }); diff --git a/apps/server/src/cli/install.ts b/apps/server/src/cli/install.ts index cdd7ef6892f5..ed3c8876a688 100644 --- a/apps/server/src/cli/install.ts +++ b/apps/server/src/cli/install.ts @@ -233,6 +233,7 @@ const SKIPPED_ARGUMENTS = new Set([ "-h", "--version", "-v", + "--completions", // Scripts and machine-readable output; a prompt would stall them or corrupt their output. "--json", "--yes", From a39cbc9e90579814d22e803a8382f0668b6367b8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 8 Nov 2026 17:27:54 -0800 Subject: [PATCH 4/6] fix(install): install.ps1 sees its folder on PATH with a trailing backslash Co-Authored-By: Claude Opus 5.5 --- scripts/install.ps1 | 3 ++- scripts/install.test.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index c14ddf9c9553..5a5d38dc3ccf 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -250,7 +250,8 @@ if ($binDir.Contains(";")) { # PATH would split the folder at the `;`, so name the command in full instead. Write-Host " Run ${bold}& '$($shim.Replace("'", "''"))'$reset to get started.`n" } else { - $added = ($env:PATH -split ";") -notcontains $binDir + # A PATH entry may end in a separator; $binDir does not. + $added = ($env:PATH -split ";" | ForEach-Object { $_.TrimEnd('\', '/') }) -notcontains $binDir # `irm | iex` runs in the user's own session, so t3 works in this window right away. # Last, so it never shadows another t3. if ($added) { $env:Path = "$env:Path;$binDir" } diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 230da9eb84db..b844caa312dd 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -324,6 +324,16 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "win32")("install.ps1 t3 expect(shadowed.output).toContain( `Another t3 at ${NodePath.join(otherBin, "t3.cmd")} runs first`, ); + + // Its own folder on PATH with a trailing separator is already on PATH. + const slashed = await installOver({ + script: "install.ps1", + home, + binDir, + env: { [pathKey]: `${binDir}\\;${process.env[pathKey] ?? ""}` }, + }); + expect(slashed.code, slashed.output).toBe(0); + expect(slashed.output).toContain("to use T3 Code here"); } finally { await NodeFSP.rm(root, { recursive: true, force: true }); } From b382f7388768a2929e484fd819426eafc0b392ca Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 8 Nov 2026 17:39:43 -0800 Subject: [PATCH 5/6] fix(install): install.ps1 also trims a trailing separator on its own folder Co-Authored-By: Claude Opus 5.5 --- scripts/install.ps1 | 4 ++-- scripts/install.test.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 5a5d38dc3ccf..7816ae60e9f6 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -250,8 +250,8 @@ if ($binDir.Contains(";")) { # PATH would split the folder at the `;`, so name the command in full instead. Write-Host " Run ${bold}& '$($shim.Replace("'", "''"))'$reset to get started.`n" } else { - # A PATH entry may end in a separator; $binDir does not. - $added = ($env:PATH -split ";" | ForEach-Object { $_.TrimEnd('\', '/') }) -notcontains $binDir + # Either side may end in a separator. Trim only here: a drive root keeps its `\` for Join-Path. + $added = ($env:PATH -split ";" | ForEach-Object { $_.TrimEnd('\', '/') }) -notcontains $binDir.TrimEnd('\', '/') # `irm | iex` runs in the user's own session, so t3 works in this window right away. # Last, so it never shadows another t3. if ($added) { $env:Path = "$env:Path;$binDir" } diff --git a/scripts/install.test.ts b/scripts/install.test.ts index b844caa312dd..8f423721505d 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -334,6 +334,15 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "win32")("install.ps1 t3 }); expect(slashed.code, slashed.output).toBe(0); expect(slashed.output).toContain("to use T3 Code here"); + // The same when the install folder itself is given with a trailing separator. + const bothSlashed = await installOver({ + script: "install.ps1", + home, + binDir: `${binDir}\\`, + env: { [pathKey]: `${binDir}\\;${process.env[pathKey] ?? ""}` }, + }); + expect(bothSlashed.code, bothSlashed.output).toBe(0); + expect(bothSlashed.output).toContain("to use T3 Code here"); } finally { await NodeFSP.rm(root, { recursive: true, force: true }); } From e8baae20aae15fde1b39b2c6e5f7bbe1fe12e9a8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 8 Nov 2026 17:51:07 -0800 Subject: [PATCH 6/6] fix(web): pairing help shows the installers before t3 serve Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/onboarding/WelcomeWizard.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 6f7dfd911c50..823b8b9042fc 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -648,13 +648,12 @@ function PairingForm({ -

- On the computer with your code, start T3 Code or run{" "} - t3 serve, then run: -

+

On the computer with your code:

- On a tailnet, run t3 pair --tailscale instead. + Start T3 Code or run t3 serve there before{" "} + t3 pair. On a tailnet, run{" "} + t3 pair --tailscale instead.