akm-cli 0.0.0 → 0.0.17

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.
@@ -0,0 +1,365 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fetchWithRetry } from "./common";
4
+ import { DEFAULT_CONFIG, loadConfig } from "./config";
5
+ import { getRegistryIndexCacheDir } from "./paths";
6
+ // ── Constants ───────────────────────────────────────────────────────────────
7
+ /** Cache TTL in milliseconds (1 hour). */
8
+ const CACHE_TTL_MS = 60 * 60 * 1000;
9
+ /** Maximum age before cache is considered stale but still usable as fallback (7 days). */
10
+ const CACHE_STALE_MS = 7 * 24 * 60 * 60 * 1000;
11
+ // ── Public API ──────────────────────────────────────────────────────────────
12
+ export async function searchRegistry(query, options) {
13
+ const trimmed = query.trim();
14
+ if (!trimmed) {
15
+ return { query: "", hits: [], warnings: [] };
16
+ }
17
+ const limit = clampLimit(options?.limit);
18
+ const entries = (options?.registries ?? resolveRegistries()).filter((r) => r.enabled !== false);
19
+ const warnings = [];
20
+ // Load index from all configured registries, merge kits
21
+ const allKits = [];
22
+ for (const entry of entries) {
23
+ try {
24
+ const index = await loadIndex(entry);
25
+ if (index) {
26
+ const regName = entry.name;
27
+ for (const kit of index.kits) {
28
+ allKits.push({ kit, registryName: regName });
29
+ }
30
+ }
31
+ }
32
+ catch (err) {
33
+ const label = entry.name ? `${entry.name} (${entry.url})` : entry.url;
34
+ warnings.push(`Registry ${label}: ${toErrorMessage(err)}`);
35
+ }
36
+ }
37
+ // Score and rank
38
+ const hits = scoreKits(allKits, trimmed, limit);
39
+ // When includeAssets is enabled, also search asset-level metadata
40
+ let assetHits = [];
41
+ if (options?.includeAssets) {
42
+ assetHits = scoreAssets(allKits, trimmed, limit);
43
+ }
44
+ return { query: trimmed, hits, warnings, assetHits: assetHits.length > 0 ? assetHits : undefined };
45
+ }
46
+ // ── Registry resolution ─────────────────────────────────────────────────────
47
+ /**
48
+ * Resolve the list of enabled registries.
49
+ *
50
+ * Priority:
51
+ * 1. AKM_REGISTRY_URL env var (CI override, comma-separated)
52
+ * 2. config.registries (filtered by enabled !== false)
53
+ * 3. Default registries from DEFAULT_CONFIG
54
+ */
55
+ export function resolveRegistries(configRegistries) {
56
+ // Allow env var override (comma-separated URLs) — CI escape hatch
57
+ const envUrls = process.env.AKM_REGISTRY_URL?.trim();
58
+ if (envUrls) {
59
+ return envUrls
60
+ .split(",")
61
+ .map((u) => u.trim())
62
+ .filter(Boolean)
63
+ .map((url) => ({ url }));
64
+ }
65
+ const registries = configRegistries ?? loadConfig().registries ?? DEFAULT_CONFIG.registries ?? [];
66
+ return registries.filter((r) => r.enabled !== false);
67
+ }
68
+ // ── Index loading with cache ────────────────────────────────────────────────
69
+ async function loadIndex(entry) {
70
+ const cachePath = indexCachePath(entry.url);
71
+ const cached = readCachedIndex(cachePath);
72
+ // Fresh cache: return immediately
73
+ if (cached && !isCacheExpired(cached.mtime)) {
74
+ return cached.index;
75
+ }
76
+ // Try to fetch fresh index
77
+ try {
78
+ const response = await fetchWithRetry(entry.url, undefined, { timeout: 10_000 });
79
+ if (!response.ok) {
80
+ throw new Error(`HTTP ${response.status}`);
81
+ }
82
+ const data = (await response.json());
83
+ const index = parseRegistryIndex(data);
84
+ if (index) {
85
+ writeCachedIndex(cachePath, index);
86
+ return index;
87
+ }
88
+ throw new Error("Invalid registry index format");
89
+ }
90
+ catch (err) {
91
+ // Fetch failed — use stale cache if available
92
+ if (cached && !isCacheStale(cached.mtime)) {
93
+ return cached.index;
94
+ }
95
+ throw err;
96
+ }
97
+ }
98
+ function readCachedIndex(cachePath) {
99
+ try {
100
+ const stat = fs.statSync(cachePath);
101
+ const raw = JSON.parse(fs.readFileSync(cachePath, "utf8"));
102
+ const index = parseRegistryIndex(raw);
103
+ if (!index)
104
+ return null;
105
+ return { index, mtime: stat.mtimeMs };
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
111
+ function writeCachedIndex(cachePath, index) {
112
+ try {
113
+ const dir = path.dirname(cachePath);
114
+ fs.mkdirSync(dir, { recursive: true });
115
+ const tmpPath = `${cachePath}.tmp.${process.pid}`;
116
+ fs.writeFileSync(tmpPath, JSON.stringify(index), "utf8");
117
+ fs.renameSync(tmpPath, cachePath);
118
+ }
119
+ catch {
120
+ // Best-effort caching — don't fail the search if we can't write
121
+ }
122
+ }
123
+ function indexCachePath(url) {
124
+ const indexDir = getRegistryIndexCacheDir();
125
+ // Deterministic filename from URL
126
+ const slug = url
127
+ .replace(/[^a-zA-Z0-9]+/g, "-")
128
+ .replace(/^-+|-+$/g, "")
129
+ .slice(0, 120);
130
+ return path.join(indexDir, `${slug}.json`);
131
+ }
132
+ function isCacheExpired(mtimeMs) {
133
+ return Date.now() - mtimeMs > CACHE_TTL_MS;
134
+ }
135
+ function isCacheStale(mtimeMs) {
136
+ return Date.now() - mtimeMs > CACHE_STALE_MS;
137
+ }
138
+ // ── Index parsing ───────────────────────────────────────────────────────────
139
+ function parseRegistryIndex(data) {
140
+ if (typeof data !== "object" || data === null || Array.isArray(data))
141
+ return null;
142
+ const obj = data;
143
+ if (typeof obj.version !== "number" || (obj.version !== 1 && obj.version !== 2))
144
+ return null;
145
+ if (typeof obj.updatedAt !== "string")
146
+ return null;
147
+ if (!Array.isArray(obj.kits))
148
+ return null;
149
+ const kits = obj.kits.flatMap((raw) => {
150
+ const kit = parseKitEntry(raw);
151
+ return kit ? [kit] : [];
152
+ });
153
+ return { version: obj.version, updatedAt: obj.updatedAt, kits };
154
+ }
155
+ function parseKitEntry(raw) {
156
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
157
+ return null;
158
+ const obj = raw;
159
+ const id = asString(obj.id);
160
+ const name = asString(obj.name);
161
+ const ref = asString(obj.ref);
162
+ const source = asSource(obj.source);
163
+ if (!id || !name || !ref || !source)
164
+ return null;
165
+ return {
166
+ id,
167
+ name,
168
+ ref,
169
+ source,
170
+ description: asString(obj.description),
171
+ homepage: asString(obj.homepage),
172
+ tags: asStringArray(obj.tags),
173
+ assetTypes: asStringArray(obj.assetTypes),
174
+ assets: parseAssets(obj.assets),
175
+ author: asString(obj.author),
176
+ license: asString(obj.license),
177
+ latestVersion: asString(obj.latestVersion),
178
+ curated: obj.curated === true ? true : undefined,
179
+ };
180
+ }
181
+ // ── Scoring ─────────────────────────────────────────────────────────────────
182
+ function scoreKits(kits, query, limit) {
183
+ const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
184
+ const scored = [];
185
+ for (const { kit, registryName } of kits) {
186
+ const score = scoreKit(kit, tokens);
187
+ if (score > 0) {
188
+ scored.push({ kit, registryName, score });
189
+ }
190
+ }
191
+ scored.sort((a, b) => b.score - a.score);
192
+ return scored.slice(0, limit).map(({ kit, registryName, score }) => toSearchHit(kit, score, registryName));
193
+ }
194
+ function scoreKit(kit, tokens) {
195
+ let score = 0;
196
+ const nameLower = kit.name.toLowerCase();
197
+ const descLower = (kit.description ?? "").toLowerCase();
198
+ const tagsLower = (kit.tags ?? []).map((t) => t.toLowerCase());
199
+ for (const token of tokens) {
200
+ // Exact name match is strongest signal
201
+ if (nameLower === token) {
202
+ score += 1.0;
203
+ }
204
+ else if (nameLower.includes(token)) {
205
+ score += 0.6;
206
+ }
207
+ // Tag matches are high-signal (curated keywords)
208
+ if (tagsLower.some((tag) => tag === token)) {
209
+ score += 0.5;
210
+ }
211
+ else if (tagsLower.some((tag) => tag.includes(token))) {
212
+ score += 0.25;
213
+ }
214
+ // Description substring
215
+ if (descLower.includes(token)) {
216
+ score += 0.2;
217
+ }
218
+ // Author match
219
+ if (kit.author?.toLowerCase().includes(token)) {
220
+ score += 0.15;
221
+ }
222
+ }
223
+ // Normalize by token count so multi-word queries don't inflate scores
224
+ return tokens.length > 0 ? score / tokens.length : 0;
225
+ }
226
+ function toSearchHit(kit, score, registryName) {
227
+ const metadata = {};
228
+ if (kit.latestVersion)
229
+ metadata.version = kit.latestVersion;
230
+ if (kit.author)
231
+ metadata.author = kit.author;
232
+ if (kit.license)
233
+ metadata.license = kit.license;
234
+ if (kit.assetTypes?.length)
235
+ metadata.assetTypes = kit.assetTypes.join(", ");
236
+ return {
237
+ source: kit.source,
238
+ id: kit.id,
239
+ title: kit.name,
240
+ description: kit.description,
241
+ ref: kit.ref,
242
+ homepage: kit.homepage,
243
+ score: Math.round(score * 1000) / 1000,
244
+ metadata,
245
+ curated: kit.curated,
246
+ registryName,
247
+ };
248
+ }
249
+ // ── Asset parsing ────────────────────────────────────────────────────────────
250
+ function parseAssets(raw) {
251
+ if (!Array.isArray(raw))
252
+ return undefined;
253
+ const parsed = raw.flatMap((item) => {
254
+ const entry = parseAssetEntry(item);
255
+ return entry ? [entry] : [];
256
+ });
257
+ return parsed.length > 0 ? parsed : undefined;
258
+ }
259
+ function parseAssetEntry(raw) {
260
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
261
+ return null;
262
+ const obj = raw;
263
+ const type = asString(obj.type);
264
+ const name = asString(obj.name);
265
+ if (!type || !name)
266
+ return null;
267
+ return {
268
+ type,
269
+ name,
270
+ description: asString(obj.description),
271
+ tags: asStringArray(obj.tags),
272
+ };
273
+ }
274
+ // ── Asset-level scoring ──────────────────────────────────────────────────────
275
+ function scoreAssets(kits, query, limit) {
276
+ const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
277
+ if (tokens.length === 0)
278
+ return [];
279
+ const scored = [];
280
+ for (const { kit, registryName } of kits) {
281
+ if (!kit.assets || kit.assets.length === 0)
282
+ continue;
283
+ const installRef = kit.source === "npm"
284
+ ? `npm:${kit.ref}`
285
+ : kit.source === "git"
286
+ ? `git+${kit.ref}`
287
+ : kit.source === "local"
288
+ ? kit.ref
289
+ : `github:${kit.ref}`;
290
+ for (const asset of kit.assets) {
291
+ const score = scoreAsset(asset, tokens);
292
+ if (score > 0) {
293
+ scored.push({
294
+ hit: {
295
+ type: "registry-asset",
296
+ assetType: asset.type,
297
+ assetName: asset.name,
298
+ description: asset.description,
299
+ kit: { id: kit.id, name: kit.name },
300
+ registryName,
301
+ action: `akm add ${installRef}`,
302
+ score: Math.round(score * 1000) / 1000,
303
+ },
304
+ score,
305
+ });
306
+ }
307
+ }
308
+ }
309
+ scored.sort((a, b) => b.score - a.score);
310
+ return scored.slice(0, limit).map(({ hit }) => hit);
311
+ }
312
+ function scoreAsset(asset, tokens) {
313
+ let score = 0;
314
+ const nameLower = asset.name.toLowerCase();
315
+ const descLower = (asset.description ?? "").toLowerCase();
316
+ const tagsLower = (asset.tags ?? []).map((t) => t.toLowerCase());
317
+ const typeLower = asset.type.toLowerCase();
318
+ for (const token of tokens) {
319
+ if (nameLower === token) {
320
+ score += 1.0;
321
+ }
322
+ else if (nameLower.includes(token)) {
323
+ score += 0.6;
324
+ }
325
+ if (typeLower === token) {
326
+ score += 0.4;
327
+ }
328
+ else if (typeLower.includes(token)) {
329
+ score += 0.2;
330
+ }
331
+ if (tagsLower.some((tag) => tag === token)) {
332
+ score += 0.5;
333
+ }
334
+ else if (tagsLower.some((tag) => tag.includes(token))) {
335
+ score += 0.25;
336
+ }
337
+ if (descLower.includes(token)) {
338
+ score += 0.2;
339
+ }
340
+ }
341
+ return tokens.length > 0 ? score / tokens.length : 0;
342
+ }
343
+ // ── Utilities ───────────────────────────────────────────────────────────────
344
+ function clampLimit(limit) {
345
+ if (!limit || !Number.isFinite(limit))
346
+ return 20;
347
+ return Math.min(100, Math.max(1, Math.trunc(limit)));
348
+ }
349
+ function asString(value) {
350
+ return typeof value === "string" && value ? value : undefined;
351
+ }
352
+ function asSource(value) {
353
+ if (value === "npm" || value === "github" || value === "git" || value === "local")
354
+ return value;
355
+ return undefined;
356
+ }
357
+ function asStringArray(value) {
358
+ if (!Array.isArray(value))
359
+ return undefined;
360
+ const filtered = value.filter((v) => typeof v === "string");
361
+ return filtered.length > 0 ? filtered : undefined;
362
+ }
363
+ function toErrorMessage(error) {
364
+ return error instanceof Error ? error.message : String(error);
365
+ }
@@ -0,0 +1 @@
1
+ export {};