fix: complete managed runtime cutover

This commit is contained in:
LLLin000 2026-07-20 02:20:25 +08:00
parent 9a15f36485
commit b41b4c88b7
4 changed files with 1624 additions and 1468 deletions

File diff suppressed because it is too large Load diff

View file

@ -2293,7 +2293,7 @@ export class PaperForgeSettingTab extends PluginSettingTab {
0
);
try {
const env = Object.assign({}, process.env, {
const env = Object.assign(paperforgeEnrichedEnv(), {
PYTHONIOENCODING: "utf-8",
PYTHONUTF8: "1",
});

View file

@ -33,10 +33,7 @@ import {
paperforgeEnrichedEnv,
buildTargetedEnv,
} from "../services/python-bridge";
import {
ManagedRuntime,
resolveRuntimeCommand,
} from "../services/managed-runtime";
import { resolveRuntimeCommand } from "../services/managed-runtime";
import type { PluginForSecrets } from "../services/secret-storage";
import { getDisclosureState, toggleDisclosureState } from "../utils/disclosure";
import { extractZoteroKeyFromPath } from "../utils/zotero-path";
@ -67,22 +64,13 @@ export class PaperForgeStatusView extends ItemView {
_cachedItems: any[] | null = null;
_modeSubscribers: { event: string; ref: any }[] = [];
_resolvePython(): { path: string; args: string[] } {
_resolvePython(): { path: string; args: string[] } | null {
const plugin = ((this.app as any).plugins.plugins as any)["paperforge"];
const vp = (this.app.vault.adapter as any).basePath as string;
const rt = new ManagedRuntime({
runtimeDir: path.join(vp, ".paperforge-test-venv"),
pluginVersion: plugin?.manifest?.version || "0.0.0",
osPlatform: process.platform,
osArch: process.arch,
fs: fs as any,
execFile: execFile as any,
execFileSync: execFileSync as any,
});
const run = resolveRuntimeCommand(rt.current());
return run
? { path: run.command, args: [...run.args] }
: { path: "python", args: [] };
const mr = plugin?.getManagedRuntime?.();
if (!mr) return null;
const run = resolveRuntimeCommand(mr.current());
if (!run) return null;
return { path: run.command, args: [...run.args] };
}
_leafChangeTimer: ReturnType<typeof setTimeout> | null = null;
_ocrPrivacyShown = false;
@ -238,7 +226,9 @@ export class PaperForgeStatusView extends ItemView {
"paperforge"
] as any;
const pluginVer = plugin?.manifest?.version || "?";
const { path: pythonExe, args = [] } = this._resolvePython();
const py = this._resolvePython();
if (!py) return;
const { path: pythonExe, args = [] } = py;
Promise.resolve({ status: "ok", pyVersion: "?" }).then((result: any) => {
if (result.status === "not-installed") return;
const v = result.pyVersion || "";
@ -277,7 +267,12 @@ export class PaperForgeStatusView extends ItemView {
const plugin = ((this.app as any).plugins.plugins as any)[
"paperforge"
] as any;
const { path: pythonExe, args = [] } = this._resolvePython();
const py = this._resolvePython();
if (!py) {
this._fallbackFetchStats(quiet, vp, plugin);
return;
}
const { path: pythonExe, args = [] } = py;
(execFile as any)(
pythonExe,
[...args, "-m", "paperforge", "dashboard", "--json"],
@ -416,7 +411,17 @@ export class PaperForgeStatusView extends ItemView {
text: "No index \u2014 trying CLI...",
});
}
const { path: pythonExe, args = [] } = this._resolvePython();
const py = this._resolvePython();
if (!py) {
if (!this._cachedStats) {
this._metricsEl!.createEl("div", {
cls: "paperforge-status-error",
text: "Cannot reach PaperForge CLI.\nMake sure paperforge is installed and in your PATH.",
});
}
return;
}
const { path: pythonExe, args = [] } = py;
(execFile as any)(
pythonExe,
[...args, "-m", "paperforge", "status", "--json"],
@ -1027,19 +1032,22 @@ export class PaperForgeStatusView extends ItemView {
const pluginVer = plugin?.manifest?.version || "?";
let pyVer = this._paperforgeVersion;
if (!pyVer) {
try {
const vp = (this.app.vault.adapter as any).basePath as string;
const { path: pyExe, args = [] } = this._resolvePython();
const raw = execFileSync(
pyExe,
[...args, "-c", "import paperforge; print(paperforge.__version__)"],
{ cwd: vp, timeout: 5000, encoding: "utf-8", windowsHide: true }
).trim();
if (raw) {
pyVer = raw.startsWith("v") ? raw : "v" + raw;
this._paperforgeVersion = pyVer;
}
} catch {}
const py = this._resolvePython();
if (py) {
const { path: pyExe, args = [] } = py;
try {
const vp = (this.app.vault.adapter as any).basePath as string;
const raw = execFileSync(
pyExe,
[...args, "-c", "import paperforge; print(paperforge.__version__)"],
{ cwd: vp, timeout: 5000, encoding: "utf-8", windowsHide: true }
).trim();
if (raw) {
pyVer = raw.startsWith("v") ? raw : "v" + raw;
this._paperforgeVersion = pyVer;
}
} catch {}
}
}
pyVer = pyVer || "\u2014";
const runtimeOk = pyVer === "v" + pluginVer;
@ -1085,27 +1093,7 @@ export class PaperForgeStatusView extends ItemView {
);
// Issue #79: check configured flag; getSecret is async so use boolean status
const pfPlugin = (this.app as any).plugins?.plugins?.["paperforge"];
let tokenOk = !!pfPlugin?.settings?._paddleocr_configured;
if (!tokenOk) {
try {
const sysDir = plugin?.settings?.system_dir || "System";
const envPath = path.join(vp, sysDir, "PaperForge", ".env");
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, "utf-8");
const tokenMatch = envContent.match(
/^PADDLEOCR_API_TOKEN\s*=\s*(.+)$/m
);
tokenOk = !!(tokenMatch && tokenMatch[1] && tokenMatch[1].trim());
}
} catch (_) {}
}
if (!tokenOk) {
tokenOk = !!(
process.env.PADDLEOCR_API_TOKEN ||
process.env.PADDLEOCR_API_KEY ||
process.env.OCR_TOKEN
);
}
const tokenOk = !!pfPlugin?.settings?._paddleocr_configured;
this._renderSystemStatusRow(
statusGrid,
"OCR Token",
@ -2642,13 +2630,14 @@ export class PaperForgeStatusView extends ItemView {
const vp = (
this.app.vault.adapter as unknown as Record<string, unknown>
)["basePath"];
if (typeof vp === "string") {
const { path: pyExe, args = [] } = this._resolvePython();
spawn(pyExe, [...args, "-m", "paperforge", "doctor"], {
cwd: vp,
stdio: "inherit",
});
}
if (typeof vp !== "string") return;
const py = this._resolvePython();
if (!py) return;
const { path: pyExe, args = [] } = py;
spawn(pyExe, [...args, "-m", "paperforge", "doctor"], {
cwd: vp,
stdio: "inherit",
});
});
const retryBtn = actions.createEl("button", {
cls: "pf-btn-secondary",
@ -2794,7 +2783,13 @@ export class PaperForgeStatusView extends ItemView {
}
}
const { path: pythonExe, args: pyExtra = [] } = this._resolvePython();
const py = this._resolvePython();
if (!py) {
this._searchState = "backend-unavailable";
this._renderSearchState();
return;
}
const { path: pythonExe, args: pyExtra = [] } = py;
const deepFlag = mode === "retrieve" ? ["--deep"] : [];
// Issue #79: resolve memory credentials immediately before search/retrieve spawn
const searchEnv = await buildTargetedEnv(
@ -3149,7 +3144,14 @@ export class PaperForgeStatusView extends ItemView {
extraArgs = [...extraArgs, "--all"];
}
const cmdTimeout = a.needsFilter ? 60000 : a.needsKey ? 30000 : 600000;
const { path: pythonExe, args: pyExtra = [] } = this._resolvePython();
const py = this._resolvePython();
if (!py) {
this._showMessage("[!!] Runtime not available", "error");
new Notice("[!!] PaperForge runtime is not ready. Check settings.", 6000);
card.removeClass("running");
return;
}
const { path: pythonExe, args: pyExtra = [] } = py;
// Issue #79: resolve credentials for allowlisted command types immediately before launch
const actionEnv = await buildTargetedEnv(
{ app: this.app } as unknown as PluginForSecrets,

View file

@ -0,0 +1,146 @@
/**
* Vitest tests for PaperForgeStatusView._resolvePython() Issue #81.
*
* Verifies the method uses the plugin singleton's ManagedRuntime and never
* constructs its own or falls back to ambient `python`.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import "obsidian-test-mocks/jest-setup";
// Mock obsidian — minimal stubs needed to construct PaperForgeStatusView
vi.mock("obsidian", () => {
class MockComponent {
register() {}
registerEvent() {}
registerDomEvent() {}
registerInterval() {
return 0;
}
load() {}
unload() {}
}
class MockView extends MockComponent {
app: any;
contentEl: HTMLElement;
constructor(leaf: any) {
super();
this.contentEl = document.createElement("div");
}
}
return {
ItemView: MockView,
WorkspaceLeaf: class {},
View: MockView,
Component: MockComponent,
Notice: class {
noticeEl: HTMLElement;
constructor(msg: string) {
this.noticeEl = document.createElement("div");
this.noticeEl.textContent = msg;
}
},
Modal: class {
contentEl: HTMLElement;
constructor() {
this.contentEl = document.createElement("div");
}
open() {}
close() {}
},
MarkdownRenderer: { render: () => {} },
App: class {},
TFile: class {},
};
});
import { PaperForgeStatusView } from "../src/views/dashboard";
/** Mock health representing a ready runtime. */
function readyHealth(pythonPath: string) {
return { state: "ready" as const, pythonPath };
}
/** Mock health for any non-ready state. */
const notReadyHealth = { state: "needs_repair" as const, pythonPath: null };
describe("PaperForgeStatusView._resolvePython", () => {
let leaf: any;
let app: any;
function createView(): PaperForgeStatusView {
return new (PaperForgeStatusView as any)(leaf);
}
beforeEach(() => {
leaf = {};
app = { plugins: { plugins: {} } };
});
it("returns null when no paperforge plugin is registered", () => {
const view = createView();
(view as any).app = app;
expect((view as any)._resolvePython()).toBeNull();
});
it("returns null when the registered plugin has no getManagedRuntime", () => {
app.plugins.plugins["paperforge"] = {};
const view = createView();
(view as any).app = app;
expect((view as any)._resolvePython()).toBeNull();
});
it("returns null when the managed runtime is not ready", () => {
const mockRuntime = { current: () => notReadyHealth };
app.plugins.plugins["paperforge"] = {
getManagedRuntime: () => mockRuntime,
};
const view = createView();
(view as any).app = app;
expect((view as any)._resolvePython()).toBeNull();
});
it("returns the singleton command when the managed runtime is ready", () => {
const mockRuntime = {
current: () => readyHealth("/opt/paperforge/venv/bin/python3"),
};
app.plugins.plugins["paperforge"] = {
getManagedRuntime: () => mockRuntime,
};
const view = createView();
(view as any).app = app;
const result = (view as any)._resolvePython();
expect(result).toEqual({
path: "/opt/paperforge/venv/bin/python3",
args: [],
});
});
it("does NOT fall back to ambient 'python' when runtime is missing", () => {
// If there is no plugin with getManagedRuntime, result must be null
// rather than { path: "python", args: [] }
app.plugins.plugins["paperforge"] = {};
const view = createView();
(view as any).app = app;
const result = (view as any)._resolvePython();
expect(result).toBeNull();
// Specifically assert NOT { path: "python" }
expect(result).not.toEqual(expect.objectContaining({ path: "python" }));
});
it("passes args from the runtime command when present", () => {
// resolveRuntimeCommand always returns args: [] currently,
// but test the contract so it survives future changes
const mockRuntime = {
current: () => readyHealth("/usr/bin/python3.11"),
};
app.plugins.plugins["paperforge"] = {
getManagedRuntime: () => mockRuntime,
};
const view = createView();
(view as any).app = app;
const result = (view as any)._resolvePython();
expect(result).toHaveProperty("path", "/usr/bin/python3.11");
expect(result).toHaveProperty("args");
expect(Array.isArray(result!.args)).toBe(true);
});
});