@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.
@@ -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 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.');
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 prompt
245
- const repo = await (0, prompt_1.promptText)('Business architecture canon repo (owner/repo, empty to skip):');
246
- if (!repo) {
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 (!repo.includes('/')) {
251
- throw new errors_1.CliError(`Invalid format "${repo}". Use "owner/repo".`);
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)('Business canon branch:', 'main');
337
+ const branch = await (0, prompt_1.promptText)('Branch:', defaultBranch);
254
338
  return {
255
339
  business: {
256
340
  repo,
257
- branch: branch || 'main',
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 <owner/repo>', 'Business canon repo (owner/repo or "none" to skip)')
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
- 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;
@@ -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
+ }
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.7",
4
4
  "description": "CLI for collaborative architecture and delivery workflows.",
5
5
  "private": false,
6
6
  "license": "UNLICENSED",