| // gorilator plugin โ manage the checkout's plugin system from the CLI. | ||
| // | ||
| // gorilator plugin list discovered plugins + enabled state | ||
| // gorilator plugin enable <name> remove <name> from realm.json plugins.disabled | ||
| // gorilator plugin disable <name> add <name> to realm.json plugins.disabled | ||
| // gorilator plugin add <path|npub> vendor a local plugin dir into plugins/, or | ||
| // trust a Nostr realm-pack author (.env REALM_PACK_AUTHORS) | ||
| // | ||
| // Mirrors the server's discovery rules (packages/server/src/systems/plugins/ | ||
| // discovery.ts): plugins/<dir>/plugin.json plus node_modules/gorilator-plugin-*, | ||
| // names starting with "_" are templates, and realm.json's plugins.disabled list | ||
| // turns plugins off by name without deleting them. The CLI ships standalone, so | ||
| // the manifest shape is re-declared here instead of importing @rpg/shared. | ||
| import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, symlinkSync, writeFileSync, } from "node:fs"; | ||
| import { basename, join, resolve } from "node:path"; | ||
| import * as log from "../lib/log.js"; | ||
| import { isValidNpub, npubToHex } from "../lib/npub.js"; | ||
| export function pluginCmd(ctx, args, flags) { | ||
| const root = ctx.appDir; | ||
| if (!existsSync(root)) { | ||
| log.die(`No Gorilator checkout at ${root}. Run inside a repo or 'gorilator install' first.`); | ||
| } | ||
| const [sub, arg] = args; | ||
| switch (sub) { | ||
| case undefined: | ||
| case "list": | ||
| listPlugins(root); | ||
| return; | ||
| case "enable": | ||
| enablePlugin(root, requireArg(sub, arg, "<name>")); | ||
| return; | ||
| case "disable": | ||
| disablePlugin(root, requireArg(sub, arg, "<name>")); | ||
| return; | ||
| case "add": | ||
| addPlugin(root, requireArg(sub, arg, "<path|npub>"), flags); | ||
| return; | ||
| default: | ||
| log.die(`Unknown plugin subcommand: ${sub}. Try list | enable | disable | add.`); | ||
| } | ||
| } | ||
| function requireArg(sub, arg, placeholder) { | ||
| if (!arg) | ||
| log.die(`Usage: gorilator plugin ${sub} ${placeholder}`); | ||
| return arg; | ||
| } | ||
| // ---- discovery (read-only mirror of the server's rules) ---- | ||
| function readManifest(dir) { | ||
| const file = join(dir, "plugin.json"); | ||
| if (!existsSync(file)) | ||
| return null; | ||
| try { | ||
| const raw = JSON.parse(readFileSync(file, "utf8")); | ||
| if (!raw?.name || !raw?.apiVersion) { | ||
| log.warn(`${file}: manifest needs "name" and "apiVersion" โ skipped`); | ||
| return null; | ||
| } | ||
| return raw; | ||
| } | ||
| catch { | ||
| log.warn(`${file}: invalid JSON โ skipped`); | ||
| return null; | ||
| } | ||
| } | ||
| function discoverPlugins(root) { | ||
| const found = []; | ||
| const seen = new Set(); | ||
| const consider = (dir, source) => { | ||
| const manifest = readManifest(dir); | ||
| if (!manifest || seen.has(manifest.name)) | ||
| return; | ||
| if (manifest.name.startsWith("_")) | ||
| return; // _template and friends | ||
| seen.add(manifest.name); | ||
| found.push({ manifest, dir, source }); | ||
| }; | ||
| const pluginRoot = join(root, "plugins"); | ||
| if (existsSync(pluginRoot)) { | ||
| for (const entry of readdirSync(pluginRoot, { withFileTypes: true })) { | ||
| if (entry.isDirectory() || entry.isSymbolicLink()) | ||
| consider(join(pluginRoot, entry.name), "plugins"); | ||
| } | ||
| } | ||
| const nm = join(root, "node_modules"); | ||
| if (existsSync(nm)) { | ||
| for (const entry of readdirSync(nm)) { | ||
| if (entry.startsWith("gorilator-plugin-")) | ||
| consider(join(nm, entry), "npm"); | ||
| } | ||
| } | ||
| return found; | ||
| } | ||
| function realmPath(root) { | ||
| return join(root, "realm.json"); | ||
| } | ||
| function readRealm(root) { | ||
| const file = realmPath(root); | ||
| if (!existsSync(file)) | ||
| return {}; | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(readFileSync(file, "utf8")); | ||
| } | ||
| catch { | ||
| return log.die(`${file} is not valid JSON โ fix it before editing plugins.disabled.`); | ||
| } | ||
| if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { | ||
| return log.die(`${file} must contain a JSON object.`); | ||
| } | ||
| return parsed; | ||
| } | ||
| function disabledList(realm) { | ||
| const disabled = realm.plugins?.disabled; | ||
| return Array.isArray(disabled) ? disabled.map(String) : []; | ||
| } | ||
| /** Persist a new plugins.disabled list, preserving every other realm.json key. */ | ||
| function writeDisabled(root, realm, disabled) { | ||
| realm.plugins = { ...(realm.plugins ?? {}), disabled }; | ||
| writeFileSync(realmPath(root), `${JSON.stringify(realm, null, 2)}\n`); | ||
| } | ||
| // ---- subcommands ---- | ||
| function listPlugins(root) { | ||
| const realmDisabled = new Set(disabledList(readRealm(root))); | ||
| const plugins = discoverPlugins(root); | ||
| if (plugins.length === 0) { | ||
| log.info("No plugins found."); | ||
| process.stdout.write(log.dim(` Drop one into ${join(root, "plugins")}/<name>/plugin.json, install a\n` + | ||
| " gorilator-plugin-* npm package, or start from plugins/_template โ see docs/plugins.md.\n")); | ||
| return; | ||
| } | ||
| const rows = plugins.map(({ manifest, source }) => { | ||
| const reason = realmDisabled.has(manifest.name) | ||
| ? "realm.json" | ||
| : manifest.enabled === false | ||
| ? "plugin.json" | ||
| : null; | ||
| return { | ||
| name: manifest.name, | ||
| version: manifest.version ? `v${manifest.version}` : "-", | ||
| status: reason ? `disabled (${reason})` : "enabled", | ||
| enabled: !reason, | ||
| source: source === "npm" ? "npm" : "plugins/", | ||
| caps: manifest.capabilities?.length ? manifest.capabilities.join(", ") : "-", | ||
| }; | ||
| }); | ||
| // Pad before colorizing โ escape codes would skew the column widths. | ||
| const nameW = Math.max(...rows.map((r) => r.name.length)); | ||
| const verW = Math.max(...rows.map((r) => r.version.length)); | ||
| const statusW = Math.max(...rows.map((r) => r.status.length)); | ||
| process.stdout.write(`Plugins (${rows.length})\n\n`); | ||
| for (const r of rows) { | ||
| const status = (r.enabled ? log.green : log.yellow)(r.status.padEnd(statusW)); | ||
| process.stdout.write(` ${log.bold(r.name.padEnd(nameW))} ${r.version.padEnd(verW)} ${status} ` + | ||
| `${log.dim(r.source.padEnd(8))} ${log.dim(r.caps)}\n`); | ||
| } | ||
| } | ||
| function enablePlugin(root, name) { | ||
| const realm = readRealm(root); | ||
| const disabled = disabledList(realm); | ||
| const plugin = discoverPlugins(root).find((p) => p.manifest.name === name); | ||
| if (!disabled.includes(name)) { | ||
| if (plugin?.manifest.enabled === false) { | ||
| log.warn(`${name} is disabled by its own manifest ("enabled": false) โ edit ${join(plugin.dir, "plugin.json")}.`); | ||
| } | ||
| else { | ||
| log.ok(`${name} is already enabled.`); | ||
| } | ||
| return; | ||
| } | ||
| writeDisabled(root, realm, disabled.filter((n) => n !== name)); | ||
| log.ok(`Enabled ${name} (removed from realm.json plugins.disabled).`); | ||
| if (!plugin) | ||
| log.warn(`${name} is not currently in plugins/ or node_modules โ nothing will load.`); | ||
| if (plugin?.manifest.enabled === false) { | ||
| log.warn(`${name} still sets "enabled": false in its plugin.json โ edit it to fully enable.`); | ||
| } | ||
| log.info("Restart the server to apply."); | ||
| } | ||
| function disablePlugin(root, name) { | ||
| const realm = readRealm(root); | ||
| const disabled = disabledList(realm); | ||
| if (disabled.includes(name)) { | ||
| log.ok(`${name} is already disabled.`); | ||
| return; | ||
| } | ||
| if (!discoverPlugins(root).some((p) => p.manifest.name === name)) { | ||
| log.warn(`${name} is not currently in plugins/ or node_modules โ disabling the name anyway.`); | ||
| } | ||
| writeDisabled(root, realm, [...disabled, name]); | ||
| log.ok(`Disabled ${name} (added to realm.json plugins.disabled).`); | ||
| log.info("Restart the server to apply."); | ||
| } | ||
| function addPlugin(root, target, flags) { | ||
| if (isValidNpub(target)) { | ||
| addPackAuthor(root, target); | ||
| return; | ||
| } | ||
| addLocalPlugin(root, target, flags); | ||
| } | ||
| /** Vendor a local plugin directory into plugins/<manifest.name> (copy, or | ||
| * symlink with --link so an out-of-tree plugin keeps its own dev loop). */ | ||
| function addLocalPlugin(root, path, flags) { | ||
| const src = resolve(path); | ||
| if (!existsSync(src) || !statSync(src).isDirectory()) { | ||
| log.die(`${path} is neither a plugin directory nor a valid npub1โฆ key.`); | ||
| } | ||
| const manifest = readManifest(src); | ||
| if (!manifest) { | ||
| log.die(`${src} has no valid plugin.json ("name" + "apiVersion") โ see docs/plugins.md.`); | ||
| } | ||
| const pluginsDir = join(root, "plugins"); | ||
| const dest = join(pluginsDir, manifest.name); | ||
| if (existsSync(dest)) | ||
| log.die(`${dest} already exists โ remove it first.`); | ||
| mkdirSync(pluginsDir, { recursive: true }); | ||
| if (flags.link) { | ||
| symlinkSync(src, dest, "dir"); | ||
| log.ok(`Linked ${manifest.name} โ ${src}`); | ||
| } | ||
| else { | ||
| cpSync(src, dest, { | ||
| recursive: true, | ||
| filter: (p) => !["node_modules", ".git"].includes(basename(p)), | ||
| }); | ||
| log.ok(`Copied ${manifest.name} into ${dest}`); | ||
| } | ||
| log.info("Restart the server to load it (run pnpm build:plugins first if it only ships src/)."); | ||
| } | ||
| /** Trust a Nostr realm-pack author: upsert the npub into REALM_PACK_AUTHORS in | ||
| * the repo-root .env (kind-30333 packs โ pure JSON data plugins consumed by | ||
| * packages/server/src/systems/plugins/nostrContent.ts). The edit is surgical โ | ||
| * one line touched โ so hand-written .env comments survive. */ | ||
| function addPackAuthor(root, npub) { | ||
| const hex = npubToHex(npub); | ||
| if (!hex) | ||
| log.die(`${npub} is not a valid npub.`); | ||
| const envPath = join(root, ".env"); | ||
| const lines = (existsSync(envPath) ? readFileSync(envPath, "utf8") : "").split("\n"); | ||
| const idx = lines.findIndex((l) => /^\s*REALM_PACK_AUTHORS\s*=/.test(l)); | ||
| let rawValue = idx === -1 ? "" : lines[idx].slice(lines[idx].indexOf("=") + 1).trim(); | ||
| if (/^(['"]).*\1$/.test(rawValue)) | ||
| rawValue = rawValue.slice(1, -1); | ||
| const authors = rawValue.split(",").map((s) => s.trim()).filter(Boolean); | ||
| // The server accepts npub or hex entries โ dedupe on the decoded key. | ||
| if (authors.some((a) => a.toLowerCase() === hex || npubToHex(a) === hex)) { | ||
| log.ok(`${npub.slice(0, 14)}โฆ${npub.slice(-6)} is already a trusted realm-pack author.`); | ||
| return; | ||
| } | ||
| const value = [...authors, npub].join(","); | ||
| if (idx >= 0) { | ||
| lines[idx] = `REALM_PACK_AUTHORS=${value}`; | ||
| } | ||
| else { | ||
| while (lines.length && lines[lines.length - 1].trim() === "") | ||
| lines.pop(); | ||
| if (lines.length) | ||
| lines.push(""); | ||
| lines.push("# Nostr realm packs (kind-30333 data plugins): JSON content events published by", "# these trusted authors are loaded live โ validated, never executed. Optional", "# relay override: REALM_PACK_RELAYS=wss://โฆ โ see docs/plugins.md.", `REALM_PACK_AUTHORS=${value}`, ""); | ||
| } | ||
| writeFileSync(envPath, lines.join("\n"), { mode: 0o600 }); | ||
| log.ok(`Added ${npub.slice(0, 14)}โฆ${npub.slice(-6)} to REALM_PACK_AUTHORS in ${envPath}`); | ||
| log.info("Restart the server to start syncing this author's packs."); | ||
| } |
+38
-0
@@ -11,2 +11,3 @@ #!/usr/bin/env node | ||
| // gorilator tunnel <login|status|restart> manage the Cloudflare tunnel | ||
| // gorilator plugin <list|enable|disable|add> manage plugins + realm-pack authors | ||
| // gorilator uninstall stop and remove Gorilator from this machine | ||
@@ -19,2 +20,3 @@ // gorilator serve internal: the supervised foreground process | ||
| import { install } from "./commands/install.js"; | ||
| import { pluginCmd } from "./commands/plugin.js"; | ||
| import { remoteCmd } from "./commands/remote.js"; | ||
@@ -80,2 +82,3 @@ import { serve } from "./commands/serve.js"; | ||
| "remote", | ||
| "plugin", | ||
| ]); | ||
@@ -113,2 +116,3 @@ /** One-line banner naming the target a command will act on. */ | ||
| tunnel <cmd> Manage the Cloudflare tunnel โ login | status | restart | ||
| plugin <cmd> Manage plugins โ list | enable | disable | add <path|npub> | ||
| uninstall Stop and remove Gorilator services, config, global command, and installed files | ||
@@ -147,2 +151,5 @@ serve Run the server in the foreground (used by the service) | ||
| --no-npm Skip the published npm package version check | ||
| Options (plugin add): | ||
| --link Symlink a local plugin directory into plugins/ instead of copying | ||
| `); | ||
@@ -292,2 +299,29 @@ } | ||
| `, | ||
| plugin: `${log.bold("gorilator plugin")} โ manage plugins and realm-pack authors | ||
| Usage: | ||
| gorilator plugin [list] | ||
| gorilator plugin enable <name> | ||
| gorilator plugin disable <name> | ||
| gorilator plugin add <path|npub> [--link] | ||
| gorilator help plugin | ||
| Subcommands: | ||
| list Show discovered plugins (plugins/ + node_modules/gorilator-plugin-*) | ||
| with version, enabled state, and declared capabilities | ||
| enable <name> Remove <name> from realm.json's plugins.disabled list | ||
| disable <name> Add <name> to realm.json's plugins.disabled list (created if missing) | ||
| add <path> Copy a local plugin directory into plugins/<name> (--link symlinks instead) | ||
| add <npub> Trust a Nostr realm-pack author: appends the npub to | ||
| REALM_PACK_AUTHORS in .env (kind-30333 data packs, see docs/plugins.md) | ||
| Options: | ||
| --link With 'add <path>': symlink instead of copy | ||
| Examples: | ||
| gorilator plugin list | ||
| gorilator plugin disable example-arena | ||
| gorilator plugin add ../my-plugin --link | ||
| gorilator plugin add npub1abcโฆ | ||
| `, | ||
| uninstall: `${log.bold("gorilator uninstall")} โ remove Gorilator from this machine | ||
@@ -376,2 +410,3 @@ | ||
| "no-npm": { type: "boolean" }, | ||
| link: { type: "boolean" }, | ||
| help: { type: "boolean", short: "h" }, | ||
@@ -458,2 +493,5 @@ version: { type: "boolean", short: "v" }, | ||
| break; | ||
| case "plugin": | ||
| pluginCmd(ctx, positionals.slice(1), { link: Boolean(values.link) }); | ||
| break; | ||
| case "uninstall": | ||
@@ -460,0 +498,0 @@ uninstall(opts); |
+4
-2
@@ -120,4 +120,6 @@ // Process & privilege helpers โ synchronous, dependency-free wrappers around | ||
| export function runPrivileged(cmd, args, opts = {}) { | ||
| if (isRoot()) | ||
| return run(cmd, args, opts); | ||
| if (isRoot()) { | ||
| run(cmd, args, opts); | ||
| return; | ||
| } | ||
| run("sudo", [cmd, ...args], opts); | ||
@@ -124,0 +126,0 @@ } |
+3
-1
| { | ||
| "name": "gorilator", | ||
| "version": "1.7.4", | ||
| "version": "1.8.0", | ||
| "description": "One command to install, run, and supervise the Gorilator RPG game server natively (no Docker) as a systemd/launchd service, with an optional Cloudflare Tunnel setup.", | ||
@@ -21,2 +21,4 @@ "type": "module", | ||
| "test": "npm run build && node --test test/*.test.mjs", | ||
| "typecheck": "tsc -p tsconfig.json --noEmit", | ||
| "lint": "biome lint .", | ||
| "prepublishOnly": "npm run build" | ||
@@ -23,0 +25,0 @@ }, |
+1
-0
@@ -80,2 +80,3 @@ # ๐ฆ gorilator | ||
| gorilator tunnel <cmd># Cloudflare tunnel โ login | status | restart | ||
| gorilator plugin <cmd># plugins โ list | enable | disable | add <path|npub> (--link) | ||
| gorilator uninstall # stop and remove services, config, command, and installed files | ||
@@ -82,0 +83,0 @@ gorilator help <cmd> # show detailed help for any command |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
273641
5.01%35
2.94%6552
4.76%122
0.83%