@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.
- package/LICENSE +20 -0
- package/README.md +50 -16
- package/bin/bridge.js +129 -0
- package/bin/cli.js +491 -0
- package/bin/cp.js +3542 -0
- package/bin/desktop.js +5 -0
- package/bin/lib/catalog.js +504 -0
- package/bin/lib/config.js +173 -0
- package/bin/lib/cpAscii.js +39 -0
- package/bin/lib/cpFormat.js +343 -0
- package/bin/lib/create.js +246 -0
- package/bin/lib/desktopConfig.js +48 -0
- package/bin/lib/generateTemplate.js +427 -0
- package/bin/lib/install.js +521 -0
- package/bin/lib/installWizardFlow.js +792 -0
- package/bin/lib/migrateModulePlayground.config.mjs +187 -0
- package/bin/lib/migrateModulePlayground.mjs +318 -0
- package/bin/lib/npmVersions.js +124 -0
- package/bin/lib/packageSources.js +375 -0
- package/bin/lib/playgroundContext.js +188 -0
- package/bin/lib/scaffold.js +205 -0
- package/bin/lib/status.js +319 -0
- package/bin/lib/validateModule.js +978 -0
- package/bin/lib/workspace.js +289 -0
- package/bin/owd.js +2 -118
- package/go/bridge/bridge.go +220 -0
- package/go/desktop-go +0 -0
- package/go/go.mod +33 -0
- package/go/go.sum +52 -0
- package/go/main.go +34 -0
- package/go/owd-cli +0 -0
- package/go/tui/engine.go +472 -0
- package/go/tui/engine_test.go +40 -0
- package/go/tui/handlers.go +604 -0
- package/go/tui/helpers.go +916 -0
- package/go/tui/helpers_test.go +97 -0
- package/go/tui/modals.go +423 -0
- package/go/tui/resolver.go +377 -0
- package/go/tui/resolver_test.go +86 -0
- package/go/tui/styles.go +153 -0
- package/go/tui/tasks.go +850 -0
- package/go/tui/tui.go +869 -0
- package/go/tui/tui_test.go +69 -0
- package/go/tui/types.go +230 -0
- package/go/tui/view.go +812 -0
- package/go/tui/workqueue.go +179 -0
- package/go/tui/workqueue_test.go +90 -0
- package/package.json +33 -22
- package/src/commands/create.js +0 -11
- package/src/commands/generate.js +0 -12
- package/src/commands/installApp.js +0 -9
- package/src/commands/installTheme.js +0 -9
- package/src/commands/start.js +0 -13
- package/src/utils/cloneRepo.js +0 -25
- package/src/utils/installPackage.js +0 -15
- 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
|
+
}
|