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