Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e78df91925 | |||
| e84cc89222 | |||
| 14709d4062 |
+37
-19
@@ -1,24 +1,33 @@
|
|||||||
# CI for @punktfunk/plugin-rom-manager (Gitea Actions). Installs resolve `@punktfunk/*` and `@unom/*`
|
# CI for @punktfunk/plugin-rom-manager (Gitea Actions). Mirrors the main repo's sdk-publish.yml:
|
||||||
# (the SPA's design system) from the Gitea npm registry via the bunfig scope maps; `effect` comes from
|
# ubuntu-24.04 runner + the oven/bun:1 container, auth via the shared REGISTRY_TOKEN secret.
|
||||||
# npm. The GITEA_NPM_TOKEN secret authenticates registry reads. Publish runs on a `v*` tag.
|
# Installs resolve @punktfunk/* + @unom/* (the SPA's design system) from the Gitea registry via the
|
||||||
|
# bunfig scope maps; `effect` comes from npm. Publish runs on a `v*` tag.
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ["v*"]
|
tags: ['v*']
|
||||||
pull_request:
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
|
container:
|
||||||
|
image: oven/bun:1
|
||||||
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
|
# oven/bun's slim base ships neither git nor a CA bundle — actions/checkout's HTTPS fetch needs both.
|
||||||
|
- name: Install git + CA certs
|
||||||
|
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: oven-sh/setup-bun@v2
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
- name: Registry auth
|
- name: Registry auth
|
||||||
run: echo "//git.unom.io/api/packages/unom/npm/:_authToken=${{ secrets.GITEA_NPM_TOKEN }}" > ~/.npmrc
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
test -n "$TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; }
|
||||||
|
printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$TOKEN" > "$HOME/.npmrc"
|
||||||
- name: Install
|
- name: Install
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
- name: Lint & format
|
- name: Lint & format
|
||||||
@@ -34,22 +43,31 @@ jobs:
|
|||||||
run: bunx tsc --noEmit
|
run: bunx tsc --noEmit
|
||||||
- name: Sanity — plugin default export is a valid PluginDef
|
- name: Sanity — plugin default export is a valid PluginDef
|
||||||
run: |
|
run: |
|
||||||
bun -e 'import p from "./dist/index.js"; const d = p.default; if (d?.name !== "rom-manager" || typeof d?.main !== "function") { console.error("bad default export", d); process.exit(1); } console.log("ok:", d.name)'
|
bun -e 'import plugin from "./dist/index.js"; if (plugin?.name !== "rom-manager" || typeof plugin?.main !== "function") { console.error("bad default export", plugin); process.exit(1); } console.log("ok:", plugin.name)'
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs: build
|
needs: build
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
|
container:
|
||||||
|
image: oven/bun:1
|
||||||
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
|
- name: Install git + CA certs
|
||||||
|
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: oven-sh/setup-bun@v2
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
- name: Registry auth
|
- name: Registry auth
|
||||||
run: echo "//git.unom.io/api/packages/unom/npm/:_authToken=${{ secrets.GITEA_NPM_TOKEN }}" > ~/.npmrc
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
test -n "$TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; }
|
||||||
|
printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$TOKEN" > "$HOME/.npmrc"
|
||||||
- name: Install
|
- name: Install
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
- name: Build (backend + SPA)
|
- name: Tag matches package version
|
||||||
run: bun run build:all
|
run: |
|
||||||
- name: Publish to the Gitea npm registry
|
TAG="${GITHUB_REF_NAME#v}"
|
||||||
run: npm publish --registry https://git.unom.io/api/packages/unom/npm/
|
PKG="$(node -p "require('./package.json').version")"
|
||||||
|
test "$TAG" = "$PKG" || { echo "tag $GITHUB_REF_NAME != package version $PKG"; exit 1; }
|
||||||
|
- name: Publish (prepublishOnly builds backend + SPA)
|
||||||
|
run: bun publish
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@punktfunk/plugin-rom-manager",
|
"name": "@punktfunk/plugin-rom-manager",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"private": false,
|
"private": false,
|
||||||
"type": "module",
|
"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.",
|
"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
@@ -19,8 +19,21 @@ export const hostConfigDir = (): string => {
|
|||||||
return path.join(base, "punktfunk");
|
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 =>
|
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");
|
path.join(hostConfigDir(), "rom-manager");
|
||||||
|
|
||||||
/** `<config_dir>/rom-manager/config.json` — operator-editable, atomic-written. */
|
/** `<config_dir>/rom-manager/config.json` — operator-editable, atomic-written. */
|
||||||
|
|||||||
+35
-2
@@ -8,7 +8,7 @@ import * as fs from "node:fs";
|
|||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { type Config, type RawConfig, resolveConfig } from "./config.js";
|
import { type Config, type RawConfig, resolveConfig } from "./config.js";
|
||||||
import type { DetectedEmulator } from "./emulators.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";
|
import type { Artwork } from "./wire.js";
|
||||||
|
|
||||||
/** One cached art verdict: the resolved artwork (or `null` = nothing found), tagged with the provider
|
/** 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: {} });
|
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 => {
|
const ensureDir = (): void => {
|
||||||
fs.mkdirSync(pluginDir(), { recursive: true, mode: 0o700 });
|
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). */
|
/** 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. */
|
/** Load the authored config (defaults filled). A missing file yields the empty config. */
|
||||||
export const loadConfig = (): 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();
|
const file = configPath();
|
||||||
let raw = "";
|
let raw = "";
|
||||||
try {
|
try {
|
||||||
@@ -86,6 +114,11 @@ export const saveRawConfig = (raw: RawConfig): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const loadCache = (): Cache => {
|
export const loadCache = (): Cache => {
|
||||||
|
try {
|
||||||
|
ensureDir(); // migrate pre-0.2.1 cache before the read (best-effort)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(fs.readFileSync(cachePath(), "utf8")) as Cache;
|
const parsed = JSON.parse(fs.readFileSync(cachePath(), "utf8")) as Cache;
|
||||||
return { ...parsed, art: parsed.art ?? {} };
|
return { ...parsed, art: parsed.art ?? {} };
|
||||||
|
|||||||
@@ -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" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user