Skip to content

Commit 4c2ce06

Browse files
committed
fix(server): run provider updates with instance env on Windows
1 parent d1e85c4 commit 4c2ce06

6 files changed

Lines changed: 134 additions & 44 deletions

apps/server/src/provider/ProviderInstanceEnvironment.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,25 @@ describe("mergeProviderInstanceEnvironment", () => {
1818
PATH: "/bin",
1919
});
2020
});
21+
22+
it("merges overrides with canonical Windows PATH casing", () => {
23+
expect(
24+
mergeProviderInstanceEnvironment(
25+
[
26+
{ name: "PATH", value: "C:\\Provider\\Bin", sensitive: false },
27+
{ name: "FOO", value: "bar", sensitive: false },
28+
],
29+
{
30+
Path: "C:\\Base\\Bin",
31+
PaTh: "C:\\Odd\\Bin",
32+
ComSpec: "C:\\Windows\\System32\\cmd.exe",
33+
},
34+
"win32",
35+
),
36+
).toEqual({
37+
ComSpec: "C:\\Windows\\System32\\cmd.exe",
38+
FOO: "bar",
39+
PATH: "C:\\Provider\\Bin",
40+
});
41+
});
2142
});

apps/server/src/provider/ProviderInstanceEnvironment.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,39 @@ import type { ProviderInstanceEnvironment } from "@t3tools/contracts";
33
export function mergeProviderInstanceEnvironment(
44
environment: ProviderInstanceEnvironment | undefined,
55
baseEnv: NodeJS.ProcessEnv = process.env,
6+
platform: NodeJS.Platform = process.platform,
67
): NodeJS.ProcessEnv {
7-
if (!environment || environment.length === 0) {
8-
return baseEnv;
9-
}
10-
118
const next: NodeJS.ProcessEnv = { ...baseEnv };
12-
for (const variable of environment) {
9+
for (const variable of environment ?? []) {
1310
next[variable.name] = variable.value;
1411
}
12+
13+
if (platform !== "win32") {
14+
return next;
15+
}
16+
17+
let pathValue: string | undefined;
18+
for (const [name, value] of Object.entries(baseEnv)) {
19+
if (name.toUpperCase() === "PATH" && pathValue === undefined) {
20+
pathValue = value;
21+
break;
22+
}
23+
}
24+
25+
for (const variable of environment ?? []) {
26+
if (variable.name.toUpperCase() === "PATH") {
27+
pathValue = variable.value;
28+
}
29+
}
30+
31+
for (const name of Object.keys(next)) {
32+
if (name.toUpperCase() === "PATH") {
33+
delete next[name];
34+
}
35+
}
36+
if (pathValue !== undefined) {
37+
next.PATH = pathValue;
38+
}
39+
1540
return next;
1641
}

apps/server/src/provider/providerMaintenance.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,17 @@ describe("providerMaintenance", () => {
132132
});
133133
});
134134

135+
it.effect("attaches provider environments to update commands", () =>
136+
Effect.gen(function* () {
137+
const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {
138+
env: { PATH: "C:\\Tools" },
139+
});
140+
141+
expect(capabilities.update?.env).toEqual({ PATH: "C:\\Tools" });
142+
expect(capabilities.update?.command).toBe("npm install -g @example/package-tool@latest");
143+
}).pipe(Effect.provide(NodeServices.layer)),
144+
);
145+
135146
it.effect(
136147
"switches package-managed providers to vite-plus updates when the resolved binary lives in vite-plus global bin",
137148
() =>

apps/server/src/provider/providerMaintenance.ts

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface ProviderMaintenanceCommandAction {
2727
readonly executable: string;
2828
readonly args: ReadonlyArray<string>;
2929
readonly lockKey: string;
30+
readonly env?: NodeJS.ProcessEnv;
3031
}
3132

3233
export interface ProviderMaintenanceCapabilityResolutionOptions {
@@ -330,28 +331,27 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn(
330331
resolver: ProviderMaintenanceCapabilitiesResolver,
331332
options?: Omit<ProviderMaintenanceCapabilityResolutionOptions, "realCommandPath">,
332333
) {
334+
let nextOptions: ProviderMaintenanceCapabilityResolutionOptions | undefined = options;
333335
const binaryPath = nonEmptyString(options?.binaryPath);
334-
if (!binaryPath) {
335-
return resolver.resolve(options);
336-
}
336+
const resolvedCommandPath = binaryPath
337+
? (resolveCommandPath(binaryPath, {
338+
...(options?.platform ? { platform: options.platform } : {}),
339+
...(options?.env ? { env: options.env } : {}),
340+
}) ?? (hasPathSeparator(binaryPath) ? binaryPath : null))
341+
: null;
337342

338-
const resolvedCommandPath =
339-
resolveCommandPath(binaryPath, {
340-
...(options?.platform ? { platform: options.platform } : {}),
341-
...(options?.env ? { env: options.env } : {}),
342-
}) ?? (hasPathSeparator(binaryPath) ? binaryPath : null);
343-
if (!resolvedCommandPath) {
344-
return resolver.resolve(options);
343+
if (resolvedCommandPath) {
344+
const fileSystem = yield* FileSystem.FileSystem;
345+
const realCommandPath = yield* fileSystem
346+
.realPath(resolvedCommandPath)
347+
.pipe(Effect.catch(() => Effect.succeed(resolvedCommandPath)));
348+
nextOptions = { ...options, realCommandPath };
345349
}
346350

347-
const fileSystem = yield* FileSystem.FileSystem;
348-
const realCommandPath = yield* fileSystem
349-
.realPath(resolvedCommandPath)
350-
.pipe(Effect.catch(() => Effect.succeed(resolvedCommandPath)));
351-
return resolver.resolve({
352-
...options,
353-
realCommandPath,
354-
});
351+
const capabilities = resolver.resolve(nextOptions);
352+
return options?.env && capabilities.update
353+
? { ...capabilities, update: { ...capabilities.update, env: options.env } }
354+
: capabilities;
355355
});
356356

357357
function deriveVersionAdvisory(input: {

apps/server/src/provider/providerMaintenanceRunner.test.ts

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ function mockSpawnerLayer(
125125
handler: (
126126
command: string,
127127
args: ReadonlyArray<string>,
128+
options: {
129+
readonly env?: NodeJS.ProcessEnv;
130+
readonly shell?: boolean;
131+
},
128132
) => {
129133
readonly stdout?: string;
130134
readonly stderr?: string;
@@ -138,8 +142,14 @@ function mockSpawnerLayer(
138142
const childProcess = command as unknown as {
139143
readonly command: string;
140144
readonly args: ReadonlyArray<string>;
145+
readonly options?: {
146+
readonly env?: NodeJS.ProcessEnv;
147+
readonly shell?: boolean;
148+
};
141149
};
142-
return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args)));
150+
return Effect.succeed(
151+
mockHandle(handler(childProcess.command, childProcess.args, childProcess.options ?? {})),
152+
);
143153
}),
144154
);
145155
}
@@ -240,7 +250,16 @@ describe("providerMaintenanceRunner", () => {
240250
});
241251

