Merge pull request 'An SDK fix could never reach an installed plugin — the runner now carries it' (#117) from worktree-runner-sdk-reconcile into main
ci / bun-nix (push) Successful in 24s
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 41s
ci / rust-arm64 (push) Successful in 2m28s
deb / build-publish-client-arm64 (push) Successful in 1m35s
deb / build-publish-host (push) Successful in 4m13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m59s
sdk-publish / publish (push) Successful in 1m52s
docker / builders-arm64cross (push) Successful in 15s
deb / build-publish (push) Successful in 7m16s
ci / rust (push) Successful in 6m52s
arch / build-publish (push) Successful in 7m29s
docker / deploy-docs (push) Failing after 3m54s
windows-host / package (push) Successful in 15m55s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 25s
nix / flake (push) Successful in 14m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m11s

Reviewed-on: #117
This commit was merged in pull request #117.
This commit is contained in:
2026-08-08 12:41:58 +00:00
6 changed files with 253 additions and 4 deletions
+12 -3
View File
@@ -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/"
},
+104
View File
@@ -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@<v>` 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));
+13 -1
View File
@@ -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) => {
+11
View File
@@ -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";
+90
View File
@@ -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);
});
});
+23
View File
@@ -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+$/);
});
});