@doriandev/devcc 0.1.2

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 (284) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +659 -0
  3. package/bin/devcc.mjs +66 -0
  4. package/dist/devcc/app/App.d.ts +178 -0
  5. package/dist/devcc/app/App.d.ts.map +1 -0
  6. package/dist/devcc/app/App.js +1255 -0
  7. package/dist/devcc/app/App.js.map +1 -0
  8. package/dist/devcc/app/builtinCommands.d.ts +17 -0
  9. package/dist/devcc/app/builtinCommands.d.ts.map +1 -0
  10. package/dist/devcc/app/builtinCommands.js +389 -0
  11. package/dist/devcc/app/builtinCommands.js.map +1 -0
  12. package/dist/devcc/app/keymap.d.ts +21 -0
  13. package/dist/devcc/app/keymap.d.ts.map +1 -0
  14. package/dist/devcc/app/keymap.js +162 -0
  15. package/dist/devcc/app/keymap.js.map +1 -0
  16. package/dist/devcc/app/theme.d.ts +49 -0
  17. package/dist/devcc/app/theme.d.ts.map +1 -0
  18. package/dist/devcc/app/theme.js +68 -0
  19. package/dist/devcc/app/theme.js.map +1 -0
  20. package/dist/devcc/cli.d.ts +3 -0
  21. package/dist/devcc/cli.d.ts.map +1 -0
  22. package/dist/devcc/cli.js +113 -0
  23. package/dist/devcc/cli.js.map +1 -0
  24. package/dist/devcc/components/CommandPalette.d.ts +26 -0
  25. package/dist/devcc/components/CommandPalette.d.ts.map +1 -0
  26. package/dist/devcc/components/CommandPalette.js +113 -0
  27. package/dist/devcc/components/CommandPalette.js.map +1 -0
  28. package/dist/devcc/components/ConfirmDialog.d.ts +31 -0
  29. package/dist/devcc/components/ConfirmDialog.d.ts.map +1 -0
  30. package/dist/devcc/components/ConfirmDialog.js +147 -0
  31. package/dist/devcc/components/ConfirmDialog.js.map +1 -0
  32. package/dist/devcc/components/Footer.d.ts +22 -0
  33. package/dist/devcc/components/Footer.d.ts.map +1 -0
  34. package/dist/devcc/components/Footer.js +72 -0
  35. package/dist/devcc/components/Footer.js.map +1 -0
  36. package/dist/devcc/components/Header.d.ts +12 -0
  37. package/dist/devcc/components/Header.d.ts.map +1 -0
  38. package/dist/devcc/components/Header.js +84 -0
  39. package/dist/devcc/components/Header.js.map +1 -0
  40. package/dist/devcc/components/HelpOverlay.d.ts +18 -0
  41. package/dist/devcc/components/HelpOverlay.d.ts.map +1 -0
  42. package/dist/devcc/components/HelpOverlay.js +48 -0
  43. package/dist/devcc/components/HelpOverlay.js.map +1 -0
  44. package/dist/devcc/components/Overlay.d.ts +31 -0
  45. package/dist/devcc/components/Overlay.d.ts.map +1 -0
  46. package/dist/devcc/components/Overlay.js +60 -0
  47. package/dist/devcc/components/Overlay.js.map +1 -0
  48. package/dist/devcc/components/ProjectPicker.d.ts +26 -0
  49. package/dist/devcc/components/ProjectPicker.d.ts.map +1 -0
  50. package/dist/devcc/components/ProjectPicker.js +114 -0
  51. package/dist/devcc/components/ProjectPicker.js.map +1 -0
  52. package/dist/devcc/components/Sidebar.d.ts +42 -0
  53. package/dist/devcc/components/Sidebar.d.ts.map +1 -0
  54. package/dist/devcc/components/Sidebar.js +127 -0
  55. package/dist/devcc/components/Sidebar.js.map +1 -0
  56. package/dist/devcc/components/StatusBadge.d.ts +16 -0
  57. package/dist/devcc/components/StatusBadge.d.ts.map +1 -0
  58. package/dist/devcc/components/StatusBadge.js +30 -0
  59. package/dist/devcc/components/StatusBadge.js.map +1 -0
  60. package/dist/devcc/components/primitives.d.ts +62 -0
  61. package/dist/devcc/components/primitives.d.ts.map +1 -0
  62. package/dist/devcc/components/primitives.js +169 -0
  63. package/dist/devcc/components/primitives.js.map +1 -0
  64. package/dist/devcc/components/styled.d.ts +16 -0
  65. package/dist/devcc/components/styled.d.ts.map +1 -0
  66. package/dist/devcc/components/styled.js +35 -0
  67. package/dist/devcc/components/styled.js.map +1 -0
  68. package/dist/devcc/core/commands.d.ts +89 -0
  69. package/dist/devcc/core/commands.d.ts.map +1 -0
  70. package/dist/devcc/core/commands.js +68 -0
  71. package/dist/devcc/core/commands.js.map +1 -0
  72. package/dist/devcc/core/config.d.ts +81 -0
  73. package/dist/devcc/core/config.d.ts.map +1 -0
  74. package/dist/devcc/core/config.js +313 -0
  75. package/dist/devcc/core/config.js.map +1 -0
  76. package/dist/devcc/core/events.d.ts +21 -0
  77. package/dist/devcc/core/events.d.ts.map +1 -0
  78. package/dist/devcc/core/events.js +27 -0
  79. package/dist/devcc/core/events.js.map +1 -0
  80. package/dist/devcc/core/state.d.ts +104 -0
  81. package/dist/devcc/core/state.d.ts.map +1 -0
  82. package/dist/devcc/core/state.js +139 -0
  83. package/dist/devcc/core/state.js.map +1 -0
  84. package/dist/devcc/deps/DependencyService.d.ts +50 -0
  85. package/dist/devcc/deps/DependencyService.d.ts.map +1 -0
  86. package/dist/devcc/deps/DependencyService.js +109 -0
  87. package/dist/devcc/deps/DependencyService.js.map +1 -0
  88. package/dist/devcc/deps/parse.d.ts +49 -0
  89. package/dist/devcc/deps/parse.d.ts.map +1 -0
  90. package/dist/devcc/deps/parse.js +158 -0
  91. package/dist/devcc/deps/parse.js.map +1 -0
  92. package/dist/devcc/git/GitService.d.ts +60 -0
  93. package/dist/devcc/git/GitService.d.ts.map +1 -0
  94. package/dist/devcc/git/GitService.js +160 -0
  95. package/dist/devcc/git/GitService.js.map +1 -0
  96. package/dist/devcc/git/parse.d.ts +57 -0
  97. package/dist/devcc/git/parse.d.ts.map +1 -0
  98. package/dist/devcc/git/parse.js +174 -0
  99. package/dist/devcc/git/parse.js.map +1 -0
  100. package/dist/devcc/index.d.ts +37 -0
  101. package/dist/devcc/index.d.ts.map +1 -0
  102. package/dist/devcc/index.js +36 -0
  103. package/dist/devcc/index.js.map +1 -0
  104. package/dist/devcc/logs/LogBuffer.d.ts +86 -0
  105. package/dist/devcc/logs/LogBuffer.d.ts.map +1 -0
  106. package/dist/devcc/logs/LogBuffer.js +200 -0
  107. package/dist/devcc/logs/LogBuffer.js.map +1 -0
  108. package/dist/devcc/logs/merge.d.ts +36 -0
  109. package/dist/devcc/logs/merge.d.ts.map +1 -0
  110. package/dist/devcc/logs/merge.js +71 -0
  111. package/dist/devcc/logs/merge.js.map +1 -0
  112. package/dist/devcc/processes/ManagedProcess.d.ts +129 -0
  113. package/dist/devcc/processes/ManagedProcess.d.ts.map +1 -0
  114. package/dist/devcc/processes/ManagedProcess.js +80 -0
  115. package/dist/devcc/processes/ManagedProcess.js.map +1 -0
  116. package/dist/devcc/processes/ProcessManager.d.ts +112 -0
  117. package/dist/devcc/processes/ProcessManager.d.ts.map +1 -0
  118. package/dist/devcc/processes/ProcessManager.js +525 -0
  119. package/dist/devcc/processes/ProcessManager.js.map +1 -0
  120. package/dist/devcc/processes/graph.d.ts +35 -0
  121. package/dist/devcc/processes/graph.d.ts.map +1 -0
  122. package/dist/devcc/processes/graph.js +68 -0
  123. package/dist/devcc/processes/graph.js.map +1 -0
  124. package/dist/devcc/projects/ProjectRegistry.d.ts +52 -0
  125. package/dist/devcc/projects/ProjectRegistry.d.ts.map +1 -0
  126. package/dist/devcc/projects/ProjectRegistry.js +177 -0
  127. package/dist/devcc/projects/ProjectRegistry.js.map +1 -0
  128. package/dist/devcc/services/ServiceManager.d.ts +48 -0
  129. package/dist/devcc/services/ServiceManager.d.ts.map +1 -0
  130. package/dist/devcc/services/ServiceManager.js +146 -0
  131. package/dist/devcc/services/ServiceManager.js.map +1 -0
  132. package/dist/devcc/services/docker.d.ts +37 -0
  133. package/dist/devcc/services/docker.d.ts.map +1 -0
  134. package/dist/devcc/services/docker.js +179 -0
  135. package/dist/devcc/services/docker.js.map +1 -0
  136. package/dist/devcc/services/node.d.ts +40 -0
  137. package/dist/devcc/services/node.d.ts.map +1 -0
  138. package/dist/devcc/services/node.js +107 -0
  139. package/dist/devcc/services/node.js.map +1 -0
  140. package/dist/devcc/services/ollama.d.ts +27 -0
  141. package/dist/devcc/services/ollama.d.ts.map +1 -0
  142. package/dist/devcc/services/ollama.js +66 -0
  143. package/dist/devcc/services/ollama.js.map +1 -0
  144. package/dist/devcc/services/postgres.d.ts +30 -0
  145. package/dist/devcc/services/postgres.d.ts.map +1 -0
  146. package/dist/devcc/services/postgres.js +47 -0
  147. package/dist/devcc/services/postgres.js.map +1 -0
  148. package/dist/devcc/services/redis.d.ts +20 -0
  149. package/dist/devcc/services/redis.d.ts.map +1 -0
  150. package/dist/devcc/services/redis.js +39 -0
  151. package/dist/devcc/services/redis.js.map +1 -0
  152. package/dist/devcc/services/types.d.ts +51 -0
  153. package/dist/devcc/services/types.d.ts.map +1 -0
  154. package/dist/devcc/services/types.js +10 -0
  155. package/dist/devcc/services/types.js.map +1 -0
  156. package/dist/devcc/system/SystemMonitor.d.ts +87 -0
  157. package/dist/devcc/system/SystemMonitor.d.ts.map +1 -0
  158. package/dist/devcc/system/SystemMonitor.js +142 -0
  159. package/dist/devcc/system/SystemMonitor.js.map +1 -0
  160. package/dist/devcc/utils/errors.d.ts +31 -0
  161. package/dist/devcc/utils/errors.d.ts.map +1 -0
  162. package/dist/devcc/utils/errors.js +52 -0
  163. package/dist/devcc/utils/errors.js.map +1 -0
  164. package/dist/devcc/utils/exec.d.ts +41 -0
  165. package/dist/devcc/utils/exec.d.ts.map +1 -0
  166. package/dist/devcc/utils/exec.js +112 -0
  167. package/dist/devcc/utils/exec.js.map +1 -0
  168. package/dist/devcc/utils/format.d.ts +18 -0
  169. package/dist/devcc/utils/format.d.ts.map +1 -0
  170. package/dist/devcc/utils/format.js +85 -0
  171. package/dist/devcc/utils/format.js.map +1 -0
  172. package/dist/devcc/utils/fuzzy.d.ts +12 -0
  173. package/dist/devcc/utils/fuzzy.d.ts.map +1 -0
  174. package/dist/devcc/utils/fuzzy.js +20 -0
  175. package/dist/devcc/utils/fuzzy.js.map +1 -0
  176. package/dist/devcc/utils/logger.d.ts +41 -0
  177. package/dist/devcc/utils/logger.d.ts.map +1 -0
  178. package/dist/devcc/utils/logger.js +117 -0
  179. package/dist/devcc/utils/logger.js.map +1 -0
  180. package/dist/devcc/utils/packageManager.d.ts +39 -0
  181. package/dist/devcc/utils/packageManager.d.ts.map +1 -0
  182. package/dist/devcc/utils/packageManager.js +85 -0
  183. package/dist/devcc/utils/packageManager.js.map +1 -0
  184. package/dist/devcc/utils/paths.d.ts +12 -0
  185. package/dist/devcc/utils/paths.d.ts.map +1 -0
  186. package/dist/devcc/utils/paths.js +39 -0
  187. package/dist/devcc/utils/paths.js.map +1 -0
  188. package/dist/devcc/views/DependenciesView.d.ts +34 -0
  189. package/dist/devcc/views/DependenciesView.d.ts.map +1 -0
  190. package/dist/devcc/views/DependenciesView.js +170 -0
  191. package/dist/devcc/views/DependenciesView.js.map +1 -0
  192. package/dist/devcc/views/GitView.d.ts +29 -0
  193. package/dist/devcc/views/GitView.d.ts.map +1 -0
  194. package/dist/devcc/views/GitView.js +226 -0
  195. package/dist/devcc/views/GitView.js.map +1 -0
  196. package/dist/devcc/views/LogsView.d.ts +39 -0
  197. package/dist/devcc/views/LogsView.d.ts.map +1 -0
  198. package/dist/devcc/views/LogsView.js +249 -0
  199. package/dist/devcc/views/LogsView.js.map +1 -0
  200. package/dist/devcc/views/OverviewView.d.ts +16 -0
  201. package/dist/devcc/views/OverviewView.d.ts.map +1 -0
  202. package/dist/devcc/views/OverviewView.js +135 -0
  203. package/dist/devcc/views/OverviewView.js.map +1 -0
  204. package/dist/devcc/views/ProcessesView.d.ts +26 -0
  205. package/dist/devcc/views/ProcessesView.d.ts.map +1 -0
  206. package/dist/devcc/views/ProcessesView.js +161 -0
  207. package/dist/devcc/views/ProcessesView.js.map +1 -0
  208. package/dist/devcc/views/ScriptsView.d.ts +30 -0
  209. package/dist/devcc/views/ScriptsView.d.ts.map +1 -0
  210. package/dist/devcc/views/ScriptsView.js +168 -0
  211. package/dist/devcc/views/ScriptsView.js.map +1 -0
  212. package/dist/devcc/views/ServicesView.d.ts +28 -0
  213. package/dist/devcc/views/ServicesView.d.ts.map +1 -0
  214. package/dist/devcc/views/ServicesView.js +153 -0
  215. package/dist/devcc/views/ServicesView.js.map +1 -0
  216. package/dist/devcc/views/SettingsView.d.ts +20 -0
  217. package/dist/devcc/views/SettingsView.d.ts.map +1 -0
  218. package/dist/devcc/views/SettingsView.js +114 -0
  219. package/dist/devcc/views/SettingsView.js.map +1 -0
  220. package/dist/devcc/views/SystemView.d.ts +13 -0
  221. package/dist/devcc/views/SystemView.d.ts.map +1 -0
  222. package/dist/devcc/views/SystemView.js +82 -0
  223. package/dist/devcc/views/SystemView.js.map +1 -0
  224. package/dist/devcc/views/View.d.ts +35 -0
  225. package/dist/devcc/views/View.d.ts.map +1 -0
  226. package/dist/devcc/views/View.js +42 -0
  227. package/dist/devcc/views/View.js.map +1 -0
  228. package/package.json +79 -0
  229. package/src/devcc/app/App.ts +1463 -0
  230. package/src/devcc/app/builtinCommands.ts +429 -0
  231. package/src/devcc/app/keymap.ts +167 -0
  232. package/src/devcc/app/theme.ts +83 -0
  233. package/src/devcc/cli.ts +122 -0
  234. package/src/devcc/components/CommandPalette.ts +136 -0
  235. package/src/devcc/components/ConfirmDialog.ts +174 -0
  236. package/src/devcc/components/Footer.ts +85 -0
  237. package/src/devcc/components/Header.ts +93 -0
  238. package/src/devcc/components/HelpOverlay.ts +62 -0
  239. package/src/devcc/components/Overlay.ts +91 -0
  240. package/src/devcc/components/ProjectPicker.ts +138 -0
  241. package/src/devcc/components/Sidebar.ts +163 -0
  242. package/src/devcc/components/StatusBadge.ts +36 -0
  243. package/src/devcc/components/primitives.ts +213 -0
  244. package/src/devcc/components/styled.ts +44 -0
  245. package/src/devcc/core/commands.ts +140 -0
  246. package/src/devcc/core/config.ts +405 -0
  247. package/src/devcc/core/events.ts +41 -0
  248. package/src/devcc/core/state.ts +236 -0
  249. package/src/devcc/deps/DependencyService.ts +152 -0
  250. package/src/devcc/deps/parse.ts +210 -0
  251. package/src/devcc/git/GitService.ts +205 -0
  252. package/src/devcc/git/parse.ts +203 -0
  253. package/src/devcc/index.ts +115 -0
  254. package/src/devcc/logs/LogBuffer.ts +237 -0
  255. package/src/devcc/logs/merge.ts +87 -0
  256. package/src/devcc/processes/ManagedProcess.ts +195 -0
  257. package/src/devcc/processes/ProcessManager.ts +625 -0
  258. package/src/devcc/processes/graph.ts +98 -0
  259. package/src/devcc/projects/ProjectRegistry.ts +219 -0
  260. package/src/devcc/services/ServiceManager.ts +180 -0
  261. package/src/devcc/services/docker.ts +220 -0
  262. package/src/devcc/services/node.ts +137 -0
  263. package/src/devcc/services/ollama.ts +80 -0
  264. package/src/devcc/services/postgres.ts +57 -0
  265. package/src/devcc/services/redis.ts +47 -0
  266. package/src/devcc/services/types.ts +59 -0
  267. package/src/devcc/system/SystemMonitor.ts +207 -0
  268. package/src/devcc/utils/errors.ts +64 -0
  269. package/src/devcc/utils/exec.ts +165 -0
  270. package/src/devcc/utils/format.ts +83 -0
  271. package/src/devcc/utils/fuzzy.ts +44 -0
  272. package/src/devcc/utils/logger.ts +140 -0
  273. package/src/devcc/utils/packageManager.ts +112 -0
  274. package/src/devcc/utils/paths.ts +39 -0
  275. package/src/devcc/views/DependenciesView.ts +206 -0
  276. package/src/devcc/views/GitView.ts +259 -0
  277. package/src/devcc/views/LogsView.ts +300 -0
  278. package/src/devcc/views/OverviewView.ts +187 -0
  279. package/src/devcc/views/ProcessesView.ts +177 -0
  280. package/src/devcc/views/ScriptsView.ts +187 -0
  281. package/src/devcc/views/ServicesView.ts +188 -0
  282. package/src/devcc/views/SettingsView.ts +146 -0
  283. package/src/devcc/views/SystemView.ts +110 -0
  284. package/src/devcc/views/View.ts +63 -0
