diff --git a/sdk/package.json b/sdk/package.json index 72a912a7..4b4ab3af 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@punktfunk/host", - "version": "0.1.3", + "version": "0.1.4", "description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.", "type": "module", "license": "MIT OR Apache-2.0", @@ -13,7 +13,13 @@ "bugs": { "url": "https://git.unom.io/unom/punktfunk/issues" }, - "keywords": ["punktfunk", "game-streaming", "automation", "sdk", "effect"], + "keywords": [ + "punktfunk", + "game-streaming", + "automation", + "sdk", + "effect" + ], "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { @@ -29,7 +35,10 @@ "bin": { "punktfunk-scripting": "./dist/runner-cli.js" }, - "files": ["dist", "README.md"], + "files": [ + "dist", + "README.md" + ], "publishConfig": { "registry": "https://git.unom.io/api/packages/unom/npm/" }, diff --git a/sdk/src/plugins.ts b/sdk/src/plugins.ts index 0e4aee01..77c83f48 100644 --- a/sdk/src/plugins.ts +++ b/sdk/src/plugins.ts @@ -6,6 +6,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { configDir } from "./config.js"; +import { SDK_VERSION } from "./version.js"; /** The `@punktfunk` package registry (Gitea's npm registry for the `unom` org). */ export const REGISTRY = "https://git.unom.io/api/packages/unom/npm/"; @@ -187,6 +188,109 @@ const runBun = (action: "add" | "remove", pkgs: string[], opts: PkgOpts): void = } }; +/** The SDK version installed in a plugins tree, or undefined if it isn't installed at all. */ +export const installedSdkVersion = ( + dir = pluginsDirDefault(), +): string | undefined => { + try { + const manifest = path.join( + dir, + "node_modules", + "@punktfunk", + "host", + "package.json", + ); + const v = ( + JSON.parse(fs.readFileSync(manifest, "utf8")) as { version?: string } + ).version; + return typeof v === "string" ? v : undefined; + } catch { + return undefined; + } +}; + +/** + * Bring the plugins tree's `@punktfunk/host` up to the version THIS runner was built from. + * + * **Why this exists.** The SDK is the seam every plugin registers through, but each plugin resolves + * it from the plugins tree, and `bun.lock` pins it to an exact version with an integrity hash. No + * user-facing flow re-resolves that pin: installing a plugin, reinstalling it, even updating it to a + * newer release all leave the SDK where it is, because the plugin's `^0.1.x` range is already + * satisfied. Measured on 2026-08-08 — publishing `@punktfunk/host@0.1.3` (the release that lets a + * library scanner register `category`, so it stays out of the console nav) reached **no existing + * install**, and the only thing that moved it was deleting the lockfile by hand over ssh. Shipping a + * fix that needs an ssh session is not shipping a fix. + * + * The runner is the right owner: it is bundled from this same `sdk/` at the host's release commit + * (`packaging/arch/PKGBUILD` builds `src/runner-cli.ts` into the punktfunk-scripting package), so + * `SDK_VERSION` is by construction the SDK that matches the host now on disk. A host upgrade then + * carries the SDK with it and nobody touches a runner. + * + * **Why the whole lockfile.** A targeted `bun add @punktfunk/host@` at the root does NOT work + * while plugins still declare the SDK in their own `dependencies` (they do, though none import it): + * bun honours their locked resolution and gives each plugin a private nested copy, which then + * SHADOWS the root — measured, 5 nested copies. A lockless resolve hoists one copy for everyone, + * also measured. Once the plugins drop that spurious dependency this can become the targeted form. + * + * Safety: the plugins' own versions are pinned exactly in the root `package.json`, so a re-resolve + * cannot move them; only shared transitive deps float within their declared ranges. The lockfile is + * backed up first and restored if the install fails, and any failure is logged and swallowed — a + * dependency refresh must never stop the plugins that are already working from loading. + */ +export const reconcileSharedSdk = ( + dir = pluginsDirDefault(), + log: (line: string) => void = (l) => console.log(l), +): void => { + const have = installedSdkVersion(dir); + // Nothing installed = no plugins yet; the first `bun add` resolves the current SDK on its own. + if (have === undefined || have === SDK_VERSION) return; + + const lock = path.join(dir, "bun.lock"); + const backup = `${lock}.pf-bak`; + log( + `[plugins] @punktfunk/host ${have} installed, this host ships ${SDK_VERSION} — refreshing`, + ); + let restore = false; + try { + if (fs.existsSync(lock)) { + fs.copyFileSync(lock, backup); + fs.rmSync(lock); + restore = true; + } + const res = Bun.spawnSync([process.execPath, "install", "--ignore-scripts"], { + cwd: dir, + stdio: ["inherit", "inherit", "inherit"], + }); + if (!res.success) { + throw new Error(`bun install exited ${res.exitCode ?? "?"}`); + } + const now = installedSdkVersion(dir); + if (now !== SDK_VERSION) { + // The install "succeeded" and still did not deliver the version — better to sit on the + // known-good tree than to keep a half-resolved one. + throw new Error(`still ${now ?? "absent"} after install`); + } + restore = false; + if (fs.existsSync(backup)) fs.rmSync(backup); + log(`[plugins] @punktfunk/host is now ${SDK_VERSION}`); + } catch (e) { + log( + `[plugins] WARNING: could not refresh @punktfunk/host (${ + e instanceof Error ? e.message : e + }) — plugins keep running against ${have}`, + ); + if (restore && fs.existsSync(backup)) { + try { + fs.copyFileSync(backup, lock); + fs.rmSync(backup); + } catch { + // The backup is still on disk under its own name; say so rather than pretend. + log(`[plugins] the previous lockfile is at ${backup}`); + } + } + } +}; + /** Install one or more plugins by friendly name or package. */ export const addPlugins = (names: string[], opts: PkgOpts = {}): void => { const pkgs = names.map((n) => resolvePackage(n, opts)); diff --git a/sdk/src/runner-cli.ts b/sdk/src/runner-cli.ts index b9ff67fb..594951da 100644 --- a/sdk/src/runner-cli.ts +++ b/sdk/src/runner-cli.ts @@ -23,7 +23,12 @@ // package that may live on somebody else's registry — but they are ordinary CLI flags too. import { Effect, Fiber } from "effect"; import { installLogShipper } from "./log-ship.js"; -import { addPlugins, listInstalled, removePlugins } from "./plugins.js"; +import { + addPlugins, + listInstalled, + reconcileSharedSdk, + removePlugins, +} from "./plugins.js"; import { discoverUnits, runner } from "./runner.js"; const arg = (flag: string): string | undefined => { @@ -166,6 +171,13 @@ const keepAlive = setInterval(() => {}, 2 ** 31 - 1); // a plugin failing to load are the first ones out. const shipper = installLogShipper(); +// Before any plugin loads: make the tree's shared SDK the one this runner was built from. A host +// upgrade is the only moment that can deliver an SDK fix to already-installed plugins, and this is +// that moment — see `reconcileSharedSdk`. Deliberately AFTER the log shipper so the operator can +// read what it did from the console's Logs page, and BEFORE `runner()` so plugins import the +// refreshed copy rather than the one they were started with. +reconcileSharedSdk(options.pluginsDir); + const fiber = Effect.runFork(runner(options)); let stopping = false; const shutdown = (signal: string) => { diff --git a/sdk/src/version.ts b/sdk/src/version.ts new file mode 100644 index 00000000..56225d68 --- /dev/null +++ b/sdk/src/version.ts @@ -0,0 +1,11 @@ +/** + * The version of this SDK, as a value the bundled runner can read about ITSELF. + * + * A constant rather than an import of `package.json`: `tsconfig.build.json` sets `rootDir: "src"`, + * so reaching one directory up breaks the npm build, and the runner ships as a single bundled + * `runner-cli.js` with no `package.json` beside it (`/usr/share/punktfunk-scripting/`), so there is + * nothing to read at runtime either. Inlining it at build time is the only form that survives both. + * + * `version.test.ts` fails if this and `package.json` disagree, so the duplication cannot rot. + */ +export const SDK_VERSION = "0.1.4"; diff --git a/sdk/test/reconcile-sdk.test.ts b/sdk/test/reconcile-sdk.test.ts new file mode 100644 index 00000000..eba89236 --- /dev/null +++ b/sdk/test/reconcile-sdk.test.ts @@ -0,0 +1,90 @@ +// `reconcileSharedSdk` runs on EVERY runner start, so its no-op path is the safety-critical one: +// a false positive deletes a working lockfile and re-resolves the whole tree on a box that was +// fine. These tests pin the decision, not the install (which needs a registry). +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, test } from "bun:test"; +import { installedSdkVersion, reconcileSharedSdk } from "../src/plugins.js"; +import { SDK_VERSION } from "../src/version.js"; + +const dirs: string[] = []; + +/** A plugins tree whose installed `@punktfunk/host` is `version` (omit for "not installed"). */ +const tree = (version?: string): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-reconcile-")); + dirs.push(dir); + fs.writeFileSync(path.join(dir, "package.json"), '{"private":true}\n'); + fs.writeFileSync(path.join(dir, "bun.lock"), "ORIGINAL-LOCK\n"); + if (version !== undefined) { + const host = path.join(dir, "node_modules", "@punktfunk", "host"); + fs.mkdirSync(host, { recursive: true }); + fs.writeFileSync( + path.join(host, "package.json"), + JSON.stringify({ name: "@punktfunk/host", version }), + ); + } + return dir; +}; + +afterEach(() => { + for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }); +}); + +describe("installedSdkVersion", () => { + test("reads the installed version, and is undefined when absent", () => { + expect(installedSdkVersion(tree("0.1.2"))).toBe("0.1.2"); + expect(installedSdkVersion(tree())).toBeUndefined(); + }); + + test("is undefined rather than throwing on a corrupt manifest", () => { + const dir = tree("0.1.2"); + fs.writeFileSync( + path.join(dir, "node_modules", "@punktfunk", "host", "package.json"), + "{ not json", + ); + expect(installedSdkVersion(dir)).toBeUndefined(); + }); +}); + +describe("reconcileSharedSdk", () => { + // The common case, every start, on every healthy box: touch nothing. + test("is a silent no-op when the installed SDK already matches", () => { + const dir = tree(SDK_VERSION); + const lines: string[] = []; + reconcileSharedSdk(dir, (l) => lines.push(l)); + expect(fs.readFileSync(path.join(dir, "bun.lock"), "utf8")).toBe( + "ORIGINAL-LOCK\n", + ); + expect(lines).toEqual([]); + }); + + // A tree with no SDK has no plugins yet — the first `bun add` resolves the current one, so + // there is nothing to refresh and nothing to log about. + test("is a silent no-op when no SDK is installed at all", () => { + const dir = tree(); + const lines: string[] = []; + reconcileSharedSdk(dir, (l) => lines.push(l)); + expect(fs.readFileSync(path.join(dir, "bun.lock"), "utf8")).toBe( + "ORIGINAL-LOCK\n", + ); + expect(lines).toEqual([]); + }); + + // The failure path matters as much as the happy one: this runs unattended at boot, and the + // tree it just took the lockfile away from is the one the operator's plugins load from. The + // install cannot succeed here (the fake package.json resolves nothing), so this exercises the + // real rollback. + test("restores the lockfile and keeps going when the refresh fails", () => { + const dir = tree("0.0.1-not-a-real-version"); + const lines: string[] = []; + expect(() => reconcileSharedSdk(dir, (l) => lines.push(l))).not.toThrow(); + expect(fs.readFileSync(path.join(dir, "bun.lock"), "utf8")).toBe( + "ORIGINAL-LOCK\n", + ); + expect(lines.join("\n")).toContain("WARNING"); + // And it names both versions, so the log says what it was trying to do. + expect(lines.join("\n")).toContain("0.0.1-not-a-real-version"); + expect(fs.existsSync(path.join(dir, "bun.lock.pf-bak"))).toBe(false); + }); +}); diff --git a/sdk/test/version.test.ts b/sdk/test/version.test.ts new file mode 100644 index 00000000..ddb51175 --- /dev/null +++ b/sdk/test/version.test.ts @@ -0,0 +1,23 @@ +// The one thing that keeps `SDK_VERSION` honest. The runner compares it against the SDK actually +// installed in the plugins tree and reinstalls on a mismatch, so a stale constant would either +// reinstall forever (constant behind) or never deliver a fix (constant ahead of a release). +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "bun:test"; +import { SDK_VERSION } from "../src/version.js"; + +describe("SDK_VERSION", () => { + test("matches package.json — bump both or neither", () => { + // Read rather than import: `tsconfig.build.json` pins `rootDir: "src"`, so a JSON import of + // the manifest would not compile for the npm build even though bun would run it fine. + const pkg = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ) as { version: string }; + expect(SDK_VERSION).toBe(pkg.version); + }); + + test("is a plain semver triple", () => { + // The runner compares it to an installed version string, so anything with a range operator + // (`^0.1.3`) would never compare equal and would reinstall on every start. + expect(SDK_VERSION).toMatch(/^\d+\.\d+\.\d+$/); + }); +});