fix(runtime): align supported requirements and release hygiene

This commit is contained in:
Federico Jaramillo Martinez
2026-07-18 00:10:47 +02:00
parent 1f13bab58a
commit aca168a311
21 changed files with 77 additions and 1204 deletions
+13
View File
@@ -8,6 +8,7 @@ import {
doctorExitCode,
isCliEntrypoint,
launchdRuntimeDetails,
nodeVersionCheck,
regularFileExists,
serviceBackendForPlatform,
} from "./cli.js";
@@ -58,6 +59,18 @@ describe("commandWithVersionCheck", () => {
});
});
describe("nodeVersionCheck", () => {
it("checks the complete supported Node version with the resolved executable", () => {
process.env["SHELL"] = "/bin/bash";
const command = nodeVersionCheck();
expect(command).toContain("22.19.0");
expect(command).toContain("process.versions.node");
expect(command).toContain("\"$pi_web_probe_executable\"");
});
});
describe("agentCommandForChecks", () => {
it("reads the configured agent command for doctor checks", () => {
const dir = mkdtempSync(join(tmpdir(), "pi-web-cli-test-"));
+10 -6
View File
@@ -15,6 +15,7 @@ import {
type NativeServiceInstallFailure,
} from "./nativeServices/serviceInstall.js";
import {
minimumSupportedNodeVersion,
nativeServiceManagerRefs,
productionNativeServiceIds,
type NativeServiceBackend,
@@ -772,11 +773,14 @@ export function commandWithVersionCheck(command: string): string {
return `${found} && (${commandWord} --version 2>&1 || true)`;
}
function nodeVersionCheck(): string {
return [
commandCheck("node"),
"node -e \"const major = Number(process.versions.node.split('.')[0]); console.log(process.version); process.exit(major >= 22 ? 0 : 1);\"",
].join(" && ");
export function nodeVersionCheck(): string {
return nativeServicePrerequisiteShellCheck(detectServiceShell().name, {
id: "caller.node",
kind: "node-version",
command: "node",
minimumVersion: minimumSupportedNodeVersion,
description: `node >= ${minimumSupportedNodeVersion}`,
});
}
export function agentCommandForChecks(env: NodeJS.ProcessEnv = process.env): string {
@@ -787,7 +791,7 @@ function generalDoctorChecks(): Check[] {
const shell = serviceShellLabel();
const agentCommand = agentCommandForChecks();
return [
[`Caller login ${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())],
[`Caller login ${shell} can find node >= ${minimumSupportedNodeVersion}`, serviceShellCommand(nodeVersionCheck())],
[`Caller login ${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
[`Caller login ${shell} can find ${agentCommand}`, serviceShellCommand(commandWithVersionCheck(agentCommand))],
];
+5 -5
View File
@@ -100,7 +100,7 @@ describe("production native service planning", () => {
wants: [],
prerequisites: [
{ id: "sessiond.command.pi-web-sessiond", kind: "command-available", command: "pi-web-sessiond" },
{ id: "sessiond.node", kind: "node-version", command: "node", minimumMajor: 22 },
{ id: "sessiond.node", kind: "node-version", command: "node", minimumVersion: "22.19.0" },
],
},
{
@@ -111,7 +111,7 @@ describe("production native service planning", () => {
wants: ["sessiond"],
prerequisites: [
{ id: "web.command.pi-web-server", kind: "command-available", command: "pi-web-server" },
{ id: "web.node", kind: "node-version", command: "node", minimumMajor: 22 },
{ id: "web.node", kind: "node-version", command: "node", minimumVersion: "22.19.0" },
],
},
],
@@ -196,7 +196,7 @@ describe("production native service planning", () => {
namedCommandFailure: "command not found",
},
prerequisites: [
{ id: "sessiond.node", kind: "node-version", command: "node", minimumMajor: 22 },
{ id: "sessiond.node", kind: "node-version", command: "node", minimumVersion: "22.19.0" },
{ id: "sessiond.entrypoint", kind: "readable-file", path: "/package with space/sessiond's entry.js" },
],
});
@@ -324,7 +324,7 @@ describe("development native service planning", () => {
environment: { PI_WEB_CONFIG: "/tmp/config.json" },
workingDirectory: "/checkout with space",
prerequisites: [
{ id: "sessiond.node", kind: "node-version", minimumMajor: 22 },
{ id: "sessiond.node", kind: "node-version", minimumVersion: "22.19.0" },
{ id: "sessiond.command.npm", kind: "command-available", command: "npm" },
{ id: "sessiond.package-scripts", kind: "package-scripts", scripts: ["start:sessiond"] },
],
@@ -338,7 +338,7 @@ describe("development native service planning", () => {
after: ["sessiond"],
wants: ["sessiond"],
prerequisites: [
{ id: "uiDev.node", kind: "node-version", minimumMajor: 22 },
{ id: "uiDev.node", kind: "node-version", minimumVersion: "22.19.0" },
{ id: "uiDev.command.npm", kind: "command-available", command: "npm" },
{ id: "uiDev.command.bash", kind: "command-available", command: "bash" },
{ id: "uiDev.package-scripts", kind: "package-scripts", scripts: ["dev:web", "dev:client"] },
+5 -3
View File
@@ -1,3 +1,5 @@
export const minimumSupportedNodeVersion = "22.19.0";
export type NativeServiceBackendKind = "systemd" | "launchd";
export type NativeServiceMode = "production" | "development";
export type NativeServiceId = "sessiond" | "web" | "uiDev";
@@ -64,7 +66,7 @@ export type NativeServicePrerequisite =
id: string;
kind: "node-version";
command: "node";
minimumMajor: number;
minimumVersion: string;
description: string;
}
| {
@@ -571,8 +573,8 @@ function nodeRequirement(serviceId: NativeServiceId): NativeServicePrerequisite
id: `${serviceId}.node`,
kind: "node-version",
command: "node",
minimumMajor: 22,
description: "node >= 22 is available to the service shell",
minimumVersion: minimumSupportedNodeVersion,
description: `node >= ${minimumSupportedNodeVersion} is available to the service shell`,
};
}
+17 -2
View File
@@ -1,3 +1,4 @@
import { spawnSync } from "node:child_process";
import { describe, expect, it, vi } from "vitest";
import {
LaunchdNativeServiceProbe,
@@ -5,6 +6,7 @@ import {
SystemdNativeServiceProbe,
launchdProbePlist,
nativeServicePrerequisiteShellCheck,
nodeVersionCheckScript,
systemdRunArguments,
type LaunchdProbeFileSystem,
type ProbeCommandResult,
@@ -433,13 +435,26 @@ describe("probe service definitions", () => {
id: "sessiond.node",
kind: "node-version",
command: "node",
minimumMajor: 22,
description: "node >= 22",
minimumVersion: "22.19.0",
description: "node >= 22.19.0",
});
expect(check).toContain("\"$pi_web_probe_executable\" '-e'");
expect(check).toContain("22.19.0");
expect(check).not.toContain("&& node -e");
});
it.each([
{ version: "21.99.99", accepted: false },
{ version: "22.18.99", accepted: false },
{ version: "22.19.0", accepted: true },
{ version: "22.19.1", accepted: true },
{ version: "23.0.0", accepted: true },
])("checks the complete Node version for $version", ({ version, accepted }) => {
const result = spawnSync(process.execPath, ["-e", nodeVersionCheckScript("22.19.0"), version]);
expect(result.error).toBeUndefined();
expect(result.status).toBe(accepted ? 0 : 1);
});
it("requires bundled entrypoints to be readable regular files", () => {
const check = nativeServicePrerequisiteShellCheck("bash", {
id: "sessiond.entrypoint",
+8 -5
View File
@@ -434,10 +434,8 @@ export function nativeServicePrerequisiteShellCheck(shell: NativeServiceShellNam
switch (prerequisite.kind) {
case "command-available":
return externalExecutableShellCheck(shell, prerequisite.command);
case "node-version": {
const script = `const major=Number(process.versions.node.split('.')[0]);process.exit(major>=${String(prerequisite.minimumMajor)}?0:1)`;
return externalExecutableShellCheck(shell, "node", ["-e", script]);
}
case "node-version":
return externalExecutableShellCheck(shell, "node", ["-e", nodeVersionCheckScript(prerequisite.minimumVersion)]);
case "readable-file": {
const path = shellQuote(shell, prerequisite.path);
return `test -f ${path} && test -r ${path}`;
@@ -449,6 +447,11 @@ export function nativeServicePrerequisiteShellCheck(shell: NativeServiceShellNam
}
}
export function nodeVersionCheckScript(minimumVersion: string): string {
const encodedMinimum = JSON.stringify(minimumVersion);
return `const version=process.argv[1]??process.versions.node;console.log(process.version);const current=version.split('.').map(Number);const minimum=${encodedMinimum}.split('.').map(Number);const length=Math.max(current.length,minimum.length);let comparison=0;for(let index=0;index<length;index+=1){const left=current[index]??0;const right=minimum[index]??0;if(left!==right){comparison=left>right?1:-1;break}}process.exit(comparison>=0?0:1)`;
}
function externalExecutableShellCheck(
shell: NativeServiceShellName,
command: string,
@@ -518,7 +521,7 @@ function unsatisfiedDetail(prerequisite: NativeServicePrerequisite): string {
case "command-available":
return `${prerequisite.command} did not resolve to an external executable in the native service environment.`;
case "node-version":
return `node >= ${String(prerequisite.minimumMajor)} was not available in the native service environment.`;
return `node >= ${prerequisite.minimumVersion} was not available in the native service environment.`;
case "readable-file":
return `${prerequisite.path} was not a readable regular file in the native service environment.`;
case "package-scripts":
+3 -2
View File
@@ -5,7 +5,7 @@ import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { InMemoryCredentialStore, type AuthPrompt, type Credential } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OAuthFlowState } from "../../shared/apiTypes.js";
import { AuthService, type AuthChange, type AuthServiceLogger } from "./authService.js";
import { AuthService, createModelRuntimeForAgentDir, type AuthChange, type AuthServiceLogger } from "./authService.js";
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
const tempDirs: string[] = [];
@@ -251,7 +251,8 @@ describe("AuthService", () => {
it("stores credentials in the configured agent directory", async () => {
const agentDir = await tempAgentDir();
const auth = await AuthService.create({ agentDir });
const runtime = await createModelRuntimeForAgentDir(agentDir, false);
const auth = await AuthService.create({ runtime });
await auth.saveApiKey("anthropic", "sk-test");
+6 -2
View File
@@ -31,8 +31,12 @@ interface AuthChangeContext {
const noopLogger: AuthServiceLogger = { error() { /* no-op */ } };
export function createModelRuntimeForAgentDir(agentDir: string): Promise<ModelRuntime> {
return ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
export function createModelRuntimeForAgentDir(agentDir: string, allowModelNetwork?: boolean): Promise<ModelRuntime> {
return ModelRuntime.create({
authPath: join(agentDir, "auth.json"),
modelsPath: join(agentDir, "models.json"),
...(allowModelNetwork === undefined ? {} : { allowModelNetwork }),
});
}
export class AuthService {
@@ -79,7 +79,7 @@ export async function seedCredential(store: InMemoryCredentialStore, providerId:
* behavior (e.g. auth-loss warnings).
*/
export function createTestModelRuntime(credentials: CredentialStore = new InMemoryCredentialStore()): Promise<ModelRuntime> {
return ModelRuntime.create({ credentials });
return ModelRuntime.create({ credentials, modelsPath: null, allowModelNetwork: false });
}
/**