Skip to content
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,13 +328,14 @@ Install a challenge package (npm package name, git URL, tarball URL, or local pa

```
USAGE
$ bitsocial challenge add PACKAGE [--pkcOptions.dataPath <value>]
$ bitsocial challenge add PACKAGE --pkcRpcUrl <value> [--pkcOptions.dataPath <value>]

ARGUMENTS
PACKAGE Package specifier — anything npm can install (name, name@version, git URL, tarball URL, local path)

FLAGS
--pkcOptions.dataPath=<value> Data path to install the challenge into
--pkcRpcUrl=<value> (required) [default: ws://localhost:9138/] URL to PKC RPC

DESCRIPTION
Install a challenge package (npm package name, git URL, tarball URL, or local path)
Expand All @@ -361,13 +362,14 @@ Install a challenge package (npm package name, git URL, tarball URL, or local pa

```
USAGE
$ bitsocial challenge i PACKAGE [--pkcOptions.dataPath <value>]
$ bitsocial challenge i PACKAGE --pkcRpcUrl <value> [--pkcOptions.dataPath <value>]

ARGUMENTS
PACKAGE Package specifier — anything npm can install (name, name@version, git URL, tarball URL, local path)

FLAGS
--pkcOptions.dataPath=<value> Data path to install the challenge into
--pkcRpcUrl=<value> (required) [default: ws://localhost:9138/] URL to PKC RPC

DESCRIPTION
Install a challenge package (npm package name, git URL, tarball URL, or local path)
Expand All @@ -394,13 +396,14 @@ Install a challenge package (npm package name, git URL, tarball URL, or local pa

```
USAGE
$ bitsocial challenge install PACKAGE [--pkcOptions.dataPath <value>]
$ bitsocial challenge install PACKAGE --pkcRpcUrl <value> [--pkcOptions.dataPath <value>]

ARGUMENTS
PACKAGE Package specifier — anything npm can install (name, name@version, git URL, tarball URL, local path)

FLAGS
--pkcOptions.dataPath=<value> Data path to install the challenge into
--pkcRpcUrl=<value> (required) [default: ws://localhost:9138/] URL to PKC RPC

DESCRIPTION
Install a challenge package (npm package name, git URL, tarball URL, or local path)
Expand Down Expand Up @@ -479,13 +482,14 @@ Remove an installed challenge package

```
USAGE
$ bitsocial challenge remove NAME [--pkcOptions.dataPath <value>]
$ bitsocial challenge remove NAME --pkcRpcUrl <value> [--pkcOptions.dataPath <value>]

ARGUMENTS
NAME The challenge package name (e.g., my-challenge or @scope/my-challenge)

FLAGS
--pkcOptions.dataPath=<value> Data path where challenges are installed
--pkcRpcUrl=<value> (required) [default: ws://localhost:9138/] URL to PKC RPC

DESCRIPTION
Remove an installed challenge package
Expand All @@ -509,13 +513,14 @@ Remove an installed challenge package

```
USAGE
$ bitsocial challenge rm NAME [--pkcOptions.dataPath <value>]
$ bitsocial challenge rm NAME --pkcRpcUrl <value> [--pkcOptions.dataPath <value>]

ARGUMENTS
NAME The challenge package name (e.g., my-challenge or @scope/my-challenge)

FLAGS
--pkcOptions.dataPath=<value> Data path where challenges are installed
--pkcRpcUrl=<value> (required) [default: ws://localhost:9138/] URL to PKC RPC

DESCRIPTION
Remove an installed challenge package
Expand All @@ -537,13 +542,14 @@ Remove an installed challenge package

```
USAGE
$ bitsocial challenge un NAME [--pkcOptions.dataPath <value>]
$ bitsocial challenge un NAME --pkcRpcUrl <value> [--pkcOptions.dataPath <value>]

ARGUMENTS
NAME The challenge package name (e.g., my-challenge or @scope/my-challenge)

FLAGS
--pkcOptions.dataPath=<value> Data path where challenges are installed
--pkcRpcUrl=<value> (required) [default: ws://localhost:9138/] URL to PKC RPC

DESCRIPTION
Remove an installed challenge package
Expand All @@ -565,13 +571,14 @@ Remove an installed challenge package

```
USAGE
$ bitsocial challenge uninstall NAME [--pkcOptions.dataPath <value>]
$ bitsocial challenge uninstall NAME --pkcRpcUrl <value> [--pkcOptions.dataPath <value>]

ARGUMENTS
NAME The challenge package name (e.g., my-challenge or @scope/my-challenge)

FLAGS
--pkcOptions.dataPath=<value> Data path where challenges are installed
--pkcRpcUrl=<value> (required) [default: ws://localhost:9138/] URL to PKC RPC

DESCRIPTION
Remove an installed challenge package
Expand Down
130 changes: 127 additions & 3 deletions src/challenge-packages/challenge-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import path from "path";
import { createHash } from "node:crypto";
import { pathToFileURL } from "node:url";
import fs from "fs/promises";
import type { Dirent } from "fs";
Expand Down Expand Up @@ -309,11 +310,115 @@ export function formatChallengeNameVersion(challenge: Pick<InstalledChallenge, "
return challenge.version && challenge.version !== "unknown" ? `${challenge.name}@${challenge.version}` : challenge.name;
}

/**
* HTTP url of a daemon's challenge-reload endpoint, derived from its --pkcRpcUrl. The endpoint
* is served by the same http server as the RPC socket, and its local-only variant requires the
* request to come from the loopback interface — so a wildcard bind is dialed as loopback rather
* than as 0.0.0.0.
*/
export function challengeReloadUrlFromPkcRpcUrl(pkcRpcUrl: string): string | undefined {
let url: URL;
try {
url = new URL(pkcRpcUrl);
} catch {
return undefined;
}
if (!url.port) return undefined;
// URL.hostname keeps the brackets around an IPv6 literal — strip them so the host can be
// compared and re-bracketed exactly once
const hostname = url.hostname.replace(/^\[|\]$/g, "");
const host = hostname === "0.0.0.0" || hostname === "::" ? "127.0.0.1" : hostname;
return `http://${host.includes(":") ? `[${host}]` : host}:${url.port}/api/challenges/reload`;
}

/** How long install/remove wait for a daemon to finish reloading before giving up. */
export const CHALLENGE_RELOAD_TIMEOUT_MS = 30000;

/**
* Ask the daemon at `pkcRpcUrl` to reload its challenge packages, so an install/remove takes
* effect without a restart. Returns whether a daemon actually reloaded — best-effort, since
* no daemon running is not an error.
*
* The request is bounded: a daemon that accepts the connection but never answers (busy, wedged,
* or something else listening on the port) would otherwise hold the CLI for undici's 300s
* default. Aborting only ends our wait — the daemon may still finish the reload.
*/
export async function reloadChallengesInDaemon(pkcRpcUrl: string | URL, timeoutMs = CHALLENGE_RELOAD_TIMEOUT_MS): Promise<boolean> {
const reloadUrl = challengeReloadUrlFromPkcRpcUrl(pkcRpcUrl.toString());
if (!reloadUrl) return false;
try {
const res = await fetch(reloadUrl, { method: "POST", signal: AbortSignal.timeout(timeoutMs) });
return res.ok;
} catch {
return false; // daemon not running or not answering, that's fine
}
}

// Hash of a challenge package's own source, used as the import cache key (see
// loadChallengesIntoPKC). Only node_modules is skipped: dependencies are pinned by the
// install that produced this package dir, and hashing them would make every reload walk
// the entire dependency tree. Dot-prefixed files and directories are hashed — pkg.main
// may point into one (".dist/index.js"), and skipping them left that entry out of the key,
// so a same-version replacement kept the old import URL and served the stale module.
export async function hashChallengePackageContents(challengeDir: string): Promise<string> {
const hash = createHash("sha256");

const walk = async (dir: string, relativeDir: string): Promise<void> => {
const entries = await fs.readdir(dir, { withFileTypes: true });
// Sort so the digest does not depend on readdir order
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
for (const entry of entries) {
if (entry.name === "node_modules") continue;
const absolutePath = path.join(dir, entry.name);
const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
await walk(absolutePath, relativePath);
} else if (entry.isFile()) {
// Frame each record: feeding the path and the raw bytes back to back makes the
// stream ambiguous, so {index.js:"A", j:"Z"} and {index.js:"AjZ"} would hash
// identically and a changed entry could reuse the previous cache key
const contents = await fs.readFile(absolutePath);
const fileDigest = createHash("sha256").update(contents).digest("hex");
hash.update(`${relativePath}\0${fileDigest}\0`);
}
}
};

await walk(challengeDir, "");
return hash.digest("hex").slice(0, 16);
}

/**
* What each challenge name held in PKC.challenges before this process first registered a package
* under it. PKC.challenges *is* pkc-js's own registry, so a package named after a built-in
* ("question") shadows it — unregistering has to hand the built-in back rather than delete the
* key. `hadPrevious: false` means the name was ours alone and can be deleted.
*
* A process serves one data path (the daemon passes its own), so this is not keyed by data path.
*/
const shadowedChallenges = new Map<string, { hadPrevious: boolean; previous: unknown }>();

export async function loadChallengesIntoPKC(dataPath?: string): Promise<InstalledChallenge[]> {
const challenges = await listInstalledChallenges(dataPath);
if (challenges.length === 0) return [];

const PKC = await import("@pkcprotocol/pkc-js");
const registry = (PKC.default as any).challenges as Record<string, unknown>;

// Hand back any name whose package is no longer installed, so `challenge remove` takes effect
// without a restart. A package that is still installed but fails to import keeps its
// previously loaded factory: it is excluded from the returned list either way, and dropping a
// working challenge because its replacement is broken would take the community's publication
// flow down instead of leaving it on known-good code.
const installedNames = new Set(challenges.map((challenge) => challenge.name));
for (const [name, original] of shadowedChallenges) {
if (installedNames.has(name)) continue;
if (original.hadPrevious) registry[name] = original.previous;
else delete registry[name];
shadowedChallenges.delete(name);
}

if (challenges.length === 0) return [];

const loaded: InstalledChallenge[] = [];

for (const challenge of challenges) {
Expand All @@ -322,9 +427,28 @@ export async function loadChallengesIntoPKC(dataPath?: string): Promise<Installe
// Resolve the entry point
const entryPoint = pkg.main || "index.js";
const entryPath = path.resolve(challenge.path, entryPoint);
const imported = await import(pathToFileURL(entryPath).href);

// Node caches ESM modules by URL, and `challenge install` swaps a new build onto
// the same destination path. Importing the bare path would therefore hand back
// the module evaluated before the upgrade, so the registry would keep serving the
// old factory while metadata reports the new version (issue #124). Keying the URL
// on the package contents re-evaluates the entry whenever the package changes and
// stays byte-identical (so cached, so factory-identical) when it does not.
//
// Only the entry module is re-evaluated: relative imports inside the package do not
// inherit the query, so a multi-file package graph would keep its stale submodules.
// Challenge packages are expected to ship a bundled entry point.
const entryUrl = pathToFileURL(entryPath);
entryUrl.searchParams.set("bitsocialChallengeContent", await hashChallengePackageContents(challenge.path));

const imported = await import(entryUrl.href);
const factory = imported.default || imported;
(PKC.default as any).challenges[challenge.name] = factory;
// Remember what this name held before we first took it over, so `challenge remove`
// can hand it back instead of leaving a dead key (or deleting a pkc-js built-in)
if (!shadowedChallenges.has(challenge.name)) {
shadowedChallenges.set(challenge.name, { hadPrevious: challenge.name in registry, previous: registry[challenge.name] });
}
registry[challenge.name] = factory;
loaded.push(challenge);
} catch (err) {
console.error(`Failed to load challenge "${challenge.name}":`, err);
Expand Down
17 changes: 9 additions & 8 deletions src/cli/commands/challenge/install.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Args, Flags, Command } from "@oclif/core";
import { Args, Flags } from "@oclif/core";
import { BaseCommand } from "../../base-command.js";
import path from "path";
import fs from "fs/promises";
import decompress from "decompress";
Expand All @@ -10,10 +11,11 @@ import {
readChallengePackageJson,
runNpmPack,
runNpmInstall,
verifyNativeModuleAbi
verifyNativeModuleAbi,
reloadChallengesInDaemon
} from "../../../challenge-packages/challenge-utils.js";

export default class Install extends Command {
export default class Install extends BaseCommand {
static override description = "Install a challenge package (npm package name, git URL, tarball URL, or local path)";

static override aliases = ["challenge:i", "challenge:add"];
Expand Down Expand Up @@ -132,11 +134,10 @@ export default class Install extends Command {
const elapsedSeconds = Math.max(1, Math.round((Date.now() - startTime) / 1000));
this.log(`${alreadyExists ? "changed" : "added"} ${pkg.name}${version} in ${elapsedSeconds}s`);

// 10. Best-effort reload via daemon
try {
await fetch("http://localhost:9138/api/challenges/reload", { method: "POST" });
} catch {
// daemon not running, that's fine
// 10. Best-effort reload in the daemon at --pkcRpcUrl, so the install takes effect
// without a restart
if (await reloadChallengesInDaemon(flags.pkcRpcUrl)) {
this.log(`reloaded ${pkg.name}${version} in the daemon at ${flags.pkcRpcUrl}`);
}
} finally {
// 11. Clean up temp dir (includes the previous-install backup, if any)
Expand Down
21 changes: 12 additions & 9 deletions src/cli/commands/challenge/remove.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { Args, Flags, Command } from "@oclif/core";
import { Args, Flags } from "@oclif/core";
import { BaseCommand } from "../../base-command.js";
import fs from "fs/promises";
import path from "path";
import defaults from "../../../common-utils/defaults.js";
import { getChallengesDir, challengeNameToDir, readChallengePackageJson } from "../../../challenge-packages/challenge-utils.js";
import {
getChallengesDir,
challengeNameToDir,
readChallengePackageJson,
reloadChallengesInDaemon
} from "../../../challenge-packages/challenge-utils.js";

export default class Remove extends Command {
export default class Remove extends BaseCommand {
static override description = "Remove an installed challenge package";

static override aliases = ["challenge:uninstall", "challenge:rm", "challenge:un"];
Expand Down Expand Up @@ -69,11 +75,8 @@ export default class Remove extends Command {

this.log(`removed ${args.name}${version}`);

// Best-effort reload via daemon
try {
await fetch("http://localhost:9138/api/challenges/reload", { method: "POST" });
} catch {
// daemon not running, that's fine
}
// Best-effort reload in the daemon at --pkcRpcUrl, so the removal takes effect
// without a restart
await reloadChallengesInDaemon(flags.pkcRpcUrl);
}
}
Loading