@danypops/pi-packed 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,92 +1,115 @@
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 · /packed security settings
15
+ │ policy-driven approval · no Bun/SQLite access │
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` uses daemon-owned security settings with secure default `installApproval: always`. Open `/packed` to choose **Always require approval** or explicitly opt out with **Never require approval**. The unsafe opt-out allows agent installs without an interactive UI. `/packages` remains the package browser.
41
+
38
42
  ## CLI
39
43
 
40
44
  | Command | What |
41
45
  |---|---|
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)
46
+ | `packed search <q> [--offline] [--limit N] [--json]` | Search npm or the local mirror, scoped to `keywords:pi-package` |
47
+ | `packed info <name> [--json]` | Show version, repository, Pi manifest, size, and license |
48
+ | `packed updates [--json]` | Show drift from the local mirror |
49
+ | `packed mirror [--json]` | Refresh the SQLite package index |
50
+ | `packed installed [--json]` | Read Pi's installed package declarations |
51
+ | `packed catalog [--json]` | Inspect the local package index |
52
+ | `packed install <source> [--json]` | Authenticated daemon install for `npm:`, `git:`, or `https://` sources |
53
+ | `packed remove <name> [--json]` | Authenticated daemon removal by bare npm name |
54
+ | `packed security [always\|never] [--json]` | Read or set the install approval policy |
55
+ | `packed serve` | Run the loopback daemon |
56
+ | `packed service` | Print the systemd user unit |
57
+ | `packed version` | Print the package/service version |
58
+
59
+ Install/remove JSON results are stable objects:
60
+
61
+ ```json
62
+ {"ok":true,"source":"npm:pi-lsp","output":"Installed npm:pi-lsp"}
63
+ ```
52
64
 
53
- `GET /health` · `GET /search?q=&limit=` · `GET /info?name=` · `POST /install`
54
- · `GET /updates` · `GET /catalog`
65
+ Failures use exit code 1 and `{ "ok": false, ... , "error": "..." }` with
66
+ credential-safe diagnostics. Usage errors use exit code 2.
55
67
 
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).
68
+ ## Service API
60
69
 
61
- ## Architecture (patterns)
70
+ Every route requires the bearer token stored in the private state directory.
71
+ The daemon listens on loopback only.
62
72
 
