@danypops/pi-packed 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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): dynamic imports load the service library
18
+ // in-process — the native pattern, no subprocess.
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,79 @@
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 — 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).
5
+ *
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.
5
9
  */
6
- import { execFile } from "node:child_process";
7
-
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
- }
10
+ import type { Db } from "../../src/db.ts";
11
+ import type { InstalledPkg, Pkg, PkgInfo, UpdateEntry } from "../../src/ports.ts";
32
12
 
13
+ export type { InstalledPkg, UpdateEntry };
14
+ export type PackageInfo = PkgInfo;
33
15
  export interface SearchResponse {
34
16
  query: string;
35
17
  total: number;
36
- results: SearchResult[];
37
- }
38
-
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;
18
+ results: Pkg[];
50
19
  }
51
20
 
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;
21
+ export interface Natives {
22
+ search(query: string, limit: number): Promise<SearchResponse>;
23
+ searchOffline(query: string, limit: number): Promise<SearchResponse>;
24
+ info(name: string): Promise<PackageInfo>;
25
+ installed(): Promise<InstalledPkg[]>;
26
+ updates(): Promise<UpdateEntry[]>;
27
+ install(source: string): Promise<string>;
28
+ remove(name: string): Promise<string>;
56
29
  }
57
30
 
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()] };
61
- }
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"),
38
+ import("../../src/state.ts"),
39
+ import("../../src/ports.ts"),
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;
69
- }
70
- resolve({ stdout, stderr });
71
- });
72
- });
73
- }
42
+ const reg = new regMod.HttpRegistry();
43
+ const inst = new execMod.ExecInstaller();
74
44
 
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)}`);
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();
53
+ }
82
54
  }
83
- }
84
55
 
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");
56
+ 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}`),
78
+ };
90
79
  }
@@ -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.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": [
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/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 {