@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.
- package/dist/commands/init.js +164 -17
- package/dist/commands/update-canons.js +11 -7
- package/dist/lib/canon-resolver.js +18 -2
- package/dist/lib/ecosystem.js +5 -0
- package/dist/lib/github-auth.js +2 -2
- package/dist/lib/github-search.js +58 -0
- package/dist/lib/mcp-contract.js +100 -0
- package/package.json +1 -1
package/dist/commands/init.js
CHANGED
|
@@ -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
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
245
|
-
const
|
|
246
|
-
|
|
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 (
|
|
251
|
-
|
|
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)('
|
|
379
|
+
const branch = await (0, prompt_1.promptText)('Branch:', defaultBranch);
|
|
254
380
|
return {
|
|
255
381
|
business: {
|
|
256
382
|
repo,
|
|
257
|
-
branch: branch ||
|
|
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 <
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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;
|
package/dist/lib/ecosystem.js
CHANGED
|
@@ -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);
|
package/dist/lib/github-auth.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|