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
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:
+12
-3
@@ -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/"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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+$/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user