xtrm-cli 2.1.4

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.
Files changed (50) hide show
  1. package/.gemini/settings.json +39 -0
  2. package/dist/index.cjs +55937 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +2 -0
  5. package/index.js +151 -0
  6. package/lib/atomic-config.js +236 -0
  7. package/lib/config-adapter.js +231 -0
  8. package/lib/config-injector.js +80 -0
  9. package/lib/context.js +73 -0
  10. package/lib/diff.js +142 -0
  11. package/lib/env-manager.js +160 -0
  12. package/lib/sync-mcp-cli.js +345 -0
  13. package/lib/sync.js +227 -0
  14. package/lib/transform-gemini.js +119 -0
  15. package/package.json +43 -0
  16. package/src/adapters/base.ts +29 -0
  17. package/src/adapters/claude.ts +38 -0
  18. package/src/adapters/registry.ts +21 -0
  19. package/src/commands/help.ts +171 -0
  20. package/src/commands/install-project.ts +566 -0
  21. package/src/commands/install-service-skills.ts +251 -0
  22. package/src/commands/install.ts +534 -0
  23. package/src/commands/reset.ts +12 -0
  24. package/src/commands/status.ts +170 -0
  25. package/src/core/context.ts +141 -0
  26. package/src/core/diff.ts +143 -0
  27. package/src/core/interactive-plan.ts +165 -0
  28. package/src/core/manifest.ts +26 -0
  29. package/src/core/preflight.ts +142 -0
  30. package/src/core/rollback.ts +32 -0
  31. package/src/core/sync-executor.ts +399 -0
  32. package/src/index.ts +69 -0
  33. package/src/types/config.ts +51 -0
  34. package/src/types/models.ts +52 -0
  35. package/src/utils/atomic-config.ts +222 -0
  36. package/src/utils/banner.ts +194 -0
  37. package/src/utils/config-adapter.ts +90 -0
  38. package/src/utils/config-injector.ts +81 -0
  39. package/src/utils/env-manager.ts +193 -0
  40. package/src/utils/hash.ts +42 -0
  41. package/src/utils/repo-root.ts +39 -0
  42. package/src/utils/sync-mcp-cli.ts +467 -0
  43. package/src/utils/theme.ts +37 -0
  44. package/test/context.test.ts +33 -0
  45. package/test/hooks.test.ts +277 -0
  46. package/test/install-project.test.ts +235 -0
  47. package/test/install-service-skills.test.ts +111 -0
  48. package/tsconfig.json +22 -0
  49. package/tsup.config.ts +17 -0
  50. package/vitest.config.ts +9 -0
