opencastle 0.10.2 → 0.10.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.
- package/dist/cli/detect.d.ts +6 -0
- package/dist/cli/detect.d.ts.map +1 -1
- package/dist/cli/detect.js +133 -0
- package/dist/cli/detect.js.map +1 -1
- package/dist/cli/detect.test.js +119 -1
- package/dist/cli/detect.test.js.map +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +2 -8
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/prompt.d.ts +8 -0
- package/dist/cli/prompt.d.ts.map +1 -1
- package/dist/cli/prompt.js +81 -20
- package/dist/cli/prompt.js.map +1 -1
- package/dist/cli/prompt.test.d.ts +2 -0
- package/dist/cli/prompt.test.d.ts.map +1 -0
- package/dist/cli/prompt.test.js +58 -0
- package/dist/cli/prompt.test.js.map +1 -0
- package/dist/cli/types.d.ts +1 -1
- package/dist/cli/types.d.ts.map +1 -1
- package/dist/cli/update.d.ts.map +1 -1
- package/dist/cli/update.js +2 -8
- package/dist/cli/update.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/detect.test.ts +133 -1
- package/src/cli/detect.ts +144 -0
- package/src/cli/init.ts +2 -8
- package/src/cli/prompt.test.ts +66 -0
- package/src/cli/prompt.ts +100 -22
- package/src/cli/types.ts +1 -1
- package/src/cli/update.ts +2 -8
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
- package/src/orchestrator/agents/team-lead.agent.md +18 -12
- package/src/orchestrator/instructions/general.instructions.md +12 -8
- package/src/orchestrator/skills/agent-hooks/SKILL.md +6 -4
package/src/cli/detect.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
|
2
2
|
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
|
-
import { detectRepoInfo, mergeStackIntoRepoInfo, formatRepoInfo } from './detect.js'
|
|
5
|
+
import { detectRepoInfo, mergeStackIntoRepoInfo, formatRepoInfo, buildDetectedToolsSet } from './detect.js'
|
|
6
6
|
import type { StackConfig, RepoInfo } from './types.js'
|
|
7
7
|
|
|
8
8
|
// ── detectRepoInfo (filesystem-backed) ─────────────────────────
|
|
@@ -196,6 +196,138 @@ describe('detectRepoInfo', () => {
|
|
|
196
196
|
expect(info.language).toBe('typescript')
|
|
197
197
|
expect(info.styling).toContain('tailwind')
|
|
198
198
|
})
|
|
199
|
+
it('detects tools from workspace package dependencies in NX monorepo', async () => {
|
|
200
|
+
await writeFile(join(tempDir, 'nx.json'), '{}')
|
|
201
|
+
await mkdir(join(tempDir, 'apps', 'web'), { recursive: true })
|
|
202
|
+
await writeFile(join(tempDir, 'apps', 'web', 'package.json'), JSON.stringify({
|
|
203
|
+
dependencies: { next: '^14.0.0', '@supabase/supabase-js': '^2.0.0' }
|
|
204
|
+
}))
|
|
205
|
+
await mkdir(join(tempDir, 'apps', 'studio'), { recursive: true })
|
|
206
|
+
await writeFile(join(tempDir, 'apps', 'studio', 'package.json'), JSON.stringify({
|
|
207
|
+
dependencies: { sanity: '^3.0.0' }
|
|
208
|
+
}))
|
|
209
|
+
const info = await detectRepoInfo(tempDir)
|
|
210
|
+
expect(info.monorepo).toBe('nx')
|
|
211
|
+
expect(info.frameworks).toContain('next')
|
|
212
|
+
expect(info.databases).toContain('supabase')
|
|
213
|
+
expect(info.cms).toContain('sanity')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('detects config files in workspace package directories', async () => {
|
|
217
|
+
await writeFile(join(tempDir, 'nx.json'), '{}')
|
|
218
|
+
await mkdir(join(tempDir, 'apps', 'web'), { recursive: true })
|
|
219
|
+
await writeFile(join(tempDir, 'apps', 'web', 'next.config.mjs'), 'export default {}')
|
|
220
|
+
await mkdir(join(tempDir, 'apps', 'studio'), { recursive: true })
|
|
221
|
+
await writeFile(join(tempDir, 'apps', 'studio', 'sanity.config.ts'), 'export default {}')
|
|
222
|
+
const info = await detectRepoInfo(tempDir)
|
|
223
|
+
expect(info.frameworks).toContain('next')
|
|
224
|
+
expect(info.cms).toContain('sanity')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('detects tools from pnpm workspace packages', async () => {
|
|
228
|
+
await writeFile(join(tempDir, 'pnpm-workspace.yaml'), 'packages:\n - apps/*\n - packages/*')
|
|
229
|
+
await writeFile(join(tempDir, 'pnpm-lock.yaml'), '')
|
|
230
|
+
await mkdir(join(tempDir, 'apps', 'web'), { recursive: true })
|
|
231
|
+
await writeFile(join(tempDir, 'apps', 'web', 'package.json'), JSON.stringify({
|
|
232
|
+
dependencies: { next: '^14.0.0' }
|
|
233
|
+
}))
|
|
234
|
+
const info = await detectRepoInfo(tempDir)
|
|
235
|
+
expect(info.frameworks).toContain('next')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('does not duplicate tools found in both root and workspace packages', async () => {
|
|
239
|
+
await writeFile(join(tempDir, 'nx.json'), '{}')
|
|
240
|
+
await writeFile(join(tempDir, 'package.json'), JSON.stringify({
|
|
241
|
+
dependencies: { next: '^14.0.0' }
|
|
242
|
+
}))
|
|
243
|
+
await mkdir(join(tempDir, 'apps', 'web'), { recursive: true })
|
|
244
|
+
await writeFile(join(tempDir, 'apps', 'web', 'package.json'), JSON.stringify({
|
|
245
|
+
dependencies: { next: '^14.0.0' }
|
|
246
|
+
}))
|
|
247
|
+
const info = await detectRepoInfo(tempDir)
|
|
248
|
+
expect(info.frameworks?.filter(f => f === 'next')).toHaveLength(1)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('detects a full monorepo stack (NX + Next.js + Sanity + Supabase + Vercel)', async () => {
|
|
252
|
+
// Root level
|
|
253
|
+
await writeFile(join(tempDir, 'nx.json'), '{}')
|
|
254
|
+
await writeFile(join(tempDir, 'vercel.json'), '{}')
|
|
255
|
+
await writeFile(join(tempDir, 'tsconfig.json'), '{}')
|
|
256
|
+
await writeFile(join(tempDir, 'pnpm-lock.yaml'), '')
|
|
257
|
+
|
|
258
|
+
// apps/web
|
|
259
|
+
await mkdir(join(tempDir, 'apps', 'web'), { recursive: true })
|
|
260
|
+
await writeFile(join(tempDir, 'apps', 'web', 'next.config.mjs'), 'export default {}')
|
|
261
|
+
await writeFile(join(tempDir, 'apps', 'web', 'package.json'), JSON.stringify({
|
|
262
|
+
dependencies: { next: '^14.0.0', '@supabase/supabase-js': '^2.0.0' },
|
|
263
|
+
devDependencies: { vitest: '^1.0.0' }
|
|
264
|
+
}))
|
|
265
|
+
|
|
266
|
+
// apps/studio
|
|
267
|
+
await mkdir(join(tempDir, 'apps', 'studio'), { recursive: true })
|
|
268
|
+
await writeFile(join(tempDir, 'apps', 'studio', 'sanity.config.ts'), 'export default {}')
|
|
269
|
+
await writeFile(join(tempDir, 'apps', 'studio', 'package.json'), JSON.stringify({
|
|
270
|
+
dependencies: { sanity: '^3.0.0' }
|
|
271
|
+
}))
|
|
272
|
+
|
|
273
|
+
// supabase dir at root
|
|
274
|
+
await mkdir(join(tempDir, 'supabase'), { recursive: true })
|
|
275
|
+
await writeFile(join(tempDir, 'supabase', 'config.toml'), '')
|
|
276
|
+
|
|
277
|
+
const info = await detectRepoInfo(tempDir)
|
|
278
|
+
|
|
279
|
+
expect(info.monorepo).toBe('nx')
|
|
280
|
+
expect(info.packageManager).toBe('pnpm')
|
|
281
|
+
expect(info.language).toBe('typescript')
|
|
282
|
+
expect(info.frameworks).toContain('next')
|
|
283
|
+
expect(info.databases).toContain('supabase')
|
|
284
|
+
expect(info.cms).toContain('sanity')
|
|
285
|
+
expect(info.deployment).toContain('vercel')
|
|
286
|
+
expect(info.testing).toContain('vitest')
|
|
287
|
+
expect(info.auth).toContain('supabase-auth')
|
|
288
|
+
|
|
289
|
+
// Verify buildDetectedToolsSet correctly maps all detected tools
|
|
290
|
+
const detected = buildDetectedToolsSet(info)
|
|
291
|
+
expect(detected.has('nx')).toBe(true)
|
|
292
|
+
expect(detected.has('nextjs')).toBe(true)
|
|
293
|
+
expect(detected.has('sanity')).toBe(true)
|
|
294
|
+
expect(detected.has('supabase')).toBe(true)
|
|
295
|
+
expect(detected.has('vercel')).toBe(true)
|
|
296
|
+
expect(detected.has('vitest')).toBe(true)
|
|
297
|
+
})
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
// ── buildDetectedToolsSet ──────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
describe('buildDetectedToolsSet', () => {
|
|
303
|
+
it('maps detection labels to plugin IDs', () => {
|
|
304
|
+
const set = buildDetectedToolsSet({
|
|
305
|
+
cms: ['sanity'],
|
|
306
|
+
databases: ['supabase'],
|
|
307
|
+
deployment: ['vercel'],
|
|
308
|
+
monorepo: 'nx',
|
|
309
|
+
frameworks: ['next'],
|
|
310
|
+
testing: ['vitest'],
|
|
311
|
+
})
|
|
312
|
+
expect(set.has('sanity')).toBe(true)
|
|
313
|
+
expect(set.has('supabase')).toBe(true)
|
|
314
|
+
expect(set.has('vercel')).toBe(true)
|
|
315
|
+
expect(set.has('nx')).toBe(true)
|
|
316
|
+
expect(set.has('nextjs')).toBe(true)
|
|
317
|
+
expect(set.has('vitest')).toBe(true)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('handles empty repoInfo', () => {
|
|
321
|
+
const set = buildDetectedToolsSet({})
|
|
322
|
+
expect(set.size).toBe(0)
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('maps next to nextjs but leaves astro as-is', () => {
|
|
326
|
+
const set = buildDetectedToolsSet({ frameworks: ['next', 'astro'] })
|
|
327
|
+
expect(set.has('nextjs')).toBe(true)
|
|
328
|
+
expect(set.has('next')).toBe(false)
|
|
329
|
+
expect(set.has('astro')).toBe(true)
|
|
330
|
+
})
|
|
199
331
|
})
|
|
200
332
|
|
|
201
333
|
// ── mergeStackIntoRepoInfo ─────────────────────────────────────
|
package/src/cli/detect.ts
CHANGED
|
@@ -236,6 +236,11 @@ export async function detectRepoInfo(projectRoot: string): Promise<RepoInfo> {
|
|
|
236
236
|
// ── 4. Detect from package.json deps ────────────────────────
|
|
237
237
|
await detectFromPackageJson(projectRoot, info);
|
|
238
238
|
|
|
239
|
+
// ── 4b. Scan workspace packages in monorepos ────────────────
|
|
240
|
+
if (info.monorepo) {
|
|
241
|
+
await scanWorkspacePackages(projectRoot, info);
|
|
242
|
+
}
|
|
243
|
+
|
|
239
244
|
// ── 5. Detect MCP config ────────────────────────────────────
|
|
240
245
|
const mcpPaths = [
|
|
241
246
|
'.vscode/mcp.json',
|
|
@@ -402,6 +407,145 @@ async function scanForPattern(root: string, pattern: RegExp): Promise<boolean> {
|
|
|
402
407
|
return false;
|
|
403
408
|
}
|
|
404
409
|
|
|
410
|
+
/**
|
|
411
|
+
* Scan workspace package directories in monorepos for additional tooling.
|
|
412
|
+
* Checks both package.json dependencies and config files in each workspace package.
|
|
413
|
+
*/
|
|
414
|
+
async function scanWorkspacePackages(projectRoot: string, info: RepoInfoInternal): Promise<void> {
|
|
415
|
+
const packageDirs = await resolveWorkspacePackageDirs(projectRoot);
|
|
416
|
+
|
|
417
|
+
for (const pkgDir of packageDirs) {
|
|
418
|
+
await detectFromPackageJson(pkgDir, info);
|
|
419
|
+
await Promise.all([
|
|
420
|
+
detectCategory(pkgDir, FRAMEWORKS, info, 'frameworks'),
|
|
421
|
+
detectCategory(pkgDir, DATABASES, info, 'databases'),
|
|
422
|
+
detectCategory(pkgDir, CMS_PLATFORMS, info, 'cms'),
|
|
423
|
+
detectCategory(pkgDir, DEPLOYMENT, info, 'deployment'),
|
|
424
|
+
detectCategory(pkgDir, TESTING, info, 'testing'),
|
|
425
|
+
detectCategory(pkgDir, CICD, info, 'cicd'),
|
|
426
|
+
detectCategory(pkgDir, STYLING, info, 'styling'),
|
|
427
|
+
detectCategory(pkgDir, AUTH, info, 'auth'),
|
|
428
|
+
]);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Resolve workspace package directories.
|
|
434
|
+
* Tries pnpm-workspace.yaml first, then falls back to scanning common directories.
|
|
435
|
+
*/
|
|
436
|
+
async function resolveWorkspacePackageDirs(projectRoot: string): Promise<string[]> {
|
|
437
|
+
const dirs: string[] = [];
|
|
438
|
+
|
|
439
|
+
// Try pnpm-workspace.yaml first
|
|
440
|
+
const pnpmWorkspacePath = resolve(projectRoot, 'pnpm-workspace.yaml');
|
|
441
|
+
if (await fileExists(pnpmWorkspacePath)) {
|
|
442
|
+
try {
|
|
443
|
+
const content = await readFile(pnpmWorkspacePath, 'utf8');
|
|
444
|
+
const globs = parsePnpmWorkspaceGlobs(content);
|
|
445
|
+
for (const glob of globs) {
|
|
446
|
+
const resolved = await expandGlobDirs(projectRoot, glob);
|
|
447
|
+
dirs.push(...resolved);
|
|
448
|
+
}
|
|
449
|
+
} catch {
|
|
450
|
+
// Fall through to common directory scan
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Fall back to common directories if nothing found
|
|
455
|
+
if (dirs.length === 0) {
|
|
456
|
+
const commonDirs = ['apps', 'packages', 'libs'];
|
|
457
|
+
for (const dir of commonDirs) {
|
|
458
|
+
const dirPath = resolve(projectRoot, dir);
|
|
459
|
+
if (await dirExists(dirPath)) {
|
|
460
|
+
try {
|
|
461
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
462
|
+
for (const entry of entries) {
|
|
463
|
+
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
464
|
+
dirs.push(resolve(dirPath, entry.name));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
} catch {
|
|
468
|
+
// Skip unreadable directories
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return dirs;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Parse pnpm-workspace.yaml to extract package glob patterns.
|
|
479
|
+
* Simple line parser — no YAML library needed.
|
|
480
|
+
*/
|
|
481
|
+
function parsePnpmWorkspaceGlobs(content: string): string[] {
|
|
482
|
+
const globs: string[] = [];
|
|
483
|
+
const lines = content.split('\n');
|
|
484
|
+
let inPackages = false;
|
|
485
|
+
|
|
486
|
+
for (const line of lines) {
|
|
487
|
+
const trimmed = line.trim();
|
|
488
|
+
if (trimmed === 'packages:') {
|
|
489
|
+
inPackages = true;
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (inPackages) {
|
|
493
|
+
// Stop at next top-level key
|
|
494
|
+
if (trimmed && !trimmed.startsWith('-') && !trimmed.startsWith('#')) {
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
const match = trimmed.match(/^-\s+['"]?([^'"]+?)['"]?\s*$/);
|
|
498
|
+
if (match) {
|
|
499
|
+
globs.push(match[1]);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return globs;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Expand a glob pattern like 'apps/*' into actual directories.
|
|
509
|
+
* Only supports simple patterns ending in /* (single-level wildcard).
|
|
510
|
+
*/
|
|
511
|
+
async function expandGlobDirs(root: string, glob: string): Promise<string[]> {
|
|
512
|
+
const dirs: string[] = [];
|
|
513
|
+
// Strip trailing /* or /** and resolve the parent directory
|
|
514
|
+
const cleaned = glob.replace(/\/\*\*?$/, '').replace(/\/$/, '');
|
|
515
|
+
const parentDir = resolve(root, cleaned);
|
|
516
|
+
|
|
517
|
+
if (await dirExists(parentDir)) {
|
|
518
|
+
try {
|
|
519
|
+
const entries = await readdir(parentDir, { withFileTypes: true });
|
|
520
|
+
for (const entry of entries) {
|
|
521
|
+
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
522
|
+
dirs.push(resolve(parentDir, entry.name));
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
} catch {
|
|
526
|
+
// Skip unreadable directories
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return dirs;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Build a Set of detected tool plugin IDs from RepoInfo.
|
|
535
|
+
* Maps detection labels to plugin IDs (e.g., 'next' → 'nextjs').
|
|
536
|
+
* Includes all categories: cms, databases, deployment, testing, monorepo, frameworks.
|
|
537
|
+
*/
|
|
538
|
+
export function buildDetectedToolsSet(repoInfo: RepoInfo): Set<string> {
|
|
539
|
+
return new Set([
|
|
540
|
+
...(repoInfo.cms ?? []),
|
|
541
|
+
...(repoInfo.databases ?? []),
|
|
542
|
+
...(repoInfo.deployment ?? []),
|
|
543
|
+
...(repoInfo.testing ?? []),
|
|
544
|
+
...(repoInfo.monorepo ? [repoInfo.monorepo] : []),
|
|
545
|
+
...((repoInfo.frameworks ?? []).map(f => f === 'next' ? 'nextjs' : f)),
|
|
546
|
+
]);
|
|
547
|
+
}
|
|
548
|
+
|
|
405
549
|
/**
|
|
406
550
|
* Remove empty arrays and undefined values, returning a clean RepoInfo.
|
|
407
551
|
*/
|
package/src/cli/init.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { removeDirIfExists } from './copy.js'
|
|
|
7
7
|
import { updateGitignore } from './gitignore.js'
|
|
8
8
|
import { getRequiredMcpEnvVars } from './stack-config.js'
|
|
9
9
|
import { TECH_PLUGINS, TEAM_PLUGINS } from '../orchestrator/plugins/index.js'
|
|
10
|
-
import { detectRepoInfo, mergeStackIntoRepoInfo, formatRepoInfo } from './detect.js'
|
|
10
|
+
import { detectRepoInfo, mergeStackIntoRepoInfo, formatRepoInfo, buildDetectedToolsSet } from './detect.js'
|
|
11
11
|
import { IDE_ADAPTERS } from './adapters/index.js'
|
|
12
12
|
import { IDE_LABELS } from './types.js'
|
|
13
13
|
import type { CliContext, IdeChoice, TechTool, TeamTool, StackConfig } from './types.js'
|
|
@@ -83,13 +83,7 @@ export default async function init({ pkgRoot, args }: CliContext): Promise<void>
|
|
|
83
83
|
|
|
84
84
|
// ── Tech Tools (multiselect, 0-N) ──────────────────────────────
|
|
85
85
|
// Pre-select tools already detected in the repo
|
|
86
|
-
const detectedTools =
|
|
87
|
-
...(repoInfo.cms ?? []),
|
|
88
|
-
...(repoInfo.databases ?? []),
|
|
89
|
-
...(repoInfo.deployment ?? []),
|
|
90
|
-
...(repoInfo.monorepo ? [repoInfo.monorepo] : []),
|
|
91
|
-
...((repoInfo.frameworks ?? []).map(f => f === 'next' ? 'nextjs' : f)),
|
|
92
|
-
])
|
|
86
|
+
const detectedTools = buildDetectedToolsSet(repoInfo)
|
|
93
87
|
|
|
94
88
|
console.log(` ${c.bold('── Tech Tools ────────────────────────────────')}`)
|
|
95
89
|
const techTools = await multiselect('Which tools does your project use?',
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { computeVisibleWindow } from './prompt.js';
|
|
3
|
+
|
|
4
|
+
describe('computeVisibleWindow', () => {
|
|
5
|
+
it('returns full range when all items fit', () => {
|
|
6
|
+
expect(computeVisibleWindow(0, 5, 10)).toEqual({ start: 0, end: 5 });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('keeps cursor visible at top', () => {
|
|
10
|
+
const { start, end } = computeVisibleWindow(0, 20, 10);
|
|
11
|
+
expect(start).toBeLessThanOrEqual(0);
|
|
12
|
+
expect(end).toBeGreaterThan(0);
|
|
13
|
+
expect(end - start).toBe(10);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('keeps cursor visible at bottom', () => {
|
|
17
|
+
const { start, end } = computeVisibleWindow(19, 20, 10);
|
|
18
|
+
expect(start).toBeLessThanOrEqual(19);
|
|
19
|
+
expect(end).toBeGreaterThanOrEqual(20);
|
|
20
|
+
expect(end - start).toBe(10);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('keeps cursor visible in middle', () => {
|
|
24
|
+
const { start, end } = computeVisibleWindow(10, 20, 10);
|
|
25
|
+
expect(start).toBeLessThanOrEqual(10);
|
|
26
|
+
expect(end).toBeGreaterThan(10);
|
|
27
|
+
expect(end - start).toBe(10);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('handles cursor at exact boundary', () => {
|
|
31
|
+
const { start, end } = computeVisibleWindow(9, 20, 10);
|
|
32
|
+
expect(start).toBeLessThanOrEqual(9);
|
|
33
|
+
expect(end).toBeGreaterThan(9);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('window size never exceeds maxVisible', () => {
|
|
37
|
+
for (let cursor = 0; cursor < 20; cursor++) {
|
|
38
|
+
const { start, end } = computeVisibleWindow(cursor, 20, 10);
|
|
39
|
+
expect(end - start).toBeLessThanOrEqual(10);
|
|
40
|
+
expect(start).toBeGreaterThanOrEqual(0);
|
|
41
|
+
expect(end).toBeLessThanOrEqual(20);
|
|
42
|
+
expect(start).toBeLessThanOrEqual(cursor);
|
|
43
|
+
expect(end).toBeGreaterThan(cursor);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Test that wrapping cursor (e.g. from 0 to 19 on arrow-up) stays visible
|
|
48
|
+
it('handles wrap from first to last item', () => {
|
|
49
|
+
const { start, end } = computeVisibleWindow(19, 20, 10);
|
|
50
|
+
expect(start).toBeLessThanOrEqual(19);
|
|
51
|
+
expect(end).toBe(20);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('handles wrap from last to first item', () => {
|
|
55
|
+
const { start, end } = computeVisibleWindow(0, 20, 10);
|
|
56
|
+
expect(start).toBe(0);
|
|
57
|
+
expect(end).toBeGreaterThan(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns valid range for maxVisible of 3 (minimum)', () => {
|
|
61
|
+
const { start, end } = computeVisibleWindow(10, 20, 3);
|
|
62
|
+
expect(end - start).toBe(3);
|
|
63
|
+
expect(start).toBeLessThanOrEqual(10);
|
|
64
|
+
expect(end).toBeGreaterThan(10);
|
|
65
|
+
});
|
|
66
|
+
});
|
package/src/cli/prompt.ts
CHANGED
|
@@ -27,6 +27,31 @@ export const c = {
|
|
|
27
27
|
magenta: (s: string) => `\x1B[35m${s}\x1B[0m`,
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
+
// ── Scrollable window helper ──────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Compute which slice of options to render in a scrollable window.
|
|
34
|
+
* Keeps the cursor visible within the window.
|
|
35
|
+
*/
|
|
36
|
+
export function computeVisibleWindow(
|
|
37
|
+
cursor: number,
|
|
38
|
+
total: number,
|
|
39
|
+
maxVisible: number
|
|
40
|
+
): { start: number; end: number } {
|
|
41
|
+
if (total <= maxVisible) return { start: 0, end: total };
|
|
42
|
+
let start = Math.max(0, cursor - maxVisible + 1);
|
|
43
|
+
let end = start + maxVisible;
|
|
44
|
+
if (end > total) {
|
|
45
|
+
end = total;
|
|
46
|
+
start = end - maxVisible;
|
|
47
|
+
}
|
|
48
|
+
if (cursor < start) {
|
|
49
|
+
start = cursor;
|
|
50
|
+
end = start + maxVisible;
|
|
51
|
+
}
|
|
52
|
+
return { start, end };
|
|
53
|
+
}
|
|
54
|
+
|
|
30
55
|
// ── Line-buffered readline ────────────────────────────────────────
|
|
31
56
|
// readline.question() drops lines that arrived between calls because
|
|
32
57
|
// it only listens for the NEXT 'line' event. When piped input
|
|
@@ -114,14 +139,22 @@ export async function select(
|
|
|
114
139
|
function renderOptions(
|
|
115
140
|
options: SelectOption[],
|
|
116
141
|
cursor: number,
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (
|
|
121
|
-
stdout.write(moveUp(
|
|
142
|
+
lastRenderedLines: number,
|
|
143
|
+
maxVisible: number
|
|
144
|
+
): number {
|
|
145
|
+
if (lastRenderedLines > 0) {
|
|
146
|
+
stdout.write(moveUp(lastRenderedLines));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const { start, end } = computeVisibleWindow(cursor, options.length, maxVisible);
|
|
150
|
+
let lines = 0;
|
|
151
|
+
|
|
152
|
+
if (start > 0) {
|
|
153
|
+
stdout.write(`${ERASE_LINE}\r ${c.dim(`↑ ${start} more above`)}\n`);
|
|
154
|
+
lines++;
|
|
122
155
|
}
|
|
123
156
|
|
|
124
|
-
for (let i =
|
|
157
|
+
for (let i = start; i < end; i++) {
|
|
125
158
|
const active = i === cursor;
|
|
126
159
|
const marker = active ? '❯' : ' ';
|
|
127
160
|
const hint = options[i].hint ? ` — ${options[i].hint}` : '';
|
|
@@ -129,7 +162,20 @@ function renderOptions(
|
|
|
129
162
|
? `\x1B[36m${options[i].label}\x1B[0m${hint}`
|
|
130
163
|
: `${options[i].label}${hint}`;
|
|
131
164
|
stdout.write(`${ERASE_LINE}\r ${marker} ${label}\n`);
|
|
165
|
+
lines++;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (end < options.length) {
|
|
169
|
+
stdout.write(`${ERASE_LINE}\r ${c.dim(`↓ ${options.length - end} more below`)}\n`);
|
|
170
|
+
lines++;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Clear leftover lines from previous longer render
|
|
174
|
+
if (lines < lastRenderedLines) {
|
|
175
|
+
stdout.write(`${CSI}J`);
|
|
132
176
|
}
|
|
177
|
+
|
|
178
|
+
return lines;
|
|
133
179
|
}
|
|
134
180
|
|
|
135
181
|
function selectInteractive(
|
|
@@ -138,13 +184,15 @@ function selectInteractive(
|
|
|
138
184
|
): Promise<string> {
|
|
139
185
|
return new Promise<string>((resolve) => {
|
|
140
186
|
let cursor = 0;
|
|
187
|
+
const maxVisible = Math.max(3, Math.min(options.length, (process.stdout.rows || 24) - 4));
|
|
188
|
+
let lastRenderedLines = 0;
|
|
141
189
|
|
|
142
190
|
// Pause the readline interface so raw mode can take over
|
|
143
191
|
if (_rl) _rl.pause();
|
|
144
192
|
|
|
145
193
|
stdout.write(`\n ${message}\n\n`);
|
|
146
194
|
stdout.write(HIDE_CURSOR);
|
|
147
|
-
renderOptions(options, cursor,
|
|
195
|
+
lastRenderedLines = renderOptions(options, cursor, 0, maxVisible);
|
|
148
196
|
|
|
149
197
|
stdin.setRawMode(true);
|
|
150
198
|
stdin.resume();
|
|
@@ -155,14 +203,14 @@ function selectInteractive(
|
|
|
155
203
|
// Arrow up or k
|
|
156
204
|
if (key === `${ESC}[A` || key === 'k') {
|
|
157
205
|
cursor = (cursor - 1 + options.length) % options.length;
|
|
158
|
-
renderOptions(options, cursor,
|
|
206
|
+
lastRenderedLines = renderOptions(options, cursor, lastRenderedLines, maxVisible);
|
|
159
207
|
return;
|
|
160
208
|
}
|
|
161
209
|
|
|
162
210
|
// Arrow down or j
|
|
163
211
|
if (key === `${ESC}[B` || key === 'j') {
|
|
164
212
|
cursor = (cursor + 1) % options.length;
|
|
165
|
-
renderOptions(options, cursor,
|
|
213
|
+
lastRenderedLines = renderOptions(options, cursor, lastRenderedLines, maxVisible);
|
|
166
214
|
return;
|
|
167
215
|
}
|
|
168
216
|
|
|
@@ -170,8 +218,9 @@ function selectInteractive(
|
|
|
170
218
|
if (key === '\r' || key === '\n') {
|
|
171
219
|
cleanup();
|
|
172
220
|
// Re-render final state with the selected option highlighted
|
|
173
|
-
|
|
174
|
-
|
|
221
|
+
const { start, end } = computeVisibleWindow(cursor, options.length, maxVisible);
|
|
222
|
+
stdout.write(moveUp(lastRenderedLines));
|
|
223
|
+
for (let i = start; i < end; i++) {
|
|
175
224
|
const active = i === cursor;
|
|
176
225
|
const hint = options[i].hint ? ` — ${options[i].hint}` : '';
|
|
177
226
|
const label = active
|
|
@@ -180,6 +229,8 @@ function selectInteractive(
|
|
|
180
229
|
const marker = active ? '✔' : ' ';
|
|
181
230
|
stdout.write(`${ERASE_LINE}\r ${marker} ${label}\n`);
|
|
182
231
|
}
|
|
232
|
+
// Clear leftover lines from scrolled render
|
|
233
|
+
stdout.write(`${CSI}J`);
|
|
183
234
|
stdout.write('\n');
|
|
184
235
|
resolve(options[cursor].value);
|
|
185
236
|
return;
|
|
@@ -276,13 +327,22 @@ function renderMultiselectOptions(
|
|
|
276
327
|
options: SelectOption[],
|
|
277
328
|
cursor: number,
|
|
278
329
|
selected: Set<number>,
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
330
|
+
lastRenderedLines: number,
|
|
331
|
+
maxVisible: number
|
|
332
|
+
): number {
|
|
333
|
+
if (lastRenderedLines > 0) {
|
|
334
|
+
stdout.write(moveUp(lastRenderedLines));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const { start, end } = computeVisibleWindow(cursor, options.length, maxVisible);
|
|
338
|
+
let lines = 0;
|
|
339
|
+
|
|
340
|
+
if (start > 0) {
|
|
341
|
+
stdout.write(`${ERASE_LINE}\r ${c.dim(`↑ ${start} more above`)}\n`);
|
|
342
|
+
lines++;
|
|
283
343
|
}
|
|
284
344
|
|
|
285
|
-
for (let i =
|
|
345
|
+
for (let i = start; i < end; i++) {
|
|
286
346
|
const active = i === cursor;
|
|
287
347
|
const checked = selected.has(i);
|
|
288
348
|
const checkbox = checked ? `\x1B[32m✔\x1B[0m` : ' ';
|
|
@@ -292,7 +352,20 @@ function renderMultiselectOptions(
|
|
|
292
352
|
? `\x1B[36m${options[i].label}\x1B[0m${hint}`
|
|
293
353
|
: `${options[i].label}${hint}`;
|
|
294
354
|
stdout.write(`${ERASE_LINE}\r ${marker} [${checkbox}] ${label}\n`);
|
|
355
|
+
lines++;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (end < options.length) {
|
|
359
|
+
stdout.write(`${ERASE_LINE}\r ${c.dim(`↓ ${options.length - end} more below`)}\n`);
|
|
360
|
+
lines++;
|
|
295
361
|
}
|
|
362
|
+
|
|
363
|
+
// Clear leftover lines from previous longer render
|
|
364
|
+
if (lines < lastRenderedLines) {
|
|
365
|
+
stdout.write(`${CSI}J`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return lines;
|
|
296
369
|
}
|
|
297
370
|
|
|
298
371
|
function multiselectInteractive(
|
|
@@ -302,6 +375,8 @@ function multiselectInteractive(
|
|
|
302
375
|
return new Promise<string[]>((resolve) => {
|
|
303
376
|
let cursor = 0;
|
|
304
377
|
const selected = new Set<number>();
|
|
378
|
+
const maxVisible = Math.max(3, Math.min(options.length, (process.stdout.rows || 24) - 4));
|
|
379
|
+
let lastRenderedLines = 0;
|
|
305
380
|
// Pre-select options marked as selected
|
|
306
381
|
for (let i = 0; i < options.length; i++) {
|
|
307
382
|
if (options[i].selected) selected.add(i);
|
|
@@ -311,7 +386,7 @@ function multiselectInteractive(
|
|
|
311
386
|
|
|
312
387
|
stdout.write(`\n ${message} ${c.dim('(↑/↓ navigate, Space toggle, Enter confirm)')}\n\n`);
|
|
313
388
|
stdout.write(HIDE_CURSOR);
|
|
314
|
-
renderMultiselectOptions(options, cursor, selected,
|
|
389
|
+
lastRenderedLines = renderMultiselectOptions(options, cursor, selected, 0, maxVisible);
|
|
315
390
|
|
|
316
391
|
stdin.setRawMode(true);
|
|
317
392
|
stdin.resume();
|
|
@@ -322,14 +397,14 @@ function multiselectInteractive(
|
|
|
322
397
|
// Arrow up or k
|
|
323
398
|
if (key === `${ESC}[A` || key === 'k') {
|
|
324
399
|
cursor = (cursor - 1 + options.length) % options.length;
|
|
325
|
-
renderMultiselectOptions(options, cursor, selected,
|
|
400
|
+
lastRenderedLines = renderMultiselectOptions(options, cursor, selected, lastRenderedLines, maxVisible);
|
|
326
401
|
return;
|
|
327
402
|
}
|
|
328
403
|
|
|
329
404
|
// Arrow down or j
|
|
330
405
|
if (key === `${ESC}[B` || key === 'j') {
|
|
331
406
|
cursor = (cursor + 1) % options.length;
|
|
332
|
-
renderMultiselectOptions(options, cursor, selected,
|
|
407
|
+
lastRenderedLines = renderMultiselectOptions(options, cursor, selected, lastRenderedLines, maxVisible);
|
|
333
408
|
return;
|
|
334
409
|
}
|
|
335
410
|
|
|
@@ -340,7 +415,7 @@ function multiselectInteractive(
|
|
|
340
415
|
} else {
|
|
341
416
|
selected.add(cursor);
|
|
342
417
|
}
|
|
343
|
-
renderMultiselectOptions(options, cursor, selected,
|
|
418
|
+
lastRenderedLines = renderMultiselectOptions(options, cursor, selected, lastRenderedLines, maxVisible);
|
|
344
419
|
return;
|
|
345
420
|
}
|
|
346
421
|
|
|
@@ -348,8 +423,9 @@ function multiselectInteractive(
|
|
|
348
423
|
if (key === '\r' || key === '\n') {
|
|
349
424
|
cleanup();
|
|
350
425
|
// Final render
|
|
351
|
-
|
|
352
|
-
|
|
426
|
+
const { start, end } = computeVisibleWindow(cursor, options.length, maxVisible);
|
|
427
|
+
stdout.write(moveUp(lastRenderedLines));
|
|
428
|
+
for (let i = start; i < end; i++) {
|
|
353
429
|
const checked = selected.has(i);
|
|
354
430
|
const hint = options[i].hint ? ` ${c.dim('—')} ${c.dim(options[i].hint!)}` : '';
|
|
355
431
|
const checkbox = checked ? `\x1B[32m✔\x1B[0m` : ' ';
|
|
@@ -358,6 +434,8 @@ function multiselectInteractive(
|
|
|
358
434
|
: `\x1B[2m${options[i].label}${hint}\x1B[0m`;
|
|
359
435
|
stdout.write(`${ERASE_LINE}\r [${checkbox}] ${label}\n`);
|
|
360
436
|
}
|
|
437
|
+
// Clear leftover lines from scrolled render
|
|
438
|
+
stdout.write(`${CSI}J`);
|
|
361
439
|
stdout.write('\n');
|
|
362
440
|
resolve(Array.from(selected).sort().map(i => options[i].value));
|
|
363
441
|
return;
|
package/src/cli/types.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { ChildProcess } from 'node:child_process';
|
|
|
3
3
|
// ── Stack selection types ──────────────────────────────────────
|
|
4
4
|
|
|
5
5
|
export type IdeChoice = 'vscode' | 'cursor' | 'claude-code' | 'opencode';
|
|
6
|
-
export type TechTool = 'sanity' | 'contentful' | 'strapi' | 'supabase' | 'convex' | 'vercel' | 'nx' | 'chrome-devtools' | 'nextjs' | 'astro';
|
|
6
|
+
export type TechTool = 'sanity' | 'contentful' | 'strapi' | 'supabase' | 'convex' | 'vercel' | 'nx' | 'chrome-devtools' | 'nextjs' | 'astro' | 'netlify' | 'turborepo' | 'prisma' | 'cypress' | 'playwright' | 'vitest' | 'figma' | 'resend';
|
|
7
7
|
export type TeamTool = 'linear' | 'jira' | 'slack' | 'teams';
|
|
8
8
|
|
|
9
9
|
export interface StackConfig {
|
package/src/cli/update.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { TECH_PLUGINS, TEAM_PLUGINS } from '../orchestrator/plugins/index.js'
|
|
|
7
7
|
import { IDE_ADAPTERS, VALID_IDES } from './adapters/index.js'
|
|
8
8
|
import { getRequiredMcpEnvVars, updateSkillMatrixFile } from './stack-config.js'
|
|
9
9
|
import { rebuildMcpConfig } from './mcp.js'
|
|
10
|
-
import { detectRepoInfo, mergeStackIntoRepoInfo } from './detect.js'
|
|
10
|
+
import { detectRepoInfo, mergeStackIntoRepoInfo, buildDetectedToolsSet } from './detect.js'
|
|
11
11
|
import type { CliContext, IdeChoice, TechTool, TeamTool, StackConfig } from './types.js'
|
|
12
12
|
|
|
13
13
|
export default async function update({
|
|
@@ -75,13 +75,7 @@ export default async function update({
|
|
|
75
75
|
let removedTools: string[] = []
|
|
76
76
|
|
|
77
77
|
if (wantsReconfigure) {
|
|
78
|
-
const detectedTools =
|
|
79
|
-
...(repoInfo.cms ?? []),
|
|
80
|
-
...(repoInfo.databases ?? []),
|
|
81
|
-
...(repoInfo.deployment ?? []),
|
|
82
|
-
...(repoInfo.monorepo ? [repoInfo.monorepo] : []),
|
|
83
|
-
...((repoInfo.frameworks ?? []).map((f) => (f === 'next' ? 'nextjs' : f))),
|
|
84
|
-
])
|
|
78
|
+
const detectedTools = buildDetectedToolsSet(repoInfo)
|
|
85
79
|
|
|
86
80
|
const currentTech = new Set(oldStack?.techTools ?? [])
|
|
87
81
|
const currentTeam = new Set(oldStack?.teamTools ?? [])
|