auriga-cli 1.15.1 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/state.js ADDED
@@ -0,0 +1,623 @@
1
+ // scanState — read the user's project + (optionally) live CLIs to produce
2
+ // a tri-state report per category. Pure-ish: all external I/O is either
3
+ // injected via `ScanOptions` (for tests) or done through the default
4
+ // filesystem / child-process implementations declared at the bottom of
5
+ // this file. See docs/architecture/web-ui.md §6.3 + §10.4 for the judgment rules
6
+ // and tests/state.test.ts for the full behavioral contract.
7
+ import { createHash } from "node:crypto";
8
+ import { exec as execCallback } from "node:child_process";
9
+ import { promisify } from "node:util";
10
+ const execAsync = promisify(execCallback);
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { parse as parseToml } from "smol-toml";
15
+ /**
16
+ * Shorten an absolute path by replacing the user's $HOME with `~`. Avoids
17
+ * leaking the full username in screenshots and keeps the TopBar label
18
+ * readable. Falls back to the original path when HOME is unset or the path
19
+ * doesn't sit under it.
20
+ */
21
+ function homeReducedPath(p) {
22
+ const home = os.homedir();
23
+ if (!home)
24
+ return p;
25
+ if (p === home)
26
+ return "~";
27
+ // Use path-segment boundary so /Users/pangcheng-foo is NOT matched.
28
+ if (p.startsWith(home + path.sep)) {
29
+ return "~" + p.slice(home.length);
30
+ }
31
+ return p;
32
+ }
33
+ export async function scanState(projectRoot, catalog, opts = {}) {
34
+ const warnings = [];
35
+ const workflow = scanWorkflow(projectRoot, catalog);
36
+ const lock = readSkillsLock(projectRoot);
37
+ const skills = scanSkills(catalog.skills, lock);
38
+ const recommendedSkills = scanRecommendedSkills(catalog.recommendedSkills, lock);
39
+ const hooks = scanHooks(projectRoot, catalog.hooks);
40
+ const claudePluginEntries = filterPluginsByAgent(catalog.plugins, "claude");
41
+ const codexPluginEntries = filterPluginsByAgent(catalog.plugins, "codex");
42
+ const claudePlugins = await scanClaudePlugins(claudePluginEntries, opts.execPluginList, warnings);
43
+ const codexPlugins = await scanCodexPlugins(codexPluginEntries, opts.readCodexConfig ?? defaultReadCodexConfig, opts.readCodexPluginsDir ?? defaultReadCodexPluginsDir, warnings);
44
+ return {
45
+ cwd: homeReducedPath(projectRoot),
46
+ workflow,
47
+ skills,
48
+ recommendedSkills,
49
+ plugins: mergePluginsById([...claudePlugins, ...codexPlugins]),
50
+ hooks,
51
+ warnings,
52
+ };
53
+ }
54
+ /**
55
+ * Dedupe plugins by `id`, merging dual-Agent records into a single
56
+ * multi-agent row. Aggregation rules:
57
+ *
58
+ * agents: union of all agent arrays for this id (claude before codex).
59
+ * status: installed ⇔ every agent's record is installed
60
+ * not-installed ⇔ every agent's record is not-installed
61
+ * otherwise → update-available (partial install or any agent
62
+ * with a pending update). One Apply covers all
63
+ * gaps because the handler iterates `agents`.
64
+ *
65
+ * Non-status fields (description, currentVersion, expectedVersion,
66
+ * versionSource) come from the first record we see. Today both sides report
67
+ * the same description (catalog-driven) and the same versions for any
68
+ * registry-pinned plugin, so this is safe; if a future divergence appears
69
+ * we'll need a deliberate merge policy.
70
+ */
71
+ export function mergePluginsById(records) {
72
+ const byId = new Map();
73
+ const statusByIdPerAgent = new Map();
74
+ for (const rec of records) {
75
+ const existing = byId.get(rec.id);
76
+ if (!existing) {
77
+ byId.set(rec.id, { ...rec });
78
+ statusByIdPerAgent.set(rec.id, [rec.status]);
79
+ continue;
80
+ }
81
+ // Union agents preserving order: existing first, then any new ones.
82
+ const seen = new Set(existing.agents);
83
+ for (const a of rec.agents) {
84
+ if (!seen.has(a)) {
85
+ existing.agents = [...existing.agents, a];
86
+ seen.add(a);
87
+ }
88
+ }
89
+ statusByIdPerAgent.get(rec.id).push(rec.status);
90
+ }
91
+ // Fold each id's per-agent status list into the aggregated status.
92
+ for (const [id, statuses] of statusByIdPerAgent) {
93
+ const rec = byId.get(id);
94
+ if (!rec)
95
+ continue;
96
+ rec.status = aggregateStatus(statuses);
97
+ }
98
+ return Array.from(byId.values());
99
+ }
100
+ function aggregateStatus(statuses) {
101
+ if (statuses.length === 0)
102
+ return "not-installed";
103
+ if (statuses.every((s) => s === "installed"))
104
+ return "installed";
105
+ if (statuses.every((s) => s === "not-installed"))
106
+ return "not-installed";
107
+ // Anything else — partial install, pending updates on any agent, mixed
108
+ // — falls through to update-available so a single Apply backfills the
109
+ // missing pieces. This is the boundary the user asked for: "一边装一边
110
+ // 没装" surfaces as a yellow UPDATE pill rather than a misleading green.
111
+ return "update-available";
112
+ }
113
+ // ---------------------------------------------------------------------------
114
+ // Workflow
115
+ // ---------------------------------------------------------------------------
116
+ const WORKFLOW_HEADER_RE = /^#\s+auriga\s+Workflow\s*\(v(\d+\.\d+\.\d+)\)/;
117
+ function scanWorkflow(projectRoot, catalog) {
118
+ const expectedVersion = catalog.workflowVersion;
119
+ const claudeMdPath = path.join(projectRoot, "CLAUDE.md");
120
+ let content;
121
+ try {
122
+ content = fs.readFileSync(claudeMdPath, "utf8");
123
+ }
124
+ catch {
125
+ return { status: "not-installed", expectedVersion };
126
+ }
127
+ // Match the canonical header anywhere in the first few lines; the spec
128
+ // and tests put it on the first line, but tolerate leading blank lines /
129
+ // BOM by scanning every line until we either find a match or run out.
130
+ for (const line of content.split(/\r?\n/)) {
131
+ const m = WORKFLOW_HEADER_RE.exec(line);
132
+ if (m) {
133
+ const currentVersion = m[1];
134
+ const status = currentVersion === expectedVersion ? "installed" : "update-available";
135
+ return { status, expectedVersion, currentVersion };
136
+ }
137
+ // Bail at the first non-blank line — the header must be a top heading.
138
+ if (line.trim().length > 0)
139
+ break;
140
+ }
141
+ // File exists but no parseable header → assumption #1 in state.test.ts:
142
+ // prefer reinstall over false-positive "installed" with unknown version.
143
+ return { status: "not-installed", expectedVersion };
144
+ }
145
+ /** Return the parsed lockfile, or null if absent / unparseable. The "null"
146
+ * path is the degraded mode: skills still show up as catalog rows but their
147
+ * `currentHash` is undefined and they are reported as not-installed. */
148
+ function readSkillsLock(projectRoot) {
149
+ const lockPath = path.join(projectRoot, "skills-lock.json");
150
+ let text;
151
+ try {
152
+ text = fs.readFileSync(lockPath, "utf8");
153
+ }
154
+ catch {
155
+ return null;
156
+ }
157
+ try {
158
+ const parsed = JSON.parse(text);
159
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
160
+ return parsed;
161
+ }
162
+ return null;
163
+ }
164
+ catch {
165
+ // Per state.test.ts "corrupt skills-lock" case: don't throw; degrade.
166
+ return null;
167
+ }
168
+ }
169
+ function classifySkill(expectedHash, lock, name) {
170
+ const entry = lock?.skills?.[name];
171
+ const currentHash = entry?.computedHash;
172
+ if (typeof currentHash !== "string" || currentHash.length === 0) {
173
+ return { status: "not-installed" };
174
+ }
175
+ if (currentHash === expectedHash) {
176
+ return { status: "installed", currentHash };
177
+ }
178
+ return { status: "update-available", currentHash };
179
+ }
180
+ function scanSkills(catalogSkills, lock) {
181
+ const out = [];
182
+ for (const [name, entry] of Object.entries(catalogSkills)) {
183
+ const cls = classifySkill(entry.expectedHash, lock, name);
184
+ out.push({
185
+ name,
186
+ description: entry.description,
187
+ status: cls.status,
188
+ isWorkflow: entry.isWorkflow,
189
+ currentHash: cls.currentHash,
190
+ expectedHash: entry.expectedHash,
191
+ });
192
+ }
193
+ return out;
194
+ }
195
+ function scanRecommendedSkills(catalogRec, lock) {
196
+ const out = [];
197
+ for (const [name, entry] of Object.entries(catalogRec)) {
198
+ const cls = classifySkill(entry.expectedHash, lock, name);
199
+ out.push({
200
+ name,
201
+ description: entry.description,
202
+ status: cls.status,
203
+ isWorkflow: false, // recommended skills are by definition opt-in utilities
204
+ currentHash: cls.currentHash,
205
+ expectedHash: entry.expectedHash,
206
+ });
207
+ }
208
+ return out;
209
+ }
210
+ // ---------------------------------------------------------------------------
211
+ // Plugins — Claude (live CLI query)
212
+ // ---------------------------------------------------------------------------
213
+ function filterPluginsByAgent(catalogPlugins, agent) {
214
+ return Object.entries(catalogPlugins).filter(([, def]) => def.agents.includes(agent));
215
+ }
216
+ /** Normalize a version ref: "v1.2.3" → "1.2.3", "1.2.3" → "1.2.3".
217
+ * Anything that is not a strict semver-like triple is returned as-is so the
218
+ * caller can detect "this is a branch / tag, not a comparable version". */
219
+ function parseRef(ref) {
220
+ if (typeof ref !== "string" || ref.length === 0)
221
+ return null;
222
+ const m = /^v?(\d+\.\d+\.\d+(?:[-+][\w.]+)?)$/.exec(ref);
223
+ return m ? m[1] : null;
224
+ }
225
+ async function scanClaudePlugins(entries, execPluginList, warnings) {
226
+ if (entries.length === 0)
227
+ return [];
228
+ // Degraded path 1: no exec injected AND no default. We expose a default
229
+ // implementation below that wraps `claude plugins list --available --json`,
230
+ // but the test contract treats "execPluginList undefined" as "Claude CLI
231
+ // missing" — we honor that by NOT silently falling back to the default
232
+ // when the caller leaves it undefined. (server.ts will pass the default
233
+ // explicitly when it confirms `claude` is on PATH.)
234
+ if (!execPluginList) {
235
+ warnings.push({
236
+ code: "claude-cli-missing",
237
+ message: "Claude CLI not available — plugin update detection disabled. Install `claude` to enable update checks.",
238
+ });
239
+ return entries.map(([id, def]) => degradedClaudeRow(id, def));
240
+ }
241
+ let payload;
242
+ try {
243
+ payload = await execPluginList();
244
+ }
245
+ catch (err) {
246
+ warnings.push({
247
+ code: "claude-cli-missing",
248
+ message: `Claude CLI plugin list failed: ${err.message}`,
249
+ });
250
+ return entries.map(([id, def]) => degradedClaudeRow(id, def));
251
+ }
252
+ const installedById = new Map();
253
+ for (const item of payload.installed ?? []) {
254
+ if (item && typeof item.id === "string")
255
+ installedById.set(item.id, item);
256
+ }
257
+ const availableById = new Map();
258
+ for (const item of payload.available ?? []) {
259
+ if (item && typeof item.id === "string")
260
+ availableById.set(item.id, item);
261
+ }
262
+ const out = [];
263
+ for (const [id, def] of entries) {
264
+ out.push(classifyClaudePlugin(id, def, installedById.get(id), availableById.get(id)));
265
+ }
266
+ return out;
267
+ }
268
+ function degradedClaudeRow(id, def) {
269
+ return {
270
+ id,
271
+ description: def.description,
272
+ status: "not-installed",
273
+ agents: ["claude"],
274
+ expectedVersion: def.expectedVersion,
275
+ versionSource: "upstream-live",
276
+ };
277
+ }
278
+ function classifyClaudePlugin(id, def, installed, available) {
279
+ // Not installed at all — easy case.
280
+ if (!installed || typeof installed.version !== "string") {
281
+ return {
282
+ id,
283
+ description: def.description,
284
+ status: "not-installed",
285
+ agents: ["claude"],
286
+ expectedVersion: typeof available?.source?.ref === "string" ? available.source.ref : def.expectedVersion,
287
+ versionSource: "upstream-live",
288
+ };
289
+ }
290
+ const installedVersion = installed.version;
291
+ const ref = available?.source?.ref;
292
+ const normalizedAvailable = parseRef(typeof ref === "string" ? ref : undefined);
293
+ const normalizedInstalled = parseRef(installedVersion);
294
+ // Fallback rule 1: installed version "unknown" → trust it's installed.
295
+ // Fallback rule 2: available.ref is a branch / non-semver → trust it.
296
+ // Fallback rule 3: available info is missing entirely → trust it (we know
297
+ // it's installed, we just can't say if there's a newer one).
298
+ if (installedVersion === "unknown" ||
299
+ normalizedAvailable === null ||
300
+ !available) {
301
+ return {
302
+ id,
303
+ description: def.description,
304
+ status: "installed",
305
+ agents: ["claude"],
306
+ currentVersion: installedVersion,
307
+ expectedVersion: typeof ref === "string" ? ref : def.expectedVersion,
308
+ versionSource: "upstream-live",
309
+ };
310
+ }
311
+ // Both sides comparable.
312
+ if (normalizedInstalled !== null && normalizedInstalled === normalizedAvailable) {
313
+ return {
314
+ id,
315
+ description: def.description,
316
+ status: "installed",
317
+ agents: ["claude"],
318
+ currentVersion: installedVersion,
319
+ expectedVersion: typeof ref === "string" ? ref : undefined,
320
+ versionSource: "upstream-live",
321
+ };
322
+ }
323
+ return {
324
+ id,
325
+ description: def.description,
326
+ status: "update-available",
327
+ agents: ["claude"],
328
+ currentVersion: installedVersion,
329
+ expectedVersion: typeof ref === "string" ? ref : undefined,
330
+ versionSource: "upstream-live",
331
+ };
332
+ }
333
+ // ---------------------------------------------------------------------------
334
+ // Plugins — Codex (config.toml + cache directory)
335
+ // ---------------------------------------------------------------------------
336
+ async function scanCodexPlugins(entries, readCodexConfig, readCodexPluginsDir, warnings) {
337
+ if (entries.length === 0)
338
+ return [];
339
+ let tomlContent;
340
+ try {
341
+ tomlContent = await readCodexConfig();
342
+ }
343
+ catch (err) {
344
+ warnings.push({
345
+ code: "codex-cli-missing",
346
+ message: `Codex config read failed: ${err.message}`,
347
+ });
348
+ return entries.map(([id, def]) => degradedCodexRow(id, def));
349
+ }
350
+ if (tomlContent === null) {
351
+ warnings.push({
352
+ code: "codex-cli-missing",
353
+ message: "Codex config.toml not found — codex plugin state unknown. Install `codex` and run a plugin install once to initialize.",
354
+ });
355
+ return entries.map(([id, def]) => degradedCodexRow(id, def));
356
+ }
357
+ let enabledIds = new Set();
358
+ try {
359
+ enabledIds = parseCodexEnabledPluginIds(tomlContent);
360
+ }
361
+ catch {
362
+ // Corrupt TOML — surface a warning but keep classifying as not-installed
363
+ // for each catalog entry rather than dropping rows.
364
+ warnings.push({
365
+ code: "codex-cli-missing",
366
+ message: "Codex config.toml is unparseable — treating no plugins as installed",
367
+ });
368
+ }
369
+ let fsVersions;
370
+ try {
371
+ fsVersions = await readCodexPluginsDir();
372
+ }
373
+ catch {
374
+ fsVersions = new Map();
375
+ }
376
+ const out = [];
377
+ for (const [id, def] of entries) {
378
+ out.push(classifyCodexPlugin(id, def, enabledIds.has(id), fsVersions.get(id)));
379
+ }
380
+ return out;
381
+ }
382
+ function degradedCodexRow(id, def) {
383
+ return {
384
+ id,
385
+ description: def.description,
386
+ status: "not-installed",
387
+ agents: ["codex"],
388
+ expectedVersion: def.expectedVersion,
389
+ versionSource: "catalog",
390
+ };
391
+ }
392
+ function classifyCodexPlugin(id, def, enabled, fsVersion) {
393
+ const expectedVersion = def.expectedVersion;
394
+ if (!enabled) {
395
+ return {
396
+ id,
397
+ description: def.description,
398
+ status: "not-installed",
399
+ agents: ["codex"],
400
+ expectedVersion,
401
+ versionSource: "catalog",
402
+ };
403
+ }
404
+ // Enabled in config but missing from fs — assumption #2 row contract:
405
+ // row present, status is NOT "installed".
406
+ if (!fsVersion) {
407
+ return {
408
+ id,
409
+ description: def.description,
410
+ status: "not-installed",
411
+ agents: ["codex"],
412
+ expectedVersion,
413
+ versionSource: "catalog",
414
+ };
415
+ }
416
+ // Compare fs version to catalog expectation. If catalog gives no
417
+ // expectedVersion, trust it as installed.
418
+ if (!expectedVersion || fsVersion === expectedVersion) {
419
+ return {
420
+ id,
421
+ description: def.description,
422
+ status: "installed",
423
+ agents: ["codex"],
424
+ currentVersion: fsVersion,
425
+ expectedVersion,
426
+ versionSource: "catalog",
427
+ };
428
+ }
429
+ return {
430
+ id,
431
+ description: def.description,
432
+ status: "update-available",
433
+ agents: ["codex"],
434
+ currentVersion: fsVersion,
435
+ expectedVersion,
436
+ versionSource: "catalog",
437
+ };
438
+ }
439
+ /** Return the set of plugin ids whose `[plugins."<id>"]` table has
440
+ * `enabled = true` in the given TOML body. Per-plugin parse errors do not
441
+ * poison the batch — anything that doesn't shape-match is skipped silently. */
442
+ function parseCodexEnabledPluginIds(tomlContent) {
443
+ const parsed = parseToml(tomlContent);
444
+ const plugins = parsed.plugins;
445
+ const ids = new Set();
446
+ if (!plugins || typeof plugins !== "object" || Array.isArray(plugins))
447
+ return ids;
448
+ for (const [id, table] of Object.entries(plugins)) {
449
+ if (table && typeof table === "object" && !Array.isArray(table)) {
450
+ const enabled = table.enabled;
451
+ if (enabled === true)
452
+ ids.add(id);
453
+ }
454
+ }
455
+ return ids;
456
+ }
457
+ function readHooksConfig(projectRoot) {
458
+ const configPath = path.join(projectRoot, ".claude", "hooks", "hooks.json");
459
+ let text;
460
+ try {
461
+ text = fs.readFileSync(configPath, "utf8");
462
+ }
463
+ catch {
464
+ return { config: null, corrupt: false };
465
+ }
466
+ try {
467
+ const parsed = JSON.parse(text);
468
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
469
+ return { config: parsed, corrupt: false };
470
+ }
471
+ return { config: null, corrupt: true };
472
+ }
473
+ catch {
474
+ return { config: null, corrupt: true };
475
+ }
476
+ }
477
+ function hashHookIndex(projectRoot, name) {
478
+ const indexPath = path.join(projectRoot, ".claude", "hooks", name, "index.mjs");
479
+ try {
480
+ const buf = fs.readFileSync(indexPath);
481
+ return createHash("sha256").update(buf).digest("hex");
482
+ }
483
+ catch {
484
+ return undefined;
485
+ }
486
+ }
487
+ function scanHooks(projectRoot, catalogHooks) {
488
+ const { config } = readHooksConfig(projectRoot);
489
+ const registeredNames = new Set();
490
+ if (config?.hooks && Array.isArray(config.hooks)) {
491
+ for (const entry of config.hooks) {
492
+ if (entry && typeof entry.name === "string")
493
+ registeredNames.add(entry.name);
494
+ }
495
+ }
496
+ const out = [];
497
+ for (const [name, def] of Object.entries(catalogHooks)) {
498
+ if (!registeredNames.has(name)) {
499
+ out.push({
500
+ name,
501
+ description: def.description,
502
+ status: "not-installed",
503
+ expectedHash: def.expectedHash,
504
+ });
505
+ continue;
506
+ }
507
+ const currentHash = hashHookIndex(projectRoot, name);
508
+ if (currentHash === undefined) {
509
+ // Registered but index.mjs missing — assumption #2: row present, not
510
+ // "installed", currentHash undefined so the UI can prompt repair.
511
+ out.push({
512
+ name,
513
+ description: def.description,
514
+ status: "not-installed",
515
+ expectedHash: def.expectedHash,
516
+ });
517
+ continue;
518
+ }
519
+ const status = currentHash === def.expectedHash ? "installed" : "update-available";
520
+ out.push({
521
+ name,
522
+ description: def.description,
523
+ status,
524
+ currentHash,
525
+ expectedHash: def.expectedHash,
526
+ });
527
+ }
528
+ return out;
529
+ }
530
+ // ---------------------------------------------------------------------------
531
+ // Default external-I/O implementations (used when ScanOptions are not
532
+ // injected — server.ts wires these up in production).
533
+ // ---------------------------------------------------------------------------
534
+ /** Default: run `claude plugins list --json` and `claude plugins list
535
+ * --available --json`. Returns null is NOT an option here — server.ts
536
+ * decides whether to pass this function based on `which claude`. */
537
+ export async function defaultExecPluginList() {
538
+ // Run both lookups in parallel via async exec so /api/state doesn't block
539
+ // the event loop. `claude plugins list` can take several seconds on cold
540
+ // marketplace fetches; sync exec would freeze heartbeats and other
541
+ // concurrent /api requests.
542
+ const [installedRes, availableRes] = await Promise.all([
543
+ execAsync("claude plugins list --json", { encoding: "utf8" }),
544
+ execAsync("claude plugins list --available --json", { encoding: "utf8" }),
545
+ ]);
546
+ const installed = parseJsonArray(installedRes.stdout);
547
+ const available = parseJsonArray(availableRes.stdout);
548
+ return { installed, available };
549
+ }
550
+ function parseJsonArray(text) {
551
+ try {
552
+ const parsed = JSON.parse(text);
553
+ return Array.isArray(parsed) ? parsed : [];
554
+ }
555
+ catch {
556
+ return [];
557
+ }
558
+ }
559
+ function codexHome() {
560
+ return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
561
+ }
562
+ export async function defaultReadCodexConfig() {
563
+ const configPath = path.join(codexHome(), "config.toml");
564
+ try {
565
+ return fs.readFileSync(configPath, "utf8");
566
+ }
567
+ catch {
568
+ return null;
569
+ }
570
+ }
571
+ /** Walk `~/.codex/plugins/cache/<marketplace>/<plugin>/<version>/`, returning
572
+ * the highest-mtime version per `<plugin>@<marketplace>` id. The version
573
+ * semantics are catalog-pinned, so we surface the directory name verbatim
574
+ * rather than try to semver-sort. */
575
+ export async function defaultReadCodexPluginsDir() {
576
+ const cacheRoot = path.join(codexHome(), "plugins", "cache");
577
+ const out = new Map();
578
+ let marketplaces;
579
+ try {
580
+ marketplaces = fs.readdirSync(cacheRoot);
581
+ }
582
+ catch {
583
+ return out;
584
+ }
585
+ for (const marketplace of marketplaces) {
586
+ const marketplaceDir = path.join(cacheRoot, marketplace);
587
+ let plugins;
588
+ try {
589
+ plugins = fs.readdirSync(marketplaceDir);
590
+ }
591
+ catch {
592
+ continue;
593
+ }
594
+ for (const plugin of plugins) {
595
+ const pluginDir = path.join(marketplaceDir, plugin);
596
+ let versions;
597
+ try {
598
+ versions = fs.readdirSync(pluginDir);
599
+ }
600
+ catch {
601
+ continue;
602
+ }
603
+ let best = null;
604
+ for (const version of versions) {
605
+ const versionDir = path.join(pluginDir, version);
606
+ try {
607
+ const stat = fs.statSync(versionDir);
608
+ if (!stat.isDirectory())
609
+ continue;
610
+ const mtime = stat.mtimeMs;
611
+ if (!best || mtime > best.mtime)
612
+ best = { name: version, mtime };
613
+ }
614
+ catch {
615
+ // skip
616
+ }
617
+ }
618
+ if (best)
619
+ out.set(`${plugin}@${marketplace}`, best.name);
620
+ }
621
+ }
622
+ return out;
623
+ }
@@ -0,0 +1,29 @@
1
+ export interface UiFetchResponse {
2
+ status: number;
3
+ body: Buffer;
4
+ }
5
+ export type UiFetcher = (url: string) => Promise<UiFetchResponse>;
6
+ export interface EnsureUiBundleOptions {
7
+ /** CLI version, e.g. "1.15.0". Used both in the cache path and the URL. */
8
+ version: string;
9
+ /** Root for cached bundles. Defaults to `~/.cache/auriga-cli`. */
10
+ cacheRoot?: string;
11
+ /** Injectable network function. Tests pass a fake; CLI omits and gets the
12
+ * built-in https fetcher. */
13
+ fetcher?: UiFetcher;
14
+ /** Optional progress sink so the CLI / server SSE can stream lines. */
15
+ onLog?: (line: string) => void;
16
+ }
17
+ /**
18
+ * Resolve / populate the per-version cache dir; return its absolute path.
19
+ *
20
+ * Algorithm:
21
+ * 1. If `<cacheRoot>/ui-v<version>/index.html` exists → cache hit, return.
22
+ * 2. Fetch tar.gz + .sha256 in parallel; verify hash.
23
+ * 3. Extract to a sibling tmp dir; rename atomically into place.
24
+ * 4. Evict older versions beyond CACHE_KEEP_COUNT.
25
+ *
26
+ * Any failure path cleans up its own scratch state and rejects with a
27
+ * descriptive Error so the CLI can surface "try `npx auriga-cli`" guidance.
28
+ */
29
+ export declare function ensureUiBundle(opts: EnsureUiBundleOptions): Promise<string>;