@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,625 @@
1
+ import { spawn, type ChildProcess } from "node:child_process"
2
+ import { isAbsolute, resolve } from "node:path"
3
+ import { TypedEmitter } from "../core/events.js"
4
+ import type { LogEntry, LogStore } from "../logs/LogBuffer.js"
5
+ import { DevccError, errorMessage } from "../utils/errors.js"
6
+ import { probeTcpPort } from "../utils/exec.js"
7
+ import type { ScopedLogger } from "../utils/logger.js"
8
+ import { planStartOrder, type StartPlan } from "./graph.js"
9
+ import {
10
+ canTransition,
11
+ crashBackoffDelay,
12
+ createManagedProcess,
13
+ DEFAULT_CRASH_POLICY,
14
+ DEFAULT_READINESS,
15
+ describeCommand,
16
+ isActive,
17
+ shouldAutoRestart,
18
+ type ManagedProcess,
19
+ type ProcessDefinition,
20
+ type ProcessStatus,
21
+ type ReadinessCheck,
22
+ } from "./ManagedProcess.js"
23
+
24
+ export const DEFAULT_KILL_GRACE_MS = 5_000
25
+
26
+ export type ProcessManagerEvents = {
27
+ /** Any change to a process snapshot (status, pid, exit code, ...). */
28
+ "process:changed": [ManagedProcess]
29
+ /** The set of known processes changed (added or removed). */
30
+ "process:list": [ManagedProcess[]]
31
+ "process:started": [ManagedProcess]
32
+ "process:exit": [ManagedProcess]
33
+ /** An automatic restart was scheduled after a crash. */
34
+ "process:retry": [ManagedProcess, number]
35
+ /** The readiness check passed (or timed out and was reported anyway). */
36
+ "process:ready": [ManagedProcess]
37
+ "process:error": [DevccError]
38
+ }
39
+
40
+ interface Runtime {
41
+ child: ChildProcess
42
+ /** Process group id we may signal, when `detached` spawning succeeded. */
43
+ pgid: number | null
44
+ killTimer: NodeJS.Timeout | null
45
+ exitPromise: Promise<void>
46
+ resolveExit: () => void
47
+ }
48
+
49
+ export interface ProcessManagerOptions {
50
+ logs: LogStore
51
+ logger: ScopedLogger
52
+ /** Default cwd for definitions that don't specify one. */
53
+ rootDir: string
54
+ env?: NodeJS.ProcessEnv
55
+ }
56
+
57
+ /**
58
+ * Owns every child process devcc launches.
59
+ *
60
+ * Design notes:
61
+ * - Commands are always `spawn(executable, args[])`; nothing is ever passed to a
62
+ * shell, so arguments containing spaces or quotes are safe by construction.
63
+ * - Children are spawned `detached`, which makes each one a process-group leader.
64
+ * Stopping signals the whole group (`kill(-pgid)`) so dev servers that fork
65
+ * workers do not leave orphans behind.
66
+ * - Stopping escalates SIGTERM -> SIGKILL after a grace period.
67
+ */
68
+ export class ProcessManager extends TypedEmitter<ProcessManagerEvents> {
69
+ private readonly definitions = new Map<string, ProcessDefinition>()
70
+ private readonly snapshots = new Map<string, ManagedProcess>()
71
+ private readonly runtimes = new Map<string, Runtime>()
72
+ /** Pending automatic-restart timers, keyed by process id. */
73
+ private readonly retryTimers = new Map<string, NodeJS.Timeout>()
74
+ /** In-flight readiness checks, so a stop can cancel the probe loop. */
75
+ private readonly readiness = new Map<string, { cancel: () => void; done: Promise<void> }>()
76
+ private readonly logs: LogStore
77
+ private readonly logger: ScopedLogger
78
+ private readonly rootDir: string
79
+ private readonly baseEnv: NodeJS.ProcessEnv
80
+ private disposed = false
81
+
82
+ constructor(options: ProcessManagerOptions) {
83
+ super()
84
+ this.logs = options.logs
85
+ this.logger = options.logger
86
+ this.rootDir = options.rootDir
87
+ this.baseEnv = options.env ?? process.env
88
+ }
89
+
90
+ /** Register (or replace, while stopped) a process definition. */
91
+ register(definition: ProcessDefinition): ManagedProcess {
92
+ const existing = this.snapshots.get(definition.id)
93
+ if (existing && isActive(existing.status)) {
94
+ this.definitions.set(definition.id, definition)
95
+ return existing
96
+ }
97
+
98
+ this.definitions.set(definition.id, definition)
99
+ const snapshot = createManagedProcess(definition)
100
+ this.snapshots.set(definition.id, snapshot)
101
+ this.logs.register(definition.id, definition.name)
102
+ this.emit("process:list", this.list())
103
+ return snapshot
104
+ }
105
+
106
+ registerAll(definitions: readonly ProcessDefinition[]): void {
107
+ for (const definition of definitions) this.register(definition)
108
+ }
109
+
110
+ /** Remove a stopped process. Refuses while it is still running. */
111
+ unregister(id: string): void {
112
+ const snapshot = this.snapshots.get(id)
113
+ if (snapshot && isActive(snapshot.status)) {
114
+ throw new DevccError({ scope: "process", message: `Cannot remove "${id}" while it is running` })
115
+ }
116
+ this.definitions.delete(id)
117
+ this.snapshots.delete(id)
118
+ this.logs.unregister(id)
119
+ this.emit("process:list", this.list())
120
+ }
121
+
122
+ list(): ManagedProcess[] {
123
+ return [...this.snapshots.values()]
124
+ }
125
+
126
+ get(id: string): ManagedProcess | undefined {
127
+ return this.snapshots.get(id)
128
+ }
129
+
130
+ getDefinition(id: string): ProcessDefinition | undefined {
131
+ return this.definitions.get(id)
132
+ }
133
+
134
+ isRunning(id: string): boolean {
135
+ const snapshot = this.snapshots.get(id)
136
+ return snapshot ? isActive(snapshot.status) : false
137
+ }
138
+
139
+ /**
140
+ * Start a process.
141
+ *
142
+ * A start the user asked for clears the crash budget; the automatic restart
143
+ * path passes `auto` so a crash loop cannot refill its own budget.
144
+ */
145
+ async start(id: string, options: { auto?: boolean } = {}): Promise<ManagedProcess> {
146
+ const definition = this.definitions.get(id)
147
+ const snapshot = this.snapshots.get(id)
148
+ if (!definition || !snapshot) {
149
+ throw new DevccError({ scope: "process", message: `Unknown process "${id}"` })
150
+ }
151
+ this.cancelRetry(id)
152
+ if (isActive(snapshot.status)) return snapshot
153
+ if (this.disposed) {
154
+ throw new DevccError({ scope: "process", message: "Shutting down; refusing to start new processes" })
155
+ }
156
+
157
+ this.update(id, {
158
+ status: "starting",
159
+ exitCode: undefined,
160
+ exitSignal: undefined,
161
+ lastError: undefined,
162
+ ...(options.auto ? {} : { crashRestarts: 0, crashRestartsExhausted: false }),
163
+ })
164
+
165
+ const cwd = definition.cwd ? resolveCwd(this.rootDir, definition.cwd) : this.rootDir
166
+ const buffer = this.logs.register(id, definition.name)
167
+ buffer.push(`$ ${describeCommand({ command: definition.command, args: definition.args })}`, "system")
168
+
169
+ let child: ChildProcess
170
+ try {
171
+ child = spawn(definition.command, definition.args ? [...definition.args] : [], {
172
+ cwd,
173
+ env: { ...this.baseEnv, ...definition.env, FORCE_COLOR: definition.env?.FORCE_COLOR ?? "0" },
174
+ stdio: ["ignore", "pipe", "pipe"],
175
+ // Own process group so we can signal the whole tree on stop.
176
+ detached: process.platform !== "win32",
177
+ windowsHide: true,
178
+ })
179
+ } catch (error) {
180
+ const message = `Failed to start ${definition.name}: ${errorMessage(error)}`
181
+ this.update(id, { status: "failed", lastError: message })
182
+ const devccError = new DevccError({ scope: "process", message, cause: error })
183
+ this.emit("process:error", devccError)
184
+ throw devccError
185
+ }
186
+
187
+ let resolveExit: () => void = () => {}
188
+ const exitPromise = new Promise<void>((resolve) => {
189
+ resolveExit = resolve
190
+ })
191
+
192
+ const runtime: Runtime = {
193
+ child,
194
+ pgid: child.pid !== undefined && process.platform !== "win32" ? child.pid : null,
195
+ killTimer: null,
196
+ exitPromise,
197
+ resolveExit,
198
+ }
199
+ this.runtimes.set(id, runtime)
200
+
201
+ child.stdout?.setEncoding("utf8")
202
+ child.stdout?.on("data", (chunk: string) => this.logs.appendChunk(id, chunk, "stdout"))
203
+ child.stderr?.setEncoding("utf8")
204
+ child.stderr?.on("data", (chunk: string) => this.logs.appendChunk(id, chunk, "stderr"))
205
+
206
+ child.once("error", (error: Error) => {
207
+ const message = `${definition.name}: ${error.message}`
208
+ this.logs.append(id, message, "stderr")
209
+ this.update(id, { status: "failed", lastError: message, pid: undefined })
210
+ this.finishRuntime(id)
211
+ this.emit("process:error", new DevccError({ scope: "process", message, cause: error }))
212
+ })
213
+
214
+ child.once("exit", (code, signal) => {
215
+ this.handleExit(id, code, signal)
216
+ })
217
+
218
+ this.update(id, {
219
+ pid: child.pid,
220
+ startedAt: new Date(),
221
+ stoppedAt: undefined,
222
+ ready: false,
223
+ readinessTimedOut: false,
224
+ })
225
+ this.logger.info("process started", { id, pid: child.pid, cwd })
226
+
227
+ if (definition.readiness) {
228
+ // Stay in `starting` until the check passes; `running` means usable.
229
+ this.beginReadinessCheck(id, definition.readiness)
230
+ } else {
231
+ this.markReady(id, false)
232
+ }
233
+
234
+ const started = this.snapshots.get(id)
235
+ if (started) this.emit("process:started", started)
236
+ return started ?? snapshot
237
+ }
238
+
239
+ /**
240
+ * Resolve once the process is ready (readiness passed or timed out), or
241
+ * immediately if it is not starting. Used to sequence dependent starts.
242
+ */
243
+ async waitForReady(id: string): Promise<ManagedProcess | undefined> {
244
+ const pending = this.readiness.get(id)
245
+ if (pending) await pending.done
246
+ return this.snapshots.get(id)
247
+ }
248
+
249
+ /**
250
+ * Start several processes honouring `dependsOn`: each wave waits for the
251
+ * previous one to become ready. Failures in one wave are reported and the
252
+ * next wave still starts, since an unrelated process should not be blocked.
253
+ */
254
+ async startAll(ids?: readonly string[]): Promise<StartPlan> {
255
+ const plan = planStartOrder([...this.definitions.values()], ids)
256
+
257
+ for (const edge of plan.missing) {
258
+ this.logs.append(edge.id, `dependsOn "${edge.dependsOn}" is not a known process; ignoring`, "stderr")
259
+ }
260
+ if (plan.cycle.length > 0) {
261
+ this.emit(
262
+ "process:error",
263
+ new DevccError({
264
+ scope: "process",
265
+ message: `Dependency cycle between ${plan.cycle.join(", ")}; not starting them`,
266
+ }),
267
+ )
268
+ }
269
+
270
+ for (const wave of plan.waves) {
271
+ const targets = wave.filter((id) => {
272
+ const snapshot = this.snapshots.get(id)
273
+ return snapshot && !isActive(snapshot.status)
274
+ })
275
+
276
+ await Promise.all(
277
+ targets.map(async (id) => {
278
+ try {
279
+ await this.start(id)
280
+ } catch (error) {
281
+ this.logger.warn("start failed", { id, error: errorMessage(error) })
282
+ }
283
+ }),
284
+ )
285
+ // Wait for the whole wave (including ones already running) before moving on.
286
+ await Promise.all(wave.map((id) => this.waitForReady(id)))
287
+ }
288
+
289
+ return plan
290
+ }
291
+
292
+ private beginReadinessCheck(id: string, check: ReadinessCheck): void {
293
+ this.cancelReadiness(id)
294
+
295
+ const timeoutMs = check.timeoutMs ?? DEFAULT_READINESS.timeoutMs
296
+ const intervalMs = check.intervalMs ?? DEFAULT_READINESS.intervalMs
297
+ let cancelled = false
298
+ let settle: () => void = () => {}
299
+ const done = new Promise<void>((resolve) => {
300
+ settle = resolve
301
+ })
302
+
303
+ const finish = (timedOut: boolean) => {
304
+ if (cancelled) return
305
+ cancelled = true
306
+ this.readiness.delete(id)
307
+ if (logListener) this.logs.off("log:append", logListener)
308
+ clearTimeout(timer)
309
+ if (this.isActiveRuntime(id)) this.markReady(id, timedOut)
310
+ settle()
311
+ }
312
+
313
+ // --- log pattern -------------------------------------------------------
314
+ let logListener: ((sourceId: string, entries: LogEntry[]) => void) | null = null
315
+ if (check.logPattern) {
316
+ let pattern: RegExp | null = null
317
+ try {
318
+ pattern = new RegExp(check.logPattern, "iu")
319
+ } catch {
320
+ this.logs.append(id, `readiness.logPattern is not a valid regex: ${check.logPattern}`, "stderr")
321
+ }
322
+ if (pattern) {
323
+ const matcher = pattern
324
+ logListener = (sourceId, entries) => {
325
+ if (sourceId !== id) return
326
+ if (entries.some((entry) => matcher.test(entry.text))) finish(false)
327
+ }
328
+ this.logs.on("log:append", logListener)
329
+ }
330
+ }
331
+
332
+ // --- port probe --------------------------------------------------------
333
+ const probe = async () => {
334
+ while (!cancelled) {
335
+ if (
336
+ check.port &&
337
+ (await probeTcpPort(check.port, check.host ?? "127.0.0.1", Math.min(intervalMs, 700)))
338
+ ) {
339
+ finish(false)
340
+ return
341
+ }
342
+ if (!check.port && !logListener) {
343
+ // Nothing to wait for after all.
344
+ finish(false)
345
+ return
346
+ }
347
+ await new Promise((resolve) => setTimeout(resolve, intervalMs))
348
+ }
349
+ }
350
+
351
+ const timer = setTimeout(() => {
352
+ this.logs.append(id, `Readiness check timed out after ${timeoutMs}ms; reporting as running`, "stderr")
353
+ finish(true)
354
+ }, timeoutMs)
355
+ timer.unref?.()
356
+
357
+ this.readiness.set(id, { cancel: () => finish(false), done })
358
+ void probe()
359
+ }
360
+
361
+ private cancelReadiness(id: string): void {
362
+ const pending = this.readiness.get(id)
363
+ if (!pending) return
364
+ this.readiness.delete(id)
365
+ pending.cancel()
366
+ }
367
+
368
+ private isActiveRuntime(id: string): boolean {
369
+ const snapshot = this.snapshots.get(id)
370
+ return this.runtimes.has(id) && snapshot?.status === "starting"
371
+ }
372
+
373
+ private markReady(id: string, timedOut: boolean): void {
374
+ this.update(id, { status: "running", ready: true, readinessTimedOut: timedOut })
375
+ const snapshot = this.snapshots.get(id)
376
+ if (snapshot) {
377
+ if (!timedOut && this.definitions.get(id)?.readiness) this.logs.append(id, "Ready", "system")
378
+ this.emit("process:ready", snapshot)
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Stop a managed process and everything it spawned.
384
+ * Resolves once the child has actually exited (or the grace period elapsed).
385
+ */
386
+ async stop(id: string, graceMs?: number): Promise<void> {
387
+ const snapshot = this.snapshots.get(id)
388
+ const runtime = this.runtimes.get(id)
389
+ if (!snapshot) throw new DevccError({ scope: "process", message: `Unknown process "${id}"` })
390
+ if (snapshot.ownership !== "managed") {
391
+ throw new DevccError({
392
+ scope: "process",
393
+ message: `"${snapshot.name}" was detected, not launched by devcc - refusing to signal it`,
394
+ })
395
+ }
396
+ // A manual stop cancels any pending automatic restart and clears the
397
+ // crash budget: the user is now in charge of this process.
398
+ this.cancelRetry(id)
399
+ this.cancelReadiness(id)
400
+ this.update(id, { crashRestarts: 0, retryAt: undefined, crashRestartsExhausted: false })
401
+
402
+ if (!runtime || !isActive(snapshot.status)) return
403
+
404
+ this.update(id, { status: "stopping" })
405
+ this.logs.append(id, "Stopping…", "system")
406
+
407
+ const grace = graceMs ?? this.definitions.get(id)?.killGraceMs ?? DEFAULT_KILL_GRACE_MS
408
+ this.signal(runtime, "SIGTERM")
409
+
410
+ runtime.killTimer = setTimeout(() => {
411
+ if (this.runtimes.has(id)) {
412
+ this.logs.append(id, "Grace period elapsed, sending SIGKILL", "system")
413
+ this.signal(runtime, "SIGKILL")
414
+ }
415
+ }, grace)
416
+ runtime.killTimer.unref?.()
417
+
418
+ await runtime.exitPromise
419
+ }
420
+
421
+ async restart(id: string): Promise<ManagedProcess> {
422
+ const snapshot = this.snapshots.get(id)
423
+ if (!snapshot) throw new DevccError({ scope: "process", message: `Unknown process "${id}"` })
424
+ this.cancelRetry(id)
425
+ if (isActive(snapshot.status)) await this.stop(id)
426
+ const restarted = await this.start(id)
427
+ this.update(id, { restarts: (this.snapshots.get(id)?.restarts ?? 0) + 1 })
428
+ return this.snapshots.get(id) ?? restarted
429
+ }
430
+
431
+ /** Start every definition marked `autoStart`. Failures are reported, not thrown. */
432
+ async startAutoStart(): Promise<void> {
433
+ const targets = [...this.definitions.values()].filter((definition) => definition.autoStart)
434
+ if (targets.length === 0) return
435
+ await this.startAll(targets.map((definition) => definition.id))
436
+ }
437
+
438
+ /**
439
+ * Stop everything devcc launched. Called on shutdown; never touches processes
440
+ * that were only detected.
441
+ */
442
+ async stopAll(graceMs = DEFAULT_KILL_GRACE_MS): Promise<void> {
443
+ this.disposed = true
444
+ for (const id of [...this.retryTimers.keys()]) this.cancelRetry(id)
445
+ const running = this.list().filter((snapshot) => isActive(snapshot.status))
446
+ await Promise.all(
447
+ running.map(async (snapshot) => {
448
+ try {
449
+ await this.stop(snapshot.id, graceMs)
450
+ } catch (error) {
451
+ this.logger.warn("stop failed during shutdown", { id: snapshot.id, error: errorMessage(error) })
452
+ }
453
+ }),
454
+ )
455
+ }
456
+
457
+ private handleExit(id: string, code: number | null, signal: NodeJS.Signals | null): void {
458
+ this.cancelReadiness(id)
459
+ this.logs.flush(id)
460
+ const snapshot = this.snapshots.get(id)
461
+ const wasStopping = snapshot?.status === "stopping"
462
+ const kind = snapshot?.kind ?? "service"
463
+
464
+ const failed = !wasStopping && code !== 0 && code !== null
465
+ const nextStatus: ProcessStatus = failed ? "failed" : "stopped"
466
+
467
+ this.logs.append(
468
+ id,
469
+ signal ? `Exited via ${signal}` : `Exited with code ${code ?? "unknown"}`,
470
+ failed ? "stderr" : "system",
471
+ )
472
+
473
+ // A run that lasted long enough counts as healthy and clears the budget.
474
+ const definition = this.definitions.get(id)
475
+ const ranFor = snapshot?.startedAt ? Date.now() - snapshot.startedAt.getTime() : 0
476
+ const healthyAfter = definition?.healthyAfterMs ?? DEFAULT_CRASH_POLICY.healthyAfterMs
477
+ const wasHealthy = ranFor >= healthyAfter
478
+ const crashRestarts = failed ? (wasHealthy ? 0 : (snapshot?.crashRestarts ?? 0)) : 0
479
+
480
+ this.update(id, {
481
+ status: nextStatus,
482
+ pid: undefined,
483
+ exitCode: code ?? undefined,
484
+ exitSignal: signal ?? undefined,
485
+ stoppedAt: new Date(),
486
+ crashRestarts,
487
+ retryAt: undefined,
488
+ ready: false,
489
+ lastError: failed ? `${snapshot?.name ?? id} exited with code ${code ?? "?"}` : undefined,
490
+ })
491
+
492
+ this.finishRuntime(id)
493
+
494
+ const updated = this.snapshots.get(id)
495
+ if (updated) {
496
+ this.logger.info("process exited", { id, code, signal, kind })
497
+ this.emit("process:exit", updated)
498
+ if (failed) this.considerAutoRestart(id)
499
+ if (failed) {
500
+ this.emit(
501
+ "process:error",
502
+ new DevccError({
503
+ scope: "process",
504
+ message: `${updated.name} exited with code ${code ?? "?"}`,
505
+ detail: `Command: ${describeCommand(updated)}`,
506
+ }),
507
+ )
508
+ }
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Schedule an automatic restart for a crashed process, if its definition opted
514
+ * in and it still has budget left. Backoff doubles per consecutive crash, so a
515
+ * process that is broken rather than flaky stops thrashing quickly.
516
+ */
517
+ private considerAutoRestart(id: string): void {
518
+ if (this.disposed) return
519
+ const definition = this.definitions.get(id)
520
+ const snapshot = this.snapshots.get(id)
521
+ if (!definition || !snapshot) return
522
+
523
+ if (!definition.restartOnCrash) return
524
+
525
+ if (!shouldAutoRestart(definition, snapshot)) {
526
+ if (!snapshot.crashRestartsExhausted) {
527
+ this.update(id, { crashRestartsExhausted: true })
528
+ this.logs.append(id, `Giving up after ${snapshot.crashRestarts} automatic restarts`, "system")
529
+ this.emit(
530
+ "process:error",
531
+ new DevccError({
532
+ scope: "process",
533
+ message: `${definition.name} keeps crashing; automatic restarts exhausted`,
534
+ detail: `Last exit code ${snapshot.exitCode ?? "?"}. Start it manually to try again.`,
535
+ }),
536
+ )
537
+ }
538
+ return
539
+ }
540
+
541
+ const attempt = snapshot.crashRestarts + 1
542
+ const delay = crashBackoffDelay(attempt, definition.crashBackoffMs, definition.maxCrashBackoffMs)
543
+ const retryAt = new Date(Date.now() + delay)
544
+
545
+ this.update(id, { crashRestarts: attempt, retryAt, crashRestartsExhausted: false })
546
+ this.logs.append(
547
+ id,
548
+ `Restarting automatically in ${Math.round(delay / 100) / 10}s (attempt ${attempt})`,
549
+ "system",
550
+ )
551
+
552
+ const updated = this.snapshots.get(id)
553
+ if (updated) this.emit("process:retry", updated, delay)
554
+
555
+ const timer = setTimeout(() => {
556
+ this.retryTimers.delete(id)
557
+ if (this.disposed) return
558
+ // The user may have started or removed it while we waited.
559
+ const current = this.snapshots.get(id)
560
+ if (!current || isActive(current.status)) return
561
+ this.update(id, { retryAt: undefined })
562
+ void this.start(id, { auto: true }).catch((error: unknown) => {
563
+ this.logger.warn("automatic restart failed", { id, error: errorMessage(error) })
564
+ })
565
+ }, delay)
566
+ timer.unref?.()
567
+ this.retryTimers.set(id, timer)
568
+ }
569
+
570
+ private cancelRetry(id: string): void {
571
+ const timer = this.retryTimers.get(id)
572
+ if (!timer) return
573
+ clearTimeout(timer)
574
+ this.retryTimers.delete(id)
575
+ this.update(id, { retryAt: undefined })
576
+ }
577
+
578
+ private finishRuntime(id: string): void {
579
+ const runtime = this.runtimes.get(id)
580
+ if (!runtime) return
581
+ if (runtime.killTimer) clearTimeout(runtime.killTimer)
582
+ this.runtimes.delete(id)
583
+ runtime.resolveExit()
584
+ }
585
+
586
+ /** Signal the child's process group when we have one, else the child alone. */
587
+ private signal(runtime: Runtime, signal: NodeJS.Signals): void {
588
+ const { child, pgid } = runtime
589
+ try {
590
+ if (pgid !== null) {
591
+ process.kill(-pgid, signal)
592
+ return
593
+ }
594
+ } catch (error) {
595
+ const code = (error as NodeJS.ErrnoException).code
596
+ // ESRCH: the group is already gone. Anything else: fall back to the child.
597
+ if (code === "ESRCH") return
598
+ }
599
+ try {
600
+ child.kill(signal)
601
+ } catch {
602
+ // The process is already gone.
603
+ }
604
+ }
605
+
606
+ private update(id: string, patch: Partial<ManagedProcess>): void {
607
+ const current = this.snapshots.get(id)
608
+ if (!current) return
609
+
610
+ if (patch.status && patch.status !== current.status && !canTransition(current.status, patch.status)) {
611
+ this.logger.debug("ignored illegal status transition", { id, from: current.status, to: patch.status })
612
+ const { status: _ignored, ...rest } = patch
613
+ if (Object.keys(rest).length === 0) return
614
+ patch = rest
615
+ }
616
+
617
+ const next: ManagedProcess = { ...current, ...patch }
618
+ this.snapshots.set(id, next)
619
+ this.emit("process:changed", next)
620
+ }
621
+ }
622
+
623
+ function resolveCwd(rootDir: string, cwd: string): string {
624
+ return isAbsolute(cwd) ? cwd : resolve(rootDir, cwd)
625
+ }