@uxmaltech/collab-cli 0.1.7 → 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.
@@ -15,6 +15,7 @@ const compose_renderer_1 = require("../lib/compose-renderer");
15
15
  const errors_1 = require("../lib/errors");
16
16
  const github_search_1 = require("../lib/github-search");
17
17
  const infra_type_1 = require("../lib/infra-type");
18
+ const mcp_contract_1 = require("../lib/mcp-contract");
18
19
  const mode_1 = require("../lib/mode");
19
20
  const parsers_1 = require("../lib/parsers");
20
21
  const orchestrator_1 = require("../lib/orchestrator");
@@ -55,7 +56,7 @@ function inferComposeMode(config) {
55
56
  }
56
57
  return 'consolidated';
57
58
  }
58
- async function resolveWizardSelection(options, config, logger) {
59
+ async function resolveWizardSelection(options, config, logger, dryRun) {
59
60
  const defaults = {
60
61
  mode: (0, mode_1.parseMode)(options.mode, config.mode),
61
62
  composeMode: parseComposeMode(options.composeMode, inferComposeMode(config)),
@@ -75,6 +76,7 @@ async function resolveWizardSelection(options, config, logger) {
75
76
  throw new errors_1.CliError('--mcp-url is required with --infra-type remote in non-interactive mode.');
76
77
  }
77
78
  mcpUrl = (0, infra_type_1.validateMcpUrl)(options.mcpUrl);
79
+ await validateMcpServerContract(mcpUrl, logger, dryRun);
78
80
  }
79
81
  return {
80
82
  mode,
@@ -104,6 +106,7 @@ async function resolveWizardSelection(options, config, logger) {
104
106
  const rawUrl = options.mcpUrl
105
107
  ?? await (0, prompt_1.promptText)('MCP server base URL:', 'http://127.0.0.1:7337');
106
108
  mcpUrl = (0, infra_type_1.validateMcpUrl)(rawUrl);
109
+ await validateMcpServerContract(mcpUrl, logger, dryRun);
107
110
  }
108
111
  }
109
112
  // Skip compose-mode prompt when mode is file-only or infra is remote —
@@ -220,6 +223,45 @@ function buildGitHubAuthStage(effectiveConfig, logger, options) {
220
223
  },
221
224
  };
222
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
+ // ────────────────────────────────────────────────────────────────
223
265
  const LOCAL_PATH_RE = /^[/~.]/;
224
266
  /**
225
267
  * Expands `~`, resolves to absolute, and validates the path is an existing directory.
@@ -685,6 +727,9 @@ async function runInfraOnly(context, options) {
685
727
  mcpUrl = options.mcpUrl ? (0, infra_type_1.validateMcpUrl)(options.mcpUrl) : effectiveConfig.mcpUrl;
686
728
  effectiveConfig.infraType = infraType;
687
729
  effectiveConfig.mcpUrl = mcpUrl;
730
+ if (mcpUrl) {
731
+ await validateMcpServerContract(mcpUrl, context.logger, context.executor.dryRun);
732
+ }
688
733
  }
689
734
  const composeMode = parseComposeMode(options.composeMode, inferComposeMode(effectiveConfig));
690
735
  const infraLabel = infraType === 'remote' ? 'Remote MCP services' : 'Docker + MCP services';
@@ -909,7 +954,7 @@ Examples:
909
954
  }
910
955
  // ── Step 1: Configuration wizard ────────────────────────
911
956
  context.logger.phaseHeader('collab init', 'Configuration');
912
- const selections = await resolveWizardSelection(options, context.config, context.logger);
957
+ const selections = await resolveWizardSelection(options, context.config, context.logger, context.executor.dryRun);
913
958
  const preserveExisting = configExistedBefore && !options.force;
914
959
  const effectiveConfig = {
915
960
  ...(0, config_1.defaultCollabConfig)(context.config.workspaceDir),
@@ -919,6 +964,7 @@ Examples:
919
964
  mcpUrl: preserveExisting ? context.config.mcpUrl : selections.mcpUrl,
920
965
  };
921
966
  // ── Step 2: Business canon configuration ──────────────────
967
+ context.logger.phaseHeader('collab init', 'Business Canon');
922
968
  const canons = await resolveBusinessCanon(options, effectiveConfig, context.logger);
923
969
  if (canons) {
924
970
  effectiveConfig.canons = canons;
@@ -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,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.7",
3
+ "version": "0.1.8",
4
4
  "description": "CLI for collaborative architecture and delivery workflows.",
5
5
  "private": false,
6
6
  "license": "UNLICENSED",