@uxmaltech/collab-cli 0.1.6 → 0.1.7
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 +116 -15
- package/dist/commands/update-canons.js +11 -7
- package/dist/lib/canon-resolver.js +18 -2
- package/dist/lib/github-search.js +58 -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,6 +13,7 @@ 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");
|
|
16
18
|
const mode_1 = require("../lib/mode");
|
|
17
19
|
const parsers_1 = require("../lib/parsers");
|
|
@@ -187,9 +189,10 @@ function buildGitHubAuthStage(effectiveConfig, logger, options) {
|
|
|
187
189
|
'Run collab init --resume after fixing GitHub access.',
|
|
188
190
|
],
|
|
189
191
|
run: async () => {
|
|
190
|
-
// Skip if no business canon
|
|
191
|
-
|
|
192
|
-
|
|
192
|
+
// Skip if no business canon or local source — GitHub auth only needed for remote repos
|
|
193
|
+
const canon = effectiveConfig.canons?.business;
|
|
194
|
+
if (!canon || canon.source === 'local') {
|
|
195
|
+
logger.info('No GitHub canon configured; skipping GitHub authorization.');
|
|
193
196
|
return;
|
|
194
197
|
}
|
|
195
198
|
// Check for pre-existing valid token
|
|
@@ -217,48 +220,146 @@ function buildGitHubAuthStage(effectiveConfig, logger, options) {
|
|
|
217
220
|
},
|
|
218
221
|
};
|
|
219
222
|
}
|
|
223
|
+
const LOCAL_PATH_RE = /^[/~.]/;
|
|
224
|
+
/**
|
|
225
|
+
* Expands `~`, resolves to absolute, and validates the path is an existing directory.
|
|
226
|
+
* Throws CliError on failure.
|
|
227
|
+
*/
|
|
228
|
+
function resolveLocalCanonPath(rawPath) {
|
|
229
|
+
const resolved = node_path_1.default.resolve(rawPath.replace(/^~/, node_os_1.default.homedir()));
|
|
230
|
+
if (!node_fs_1.default.existsSync(resolved) || !node_fs_1.default.statSync(resolved).isDirectory()) {
|
|
231
|
+
throw new errors_1.CliError(`Not a valid directory: ${resolved}`);
|
|
232
|
+
}
|
|
233
|
+
return resolved;
|
|
234
|
+
}
|
|
220
235
|
function parseBusinessCanonOption(value) {
|
|
221
236
|
if (!value || value === 'none' || value === 'skip') {
|
|
222
237
|
return undefined;
|
|
223
238
|
}
|
|
239
|
+
// Detect local path: starts with /, ./, ../, or ~
|
|
240
|
+
if (LOCAL_PATH_RE.test(value)) {
|
|
241
|
+
const resolved = resolveLocalCanonPath(value);
|
|
242
|
+
return {
|
|
243
|
+
business: {
|
|
244
|
+
repo: `local/${node_path_1.default.basename(resolved)}`,
|
|
245
|
+
branch: 'local',
|
|
246
|
+
localDir: 'business',
|
|
247
|
+
source: 'local',
|
|
248
|
+
localPath: resolved,
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
// GitHub repo: must contain /
|
|
224
253
|
if (!value.includes('/')) {
|
|
225
|
-
throw new errors_1.CliError(`Invalid business canon format "${value}". Use "owner/repo" or "none" to skip.`);
|
|
254
|
+
throw new errors_1.CliError(`Invalid business canon format "${value}". Use "owner/repo", a local path, or "none" to skip.`);
|
|
226
255
|
}
|
|
227
256
|
return {
|
|
228
257
|
business: {
|
|
229
258
|
repo: value,
|
|
230
259
|
branch: 'main',
|
|
231
260
|
localDir: 'business',
|
|
261
|
+
source: 'github',
|
|
232
262
|
},
|
|
233
263
|
};
|
|
234
264
|
}
|
|
235
|
-
async function resolveBusinessCanon(options, logger) {
|
|
265
|
+
async function resolveBusinessCanon(options, config, logger) {
|
|
236
266
|
// CLI flag takes priority
|
|
237
267
|
if (options.businessCanon) {
|
|
238
268
|
return parseBusinessCanonOption(options.businessCanon);
|
|
239
269
|
}
|
|
240
270
|
// --yes without --business-canon: mandatory error
|
|
241
271
|
if (options.yes) {
|
|
242
|
-
throw new errors_1.CliError('--business-canon is required with --yes. Use --business-canon owner/repo or --business-canon none.');
|
|
272
|
+
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
273
|
}
|
|
244
|
-
// Interactive
|
|
245
|
-
const
|
|
246
|
-
|
|
274
|
+
// Interactive: choose source
|
|
275
|
+
const source = await (0, prompt_1.promptChoice)('Business canon source:', [
|
|
276
|
+
{ value: 'github', label: 'GitHub repository (search and select)' },
|
|
277
|
+
{ value: 'local', label: 'Local directory' },
|
|
278
|
+
{ value: 'skip', label: 'Skip (no business canon)' },
|
|
279
|
+
], 'skip');
|
|
280
|
+
if (source === 'skip') {
|
|
247
281
|
logger.info('No business canon configured.');
|
|
248
282
|
return undefined;
|
|
249
283
|
}
|
|
250
|
-
if (
|
|
251
|
-
|
|
284
|
+
if (source === 'local') {
|
|
285
|
+
return resolveLocalBusinessCanon(logger);
|
|
286
|
+
}
|
|
287
|
+
return resolveGitHubBusinessCanon(config, logger);
|
|
288
|
+
}
|
|
289
|
+
async function resolveLocalBusinessCanon(logger) {
|
|
290
|
+
const rawPath = await (0, prompt_1.promptText)('Local canon directory path:');
|
|
291
|
+
if (!rawPath) {
|
|
292
|
+
throw new errors_1.CliError('Path is required for local canon.');
|
|
293
|
+
}
|
|
294
|
+
const resolved = resolveLocalCanonPath(rawPath);
|
|
295
|
+
const dirName = node_path_1.default.basename(resolved);
|
|
296
|
+
logger.info(`Using local canon at ${resolved}`);
|
|
297
|
+
return {
|
|
298
|
+
business: {
|
|
299
|
+
repo: `local/${dirName}`,
|
|
300
|
+
branch: 'local',
|
|
301
|
+
localDir: 'business',
|
|
302
|
+
source: 'local',
|
|
303
|
+
localPath: resolved,
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
async function resolveGitHubBusinessCanon(config, logger) {
|
|
308
|
+
// Ensure GitHub auth
|
|
309
|
+
const token = await ensureGitHubAuth(config.collabDir, logger);
|
|
310
|
+
// Search loop
|
|
311
|
+
let repo;
|
|
312
|
+
let defaultBranch = 'main';
|
|
313
|
+
while (!repo) {
|
|
314
|
+
const query = await (0, prompt_1.promptText)('Search GitHub repositories:');
|
|
315
|
+
if (!query) {
|
|
316
|
+
throw new errors_1.CliError('Search query is required.');
|
|
317
|
+
}
|
|
318
|
+
const results = await (0, github_search_1.searchGitHubRepos)(query, token, 8);
|
|
319
|
+
if (results.items.length === 0) {
|
|
320
|
+
logger.info(`No repositories found for "${query}". Try a different search.`);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
logger.info(`Found ${results.items.length} results (of ${results.totalCount} total):`);
|
|
324
|
+
const choices = results.items.map((r) => ({
|
|
325
|
+
value: r.fullName,
|
|
326
|
+
label: `${r.fullName}${r.private ? ' \u{1F512}' : ''}${r.description ? ` — ${r.description}` : ''}`,
|
|
327
|
+
}));
|
|
328
|
+
choices.push({ value: '__search_again__', label: '\u21BB Search again' });
|
|
329
|
+
const selected = await (0, prompt_1.promptChoice)('Select repository:', choices, choices[0].value);
|
|
330
|
+
if (selected === '__search_again__') {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
repo = selected;
|
|
334
|
+
defaultBranch =
|
|
335
|
+
results.items.find((r) => r.fullName === selected)?.defaultBranch ?? 'main';
|
|
252
336
|
}
|
|
253
|
-
const branch = await (0, prompt_1.promptText)('
|
|
337
|
+
const branch = await (0, prompt_1.promptText)('Branch:', defaultBranch);
|
|
254
338
|
return {
|
|
255
339
|
business: {
|
|
256
340
|
repo,
|
|
257
|
-
branch: branch ||
|
|
341
|
+
branch: branch || defaultBranch,
|
|
258
342
|
localDir: 'business',
|
|
343
|
+
source: 'github',
|
|
259
344
|
},
|
|
260
345
|
};
|
|
261
346
|
}
|
|
347
|
+
async function ensureGitHubAuth(collabDir, logger) {
|
|
348
|
+
const existing = (0, github_auth_1.loadGitHubAuth)(collabDir);
|
|
349
|
+
if (existing) {
|
|
350
|
+
const valid = await (0, github_auth_1.isGitHubAuthValid)(existing);
|
|
351
|
+
if (valid) {
|
|
352
|
+
return existing.token;
|
|
353
|
+
}
|
|
354
|
+
logger.info('Existing GitHub token expired. Re-authorizing...');
|
|
355
|
+
}
|
|
356
|
+
await (0, github_auth_1.runGitHubDeviceFlow)(collabDir, (msg) => logger.info(msg));
|
|
357
|
+
const auth = (0, github_auth_1.loadGitHubAuth)(collabDir);
|
|
358
|
+
if (!auth) {
|
|
359
|
+
throw new errors_1.CliError('GitHub authorization failed.');
|
|
360
|
+
}
|
|
361
|
+
return auth.token;
|
|
362
|
+
}
|
|
262
363
|
// ────────────────────────────────────────────────────────────────
|
|
263
364
|
// Shared inline stages used by both pipelines
|
|
264
365
|
// ────────────────────────────────────────────────────────────────
|
|
@@ -756,7 +857,7 @@ function registerInitCommand(program) {
|
|
|
756
857
|
.option('--skip-analysis', 'Skip AI-powered repository analysis stage')
|
|
757
858
|
.option('--skip-ci', 'Skip CI workflow generation')
|
|
758
859
|
.option('--providers <list>', 'Comma-separated AI provider list (codex,claude,gemini,copilot)')
|
|
759
|
-
.option('--business-canon <
|
|
860
|
+
.option('--business-canon <value>', 'Business canon: owner/repo, /local/path, or "none" to skip')
|
|
760
861
|
.option('--github-token <token>', 'GitHub token for non-interactive mode')
|
|
761
862
|
.option('--timeout-ms <ms>', 'Per-check timeout in milliseconds', '5000')
|
|
762
863
|
.option('--retries <count>', 'Health check retries', '15')
|
|
@@ -818,7 +919,7 @@ Examples:
|
|
|
818
919
|
mcpUrl: preserveExisting ? context.config.mcpUrl : selections.mcpUrl,
|
|
819
920
|
};
|
|
820
921
|
// ── Step 2: Business canon configuration ──────────────────
|
|
821
|
-
const canons = await resolveBusinessCanon(options, context.logger);
|
|
922
|
+
const canons = await resolveBusinessCanon(options, effectiveConfig, context.logger);
|
|
822
923
|
if (canons) {
|
|
823
924
|
effectiveConfig.canons = canons;
|
|
824
925
|
}
|
|
@@ -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;
|
|
@@ -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
|
+
}
|