@fatalmistake02/rian 0.0.0-stage → 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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +155 -2
  3. package/THIRD_PARTY_NOTICES.txt +673 -0
  4. package/bin/rian.cjs +12 -0
  5. package/cmd/rian/main.go +17 -0
  6. package/dist/binaries/darwin-arm64/rian +0 -0
  7. package/dist/binaries/darwin-x64/rian +0 -0
  8. package/dist/binaries/linux-arm64/rian +0 -0
  9. package/dist/binaries/linux-x64/rian +0 -0
  10. package/dist/binaries/manifest.json +29 -0
  11. package/dist/binaries/win32-arm64/rian.exe +0 -0
  12. package/dist/binaries/win32-x64/rian.exe +0 -0
  13. package/go.mod +28 -0
  14. package/go.sum +44 -0
  15. package/internal/app/app.go +298 -0
  16. package/internal/app/app_test.go +85 -0
  17. package/internal/config/config.go +189 -0
  18. package/internal/config/config_test.go +101 -0
  19. package/internal/detect/detect.go +252 -0
  20. package/internal/detect/detect_test.go +86 -0
  21. package/internal/git/git.go +166 -0
  22. package/internal/git/git_test.go +73 -0
  23. package/internal/git/window_unix.go +7 -0
  24. package/internal/git/window_windows.go +8 -0
  25. package/internal/process/platform_unix.go +19 -0
  26. package/internal/process/platform_windows.go +25 -0
  27. package/internal/process/process.go +172 -0
  28. package/internal/process/process_test.go +137 -0
  29. package/internal/project/project.go +180 -0
  30. package/internal/project/project_test.go +71 -0
  31. package/internal/tools/lookup.go +41 -0
  32. package/internal/tools/platform_unix.go +34 -0
  33. package/internal/tools/platform_windows.go +52 -0
  34. package/internal/tools/platform_windows_test.go +29 -0
  35. package/internal/tools/tools.go +152 -0
  36. package/internal/tools/tools_test.go +56 -0
  37. package/internal/ui/actions.go +259 -0
  38. package/internal/ui/forms.go +156 -0
  39. package/internal/ui/model.go +220 -0
  40. package/internal/ui/styles.go +85 -0
  41. package/internal/ui/ui_test.go +170 -0
  42. package/internal/ui/update.go +470 -0
  43. package/internal/ui/view.go +459 -0
  44. package/lib/launcher.cjs +46 -0
  45. package/lib/platforms.cjs +20 -0
  46. package/package.json +39 -4
  47. package/scripts/build-binaries.cjs +60 -0
  48. package/scripts/check-package.cjs +49 -0
  49. package/scripts/ensure-binary.cjs +11 -0
  50. package/test/npm/launcher.test.cjs +45 -0
  51. package/test/npm/package.test.cjs +26 -0
