@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
@@ -0,0 +1,792 @@
1
+ import { join } from 'node:path'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import {
4
+ isInstallableDesktopModule,
5
+ isDesktopKitPackage,
6
+ shortName,
7
+ getPackagesRequiredByTheme,
8
+ findMissingWorkspaceDependencies,
9
+ } from './workspace.js'
10
+ import {
11
+ readDesktopConfig,
12
+ readDesktopDependencies,
13
+ writeDesktopConfig,
14
+ resolveConfigPathForWrite,
15
+ } from './config.js'
16
+ import {
17
+ installPackage,
18
+ materializeToWorkspace,
19
+ runPrepareModules,
20
+ spawnAsync,
21
+ resolveInstallPlan,
22
+ cloneRepo,
23
+ hasLocalWorkspaceSource,
24
+ linkWorkspacePackage,
25
+ } from './install.js'
26
+ import {
27
+ mergeInstalled,
28
+ } from './catalog.js'
29
+ import {
30
+ progressTrack,
31
+ radarSpinner,
32
+ spinnerFrameCount,
33
+ } from './cpAscii.js'
34
+ function keyHint(key) {
35
+ return `{green-fg}[${key}]{/}`
36
+ }
37
+
38
+ /**
39
+ * Executes the save changes install plan.
40
+ * @param {any} ctx
41
+ */
42
+ export async function executeInstallPlan(ctx) {
43
+ ctx.stopSpinner()
44
+ let installSpinnerTimer = null
45
+ const plan = ctx.collectInstallPlan()
46
+ const { packagesToInstall, packagesToMaterialize } = plan
47
+ let { nextApps, nextModules, nextTheme } = plan
48
+
49
+ const config = ctx.getConfig()
50
+ const settings = ctx.getSettings()
51
+ const workspaceRoot = ctx.getWorkspaceRoot()
52
+ const installChoices = ctx.getInstallChoices()
53
+ const paths = ctx.getPaths()
54
+
55
+ const currentDeps = readDesktopDependencies(paths.packageJson)
56
+
57
+ const wizardSet = new Set([...packagesToInstall, ...packagesToMaterialize])
58
+ const confirmed = (pkg) =>
59
+ !wizardSet.has(pkg) ||
60
+ installChoices.has(pkg) ||
61
+ currentDeps.includes(pkg) ||
62
+ hasLocalWorkspaceSource(workspaceRoot, pkg)
63
+
64
+ nextApps = nextApps.filter(confirmed)
65
+ nextModules = nextModules.filter(confirmed)
66
+ if (
67
+ nextTheme &&
68
+ wizardSet.has(nextTheme) &&
69
+ !installChoices.has(nextTheme) &&
70
+ !currentDeps.includes(nextTheme) &&
71
+ !hasLocalWorkspaceSource(workspaceRoot, nextTheme)
72
+ ) {
73
+ nextTheme = config.theme
74
+ ctx.setPendingTheme(config.theme)
75
+ }
76
+
77
+ const toInstall = packagesToInstall.filter((pkg) => installChoices.has(pkg))
78
+ const toMaterialize = packagesToMaterialize.filter((pkg) => installChoices.has(pkg))
79
+
80
+ const activePackages = [...nextApps, ...nextModules, nextTheme].filter(Boolean)
81
+ const toLink = []
82
+ for (const pkgName of activePackages) {
83
+ if (!currentDeps.includes(pkgName)) {
84
+ if (hasLocalWorkspaceSource(workspaceRoot, pkgName)) {
85
+ toLink.push(pkgName)
86
+ }
87
+ }
88
+ }
89
+
90
+ const gitPackages = [...toInstall, ...toMaterialize].filter(
91
+ (pkg) => installChoices.get(pkg)?.type === 'git'
92
+ )
93
+
94
+ const actualMaterialize = toMaterialize.filter((pkg) => {
95
+ const choice = installChoices.get(pkg)
96
+ return choice && choice.type !== 'npm'
97
+ })
98
+
99
+ const gitToInstall = toInstall.filter((pkg) => installChoices.get(pkg)?.type === 'git')
100
+ const expectedClonedPackages = [...gitToInstall, ...actualMaterialize, ...toLink]
101
+ const willClone = expectedClonedPackages.length > 0
102
+
103
+ let calculatedTotal = 1 // Initializing
104
+ calculatedTotal += gitPackages.length // Pre-clonings
105
+ calculatedTotal += toLink.length // Linking loop
106
+ calculatedTotal += toInstall.length // Installing loop
107
+ calculatedTotal += actualMaterialize.length // Cloning loop
108
+ if (willClone) {
109
+ calculatedTotal += 1 // Installing dependencies
110
+ calculatedTotal += expectedClonedPackages.length // Preparing loop
111
+ calculatedTotal += 1 // Finalizing workspace
112
+ }
113
+ calculatedTotal += 1 // Updating configuration
114
+
115
+ const totalSteps = calculatedTotal
116
+ let step = 0
117
+ let didClone = false
118
+ /** @type {Array<{pkg: string, error: string}>} */
119
+ const failures = []
120
+
121
+ const bump = (label) => {
122
+ step++
123
+ const currentStep = Math.min(step, totalSteps)
124
+ ctx.setSaveProgress({ step: currentStep, total: totalSteps, label })
125
+ ctx.setStatus(label)
126
+
127
+ // Update the progress modal content
128
+ const bar = `[${progressTrack(currentStep, totalSteps, 0, 36)}]`
129
+ const spin = radarSpinner(ctx.getSpinnerFrame())
130
+ let displayLabel = label
131
+ if (label.startsWith('Cloning ')) {
132
+ displayLabel = `Cloning: ${label.slice(8).replace(/…$/, '')}`
133
+ } else if (label.startsWith('Installing ') && label !== 'Installing dependencies…') {
134
+ displayLabel = `Installing: ${label.slice(11).replace(/…$/, '')}`
135
+ } else if (label.startsWith('Preparing ') && label !== 'Preparing…') {
136
+ displayLabel = `Preparing: ${label.slice(10).replace(/—$/, '')}`
137
+ }
138
+
139
+ ctx.installProgressContent.setContent(
140
+ [
141
+ ` {bold}${spin} ${displayLabel}{/}`,
142
+ '',
143
+ ` {cyan-fg}${bar}{/}`,
144
+ ` Step ${currentStep} of ${totalSteps}`,
145
+ ].join('\n')
146
+ )
147
+
148
+ ctx.screen.render()
149
+ }
150
+
151
+ ctx.setInstalling(true)
152
+ ctx.updateCatalogKeysState()
153
+ ctx.installProgressOverlay.show()
154
+ ctx.installProgressOverlay.setFront()
155
+ ctx.installProgressOverlay.focus()
156
+
157
+ installSpinnerTimer = setInterval(() => {
158
+ ctx.setSpinnerFrame((ctx.getSpinnerFrame() + 1) % spinnerFrameCount)
159
+ ctx.renderHelp()
160
+
161
+ // Update spinner in modal
162
+ const saveProgress = ctx.getSaveProgress()
163
+ if (saveProgress !== null) {
164
+ const bar = `[${progressTrack(saveProgress.step, saveProgress.total, 0, 36)}]`
165
+ const spin = radarSpinner(ctx.getSpinnerFrame())
166
+ let displayLabel = saveProgress.label
167
+ if (displayLabel.startsWith('Cloning ')) {
168
+ displayLabel = `Cloning: ${displayLabel.slice(8).replace(/…$/, '')}`
169
+ } else if (displayLabel.startsWith('Installing ') && displayLabel !== 'Installing dependencies…') {
170
+ displayLabel = `Installing: ${displayLabel.slice(11).replace(/…$/, '')}`
171
+ } else if (displayLabel.startsWith('Preparing ') && displayLabel !== 'Preparing…') {
172
+ displayLabel = `Preparing: ${displayLabel.slice(10).replace(/—$/, '')}`
173
+ }
174
+ ctx.installProgressContent.setContent(
175
+ [
176
+ ` {bold}${spin} ${displayLabel}{/}`,
177
+ '',
178
+ ` {cyan-fg}${bar}{/}`,
179
+ ` Step ${saveProgress.step} of ${saveProgress.total}`,
180
+ ].join('\n')
181
+ )
182
+ }
183
+
184
+ ctx.screen.render()
185
+ }, 150)
186
+
187
+ try {
188
+ bump('Initializing…')
189
+
190
+ // Pre-clone all Git repositories first to prevent pnpm workspace failures on missing packages
191
+ if (gitPackages.length > 0) {
192
+ for (const pkg of gitPackages) {
193
+ bump(`Cloning ${shortName(pkg)}…`)
194
+ ctx.clearLogs()
195
+ const choice = installChoices.get(pkg)
196
+ const plan = await resolveInstallPlan(pkg, settings, workspaceRoot, choice)
197
+ if (!plan.error && plan.targetDir && plan.source?.gitUrl) {
198
+ try {
199
+ await cloneRepo(plan.targetDir, plan.source.gitUrl, undefined, workspaceRoot, { quiet: true })
200
+ } catch (err) {
201
+ // Non-fatal pre-clone failure
202
+ }
203
+ }
204
+ }
205
+ }
206
+
207
+ const clonedPackages = []
208
+
209
+ if (toLink.length > 0) {
210
+ for (const pkg of toLink) {
211
+ bump(`Linking ${shortName(pkg)}…`)
212
+ ctx.clearLogs()
213
+ try {
214
+ await linkWorkspacePackage(paths.desktop, pkg)
215
+ clonedPackages.push(pkg)
216
+ didClone = true
217
+ } catch (err) {
218
+ const msg = err.message?.split('\n').find(l => l.trim()) ?? String(err)
219
+ failures.push({ pkg, error: msg })
220
+ nextApps = nextApps.filter((p) => p !== pkg)
221
+ nextModules = nextModules.filter((p) => p !== pkg)
222
+ if (nextTheme === pkg) {
223
+ nextTheme = config.theme
224
+ ctx.setPendingTheme(config.theme)
225
+ }
226
+ ctx.setStatus(`${shortName(pkg)}: ${msg}`, 'error')
227
+ ctx.screen.render()
228
+ }
229
+ }
230
+ }
231
+
232
+ for (const pkg of toInstall) {
233
+ const choice = installChoices.get(pkg)
234
+ bump(`Installing ${shortName(pkg)}…`)
235
+ ctx.clearLogs()
236
+ try {
237
+ await installPackage(pkg, settings, workspaceRoot, {
238
+ stdio: 'pipe',
239
+ sourceChoice: choice,
240
+ })
241
+ if (choice?.type === 'git') {
242
+ didClone = true
243
+ clonedPackages.push(pkg)
244
+ }
245
+ } catch (err) {
246
+ const msg = err.message?.split('\n').find(l => l.trim()) ?? String(err)
247
+ failures.push({ pkg, error: msg })
248
+ nextApps = nextApps.filter((p) => p !== pkg)
249
+ nextModules = nextModules.filter((p) => p !== pkg)
250
+ if (nextTheme === pkg) {
251
+ nextTheme = config.theme
252
+ ctx.setPendingTheme(config.theme)
253
+ }
254
+ ctx.setStatus(`${shortName(pkg)}: ${msg}`, 'error')
255
+ ctx.screen.render()
256
+ }
257
+ }
258
+
259
+ for (const pkg of toMaterialize) {
260
+ const choice = installChoices.get(pkg)
261
+ if (!choice || choice.type === 'npm') continue
262
+ bump(`Cloning ${shortName(pkg)}…`)
263
+ ctx.clearLogs()
264
+ try {
265
+ await materializeToWorkspace(pkg, settings, workspaceRoot, {
266
+ stdio: 'pipe',
267
+ sourceChoice: choice,
268
+ })
269
+ didClone = true
270
+ clonedPackages.push(pkg)
271
+ } catch (err) {
272
+ const msg = err.message?.split('\n').find(l => l.trim()) ?? String(err)
273
+ failures.push({ pkg, error: msg })
274
+ ctx.setStatus(`${shortName(pkg)}: ${msg}`, 'error')
275
+ ctx.screen.render()
276
+ }
277
+ }
278
+
279
+ // Detect and clone any missing workspace:* dependencies recursively
280
+ let missingWorkspaceDeps = findMissingWorkspaceDependencies(workspaceRoot)
281
+ const autoClonedDeps = new Set()
282
+
283
+ while (missingWorkspaceDeps.length > 0) {
284
+ const dep = missingWorkspaceDeps.shift()
285
+ if (autoClonedDeps.has(dep)) continue
286
+ autoClonedDeps.add(dep)
287
+
288
+ bump(`Cloning dependency ${shortName(dep)}…`)
289
+ ctx.clearLogs()
290
+
291
+ try {
292
+ const plan = await resolveInstallPlan(dep, settings, workspaceRoot)
293
+ if (plan.error) {
294
+ throw new Error(plan.error)
295
+ }
296
+ if (plan.targetDir && plan.source?.gitUrl) {
297
+ await cloneRepo(plan.targetDir, plan.source.gitUrl, undefined, workspaceRoot, { quiet: true })
298
+ didClone = true
299
+ clonedPackages.push(dep)
300
+ const newMissing = findMissingWorkspaceDependencies(workspaceRoot)
301
+ for (const m of newMissing) {
302
+ if (!autoClonedDeps.has(m) && !missingWorkspaceDeps.includes(m)) {
303
+ missingWorkspaceDeps.push(m)
304
+ }
305
+ }
306
+ } else {
307
+ throw new Error('Unknown git clone URL')
308
+ }
309
+ } catch (err) {
310
+ const msg = err.message?.split('\n').find(l => l.trim()) ?? String(err)
311
+ failures.push({ pkg: dep, error: `Failed to resolve workspace dependency ${shortName(dep)}: ${msg}` })
312
+ }
313
+ }
314
+
315
+ if (didClone) {
316
+ bump('Installing dependencies…')
317
+ ctx.clearLogs()
318
+ try {
319
+ await spawnAsync('pnpm', ['install'], { cwd: workspaceRoot })
320
+ } catch {
321
+ // Non-fatal
322
+ }
323
+ }
324
+
325
+ bump('Updating configuration…')
326
+ ctx.setIgnoreNextConfigWatch(true)
327
+ ctx.setWritingConfig(true)
328
+ writeDesktopConfig(resolveConfigPathForWrite(paths), workspaceRoot, {
329
+ theme: nextTheme,
330
+ apps: nextApps,
331
+ modules: nextModules,
332
+ })
333
+
334
+ for (const pkg of config.apps ?? []) {
335
+ if (!nextApps.includes(pkg)) {
336
+ await spawnAsync('pnpm', ['remove', pkg], { cwd: paths.desktop })
337
+ }
338
+ }
339
+ for (const pkg of config.modules ?? []) {
340
+ if (!nextModules.includes(pkg)) {
341
+ await spawnAsync('pnpm', ['remove', pkg], { cwd: paths.desktop })
342
+ }
343
+ }
344
+
345
+ pendingPackages.clear()
346
+ installChoices.clear()
347
+ paths.config = resolveConfigPathForWrite(paths)
348
+ ctx.setPaths(paths)
349
+
350
+ let configError = null
351
+ let newConfig = config
352
+ try {
353
+ newConfig = readDesktopConfig(paths.config, workspaceRoot)
354
+ } catch (err) {
355
+ configError = err.message
356
+ newConfig = { apps: [], modules: [], theme: null }
357
+ }
358
+ ctx.setConfig(newConfig)
359
+ ctx.setConfigError(configError)
360
+
361
+ const newDeps = readDesktopDependencies(paths.packageJson)
362
+ ctx.setDeps(newDeps)
363
+ ctx.setPendingTheme(newConfig.theme)
364
+ ctx.setSaveProgress(null)
365
+
366
+ if (ctx.getConfigRestartHintTimer()) {
367
+ clearTimeout(ctx.getConfigRestartHintTimer())
368
+ ctx.setConfigRestartHintTimer(null)
369
+ }
370
+ if (ctx.isDevServerUp()) {
371
+ ctx.setConfigRestartHintUntil(Date.now() + 5000)
372
+ ctx.setConfigRestartHintTimer(setTimeout(() => {
373
+ ctx.setConfigRestartHintUntil(0)
374
+ ctx.setConfigRestartHintTimer(null)
375
+ ctx.renderClient()
376
+ ctx.screen.render()
377
+ }, 5000))
378
+ }
379
+
380
+ ctx.renderAll()
381
+
382
+ setTimeout(() => {
383
+ ctx.setWritingConfig(false)
384
+ }, 500)
385
+
386
+ if (failures.length > 0) {
387
+ const names = failures.map((f) => shortName(f.pkg)).join(', ')
388
+ const detail = failures[0].error
389
+ ctx.setStatus(
390
+ `${failures.length} package(s) failed (${names}) — ${detail}`,
391
+ 'error',
392
+ )
393
+ return false
394
+ } else {
395
+ ctx.setStatus(
396
+ ctx.isDevServerUp()
397
+ ? 'Saved — desktop.config.ts updated; Nuxt restarts automatically (see dev server log)'
398
+ : `Saved successfully — start dev server ${keyHint('d')} to apply`,
399
+ 'ok',
400
+ )
401
+ return true
402
+ }
403
+ } catch (err) {
404
+ ctx.setWritingConfig(false)
405
+ ctx.setSaveProgress(null)
406
+ ctx.setStatus(`Save failed: ${err.message}`, 'error')
407
+ return false
408
+ } finally {
409
+ if (installSpinnerTimer) {
410
+ clearInterval(installSpinnerTimer)
411
+ }
412
+ ctx.setInstalling(false)
413
+ ctx.installProgressOverlay.hide()
414
+ ctx.updateCatalogKeysState()
415
+ ctx.screen.render()
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Unified startup install flow blocker.
421
+ * @param {any} ctx
422
+ * @param {{ isStartup?: boolean }} [opts]
423
+ */
424
+ export async function runStartupInstallFlow(ctx, { isStartup = false } = {}) {
425
+ ctx.stopSpinner()
426
+
427
+ const paths = ctx.getPaths()
428
+ const workspaceRoot = ctx.getWorkspaceRoot()
429
+ const installChoices = ctx.getInstallChoices()
430
+ const pendingPackages = ctx.getPendingPackages()
431
+ const settings = ctx.getSettings()
432
+ const catalog = ctx.getCatalog()
433
+
434
+ // Reload latest config/deps state
435
+ try {
436
+ ctx.setConfig(readDesktopConfig(paths.config, workspaceRoot))
437
+ ctx.setDeps(readDesktopDependencies(paths.packageJson))
438
+ } catch {}
439
+
440
+ const config = ctx.getConfig()
441
+ const deps = ctx.getDeps()
442
+
443
+ const plan = ctx.collectInstallPlan()
444
+ let { nextApps, nextModules, nextTheme } = plan
445
+
446
+ const allCloned = []
447
+ let didClone = false
448
+ const skippedKits = []
449
+
450
+ async function cloneOrInstallPkg(pkg) {
451
+ const choice = installChoices.get(pkg)
452
+ if (!choice) return
453
+ if (choice.type === 'npm') {
454
+ try {
455
+ await installPackage(pkg, settings, workspaceRoot, { stdio: 'pipe', sourceChoice: choice })
456
+ } catch (err) {
457
+ const msg = err.message?.split('\n').find(l => l.trim()) ?? String(err)
458
+ ctx.setStatus(`${shortName(pkg)}: ${msg}`, 'error')
459
+ ctx.screen.render()
460
+ }
461
+ return
462
+ }
463
+ try {
464
+ await materializeToWorkspace(pkg, settings, workspaceRoot, { stdio: 'pipe', sourceChoice: choice })
465
+ didClone = true
466
+ allCloned.push(pkg)
467
+ } catch (err) {
468
+ const msg = err.message?.split('\n').find(l => l.trim()) ?? String(err)
469
+ ctx.setStatus(`${shortName(pkg)}: ${msg}`, 'error')
470
+ ctx.screen.render()
471
+ }
472
+ }
473
+
474
+ const merged = mergeInstalled(catalog, config, deps, workspaceRoot)
475
+ const missingAppsModules = merged
476
+ .filter((item) => item.installed && !item.localSource && !item.inPackageJson && item.kind !== 'theme')
477
+ .map((item) => item.name)
478
+
479
+ const desiredTheme = ctx.getPendingTheme() ?? config.theme
480
+ const themeMissing = !!(desiredTheme && !hasLocalWorkspaceSource(workspaceRoot, desiredTheme))
481
+ const round1Queue = [...missingAppsModules, ...(themeMissing ? [desiredTheme] : [])]
482
+
483
+ if (round1Queue.length > 0) {
484
+ ctx.stopSpinner()
485
+ ctx.renderAll()
486
+ const success = await ctx.startInstallWizard(round1Queue, isStartup, { autoExecute: false })
487
+ if (!success) {
488
+ ctx.setStatus('Startup aborted', 'error')
489
+ return false
490
+ }
491
+ }
492
+
493
+ // Determine effective theme after wizard choices
494
+ let activeTheme = desiredTheme
495
+ if (themeMissing && !installChoices.has(desiredTheme)) {
496
+ activeTheme = config.theme
497
+ nextTheme = config.theme
498
+ ctx.setPendingTheme(config.theme)
499
+ }
500
+
501
+ const activePackages = [...nextApps, ...nextModules, activeTheme].filter(Boolean)
502
+ const toLink = []
503
+ for (const pkgName of activePackages) {
504
+ if (!deps.includes(pkgName)) {
505
+ if (hasLocalWorkspaceSource(workspaceRoot, pkgName)) {
506
+ toLink.push(pkgName)
507
+ }
508
+ }
509
+ }
510
+
511
+ const round1Confirmed = round1Queue.filter((pkg) => installChoices.has(pkg))
512
+ const failedLinks = new Set()
513
+
514
+ let dynTotal = round1Confirmed.length + toLink.length
515
+ let step = 0
516
+ const syncProgress = { step: 0, total: Math.max(dynTotal, 1), label: 'Initializing…' }
517
+
518
+ let installSpinnerTimer = null
519
+
520
+ const renderProgressContent = () => {
521
+ let lbl = syncProgress.label
522
+ if (lbl.startsWith('Cloning ')) lbl = `Cloning: ${lbl.slice(8).replace(/…$/, '')}`
523
+ else if (lbl.startsWith('Preparing ')) lbl = `Preparing: ${lbl.slice(10).replace(/…$/, '')}`
524
+ const bar = `[${progressTrack(syncProgress.step, syncProgress.total, 0, 36)}]`
525
+ const spin = radarSpinner(ctx.getSpinnerFrame())
526
+ ctx.installProgressContent.setContent([
527
+ ` {bold}${spin} ${lbl}{/}`,
528
+ '',
529
+ ` {cyan-fg}${bar}{/}`,
530
+ ` Step ${syncProgress.step} of ${syncProgress.total}`,
531
+ ].join('\n'))
532
+ }
533
+
534
+ const bump = (label, addSteps = 0) => {
535
+ step++
536
+ dynTotal += addSteps
537
+ syncProgress.step = Math.min(step, dynTotal)
538
+ syncProgress.total = dynTotal
539
+ syncProgress.label = label
540
+ ctx.setStatus(label)
541
+ renderProgressContent()
542
+ ctx.screen.render()
543
+ }
544
+
545
+ const openProgress = () => {
546
+ ctx.setInstalling(true)
547
+ ctx.updateCatalogKeysState()
548
+ ctx.installProgressOverlay.show()
549
+ ctx.installProgressOverlay.setFront()
550
+ ctx.installProgressOverlay.focus()
551
+ renderProgressContent()
552
+ ctx.screen.render()
553
+ }
554
+
555
+ const closeProgress = () => {
556
+ if (installSpinnerTimer) { clearInterval(installSpinnerTimer); installSpinnerTimer = null }
557
+ ctx.setInstalling(false)
558
+ ctx.installProgressOverlay.hide()
559
+ ctx.updateCatalogKeysState()
560
+ ctx.screen.render()
561
+ }
562
+
563
+ const hasWork = round1Confirmed.length > 0 || toLink.length > 0
564
+ if (hasWork) {
565
+ openProgress()
566
+ installSpinnerTimer = setInterval(() => {
567
+ ctx.setSpinnerFrame((ctx.getSpinnerFrame() + 1) % spinnerFrameCount)
568
+ ctx.renderHelp()
569
+ renderProgressContent()
570
+ ctx.screen.render()
571
+ }, 150)
572
+ }
573
+
574
+ try {
575
+ if (toLink.length > 0) {
576
+ for (const pkg of toLink) {
577
+ bump(`Linking ${shortName(pkg)}…`)
578
+ ctx.clearLogs()
579
+ try {
580
+ await linkWorkspacePackage(paths.desktop, pkg)
581
+ allCloned.push(pkg)
582
+ didClone = true
583
+ } catch (err) {
584
+ const msg = err.message?.split('\n').find(l => l.trim()) ?? String(err)
585
+ failedLinks.add(pkg)
586
+ ctx.setStatus(`${shortName(pkg)}: ${msg}`, 'error')
587
+ ctx.screen.render()
588
+ }
589
+ }
590
+ }
591
+
592
+ for (const pkg of round1Confirmed) {
593
+ bump(`Cloning ${shortName(pkg)}…`)
594
+ ctx.clearLogs()
595
+ await cloneOrInstallPkg(pkg)
596
+ }
597
+
598
+ if (activeTheme) {
599
+ const themeKits = getPackagesRequiredByTheme(workspaceRoot, activeTheme)
600
+ const missingKits = themeKits.filter((kit) => !hasLocalWorkspaceSource(workspaceRoot, kit))
601
+
602
+ if (missingKits.length > 0) {
603
+ closeProgress()
604
+ ctx.renderAll()
605
+
606
+ const success = await ctx.startInstallWizard(missingKits, isStartup, {
607
+ autoExecute: false,
608
+ clearChoices: false,
609
+ })
610
+
611
+ const kitConfirmed = success
612
+ ? missingKits.filter((kit) => installChoices.has(kit))
613
+ : []
614
+ const kitSkipped = missingKits.filter((kit) => !installChoices.has(kit))
615
+ skippedKits.push(...kitSkipped)
616
+
617
+ dynTotal += kitConfirmed.length
618
+ syncProgress.total = dynTotal
619
+ if (kitConfirmed.length > 0) {
620
+ openProgress()
621
+ installSpinnerTimer = setInterval(() => {
622
+ ctx.setSpinnerFrame((ctx.getSpinnerFrame() + 1) % spinnerFrameCount)
623
+ ctx.renderHelp()
624
+ renderProgressContent()
625
+ ctx.screen.render()
626
+ }, 150)
627
+
628
+ for (const kit of kitConfirmed) {
629
+ bump(`Cloning ${shortName(kit)}…`)
630
+ ctx.clearLogs()
631
+ await cloneOrInstallPkg(kit)
632
+ }
633
+ }
634
+ }
635
+ }
636
+
637
+ // Detect and clone any missing workspace:* dependencies recursively
638
+ let missingWorkspaceDeps = findMissingWorkspaceDependencies(workspaceRoot)
639
+ const autoClonedDeps = new Set()
640
+
641
+ while (missingWorkspaceDeps.length > 0) {
642
+ const dep = missingWorkspaceDeps.shift()
643
+ if (autoClonedDeps.has(dep)) continue
644
+ autoClonedDeps.add(dep)
645
+
646
+ bump(`Cloning dependency ${shortName(dep)}…`)
647
+ ctx.clearLogs()
648
+
649
+ try {
650
+ const plan = await resolveInstallPlan(dep, settings, workspaceRoot)
651
+ if (plan.error) {
652
+ throw new Error(plan.error)
653
+ }
654
+ if (plan.targetDir && plan.source?.gitUrl) {
655
+ await cloneRepo(plan.targetDir, plan.source.gitUrl, undefined, workspaceRoot, { quiet: true })
656
+ didClone = true
657
+ allCloned.push(dep)
658
+ const newMissing = findMissingWorkspaceDependencies(workspaceRoot)
659
+ for (const m of newMissing) {
660
+ if (!autoClonedDeps.has(m) && !missingWorkspaceDeps.includes(m)) {
661
+ missingWorkspaceDeps.push(m)
662
+ }
663
+ }
664
+ } else {
665
+ throw new Error('Unknown git clone URL')
666
+ }
667
+ } catch (err) {
668
+ const msg = err.message?.split('\n').find(l => l.trim()) ?? String(err)
669
+ ctx.setStatus(`Failed to resolve workspace dependency ${shortName(dep)}: ${msg}`, 'error')
670
+ ctx.screen.render()
671
+ }
672
+ }
673
+
674
+ if (didClone) {
675
+ if (!ctx.isInstalling()) {
676
+ dynTotal += 1 + allCloned.length + 1
677
+ syncProgress.total = dynTotal
678
+ openProgress()
679
+ installSpinnerTimer = setInterval(() => {
680
+ ctx.setSpinnerFrame((ctx.getSpinnerFrame() + 1) % spinnerFrameCount)
681
+ ctx.renderHelp()
682
+ renderProgressContent()
683
+ ctx.screen.render()
684
+ }, 150)
685
+ } else {
686
+ bump('', 1 + allCloned.length + 1)
687
+ step--
688
+ syncProgress.step = step
689
+ }
690
+
691
+ bump('Installing dependencies…')
692
+ ctx.clearLogs()
693
+ try {
694
+ await spawnAsync('pnpm', ['install'], { cwd: workspaceRoot })
695
+ } catch {
696
+ // Non-fatal
697
+ }
698
+ }
699
+ } catch (err) {
700
+ ctx.setStatus(`Install failed: ${err.message}`, 'error')
701
+ } finally {
702
+ closeProgress()
703
+ }
704
+
705
+ nextApps = (plan.nextApps ?? []).filter(
706
+ (pkg) => (!installChoices.has(pkg) || installChoices.get(pkg) !== null) && !failedLinks.has(pkg)
707
+ )
708
+ nextModules = (plan.nextModules ?? []).filter(
709
+ (pkg) => (!installChoices.has(pkg) || installChoices.get(pkg) !== null) && !failedLinks.has(pkg)
710
+ )
711
+ if (failedLinks.has(activeTheme)) {
712
+ activeTheme = config.theme
713
+ }
714
+ nextTheme = activeTheme ?? nextTheme
715
+
716
+ ctx.setIgnoreNextConfigWatch(true)
717
+ ctx.setWritingConfig(true)
718
+ try {
719
+ writeDesktopConfig(resolveConfigPathForWrite(paths), workspaceRoot, {
720
+ theme: nextTheme,
721
+ apps: nextApps,
722
+ modules: nextModules,
723
+ })
724
+
725
+ for (const pkg of config.apps ?? []) {
726
+ if (!nextApps.includes(pkg)) {
727
+ await spawnAsync('pnpm', ['remove', pkg], { cwd: paths.desktop })
728
+ }
729
+ }
730
+ for (const pkg of config.modules ?? []) {
731
+ if (!nextModules.includes(pkg)) {
732
+ await spawnAsync('pnpm', ['remove', pkg], { cwd: paths.desktop })
733
+ }
734
+ }
735
+
736
+ pendingPackages.clear()
737
+ installChoices.clear()
738
+ paths.config = resolveConfigPathForWrite(paths)
739
+ ctx.setPaths(paths)
740
+
741
+ let configError = null
742
+ let newConfig = config
743
+ try {
744
+ newConfig = readDesktopConfig(paths.config, workspaceRoot)
745
+ } catch (err) {
746
+ configError = err.message
747
+ newConfig = { apps: [], modules: [], theme: null }
748
+ }
749
+ ctx.setConfig(newConfig)
750
+ ctx.setConfigError(configError)
751
+
752
+ const newDeps = readDesktopDependencies(paths.packageJson)
753
+ ctx.setDeps(newDeps)
754
+ ctx.setPendingTheme(newConfig.theme)
755
+ ctx.setSaveProgress(null)
756
+
757
+ if (ctx.getConfigRestartHintTimer()) {
758
+ clearTimeout(ctx.getConfigRestartHintTimer())
759
+ ctx.setConfigRestartHintTimer(null)
760
+ }
761
+ if (ctx.isDevServerUp()) {
762
+ ctx.setConfigRestartHintUntil(Date.now() + 5000)
763
+ ctx.setConfigRestartHintTimer(setTimeout(() => {
764
+ ctx.setConfigRestartHintUntil(0)
765
+ ctx.setConfigRestartHintTimer(null)
766
+ ctx.renderClient()
767
+ ctx.screen.render()
768
+ }, 5000))
769
+ }
770
+
771
+ ctx.renderAll()
772
+ setTimeout(() => { ctx.setWritingConfig(false) }, 500)
773
+
774
+ if (skippedKits.length > 0) {
775
+ const names = skippedKits.map(shortName).join(', ')
776
+ ctx.setStatus(`Done — skipped kit(s): ${names} (server may not work correctly)`, 'warn')
777
+ } else if (!isStartup) {
778
+ ctx.setStatus(
779
+ ctx.isDevServerUp()
780
+ ? 'Saved — desktop.config.ts updated; Nuxt restarts automatically (see dev server log)'
781
+ : `Saved successfully — start dev server ${keyHint('d')} to apply`,
782
+ 'ok',
783
+ )
784
+ }
785
+ } catch (err) {
786
+ ctx.setWritingConfig(false)
787
+ ctx.setStatus(`Save failed: ${err.message}`, 'error')
788
+ return false
789
+ }
790
+
791
+ return true
792
+ }