@owdproject/cli 0.1.0 → 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/README.md +7 -7
- package/bin/bridge.js +4 -2
- package/bin/cli.js +63 -5
- package/bin/cp.js +42 -23
- package/bin/lib/catalog.js +3 -0
- package/bin/lib/config.js +51 -8
- package/bin/lib/cpFormat.js +2 -2
- package/bin/lib/create.js +246 -0
- package/bin/lib/desktopConfig.js +48 -8
- package/bin/lib/generateTemplate.js +2 -1
- package/bin/lib/install.js +17 -21
- package/bin/lib/installWizardFlow.js +90 -24
- package/bin/lib/packageSources.js +10 -0
- package/bin/lib/status.js +2 -0
- package/bin/lib/validateModule.js +112 -2
- package/bin/lib/workspace.js +68 -1
- package/go/bridge/bridge.go +20 -0
- package/go/desktop-go +0 -0
- package/go/go.mod +17 -10
- package/go/go.sum +34 -19
- package/go/main.go +7 -3
- 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 +451 -2596
- 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 +2 -2
package/go/tui/tasks.go
ADDED
|
@@ -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
|
+
|