@@ -0,0 +1,467 @@
1
+ import { execSync, exec } from 'child_process';
2
+ import { promisify } from 'util';
3
+ const execAsync = promisify(exec);
4
+ import fs from 'fs-extra';
5
+ import path from 'path';
6
+ import kleur from 'kleur';
7
+ // @ts-ignore
8
+ import ora from 'ora';
9
+ import { ensureEnvFile, loadEnvFile, checkRequiredEnvVars, handleMissingEnvVars, getEnvFilePath } from './env-manager.js';
10
+
11
+ export type AgentName = 'claude' | 'gemini' | 'qwen';
12
+
13
+ interface AgentCLI {
14
+ command: string;
15
+ listArgs: string[];
16
+ addStdio: (name: string, cmd: string, args?: string[], env?: Record<string, string>) => string[];
17
+ addHttp: (name: string, url: string, headers?: Record<string, string>) => string[];
18
+ addSse: (name: string, url: string) => string[];
19
+ remove: (name: string) => string[];
20
+ parseList: (output: string) => string[];
21
+ }
22
+
23
+ const AGENT_CLI: Record<AgentName, AgentCLI> = {
24
+ claude: {
25
+ command: 'claude',
26
+ listArgs: ['mcp', 'list'],
27
+ addStdio: (name, cmd, args, env) => {
28
+ const base = ['mcp', 'add', '-s', 'user', name, '--'];
29
+ if (env && Object.keys(env).length > 0) {
30
+ for (const [key, value] of Object.entries(env)) {
31
+ base.push('-e', `${key}=${resolveEnvVar(value)}`);
32
+ }
33
+ }
34
+ base.push(cmd, ...(args || []));
35
+ return base;
36
+ },
37
+ addHttp: (name, url, headers) => {
38
+ const base = ['mcp', 'add', '-s', 'user', '--transport', 'http', name, url];
39
+ if (headers) {
40
+ for (const [key, value] of Object.entries(headers)) {
41
+ base.push('--header', `${key}: ${resolveEnvVar(value)}`);
42
+ }
43
+ }
44
+ return base;
45
+ },
46
+ addSse: (name, url) => {
47
+ return ['mcp', 'add', '-s', 'user', '--transport', 'sse', name, url];
48
+ },
49
+ remove: (name) => ['mcp', 'remove', '-s', 'user', name],
50
+ parseList: (output) => parseMcpListOutput(output, /^([a-zA-Z0-9_-]+):/)
51
+ },
52
+ gemini: {
53
+ command: 'gemini',
54
+ listArgs: ['mcp', 'list'], // list doesn't support -s flag, lists all scopes
55
+ addStdio: (name, cmd, args, env) => {
56
+ const base = ['mcp', 'add', '-s', 'user', name, cmd];
57
+ if (args && args.length > 0) base.push(...args);
58
+ if (env && Object.keys(env).length > 0) {
59
+ for (const [key, value] of Object.entries(env)) {
60
+ base.push('-e', `${key}=${resolveEnvVar(value)}`);
61
+ }
62
+ }
63
+ return base;
64
+ },
65
+ addHttp: (name, url, headers) => {
66
+ const base = ['mcp', 'add', '-s', 'user', '-t', 'http', name, url];
67
+ if (headers) {
68
+ for (const [key, value] of Object.entries(headers)) {
69
+ base.push('-H', `${key}=${resolveEnvVar(value)}`);
70
+ }
71
+ }
72
+ return base;
73
+ },
74
+ addSse: (name, url) => {
75
+ return ['mcp', 'add', '-s', 'user', '-t', 'sse', name, url];
76
+ },
77
+ remove: (name) => ['mcp', 'remove', '-s', 'user', name],
78
+ parseList: (output) => parseMcpListOutput(output, /^✓ ([a-zA-Z0-9_-]+):/)
79
+ },
80
+ qwen: {
81
+ command: 'qwen',
82
+ listArgs: ['mcp', 'list'],
83
+ addStdio: (name, cmd, args, env) => {
84
+ const base = ['mcp', 'add', '-s', 'user', name, cmd];
85
+ if (args && args.length > 0) base.push(...args);
86
+ if (env && Object.keys(env).length > 0) {
87
+ for (const [key, value] of Object.entries(env)) {
88
+ base.push('-e', `${key}=${resolveEnvVar(value)}`);
89
+ }
90
+ }
91
+ return base;
92
+ },
93
+ addHttp: (name, url, headers) => {
94
+ const base = ['mcp', 'add', '-s', 'user', '-t', 'http', name, url];
95
+ if (headers) {
96
+ for (const [key, value] of Object.entries(headers)) {
97
+ base.push('-H', `${key}=${resolveEnvVar(value)}`);
98
+ }
99
+ }
100
+ return base;
101
+ },
102
+ addSse: (name, url) => {
103
+ return ['mcp', 'add', '-s', 'user', '-t', 'sse', name, url];
104
+ },
105
+ remove: (name) => ['mcp', 'remove', '-s', 'user', name],
106
+ parseList: (output) => parseMcpListOutput(output, /^✓ ([a-zA-Z0-9_-]+):/)
107
+ }
108
+ };
109
+
110
+ // Strip ANSI escape codes (e.g. qwen wraps ✓ in color codes)
111
+ function stripAnsi(str: string): string {
112
+ return str.replace(/\x1b\[[0-9;]*m/g, '');
113
+ }
114
+
115
+ function parseMcpListOutput(output: string, pattern: RegExp): string[] {
116
+ const servers: string[] = [];
117
+ for (const line of output.split('\n')) {
118
+ const match = stripAnsi(line).match(pattern);
119
+ if (match) {
120
+ servers.push(match[1]);
121
+ }
122
+ }
123
+ return servers;
124
+ }
125
+
126
+ function resolveEnvVar(value: string): string {
127
+ if (typeof value !== 'string') return value;
128
+
129
+ return value.replace(/\$\{([A-Z0-9_]+)\}/g, (_match, envName) => {
130
+ const upperName = envName.toUpperCase();
131
+ const envValue = process.env[upperName];
132
+ if (envValue) {
133
+ return envValue;
134
+ } else {
135
+ console.warn(kleur.yellow(` ⚠️ Environment variable ${upperName} is not set in ${getEnvFilePath()}`));
136
+ return '';
137
+ }
138
+ });
139
+ }
140
+
141
+ export function detectAgent(systemRoot: string): AgentName | null {
142
+ const normalizedRoot = systemRoot.replace(/\\/g, '/').toLowerCase();
143
+ if (normalizedRoot.includes('.claude') || normalizedRoot.includes('/claude')) {
144
+ return 'claude';
145
+ } else if (normalizedRoot.includes('.gemini') || normalizedRoot.includes('/gemini')) {
146
+ return 'gemini';
147
+ } else if (normalizedRoot.includes('.qwen') || normalizedRoot.includes('/qwen')) {
148
+ return 'qwen';
149
+ }
150
+ return null;
151
+ }
152
+
153
+ function buildAddCommand(agent: AgentName, name: string, server: any): string[] | null {
154
+ const cli = AGENT_CLI[agent];
155
+ if (!cli) return null;
156
+
157
+ if (server.url || server.serverUrl) {
158
+ const url = server.url || server.serverUrl;
159
+ const type = server.type || (url.includes('/sse') ? 'sse' : 'http');
160
+
161
+ if (type === 'sse') {
162
+ return cli.addSse(name, url);
163
+ } else {
164
+ return cli.addHttp(name, url, server.headers);
165
+ }
166
+ }
167
+
168
+ if (server.command) {
169
+ return cli.addStdio(name, server.command, server.args, server.env);
170
+ }
171
+
172
+ console.warn(kleur.yellow(` ⚠️ Skipping server "${name}": Unknown configuration`));
173
+ return null;
174
+ }
175
+
176
+ interface CommandResult {
177
+ success: boolean;
178
+ dryRun?: boolean;
179
+ skipped?: boolean;
180
+ error?: string;
181
+ }
182
+
183
+ function executeCommand(agent: AgentName, args: string[], dryRun: boolean = false, displayName?: string): CommandResult {
184
+ const cli = AGENT_CLI[agent];
185
+
186
+ const quotedArgs = args.map(arg => {
187
+ if (arg.includes(' ') && !arg.startsWith('"') && !arg.startsWith("'")) {
188
+ return `"${arg}"`;
189
+ }
190
+ return arg;
191
+ });
192
+ const command = `${cli.command} ${quotedArgs.join(' ')}`;
193
+
194
+ if (dryRun) {
195
+ console.log(kleur.cyan(` [DRY RUN] ${displayName ?? args.slice(2).join(' ')}`));
196
+ return { success: true, dryRun: true };
197
+ }
198
+
199
+ try {
200
+ execSync(command, { stdio: 'pipe', timeout: 10000 });
201
+ console.log(kleur.green(` ✓ ${displayName ?? args.slice(2).join(' ')}`));
202
+ return { success: true };
203
+ } catch (error: any) {
204
+ const stderr = error.stderr?.toString() || error.message;
205
+
206
+ if (stderr.includes('already exists') || stderr.includes('already configured')) {
207
+ let serverName = 'unknown';
208
+ if (agent === 'claude') {
209
+ const addIndex = args.indexOf('add');
210
+ for (let i = addIndex + 1; i < args.length; i++) {
211
+ const arg = args[i];
212
+ if (arg === '--') continue;
213
+ if (arg.startsWith('-')) continue;
214
+ if (['local', 'user', 'project', 'http', 'sse', 'stdio'].includes(arg)) continue;
215
+ serverName = arg;
216
+ break;
217
+ }
218
+ } else if (agent === 'gemini' || agent === 'qwen') {
219
+ const addIndex = args.indexOf('add');
220
+ for (let i = addIndex + 1; i < args.length; i++) {
221
+ const arg = args[i];
222
+ if (arg === '-t') { i++; continue; }
223
+ if (arg.startsWith('-')) continue;
224
+ if (['http', 'sse', 'stdio'].includes(arg)) continue;
225
+ serverName = arg;
226
+ break;
227
+ }
228
+ } else {
229
+ serverName = args[2];
230
+ }
231
+ console.log(kleur.dim(` ✓ ${serverName} (already configured)`));
232
+ return { success: true, skipped: true };
233
+ }
234
+
235
+ console.log(kleur.red(` ✗ Failed: ${stderr.trim()}`));
236
+ return { success: false, error: stderr };
237
+ }
238
+ }
239
+
240
+ export function getCurrentServers(agent: AgentName): string[] {
241
+ const cli = AGENT_CLI[agent];
242
+ try {
243
+ const output = execSync(`${cli.command} ${cli.listArgs.join(' ')}`, {
244
+ encoding: 'utf8',
245
+ stdio: ['pipe', 'pipe', 'pipe']
246
+ });
247
+ return cli.parseList(output);
248
+ } catch (error: any) {
249
+ // Some CLIs (e.g. gemini) write server list to stderr
250
+ const combined = (error.stdout || '') + '\n' + (error.stderr || '');
251
+ return cli.parseList(combined);
252
+ }
253
+ }
254
+
255
+ export async function getCurrentServersAsync(agent: AgentName): Promise<string[]> {
256
+ const cli = AGENT_CLI[agent];
257
+ try {
258
+ const { stdout, stderr } = await execAsync(`${cli.command} ${cli.listArgs.join(' ')}`, {
259
+ timeout: 10000,
260
+ });
261
+ // Some CLIs (e.g. gemini) write server list to stderr
262
+ return cli.parseList(stdout + '\n' + stderr);
263
+ } catch (error) {
264
+ return [];
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Extract ${VAR_NAME} references from a list of server config objects
270
+ */
271
+ function getServerEnvVarNames(servers: any[]): string[] {
272
+ const vars = new Set<string>();
273
+ const pattern = /\$\{([A-Z0-9_]+)\}/g;
274
+ for (const server of servers) {
275
+ const json = JSON.stringify(server);
276
+ let match;
277
+ const re = new RegExp(pattern.source, pattern.flags);
278
+ while ((match = re.exec(json)) !== null) {
279
+ vars.add(match[1]);
280
+ }
281
+ }
282
+ return Array.from(vars);
283
+ }
284
+
285
+ /**
286
+ * Sync MCP servers to an agent using official CLI
287
+ */
288
+ // Prevents ensureEnvFile/loadEnvFile from firing once per target directory
289
+ let envInitialized = false;
290
+
291
+ export async function syncMcpServersWithCli(
292
+ agent: AgentName,
293
+ mcpConfig: any,
294
+ dryRun: boolean = false,
295
+ prune: boolean = false
296
+ ): Promise<void> {
297
+ const cli = AGENT_CLI[agent];
298
+ if (!cli) {
299
+ console.log(kleur.yellow(` ⚠️ Unsupported agent: ${agent}`));
300
+ return;
301
+ }
302
+
303
+ if (!envInitialized) {
304
+ ensureEnvFile();
305
+ loadEnvFile();
306
+ envInitialized = true;
307
+ }
308
+
309
+ const spinner = ora({ text: kleur.dim(' checking installed servers…'), indent: 2 }).start();
310
+ const currentServers = await getCurrentServersAsync(agent);
311
+ spinner.stop();
312
+ const currentServersSet = new Set(currentServers);
313
+ const canonicalServers = new Set(Object.keys(mcpConfig.mcpServers || {}));
314
+
315
+ if (prune) {
316
+ const toRemove = currentServers.filter(s => !canonicalServers.has(s));
317
+ if (toRemove.length > 0) {
318
+ console.log(kleur.red(` Pruning ${toRemove.length} server(s)...`));
319
+ for (const serverName of toRemove) {
320
+ executeCommand(agent, cli.remove(serverName), dryRun);
321
+ }
322
+ }
323
+ }
324
+
325
+ // Determine which servers actually need to be added
326
+ const toAdd = Object.entries(mcpConfig.mcpServers || {}).filter(([name]) => !currentServersSet.has(name));
327
+ const skippedCount = canonicalServers.size - toAdd.length;
328
+
329
+ if (toAdd.length === 0) {
330
+ // Nothing to add — skip env warning, no CLI calls needed
331
+ console.log(kleur.dim(` ✓ ${skippedCount} server(s) already installed`));
332
+ return;
333
+ }
334
+
335
+ // Step 1: Multiselect — all servers pre-selected, user can deselect with space
336
+ let selectedNames: string[] = toAdd.map(([name]) => name);
337
+
338
+ if (!dryRun) {
339
+ // @ts-ignore
340
+ const prompts = await import('prompts');
341
+ const { selected } = await prompts.default({
342
+ type: 'multiselect',
343
+ name: 'selected',
344
+ message: `Select MCP servers to install via ${agent} CLI:`,
345
+ choices: toAdd.map(([name, server]: [string, any]) => ({
346
+ title: name,
347
+ description: (server as any)._notes?.description || '',
348
+ value: name,
349
+ selected: true
350
+ })),
351
+ hint: '- Space to toggle. Enter to confirm.',
352
+ instructions: false
353
+ });
354
+
355
+ if (!selected || selected.length === 0) {
356
+ console.log(kleur.gray(' Skipped MCP installation.'));
357
+ return;
358
+ }
359
+
360
+ selectedNames = selected;
361
+
362
+ // Step 2: Only ask for env vars needed by the selected servers
363
+ const selectedEntries = toAdd.filter(([name]) => new Set(selectedNames).has(name));
364
+ const neededVarNames = getServerEnvVarNames(selectedEntries.map(([, s]) => s as any));
365
+ const missingEnvVars = checkRequiredEnvVars(neededVarNames);
366
+ if (missingEnvVars.length > 0) {
367
+ const shouldProceed = await handleMissingEnvVars(missingEnvVars);
368
+ if (!shouldProceed) return;
369
+ }
370
+ }
371
+
372
+ // Step 3: Build and execute commands for selected servers only
373
+ const selectedSet = new Set(selectedNames);
374
+ const commandsToRun: Array<{ name: string; cmd: string[] }> = [];
375
+ for (const [name, server] of toAdd) {
376
+ if (!selectedSet.has(name)) continue;
377
+ const cmd = buildAddCommand(agent, name, server as any);
378
+ if (cmd) commandsToRun.push({ name, cmd });
379
+ }
380
+
381
+ if (commandsToRun.length === 0) return;
382
+
383
+ let successCount = 0;
384
+ for (const { name, cmd } of commandsToRun) {
385
+ const result = executeCommand(agent, cmd, dryRun, name);
386
+ if (result.success && !result.skipped) {
387
+ successCount++;
388
+ }
389
+ }
390
+
391
+ if (skippedCount > 0) {
392
+ console.log(kleur.dim(` ✓ ${skippedCount} already installed, ${successCount} added`));
393
+ } else {
394
+ console.log(kleur.green(` ✓ ${successCount} server(s) added`));
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Load canonical MCP config from repository
400
+ */
401
+ export function loadCanonicalMcpConfig(repoRoot: string, includeOptional: boolean = false): any {
402
+ const corePath = path.join(repoRoot, 'config', 'mcp_servers.json');
403
+ const optionalPath = path.join(repoRoot, 'config', 'mcp_servers_optional.json');
404
+
405
+ const config: any = { mcpServers: {} };
406
+
407
+ if (fs.existsSync(corePath)) {
408
+ const core = fs.readJsonSync(corePath);
409
+ config.mcpServers = { ...config.mcpServers, ...core.mcpServers };
410
+ }
411
+
412
+ if (includeOptional && fs.existsSync(optionalPath)) {
413
+ const optional = fs.readJsonSync(optionalPath);
414
+ config.mcpServers = { ...config.mcpServers, ...optional.mcpServers };
415
+ }
416
+
417
+ return config;
418
+ }
419
+
420
+ /**
421
+ * Prompt user to select optional MCP servers
422
+ */
423
+ export async function promptOptionalServers(repoRoot: string): Promise<string[] | false> {
424
+ const optionalPath = path.join(repoRoot, 'config', 'mcp_servers_optional.json');
425
+
426
+ if (!fs.existsSync(optionalPath)) {
427
+ return false;
428
+ }
429
+
430
+ const optional = fs.readJsonSync(optionalPath);
431
+ const servers = Object.entries(optional.mcpServers || {}).map(([name, server]: [string, any]) => ({
432
+ name,
433
+ description: server._notes?.description || 'No description',
434
+ prerequisite: server._notes?.prerequisite || ''
435
+ }));
436
+
437
+ if (servers.length === 0) {
438
+ return false;
439
+ }
440
+
441
+ // @ts-ignore
442
+ const prompts = await import('prompts');
443
+
444
+ const { selected } = await prompts.default({
445
+ type: 'multiselect',
446
+ name: 'selected',
447
+ message: 'Optional MCP servers available — select to install (space to toggle, enter to confirm):',
448
+ choices: servers.map(s => ({
449
+ title: s.name,
450
+ description: s.prerequisite
451
+ ? `${s.description} — ⚠️ ${s.prerequisite}`
452
+ : s.description,
453
+ value: s.name,
454
+ selected: false
455
+ })),
456
+ hint: '- Space to select. Enter to skip or confirm.',
457
+ instructions: false
458
+ });
459
+
460
+ if (!selected || selected.length === 0) {
461
+ console.log(kleur.gray(' Skipping optional servers.\n'));
462
+ return false;
463
+ }
464
+
465
+ console.log(kleur.green(` Selected: ${selected.join(', ')}\n`));
466
+ return selected;
467
+ }
@@ -0,0 +1,37 @@
1
+ import kleur from 'kleur';
2
+
3
+ /** Semantic color tokens */
4
+ export const t = {
5
+ // Status
6
+ success: (s: string) => kleur.green(s),
7
+ error: (s: string) => kleur.red(s),
8
+ warning: (s: string) => kleur.yellow(s),
9
+ info: (s: string) => kleur.cyan(s), // was blue — unreadable on dark terminals
10
+
11
+ // Hierarchy (3-tier weight system)
12
+ header: (s: string) => kleur.bold().white(s), // section headers
13
+ label: (s: string) => kleur.dim(s), // metadata labels
14
+ muted: (s: string) => kleur.gray(s),
15
+ accent: (s: string) => kleur.cyan(s),
16
+ bold: (s: string) => kleur.bold(s),
17
+ dim: (s: string) => kleur.dim(s),
18
+
19
+ // Compound
20
+ boldGreen: (s: string) => kleur.bold().green(s),
21
+ boldRed: (s: string) => kleur.bold().red(s),
22
+ };
23
+
24
+ /** Status symbols with colour baked in */
25
+ export const sym = {
26
+ ok: kleur.green('✓'),
27
+ fail: kleur.red('✗'),
28
+ warn: kleur.yellow('⚠'),
29
+
30
+ // File change states — directional metaphor
31
+ missing: kleur.green('+'),
32
+ outdated: kleur.yellow('↑'),
33
+ drifted: kleur.magenta('≠'), // was red '!' — magenta = conflict/divergence
34
+
35
+ arrow: kleur.gray('→'),
36
+ bullet: kleur.gray('•'),
37
+ };
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getCandidatePaths, resolveTargets } from '../src/core/context.js';
3
+
4
+ describe('getCandidatePaths', () => {
5
+ it('includes Claude Code and skills-only targets', () => {
6
+ const candidates = getCandidatePaths();
7
+ expect(candidates.some(candidate => candidate.label === '~/.claude (hooks + skills)')).toBe(true);
8
+ expect(candidates.some(candidate => candidate.label === '~/.agents/skills')).toBe(true);
9
+ expect(candidates.length).toBe(2);
10
+ });
11
+ });
12
+
13
+ describe('resolveTargets', () => {
14
+ it('returns all candidate paths for the "*" selector', () => {
15
+ const candidates = getCandidatePaths();
16
+ expect(resolveTargets('*', candidates)).toEqual(candidates.map(candidate => candidate.path));
17
+ });
18
+
19
+ it('returns all candidate paths for the "all" selector', () => {
20
+ const candidates = getCandidatePaths();
21
+ expect(resolveTargets('all', candidates)).toEqual(candidates.map(candidate => candidate.path));
22
+ });
23
+
24
+ it('returns null when no selector is provided', () => {
25
+ expect(resolveTargets(undefined, getCandidatePaths())).toBeNull();
26
+ });
27
+
28
+ it('rejects unknown selectors', () => {
29
+ expect(() => resolveTargets('everything', getCandidatePaths())).toThrow(
30
+ "Unknown install target selector 'everything'. Use '*' or 'all'.",
31
+ );
32
+ });
33
+ });