@vibecompass/vibecompass 0.3.1 → 0.3.3

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.
Files changed (3) hide show
  1. package/README.md +8 -5
  2. package/package.json +1 -1
  3. package/src/setup.js +150 -17
package/README.md CHANGED
@@ -327,11 +327,14 @@ Guided init can also record workflow defaults in
327
327
  `project.yaml.metadata.workflow`, including whether close-session should
328
328
  include a Git publish step and which remote name to use for that step.
329
329
 
330
- Guided init is inference-first for the common single-repo case: it reads
331
- `package.json`, detects the current Git `origin`, asks you to confirm the
332
- detected repo, and maps a friendly setup goal to the stored project mode.
333
- Hosted credentials are deferred by default; run `vibecompass connect-hosted`
334
- when you are ready to connect the local root to `vibecompass.dev`.
330
+ Guided init is inference-first for common repo layouts: it reads `package.json`,
331
+ detects the current Git `origin` when you run it inside a repo, or scans a
332
+ parent workspace for immediate child repos when the parent is not itself a Git
333
+ checkout. It asks once before using detected repos, maps a friendly setup goal
334
+ to the stored project mode, places single-child repo memory under that child
335
+ repo, and uses workspace-root placement for multi-repo parent folders. Hosted
336
+ credentials are deferred by default; run `vibecompass connect-hosted` when you
337
+ are ready to connect the local root to `vibecompass.dev`.
335
338
 
336
339
  ### `vibecompass connect-hosted`
337
340
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecompass/vibecompass",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Local-first project-memory core for VibeCompass",
5
5
  "type": "module",
