@danypops/pi-packed 0.1.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 +92 -0
- package/extension/src/index.ts +41 -0
- package/extension/src/model.ts +50 -0
- package/extension/src/packed.ts +90 -0
- package/extension/src/tools.ts +98 -0
- package/extension/src/tui.ts +222 -0
- package/package.json +36 -0
- package/src/cache.ts +19 -0
- package/src/catalog.ts +63 -0
- package/src/cli.ts +239 -0
- package/src/client.ts +80 -0
- package/src/daemon.ts +63 -0
- package/src/install.ts +29 -0
- package/src/installed.ts +56 -0
- package/src/ports.ts +74 -0
- package/src/registry.ts +94 -0
- package/src/service.ts +112 -0
- package/src/state.ts +48 -0
- package/src/watcher.ts +70 -0
package/src/service.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* service.ts — the hexagon's HTTP driving adapter as a pure Web Standard
|
|
3
|
+
* handler: (Request) → Response. Bun.serve wraps it for the network;
|
|
4
|
+
* tests call it in-process. Same port, two adapters — Cockburn's symmetry.
|
|
5
|
+
*/
|
|
6
|
+
import { buildSearchQuery, clampLimit } from "./ports.ts";
|
|
7
|
+
import type { Installer, Registry } from "./ports.ts";
|
|
8
|
+
import { TTLCache } from "./cache.ts";
|
|
9
|
+
import { loadUpdates } from "./watcher.ts";
|
|
10
|
+
import { loadCatalog } from "./catalog.ts";
|
|
11
|
+
|
|
12
|
+
export interface Deps {
|
|
13
|
+
reg: Registry;
|
|
14
|
+
inst: Installer;
|
|
15
|
+
token: string;
|
|
16
|
+
stateDir: string;
|
|
17
|
+
cache?: TTLCache;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const SOURCE_RE = /^(npm:[A-Za-z0-9@._/-]+|git:[A-Za-z0-9@:._/-]+|https:\/\/[A-Za-z0-9@:._/?=&%~-]+)$/;
|
|
21
|
+
|
|
22
|
+
function json(v: unknown, init?: ResponseInit): Response {
|
|
23
|
+
return Response.json(v, init);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function err(status: number, msg: string): Response {
|
|
27
|
+
return json({ error: msg }, { status });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Response> } {
|
|
31
|
+
const cache = deps.cache ?? new TTLCache();
|
|
32
|
+
|
|
33
|
+
async function route(req: Request): Promise<Response> {
|
|
34
|
+
const url = new URL(req.url);
|
|
35
|
+
const path = url.pathname;
|
|
36
|
+
|
|
37
|
+
if (path === "/health" && req.method === "GET") {
|
|
38
|
+
return json({ ok: true, version: "0.1.0" });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (path === "/search" && req.method === "GET") {
|
|
42
|
+
const q = url.searchParams.get("q") ?? "";
|
|
43
|
+
const limit = clampLimit(Number(url.searchParams.get("limit")), 10, 50);
|
|
44
|
+
try {
|
|
45
|
+
const { results, total } = await deps.reg.search(buildSearchQuery(q), limit);
|
|
46
|
+
return json({ query: q, total, results });
|
|
47
|
+
} catch (e) {
|
|
48
|
+
return err(502, e instanceof Error ? e.message : String(e));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (path === "/info" && req.method === "GET") {
|
|
53
|
+
const name = url.searchParams.get("name") ?? "";
|
|
54
|
+
if (!name) return err(400, "missing name");
|
|
55
|
+
try {
|
|
56
|
+
return json(await deps.reg.info(name));
|
|
57
|
+
} catch (e) {
|
|
58
|
+
return err(502, e instanceof Error ? e.message : String(e));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (path === "/install" && req.method === "POST") {
|
|
63
|
+
let source = "";
|
|
64
|
+
try {
|
|
65
|
+
const body = (await req.json()) as { source?: unknown };
|
|
66
|
+
source = String(body.source ?? "");
|
|
67
|
+
} catch {
|
|
68
|
+
/* fall through to validation */
|
|
69
|
+
}
|
|
70
|
+
if (!SOURCE_RE.test(source)) {
|
|
71
|
+
return err(400, "invalid source; want npm:<pkg>[@ver], git:<host>/<owner>/<repo>[@ref], or https://…");
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const output = await deps.inst.install(source);
|
|
75
|
+
return json({ ok: true, source, output });
|
|
76
|
+
} catch (e) {
|
|
77
|
+
return json({ ok: false, source, output: e instanceof Error ? e.message : String(e) });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (path === "/updates" && req.method === "GET") {
|
|
82
|
+
const snap = await loadUpdates(deps.stateDir);
|
|
83
|
+
return json(snap ?? { updates: [] });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (path === "/catalog" && req.method === "GET") {
|
|
87
|
+
const snap = await loadCatalog(deps.stateDir);
|
|
88
|
+
return json(snap ?? { packages: [] });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return err(404, "not found");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
async fetch(req: Request): Promise<Response> {
|
|
96
|
+
if (req.headers.get("authorization") !== `Bearer ${deps.token}`) {
|
|
97
|
+
return err(401, "missing or invalid bearer token");
|
|
98
|
+
}
|
|
99
|
+
// Cache successful GETs by URI (smart-proxy concern).
|
|
100
|
+
if (req.method === "GET" && !["/health", "/updates", "/catalog"].includes(new URL(req.url).pathname)) {
|
|
101
|
+
const hit = cache.get(req.url);
|
|
102
|
+
if (hit) {
|
|
103
|
+
return new Response(hit, { headers: { "content-type": "application/json", "x-cache": "hit" } });
|
|
104
|
+
}
|
|
105
|
+
const res = await route(req);
|
|
106
|
+
if (res.status === 200) cache.set(req.url, await res.clone().text());
|
|
107
|
+
return res;
|
|
108
|
+
}
|
|
109
|
+
return route(req);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Daemon state: config dir, bearer token, port file, idle predicate. */
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
export function stateDir(): string {
|
|
8
|
+
if (process.env["PI_PACKED_HOME"]) return process.env["PI_PACKED_HOME"];
|
|
9
|
+
try {
|
|
10
|
+
return join(homedir(), ".cache", "pi-packed");
|
|
11
|
+
} catch {
|
|
12
|
+
return join(tmpdir(), "pi-packed");
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function loadOrCreateToken(dir: string): string {
|
|
17
|
+
const path = join(dir, "token");
|
|
18
|
+
try {
|
|
19
|
+
const tok = readFileSync(path, "utf8").trim();
|
|
20
|
+
if (tok) return tok;
|
|
21
|
+
} catch {
|
|
22
|
+
/* first run */
|
|
23
|
+
}
|
|
24
|
+
const tok = randomBytes(16).toString("hex");
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
26
|
+
writeFileSync(path, tok + "\n", { mode: 0o600 });
|
|
27
|
+
return tok;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function writePort(dir: string, port: number): void {
|
|
31
|
+
mkdirSync(dir, { recursive: true });
|
|
32
|
+
writeFileSync(join(dir, "port"), String(port) + "\n", { mode: 0o600 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function idleExpired(lastActiveMs: number, nowMs: number, budgetMs: number): boolean {
|
|
36
|
+
return nowMs - lastActiveMs > budgetMs;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Env knob in seconds → ms. Zero means "disabled" (external lifecycle
|
|
40
|
+
* manager such as systemd owns the process). Garbage → default. */
|
|
41
|
+
export function envMs(key: string, defMs: number): number {
|
|
42
|
+
const raw = process.env[key];
|
|
43
|
+
if (raw === undefined) return defMs;
|
|
44
|
+
const v = Number(raw);
|
|
45
|
+
if (!Number.isFinite(v) || v < 0) return defMs;
|
|
46
|
+
if (v === 0) return 0;
|
|
47
|
+
return v * 1000;
|
|
48
|
+
}
|
package/src/watcher.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* watcher.ts — event producer #1: version-drift detection.
|
|
3
|
+
* Diffs installed packages against registry dist-tags.latest and persists
|
|
4
|
+
* an event snapshot (event-carried state) for cheap consumer reads.
|
|
5
|
+
*/
|
|
6
|
+
import { writeFile, readFile, mkdir } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import type { InstalledPkg, Registry, UpdateEntry, UpdatesSnapshot } from "./ports.ts";
|
|
9
|
+
|
|
10
|
+
function updatesPath(dir: string): string {
|
|
11
|
+
return join(dir, "updates.json");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function saveUpdates(dir: string, snap: UpdatesSnapshot): Promise<void> {
|
|
15
|
+
await mkdir(dir, { recursive: true });
|
|
16
|
+
await writeFile(updatesPath(dir), JSON.stringify(snap), { mode: 0o600 });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function loadUpdates(dir: string): Promise<UpdatesSnapshot | undefined> {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(await readFile(updatesPath(dir), "utf8")) as UpdatesSnapshot;
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Pure diff: drift = latest ≠ what we have (registry latest is authoritative). */
|
|
28
|
+
export async function checkUpdates(reg: Registry, installed: InstalledPkg[]): Promise<UpdateEntry[]> {
|
|
29
|
+
const now = new Date().toISOString();
|
|
30
|
+
const updates: UpdateEntry[] = [];
|
|
31
|
+
for (const p of installed) {
|
|
32
|
+
const have = p.installed || p.pinned;
|
|
33
|
+
if (!have) continue;
|
|
34
|
+
try {
|
|
35
|
+
const info = await reg.info(p.name);
|
|
36
|
+
if (info.version && info.version !== have) {
|
|
37
|
+
updates.push({ name: p.name, installed: have, latest: info.version, detectedAt: now });
|
|
38
|
+
}
|
|
39
|
+
} catch (e) {
|
|
40
|
+
console.error(`updates: ${p.name}: ${e instanceof Error ? e.message : e}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return updates;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface WatcherOptions {
|
|
47
|
+
intervalMs: number;
|
|
48
|
+
onError?: (e: unknown) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Producer loop: immediate check, then on a timer. Returns a stop function. */
|
|
52
|
+
export function startWatcher(
|
|
53
|
+
reg: Registry,
|
|
54
|
+
stateDir: string,
|
|
55
|
+
readInstalled: () => InstalledPkg[],
|
|
56
|
+
opts: WatcherOptions,
|
|
57
|
+
): () => void {
|
|
58
|
+
async function check(): Promise<void> {
|
|
59
|
+
try {
|
|
60
|
+
const updates = await checkUpdates(reg, readInstalled());
|
|
61
|
+
await saveUpdates(stateDir, { checkedAt: new Date().toISOString(), updates });
|
|
62
|
+
if (updates.length > 0) console.error(`[packed] updates available: ${updates.length}`);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
opts.onError?.(e);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
void check();
|
|
68
|
+
const timer = setInterval(() => void check(), opts.intervalMs);
|
|
69
|
+
return () => clearInterval(timer);
|
|
70
|
+
}
|