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
30 changes: 22 additions & 8 deletions packages/provider-core/src/server/maintenanceResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,18 +395,32 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
"package-tool",
]);

const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
packageToolUpdate,
{
const updateCommand = (spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]) =>
resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {
binaryPath: link,
env: { PATH: "" },
},
).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));

expect(capabilities.update).toMatchObject({
command: "pnpm add -g @example/package-tool@latest",
}).pipe(
Effect.map((capabilities) => capabilities.update),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);
const pnpmVersion = (version: string) =>
stdoutSpawner((command, args) => {
expect([command, ...args].join(" ")).toMatch(/pnpm(\.cmd)?"? --version$/);
return `${version}
`;
});

expect(yield* updateCommand(pnpmVersion("11.1.3"))).toMatchObject({
command: "pnpm add -g --allow-build=@example/package-tool @example/package-tool@latest",
lockKey: "pnpm-global",
});
// Older pnpm rejects the unknown flag, and runs install scripts before 10.
expect((yield* updateCommand(pnpmVersion("10.3.0")))?.command).toBe(
"pnpm add -g @example/package-tool@latest",
);
expect((yield* updateCommand(noSpawn))?.command).toBe(
"pnpm add -g @example/package-tool@latest",
);
}),
);

Expand Down
46 changes: 32 additions & 14 deletions packages/provider-core/src/server/maintenanceResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ import {
type ServerProvider,
type ServerProviderVersionAdvisory,
} from "@t3tools/contracts";
import { compareSemverVersions } from "@t3tools/shared/semver";
import { compareSemverVersions, satisfiesSemverRange } from "@t3tools/shared/semver";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { causeErrorTag } from "@t3tools/shared/observability";
import { resolveCommandPath } from "@t3tools/shared/shell";
import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell";
import * as Cache from "effect/Cache";
import * as Config from "effect/Config";
import * as DateTime from "effect/DateTime";
Expand All @@ -34,8 +34,8 @@ import { collectUint8StreamText } from "./collectStreamText.ts";
import * as ProviderLatestVersions from "./ProviderLatestVersions.ts";

const LATEST_VERSION_TIMEOUT_MS = 4_000;
const HOMEBREW_INFO_TIMEOUT_MS = 10_000;
const HOMEBREW_INFO_MAX_BYTES = 256 * 1_024;
const PROBE_TIMEOUT_MS = 10_000;
const PROBE_MAX_BYTES = 256 * 1_024;
const MISE_WRAPPER_MAX_BYTES = 16 * 1_024;
const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings.";

Expand Down Expand Up @@ -293,19 +293,27 @@ export function parseHomebrewLatestVersion(
return nonEmptyString(raw);
}

const runHomebrew = Effect.fn("runHomebrew")(function* (
brewPath: string,
const runProbe = Effect.fn("runProbe")(function* (
command: string,
args: ReadonlyArray<string>,
env: NodeJS.ProcessEnv,
) {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const collect = Effect.gen(function* () {
const child = yield* spawner.spawn(ChildProcess.make(brewPath, args, { env, extendEnv: true }));
// Windows package managers are `.cmd` shims that need the shell.
const resolved = yield* resolveSpawnCommand(command, args, { env, extendEnv: true });
const child = yield* spawner.spawn(
ChildProcess.make(resolved.command, resolved.args, {
env,
extendEnv: true,
shell: resolved.shell,
}),
);
yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore));
// stderr is drained so a chatty brew cannot block on a full pipe.
// stderr is drained so a chatty tool cannot block on a full pipe.
const [stdout, exitCode] = yield* Effect.all(
[
collectUint8StreamText({ stream: child.stdout, maxBytes: HOMEBREW_INFO_MAX_BYTES }),
collectUint8StreamText({ stream: child.stdout, maxBytes: PROBE_MAX_BYTES }),
child.exitCode,
Stream.runDrain(child.stderr),
],
Expand All @@ -315,10 +323,11 @@ const runHomebrew = Effect.fn("runHomebrew")(function* (
});
return yield* collect.pipe(
Effect.scoped,
Effect.timeoutOption(Duration.millis(HOMEBREW_INFO_TIMEOUT_MS)),
Effect.timeoutOption(Duration.millis(PROBE_TIMEOUT_MS)),
Effect.map(Option.getOrNull),
Effect.catchCause((cause) =>
Effect.logWarning("Homebrew probe failed", {
Effect.logWarning("Maintenance probe failed", {
command,
subcommand: args[0],
errorTag: causeErrorTag(cause),
}).pipe(Effect.as(null)),
Expand Down Expand Up @@ -383,11 +392,20 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(
});
}
if (commandPaths.some(isPnpmGlobalCommandPath)) {
// pnpm 10 skips unapproved install scripts and still exits 0, leaving a
// package whose postinstall finishes the install broken. Global installs
// can only approve on the command line, and pnpm before 10.4 rejects the
// flag as an unknown option.
const pnpmVersion = yield* runProbe("pnpm", ["--version"], context.env);
const allowBuild =
pnpmVersion !== null && satisfiesSemverRange(pnpmVersion, ">=10.4.0")
? [`--allow-build=${packageName}`]
: [];
return makeProviderMaintenanceCapabilities({
provider: definition.provider,
packageName,
updateExecutable: "pnpm",
updateArgs: ["add", "-g", `${packageName}@latest`],
updateArgs: ["add", "-g", ...allowBuild, `${packageName}@latest`],
updateLockKey: "pnpm-global",
});
}
Expand Down Expand Up @@ -458,7 +476,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(
if (!brewPath) {
return fallback;
}
const brewPrefix = nonEmptyString(yield* runHomebrew(brewPath, ["--prefix"], context.env));
const brewPrefix = nonEmptyString(yield* runProbe(brewPath, ["--prefix"], context.env));
const realBrewPrefix = brewPrefix
? yield* fileSystem.realPath(brewPrefix).pipe(Effect.orElseSucceed(() => brewPrefix))
: null;
Expand All @@ -472,7 +490,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(
homebrew.kind === "cask" ? ["upgrade", "--cask", homebrew.name] : ["upgrade", homebrew.name];
// Homebrew lags npm by hours on every release, so compare against what
// `brew upgrade` can actually deliver.
const info = yield* runHomebrew(brewPath, ["info", "--json=v2", homebrew.name], context.env);
const info = yield* runProbe(brewPath, ["info", "--json=v2", homebrew.name], context.env);
return makeProviderMaintenanceCapabilities({
provider: definition.provider,
packageName,
Expand Down
Loading