@danypops/pi-packed 0.1.0 → 0.2.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.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * constants.ts — every magic value in one place, named.
3
+ * (lexicon: replace-magic-number-with-symbolic-constant)
4
+ */
5
+
6
+ // --- Upstream ---
7
+ export const NPM_REGISTRY_BASE = "https://registry.npmjs.org";
8
+
9
+ // --- Search / pagination ---
10
+ export const SEARCH_DEFAULT_LIMIT = 10;
11
+ export const SEARCH_MAX_LIMIT = 50;
12
+ export const SEARCH_PAGE_SIZE = 250; // npm registry max page size
13
+ export const PI_PACKAGE_KEYWORD = "keywords:pi-package";
14
+
15
+ // --- Upstream etiquette (429s) ---
16
+ export const RETRY_MAX_ATTEMPTS = 6;
17
+ export const RETRY_BASE_DELAY_MS = 2_000; // 2+4+8+16+32s spans npm's ~60s search window
18
+ export const PAGE_DELAY_MS = 100; // politeness pause between catalog pages
19
+ export const MIRROR_PAGE_DELAY_MS = 400; // manual full-sync: extra polite (burst limits)
20
+
21
+ // --- Cache / fetch ---
22
+ export const CACHE_TTL_MS = 5 * 60_000;
23
+ export const PROBE_TIMEOUT_MS = 800;
24
+ export const REGISTRY_FETCH_TIMEOUT_MS = 15_000;
25
+
26
+ // --- Daemon ---
27
+ export const DAEMON_HOST = "127.0.0.1";
28
+ export const WATCH_INTERVAL_DEFAULT_MS = 30 * 60_000; // updates diff cadence
29
+ export const CATALOG_INTERVAL_DEFAULT_MS = 6 * 3_600_000; // full mirror TTL
30
+ export const IDLE_BUDGET_DEFAULT_MS = 10 * 60_000; // on-demand self-exit
31
+ export const WATCHDOG_TICK_MS = 15_000;
32
+
33
+ // --- Identity ---
34
+ export const VERSION = "0.1.0";
35
+
36
+ // --- State-dir file names ---
37
+ export const TOKEN_FILE = "token";
38
+ export const PORT_FILE = "port";
39
+ export const UPDATES_FILE = "updates.json";
40
+ export const DB_FILE = "packed.db";
41
+ export const SETTINGS_FILE = "settings.json";
42
+
43
+ // --- Environment knobs ---
44
+ export const ENV = {
45
+ HOME: "PI_PACKED_HOME",
46
+ PI_HOME: "PI_PACKED_PI_HOME",
47
+ WATCH_SECS: "PI_PACKED_WATCH_SECS",
48
+ CATALOG_SECS: "PI_PACKED_CATALOG_SECS",
49
+ IDLE_SECS: "PI_PACKED_IDLE_SECS",
50
+ } as const;
package/src/daemon.ts CHANGED
@@ -9,6 +9,11 @@ import { ExecInstaller } from "./install.ts";
9
9
  import { loadOrCreateToken, writePort, idleExpired, envMs, stateDir } from "./state.ts";
10
10
  import { startWatcher } from "./watcher.ts";
11
11
  import { startCatalogSync } from "./catalog.ts";
12
+ import { openDb, latestVersion, dbPath } from "./db.ts";
13
+ import {
14
+ DAEMON_HOST, ENV, WATCH_INTERVAL_DEFAULT_MS, CATALOG_INTERVAL_DEFAULT_MS,
15
+ IDLE_BUDGET_DEFAULT_MS, WATCHDOG_TICK_MS,
16
+ } from "./constants.ts";
12
17
  import { readInstalledPackages, defaultPiHome } from "./installed.ts";
13
18
 
