@danypops/pi-packed 0.3.0 → 0.5.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
@@ -10,9 +10,9 @@ inside the supervised Bun daemon.
10
10
 
11
11
  ```text
12
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
13
+ │ pkg_search · pkg_info · pkg_install/update/remove
14
+ │ /packages · /packed permission settings
15
+ operation-aware approval · no Bun/SQLite access
16
16
  └──────────────────┬────────────────────────────┘
17
17
  │ authenticated loopback HTTP
18
18
  ┌─ packed.service (Bun) ────────────────────────┐
@@ -37,7 +37,7 @@ packed installed --json
37
37
  /packages
38
38
  ```
39
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.
40
+ Packages execute arbitrary code and mutate Pi settings/install roots. One daemon-owned operation policy classifies every public package operation. Install, update, remove, and security-setting changes require explicit approval by default (`mutationApproval: always`); search, info, installed, catalog, and update-status reads are bounded reads, while mirror refresh is classified maintenance. Open `/packed` to retain the recommended approval policy or deliberately choose the unsafe **Never require mutation approval** opt-out. `/packages` uses the same policy for updates and removals.
41
41
 
42
42
  ## CLI
43
43
 
@@ -46,17 +46,18 @@ Packages execute arbitrary code. `pkg_install` uses daemon-owned security settin
46
46
  | `packed search <q> [--offline] [--limit N] [--json]` | Search npm or the local mirror, scoped to `keywords:pi-package` |
47
47
  | `packed info <name> [--json]` | Show version, repository, Pi manifest, size, and license |
48
48
  | `packed updates [--json]` | Show drift from the local mirror |
49
+ | `packed update <source> [--approve] [--json]` | Update one configured source through `pi update --extension` |
49
50
  | `packed mirror [--json]` | Refresh the SQLite package index |
50
51
  | `packed installed [--json]` | Read Pi's installed package declarations |
51
52
  | `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 |
53
+ | `packed install <source> [--approve] [--json]` | Authenticated daemon install for `npm:`, `git:`, or `https://` sources |
54
+ | `packed remove <name> [--approve] [--json]` | Authenticated daemon removal by bare npm name |
55
+ | `packed security [always\|never] [--approve] [--json]` | Read or set the package mutation approval policy |
55
56
  | `packed serve` | Run the loopback daemon |
56
57
  | `packed service` | Print the systemd user unit |
57
58
  | `packed version` | Print the package/service version |
58
59
 
59
- Install/remove JSON results are stable objects:
60
+ Guarded CLI mutations require `--approve` under the secure default. This is pi-packed mutation authorization, distinct from Pi's project-trust `--approve` semantics. Install/remove JSON results are stable objects:
60
61
 
