@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 +95 -72
- package/extension/src/index.ts +10 -2
- package/extension/src/packed.ts +37 -47
- package/extension/src/security-tui.ts +33 -0
- package/extension/src/tools.ts +24 -15
- package/package.json +2 -1
- package/src/cli.ts +49 -17
- package/src/client.ts +158 -22
- package/src/constants.ts +2 -1
- package/src/install.ts +5 -1
- package/src/security.ts +34 -0
- package/src/service.ts +19 -1
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
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
│
|
|
14
|
-
│
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
│
|
|
20
|
-
│ npm registry
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
43
|
-
| `packed info <name> [--json]` | version,
|
|
44
|
-
| `packed updates [--
|
|
45
|
-
| `packed
|
|
46
|
-
| `packed
|
|
47
|
-
| `packed
|
|
48
|
-
| `packed
|
|
49
|
-
| `packed
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
65
|
+
Failures use exit code 1 and `{ "ok": false, ... , "error": "..." }` with
|
|
66
|
+
credential-safe diagnostics. Usage errors use exit code 2.
|
|
55
67
|
|
|
56
|
-
|
|
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
|
-
|
|
70
|
+
Every route requires the bearer token stored in the private state directory.
|
|
71
|
+
The daemon listens on loopback only.
|
|
62
72
|
|
|
63
|
-
|
|
|
73
|
+
| Method | Route |
|
|
64
74
|
|---|---|
|
|
65
|
-
|
|
|
66
|
-
|
|
|
67
|
-
|
|
|
68
|
-
|
|
|
69
|
-
|
|
|
70
|
-
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
-
|
|
90
|
-
|
|
91
|
-
-
|
|
92
|
-
|
|
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
|
+
```
|
package/extension/src/index.ts
CHANGED
|
@@ -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):
|
|
18
|
-
//
|
|
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) => {
|
package/extension/src/packed.ts
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* packed.ts —
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 {
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
import("../../src/
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
+
}
|
package/extension/src/tools.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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>
|
|
30
|
-
packed remove <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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
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
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
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 (!
|
|
24
|
-
return
|
|
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.
|
|
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,
|
|
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
|
-
|
|
46
|
-
return this.
|
|
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.
|
|
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 =
|
|
12
|
+
constructor(private bin = defaultPiBin()) {}
|
|
9
13
|
|
|
10
14
|
private async run(args: string[]): Promise<string> {
|
|
11
15
|
const proc = Bun.spawn([this.bin, ...args], { stdout: "pipe", stderr: "pipe" });
|
package/src/security.ts
ADDED
|
@@ -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 });
|