14
19
  export function serveMain(): void {
@@ -20,7 +25,7 @@ export function serveMain(): void {
20
25
  let lastActive = Date.now();
21
26
  const server = Bun.serve({
22
27
  port: 0,
23
- hostname: "127.0.0.1",
28
+ hostname: DAEMON_HOST,
24
29
  fetch: (req) => {
25
30
  lastActive = Date.now();
26
31
  return app.fetch(req);
@@ -29,14 +34,16 @@ export function serveMain(): void {
29
34
  if (!server.port) throw new Error("failed to bind listener");
30
35
  writePort(dir, server.port);
31
36
 
32
- const stopWatcher = startWatcher(reg, dir, () => readInstalledPackages(defaultPiHome()), {
33
- intervalMs: envMs("PI_PACKED_WATCH_SECS", 30 * 60_000),
37
+ // Event producer #1: drift detection from the local mirror (default 30min).
38
+ const watcherDb = openDb(dbPath(dir));
39
+ const stopWatcher = startWatcher((name) => latestVersion(watcherDb, name), dir, () => readInstalledPackages(defaultPiHome()), {
40
+ intervalMs: envMs(ENV.WATCH_SECS, WATCH_INTERVAL_DEFAULT_MS),
34
41
  });
35
- const stopCatalog = startCatalogSync(reg, dir, envMs("PI_PACKED_CATALOG_SECS", 6 * 3_600_000));
42
+ const stopCatalog = startCatalogSync(reg, dir, envMs(ENV.CATALOG_SECS, CATALOG_INTERVAL_DEFAULT_MS));
36
43
 
37
44
  // Idle watchdog: for on-demand spawns. PI_PACKED_IDLE_SECS=0 disables it
38
45
  // (systemd or another supervisor owns the lifecycle then).
39
- const idleBudget = envMs("PI_PACKED_IDLE_SECS", 10 * 60_000);
46
+ const idleBudget = envMs(ENV.IDLE_SECS, IDLE_BUDGET_DEFAULT_MS);
40
47
  let watchdog: ReturnType<typeof setInterval> | undefined;
41
48
  if (idleBudget > 0) {
42
49
  watchdog = setInterval(() => {
@@ -44,13 +51,14 @@ export function serveMain(): void {
44
51
  console.error(`[packed] idle for ${idleBudget / 1000}s, exiting`);
45
52
  shutdown();
46
53
  }
47
- }, 15_000);
54
+ }, WATCHDOG_TICK_MS);
48
55
  }
49
56
 
50
57
  function shutdown(): void {
51
58
  if (watchdog) clearInterval(watchdog);
52
59
  stopWatcher();
53
60
  stopCatalog();
61
+ watcherDb.close();
54
62
  server.stop(true);
55
63
  process.exit(0);
56
64
  }
@@ -58,6 +66,6 @@ export function serveMain(): void {
58
66
  process.on("SIGINT", shutdown);
59
67
 
60
68
  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)`,
69
+ `[packed] listening on ${DAEMON_HOST}:${server.port} (state ${dir}, watch ${envMs(ENV.WATCH_SECS, WATCH_INTERVAL_DEFAULT_MS) / 1000}s, idle ${idleBudget / 1000}s)`,
62
70
  );
63
71
  }
package/src/db.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * db.ts — the local registry index (SQLite), the pkgng/YUM model.
3
+ * Master-index role: sync_meta (source, time, count, payload checksum —
4
+ * the APT Release / repomd.xml analog). Payload: packages + FTS5 mirror.
5
+ */
6
+ // Runtime-detected SQLite backend: bun:sqlite under Bun (CLI, daemon),
7
+ // node:sqlite under Node ≥22.5 (pi's extension host — jiti runs on Node).
8
+ // Both share the better-sqlite3 API shape; transactions are done manually
9
+ // because node:sqlite has no .transaction() helper.
10
+ import { createRequire } from "node:module";
11
+ import { mkdirSync } from "node:fs";
12
+
13
+ const require_ = createRequire(import.meta.url);
14
+ const IS_BUN = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
15
+ const backend = IS_BUN
16
+ ? (require_("bun:sqlite") as typeof import("bun:sqlite"))
17
+ : (require_("node:sqlite") as unknown as typeof import("bun:sqlite"));
18
+
19
+ // Constructor + options differ: bun:sqlite exports Database({create}),
20
+ // node:sqlite exports DatabaseSync (creates by default).
21
+ const DatabaseCtor = (
22
+ "DatabaseSync" in backend ? (backend as { DatabaseSync: unknown }).DatabaseSync : backend.Database
23
+ ) as new (path: string, opts?: { create?: boolean }) => Db;
24
+
25
+ export interface DbStatement {
26
+ run(...params: unknown[]): { lastInsertRowid: number | bigint };
27
+ get(...params: unknown[]): unknown;
28
+ all(...params: unknown[]): unknown[];
29
+ }
30
+
31
+ export interface Db {
32
+ exec(sql: string): unknown;
33
+ prepare(sql: string): DbStatement;
34
+ close(): void;
35
+ }
36
+
37
+ function inTransaction<T>(db: Db, fn: () => T): T {
38
+ db.exec("BEGIN");
39
+ try {
40
+ const result = fn();
41
+ db.exec("COMMIT");
42
+ return result;
43
+ } catch (e) {
44
+ db.exec("ROLLBACK");
45
+ throw e;
46
+ }
47
+ }
48
+ import { join } from "node:path";
49
+ import { createHash } from "node:crypto";
50
+ import type { Pkg } from "./ports.ts";
51
+ import { DB_FILE } from "./constants.ts";
52
+
53
+ export interface SyncMeta {
54
+ source: string;
55
+ fetchedAt: string;
56
+ packageCount: number;
57
+ sha256: string;
58
+ }
59
+
60
+ export function dbPath(dir: string): string {
61
+ return join(dir, DB_FILE);
62
+ }
63
+
64
+ export function openDb(path: string): Db {
65
+ if (path !== ":memory:") mkdirSync(join(path, ".."), { recursive: true });
66
+ const db = IS_BUN ? new DatabaseCtor(path, { create: true }) : new DatabaseCtor(path);
67
+ db.exec("PRAGMA journal_mode = WAL");
68
+ db.exec(`CREATE TABLE IF NOT EXISTS packages (
69
+ name TEXT PRIMARY KEY,
70
+ version TEXT NOT NULL DEFAULT '',
71
+ description TEXT,
72
+ date TEXT
73
+ )`);
74
+ db.exec(`CREATE TABLE IF NOT EXISTS sync_meta (
75
+ id INTEGER PRIMARY KEY CHECK (id = 1),
76
+ source TEXT NOT NULL,
77
+ fetched_at TEXT NOT NULL,
78
+ package_count INTEGER NOT NULL,
79
+ sha256 TEXT NOT NULL
80
+ )`);
81
+ // FTS5 mirror; rebuilt wholesale on each sync.
82
+ db.exec(
83
+ `CREATE VIRTUAL TABLE IF NOT EXISTS packages_fts USING fts5(name, description)`,
84
+ );
85
+ return db;
86
+ }
87
+
88
+ /** Canonical checksum over the payload — the Release-file analog: cheap
89
+ * change detection between syncs and integrity for the local mirror. */
90
+ function payloadHash(pkgs: Pkg[]): string {
91
+ const h = createHash("sha256");
92
+ for (const p of pkgs) h.update(`${p.name}@${p.version}\n`);
93
+ return h.digest("hex");
94
+ }
95
+
96
+ /** Atomic full-catalog replace (the mirror write side of `apt update`). */
97
+ export function replaceAll(db: Db, pkgs: Pkg[], source: string): SyncMeta {
98
+ const meta: SyncMeta = {
99
+ source,
100
+ fetchedAt: new Date().toISOString(),
101
+ packageCount: pkgs.length,
102
+ sha256: payloadHash(pkgs),
103
+ };
104
+ // INSERT OR REPLACE: npm pagination is unstable — rankings shift between
105
+ // pages and a package can appear twice in one sync (rowid reuse is fine).
106
+ const insert = db.prepare("INSERT OR REPLACE INTO packages (name, version, description, date) VALUES (?, ?, ?, ?)");
107
+ const insertFts = db.prepare("INSERT INTO packages_fts (rowid, name, description) VALUES (?, ?, ?)");
108
+ inTransaction(db, () => {
109
+ db.exec("DELETE FROM packages");
110
+ db.exec("DELETE FROM packages_fts");
111
+ for (const p of pkgs) {
112
+ const { lastInsertRowid } = insert.run(p.name, p.version, p.description ?? null, p.date ?? null);
113
+ insertFts.run(Number(lastInsertRowid), p.name, p.description ?? "");
114
+ }
115
+ db.prepare(
116
+ "INSERT INTO sync_meta (id, source, fetched_at, package_count, sha256) VALUES (1, ?, ?, ?, ?) " +
117
+ "ON CONFLICT(id) DO UPDATE SET source=excluded.source, fetched_at=excluded.fetched_at, " +
118
+ "package_count=excluded.package_count, sha256=excluded.sha256",
119
+ ).run(meta.source, meta.fetchedAt, meta.packageCount, meta.sha256);
120
+ });
121
+ return meta;
122
+ }
123
+
124
+ export function getSyncMeta(db: Db): SyncMeta | undefined {
125
+ return db
126
+ .prepare("SELECT source, fetched_at AS fetchedAt, package_count AS packageCount, sha256 FROM sync_meta WHERE id = 1")
127
+ .get() as SyncMeta | undefined;
128
+ }
129
+
130
+ export function catalogList(db: Db, limit = 0, offset = 0): Pkg[] {
131
+ const sql =
132
+ "SELECT name, version, description, date FROM packages ORDER BY name" +
133
+ (limit > 0 ? ` LIMIT ${Math.floor(limit)} OFFSET ${Math.floor(offset)}` : "");
134
+ return db.prepare(sql).all() as Pkg[];
135
+ }
136
+
137
+ /** FTS5 over the mirror (the `apt-cache search` analog). Sanitizes user
138
+ * input into AND-joined quoted terms; falls back to LIKE for hostile input. */
139
+ export function searchLocal(db: Db, q: string, limit = 50): Pkg[] {
140
+ const terms = q.trim().split(/\s+/).filter(Boolean);
141
+ if (terms.length === 0) return [];
142
+ try {
143
+ const match = terms.map((t) => `"${t.replaceAll('"', '""')}"*`).join(" ");
144
+ return db
145
+ .prepare(
146
+ `SELECT p.name, p.version, p.description, p.date
147
+ FROM packages_fts f JOIN packages p ON p.rowid = f.rowid
148
+ WHERE packages_fts MATCH ? ORDER BY rank LIMIT ?`,
149
+ )
150
+ .all(match, limit) as Pkg[];
151
+ } catch {
152
+ // FTS syntax hostility → substring fallback
153
+ const like = `%${q}%`;
154
+ return db
155
+ .prepare("SELECT name, version, description, date FROM packages WHERE name LIKE ? OR description LIKE ? ORDER BY name LIMIT ?")
156
+ .all(like, like, limit) as Pkg[];
157
+ }
158
+ }
159
+
160
+ /** Latest mirrored version of one package (watcher's lookup). */
161
+ export function latestVersion(db: Db, name: string): string | undefined {
162
+ const row = db.prepare("SELECT version FROM packages WHERE name = ?").get(name) as { version: string } | null;
163
+ return row?.version;
164
+ }
package/src/installed.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import { readFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import { ENV, SETTINGS_FILE } from "./constants.ts";
6
7
  import type { InstalledPkg } from "./ports.ts";
7
8
 
8
9
  export function splitNpmSource(spec: string): [name: string, version: string] {
@@ -32,7 +33,7 @@ function nodeModulesVersion(piHome: string, name: string): string | undefined {
32
33
  export function readInstalledPackages(piHome: string): InstalledPkg[] {
33
34
  let settings: { packages?: unknown[] };
34
35
  try {
35
- settings = JSON.parse(readFileSync(join(piHome, "settings.json"), "utf8"));
36
+ settings = JSON.parse(readFileSync(join(piHome, SETTINGS_FILE), "utf8"));
36
37
  } catch {
37
38
  return [];
38
39
  }
@@ -51,6 +52,7 @@ export function readInstalledPackages(piHome: string): InstalledPkg[] {
51
52
  }
52
53
 
53
54
  export function defaultPiHome(): string {
54
- if (process.env["PI_PACKED_PI_HOME"]) return process.env["PI_PACKED_PI_HOME"];
55
+ const envHome = process.env[ENV.PI_HOME];
56
+ if (envHome) return envHome;
55
57
  return join(homedir(), ".pi", "agent");
56
58
  }
package/src/log.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * log.ts — structured logging for the service. JSON lines to stderr
3
+ * (stdout belongs to CLI output; the TUI owns the terminal UI).
4
+ * Level via PI_PACKED_LOG_LEVEL (debug|info|warn|error, default info).
5
+ * Sinks are injectable so tests can capture without a terminal.
6
+ */
7
+
8
+ export type LogLevel = "debug" | "info" | "warn" | "error";
9
+ export type LogSink = (line: string) => void;
10
+
11
+ const ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 };
12
+ const LEVELS = new Set(Object.keys(ORDER));
13
+
14
+ export interface LogFields {
15
+ [key: string]: unknown;
16
+ }
17
+
18
+ export interface Logger {
19
+ debug(msg: string, fields?: LogFields): void;
20
+ info(msg: string, fields?: LogFields): void;
21
+ warn(msg: string, fields?: LogFields): void;
22
+ error(msg: string, fields?: LogFields): void;
23
+ }
24
+
25
+ function envLevel(): LogLevel {
26
+ const raw = process.env["PI_PACKED_LOG_LEVEL"] ?? "info";
27
+ return (LEVELS.has(raw) ? raw : "info") as LogLevel;
28
+ }
29
+
30
+ export function createLogger(module: string, sink: LogSink = (l) => console.error(l), minLevel?: LogLevel): Logger {
31
+ const threshold = ORDER[minLevel ?? envLevel()];
32
+ function emit(level: LogLevel, msg: string, fields?: LogFields): void {
33
+ if (ORDER[level] < threshold) return;
34
+ sink(JSON.stringify({ ts: new Date().toISOString(), level, module, msg, ...fields }));
35
+ }
36
+ return {
37
+ debug: (m, f) => emit("debug", m, f),
38
+ info: (m, f) => emit("info", m, f),
39
+ warn: (m, f) => emit("warn", m, f),
40
+ error: (m, f) => emit("error", m, f),
41
+ };
42
+ }
package/src/ports.ts CHANGED
@@ -53,7 +53,7 @@ export interface UpdateEntry {
53
53
  name: string;
54
54
  installed: string;
55
55
  latest: string;
56
- detectedAt: string;
56
+ detectedAt?: string;
57
57
  }
58
58
 
59
59
  export interface UpdatesSnapshot {
@@ -62,10 +62,12 @@ export interface UpdatesSnapshot {
62
62
  }
63
63
 
64
64
  /** Scope every query to pi packages unless the caller already qualified it. */
65
+ import { PI_PACKAGE_KEYWORD } from "./constants.ts";
66
+
65
67
  export function buildSearchQuery(q: string): string {
66
68
  const t = q.trim();
67
69
  if (t.includes("keywords:")) return t;
68
- return t === "" ? "keywords:pi-package" : `keywords:pi-package ${t}`;
70
+ return t === "" ? PI_PACKAGE_KEYWORD : `${PI_PACKAGE_KEYWORD} ${t}`;
69
71
  }
70
72
 
71
73
  export function clampLimit(v: number, def: number, max: number): number {
package/src/registry.ts CHANGED
@@ -3,11 +3,50 @@
3
3
  * Lean mapping = Facade over npm's verbose package documents.
4
4
  */
5
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
+ }
6
43
 
7
44
  export class HttpRegistry implements Registry {
8
45
  constructor(
9
- private base = "https://registry.npmjs.org",
10
- private pageSize = 250,
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,
11
50
  ) {}
12
51
 
13
52
  async search(query: string, limit: number): Promise<SearchPage> {
@@ -16,7 +55,7 @@ export class HttpRegistry implements Registry {
16
55
 
17
56
  async searchPage(query: string, from: number, size: number): Promise<SearchPage> {
18
57
  const params = new URLSearchParams({ text: query, size: String(size), from: String(from) });
19
- const res = await fetch(`${this.base}/-/v1/search?${params}`);
58
+ const res = await fetchWithRetry(`${this.base}/-/v1/search?${params}`, undefined, this.retryBaseDelayMs);
20
59
  if (!res.ok) throw new Error(`npm search: HTTP ${res.status}`);
21
60
  const doc = (await res.json()) as {
22
61
  total?: number;
@@ -32,20 +71,28 @@ export class HttpRegistry implements Registry {
32
71
  }
33
72
 
34
73
  async searchAll(query: string): Promise<Pkg[]> {
35
- const out: 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>();
36
77
  let from = 0;
37
78
  for (;;) {
79
+ if (from > 0 && this.pageDelayMs > 0) await Bun.sleep(this.pageDelayMs);
38
80
  const { results, total } = await this.searchPage(query, from, this.pageSize);
39
- out.push(...results);
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
+ }
40
85
  from += results.length;
41
- if (results.length === 0 || from >= total) return out;
42
86
  }
87
+ return [...byName.values()];
43
88
  }
44
89
 
45
90
  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
- });
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
+ );
49
96
  if (!res.ok) throw new Error(`npm info ${name}: HTTP ${res.status}`);
50
97
  const doc = (await res.json()) as {
51
98
  name?: string;
package/src/service.ts CHANGED
@@ -7,17 +7,24 @@ import { buildSearchQuery, clampLimit } from "./ports.ts";
7
7
  import type { Installer, Registry } from "./ports.ts";
8
8
  import { TTLCache } from "./cache.ts";
9
9
  import { loadUpdates } from "./watcher.ts";
10
- import { loadCatalog } from "./catalog.ts";
10
+ import { readInstalledPackages, defaultPiHome } from "./installed.ts";
11
+ import { openDb, searchLocal, catalogList, getSyncMeta, dbPath } from "./db.ts";
12
+ import { VERSION, SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT } from "./constants.ts";
13
+ import { createLogger } from "./log.ts";
14
+
15
+ const log = createLogger("service");
11
16
 
12
17
  export interface Deps {
13
18
  reg: Registry;
14
19
  inst: Installer;
15
20
  token: string;
16
21
  stateDir: string;
22
+ piHome?: string;
17
23
  cache?: TTLCache;
18
24
  }
19
25
 
20
26
  const SOURCE_RE = /^(npm:[A-Za-z0-9@._/-]+|git:[A-Za-z0-9@:._/-]+|https:\/\/[A-Za-z0-9@:._/?=&%~-]+)$/;
27
+ const NAME_RE = /^(@[A-Za-z0-9._-]+\/)?[A-Za-z0-9._-]+$/;
21
28
 
22
29
  function json(v: unknown, init?: ResponseInit): Response {
23
30
  return Response.json(v, init);
@@ -35,12 +42,22 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
35
42
  const path = url.pathname;
36
43
 
37
44
  if (path === "/health" && req.method === "GET") {
38
- return json({ ok: true, version: "0.1.0" });
45
+ return json({ ok: true, version: VERSION });
39
46
  }
40
47
 
41
48
  if (path === "/search" && req.method === "GET") {
42
49
  const q = url.searchParams.get("q") ?? "";
43
- const limit = clampLimit(Number(url.searchParams.get("limit")), 10, 50);
50
+ const limit = clampLimit(Number(url.searchParams.get("limit")), SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT);
51
+ // offline=1: serve from the SQLite mirror (apt-cache search analog)
52
+ if (url.searchParams.get("offline") === "1") {
53
+ const db = openDb(dbPath(deps.stateDir));
54
+ try {
55
+ const results = searchLocal(db, q, limit);
56
+ return json({ query: q, total: results.length, results, offline: true });
57
+ } finally {
58
+ db.close();
59
+ }
60
+ }
44
61
  try {
45
62
  const { results, total } = await deps.reg.search(buildSearchQuery(q), limit);
46
63
  return json({ query: q, total, results });
@@ -59,6 +76,29 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
59
76
  }
60
77
  }
61
78
 
79
+ if (path === "/installed" && req.method === "GET") {
80
+ return json(readInstalledPackages(deps.piHome ?? defaultPiHome()));
81
+ }
82
+
83
+ if (path === "/remove" && req.method === "POST") {
84
+ let name = "";
85
+ try {
86
+ const body = (await req.json()) as { name?: unknown };
87
+ name = String(body.name ?? "");
88
+ } catch {
89
+ /* fall through to validation */
90
+ }
91
+ if (!NAME_RE.test(name)) {
92
+ return err(400, "invalid name; want a bare npm package name");
93
+ }
94
+ try {
95
+ const output = await deps.inst.remove(`npm:${name}`);
96
+ return json({ ok: true, name, output });
97
+ } catch (e) {
98
+ return json({ ok: false, name, output: e instanceof Error ? e.message : String(e) });
99
+ }
100
+ }
101
+
62
102
  if (path === "/install" && req.method === "POST") {
63
103
  let source = "";
64
104
  try {
@@ -84,8 +124,13 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
84
124
  }
85
125
 
86
126
  if (path === "/catalog" && req.method === "GET") {
87
- const snap = await loadCatalog(deps.stateDir);
88
- return json(snap ?? { packages: [] });
127
+ const db = openDb(dbPath(deps.stateDir));
128
+ try {
129
+ const meta = getSyncMeta(db);
130
+ return json({ fetchedAt: meta?.fetchedAt, sha256: meta?.sha256, packages: catalogList(db) });
131
+ } finally {
132
+ db.close();
133
+ }
89
134
  }
90
135
 
91
136
  return err(404, "not found");
@@ -93,6 +138,7 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
93
138
 
94
139
  return {
95
140
  async fetch(req: Request): Promise<Response> {
141
+ const t0 = Date.now();
96
142
  if (req.headers.get("authorization") !== `Bearer ${deps.token}`) {
97
143
  return err(401, "missing or invalid bearer token");
98
144
  }
@@ -100,13 +146,17 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
100
146
  if (req.method === "GET" && !["/health", "/updates", "/catalog"].includes(new URL(req.url).pathname)) {
101
147
  const hit = cache.get(req.url);
102
148
  if (hit) {
149
+ log.debug("request", { path: new URL(req.url).pathname, cache: "hit", ms: Date.now() - t0 });
103
150
  return new Response(hit, { headers: { "content-type": "application/json", "x-cache": "hit" } });
104
151
  }
105
152
  const res = await route(req);
106
153
  if (res.status === 200) cache.set(req.url, await res.clone().text());
154
+ log.debug("request", { path: new URL(req.url).pathname, status: res.status, cache: "miss", ms: Date.now() - t0 });
107
155
  return res;
108
156
  }
109
- return route(req);
157
+ const res = await route(req);
158
+ log.debug("request", { path: new URL(req.url).pathname, status: res.status, ms: Date.now() - t0 });
159
+ return res;
110
160
  },
111
161
  };
112
162
  }
package/src/state.ts CHANGED
@@ -2,10 +2,12 @@
2
2
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { ENV, TOKEN_FILE, PORT_FILE } from "./constants.ts";
5
6
  import { randomBytes } from "node:crypto";
6
7
 
7
8
  export function stateDir(): string {
8
- if (process.env["PI_PACKED_HOME"]) return process.env["PI_PACKED_HOME"];
9
+ const envHome = process.env[ENV.HOME];
10
+ if (envHome) return envHome;
9
11
  try {
10
12
  return join(homedir(), ".cache", "pi-packed");
11
13
  } catch {
@@ -14,7 +16,7 @@ export function stateDir(): string {
14
16
  }
15
17
 
16
18
  export function loadOrCreateToken(dir: string): string {
17
- const path = join(dir, "token");
19
+ const path = join(dir, TOKEN_FILE);
18
20
  try {
19
21
  const tok = readFileSync(path, "utf8").trim();
20
22
  if (tok) return tok;
@@ -29,7 +31,7 @@ export function loadOrCreateToken(dir: string): string {
29
31
 
30
32
  export function writePort(dir: string, port: number): void {
31
33
  mkdirSync(dir, { recursive: true });
32
- writeFileSync(join(dir, "port"), String(port) + "\n", { mode: 0o600 });
34
+ writeFileSync(join(dir, PORT_FILE), String(port) + "\n", { mode: 0o600 });
33
35
  }
34
36
 
35
37
  export function idleExpired(lastActiveMs: number, nowMs: number, budgetMs: number): boolean {