@danypops/pi-packed 0.2.0 → 0.2.1

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/README.md CHANGED
@@ -1,92 +1,114 @@
1
1
  # pi-packed
2
2
 
3
- Package service for the [Pi](https://github.com/earendil-works/pi-coding-agent) agent —
3
+ Package service for the [Pi](https://github.com/earendil-works/pi) agent —
4
4
  DNF-style package management that both **you** and the **agent** can use.
5
5
 
6
- The agent gets tools (`pkg_search`, `pkg_info`, `pkg_install`); you get a CLI
7
- (`packed`) and an interactive TUI (`/packages`). All logic lives in a
8
- long-running Bun service the extension is a thin seam.
9
-
10
- ```
11
- ┌─ Pi extension seam (extension/src) ───────────┐
12
- pkg_search/pkg_info/pkg_install tools │
13
- /packages TUI · session_start notify
14
- thin: exec `bun src/cli.ts …`, confirm gates
15
- └───────────────┬───────────────────────────────┘
16
- │ CLI (same hexagon ports)
17
- ┌─ Bun service (src/) ──────────────────────────┼──────────┐
18
- cli.ts (command table) · service.ts (fetch) │ │
19
- daemon: watcher (update drift) + catalogSync
20
- │ npm registry adapter · pi install exec adapter
21
- └───────────────────────────────────────────────┴──────────┘
6
+ The agent gets native tools (`pkg_search`, `pkg_info`, `pkg_install`); you get
7
+ the `packed` CLI and an interactive `/packages` TUI. The extension is a thin,
8
+ Node-compatible client. Registry access, SQLite, and package execution remain
9
+ inside the supervised Bun daemon.
10
+
11
+ ```text
12
+ ┌─ Pi extension (Node-compatible) ──────────────┐
13
+ pkg_search · pkg_info · pkg_install
14
+ /packages · session_start update notification
15
+ │ confirmation gates · no Bun/SQLite dependency │
16
+ └──────────────────┬────────────────────────────┘
17
+ authenticated loopback HTTP
18
+ ┌─ packed.service (Bun) ────────────────────────┐
19
+ typed package client API · watcher · mirror
20
+ │ npm registry · SQLite WAL · pi install/remove
21
+ └───────────────────────────────────────────────┘
22
22
  ```
23
23
 
24
24
  ## Quickstart
25
25
 
26
26
  ```bash
27
- bun test # 52 tests
28
- bun src/cli.ts search lsp # one-shot, direct to npm
29
- bun src/cli.ts serve & # long-running service (warm cache + watcher)
30
- bun src/cli.ts updates # diff installed vs latest
31
-
32
- # In Pi:
33
- pi -e /path/to/pi-packed/extension/src/index.ts # ephemeral
34
- # or: pi install git:github.com/DanyPops/pi-packed
35
- /packages # interactive panel
27
+ bun test
28
+ packed service > ~/.config/systemd/user/packed.service
29
+ systemctl --user daemon-reload
30
+ systemctl --user enable --now packed.service
31
+
32
+ packed search lsp
33
+ packed install npm:pi-lsp
34
+ packed installed --json
35
+
36
+ # In Pi after installing the package:
37
+ /packages
36
38
  ```
37
39
 
40
+ Packages execute arbitrary code. `pkg_install` always requires interactive user
41
+ confirmation before the extension sends the mutation to the authenticated
42
+ daemon.
43
+
38
44
  ## CLI
39
45
 
40
46
  | Command | What |
41
47
  |---|---|
42
- | `packed search <q> [--limit N] [--json]` | npm search scoped to `keywords:pi-package` |
43
- | `packed info <name> [--json]` | version, repo, pi manifest, size |
44
- | `packed updates [--cached]` | drift vs dist-tags.latest (`--cached` = watcher snapshot) |
45
- | `packed installed` | parse `~/.pi/agent/settings.json` (+ node_modules versions) |
46
- | `packed catalog` | full pi-package snapshot (hot browse) |
47
- | `packed install <source>` | allowlisted: `npm:` / `git:` / `https://` |
48
- | `packed remove <name>` | bare npm name |
49
- | `packed serve` | daemon: HTTP API + watcher + catalog sync + idle self-exit |
50
-
51
- ## Service API (loopback + bearer token)
48
+ | `packed search <q> [--offline] [--limit N] [--json]` | Search npm or the local mirror, scoped to `keywords:pi-package` |
49
+ | `packed info <name> [--json]` | Show version, repository, Pi manifest, size, and license |
50
+ | `packed updates [--json]` | Show drift from the local mirror |
51
+ | `packed mirror [--json]` | Refresh the SQLite package index |
52
+ | `packed installed [--json]` | Read Pi's installed package declarations |
53
+ | `packed catalog [--json]` | Inspect the local package index |
54
+ | `packed install <source> [--json]` | Authenticated daemon install for `npm:`, `git:`, or `https://` sources |
55
+ | `packed remove <name> [--json]` | Authenticated daemon removal by bare npm name |
56
+ | `packed serve` | Run the loopback daemon |
57
+ | `packed service` | Print the systemd user unit |
58
+ | `packed version` | Print the package/service version |
59
+
60
+ Install/remove JSON results are stable objects:
61
+
62
+ ```json
63
+ {"ok":true,"source":"npm:pi-lsp","output":"Installed npm:pi-lsp"}
64
+ ```
52
65
 
53
- `GET /health` · `GET /search?q=&limit=` · `GET /info?name=` · `POST /install`
54
- · `GET /updates` · `GET /catalog`
66
+ Failures use exit code 1 and `{ "ok": false, ... , "error": "..." }` with
67
+ credential-safe diagnostics. Usage errors use exit code 2.
55
68
 
56
- State in `~/.cache/pi-packed/` (`PI_PACKED_HOME`): `token`, `port`,
57
- `updates.json`, `catalog.json`. Env knobs: `PI_PACKED_WATCH_SECS` (default
58
- 30min), `PI_PACKED_CATALOG_SECS` (6h), `PI_PACKED_IDLE_SECS` (10min),
59
- `PI_PACKED_PI_HOME`, `PACKED_CLI` / `PACKED_BIN` (seam overrides).
69
+ ## Service API
60
70
 
61
- ## Architecture (patterns)
71
+ Every route requires the bearer token stored in the private state directory.
72
+ The daemon listens on loopback only.
62
73
 
63
- | Pattern | Where |
74
+ | Method | Route |
64
75
  |---|---|
65
- | Ports & Adapters | `ports.ts` interfaces; drivers: HTTP, CLI, watcher, tests; driven: npm, pi exec |
66
- | Proxy | daemon = caching/protection proxy of npm; `DaemonRegistry` = remote proxy |
67
- | Facade | lean JSON over npm's verbose documents |
68
- | Event-driven | watcher + catalogSync produce snapshots; seam consumes on `session_start` `ctx.ui.notify` (event-carried state, no callbacks) |
69
- | Command | `cli.ts` command table (go-tool/Cobra convention, zero deps) |
70
- | Deep module | tiny API surface, rich internals — one package, no sprawl |
71
-
72
- ## Decisions
73
-
74
- - **Bun/TS over Go/Rust**: one language end-to-end, no build step (Bun runs
75
- TS directly), `pi install git:…` works without binaries. Service is
76
- IO-bound; runtime perf is irrelevant.
77
- - **No Cobra/urfave**: agent-first CLI; stdlib-style flag parsing with
78
- flags-anywhere support (LLMs emit flags in random positions).
79
- - **npm registry is the source of truth** (5,500+ pkgs): pi.dev has no API
80
- (`/api/*` → "reserved for future features"), its gallery is curated HTML
81
- (~50/page, server-side `?name=` filter). Catalog sync paginates
82
- `keywords:pi-package` (250/page) into `catalog.json`, TTL 6h — npm's
83
- search API has no ETag, so conditional revalidation is impossible.
84
- - **Install validation** is an allowlist regex (defense-in-depth on top of
85
- the bearer token): no shell metacharacters, ever.
86
-
87
- ## Roadmap
88
-
89
- - `pi.dev` JSON API when it ships (currently 501 reserved)
90
- - `replicate.npmjs.com` changes feed for near-real-time catalog
91
- - Unix socket transport; `bun build --compile` single-binary distribution
92
- - `/packages` remote-browse view fed by `catalog.json`
76
+ | `GET` | `/health` |
77
+ | `GET` | `/search?q=&limit=&offline=1` |
78
+ | `GET` | `/info?name=` |
79
+ | `GET` | `/installed` |
80
+ | `GET` | `/updates` |
81
+ | `GET` | `/catalog` |
82
+ | `POST` | `/install` with `{ "source": "..." }` |
83
+ | `POST` | `/remove` with `{ "name": "..." }` |
84
+
85
+ State defaults to `~/.cache/pi-packed/` and contains `token`, `port`,
86
+ `updates.json`, and `packed.db`. Relevant environment variables:
87
+
88
+ - `PI_PACKED_HOME`
89
+ - `PI_PACKED_PI_HOME`
90
+ - `PI_PACKED_WATCH_SECS`
91
+ - `PI_PACKED_CATALOG_SECS`
92
+ - `PI_PACKED_IDLE_SECS`
93
+ - `PI_PACKED_PI_BIN` / `PI_BIN`
94
+
95
+ ## Architecture and safety
96
+
97
+ - **Daemon-owned SQLite:** extensions never open the mirror directly.
98
+ - **Runtime boundary:** extensions never call `Bun.spawn`; only the supervised
99
+ Bun daemon owns the `ExecInstaller` adapter.
100
+ - **Authenticated typed client:** extension and mutation CLI paths call the same
101
+ loopback API and reconnect after daemon restarts.
102
+ - **Ports and adapters:** registry and installer ports keep policy independent
103
+ from npm, SQLite, subprocess, HTTP, and UI adapters.
104
+ - **Allowlisted mutation input:** package sources and names reject shell
105
+ metacharacters before reaching the installer.
106
+ - **Bounded requests:** daemon calls use timeouts and return structured errors
107
+ without tokens or credentials.
108
+
109
+ ## Development
110
+
111
+ ```bash
112
+ bun test
113
+ bunx tsc --noEmit
114
+ ```
@@ -14,8 +14,8 @@ import { showPackages } from "./tui.js";
14
14
  import { createNatives } from "./packed.js";
15
15
  import { formatUpdateNotice } from "./model.js";
16
16
 
17
- // Async factory (pi awaits it): dynamic imports load the service library
18
- // in-process the native pattern, no subprocess.
17
+ // Async factory (pi awaits it): the seam creates authenticated daemon
18
+ // clients lazily. It never executes Bun-only adapters or opens SQLite.
19
19
  export default async function (pi: ExtensionAPI) {
20
20
  const natives = await createNatives();
21
21
  registerTools(pi, natives);
@@ -1,17 +1,18 @@
1
1
  /**
2
- * packed.ts — native library client. The seam imports the pi-packed service
3
- * modules IN-PROCESS (web-spider's pattern: dynamic import() bypasses jiti's
4
- * CJS interop, which can drop class constructors for "type":"module" packages).
2
+ * packed.ts — thin Pi extension seam over the authenticated package daemon.
5
3
  *
6
- * The SQLite mirror is the shared substrate (WAL: the daemon writes, we read
7
- * concurrently). The daemon remains the background producer; the seam works
8
- * even with the daemon down. No subprocess, no token/port files.
4
+ * Pi's extension runtime is Node-compatible and does not guarantee a global
5
+ * `Bun`. All registry reads, SQLite access, and package mutations therefore
6
+ * stay inside the supervised Bun daemon. The seam reconnects for every call so
7
+ * a restarted daemon cannot leave a stale port/token client cached in Pi.
9
8
  */
10
- import type { Db } from "../../src/db.ts";
9
+ import type { PackageDaemonPort as ClientPackageDaemonPort } from "../../src/client.ts";
11
10
  import type { InstalledPkg, Pkg, PkgInfo, UpdateEntry } from "../../src/ports.ts";
12
11
 
13
12
  export type { InstalledPkg, UpdateEntry };
14
13
  export type PackageInfo = PkgInfo;
14
+ export type PackageDaemonPort = ClientPackageDaemonPort;
15
+
15
16
  export interface SearchResponse {
16
17
  query: string;
17
18
  total: number;
@@ -28,52 +29,36 @@ export interface Natives {
28
29
  remove(name: string): Promise<string>;
29
30
  }
30
31
 
31
- export async function createNatives(): Promise<Natives> {
32
- const [dbMod, regMod, instMod, watchMod, execMod, stateMod, portsMod] = await Promise.all([
33
- import("../../src/db.ts"),
34
- import("../../src/registry.ts"),
35
- import("../../src/installed.ts"),
36
- import("../../src/watcher.ts"),
37
- import("../../src/install.ts"),
32
+ export type PackageDaemonConnector = () => Promise<PackageDaemonPort>;
33
+
34
+ async function connectDefaultDaemon(): Promise<PackageDaemonPort> {
35
+ const [client, state] = await Promise.all([
36
+ import("../../src/client.ts"),
38
37
  import("../../src/state.ts"),
39
- import("../../src/ports.ts"),
40
38
  ]);
39
+ return client.connectPackageDaemon(state.stateDir());
40
+ }
41
41
 
42
- const reg = new regMod.HttpRegistry();
43
- const inst = new execMod.ExecInstaller();
44
-
45
- // Per-call open/close: lifecycle-clean (no held handles, safe with the
46
- // daemon writing concurrently under WAL).
47
- function withDb<T>(fn: (db: Db) => T): T {
48
- const db = dbMod.openDb(dbMod.dbPath(stateMod.stateDir()));
49
- try {
50
- return fn(db);
51
- } finally {
52
- db.close();
42
+ export async function createNatives(connect: PackageDaemonConnector = connectDefaultDaemon): Promise<Natives> {
43
+ async function call<T>(operation: (daemon: PackageDaemonPort) => Promise<T>): Promise<T> {
44
+ let lastError: unknown;
45
+ for (let attempt = 0; attempt < 2; attempt += 1) {
46
+ try {
47
+ return await operation(await connect());
48
+ } catch (error) {
49
+ lastError = error;
50
+ }
53
51
  }
52
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
54
53
  }
55
54
 
56
55
  return {
57
- async search(query, limit) {
58
- const { results, total } = await reg.search(portsMod.buildSearchQuery(query), limit);
59
- return { query, total, results };
60
- },
61
- async searchOffline(query, limit) {
62
- const results = withDb((db) => dbMod.searchLocal(db, query, limit));
63
- return { query, total: results.length, results };
64
- },
65
- info: (name) => reg.info(name),
66
- installed: () => Promise.resolve(instMod.readInstalledPackages(instMod.defaultPiHome())),
67
- updates: () =>
68
- Promise.resolve(
69
- withDb((db) =>
70
- watchMod.checkUpdates(
71
- (name) => dbMod.latestVersion(db, name),
72
- instMod.readInstalledPackages(instMod.defaultPiHome()),
73
- ),
74
- ),
75
- ),
76
- install: (source) => inst.install(source),
77
- remove: (name) => inst.remove(`npm:${name}`),
56
+ search: (query, limit) => call((daemon) => daemon.search(query, limit)),
57
+ searchOffline: (query, limit) => call((daemon) => daemon.search(query, limit, true)),
58
+ info: (name) => call((daemon) => daemon.info(name)),
59
+ installed: () => call((daemon) => daemon.installed()),
60
+ updates: () => call((daemon) => daemon.updates()),
61
+ install: (source) => call((daemon) => daemon.install(source)),
62
+ remove: (name) => call((daemon) => daemon.remove(name)),
78
63
  };
79
64
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-packed",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Package service for the Pi agent: search/info/install/updates + /packages TUI, backed by a long-running Bun service",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -8,6 +8,7 @@
8
8
  ],
9
9
  "scripts": {
10
10
  "test": "bun test",
11
+ "typecheck": "bun x tsc --noEmit",
11
12
  "cli": "bun src/cli.ts",
12
13
  "serve": "bun src/cli.ts serve"
13
14
  },
package/src/cli.ts CHANGED
@@ -10,7 +10,12 @@ import { readInstalledPackages } from "./installed.ts";
10
10
  import { checkUpdates } from "./watcher.ts";
11
11
  import { syncCatalog } from "./catalog.ts";
12
12
  import { openDb, searchLocal, catalogList, getSyncMeta, latestVersion, dbPath } from "./db.ts";
13
- import { NAME_RE } from "./install.ts";
13
+ import { NAME_RE, defaultPiBin } from "./install.ts";
14
+
15
+ function defaultPiBinForUnit(): string | undefined {
16
+ const b = defaultPiBin();
17
+ return b === "pi" ? undefined : b; // bare name needs no pin
18
+ }
14
19
  import {
15
20
  VERSION, SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT, NPM_REGISTRY_BASE, SEARCH_PAGE_SIZE, MIRROR_PAGE_DELAY_MS,
16
21
  } from "./constants.ts";
@@ -26,8 +31,8 @@ usage:
26
31
  packed mirror [--json] sync upstream into the local SQLite index
27
32
  packed installed [--json] installed pi packages
28
33
  packed catalog [--json] local package index (apt-cache stats)
29
- packed install <source> pi install npm:|git:|https://…
30
- packed remove <name> remove by bare npm name
34
+ packed install <source> [--json] pi install npm:|git:|https://… via daemon
35
+ packed remove <name> [--json] remove by bare npm name via daemon
31
36
  packed serve run the long-running daemon
32
37
  packed service print a systemd user unit
33
38
  packed version print version
@@ -40,6 +45,7 @@ export interface CliDeps {
40
45
  piHome: string;
41
46
  execPath?: string; // bun binary (defaults to process.execPath)
42
47
  cliPath?: string; // this CLI's entry file (for the systemd unit)
48
+ piBin?: string; // pi binary path to pin into the unit's Environment
43
49
  }
44
50
 
45
51
  export interface CliResult {
@@ -179,27 +185,31 @@ const commands: Record<string, { usage: string; run: Command }> = {
179
185
  },
180
186
 
181
187
  install: {
182
- usage: "packed install npm:<pkg>[@ver] | git:<host>/<owner>/<repo>[@ref] | https://…",
183
- async run(_rest, d, _flags, pos) {
188
+ usage: "packed install npm:<pkg>[@ver] | git:<host>/<owner>/<repo>[@ref] | https://… [--json]",
189
+ async run(_rest, d, flags, pos) {
184
190
  const source = pos[0] ?? "";
185
191
  if (!SOURCE_RE.test(source)) return usageErr(`usage: ${commands["install"]!.usage}\n`);
186
192
  try {
187
- return ok((await d.inst.install(source)) + "\n");
193
+ const output = await d.inst.install(source);
194
+ return flags.json ? ok(`${JSON.stringify({ ok: true, source, output })}\n`) : ok(`${output}\n`);
188
195
  } catch (e) {
189
- return fail(`${e instanceof Error ? e.message : e}\n`);
196
+ const error = e instanceof Error ? e.message : String(e);
197
+ return flags.json ? fail(`${JSON.stringify({ ok: false, source, error })}\n`) : fail(`${error}\n`);
190
198
  }
191
199
  },
192
200
  },
193
201
 
194
202
  remove: {
195
- usage: "packed remove <name> (bare npm name, e.g. pi-lsp or @scope/pkg)",
196
- async run(_rest, d, _flags, pos) {
203
+ usage: "packed remove <name> [--json] (bare npm name, e.g. pi-lsp or @scope/pkg)",
204
+ async run(_rest, d, flags, pos) {
197
205
  const name = pos[0] ?? "";
198
206
  if (!NAME_RE.test(name)) return usageErr(`usage: ${commands["remove"]!.usage}\n`);
199
207
  try {
200
- return ok((await d.inst.remove(`npm:${name}`)) + "\n");
208
+ const output = await d.inst.remove(`npm:${name}`);
209
+ return flags.json ? ok(`${JSON.stringify({ ok: true, name, output })}\n`) : ok(`${output}\n`);
201
210
  } catch (e) {
202
- return fail(`${e instanceof Error ? e.message : e}\n`);
211
+ const error = e instanceof Error ? e.message : String(e);
212
+ return flags.json ? fail(`${JSON.stringify({ ok: false, name, error })}\n`) : fail(`${error}\n`);
203
213
  }
204
214
  },
205
215
  },
@@ -209,7 +219,7 @@ const commands: Record<string, { usage: string; run: Command }> = {
209
219
  async run(_rest, d) {
210
220
  const execPath = d.execPath ?? process.execPath;
211
221
  const cliPath = d.cliPath ?? new URL("./cli.ts", import.meta.url).pathname;
212
- return ok(renderUnit(execPath, cliPath));
222
+ return ok(renderUnit(execPath, cliPath, d.piBin ?? defaultPiBinForUnit()));
213
223
  },
214
224
  },
215
225
 
@@ -223,7 +233,10 @@ const commands: Record<string, { usage: string; run: Command }> = {
223
233
 
224
234
  /** systemd user unit. Idle self-exit is disabled — systemd owns the
225
235
  * lifecycle (Restart=on-failure takes over). */
226
- export function renderUnit(execPath: string, cliPath: string): string {
236
+ export function renderUnit(execPath: string, cliPath: string, piBin?: string): string {
237
+ // systemd does not read shell rc files: PI_BIN must be explicit so the
238
+ // daemon's install/remove execs can find the pi binary.
239
+ const piEnv = piBin ? `Environment=PI_BIN=${piBin}\n` : "";
227
240
  return `[Unit]
228
241
  Description=pi-packed package service (Pi agent)
229
242
 
@@ -233,7 +246,7 @@ ExecStart=${execPath} ${cliPath} serve
233
246
  Restart=on-failure
234
247
  RestartSec=2
235
248
  Environment=PI_PACKED_IDLE_SECS=0
236
- NoNewPrivileges=true
249
+ ${piEnv}NoNewPrivileges=true
237
250
 
238
251
  [Install]
239
252
  WantedBy=default.target
@@ -264,8 +277,7 @@ if (import.meta.main) {
264
277
  } else {
265
278
  const { stateDir } = await import("./state.ts");
266
279
  const { defaultPiHome } = await import("./installed.ts");
267
- const { resolveRegistry } = await import("./client.ts");
268
- const { ExecInstaller } = await import("./install.ts");
280
+ const { DaemonBackedInstaller, resolveRegistry } = await import("./client.ts");
269
281
  const dir = stateDir();
270
282
  // mirror talks to UPSTREAM, not the daemon cache — apt update semantics.
271
283
  const reg =
@@ -274,7 +286,7 @@ if (import.meta.main) {
274
286
  : await resolveRegistry(dir, NPM_REGISTRY_BASE);
275
287
  const { code, out } = await cliRun(args, {
276
288
  reg,
277
- inst: new ExecInstaller(),
289
+ inst: new DaemonBackedInstaller(dir),
278
290
  stateDir: dir,
279
291
  piHome: defaultPiHome(),
280
292
  });
package/src/client.ts CHANGED
@@ -1,49 +1,158 @@
1
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.
2
+ * client.ts — authenticated loopback clients for the supervised package daemon.
3
+ * The CLI may fall back to npm for read-only registry queries; Pi extensions use
4
+ * PackageDaemonClient exclusively so Bun execution and SQLite remain daemon-owned.
5
5
  */
6
6
  import { readFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
- import type { PkgInfo, Registry, SearchPage } from "./ports.ts";
8
+ import type { InstalledPkg, Installer, PkgInfo, Registry, SearchPage, UpdateEntry, UpdatesSnapshot } from "./ports.ts";
9
9
  import { HttpRegistry } from "./registry.ts";
10
10
  import { DAEMON_HOST, PROBE_TIMEOUT_MS, REGISTRY_FETCH_TIMEOUT_MS, PORT_FILE, TOKEN_FILE } from "./constants.ts";
11
11
 
12
- export class DaemonRegistry implements Registry {
12
+ export type FetchTransport = (request: Request) => Promise<Response>;
13
+
14
+ export interface PackageDaemonPort {
15
+ search(query: string, limit: number, offline?: boolean): Promise<{ query: string; total: number; results: SearchPage["results"] }>;
16
+ info(name: string): Promise<PkgInfo>;
17
+ installed(): Promise<InstalledPkg[]>;
18
+ updates(): Promise<UpdateEntry[]>;
19
+ install(source: string): Promise<string>;
20
+ remove(name: string): Promise<string>;
21
+ }
22
+
23
+ interface MutationResponse {
24
+ ok: boolean;
25
+ output: string;
26
+ }
27
+
28
+ export class PackageDaemonError extends Error {
13
29
  constructor(
14
- private base: string,
15
- private token: string,
30
+ message: string,
31
+ readonly operation: string,
32
+ readonly status?: number,
33
+ ) {
34
+ super(message);
35
+ this.name = "PackageDaemonError";
36
+ }
37
+ }
38
+
39
+ export class PackageDaemonClient implements PackageDaemonPort {
40
+ constructor(
41
+ private readonly base: string,
42
+ private readonly token: string,
43
+ private readonly transport: FetchTransport = fetch,
16
44
  ) {}
17
45
 
18
- private async get<T>(path: string): Promise<T> {
19
- const res = await fetch(`${this.base}${path}`, {
20
- headers: { authorization: `Bearer ${this.token}` },
21
- signal: AbortSignal.timeout(REGISTRY_FETCH_TIMEOUT_MS),
46
+ private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
47
+ const headers = new Headers(init.headers);
48
+ headers.set("authorization", `Bearer ${this.token}`);
49
+ if (init.body !== undefined) headers.set("content-type", "application/json");
50
+ const response = await this.transport(new Request(`${this.base}${path}`, {
51
+ ...init,
52
+ headers,
53
+ signal: init.signal ?? AbortSignal.timeout(REGISTRY_FETCH_TIMEOUT_MS),
54
+ }));
55
+ let body: unknown;
56
+ try {
57
+ body = await response.json();
58
+ } catch {
59
+ throw new PackageDaemonError(`package daemon returned invalid JSON (HTTP ${response.status})`, path, response.status);
60
+ }
61
+ if (!response.ok) {
62
+ const message = typeof body === "object" && body !== null && typeof (body as { error?: unknown }).error === "string"
63
+ ? (body as { error: string }).error
64
+ : `package daemon HTTP ${response.status}`;
65
+ throw new PackageDaemonError(message, path, response.status);
66
+ }
67
+ return body as T;
68
+ }
69
+
70
+ async search(query: string, limit: number, offline = false): Promise<{ query: string; total: number; results: SearchPage["results"] }> {
71
+ const params = new URLSearchParams({ q: query, limit: String(limit) });
72
+ if (offline) params.set("offline", "1");
73
+ return this.request(`/search?${params}`);
74
+ }
75
+
76
+ info(name: string): Promise<PkgInfo> {
77
+ return this.request(`/info?name=${encodeURIComponent(name)}`);
78
+ }
79
+
80
+ installed(): Promise<InstalledPkg[]> {
81
+ return this.request("/installed");
82
+ }
83
+
84
+ async updates(): Promise<UpdateEntry[]> {
85
+ return (await this.request<UpdatesSnapshot>("/updates")).updates;
86
+ }
87
+
88
+ async install(source: string): Promise<string> {
89
+ const result = await this.request<MutationResponse>("/install", {
90
+ method: "POST",
91
+ body: JSON.stringify({ source }),
22
92
  });
23
- if (!res.ok) throw new Error(`daemon HTTP ${res.status}`);
24
- return (await res.json()) as T;
93
+ if (!result.ok) throw new PackageDaemonError(result.output || `failed to install ${source}`, "install");
94
+ return result.output;
95
+ }
96
+
97
+ async remove(name: string): Promise<string> {
98
+ const result = await this.request<MutationResponse>("/remove", {
99
+ method: "POST",
100
+ body: JSON.stringify({ name }),
101
+ });
102
+ if (!result.ok) throw new PackageDaemonError(result.output || `failed to remove ${name}`, "remove");
103
+ return result.output;
104
+ }
105
+ }
106
+
107
+ export class PackageDaemonInstaller implements Installer {
108
+ constructor(private readonly client: PackageDaemonClient) {}
109
+
110
+ install(source: string): Promise<string> {
111
+ return this.client.install(source);
112
+ }
113
+
114
+ remove(source: string): Promise<string> {
115
+ if (!source.startsWith("npm:") || source.length <= 4) {
116
+ throw new PackageDaemonError("daemon package removal requires an npm: source", "remove");
117
+ }
118
+ return this.client.remove(source.slice(4));
119
+ }
120
+ }
121
+
122
+ export class DaemonBackedInstaller implements Installer {
123
+ constructor(private readonly stateDirectory: string) {}
124
+
125
+ async install(source: string): Promise<string> {
126
+ return (await connectPackageDaemon(this.stateDirectory)).install(source);
127
+ }
128
+
129
+ async remove(source: string): Promise<string> {
130
+ return new PackageDaemonInstaller(await connectPackageDaemon(this.stateDirectory)).remove(source);
131
+ }
132
+ }
133
+
134
+ export class DaemonRegistry implements Registry {
135
+ private readonly client: PackageDaemonClient;
136
+
137
+ constructor(base: string, token: string, transport: FetchTransport = fetch) {
138
+ this.client = new PackageDaemonClient(base, token, transport);
25
139
  }
26
140
 
27
141
  async search(query: string, limit: number): Promise<SearchPage> {
28
- const body = await this.get<{ results: SearchPage["results"]; total: number }>(
29
- `/search?q=${encodeURIComponent(query)}&limit=${limit}`,
30
- );
142
+ const body = await this.client.search(query, limit);
31
143
  return { results: body.results, total: body.total };
32
144
  }
33
145
 
34
- async searchPage(query: string, from: number, size: number): Promise<SearchPage> {
35
- // The daemon clamps to 50; page through it for bulk reads.
146
+ async searchPage(query: string, _from: number, size: number): Promise<SearchPage> {
36
147
  return this.search(query, size).catch(() => ({ results: [], total: 0 }));
37
148
  }
38
149
 
39
150
  async searchAll(): Promise<never> {
40
- // Bulk sync runs inside the daemon against the direct registry;
41
- // the proxy never paginates the full universe through the clamp.
42
151
  throw new Error("searchAll is not supported via the daemon proxy");
43
152
  }
44
153
 
45
- async info(name: string): Promise<PkgInfo> {
46
- return this.get<PkgInfo>(`/info?name=${encodeURIComponent(name)}`);
154
+ info(name: string): Promise<PkgInfo> {
155
+ return this.client.info(name);
47
156
  }
48
157
  }
49
158
 
@@ -74,6 +183,12 @@ export async function probe(dir: string): Promise<DaemonHandle | undefined> {
74
183
  return undefined;
75
184
  }
76
185
 
186
+ export async function connectPackageDaemon(dir: string): Promise<PackageDaemonClient> {
187
+ const handle = await probe(dir);
188
+ if (!handle) throw new Error("pi-packed daemon is unavailable; start packed.service");
189
+ return new PackageDaemonClient(handle.base, handle.token);
190
+ }
191
+
77
192
  export async function resolveRegistry(dir: string, npmBase: string): Promise<Registry> {
78
193
  const handle = await probe(dir);
79
194
  if (handle) return new DaemonRegistry(handle.base, handle.token);
package/src/constants.ts CHANGED
@@ -31,7 +31,7 @@ export const IDLE_BUDGET_DEFAULT_MS = 10 * 60_000; // on-demand self-exit
31
31
  export const WATCHDOG_TICK_MS = 15_000;
32
32
 
33
33
  // --- Identity ---
34
- export const VERSION = "0.1.0";
34
+ export const VERSION = "0.2.1";
35
35
 
36
36
  // --- State-dir file names ---
37
37
  export const TOKEN_FILE = "token";
package/src/install.ts CHANGED
@@ -4,8 +4,12 @@ import type { Installer } from "./ports.ts";
4
4
  /** Bare npm package name (for `packed remove`). */
5
5
  export const NAME_RE = /^(@[A-Za-z0-9._-]+\/)?[A-Za-z0-9._-]+$/;
6
6
 
7
+ export function defaultPiBin(): string {
8
+ return process.env["PI_PACKED_PI_BIN"] ?? process.env["PI_BIN"] ?? "pi";
9
+ }
10
+
7
11
  export class ExecInstaller implements Installer {
8
- constructor(private bin = "pi") {}
12
+ constructor(private bin = defaultPiBin()) {}
9
13
 
10
14
  private async run(args: string[]): Promise<string> {
11
15
  const proc = Bun.spawn([this.bin, ...args], { stdout: "pipe", stderr: "pipe" });