@danypops/pi-packed 0.5.2 → 0.6.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/registry.ts DELETED
@@ -1,141 +0,0 @@
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
- import {
7
- NPM_REGISTRY_BASE, SEARCH_PAGE_SIZE, RETRY_MAX_ATTEMPTS, RETRY_BASE_DELAY_MS, PAGE_DELAY_MS,
8
- } from "./constants.ts";
9
- import { createLogger } from "./log.ts";
10
-
11
- const log = createLogger("registry");
12
-
13
- /** Upstream etiquette: honor Retry-After on 429, exponential backoff
14
- * otherwise, give up after RETRY_MAX_ATTEMPTS. */
15
- async function fetchWithRetry(url: string, init: RequestInit | undefined, baseDelayMs: number): Promise<Response> {
16
- let lastErr: Error | undefined;
17
- for (let attempt = 1; attempt <= RETRY_MAX_ATTEMPTS; attempt++) {
18
- const t0 = Date.now();
19
- try {
20
- const res = await fetch(url, init);
21
- const ms = Date.now() - t0;
22
- if (res.status === 429 && attempt < RETRY_MAX_ATTEMPTS) {
23
- const ra = res.headers.get("retry-after");
24
- const delayMs =
25
- ra !== null && Number(ra) > 0
26
- ? Number(ra) * 1000 // authoritative only when positive
27
- : baseDelayMs * 2 ** (attempt - 1); // npm sends 0: use exponential
28
- log.warn("429 rate-limited, backing off", { attempt, delayMs, ms, url: url.slice(0, 120) });
29
- await Bun.sleep(delayMs);
30
- continue;
31
- }
32
- log.debug("fetch", { status: res.status, ms, attempt, url: url.slice(0, 120) });
33
- return res;
34
- } catch (e) {
35
- lastErr = e instanceof Error ? e : new Error(String(e));
36
- log.warn("fetch error, retrying", { attempt, error: lastErr.message, url: url.slice(0, 120) });
37
- if (attempt < RETRY_MAX_ATTEMPTS) await Bun.sleep(baseDelayMs * 2 ** (attempt - 1));
38
- }
39
- }
40
- log.error("retry budget exhausted", { attempts: RETRY_MAX_ATTEMPTS, url: url.slice(0, 120) });
41
- throw lastErr ?? new Error("retry budget exhausted");
42
- }
43
-
44
- export class HttpRegistry implements Registry {
45
- constructor(
46
- private base = NPM_REGISTRY_BASE,
47
- private pageSize = SEARCH_PAGE_SIZE,
48
- private pageDelayMs = PAGE_DELAY_MS,
49
- private retryBaseDelayMs = RETRY_BASE_DELAY_MS,
50
- ) {}
51
-
52
- async search(query: string, limit: number): Promise<SearchPage> {
53
- return this.searchPage(query, 0, limit);
54
- }
55
-
56
- async searchPage(query: string, from: number, size: number): Promise<SearchPage> {
57
- const params = new URLSearchParams({ text: query, size: String(size), from: String(from) });
58
- const res = await fetchWithRetry(`${this.base}/-/v1/search?${params}`, undefined, this.retryBaseDelayMs);
59
- if (!res.ok) throw new Error(`npm search: HTTP ${res.status}`);
60
- const doc = (await res.json()) as {
61
- total?: number;
62
- objects?: { package?: { name?: string; version?: string; description?: string; date?: string } }[];
63
- };
64
- const results: Pkg[] = (doc.objects ?? []).flatMap((o) => {
65
- const p = o.package;
66
- return p?.name
67
- ? [{ name: p.name, version: p.version ?? "", description: p.description, date: p.date }]
68
- : [];
69
- });
70
- return { results, total: doc.total ?? results.length };
71
- }
72
-
73
- async searchAll(query: string): Promise<Pkg[]> {
74
- // Map by name: npm's ranking shifts mid-pagination and a package can
75
- // appear on two pages — first occurrence wins.
76
- const byName = new Map<string, Pkg>();
77
- let from = 0;
78
- for (;;) {
79
- if (from > 0 && this.pageDelayMs > 0) await Bun.sleep(this.pageDelayMs);
80
- const { results, total } = await this.searchPage(query, from, this.pageSize);
81
- if (results.length === 0 || from >= total) break;
82
- for (const p of results) {
83
- if (!byName.has(p.name)) byName.set(p.name, p);
84
- }
85
- from += results.length;
86
- }
87
- return [...byName.values()];
88
- }
89
-
90
- async info(name: string): Promise<PkgInfo> {
91
- const res = await fetchWithRetry(
92
- `${this.base}/${encodeURIComponent(name).replace("%2F", "/")}`,
93
- { headers: { accept: "application/vnd.npm.install-v1+json" } },
94
- this.retryBaseDelayMs,
95
- );
96
- if (!res.ok) throw new Error(`npm info ${name}: HTTP ${res.status}`);
97
- const doc = (await res.json()) as {
98
- name?: string;
99
- "dist-tags"?: Record<string, string>;
100
- versions?: Record<
101
- string,
102
- {
103
- version?: string;
104
- description?: string;
105
- homepage?: string;
106
- license?: unknown;
107
- repository?: unknown;
108
- keywords?: string[];
109
- pi?: Record<string, unknown>;
110
- dist?: { unpackedSize?: number };
111
- }
112
- >;
113
- time?: Record<string, string>;
114
- };
115
- const latest = doc["dist-tags"]?.["latest"] ?? "";
116
- const v = doc.versions?.[latest];
117
- if (!v) throw new Error(`npm info ${name}: version ${latest} not in document`);
118
- return {
119
- name: doc.name ?? name,
120
- version: v.version ?? latest,
121
- description: v.description,
122
- homepage: v.homepage,
123
- repository: rawToString(v.repository, "url"),
124
- license: rawToString(v.license, "type"),
125
- keywords: v.keywords,
126
- pi: v.pi,
127
- modified: doc.time?.["modified"],
128
- unpackedSize: v.dist?.unpackedSize,
129
- };
130
- }
131
- }
132
-
133
- /** npm fields appear as plain string OR object: license: "MIT" | {type:"MIT"}. */
134
- function rawToString(raw: unknown, objKey: string): string | undefined {
135
- if (typeof raw === "string") return raw;
136
- if (raw && typeof raw === "object") {
137
- const v = (raw as Record<string, unknown>)[objKey];
138
- if (typeof v === "string") return v;
139
- }
140
- return undefined;
141
- }
package/src/security.ts DELETED
@@ -1,95 +0,0 @@
1
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { SECURITY_FILE } from "./constants.ts";
4
-
5
- export const MUTATION_APPROVAL_VALUES = ["always", "never"] as const;
6
- export type MutationApproval = typeof MUTATION_APPROVAL_VALUES[number];
7
-
8
- export const PACKAGE_OPERATIONS = [
9
- "search",
10
- "info",
11
- "installed",
12
- "catalog",
13
- "updates",
14
- "security.read",
15
- "mirror",
16
- "install",
17
- "update",
18
- "remove",
19
- "security.write",
20
- ] as const;
21
- export type PackageOperation = typeof PACKAGE_OPERATIONS[number];
22
- export type PackageOperationClassification = "read" | "maintenance" | "code-execution" | "settings-mutation" | "security-mutation";
23
-
24
- export interface SecuritySettings { mutationApproval: MutationApproval }
25
- export interface SecuritySettingsPort {
26
- security(): Promise<SecuritySettings>;
27
- setMutationApproval(value: MutationApproval, options?: { approved?: boolean }): Promise<SecuritySettings>;
28
- }
29
-
30
- export interface PackagePermissionDecision {
31
- operation: PackageOperation;
32
- classification: PackageOperationClassification;
33
- approvalRequired: boolean;
34
- }
35
-
36
- export const DEFAULT_SECURITY_SETTINGS: SecuritySettings = { mutationApproval: "always" };
37
-
38
- const CLASSIFICATIONS: Record<PackageOperation, PackageOperationClassification> = {
39
- search: "read",
40
- info: "read",
41
- installed: "read",
42
- catalog: "read",
43
- updates: "read",
44
- "security.read": "read",
45
- mirror: "maintenance",
46
- install: "code-execution",
47
- update: "code-execution",
48
- remove: "settings-mutation",
49
- "security.write": "security-mutation",
50
- };
51
-
52
- export class PackageApprovalRequiredError extends Error {
53
- readonly code = "approval_required";
54
- constructor(readonly operation: PackageOperation) {
55
- super(`approval required for package operation ${operation}`);
56
- this.name = "PackageApprovalRequiredError";
57
- }
58
- }
59
-
60
- export function packagePermissionDecision(settings: SecuritySettings, operation: PackageOperation): PackagePermissionDecision {
61
- const classification = CLASSIFICATIONS[operation];
62
- const guarded = classification === "code-execution" || classification === "settings-mutation" || classification === "security-mutation";
63
- return { operation, classification, approvalRequired: guarded && settings.mutationApproval === "always" };
64
- }
65
-
66
- export function assertPackagePermission(settings: SecuritySettings, operation: PackageOperation, approved = false): void {
67
- if (packagePermissionDecision(settings, operation).approvalRequired && !approved) {
68
- throw new PackageApprovalRequiredError(operation);
69
- }
70
- }
71
-
72
- export function readSecuritySettings(stateDir: string): SecuritySettings {
73
- try {
74
- const value = JSON.parse(readFileSync(join(stateDir, SECURITY_FILE), "utf8")) as {
75
- mutationApproval?: unknown;
76
- installApproval?: unknown;
77
- };
78
- const stored = value.mutationApproval ?? value.installApproval;
79
- return MUTATION_APPROVAL_VALUES.includes(stored as MutationApproval)
80
- ? { mutationApproval: stored as MutationApproval }
81
- : { ...DEFAULT_SECURITY_SETTINGS };
82
- } catch {
83
- return { ...DEFAULT_SECURITY_SETTINGS };
84
- }
85
- }
86
-
87
- export function writeSecuritySettings(stateDir: string, settings: SecuritySettings): SecuritySettings {
88
- if (!MUTATION_APPROVAL_VALUES.includes(settings.mutationApproval)) throw new Error("mutationApproval must be always or never");
89
- mkdirSync(stateDir, { recursive: true, mode: 0o700 });
90
- const target = join(stateDir, SECURITY_FILE);
91
- const temporary = `${target}.tmp`;
92
- writeFileSync(temporary, `${JSON.stringify(settings)}\n`, { mode: 0o600 });
93
- renameSync(temporary, target);
94
- return { ...settings };
95
- }
package/src/service.ts DELETED
@@ -1,233 +0,0 @@
1
- /**
2
- * service.ts — the hexagon's HTTP driving adapter as a pure Web Standard
3
- * handler: (Request) → Response. Bun.serve wraps it for the network;
4
- * tests call it in-process. Same port, two adapters — Cockburn's symmetry.
5
- */
6
- import { buildSearchQuery, clampLimit } from "./ports.ts";
7
- import type { Installer, Registry } from "./ports.ts";
8
- import { TTLCache } from "./cache.ts";
9
- import { loadUpdates } from "./watcher.ts";
10
- import { readInstalledPackages, defaultPiHome } from "./installed.ts";
11
- import { openDb, searchLocal, catalogList, getSyncMeta, dbPath } from "./db.ts";
12
- import { SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT } from "./constants.ts";
13
- import { VERSION } from "./version.ts";
14
- import { createLogger } from "./log.ts";
15
- import {
16
- assertPackagePermission,
17
- PackageApprovalRequiredError,
18
- readSecuritySettings,
19
- writeSecuritySettings,
20
- type MutationApproval,
21
- type PackageOperation,
22
- } from "./security.ts";
23
-
24
- const log = createLogger("service");
25
-
26
- export interface Deps {
27
- reg: Registry;
28
- inst: Installer;
29
- token: string;
30
- stateDir: string;
31
- piHome?: string;
32
- cache?: TTLCache;
33
- }
34
-
35
- const SOURCE_RE = /^(npm:[A-Za-z0-9@._/-]+|git:[A-Za-z0-9@:._/-]+|https:\/\/[A-Za-z0-9@:._/?=&%~-]+)$/;
36
- const NAME_RE = /^(@[A-Za-z0-9._-]+\/)?[A-Za-z0-9._-]+$/;
37
-
38
- function json(v: unknown, init?: ResponseInit): Response {
39
- return Response.json(v, init);
40
- }
41
-
42
- function err(status: number, msg: string, details: Record<string, unknown> = {}): Response {
43
- return json({ error: msg, ...details }, { status });
44
- }
45
-
46
- export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Response> } {
47
- const cache = deps.cache ?? new TTLCache();
48
-
49
- function authorize(operation: PackageOperation, approved: boolean): Response | undefined {
50
- try {
51
- assertPackagePermission(readSecuritySettings(deps.stateDir), operation, approved);
52
- return undefined;
53
- } catch (error) {
54
- if (error instanceof PackageApprovalRequiredError) {
55
- return err(403, error.message, { code: error.code, operation: error.operation });
56
- }
57
- throw error;
58
- }
59
- }
60
-
61
- async function route(req: Request): Promise<Response> {
62
- const url = new URL(req.url);
63
- const path = url.pathname;
64
-
65
- if (path === "/health" && req.method === "GET") {
66
- return json({ ok: true, version: VERSION });
67
- }
68
-
69
- if (path === "/security" && req.method === "GET") {
70
- return json(readSecuritySettings(deps.stateDir));
71
- }
72
-
73
- if (path === "/security" && req.method === "POST") {
74
- let body: { mutationApproval?: unknown; approved?: unknown };
75
- try {
76
- body = (await req.json()) as typeof body;
77
- } catch {
78
- return err(400, "invalid security settings JSON");
79
- }
80
- if (body.mutationApproval !== "always" && body.mutationApproval !== "never") {
81
- return err(400, "mutationApproval must be always or never");
82
- }
83
- const denied = authorize("security.write", body.approved === true);
84
- if (denied) return denied;
85
- return json(writeSecuritySettings(deps.stateDir, { mutationApproval: body.mutationApproval as MutationApproval }));
86
- }
87
-
88
- if (path === "/search" && req.method === "GET") {
89
- const q = url.searchParams.get("q") ?? "";
90
- const limit = clampLimit(Number(url.searchParams.get("limit")), SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT);
91
- // offline=1: serve from the SQLite mirror (apt-cache search analog)
92
- if (url.searchParams.get("offline") === "1") {
93
- const db = openDb(dbPath(deps.stateDir));
94
- try {
95
- const results = searchLocal(db, q, limit);
96
- return json({ query: q, total: results.length, results, offline: true });
97
- } finally {
98
- db.close();
99
- }
100
- }
101
- try {
102
- const { results, total } = await deps.reg.search(buildSearchQuery(q), limit);
103
- return json({ query: q, total, results });
104
- } catch (e) {
105
- return err(502, e instanceof Error ? e.message : String(e));
106
- }
107
- }
108
-
109
- if (path === "/info" && req.method === "GET") {
110
- const name = url.searchParams.get("name") ?? "";
111
- if (!name) return err(400, "missing name");
112
- try {
113
- return json(await deps.reg.info(name));
114
- } catch (e) {
115
- return err(502, e instanceof Error ? e.message : String(e));
116
- }
117
- }
118
-
119
- if (path === "/installed" && req.method === "GET") {
120
- return json(readInstalledPackages(deps.piHome ?? defaultPiHome()));
121
- }
122
-
123
- if (path === "/remove" && req.method === "POST") {
124
- let name = "";
125
- let approved = false;
126
- try {
127
- const body = (await req.json()) as { name?: unknown; approved?: unknown };
128
- name = String(body.name ?? "");
129
- approved = body.approved === true;
130
- } catch {
131
- /* fall through to validation */
132
- }
133
- if (!NAME_RE.test(name)) {
134
- return err(400, "invalid name; want a bare npm package name");
135
- }
136
- const denied = authorize("remove", approved);
137
- if (denied) return denied;
138
- try {
139
- const output = await deps.inst.remove(`npm:${name}`, { approved });
140
- return json({ ok: true, name, output });
141
- } catch (e) {
142
- return json({ ok: false, name, output: e instanceof Error ? e.message : String(e) });
143
- }
144
- }
145
-
146
- if (path === "/install" && req.method === "POST") {
147
- let source = "";
148
- let approved = false;
149
- try {
150
- const body = (await req.json()) as { source?: unknown; approved?: unknown };
151
- source = String(body.source ?? "");
152
- approved = body.approved === true;
153
- } catch {
154
- /* fall through to validation */
155
- }
156
- if (!SOURCE_RE.test(source)) {
157
- return err(400, "invalid source; want npm:<pkg>[@ver], git:<host>/<owner>/<repo>[@ref], or https://…");
158
- }
159
- const denied = authorize("install", approved);
160
- if (denied) return denied;
161
- try {
162
- const output = await deps.inst.install(source, { approved });
163
- return json({ ok: true, source, output });
164
- } catch (e) {
165
- return json({ ok: false, source, output: e instanceof Error ? e.message : String(e) });
166
- }
167
- }
168
-
169
- if (path === "/update" && req.method === "POST") {
170
- let source = "";
171
- let approved = false;
172
- try {
173
- const body = (await req.json()) as { source?: unknown; approved?: unknown };
174
- source = String(body.source ?? "");
175
- approved = body.approved === true;
176
- } catch {
177
- /* fall through to validation */
178
- }
179
- if (!SOURCE_RE.test(source)) {
180
- return err(400, "invalid source; want a configured npm:, git:, or https package source");
181
- }
182
- const denied = authorize("update", approved);
183
- if (denied) return denied;
184
- try {
185
- const outcome = await deps.inst.update(source, { approved });
186
- return json({ ok: true, source, ...outcome });
187
- } catch (error) {
188
- return json({ ok: false, source, output: error instanceof Error ? error.message : String(error), reloadRequired: false });
189
- }
190
- }
191
-
192
- if (path === "/updates" && req.method === "GET") {
193
- const snap = await loadUpdates(deps.stateDir);
194
- return json(snap ?? { updates: [] });
195
- }
196
-
197
- if (path === "/catalog" && req.method === "GET") {
198
- const db = openDb(dbPath(deps.stateDir));
199
- try {
200
- const meta = getSyncMeta(db);
201
- return json({ fetchedAt: meta?.fetchedAt, sha256: meta?.sha256, packages: catalogList(db) });
202
- } finally {
203
- db.close();
204
- }
205
- }
206
-
207
- return err(404, "not found");
208
- }
209
-
210
- return {
211
- async fetch(req: Request): Promise<Response> {
212
- const t0 = Date.now();
213
- if (req.headers.get("authorization") !== `Bearer ${deps.token}`) {
214
- return err(401, "missing or invalid bearer token");
215
- }
216
- // Cache successful GETs by URI (smart-proxy concern).
217
- if (req.method === "GET" && !["/health", "/updates", "/catalog", "/security"].includes(new URL(req.url).pathname)) {
218
- const hit = cache.get(req.url);
219
- if (hit) {
220
- log.debug("request", { path: new URL(req.url).pathname, cache: "hit", ms: Date.now() - t0 });
221
- return new Response(hit, { headers: { "content-type": "application/json", "x-cache": "hit" } });
222
- }
223
- const res = await route(req);
224
- if (res.status === 200) cache.set(req.url, await res.clone().text());
225
- log.debug("request", { path: new URL(req.url).pathname, status: res.status, cache: "miss", ms: Date.now() - t0 });
226
- return res;
227
- }
228
- const res = await route(req);
229
- log.debug("request", { path: new URL(req.url).pathname, status: res.status, ms: Date.now() - t0 });
230
- return res;
231
- },
232
- };
233
- }
package/src/state.ts DELETED
@@ -1,50 +0,0 @@
1
- /** Daemon state: config dir, bearer token, port file, idle predicate. */
2
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import { homedir, tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import { ENV, TOKEN_FILE, PORT_FILE } from "./constants.ts";
6
- import { randomBytes } from "node:crypto";
7
-
8
- export function stateDir(): string {
9
- const envHome = process.env[ENV.HOME];
10
- if (envHome) return envHome;
11
- try {
12
- return join(homedir(), ".cache", "pi-packed");
13
- } catch {
14
- return join(tmpdir(), "pi-packed");
15
- }
16
- }
17
-
18
- export function loadOrCreateToken(dir: string): string {
19
- const path = join(dir, TOKEN_FILE);
20
- try {
21
- const tok = readFileSync(path, "utf8").trim();
22
- if (tok) return tok;
23
- } catch {
24
- /* first run */
25
- }
26
- const tok = randomBytes(16).toString("hex");
27
- mkdirSync(dir, { recursive: true });
28
- writeFileSync(path, tok + "\n", { mode: 0o600 });
29
- return tok;
30
- }
31
-
32
- export function writePort(dir: string, port: number): void {
33
- mkdirSync(dir, { recursive: true });
34
- writeFileSync(join(dir, PORT_FILE), String(port) + "\n", { mode: 0o600 });
35
- }
36
-
37
- export function idleExpired(lastActiveMs: number, nowMs: number, budgetMs: number): boolean {
38
- return nowMs - lastActiveMs > budgetMs;
39
- }
40
-
41
- /** Env knob in seconds → ms. Zero means "disabled" (external lifecycle
42
- * manager such as systemd owns the process). Garbage → default. */
43
- export function envMs(key: string, defMs: number): number {
44
- const raw = process.env[key];
45
- if (raw === undefined) return defMs;
46
- const v = Number(raw);
47
- if (!Number.isFinite(v) || v < 0) return defMs;
48
- if (v === 0) return 0;
49
- return v * 1000;
50
- }
package/src/version.ts DELETED
@@ -1,16 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
-
3
- function packageVersion(): string {
4
- const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as unknown;
5
- if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) {
6
- throw new Error("pi-packed package manifest must be an object");
7
- }
8
- const version = (manifest as Record<string, unknown>)["version"];
9
- if (typeof version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
10
- throw new Error("pi-packed package manifest has an invalid version");
11
- }
12
- return version;
13
- }
14
-
15
- /** Runtime package version; package.json is the single release source of truth. */
16
- export const VERSION = packageVersion();
package/src/watcher.ts DELETED
@@ -1,72 +0,0 @@
1
- /**
2
- * watcher.ts — event producer #1: version-drift detection.
3
- * Diffs installed packages against registry dist-tags.latest and persists
4
- * an event snapshot (event-carried state) for cheap consumer reads.
5
- */
6
- import { writeFile, readFile, mkdir } from "node:fs/promises";
7
- import { join } from "node:path";
8
- import { UPDATES_FILE } from "./constants.ts";
9
- import { createLogger } from "./log.ts";
10
-
11
- const log = createLogger("watcher");
12
- import type { InstalledPkg, UpdateEntry, UpdatesSnapshot } from "./ports.ts";
13
-
14
- function updatesPath(dir: string): string {
15
- return join(dir, UPDATES_FILE);
16
- }
17
-
18
- export async function saveUpdates(dir: string, snap: UpdatesSnapshot): Promise<void> {
19
- await mkdir(dir, { recursive: true });
20
- await writeFile(updatesPath(dir), JSON.stringify(snap), { mode: 0o600 });
21
- }
22
-
23
- export async function loadUpdates(dir: string): Promise<UpdatesSnapshot | undefined> {
24
- try {
25
- return JSON.parse(await readFile(updatesPath(dir), "utf8")) as UpdatesSnapshot;
26
- } catch {
27
- return undefined;
28
- }
29
- }
30
-
31
- /** Pure diff against the local mirror (apt list --upgradable semantics):
32
- * drift = mirrored latest ≠ what we have. The mirror is refreshed by
33
- * catalogSync; updates are computed offline, exactly like APT. */
34
- export function checkUpdates(latestOf: (name: string) => string | undefined, installed: InstalledPkg[]): UpdateEntry[] {
35
- const now = new Date().toISOString();
36
- const updates: UpdateEntry[] = [];
37
- for (const p of installed) {
38
- const have = p.installed || p.pinned;
39
- if (!have) continue;
40
- const latest = latestOf(p.name);
41
- if (latest && latest !== have) {
42
- updates.push({ name: p.name, installed: have, latest, detectedAt: now });
43
- }
44
- }
45
- return updates;
46
- }
47
-
48
- export interface WatcherOptions {
49
- intervalMs: number;
50
- onError?: (e: unknown) => void;
51
- }
52
-
53
- /** Producer loop: immediate check, then on a timer. Returns a stop function. */
54
- export function startWatcher(
55
- latestOf: (name: string) => string | undefined,
56
- stateDir: string,
57
- readInstalled: () => InstalledPkg[],
58
- opts: WatcherOptions,
59
- ): () => void {
60
- async function check(): Promise<void> {
61
- try {
62
- const updates = checkUpdates(latestOf, readInstalled());
63
- await saveUpdates(stateDir, { checkedAt: new Date().toISOString(), updates });
64
- log.info("updates check", { updates: updates.length });
65
- } catch (e) {
66
- opts.onError?.(e);
67
- }
68
- }
69
- void check();
70
- const timer = setInterval(() => void check(), opts.intervalMs);
71
- return () => clearInterval(timer);
72
- }