@danypops/pi-packed 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -72
- package/extension/src/index.ts +2 -2
- package/extension/src/packed.ts +32 -47
- package/package.json +2 -1
- package/src/cli.ts +29 -17
- package/src/client.ts +137 -22
- package/src/constants.ts +1 -1
- package/src/install.ts +5 -1
package/README.md
CHANGED
|
@@ -1,92 +1,114 @@
|
|
|
1
1
|
# pi-packed
|
|
2
2
|
|
|
3
|
-
Package service for the [Pi](https://github.com/earendil-works/pi
|
|
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 · session_start update notification │
|
|
15
|
+
│ confirmation gates · no Bun/SQLite dependency │
|
|
16
|
+
└──────────────────┬────────────────────────────┘
|
|
17
|
+
│ authenticated loopback HTTP
|
|
18
|
+
┌─ packed.service (Bun) ────────────────────────┐
|
|
19
|
+
│ typed package client API · watcher · mirror │
|
|
20
|
+
│ npm registry · SQLite WAL · pi install/remove │
|
|
21
|
+
└───────────────────────────────────────────────┘
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
## Quickstart
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
|
-
bun test
|
|
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` always requires interactive user
|
|
41
|
+
confirmation before the extension sends the mutation to the authenticated
|
|
42
|
+
daemon.
|
|
43
|
+
|
|
38
44
|
## CLI
|
|
39
45
|
|
|
40
46
|
| Command | What |
|
|
41
47
|
|---|---|
|
|
42
|
-
| `packed search <q> [--limit N] [--json]` | npm
|
|
43
|
-
| `packed info <name> [--json]` | version,
|
|
44
|
-
| `packed updates [--
|
|
45
|
-
| `packed
|
|
46
|
-
| `packed
|
|
47
|
-
| `packed
|
|
48
|
-
| `packed
|
|
49
|
-
| `packed
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
| `packed search <q> [--offline] [--limit N] [--json]` | Search npm or the local mirror, scoped to `keywords:pi-package` |
|
|
49
|
+
| `packed info <name> [--json]` | Show version, repository, Pi manifest, size, and license |
|
|
50
|
+
| `packed updates [--json]` | Show drift from the local mirror |
|
|
51
|
+
| `packed mirror [--json]` | Refresh the SQLite package index |
|
|
52
|
+
| `packed installed [--json]` | Read Pi's installed package declarations |
|
|
53
|
+
| `packed catalog [--json]` | Inspect the local package index |
|
|
54
|
+
| `packed install <source> [--json]` | Authenticated daemon install for `npm:`, `git:`, or `https://` sources |
|
|
55
|
+
| `packed remove <name> [--json]` | Authenticated daemon removal by bare npm name |
|
|
56
|
+
| `packed serve` | Run the loopback daemon |
|
|
57
|
+
| `packed service` | Print the systemd user unit |
|
|
58
|
+
| `packed version` | Print the package/service version |
|
|
59
|
+
|
|
60
|
+
Install/remove JSON results are stable objects:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{"ok":true,"source":"npm:pi-lsp","output":"Installed npm:pi-lsp"}
|
|
64
|
+
```
|
|
52
65
|
|
|
53
|
-
|
|
54
|
-
|
|
66
|
+
Failures use exit code 1 and `{ "ok": false, ... , "error": "..." }` with
|
|
67
|
+
credential-safe diagnostics. Usage errors use exit code 2.
|
|
55
68
|
|
|
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).
|
|
69
|
+
## Service API
|
|
60
70
|
|
|
61
|
-
|
|
71
|
+
Every route requires the bearer token stored in the private state directory.
|
|
72
|
+
The daemon listens on loopback only.
|
|
62
73
|
|
|
63
|
-
|
|
|
74
|
+
| Method | Route |
|
|
64
75
|
|---|---|
|
|
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
|
-
|
|
76
|
+
| `GET` | `/health` |
|
|
77
|
+
| `GET` | `/search?q=&limit=&offline=1` |
|
|
78
|
+
| `GET` | `/info?name=` |
|
|
79
|
+
| `GET` | `/installed` |
|
|
80
|
+
| `GET` | `/updates` |
|
|
81
|
+
| `GET` | `/catalog` |
|
|
82
|
+
| `POST` | `/install` with `{ "source": "..." }` |
|
|
83
|
+
| `POST` | `/remove` with `{ "name": "..." }` |
|
|
84
|
+
|
|
85
|
+
State defaults to `~/.cache/pi-packed/` and contains `token`, `port`,
|
|
86
|
+
`updates.json`, and `packed.db`. Relevant environment variables:
|
|
87
|
+
|
|
88
|
+
- `PI_PACKED_HOME`
|
|
89
|
+
- `PI_PACKED_PI_HOME`
|
|
90
|
+
- `PI_PACKED_WATCH_SECS`
|
|
91
|
+
- `PI_PACKED_CATALOG_SECS`
|
|
92
|
+
- `PI_PACKED_IDLE_SECS`
|
|
93
|
+
- `PI_PACKED_PI_BIN` / `PI_BIN`
|
|
94
|
+
|
|
95
|
+
## Architecture and safety
|
|
96
|
+
|
|
97
|
+
- **Daemon-owned SQLite:** extensions never open the mirror directly.
|
|
98
|
+
- **Runtime boundary:** extensions never call `Bun.spawn`; only the supervised
|
|
99
|
+
Bun daemon owns the `ExecInstaller` adapter.
|
|
100
|
+
- **Authenticated typed client:** extension and mutation CLI paths call the same
|
|
101
|
+
loopback API and reconnect after daemon restarts.
|
|
102
|
+
- **Ports and adapters:** registry and installer ports keep policy independent
|
|
103
|
+
from npm, SQLite, subprocess, HTTP, and UI adapters.
|
|
104
|
+
- **Allowlisted mutation input:** package sources and names reject shell
|
|
105
|
+
metacharacters before reaching the installer.
|
|
106
|
+
- **Bounded requests:** daemon calls use timeouts and return structured errors
|
|
107
|
+
without tokens or credentials.
|
|
108
|
+
|
|
109
|
+
## Development
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
bun test
|
|
113
|
+
bunx tsc --noEmit
|
|
114
|
+
```
|
package/extension/src/index.ts
CHANGED
|
@@ -14,8 +14,8 @@ import { showPackages } from "./tui.js";
|
|
|
14
14
|
import { createNatives } from "./packed.js";
|
|
15
15
|
import { formatUpdateNotice } from "./model.js";
|
|
16
16
|
|
|
17
|
-
// Async factory (pi awaits it):
|
|
18
|
-
//
|
|
17
|
+
// Async factory (pi awaits it): the seam creates authenticated daemon
|
|
18
|
+
// clients lazily. It never executes Bun-only adapters or opens SQLite.
|
|
19
19
|
export default async function (pi: ExtensionAPI) {
|
|
20
20
|
const natives = await createNatives();
|
|
21
21
|
registerTools(pi, natives);
|
package/extension/src/packed.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
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";
|
|
12
11
|
|
|
13
12
|
export type { InstalledPkg, UpdateEntry };
|
|
14
13
|
export type PackageInfo = PkgInfo;
|
|
14
|
+
export type PackageDaemonPort = ClientPackageDaemonPort;
|
|
15
|
+
|
|
15
16
|
export interface SearchResponse {
|
|
16
17
|
query: string;
|
|
17
18
|
total: number;
|
|
@@ -28,52 +29,36 @@ export interface Natives {
|
|
|
28
29
|
remove(name: string): Promise<string>;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
export
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
import("../../src/
|
|
36
|
-
import("../../src/watcher.ts"),
|
|
37
|
-
import("../../src/install.ts"),
|
|
32
|
+
export type PackageDaemonConnector = () => Promise<PackageDaemonPort>;
|
|
33
|
+
|
|
34
|
+
async function connectDefaultDaemon(): Promise<PackageDaemonPort> {
|
|
35
|
+
const [client, state] = await Promise.all([
|
|
36
|
+
import("../../src/client.ts"),
|
|
38
37
|
import("../../src/state.ts"),
|
|
39
|
-
import("../../src/ports.ts"),
|
|
40
38
|
]);
|
|
39
|
+
return client.connectPackageDaemon(state.stateDir());
|
|
40
|
+
}
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
} finally {
|
|
52
|
-
db.close();
|
|
42
|
+
export async function createNatives(connect: PackageDaemonConnector = connectDefaultDaemon): Promise<Natives> {
|
|
43
|
+
async function call<T>(operation: (daemon: PackageDaemonPort) => Promise<T>): Promise<T> {
|
|
44
|
+
let lastError: unknown;
|
|
45
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
46
|
+
try {
|
|
47
|
+
return await operation(await connect());
|
|
48
|
+
} catch (error) {
|
|
49
|
+
lastError = error;
|
|
50
|
+
}
|
|
53
51
|
}
|
|
52
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
54
53
|
}
|
|
55
54
|
|
|
56
55
|
return {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
},
|
|
65
|
-
info: (name) => reg.info(name),
|
|
66
|
-
installed: () => Promise.resolve(instMod.readInstalledPackages(instMod.defaultPiHome())),
|
|
67
|
-
updates: () =>
|
|
68
|
-
Promise.resolve(
|
|
69
|
-
withDb((db) =>
|
|
70
|
-
watchMod.checkUpdates(
|
|
71
|
-
(name) => dbMod.latestVersion(db, name),
|
|
72
|
-
instMod.readInstalledPackages(instMod.defaultPiHome()),
|
|
73
|
-
),
|
|
74
|
-
),
|
|
75
|
-
),
|
|
76
|
-
install: (source) => inst.install(source),
|
|
77
|
-
remove: (name) => inst.remove(`npm:${name}`),
|
|
56
|
+
search: (query, limit) => call((daemon) => daemon.search(query, limit)),
|
|
57
|
+
searchOffline: (query, limit) => call((daemon) => daemon.search(query, limit, true)),
|
|
58
|
+
info: (name) => call((daemon) => daemon.info(name)),
|
|
59
|
+
installed: () => call((daemon) => daemon.installed()),
|
|
60
|
+
updates: () => call((daemon) => daemon.updates()),
|
|
61
|
+
install: (source) => call((daemon) => daemon.install(source)),
|
|
62
|
+
remove: (name) => call((daemon) => daemon.remove(name)),
|
|
78
63
|
};
|
|
79
64
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/pi-packed",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Package service for the Pi agent: search/info/install/updates + /packages TUI, backed by a long-running Bun service",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
],
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "bun test",
|
|
11
|
+
"typecheck": "bun x tsc --noEmit",
|
|
11
12
|
"cli": "bun src/cli.ts",
|
|
12
13
|
"serve": "bun src/cli.ts serve"
|
|
13
14
|
},
|
package/src/cli.ts
CHANGED
|
@@ -10,7 +10,12 @@ import { readInstalledPackages } from "./installed.ts";
|
|
|
10
10
|
import { checkUpdates } from "./watcher.ts";
|
|
11
11
|
import { syncCatalog } from "./catalog.ts";
|
|
12
12
|
import { openDb, searchLocal, catalogList, getSyncMeta, latestVersion, dbPath } from "./db.ts";
|
|
13
|
-
import { NAME_RE } from "./install.ts";
|
|
13
|
+
import { NAME_RE, defaultPiBin } from "./install.ts";
|
|
14
|
+
|
|
15
|
+
function defaultPiBinForUnit(): string | undefined {
|
|
16
|
+
const b = defaultPiBin();
|
|
17
|
+
return b === "pi" ? undefined : b; // bare name needs no pin
|
|
18
|
+
}
|
|
14
19
|
import {
|
|
15
20
|
VERSION, SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT, NPM_REGISTRY_BASE, SEARCH_PAGE_SIZE, MIRROR_PAGE_DELAY_MS,
|
|
16
21
|
} from "./constants.ts";
|
|
@@ -26,8 +31,8 @@ usage:
|
|
|
26
31
|
packed mirror [--json] sync upstream into the local SQLite index
|
|
27
32
|
packed installed [--json] installed pi packages
|
|
28
33
|
packed catalog [--json] local package index (apt-cache stats)
|
|
29
|
-
packed install <source>
|
|
30
|
-
packed remove <name>
|
|
34
|
+
packed install <source> [--json] pi install npm:|git:|https://… via daemon
|
|
35
|
+
packed remove <name> [--json] remove by bare npm name via daemon
|
|
31
36
|
packed serve run the long-running daemon
|
|
32
37
|
packed service print a systemd user unit
|
|
33
38
|
packed version print version
|
|
@@ -40,6 +45,7 @@ export interface CliDeps {
|
|
|
40
45
|
piHome: string;
|
|
41
46
|
execPath?: string; // bun binary (defaults to process.execPath)
|
|
42
47
|
cliPath?: string; // this CLI's entry file (for the systemd unit)
|
|
48
|
+
piBin?: string; // pi binary path to pin into the unit's Environment
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
export interface CliResult {
|
|
@@ -179,27 +185,31 @@ const commands: Record<string, { usage: string; run: Command }> = {
|
|
|
179
185
|
},
|
|
180
186
|
|
|
181
187
|
install: {
|
|
182
|
-
usage: "packed install npm:<pkg>[@ver] | git:<host>/<owner>/<repo>[@ref] | https://…",
|
|
183
|
-
async run(_rest, d,
|
|
188
|
+
usage: "packed install npm:<pkg>[@ver] | git:<host>/<owner>/<repo>[@ref] | https://… [--json]",
|
|
189
|
+
async run(_rest, d, flags, pos) {
|
|
184
190
|
const source = pos[0] ?? "";
|
|
185
191
|
if (!SOURCE_RE.test(source)) return usageErr(`usage: ${commands["install"]!.usage}\n`);
|
|
186
192
|
try {
|
|
187
|
-
|
|
193
|
+
const output = await d.inst.install(source);
|
|
194
|
+
return flags.json ? ok(`${JSON.stringify({ ok: true, source, output })}\n`) : ok(`${output}\n`);
|
|
188
195
|
} catch (e) {
|
|
189
|
-
|
|
196
|
+
const error = e instanceof Error ? e.message : String(e);
|
|
197
|
+
return flags.json ? fail(`${JSON.stringify({ ok: false, source, error })}\n`) : fail(`${error}\n`);
|
|
190
198
|
}
|
|
191
199
|
},
|
|
192
200
|
},
|
|
193
201
|
|
|
194
202
|
remove: {
|
|
195
|
-
usage: "packed remove <name> (bare npm name, e.g. pi-lsp or @scope/pkg)",
|
|
196
|
-
async run(_rest, d,
|
|
203
|
+
usage: "packed remove <name> [--json] (bare npm name, e.g. pi-lsp or @scope/pkg)",
|
|
204
|
+
async run(_rest, d, flags, pos) {
|
|
197
205
|
const name = pos[0] ?? "";
|
|
198
206
|
if (!NAME_RE.test(name)) return usageErr(`usage: ${commands["remove"]!.usage}\n`);
|
|
199
207
|
try {
|
|
200
|
-
|
|
208
|
+
const output = await d.inst.remove(`npm:${name}`);
|
|
209
|
+
return flags.json ? ok(`${JSON.stringify({ ok: true, name, output })}\n`) : ok(`${output}\n`);
|
|
201
210
|
} catch (e) {
|
|
202
|
-
|
|
211
|
+
const error = e instanceof Error ? e.message : String(e);
|
|
212
|
+
return flags.json ? fail(`${JSON.stringify({ ok: false, name, error })}\n`) : fail(`${error}\n`);
|
|
203
213
|
}
|
|
204
214
|
},
|
|
205
215
|
},
|
|
@@ -209,7 +219,7 @@ const commands: Record<string, { usage: string; run: Command }> = {
|
|
|
209
219
|
async run(_rest, d) {
|
|
210
220
|
const execPath = d.execPath ?? process.execPath;
|
|
211
221
|
const cliPath = d.cliPath ?? new URL("./cli.ts", import.meta.url).pathname;
|
|
212
|
-
return ok(renderUnit(execPath, cliPath));
|
|
222
|
+
return ok(renderUnit(execPath, cliPath, d.piBin ?? defaultPiBinForUnit()));
|
|
213
223
|
},
|
|
214
224
|
},
|
|
215
225
|
|
|
@@ -223,7 +233,10 @@ const commands: Record<string, { usage: string; run: Command }> = {
|
|
|
223
233
|
|
|
224
234
|
/** systemd user unit. Idle self-exit is disabled — systemd owns the
|
|
225
235
|
* lifecycle (Restart=on-failure takes over). */
|
|
226
|
-
export function renderUnit(execPath: string, cliPath: string): string {
|
|
236
|
+
export function renderUnit(execPath: string, cliPath: string, piBin?: string): string {
|
|
237
|
+
// systemd does not read shell rc files: PI_BIN must be explicit so the
|
|
238
|
+
// daemon's install/remove execs can find the pi binary.
|
|
239
|
+
const piEnv = piBin ? `Environment=PI_BIN=${piBin}\n` : "";
|
|
227
240
|
return `[Unit]
|
|
228
241
|
Description=pi-packed package service (Pi agent)
|
|
229
242
|
|
|
@@ -233,7 +246,7 @@ ExecStart=${execPath} ${cliPath} serve
|
|
|
233
246
|
Restart=on-failure
|
|
234
247
|
RestartSec=2
|
|
235
248
|
Environment=PI_PACKED_IDLE_SECS=0
|
|
236
|
-
NoNewPrivileges=true
|
|
249
|
+
${piEnv}NoNewPrivileges=true
|
|
237
250
|
|
|
238
251
|
[Install]
|
|
239
252
|
WantedBy=default.target
|
|
@@ -264,8 +277,7 @@ if (import.meta.main) {
|
|
|
264
277
|
} else {
|
|
265
278
|
const { stateDir } = await import("./state.ts");
|
|
266
279
|
const { defaultPiHome } = await import("./installed.ts");
|
|
267
|
-
const { resolveRegistry } = await import("./client.ts");
|
|
268
|
-
const { ExecInstaller } = await import("./install.ts");
|
|
280
|
+
const { DaemonBackedInstaller, resolveRegistry } = await import("./client.ts");
|
|
269
281
|
const dir = stateDir();
|
|
270
282
|
// mirror talks to UPSTREAM, not the daemon cache — apt update semantics.
|
|
271
283
|
const reg =
|
|
@@ -274,7 +286,7 @@ if (import.meta.main) {
|
|
|
274
286
|
: await resolveRegistry(dir, NPM_REGISTRY_BASE);
|
|
275
287
|
const { code, out } = await cliRun(args, {
|
|
276
288
|
reg,
|
|
277
|
-
inst: new
|
|
289
|
+
inst: new DaemonBackedInstaller(dir),
|
|
278
290
|
stateDir: dir,
|
|
279
291
|
piHome: defaultPiHome(),
|
|
280
292
|
});
|
package/src/client.ts
CHANGED
|
@@ -1,49 +1,158 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* client.ts —
|
|
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
11
|
|
|
12
|
-
export
|
|
12
|
+
export type FetchTransport = (request: Request) => Promise<Response>;
|
|
13
|
+
|
|
14
|
+
export interface PackageDaemonPort {
|
|
15
|
+
search(query: string, limit: number, offline?: boolean): Promise<{ query: string; total: number; results: SearchPage["results"] }>;
|
|
16
|
+
info(name: string): Promise<PkgInfo>;
|
|
17
|
+
installed(): Promise<InstalledPkg[]>;
|
|
18
|
+
updates(): Promise<UpdateEntry[]>;
|
|
19
|
+
install(source: string): Promise<string>;
|
|
20
|
+
remove(name: string): Promise<string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface MutationResponse {
|
|
24
|
+
ok: boolean;
|
|
25
|
+
output: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class PackageDaemonError extends Error {
|
|
13
29
|
constructor(
|
|
14
|
-
|
|
15
|
-
|
|
30
|
+
message: string,
|
|
31
|
+
readonly operation: string,
|
|
32
|
+
readonly status?: number,
|
|
33
|
+
) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "PackageDaemonError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class PackageDaemonClient implements PackageDaemonPort {
|
|
40
|
+
constructor(
|
|
41
|
+
private readonly base: string,
|
|
42
|
+
private readonly token: string,
|
|
43
|
+
private readonly transport: FetchTransport = fetch,
|
|
16
44
|
) {}
|
|
17
45
|
|
|
18
|
-
private async
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
46
|
+
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
47
|
+
const headers = new Headers(init.headers);
|
|
48
|
+
headers.set("authorization", `Bearer ${this.token}`);
|
|
49
|
+
if (init.body !== undefined) headers.set("content-type", "application/json");
|
|
50
|
+
const response = await this.transport(new Request(`${this.base}${path}`, {
|
|
51
|
+
...init,
|
|
52
|
+
headers,
|
|
53
|
+
signal: init.signal ?? AbortSignal.timeout(REGISTRY_FETCH_TIMEOUT_MS),
|
|
54
|
+
}));
|
|
55
|
+
let body: unknown;
|
|
56
|
+
try {
|
|
57
|
+
body = await response.json();
|
|
58
|
+
} catch {
|
|
59
|
+
throw new PackageDaemonError(`package daemon returned invalid JSON (HTTP ${response.status})`, path, response.status);
|
|
60
|
+
}
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
const message = typeof body === "object" && body !== null && typeof (body as { error?: unknown }).error === "string"
|
|
63
|
+
? (body as { error: string }).error
|
|
64
|
+
: `package daemon HTTP ${response.status}`;
|
|
65
|
+
throw new PackageDaemonError(message, path, response.status);
|
|
66
|
+
}
|
|
67
|
+
return body as T;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async search(query: string, limit: number, offline = false): Promise<{ query: string; total: number; results: SearchPage["results"] }> {
|
|
71
|
+
const params = new URLSearchParams({ q: query, limit: String(limit) });
|
|
72
|
+
if (offline) params.set("offline", "1");
|
|
73
|
+
return this.request(`/search?${params}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
info(name: string): Promise<PkgInfo> {
|
|
77
|
+
return this.request(`/info?name=${encodeURIComponent(name)}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
installed(): Promise<InstalledPkg[]> {
|
|
81
|
+
return this.request("/installed");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async updates(): Promise<UpdateEntry[]> {
|
|
85
|
+
return (await this.request<UpdatesSnapshot>("/updates")).updates;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async install(source: string): Promise<string> {
|
|
89
|
+
const result = await this.request<MutationResponse>("/install", {
|
|
90
|
+
method: "POST",
|
|
91
|
+
body: JSON.stringify({ source }),
|
|
22
92
|
});
|
|
23
|
-
if (!
|
|
24
|
-
return
|
|
93
|
+
if (!result.ok) throw new PackageDaemonError(result.output || `failed to install ${source}`, "install");
|
|
94
|
+
return result.output;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async remove(name: string): Promise<string> {
|
|
98
|
+
const result = await this.request<MutationResponse>("/remove", {
|
|
99
|
+
method: "POST",
|
|
100
|
+
body: JSON.stringify({ name }),
|
|
101
|
+
});
|
|
102
|
+
if (!result.ok) throw new PackageDaemonError(result.output || `failed to remove ${name}`, "remove");
|
|
103
|
+
return result.output;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class PackageDaemonInstaller implements Installer {
|
|
108
|
+
constructor(private readonly client: PackageDaemonClient) {}
|
|
109
|
+
|
|
110
|
+
install(source: string): Promise<string> {
|
|
111
|
+
return this.client.install(source);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
remove(source: string): Promise<string> {
|
|
115
|
+
if (!source.startsWith("npm:") || source.length <= 4) {
|
|
116
|
+
throw new PackageDaemonError("daemon package removal requires an npm: source", "remove");
|
|
117
|
+
}
|
|
118
|
+
return this.client.remove(source.slice(4));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class DaemonBackedInstaller implements Installer {
|
|
123
|
+
constructor(private readonly stateDirectory: string) {}
|
|
124
|
+
|
|
125
|
+
async install(source: string): Promise<string> {
|
|
126
|
+
return (await connectPackageDaemon(this.stateDirectory)).install(source);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async remove(source: string): Promise<string> {
|
|
130
|
+
return new PackageDaemonInstaller(await connectPackageDaemon(this.stateDirectory)).remove(source);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export class DaemonRegistry implements Registry {
|
|
135
|
+
private readonly client: PackageDaemonClient;
|
|
136
|
+
|
|
137
|
+
constructor(base: string, token: string, transport: FetchTransport = fetch) {
|
|
138
|
+
this.client = new PackageDaemonClient(base, token, transport);
|
|
25
139
|
}
|
|
26
140
|
|
|
27
141
|
async search(query: string, limit: number): Promise<SearchPage> {
|
|
28
|
-
const body = await this.
|
|
29
|
-
`/search?q=${encodeURIComponent(query)}&limit=${limit}`,
|
|
30
|
-
);
|
|
142
|
+
const body = await this.client.search(query, limit);
|
|
31
143
|
return { results: body.results, total: body.total };
|
|
32
144
|
}
|
|
33
145
|
|
|
34
|
-
async searchPage(query: string,
|
|
35
|
-
// The daemon clamps to 50; page through it for bulk reads.
|
|
146
|
+
async searchPage(query: string, _from: number, size: number): Promise<SearchPage> {
|
|
36
147
|
return this.search(query, size).catch(() => ({ results: [], total: 0 }));
|
|
37
148
|
}
|
|
38
149
|
|
|
39
150
|
async searchAll(): Promise<never> {
|
|
40
|
-
// Bulk sync runs inside the daemon against the direct registry;
|
|
41
|
-
// the proxy never paginates the full universe through the clamp.
|
|
42
151
|
throw new Error("searchAll is not supported via the daemon proxy");
|
|
43
152
|
}
|
|
44
153
|
|
|
45
|
-
|
|
46
|
-
return this.
|
|
154
|
+
info(name: string): Promise<PkgInfo> {
|
|
155
|
+
return this.client.info(name);
|
|
47
156
|
}
|
|
48
157
|
}
|
|
49
158
|
|
|
@@ -74,6 +183,12 @@ export async function probe(dir: string): Promise<DaemonHandle | undefined> {
|
|
|
74
183
|
return undefined;
|
|
75
184
|
}
|
|
76
185
|
|
|
186
|
+
export async function connectPackageDaemon(dir: string): Promise<PackageDaemonClient> {
|
|
187
|
+
const handle = await probe(dir);
|
|
188
|
+
if (!handle) throw new Error("pi-packed daemon is unavailable; start packed.service");
|
|
189
|
+
return new PackageDaemonClient(handle.base, handle.token);
|
|
190
|
+
}
|
|
191
|
+
|
|
77
192
|
export async function resolveRegistry(dir: string, npmBase: string): Promise<Registry> {
|
|
78
193
|
const handle = await probe(dir);
|
|
79
194
|
if (handle) return new DaemonRegistry(handle.base, handle.token);
|
package/src/constants.ts
CHANGED
|
@@ -31,7 +31,7 @@ export const IDLE_BUDGET_DEFAULT_MS = 10 * 60_000; // on-demand self-exit
|
|
|
31
31
|
export const WATCHDOG_TICK_MS = 15_000;
|
|
32
32
|
|
|
33
33
|
// --- Identity ---
|
|
34
|
-
export const VERSION = "0.1
|
|
34
|
+
export const VERSION = "0.2.1";
|
|
35
35
|
|
|
36
36
|
// --- State-dir file names ---
|
|
37
37
|
export const TOKEN_FILE = "token";
|
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" });
|