63
- | Pattern | Where |
73
+ | Method | Route |
64
74
  |---|---|
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`
75
+ | `GET` | `/health` |
76
+ | `GET` | `/search?q=&limit=&offline=1` |
77
+ | `GET` | `/info?name=` |
78
+ | `GET` | `/installed` |
79
+ | `GET` | `/security` |
80
+ | `POST` | `/security` with `{ "installApproval": "always" | "never" }` |
81
+ | `GET` | `/updates` |
82
+ | `GET` | `/catalog` |
83
+ | `POST` | `/install` with `{ "source": "..." }` |
84
+ | `POST` | `/remove` with `{ "name": "..." }` |
85
+
86
+ State defaults to `~/.cache/pi-packed/` and contains `token`, `port`,
87
+ `updates.json`, `security.json`, and `packed.db`. Relevant environment variables:
88
+
89
+ - `PI_PACKED_HOME`
90
+ - `PI_PACKED_PI_HOME`
91
+ - `PI_PACKED_WATCH_SECS`
92
+ - `PI_PACKED_CATALOG_SECS`
93
+ - `PI_PACKED_IDLE_SECS`
94
+ - `PI_PACKED_PI_BIN` / `PI_BIN`
95
+
96
+ ## Architecture and safety
97
+
98
+ - **Daemon-owned SQLite:** extensions never open the mirror directly.
99
+ - **Runtime boundary:** extensions never call `Bun.spawn`; only the supervised
100
+ Bun daemon owns the `ExecInstaller` adapter.
101
+ - **Authenticated typed client:** extension and mutation CLI paths call the same
102
+ loopback API and reconnect after daemon restarts.
103
+ - **Ports and adapters:** registry and installer ports keep policy independent
104
+ from npm, SQLite, subprocess, HTTP, and UI adapters.
105
+ - **Allowlisted mutation input:** package sources and names reject shell
106
+ metacharacters before reaching the installer.
107
+ - **Bounded requests:** daemon calls use timeouts and return structured errors
108
+ without tokens or credentials.
109
+
110
+ ## Development
111
+
112
+ ```bash
113
+ bun test
114
+ bunx tsc --noEmit
115
+ ```
@@ -13,13 +13,21 @@ import { registerTools } from "./tools.js";
13
13
  import { showPackages } from "./tui.js";
14
14
  import { createNatives } from "./packed.js";
15
15
  import { formatUpdateNotice } from "./model.js";
16
+ import { showPackedSettings } from "./security-tui.js";
16
17
 
17
- // Async factory (pi awaits it): dynamic imports load the service library
18
- // in-process the native pattern, no subprocess.
18
+ // Async factory (pi awaits it): the seam creates authenticated daemon
19
+ // clients lazily. It never executes Bun-only adapters or opens SQLite.
19
20
  export default async function (pi: ExtensionAPI) {
20
21
  const natives = await createNatives();
21
22
  registerTools(pi, natives);
22
23
 
24
+ pi.registerCommand("packed", {
25
+ description: "Configure pi-packed security settings",
26
+ handler: async (_args, ctx) => {
27
+ await showPackedSettings(ctx, natives);
28
+ },
29
+ });
30
+
23
31
  pi.registerCommand("packages", {
24
32
  description: "Browse and manage installed Pi packages (pi-packed)",
25
33
  handler: async (_args, ctx) => {
@@ -1,17 +1,19 @@
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";
11
+ import type { InstallApproval, SecuritySettings } from "../../src/security.ts";
12
12
 
13
13
  export type { InstalledPkg, UpdateEntry };
14
14
  export type PackageInfo = PkgInfo;
15
+ export type PackageDaemonPort = ClientPackageDaemonPort;
16
+
15
17
  export interface SearchResponse {
16
18
  query: string;
17
19
  total: number;
@@ -24,56 +26,44 @@ export interface Natives {
24
26
  info(name: string): Promise<PackageInfo>;
25
27
  installed(): Promise<InstalledPkg[]>;
26
28
  updates(): Promise<UpdateEntry[]>;
29
+ security(): Promise<SecuritySettings>;
30
+ setInstallApproval(value: InstallApproval): Promise<SecuritySettings>;
27
31
  install(source: string): Promise<string>;
28
32
  remove(name: string): Promise<string>;
29
33
  }
30
34
 
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"),
35
+ export type PackageDaemonConnector = () => Promise<PackageDaemonPort>;
36
+
37
+ async function connectDefaultDaemon(): Promise<PackageDaemonPort> {
38
+ const [client, state] = await Promise.all([
39
+ import("../../src/client.ts"),
38
40
  import("../../src/state.ts"),
39
- import("../../src/ports.ts"),
40
41
  ]);
42
+ return client.connectPackageDaemon(state.stateDir());
43
+ }
41
44
 
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();
45
+ export async function createNatives(connect: PackageDaemonConnector = connectDefaultDaemon): Promise<Natives> {
46
+ async function call<T>(operation: (daemon: PackageDaemonPort) => Promise<T>): Promise<T> {
47
+ let lastError: unknown;
48
+ for (let attempt = 0; attempt < 2; attempt += 1) {
49
+ try {
50
+ return await operation(await connect());
51
+ } catch (error) {
52
+ lastError = error;
53
+ }
53
54
  }
55
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
54
56
  }
55
57
 
56
58
  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}`),
59
+ search: (query, limit) => call((daemon) => daemon.search(query, limit)),
60
+ searchOffline: (query, limit) => call((daemon) => daemon.search(query, limit, true)),
61
+ info: (name) => call((daemon) => daemon.info(name)),
62
+ installed: () => call((daemon) => daemon.installed()),
63
+ updates: () => call((daemon) => daemon.updates()),
64
+ security: () => call((daemon) => daemon.security()),
65
+ setInstallApproval: (value) => call((daemon) => daemon.setInstallApproval(value)),
66
+ install: (source) => call((daemon) => daemon.install(source)),
67
+ remove: (name) => call((daemon) => daemon.remove(name)),
78
68
  };
