@danypops/pi-packed 0.19.7 → 0.19.10
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/dist/client.d.ts +109 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1 -0
- package/dist/protocol.d.ts +221 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +1 -0
- package/extension/src/{permission.ts → approval/permission.ts} +1 -1
- package/extension/src/index.ts +1 -1
- package/extension/src/packed.ts +2 -2
- package/extension/src/{discover.ts → tabs/discover.ts} +4 -4
- package/extension/src/{resource-config.ts → tabs/resource-config.ts} +4 -4
- package/extension/src/{security-tui.ts → tabs/security-tui.ts} +3 -3
- package/extension/src/tool-output.ts +1 -1
- package/extension/src/tools.ts +2 -2
- package/extension/src/tui.ts +4 -4
- package/package.json +31 -8
- package/service/schema/pi-setup-v1.schema.json +70 -0
- package/service/setup/danypops-ecosystem.pi-setup.json +15 -0
- package/service/src/adoption/advisories.ts +268 -0
- package/service/src/adoption/check.ts +872 -0
- package/service/src/adoption/commit-freshness.ts +167 -0
- package/service/src/adoption/doctor.ts +135 -0
- package/service/src/adoption/install-validation.ts +187 -0
- package/service/src/adoption/pack.ts +291 -0
- package/service/src/adoption/score.ts +466 -0
- package/service/src/adoption/smoke-child.ts +113 -0
- package/service/src/adoption/smoke.ts +282 -0
- package/service/src/cli/cli.ts +926 -0
- package/service/src/daemon/cleanup.ts +76 -0
- package/service/src/daemon/client.ts +412 -0
- package/service/src/daemon/daemon-service.ts +249 -0
- package/service/src/daemon/daemon.ts +110 -0
- package/service/src/daemon/service.ts +664 -0
- package/service/src/daemon/watcher.ts +92 -0
- package/service/src/index/build-index.ts +256 -0
- package/service/src/packages/catalog.ts +61 -0
- package/service/src/packages/db.ts +224 -0
- package/service/src/packages/install.ts +60 -0
- package/service/src/packages/installed.ts +123 -0
- package/service/src/packages/package.ts +141 -0
- package/service/src/packages/resources.ts +203 -0
- package/service/src/pi/pi-version.ts +171 -0
- package/service/src/public/atomic-json.ts +32 -0
- package/service/src/public/client.ts +277 -0
- package/service/src/public/protocol.ts +169 -0
- package/service/src/publish/publish.ts +855 -0
- package/service/src/registry/registry.ts +246 -0
- package/service/src/security/security.ts +128 -0
- package/service/src/self-update/self-update.ts +148 -0
- package/service/src/setup/setup.ts +761 -0
- package/service/src/shared/atomic-json.ts +33 -0
- package/service/src/shared/cache.ts +21 -0
- package/service/src/shared/constants.ts +73 -0
- package/service/src/shared/log.ts +21 -0
- package/service/src/shared/paths.ts +88 -0
- package/service/src/shared/state.ts +15 -0
- package/service/src/shared/version.ts +46 -0
- package/service/test/advisories.test.ts +287 -0
- package/service/test/check.test.ts +368 -0
- package/service/test/cleanup.test.ts +220 -0
- package/service/test/cli.test.ts +1303 -0
- package/service/test/core.test.ts +181 -0
- package/service/test/daemon-kit-migration.test.ts +181 -0
- package/service/test/daemon-service.test.ts +238 -0
- package/service/test/db.test.ts +178 -0
- package/service/test/doctor.test.ts +234 -0
- package/service/test/domain.test.ts +291 -0
- package/service/test/fixtures/install-validation/broken-package/extension/index.ts +3 -0
- package/service/test/fixtures/install-validation/broken-package/package.json +8 -0
- package/service/test/fixtures/install-validation/healthy-package/extension/index.ts +3 -0
- package/service/test/fixtures/install-validation/healthy-package/package.json +8 -0
- package/service/test/fixtures/install-validation/no-manifest-package/package.json +5 -0
- package/service/test/index.test.ts +353 -0
- package/service/test/install-validation.test.ts +114 -0
- package/service/test/install.test.ts +113 -0
- package/service/test/log.test.ts +42 -0
- package/service/test/pack-score.test.ts +513 -0
- package/service/test/pi-version.test.ts +318 -0
- package/service/test/public-boundary.test.ts +54 -0
- package/service/test/public-client.test.ts +127 -0
- package/service/test/public-consumer.ts +8 -0
- package/service/test/publish.test.ts +333 -0
- package/service/test/registry-contract.test.ts +148 -0
- package/service/test/resources.test.ts +255 -0
- package/service/test/security.test.ts +89 -0
- package/service/test/self-update.test.ts +257 -0
- package/service/test/service.test.ts +555 -0
- package/service/test/setup.test.ts +375 -0
- package/service/test/smoke.test.ts +118 -0
- package/service/test/version.test.ts +37 -0
- package/service/tsconfig.consumer.json +13 -0
- package/service/tsconfig.public.json +12 -0
- /package/extension/src/{reload.ts → approval/reload.ts} +0 -0
- /package/extension/src/{discover-model.ts → tabs/discover-model.ts} +0 -0
|
@@ -0,0 +1,92 @@
|
|
|
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 { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { gt, valid } from "semver";
|
|
9
|
+
import { UPDATES_FILE } from "../shared/constants.ts";
|
|
10
|
+
import { createLogger } from "../shared/log.ts";
|
|
11
|
+
|
|
12
|
+
const log = createLogger("watcher");
|
|
13
|
+
|
|
14
|
+
import type { InstalledPkg, UpdateEntry, UpdatesSnapshot } from "../packages/package.ts";
|
|
15
|
+
|
|
16
|
+
function updatesPath(dir: string): string {
|
|
17
|
+
return join(dir, UPDATES_FILE);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function saveUpdates(dir: string, snap: UpdatesSnapshot): Promise<void> {
|
|
21
|
+
await mkdir(dir, { recursive: true });
|
|
22
|
+
await writeFile(updatesPath(dir), JSON.stringify(snap), { mode: 0o600 });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function loadUpdates(dir: string): Promise<UpdatesSnapshot | undefined> {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(await readFile(updatesPath(dir), "utf8")) as UpdatesSnapshot;
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* True drift only: mirrored latest is a real semver step *ahead* of what's
|
|
35
|
+
* installed, never merely different from it. A plain !== check (this
|
|
36
|
+
* function's own bug until fixed) cannot distinguish "installed is behind
|
|
37
|
+
* latest" from "installed is already ahead of a stale/wrong mirrored
|
|
38
|
+
* latest" -- confirmed live: an installed package whose version had
|
|
39
|
+
* already passed the daemon's own mirrored dist-tags.latest kept showing
|
|
40
|
+
* a permanent, un-clearable "update available" badge pointing at an
|
|
41
|
+
* *older* version. Falls back to the old inequality only when either side
|
|
42
|
+
* isn't parseable semver (a git ref, a literal "latest" tag, etc.) --
|
|
43
|
+
* those aren't comparable at all, so "different" is the only signal left.
|
|
44
|
+
*/
|
|
45
|
+
function isNewer(latest: string, have: string): boolean {
|
|
46
|
+
if (valid(latest, { loose: true }) && valid(have, { loose: true })) return gt(latest, have, { loose: true });
|
|
47
|
+
return latest !== have;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Pure diff against the local mirror (apt list --upgradable semantics):
|
|
51
|
+
* drift = mirrored latest is genuinely newer than what we have. The
|
|
52
|
+
* mirror is refreshed by catalogSync; updates are computed offline,
|
|
53
|
+
* exactly like APT. */
|
|
54
|
+
export function checkUpdates(latestOf: (name: string) => string | undefined, installed: InstalledPkg[]): UpdateEntry[] {
|
|
55
|
+
const now = new Date().toISOString();
|
|
56
|
+
const updates: UpdateEntry[] = [];
|
|
57
|
+
for (const p of installed) {
|
|
58
|
+
const have = p.installed || p.pinned;
|
|
59
|
+
if (!have) continue;
|
|
60
|
+
const latest = latestOf(p.name);
|
|
61
|
+
if (latest && isNewer(latest, have)) {
|
|
62
|
+
updates.push({ name: p.name, installed: have, latest, detectedAt: now, ...(p.scope ? { scope: p.scope } : {}) });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return updates;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface WatcherOptions {
|
|
69
|
+
intervalMs: number;
|
|
70
|
+
onError?: (e: unknown) => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Producer loop: immediate check, then on a timer. Returns a stop function. */
|
|
74
|
+
export function startWatcher(
|
|
75
|
+
latestOf: (name: string) => string | undefined,
|
|
76
|
+
stateDir: string,
|
|
77
|
+
readInstalled: () => InstalledPkg[],
|
|
78
|
+
opts: WatcherOptions,
|
|
79
|
+
): () => void {
|
|
80
|
+
async function check(): Promise<void> {
|
|
81
|
+
try {
|
|
82
|
+
const updates = checkUpdates(latestOf, readInstalled());
|
|
83
|
+
await saveUpdates(stateDir, { checkedAt: new Date().toISOString(), updates });
|
|
84
|
+
log.info("updates check", { updates: updates.length });
|
|
85
|
+
} catch (e) {
|
|
86
|
+
opts.onError?.(e);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
void check();
|
|
90
|
+
const timer = setInterval(() => void check(), opts.intervalMs);
|
|
91
|
+
return () => clearInterval(timer);
|
|
92
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* build-index.ts — batch-generates a versioned snapshot across the whole
|
|
3
|
+
* local catalog (the createrepo/dpkg-scanpackages analog). Incremental:
|
|
4
|
+
* a package new to the catalog or whose npm date has advanced since it
|
|
5
|
+
* was last scored gets a real assessRegistryAdoption() call; a package
|
|
6
|
+
* whose catalog date hasn't moved is carried forward from the prior index
|
|
7
|
+
* untouched, at zero live-call cost. Local generation only -- publishing
|
|
8
|
+
* this file somewhere public is a separate, later decision.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately never calls GitHub: assessRegistryAdoption()'s freshness
|
|
11
|
+
* dimension only takes the git-commit-based path when a caller passes it
|
|
12
|
+
* a candidateCommitAt, and this file never does. Computing that path (a
|
|
13
|
+
* GitHub Commits API call) for every cataloged package would blow through
|
|
14
|
+
* the unauthenticated 60 req/hour limit commit-freshness.ts's own
|
|
15
|
+
* single-attempt design was built around -- bulk generation instead
|
|
16
|
+
* always falls back to the npm publish-date proxy, by construction.
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { type AdoptionReport, assessRegistryAdoption, PI_COMMAND_NAME } from "../adoption/score.ts";
|
|
21
|
+
import { catalogList, dbPath, openDb } from "../packages/db.ts";
|
|
22
|
+
import type { Registry } from "../packages/package.ts";
|
|
23
|
+
import { resolveCurrentPiVersion } from "../pi/pi-version.ts";
|
|
24
|
+
import { writeJsonAtomic } from "../shared/atomic-json.ts";
|
|
25
|
+
import { INDEX_FILE, PAGE_DELAY_MS } from "../shared/constants.ts";
|
|
26
|
+
import { createLogger } from "../shared/log.ts";
|
|
27
|
+
|
|
28
|
+
const log = createLogger("index");
|
|
29
|
+
|
|
30
|
+
export interface PackageIndexEntry {
|
|
31
|
+
name: string;
|
|
32
|
+
version: string;
|
|
33
|
+
dimensions: AdoptionReport["dimensions"];
|
|
34
|
+
/** The catalog's own npm date this entry was scored against -- the
|
|
35
|
+
* per-package staleness watermark the next run compares its current
|
|
36
|
+
* catalog date to. Undefined when the catalog had no date for this
|
|
37
|
+
* package at scoring time (rare; treated as "unknown, always rescore"
|
|
38
|
+
* by the next run rather than assumed unchanged). A single global
|
|
39
|
+
* generatedAt watermark would be wrong here: packages enter the catalog
|
|
40
|
+
* and get scored at different times, so staleness must be per-package. */
|
|
41
|
+
date?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PackageIndex {
|
|
45
|
+
generatedAt: string;
|
|
46
|
+
packages: PackageIndexEntry[];
|
|
47
|
+
/** True when the local catalog held more entries than maxPackages --
|
|
48
|
+
* confirmed live against this project's own real catalog (5,250
|
|
49
|
+
* keywords:pi-package matches, overwhelmingly false positives from the
|
|
50
|
+
* search itself, a separate known issue): an unbounded run hit npm's
|
|
51
|
+
* downloads API 149 times with 429s in under three minutes and would
|
|
52
|
+
* have run for hours. Never silently incomplete -- always stated. */
|
|
53
|
+
truncated: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Confirmed live this needs to stay small: even with downloads() removed,
|
|
57
|
+
* 500 packages against a real npm registry took long enough that two
|
|
58
|
+
* overlapping runs (the daemon's own maintenance-task tick and an
|
|
59
|
+
* on-demand CLI build) compounded into a shutdown the daemon's own
|
|
60
|
+
* SIGTERM grace period couldn't outlast, forcing a SIGABRT/coredump.
|
|
61
|
+
* Small and bounded beats generous here. */
|
|
62
|
+
const DEFAULT_MAX_PACKAGES = 50;
|
|
63
|
+
|
|
64
|
+
// Guards against the exact failure just described: the daemon's scheduled
|
|
65
|
+
// maintenance tick and an on-demand `packed index build` both call into
|
|
66
|
+
// this module inside the same process. Without this, both would run a
|
|
67
|
+
// full generation concurrently against the same npm endpoints, doubling
|
|
68
|
+
// load and compounding retry backoff. A concurrent caller gets the
|
|
69
|
+
// in-flight run's own result instead of starting a second one.
|
|
70
|
+
let inFlight: Promise<PackageIndex> | undefined;
|
|
71
|
+
|
|
72
|
+
export interface IndexStatus {
|
|
73
|
+
stale: boolean;
|
|
74
|
+
generatedAt?: string;
|
|
75
|
+
packageCount?: number;
|
|
76
|
+
truncated?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface BuildIndexOptions {
|
|
80
|
+
/** Politeness pause between per-package registry calls -- same role as
|
|
81
|
+
* catalog.ts's PAGE_DELAY_MS. */
|
|
82
|
+
delayMs?: number;
|
|
83
|
+
currentPiVersion?: () => Promise<string | undefined>;
|
|
84
|
+
/** Explicit bound on how many cataloged packages one generation run will
|
|
85
|
+
* process -- defaults to DEFAULT_MAX_PACKAGES. A run never silently
|
|
86
|
+
* processes an unbounded catalog. */
|
|
87
|
+
maxPackages?: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Same per-directory-filename convention as db.ts's dbPath(). */
|
|
91
|
+
export function indexPath(dir: string): string {
|
|
92
|
+
return join(dir, INDEX_FILE);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* One assessRegistryAdoption() call per cataloged package -- npm registry
|
|
97
|
+
* data only, no GitHub. A single package failing its registry lookup
|
|
98
|
+
* (removed, renamed, transient error) is skipped and logged rather than
|
|
99
|
+
* failing the whole run.
|
|
100
|
+
*
|
|
101
|
+
* Deliberately never calls reg.downloads(): confirmed live against this
|
|
102
|
+
* project's own real catalog (5,250 entries, overwhelmingly false
|
|
103
|
+
* positives from the keywords:pi-package search itself, a separate known
|
|
104
|
+
* issue) that calling it per package hits npm's downloads API 429s within
|
|
105
|
+
* minutes -- two extra requests per package on top of info(). traction
|
|
106
|
+
* stays "unknown" for every bulk-generated entry, which is already the
|
|
107
|
+
* dimension's own honest fallback for missing download data, not a new
|
|
108
|
+
* failure mode.
|
|
109
|
+
*/
|
|
110
|
+
export function buildIndex(reg: Registry, catalogDir: string, options: BuildIndexOptions = {}): Promise<PackageIndex> {
|
|
111
|
+
if (inFlight) return inFlight;
|
|
112
|
+
const run = buildIndexNow(reg, catalogDir, options).finally(() => {
|
|
113
|
+
inFlight = undefined;
|
|
114
|
+
});
|
|
115
|
+
inFlight = run;
|
|
116
|
+
return run;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Partitions the current catalog against the prior index (when one exists)
|
|
121
|
+
* into three groups -- New (never scored before), Changed (scored before,
|
|
122
|
+
* but the catalog's date for it has advanced since), and Unchanged (scored
|
|
123
|
+
* before, same date). Identity (name presence) decides New vs. known;
|
|
124
|
+
* date decides Changed vs. Unchanged among known packages -- these are
|
|
125
|
+
* deliberately different questions (see this module's own research Doc):
|
|
126
|
+
* a package can be old on npm but new to *our* catalog, so date alone
|
|
127
|
+
* can never answer "have we scored this before."
|
|
128
|
+
*/
|
|
129
|
+
function partitionAgainstPriorIndex(
|
|
130
|
+
catalogPkgs: Array<{ name: string; date?: string }>,
|
|
131
|
+
priorByName: Map<string, PackageIndexEntry>,
|
|
132
|
+
): { toScore: string[]; unchanged: PackageIndexEntry[] } {
|
|
133
|
+
const toScore: string[] = [];
|
|
134
|
+
const unchanged: PackageIndexEntry[] = [];
|
|
135
|
+
for (const pkg of catalogPkgs) {
|
|
136
|
+
const prior = priorByName.get(pkg.name);
|
|
137
|
+
if (!prior) {
|
|
138
|
+
toScore.push(pkg.name);
|
|
139
|
+
continue;
|
|
140
|
+
} // New
|
|
141
|
+
// Can't prove nothing changed without both dates -- rescore rather
|
|
142
|
+
// than assume, matching this codebase's "never guess" convention.
|
|
143
|
+
if (pkg.date === undefined || prior.date === undefined) {
|
|
144
|
+
toScore.push(pkg.name);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (Date.parse(pkg.date) > Date.parse(prior.date)) {
|
|
148
|
+
toScore.push(pkg.name);
|
|
149
|
+
continue;
|
|
150
|
+
} // Changed
|
|
151
|
+
unchanged.push(prior); // Unchanged -- carried forward verbatim, zero live calls
|
|
152
|
+
}
|
|
153
|
+
return { toScore, unchanged };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function buildIndexNow(reg: Registry, catalogDir: string, options: BuildIndexOptions): Promise<PackageIndex> {
|
|
157
|
+
const delayMs = options.delayMs ?? PAGE_DELAY_MS;
|
|
158
|
+
const currentPiVersion = options.currentPiVersion ?? resolveCurrentPiVersion;
|
|
159
|
+
const maxPackages = options.maxPackages ?? DEFAULT_MAX_PACKAGES;
|
|
160
|
+
|
|
161
|
+
const db = openDb(dbPath(catalogDir));
|
|
162
|
+
let catalogPkgs: Array<{ name: string; date?: string }>;
|
|
163
|
+
try {
|
|
164
|
+
catalogPkgs = catalogList(db).map((p) => ({ name: p.name, date: p.date }));
|
|
165
|
+
} finally {
|
|
166
|
+
db.close();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const priorIndex = readIndex(indexPath(catalogDir));
|
|
170
|
+
const priorByName = new Map((priorIndex?.packages ?? []).map((entry) => [entry.name, entry]));
|
|
171
|
+
const { toScore, unchanged } = partitionAgainstPriorIndex(catalogPkgs, priorByName);
|
|
172
|
+
const catalogDateByName = new Map(catalogPkgs.map((p) => [p.name, p.date]));
|
|
173
|
+
|
|
174
|
+
// The bound applies to the live-call queue specifically, not the whole
|
|
175
|
+
// catalog -- Unchanged carry-forwards are cheap local copies and were
|
|
176
|
+
// never the resource this cap exists to protect.
|
|
177
|
+
const truncated = toScore.length > maxPackages;
|
|
178
|
+
const names = toScore.slice(0, maxPackages);
|
|
179
|
+
if (truncated) log.warn("new/changed queue exceeds maxPackages, truncating this run", { queueSize: toScore.length, maxPackages });
|
|
180
|
+
|
|
181
|
+
// Skip both lookups entirely when nothing needs live scoring this run --
|
|
182
|
+
// an all-Unchanged catalog should make zero network calls, not one.
|
|
183
|
+
let piVersion: string | undefined;
|
|
184
|
+
let piModified: string | undefined;
|
|
185
|
+
if (names.length > 0) {
|
|
186
|
+
piVersion = await currentPiVersion();
|
|
187
|
+
if (reg.modifiedAt) {
|
|
188
|
+
try {
|
|
189
|
+
piModified = await reg.modifiedAt(PI_COMMAND_NAME);
|
|
190
|
+
} catch {
|
|
191
|
+
/* freshness stays explicitly unknown for every entry */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const scored: PackageIndexEntry[] = [];
|
|
197
|
+
for (const name of names) {
|
|
198
|
+
try {
|
|
199
|
+
const info = await reg.info(name);
|
|
200
|
+
if (reg.modifiedAt) {
|
|
201
|
+
try {
|
|
202
|
+
info.modified = await reg.modifiedAt(name);
|
|
203
|
+
} catch {
|
|
204
|
+
/* freshness proxy stays explicitly unknown */
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// No candidateCommitAt argument here -- GitHub is never touched
|
|
208
|
+
// during bulk generation, by construction, not by omission.
|
|
209
|
+
const report = assessRegistryAdoption(info, piVersion, piModified);
|
|
210
|
+
scored.push({ name: info.name, version: info.version, dimensions: report.dimensions, date: catalogDateByName.get(name) });
|
|
211
|
+
} catch (e) {
|
|
212
|
+
log.warn("index entry failed, skipping", { name, error: e instanceof Error ? e.message : String(e) });
|
|
213
|
+
}
|
|
214
|
+
if (delayMs > 0) await Bun.sleep(delayMs);
|
|
215
|
+
}
|
|
216
|
+
return { generatedAt: new Date().toISOString(), packages: [...unchanged, ...scored], truncated };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Atomic write -- tmp file + rename, same partial-write-safety convention
|
|
220
|
+
* as security.ts's writeSecuritySettings. */
|
|
221
|
+
export async function writeIndex(path: string, index: PackageIndex): Promise<void> {
|
|
222
|
+
await writeJsonAtomic(path, index, { trailingNewline: false });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function readIndex(path: string): PackageIndex | undefined {
|
|
226
|
+
try {
|
|
227
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<PackageIndex>;
|
|
228
|
+
if (typeof parsed.generatedAt === "string" && Array.isArray(parsed.packages)) return parsed as PackageIndex;
|
|
229
|
+
return undefined;
|
|
230
|
+
} catch {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function indexStatus(path: string, ttlMs: number): IndexStatus {
|
|
236
|
+
const index = readIndex(path);
|
|
237
|
+
if (!index) return { stale: true };
|
|
238
|
+
return {
|
|
239
|
+
stale: Date.now() - Date.parse(index.generatedAt) > ttlMs,
|
|
240
|
+
generatedAt: index.generatedAt,
|
|
241
|
+
packageCount: index.packages.length,
|
|
242
|
+
truncated: index.truncated,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Build + write in one call -- the maintenance-task/CLI entry point. */
|
|
247
|
+
export async function generateIndex(
|
|
248
|
+
reg: Registry,
|
|
249
|
+
catalogDir: string,
|
|
250
|
+
path: string,
|
|
251
|
+
options: BuildIndexOptions = {},
|
|
252
|
+
): Promise<PackageIndex> {
|
|
253
|
+
const index = await buildIndex(reg, catalogDir, options);
|
|
254
|
+
await writeIndex(path, index);
|
|
255
|
+
return index;
|
|
256
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
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
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { PI_PACKAGE_KEYWORD } from "../shared/constants.ts";
|
|
8
|
+
import { createLogger } from "../shared/log.ts";
|
|
9
|
+
import { dbPath, getSyncMeta, openDb, replaceAll } from "./db.ts";
|
|
10
|
+
import type { Registry } from "./package.ts";
|
|
11
|
+
|
|
12
|
+
const log = createLogger("catalog");
|
|
13
|
+
|
|
14
|
+
import type { SyncMeta } from "./db.ts";
|
|
15
|
+
|
|
16
|
+
export interface CatalogStatus {
|
|
17
|
+
stale: boolean;
|
|
18
|
+
meta?: SyncMeta;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function catalogStatus(dir: string, ttlMs: number): CatalogStatus {
|
|
22
|
+
const db = openDb(dbPath(dir));
|
|
23
|
+
try {
|
|
24
|
+
const meta = getSyncMeta(db);
|
|
25
|
+
if (!meta) return { stale: true };
|
|
26
|
+
return { stale: Date.now() - Date.parse(meta.fetchedAt) > ttlMs, meta };
|
|
27
|
+
} finally {
|
|
28
|
+
db.close();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Full mirror sync: upstream pages → atomic SQLite replace. */
|
|
33
|
+
export async function syncCatalog(reg: Registry, dir: string, query: string = PI_PACKAGE_KEYWORD): Promise<number> {
|
|
34
|
+
const t0 = Date.now();
|
|
35
|
+
log.info("mirror sync started", { query });
|
|
36
|
+
const packages = await reg.searchAll(query);
|
|
37
|
+
const db = openDb(dbPath(dir));
|
|
38
|
+
try {
|
|
39
|
+
replaceAll(db, packages, `npm:${query}`);
|
|
40
|
+
log.info("mirror sync complete", { packages: packages.length, ms: Date.now() - t0 });
|
|
41
|
+
return packages.length;
|
|
42
|
+
} finally {
|
|
43
|
+
db.close();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function startCatalogSync(reg: Registry, dir: string, ttlMs: number, onError?: (e: unknown) => void): () => void {
|
|
48
|
+
async function sync(): Promise<void> {
|
|
49
|
+
try {
|
|
50
|
+
if (catalogStatus(dir, ttlMs).stale) {
|
|
51
|
+
const n = await syncCatalog(reg, dir);
|
|
52
|
+
log.info("scheduled sync complete", { packages: n });
|
|
53
|
+
}
|
|
54
|
+
} catch (e) {
|
|
55
|
+
onError?.(e);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
void sync();
|
|
59
|
+
const timer = setInterval(() => void sync(), ttlMs);
|
|
60
|
+
return () => clearInterval(timer);
|
|
61
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
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
|
+
// Runtime-detected SQLite backend: bun:sqlite under Bun (CLI, daemon),
|
|
7
|
+
// node:sqlite under Node ≥22.5 (pi's extension host — jiti runs on Node).
|
|
8
|
+
// Both share the better-sqlite3 API shape; transactions are done manually
|
|
9
|
+
// because node:sqlite has no .transaction() helper.
|
|
10
|
+
|
|
11
|
+
import { mkdirSync } from "node:fs";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
|
|
14
|
+
const require_ = createRequire(import.meta.url);
|
|
15
|
+
const IS_BUN = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
|
|
16
|
+
const backend = IS_BUN
|
|
17
|
+
? (require_("bun:sqlite") as typeof import("bun:sqlite"))
|
|
18
|
+
: (require_("node:sqlite") as unknown as typeof import("bun:sqlite"));
|
|
19
|
+
|
|
20
|
+
// Constructor + options differ: bun:sqlite exports Database({create}),
|
|
21
|
+
// node:sqlite exports DatabaseSync (creates by default).
|
|
22
|
+
const DatabaseCtor = ("DatabaseSync" in backend ? (backend as { DatabaseSync: unknown }).DatabaseSync : backend.Database) as new (
|
|
23
|
+
path: string,
|
|
24
|
+
opts?: { create?: boolean },
|
|
25
|
+
) => Db;
|
|
26
|
+
|
|
27
|
+
export interface DbStatement {
|
|
28
|
+
run(...params: unknown[]): { lastInsertRowid: number | bigint };
|
|
29
|
+
get(...params: unknown[]): unknown;
|
|
30
|
+
all(...params: unknown[]): unknown[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Db {
|
|
34
|
+
exec(sql: string): unknown;
|
|
35
|
+
prepare(sql: string): DbStatement;
|
|
36
|
+
close(): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function inTransaction<T>(db: Db, fn: () => T): T {
|
|
40
|
+
db.exec("BEGIN");
|
|
41
|
+
try {
|
|
42
|
+
const result = fn();
|
|
43
|
+
db.exec("COMMIT");
|
|
44
|
+
return result;
|
|
45
|
+
} catch (e) {
|
|
46
|
+
db.exec("ROLLBACK");
|
|
47
|
+
throw e;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
import { createHash } from "node:crypto";
|
|
52
|
+
import { join } from "node:path";
|
|
53
|
+
import { DB_FILE } from "../shared/constants.ts";
|
|
54
|
+
import type { Pkg } from "./package.ts";
|
|
55
|
+
|
|
56
|
+
export interface SyncMeta {
|
|
57
|
+
source: string;
|
|
58
|
+
fetchedAt: string;
|
|
59
|
+
packageCount: number;
|
|
60
|
+
sha256: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function dbPath(dir: string): string {
|
|
64
|
+
return join(dir, DB_FILE);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function openDb(path: string): Db {
|
|
68
|
+
if (path !== ":memory:") mkdirSync(join(path, ".."), { recursive: true });
|
|
69
|
+
const db = IS_BUN ? new DatabaseCtor(path, { create: true }) : new DatabaseCtor(path);
|
|
70
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
71
|
+
db.exec(`CREATE TABLE IF NOT EXISTS packages (
|
|
72
|
+
name TEXT PRIMARY KEY,
|
|
73
|
+
version TEXT NOT NULL DEFAULT '',
|
|
74
|
+
description TEXT,
|
|
75
|
+
date TEXT
|
|
76
|
+
)`);
|
|
77
|
+
ensureColumn(db, "packages", "shape", "TEXT NOT NULL DEFAULT 'keyword-only'");
|
|
78
|
+
ensureColumn(db, "packages", "verified", "INTEGER NOT NULL DEFAULT 0");
|
|
79
|
+
ensureColumn(db, "packages", "evidence", "TEXT NOT NULL DEFAULT '[]'");
|
|
80
|
+
ensureColumn(db, "packages", "provenance_url", "TEXT");
|
|
81
|
+
ensureColumn(db, "packages", "trusted_publisher", "TEXT NOT NULL DEFAULT 'unknown'");
|
|
82
|
+
db.exec(`CREATE TABLE IF NOT EXISTS sync_meta (
|
|
83
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
84
|
+
source TEXT NOT NULL,
|
|
85
|
+
fetched_at TEXT NOT NULL,
|
|
86
|
+
package_count INTEGER NOT NULL,
|
|
87
|
+
sha256 TEXT NOT NULL
|
|
88
|
+
)`);
|
|
89
|
+
// FTS5 mirror; rebuilt wholesale on each sync.
|
|
90
|
+
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS packages_fts USING fts5(name, description)`);
|
|
91
|
+
return db;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function ensureColumn(db: Db, table: string, column: string, declaration: string): void {
|
|
95
|
+
const columns = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
|
96
|
+
if (!columns.some((item) => item.name === column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${declaration}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Canonical checksum over the payload — the Release-file analog: cheap
|
|
100
|
+
* change detection between syncs and integrity for the local mirror. */
|
|
101
|
+
function payloadHash(pkgs: Pkg[]): string {
|
|
102
|
+
const h = createHash("sha256");
|
|
103
|
+
for (const p of pkgs)
|
|
104
|
+
h.update(
|
|
105
|
+
`${p.name}@${p.version}:${p.packageEvidence?.shape ?? "keyword-only"}:${p.packageEvidence?.verified === true ? 1 : 0}:${p.publication?.provenanceUrl ?? ""}\n`,
|
|
106
|
+
);
|
|
107
|
+
return h.digest("hex");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Atomic full-catalog replace (the mirror write side of `apt update`). */
|
|
111
|
+
export function replaceAll(db: Db, pkgs: Pkg[], source: string): SyncMeta {
|
|
112
|
+
const meta: SyncMeta = {
|
|
113
|
+
source,
|
|
114
|
+
fetchedAt: new Date().toISOString(),
|
|
115
|
+
packageCount: pkgs.length,
|
|
116
|
+
sha256: payloadHash(pkgs),
|
|
117
|
+
};
|
|
118
|
+
// INSERT OR REPLACE: npm pagination is unstable — rankings shift between
|
|
119
|
+
// pages and a package can appear twice in one sync (rowid reuse is fine).
|
|
120
|
+
const insert = db.prepare(
|
|
121
|
+
"INSERT OR REPLACE INTO packages (name, version, description, date, shape, verified, evidence, provenance_url, trusted_publisher) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
122
|
+
);
|
|
123
|
+
const insertFts = db.prepare("INSERT INTO packages_fts (rowid, name, description) VALUES (?, ?, ?)");
|
|
124
|
+
inTransaction(db, () => {
|
|
125
|
+
db.exec("DELETE FROM packages");
|
|
126
|
+
db.exec("DELETE FROM packages_fts");
|
|
127
|
+
for (const p of pkgs) {
|
|
128
|
+
const { lastInsertRowid } = insert.run(
|
|
129
|
+
p.name,
|
|
130
|
+
p.version,
|
|
131
|
+
p.description ?? null,
|
|
132
|
+
p.date ?? null,
|
|
133
|
+
p.packageEvidence?.shape ?? "keyword-only",
|
|
134
|
+
p.packageEvidence?.verified === true ? 1 : 0,
|
|
135
|
+
JSON.stringify(p.packageEvidence?.evidence ?? []),
|
|
136
|
+
p.publication?.provenanceUrl ?? null,
|
|
137
|
+
p.publication?.trustedPublisher ?? "unknown",
|
|
138
|
+
);
|
|
139
|
+
insertFts.run(Number(lastInsertRowid), p.name, p.description ?? "");
|
|
140
|
+
}
|
|
141
|
+
db.prepare(
|
|
142
|
+
"INSERT INTO sync_meta (id, source, fetched_at, package_count, sha256) VALUES (1, ?, ?, ?, ?) " +
|
|
143
|
+
"ON CONFLICT(id) DO UPDATE SET source=excluded.source, fetched_at=excluded.fetched_at, " +
|
|
144
|
+
"package_count=excluded.package_count, sha256=excluded.sha256",
|
|
145
|
+
).run(meta.source, meta.fetchedAt, meta.packageCount, meta.sha256);
|
|
146
|
+
});
|
|
147
|
+
return meta;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function getSyncMeta(db: Db): SyncMeta | undefined {
|
|
151
|
+
return db.prepare("SELECT source, fetched_at AS fetchedAt, package_count AS packageCount, sha256 FROM sync_meta WHERE id = 1").get() as
|
|
152
|
+
| SyncMeta
|
|
153
|
+
| undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function catalogList(db: Db, limit = 0, offset = 0): Pkg[] {
|
|
157
|
+
const sql =
|
|
158
|
+
"SELECT name, version, description, date, shape, verified, evidence, provenance_url AS provenanceUrl, trusted_publisher AS trustedPublisher FROM packages ORDER BY name" +
|
|
159
|
+
(limit > 0 ? ` LIMIT ${Math.floor(limit)} OFFSET ${Math.floor(offset)}` : "");
|
|
160
|
+
return mapPackageRows(db.prepare(sql).all());
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** FTS5 over the mirror (the `apt-cache search` analog). Sanitizes user
|
|
164
|
+
* input into AND-joined quoted terms; falls back to LIKE for hostile input. */
|
|
165
|
+
export function searchLocal(db: Db, q: string, limit = 50): Pkg[] {
|
|
166
|
+
const terms = q.trim().split(/\s+/).filter(Boolean);
|
|
167
|
+
if (terms.length === 0) return [];
|
|
168
|
+
try {
|
|
169
|
+
const match = terms.map((t) => `"${t.replaceAll('"', '""')}"*`).join(" ");
|
|
170
|
+
return mapPackageRows(
|
|
171
|
+
db
|
|
172
|
+
.prepare(
|
|
173
|
+
`SELECT p.name, p.version, p.description, p.date, p.shape, p.verified, p.evidence,
|
|
174
|
+
p.provenance_url AS provenanceUrl, p.trusted_publisher AS trustedPublisher
|
|
175
|
+
FROM packages_fts f JOIN packages p ON p.rowid = f.rowid
|
|
176
|
+
WHERE packages_fts MATCH ? ORDER BY p.verified DESC, rank LIMIT ?`,
|
|
177
|
+
)
|
|
178
|
+
.all(match, limit),
|
|
179
|
+
);
|
|
180
|
+
} catch {
|
|
181
|
+
// FTS syntax hostility → substring fallback
|
|
182
|
+
const like = `%${q}%`;
|
|
183
|
+
return mapPackageRows(
|
|
184
|
+
db
|
|
185
|
+
.prepare(
|
|
186
|
+
"SELECT name, version, description, date, shape, verified, evidence, provenance_url AS provenanceUrl, trusted_publisher AS trustedPublisher FROM packages WHERE name LIKE ? OR description LIKE ? ORDER BY verified DESC, name LIMIT ?",
|
|
187
|
+
)
|
|
188
|
+
.all(like, like, limit),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function mapPackageRows(rows: unknown[]): Pkg[] {
|
|
194
|
+
return (rows as Array<Record<string, unknown>>).map((row) => ({
|
|
195
|
+
name: String(row.name),
|
|
196
|
+
version: String(row.version ?? ""),
|
|
197
|
+
description: typeof row.description === "string" ? row.description : undefined,
|
|
198
|
+
date: typeof row.date === "string" ? row.date : undefined,
|
|
199
|
+
packageEvidence: {
|
|
200
|
+
shape: row.shape === "manifest" || row.shape === "conventional" ? row.shape : "keyword-only",
|
|
201
|
+
verified: row.verified === 1 || row.verified === true,
|
|
202
|
+
evidence: parseEvidence(row.evidence),
|
|
203
|
+
},
|
|
204
|
+
publication: {
|
|
205
|
+
provenanceUrl: typeof row.provenanceUrl === "string" ? row.provenanceUrl : undefined,
|
|
206
|
+
trustedPublisher: row.trustedPublisher === "verified" || row.trustedPublisher === "not-verified" ? row.trustedPublisher : "unknown",
|
|
207
|
+
},
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function parseEvidence(value: unknown): string[] {
|
|
212
|
+
try {
|
|
213
|
+
const parsed = JSON.parse(typeof value === "string" ? value : "[]") as unknown;
|
|
214
|
+
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string").slice(0, 20) : [];
|
|
215
|
+
} catch {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Latest mirrored version of one package (watcher's lookup). */
|
|
221
|
+
export function latestVersion(db: Db, name: string): string | undefined {
|
|
222
|
+
const row = db.prepare("SELECT version FROM packages WHERE name = ?").get(name) as { version: string } | null;
|
|
223
|
+
return row?.version;
|
|
224
|
+
}
|