@uxmaltech/collab-cli 0.1.6 → 0.1.8

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.
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.registerInitCommand = registerInitCommand;
7
7
  const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_os_1 = __importDefault(require("node:os"));
8
9
  const node_path_1 = __importDefault(require("node:path"));
9
10
  const command_context_1 = require("../lib/command-context");
10
11
  const compose_validator_1 = require("../lib/compose-validator");
@@ -12,7 +13,9 @@ const config_1 = require("../lib/config");
12
13
  const ecosystem_1 = require("../lib/ecosystem");
13
14
  const compose_renderer_1 = require("../lib/compose-renderer");
14
15
  const errors_1 = require("../lib/errors");
16
+ const github_search_1 = require("../lib/github-search");
15
17
  const infra_type_1 = require("../lib/infra-type");
18
+ const mcp_contract_1 = require("../lib/mcp-contract");
16
19
  const mode_1 = require("../lib/mode");
17
20
  const parsers_1 = require("../lib/parsers");
18
21
  const orchestrator_1 = require("../lib/orchestrator");
@@ -53,7 +56,7 @@ function inferComposeMode(config) {
53
56
  }
54
57
  return 'consolidated';
55
58
  }
56
- async function resolveWizardSelection(options, config, logger) {
59
+ async function resolveWizardSelection(options, config, logger, dryRun) {
57
60
  const defaults = {
58
61
  mode: (0, mode_1.parseMode)(options.mode, config.mode),
59
62
  composeMode: parseComposeMode(options.composeMode, inferComposeMode(config)),
@@ -73,6 +76,7 @@ async function resolveWizardSelection(options, config, logger) {
73
76
  throw new errors_1.CliError('--mcp-url is required with --infra-type remote in non-interactive mode.');
74
77
  }
75
78
  mcpUrl = (0, infra_type_1.validateMcpUrl)(options.mcpUrl);
79
+ await validateMcpServerContract(mcpUrl, logger, dryRun);
76
80
  }
77
81
  return {
78
82
  mode,
@@ -102,6 +106,7 @@ async function resolveWizardSelection(options, config, logger) {
102
106
  const rawUrl = options.mcpUrl
103
107
  ?? await (0, prompt_1.promptText)('MCP server base URL:', 'http://127.0.0.1:7337');
104
108
  mcpUrl = (0, infra_type_1.validateMcpUrl)(rawUrl);
109
+ await validateMcpServerContract(mcpUrl, logger, dryRun);
105
110
  }
106
111
  }
107
112
  // Skip compose-mode prompt when mode is file-only or infra is remote —
@@ -187,9 +192,10 @@ function buildGitHubAuthStage(effectiveConfig, logger, options) {
187
192
  'Run collab init --resume after fixing GitHub access.',
188
193
  ],
189
194
  run: async () => {
190
- // Skip if no business canon is configured — GitHub auth is only needed for private repos
191
- if (!effectiveConfig.canons?.business) {
192
- logger.info('No business canon configured; skipping GitHub authorization.');
195
+ // Skip if no business canon or local source — GitHub auth only needed for remote repos
196
+ const canon = effectiveConfig.canons?.business;
197
+ if (!canon || canon.source === 'local') {
198
+ logger.info('No GitHub canon configured; skipping GitHub authorization.');
193
199
  return;
194
200
  }
195
201
  // Check for pre-existing valid token
@@ -217,48 +223,185 @@ function buildGitHubAuthStage(effectiveConfig, logger, options) {
217
223
  },
218
224
  };
219
225
  }
226
+ // ────────────────────────────────────────────────────────────────
227
+ // MCP contract validation (single-shot probe at URL-entry time)
228
+ // ────────────────────────────────────────────────────────────────
229
+ async function validateMcpServerContract(mcpUrl, logger, dryRun) {
230
+ const result = await (0, mcp_contract_1.probeMcpContract)(mcpUrl, {
231
+ contractRange: (0, ecosystem_1.getMcpContractRange)(),
232
+ dryRun,
233
+ });
234
+ if (result.skipped) {
235
+ logger.info('[dry-run] Skipped MCP contract validation.');
236
+ return;
237
+ }
238
+ if (!result.ok) {
239
+ throw new errors_1.CliError(result.error ?? `MCP contract validation failed for ${mcpUrl}`);
240
+ }
241
+ const health = result.health;
242
+ logger.step(true, `MCP server reachable at ${mcpUrl}`);
243
+ logger.info(`Server version: ${health.version}`);
244
+ logger.info(`Contract version: ${health.contractVersion}`);
245
+ const deps = Object.entries(health.dependencies)
246
+ .map(([name, status]) => `${name} (${status})`)
247
+ .join(', ');
248
+ if (deps) {
249
+ logger.info(`Dependencies: ${deps}`);
250
+ }
251
+ if (result.contractCompatible === false) {
252
+ logger.warn(`MCP contract version ${health.contractVersion} may be incompatible ` +
253
+ `(CLI expects ${(0, ecosystem_1.getMcpContractRange)()}). Some features may not work correctly.`);
254
+ }
255
+ const degraded = Object.entries(health.dependencies)
256
+ .filter(([, status]) => status !== 'up');
257
+ if (degraded.length > 0) {
258
+ const names = degraded.map(([name, status]) => `${name} (${status})`).join(', ');
259
+ logger.warn(`Some MCP dependencies are not fully ready: ${names}`);
260
+ }
261
+ }
262
+ // ────────────────────────────────────────────────────────────────
263
+ // Business canon helpers
264
+ // ────────────────────────────────────────────────────────────────
265
+ const LOCAL_PATH_RE = /^[/~.]/;
266
+ /**
267
+ * Expands `~`, resolves to absolute, and validates the path is an existing directory.
268
+ * Throws CliError on failure.
269
+ */
270
+ function resolveLocalCanonPath(rawPath) {
271
+ const resolved = node_path_1.default.resolve(rawPath.replace(/^~/, node_os_1.default.homedir()));
272
+ if (!node_fs_1.default.existsSync(resolved) || !node_fs_1.default.statSync(resolved).isDirectory()) {
273
+ throw new errors_1.CliError(`Not a valid directory: ${resolved}`);
274
+ }
275
+ return resolved;
276
+ }
220
277
  function parseBusinessCanonOption(value) {
221
278
  if (!value || value === 'none' || value === 'skip') {
222
279
  return undefined;
223
280
  }
281
+ // Detect local path: starts with /, ./, ../, or ~
282
+ if (LOCAL_PATH_RE.test(value)) {
283
+ const resolved = resolveLocalCanonPath(value);
284
+ return {
285
+ business: {
286
+ repo: `local/${node_path_1.default.basename(resolved)}`,
287
+ branch: 'local',
288
+ localDir: 'business',
289
+ source: 'local',
290
+ localPath: resolved,
291
+ },
292
+ };
293
+ }
294
+ // GitHub repo: must contain /
224
295
  if (!value.includes('/')) {
225
- throw new errors_1.CliError(`Invalid business canon format "${value}". Use "owner/repo" or "none" to skip.`);
296
+ throw new errors_1.CliError(`Invalid business canon format "${value}". Use "owner/repo", a local path, or "none" to skip.`);
226
297
  }
227
298
  return {
228
299
  business: {
229
300
  repo: value,
230
301
  branch: 'main',
231
302
  localDir: 'business',
303
+ source: 'github',
232
304
  },
233
305
  };
234
306
  }
235
- async function resolveBusinessCanon(options, logger) {
307
+ async function resolveBusinessCanon(options, config, logger) {
236
308
  // CLI flag takes priority
237
309
  if (options.businessCanon) {
238
310
  return parseBusinessCanonOption(options.businessCanon);
239
311
  }
240
312
  // --yes without --business-canon: mandatory error
241
313
  if (options.yes) {
242
- throw new errors_1.CliError('--business-canon is required with --yes. Use --business-canon owner/repo or --business-canon none.');
314
+ throw new errors_1.CliError('--business-canon is required with --yes. Use --business-canon owner/repo, --business-canon /local/path, or --business-canon none.');
243
315
  }
244
- // Interactive prompt
245
- const repo = await (0, prompt_1.promptText)('Business architecture canon repo (owner/repo, empty to skip):');
246
- if (!repo) {
316
+ // Interactive: choose source
317
+ const source = await (0, prompt_1.promptChoice)('Business canon source:', [
318
+ { value: 'github', label: 'GitHub repository (search and select)' },
319
+ { value: 'local', label: 'Local directory' },
320
+ { value: 'skip', label: 'Skip (no business canon)' },
321
+ ], 'skip');
322
+ if (source === 'skip') {
247
323
  logger.info('No business canon configured.');
248
324
  return undefined;
249
325
  }
250
- if (!repo.includes('/')) {
251
- throw new errors_1.CliError(`Invalid format "${repo}". Use "owner/repo".`);
326
+ if (source === 'local') {
327
+ return resolveLocalBusinessCanon(logger);
328
+ }
329
+ return resolveGitHubBusinessCanon(config, logger);
330
+ }
331
+ async function resolveLocalBusinessCanon(logger) {
332
+ const rawPath = await (0, prompt_1.promptText)('Local canon directory path:');
333
+ if (!rawPath) {
334
+ throw new errors_1.CliError('Path is required for local canon.');
335
+ }
336
+ const resolved = resolveLocalCanonPath(rawPath);
337
+ const dirName = node_path_1.default.basename(resolved);
338
+ logger.info(`Using local canon at ${resolved}`);
339
+ return {
340
+ business: {
341
+ repo: `local/${dirName}`,
342
+ branch: 'local',
343
+ localDir: 'business',
344
+ source: 'local',
345
+ localPath: resolved,
346
+ },
347
+ };
348
+ }
349
+ async function resolveGitHubBusinessCanon(config, logger) {
350
+ // Ensure GitHub auth
351
+ const token = await ensureGitHubAuth(config.collabDir, logger);
352
+ // Search loop
353
+ let repo;
354
+ let defaultBranch = 'main';
355
+ while (!repo) {
356
+ const query = await (0, prompt_1.promptText)('Search GitHub repositories:');
357
+ if (!query) {
358
+ throw new errors_1.CliError('Search query is required.');
359
+ }
360
+ const results = await (0, github_search_1.searchGitHubRepos)(query, token, 8);
361
+ if (results.items.length === 0) {
362
+ logger.info(`No repositories found for "${query}". Try a different search.`);
363
+ continue;
364
+ }
365
+ logger.info(`Found ${results.items.length} results (of ${results.totalCount} total):`);
366
+ const choices = results.items.map((r) => ({
367
+ value: r.fullName,
368
+ label: `${r.fullName}${r.private ? ' \u{1F512}' : ''}${r.description ? ` — ${r.description}` : ''}`,
369
+ }));
370
+ choices.push({ value: '__search_again__', label: '\u21BB Search again' });
371
+ const selected = await (0, prompt_1.promptChoice)('Select repository:', choices, choices[0].value);
372
+ if (selected === '__search_again__') {
373
+ continue;
374
+ }
375
+ repo = selected;
376
+ defaultBranch =
377
+ results.items.find((r) => r.fullName === selected)?.defaultBranch ?? 'main';
252
378
  }
253
- const branch = await (0, prompt_1.promptText)('Business canon branch:', 'main');
379
+ const branch = await (0, prompt_1.promptText)('Branch:', defaultBranch);
254
380
  return {
255
381
  business: {
256
382
  repo,
257
- branch: branch || 'main',
383
+ branch: branch || defaultBranch,
258
384
  localDir: 'business',
385
+ source: 'github',
259
386
  },
260
387
  };
261
388
  }
389
+ async function ensureGitHubAuth(collabDir, logger) {
390
+ const existing = (0, github_auth_1.loadGitHubAuth)(collabDir);
391
+ if (existing) {
392
+ const valid = await (0, github_auth_1.isGitHubAuthValid)(existing);
393
+ if (valid) {
394
+ return existing.token;
395
+ }
396
+ logger.info('Existing GitHub token expired. Re-authorizing...');
397
+ }
398
+ await (0, github_auth_1.runGitHubDeviceFlow)(collabDir, (msg) => logger.info(msg));
399
+ const auth = (0, github_auth_1.loadGitHubAuth)(collabDir);
400
+ if (!auth) {
401
+ throw new errors_1.CliError('GitHub authorization failed.');
402
+ }
403
+ return auth.token;
404
+ }
262
405
  // ────────────────────────────────────────────────────────────────
263
406
  // Shared inline stages used by both pipelines
264
407
  // ────────────────────────────────────────────────────────────────
@@ -584,6 +727,9 @@ async function runInfraOnly(context, options) {
584
727
  mcpUrl = options.mcpUrl ? (0, infra_type_1.validateMcpUrl)(options.mcpUrl) : effectiveConfig.mcpUrl;
585
728
  effectiveConfig.infraType = infraType;
586
729
  effectiveConfig.mcpUrl = mcpUrl;
730
+ if (mcpUrl) {
731
+ await validateMcpServerContract(mcpUrl, context.logger, context.executor.dryRun);
732
+ }
587
733
  }
588
734
  const composeMode = parseComposeMode(options.composeMode, inferComposeMode(effectiveConfig));
589
735
  const infraLabel = infraType === 'remote' ? 'Remote MCP services' : 'Docker + MCP services';
@@ -756,7 +902,7 @@ function registerInitCommand(program) {
756
902
  .option('--skip-analysis', 'Skip AI-powered repository analysis stage')
757
903
  .option('--skip-ci', 'Skip CI workflow generation')
758
904
  .option('--providers <list>', 'Comma-separated AI provider list (codex,claude,gemini,copilot)')
759
- .option('--business-canon <owner/repo>', 'Business canon repo (owner/repo or "none" to skip)')
905
+ .option('--business-canon <value>', 'Business canon: owner/repo, /local/path, or "none" to skip')
760
906
  .option('--github-token <token>', 'GitHub token for non-interactive mode')
761
907
  .option('--timeout-ms <ms>', 'Per-check timeout in milliseconds', '5000')
762
908
  .option('--retries <count>', 'Health check retries', '15')
@@ -808,7 +954,7 @@ Examples:
808
954
  }
809
955
  // ── Step 1: Configuration wizard ────────────────────────
810
956
  context.logger.phaseHeader('collab init', 'Configuration');
811
- const selections = await resolveWizardSelection(options, context.config, context.logger);
957
+ const selections = await resolveWizardSelection(options, context.config, context.logger, context.executor.dryRun);
812
958
  const preserveExisting = configExistedBefore && !options.force;
813
959
  const effectiveConfig = {
814
960
  ...(0, config_1.defaultCollabConfig)(context.config.workspaceDir),
@@ -818,7 +964,8 @@ Examples:
818
964
  mcpUrl: preserveExisting ? context.config.mcpUrl : selections.mcpUrl,
819
965
  };
820
966
  // ── Step 2: Business canon configuration ──────────────────
821
- const canons = await resolveBusinessCanon(options, context.logger);
967
+ context.logger.phaseHeader('collab init', 'Business Canon');
968
+ const canons = await resolveBusinessCanon(options, effectiveConfig, context.logger);
822
969
  if (canons) {
823
970
  effectiveConfig.canons = canons;
824
971
  }
@@ -29,19 +29,23 @@ function registerUpdateCanonsCommand(program) {
29
29
  }
30
30
  // ── Business canon ───────────────────────────────────────
31
31
  if ((0, canon_resolver_1.isBusinessCanonConfigured)(context.config)) {
32
+ const canon = context.config.canons?.business;
32
33
  const auth = (0, github_auth_1.loadGitHubAuth)(context.config.collabDir);
33
34
  const token = auth?.token;
34
35
  const bizOk = (0, canon_resolver_1.syncBusinessCanon)(context.config, (msg) => context.logger.info(msg), token);
35
36
  if (!bizOk) {
36
37
  throw new errors_1.CliError('Failed to sync business canon.');
37
38
  }
38
- const bizDir = (0, canon_resolver_1.getBusinessCanonDir)(context.config);
39
- try {
40
- const bizCommitInfo = (0, node_child_process_1.execFileSync)('git', ['-C', bizDir, 'log', '-1', `--format=${COMMIT_FORMAT}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
41
- context.logger.info(`Business canon up to date: ${bizCommitInfo}`);
42
- }
43
- catch {
44
- context.logger.info('Business canon updated successfully.');
39
+ // Show git log only for GitHub-sourced canons (local dirs may not be git repos)
40
+ if (canon?.source !== 'local') {
41
+ const bizDir = (0, canon_resolver_1.getBusinessCanonDir)(context.config);
42
+ try {
43
+ const bizCommitInfo = (0, node_child_process_1.execFileSync)('git', ['-C', bizDir, 'log', '-1', `--format=${COMMIT_FORMAT}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
44
+ context.logger.info(`Business canon up to date: ${bizCommitInfo}`);
45
+ }
46
+ catch {
47
+ context.logger.info('Business canon updated successfully.');
48
+ }
45
49
  }
46
50
  }
47
51
  });
@@ -167,13 +167,20 @@ function isBusinessCanonConfigured(config) {
167
167
  return !!config.canons?.business?.repo;
168
168
  }
169
169
  /**
170
- * Returns the local directory where the business canon is cloned.
170
+ * Returns the local directory where the business canon lives.
171
+ * For local canons this is the user-provided path; for GitHub canons
172
+ * it's the cached clone under `~/.collab/canons/<repo>`.
171
173
  */
172
174
  function getBusinessCanonDir(config) {
173
175
  const canon = config.canons?.business;
174
176
  if (!canon) {
175
177
  throw new Error('No business canon configured.');
176
178
  }
179
+ // Local source: the user's directory as-is
180
+ if (canon.source === 'local' && canon.localPath) {
181
+ return canon.localPath;
182
+ }
183
+ // GitHub source: cached clone
177
184
  const repoName = canon.repo.split('/').pop() ?? canon.repo;
178
185
  const collabHome = process.env.COLLAB_HOME ?? node_path_1.default.join(node_os_1.default.homedir(), '.collab');
179
186
  return node_path_1.default.join(collabHome, CANONS_SUBDIR, repoName);
@@ -187,9 +194,18 @@ function syncBusinessCanon(config, log, token) {
187
194
  if (!canon) {
188
195
  return false;
189
196
  }
197
+ const print = log ?? console.log;
198
+ // Local canons don't need syncing — just validate the path exists
199
+ if (canon.source === 'local') {
200
+ if (!canon.localPath || !node_fs_1.default.existsSync(canon.localPath)) {
201
+ print(`Local canon path not found: ${canon.localPath ?? '(not set)'}`);
202
+ return false;
203
+ }
204
+ print(`Using local business canon at ${canon.localPath}`);
205
+ return true;
206
+ }
190
207
  const canonsDir = getBusinessCanonDir(config);
191
208
  const parentDir = node_path_1.default.dirname(canonsDir);
192
- const print = log ?? console.log;
193
209
  const branch = canon.branch || 'main';
194
210
  // Build repo URL — inject token for private repo access if available
195
211
  let repoUrl;
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getMcpContractRange = getMcpContractRange;
6
7
  exports.checkEcosystemCompatibility = checkEcosystemCompatibility;
7
8
  const node_fs_1 = __importDefault(require("node:fs"));
8
9
  const node_path_1 = __importDefault(require("node:path"));
@@ -31,6 +32,10 @@ function readManifest() {
31
32
  }
32
33
  return manifest;
33
34
  }
35
+ /** Returns the required MCP contract version range from the ecosystem manifest. */
36
+ function getMcpContractRange() {
37
+ return readManifest().collabArchitectureMcpContractRange;
38
+ }
34
39
  function readCliVersion() {
35
40
  const packagePath = node_path_1.default.resolve(__dirname, '../../package.json');
36
41
  const pkg = readJsonFile(packagePath);
@@ -19,9 +19,9 @@ const node_path_1 = __importDefault(require("node:path"));
19
19
  const AUTH_FILENAME = 'github-auth.json';
20
20
  /**
21
21
  * Client ID for the collab-cli GitHub OAuth App.
22
- * Override with COLLAB_GITHUB_CLIENT_ID env var until the app is registered.
22
+ * Override with COLLAB_GITHUB_CLIENT_ID env var for custom OAuth apps.
23
23
  */
24
- const DEFAULT_CLIENT_ID = process.env.COLLAB_GITHUB_CLIENT_ID ?? '';
24
+ const DEFAULT_CLIENT_ID = process.env.COLLAB_GITHUB_CLIENT_ID ?? 'Ov23liocAEoUmWO39r5B';
25
25
  const DEVICE_CODE_URL = 'https://github.com/login/device/code';
26
26
  const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
27
27
  const SCOPES = 'repo read:org read:project';
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.searchGitHubRepos = searchGitHubRepos;
4
+ const errors_1 = require("./errors");
5
+ const GITHUB_API_VERSION = '2022-11-28';
6
+ const SEARCH_TIMEOUT_MS = 15_000;
7
+ /**
8
+ * Searches GitHub repositories using the REST search API.
9
+ * Requires a valid GitHub token with `repo` scope for private repos.
10
+ *
11
+ * @param query - Search query (same syntax as GitHub web search)
12
+ * @param token - GitHub personal access token or OAuth token
13
+ * @param limit - Max results to return (default 8, max 100)
14
+ */
15
+ async function searchGitHubRepos(query, token, limit = 8) {
16
+ const encoded = encodeURIComponent(query);
17
+ const url = `https://api.github.com/search/repositories?q=${encoded}&per_page=${limit}&sort=updated`;
18
+ const controller = new AbortController();
19
+ const timer = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS);
20
+ try {
21
+ const response = await fetch(url, {
22
+ headers: {
23
+ Authorization: `Bearer ${token}`,
24
+ Accept: 'application/vnd.github+json',
25
+ 'X-GitHub-Api-Version': GITHUB_API_VERSION,
26
+ },
27
+ signal: controller.signal,
28
+ });
29
+ if (!response.ok) {
30
+ const body = await response.text().catch(() => '');
31
+ throw new errors_1.CliError(`GitHub search failed (HTTP ${response.status}): ${body || response.statusText}`);
32
+ }
33
+ const data = (await response.json());
34
+ return {
35
+ totalCount: data.total_count,
36
+ items: data.items.map((item) => ({
37
+ fullName: item.full_name,
38
+ description: item.description ?? '',
39
+ private: item.private,
40
+ defaultBranch: item.default_branch,
41
+ })),
42
+ };
43
+ }
44
+ catch (error) {
45
+ if (error instanceof errors_1.CliError) {
46
+ throw error;
47
+ }
48
+ // AbortController fires when the timeout elapses — surface a clear message
49
+ if (error instanceof Error && error.name === 'AbortError') {
50
+ throw new errors_1.CliError(`GitHub search request timed out after ${SEARCH_TIMEOUT_MS / 1000}s.`);
51
+ }
52
+ const message = error instanceof Error ? error.message : String(error);
53
+ throw new errors_1.CliError(`GitHub search request failed: ${message}`);
54
+ }
55
+ finally {
56
+ clearTimeout(timer);
57
+ }
58
+ }
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.probeMcpContract = probeMcpContract;
7
+ const semver_1 = __importDefault(require("semver"));
8
+ const DEFAULT_PROBE_TIMEOUT_MS = 5_000;
9
+ /**
10
+ * Probes the MCP server's /health endpoint once and validates
11
+ * the response contract structure and version compatibility.
12
+ *
13
+ * This is a quick, single-attempt probe — not a retry loop.
14
+ * Use it immediately after URL entry to give fast feedback.
15
+ */
16
+ async function probeMcpContract(baseUrl, options = {}) {
17
+ const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
18
+ const contractRange = options.contractRange;
19
+ const dryRun = options.dryRun ?? false;
20
+ if (dryRun) {
21
+ return { ok: true, skipped: true };
22
+ }
23
+ const healthUrl = `${baseUrl}/health`;
24
+ const controller = new AbortController();
25
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
26
+ let response;
27
+ try {
28
+ response = await fetch(healthUrl, {
29
+ method: 'GET',
30
+ signal: controller.signal,
31
+ });
32
+ }
33
+ catch (error) {
34
+ if (error instanceof Error && error.name === 'AbortError') {
35
+ return {
36
+ ok: false,
37
+ skipped: false,
38
+ error: `MCP server at ${baseUrl} did not respond within ${timeoutMs / 1000}s.`,
39
+ };
40
+ }
41
+ const message = error instanceof Error ? error.message : String(error);
42
+ return {
43
+ ok: false,
44
+ skipped: false,
45
+ error: `MCP server unreachable at ${baseUrl}: ${message}`,
46
+ };
47
+ }
48
+ finally {
49
+ clearTimeout(timer);
50
+ }
51
+ if (!response.ok) {
52
+ return {
53
+ ok: false,
54
+ skipped: false,
55
+ error: `MCP server returned HTTP ${response.status} at ${healthUrl}`,
56
+ };
57
+ }
58
+ let body;
59
+ try {
60
+ body = await response.json();
61
+ }
62
+ catch {
63
+ return {
64
+ ok: false,
65
+ skipped: false,
66
+ error: `MCP server returned invalid JSON from ${healthUrl}`,
67
+ };
68
+ }
69
+ if (!isValidHealthResponse(body)) {
70
+ return {
71
+ ok: false,
72
+ skipped: false,
73
+ error: 'MCP /health response has unexpected structure. Expected: { status, version, contractVersion, dependencies }',
74
+ };
75
+ }
76
+ const health = body;
77
+ let contractCompatible;
78
+ if (contractRange) {
79
+ contractCompatible = semver_1.default.satisfies(health.contractVersion, contractRange, {
80
+ includePrerelease: true,
81
+ });
82
+ }
83
+ return {
84
+ ok: true,
85
+ skipped: false,
86
+ health,
87
+ contractCompatible,
88
+ };
89
+ }
90
+ /** Type guard for the expected /health response shape. */
91
+ function isValidHealthResponse(body) {
92
+ if (typeof body !== 'object' || body === null)
93
+ return false;
94
+ const obj = body;
95
+ return (typeof obj.status === 'string' &&
96
+ typeof obj.version === 'string' &&
97
+ typeof obj.contractVersion === 'string' &&
98
+ typeof obj.dependencies === 'object' &&
99
+ obj.dependencies !== null);
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uxmaltech/collab-cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "CLI for collaborative architecture and delivery workflows.",
5
5
  "private": false,
6
6
  "license": "UNLICENSED",