79
69
  }
@@ -0,0 +1,33 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { InstallApproval } from "../../src/security.ts";
3
+ import type { Natives } from "./packed.ts";
4
+
5
+ const OPTIONS: Array<{ value: InstallApproval; label: string }> = [
6
+ { value: "always", label: "Always require approval (recommended)" },
7
+ { value: "never", label: "Never require approval (unsafe opt-out)" },
8
+ ];
9
+
10
+ export async function showPackedSettings(ctx: ExtensionCommandContext, natives: Natives): Promise<void> {
11
+ if (!ctx.hasUI) {
12
+ ctx.ui.notify("/packed requires interactive mode", "warning");
13
+ return;
14
+ }
15
+ try {
16
+ const current = await natives.security();
17
+ const choice = await ctx.ui.select(
18
+ `Package install approval · current: ${current.installApproval}`,
19
+ [...OPTIONS.map(({ label }) => label), "Cancel"],
20
+ );
21
+ const selected = OPTIONS.find(({ label }) => label === choice);
22
+ if (!selected || selected.value === current.installApproval) return;
23
+ const updated = await natives.setInstallApproval(selected.value);
24
+ ctx.ui.notify(
25
+ updated.installApproval === "always"
26
+ ? "Package installs now require confirmation."
27
+ : "Package install confirmation disabled. Packages can execute arbitrary code.",
28
+ updated.installApproval === "always" ? "info" : "warning",
29
+ );
30
+ } catch (error) {
31
+ ctx.ui.notify(`packed security settings failed: ${error instanceof Error ? error.message : error}`, "error");
32
+ }
33
+ }
@@ -10,6 +10,28 @@ function text(t: string, details: Record<string, unknown> = {}) {
10
10
  return { content: [{ type: "text" as const, text: t }], details };
11
11
  }
12
12
 
