From e84cc89222909c1453f1f45ad1cfde1978000875 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 19 Jul 2026 23:27:33 +0200 Subject: [PATCH] feat: persist state under plugin-state/ (runner de-privilege) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The managed runner is de-privileged on Windows now (runs as LocalService), and %ProgramData%\punktfunk is locked read-only to it — so writing config/ cache straight under the config dir fails EPERM (proven on-glass). Move the plugin's state to /plugin-state/rom-manager, which `punktfunk-host plugins enable` grants the runner write on. On Linux the runner owns the config dir, so the path is writable there too — one path, no branch. Migrate a pre-0.2.1 config/cache from the old /rom-manager on first load (copy, guarded, best-effort) so an existing operator's roots + launch templates survive the move instead of silently resetting to defaults. Co-Authored-By: Claude Fable 5 --- package.json | 2 +- src/paths.ts | 15 ++++++++++- src/state.ts | 37 +++++++++++++++++++++++++-- test/state.test.ts | 63 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 test/state.test.ts diff --git a/package.json b/package.json index 7f65d72..49a1b9d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@punktfunk/plugin-rom-manager", - "version": "0.2.0", + "version": "0.2.1", "private": false, "type": "module", "description": "Punktfunk plugin: scans ROM directories, maps them to emulators, and reconciles them into the host game library as a provider — with a console-hosted web UI.", diff --git a/src/paths.ts b/src/paths.ts index 7e3d52e..ade451c 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -19,8 +19,21 @@ export const hostConfigDir = (): string => { return path.join(base, "punktfunk"); }; -/** This plugin's private directory: `/rom-manager`. */ +/** + * This plugin's private directory: `/plugin-state/rom-manager`. + * + * The state lives under `plugin-state/`, not straight under the config dir, because the managed + * runner is de-privileged on Windows (`NT AUTHORITY\LocalService`) and the config dir is locked + * read-only there — `punktfunk-host plugins enable` grants the runner write on exactly + * `plugin-state`. (Mirrors `@punktfunk/host`'s `pluginStateDir`; reimplemented locally like + * `hostConfigDir`, since we don't want to gate on an SDK version bump.) On Linux the runner owns + * the config dir, so the same path is writable with no special step. + */ export const pluginDir = (): string => + path.join(hostConfigDir(), "plugin-state", "rom-manager"); + +/** The pre-0.2.1 location (`/rom-manager`), migrated away from on first run (state.ts). */ +export const legacyPluginDir = (): string => path.join(hostConfigDir(), "rom-manager"); /** `/rom-manager/config.json` — operator-editable, atomic-written. */ diff --git a/src/state.ts b/src/state.ts index 7eb0ab7..ea9075c 100644 --- a/src/state.ts +++ b/src/state.ts @@ -8,7 +8,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { type Config, type RawConfig, resolveConfig } from "./config.js"; import type { DetectedEmulator } from "./emulators.js"; -import { cachePath, configPath, pluginDir } from "./paths.js"; +import { cachePath, configPath, legacyPluginDir, pluginDir } from "./paths.js"; import type { Artwork } from "./wire.js"; /** One cached art verdict: the resolved artwork (or `null` = nothing found), tagged with the provider @@ -32,9 +32,30 @@ export interface Cache { export const emptyCache = (): Cache => ({ art: {} }); -/** Create the plugin dir 0700 if absent (idempotent). */ +/** Create the plugin dir 0700 if absent (idempotent), migrating pre-0.2.1 state on first run. */ const ensureDir = (): void => { fs.mkdirSync(pluginDir(), { recursive: true, mode: 0o700 }); + migrateLegacyState(); +}; + +/** + * One-time move from the pre-0.2.1 location (`/rom-manager`) to the runner-writable + * `/plugin-state/rom-manager`. The old path sat directly under the config dir, which + * the de-privileged Windows runner (LocalService) can read but not write. Copy (not move): the old + * dir may be unwritable, and leaving it is harmless. Guarded — only fills a file the new dir does + * not already have — and best-effort: a failed copy just means the plugin starts from defaults. + */ +const migrateLegacyState = (): void => { + const legacy = legacyPluginDir(); + for (const name of ["config.json", "cache.json"]) { + const dst = path.join(pluginDir(), name); + const src = path.join(legacy, name); + try { + if (!fs.existsSync(dst) && fs.existsSync(src)) fs.copyFileSync(src, dst); + } catch { + // best-effort + } + } }; /** Refuse a group/world-writable config file (POSIX only; Windows config dir is DACL'd). */ @@ -63,6 +84,13 @@ const atomicWrite = (file: string, data: string): void => { /** Load the authored config (defaults filled). A missing file yields the empty config. */ export const loadConfig = (): Config => { + // Migrate the pre-0.2.1 location before the first read, or an existing operator config would + // look absent and silently reset to defaults. Best-effort — never block a load. + try { + ensureDir(); + } catch { + // ignore — fall through to the read below + } const file = configPath(); let raw = ""; try { @@ -86,6 +114,11 @@ export const saveRawConfig = (raw: RawConfig): void => { }; export const loadCache = (): Cache => { + try { + ensureDir(); // migrate pre-0.2.1 cache before the read (best-effort) + } catch { + // ignore + } try { const parsed = JSON.parse(fs.readFileSync(cachePath(), "utf8")) as Cache; return { ...parsed, art: parsed.art ?? {} }; diff --git a/test/state.test.ts b/test/state.test.ts new file mode 100644 index 0000000..a8ce0d3 --- /dev/null +++ b/test/state.test.ts @@ -0,0 +1,63 @@ +// State persistence: the plugin writes under `/plugin-state/rom-manager`, and on first +// run migrates an operator config authored in the pre-0.2.1 `/rom-manager` location — +// the write path moved when the runner was de-privileged, and a missed migration would silently +// reset the operator's settings. +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { loadConfig, saveRawConfig } from "../src/state.js"; + +let root: string; +let saved: string | undefined; + +beforeEach(() => { + saved = process.env.PUNKTFUNK_CONFIG_DIR; + root = fs.mkdtempSync(path.join(os.tmpdir(), "rm-state-")); + process.env.PUNKTFUNK_CONFIG_DIR = root; +}); +afterEach(() => { + if (saved === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR; + else process.env.PUNKTFUNK_CONFIG_DIR = saved; + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe("state location + migration", () => { + test("persists under plugin-state/rom-manager", () => { + saveRawConfig({ roots: [{ dir: "/games", platform: "snes" }] }); + expect( + fs.existsSync( + path.join(root, "plugin-state", "rom-manager", "config.json"), + ), + ).toBe(true); + }); + + test("migrates a pre-0.2.1 config from /rom-manager on first load", () => { + // An operator config authored in the OLD location. + const legacy = path.join(root, "rom-manager"); + fs.mkdirSync(legacy, { recursive: true }); + fs.writeFileSync( + path.join(legacy, "config.json"), + JSON.stringify({ roots: [{ dir: "/legacy/games", platform: "snes" }] }), + ); + // First load migrates it — the operator's roots survive rather than resetting to defaults. + const cfg = loadConfig(); + expect(cfg.roots).toEqual([{ dir: "/legacy/games", platform: "snes" }]); + expect( + fs.existsSync( + path.join(root, "plugin-state", "rom-manager", "config.json"), + ), + ).toBe(true); + }); + + test("does not clobber an existing new-location config with the legacy one", () => { + const legacy = path.join(root, "rom-manager"); + fs.mkdirSync(legacy, { recursive: true }); + fs.writeFileSync( + path.join(legacy, "config.json"), + JSON.stringify({ roots: [{ dir: "/old", platform: "snes" }] }), + ); + saveRawConfig({ roots: [{ dir: "/new", platform: "snes" }] }); // new location already authored + expect(loadConfig().roots).toEqual([{ dir: "/new", platform: "snes" }]); + }); +});