feat: persist state under plugin-state/ (runner de-privilege)
CI / publish (push) Has been skipped
CI / build (push) Failing after 1m50s

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 <config_dir>/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 <config_dir>/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 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 23:27:33 +02:00
parent 14709d4062
commit e84cc89222
4 changed files with 113 additions and 4 deletions
+1 -1
View File
@@ -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.",
+14 -1
View File
@@ -19,8 +19,21 @@ export const hostConfigDir = (): string => {
return path.join(base, "punktfunk");
};
/** This plugin's private directory: `<config_dir>/rom-manager`. */
/**
* This plugin's private directory: `<config_dir>/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 (`<config_dir>/rom-manager`), migrated away from on first run (state.ts). */
export const legacyPluginDir = (): string =>
path.join(hostConfigDir(), "rom-manager");
/** `<config_dir>/rom-manager/config.json` — operator-editable, atomic-written. */
+35 -2
View File
@@ -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 (`<config_dir>/rom-manager`) to the runner-writable
* `<config_dir>/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 ?? {} };
+63
View File
@@ -0,0 +1,63 @@
// State persistence: the plugin writes under `<config_dir>/plugin-state/rom-manager`, and on first
// run migrates an operator config authored in the pre-0.2.1 `<config_dir>/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 <config_dir>/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" }]);
});
});