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.
@@ -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 = new Set([
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
- initial: boolean
118
- ): void {
119
- // Move back up to overwrite previous render (skip on first draw)
120
- if (!initial) {
121
- stdout.write(moveUp(options.length));
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 = 0; i < options.length; 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, true);
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, false);
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, false);
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
- stdout.write(moveUp(options.length));
174
- for (let i = 0; i < options.length; i++) {
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
- initial: boolean
280
- ): void {
281
- if (!initial) {
282
- stdout.write(moveUp(options.length));
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 = 0; i < options.length; 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, true);
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, false);
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, false);
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, false);
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
- stdout.write(moveUp(options.length));
352
- for (let i = 0; i < options.length; i++) {
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 = new Set([
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 ?? [])