| author | |
| committer | |
| log | 3bfbc37dbf37095816eda500f3f1e05cb98bb95b |
| tree | c636ab62532b8977d6098feb805a32bc18445c54 |
| parent | 66298aa926e6a4c95ebecbc576a68634315899c0 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { expect, it } from "@effect/vitest"; | 2 | import { afterEach, beforeEach, expect, it } from "@effect/vitest"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Option from "effect/Option"; | 5 | import * 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 | }; |
| 87 | 87 | ||
| 88 | // The machine's own PATH may already hold a t3; each test sets the PATH it means. | ||
| 89 | const machinePath = process.env.PATH; | ||
| 90 | beforeEach(() => { | ||
| 91 | process.env.PATH = ""; | ||
| 92 | }); | ||
| 93 | afterEach(() => { | ||
| 94 | process.env.PATH = machinePath; | ||
| 95 | }); | ||
| 96 | |||
| 88 | it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { | 97 | it.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 | ); |
| 167 | 176 | ||
| 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))); | ||
| 179 | 186 | ||
| 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 | ); |
| 188 | 204 |
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 | }); |
| 148 | 148 | ||
| 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 the | 183 | // 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 }))); |
| 177 | 189 | ||
| 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() { |
| 51 | 51 | ||
| 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 = !installed | 54 | 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.onPath | 56 | : !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\`.`; | ||
| 59 | 61 | ||
| 60 | return ( | 62 | return ( |
| 61 | <SettingsRow | 63 | <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 | }); |
| 329 | export type DesktopCliCommandState = typeof DesktopCliCommandStateSchema.Type; | 334 | export type DesktopCliCommandState = typeof DesktopCliCommandStateSchema.Type; |
| 330 | 335 |