Skip to content
Closed
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
9 changes: 8 additions & 1 deletion apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
import * as Result from "effect/Result";
import * as Schema from "effect/Schema";

import * as NetService from "@t3tools/shared/Net";
Expand All @@ -27,6 +28,7 @@ import * as DesktopShutdown from "./DesktopShutdown.ts";
import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts";
import { recoverShellEnvironment } from "../shell/DesktopShellEnvironmentRecovery.ts";
import * as DesktopState from "./DesktopState.ts";
import * as DesktopRemoteUpdates from "../updates/DesktopRemoteUpdates.ts";
import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
Expand Down Expand Up @@ -271,7 +273,7 @@ const startup = Effect.gen(function* () {
const updates = yield* DesktopUpdates.DesktopUpdates;
const environment = yield* DesktopEnvironment.DesktopEnvironment;

yield* shellEnvironment.installIntoProcess;
const shellEnvironmentResult = yield* Effect.result(shellEnvironment.installIntoProcess);
const hasCommandLinePasswordStore =
preReadyElectronOptions.linuxPasswordStoreCommandLine !== null;
const linuxElectronOptions =
Expand Down Expand Up @@ -316,6 +318,11 @@ const startup = Effect.gen(function* () {
Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)),
);
yield* logStartupInfo("app ready");
if (Result.isFailure(shellEnvironmentResult)) {
yield* recoverShellEnvironment().pipe(
Effect.catchCause((cause) => fatalStartupCause("shell environment recovery", cause)),
);
}
if (environment.platform === "linux") {
const selectedBackend = yield* safeStorage.selectedStorageBackend;
yield* logStartupInfo("safe storage ready", {
Expand Down
46 changes: 46 additions & 0 deletions apps/desktop/src/app/DesktopAppErrors.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { recoverShellEnvironment } from "../shell/DesktopShellEnvironmentRecovery.ts";
import * as ElectronDialog from "../electron/ElectronDialog.ts";
import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts";

import {
DesktopBackendPortUnavailableError,
Expand Down Expand Up @@ -28,3 +33,44 @@ describe("DesktopApp errors", () => {
assert.equal(error.message, "T3CODE_PORT is required in desktop development.");
});
});

describe("shell environment recovery", () => {
it.effect.each([
{ responses: [0], failures: 0, attempts: 1 },
{ responses: [0, 0], failures: 1, attempts: 2 },
{ responses: [1], failures: 0, attempts: 0 },
{ responses: [0, 1], failures: 1, attempts: 1 },
])("recovers or continues: %j", ({ responses, failures, attempts }) =>
Effect.gen(function* () {
let captures = 0;
let dialogs = 0;
yield* recoverShellEnvironment().pipe(
Effect.provide(
Layer.mergeAll(
Layer.succeed(DesktopShellEnvironment.DesktopShellEnvironment, {
installIntoProcess: Effect.suspend(() => {
captures++;
return captures <= failures
? Effect.fail(new DesktopShellEnvironment.DesktopShellEnvironmentCaptureError())
: Effect.void;
}),
}),
Layer.succeed(ElectronDialog.ElectronDialog, {
pickFolder: () => Effect.die("unused"),
pickFiles: () => Effect.die("unused"),
showErrorBox: () => Effect.die("unused"),
showMessageBox: () =>
Effect.sync(() => {
const response = responses[dialogs++];
assert.isDefined(response);
return { response: response ?? 1, checkboxChecked: false };
}),
}),
),
),
);
assert.equal(captures, attempts);
assert.equal(dialogs, responses.length);
}),
);
});
97 changes: 91 additions & 6 deletions apps/desktop/src/shell/DesktopShellEnvironment.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as TestClock from "effect/testing/TestClock";
import * as Layer from "effect/Layer";
import * as Logger from "effect/Logger";
import * as Option from "effect/Option";
import type * as Tracer from "effect/Tracer";
import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
import * as Sink from "effect/Sink";
Expand Down Expand Up @@ -67,7 +72,7 @@ function withProcessEnv<A, E, R>(
function runShellEnvironment(input: {
readonly env: NodeJS.ProcessEnv;
readonly platform: NodeJS.Platform;
readonly handler: (command: ChildProcess.Command) => string;
readonly handler: (command: ChildProcess.Command) => string | Effect.Effect<string>;
readonly failure?: PlatformError.PlatformError;
}) {
const environmentLayer = Layer.succeed(
Expand All @@ -79,9 +84,13 @@ function runShellEnvironment(input: {
const spawnerLayer = Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make((command) =>
input.failure === undefined
? Effect.succeed(makeProcess(input.handler(command)))
: Effect.fail(input.failure),
Effect.suspend(() => {
if (input.failure !== undefined) return Effect.fail(input.failure);
const output = input.handler(command);
return (Effect.isEffect(output) ? output : Effect.succeed(output)).pipe(
Effect.map(makeProcess),
);
}),
),
);

Expand Down Expand Up @@ -129,6 +138,75 @@ describe("DesktopShellEnvironment", () => {
}),
);

it.effect("retries a timed-out login shell before starting with an incomplete PATH", () =>
Effect.gen(function* () {
const env = { SHELL: "/bin/zsh", PATH: "/usr/bin:/bin:/usr/sbin:/sbin" };
let attempts = 0;
const capture = yield* runShellEnvironment({
env,
platform: "darwin",
handler: (command) => {
if (command._tag !== "StandardCommand" || command.command === "/bin/launchctl") return "";
attempts++;
return attempts === 1 ? Effect.never : envOutput({ PATH: "/opt/homebrew/bin:/usr/bin" });
},
}).pipe(Effect.forkChild);

yield* TestClock.adjust("5 seconds");
yield* Fiber.join(capture);
assert.equal(attempts, 2);
assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin");
}),
);

it.effect("fails capture when both login shell and launchctl provide no PATH", () =>
Effect.gen(function* () {
const env = { SHELL: "/bin/zsh", PATH: "/usr/bin" };
const spans = new Map<string, Tracer.Span>();
const result = yield* runShellEnvironment({
env,
platform: "darwin",
handler: () =>
Effect.gen(function* () {
let span: Tracer.AnySpan = yield* Effect.currentSpan.pipe(Effect.orDie);
while (span._tag === "Span") {
spans.set(span.name, span);
if (Option.isNone(span.parent)) break;
span = span.parent.value;
}
return "";
}),
}).pipe(Effect.exit);
assert.isTrue(Exit.isFailure(result));
for (const name of ["installPosixEnvironment", "installIntoProcess"]) {
const status = spans.get(`desktop.shellEnvironment.${name}`)?.status;
assert.equal(status?._tag, "Ended");
if (status?._tag === "Ended") assert.isTrue(Exit.isFailure(status.exit));
}
assert.equal(env.PATH, "/usr/bin");
}),
);

it.effect("bounds repeated timeouts and still tries launchctl", () =>
Effect.gen(function* () {
const env = { SHELL: "/bin/zsh", PATH: "/usr/bin" };
const commands: string[] = [];
const capture = yield* runShellEnvironment({
env,
platform: "darwin",
handler: (command) => {
if (command._tag !== "StandardCommand") return "";
commands.push(command.command);
return command.command === "/bin/launchctl" ? "" : Effect.never;
},
}).pipe(Effect.exit, Effect.forkChild);

yield* TestClock.adjust("10 seconds");
assert.isTrue(Exit.isFailure(yield* Fiber.join(capture)));
assert.deepEqual(commands, ["/bin/zsh", "/bin/zsh", "/bin/launchctl"]);
}),
);

it.effect("preserves inherited POSIX values when present", () =>
Effect.gen(function* () {
const env: NodeJS.ProcessEnv = {
Expand Down Expand Up @@ -294,7 +372,13 @@ describe("DesktopShellEnvironment", () => {
},
});

assert.deepEqual(commands, ["/opt/homebrew/bin/nu", "/bin/zsh", "/bin/launchctl"]);
assert.deepEqual(commands, [
"/opt/homebrew/bin/nu",
"/opt/homebrew/bin/nu",
"/bin/zsh",
"/bin/zsh",
"/bin/launchctl",
]);
assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin");
}),
);
Expand Down Expand Up @@ -419,12 +503,13 @@ describe("DesktopShellEnvironment", () => {
handler: () => "",
failure: cause,
}).pipe(
Effect.exit,
Effect.andThen(
Effect.sync(() => {
const errors = messages
.flatMap((message) => (Array.isArray(message) ? message : [message]))
.filter(isDesktopShellEnvironmentCommandError);
assert.lengthOf(errors, 1);
assert.lengthOf(errors, 2);
assert.equal(errors[0]?.probe, "login-shell");
assert.equal(errors[0]?.executable, "bash");
assert.equal(errors[0]?.argumentCount, 2);
Expand Down
40 changes: 30 additions & 10 deletions apps/desktop/src/shell/DesktopShellEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,19 @@ export class DesktopShellEnvironmentCommandTimeoutError extends Schema.TaggedErr
}
}

export class DesktopShellEnvironmentCaptureError extends Schema.TaggedError<DesktopShellEnvironmentCaptureError>()(
"DesktopShellEnvironmentCaptureError",
{},
) {
override get message(): string {
return "The shell environment could not be captured.";
}
}

export class DesktopShellEnvironment extends Context.Service<
DesktopShellEnvironment,
{
readonly installIntoProcess: Effect.Effect<void>;
readonly installIntoProcess: Effect.Effect<void, DesktopShellEnvironmentCaptureError>;
}
>()("@t3tools/desktop/shell/DesktopShellEnvironment") {}

Expand Down Expand Up @@ -318,7 +327,10 @@ const readLoginShellEnvironment = (
command: shell,
args: ["-ilc", capturePosixEnvironmentCommand(names)],
timeout: LOGIN_SHELL_TIMEOUT,
}).pipe(Effect.map((output) => extractEnvironment(output, names)));
}).pipe(
Effect.map((output) => extractEnvironment(output, names)),
Effect.repeat({ times: 1, while: (environment) => !environment.PATH?.trim() }),
);

const readLaunchctlPath = runCommandOutput({
probe: "launchctl-path",
Expand Down Expand Up @@ -399,7 +411,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
config: ShellEnvironmentConfig,
): Effect.fn.Return<
void,
never,
DesktopShellEnvironmentCaptureError,
ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem
> {
const fileSystem = yield* FileSystem.FileSystem;
Expand All @@ -410,17 +422,17 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
shellEnvironment,
yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES),
);
if (shellEnvironment.PATH) break;
if (shellEnvironment.PATH?.trim()) break;
}

const launchctlPath =
config.platform === "darwin" && !shellEnvironment.PATH
config.platform === "darwin" && !shellEnvironment.PATH?.trim()
? yield* readLaunchctlPath
: Option.none<string>();
const mergedPath = mergePaths(config.platform, [
trimNonEmpty(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)),
readEnvPath(config.env),
]);
const capturedPath = trimNonEmpty(shellEnvironment.PATH).pipe(
Option.orElse(() => launchctlPath),
);
const mergedPath = mergePaths(config.platform, [capturedPath, readEnvPath(config.env)]);

if (Option.isSome(mergedPath)) {
config.env.PATH = mergedPath.value;
Expand Down Expand Up @@ -494,12 +506,19 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
}
}
}
if (Option.isNone(capturedPath)) {
return yield* new DesktopShellEnvironmentCaptureError();
}
},
);

