镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9a64258
fix(cli): upgrades never replace the public t3 link
t3dotgg Oct 31, 2026
d2568eb
fix(cli): reserve the aside name and claim only direct links
t3dotgg Oct 31, 2026
681e66e
fix(cli): keep the aside entry beside the link and skip dot targets
t3dotgg Oct 31, 2026
cff945d
fix(cli): resolve link target folders with native realpath
t3dotgg Oct 31, 2026
87b97d1
fix(cli): a link naming a folder is never ours
t3dotgg Oct 31, 2026
82c5242
fix(desktop): claim only a direct link to the launcher
t3dotgg Oct 31, 2026
1a1d907
fix(cli): trust a link's text only when its folder is gone
t3dotgg Nov 1, 2026
ceca60f
fix(cli): close the remaining paths that could replace or lose a t3
t3dotgg Nov 1, 2026
cfbc6f2
fix(cli): never follow a linked version folder, and keep what cannot …
t3dotgg Nov 1, 2026
0e2a1a0
fix(cli): keep the command link where released t3 can update it
t3dotgg Nov 1, 2026
5ebe759
fix(cli): write the T3 home as t3 spells it, and accept released spel…
t3dotgg Nov 1, 2026
c443e06
fix(cli): match t3's own T3 home spelling everywhere, and never leave…
t3dotgg Nov 1, 2026
9635a0f
fix(cli): read a T3 home and link the way the OS and CLI do
t3dotgg Nov 1, 2026
fbfe5a4
fix(cli): claim the desktop launcher only by its marker
t3dotgg Nov 1, 2026
8c90f4f
fix(cli): a linked desktop launcher name is not the desktop app's
t3dotgg Nov 1, 2026
65680f2
fix(cli): the command link's staging name never removes an existing file
t3dotgg Nov 1, 2026
95b9968
fix(cli): stage the command link in a folder of its own, and allow ne…
t3dotgg Nov 1, 2026
1e88385
fix(cli): switch the command link only after the public link is in place
t3dotgg Nov 1, 2026
4902fdc
fix(cli): switch the home's command first, after checking the bin folder
t3dotgg Nov 1, 2026
283a904
fix(cli): check the bin folder only when it needs a write, and read r…
t3dotgg Nov 1, 2026
1fcc662
fix(cli): accept a concurrent install's identical link, and trust onl…
t3dotgg Nov 1, 2026
30e12f4
fix(shared): yield the error directly
t3dotgg Nov 1, 2026
e0275b7
fix(cli): nothing in the command link's folder becomes a link to it
t3dotgg Nov 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions apps/desktop/src/app/DesktopCliCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand Down
51 changes: 32 additions & 19 deletions apps/desktop/src/app/DesktopCliCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/app/DesktopCliShim.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand Down
Loading
Loading