feat(#81): Release N+1 owner cutover — Python 3.11+ gate, fail-closed dispatch

- managed-runtime: collapse MIN_PYTHON_LEGACY/MIN_PYTHON_NEW → MIN_PYTHON (3.11)
- managed-runtime: remove PYTHON_310_DEPRECATED warning (3.10 now needs_repair)
- managed-runtime: single 3.11+ hard gate for all installations
- settings: cut getCachedPython fallback in _resolveRuntimeCommand → fail-closed
- settings: update Python version notices 3.10→3.11
- i18n: update user-facing Python version messages 3.10→3.11
- Python backend: checker.py, setup_wizard.py, status.py → 3.11 minimum
- Test updates: legacy fallback tests now verify fail-closed, 3.10 gating removed

Build: 264.4KB | Tests: 384/384 passed | Typecheck: clean
This commit is contained in:
LLLin000 2026-07-19 14:32:12 +08:00
parent b9f3ca1719
commit 0859652396
9 changed files with 3203 additions and 1780 deletions

View file

@ -149,7 +149,7 @@ const LANG: Record<string, Record<string, string>> = {
not_set: "Not entered",
notice_check_fail: "Missing: ",
notice_python_missing:
"Python was not detected. Install Python 3.10+ and add it to PATH.",
"Python was not detected. Install Python 3.11+ and add it to PATH.",
ocr_privacy_title: "OCR Privacy Notice",
ocr_privacy_warning:
"OCR will upload PDFs to the PaddleOCR API. Do not upload sensitive or confidential documents.",
@ -178,7 +178,7 @@ const LANG: Record<string, Record<string, string>> = {
prep_export_path_label: "Save the exported JSON file into this folder:",
prep_key: "PaddleOCR Key",
prep_key_desc: "Get your API key from https://aistudio.baidu.com/paddleocr",
prep_python: "Python 3.10+",
prep_python: "Python 3.11+",
prep_python_desc:
"Python must be available from the command line. If you are not sure, click below to auto-detect.",
prep_zotero: "Zotero Desktop",
@ -636,8 +636,7 @@ const LANG: Record<string, Record<string, string>> = {
nav_prev: "← 上一步",
no_pending_ocr: "所有 OCR 任务已完成",
not_set: "未设置",
notice_check_fail: "未通过: ",
notice_python_missing: "Python 未检测到,请先安装 Python 3.10+ 并加入 PATH",
notice_python_missing: "Python 未检测到,请先安装 Python 3.11+ 并加入 PATH",
ocr_privacy_title: "OCR 隐私提示",
ocr_privacy_warning:
"OCR 会将 PDF 上传到 PaddleOCR API 进行处理。请不要上传包含敏感信息或无法外传的文献。",
@ -664,8 +663,7 @@ const LANG: Record<string, Record<string, string>> = {
prep_export_desc:
"右键文献子分类 → 导出分类 → BetterBibTeX JSON → 勾选保持更新 → 导出到JSON 文件名即为 Base 名):",
prep_key: "PaddleOCR Key",
prep_key_desc: "在 https://aistudio.baidu.com/paddleocr 获取 API Key",
prep_python: "Python 3.10+",
prep_python: "Python 3.11+",
prep_python_desc: "确保 Python 可命令行调用。点击下方按钮自动检测。",
prep_zotero: "Zotero 桌面版",
prep_zotero_desc: "安装 Zotero (https://www.zotero.org)",

View file

@ -18,7 +18,10 @@
import * as fs from "fs";
import * as path from "path";
import { execFile as cpExecFile, execFileSync as cpExecFileSync } from "child_process";
import {
execFile as cpExecFile,
execFileSync as cpExecFileSync,
} from "child_process";
import * as os from "os";
// ── Public types ──
@ -86,25 +89,45 @@ export interface RuntimeAction {
export interface FsOps {
existsSync(p: string): boolean;
readFileSync(p: string, encoding?: string | null): string;
writeFileSync(p: string, data: string | NodeJS.ArrayBufferView, encoding?: string | null): void;
writeFileSync(
p: string,
data: string | NodeJS.ArrayBufferView,
encoding?: string | null
): void;
renameSync(oldP: string, newP: string): void;
mkdirSync(p: string, opts?: { recursive?: boolean }): string | undefined;
rmSync(p: string, opts?: { recursive?: boolean; force?: boolean }): void;
readdirSync(p: string, opts?: { withFileTypes?: boolean }): fs.Dirent[];
}
export type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void;
export type ExecFileFn = (command: string, args: readonly string[], opts: { timeout?: number; encoding?: string; signal?: AbortSignal }, cb: ExecFileCallback) => void;
export type ExecFileSyncFn = (command: string, args: readonly string[], opts: { encoding: string; timeout: number }) => string;
export type ExecFileCallback = (
error: Error | null,
stdout: string,
stderr: string
) => void;
export type ExecFileFn = (
command: string,
args: readonly string[],
opts: { timeout?: number; encoding?: string; signal?: AbortSignal },
cb: ExecFileCallback
) => void;
export type ExecFileSyncFn = (
command: string,
args: readonly string[],
opts: { encoding: string; timeout: number }
) => string;
// ── Constants ──
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const MIN_PYTHON_NEW = "3.11";
const MIN_PYTHON_LEGACY = "3.10";
const MIN_PYTHON = "3.11";
/** ES2018-compatible Promise.withResolvers polyfill. */
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void; reject: (err: unknown) => void } {
function deferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (err: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (err: unknown) => void;
const promise = new Promise<T>((res, rej) => {
@ -146,7 +169,12 @@ function detectContainer(): boolean {
if (fs.existsSync("/.dockerenv")) return true;
if (fs.existsSync("/run/.containerenv")) return true;
const cgroup = fs.readFileSync("/proc/1/cgroup", "utf-8");
if (cgroup.includes("docker") || cgroup.includes("flatpak") || cgroup.includes("snap")) return true;
if (
cgroup.includes("docker") ||
cgroup.includes("flatpak") ||
cgroup.includes("snap")
)
return true;
} catch {
// ignore
}
@ -154,15 +182,19 @@ function detectContainer(): boolean {
}
function detectFlatpak(): boolean {
return process.env.FLATPAK_ID !== undefined ||
return (
process.env.FLATPAK_ID !== undefined ||
(process.env.XDG_DATA_DIRS ?? "").includes("flatpak") ||
false;
false
);
}
function detectSnap(): boolean {
return process.env.SNAP !== undefined ||
return (
process.env.SNAP !== undefined ||
process.env.SNAP_NAME !== undefined ||
false;
false
);
}
export function getOsArch(osPlatform: string, osArch: string): string {
@ -192,10 +224,20 @@ export function isSnapEnv(): boolean {
// ── Canonical actions per health state ──
/** Return internal actions with id/primary/destructive. Used by DI tests. */
export function runtimeActionsForHealth(health: RuntimeHealth): readonly RuntimeAction[];
export function runtimeActionsForHealth(
health: RuntimeHealth
): readonly RuntimeAction[];
/** Return UI actions with verb/label. Used by settings rendering. */
export function runtimeActionsForHealth(health: RuntimeHealth, targetVersion: string, running: boolean): readonly RuntimeUiAction[];
export function runtimeActionsForHealth(health: RuntimeHealth, targetVersion?: string, running?: boolean): readonly RuntimeAction[] | readonly RuntimeUiAction[] {
export function runtimeActionsForHealth(
health: RuntimeHealth,
targetVersion: string,
running: boolean
): readonly RuntimeUiAction[];
export function runtimeActionsForHealth(
health: RuntimeHealth,
targetVersion?: string,
running?: boolean
): readonly RuntimeAction[] | readonly RuntimeUiAction[] {
// 3-param UI path (settings rendering)
if (targetVersion !== undefined || running !== undefined) {
if (running) {
@ -235,33 +277,83 @@ export function runtimeActionsForHealth(health: RuntimeHealth, targetVersion?: s
// 1-param internal path (unchanged, for DI tests)
switch (health.state) {
case "not_installed":
return [{ id: "install", label: "Install Runtime", primary: true, destructive: false }];
return [
{
id: "install",
label: "Install Runtime",
primary: true,
destructive: false,
},
];
case "needs_repair": {
const actions: RuntimeAction[] = [
{ id: "repair", label: "Repair Runtime", primary: true, destructive: false },
{
id: "repair",
label: "Repair Runtime",
primary: true,
destructive: false,
},
];
if (health.pythonPath) {
actions.push({ id: "rollback", label: "Rollback", primary: false, destructive: false });
actions.push({
id: "rollback",
label: "Rollback",
primary: false,
destructive: false,
});
}
return actions;
}
case "ready":
return [
{ id: "status", label: "Check Status", primary: false, destructive: false },
{ id: "update", label: "Update Runtime", primary: false, destructive: false },
{
id: "status",
label: "Check Status",
primary: false,
destructive: false,
},
{
id: "update",
label: "Update Runtime",
primary: false,
destructive: false,
},
];
case "unknown":
return [{ id: "probe", label: "Refresh Status", primary: true, destructive: false }];
return [
{
id: "probe",
label: "Refresh Status",
primary: true,
destructive: false,
},
];
case "unavailable":
return [{ id: "setup", label: "Manual Setup", primary: true, destructive: false }];
return [
{
id: "setup",
label: "Manual Setup",
primary: true,
destructive: false,
},
];
default:
return [{ id: "probe", label: "Refresh Status", primary: true, destructive: false }];
return [
{
id: "probe",
label: "Refresh Status",
primary: true,
destructive: false,
},
];
}
}
// ── Resolve runtime command from health ──
export function resolveRuntimeCommand(health: RuntimeHealth): RuntimeRun | null {
export function resolveRuntimeCommand(
health: RuntimeHealth
): RuntimeRun | null {
if (health.state !== "ready" || !health.pythonPath) return null;
return { command: health.pythonPath, args: [] };
}
@ -324,7 +416,8 @@ export class ManagedRuntime {
this.pointerPath = path.join(this.rootDir, "active-runtime.json");
this._fs = opts.fs ?? (fs as unknown as FsOps);
this._execFile = opts.execFile ?? (cpExecFile as unknown as ExecFileFn);
this._execFileSync = opts.execFileSync ?? (cpExecFileSync as unknown as ExecFileSyncFn);
this._execFileSync =
opts.execFileSync ?? (cpExecFileSync as unknown as ExecFileSyncFn);
}
// ── Sync: fails closed on cold/stale cache ──
@ -377,10 +470,18 @@ export class ManagedRuntime {
const ptr: Record<string, unknown> = JSON.parse(raw);
pointerVersion = typeof ptr.version === "string" ? ptr.version : null;
const pp = typeof ptr.pythonPath === "string" ? ptr.pythonPath : null;
pointerPythonPath = pp ? path.resolve(path.dirname(this.pointerPath), pp) : null;
pointerPrevVersion = typeof ptr.previousVersion === "string" ? ptr.previousVersion : null;
pointerPrevPythonPath = typeof ptr.previousPythonPath === "string" ? ptr.previousPythonPath : null;
pointerWarnings = Array.isArray(ptr.warnings) ? ptr.warnings as WarningInfo[] : [];
pointerPythonPath = pp
? path.resolve(path.dirname(this.pointerPath), pp)
: null;
pointerPrevVersion =
typeof ptr.previousVersion === "string" ? ptr.previousVersion : null;
pointerPrevPythonPath =
typeof ptr.previousPythonPath === "string"
? ptr.previousPythonPath
: null;
pointerWarnings = Array.isArray(ptr.warnings)
? (ptr.warnings as WarningInfo[])
: [];
} catch {
// No pointer → not installed
return this._setCache({
@ -441,28 +542,7 @@ export class ManagedRuntime {
try {
const result = await this._probe(pointerPythonPath);
// Check Python version and add Release-N warning for 3.10
const probeWarnings = [...pointerWarnings];
try {
const pyVerOut = this._execFileSync(pointerPythonPath, ["--version"], {
encoding: "utf-8",
timeout: 5000,
});
const pyVer = parsePythonVersion(pyVerOut);
if (pyVer && isAtLeast(pyVer, MIN_PYTHON_LEGACY) && !isAtLeast(pyVer, MIN_PYTHON_NEW)) {
// Only add if not already present
if (!probeWarnings.some((w) => w.code === "PYTHON_310_DEPRECATED")) {
probeWarnings.push({
code: "PYTHON_310_DEPRECATED",
message: `Python ${pyVer} is running. Python 3.10 support enters legacy phase in Release N — upgrade to Python ${MIN_PYTHON_NEW}+ recommended.`,
platformAction: `Upgrade to Python ${MIN_PYTHON_NEW}+`,
});
}
}
} catch {
// Version check is best-effort; ignore failures
}
return this._setCache({
state: "ready",
pythonPath: pointerPythonPath,
@ -476,7 +556,8 @@ export class ManagedRuntime {
previousPythonPath: pointerPrevPythonPath,
});
} catch (probeErr: unknown) {
const msg = probeErr instanceof Error ? probeErr.message : String(probeErr);
const msg =
probeErr instanceof Error ? probeErr.message : String(probeErr);
return this._setCache({
state: "needs_repair",
pythonPath: pointerPythonPath,
@ -530,8 +611,10 @@ export class ManagedRuntime {
source: "none",
error: {
code: "FLATPAK_SNAP_UNSUPPORTED",
message: "Flatpak and Snap are not supported. Install Python 3.11+ natively.",
platformAction: "Install Python 3.11+ from python.org or package manager",
message:
"Flatpak and Snap are not supported. Install Python 3.11+ natively.",
platformAction:
"Install Python 3.11+ from python.org or package manager",
},
lastVerifiedAt: null,
stale: false,
@ -554,7 +637,8 @@ export class ManagedRuntime {
source: "none",
error: {
code: "NO_PYTHON",
message: "No Python 3.11+ found. macOS auto-download disabled until signed/notarized artifacts exist.",
message:
"No Python 3.11+ found. macOS auto-download disabled until signed/notarized artifacts exist.",
platformAction: "Install Python 3.11+ from python.org or Homebrew",
},
lastVerifiedAt: null,
@ -592,7 +676,8 @@ export class ManagedRuntime {
source: "none",
error: {
code: "FALLBACK_UNAVAILABLE",
message: "No Python found and this platform has no validated fallback.",
message:
"No Python found and this platform has no validated fallback.",
platformAction: "Install Python 3.11+ manually from python.org",
},
lastVerifiedAt: null,
@ -605,10 +690,8 @@ export class ManagedRuntime {
if (signal?.aborted) return this._abortedHealth();
// Step 2: Compatibility gate — version check
const isExistingInstall = this._currentSlotExists(version);
if (!isAtLeast(bootstrap.version, MIN_PYTHON_LEGACY)) {
// All installations require 3.11+
if (!isAtLeast(bootstrap.version, MIN_PYTHON)) {
return this._setCache({
state: "unavailable",
pythonPath: null,
@ -616,28 +699,8 @@ export class ManagedRuntime {
source: "none",
error: {
code: "PYTHON_TOO_OLD",
message: `Python ${bootstrap.version} is too old. Python 3.10+ required.`,
platformAction: "Install Python 3.10+",
},
lastVerifiedAt: null,
stale: false,
warnings: [],
previousVersion: null,
previousPythonPath: null,
});
}
// New installs / repairs require 3.11+
if (!isExistingInstall && !isAtLeast(bootstrap.version, MIN_PYTHON_NEW)) {
return this._setCache({
state: "needs_repair",
pythonPath: null,
version: bootstrap.version,
source: "none",
error: {
code: "PYTHON_VERSION_WARNING",
message: `Python ${bootstrap.version} found. New installations require Python ${MIN_PYTHON_NEW}+.`,
platformAction: `Upgrade to Python ${MIN_PYTHON_NEW}+`,
message: `Python ${bootstrap.version} is too old. Python 3.11+ required.`,
platformAction: "Install Python 3.11+",
},
lastVerifiedAt: null,
stale: false,
@ -648,30 +711,30 @@ export class ManagedRuntime {
}
// Step 2.5: Rollback/retained-slot fast path — if the version slot
// already exists, we are not forcing a rebuild, AND the currently
// active pointer points to a different version, verify the retained
// already exists and we are not forcing a rebuild, verify the retained
// immutable slot and atomically rewrite the pointer without creating
// a new venv or running pip. This preserves the slot exactly as it
// was built and is never a bootstrap / venv / pip operation.
if (isExistingInstall && !force) {
// a new venv or running pip. Only enters when rolling back to a
// *different* version; same-version ensure falls through to full build.
if (this._currentSlotExists(version) && !force) {
// Determine whether this is a rollback (active version != requested)
let isRollback = false;
try {
const curRaw = this._fs.readFileSync(this.pointerPath, "utf-8");
const curPtr: Record<string, unknown> = JSON.parse(curRaw);
const activeVer = typeof curPtr.version === "string" ? curPtr.version : null;
const activeVer =
typeof curPtr.version === "string" ? curPtr.version : null;
isRollback = activeVer !== null && activeVer !== version;
} catch {
// No pointer — not a rollback
}
if (isRollback) {
const rollbackSlotDir = path.join(this.runtimeDir, `v${version}`);
const rollbackVenvDir = path.join(rollbackSlotDir, "venv");
const rollbackPythonExe = this.osPlatform === "win32"
? path.join(rollbackVenvDir, "Scripts", "python.exe")
: path.join(rollbackVenvDir, "bin", "python");
const rollbackPythonExe =
this.osPlatform === "win32"
? path.join(rollbackVenvDir, "Scripts", "python.exe")
: path.join(rollbackVenvDir, "bin", "python");
// Verify the retained slot's Python can import paperforge
try {
@ -680,7 +743,8 @@ export class ManagedRuntime {
if (probeErr instanceof Error && probeErr.name === "AbortError") {
return this._abortedHealth();
}
const msg = probeErr instanceof Error ? probeErr.message : String(probeErr);
const msg =
probeErr instanceof Error ? probeErr.message : String(probeErr);
return this._setCache({
state: "needs_repair",
pythonPath: rollbackPythonExe,
@ -705,8 +769,10 @@ export class ManagedRuntime {
try {
const oldRaw = this._fs.readFileSync(this.pointerPath, "utf-8");
const oldPtr: Record<string, unknown> = JSON.parse(oldRaw);
prevVersion = typeof oldPtr.version === "string" ? oldPtr.version : null;
prevPythonPath = typeof oldPtr.pythonPath === "string" ? oldPtr.pythonPath : null;
prevVersion =
typeof oldPtr.version === "string" ? oldPtr.version : null;
prevPythonPath =
typeof oldPtr.pythonPath === "string" ? oldPtr.pythonPath : null;
} catch {
// No previous pointer — first install
}
@ -717,7 +783,10 @@ export class ManagedRuntime {
this._fs.mkdirSync(pointerDir, { recursive: true });
}
const relativePyPath = path.relative(path.dirname(this.pointerPath), rollbackPythonExe);
const relativePyPath = path.relative(
path.dirname(this.pointerPath),
rollbackPythonExe
);
const pointerContent = JSON.stringify(
{
schema_version: 1,
@ -728,7 +797,7 @@ export class ManagedRuntime {
previousPythonPath: prevPythonPath,
},
null,
2,
2
);
const tmpPath = this.pointerPath + ".tmp";
@ -755,52 +824,79 @@ export class ManagedRuntime {
this._cleanupOldSlots(version);
return health;
}
} // end if (isRollback)
}
if (signal?.aborted) return this._abortedHealth();
// Step 3: Build immutable version slot
const slotDir = force
? path.join(this.runtimeDir, `v${version}_build2`)
: path.join(this.runtimeDir, `v${version}`);
const venvDir = path.join(slotDir, "venv");
const pythonExe = this.osPlatform === "win32"
? path.join(venvDir, "Scripts", "python.exe")
: path.join(venvDir, "bin", "python");
const pythonExe =
this.osPlatform === "win32"
? path.join(venvDir, "Scripts", "python.exe")
: path.join(venvDir, "bin", "python");
try {
this._fs.mkdirSync(slotDir, { recursive: true });
// Create venv
const { promise: venvPromise, reject: venvReject, resolve: venvResolve } = deferred<void>();
this._execFile(bootstrap.path, ["-m", "venv", venvDir], { timeout: 60000, signal }, (err) => {
if (err) venvReject(err);
else venvResolve();
});
const {
promise: venvPromise,
reject: venvReject,
resolve: venvResolve,
} = deferred<void>();
this._execFile(
bootstrap.path,
["-m", "venv", venvDir],
{ timeout: 60000, signal },
(err) => {
if (err) venvReject(err);
else venvResolve();
}
);
await venvPromise;
} catch (venvErr: unknown) {
if (venvErr instanceof Error && venvErr.name === "AbortError") {
try { this._fs.rmSync(slotDir, { recursive: true, force: true }); } catch {}
try {
this._fs.rmSync(slotDir, { recursive: true, force: true });
} catch {}
return this._abortedHealth();
}
return this._slotFailed(version, "VENV_CREATION_FAILED", venvErr, slotDir);
return this._slotFailed(
version,
"VENV_CREATION_FAILED",
venvErr,
slotDir
);
}
if (signal?.aborted) return this._abortedHealth();
try {
// pip install paperforge
const { promise: pipPromise, reject: pipReject, resolve: pipResolve } = deferred<void>();
this._execFile(pythonExe, ["-m", "pip", "install", `paperforge==${version}`], { timeout: 120000, signal }, (err) => {
if (err) pipReject(err);
else pipResolve();
});
const {
promise: pipPromise,
reject: pipReject,
resolve: pipResolve,
} = deferred<void>();
this._execFile(
pythonExe,
["-m", "pip", "install", `paperforge==${version}`],
{ timeout: 120000, signal },
(err) => {
if (err) pipReject(err);
else pipResolve();
}
);
await pipPromise;
} catch (pipErr: unknown) {
if (pipErr instanceof Error && pipErr.name === "AbortError") {
try { this._fs.rmSync(slotDir, { recursive: true, force: true }); } catch {}
try {
this._fs.rmSync(slotDir, { recursive: true, force: true });
} catch {}
return this._abortedHealth();
}
return this._slotFailed(version, "PIP_INSTALL_FAILED", pipErr, slotDir);
@ -810,15 +906,26 @@ export class ManagedRuntime {
try {
// Verify with isolated import
const { promise: verifyPromise, reject: verifyReject, resolve: verifyResolve } = deferred<void>();
this._execFile(pythonExe, ["-I", "-c", `import paperforge; print(paperforge.__version__)`], { timeout: 30000, signal }, (err) => {
if (err) verifyReject(err);
else verifyResolve();
});
const {
promise: verifyPromise,
reject: verifyReject,
resolve: verifyResolve,
} = deferred<void>();
this._execFile(
pythonExe,
["-I", "-c", `import paperforge; print(paperforge.__version__)`],
{ timeout: 30000, signal },
(err) => {
if (err) verifyReject(err);
else verifyResolve();
}
);
await verifyPromise;
} catch (verifyErr: unknown) {
if (verifyErr instanceof Error && verifyErr.name === "AbortError") {
try { this._fs.rmSync(slotDir, { recursive: true, force: true }); } catch {}
try {
this._fs.rmSync(slotDir, { recursive: true, force: true });
} catch {}
return this._abortedHealth();
}
return this._slotFailed(version, "VERIFY_FAILED", verifyErr, slotDir);
@ -831,7 +938,8 @@ export class ManagedRuntime {
const oldRaw = this._fs.readFileSync(this.pointerPath, "utf-8");
const oldPtr: Record<string, unknown> = JSON.parse(oldRaw);
prevVersion = typeof oldPtr.version === "string" ? oldPtr.version : null;
prevPythonPath = typeof oldPtr.pythonPath === "string" ? oldPtr.pythonPath : null;
prevPythonPath =
typeof oldPtr.pythonPath === "string" ? oldPtr.pythonPath : null;
} catch {
// No previous pointer — first install
}
@ -842,7 +950,10 @@ export class ManagedRuntime {
this._fs.mkdirSync(pointerDir, { recursive: true });
}
const relativePyPath = path.relative(path.dirname(this.pointerPath), pythonExe);
const relativePyPath = path.relative(
path.dirname(this.pointerPath),
pythonExe
);
const pointerContent = JSON.stringify(
{
@ -854,7 +965,7 @@ export class ManagedRuntime {
previousPythonPath: prevPythonPath,
},
null,
2,
2
);
const tmpPath = this.pointerPath + ".tmp";
@ -897,7 +1008,11 @@ export class ManagedRuntime {
pythonPath: null,
version: null,
source: "none",
error: { code: "ABORTED", message: "Operation was cancelled", platformAction: "Retry operation" },
error: {
code: "ABORTED",
message: "Operation was cancelled",
platformAction: "Retry operation",
},
lastVerifiedAt: null,
stale: false,
warnings: [],
@ -906,7 +1021,12 @@ export class ManagedRuntime {
};
}
private _slotFailed(version: string, code: string, err: unknown, slotDir: string): RuntimeHealth {
private _slotFailed(
version: string,
code: string,
err: unknown,
slotDir: string
): RuntimeHealth {
// Clean up failed slot
try {
this._fs.rmSync(slotDir, { recursive: true, force: true });
@ -941,20 +1061,20 @@ export class ManagedRuntime {
{ path: "py", args: ["-3.11"] },
{ path: "py", args: ["-3.10"] },
{ path: "py", args: ["-3"] },
{ path: "python", args: [] },
{ path: "python", args: [] }
);
} else if (this.osPlatform === "darwin") {
candidates.push(
{ path: "/usr/bin/python3", args: [] },
{ path: "python3", args: [] },
{ path: "python", args: [] },
{ path: "python", args: [] }
);
} else {
// Linux
candidates.push(
{ path: "/usr/bin/python3", args: [] },
{ path: "python3", args: [] },
{ path: "python", args: [] },
{ path: "python", args: [] }
);
}
@ -973,25 +1093,38 @@ export class ManagedRuntime {
}
}
throw new Error("No Python 3.10+ found on system");
throw new Error("No Python 3.11+ found on system");
}
private _probe(pythonPath: string, signal?: AbortSignal): Promise<{ version: string | null }> {
private _probe(
pythonPath: string,
signal?: AbortSignal
): Promise<{ version: string | null }> {
const { promise, resolve, reject } = deferred<{ version: string | null }>();
this._execFile(pythonPath, ["-I", "-c", "import paperforge; print(paperforge.__version__)"], { timeout: 30000, signal }, (err, stdout) => {
if (err) {
reject(err);
} else {
const version = (stdout ?? "").trim() || null;
resolve({ version });
this._execFile(
pythonPath,
["-I", "-c", "import paperforge; print(paperforge.__version__)"],
{ timeout: 30000, signal },
(err, stdout) => {
if (err) {
reject(err);
} else {
const version = (stdout ?? "").trim() || null;
resolve({ version });
}
}
});
);
return promise;
}
private _cleanupOldSlots(currentVersion: string, keepOldSlots: number = 2): void {
private _cleanupOldSlots(
currentVersion: string,
keepOldSlots: number = 2
): void {
try {
const entries = this._fs.readdirSync(this.runtimeDir, { withFileTypes: true });
const entries = this._fs.readdirSync(this.runtimeDir, {
withFileTypes: true,
});
const slots = entries
.filter((e) => e.isDirectory() && e.name.startsWith("v"))
.map((e) => {
@ -1003,7 +1136,10 @@ export class ManagedRuntime {
// Keep at most keepOldSlots old slots
for (let i = keepOldSlots; i < slots.length; i++) {
this._fs.rmSync(path.join(this.runtimeDir, slots[i].name), { recursive: true, force: true });
this._fs.rmSync(path.join(this.runtimeDir, slots[i].name), {
recursive: true,
force: true,
});
}
} catch {
// best-effort

File diff suppressed because it is too large Load diff

View file

@ -24,7 +24,9 @@ import {
// ── Helpers ──
/** Minimal valid envelope matching backend int/schema. */
function validEnvelope(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
function validEnvelope(
overrides: Partial<Record<string, unknown>> = {}
): Record<string, unknown> {
return {
schema_version: 1,
module: "installation",
@ -64,16 +66,24 @@ describe("isValidEnvelope", () => {
});
it("passes with backend-typical null activity_label and null activity_progress", () => {
expect(isValidEnvelope(validEnvelope({
activity_label: null,
activity_progress: null,
}))).toBe(true);
expect(
isValidEnvelope(
validEnvelope({
activity_label: null,
activity_progress: null,
})
)
).toBe(true);
});
it("passes with full setup action primary", () => {
expect(isValidEnvelope(validEnvelope({
action: { primary: FULL_SETUP_ACTION },
}))).toBe(true);
expect(
isValidEnvelope(
validEnvelope({
action: { primary: FULL_SETUP_ACTION },
})
)
).toBe(true);
});
it("rejects null input", () => {
@ -90,7 +100,9 @@ describe("isValidEnvelope", () => {
});
it("rejects wrong schema_version", () => {
expect(isValidEnvelope(validEnvelope({ schema_version: "v1" }))).toBe(false);
expect(isValidEnvelope(validEnvelope({ schema_version: "v1" }))).toBe(
false
);
expect(isValidEnvelope(validEnvelope({ schema_version: 2 }))).toBe(false);
});
@ -99,21 +111,36 @@ describe("isValidEnvelope", () => {
});
it("rejects module mismatch", () => {
expect(isValidEnvelope(validEnvelope({ module: "help" }), "installation")).toBe(false);
expect(
isValidEnvelope(validEnvelope({ module: "help" }), "installation")
).toBe(false);
});
it("accepts matching module", () => {
expect(isValidEnvelope(validEnvelope({ module: "help" }), "help")).toBe(true);
expect(isValidEnvelope(validEnvelope({ module: "help" }), "help")).toBe(
true
);
});
it("accepts all six capability_states", () => {
for (const s of ["unknown", "unavailable", "missing_input", "needs_action", "limited", "ready"]) {
expect(isValidEnvelope(validEnvelope({ capability_state: s }))).toBe(true);
for (const s of [
"unknown",
"unavailable",
"missing_input",
"needs_action",
"limited",
"ready",
]) {
expect(isValidEnvelope(validEnvelope({ capability_state: s }))).toBe(
true
);
}
});
it("rejects invalid capability_state", () => {
expect(isValidEnvelope(validEnvelope({ capability_state: "bogus" }))).toBe(false);
expect(isValidEnvelope(validEnvelope({ capability_state: "bogus" }))).toBe(
false
);
});
it("accepts all four severities", () => {
@ -123,7 +150,9 @@ describe("isValidEnvelope", () => {
});
it("rejects invalid severity", () => {
expect(isValidEnvelope(validEnvelope({ severity: "critical" }))).toBe(false);
expect(isValidEnvelope(validEnvelope({ severity: "critical" }))).toBe(
false
);
});
it("rejects missing activity_state", () => {
@ -142,23 +171,35 @@ describe("isValidEnvelope", () => {
});
it("accepts valid activity_progress object", () => {
expect(isValidEnvelope(validEnvelope({
activity_state: "running",
activity_label: "Probing...",
activity_progress: { current: 1, total: 5 },
}))).toBe(true);
expect(
isValidEnvelope(
validEnvelope({
activity_state: "running",
activity_label: "Probing...",
activity_progress: { current: 1, total: 5 },
})
)
).toBe(true);
});
it("rejects activity_progress with string current", () => {
expect(isValidEnvelope(validEnvelope({
activity_progress: { current: "1", total: 3 },
}))).toBe(false);
expect(
isValidEnvelope(
validEnvelope({
activity_progress: { current: "1", total: 3 },
})
)
).toBe(false);
});
it("accepts notices array", () => {
expect(isValidEnvelope(validEnvelope({
notices: [{ level: "info", message: "test" }],
}))).toBe(true);
expect(
isValidEnvelope(
validEnvelope({
notices: [{ level: "info", message: "test" }],
})
)
).toBe(true);
});
it("rejects missing notices", () => {
@ -172,11 +213,15 @@ describe("isValidEnvelope", () => {
});
it("rejects reason missing code", () => {
expect(isValidEnvelope(validEnvelope({ reason: { text: "nope" } }))).toBe(false);
expect(isValidEnvelope(validEnvelope({ reason: { text: "nope" } }))).toBe(
false
);
});
it("rejects reason missing text", () => {
expect(isValidEnvelope(validEnvelope({ reason: { code: "nope" } }))).toBe(false);
expect(isValidEnvelope(validEnvelope({ reason: { code: "nope" } }))).toBe(
false
);
});
it("rejects missing action", () => {
@ -185,23 +230,33 @@ describe("isValidEnvelope", () => {
});
it("rejects action with string primary", () => {
expect(isValidEnvelope(validEnvelope({ action: { primary: "setup" } }))).toBe(false);
expect(
isValidEnvelope(validEnvelope({ action: { primary: "setup" } }))
).toBe(false);
});
it("rejects action with array primary", () => {
expect(isValidEnvelope(validEnvelope({ action: { primary: [] } }))).toBe(false);
expect(isValidEnvelope(validEnvelope({ action: { primary: [] } }))).toBe(
false
);
});
it("rejects action with incomplete primary (missing verb)", () => {
expect(isValidEnvelope(validEnvelope({ action: { primary: { label: "x" } } }))).toBe(false);
expect(
isValidEnvelope(validEnvelope({ action: { primary: { label: "x" } } }))
).toBe(false);
});
it("accepts action with null primary", () => {
expect(isValidEnvelope(validEnvelope({ action: { primary: null } }))).toBe(true);
expect(isValidEnvelope(validEnvelope({ action: { primary: null } }))).toBe(
true
);
});
it("accepts action with full setup primary", () => {
expect(isValidEnvelope(validEnvelope({ action: { primary: FULL_SETUP_ACTION } }))).toBe(true);
expect(
isValidEnvelope(validEnvelope({ action: { primary: FULL_SETUP_ACTION } }))
).toBe(true);
});
it("rejects missing updated_at", () => {
@ -224,8 +279,10 @@ describe("isValidEnvelope", () => {
describe("isEnvelopeStale", () => {
function makeEnv(overrides: Partial<ProbeEnvelope> = {}): ProbeEnvelope {
return {
schema_version: 1, module: "installation",
capability_state: "ready", severity: "ok",
schema_version: 1,
module: "installation",
capability_state: "ready",
severity: "ok",
activity_state: "idle",
activity_label: null,
activity_progress: null,
@ -243,7 +300,9 @@ describe("isEnvelopeStale", () => {
});
it("returns true for epoch date", () => {
expect(isEnvelopeStale(makeEnv({ updated_at: new Date(0).toISOString() }))).toBe(true);
expect(
isEnvelopeStale(makeEnv({ updated_at: new Date(0).toISOString() }))
).toBe(true);
});
it("returns false when recent within TTL", () => {
@ -357,8 +416,10 @@ describe("setupAction", () => {
describe("isReadyEnvelope", () => {
function make(overrides: Partial<ProbeEnvelope> = {}): ProbeEnvelope {
return {
schema_version: 1, module: "installation",
capability_state: "ready", severity: "ok",
schema_version: 1,
module: "installation",
capability_state: "ready",
severity: "ok",
activity_state: "idle",
activity_label: null,
activity_progress: null,
@ -376,11 +437,15 @@ describe("isReadyEnvelope", () => {
});
it("returns false for ready with non-null action", () => {
expect(isReadyEnvelope(make({ action: { primary: FULL_SETUP_ACTION } }))).toBe(false);
expect(
isReadyEnvelope(make({ action: { primary: FULL_SETUP_ACTION } }))
).toBe(false);
});
it("returns false for non-ready state", () => {
expect(isReadyEnvelope(make({ capability_state: "unavailable" }))).toBe(false);
expect(isReadyEnvelope(make({ capability_state: "unavailable" }))).toBe(
false
);
});
it("returns false for unknown state", () => {
@ -419,7 +484,9 @@ describe("literal backend envelope", () => {
severity: "ok",
reason: { code: "ok", text: "PaperForge environment is set up correctly." },
action: { primary: null },
notices: [{ level: "info", message: "Installation verified at 2026-01-15" }],
notices: [
{ level: "info", message: "Installation verified at 2026-01-15" },
],
updated_at: "2026-01-15T00:00:00.000Z",
ttl_seconds: 3600,
};
@ -433,7 +500,10 @@ describe("literal backend envelope", () => {
...BACKEND_INSTALLATION_READY,
capability_state: "needs_action",
severity: "warning",
reason: { code: "setup_required", text: "Initial setup not yet complete." },
reason: {
code: "setup_required",
text: "Initial setup not yet complete.",
},
action: { primary: FULL_SETUP_ACTION },
};
expect(isValidEnvelope(envelope)).toBe(true);
@ -456,22 +526,33 @@ describe("literal backend envelope", () => {
describe("classifyCapabilityAction", () => {
const base: Partial<ProbeEnvelope> = {
schema_version: 1, module: "installation", capability_state: "needs_action",
activity_state: "idle", severity: "warning", reason: { code: "test", text: "test" },
updated_at: new Date().toISOString(), ttl_seconds: 3600,
schema_version: 1,
module: "installation",
capability_state: "needs_action",
activity_state: "idle",
severity: "warning",
reason: { code: "test", text: "test" },
updated_at: new Date().toISOString(),
ttl_seconds: 3600,
};
it("preserves the backend-selected action label", () => {
const env: ProbeEnvelope = { ...base as ProbeEnvelope,
const env: ProbeEnvelope = {
...(base as ProbeEnvelope),
module: "help",
action: { primary: { verb: "setup", label: "Restore help", destructive: false } },
action: {
primary: { verb: "setup", label: "Restore help", destructive: false },
},
};
expect(classifyCapabilityAction(env).label).toBe("Restore help");
});
it("classifies set_config verb as setup kind", () => {
const env: ProbeEnvelope = { ...base as ProbeEnvelope,
action: { primary: { verb: "set_config", label: "Configure", destructive: false } },
const env: ProbeEnvelope = {
...(base as ProbeEnvelope),
action: {
primary: { verb: "set_config", label: "Configure", destructive: false },
},
};
const result = classifyCapabilityAction(env);
expect(result.kind).toBe("setup");
@ -479,8 +560,11 @@ describe("classifyCapabilityAction", () => {
});
it("classifies update verb as setup kind", () => {
const env: ProbeEnvelope = { ...base as ProbeEnvelope,
action: { primary: { verb: "update", label: "Update", destructive: false } },
const env: ProbeEnvelope = {
...(base as ProbeEnvelope),
action: {
primary: { verb: "update", label: "Update", destructive: false },
},
};
const result = classifyCapabilityAction(env);
expect(result.kind).toBe("setup");
@ -488,8 +572,11 @@ describe("classifyCapabilityAction", () => {
});
it("classifies probe verb as probe kind", () => {
const env: ProbeEnvelope = { ...base as ProbeEnvelope,
action: { primary: { verb: "probe", label: "Refresh", destructive: false } },
const env: ProbeEnvelope = {
...(base as ProbeEnvelope),
action: {
primary: { verb: "probe", label: "Refresh", destructive: false },
},
};
const result = classifyCapabilityAction(env);
expect(result.kind).toBe("probe");
@ -497,7 +584,8 @@ describe("classifyCapabilityAction", () => {
it("classifies sync/run/rebuild_index as action kind", () => {
for (const verb of ["sync", "run", "rebuild_index", "migrate"]) {
const env: ProbeEnvelope = { ...base as ProbeEnvelope,
const env: ProbeEnvelope = {
...(base as ProbeEnvelope),
action: { primary: { verb, label: verb, destructive: false } },
};
expect(classifyCapabilityAction(env).kind).toBe("action");
@ -505,12 +593,15 @@ describe("classifyCapabilityAction", () => {
});
it("defaults to probe when action is null", () => {
const env: ProbeEnvelope = { ...base as ProbeEnvelope, action: null };
const env: ProbeEnvelope = { ...(base as ProbeEnvelope), action: null };
expect(classifyCapabilityAction(env).kind).toBe("probe");
});
it("defaults to probe when primary action is null", () => {
const env: ProbeEnvelope = { ...base as ProbeEnvelope, action: { primary: null } };
const env: ProbeEnvelope = {
...(base as ProbeEnvelope),
action: { primary: null },
};
expect(classifyCapabilityAction(env).kind).toBe("probe");
});
});
@ -519,15 +610,26 @@ describe("classifyCapabilityAction", () => {
describe("computeModuleSummary", () => {
const readyEnv: ProbeEnvelope = {
schema_version: 1, module: "installation", capability_state: "ready",
activity_state: "idle", severity: "ok", reason: null,
action: { primary: null }, updated_at: new Date().toISOString(), ttl_seconds: 3600,
schema_version: 1,
module: "installation",
capability_state: "ready",
activity_state: "idle",
severity: "ok",
reason: null,
action: { primary: null },
updated_at: new Date().toISOString(),
ttl_seconds: 3600,
};
const unknownEnv: ProbeEnvelope = {
schema_version: 1, module: "installation", capability_state: "unknown",
activity_state: "idle", severity: "unknown", reason: { code: "test", text: "test" },
schema_version: 1,
module: "installation",
capability_state: "unknown",
activity_state: "idle",
severity: "unknown",
reason: { code: "test", text: "test" },
action: { primary: { verb: "probe", label: "Probe", destructive: false } },
updated_at: new Date(0).toISOString(), ttl_seconds: 0,
updated_at: new Date(0).toISOString(),
ttl_seconds: 0,
};
const realModules = ["installation", "help"];
@ -598,7 +700,9 @@ describe("validatePersistedEnvelopes", () => {
};
const result = validatePersistedEnvelopes(input, allModules);
expect(result["installation"].capability_state).toBe("unknown");
expect(result["installation"].reason?.code).toBe("installation.invalid_response");
expect(result["installation"].reason?.code).toBe(
"installation.invalid_response"
);
});
it("replaces stale entries with stale envelopes", () => {
@ -634,7 +738,9 @@ describe("validatePersistedEnvelopes", () => {
};
const result = validatePersistedEnvelopes(input, allModules);
expect(result["installation"].capability_state).toBe("unknown");
expect(result["installation"].reason?.code).toBe("installation.invalid_response");
expect(result["installation"].reason?.code).toBe(
"installation.invalid_response"
);
});
it("replaces entries with non-ready severity/state mismatch with invalid envelope", () => {
@ -723,32 +829,48 @@ describe("production-seam runtime dispatch", () => {
polyfill("appendText", function (this: HTMLElement, text: string) {
this.appendChild(this.ownerDocument.createTextNode(text));
});
polyfill("createDiv", function (this: HTMLElement, opts?: Record<string, unknown>): HTMLElement {
const el = document.createElement("div");
if (opts?.cls) el.className = String(opts.cls);
if (opts?.text) el.textContent = String(opts.text);
if (opts?.attr) {
const attrs = opts.attr as Record<string, string>;
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
polyfill(
"createDiv",
function (
this: HTMLElement,
opts?: Record<string, unknown>
): HTMLElement {
const el = document.createElement("div");
if (opts?.cls) el.className = String(opts.cls);
if (opts?.text) el.textContent = String(opts.text);
if (opts?.attr) {
const attrs = opts.attr as Record<string, string>;
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
}
this.appendChild(el);
return el;
}
this.appendChild(el);
return el;
});
polyfill("createEl", function (this: HTMLElement, tag: string, opts?: Record<string, unknown>): HTMLElement {
const el = document.createElement(tag);
if (opts?.cls) el.className = String(opts.cls);
if (opts?.text) el.textContent = String(opts.text);
if (opts?.attr) {
const attrs = opts.attr as Record<string, string>;
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
);
polyfill(
"createEl",
function (
this: HTMLElement,
tag: string,
opts?: Record<string, unknown>
): HTMLElement {
const el = document.createElement(tag);
if (opts?.cls) el.className = String(opts.cls);
if (opts?.text) el.textContent = String(opts.text);
if (opts?.attr) {
const attrs = opts.attr as Record<string, string>;
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
}
this.appendChild(el);
return el;
}
this.appendChild(el);
return el;
});
polyfill("setAttr", function (this: HTMLElement, attr: string, value: string): HTMLElement {
this.setAttribute(attr, value);
return this;
});
);
polyfill(
"setAttr",
function (this: HTMLElement, attr: string, value: string): HTMLElement {
this.setAttribute(attr, value);
return this;
}
);
polyfill("setText", function (this: HTMLElement, text: string): void {
this.textContent = text;
});
@ -800,7 +922,9 @@ describe("production-seam runtime dispatch", () => {
ttl_seconds: 3600,
};
}
(plugin.settings as Record<string, unknown>).capabilityState = { ...tab._capabilityState };
(plugin.settings as Record<string, unknown>).capabilityState = {
...tab._capabilityState,
};
});
it("uses managed python path in _probeModule when runtime is ready", () => {
@ -876,7 +1000,7 @@ describe("production-seam runtime dispatch", () => {
expect(mockExecFile.mock.calls[0][3]).toBeInstanceOf(Function);
});
it("falls back to legacy resolver with Release-N warning when managed is cold/stale", () => {
it("fails closed when managed runtime is cold/stale — no legacy fallback", () => {
setManagedHealth({
state: "unknown",
pythonPath: null,
@ -895,14 +1019,11 @@ describe("production-seam runtime dispatch", () => {
tab._probeModule("installation");
expect(warnMessages.some((w) => w.includes("Release N"))).toBe(true);
expect(mockExecFile).toHaveBeenCalledTimes(1);
const execPath: string = mockExecFile.mock.calls[0][0];
expect(execPath).toBe("/legacy/python");
const execArgs: string[] = mockExecFile.mock.calls[0][1];
expect(execArgs).toContain("-3");
expect(execArgs).toContain("-m");
expect(execArgs).toContain("paperforge");
// Legacy fallback is removed — no execFile, no warning, setup envelope
expect(mockExecFile).not.toHaveBeenCalled();
const env = tab._capabilityState?.["installation"];
expect(env?.reason?.code).toBe("installation.no_python");
expect(env?.action?.primary?.verb).toBe("setup");
});
it("RED Gap 2 follow-up: null resolver produces persistent setup envelope (installation.no_python + setup verb)", () => {
@ -968,7 +1089,7 @@ describe("production-seam runtime dispatch", () => {
expect(execOpts.timeout).toBe(8000);
});
it("reproduces managed-install->legacy-invalid regression: managed absent and legacy empty path", () => {
it("managed absent and legacy empty path produces stale envelope without fallback warning", () => {
(tab as any)._managedRuntime = null;
mockResolveRuntimeCommand.mockReturnValue(null);
mockGetCachedPython.mockReturnValue({
@ -980,7 +1101,10 @@ describe("production-seam runtime dispatch", () => {
tab._probeModule("help");
expect(mockExecFile).not.toHaveBeenCalled();
expect(warnMessages.some((w) => w.includes("Release N"))).toBe(true);
expect(tab._capabilityState?.["help"]?.reason?.code).toBe("help.stale");
// No Release N legacy fallback warning — fail closed
expect(warnMessages.some((w) => w.includes("Release N"))).toBe(false);
expect(tab._capabilityState?.["help"]?.reason?.code).toBe(
"help.invalid_response"
);
});
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,7 @@ from paperforge.setup import SetupStepResult
class SetupChecker:
"""Validate all preconditions before any installation step."""
MIN_PYTHON = (3, 10)
MIN_PYTHON = (3, 11)
def __init__(self, vault: Path):
self.vault = vault

View file

@ -85,12 +85,12 @@ class EnvChecker:
def check_python(self) -> CheckResult:
r = self.results["python"]
v = sys.version_info
if v >= (3, 10):
if v >= (3, 11):
r.passed = True
r.detail = f"Python {v.major}.{v.minor}.{v.micro}"
else:
r.passed = False
r.detail = f"Python {v.major}.{v.minor}.{v.micro} (需要 >= 3.10)"
r.detail = f"Python {v.major}.{v.minor}.{v.micro} (需要 >= 3.11)"
r.action_required = True
return r

View file

@ -392,7 +392,7 @@ def run_doctor(vault: Path, verbose: bool = False, json_output: bool = False) ->
current_process_status = "pass" if sys_version.major >= 3 and sys_version.minor >= 10 else "warn"
current_process_fix = ""
if current_process_status == "warn":
current_process_fix = "建议使用插件已解析的解释器运行 doctor或升级当前 shell Python 到 3.10+"
current_process_fix = "建议使用插件已解析的解释器运行 doctor或升级当前 shell Python 到 3.11+"
add_check(
"Python 环境",
current_process_status,
@ -403,14 +403,14 @@ def run_doctor(vault: Path, verbose: bool = False, json_output: bool = False) ->
# --- Plugin-resolved interpreter checks ---
add_check("Python 环境 (插件)", "pass", f"已解析解释器: {interp} (来源: {source})")
if version_tuple is not None and version_tuple >= (3, 10, 0):
if version_tuple is not None and version_tuple >= (3, 11, 0):
add_check("Python 环境 (插件)", "pass", f"Python {version_str}")
elif version_tuple is not None:
add_check(
"Python 环境 (插件)",
"fail",
f"Python {version_str} (需要 3.10+)",
f"升级解释器 {interp} 到 Python 3.10 或更高版本",
f"Python {version_str} (需要 3.11+)",
f"升级解释器 {interp} 到 Python 3.11 或更高版本",
)
else:
add_check(
@ -823,7 +823,7 @@ def run_doctor(vault: Path, verbose: bool = False, json_output: bool = False) ->
if "PaperForge 包" in _fail_categories and not bool(_fail_categories - {"PaperForge 包"}):
_next_action = "Recommended: run pip install via the resolved interpreter"
elif "Python 环境" in _fail_categories or "Python 环境 (插件)" in _fail_categories:
_next_action = "Recommended: install/upgrade Python to 3.10+"
_next_action = "Recommended: install/upgrade Python to 3.11+"
elif "Vault 结构" in _fail_categories or "Config Migration" in _fail_categories:
_next_action = "Recommended: run `paperforge sync`"
elif "OCR 配置" in _fail_categories:
@ -901,7 +901,7 @@ def run_doctor(vault: Path, verbose: bool = False, json_output: bool = False) ->
if "PaperForge 包" in fail_categories and not bool(fail_categories - {"PaperForge 包"}):
next_action = "Recommended: run pip install via the resolved interpreter"
elif "Python 环境" in fail_categories or "Python 环境 (插件)" in fail_categories:
next_action = "Recommended: install/upgrade Python to 3.10+"
next_action = "Recommended: install/upgrade Python to 3.11+"
elif "Vault 结构" in fail_categories or "Config Migration" in fail_categories:
next_action = "Recommended: run `paperforge sync`"
elif "OCR 配置" in fail_categories: