registry, verdaccio, portainer: full nox modules (ADR 0044/0046)
registry (docker v2): catalog/tags/delete tools, emits image.pushed (a build's image is now pullable). verdaccio (npm): list/info tools, emits package.published. portainer: endpoints/stacks/containers tools — tools-only, since its only events are the underlying containers' lifecycle, which the host owns. Typecheck; manifests parse.
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
// The Verdaccio (npm registry) client — verdaccio's own code, living in the module (novox/hq
|
||||
// ADR 0044). Both this module's tools and its events entrypoint import it, and nothing outside
|
||||
// verdaccio does.
|
||||
|
||||
export interface VerdaccioPackage {
|
||||
name: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
time?: string;
|
||||
}
|
||||
|
||||
export interface PackageInfo {
|
||||
name: string;
|
||||
latest?: string;
|
||||
versions: string[];
|
||||
description?: string;
|
||||
modified?: string;
|
||||
}
|
||||
|
||||
export class VerdaccioClient {
|
||||
readonly baseUrl: string;
|
||||
|
||||
// A bearer token is optional: package listing and reading are public on most registries, so the
|
||||
// token is sent only when configured, for a registry that gates reads behind auth.
|
||||
constructor(
|
||||
url: string,
|
||||
private readonly token?: string,
|
||||
) {
|
||||
this.baseUrl = url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build from the module's resolved environment. The URL is MESH_VERDACCIO_URL (or the local
|
||||
* port); an optional MESH_VERDACCIO_TOKEN authenticates. Throws when no URL is configured.
|
||||
*/
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): VerdaccioClient {
|
||||
const url = env.MESH_VERDACCIO_URL ?? `http://127.0.0.1:${env.VERDACCIO_PORT ?? "4873"}`;
|
||||
if (!url) throw new Error("no verdaccio URL — set MESH_VERDACCIO_URL");
|
||||
return new VerdaccioClient(url, env.MESH_VERDACCIO_TOKEN);
|
||||
}
|
||||
|
||||
private async getJson<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`Verdaccio ${path}: ${res.status} ${await res.text()}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every package the registry hosts, from Verdaccio's own web API — the same list its UI shows.
|
||||
* Each entry carries the latest version and the time it was last published.
|
||||
*/
|
||||
async listPackages(): Promise<VerdaccioPackage[]> {
|
||||
const raw = await this.getJson<any[]>("/-/verdaccio/data/packages");
|
||||
return (raw ?? []).map((p) => ({
|
||||
name: p.name,
|
||||
version: p.version ?? p["dist-tags"]?.latest,
|
||||
description: p.description,
|
||||
time: p.time?.modified ?? p.time,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The full detail of one package — its dist-tags, every published version, and timestamps —
|
||||
* from the standard npm packument endpoint (`GET /<name>`).
|
||||
*/
|
||||
async getPackageInfo(name: string): Promise<PackageInfo> {
|
||||
const doc = await this.getJson<any>(`/${encodeURIComponent(name).replace(/%2F/g, "/")}`);
|
||||
return {
|
||||
name: doc.name ?? name,
|
||||
latest: doc["dist-tags"]?.latest,
|
||||
versions: Object.keys(doc.versions ?? {}),
|
||||
description: doc.description,
|
||||
modified: doc.time?.modified,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// verdaccio's events. The tool runtime imports this once the broker is bound.
|
||||
//
|
||||
// Emits (novox/hq ADR 0046/0047):
|
||||
// module.verdaccio.package.published — a new package version was published to the registry
|
||||
//
|
||||
// A genuinely useful signal: a package was just published, so anything on the mesh that pins,
|
||||
// mirrors or announces dependency releases can react without polling the registry. Verdaccio has
|
||||
// no publish webhook, so the module discovers it by diffing the package list's latest versions.
|
||||
//
|
||||
// The polling is deliberately unhurried: a publish a minute late is still the event, whereas
|
||||
// hammering the registry for immediacy nobody asked for is not.
|
||||
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { VerdaccioClient } from "./client.js";
|
||||
|
||||
const verdaccio = VerdaccioClient.fromEnv();
|
||||
|
||||
// The latest version we have seen per package name. Primed silently on the first look so a registry
|
||||
// that was already populated when this started does not announce its whole catalog as freshly
|
||||
// published.
|
||||
const latest = new Map<string, string>();
|
||||
let primed = false;
|
||||
|
||||
async function pollPackages(): Promise<void> {
|
||||
const packages = await verdaccio.listPackages();
|
||||
for (const pkg of packages) {
|
||||
if (!pkg.version) continue;
|
||||
const known = latest.get(pkg.name);
|
||||
if (known !== pkg.version) {
|
||||
// A name we have not seen, or a name whose latest version moved — both are a publish.
|
||||
if (primed) await emit("module.verdaccio.package.published", { name: pkg.name, version: pkg.version });
|
||||
latest.set(pkg.name, pkg.version);
|
||||
}
|
||||
}
|
||||
primed = true;
|
||||
}
|
||||
|
||||
const tick = (fn: () => Promise<void>, everyMs: number): void => {
|
||||
const run = (): void => void fn().catch((err) => console.error(`[verdaccio] ${err}`));
|
||||
setInterval(run, everyMs);
|
||||
run();
|
||||
};
|
||||
tick(pollPackages, 60_000);
|
||||
|
||||
console.log("[verdaccio] watching the registry for newly published packages");
|
||||
@@ -4,6 +4,12 @@
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.verdaccio.package.published"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/verdaccio/broker"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"port": 4873,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-verdaccio",
|
||||
"version": "0.1.0",
|
||||
"description": "verdaccio — private npm registry. Its API client, tools and events live here (novox/hq ADR 0044).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// verdaccio's tools — its own code (novox/hq ADR 0044), importing verdaccio's own client. They
|
||||
// return structured data; the mesh serves them through the sdk's tool harness.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { VerdaccioClient } from "../client.js";
|
||||
|
||||
export function getVerdaccioTools(verdaccio: VerdaccioClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "verdaccio_list_packages",
|
||||
description: "List every package hosted on the private npm registry, with each one's latest version.",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const packages = await verdaccio.listPackages();
|
||||
return { count: packages.length, packages };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "verdaccio_package_info",
|
||||
description: "Details of one package on the registry: its latest tag, all published versions, and description.",
|
||||
input: { name: { type: "string", description: "the package name, e.g. '@novox/mesh-sdk'" } },
|
||||
run: async (args) => verdaccio.getPackageInfo(String(args.name)),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// The tools exist only when a registry URL is configured; otherwise verdaccio contributes none
|
||||
// rather than failing the whole runtime.
|
||||
registerModuleTools("verdaccio", (env) => {
|
||||
try {
|
||||
return getVerdaccioTools(VerdaccioClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user