@danypops/pi-packed 0.1.7 → 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
+ ```
@@ -11,31 +11,32 @@
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import { registerTools } from "./tools.js";
13
13
  import { showPackages } from "./tui.js";
14
- import { runPacked } from "./packed.js";
14
+ import { createNatives } from "./packed.js";
15
15
  import { formatUpdateNotice } from "./model.js";
16
- import type { UpdatesSnapshot } from "./packed.js";
17
16
 
18
- export default function (pi: ExtensionAPI) {
19
- registerTools(pi);
17
+ // Async factory (pi awaits it): the seam creates authenticated daemon
18
+ // clients lazily. It never executes Bun-only adapters or opens SQLite.
19
+ export default async function (pi: ExtensionAPI) {
20
+ const natives = await createNatives();
21
+ registerTools(pi, natives);
20
22
 
21
23
  pi.registerCommand("packages", {
22
24
  description: "Browse and manage installed Pi packages (pi-packed)",
23
25
  handler: async (_args, ctx) => {
24
- await showPackages(ctx);
26
+ await showPackages(ctx, natives);
25
27
  },
26
28
  });
27
29
 
28
- // The watcher (daemon side) produces the snapshot; the seam consumes it
29
- // on Pi's own lifecycle event — that's the whole event-driven story.
30
+ // Update check against the local mirror, on Pi's own lifecycle event.
30
31
  pi.on("session_start", async (_event, ctx) => {
31
32
  if (!ctx.hasUI) return;
32
33
  try {
33
- const snap = await runPacked<UpdatesSnapshot>(["updates", "--cached"], 3_000);
34
- if (snap.updates?.length) {
35
- ctx.ui.notify(`${formatUpdateNotice(snap.updates)} — /packages to review`, "info");
34
+ const updates = await natives.updates();
35
+ if (updates.length) {
36
+ ctx.ui.notify(`${formatUpdateNotice(updates)} — /packages to review`, "info");
36
37
  }
37
38
  } catch {
38
- // packed missing or daemon down — stay silent, never block startup.
39
+ // mirror missing or unreadable — stay silent, never block startup.
39
40
  }
40
41
  });
41
42
  }
@@ -1,90 +1,64 @@
1
1
  /**
2
- * packed.ts — the ONLY place the seam touches the service side.
3
- * Thin exec wrapper: every call is `bun <pi-packed>/src/cli.ts <cmd>`.
4
- * No registry, daemon, or npm knowledge lives here.
2
+ * packed.ts — thin Pi extension seam over the authenticated package daemon.
3
+ *
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.
5
8
  */
6
- import { execFile } from "node:child_process";
9
+ import type { PackageDaemonPort as ClientPackageDaemonPort } from "../../src/client.ts";
10
+ import type { InstalledPkg, Pkg, PkgInfo, UpdateEntry } from "../../src/ports.ts";
7
11
 
8
- export interface InstalledPkg {
9
- name: string;
10
- pinned?: string;
11
- installed?: string;
12
- }
13
-
14
- export interface UpdateEntry {
15
- name: string;
16
- installed: string;
17
- latest: string;
18
- detectedAt?: string;
19
- }
20
-
21
- export interface UpdatesSnapshot {
22
- checkedAt?: string;
23
- updates: UpdateEntry[];
24
- }
25
-
26
- export interface SearchResult {
27
- name: string;
28
- version: string;
29
- description?: string;
30
- date?: string;
31
- }
12
+ export type { InstalledPkg, UpdateEntry };
13
+ export type PackageInfo = PkgInfo;
14
+ export type PackageDaemonPort = ClientPackageDaemonPort;
32
15
 
33
16
  export interface SearchResponse {
34
17
  query: string;
35
18
  total: number;
36
- results: SearchResult[];
19
+ results: Pkg[];
37
20
  }
38
21
 
39
- export interface PackageInfo {
40
- name: string;
41
- version: string;
42
- description?: string;
43
- homepage?: string;
44
- repository?: string;
45
- license?: string;
46
- keywords?: string[];
47
- pi?: Record<string, unknown>;
48
- modified?: string;
49
- unpackedSize?: number;
22
+ export interface Natives {
23
+ search(query: string, limit: number): Promise<SearchResponse>;
24
+ searchOffline(query: string, limit: number): Promise<SearchResponse>;
25
+ info(name: string): Promise<PackageInfo>;
26
+ installed(): Promise<InstalledPkg[]>;
27
+ updates(): Promise<UpdateEntry[]>;
28
+ install(source: string): Promise<string>;
29
+ remove(name: string): Promise<string>;
50
30
  }
51
31
 
52
- /** Path to the pi-packed CLI entry (resolved relative to this file). */
53
- export function cliPath(): string {
54
- if (process.env["PACKED_CLI"]) return process.env["PACKED_CLI"];
55
- return new URL("../../src/cli.ts", import.meta.url).pathname;
56
- }
32
+ export type PackageDaemonConnector = () => Promise<PackageDaemonPort>;
57
33
 
58
- export function packedCmd(): { bin: string; prefix: string[] } {
59
- if (process.env["PACKED_BIN"]) return { bin: process.env["PACKED_BIN"], prefix: [] };
60
- return { bin: process.env["PACKED_BUN"] ?? "bun", prefix: [cliPath()] };
34
+ async function connectDefaultDaemon(): Promise<PackageDaemonPort> {
35
+ const [client, state] = await Promise.all([
36
+ import("../../src/client.ts"),
37
+ import("../../src/state.ts"),
38
+ ]);
39
+ return client.connectPackageDaemon(state.stateDir());
61
40
  }
62
41
 
63
- function exec(cmd: string, args: string[], timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
64
- return new Promise((resolve, reject) => {
65
- execFile(cmd, args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
66
- if (err) {
67
- reject(new Error(stderr?.trim() || err.message));
68
- return;
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;
69
50
  }
70
- resolve({ stdout, stderr });
71
- });
72
- });
73
- }
74
-
75
- export async function runPacked<T>(args: string[], timeoutMs = 15_000): Promise<T> {
76
- const { bin, prefix } = packedCmd();
77
- const { stdout } = await exec(bin, [...prefix, ...args, "--json"], timeoutMs);
78
- try {
79
- return JSON.parse(stdout) as T;
80
- } catch {
81
- throw new Error(`packed ${args[0]}: invalid JSON: ${stdout.slice(0, 200)}`);
51
+ }
52
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
82
53
  }
83
- }
84
54
 
85
- /** Text output variant for install/remove (human-readable pi output). */
86
- export async function runPackedText(args: string[], timeoutMs = 180_000): Promise<string> {
87
- const { bin, prefix } = packedCmd();
88
- const { stdout, stderr } = await exec(bin, [...prefix, ...args], timeoutMs);
89
- return [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
55
+ return {
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)),
63
+ };
90
64
  }
@@ -4,14 +4,13 @@
4
4
  */
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import { Type } from "typebox";
7
- import { runPacked, runPackedText } from "./packed.js";
8
- import type { PackageInfo, SearchResponse } from "./packed.js";
7
+ import type { Natives } from "./packed.js";
9
8
 
10
9
  function text(t: string, details: Record<string, unknown> = {}) {
11
10
  return { content: [{ type: "text" as const, text: t }], details };
12
11
  }
13
12
 
14
- export function registerTools(pi: ExtensionAPI): void {
13
+ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
15
14
  pi.registerTool({
16
15
  name: "pkg_search",
17
16
  label: "Pi Package Search",
@@ -24,9 +23,7 @@ export function registerTools(pi: ExtensionAPI): void {
24
23
  }),
25
24
  async execute(_id, params, _signal, _onUpdate, _ctx) {
26
25
  try {
27
- const r = await runPacked<SearchResponse>(
28
- ["search", params.query, "--limit", String(params.limit ?? 10)],
29
- );
26
+ const r = await natives.search(params.query, params.limit ?? 10);
30
27
  if (r.results.length === 0) return text(`No Pi packages found for "${params.query}".`);
31
28
  const lines = r.results.map(
32
29
  (p, i) => `${i + 1}. ${p.name}@${p.version}\n ${p.description ?? ""}`,
@@ -52,7 +49,7 @@ export function registerTools(pi: ExtensionAPI): void {
52
49
  }),
53
50
  async execute(_id, params, _signal, _onUpdate, _ctx) {
54
51
  try {
55
- const info = await runPacked<PackageInfo>(["info", params.name]);
52
+ const info = await natives.info(params.name);
56
53
  const lines = [
57
54
  `${info.name}@${info.version}`,
58
55
  info.description ?? "",
@@ -88,7 +85,7 @@ export function registerTools(pi: ExtensionAPI): void {
88
85
  );
89
86
  if (!ok) return text("Install cancelled by user.");
90
87
  try {
91
- const out = await runPackedText(["install", params.source]);
88
+ const out = await natives.install(params.source);
92
89
  return text(out || `Installed ${params.source}. Reload with /reload to activate.`);
93
90
  } catch (e) {
94
91
  return text(`install failed: ${e instanceof Error ? e.message : e}`);
@@ -9,33 +9,32 @@ import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
9
9
  import { Container, Input, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
10
  import { filterRows, mergeRows, nextMode, visibleRows } from "./model.js";
11
11
  import type { Row, ViewMode } from "./model.js";
12
- import { runPacked, runPackedText } from "./packed.js";
13
- import type { InstalledPkg, UpdatesSnapshot } from "./packed.js";
12
+ import type { Natives } from "./packed.js";
14
13
 
15
14
  interface PanelAction {
16
15
  type: "menu" | "refresh";
17
16
  row?: Row;
18
17
  }
19
18
 
20
- async function loadRows(): Promise<{ rows: Row[]; error?: string }> {
19
+ async function loadRows(natives: Natives): Promise<{ rows: Row[]; error?: string }> {
21
20
  try {
22
- const [installed, snap] = await Promise.all([
23
- runPacked<InstalledPkg[]>(["installed"]),
24
- runPacked<UpdatesSnapshot>(["updates", "--cached"], 5_000).catch(() => ({ updates: [] }) as UpdatesSnapshot),
21
+ const [installed, updates] = await Promise.all([
22
+ natives.installed(),
23
+ natives.updates().catch(() => []),
25
24
  ]);
26
- return { rows: mergeRows(installed, snap.updates ?? []) };
25
+ return { rows: mergeRows(installed, updates) };
27
26
  } catch (e) {
28
27
  return { rows: [], error: e instanceof Error ? e.message : String(e) };
29
28
  }
30
29
  }
31
30
 
32
- export async function showPackages(ctx: ExtensionCommandContext): Promise<void> {
31
+ export async function showPackages(ctx: ExtensionCommandContext, natives: Natives): Promise<void> {
33
32
  if (!ctx.hasUI) {
34
33
  ctx.ui.notify("/packages requires interactive mode", "warning");
35
34
  return;
36
35
  }
37
36
 
38
- let { rows, error } = await loadRows();
37
+ let { rows, error } = await loadRows(natives);
39
38
  if (error) {
40
39
  ctx.ui.notify(`packed unavailable: ${error}`, "error");
41
40
  return;
@@ -47,7 +46,7 @@ export async function showPackages(ctx: ExtensionCommandContext): Promise<void>
47
46
  if (!action) return; // closed
48
47
 
49
48
  if (action.type === "refresh") {
50
- ({ rows, error } = await loadRows());
49
+ ({ rows, error } = await loadRows(natives));
51
50
  if (error) ctx.ui.notify(`refresh failed: ${error}`, "error");
52
51
  continue;
53
52
  }
@@ -67,7 +66,7 @@ export async function showPackages(ctx: ExtensionCommandContext): Promise<void>
67
66
  if (choice?.startsWith("Update")) {
68
67
  ctx.ui.notify(`Updating ${row.name}…`, "info");
69
68
  try {
70
- await runPackedText(["install", `npm:${row.name}@${row.latest}`]);
69
+ await natives.install(`npm:${row.name}@${row.latest}`);
71
70
  ctx.ui.notify(`Updated ${row.name} to ${row.latest} (takes effect after /reload)`, "info");
72
71
  row.version = row.latest ?? row.version;
73
72
  row.hasUpdate = false;
@@ -78,7 +77,7 @@ export async function showPackages(ctx: ExtensionCommandContext): Promise<void>
78
77
  const sure = await ctx.ui.confirm("Remove package", `pi remove npm:${row.name}?`);
79
78
  if (sure) {
80
79
  try {
81
- await runPackedText(["remove", row.name]);
80
+ await natives.remove(row.name);
82
81
  ctx.ui.notify(`Removed ${row.name}`, "info");
83
82
  rows = rows.filter((r) => r.name !== row.name);
84
83
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-packed",
3
- "version": "0.1.7",
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/db.ts CHANGED
@@ -3,8 +3,48 @@
3
3
  * Master-index role: sync_meta (source, time, count, payload checksum —
4
4
  * the APT Release / repomd.xml analog). Payload: packages + FTS5 mirror.
5
5
  */
6
- import { Database } from "bun:sqlite";
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";
7
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
+ }
8
48
  import { join } from "node:path";
9
49
  import { createHash } from "node:crypto";
10
50
  import type { Pkg } from "./ports.ts";
@@ -21,9 +61,9 @@ export function dbPath(dir: string): string {
21
61
  return join(dir, DB_FILE);
22
62
  }
23
63
 
24
- export function openDb(path: string): Database {
64
+ export function openDb(path: string): Db {
25
65
  if (path !== ":memory:") mkdirSync(join(path, ".."), { recursive: true });
26
- const db = new Database(path, { create: true });
66
+ const db = IS_BUN ? new DatabaseCtor(path, { create: true }) : new DatabaseCtor(path);
27
67
  db.exec("PRAGMA journal_mode = WAL");
28
68
  db.exec(`CREATE TABLE IF NOT EXISTS packages (
29
69
  name TEXT PRIMARY KEY,
@@ -54,7 +94,7 @@ function payloadHash(pkgs: Pkg[]): string {
54
94
  }
55
95
 
56
96
  /** Atomic full-catalog replace (the mirror write side of `apt update`). */
57
- export function replaceAll(db: Database, pkgs: Pkg[], source: string): SyncMeta {
97
+ export function replaceAll(db: Db, pkgs: Pkg[], source: string): SyncMeta {
58
98
  const meta: SyncMeta = {
59
99
  source,
60
100
  fetchedAt: new Date().toISOString(),
@@ -65,7 +105,7 @@ export function replaceAll(db: Database, pkgs: Pkg[], source: string): SyncMeta
65
105
  // pages and a package can appear twice in one sync (rowid reuse is fine).
66
106
  const insert = db.prepare("INSERT OR REPLACE INTO packages (name, version, description, date) VALUES (?, ?, ?, ?)");
67
107
  const insertFts = db.prepare("INSERT INTO packages_fts (rowid, name, description) VALUES (?, ?, ?)");
68
- db.transaction(() => {
108
+ inTransaction(db, () => {
69
109
  db.exec("DELETE FROM packages");
70
110
  db.exec("DELETE FROM packages_fts");
71
111
  for (const p of pkgs) {
@@ -77,32 +117,32 @@ export function replaceAll(db: Database, pkgs: Pkg[], source: string): SyncMeta
77
117
  "ON CONFLICT(id) DO UPDATE SET source=excluded.source, fetched_at=excluded.fetched_at, " +
78
118
  "package_count=excluded.package_count, sha256=excluded.sha256",
79
119
  ).run(meta.source, meta.fetchedAt, meta.packageCount, meta.sha256);
80
- })();
120
+ });
81
121
  return meta;
82
122
  }
83
123
 
84
- export function getSyncMeta(db: Database): SyncMeta | undefined {
124
+ export function getSyncMeta(db: Db): SyncMeta | undefined {
85
125
  return db
86
- .query("SELECT source, fetched_at AS fetchedAt, package_count AS packageCount, sha256 FROM sync_meta WHERE id = 1")
126
+ .prepare("SELECT source, fetched_at AS fetchedAt, package_count AS packageCount, sha256 FROM sync_meta WHERE id = 1")
87
127
  .get() as SyncMeta | undefined;
88
128
  }
89
129
 
90
- export function catalogList(db: Database, limit = 0, offset = 0): Pkg[] {
130
+ export function catalogList(db: Db, limit = 0, offset = 0): Pkg[] {
91
131
  const sql =
92
132
  "SELECT name, version, description, date FROM packages ORDER BY name" +
93
133
  (limit > 0 ? ` LIMIT ${Math.floor(limit)} OFFSET ${Math.floor(offset)}` : "");
94
- return db.query(sql).all() as Pkg[];
134
+ return db.prepare(sql).all() as Pkg[];
95
135
  }
96
136
 
97
137
  /** FTS5 over the mirror (the `apt-cache search` analog). Sanitizes user
98
138
  * input into AND-joined quoted terms; falls back to LIKE for hostile input. */
99
- export function searchLocal(db: Database, q: string, limit = 50): Pkg[] {
139
+ export function searchLocal(db: Db, q: string, limit = 50): Pkg[] {
100
140
  const terms = q.trim().split(/\s+/).filter(Boolean);
101
141
  if (terms.length === 0) return [];
102
142
  try {
103
143
  const match = terms.map((t) => `"${t.replaceAll('"', '""')}"*`).join(" ");
104
144
  return db
105
- .query(
145
+ .prepare(
106
146
  `SELECT p.name, p.version, p.description, p.date
107
147
  FROM packages_fts f JOIN packages p ON p.rowid = f.rowid
108
148
  WHERE packages_fts MATCH ? ORDER BY rank LIMIT ?`,
@@ -112,13 +152,13 @@ export function searchLocal(db: Database, q: string, limit = 50): Pkg[] {
112
152
  // FTS syntax hostility → substring fallback
113
153
  const like = `%${q}%`;
114
154
  return db
115
- .query("SELECT name, version, description, date FROM packages WHERE name LIKE ? OR description LIKE ? ORDER BY name LIMIT ?")
155
+ .prepare("SELECT name, version, description, date FROM packages WHERE name LIKE ? OR description LIKE ? ORDER BY name LIMIT ?")
116
156
  .all(like, like, limit) as Pkg[];
117
157
  }
118
158
  }
119
159
 
120
160
  /** Latest mirrored version of one package (watcher's lookup). */
121
- export function latestVersion(db: Database, name: string): string | undefined {
122
- const row = db.query("SELECT version FROM packages WHERE name = ?").get(name) as { version: string } | null;
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;
123
163
  return row?.version;
124
164
  }
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" });
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 {
package/src/service.ts CHANGED
@@ -7,6 +7,7 @@ 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 { readInstalledPackages, defaultPiHome } from "./installed.ts";
10
11
  import { openDb, searchLocal, catalogList, getSyncMeta, dbPath } from "./db.ts";
11
12
  import { VERSION, SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT } from "./constants.ts";
12
13
  import { createLogger } from "./log.ts";
@@ -18,10 +19,12 @@ export interface Deps {
18
19
  inst: Installer;
19
20
  token: string;
20
21
  stateDir: string;
22
+ piHome?: string;
21
23
  cache?: TTLCache;
22
24
  }
23
25
 
24
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._-]+$/;
25
28
 
26
29
  function json(v: unknown, init?: ResponseInit): Response {
27
30
  return Response.json(v, init);
@@ -73,6 +76,29 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
73
76
  }
74
77
  }
75
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
+
76
102
  if (path === "/install" && req.method === "POST") {
77
103
  let source = "";
78
104
  try {