From 9a6425821921852603e9a459351d9c0011db80ed Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 03:15:50 -0700 Subject: [PATCH 01/23] fix(cli): upgrades never replace the public t3 link The t3 command (~/.local/bin/t3 or T3CODE_INSTALL_BIN_DIR) was upgraded with check-then-replace, so a file another program wrote there between the check and the replace was overwritten. The public link now points at $T3CODE_HOME/runtime/t3, and install.sh, `t3 install`, and `t3 update` switch only that inner link. The public name is created only when free. An older link straight to a version, or this home's desktop launcher, moves over once: it is renamed aside, checked again, and put back if it is not ours. The desktop app and `t3 uninstall` use the same swap. Pruning always keeps the version the command link points at. Co-Authored-By: Claude Opus 5.5 --- .../desktop/src/app/DesktopCliCommand.test.ts | 35 ++++ apps/desktop/src/app/DesktopCliCommand.ts | 32 ++- apps/server/src/cli/install.test.ts | 43 +++-- apps/server/src/cli/install.ts | 182 ++++++++++++------ apps/server/src/cli/uninstall.test.ts | 44 ++++- apps/server/src/cli/uninstall.ts | 48 ++--- apps/server/src/cli/update.test.ts | 42 +++- apps/server/src/cli/update.ts | 24 ++- apps/server/src/cloud/pinnedRuntime.test.ts | 11 +- apps/server/src/cloud/pinnedRuntime.ts | 13 ++ packages/shared/package.json | 4 + packages/shared/src/ownedLink.ts | 32 +++ scripts/install.sh | 103 +++++++--- scripts/install.test.ts | 61 +++++- 14 files changed, 500 insertions(+), 174 deletions(-) create mode 100644 packages/shared/src/ownedLink.ts diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index 3c965e04663b8..567c32320ba3c 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -242,6 +242,41 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { }).pipe(Effect.scoped), ); + it.effect("keeps a t3 another program writes while it replaces its own link", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const before = yield* commandIn({ home, baseDir: path.join(home, "old-t3") }); + const link = (yield* before.install).installedPath!; + const theirs = path.join(home, "theirs"); + yield* fs.writeFileString(theirs, "#!/bin/sh\n", { mode: 0o755 }); + // Right after the app finds its old link, another program puts its own t3 there. + let raced = false; + const racing = FileSystem.FileSystem.of({ + ...fs, + access: (target, options) => + (raced + ? Effect.void + : fs + .symlink(theirs, `${link}.new`) + .pipe( + Effect.andThen(fs.rename(`${link}.new`, link)), + Effect.andThen(Effect.sync(() => (raced = true))), + ) + ).pipe(Effect.andThen(fs.access(target, options))), + }); + const after = yield* commandIn({ home, baseDir: path.join(home, "new-t3") }).pipe( + Effect.provideService(FileSystem.FileSystem, racing), + ); + const installed = yield* after.install; + expect(raced).toBe(true); + expect(yield* fs.readLink(link)).toBe(theirs); + expect(installed.installedPath).toBe(path.join(home, "bin", "t3")); + expect(yield* fs.readDirectory(path.dirname(link))).toEqual(["t3"]); + }).pipe(Effect.scoped), + ); + it.effect("refuses to install behind another t3 that runs first", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index b990ed79a7326..1972ee2eb73bf 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -7,13 +7,13 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Random from "effect/Random"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { replaceOwnedLink } from "@t3tools/shared/ownedLink"; import { pathSetup, readUserLoginShell } from "@t3tools/shared/shell"; import * as DesktopCliShim from "./DesktopCliShim.ts"; @@ -108,6 +108,12 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => false), ); + /** Other programs write these folders too, so the link is checked again once moved aside. */ + const replaceOurLink = (input: { readonly link: string; readonly target?: string }) => + replaceOwnedLink({ ...input, owns: isOurLink }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ); + // PATH can reach the regular launcher directly; removal still requires isOurLink. const runsOurLauncher = Effect.fn("DesktopCliCommand.runsOurLauncher")( // Another T3 home's link is marked too, so compare where it leads, not the marker alone. @@ -326,26 +332,14 @@ export const make = Effect.gen(function* () { ); if (!created) continue; // Our own link is swapped in place; anything else there makes symlink fail and stays. - const staging = `${link}.${yield* Random.nextIntBetween(0, 0xffffff)}.tmp`; const linked = yield* ( link === replaced - ? fs - .symlink(launcher, staging) - .pipe( - Effect.andThen( - fs - .rename(staging, link) - .pipe(Effect.tapError(() => fs.remove(staging).pipe(Effect.ignore))), - ), - ) - : fs.symlink(launcher, link) - ).pipe( - Effect.as(true), - Effect.orElseSucceed(() => false), - ); + ? replaceOurLink({ link, target: launcher }) + : fs.symlink(launcher, link).pipe(Effect.as(true)) + ).pipe(Effect.orElseSucceed(() => false)); if (!linked) continue; if (replaced !== undefined && replaced !== link) { - yield* fs.remove(replaced).pipe(Effect.ignore); + yield* replaceOurLink({ link: replaced }).pipe(Effect.ignore); } return yield* state; } @@ -367,7 +361,9 @@ export const make = Effect.gen(function* () { for (const directory of knownLocations(environment.homeDirectory, environment.platform)) { const link = path.join(directory, "t3"); if (yield* isOurLink(link)) { - yield* fs.remove(link).pipe(Effect.mapError(() => fail(`Could not remove ${link}.`))); + yield* replaceOurLink({ link }).pipe( + Effect.mapError(() => fail(`Could not remove ${link}.`)), + ); } } return yield* state; diff --git a/apps/server/src/cli/install.test.ts b/apps/server/src/cli/install.test.ts index 78192b21b87f3..dba9f3693fb89 100644 --- a/apps/server/src/cli/install.test.ts +++ b/apps/server/src/cli/install.test.ts @@ -54,10 +54,12 @@ it.layer(NodeServices.layer)("t3 install", (it) => { const { root, runtimeDir, executable, link, provide } = yield* setup; const older = path.join(runtimeDir, "versions/0.0.1/t3"); + const commandLink = path.join(runtimeDir, "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.equal(yield* fs.readLink(link), commandLink); + assert.equal(yield* fs.readLink(commandLink), executable); assert.isFalse(installed.onPath); assert.include(installed.pathCommand, "$HOME/bin"); @@ -96,31 +98,32 @@ it.layer(NodeServices.layer)("t3 install", (it) => { yield* fs.remove(link); yield* fs.symlink(path.join(root, ".t3/bin/t3"), link); yield* provide(installCli()); - assert.equal(yield* fs.readLink(link), executable); + assert.equal(yield* fs.readLink(link), commandLink); - // 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); + // Another program may write its t3 at any moment, here at the first symlink this + // install makes. Whether the spot was empty, held an older link, or the command + // link, that file is kept. const racing = installCli().pipe( Effect.provideService(FileSystem.FileSystem, { ...fs, - symlink: (target, path) => + symlink: (target, to) => fs - .writeFileString(path, "#!/bin/sh\necho raced\n") - .pipe(Effect.andThen(fs.symlink(target, path))), + .writeFileString(`${link}.new`, "#!/bin/sh\necho raced\n") + .pipe( + Effect.andThen(fs.rename(`${link}.new`, link)), + Effect.andThen(fs.symlink(target, to)), + ), }), ); - assert.include((yield* provide(racing).pipe(Effect.flip)).message, "Could not link"); - assert.equal(yield* fs.readFileString(link), "#!/bin/sh\necho raced\n"); + for (const before of [undefined, older, commandLink]) { + yield* fs.remove(link); + if (before !== undefined) yield* fs.symlink(before, link); + const raced = yield* provide(racing).pipe(Effect.result); + assert.equal(yield* fs.readFileString(link), "#!/bin/sh\necho raced\n", before); + assert.deepStrictEqual(yield* fs.readDirectory(path.dirname(link)), ["t3"]); + // Nothing was replaced over the command link's own spot, so that install succeeds. + assert.equal(raced._tag === "Success", before === commandLink, before); + } yield* fs.remove(link); // Windows writes a t3.cmd: only over its own, and only for paths cmd can run. @@ -231,7 +234,7 @@ it.layer(NodeServices.layer)("t3 install", (it) => { 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); + assert.equal(yield* fs.realPath(link), yield* fs.realPath(executable)); yield* fs.remove(path.join(runtimeDir, "install-prompt-declined")); yield* offer(true); diff --git a/apps/server/src/cli/install.ts b/apps/server/src/cli/install.ts index b5d719ceb1bc3..ffc661c40a884 100644 --- a/apps/server/src/cli/install.ts +++ b/apps/server/src/cli/install.ts @@ -1,4 +1,5 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; +import { replaceOwnedLink } from "@t3tools/shared/ownedLink"; import { pathSetupCommand } from "@t3tools/shared/shell"; import * as Console from "effect/Console"; import * as Duration from "effect/Duration"; @@ -12,7 +13,12 @@ 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 } from "../cloud/pinnedRuntime.ts"; +import { + generatedWindowsShimTarget, + isVersionExecutable, + pinnedRuntimeCommandLink, + pinnedRuntimeVersionsDir, +} from "../cloud/pinnedRuntime.ts"; import { detectCliRunner, resolveCliCommand, resolveEntryPath } from "./invocation.ts"; export class CliInstallError extends Schema.TaggedError()("CliInstallError", { @@ -37,7 +43,86 @@ const standaloneRuntime = Effect.gen(function* () { return Option.some({ executable, versionsDir, runtimeDir }); }); -/** Links this runtime as the `t3` command the way the install scripts do, replacing only our own link. */ +/** + * What `link` is to this T3 home: missing, its command link, an older link of this home (straight + * to a version's t3, or the desktop app's launcher), or another program's. + */ +const classifyCommandLink = Effect.fn("cli.install.classify_command_link")(function* ( + link: string, + baseDir: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // A link through a symlinked T3 home names the alias, so compare real folders as well. + const real = (file: string) => + fs.realPath(path.dirname(file)).pipe( + Effect.map((directory) => path.join(directory, path.basename(file))), + Effect.orElseSucceed(() => file), + ); + const commandLink = pinnedRuntimeCommandLink(path, baseDir); + const realCommandLink = yield* real(commandLink); + if ((yield* real(link)) === realCommandLink) return "current"; + const text = yield* fs.readLink(link).pipe(Effect.option); + if (Option.isNone(text)) { + return (yield* fs.exists(link).pipe(Effect.orElseSucceed(() => true))) ? "foreign" : "missing"; + } + const target = path.resolve(path.dirname(link), text.value); + const realTarget = yield* real(target); + if (target === commandLink || realTarget === realCommandLink) return "current"; + const versionsDir = pinnedRuntimeVersionsDir(path, baseDir); + const realVersionsDir = yield* fs + .realPath(versionsDir) + .pipe(Effect.orElseSucceed(() => versionsDir)); + const desktopLauncher = path.join(baseDir, "bin", "t3"); + const realDesktopLauncher = yield* real(desktopLauncher); + // Fully resolved too; a dangling link (a pruned version) keeps its own text. + const resolved = yield* fs.realPath(link).pipe(Effect.orElseSucceed(() => target)); + const older = [target, realTarget, resolved].some( + (candidate) => + isVersionExecutable(path, versionsDir, candidate) || + isVersionExecutable(path, realVersionsDir, candidate) || + candidate === desktopLauncher || + candidate === realDesktopLauncher, + ); + return older ? "older" : "foreign"; +}); + +/** + * Points this home's command link at `executable`, then makes `link` lead to it. `link` is only + * created where nothing is, or moved over from an older link of this home. Returns false when it + * belongs to another program; nothing changes then. + */ +export const linkCommand = Effect.fn("cli.install.link_command")(function* (input: { + readonly link: string; + readonly baseDir: string; + readonly executable: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const kind = yield* classifyCommandLink(input.link, input.baseDir); + if (kind === "foreign") return false; + const commandLink = pinnedRuntimeCommandLink(path, input.baseDir); + // Inside the T3 home, so swapping it in place cannot replace another program's file. + const staging = `${commandLink}.${process.pid}.tmp`; + yield* fs + .remove(staging, { force: true }) + .pipe( + Effect.andThen(fs.symlink(input.executable, staging)), + Effect.andThen(fs.rename(staging, commandLink)), + ); + if (kind === "missing") yield* fs.symlink(commandLink, input.link); + if (kind !== "older") return true; + return yield* replaceOwnedLink({ + link: input.link, + target: commandLink, + owns: (candidate) => + classifyCommandLink(candidate, input.baseDir).pipe( + Effect.map((found) => found !== "foreign"), + ), + }); +}); + +/** Links this runtime as the `t3` command the way the install scripts do, never over another t3. */ export const installCli = Effect.fn("cli.install.install")(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -69,64 +154,53 @@ export const installCli = Effect.fn("cli.install.install")(function* () { 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) { + // Resolve fully so a shim through a symlinked T3 home still counts as ours. + const realVersionsDir = yield* fs + .realPath(versionsDir) + .pipe(Effect.orElseSucceed(() => versionsDir)); + const current = yield* fs + .readFileString(link) + .pipe(Effect.map(generatedWindowsShimTarget), 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. 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}.`)), - ); + .makeDirectory(binDir, { recursive: true }) + .pipe(Effect.mapError(failed(`Could not create ${binDir}.`))); + const linked = yield* linkCommand({ link, baseDir: path.dirname(runtimeDir), executable }).pipe( + Effect.mapError(failed(`Could not link ${link}.`)), + ); + if (!linked) { + return yield* new CliInstallError({ + reason: `Another t3 is already at ${link}. Remove it, then run this again.`, + }); + } } // Pruning keeps what recorded launchers point at; a background service cannot see binDir otherwise. diff --git a/apps/server/src/cli/uninstall.test.ts b/apps/server/src/cli/uninstall.test.ts index 8d06f3b0c74f3..c04f8108b5054 100644 --- a/apps/server/src/cli/uninstall.test.ts +++ b/apps/server/src/cli/uninstall.test.ts @@ -32,6 +32,17 @@ it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => { yield* fs.symlink(otherExe, theirs); assert.equal(yield* findOwnedLauncher({ launchedAs: ours, versionsDir }), ours); + // Installs link to the command link, which points at the version. + const commandLink = path.join(root, "runtime/t3"); + const viaCommandLink = path.join(root, "bin/t3-command"); + yield* fs.symlink(exe, commandLink); + yield* fs.symlink(commandLink, viaCommandLink); + assert.equal( + yield* findOwnedLauncher({ launchedAs: viaCommandLink, versionsDir }), + viaCommandLink, + ); + // A version run directly is not a launcher. + assert.isUndefined(yield* findOwnedLauncher({ launchedAs: exe, versionsDir })); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: theirs, versionsDir })); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: copy, versionsDir })); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: undefined, versionsDir })); @@ -61,13 +72,40 @@ it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => { // 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); + // The command link leads to a pruned version, so only its name says it is ours. + const otherRoot = path.join(root, "pruned-home"); + const prunedCommand = path.join(root, "bin/t3-pruned-command"); + yield* fs.makeDirectory(path.join(otherRoot, "runtime/versions"), { recursive: true }); + yield* fs.symlink( + path.join(otherRoot, "runtime/versions/0.9.0/t3"), + path.join(otherRoot, "runtime/t3"), + ); + yield* fs.symlink(path.join(otherRoot, "runtime/t3"), prunedCommand); + yield* fs.writeFileString(path.join(otherRoot, "runtime/launchers"), `${prunedCommand}\n`); + assert.deepStrictEqual( + yield* findRecordedLaunchers(path.join(otherRoot, "runtime/versions")), + [prunedCommand], + ); yield* fs.writeFileString( path.join(root, "runtime/launchers"), - [ours, theirs, copy, throughAlias, pruned, sibling, path.join(root, "missing")].join("\n") + - "\n", + [ + ours, + theirs, + copy, + viaCommandLink, + 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]); + assert.deepStrictEqual(yield* findRecordedLaunchers(dir), [ + ours, + viaCommandLink, + throughAlias, + pruned, + ]); } // On Windows only an unedited t3.cmd is ours; a hand-edited wrapper belongs to the user. diff --git a/apps/server/src/cli/uninstall.ts b/apps/server/src/cli/uninstall.ts index 5cf6f0c5e64cc..cbf2c52bb42cd 100644 --- a/apps/server/src/cli/uninstall.ts +++ b/apps/server/src/cli/uninstall.ts @@ -3,6 +3,7 @@ import * as NodeChildProcess from "node:child_process"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { replaceOwnedLink } from "@t3tools/shared/ownedLink"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -56,13 +57,10 @@ export const findOwnedLauncher = Effect.fn("cli.uninstall.find_launcher")(functi ? 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 isVersionExecutable(path, input.versionsDir, resolved) ? input.launchedAs : undefined; + return (yield* ownsLauncher(input.launchedAs, input.versionsDir)) ? input.launchedAs : undefined; }); -/** Whether `launcher` is a link or unedited `t3.cmd` into this home's runtime/versions. */ +/** Whether `launcher` is a link to this home's command link or a version, or an unedited `t3.cmd`. */ const ownsLauncher = Effect.fn("cli.uninstall.owns_launcher")(function* ( launcher: string, versionsDir: string, @@ -73,8 +71,9 @@ const ownsLauncher = Effect.fn("cli.uninstall.owns_launcher")(function* ( const realVersionsDir = yield* fs .realPath(versionsDir) .pipe(Effect.orElseSucceed(() => versionsDir)); + const linkText = yield* fs.readLink(launcher).pipe(Effect.option); // The link's own text keeps a pruned (dangling) target; the real path sees through a - // symlinked T3 home. + // symlinked T3 home. Only a link counts: a version's own executable resolves into versions too. const targets = platform === "win32" ? [ @@ -83,18 +82,22 @@ const ownsLauncher = Effect.fn("cli.uninstall.owns_launcher")(function* ( 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)), - ]; + : Option.isNone(linkText) + ? [] + : [ + path.resolve(path.dirname(launcher), linkText.value), + yield* fs.realPath(launcher).pipe(Effect.orElseSucceed(() => undefined)), + ]; + // The command link sits beside versions; a link to it may dangle after a prune. + const commandLinks = [versionsDir, realVersionsDir].map((dir) => + path.join(path.dirname(dir), "t3"), + ); return targets.some( (target) => target !== undefined && (isVersionExecutable(path, versionsDir, target) || - isVersionExecutable(path, realVersionsDir, target)), + isVersionExecutable(path, realVersionsDir, target) || + (platform !== "win32" && commandLinks.includes(target))), ); }); @@ -246,17 +249,18 @@ const runUninstall = Effect.fn("cli.uninstall.run")(function* (input: { } 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(launcher, { force: true }) - .pipe( + const owns = (candidate: string) => ownsLauncher(candidate, plan.versionsDir); + const removed = + (yield* owns(launcher)) && + (yield* replaceOwnedLink({ link: launcher, owns }).pipe( Effect.mapError( () => new CliUninstallError({ reason: `Could not remove the launcher at ${launcher}.` }), ), - ); + )); + if (!removed) { + yield* Console.log(`Left ${launcher}: it no longer runs this t3.`); + continue; + } yield* Console.log(`Removed ${launcher}.`); } if (plan.runtimeDir !== undefined) { diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index e6a6eb5ee1dbf..6b18a6a721daa 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -36,29 +36,53 @@ it.effect("authenticates each release page until the requested channel is found" ); it.layer(NodeServices.layer)("t3 update launcher", (it) => { - it.effect("repoints a symlink that lives in a runtime versions tree", () => + it.effect("moves an older launcher to the command link, then switches only that link", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" }); const oldExe = path.join(root, "runtime/versions/1.0.0/t3"); const newExe = path.join(root, "runtime/versions/2.0.0/t3"); + const newerExe = path.join(root, "runtime/versions/3.0.0/t3"); + const commandLink = path.join(root, "runtime/t3"); const launcher = path.join(root, "bin/t3"); - for (const file of [oldExe, newExe]) { + for (const file of [oldExe, newExe, newerExe]) { yield* fs.makeDirectory(path.dirname(file), { recursive: true }); yield* fs.writeFileString(file, ""); } yield* fs.makeDirectory(path.dirname(launcher), { recursive: true }); yield* fs.symlink(oldExe, launcher); + const update = (targetEntryPath: string) => + repointLauncher({ + launchedAs: launcher, + versionsDir: path.join(root, "runtime/versions"), + targetEntryPath, + }); - const repointed = yield* repointLauncher({ - launchedAs: launcher, - versionsDir: path.join(root, "runtime/versions"), - targetEntryPath: newExe, - }); + assert.deepStrictEqual(Option.getOrUndefined(yield* update(newExe)), launcher); + assert.equal(yield* fs.readLink(launcher), commandLink); + assert.equal(yield* fs.readLink(commandLink), newExe); - assert.deepStrictEqual(Option.getOrUndefined(repointed), launcher); - assert.equal(yield* fs.readLink(launcher), newExe); + // Once linked, nothing is written in the public folder, where other programs write too. + const written: Array = []; + const recording = FileSystem.FileSystem.of({ + ...fs, + rename: (from, to) => + Effect.sync(() => written.push(from, to)).pipe(Effect.andThen(fs.rename(from, to))), + symlink: (target, to) => + Effect.sync(() => written.push(to)).pipe(Effect.andThen(fs.symlink(target, to))), + remove: (file, options) => + Effect.sync(() => written.push(file)).pipe(Effect.andThen(fs.remove(file, options))), + }); + const switched = yield* update(newerExe).pipe( + Effect.provideService(FileSystem.FileSystem, recording), + ); + assert.deepStrictEqual(Option.getOrUndefined(switched), launcher); + assert.equal(yield* fs.readLink(commandLink), newerExe); + assert.deepStrictEqual( + written.filter((file) => file.startsWith(path.dirname(launcher))), + [], + ); }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), ); diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index 08d77d309b3c6..560ce6ece512e 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -35,6 +35,7 @@ import * as ProcessRunner from "../processRunner.ts"; import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; import { createUpdateProgress } from "./updateProgress.ts"; +import { linkCommand } from "./install.ts"; import * as CliService from "./service.ts"; export class CliUpdateError extends Schema.TaggedError()("CliUpdateError", { @@ -92,8 +93,9 @@ export const resolveNewestVersion = Effect.fn("cli.update.resolve_newest")(funct }); /** - * Only a launcher that already points into this home's `runtime/versions` tree is touched; a - * plain copy of the executable, or a launcher for some other install, is left alone. + * Only a launcher of this home is touched: on Windows a shim into its `runtime/versions`, elsewhere + * a link to its command link, which is switched, or an older link, which moves to it. A plain copy + * of the executable, or a launcher for some other install, is left alone. */ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function* (input: { readonly launchedAs: string | undefined; @@ -124,19 +126,21 @@ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function return Option.some(shimPath); } - const linkTarget = yield* fs.readLink(input.launchedAs).pipe(Effect.option); - if (Option.isNone(linkTarget)) return Option.none(); - const resolvedTarget = path.resolve(path.dirname(input.launchedAs), linkTarget.value); - if (!ownsTarget(resolvedTarget)) return Option.none(); - const tempLink = `${input.launchedAs}.${process.pid}.tmp`; - yield* fs.symlink(input.targetEntryPath, tempLink).pipe( - Effect.andThen(fs.rename(tempLink, input.launchedAs)), + // A plain copy of the executable is the user's own. + if (Option.isNone(yield* fs.readLink(input.launchedAs).pipe(Effect.option))) { + return Option.none(); + } + const linked = yield* linkCommand({ + link: input.launchedAs, + baseDir: path.dirname(path.dirname(input.versionsDir)), + executable: input.targetEntryPath, + }).pipe( Effect.mapError( () => new CliUpdateError({ reason: `Could not repoint the t3 launcher at ${input.launchedAs}.` }), ), ); - return Option.some(input.launchedAs); + return linked ? Option.some(input.launchedAs) : Option.none(); }); /** Node preserves argv0's spelling; resolve bare names on PATH to find the launcher symlink. */ diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index b1779185162b2..9f0e7d882fb23 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -388,6 +388,8 @@ it.layer(NodeServices.layer)("prunePinnedRuntimes", (it) => { yield* fs.symlink(path.join(versionsDir, "1.2.0", "t3"), path.join(baseDir, "alias-t3")); yield* fs.symlink(path.join(baseDir, "alias-t3"), path.join(binDir, "t3")); yield* fs.symlink(path.join(versionsDir, "1.2.5", "t3"), path.join(customBinDir, "t3")); + // The command link, which no launcher that can be found leads to. + yield* fs.symlink(path.join(versionsDir, "1.1.0", "t3"), path.join(baseDir, "runtime", "t3")); yield* fs.writeFileString( path.join(baseDir, "runtime", "launchers"), `${path.join(customBinDir, "t3")}\n`, @@ -414,10 +416,10 @@ it.layer(NodeServices.layer)("prunePinnedRuntimes", (it) => { Effect.provideService(HostProcess.Environment, { T3CODE_INSTALL_BIN_DIR: binDir }), ); - // 1.2.0 is the default launcher, 1.2.5 a recorded one, 1.3.0 running, 1.3.5 in the unit, - // 1.4.0 active, 1.5.0 the rolled-back target, 2.0.0 the caller's, 0.5.0 just installed. - // Being newer than the caller's version does not keep 2.1.0. - assert.deepEqual(removed.toSorted(), ["0.8.0", "1.0.0", "1.1.0", "2.1.0"]); + // 1.1.0 is the command link's, 1.2.0 the default launcher's, 1.2.5 a recorded one's, 1.3.0 + // running, 1.3.5 in the unit, 1.4.0 active, 1.5.0 the rolled-back target, 2.0.0 the + // caller's, 0.5.0 just installed. Being newer than the caller's version does not keep 2.1.0. + assert.deepEqual(removed.toSorted(), ["0.8.0", "1.0.0", "2.1.0"]); // The launcher found on PATH is remembered for a service that cannot see it. assert.equal( yield* fs.readFileString(path.join(baseDir, "runtime", "launchers")), @@ -428,6 +430,7 @@ it.layer(NodeServices.layer)("prunePinnedRuntimes", (it) => { "0.5.0", "0.9.0", "1.0.1", + "1.1.0", "1.2.0", "1.2.5", "1.3.0", diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 8ed3b9008e591..65e3e606e156a 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -49,6 +49,14 @@ export function pinnedRuntimeVersionsDir(path: Path.Path, baseDir: string): stri return path.join(baseDir, PINNED_RUNTIME_DIR, "versions"); } +/** + * `/runtime/t3`, the link the public `t3` command points at. Upgrades switch only this + * link, so the public name, where other programs write too, is never replaced. + */ +export function pinnedRuntimeCommandLink(path: Path.Path, baseDir: string): string { + return path.join(baseDir, PINNED_RUNTIME_DIR, "t3"); +} + export function pinnedRuntimePaths( path: Path.Path, baseDir: string, @@ -430,6 +438,11 @@ const shellLauncherTargets = Effect.fn("cloud.pinned_runtime.shell_launcher_targ .pipe(Effect.orElseSucceed(() => versionsDir)); const unrecorded: Array = []; const targets: Array = []; + // The command link is always this home's, even when no public launcher was found. + const commandLink = yield* fs + .realPath(pinnedRuntimeCommandLink(path, baseDir)) + .pipe(Effect.option); + if (Option.isSome(commandLink)) targets.push(commandLink.value); for (const launcher of [...recorded, ...found]) { const target = platform === "win32" diff --git a/packages/shared/package.json b/packages/shared/package.json index e3d0b2ce57cf7..0b5d4b7b9b276 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -499,6 +499,10 @@ "types": "./src/atomicWrite.ts", "import": "./src/atomicWrite.ts" }, + "./ownedLink": { + "types": "./src/ownedLink.ts", + "import": "./src/ownedLink.ts" + }, "./mcpResponsePayloads": { "types": "./src/mcpResponsePayloads.ts", "import": "./src/mcpResponsePayloads.ts" diff --git a/packages/shared/src/ownedLink.ts b/packages/shared/src/ownedLink.ts new file mode 100644 index 0000000000000..12ad37adddd80 --- /dev/null +++ b/packages/shared/src/ownedLink.ts @@ -0,0 +1,32 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Random from "effect/Random"; + +/** + * Replaces `link` with a symlink to `target`, or removes it without a `target`, when `owns` holds + * for what is there. Another program can write `link` after any earlier check, so the entry is + * renamed aside and checked there. Returns false when it was not ours; it is then put back + * without overwriting anything newer, or left beside `link` as `..old` if that fails. + */ +export const replaceOwnedLink = Effect.fn("ownedLink.replace")(function* (input: { + readonly link: string; + readonly target?: string; + readonly owns: (path: string) => Effect.Effect; +}) { + const fs = yield* FileSystem.FileSystem; + const aside = `${input.link}.${process.pid}.${yield* Random.nextIntBetween(0, 0xffffff)}.old`; + yield* fs.rename(input.link, aside); + if (!(yield* input.owns(aside))) { + const text = yield* fs.readLink(aside).pipe(Effect.option); + yield* ( + Option.isSome(text) ? fs.symlink(text.value, input.link) : fs.link(aside, input.link) + ).pipe(Effect.andThen(fs.remove(aside))); + return false; + } + // Something written after the rename keeps the name; this home's old link goes either way. + yield* (input.target === undefined ? Effect.void : fs.symlink(input.target, input.link)).pipe( + Effect.ensuring(fs.remove(aside).pipe(Effect.ignore)), + ); + return true; +}); diff --git a/scripts/install.sh b/scripts/install.sh index e4b21de8c3e14..6578d586e9f3f 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -15,6 +15,8 @@ # The archive is unpacked into $T3CODE_HOME/runtime/versions/, the # same layout `t3 service install` uses, so the service reuses this download # instead of fetching the release again. +# The `t3` symlink points at $T3CODE_HOME/runtime/t3, which points at that +# version, so upgrades never replace a file in the bin folder. set -eu repo="pingdotgg/t3code" @@ -235,47 +237,84 @@ fi step "Setting up the t3 command..." mkdir -p "$bin_dir" -# 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. +public="${bin_dir}/t3" +# The t3 command links to this home's command link, and upgrades switch only that one. Other +# programs write to bin_dir too, so the public name is never replaced while it may be theirs. +command_link="${t3_home}/runtime/t3" +real_versions_dir="$(CDPATH='' cd -P -- "$versions_dir" && pwd)" +real_command_link="$(CDPATH='' cd -P -- "${t3_home}/runtime" && pwd)/t3" # 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 +# Prints what the link at $1 is to this home: current (its command link), older (a version's t3 +# or the desktop app's launcher), or nothing. Anything that is not a symlink is someone else's. +link_kind() { + [ -L "$1" ] || return 0 + existing="$(readlink "$1" 2>/dev/null || true)" + case "$existing" in /*) ;; *) existing="${bin_dir}/${existing}" ;; esac + # 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)" + real_existing="${existing_dir}/${existing##*/}" + # The text alone counts only without . or .. segments (a pruned version leaves a dangling link). + case "$existing" in + */./* | */../* | */. | */..) ;; + "$command_link") echo current; return 0 ;; + "${t3_home}/bin/t3") echo older; return 0 ;; + *) + if version_t3 "$existing" "$versions_dir" || version_t3 "$existing" "$real_versions_dir"; then + echo older; return 0 + fi + ;; + esac + [ -n "$existing_dir" ] || return 0 + if [ "$real_existing" = "$real_command_link" ]; then echo current + elif version_t3 "$real_existing" "$real_versions_dir"; then echo older 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 - ln -sfn "${target_dir}/t3" "${bin_dir}/t3" +} +kind= +real_public="$(CDPATH='' cd -P -- "$bin_dir" && pwd)/t3" +# A bin folder of runtime itself would make the command link point at itself. +if [ "$real_public" = "$real_command_link" ]; then + kind=current +elif [ -e "$public" ] || [ -L "$public" ]; then + kind="$(link_kind "$public")" + [ -n "$kind" ] || fail "another t3 is already at ${public}. Remove it and run this again, or run ${target_dir}/t3 directly." fi +# Inside the T3 home, so swapping it in place cannot replace another program's file. +rm -f "${command_link}.$$.tmp" +ln -s "${target_dir}/t3" "${command_link}.$$.tmp" && mv -f "${command_link}.$$.tmp" "$command_link" || + fail "could not link ${command_link}" +case "$kind" in + # An empty spot is linked without -f, so a file that appears after the check above is kept. + "") ln -s "$command_link" "$public" || fail "could not link ${public}; something else was created there." ;; + older) + # Moved aside before it is checked again, so the check sees exactly what gets replaced. + aside="${bin_dir}/.t3.$$.old" + mv -f "$public" "$aside" || fail "could not replace ${public}" + if [ -n "$(link_kind "$aside")" ]; then + linked=true + ln -s "$command_link" "$public" || linked=false + rm -f "$aside" + "$linked" || fail "could not link ${public}; something else was created there." + else + # Another program wrote it after the check: put it back without overwriting anything newer. + restored=false + if [ -L "$aside" ]; then + ln -s "$(readlink "$aside")" "$public" 2>/dev/null && restored=true + else + ln "$aside" "$public" 2>/dev/null && restored=true + fi + "$restored" || fail "another program wrote ${public} during the install; its file is now at ${aside}." + rm -f "$aside" + fail "another t3 was just written at ${public}. Remove it and run this again, or run ${target_dir}/t3 directly." + fi + ;; +esac # 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" +grep -qxF "$real_public" "$launchers" 2>/dev/null || printf '%s\n' "$real_public" >> "$launchers" if "$interactive"; then printf '\r\033[2K' >&2; fi printf ' %sInstalled T3 Code %s%s\n\n' "$green" "$version" "$reset" >&2 # The folder a shell reads its startup file from: $1 when set and safe to paste, else $2 when diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 393f81377852f..c3e503855fd4a 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -165,7 +165,8 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 env: { HOME: root, SHELL: "/bin/zsh", PATH: "/usr/bin:/bin" }, }); expect(upgrade.code, upgrade.output).toBe(0); - expect(await NodeFSP.readlink(link)).toBe( + expect(await NodeFSP.readlink(link)).toBe(NodePath.join(root, "alias/runtime/t3")); + expect(await NodeFSP.readlink(NodePath.join(home, "runtime/t3"))).toBe( NodePath.join(root, "alias/runtime/versions/1.2.3/t3"), ); expect(upgrade.output).toContain( @@ -201,7 +202,63 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 binDir: `${binDir}/`, }); expect(overDesktop.code, overDesktop.output).toBe(0); - expect(await NodeFSP.readlink(link)).toBe(NodePath.join(home, "runtime/versions/1.2.3/t3")); + expect(await NodeFSP.readlink(link)).toBe(NodePath.join(home, "runtime/t3")); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); + + it("never overwrites a t3 another program writes during the install", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-race-")); + try { + const home = NodePath.join(root, "home"); + const binDir = NodePath.join(root, "bin"); + const link = NodePath.join(binDir, "t3"); + const commandLink = NodePath.join(home, "runtime/t3"); + const tools = NodePath.join(root, "tools"); + await NodeFSP.mkdir(binDir, { recursive: true }); + await NodeFSP.mkdir(tools); + // Before the installer's first `ln`, another program writes its own t3 in one rename. + const realLn = NodeChildProcess.execFileSync("sh", ["-c", "command -v ln"], { + encoding: "utf8", + }).trim(); + await NodeFSP.writeFile( + NodePath.join(tools, "ln"), + `#!/bin/sh +if [ -n "$RACE" ] && [ ! -e "$RACE.fired" ]; then + : > "$RACE.fired" + printf 'theirs\\n' > "$RACE.new" && mv -f "$RACE.new" "$RACE" +fi +exec ${realLn} "$@" +`, + { mode: 0o755 }, + ); + const racing = (env: NodeJS.ProcessEnv = {}) => + installOver({ + script: "install.sh", + home, + binDir, + env: { PATH: `${tools}:${process.env.PATH}`, RACE: link, ...env }, + }); + + // An older link straight to a version moves to the command link only if it is still ours. + await NodeFSP.symlink(NodePath.join(home, "runtime/versions/1.0.0/t3"), link); + const older = await racing(); + expect(older.code).not.toBe(0); + expect(await NodeFSP.readFile(link, "utf8")).toBe("theirs\n"); + expect(await NodeFSP.readdir(binDir)).toEqual(["t3", "t3.fired"]); + + // Once linked, an upgrade switches only the command link and never writes the public name. + await NodeFSP.rm(link); + await NodeFSP.rm(`${link}.fired`); + await NodeFSP.symlink(commandLink, link); + const calm = await racing({ RACE: "" }); + expect(calm.code, calm.output).toBe(0); + expect(await NodeFSP.readlink(link)).toBe(commandLink); + expect(await NodeFSP.readlink(commandLink)).toBe(NodePath.join(calm.target, "t3")); + const current = await racing(); + expect(current.code, current.output).toBe(0); + expect(await NodeFSP.readFile(link, "utf8")).toBe("theirs\n"); } finally { await NodeFSP.rm(root, { recursive: true, force: true }); } From d2568eb8d7bd5953ff918f26502bc74b0014f670 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 03:22:28 -0700 Subject: [PATCH 02/23] fix(cli): reserve the aside name and claim only direct links Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/app/DesktopCliCommand.ts | 2 ++ apps/server/src/cli/install.test.ts | 24 ++++++++++++++-------- apps/server/src/cli/install.ts | 5 ++--- apps/server/src/cli/uninstall.test.ts | 8 +++++++- apps/server/src/cli/uninstall.ts | 18 ++++++++++++---- apps/server/src/cli/update.test.ts | 10 ++++++++- packages/shared/src/ownedLink.ts | 25 +++++++++++++++-------- scripts/install.sh | 10 +++++---- 8 files changed, 72 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 1972ee2eb73bf..28a5708e62bdd 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -7,6 +7,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; @@ -112,6 +113,7 @@ export const make = Effect.gen(function* () { const replaceOurLink = (input: { readonly link: string; readonly target?: string }) => replaceOwnedLink({ ...input, owns: isOurLink }).pipe( Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), ); // PATH can reach the regular launcher directly; removal still requires isOurLink. diff --git a/apps/server/src/cli/install.test.ts b/apps/server/src/cli/install.test.ts index dba9f3693fb89..a142f3f714e92 100644 --- a/apps/server/src/cli/install.test.ts +++ b/apps/server/src/cli/install.test.ts @@ -100,25 +100,31 @@ it.layer(NodeServices.layer)("t3 install", (it) => { yield* provide(installCli()); assert.equal(yield* fs.readLink(link), commandLink); - // Another program may write its t3 at any moment, here at the first symlink this - // install makes. Whether the spot was empty, held an older link, or the command - // link, that file is kept. + // Another program may write its t3 at any moment, here once, at the first symlink this + // install makes. Whether the spot was empty, held an older link, or the command link, + // that file is kept. + let fired = false; const racing = installCli().pipe( Effect.provideService(FileSystem.FileSystem, { ...fs, symlink: (target, to) => - fs - .writeFileString(`${link}.new`, "#!/bin/sh\necho raced\n") - .pipe( - Effect.andThen(fs.rename(`${link}.new`, link)), - Effect.andThen(fs.symlink(target, to)), - ), + (fired + ? Effect.void + : fs + .writeFileString(`${link}.new`, "#!/bin/sh\necho raced\n") + .pipe( + Effect.andThen(fs.rename(`${link}.new`, link)), + Effect.andThen(Effect.sync(() => (fired = true))), + ) + ).pipe(Effect.andThen(fs.symlink(target, to))), }), ); for (const before of [undefined, older, commandLink]) { + fired = false; yield* fs.remove(link); if (before !== undefined) yield* fs.symlink(before, link); const raced = yield* provide(racing).pipe(Effect.result); + assert.isTrue(fired); assert.equal(yield* fs.readFileString(link), "#!/bin/sh\necho raced\n", before); assert.deepStrictEqual(yield* fs.readDirectory(path.dirname(link)), ["t3"]); // Nothing was replaced over the command link's own spot, so that install succeeds. diff --git a/apps/server/src/cli/install.ts b/apps/server/src/cli/install.ts index ffc661c40a884..4e3b257d74eda 100644 --- a/apps/server/src/cli/install.ts +++ b/apps/server/src/cli/install.ts @@ -75,9 +75,8 @@ const classifyCommandLink = Effect.fn("cli.install.classify_command_link")(funct .pipe(Effect.orElseSucceed(() => versionsDir)); const desktopLauncher = path.join(baseDir, "bin", "t3"); const realDesktopLauncher = yield* real(desktopLauncher); - // Fully resolved too; a dangling link (a pruned version) keeps its own text. - const resolved = yield* fs.realPath(link).pipe(Effect.orElseSucceed(() => target)); - const older = [target, realTarget, resolved].some( + // Only the direct target: a chain through another tool's link is that tool's. + const older = [target, realTarget].some( (candidate) => isVersionExecutable(path, versionsDir, candidate) || isVersionExecutable(path, realVersionsDir, candidate) || diff --git a/apps/server/src/cli/uninstall.test.ts b/apps/server/src/cli/uninstall.test.ts index c04f8108b5054..034bae60d9802 100644 --- a/apps/server/src/cli/uninstall.test.ts +++ b/apps/server/src/cli/uninstall.test.ts @@ -41,8 +41,14 @@ it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => { yield* findOwnedLauncher({ launchedAs: viaCommandLink, versionsDir }), viaCommandLink, ); - // A version run directly is not a launcher. + // A version run directly is not a launcher, nor another tool's link that selects it. assert.isUndefined(yield* findOwnedLauncher({ launchedAs: exe, versionsDir })); + const managed = path.join(root, "manager/t3"); + const managedBin = path.join(root, "bin/t3-managed"); + yield* fs.makeDirectory(path.dirname(managed), { recursive: true }); + yield* fs.symlink(exe, managed); + yield* fs.symlink(managed, managedBin); + assert.isUndefined(yield* findOwnedLauncher({ launchedAs: managedBin, versionsDir })); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: theirs, versionsDir })); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: copy, versionsDir })); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: undefined, versionsDir })); diff --git a/apps/server/src/cli/uninstall.ts b/apps/server/src/cli/uninstall.ts index cbf2c52bb42cd..406e46bc6e500 100644 --- a/apps/server/src/cli/uninstall.ts +++ b/apps/server/src/cli/uninstall.ts @@ -72,8 +72,8 @@ const ownsLauncher = Effect.fn("cli.uninstall.owns_launcher")(function* ( .realPath(versionsDir) .pipe(Effect.orElseSucceed(() => versionsDir)); const linkText = yield* fs.readLink(launcher).pipe(Effect.option); - // The link's own text keeps a pruned (dangling) target; the real path sees through a - // symlinked T3 home. Only a link counts: a version's own executable resolves into versions too. + // The link's own text keeps a pruned (dangling) target; its real folder sees through a + // symlinked T3 home. Only a direct link counts: a chain through another tool's link is theirs. const targets = platform === "win32" ? [ @@ -86,7 +86,12 @@ const ownsLauncher = Effect.fn("cli.uninstall.owns_launcher")(function* ( ? [] : [ path.resolve(path.dirname(launcher), linkText.value), - yield* fs.realPath(launcher).pipe(Effect.orElseSucceed(() => undefined)), + yield* fs + .realPath(path.dirname(path.resolve(path.dirname(launcher), linkText.value))) + .pipe( + Effect.map((directory) => path.join(directory, path.basename(linkText.value))), + Effect.orElseSucceed(() => undefined), + ), ]; // The command link sits beside versions; a link to it may dangle after a prune. const commandLinks = [versionsDir, realVersionsDir].map((dir) => @@ -250,9 +255,14 @@ const runUninstall = Effect.fn("cli.uninstall.run")(function* (input: { for (const launcher of plan.launchers) { // The prompt can wait a long time; never delete a file that replaced the launcher meanwhile. const owns = (candidate: string) => ownsLauncher(candidate, plan.versionsDir); + // Windows keeps the checked removal; its shims are out of the moved-aside swap. const removed = (yield* owns(launcher)) && - (yield* replaceOwnedLink({ link: launcher, owns }).pipe( + (yield* ( + platform === "win32" + ? fs.remove(launcher, { force: true }).pipe(Effect.as(true)) + : replaceOwnedLink({ link: launcher, owns }) + ).pipe( Effect.mapError( () => new CliUninstallError({ reason: `Could not remove the launcher at ${launcher}.` }), ), diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index 6b18a6a721daa..a816b8f2ed8d6 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -105,8 +105,15 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { yield* fs.symlink(elsewhere, foreign); yield* fs.makeDirectory(path.dirname(otherLauncher), { recursive: true }); yield* fs.symlink(otherHome, otherLauncher); + // Another tool's link that happens to select one of our versions is still that tool's. + const managed = path.join(root, "manager/t3"); + const managedBin = path.join(root, "manager-bin/t3"); + yield* fs.makeDirectory(path.dirname(managed), { recursive: true }); + yield* fs.makeDirectory(path.dirname(managedBin), { recursive: true }); + yield* fs.symlink(newExe, managed); + yield* fs.symlink(managed, managedBin); - for (const launchedAs of [copy, foreign, otherLauncher, undefined]) { + for (const launchedAs of [copy, foreign, otherLauncher, managedBin, undefined]) { const repointed = yield* repointLauncher({ launchedAs, versionsDir: path.join(root, "runtime/versions"), @@ -116,6 +123,7 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { } assert.equal(yield* fs.readLink(foreign), elsewhere); assert.equal(yield* fs.readLink(otherLauncher), otherHome); + assert.equal(yield* fs.readLink(managedBin), managed); }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), ); diff --git a/packages/shared/src/ownedLink.ts b/packages/shared/src/ownedLink.ts index 12ad37adddd80..cb492430da461 100644 --- a/packages/shared/src/ownedLink.ts +++ b/packages/shared/src/ownedLink.ts @@ -1,13 +1,13 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; -import * as Random from "effect/Random"; +import * as Path from "effect/Path"; /** * Replaces `link` with a symlink to `target`, or removes it without a `target`, when `owns` holds * for what is there. Another program can write `link` after any earlier check, so the entry is - * renamed aside and checked there. Returns false when it was not ours; it is then put back - * without overwriting anything newer, or left beside `link` as `..old` if that fails. + * moved aside and checked there. Returns false when it was not ours; it is then put back without + * overwriting anything newer, or left in a `.` folder beside `link` if that fails. */ export const replaceOwnedLink = Effect.fn("ownedLink.replace")(function* (input: { readonly link: string; @@ -15,18 +15,27 @@ export const replaceOwnedLink = Effect.fn("ownedLink.replace")(function* ( readonly owns: (path: string) => Effect.Effect; }) { const fs = yield* FileSystem.FileSystem; - const aside = `${input.link}.${process.pid}.${yield* Random.nextIntBetween(0, 0xffffff)}.old`; - yield* fs.rename(input.link, aside); + const path = yield* Path.Path; + // A new folder of its own, so nothing already there is ever replaced by the move. + const folder = yield* fs.makeTempDirectory({ + directory: path.dirname(input.link), + prefix: `${path.basename(input.link)}.`, + }); + const aside = path.join(folder, path.basename(input.link)); + const discard = fs.remove(aside).pipe(Effect.andThen(fs.remove(folder, { recursive: true }))); + yield* fs + .rename(input.link, aside) + .pipe(Effect.tapError(() => fs.remove(folder, { recursive: true }).pipe(Effect.ignore))); if (!(yield* input.owns(aside))) { const text = yield* fs.readLink(aside).pipe(Effect.option); yield* ( Option.isSome(text) ? fs.symlink(text.value, input.link) : fs.link(aside, input.link) - ).pipe(Effect.andThen(fs.remove(aside))); + ).pipe(Effect.andThen(discard)); return false; } - // Something written after the rename keeps the name; this home's old link goes either way. + // Something written after the move keeps the name; this home's old link goes either way. yield* (input.target === undefined ? Effect.void : fs.symlink(input.target, input.link)).pipe( - Effect.ensuring(fs.remove(aside).pipe(Effect.ignore)), + Effect.ensuring(discard.pipe(Effect.ignore)), ); return true; }); diff --git a/scripts/install.sh b/scripts/install.sh index 6578d586e9f3f..cf75e06bd3925 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -288,14 +288,16 @@ ln -s "${target_dir}/t3" "${command_link}.$$.tmp" && mv -f "${command_link}.$$.t fail "could not link ${command_link}" case "$kind" in # An empty spot is linked without -f, so a file that appears after the check above is kept. - "") ln -s "$command_link" "$public" || fail "could not link ${public}; something else was created there." ;; + # Into the folder, so an entry of any kind already named t3 makes ln fail instead of filling it. + "") ln -s "$command_link" "${bin_dir}/" || fail "could not link ${public}; something else was created there." ;; older) # Moved aside before it is checked again, so the check sees exactly what gets replaced. - aside="${bin_dir}/.t3.$$.old" - mv -f "$public" "$aside" || fail "could not replace ${public}" + # Reserved first, so an entry left by an earlier install is never replaced. + aside="$(mktemp "${bin_dir}/.t3.XXXXXX")" || fail "could not replace ${public}" + mv -f "$public" "$aside" || { rm -f "$aside"; fail "could not replace ${public}"; } if [ -n "$(link_kind "$aside")" ]; then linked=true - ln -s "$command_link" "$public" || linked=false + ln -s "$command_link" "${bin_dir}/" || linked=false rm -f "$aside" "$linked" || fail "could not link ${public}; something else was created there." else From 681e66e80d2d47ba8f62e84bf20a15780c1b05dc Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 03:27:12 -0700 Subject: [PATCH 03/23] fix(cli): keep the aside entry beside the link and skip dot targets Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/install.test.ts | 7 +++++++ apps/server/src/cli/install.ts | 13 ++++++------- apps/server/src/cli/uninstall.test.ts | 7 +++++++ apps/server/src/cli/uninstall.ts | 17 +++-------------- apps/server/src/cloud/pinnedRuntime.ts | 26 ++++++++++++++++++++++++++ packages/shared/src/ownedLink.ts | 22 +++++++++++----------- 6 files changed, 60 insertions(+), 32 deletions(-) diff --git a/apps/server/src/cli/install.test.ts b/apps/server/src/cli/install.test.ts index a142f3f714e92..857f4044727a7 100644 --- a/apps/server/src/cli/install.test.ts +++ b/apps/server/src/cli/install.test.ts @@ -94,6 +94,13 @@ it.layer(NodeServices.layer)("t3 install", (it) => { 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"); + // A relative older link still resolves the same once moved aside. + const rootLink = path.join(root, "t3"); + yield* fs.symlink(`.t3/runtime/versions/${VERSION}/t3`, rootLink); + yield* provide(installCli(), { T3CODE_INSTALL_BIN_DIR: root }); + assert.equal(yield* fs.readLink(rootLink), commandLink); + yield* fs.remove(rootLink); + // 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); diff --git a/apps/server/src/cli/install.ts b/apps/server/src/cli/install.ts index 4e3b257d74eda..7a80c59718410 100644 --- a/apps/server/src/cli/install.ts +++ b/apps/server/src/cli/install.ts @@ -14,6 +14,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import packageJson from "../../package.json" with { type: "json" }; import { + directLinkTargets, generatedWindowsShimTarget, isVersionExecutable, pinnedRuntimeCommandLink, @@ -62,21 +63,19 @@ const classifyCommandLink = Effect.fn("cli.install.classify_command_link")(funct const commandLink = pinnedRuntimeCommandLink(path, baseDir); const realCommandLink = yield* real(commandLink); if ((yield* real(link)) === realCommandLink) return "current"; - const text = yield* fs.readLink(link).pipe(Effect.option); - if (Option.isNone(text)) { + // Only the direct target: a chain through another tool's link is that tool's. + const targets = yield* directLinkTargets(link); + if (targets.length === 0 && Option.isNone(yield* fs.readLink(link).pipe(Effect.option))) { return (yield* fs.exists(link).pipe(Effect.orElseSucceed(() => true))) ? "foreign" : "missing"; } - const target = path.resolve(path.dirname(link), text.value); - const realTarget = yield* real(target); - if (target === commandLink || realTarget === realCommandLink) return "current"; + if (targets.includes(commandLink) || targets.includes(realCommandLink)) return "current"; const versionsDir = pinnedRuntimeVersionsDir(path, baseDir); const realVersionsDir = yield* fs .realPath(versionsDir) .pipe(Effect.orElseSucceed(() => versionsDir)); const desktopLauncher = path.join(baseDir, "bin", "t3"); const realDesktopLauncher = yield* real(desktopLauncher); - // Only the direct target: a chain through another tool's link is that tool's. - const older = [target, realTarget].some( + const older = targets.some( (candidate) => isVersionExecutable(path, versionsDir, candidate) || isVersionExecutable(path, realVersionsDir, candidate) || diff --git a/apps/server/src/cli/uninstall.test.ts b/apps/server/src/cli/uninstall.test.ts index 034bae60d9802..63a5906a90f13 100644 --- a/apps/server/src/cli/uninstall.test.ts +++ b/apps/server/src/cli/uninstall.test.ts @@ -49,6 +49,13 @@ it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => { yield* fs.symlink(exe, managed); yield* fs.symlink(managed, managedBin); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: managedBin, versionsDir })); + // `..` after a symlinked folder leads where the folder does, not back into this home. + const hop = path.join(root, "runtime/hop"); + const hopped = path.join(root, "bin/t3-hopped"); + yield* fs.makeDirectory(path.join(root, "other/runtime/inner"), { recursive: true }); + yield* fs.symlink(path.join(root, "other/runtime/inner"), hop); + yield* fs.symlink(`${hop}/../versions/1.0.0/t3`, hopped); + assert.isUndefined(yield* findOwnedLauncher({ launchedAs: hopped, versionsDir })); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: theirs, versionsDir })); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: copy, versionsDir })); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: undefined, versionsDir })); diff --git a/apps/server/src/cli/uninstall.ts b/apps/server/src/cli/uninstall.ts index 406e46bc6e500..9d8d21bf97f75 100644 --- a/apps/server/src/cli/uninstall.ts +++ b/apps/server/src/cli/uninstall.ts @@ -14,6 +14,7 @@ import { Command, Flag, GlobalFlag, Prompt } from "effect/cli"; import * as BootService from "../cloud/bootService.ts"; import { + directLinkTargets, generatedWindowsShimTarget, isVersionExecutable, pinnedRuntimeVersionsDir, @@ -71,9 +72,7 @@ const ownsLauncher = Effect.fn("cli.uninstall.owns_launcher")(function* ( const realVersionsDir = yield* fs .realPath(versionsDir) .pipe(Effect.orElseSucceed(() => versionsDir)); - const linkText = yield* fs.readLink(launcher).pipe(Effect.option); - // The link's own text keeps a pruned (dangling) target; its real folder sees through a - // symlinked T3 home. Only a direct link counts: a chain through another tool's link is theirs. + // Only a direct link counts: a chain through another tool's link is theirs. const targets = platform === "win32" ? [ @@ -82,17 +81,7 @@ const ownsLauncher = Effect.fn("cli.uninstall.owns_launcher")(function* ( Effect.orElseSucceed(() => undefined), ), ] - : Option.isNone(linkText) - ? [] - : [ - path.resolve(path.dirname(launcher), linkText.value), - yield* fs - .realPath(path.dirname(path.resolve(path.dirname(launcher), linkText.value))) - .pipe( - Effect.map((directory) => path.join(directory, path.basename(linkText.value))), - Effect.orElseSucceed(() => undefined), - ), - ]; + : yield* directLinkTargets(launcher); // The command link sits beside versions; a link to it may dangle after a prune. const commandLinks = [versionsDir, realVersionsDir].map((dir) => path.join(path.dirname(dir), "t3"), diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 65e3e606e156a..7297efd6b0044 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -394,6 +394,32 @@ export function launcherOwnsVersionsDir( return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative); } +/** + * Where the symlink at `link` points, not following that target any further: its text, and the + * same with its folder resolved on disk, which sees through a symlinked T3 home. Empty for a + * non-link, or a target with `.` or `..`: Node resolves those as text, but the OS after a + * symlinked folder, and t3 never writes them. + */ +export const directLinkTargets = Effect.fn("cloud.pinned_runtime.direct_link_targets")(function* ( + link: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const text = yield* fs.readLink(link).pipe(Effect.option); + if ( + Option.isNone(text) || + text.value.split(/[\\/]/).some((part) => part === "." || part === "..") + ) { + return []; + } + const target = path.resolve(path.dirname(link), text.value); + const physical = yield* fs.realPath(path.dirname(target)).pipe( + Effect.map((directory) => [path.join(directory, path.basename(target))]), + Effect.orElseSucceed(() => []), + ); + return [target, ...physical]; +}); + /** Whether `candidate` is a version's own executable, `//t3[.exe]`. */ export function isVersionExecutable( path: Path.Path, diff --git a/packages/shared/src/ownedLink.ts b/packages/shared/src/ownedLink.ts index cb492430da461..017d4e11d222c 100644 --- a/packages/shared/src/ownedLink.ts +++ b/packages/shared/src/ownedLink.ts @@ -2,12 +2,13 @@ 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 Random from "effect/Random"; /** * Replaces `link` with a symlink to `target`, or removes it without a `target`, when `owns` holds * for what is there. Another program can write `link` after any earlier check, so the entry is * moved aside and checked there. Returns false when it was not ours; it is then put back without - * overwriting anything newer, or left in a `.` folder beside `link` if that fails. + * overwriting anything newer, or left beside `link` as `....old` if that fails. */ export const replaceOwnedLink = Effect.fn("ownedLink.replace")(function* (input: { readonly link: string; @@ -16,16 +17,15 @@ export const replaceOwnedLink = Effect.fn("ownedLink.replace")(function* ( }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - // A new folder of its own, so nothing already there is ever replaced by the move. - const folder = yield* fs.makeTempDirectory({ - directory: path.dirname(input.link), - prefix: `${path.basename(input.link)}.`, - }); - const aside = path.join(folder, path.basename(input.link)); - const discard = fs.remove(aside).pipe(Effect.andThen(fs.remove(folder, { recursive: true }))); - yield* fs - .rename(input.link, aside) - .pipe(Effect.tapError(() => fs.remove(folder, { recursive: true }).pipe(Effect.ignore))); + // A sibling, so a relative link resolves the same there, reserved so nothing left by an + // earlier run is replaced. A directory cannot be renamed over it, so one fails here. + const aside = path.join( + path.dirname(input.link), + `.${path.basename(input.link)}.${process.pid}.${yield* Random.nextIntBetween(0, 0x7fffffff)}.old`, + ); + yield* fs.writeFileString(aside, "", { flag: "wx" }); + const discard = fs.remove(aside); + yield* fs.rename(input.link, aside).pipe(Effect.tapError(() => discard.pipe(Effect.ignore))); if (!(yield* input.owns(aside))) { const text = yield* fs.readLink(aside).pipe(Effect.option); yield* ( From cff945dee89e3e3be829b99a0ad647a659a7e9fc Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 03:29:26 -0700 Subject: [PATCH 04/23] fix(cli): resolve link target folders with native realpath Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/install.test.ts | 5 +++++ apps/server/src/cloud/pinnedRuntime.ts | 29 +++++++++++++------------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/apps/server/src/cli/install.test.ts b/apps/server/src/cli/install.test.ts index 857f4044727a7..74a6aa3059f19 100644 --- a/apps/server/src/cli/install.test.ts +++ b/apps/server/src/cli/install.test.ts @@ -100,6 +100,11 @@ it.layer(NodeServices.layer)("t3 install", (it) => { yield* provide(installCli(), { T3CODE_INSTALL_BIN_DIR: root }); assert.equal(yield* fs.readLink(rootLink), commandLink); yield* fs.remove(rootLink); + // install.sh keeps a T3 home spelled with `.` as given. + yield* fs.remove(link); + yield* fs.symlink(`${root}/./.t3/runtime/versions/${VERSION}/t3`, link); + yield* provide(installCli()); + assert.equal(yield* fs.readLink(link), commandLink); // This home's desktop launcher is replaced, as install.sh does. yield* fs.remove(link); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 7297efd6b0044..28e05dc216b32 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's realPath is Node's JS realpath, which collapses `..` as text. +import * as NodeFSP from "node:fs/promises"; + import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; @@ -395,10 +398,9 @@ export function launcherOwnsVersionsDir( } /** - * Where the symlink at `link` points, not following that target any further: its text, and the - * same with its folder resolved on disk, which sees through a symlinked T3 home. Empty for a - * non-link, or a target with `.` or `..`: Node resolves those as text, but the OS after a - * symlinked folder, and t3 never writes them. + * Where the symlink at `link` points, not following that target any further: the target with its + * folder resolved on disk, which sees through a symlinked T3 home, and, for a dangling link, its + * text when it has no `.` or `..`. Empty for a non-link. */ export const directLinkTargets = Effect.fn("cloud.pinned_runtime.direct_link_targets")(function* ( link: string, @@ -406,18 +408,17 @@ export const directLinkTargets = Effect.fn("cloud.pinned_runtime.direct_link_tar const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const text = yield* fs.readLink(link).pipe(Effect.option); - if ( - Option.isNone(text) || - text.value.split(/[\\/]/).some((part) => part === "." || part === "..") - ) { - return []; - } - const target = path.resolve(path.dirname(link), text.value); - const physical = yield* fs.realPath(path.dirname(target)).pipe( - Effect.map((directory) => [path.join(directory, path.basename(target))]), + if (Option.isNone(text)) return []; + const raw = path.isAbsolute(text.value) + ? text.value + : `${path.dirname(link)}${path.sep}${text.value}`; + // Native and unnormalized, so `..` after a symlinked folder goes where the OS takes it. + const physical = yield* Effect.tryPromise(() => NodeFSP.realpath(path.dirname(raw))).pipe( + Effect.map((directory) => [path.join(directory, path.basename(raw))]), Effect.orElseSucceed(() => []), ); - return [target, ...physical]; + const plain = !text.value.split(/[\\/]/).some((part) => part === "." || part === ".."); + return plain ? [path.resolve(raw), ...physical] : physical; }); /** Whether `candidate` is a version's own executable, `//t3[.exe]`. */ From 87b97d130825c4d7647fc64915cbc4446df91ac2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 03:31:52 -0700 Subject: [PATCH 05/23] fix(cli): a link naming a folder is never ours Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/uninstall.test.ts | 3 +++ apps/server/src/cloud/pinnedRuntime.ts | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/server/src/cli/uninstall.test.ts b/apps/server/src/cli/uninstall.test.ts index 63a5906a90f13..7623018666bc4 100644 --- a/apps/server/src/cli/uninstall.test.ts +++ b/apps/server/src/cli/uninstall.test.ts @@ -49,6 +49,9 @@ it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => { yield* fs.symlink(exe, managed); yield* fs.symlink(managed, managedBin); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: managedBin, versionsDir })); + const asFolder = path.join(root, "bin/t3-folder"); + yield* fs.symlink(`${exe}/.`, asFolder); + assert.isUndefined(yield* findOwnedLauncher({ launchedAs: asFolder, versionsDir })); // `..` after a symlinked folder leads where the folder does, not back into this home. const hop = path.join(root, "runtime/hop"); const hopped = path.join(root, "bin/t3-hopped"); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 28e05dc216b32..33bb2a30dffed 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -408,7 +408,8 @@ export const directLinkTargets = Effect.fn("cloud.pinned_runtime.direct_link_tar const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const text = yield* fs.readLink(link).pipe(Effect.option); - if (Option.isNone(text)) return []; + // A target naming a folder (`t3/`, `t3/.`) never runs the file it would resolve to. + if (Option.isNone(text) || /(?:^|[\\/])\.{0,2}$/.test(text.value)) return []; const raw = path.isAbsolute(text.value) ? text.value : `${path.dirname(link)}${path.sep}${text.value}`; From 82c52422defdcf26ebb8cb7622b399c765429d0b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 03:54:25 -0700 Subject: [PATCH 06/23] fix(desktop): claim only a direct link to the launcher Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/app/DesktopCliCommand.test.ts | 9 +++++++++ apps/desktop/src/app/DesktopCliCommand.ts | 8 +++++--- apps/server/src/cli/install.ts | 6 +++++- scripts/install.sh | 4 +++- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index 567c32320ba3c..faf906e2cc441 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -178,6 +178,15 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { yield* command.uninstall; expect(yield* fs.readFileString(theirs)).toBe("npm's t3\n"); expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe(path.join(home, "gone")); + // Another tool's link that leads on to this app's launcher is still that tool's. + yield* command.install.pipe(Effect.ignore); + const managed = path.join(home, "manager", "t3"); + yield* fs.makeDirectory(path.dirname(managed)); + yield* fs.symlink(path.join(home, ".t3", "bin", "t3"), managed); + yield* fs.remove(path.join(home, "bin", "t3")); + yield* fs.symlink(managed, path.join(home, "bin", "t3")); + yield* command.uninstall; + expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe(managed); const launcher = path.join(home, ".t3", "bin", "t3"); yield* fs.writeFileString(launcher, "npm's t3\n"); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 28a5708e62bdd..055fd1b750663 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -99,11 +99,13 @@ export const make = Effect.gen(function* () { const isOurLink = Effect.fn("DesktopCliCommand.isOurLink")( function* (link: string) { - yield* fs.readLink(link); + // Only a direct link: a chain through another tool's link is that tool's. + const target = path.resolve(path.dirname(link), yield* fs.readLink(link)); + if (Option.isSome(yield* fs.readLink(target).pipe(Effect.option))) return false; // The launcher is a few KB; never read a large binary another `t3` links to. - const info = yield* fs.stat(link); + const info = yield* fs.stat(target); if (info.type !== "File" || Number(info.size) > 16_384) return false; - const content = yield* fs.readFileString(link); + const content = yield* fs.readFileString(target); return content.includes(DesktopCliShim.MARKER); }, Effect.orElseSucceed(() => false), diff --git a/apps/server/src/cli/install.ts b/apps/server/src/cli/install.ts index 7a80c59718410..baeba2a507a63 100644 --- a/apps/server/src/cli/install.ts +++ b/apps/server/src/cli/install.ts @@ -106,7 +106,11 @@ export const linkCommand = Effect.fn("cli.install.link_command")(function* (inpu .remove(staging, { force: true }) .pipe( Effect.andThen(fs.symlink(input.executable, staging)), - Effect.andThen(fs.rename(staging, commandLink)), + Effect.andThen( + fs + .rename(staging, commandLink) + .pipe(Effect.tapError(() => fs.remove(staging, { force: true }).pipe(Effect.ignore))), + ), ); if (kind === "missing") yield* fs.symlink(commandLink, input.link); if (kind !== "older") return true; diff --git a/scripts/install.sh b/scripts/install.sh index cf75e06bd3925..a823d12707d33 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -284,8 +284,10 @@ elif [ -e "$public" ] || [ -L "$public" ]; then fi # Inside the T3 home, so swapping it in place cannot replace another program's file. rm -f "${command_link}.$$.tmp" -ln -s "${target_dir}/t3" "${command_link}.$$.tmp" && mv -f "${command_link}.$$.tmp" "$command_link" || +if ! { ln -s "${target_dir}/t3" "${command_link}.$$.tmp" && mv -f "${command_link}.$$.tmp" "$command_link"; }; then + rm -f "${command_link}.$$.tmp" fail "could not link ${command_link}" +fi case "$kind" in # An empty spot is linked without -f, so a file that appears after the check above is kept. # Into the folder, so an entry of any kind already named t3 makes ln fail instead of filling it. From 1a1d907bb6f14c3ad04d5bfe218c1f514cf5c2ba Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 17:37:39 -0700 Subject: [PATCH 07/23] fix(cli): trust a link's text only when its folder is gone Desktop checks the raw link target, and the command link is replaced only when it is a link. Co-Authored-By: Claude Opus 5.5 --- .../desktop/src/app/DesktopCliCommand.test.ts | 6 +++ apps/desktop/src/app/DesktopCliCommand.ts | 6 ++- apps/server/src/cli/install.test.ts | 10 +++++ apps/server/src/cli/install.ts | 11 ++++- apps/server/src/cli/uninstall.test.ts | 5 +++ apps/server/src/cloud/pinnedRuntime.ts | 7 ++-- scripts/install.sh | 41 +++++++++++-------- scripts/install.test.ts | 24 +++++++++++ 8 files changed, 87 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index faf906e2cc441..95427cd1ad139 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -187,6 +187,12 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { yield* fs.symlink(managed, path.join(home, "bin", "t3")); yield* command.uninstall; expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe(managed); + // A target that names a folder never runs the launcher, so it is not this app's. + const asFolder = `${path.join(home, ".t3", "bin", "t3")}/.`; + yield* fs.remove(path.join(home, "bin", "t3")); + yield* fs.symlink(asFolder, path.join(home, "bin", "t3")); + yield* command.uninstall; + expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe(asFolder); const launcher = path.join(home, ".t3", "bin", "t3"); yield* fs.writeFileString(launcher, "npm's t3\n"); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 055fd1b750663..201bc6bbf66bd 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -99,8 +99,10 @@ export const make = Effect.gen(function* () { const isOurLink = Effect.fn("DesktopCliCommand.isOurLink")( function* (link: string) { - // Only a direct link: a chain through another tool's link is that tool's. - const target = path.resolve(path.dirname(link), yield* fs.readLink(link)); + // Only a direct link: a chain through another tool's link is that tool's. Unnormalized, + // so a target the OS would refuse (`t3/.`) fails here too. + const text = yield* fs.readLink(link); + const target = path.isAbsolute(text) ? text : `${path.dirname(link)}${path.sep}${text}`; if (Option.isSome(yield* fs.readLink(target).pipe(Effect.option))) return false; // The launcher is a few KB; never read a large binary another `t3` links to. const info = yield* fs.stat(target); diff --git a/apps/server/src/cli/install.test.ts b/apps/server/src/cli/install.test.ts index 74a6aa3059f19..8c152a72c1f29 100644 --- a/apps/server/src/cli/install.test.ts +++ b/apps/server/src/cli/install.test.ts @@ -94,6 +94,16 @@ it.layer(NodeServices.layer)("t3 install", (it) => { 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"); + // Only a link is ever replaced at the command link, even inside the T3 home. + yield* fs.remove(link); + yield* fs.symlink(older, link); + yield* fs.remove(commandLink); + yield* fs.writeFileString(commandLink, "mine\n"); + assert.include((yield* provide(installCli()).pipe(Effect.flip)).message, "Could not link"); + assert.equal(yield* fs.readFileString(commandLink), "mine\n"); + assert.equal(yield* fs.readLink(link), older); + yield* fs.remove(commandLink); + // A relative older link still resolves the same once moved aside. const rootLink = path.join(root, "t3"); yield* fs.symlink(`.t3/runtime/versions/${VERSION}/t3`, rootLink); diff --git a/apps/server/src/cli/install.ts b/apps/server/src/cli/install.ts index baeba2a507a63..7d31ab61627c2 100644 --- a/apps/server/src/cli/install.ts +++ b/apps/server/src/cli/install.ts @@ -100,7 +100,16 @@ export const linkCommand = Effect.fn("cli.install.link_command")(function* (inpu const kind = yield* classifyCommandLink(input.link, input.baseDir); if (kind === "foreign") return false; const commandLink = pinnedRuntimeCommandLink(path, input.baseDir); - // Inside the T3 home, so swapping it in place cannot replace another program's file. + // Inside the T3 home, so swapping it in place cannot replace another program's file. Still, + // only a link is ever replaced there. + if ( + Option.isNone(yield* fs.readLink(commandLink).pipe(Effect.option)) && + (yield* fs.exists(commandLink)) + ) { + return yield* new CliInstallError({ + reason: `${commandLink} is not a link. Remove it, then run this again.`, + }); + } const staging = `${commandLink}.${process.pid}.tmp`; yield* fs .remove(staging, { force: true }) diff --git a/apps/server/src/cli/uninstall.test.ts b/apps/server/src/cli/uninstall.test.ts index 7623018666bc4..2b45d0d0b0500 100644 --- a/apps/server/src/cli/uninstall.test.ts +++ b/apps/server/src/cli/uninstall.test.ts @@ -59,6 +59,11 @@ it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => { yield* fs.symlink(path.join(root, "other/runtime/inner"), hop); yield* fs.symlink(`${hop}/../versions/1.0.0/t3`, hopped); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: hopped, versionsDir })); + // A version folder that is itself another tool's folder only looks like ours. + yield* fs.symlink(path.dirname(otherExe), path.join(versionsDir, "2.0.0")); + const borrowed = path.join(root, "bin/t3-borrowed"); + yield* fs.symlink(path.join(versionsDir, "2.0.0/t3"), borrowed); + assert.isUndefined(yield* findOwnedLauncher({ launchedAs: borrowed, versionsDir })); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: theirs, versionsDir })); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: copy, versionsDir })); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: undefined, versionsDir })); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 33bb2a30dffed..5c9d28bfb03fc 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -399,8 +399,8 @@ export function launcherOwnsVersionsDir( /** * Where the symlink at `link` points, not following that target any further: the target with its - * folder resolved on disk, which sees through a symlinked T3 home, and, for a dangling link, its - * text when it has no `.` or `..`. Empty for a non-link. + * folder resolved on disk, which sees through a symlinked T3 home. Only when that folder is gone + * (a pruned version) does the text count, and only without `.` or `..`. Empty for a non-link. */ export const directLinkTargets = Effect.fn("cloud.pinned_runtime.direct_link_targets")(function* ( link: string, @@ -418,8 +418,9 @@ export const directLinkTargets = Effect.fn("cloud.pinned_runtime.direct_link_tar Effect.map((directory) => [path.join(directory, path.basename(raw))]), Effect.orElseSucceed(() => []), ); + if (physical.length > 0) return physical; const plain = !text.value.split(/[\\/]/).some((part) => part === "." || part === ".."); - return plain ? [path.resolve(raw), ...physical] : physical; + return plain ? [path.resolve(raw)] : []; }); /** Whether `candidate` is a version's own executable, `//t3[.exe]`. */ diff --git a/scripts/install.sh b/scripts/install.sh index a823d12707d33..b1a25a5fc330a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -248,30 +248,33 @@ version_t3() { case "$1" in "$2"/*) ;; *) return 1 ;; esac case "${1#"$2"/}" in */*/*) return 1 ;; */t3) return 0 ;; *) return 1 ;; esac } +real_desktop_launcher= +if real_bin="$(CDPATH='' cd -P -- "${t3_home}/bin" 2>/dev/null && pwd)"; then + real_desktop_launcher="${real_bin}/t3" +fi # Prints what the link at $1 is to this home: current (its command link), older (a version's t3 # or the desktop app's launcher), or nothing. Anything that is not a symlink is someone else's. link_kind() { [ -L "$1" ] || return 0 existing="$(readlink "$1" 2>/dev/null || true)" + # A target naming a folder (t3/, t3/.) never runs the file it would resolve to. + case "$existing" in "" | . | .. | */ | */. | */..) return 0 ;; esac case "$existing" in /*) ;; *) existing="${bin_dir}/${existing}" ;; esac - # 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)" - real_existing="${existing_dir}/${existing##*/}" - # The text alone counts only without . or .. segments (a pruned version leaves a dangling link). + # Resolved on disk, which sees through a symlinked T3 home and follows `..` as the OS does. + if existing_dir="$(CDPATH='' cd -P -- "$(dirname -- "$existing")" 2>/dev/null && pwd)"; then + real_existing="${existing_dir}/${existing##*/}" + if [ "$real_existing" = "$real_command_link" ]; then echo current + elif [ "$real_existing" = "$real_desktop_launcher" ] || version_t3 "$real_existing" "$real_versions_dir"; then + echo older + fi + return 0 + fi + # Only a gone folder (a pruned version) leaves the text, and only without . or .. segments. case "$existing" in - */./* | */../* | */. | */..) ;; - "$command_link") echo current; return 0 ;; - "${t3_home}/bin/t3") echo older; return 0 ;; - *) - if version_t3 "$existing" "$versions_dir" || version_t3 "$existing" "$real_versions_dir"; then - echo older; return 0 - fi - ;; + */./* | */../*) ;; + "${t3_home}/bin/t3") echo older ;; + *) if version_t3 "$existing" "$versions_dir" || version_t3 "$existing" "$real_versions_dir"; then echo older; fi ;; esac - [ -n "$existing_dir" ] || return 0 - if [ "$real_existing" = "$real_command_link" ]; then echo current - elif version_t3 "$real_existing" "$real_versions_dir"; then echo older - fi } kind= real_public="$(CDPATH='' cd -P -- "$bin_dir" && pwd)/t3" @@ -282,7 +285,11 @@ elif [ -e "$public" ] || [ -L "$public" ]; then kind="$(link_kind "$public")" [ -n "$kind" ] || fail "another t3 is already at ${public}. Remove it and run this again, or run ${target_dir}/t3 directly." fi -# Inside the T3 home, so swapping it in place cannot replace another program's file. +# Inside the T3 home, so swapping it in place cannot replace another program's file. Still, +# only a link is ever replaced there. +if [ -e "$command_link" ] && [ ! -L "$command_link" ]; then + fail "${command_link} is not a link. Remove it and run this again." +fi rm -f "${command_link}.$$.tmp" if ! { ln -s "${target_dir}/t3" "${command_link}.$$.tmp" && mv -f "${command_link}.$$.tmp" "$command_link"; }; then rm -f "${command_link}.$$.tmp" diff --git a/scripts/install.test.ts b/scripts/install.test.ts index c3e503855fd4a..353c1cd28c85e 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -173,6 +173,30 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 `printf '\\n%s\\n' 'export PATH="$PATH:$HOME/bin"' >> ~/.zshrc`, ); + // A version folder that is another tool's folder only looks like ours. + await NodeFSP.rm(link); + await NodeFSP.mkdir(NodePath.join(root, "tool")); + await NodeFSP.writeFile(NodePath.join(root, "tool/t3"), "#!/bin/sh\n"); + await NodeFSP.symlink( + NodePath.join(root, "tool"), + NodePath.join(home, "runtime/versions/0.9.0"), + ); + await NodeFSP.symlink(NodePath.join(home, "runtime/versions/0.9.0/t3"), link); + const borrowed = await installOver({ script: "install.sh", home, binDir }); + expect(borrowed.code).not.toBe(0); + expect(await NodeFSP.readlink(link)).toBe(NodePath.join(home, "runtime/versions/0.9.0/t3")); + + // Only a link is ever replaced at the command link, even inside the T3 home. + const commandLink = NodePath.join(home, "runtime/t3"); + await NodeFSP.rm(commandLink, { force: true }); + await NodeFSP.writeFile(commandLink, "mine\n"); + const notLink = await installOver({ script: "install.sh", home, binDir }); + expect(notLink.code).not.toBe(0); + expect(await NodeFSP.readFile(commandLink, "utf8")).toBe("mine\n"); + await NodeFSP.rm(commandLink); + await NodeFSP.rm(link); + await NodeFSP.symlink(NodePath.join(home, "runtime/versions/1.0.0/t3"), link); + // Containers and CI often have no SHELL; `set -u` must not stop the installer there. const noShell = await installOver({ script: "install.sh", From ceca60f495780cde486909f5fb5cedbaa4eaa650 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 17:47:50 -0700 Subject: [PATCH 08/23] fix(cli): close the remaining paths that could replace or lose a t3 The bin folder cannot be the runtime folder, a failed migration keeps the old command, the command link never leads to a folder, a relative T3 home is made absolute, and a version folder only counts by its text when it is missing. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/install.test.ts | 26 ++++++++- apps/server/src/cli/install.ts | 13 ++++- apps/server/src/cli/uninstall.test.ts | 20 +++++++ apps/server/src/cli/uninstall.ts | 10 ++-- apps/server/src/cloud/pinnedRuntime.ts | 8 +++ home/runtime/versions/1.2.3/.install-complete | 1 + packages/shared/src/ownedLink.ts | 27 +++++++--- scripts/install.sh | 53 +++++++++--------- scripts/install.test.ts | 54 +++++++++++++++---- 9 files changed, 165 insertions(+), 47 deletions(-) create mode 100644 home/runtime/versions/1.2.3/.install-complete diff --git a/apps/server/src/cli/install.test.ts b/apps/server/src/cli/install.test.ts index 8c152a72c1f29..4b7aa42eda579 100644 --- a/apps/server/src/cli/install.test.ts +++ b/apps/server/src/cli/install.test.ts @@ -99,11 +99,35 @@ it.layer(NodeServices.layer)("t3 install", (it) => { yield* fs.symlink(older, link); yield* fs.remove(commandLink); yield* fs.writeFileString(commandLink, "mine\n"); - assert.include((yield* provide(installCli()).pipe(Effect.flip)).message, "Could not link"); + assert.include((yield* provide(installCli()).pipe(Effect.flip)).message, "is not a link"); assert.equal(yield* fs.readFileString(commandLink), "mine\n"); assert.equal(yield* fs.readLink(link), older); yield* fs.remove(commandLink); + // The command link is replaced in place, so it can never be the public name. + const inRuntime = yield* provide(installCli(), { T3CODE_INSTALL_BIN_DIR: runtimeDir }).pipe( + Effect.flip, + ); + assert.include(inRuntime.message, "T3CODE_INSTALL_BIN_DIR cannot be"); + + // When the new link cannot be made, the old command keeps working. + const failing = installCli().pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + symlink: (target, to) => + to === link && target === commandLink + ? // Fails like a full disk: the folder name is taken, so symlink() errors. + fs.symlink(target, path.dirname(to)) + : fs.symlink(target, to), + }), + ); + yield* provide(failing).pipe(Effect.flip); + assert.equal(yield* fs.readLink(link), older); + assert.deepStrictEqual( + (yield* fs.readDirectory(path.dirname(link))).filter((name) => name.startsWith(".t3.")), + [], + ); + // A relative older link still resolves the same once moved aside. const rootLink = path.join(root, "t3"); yield* fs.symlink(`.t3/runtime/versions/${VERSION}/t3`, rootLink); diff --git a/apps/server/src/cli/install.ts b/apps/server/src/cli/install.ts index 7d31ab61627c2..ee398cff55b9e 100644 --- a/apps/server/src/cli/install.ts +++ b/apps/server/src/cli/install.ts @@ -204,8 +204,19 @@ export const installCli = Effect.fn("cli.install.install")(function* () { yield* fs .makeDirectory(binDir, { recursive: true }) .pipe(Effect.mapError(failed(`Could not create ${binDir}.`))); + // The command link is replaced in place, which is safe only away from the public name. + const real = (directory: string) => + fs.realPath(directory).pipe(Effect.orElseSucceed(() => directory)); + if ((yield* real(binDir)) === (yield* real(runtimeDir))) { + return yield* new CliInstallError({ + reason: `T3CODE_INSTALL_BIN_DIR cannot be ${runtimeDir}. Choose another folder.`, + }); + } const linked = yield* linkCommand({ link, baseDir: path.dirname(runtimeDir), executable }).pipe( - Effect.mapError(failed(`Could not link ${link}.`)), + Effect.catchTags({ + PlatformError: (cause) => + Effect.fail(new CliInstallError({ reason: `Could not link ${link}.`, cause })), + }), ); if (!linked) { return yield* new CliInstallError({ diff --git a/apps/server/src/cli/uninstall.test.ts b/apps/server/src/cli/uninstall.test.ts index 2b45d0d0b0500..e1c06f0c5d56c 100644 --- a/apps/server/src/cli/uninstall.test.ts +++ b/apps/server/src/cli/uninstall.test.ts @@ -64,6 +64,26 @@ it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => { const borrowed = path.join(root, "bin/t3-borrowed"); yield* fs.symlink(path.join(versionsDir, "2.0.0/t3"), borrowed); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: borrowed, versionsDir })); + // A version folder link that leads nowhere is not missing; it may be another tool's. + yield* fs.symlink(path.join(root, "unmounted"), path.join(versionsDir, "2.1.0")); + const unmounted = path.join(root, "bin/t3-unmounted"); + yield* fs.symlink(path.join(versionsDir, "2.1.0/t3"), unmounted); + assert.isUndefined(yield* findOwnedLauncher({ launchedAs: unmounted, versionsDir })); + // With versions itself a link, the command link is still the one in the runtime folder. + const store = path.join(root, "store"); + yield* fs.makeDirectory(path.join(store, "versions/1.0.0"), { recursive: true }); + yield* fs.writeFileString(path.join(store, "t3"), ""); + const linkedHome = path.join(root, "linked-home"); + yield* fs.makeDirectory(path.join(linkedHome, "runtime"), { recursive: true }); + yield* fs.symlink(path.join(store, "versions"), path.join(linkedHome, "runtime/versions")); + const storeLauncher = path.join(root, "bin/t3-store"); + yield* fs.symlink(path.join(store, "t3"), storeLauncher); + assert.isUndefined( + yield* findOwnedLauncher({ + launchedAs: storeLauncher, + versionsDir: path.join(linkedHome, "runtime/versions"), + }), + ); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: theirs, versionsDir })); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: copy, versionsDir })); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: undefined, versionsDir })); diff --git a/apps/server/src/cli/uninstall.ts b/apps/server/src/cli/uninstall.ts index 9d8d21bf97f75..64c030ed2c376 100644 --- a/apps/server/src/cli/uninstall.ts +++ b/apps/server/src/cli/uninstall.ts @@ -82,10 +82,12 @@ const ownsLauncher = Effect.fn("cli.uninstall.owns_launcher")(function* ( ), ] : yield* directLinkTargets(launcher); - // The command link sits beside versions; a link to it may dangle after a prune. - const commandLinks = [versionsDir, realVersionsDir].map((dir) => - path.join(path.dirname(dir), "t3"), - ); + // The command link sits beside versions, in the runtime folder itself; versions may be a link. + const runtimeDir = path.dirname(versionsDir); + const commandLinks = [ + runtimeDir, + yield* fs.realPath(runtimeDir).pipe(Effect.orElseSucceed(() => runtimeDir)), + ].map((dir) => path.join(dir, "t3")); return targets.some( (target) => target !== undefined && diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 5c9d28bfb03fc..e96f254bedd8c 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -419,6 +419,14 @@ export const directLinkTargets = Effect.fn("cloud.pinned_runtime.direct_link_tar Effect.orElseSucceed(() => []), ); if (physical.length > 0) return physical; + // A folder that exists but cannot be resolved, or is a link, may be another program's. + const folder = path.dirname(raw); + if ( + Option.isSome(yield* fs.readLink(folder).pipe(Effect.option)) || + (yield* fs.exists(folder).pipe(Effect.orElseSucceed(() => true))) + ) { + return []; + } const plain = !text.value.split(/[\\/]/).some((part) => part === "." || part === ".."); return plain ? [path.resolve(raw)] : []; }); diff --git a/home/runtime/versions/1.2.3/.install-complete b/home/runtime/versions/1.2.3/.install-complete new file mode 100644 index 0000000000000..0495c4a88caed --- /dev/null +++ b/home/runtime/versions/1.2.3/.install-complete @@ -0,0 +1 @@ +1.2.3 diff --git a/packages/shared/src/ownedLink.ts b/packages/shared/src/ownedLink.ts index 017d4e11d222c..ecc7466915fc5 100644 --- a/packages/shared/src/ownedLink.ts +++ b/packages/shared/src/ownedLink.ts @@ -26,16 +26,27 @@ export const replaceOwnedLink = Effect.fn("ownedLink.replace")(function* ( yield* fs.writeFileString(aside, "", { flag: "wx" }); const discard = fs.remove(aside); yield* fs.rename(input.link, aside).pipe(Effect.tapError(() => discard.pipe(Effect.ignore))); + // Exclusive, so it never overwrites anything written at `link` meanwhile. + const putBack = fs.readLink(aside).pipe( + Effect.option, + Effect.flatMap((text) => + Option.isSome(text) ? fs.symlink(text.value, input.link) : fs.link(aside, input.link), + ), + ); if (!(yield* input.owns(aside))) { - const text = yield* fs.readLink(aside).pipe(Effect.option); - yield* ( - Option.isSome(text) ? fs.symlink(text.value, input.link) : fs.link(aside, input.link) - ).pipe(Effect.andThen(discard)); + yield* putBack.pipe(Effect.andThen(discard)); return false; } - // Something written after the move keeps the name; this home's old link goes either way. - yield* (input.target === undefined ? Effect.void : fs.symlink(input.target, input.link)).pipe( - Effect.ensuring(discard.pipe(Effect.ignore)), - ); + if (input.target !== undefined) { + // On failure the old link goes back while the name is free, so the command keeps working. + yield* fs + .symlink(input.target, input.link) + .pipe( + Effect.tapError(() => + putBack.pipe(Effect.ignore, Effect.andThen(discard.pipe(Effect.ignore))), + ), + ); + } + yield* discard.pipe(Effect.ignore); return true; }); diff --git a/scripts/install.sh b/scripts/install.sh index b1a25a5fc330a..9353ef88db723 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -26,8 +26,10 @@ 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. +# Absolute, so the PATH hint never adds a folder that changes with the working directory, and +# the links never resolve against the folder they sit in. case "$bin_dir" in /*) ;; *) bin_dir="$PWD/$bin_dir" ;; esac +case "$t3_home" in /*) ;; *) t3_home="$PWD/$t3_home" ;; esac fail() { printf '\nt3 install: %s\n' "$1" >&2 @@ -269,7 +271,9 @@ link_kind() { fi return 0 fi - # Only a gone folder (a pruned version) leaves the text, and only without . or .. segments. + # Only a missing folder (a pruned version) leaves the text, and only without . or .. segments. + # A folder that exists but cannot be resolved, or is a link, may be another program's. + if [ -e "$(dirname -- "$existing")" ] || [ -L "$(dirname -- "$existing")" ]; then return 0; fi case "$existing" in */./* | */../*) ;; "${t3_home}/bin/t3") echo older ;; @@ -278,17 +282,18 @@ link_kind() { } kind= real_public="$(CDPATH='' cd -P -- "$bin_dir" && pwd)/t3" -# A bin folder of runtime itself would make the command link point at itself. +# The command link is replaced in place, which is safe only away from the public name. if [ "$real_public" = "$real_command_link" ]; then - kind=current -elif [ -e "$public" ] || [ -L "$public" ]; then + fail "T3CODE_INSTALL_BIN_DIR cannot be ${t3_home}/runtime. Choose another folder." +fi +if [ -e "$public" ] || [ -L "$public" ]; then kind="$(link_kind "$public")" [ -n "$kind" ] || fail "another t3 is already at ${public}. Remove it and run this again, or run ${target_dir}/t3 directly." fi # Inside the T3 home, so swapping it in place cannot replace another program's file. Still, -# only a link is ever replaced there. -if [ -e "$command_link" ] && [ ! -L "$command_link" ]; then - fail "${command_link} is not a link. Remove it and run this again." +# only a link to a file is ever replaced there; mv would move into a folder. +if [ -d "$command_link" ] || { [ -e "$command_link" ] && [ ! -L "$command_link" ]; }; then + fail "${command_link} is not a link to a file. Remove it and run this again." fi rm -f "${command_link}.$$.tmp" if ! { ln -s "${target_dir}/t3" "${command_link}.$$.tmp" && mv -f "${command_link}.$$.tmp" "$command_link"; }; then @@ -300,25 +305,25 @@ case "$kind" in # Into the folder, so an entry of any kind already named t3 makes ln fail instead of filling it. "") ln -s "$command_link" "${bin_dir}/" || fail "could not link ${public}; something else was created there." ;; older) - # Moved aside before it is checked again, so the check sees exactly what gets replaced. - # Reserved first, so an entry left by an earlier install is never replaced. - aside="$(mktemp "${bin_dir}/.t3.XXXXXX")" || fail "could not replace ${public}" - mv -f "$public" "$aside" || { rm -f "$aside"; fail "could not replace ${public}"; } + # Moved aside before it is checked again, so the check sees exactly what gets replaced. A new + # folder of its own, so nothing left by an earlier install is replaced; the entry keeps its + # name t3, so it goes back with `ln -P ... "${bin_dir}/"`, which never overwrites anything. + aside_dir="$(mktemp -d "${bin_dir}/.t3.XXXXXX")" || fail "could not replace ${public}" + aside="${aside_dir}/t3" + mv "$public" "$aside" || { rmdir "$aside_dir"; fail "could not replace ${public}"; } if [ -n "$(link_kind "$aside")" ]; then - linked=true - ln -s "$command_link" "${bin_dir}/" || linked=false - rm -f "$aside" - "$linked" || fail "could not link ${public}; something else was created there." + if ! ln -s "$command_link" "${bin_dir}/"; then + # Keep the working old command when the name is still free. + ln -P "$aside" "${bin_dir}/" 2>/dev/null || true + rm -f "$aside" && rmdir "$aside_dir" + fail "could not link ${public}" + fi + rm -f "$aside" && rmdir "$aside_dir" else # Another program wrote it after the check: put it back without overwriting anything newer. - restored=false - if [ -L "$aside" ]; then - ln -s "$(readlink "$aside")" "$public" 2>/dev/null && restored=true - else - ln "$aside" "$public" 2>/dev/null && restored=true - fi - "$restored" || fail "another program wrote ${public} during the install; its file is now at ${aside}." - rm -f "$aside" + ln -P "$aside" "${bin_dir}/" 2>/dev/null || + fail "another program wrote ${public} during the install; its file is now at ${aside}." + rm -f "$aside" && rmdir "$aside_dir" fail "another t3 was just written at ${public}. Remove it and run this again, or run ${target_dir}/t3 directly." fi ;; diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 353c1cd28c85e..8011737f0aa29 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -88,7 +88,7 @@ async function installOver(input: { readonly env?: NodeJS.ProcessEnv; readonly cwd?: string; }) { - const target = NodePath.join(input.home, "runtime", "versions", "1.2.3"); + const target = NodePath.resolve(input.cwd ?? ".", 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); @@ -186,16 +186,31 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 expect(borrowed.code).not.toBe(0); expect(await NodeFSP.readlink(link)).toBe(NodePath.join(home, "runtime/versions/0.9.0/t3")); - // Only a link is ever replaced at the command link, even inside the T3 home. - const commandLink = NodePath.join(home, "runtime/t3"); - await NodeFSP.rm(commandLink, { force: true }); - await NodeFSP.writeFile(commandLink, "mine\n"); - const notLink = await installOver({ script: "install.sh", home, binDir }); - expect(notLink.code).not.toBe(0); - expect(await NodeFSP.readFile(commandLink, "utf8")).toBe("mine\n"); - await NodeFSP.rm(commandLink); + // Only a link to a file is ever replaced at the command link, even inside the T3 home. await NodeFSP.rm(link); await NodeFSP.symlink(NodePath.join(home, "runtime/versions/1.0.0/t3"), link); + const commandLink = NodePath.join(home, "runtime/t3"); + await NodeFSP.mkdir(NodePath.join(root, "folder")); + for (const make of [ + () => NodeFSP.writeFile(commandLink, "mine\n"), + () => NodeFSP.symlink(NodePath.join(root, "folder"), commandLink), + ]) { + await NodeFSP.rm(commandLink, { force: true }); + await make(); + const notLink = await installOver({ script: "install.sh", home, binDir }); + expect(notLink.code).not.toBe(0); + expect(notLink.output).toContain("is not a link to a file"); + expect(await NodeFSP.readdir(NodePath.join(root, "folder"))).toEqual([]); + } + await NodeFSP.rm(commandLink); + // The command link is replaced in place, so it can never be the public name. + const inRuntime = await installOver({ + script: "install.sh", + home, + binDir: NodePath.join(home, "runtime"), + }); + expect(inRuntime.code).not.toBe(0); + expect(inRuntime.output).toContain("T3CODE_INSTALL_BIN_DIR cannot be"); // Containers and CI often have no SHELL; `set -u` must not stop the installer there. const noShell = await installOver({ @@ -217,6 +232,16 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 }); expect(relative.code, relative.output).toBe(0); expect(relative.output).toContain(`'export PATH="$PATH:$HOME/bin"'`); + // So is a relative T3 home, or both links would resolve against the folder they sit in. + await NodeFSP.rm(link); + const relativeHome = await installOver({ + script: "install.sh", + home: NodePath.relative(root, home), + binDir, + cwd: root, + }); + expect(relativeHome.code, relativeHome.output).toBe(0); + expect(await NodeFSP.readlink(link)).toBe(NodePath.join(home, "runtime/t3")); await NodeFSP.rm(link); await NodeFSP.symlink(NodePath.join(home, "bin/t3"), link); @@ -253,6 +278,8 @@ if [ -n "$RACE" ] && [ ! -e "$RACE.fired" ]; then : > "$RACE.fired" printf 'theirs\\n' > "$RACE.new" && mv -f "$RACE.new" "$RACE" fi +# Fails only creating the new public link, never putting one back. +if [ -n "$FAIL" ] && [ "$1" = -s ]; then for last; do :; done; [ "$last" != "$FAIL" ] || exit 1; fi exec ${realLn} "$@" `, { mode: 0o755 }, @@ -283,6 +310,15 @@ exec ${realLn} "$@" const current = await racing(); expect(current.code, current.output).toBe(0); expect(await NodeFSP.readFile(link, "utf8")).toBe("theirs\n"); + + // When the new link cannot be made, the old command keeps working. + const oldLink = NodePath.join(home, "runtime/versions/1.0.0/t3"); + await NodeFSP.rm(link); + await NodeFSP.symlink(oldLink, link); + const failing = await racing({ RACE: "", FAIL: `${binDir}/` }); + expect(failing.code).not.toBe(0); + expect(await NodeFSP.readlink(link)).toBe(oldLink); + expect((await NodeFSP.readdir(binDir)).filter((name) => name.startsWith(".t3."))).toEqual([]); } finally { await NodeFSP.rm(root, { recursive: true, force: true }); } From cfbc6f2c2d7be513ddcd9d440c3b1375bf7b597b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 17:59:11 -0700 Subject: [PATCH 09/23] fix(cli): never follow a linked version folder, and keep what cannot go back A version folder that is itself a link is not ours. A folder that cannot be resolved for a reason other than being gone is not ours. The bin folder is compared with the runtime folder by device and inode. A moved-aside entry goes back byte for byte, a folder goes back too, and an entry that cannot go back stays beside the link. Removes a test marker committed by mistake. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/install.test.ts | 50 +++++++++++++++++++ apps/server/src/cli/install.ts | 21 ++++++-- apps/server/src/cli/uninstall.test.ts | 5 ++ apps/server/src/cloud/pinnedRuntime.ts | 29 ++++++----- home/runtime/versions/1.2.3/.install-complete | 1 - packages/shared/src/ownedLink.ts | 39 +++++++++++---- scripts/install.sh | 37 ++++++++++---- scripts/install.test.ts | 21 ++++++++ 8 files changed, 164 insertions(+), 39 deletions(-) delete mode 100644 home/runtime/versions/1.2.3/.install-complete diff --git a/apps/server/src/cli/install.test.ts b/apps/server/src/cli/install.test.ts index 4b7aa42eda579..57c865e2052fd 100644 --- a/apps/server/src/cli/install.test.ts +++ b/apps/server/src/cli/install.test.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's link calls take strings; this test needs raw bytes. +import * as NodeFSP from "node:fs/promises"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -128,6 +131,28 @@ it.layer(NodeServices.layer)("t3 install", (it) => { [], ); + // When the old link cannot go back either, because another program took the name, it is + // kept beside the link rather than removed. + const taken = installCli().pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + symlink: (target, to) => + to === link && target === commandLink + ? fs.writeFileString(link, "theirs\n").pipe(Effect.andThen(fs.symlink(target, to))) + : fs.symlink(target, to), + }), + ); + yield* provide(taken).pipe(Effect.flip); + assert.equal(yield* fs.readFileString(link), "theirs\n"); + const kept = (yield* fs.readDirectory(path.dirname(link))).filter((name) => + name.startsWith(".t3."), + ); + assert.equal(kept.length, 1); + assert.equal(yield* fs.readLink(path.join(path.dirname(link), kept[0]!)), older); + yield* fs.remove(path.join(path.dirname(link), kept[0]!)); + yield* fs.remove(link); + yield* fs.symlink(older, link); + // A relative older link still resolves the same once moved aside. const rootLink = path.join(root, "t3"); yield* fs.symlink(`.t3/runtime/versions/${VERSION}/t3`, rootLink); @@ -165,6 +190,31 @@ it.layer(NodeServices.layer)("t3 install", (it) => { ).pipe(Effect.andThen(fs.symlink(target, to))), }), ); + // A foreign link whose target is not UTF-8 goes back byte for byte. + const bytes = Buffer.from([0x2f, 0x74, 0x6d, 0x70, 0x2f, 0xff, 0x74, 0x33]); + let swapped = false; + const rawRace = installCli().pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + symlink: (target, to) => + (swapped + ? Effect.void + : Effect.promise(async () => { + await NodeFSP.symlink(bytes, `${link}.new`); + await NodeFSP.rename(`${link}.new`, link); + swapped = true; + }) + ).pipe(Effect.andThen(fs.symlink(target, to))), + }), + ); + yield* fs.remove(link); + yield* fs.symlink(older, link); + yield* provide(rawRace).pipe(Effect.flip); + assert.deepStrictEqual( + yield* Effect.promise(() => NodeFSP.readlink(link, { encoding: "buffer" })), + bytes, + ); + for (const before of [undefined, older, commandLink]) { fired = false; yield* fs.remove(link); diff --git a/apps/server/src/cli/install.ts b/apps/server/src/cli/install.ts index ee398cff55b9e..6a9adc6250112 100644 --- a/apps/server/src/cli/install.ts +++ b/apps/server/src/cli/install.ts @@ -204,10 +204,19 @@ export const installCli = Effect.fn("cli.install.install")(function* () { yield* fs .makeDirectory(binDir, { recursive: true }) .pipe(Effect.mapError(failed(`Could not create ${binDir}.`))); - // The command link is replaced in place, which is safe only away from the public name. - const real = (directory: string) => - fs.realPath(directory).pipe(Effect.orElseSucceed(() => directory)); - if ((yield* real(binDir)) === (yield* real(runtimeDir))) { + // The command link is replaced in place, which is safe only away from the public name. Same + // device and inode, so an alias or a bind mount of the runtime folder counts too. + const identity = (directory: string) => + fs.stat(directory).pipe( + Effect.map((info) => + Option.match(info.ino, { + onSome: (ino) => `${info.dev}:${ino}`, + onNone: () => directory, + }), + ), + Effect.orElseSucceed(() => directory), + ); + if ((yield* identity(binDir)) === (yield* identity(runtimeDir))) { return yield* new CliInstallError({ reason: `T3CODE_INSTALL_BIN_DIR cannot be ${runtimeDir}. Choose another folder.`, }); @@ -216,6 +225,10 @@ export const installCli = Effect.fn("cli.install.install")(function* () { Effect.catchTags({ PlatformError: (cause) => Effect.fail(new CliInstallError({ reason: `Could not link ${link}.`, cause })), + OwnedLinkError: (cause) => + Effect.fail( + new CliInstallError({ reason: `Could not link ${link}. ${cause.message}`, cause }), + ), }), ); if (!linked) { diff --git a/apps/server/src/cli/uninstall.test.ts b/apps/server/src/cli/uninstall.test.ts index e1c06f0c5d56c..179488d4483b9 100644 --- a/apps/server/src/cli/uninstall.test.ts +++ b/apps/server/src/cli/uninstall.test.ts @@ -64,6 +64,11 @@ it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => { const borrowed = path.join(root, "bin/t3-borrowed"); yield* fs.symlink(path.join(versionsDir, "2.0.0/t3"), borrowed); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: borrowed, versionsDir })); + // A version folder that is a link to one of ours (versions/current) is not followed. + yield* fs.symlink(path.join(versionsDir, "1.0.0"), path.join(versionsDir, "current")); + const current = path.join(root, "bin/t3-current"); + yield* fs.symlink(path.join(versionsDir, "current/t3"), current); + assert.isUndefined(yield* findOwnedLauncher({ launchedAs: current, versionsDir })); // A version folder link that leads nowhere is not missing; it may be another tool's. yield* fs.symlink(path.join(root, "unmounted"), path.join(versionsDir, "2.1.0")); const unmounted = path.join(root, "bin/t3-unmounted"); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index e96f254bedd8c..840f37ea4e432 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -10,6 +10,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Option from "effect/Option"; +import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; @@ -399,8 +400,9 @@ export function launcherOwnsVersionsDir( /** * Where the symlink at `link` points, not following that target any further: the target with its - * folder resolved on disk, which sees through a symlinked T3 home. Only when that folder is gone - * (a pruned version) does the text count, and only without `.` or `..`. Empty for a non-link. + * folder resolved on disk, which sees through a symlinked T3 home. A folder that is itself a link + * (`versions/current`) is not followed. Only when the folder is missing (a pruned version) does + * the text count, and only without `.` or `..`. Empty for a non-link. */ export const directLinkTargets = Effect.fn("cloud.pinned_runtime.direct_link_targets")(function* ( link: string, @@ -413,22 +415,19 @@ export const directLinkTargets = Effect.fn("cloud.pinned_runtime.direct_link_tar const raw = path.isAbsolute(text.value) ? text.value : `${path.dirname(link)}${path.sep}${text.value}`; + const folder = path.dirname(raw); + if (Option.isSome(yield* fs.readLink(folder).pipe(Effect.option))) return []; // Native and unnormalized, so `..` after a symlinked folder goes where the OS takes it. - const physical = yield* Effect.tryPromise(() => NodeFSP.realpath(path.dirname(raw))).pipe( - Effect.map((directory) => [path.join(directory, path.basename(raw))]), - Effect.orElseSucceed(() => []), + const physical = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(folder), + catch: (error) => (error as NodeJS.ErrnoException).code, + }).pipe( + Effect.map((directory) => path.join(directory, path.basename(raw))), + Effect.result, ); - if (physical.length > 0) return physical; - // A folder that exists but cannot be resolved, or is a link, may be another program's. - const folder = path.dirname(raw); - if ( - Option.isSome(yield* fs.readLink(folder).pipe(Effect.option)) || - (yield* fs.exists(folder).pipe(Effect.orElseSucceed(() => true))) - ) { - return []; - } + if (Result.isSuccess(physical)) return [physical.success]; const plain = !text.value.split(/[\\/]/).some((part) => part === "." || part === ".."); - return plain ? [path.resolve(raw)] : []; + return physical.failure === "ENOENT" && plain ? [path.resolve(raw)] : []; }); /** Whether `candidate` is a version's own executable, `//t3[.exe]`. */ diff --git a/home/runtime/versions/1.2.3/.install-complete b/home/runtime/versions/1.2.3/.install-complete deleted file mode 100644 index 0495c4a88caed..0000000000000 --- a/home/runtime/versions/1.2.3/.install-complete +++ /dev/null @@ -1 +0,0 @@ -1.2.3 diff --git a/packages/shared/src/ownedLink.ts b/packages/shared/src/ownedLink.ts index ecc7466915fc5..44759f46803fb 100644 --- a/packages/shared/src/ownedLink.ts +++ b/packages/shared/src/ownedLink.ts @@ -1,8 +1,21 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's readLink and symlink take strings, not the raw bytes a link may hold. +import * as NodeFSP from "node:fs/promises"; + 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 Random from "effect/Random"; +import * as Schema from "effect/Schema"; + +/** An entry moved aside could not go back; it is still at `path`. */ +export class OwnedLinkError extends Schema.TaggedError()("OwnedLinkError", { + path: Schema.String, +}) { + override get message(): string { + return `Could not put back the entry now at ${this.path}.`; + } +} /** * Replaces `link` with a symlink to `target`, or removes it without a `target`, when `owns` holds @@ -26,11 +39,22 @@ export const replaceOwnedLink = Effect.fn("ownedLink.replace")(function* ( yield* fs.writeFileString(aside, "", { flag: "wx" }); const discard = fs.remove(aside); yield* fs.rename(input.link, aside).pipe(Effect.tapError(() => discard.pipe(Effect.ignore))); - // Exclusive, so it never overwrites anything written at `link` meanwhile. - const putBack = fs.readLink(aside).pipe( + // Exclusive, so it never overwrites anything written at `link` meanwhile. The link text is + // copied as bytes; a string would mangle a target that is not UTF-8. + const putBack = Effect.tryPromise({ + try: () => NodeFSP.readlink(aside, { encoding: "buffer" }), + catch: () => new OwnedLinkError({ path: aside }), + }).pipe( Effect.option, Effect.flatMap((text) => - Option.isSome(text) ? fs.symlink(text.value, input.link) : fs.link(aside, input.link), + Option.isSome(text) + ? Effect.tryPromise({ + try: () => NodeFSP.symlink(text.value, input.link), + catch: () => new OwnedLinkError({ path: aside }), + }) + : fs + .link(aside, input.link) + .pipe(Effect.mapError(() => new OwnedLinkError({ path: aside }))), ), ); if (!(yield* input.owns(aside))) { @@ -38,14 +62,11 @@ export const replaceOwnedLink = Effect.fn("ownedLink.replace")(function* ( return false; } if (input.target !== undefined) { - // On failure the old link goes back while the name is free, so the command keeps working. + // On failure the old link goes back while the name is free, so the command keeps working; + // if it cannot, it stays beside `link`. yield* fs .symlink(input.target, input.link) - .pipe( - Effect.tapError(() => - putBack.pipe(Effect.ignore, Effect.andThen(discard.pipe(Effect.ignore))), - ), - ); + .pipe(Effect.tapError(() => putBack.pipe(Effect.andThen(discard), Effect.ignore))); } yield* discard.pipe(Effect.ignore); return true; diff --git a/scripts/install.sh b/scripts/install.sh index 9353ef88db723..a174e0c8f5fd5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -250,6 +250,8 @@ version_t3() { case "$1" in "$2"/*) ;; *) return 1 ;; esac case "${1#"$2"/}" in */*/*) return 1 ;; */t3) return 0 ;; *) return 1 ;; esac } +newline=" +" real_desktop_launcher= if real_bin="$(CDPATH='' cd -P -- "${t3_home}/bin" 2>/dev/null && pwd)"; then real_desktop_launcher="${real_bin}/t3" @@ -258,10 +260,16 @@ fi # or the desktop app's launcher), or nothing. Anything that is not a symlink is someone else's. link_kind() { [ -L "$1" ] || return 0 - existing="$(readlink "$1" 2>/dev/null || true)" + # The x keeps a trailing newline in the target, which $(...) would drop. + existing="$(readlink "$1" 2>/dev/null && echo x)" || return 0 + existing="${existing%x}" + existing="${existing%"$newline"}" # A target naming a folder (t3/, t3/.) never runs the file it would resolve to. case "$existing" in "" | . | .. | */ | */. | */..) return 0 ;; esac case "$existing" in /*) ;; *) existing="${bin_dir}/${existing}" ;; esac + # A folder that is itself a link (versions/current) is not followed. + folder="$(dirname -- "$existing")" + [ ! -L "$folder" ] || return 0 # Resolved on disk, which sees through a symlinked T3 home and follows `..` as the OS does. if existing_dir="$(CDPATH='' cd -P -- "$(dirname -- "$existing")" 2>/dev/null && pwd)"; then real_existing="${existing_dir}/${existing##*/}" @@ -272,8 +280,8 @@ link_kind() { return 0 fi # Only a missing folder (a pruned version) leaves the text, and only without . or .. segments. - # A folder that exists but cannot be resolved, or is a link, may be another program's. - if [ -e "$(dirname -- "$existing")" ] || [ -L "$(dirname -- "$existing")" ]; then return 0; fi + # A folder that cannot be resolved for another reason may be another program's. + [ ! -e "$folder" ] && (CDPATH='' cd -P -- "$(dirname -- "$folder")" 2>/dev/null) || return 0 case "$existing" in */./* | */../*) ;; "${t3_home}/bin/t3") echo older ;; @@ -282,8 +290,9 @@ link_kind() { } kind= real_public="$(CDPATH='' cd -P -- "$bin_dir" && pwd)/t3" -# The command link is replaced in place, which is safe only away from the public name. -if [ "$real_public" = "$real_command_link" ]; then +# The command link is replaced in place, which is safe only away from the public name. -ef compares +# device and inode, so an alias or a bind mount of the runtime folder counts too. +if [ "$real_public" = "$real_command_link" ] || [ "$bin_dir" -ef "${t3_home}/runtime" ]; then fail "T3CODE_INSTALL_BIN_DIR cannot be ${t3_home}/runtime. Choose another folder." fi if [ -e "$public" ] || [ -L "$public" ]; then @@ -313,17 +322,25 @@ case "$kind" in mv "$public" "$aside" || { rmdir "$aside_dir"; fail "could not replace ${public}"; } if [ -n "$(link_kind "$aside")" ]; then if ! ln -s "$command_link" "${bin_dir}/"; then - # Keep the working old command when the name is still free. - ln -P "$aside" "${bin_dir}/" 2>/dev/null || true + # Keep the working old command when the name is still free; if it cannot go back, it stays. + ln -P "$aside" "${bin_dir}/" 2>/dev/null || + fail "could not link ${public}; the old link is at ${aside}." rm -f "$aside" && rmdir "$aside_dir" fail "could not link ${public}" fi rm -f "$aside" && rmdir "$aside_dir" else # Another program wrote it after the check: put it back without overwriting anything newer. - ln -P "$aside" "${bin_dir}/" 2>/dev/null || - fail "another program wrote ${public} during the install; its file is now at ${aside}." - rm -f "$aside" && rmdir "$aside_dir" + if [ -d "$aside" ] && [ ! -L "$aside" ]; then + # A folder cannot be hard linked, so it moves back while the name is free. + { [ ! -e "$public" ] && [ ! -L "$public" ] && mv "$aside" "$public"; } || + fail "another program wrote ${public} during the install; it is now at ${aside}." + rmdir "$aside_dir" + else + ln -P "$aside" "${bin_dir}/" 2>/dev/null || + fail "another program wrote ${public} during the install; it is now at ${aside}." + rm -f "$aside" && rmdir "$aside_dir" + fi fail "another t3 was just written at ${public}. Remove it and run this again, or run ${target_dir}/t3 directly." fi ;; diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 8011737f0aa29..3a5a3461480ae 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -186,6 +186,27 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 expect(borrowed.code).not.toBe(0); expect(await NodeFSP.readlink(link)).toBe(NodePath.join(home, "runtime/versions/0.9.0/t3")); + // A version folder that is a link (versions/current) is not followed, and a target that + // only ends in t3 before a newline is not ours either. + await NodeFSP.rm(link); + await NodeFSP.mkdir(NodePath.join(home, "runtime/versions/1.1.0"), { recursive: true }); + await NodeFSP.writeFile(NodePath.join(home, "runtime/versions/1.1.0/t3"), ""); + await NodeFSP.symlink( + NodePath.join(home, "runtime/versions/1.1.0"), + NodePath.join(home, "runtime/versions/current"), + ); + for (const target of [ + NodePath.join(home, "runtime/versions/current/t3"), + `${NodePath.join(home, "runtime/versions/1.0.0/t3")}\n`, + ]) { + await NodeFSP.rm(link, { force: true }); + await NodeFSP.symlink(target, link); + const notOurs = await installOver({ script: "install.sh", home, binDir }); + expect(notOurs.code).not.toBe(0); + expect(await NodeFSP.readlink(link)).toBe(target); + } + await NodeFSP.rm(NodePath.join(home, "runtime/versions/current")); + // Only a link to a file is ever replaced at the command link, even inside the T3 home. await NodeFSP.rm(link); await NodeFSP.symlink(NodePath.join(home, "runtime/versions/1.0.0/t3"), link); From 0e2a1a0628d2bcf505707cac1b69cd0ff21e62c7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 18:11:43 -0700 Subject: [PATCH 10/23] fix(cli): keep the command link where released t3 can update it The command link moves to runtime/versions/current/t3, so a t3 released before this change (whose update and uninstall own anything under versions, and whose prune skips non-version names) still updates and uninstalls a command made by the new installer. install.sh writes the T3 home by its real path, so a link target with `.`, `..`, or an empty segment is never ours. A bin folder that is the command link's own folder, by alias or bind mount, only switches that link. A put-back that fails now reports where the old link is. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/install.test.ts | 25 +++--- apps/server/src/cli/install.ts | 41 +++++----- apps/server/src/cli/uninstall.test.ts | 23 +++--- apps/server/src/cli/uninstall.ts | 11 +-- apps/server/src/cli/update.test.ts | 2 +- apps/server/src/cloud/pinnedRuntime.test.ts | 7 +- apps/server/src/cloud/pinnedRuntime.ts | 38 +++++---- packages/shared/src/ownedLink.ts | 2 +- scripts/install.sh | 86 +++++++++++---------- scripts/install.test.ts | 33 ++++---- 10 files changed, 141 insertions(+), 127 deletions(-) diff --git a/apps/server/src/cli/install.test.ts b/apps/server/src/cli/install.test.ts index 57c865e2052fd..85d2484f05187 100644 --- a/apps/server/src/cli/install.test.ts +++ b/apps/server/src/cli/install.test.ts @@ -57,7 +57,7 @@ it.layer(NodeServices.layer)("t3 install", (it) => { const { root, runtimeDir, executable, link, provide } = yield* setup; const older = path.join(runtimeDir, "versions/0.0.1/t3"); - const commandLink = path.join(runtimeDir, "t3"); + const commandLink = path.join(runtimeDir, "versions/current/t3"); yield* fs.makeDirectory(path.dirname(link), { recursive: true }); yield* fs.symlink(older, link); const installed = yield* provide(installCli()); @@ -107,11 +107,9 @@ it.layer(NodeServices.layer)("t3 install", (it) => { assert.equal(yield* fs.readLink(link), older); yield* fs.remove(commandLink); - // The command link is replaced in place, so it can never be the public name. - const inRuntime = yield* provide(installCli(), { T3CODE_INSTALL_BIN_DIR: runtimeDir }).pipe( - Effect.flip, - ); - assert.include(inRuntime.message, "T3CODE_INSTALL_BIN_DIR cannot be"); + // A bin folder that is the command link's own folder only switches that link. + yield* provide(installCli(), { T3CODE_INSTALL_BIN_DIR: path.dirname(commandLink) }); + assert.equal(yield* fs.readLink(commandLink), executable); // When the new link cannot be made, the old command keeps working. const failing = installCli().pipe( @@ -159,11 +157,16 @@ it.layer(NodeServices.layer)("t3 install", (it) => { yield* provide(installCli(), { T3CODE_INSTALL_BIN_DIR: root }); assert.equal(yield* fs.readLink(rootLink), commandLink); yield* fs.remove(rootLink); - // install.sh keeps a T3 home spelled with `.` as given. - yield* fs.remove(link); - yield* fs.symlink(`${root}/./.t3/runtime/versions/${VERSION}/t3`, link); - yield* provide(installCli()); - assert.equal(yield* fs.readLink(link), commandLink); + // t3 writes plain targets, so one with `.` or `//` is left as it is. + for (const spelled of [ + `${root}/./.t3/runtime/versions/${VERSION}/t3`, + `${root}//.t3/runtime/versions/${VERSION}/t3`, + ]) { + yield* fs.remove(link); + yield* fs.symlink(spelled, link); + assert.include((yield* provide(installCli()).pipe(Effect.flip)).message, "Another t3"); + assert.equal(yield* fs.readLink(link), spelled); + } // This home's desktop launcher is replaced, as install.sh does. yield* fs.remove(link); diff --git a/apps/server/src/cli/install.ts b/apps/server/src/cli/install.ts index 6a9adc6250112..3bb67a3ddfc1c 100644 --- a/apps/server/src/cli/install.ts +++ b/apps/server/src/cli/install.ts @@ -62,7 +62,20 @@ const classifyCommandLink = Effect.fn("cli.install.classify_command_link")(funct ); const commandLink = pinnedRuntimeCommandLink(path, baseDir); const realCommandLink = yield* real(commandLink); - if ((yield* real(link)) === realCommandLink) return "current"; + // The command link itself, whatever its folder is called (an alias or a bind mount). + const folderId = (file: string) => + fs.stat(path.dirname(file)).pipe( + Effect.map((info) => Option.map(info.ino, (ino) => `${info.dev}:${ino}`)), + Effect.orElseSucceed(() => Option.none()), + ); + const linkFolder = yield* folderId(link); + if ( + path.basename(link) === path.basename(commandLink) && + Option.isSome(linkFolder) && + Option.getOrUndefined(yield* folderId(commandLink)) === linkFolder.value + ) { + return "current"; + } // Only the direct target: a chain through another tool's link is that tool's. const targets = yield* directLinkTargets(link); if (targets.length === 0 && Option.isNone(yield* fs.readLink(link).pipe(Effect.option))) { @@ -101,7 +114,14 @@ export const linkCommand = Effect.fn("cli.install.link_command")(function* (inpu if (kind === "foreign") return false; const commandLink = pinnedRuntimeCommandLink(path, input.baseDir); // Inside the T3 home, so swapping it in place cannot replace another program's file. Still, - // only a link is ever replaced there. + // only a link in a real folder is ever replaced there. + const folder = path.dirname(commandLink); + if (Option.isSome(yield* fs.readLink(folder).pipe(Effect.option))) { + return yield* new CliInstallError({ + reason: `${folder} is a link, not a folder. Remove it, then run this again.`, + }); + } + yield* fs.makeDirectory(folder, { recursive: true }); if ( Option.isNone(yield* fs.readLink(commandLink).pipe(Effect.option)) && (yield* fs.exists(commandLink)) @@ -204,23 +224,6 @@ export const installCli = Effect.fn("cli.install.install")(function* () { yield* fs .makeDirectory(binDir, { recursive: true }) .pipe(Effect.mapError(failed(`Could not create ${binDir}.`))); - // The command link is replaced in place, which is safe only away from the public name. Same - // device and inode, so an alias or a bind mount of the runtime folder counts too. - const identity = (directory: string) => - fs.stat(directory).pipe( - Effect.map((info) => - Option.match(info.ino, { - onSome: (ino) => `${info.dev}:${ino}`, - onNone: () => directory, - }), - ), - Effect.orElseSucceed(() => directory), - ); - if ((yield* identity(binDir)) === (yield* identity(runtimeDir))) { - return yield* new CliInstallError({ - reason: `T3CODE_INSTALL_BIN_DIR cannot be ${runtimeDir}. Choose another folder.`, - }); - } const linked = yield* linkCommand({ link, baseDir: path.dirname(runtimeDir), executable }).pipe( Effect.catchTags({ PlatformError: (cause) => diff --git a/apps/server/src/cli/uninstall.test.ts b/apps/server/src/cli/uninstall.test.ts index 179488d4483b9..9cff7ca8d5e16 100644 --- a/apps/server/src/cli/uninstall.test.ts +++ b/apps/server/src/cli/uninstall.test.ts @@ -33,8 +33,9 @@ it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => { assert.equal(yield* findOwnedLauncher({ launchedAs: ours, versionsDir }), ours); // Installs link to the command link, which points at the version. - const commandLink = path.join(root, "runtime/t3"); + const commandLink = path.join(versionsDir, "current/t3"); const viaCommandLink = path.join(root, "bin/t3-command"); + yield* fs.makeDirectory(path.dirname(commandLink)); yield* fs.symlink(exe, commandLink); yield* fs.symlink(commandLink, viaCommandLink); assert.equal( @@ -64,11 +65,11 @@ it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => { const borrowed = path.join(root, "bin/t3-borrowed"); yield* fs.symlink(path.join(versionsDir, "2.0.0/t3"), borrowed); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: borrowed, versionsDir })); - // A version folder that is a link to one of ours (versions/current) is not followed. - yield* fs.symlink(path.join(versionsDir, "1.0.0"), path.join(versionsDir, "current")); - const current = path.join(root, "bin/t3-current"); - yield* fs.symlink(path.join(versionsDir, "current/t3"), current); - assert.isUndefined(yield* findOwnedLauncher({ launchedAs: current, versionsDir })); + // A version folder that is a link to one of ours (versions/latest) is not followed. + yield* fs.symlink(path.join(versionsDir, "1.0.0"), path.join(versionsDir, "latest")); + const latest = path.join(root, "bin/t3-latest"); + yield* fs.symlink(path.join(versionsDir, "latest/t3"), latest); + assert.isUndefined(yield* findOwnedLauncher({ launchedAs: latest, versionsDir })); // A version folder link that leads nowhere is not missing; it may be another tool's. yield* fs.symlink(path.join(root, "unmounted"), path.join(versionsDir, "2.1.0")); const unmounted = path.join(root, "bin/t3-unmounted"); @@ -121,12 +122,10 @@ it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => { // The command link leads to a pruned version, so only its name says it is ours. const otherRoot = path.join(root, "pruned-home"); const prunedCommand = path.join(root, "bin/t3-pruned-command"); - yield* fs.makeDirectory(path.join(otherRoot, "runtime/versions"), { recursive: true }); - yield* fs.symlink( - path.join(otherRoot, "runtime/versions/0.9.0/t3"), - path.join(otherRoot, "runtime/t3"), - ); - yield* fs.symlink(path.join(otherRoot, "runtime/t3"), prunedCommand); + const prunedCommandLink = path.join(otherRoot, "runtime/versions/current/t3"); + yield* fs.makeDirectory(path.dirname(prunedCommandLink), { recursive: true }); + yield* fs.symlink(path.join(otherRoot, "runtime/versions/0.9.0/t3"), prunedCommandLink); + yield* fs.symlink(prunedCommandLink, prunedCommand); yield* fs.writeFileString(path.join(otherRoot, "runtime/launchers"), `${prunedCommand}\n`); assert.deepStrictEqual( yield* findRecordedLaunchers(path.join(otherRoot, "runtime/versions")), diff --git a/apps/server/src/cli/uninstall.ts b/apps/server/src/cli/uninstall.ts index 64c030ed2c376..c39e549149901 100644 --- a/apps/server/src/cli/uninstall.ts +++ b/apps/server/src/cli/uninstall.ts @@ -61,7 +61,7 @@ export const findOwnedLauncher = Effect.fn("cli.uninstall.find_launcher")(functi return (yield* ownsLauncher(input.launchedAs, input.versionsDir)) ? input.launchedAs : undefined; }); -/** Whether `launcher` is a link to this home's command link or a version, or an unedited `t3.cmd`. */ +/** Whether `launcher` links to this home's command link or a version, or is an unedited `t3.cmd`. */ const ownsLauncher = Effect.fn("cli.uninstall.owns_launcher")(function* ( launcher: string, versionsDir: string, @@ -82,18 +82,11 @@ const ownsLauncher = Effect.fn("cli.uninstall.owns_launcher")(function* ( ), ] : yield* directLinkTargets(launcher); - // The command link sits beside versions, in the runtime folder itself; versions may be a link. - const runtimeDir = path.dirname(versionsDir); - const commandLinks = [ - runtimeDir, - yield* fs.realPath(runtimeDir).pipe(Effect.orElseSucceed(() => runtimeDir)), - ].map((dir) => path.join(dir, "t3")); return targets.some( (target) => target !== undefined && (isVersionExecutable(path, versionsDir, target) || - isVersionExecutable(path, realVersionsDir, target) || - (platform !== "win32" && commandLinks.includes(target))), + isVersionExecutable(path, realVersionsDir, target)), ); }); diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index a816b8f2ed8d6..e42b59b78ddf7 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -44,7 +44,7 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { const oldExe = path.join(root, "runtime/versions/1.0.0/t3"); const newExe = path.join(root, "runtime/versions/2.0.0/t3"); const newerExe = path.join(root, "runtime/versions/3.0.0/t3"); - const commandLink = path.join(root, "runtime/t3"); + const commandLink = path.join(root, "runtime/versions/current/t3"); const launcher = path.join(root, "bin/t3"); for (const file of [oldExe, newExe, newerExe]) { yield* fs.makeDirectory(path.dirname(file), { recursive: true }); diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index 9f0e7d882fb23..8c65dd4a7a3cb 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -389,7 +389,11 @@ it.layer(NodeServices.layer)("prunePinnedRuntimes", (it) => { yield* fs.symlink(path.join(baseDir, "alias-t3"), path.join(binDir, "t3")); yield* fs.symlink(path.join(versionsDir, "1.2.5", "t3"), path.join(customBinDir, "t3")); // The command link, which no launcher that can be found leads to. - yield* fs.symlink(path.join(versionsDir, "1.1.0", "t3"), path.join(baseDir, "runtime", "t3")); + yield* fs.makeDirectory(path.join(versionsDir, "current")); + yield* fs.symlink( + path.join(versionsDir, "1.1.0", "t3"), + path.join(versionsDir, "current", "t3"), + ); yield* fs.writeFileString( path.join(baseDir, "runtime", "launchers"), `${path.join(customBinDir, "t3")}\n`, @@ -438,6 +442,7 @@ it.layer(NodeServices.layer)("prunePinnedRuntimes", (it) => { "1.4.0", "1.5.0", "2.0.0", + "current", "other", ]); }), diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 840f37ea4e432..da82bbf77bc77 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -54,11 +54,13 @@ export function pinnedRuntimeVersionsDir(path: Path.Path, baseDir: string): stri } /** - * `/runtime/t3`, the link the public `t3` command points at. Upgrades switch only this - * link, so the public name, where other programs write too, is never replaced. + * `/runtime/versions/current/t3`, the link the public `t3` command points at. Upgrades + * switch only this link, so the public name, where other programs write too, is never replaced. + * It sits in versions so a t3 from before it still updates and uninstalls that command; prune + * skips `current`, which is not a version. */ export function pinnedRuntimeCommandLink(path: Path.Path, baseDir: string): string { - return path.join(baseDir, PINNED_RUNTIME_DIR, "t3"); + return path.join(pinnedRuntimeVersionsDir(path, baseDir), "current", "t3"); } export function pinnedRuntimePaths( @@ -401,8 +403,8 @@ export function launcherOwnsVersionsDir( /** * Where the symlink at `link` points, not following that target any further: the target with its * folder resolved on disk, which sees through a symlinked T3 home. A folder that is itself a link - * (`versions/current`) is not followed. Only when the folder is missing (a pruned version) does - * the text count, and only without `.` or `..`. Empty for a non-link. + * (another `versions/`) is not followed. Only when the folder is missing (a pruned version) + * does the text count. Empty for a non-link, or a target t3 never writes. */ export const directLinkTargets = Effect.fn("cloud.pinned_runtime.direct_link_targets")(function* ( link: string, @@ -410,24 +412,30 @@ export const directLinkTargets = Effect.fn("cloud.pinned_runtime.direct_link_tar const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const text = yield* fs.readLink(link).pipe(Effect.option); - // A target naming a folder (`t3/`, `t3/.`) never runs the file it would resolve to. - if (Option.isNone(text) || /(?:^|[\\/])\.{0,2}$/.test(text.value)) return []; - const raw = path.isAbsolute(text.value) - ? text.value - : `${path.dirname(link)}${path.sep}${text.value}`; - const folder = path.dirname(raw); + if (Option.isNone(text)) return []; + // t3 writes plain targets; `.`, `..`, or an empty segment (`t3/`, `a//t3`) is someone else's. + const parts = text.value.split(/[\\/]/); + if (parts.some((part, index) => part === "." || part === ".." || (part === "" && index > 0))) { + return []; + } + const target = path.resolve(path.dirname(link), text.value); + const folder = path.dirname(target); if (Option.isSome(yield* fs.readLink(folder).pipe(Effect.option))) return []; - // Native and unnormalized, so `..` after a symlinked folder goes where the OS takes it. const physical = yield* Effect.tryPromise({ try: () => NodeFSP.realpath(folder), catch: (error) => (error as NodeJS.ErrnoException).code, }).pipe( - Effect.map((directory) => path.join(directory, path.basename(raw))), + Effect.map((directory) => path.join(directory, path.basename(target))), Effect.result, ); if (Result.isSuccess(physical)) return [physical.success]; - const plain = !text.value.split(/[\\/]/).some((part) => part === "." || part === ".."); - return physical.failure === "ENOENT" && plain ? [path.resolve(raw)] : []; + if (physical.failure !== "ENOENT") return []; + // The folder's own parent still resolves, which sees through a symlinked T3 home. + const parent = yield* Effect.tryPromise(() => NodeFSP.realpath(path.dirname(folder))).pipe( + Effect.map((directory) => [path.join(directory, path.basename(folder), path.basename(target))]), + Effect.orElseSucceed(() => []), + ); + return [target, ...parent]; }); /** Whether `candidate` is a version's own executable, `//t3[.exe]`. */ diff --git a/packages/shared/src/ownedLink.ts b/packages/shared/src/ownedLink.ts index 44759f46803fb..cbee66d2b41e2 100644 --- a/packages/shared/src/ownedLink.ts +++ b/packages/shared/src/ownedLink.ts @@ -66,7 +66,7 @@ export const replaceOwnedLink = Effect.fn("ownedLink.replace")(function* ( // if it cannot, it stays beside `link`. yield* fs .symlink(input.target, input.link) - .pipe(Effect.tapError(() => putBack.pipe(Effect.andThen(discard), Effect.ignore))); + .pipe(Effect.tapError(() => putBack.pipe(Effect.andThen(discard.pipe(Effect.ignore))))); } yield* discard.pipe(Effect.ignore); return true; diff --git a/scripts/install.sh b/scripts/install.sh index a174e0c8f5fd5..38c2efb091207 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -15,8 +15,8 @@ # The archive is unpacked into $T3CODE_HOME/runtime/versions/, the # same layout `t3 service install` uses, so the service reuses this download # instead of fetching the release again. -# The `t3` symlink points at $T3CODE_HOME/runtime/t3, which points at that -# version, so upgrades never replace a file in the bin folder. +# The `t3` symlink points at $T3CODE_HOME/runtime/versions/current/t3, which +# points at that version, so upgrades never replace a file in the bin folder. set -eu repo="pingdotgg/t3code" @@ -26,10 +26,8 @@ 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, and -# the links never resolve against the folder they sit in. +# 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 -case "$t3_home" in /*) ;; *) t3_home="$PWD/$t3_home" ;; esac fail() { printf '\nt3 install: %s\n' "$1" >&2 @@ -193,6 +191,9 @@ case "$version" in ;; esac +# Real and absolute, so the links never hold `.`, `..`, or a path relative to their own folder. +mkdir -p "$t3_home" +t3_home="$(CDPATH='' cd -P -- "$t3_home" && pwd)" stem="t3-${version}-${platform}-${arch}" archive="${stem}.tar.gz" versions_dir="${t3_home}/runtime/versions" @@ -242,9 +243,20 @@ mkdir -p "$bin_dir" public="${bin_dir}/t3" # The t3 command links to this home's command link, and upgrades switch only that one. Other # programs write to bin_dir too, so the public name is never replaced while it may be theirs. -command_link="${t3_home}/runtime/t3" +# In versions, so a t3 from before it still updates and uninstalls the command. +current_dir="${versions_dir}/current" +command_link="${current_dir}/t3" +# Inside the T3 home, so swapping it in place cannot replace another program's file. Still, +# only a link in a real folder is ever replaced there; mv would move into a folder. +if [ -L "$current_dir" ] || { [ -e "$current_dir" ] && [ ! -d "$current_dir" ]; }; then + fail "${current_dir} is not a folder. Remove it and run this again." +fi +mkdir -p "$current_dir" +if [ -d "$command_link" ] || { [ -e "$command_link" ] && [ ! -L "$command_link" ]; }; then + fail "${command_link} is not a link to a file. Remove it and run this again." +fi real_versions_dir="$(CDPATH='' cd -P -- "$versions_dir" && pwd)" -real_command_link="$(CDPATH='' cd -P -- "${t3_home}/runtime" && pwd)/t3" +real_command_link="$(CDPATH='' cd -P -- "$current_dir" && pwd)/t3" # Exactly //t3 ($1 under $2), never another program inside a version. version_t3() { case "$1" in "$2"/*) ;; *) return 1 ;; esac @@ -252,7 +264,8 @@ version_t3() { } newline=" " -real_desktop_launcher= +# t3_home is already real; its bin folder may be a link, or missing until the app runs. +real_desktop_launcher="${t3_home}/bin/t3" if real_bin="$(CDPATH='' cd -P -- "${t3_home}/bin" 2>/dev/null && pwd)"; then real_desktop_launcher="${real_bin}/t3" fi @@ -264,14 +277,15 @@ link_kind() { existing="$(readlink "$1" 2>/dev/null && echo x)" || return 0 existing="${existing%x}" existing="${existing%"$newline"}" - # A target naming a folder (t3/, t3/.) never runs the file it would resolve to. - case "$existing" in "" | . | .. | */ | */. | */..) return 0 ;; esac + # t3 writes plain targets; `.`, `..`, or an empty segment (`t3/`, `a//t3`) is someone else's. + case "$existing" in /*) segments="${existing}/" ;; *) segments="/${existing}/" ;; esac + case "$segments" in *//* | */./* | */../*) return 0 ;; esac case "$existing" in /*) ;; *) existing="${bin_dir}/${existing}" ;; esac - # A folder that is itself a link (versions/current) is not followed. + # A folder that is itself a link (another versions/) is not followed. folder="$(dirname -- "$existing")" [ ! -L "$folder" ] || return 0 - # Resolved on disk, which sees through a symlinked T3 home and follows `..` as the OS does. - if existing_dir="$(CDPATH='' cd -P -- "$(dirname -- "$existing")" 2>/dev/null && pwd)"; then + # Resolved on disk, which sees through a symlinked T3 home. + if existing_dir="$(CDPATH='' cd -P -- "$folder" 2>/dev/null && pwd)"; then real_existing="${existing_dir}/${existing##*/}" if [ "$real_existing" = "$real_command_link" ]; then echo current elif [ "$real_existing" = "$real_desktop_launcher" ] || version_t3 "$real_existing" "$real_versions_dir"; then @@ -279,31 +293,25 @@ link_kind() { fi return 0 fi - # Only a missing folder (a pruned version) leaves the text, and only without . or .. segments. - # A folder that cannot be resolved for another reason may be another program's. - [ ! -e "$folder" ] && (CDPATH='' cd -P -- "$(dirname -- "$folder")" 2>/dev/null) || return 0 - case "$existing" in - */./* | */../*) ;; - "${t3_home}/bin/t3") echo older ;; - *) if version_t3 "$existing" "$versions_dir" || version_t3 "$existing" "$real_versions_dir"; then echo older; fi ;; - esac + # Only a missing folder (a pruned version) leaves the text. A folder that cannot be resolved for + # another reason may be another program's. + [ ! -e "$folder" ] || return 0 + parent="$(CDPATH='' cd -P -- "$(dirname -- "$folder")" 2>/dev/null && pwd)" || return 0 + real_existing="${parent}/${folder##*/}/${existing##*/}" + if [ "$real_existing" = "$real_desktop_launcher" ] || version_t3 "$real_existing" "$real_versions_dir"; then + echo older + fi } kind= real_public="$(CDPATH='' cd -P -- "$bin_dir" && pwd)/t3" -# The command link is replaced in place, which is safe only away from the public name. -ef compares -# device and inode, so an alias or a bind mount of the runtime folder counts too. -if [ "$real_public" = "$real_command_link" ] || [ "$bin_dir" -ef "${t3_home}/runtime" ]; then - fail "T3CODE_INSTALL_BIN_DIR cannot be ${t3_home}/runtime. Choose another folder." -fi -if [ -e "$public" ] || [ -L "$public" ]; then +# The command link itself, by any name for its folder: -ef compares device and inode, so an alias +# or a bind mount counts too. +if [ "$real_public" = "$real_command_link" ] || [ "$bin_dir" -ef "$current_dir" ]; then + kind=current +elif [ -e "$public" ] || [ -L "$public" ]; then kind="$(link_kind "$public")" [ -n "$kind" ] || fail "another t3 is already at ${public}. Remove it and run this again, or run ${target_dir}/t3 directly." fi -# Inside the T3 home, so swapping it in place cannot replace another program's file. Still, -# only a link to a file is ever replaced there; mv would move into a folder. -if [ -d "$command_link" ] || { [ -e "$command_link" ] && [ ! -L "$command_link" ]; }; then - fail "${command_link} is not a link to a file. Remove it and run this again." -fi rm -f "${command_link}.$$.tmp" if ! { ln -s "${target_dir}/t3" "${command_link}.$$.tmp" && mv -f "${command_link}.$$.tmp" "$command_link"; }; then rm -f "${command_link}.$$.tmp" @@ -331,16 +339,10 @@ case "$kind" in rm -f "$aside" && rmdir "$aside_dir" else # Another program wrote it after the check: put it back without overwriting anything newer. - if [ -d "$aside" ] && [ ! -L "$aside" ]; then - # A folder cannot be hard linked, so it moves back while the name is free. - { [ ! -e "$public" ] && [ ! -L "$public" ] && mv "$aside" "$public"; } || - fail "another program wrote ${public} during the install; it is now at ${aside}." - rmdir "$aside_dir" - else - ln -P "$aside" "${bin_dir}/" 2>/dev/null || - fail "another program wrote ${public} during the install; it is now at ${aside}." - rm -f "$aside" && rmdir "$aside_dir" - fi + # A folder cannot be hard linked; it stays where it is, and the message says where. + ln -P "$aside" "${bin_dir}/" 2>/dev/null || + fail "another program wrote ${public} during the install; it is now at ${aside}." + rm -f "$aside" && rmdir "$aside_dir" fail "another t3 was just written at ${public}. Remove it and run this again, or run ${target_dir}/t3 directly." fi ;; diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 3a5a3461480ae..8afdd68d1c9f1 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -165,9 +165,10 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 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/t3")); - expect(await NodeFSP.readlink(NodePath.join(home, "runtime/t3"))).toBe( - NodePath.join(root, "alias/runtime/versions/1.2.3/t3"), + // The links name the T3 home by its real path. + expect(await NodeFSP.readlink(link)).toBe(NodePath.join(home, "runtime/versions/current/t3")); + expect(await NodeFSP.readlink(NodePath.join(home, "runtime/versions/current/t3"))).toBe( + NodePath.join(home, "runtime/versions/1.2.3/t3"), ); expect(upgrade.output).toContain( `printf '\\n%s\\n' 'export PATH="$PATH:$HOME/bin"' >> ~/.zshrc`, @@ -186,17 +187,17 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 expect(borrowed.code).not.toBe(0); expect(await NodeFSP.readlink(link)).toBe(NodePath.join(home, "runtime/versions/0.9.0/t3")); - // A version folder that is a link (versions/current) is not followed, and a target that + // A version folder that is a link (versions/latest) is not followed, and a target that // only ends in t3 before a newline is not ours either. await NodeFSP.rm(link); await NodeFSP.mkdir(NodePath.join(home, "runtime/versions/1.1.0"), { recursive: true }); await NodeFSP.writeFile(NodePath.join(home, "runtime/versions/1.1.0/t3"), ""); await NodeFSP.symlink( NodePath.join(home, "runtime/versions/1.1.0"), - NodePath.join(home, "runtime/versions/current"), + NodePath.join(home, "runtime/versions/latest"), ); for (const target of [ - NodePath.join(home, "runtime/versions/current/t3"), + NodePath.join(home, "runtime/versions/latest/t3"), `${NodePath.join(home, "runtime/versions/1.0.0/t3")}\n`, ]) { await NodeFSP.rm(link, { force: true }); @@ -205,12 +206,12 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 expect(notOurs.code).not.toBe(0); expect(await NodeFSP.readlink(link)).toBe(target); } - await NodeFSP.rm(NodePath.join(home, "runtime/versions/current")); + await NodeFSP.rm(NodePath.join(home, "runtime/versions/latest")); // Only a link to a file is ever replaced at the command link, even inside the T3 home. await NodeFSP.rm(link); await NodeFSP.symlink(NodePath.join(home, "runtime/versions/1.0.0/t3"), link); - const commandLink = NodePath.join(home, "runtime/t3"); + const commandLink = NodePath.join(home, "runtime/versions/current/t3"); await NodeFSP.mkdir(NodePath.join(root, "folder")); for (const make of [ () => NodeFSP.writeFile(commandLink, "mine\n"), @@ -224,14 +225,14 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 expect(await NodeFSP.readdir(NodePath.join(root, "folder"))).toEqual([]); } await NodeFSP.rm(commandLink); - // The command link is replaced in place, so it can never be the public name. - const inRuntime = await installOver({ + // A bin folder that is the command link's own folder only switches that link. + const inCurrent = await installOver({ script: "install.sh", home, - binDir: NodePath.join(home, "runtime"), + binDir: NodePath.join(home, "runtime/versions/current"), }); - expect(inRuntime.code).not.toBe(0); - expect(inRuntime.output).toContain("T3CODE_INSTALL_BIN_DIR cannot be"); + expect(inCurrent.code, inCurrent.output).toBe(0); + expect(await NodeFSP.readlink(commandLink)).toBe(NodePath.join(inCurrent.target, "t3")); // Containers and CI often have no SHELL; `set -u` must not stop the installer there. const noShell = await installOver({ @@ -262,7 +263,7 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 cwd: root, }); expect(relativeHome.code, relativeHome.output).toBe(0); - expect(await NodeFSP.readlink(link)).toBe(NodePath.join(home, "runtime/t3")); + expect(await NodeFSP.readlink(link)).toBe(NodePath.join(home, "runtime/versions/current/t3")); await NodeFSP.rm(link); await NodeFSP.symlink(NodePath.join(home, "bin/t3"), link); @@ -272,7 +273,7 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 binDir: `${binDir}/`, }); expect(overDesktop.code, overDesktop.output).toBe(0); - expect(await NodeFSP.readlink(link)).toBe(NodePath.join(home, "runtime/t3")); + expect(await NodeFSP.readlink(link)).toBe(NodePath.join(home, "runtime/versions/current/t3")); } finally { await NodeFSP.rm(root, { recursive: true, force: true }); } @@ -284,7 +285,7 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 const home = NodePath.join(root, "home"); const binDir = NodePath.join(root, "bin"); const link = NodePath.join(binDir, "t3"); - const commandLink = NodePath.join(home, "runtime/t3"); + const commandLink = NodePath.join(home, "runtime/versions/current/t3"); const tools = NodePath.join(root, "tools"); await NodeFSP.mkdir(binDir, { recursive: true }); await NodeFSP.mkdir(tools); From 5ebe7598c0074bf8f772f30d4b5951ab07f570a3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 18:17:39 -0700 Subject: [PATCH 11/23] fix(cli): write the T3 home as t3 spells it, and accept released spellings install.sh writes the T3 home as Node's path.resolve gives it, keeping a symlinked home's own name, so released t3 still owns the links. A link with `//` or `/./`, which released installers wrote, is still ours; only `..` and folder-naming targets are refused. The bin folder cannot be the command link's own folder. Update, uninstall, and desktop pass the cause on. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/app/DesktopCliCommand.ts | 2 +- apps/server/src/cli/install.test.ts | 22 +++++++++----- apps/server/src/cli/install.ts | 18 ++++++++++++ apps/server/src/cli/uninstall.ts | 5 +++- apps/server/src/cli/update.ts | 6 ++-- apps/server/src/cloud/pinnedRuntime.ts | 9 +++--- scripts/install.sh | 35 +++++++++++++++-------- scripts/install.test.ts | 21 ++++++++++---- 8 files changed, 84 insertions(+), 34 deletions(-) diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 201bc6bbf66bd..437c8c03ddd68 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -368,7 +368,7 @@ export const make = Effect.gen(function* () { const link = path.join(directory, "t3"); if (yield* isOurLink(link)) { yield* replaceOurLink({ link }).pipe( - Effect.mapError(() => fail(`Could not remove ${link}.`)), + Effect.mapError((error) => fail(`Could not remove ${link}: ${error.message}`)), ); } } diff --git a/apps/server/src/cli/install.test.ts b/apps/server/src/cli/install.test.ts index 85d2484f05187..1782e3a178904 100644 --- a/apps/server/src/cli/install.test.ts +++ b/apps/server/src/cli/install.test.ts @@ -107,9 +107,11 @@ it.layer(NodeServices.layer)("t3 install", (it) => { assert.equal(yield* fs.readLink(link), older); yield* fs.remove(commandLink); - // A bin folder that is the command link's own folder only switches that link. - yield* provide(installCli(), { T3CODE_INSTALL_BIN_DIR: path.dirname(commandLink) }); - assert.equal(yield* fs.readLink(commandLink), executable); + // The command link is replaced in place, so its folder can never be the bin folder. + const inCurrent = yield* provide(installCli(), { + T3CODE_INSTALL_BIN_DIR: path.dirname(commandLink), + }).pipe(Effect.flip); + assert.include(inCurrent.message, "T3CODE_INSTALL_BIN_DIR cannot be"); // When the new link cannot be made, the old command keeps working. const failing = installCli().pipe( @@ -157,16 +159,22 @@ it.layer(NodeServices.layer)("t3 install", (it) => { yield* provide(installCli(), { T3CODE_INSTALL_BIN_DIR: root }); assert.equal(yield* fs.readLink(rootLink), commandLink); yield* fs.remove(rootLink); - // t3 writes plain targets, so one with `.` or `//` is left as it is. + // Released installers wrote `//` for a T3 home set with a trailing slash; that is still ours. for (const spelled of [ + `${root}/.t3//runtime/versions/${VERSION}/t3`, `${root}/./.t3/runtime/versions/${VERSION}/t3`, - `${root}//.t3/runtime/versions/${VERSION}/t3`, ]) { yield* fs.remove(link); yield* fs.symlink(spelled, link); - assert.include((yield* provide(installCli()).pipe(Effect.flip)).message, "Another t3"); - assert.equal(yield* fs.readLink(link), spelled); + yield* provide(installCli()); + assert.equal(yield* fs.readLink(link), commandLink); } + // `..` can lead elsewhere once a link is followed, so it is left as it is. + const climbing = `${root}/.t3/runtime/versions/x/../${VERSION}/t3`; + yield* fs.remove(link); + yield* fs.symlink(climbing, link); + assert.include((yield* provide(installCli()).pipe(Effect.flip)).message, "Another t3"); + assert.equal(yield* fs.readLink(link), climbing); // This home's desktop launcher is replaced, as install.sh does. yield* fs.remove(link); diff --git a/apps/server/src/cli/install.ts b/apps/server/src/cli/install.ts index 3bb67a3ddfc1c..03b86a6fa0be1 100644 --- a/apps/server/src/cli/install.ts +++ b/apps/server/src/cli/install.ts @@ -224,6 +224,24 @@ export const installCli = Effect.fn("cli.install.install")(function* () { yield* fs .makeDirectory(binDir, { recursive: true }) .pipe(Effect.mapError(failed(`Could not create ${binDir}.`))); + // The command link is replaced in place, which is safe only away from the public name. Same + // device and inode, so an alias or a bind mount of its folder counts too. + const commandFolder = path.dirname(pinnedRuntimeCommandLink(path, path.dirname(runtimeDir))); + const folderId = (directory: string) => + fs.stat(directory).pipe( + Effect.map((info) => Option.map(info.ino, (ino) => `${info.dev}:${ino}`)), + Effect.orElseSucceed(() => Option.none()), + ); + const binId = yield* folderId(binDir); + if ( + path.resolve(binDir) === commandFolder || + (Option.isSome(binId) && + Option.getOrUndefined(yield* folderId(commandFolder)) === binId.value) + ) { + return yield* new CliInstallError({ + reason: `T3CODE_INSTALL_BIN_DIR cannot be ${commandFolder}. Choose another folder.`, + }); + } const linked = yield* linkCommand({ link, baseDir: path.dirname(runtimeDir), executable }).pipe( Effect.catchTags({ PlatformError: (cause) => diff --git a/apps/server/src/cli/uninstall.ts b/apps/server/src/cli/uninstall.ts index c39e549149901..5b787e8d6e8ef 100644 --- a/apps/server/src/cli/uninstall.ts +++ b/apps/server/src/cli/uninstall.ts @@ -248,7 +248,10 @@ const runUninstall = Effect.fn("cli.uninstall.run")(function* (input: { : replaceOwnedLink({ link: launcher, owns }) ).pipe( Effect.mapError( - () => new CliUninstallError({ reason: `Could not remove the launcher at ${launcher}.` }), + (error) => + new CliUninstallError({ + reason: `Could not remove the launcher at ${launcher}: ${error.message}`, + }), ), )); if (!removed) { diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index 560ce6ece512e..e00c175376b5a 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -136,8 +136,10 @@ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function executable: input.targetEntryPath, }).pipe( Effect.mapError( - () => - new CliUpdateError({ reason: `Could not repoint the t3 launcher at ${input.launchedAs}.` }), + (error) => + new CliUpdateError({ + reason: `Could not repoint the t3 launcher at ${input.launchedAs}: ${error.message}`, + }), ), ); return linked ? Option.some(input.launchedAs) : Option.none(); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index da82bbf77bc77..13abbb8a9e89c 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -404,7 +404,7 @@ export function launcherOwnsVersionsDir( * Where the symlink at `link` points, not following that target any further: the target with its * folder resolved on disk, which sees through a symlinked T3 home. A folder that is itself a link * (another `versions/`) is not followed. Only when the folder is missing (a pruned version) - * does the text count. Empty for a non-link, or a target t3 never writes. + * does the text count. Empty for a non-link, or a target with `..` or naming a folder. */ export const directLinkTargets = Effect.fn("cloud.pinned_runtime.direct_link_targets")(function* ( link: string, @@ -413,11 +413,10 @@ export const directLinkTargets = Effect.fn("cloud.pinned_runtime.direct_link_tar const path = yield* Path.Path; const text = yield* fs.readLink(link).pipe(Effect.option); if (Option.isNone(text)) return []; - // t3 writes plain targets; `.`, `..`, or an empty segment (`t3/`, `a//t3`) is someone else's. + // `..` can mean another folder once a link is followed, and a target naming a folder (`t3/`, + // `t3/.`) never runs the file it would resolve to. Other spellings resolve on disk below. const parts = text.value.split(/[\\/]/); - if (parts.some((part, index) => part === "." || part === ".." || (part === "" && index > 0))) { - return []; - } + if (parts.includes("..") || [".", ""].includes(parts.at(-1)!)) return []; const target = path.resolve(path.dirname(link), text.value); const folder = path.dirname(target); if (Option.isSome(yield* fs.readLink(folder).pipe(Effect.option))) return []; diff --git a/scripts/install.sh b/scripts/install.sh index 38c2efb091207..88d8df7a01806 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -191,9 +191,18 @@ case "$version" in ;; esac -# Real and absolute, so the links never hold `.`, `..`, or a path relative to their own folder. -mkdir -p "$t3_home" -t3_home="$(CDPATH='' cd -P -- "$t3_home" && pwd)" +# Spelled as Node's path.resolve gives it, which is the T3 home every t3 compares links against: +# absolute, without `.`, `..`, or `//`, but keeping a symlinked home's own name. +t3_home="$( + case "$t3_home" in /*) rest="$t3_home" ;; *) rest="$PWD/$t3_home" ;; esac + out= + IFS=/ + set -f + for part in $rest; do + case "$part" in "" | .) ;; ..) out="${out%/*}" ;; *) out="$out/$part" ;; esac + done + printf '%s' "${out:-/}" +)" stem="t3-${version}-${platform}-${arch}" archive="${stem}.tar.gz" versions_dir="${t3_home}/runtime/versions" @@ -264,8 +273,8 @@ version_t3() { } newline=" " -# t3_home is already real; its bin folder may be a link, or missing until the app runs. -real_desktop_launcher="${t3_home}/bin/t3" +# Its bin folder may be a link, or missing until the app runs. +real_desktop_launcher="$(CDPATH='' cd -P -- "$t3_home" && pwd)/bin/t3" if real_bin="$(CDPATH='' cd -P -- "${t3_home}/bin" 2>/dev/null && pwd)"; then real_desktop_launcher="${real_bin}/t3" fi @@ -277,9 +286,10 @@ link_kind() { existing="$(readlink "$1" 2>/dev/null && echo x)" || return 0 existing="${existing%x}" existing="${existing%"$newline"}" - # t3 writes plain targets; `.`, `..`, or an empty segment (`t3/`, `a//t3`) is someone else's. - case "$existing" in /*) segments="${existing}/" ;; *) segments="/${existing}/" ;; esac - case "$segments" in *//* | */./* | */../*) return 0 ;; esac + # `..` can mean another folder once a link is followed, and a target naming a folder (`t3/`, + # `t3/.`) never runs the file it would resolve to. Other spellings resolve on disk below. + case "/${existing}/" in */../*) return 0 ;; esac + case "$existing" in */ | */. | .) return 0 ;; esac case "$existing" in /*) ;; *) existing="${bin_dir}/${existing}" ;; esac # A folder that is itself a link (another versions/) is not followed. folder="$(dirname -- "$existing")" @@ -304,11 +314,12 @@ link_kind() { } kind= real_public="$(CDPATH='' cd -P -- "$bin_dir" && pwd)/t3" -# The command link itself, by any name for its folder: -ef compares device and inode, so an alias -# or a bind mount counts too. +# The command link is replaced in place, which is safe only away from the public name. -ef +# compares device and inode, so an alias or a bind mount of its folder counts too. if [ "$real_public" = "$real_command_link" ] || [ "$bin_dir" -ef "$current_dir" ]; then - kind=current -elif [ -e "$public" ] || [ -L "$public" ]; then + fail "T3CODE_INSTALL_BIN_DIR cannot be ${current_dir}. Choose another folder." +fi +if [ -e "$public" ] || [ -L "$public" ]; then kind="$(link_kind "$public")" [ -n "$kind" ] || fail "another t3 is already at ${public}. Remove it and run this again, or run ${target_dir}/t3 directly." fi diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 8afdd68d1c9f1..de26ecb10124d 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -165,10 +165,12 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 env: { HOME: root, SHELL: "/bin/zsh", PATH: "/usr/bin:/bin" }, }); expect(upgrade.code, upgrade.output).toBe(0); - // The links name the T3 home by its real path. - expect(await NodeFSP.readlink(link)).toBe(NodePath.join(home, "runtime/versions/current/t3")); + // The links keep the T3 home's own name, which released t3 compares them against. + expect(await NodeFSP.readlink(link)).toBe( + NodePath.join(root, "alias/runtime/versions/current/t3"), + ); expect(await NodeFSP.readlink(NodePath.join(home, "runtime/versions/current/t3"))).toBe( - NodePath.join(home, "runtime/versions/1.2.3/t3"), + NodePath.join(root, "alias/runtime/versions/1.2.3/t3"), ); expect(upgrade.output).toContain( `printf '\\n%s\\n' 'export PATH="$PATH:$HOME/bin"' >> ~/.zshrc`, @@ -225,14 +227,21 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 expect(await NodeFSP.readdir(NodePath.join(root, "folder"))).toEqual([]); } await NodeFSP.rm(commandLink); - // A bin folder that is the command link's own folder only switches that link. + // The command link is replaced in place, so its folder can never be the bin folder. const inCurrent = await installOver({ script: "install.sh", home, binDir: NodePath.join(home, "runtime/versions/current"), }); - expect(inCurrent.code, inCurrent.output).toBe(0); - expect(await NodeFSP.readlink(commandLink)).toBe(NodePath.join(inCurrent.target, "t3")); + expect(inCurrent.code).not.toBe(0); + expect(inCurrent.output).toContain("T3CODE_INSTALL_BIN_DIR cannot be"); + + // Released installers wrote `//` for a T3 home set with a trailing slash; that is still ours. + await NodeFSP.rm(link); + await NodeFSP.symlink(`${home}//runtime/versions/1.0.0/t3`, link); + const doubled = await installOver({ script: "install.sh", home, binDir }); + expect(doubled.code, doubled.output).toBe(0); + expect(await NodeFSP.readlink(link)).toBe(NodePath.join(home, "runtime/versions/current/t3")); // Containers and CI often have no SHELL; `set -u` must not stop the installer there. const noShell = await installOver({ From c443e06eeea026d89a453510bf48f9f4550c60c8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 18:24:30 -0700 Subject: [PATCH 12/23] fix(cli): match t3's own T3 home spelling everywhere, and never leave t3 missing install.sh resolves a relative T3 home against the real working folder, as Node does, and collapses `//` and `/./` in a link before checking its folder. `t3 install` writes the T3 home as it is set, not the executable's real path. A bind mount of the command link's folder is refused. The move aside cannot be interrupted halfway, and desktop reports an entry it could not put back instead of moving on. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/app/DesktopCliCommand.ts | 13 ++++- apps/server/src/cli/install.test.ts | 14 +++++ apps/server/src/cli/install.ts | 20 +++++-- packages/shared/src/ownedLink.ts | 68 ++++++++++++----------- scripts/install.sh | 21 ++++--- scripts/install.test.ts | 24 ++++++++ 6 files changed, 116 insertions(+), 44 deletions(-) diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 437c8c03ddd68..078e45b865c61 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -342,10 +342,19 @@ export const make = Effect.gen(function* () { link === replaced ? replaceOurLink({ link, target: launcher }) : fs.symlink(launcher, link).pipe(Effect.as(true)) - ).pipe(Effect.orElseSucceed(() => false)); + ).pipe( + // A moved-aside entry that could not go back is reported, not left for another folder. + Effect.catchTags({ OwnedLinkError: (error) => Effect.fail(fail(error.message)) }), + Effect.catchTags({ PlatformError: () => Effect.succeed(false) }), + ); if (!linked) continue; if (replaced !== undefined && replaced !== link) { - yield* replaceOurLink({ link: replaced }).pipe(Effect.ignore); + yield* replaceOurLink({ link: replaced }).pipe( + Effect.catchTags({ + OwnedLinkError: (error) => Effect.fail(fail(error.message)), + PlatformError: () => Effect.void, + }), + ); } return yield* state; } diff --git a/apps/server/src/cli/install.test.ts b/apps/server/src/cli/install.test.ts index 1782e3a178904..1f4862ee0fd95 100644 --- a/apps/server/src/cli/install.test.ts +++ b/apps/server/src/cli/install.test.ts @@ -66,6 +66,20 @@ it.layer(NodeServices.layer)("t3 install", (it) => { assert.isFalse(installed.onPath); assert.include(installed.pathCommand, "$HOME/bin"); + // The links name the T3 home as it is set, not the executable's real path, so released t3 + // still owns them through a symlinked home. + const configured = path.join(root, "configured"); + yield* fs.symlink(path.join(root, ".t3"), configured); + yield* fs.remove(link); + yield* provide(installCli(), { T3CODE_HOME: configured }); + assert.equal(yield* fs.readLink(link), path.join(configured, "runtime/versions/current/t3")); + assert.equal( + yield* fs.readLink(commandLink), + path.join(configured, "runtime/versions", VERSION, "t3"), + ); + yield* provide(installCli()); + assert.equal(yield* fs.readLink(commandLink), executable); + // 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); diff --git a/apps/server/src/cli/install.ts b/apps/server/src/cli/install.ts index 03b86a6fa0be1..abcf8f30804a4 100644 --- a/apps/server/src/cli/install.ts +++ b/apps/server/src/cli/install.ts @@ -20,6 +20,7 @@ import { pinnedRuntimeCommandLink, pinnedRuntimeVersionsDir, } from "../cloud/pinnedRuntime.ts"; +import { resolveBaseDir } from "../os-jank.ts"; import { detectCliRunner, resolveCliCommand, resolveEntryPath } from "./invocation.ts"; export class CliInstallError extends Schema.TaggedError()("CliInstallError", { @@ -62,7 +63,8 @@ const classifyCommandLink = Effect.fn("cli.install.classify_command_link")(funct ); const commandLink = pinnedRuntimeCommandLink(path, baseDir); const realCommandLink = yield* real(commandLink); - // The command link itself, whatever its folder is called (an alias or a bind mount). + // The command link itself, through any alias of its folder. A bind mount shows the same folder + // under another real path, where it may be a shared bin folder, so it is refused. const folderId = (file: string) => fs.stat(path.dirname(file)).pipe( Effect.map((info) => Option.map(info.ino, (ino) => `${info.dev}:${ino}`)), @@ -74,7 +76,7 @@ const classifyCommandLink = Effect.fn("cli.install.classify_command_link")(funct Option.isSome(linkFolder) && Option.getOrUndefined(yield* folderId(commandLink)) === linkFolder.value ) { - return "current"; + return (yield* real(link)) === realCommandLink ? "current" : "foreign"; } // Only the direct target: a chain through another tool's link is that tool's. const targets = yield* directLinkTargets(link); @@ -226,7 +228,17 @@ export const installCli = Effect.fn("cli.install.install")(function* () { .pipe(Effect.mapError(failed(`Could not create ${binDir}.`))); // The command link is replaced in place, which is safe only away from the public name. Same // device and inode, so an alias or a bind mount of its folder counts too. - const commandFolder = path.dirname(pinnedRuntimeCommandLink(path, path.dirname(runtimeDir))); + // Spelled as the T3 home this t3 is set up with, which every t3 compares links against; the + // executable's own path names a symlinked home by its real path. + const configured = yield* resolveBaseDir(environment.T3CODE_HOME); + const realHome = (directory: string) => + fs.realPath(directory).pipe(Effect.orElseSucceed(() => directory)); + const baseDir = + (yield* realHome(configured)) === (yield* realHome(path.dirname(runtimeDir))) + ? configured + : path.dirname(runtimeDir); + const spelled = path.join(baseDir, path.relative(path.dirname(runtimeDir), executable)); + const commandFolder = path.dirname(pinnedRuntimeCommandLink(path, baseDir)); const folderId = (directory: string) => fs.stat(directory).pipe( Effect.map((info) => Option.map(info.ino, (ino) => `${info.dev}:${ino}`)), @@ -242,7 +254,7 @@ export const installCli = Effect.fn("cli.install.install")(function* () { reason: `T3CODE_INSTALL_BIN_DIR cannot be ${commandFolder}. Choose another folder.`, }); } - const linked = yield* linkCommand({ link, baseDir: path.dirname(runtimeDir), executable }).pipe( + const linked = yield* linkCommand({ link, baseDir, executable: spelled }).pipe( Effect.catchTags({ PlatformError: (cause) => Effect.fail(new CliInstallError({ reason: `Could not link ${link}.`, cause })), diff --git a/packages/shared/src/ownedLink.ts b/packages/shared/src/ownedLink.ts index cbee66d2b41e2..6e8b15882c6e8 100644 --- a/packages/shared/src/ownedLink.ts +++ b/packages/shared/src/ownedLink.ts @@ -38,36 +38,42 @@ export const replaceOwnedLink = Effect.fn("ownedLink.replace")(function* ( ); yield* fs.writeFileString(aside, "", { flag: "wx" }); const discard = fs.remove(aside); - yield* fs.rename(input.link, aside).pipe(Effect.tapError(() => discard.pipe(Effect.ignore))); - // Exclusive, so it never overwrites anything written at `link` meanwhile. The link text is - // copied as bytes; a string would mangle a target that is not UTF-8. - const putBack = Effect.tryPromise({ - try: () => NodeFSP.readlink(aside, { encoding: "buffer" }), - catch: () => new OwnedLinkError({ path: aside }), - }).pipe( - Effect.option, - Effect.flatMap((text) => - Option.isSome(text) - ? Effect.tryPromise({ - try: () => NodeFSP.symlink(text.value, input.link), - catch: () => new OwnedLinkError({ path: aside }), - }) - : fs - .link(aside, input.link) - .pipe(Effect.mapError(() => new OwnedLinkError({ path: aside }))), - ), + // Not interrupted between moving the entry aside and putting a link back, so a cancelled + // install never leaves the name empty. + return yield* Effect.uninterruptible( + Effect.gen(function* () { + yield* fs.rename(input.link, aside).pipe(Effect.tapError(() => discard.pipe(Effect.ignore))); + // Exclusive, so it never overwrites anything written at `link` meanwhile. The link text is + // copied as bytes; a string would mangle a target that is not UTF-8. + const putBack = Effect.tryPromise({ + try: () => NodeFSP.readlink(aside, { encoding: "buffer" }), + catch: () => new OwnedLinkError({ path: aside }), + }).pipe( + Effect.option, + Effect.flatMap((text) => + Option.isSome(text) + ? Effect.tryPromise({ + try: () => NodeFSP.symlink(text.value, input.link), + catch: () => new OwnedLinkError({ path: aside }), + }) + : fs + .link(aside, input.link) + .pipe(Effect.mapError(() => new OwnedLinkError({ path: aside }))), + ), + ); + if (!(yield* input.owns(aside))) { + yield* putBack.pipe(Effect.andThen(discard)); + return false; + } + if (input.target !== undefined) { + // On failure the old link goes back while the name is free, so the command keeps working; + // if it cannot, it stays beside `link`. + yield* fs + .symlink(input.target, input.link) + .pipe(Effect.tapError(() => putBack.pipe(Effect.andThen(discard.pipe(Effect.ignore))))); + } + yield* discard.pipe(Effect.ignore); + return true; + }), ); - if (!(yield* input.owns(aside))) { - yield* putBack.pipe(Effect.andThen(discard)); - return false; - } - if (input.target !== undefined) { - // On failure the old link goes back while the name is free, so the command keeps working; - // if it cannot, it stays beside `link`. - yield* fs - .symlink(input.target, input.link) - .pipe(Effect.tapError(() => putBack.pipe(Effect.andThen(discard.pipe(Effect.ignore))))); - } - yield* discard.pipe(Effect.ignore); - return true; }); diff --git a/scripts/install.sh b/scripts/install.sh index 88d8df7a01806..2a785bd253a17 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -191,10 +191,10 @@ case "$version" in ;; esac -# Spelled as Node's path.resolve gives it, which is the T3 home every t3 compares links against: -# absolute, without `.`, `..`, or `//`, but keeping a symlinked home's own name. -t3_home="$( - case "$t3_home" in /*) rest="$t3_home" ;; *) rest="$PWD/$t3_home" ;; esac +# A path as Node's path.resolve spells it: absolute against the real working folder, with `.`, +# `..`, and `//` collapsed as text, keeping the names of symlinked folders. +resolve_path() ( + case "$1" in /*) rest="$1" ;; *) rest="$(pwd -P)/$1" ;; esac out= IFS=/ set -f @@ -202,7 +202,9 @@ t3_home="$( case "$part" in "" | .) ;; ..) out="${out%/*}" ;; *) out="$out/$part" ;; esac done printf '%s' "${out:-/}" -)" +) +# Spelled as every t3 spells its T3 home, so released t3 still owns the links made from it. +t3_home="$(resolve_path "$t3_home")" stem="t3-${version}-${platform}-${arch}" archive="${stem}.tar.gz" versions_dir="${t3_home}/runtime/versions" @@ -287,10 +289,11 @@ link_kind() { existing="${existing%x}" existing="${existing%"$newline"}" # `..` can mean another folder once a link is followed, and a target naming a folder (`t3/`, - # `t3/.`) never runs the file it would resolve to. Other spellings resolve on disk below. + # `t3/.`) never runs the file it would resolve to. `//` and `/./` collapse as text. case "/${existing}/" in */../*) return 0 ;; esac - case "$existing" in */ | */. | .) return 0 ;; esac + case "$existing" in */ | */. | . | *"$newline"*) return 0 ;; esac case "$existing" in /*) ;; *) existing="${bin_dir}/${existing}" ;; esac + existing="$(resolve_path "$existing")" # A folder that is itself a link (another versions/) is not followed. folder="$(dirname -- "$existing")" [ ! -L "$folder" ] || return 0 @@ -338,6 +341,9 @@ case "$kind" in # name t3, so it goes back with `ln -P ... "${bin_dir}/"`, which never overwrites anything. aside_dir="$(mktemp -d "${bin_dir}/.t3.XXXXXX")" || fail "could not replace ${public}" aside="${aside_dir}/t3" + # Not stopped between moving the old link aside and putting a link back, so a cancelled + # install never leaves the name empty. + trap '' INT TERM HUP mv "$public" "$aside" || { rmdir "$aside_dir"; fail "could not replace ${public}"; } if [ -n "$(link_kind "$aside")" ]; then if ! ln -s "$command_link" "${bin_dir}/"; then @@ -348,6 +354,7 @@ case "$kind" in fail "could not link ${public}" fi rm -f "$aside" && rmdir "$aside_dir" + trap - INT TERM HUP else # Another program wrote it after the check: put it back without overwriting anything newer. # A folder cannot be hard linked; it stays where it is, and the message says where. diff --git a/scripts/install.test.ts b/scripts/install.test.ts index de26ecb10124d..93660b3139902 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -200,6 +200,8 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 ); for (const target of [ NodePath.join(home, "runtime/versions/latest/t3"), + `${home}/runtime/versions/latest/./t3`, + `${home}/runtime/versions/latest//t3`, `${NodePath.join(home, "runtime/versions/1.0.0/t3")}\n`, ]) { await NodeFSP.rm(link, { force: true }); @@ -272,6 +274,28 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 cwd: root, }); expect(relativeHome.code, relativeHome.output).toBe(0); + // Resolved against the real working folder, as t3 itself resolves a relative T3 home. + await NodeFSP.mkdir(NodePath.join(root, "a/b"), { recursive: true }); + await NodeFSP.symlink(NodePath.join(root, "a/b"), NodePath.join(root, "cwd")); + const physicalHome = NodePath.join(root, "a/home"); + await NodeFSP.mkdir(NodePath.join(physicalHome, "runtime/versions/1.2.3"), { + recursive: true, + }); + await NodeFSP.writeFile( + NodePath.join(physicalHome, "runtime/versions/1.2.3/.install-complete"), + "1.2.3\n", + ); + const fromLink = await installOver({ + script: "install.sh", + home: "../home", + binDir: NodePath.join(root, "bin-cwd"), + env: { PWD: NodePath.join(root, "cwd") }, + cwd: NodePath.join(root, "cwd"), + }); + expect(fromLink.code, fromLink.output).toBe(0); + expect(await NodeFSP.readlink(NodePath.join(root, "bin-cwd/t3"))).toBe( + NodePath.join(physicalHome, "runtime/versions/current/t3"), + ); expect(await NodeFSP.readlink(link)).toBe(NodePath.join(home, "runtime/versions/current/t3")); await NodeFSP.rm(link); From 9635a0fd4d1d9555b5f13f71e1e05d49e75b7953 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 18:31:38 -0700 Subject: [PATCH 13/23] fix(cli): read a T3 home and link the way the OS and CLI do install.sh expands a quoted ~ in T3CODE_HOME like the CLI. A relative link target starts from the bin folder's real path. The desktop launcher counts by its exact path when the home's bin folder is a link. A closed terminal's SIGHUP no longer ends the process during the move aside. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/install.test.ts | 6 +- apps/server/src/cli/install.ts | 9 +++ apps/server/src/cloud/pinnedRuntime.ts | 8 ++- packages/shared/src/ownedLink.ts | 82 +++++++++++++++----------- scripts/install.sh | 8 ++- scripts/install.test.ts | 12 ++++ 6 files changed, 86 insertions(+), 39 deletions(-) diff --git a/apps/server/src/cli/install.test.ts b/apps/server/src/cli/install.test.ts index 1f4862ee0fd95..125534cc15258 100644 --- a/apps/server/src/cli/install.test.ts +++ b/apps/server/src/cli/install.test.ts @@ -190,11 +190,15 @@ it.layer(NodeServices.layer)("t3 install", (it) => { assert.include((yield* provide(installCli()).pipe(Effect.flip)).message, "Another t3"); assert.equal(yield* fs.readLink(link), climbing); - // This home's desktop launcher is replaced, as install.sh does. + // This home's desktop launcher is replaced, as install.sh does, also when its bin folder + // is a link. + yield* fs.makeDirectory(path.join(root, "desktop-bin")); + yield* fs.symlink(path.join(root, "desktop-bin"), path.join(root, ".t3/bin")); yield* fs.remove(link); yield* fs.symlink(path.join(root, ".t3/bin/t3"), link); yield* provide(installCli()); assert.equal(yield* fs.readLink(link), commandLink); + yield* fs.remove(path.join(root, ".t3/bin")); // Another program may write its t3 at any moment, here once, at the first symlink this // install makes. Whether the spot was empty, held an older link, or the command link, diff --git a/apps/server/src/cli/install.ts b/apps/server/src/cli/install.ts index abcf8f30804a4..75722955ca328 100644 --- a/apps/server/src/cli/install.ts +++ b/apps/server/src/cli/install.ts @@ -90,6 +90,15 @@ const classifyCommandLink = Effect.fn("cli.install.classify_command_link")(funct .pipe(Effect.orElseSucceed(() => versionsDir)); const desktopLauncher = path.join(baseDir, "bin", "t3"); const realDesktopLauncher = yield* real(desktopLauncher); + // The desktop app writes this exact path, also when the home's bin folder is a link. + const text = yield* fs.readLink(link).pipe(Effect.option); + if ( + Option.isSome(text) && + path.isAbsolute(text.value) && + path.normalize(text.value) === desktopLauncher + ) { + return "older"; + } const older = targets.some( (candidate) => isVersionExecutable(path, versionsDir, candidate) || diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 13abbb8a9e89c..4946ea14f0a2f 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -417,7 +417,13 @@ export const directLinkTargets = Effect.fn("cloud.pinned_runtime.direct_link_tar // `t3/.`) never runs the file it would resolve to. Other spellings resolve on disk below. const parts = text.value.split(/[\\/]/); if (parts.includes("..") || [".", ""].includes(parts.at(-1)!)) return []; - const target = path.resolve(path.dirname(link), text.value); + // A relative target starts from the link's real folder, as the OS follows it. + const base = path.isAbsolute(text.value) + ? path.dirname(link) + : yield* Effect.tryPromise(() => NodeFSP.realpath(path.dirname(link))).pipe( + Effect.orElseSucceed(() => path.dirname(link)), + ); + const target = path.resolve(base, text.value); const folder = path.dirname(target); if (Option.isSome(yield* fs.readLink(folder).pipe(Effect.option))) return []; const physical = yield* Effect.tryPromise({ diff --git a/packages/shared/src/ownedLink.ts b/packages/shared/src/ownedLink.ts index 6e8b15882c6e8..5d79165d026d6 100644 --- a/packages/shared/src/ownedLink.ts +++ b/packages/shared/src/ownedLink.ts @@ -39,41 +39,51 @@ export const replaceOwnedLink = Effect.fn("ownedLink.replace")(function* ( yield* fs.writeFileString(aside, "", { flag: "wx" }); const discard = fs.remove(aside); // Not interrupted between moving the entry aside and putting a link back, so a cancelled - // install never leaves the name empty. - return yield* Effect.uninterruptible( - Effect.gen(function* () { - yield* fs.rename(input.link, aside).pipe(Effect.tapError(() => discard.pipe(Effect.ignore))); - // Exclusive, so it never overwrites anything written at `link` meanwhile. The link text is - // copied as bytes; a string would mangle a target that is not UTF-8. - const putBack = Effect.tryPromise({ - try: () => NodeFSP.readlink(aside, { encoding: "buffer" }), - catch: () => new OwnedLinkError({ path: aside }), - }).pipe( - Effect.option, - Effect.flatMap((text) => - Option.isSome(text) - ? Effect.tryPromise({ - try: () => NodeFSP.symlink(text.value, input.link), - catch: () => new OwnedLinkError({ path: aside }), - }) - : fs - .link(aside, input.link) - .pipe(Effect.mapError(() => new OwnedLinkError({ path: aside }))), - ), - ); - if (!(yield* input.owns(aside))) { - yield* putBack.pipe(Effect.andThen(discard)); - return false; - } - if (input.target !== undefined) { - // On failure the old link goes back while the name is free, so the command keeps working; - // if it cannot, it stays beside `link`. - yield* fs - .symlink(input.target, input.link) - .pipe(Effect.tapError(() => putBack.pipe(Effect.andThen(discard.pipe(Effect.ignore))))); - } - yield* discard.pipe(Effect.ignore); - return true; - }), + // install never leaves the name empty. A closed terminal's SIGHUP would end Node at once. + const ignoreHangup = () => {}; + return yield* Effect.acquireUseRelease( + Effect.sync(() => process.on("SIGHUP", ignoreHangup)), + () => + Effect.uninterruptible( + Effect.gen(function* () { + yield* fs + .rename(input.link, aside) + .pipe(Effect.tapError(() => discard.pipe(Effect.ignore))); + // Exclusive, so it never overwrites anything written at `link` meanwhile. The link text is + // copied as bytes; a string would mangle a target that is not UTF-8. + const putBack = Effect.tryPromise({ + try: () => NodeFSP.readlink(aside, { encoding: "buffer" }), + catch: () => new OwnedLinkError({ path: aside }), + }).pipe( + Effect.option, + Effect.flatMap((text) => + Option.isSome(text) + ? Effect.tryPromise({ + try: () => NodeFSP.symlink(text.value, input.link), + catch: () => new OwnedLinkError({ path: aside }), + }) + : fs + .link(aside, input.link) + .pipe(Effect.mapError(() => new OwnedLinkError({ path: aside }))), + ), + ); + if (!(yield* input.owns(aside))) { + yield* putBack.pipe(Effect.andThen(discard)); + return false; + } + if (input.target !== undefined) { + // On failure the old link goes back while the name is free, so the command keeps working; + // if it cannot, it stays beside `link`. + yield* fs + .symlink(input.target, input.link) + .pipe( + Effect.tapError(() => putBack.pipe(Effect.andThen(discard.pipe(Effect.ignore)))), + ); + } + yield* discard.pipe(Effect.ignore); + return true; + }), + ), + () => Effect.sync(() => process.off("SIGHUP", ignoreHangup)), ); }); diff --git a/scripts/install.sh b/scripts/install.sh index 2a785bd253a17..e297e8a6226e6 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -204,6 +204,8 @@ resolve_path() ( printf '%s' "${out:-/}" ) # Spelled as every t3 spells its T3 home, so released t3 still owns the links made from it. +# Like the CLI, a quoted ~ at the start means the home folder. +case "$t3_home" in "~") t3_home="$HOME" ;; "~/"*) t3_home="${HOME}/${t3_home#"~/"}" ;; esac t3_home="$(resolve_path "$t3_home")" stem="t3-${version}-${platform}-${arch}" archive="${stem}.tar.gz" @@ -280,6 +282,7 @@ real_desktop_launcher="$(CDPATH='' cd -P -- "$t3_home" && pwd)/bin/t3" if real_bin="$(CDPATH='' cd -P -- "${t3_home}/bin" 2>/dev/null && pwd)"; then real_desktop_launcher="${real_bin}/t3" fi +real_bin_dir="$(CDPATH='' cd -P -- "$bin_dir" && pwd)" # Prints what the link at $1 is to this home: current (its command link), older (a version's t3 # or the desktop app's launcher), or nothing. Anything that is not a symlink is someone else's. link_kind() { @@ -292,8 +295,11 @@ link_kind() { # `t3/.`) never runs the file it would resolve to. `//` and `/./` collapse as text. case "/${existing}/" in */../*) return 0 ;; esac case "$existing" in */ | */. | . | *"$newline"*) return 0 ;; esac - case "$existing" in /*) ;; *) existing="${bin_dir}/${existing}" ;; esac + # A relative target starts from the bin folder's real path, as the OS follows it. + case "$existing" in /*) ;; *) existing="${real_bin_dir}/${existing}" ;; esac existing="$(resolve_path "$existing")" + # The desktop app writes this exact path, also when the home's bin folder is a link. + if [ "$existing" = "${t3_home}/bin/t3" ]; then echo older; return 0; fi # A folder that is itself a link (another versions/) is not followed. folder="$(dirname -- "$existing")" [ ! -L "$folder" ] || return 0 diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 93660b3139902..4e3487adb2c76 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -274,6 +274,18 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 cwd: root, }); expect(relativeHome.code, relativeHome.output).toBe(0); + // A quoted ~ means the home folder, as the CLI reads it. + await NodeFSP.rm(link, { force: true }); + const tilde = await installOver({ + script: "install.sh", + home: "~/home", + binDir, + // The helper's own setup reads ~ as a folder name; keep that inside the temp folder. + cwd: root, + env: { HOME: root }, + }); + expect(tilde.code, tilde.output).toBe(0); + expect(await NodeFSP.readlink(link)).toBe(NodePath.join(home, "runtime/versions/current/t3")); // Resolved against the real working folder, as t3 itself resolves a relative T3 home. await NodeFSP.mkdir(NodePath.join(root, "a/b"), { recursive: true }); await NodeFSP.symlink(NodePath.join(root, "a/b"), NodePath.join(root, "cwd")); From fbfe5a41a3c702980e3314fb1b9f8606e465ecda Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 18:37:51 -0700 Subject: [PATCH 14/23] fix(cli): claim the desktop launcher only by its marker A link to /bin/t3 counts as the desktop app's only when that file carries the app's marker, so a user's own t3 in a bin folder the home links to stays theirs. The marker moves to @t3tools/shared. install.sh falls back to a plain ln when BusyBox has no ln -P. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/app/DesktopCliShim.ts | 3 +- apps/server/src/cli/install.test.ts | 26 +++++++++++---- apps/server/src/cli/install.ts | 40 ++++++++++++++++++----- packages/shared/package.json | 4 +++ packages/shared/src/desktopCliLauncher.ts | 3 ++ scripts/install.sh | 20 ++++++++---- scripts/install.test.ts | 30 ++++++++++++----- 7 files changed, 94 insertions(+), 32 deletions(-) create mode 100644 packages/shared/src/desktopCliLauncher.ts diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts index 97517156710cc..78994a089a312 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -1,3 +1,4 @@ +import { DESKTOP_CLI_LAUNCHER_MARKER } from "@t3tools/shared/desktopCliLauncher"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -10,7 +11,7 @@ import { makeComponentLogger } from "./DesktopObservability.ts"; // AppImage mounts change each run; the launcher must mount the image itself. const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim"); -export const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI."; +export const MARKER = DESKTOP_CLI_LAUNCHER_MARKER; const SERVER_ENTRY = "apps/server/dist/bin.mjs"; diff --git a/apps/server/src/cli/install.test.ts b/apps/server/src/cli/install.test.ts index 125534cc15258..e8d23d6645835 100644 --- a/apps/server/src/cli/install.test.ts +++ b/apps/server/src/cli/install.test.ts @@ -12,6 +12,8 @@ import * as Path from "effect/Path"; import * as TestClock from "effect/testing/TestClock"; import packageJson from "../../package.json" with { type: "json" }; +import { DESKTOP_CLI_LAUNCHER_MARKER } from "@t3tools/shared/desktopCliLauncher"; + import { installCli, isForegroundProcStat, isForegroundPs, offerCliInstall } from "./install.ts"; const VERSION = packageJson.version; @@ -192,12 +194,24 @@ it.layer(NodeServices.layer)("t3 install", (it) => { // This home's desktop launcher is replaced, as install.sh does, also when its bin folder // is a link. - yield* fs.makeDirectory(path.join(root, "desktop-bin")); - yield* fs.symlink(path.join(root, "desktop-bin"), path.join(root, ".t3/bin")); - yield* fs.remove(link); - yield* fs.symlink(path.join(root, ".t3/bin/t3"), link); - yield* provide(installCli()); - assert.equal(yield* fs.readLink(link), commandLink); + // A t3 there without the desktop app's marker is the user's own. + const desktopBin = path.join(root, "desktop-bin"); + yield* fs.makeDirectory(desktopBin); + yield* fs.symlink(desktopBin, path.join(root, ".t3/bin")); + for (const [contents, ours] of [ + ["#!/bin/sh\necho mine\n", false], + [`#!/bin/sh\n# ${DESKTOP_CLI_LAUNCHER_MARKER}\n`, true], + ] as const) { + yield* fs.writeFileString(path.join(desktopBin, "t3"), contents); + yield* fs.remove(link); + yield* fs.symlink(path.join(root, ".t3/bin/t3"), link); + yield* provide(installCli()).pipe(Effect.ignore); + assert.equal( + yield* fs.readLink(link), + ours ? commandLink : path.join(root, ".t3/bin/t3"), + contents, + ); + } yield* fs.remove(path.join(root, ".t3/bin")); // Another program may write its t3 at any moment, here once, at the first symlink this diff --git a/apps/server/src/cli/install.ts b/apps/server/src/cli/install.ts index 75722955ca328..7fa976a972b93 100644 --- a/apps/server/src/cli/install.ts +++ b/apps/server/src/cli/install.ts @@ -1,4 +1,5 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; +import { DESKTOP_CLI_LAUNCHER_MARKER } from "@t3tools/shared/desktopCliLauncher"; import { replaceOwnedLink } from "@t3tools/shared/ownedLink"; import { pathSetupCommand } from "@t3tools/shared/shell"; import * as Console from "effect/Console"; @@ -90,23 +91,44 @@ const classifyCommandLink = Effect.fn("cli.install.classify_command_link")(funct .pipe(Effect.orElseSucceed(() => versionsDir)); const desktopLauncher = path.join(baseDir, "bin", "t3"); const realDesktopLauncher = yield* real(desktopLauncher); + // The desktop app's launcher carries its marker; a linked bin folder could hold a user's own t3. + const isDesktopLauncher = Effect.fn("cli.install.is_desktop_launcher")( + function* (file: string) { + const info = yield* fs.stat(file); + if (info.type !== "File" || Number(info.size) > 16_384) return false; + return (yield* fs.readFileString(file)).includes(DESKTOP_CLI_LAUNCHER_MARKER); + }, + Effect.orElseSucceed(() => false), + ); // The desktop app writes this exact path, also when the home's bin folder is a link. const text = yield* fs.readLink(link).pipe(Effect.option); if ( Option.isSome(text) && path.isAbsolute(text.value) && - path.normalize(text.value) === desktopLauncher + !text.value.split(/[\\/]/).includes("..") && + path.normalize(text.value) === desktopLauncher && + (yield* isDesktopLauncher(desktopLauncher)) ) { return "older"; } - const older = targets.some( - (candidate) => - isVersionExecutable(path, versionsDir, candidate) || - isVersionExecutable(path, realVersionsDir, candidate) || - candidate === desktopLauncher || - candidate === realDesktopLauncher, - ); - return older ? "older" : "foreign"; + if ( + targets.some( + (candidate) => + isVersionExecutable(path, versionsDir, candidate) || + isVersionExecutable(path, realVersionsDir, candidate), + ) + ) { + return "older"; + } + for (const candidate of targets) { + if ( + (candidate === desktopLauncher || candidate === realDesktopLauncher) && + (yield* isDesktopLauncher(candidate)) + ) { + return "older"; + } + } + return "foreign"; }); /** diff --git a/packages/shared/package.json b/packages/shared/package.json index 0b5d4b7b9b276..9465461a10115 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -499,6 +499,10 @@ "types": "./src/atomicWrite.ts", "import": "./src/atomicWrite.ts" }, + "./desktopCliLauncher": { + "types": "./src/desktopCliLauncher.ts", + "import": "./src/desktopCliLauncher.ts" + }, "./ownedLink": { "types": "./src/ownedLink.ts", "import": "./src/ownedLink.ts" diff --git a/packages/shared/src/desktopCliLauncher.ts b/packages/shared/src/desktopCliLauncher.ts new file mode 100644 index 0000000000000..54b3d996988a8 --- /dev/null +++ b/packages/shared/src/desktopCliLauncher.ts @@ -0,0 +1,3 @@ +/** In every `t3` launcher the desktop app writes, so installers can tell it from a user's own t3. */ +export const DESKTOP_CLI_LAUNCHER_MARKER = + "Written by T3 Code: runs the desktop app's bundled t3 CLI."; diff --git a/scripts/install.sh b/scripts/install.sh index e297e8a6226e6..fa3085143fd0d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -283,6 +283,11 @@ if real_bin="$(CDPATH='' cd -P -- "${t3_home}/bin" 2>/dev/null && pwd)"; then real_desktop_launcher="${real_bin}/t3" fi real_bin_dir="$(CDPATH='' cd -P -- "$bin_dir" && pwd)" +# The desktop app's launcher carries its marker; a linked bin folder could hold a user's own t3. +desktop_launcher() { + [ -f "$1" ] && [ "$(wc -c < "$1")" -le 16384 ] && + grep -qF "Written by T3 Code: runs the desktop app's bundled t3 CLI." "$1" +} # Prints what the link at $1 is to this home: current (its command link), older (a version's t3 # or the desktop app's launcher), or nothing. Anything that is not a symlink is someone else's. link_kind() { @@ -299,7 +304,7 @@ link_kind() { case "$existing" in /*) ;; *) existing="${real_bin_dir}/${existing}" ;; esac existing="$(resolve_path "$existing")" # The desktop app writes this exact path, also when the home's bin folder is a link. - if [ "$existing" = "${t3_home}/bin/t3" ]; then echo older; return 0; fi + if [ "$existing" = "${t3_home}/bin/t3" ] && desktop_launcher "$existing"; then echo older; return 0; fi # A folder that is itself a link (another versions/) is not followed. folder="$(dirname -- "$existing")" [ ! -L "$folder" ] || return 0 @@ -307,7 +312,8 @@ link_kind() { if existing_dir="$(CDPATH='' cd -P -- "$folder" 2>/dev/null && pwd)"; then real_existing="${existing_dir}/${existing##*/}" if [ "$real_existing" = "$real_command_link" ]; then echo current - elif [ "$real_existing" = "$real_desktop_launcher" ] || version_t3 "$real_existing" "$real_versions_dir"; then + elif version_t3 "$real_existing" "$real_versions_dir" || + { [ "$real_existing" = "$real_desktop_launcher" ] && desktop_launcher "$real_existing"; }; then echo older fi return 0 @@ -317,9 +323,7 @@ link_kind() { [ ! -e "$folder" ] || return 0 parent="$(CDPATH='' cd -P -- "$(dirname -- "$folder")" 2>/dev/null && pwd)" || return 0 real_existing="${parent}/${folder##*/}/${existing##*/}" - if [ "$real_existing" = "$real_desktop_launcher" ] || version_t3 "$real_existing" "$real_versions_dir"; then - echo older - fi + if version_t3 "$real_existing" "$real_versions_dir"; then echo older; fi } kind= real_public="$(CDPATH='' cd -P -- "$bin_dir" && pwd)/t3" @@ -346,6 +350,8 @@ case "$kind" in # folder of its own, so nothing left by an earlier install is replaced; the entry keeps its # name t3, so it goes back with `ln -P ... "${bin_dir}/"`, which never overwrites anything. aside_dir="$(mktemp -d "${bin_dir}/.t3.XXXXXX")" || fail "could not replace ${public}" + # BusyBox ln has no -P; its plain ln links a symlink itself, as Linux link() does. + put_back() { ln -P "$aside" "${bin_dir}/" 2>/dev/null || ln "$aside" "${bin_dir}/" 2>/dev/null; } aside="${aside_dir}/t3" # Not stopped between moving the old link aside and putting a link back, so a cancelled # install never leaves the name empty. @@ -354,7 +360,7 @@ case "$kind" in if [ -n "$(link_kind "$aside")" ]; then if ! ln -s "$command_link" "${bin_dir}/"; then # Keep the working old command when the name is still free; if it cannot go back, it stays. - ln -P "$aside" "${bin_dir}/" 2>/dev/null || + put_back || fail "could not link ${public}; the old link is at ${aside}." rm -f "$aside" && rmdir "$aside_dir" fail "could not link ${public}" @@ -364,7 +370,7 @@ case "$kind" in else # Another program wrote it after the check: put it back without overwriting anything newer. # A folder cannot be hard linked; it stays where it is, and the message says where. - ln -P "$aside" "${bin_dir}/" 2>/dev/null || + put_back || fail "another program wrote ${public} during the install; it is now at ${aside}." rm -f "$aside" && rmdir "$aside_dir" fail "another t3 was just written at ${public}. Remove it and run this again, or run ${target_dir}/t3 directly." diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 4e3487adb2c76..180c97592f804 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -310,15 +310,27 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 ); expect(await NodeFSP.readlink(link)).toBe(NodePath.join(home, "runtime/versions/current/t3")); - 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/current/t3")); + // This home's desktop launcher is replaced only when it carries the app's marker; a t3 the + // user keeps in a bin folder the home links to is theirs. + await NodeFSP.mkdir(NodePath.join(root, "user-bin")); + await NodeFSP.symlink(NodePath.join(root, "user-bin"), NodePath.join(home, "bin")); + for (const [contents, ours] of [ + ["#!/bin/sh\necho mine\n", false], + ["#!/bin/sh\n# Written by T3 Code: runs the desktop app's bundled t3 CLI.\n", true], + ] as const) { + await NodeFSP.writeFile(NodePath.join(root, "user-bin/t3"), contents); + 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 === 0, overDesktop.output).toBe(ours); + expect(await NodeFSP.readlink(link)).toBe( + ours ? NodePath.join(home, "runtime/versions/current/t3") : NodePath.join(home, "bin/t3"), + ); + } } finally { await NodeFSP.rm(root, { recursive: true, force: true }); } From 8c90f4f6ee2f1b8f2ed65209c8fe788c6386c6e3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 18:41:26 -0700 Subject: [PATCH 15/23] fix(cli): a linked desktop launcher name is not the desktop app's Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/install.test.ts | 12 ++++++++++++ apps/server/src/cli/install.ts | 3 +++ scripts/install.sh | 3 ++- scripts/install.test.ts | 15 +++++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/server/src/cli/install.test.ts b/apps/server/src/cli/install.test.ts index e8d23d6645835..c52795f83dee8 100644 --- a/apps/server/src/cli/install.test.ts +++ b/apps/server/src/cli/install.test.ts @@ -212,6 +212,18 @@ it.layer(NodeServices.layer)("t3 install", (it) => { contents, ); } + // A link at the launcher's name leads to another home's or another tool's t3, and a target + // naming it as a folder never runs it. + const nightly = path.join(root, "nightly-launcher"); + yield* fs.writeFileString(nightly, `#!/bin/sh\n# ${DESKTOP_CLI_LAUNCHER_MARKER}\n`); + yield* fs.rename(path.join(desktopBin, "t3"), path.join(desktopBin, "t3.real")); + yield* fs.symlink(nightly, path.join(desktopBin, "t3")); + for (const target of [path.join(root, ".t3/bin/t3"), `${path.join(root, ".t3/bin/t3")}/.`]) { + yield* fs.remove(link); + yield* fs.symlink(target, link); + yield* provide(installCli()).pipe(Effect.ignore); + assert.equal(yield* fs.readLink(link), target); + } yield* fs.remove(path.join(root, ".t3/bin")); // Another program may write its t3 at any moment, here once, at the first symlink this diff --git a/apps/server/src/cli/install.ts b/apps/server/src/cli/install.ts index 7fa976a972b93..a237e6ef1297f 100644 --- a/apps/server/src/cli/install.ts +++ b/apps/server/src/cli/install.ts @@ -94,6 +94,8 @@ const classifyCommandLink = Effect.fn("cli.install.classify_command_link")(funct // The desktop app's launcher carries its marker; a linked bin folder could hold a user's own t3. const isDesktopLauncher = Effect.fn("cli.install.is_desktop_launcher")( function* (file: string) { + // The app writes a file; a link there leads to another home's or another tool's t3. + if (Option.isSome(yield* fs.readLink(file).pipe(Effect.option))) return false; const info = yield* fs.stat(file); if (info.type !== "File" || Number(info.size) > 16_384) return false; return (yield* fs.readFileString(file)).includes(DESKTOP_CLI_LAUNCHER_MARKER); @@ -106,6 +108,7 @@ const classifyCommandLink = Effect.fn("cli.install.classify_command_link")(funct Option.isSome(text) && path.isAbsolute(text.value) && !text.value.split(/[\\/]/).includes("..") && + !/[\\/]\.?$/.test(text.value) && path.normalize(text.value) === desktopLauncher && (yield* isDesktopLauncher(desktopLauncher)) ) { diff --git a/scripts/install.sh b/scripts/install.sh index fa3085143fd0d..0c434daae5b18 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -285,7 +285,8 @@ fi real_bin_dir="$(CDPATH='' cd -P -- "$bin_dir" && pwd)" # The desktop app's launcher carries its marker; a linked bin folder could hold a user's own t3. desktop_launcher() { - [ -f "$1" ] && [ "$(wc -c < "$1")" -le 16384 ] && + # The app writes a file; a link there leads to another home's or another tool's t3. + [ ! -L "$1" ] && [ -f "$1" ] && [ "$(wc -c < "$1")" -le 16384 ] && grep -qF "Written by T3 Code: runs the desktop app's bundled t3 CLI." "$1" } # Prints what the link at $1 is to this home: current (its command link), older (a version's t3 diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 180c97592f804..6a73f0d274828 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -331,6 +331,21 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 ours ? NodePath.join(home, "runtime/versions/current/t3") : NodePath.join(home, "bin/t3"), ); } + // A link at the launcher's name leads to another home's or another tool's t3. + await NodeFSP.writeFile( + NodePath.join(root, "nightly-launcher"), + "#!/bin/sh\n# Written by T3 Code: runs the desktop app's bundled t3 CLI.\n", + ); + await NodeFSP.rm(NodePath.join(root, "user-bin/t3")); + await NodeFSP.symlink( + NodePath.join(root, "nightly-launcher"), + NodePath.join(root, "user-bin/t3"), + ); + await NodeFSP.rm(link); + await NodeFSP.symlink(NodePath.join(home, "bin/t3"), link); + const chained = await installOver({ script: "install.sh", home, binDir }); + expect(chained.code).not.toBe(0); + expect(await NodeFSP.readlink(link)).toBe(NodePath.join(home, "bin/t3")); } finally { await NodeFSP.rm(root, { recursive: true, force: true }); } From 65680f222e7c6f4afff8f9106b1bb82a9306828d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 18:45:15 -0700 Subject: [PATCH 16/23] fix(cli): the command link's staging name never removes an existing file Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/install.test.ts | 23 ++++++++++++++--------- apps/server/src/cli/install.ts | 16 ++++++---------- scripts/install.sh | 9 ++++----- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/apps/server/src/cli/install.test.ts b/apps/server/src/cli/install.test.ts index c52795f83dee8..cf592f733a536 100644 --- a/apps/server/src/cli/install.test.ts +++ b/apps/server/src/cli/install.test.ts @@ -198,9 +198,17 @@ it.layer(NodeServices.layer)("t3 install", (it) => { const desktopBin = path.join(root, "desktop-bin"); yield* fs.makeDirectory(desktopBin); yield* fs.symlink(desktopBin, path.join(root, ".t3/bin")); + // A target naming the marked launcher as a folder never runs it. + const marked = `#!/bin/sh\n# ${DESKTOP_CLI_LAUNCHER_MARKER}\n`; + yield* fs.writeFileString(path.join(desktopBin, "t3"), marked); + const asFolder = `${path.join(root, ".t3/bin/t3")}/.`; + yield* fs.remove(link); + yield* fs.symlink(asFolder, link); + yield* provide(installCli()).pipe(Effect.ignore); + assert.equal(yield* fs.readLink(link), asFolder); for (const [contents, ours] of [ ["#!/bin/sh\necho mine\n", false], - [`#!/bin/sh\n# ${DESKTOP_CLI_LAUNCHER_MARKER}\n`, true], + [marked, true], ] as const) { yield* fs.writeFileString(path.join(desktopBin, "t3"), contents); yield* fs.remove(link); @@ -212,18 +220,15 @@ it.layer(NodeServices.layer)("t3 install", (it) => { contents, ); } - // A link at the launcher's name leads to another home's or another tool's t3, and a target - // naming it as a folder never runs it. + // A link at the launcher's name leads to another home's or another tool's t3. const nightly = path.join(root, "nightly-launcher"); yield* fs.writeFileString(nightly, `#!/bin/sh\n# ${DESKTOP_CLI_LAUNCHER_MARKER}\n`); yield* fs.rename(path.join(desktopBin, "t3"), path.join(desktopBin, "t3.real")); yield* fs.symlink(nightly, path.join(desktopBin, "t3")); - for (const target of [path.join(root, ".t3/bin/t3"), `${path.join(root, ".t3/bin/t3")}/.`]) { - yield* fs.remove(link); - yield* fs.symlink(target, link); - yield* provide(installCli()).pipe(Effect.ignore); - assert.equal(yield* fs.readLink(link), target); - } + yield* fs.remove(link); + yield* fs.symlink(path.join(root, ".t3/bin/t3"), link); + yield* provide(installCli()).pipe(Effect.ignore); + assert.equal(yield* fs.readLink(link), path.join(root, ".t3/bin/t3")); yield* fs.remove(path.join(root, ".t3/bin")); // Another program may write its t3 at any moment, here once, at the first symlink this diff --git a/apps/server/src/cli/install.ts b/apps/server/src/cli/install.ts index a237e6ef1297f..a43ce123d5436 100644 --- a/apps/server/src/cli/install.ts +++ b/apps/server/src/cli/install.ts @@ -8,6 +8,7 @@ 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 Random from "effect/Random"; import * as Schema from "effect/Schema"; import { Command, Prompt } from "effect/cli"; import * as ChildProcess from "effect/process/ChildProcess"; @@ -166,17 +167,12 @@ export const linkCommand = Effect.fn("cli.install.link_command")(function* (inpu reason: `${commandLink} is not a link. Remove it, then run this again.`, }); } - const staging = `${commandLink}.${process.pid}.tmp`; + // Created exclusively and removed only once it is this call's own. + const staging = `${commandLink}.${process.pid}.${yield* Random.nextIntBetween(0, 0x7fffffff)}.tmp`; + yield* fs.symlink(input.executable, staging); yield* fs - .remove(staging, { force: true }) - .pipe( - Effect.andThen(fs.symlink(input.executable, staging)), - Effect.andThen( - fs - .rename(staging, commandLink) - .pipe(Effect.tapError(() => fs.remove(staging, { force: true }).pipe(Effect.ignore))), - ), - ); + .rename(staging, commandLink) + .pipe(Effect.tapError(() => fs.remove(staging, { force: true }).pipe(Effect.ignore))); if (kind === "missing") yield* fs.symlink(commandLink, input.link); if (kind !== "older") return true; return yield* replaceOwnedLink({ diff --git a/scripts/install.sh b/scripts/install.sh index 0c434daae5b18..41391ebf99bda 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -337,11 +337,10 @@ if [ -e "$public" ] || [ -L "$public" ]; then kind="$(link_kind "$public")" [ -n "$kind" ] || fail "another t3 is already at ${public}. Remove it and run this again, or run ${target_dir}/t3 directly." fi -rm -f "${command_link}.$$.tmp" -if ! { ln -s "${target_dir}/t3" "${command_link}.$$.tmp" && mv -f "${command_link}.$$.tmp" "$command_link"; }; then - rm -f "${command_link}.$$.tmp" - fail "could not link ${command_link}" -fi +# Created without -f and removed only once it is this install's own. +staging="${command_link}.$$.tmp" +ln -s "${target_dir}/t3" "$staging" || fail "could not link ${command_link}" +mv -f "$staging" "$command_link" || { rm -f "$staging"; fail "could not link ${command_link}"; } case "$kind" in # An empty spot is linked without -f, so a file that appears after the check above is kept. # Into the folder, so an entry of any kind already named t3 makes ln fail instead of filling it. From 95b99686f6eed2c1cf75e45c33eac984120d1f61 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 18:48:51 -0700 Subject: [PATCH 17/23] fix(cli): stage the command link in a folder of its own, and allow newlines in a T3 home Co-Authored-By: Claude Opus 5.5 --- scripts/install.sh | 16 +++++++++++----- scripts/install.test.ts | 11 +++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 41391ebf99bda..7015a6b43082c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -205,6 +205,7 @@ resolve_path() ( ) # Spelled as every t3 spells its T3 home, so released t3 still owns the links made from it. # Like the CLI, a quoted ~ at the start means the home folder. +# shellcheck disable=SC2088 # The ~ here is the literal text to expand. case "$t3_home" in "~") t3_home="$HOME" ;; "~/"*) t3_home="${HOME}/${t3_home#"~/"}" ;; esac t3_home="$(resolve_path "$t3_home")" stem="t3-${version}-${platform}-${arch}" @@ -300,7 +301,8 @@ link_kind() { # `..` can mean another folder once a link is followed, and a target naming a folder (`t3/`, # `t3/.`) never runs the file it would resolve to. `//` and `/./` collapse as text. case "/${existing}/" in */../*) return 0 ;; esac - case "$existing" in */ | */. | . | *"$newline"*) return 0 ;; esac + # A trailing newline would be lost in $(...) below, so such a target could pass as ours. + case "$existing" in */ | */. | . | *"$newline") return 0 ;; esac # A relative target starts from the bin folder's real path, as the OS follows it. case "$existing" in /*) ;; *) existing="${real_bin_dir}/${existing}" ;; esac existing="$(resolve_path "$existing")" @@ -337,10 +339,14 @@ if [ -e "$public" ] || [ -L "$public" ]; then kind="$(link_kind "$public")" [ -n "$kind" ] || fail "another t3 is already at ${public}. Remove it and run this again, or run ${target_dir}/t3 directly." fi -# Created without -f and removed only once it is this install's own. -staging="${command_link}.$$.tmp" -ln -s "${target_dir}/t3" "$staging" || fail "could not link ${command_link}" -mv -f "$staging" "$command_link" || { rm -f "$staging"; fail "could not link ${command_link}"; } +# Made in a new folder of its own, so nothing already in current is reused or removed. +staging_dir="$(mktemp -d "${current_dir}/.t3.XXXXXX")" || fail "could not link ${command_link}" +if ! { ln -s "${target_dir}/t3" "${staging_dir}/t3" && mv -f "${staging_dir}/t3" "$command_link"; }; then + rm -f "${staging_dir}/t3" + rmdir "$staging_dir" + fail "could not link ${command_link}" +fi +rmdir "$staging_dir" case "$kind" in # An empty spot is linked without -f, so a file that appears after the check above is kept. # Into the folder, so an entry of any kind already named t3 makes ln fail instead of filling it. diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 6a73f0d274828..71ba932c4dc6f 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -274,6 +274,16 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 cwd: root, }); expect(relativeHome.code, relativeHome.output).toBe(0); + // A T3 home with a newline in its name is still recognized on the next install. + const oddHome = NodePath.join(root, "odd\nhome"); + const oddBin = NodePath.join(root, "odd-bin"); + for (const run of [1, 2]) { + const odd = await installOver({ script: "install.sh", home: oddHome, binDir: oddBin }); + expect(odd.code, `run ${run}: ${odd.output}`).toBe(0); + } + expect(await NodeFSP.readlink(NodePath.join(oddBin, "t3"))).toBe( + NodePath.join(oddHome, "runtime/versions/current/t3"), + ); // A quoted ~ means the home folder, as the CLI reads it. await NodeFSP.rm(link, { force: true }); const tilde = await installOver({ @@ -499,6 +509,7 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("installer chec "mkdir", "mktemp", "rm", + "rmdir", "mv", "ln", checksumTool, From 1e883856756e8459ebb5351cbbf41be9353a47fd Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 18:57:57 -0700 Subject: [PATCH 18/23] fix(cli): switch the command link only after the public link is in place A failed install no longer moves another bin folder's t3 to the new version: the command link is created first only when it is new, and switched only once the public link leads to it. install.sh keeps a trailing newline in path names and stages the switch into current/, never through a link. A failed put-back now reports where the old link is. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/install.test.ts | 35 +++++++++++++--- apps/server/src/cli/install.ts | 38 ++++++++++------- packages/shared/src/ownedLink.ts | 7 +++- scripts/install.sh | 64 ++++++++++++++++++----------- scripts/install.test.ts | 5 +++ 5 files changed, 106 insertions(+), 43 deletions(-) diff --git a/apps/server/src/cli/install.test.ts b/apps/server/src/cli/install.test.ts index cf592f733a536..40fe616018f0b 100644 --- a/apps/server/src/cli/install.test.ts +++ b/apps/server/src/cli/install.test.ts @@ -129,7 +129,11 @@ it.layer(NodeServices.layer)("t3 install", (it) => { }).pipe(Effect.flip); assert.include(inCurrent.message, "T3CODE_INSTALL_BIN_DIR cannot be"); - // When the new link cannot be made, the old command keeps working. + // When the new link cannot be made, the old command keeps working, and so does any other + // bin folder's: the command link switches only after this one is linked. + const otherVersion = path.join(runtimeDir, "versions/0.0.2/t3"); + yield* fs.remove(commandLink, { force: true }); + yield* fs.symlink(otherVersion, commandLink); const failing = installCli().pipe( Effect.provideService(FileSystem.FileSystem, { ...fs, @@ -140,6 +144,13 @@ it.layer(NodeServices.layer)("t3 install", (it) => { : fs.symlink(target, to), }), ); + for (const before of [older, undefined]) { + yield* fs.remove(link).pipe(Effect.ignore); + if (before !== undefined) yield* fs.symlink(before, link); + yield* provide(failing).pipe(Effect.flip); + assert.equal(yield* fs.readLink(commandLink), otherVersion, before); + } + yield* fs.symlink(older, link); yield* provide(failing).pipe(Effect.flip); assert.equal(yield* fs.readLink(link), older); assert.deepStrictEqual( @@ -253,23 +264,25 @@ it.layer(NodeServices.layer)("t3 install", (it) => { // A foreign link whose target is not UTF-8 goes back byte for byte. const bytes = Buffer.from([0x2f, 0x74, 0x6d, 0x70, 0x2f, 0xff, 0x74, 0x33]); let swapped = false; + // It arrives just before the older link is moved aside, so it is what gets moved. const rawRace = installCli().pipe( Effect.provideService(FileSystem.FileSystem, { ...fs, - symlink: (target, to) => - (swapped + writeFileString: (file, data, options) => + (swapped || !file.endsWith(".old") ? Effect.void : Effect.promise(async () => { await NodeFSP.symlink(bytes, `${link}.new`); await NodeFSP.rename(`${link}.new`, link); swapped = true; }) - ).pipe(Effect.andThen(fs.symlink(target, to))), + ).pipe(Effect.andThen(fs.writeFileString(file, data, options))), }), ); yield* fs.remove(link); yield* fs.symlink(older, link); yield* provide(rawRace).pipe(Effect.flip); + assert.isTrue(swapped); assert.deepStrictEqual( yield* Effect.promise(() => NodeFSP.readlink(link, { encoding: "buffer" })), bytes, @@ -282,7 +295,19 @@ it.layer(NodeServices.layer)("t3 install", (it) => { const raced = yield* provide(racing).pipe(Effect.result); assert.isTrue(fired); assert.equal(yield* fs.readFileString(link), "#!/bin/sh\necho raced\n", before); - assert.deepStrictEqual(yield* fs.readDirectory(path.dirname(link)), ["t3"]); + // An older link that was moved aside when the write came stays beside it, and the error + // says where. + const entries = yield* fs.readDirectory(path.dirname(link)); + const kept = entries.filter((name) => name.startsWith(".t3.")); + assert.deepStrictEqual( + entries.filter((name) => !kept.includes(name)), + ["t3"], + ); + for (const name of kept) { + assert.equal(yield* fs.readLink(path.join(path.dirname(link), name)), older); + assert.include(String(raced._tag === "Failure" && raced.failure.message), name); + yield* fs.remove(path.join(path.dirname(link), name)); + } // Nothing was replaced over the command link's own spot, so that install succeeds. assert.equal(raced._tag === "Success", before === commandLink, before); } diff --git a/apps/server/src/cli/install.ts b/apps/server/src/cli/install.ts index a43ce123d5436..a77be23060a09 100644 --- a/apps/server/src/cli/install.ts +++ b/apps/server/src/cli/install.ts @@ -168,21 +168,31 @@ export const linkCommand = Effect.fn("cli.install.link_command")(function* (inpu }); } // Created exclusively and removed only once it is this call's own. - const staging = `${commandLink}.${process.pid}.${yield* Random.nextIntBetween(0, 0x7fffffff)}.tmp`; - yield* fs.symlink(input.executable, staging); - yield* fs - .rename(staging, commandLink) - .pipe(Effect.tapError(() => fs.remove(staging, { force: true }).pipe(Effect.ignore))); - if (kind === "missing") yield* fs.symlink(commandLink, input.link); - if (kind !== "older") return true; - return yield* replaceOwnedLink({ - link: input.link, - target: commandLink, - owns: (candidate) => - classifyCommandLink(candidate, input.baseDir).pipe( - Effect.map((found) => found !== "foreign"), - ), + const switchCommandLink = Effect.gen(function* () { + const staging = `${commandLink}.${process.pid}.${yield* Random.nextIntBetween(0, 0x7fffffff)}.tmp`; + yield* fs.symlink(input.executable, staging); + yield* fs + .rename(staging, commandLink) + .pipe(Effect.tapError(() => fs.remove(staging, { force: true }).pipe(Effect.ignore))); }); + // Other bin folders may run the command link already, so it switches only once `link` leads + // to it; a new one has nobody to surprise. + const existed = Option.isSome(yield* fs.readLink(commandLink).pipe(Effect.option)); + if (!existed) yield* switchCommandLink; + if (kind === "missing") yield* fs.symlink(commandLink, input.link); + if (kind === "older") { + const moved = yield* replaceOwnedLink({ + link: input.link, + target: commandLink, + owns: (candidate) => + classifyCommandLink(candidate, input.baseDir).pipe( + Effect.map((found) => found !== "foreign"), + ), + }); + if (!moved) return false; + } + if (existed) yield* switchCommandLink; + return true; }); /** Links this runtime as the `t3` command the way the install scripts do, never over another t3. */ diff --git a/packages/shared/src/ownedLink.ts b/packages/shared/src/ownedLink.ts index 5d79165d026d6..59582fd5969a3 100644 --- a/packages/shared/src/ownedLink.ts +++ b/packages/shared/src/ownedLink.ts @@ -77,7 +77,12 @@ export const replaceOwnedLink = Effect.fn("ownedLink.replace")(function* ( yield* fs .symlink(input.target, input.link) .pipe( - Effect.tapError(() => putBack.pipe(Effect.andThen(discard.pipe(Effect.ignore)))), + Effect.catch((error) => + putBack.pipe( + Effect.andThen(discard.pipe(Effect.ignore)), + Effect.andThen(Effect.fail(error)), + ), + ), ); } yield* discard.pipe(Effect.ignore); diff --git a/scripts/install.sh b/scripts/install.sh index 7015a6b43082c..2df210f19764e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -207,7 +207,9 @@ resolve_path() ( # Like the CLI, a quoted ~ at the start means the home folder. # shellcheck disable=SC2088 # The ~ here is the literal text to expand. case "$t3_home" in "~") t3_home="$HOME" ;; "~/"*) t3_home="${HOME}/${t3_home#"~/"}" ;; esac -t3_home="$(resolve_path "$t3_home")" +# The x keeps a trailing newline in the name, which $(...) would drop. +t3_home="$(resolve_path "$t3_home" && echo x)" +t3_home="${t3_home%x}" stem="t3-${version}-${platform}-${arch}" archive="${stem}.tar.gz" versions_dir="${t3_home}/runtime/versions" @@ -269,21 +271,28 @@ mkdir -p "$current_dir" if [ -d "$command_link" ] || { [ -e "$command_link" ] && [ ! -L "$command_link" ]; }; then fail "${command_link} is not a link to a file. Remove it and run this again." fi -real_versions_dir="$(CDPATH='' cd -P -- "$versions_dir" && pwd)" -real_command_link="$(CDPATH='' cd -P -- "$current_dir" && pwd)/t3" +newline=" +" +# Sets real to folder $1's real path, keeping a trailing newline that $(...) would drop. +real_dir() { + real="$(CDPATH='' cd -P -- "$1" 2>/dev/null && pwd && echo x)" || return 1 + real="${real%"${newline}x"}" +} +real_dir "$versions_dir" || fail "could not read ${versions_dir}" +real_versions_dir="$real" +real_dir "$current_dir" || fail "could not read ${current_dir}" +real_command_link="${real}/t3" # 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 } -newline=" -" # Its bin folder may be a link, or missing until the app runs. -real_desktop_launcher="$(CDPATH='' cd -P -- "$t3_home" && pwd)/bin/t3" -if real_bin="$(CDPATH='' cd -P -- "${t3_home}/bin" 2>/dev/null && pwd)"; then - real_desktop_launcher="${real_bin}/t3" -fi -real_bin_dir="$(CDPATH='' cd -P -- "$bin_dir" && pwd)" +real_dir "$t3_home" || fail "could not read ${t3_home}" +real_desktop_launcher="${real}/bin/t3" +if real_dir "${t3_home}/bin"; then real_desktop_launcher="${real}/t3"; fi +real_dir "$bin_dir" || fail "could not read ${bin_dir}" +real_bin_dir="$real" # The desktop app's launcher carries its marker; a linked bin folder could hold a user's own t3. desktop_launcher() { # The app writes a file; a link there leads to another home's or another tool's t3. @@ -309,11 +318,12 @@ link_kind() { # The desktop app writes this exact path, also when the home's bin folder is a link. if [ "$existing" = "${t3_home}/bin/t3" ] && desktop_launcher "$existing"; then echo older; return 0; fi # A folder that is itself a link (another versions/) is not followed. - folder="$(dirname -- "$existing")" + folder="${existing%/*}" + [ -n "$folder" ] || folder=/ [ ! -L "$folder" ] || return 0 # Resolved on disk, which sees through a symlinked T3 home. - if existing_dir="$(CDPATH='' cd -P -- "$folder" 2>/dev/null && pwd)"; then - real_existing="${existing_dir}/${existing##*/}" + if real_dir "$folder"; then + real_existing="${real}/${existing##*/}" if [ "$real_existing" = "$real_command_link" ]; then echo current elif version_t3 "$real_existing" "$real_versions_dir" || { [ "$real_existing" = "$real_desktop_launcher" ] && desktop_launcher "$real_existing"; }; then @@ -324,12 +334,13 @@ link_kind() { # Only a missing folder (a pruned version) leaves the text. A folder that cannot be resolved for # another reason may be another program's. [ ! -e "$folder" ] || return 0 - parent="$(CDPATH='' cd -P -- "$(dirname -- "$folder")" 2>/dev/null && pwd)" || return 0 - real_existing="${parent}/${folder##*/}/${existing##*/}" + parent="${folder%/*}" + real_dir "${parent:-/}" || return 0 + real_existing="${real}/${folder##*/}/${existing##*/}" if version_t3 "$real_existing" "$real_versions_dir"; then echo older; fi } kind= -real_public="$(CDPATH='' cd -P -- "$bin_dir" && pwd)/t3" +real_public="${real_bin_dir}/t3" # The command link is replaced in place, which is safe only away from the public name. -ef # compares device and inode, so an alias or a bind mount of its folder counts too. if [ "$real_public" = "$real_command_link" ] || [ "$bin_dir" -ef "$current_dir" ]; then @@ -339,14 +350,20 @@ if [ -e "$public" ] || [ -L "$public" ]; then kind="$(link_kind "$public")" [ -n "$kind" ] || fail "another t3 is already at ${public}. Remove it and run this again, or run ${target_dir}/t3 directly." fi -# Made in a new folder of its own, so nothing already in current is reused or removed. -staging_dir="$(mktemp -d "${current_dir}/.t3.XXXXXX")" || fail "could not link ${command_link}" -if ! { ln -s "${target_dir}/t3" "${staging_dir}/t3" && mv -f "${staging_dir}/t3" "$command_link"; }; then - rm -f "${staging_dir}/t3" +# Made in a new folder of its own, so nothing already in current is reused or removed, and moved +# into current, so a link that replaced current/t3 is replaced itself, never followed. +switch_command_link() { + staging_dir="$(mktemp -d "${current_dir}/.t3.XXXXXX")" || fail "could not link ${command_link}" + if ! { ln -s "${target_dir}/t3" "${staging_dir}/t3" && mv -f "${staging_dir}/t3" "${current_dir}/"; }; then + rm -f "${staging_dir}/t3" + rmdir "$staging_dir" + fail "could not link ${command_link}" + fi rmdir "$staging_dir" - fail "could not link ${command_link}" -fi -rmdir "$staging_dir" +} +# Other bin folders may run the command link already, so it switches only once this one is linked. +switched=false +if [ ! -L "$command_link" ]; then switch_command_link; switched=true; fi case "$kind" in # An empty spot is linked without -f, so a file that appears after the check above is kept. # Into the folder, so an entry of any kind already named t3 makes ln fail instead of filling it. @@ -383,6 +400,7 @@ case "$kind" in fi ;; esac +"$switched" || switch_command_link # Pruning keeps what this launcher points at; a background service cannot see bin_dir otherwise. launchers="${t3_home}/runtime/launchers" grep -qxF "$real_public" "$launchers" 2>/dev/null || printf '%s\n' "$real_public" >> "$launchers" diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 71ba932c4dc6f..c323adfcc748e 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -419,9 +419,14 @@ exec ${realLn} "$@" const oldLink = NodePath.join(home, "runtime/versions/1.0.0/t3"); await NodeFSP.rm(link); await NodeFSP.symlink(oldLink, link); + // Another bin folder's command keeps its version too: the command link switches last. + const otherVersion = NodePath.join(home, "runtime/versions/0.0.2/t3"); + await NodeFSP.rm(commandLink); + await NodeFSP.symlink(otherVersion, commandLink); const failing = await racing({ RACE: "", FAIL: `${binDir}/` }); expect(failing.code).not.toBe(0); expect(await NodeFSP.readlink(link)).toBe(oldLink); + expect(await NodeFSP.readlink(commandLink)).toBe(otherVersion); expect((await NodeFSP.readdir(binDir)).filter((name) => name.startsWith(".t3."))).toEqual([]); } finally { await NodeFSP.rm(root, { recursive: true, force: true }); From 4902fdc132ef5a70f88376320a4565cfe1ec176f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 19:03:20 -0700 Subject: [PATCH 19/23] fix(cli): switch the home's command first, after checking the bin folder A failed public link now leaves the home on the version asked for, never a third one; a bin folder that cannot be written is refused before anything changes, and a public link a concurrent install just made counts as done. On POSIX only an exact /t3 is a version's t3. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/install.test.ts | 35 ++++++++++++++++----- apps/server/src/cli/install.ts | 43 ++++++++++++++++---------- apps/server/src/cli/uninstall.test.ts | 6 ++++ apps/server/src/cloud/pinnedRuntime.ts | 8 +++-- scripts/install.sh | 20 ++++++++---- scripts/install.test.ts | 15 ++++++--- 6 files changed, 91 insertions(+), 36 deletions(-) diff --git a/apps/server/src/cli/install.test.ts b/apps/server/src/cli/install.test.ts index 40fe616018f0b..a30b8a9b533fe 100644 --- a/apps/server/src/cli/install.test.ts +++ b/apps/server/src/cli/install.test.ts @@ -129,11 +129,35 @@ it.layer(NodeServices.layer)("t3 install", (it) => { }).pipe(Effect.flip); assert.include(inCurrent.message, "T3CODE_INSTALL_BIN_DIR cannot be"); - // When the new link cannot be made, the old command keeps working, and so does any other - // bin folder's: the command link switches only after this one is linked. + // A bin folder that cannot be written changes nothing, not even the home's command. const otherVersion = path.join(runtimeDir, "versions/0.0.2/t3"); yield* fs.remove(commandLink, { force: true }); yield* fs.symlink(otherVersion, commandLink); + const readOnly = path.join(root, "read-only-bin"); + yield* fs.makeDirectory(readOnly); + yield* fs.chmod(readOnly, 0o555); + const refused = yield* provide(installCli(), { T3CODE_INSTALL_BIN_DIR: readOnly }).pipe( + Effect.flip, + ); + assert.include(refused.message, "Cannot write to"); + assert.equal(yield* fs.readLink(commandLink), otherVersion); + yield* fs.chmod(readOnly, 0o755); + + // A concurrent install that made the same link a moment ago is not a failure. + const concurrent = installCli().pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + symlink: (target, to) => + to === link && target === commandLink + ? fs.symlink(target, to).pipe(Effect.andThen(fs.symlink(target, to))) + : fs.symlink(target, to), + }), + ); + yield* fs.remove(link); + yield* provide(concurrent); + assert.equal(yield* fs.readLink(link), commandLink); + + // When the new link cannot be made, the old command keeps working. const failing = installCli().pipe( Effect.provideService(FileSystem.FileSystem, { ...fs, @@ -144,12 +168,7 @@ it.layer(NodeServices.layer)("t3 install", (it) => { : fs.symlink(target, to), }), ); - for (const before of [older, undefined]) { - yield* fs.remove(link).pipe(Effect.ignore); - if (before !== undefined) yield* fs.symlink(before, link); - yield* provide(failing).pipe(Effect.flip); - assert.equal(yield* fs.readLink(commandLink), otherVersion, before); - } + yield* fs.remove(link); yield* fs.symlink(older, link); yield* provide(failing).pipe(Effect.flip); assert.equal(yield* fs.readLink(link), older); diff --git a/apps/server/src/cli/install.ts b/apps/server/src/cli/install.ts index a77be23060a09..b4d018c4badae 100644 --- a/apps/server/src/cli/install.ts +++ b/apps/server/src/cli/install.ts @@ -175,24 +175,30 @@ export const linkCommand = Effect.fn("cli.install.link_command")(function* (inpu .rename(staging, commandLink) .pipe(Effect.tapError(() => fs.remove(staging, { force: true }).pipe(Effect.ignore))); }); - // Other bin folders may run the command link already, so it switches only once `link` leads - // to it; a new one has nobody to surprise. - const existed = Option.isSome(yield* fs.readLink(commandLink).pipe(Effect.option)); - if (!existed) yield* switchCommandLink; - if (kind === "missing") yield* fs.symlink(commandLink, input.link); - if (kind === "older") { - const moved = yield* replaceOwnedLink({ - link: input.link, - target: commandLink, - owns: (candidate) => - classifyCommandLink(candidate, input.baseDir).pipe( - Effect.map((found) => found !== "foreign"), + // The home's command switches first: if `link` then fails, the home runs the version asked for + // and only `link` is missing, never a third version. + yield* switchCommandLink; + if (kind === "missing") { + // A concurrent install may have made the same link a moment ago. + yield* fs + .symlink(commandLink, input.link) + .pipe( + Effect.catch((error) => + classifyCommandLink(input.link, input.baseDir).pipe( + Effect.flatMap((found) => (found === "current" ? Effect.void : Effect.fail(error))), + ), ), - }); - if (!moved) return false; + ); } - if (existed) yield* switchCommandLink; - return true; + if (kind !== "older") return true; + return yield* replaceOwnedLink({ + link: input.link, + target: commandLink, + owns: (candidate) => + classifyCommandLink(candidate, input.baseDir).pipe( + Effect.map((found) => found !== "foreign"), + ), + }); }); /** Links this runtime as the `t3` command the way the install scripts do, never over another t3. */ @@ -294,6 +300,11 @@ export const installCli = Effect.fn("cli.install.install")(function* () { reason: `T3CODE_INSTALL_BIN_DIR cannot be ${commandFolder}. Choose another folder.`, }); } + // Checked before the home's command switches, so a bin folder that cannot be written changes + // nothing. + if (!(yield* fs.access(binDir, { writable: true }).pipe(Effect.isSuccess))) { + return yield* new CliInstallError({ reason: `Cannot write to ${binDir}.` }); + } const linked = yield* linkCommand({ link, baseDir, executable: spelled }).pipe( Effect.catchTags({ PlatformError: (cause) => diff --git a/apps/server/src/cli/uninstall.test.ts b/apps/server/src/cli/uninstall.test.ts index 9cff7ca8d5e16..17dba12aa3a96 100644 --- a/apps/server/src/cli/uninstall.test.ts +++ b/apps/server/src/cli/uninstall.test.ts @@ -42,6 +42,12 @@ it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => { yield* findOwnedLauncher({ launchedAs: viaCommandLink, versionsDir }), viaCommandLink, ); + // On POSIX a backslash is part of a name, so `versions/manager\t3` is not a version's t3. + const backslashed = path.join(versionsDir, "manager\\t3"); + const viaBackslash = path.join(root, "bin/t3-backslash"); + yield* fs.writeFileString(backslashed, ""); + yield* fs.symlink(backslashed, viaBackslash); + assert.isUndefined(yield* findOwnedLauncher({ launchedAs: viaBackslash, versionsDir })); // A version run directly is not a launcher, nor another tool's link that selects it. assert.isUndefined(yield* findOwnedLauncher({ launchedAs: exe, versionsDir })); const managed = path.join(root, "manager/t3"); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 4946ea14f0a2f..112c39fe4d6a1 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -450,8 +450,12 @@ export function isVersionExecutable( 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); + // On another Windows drive, relative() returns the absolute path, like `D:\t3.exe`. On POSIX, + // `\` and case are part of a name, so only `/` separates and `t3` must match exactly. + if (path.isAbsolute(relative)) return false; + return path.sep === "/" + ? /^(?!\.\.)[^/\\]+\/t3(?:\.exe)?$/.test(relative) + : /^(?!\.\.)[^\\/]+[\\/]t3(?:\.exe)?$/i.test(relative); } /** The executable a `t3.cmd` shim written by the install script or `t3 update` runs. */ diff --git a/scripts/install.sh b/scripts/install.sh index 2df210f19764e..0b32bd29f2a68 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -194,7 +194,12 @@ esac # A path as Node's path.resolve spells it: absolute against the real working folder, with `.`, # `..`, and `//` collapsed as text, keeping the names of symlinked folders. resolve_path() ( - case "$1" in /*) rest="$1" ;; *) rest="$(pwd -P)/$1" ;; esac + # The x keeps a trailing newline in the working folder's name, which $(...) would drop. + case "$1" in + /*) rest="$1" ;; + *) rest="$(pwd -P && echo x)" && rest="${rest%" +x"}/$1" ;; + esac out= IFS=/ set -f @@ -361,13 +366,17 @@ switch_command_link() { fi rmdir "$staging_dir" } -# Other bin folders may run the command link already, so it switches only once this one is linked. -switched=false -if [ ! -L "$command_link" ]; then switch_command_link; switched=true; fi +# Checked before the home's command switches, so a bin folder that cannot be written changes nothing. +[ -w "$bin_dir" ] || fail "cannot write to ${bin_dir}" +# The home's command switches first: if the public link then fails, the home runs the version asked +# for and only the public link is missing, never a third version. +switch_command_link case "$kind" in # An empty spot is linked without -f, so a file that appears after the check above is kept. # Into the folder, so an entry of any kind already named t3 makes ln fail instead of filling it. - "") ln -s "$command_link" "${bin_dir}/" || fail "could not link ${public}; something else was created there." ;; + # A concurrent install may have made the same link a moment ago. + "") ln -s "$command_link" "${bin_dir}/" || [ "$(link_kind "$public")" = current ] || + fail "could not link ${public}; something else was created there." ;; older) # Moved aside before it is checked again, so the check sees exactly what gets replaced. A new # folder of its own, so nothing left by an earlier install is replaced; the entry keeps its @@ -400,7 +409,6 @@ case "$kind" in fi ;; esac -"$switched" || switch_command_link # Pruning keeps what this launcher points at; a background service cannot see bin_dir otherwise. launchers="${t3_home}/runtime/launchers" grep -qxF "$real_public" "$launchers" 2>/dev/null || printf '%s\n' "$real_public" >> "$launchers" diff --git a/scripts/install.test.ts b/scripts/install.test.ts index c323adfcc748e..e19ae9e8a5798 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -419,13 +419,20 @@ exec ${realLn} "$@" const oldLink = NodePath.join(home, "runtime/versions/1.0.0/t3"); await NodeFSP.rm(link); await NodeFSP.symlink(oldLink, link); - // Another bin folder's command keeps its version too: the command link switches last. - const otherVersion = NodePath.join(home, "runtime/versions/0.0.2/t3"); - await NodeFSP.rm(commandLink); - await NodeFSP.symlink(otherVersion, commandLink); const failing = await racing({ RACE: "", FAIL: `${binDir}/` }); expect(failing.code).not.toBe(0); expect(await NodeFSP.readlink(link)).toBe(oldLink); + + // A bin folder that cannot be written changes nothing, not even the home's command. + const otherVersion = NodePath.join(home, "runtime/versions/0.0.2/t3"); + await NodeFSP.rm(commandLink); + await NodeFSP.symlink(otherVersion, commandLink); + const readOnly = NodePath.join(root, "read-only-bin"); + await NodeFSP.mkdir(readOnly, { mode: 0o555 }); + const refused = await installOver({ script: "install.sh", home, binDir: readOnly }); + await NodeFSP.chmod(readOnly, 0o755); + expect(refused.code).not.toBe(0); + expect(refused.output).toContain("cannot write to"); expect(await NodeFSP.readlink(commandLink)).toBe(otherVersion); expect((await NodeFSP.readdir(binDir)).filter((name) => name.startsWith(".t3."))).toEqual([]); } finally { From 283a9041e49ffccd2d757dd75fb74f47eda31bb0 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 19:09:00 -0700 Subject: [PATCH 20/23] fix(cli): check the bin folder only when it needs a write, and read races twice The writable check runs after classification and only when the public entry must change, for t3 update too. A link a concurrent install makes between two reads is not mistaken for a foreign file, update never recreates a launcher removed while it ran, and a link target that is not UTF-8 is not ours. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/install.test.ts | 19 +++++++++---- apps/server/src/cli/install.ts | 39 +++++++++++++++++--------- apps/server/src/cli/uninstall.test.ts | 11 ++++++++ apps/server/src/cli/update.test.ts | 21 ++++++++++++++ apps/server/src/cli/update.ts | 1 + apps/server/src/cloud/pinnedRuntime.ts | 5 ++++ scripts/install.sh | 3 +- scripts/install.test.ts | 9 ++++-- 8 files changed, 86 insertions(+), 22 deletions(-) diff --git a/apps/server/src/cli/install.test.ts b/apps/server/src/cli/install.test.ts index a30b8a9b533fe..450faa93de8bb 100644 --- a/apps/server/src/cli/install.test.ts +++ b/apps/server/src/cli/install.test.ts @@ -136,11 +136,20 @@ it.layer(NodeServices.layer)("t3 install", (it) => { const readOnly = path.join(root, "read-only-bin"); yield* fs.makeDirectory(readOnly); yield* fs.chmod(readOnly, 0o555); - const refused = yield* provide(installCli(), { T3CODE_INSTALL_BIN_DIR: readOnly }).pipe( - Effect.flip, - ); - assert.include(refused.message, "Cannot write to"); - assert.equal(yield* fs.readLink(commandLink), otherVersion); + // Root can write anywhere, so a read-only folder means nothing there. + if (process.getuid?.() !== 0) { + const refused = yield* provide(installCli(), { T3CODE_INSTALL_BIN_DIR: readOnly }).pipe( + Effect.flip, + ); + assert.include(refused.message, "Cannot write to"); + assert.equal(yield* fs.readLink(commandLink), otherVersion); + // A link there that already leads to the command link needs no write. + yield* fs.chmod(readOnly, 0o755); + yield* fs.symlink(commandLink, path.join(readOnly, "t3")); + yield* fs.chmod(readOnly, 0o555); + yield* provide(installCli(), { T3CODE_INSTALL_BIN_DIR: readOnly }); + assert.equal(yield* fs.readLink(commandLink), executable); + } yield* fs.chmod(readOnly, 0o755); // A concurrent install that made the same link a moment ago is not a failure. diff --git a/apps/server/src/cli/install.ts b/apps/server/src/cli/install.ts index b4d018c4badae..e9cfec8db40f2 100644 --- a/apps/server/src/cli/install.ts +++ b/apps/server/src/cli/install.ts @@ -47,6 +47,18 @@ const standaloneRuntime = Effect.gen(function* () { return Option.some({ executable, versionsDir, runtimeDir }); }); +/** + * A link, nothing, or something else. A concurrent install may make a link between two reads, so + * "something else" is read twice before it counts. + */ +const entryAt = Effect.fn("cli.install.entry_at")(function* (file: string) { + const fs = yield* FileSystem.FileSystem; + const isLink = fs.readLink(file).pipe(Effect.option, Effect.map(Option.isSome)); + if (yield* isLink) return "link"; + if (!(yield* fs.exists(file).pipe(Effect.orElseSucceed(() => true)))) return "missing"; + return (yield* isLink) ? "link" : "other"; +}); + /** * What `link` is to this T3 home: missing, its command link, an older link of this home (straight * to a version's t3, or the desktop app's launcher), or another program's. @@ -80,11 +92,10 @@ const classifyCommandLink = Effect.fn("cli.install.classify_command_link")(funct ) { return (yield* real(link)) === realCommandLink ? "current" : "foreign"; } + const entry = yield* entryAt(link); + if (entry !== "link") return entry === "missing" ? "missing" : "foreign"; // Only the direct target: a chain through another tool's link is that tool's. const targets = yield* directLinkTargets(link); - if (targets.length === 0 && Option.isNone(yield* fs.readLink(link).pipe(Effect.option))) { - return (yield* fs.exists(link).pipe(Effect.orElseSucceed(() => true))) ? "foreign" : "missing"; - } if (targets.includes(commandLink) || targets.includes(realCommandLink)) return "current"; const versionsDir = pinnedRuntimeVersionsDir(path, baseDir); const realVersionsDir = yield* fs @@ -144,11 +155,21 @@ export const linkCommand = Effect.fn("cli.install.link_command")(function* (inpu readonly link: string; readonly baseDir: string; readonly executable: string; + /** False for `t3 update`, which only switches a launcher it was started from. */ + readonly create?: boolean; }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const kind = yield* classifyCommandLink(input.link, input.baseDir); - if (kind === "foreign") return false; + if (kind === "foreign" || (kind === "missing" && input.create === false)) return false; + // Checked before the home's command switches, so a folder that cannot be written changes + // nothing. A link that already leads to the command link needs no write there. + if ( + kind !== "current" && + !(yield* fs.access(path.dirname(input.link), { writable: true }).pipe(Effect.isSuccess)) + ) { + return yield* new CliInstallError({ reason: `Cannot write to ${path.dirname(input.link)}.` }); + } const commandLink = pinnedRuntimeCommandLink(path, input.baseDir); // Inside the T3 home, so swapping it in place cannot replace another program's file. Still, // only a link in a real folder is ever replaced there. @@ -159,10 +180,7 @@ export const linkCommand = Effect.fn("cli.install.link_command")(function* (inpu }); } yield* fs.makeDirectory(folder, { recursive: true }); - if ( - Option.isNone(yield* fs.readLink(commandLink).pipe(Effect.option)) && - (yield* fs.exists(commandLink)) - ) { + if ((yield* entryAt(commandLink)) === "other") { return yield* new CliInstallError({ reason: `${commandLink} is not a link. Remove it, then run this again.`, }); @@ -300,11 +318,6 @@ export const installCli = Effect.fn("cli.install.install")(function* () { reason: `T3CODE_INSTALL_BIN_DIR cannot be ${commandFolder}. Choose another folder.`, }); } - // Checked before the home's command switches, so a bin folder that cannot be written changes - // nothing. - if (!(yield* fs.access(binDir, { writable: true }).pipe(Effect.isSuccess))) { - return yield* new CliInstallError({ reason: `Cannot write to ${binDir}.` }); - } const linked = yield* linkCommand({ link, baseDir, executable: spelled }).pipe( Effect.catchTags({ PlatformError: (cause) => diff --git a/apps/server/src/cli/uninstall.test.ts b/apps/server/src/cli/uninstall.test.ts index 17dba12aa3a96..f143fda3a6335 100644 --- a/apps/server/src/cli/uninstall.test.ts +++ b/apps/server/src/cli/uninstall.test.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's link calls take strings; this test needs raw bytes. +import * as NodeFSP from "node:fs/promises"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -71,6 +74,14 @@ it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => { const borrowed = path.join(root, "bin/t3-borrowed"); yield* fs.symlink(path.join(versionsDir, "2.0.0/t3"), borrowed); assert.isUndefined(yield* findOwnedLauncher({ launchedAs: borrowed, versionsDir })); + // A target that is not UTF-8 names another file than its decoded text, so it is not ours. + const rawFolder = Buffer.concat([Buffer.from(`${versionsDir}/`), Buffer.from([0xff])]); + yield* Effect.promise(() => NodeFSP.symlink(path.join(root, "elsewhere"), rawFolder)); + const rawLink = path.join(root, "bin/t3-raw"); + yield* Effect.promise(() => + NodeFSP.symlink(Buffer.concat([rawFolder, Buffer.from("/t3")]), rawLink), + ); + assert.isUndefined(yield* findOwnedLauncher({ launchedAs: rawLink, versionsDir })); // A version folder that is a link to one of ours (versions/latest) is not followed. yield* fs.symlink(path.join(versionsDir, "1.0.0"), path.join(versionsDir, "latest")); const latest = path.join(root, "bin/t3-latest"); diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index e42b59b78ddf7..cdd1e1657613c 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -83,6 +83,27 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { written.filter((file) => file.startsWith(path.dirname(launcher))), [], ); + // A launcher removed while update ran stays removed. + let removed = false; + const vanishing = FileSystem.FileSystem.of({ + ...fs, + readLink: (file) => + fs + .readLink(file) + .pipe( + Effect.tap(() => + file === launcher && !removed + ? fs.remove(launcher).pipe(Effect.andThen(Effect.sync(() => (removed = true)))) + : Effect.void, + ), + ), + }); + const gone = yield* update(newExe).pipe( + Effect.provideService(FileSystem.FileSystem, vanishing), + ); + assert.isTrue(removed); + assert.isTrue(Option.isNone(gone)); + assert.isFalse(yield* fs.exists(launcher)); }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), ); diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index e00c175376b5a..bfc7dcb3d8b21 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -131,6 +131,7 @@ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function return Option.none(); } const linked = yield* linkCommand({ + create: false, link: input.launchedAs, baseDir: path.dirname(path.dirname(input.versionsDir)), executable: input.targetEntryPath, diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 112c39fe4d6a1..5be54cd4fb661 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -413,6 +413,11 @@ export const directLinkTargets = Effect.fn("cloud.pinned_runtime.direct_link_tar const path = yield* Path.Path; const text = yield* fs.readLink(link).pipe(Effect.option); if (Option.isNone(text)) return []; + // The text is decoded as UTF-8; bytes that are not would name another file than the link does. + const raw = yield* Effect.tryPromise(() => NodeFSP.readlink(link, { encoding: "buffer" })).pipe( + Effect.option, + ); + if (Option.isNone(raw) || !raw.value.equals(Buffer.from(text.value))) return []; // `..` can mean another folder once a link is followed, and a target naming a folder (`t3/`, // `t3/.`) never runs the file it would resolve to. Other spellings resolve on disk below. const parts = text.value.split(/[\\/]/); diff --git a/scripts/install.sh b/scripts/install.sh index 0b32bd29f2a68..546329afd10b8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -367,7 +367,8 @@ switch_command_link() { rmdir "$staging_dir" } # Checked before the home's command switches, so a bin folder that cannot be written changes nothing. -[ -w "$bin_dir" ] || fail "cannot write to ${bin_dir}" +# A link that already leads to the command link needs no write there. +if [ "$kind" != current ] && [ ! -w "$bin_dir" ]; then fail "cannot write to ${bin_dir}"; fi # The home's command switches first: if the public link then fails, the home runs the version asked # for and only the public link is missing, never a third version. switch_command_link diff --git a/scripts/install.test.ts b/scripts/install.test.ts index e19ae9e8a5798..912e3ef486d1c 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -431,9 +431,12 @@ exec ${realLn} "$@" await NodeFSP.mkdir(readOnly, { mode: 0o555 }); const refused = await installOver({ script: "install.sh", home, binDir: readOnly }); await NodeFSP.chmod(readOnly, 0o755); - expect(refused.code).not.toBe(0); - expect(refused.output).toContain("cannot write to"); - expect(await NodeFSP.readlink(commandLink)).toBe(otherVersion); + // Root can write anywhere, so a read-only folder means nothing there. + if (process.getuid?.() !== 0) { + expect(refused.code).not.toBe(0); + expect(refused.output).toContain("cannot write to"); + expect(await NodeFSP.readlink(commandLink)).toBe(otherVersion); + } expect((await NodeFSP.readdir(binDir)).filter((name) => name.startsWith(".t3."))).toEqual([]); } finally { await NodeFSP.rm(root, { recursive: true, force: true }); From 1fcc6622d630837df615b4171df65b4e783acbe3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 19:14:27 -0700 Subject: [PATCH 21/23] fix(cli): accept a concurrent install's identical link, and trust only exact link bytes Two installs moving the same older link both succeed when the other one made the same link. The command link counts only through its real folder, not a bin folder that links to it, and a launcher gone mid-update stays gone. Link text that does not read back as the same bytes is never ours, for the desktop app too, and on POSIX a version's t3 is exactly `t3`. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/app/DesktopCliCommand.ts | 6 ++-- apps/server/src/cli/install.test.ts | 14 +++++++-- apps/server/src/cli/install.ts | 23 +++++++++----- apps/server/src/cli/uninstall.ts | 6 ++-- apps/server/src/cli/update.test.ts | 11 +++++++ apps/server/src/cloud/pinnedRuntime.test.ts | 18 +++++++++-- apps/server/src/cloud/pinnedRuntime.ts | 19 +++++------- packages/shared/src/ownedLink.ts | 34 +++++++++++++++------ scripts/install.sh | 3 +- 9 files changed, 94 insertions(+), 40 deletions(-) diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 078e45b865c61..0e4a32a903d8c 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -14,7 +14,7 @@ import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import { replaceOwnedLink } from "@t3tools/shared/ownedLink"; +import { readLinkText, replaceOwnedLink } from "@t3tools/shared/ownedLink"; import { pathSetup, readUserLoginShell } from "@t3tools/shared/shell"; import * as DesktopCliShim from "./DesktopCliShim.ts"; @@ -101,7 +101,9 @@ export const make = Effect.gen(function* () { function* (link: string) { // Only a direct link: a chain through another tool's link is that tool's. Unnormalized, // so a target the OS would refuse (`t3/.`) fails here too. - const text = yield* fs.readLink(link); + const text = Option.getOrUndefined(yield* readLinkText(link)); + // Text that is not UTF-8 names another file than the one the OS follows. + if (text === undefined) return false; const target = path.isAbsolute(text) ? text : `${path.dirname(link)}${path.sep}${text}`; if (Option.isSome(yield* fs.readLink(target).pipe(Effect.option))) return false; // The launcher is a few KB; never read a large binary another `t3` links to. diff --git a/apps/server/src/cli/install.test.ts b/apps/server/src/cli/install.test.ts index 450faa93de8bb..26d880e9975ae 100644 --- a/apps/server/src/cli/install.test.ts +++ b/apps/server/src/cli/install.test.ts @@ -162,9 +162,17 @@ it.layer(NodeServices.layer)("t3 install", (it) => { : fs.symlink(target, to), }), ); - yield* fs.remove(link); - yield* provide(concurrent); - assert.equal(yield* fs.readLink(link), commandLink); + // Also while moving an older link over: the other install's identical link wins. + for (const before of [undefined, older]) { + yield* fs.remove(link).pipe(Effect.ignore); + if (before !== undefined) yield* fs.symlink(before, link); + yield* provide(concurrent); + assert.equal(yield* fs.readLink(link), commandLink, before); + assert.deepStrictEqual( + (yield* fs.readDirectory(path.dirname(link))).filter((name) => name.startsWith(".t3.")), + [], + ); + } // When the new link cannot be made, the old command keeps working. const failing = installCli().pipe( diff --git a/apps/server/src/cli/install.ts b/apps/server/src/cli/install.ts index e9cfec8db40f2..daac0acc76bad 100644 --- a/apps/server/src/cli/install.ts +++ b/apps/server/src/cli/install.ts @@ -1,6 +1,6 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { DESKTOP_CLI_LAUNCHER_MARKER } from "@t3tools/shared/desktopCliLauncher"; -import { replaceOwnedLink } from "@t3tools/shared/ownedLink"; +import { readLinkText, replaceOwnedLink } from "@t3tools/shared/ownedLink"; import { pathSetupCommand } from "@t3tools/shared/shell"; import * as Console from "effect/Console"; import * as Duration from "effect/Duration"; @@ -90,7 +90,16 @@ const classifyCommandLink = Effect.fn("cli.install.classify_command_link")(funct Option.isSome(linkFolder) && Option.getOrUndefined(yield* folderId(commandLink)) === linkFolder.value ) { - return (yield* real(link)) === realCommandLink ? "current" : "foreign"; + // Only through its real folder: a folder that is a link (a bin folder aliasing it) or a bind + // mount may be shared with other programs. + if ( + Option.isSome(yield* fs.readLink(path.dirname(link)).pipe(Effect.option)) || + (yield* real(link)) !== realCommandLink + ) { + return "foreign"; + } + const self = yield* entryAt(link); + return self === "link" ? "current" : self === "missing" ? "missing" : "foreign"; } const entry = yield* entryAt(link); if (entry !== "link") return entry === "missing" ? "missing" : "foreign"; @@ -115,7 +124,7 @@ const classifyCommandLink = Effect.fn("cli.install.classify_command_link")(funct Effect.orElseSucceed(() => false), ); // The desktop app writes this exact path, also when the home's bin folder is a link. - const text = yield* fs.readLink(link).pipe(Effect.option); + const text = yield* readLinkText(link); if ( Option.isSome(text) && path.isAbsolute(text.value) && @@ -129,8 +138,8 @@ const classifyCommandLink = Effect.fn("cli.install.classify_command_link")(funct if ( targets.some( (candidate) => - isVersionExecutable(path, versionsDir, candidate) || - isVersionExecutable(path, realVersionsDir, candidate), + isVersionExecutable(path, versionsDir, candidate, false) || + isVersionExecutable(path, realVersionsDir, candidate, false), ) ) { return "older"; @@ -269,8 +278,8 @@ export const installCli = Effect.fn("cli.install.install")(function* () { !( Option.isSome(current) && current.value !== undefined && - (isVersionExecutable(path, versionsDir, current.value) || - isVersionExecutable(path, realVersionsDir, current.value) || + (isVersionExecutable(path, versionsDir, current.value, true) || + isVersionExecutable(path, realVersionsDir, current.value, true) || // This home's desktop launcher, which install.sh also replaces. current.value === path.join(path.dirname(runtimeDir), "bin", name)) ) diff --git a/apps/server/src/cli/uninstall.ts b/apps/server/src/cli/uninstall.ts index 5b787e8d6e8ef..17dccc72f3714 100644 --- a/apps/server/src/cli/uninstall.ts +++ b/apps/server/src/cli/uninstall.ts @@ -54,7 +54,7 @@ export const findOwnedLauncher = Effect.fn("cli.uninstall.find_launcher")(functi if (shimPath === undefined) return undefined; const contents = yield* fs.readFileString(shimPath).pipe(Effect.option); const target = Option.isSome(contents) ? generatedWindowsShimTarget(contents.value) : undefined; - return target !== undefined && isVersionExecutable(path, input.versionsDir, target) + return target !== undefined && isVersionExecutable(path, input.versionsDir, target, true) ? shimPath : undefined; } @@ -85,8 +85,8 @@ const ownsLauncher = Effect.fn("cli.uninstall.owns_launcher")(function* ( return targets.some( (target) => target !== undefined && - (isVersionExecutable(path, versionsDir, target) || - isVersionExecutable(path, realVersionsDir, target)), + (isVersionExecutable(path, versionsDir, target, platform === "win32") || + isVersionExecutable(path, realVersionsDir, target, platform === "win32")), ); }); diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index cdd1e1657613c..79cfea2f594bc 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -83,6 +83,17 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { written.filter((file) => file.startsWith(path.dirname(launcher))), [], ); + // A bin folder that is a link to the command link's folder may be shared, so update leaves + // it alone. + const aliasBin = path.join(root, "alias-bin"); + yield* fs.symlink(path.dirname(commandLink), aliasBin); + const viaAlias = yield* repointLauncher({ + launchedAs: path.join(aliasBin, "t3"), + versionsDir: path.join(root, "runtime/versions"), + targetEntryPath: newExe, + }); + assert.isTrue(Option.isNone(viaAlias)); + assert.equal(yield* fs.readLink(commandLink), newerExe); // A launcher removed while update ran stays removed. let removed = false; const vanishing = FileSystem.FileSystem.of({ diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index 8c65dd4a7a3cb..437b85f6e219d 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -310,11 +310,23 @@ 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.isTrue( + isVersionExecutable(win32, "C:\\t3\\versions", "C:\\t3\\versions\\1.0.0\\t3.exe", true), + ); + assert.isFalse(isVersionExecutable(win32, "C:\\t3\\versions", "D:\\t3.exe", true)); assert.isFalse( - isVersionExecutable(win32, "C:\\t3\\versions", "C:\\t3\\versions\\1.0.0\\node.exe"), + isVersionExecutable(win32, "C:\\t3\\versions", "C:\\t3\\versions\\1.0.0\\node.exe", true), ); + // On POSIX the name is exactly `t3`, and `\\` is part of a name, not a separator. + const posix = NodePath.posix as unknown as Path.Path; + for (const [candidate, ours] of [ + ["/t3/versions/1.0.0/t3", true], + ["/t3/versions/1.0.0/t3.exe", false], + ["/t3/versions/1.0.0/T3", false], + ["/t3/versions/manager\\t3", false], + ] as const) { + assert.equal(isVersionExecutable(posix, "/t3/versions", candidate, false), ours, candidate); + } }); it.layer(NodeServices.layer)("prunePinnedRuntimes", (it) => { diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 5be54cd4fb661..4a4ba242e15f5 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -25,6 +25,7 @@ import { } from "@t3tools/shared/cliRelease"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { readLinkText } from "@t3tools/shared/ownedLink"; import * as ProcessRunner from "../processRunner.ts"; import { isExactServiceVersion, parseServiceState, SERVICE_STATE_FILE } from "./serviceProtocol.ts"; @@ -411,13 +412,8 @@ export const directLinkTargets = Effect.fn("cloud.pinned_runtime.direct_link_tar ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const text = yield* fs.readLink(link).pipe(Effect.option); + const text = yield* readLinkText(link); if (Option.isNone(text)) return []; - // The text is decoded as UTF-8; bytes that are not would name another file than the link does. - const raw = yield* Effect.tryPromise(() => NodeFSP.readlink(link, { encoding: "buffer" })).pipe( - Effect.option, - ); - if (Option.isNone(raw) || !raw.value.equals(Buffer.from(text.value))) return []; // `..` can mean another folder once a link is followed, and a target naming a folder (`t3/`, // `t3/.`) never runs the file it would resolve to. Other spellings resolve on disk below. const parts = text.value.split(/[\\/]/); @@ -453,14 +449,15 @@ export function isVersionExecutable( path: Path.Path, versionsDir: string, candidate: string, + windows: boolean, ): boolean { const relative = path.relative(versionsDir, path.resolve(candidate)); - // On another Windows drive, relative() returns the absolute path, like `D:\t3.exe`. On POSIX, - // `\` and case are part of a name, so only `/` separates and `t3` must match exactly. + // On another Windows drive, relative() returns the absolute path, like `D:\t3.exe`. Elsewhere + // `\` and case are part of a name, so only `/` separates and the name is exactly `t3`. if (path.isAbsolute(relative)) return false; - return path.sep === "/" - ? /^(?!\.\.)[^/\\]+\/t3(?:\.exe)?$/.test(relative) - : /^(?!\.\.)[^\\/]+[\\/]t3(?:\.exe)?$/i.test(relative); + return windows + ? /^(?!\.\.)[^\\/]+[\\/]t3(?:\.exe)?$/i.test(relative) + : /^(?!\.\.)[^/\\]+\/t3$/.test(relative); } /** The executable a `t3.cmd` shim written by the install script or `t3 update` runs. */ diff --git a/packages/shared/src/ownedLink.ts b/packages/shared/src/ownedLink.ts index 59582fd5969a3..6ebf972f78195 100644 --- a/packages/shared/src/ownedLink.ts +++ b/packages/shared/src/ownedLink.ts @@ -8,6 +8,19 @@ import * as Path from "effect/Path"; import * as Random from "effect/Random"; import * as Schema from "effect/Schema"; +/** + * The text of the link at `file`, only when it reads back as the same bytes. Text that is not + * UTF-8 decodes to another name than the one the OS follows, so it never counts as anyone's. + */ +export const readLinkText = Effect.fn("ownedLink.read_link_text")(function* (file: string) { + const raw = yield* Effect.tryPromise(() => NodeFSP.readlink(file, { encoding: "buffer" })).pipe( + Effect.option, + ); + if (Option.isNone(raw)) return Option.none(); + const text = raw.value.toString("utf8"); + return Buffer.from(text).equals(raw.value) ? Option.some(text) : Option.none(); +}); + /** An entry moved aside could not go back; it is still at `path`. */ export class OwnedLinkError extends Schema.TaggedError()("OwnedLinkError", { path: Schema.String, @@ -74,16 +87,17 @@ export const replaceOwnedLink = Effect.fn("ownedLink.replace")(function* ( if (input.target !== undefined) { // On failure the old link goes back while the name is free, so the command keeps working; // if it cannot, it stays beside `link`. - yield* fs - .symlink(input.target, input.link) - .pipe( - Effect.catch((error) => - putBack.pipe( - Effect.andThen(discard.pipe(Effect.ignore)), - Effect.andThen(Effect.fail(error)), - ), - ), - ); + yield* fs.symlink(input.target, input.link).pipe( + Effect.catch((error) => + Effect.gen(function* () { + // Another install made the same link a moment ago: that is done, not failed. + const now = yield* fs.readLink(input.link).pipe(Effect.option); + if (Option.getOrUndefined(now) === input.target) return; + yield* putBack.pipe(Effect.andThen(discard.pipe(Effect.ignore))); + return yield* Effect.fail(error); + }), + ), + ); } yield* discard.pipe(Effect.ignore); return true; diff --git a/scripts/install.sh b/scripts/install.sh index 546329afd10b8..c8cc1edc03828 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -391,7 +391,8 @@ case "$kind" in trap '' INT TERM HUP mv "$public" "$aside" || { rmdir "$aside_dir"; fail "could not replace ${public}"; } if [ -n "$(link_kind "$aside")" ]; then - if ! ln -s "$command_link" "${bin_dir}/"; then + # Another install made the same link a moment ago: that is done, not failed. + if ! ln -s "$command_link" "${bin_dir}/" && [ "$(link_kind "$public")" != current ]; then # Keep the working old command when the name is still free; if it cannot go back, it stays. put_back || fail "could not link ${public}; the old link is at ${aside}." From 30e12f46bc7b8823e7533e991632576f331ec47d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 19:15:32 -0700 Subject: [PATCH 22/23] fix(shared): yield the error directly Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/ownedLink.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/ownedLink.ts b/packages/shared/src/ownedLink.ts index 6ebf972f78195..a0f9b3372bbea 100644 --- a/packages/shared/src/ownedLink.ts +++ b/packages/shared/src/ownedLink.ts @@ -94,7 +94,7 @@ export const replaceOwnedLink = Effect.fn("ownedLink.replace")(function* ( const now = yield* fs.readLink(input.link).pipe(Effect.option); if (Option.getOrUndefined(now) === input.target) return; yield* putBack.pipe(Effect.andThen(discard.pipe(Effect.ignore))); - return yield* Effect.fail(error); + return yield* error; }), ), ); From e0275b78dfd7696c579f53267c0b9c4001ba3001 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 21:08:16 -0700 Subject: [PATCH 23/23] fix(cli): nothing in the command link's folder becomes a link to it In versions/current, `t3` in any case is the command link itself, as on a case-insensitive disk, and any other name there is refused, so an update run as current/T3 only switches the command link. A concurrent install's identical link is recognized by its exact bytes. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/install.ts | 10 ++++++---- apps/server/src/cli/update.test.ts | 28 ++++++++++++++++++++++++++++ packages/shared/src/ownedLink.ts | 2 +- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/apps/server/src/cli/install.ts b/apps/server/src/cli/install.ts index daac0acc76bad..59bd3e510aacd 100644 --- a/apps/server/src/cli/install.ts +++ b/apps/server/src/cli/install.ts @@ -86,15 +86,17 @@ const classifyCommandLink = Effect.fn("cli.install.classify_command_link")(funct ); const linkFolder = yield* folderId(link); if ( - path.basename(link) === path.basename(commandLink) && Option.isSome(linkFolder) && Option.getOrUndefined(yield* folderId(commandLink)) === linkFolder.value ) { - // Only through its real folder: a folder that is a link (a bin folder aliasing it) or a bind - // mount may be shared with other programs. + // In the command link's own folder, `t3` in any case is the command link itself (one entry on + // a case-insensitive disk), and nothing else there is a launcher, so nothing there can be made + // a link to itself. Only through its real folder: a folder that is a link (a bin folder + // aliasing it) or a bind mount may be shared with other programs. if ( + path.basename(link).toLowerCase() !== path.basename(commandLink) || Option.isSome(yield* fs.readLink(path.dirname(link)).pipe(Effect.option)) || - (yield* real(link)) !== realCommandLink + (yield* real(path.dirname(link))) !== (yield* real(path.dirname(commandLink))) ) { return "foreign"; } diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index 79cfea2f594bc..d812ca66af408 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -94,6 +94,34 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { }); assert.isTrue(Option.isNone(viaAlias)); assert.equal(yield* fs.readLink(commandLink), newerExe); + // In the command link's own folder, `T3` is the command link on a case-insensitive disk, so + // it only switches that link, never becomes a link to it; any other name there is refused. + const upper = path.join(path.dirname(commandLink), "T3"); + const other = path.join(path.dirname(commandLink), "other"); + yield* fs.symlink(oldExe, upper); + yield* fs.symlink(oldExe, other); + const viaUpper = yield* repointLauncher({ + launchedAs: upper, + versionsDir: path.join(root, "runtime/versions"), + targetEntryPath: newExe, + }); + assert.equal(Option.getOrUndefined(viaUpper), upper); + assert.equal(yield* fs.readLink(commandLink), newExe); + assert.equal(yield* fs.readLink(upper), oldExe); + const viaOther = yield* repointLauncher({ + launchedAs: other, + versionsDir: path.join(root, "runtime/versions"), + targetEntryPath: newerExe, + }); + assert.isTrue(Option.isNone(viaOther)); + assert.equal(yield* fs.readLink(commandLink), newExe); + yield* fs.remove(upper); + yield* fs.remove(other); + yield* repointLauncher({ + launchedAs: launcher, + versionsDir: path.join(root, "runtime/versions"), + targetEntryPath: newerExe, + }); // A launcher removed while update ran stays removed. let removed = false; const vanishing = FileSystem.FileSystem.of({ diff --git a/packages/shared/src/ownedLink.ts b/packages/shared/src/ownedLink.ts index a0f9b3372bbea..da915c4861142 100644 --- a/packages/shared/src/ownedLink.ts +++ b/packages/shared/src/ownedLink.ts @@ -91,7 +91,7 @@ export const replaceOwnedLink = Effect.fn("ownedLink.replace")(function* ( Effect.catch((error) => Effect.gen(function* () { // Another install made the same link a moment ago: that is done, not failed. - const now = yield* fs.readLink(input.link).pipe(Effect.option); + const now = yield* readLinkText(input.link); if (Option.getOrUndefined(now) === input.target) return; yield* putBack.pipe(Effect.andThen(discard.pipe(Effect.ignore))); return yield* error;