@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,916 @@
1
+ package tui
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ "math/rand"
9
+ "net"
10
+ "net/http"
11
+ "net/url"
12
+ "os"
13
+ "os/exec"
14
+ "path/filepath"
15
+ "strings"
16
+ "time"
17
+
18
+ "github.com/charmbracelet/lipgloss"
19
+
20
+ "owd-cli/bridge"
21
+ )
22
+
23
+ // ─────────────────────────────────────────────
24
+ // Helpers & Utilities
25
+ // ─────────────────────────────────────────────
26
+
27
+ func isPortOpen(port int) bool {
28
+ conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 500*time.Millisecond)
29
+ if err != nil {
30
+ return false
31
+ }
32
+ conn.Close()
33
+ return true
34
+ }
35
+
36
+ func findProcessPID(port int) int {
37
+ // Try lsof first with TCP:LISTEN filter to get only the listening process
38
+ cmd := exec.Command("lsof", "-t", "-i", fmt.Sprintf(":%d", port), "-sTCP:LISTEN")
39
+ var stdout bytes.Buffer
40
+ cmd.Stdout = &stdout
41
+ if err := cmd.Run(); err == nil {
42
+ var pid int
43
+ if _, err := fmt.Sscanf(strings.TrimSpace(stdout.String()), "%d", &pid); err == nil && pid > 0 {
44
+ return pid
45
+ }
46
+ }
47
+
48
+ // Try pgrep patterns
49
+ patterns := []string{
50
+ "nuxt dev",
51
+ "nuxi dev",
52
+ "nuxt.mjs dev",
53
+ "nuxi.mjs dev",
54
+ "nx run desktop:serve",
55
+ }
56
+ for _, pat := range patterns {
57
+ cmd = exec.Command("pgrep", "-f", pat)
58
+ stdout.Reset()
59
+ cmd.Stdout = &stdout
60
+ if err := cmd.Run(); err == nil {
61
+ lines := strings.Split(stdout.String(), "\n")
62
+ for _, line := range lines {
63
+ line = strings.TrimSpace(line)
64
+ if line == "" {
65
+ continue
66
+ }
67
+ var pid int
68
+ if _, err := fmt.Sscanf(line, "%d", &pid); err == nil && pid > 0 {
69
+ return pid
70
+ }
71
+ }
72
+ }
73
+ }
74
+ return 0
75
+ }
76
+
77
+ func isLocallyAvailable(root, shortName string) bool {
78
+ kindDirs := []string{"apps", "packages", "themes"}
79
+ for _, kd := range kindDirs {
80
+ if _, err := os.Stat(filepath.Join(root, kd, shortName)); err == nil {
81
+ return true
82
+ }
83
+ }
84
+ return false
85
+ }
86
+
87
+ func getThemeDependencies(workspaceRoot, themeShortName string) ([]string, error) {
88
+ var pathsToCheck []string
89
+ pathsToCheck = append(pathsToCheck, filepath.Join(workspaceRoot, "themes", themeShortName, "package.json"))
90
+ pathsToCheck = append(pathsToCheck, filepath.Join(workspaceRoot, "node_modules", "@owdproject", themeShortName, "package.json"))
91
+ pathsToCheck = append(pathsToCheck, filepath.Join(workspaceRoot, "desktop", "node_modules", "@owdproject", themeShortName, "package.json"))
92
+
93
+ var data []byte
94
+ var err error
95
+ for _, p := range pathsToCheck {
96
+ data, err = os.ReadFile(p)
97
+ if err == nil {
98
+ break
99
+ }
100
+ }
101
+
102
+ if err != nil {
103
+ return nil, err
104
+ }
105
+
106
+ var pkg struct {
107
+ Dependencies map[string]string `json:"dependencies"`
108
+ DevDependencies map[string]string `json:"devDependencies"`
109
+ }
110
+ if err := json.Unmarshal(data, &pkg); err != nil {
111
+ return nil, err
112
+ }
113
+
114
+ var deps []string
115
+ seen := make(map[string]bool)
116
+ for name := range pkg.Dependencies {
117
+ if strings.HasPrefix(name, "@owdproject/") && !seen[name] {
118
+ deps = append(deps, name)
119
+ seen[name] = true
120
+ }
121
+ }
122
+ for name := range pkg.DevDependencies {
123
+ if strings.HasPrefix(name, "@owdproject/") && !seen[name] {
124
+ deps = append(deps, name)
125
+ seen[name] = true
126
+ }
127
+ }
128
+ return deps, nil
129
+ }
130
+
131
+ // getOwdDepsLocal reads package.json for any OWD package (app/module/theme) from the local filesystem.
132
+ // Returns all @owdproject/* dependencies whose shortname starts with "module-" or "kit-".
133
+ // Checks multiple locations: workspace subdirs + node_modules.
134
+ func getOwdDepsLocal(workspaceRoot, shortName, kind string) ([]string, bool) {
135
+ kindDir := ""
136
+ switch kind {
137
+ case "app":
138
+ kindDir = "apps"
139
+ case "module":
140
+ kindDir = "packages"
141
+ case "theme":
142
+ kindDir = "themes"
143
+ }
144
+
145
+ var pathsToCheck []string
146
+ if kindDir != "" {
147
+ pathsToCheck = append(pathsToCheck, filepath.Join(workspaceRoot, kindDir, shortName, "package.json"))
148
+ }
149
+ pathsToCheck = append(pathsToCheck,
150
+ filepath.Join(workspaceRoot, "node_modules", "@owdproject", shortName, "package.json"),
151
+ filepath.Join(workspaceRoot, "desktop", "node_modules", "@owdproject", shortName, "package.json"),
152
+ )
153
+
154
+ for _, p := range pathsToCheck {
155
+ data, err := os.ReadFile(p)
156
+ if err != nil {
157
+ continue
158
+ }
159
+ var pkg struct {
160
+ Dependencies map[string]string `json:"dependencies"`
161
+ DevDependencies map[string]string `json:"devDependencies"`
162
+ }
163
+ if err := json.Unmarshal(data, &pkg); err != nil {
164
+ continue
165
+ }
166
+ var deps []string
167
+ seen := make(map[string]bool)
168
+ allDeps := make(map[string]string)
169
+ for k, v := range pkg.Dependencies {
170
+ allDeps[k] = v
171
+ }
172
+ for k, v := range pkg.DevDependencies {
173
+ allDeps[k] = v
174
+ }
175
+ for name := range allDeps {
176
+ if !strings.HasPrefix(name, "@owdproject/") || seen[name] {
177
+ continue
178
+ }
179
+ short := name[strings.LastIndex(name, "/")+1:]
180
+ if strings.HasPrefix(short, "module-") || strings.HasPrefix(short, "kit-") || strings.HasPrefix(short, "app-") || strings.HasPrefix(short, "theme-") {
181
+ deps = append(deps, name)
182
+ seen[name] = true
183
+ }
184
+ }
185
+ return deps, true
186
+ }
187
+ return nil, false
188
+ }
189
+
190
+ // getOwdDepsFromNpm fetches package.json from npm registry for an @owdproject package
191
+ // and returns @owdproject/* deps whose shortname starts with "module-" or "kit-".
192
+ func getOwdDepsFromNpm(pkgName string) ([]string, error) {
193
+ client := &http.Client{Timeout: 5 * time.Second}
194
+ resp, err := client.Get(fmt.Sprintf("https://registry.npmjs.org/%s/latest", url.PathEscape(pkgName)))
195
+ if err != nil {
196
+ return nil, err
197
+ }
198
+ defer resp.Body.Close()
199
+ if resp.StatusCode != http.StatusOK {
200
+ return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
201
+ }
202
+ var data struct {
203
+ Dependencies map[string]string `json:"dependencies"`
204
+ DevDependencies map[string]string `json:"devDependencies"`
205
+ }
206
+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
207
+ return nil, err
208
+ }
209
+ var deps []string
210
+ seen := make(map[string]bool)
211
+ allDeps := make(map[string]string)
212
+ for k, v := range data.Dependencies {
213
+ allDeps[k] = v
214
+ }
215
+ for k, v := range data.DevDependencies {
216
+ allDeps[k] = v
217
+ }
218
+ for name := range allDeps {
219
+ if !strings.HasPrefix(name, "@owdproject/") || seen[name] {
220
+ continue
221
+ }
222
+ short := name[strings.LastIndex(name, "/")+1:]
223
+ if strings.HasPrefix(short, "module-") || strings.HasPrefix(short, "kit-") || strings.HasPrefix(short, "app-") || strings.HasPrefix(short, "theme-") {
224
+ deps = append(deps, name)
225
+ seen[name] = true
226
+ }
227
+ }
228
+ return deps, nil
229
+ }
230
+
231
+
232
+
233
+ func wipeDirectory(dir string, preserve map[string]bool) error {
234
+ entries, err := os.ReadDir(dir)
235
+ if err != nil {
236
+ if os.IsNotExist(err) {
237
+ return nil
238
+ }
239
+ return err
240
+ }
241
+ for _, entry := range entries {
242
+ if entry.IsDir() {
243
+ name := entry.Name()
244
+ if preserve != nil && preserve[name] {
245
+ continue
246
+ }
247
+ err := os.RemoveAll(filepath.Join(dir, name))
248
+ if err != nil {
249
+ return err
250
+ }
251
+ }
252
+ }
253
+ return nil
254
+ }
255
+
256
+ func runGitStatus(dir string) (added, modified, deleted int, err error) {
257
+ cmd := exec.Command("git", "status", "--porcelain")
258
+ cmd.Dir = dir
259
+ out, err := cmd.Output()
260
+ if err != nil {
261
+ return 0, 0, 0, err
262
+ }
263
+ lines := strings.Split(string(out), "\n")
264
+ for _, line := range lines {
265
+ if len(line) < 3 {
266
+ continue
267
+ }
268
+ status := line[:2]
269
+ if strings.HasPrefix(status, "??") {
270
+ added++
271
+ } else if strings.Contains(status, "D") {
272
+ deleted++
273
+ } else if strings.Contains(status, "M") || strings.Contains(status, "A") || strings.Contains(status, "R") || strings.Contains(status, "C") {
274
+ modified++
275
+ }
276
+ }
277
+ return added, modified, deleted, nil
278
+ }
279
+
280
+ func runGitStatusForSubdir(rootDir, subdir string) (added, modified, deleted int, err error) {
281
+ cmd := exec.Command("git", "status", "--porcelain", subdir)
282
+ cmd.Dir = rootDir
283
+ out, err := cmd.Output()
284
+ if err != nil {
285
+ return 0, 0, 0, err
286
+ }
287
+ lines := strings.Split(string(out), "\n")
288
+ for _, line := range lines {
289
+ if len(line) < 3 {
290
+ continue
291
+ }
292
+ status := line[:2]
293
+ if strings.HasPrefix(status, "??") {
294
+ added++
295
+ } else if strings.Contains(status, "D") {
296
+ deleted++
297
+ } else if strings.Contains(status, "M") || strings.Contains(status, "A") || strings.Contains(status, "R") || strings.Contains(status, "C") {
298
+ modified++
299
+ }
300
+ }
301
+ return added, modified, deleted, nil
302
+ }
303
+
304
+ func runGitBehindCheck(dir string) (int, error) {
305
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
306
+ defer cancel()
307
+
308
+ cmdFetch := exec.CommandContext(ctx, "git", "fetch")
309
+ cmdFetch.Dir = dir
310
+ _ = cmdFetch.Run() // ignore fetch errors
311
+
312
+ cmdRev := exec.Command("git", "rev-list", "--count", "HEAD..@{u}")
313
+ cmdRev.Dir = dir
314
+ out, err := cmdRev.Output()
315
+ if err != nil {
316
+ // Try with origin/branch
317
+ cmdBranch := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
318
+ cmdBranch.Dir = dir
319
+ branchOut, errBranch := cmdBranch.Output()
320
+ if errBranch != nil {
321
+ return 0, errBranch
322
+ }
323
+ branch := strings.TrimSpace(string(branchOut))
324
+ if branch == "" || branch == "HEAD" {
325
+ return 0, err
326
+ }
327
+ cmdRev = exec.Command("git", "rev-list", "--count", fmt.Sprintf("HEAD..origin/%s", branch))
328
+ cmdRev.Dir = dir
329
+ out, err = cmdRev.Output()
330
+ if err != nil {
331
+ return 0, err
332
+ }
333
+ }
334
+
335
+ var behind int
336
+ _, err = fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &behind)
337
+ return behind, err
338
+ }
339
+
340
+ // runGitBehindCheckNoFetch checks how many commits behind the remote the local repo is,
341
+ // WITHOUT running git fetch. Uses already-cached remote refs only — no network activity.
342
+ func runGitBehindCheckNoFetch(dir string) (int, error) {
343
+ cmdRev := exec.Command("git", "rev-list", "--count", "HEAD..@{u}")
344
+ cmdRev.Dir = dir
345
+ out, err := cmdRev.Output()
346
+ if err != nil {
347
+ // Fallback: try origin/<branch>
348
+ cmdBranch := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
349
+ cmdBranch.Dir = dir
350
+ branchOut, errBranch := cmdBranch.Output()
351
+ if errBranch != nil {
352
+ return 0, errBranch
353
+ }
354
+ branch := strings.TrimSpace(string(branchOut))
355
+ if branch == "" || branch == "HEAD" {
356
+ return 0, err
357
+ }
358
+ cmdRev = exec.Command("git", "rev-list", "--count", fmt.Sprintf("HEAD..origin/%s", branch))
359
+ cmdRev.Dir = dir
360
+ out, err = cmdRev.Output()
361
+ if err != nil {
362
+ return 0, err
363
+ }
364
+ }
365
+ var behind int
366
+ _, err = fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &behind)
367
+ return behind, err
368
+ }
369
+
370
+
371
+ func fetchLatestNpmVersion(pkgName string) (string, error) {
372
+ client := &http.Client{Timeout: 3 * time.Second}
373
+ resp, err := client.Get(fmt.Sprintf("https://registry.npmjs.org/%s/latest", url.PathEscape(pkgName)))
374
+ if err != nil {
375
+ return "", err
376
+ }
377
+ defer resp.Body.Close()
378
+ if resp.StatusCode != http.StatusOK {
379
+ return "", fmt.Errorf("HTTP %d", resp.StatusCode)
380
+ }
381
+ var data struct {
382
+ Version string `json:"version"`
383
+ }
384
+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
385
+ return "", err
386
+ }
387
+ return data.Version, nil
388
+ }
389
+
390
+ func sparkline(vals []int) string {
391
+ blocks := []rune{'\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588'}
392
+ if len(vals) == 0 {
393
+ mutedBar := lipgloss.NewStyle().Foreground(colorDim).Render(string('\u2581'))
394
+ return strings.Repeat(mutedBar, 12)
395
+ }
396
+
397
+ min := vals[0]
398
+ max := vals[0]
399
+ for _, v := range vals {
400
+ if v < min {
401
+ min = v
402
+ }
403
+ if v > max {
404
+ max = v
405
+ }
406
+ }
407
+
408
+ var sb strings.Builder
409
+ show := vals
410
+ if len(show) > 12 {
411
+ show = show[len(show)-12:]
412
+ }
413
+
414
+ for _, v := range show {
415
+ var idx int
416
+ if max == min {
417
+ if v == 0 {
418
+ idx = 0
419
+ } else {
420
+ idx = 2
421
+ }
422
+ } else {
423
+ idx = int(float64(v-min) / float64(max-min) * float64(len(blocks)-1))
424
+ if idx < 0 {
425
+ idx = 0
426
+ }
427
+ if idx >= len(blocks) {
428
+ idx = len(blocks) - 1
429
+ }
430
+ }
431
+
432
+ var color lipgloss.Color
433
+ if v == 0 {
434
+ color = colorDim
435
+ } else {
436
+ if v > 1024 {
437
+ color = colorErr
438
+ } else if v > 512 {
439
+ color = colorWarn
440
+ } else {
441
+ color = colorCyan
442
+ }
443
+ }
444
+
445
+ styledRune := lipgloss.NewStyle().Foreground(color).Render(string(blocks[idx]))
446
+ sb.WriteString(styledRune)
447
+ }
448
+ return sb.String()
449
+ }
450
+
451
+ func gitBranch(root string) string {
452
+ out, err := exec.Command("git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD").Output()
453
+ if err != nil {
454
+ return "—"
455
+ }
456
+ return strings.TrimSpace(string(out))
457
+ }
458
+
459
+ func gitChanges(root string) string {
460
+ out, err := exec.Command("git", "-C", root, "status", "--porcelain").Output()
461
+ if err != nil {
462
+ return "unknown"
463
+ }
464
+ lines := strings.Split(strings.TrimSpace(string(out)), "\n")
465
+ if len(lines) == 0 || (len(lines) == 1 && lines[0] == "") {
466
+ return "clean"
467
+ }
468
+ mod, add, del := 0, 0, 0
469
+ for _, l := range lines {
470
+ if len(l) < 2 {
471
+ continue
472
+ }
473
+ switch {
474
+ case strings.HasPrefix(l, "??"):
475
+ add++
476
+ case strings.HasPrefix(l, " D") || strings.HasPrefix(l, "D "):
477
+ del++
478
+ default:
479
+ mod++
480
+ }
481
+ }
482
+ var parts []string
483
+ if add > 0 {
484
+ parts = append(parts, accentStyle.Render(fmt.Sprintf("+%d", add)))
485
+ }
486
+ if mod > 0 {
487
+ parts = append(parts, warnStyle.Render(fmt.Sprintf("~%d", mod)))
488
+ }
489
+ if del > 0 {
490
+ parts = append(parts, errStyle.Render(fmt.Sprintf("-%d", del)))
491
+ }
492
+ if len(parts) == 0 {
493
+ return "clean"
494
+ }
495
+ return strings.Join(parts, " ")
496
+ }
497
+
498
+ func countLocalDirs(root, subdir string, excludePrefixes ...string) int {
499
+ dir := filepath.Join(root, subdir)
500
+ entries, err := os.ReadDir(dir)
501
+ if err != nil {
502
+ return 0
503
+ }
504
+ count := 0
505
+ for _, e := range entries {
506
+ if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
507
+ continue
508
+ }
509
+ skip := false
510
+ for _, pf := range excludePrefixes {
511
+ if strings.HasPrefix(e.Name(), pf) {
512
+ skip = true
513
+ break
514
+ }
515
+ }
516
+ if !skip {
517
+ count++
518
+ }
519
+ }
520
+ return count
521
+ }
522
+
523
+ func truncate(s string, max int) string {
524
+ if max <= 0 {
525
+ return ""
526
+ }
527
+
528
+ // If the printable width fits, return as-is
529
+ if lipgloss.Width(s) <= max {
530
+ return s
531
+ }
532
+
533
+ var result strings.Builder
534
+ visibleLen := 0
535
+ inEsc := false
536
+
537
+ runes := []rune(s)
538
+ n := len(runes)
539
+
540
+ for i := 0; i < n; i++ {
541
+ r := runes[i]
542
+ if r == '\x1b' {
543
+ inEsc = true
544
+ result.WriteRune(r)
545
+ continue
546
+ }
547
+ if inEsc {
548
+ result.WriteRune(r)
549
+ if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
550
+ inEsc = false
551
+ }
552
+ continue
553
+ }
554
+
555
+ w := 1
556
+ if r > 0x1100 && (r < 0x115f || r > 0x2329) {
557
+ w = 2
558
+ }
559
+
560
+ if visibleLen+w > max-1 {
561
+ result.WriteString("\x1b[0m…")
562
+ return result.String()
563
+ }
564
+
565
+ result.WriteRune(r)
566
+ visibleLen += w
567
+ }
568
+
569
+ return result.String()
570
+ }
571
+
572
+ func padRight(s string, width int) string {
573
+ vis := lipgloss.Width(s)
574
+ if vis >= width {
575
+ return truncate(s, width)
576
+ }
577
+ return s + strings.Repeat(" ", width-vis)
578
+ }
579
+
580
+ func padLeft(s string, width int) string {
581
+ vis := lipgloss.Width(s)
582
+ if vis >= width {
583
+ return truncate(s, width)
584
+ }
585
+ return strings.Repeat(" ", width-vis) + s
586
+ }
587
+
588
+ func formatCatalogAge(iso *string) string {
589
+ if iso == nil || *iso == "" {
590
+ return ""
591
+ }
592
+ t, err := time.Parse(time.RFC3339, *iso)
593
+ if err != nil {
594
+ t, err = time.Parse("2006-01-02T15:04:05.000Z", *iso)
595
+ if err != nil {
596
+ t, err = time.Parse("2006-01-02T15:04:05Z", *iso)
597
+ if err != nil {
598
+ return ""
599
+ }
600
+ }
601
+ }
602
+ duration := time.Since(t)
603
+ days := int(duration.Hours() / 24)
604
+ if days < 0 {
605
+ return "<1d"
606
+ }
607
+ if days < 1 {
608
+ return "<1d"
609
+ }
610
+ if days < 7 {
611
+ return fmt.Sprintf("%dd", days)
612
+ }
613
+ if days < 30 {
614
+ return fmt.Sprintf("%dw", days/7)
615
+ }
616
+ return fmt.Sprintf("%dmo", days/30)
617
+ }
618
+
619
+ func overlayCenter(bg, overlay string) string {
620
+ bgLines := strings.Split(bg, "\n")
621
+ ovLines := strings.Split(overlay, "\n")
622
+ bgH := len(bgLines)
623
+ ovH := len(ovLines)
624
+ bgW := 0
625
+ for _, l := range bgLines {
626
+ if w := lipgloss.Width(l); w > bgW {
627
+ bgW = w
628
+ }
629
+ }
630
+ ovW := 0
631
+ for _, l := range ovLines {
632
+ if w := lipgloss.Width(l); w > ovW {
633
+ ovW = w
634
+ }
635
+ }
636
+ topPad := (bgH - ovH) / 2
637
+ leftPad := (bgW - ovW) / 2
638
+ if leftPad < 0 {
639
+ leftPad = 0
640
+ }
641
+ out := make([]string, bgH)
642
+ copy(out, bgLines)
643
+ for i, ovLine := range ovLines {
644
+ row := topPad + i
645
+ if row < 0 || row >= bgH {
646
+ continue
647
+ }
648
+ bg := out[row]
649
+ bgVisible := lipgloss.Width(bg)
650
+ if bgVisible < bgW {
651
+ bg = bg + strings.Repeat(" ", bgW-bgVisible)
652
+ }
653
+ prefix := strings.Repeat(" ", leftPad)
654
+ suffix := ""
655
+ rightStart := leftPad + ovW
656
+ if rightStart < bgW {
657
+ suffix = strings.Repeat(" ", bgW-rightStart)
658
+ }
659
+ out[row] = prefix + ovLine + suffix
660
+ }
661
+ return strings.Join(out, "\n")
662
+ }
663
+
664
+ func drawPanel(w, h int, title string, content string, active bool) string {
665
+ style := panelBorderStyle
666
+ if active {
667
+ style = panelBorderActive
668
+ }
669
+
670
+ style = style.Width(w - 2).Height(h - 2)
671
+ rendered := style.Render(content)
672
+
673
+ if title == "" {
674
+ return rendered
675
+ }
676
+
677
+ var styledTitle string
678
+ if strings.Contains(title, "\x1b") {
679
+ styledTitle = title
680
+ } else {
681
+ styledTitle = lipgloss.NewStyle().Foreground(colorWhite).Bold(true).Render(title)
682
+ }
683
+
684
+ titleFmt := " " + styledTitle + " "
685
+ titleWidth := lipgloss.Width(titleFmt)
686
+
687
+ lines := strings.Split(rendered, "\n")
688
+ if len(lines) > 0 {
689
+ borderColor := colorBorder
690
+ if active {
691
+ borderColor = colorBorderAct
692
+ }
693
+
694
+ dashCount := w - 3 - titleWidth
695
+ if dashCount < 2 {
696
+ dashCount = 2
697
+ }
698
+
699
+ borderStyle := lipgloss.NewStyle().Foreground(borderColor)
700
+ topBorder := borderStyle.Render("╭─") +
701
+ titleFmt +
702
+ borderStyle.Render(strings.Repeat("─", dashCount)+"╮")
703
+
704
+ lines[0] = topBorder
705
+ rendered = strings.Join(lines, "\n")
706
+ }
707
+
708
+ return rendered
709
+ }
710
+
711
+ type WorkspaceVersions struct {
712
+ Nuxt string
713
+ Pnpm string
714
+ Owd string
715
+ }
716
+
717
+ func getVersions(root string, workspaceRoot string) WorkspaceVersions {
718
+ vers := WorkspaceVersions{Nuxt: "—", Pnpm: "—", Owd: "—"}
719
+
720
+ rootPkgPath := filepath.Join(root, "package.json")
721
+ if data, err := os.ReadFile(rootPkgPath); err == nil {
722
+ lines := strings.Split(string(data), "\n")
723
+ for _, l := range lines {
724
+ if strings.Contains(l, `"packageManager"`) && strings.Contains(l, "pnpm@") {
725
+ parts := strings.Split(l, "pnpm@")
726
+ if len(parts) >= 2 {
727
+ vers.Pnpm = strings.Trim(parts[1], " \t\r\n,\"")
728
+ vers.Pnpm = strings.ReplaceAll(vers.Pnpm, `"`, "")
729
+ vers.Pnpm = strings.TrimSpace(vers.Pnpm)
730
+ }
731
+ }
732
+ if strings.Contains(l, `"nuxt"`) {
733
+ parts := strings.Split(l, ":")
734
+ if len(parts) >= 2 {
735
+ v := strings.Trim(parts[1], " \t\r\n,\"^~")
736
+ v = strings.ReplaceAll(v, `"`, "")
737
+ vers.Nuxt = strings.TrimSpace(v)
738
+ }
739
+ }
740
+ }
741
+ }
742
+
743
+ corePkgPath := filepath.Join(workspaceRoot, "packages", "core", "package.json")
744
+ if data, err := os.ReadFile(corePkgPath); err == nil {
745
+ lines := strings.Split(string(data), "\n")
746
+ for _, l := range lines {
747
+ if strings.Contains(l, `"version"`) {
748
+ parts := strings.Split(l, ":")
749
+ if len(parts) >= 2 {
750
+ v := strings.Trim(parts[1], " \t\r\n,\"")
751
+ v = strings.ReplaceAll(v, `"`, "")
752
+ vers.Owd = strings.TrimSpace(v)
753
+ break
754
+ }
755
+ }
756
+ }
757
+ }
758
+
759
+ return vers
760
+ }
761
+
762
+ func getProcessMemoryMB(pid int) int {
763
+ if pid <= 0 {
764
+ return 0
765
+ }
766
+ path := fmt.Sprintf("/proc/%d/status", pid)
767
+ data, err := os.ReadFile(path)
768
+ if err != nil {
769
+ return 0
770
+ }
771
+ lines := strings.Split(string(data), "\n")
772
+ for _, l := range lines {
773
+ if strings.HasPrefix(l, "VmRSS:") {
774
+ fields := strings.Fields(l)
775
+ if len(fields) >= 2 {
776
+ var kb int
777
+ if _, err := fmt.Sscanf(fields[1], "%d", &kb); err == nil {
778
+ return kb / 1024
779
+ }
780
+ }
781
+ }
782
+ }
783
+ return 0
784
+ }
785
+
786
+ func renderPanelTitle(title string) string {
787
+ return lipgloss.NewStyle().Foreground(colorWhite).Bold(true).Render(title)
788
+ }
789
+
790
+ func kv(label, value string) string {
791
+ return mutedStyle.Render(padRight(label, 10)) + " " + boldStyle.Render(value)
792
+ }
793
+
794
+ // keep rand used (for future sparkline noise simulation)
795
+ var _ = rand.Float64
796
+
797
+ // catalogSourceLabel returns the SRC column value for a catalog row.
798
+ // It reflects only a known, materialized source — not an assumed install method.
799
+ func catalogSourceLabel(item bridge.CatalogEntry, localGitDirs map[string]bool) string {
800
+ if item.LocalSource {
801
+ if localGitDirs[item.ShortName] {
802
+ return "git"
803
+ }
804
+ return "dev"
805
+ }
806
+ if item.Installed && item.InPackageJson {
807
+ return "npm"
808
+ }
809
+ return "—"
810
+ }
811
+
812
+ // activeThemeName returns the currently selected theme (pending overrides saved config).
813
+ func activeThemeName(ctx *bridge.WorkspaceContext, pendingTheme *string) string {
814
+ if pendingTheme != nil {
815
+ return *pendingTheme
816
+ }
817
+ if ctx != nil && ctx.Config.Theme != nil {
818
+ return *ctx.Config.Theme
819
+ }
820
+ return ""
821
+ }
822
+
823
+ func (m *TuiModel) getInstallMethods(pkg *bridge.CatalogEntry) []InstallMethod {
824
+ var methods []InstallMethod
825
+
826
+ // 0. Local folder option if already present in workspace
827
+ short := pkg.ShortName
828
+ if short == "" {
829
+ short = shortNameFromPkg(pkg.Name)
830
+ }
831
+ if pkg.LocalSource || isLocallyAvailable(m.workspaceRoot, short) {
832
+ methods = append(methods, InstallMethod{
833
+ Name: "local",
834
+ Label: "Use Existing Local Folder",
835
+ Desc: "Import/link the existing local folder in your workspace",
836
+ })
837
+ }
838
+
839
+ // 1. NPM Registry — only when NOT already configured as workspace:* in package.json
840
+ if !pkg.InPackageJson && pkg.SourcesMeta != nil && pkg.SourcesMeta.Npm != nil {
841
+ methods = append(methods, InstallMethod{
842
+ Name: "npm",
843
+ Label: "NPM Registry",
844
+ Desc: "Install from npm (not recommended)",
845
+ })
846
+ }
847
+
848
+ // 2. Git options
849
+ hasFork := false
850
+ forkOwner := ""
851
+ officialOwner := pkg.Org
852
+ if officialOwner == "" || officialOwner == "workspace" {
853
+ officialOwner = "owdproject"
854
+ }
855
+
856
+ if pkg.SourcesMeta != nil && pkg.SourcesMeta.Github.Fork != nil && pkg.SourcesMeta.Github.Fork.Exists {
857
+ // Only treat as "your fork" if GitHub confirms it's actually a fork (forked from another repo)
858
+ if pkg.SourcesMeta.Github.Fork.IsFork {
859
+ hasFork = true
860
+ forkOwner = pkg.SourcesMeta.Github.Fork.Owner
861
+ }
862
+ }
863
+
864
+ if hasFork {
865
+ methods = append(methods, InstallMethod{
866
+ Name: "git-https",
867
+ Label: "GIT HTTPS (your fork)",
868
+ Desc: fmt.Sprintf("Clone via HTTPS from github.com/%s/%s", forkOwner, pkg.ShortName),
869
+ })
870
+ methods = append(methods, InstallMethod{
871
+ Name: "git-ssh",
872
+ Label: "GIT SSH (your fork)",
873
+ Desc: fmt.Sprintf("Clone via SSH from github.com/%s/%s", forkOwner, pkg.ShortName),
874
+ })
875
+ } else {
876
+ methods = append(methods, InstallMethod{
877
+ Name: "git-https",
878
+ Label: "GIT HTTPS",
879
+ Desc: fmt.Sprintf("Clone via HTTPS from github.com/%s/%s", officialOwner, pkg.ShortName),
880
+ })
881
+ methods = append(methods, InstallMethod{
882
+ Name: "git-ssh",
883
+ Label: "GIT SSH",
884
+ Desc: fmt.Sprintf("Clone via SSH from github.com/%s/%s", officialOwner, pkg.ShortName),
885
+ })
886
+ }
887
+
888
+ return methods
889
+ }
890
+
891
+ func (m *TuiModel) resolveOwner(pkgName string) string {
892
+ if m.catalog == nil {
893
+ return "owdproject"
894
+ }
895
+ var entry *bridge.CatalogEntry
896
+ for i := range m.catalog.Entries {
897
+ if m.catalog.Entries[i].Name == pkgName {
898
+ entry = &m.catalog.Entries[i]
899
+ break
900
+ }
901
+ }
902
+ if entry != nil && entry.SourcesMeta != nil && entry.SourcesMeta.Github.Fork != nil && entry.SourcesMeta.Github.Fork.Exists && entry.SourcesMeta.Github.Fork.IsFork {
903
+ return entry.SourcesMeta.Github.Fork.Owner
904
+ }
905
+ if entry != nil && entry.Org != "" && entry.Org != "workspace" {
906
+ return entry.Org
907
+ }
908
+ return "owdproject"
909
+ }
910
+
911
+ func (m *TuiModel) logInfo(msg string) { m.addLog("ℹ " + msg) }
912
+ func (m *TuiModel) logSuccess(msg string) { m.addLog("✔ " + msg) }
913
+ func (m *TuiModel) logWarn(msg string) { m.addLog("⚠ " + msg) }
914
+ func (m *TuiModel) logError(msg string) { m.addLog("✖ " + msg) }
915
+
916
+