@danypops/pi-packed 0.1.0 → 0.1.7
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/package.json +1 -1
- package/src/cache.ts +3 -1
- package/src/catalog.ts +32 -31
- package/src/cli.ts +69 -24
- package/src/client.ts +6 -5
- package/src/constants.ts +50 -0
- package/src/daemon.ts +15 -7
- package/src/db.ts +124 -0
- package/src/installed.ts +4 -2
- package/src/log.ts +42 -0
- package/src/ports.ts +3 -1
- package/src/registry.ts +56 -9
- package/src/service.ts +30 -6
- package/src/state.ts +5 -3
- package/src/watcher.ts +16 -14
package/package.json
CHANGED
package/src/cache.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/** TTLCache — the smart-proxy concern, nothing more. */
|
|
2
|
+
import { CACHE_TTL_MS } from "./constants.ts";
|
|
3
|
+
|
|
2
4
|
export class TTLCache {
|
|
3
5
|
private m = new Map<string, { body: string; expires: number }>();
|
|
4
|
-
constructor(private ttlMs =
|
|
6
|
+
constructor(private ttlMs = CACHE_TTL_MS) {}
|
|
5
7
|
|
|
6
8
|
get(key: string): string | undefined {
|
|
7
9
|
const e = this.m.get(key);
|
package/src/catalog.ts
CHANGED
|
@@ -1,44 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* catalog.ts —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* catalog.ts — the sync pipeline (apt update / pkg update analog).
|
|
3
|
+
* Paginates the upstream registry into the local SQLite mirror and records
|
|
4
|
+
* sync metadata (the Release/repomd checksum role) in sync_meta.
|
|
5
5
|
*/
|
|
6
|
-
import { writeFile, readFile, mkdir } from "node:fs/promises";
|
|
7
|
-
import { join } from "node:path";
|
|
8
6
|
import type { Pkg, Registry } from "./ports.ts";
|
|
7
|
+
import { PI_PACKAGE_KEYWORD } from "./constants.ts";
|
|
8
|
+
import { openDb, replaceAll, getSyncMeta, dbPath } from "./db.ts";
|
|
9
|
+
import { createLogger } from "./log.ts";
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
packages: Pkg[];
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function catalogPath(dir: string): string {
|
|
16
|
-
return join(dir, "catalog.json");
|
|
17
|
-
}
|
|
11
|
+
const log = createLogger("catalog");
|
|
12
|
+
import type { SyncMeta } from "./db.ts";
|
|
18
13
|
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
export interface CatalogStatus {
|
|
15
|
+
stale: boolean;
|
|
16
|
+
meta?: SyncMeta;
|
|
22
17
|
}
|
|
23
18
|
|
|
24
|
-
export
|
|
19
|
+
export function catalogStatus(dir: string, ttlMs: number): CatalogStatus {
|
|
20
|
+
const db = openDb(dbPath(dir));
|
|
25
21
|
try {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return
|
|
22
|
+
const meta = getSyncMeta(db);
|
|
23
|
+
if (!meta) return { stale: true };
|
|
24
|
+
return { stale: Date.now() - Date.parse(meta.fetchedAt) > ttlMs, meta };
|
|
25
|
+
} finally {
|
|
26
|
+
db.close();
|
|
29
27
|
}
|
|
30
28
|
}
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Full sync: paginate the whole keyword universe into a lean snapshot. */
|
|
38
|
-
export async function syncCatalog(reg: Registry, dir: string, query = "keywords:pi-package"): Promise<number> {
|
|
30
|
+
/** Full mirror sync: upstream pages → atomic SQLite replace. */
|
|
31
|
+
export async function syncCatalog(reg: Registry, dir: string, query: string = PI_PACKAGE_KEYWORD): Promise<number> {
|
|
32
|
+
const t0 = Date.now();
|
|
33
|
+
log.info("mirror sync started", { query });
|
|
39
34
|
const packages = await reg.searchAll(query);
|
|
40
|
-
|
|
41
|
-
|
|
35
|
+
const db = openDb(dbPath(dir));
|
|
36
|
+
try {
|
|
37
|
+
replaceAll(db, packages, "npm:" + query);
|
|
38
|
+
log.info("mirror sync complete", { packages: packages.length, ms: Date.now() - t0 });
|
|
39
|
+
return packages.length;
|
|
40
|
+
} finally {
|
|
41
|
+
db.close();
|
|
42
|
+
}
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
export function startCatalogSync(
|
|
@@ -49,9 +50,9 @@ export function startCatalogSync(
|
|
|
49
50
|
): () => void {
|
|
50
51
|
async function sync(): Promise<void> {
|
|
51
52
|
try {
|
|
52
|
-
if (
|
|
53
|
+
if (catalogStatus(dir, ttlMs).stale) {
|
|
53
54
|
const n = await syncCatalog(reg, dir);
|
|
54
|
-
|
|
55
|
+
log.info("scheduled sync complete", { packages: n });
|
|
55
56
|
}
|
|
56
57
|
} catch (e) {
|
|
57
58
|
onError?.(e);
|
package/src/cli.ts
CHANGED
|
@@ -7,20 +7,25 @@
|
|
|
7
7
|
import { buildSearchQuery, clampLimit } from "./ports.ts";
|
|
8
8
|
import type { Installer, Registry } from "./ports.ts";
|
|
9
9
|
import { readInstalledPackages } from "./installed.ts";
|
|
10
|
-
import { checkUpdates
|
|
11
|
-
import {
|
|
10
|
+
import { checkUpdates } from "./watcher.ts";
|
|
11
|
+
import { syncCatalog } from "./catalog.ts";
|
|
12
|
+
import { openDb, searchLocal, catalogList, getSyncMeta, latestVersion, dbPath } from "./db.ts";
|
|
12
13
|
import { NAME_RE } from "./install.ts";
|
|
14
|
+
import {
|
|
15
|
+
VERSION, SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT, NPM_REGISTRY_BASE, SEARCH_PAGE_SIZE, MIRROR_PAGE_DELAY_MS,
|
|
16
|
+
} from "./constants.ts";
|
|
13
17
|
|
|
14
18
|
const SOURCE_RE = /^(npm:[A-Za-z0-9@._/-]+|git:[A-Za-z0-9@:._/-]+|https:\/\/[A-Za-z0-9@:._/?=&%~-]+)$/;
|
|
15
19
|
|
|
16
20
|
const USAGE = `packed — package service for the Pi agent
|
|
17
21
|
|
|
18
22
|
usage:
|
|
19
|
-
packed search <query> [--
|
|
23
|
+
packed search <query> [--offline] [--json] search npm (or the local mirror with --offline)
|
|
20
24
|
packed info <name> [--json] package details
|
|
21
|
-
packed updates [--
|
|
25
|
+
packed updates [--json] updates per the local mirror
|
|
26
|
+
packed mirror [--json] sync upstream into the local SQLite index
|
|
22
27
|
packed installed [--json] installed pi packages
|
|
23
|
-
packed catalog [--json]
|
|
28
|
+
packed catalog [--json] local package index (apt-cache stats)
|
|
24
29
|
packed install <source> pi install npm:|git:|https://…
|
|
25
30
|
packed remove <name> remove by bare npm name
|
|
26
31
|
packed serve run the long-running daemon
|
|
@@ -46,17 +51,19 @@ interface Flags {
|
|
|
46
51
|
json: boolean;
|
|
47
52
|
limit: number;
|
|
48
53
|
cached: boolean;
|
|
54
|
+
offline: boolean;
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
function parseFlags(rest: string[]): { flags: Flags; pos: string[] } {
|
|
52
|
-
const flags: Flags = { json: false, limit:
|
|
58
|
+
const flags: Flags = { json: false, limit: SEARCH_DEFAULT_LIMIT, cached: false, offline: false };
|
|
53
59
|
const pos: string[] = [];
|
|
54
60
|
for (let i = 0; i < rest.length; i++) {
|
|
55
61
|
const a = rest[i]!;
|
|
56
62
|
if (a === "--json") flags.json = true;
|
|
57
63
|
else if (a === "--cached") flags.cached = true;
|
|
58
|
-
else if (a === "--
|
|
59
|
-
else if (a
|
|
64
|
+
else if (a === "--offline") flags.offline = true;
|
|
65
|
+
else if (a === "--limit" && i + 1 < rest.length) flags.limit = Number(rest[++i]) || SEARCH_DEFAULT_LIMIT;
|
|
66
|
+
else if (a.startsWith("--limit=")) flags.limit = Number(a.slice(8)) || SEARCH_DEFAULT_LIMIT;
|
|
60
67
|
else pos.push(a);
|
|
61
68
|
}
|
|
62
69
|
return { flags, pos };
|
|
@@ -70,11 +77,26 @@ const usageErr = (out: string): CliResult => ({ code: 2, out });
|
|
|
70
77
|
|
|
71
78
|
const commands: Record<string, { usage: string; run: Command }> = {
|
|
72
79
|
search: {
|
|
73
|
-
usage: "packed search <query> [--limit N] [--json]",
|
|
80
|
+
usage: "packed search <query> [--offline] [--limit N] [--json]",
|
|
74
81
|
async run(_rest, d, flags, pos) {
|
|
75
82
|
const q = pos[0];
|
|
76
83
|
if (!q) return usageErr(`usage: ${commands["search"]!.usage}\n`);
|
|
77
|
-
const
|
|
84
|
+
const limit = clampLimit(flags.limit, SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT);
|
|
85
|
+
// --offline: query the SQLite mirror only (apt-cache search analog)
|
|
86
|
+
if (flags.offline) {
|
|
87
|
+
const db = openDb(dbPath(d.stateDir));
|
|
88
|
+
try {
|
|
89
|
+
const results = searchLocal(db, q, limit);
|
|
90
|
+
if (flags.json) return ok(JSON.stringify({ query: q, total: results.length, results, offline: true }) + "\n");
|
|
91
|
+
if (results.length === 0) return ok(`no mirrored packages match "${q}" (run: packed mirror)\n`);
|
|
92
|
+
let out = `${results.length} mirrored package(s):\n\n`;
|
|
93
|
+
for (const p of results) out += ` ${p.name}@${p.version}\n ${p.description ?? ""}\n`;
|
|
94
|
+
return ok(out);
|
|
95
|
+
} finally {
|
|
96
|
+
db.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const { results, total } = await d.reg.search(buildSearchQuery(q), limit);
|
|
78
100
|
if (flags.json) return ok(JSON.stringify({ query: q, total, results }) + "\n");
|
|
79
101
|
if (results.length === 0) return ok(`no pi packages found for "${q}"\n`);
|
|
80
102
|
let out = `${total} package(s) (showing ${results.length}):\n\n`;
|
|
@@ -83,6 +105,15 @@ const commands: Record<string, { usage: string; run: Command }> = {
|
|
|
83
105
|
},
|
|
84
106
|
},
|
|
85
107
|
|
|
108
|
+
mirror: {
|
|
109
|
+
usage: "packed mirror [--json] (sync the upstream registry into the local SQLite index — the apt update analog)",
|
|
110
|
+
async run(_rest, d, flags) {
|
|
111
|
+
const n = await syncCatalog(d.reg, d.stateDir);
|
|
112
|
+
if (flags.json) return ok(JSON.stringify({ synced: n }) + "\n");
|
|
113
|
+
return ok(`mirrored ${n} packages into the local index\n`);
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
|
|
86
117
|
info: {
|
|
87
118
|
usage: "packed info <name> [--json]",
|
|
88
119
|
async run(_rest, d, flags, pos) {
|
|
@@ -98,18 +129,19 @@ const commands: Record<string, { usage: string; run: Command }> = {
|
|
|
98
129
|
},
|
|
99
130
|
|
|
100
131
|
updates: {
|
|
101
|
-
usage: "packed updates [--
|
|
132
|
+
usage: "packed updates [--json] (from the local mirror — run `packed mirror` first)",
|
|
102
133
|
async run(_rest, d, flags) {
|
|
134
|
+
const db = openDb(dbPath(d.stateDir));
|
|
103
135
|
let updates;
|
|
104
|
-
|
|
105
|
-
updates = (
|
|
106
|
-
}
|
|
107
|
-
|
|
136
|
+
try {
|
|
137
|
+
updates = checkUpdates((name) => latestVersion(db, name), readInstalledPackages(d.piHome));
|
|
138
|
+
} finally {
|
|
139
|
+
db.close();
|
|
108
140
|
}
|
|
109
141
|
if (flags.json) {
|
|
110
142
|
return ok(JSON.stringify({ checkedAt: new Date().toISOString(), updates }) + "\n");
|
|
111
143
|
}
|
|
112
|
-
if (updates.length === 0) return ok("all pi packages up to date\n");
|
|
144
|
+
if (updates.length === 0) return ok("all pi packages up to date (per the local mirror)\n");
|
|
113
145
|
let out = `${updates.length} update(s) available:\n\n`;
|
|
114
146
|
for (const u of updates) out += ` ${u.name} ${u.installed} → ${u.latest}\n`;
|
|
115
147
|
return ok(out + "\nrun: pi update --extensions\n");
|
|
@@ -128,12 +160,21 @@ const commands: Record<string, { usage: string; run: Command }> = {
|
|
|
128
160
|
catalog: {
|
|
129
161
|
usage: "packed catalog [--json]",
|
|
130
162
|
async run(_rest, d, flags) {
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
163
|
+
const db = openDb(dbPath(d.stateDir));
|
|
164
|
+
try {
|
|
165
|
+
const meta = getSyncMeta(db);
|
|
166
|
+
const packages = catalogList(db);
|
|
167
|
+
if (flags.json) {
|
|
168
|
+
return ok(JSON.stringify({ fetchedAt: meta?.fetchedAt, sha256: meta?.sha256, packages }) + "\n");
|
|
169
|
+
}
|
|
170
|
+
let out = `${packages.length} packages in the local index`;
|
|
171
|
+
if (meta) out += ` (synced ${meta.fetchedAt}, sha256:${meta.sha256.slice(0, 12)}…)`;
|
|
172
|
+
out += "\n\n";
|
|
173
|
+
for (const p of packages.slice(0, 50)) out += ` ${p.name}@${p.version}\n`;
|
|
174
|
+
return ok(out);
|
|
175
|
+
} finally {
|
|
176
|
+
db.close();
|
|
177
|
+
}
|
|
137
178
|
},
|
|
138
179
|
},
|
|
139
180
|
|
|
@@ -175,7 +216,7 @@ const commands: Record<string, { usage: string; run: Command }> = {
|
|
|
175
216
|
version: {
|
|
176
217
|
usage: "packed version",
|
|
177
218
|
async run() {
|
|
178
|
-
return ok("
|
|
219
|
+
return ok(VERSION + "\n");
|
|
179
220
|
},
|
|
180
221
|
},
|
|
181
222
|
};
|
|
@@ -226,7 +267,11 @@ if (import.meta.main) {
|
|
|
226
267
|
const { resolveRegistry } = await import("./client.ts");
|
|
227
268
|
const { ExecInstaller } = await import("./install.ts");
|
|
228
269
|
const dir = stateDir();
|
|
229
|
-
|
|
270
|
+
// mirror talks to UPSTREAM, not the daemon cache — apt update semantics.
|
|
271
|
+
const reg =
|
|
272
|
+
args[0] === "mirror"
|
|
273
|
+
? new (await import("./registry.ts")).HttpRegistry(NPM_REGISTRY_BASE, SEARCH_PAGE_SIZE, MIRROR_PAGE_DELAY_MS)
|
|
274
|
+
: await resolveRegistry(dir, NPM_REGISTRY_BASE);
|
|
230
275
|
const { code, out } = await cliRun(args, {
|
|
231
276
|
reg,
|
|
232
277
|
inst: new ExecInstaller(),
|
package/src/client.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { readFileSync } from "node:fs";
|
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import type { PkgInfo, Registry, SearchPage } from "./ports.ts";
|
|
9
9
|
import { HttpRegistry } from "./registry.ts";
|
|
10
|
+
import { DAEMON_HOST, PROBE_TIMEOUT_MS, REGISTRY_FETCH_TIMEOUT_MS, PORT_FILE, TOKEN_FILE } from "./constants.ts";
|
|
10
11
|
|
|
11
12
|
export class DaemonRegistry implements Registry {
|
|
12
13
|
constructor(
|
|
@@ -17,7 +18,7 @@ export class DaemonRegistry implements Registry {
|
|
|
17
18
|
private async get<T>(path: string): Promise<T> {
|
|
18
19
|
const res = await fetch(`${this.base}${path}`, {
|
|
19
20
|
headers: { authorization: `Bearer ${this.token}` },
|
|
20
|
-
signal: AbortSignal.timeout(
|
|
21
|
+
signal: AbortSignal.timeout(REGISTRY_FETCH_TIMEOUT_MS),
|
|
21
22
|
});
|
|
22
23
|
if (!res.ok) throw new Error(`daemon HTTP ${res.status}`);
|
|
23
24
|
return (await res.json()) as T;
|
|
@@ -55,16 +56,16 @@ export async function probe(dir: string): Promise<DaemonHandle | undefined> {
|
|
|
55
56
|
let port: string;
|
|
56
57
|
let token: string;
|
|
57
58
|
try {
|
|
58
|
-
port = readFileSync(join(dir,
|
|
59
|
-
token = readFileSync(join(dir,
|
|
59
|
+
port = readFileSync(join(dir, PORT_FILE), "utf8").trim();
|
|
60
|
+
token = readFileSync(join(dir, TOKEN_FILE), "utf8").trim();
|
|
60
61
|
} catch {
|
|
61
62
|
return undefined;
|
|
62
63
|
}
|
|
63
|
-
const base = `http
|
|
64
|
+
const base = `http://${DAEMON_HOST}:${port}`;
|
|
64
65
|
try {
|
|
65
66
|
const res = await fetch(`${base}/health`, {
|
|
66
67
|
headers: { authorization: `Bearer ${token}` },
|
|
67
|
-
signal: AbortSignal.timeout(
|
|
68
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
68
69
|
});
|
|
69
70
|
if (res.ok) return { base, token };
|
|
70
71
|
} catch {
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* constants.ts — every magic value in one place, named.
|
|
3
|
+
* (lexicon: replace-magic-number-with-symbolic-constant)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// --- Upstream ---
|
|
7
|
+
export const NPM_REGISTRY_BASE = "https://registry.npmjs.org";
|
|
8
|
+
|
|
9
|
+
// --- Search / pagination ---
|
|
10
|
+
export const SEARCH_DEFAULT_LIMIT = 10;
|
|
11
|
+
export const SEARCH_MAX_LIMIT = 50;
|
|
12
|
+
export const SEARCH_PAGE_SIZE = 250; // npm registry max page size
|
|
13
|
+
export const PI_PACKAGE_KEYWORD = "keywords:pi-package";
|
|
14
|
+
|
|
15
|
+
// --- Upstream etiquette (429s) ---
|
|
16
|
+
export const RETRY_MAX_ATTEMPTS = 6;
|
|
17
|
+
export const RETRY_BASE_DELAY_MS = 2_000; // 2+4+8+16+32s spans npm's ~60s search window
|
|
18
|
+
export const PAGE_DELAY_MS = 100; // politeness pause between catalog pages
|
|
19
|
+
export const MIRROR_PAGE_DELAY_MS = 400; // manual full-sync: extra polite (burst limits)
|
|
20
|
+
|
|
21
|
+
// --- Cache / fetch ---
|
|
22
|
+
export const CACHE_TTL_MS = 5 * 60_000;
|
|
23
|
+
export const PROBE_TIMEOUT_MS = 800;
|
|
24
|
+
export const REGISTRY_FETCH_TIMEOUT_MS = 15_000;
|
|
25
|
+
|
|
26
|
+
// --- Daemon ---
|
|
27
|
+
export const DAEMON_HOST = "127.0.0.1";
|
|
28
|
+
export const WATCH_INTERVAL_DEFAULT_MS = 30 * 60_000; // updates diff cadence
|
|
29
|
+
export const CATALOG_INTERVAL_DEFAULT_MS = 6 * 3_600_000; // full mirror TTL
|
|
30
|
+
export const IDLE_BUDGET_DEFAULT_MS = 10 * 60_000; // on-demand self-exit
|
|
31
|
+
export const WATCHDOG_TICK_MS = 15_000;
|
|
32
|
+
|
|
33
|
+
// --- Identity ---
|
|
34
|
+
export const VERSION = "0.1.0";
|
|
35
|
+
|
|
36
|
+
// --- State-dir file names ---
|
|
37
|
+
export const TOKEN_FILE = "token";
|
|
38
|
+
export const PORT_FILE = "port";
|
|
39
|
+
export const UPDATES_FILE = "updates.json";
|
|
40
|
+
export const DB_FILE = "packed.db";
|
|
41
|
+
export const SETTINGS_FILE = "settings.json";
|
|
42
|
+
|
|
43
|
+
// --- Environment knobs ---
|
|
44
|
+
export const ENV = {
|
|
45
|
+
HOME: "PI_PACKED_HOME",
|
|
46
|
+
PI_HOME: "PI_PACKED_PI_HOME",
|
|
47
|
+
WATCH_SECS: "PI_PACKED_WATCH_SECS",
|
|
48
|
+
CATALOG_SECS: "PI_PACKED_CATALOG_SECS",
|
|
49
|
+
IDLE_SECS: "PI_PACKED_IDLE_SECS",
|
|
50
|
+
} as const;
|
package/src/daemon.ts
CHANGED
|
@@ -9,6 +9,11 @@ import { ExecInstaller } from "./install.ts";
|
|
|
9
9
|
import { loadOrCreateToken, writePort, idleExpired, envMs, stateDir } from "./state.ts";
|
|
10
10
|
import { startWatcher } from "./watcher.ts";
|
|
11
11
|
import { startCatalogSync } from "./catalog.ts";
|
|
12
|
+
import { openDb, latestVersion, dbPath } from "./db.ts";
|
|
13
|
+
import {
|
|
14
|
+
DAEMON_HOST, ENV, WATCH_INTERVAL_DEFAULT_MS, CATALOG_INTERVAL_DEFAULT_MS,
|
|
15
|
+
IDLE_BUDGET_DEFAULT_MS, WATCHDOG_TICK_MS,
|
|
16
|
+
} from "./constants.ts";
|
|
12
17
|
import { readInstalledPackages, defaultPiHome } from "./installed.ts";
|
|
13
18
|
|
|
14
19
|
export function serveMain(): void {
|
|
@@ -20,7 +25,7 @@ export function serveMain(): void {
|
|
|
20
25
|
let lastActive = Date.now();
|
|
21
26
|
const server = Bun.serve({
|
|
22
27
|
port: 0,
|
|
23
|
-
hostname:
|
|
28
|
+
hostname: DAEMON_HOST,
|
|
24
29
|
fetch: (req) => {
|
|
25
30
|
lastActive = Date.now();
|
|
26
31
|
return app.fetch(req);
|
|
@@ -29,14 +34,16 @@ export function serveMain(): void {
|
|
|
29
34
|
if (!server.port) throw new Error("failed to bind listener");
|
|
30
35
|
writePort(dir, server.port);
|
|
31
36
|
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
// Event producer #1: drift detection from the local mirror (default 30min).
|
|
38
|
+
const watcherDb = openDb(dbPath(dir));
|
|
39
|
+
const stopWatcher = startWatcher((name) => latestVersion(watcherDb, name), dir, () => readInstalledPackages(defaultPiHome()), {
|
|
40
|
+
intervalMs: envMs(ENV.WATCH_SECS, WATCH_INTERVAL_DEFAULT_MS),
|
|
34
41
|
});
|
|
35
|
-
const stopCatalog = startCatalogSync(reg, dir, envMs(
|
|
42
|
+
const stopCatalog = startCatalogSync(reg, dir, envMs(ENV.CATALOG_SECS, CATALOG_INTERVAL_DEFAULT_MS));
|
|
36
43
|
|
|
37
44
|
// Idle watchdog: for on-demand spawns. PI_PACKED_IDLE_SECS=0 disables it
|
|
38
45
|
// (systemd or another supervisor owns the lifecycle then).
|
|
39
|
-
const idleBudget = envMs(
|
|
46
|
+
const idleBudget = envMs(ENV.IDLE_SECS, IDLE_BUDGET_DEFAULT_MS);
|
|
40
47
|
let watchdog: ReturnType<typeof setInterval> | undefined;
|
|
41
48
|
if (idleBudget > 0) {
|
|
42
49
|
watchdog = setInterval(() => {
|
|
@@ -44,13 +51,14 @@ export function serveMain(): void {
|
|
|
44
51
|
console.error(`[packed] idle for ${idleBudget / 1000}s, exiting`);
|
|
45
52
|
shutdown();
|
|
46
53
|
}
|
|
47
|
-
},
|
|
54
|
+
}, WATCHDOG_TICK_MS);
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
function shutdown(): void {
|
|
51
58
|
if (watchdog) clearInterval(watchdog);
|
|
52
59
|
stopWatcher();
|
|
53
60
|
stopCatalog();
|
|
61
|
+
watcherDb.close();
|
|
54
62
|
server.stop(true);
|
|
55
63
|
process.exit(0);
|
|
56
64
|
}
|
|
@@ -58,6 +66,6 @@ export function serveMain(): void {
|
|
|
58
66
|
process.on("SIGINT", shutdown);
|
|
59
67
|
|
|
60
68
|
console.error(
|
|
61
|
-
`[packed] listening on
|
|
69
|
+
`[packed] listening on ${DAEMON_HOST}:${server.port} (state ${dir}, watch ${envMs(ENV.WATCH_SECS, WATCH_INTERVAL_DEFAULT_MS) / 1000}s, idle ${idleBudget / 1000}s)`,
|
|
62
70
|
);
|
|
63
71
|
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* db.ts — the local registry index (SQLite), the pkgng/YUM model.
|
|
3
|
+
* Master-index role: sync_meta (source, time, count, payload checksum —
|
|
4
|
+
* the APT Release / repomd.xml analog). Payload: packages + FTS5 mirror.
|
|
5
|
+
*/
|
|
6
|
+
import { Database } from "bun:sqlite";
|
|
7
|
+
import { mkdirSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import type { Pkg } from "./ports.ts";
|
|
11
|
+
import { DB_FILE } from "./constants.ts";
|
|
12
|
+
|
|
13
|
+
export interface SyncMeta {
|
|
14
|
+
source: string;
|
|
15
|
+
fetchedAt: string;
|
|
16
|
+
packageCount: number;
|
|
17
|
+
sha256: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function dbPath(dir: string): string {
|
|
21
|
+
return join(dir, DB_FILE);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function openDb(path: string): Database {
|
|
25
|
+
if (path !== ":memory:") mkdirSync(join(path, ".."), { recursive: true });
|
|
26
|
+
const db = new Database(path, { create: true });
|
|
27
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
28
|
+
db.exec(`CREATE TABLE IF NOT EXISTS packages (
|
|
29
|
+
name TEXT PRIMARY KEY,
|
|
30
|
+
version TEXT NOT NULL DEFAULT '',
|
|
31
|
+
description TEXT,
|
|
32
|
+
date TEXT
|
|
33
|
+
)`);
|
|
34
|
+
db.exec(`CREATE TABLE IF NOT EXISTS sync_meta (
|
|
35
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
36
|
+
source TEXT NOT NULL,
|
|
37
|
+
fetched_at TEXT NOT NULL,
|
|
38
|
+
package_count INTEGER NOT NULL,
|
|
39
|
+
sha256 TEXT NOT NULL
|
|
40
|
+
)`);
|
|
41
|
+
// FTS5 mirror; rebuilt wholesale on each sync.
|
|
42
|
+
db.exec(
|
|
43
|
+
`CREATE VIRTUAL TABLE IF NOT EXISTS packages_fts USING fts5(name, description)`,
|
|
44
|
+
);
|
|
45
|
+
return db;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Canonical checksum over the payload — the Release-file analog: cheap
|
|
49
|
+
* change detection between syncs and integrity for the local mirror. */
|
|
50
|
+
function payloadHash(pkgs: Pkg[]): string {
|
|
51
|
+
const h = createHash("sha256");
|
|
52
|
+
for (const p of pkgs) h.update(`${p.name}@${p.version}\n`);
|
|
53
|
+
return h.digest("hex");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Atomic full-catalog replace (the mirror write side of `apt update`). */
|
|
57
|
+
export function replaceAll(db: Database, pkgs: Pkg[], source: string): SyncMeta {
|
|
58
|
+
const meta: SyncMeta = {
|
|
59
|
+
source,
|
|
60
|
+
fetchedAt: new Date().toISOString(),
|
|
61
|
+
packageCount: pkgs.length,
|
|
62
|
+
sha256: payloadHash(pkgs),
|
|
63
|
+
};
|
|
64
|
+
// INSERT OR REPLACE: npm pagination is unstable — rankings shift between
|
|
65
|
+
// pages and a package can appear twice in one sync (rowid reuse is fine).
|
|
66
|
+
const insert = db.prepare("INSERT OR REPLACE INTO packages (name, version, description, date) VALUES (?, ?, ?, ?)");
|
|
67
|
+
const insertFts = db.prepare("INSERT INTO packages_fts (rowid, name, description) VALUES (?, ?, ?)");
|
|
68
|
+
db.transaction(() => {
|
|
69
|
+
db.exec("DELETE FROM packages");
|
|
70
|
+
db.exec("DELETE FROM packages_fts");
|
|
71
|
+
for (const p of pkgs) {
|
|
72
|
+
const { lastInsertRowid } = insert.run(p.name, p.version, p.description ?? null, p.date ?? null);
|
|
73
|
+
insertFts.run(Number(lastInsertRowid), p.name, p.description ?? "");
|
|
74
|
+
}
|
|
75
|
+
db.prepare(
|
|
76
|
+
"INSERT INTO sync_meta (id, source, fetched_at, package_count, sha256) VALUES (1, ?, ?, ?, ?) " +
|
|
77
|
+
"ON CONFLICT(id) DO UPDATE SET source=excluded.source, fetched_at=excluded.fetched_at, " +
|
|
78
|
+
"package_count=excluded.package_count, sha256=excluded.sha256",
|
|
79
|
+
).run(meta.source, meta.fetchedAt, meta.packageCount, meta.sha256);
|
|
80
|
+
})();
|
|
81
|
+
return meta;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getSyncMeta(db: Database): SyncMeta | undefined {
|
|
85
|
+
return db
|
|
86
|
+
.query("SELECT source, fetched_at AS fetchedAt, package_count AS packageCount, sha256 FROM sync_meta WHERE id = 1")
|
|
87
|
+
.get() as SyncMeta | undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function catalogList(db: Database, limit = 0, offset = 0): Pkg[] {
|
|
91
|
+
const sql =
|
|
92
|
+
"SELECT name, version, description, date FROM packages ORDER BY name" +
|
|
93
|
+
(limit > 0 ? ` LIMIT ${Math.floor(limit)} OFFSET ${Math.floor(offset)}` : "");
|
|
94
|
+
return db.query(sql).all() as Pkg[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** FTS5 over the mirror (the `apt-cache search` analog). Sanitizes user
|
|
98
|
+
* input into AND-joined quoted terms; falls back to LIKE for hostile input. */
|
|
99
|
+
export function searchLocal(db: Database, q: string, limit = 50): Pkg[] {
|
|
100
|
+
const terms = q.trim().split(/\s+/).filter(Boolean);
|
|
101
|
+
if (terms.length === 0) return [];
|
|
102
|
+
try {
|
|
103
|
+
const match = terms.map((t) => `"${t.replaceAll('"', '""')}"*`).join(" ");
|
|
104
|
+
return db
|
|
105
|
+
.query(
|
|
106
|
+
`SELECT p.name, p.version, p.description, p.date
|
|
107
|
+
FROM packages_fts f JOIN packages p ON p.rowid = f.rowid
|
|
108
|
+
WHERE packages_fts MATCH ? ORDER BY rank LIMIT ?`,
|
|
109
|
+
)
|
|
110
|
+
.all(match, limit) as Pkg[];
|
|
111
|
+
} catch {
|
|
112
|
+
// FTS syntax hostility → substring fallback
|
|
113
|
+
const like = `%${q}%`;
|
|
114
|
+
return db
|
|
115
|
+
.query("SELECT name, version, description, date FROM packages WHERE name LIKE ? OR description LIKE ? ORDER BY name LIMIT ?")
|
|
116
|
+
.all(like, like, limit) as Pkg[];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Latest mirrored version of one package (watcher's lookup). */
|
|
121
|
+
export function latestVersion(db: Database, name: string): string | undefined {
|
|
122
|
+
const row = db.query("SELECT version FROM packages WHERE name = ?").get(name) as { version: string } | null;
|
|
123
|
+
return row?.version;
|
|
124
|
+
}
|
package/src/installed.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
+
import { ENV, SETTINGS_FILE } from "./constants.ts";
|
|
6
7
|
import type { InstalledPkg } from "./ports.ts";
|
|
7
8
|
|
|
8
9
|
export function splitNpmSource(spec: string): [name: string, version: string] {
|
|
@@ -32,7 +33,7 @@ function nodeModulesVersion(piHome: string, name: string): string | undefined {
|
|
|
32
33
|
export function readInstalledPackages(piHome: string): InstalledPkg[] {
|
|
33
34
|
let settings: { packages?: unknown[] };
|
|
34
35
|
try {
|
|
35
|
-
settings = JSON.parse(readFileSync(join(piHome,
|
|
36
|
+
settings = JSON.parse(readFileSync(join(piHome, SETTINGS_FILE), "utf8"));
|
|
36
37
|
} catch {
|
|
37
38
|
return [];
|
|
38
39
|
}
|
|
@@ -51,6 +52,7 @@ export function readInstalledPackages(piHome: string): InstalledPkg[] {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
export function defaultPiHome(): string {
|
|
54
|
-
|
|
55
|
+
const envHome = process.env[ENV.PI_HOME];
|
|
56
|
+
if (envHome) return envHome;
|
|
55
57
|
return join(homedir(), ".pi", "agent");
|
|
56
58
|
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* log.ts — structured logging for the service. JSON lines to stderr
|
|
3
|
+
* (stdout belongs to CLI output; the TUI owns the terminal UI).
|
|
4
|
+
* Level via PI_PACKED_LOG_LEVEL (debug|info|warn|error, default info).
|
|
5
|
+
* Sinks are injectable so tests can capture without a terminal.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
9
|
+
export type LogSink = (line: string) => void;
|
|
10
|
+
|
|
11
|
+
const ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
12
|
+
const LEVELS = new Set(Object.keys(ORDER));
|
|
13
|
+
|
|
14
|
+
export interface LogFields {
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Logger {
|
|
19
|
+
debug(msg: string, fields?: LogFields): void;
|
|
20
|
+
info(msg: string, fields?: LogFields): void;
|
|
21
|
+
warn(msg: string, fields?: LogFields): void;
|
|
22
|
+
error(msg: string, fields?: LogFields): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function envLevel(): LogLevel {
|
|
26
|
+
const raw = process.env["PI_PACKED_LOG_LEVEL"] ?? "info";
|
|
27
|
+
return (LEVELS.has(raw) ? raw : "info") as LogLevel;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createLogger(module: string, sink: LogSink = (l) => console.error(l), minLevel?: LogLevel): Logger {
|
|
31
|
+
const threshold = ORDER[minLevel ?? envLevel()];
|
|
32
|
+
function emit(level: LogLevel, msg: string, fields?: LogFields): void {
|
|
33
|
+
if (ORDER[level] < threshold) return;
|
|
34
|
+
sink(JSON.stringify({ ts: new Date().toISOString(), level, module, msg, ...fields }));
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
debug: (m, f) => emit("debug", m, f),
|
|
38
|
+
info: (m, f) => emit("info", m, f),
|
|
39
|
+
warn: (m, f) => emit("warn", m, f),
|
|
40
|
+
error: (m, f) => emit("error", m, f),
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/ports.ts
CHANGED
|
@@ -62,10 +62,12 @@ export interface UpdatesSnapshot {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/** Scope every query to pi packages unless the caller already qualified it. */
|
|
65
|
+
import { PI_PACKAGE_KEYWORD } from "./constants.ts";
|
|
66
|
+
|
|
65
67
|
export function buildSearchQuery(q: string): string {
|
|
66
68
|
const t = q.trim();
|
|
67
69
|
if (t.includes("keywords:")) return t;
|
|
68
|
-
return t === "" ?
|
|
70
|
+
return t === "" ? PI_PACKAGE_KEYWORD : `${PI_PACKAGE_KEYWORD} ${t}`;
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
export function clampLimit(v: number, def: number, max: number): number {
|
package/src/registry.ts
CHANGED
|
@@ -3,11 +3,50 @@
|
|
|
3
3
|
* Lean mapping = Facade over npm's verbose package documents.
|
|
4
4
|
*/
|
|
5
5
|
import type { Pkg, PkgInfo, Registry, SearchPage } from "./ports.ts";
|
|
6
|
+
import {
|
|
7
|
+
NPM_REGISTRY_BASE, SEARCH_PAGE_SIZE, RETRY_MAX_ATTEMPTS, RETRY_BASE_DELAY_MS, PAGE_DELAY_MS,
|
|
8
|
+
} from "./constants.ts";
|
|
9
|
+
import { createLogger } from "./log.ts";
|
|
10
|
+
|
|
11
|
+
const log = createLogger("registry");
|
|
12
|
+
|
|
13
|
+
/** Upstream etiquette: honor Retry-After on 429, exponential backoff
|
|
14
|
+
* otherwise, give up after RETRY_MAX_ATTEMPTS. */
|
|
15
|
+
async function fetchWithRetry(url: string, init: RequestInit | undefined, baseDelayMs: number): Promise<Response> {
|
|
16
|
+
let lastErr: Error | undefined;
|
|
17
|
+
for (let attempt = 1; attempt <= RETRY_MAX_ATTEMPTS; attempt++) {
|
|
18
|
+
const t0 = Date.now();
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(url, init);
|
|
21
|
+
const ms = Date.now() - t0;
|
|
22
|
+
if (res.status === 429 && attempt < RETRY_MAX_ATTEMPTS) {
|
|
23
|
+
const ra = res.headers.get("retry-after");
|
|
24
|
+
const delayMs =
|
|
25
|
+
ra !== null && Number(ra) > 0
|
|
26
|
+
? Number(ra) * 1000 // authoritative only when positive
|
|
27
|
+
: baseDelayMs * 2 ** (attempt - 1); // npm sends 0: use exponential
|
|
28
|
+
log.warn("429 rate-limited, backing off", { attempt, delayMs, ms, url: url.slice(0, 120) });
|
|
29
|
+
await Bun.sleep(delayMs);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
log.debug("fetch", { status: res.status, ms, attempt, url: url.slice(0, 120) });
|
|
33
|
+
return res;
|
|
34
|
+
} catch (e) {
|
|
35
|
+
lastErr = e instanceof Error ? e : new Error(String(e));
|
|
36
|
+
log.warn("fetch error, retrying", { attempt, error: lastErr.message, url: url.slice(0, 120) });
|
|
37
|
+
if (attempt < RETRY_MAX_ATTEMPTS) await Bun.sleep(baseDelayMs * 2 ** (attempt - 1));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
log.error("retry budget exhausted", { attempts: RETRY_MAX_ATTEMPTS, url: url.slice(0, 120) });
|
|
41
|
+
throw lastErr ?? new Error("retry budget exhausted");
|
|
42
|
+
}
|
|
6
43
|
|
|
7
44
|
export class HttpRegistry implements Registry {
|
|
8
45
|
constructor(
|
|
9
|
-
private base =
|
|
10
|
-
private pageSize =
|
|
46
|
+
private base = NPM_REGISTRY_BASE,
|
|
47
|
+
private pageSize = SEARCH_PAGE_SIZE,
|
|
48
|
+
private pageDelayMs = PAGE_DELAY_MS,
|
|
49
|
+
private retryBaseDelayMs = RETRY_BASE_DELAY_MS,
|
|
11
50
|
) {}
|
|
12
51
|
|
|
13
52
|
async search(query: string, limit: number): Promise<SearchPage> {
|
|
@@ -16,7 +55,7 @@ export class HttpRegistry implements Registry {
|
|
|
16
55
|
|
|
17
56
|
async searchPage(query: string, from: number, size: number): Promise<SearchPage> {
|
|
18
57
|
const params = new URLSearchParams({ text: query, size: String(size), from: String(from) });
|
|
19
|
-
const res = await
|
|
58
|
+
const res = await fetchWithRetry(`${this.base}/-/v1/search?${params}`, undefined, this.retryBaseDelayMs);
|
|
20
59
|
if (!res.ok) throw new Error(`npm search: HTTP ${res.status}`);
|
|
21
60
|
const doc = (await res.json()) as {
|
|
22
61
|
total?: number;
|
|
@@ -32,20 +71,28 @@ export class HttpRegistry implements Registry {
|
|
|
32
71
|
}
|
|
33
72
|
|
|
34
73
|
async searchAll(query: string): Promise<Pkg[]> {
|
|
35
|
-
|
|
74
|
+
// Map by name: npm's ranking shifts mid-pagination and a package can
|
|
75
|
+
// appear on two pages — first occurrence wins.
|
|
76
|
+
const byName = new Map<string, Pkg>();
|
|
36
77
|
let from = 0;
|
|
37
78
|
for (;;) {
|
|
79
|
+
if (from > 0 && this.pageDelayMs > 0) await Bun.sleep(this.pageDelayMs);
|
|
38
80
|
const { results, total } = await this.searchPage(query, from, this.pageSize);
|
|
39
|
-
|
|
81
|
+
if (results.length === 0 || from >= total) break;
|
|
82
|
+
for (const p of results) {
|
|
83
|
+
if (!byName.has(p.name)) byName.set(p.name, p);
|
|
84
|
+
}
|
|
40
85
|
from += results.length;
|
|
41
|
-
if (results.length === 0 || from >= total) return out;
|
|
42
86
|
}
|
|
87
|
+
return [...byName.values()];
|
|
43
88
|
}
|
|
44
89
|
|
|
45
90
|
async info(name: string): Promise<PkgInfo> {
|
|
46
|
-
const res = await
|
|
47
|
-
|
|
48
|
-
|
|
91
|
+
const res = await fetchWithRetry(
|
|
92
|
+
`${this.base}/${encodeURIComponent(name).replace("%2F", "/")}`,
|
|
93
|
+
{ headers: { accept: "application/vnd.npm.install-v1+json" } },
|
|
94
|
+
this.retryBaseDelayMs,
|
|
95
|
+
);
|
|
49
96
|
if (!res.ok) throw new Error(`npm info ${name}: HTTP ${res.status}`);
|
|
50
97
|
const doc = (await res.json()) as {
|
|
51
98
|
name?: string;
|
package/src/service.ts
CHANGED
|
@@ -7,7 +7,11 @@ import { buildSearchQuery, clampLimit } from "./ports.ts";
|
|
|
7
7
|
import type { Installer, Registry } from "./ports.ts";
|
|
8
8
|
import { TTLCache } from "./cache.ts";
|
|
9
9
|
import { loadUpdates } from "./watcher.ts";
|
|
10
|
-
import {
|
|
10
|
+
import { openDb, searchLocal, catalogList, getSyncMeta, dbPath } from "./db.ts";
|
|
11
|
+
import { VERSION, SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT } from "./constants.ts";
|
|
12
|
+
import { createLogger } from "./log.ts";
|
|
13
|
+
|
|
14
|
+
const log = createLogger("service");
|
|
11
15
|
|
|
12
16
|
export interface Deps {
|
|
13
17
|
reg: Registry;
|
|
@@ -35,12 +39,22 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
35
39
|
const path = url.pathname;
|
|
36
40
|
|
|
37
41
|
if (path === "/health" && req.method === "GET") {
|
|
38
|
-
return json({ ok: true, version:
|
|
42
|
+
return json({ ok: true, version: VERSION });
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
if (path === "/search" && req.method === "GET") {
|
|
42
46
|
const q = url.searchParams.get("q") ?? "";
|
|
43
|
-
const limit = clampLimit(Number(url.searchParams.get("limit")),
|
|
47
|
+
const limit = clampLimit(Number(url.searchParams.get("limit")), SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT);
|
|
48
|
+
// offline=1: serve from the SQLite mirror (apt-cache search analog)
|
|
49
|
+
if (url.searchParams.get("offline") === "1") {
|
|
50
|
+
const db = openDb(dbPath(deps.stateDir));
|
|
51
|
+
try {
|
|
52
|
+
const results = searchLocal(db, q, limit);
|
|
53
|
+
return json({ query: q, total: results.length, results, offline: true });
|
|
54
|
+
} finally {
|
|
55
|
+
db.close();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
44
58
|
try {
|
|
45
59
|
const { results, total } = await deps.reg.search(buildSearchQuery(q), limit);
|
|
46
60
|
return json({ query: q, total, results });
|
|
@@ -84,8 +98,13 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
84
98
|
}
|
|
85
99
|
|
|
86
100
|
if (path === "/catalog" && req.method === "GET") {
|
|
87
|
-
const
|
|
88
|
-
|
|
101
|
+
const db = openDb(dbPath(deps.stateDir));
|
|
102
|
+
try {
|
|
103
|
+
const meta = getSyncMeta(db);
|
|
104
|
+
return json({ fetchedAt: meta?.fetchedAt, sha256: meta?.sha256, packages: catalogList(db) });
|
|
105
|
+
} finally {
|
|
106
|
+
db.close();
|
|
107
|
+
}
|
|
89
108
|
}
|
|
90
109
|
|
|
91
110
|
return err(404, "not found");
|
|
@@ -93,6 +112,7 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
93
112
|
|
|
94
113
|
return {
|
|
95
114
|
async fetch(req: Request): Promise<Response> {
|
|
115
|
+
const t0 = Date.now();
|
|
96
116
|
if (req.headers.get("authorization") !== `Bearer ${deps.token}`) {
|
|
97
117
|
return err(401, "missing or invalid bearer token");
|
|
98
118
|
}
|
|
@@ -100,13 +120,17 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
100
120
|
if (req.method === "GET" && !["/health", "/updates", "/catalog"].includes(new URL(req.url).pathname)) {
|
|
101
121
|
const hit = cache.get(req.url);
|
|
102
122
|
if (hit) {
|
|
123
|
+
log.debug("request", { path: new URL(req.url).pathname, cache: "hit", ms: Date.now() - t0 });
|
|
103
124
|
return new Response(hit, { headers: { "content-type": "application/json", "x-cache": "hit" } });
|
|
104
125
|
}
|
|
105
126
|
const res = await route(req);
|
|
106
127
|
if (res.status === 200) cache.set(req.url, await res.clone().text());
|
|
128
|
+
log.debug("request", { path: new URL(req.url).pathname, status: res.status, cache: "miss", ms: Date.now() - t0 });
|
|
107
129
|
return res;
|
|
108
130
|
}
|
|
109
|
-
|
|
131
|
+
const res = await route(req);
|
|
132
|
+
log.debug("request", { path: new URL(req.url).pathname, status: res.status, ms: Date.now() - t0 });
|
|
133
|
+
return res;
|
|
110
134
|
},
|
|
111
135
|
};
|
|
112
136
|
}
|
package/src/state.ts
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { ENV, TOKEN_FILE, PORT_FILE } from "./constants.ts";
|
|
5
6
|
import { randomBytes } from "node:crypto";
|
|
6
7
|
|
|
7
8
|
export function stateDir(): string {
|
|
8
|
-
|
|
9
|
+
const envHome = process.env[ENV.HOME];
|
|
10
|
+
if (envHome) return envHome;
|
|
9
11
|
try {
|
|
10
12
|
return join(homedir(), ".cache", "pi-packed");
|
|
11
13
|
} catch {
|
|
@@ -14,7 +16,7 @@ export function stateDir(): string {
|
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export function loadOrCreateToken(dir: string): string {
|
|
17
|
-
const path = join(dir,
|
|
19
|
+
const path = join(dir, TOKEN_FILE);
|
|
18
20
|
try {
|
|
19
21
|
const tok = readFileSync(path, "utf8").trim();
|
|
20
22
|
if (tok) return tok;
|
|
@@ -29,7 +31,7 @@ export function loadOrCreateToken(dir: string): string {
|
|
|
29
31
|
|
|
30
32
|
export function writePort(dir: string, port: number): void {
|
|
31
33
|
mkdirSync(dir, { recursive: true });
|
|
32
|
-
writeFileSync(join(dir,
|
|
34
|
+
writeFileSync(join(dir, PORT_FILE), String(port) + "\n", { mode: 0o600 });
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export function idleExpired(lastActiveMs: number, nowMs: number, budgetMs: number): boolean {
|
package/src/watcher.ts
CHANGED
|
@@ -5,10 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { writeFile, readFile, mkdir } from "node:fs/promises";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
-
import
|
|
8
|
+
import { UPDATES_FILE } from "./constants.ts";
|
|
9
|
+
import { createLogger } from "./log.ts";
|
|
10
|
+
|
|
11
|
+
const log = createLogger("watcher");
|
|
12
|
+
import type { InstalledPkg, UpdateEntry, UpdatesSnapshot } from "./ports.ts";
|
|
9
13
|
|
|
10
14
|
function updatesPath(dir: string): string {
|
|
11
|
-
return join(dir,
|
|
15
|
+
return join(dir, UPDATES_FILE);
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
export async function saveUpdates(dir: string, snap: UpdatesSnapshot): Promise<void> {
|
|
@@ -24,20 +28,18 @@ export async function loadUpdates(dir: string): Promise<UpdatesSnapshot | undefi
|
|
|
24
28
|
}
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
/** Pure diff
|
|
28
|
-
|
|
31
|
+
/** Pure diff against the local mirror (apt list --upgradable semantics):
|
|
32
|
+
* drift = mirrored latest ≠ what we have. The mirror is refreshed by
|
|
33
|
+
* catalogSync; updates are computed offline, exactly like APT. */
|
|
34
|
+
export function checkUpdates(latestOf: (name: string) => string | undefined, installed: InstalledPkg[]): UpdateEntry[] {
|
|
29
35
|
const now = new Date().toISOString();
|
|
30
36
|
const updates: UpdateEntry[] = [];
|
|
31
37
|
for (const p of installed) {
|
|
32
38
|
const have = p.installed || p.pinned;
|
|
33
39
|
if (!have) continue;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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}`);
|
|
40
|
+
const latest = latestOf(p.name);
|
|
41
|
+
if (latest && latest !== have) {
|
|
42
|
+
updates.push({ name: p.name, installed: have, latest, detectedAt: now });
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
45
|
return updates;
|
|
@@ -50,16 +52,16 @@ export interface WatcherOptions {
|
|
|
50
52
|
|
|
51
53
|
/** Producer loop: immediate check, then on a timer. Returns a stop function. */
|
|
52
54
|
export function startWatcher(
|
|
53
|
-
|
|
55
|
+
latestOf: (name: string) => string | undefined,
|
|
54
56
|
stateDir: string,
|
|
55
57
|
readInstalled: () => InstalledPkg[],
|
|
56
58
|
opts: WatcherOptions,
|
|
57
59
|
): () => void {
|
|
58
60
|
async function check(): Promise<void> {
|
|
59
61
|
try {
|
|
60
|
-
const updates =
|
|
62
|
+
const updates = checkUpdates(latestOf, readInstalled());
|
|
61
63
|
await saveUpdates(stateDir, { checkedAt: new Date().toISOString(), updates });
|
|
62
|
-
|
|
64
|
+
log.info("updates check", { updates: updates.length });
|
|
63
65
|
} catch (e) {
|
|
64
66
|
opts.onError?.(e);
|
|
65
67
|
}
|