6
6
  "bin": {
package/src/setup.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { access, readFile } from 'node:fs/promises';
2
+ import { access, readFile, readdir, realpath } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { createInterface } from 'node:readline/promises';
5
5
  import { promisify } from 'node:util';
@@ -220,14 +220,18 @@ async function completeGuidedInitOptions(options, environment) {
220
220
  resolved.mode = setupGoal === 'share-later' ? 'local-primary' : setupGoal;
221
221
  }
222
222
 
223
+ const detectedRepos = inferredProject.repos ?? [];
224
+
223
225
  if (!Array.isArray(resolved.repos) || resolved.repos.length === 0) {
224
- if (inferredProject.repo) {
225
- const useDetectedRepo = await askConfirm(
226
+ if (detectedRepos.length > 0) {
227
+ const useDetectedRepos = await askConfirm(
226
228
  prompter,
227
- `Use detected Git repo ${inferredProject.repo.remote}?`,
229
+ formatDetectedReposPrompt(detectedRepos),
228
230
  true,
229
231
  );
230
- resolved.repos = useDetectedRepo ? [inferredProject.repo] : await promptForRepos(prompter, cwd);
232
+ resolved.repos = useDetectedRepos
233
+ ? detectedRepos.map((repo) => toProjectRepo(repo))
234
+ : await promptForRepos(prompter, cwd);
231
235
  } else {
232
236
  resolved.repos = await promptForRepos(prompter, cwd);
233
237
  }
@@ -235,7 +239,15 @@ async function completeGuidedInitOptions(options, environment) {
235
239
 
236
240
  let recommendation = null;
237
241
  if (!resolved.placementPattern) {
238
- if (inferredProject.repo && resolved.repos.length === 1) {
242
+ if (inferredProject.source === 'child-repos' && detectedRepos.length > 1 && reposMatchDetected(resolved.repos, detectedRepos)) {
243
+ recommendation = recommendPlacementPattern({
244
+ repoCount: detectedRepos.length,
245
+ usesSharedWorkspace: true,
246
+ });
247
+ resolved.placementPattern = recommendation.pattern;
248
+ prompter.write(`Recommended placement: ${recommendation.pattern}\n`);
249
+ prompter.write(`${recommendation.reason}\n`);
250
+ } else if (detectedRepos.length === 1 && reposMatchDetected(resolved.repos, detectedRepos)) {
239
251
  recommendation = recommendPlacementPattern({ repoCount: 1 });
240
252
  resolved.placementPattern = recommendation.pattern;
241
253
  } else {
@@ -248,7 +260,9 @@ async function completeGuidedInitOptions(options, environment) {
248
260
  }
249
261
 
250
262
  if (!resolved.toolingRootDir) {
251
- if (inferredProject.repo && resolved.repos.length === 1 && resolved.placementPattern === PLACEMENT_PATTERNS.PRIMARY_REPO) {
263
+ if (resolved.placementPattern === PLACEMENT_PATTERNS.PRIMARY_REPO && detectedRepos.length === 1 && reposMatchDetected(resolved.repos, detectedRepos)) {
264
+ resolved.toolingRootDir = detectedRepos[0].directory;
265
+ } else if (resolved.placementPattern === PLACEMENT_PATTERNS.WORKSPACE_ROOT && inferredProject.source === 'child-repos' && detectedRepos.length > 1 && reposMatchDetected(resolved.repos, detectedRepos)) {
252
266
  resolved.toolingRootDir = '.';
253
267
  } else {
254
268
  const ownerLabel = describePlacementOwner(resolved.placementPattern);
@@ -381,26 +395,43 @@ async function completeGuidedInitOptions(options, environment) {
381
395
  }
382
396
 
383
397
  async function inferProjectFromCwd(cwd) {
384
- const [packageName, gitInfo] = await Promise.all([
398
+ const [packageName, gitInfo, childRepos] = await Promise.all([
385
399
  readPackageName(cwd),
386
400
  readGitInfo(cwd),
401
+ discoverChildGitRepos(cwd),
387
402
  ]);
388
403
  const projectName = packageName ?? path.basename(cwd);
389
404
 
390
- if (!gitInfo?.remote) {
391
- return { projectName, repo: null };
405
+ if (gitInfo?.remote) {
406
+ const remoteSlug = extractRemoteSlug(gitInfo.remote);
407
+ const repoId = suggestRepoId(remoteSlug ?? packageName ?? path.basename(gitInfo.root ?? cwd));
408
+
409
+ return {
410
+ projectName,
411
+ source: 'current-repo',
412
+ repos: [
413
+ {
414
+ id: repoId,
415
+ remote: gitInfo.remote,
416
+ directory: '.',
417
+ ...(gitInfo.branch ? { defaultBranch: gitInfo.branch } : {}),
418
+ },
419
+ ],
420
+ };
392
421
  }
393
422
 
394
- const remoteSlug = extractRemoteSlug(gitInfo.remote);
395
- const repoId = suggestRepoId(remoteSlug ?? packageName ?? path.basename(gitInfo.root ?? cwd));
423
+ if (childRepos.length > 0) {
424
+ return {
425
+ projectName,
426
+ source: 'child-repos',
427
+ repos: childRepos,
428
+ };
429
+ }
396
430
 
397
431
  return {
398
432
  projectName,
399
- repo: {
400
- id: repoId,
401
- remote: gitInfo.remote,
402
- ...(gitInfo.branch ? { defaultBranch: gitInfo.branch } : {}),
403
- },
433
+ source: 'none',
434
+ repos: [],
404
435
  };
405
436
  }
406
437
 
@@ -437,6 +468,96 @@ async function readGitInfo(cwd) {
437
468
  }
438
469
  }
439
470
 
471
+ async function discoverChildGitRepos(cwd) {
472
+ let entries = [];
473
+ try {
474
+ entries = await readdir(cwd, { withFileTypes: true });
475
+ } catch {
476
+ return [];
477
+ }
478
+
479
+ const candidates = entries
480
+ .filter((entry) => entry.isDirectory() && isScannableWorkspaceChild(entry.name))
481
+ .sort((left, right) => left.name.localeCompare(right.name));
482
+
483
+ const discovered = await Promise.all(candidates.map(async (entry) => {
484
+ const directory = entry.name;
485
+ const absoluteDirectory = path.join(cwd, directory);
486
+ const [gitInfo, packageName] = await Promise.all([
487
+ readGitInfo(absoluteDirectory),
488
+ readPackageName(absoluteDirectory),
489
+ ]);
490
+
491
+ if (!gitInfo?.remote) {
492
+ return null;
493
+ }
494
+
495
+ if (gitInfo.root && !(await isSameDirectory(gitInfo.root, absoluteDirectory))) {
496
+ return null;
497
+ }
498
+
499
+ const remoteSlug = extractRemoteSlug(gitInfo.remote);
500
+ return {
501
+ id: suggestRepoId(remoteSlug ?? packageName ?? directory),
502
+ remote: gitInfo.remote,
503
+ directory,
504
+ ...(gitInfo.branch ? { defaultBranch: gitInfo.branch } : {}),
505
+ };
506
+ }));
507
+
508
+ const reposByDirectory = new Map();
509
+ for (const repo of discovered) {
510
+ if (repo && !reposByDirectory.has(repo.directory)) {
511
+ reposByDirectory.set(repo.directory, repo);
512
+ }
513
+ }
514
+
515
+ return [...reposByDirectory.values()].sort((left, right) => left.directory.localeCompare(right.directory));
516
+ }
517
+
518
+ function isScannableWorkspaceChild(name) {
519
+ if (!name || name.startsWith('.')) {
520
+ return false;
521
+ }
522
+
523
+ return !new Set([
524
+ 'build',
525
+ 'coverage',
526
+ 'dist',
527
+ 'node_modules',
528
+ 'out',
529
+ 'target',
530
+ 'vendor',
531
+ ]).has(name);
532
+ }
533
+
534
+ function formatDetectedReposPrompt(repos) {
535
+ if (repos.length === 1) {
536
+ const repo = repos[0];
537
+ const location = repo.directory && repo.directory !== '.' ? ` in ${repo.directory}` : '';
538
+ return `Use detected Git repo${location} (${repo.remote})?`;
539
+ }
540
+
541
+ return `Use ${repos.length} detected Git repos (${repos.map((repo) => repo.directory).join(', ')})?`;
542
+ }
543
+
544
+ function toProjectRepo(repo) {
545
+ return {
546
+ id: repo.id,
547
+ remote: repo.remote,
548
+ ...(repo.defaultBranch ? { defaultBranch: repo.defaultBranch } : {}),
549
+ };
550
+ }
551
+
552
+ function reposMatchDetected(repos, detectedRepos) {
553
+ if (!Array.isArray(repos) || repos.length !== detectedRepos.length) {
554
+ return false;
555
+ }
556
+
557
+ const detectedKeys = new Set(detectedRepos.map((repo) => `${repo.id}\n${repo.remote}`));
558
+ return repos.every((repo) => detectedKeys.has(`${repo.id}\n${repo.remote}`));
559
+ }
560
+
440
561
  async function promptForRepos(prompter, cwd) {
441
562
  const repoCount = await askInteger(prompter, 'How many repos belong to this logical project?', {
442
563
  defaultValue: 1,
@@ -872,6 +993,18 @@ async function directoryExists(directoryPath) {
872
993
  }
873
994
  }
874
995
 
996
+ async function isSameDirectory(left, right) {
997
+ try {
998
+ const [leftRealPath, rightRealPath] = await Promise.all([
999
+ realpath(left),
1000
+ realpath(right),
1001
+ ]);
1002
+ return leftRealPath === rightRealPath;
1003
+ } catch {
1004
+ return path.resolve(left) === path.resolve(right);
1005
+ }
1006
+ }
1007
+
875
1008
  function toRelativeIfInsideCwd(targetPath, cwd) {
876
1009
  const absolute = path.resolve(cwd, targetPath);
877
1010
  const relative = path.relative(cwd, absolute);