Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopApp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ it.effect.each([false, true])(
Effect.provideService(DesktopShellEnvironment.DesktopShellEnvironment, {
installIntoProcess: Effect.succeed(true),
awaitDiscovery: Effect.succeed(true),
awaitUserShellDiscovery: Effect.succeed(true),
updates: Stream.empty,
}),
Effect.provideService(DesktopAppSettings.DesktopAppSettings, {
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
import * as DesktopAppActivation from "./DesktopAppActivation.ts";
import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
import * as DesktopClerk from "./DesktopClerk.ts";
import * as DesktopCliCommand from "./DesktopCliCommand.ts";
import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
import * as PreviewPasskeys from "../preview/Passkeys.ts";
Expand Down Expand Up @@ -192,6 +193,18 @@ const bootstrap = Effect.gen(function* () {
);

yield* snapShot.initialize;
// Only the user's own login shell PATH shows every t3 a new terminal runs; a fresh install has
// no cached one. Without it, the command is left to Settings.
const installCli = (yield* DesktopCliCommand.DesktopCliCommand).installOnFirstLaunch;
yield* Effect.forkScoped(
(yield* DesktopShellEnvironment.DesktopShellEnvironment).awaitUserShellDiscovery.pipe(
Effect.flatMap((found) =>
found || environment.platform === "win32"
? installCli
: logBootstrapInfo("skipped installing the t3 command without the shell PATH"),
),
),
);

if (!settings.localEnvironmentEnabled) {
yield* logBootstrapInfo("bootstrap skipping local environment (disabled in settings)");
Expand Down
173 changes: 160 additions & 13 deletions apps/desktop/src/app/DesktopCliCommand.test.ts

Large diffs are not rendered by default.

221 changes: 197 additions & 24 deletions apps/desktop/src/app/DesktopCliCommand.ts

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion apps/desktop/src/app/DesktopCliShim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Random from "effect/Random";

import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import { makeComponentLogger } from "./DesktopObservability.ts";
Expand Down Expand Up @@ -147,7 +148,8 @@ export const install = Effect.gen(function* () {
}
if (Option.getOrUndefined(existing) !== content) {
yield* fs.makeDirectory(path.dirname(shimPath), { recursive: true });
const staging = `${shimPath}.${process.pid}.tmp`;
// Unique per call: backend startup and the first-launch command install can race here.
const staging = `${shimPath}.${process.pid}.${yield* Random.nextIntBetween(0, 0xffffff)}.tmp`;
yield* fs.writeFileString(staging, content, { mode: 0o755 });
yield* fs.rename(staging, shimPath);
yield* logInfo("installed t3 launcher", { shimPath });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const layerShellEnvironment = (prepared = true) =>
Layer.succeed(DesktopShellEnvironment.DesktopShellEnvironment, {
installIntoProcess: Effect.succeed(prepared),
awaitDiscovery: Effect.succeed(prepared),
awaitUserShellDiscovery: Effect.succeed(prepared),
updates: Stream.empty,
});

Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import {
getCliCommandState,
installCliCommand,
uninstallCliCommand,
addCliCommandToPath,
} from "./methods/cliCommand.ts";

export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () {
Expand Down Expand Up @@ -162,6 +163,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(getCliCommandState);
yield* ipc.handle(installCliCommand);
yield* ipc.handle(uninstallCliCommand);
yield* ipc.handle(addCliCommandToPath);
for (const previewMethod of PreviewIpc.methods) {
// Explicit types preserve the error/service union of these heterogeneous preview methods.
yield* ipc.handle<
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const UPDATE_CHECK_CHANNEL = "desktop:update-check";
export const CLI_COMMAND_GET_STATE_CHANNEL = "desktop:cli-command-get-state";
export const CLI_COMMAND_INSTALL_CHANNEL = "desktop:cli-command-install";
export const CLI_COMMAND_UNINSTALL_CHANNEL = "desktop:cli-command-uninstall";
export const CLI_COMMAND_ADD_TO_PATH_CHANNEL = "desktop:cli-command-add-to-path";
export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
export const GET_RELAY_TELEMETRY_ENABLED_CHANNEL = "desktop:get-relay-telemetry-enabled";
export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale";
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/ipc/methods/cliCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,12 @@ export const uninstallCliCommand = makeIpcMethod({
return yield* (yield* DesktopCliCommand.DesktopCliCommand).uninstall;
}),
});

export const addCliCommandToPath = makeIpcMethod({
channel: IpcChannels.CLI_COMMAND_ADD_TO_PATH_CHANNEL,
payload: Schema.Void,
result: DesktopCliCommandStateSchema,
handler: Effect.fn("desktop.ipc.cliCommand.addToPath")(function* () {
return yield* (yield* DesktopCliCommand.DesktopCliCommand).addToPath;
}),
});
1 change: 1 addition & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
getState: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_GET_STATE_CHANNEL),
install: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_INSTALL_CHANNEL),
uninstall: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_UNINSTALL_CHANNEL),
addToPath: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_ADD_TO_PATH_CHANNEL),
},
onUpdateState: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => {
Expand Down
22 changes: 21 additions & 1 deletion apps/desktop/src/shell/DesktopShellEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,10 +444,13 @@ describe("DesktopShellEnvironment", () => {
const env: NodeJS.ProcessEnv = { SHELL: "/bin/failing", PATH: "/usr/bin" };
const commands: string[] = [];

yield* runShellEnvironment({
const discovery = (service: DesktopShellEnvironment.DesktopShellEnvironment["Service"]) =>
Effect.all([service.awaitDiscovery, service.awaitUserShellDiscovery]);
const answered = yield* runShellEnvironment({
env,
platform: "linux",
userShell: "/usr/local/bin/fish",
use: (service) => discovery(service).pipe(Effect.map(([, fromUserShell]) => fromUserShell)),
handler: (command) => {
if (command._tag !== "StandardCommand") return "";
commands.push(command.command);
Expand All @@ -459,6 +462,23 @@ describe("DesktopShellEnvironment", () => {

assert.deepEqual(commands, ["/bin/failing", "/usr/local/bin/fish"]);
assert.equal(env.PATH, "/home/test/.local/bin:/usr/bin");
// The account's own login shell answered.
assert.isTrue(answered);

// Only the platform fallback answered: its PATH can miss folders the user's shell adds.
const fallbackOnly = yield* runShellEnvironment({
env: { SHELL: "/usr/bin/fish", PATH: "/usr/bin" },
platform: "linux",
use: (service) =>
discovery(service).pipe(
Effect.map(([prepared, fromUserShell]) => prepared && !fromUserShell),
),
handler: (command) =>
command._tag === "StandardCommand" && command.command === "/usr/bin/fish"
? { output: "", exitCode: 7 }
: envOutput({ PATH: "/usr/bin" }),
}).pipe(Effect.provide(Logger.layer([])));
assert.isTrue(fallbackOnly);
}),
);

Expand Down
48 changes: 38 additions & 10 deletions apps/desktop/src/shell/DesktopShellEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
applyShellEnvironment,
buildEnvironmentCaptureCommand,
listLoginShellCandidates,
readUserLoginShell,
setWindowsPath,
} from "@t3tools/shared/shell";
import * as Context from "effect/Context";
Expand Down Expand Up @@ -79,6 +80,8 @@ export class DesktopShellEnvironment extends Context.Service<
// Desktop owns discovery, including on cache misses; the backend receives updates over fd4.
readonly installIntoProcess: Effect.Effect<boolean>;
readonly awaitDiscovery: Effect.Effect<boolean>;
/** Whether the user's own login shell, not a fallback, gave the PATH a new terminal gets. */
readonly awaitUserShellDiscovery: Effect.Effect<boolean>;
readonly updates: Stream.Stream<DesktopShellEnvironmentSnapshot>;
}
>()("@t3tools/desktop/shell/DesktopShellEnvironment") {}
Expand Down Expand Up @@ -417,19 +420,24 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
function* (
config: ShellEnvironmentConfig,
): Effect.fn.Return<
boolean,
ShellDiscovery,
never,
ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem
> {
const fileSystem = yield* FileSystem.FileSystem;
const shellEnvironment: EnvironmentPatch = {};
const userShells = [config.env.SHELL?.trim(), readUserLoginShell()];

let answeredBy: string | undefined;
for (const shell of listLoginShellCandidates(config.platform, config.env.SHELL)) {
Object.assign(
shellEnvironment,
yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES, config.env),
);
if (shellEnvironment.PATH) break;
if (shellEnvironment.PATH) {
answeredBy = shell;
break;
}
}

const launchctlPath =
Expand Down Expand Up @@ -518,24 +526,33 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
}
}

return Option.isSome(trimNonEmpty(shellEnvironment.PATH));
const prepared = Option.isSome(trimNonEmpty(shellEnvironment.PATH));
return { prepared, fromUserShell: prepared && userShells.includes(answeredBy) };
},
);

/** `fromUserShell` is false when only a fallback shell answered, whose PATH can miss folders. */
interface ShellDiscovery {
readonly prepared: boolean;
readonly fromUserShell: boolean;
}

const installShellEnvironment = (
config: ShellEnvironmentConfig,
): Effect.Effect<
boolean,
ShellDiscovery,
never,
ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem
> => {
if (config.platform === "win32") {
return installWindowsEnvironment(config);
return installWindowsEnvironment(config).pipe(
Effect.map((prepared) => ({ prepared, fromUserShell: prepared })),
);
}
if (config.platform === "darwin" || config.platform === "linux") {
return installPosixEnvironment(config);
}
return Effect.succeed(false);
return Effect.succeed({ prepared: false, fromUserShell: false });
};

const ShellEnvironmentCache = Schema.fromJsonString(
Expand Down Expand Up @@ -578,7 +595,7 @@ export const make = Effect.gen(function* () {
const cachePath = path.join(environment.stateDir, "shell-environment-cache.json");
const changes = yield* PubSub.unbounded<DesktopShellEnvironmentSnapshot>();
const latest = yield* Ref.make<Option.Option<DesktopShellEnvironmentSnapshot>>(Option.none());
const discovered = yield* Deferred.make<boolean>();
const discovered = yield* Deferred.make<ShellDiscovery>();
const publish = Effect.fn("desktop.shellEnvironment.publish")(function* (
patch: ShellEnvironmentPatch,
discovered = false,
Expand All @@ -597,7 +614,11 @@ export const make = Effect.gen(function* () {
const refresh = Effect.gen(function* () {
// Refresh from the inherited baseline so removed cached paths and values do not survive.
const fresh = { ...inherited };
const prepared = yield* installShellEnvironment({ env: fresh, platform: environment.platform });
const discovery = yield* installShellEnvironment({
env: fresh,
platform: environment.platform,
});
const { prepared } = discovery;
const patch = Object.fromEntries(
Object.keys(ShellEnvironmentPatch.fields).map((name) => [name, fresh[name] ?? null]),
);
Expand All @@ -616,7 +637,7 @@ export const make = Effect.gen(function* () {
Effect.catch(() => Effect.logWarning("Could not cache shell environment")),
);
}
return prepared;
return discovery;
}).pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Expand Down Expand Up @@ -669,7 +690,14 @@ export const make = Effect.gen(function* () {
);
return DesktopShellEnvironment.of({
installIntoProcess,
awaitDiscovery: installIntoProcess.pipe(Effect.andThen(Deferred.await(discovered))),
awaitDiscovery: installIntoProcess.pipe(
Effect.andThen(Deferred.await(discovered)),
Effect.map(({ prepared }) => prepared),
),
awaitUserShellDiscovery: installIntoProcess.pipe(
Effect.andThen(Deferred.await(discovered)),
Effect.map(({ fromUserShell }) => fromUserShell),
),
updates,
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ describe("DesktopTelemetryPublisher", () => {
Layer.succeed(DesktopShellEnvironment.DesktopShellEnvironment, {
installIntoProcess: Effect.succeed(true),
awaitDiscovery: Effect.succeed(true),
awaitUserShellDiscovery: Effect.succeed(true),
updates: Stream.make({
version: 1,
type: "shellEnvironment",
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/components/onboarding/WelcomeWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import { connectPairing } from "../../connection/onboarding";
import { getProviderSummary } from "../settings/providerStatus";
import { providerClients } from "../settings/providerDriverMeta";
import { ChatGptWelcomeCoordinator } from "../settings/ChatGptWelcomeCoordinator";
import { useDesktopCliCommand } from "../settings/CliCommandSettingsRow";
import { AddManagedCodexAccountDialog, CodexSetupSection } from "../settings/CodexSetupSection";
import { readCodexSetupMode } from "../settings/CodexSetupSection.logic";
import { buildProviderInstanceUpdatePatch } from "../settings/SettingsPanels.logic";
Expand Down Expand Up @@ -701,6 +702,7 @@ function AgentsStep({
))}
</div>
</ScrollArea>
<CliPathSetup />
<div className="mt-6 flex justify-end">
<Button autoFocus onClick={onContinue}>
Continue
Expand Down Expand Up @@ -1794,6 +1796,34 @@ function ImportRowMeta({
);
}

/** The desktop app links `t3` on a fresh install; this puts its folder on PATH when it is not. */
function CliPathSetup() {
const { state, busy, run } = useDesktopCliCommand();
const setup = state?.pathSetup;
if (!setup) return null;
return (
<div className="mt-5 flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{setup.added ? (
<>
Added to {setup.file}. Open a new terminal to use <code className="font-mono">t3</code>.
</>
) : (
<>
Use <code className="font-mono">t3</code> from any terminal. Adds one line to{" "}
{setup.file}.
</>
)}
</p>
{setup.added ? null : (
<Button size="sm" variant="outline" disabled={busy} onClick={() => run("addToPath")}>
Add to PATH
</Button>
)}
</div>
);
}

function StepShell({
title,
description,
Expand Down
Loading
Loading