@@ -0,0 +1,459 @@
1
+ package ui
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "strings"
7
+ "time"
8
+
9
+ tea "charm.land/bubbletea/v2"
10
+ "charm.land/lipgloss/v2"
11
+
12
+ "rian/internal/detect"
13
+ "rian/internal/project"
14
+ )
15
+
16
+ func (m *Model) View() tea.View {
17
+ if m.width == 0 {
18
+ v := tea.NewView("Starting Rian…")
19
+ v.AltScreen = true
20
+ return v
21
+ }
22
+ var content string
23
+ if m.width < 52 || m.height < 16 {
24
+ content = fit(highlight.Bold(true).Render("RIAN")+"\n\n"+muted.Render("Resize to at least 52 × 16.\nYour projects are ready when you are.\n\nq quit · Ctrl+K actions"), m.width)
25
+ } else {
26
+ content = m.homeView()
27
+ if m.mode != home {
28
+ modal := m.modalView()
29
+ x, y := max(0, (m.width-lipgloss.Width(modal))/2), max(0, (m.height-lipgloss.Height(modal))/2)
30
+ content = lipgloss.NewCompositor(lipgloss.NewLayer(content), lipgloss.NewLayer(modal).X(x).Y(y).Z(1)).Render()
31
+ }
32
+ }
33
+ // A toast is also visible over a modal, where most validation happens.
34
+ if m.toast != "" && m.height >= 16 && m.width >= 52 {
35
+ style := muted
36
+ if m.toastError {
37
+ style = warning
38
+ }
39
+ toast := lipgloss.NewStyle().Background(background).Padding(0, 1).Render(style.Render(fit(oneLine(m.toast), m.width-6)))
40
+ content = lipgloss.NewCompositor(lipgloss.NewLayer(content), lipgloss.NewLayer(toast).X(2).Y(m.height-1).Z(2)).Render()
41
+ }
42
+ v := tea.NewView(content)
43
+ v.AltScreen, v.ReportFocus, v.WindowTitle = true, true, "Rian · Projects"
44
+ v.BackgroundColor, v.ForegroundColor = background, foreground
45
+ return v
46
+ }
47
+
48
+ func (m *Model) homeView() string {
49
+ w, h := m.width-4, m.height-6
50
+ head := highlight.Bold(true).Render("RIAN") + muted.Render(" / your project desk")
51
+ right := keyHint("Ctrl+K", "actions")
52
+ header := " " + head + strings.Repeat(" ", max(1, w-lipgloss.Width(head)-lipgloss.Width(right))) + right
53
+ leftW := min(44, max(25, w*36/100))
54
+ if m.width < 84 {
55
+ leftW = max(23, w/2-1)
56
+ }
57
+ rightW := w - leftW - 1
58
+ left := box(m.listView(leftW-4, h-2), leftW, h, m.mode == home)
59
+ rightPanel := box(m.detailView(rightW-4, h-2), rightW, h, false)
60
+ body := lipgloss.JoinHorizontal(lipgloss.Top, left, " ", rightPanel)
61
+ footer := keyHint("↑↓ / jk", "navigate") + " " + keyHint("a", "add") + " " + keyHint("/", "search") + " " + keyHint("?", "help") + " " + keyHint("q", "quit")
62
+ if m.busy {
63
+ footer = m.spin.View() + muted.Render(" Working…")
64
+ }
65
+ return header + "\n\n" + lipgloss.NewStyle().MarginLeft(2).Render(body) + "\n " + fit(footer, m.width-2) + "\n\n"
66
+ }
67
+
68
+ func (m *Model) listView(width, height int) string {
69
+ projects := m.projects()
70
+ heading := titleStyle.Render("PROJECTS") + muted.Render(fmt.Sprintf(" %d", len(projects)))
71
+ lines := []string{pad(heading, width), ""}
72
+ if m.searching || m.search.Value() != "" {
73
+ lines = append(lines, fit(highlight.Render("/ ")+m.search.View(), width), "")
74
+ }
75
+ if len(projects) == 0 {
76
+ if m.search.Value() != "" {
77
+ lines = append(lines, muted.Render("No matching projects."), "", muted.Render("Esc clears your search."))
78
+ } else {
79
+ lines = append(lines, "", titleStyle.Render("A home for your work."), "", fit(muted.Render("Save a directory once."), width), fit(muted.Render("Pick up where you left off."), width), "", keyHint("a", "Add your first project"))
80
+ }
81
+ return strings.Join(lines, "\n")
82
+ }
83
+ visible := max(1, (height-len(lines)-2)/3)
84
+ start := max(0, m.selected-visible+1)
85
+ end := min(len(projects), start+visible)
86
+ for i := start; i < end; i++ {
87
+ p := projects[i]
88
+ prefix := " "
89
+ if i == m.selected {
90
+ prefix = "› "
91
+ }
92
+ name := oneLine(p.Name)
93
+ if p.Favorite {
94
+ name = "★ " + name
95
+ }
96
+ status := m.statuses[p.ID]
97
+ branch := fit(oneLine(status.Branch), min(11, width/3))
98
+ marker := ""
99
+ if status.Repository {
100
+ if status.Dirty() {
101
+ marker = warning.Render(" ●")
102
+ } else {
103
+ marker = good.Render(" ○")
104
+ }
105
+ }
106
+ if m.loading[p.ID] {
107
+ marker = " " + m.spin.View()
108
+ }
109
+ meta := muted.Render(branch) + marker
110
+ nameWidth := max(4, width-lipgloss.Width(meta)-3)
111
+ row := pad(prefix+fit(name, nameWidth-2), width-lipgloss.Width(meta)) + meta
112
+ if i == m.selected {
113
+ row = selectedStyle.Render(row)
114
+ } else {
115
+ row = textStyle.Render(row)
116
+ }
117
+ lines = append(lines, row, muted.Render(pad(" "+shortPath(p.Path), width)), "")
118
+ }
119
+ for len(lines) < height-1 {
120
+ lines = append(lines, "")
121
+ }
122
+ position := fmt.Sprintf("%d of %d", min(m.selected+1, len(projects)), len(projects))
123
+ if len(projects) > visible {
124
+ position += " · scroll ↑↓"
125
+ }
126
+ lines = append(lines, muted.Render(fit(position, width)))
127
+ return strings.Join(lines, "\n")
128
+ }
129
+
130
+ func (m *Model) detailView(width, height int) string {
131
+ p, ok := m.current()
132
+ if !ok {
133
+ return strings.Join([]string{titleStyle.Render("READY WHEN YOU ARE"), "", "", highlight.Render("Local projects. Less friction."), "", fit(muted.Render("Open your editor, coding tools, and commands"), width), fit(muted.Render("from one keyboard-first workspace."), width), "", keyHint("a", "add a project"), keyHint("Ctrl+K", "browse actions")}, "\n")
134
+ }
135
+ s := m.statuses[p.ID]
136
+ lines := []string{titleStyle.Render(fit(oneLine(p.Name), width)), muted.Render(fit(shortPath(p.Path), width)), ""}
137
+ if len(p.Stack) > 0 {
138
+ lines = append(lines, muted.Render(fit(strings.Join(p.Stack, " · "), width)), "")
139
+ }
140
+ label := func(name, value string) string {
141
+ return muted.Render(pad(name, min(14, width/3))) + textStyle.Render(fit(oneLine(value), max(1, width-min(14, width/3))))
142
+ }
143
+ repo := p.GitHub
144
+ if repo == "" {
145
+ repo = s.GitHub
146
+ }
147
+ if repo != "" {
148
+ lines = append(lines, highlight.Render("GITHUB"), textStyle.Render(fit(oneLine(repo), width)), "")
149
+ }
150
+ if s.Repository {
151
+ state := good.Render("○ clean")
152
+ if s.Dirty() {
153
+ state = warning.Render("● changes")
154
+ }
155
+ lines = append(lines, label("Branch", s.Branch), label("Status", "")+state, label("Modified", fmt.Sprint(s.Modified)), label("Untracked", fmt.Sprint(s.Untracked)), label("Ahead / back", fmt.Sprintf("↑ %d ↓ %d", s.Ahead, s.Behind)), "")
156
+ if s.Commit != "" {
157
+ lines = append(lines, muted.Render("LAST COMMIT"), fit(oneLine(s.Commit), width), muted.Render(relativeTime(s.CommitTime)), "")
158
+ }
159
+ if s.Remote != "" && repo == "" {
160
+ lines = append(lines, label("Remote", s.Remote))
161
+ }
162
+ } else if m.loading[p.ID] {
163
+ lines = append(lines, m.spin.View()+muted.Render(" Reading Git status…"), "")
164
+ } else {
165
+ lines = append(lines, muted.Render("No Git repository detected."), "")
166
+ }
167
+ if s.Error != "" {
168
+ lines = append(lines, fit(warning.Render(s.Error), width))
169
+ }
170
+ state := m.manager.Snapshot(p.ID)
171
+ if state.Command != "" {
172
+ status := "stopped"
173
+ if state.Running {
174
+ status = "running"
175
+ }
176
+ lines = append(lines, highlight.Render("PROCESS · "+status), fit(oneLine(state.Command), width), keyHint("p", "output"), "")
177
+ }
178
+ if !p.LastUsed.IsZero() {
179
+ lines = append(lines, muted.Render("Used "+relativeTime(p.LastUsed)), "")
180
+ }
181
+ // Keep the action hints anchored below the details at every terminal size.
182
+ hints := []string{keyHint("v", "VS Code") + " " + keyHint("o", "OpenCode"), keyHint("K", "KiloCode") + " " + keyHint("c", "ChatGPT"), keyHint("t", "Terminal") + " " + keyHint("g", "GitHub"), keyHint("r", "Run") + " " + keyHint("d", "Commands") + " " + keyHint("G", "Git")}
183
+ space := max(2, height-len(hints)-1)
184
+ if len(lines) > space {
185
+ lines = lines[:space]
186
+ }
187
+ for len(lines) < space {
188
+ lines = append(lines, "")
189
+ }
190
+ lines = append(lines, muted.Render(strings.Repeat("─", width)))
191
+ for _, hint := range hints {
192
+ lines = append(lines, fit(hint, width))
193
+ }
194
+ return strings.Join(lines, "\n")
195
+ }
196
+
197
+ func (m *Model) modalView() string {
198
+ w := max(20, min(78, m.width-8))
199
+ inner := w - 6
200
+ var body, title, footer string
201
+ switch m.mode {
202
+ case add:
203
+ title = "ADD PROJECT"
204
+ body = muted.Render("Choose a directory. Rian will inspect it first.") + "\n\n" + m.query.View()
205
+ footer = keyHint("Enter", "inspect") + " " + keyHint("Esc", "cancel")
206
+ case addConfirm:
207
+ title = "PROJECT FOUND"
208
+ body = m.viewport.View()
209
+ footer = keyHint("Enter", "save project") + " " + keyHint("↑↓", "scroll") + " " + keyHint("Esc", "cancel")
210
+ case edit:
211
+ title = "EDIT PROJECT"
212
+ lines := []string{muted.Render("Commands: press d, then Ctrl+N / Ctrl+E."), ""}
213
+ visible := max(3, min(len(m.fields), m.height-13))
214
+ start := max(0, m.fieldIndex-visible+1)
215
+ for i := start; i < min(len(m.fields), start+visible); i++ {
216
+ field := m.fields[i]
217
+ label := muted.Render(pad(field.label, 16))
218
+ value := field.input.View()
219
+ if field.toggle {
220
+ value = muted.Render("[ ] disabled")
221
+ if field.checked {
222
+ value = good.Render("[✓] enabled")
223
+ }
224
+ if field.label == "Favorite" {
225
+ value = "[ ]"
226
+ if field.checked {
227
+ value = highlight.Render("[★]")
228
+ }
229
+ }
230
+ }
231
+ prefix := " "
232
+ if i == m.fieldIndex {
233
+ prefix = highlight.Render("› ")
234
+ label = textStyle.Render(pad(field.label, 16))
235
+ }
236
+ lines = append(lines, fit(prefix+label+value, inner))
237
+ }
238
+ body = strings.Join(lines, "\n")
239
+ footer = keyHint("Tab / ↑↓", "field") + " " + keyHint("Space", "toggle") + " " + keyHint("Ctrl+S", "save")
240
+ if inner < 54 {
241
+ footer = keyHint("Tab", "field") + " " + keyHint("Space", "toggle") + " " + keyHint("Ctrl+S", "save")
242
+ }
243
+ case palette, commands, gitMenu:
244
+ title = "COMMAND PALETTE"
245
+ if m.mode == commands {
246
+ title = "PROJECT COMMANDS"
247
+ }
248
+ if m.mode == gitMenu {
249
+ title = "GIT ACTIONS"
250
+ }
251
+ items := m.filteredActions()
252
+ lines := []string{m.query.View(), muted.Render(strings.Repeat("─", inner)), ""}
253
+ bodyCapacity := m.height - 11
254
+ if m.mode == commands {
255
+ bodyCapacity--
256
+ }
257
+ stride := 2
258
+ if m.height < 24 {
259
+ stride = 1
260
+ }
261
+ visible := max(1, min(8, (bodyCapacity-3)/stride))
262
+ start := max(0, m.menuIndex-visible+1)
263
+ if len(items) == 0 {
264
+ lines = append(lines, muted.Render("No matching actions."))
265
+ }
266
+ for i := start; i < min(len(items), start+visible); i++ {
267
+ item := items[i]
268
+ prefix := " "
269
+ if i == m.menuIndex {
270
+ prefix = "› "
271
+ }
272
+ row := pad(prefix+oneLine(item.Label), inner)
273
+ if i == m.menuIndex {
274
+ row = selectedStyle.Render(row)
275
+ } else if item.Disabled != "" {
276
+ row = muted.Render(row)
277
+ }
278
+ detail := item.Detail
279
+ if item.Disabled != "" {
280
+ detail = item.Disabled
281
+ }
282
+ lines = append(lines, row)
283
+ if stride == 2 {
284
+ lines = append(lines, muted.Render(fit(" "+oneLine(detail), inner)))
285
+ }
286
+ }
287
+ body = strings.Join(lines, "\n")
288
+ footer = keyHint("↑↓", "choose") + " " + keyHint("Enter", "select") + " " + keyHint("Esc", "close")
289
+ if m.mode == commands {
290
+ footer += "\n" + keyHint("Ctrl+N", "new") + " " + keyHint("Ctrl+E", "edit") + " " + keyHint("Ctrl+D", "remove")
291
+ }
292
+ case help:
293
+ title = "KEYBOARD SHORTCUTS"
294
+ body = m.viewport.View()
295
+ footer = keyHint("↑↓", "scroll") + " " + keyHint("Esc / ?", "close")
296
+ case removeConfirm:
297
+ title = "REMOVE PROJECT?"
298
+ body = titleStyle.Render(fit(oneLine(m.editProject.Name), inner)) + "\n\n" + muted.Render("Only the Rian entry will be removed.\nYour project directory and files stay in place.")
299
+ footer = keyHint("Enter / y", "remove entry") + " " + keyHint("Esc / n", "keep project")
300
+ case quitConfirm:
301
+ title = "COMMANDS ARE STILL RUNNING"
302
+ body = fmt.Sprintf("%d active process(es).\n\nQuitting will stop their process trees.", m.manager.Active())
303
+ footer = keyHint("Enter / y", "stop and quit") + " " + keyHint("Esc / n", "stay")
304
+ case prompt:
305
+ title = "RUN COMMAND"
306
+ if m.promptAction == "commit" {
307
+ title = "COMMIT STAGED CHANGES"
308
+ }
309
+ if m.promptAction == "branch" {
310
+ title = "SWITCH BRANCH"
311
+ }
312
+ body = m.query.View()
313
+ footer = keyHint("Enter", "continue") + " " + keyHint("Esc", "cancel")
314
+ if m.commandEditing {
315
+ title = "CUSTOM COMMAND"
316
+ body = muted.Render("Name (an existing name overrides detection)") + "\n" + m.commandName.View() + "\n\n" + muted.Render("Shell command, run in the project directory") + "\n" + m.commandValue.View()
317
+ footer = keyHint("Tab", "field") + " " + keyHint("Ctrl+S", "save") + " " + keyHint("Esc", "cancel")
318
+ }
319
+ case processView:
320
+ title = "PROCESS OUTPUT"
321
+ s := m.processState
322
+ state := muted.Render("STOPPED")
323
+ if s.Error != "" {
324
+ state = warning.Render("FAILED")
325
+ }
326
+ if s.Running {
327
+ state = good.Render("● RUNNING")
328
+ }
329
+ if s.Command == "" {
330
+ state = muted.Render("No command has run yet.")
331
+ }
332
+ meta := state
333
+ if s.PID > 0 {
334
+ meta += muted.Render(fmt.Sprintf(" PID %d started %s", s.PID, s.Started.Local().Format("15:04:05")))
335
+ }
336
+ body = fit(oneLine(s.Command), inner) + "\n" + fit(meta, inner) + "\n\n" + m.viewport.View()
337
+ if s.Error != "" {
338
+ body += "\n" + warning.Render(fit(oneLine(s.Error), inner))
339
+ }
340
+ follow := "paused"
341
+ if m.followOutput {
342
+ follow = "live"
343
+ }
344
+ footer = keyHint("s", "stop") + " " + keyHint("r", "rerun") + " " + keyHint("f", "follow: "+follow) + " " + keyHint("Esc", "back")
345
+ if inner < 60 {
346
+ footer = keyHint("s", "stop") + " " + keyHint("r", "rerun") + " " + keyHint("f", "follow") + " " + keyHint("Esc", "back")
347
+ }
348
+ }
349
+ if m.formError != "" {
350
+ body += "\n\n" + warning.Render(fit(oneLine(m.formError), inner))
351
+ }
352
+ if m.busy {
353
+ footer = m.spin.View() + muted.Render(" Working…")
354
+ }
355
+ bodyLines := strings.Split(body, "\n")
356
+ footerLines := strings.Split(footer, "\n")
357
+ allowed := max(1, m.height-10-len(footerLines))
358
+ if len(bodyLines) > allowed {
359
+ bodyLines = bodyLines[:allowed]
360
+ }
361
+ for i := range bodyLines {
362
+ bodyLines[i] = fit(bodyLines[i], inner)
363
+ }
364
+ for i := range footerLines {
365
+ footerLines[i] = fit(footerLines[i], inner)
366
+ }
367
+ content := titleStyle.Render(title) + "\n\n" + strings.Join(bodyLines, "\n") + "\n\n" + strings.Join(footerLines, "\n")
368
+ return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(accent).
369
+ Background(background).Foreground(foreground).Padding(1, 2).Width(w).
370
+ MaxWidth(w).MaxHeight(m.height - 2).Render(content)
371
+ }
372
+
373
+ func (m *Model) detectionSummary(result detect.Result) string {
374
+ p := result.Project
375
+ lines := []string{titleStyle.Render(oneLine(p.Name)), muted.Render(oneLine(p.Path)), ""}
376
+ if len(p.Stack) > 0 {
377
+ lines = append(lines, "Stack "+strings.Join(p.Stack, " · "))
378
+ }
379
+ if result.Git.Repository {
380
+ lines = append(lines, "Branch "+oneLine(result.Git.Branch))
381
+ }
382
+ if result.Git.GitHub != "" {
383
+ lines = append(lines, "GitHub "+oneLine(result.Git.GitHub))
384
+ }
385
+ lines = append(lines, "", highlight.Render("DETECTED COMMANDS"))
386
+ if len(p.AutoCommands) == 0 {
387
+ lines = append(lines, muted.Render("None yet. Add custom commands with d."))
388
+ }
389
+ for _, name := range detect.CommandNames(p.AutoCommands) {
390
+ lines = append(lines, pad(oneLine(name), 14)+oneLine(p.AutoCommands[name]))
391
+ }
392
+ lines = append(lines, "", highlight.Render("TOOLS"))
393
+ for _, tool := range []string{"code", "opencode", "kilocode", "gh", "lazygit"} {
394
+ status := "not in PATH"
395
+ if result.Available[tool] {
396
+ status = "available"
397
+ }
398
+ lines = append(lines, pad(tool, 14)+status)
399
+ }
400
+ for _, warning := range result.Warnings {
401
+ lines = append(lines, "", oneLine(warning))
402
+ }
403
+ return strings.Join(lines, "\n")
404
+ }
405
+
406
+ func (m *Model) helpView(width int) string {
407
+ rows := []struct{ label, keys string }{
408
+ {"NAVIGATION", "↑↓ / j k projects / fuzzy search Enter actions"},
409
+ {"OPEN", "v VS Code o OpenCode K KiloCode\nt shell c ChatGPT g GitHub"},
410
+ {"RUN", "r run / active process d commands p output\nProcess: s stop r rerun f follow output"},
411
+ {"GIT", "G Git menu Ctrl+R refresh status"},
412
+ {"PROJECT", "a add e edit f favorite Delete remove\nCommands: Ctrl+N new Ctrl+E edit Ctrl+D remove"},
413
+ {"GLOBAL", "Ctrl+K palette ? help q / Ctrl+C quit"},
414
+ }
415
+ var lines []string
416
+ for _, row := range rows {
417
+ lines = append(lines, highlight.Render(row.label))
418
+ for _, line := range strings.Split(row.keys, "\n") {
419
+ lines = append(lines, fit(line, width))
420
+ }
421
+ lines = append(lines, "")
422
+ }
423
+ if m.height < 33 {
424
+ lines = []string{keyHint("↑↓ / jk", "navigate") + " " + keyHint("/", "search"), keyHint("Ctrl+K / Enter", "actions"), keyHint("v / o / K", "VS Code / OpenCode / KiloCode"), keyHint("t / c / g", "shell / ChatGPT / GitHub"), keyHint("r / d / p", "run / commands / output"), keyHint("G / Ctrl+R", "Git menu / refresh"), keyHint("a / e / f", "add / edit / favorite"), keyHint("Delete", "remove entry"), keyHint("q / Ctrl+C", "quit")}
425
+ }
426
+ return strings.Join(lines, "\n")
427
+ }
428
+
429
+ func shortPath(path string) string {
430
+ if home, err := os.UserHomeDir(); err == nil {
431
+ if project.SamePath(path, home) {
432
+ return "~"
433
+ }
434
+ if len(path) > len(home) && project.SamePath(path[:len(home)], home) && (path[len(home)] == '/' || path[len(home)] == '\\') {
435
+ return "~" + oneLine(path[len(home):])
436
+ }
437
+ }
438
+ return oneLine(path)
439
+ }
440
+
441
+ func relativeTime(t time.Time) string {
442
+ if t.IsZero() {
443
+ return "—"
444
+ }
445
+ d := time.Since(t)
446
+ if d < time.Minute {
447
+ return "just now"
448
+ }
449
+ if d < time.Hour {
450
+ return fmt.Sprintf("%d min ago", int(d.Minutes()))
451
+ }
452
+ if d < 24*time.Hour {
453
+ return fmt.Sprintf("%d hr ago", int(d.Hours()))
454
+ }
455
+ if d < 30*24*time.Hour {
456
+ return fmt.Sprintf("%d days ago", int(d.Hours()/24))
457
+ }
458
+ return t.Format("Jan 2, 2006")
459
+ }
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { spawn } = require('node:child_process');
6
+ const { targetFor } = require('./platforms.cjs');
7
+ const { name: packageName } = require('../package.json');
8
+
9
+ function binaryPath(root, platform = process.platform, arch = process.arch) {
10
+ const target = targetFor(platform, arch);
11
+ const binary = path.join(root, 'dist', 'binaries', target.key, target.filename);
12
+ if (!fs.existsSync(binary)) {
13
+ throw new Error(`The Rian executable is missing. Reinstall with: npm install -g ${packageName}`);
14
+ }
15
+ if (platform !== 'win32' && !(fs.statSync(binary).mode & 0o111)) {
16
+ try { fs.chmodSync(binary, 0o755); } catch {
17
+ throw new Error(`The Rian executable needs permission to run. Repair the installation with: npm rebuild -g ${packageName}`);
18
+ }
19
+ }
20
+ return binary;
21
+ }
22
+
23
+ function launch(root, args = process.argv.slice(2), spawnProcess = spawn) {
24
+ const binary = binaryPath(root);
25
+ // Native stdio must pass straight through: Bubble Tea owns the terminal.
26
+ const child = spawnProcess(binary, args, { stdio: 'inherit', shell: false, windowsHide: false });
27
+ const handlers = new Map();
28
+ for (const signal of ['SIGINT', 'SIGTERM', ...(process.platform === 'win32' ? [] : ['SIGHUP'])]) {
29
+ const handler = () => { if (child.exitCode === null && child.signalCode === null) child.kill(signal); };
30
+ handlers.set(signal, handler);
31
+ process.on(signal, handler);
32
+ }
33
+ const cleanup = () => { for (const [signal, handler] of handlers) process.removeListener(signal, handler); };
34
+ child.once('error', err => {
35
+ cleanup();
36
+ console.error(`rian: could not start the executable: ${err.message}`);
37
+ process.exitCode = 1;
38
+ });
39
+ child.once('exit', (code, signal) => {
40
+ cleanup();
41
+ process.exitCode = code ?? ({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }[signal] ?? 1);
42
+ });
43
+ return child;
44
+ }
45
+
46
+ module.exports = { binaryPath, launch };
@@ -0,0 +1,20 @@
1
+ 'use strict';
2
+
3
+ const targets = Object.freeze([
4
+ { platform: 'darwin', arch: 'arm64', goos: 'darwin', goarch: 'arm64' },
5
+ { platform: 'darwin', arch: 'x64', goos: 'darwin', goarch: 'amd64' },
6
+ { platform: 'linux', arch: 'arm64', goos: 'linux', goarch: 'arm64' },
7
+ { platform: 'linux', arch: 'x64', goos: 'linux', goarch: 'amd64' },
8
+ { platform: 'win32', arch: 'arm64', goos: 'windows', goarch: 'arm64' },
9
+ { platform: 'win32', arch: 'x64', goos: 'windows', goarch: 'amd64' },
10
+ ]);
11
+
12
+ function targetFor(platform = process.platform, arch = process.arch) {
13
+ const target = targets.find(item => item.platform === platform && item.arch === arch);
14
+ if (!target) {
15
+ throw new Error(`Rian does not support ${platform}/${arch}. Use macOS, Linux, or Windows on x64 or arm64.`);
16
+ }
17
+ return { ...target, key: `${platform}-${arch}`, filename: platform === 'win32' ? 'rian.exe' : 'rian' };
18
+ }
19
+
20
+ module.exports = { targets, targetFor };
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@fatalmistake02/rian",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
4
+ "description": "Rian: your local project desk. A keyboard-first terminal app for Git, coding tools, and project commands.",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "rian": "bin/rian.cjs"
8
+ },
9
+ "engines": {
10
+ "node": ">=22"
11
+ },
12
+ "os": ["darwin", "linux", "win32"],
13
+ "cpu": ["arm64", "x64"],
14
+ "files": [
15
+ "bin/rian.cjs",
16
+ "lib/",
17
+ "dist/binaries/",
18
+ "cmd/",
19
+ "internal/",
20
+ "go.mod",
21
+ "go.sum",
22
+ "scripts/",
23
+ "test/npm/",
24
+ "README.md",
25
+ "LICENSE",
26
+ "THIRD_PARTY_NOTICES.txt"
27
+ ],
28
+ "scripts": {
29
+ "build": "node scripts/build-binaries.cjs",
30
+ "test": "node --test test/npm/launcher.test.cjs test/npm/package.test.cjs",
31
+ "check:package": "node scripts/check-package.cjs",
32
+ "postinstall": "node scripts/ensure-binary.cjs",
33
+ "prepack": "npm run check:package",
34
+ "prepublishOnly": "npm test && npm run check:package"
35
+ },
36
+ "keywords": ["rian", "tui", "terminal", "projects", "git", "developer-tools", "bubbletea"],
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "registry": "https://registry.npmjs.org/"
40
+ }
41
+ }
@@ -0,0 +1,60 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const crypto = require('node:crypto');
6
+ const { spawnSync } = require('node:child_process');
7
+ const { targets, targetFor } = require('../lib/platforms.cjs');
8
+
9
+ const root = path.resolve(__dirname, '..');
10
+ const pkg = require('../package.json');
11
+ const output = path.join(root, 'dist', 'binaries');
12
+
13
+ function runGo(args, env = {}, capture = false) {
14
+ const result = spawnSync('go', args, { cwd: root, env: { ...process.env, ...env },
15
+ encoding: 'utf8', stdio: capture ? 'pipe' : 'inherit', maxBuffer: 10 * 1024 * 1024 });
16
+ if (result.error || result.status !== 0) {
17
+ throw new Error(result.error?.message || result.stderr?.trim() || `go ${args[0]} failed (${result.status})`);
18
+ }
19
+ return result.stdout || '';
20
+ }
21
+
22
+ function collectLicenses() {
23
+ const goroot = runGo(['env', 'GOROOT'], {}, true).trim();
24
+ const parts = ['Rian bundles the Go runtime and the following third-party modules.\nTheir original copyright and license notices are reproduced below.\n',
25
+ `Go runtime\n${runGo(['version'], {}, true).trim()}\n\n${fs.readFileSync(path.join(goroot, 'LICENSE'), 'utf8')}`];
26
+ const modules = runGo(['list', '-m', '-f', '{{.Path}}|{{.Version}}|{{.Dir}}', 'all'], {}, true).trim().split(/\r?\n/);
27
+ for (const entry of modules) {
28
+ const [name, version, dir] = entry.split('|');
29
+ if (!version || !dir) continue;
30
+ const files = fs.readdirSync(dir).filter(name => /^(LICENSE|COPYING|NOTICE)([.-].*)?$/i.test(name)).sort();
31
+ if (!files.length) throw new Error(`Missing license notice for ${name}@${version}`);
32
+ for (const file of files) {
33
+ if (!fs.statSync(path.join(dir, file)).isFile()) continue;
34
+ parts.push(`${name}@${version} — ${file}\n\n${fs.readFileSync(path.join(dir, file), 'utf8')}`);
35
+ }
36
+ }
37
+ fs.writeFileSync(path.join(root, 'THIRD_PARTY_NOTICES.txt'), parts.join('\n\n' + '='.repeat(72) + '\n\n'));
38
+ }
39
+
40
+ function build() {
41
+ if (!/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(pkg.version)) throw new Error('Invalid package version');
42
+ fs.mkdirSync(output, { recursive: true });
43
+ const manifest = { version: pkg.version, targets: {} };
44
+ for (const item of targets) {
45
+ const target = targetFor(item.platform, item.arch);
46
+ const relative = `${target.key}/${target.filename}`;
47
+ const file = path.join(output, relative);
48
+ fs.mkdirSync(path.dirname(file), { recursive: true });
49
+ console.log(`Building Rian ${pkg.version} for ${target.key}…`);
50
+ runGo(['build', '-trimpath', '-buildvcs=false', '-ldflags', `-s -w -X main.version=${pkg.version}`, '-o', file, './cmd/rian'],
51
+ { GOOS: target.goos, GOARCH: target.goarch, CGO_ENABLED: '0' });
52
+ fs.chmodSync(file, 0o755);
53
+ manifest.targets[target.key] = { file: relative, sha256: crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') };
54
+ }
55
+ collectLicenses();
56
+ fs.writeFileSync(path.join(output, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
57
+ console.log('All six executables and dependency licenses are ready.');
58
+ }
59
+
60
+ try { build(); } catch (err) { console.error(`rian build: ${err.message}`); process.exitCode = 1; }