@danypops/pi-packed 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/catalog.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * catalog.ts — event producer #2: bulk snapshot of every pi package.
3
+ * npm's search API has no ETag/conditional requests (CDN max-age only),
4
+ * so freshness is TTL-based: resync on serve start + interval.
5
+ */
6
+ import { writeFile, readFile, mkdir } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import type { Pkg, Registry } from "./ports.ts";
9
+
10
+ export interface CatalogSnapshot {
11
+ fetchedAt: string;
12
+ packages: Pkg[];
13
+ }
14
+
15
+ function catalogPath(dir: string): string {
16
+ return join(dir, "catalog.json");
17
+ }
18
+
19
+ export async function saveCatalog(dir: string, snap: CatalogSnapshot): Promise<void> {
20
+ await mkdir(dir, { recursive: true });
21
+ await writeFile(catalogPath(dir), JSON.stringify(snap), { mode: 0o600 });
22
+ }
23
+
24
+ export async function loadCatalog(dir: string): Promise<CatalogSnapshot | undefined> {
25
+ try {
26
+ return JSON.parse(await readFile(catalogPath(dir), "utf8")) as CatalogSnapshot;
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ }
31
+
32
+ export function catalogStale(snap: CatalogSnapshot | undefined, ttlMs: number): boolean {
33
+ if (!snap?.fetchedAt) return true;
34
+ return Date.now() - Date.parse(snap.fetchedAt) > ttlMs;
35
+ }
36
+
37
+ /** Full sync: paginate the whole keyword universe into a lean snapshot. */
38
+ export async function syncCatalog(reg: Registry, dir: string, query = "keywords:pi-package"): Promise<number> {
39
+ const packages = await reg.searchAll(query);
40
+ await saveCatalog(dir, { fetchedAt: new Date().toISOString(), packages });
41
+ return packages.length;
42
+ }
43
+
44
+ export function startCatalogSync(
45
+ reg: Registry,
46
+ dir: string,
47
+ ttlMs: number,
48
+ onError?: (e: unknown) => void,
49
+ ): () => void {
50
+ async function sync(): Promise<void> {
51
+ try {
52
+ if (catalogStale(await loadCatalog(dir), ttlMs)) {
53
+ const n = await syncCatalog(reg, dir);
54
+ console.error(`[packed] catalog synced: ${n} packages`);
55
+ }
56
+ } catch (e) {
57
+ onError?.(e);
58
+ }
59
+ }
60
+ void sync();
61
+ const timer = setInterval(() => void sync(), ttlMs);
62
+ return () => clearInterval(timer);
63
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,239 @@
1
+ /**
2
+ * cli.ts — second driving adapter (humans drive the same hexagon ports).
3
+ * cliRun is pure: ({code, out}) in, no I/O — the entry point prints.
4
+ * Command table follows the go-tool/Cobra convention; flags may appear
5
+ * anywhere (agents put them anywhere).
6
+ */
7
+ import { buildSearchQuery, clampLimit } from "./ports.ts";
8
+ import type { Installer, Registry } from "./ports.ts";
9
+ import { readInstalledPackages } from "./installed.ts";
10
+ import { checkUpdates, loadUpdates } from "./watcher.ts";
11
+ import { loadCatalog } from "./catalog.ts";
12
+ import { NAME_RE } from "./install.ts";
13
+
14
+ const SOURCE_RE = /^(npm:[A-Za-z0-9@._/-]+|git:[A-Za-z0-9@:._/-]+|https:\/\/[A-Za-z0-9@:._/?=&%~-]+)$/;
15
+
16
+ const USAGE = `packed — package service for the Pi agent
17
+
18
+ usage:
19
+ packed search <query> [--limit N] [--json] search pi packages on npm
20
+ packed info <name> [--json] package details
21
+ packed updates [--cached] [--json] available updates (cached = daemon snapshot)
22
+ packed installed [--json] installed pi packages
23
+ packed catalog [--json] full pi-package catalog snapshot
24
+ packed install <source> pi install npm:|git:|https://…
25
+ packed remove <name> remove by bare npm name
26
+ packed serve run the long-running daemon
27
+ packed service print a systemd user unit
28
+ packed version print version
29
+ `;
30
+
31
+ export interface CliDeps {
32
+ reg: Registry;
33
+ inst: Installer;
34
+ stateDir: string;
35
+ piHome: string;
36
+ execPath?: string; // bun binary (defaults to process.execPath)
37
+ cliPath?: string; // this CLI's entry file (for the systemd unit)
38
+ }
39
+
40
+ export interface CliResult {
41
+ code: number;
42
+ out: string;
43
+ }
44
+
45
+ interface Flags {
46
+ json: boolean;
47
+ limit: number;
48
+ cached: boolean;
49
+ }
50
+
51
+ function parseFlags(rest: string[]): { flags: Flags; pos: string[] } {
52
+ const flags: Flags = { json: false, limit: 10, cached: false };
53
+ const pos: string[] = [];
54
+ for (let i = 0; i < rest.length; i++) {
55
+ const a = rest[i]!;
56
+ if (a === "--json") flags.json = true;
57
+ else if (a === "--cached") flags.cached = true;
58
+ else if (a === "--limit" && i + 1 < rest.length) flags.limit = Number(rest[++i]) || 10;
59
+ else if (a.startsWith("--limit=")) flags.limit = Number(a.slice(8)) || 10;
60
+ else pos.push(a);
61
+ }
62
+ return { flags, pos };
63
+ }
64
+
65
+ type Command = (rest: string[], d: CliDeps, flags: Flags, pos: string[]) => Promise<CliResult>;
66
+
67
+ const ok = (out: string): CliResult => ({ code: 0, out });
68
+ const fail = (out: string, code = 1): CliResult => ({ code, out });
69
+ const usageErr = (out: string): CliResult => ({ code: 2, out });
70
+
71
+ const commands: Record<string, { usage: string; run: Command }> = {
72
+ search: {
73
+ usage: "packed search <query> [--limit N] [--json]",
74
+ async run(_rest, d, flags, pos) {
75
+ const q = pos[0];
76
+ if (!q) return usageErr(`usage: ${commands["search"]!.usage}\n`);
77
+ const { results, total } = await d.reg.search(buildSearchQuery(q), clampLimit(flags.limit, 10, 50));
78
+ if (flags.json) return ok(JSON.stringify({ query: q, total, results }) + "\n");
79
+ if (results.length === 0) return ok(`no pi packages found for "${q}"\n`);
80
+ let out = `${total} package(s) (showing ${results.length}):\n\n`;
81
+ for (const p of results) out += ` ${p.name}@${p.version}\n ${p.description ?? ""}\n`;
82
+ return ok(out);
83
+ },
84
+ },
85
+
86
+ info: {
87
+ usage: "packed info <name> [--json]",
88
+ async run(_rest, d, flags, pos) {
89
+ const name = pos[0];
90
+ if (!name) return usageErr(`usage: ${commands["info"]!.usage}\n`);
91
+ const info = await d.reg.info(name);
92
+ if (flags.json) return ok(JSON.stringify(info) + "\n");
93
+ let out = `${info.name}@${info.version}\n${info.description ?? ""}\n`;
94
+ if (info.repository) out += `repo: ${info.repository}\n`;
95
+ if (info.pi) out += `provides: ${Object.keys(info.pi).join(", ")}\n`;
96
+ return ok(out);
97
+ },
98
+ },
99
+
100
+ updates: {
101
+ usage: "packed updates [--cached] [--json]",
102
+ async run(_rest, d, flags) {
103
+ let updates;
104
+ if (flags.cached) {
105
+ updates = (await loadUpdates(d.stateDir))?.updates ?? [];
106
+ } else {
107
+ updates = await checkUpdates(d.reg, readInstalledPackages(d.piHome));
108
+ }
109
+ if (flags.json) {
110
+ return ok(JSON.stringify({ checkedAt: new Date().toISOString(), updates }) + "\n");
111
+ }
112
+ if (updates.length === 0) return ok("all pi packages up to date\n");
113
+ let out = `${updates.length} update(s) available:\n\n`;
114
+ for (const u of updates) out += ` ${u.name} ${u.installed} → ${u.latest}\n`;
115
+ return ok(out + "\nrun: pi update --extensions\n");
116
+ },
117
+ },
118
+
119
+ installed: {
120
+ usage: "packed installed [--json]",
121
+ async run(_rest, d, flags) {
122
+ const installed = readInstalledPackages(d.piHome);
123
+ if (flags.json) return ok(JSON.stringify(installed) + "\n");
124
+ return ok(installed.map((p) => ` ${p.name}@${p.pinned ?? p.installed ?? "?"}\n`).join(""));
125
+ },
126
+ },
127
+
128
+ catalog: {
129
+ usage: "packed catalog [--json]",
130
+ async run(_rest, d, flags) {
131
+ const snap = await loadCatalog(d.stateDir);
132
+ const packages = snap?.packages ?? [];
133
+ if (flags.json) return ok(JSON.stringify(snap ?? { packages: [] }) + "\n");
134
+ let out = `${packages.length} packages in catalog (fetched ${snap?.fetchedAt ?? "never"})\n\n`;
135
+ for (const p of packages.slice(0, 50)) out += ` ${p.name}@${p.version}\n`;
136
+ return ok(out);
137
+ },
138
+ },
139
+
140
+ install: {
141
+ usage: "packed install npm:<pkg>[@ver] | git:<host>/<owner>/<repo>[@ref] | https://…",
142
+ async run(_rest, d, _flags, pos) {
143
+ const source = pos[0] ?? "";
144
+ if (!SOURCE_RE.test(source)) return usageErr(`usage: ${commands["install"]!.usage}\n`);
145
+ try {
146
+ return ok((await d.inst.install(source)) + "\n");
147
+ } catch (e) {
148
+ return fail(`${e instanceof Error ? e.message : e}\n`);
149
+ }
150
+ },
151
+ },
152
+
153
+ remove: {
154
+ usage: "packed remove <name> (bare npm name, e.g. pi-lsp or @scope/pkg)",
155
+ async run(_rest, d, _flags, pos) {
156
+ const name = pos[0] ?? "";
157
+ if (!NAME_RE.test(name)) return usageErr(`usage: ${commands["remove"]!.usage}\n`);
158
+ try {
159
+ return ok((await d.inst.remove(`npm:${name}`)) + "\n");
160
+ } catch (e) {
161
+ return fail(`${e instanceof Error ? e.message : e}\n`);
162
+ }
163
+ },
164
+ },
165
+
166
+ service: {
167
+ usage: "packed service (print a systemd user unit to stdout)",
168
+ async run(_rest, d) {
169
+ const execPath = d.execPath ?? process.execPath;
170
+ const cliPath = d.cliPath ?? new URL("./cli.ts", import.meta.url).pathname;
171
+ return ok(renderUnit(execPath, cliPath));
172
+ },
173
+ },
174
+
175
+ version: {
176
+ usage: "packed version",
177
+ async run() {
178
+ return ok("0.1.0\n");
179
+ },
180
+ },
181
+ };
182
+
183
+ /** systemd user unit. Idle self-exit is disabled — systemd owns the
184
+ * lifecycle (Restart=on-failure takes over). */
185
+ export function renderUnit(execPath: string, cliPath: string): string {
186
+ return `[Unit]
187
+ Description=pi-packed package service (Pi agent)
188
+
189
+ [Service]
190
+ Type=simple
191
+ ExecStart=${execPath} ${cliPath} serve
192
+ Restart=on-failure
193
+ RestartSec=2
194
+ Environment=PI_PACKED_IDLE_SECS=0
195
+ NoNewPrivileges=true
196
+
197
+ [Install]
198
+ WantedBy=default.target
199
+ `;
200
+ }
201
+
202
+ export async function cliRun(args: string[], d: CliDeps): Promise<CliResult> {
203
+ const [name, ...rest] = args;
204
+ if (!name) return usageErr(USAGE);
205
+ if (name === "help" || name === "--help" || name === "-h") return { code: 0, out: USAGE };
206
+ const cmd = commands[name];
207
+ if (!cmd) return usageErr(`unknown command "${name}"\n${USAGE}`);
208
+ const { flags, pos } = parseFlags(rest);
209
+ try {
210
+ return await cmd.run(rest, d, flags, pos);
211
+ } catch (e) {
212
+ return fail(`${name} failed: ${e instanceof Error ? e.message : e}\n`);
213
+ }
214
+ }
215
+
216
+ // Entry point (bun src/cli.ts …). `serve` is dispatched before any proxying
217
+ // so the daemon always talks directly to npm.
218
+ if (import.meta.main) {
219
+ const args = process.argv.slice(2);
220
+ if (args[0] === "serve") {
221
+ const { serveMain } = await import("./daemon.ts");
222
+ serveMain();
223
+ } else {
224
+ const { stateDir } = await import("./state.ts");
225
+ const { defaultPiHome } = await import("./installed.ts");
226
+ const { resolveRegistry } = await import("./client.ts");
227
+ const { ExecInstaller } = await import("./install.ts");
228
+ const dir = stateDir();
229
+ const reg = await resolveRegistry(dir, "https://registry.npmjs.org");
230
+ const { code, out } = await cliRun(args, {
231
+ reg,
232
+ inst: new ExecInstaller(),
233
+ stateDir: dir,
234
+ piHome: defaultPiHome(),
235
+ });
236
+ process.stdout.write(out);
237
+ process.exit(code);
238
+ }
239
+ }
package/src/client.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * client.ts — daemonRegistry: remote proxy implementing the Registry port
3
+ * over loopback HTTP. resolveRegistry routes CLI to the warm daemon when
4
+ * reachable, straight to npm otherwise.
5
+ */
6
+ import { readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import type { PkgInfo, Registry, SearchPage } from "./ports.ts";
9
+ import { HttpRegistry } from "./registry.ts";
10
+
11
+ export class DaemonRegistry implements Registry {
12
+ constructor(
13
+ private base: string,
14
+ private token: string,
15
+ ) {}
16
+
17
+ private async get<T>(path: string): Promise<T> {
18
+ const res = await fetch(`${this.base}${path}`, {
19
+ headers: { authorization: `Bearer ${this.token}` },
20
+ signal: AbortSignal.timeout(15_000),
21
+ });
22
+ if (!res.ok) throw new Error(`daemon HTTP ${res.status}`);
23
+ return (await res.json()) as T;
24
+ }
25
+
26
+ async search(query: string, limit: number): Promise<SearchPage> {
27
+ const body = await this.get<{ results: SearchPage["results"]; total: number }>(
28
+ `/search?q=${encodeURIComponent(query)}&limit=${limit}`,
29
+ );
30
+ return { results: body.results, total: body.total };
31
+ }
32
+
33
+ async searchPage(query: string, from: number, size: number): Promise<SearchPage> {
34
+ // The daemon clamps to 50; page through it for bulk reads.
35
+ return this.search(query, size).catch(() => ({ results: [], total: 0 }));
36
+ }
37
+
38
+ async searchAll(): Promise<never> {
39
+ // Bulk sync runs inside the daemon against the direct registry;
40
+ // the proxy never paginates the full universe through the clamp.
41
+ throw new Error("searchAll is not supported via the daemon proxy");
42
+ }
43
+
44
+ async info(name: string): Promise<PkgInfo> {
45
+ return this.get<PkgInfo>(`/info?name=${encodeURIComponent(name)}`);
46
+ }
47
+ }
48
+
49
+ export interface DaemonHandle {
50
+ base: string;
51
+ token: string;
52
+ }
53
+
54
+ export async function probe(dir: string): Promise<DaemonHandle | undefined> {
55
+ let port: string;
56
+ let token: string;
57
+ try {
58
+ port = readFileSync(join(dir, "port"), "utf8").trim();
59
+ token = readFileSync(join(dir, "token"), "utf8").trim();
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ const base = `http://127.0.0.1:${port}`;
64
+ try {
65
+ const res = await fetch(`${base}/health`, {
66
+ headers: { authorization: `Bearer ${token}` },
67
+ signal: AbortSignal.timeout(800),
68
+ });
69
+ if (res.ok) return { base, token };
70
+ } catch {
71
+ /* dead daemon or stale files */
72
+ }
73
+ return undefined;
74
+ }
75
+
76
+ export async function resolveRegistry(dir: string, npmBase: string): Promise<Registry> {
77
+ const handle = await probe(dir);
78
+ if (handle) return new DaemonRegistry(handle.base, handle.token);
79
+ return new HttpRegistry(npmBase);
80
+ }
package/src/daemon.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * daemon.ts — serve mode: thin wiring around tested units. Bun.serve wraps
3
+ * the hexagon's fetch handler; watcher + catalogSync produce snapshots;
4
+ * idle watchdog self-terminates (the daemon is spawned on demand).
5
+ */
6
+ import { createApp } from "./service.ts";
7
+ import { HttpRegistry } from "./registry.ts";
8
+ import { ExecInstaller } from "./install.ts";
9
+ import { loadOrCreateToken, writePort, idleExpired, envMs, stateDir } from "./state.ts";
10
+ import { startWatcher } from "./watcher.ts";
11
+ import { startCatalogSync } from "./catalog.ts";
12
+ import { readInstalledPackages, defaultPiHome } from "./installed.ts";
13
+
14
+ export function serveMain(): void {
15
+ const dir = stateDir();
16
+ const token = loadOrCreateToken(dir);
17
+ const reg = new HttpRegistry();
18
+ const app = createApp({ reg, inst: new ExecInstaller(), token, stateDir: dir });
19
+
20
+ let lastActive = Date.now();
21
+ const server = Bun.serve({
22
+ port: 0,
23
+ hostname: "127.0.0.1",
24
+ fetch: (req) => {
25
+ lastActive = Date.now();
26
+ return app.fetch(req);
27
+ },
28
+ });
29
+ if (!server.port) throw new Error("failed to bind listener");
30
+ writePort(dir, server.port);
31
+
32
+ const stopWatcher = startWatcher(reg, dir, () => readInstalledPackages(defaultPiHome()), {
33
+ intervalMs: envMs("PI_PACKED_WATCH_SECS", 30 * 60_000),
34
+ });
35
+ const stopCatalog = startCatalogSync(reg, dir, envMs("PI_PACKED_CATALOG_SECS", 6 * 3_600_000));
36
+
37
+ // Idle watchdog: for on-demand spawns. PI_PACKED_IDLE_SECS=0 disables it
38
+ // (systemd or another supervisor owns the lifecycle then).
39
+ const idleBudget = envMs("PI_PACKED_IDLE_SECS", 10 * 60_000);
40
+ let watchdog: ReturnType<typeof setInterval> | undefined;
41
+ if (idleBudget > 0) {
42
+ watchdog = setInterval(() => {
43
+ if (idleExpired(lastActive, Date.now(), idleBudget)) {
44
+ console.error(`[packed] idle for ${idleBudget / 1000}s, exiting`);
45
+ shutdown();
46
+ }
47
+ }, 15_000);
48
+ }
49
+
50
+ function shutdown(): void {
51
+ if (watchdog) clearInterval(watchdog);
52
+ stopWatcher();
53
+ stopCatalog();
54
+ server.stop(true);
55
+ process.exit(0);
56
+ }
57
+ process.on("SIGTERM", shutdown);
58
+ process.on("SIGINT", shutdown);
59
+
60
+ console.error(
61
+ `[packed] listening on 127.0.0.1:${server.port} (state ${dir}, watch ${envMs("PI_PACKED_WATCH_SECS", 30 * 60_000) / 1000}s, idle ${idleBudget / 1000}s)`,
62
+ );
63
+ }
package/src/install.ts ADDED
@@ -0,0 +1,29 @@
1
+ /** install.ts — driven adapter: pi CLI mutations via Bun.spawn. */
2
+ import type { Installer } from "./ports.ts";
3
+
4
+ /** Bare npm package name (for `packed remove`). */
5
+ export const NAME_RE = /^(@[A-Za-z0-9._-]+\/)?[A-Za-z0-9._-]+$/;
6
+
7
+ export class ExecInstaller implements Installer {
8
+ constructor(private bin = "pi") {}
9
+
10
+ private async run(args: string[]): Promise<string> {
11
+ const proc = Bun.spawn([this.bin, ...args], { stdout: "pipe", stderr: "pipe" });
12
+ const [stdout, stderr] = await Promise.all([
13
+ new Response(proc.stdout).text(),
14
+ new Response(proc.stderr).text(),
15
+ ]);
16
+ const out = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
17
+ const code = await proc.exited;
18
+ if (code !== 0) throw new Error(out || `exit ${code}`);
19
+ return out;
20
+ }
21
+
22
+ install(source: string): Promise<string> {
23
+ return this.run(["install", source]);
24
+ }
25
+
26
+ remove(source: string): Promise<string> {
27
+ return this.run(["remove", source]);
28
+ }
29
+ }
@@ -0,0 +1,56 @@
1
+ /** installed.ts — pi's settings.json is the source of truth for what is
2
+ * installed; node_modules supplies versions for unpinned sources. */
3
+ import { readFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import type { InstalledPkg } from "./ports.ts";
7
+
8
+ export function splitNpmSource(spec: string): [name: string, version: string] {
9
+ const i = spec.lastIndexOf("@");
10
+ if (i <= 0) return [spec, ""];
11
+ return [spec.slice(0, i), spec.slice(i + 1)];
12
+ }
13
+
14
+ function extractSource(entry: unknown): string {
15
+ if (typeof entry === "string") return entry;
16
+ if (entry && typeof entry === "object") {
17
+ const s = (entry as Record<string, unknown>)["source"];
18
+ if (typeof s === "string") return s;
19
+ }
20
+ return "";
21
+ }
22
+
23
+ function nodeModulesVersion(piHome: string, name: string): string | undefined {
24
+ try {
25
+ const pkg = JSON.parse(readFileSync(join(piHome, "npm", "node_modules", name, "package.json"), "utf8"));
26
+ return typeof pkg.version === "string" ? pkg.version : undefined;
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ }
31
+
32
+ export function readInstalledPackages(piHome: string): InstalledPkg[] {
33
+ let settings: { packages?: unknown[] };
34
+ try {
35
+ settings = JSON.parse(readFileSync(join(piHome, "settings.json"), "utf8"));
36
+ } catch {
37
+ return [];
38
+ }
39
+ const out: InstalledPkg[] = [];
40
+ for (const raw of settings.packages ?? []) {
41
+ const source = extractSource(raw);
42
+ if (!source.startsWith("npm:")) continue;
43
+ const [name, pinned] = splitNpmSource(source.slice(4));
44
+ out.push({
45
+ name,
46
+ pinned: pinned || undefined,
47
+ installed: pinned ? undefined : nodeModulesVersion(piHome, name),
48
+ });
49
+ }
50
+ return out;
51
+ }
52
+
53
+ export function defaultPiHome(): string {
54
+ if (process.env["PI_PACKED_PI_HOME"]) return process.env["PI_PACKED_PI_HOME"];
55
+ return join(homedir(), ".pi", "agent");
56
+ }
package/src/ports.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * ports.ts — the hexagon's driven ports and lean domain types.
3
+ * Driving adapters: HTTP service (service.ts), CLI (cli.ts), watcher,
4
+ * tests. Driven adapters: registry.ts (npm), install.ts (pi exec).
5
+ */
6
+
7
+ export interface Pkg {
8
+ name: string;
9
+ version: string;
10
+ description?: string;
11
+ date?: string;
12
+ }
13
+
14
+ export interface PkgInfo {
15
+ name: string;
16
+ version: string;
17
+ description?: string;
18
+ homepage?: string;
19
+ repository?: string;
20
+ license?: string;
21
+ keywords?: string[];
22
+ pi?: Record<string, unknown>;
23
+ modified?: string;
24
+ unpackedSize?: number;
25
+ }
26
+
27
+ export interface SearchPage {
28
+ results: Pkg[];
29
+ total: number;
30
+ }
31
+
32
+ /** Driven port: package metadata source (npm registry, or the daemon proxy). */
33
+ export interface Registry {
34
+ search(query: string, limit: number): Promise<SearchPage>;
35
+ searchPage(query: string, from: number, size: number): Promise<SearchPage>;
36
+ searchAll(query: string): Promise<Pkg[]>;
37
+ info(name: string): Promise<PkgInfo>;
38
+ }
39
+
40
+ /** Driven port: pi CLI mutations. */
41
+ export interface Installer {
42
+ install(source: string): Promise<string>;
43
+ remove(source: string): Promise<string>;
44
+ }
45
+
46
+ export interface InstalledPkg {
47
+ name: string;
48
+ pinned?: string;
49
+ installed?: string;
50
+ }
51
+
52
+ export interface UpdateEntry {
53
+ name: string;
54
+ installed: string;
55
+ latest: string;
56
+ detectedAt: string;
57
+ }
58
+
59
+ export interface UpdatesSnapshot {
60
+ checkedAt: string;
61
+ updates: UpdateEntry[];
62
+ }
63
+
64
+ /** Scope every query to pi packages unless the caller already qualified it. */
65
+ export function buildSearchQuery(q: string): string {
66
+ const t = q.trim();
67
+ if (t.includes("keywords:")) return t;
68
+ return t === "" ? "keywords:pi-package" : `keywords:pi-package ${t}`;
69
+ }
70
+
71
+ export function clampLimit(v: number, def: number, max: number): number {
72
+ if (!Number.isFinite(v) || v <= 0) return def;
73
+ return Math.min(v, max);
74
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * registry.ts — driven adapter: npm registry over HTTP (web-standard fetch).
3
+ * Lean mapping = Facade over npm's verbose package documents.
4
+ */
5
+ import type { Pkg, PkgInfo, Registry, SearchPage } from "./ports.ts";
6
+
7
+ export class HttpRegistry implements Registry {
8
+ constructor(
9
+ private base = "https://registry.npmjs.org",
10
+ private pageSize = 250,
11
+ ) {}
12
+
13
+ async search(query: string, limit: number): Promise<SearchPage> {
14
+ return this.searchPage(query, 0, limit);
15
+ }
16
+
17
+ async searchPage(query: string, from: number, size: number): Promise<SearchPage> {
18
+ const params = new URLSearchParams({ text: query, size: String(size), from: String(from) });
19
+ const res = await fetch(`${this.base}/-/v1/search?${params}`);
20
+ if (!res.ok) throw new Error(`npm search: HTTP ${res.status}`);
21
+ const doc = (await res.json()) as {
22
+ total?: number;
23
+ objects?: { package?: { name?: string; version?: string; description?: string; date?: string } }[];
24
+ };
25
+ const results: Pkg[] = (doc.objects ?? []).flatMap((o) => {
26
+ const p = o.package;
27
+ return p?.name
28
+ ? [{ name: p.name, version: p.version ?? "", description: p.description, date: p.date }]
29
+ : [];
30
+ });
31
+ return { results, total: doc.total ?? results.length };
32
+ }
33
+
34
+ async searchAll(query: string): Promise<Pkg[]> {
35
+ const out: Pkg[] = [];
36
+ let from = 0;
37
+ for (;;) {
38
+ const { results, total } = await this.searchPage(query, from, this.pageSize);
39
+ out.push(...results);
40
+ from += results.length;
41
+ if (results.length === 0 || from >= total) return out;
42
+ }
43
+ }
44
+
45
+ async info(name: string): Promise<PkgInfo> {
46
+ const res = await fetch(`${this.base}/${encodeURIComponent(name).replace("%2F", "/")}`, {
47
+ headers: { accept: "application/vnd.npm.install-v1+json" },
48
+ });
49
+ if (!res.ok) throw new Error(`npm info ${name}: HTTP ${res.status}`);
50
+ const doc = (await res.json()) as {
51
+ name?: string;
52
+ "dist-tags"?: Record<string, string>;
53
+ versions?: Record<
54
+ string,
55
+ {
56
+ version?: string;
57
+ description?: string;
58
+ homepage?: string;
59
+ license?: unknown;
60
+ repository?: unknown;
61
+ keywords?: string[];
62
+ pi?: Record<string, unknown>;
63
+ dist?: { unpackedSize?: number };
64
+ }
65
+ >;
66
+ time?: Record<string, string>;
67
+ };
68
+ const latest = doc["dist-tags"]?.["latest"] ?? "";
69
+ const v = doc.versions?.[latest];
70
+ if (!v) throw new Error(`npm info ${name}: version ${latest} not in document`);
71
+ return {
72
+ name: doc.name ?? name,
73
+ version: v.version ?? latest,
74
+ description: v.description,
75
+ homepage: v.homepage,
76
+ repository: rawToString(v.repository, "url"),
77
+ license: rawToString(v.license, "type"),
78
+ keywords: v.keywords,
79
+ pi: v.pi,
80
+ modified: doc.time?.["modified"],
81
+ unpackedSize: v.dist?.unpackedSize,
82
+ };
83
+ }
84
+ }
85
+
86
+ /** npm fields appear as plain string OR object: license: "MIT" | {type:"MIT"}. */
87
+ function rawToString(raw: unknown, objKey: string): string | undefined {
88
+ if (typeof raw === "string") return raw;
89
+ if (raw && typeof raw === "object") {
90
+ const v = (raw as Record<string, unknown>)[objKey];
91
+ if (typeof v === "string") return v;
92
+ }
93
+ return undefined;
94
+ }