61
62
  ```json
62
63
  {"ok":true,"source":"npm:pi-lsp","output":"Installed npm:pi-lsp"}
@@ -77,11 +78,12 @@ The daemon listens on loopback only.
77
78
  | `GET` | `/info?name=` |
78
79
  | `GET` | `/installed` |
79
80
  | `GET` | `/security` |
80
- | `POST` | `/security` with `{ "installApproval": "always" | "never" }` |
81
+ | `POST` | `/security` with `{ "mutationApproval": "always" | "never", "approved": true }` |
81
82
  | `GET` | `/updates` |
82
83
  | `GET` | `/catalog` |
83
- | `POST` | `/install` with `{ "source": "..." }` |
84
- | `POST` | `/remove` with `{ "name": "..." }` |
84
+ | `POST` | `/install` with `{ "source": "...", "approved": true }` |
85
+ | `POST` | `/update` with `{ "source": "...", "approved": true }` |
86
+ | `POST` | `/remove` with `{ "name": "...", "approved": true }` |
85
87
 
86
88
  State defaults to `~/.cache/pi-packed/` and contains `token`, `port`,
87
89
  `updates.json`, `security.json`, and `packed.db`. Relevant environment variables:
@@ -102,6 +104,7 @@ State defaults to `~/.cache/pi-packed/` and contains `token`, `port`,
102
104
  loopback API and reconnect after daemon restarts.
103
105
  - **Ports and adapters:** registry and installer ports keep policy independent
104
106
  from npm, SQLite, subprocess, HTTP, and UI adapters.
107
+ - **Operation-aware authorization:** one policy matrix classifies reads, maintenance, code execution, settings mutation, and security mutation; guarded daemon routes reject missing approval with stable `approval_required` errors.
105
108
  - **Allowlisted mutation input:** package sources and names reject shell
106
109
  metacharacters before reaching the installer.
107
110
  - **Bounded requests:** daemon calls use timeouts and return structured errors
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * pi-packed — Pi extension seam.
3
3
  *
4
- * Thin by design: registers agent tools (pkg_search/pkg_info/pkg_install),
4
+ * Thin by design: registers agent tools (pkg_search/pkg_info/pkg_install/pkg_update/pkg_remove),
5
5
  * the /packages command, and a session_start update notification. ALL logic
6
6
  * lives in the Bun service (src/): registry access, caching, watcher,
7
7
  * catalog sync, install execution.
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import type { PackageDaemonPort as ClientPackageDaemonPort } from "../../src/client.ts";
10
10
  import type { InstalledPkg, Pkg, PkgInfo, UpdateEntry } from "../../src/ports.ts";
11
- import type { InstallApproval, SecuritySettings } from "../../src/security.ts";
11
+ import type { MutationApproval, SecuritySettings } from "../../src/security.ts";
12
12
 
13
13
  export type { InstalledPkg, UpdateEntry };
14
14
  export type PackageInfo = PkgInfo;
@@ -27,9 +27,10 @@ export interface Natives {
27
27
  installed(): Promise<InstalledPkg[]>;
28
28
  updates(): Promise<UpdateEntry[]>;
29
29
  security(): Promise<SecuritySettings>;
30
- setInstallApproval(value: InstallApproval): Promise<SecuritySettings>;
31
- install(source: string): Promise<string>;
32
- remove(name: string): Promise<string>;
30
+ setMutationApproval(value: MutationApproval, approved?: boolean): Promise<SecuritySettings>;
31
+ install(source: string, approved?: boolean): Promise<string>;
32
+ remove(name: string, approved?: boolean): Promise<string>;
33
+ update(source: string, approved?: boolean): Promise<string>;
33
34
  }
34
35
 
35
36
  export type PackageDaemonConnector = () => Promise<PackageDaemonPort>;
@@ -62,8 +63,9 @@ export async function createNatives(connect: PackageDaemonConnector = connectDef
62
63
  installed: () => call((daemon) => daemon.installed()),
63
64
  updates: () => call((daemon) => daemon.updates()),
64
65
  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)),
66
+ setMutationApproval: (value, approved) => call((daemon) => daemon.setMutationApproval(value, approved)),
67
+ install: (source, approved) => call((daemon) => daemon.install(source, approved)),
68
+ remove: (name, approved) => call((daemon) => daemon.remove(name, approved)),
69
+ update: (source, approved) => call((daemon) => daemon.update(source, approved)),
68
70
  };
69
71
  }
@@ -1,10 +1,10 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import type { InstallApproval } from "../../src/security.ts";
2
+ import type { MutationApproval } from "../../src/security.ts";
3
3
  import type { Natives } from "./packed.ts";
4
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)" },
5
+ const OPTIONS: Array<{ value: MutationApproval; label: string }> = [
6
+ { value: "always", label: "Always require mutation approval (recommended)" },
7
+ { value: "never", label: "Never require mutation approval (unsafe opt-out)" },
8
8
  ];
9
9
 
10
10
  export async function showPackedSettings(ctx: ExtensionCommandContext, natives: Natives): Promise<void> {
@@ -15,17 +15,24 @@ export async function showPackedSettings(ctx: ExtensionCommandContext, natives:
15
15
  try {
16
16
  const current = await natives.security();
17
17
  const choice = await ctx.ui.select(
18
- `Package install approval · current: ${current.installApproval}`,
18
+ `Package mutation approval · current: ${current.mutationApproval}`,
19
19
  [...OPTIONS.map(({ label }) => label), "Cancel"],
20
20
  );
21
21
  const selected = OPTIONS.find(({ label }) => label === choice);
22
- if (!selected || selected.value === current.installApproval) return;
23
- const updated = await natives.setInstallApproval(selected.value);
22
+ if (!selected || selected.value === current.mutationApproval) return;
23
+ const approved = await ctx.ui.confirm(
24
+ "Change package mutation approval",
25
+ selected.value === "never"
26
+ ? "Disable confirmation for install, update, remove, and package security changes? Packages can execute arbitrary code."
27
+ : "Restore confirmation for install, update, remove, and package security changes?",
28
+ );
29
+ if (!approved) return;
30
+ const updated = await natives.setMutationApproval(selected.value, true);
24
31
  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",
32
+ updated.mutationApproval === "always"
33
+ ? "Package mutations now require confirmation."
34
+ : "Package mutation confirmation disabled. Packages can execute arbitrary code.",
35
+ updated.mutationApproval === "always" ? "info" : "warning",
29
36
  );
30
37
  } catch (error) {
31
38
  ctx.ui.notify(`packed security settings failed: ${error instanceof Error ? error.message : error}`, "error");
@@ -1,61 +1,103 @@
1
- /**
2
- * tools.ts — agent-facing tools. Thin by design: descriptions teach the
3
- * agent the packed CLI surface; logic lives in the Bun service.
4
- */
1
+ /** Agent-facing package tools over the authenticated daemon. */
5
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
3
  import { Type } from "typebox";
4
+ import { packagePermissionDecision, type PackageOperation } from "../../src/security.ts";
7
5
  import type { Natives } from "./packed.js";
8
6
 
9
7
  function text(t: string, details: Record<string, unknown> = {}) {
10
8
  return { content: [{ type: "text" as const, text: t }], details };
11
9
  }
12
10
 
11
+ type ApprovalContext = {
12
+ hasUI: boolean;
13
+ ui: { confirm(title: string, message: string): Promise<boolean> };
14
+ };
15
+
16
+ export async function approvePackageOperation(
17
+ operation: PackageOperation,
18
+ command: string,
19
+ natives: Pick<Natives, "security">,
20
+ ctx: ApprovalContext,
21
+ ): Promise<{ allowed: boolean; approved: boolean; message?: string }> {
22
+ const settings = await natives.security();
23
+ const decision = packagePermissionDecision(settings, operation);
24
+ if (!decision.approvalRequired) return { allowed: true, approved: false };
25
+ if (!ctx.hasUI) {
26
+ return {
27
+ allowed: false,
28
+ approved: false,
29
+ message: `${operation} requires interactive approval; change mutationApproval in /packed only to deliberately opt out.`,
30
+ };
31
+ }
32
+ const approved = await ctx.ui.confirm(
33
+ `${operation[0]!.toUpperCase()}${operation.slice(1)} Pi package`,
34
+ `Run: ${command}\n\nThis operation can execute package code or mutate Pi settings/install roots. Continue?`,
35
+ );
36
+ return approved ? { allowed: true, approved: true } : { allowed: false, approved: false, message: `${operation} cancelled by user.` };
37
+ }
38
+
13
39
  export async function installPackageWithPolicy(
14
40
  source: string,
15
41
  natives: Pick<Natives, "security" | "install">,
16
- ctx: { hasUI: boolean; ui: { confirm(title: string, message: string): Promise<boolean> } },
42
+ ctx: ApprovalContext,
17
43
  ) {
18
44
  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);
45
+ const approval = await approvePackageOperation("install", `pi install ${source}`, natives, ctx);
46
+ if (!approval.allowed) return text(approval.message ?? "install denied");
47
+ const out = await natives.install(source, approval.approved);
29
48
  return text(out || `Installed ${source}. Reload with /reload to activate.`);
30
49
  } catch (error) {
31
50
  return text(`install failed: ${error instanceof Error ? error.message : error}`);
32
51
  }
33
52
  }
34
53
 
54
+ export async function updatePackageWithPolicy(
55
+ source: string,
56
+ natives: Pick<Natives, "security" | "update">,
57
+ ctx: ApprovalContext,
58
+ ) {
59
+ try {
60
+ const approval = await approvePackageOperation("update", `pi update --extension ${source}`, natives, ctx);
61
+ if (!approval.allowed) return text(approval.message ?? "update denied");
62
+ const out = await natives.update(source, approval.approved);
63
+ return text(`${out || `Updated ${source}.`} Reload with /reload to activate.`);
64
+ } catch (error) {
65
+ return text(`update failed: ${error instanceof Error ? error.message : error}`);
66
+ }
67
+ }
68
+
69
+ export async function removePackageWithPolicy(
70
+ name: string,
71
+ natives: Pick<Natives, "security" | "remove">,
72
+ ctx: ApprovalContext,
73
+ ) {
74
+ try {
75
+ const approval = await approvePackageOperation("remove", `pi remove npm:${name}`, natives, ctx);
76
+ if (!approval.allowed) return text(approval.message ?? "remove denied");
77
+ const out = await natives.remove(name, approval.approved);
78
+ return text(out || `Removed ${name}. Reload with /reload to deactivate.`);
79
+ } catch (error) {
80
+ return text(`remove failed: ${error instanceof Error ? error.message : error}`);
81
+ }
82
+ }
83
+
35
84
  export function registerTools(pi: ExtensionAPI, natives: Natives): void {
36
85
  pi.registerTool({
37
86
  name: "pkg_search",
38
87
  label: "Pi Package Search",
39
- description:
40
- "Search Pi packages (extensions, skills, themes, prompts) on the npm registry. " +
41
- "Scoped to the pi-package keyword automatically. Returns name, version, description.",
88
+ description: "Search Pi packages on npm. Bounded read operation; defaults to 10 results and caps at 50.",
42
89
  parameters: Type.Object({
43
90
  query: Type.String({ description: "Search terms, e.g. 'lsp' or 'telegram'" }),
44
91
  limit: Type.Optional(Type.Number({ description: "Max results (default 10, max 50)" })),
45
92
  }),
46
- async execute(_id, params, _signal, _onUpdate, _ctx) {
93
+ async execute(_id, params) {
47
94
  try {
48
95
  const r = await natives.search(params.query, params.limit ?? 10);
49
96
  if (r.results.length === 0) return text(`No Pi packages found for "${params.query}".`);
50
- const lines = r.results.map(
51
- (p, i) => `${i + 1}. ${p.name}@${p.version}\n ${p.description ?? ""}`,
52
- );
53
- return text(
54
- `Found ${r.total} pi package(s) (showing ${r.results.length}):\n\n${lines.join("\n")}`,
55
- { results: r.results, total: r.total },
56
- );
57
- } catch (e) {
58
- return text(`pkg_search failed: ${e instanceof Error ? e.message : e}`);
97
+ const lines = r.results.map((p, i) => `${i + 1}. ${p.name}@${p.version}\n ${p.description ?? ""}`);
98
+ return text(`Found ${r.total} pi package(s) (showing ${r.results.length}):\n\n${lines.join("\n")}`, { results: r.results, total: r.total });
99
+ } catch (error) {
100
+ return text(`pkg_search failed: ${error instanceof Error ? error.message : error}`);
59
101
  }
60
102
  },
61
103
  });
@@ -63,13 +105,9 @@ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
63
105
  pi.registerTool({
64
106
  name: "pkg_info",
65
107
  label: "Pi Package Info",
66
- description:
67
- "Show details for a Pi package: latest version, description, repository, " +
68
- "declared pi resources (extensions/skills/themes/prompts), size, license.",
69
- parameters: Type.Object({
70
- name: Type.String({ description: "npm package name, e.g. 'pi-lsp' or '@scope/pkg'" }),
71
- }),
72
- async execute(_id, params, _signal, _onUpdate, _ctx) {
108
+ description: "Show bounded metadata and declared Pi resources for one package.",
109
+ parameters: Type.Object({ name: Type.String({ description: "npm package name" }) }),
110
+ async execute(_id, params) {
73
111
  try {
74
112
  const info = await natives.info(params.name);
75
113
  const lines = [
@@ -82,8 +120,8 @@ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
82
120
  info.modified ? `modified: ${info.modified}` : "",
83
121
  ].filter(Boolean);
84
122
  return text(lines.join("\n"), { info });
85
- } catch (e) {
86
- return text(`pkg_info failed: ${e instanceof Error ? e.message : e}`);
123
+ } catch (error) {
124
+ return text(`pkg_info failed: ${error instanceof Error ? error.message : error}`);
87
125
  }
88
126
  },
89
127
  });
@@ -91,14 +129,30 @@ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
91
129
  pi.registerTool({
92
130
  name: "pkg_install",
93
131
  label: "Pi Package Install",
94
- description:
95
- "Install a Pi package (pi install). Supports npm:<pkg>[@ver], git:<host>/<owner>/<repo>[@ref], " +
96
- "or https:// URLs. Packages execute arbitrary code. Approval follows the installApproval policy configured in /packed (secure default: always).",
97
- parameters: Type.Object({
98
- source: Type.String({ description: "e.g. 'npm:pi-lsp', 'npm:@scope/pkg@1.2.3', 'git:github.com/u/r@v1'" }),
99
- }),
132
+ description: "Install a Pi package through the authenticated daemon. Operation-aware approval is secure by default.",
133
+ parameters: Type.Object({ source: Type.String({ description: "npm:, git:, or https source" }) }),
100
134
  async execute(_id, params, _signal, _onUpdate, ctx) {
101
135
  return installPackageWithPolicy(params.source, natives, ctx);
102
136
  },
103
137
  });
138
+
139
+ pi.registerTool({
140
+ name: "pkg_update",
141
+ label: "Pi Package Update",
142
+ description: "Update one configured Pi package through Pi's documented update command. Operation-aware approval is secure by default.",
143
+ parameters: Type.Object({ source: Type.String({ description: "configured npm:, git:, or https source" }) }),
144
+ async execute(_id, params, _signal, _onUpdate, ctx) {
145
+ return updatePackageWithPolicy(params.source, natives, ctx);
146
+ },
147
+ });
148
+
149
+ pi.registerTool({
150
+ name: "pkg_remove",
151
+ label: "Pi Package Remove",
152
+ description: "Remove an installed npm Pi package through the authenticated daemon. Operation-aware approval is secure by default.",
153
+ parameters: Type.Object({ name: Type.String({ description: "bare npm name, e.g. pi-lsp or @scope/pkg" }) }),
154
+ async execute(_id, params, _signal, _onUpdate, ctx) {
155
+ return removePackageWithPolicy(params.name, natives, ctx);
156
+ },
157
+ });
104
158
  }
@@ -10,6 +10,7 @@ import { Container, Input, Spacer, truncateToWidth, visibleWidth } from "@earend
10
10
  import { filterRows, mergeRows, nextMode, visibleRows } from "./model.js";
11
11
  import type { Row, ViewMode } from "./model.js";
12
12
  import type { Natives } from "./packed.js";
13
+ import { approvePackageOperation } from "./tools.js";
13
14
 
14
15
  interface PanelAction {
15
16
  type: "menu" | "refresh";
@@ -64,25 +65,32 @@ export async function showPackages(ctx: ExtensionCommandContext, natives: Native
64
65
  );
65
66
 
66
67
  if (choice?.startsWith("Update")) {
67
- ctx.ui.notify(`Updating ${row.name}…`, "info");
68
68
  try {
69
- await natives.install(`npm:${row.name}@${row.latest}`);
70
- ctx.ui.notify(`Updated ${row.name} to ${row.latest} (takes effect after /reload)`, "info");
71
- row.version = row.latest ?? row.version;
72
- row.hasUpdate = false;
69
+ const approval = await approvePackageOperation("update", `pi update --extension npm:${row.name}`, natives, ctx);
70
+ if (!approval.allowed) {
71
+ ctx.ui.notify(approval.message ?? "update denied", "warning");
72
+ continue;
73
+ }
74
+ ctx.ui.notify(`Updating ${row.name}…`, "info");
75
+ await natives.update(`npm:${row.name}`, approval.approved);
76
+ ctx.ui.notify(`Updated ${row.name} to ${row.latest}; reloading Pi resources`, "info");
77
+ await ctx.reload();
78
+ return;
73
79
  } catch (e) {
74
80
  ctx.ui.notify(`update failed: ${e instanceof Error ? e.message : e}`, "error");
75
81
  }
76
82
  } else if (choice === "Remove") {
77
- const sure = await ctx.ui.confirm("Remove package", `pi remove npm:${row.name}?`);
78
- if (sure) {
79
- try {
80
- await natives.remove(row.name);
83
+ try {
84
+ const approval = await approvePackageOperation("remove", `pi remove npm:${row.name}`, natives, ctx);
85
+ if (approval.allowed) {
86
+ await natives.remove(row.name, approval.approved);
81
87
  ctx.ui.notify(`Removed ${row.name}`, "info");
82
88
  rows = rows.filter((r) => r.name !== row.name);
83
- } catch (e) {
84
- ctx.ui.notify(`remove failed: ${e instanceof Error ? e.message : e}`, "error");
89
+ } else {
90
+ ctx.ui.notify(approval.message ?? "remove denied", "warning");
85
91
  }
92
+ } catch (e) {
93
+ ctx.ui.notify(`remove failed: ${e instanceof Error ? e.message : e}`, "error");
86
94
  }
87
95
  }
88
96
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-packed",
3
- "version": "0.3.0",
3
+ "version": "0.5.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/cli.ts CHANGED
@@ -11,15 +11,21 @@ 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
13
  import { NAME_RE, defaultPiBin } from "./install.ts";
14
- import type { InstallApproval, SecuritySettingsPort } from "./security.ts";
14
+ import {
15
+ assertPackagePermission,
16
+ type MutationApproval,
17
+ type PackageOperation,
18
+ type SecuritySettingsPort,
19
+ } from "./security.ts";
15
20
 
16
21
  function defaultPiBinForUnit(): string | undefined {
17
22
  const b = defaultPiBin();
18
23
  return b === "pi" ? undefined : b; // bare name needs no pin
19
24
  }
20
25
  import {
21
- VERSION, SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT, NPM_REGISTRY_BASE, SEARCH_PAGE_SIZE, MIRROR_PAGE_DELAY_MS,
26
+ SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT, NPM_REGISTRY_BASE, SEARCH_PAGE_SIZE, MIRROR_PAGE_DELAY_MS,
22
27
  } from "./constants.ts";
28
+ import { VERSION } from "./version.ts";
23
29
 
24
30
  const SOURCE_RE = /^(npm:[A-Za-z0-9@._/-]+|git:[A-Za-z0-9@:._/-]+|https:\/\/[A-Za-z0-9@:._/?=&%~-]+)$/;
25
31
 
@@ -29,12 +35,13 @@ usage:
29
35
  packed search <query> [--offline] [--json] search npm (or the local mirror with --offline)
30
36
  packed info <name> [--json] package details
31
37
  packed updates [--json] updates per the local mirror
38
+ packed update <source> [--approve] [--json] update one configured package through Pi
32
39
  packed mirror [--json] sync upstream into the local SQLite index
33
40
  packed installed [--json] installed pi packages
34
41
  packed catalog [--json] local package index (apt-cache stats)
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
42
+ packed install <source> [--approve] [--json] pi install npm:|git:|https://… via daemon
43
+ packed remove <name> [--approve] [--json] remove by bare npm name via daemon
44
+ packed security [always|never] [--approve] [--json] read or set mutation approval policy
38
45
  packed serve run the long-running daemon
39
46
  packed service print a systemd user unit
40
47
  packed version print version
@@ -61,16 +68,18 @@ interface Flags {
61
68
  limit: number;
62
69
  cached: boolean;
63
70
  offline: boolean;
71
+ approved: boolean;
64
72
  }
65
73
 
66
74
  function parseFlags(rest: string[]): { flags: Flags; pos: string[] } {
67
- const flags: Flags = { json: false, limit: SEARCH_DEFAULT_LIMIT, cached: false, offline: false };
75
+ const flags: Flags = { json: false, limit: SEARCH_DEFAULT_LIMIT, cached: false, offline: false, approved: false };
68
76
  const pos: string[] = [];
69
77
  for (let i = 0; i < rest.length; i++) {
70
78
  const a = rest[i]!;
71
79
  if (a === "--json") flags.json = true;
72
80
  else if (a === "--cached") flags.cached = true;
73
81
  else if (a === "--offline") flags.offline = true;
82
+ else if (a === "--approve") flags.approved = true;
74
83
  else if (a === "--limit" && i + 1 < rest.length) flags.limit = Number(rest[++i]) || SEARCH_DEFAULT_LIMIT;
75
84
  else if (a.startsWith("--limit=")) flags.limit = Number(a.slice(8)) || SEARCH_DEFAULT_LIMIT;
76
85
  else pos.push(a);
@@ -84,6 +93,18 @@ const ok = (out: string): CliResult => ({ code: 0, out });
84
93
  const fail = (out: string, code = 1): CliResult => ({ code, out });
85
94
  const usageErr = (out: string): CliResult => ({ code: 2, out });
86
95
 
96
+ const PACKAGE_COMMAND_OPERATIONS: Record<string, PackageOperation | undefined> = {
97
+ search: "search",
98
+ info: "info",
99
+ installed: "installed",
100
+ catalog: "catalog",
101
+ updates: "updates",
102
+ update: "update",
103
+ mirror: "mirror",
104
+ install: "install",
105
+ remove: "remove",
106
+ };
107
+
87
108
  const commands: Record<string, { usage: string; run: Command }> = {
88
109
  search: {
89
110
  usage: "packed search <query> [--offline] [--limit N] [--json]",
@@ -193,7 +214,7 @@ const commands: Record<string, { usage: string; run: Command }> = {
193
214
  const source = pos[0] ?? "";
194
215
  if (!SOURCE_RE.test(source)) return usageErr(`usage: ${commands["install"]!.usage}\n`);
195
216
  try {
196
- const output = await d.inst.install(source);
217
+ const output = await d.inst.install(source, { approved: flags.approved });
197
218
  return flags.json ? ok(`${JSON.stringify({ ok: true, source, output })}\n`) : ok(`${output}\n`);
198
219
  } catch (e) {
199
220
  const error = e instanceof Error ? e.message : String(e);
@@ -210,21 +231,40 @@ const commands: Record<string, { usage: string; run: Command }> = {
210
231
  return usageErr(`usage: ${commands["security"]!.usage}\n`);
211
232
  }
212
233
  const settings = requested
213
- ? await d.security.setInstallApproval(requested as InstallApproval)
234
+ ? await d.security.setMutationApproval(requested as MutationApproval, { approved: flags.approved })
214
235
  : await d.security.security();
215
236
  return flags.json
216
237
  ? ok(`${JSON.stringify(settings)}\n`)
217
- : ok(`install approval: ${settings.installApproval}\n`);
238
+ : ok(`package mutation approval: ${settings.mutationApproval}\n`);
239
+ },
240
+ },
241
+
242
+ update: {
243
+ usage: "packed update <configured-source> [--approve] [--json]",
244
+ async run(_rest, d, flags, pos) {
245
+ const source = pos[0] ?? "";
246
+ if (!SOURCE_RE.test(source)) return usageErr(`usage: ${commands["update"]!.usage}\n`);
247
+ try {
248
+ const output = await d.inst.update(source, { approved: flags.approved });
249
+ return flags.json
250
+ ? ok(`${JSON.stringify({ ok: true, source, output, reloadRequired: true })}\n`)
251
+ : ok(`${output}\nReload Pi with /reload to activate the updated package.\n`);
252
+ } catch (error) {
253
+ const message = error instanceof Error ? error.message : String(error);
254
+ return flags.json
255
+ ? fail(`${JSON.stringify({ ok: false, source, error: message, reloadRequired: false })}\n`)
256
+ : fail(`${message}\n`);
257
+ }
218
258
  },
219
259
  },
220
260
 
221
261
  remove: {
222
- usage: "packed remove <name> [--json] (bare npm name, e.g. pi-lsp or @scope/pkg)",
262
+ usage: "packed remove <name> [--approve] [--json] (bare npm name, e.g. pi-lsp or @scope/pkg)",
223
263
  async run(_rest, d, flags, pos) {
224
264
  const name = pos[0] ?? "";
225
265
  if (!NAME_RE.test(name)) return usageErr(`usage: ${commands["remove"]!.usage}\n`);
226
266
  try {
227
- const output = await d.inst.remove(`npm:${name}`);
267
+ const output = await d.inst.remove(`npm:${name}`, { approved: flags.approved });
228
268
  return flags.json ? ok(`${JSON.stringify({ ok: true, name, output })}\n`) : ok(`${output}\n`);
229
269
  } catch (e) {
230
270
  const error = e instanceof Error ? e.message : String(e);
@@ -280,6 +320,15 @@ export async function cliRun(args: string[], d: CliDeps): Promise<CliResult> {
280
320
  if (!cmd) return usageErr(`unknown command "${name}"\n${USAGE}`);
281
321
  const { flags, pos } = parseFlags(rest);
282
322
  try {
323
+ const validMutationInput = name === "install" || name === "update"
324
+ ? SOURCE_RE.test(pos[0] ?? "")
325
+ : name === "remove" ? NAME_RE.test(pos[0] ?? "")
326
+ : name === "security" ? (pos[0] === undefined || pos[0] === "always" || pos[0] === "never")
327
+ : true;
328
+ const operation = name === "security"
329
+ ? (pos[0] === undefined ? "security.read" : "security.write")
330
+ : PACKAGE_COMMAND_OPERATIONS[name];
331
+ if (operation && validMutationInput) assertPackagePermission(await d.security.security(), operation, flags.approved);
283
332
  return await cmd.run(rest, d, flags, pos);
284
333
  } catch (e) {
285
334
  return fail(`${name} failed: ${e instanceof Error ? e.message : e}\n`);
package/src/client.ts CHANGED
@@ -8,7 +8,7 @@ import { join } from "node:path";
8
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
+ import type { MutationApproval, SecuritySettings } from "./security.ts";
12
12
 
13
13
  export type FetchTransport = (request: Request) => Promise<Response>;
14
14
 
@@ -18,9 +18,10 @@ export interface PackageDaemonPort {
18
18
  installed(): Promise<InstalledPkg[]>;
19
19
  updates(): Promise<UpdateEntry[]>;
20
20
  security(): Promise<SecuritySettings>;
21
- setInstallApproval(value: InstallApproval): Promise<SecuritySettings>;
22
- install(source: string): Promise<string>;
23
- remove(name: string): Promise<string>;
21
+ setMutationApproval(value: MutationApproval, approved?: boolean): Promise<SecuritySettings>;
22
+ install(source: string, approved?: boolean): Promise<string>;
23
+ remove(name: string, approved?: boolean): Promise<string>;
24
+ update(source: string, approved?: boolean): Promise<string>;
24
25
  }
25
26
 
26
27
  interface MutationResponse {
@@ -92,41 +93,54 @@ export class PackageDaemonClient implements PackageDaemonPort {
92
93
  return this.request("/security");
93
94
  }
94
95
 
95
- setInstallApproval(installApproval: InstallApproval): Promise<SecuritySettings> {
96
- return this.request("/security", { method: "POST", body: JSON.stringify({ installApproval }) });
96
+ setMutationApproval(mutationApproval: MutationApproval, approved = false): Promise<SecuritySettings> {
97
+ return this.request("/security", { method: "POST", body: JSON.stringify({ mutationApproval, approved }) });
97
98
  }
98
99
 
99
- async install(source: string): Promise<string> {
100
+ async install(source: string, approved = false): Promise<string> {
100
101
  const result = await this.request<MutationResponse>("/install", {
101
102
  method: "POST",
102
- body: JSON.stringify({ source }),
103
+ body: JSON.stringify({ source, approved }),
103
104
  });
104
105
  if (!result.ok) throw new PackageDaemonError(result.output || `failed to install ${source}`, "install");
105
106
  return result.output;
106
107
  }
107
108
 
108
- async remove(name: string): Promise<string> {
109
+ async remove(name: string, approved = false): Promise<string> {
109
110
  const result = await this.request<MutationResponse>("/remove", {
110
111
  method: "POST",
111
- body: JSON.stringify({ name }),
112
+ body: JSON.stringify({ name, approved }),
112
113
  });
113
114
  if (!result.ok) throw new PackageDaemonError(result.output || `failed to remove ${name}`, "remove");
114
115
  return result.output;
115
116
  }
117
+
118
+ async update(source: string, approved = false): Promise<string> {
119
+ const result = await this.request<MutationResponse>("/update", {
120
+ method: "POST",
121
+ body: JSON.stringify({ source, approved }),
122
+ });
123
+ if (!result.ok) throw new PackageDaemonError(result.output || `failed to update ${source}`, "update");
124
+ return result.output;
125
+ }
116
126
  }
117
127
 
118
128
  export class PackageDaemonInstaller implements Installer {
119
129
  constructor(private readonly client: PackageDaemonClient) {}
120
130
 
121
- install(source: string): Promise<string> {
122
- return this.client.install(source);
131
+ install(source: string, options?: { approved?: boolean }): Promise<string> {
132
+ return this.client.install(source, options?.approved);
123
133
  }
124
134
 
125
- remove(source: string): Promise<string> {
135
+ remove(source: string, options?: { approved?: boolean }): Promise<string> {
126
136
  if (!source.startsWith("npm:") || source.length <= 4) {
127
137
  throw new PackageDaemonError("daemon package removal requires an npm: source", "remove");
128
138
  }
129
- return this.client.remove(source.slice(4));
139
+ return this.client.remove(source.slice(4), options?.approved);
140
+ }
141
+
142
+ update(source: string, options?: { approved?: boolean }): Promise<string> {
143
+ return this.client.update(source, options?.approved);
130
144
  }
131
145
  }
132
146
 
@@ -135,20 +149,24 @@ export class DaemonBackedSecurity {
135
149
  async security(): Promise<SecuritySettings> {
136
150
  return (await connectPackageDaemon(this.stateDirectory)).security();
137
151
  }
138
- async setInstallApproval(value: InstallApproval): Promise<SecuritySettings> {
139
- return (await connectPackageDaemon(this.stateDirectory)).setInstallApproval(value);
152
+ async setMutationApproval(value: MutationApproval, options?: { approved?: boolean }): Promise<SecuritySettings> {
153
+ return (await connectPackageDaemon(this.stateDirectory)).setMutationApproval(value, options?.approved);
140
154
  }
141
155
  }
142
156
 
143
157
  export class DaemonBackedInstaller implements Installer {
144
158
  constructor(private readonly stateDirectory: string) {}
145
159
 
146
- async install(source: string): Promise<string> {
147
- return (await connectPackageDaemon(this.stateDirectory)).install(source);
160
+ async install(source: string, options?: { approved?: boolean }): Promise<string> {
161
+ return (await connectPackageDaemon(this.stateDirectory)).install(source, options?.approved);
162
+ }
163
+
164
+ async remove(source: string, options?: { approved?: boolean }): Promise<string> {
165
+ return new PackageDaemonInstaller(await connectPackageDaemon(this.stateDirectory)).remove(source, options);
148
166
  }
149
167
 
150
- async remove(source: string): Promise<string> {
151
- return new PackageDaemonInstaller(await connectPackageDaemon(this.stateDirectory)).remove(source);
168
+ async update(source: string, options?: { approved?: boolean }): Promise<string> {
169
+ return (await connectPackageDaemon(this.stateDirectory)).update(source, options?.approved);
152
170
  }
153
171
  }
154
172
 
package/src/constants.ts CHANGED
@@ -31,7 +31,6 @@ 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.3.0";
35
34
 
36
35
  // --- State-dir file names ---
37
36
  export const TOKEN_FILE = "token";
package/src/install.ts CHANGED
@@ -23,11 +23,15 @@ export class ExecInstaller implements Installer {
23
23
  return out;
24
24
  }
25
25
 
26
- install(source: string): Promise<string> {
26
+ install(source: string, _options?: { approved?: boolean }): Promise<string> {
27
27
  return this.run(["install", source]);
28
28
  }
29
29
 
30
- remove(source: string): Promise<string> {
30
+ remove(source: string, _options?: { approved?: boolean }): Promise<string> {
31
31
  return this.run(["remove", source]);
32
32
  }
33
+
34
+ update(source: string, _options?: { approved?: boolean }): Promise<string> {
35
+ return this.run(["update", "--extension", source]);
36
+ }
33
37
  }
package/src/ports.ts CHANGED
@@ -39,8 +39,9 @@ export interface Registry {
39
39
 
40
40
  /** Driven port: pi CLI mutations. */
41
41
  export interface Installer {
42
- install(source: string): Promise<string>;
43
- remove(source: string): Promise<string>;
42
+ install(source: string, options?: { approved?: boolean }): Promise<string>;
43
+ remove(source: string, options?: { approved?: boolean }): Promise<string>;
44
+ update(source: string, options?: { approved?: boolean }): Promise<string>;
44
45
  }
45
46
 
46
47
  export interface InstalledPkg {
package/src/security.ts CHANGED
@@ -2,21 +2,82 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { SECURITY_FILE } from "./constants.ts";
4
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 }
5
+ export const MUTATION_APPROVAL_VALUES = ["always", "never"] as const;
6
+ export type MutationApproval = typeof MUTATION_APPROVAL_VALUES[number];
7
+
8
+ export const PACKAGE_OPERATIONS = [
9
+ "search",
10
+ "info",
11
+ "installed",
12
+ "catalog",
13
+ "updates",
14
+ "security.read",
15
+ "mirror",
16
+ "install",
17
+ "update",
18
+ "remove",
19
+ "security.write",
20
+ ] as const;
21
+ export type PackageOperation = typeof PACKAGE_OPERATIONS[number];
22
+ export type PackageOperationClassification = "read" | "maintenance" | "code-execution" | "settings-mutation" | "security-mutation";
23
+
24
+ export interface SecuritySettings { mutationApproval: MutationApproval }
8
25
  export interface SecuritySettingsPort {
9
26
  security(): Promise<SecuritySettings>;
10
- setInstallApproval(value: InstallApproval): Promise<SecuritySettings>;
27
+ setMutationApproval(value: MutationApproval, options?: { approved?: boolean }): Promise<SecuritySettings>;
28
+ }
29
+
30
+ export interface PackagePermissionDecision {
31
+ operation: PackageOperation;
32
+ classification: PackageOperationClassification;
33
+ approvalRequired: boolean;
11
34
  }
12
35
 
13
- export const DEFAULT_SECURITY_SETTINGS: SecuritySettings = { installApproval: "always" };
36
+ export const DEFAULT_SECURITY_SETTINGS: SecuritySettings = { mutationApproval: "always" };
37
+
38
+ const CLASSIFICATIONS: Record<PackageOperation, PackageOperationClassification> = {
39
+ search: "read",
40
+ info: "read",
41
+ installed: "read",
42
+ catalog: "read",
43
+ updates: "read",
44
+ "security.read": "read",
45
+ mirror: "maintenance",
46
+ install: "code-execution",
47
+ update: "code-execution",
48
+ remove: "settings-mutation",
49
+ "security.write": "security-mutation",
50
+ };
51
+
52
+ export class PackageApprovalRequiredError extends Error {
53
+ readonly code = "approval_required";
54
+ constructor(readonly operation: PackageOperation) {
55
+ super(`approval required for package operation ${operation}`);
56
+ this.name = "PackageApprovalRequiredError";
57
+ }
58
+ }
59
+
60
+ export function packagePermissionDecision(settings: SecuritySettings, operation: PackageOperation): PackagePermissionDecision {
61
+ const classification = CLASSIFICATIONS[operation];
62
+ const guarded = classification === "code-execution" || classification === "settings-mutation" || classification === "security-mutation";
63
+ return { operation, classification, approvalRequired: guarded && settings.mutationApproval === "always" };
64
+ }
65
+
66
+ export function assertPackagePermission(settings: SecuritySettings, operation: PackageOperation, approved = false): void {
67
+ if (packagePermissionDecision(settings, operation).approvalRequired && !approved) {
68
+ throw new PackageApprovalRequiredError(operation);
69
+ }
70
+ }
14
71
 
15
72
  export function readSecuritySettings(stateDir: string): SecuritySettings {
16
73
  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 }
74
+ const value = JSON.parse(readFileSync(join(stateDir, SECURITY_FILE), "utf8")) as {
75
+ mutationApproval?: unknown;
76
+ installApproval?: unknown;
77
+ };
78
+ const stored = value.mutationApproval ?? value.installApproval;
79
+ return MUTATION_APPROVAL_VALUES.includes(stored as MutationApproval)
80
+ ? { mutationApproval: stored as MutationApproval }
20
81
  : { ...DEFAULT_SECURITY_SETTINGS };
21
82
  } catch {
22
83
  return { ...DEFAULT_SECURITY_SETTINGS };
@@ -24,7 +85,7 @@ export function readSecuritySettings(stateDir: string): SecuritySettings {
24
85
  }
25
86
 
26
87
  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");
88
+ if (!MUTATION_APPROVAL_VALUES.includes(settings.mutationApproval)) throw new Error("mutationApproval must be always or never");
28
89
  mkdirSync(stateDir, { recursive: true, mode: 0o700 });
29
90
  const target = join(stateDir, SECURITY_FILE);
30
91
  const temporary = `${target}.tmp`;
package/src/service.ts CHANGED
@@ -9,9 +9,17 @@ import { TTLCache } from "./cache.ts";
9
9
  import { loadUpdates } from "./watcher.ts";
10
10
  import { readInstalledPackages, defaultPiHome } from "./installed.ts";
11
11
  import { openDb, searchLocal, catalogList, getSyncMeta, dbPath } from "./db.ts";
12
- import { VERSION, SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT } from "./constants.ts";
12
+ import { SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT } from "./constants.ts";
13
+ import { VERSION } from "./version.ts";
13
14
  import { createLogger } from "./log.ts";
14
- import { readSecuritySettings, writeSecuritySettings, type InstallApproval } from "./security.ts";
15
+ import {
16
+ assertPackagePermission,
17
+ PackageApprovalRequiredError,
18
+ readSecuritySettings,
19
+ writeSecuritySettings,
20
+ type MutationApproval,
21
+ type PackageOperation,
22
+ } from "./security.ts";
15
23
 
16
24
  const log = createLogger("service");
17
25
 
@@ -31,13 +39,25 @@ function json(v: unknown, init?: ResponseInit): Response {
31
39
  return Response.json(v, init);
32
40
  }
33
41
 
34
- function err(status: number, msg: string): Response {
35
- return json({ error: msg }, { status });
42
+ function err(status: number, msg: string, details: Record<string, unknown> = {}): Response {
43
+ return json({ error: msg, ...details }, { status });
36
44
  }
37
45
 
38
46
  export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Response> } {
39
47
  const cache = deps.cache ?? new TTLCache();
40
48
 
49
+ function authorize(operation: PackageOperation, approved: boolean): Response | undefined {
50
+ try {
51
+ assertPackagePermission(readSecuritySettings(deps.stateDir), operation, approved);
52
+ return undefined;
53
+ } catch (error) {
54
+ if (error instanceof PackageApprovalRequiredError) {
55
+ return err(403, error.message, { code: error.code, operation: error.operation });
56
+ }
57
+ throw error;
58
+ }
59
+ }
60
+
41
61
  async function route(req: Request): Promise<Response> {
42
62
  const url = new URL(req.url);
43
63
  const path = url.pathname;
@@ -51,16 +71,18 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
51
71
  }
52
72
 
53
73
  if (path === "/security" && req.method === "POST") {
54
- let installApproval: unknown;
74
+ let body: { mutationApproval?: unknown; approved?: unknown };
55
75
  try {
56
- installApproval = ((await req.json()) as { installApproval?: unknown }).installApproval;
76
+ body = (await req.json()) as typeof body;
57
77
  } catch {
58
78
  return err(400, "invalid security settings JSON");
59
79
  }
60
- if (installApproval !== "always" && installApproval !== "never") {
61
- return err(400, "installApproval must be always or never");
80
+ if (body.mutationApproval !== "always" && body.mutationApproval !== "never") {
81
+ return err(400, "mutationApproval must be always or never");
62
82
  }
63
- return json(writeSecuritySettings(deps.stateDir, { installApproval: installApproval as InstallApproval }));
83
+ const denied = authorize("security.write", body.approved === true);
84
+ if (denied) return denied;
85
+ return json(writeSecuritySettings(deps.stateDir, { mutationApproval: body.mutationApproval as MutationApproval }));
64
86
  }
65
87
 
66
88
  if (path === "/search" && req.method === "GET") {
@@ -100,17 +122,21 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
100
122
 
101
123
  if (path === "/remove" && req.method === "POST") {
102
124
  let name = "";
125
+ let approved = false;
103
126
  try {
104
- const body = (await req.json()) as { name?: unknown };
127
+ const body = (await req.json()) as { name?: unknown; approved?: unknown };
105
128
  name = String(body.name ?? "");
129
+ approved = body.approved === true;
106
130
  } catch {
107
131
  /* fall through to validation */
108
132
  }
109
133
  if (!NAME_RE.test(name)) {
110
134
  return err(400, "invalid name; want a bare npm package name");
111
135
  }
136
+ const denied = authorize("remove", approved);
137
+ if (denied) return denied;
112
138
  try {
113
- const output = await deps.inst.remove(`npm:${name}`);
139
+ const output = await deps.inst.remove(`npm:${name}`, { approved });
114
140
  return json({ ok: true, name, output });
115
141
  } catch (e) {
116
142
  return json({ ok: false, name, output: e instanceof Error ? e.message : String(e) });
@@ -119,23 +145,50 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
119
145
 
120
146
  if (path === "/install" && req.method === "POST") {
121
147
  let source = "";
148
+ let approved = false;
122
149
  try {
123
- const body = (await req.json()) as { source?: unknown };
150
+ const body = (await req.json()) as { source?: unknown; approved?: unknown };
124
151
  source = String(body.source ?? "");
152
+ approved = body.approved === true;
125
153
  } catch {
126
154
  /* fall through to validation */
127
155
  }
128
156
  if (!SOURCE_RE.test(source)) {
129
157
  return err(400, "invalid source; want npm:<pkg>[@ver], git:<host>/<owner>/<repo>[@ref], or https://…");
130
158
  }
159
+ const denied = authorize("install", approved);
160
+ if (denied) return denied;
131
161
  try {
132
- const output = await deps.inst.install(source);
162
+ const output = await deps.inst.install(source, { approved });
133
163
  return json({ ok: true, source, output });
134
164
  } catch (e) {
135
165
  return json({ ok: false, source, output: e instanceof Error ? e.message : String(e) });
136
166
  }
137
167
  }
138
168
 
169
+ if (path === "/update" && req.method === "POST") {
170
+ let source = "";
171
+ let approved = false;
172
+ try {
173
+ const body = (await req.json()) as { source?: unknown; approved?: unknown };
174
+ source = String(body.source ?? "");
175
+ approved = body.approved === true;
176
+ } catch {
177
+ /* fall through to validation */
178
+ }
179
+ if (!SOURCE_RE.test(source)) {
180
+ return err(400, "invalid source; want a configured npm:, git:, or https package source");
181
+ }
182
+ const denied = authorize("update", approved);
183
+ if (denied) return denied;
184
+ try {
185
+ const output = await deps.inst.update(source, { approved });
186
+ return json({ ok: true, source, output, reloadRequired: true });
187
+ } catch (error) {
188
+ return json({ ok: false, source, output: error instanceof Error ? error.message : String(error), reloadRequired: false });
189
+ }
190
+ }
191
+
139
192
  if (path === "/updates" && req.method === "GET") {
140
193
  const snap = await loadUpdates(deps.stateDir);
141
194
  return json(snap ?? { updates: [] });
package/src/version.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ function packageVersion(): string {
4
+ const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as unknown;
5
+ if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) {
6
+ throw new Error("pi-packed package manifest must be an object");
7
+ }
8
+ const version = (manifest as Record<string, unknown>)["version"];
9
+ if (typeof version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
10
+ throw new Error("pi-packed package manifest has an invalid version");
11
+ }
12
+ return version;
13
+ }
14
+
15
+ /** Runtime package version; package.json is the single release source of truth. */
16
+ export const VERSION = packageVersion();