const installShellEnvironment = (
config: ShellEnvironmentConfig,
): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem> => {
): Effect.Effect<
void,
DesktopShellEnvironmentCaptureError,
ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem
> => {
if (config.platform === "win32") {
return installWindowsEnvironment(config);
}
Expand All @@ -523,6 +542,7 @@ export const make = Effect.gen(function* () {
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.withSpan("desktop.shellEnvironment.installIntoProcess"),
Effect.tapError(Effect.logWarning),
);

return DesktopShellEnvironment.of({ installIntoProcess });
Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/src/shell/DesktopShellEnvironmentRecovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as Effect from "effect/Effect";
import * as Result from "effect/Result";
import * as ElectronDialog from "../electron/ElectronDialog.ts";
import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts";

export const recoverShellEnvironment = Effect.fn("desktop.startup.recoverShellEnvironment")(
function* () {
const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment;
const electronDialog = yield* ElectronDialog.ElectronDialog;
while (true) {
const { response } = yield* electronDialog.showMessageBox({
type: "warning",
title: "T3 Code",
message: "The shell environment could not be captured.",
detail:
"Installed agent CLIs may appear missing because T3 Code could not read your shell's PATH. Retry after checking your shell configuration, or continue and set an absolute CLI path in provider settings.",
buttons: ["Retry", "Continue"],
defaultId: 0,
cancelId: 1,
});
if (response !== 0) return;
const result = yield* Effect.result(shellEnvironment.installIntoProcess);
if (Result.isSuccess(result)) return;
}
},
);
Loading