feat(plugins): punktfunk-host plugins CLI — add/remove/list/enable/disable/status
One-liner plugin management replacing the manual scripting-dir + bunfig + bun-add ritual: package ops forward to the bun runner (new sdk plugins module + runner-cli subcommands, 11 tests green), enable/disable/status drive the systemd unit on Linux and the PunktfunkScripting scheduled task on Windows (installer support in the ISS). Docs page rewritten as .mdx with per-platform Tabs (registered in mdx.tsx). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -195,6 +195,24 @@ bun src/runner-cli.ts # runs <config_dir>/scripts/* + installed punkt
|
||||
bun src/runner-cli.ts --list # show what it found
|
||||
```
|
||||
|
||||
The same CLI manages plugin packages — it creates the plugins dir, points it at the `@punktfunk`
|
||||
registry, and installs on the bun it is already running on:
|
||||
|
||||
```sh
|
||||
bun src/runner-cli.ts add playnite # → @punktfunk/plugin-playnite (bare names resolve first-party)
|
||||
bun src/runner-cli.ts remove playnite
|
||||
bun src/runner-cli.ts list # installed plugin packages + versions
|
||||
```
|
||||
|
||||
On an installed host these are reached through the host CLI, which also drives the runner service
|
||||
and checks for elevation on Windows — that is the documented path for operators:
|
||||
|
||||
```sh
|
||||
punktfunk-host plugins add playnite
|
||||
punktfunk-host plugins enable # enable + start the runner (opt-in)
|
||||
punktfunk-host plugins status
|
||||
```
|
||||
|
||||
- **Plugins** (a `definePlugin` default export, from the scripts dir or a
|
||||
`punktfunk-plugin-*` package installed under `<config_dir>/plugins/`): supervised — a crash
|
||||
restarts them with capped exponential backoff; a clean return completes them.
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// `punktfunk-host plugins …` package operations, run on the vendored bun. The host CLI forwards
|
||||
// add/remove/list here (crates/punktfunk-host/src/plugins.rs) and the runner-cli exposes them as
|
||||
// subcommands. Everything a plugin needs to be installed — the plugins dir, the `@punktfunk`
|
||||
// registry scope in bunfig.toml, and the right bun — is handled here so the operator types one line
|
||||
// instead of the old create-dir / write-bunfig / `bun add` ritual.
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { configDir } from "./config.js";
|
||||
|
||||
/** The `@punktfunk` package registry (Gitea's npm registry for the `unom` org). */
|
||||
export const REGISTRY = "https://git.unom.io/api/packages/unom/npm/";
|
||||
|
||||
/** Where plugin packages install: `<config_dir>/plugins` (matches runner.ts discovery). */
|
||||
export const pluginsDirDefault = (): string => path.join(configDir(), "plugins");
|
||||
|
||||
/**
|
||||
* Resolve a friendly plugin name to its npm package. A bare first-party name maps into the
|
||||
* `@punktfunk` scope (`playnite` → `@punktfunk/plugin-playnite`, `rom-manager` →
|
||||
* `@punktfunk/plugin-rom-manager`); a scoped name (`@…/…`), the unscoped plugin convention
|
||||
* (`punktfunk-plugin-…`), or any name with a `/` is used verbatim.
|
||||
*/
|
||||
export const resolvePackage = (name: string): string => {
|
||||
const n = name.trim();
|
||||
if (!n) throw new Error("empty plugin name");
|
||||
if (n.startsWith("@")) return n; // already scoped, e.g. @punktfunk/plugin-playnite
|
||||
if (n.startsWith("punktfunk-plugin-")) return n; // unscoped plugin convention, verbatim
|
||||
if (n.includes("/")) return n; // some other registry path — trust it
|
||||
return `@punktfunk/plugin-${n}`; // bare first-party name
|
||||
};
|
||||
|
||||
/** Create the plugins dir (and parents) if needed. On Windows the ACL lockdown is the host's job. */
|
||||
export const ensurePluginsDir = (dir = pluginsDirDefault()): string => {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure `<dir>/bunfig.toml` points the `@punktfunk` scope at the registry so `bun add` resolves
|
||||
* first-party plugins. Idempotent: a file already mapping the scope is left untouched; an existing
|
||||
* bunfig with an `[install.scopes]` table gets our line inserted under it; anything else appends a
|
||||
* fresh table.
|
||||
*/
|
||||
export const ensureBunfig = (dir = pluginsDirDefault()): void => {
|
||||
const file = path.join(dir, "bunfig.toml");
|
||||
const scopeLine = `"@punktfunk" = "${REGISTRY}"`;
|
||||
let existing = "";
|
||||
try {
|
||||
existing = fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
// no bunfig yet — write a fresh one below
|
||||
}
|
||||
if (existing.includes("@punktfunk") && existing.includes(REGISTRY)) return; // already wired
|
||||
|
||||
const table = `[install.scopes]\n${scopeLine}\n`;
|
||||
if (!existing.trim()) {
|
||||
fs.writeFileSync(file, table);
|
||||
} else if (/^\[install\.scopes\][^\n]*$/m.test(existing)) {
|
||||
// Insert our scope line right after the existing table header.
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
existing.replace(/^\[install\.scopes\][^\n]*$/m, (m) => `${m}\n${scopeLine}`),
|
||||
);
|
||||
} else {
|
||||
const sep = existing.endsWith("\n") ? "" : "\n";
|
||||
fs.writeFileSync(file, `${existing}${sep}\n${table}`);
|
||||
}
|
||||
};
|
||||
|
||||
export interface PkgOpts {
|
||||
/** Plugins dir. Default `<config_dir>/plugins`. */
|
||||
dir?: string;
|
||||
/** Line sink for progress. Default stdout. */
|
||||
log?: (line: string) => void;
|
||||
}
|
||||
|
||||
/** Run `bun add`/`bun remove` in the plugins dir on the current (vendored) bun. */
|
||||
const runBun = (action: "add" | "remove", pkgs: string[], opts: PkgOpts): void => {
|
||||
const dir = opts.dir ?? pluginsDirDefault();
|
||||
const log = opts.log ?? ((l: string) => console.log(l));
|
||||
ensurePluginsDir(dir);
|
||||
if (action === "add") ensureBunfig(dir);
|
||||
log(`${action === "add" ? "installing" : "removing"} ${pkgs.join(", ")} in ${dir}`);
|
||||
// `process.execPath` is the bun running this file (the vendored one under the package), so a
|
||||
// system-wide bun on PATH is not required. Inherit stdio so `bun`'s progress reaches the user.
|
||||
const res = Bun.spawnSync([process.execPath, action, ...pkgs], {
|
||||
cwd: dir,
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
});
|
||||
if (!res.success) {
|
||||
throw new Error(`bun ${action} exited ${res.exitCode ?? "?"} — see output above`);
|
||||
}
|
||||
};
|
||||
|
||||
/** Install one or more plugins by friendly name or package. */
|
||||
export const addPlugins = (names: string[], opts: PkgOpts = {}): void =>
|
||||
runBun("add", names.map(resolvePackage), opts);
|
||||
|
||||
/** Uninstall one or more plugins by friendly name or package. */
|
||||
export const removePlugins = (names: string[], opts: PkgOpts = {}): void =>
|
||||
runBun("remove", names.map(resolvePackage), opts);
|
||||
|
||||
export interface InstalledPlugin {
|
||||
/** npm package name, e.g. `@punktfunk/plugin-playnite` or `punktfunk-plugin-foo`. */
|
||||
pkg: string;
|
||||
/** Installed version from the package's package.json, if readable. */
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate installed plugin packages under `<dir>/node_modules` — both the scoped first-party
|
||||
* convention (`@punktfunk/plugin-*`) and the unscoped one (`punktfunk-plugin-*`). Mirrors the
|
||||
* discovery in runner.ts so `list` shows exactly what the runner would supervise.
|
||||
*/
|
||||
export const listInstalled = (dir = pluginsDirDefault()): InstalledPlugin[] => {
|
||||
const modules = path.join(dir, "node_modules");
|
||||
const out: InstalledPlugin[] = [];
|
||||
const versionOf = (pkgDir: string): string | undefined => {
|
||||
try {
|
||||
const m = JSON.parse(
|
||||
fs.readFileSync(path.join(pkgDir, "package.json"), "utf8"),
|
||||
) as { version?: string };
|
||||
return m.version;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(modules).sort();
|
||||
} catch {
|
||||
return out; // no plugins installed yet
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith("punktfunk-plugin-")) {
|
||||
out.push({ pkg: entry, version: versionOf(path.join(modules, entry)) });
|
||||
} else if (entry === "@punktfunk") {
|
||||
let scoped: string[] = [];
|
||||
try {
|
||||
scoped = fs.readdirSync(path.join(modules, entry)).sort();
|
||||
} catch {
|
||||
scoped = [];
|
||||
}
|
||||
for (const s of scoped) {
|
||||
if (s.startsWith("plugin-")) {
|
||||
out.push({
|
||||
pkg: `${entry}/${s}`,
|
||||
version: versionOf(path.join(modules, entry, s)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
+77
-4
@@ -1,10 +1,21 @@
|
||||
#!/usr/bin/env bun
|
||||
// `punktfunk-scripting` — run the operator's scripts and punktfunk-plugin-* packages under
|
||||
// supervision (see ./runner.ts). SIGINT/SIGTERM interrupt the whole tree structurally, so
|
||||
// every plugin's finalizers run before exit (the systemd-stop story).
|
||||
// `punktfunk-scripting` — the plugin/script runner AND the `punktfunk-host plugins …` package ops.
|
||||
//
|
||||
// bun src/runner-cli.ts [--scripts DIR] [--plugins DIR] [--list]
|
||||
// With NO subcommand it RUNS the runner: discover the operator's scripts + punktfunk-plugin-*
|
||||
// packages and supervise them (see ./runner.ts). SIGINT/SIGTERM interrupt the whole tree
|
||||
// structurally, so every plugin's finalizers run before exit (the systemd-stop story). This bare
|
||||
// form is what the systemd unit / Windows scheduled task launch — do not change its behavior.
|
||||
//
|
||||
// With a subcommand it manages plugin packages (the host CLI forwards `punktfunk-host plugins …`
|
||||
// here):
|
||||
// add <name…> install first-party (playnite, rom-manager) or punktfunk-plugin-* packages
|
||||
// remove <name…> uninstall
|
||||
// list list installed plugin packages
|
||||
//
|
||||
// bun src/runner-cli.ts [--scripts DIR] [--plugins DIR] [--list] (run the runner)
|
||||
// bun src/runner-cli.ts add playnite [--plugins DIR] (package ops)
|
||||
import { Effect, Fiber } from "effect";
|
||||
import { addPlugins, listInstalled, removePlugins } from "./plugins.js";
|
||||
import { discoverUnits, runner } from "./runner.js";
|
||||
|
||||
const arg = (flag: string): string | undefined => {
|
||||
@@ -17,6 +28,68 @@ const options = {
|
||||
pluginsDir: arg("--plugins"),
|
||||
};
|
||||
|
||||
// Positional plugin names after the subcommand (argv: [bun, script, <cmd>, …]). Skip flags and the
|
||||
// value of `--plugins`/`--scripts` wherever they appear, so ordering doesn't matter.
|
||||
const positionals = (): string[] => {
|
||||
const out: string[] = [];
|
||||
for (let i = 3; i < process.argv.length; i++) {
|
||||
const a = process.argv[i];
|
||||
if (a === "--plugins" || a === "--scripts") {
|
||||
i++; // skip its value too
|
||||
continue;
|
||||
}
|
||||
if (a.startsWith("-")) continue;
|
||||
out.push(a);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const pkgOpts = { dir: options.pluginsDir };
|
||||
|
||||
const runPkgOp = (
|
||||
op: (names: string[], o: typeof pkgOpts) => void,
|
||||
verb: string,
|
||||
): never => {
|
||||
const names = positionals();
|
||||
if (names.length === 0) {
|
||||
console.error(
|
||||
`usage: punktfunk-host plugins ${verb} <name…> (e.g. playnite, rom-manager)`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
try {
|
||||
op(names, pkgOpts);
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
console.error(`[plugins] ${e instanceof Error ? e.message : e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
switch (process.argv[2]) {
|
||||
case "add":
|
||||
runPkgOp(addPlugins, "add");
|
||||
break;
|
||||
case "remove":
|
||||
case "rm":
|
||||
case "uninstall":
|
||||
runPkgOp(removePlugins, "remove");
|
||||
break;
|
||||
case "list":
|
||||
case "ls": {
|
||||
const installed = listInstalled(options.pluginsDir);
|
||||
if (installed.length === 0) {
|
||||
console.log("No plugins installed.");
|
||||
} else {
|
||||
for (const p of installed) {
|
||||
console.log(p.version ? `${p.pkg}\t${p.version}` : p.pkg);
|
||||
}
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- run the runner (default; --list keeps the legacy unit-listing behavior) ------------------
|
||||
if (process.argv.includes("--list")) {
|
||||
for (const u of discoverUnits(options)) console.log(`${u.name}\t${u.file}`);
|
||||
process.exit(0);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// The `punktfunk-host plugins …` package-op helpers: friendly-name resolution, the bunfig scope
|
||||
// wiring (idempotent + merges into an existing file), and installed-plugin discovery. `add`/`remove`
|
||||
// shell out to bun over the network, so the live install is verified on-glass, not here.
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
ensureBunfig,
|
||||
ensurePluginsDir,
|
||||
listInstalled,
|
||||
REGISTRY,
|
||||
resolvePackage,
|
||||
} from "../src/plugins.js";
|
||||
|
||||
const ROOT = path.join(import.meta.dir, "..", `.plugins-fixtures-${process.pid}`);
|
||||
fs.mkdirSync(ROOT, { recursive: true });
|
||||
afterAll(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
const tmp = (name: string): string => {
|
||||
const dir = path.join(ROOT, name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
};
|
||||
|
||||
const writePkg = (dir: string, name: string, version: string) => {
|
||||
const pkgDir = path.join(dir, "node_modules", name);
|
||||
fs.mkdirSync(pkgDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pkgDir, "package.json"),
|
||||
JSON.stringify({ name, version }),
|
||||
);
|
||||
};
|
||||
|
||||
describe("resolvePackage", () => {
|
||||
test("maps bare first-party names into the @punktfunk scope", () => {
|
||||
expect(resolvePackage("playnite")).toBe("@punktfunk/plugin-playnite");
|
||||
expect(resolvePackage("rom-manager")).toBe("@punktfunk/plugin-rom-manager");
|
||||
});
|
||||
|
||||
test("passes through scoped, unscoped-convention, and pathed names verbatim", () => {
|
||||
expect(resolvePackage("@punktfunk/plugin-playnite")).toBe(
|
||||
"@punktfunk/plugin-playnite",
|
||||
);
|
||||
expect(resolvePackage("@someone/plugin-x")).toBe("@someone/plugin-x");
|
||||
expect(resolvePackage("punktfunk-plugin-custom")).toBe("punktfunk-plugin-custom");
|
||||
});
|
||||
|
||||
test("trims and rejects empty", () => {
|
||||
expect(resolvePackage(" playnite ")).toBe("@punktfunk/plugin-playnite");
|
||||
expect(() => resolvePackage(" ")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureBunfig", () => {
|
||||
test("writes the @punktfunk scope map when absent", () => {
|
||||
const dir = tmp("bunfig-fresh");
|
||||
ensureBunfig(dir);
|
||||
const toml = fs.readFileSync(path.join(dir, "bunfig.toml"), "utf8");
|
||||
expect(toml).toContain("[install.scopes]");
|
||||
expect(toml).toContain(`"@punktfunk" = "${REGISTRY}"`);
|
||||
});
|
||||
|
||||
test("is idempotent — a second call doesn't duplicate the scope", () => {
|
||||
const dir = tmp("bunfig-idempotent");
|
||||
ensureBunfig(dir);
|
||||
ensureBunfig(dir);
|
||||
const toml = fs.readFileSync(path.join(dir, "bunfig.toml"), "utf8");
|
||||
expect(toml.match(/@punktfunk/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
test("merges into an existing [install.scopes] table, keeping other scopes", () => {
|
||||
const dir = tmp("bunfig-merge");
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "bunfig.toml"),
|
||||
'[install.scopes]\n"@other" = "https://example.test/npm/"\n',
|
||||
);
|
||||
ensureBunfig(dir);
|
||||
const toml = fs.readFileSync(path.join(dir, "bunfig.toml"), "utf8");
|
||||
expect(toml).toContain('"@other" = "https://example.test/npm/"');
|
||||
expect(toml).toContain(`"@punktfunk" = "${REGISTRY}"`);
|
||||
expect(toml.match(/\[install\.scopes\]/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
test("appends a table to an unrelated existing bunfig", () => {
|
||||
const dir = tmp("bunfig-append");
|
||||
fs.writeFileSync(path.join(dir, "bunfig.toml"), "telemetry = false\n");
|
||||
ensureBunfig(dir);
|
||||
const toml = fs.readFileSync(path.join(dir, "bunfig.toml"), "utf8");
|
||||
expect(toml).toContain("telemetry = false");
|
||||
expect(toml).toContain("[install.scopes]");
|
||||
expect(toml).toContain(`"@punktfunk" = "${REGISTRY}"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listInstalled", () => {
|
||||
test("returns empty for a dir with no node_modules", () => {
|
||||
expect(listInstalled(tmp("list-empty"))).toEqual([]);
|
||||
});
|
||||
|
||||
test("finds both scoped and unscoped plugins with versions, ignoring other packages", () => {
|
||||
const dir = tmp("list-mixed");
|
||||
writePkg(dir, "punktfunk-plugin-custom", "1.2.3");
|
||||
writePkg(dir, path.join("@punktfunk", "plugin-playnite"), "0.2.0");
|
||||
writePkg(dir, "effect", "4.0.0"); // an ordinary dep — must not be listed
|
||||
writePkg(dir, path.join("@punktfunk", "host"), "0.1.1"); // scoped non-plugin — ignored
|
||||
|
||||
const found = listInstalled(dir);
|
||||
expect(found).toEqual([
|
||||
{ pkg: "@punktfunk/plugin-playnite", version: "0.2.0" },
|
||||
{ pkg: "punktfunk-plugin-custom", version: "1.2.3" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("tolerates a plugin with an unreadable package.json", () => {
|
||||
const dir = tmp("list-broken");
|
||||
fs.mkdirSync(path.join(dir, "node_modules", "punktfunk-plugin-broken"), {
|
||||
recursive: true,
|
||||
});
|
||||
expect(listInstalled(dir)).toEqual([
|
||||
{ pkg: "punktfunk-plugin-broken", version: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensurePluginsDir", () => {
|
||||
test("creates the dir (and parents) and returns it", () => {
|
||||
const dir = path.join(tmp("ensure-dir"), "nested", "plugins");
|
||||
expect(ensurePluginsDir(dir)).toBe(dir);
|
||||
expect(fs.statSync(dir).isDirectory()).toBe(true);
|
||||
ensurePluginsDir(dir); // idempotent
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user