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
31 changes: 31 additions & 0 deletions apps/desktop/src/shell/DesktopShellEnvironment.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import { WindowsPersistentPath } from "@t3tools/shared/shell";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Logger from "effect/Logger";
Expand Down Expand Up @@ -69,6 +70,7 @@ function runShellEnvironment(input: {
readonly platform: NodeJS.Platform;
readonly handler: (command: ChildProcess.Command) => string;
readonly failure?: PlatformError.PlatformError;
readonly persistentPath?: string;
}) {
const environmentLayer = Layer.succeed(
DesktopEnvironment.DesktopEnvironment,
Expand All @@ -84,11 +86,13 @@ function runShellEnvironment(input: {
: Effect.fail(input.failure),
),
);
const persistentPathLayer = Layer.succeed(WindowsPersistentPath, () => input.persistentPath);

const program = Effect.gen(function* () {
const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment;
yield* shellEnvironment.installIntoProcess;
}).pipe(
Effect.provide(persistentPathLayer),
Effect.provide(
DesktopShellEnvironment.layer.pipe(
Layer.provide(Layer.mergeAll(environmentLayer, NodeServices.layer, spawnerLayer)),
Expand Down Expand Up @@ -333,6 +337,7 @@ describe("DesktopShellEnvironment", () => {
"C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
"C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
"C:\\Users\\testuser\\AppData\\Local\\pnpm",
"C:\\Users\\testuser\\AppData\\Local\\cursor-agent",
"C:\\Users\\testuser\\.local\\bin",
"C:\\Users\\testuser\\.bun\\bin",
"C:\\Users\\testuser\\scoop\\shims",
Expand All @@ -347,6 +352,32 @@ describe("DesktopShellEnvironment", () => {
}),
);

it.effect("merges persistent User PATH when PowerShell only returns a stale process PATH", () =>
Effect.gen(function* () {
const env: NodeJS.ProcessEnv = {
PATH: "C:\\Windows\\System32",
APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
USERPROFILE: "C:\\Users\\testuser",
};

yield* runShellEnvironment({
env,
platform: "win32",
persistentPath: [
"C:\\Users\\testuser\\AppData\\Local\\Microsoft\\WinGet\\Links",
"C:\\Users\\testuser\\AppData\\Local\\cursor-agent",
].join(";"),
handler: () => envOutput({ PATH: "C:\\Windows\\System32" }),
});

assert.ok(
env.PATH?.includes("C:\\Users\\testuser\\AppData\\Local\\Microsoft\\WinGet\\Links"),
);
assert.ok(env.PATH?.includes("C:\\Users\\testuser\\AppData\\Local\\cursor-agent"));
}),
);

it.effect("prefers login-shell desktop session hints over inherited values on linux", () =>
Effect.gen(function* () {
const env: NodeJS.ProcessEnv = {
Expand Down
44 changes: 10 additions & 34 deletions apps/desktop/src/shell/DesktopShellEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import * as Schema from "effect/Schema";
import * as ChildProcess from "effect/unstable/process/ChildProcess";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";

import {
buildWindowsEnvironmentCaptureCommand,
resolveKnownWindowsCliDirs,
WindowsPersistentPath,
} from "@t3tools/shared/shell";

import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";

type EnvironmentPatch = Record<string, string>;
Expand Down Expand Up @@ -196,27 +202,6 @@ const listLoginShellCandidates = (config: ShellEnvironmentConfig): ReadonlyArray
return candidates;
};

const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray<string> => [
...trimNonEmpty(env.APPDATA).pipe(
Option.match({
onNone: () => [],
onSome: (value) => [`${value}\\npm`],
}),
),
...trimNonEmpty(env.LOCALAPPDATA).pipe(
Option.match({
onNone: () => [],
onSome: (value) => [`${value}\\Programs\\nodejs`, `${value}\\Volta\\bin`, `${value}\\pnpm`],
}),
),
...trimNonEmpty(env.USERPROFILE).pipe(
Option.match({
onNone: () => [],
onSome: (value) => [`${value}\\.local\\bin`, `${value}\\.bun\\bin`, `${value}\\scoop\\shims`],
}),
),
];

const startMarker = (name: string) => `__T3CODE_ENV_${name}_START__`;
const endMarker = (name: string) => `__T3CODE_ENV_${name}_END__`;

Expand All @@ -243,18 +228,7 @@ const capturePosixEnvironmentCommand = (names: ReadonlyArray<string>) =>
})
.join("; ");

const captureWindowsEnvironmentCommand = (names: ReadonlyArray<string>) =>
[
"$ErrorActionPreference = 'Stop'",
...names.flatMap((name) => {
return [
`Write-Output '${startMarker(name)}'`,
`$value = [Environment]::GetEnvironmentVariable('${name}')`,
"if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }",
`Write-Output '${endMarker(name)}'`,
];
}),
].join("; ");
const captureWindowsEnvironmentCommand = buildWindowsEnvironmentCaptureCommand;

const extractEnvironment = (output: string, names: ReadonlyArray<string>): EnvironmentPatch => {
const environment: EnvironmentPatch = {};
Expand Down Expand Up @@ -396,9 +370,11 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin
],
{ concurrency: 2 },
);
const readPersistentPath = yield* WindowsPersistentPath;
const mergedPath = mergePaths("win32", [
trimNonEmpty(profile.PATH),
trimNonEmpty(knownWindowsCliDirs(config.env).join(";")),
trimNonEmpty(readPersistentPath(config.env)),
trimNonEmpty(resolveKnownWindowsCliDirs(config.env).join(";")),
trimNonEmpty(noProfile.PATH),
readEnvPath(config.env),
]);
Expand Down
123 changes: 123 additions & 0 deletions apps/server/src/provider/Drivers/HornetDriver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { HornetSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import { HttpClient } from "effect/unstable/http";

import { ServerSettingsService } from "../../serverSettings.ts";
import { makeHornetTextGeneration } from "../../textGeneration/HornetTextGeneration.ts";
import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
import { ProviderDriverError } from "../Errors.ts";
import { makeHornetAdapter } from "../Layers/HornetAdapter.ts";
import { checkHornetProviderStatus, makePendingHornetProvider } from "../Layers/HornetProvider.ts";
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
import {
defaultProviderContinuationIdentity,
type ProviderDriver,
type ProviderInstance,
} from "../ProviderDriver.ts";
import type { ServerProviderDraft } from "../providerSnapshot.ts";
import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
import {
haveProviderSnapshotSettingsChanged,
makeProviderSnapshotSettingsSource,
type ProviderSnapshotSettings,
} from "../providerUpdateSettings.ts";

const decodeHornetSettings = Schema.decodeSync(HornetSettings);

const DRIVER_KIND = ProviderDriverKind.make("hornet");

export type HornetDriverEnv =
| BackgroundPolicy.BackgroundPolicy
| Crypto.Crypto
| HttpClient.HttpClient
| ServerSettingsService;

const withInstanceIdentity =
(input: {
readonly instanceId: ProviderInstance["instanceId"];
readonly displayName: string | undefined;
readonly accentColor: string | undefined;
readonly continuationGroupKey: string;
}) =>
(snapshot: ServerProviderDraft): ServerProvider => ({
...snapshot,
instanceId: input.instanceId,
driver: DRIVER_KIND,
...(input.displayName ? { displayName: input.displayName } : {}),
...(input.accentColor ? { accentColor: input.accentColor } : {}),
continuation: { groupKey: input.continuationGroupKey },
});

export const HornetDriver: ProviderDriver<HornetSettings, HornetDriverEnv> = {
driverKind: DRIVER_KIND,
metadata: {
displayName: "Hornet",
supportsMultipleInstances: true,
},
configSchema: HornetSettings,
defaultConfig: (): HornetSettings => decodeHornetSettings({}),
create: ({ instanceId, displayName, accentColor, enabled, config }) =>
Effect.gen(function* () {
const httpClient = yield* HttpClient.HttpClient;
const serverSettings = yield* ServerSettingsService;
const continuationIdentity = defaultProviderContinuationIdentity({
driverKind: DRIVER_KIND,
instanceId,
});
const stampIdentity = withInstanceIdentity({
instanceId,
displayName,
accentColor,
continuationGroupKey: continuationIdentity.continuationKey,
});
const effectiveConfig = { ...config, enabled } satisfies HornetSettings;
const maintenanceCapabilities = makeManualOnlyProviderMaintenanceCapabilities({
provider: DRIVER_KIND,
packageName: null,
});

const adapter = yield* makeHornetAdapter(effectiveConfig, { instanceId });
const textGeneration = yield* makeHornetTextGeneration(effectiveConfig);

const checkProvider = checkHornetProviderStatus(effectiveConfig).pipe(
Effect.map(stampIdentity),
Effect.provideService(HttpClient.HttpClient, httpClient),
);

const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<HornetSettings>>({
maintenanceCapabilities,
getSettings: snapshotSettings.getSettings,
streamSettings: snapshotSettings.streamSettings,
haveSettingsChanged: haveProviderSnapshotSettingsChanged,
initialSnapshot: (settings) =>
makePendingHornetProvider(settings.provider).pipe(Effect.map(stampIdentity)),
checkProvider,
enrichSnapshot: ({ snapshot, publishSnapshot }) => publishSnapshot(snapshot),
}).pipe(
Effect.mapError(
(cause) =>
new ProviderDriverError({
driver: DRIVER_KIND,
instanceId,
detail: `Failed to build Hornet snapshot: ${cause.message ?? String(cause)}`,
cause,
}),
),
);

return {
instanceId,
driverKind: DRIVER_KIND,
continuationIdentity,
displayName,
accentColor,
enabled,
snapshot,
adapter,
textGeneration,
} satisfies ProviderInstance;
}),
};
Loading
Loading