Skip to content
Open
82 changes: 82 additions & 0 deletions packages/provider-core/src/server/maintenanceResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,88 @@ it.layer(
}),
);

// Root ignores directory modes, and chmod does not stop writes on Windows.
const readOnlyDirsUnenforced = windowsHost || process.getuid?.() === 0;
const resolveNpmInstallWithReadOnlyDir = (readOnlySegments: ReadonlyArray<string>) =>
Effect.gen(function* () {
const tempDir = yield* makeTempDir("t3-npm-read-only");
const link = linkIntoPackage(tempDir, "package-tool", [
"lib",
"node_modules",
"@example",
"package-tool",
]);
// An npm only the provider's PATH reaches, like a version manager's.
const providerNpm = NodePath.join(tempDir, "provider-bin", "npm");
writeExecutable(providerNpm);
const readOnlyDir = NodePath.join(tempDir, ...readOnlySegments);
NodeFS.chmodSync(readOnlyDir, 0o555);
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
NodeFS.chmodSync(readOnlyDir, 0o755);
NodeFS.rmSync(tempDir, { recursive: true, force: true });
}),
);
const views: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> = [];
const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {
binaryPath: link,
env: { PATH: [NodePath.dirname(providerNpm), NodePath.join(tempDir, "bin")].join(":") },
}).pipe(
Effect.provideService(HostProcess.Environment, { PATH: "" }),
Effect.provideService(
ChildProcessSpawner.ChildProcessSpawner,
stdoutSpawner((command, args) => {
views.push({ command, args });
return '"1.2.0"';
}),
),
);
const latestVersion = yield* capabilities.lookupLatestVersion ?? Effect.succeed(null);
return { tempDir, providerNpm, capabilities, latestVersion, views };
});

it.effect.skipIf(!symlinksSupported || readOnlyDirsUnenforced)(
"shows the npm command without offering to run it when the package scope is read-only",
() =>
Effect.gen(function* () {
const { tempDir, providerNpm, capabilities, latestVersion, views } =
yield* resolveNpmInstallWithReadOnlyDir(["lib", "node_modules", "@example"]);

expect(capabilities.update).toBeNull();
expect(capabilities.manualCommand).toBe(
`sudo npm install -g --prefix ${tempDir} --allow-scripts=@example/package-tool @example/package-tool@latest`,
);
expect(latestVersion).toBe("1.2.0");
expect(views).toEqual([
{
command: providerNpm,
args: [
"view",
"--global",
"--prefix",
tempDir,
"--json",
"@example/package-tool@latest",
"version",
],
},
]);
}),
);

it.effect.skipIf(!symlinksSupported || readOnlyDirsUnenforced)(
"shows the npm command without offering to run it when the prefix bin is read-only",
() =>
Effect.gen(function* () {
const { tempDir, capabilities } = yield* resolveNpmInstallWithReadOnlyDir(["bin"]);

expect(capabilities.update).toBeNull();
expect(capabilities.manualCommand).toBe(
`sudo npm install -g --prefix ${tempDir} --allow-scripts=@example/package-tool @example/package-tool@latest`,
);
}),
);

it("derives the npm prefix only from the global lib/node_modules layout", () => {
expect(
npmGlobalPrefixFromCommandPath(
Expand Down
41 changes: 40 additions & 1 deletion packages/provider-core/src/server/maintenanceResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -769,7 +769,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(
const npmPrefix = yield* resolveNpmGlobalPrefix(context, packageName);
if (npmPrefix) {
// npm 12 requires script approval to install native binaries; older npm only warns about the flag.
return makeProviderMaintenanceCapabilities({
const npm = makeProviderMaintenanceCapabilities({
provider: definition.provider,
packageName,
updateExecutable: "npm",
Expand All @@ -784,6 +784,23 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(
updateLockKey: `npm-global:${normalizeCommandPath(npmPrefix)}`,
env: context.env,
});
// Windows access() ignores directory ACLs, so only POSIX modes can predict npm's EACCES.
if (
npm.update &&
context.platform !== "win32" &&
(yield* npmPrefixDeniesWrite(npmPrefix, packageName))
) {
// The version lookup still needs the provider's npm and prefix.
const { lookupLatestVersion } = yield* withConfiguredNpmVersionLookup(npm);
return makeManualOnlyProviderMaintenanceCapabilities({
provider: definition.provider,
packageName,
installPath: context.realCommandPath,
manualCommand: `sudo ${npm.update.command}`,
...(lookupLatestVersion ? { lookupLatestVersion } : {}),
});
}
return npm;
}

const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath);
Expand Down Expand Up @@ -917,6 +934,28 @@ const resolveNpmGlobalPrefix = Effect.fn("resolveNpmGlobalPrefix")(function* (
return hasManifest ? shimDir : null;
});

// npm's reify renames the package within its parent and the bin links within `<prefix>/bin`.
// A missing directory is one npm creates, so only a definite denial counts.
const npmPrefixDeniesWrite = Effect.fn("npmPrefixDeniesWrite")(function* (
prefix: string,
packageName: string,
) {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const packageDir = path.join(prefix, "lib", "node_modules", ...packageName.split("/"));
const denials = yield* Effect.forEach(
[path.dirname(packageDir), path.join(prefix, "bin")],
(dir) =>
fileSystem.access(dir, { writable: true }).pipe(
Effect.as(false),
Effect.catchTags({
PlatformError: (error) => Effect.succeed(error.reason._tag === "PermissionDenied"),
}),
),
);
return denials.some(Boolean);
});

export function makePackageManagedProviderMaintenanceResolver(
definition: PackageManagedProviderMaintenanceDefinition,
): ProviderMaintenanceCapabilitiesResolver {
Expand Down
Loading