242252
it.effect("uses the resolved provider capabilities when choosing the update executable", () => {
243-
const calls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
253+
const updateEnv = {
254+
PATH: "C:\\Provider\\Bin",
255+
FOO: "bar",
256+
};
257+
const calls: Array<{
258+
command: string;
259+
args: ReadonlyArray<string>;
260+
env?: NodeJS.ProcessEnv;
261+
shell?: boolean;
262+
}> = [];
244263
return Effect.gen(function* () {
245264
const { registry } = yield* makeRegistry({
246265
...baseProvider,
@@ -257,30 +276,39 @@ describe("providerMaintenanceRunner", () => {
257276
const updater = yield* makeTestRunner({
258277
...registry,
259278
getProviderMaintenanceCapabilitiesForInstance: () =>
260-
Effect.succeed(
261-
makeProviderMaintenanceCapabilities({
262-
provider: CODEX_DRIVER,
263-
packageName: "@openai/codex",
264-
updateExecutable: "bun",
265-
updateArgs: ["i", "-g", "@openai/codex@latest"],
266-
updateLockKey: "bun-global",
267-
}),
268-
),
279+
Effect.succeed({
280+
provider: CODEX_DRIVER,
281+
packageName: "@openai/codex",
282+
update: {
283+
command: "bun i -g @openai/codex@latest",
284+
executable: "bun",
285+
args: ["i", "-g", "@openai/codex@latest"],
286+
lockKey: "bun-global",
287+
env: updateEnv,
288+
},
289+
}),
269290
});
270291

271292
yield* updater.updateProvider(CODEX_DRIVER);
272293
assert.deepStrictEqual(calls, [
273294
{
274295
command: "bun",
275296
args: ["i", "-g", "@openai/codex@latest"],
297+
env: updateEnv,
298+
...(process.platform === "win32" ? { shell: true } : {}),
276299
},
277300
]);
278301
}).pipe(
279302
Effect.provide(
280303
Layer.mergeAll(
281304
latestVersionHttpClient("0.0.0"),
282-
mockSpawnerLayer((command, args) => {
283-
calls.push({ command, args });
305+
mockSpawnerLayer((command, args, options) => {
306+
calls.push({
307+
command,
308+
args,
309+
...(options.env ? { env: options.env } : {}),
310+
...(options.shell !== undefined ? { shell: options.shell } : {}),
311+
});
284312
return { stdout: "updated" };
285313
}),
286314
),

apps/server/src/provider/providerMaintenanceRunner.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,17 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR
7272
readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
7373
readonly command: string;
7474
readonly args: ReadonlyArray<string>;
75+
readonly env?: NodeJS.ProcessEnv;
7576
}) {
7677
const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")(
7778
function* () {
7879
const child = yield* input.spawner
79-
.spawn(ChildProcess.make(input.command, [...input.args]))
80+
.spawn(
81+
ChildProcess.make(input.command, [...input.args], {
82+
...(input.env ? { env: input.env } : {}),
83+
...(process.platform === "win32" ? { shell: true } : {}),
84+
}),
85+
)
8086
.pipe(
8187
Effect.mapError(
8288
(cause) =>
@@ -194,12 +200,6 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
194200
const providerRegistry = yield* ProviderRegistry;
195201
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
196202
const httpClient = yield* HttpClient.HttpClient;
197-
const runMaintenanceCommand = (command: string, args: ReadonlyArray<string>) =>
198-
runProviderMaintenanceCommandWithSpawner({
199-
spawner,
200-
command,
201-
args,
202-
});
203203
const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({
204204
makeAlreadyRunningError: () =>
205205
new ServerProviderUpdateError({
@@ -330,7 +330,12 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
330330
}),
331331
);
332332

333-
const result = yield* runMaintenanceCommand(update.executable, update.args);
333+
const result = yield* runProviderMaintenanceCommandWithSpawner({
334+
spawner,
335+
command: update.executable,
336+
args: update.args,
337+
...(update.env ? { env: update.env } : {}),
338+
});
334339
const finishedAt = yield* nowIso;
335340
if (result.timedOut || result.exitCode !== 0) {
336341
return yield* finish(

0 commit comments

Comments
 (0)