@owdproject/cli 0.0.2 → 0.1.0

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/bin/cp.js ADDED
@@ -0,0 +1,3523 @@
1
+ import { execSync } from 'node:child_process'
2
+ import { watch, existsSync, statSync, openSync, readSync, closeSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'
3
+ import { dirname, join } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import blessed from 'neo-blessed'
6
+ import {
7
+ findWorkspaceRoot,
8
+ loadSettings,
9
+ saveSettings,
10
+ desktopPaths,
11
+ KINDS,
12
+ shortName,
13
+ isInstallableDesktopModule,
14
+ isDesktopKitPackage,
15
+ inferKind,
16
+ normalizeCatalogSort,
17
+ hasDocsModuleInstalled,
18
+ docsBasePathFromConfig,
19
+ getPackagesRequiredByTheme as getPackagesRequiredByThemeImport,
20
+ } from './lib/workspace.js'
21
+ import {
22
+ executeInstallPlan as extExecuteInstallPlan,
23
+ runStartupInstallFlow as extRunStartupInstallFlow,
24
+ } from './lib/installWizardFlow.js'
25
+ import {
26
+ readDesktopConfig,
27
+ writeDesktopConfig,
28
+ readDesktopDependencies,
29
+ resolveConfigPathForWrite,
30
+ } from './lib/config.js'
31
+ import { warnLegacyDesktopConfig } from './lib/desktopConfig.js'
32
+ import {
33
+ loadCatalog,
34
+ filterCatalog,
35
+ mergeInstalled,
36
+ effectiveInstalledSets,
37
+ CATALOG_SORT_MODES,
38
+ CATALOG_SORT_LABELS,
39
+ CATALOG_SORT_DESCRIPTIONS,
40
+ } from './lib/catalog.js'
41
+ import {
42
+ installPackage,
43
+ materializeToWorkspace,
44
+ hasLocalWorkspaceSource,
45
+ runPrepareModules,
46
+ resolveInstallPlan,
47
+ cloneRepo,
48
+ spawnAsync,
49
+ } from './lib/install.js'
50
+ import {
51
+ resolvePackageSources,
52
+ buildSourceOptions,
53
+ detectGithubSshAuth,
54
+ getCachedSshAuth,
55
+ trustedPublishers,
56
+ githubCloneUrl,
57
+ } from './lib/packageSources.js'
58
+ import {
59
+ formatCatalogRowPlain,
60
+ formatLegendLine,
61
+ formatDetailPanel,
62
+ formatHeaderLine,
63
+ getColumnWidths,
64
+ } from './lib/cpFormat.js'
65
+ import {
66
+ radarSpinner,
67
+ progressTrack,
68
+ statusPrefix,
69
+ spinnerFrameCount,
70
+ } from './lib/cpAscii.js'
71
+ import {
72
+ getClientStatus,
73
+ startDev,
74
+ stopDev,
75
+ waitForDev,
76
+ waitForDevStop,
77
+ devLogPath,
78
+ formatSparkline,
79
+ } from './lib/status.js'
80
+ import { resolveDevTarget } from './lib/playgroundContext.js'
81
+
82
+ const __dirname = dirname(fileURLToPath(import.meta.url))
83
+
84
+ const C = {
85
+ border: 'cyan',
86
+ borderDim: '#3a3a4a',
87
+ focus: 'green',
88
+ accent: '#7ee787',
89
+ warn: 'yellow',
90
+ err: 'red',
91
+ muted: '#6c7086',
92
+ title: 'white',
93
+ npm: '#7ee787',
94
+ git: 'cyan',
95
+ local: '#ff79c6',
96
+ add: '#7ee787',
97
+ remove: 'red',
98
+ label: '#a6adc8',
99
+ borderActive: 'white',
100
+ }
101
+
102
+ const FORMAT_COLORS = {
103
+ npm: C.npm,
104
+ git: C.git,
105
+ local: C.local,
106
+ warn: C.warn,
107
+ add: C.add,
108
+ remove: C.remove,
109
+ accent: C.accent,
110
+ muted: C.muted,
111
+ }
112
+
113
+ /** Text color only — no bg fill so the terminal theme shows through. */
114
+ function flatStyle(fg = 'white') {
115
+ return { fg }
116
+ }
117
+
118
+ /** Colored border lines + label (no panel fill). */
119
+ function panelStyle(borderFg, fg = 'white') {
120
+ return {
121
+ fg,
122
+ border: { fg: borderFg },
123
+ label: { fg },
124
+ }
125
+ }
126
+
127
+ const LIST_SCROLLBAR = {
128
+ ch: '│',
129
+ track: { ch: ' ' },
130
+ style: { fg: C.borderDim },
131
+ }
132
+
133
+ const LIST_STYLE = {
134
+ fg: 'white',
135
+ item: { fg: 'white' },
136
+ scrollbar: { fg: C.borderDim },
137
+ selected: { fg: C.accent, bold: true },
138
+ }
139
+
140
+ /** Unicode superscript hints (HTML <sup> equivalent in TUI). */
141
+ const KEY_SUP = {
142
+ '0': '⁰',
143
+ '1': '¹',
144
+ '2': '²',
145
+ '3': '³',
146
+ '4': '⁴',
147
+ '5': '⁵',
148
+ '6': '⁶',
149
+ '7': '⁷',
150
+ '8': '⁸',
151
+ '9': '⁹',
152
+ a: 'ᵃ',
153
+ b: 'ᵇ',
154
+ d: 'ᵈ',
155
+ g: 'ᵍ',
156
+ i: 'ⁱ',
157
+ m: 'ᵐ',
158
+ o: 'ᵒ',
159
+ q: 'ᵠ',
160
+ r: 'ʳ',
161
+ s: 'ˢ',
162
+ t: 'ᵗ',
163
+ w: 'ʷ',
164
+ x: 'ˣ',
165
+ }
166
+
167
+ /** @param {string} key */
168
+ function keySup(key) {
169
+ const k = String(key).toLowerCase()
170
+ return KEY_SUP[k] ?? KEY_SUP[key] ?? key
171
+ }
172
+
173
+ /** @param {string} key */
174
+ function keyHint(key) {
175
+ return `{${C.accent}-fg}${keySup(key)}{/}`
176
+ }
177
+
178
+ /** First letter primary, optional superscript key. */
179
+ function cardTitle(title, key = null) {
180
+ const first = title.charAt(0)
181
+ const rest = title.slice(1)
182
+ const isKeyFirstLetter = key !== null && key.toLowerCase() === first.toLowerCase()
183
+ const hint = (key !== null && !isKeyFirstLetter) ? keyHint(key) : ''
184
+ return `{${C.accent}-fg}${first}{/}{white-fg}${rest}{/}${hint}`
185
+ }
186
+
187
+ /** @param {string} title @param {string | null} [key] */
188
+ function cardLabel(title, key = null) {
189
+ return ` ${cardTitle(title, key)} `
190
+ }
191
+
192
+ const MEM_HISTORY_LEN = 30
193
+ const LEGEND_ROWS = 1
194
+ const TAB_ROWS = 1
195
+
196
+ function areConfigsEqual(c1, c2) {
197
+ if (!c1 || !c2) return false
198
+ if (shortName(c1.theme ?? '') !== shortName(c2.theme ?? '')) return false
199
+ if ((c1.apps ?? []).length !== (c2.apps ?? []).length) return false
200
+ if ((c1.modules ?? []).length !== (c2.modules ?? []).length) return false
201
+
202
+ const apps1 = [...(c1.apps ?? [])].sort()
203
+ const apps2 = [...(c2.apps ?? [])].sort()
204
+ for (let i = 0; i < apps1.length; i++) {
205
+ if (apps1[i] !== apps2[i]) return false
206
+ }
207
+
208
+ const mods1 = [...(c1.modules ?? [])].sort()
209
+ const mods2 = [...(c2.modules ?? [])].sort()
210
+ for (let i = 0; i < mods1.length; i++) {
211
+ if (mods1[i] !== mods2[i]) return false
212
+ }
213
+
214
+ return true
215
+ }
216
+
217
+ const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
218
+ function stripAnsi(str) {
219
+ return str.replace(ansiRegex, '')
220
+ }
221
+
222
+ function truncateFormatted(str, maxLen) {
223
+ let result = ''
224
+ let visibleCount = 0
225
+ let i = 0
226
+ let truncated = false
227
+ while (i < str.length) {
228
+ if (str[i] === '{') {
229
+ const closeIdx = str.indexOf('}', i)
230
+ if (closeIdx !== -1) {
231
+ result += str.slice(i, closeIdx + 1)
232
+ i = closeIdx + 1
233
+ continue
234
+ }
235
+ }
236
+ if (visibleCount >= maxLen) {
237
+ truncated = true
238
+ break
239
+ }
240
+ result += str[i]
241
+ visibleCount++
242
+ i++
243
+ }
244
+ if (truncated) {
245
+ result += '...{/}'
246
+ }
247
+ return result
248
+ }
249
+
250
+ /** @param {string} commandName */
251
+ export async function runCp(commandName = 'desktop') {
252
+ process.title = 'Desktop'
253
+ const workspaceRoot = findWorkspaceRoot()
254
+ if (!workspaceRoot) {
255
+ console.error('Not inside an OWD workspace.')
256
+ process.exit(1)
257
+ }
258
+
259
+ let paths = desktopPaths(workspaceRoot)
260
+ const devTarget = resolveDevTarget(process.cwd(), workspaceRoot)
261
+ const playgroundActive = devTarget?.mode === 'playground'
262
+ const playgroundLabel = devTarget?.packageName ?? null
263
+ warnLegacyDesktopConfig(
264
+ paths.configLegacy ? { legacy: true, file: 'owd.config.ts' } : null,
265
+ )
266
+ let settings = loadSettings(workspaceRoot)
267
+ let configError = null
268
+ let config = null
269
+ try {
270
+ config = readDesktopConfig(paths.config, workspaceRoot)
271
+ } catch (err) {
272
+ configError = err.message
273
+ config = { apps: [], modules: [], theme: null }
274
+ }
275
+ let deps = readDesktopDependencies(paths.packageJson)
276
+
277
+ function getGitStatus(root) {
278
+ try {
279
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', {
280
+ cwd: root,
281
+ encoding: 'utf8',
282
+ stdio: ['ignore', 'pipe', 'ignore'],
283
+ }).trim()
284
+
285
+ const porcelain = execSync('git status --porcelain', {
286
+ cwd: root,
287
+ encoding: 'utf8',
288
+ stdio: ['ignore', 'pipe', 'ignore'],
289
+ }).trim()
290
+
291
+ if (!porcelain) {
292
+ return { branch, status: 'clean' }
293
+ }
294
+
295
+ const lines = porcelain.split('\n').filter(Boolean)
296
+ const modifiedCount = lines.filter(l => !l.startsWith('??')).length
297
+ const untrackedCount = lines.filter(l => l.startsWith('??')).length
298
+
299
+ const parts = []
300
+ if (modifiedCount > 0) parts.push(`${modifiedCount} mod`)
301
+ if (untrackedCount > 0) parts.push(`${untrackedCount} untracked`)
302
+ return { branch, status: parts.join(', ') || 'clean' }
303
+ } catch {
304
+ return { branch: '—', status: 'unknown' }
305
+ }
306
+ }
307
+
308
+ function getMissingGitWarnings(root) {
309
+ const warnings = []
310
+ const checkDirs = ['apps', 'themes', 'packages']
311
+
312
+ for (const dirName of checkDirs) {
313
+ const fullDir = join(root, dirName)
314
+ if (!existsSync(fullDir)) continue
315
+
316
+ try {
317
+ const subdirs = readdirSync(fullDir, { withFileTypes: true })
318
+ for (const subdir of subdirs) {
319
+ if (!subdir.isDirectory()) continue
320
+ const name = subdir.name
321
+ if (name.startsWith('.')) continue
322
+
323
+ // Exclude exclusions
324
+ if (dirName === 'packages') {
325
+ if (name.startsWith('core') || name.startsWith('nx') || name.startsWith('kit-')) {
326
+ continue
327
+ }
328
+ }
329
+
330
+ const gitPath = join(fullDir, name, '.git')
331
+ if (!existsSync(gitPath)) {
332
+ warnings.push(subdir.name)
333
+ }
334
+ }
335
+ } catch {
336
+ // Ignore read errors
337
+ }
338
+ }
339
+
340
+ return warnings
341
+ }
342
+
343
+ function getWorkspaceGitChanges(root) {
344
+ // Returns { added, modified, deleted } summed across the main repo
345
+ // and all sub-repos in apps/, packages/, themes/ that have a .git folder.
346
+ const sumChanges = (dir) => {
347
+ let added = 0, modified = 0, deleted = 0
348
+ try {
349
+ const porcelain = execSync('git status --porcelain', {
350
+ cwd: dir,
351
+ encoding: 'utf8',
352
+ stdio: ['ignore', 'pipe', 'ignore'],
353
+ }).trim()
354
+ if (!porcelain) return { added: 0, modified: 0, deleted: 0 }
355
+ for (const line of porcelain.split('\n').filter(Boolean)) {
356
+ if (line.startsWith('??')) { added++; continue }
357
+ const xy = line.slice(0, 2)
358
+ if (xy.includes('A')) added++
359
+ else if (xy.includes('D')) deleted++
360
+ else modified++
361
+ }
362
+ } catch { /* no git or not a repo */ }
363
+ return { added, modified, deleted }
364
+ }
365
+
366
+ let totalAdded = 0, totalModified = 0, totalDeleted = 0
367
+ let appsRepos = 0
368
+ let modulesRepos = 0
369
+ let themesRepos = 0
370
+
371
+ // Main workspace repo
372
+ const main = sumChanges(root)
373
+ totalAdded += main.added
374
+ totalModified += main.modified
375
+ totalDeleted += main.deleted
376
+ const mainRepo = (main.added > 0 || main.modified > 0 || main.deleted > 0) ? 1 : 0
377
+
378
+ // Sub-repos
379
+ const checkDirs = ['apps', 'themes', 'packages']
380
+ for (const dirName of checkDirs) {
381
+ const fullDir = join(root, dirName)
382
+ if (!existsSync(fullDir)) continue
383
+ try {
384
+ const subdirs = readdirSync(fullDir, { withFileTypes: true })
385
+ for (const subdir of subdirs) {
386
+ if (!subdir.isDirectory()) continue
387
+ const name = subdir.name
388
+ if (name.startsWith('.')) continue
389
+ if (dirName === 'packages') {
390
+ if (name.startsWith('core') || name.startsWith('nx') || name.startsWith('kit-')) continue
391
+ }
392
+ const subGit = join(fullDir, name, '.git')
393
+ if (!existsSync(subGit)) continue
394
+ const sub = sumChanges(join(fullDir, name))
395
+ if (sub.added > 0 || sub.modified > 0 || sub.deleted > 0) {
396
+ if (dirName === 'apps') appsRepos++
397
+ else if (dirName === 'packages') modulesRepos++
398
+ else if (dirName === 'themes') themesRepos++
399
+ }
400
+ totalAdded += sub.added
401
+ totalModified += sub.modified
402
+ totalDeleted += sub.deleted
403
+ }
404
+ } catch { /* ignore */ }
405
+ }
406
+
407
+ return {
408
+ added: totalAdded,
409
+ modified: totalModified,
410
+ deleted: totalDeleted,
411
+ mainRepo,
412
+ appsRepos,
413
+ modulesRepos,
414
+ themesRepos,
415
+ }
416
+ }
417
+
418
+ let nuxtVersion = '—'
419
+ let pnpmVersion = '—'
420
+ try {
421
+ const pkg = JSON.parse(readFileSync(join(workspaceRoot, 'package.json'), 'utf8'))
422
+ nuxtVersion = pkg.devDependencies?.nuxt || pkg.dependencies?.nuxt || '—'
423
+ pnpmVersion = (pkg.packageManager || '').replace('pnpm@', '') || '—'
424
+ } catch {
425
+ // Ignore
426
+ }
427
+
428
+ let isWritingConfig = false
429
+ let ignoreNextConfigWatch = false
430
+ let isInstalling = false
431
+ let hasInstallError = false
432
+ let isStartingServer = false
433
+ let configWatcher = null
434
+ let configWatchTimeout = null
435
+ let logWatcher = null
436
+
437
+ const packageUpdates = new Map()
438
+ const localGitChanges = new Map()
439
+ let checkingUpdates = false
440
+
441
+ let catalog = []
442
+ let catalogCacheAge = ''
443
+ let activeTab = 'app'
444
+ let pendingTheme = config.theme
445
+ /** @type {Map<string, boolean>} */
446
+ const pendingPackages = new Map()
447
+ /** @type {Map<string, import('./lib/packageSources.js').InstallSourceChoice>} */
448
+ const installChoices = new Map()
449
+ let statusLine = ''
450
+ let lastTone = 'info'
451
+ let confirmDialogOpen = false
452
+ let activeTaskMessage = null
453
+ let devPhase = 'idle'
454
+ let clientStatus = await getClientStatus(workspaceRoot, settings.devPort)
455
+ /** @type {number[]} */
456
+ const memHistory = []
457
+ let spinnerFrame = 0
458
+ let showcaseIndex = 0
459
+ let spinnerTimer = null
460
+ let saveProgress = null
461
+ let currentColumns = getColumnWidths(76)
462
+ let settingsOpen = false
463
+ let menuOpen = false
464
+ let installWizardOpen = false
465
+ let installWizardResolve = null
466
+ let installWizardIsStartup = false
467
+ /** @type {string[]} */
468
+ let installWizardQueue = []
469
+ let installWizardIndex = 0
470
+ /** @type {import('./lib/packageSources.js').SourceOption[]} */
471
+ let installWizardOptions = []
472
+ let installWizardUntrusted = false
473
+ let installWizardConfirmUntrusted = false
474
+ let installWizardAutoExecute = true
475
+ let sortPickerOpen = false
476
+ let sshAuthStatus = getCachedSshAuth(workspaceRoot)
477
+ /** @type {ReturnType<typeof setTimeout> | null} */
478
+ let configRestartHintTimer = null
479
+ let configRestartHintUntil = 0
480
+
481
+ /** @type {{ id: string, label: string, key: string }[]} */
482
+ const MENU_ITEMS = [
483
+ { id: 'start', label: 'Start dev server', key: 's' },
484
+ { id: 'stop', label: 'Stop dev server', key: 'x' },
485
+ { id: 'reboot', label: 'Reboot dev server', key: 'R' },
486
+ { id: 'save', label: 'Save catalog / theme', key: 'w' },
487
+ { id: 'refresh', label: 'Refresh package list', key: 'r' },
488
+ { id: 'check-updates', label: 'Check GitHub/NPM updates', key: 'u' },
489
+ { id: 'docs', label: 'Open documentation', key: 'i' },
490
+ { id: 'settings', label: 'Settings', key: 'g' },
491
+ { id: 'build', label: 'Build (pnpm generate)', key: 'b' },
492
+ { id: 'quit', label: 'Quit control panel', key: 'q' },
493
+ ]
494
+
495
+ const screen = blessed.screen({
496
+ smartCSR: true,
497
+ fullUnicode: true,
498
+ title: 'Desktop',
499
+ dockCorners: false,
500
+ style: flatStyle(),
501
+ })
502
+
503
+ const HEADER_ROWS = 0
504
+ const MAIN_TOP = 0
505
+ const CLIENT_PANEL_H = 9
506
+ const PANELS_TOP = MAIN_TOP + CLIENT_PANEL_H
507
+
508
+ const clientBox = blessed.box({
509
+ parent: screen,
510
+ top: MAIN_TOP,
511
+ left: 0,
512
+ width: '58%-1',
513
+ height: CLIENT_PANEL_H,
514
+ tags: true,
515
+ border: { type: 'line' },
516
+ style: panelStyle(C.border),
517
+ label: ` {bold}{${C.accent}-fg}Desktop{/}{/} control panel · ${keyHint('m')} menu · ${keyHint('g')} settings `,
518
+ })
519
+
520
+ const metricsBox = blessed.box({
521
+ parent: screen,
522
+ top: MAIN_TOP,
523
+ left: '58%',
524
+ width: '42%',
525
+ height: CLIENT_PANEL_H,
526
+ tags: true,
527
+ border: { type: 'line' },
528
+ style: panelStyle(C.border),
529
+ label: ' Metrics ',
530
+ })
531
+
532
+ const catalogBox = blessed.box({
533
+ parent: screen,
534
+ top: PANELS_TOP,
535
+ left: 0,
536
+ width: '100%',
537
+ bottom: 5,
538
+ tags: true,
539
+ border: { type: 'line' },
540
+ style: panelStyle(C.borderDim),
541
+ label: cardLabel('Catalog'),
542
+ })
543
+
544
+ const logsBox = blessed.box({
545
+ parent: screen,
546
+ border: { type: 'line' },
547
+ style: panelStyle(C.borderDim),
548
+ label: cardLabel('Logs', 'l'),
549
+ tags: true,
550
+ parseAnsi: true,
551
+ scrollable: true,
552
+ alwaysScroll: true,
553
+ scrollbar: LIST_SCROLLBAR,
554
+ mouse: true,
555
+ keys: true,
556
+ top: PANELS_TOP,
557
+ left: '58%',
558
+ width: '42%',
559
+ bottom: 5,
560
+ })
561
+
562
+ const tabBar = blessed.box({
563
+ parent: catalogBox,
564
+ top: 0,
565
+ left: 1,
566
+ width: '100%-2',
567
+ height: TAB_ROWS,
568
+ tags: true,
569
+ content: '',
570
+ style: flatStyle(),
571
+ mouse: true,
572
+ })
573
+
574
+ const headerBar = blessed.box({
575
+ parent: catalogBox,
576
+ top: TAB_ROWS + 1,
577
+ left: 1,
578
+ width: '100%-2',
579
+ height: 1,
580
+ tags: true,
581
+ content: '',
582
+ style: flatStyle(C.muted),
583
+ })
584
+
585
+ const catalogList = blessed.list({
586
+ parent: catalogBox,
587
+ top: TAB_ROWS + 2,
588
+ left: 1,
589
+ width: '100%-2',
590
+ bottom: 9,
591
+ tags: true,
592
+ keys: true,
593
+ vi: true,
594
+ mouse: true,
595
+ scrollbar: LIST_SCROLLBAR,
596
+ alwaysScroll: true,
597
+ style: LIST_STYLE,
598
+ })
599
+
600
+ const detailDivider = blessed.box({
601
+ parent: catalogBox,
602
+ bottom: 8,
603
+ left: 1,
604
+ width: '100%-2',
605
+ height: 1,
606
+ tags: true,
607
+ content: '',
608
+ style: flatStyle(C.borderDim),
609
+ })
610
+
611
+ const detailBox = blessed.box({
612
+ parent: catalogBox,
613
+ bottom: 1,
614
+ left: 1,
615
+ width: '100%-2',
616
+ height: 7,
617
+ tags: true,
618
+ content: '',
619
+ style: flatStyle(C.muted),
620
+ })
621
+
622
+ function layoutCatalogPanel() {
623
+ const cols = screen.width ?? 80
624
+ const rows = screen.height ?? 24
625
+
626
+ const showLogs = (isDevServerUp() || isInstalling || hasInstallError) && cols >= 120
627
+
628
+ if (showLogs) {
629
+ logsBox.show()
630
+
631
+ clientBox.position.width = '40%-1'
632
+
633
+ metricsBox.position.left = '40%'
634
+ metricsBox.position.width = '28%-1'
635
+
636
+ catalogBox.position.width = '68%-1'
637
+
638
+ logsBox.position.left = '68%'
639
+ logsBox.position.width = '32%'
640
+ logsBox.position.top = MAIN_TOP
641
+ logsBox.position.height = rows - MAIN_TOP - 5
642
+ } else {
643
+ logsBox.hide()
644
+ if (screen.focused === logsBox) {
645
+ focusCatalog()
646
+ }
647
+
648
+ clientBox.position.width = '58%-1'
649
+
650
+ metricsBox.position.left = '58%'
651
+ metricsBox.position.width = '42%'
652
+
653
+ catalogBox.position.width = '100%'
654
+ }
655
+
656
+ const detailH = rows < 26 ? 4 : rows < 34 ? 5 : 7
657
+ catalogBox.position.height = rows - PANELS_TOP - 5
658
+
659
+ Object.assign(detailBox.position, {
660
+ bottom: 1,
661
+ left: 1,
662
+ width: '100%-2',
663
+ height: detailH,
664
+ top: undefined,
665
+ right: undefined,
666
+ })
667
+
668
+ Object.assign(detailDivider.position, {
669
+ bottom: detailH + 1,
670
+ left: 1,
671
+ width: '100%-2',
672
+ height: 1,
673
+ top: undefined,
674
+ right: undefined,
675
+ })
676
+
677
+ Object.assign(headerBar.position, {
678
+ top: TAB_ROWS + 1,
679
+ left: 1,
680
+ width: '100%-2',
681
+ height: 1,
682
+ bottom: undefined,
683
+ right: undefined,
684
+ })
685
+
686
+ Object.assign(catalogList.position, {
687
+ top: TAB_ROWS + 2,
688
+ left: 1,
689
+ width: '100%-2',
690
+ bottom: detailH + 2,
691
+ height: undefined,
692
+ right: undefined,
693
+ })
694
+
695
+ const catalogBoxWidth = catalogBox.width || (showLogs ? Math.floor(cols * 0.68) : cols)
696
+ const targetWidth = Math.max(40, catalogBoxWidth - 4)
697
+ currentColumns = getColumnWidths(targetWidth)
698
+ detailDivider.setContent(`{${C.borderDim}-fg}${ '─'.repeat(targetWidth) }{/}`)
699
+
700
+ clientBox.emit('resize')
701
+ metricsBox.emit('resize')
702
+ catalogBox.emit('resize')
703
+ detailBox.emit('resize')
704
+ detailDivider.emit('resize')
705
+ catalogList.emit('resize')
706
+ logsBox.emit('resize')
707
+ headerBar.emit('resize')
708
+ }
709
+
710
+ layoutCatalogPanel()
711
+ screen.on('resize', () => {
712
+ renderAll()
713
+ })
714
+
715
+ const helpBar = blessed.box({
716
+ parent: screen,
717
+ bottom: 0,
718
+ left: 0,
719
+ width: '100%',
720
+ height: 5,
721
+ tags: true,
722
+ border: { type: 'line' },
723
+ label: ' Status & Shortcuts ',
724
+ style: panelStyle(C.borderDim, C.muted),
725
+ })
726
+
727
+ const settingsOverlay = blessed.box({
728
+ parent: screen,
729
+ top: 'center',
730
+ left: 'center',
731
+ width: 70,
732
+ height: 13,
733
+ hidden: true,
734
+ tags: true,
735
+ border: { type: 'line' },
736
+ label: cardLabel('Settings', 'g'),
737
+ style: panelStyle(C.accent),
738
+ content: '',
739
+ })
740
+
741
+ const settingsInfo = blessed.box({
742
+ parent: settingsOverlay,
743
+ top: 1,
744
+ left: 2,
745
+ width: '100%-4',
746
+ height: 4,
747
+ tags: true,
748
+ style: flatStyle(C.muted),
749
+ })
750
+
751
+ const trustedOrgsInput = blessed.textbox({
752
+ parent: settingsOverlay,
753
+ top: 6,
754
+ left: 2,
755
+ width: '100%-4',
756
+ height: 3,
757
+ inputOnFocus: true,
758
+ keys: true,
759
+ mouse: true,
760
+ style: {
761
+ fg: 'white',
762
+ focus: { border: { fg: C.accent } },
763
+ border: { fg: C.borderDim },
764
+ label: { fg: 'white' },
765
+ },
766
+ border: { type: 'line' },
767
+ label: ' Trusted orgs (comma-separated) ',
768
+ })
769
+
770
+ const menuOverlay = blessed.box({
771
+ parent: screen,
772
+ top: 'center',
773
+ left: 'center',
774
+ width: 52,
775
+ height: MENU_ITEMS.length + 4,
776
+ hidden: true,
777
+ tags: true,
778
+ border: { type: 'line' },
779
+ label: cardLabel('Menu', 'm'),
780
+ style: panelStyle(C.accent),
781
+ })
782
+
783
+ const menuList = blessed.list({
784
+ parent: menuOverlay,
785
+ top: 1,
786
+ left: 1,
787
+ width: '100%-2',
788
+ height: MENU_ITEMS.length + 1,
789
+ tags: true,
790
+ keys: true,
791
+ vi: true,
792
+ mouse: true,
793
+ scrollbar: LIST_SCROLLBAR,
794
+ style: LIST_STYLE,
795
+ items: MENU_ITEMS.map(({ label, key }) => `${keyHint(key)} ${label}`),
796
+ })
797
+
798
+ const installWizardOverlay = blessed.box({
799
+ parent: screen,
800
+ top: 'center',
801
+ left: 'center',
802
+ width: 68,
803
+ height: 16,
804
+ hidden: true,
805
+ tags: true,
806
+ border: { type: 'line' },
807
+ label: ' Install source ',
808
+ style: panelStyle(C.accent),
809
+ })
810
+
811
+ const installWizardHeader = blessed.box({
812
+ parent: installWizardOverlay,
813
+ top: 1,
814
+ left: 2,
815
+ width: '100%-4',
816
+ height: 3,
817
+ tags: true,
818
+ style: flatStyle(C.muted),
819
+ })
820
+
821
+ const installWizardWarning = blessed.box({
822
+ parent: installWizardOverlay,
823
+ top: 4,
824
+ left: 2,
825
+ width: '100%-4',
826
+ height: 2,
827
+ hidden: true,
828
+ tags: true,
829
+ style: flatStyle(C.warn),
830
+ })
831
+
832
+ const installWizardList = blessed.list({
833
+ parent: installWizardOverlay,
834
+ top: 6,
835
+ left: 2,
836
+ width: '100%-4',
837
+ height: 6,
838
+ tags: true,
839
+ keys: true,
840
+ vi: true,
841
+ mouse: true,
842
+ scrollbar: LIST_SCROLLBAR,
843
+ style: LIST_STYLE,
844
+ items: [],
845
+ })
846
+
847
+ const installWizardFooter = blessed.box({
848
+ parent: installWizardOverlay,
849
+ bottom: 1,
850
+ left: 2,
851
+ width: '100%-4',
852
+ height: 2,
853
+ tags: true,
854
+ style: flatStyle(C.muted),
855
+ })
856
+
857
+ const sortPickerOverlay = blessed.box({
858
+ parent: screen,
859
+ top: 'center',
860
+ left: 'center',
861
+ width: 52,
862
+ height: 12,
863
+ hidden: true,
864
+ tags: true,
865
+ border: { type: 'line' },
866
+ label: cardLabel('Sort', 'O'),
867
+ style: panelStyle(C.accent),
868
+ })
869
+
870
+ const sortPickerList = blessed.list({
871
+ parent: sortPickerOverlay,
872
+ top: 1,
873
+ left: 1,
874
+ width: '100%-2',
875
+ height: 8,
876
+ tags: true,
877
+ keys: true,
878
+ vi: true,
879
+ mouse: true,
880
+ scrollbar: LIST_SCROLLBAR,
881
+ style: LIST_STYLE,
882
+ })
883
+
884
+ const installProgressOverlay = blessed.box({
885
+ parent: screen,
886
+ top: 'center',
887
+ left: 'center',
888
+ width: 60,
889
+ height: 8,
890
+ hidden: true,
891
+ tags: true,
892
+ border: { type: 'line' },
893
+ label: cardLabel(' Syncing Workspace '),
894
+ style: panelStyle(C.accent),
895
+ })
896
+
897
+ const installProgressContent = blessed.box({
898
+ parent: installProgressOverlay,
899
+ top: 1,
900
+ left: 2,
901
+ width: '100%-4',
902
+ height: 4,
903
+ tags: true,
904
+ })
905
+
906
+ const ctx = {
907
+ getConfig: () => config,
908
+ setConfig: (val) => { config = val },
909
+ getDeps: () => deps,
910
+ setDeps: (val) => { deps = val },
911
+ getPaths: () => paths,
912
+ setPaths: (val) => { paths = val },
913
+ getSettings: () => settings,
914
+ setSettings: (val) => { settings = val },
915
+ getWorkspaceRoot: () => workspaceRoot,
916
+ getCatalog: () => catalog,
917
+ getPendingTheme: () => pendingTheme,
918
+ setPendingTheme: (val) => { pendingTheme = val },
919
+ getPendingPackages: () => pendingPackages,
920
+ getInstallChoices: () => installChoices,
921
+ isInstalling: () => isInstalling,
922
+ setInstalling: (val) => {
923
+ isInstalling = val
924
+ if (val) {
925
+ hasInstallError = false
926
+ startLogWatcher()
927
+ } else if (!isDevServerUp() && !hasInstallError) {
928
+ stopLogWatcher()
929
+ }
930
+ layoutCatalogPanel()
931
+ },
932
+ isWritingConfig: () => isWritingConfig,
933
+ setWritingConfig: (val) => { isWritingConfig = val },
934
+ getIgnoreNextConfigWatch: () => ignoreNextConfigWatch,
935
+ setIgnoreNextConfigWatch: (val) => { ignoreNextConfigWatch = val },
936
+ isDevServerUp: () => isDevServerUp(),
937
+ rebootDevServer: () => rebootDevServer(),
938
+ clearLogs: () => clearLogsBox(),
939
+ getConfigError: () => configError,
940
+ setConfigError: (val) => { configError = val },
941
+ getConfigRestartHintUntil: () => configRestartHintUntil,
942
+ setConfigRestartHintUntil: (val) => { configRestartHintUntil = val },
943
+ getConfigRestartHintTimer: () => configRestartHintTimer,
944
+ setConfigRestartHintTimer: (val) => { configRestartHintTimer = val },
945
+ getSaveProgress: () => saveProgress,
946
+ setSaveProgress: (val) => { saveProgress = val },
947
+ getSpinnerFrame: () => spinnerFrame,
948
+ setSpinnerFrame: (val) => { spinnerFrame = val },
949
+ collectInstallPlan: () => collectInstallPlan(),
950
+ startInstallWizard: (packagesToInstall, isStartup = false, opts = {}) => startInstallWizard(packagesToInstall, isStartup, opts),
951
+ showCustomConfirm: (opts) => showCustomConfirm(opts),
952
+ setStatus: (msg, tone) => setStatus(msg, tone),
953
+ startSpinner: (msg) => startSpinner(msg),
954
+ stopSpinner: () => stopSpinner(),
955
+ renderAll: () => renderAll(),
956
+ renderClient: () => renderClient(),
957
+ renderHelp: () => renderHelp(),
958
+ updateCatalogKeysState: () => updateCatalogKeysState(),
959
+ installProgressOverlay,
960
+ installProgressContent,
961
+ screen,
962
+ }
963
+
964
+
965
+ // Custom update selection dialog in the style of the install wizard
966
+ function showUpdateSelectionDialog({ title, updates }) {
967
+ return new Promise((resolve) => {
968
+ confirmDialogOpen = true
969
+ updateCatalogKeysState()
970
+
971
+ const innerHeight = Math.max(12, 8 + updates.length)
972
+
973
+ const dialogBox = blessed.box({
974
+ parent: screen,
975
+ border: 'line',
976
+ height: innerHeight,
977
+ width: 68,
978
+ top: 'center',
979
+ left: 'center',
980
+ label: cardLabel(` ${title} `),
981
+ style: panelStyle(C.accent),
982
+ tags: true,
983
+ keys: true,
984
+ })
985
+
986
+ const headerBox = blessed.box({
987
+ parent: dialogBox,
988
+ top: 1,
989
+ left: 2,
990
+ width: '100%-4',
991
+ height: updates.length + 2,
992
+ tags: true,
993
+ style: flatStyle(C.muted),
994
+ })
995
+
996
+ const updatesText = updates.map(([name, info]) => {
997
+ if (info.latestVersion) {
998
+ return ` • {bold}${name}{/} (${info.localVersion} -> {green-fg}${info.latestVersion}{/}) {${C.npm}-fg}[NPM]{/}`
999
+ } else {
1000
+ const suffix = info.behindCount > 0 ? ` (${info.behindCount} commit${info.behindCount > 1 ? 's' : ''} behind)` : ''
1001
+ return ` • {bold}${name}{/}${suffix} {${C.git}-fg}[GIT]{/}`
1002
+ }
1003
+ }).join('\n')
1004
+
1005
+ headerBox.setContent(
1006
+ `{bold}Updates are available for packages:{/}\n` +
1007
+ updatesText
1008
+ )
1009
+
1010
+ const choiceList = blessed.list({
1011
+ parent: dialogBox,
1012
+ top: updates.length + 3,
1013
+ left: 2,
1014
+ width: '100%-4',
1015
+ height: 3,
1016
+ tags: true,
1017
+ keys: true,
1018
+ vi: true,
1019
+ mouse: true,
1020
+ scrollbar: LIST_SCROLLBAR,
1021
+ style: LIST_STYLE,
1022
+ items: [
1023
+ ` {${C.accent}-fg}▶{/} Run Update Wizard `,
1024
+ ` {${C.muted}-fg}▷{/} Skip updates `,
1025
+ ]
1026
+ })
1027
+
1028
+ blessed.box({
1029
+ parent: dialogBox,
1030
+ bottom: 1,
1031
+ left: 2,
1032
+ width: '100%-4',
1033
+ height: 1,
1034
+ tags: true,
1035
+ style: flatStyle(C.muted),
1036
+ content: `{${C.muted}-fg}Enter select · Esc cancel{/}`
1037
+ })
1038
+
1039
+ function close(value) {
1040
+ confirmDialogOpen = false
1041
+ updateCatalogKeysState()
1042
+ dialogBox.destroy()
1043
+ focusCatalog()
1044
+ screen.render()
1045
+ resolve(value)
1046
+ }
1047
+
1048
+ choiceList.on('select', () => {
1049
+ close(choiceList.selected === 0)
1050
+ })
1051
+
1052
+ dialogBox.key(['escape', 'q'], () => {
1053
+ close(false)
1054
+ })
1055
+
1056
+ choiceList.key(['escape', 'q'], () => {
1057
+ close(false)
1058
+ })
1059
+
1060
+ choiceList.select(0)
1061
+ dialogBox.setFront()
1062
+ choiceList.focus()
1063
+ screen.render()
1064
+ })
1065
+ }
1066
+
1067
+
1068
+ // Custom reusable navigable confirm dialog (with arrow key navigation, mouse support, and vertical stacking)
1069
+ function showCustomConfirm({ title, message, yesText, noText, cancelText, height = 11, width = 'half' }) {
1070
+ return new Promise((resolve) => {
1071
+ confirmDialogOpen = true
1072
+ updateCatalogKeysState()
1073
+
1074
+ const dialogBox = blessed.box({
1075
+ parent: screen,
1076
+ border: 'line',
1077
+ height: height,
1078
+ width: width,
1079
+ top: 'center',
1080
+ left: 'center',
1081
+ label: cardLabel(` ${title} `),
1082
+ style: panelStyle(C.accent),
1083
+ tags: true,
1084
+ keys: true,
1085
+ })
1086
+
1087
+ const hasCancel = !!cancelText
1088
+ const bottomOffset = hasCancel ? 7 : 5
1089
+
1090
+ const msgBox = blessed.box({
1091
+ parent: dialogBox,
1092
+ top: 1,
1093
+ left: 2,
1094
+ right: 2,
1095
+ bottom: bottomOffset,
1096
+ content: message,
1097
+ tags: true,
1098
+ })
1099
+
1100
+ const yesBtn = blessed.box({
1101
+ parent: dialogBox,
1102
+ bottom: hasCancel ? 5 : 3,
1103
+ left: 2,
1104
+ right: 2,
1105
+ height: 1,
1106
+ content: yesText,
1107
+ align: 'center',
1108
+ valign: 'middle',
1109
+ clickable: true,
1110
+ style: {
1111
+ bg: '#21262d',
1112
+ fg: '#2ea44f',
1113
+ bold: true,
1114
+ }
1115
+ })
1116
+
1117
+ const noBtn = blessed.box({
1118
+ parent: dialogBox,
1119
+ bottom: hasCancel ? 3 : 1,
1120
+ left: 2,
1121
+ right: 2,
1122
+ height: 1,
1123
+ content: noText,
1124
+ align: 'center',
1125
+ valign: 'middle',
1126
+ clickable: true,
1127
+ style: {
1128
+ bg: '#21262d',
1129
+ fg: '#cb2431',
1130
+ bold: true,
1131
+ }
1132
+ })
1133
+
1134
+ let cancelBtn = null
1135
+ if (hasCancel) {
1136
+ cancelBtn = blessed.box({
1137
+ parent: dialogBox,
1138
+ bottom: 1,
1139
+ left: 2,
1140
+ right: 2,
1141
+ height: 1,
1142
+ content: cancelText,
1143
+ align: 'center',
1144
+ valign: 'middle',
1145
+ clickable: true,
1146
+ style: {
1147
+ bg: '#21262d',
1148
+ fg: '#8b949e',
1149
+ bold: true,
1150
+ }
1151
+ })
1152
+ }
1153
+
1154
+ let selectedIndex = 0
1155
+ const maxIndex = hasCancel ? 2 : 1
1156
+
1157
+ function updateSelection() {
1158
+ yesBtn.style.bg = '#21262d'
1159
+ yesBtn.style.fg = '#2ea44f'
1160
+ noBtn.style.bg = '#21262d'
1161
+ noBtn.style.fg = '#cb2431'
1162
+ if (cancelBtn) {
1163
+ cancelBtn.style.bg = '#21262d'
1164
+ cancelBtn.style.fg = '#8b949e'
1165
+ }
1166
+
1167
+ if (selectedIndex === 0) {
1168
+ yesBtn.style.bg = '#2ea44f'
1169
+ yesBtn.style.fg = 'white'
1170
+ } else if (selectedIndex === 1) {
1171
+ noBtn.style.bg = '#cb2431'
1172
+ noBtn.style.fg = 'white'
1173
+ } else if (selectedIndex === 2 && cancelBtn) {
1174
+ cancelBtn.style.bg = '#8b949e'
1175
+ cancelBtn.style.fg = 'black'
1176
+ }
1177
+ screen.render()
1178
+ }
1179
+
1180
+ function cleanup() {
1181
+ screen.removeListener('keypress', keyHandler)
1182
+ dialogBox.destroy()
1183
+ confirmDialogOpen = false
1184
+ updateCatalogKeysState()
1185
+ screen.render()
1186
+ }
1187
+
1188
+ const keyHandler = (ch, key) => {
1189
+ if (!key) return
1190
+ if (key.name === 'up' || key.name === 'left') {
1191
+ selectedIndex = (selectedIndex - 1 + (maxIndex + 1)) % (maxIndex + 1)
1192
+ updateSelection()
1193
+ } else if (key.name === 'down' || key.name === 'right' || key.name === 'tab') {
1194
+ selectedIndex = (selectedIndex + 1) % (maxIndex + 1)
1195
+ updateSelection()
1196
+ } else if (key.name === 'enter') {
1197
+ cleanup()
1198
+ if (selectedIndex === 0) resolve(true)
1199
+ else if (selectedIndex === 1) resolve(false)
1200
+ else resolve(null)
1201
+ } else if (key.name === 'escape') {
1202
+ cleanup()
1203
+ resolve(null)
1204
+ }
1205
+ }
1206
+
1207
+ screen.on('keypress', keyHandler)
1208
+
1209
+ yesBtn.on('click', () => {
1210
+ cleanup()
1211
+ resolve(true)
1212
+ })
1213
+
1214
+ noBtn.on('click', () => {
1215
+ cleanup()
1216
+ resolve(false)
1217
+ })
1218
+
1219
+ if (cancelBtn) {
1220
+ cancelBtn.on('click', () => {
1221
+ cleanup()
1222
+ resolve(null)
1223
+ })
1224
+ cancelBtn.on('mouseover', () => {
1225
+ selectedIndex = 2
1226
+ updateSelection()
1227
+ })
1228
+ }
1229
+
1230
+ yesBtn.on('mouseover', () => {
1231
+ selectedIndex = 0
1232
+ updateSelection()
1233
+ })
1234
+
1235
+ noBtn.on('mouseover', () => {
1236
+ selectedIndex = 1
1237
+ updateSelection()
1238
+ })
1239
+
1240
+ dialogBox.setFront()
1241
+ dialogBox.focus()
1242
+ updateSelection()
1243
+ })
1244
+ }
1245
+
1246
+ function overlayBlocksKeys() {
1247
+ return settingsOpen || menuOpen || installWizardOpen || sortPickerOpen || confirmDialogOpen || isInstalling || isStartingServer
1248
+ }
1249
+
1250
+ function updateCatalogKeysState() {
1251
+ const blocked = overlayBlocksKeys()
1252
+ catalogList.keys = !blocked
1253
+ }
1254
+
1255
+ function pendingQueueSummary() {
1256
+ const installs = []
1257
+ const removals = []
1258
+ for (const [name, on] of pendingPackages) {
1259
+ if (on) installs.push(shortName(name))
1260
+ else removals.push(shortName(name))
1261
+ }
1262
+ const themeChange =
1263
+ pendingTheme && pendingTheme !== config.theme ? shortName(pendingTheme) : null
1264
+ const parts = []
1265
+ if (installs.length) parts.push(`+${installs.join(', +')}`)
1266
+ if (themeChange) parts.push(`+theme:${themeChange}`)
1267
+ if (removals.length) parts.push(`−${removals.join(', −')}`)
1268
+ return parts.length ? parts.join(' ') : null
1269
+ }
1270
+
1271
+ function catalogFilterOptions() {
1272
+ return {
1273
+ sortMode: settings.catalogSort,
1274
+ config: effectiveInstalledSets(config, pendingPackages, pendingTheme),
1275
+ }
1276
+ }
1277
+
1278
+
1279
+
1280
+ function renderLegendBar() {
1281
+ renderHelp()
1282
+ }
1283
+
1284
+ function catalogEntries() {
1285
+ return mergeInstalled(
1286
+ filterCatalog(catalog, activeTab, catalogFilterOptions()),
1287
+ config,
1288
+ deps,
1289
+ workspaceRoot,
1290
+ )
1291
+ }
1292
+
1293
+ function applyFocusStyles() {
1294
+ const tab = KINDS[activeTab].label
1295
+ catalogBox.setLabel(` ${cardTitle('Catalog')} · ${tab} ◀ `)
1296
+ }
1297
+
1298
+ function sortModeLabel() {
1299
+ return CATALOG_SORT_LABELS[normalizeCatalogSort(settings.catalogSort)]
1300
+ }
1301
+
1302
+ function applyCatalogSort(mode) {
1303
+ settings = { ...settings, catalogSort: mode }
1304
+ saveSettings(workspaceRoot, settings)
1305
+ renderCatalogList()
1306
+ renderLegendBar()
1307
+ renderTabs()
1308
+ renderDetail()
1309
+ setStatus(`Sort → ${sortModeLabel()}`, 'ok')
1310
+ screen.render()
1311
+ }
1312
+
1313
+ function cycleCatalogSort() {
1314
+ const idx = CATALOG_SORT_MODES.indexOf(normalizeCatalogSort(settings.catalogSort))
1315
+ const next = CATALOG_SORT_MODES[(idx + 1) % CATALOG_SORT_MODES.length]
1316
+ applyCatalogSort(next)
1317
+ }
1318
+
1319
+ function formatSortPickerItems() {
1320
+ const current = normalizeCatalogSort(settings.catalogSort)
1321
+ return CATALOG_SORT_MODES.map((mode) => {
1322
+ const bullet = mode === current ? `{${C.accent}-fg}>{/}` : ' '
1323
+ const label = CATALOG_SORT_LABELS[mode]
1324
+ const desc = CATALOG_SORT_DESCRIPTIONS[mode]
1325
+ return `${bullet} {bold}${label.padEnd(10)}{/} {${C.muted}-fg}${desc}{/}`
1326
+ })
1327
+ }
1328
+
1329
+ function openSortPicker() {
1330
+ if (settingsOpen || menuOpen || installWizardOpen || sortPickerOpen) return
1331
+ sortPickerOpen = true
1332
+ updateCatalogKeysState()
1333
+ sortPickerOverlay.show()
1334
+ sortPickerList.setItems(formatSortPickerItems())
1335
+ const idx = CATALOG_SORT_MODES.indexOf(normalizeCatalogSort(settings.catalogSort))
1336
+ sortPickerList.select(idx >= 0 ? idx : 0)
1337
+ sortPickerList.focus()
1338
+ setStatus(`Sort — Enter apply · ${keyHint('o')} quick cycle · Esc close`)
1339
+ screen.render()
1340
+ }
1341
+
1342
+ function closeSortPicker() {
1343
+ sortPickerOpen = false
1344
+ updateCatalogKeysState()
1345
+ sortPickerOverlay.hide()
1346
+ focusCatalog()
1347
+ screen.render()
1348
+ }
1349
+
1350
+ function selectSortAt(index) {
1351
+ const mode = CATALOG_SORT_MODES[index]
1352
+ if (!mode) return
1353
+ closeSortPicker()
1354
+ applyCatalogSort(mode)
1355
+ }
1356
+
1357
+ function isDevServerUp() {
1358
+ return (
1359
+ devPhase === 'running' ||
1360
+ devPhase === 'starting' ||
1361
+ clientStatus.running ||
1362
+ clientStatus.http.up
1363
+ )
1364
+ }
1365
+
1366
+ function serverKeyHints() {
1367
+ return `${keyHint('d')} start/stop ${keyHint('m')} menu`
1368
+ }
1369
+
1370
+ function defaultStatusHint() {
1371
+ return 'Ready'
1372
+ }
1373
+
1374
+ function renderHelp(tone = null) {
1375
+ if (tone !== null) {
1376
+ lastTone = tone
1377
+ }
1378
+ const statusColor = lastTone === 'error' ? C.err : lastTone === 'ok' ? C.accent : C.warn
1379
+ const msg = statusLine || defaultStatusHint()
1380
+
1381
+ const msgLine = ` {bold}{${statusColor}-fg}${msg}{/}{/}`
1382
+
1383
+ const queue = pendingQueueSummary()
1384
+ const saveShortcut = queue
1385
+ ? `{${C.accent}-fg}${keyHint('s')} save (${queue}){/}`
1386
+ : `${keyHint('s')} save`
1387
+
1388
+ helpBar.setContent(
1389
+ [
1390
+ msgLine,
1391
+ ` Legend: {${C.add}-fg}[+]{/} add {${C.remove}-fg}[-]{/} remove {${C.accent}-fg}[*]{/} on desktop | {${C.npm}-fg}NPM{/} registry {${C.git}-fg}GIT{/} repo {${C.local}-fg}LOC{/} workspace | {${C.warn}-fg}WRN{/} untrusted`,
1392
+ ` Shortcuts: ↑↓ Space toggle ${keyHint('1')}${keyHint('2')}${keyHint('3')} tabs ${saveShortcut} ${keyHint('d')} server ${keyHint('O')} sort ${keyHint('m')} menu ${keyHint('u')} updates ${keyHint('r')} refresh ${keyHint('q')} quit`,
1393
+ ].join('\n'),
1394
+ )
1395
+ }
1396
+
1397
+ function setStatus(msg, tone = 'info') {
1398
+ statusLine = `${statusPrefix(tone)}${msg}`
1399
+ renderHelp(tone)
1400
+ screen.render()
1401
+ }
1402
+
1403
+ function handleExternalConfigChange() {
1404
+ if (isWritingConfig) return
1405
+ if (configWatchTimeout) {
1406
+ clearTimeout(configWatchTimeout)
1407
+ }
1408
+ configWatchTimeout = setTimeout(async () => {
1409
+ try {
1410
+ const newConfig = readDesktopConfig(paths.config, workspaceRoot)
1411
+ configError = null
1412
+ if (!areConfigsEqual(config, newConfig)) {
1413
+ config = newConfig
1414
+ deps = readDesktopDependencies(paths.packageJson)
1415
+
1416
+ for (const [name, on] of pendingPackages.entries()) {
1417
+ const isApp = name.startsWith('app-') || inferKind(shortName(name)) === 'app'
1418
+ const list = isApp ? (config.apps ?? []) : (config.modules ?? [])
1419
+ if (on === list.includes(name)) {
1420
+ pendingPackages.delete(name)
1421
+ }
1422
+ }
1423
+ if (pendingTheme === config.theme || !pendingTheme) {
1424
+ pendingTheme = config.theme
1425
+ }
1426
+
1427
+ renderAll()
1428
+ setStatus('Configuration reloaded from disk', 'ok')
1429
+ await syncDependenciesWithConfig()
1430
+ } else {
1431
+ renderAll()
1432
+ }
1433
+ } catch (err) {
1434
+ configError = err.message
1435
+ renderAll()
1436
+ setStatus(`Error reading config: ${err.message}`, 'error')
1437
+ }
1438
+ }, 200)
1439
+ }
1440
+
1441
+ function startConfigWatcher() {
1442
+ if (configWatcher) {
1443
+ configWatcher.close()
1444
+ }
1445
+ try {
1446
+ configWatcher = watch(paths.config, (eventType) => {
1447
+ if (eventType === 'rename') {
1448
+ // Re-bind watcher to the new inode (e.g. VS Code atomic save)
1449
+ setTimeout(startConfigWatcher, 100)
1450
+ }
1451
+ if (eventType === 'change' || eventType === 'rename') {
1452
+ if (ignoreNextConfigWatch) {
1453
+ ignoreNextConfigWatch = false
1454
+ return
1455
+ }
1456
+ handleExternalConfigChange()
1457
+ }
1458
+ })
1459
+ configWatcher.on('error', () => {
1460
+ setTimeout(startConfigWatcher, 500)
1461
+ })
1462
+ } catch (err) {
1463
+ setTimeout(startConfigWatcher, 1000)
1464
+ }
1465
+ }
1466
+
1467
+ function readLogs() {
1468
+ const logFile = devLogPath(workspaceRoot)
1469
+ if (!existsSync(logFile)) {
1470
+ logsBox.setContent('')
1471
+ screen.render()
1472
+ return
1473
+ }
1474
+ try {
1475
+ const stats = statSync(logFile)
1476
+ const streamSize = Math.min(stats.size, 10000)
1477
+ if (streamSize === 0) {
1478
+ logsBox.setContent('')
1479
+ screen.render()
1480
+ return
1481
+ }
1482
+ const fd = openSync(logFile, 'r')
1483
+ const buffer = Buffer.alloc(streamSize)
1484
+ readSync(fd, buffer, 0, streamSize, stats.size - streamSize)
1485
+ closeSync(fd)
1486
+
1487
+ const rawText = buffer.toString('utf8')
1488
+ const escapedText = rawText.replace(/{/g, '\\{').replace(/}/g, '\\}')
1489
+
1490
+ const lines = escapedText.split('\n')
1491
+ const lastLines = lines.slice(-100).join('\n')
1492
+
1493
+ logsBox.setContent(lastLines)
1494
+ logsBox.setScrollPerc(100)
1495
+ screen.render()
1496
+ } catch (err) {
1497
+ logsBox.setContent(`{red-fg}Error reading logs: ${err.message}{/}`)
1498
+ screen.render()
1499
+ }
1500
+ }
1501
+
1502
+ function clearLogsBox() {
1503
+ const logFile = devLogPath(workspaceRoot)
1504
+ try {
1505
+ writeFileSync(logFile, '')
1506
+ } catch {
1507
+ /* ignore */
1508
+ }
1509
+ logsBox.setContent('{gray-fg}Waiting for logs...{/}')
1510
+ logsBox.setScrollPerc(0)
1511
+ screen.render()
1512
+ }
1513
+
1514
+ function startLogWatcher() {
1515
+ if (logWatcher) logWatcher.close()
1516
+ const logFile = devLogPath(workspaceRoot)
1517
+ readLogs()
1518
+ try {
1519
+ logWatcher = watch(logFile, (eventType) => {
1520
+ if (eventType === 'change') {
1521
+ readLogs()
1522
+ }
1523
+ })
1524
+ logWatcher.on('error', () => {
1525
+ setTimeout(startLogWatcher, 1000)
1526
+ })
1527
+ } catch {
1528
+ setTimeout(startLogWatcher, 1000)
1529
+ }
1530
+ }
1531
+
1532
+ function stopLogWatcher() {
1533
+ if (logWatcher) {
1534
+ logWatcher.close()
1535
+ logWatcher = null
1536
+ }
1537
+ }
1538
+
1539
+ /** Clear stray CLI output and redraw the full TUI (after save, build, etc.). */
1540
+ function refreshTerminalUi() {
1541
+ try {
1542
+ screen.program.clear()
1543
+ screen.program.home()
1544
+ } catch {
1545
+ process.stdout.write('\x1b[2J\x1b[H')
1546
+ }
1547
+ layoutCatalogPanel()
1548
+ catalogList.focus()
1549
+ renderAll()
1550
+ }
1551
+
1552
+ function startSpinner(message, showProgressOverlay = false) {
1553
+ stopSpinner()
1554
+ activeTaskMessage = message
1555
+ spinnerFrame = 0
1556
+ setStatus(message)
1557
+ spinnerTimer = setInterval(() => {
1558
+ spinnerFrame = (spinnerFrame + 1) % spinnerFrameCount
1559
+ statusLine = `${radarSpinner(spinnerFrame)} ${message}`
1560
+ renderHelp('info')
1561
+
1562
+ if (showProgressOverlay && saveProgress) {
1563
+ const bar = `[${progressTrack(saveProgress.step, saveProgress.total, 0, 36)}]`
1564
+ const spin = radarSpinner(spinnerFrame)
1565
+ installProgressContent.setContent(
1566
+ [
1567
+ ` {bold}${spin} ${saveProgress.label}{/}`,
1568
+ '',
1569
+ ` {cyan-fg}${bar}{/}`,
1570
+ ` Step ${saveProgress.step} of ${saveProgress.total}`,
1571
+ ].join('\n')
1572
+ )
1573
+ }
1574
+
1575
+ renderAll()
1576
+ }, 150)
1577
+ }
1578
+
1579
+ function stopSpinner() {
1580
+ activeTaskMessage = null
1581
+ if (spinnerTimer) {
1582
+ clearInterval(spinnerTimer)
1583
+ spinnerTimer = null
1584
+ }
1585
+ renderAll()
1586
+ }
1587
+
1588
+ function renderSettingsPanel() {
1589
+ const ssh = sshAuthStatus?.available
1590
+ ? `{${C.accent}-fg}SSH authenticated{/} as ${sshAuthStatus.githubLogin ?? '?'}`
1591
+ : `{${C.warn}-fg}SSH not available{/} — use HTTPS git clones`
1592
+ settingsInfo.setContent(
1593
+ [
1594
+ ssh,
1595
+ sshAuthStatus?.githubLogin
1596
+ ? `{${C.muted}-fg}GitHub username '${sshAuthStatus.githubLogin}' detected via SSH and used for fork clones.{/}`
1597
+ : `{${C.muted}-fg}Configure SSH or environment variable OWD_GITHUB_USER to enable fork clones.{/}`,
1598
+ `{${C.muted}-fg}Enter save · t test SSH · Esc cancel{/}`,
1599
+ ].join('\n'),
1600
+ )
1601
+ }
1602
+
1603
+ async function testSshFromSettings() {
1604
+ setStatus('Testing SSH to GitHub…')
1605
+ screen.render()
1606
+ sshAuthStatus = await detectGithubSshAuth(workspaceRoot, { force: true })
1607
+ if (sshAuthStatus.available && sshAuthStatus.githubLogin) {
1608
+ settings.githubUser = sshAuthStatus.githubLogin
1609
+ }
1610
+ renderSettingsPanel()
1611
+ setStatus(sshAuthStatus.message ?? 'SSH test complete', sshAuthStatus.available ? 'ok' : 'error')
1612
+ screen.render()
1613
+ }
1614
+
1615
+ function openSettings() {
1616
+ settingsOpen = true
1617
+ updateCatalogKeysState()
1618
+ settingsOverlay.show()
1619
+ renderSettingsPanel()
1620
+ trustedOrgsInput.setValue((settings.githubOrgs ?? ['owdproject']).join(', '))
1621
+ trustedOrgsInput.focus()
1622
+ setStatus('Settings — Enter save · t test SSH · Esc cancel')
1623
+ screen.render()
1624
+ }
1625
+
1626
+ function closeSettings() {
1627
+ settingsOpen = false
1628
+ updateCatalogKeysState()
1629
+ settingsOverlay.hide()
1630
+ focusCatalog()
1631
+ screen.render()
1632
+ }
1633
+
1634
+ function openMenu() {
1635
+ if (settingsOpen || installWizardOpen || sortPickerOpen) return
1636
+
1637
+ menuOpen = true
1638
+ updateCatalogKeysState()
1639
+ menuOverlay.show()
1640
+ menuList.focus()
1641
+ setStatus('Menu — ↑↓ select · Enter run · Esc close')
1642
+ screen.render()
1643
+ }
1644
+
1645
+ function closeMenu() {
1646
+ menuOpen = false
1647
+ updateCatalogKeysState()
1648
+ menuOverlay.hide()
1649
+ focusCatalog()
1650
+ screen.render()
1651
+ }
1652
+
1653
+ function getLocalGitChanges(dir) {
1654
+ try {
1655
+ const porcelain = execSync('git status --porcelain', {
1656
+ cwd: dir,
1657
+ encoding: 'utf8',
1658
+ stdio: ['ignore', 'pipe', 'ignore'],
1659
+ }).trim()
1660
+ if (!porcelain) return null
1661
+
1662
+ let added = 0, modified = 0, deleted = 0
1663
+ for (const line of porcelain.split('\n').filter(Boolean)) {
1664
+ if (line.startsWith('??')) { added++; continue }
1665
+ const xy = line.slice(0, 2)
1666
+ if (xy.includes('A')) added++
1667
+ else if (xy.includes('D')) deleted++
1668
+ else modified++
1669
+ }
1670
+ return { added, modified, deleted }
1671
+ } catch {
1672
+ return null
1673
+ }
1674
+ }
1675
+
1676
+ function updateLocalChanges() {
1677
+ const checkDirs = ['apps', 'themes', 'packages']
1678
+ for (const dirName of checkDirs) {
1679
+ const fullDir = join(workspaceRoot, dirName)
1680
+ if (!existsSync(fullDir)) continue
1681
+ try {
1682
+ const subdirs = readdirSync(fullDir, { withFileTypes: true })
1683
+ for (const subdir of subdirs) {
1684
+ if (!subdir.isDirectory()) continue
1685
+ const name = subdir.name
1686
+ if (name.startsWith('.')) continue
1687
+ const subGit = join(fullDir, name, '.git')
1688
+ if (existsSync(subGit)) {
1689
+ const changes = getLocalGitChanges(join(fullDir, name))
1690
+ if (changes) {
1691
+ localGitChanges.set(name, changes)
1692
+ } else {
1693
+ localGitChanges.delete(name)
1694
+ }
1695
+ }
1696
+ }
1697
+ } catch {}
1698
+ }
1699
+ }
1700
+
1701
+ function getLocalVersion(entry) {
1702
+ const short = shortName(entry.name)
1703
+ const kind = entry.kind
1704
+ if (entry.localSource) {
1705
+ const pkgJsonPath = join(workspaceRoot, KINDS[kind].workspaceDir, short, 'package.json')
1706
+ if (existsSync(pkgJsonPath)) {
1707
+ try {
1708
+ return JSON.parse(readFileSync(pkgJsonPath, 'utf8')).version
1709
+ } catch {}
1710
+ }
1711
+ } else {
1712
+ const nodeModulesPath = join(workspaceRoot, 'desktop', 'node_modules', entry.name, 'package.json')
1713
+ if (existsSync(nodeModulesPath)) {
1714
+ try {
1715
+ return JSON.parse(readFileSync(nodeModulesPath, 'utf8')).version
1716
+ } catch {}
1717
+ }
1718
+ try {
1719
+ const desktopPkg = JSON.parse(readFileSync(join(workspaceRoot, 'desktop', 'package.json'), 'utf8'))
1720
+ const versionSpec = desktopPkg.dependencies?.[entry.name] || desktopPkg.devDependencies?.[entry.name]
1721
+ if (versionSpec) {
1722
+ return versionSpec.replace(/[\^~]/g, '')
1723
+ }
1724
+ } catch {}
1725
+ }
1726
+ return null
1727
+ }
1728
+
1729
+ function semverCompare(v1, v2) {
1730
+ const parse = (v) => String(v).replace(/^[^\d]+/, '').split('.').map(Number)
1731
+ const a = parse(v1)
1732
+ const b = parse(v2)
1733
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
1734
+ const na = a[i] || 0
1735
+ const nb = b[i] || 0
1736
+ if (na < nb) return -1
1737
+ if (na > nb) return 1
1738
+ }
1739
+ return 0
1740
+ }
1741
+
1742
+ async function checkRepoUpdate(dir) {
1743
+ try {
1744
+ await spawnAsync('git', ['fetch'], {
1745
+ cwd: dir,
1746
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', SSH_ASKPASS: '', DISPLAY: '' }
1747
+ })
1748
+
1749
+ const behind = execSync('git rev-list --count HEAD..@{u}', {
1750
+ cwd: dir,
1751
+ encoding: 'utf8',
1752
+ stdio: ['ignore', 'pipe', 'ignore'],
1753
+ }).trim()
1754
+
1755
+ const count = parseInt(behind, 10)
1756
+ return {
1757
+ hasUpdate: !isNaN(count) && count > 0,
1758
+ behindCount: isNaN(count) ? 0 : count,
1759
+ }
1760
+ } catch {
1761
+ try {
1762
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', {
1763
+ cwd: dir,
1764
+ encoding: 'utf8',
1765
+ stdio: ['ignore', 'pipe', 'ignore'],
1766
+ }).trim()
1767
+ if (!branch || branch === 'HEAD') return { hasUpdate: false, behindCount: 0 }
1768
+
1769
+ const behind = execSync(`git rev-list --count HEAD..origin/${branch}`, {
1770
+ cwd: dir,
1771
+ encoding: 'utf8',
1772
+ stdio: ['ignore', 'pipe', 'ignore'],
1773
+ }).trim()
1774
+
1775
+ const count = parseInt(behind, 10)
1776
+ return {
1777
+ hasUpdate: !isNaN(count) && count > 0,
1778
+ behindCount: isNaN(count) ? 0 : count,
1779
+ }
1780
+ } catch {
1781
+ return { hasUpdate: false, behindCount: 0 }
1782
+ }
1783
+ }
1784
+ }
1785
+
1786
+ async function runUpdateWizard(updates) {
1787
+ if (isInstalling) {
1788
+ setStatus('Install/update process already running.', 'error')
1789
+ return
1790
+ }
1791
+ ctx.setInstalling(true)
1792
+
1793
+ // Determine steps to execute and compute totalSteps
1794
+ const stepsToExecute = []
1795
+ let needInstall = false
1796
+
1797
+ for (const [name, updateInfo] of updates) {
1798
+ if (name === 'client') {
1799
+ stepsToExecute.push({
1800
+ label: 'Updating client (Git)…',
1801
+ action: async () => {
1802
+ await spawnAsync('git', ['pull'], { cwd: workspaceRoot })
1803
+ }
1804
+ })
1805
+ needInstall = true
1806
+ continue
1807
+ }
1808
+
1809
+ const entry = catalog.find(e => e.shortName === name || e.name === name)
1810
+ const isGitUpdate = updateInfo.localGit || (entry && entry.localSource)
1811
+
1812
+ if (isGitUpdate) {
1813
+ let localDir = null
1814
+ if (entry && entry.kind) {
1815
+ localDir = join(workspaceRoot, KINDS[entry.kind].workspaceDir, name)
1816
+ } else {
1817
+ if (existsSync(join(workspaceRoot, 'apps', name))) {
1818
+ localDir = join(workspaceRoot, 'apps', name)
1819
+ } else if (existsSync(join(workspaceRoot, 'themes', name))) {
1820
+ localDir = join(workspaceRoot, 'themes', name)
1821
+ } else if (existsSync(join(workspaceRoot, 'packages', name))) {
1822
+ localDir = join(workspaceRoot, 'packages', name)
1823
+ }
1824
+ }
1825
+
1826
+ if (localDir) {
1827
+ stepsToExecute.push({
1828
+ label: `Updating ${name} (Git)…`,
1829
+ action: async () => {
1830
+ await spawnAsync('git', ['pull'], { cwd: localDir })
1831
+ }
1832
+ })
1833
+ needInstall = true
1834
+ }
1835
+ } else if (entry) {
1836
+ const latestVer = updateInfo.latestVersion
1837
+ stepsToExecute.push({
1838
+ label: `Updating ${shortName(entry.name)} to ${latestVer} (NPM)…`,
1839
+ action: async () => {
1840
+ await spawnAsync('pnpm', ['--filter', 'desktop', 'add', `${entry.name}@${latestVer}`], { cwd: workspaceRoot })
1841
+ }
1842
+ })
1843
+ }
1844
+ }
1845
+
1846
+ if (needInstall) {
1847
+ stepsToExecute.push({
1848
+ label: 'Updating dependencies (pnpm)…',
1849
+ action: async () => {
1850
+ await spawnAsync('pnpm', ['install'], { cwd: workspaceRoot })
1851
+ }
1852
+ })
1853
+ }
1854
+
1855
+ const totalSteps = stepsToExecute.length
1856
+ let currentStepIndex = 0
1857
+
1858
+ // Configure and show progress overlay
1859
+ installProgressOverlay.setLabel(cardLabel(' Syncing Workspace '))
1860
+ installProgressOverlay.show()
1861
+ installProgressOverlay.setFront()
1862
+ installProgressOverlay.focus()
1863
+
1864
+ const updateProgress = (label) => {
1865
+ saveProgress = {
1866
+ step: Math.min(currentStepIndex + 1, totalSteps),
1867
+ total: totalSteps,
1868
+ label
1869
+ }
1870
+ setStatus(label)
1871
+ }
1872
+
1873
+ startSpinner('Running Update Wizard…', true)
1874
+ renderAll()
1875
+
1876
+ try {
1877
+ for (const step of stepsToExecute) {
1878
+ updateProgress(step.label)
1879
+ await step.action()
1880
+ currentStepIndex++
1881
+ }
1882
+
1883
+ setStatus('All updates applied successfully!', 'ok')
1884
+ packageUpdates.clear()
1885
+ updateLocalChanges()
1886
+ await checkForUpdates({ silent: true })
1887
+ } catch (err) {
1888
+ hasInstallError = true
1889
+ setStatus(`Update Wizard failed: ${err.message}`, 'error')
1890
+ } finally {
1891
+ saveProgress = null
1892
+ installProgressOverlay.hide()
1893
+ ctx.setInstalling(false)
1894
+ stopSpinner()
1895
+ focusCatalog()
1896
+ renderAll()
1897
+ }
1898
+ }
1899
+
1900
+ async function checkForUpdates({ silent = false } = {}) {
1901
+ if (checkingUpdates) return
1902
+ checkingUpdates = true
1903
+ if (!silent) {
1904
+ startSpinner('Checking for GitHub & NPM updates…')
1905
+ renderAll()
1906
+ }
1907
+
1908
+ try {
1909
+ const repos = []
1910
+ // 1. Client itself
1911
+ repos.push({ name: 'client', dir: workspaceRoot })
1912
+
1913
+ // 2. Sub-repositories
1914
+ const checkDirs = ['apps', 'themes', 'packages']
1915
+ for (const dirName of checkDirs) {
1916
+ const fullDir = join(workspaceRoot, dirName)
1917
+ if (!existsSync(fullDir)) continue
1918
+ try {
1919
+ const subdirs = readdirSync(fullDir, { withFileTypes: true })
1920
+ for (const subdir of subdirs) {
1921
+ if (!subdir.isDirectory()) continue
1922
+ const name = subdir.name
1923
+ if (name.startsWith('.')) continue
1924
+ const subGit = join(fullDir, name, '.git')
1925
+ if (existsSync(subGit)) {
1926
+ repos.push({ name, dir: join(fullDir, name) })
1927
+ }
1928
+ }
1929
+ } catch {}
1930
+ }
1931
+
1932
+ // 3. NPM packages from catalog
1933
+ const npmPackages = catalog.filter(e => e.installed && !e.localSource)
1934
+
1935
+ // Run parallel checks
1936
+ await Promise.all([
1937
+ ...repos.map(async (repo) => {
1938
+ const res = await checkRepoUpdate(repo.dir)
1939
+ if (res.hasUpdate) {
1940
+ packageUpdates.set(repo.name, {
1941
+ ...res,
1942
+ localGit: true
1943
+ })
1944
+ } else {
1945
+ packageUpdates.delete(repo.name)
1946
+ }
1947
+ }),
1948
+ ...npmPackages.map(async (item) => {
1949
+ const localVer = getLocalVersion(item)
1950
+ if (!localVer) return
1951
+ try {
1952
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(item.name)}/latest`)
1953
+ if (res.ok) {
1954
+ const json = await res.json()
1955
+ const latestVer = json.version
1956
+ if (latestVer && semverCompare(localVer, latestVer) < 0) {
1957
+ packageUpdates.set(item.shortName, {
1958
+ hasUpdate: true,
1959
+ behindCount: 0,
1960
+ localVersion: localVer,
1961
+ latestVersion: latestVer
1962
+ })
1963
+ return
1964
+ }
1965
+ }
1966
+ } catch {}
1967
+ packageUpdates.delete(item.shortName)
1968
+ })
1969
+ ])
1970
+
1971
+ const updateCount = packageUpdates.size
1972
+ if (!silent) stopSpinner()
1973
+
1974
+ if (updateCount > 0) {
1975
+ showUpdateSelectionDialog({
1976
+ title: 'Updates Available',
1977
+ updates: [...packageUpdates.entries()]
1978
+ }).then((runWizard) => {
1979
+ if (runWizard) {
1980
+ runUpdateWizard([...packageUpdates.entries()])
1981
+ }
1982
+ })
1983
+ } else {
1984
+ if (!silent) {
1985
+ setStatus('All repositories and packages are up-to-date.', 'ok')
1986
+ }
1987
+ }
1988
+ } catch (err) {
1989
+ if (!silent) {
1990
+ stopSpinner()
1991
+ setStatus(`Failed to check updates: ${err.message}`, 'error')
1992
+ }
1993
+ } finally {
1994
+ checkingUpdates = false
1995
+ renderAll()
1996
+ }
1997
+ }
1998
+
1999
+ async function runMenuAction(id) {
2000
+ closeMenu()
2001
+ switch (id) {
2002
+ case 'start':
2003
+ await startDevServer()
2004
+ break
2005
+ case 'stop':
2006
+ await stopDevServer()
2007
+ break
2008
+ case 'reboot':
2009
+ await rebootDevServer()
2010
+ break
2011
+ case 'save':
2012
+ await applyChanges()
2013
+ break
2014
+ case 'refresh':
2015
+ await refreshCatalog()
2016
+ break
2017
+ case 'check-updates':
2018
+ await checkForUpdates()
2019
+ break
2020
+ case 'docs':
2021
+ openDocsInBrowser()
2022
+ break
2023
+ case 'settings':
2024
+ openSettings()
2025
+ break
2026
+ case 'build':
2027
+ stopSpinner()
2028
+ setStatus('Running pnpm run generate…')
2029
+ screen.render()
2030
+ try {
2031
+ execSync('pnpm run generate', { cwd: workspaceRoot, stdio: 'pipe' })
2032
+ setStatus('Generate finished', 'ok')
2033
+ } catch (err) {
2034
+ setStatus(`Generate failed: ${err.message}`, 'error')
2035
+ } finally {
2036
+ refreshTerminalUi()
2037
+ }
2038
+ break
2039
+ case 'quit':
2040
+ process.exit(0)
2041
+ break
2042
+ default:
2043
+ break
2044
+ }
2045
+ }
2046
+
2047
+ function saveSettingsFromOverlay() {
2048
+ const orgsRaw = trustedOrgsInput.getValue().trim()
2049
+ const githubOrgs = orgsRaw
2050
+ ? orgsRaw.split(',').map((s) => s.trim()).filter(Boolean)
2051
+ : ['owdproject']
2052
+ const user = settings.githubUser || (sshAuthStatus?.available ? sshAuthStatus.githubLogin : null)
2053
+ settings = { ...settings, githubUser: user, githubOrgs }
2054
+ saveSettings(workspaceRoot, settings)
2055
+ closeSettings()
2056
+ renderClient()
2057
+ renderCatalogList()
2058
+ renderDetail()
2059
+ setStatus(user ? `Settings saved — GitHub user ${user}` : 'Settings saved', 'ok')
2060
+ }
2061
+
2062
+ function renderTabs() {
2063
+ const tab = (id, name, numKey) => {
2064
+ const active = activeTab === id
2065
+ if (active) {
2066
+ return `{bold}${cardTitle(name, numKey)}{/}`
2067
+ }
2068
+ return `{${C.muted}-fg}${name}{/}${keyHint(numKey)}`
2069
+ }
2070
+ tabBar.setContent(
2071
+ ` ${tab('app', 'Apps', '1')} ${tab('module', 'Modules', '2')} ${tab('theme', 'Themes', '3')} | {${C.muted}-fg}Sort:{/} {bold}${sortModeLabel()}{/} ${keyHint('o')}${keyHint('O')}`,
2072
+ )
2073
+ }
2074
+
2075
+
2076
+
2077
+ const npmDownloads = {
2078
+ '@owdproject/core': 410,
2079
+ '@owdproject/app-about': 8,
2080
+ '@owdproject/app-todo': 12,
2081
+ '@owdproject/app-terminal': 24,
2082
+ '@owdproject/module-fs': 52,
2083
+ '@owdproject/module-persistence': 38,
2084
+ '@owdproject/theme-gnome': 14,
2085
+ '@owdproject/theme-win95': 36,
2086
+ }
2087
+
2088
+ async function loadNpmDownloads() {
2089
+ try {
2090
+ const pkgs = ['@owdproject/core']
2091
+ for (const item of catalog) {
2092
+ const name = item.name
2093
+ pkgs.push(name)
2094
+ }
2095
+
2096
+ await Promise.allSettled(
2097
+ pkgs.map(async (name) => {
2098
+ try {
2099
+ const res = await fetch(`https://api.npmjs.org/downloads/point/last-week/${name}`)
2100
+ const data = await res.json()
2101
+ if (data && typeof data.downloads === 'number') {
2102
+ npmDownloads[name] = data.downloads
2103
+ }
2104
+ } catch {
2105
+ // Ignore single fetch failure
2106
+ }
2107
+ })
2108
+ )
2109
+
2110
+ renderClient()
2111
+ screen.render()
2112
+ } catch (err) {
2113
+ // Ignore
2114
+ }
2115
+ }
2116
+
2117
+ function getShowcaseItems() {
2118
+ const items = [
2119
+ `{bold}Discord Server{/} · Join the community: https://discord.gg/owdproject`,
2120
+ `{bold}Tip{/} · Press {${C.accent}-fg}Space{/} on any app or module to toggle installation/removal`,
2121
+ `{bold}Tip{/} · Press {${C.accent}-fg}1{/}, {${C.accent}-fg}2{/}, or {${C.accent}-fg}3{/} keys to switch catalog tabs`,
2122
+ `{bold}Tip{/} · Press {${C.accent}-fg}w{/} to review changes and launch the package install wizard`,
2123
+ `{bold}Tip{/} · Press {${C.accent}-fg}g{/} to configure your GitHub user for forks/clones`,
2124
+ `{bold}Tip{/} · Press {${C.accent}-fg}o{/} to cycle catalog sorting, or {${C.accent}-fg}O{/} for sort menu`,
2125
+ `{bold}Tip{/} · Press {${C.accent}-fg}m{/} to open the command action menu`,
2126
+ `{bold}Tip{/} · Click anywhere on the bottom status bar to force-refresh catalog`,
2127
+ `{bold}Tip{/} · Press {${C.accent}-fg}s{/} to start dev server, {${C.accent}-fg}x{/} to stop, and {${C.accent}-fg}R{/} to reboot`,
2128
+ `{bold}Tip{/} · Press {${C.accent}-fg}l{/} to focus Logs; press {${C.accent}-fg}Esc{/} to return to Catalog`,
2129
+ ]
2130
+
2131
+ const apps = catalog.filter((e) => e.kind === 'app')
2132
+ const mods = catalog.filter((e) => e.kind === 'module')
2133
+ const themes = catalog.filter((e) => e.kind === 'theme')
2134
+
2135
+ const totalStars = catalog.reduce((acc, item) => acc + (item.stars ?? 0), 0)
2136
+ if (totalStars > 0) {
2137
+ items.push(`{bold}GitHub Stats{/} · ${totalStars} total stars across all catalog modules`)
2138
+ }
2139
+
2140
+ if (catalog.length > 0) {
2141
+ const sortedByStars = [...catalog].sort((a, b) => (b.stars ?? 0) - (a.stars ?? 0))
2142
+ const topStar = sortedByStars[0]
2143
+ if (topStar && topStar.stars > 0) {
2144
+ items.push(`{bold}Trending{/} · {bold}{${C.accent}-fg}${topStar.shortName}{/}{/} is most popular with ★${topStar.stars} stars`)
2145
+ }
2146
+ }
2147
+
2148
+ const coreDownloads = npmDownloads['@owdproject/core']
2149
+ if (coreDownloads !== undefined) {
2150
+ items.push(`{bold}NPM Downloads{/} · core package {bold}@owdproject/core{/} got ${coreDownloads} downloads last week`)
2151
+ }
2152
+
2153
+ let totalCatalogDownloads = 0
2154
+ let topDownloadItem = null
2155
+ let topDownloadCount = 0
2156
+
2157
+ for (const item of catalog) {
2158
+ const name = item.name
2159
+ const dls = npmDownloads[name]
2160
+ if (dls !== undefined) {
2161
+ totalCatalogDownloads += dls
2162
+ if (dls > topDownloadCount) {
2163
+ topDownloadCount = dls
2164
+ topDownloadItem = item
2165
+ }
2166
+ }
2167
+ }
2168
+
2169
+ if (totalCatalogDownloads > 0) {
2170
+ items.push(`{bold}NPM Stats{/} · ${totalCatalogDownloads} total weekly downloads for OWD catalog modules`)
2171
+ }
2172
+ if (topDownloadItem && topDownloadCount > 0) {
2173
+ items.push(`{bold}NPM Stats{/} · {bold}{${C.accent}-fg}${topDownloadItem.shortName}{/}{/} is top with ${topDownloadCount} weekly downloads`)
2174
+ }
2175
+
2176
+ const maxDescLen = 25
2177
+
2178
+ if (apps.length > 0) {
2179
+ const app = apps[showcaseIndex % apps.length]
2180
+ const name = app.name
2181
+ const dls = npmDownloads[name]
2182
+ const dlsStr = dls !== undefined ? ` · ${dls} downloads/wk` : ''
2183
+ const starsStr = app.stars > 0 ? ` · ★${app.stars}` : ''
2184
+ const descStr = app.description
2185
+ ? ` · {${C.muted}-fg}${app.description.length > maxDescLen ? app.description.slice(0, maxDescLen) + '...' : app.description}{/}`
2186
+ : ''
2187
+ items.push(`{bold}App Spotlight{/} · {bold}{${C.accent}-fg}${app.shortName}{/}{/}${starsStr}${dlsStr}${descStr}`)
2188
+ }
2189
+
2190
+ if (mods.length > 0) {
2191
+ const mod = mods[showcaseIndex % mods.length]
2192
+ const name = mod.name
2193
+ const dls = npmDownloads[name]
2194
+ const dlsStr = dls !== undefined ? ` · ${dls} downloads/wk` : ''
2195
+ const starsStr = mod.stars > 0 ? ` · ★${mod.stars}` : ''
2196
+ const descStr = mod.description
2197
+ ? ` · {${C.muted}-fg}${mod.description.length > maxDescLen ? mod.description.slice(0, maxDescLen) + '...' : mod.description}{/}`
2198
+ : ''
2199
+ items.push(`{bold}Module Spotlight{/} · {bold}{${C.accent}-fg}${mod.shortName}{/}{/}${starsStr}${dlsStr}${descStr}`)
2200
+ }
2201
+
2202
+ if (themes.length > 0) {
2203
+ const theme = themes[showcaseIndex % themes.length]
2204
+ const name = theme.name
2205
+ const dls = npmDownloads[name]
2206
+ const dlsStr = dls !== undefined ? ` · ${dls} downloads/wk` : ''
2207
+ const starsStr = theme.stars > 0 ? ` · ★${theme.stars}` : ''
2208
+ const descStr = theme.description
2209
+ ? ` · {${C.muted}-fg}${theme.description.length > maxDescLen ? theme.description.slice(0, maxDescLen) + '...' : theme.description}{/}`
2210
+ : ''
2211
+ items.push(`{bold}Theme Spotlight{/} · {bold}{${C.accent}-fg}${theme.shortName}{/}{/}${starsStr}${dlsStr}${descStr}`)
2212
+ }
2213
+
2214
+ return items
2215
+ }
2216
+
2217
+ function renderClient() {
2218
+ const http = clientStatus.http
2219
+ const serverRunning = clientStatus.running || http.up
2220
+ if (devPhase === 'starting') {
2221
+ // Keep starting state
2222
+ } else if (serverRunning) {
2223
+ devPhase = 'running'
2224
+ } else {
2225
+ devPhase = 'stopped'
2226
+ }
2227
+
2228
+ const stateUrl = `http://127.0.0.1:${settings.devPort}`
2229
+ const hostPort = `localhost:${settings.devPort}`
2230
+
2231
+ if (isInstalling) {
2232
+ screen.title = 'Installing'
2233
+ } else if (checkingUpdates) {
2234
+ screen.title = 'Checking'
2235
+ } else if (activeTaskMessage?.toLowerCase().includes('catalog')) {
2236
+ screen.title = 'Loading'
2237
+ } else if (activeTaskMessage) {
2238
+ screen.title = 'Working'
2239
+ } else if (isStartingServer || devPhase === 'starting') {
2240
+ screen.title = `[~] ${hostPort}`
2241
+ } else if (serverRunning) {
2242
+ screen.title = `[#] ${hostPort}`
2243
+ } else {
2244
+ screen.title = '[ ] Desktop'
2245
+ }
2246
+
2247
+ clientBox.style.border.fg = serverRunning ? C.focus : C.border
2248
+ metricsBox.style.border.fg = serverRunning ? C.focus : C.border
2249
+
2250
+ const boxWidth = clientBox.width || 50
2251
+ const maxValLen = Math.max(10, boxWidth - 16)
2252
+
2253
+ let stateLine
2254
+ if (devPhase === 'starting') {
2255
+ stateLine = ` {${C.warn}-fg}…{/} {bold}STARTING{/} ${stateUrl}`
2256
+ } else if (serverRunning) {
2257
+ stateLine = ` {${C.accent}-fg}●{/} {bold}RUNNING{/} {cyan-fg}${stateUrl}{/} {${C.muted}-fg}(PID ${clientStatus.pid}){/}`
2258
+ } else {
2259
+ stateLine = ` {${C.err}-fg}○{/} {bold}STOPPED{/} ${stateUrl}`
2260
+ }
2261
+
2262
+ const docsInstalled = hasDocsModuleInstalled(config, deps)
2263
+ let extraLine = null
2264
+ if (playgroundActive && playgroundLabel) {
2265
+ extraLine = ` {${C.muted}-fg}Playground{/} {bold}${playgroundLabel}{/} active`
2266
+ if (docsInstalled && serverRunning) {
2267
+ extraLine += ` · {${C.muted}-fg}Docs{/} ${keyHint('i')} open`
2268
+ }
2269
+ } else if (docsInstalled) {
2270
+ extraLine = serverRunning
2271
+ ? ` {${C.muted}-fg}Docs{/} ${keyHint('i')} open · ${docsBasePathFromConfig(config)}`
2272
+ : ` {${C.muted}-fg}Docs{/} ${keyHint('d')} start server to view docs`
2273
+ } else {
2274
+ extraLine = ` {${C.muted}-fg}System{/} ready`
2275
+ }
2276
+
2277
+ const configRestartText = 'desktop.config.ts updated — Nuxt is restarting (see dev server log)'
2278
+ const maxRestartLen = Math.max(10, boxWidth - 8)
2279
+ const configRestartDisplay = configRestartText.length > maxRestartLen
2280
+ ? configRestartText.slice(0, maxRestartLen - 3) + '...'
2281
+ : configRestartText
2282
+ const configRestartLine =
2283
+ configRestartHintUntil > Date.now() && serverRunning
2284
+ ? ` {${C.devMode}-fg}ℹ{/} {${C.muted}-fg}${configRestartDisplay}{/}`
2285
+ : null
2286
+
2287
+ const workspaceShortPath = workspaceRoot.replace(process.env.HOME || '', '~')
2288
+ const workspaceDisplayPath = workspaceShortPath.length > maxValLen
2289
+ ? '...' + workspaceShortPath.slice(-(maxValLen - 3))
2290
+ : workspaceShortPath
2291
+
2292
+ // Theme details
2293
+ const activeThemeName = (pendingTheme ?? config.theme ?? '—').replace('@owdproject/', '')
2294
+ const themePendingAsterisk = (pendingTheme && pendingTheme !== config.theme) ? ` {${C.warn}-fg}*{/}` : ''
2295
+ const themeLine = ` {${C.muted}-fg}Theme{/} ${activeThemeName}${themePendingAsterisk}`
2296
+
2297
+ // Git info & workspace changes/warnings integration
2298
+ const gitInfo = getGitStatus(workspaceRoot)
2299
+ const gitWarnings = getMissingGitWarnings(workspaceRoot)
2300
+ const gitWarningBadge = gitWarnings.length > 0 ? ' {yellow-fg}⚠️{/}' : ''
2301
+ const gitChanges = getWorkspaceGitChanges(workspaceRoot)
2302
+ const hasChanges = gitChanges.added > 0 || gitChanges.modified > 0 || gitChanges.deleted > 0
2303
+
2304
+ // Check if client repo has an update
2305
+ const clientUpdate = packageUpdates.get('client')
2306
+ const updateStr = (clientUpdate && clientUpdate.hasUpdate)
2307
+ ? ` · {yellow-fg}update available (↑${clientUpdate.behindCount}){/}`
2308
+ : ''
2309
+
2310
+ let gitDisplayText
2311
+ if (hasChanges) {
2312
+ const parts = []
2313
+ if (gitChanges.added > 0) parts.push(`{${C.add}-fg}+${gitChanges.added}{/}`)
2314
+ if (gitChanges.modified > 0) parts.push(`{${C.warn}-fg}~${gitChanges.modified}{/}`)
2315
+ if (gitChanges.deleted > 0) parts.push(`{${C.remove}-fg}-${gitChanges.deleted}{/}`)
2316
+
2317
+ const repoParts = []
2318
+ if (gitChanges.mainRepo > 0) repoParts.push('root')
2319
+ if (gitChanges.appsRepos > 0) repoParts.push(`${gitChanges.appsRepos} app`)
2320
+ if (gitChanges.modulesRepos > 0) repoParts.push(`${gitChanges.modulesRepos} mod`)
2321
+ if (gitChanges.themesRepos > 0) repoParts.push(`${gitChanges.themesRepos} tem`)
2322
+
2323
+ const repoStr = repoParts.length > 0 ? ` (in ${repoParts.join(', ')})` : ''
2324
+ gitDisplayText = `branch: ${gitInfo.branch}${gitWarningBadge} · changes: ${parts.join(' ')}${repoStr}${updateStr}`
2325
+ } else {
2326
+ gitDisplayText = `branch: ${gitInfo.branch}${gitWarningBadge} · status: clean${updateStr}`
2327
+ }
2328
+
2329
+ const gitDisplay = gitDisplayText.length > maxValLen
2330
+ ? gitDisplayText.slice(0, maxValLen - 3) + '...'
2331
+ : gitDisplayText
2332
+
2333
+ // Versions
2334
+ const versionsText = `Node ${process.version} · Nuxt ${nuxtVersion} · PNPM ${pnpmVersion}`
2335
+ const versionsDisplay = versionsText.length > maxValLen
2336
+ ? versionsText.slice(0, maxValLen - 3) + '...'
2337
+ : versionsText
2338
+
2339
+ const configErrorMsg = configError ? configError.split('\n')[0] : ''
2340
+ const configErrorDisplay = configErrorMsg.length > maxValLen
2341
+ ? configErrorMsg.slice(0, maxValLen - 3) + '...'
2342
+ : configErrorMsg
2343
+
2344
+ const workspaceLine = configError
2345
+ ? ` {${C.err}-fg}⚠️ Config Error:{/} {${C.warn}-fg}${configErrorDisplay}{/}`
2346
+ : configRestartLine
2347
+ ? configRestartLine
2348
+ : ` {${C.muted}-fg}Workspace{/} ${workspaceDisplayPath}`
2349
+
2350
+ const contentLines = [
2351
+ stateLine,
2352
+ truncateFormatted(extraLine, boxWidth - 4),
2353
+ themeLine,
2354
+ '',
2355
+ workspaceLine,
2356
+ ` {${C.muted}-fg}Git{/} ${gitDisplay}`,
2357
+ ` {${C.muted}-fg}Versions{/} ${versionsDisplay}`,
2358
+ ]
2359
+
2360
+ clientBox.setContent(contentLines.join('\n'))
2361
+
2362
+ renderHelp()
2363
+ }
2364
+
2365
+ function getDesiredPackages(kind) {
2366
+ if (kind === 'theme') {
2367
+ return pendingTheme ?? config.theme ? [pendingTheme ?? config.theme] : []
2368
+ }
2369
+ return filterCatalog(catalog, kind, catalogFilterOptions())
2370
+ .filter((item) => {
2371
+ const pending = pendingPackages.get(item.name)
2372
+ if (pending !== undefined) return pending
2373
+ return kind === 'app'
2374
+ ? (config.apps ?? []).includes(item.name)
2375
+ : (config.modules ?? []).includes(item.name)
2376
+ })
2377
+ .map((item) => item.name)
2378
+ }
2379
+
2380
+ function enabledCountForTab(kind) {
2381
+ return getDesiredPackages(kind).length
2382
+ }
2383
+
2384
+ function renderMetrics() {
2385
+ const tabLabel = KINDS[activeTab].label
2386
+ const onDesktop = enabledCountForTab(activeTab)
2387
+
2388
+ const spark = memHistory.length ? formatSparkline(memHistory) : '—'
2389
+ const memLabel =
2390
+ memHistory.length > 0
2391
+ ? `${memHistory[memHistory.length - 1]} MiB`
2392
+ : clientStatus.stats.memMb
2393
+ ? `${clientStatus.stats.memMb} MiB`
2394
+ : '—'
2395
+
2396
+ // Compute local stats
2397
+ let localAppsCount = 0
2398
+ let localModulesCount = 0
2399
+ let localThemesCount = 0
2400
+ try {
2401
+ localAppsCount = readdirSync(join(workspaceRoot, 'apps')).filter(d => !d.startsWith('.')).length
2402
+ } catch {}
2403
+ try {
2404
+ localModulesCount = readdirSync(join(workspaceRoot, 'packages')).filter(d => !d.startsWith('.') && !d.startsWith('core') && !d.startsWith('cli') && !d.startsWith('nx') && !d.startsWith('kit-')).length
2405
+ } catch {}
2406
+ try {
2407
+ localThemesCount = readdirSync(join(workspaceRoot, 'themes')).filter(d => !d.startsWith('.')).length
2408
+ } catch {}
2409
+
2410
+ // Compute registry stats
2411
+ const totalStars = catalog.reduce((acc, item) => acc + (item.stars ?? 0), 0)
2412
+ let totalCatalogDownloads = 0
2413
+ for (const item of catalog) {
2414
+ const name = item.name
2415
+ const dls = npmDownloads[name]
2416
+ if (dls !== undefined) {
2417
+ totalCatalogDownloads += dls
2418
+ }
2419
+ }
2420
+
2421
+ const ghUserText = settings.githubUser ? `${settings.githubUser}` : '(not set)'
2422
+ const sshText = sshAuthStatus?.available
2423
+ ? `{${C.accent}-fg}SSH ok{/}`
2424
+ : `{${C.warn}-fg}HTTPS{/}`
2425
+ const trustedText = trustedPublishers(settings.githubOrgs, settings.githubUser).join(', ')
2426
+ const catalogCountText = catalog.length
2427
+ ? `${catalog.length} pkgs${catalogCacheAge ? ` (${catalogCacheAge})` : ''}`
2428
+ : 'loading…'
2429
+
2430
+ const maxInnerLines = 7
2431
+ const metricsLines = [
2432
+ ` {${C.muted}-fg}Memory{/} {bold}${memLabel}{/} {${C.accent}-fg}${spark}{/}`,
2433
+ ` {${C.muted}-fg}Local{/} {bold}${localAppsCount}{/} apps · {bold}${localModulesCount}{/} modules · {bold}${localThemesCount}{/} themes`,
2434
+ ` {${C.muted}-fg}Registry{/} {bold}${totalStars}{/} stars · {bold}${totalCatalogDownloads.toLocaleString('en-US')}{/} dls/wk`,
2435
+ ` {${C.muted}-fg}GitHub{/} {bold}${ghUserText}{/} · ${sshText}`,
2436
+ ` {${C.muted}-fg}Catalog{/} {bold}${catalogCountText}{/} · {${C.muted}-fg}Trust:{/} ${trustedText}`,
2437
+ ]
2438
+
2439
+ const showcaseItems = getShowcaseItems()
2440
+ if (showcaseItems.length > 0) {
2441
+ const showcaseItem = showcaseItems[showcaseIndex % showcaseItems.length]
2442
+ const boxWidth = metricsBox.width || 40
2443
+ const borderLine = ` {${C.borderDim}-fg}${ '─'.repeat(Math.max(10, boxWidth - 4)) }{/}`
2444
+
2445
+ const padCount = maxInnerLines - metricsLines.length - 2
2446
+ for (let i = 0; i < padCount; i++) {
2447
+ metricsLines.push('')
2448
+ }
2449
+
2450
+ metricsLines.push(borderLine)
2451
+ metricsLines.push(` ${truncateFormatted(showcaseItem, boxWidth - 4)}`)
2452
+ }
2453
+
2454
+ metricsBox.setContent(metricsLines.join('\n'))
2455
+ }
2456
+
2457
+ function formatCatalogRow(item) {
2458
+ if (activeTab === 'theme') {
2459
+ const selected = shortName(pendingTheme ?? config.theme ?? '') === shortName(item.name)
2460
+ const pendingChange =
2461
+ pendingTheme &&
2462
+ shortName(pendingTheme) !== shortName(config.theme ?? '') &&
2463
+ shortName(item.name) === shortName(pendingTheme)
2464
+ return formatCatalogRowPlain(
2465
+ {
2466
+ ...item,
2467
+ installed: selected && !pendingChange,
2468
+ pending: pendingChange ? true : undefined,
2469
+ },
2470
+ { colors: FORMAT_COLORS, columns: currentColumns, packageUpdates, localGitChanges },
2471
+ )
2472
+ }
2473
+
2474
+ const pending = pendingPackages.get(item.name)
2475
+ return formatCatalogRowPlain(
2476
+ {
2477
+ ...item,
2478
+ pending,
2479
+ isNew: activeTab === 'module' && item.isNew,
2480
+ isRecent: activeTab === 'module' && item.isRecent,
2481
+ },
2482
+ { colors: FORMAT_COLORS, columns: currentColumns, packageUpdates, localGitChanges },
2483
+ )
2484
+ }
2485
+
2486
+ function restoreCatalogListSelection(entries, pkgName) {
2487
+ if (!pkgName || !entries.length) return
2488
+ const idx = entries.findIndex((e) => e.name === pkgName)
2489
+ if (idx < 0) return
2490
+ catalogList._listInitialized = false
2491
+ catalogList.select(idx)
2492
+ }
2493
+
2494
+ function listPageStep(list) {
2495
+ return Math.max(1, list.height - list.iheight - 1)
2496
+ }
2497
+
2498
+ function renderCatalogList() {
2499
+ const entries = catalogEntries()
2500
+ const prevPkg = entries[catalogList.selected]?.name
2501
+ const items = entries.map((item) => formatCatalogRow(item))
2502
+ catalogList.setItems(items.length ? items : ['{gray-fg}Loading…{/}'])
2503
+ restoreCatalogListSelection(entries, prevPkg)
2504
+ renderTabs()
2505
+ }
2506
+
2507
+ async function renderDetail() {
2508
+ const entries = catalogEntries()
2509
+ const idx = catalogList.selected
2510
+ const item = entries[idx]
2511
+ if (!item) {
2512
+ detailBox.setContent(`{${C.muted}-fg}Select a package for install preview{/}`)
2513
+ return
2514
+ }
2515
+
2516
+ const kind = KINDS[activeTab]
2517
+ const target = `${kind.workspaceDir}/${item.shortName}`
2518
+ const detailWidth = currentColumns.sel + currentColumns.name + currentColumns.sources + currentColumns.publisher + currentColumns.meta + 4
2519
+ detailBox.setContent(formatDetailPanel(item, target, FORMAT_COLORS, detailWidth))
2520
+ }
2521
+
2522
+ function renderAll() {
2523
+ layoutCatalogPanel()
2524
+ renderLegendBar()
2525
+ headerBar.setContent(formatHeaderLine(FORMAT_COLORS, currentColumns))
2526
+ applyFocusStyles()
2527
+ renderClient()
2528
+ renderMetrics()
2529
+ renderCatalogList()
2530
+ renderDetail()
2531
+ renderHelp('info')
2532
+ screen.render()
2533
+ }
2534
+
2535
+ function focusCatalog() {
2536
+ catalogList.focus()
2537
+ applyFocusStyles()
2538
+ const hint =
2539
+ activeTab === 'theme'
2540
+ ? `${KINDS[activeTab].label} — Space select theme · ${keyHint('s')} save`
2541
+ : `${KINDS[activeTab].label} — Space toggle · ${keyHint('s')} save`
2542
+ setStatus(hint)
2543
+ }
2544
+
2545
+ function selectThemeAt(index) {
2546
+ const entries = catalogEntries()
2547
+ const item = entries[index]
2548
+ if (!item) return
2549
+ if (pendingTheme === item.name) {
2550
+ pendingTheme = config.theme
2551
+ setStatus(`Restored theme to default`)
2552
+ } else {
2553
+ pendingTheme = item.name
2554
+ setStatus(`Theme → ${item.shortName} — ${keyHint('s')} save`, 'ok')
2555
+ }
2556
+ renderCatalogList()
2557
+ renderMetrics()
2558
+ renderClient()
2559
+ renderDetail()
2560
+ screen.render()
2561
+ }
2562
+
2563
+ function toggleCatalogAt(index) {
2564
+ if (activeTab === 'theme') {
2565
+ selectThemeAt(index)
2566
+ return
2567
+ }
2568
+
2569
+ const entries = catalogEntries()
2570
+ const item = entries[index]
2571
+ if (!item) return
2572
+
2573
+ if (pendingPackages.has(item.name)) {
2574
+ pendingPackages.delete(item.name)
2575
+ installChoices.delete(item.name)
2576
+ setStatus(`Restored ${item.shortName}`)
2577
+ } else {
2578
+ const next = !item.installed
2579
+ pendingPackages.set(item.name, next)
2580
+ setStatus(
2581
+ `${next ? 'Queued install' : 'Queued remove'} ${item.shortName} — ${keyHint('s')} review`,
2582
+ )
2583
+ }
2584
+
2585
+ renderCatalogList()
2586
+ renderMetrics()
2587
+ renderClient()
2588
+ renderDetail()
2589
+ screen.render()
2590
+ }
2591
+
2592
+ function setCatalogStateAt(index, forceState) {
2593
+ if (activeTab === 'theme') {
2594
+ if (forceState === true) {
2595
+ selectThemeAt(index)
2596
+ }
2597
+ return
2598
+ }
2599
+
2600
+ const entries = catalogEntries()
2601
+ const item = entries[index]
2602
+ if (!item) return
2603
+
2604
+ const isCurrentDesired = pendingPackages.has(item.name)
2605
+ ? pendingPackages.get(item.name)
2606
+ : item.installed
2607
+
2608
+ if (isCurrentDesired === forceState) {
2609
+ if (pendingPackages.has(item.name)) {
2610
+ pendingPackages.delete(item.name)
2611
+ installChoices.delete(item.name)
2612
+ setStatus(`Restored ${item.shortName}`)
2613
+ }
2614
+ } else {
2615
+ pendingPackages.set(item.name, forceState)
2616
+ setStatus(
2617
+ `${forceState ? 'Queued install' : 'Queued remove'} ${item.shortName} — ${keyHint('s')} review`,
2618
+ )
2619
+ }
2620
+
2621
+ renderCatalogList()
2622
+ renderMetrics()
2623
+ renderClient()
2624
+ renderDetail()
2625
+ screen.render()
2626
+ }
2627
+
2628
+
2629
+ function collectInstallPlan() {
2630
+ const packagesToInstall = []
2631
+ const packagesToMaterialize = []
2632
+ const nextApps = getDesiredPackages('app')
2633
+ const nextModules = getDesiredPackages('module').filter(isInstallableDesktopModule)
2634
+ const nextTheme = pendingTheme ?? config.theme
2635
+ const prevApps = new Set(config.apps ?? [])
2636
+ const prevModules = new Set(config.modules ?? [])
2637
+
2638
+ for (const pkg of nextApps) {
2639
+ if (!prevApps.has(pkg)) packagesToInstall.push(pkg)
2640
+ }
2641
+ for (const pkg of nextModules) {
2642
+ if (!prevModules.has(pkg)) packagesToInstall.push(pkg)
2643
+ }
2644
+ if (nextTheme && shortName(nextTheme) !== shortName(config.theme ?? '')) packagesToInstall.push(nextTheme)
2645
+
2646
+ const enabled = [...nextApps, ...nextModules, nextTheme].filter(Boolean)
2647
+ for (const pkg of enabled) {
2648
+ if (packagesToInstall.includes(pkg)) continue
2649
+ if (!hasLocalWorkspaceSource(workspaceRoot, pkg)) {
2650
+ packagesToMaterialize.push(pkg)
2651
+ }
2652
+ }
2653
+
2654
+ for (const pkg of installChoices.keys()) {
2655
+ const choice = installChoices.get(pkg)
2656
+ if (choice?.type === 'npm') {
2657
+ if (!packagesToInstall.includes(pkg)) {
2658
+ packagesToInstall.push(pkg)
2659
+ }
2660
+ } else {
2661
+ if (
2662
+ !packagesToInstall.includes(pkg) &&
2663
+ !packagesToMaterialize.includes(pkg) &&
2664
+ !hasLocalWorkspaceSource(workspaceRoot, pkg)
2665
+ ) {
2666
+ packagesToMaterialize.push(pkg)
2667
+ }
2668
+ }
2669
+ }
2670
+
2671
+ return { packagesToInstall, packagesToMaterialize, nextApps, nextModules, nextTheme }
2672
+ }
2673
+
2674
+ function renderInstallWizardPanel() {
2675
+ const pkg = installWizardQueue[installWizardIndex]
2676
+ if (!pkg) return
2677
+ const entry = catalog.find((e) => e.name === pkg)
2678
+ const idx = installWizardIndex + 1
2679
+ const total = installWizardQueue.length
2680
+ installWizardOverlay.setLabel(` Install ${shortName(pkg)} (${idx}/${total}) `)
2681
+
2682
+ const kind = entry?.kind || 'module'
2683
+ const folder = kind === 'app' ? 'apps' : kind === 'theme' ? 'themes' : 'packages'
2684
+ const targetDir = `${folder}/${shortName(pkg)}`
2685
+
2686
+ if (installWizardIsStartup) {
2687
+ installWizardOverlay.height = 19
2688
+ installWizardHeader.height = 6
2689
+ installWizardList.top = 8
2690
+ installWizardWarning.top = 6
2691
+ } else {
2692
+ installWizardOverlay.height = 16
2693
+ installWizardHeader.height = 3
2694
+ installWizardList.top = 6
2695
+ installWizardWarning.top = 4
2696
+ }
2697
+
2698
+ const lines = [`{bold}${pkg}{/}`]
2699
+ if (entry?.description) {
2700
+ lines.push(`{${C.muted}-fg}${entry.description.slice(0, 90)}{/}`)
2701
+ lines.push('')
2702
+ }
2703
+ if (installWizardIsStartup) {
2704
+ lines.push(`{yellow-fg}Active package (workspace:*) but local folder is missing.{/}`)
2705
+ }
2706
+ lines.push(`{${C.muted}-fg}Destination path: {bold}${targetDir}/{/}{/}`)
2707
+
2708
+ installWizardHeader.setContent(lines.join('\n'))
2709
+
2710
+ if (installWizardUntrusted && !installWizardConfirmUntrusted) {
2711
+ installWizardWarning.show()
2712
+ const trusted = trustedPublishers(settings.githubOrgs, settings.githubUser).join(', ')
2713
+ installWizardWarning.setContent(
2714
+ `{${C.warn}-fg}⚠ Publisher "${entry?.org ?? 'unknown'}" is not trusted (${trusted}). Enter to confirm anyway.{/}`,
2715
+ )
2716
+ installWizardList.hide()
2717
+ installWizardFooter.setContent(
2718
+ `{${C.muted}-fg}Enter confirm · s skip · Esc cancel all{/}`,
2719
+ )
2720
+ } else {
2721
+ installWizardWarning.hide()
2722
+ installWizardList.show()
2723
+ installWizardList.setItems(
2724
+ installWizardOptions.map((opt) => {
2725
+ const prefix =
2726
+ opt.kind === 'npm' ? `{${C.npm}-fg}NPM{/}` : `{${C.git}-fg}GIT{/}`
2727
+ return `${prefix} ${opt.label}`
2728
+ }),
2729
+ )
2730
+ let defaultIdx = 0
2731
+ const lastId = settings.lastInstallChoiceId
2732
+ if (lastId) {
2733
+ let found = installWizardOptions.findIndex((opt) => opt.id === lastId)
2734
+ if (found >= 0) {
2735
+ defaultIdx = found
2736
+ } else {
2737
+ const isSsh = lastId.includes('ssh')
2738
+ const isHttps = lastId.includes('https')
2739
+ const isNpm = lastId === 'npm'
2740
+ if (isNpm) {
2741
+ found = installWizardOptions.findIndex((opt) => opt.id === 'npm')
2742
+ if (found >= 0) defaultIdx = found
2743
+ } else {
2744
+ found = installWizardOptions.findIndex((opt) => {
2745
+ if (isSsh && opt.protocol === 'ssh') return true
2746
+ if (isHttps && opt.protocol === 'https') return true
2747
+ return false
2748
+ })
2749
+ if (found >= 0) defaultIdx = found
2750
+ }
2751
+ }
2752
+ }
2753
+ installWizardList.select(defaultIdx)
2754
+ installWizardFooter.setContent(
2755
+ `{${C.muted}-fg}↑↓ select source · Enter install · s skip · Esc cancel all{/}`,
2756
+ )
2757
+ }
2758
+ }
2759
+
2760
+ async function openInstallWizardStep() {
2761
+ const pkg = installWizardQueue[installWizardIndex]
2762
+ if (!pkg) {
2763
+ if (installWizardAutoExecute) {
2764
+ closeInstallWizard()
2765
+ await executeInstallPlan()
2766
+ } else {
2767
+ closeInstallWizard(true)
2768
+ }
2769
+ return
2770
+ }
2771
+
2772
+ const entry = catalog.find((e) => e.name === pkg)
2773
+ installWizardUntrusted = entry?.trusted === false
2774
+ installWizardConfirmUntrusted = false
2775
+
2776
+ startSpinner(`Resolving sources for ${shortName(pkg)}…`)
2777
+ screen.render()
2778
+
2779
+ const metadata = await resolvePackageSources(
2780
+ shortName(pkg),
2781
+ settings,
2782
+ workspaceRoot,
2783
+ entry ?? {},
2784
+ )
2785
+ if (!sshAuthStatus) {
2786
+ sshAuthStatus = await detectGithubSshAuth(workspaceRoot)
2787
+ }
2788
+ installWizardOptions = buildSourceOptions(metadata, settings, sshAuthStatus ?? {})
2789
+
2790
+ stopSpinner()
2791
+
2792
+ if (installWizardOptions.length === 0) {
2793
+ setStatus(`No install source for ${shortName(pkg)} — skipped`, 'error')
2794
+ installWizardIndex++
2795
+ await openInstallWizardStep()
2796
+ return
2797
+ }
2798
+
2799
+ installWizardOpen = true
2800
+ updateCatalogKeysState()
2801
+ installWizardOverlay.setFront()
2802
+ installWizardOverlay.show()
2803
+ renderInstallWizardPanel()
2804
+ screen.render()
2805
+ installWizardList.focus()
2806
+ screen.render()
2807
+ }
2808
+
2809
+ function closeInstallWizard(success = false) {
2810
+ installWizardOpen = false
2811
+ updateCatalogKeysState()
2812
+ installWizardOverlay.hide()
2813
+ installWizardQueue = []
2814
+ installWizardIndex = 0
2815
+ installWizardOptions = []
2816
+ installWizardUntrusted = false
2817
+ installWizardConfirmUntrusted = false
2818
+ installWizardIsStartup = false
2819
+ installWizardAutoExecute = true
2820
+ focusCatalog()
2821
+ screen.render()
2822
+ if (installWizardResolve) {
2823
+ const resolve = installWizardResolve
2824
+ installWizardResolve = null
2825
+ resolve(success)
2826
+ }
2827
+ }
2828
+
2829
+ let installWizardProcessing = false
2830
+
2831
+ async function skipInstallWizardStep() {
2832
+ if (installWizardProcessing) return
2833
+ installWizardProcessing = true
2834
+ try {
2835
+ installWizardIndex++
2836
+ if (installWizardIndex >= installWizardQueue.length) {
2837
+ closeInstallWizard(true)
2838
+ return
2839
+ }
2840
+ await openInstallWizardStep()
2841
+ } finally {
2842
+ installWizardProcessing = false
2843
+ }
2844
+ }
2845
+
2846
+ async function confirmInstallWizardStep(choice) {
2847
+ const pkg = installWizardQueue[installWizardIndex]
2848
+ if (!pkg) return
2849
+ installChoices.set(pkg, choice)
2850
+
2851
+ // Save choice to settings
2852
+ const option = installWizardOptions[installWizardList.selected]
2853
+ if (option) {
2854
+ settings.lastInstallChoiceId = option.id
2855
+ saveSettings(workspaceRoot, settings)
2856
+ }
2857
+
2858
+ installWizardIndex++
2859
+ installWizardConfirmUntrusted = false
2860
+ if (installWizardIndex >= installWizardQueue.length) {
2861
+ closeInstallWizard(true)
2862
+ return
2863
+ }
2864
+ await openInstallWizardStep()
2865
+ }
2866
+
2867
+ async function handleInstallWizardSelect() {
2868
+ if (installWizardProcessing) return
2869
+ installWizardProcessing = true
2870
+ try {
2871
+ const pkg = installWizardQueue[installWizardIndex]
2872
+ if (!pkg) return
2873
+
2874
+ if (installWizardUntrusted && !installWizardConfirmUntrusted) {
2875
+ installWizardConfirmUntrusted = true
2876
+ renderInstallWizardPanel()
2877
+ installWizardList.focus()
2878
+ screen.render()
2879
+ return
2880
+ }
2881
+
2882
+ const idx = installWizardList.selected
2883
+ const option = installWizardOptions[idx]
2884
+ if (!option) return
2885
+ await confirmInstallWizardStep(option.choice)
2886
+ } finally {
2887
+ installWizardProcessing = false
2888
+ }
2889
+ }
2890
+
2891
+ function startInstallWizard(packagesToInstall, isStartup = false, { autoExecute = true, clearChoices: doClear = true } = {}) {
2892
+ return new Promise((resolve) => {
2893
+ if (packagesToInstall.length === 0) {
2894
+ resolve(true)
2895
+ return
2896
+ }
2897
+ installWizardIsStartup = isStartup
2898
+ installWizardAutoExecute = autoExecute
2899
+ installWizardResolve = resolve
2900
+ installWizardQueue = [...packagesToInstall]
2901
+ installWizardIndex = 0
2902
+ if (doClear) installChoices.clear()
2903
+ openInstallWizardStep()
2904
+ })
2905
+ }
2906
+
2907
+ async function executeInstallPlan() {
2908
+ return await extExecuteInstallPlan(ctx)
2909
+ }
2910
+
2911
+ async function applyChanges() {
2912
+ stopSpinner()
2913
+ return await runStartupInstallFlow({ isStartup: false })
2914
+ }
2915
+
2916
+ async function syncDependenciesWithConfig() {
2917
+ const declared = [
2918
+ ...(config.apps ?? []),
2919
+ ...(config.modules ?? []),
2920
+ config.theme,
2921
+ ].filter(Boolean)
2922
+
2923
+ const installedDeps = readDesktopDependencies(paths.packageJson)
2924
+
2925
+ const missing = declared.filter(pkg => !installedDeps.includes(pkg))
2926
+ const extra = installedDeps.filter(pkg => !declared.includes(pkg))
2927
+
2928
+ if (missing.length === 0 && extra.length === 0) {
2929
+ return // Already synced!
2930
+ }
2931
+
2932
+ setStatus('Synchronizing dependencies with config…')
2933
+ startSpinner('Syncing package.json…')
2934
+ ctx.setInstalling(true)
2935
+ screen.render()
2936
+
2937
+ try {
2938
+ // 1. Add missing dependencies
2939
+ for (const pkg of missing) {
2940
+ setStatus(`Installing missing package ${shortName(pkg)}…`)
2941
+ screen.render()
2942
+ await installPackage(pkg, settings, workspaceRoot, { stdio: 'pipe' })
2943
+ }
2944
+
2945
+ // 2. Remove extra dependencies
2946
+ for (const pkg of extra) {
2947
+ setStatus(`Removing extra package ${shortName(pkg)}…`)
2948
+ screen.render()
2949
+ await spawnAsync('pnpm', ['remove', pkg], { cwd: paths.desktop })
2950
+ }
2951
+
2952
+ // Reload dependencies
2953
+ deps = readDesktopDependencies(paths.packageJson)
2954
+ renderAll()
2955
+ setStatus('Dependencies synced successfully', 'ok')
2956
+ } catch (err) {
2957
+ hasInstallError = true
2958
+ setStatus(`Sync failed: ${err.message}`, 'error')
2959
+ } finally {
2960
+ ctx.setInstalling(false)
2961
+ stopSpinner()
2962
+ screen.render()
2963
+ }
2964
+ }
2965
+
2966
+ async function startDevServer() {
2967
+ if (isStartingServer || devPhase === 'starting') {
2968
+ setStatus('Dev server is already starting…')
2969
+ return
2970
+ }
2971
+ if (isDevServerUp()) {
2972
+ setStatus(`Dev server already running — ${keyHint('m')} menu to stop or reboot`, 'info')
2973
+ return
2974
+ }
2975
+
2976
+ isStartingServer = true
2977
+ updateCatalogKeysState()
2978
+
2979
+ try {
2980
+ const queue = pendingQueueSummary()
2981
+ if (queue) {
2982
+ const confirmSave = await showCustomConfirm({
2983
+ title: 'Unsaved Changes',
2984
+ message: `You have unsaved changes: ${queue}\nSave and apply changes before starting the dev server?`,
2985
+ yesText: 'Save & Start',
2986
+ noText: 'Discard & Start',
2987
+ cancelText: 'Cancel',
2988
+ height: 13,
2989
+ })
2990
+
2991
+ if (confirmSave === null) {
2992
+ setStatus('Start aborted', 'info')
2993
+ return
2994
+ }
2995
+
2996
+ if (confirmSave) {
2997
+ const saveOk = await applyChanges()
2998
+ if (!saveOk) {
2999
+ setStatus('Start aborted — changes could not be saved/applied', 'error')
3000
+ return
3001
+ }
3002
+ } else {
3003
+ pendingPackages.clear()
3004
+ pendingTheme = config.theme
3005
+ installChoices.clear()
3006
+ renderAll()
3007
+ }
3008
+ }
3009
+
3010
+ startSpinner('Initializing startup checks…')
3011
+ renderAll()
3012
+
3013
+ const ok = await runStartupInstallFlow({ isStartup: true })
3014
+
3015
+ if (!ok) {
3016
+ devPhase = 'stopped'
3017
+ stopSpinner()
3018
+ renderAll()
3019
+ return
3020
+ }
3021
+
3022
+ await syncDependenciesWithConfig()
3023
+
3024
+ // Compile stubs before dev server boots
3025
+ startSpinner('Preparing workspace modules…')
3026
+ renderAll()
3027
+ try {
3028
+ await runPrepareModules(workspaceRoot, 'pipe')
3029
+ } catch (err) {
3030
+ // Log error but attempt to start server anyway so the user can inspect Nuxt log details
3031
+ setStatus(`Preparing modules failed: ${err.message}`, 'error')
3032
+ }
3033
+
3034
+ devPhase = 'starting'
3035
+ startSpinner('Starting dev server…')
3036
+ clearLogsBox()
3037
+ renderAll()
3038
+ startDev(devTarget)
3039
+ startLogWatcher()
3040
+ clientStatus = await waitForDev(workspaceRoot, settings.devPort)
3041
+ stopSpinner()
3042
+ devPhase = clientStatus.http.up ? 'running' : 'stopped'
3043
+ if (!isDevServerUp()) {
3044
+ stopLogWatcher()
3045
+ }
3046
+ renderAll()
3047
+ const targetHint = playgroundActive && playgroundLabel ? ` (${playgroundLabel})` : ''
3048
+ setStatus(
3049
+ clientStatus.http.up
3050
+ ? `Dev server up on port ${settings.devPort}${targetHint} — ${keyHint('m')} menu to stop or reboot`
3051
+ : `Dev not responding — check ${devLogPath(workspaceRoot)}`,
3052
+ clientStatus.http.up ? 'ok' : 'error',
3053
+ )
3054
+ } catch (err) {
3055
+ setStatus(`Server start failed: ${err.message}`, 'error')
3056
+ devPhase = 'stopped'
3057
+ stopSpinner()
3058
+ renderAll()
3059
+ } finally {
3060
+ isStartingServer = false
3061
+ updateCatalogKeysState()
3062
+ screen.render()
3063
+ }
3064
+ }
3065
+
3066
+ async function stopDevServer() {
3067
+ if (devPhase === 'starting') {
3068
+ setStatus('Stopping dev server startup…')
3069
+ stopDev(workspaceRoot, clientStatus.pid)
3070
+ devPhase = 'stopped'
3071
+ stopSpinner()
3072
+ stopLogWatcher()
3073
+ renderAll()
3074
+ setStatus('Startup cancelled', 'info')
3075
+ return
3076
+ }
3077
+ if (!isDevServerUp()) {
3078
+ setStatus(`Dev server not running — press ${keyHint('d')} to start`, 'info')
3079
+ return
3080
+ }
3081
+
3082
+ startSpinner('Stopping dev server…')
3083
+ stopDev(workspaceRoot, clientStatus.pid)
3084
+ devPhase = 'stopped'
3085
+ stopLogWatcher()
3086
+ memHistory.length = 0
3087
+ clientStatus = await waitForDevStop(workspaceRoot, settings.devPort)
3088
+ stopSpinner()
3089
+ renderAll()
3090
+ setStatus(`Dev server stopped — press ${keyHint('d')} to start`, 'ok')
3091
+ }
3092
+
3093
+ async function rebootDevServer() {
3094
+ if (devPhase === 'starting') {
3095
+ setStatus('Cannot reboot while dev server is starting…')
3096
+ return
3097
+ }
3098
+ if (!isDevServerUp()) {
3099
+ setStatus(`Dev server not running — press ${keyHint('d')} to start`, 'info')
3100
+ return
3101
+ }
3102
+
3103
+ devPhase = 'starting'
3104
+ startSpinner('Rebooting dev server…')
3105
+ clearLogsBox()
3106
+ stopLogWatcher()
3107
+ renderAll()
3108
+ stopDev(workspaceRoot, clientStatus.pid)
3109
+ memHistory.length = 0
3110
+ await waitForDevStop(workspaceRoot, settings.devPort)
3111
+ startDev(devTarget)
3112
+ startLogWatcher()
3113
+ clientStatus = await waitForDev(workspaceRoot, settings.devPort)
3114
+ stopSpinner()
3115
+ devPhase = clientStatus.http.up ? 'running' : 'stopped'
3116
+ if (!isDevServerUp()) {
3117
+ stopLogWatcher()
3118
+ }
3119
+ renderAll()
3120
+ const targetHint = playgroundActive && playgroundLabel ? ` (${playgroundLabel})` : ''
3121
+ setStatus(
3122
+ clientStatus.http.up
3123
+ ? `Dev server rebooted on port ${settings.devPort}${targetHint}`
3124
+ : `Reboot failed — check ${devLogPath(workspaceRoot)}`,
3125
+ clientStatus.http.up ? 'ok' : 'error',
3126
+ )
3127
+ }
3128
+
3129
+ async function refreshCatalog({ force = true } = {}) {
3130
+ startSpinner(force ? 'Fetching GitHub catalog…' : 'Loading GitHub catalog…')
3131
+ try {
3132
+ sshAuthStatus = await detectGithubSshAuth(workspaceRoot, { force })
3133
+ if (sshAuthStatus.available && sshAuthStatus.githubLogin) {
3134
+ settings.githubUser = sshAuthStatus.githubLogin
3135
+ }
3136
+ const result = await loadCatalog(workspaceRoot, settings, { force })
3137
+ catalog = result.entries
3138
+ catalogCacheAge = result.cacheAge ?? ''
3139
+ try {
3140
+ config = readDesktopConfig(paths.config, workspaceRoot)
3141
+ configError = null
3142
+ } catch (err) {
3143
+ configError = err.message
3144
+ config = { apps: [], modules: [], theme: null }
3145
+ }
3146
+ deps = readDesktopDependencies(paths.packageJson)
3147
+ pendingTheme = pendingTheme ?? config.theme
3148
+ stopSpinner()
3149
+ renderAll()
3150
+ loadNpmDownloads()
3151
+ setStatus(
3152
+ force
3153
+ ? `Package list: ${catalog.length} from GitHub`
3154
+ : `Package list loaded: ${catalog.length} packages (cache: ${catalogCacheAge})`,
3155
+ 'ok',
3156
+ )
3157
+ } catch (err) {
3158
+ stopSpinner()
3159
+ setStatus(`Package list error: ${err.message}`, 'error')
3160
+ }
3161
+ }
3162
+
3163
+ function getPackagesRequiredByTheme(workspaceRoot, themeName) {
3164
+ return getPackagesRequiredByThemeImport(workspaceRoot, themeName)
3165
+ }
3166
+
3167
+ // Keep legacy alias used in startDevServer
3168
+ const getKitsRequiredByTheme = (wr, name) => getPackagesRequiredByTheme(wr, name)
3169
+
3170
+ async function runStartupInstallFlow(opts = {}) {
3171
+ return await extRunStartupInstallFlow(ctx, opts)
3172
+ }
3173
+
3174
+ screen.key(['escape'], () => {
3175
+ if (installWizardOpen) {
3176
+ closeInstallWizard()
3177
+ setStatus('Install cancelled — no changes applied', 'info')
3178
+ return
3179
+ }
3180
+ if (sortPickerOpen) {
3181
+ closeSortPicker()
3182
+ return
3183
+ }
3184
+ if (settingsOpen) {
3185
+ closeSettings()
3186
+ return
3187
+ }
3188
+ if (menuOpen) {
3189
+ closeMenu()
3190
+ }
3191
+ })
3192
+
3193
+ screen.key(['q', 'C-c'], () => {
3194
+ if (installWizardOpen) {
3195
+ closeInstallWizard()
3196
+ setStatus('Install cancelled', 'info')
3197
+ return
3198
+ }
3199
+ if (sortPickerOpen) {
3200
+ closeSortPicker()
3201
+ return
3202
+ }
3203
+ if (settingsOpen) {
3204
+ closeSettings()
3205
+ return
3206
+ }
3207
+ if (menuOpen) {
3208
+ closeMenu()
3209
+ return
3210
+ }
3211
+ process.exit(0)
3212
+ })
3213
+
3214
+ screen.key(['m', 'M'], () => {
3215
+ if (settingsOpen || installWizardOpen || sortPickerOpen) return
3216
+ if (menuOpen) closeMenu()
3217
+ else openMenu()
3218
+ })
3219
+ screen.key(['d', 'D'], () => {
3220
+ if (!overlayBlocksKeys()) {
3221
+ if (isDevServerUp()) stopDevServer()
3222
+ else startDevServer()
3223
+ }
3224
+ })
3225
+ screen.key(['x', 'X'], () => {
3226
+ if (!overlayBlocksKeys()) stopDevServer()
3227
+ })
3228
+ screen.key(['R'], () => {
3229
+ if (!overlayBlocksKeys()) rebootDevServer()
3230
+ })
3231
+ screen.key(['g'], () => {
3232
+ if (!overlayBlocksKeys()) openSettings()
3233
+ })
3234
+
3235
+ function openDocsInBrowser() {
3236
+ if (!hasDocsModuleInstalled(config, deps)) {
3237
+ setStatus('Install @owdproject/module-docs first', 'error')
3238
+ return
3239
+ }
3240
+ const http = clientStatus.http
3241
+ if (!clientStatus.running && !http.up) {
3242
+ setStatus(`Start dev server ${keyHint('d')} before opening docs`, 'error')
3243
+ return
3244
+ }
3245
+ const base = docsBasePathFromConfig(config)
3246
+ const url = `http://127.0.0.1:${settings.devPort}${base}`
3247
+ const platform = process.platform
3248
+ try {
3249
+ if (platform === 'darwin') {
3250
+ execSync('open', [url])
3251
+ } else if (platform === 'win32') {
3252
+ execSync('cmd', ['/c', 'start', '', url])
3253
+ } else {
3254
+ execSync('xdg-open', [url])
3255
+ }
3256
+ setStatus(`Opened ${url}`, 'ok')
3257
+ } catch (err) {
3258
+ setStatus(`Could not open browser: ${err.message}`, 'error')
3259
+ }
3260
+ }
3261
+
3262
+ screen.key(['i', 'I'], () => {
3263
+ if (!overlayBlocksKeys()) openDocsInBrowser()
3264
+ })
3265
+ screen.key(['b'], () => {
3266
+ if (!overlayBlocksKeys()) {
3267
+ stopSpinner()
3268
+ setStatus('Running pnpm run generate…')
3269
+ screen.render()
3270
+ try {
3271
+ execSync('pnpm run generate', { cwd: workspaceRoot, stdio: 'pipe' })
3272
+ setStatus('Generate finished', 'ok')
3273
+ } catch (err) {
3274
+ setStatus(`Generate failed: ${err.message}`, 'error')
3275
+ } finally {
3276
+ refreshTerminalUi()
3277
+ }
3278
+ }
3279
+ })
3280
+ screen.key(['s', 'S'], () => {
3281
+ if (installWizardOpen) {
3282
+ skipInstallWizardStep()
3283
+ } else if (!overlayBlocksKeys()) {
3284
+ applyChanges()
3285
+ }
3286
+ })
3287
+ screen.key(['enter'], () => {
3288
+ if (installWizardOpen) {
3289
+ handleInstallWizardSelect()
3290
+ }
3291
+ })
3292
+ screen.key(['r'], () => {
3293
+ if (!overlayBlocksKeys()) refreshCatalog()
3294
+ })
3295
+ screen.key(['u', 'U'], () => {
3296
+ if (!overlayBlocksKeys()) checkForUpdates()
3297
+ })
3298
+
3299
+ screen.key(['o'], () => {
3300
+ if (!overlayBlocksKeys()) cycleCatalogSort()
3301
+ })
3302
+ screen.key(['O'], () => {
3303
+ if (!overlayBlocksKeys()) openSortPicker()
3304
+ })
3305
+
3306
+ function changeTab(tabName) {
3307
+ activeTab = tabName
3308
+ if (tabName === 'theme') {
3309
+ focusCatalog()
3310
+ renderCatalogList()
3311
+ restoreCatalogListSelection(catalogEntries(), pendingTheme ?? config.theme)
3312
+ renderLegendBar()
3313
+ applyFocusStyles()
3314
+ renderMetrics()
3315
+ renderDetail()
3316
+ renderTabs()
3317
+ screen.render()
3318
+ } else {
3319
+ focusCatalog()
3320
+ renderAll()
3321
+ }
3322
+ }
3323
+
3324
+ screen.key(['1'], () => {
3325
+ if (overlayBlocksKeys()) return
3326
+ changeTab('app')
3327
+ })
3328
+ screen.key(['2'], () => {
3329
+ if (overlayBlocksKeys()) return
3330
+ changeTab('module')
3331
+ })
3332
+ screen.key(['3'], () => {
3333
+ if (overlayBlocksKeys()) return
3334
+ changeTab('theme')
3335
+ })
3336
+
3337
+ const TABS = ['app', 'module', 'theme']
3338
+
3339
+ function cycleTab(direction) {
3340
+ const currentIndex = TABS.indexOf(activeTab)
3341
+ if (currentIndex === -1) return
3342
+ let nextIndex = currentIndex + direction
3343
+ if (nextIndex < 0) nextIndex = TABS.length - 1
3344
+ if (nextIndex >= TABS.length) nextIndex = 0
3345
+ changeTab(TABS[nextIndex])
3346
+ }
3347
+
3348
+ screen.key(['left'], () => {
3349
+ if (overlayBlocksKeys()) return
3350
+ if (screen.focused === catalogList) {
3351
+ cycleTab(-1)
3352
+ }
3353
+ })
3354
+
3355
+ screen.key(['right'], () => {
3356
+ if (overlayBlocksKeys()) return
3357
+ if (screen.focused === catalogList) {
3358
+ cycleTab(1)
3359
+ }
3360
+ })
3361
+
3362
+ tabBar.on('click', (data) => {
3363
+ if (overlayBlocksKeys()) return
3364
+ const relativeX = data.x - tabBar.aleft
3365
+ if (relativeX >= 1 && relativeX <= 5) {
3366
+ changeTab('app')
3367
+ } else if (relativeX >= 8 && relativeX <= 15) {
3368
+ changeTab('module')
3369
+ } else if (relativeX >= 18 && relativeX <= 24) {
3370
+ changeTab('theme')
3371
+ }
3372
+ })
3373
+
3374
+ trustedOrgsInput.key(['enter'], () => saveSettingsFromOverlay())
3375
+ trustedOrgsInput.key(['escape'], () => closeSettings())
3376
+ settingsOverlay.key(['t', 'T'], () => {
3377
+ if (settingsOpen) testSshFromSettings()
3378
+ })
3379
+
3380
+ installWizardList.key(['space'], () => {})
3381
+ installWizardList.key(['enter'], () => handleInstallWizardSelect())
3382
+ installWizardList.key(['s', 'S'], () => skipInstallWizardStep())
3383
+ installWizardList.key(['escape'], () => {
3384
+ closeInstallWizard()
3385
+ setStatus('Install cancelled — no changes applied', 'info')
3386
+ })
3387
+ installWizardOverlay.key(['s', 'S'], () => skipInstallWizardStep())
3388
+ installWizardOverlay.key(['enter'], () => handleInstallWizardSelect())
3389
+ installWizardOverlay.key(['escape'], () => {
3390
+ closeInstallWizard()
3391
+ setStatus('Install cancelled — no changes applied', 'info')
3392
+ })
3393
+
3394
+ menuList.on('select', (_item, index) => {
3395
+ const entry = MENU_ITEMS[index]
3396
+ if (entry) runMenuAction(entry.id)
3397
+ })
3398
+ menuList.key(['escape'], () => closeMenu())
3399
+
3400
+ sortPickerList.on('select', (_item, index) => selectSortAt(index))
3401
+ sortPickerList.key(['enter'], function () {
3402
+ selectSortAt(this.selected)
3403
+ })
3404
+ sortPickerList.key(['escape'], () => closeSortPicker())
3405
+ sortPickerOverlay.key(['escape'], () => closeSortPicker())
3406
+
3407
+ catalogList.on('select item', () => {
3408
+ renderDetail()
3409
+ })
3410
+ catalogList.key(['space'], function () {
3411
+ if (overlayBlocksKeys()) return
3412
+ toggleCatalogAt(this.selected)
3413
+ })
3414
+ catalogList.key(['+'], function () {
3415
+ if (overlayBlocksKeys()) return
3416
+ setCatalogStateAt(this.selected, true)
3417
+ })
3418
+ catalogList.key(['-'], function () {
3419
+ if (overlayBlocksKeys()) return
3420
+ setCatalogStateAt(this.selected, false)
3421
+ })
3422
+ catalogList.key(['pageup'], function () {
3423
+ if (overlayBlocksKeys()) return
3424
+ this.up(listPageStep(this))
3425
+ renderDetail()
3426
+ this.screen.render()
3427
+ })
3428
+ catalogList.key(['pagedown'], function () {
3429
+ if (overlayBlocksKeys()) return
3430
+ this.down(listPageStep(this))
3431
+ renderDetail()
3432
+ this.screen.render()
3433
+ })
3434
+
3435
+ screen.key(['l', 'L'], () => {
3436
+ if (!overlayBlocksKeys() && isDevServerUp() && logsBox.visible) {
3437
+ logsBox.focus()
3438
+ setStatus('Logs — Use ↑↓ or PageUp/PageDown to scroll · Esc to return focus to Catalog')
3439
+ }
3440
+ })
3441
+
3442
+ logsBox.key(['escape'], () => {
3443
+ focusCatalog()
3444
+ })
3445
+
3446
+
3447
+
3448
+ helpBar.on('click', () => {
3449
+ if (overlayBlocksKeys()) return
3450
+ showCustomConfirm({
3451
+ title: 'Confirm Refresh',
3452
+ message: '\n Do you want to force-refresh the GitHub package catalog?\n This will fetch package data from upstream.',
3453
+ yesText: ' Yes, force-refresh ',
3454
+ noText: ' No, cancel ',
3455
+ height: 11
3456
+ }).then((value) => {
3457
+ if (value) {
3458
+ refreshCatalog({ force: true })
3459
+ }
3460
+ })
3461
+ })
3462
+
3463
+ await refreshCatalog({ force: false })
3464
+ updateLocalChanges()
3465
+ clientStatus = await getClientStatus(workspaceRoot, settings.devPort)
3466
+ devPhase = clientStatus.running ? 'running' : 'stopped'
3467
+ focusCatalog()
3468
+ if (isDevServerUp()) {
3469
+ startLogWatcher()
3470
+ }
3471
+ renderAll()
3472
+ checkForUpdates({ silent: true })
3473
+
3474
+ screen.on('destroy', () => {
3475
+ if (configWatcher) {
3476
+ configWatcher.close()
3477
+ }
3478
+ stopLogWatcher()
3479
+ })
3480
+
3481
+ startConfigWatcher()
3482
+
3483
+ setStatus(
3484
+ `Select packages · ${keyHint('s')} opens install wizard · ${keyHint('u')} updates · ${keyHint('g')} settings · ${keyHint('d')} server`,
3485
+ 'info',
3486
+ )
3487
+
3488
+ let lastShowcaseTime = Date.now()
3489
+ let gitScanCycle = 0
3490
+
3491
+ setInterval(async () => {
3492
+ const now = Date.now()
3493
+ if (now - lastShowcaseTime >= 5000) {
3494
+ showcaseIndex++
3495
+ lastShowcaseTime = now
3496
+ }
3497
+
3498
+ gitScanCycle++
3499
+ if (gitScanCycle % 3 === 0 && !isInstalling) {
3500
+ updateLocalChanges()
3501
+ renderCatalogList()
3502
+ }
3503
+
3504
+ if (devPhase !== 'starting' && !settingsOpen && !overlayBlocksKeys()) {
3505
+ clientStatus = await getClientStatus(workspaceRoot, settings.devPort)
3506
+ if (clientStatus.stats.memMb > 0) {
3507
+ memHistory.push(clientStatus.stats.memMb)
3508
+ if (memHistory.length > MEM_HISTORY_LEN) memHistory.shift()
3509
+ }
3510
+ if (clientStatus.http.up) devPhase = 'running'
3511
+ else if (devPhase === 'running') devPhase = 'stopped'
3512
+ }
3513
+ const serverRunning = isDevServerUp()
3514
+ if (serverRunning && !logWatcher) {
3515
+ startLogWatcher()
3516
+ } else if (!serverRunning && logWatcher) {
3517
+ stopLogWatcher()
3518
+ }
3519
+ renderClient()
3520
+ renderMetrics()
3521
+ screen.render()
3522
+ }, 2000)
3523
+ }