authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 16:39:48-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 16:39:48-07:00
log3bfbc37dbf37095816eda500f3f1e05cb98bb95b
treec636ab62532b8977d6098feb805a32bc18445c54
parent66298aa926e6a4c95ebecbc576a68634315899c0
signature Signed by PGP key B5690EEEBB952194

fix(desktop): the t3 command warns instead of installing behind another t3 (#17351)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

4 files changed, 57 insertions(+), 15 deletions(-)

apps/desktop/src/app/DesktopCliCommand.test.ts+24-8
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";2import { afterEach, beforeEach, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";4import * as FileSystem from "effect/FileSystem";
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
...@@ -85,6 +85,15 @@ const fakePowerShell = (initial: string) => {...@@ -85,6 +85,15 @@ const fakePowerShell = (initial: string) => {
85 return { registry, spawner };85 return { registry, spawner };
86};86};
8787
88// The machine's own PATH may already hold a t3; each test sets the PATH it means.
89const machinePath = process.env.PATH;
90beforeEach(() => {
91 process.env.PATH = "";
92});
93afterEach(() => {
94 process.env.PATH = machinePath;
95});
96
88it.layer(NodeServices.layer)("DesktopCliCommand", (it) => {97it.layer(NodeServices.layer)("DesktopCliCommand", (it) => {
89 it.effect("links the launcher onto PATH and removes only that link", () =>98 it.effect("links the launcher onto PATH and removes only that link", () =>
90 Effect.gen(function* () {99 Effect.gen(function* () {
...@@ -165,7 +174,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => {...@@ -165,7 +174,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => {
165 }).pipe(Effect.scoped),174 }).pipe(Effect.scoped),
166 );175 );
167176
168 it.effect("reports when another t3 earlier on PATH would run instead", () =>177 it.effect("refuses to install behind another t3 that runs first", () =>
169 Effect.gen(function* () {178 Effect.gen(function* () {
170 const fs = yield* FileSystem.FileSystem;179 const fs = yield* FileSystem.FileSystem;
171 const path = yield* Path.Path;180 const path = yield* Path.Path;
...@@ -173,16 +182,23 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => {...@@ -173,16 +182,23 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => {
173 const shadow = path.join(home, "shadow");182 const shadow = path.join(home, "shadow");
174 yield* fs.makeDirectory(shadow);183 yield* fs.makeDirectory(shadow);
175 yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 });184 yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 });
176 const previous = process.env.PATH;
177 process.env.PATH = [shadow, path.join(home, ".local", "bin")].join(":");185 process.env.PATH = [shadow, path.join(home, ".local", "bin")].join(":");
178 yield* Effect.addFinalizer(() => Effect.sync(() => (process.env.PATH = previous)));
179186
180 const command = yield* commandIn({ home });187 const command = yield* commandIn({ home });
188 const theirs = path.join(shadow, "t3");
189 expect((yield* command.state).shadowedBy).toBe(theirs);
190 // A link behind it would never run, so nothing is created.
191 const error = yield* Effect.flip(command.install);
192 expect(error.message).toContain(theirs);
193 expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false);
194
195 yield* fs.remove(theirs);
181 const installed = yield* command.install;196 const installed = yield* command.install;
182 expect(installed.installedPath).toBe(path.join(home, ".local", "bin", "t3"));197 expect(installed).toMatchObject({
183 expect(installed.onPath).toBe(false);198 onPath: true,
184 yield* fs.remove(path.join(shadow, "t3"));199 installedPath: path.join(home, ".local", "bin", "t3"),
185 expect((yield* command.state).onPath).toBe(true);200 });
201 expect(installed.shadowedBy).toBeUndefined();
186 }).pipe(Effect.scoped),202 }).pipe(Effect.scoped),
187 );203 );
188204
apps/desktop/src/app/DesktopCliCommand.ts+21-2
...@@ -146,6 +146,14 @@ export const make = Effect.gen(function* () {...@@ -146,6 +146,14 @@ export const make = Effect.gen(function* () {
146 return Option.none<string>();146 return Option.none<string>();
147 });147 });
148148
149 /** The `t3` a new shell runs when it is not this app's, on Unix. */
150 const foreignFirstOnPath = Effect.gen(function* () {
151 if (windows) return Option.none<string>();
152 const first = yield* firstOnPath;
153 if (Option.isNone(first) || (yield* isOurLink(first.value))) return Option.none<string>();
154 return first;
155 });
156
149 /** Where this app's command is installed now, if anywhere. */157 /** Where this app's command is installed now, if anywhere. */
150 const installedAt = Effect.gen(function* () {158 const installedAt = Effect.gen(function* () {
151 if (windows) {159 if (windows) {
...@@ -167,12 +175,16 @@ export const make = Effect.gen(function* () {...@@ -167,12 +175,16 @@ export const make = Effect.gen(function* () {
167 return { supported: false, installedPath: null, onPath: false } as const;175 return { supported: false, installedPath: null, onPath: false } as const;
168 }176 }
169 const installed = yield* installedAt;177 const installed = yield* installedAt;
170 if (Option.isNone(installed)) return { supported: true, installedPath: null, onPath: false };178 const shadowedBy = yield* foreignFirstOnPath;
179 const shadow = Option.isSome(shadowedBy) ? { shadowedBy: shadowedBy.value } : {};
180 if (Option.isNone(installed)) {
181 return { supported: true, installedPath: null, onPath: false, ...shadow };
182 }
171 // On Windows only terminals opened after the change see it. On Unix the183 // On Windows only terminals opened after the change see it. On Unix the
172 // first `t3` on PATH must be ours; a `t3` earlier on PATH would shadow it.184 // first `t3` on PATH must be ours; a `t3` earlier on PATH would shadow it.
173 const first = yield* firstOnPath;185 const first = yield* firstOnPath;
174 const onPath = windows || (Option.isSome(first) && (yield* isOurLink(first.value)));186 const onPath = windows || (Option.isSome(first) && (yield* isOurLink(first.value)));
175 return { supported: true, installedPath: installed.value, onPath };187 return { supported: true, installedPath: installed.value, onPath, ...shadow };
176 }).pipe(Effect.orElseSucceed(() => ({ supported: false, installedPath: null, onPath: false })));188 }).pipe(Effect.orElseSucceed(() => ({ supported: false, installedPath: null, onPath: false })));
177189
178 /** Writes the launcher if the app has not yet, e.g. when no local backend runs. */190 /** Writes the launcher if the app has not yet, e.g. when no local backend runs. */
...@@ -209,6 +221,13 @@ export const make = Effect.gen(function* () {...@@ -209,6 +221,13 @@ export const make = Effect.gen(function* () {
209 .remove(existing.value)221 .remove(existing.value)
210 .pipe(Effect.mapError(() => fail(`Could not replace ${existing.value}.`)));222 .pipe(Effect.mapError(() => fail(`Could not replace ${existing.value}.`)));
211 }223 }
224 // A link behind another `t3` never runs, so installing one would only hide the problem.
225 const shadowedBy = yield* foreignFirstOnPath;
226 if (Option.isSome(shadowedBy)) {
227 return yield* fail(
228 `Another t3 at ${shadowedBy.value} runs first in a new terminal. Remove it, or run the launcher directly at ${launcher}.`,
229 );
230 }
212 const onPath = pathEntries(process.env.PATH, ":");231 const onPath = pathEntries(process.env.PATH, ":");
213 const candidates = unixCandidates(environment.homeDirectory, environment.platform);232 const candidates = unixCandidates(environment.homeDirectory, environment.platform);
214 // Prefer a folder already on PATH that the user can write to without admin rights.233 // Prefer a folder already on PATH that the user can write to without admin rights.
apps/web/src/components/settings/CliCommandSettingsRow.tsx+7-5
...@@ -51,11 +51,13 @@ export function CliCommandSettingsRow() {...@@ -51,11 +51,13 @@ export function CliCommandSettingsRow() {
5151
52 if (!bridge || !state?.supported) return null;52 if (!bridge || !state?.supported) return null;
53 const installed = state.installedPath !== null;53 const installed = state.installedPath !== null;
54 const description = !installed54 const description = state.shadowedBy
55 ? "Run T3 Code's CLI as `t3` from any terminal."55 ? `Another t3 at ${state.shadowedBy} runs first in a new terminal. Remove it to use T3 Code's.`
56 : state.onPath56 : !installed
57 ? `Installed at ${state.installedPath}. Open a new terminal to use it.`57 ? "Run T3 Code's CLI as `t3` from any terminal."
58 : `Installed at ${state.installedPath}, which is not on your PATH yet. Add its folder to your PATH to run \`t3\`.`;58 : state.onPath
59 ? `Installed at ${state.installedPath}. Open a new terminal to use it.`
60 : `Installed at ${state.installedPath}, which is not on your PATH yet. Add its folder to your PATH to run \`t3\`.`;
5961
60 return (62 return (
61 <SettingsRow63 <SettingsRow
packages/contracts/src/ipc.ts+5
...@@ -325,6 +325,11 @@ export const DesktopCliCommandStateSchema = Schema.Struct({...@@ -325,6 +325,11 @@ export const DesktopCliCommandStateSchema = Schema.Struct({
325 installedPath: Schema.NullOr(Schema.String),325 installedPath: Schema.NullOr(Schema.String),
326 /** Whether a new terminal finds it; false when the folder is not on PATH yet. */326 /** Whether a new terminal finds it; false when the folder is not on PATH yet. */
327 onPath: Schema.Boolean,327 onPath: Schema.Boolean,
328 /**
329 * Another `t3` a new terminal runs instead, earlier on PATH. Install refuses
330 * while it is there, since a link behind it would never run.
331 */
332 shadowedBy: Schema.optionalKey(Schema.String),
328});333});
329export type DesktopCliCommandState = typeof DesktopCliCommandStateSchema.Type;334export type DesktopCliCommandState = typeof DesktopCliCommandStateSchema.Type;
330335