@owdproject/cli 0.1.0 → 0.2.1

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,850 @@
1
+ package tui
2
+
3
+ import (
4
+ "bufio"
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ "os"
9
+ "os/exec"
10
+ "path/filepath"
11
+ "strings"
12
+ "syscall"
13
+ "time"
14
+
15
+ "owd-cli/bridge"
16
+
17
+ tea "github.com/charmbracelet/bubbletea"
18
+ )
19
+
20
+ // ─────────────────────────────────────────────
21
+ // Background tasks
22
+ // ─────────────────────────────────────────────
23
+
24
+ type ProcessResult struct {
25
+ ExitCode int
26
+ Err error
27
+ }
28
+
29
+ func (r *RuntimeState) runProcessStream(cwd, command string, args []string) ProcessResult {
30
+ cmdLine := command + " " + strings.Join(args, " ")
31
+ r.msgChan <- logLineMsg("ℹ " + cmdLine)
32
+
33
+ cmd := exec.Command(command, args...)
34
+ cmd.Dir = cwd
35
+
36
+ stdout, err := cmd.StdoutPipe()
37
+ if err != nil {
38
+ r.msgChan <- logLineMsg(fmt.Sprintf("✖ Failed to pipe stdout: %v", err))
39
+ return ProcessResult{ExitCode: 1, Err: err}
40
+ }
41
+ cmd.Stderr = cmd.Stdout
42
+
43
+ if err := cmd.Start(); err != nil {
44
+ r.msgChan <- logLineMsg(fmt.Sprintf("✖ Failed to start process: %v", err))
45
+ return ProcessResult{ExitCode: 1, Err: err}
46
+ }
47
+
48
+ reader := bufio.NewReader(stdout)
49
+ for {
50
+ line, err := reader.ReadString('\n')
51
+ if err != nil {
52
+ break
53
+ }
54
+ r.msgChan <- logLineMsg(strings.TrimRight(line, "\r\n"))
55
+ }
56
+
57
+ err = cmd.Wait()
58
+ exitCode := 0
59
+ if err != nil {
60
+ if exitErr, ok := err.(*exec.ExitError); ok {
61
+ exitCode = exitErr.ExitCode()
62
+ } else {
63
+ exitCode = 1
64
+ }
65
+ r.msgChan <- logLineMsg(fmt.Sprintf("✖ Command failed (exit %d): %v", exitCode, err))
66
+ } else {
67
+ r.msgChan <- logLineMsg(fmt.Sprintf("✔ Command completed (exit 0)"))
68
+ }
69
+ return ProcessResult{ExitCode: exitCode, Err: err}
70
+ }
71
+
72
+ func (r *RuntimeState) runProcessAndStreamLogs(root, command string, args []string) {
73
+ result := r.runProcessStream(root, command, args)
74
+ r.msgChan <- taskFinishedMsg{Success: result.Err == nil, Err: result.Err}
75
+ }
76
+
77
+ func (r *RuntimeState) runProcessAndStreamLogsSilent(root, command string, args []string) error {
78
+ result := r.runProcessStream(root, command, args)
79
+ return result.Err
80
+ }
81
+
82
+ func (m *TuiModel) RunSetupTask(adds map[string]string) {
83
+ workspaceRoot := m.workspaceRoot
84
+ msgChan := m.runtime.msgChan
85
+ runtime := m.runtime
86
+
87
+ var catalogEntries []bridge.CatalogEntry
88
+ if m.catalog != nil {
89
+ catalogEntries = make([]bridge.CatalogEntry, len(m.catalog.Entries))
90
+ copy(catalogEntries, m.catalog.Entries)
91
+ }
92
+
93
+ currentBatch := make(map[string]string)
94
+ for k, v := range adds {
95
+ currentBatch[k] = v
96
+ }
97
+
98
+ cloneCount := 0
99
+ for _, method := range adds {
100
+ if method == "git-https" || method == "git-ssh" {
101
+ cloneCount++
102
+ }
103
+ }
104
+ totalSteps := cloneCount + 2
105
+ step := 0
106
+
107
+ go func() {
108
+ msgChan <- setupProgressMsg{Step: step, Total: totalSteps, Label: "Initializing…"}
109
+
110
+ // clonedDirs tracks successfully cloned target directories for dep scanning in this batch
111
+ type clonedInfo struct {
112
+ shortName string
113
+ targetDir string
114
+ pkgName string
115
+ }
116
+ var clonedDirs []clonedInfo
117
+
118
+ // ── Phase 1: Clone each package in the current batch ──────────────────
119
+ for pkgName, method := range currentBatch {
120
+ shortName := pkgName
121
+ if idx := strings.LastIndex(pkgName, "/"); idx >= 0 {
122
+ shortName = pkgName[idx+1:]
123
+ }
124
+
125
+ if method == "npm" {
126
+ msgChan <- logLineMsg(fmt.Sprintf("ℹ %s will be installed via npm (pnpm install)", shortName))
127
+ continue
128
+ }
129
+ if method == "local" {
130
+ msgChan <- logLineMsg(fmt.Sprintf("ℹ %s already available as local workspace package", shortName))
131
+ continue
132
+ }
133
+
134
+ // Determine target directory
135
+ kindDir := kindDirForEntry(pkgName, shortName, catalogEntries)
136
+ targetDir := filepath.Join(workspaceRoot, kindDir, shortName)
137
+
138
+ // Skip if already cloned
139
+ if _, err := os.Stat(filepath.Join(targetDir, "package.json")); err == nil {
140
+ msgChan <- logLineMsg(fmt.Sprintf("ℹ %s already cloned — skipping", shortName))
141
+ clonedDirs = append(clonedDirs, clonedInfo{shortName, targetDir, pkgName})
142
+ continue
143
+ }
144
+
145
+ step++
146
+ msgChan <- setupProgressMsg{Step: step, Total: totalSteps, Label: fmt.Sprintf("Cloning %s…", shortName)}
147
+
148
+ // Resolve git URL
149
+ owner := resolveOwner(pkgName, catalogEntries)
150
+ var gitURL string
151
+ if method == "git-ssh" {
152
+ gitURL = fmt.Sprintf("git@github.com:%s/%s.git", owner, shortName)
153
+ } else {
154
+ gitURL = fmt.Sprintf("https://github.com/%s/%s.git", owner, shortName)
155
+ }
156
+
157
+ msgChan <- logLineMsg(fmt.Sprintf("ℹ Cloning %s from %s", shortName, gitURL))
158
+ if err := runtime.runProcessAndStreamLogsSilent(workspaceRoot, "git", []string{"clone", gitURL, targetDir}); err != nil {
159
+ msgChan <- logLineMsg(fmt.Sprintf("✗ Clone failed for %s: %v", shortName, err))
160
+ msgChan <- taskFinishedMsg{Success: false, Err: err}
161
+ return
162
+ }
163
+ msgChan <- logLineMsg(fmt.Sprintf("✓ %s cloned", shortName))
164
+ clonedDirs = append(clonedDirs, clonedInfo{shortName, targetDir, pkgName})
165
+ }
166
+
167
+ // ── Phase 1.5: Recursively scan and clone missing workspace dependencies ──
168
+ msgChan <- setupProgressMsg{Step: step, Total: totalSteps, Label: "Scanning package dependencies…"}
169
+
170
+ logFunc := func(line string) { msgChan <- logLineMsg(line) }
171
+ if err := ResolveAndCloneMissingDependencies(workspaceRoot, catalogEntries, runtime, logFunc, msgChan, &step, &totalSteps); err != nil {
172
+ msgChan <- taskFinishedMsg{Success: false, Err: err}
173
+ return
174
+ }
175
+
176
+ // ── Phase 2: pnpm install ─────────────────────────────────────────────
177
+ step++
178
+ msgChan <- setupProgressMsg{Step: step, Total: totalSteps, Label: "Installing dependencies (pnpm install)…"}
179
+ msgChan <- logLineMsg("ℹ Running pnpm install (syncing workspace)…")
180
+ if err := runtime.runProcessAndStreamLogsSilent(workspaceRoot, "pnpm", []string{"install"}); err != nil {
181
+ msgChan <- taskFinishedMsg{Success: false, Err: err}
182
+ return
183
+ }
184
+
185
+ // ── Phase 3: Rebuild stubs ────────────────────────────────────────────
186
+ step++
187
+ msgChan <- setupProgressMsg{Step: step, Total: totalSteps, Label: "Rebuilding stubs (prepare:modules)…"}
188
+ msgChan <- logLineMsg("ℹ Rebuilding stubs…")
189
+ if err := runtime.runProcessAndStreamLogsSilent(workspaceRoot, "pnpm", []string{"run", "prepare:modules"}); err != nil {
190
+ msgChan <- taskFinishedMsg{Success: false, Err: err}
191
+ return
192
+ }
193
+
194
+ time.Sleep(500 * time.Millisecond)
195
+ msgChan <- taskFinishedMsg{Success: true}
196
+ }()
197
+ }
198
+
199
+ // kindDirForEntry resolves the workspace subdirectory for a package.
200
+ // Uses catalog entries first, falls back to shortname prefix.
201
+ func kindDirForEntry(pkgName, shortName string, entries []bridge.CatalogEntry) string {
202
+ for _, e := range entries {
203
+ if e.Name == pkgName {
204
+ switch e.Kind {
205
+ case "app":
206
+ return "apps"
207
+ case "module":
208
+ return "packages"
209
+ case "theme":
210
+ return "themes"
211
+ }
212
+ }
213
+ }
214
+ return kindDirForShortName(shortName)
215
+ }
216
+
217
+ // kindDirForShortName infers the workspace subdirectory from the package short name.
218
+ func kindDirForShortName(shortName string) string {
219
+ switch {
220
+ case strings.HasPrefix(shortName, "app-"):
221
+ return "apps"
222
+ case strings.HasPrefix(shortName, "theme-"):
223
+ return "themes"
224
+ default:
225
+ return "packages" // module-*, kit-*, and anything else
226
+ }
227
+ }
228
+
229
+ // resolveOwner returns the GitHub owner to use for cloning.
230
+ // Priority: fork owner (if fork is confirmed to exist) > catalog org > "owdproject".
231
+ func resolveOwner(pkgName string, entries []bridge.CatalogEntry) string {
232
+ var entry *bridge.CatalogEntry
233
+ for i := range entries {
234
+ if entries[i].Name == pkgName {
235
+ entry = &entries[i]
236
+ break
237
+ }
238
+ }
239
+ if entry != nil && entry.SourcesMeta != nil && entry.SourcesMeta.Github.Fork != nil && entry.SourcesMeta.Github.Fork.Exists && entry.SourcesMeta.Github.Fork.IsFork {
240
+ return entry.SourcesMeta.Github.Fork.Owner
241
+ }
242
+ if entry != nil && entry.Org != "" && entry.Org != "workspace" {
243
+ return entry.Org
244
+ }
245
+ return "owdproject"
246
+ }
247
+
248
+
249
+ func (m *TuiModel) RunUpdatePackageTask(pkgName string, shortName string, kind string, isLocalSource bool) {
250
+ workspaceRoot := m.workspaceRoot
251
+ runtime := m.runtime
252
+
253
+ go func() {
254
+ runtime.msgChan <- clearLogsMsg{}
255
+ runtime.msgChan <- logLineMsg(fmt.Sprintf("ℹ Starting update for %s…", shortName))
256
+ runtime.msgChan <- setupProgressMsg{Step: 1, Total: 3, Label: fmt.Sprintf("Updating %s…", shortName)}
257
+
258
+ desktopJs := filepath.Join(workspaceRoot, "packages", "cli", "bin", "desktop.js")
259
+
260
+ if isLocalSource {
261
+ kindDir := ""
262
+ switch kind {
263
+ case "app":
264
+ kindDir = "apps"
265
+ case "module":
266
+ kindDir = "packages"
267
+ case "theme":
268
+ kindDir = "themes"
269
+ }
270
+
271
+ if kindDir != "" {
272
+ pkgPath := filepath.Join(workspaceRoot, kindDir, shortName)
273
+ runtime.msgChan <- logLineMsg(fmt.Sprintf("ℹ Running git pull in %s…", pkgPath))
274
+ if err := runtime.runProcessAndStreamLogsSilent(pkgPath, "git", []string{"pull"}); err != nil {
275
+ runtime.msgChan <- logLineMsg(fmt.Sprintf("✗ Git pull failed: %v", err))
276
+ runtime.msgChan <- taskFinishedMsg{Success: false, Err: err}
277
+ return
278
+ }
279
+ }
280
+ } else {
281
+ runtime.msgChan <- logLineMsg(fmt.Sprintf("ℹ Re-installing %s from NPM to get latest version…", shortName))
282
+ args := []string{desktopJs, "add", shortName, "--npm"}
283
+ if err := runtime.runProcessAndStreamLogsSilent(workspaceRoot, "node", args); err != nil {
284
+ runtime.msgChan <- logLineMsg(fmt.Sprintf("✗ NPM update failed: %v", err))
285
+ runtime.msgChan <- taskFinishedMsg{Success: false, Err: err}
286
+ return
287
+ }
288
+ }
289
+
290
+ // Run pnpm install
291
+ runtime.msgChan <- setupProgressMsg{Step: 2, Total: 3, Label: "Installing dependencies (pnpm install)…"}
292
+ runtime.msgChan <- logLineMsg("ℹ Running pnpm install (syncing workspace)…")
293
+ if err := runtime.runProcessAndStreamLogsSilent(workspaceRoot, "pnpm", []string{"install"}); err != nil {
294
+ runtime.msgChan <- taskFinishedMsg{Success: false, Err: err}
295
+ return
296
+ }
297
+
298
+ // Rebuild stubs
299
+ runtime.msgChan <- setupProgressMsg{Step: 3, Total: 3, Label: "Rebuilding stubs (prepare:modules)…"}
300
+ runtime.msgChan <- logLineMsg("ℹ Rebuilding stubs…")
301
+ if err := runtime.runProcessAndStreamLogsSilent(workspaceRoot, "pnpm", []string{"run", "prepare:modules"}); err != nil {
302
+ runtime.msgChan <- taskFinishedMsg{Success: false, Err: err}
303
+ return
304
+ }
305
+
306
+ runtime.msgChan <- taskFinishedMsg{Success: true}
307
+ }()
308
+ }
309
+
310
+ func (m *TuiModel) RunServeTask() {
311
+ var metaDir string
312
+ var isPlayground bool
313
+ var packageDir string
314
+ var devPort int
315
+
316
+ if m.ctx != nil {
317
+ metaDir = m.ctx.Paths.MetaDir
318
+ isPlayground = m.ctx.Paths.IsPlayground
319
+ packageDir = m.ctx.Paths.PackageDir
320
+ devPort = m.ctx.Settings.DevPort
321
+ }
322
+ workspaceRoot := m.workspaceRoot
323
+ runtime := m.runtime
324
+
325
+ go func() {
326
+ if metaDir == "" {
327
+ runtime.msgChan <- serverStatusMsg{Running: false}
328
+ return
329
+ }
330
+
331
+ _ = os.MkdirAll(metaDir, 0755)
332
+
333
+ logPath := filepath.Join(metaDir, "dev.log")
334
+ logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
335
+ if err != nil {
336
+ runtime.msgChan <- logLineMsg(fmt.Sprintf("✗ Failed to open log file: %v", err))
337
+ runtime.msgChan <- serverStatusMsg{Running: false}
338
+ return
339
+ }
340
+ defer logFile.Close()
341
+
342
+ logFile.WriteString("ℹ Starting Nuxt dev server (pnpm run dev)…\n")
343
+
344
+ cmd := exec.Command("pnpm", "run", "dev")
345
+ if isPlayground {
346
+ cmd.Dir = packageDir
347
+ } else {
348
+ cmd.Dir = workspaceRoot
349
+ }
350
+
351
+ if devPort == 0 {
352
+ devPort = 3000
353
+ }
354
+ cmd.Env = append(os.Environ(), fmt.Sprintf("PORT=%d", devPort))
355
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
356
+ cmd.Stdout = logFile
357
+ cmd.Stderr = logFile
358
+
359
+ runtime.serverMu.Lock()
360
+ runtime.serverCmd = cmd
361
+ runtime.serverMu.Unlock()
362
+
363
+ if err := cmd.Start(); err != nil {
364
+ logFile.WriteString(fmt.Sprintf("✗ Server failed to start: %v\n", err))
365
+ runtime.msgChan <- serverStatusMsg{Running: false}
366
+ return
367
+ }
368
+
369
+ // Write PID file
370
+ pidPath := filepath.Join(metaDir, "dev.pid")
371
+ _ = os.WriteFile(pidPath, []byte(fmt.Sprintf("%d", cmd.Process.Pid)), 0644)
372
+
373
+ runtime.msgChan <- serverStatusMsg{Running: true}
374
+
375
+ cmd.Wait()
376
+ logFile.WriteString("ℹ Dev server exited.\n")
377
+ runtime.msgChan <- serverStatusMsg{Running: false}
378
+
379
+ runtime.serverMu.Lock()
380
+ runtime.serverCmd = nil
381
+ runtime.serverMu.Unlock()
382
+ }()
383
+ }
384
+
385
+ func stopServeTaskSync(workspaceRoot, metaDir string, runtime *RuntimeState) {
386
+ if metaDir == "" {
387
+ runtime.msgChan <- serverStatusMsg{Running: false}
388
+ return
389
+ }
390
+
391
+ runtime.serverMu.Lock()
392
+ cmd := runtime.serverCmd
393
+ runtime.serverCmd = nil
394
+ runtime.serverMu.Unlock()
395
+
396
+ if cmd != nil && cmd.Process != nil {
397
+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
398
+ }
399
+
400
+ pidPath := filepath.Join(metaDir, "dev.pid")
401
+ if data, err := os.ReadFile(pidPath); err == nil {
402
+ var pid int
403
+ if _, err := fmt.Sscanf(strings.TrimSpace(string(data)), "%d", &pid); err == nil && pid > 0 {
404
+ _ = syscall.Kill(-pid, syscall.SIGKILL)
405
+ _ = syscall.Kill(pid, syscall.SIGKILL)
406
+ }
407
+ _ = os.Remove(pidPath)
408
+ }
409
+
410
+ // Safe, scoped process cleanup targeting processes for this workspace
411
+ killWorkspaceProcesses(workspaceRoot)
412
+
413
+ runtime.msgChan <- serverStatusMsg{Running: false}
414
+ }
415
+
416
+ func (m *TuiModel) StopServeTask() {
417
+ var metaDir string
418
+ if m.ctx != nil {
419
+ metaDir = m.ctx.Paths.MetaDir
420
+ }
421
+ workspaceRoot := m.workspaceRoot
422
+ runtime := m.runtime
423
+
424
+ go func() {
425
+ stopServeTaskSync(workspaceRoot, metaDir, runtime)
426
+ }()
427
+ }
428
+
429
+ func (m *TuiModel) StopLogTailer() {
430
+ m.runtime.serverMu.Lock()
431
+ defer m.runtime.serverMu.Unlock()
432
+ if m.runtime.logCancel != nil {
433
+ m.runtime.logCancel()
434
+ m.runtime.logCancel = nil
435
+ }
436
+ }
437
+
438
+ func (m *TuiModel) StartLogTailer(logPath string) {
439
+ m.StopLogTailer()
440
+
441
+ m.runtime.serverMu.Lock()
442
+ ctx, cancel := context.WithCancel(context.Background())
443
+ m.runtime.logCancel = cancel
444
+ m.runtime.serverMu.Unlock()
445
+
446
+ runtime := m.runtime
447
+
448
+ go func() {
449
+ var lastSize int64 = 0
450
+
451
+ // Check if file exists and get its initial size
452
+ if info, err := os.Stat(logPath); err == nil {
453
+ lastSize = info.Size()
454
+ // Seek back up to 10000 bytes (~100 lines) to show initial history
455
+ offset := lastSize - 10000
456
+ if offset < 0 {
457
+ offset = 0
458
+ }
459
+
460
+ file, err := os.Open(logPath)
461
+ if err == nil {
462
+ _, _ = file.Seek(offset, 0)
463
+ reader := bufio.NewReader(file)
464
+ if offset > 0 {
465
+ _, _ = reader.ReadString('\n') // discard first potentially cut line
466
+ }
467
+ for {
468
+ select {
469
+ case <-ctx.Done():
470
+ file.Close()
471
+ return
472
+ default:
473
+ }
474
+ line, err := reader.ReadString('\n')
475
+ if err != nil {
476
+ break
477
+ }
478
+ runtime.msgChan <- logLineMsg(strings.TrimRight(line, "\r\n"))
479
+ }
480
+ file.Close()
481
+ }
482
+ }
483
+
484
+ for {
485
+ select {
486
+ case <-ctx.Done():
487
+ return
488
+ default:
489
+ }
490
+
491
+ time.Sleep(250 * time.Millisecond)
492
+
493
+ info, err := os.Stat(logPath)
494
+ if err != nil {
495
+ continue
496
+ }
497
+
498
+ if info.Size() < lastSize {
499
+ // File was truncated/cleared
500
+ lastSize = info.Size()
501
+ runtime.msgChan <- clearLogsMsg{}
502
+ continue
503
+ }
504
+
505
+ if info.Size() > lastSize {
506
+ file, err := os.Open(logPath)
507
+ if err != nil {
508
+ continue
509
+ }
510
+ _, err = file.Seek(lastSize, 0)
511
+ if err != nil {
512
+ file.Close()
513
+ continue
514
+ }
515
+
516
+ reader := bufio.NewReader(file)
517
+ for {
518
+ select {
519
+ case <-ctx.Done():
520
+ file.Close()
521
+ return
522
+ default:
523
+ }
524
+ line, err := reader.ReadString('\n')
525
+ if err != nil {
526
+ break
527
+ }
528
+ runtime.msgChan <- logLineMsg(strings.TrimRight(line, "\r\n"))
529
+ }
530
+ lastSize = info.Size()
531
+ file.Close()
532
+ }
533
+ }
534
+ }()
535
+ }
536
+
537
+ func (m *TuiModel) RunWipeWorkspaceTask() {
538
+ workspaceRoot := m.workspaceRoot
539
+ runtime := m.runtime
540
+
541
+ go func() {
542
+ runtime.msgChan <- clearLogsMsg{}
543
+ runtime.msgChan <- logLineMsg("ℹ Starting workspace reset task…")
544
+
545
+ totalSteps := 7
546
+ step := 0
547
+
548
+ // Step 1: Clean apps/
549
+ step++
550
+ runtime.msgChan <- setupProgressMsg{Step: step, Total: totalSteps, Label: "Cleaning apps directory…"}
551
+ runtime.msgChan <- logLineMsg("ℹ Cleaning apps/ directory…")
552
+ if err := wipeDirectory(filepath.Join(workspaceRoot, "apps"), nil); err != nil {
553
+ runtime.msgChan <- logLineMsg(fmt.Sprintf("✗ Error cleaning apps: %v", err))
554
+ runtime.msgChan <- taskFinishedMsg{Success: false, Err: err}
555
+ return
556
+ }
557
+
558
+ // Step 2: Clean themes/
559
+ step++
560
+ runtime.msgChan <- setupProgressMsg{Step: step, Total: totalSteps, Label: "Cleaning themes directory…"}
561
+ runtime.msgChan <- logLineMsg("ℹ Cleaning themes/ directory…")
562
+ if err := wipeDirectory(filepath.Join(workspaceRoot, "themes"), nil); err != nil {
563
+ runtime.msgChan <- logLineMsg(fmt.Sprintf("✗ Error cleaning themes: %v", err))
564
+ runtime.msgChan <- taskFinishedMsg{Success: false, Err: err}
565
+ return
566
+ }
567
+
568
+ // Step 3: Clean packages/ (preserving core, cli, nx)
569
+ step++
570
+ runtime.msgChan <- setupProgressMsg{Step: step, Total: totalSteps, Label: "Cleaning packages directory…"}
571
+ runtime.msgChan <- logLineMsg("ℹ Cleaning packages/ directory (preserving core, cli, nx)…")
572
+ preservePkgs := map[string]bool{"core": true, "cli": true, "nx": true}
573
+ if err := wipeDirectory(filepath.Join(workspaceRoot, "packages"), preservePkgs); err != nil {
574
+ runtime.msgChan <- logLineMsg(fmt.Sprintf("✗ Error cleaning packages: %v", err))
575
+ runtime.msgChan <- taskFinishedMsg{Success: false, Err: err}
576
+ return
577
+ }
578
+
579
+ // Step 4: Reset desktop.config.ts
580
+ step++
581
+ runtime.msgChan <- setupProgressMsg{Step: step, Total: totalSteps, Label: "Resetting desktop config…"}
582
+ runtime.msgChan <- logLineMsg("ℹ Resetting desktop/desktop.config.ts to default…")
583
+ defaultConfig := `import { defineDesktopConfig } from '@owdproject/core'
584
+
585
+ export default defineDesktopConfig({
586
+ theme: '',
587
+ apps: [],
588
+ modules: [],
589
+ })
590
+ `
591
+ configPath := filepath.Join(workspaceRoot, "desktop", "desktop.config.ts")
592
+ if err := os.WriteFile(configPath, []byte(defaultConfig), 0644); err != nil {
593
+ runtime.msgChan <- logLineMsg(fmt.Sprintf("✗ Error resetting desktop config: %v", err))
594
+ runtime.msgChan <- taskFinishedMsg{Success: false, Err: err}
595
+ return
596
+ }
597
+
598
+ // Step 5: Reset package.json
599
+ step++
600
+ runtime.msgChan <- setupProgressMsg{Step: step, Total: totalSteps, Label: "Resetting package dependencies…"}
601
+ runtime.msgChan <- logLineMsg("ℹ Resetting desktop/package.json dependencies to core only…")
602
+ defaultPackageJson := `{
603
+ "name": "@owdproject/client",
604
+ "private": true,
605
+ "nx": {
606
+ "name": "desktop"
607
+ },
608
+ "scripts": {
609
+ "build": "nuxt generate",
610
+ "dev": "nuxt dev --host",
611
+ "generate": "nuxt generate --dev",
612
+ "postinstall": "nuxt prepare",
613
+ "preview": "nuxt preview",
614
+ "desktop": "desktop"
615
+ },
616
+ "dependencies": {
617
+ "@owdproject/core": "workspace:*"
618
+ }
619
+ }
620
+ `
621
+ pkgJsonPath := filepath.Join(workspaceRoot, "desktop", "package.json")
622
+ if err := os.WriteFile(pkgJsonPath, []byte(defaultPackageJson), 0644); err != nil {
623
+ runtime.msgChan <- logLineMsg(fmt.Sprintf("✗ Error resetting package.json: %v", err))
624
+ runtime.msgChan <- taskFinishedMsg{Success: false, Err: err}
625
+ return
626
+ }
627
+
628
+ // Step 6: Run pnpm install
629
+ step++
630
+ runtime.msgChan <- setupProgressMsg{Step: step, Total: totalSteps, Label: "Installing dependencies (pnpm install)…"}
631
+ runtime.msgChan <- logLineMsg("ℹ Running pnpm install (syncing workspace)…")
632
+ if err := runtime.runProcessAndStreamLogsSilent(workspaceRoot, "pnpm", []string{"install"}); err != nil {
633
+ runtime.msgChan <- logLineMsg(fmt.Sprintf("✗ Error running pnpm install: %v", err))
634
+ runtime.msgChan <- taskFinishedMsg{Success: false, Err: err}
635
+ return
636
+ }
637
+
638
+ // Step 7: Run prepare:modules
639
+ step++
640
+ runtime.msgChan <- setupProgressMsg{Step: step, Total: totalSteps, Label: "Rebuilding stubs (prepare:modules)…"}
641
+ runtime.msgChan <- logLineMsg("ℹ Rebuilding stubs…")
642
+ if err := runtime.runProcessAndStreamLogsSilent(workspaceRoot, "pnpm", []string{"run", "prepare:modules"}); err != nil {
643
+ runtime.msgChan <- logLineMsg(fmt.Sprintf("✗ Error running prepare:modules: %v", err))
644
+ runtime.msgChan <- taskFinishedMsg{Success: false, Err: err}
645
+ return
646
+ }
647
+
648
+ runtime.msgChan <- taskFinishedMsg{Success: true}
649
+ }()
650
+ }
651
+
652
+ // killWorkspaceProcesses kills any target processes (node, nuxt, etc.) that are associated with the workspaceRoot
653
+ func killWorkspaceProcesses(workspaceRoot string) {
654
+ entries, err := os.ReadDir("/proc")
655
+ if err != nil {
656
+ return
657
+ }
658
+ myPid := os.Getpid()
659
+ for _, entry := range entries {
660
+ if !entry.IsDir() {
661
+ continue
662
+ }
663
+ name := entry.Name()
664
+ var pid int
665
+ if _, err := fmt.Sscanf(name, "%d", &pid); err != nil || pid <= 0 {
666
+ continue
667
+ }
668
+ if pid == myPid {
669
+ continue
670
+ }
671
+
672
+ cmdlinePath := fmt.Sprintf("/proc/%d/cmdline", pid)
673
+ data, err := os.ReadFile(cmdlinePath)
674
+ if err != nil {
675
+ continue
676
+ }
677
+
678
+ args := strings.Split(string(data), "\x00")
679
+ if len(args) == 0 || args[0] == "" {
680
+ continue
681
+ }
682
+
683
+ hasWorkspace := false
684
+ for _, arg := range args {
685
+ if strings.Contains(arg, workspaceRoot) {
686
+ hasWorkspace = true
687
+ break
688
+ }
689
+ }
690
+
691
+ if !hasWorkspace {
692
+ continue
693
+ }
694
+
695
+ exe := args[0]
696
+ isTarget := false
697
+ targets := []string{"node", "nuxt", "nuxi", "pnpm", "vite", "nitro", "next"}
698
+ for _, t := range targets {
699
+ if strings.Contains(strings.ToLower(exe), t) {
700
+ isTarget = true
701
+ break
702
+ }
703
+ }
704
+
705
+ if !isTarget {
706
+ for _, arg := range args {
707
+ for _, t := range targets {
708
+ if strings.Contains(strings.ToLower(arg), t) {
709
+ isTarget = true
710
+ break
711
+ }
712
+ }
713
+ if isTarget {
714
+ break
715
+ }
716
+ }
717
+ }
718
+
719
+ if isTarget {
720
+ // Kill process group, then the process itself
721
+ _ = syscall.Kill(-pid, syscall.SIGKILL)
722
+ _ = syscall.Kill(pid, syscall.SIGKILL)
723
+ }
724
+ }
725
+ }
726
+
727
+ // ResolveAndCloneMissingDependencies recursively scans the workspace for any missing workspace:* dependencies and clones them.
728
+ func ResolveAndCloneMissingDependencies(
729
+ workspaceRoot string,
730
+ catalogEntries []bridge.CatalogEntry,
731
+ runtime *RuntimeState,
732
+ log func(string),
733
+ msgChan chan<- tea.Msg,
734
+ step *int,
735
+ totalSteps *int,
736
+ ) error {
737
+ inWorkspace := map[string]bool{}
738
+
739
+ scanWorkspace := func() ([]string, error) {
740
+ localFolders := []string{filepath.Join(workspaceRoot, "desktop")}
741
+ dirs := []string{"apps", "packages", "themes"}
742
+ for _, d := range dirs {
743
+ p := filepath.Join(workspaceRoot, d)
744
+ entries, err := os.ReadDir(p)
745
+ if err != nil {
746
+ continue
747
+ }
748
+ for _, entry := range entries {
749
+ if entry.IsDir() {
750
+ localFolders = append(localFolders, filepath.Join(p, entry.Name()))
751
+ }
752
+ }
753
+ }
754
+
755
+ // Clean/reset inWorkspace map
756
+ for k := range inWorkspace {
757
+ delete(inWorkspace, k)
758
+ }
759
+
760
+ for _, folder := range localFolders {
761
+ data, err := os.ReadFile(filepath.Join(folder, "package.json"))
762
+ if err != nil {
763
+ continue
764
+ }
765
+ var pkg struct {
766
+ Name string `json:"name"`
767
+ }
768
+ if json.Unmarshal(data, &pkg) == nil && pkg.Name != "" {
769
+ inWorkspace[pkg.Name] = true
770
+ }
771
+ }
772
+
773
+ var missing []string
774
+ for _, folder := range localFolders {
775
+ data, err := os.ReadFile(filepath.Join(folder, "package.json"))
776
+ if err != nil {
777
+ continue
778
+ }
779
+ var pkg struct {
780
+ Dependencies map[string]string `json:"dependencies"`
781
+ DevDependencies map[string]string `json:"devDependencies"`
782
+ }
783
+ if json.Unmarshal(data, &pkg) != nil {
784
+ continue
785
+ }
786
+
787
+ allDeps := make(map[string]string)
788
+ for k, v := range pkg.Dependencies {
789
+ allDeps[k] = v
790
+ }
791
+ for k, v := range pkg.DevDependencies {
792
+ allDeps[k] = v
793
+ }
794
+
795
+ for depName, depVer := range allDeps {
796
+ if !strings.HasPrefix(depName, "@owdproject/") {
797
+ continue
798
+ }
799
+ if !strings.HasPrefix(depVer, "workspace:") {
800
+ continue
801
+ }
802
+ if !inWorkspace[depName] {
803
+ alreadyAdded := false
804
+ for _, m := range missing {
805
+ if m == depName {
806
+ alreadyAdded = true
807
+ break
808
+ }
809
+ }
810
+ if !alreadyAdded {
811
+ missing = append(missing, depName)
812
+ }
813
+ }
814
+ }
815
+ }
816
+ return missing, nil
817
+ }
818
+
819
+ for {
820
+ missing, err := scanWorkspace()
821
+ if err != nil || len(missing) == 0 {
822
+ break
823
+ }
824
+
825
+ depName := missing[0]
826
+ depShort := depName[strings.LastIndex(depName, "/")+1:]
827
+ depKindDir := kindDirForShortName(depShort)
828
+ depTarget := filepath.Join(workspaceRoot, depKindDir, depShort)
829
+
830
+ owner := resolveOwner(depName, catalogEntries)
831
+ gitURL := fmt.Sprintf("https://github.com/%s/%s.git", owner, depShort)
832
+
833
+ if step != nil && totalSteps != nil && msgChan != nil {
834
+ *totalSteps++
835
+ *step++
836
+ msgChan <- setupProgressMsg{Step: *step, Total: *totalSteps, Label: fmt.Sprintf("Cloning dep %s…", depShort)}
837
+ }
838
+ log(fmt.Sprintf("ℹ Auto-cloning missing workspace dependency %s from %s", depShort, gitURL))
839
+
840
+ if err := runtime.runProcessAndStreamLogsSilent(workspaceRoot, "git", []string{"clone", gitURL, depTarget}); err != nil {
841
+ log(fmt.Sprintf("✗ Auto-clone failed for %s: %v", depShort, err))
842
+ return err
843
+ }
844
+ log(fmt.Sprintf("✓ %s auto-cloned", depShort))
845
+ inWorkspace[depName] = true
846
+ }
847
+
848
+ return nil
849
+ }
850
+