dxai-cli 1.0.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.
@@ -0,0 +1,209 @@
1
+ // Validation for registry-supplied values that flow into shell/exec/URL sinks.
2
+ //
3
+ // The catalog is fetched over the network (cache-over-bundled, see loader.js) and
4
+ // is only shape-checked on fetch. Any field that ends up in a spawned command, an
5
+ // installed package name, or a fetch URL must be validated here first, so a
6
+ // poisoned or redirected registry cannot achieve command execution or repoint a
7
+ // fetch.
8
+
9
+ // owner/name — exactly two path-safe segments (e.g. "anthropics/skills").
10
+ const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
11
+
12
+ // A skill's sub-path within its repo: "." or slash-separated safe segments,
13
+ // never absolute, never containing a ".." traversal segment.
14
+ const SKILL_PATH_RE = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
15
+
16
+ // npm package specifier (optionally scoped), no shell metacharacters.
17
+ const PACKAGE_SPEC_RE = /^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/i;
18
+
19
+ // Same, but allowing a trailing `@version` (e.g. "@scope/pkg@1.2.3", "pkg@latest").
20
+ const PACKAGE_SPEC_VERSIONED_RE = /^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*(@[\w.-]+)?$/i;
21
+
22
+ // Version / branch ref used to repoint the registry base URL. Path-safe, no
23
+ // traversal — a "../.." here would repoint the fetch at a different repo.
24
+ const VERSION_REF_RE = /^[A-Za-z0-9._-]+$/;
25
+
26
+ // Official MCP Registry server name: reverse-DNS namespace, one slash, name
27
+ // (schema pattern). E.g. "io.github.upstash/context7", "com.supabase/mcp".
28
+ const REGISTRY_NAME_RE = /^[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+$/;
29
+
30
+ // Catalog fields the registry resolver may own (see mcp-registry.js). Anything
31
+ // else on an entry is curated by hand and never touched by a sync.
32
+ export const RESOLVABLE_FIELDS = ['transport', 'requiresEnv', 'requiresInput', 'version', 'stale', 'staleReason'];
33
+ const PREFER_TRANSPORTS = new Set(['remote', 'package']);
34
+
35
+ // A bare binary name (no path separators, no shell metacharacters). Used for
36
+ // registry fields that name a command to *probe* for (e.g. a tool's
37
+ // `detectCommand`) — probing must never be able to execute anything else.
38
+ const BINARY_NAME_RE = /^[A-Za-z0-9._-]+$/;
39
+
40
+ // Binaries we are willing to spawn from registry data. Everything else is a
41
+ // command we don't recognize and won't execute on the user's behalf.
42
+ const COMMAND_ALLOWLIST = new Set([
43
+ 'npx', 'node', 'npm', 'bunx', 'bun', 'pnpm', 'yarn', 'deno',
44
+ 'uvx', 'uv', 'pipx', 'pip', 'pip3', 'python', 'python3', 'docker', 'claude',
45
+ ]);
46
+
47
+ export function isValidRepo(repo) {
48
+ return typeof repo === 'string' && REPO_RE.test(repo);
49
+ }
50
+
51
+ export function isValidSkillPath(p) {
52
+ if (typeof p !== 'string') return false;
53
+ if (p === '.') return true;
54
+ return SKILL_PATH_RE.test(p) && !p.split('/').includes('..');
55
+ }
56
+
57
+ export function isPackageSpec(tok) {
58
+ return typeof tok === 'string' && PACKAGE_SPEC_RE.test(tok);
59
+ }
60
+
61
+ export function isAllowedCommand(cmd) {
62
+ return typeof cmd === 'string' && COMMAND_ALLOWLIST.has(cmd);
63
+ }
64
+
65
+ export function isSafeVersionRef(v) {
66
+ return typeof v === 'string' && VERSION_REF_RE.test(v) && !v.split('/').includes('..');
67
+ }
68
+
69
+ export function isSafeBinaryName(name) {
70
+ return typeof name === 'string' && BINARY_NAME_RE.test(name);
71
+ }
72
+
73
+ export function isValidRegistryName(name) {
74
+ return typeof name === 'string' && REGISTRY_NAME_RE.test(name);
75
+ }
76
+
77
+ // Registry-supplied remote URLs are written into user configs and probed by
78
+ // catalog-health; only accept https so a poisoned record can't point a client
79
+ // at a plaintext endpoint.
80
+ export function isHttpsUrl(value) {
81
+ if (typeof value !== 'string') return false;
82
+ try {
83
+ return new URL(value).protocol === 'https:';
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ // Shape-check an entry's optional `registry` block (the link to the official
90
+ // MCP Registry). Returns problem strings; empty when clean or absent.
91
+ export function validateRegistryBlock(id, block) {
92
+ if (block === undefined) return [];
93
+ const problems = [];
94
+ if (!block || typeof block !== 'object') return [`server ${id}: registry must be an object`];
95
+ if (!isValidRegistryName(block.name)) problems.push(`server ${id}: invalid registry name "${block.name}"`);
96
+ const { prefer, resolved } = block;
97
+ if (prefer !== undefined) {
98
+ if (!prefer || typeof prefer !== 'object') problems.push(`server ${id}: registry.prefer must be an object`);
99
+ else {
100
+ if (prefer.transport !== undefined && !PREFER_TRANSPORTS.has(prefer.transport)) {
101
+ problems.push(`server ${id}: registry.prefer.transport must be remote|package`);
102
+ }
103
+ if (prefer.remote !== undefined && typeof prefer.remote !== 'string') {
104
+ problems.push(`server ${id}: registry.prefer.remote must be a string`);
105
+ }
106
+ if (prefer.pin !== undefined && typeof prefer.pin !== 'boolean') {
107
+ problems.push(`server ${id}: registry.prefer.pin must be a boolean`);
108
+ }
109
+ }
110
+ }
111
+ if (resolved !== undefined) {
112
+ if (!resolved || typeof resolved !== 'object' || !Array.isArray(resolved.fields)) {
113
+ problems.push(`server ${id}: registry.resolved must carry a fields array`);
114
+ } else {
115
+ for (const f of resolved.fields) {
116
+ if (!RESOLVABLE_FIELDS.includes(f)) problems.push(`server ${id}: registry.resolved.fields has unknown field "${f}"`);
117
+ }
118
+ }
119
+ }
120
+ return problems;
121
+ }
122
+
123
+ // Reject registry ids that could pollute Object.prototype when used as a map key.
124
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
125
+ export function isSafeId(id) {
126
+ return typeof id === 'string' && id.length > 0 && !UNSAFE_KEYS.has(id);
127
+ }
128
+
129
+ // Split a registry-supplied install command string into argv for shell-free
130
+ // execution. Rejects any shell metacharacter so the string can never be
131
+ // interpreted as more than a single command + plain arguments. Throws on an
132
+ // unsafe command or a disallowed leading binary.
133
+ const SHELL_META_RE = /[;&|`$(){}<>\\"'*?~\n\r]/;
134
+ export function parseSafeCommand(str) {
135
+ if (typeof str !== 'string' || !str.trim()) {
136
+ throw new Error('empty command');
137
+ }
138
+ if (SHELL_META_RE.test(str)) {
139
+ throw new Error(`command contains shell metacharacters: ${str}`);
140
+ }
141
+ const parts = str.trim().split(/\s+/);
142
+ const [command, ...args] = parts;
143
+ if (!isAllowedCommand(command)) {
144
+ throw new Error(`command not in allowlist: ${command}`);
145
+ }
146
+ return { command, args };
147
+ }
148
+
149
+ // Validate a server's spawn/transport spec before it is executed (handshake) or
150
+ // recorded. Returns true only when the command is allowlisted and every npm
151
+ // package argument is a clean package spec.
152
+ // Defense-in-depth at the fetch boundary: vet a freshly-fetched registry payload
153
+ // before it is cached and trusted by every later run. Returns an array of problem
154
+ // strings (empty = clean). Each entry's id must be map-key-safe; command-bearing
155
+ // fields must use an allowlisted binary; skills' repo/path must be clean. A bad
156
+ // file is rejected wholesale (caller falls back to the bundled snapshot) rather
157
+ // than silently caching a poisoned entry.
158
+ export function validateRegistryPayload(listKey, items) {
159
+ const problems = [];
160
+ for (const item of items) {
161
+ const id = item?.id;
162
+ if (!isSafeId(id)) {
163
+ problems.push(`entry with invalid id: ${JSON.stringify(id)}`);
164
+ continue;
165
+ }
166
+ if (listKey === 'skills') {
167
+ if (!isValidRepo(item.repo)) problems.push(`skill ${id}: invalid repo "${item.repo}"`);
168
+ if (!isValidSkillPath(item.path)) problems.push(`skill ${id}: invalid path "${item.path}"`);
169
+ } else if (listKey === 'tools') {
170
+ for (const cmd of Object.values(item.installCommand || {})) {
171
+ try { parseSafeCommand(cmd); }
172
+ catch (err) { problems.push(`tool ${id}: ${err.message}`); }
173
+ }
174
+ // detectCommand is probed for existence on the user's PATH; a poisoned
175
+ // value must not be able to smuggle anything past that probe.
176
+ if (item.detectCommand !== undefined && !isSafeBinaryName(item.detectCommand)) {
177
+ problems.push(`tool ${id}: detectCommand is not a bare binary name "${item.detectCommand}"`);
178
+ }
179
+ } else if (listKey === 'servers') {
180
+ problems.push(...validateRegistryBlock(id, item.registry));
181
+ const cmd = item.transport?.command;
182
+ if (cmd !== undefined && !isAllowedCommand(cmd)) {
183
+ problems.push(`server ${id}: transport command not allowlisted "${cmd}"`);
184
+ }
185
+ for (const [agent, cfg] of Object.entries(item.configs || {})) {
186
+ if (cfg && typeof cfg.command === 'string' && cfg.command !== 'claude' && !isAllowedCommand(cfg.command)) {
187
+ problems.push(`server ${id} (${agent}): command not allowlisted "${cfg.command}"`);
188
+ }
189
+ }
190
+ if (item.excludeAgents !== undefined && !(Array.isArray(item.excludeAgents) && item.excludeAgents.every(isSafeId))) {
191
+ problems.push(`server ${id}: excludeAgents must be a list of agent ids`);
192
+ }
193
+ }
194
+ }
195
+ return problems;
196
+ }
197
+
198
+ const PACKAGE_LAUNCHERS = new Set(['npx', 'bunx', 'uvx', 'pnpm']);
199
+ export function isSafeSpawnSpec(spec) {
200
+ if (!spec || !isAllowedCommand(spec.command)) return false;
201
+ const args = spec.args || [];
202
+ if (!Array.isArray(args)) return false;
203
+ // For npx/uvx-style launchers the package name is the payload — vet it.
204
+ if (PACKAGE_LAUNCHERS.has(spec.command)) {
205
+ const pkg = args.find((a) => typeof a === 'string' && !a.startsWith('-') && a !== 'dlx');
206
+ if (pkg && !PACKAGE_SPEC_VERSIONED_RE.test(pkg)) return false;
207
+ }
208
+ return true;
209
+ }
@@ -0,0 +1,182 @@
1
+ // `dxai rollback` — restore a config/project file from the most recent
2
+ // `.bak.<ts>` snapshot dxai wrote before it last modified that file.
3
+ //
4
+ // Every writer backs up the target as `<file>.bak.<ts>` (see backupFile in
5
+ // config-writer.js). This command finds those snapshots next to the files dxai
6
+ // manages, picks the newest per file, and restores it — snapshotting the current
7
+ // file first so the rollback is itself reversible.
8
+
9
+ import inquirer from 'inquirer';
10
+ import fs from 'fs-extra';
11
+ import path from 'path';
12
+ import os from 'os';
13
+
14
+ import {
15
+ printBanner, sectionHeader, successMsg, warnMsg, infoMsg, theme,
16
+ } from './branding.js';
17
+ import { AGENT_DEFINITIONS } from './detect.js';
18
+ import { scanBackupFiles, scanProjectFiles } from './config-remover.js';
19
+
20
+ // Timestamp suffix of a `<name>.bak.<ts>` file (`YYYY-MM-DDTHH-MM-SS`), or '' if
21
+ // the name doesn't carry one.
22
+ const BAK_RE = /\.bak\.([0-9T-]+)$/;
23
+ export function backupTimestamp(backupPath) {
24
+ const m = path.basename(backupPath).match(BAK_RE);
25
+ return m ? m[1] : '';
26
+ }
27
+
28
+ // The most recent `.bak.<ts>` sibling of `originalPath`, or null if none exist.
29
+ // The timestamp format is lexicographically ordered, so a string max is
30
+ // chronological — no date parsing needed.
31
+ export function latestBackupFor(originalPath) {
32
+ const backups = scanBackupFiles([originalPath]).filter((b) => BAK_RE.test(b));
33
+ if (backups.length === 0) return null;
34
+ let best = null;
35
+ let bestTs = '';
36
+ for (const b of backups) {
37
+ const ts = backupTimestamp(b);
38
+ if (ts >= bestTs) { bestTs = ts; best = b; }
39
+ }
40
+ return { backup: best, ts: bestTs };
41
+ }
42
+
43
+ // For each candidate original path, find its newest backup. Returns
44
+ // [{ original, backup, ts }] only for paths that actually have a backup.
45
+ export function collectRestorable(originalPaths) {
46
+ const out = [];
47
+ const seen = new Set();
48
+ for (const original of originalPaths) {
49
+ if (!original || seen.has(original)) continue;
50
+ seen.add(original);
51
+ const latest = latestBackupFor(original);
52
+ if (latest) out.push({ original, backup: latest.backup, ts: latest.ts });
53
+ }
54
+ return out;
55
+ }
56
+
57
+ // Fresh backup timestamp, mirroring config-writer's backupFile format.
58
+ function nowTs() {
59
+ return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
60
+ }
61
+
62
+ // Restore `backup` over `original`, first snapshotting the current `original`
63
+ // (when it exists) to `<original>.bak.<ts>` so the rollback can be undone.
64
+ export function restoreBackup(original, backup, { dryRun = false } = {}) {
65
+ const result = { original, backup, savedCurrentTo: null };
66
+ if (dryRun) return result;
67
+ if (fs.existsSync(original)) {
68
+ const snapshot = `${original}.bak.${nowTs()}`;
69
+ fs.copySync(original, snapshot);
70
+ result.savedCurrentTo = snapshot;
71
+ }
72
+ fs.copySync(backup, original, { overwrite: true });
73
+ return result;
74
+ }
75
+
76
+ // The files dxai may have backed up: file-based agent global configs (CLI agents
77
+ // write no files) plus dxai-generated project files in the cwd.
78
+ function candidateOriginals(home, cwd) {
79
+ const agentPaths = AGENT_DEFINITIONS
80
+ .filter((a) => a.configFormat !== 'cli')
81
+ .map((a) => { try { return a.globalMcpPath(home); } catch { return null; } })
82
+ .filter(Boolean);
83
+ const projectPaths = scanProjectFiles(cwd).map((f) => f.absolutePath);
84
+ return [...agentPaths, ...projectPaths];
85
+ }
86
+
87
+ // Render an absolute path with the home dir collapsed to `~` for readability.
88
+ function displayPath(p, home) {
89
+ return p.startsWith(home + path.sep) ? `~${p.slice(home.length)}` : p;
90
+ }
91
+
92
+ export async function rollbackCmd(opts = {}) {
93
+ const json = !!opts.json;
94
+ const dryRun = !!opts.dryRun || process.env.DXAI_DRY_RUN === '1';
95
+ const listOnly = !!opts.list;
96
+ const home = os.homedir();
97
+ const cwd = process.cwd();
98
+
99
+ const restorable = collectRestorable(candidateOriginals(home, cwd));
100
+
101
+ if (!json) {
102
+ printBanner();
103
+ sectionHeader('Rollback — restore config backups');
104
+ }
105
+
106
+ if (restorable.length === 0) {
107
+ if (json) {
108
+ process.stdout.write(JSON.stringify({ ok: true, restored: [], available: [] }, null, 2) + '\n');
109
+ } else {
110
+ infoMsg('No dxai backup files (.bak.*) found to restore from.');
111
+ console.log();
112
+ }
113
+ return;
114
+ }
115
+
116
+ // List-only: report available backups and stop.
117
+ if (listOnly) {
118
+ if (json) {
119
+ process.stdout.write(JSON.stringify({ ok: true, available: restorable }, null, 2) + '\n');
120
+ return;
121
+ }
122
+ for (const r of restorable) {
123
+ console.log(` ${theme.label(displayPath(r.original, home))}`);
124
+ console.log(` ${theme.dim(`↩ ${path.basename(r.backup)}`)}`);
125
+ }
126
+ console.log();
127
+ infoMsg('Run `dxai rollback` to restore, or `--dry-run` to preview.');
128
+ console.log();
129
+ return;
130
+ }
131
+
132
+ // Select which files to restore. Non-interactive (--yes / --json) restores the
133
+ // latest backup for every file; interactive lets the user pick.
134
+ let selected = restorable;
135
+ if (!opts.yes && !json) {
136
+ const { picks } = await inquirer.prompt([
137
+ {
138
+ type: 'checkbox',
139
+ name: 'picks',
140
+ message: 'Select files to restore from their latest backup:',
141
+ choices: restorable.map((r) => ({
142
+ name: `${displayPath(r.original, home)} ${theme.dim(`(${path.basename(r.backup)})`)}`,
143
+ value: r.original,
144
+ checked: true,
145
+ })),
146
+ },
147
+ ]);
148
+ selected = restorable.filter((r) => picks.includes(r.original));
149
+ }
150
+
151
+ if (selected.length === 0) {
152
+ if (!json) { infoMsg('Nothing selected.'); console.log(); }
153
+ else process.stdout.write(JSON.stringify({ ok: true, restored: [] }, null, 2) + '\n');
154
+ return;
155
+ }
156
+
157
+ const restored = [];
158
+ for (const r of selected) {
159
+ const res = restoreBackup(r.original, r.backup, { dryRun });
160
+ restored.push({ ...res, ts: r.ts });
161
+ }
162
+
163
+ if (json) {
164
+ process.stdout.write(JSON.stringify({ ok: true, dryRun, restored }, null, 2) + '\n');
165
+ return;
166
+ }
167
+
168
+ console.log();
169
+ for (const r of restored) {
170
+ if (dryRun) {
171
+ infoMsg(`Would restore ${displayPath(r.original, home)} ← ${path.basename(r.backup)}`);
172
+ } else {
173
+ successMsg(`Restored ${displayPath(r.original, home)} ← ${path.basename(r.backup)}`);
174
+ if (r.savedCurrentTo) {
175
+ console.log(` ${theme.dim(`(previous version saved to ${path.basename(r.savedCurrentTo)})`)}`);
176
+ }
177
+ }
178
+ }
179
+ console.log();
180
+ if (dryRun) warnMsg('Dry run — no files were changed.');
181
+ console.log();
182
+ }
package/src/runtime.js ADDED
@@ -0,0 +1,40 @@
1
+ import { normalizeAgentIds } from './detect.js';
2
+ // Runtime options + non-interactive helpers.
3
+ // Centralizes how we decide whether to prompt or use flag-provided values.
4
+
5
+ export function normalizeOptions(opts = {}) {
6
+ const ci = process.env.CI === 'true' || process.env.CI === '1';
7
+ const dryRun = !!opts.dryRun || process.env.DXAI_DRY_RUN === '1';
8
+ const json = !!opts.json;
9
+ // --yes implies non-interactive. CI=true also implies non-interactive (and json output).
10
+ const yes = !!opts.yes || ci;
11
+ const nonInteractive = yes;
12
+ return {
13
+ yes,
14
+ ci,
15
+ dryRun,
16
+ json: json || ci,
17
+ nonInteractive,
18
+ // --no-update sets opts.update === false (commander negation).
19
+ update: opts.update !== false,
20
+ agents: opts.agents ? normalizeAgentIds(opts.agents) : opts.agents,
21
+ mcp: opts.mcp,
22
+ skills: opts.skills,
23
+ tools: opts.tools,
24
+ features: opts.features,
25
+ stack: opts.stack,
26
+ };
27
+ }
28
+
29
+ // Validate that user-supplied IDs exist in the registry. Returns { valid, invalid }.
30
+ export function partitionByKnown(ids, knownIds) {
31
+ if (!ids || ids.length === 0) return { valid: [], invalid: [] };
32
+ const set = new Set(knownIds);
33
+ const valid = [];
34
+ const invalid = [];
35
+ for (const id of ids) {
36
+ if (set.has(id)) valid.push(id);
37
+ else invalid.push(id);
38
+ }
39
+ return { valid, invalid };
40
+ }
package/src/select.js ADDED
@@ -0,0 +1,72 @@
1
+ // Shared selection resolution for the setup flows.
2
+ //
3
+ // Every selectable registry (agents, MCP servers, skills, automation tools,
4
+ // stacks, features) resolves the same way, in the same precedence order:
5
+ // 1. explicit flag values → validate against known IDs, throw with a hint
6
+ // 2. non-interactive mode → computed defaults
7
+ // 3. interactive mode → prompt
8
+ // This module centralizes that flow so the wizard code declares *what* each
9
+ // selection looks like instead of re-implementing the triplet per registry.
10
+
11
+ import inquirer from 'inquirer';
12
+ import chalk from 'chalk';
13
+ import { partitionByKnown } from './runtime.js';
14
+
15
+ // Resolve one selection. `flag` is the raw CLI flag value (undefined = not
16
+ // passed), `defaults`/`prompt` are lazy so they only run when actually needed.
17
+ export async function resolveSelection({
18
+ flag,
19
+ knownIds,
20
+ label,
21
+ requireNonEmpty = false,
22
+ nonInteractive,
23
+ defaults,
24
+ prompt,
25
+ }) {
26
+ if (flag !== undefined) {
27
+ const { valid, invalid } = partitionByKnown(flag, knownIds);
28
+ if (invalid.length > 0) {
29
+ throw new Error(`Unknown ${label}(s): ${invalid.join(', ')}. Known: ${knownIds.join(', ')}`);
30
+ }
31
+ if (requireNonEmpty && valid.length === 0) {
32
+ throw new Error(`No valid ${label}s specified.`);
33
+ }
34
+ return valid;
35
+ }
36
+ if (nonInteractive) return defaults();
37
+ return prompt();
38
+ }
39
+
40
+ // Build inquirer checkbox choices for a categorized catalog (MCP servers,
41
+ // skills, automation tools): a cyan separator per category, ★ on recommended
42
+ // entries, checked-by-default when recommended. `decorate(item)` may return
43
+ // { status, note, checked } to append per-item annotations or override the
44
+ // default checked state.
45
+ export function buildCatalogChoices(categories, items, { decorate } = {}) {
46
+ const choices = [];
47
+ for (const cat of categories) {
48
+ const catItems = items.filter((i) => i.category === cat.id);
49
+ if (catItems.length === 0) continue;
50
+
51
+ const header = chalk.cyan(`\n ${cat.label} `) + (cat.description ? chalk.dim(cat.description) : '');
52
+ choices.push(new inquirer.Separator(header));
53
+ for (const item of catItems) {
54
+ const rec = item.recommended ? chalk.yellow(' ★') : '';
55
+ const extra = decorate ? decorate(item) || {} : {};
56
+ choices.push({
57
+ name: `${item.name}${rec}${extra.status || ''} — ${chalk.dim(item.description)}${extra.note || ''}`,
58
+ value: item.id,
59
+ checked: extra.checked ?? !!item.recommended,
60
+ });
61
+ }
62
+ }
63
+ return choices;
64
+ }
65
+
66
+ // Yes/no prompt. Returns the boolean answer.
67
+ export async function confirm(message, { defaultValue = true } = {}) {
68
+ const { answer } = await inquirer.prompt([
69
+ { type: 'confirm', name: 'answer', message, default: defaultValue },
70
+ ]);
71
+ return answer;
72
+ }
package/src/update.js ADDED
@@ -0,0 +1,126 @@
1
+ import {
2
+ fetchRegistry, writeRegistryCache, loadRegistry,
3
+ diffRegistry, registryBaseFor,
4
+ } from './registry/loader.js';
5
+ import { validateRegistryPayload } from './registry/validate.js';
6
+ import { resolveEntries } from './registry/mcp-registry.js';
7
+ import {
8
+ printBanner, sectionHeader, successMsg, warnMsg, errorMsg, infoMsg, theme,
9
+ } from './branding.js';
10
+
11
+ const REGISTRY_FILES = [
12
+ { name: 'mcp-servers', listKey: 'servers' },
13
+ { name: 'skills', listKey: 'skills' },
14
+ { name: 'automation-tools', listKey: 'tools' },
15
+ ];
16
+
17
+ // Re-resolve the registry-linked MCP servers live from the official MCP
18
+ // Registry. The fetched snapshot was already resolved by the maintainer-side
19
+ // sync, so this only matters for users who want the very latest; any failure
20
+ // (offline, a bad record, a resolver problem) keeps the snapshot values.
21
+ // Returns { data, summary } where summary is null when nothing was attempted.
22
+ async function resolveLive(data, { timeoutMs, resolveFn }) {
23
+ const { entries, results } = await resolveFn(data.servers, { timeoutMs, retries: 0 });
24
+ if (results.length === 0) return { data, summary: null };
25
+ const problems = validateRegistryPayload('servers', entries);
26
+ const summary = {
27
+ resolved: results.filter((r) => r.ok).length,
28
+ failed: results.filter((r) => !r.ok).map((r) => ({ id: r.id, error: r.error })),
29
+ changed: results.filter((r) => r.ok && r.changed).map((r) => r.id),
30
+ };
31
+ if (problems.length) {
32
+ summary.error = `live resolution rejected: ${problems.slice(0, 3).join('; ')}`;
33
+ return { data, summary };
34
+ }
35
+ return { data: { ...data, servers: entries }, summary };
36
+ }
37
+
38
+ // Fetch every registry file from `base`, validate shape, write the cache, and diff
39
+ // against the previously-resolved registry. Returns a results array (one per file);
40
+ // a per-file fetch/validation failure is captured as { ok: false, error } rather than
41
+ // thrown, so one bad file doesn't sink the rest. Pure of any output — callers print.
42
+ // `resolve` (default true) re-resolves registry-linked MCP servers live after the
43
+ // snapshot is fetched; the background auto-refresh passes false to stay cheap.
44
+ export async function refreshRegistry({
45
+ base = registryBaseFor({}), timeoutMs, retries, resolve = true,
46
+ fetch = fetchRegistry, resolveFn = resolveEntries, writeCache = writeRegistryCache, loadPrev = loadRegistry,
47
+ } = {}) {
48
+ const results = [];
49
+ for (const { name, listKey } of REGISTRY_FILES) {
50
+ const before = (() => {
51
+ try { return loadPrev(name); } catch { return null; }
52
+ })();
53
+ try {
54
+ let { url, data } = await fetch(name, base, { timeoutMs, retries });
55
+ // Basic shape check — must have an array under listKey.
56
+ if (!Array.isArray(data?.[listKey])) {
57
+ throw new Error(`Registry payload missing "${listKey}" array`);
58
+ }
59
+ // Security: vet untrusted fields (ids, commands, repo/path) before caching,
60
+ // so a poisoned/redirected registry can't seed a malicious entry that later
61
+ // drives command execution. A bad file is rejected; bundled fallback stands.
62
+ const problems = validateRegistryPayload(listKey, data[listKey]);
63
+ if (problems.length) {
64
+ throw new Error(`Registry payload failed validation: ${problems.slice(0, 3).join('; ')}${problems.length > 3 ? ` (+${problems.length - 3} more)` : ''}`);
65
+ }
66
+ let live = null;
67
+ if (name === 'mcp-servers' && resolve) {
68
+ try {
69
+ ({ data, summary: live } = await resolveLive(data, { timeoutMs, resolveFn }));
70
+ } catch (err) {
71
+ live = { resolved: 0, failed: [], changed: [], error: err.message };
72
+ }
73
+ }
74
+ const cachePath = writeCache(name, data);
75
+ const diff = diffRegistry(before, data, listKey);
76
+ results.push({ name, url, cachePath, ok: true, count: data[listKey].length, ...diff, ...(live ? { live } : {}) });
77
+ } catch (err) {
78
+ results.push({ name, ok: false, error: err.message });
79
+ }
80
+ }
81
+ return results;
82
+ }
83
+
84
+ export async function updateCmd(opts = {}) {
85
+ const json = !!opts.json;
86
+ const base = registryBaseFor({ version: opts.registryVersion, url: opts.registryUrl });
87
+ if (!json) {
88
+ printBanner();
89
+ sectionHeader('Update — refreshing registry');
90
+ infoMsg(`Source: ${base}`);
91
+ console.log();
92
+ }
93
+
94
+ const results = await refreshRegistry({ base, resolve: opts.resolve !== false });
95
+
96
+ if (!json) {
97
+ for (const r of results) {
98
+ if (r.ok) {
99
+ const liveNote = r.live ? `, ${r.live.resolved} re-resolved from the MCP Registry` : '';
100
+ successMsg(`${r.name}: cached (${r.count} entries${liveNote}) → ${r.cachePath}`);
101
+ if (r.added.length) console.log(` ${theme.label('+ added:')} ${r.added.join(', ')}`);
102
+ if (r.removed.length) console.log(` ${theme.label('- removed:')} ${r.removed.join(', ')}`);
103
+ if (r.live?.changed.length) console.log(` ${theme.label('~ updated live:')} ${r.live.changed.join(', ')}`);
104
+ if (r.live?.error) warnMsg(`${r.name}: ${r.live.error}; kept the snapshot values`);
105
+ for (const f of r.live?.failed || []) warnMsg(`${r.name}/${f.id}: ${f.error}; kept the snapshot values`);
106
+ } else {
107
+ errorMsg(`${r.name}: ${r.error}`);
108
+ }
109
+ }
110
+ }
111
+
112
+ if (json) {
113
+ process.stdout.write(JSON.stringify({ ok: results.every((r) => r.ok), results }, null, 2) + '\n');
114
+ if (results.some((r) => !r.ok)) process.exit(1);
115
+ return;
116
+ }
117
+
118
+ console.log();
119
+ const failed = results.filter((r) => !r.ok);
120
+ if (failed.length === 0) {
121
+ successMsg('Registry up to date.');
122
+ } else {
123
+ warnMsg(`${failed.length} fetch(es) failed; using bundled fallback for those.`);
124
+ }
125
+ console.log();
126
+ }