@owdproject/cli 0.0.3 → 0.1.0

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