13
+ export async function installPackageWithPolicy(
14
+ source: string,
15
+ natives: Pick<Natives, "security" | "install">,
16
+ ctx: { hasUI: boolean; ui: { confirm(title: string, message: string): Promise<boolean> } },
17
+ ) {
18
+ try {
19
+ const { installApproval } = await natives.security();
20
+ if (installApproval === "always") {
21
+ if (!ctx.hasUI) return text("pkg_install requires interactive approval; change installApproval in /packed to opt out.");
22
+ const ok = await ctx.ui.confirm(
23
+ "Install Pi package",
24
+ `Run: pi install ${source}\n\nPackages execute arbitrary code. Continue?`,
25
+ );
26
+ if (!ok) return text("Install cancelled by user.");
27
+ }
28
+ const out = await natives.install(source);
29
+ return text(out || `Installed ${source}. Reload with /reload to activate.`);
30
+ } catch (error) {
31
+ return text(`install failed: ${error instanceof Error ? error.message : error}`);
32
+ }
33
+ }
34
+
13
35
  export function registerTools(pi: ExtensionAPI, natives: Natives): void {
14
36
  pi.registerTool({
15
37
  name: "pkg_search",
@@ -71,25 +93,12 @@ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
71
93
  label: "Pi Package Install",
72
94
  description:
73
95
  "Install a Pi package (pi install). Supports npm:<pkg>[@ver], git:<host>/<owner>/<repo>[@ref], " +
74
- "or https:// URLs. Packages execute arbitrary code the user confirms every install.",
96
+ "or https:// URLs. Packages execute arbitrary code. Approval follows the installApproval policy configured in /packed (secure default: always).",
75
97
  parameters: Type.Object({
76
98
  source: Type.String({ description: "e.g. 'npm:pi-lsp', 'npm:@scope/pkg@1.2.3', 'git:github.com/u/r@v1'" }),
77
99
  }),
78
100
  async execute(_id, params, _signal, _onUpdate, ctx) {
79
- if (!ctx.hasUI) {
80
- return text("pkg_install requires an interactive session (the user must confirm).");
81
- }
82
- const ok = await ctx.ui.confirm(
83
- "Install Pi package",
84
- `Run: pi install ${params.source}\n\nPackages execute arbitrary code. Continue?`,
85
- );
86
- if (!ok) return text("Install cancelled by user.");
87
- try {
88
- const out = await natives.install(params.source);
89
- return text(out || `Installed ${params.source}. Reload with /reload to activate.`);
90
- } catch (e) {
91
- return text(`install failed: ${e instanceof Error ? e.message : e}`);
92
- }
101
+ return installPackageWithPolicy(params.source, natives, ctx);
93
102
  },
94
103
  });
95
104
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-packed",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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,13 @@ 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
+ import type { InstallApproval, SecuritySettingsPort } from "./security.ts";
15
+
16
+ function defaultPiBinForUnit(): string | undefined {
17
+ const b = defaultPiBin();
18
+ return b === "pi" ? undefined : b; // bare name needs no pin
19
+ }
14
20
  import {
15
21
  VERSION, SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT, NPM_REGISTRY_BASE, SEARCH_PAGE_SIZE, MIRROR_PAGE_DELAY_MS,
16
22
  } from "./constants.ts";
@@ -26,8 +32,9 @@ usage:
26
32
  packed mirror [--json] sync upstream into the local SQLite index
27
33
  packed installed [--json] installed pi packages
28
34
  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
35
+ packed install <source> [--json] pi install npm:|git:|https://… via daemon
36
+ packed remove <name> [--json] remove by bare npm name via daemon
37
+ packed security [always|never] [--json] read or set install approval policy
31
38
  packed serve run the long-running daemon
32
39
  packed service print a systemd user unit
33
40
  packed version print version
@@ -36,10 +43,12 @@ usage:
36
43
  export interface CliDeps {
37
44
  reg: Registry;
38
45
  inst: Installer;
46
+ security: SecuritySettingsPort;
39
47
  stateDir: string;
40
48
  piHome: string;
41
49
  execPath?: string; // bun binary (defaults to process.execPath)
42
50
  cliPath?: string; // this CLI's entry file (for the systemd unit)
51
+ piBin?: string; // pi binary path to pin into the unit's Environment
43
52
  }
44
53
 
45
54
  export interface CliResult {
@@ -179,27 +188,47 @@ const commands: Record<string, { usage: string; run: Command }> = {
179
188
  },
180
189
 
181
190
  install: {
182
- usage: "packed install npm:<pkg>[@ver] | git:<host>/<owner>/<repo>[@ref] | https://…",
183
- async run(_rest, d, _flags, pos) {
191
+ usage: "packed install npm:<pkg>[@ver] | git:<host>/<owner>/<repo>[@ref] | https://… [--json]",
192
+ async run(_rest, d, flags, pos) {
184
193
  const source = pos[0] ?? "";
185
194
  if (!SOURCE_RE.test(source)) return usageErr(`usage: ${commands["install"]!.usage}\n`);
186
195
  try {
187
- return ok((await d.inst.install(source)) + "\n");
196
+ const output = await d.inst.install(source);
197
+ return flags.json ? ok(`${JSON.stringify({ ok: true, source, output })}\n`) : ok(`${output}\n`);
188
198
  } catch (e) {
189
- return fail(`${e instanceof Error ? e.message : e}\n`);
199
+ const error = e instanceof Error ? e.message : String(e);
200
+ return flags.json ? fail(`${JSON.stringify({ ok: false, source, error })}\n`) : fail(`${error}\n`);
190
201
  }
191
202
  },
192
203
  },
193
204
 
205
+ security: {
206
+ usage: "packed security [always|never] [--json]",
207
+ async run(_rest, d, flags, pos) {
208
+ const requested = pos[0];
209
+ if (requested !== undefined && requested !== "always" && requested !== "never") {
210
+ return usageErr(`usage: ${commands["security"]!.usage}\n`);
211
+ }
212
+ const settings = requested
213
+ ? await d.security.setInstallApproval(requested as InstallApproval)
214
+ : await d.security.security();
215
+ return flags.json
216
+ ? ok(`${JSON.stringify(settings)}\n`)
217
+ : ok(`install approval: ${settings.installApproval}\n`);
218
+ },
219
+ },
220
+
194
221
  remove: {
195
- usage: "packed remove <name> (bare npm name, e.g. pi-lsp or @scope/pkg)",
196
- async run(_rest, d, _flags, pos) {
222
+ usage: "packed remove <name> [--json] (bare npm name, e.g. pi-lsp or @scope/pkg)",
223
+ async run(_rest, d, flags, pos) {
197
224
  const name = pos[0] ?? "";
198
225
  if (!NAME_RE.test(name)) return usageErr(`usage: ${commands["remove"]!.usage}\n`);
199
226
  try {
200
- return ok((await d.inst.remove(`npm:${name}`)) + "\n");
227
+ const output = await d.inst.remove(`npm:${name}`);
228
+ return flags.json ? ok(`${JSON.stringify({ ok: true, name, output })}\n`) : ok(`${output}\n`);
201
229
  } catch (e) {
202
- return fail(`${e instanceof Error ? e.message : e}\n`);
230
+ const error = e instanceof Error ? e.message : String(e);
231
+ return flags.json ? fail(`${JSON.stringify({ ok: false, name, error })}\n`) : fail(`${error}\n`);
203
232
  }
204
233
  },
205
234
  },
@@ -209,7 +238,7 @@ const commands: Record<string, { usage: string; run: Command }> = {
209
238
  async run(_rest, d) {
210
239
  const execPath = d.execPath ?? process.execPath;
211
240
  const cliPath = d.cliPath ?? new URL("./cli.ts", import.meta.url).pathname;
212
- return ok(renderUnit(execPath, cliPath));
241
+ return ok(renderUnit(execPath, cliPath, d.piBin ?? defaultPiBinForUnit()));
213
242
  },
214
243
  },
215
244
 
@@ -223,7 +252,10 @@ const commands: Record<string, { usage: string; run: Command }> = {
223
252
 
224
253
  /** systemd user unit. Idle self-exit is disabled — systemd owns the
225
254
  * lifecycle (Restart=on-failure takes over). */
226
- export function renderUnit(execPath: string, cliPath: string): string {
255
+ export function renderUnit(execPath: string, cliPath: string, piBin?: string): string {
256
+ // systemd does not read shell rc files: PI_BIN must be explicit so the
257
+ // daemon's install/remove execs can find the pi binary.
258
+ const piEnv = piBin ? `Environment=PI_BIN=${piBin}\n` : "";
227
259
  return `[Unit]
228
260
  Description=pi-packed package service (Pi agent)
229
261
 
@@ -233,7 +265,7 @@ ExecStart=${execPath} ${cliPath} serve
233
265
  Restart=on-failure
234
266
  RestartSec=2
235
267
  Environment=PI_PACKED_IDLE_SECS=0
236
- NoNewPrivileges=true
268
+ ${piEnv}NoNewPrivileges=true
237
269
 
238
270
  [Install]
239
271
  WantedBy=default.target
@@ -264,8 +296,7 @@ if (import.meta.main) {
264
296
  } else {
265
297
  const { stateDir } = await import("./state.ts");
266
298
  const { defaultPiHome } = await import("./installed.ts");
267
- const { resolveRegistry } = await import("./client.ts");
268
- const { ExecInstaller } = await import("./install.ts");
299
+ const { DaemonBackedSecurity, DaemonBackedInstaller, resolveRegistry } = await import("./client.ts");
269
300
  const dir = stateDir();
270
301
  // mirror talks to UPSTREAM, not the daemon cache — apt update semantics.
271
302
  const reg =
@@ -274,7 +305,8 @@ if (import.meta.main) {
274
305
  : await resolveRegistry(dir, NPM_REGISTRY_BASE);
275
306
  const { code, out } = await cliRun(args, {
276
307
  reg,
277
- inst: new ExecInstaller(),
308
+ inst: new DaemonBackedInstaller(dir),
309
+ security: new DaemonBackedSecurity(dir),
278
310
  stateDir: dir,
279
311
  piHome: defaultPiHome(),
280
312
  });
package/src/client.ts CHANGED
@@ -1,49 +1,179 @@
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
+ import type { InstallApproval, SecuritySettings } from "./security.ts";
11
12
 
12
- export class DaemonRegistry implements Registry {
13
+ export type FetchTransport = (request: Request) => Promise<Response>;
14
+
15
+ export interface PackageDaemonPort {
16
+ search(query: string, limit: number, offline?: boolean): Promise<{ query: string; total: number; results: SearchPage["results"] }>;
17
+ info(name: string): Promise<PkgInfo>;
18
+ installed(): Promise<InstalledPkg[]>;
19
+ updates(): Promise<UpdateEntry[]>;
20
+ security(): Promise<SecuritySettings>;
21
+ setInstallApproval(value: InstallApproval): Promise<SecuritySettings>;
22
+ install(source: string): Promise<string>;
23
+ remove(name: string): Promise<string>;
24
+ }
25
+
26
+ interface MutationResponse {
27
+ ok: boolean;
28
+ output: string;
29
+ }
30
+
31
+ export class PackageDaemonError extends Error {
13
32
  constructor(
14
- private base: string,
15
- private token: string,
33
+ message: string,
34
+ readonly operation: string,
35
+ readonly status?: number,
36
+ ) {
37
+ super(message);
38
+ this.name = "PackageDaemonError";
39
+ }
40
+ }
41
+
42
+ export class PackageDaemonClient implements PackageDaemonPort {
43
+ constructor(
44
+ private readonly base: string,
45
+ private readonly token: string,
46
+ private readonly transport: FetchTransport = fetch,
16
47
  ) {}
17
48
 
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),
49
+ private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
50
+ const headers = new Headers(init.headers);
51
+ headers.set("authorization", `Bearer ${this.token}`);
52
+ if (init.body !== undefined) headers.set("content-type", "application/json");
53
+ const response = await this.transport(new Request(`${this.base}${path}`, {
54
+ ...init,
55
+ headers,
56
+ signal: init.signal ?? AbortSignal.timeout(REGISTRY_FETCH_TIMEOUT_MS),
57
+ }));
58
+ let body: unknown;
59
+ try {
60
+ body = await response.json();
61
+ } catch {
62
+ throw new PackageDaemonError(`package daemon returned invalid JSON (HTTP ${response.status})`, path, response.status);
63
+ }
64
+ if (!response.ok) {
65
+ const message = typeof body === "object" && body !== null && typeof (body as { error?: unknown }).error === "string"
66
+ ? (body as { error: string }).error
67
+ : `package daemon HTTP ${response.status}`;
68
+ throw new PackageDaemonError(message, path, response.status);
69
+ }
70
+ return body as T;
71
+ }
72
+
73
+ async search(query: string, limit: number, offline = false): Promise<{ query: string; total: number; results: SearchPage["results"] }> {
74
+ const params = new URLSearchParams({ q: query, limit: String(limit) });
75
+ if (offline) params.set("offline", "1");
76
+ return this.request(`/search?${params}`);
77
+ }
78
+
79
+ info(name: string): Promise<PkgInfo> {
80
+ return this.request(`/info?name=${encodeURIComponent(name)}`);
81
+ }
82
+
83
+ installed(): Promise<InstalledPkg[]> {
84
+ return this.request("/installed");
85
+ }
86
+
87
+ async updates(): Promise<UpdateEntry[]> {
88
+ return (await this.request<UpdatesSnapshot>("/updates")).updates;
89
+ }
90
+
91
+ security(): Promise<SecuritySettings> {
92
+ return this.request("/security");
93
+ }
94
+
95
+ setInstallApproval(installApproval: InstallApproval): Promise<SecuritySettings> {
96
+ return this.request("/security", { method: "POST", body: JSON.stringify({ installApproval }) });
97
+ }
98
+
99
+ async install(source: string): Promise<string> {
100
+ const result = await this.request<MutationResponse>("/install", {
101
+ method: "POST",
102
+ body: JSON.stringify({ source }),
22
103
  });
23
- if (!res.ok) throw new Error(`daemon HTTP ${res.status}`);
24
- return (await res.json()) as T;
104
+ if (!result.ok) throw new PackageDaemonError(result.output || `failed to install ${source}`, "install");
105
+ return result.output;
106
+ }
107
+
108
+ async remove(name: string): Promise<string> {
109
+ const result = await this.request<MutationResponse>("/remove", {
110
+ method: "POST",
111
+ body: JSON.stringify({ name }),
112
+ });
113
+ if (!result.ok) throw new PackageDaemonError(result.output || `failed to remove ${name}`, "remove");
114
+ return result.output;
115
+ }
116
+ }
117
+
118
+ export class PackageDaemonInstaller implements Installer {
119
+ constructor(private readonly client: PackageDaemonClient) {}
120
+
121
+ install(source: string): Promise<string> {
122
+ return this.client.install(source);
123
+ }
124
+
125
+ remove(source: string): Promise<string> {
126
+ if (!source.startsWith("npm:") || source.length <= 4) {
127
+ throw new PackageDaemonError("daemon package removal requires an npm: source", "remove");
128
+ }
129
+ return this.client.remove(source.slice(4));
130
+ }
131
+ }
132
+
133
+ export class DaemonBackedSecurity {
134
+ constructor(private readonly stateDirectory: string) {}
135
+ async security(): Promise<SecuritySettings> {
136
+ return (await connectPackageDaemon(this.stateDirectory)).security();
137
+ }
138
+ async setInstallApproval(value: InstallApproval): Promise<SecuritySettings> {
139
+ return (await connectPackageDaemon(this.stateDirectory)).setInstallApproval(value);
140
+ }
141
+ }
142
+
143
+ export class DaemonBackedInstaller implements Installer {
144
+ constructor(private readonly stateDirectory: string) {}
145
+
146
+ async install(source: string): Promise<string> {
147
+ return (await connectPackageDaemon(this.stateDirectory)).install(source);
148
+ }
149
+
150
+ async remove(source: string): Promise<string> {
151
+ return new PackageDaemonInstaller(await connectPackageDaemon(this.stateDirectory)).remove(source);
152
+ }
153
+ }
154
+
155
+ export class DaemonRegistry implements Registry {
156
+ private readonly client: PackageDaemonClient;
157
+
158
+ constructor(base: string, token: string, transport: FetchTransport = fetch) {
159
+ this.client = new PackageDaemonClient(base, token, transport);
25
160
  }
26
161
 
27
162
  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
- );
163
+ const body = await this.client.search(query, limit);
31
164
  return { results: body.results, total: body.total };
32
165
  }
33
166
 
34
- async searchPage(query: string, from: number, size: number): Promise<SearchPage> {
35
- // The daemon clamps to 50; page through it for bulk reads.
167
+ async searchPage(query: string, _from: number, size: number): Promise<SearchPage> {
36
168
  return this.search(query, size).catch(() => ({ results: [], total: 0 }));
37
169
  }
38
170
 
39
171
  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
172
  throw new Error("searchAll is not supported via the daemon proxy");
43
173
  }
44
174
 
45
- async info(name: string): Promise<PkgInfo> {
46
- return this.get<PkgInfo>(`/info?name=${encodeURIComponent(name)}`);
175
+ info(name: string): Promise<PkgInfo> {
176
+ return this.client.info(name);
47
177
  }
48
178
  }
49
179
 
@@ -74,6 +204,12 @@ export async function probe(dir: string): Promise<DaemonHandle | undefined> {
74
204
  return undefined;
75
205
  }
76
206
 
207
+ export async function connectPackageDaemon(dir: string): Promise<PackageDaemonClient> {
208
+ const handle = await probe(dir);
209
+ if (!handle) throw new Error("pi-packed daemon is unavailable; start packed.service");
210
+ return new PackageDaemonClient(handle.base, handle.token);
211
+ }
212
+
77
213
  export async function resolveRegistry(dir: string, npmBase: string): Promise<Registry> {
78
214
  const handle = await probe(dir);
79
215
  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.3.0";
35
35
 
36
36
  // --- State-dir file names ---
37
37
  export const TOKEN_FILE = "token";
@@ -39,6 +39,7 @@ export const PORT_FILE = "port";
39
39
  export const UPDATES_FILE = "updates.json";
40
40
  export const DB_FILE = "packed.db";
41
41
  export const SETTINGS_FILE = "settings.json";
42
+ export const SECURITY_FILE = "security.json";
42
43
 
43
44
  // --- Environment knobs ---
44
45
  export const ENV = {
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" });
@@ -0,0 +1,34 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { SECURITY_FILE } from "./constants.ts";
4
+
5
+ export const INSTALL_APPROVAL_VALUES = ["always", "never"] as const;
6
+ export type InstallApproval = typeof INSTALL_APPROVAL_VALUES[number];
7
+ export interface SecuritySettings { installApproval: InstallApproval }
8
+ export interface SecuritySettingsPort {
9
+ security(): Promise<SecuritySettings>;
10
+ setInstallApproval(value: InstallApproval): Promise<SecuritySettings>;
11
+ }
12
+
13
+ export const DEFAULT_SECURITY_SETTINGS: SecuritySettings = { installApproval: "always" };
14
+
15
+ export function readSecuritySettings(stateDir: string): SecuritySettings {
16
+ try {
17
+ const value = JSON.parse(readFileSync(join(stateDir, SECURITY_FILE), "utf8")) as { installApproval?: unknown };
18
+ return INSTALL_APPROVAL_VALUES.includes(value.installApproval as InstallApproval)
19
+ ? { installApproval: value.installApproval as InstallApproval }
20
+ : { ...DEFAULT_SECURITY_SETTINGS };
21
+ } catch {
22
+ return { ...DEFAULT_SECURITY_SETTINGS };
23
+ }
24
+ }
25
+
26
+ export function writeSecuritySettings(stateDir: string, settings: SecuritySettings): SecuritySettings {
27
+ if (!INSTALL_APPROVAL_VALUES.includes(settings.installApproval)) throw new Error("installApproval must be always or never");
28
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
29
+ const target = join(stateDir, SECURITY_FILE);
30
+ const temporary = `${target}.tmp`;
31
+ writeFileSync(temporary, `${JSON.stringify(settings)}\n`, { mode: 0o600 });
32
+ renameSync(temporary, target);
33
+ return { ...settings };
34
+ }
package/src/service.ts CHANGED
@@ -11,6 +11,7 @@ import { readInstalledPackages, defaultPiHome } from "./installed.ts";
11
11
  import { openDb, searchLocal, catalogList, getSyncMeta, dbPath } from "./db.ts";
12
12
  import { VERSION, SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT } from "./constants.ts";
13
13
  import { createLogger } from "./log.ts";
14
+ import { readSecuritySettings, writeSecuritySettings, type InstallApproval } from "./security.ts";
14
15
 
15
16
  const log = createLogger("service");
16
17
 
@@ -45,6 +46,23 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
45
46
  return json({ ok: true, version: VERSION });
46
47
  }
47
48
 
49
+ if (path === "/security" && req.method === "GET") {
50
+ return json(readSecuritySettings(deps.stateDir));
51
+ }
52
+
53
+ if (path === "/security" && req.method === "POST") {
54
+ let installApproval: unknown;
55
+ try {
56
+ installApproval = ((await req.json()) as { installApproval?: unknown }).installApproval;
57
+ } catch {
58
+ return err(400, "invalid security settings JSON");
59
+ }
60
+ if (installApproval !== "always" && installApproval !== "never") {
61
+ return err(400, "installApproval must be always or never");
62
+ }
63
+ return json(writeSecuritySettings(deps.stateDir, { installApproval: installApproval as InstallApproval }));
64
+ }
65
+
48
66
  if (path === "/search" && req.method === "GET") {
49
67
  const q = url.searchParams.get("q") ?? "";
50
68
  const limit = clampLimit(Number(url.searchParams.get("limit")), SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT);
@@ -143,7 +161,7 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
143
161
  return err(401, "missing or invalid bearer token");
144
162
  }
145
163
  // Cache successful GETs by URI (smart-proxy concern).
146
- if (req.method === "GET" && !["/health", "/updates", "/catalog"].includes(new URL(req.url).pathname)) {
164
+ if (req.method === "GET" && !["/health", "/updates", "/catalog", "/security"].includes(new URL(req.url).pathname)) {
147
165
  const hit = cache.get(req.url);
148
166
  if (hit) {
149
167
  log.debug("request", { path: new URL(req.url).pathname, cache: "hit", ms: Date.now() - t0 });