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,97 @@
|
|||||||
|
// The Portainer API client — portainer's own code, living in the module (novox/hq ADR 0044).
|
||||||
|
// portainer is tools-only: its "events" would really be the underlying containers' lifecycle,
|
||||||
|
// which the host owns and emits — so this module reads Portainer's own resources (endpoints,
|
||||||
|
// stacks, containers) and exposes them, and stops there.
|
||||||
|
|
||||||
|
export interface PortainerEndpoint {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: number;
|
||||||
|
url: string;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortainerStack {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: number;
|
||||||
|
endpointId: number;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortainerContainer {
|
||||||
|
id: string;
|
||||||
|
names: string[];
|
||||||
|
image: string;
|
||||||
|
state: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PortainerClient {
|
||||||
|
readonly baseUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
url: string,
|
||||||
|
private readonly token: string,
|
||||||
|
) {
|
||||||
|
this.baseUrl = url.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build from the module's resolved environment. The URL is MESH_PORTAINER_URL (or the local
|
||||||
|
* dashboard port) and the API token is MESH_PORTAINER_TOKEN — an access token minted in
|
||||||
|
* Portainer, sent as X-API-Key. Throws when no token is configured, so a misconfigured module
|
||||||
|
* exposes nothing rather than calling Portainer unauthenticated.
|
||||||
|
*/
|
||||||
|
static fromEnv(env: NodeJS.ProcessEnv = process.env): PortainerClient {
|
||||||
|
const url = env.MESH_PORTAINER_URL ?? `https://127.0.0.1:${env.PORTAINER_PORT ?? "9443"}`;
|
||||||
|
const token = env.MESH_PORTAINER_TOKEN;
|
||||||
|
if (!token) throw new Error("no Portainer token — set MESH_PORTAINER_TOKEN");
|
||||||
|
return new PortainerClient(url, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async get<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(`${this.baseUrl}${path}`, { headers: { "X-API-Key": this.token } });
|
||||||
|
if (!res.ok) throw new Error(`Portainer ${path}: ${res.status} ${await res.text()}`);
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The environments (endpoints) Portainer manages — each a Docker host or cluster it talks to. */
|
||||||
|
async listEndpoints(): Promise<PortainerEndpoint[]> {
|
||||||
|
const raw = await this.get<any[]>("/api/endpoints");
|
||||||
|
return (raw ?? []).map((e) => ({
|
||||||
|
id: e.Id,
|
||||||
|
name: e.Name,
|
||||||
|
type: e.Type,
|
||||||
|
url: e.URL,
|
||||||
|
status: e.Status,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stacks (compose/swarm deployments) Portainer knows about. */
|
||||||
|
async listStacks(): Promise<PortainerStack[]> {
|
||||||
|
const raw = await this.get<any[]>("/api/stacks");
|
||||||
|
return (raw ?? []).map((s) => ({
|
||||||
|
id: s.Id,
|
||||||
|
name: s.Name,
|
||||||
|
type: s.Type,
|
||||||
|
endpointId: s.EndpointId,
|
||||||
|
status: s.Status,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The containers on one endpoint, read through Portainer's Docker API proxy. Includes stopped
|
||||||
|
* containers, so the caller sees the whole picture rather than only what is running.
|
||||||
|
*/
|
||||||
|
async listContainers(endpointId: number): Promise<PortainerContainer[]> {
|
||||||
|
const raw = await this.get<any[]>(`/api/endpoints/${endpointId}/docker/containers/json?all=1`);
|
||||||
|
return (raw ?? []).map((c) => ({
|
||||||
|
id: c.Id,
|
||||||
|
names: c.Names ?? [],
|
||||||
|
image: c.Image,
|
||||||
|
state: c.State,
|
||||||
|
status: c.Status,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@novox/module-portainer",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "portainer — container management UI. Its API client and tools 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,50 @@
|
|||||||
|
// portainer's tools — its own code (novox/hq ADR 0044), importing portainer's own client. They
|
||||||
|
// return structured data; the mesh serves them through the sdk's tool harness. portainer is
|
||||||
|
// tools-only (no events entrypoint): a container starting or stopping is the host's signal to emit,
|
||||||
|
// not Portainer's to re-announce.
|
||||||
|
|
||||||
|
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||||
|
import { PortainerClient } from "../client.js";
|
||||||
|
|
||||||
|
export function getPortainerTools(portainer: PortainerClient): ToolDefinition[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "portainer_endpoints",
|
||||||
|
description: "List the environments (endpoints) Portainer manages — each a Docker host or cluster.",
|
||||||
|
input: {},
|
||||||
|
run: async () => {
|
||||||
|
const endpoints = await portainer.listEndpoints();
|
||||||
|
return { count: endpoints.length, endpoints };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "portainer_stacks",
|
||||||
|
description: "List the stacks (compose/swarm deployments) Portainer knows about.",
|
||||||
|
input: {},
|
||||||
|
run: async () => {
|
||||||
|
const stacks = await portainer.listStacks();
|
||||||
|
return { count: stacks.length, stacks };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "portainer_containers",
|
||||||
|
description: "List the containers on one Portainer endpoint, including stopped ones.",
|
||||||
|
input: { endpoint: { type: "number", description: "the endpoint id (see portainer_endpoints)" } },
|
||||||
|
run: async (args) => {
|
||||||
|
const endpointId = Number(args.endpoint);
|
||||||
|
const containers = await portainer.listContainers(endpointId);
|
||||||
|
return { endpointId, count: containers.length, containers };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tools exist only when a token is configured; without one, portainer contributes none rather
|
||||||
|
// than failing the whole runtime.
|
||||||
|
registerModuleTools("portainer", (env) => {
|
||||||
|
try {
|
||||||
|
return getPortainerTools(PortainerClient.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", "tools/index.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// The Docker Registry v2 client — registry's own code, living in the module (novox/hq ADR 0044).
|
||||||
|
// Ported from the shared hal sdk, where a change to the registry API rebuilt everything; here it
|
||||||
|
// rebuilds only registry. Both this module's tools and its events entrypoint import it.
|
||||||
|
|
||||||
|
export interface RegistryImage {
|
||||||
|
repo: string;
|
||||||
|
tag: string;
|
||||||
|
digest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RegistryClient {
|
||||||
|
readonly baseUrl: string;
|
||||||
|
|
||||||
|
// Auth is optional: a mesh-internal registry often runs open on the node, so a Basic header is
|
||||||
|
// sent only when credentials were configured — an empty one would look like a failed login.
|
||||||
|
constructor(
|
||||||
|
url: string,
|
||||||
|
private readonly authHeader?: string,
|
||||||
|
) {
|
||||||
|
this.baseUrl = url.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build from the module's resolved environment. The URL is MESH_REGISTRY_URL (or the local
|
||||||
|
* registry port), and credentials — if the registry requires them — are MESH_REGISTRY_USER and
|
||||||
|
* MESH_REGISTRY_PASSWORD. Throws when no URL is configured, so a misconfigured module exposes
|
||||||
|
* nothing rather than talking to the wrong place.
|
||||||
|
*/
|
||||||
|
static fromEnv(env: NodeJS.ProcessEnv = process.env): RegistryClient {
|
||||||
|
const url = env.MESH_REGISTRY_URL ?? `http://127.0.0.1:${env.REGISTRY_PORT ?? "5000"}`;
|
||||||
|
if (!url) throw new Error("no registry URL — set MESH_REGISTRY_URL");
|
||||||
|
const user = env.MESH_REGISTRY_USER;
|
||||||
|
const password = env.MESH_REGISTRY_PASSWORD;
|
||||||
|
const authHeader =
|
||||||
|
user && password ? `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}` : undefined;
|
||||||
|
return new RegistryClient(url, authHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
private headers(extra: Record<string, string> = {}): Record<string, string> {
|
||||||
|
return { ...(this.authHeader ? { Authorization: this.authHeader } : {}), ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getJson<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(`${this.baseUrl}${path}`, { headers: this.headers() });
|
||||||
|
if (!res.ok) throw new Error(`Registry ${path}: ${res.status} ${await res.text()}`);
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The catalog — every repository the registry holds. */
|
||||||
|
async listRepositories(): Promise<string[]> {
|
||||||
|
const data = await this.getJson<{ repositories: string[] | null }>("/v2/_catalog");
|
||||||
|
return data.repositories ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tags of one repository. */
|
||||||
|
async listTags(repo: string): Promise<string[]> {
|
||||||
|
const data = await this.getJson<{ tags: string[] | null }>(`/v2/${repo}/tags/list`);
|
||||||
|
return data.tags ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The content digest of a repo:tag — the stable identity a tag currently points at. */
|
||||||
|
async getManifestDigest(repo: string, tag: string): Promise<string> {
|
||||||
|
const res = await fetch(`${this.baseUrl}/v2/${repo}/manifests/${tag}`, {
|
||||||
|
method: "HEAD",
|
||||||
|
headers: this.headers({ Accept: "application/vnd.docker.distribution.manifest.v2+json" }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Registry manifest ${repo}:${tag}: ${res.status} ${await res.text()}`);
|
||||||
|
const digest = res.headers.get("docker-content-digest");
|
||||||
|
if (!digest) throw new Error(`no Docker-Content-Digest for ${repo}:${tag}`);
|
||||||
|
return digest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a manifest by digest. Garbage collection reclaims the storage later. */
|
||||||
|
async deleteManifest(repo: string, digest: string): Promise<void> {
|
||||||
|
const res = await fetch(`${this.baseUrl}/v2/${repo}/manifests/${digest}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: this.headers({ Accept: "application/vnd.docker.distribution.manifest.v2+json" }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Registry delete ${repo}@${digest}: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// registry's events. The tool runtime imports this once the broker is bound.
|
||||||
|
//
|
||||||
|
// Emits (novox/hq ADR 0046/0047):
|
||||||
|
// module.registry.image.pushed — a new image (repo:tag) was published to the registry
|
||||||
|
//
|
||||||
|
// This is a genuinely useful signal: a build finished and its image is now pullable, so anything
|
||||||
|
// on the mesh that redeploys, mirrors or announces releases can react without polling the registry
|
||||||
|
// itself. It is discovered by diffing the catalog and each repo's tags — the registry has no push
|
||||||
|
// webhook of its own, so the module watches for it.
|
||||||
|
//
|
||||||
|
// The polling is deliberately unhurried: a new image a minute late is still the event, whereas
|
||||||
|
// hammering the registry's catalog for immediacy nobody asked for is not.
|
||||||
|
|
||||||
|
import { emit } from "@novox/mesh-sdk/events";
|
||||||
|
import { RegistryClient } from "./client.js";
|
||||||
|
|
||||||
|
const registry = RegistryClient.fromEnv();
|
||||||
|
|
||||||
|
// Every repo:tag we have already accounted for. Primed silently on the first look so a registry
|
||||||
|
// that was already full when this started does not announce its whole history as freshly pushed.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let primed = false;
|
||||||
|
|
||||||
|
async function pollCatalog(): Promise<void> {
|
||||||
|
const repos = await registry.listRepositories();
|
||||||
|
for (const repo of repos) {
|
||||||
|
let tags: string[];
|
||||||
|
try {
|
||||||
|
tags = await registry.listTags(repo);
|
||||||
|
} catch {
|
||||||
|
continue; // a repo can vanish between catalog and tag read — skip it, catch it next tick
|
||||||
|
}
|
||||||
|
for (const tag of tags) {
|
||||||
|
const id = `${repo}:${tag}`;
|
||||||
|
if (!seen.has(id)) {
|
||||||
|
if (primed) await emit("module.registry.image.pushed", { repo, tag });
|
||||||
|
seen.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
primed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = (fn: () => Promise<void>, everyMs: number): void => {
|
||||||
|
const run = (): void => void fn().catch((err) => console.error(`[registry] ${err}`));
|
||||||
|
setInterval(run, everyMs);
|
||||||
|
run();
|
||||||
|
};
|
||||||
|
tick(pollCatalog, 60_000);
|
||||||
|
|
||||||
|
console.log("[registry] watching the catalog for newly pushed images");
|
||||||
@@ -16,6 +16,12 @@
|
|||||||
"capabilities": [
|
"capabilities": [
|
||||||
"container-runtime"
|
"container-runtime"
|
||||||
],
|
],
|
||||||
|
"emits": [
|
||||||
|
"module.registry.image.pushed"
|
||||||
|
],
|
||||||
|
"own-secrets": {
|
||||||
|
"broker": "/var/lib/registry/broker"
|
||||||
|
},
|
||||||
"serves": {
|
"serves": {
|
||||||
"artifact-store": {
|
"artifact-store": {
|
||||||
"port": 5000
|
"port": 5000
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@novox/module-registry",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "registry — private Docker image 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,59 @@
|
|||||||
|
// registry's tools — moved here from the shared sdk (novox/hq ADR 0044), importing registry'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 { RegistryClient } from "../client.js";
|
||||||
|
|
||||||
|
export function getRegistryTools(registry: RegistryClient): ToolDefinition[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "registry_list",
|
||||||
|
description: "List every repository in the Docker registry (the catalog).",
|
||||||
|
input: {},
|
||||||
|
run: async () => {
|
||||||
|
const repositories = await registry.listRepositories();
|
||||||
|
return { count: repositories.length, repositories };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "registry_tags",
|
||||||
|
description: "List the tags of one repository in the Docker registry.",
|
||||||
|
input: { repo: { type: "string", description: "the repository name, e.g. 'novox/mesh'" } },
|
||||||
|
run: async (args) => {
|
||||||
|
const repo = String(args.repo);
|
||||||
|
const tags = await registry.listTags(repo);
|
||||||
|
return { repo, count: tags.length, tags };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "registry_delete_image",
|
||||||
|
description:
|
||||||
|
"Delete an image tag from the registry (DESTRUCTIVE). Removes the manifest; storage is reclaimed by garbage collection later. Requires confirm: true.",
|
||||||
|
input: {
|
||||||
|
repo: { type: "string", description: "the repository name, e.g. 'novox/mesh'" },
|
||||||
|
tag: { type: "string", description: "the tag to delete, e.g. 'latest'" },
|
||||||
|
confirm: { type: "boolean", description: "must be true to actually delete" },
|
||||||
|
},
|
||||||
|
run: async (args) => {
|
||||||
|
const repo = String(args.repo);
|
||||||
|
const tag = String(args.tag);
|
||||||
|
if (args.confirm !== true) {
|
||||||
|
return { deleted: false, reason: "confirm must be true to delete an image" };
|
||||||
|
}
|
||||||
|
const digest = await registry.getManifestDigest(repo, tag);
|
||||||
|
await registry.deleteManifest(repo, digest);
|
||||||
|
return { deleted: true, repo, tag, digest, note: "run registry garbage collection to reclaim storage" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tools exist only when a registry URL is configured; otherwise registry contributes none
|
||||||
|
// rather than failing the whole runtime.
|
||||||
|
registerModuleTools("registry", (env) => {
|
||||||
|
try {
|
||||||
|
return getRegistryTools(RegistryClient.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"]
|
||||||
|
}
|
||||||
@@ -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": [
|
"capabilities": [
|
||||||
"container-runtime"
|
"container-runtime"
|
||||||
],
|
],
|
||||||
|
"emits": [
|
||||||
|
"module.verdaccio.package.published"
|
||||||
|
],
|
||||||
|
"own-secrets": {
|
||||||
|
"broker": "/var/lib/verdaccio/broker"
|
||||||
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
"port": 4873,
|
"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