diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index 3c965e04663b8..95427cd1ad139 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -178,6 +178,21 @@ 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); + // 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"); @@ -242,6 +257,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..0e4a32a903d8c 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -7,13 +7,14 @@ 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 Path from "effect/Path"; 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 { readLinkText, replaceOwnedLink } from "@t3tools/shared/ownedLink"; import { pathSetup, readUserLoginShell } from "@t3tools/shared/shell"; import * as DesktopCliShim from "./DesktopCliShim.ts"; @@ -98,16 +99,29 @@ 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. Unnormalized, + // so a target the OS would refuse (`t3/.`) fails here too. + 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. - 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), ); + /** 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), + Effect.provideService(Path.Path, path), + ); + // 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 +340,23 @@ 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) + ? replaceOurLink({ link, target: launcher }) + : fs.symlink(launcher, link).pipe(Effect.as(true)) ).pipe( - Effect.as(true), - Effect.orElseSucceed(() => false), + // 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* fs.remove(replaced).pipe(Effect.ignore); + yield* replaceOurLink({ link: replaced }).pipe( + Effect.catchTags({ + OwnedLinkError: (error) => Effect.fail(fail(error.message)), + PlatformError: () => Effect.void, + }), + ); } return yield* state; } @@ -367,7 +378,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((error) => fail(`Could not remove ${link}: ${error.message}`)), + ); } } return yield* state; 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 78192b21b87f3..26d880e9975ae 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"; @@ -9,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; @@ -54,13 +59,29 @@ 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, "versions/current/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"); + // 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); @@ -92,35 +113,240 @@ 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"); - // This home's desktop launcher is replaced, as install.sh does. + // Only a link is ever replaced at the command link, even inside the T3 home. yield* fs.remove(link); - yield* fs.symlink(path.join(root, ".t3/bin/t3"), link); - yield* provide(installCli()); - assert.equal(yield* fs.readLink(link), executable); + yield* fs.symlink(older, link); + yield* fs.remove(commandLink); + yield* fs.writeFileString(commandLink, "mine\n"); + 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 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"); + + // 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); + // 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. + 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), + }), + ); + // 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( + 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* fs.remove(link); + yield* fs.symlink(older, link); + 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 failed swap over our own link leaves a staging path it did not create alone. + // 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); - 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. + // 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); + // 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`, + ]) { + yield* fs.remove(link); + yield* fs.symlink(spelled, link); + 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, also when its bin folder + // is a link. + // 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")); + // 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], + [marked, 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, + ); + } + // 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")); 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 + // 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, path) => - fs - .writeFileString(path, "#!/bin/sh\necho raced\n") - .pipe(Effect.andThen(fs.symlink(target, path))), + 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))), + }), + ); + // 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, + 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.writeFileString(file, data, options))), }), ); - assert.include((yield* provide(racing).pipe(Effect.flip)).message, "Could not link"); - assert.equal(yield* fs.readFileString(link), "#!/bin/sh\necho raced\n"); + yield* fs.remove(link); + 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, + ); + + 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); + // 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); + } yield* fs.remove(link); // Windows writes a t3.cmd: only over its own, and only for paths cmd can run. @@ -231,7 +457,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..59bd3e510aacd 100644 --- a/apps/server/src/cli/install.ts +++ b/apps/server/src/cli/install.ts @@ -1,4 +1,6 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; +import { DESKTOP_CLI_LAUNCHER_MARKER } from "@t3tools/shared/desktopCliLauncher"; +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"; @@ -6,13 +8,21 @@ 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"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import packageJson from "../../package.json" with { type: "json" }; -import { generatedWindowsShimTarget, isVersionExecutable } from "../cloud/pinnedRuntime.ts"; +import { + directLinkTargets, + generatedWindowsShimTarget, + isVersionExecutable, + 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", { @@ -37,7 +47,190 @@ 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. */ +/** + * 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. + */ +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); + // 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}`)), + Effect.orElseSucceed(() => Option.none()), + ); + const linkFolder = yield* folderId(link); + if ( + Option.isSome(linkFolder) && + Option.getOrUndefined(yield* folderId(commandLink)) === linkFolder.value + ) { + // 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(path.dirname(link))) !== (yield* real(path.dirname(commandLink))) + ) { + 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"; + // Only the direct target: a chain through another tool's link is that tool's. + const targets = yield* directLinkTargets(link); + 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); + // 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); + }, + Effect.orElseSucceed(() => false), + ); + // The desktop app writes this exact path, also when the home's bin folder is a link. + const text = yield* readLinkText(link); + if ( + Option.isSome(text) && + path.isAbsolute(text.value) && + !text.value.split(/[\\/]/).includes("..") && + !/[\\/]\.?$/.test(text.value) && + path.normalize(text.value) === desktopLauncher && + (yield* isDesktopLauncher(desktopLauncher)) + ) { + return "older"; + } + if ( + targets.some( + (candidate) => + isVersionExecutable(path, versionsDir, candidate, false) || + isVersionExecutable(path, realVersionsDir, candidate, false), + ) + ) { + return "older"; + } + for (const candidate of targets) { + if ( + (candidate === desktopLauncher || candidate === realDesktopLauncher) && + (yield* isDesktopLauncher(candidate)) + ) { + return "older"; + } + } + return "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; + /** 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" || (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. + 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 ((yield* entryAt(commandLink)) === "other") { + return yield* new CliInstallError({ + reason: `${commandLink} is not a link. Remove it, then run this again.`, + }); + } + // Created exclusively and removed only once it is this call's own. + 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))); + }); + // 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 (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 +262,88 @@ 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, 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)) + ) + ) { + 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}.`))); + // 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. + // 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}`)), + 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, executable: spelled }).pipe( + 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) { + 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..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"; @@ -32,6 +35,78 @@ 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(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( + 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"); + 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 })); + 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"); + 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 })); + // 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 })); + // 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"); + 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"); + 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 })); @@ -61,13 +136,38 @@ 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"); + 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")), + [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..17dccc72f3714 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"; @@ -13,6 +14,7 @@ import { Command, Flag, GlobalFlag, Prompt } from "effect/cli"; import * as BootService from "../cloud/bootService.ts"; import { + directLinkTargets, generatedWindowsShimTarget, isVersionExecutable, pinnedRuntimeVersionsDir, @@ -52,17 +54,14 @@ 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; } - 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` 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, @@ -73,8 +72,7 @@ const ownsLauncher = Effect.fn("cli.uninstall.owns_launcher")(function* ( const realVersionsDir = yield* fs .realPath(versionsDir) .pipe(Effect.orElseSucceed(() => versionsDir)); - // The link's own text keeps a pruned (dangling) target; the real path sees through a - // symlinked T3 home. + // Only a direct link counts: a chain through another tool's link is theirs. const targets = platform === "win32" ? [ @@ -83,18 +81,12 @@ 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)), - ]; + : yield* directLinkTargets(launcher); 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")), ); }); @@ -246,17 +238,26 @@ 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))) { + 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* ( + platform === "win32" + ? fs.remove(launcher, { force: true }).pipe(Effect.as(true)) + : replaceOwnedLink({ link: launcher, owns }) + ).pipe( + Effect.mapError( + (error) => + new CliUninstallError({ + reason: `Could not remove the launcher at ${launcher}: ${error.message}`, + }), + ), + )); + if (!removed) { yield* Console.log(`Left ${launcher}: it no longer runs this t3.`); continue; } - yield* fs - .remove(launcher, { force: true }) - .pipe( - Effect.mapError( - () => new CliUninstallError({ reason: `Could not remove the launcher at ${launcher}.` }), - ), - ); 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..d812ca66af408 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -36,29 +36,113 @@ 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/versions/current/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, + assert.deepStrictEqual(Option.getOrUndefined(yield* update(newExe)), launcher); + assert.equal(yield* fs.readLink(launcher), commandLink); + assert.equal(yield* fs.readLink(commandLink), 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))), + [], + ); + // 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.deepStrictEqual(Option.getOrUndefined(repointed), launcher); - assert.equal(yield* fs.readLink(launcher), newExe); + 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({ + ...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")), ); @@ -81,8 +165,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"), @@ -92,6 +183,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/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index 08d77d309b3c6..bfc7dcb3d8b21 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,24 @@ 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({ + create: false, + 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}.` }), + (error) => + new CliUpdateError({ + reason: `Could not repoint the t3 launcher at ${input.launchedAs}: ${error.message}`, + }), ), ); - 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..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) => { @@ -388,6 +400,12 @@ 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.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`, @@ -414,10 +432,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 +446,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", @@ -435,6 +454,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 8ed3b9008e591..4a4ba242e15f5 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"; @@ -7,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"; @@ -21,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"; @@ -49,6 +54,16 @@ export function pinnedRuntimeVersionsDir(path: Path.Path, baseDir: string): stri return path.join(baseDir, PINNED_RUNTIME_DIR, "versions"); } +/** + * `/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(pinnedRuntimeVersionsDir(path, baseDir), "current", "t3"); +} + export function pinnedRuntimePaths( path: Path.Path, baseDir: string, @@ -386,15 +401,63 @@ export function launcherOwnsVersionsDir( return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative); } +/** + * 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 with `..` or naming a folder. + */ +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* readLinkText(link); + if (Option.isNone(text)) 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(/[\\/]/); + if (parts.includes("..") || [".", ""].includes(parts.at(-1)!)) return []; + // 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({ + try: () => NodeFSP.realpath(folder), + catch: (error) => (error as NodeJS.ErrnoException).code, + }).pipe( + Effect.map((directory) => path.join(directory, path.basename(target))), + Effect.result, + ); + if (Result.isSuccess(physical)) return [physical.success]; + 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]`. */ 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`. - return !path.isAbsolute(relative) && /^(?!\.\.)[^\\/]+[\\/]t3(?:\.exe)?$/i.test(relative); + // 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 windows + ? /^(?!\.\.)[^\\/]+[\\/]t3(?:\.exe)?$/i.test(relative) + : /^(?!\.\.)[^/\\]+\/t3$/.test(relative); } /** The executable a `t3.cmd` shim written by the install script or `t3 update` runs. */ @@ -430,6 +493,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..9465461a10115 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -499,6 +499,14 @@ "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" + }, "./mcpResponsePayloads": { "types": "./src/mcpResponsePayloads.ts", "import": "./src/mcpResponsePayloads.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/packages/shared/src/ownedLink.ts b/packages/shared/src/ownedLink.ts new file mode 100644 index 0000000000000..da915c4861142 --- /dev/null +++ b/packages/shared/src/ownedLink.ts @@ -0,0 +1,108 @@ +// @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"; + +/** + * 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, +}) { + 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 + * 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 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 path = yield* Path.Path; + // 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); + // Not interrupted between moving the entry aside and putting a link back, so a cancelled + // 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.catch((error) => + Effect.gen(function* () { + // Another install made the same link a moment ago: that is done, not failed. + 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; + }), + ), + ); + } + yield* discard.pipe(Effect.ignore); + return true; + }), + ), + () => Effect.sync(() => process.off("SIGHUP", ignoreHangup)), + ); +}); diff --git a/scripts/install.sh b/scripts/install.sh index e4b21de8c3e14..c8cc1edc03828 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/versions/current/t3, which +# points at that version, so upgrades never replace a file in the bin folder. set -eu repo="pingdotgg/t3code" @@ -189,6 +191,30 @@ case "$version" in ;; 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() ( + # 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 + for part in $rest; do + 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. +# 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 +# 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" @@ -235,47 +261,159 @@ 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. +# 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 +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 } -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 +# Its bin folder may be a link, or missing until the app runs. +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. + [ ! -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 +# or the desktop app's launcher), or nothing. Anything that is not a symlink is someone else's. +link_kind() { + [ -L "$1" ] || return 0 + # 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"}" + # `..` 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 + # 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")" + # 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="${existing%/*}" + [ -n "$folder" ] || folder=/ + [ ! -L "$folder" ] || return 0 + # Resolved on disk, which sees through a symlinked T3 home. + 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 + echo older fi + return 0 fi - "$owned" || fail "another t3 is already at ${bin_dir}/t3. Remove it and run this again, or run ${target_dir}/t3 directly." + # 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="${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="${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 + fail "T3CODE_INSTALL_BIN_DIR cannot be ${current_dir}. Choose another folder." 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" +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, 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" +} +# Checked before the home's command switches, so a bin folder that cannot be written changes nothing. +# 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 +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. + # 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 + # 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. + trap '' INT TERM HUP + mv "$public" "$aside" || { rmdir "$aside_dir"; fail "could not replace ${public}"; } + if [ -n "$(link_kind "$aside")" ]; 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}." + rm -f "$aside" && rmdir "$aside_dir" + 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. + 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." + 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..912e3ef486d1c 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); @@ -165,13 +165,86 @@ 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 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(root, "alias/runtime/versions/1.2.3/t3"), ); expect(upgrade.output).toContain( `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")); + + // 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/latest"), + ); + 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 }); + 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/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/versions/current/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 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).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({ script: "install.sh", @@ -192,16 +265,179 @@ 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); - await NodeFSP.symlink(NodePath.join(home, "bin/t3"), link); - const overDesktop = await installOver({ + const relativeHome = await installOver({ + script: "install.sh", + home: NodePath.relative(root, home), + binDir, + 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({ script: "install.sh", - home: `${home}//`, - binDir: `${binDir}/`, + 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")); + const physicalHome = NodePath.join(root, "a/home"); + await NodeFSP.mkdir(NodePath.join(physicalHome, "runtime/versions/1.2.3"), { + recursive: true, }); - expect(overDesktop.code, overDesktop.output).toBe(0); - expect(await NodeFSP.readlink(link)).toBe(NodePath.join(home, "runtime/versions/1.2.3/t3")); + 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")); + + // 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"), + ); + } + // 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 }); + } + }); + + 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/versions/current/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 +# 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 }, + ); + 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"); + + // 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); + + // 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); + // 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 }); } @@ -288,6 +524,7 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("installer chec "mkdir", "mktemp", "rm", + "rmdir", "mv", "ln", checksumTool,