@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/go/tui/tui.go ADDED
@@ -0,0 +1,869 @@
1
+ package tui
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "net/http"
7
+ "os"
8
+ "path/filepath"
9
+ "runtime"
10
+ "strings"
11
+ "time"
12
+
13
+ "owd-cli/bridge"
14
+
15
+ "github.com/charmbracelet/bubbles/textinput"
16
+ tea "github.com/charmbracelet/bubbletea"
17
+ )
18
+
19
+ func NewModel(root string) TuiModel {
20
+ return TuiModel{
21
+ workspaceRoot: root,
22
+ activeTab: 1, // Default to Apps (index 1), Internal (index 0) is secret
23
+ loading: true,
24
+ statusMsg: "Initializing OWD Control Panel…",
25
+ logLines: []string{},
26
+ memHistory: []int{},
27
+ gitChangesMap: make(map[string]GitChanges),
28
+ updatesMap: make(map[string]UpdateInfo),
29
+ localGitDirs: make(map[string]bool),
30
+ settingsSel: 0,
31
+ termWidth: 160,
32
+ termHeight: 40,
33
+ lastInstallMethod: "npm",
34
+ logTailerStarted: false,
35
+ pendingPackages: make(map[string]bool),
36
+ pendingTheme: nil,
37
+ workQueue: NewWorkQueue(),
38
+ enginePhase: PhaseIdle,
39
+ runtime: &RuntimeState{
40
+ msgChan: make(chan tea.Msg, 500),
41
+ engineResume: make(chan string, 1),
42
+ },
43
+ startServerAfterSetup: false,
44
+ setupStep: 0,
45
+ setupTotalSteps: 0,
46
+ setupLabel: "",
47
+ activeTask: TaskNone,
48
+ workspaceBranch: "—",
49
+ workspaceChanges: "clean",
50
+ clientStars: -1,
51
+ }
52
+ }
53
+
54
+ func (m *TuiModel) listenToChannel() tea.Cmd {
55
+ return func() tea.Msg {
56
+ return <-m.runtime.msgChan
57
+ }
58
+ }
59
+
60
+ func (m *TuiModel) Init() tea.Cmd {
61
+ return tea.Batch(
62
+ m.loadContextCmd(),
63
+ m.loadCatalogCmd(false),
64
+ m.checkServerStatusCmd(),
65
+ m.updateWorkspaceGitStatusCmd(),
66
+ m.listenToChannel(),
67
+ tickCmd(),
68
+ m.fetchClientStarsCmd(),
69
+ )
70
+ }
71
+
72
+ // ─────────────────────────────────────────────
73
+ // Commands
74
+ // ─────────────────────────────────────────────
75
+
76
+ func tickCmd() tea.Cmd {
77
+ return tea.Tick(150*time.Millisecond, func(t time.Time) tea.Msg {
78
+ return tickMsg(t)
79
+ })
80
+ }
81
+
82
+ func memSampleCmd() tea.Cmd {
83
+ return func() tea.Msg {
84
+ var ms runtime.MemStats
85
+ runtime.ReadMemStats(&ms)
86
+ mb := int(ms.Sys / 1024 / 1024)
87
+ return memTickMsg{MemMB: mb}
88
+ }
89
+ }
90
+
91
+ func (m *TuiModel) loadContextCmd() tea.Cmd {
92
+ return func() tea.Msg {
93
+ ctx, err := bridge.ReadWorkspaceContext(m.workspaceRoot)
94
+ return contextLoadedMsg{Ctx: ctx, Err: err}
95
+ }
96
+ }
97
+
98
+ func (m *TuiModel) loadCatalogCmd(force bool) tea.Cmd {
99
+ return func() tea.Msg {
100
+ cat, err := bridge.ReadCatalog(m.workspaceRoot, force)
101
+ return catalogLoadedMsg{Cat: cat, Err: err}
102
+ }
103
+ }
104
+
105
+ type clientStarsMsg int
106
+
107
+ func (m *TuiModel) fetchClientStarsCmd() tea.Cmd {
108
+ return func() tea.Msg {
109
+ client := &http.Client{Timeout: 3 * time.Second}
110
+ req, err := http.NewRequest("GET", "https://api.github.com/repos/owdproject/client", nil)
111
+ if err != nil {
112
+ return clientStarsMsg(-1)
113
+ }
114
+ req.Header.Set("User-Agent", "owd-cli")
115
+ resp, err := client.Do(req)
116
+ if err != nil {
117
+ return clientStarsMsg(-1)
118
+ }
119
+ defer resp.Body.Close()
120
+
121
+ var res map[string]interface{}
122
+ if err := json.NewDecoder(resp.Body).Decode(&res); err == nil {
123
+ if starsVal, ok := res["stargazers_count"]; ok {
124
+ if starsFloat, ok := starsVal.(float64); ok {
125
+ return clientStarsMsg(int(starsFloat))
126
+ }
127
+ }
128
+ }
129
+ return clientStarsMsg(-1)
130
+ }
131
+ }
132
+
133
+ func (m *TuiModel) checkServerStatusCmd() tea.Cmd {
134
+ return func() tea.Msg {
135
+ if m.ctx == nil {
136
+ return serverStatusMsg{Running: false}
137
+ }
138
+
139
+ port := m.ctx.Settings.DevPort
140
+ if port == 0 {
141
+ port = 3000
142
+ }
143
+
144
+ open := isPortOpen(port)
145
+ if !open {
146
+ pidPath := filepath.Join(m.ctx.Paths.MetaDir, "dev.pid")
147
+ _ = os.Remove(pidPath)
148
+ return serverStatusMsg{Running: false}
149
+ }
150
+
151
+ pid := findProcessPID(port)
152
+ if pid > 0 {
153
+ pidPath := filepath.Join(m.ctx.Paths.MetaDir, "dev.pid")
154
+ _ = os.WriteFile(pidPath, []byte(fmt.Sprintf("%d", pid)), 0644)
155
+ }
156
+
157
+ return serverStatusMsg{Running: true}
158
+ }
159
+ }
160
+
161
+ func (m *TuiModel) updateWorkspaceGitStatusCmd() tea.Cmd {
162
+ return func() tea.Msg {
163
+ branch := gitBranch(m.workspaceRoot)
164
+ changes := gitChanges(m.workspaceRoot)
165
+ return workspaceGitStatusMsg{Branch: branch, Changes: changes}
166
+ }
167
+ }
168
+
169
+ func (m *TuiModel) hasPendingChanges() bool {
170
+ if len(m.pendingPackages) > 0 {
171
+ return true
172
+ }
173
+ if m.pendingTheme != nil {
174
+ if m.ctx != nil && m.ctx.Config.Theme != nil {
175
+ return *m.ctx.Config.Theme != *m.pendingTheme
176
+ }
177
+ return true
178
+ }
179
+ return false
180
+ }
181
+
182
+ func (m *TuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
183
+ switch msg := msg.(type) {
184
+
185
+ case tea.MouseMsg:
186
+ if msg.Action == tea.MouseActionRelease && msg.Button == tea.MouseButtonLeft {
187
+ if m.isClearLogsClick(msg) {
188
+ m.logLines = []string{}
189
+ m.statusMsg = "Logs cleared."
190
+ return m, nil
191
+ }
192
+ if msg.Y == 10 {
193
+ if msg.X <= 13 {
194
+ m.activeTab, m.selectedIndex, m.scrollOffset = 0, 0, 0
195
+ return m, nil
196
+ }
197
+ if msg.X >= 14 && msg.X <= 22 {
198
+ m.activeTab, m.selectedIndex, m.scrollOffset = 1, 0, 0
199
+ return m, nil
200
+ }
201
+ if msg.X >= 23 && msg.X <= 34 {
202
+ m.activeTab, m.selectedIndex, m.scrollOffset = 2, 0, 0
203
+ return m, nil
204
+ }
205
+ if msg.X >= 35 {
206
+ m.activeTab, m.selectedIndex, m.scrollOffset = 3, 0, 0
207
+ return m, nil
208
+ }
209
+ }
210
+ }
211
+ return m, nil
212
+
213
+ case tea.WindowSizeMsg:
214
+ m.termWidth = msg.Width
215
+ m.termHeight = msg.Height
216
+ return m, nil
217
+
218
+ case clientStarsMsg:
219
+ if int(msg) >= 0 {
220
+ m.clientStars = int(msg)
221
+ }
222
+ return m, nil
223
+
224
+ case tickMsg:
225
+ m.tickCount++
226
+ if m.tickCount%4 == 0 {
227
+ m.blink = !m.blink
228
+ }
229
+
230
+ var cmds []tea.Cmd
231
+ cmds = append(cmds, tickCmd())
232
+
233
+ // Every ~1.5s: sample memory + check server status
234
+ if m.tickCount%10 == 0 {
235
+ var mb int
236
+ if m.ctx != nil {
237
+ pidPath := filepath.Join(m.ctx.Paths.MetaDir, "dev.pid")
238
+ if data, err := os.ReadFile(pidPath); err == nil {
239
+ var pid int
240
+ if _, err := fmt.Sscanf(strings.TrimSpace(string(data)), "%d", &pid); err == nil {
241
+ mb = getProcessMemoryMB(pid)
242
+ }
243
+ }
244
+ }
245
+ m.memHistory = append(m.memHistory, mb)
246
+ if len(m.memHistory) > 30 {
247
+ m.memHistory = m.memHistory[1:]
248
+ }
249
+ if m.ctx != nil {
250
+ cmds = append(cmds, m.checkServerStatusCmd())
251
+ }
252
+ }
253
+
254
+ // Every ~15s: fast local git status check (no network)
255
+ if m.tickCount%100 == 0 && !m.loading && m.activeTask == TaskNone && !m.checkingUpdates {
256
+ m.checkingUpdates = true
257
+ cmds = append(cmds, m.checkLocalChangesCmd(), m.updateWorkspaceGitStatusCmd())
258
+ }
259
+
260
+ // Every ~5min: slow remote check (git behind without fetch + npm versions)
261
+ if m.tickCount%2000 == 0 && !m.loading && m.activeTask == TaskNone {
262
+ cmds = append(cmds, m.checkRemoteUpdatesCmd())
263
+ }
264
+
265
+ return m, tea.Batch(cmds...)
266
+
267
+ case tea.KeyMsg:
268
+ promptToShow := m.activePrompt
269
+ if (m.activeTask == TaskSetup || m.activeTask == TaskWipe) &&
270
+ m.activePrompt != PromptResolveDependency &&
271
+ (promptToShow == PromptNone || promptToShow == PromptUninstallConfirm || promptToShow == PromptInstallMethod || promptToShow == PromptWipeWorkspaceConfirm) {
272
+ promptToShow = PromptSetupProgress
273
+ }
274
+ if promptToShow != PromptNone {
275
+ return m.handlePromptKeys(msg)
276
+ }
277
+ switch msg.String() {
278
+ case "q", "ctrl+c":
279
+ return m, tea.Quit
280
+ case "0":
281
+ m.activeTab, m.selectedIndex, m.scrollOffset = 0, 0, 0
282
+ case "1":
283
+ m.activeTab, m.selectedIndex, m.scrollOffset = 1, 0, 0
284
+ case "2":
285
+ m.activeTab, m.selectedIndex, m.scrollOffset = 2, 0, 0
286
+ case "3":
287
+ m.activeTab, m.selectedIndex, m.scrollOffset = 3, 0, 0
288
+ case "left":
289
+ if m.activeTab == 0 {
290
+ m.activeTab = 3
291
+ } else {
292
+ m.activeTab--
293
+ }
294
+ m.selectedIndex, m.scrollOffset = 0, 0
295
+ case "right":
296
+ if m.activeTab == 3 {
297
+ m.activeTab = 1
298
+ } else {
299
+ m.activeTab++
300
+ }
301
+ m.selectedIndex, m.scrollOffset = 0, 0
302
+ case "up", "k":
303
+ if m.selectedIndex > 0 {
304
+ m.selectedIndex--
305
+ if m.selectedIndex < m.scrollOffset {
306
+ m.scrollOffset = m.selectedIndex
307
+ }
308
+ }
309
+ case "down", "j":
310
+ items := m.getActiveItems()
311
+ if m.selectedIndex < len(items)-1 {
312
+ m.selectedIndex++
313
+ }
314
+ case "r":
315
+ m.loading = true
316
+ m.statusMsg = "Refreshing package catalog…"
317
+ return m, m.loadCatalogCmd(true)
318
+ case "s":
319
+ if m.hasPendingChanges() {
320
+ return m, m.startWizard(WizardSave)
321
+ }
322
+ cmd := m.startWizard(WizardStartupCheck)
323
+ if cmd != nil {
324
+ return m, cmd
325
+ }
326
+ case "d":
327
+ if !m.serverRunning && m.activeTask == TaskNone {
328
+ if m.hasPendingChanges() {
329
+ return m, m.startWizard(WizardSaveAndServe)
330
+ }
331
+ cmd := m.startWizard(WizardStartupCheck)
332
+ if cmd != nil {
333
+ m.startServerAfterSetup = true
334
+ return m, cmd
335
+ }
336
+
337
+ m.statusMsg = "Starting dev server…"
338
+ m.activeTask = TaskServe
339
+ m.RunServeTask()
340
+ }
341
+ case "x":
342
+ if m.serverRunning {
343
+ m.statusMsg = "Stopping dev server…"
344
+ m.activeTask = TaskServe
345
+ m.StopServeTask()
346
+ }
347
+ case "c":
348
+ items := m.getActiveItems()
349
+ if len(items) > 0 && m.selectedIndex < len(items) {
350
+ pkg := items[m.selectedIndex]
351
+ m.promptPkg = &pkg
352
+ m.loadPackageDetails(&pkg)
353
+ m.activePrompt = PromptManagePackage
354
+ m.promptSel = 0
355
+ }
356
+ case "enter", " ":
357
+ items := m.getActiveItems()
358
+ if len(items) > 0 && m.selectedIndex < len(items) {
359
+ pkg := items[m.selectedIndex]
360
+ if m.activeTab == 3 { // Themes (radiobox)
361
+ current := activeThemeName(m.ctx, m.pendingTheme)
362
+ if current == pkg.Name && m.pendingTheme != nil {
363
+ m.pendingTheme = nil
364
+ } else if current != pkg.Name {
365
+ themeName := pkg.Name
366
+ m.pendingTheme = &themeName
367
+ }
368
+ } else { // Apps or Modules (checkbox)
369
+ if pkg.Installed {
370
+ if _, exists := m.pendingPackages[pkg.Name]; exists {
371
+ delete(m.pendingPackages, pkg.Name)
372
+ } else {
373
+ m.pendingPackages[pkg.Name] = false
374
+ }
375
+ } else {
376
+ if _, exists := m.pendingPackages[pkg.Name]; exists {
377
+ delete(m.pendingPackages, pkg.Name)
378
+ } else {
379
+ m.pendingPackages[pkg.Name] = true
380
+ }
381
+ }
382
+ }
383
+ }
384
+ case "u":
385
+ items := m.getActiveItems()
386
+ if len(items) > 0 && m.selectedIndex < len(items) {
387
+ pkg := items[m.selectedIndex]
388
+ if pkg.Installed && m.activeTask == TaskNone {
389
+ m.activeTask = TaskSetup
390
+ m.statusMsg = fmt.Sprintf("Updating %s…", pkg.ShortName)
391
+ m.RunUpdatePackageTask(pkg.Name, pkg.ShortName, pkg.Kind, pkg.LocalSource)
392
+ }
393
+ }
394
+ case "g":
395
+ if m.ctx != nil {
396
+ m.settingsInstallMode = m.ctx.Settings.InstallMode
397
+ m.settingsCatalogSort = m.ctx.Settings.CatalogSort
398
+ m.settingsSel = 0
399
+ m.activePrompt = PromptSettings
400
+
401
+ // Initialize text inputs
402
+ tiUser := textinput.New()
403
+ tiUser.Placeholder = "github_user"
404
+ tiUser.CharLimit = 64
405
+ tiUser.Width = 30
406
+ if m.ctx.Settings.GithubUser != nil {
407
+ tiUser.SetValue(*m.ctx.Settings.GithubUser)
408
+ }
409
+ m.settingsUserInput = tiUser
410
+
411
+ tiOrgs := textinput.New()
412
+ tiOrgs.Placeholder = "org1, org2"
413
+ tiOrgs.CharLimit = 256
414
+ tiOrgs.Width = 40
415
+ if len(m.ctx.Settings.GithubOrgs) > 0 {
416
+ tiOrgs.SetValue(strings.Join(m.ctx.Settings.GithubOrgs, ", "))
417
+ } else {
418
+ tiOrgs.SetValue("owdproject, atproto-os")
419
+ }
420
+ m.settingsOrgsInput = tiOrgs
421
+
422
+ m.updateSettingsFocus()
423
+ }
424
+ case "n":
425
+ m.ExitCode = 10
426
+ return m, tea.Quit
427
+ }
428
+ return m, nil
429
+
430
+ case contextLoadedMsg:
431
+ m.loading = false
432
+ if msg.Err != nil {
433
+ m.err = msg.Err
434
+ m.statusMsg = fmt.Sprintf("Error loading workspace: %v", msg.Err)
435
+ } else {
436
+ m.ctx = msg.Ctx
437
+ m.statusMsg = "Workspace loaded."
438
+
439
+ if !m.logTailerStarted && m.ctx != nil {
440
+ m.logTailerStarted = true
441
+ logPath := filepath.Join(m.ctx.Paths.MetaDir, "dev.log")
442
+ m.StartLogTailer(logPath)
443
+ }
444
+
445
+ return m, m.checkServerStatusCmd()
446
+ }
447
+
448
+ case catalogLoadedMsg:
449
+ m.loading = false
450
+ if msg.Err != nil {
451
+ m.err = msg.Err
452
+ m.statusMsg = fmt.Sprintf("Error loading catalog: %v", msg.Err)
453
+ } else {
454
+ m.catalog = msg.Cat
455
+ m.statusMsg = fmt.Sprintf("Catalog loaded (%s).", msg.Cat.CacheAge)
456
+
457
+ // Run fast local check immediately; schedule remote check too
458
+ m.checkingUpdates = true
459
+ return m, tea.Batch(m.checkLocalChangesCmd(), m.checkRemoteUpdatesCmd())
460
+ }
461
+
462
+ case localChangesMsg:
463
+ m.gitChangesMap = msg.GitChanges
464
+ m.localGitDirs = msg.LocalGitDirs
465
+ m.checkingUpdates = false
466
+ m.loading = false
467
+
468
+ case remoteUpdatesMsg:
469
+ m.updatesMap = msg.Updates
470
+ if msg.Err == nil {
471
+ count := 0
472
+ for _, up := range msg.Updates {
473
+ if up.LocalGit || up.Npm {
474
+ count++
475
+ }
476
+ }
477
+ if count > 0 {
478
+ m.statusMsg = fmt.Sprintf("%d update(s) available.", count)
479
+ }
480
+ }
481
+
482
+ case setupProgressMsg:
483
+ m.setupStep = msg.Step
484
+ m.setupTotalSteps = msg.Total
485
+ m.setupLabel = msg.Label
486
+ return m, m.listenToChannel()
487
+
488
+ case engineQueueUpdatedMsg:
489
+ q := msg.Queue
490
+ m.workQueue = &q
491
+ completed, total := q.Progress()
492
+ m.setupStep = completed
493
+ m.setupTotalSteps = total
494
+ return m, m.listenToChannel()
495
+
496
+ case enginePhaseMsg:
497
+ m.enginePhase = msg.Phase
498
+ return m, m.listenToChannel()
499
+
500
+ case engineNeedsPromptMsg:
501
+ m.promptItem = &msg.Item
502
+ m.promptPkg = &msg.Item.Entry
503
+ m.activePrompt = PromptResolveDependency
504
+ m.promptSel = 0
505
+ m.statusMsg = fmt.Sprintf("Resolve dependency: %s", msg.Item.ShortName)
506
+ return m, m.listenToChannel()
507
+
508
+ case logLineMsg:
509
+ line := string(msg)
510
+ if strings.Contains(line, "█") || strings.Contains(line, "▄") || strings.Contains(line, "▀") {
511
+ return m, m.listenToChannel()
512
+ }
513
+ if strings.Contains(line, "Local:") || strings.Contains(line, "Network:") || strings.Contains(line, "expose") || strings.Contains(line, "➜") {
514
+ return m, m.listenToChannel()
515
+ }
516
+ m.addLog(line)
517
+ return m, m.listenToChannel()
518
+
519
+ case clearLogsMsg:
520
+ m.logLines = []string{}
521
+ return m, m.listenToChannel()
522
+
523
+ case taskFinishedMsg:
524
+ m.activeTask = TaskNone
525
+ m.enginePhase = PhaseIdle
526
+ m.activePrompt = PromptNone
527
+ m.promptItem = nil
528
+ if msg.Success {
529
+ m.statusMsg = "Task completed."
530
+ m.addLog("✓ Wizard completed successfully")
531
+ if m.serverRunning {
532
+ m.addLog("ℹ desktop.config.ts updated — Nuxt restarts automatically (see dev server log)")
533
+ }
534
+
535
+ if m.startServerAfterSetup {
536
+ m.startServerAfterSetup = false
537
+ m.statusMsg = "Starting dev server…"
538
+ m.addLog("ℹ Starting development server…")
539
+ m.activeTask = TaskServe
540
+ m.RunServeTask()
541
+ }
542
+ } else {
543
+ m.statusMsg = fmt.Sprintf("Task failed: %v", msg.Err)
544
+ m.addLog(fmt.Sprintf("✖ Wizard failed: %v", msg.Err))
545
+ m.startServerAfterSetup = false
546
+ }
547
+ return m, tea.Batch(m.loadContextCmd(), m.loadCatalogCmd(false), m.updateWorkspaceGitStatusCmd(), m.listenToChannel())
548
+
549
+ case serverStatusMsg:
550
+ m.serverRunning = msg.Running
551
+ if m.activeTask == TaskServe {
552
+ m.activeTask = TaskNone
553
+ }
554
+ if !msg.Running {
555
+ m.statusMsg = "Dev server stopped."
556
+ } else {
557
+ m.statusMsg = "Dev server running."
558
+ }
559
+ return m, m.listenToChannel()
560
+ case workspaceGitStatusMsg:
561
+ m.workspaceBranch = msg.Branch
562
+ m.workspaceChanges = msg.Changes
563
+ return m, nil
564
+ }
565
+
566
+ return m, nil
567
+ }
568
+
569
+ func (m *TuiModel) addLog(line string) {
570
+ // Split on embedded newlines — Nuxt can emit multi-line chunks as a single string.
571
+ // If we store them unsplit, the panel renders 1 counted line but N terminal lines → layout breaks.
572
+ line = strings.ReplaceAll(line, "\r\n", "\n")
573
+ sublines := strings.Split(line, "\n")
574
+ for _, sl := range sublines {
575
+ sl = strings.TrimRight(sl, "\r")
576
+ if sl == "" {
577
+ continue
578
+ }
579
+ m.logLines = append(m.logLines, sl)
580
+ }
581
+ if len(m.logLines) > 200 {
582
+ m.logLines = m.logLines[len(m.logLines)-200:]
583
+ }
584
+ }
585
+
586
+
587
+ // checkLocalChangesCmd runs a fast local-only git status check for every catalog entry.
588
+ // No network activity — only reads the local working tree.
589
+ // Runs every ~15s. Also detects which packages have their own .git folder.
590
+ func (m *TuiModel) checkLocalChangesCmd() tea.Cmd {
591
+ return func() tea.Msg {
592
+ gitChangesMap := make(map[string]GitChanges)
593
+ localGitDirs := make(map[string]bool)
594
+
595
+ if m.catalog == nil {
596
+ return localChangesMsg{GitChanges: gitChangesMap, LocalGitDirs: localGitDirs}
597
+ }
598
+
599
+ for _, entry := range m.catalog.Entries {
600
+ short := entry.ShortName
601
+ var kindDir string
602
+ switch entry.Kind {
603
+ case "app":
604
+ kindDir = "apps"
605
+ case "module":
606
+ kindDir = "packages"
607
+ case "theme":
608
+ kindDir = "themes"
609
+ }
610
+ if kindDir == "" {
611
+ continue
612
+ }
613
+
614
+ pkgPath := filepath.Join(m.workspaceRoot, kindDir, short)
615
+ gitDir := filepath.Join(pkgPath, ".git")
616
+
617
+ if _, err := os.Stat(gitDir); err == nil {
618
+ // Package has its own .git repo
619
+ localGitDirs[short] = true
620
+ added, modified, deleted, err := runGitStatus(pkgPath)
621
+ if err == nil && (added > 0 || modified > 0 || deleted > 0) {
622
+ gitChangesMap[short] = GitChanges{Added: added, Modified: modified, Deleted: deleted}
623
+ }
624
+ } else {
625
+ // Fallback: check monorepo git for this subdirectory
626
+ parentGitDir := filepath.Join(m.workspaceRoot, ".git")
627
+ if _, err := os.Stat(parentGitDir); err == nil {
628
+ relPath := filepath.Join(kindDir, short)
629
+ added, modified, deleted, err := runGitStatusForSubdir(m.workspaceRoot, relPath)
630
+ if err == nil && (added > 0 || modified > 0 || deleted > 0) {
631
+ gitChangesMap[short] = GitChanges{Added: added, Modified: modified, Deleted: deleted}
632
+ }
633
+ }
634
+ }
635
+ }
636
+
637
+ return localChangesMsg{GitChanges: gitChangesMap, LocalGitDirs: localGitDirs}
638
+ }
639
+ }
640
+
641
+ // checkRemoteUpdatesCmd runs the slow network-dependent checks:
642
+ // - git behind count (uses cached remote refs, NO git fetch)
643
+ // - npm latest version check
644
+ // Runs every ~5 minutes.
645
+ func (m *TuiModel) checkRemoteUpdatesCmd() tea.Cmd {
646
+ return func() tea.Msg {
647
+ updatesMap := make(map[string]UpdateInfo)
648
+
649
+ if m.catalog == nil {
650
+ return remoteUpdatesMsg{Updates: updatesMap}
651
+ }
652
+
653
+ type result struct {
654
+ shortName string
655
+ update *UpdateInfo
656
+ err error
657
+ }
658
+
659
+ resultsChan := make(chan result, len(m.catalog.Entries))
660
+ sem := make(chan struct{}, 5) // modest concurrency — these are network ops
661
+
662
+ for _, e := range m.catalog.Entries {
663
+ sem <- struct{}{}
664
+ go func(entry bridge.CatalogEntry) {
665
+ defer func() { <-sem }()
666
+
667
+ res := result{shortName: entry.ShortName}
668
+
669
+ short := entry.ShortName
670
+ var kindDir string
671
+ switch entry.Kind {
672
+ case "app":
673
+ kindDir = "apps"
674
+ case "module":
675
+ kindDir = "packages"
676
+ case "theme":
677
+ kindDir = "themes"
678
+ }
679
+
680
+ // 1. Git behind check (NO fetch — uses cached remote refs)
681
+ if kindDir != "" {
682
+ pkgPath := filepath.Join(m.workspaceRoot, kindDir, short)
683
+ gitDir := filepath.Join(pkgPath, ".git")
684
+ if _, err := os.Stat(gitDir); err == nil {
685
+ behind, err := runGitBehindCheckNoFetch(pkgPath)
686
+ if err == nil && behind > 0 {
687
+ res.update = &UpdateInfo{LocalGit: true, BehindCount: behind}
688
+ }
689
+ }
690
+ }
691
+
692
+ // 2. NPM latest version check
693
+ if entry.Installed && !entry.LocalSource {
694
+ localVer := getLocalVersion(m.workspaceRoot, entry)
695
+ if localVer != "" {
696
+ latestVer, err := fetchLatestNpmVersion(entry.Name)
697
+ if err != nil {
698
+ res.err = err
699
+ } else if semverCompare(localVer, latestVer) < 0 {
700
+ res.update = &UpdateInfo{Npm: true}
701
+ }
702
+ }
703
+ }
704
+
705
+ resultsChan <- res
706
+ }(e)
707
+ }
708
+
709
+ var checkErr error
710
+ for i := 0; i < len(m.catalog.Entries); i++ {
711
+ res := <-resultsChan
712
+ if res.update != nil {
713
+ updatesMap[res.shortName] = *res.update
714
+ }
715
+ if res.err != nil && checkErr == nil {
716
+ checkErr = res.err
717
+ }
718
+ }
719
+
720
+ return remoteUpdatesMsg{Updates: updatesMap, Err: checkErr}
721
+ }
722
+ }
723
+
724
+ func getLocalVersion(workspaceRoot string, entry bridge.CatalogEntry) string {
725
+ short := entry.ShortName
726
+ kindDir := ""
727
+ switch entry.Kind {
728
+ case "app":
729
+ kindDir = "apps"
730
+ case "module":
731
+ kindDir = "packages"
732
+ case "theme":
733
+ kindDir = "themes"
734
+ }
735
+ var paths []string
736
+ if kindDir != "" {
737
+ paths = append(paths, filepath.Join(workspaceRoot, kindDir, short, "package.json"))
738
+ }
739
+ paths = append(paths, filepath.Join(workspaceRoot, "node_modules", entry.Name, "package.json"))
740
+
741
+ for _, p := range paths {
742
+ if data, err := os.ReadFile(p); err == nil {
743
+ var pkg struct {
744
+ Version string `json:"version"`
745
+ }
746
+ if err := json.Unmarshal(data, &pkg); err == nil {
747
+ return pkg.Version
748
+ }
749
+ }
750
+ }
751
+ return ""
752
+ }
753
+
754
+ func semverCompare(v1, v2 string) int {
755
+ v1 = strings.TrimPrefix(v1, "v")
756
+ v2 = strings.TrimPrefix(v2, "v")
757
+ parts1 := strings.Split(v1, ".")
758
+ parts2 := strings.Split(v2, ".")
759
+ for i := 0; i < len(parts1) && i < len(parts2); i++ {
760
+ var n1, n2 int
761
+ fmt.Sscanf(parts1[i], "%d", &n1)
762
+ fmt.Sscanf(parts2[i], "%d", &n2)
763
+ if n1 != n2 {
764
+ return n1 - n2
765
+ }
766
+ }
767
+ return len(parts1) - len(parts2)
768
+ }
769
+
770
+ func (m *TuiModel) loadPackageDetails(pkg *bridge.CatalogEntry) {
771
+ m.promptPkgDeps = []string{}
772
+ m.promptPkgHasPrimevue = false
773
+ m.promptPkgHasTailwind = false
774
+
775
+ if pkg == nil {
776
+ return
777
+ }
778
+
779
+ short := pkg.ShortName
780
+ var pathsToCheck []string
781
+
782
+ kindDir := ""
783
+ switch pkg.Kind {
784
+ case "app":
785
+ kindDir = "apps"
786
+ case "module":
787
+ kindDir = "packages"
788
+ case "theme":
789
+ kindDir = "themes"
790
+ }
791
+
792
+ if kindDir != "" {
793
+ pathsToCheck = append(pathsToCheck, filepath.Join(m.workspaceRoot, kindDir, short, "package.json"))
794
+ }
795
+ pathsToCheck = append(pathsToCheck, filepath.Join(m.workspaceRoot, "node_modules", "@owdproject", short, "package.json"))
796
+
797
+ var data []byte
798
+ var err error
799
+ for _, p := range pathsToCheck {
800
+ data, err = os.ReadFile(p)
801
+ if err == nil {
802
+ break
803
+ }
804
+ }
805
+
806
+ if err != nil {
807
+ return
808
+ }
809
+
810
+ var pkgJson struct {
811
+ Dependencies map[string]string `json:"dependencies"`
812
+ DevDependencies map[string]string `json:"devDependencies"`
813
+ }
814
+
815
+ if err := json.Unmarshal(data, &pkgJson); err == nil {
816
+ checkDep := func(name string) {
817
+ if strings.HasPrefix(name, "@owdproject/") {
818
+ for _, d := range m.promptPkgDeps {
819
+ if d == name {
820
+ return
821
+ }
822
+ }
823
+ m.promptPkgDeps = append(m.promptPkgDeps, name)
824
+ }
825
+ if name == "@owdproject/kit-primevue" {
826
+ m.promptPkgHasPrimevue = true
827
+ }
828
+ if name == "@owdproject/kit-tailwind" || name == "tailwindcss" {
829
+ m.promptPkgHasTailwind = true
830
+ }
831
+ }
832
+ for name := range pkgJson.Dependencies {
833
+ checkDep(name)
834
+ }
835
+ for name := range pkgJson.DevDependencies {
836
+ checkDep(name)
837
+ }
838
+ }
839
+ }
840
+
841
+ func (m *TuiModel) isClearLogsClick(msg tea.MouseMsg) bool {
842
+ if msg.Y != 0 {
843
+ return false
844
+ }
845
+
846
+ w := m.termWidth
847
+ if w < 120 {
848
+ return false
849
+ }
850
+
851
+ leftW := w * 40 / 100
852
+ midW := w * 28 / 100
853
+ catW := leftW + midW
854
+
855
+ panelStartX := catW - 1
856
+
857
+ rightPanelTitle := "Logs"
858
+ if m.activePrompt == PromptResolveDependency {
859
+ rightPanelTitle = "Wizard Setup"
860
+ } else if m.activeTask == TaskSetup || m.activeTask == TaskWipe {
861
+ rightPanelTitle = "Setup"
862
+ }
863
+
864
+ prefixLen := len(rightPanelTitle) + 2
865
+ clearStartX := panelStartX + 2 + prefixLen
866
+ clearEndX := clearStartX + 7
867
+
868
+ return msg.X >= clearStartX && msg.X < clearEndX
869
+ }