@@ -0,0 +1,98 @@
1
+ import type { ProcessDefinition } from "./ManagedProcess.js"
2
+
3
+ /**
4
+ * Dependency ordering for `dependsOn`.
5
+ *
6
+ * Pure so the ordering rules can be tested without spawning anything: the
7
+ * manager only has to execute the waves this produces.
8
+ */
9
+
10
+ export interface StartPlan {
11
+ /**
12
+ * Processes to start, grouped into waves. Everything in a wave can start
13
+ * concurrently; wave N+1 waits for wave N to become ready.
14
+ */
15
+ waves: string[][]
16
+ /** Edges pointing at ids that were never defined. */
17
+ missing: { id: string; dependsOn: string }[]
18
+ /** Ids that form a dependency cycle, if any. Their waves are omitted. */
19
+ cycle: string[]
20
+ }
21
+
22
+ /**
23
+ * Group ids into dependency waves (Kahn's algorithm).
24
+ *
25
+ * Unknown dependencies are reported and then ignored, so one typo cannot stop
26
+ * the rest of the project from starting. A cycle is reported rather than
27
+ * guessed at - devcc refuses to pick an arbitrary order.
28
+ */
29
+ export function planStartOrder(
30
+ definitions: readonly ProcessDefinition[],
31
+ only?: readonly string[],
32
+ ): StartPlan {
33
+ const byId = new Map(definitions.map((definition) => [definition.id, definition]))
34
+ const selected = new Set(only ?? definitions.map((definition) => definition.id))
35
+
36
+ // Only consider selected ids that actually exist.
37
+ const ids = [...selected].filter((id) => byId.has(id))
38
+
39
+ const missing: { id: string; dependsOn: string }[] = []
40
+ const dependencies = new Map<string, Set<string>>()
41
+
42
+ for (const id of ids) {
43
+ const declared = byId.get(id)?.dependsOn ?? []
44
+ const resolved = new Set<string>()
45
+ for (const dependency of declared) {
46
+ if (!byId.has(dependency)) {
47
+ missing.push({ id, dependsOn: dependency })
48
+ continue
49
+ }
50
+ // A dependency outside the selection is still a real edge, but it cannot
51
+ // gate a start we were not asked to perform.
52
+ if (ids.includes(dependency)) resolved.add(dependency)
53
+ }
54
+ dependencies.set(id, resolved)
55
+ }
56
+
57
+ const waves: string[][] = []
58
+ const placed = new Set<string>()
59
+
60
+ while (placed.size < ids.length) {
61
+ const wave = ids
62
+ .filter((id) => !placed.has(id))
63
+ .filter((id) => [...(dependencies.get(id) ?? [])].every((dependency) => placed.has(dependency)))
64
+
65
+ if (wave.length === 0) {
66
+ // Everything left depends on something unplaced: that is a cycle.
67
+ return { waves, missing, cycle: ids.filter((id) => !placed.has(id)).sort() }
68
+ }
69
+
70
+ for (const id of wave) placed.add(id)
71
+ waves.push(wave)
72
+ }
73
+
74
+ return { waves, missing, cycle: [] }
75
+ }
76
+
77
+ /**
78
+ * Ids that must be stopped before `id` can be, i.e. everything depending on it,
79
+ * transitively. Used to stop a stack from the top down.
80
+ */
81
+ export function dependentsOf(definitions: readonly ProcessDefinition[], id: string): string[] {
82
+ const direct = new Map<string, string[]>()
83
+ for (const definition of definitions) {
84
+ for (const dependency of definition.dependsOn ?? []) {
85
+ direct.set(dependency, [...(direct.get(dependency) ?? []), definition.id])
86
+ }
87
+ }
88
+
89
+ const found = new Set<string>()
90
+ const queue = [...(direct.get(id) ?? [])]
91
+ while (queue.length > 0) {
92
+ const next = queue.shift()
93
+ if (!next || found.has(next)) continue
94
+ found.add(next)
95
+ queue.push(...(direct.get(next) ?? []))
96
+ }
97
+ return [...found].sort()
98
+ }
@@ -0,0 +1,219 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
2
+ import { homedir } from "node:os"
3
+ import { basename, dirname, join, resolve } from "node:path"
4
+ import { errorMessage } from "../utils/errors.js"
5
+
6
+ export interface ProjectEntry {
7
+ /** Absolute path; the identity of a project. */
8
+ path: string
9
+ name: string
10
+ /** How devcc knows about it. */
11
+ source: "current" | "recent" | "configured" | "discovered"
12
+ /** Last time it was opened in devcc, for recents. */
13
+ lastOpenedAt?: Date
14
+ hasGit: boolean
15
+ hasPackageJson: boolean
16
+ }
17
+
18
+ /** Directories that are never worth scanning into. */
19
+ const IGNORED_DIRECTORIES = new Set([
20
+ "node_modules",
21
+ ".git",
22
+ ".next",
23
+ "dist",
24
+ "build",
25
+ "target",
26
+ "vendor",
27
+ "coverage",
28
+ ".turbo",
29
+ ".cache",
30
+ ])
31
+
32
+ export function isProjectDirectory(path: string, exists: (p: string) => boolean = existsSync): boolean {
33
+ return exists(join(path, "package.json")) || exists(join(path, ".git"))
34
+ }
35
+
36
+ /** Read `name` from a package.json, falling back to the directory name. */
37
+ export function projectNameFor(path: string): string {
38
+ try {
39
+ const raw: unknown = JSON.parse(readFileSync(join(path, "package.json"), "utf8"))
40
+ const name = typeof raw === "object" && raw !== null ? (raw as { name?: unknown }).name : undefined
41
+ if (typeof name === "string" && name.length > 0) return name
42
+ } catch {
43
+ // Fall through to the directory name.
44
+ }
45
+ return basename(path)
46
+ }
47
+
48
+ export interface DiscoverOptions {
49
+ /** Directories whose immediate children are candidate projects. */
50
+ searchDirs: readonly string[]
51
+ /** Maximum entries to return per search directory. */
52
+ limit?: number
53
+ readdir?: (dir: string) => string[]
54
+ exists?: (path: string) => boolean
55
+ isDirectory?: (path: string) => boolean
56
+ }
57
+
58
+ /**
59
+ * Find sibling projects one level below each search directory.
60
+ *
61
+ * Deliberately shallow: a recursive walk of a developer's home directory is slow
62
+ * and finds far more than anyone wants in a picker.
63
+ */
64
+ export function discoverProjects(options: DiscoverOptions): ProjectEntry[] {
65
+ const readdir = options.readdir ?? ((dir: string) => readdirSync(dir))
66
+ const exists = options.exists ?? existsSync
67
+ const isDirectory = options.isDirectory ?? ((path: string) => statSync(path).isDirectory())
68
+ const limit = options.limit ?? 200
69
+
70
+ const found = new Map<string, ProjectEntry>()
71
+
72
+ for (const searchDir of options.searchDirs) {
73
+ let entries: string[]
74
+ try {
75
+ entries = readdir(searchDir)
76
+ } catch {
77
+ continue
78
+ }
79
+
80
+ for (const entry of entries) {
81
+ if (found.size >= limit) break
82
+ if (entry.startsWith(".") || IGNORED_DIRECTORIES.has(entry)) continue
83
+
84
+ const path = join(searchDir, entry)
85
+ try {
86
+ if (!isDirectory(path)) continue
87
+ } catch {
88
+ continue
89
+ }
90
+ if (!isProjectDirectory(path, exists)) continue
91
+ if (found.has(path)) continue
92
+
93
+ found.set(path, {
94
+ path,
95
+ name: entry,
96
+ source: "discovered",
97
+ hasGit: exists(join(path, ".git")),
98
+ hasPackageJson: exists(join(path, "package.json")),
99
+ })
100
+ }
101
+ }
102
+
103
+ return [...found.values()]
104
+ }
105
+
106
+ interface RecentsFile {
107
+ projects?: { path?: unknown; lastOpenedAt?: unknown }[]
108
+ }
109
+
110
+ /** Where the cross-project recents list lives. */
111
+ export function recentsPath(home: string = homedir()): string {
112
+ return join(home, ".devcc", "recent-projects.json")
113
+ }
114
+
115
+ export function readRecents(path: string = recentsPath(), limit = 20): ProjectEntry[] {
116
+ try {
117
+ if (!existsSync(path)) return []
118
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as RecentsFile
119
+ const entries = Array.isArray(parsed.projects) ? parsed.projects : []
120
+
121
+ return entries
122
+ .map((entry): ProjectEntry | null => {
123
+ const projectPath = typeof entry.path === "string" ? entry.path : null
124
+ if (!projectPath) return null
125
+ const openedAt = typeof entry.lastOpenedAt === "string" ? new Date(entry.lastOpenedAt) : undefined
126
+ return {
127
+ path: projectPath,
128
+ name: basename(projectPath),
129
+ source: "recent" as const,
130
+ lastOpenedAt: openedAt && !Number.isNaN(openedAt.getTime()) ? openedAt : undefined,
131
+ hasGit: existsSync(join(projectPath, ".git")),
132
+ hasPackageJson: existsSync(join(projectPath, "package.json")),
133
+ }
134
+ })
135
+ .filter((entry): entry is ProjectEntry => entry !== null && existsSync(entry.path))
136
+ .slice(0, limit)
137
+ } catch {
138
+ return []
139
+ }
140
+ }
141
+
142
+ /** Record a project as most-recently opened. Failure is never fatal. */
143
+ export function recordRecent(projectPath: string, path: string = recentsPath(), limit = 20): string | null {
144
+ try {
145
+ const existing = readRecents(path, limit).filter((entry) => entry.path !== projectPath)
146
+ const next = [{ path: projectPath, lastOpenedAt: new Date().toISOString() }].concat(
147
+ existing.map((entry) => ({
148
+ path: entry.path,
149
+ lastOpenedAt: (entry.lastOpenedAt ?? new Date()).toISOString(),
150
+ })),
151
+ )
152
+ mkdirSync(dirname(path), { recursive: true })
153
+ writeFileSync(path, `${JSON.stringify({ projects: next.slice(0, limit) }, null, 2)}\n`, "utf8")
154
+ return null
155
+ } catch (error) {
156
+ return errorMessage(error)
157
+ }
158
+ }
159
+
160
+ export interface BuildProjectListOptions {
161
+ currentPath: string
162
+ /** Explicit paths from project config. */
163
+ configured?: readonly string[]
164
+ /** Extra directories to scan for siblings. Defaults to the parent directory. */
165
+ searchDirs?: readonly string[]
166
+ recents?: readonly ProjectEntry[]
167
+ discovered?: readonly ProjectEntry[]
168
+ }
169
+
170
+ /**
171
+ * Merge the sources into one de-duplicated, ranked list:
172
+ * current project first, then recents, then configured, then discovered.
173
+ */
174
+ export function buildProjectList(options: BuildProjectListOptions): ProjectEntry[] {
175
+ const current = resolve(options.currentPath)
176
+ const byPath = new Map<string, ProjectEntry>()
177
+
178
+ const add = (entry: ProjectEntry) => {
179
+ const path = resolve(entry.path)
180
+ const existing = byPath.get(path)
181
+ if (existing) {
182
+ // Keep the highest-priority source but carry over a known open time.
183
+ if (entry.lastOpenedAt && !existing.lastOpenedAt) existing.lastOpenedAt = entry.lastOpenedAt
184
+ return
185
+ }
186
+ byPath.set(path, { ...entry, path })
187
+ }
188
+
189
+ add({
190
+ path: current,
191
+ name: projectNameFor(current),
192
+ source: "current",
193
+ hasGit: existsSync(join(current, ".git")),
194
+ hasPackageJson: existsSync(join(current, "package.json")),
195
+ })
196
+
197
+ for (const entry of options.recents ?? []) add(entry)
198
+
199
+ for (const configured of options.configured ?? []) {
200
+ const path = resolve(current, configured)
201
+ if (!existsSync(path)) continue
202
+ add({
203
+ path,
204
+ name: basename(path),
205
+ source: "configured",
206
+ hasGit: existsSync(join(path, ".git")),
207
+ hasPackageJson: existsSync(join(path, "package.json")),
208
+ })
209
+ }
210
+
211
+ for (const entry of options.discovered ?? []) add(entry)
212
+
213
+ return [...byPath.values()]
214
+ }
215
+
216
+ /** Default search directories: the parent of the current project. */
217
+ export function defaultSearchDirs(currentPath: string): string[] {
218
+ return [dirname(resolve(currentPath))]
219
+ }
@@ -0,0 +1,180 @@
1
+ import { TypedEmitter } from "../core/events.js"
2
+ import { DevccError, errorMessage, toDevccError } from "../utils/errors.js"
3
+ import { withTimeout } from "../utils/exec.js"
4
+ import type { ScopedLogger } from "../utils/logger.js"
5
+ import type { ServiceActionContext, ServiceAdapter, ServiceSnapshot, ServiceStatus } from "./types.js"
6
+
7
+ export type ServiceManagerEvents = {
8
+ "service:list": [ServiceSnapshot[]]
9
+ "service:changed": [ServiceSnapshot]
10
+ "service:error": [DevccError]
11
+ }
12
+
13
+ export interface ServiceManagerOptions {
14
+ logger: ScopedLogger
15
+ /** Poll interval for status refreshes. */
16
+ intervalMs?: number
17
+ /** Per-adapter budget; a slow adapter must not stall the others. */
18
+ statusTimeoutMs?: number
19
+ }
20
+
21
+ const STALLED_STATUS: ServiceStatus = {
22
+ state: "unknown",
23
+ detail: "status check timed out",
24
+ checkedAt: new Date(0),
25
+ }
26
+
27
+ /**
28
+ * Runs detection once at startup (off the critical path) and then polls the
29
+ * adapters that reported themselves relevant.
30
+ *
31
+ * All adapters are queried concurrently with an individual timeout, so one
32
+ * unresponsive daemon cannot freeze the panel.
33
+ */
34
+ export class ServiceManager extends TypedEmitter<ServiceManagerEvents> {
35
+ private readonly adapters = new Map<string, ServiceAdapter>()
36
+ private readonly detected = new Set<string>()
37
+ private readonly snapshots = new Map<string, ServiceSnapshot>()
38
+ private timer: NodeJS.Timeout | null = null
39
+ private refreshing = false
40
+ private readonly intervalMs: number
41
+ private readonly statusTimeoutMs: number
42
+
43
+ constructor(private readonly options: ServiceManagerOptions) {
44
+ super()
45
+ this.intervalMs = options.intervalMs ?? 5_000
46
+ this.statusTimeoutMs = options.statusTimeoutMs ?? 4_000
47
+ }
48
+
49
+ register(adapter: ServiceAdapter): void {
50
+ this.adapters.set(adapter.id, adapter)
51
+ }
52
+
53
+ registerAll(adapters: readonly ServiceAdapter[]): void {
54
+ for (const adapter of adapters) this.register(adapter)
55
+ }
56
+
57
+ list(): ServiceSnapshot[] {
58
+ return [...this.snapshots.values()]
59
+ }
60
+
61
+ get(id: string): ServiceSnapshot | undefined {
62
+ return this.snapshots.get(id)
63
+ }
64
+
65
+ getAdapter(id: string): ServiceAdapter | undefined {
66
+ return this.adapters.get(id)
67
+ }
68
+
69
+ /** Ask every adapter whether it is relevant here. Failures mean "not relevant". */
70
+ async detectAll(): Promise<ServiceSnapshot[]> {
71
+ const results = await Promise.all(
72
+ [...this.adapters.values()].map(async (adapter) => {
73
+ try {
74
+ const relevant = await withTimeout(adapter.detect(), this.statusTimeoutMs, false)
75
+ return { adapter, relevant }
76
+ } catch (error) {
77
+ this.options.logger.debug("service detect failed", { id: adapter.id, error: errorMessage(error) })
78
+ return { adapter, relevant: false }
79
+ }
80
+ }),
81
+ )
82
+
83
+ this.detected.clear()
84
+ for (const { adapter, relevant } of results) {
85
+ if (relevant) this.detected.add(adapter.id)
86
+ else this.snapshots.delete(adapter.id)
87
+ }
88
+
89
+ await this.refresh()
90
+ return this.list()
91
+ }
92
+
93
+ start(): void {
94
+ if (this.timer) return
95
+ this.timer = setInterval(() => void this.refresh(), this.intervalMs)
96
+ this.timer.unref?.()
97
+ }
98
+
99
+ stop(): void {
100
+ if (!this.timer) return
101
+ clearInterval(this.timer)
102
+ this.timer = null
103
+ }
104
+
105
+ /** Refresh all detected adapters concurrently. Overlapping calls are skipped. */
106
+ async refresh(): Promise<ServiceSnapshot[]> {
107
+ if (this.refreshing) return this.list()
108
+ this.refreshing = true
109
+ try {
110
+ const targets = [...this.detected]
111
+ .map((id) => this.adapters.get(id))
112
+ .filter((a): a is ServiceAdapter => !!a)
113
+
114
+ await Promise.all(
115
+ targets.map(async (adapter) => {
116
+ const status = await withTimeout(
117
+ adapter.getStatus().catch((error: unknown) => {
118
+ this.options.logger.debug("service status failed", {
119
+ id: adapter.id,
120
+ error: errorMessage(error),
121
+ })
122
+ return {
123
+ state: "unknown",
124
+ detail: errorMessage(error),
125
+ checkedAt: new Date(),
126
+ } satisfies ServiceStatus
127
+ }),
128
+ this.statusTimeoutMs,
129
+ { ...STALLED_STATUS, checkedAt: new Date() },
130
+ )
131
+ this.setSnapshot(adapter, status)
132
+ }),
133
+ )
134
+
135
+ this.emit("service:list", this.list())
136
+ return this.list()
137
+ } finally {
138
+ this.refreshing = false
139
+ }
140
+ }
141
+
142
+ async runAction(
143
+ id: string,
144
+ action: "start" | "stop" | "restart",
145
+ context: ServiceActionContext,
146
+ ): Promise<void> {
147
+ const adapter = this.adapters.get(id)
148
+ if (!adapter) throw new DevccError({ scope: "service", message: `Unknown service "${id}"` })
149
+
150
+ const handler = adapter[action]
151
+ if (!handler) {
152
+ throw new DevccError({ scope: "service", message: `${adapter.name} does not support ${action}` })
153
+ }
154
+
155
+ try {
156
+ await handler.call(adapter, context)
157
+ this.options.logger.info("service action ok", { id, action })
158
+ } catch (error) {
159
+ const devccError = toDevccError(error, "service", `${adapter.name} ${action} failed`)
160
+ this.emit("service:error", devccError)
161
+ throw devccError
162
+ } finally {
163
+ await this.refresh()
164
+ }
165
+ }
166
+
167
+ private setSnapshot(adapter: ServiceAdapter, status: ServiceStatus): void {
168
+ const snapshot: ServiceSnapshot = {
169
+ id: adapter.id,
170
+ name: adapter.name,
171
+ category: adapter.category,
172
+ status,
173
+ canStart: typeof adapter.start === "function",
174
+ canStop: typeof adapter.stop === "function",
175
+ canRestart: typeof adapter.restart === "function",
176
+ }
177
+ this.snapshots.set(adapter.id, snapshot)
178
+ this.emit("service:changed", snapshot)
179
+ }
180
+ }