@doriandev/devcc 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +589 -389
  2. package/bin/devcc.mjs +50 -23
  3. package/dist/devcc/agents/agents.d.ts +119 -0
  4. package/dist/devcc/agents/agents.d.ts.map +1 -0
  5. package/dist/devcc/agents/agents.js +182 -0
  6. package/dist/devcc/agents/agents.js.map +1 -0
  7. package/dist/devcc/app/App.d.ts +23 -0
  8. package/dist/devcc/app/App.d.ts.map +1 -1
  9. package/dist/devcc/app/App.js +451 -19
  10. package/dist/devcc/app/App.js.map +1 -1
  11. package/dist/devcc/app/controlPlaneCommands.d.ts +14 -0
  12. package/dist/devcc/app/controlPlaneCommands.d.ts.map +1 -0
  13. package/dist/devcc/app/controlPlaneCommands.js +204 -0
  14. package/dist/devcc/app/controlPlaneCommands.js.map +1 -0
  15. package/dist/devcc/app/keymap.d.ts.map +1 -1
  16. package/dist/devcc/app/keymap.js +25 -3
  17. package/dist/devcc/app/keymap.js.map +1 -1
  18. package/dist/devcc/automation.d.ts +52 -0
  19. package/dist/devcc/automation.d.ts.map +1 -0
  20. package/dist/devcc/automation.js +339 -0
  21. package/dist/devcc/automation.js.map +1 -0
  22. package/dist/devcc/cli.d.ts.map +1 -1
  23. package/dist/devcc/cli.js +73 -3
  24. package/dist/devcc/cli.js.map +1 -1
  25. package/dist/devcc/components/Sidebar.d.ts.map +1 -1
  26. package/dist/devcc/components/Sidebar.js +1 -0
  27. package/dist/devcc/components/Sidebar.js.map +1 -1
  28. package/dist/devcc/components/primitives.d.ts.map +1 -1
  29. package/dist/devcc/components/primitives.js +3 -0
  30. package/dist/devcc/components/primitives.js.map +1 -1
  31. package/dist/devcc/core/commands.d.ts +33 -0
  32. package/dist/devcc/core/commands.d.ts.map +1 -1
  33. package/dist/devcc/core/commands.js.map +1 -1
  34. package/dist/devcc/core/config.d.ts +71 -3
  35. package/dist/devcc/core/config.d.ts.map +1 -1
  36. package/dist/devcc/core/config.js +189 -5
  37. package/dist/devcc/core/config.js.map +1 -1
  38. package/dist/devcc/core/state.d.ts +19 -1
  39. package/dist/devcc/core/state.d.ts.map +1 -1
  40. package/dist/devcc/core/state.js +6 -0
  41. package/dist/devcc/core/state.js.map +1 -1
  42. package/dist/devcc/environment/EnvironmentService.d.ts +58 -0
  43. package/dist/devcc/environment/EnvironmentService.d.ts.map +1 -0
  44. package/dist/devcc/environment/EnvironmentService.js +159 -0
  45. package/dist/devcc/environment/EnvironmentService.js.map +1 -0
  46. package/dist/devcc/environment/integrations.d.ts +52 -0
  47. package/dist/devcc/environment/integrations.d.ts.map +1 -0
  48. package/dist/devcc/environment/integrations.js +94 -0
  49. package/dist/devcc/environment/integrations.js.map +1 -0
  50. package/dist/devcc/environment/tools.d.ts +51 -0
  51. package/dist/devcc/environment/tools.d.ts.map +1 -0
  52. package/dist/devcc/environment/tools.js +99 -0
  53. package/dist/devcc/environment/tools.js.map +1 -0
  54. package/dist/devcc/git/GitService.d.ts +26 -0
  55. package/dist/devcc/git/GitService.d.ts.map +1 -1
  56. package/dist/devcc/git/GitService.js +71 -2
  57. package/dist/devcc/git/GitService.js.map +1 -1
  58. package/dist/devcc/git/worktrees.d.ts +88 -0
  59. package/dist/devcc/git/worktrees.d.ts.map +1 -0
  60. package/dist/devcc/git/worktrees.js +226 -0
  61. package/dist/devcc/git/worktrees.js.map +1 -0
  62. package/dist/devcc/index.d.ts +15 -3
  63. package/dist/devcc/index.d.ts.map +1 -1
  64. package/dist/devcc/index.js +14 -2
  65. package/dist/devcc/index.js.map +1 -1
  66. package/dist/devcc/init/detect.d.ts +55 -0
  67. package/dist/devcc/init/detect.d.ts.map +1 -0
  68. package/dist/devcc/init/detect.js +207 -0
  69. package/dist/devcc/init/detect.js.map +1 -0
  70. package/dist/devcc/init/prompt.d.ts +25 -0
  71. package/dist/devcc/init/prompt.d.ts.map +1 -0
  72. package/dist/devcc/init/prompt.js +156 -0
  73. package/dist/devcc/init/prompt.js.map +1 -0
  74. package/dist/devcc/platform/launch.d.ts +30 -0
  75. package/dist/devcc/platform/launch.d.ts.map +1 -0
  76. package/dist/devcc/platform/launch.js +93 -0
  77. package/dist/devcc/platform/launch.js.map +1 -0
  78. package/dist/devcc/services/ServiceManager.d.ts +10 -2
  79. package/dist/devcc/services/ServiceManager.d.ts.map +1 -1
  80. package/dist/devcc/services/ServiceManager.js +18 -4
  81. package/dist/devcc/services/ServiceManager.js.map +1 -1
  82. package/dist/devcc/services/node.d.ts +11 -0
  83. package/dist/devcc/services/node.d.ts.map +1 -1
  84. package/dist/devcc/services/node.js +20 -18
  85. package/dist/devcc/services/node.js.map +1 -1
  86. package/dist/devcc/services/ollama.d.ts +64 -5
  87. package/dist/devcc/services/ollama.d.ts.map +1 -1
  88. package/dist/devcc/services/ollama.js +118 -17
  89. package/dist/devcc/services/ollama.js.map +1 -1
  90. package/dist/devcc/services/remote.d.ts +90 -0
  91. package/dist/devcc/services/remote.d.ts.map +1 -0
  92. package/dist/devcc/services/remote.js +233 -0
  93. package/dist/devcc/services/remote.js.map +1 -0
  94. package/dist/devcc/services/types.d.ts +8 -1
  95. package/dist/devcc/services/types.d.ts.map +1 -1
  96. package/dist/devcc/services/types.js.map +1 -1
  97. package/dist/devcc/utils/exec.d.ts +6 -0
  98. package/dist/devcc/utils/exec.d.ts.map +1 -1
  99. package/dist/devcc/utils/exec.js +12 -2
  100. package/dist/devcc/utils/exec.js.map +1 -1
  101. package/dist/devcc/verify/verify.d.ts +107 -0
  102. package/dist/devcc/verify/verify.d.ts.map +1 -0
  103. package/dist/devcc/verify/verify.js +203 -0
  104. package/dist/devcc/verify/verify.js.map +1 -0
  105. package/dist/devcc/views/OverviewView.d.ts.map +1 -1
  106. package/dist/devcc/views/OverviewView.js +58 -2
  107. package/dist/devcc/views/OverviewView.js.map +1 -1
  108. package/dist/devcc/views/ServicesView.d.ts +2 -0
  109. package/dist/devcc/views/ServicesView.d.ts.map +1 -1
  110. package/dist/devcc/views/ServicesView.js +15 -0
  111. package/dist/devcc/views/ServicesView.js.map +1 -1
  112. package/dist/devcc/views/SystemView.d.ts +10 -1
  113. package/dist/devcc/views/SystemView.d.ts.map +1 -1
  114. package/dist/devcc/views/SystemView.js +127 -19
  115. package/dist/devcc/views/SystemView.js.map +1 -1
  116. package/dist/devcc/views/WorktreesView.d.ts +48 -0
  117. package/dist/devcc/views/WorktreesView.d.ts.map +1 -0
  118. package/dist/devcc/views/WorktreesView.js +235 -0
  119. package/dist/devcc/views/WorktreesView.js.map +1 -0
  120. package/package.json +1 -1
  121. package/src/devcc/agents/agents.ts +292 -0
  122. package/src/devcc/app/App.ts +529 -22
  123. package/src/devcc/app/controlPlaneCommands.ts +227 -0
  124. package/src/devcc/app/keymap.ts +25 -3
  125. package/src/devcc/automation.ts +409 -0
  126. package/src/devcc/cli.ts +77 -3
  127. package/src/devcc/components/Sidebar.ts +1 -0
  128. package/src/devcc/components/primitives.ts +3 -0
  129. package/src/devcc/core/commands.ts +34 -0
  130. package/src/devcc/core/config.ts +275 -11
  131. package/src/devcc/core/state.ts +25 -0
  132. package/src/devcc/environment/EnvironmentService.ts +215 -0
  133. package/src/devcc/environment/integrations.ts +133 -0
  134. package/src/devcc/environment/tools.ts +151 -0
  135. package/src/devcc/git/GitService.ts +104 -2
  136. package/src/devcc/git/worktrees.ts +256 -0
  137. package/src/devcc/index.ts +66 -2
  138. package/src/devcc/init/detect.ts +268 -0
  139. package/src/devcc/init/prompt.ts +179 -0
  140. package/src/devcc/platform/launch.ts +125 -0
  141. package/src/devcc/services/ServiceManager.ts +15 -3
  142. package/src/devcc/services/node.ts +17 -14
  143. package/src/devcc/services/ollama.ts +162 -19
  144. package/src/devcc/services/remote.ts +306 -0
  145. package/src/devcc/services/types.ts +9 -1
  146. package/src/devcc/utils/exec.ts +26 -2
  147. package/src/devcc/verify/verify.ts +316 -0
  148. package/src/devcc/views/OverviewView.ts +84 -2
  149. package/src/devcc/views/ServicesView.ts +17 -0
  150. package/src/devcc/views/SystemView.ts +144 -22
  151. package/src/devcc/views/WorktreesView.ts +270 -0
@@ -0,0 +1,306 @@
1
+ import type { RemoteMachineConfig } from "../core/config.js"
2
+ import { findExecutable, firstLine, type Exec, type Which } from "../environment/tools.js"
3
+ import { exec, probeTcpPort } from "../utils/exec.js"
4
+ import type { ScopedLogger } from "../utils/logger.js"
5
+ import type { ServiceAdapter, ServiceSnapshot, ServiceStatus, ServiceStatusItem } from "./types.js"
6
+
7
+ /**
8
+ * Remote development machines and Tailscale.
9
+ *
10
+ * Both are observed, never managed: devcc resolves hosts through the user's own
11
+ * SSH configuration, checks reachability, and reads Tailscale's structured
12
+ * status. Opening a session is a separate, explicit action (see `platform/`).
13
+ */
14
+
15
+ const REMOTE_REFRESH_MS = 60_000
16
+ const TAILSCALE_REFRESH_MS = 30_000
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // SSH configuration
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export interface SshTarget {
23
+ hostname: string
24
+ port: number
25
+ user?: string
26
+ }
27
+
28
+ /** Arguments for `ssh -G`, which prints the effective config without connecting. */
29
+ export function sshConfigArgs(machine: Pick<RemoteMachineConfig, "host" | "port" | "user">): string[] {
30
+ const args = ["-G"]
31
+ if (machine.port) args.push("-p", String(machine.port))
32
+ if (machine.user) args.push("-l", machine.user)
33
+ args.push(machine.host)
34
+ return args
35
+ }
36
+
37
+ /** Parse the `key value` lines of `ssh -G`. */
38
+ export function parseSshConfig(output: string): Partial<SshTarget> {
39
+ const target: Partial<SshTarget> = {}
40
+ for (const line of output.split("\n")) {
41
+ const [key, ...rest] = line.trim().split(/\s+/u)
42
+ const value = rest.join(" ")
43
+ if (!value) continue
44
+ if (key === "hostname") target.hostname = value
45
+ else if (key === "port" && Number.isInteger(Number(value))) target.port = Number(value)
46
+ else if (key === "user") target.user = value
47
+ }
48
+ return target
49
+ }
50
+
51
+ /** The destination `ssh` should connect to: the alias, so the user's config applies in full. */
52
+ export function sshDestination(machine: Pick<RemoteMachineConfig, "host" | "user">): string {
53
+ return machine.user ? `${machine.user}@${machine.host}` : machine.host
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Tailscale
58
+ // ---------------------------------------------------------------------------
59
+
60
+ export interface TailscaleNode {
61
+ hostName: string
62
+ /** MagicDNS name without the trailing dot. */
63
+ dnsName: string
64
+ ips: string[]
65
+ online: boolean
66
+ os?: string
67
+ }
68
+
69
+ export interface TailscaleStatus {
70
+ backendState: string
71
+ connected: boolean
72
+ self?: TailscaleNode
73
+ peers: TailscaleNode[]
74
+ tailnet?: string
75
+ }
76
+
77
+ function asNode(value: unknown): TailscaleNode | undefined {
78
+ if (typeof value !== "object" || value === null) return undefined
79
+ const node = value as Record<string, unknown>
80
+ return {
81
+ hostName: typeof node.HostName === "string" ? node.HostName : "",
82
+ dnsName: typeof node.DNSName === "string" ? node.DNSName.replace(/\.$/u, "") : "",
83
+ ips: Array.isArray(node.TailscaleIPs)
84
+ ? node.TailscaleIPs.filter((ip): ip is string => typeof ip === "string")
85
+ : [],
86
+ online: node.Online === true,
87
+ os: typeof node.OS === "string" ? node.OS : undefined,
88
+ }
89
+ }
90
+
91
+ /** Parse `tailscale status --json`. Returns null for output that is not a status object. */
92
+ export function parseTailscaleStatus(output: string): TailscaleStatus | null {
93
+ let data: unknown
94
+ try {
95
+ data = JSON.parse(output)
96
+ } catch {
97
+ return null
98
+ }
99
+ if (typeof data !== "object" || data === null) return null
100
+ const status = data as Record<string, unknown>
101
+ const backendState = typeof status.BackendState === "string" ? status.BackendState : "Unknown"
102
+ const peerRecord = typeof status.Peer === "object" && status.Peer !== null ? status.Peer : {}
103
+ const tailnet = (status.CurrentTailnet as { Name?: unknown } | undefined)?.Name
104
+ return {
105
+ backendState,
106
+ connected: backendState === "Running",
107
+ self: asNode(status.Self),
108
+ peers: Object.values(peerRecord)
109
+ .map(asNode)
110
+ .filter((node): node is TailscaleNode => node !== undefined),
111
+ tailnet: typeof tailnet === "string" ? tailnet : undefined,
112
+ }
113
+ }
114
+
115
+ /** Find the tailnet peer a configured host refers to: MagicDNS name, short name, host name or IP. */
116
+ export function findTailscalePeer(status: TailscaleStatus, host: string): TailscaleNode | undefined {
117
+ const wanted = host.toLowerCase().replace(/\.$/u, "")
118
+ return status.peers.find((peer) => {
119
+ const dns = peer.dnsName.toLowerCase()
120
+ return (
121
+ dns === wanted ||
122
+ dns.split(".")[0] === wanted ||
123
+ peer.hostName.toLowerCase() === wanted ||
124
+ peer.ips.includes(host)
125
+ )
126
+ })
127
+ }
128
+
129
+ export interface TailscaleServiceOptions {
130
+ /** Hosts of configured remote machines, shown when they are tailnet peers. */
131
+ machines?: readonly RemoteMachineConfig[]
132
+ run?: Exec
133
+ which?: Which
134
+ logger?: ScopedLogger
135
+ }
136
+
137
+ const TAILSCALE_APP_CLI = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
138
+
139
+ export class TailscaleService implements ServiceAdapter {
140
+ readonly id = "tailscale"
141
+ readonly name = "Tailscale"
142
+ readonly category = "remote" as const
143
+ readonly refreshIntervalMs = TAILSCALE_REFRESH_MS
144
+
145
+ private readonly run: Exec
146
+ private readonly which: Which
147
+ private last: TailscaleStatus | null = null
148
+
149
+ constructor(private readonly options: TailscaleServiceOptions = {}) {
150
+ this.run = options.run ?? exec
151
+ this.which = options.which ?? ((command) => findExecutable(command))
152
+ }
153
+
154
+ /** The most recent parsed status, for remote machines to look up peers. */
155
+ getLastStatus(): TailscaleStatus | null {
156
+ return this.last
157
+ }
158
+
159
+ private command(): string | undefined {
160
+ return this.which("tailscale") ?? this.which(TAILSCALE_APP_CLI)
161
+ }
162
+
163
+ async detect(): Promise<boolean> {
164
+ return this.command() !== undefined
165
+ }
166
+
167
+ async getStatus(): Promise<ServiceStatus> {
168
+ const checkedAt = new Date()
169
+ const command = this.command()
170
+ if (!command) return { state: "unavailable", detail: "not installed", checkedAt }
171
+
172
+ const result = await this.run(command, ["status", "--json"], {
173
+ timeoutMs: 4_000,
174
+ logger: this.options.logger,
175
+ })
176
+ const status = parseTailscaleStatus(result.stdout)
177
+ this.last = status
178
+ if (!status) {
179
+ const reason = result.timedOut ? "no answer" : (firstLine(result.stderr) ?? "status unavailable")
180
+ return { state: "unknown", detail: reason, checkedAt }
181
+ }
182
+
183
+ const items: ServiceStatusItem[] = []
184
+ for (const machine of this.options.machines ?? []) {
185
+ const peer = findTailscalePeer(status, machine.host)
186
+ if (!peer) continue
187
+ items.push({
188
+ name: machine.name,
189
+ state: peer.online ? "running" : "stopped",
190
+ detail: `${peer.dnsName || peer.hostName} · ${peer.online ? "online" : "offline"}`,
191
+ })
192
+ }
193
+
194
+ if (!status.connected) {
195
+ return { state: "stopped", detail: `disconnected (${status.backendState})`, items, checkedAt }
196
+ }
197
+ const online = status.peers.filter((peer) => peer.online).length
198
+ return {
199
+ state: "running",
200
+ endpoint: status.self?.dnsName || status.self?.ips[0],
201
+ detail: `connected · ${online}/${status.peers.length} peers online`,
202
+ items,
203
+ checkedAt,
204
+ }
205
+ }
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Remote machines
210
+ // ---------------------------------------------------------------------------
211
+
212
+ export interface RemoteMachineServiceOptions {
213
+ machine: RemoteMachineConfig
214
+ run?: Exec
215
+ probe?: (port: number, host: string, timeoutMs: number) => Promise<boolean>
216
+ /** Current Tailscale status, when the Tailscale adapter has one. */
217
+ tailscale?: () => TailscaleStatus | null
218
+ /** Status of an associated service by id. */
219
+ service?: (id: string) => ServiceSnapshot | undefined
220
+ logger?: ScopedLogger
221
+ }
222
+
223
+ export function remoteServiceId(machineId: string): string {
224
+ return `remote:${machineId}`
225
+ }
226
+
227
+ /**
228
+ * A configured remote machine as a service: reachability of its SSH port, the
229
+ * effective target from `ssh -G`, Tailscale presence and associated services.
230
+ * It connects to nothing but the TCP port, and only once a minute while polling.
231
+ */
232
+ export class RemoteMachineService implements ServiceAdapter {
233
+ readonly id: string
234
+ readonly name: string
235
+ readonly category = "remote" as const
236
+ readonly refreshIntervalMs = REMOTE_REFRESH_MS
237
+
238
+ private readonly run: Exec
239
+ private readonly probe: (port: number, host: string, timeoutMs: number) => Promise<boolean>
240
+ private target: SshTarget | null = null
241
+
242
+ constructor(private readonly options: RemoteMachineServiceOptions) {
243
+ this.id = remoteServiceId(options.machine.id)
244
+ this.name = options.machine.name
245
+ this.run = options.run ?? exec
246
+ this.probe = options.probe ?? probeTcpPort
247
+ }
248
+
249
+ get machine(): RemoteMachineConfig {
250
+ return this.options.machine
251
+ }
252
+
253
+ async detect(): Promise<boolean> {
254
+ return true
255
+ }
256
+
257
+ /** Effective host/port/user, resolved once through `ssh -G` and cached. */
258
+ async resolveTarget(): Promise<SshTarget> {
259
+ if (this.target) return this.target
260
+ const { machine } = this.options
261
+ const result = await this.run("ssh", sshConfigArgs(machine), {
262
+ timeoutMs: 3_000,
263
+ logger: this.options.logger,
264
+ })
265
+ const parsed = result.ok ? parseSshConfig(result.stdout) : {}
266
+ const target: SshTarget = {
267
+ hostname: parsed.hostname ?? machine.host,
268
+ port: machine.port ?? parsed.port ?? 22,
269
+ user: machine.user ?? parsed.user,
270
+ }
271
+ if (result.ok) this.target = target
272
+ return target
273
+ }
274
+
275
+ async getStatus(): Promise<ServiceStatus> {
276
+ const checkedAt = new Date()
277
+ const { machine } = this.options
278
+ const target = await this.resolveTarget()
279
+ const endpoint = `${target.user ? `${target.user}@` : ""}${target.hostname}:${target.port}`
280
+
281
+ const startedAt = Date.now()
282
+ const reachable = await this.probe(target.port, target.hostname, 2_000)
283
+ const latencyMs = Date.now() - startedAt
284
+
285
+ const tailscale = this.options.tailscale?.()
286
+ const peer = tailscale
287
+ ? (findTailscalePeer(tailscale, machine.host) ?? findTailscalePeer(tailscale, target.hostname))
288
+ : undefined
289
+
290
+ const items: ServiceStatusItem[] = (machine.services ?? []).map((serviceId) => {
291
+ const snapshot = this.options.service?.(serviceId)
292
+ return snapshot
293
+ ? { name: snapshot.name, state: snapshot.status.state, detail: snapshot.status.detail }
294
+ : { name: serviceId, state: "unknown" as const, detail: "not checked yet" }
295
+ })
296
+
297
+ const detail = [
298
+ reachable ? `ssh reachable · ${latencyMs}ms` : "ssh unreachable",
299
+ peer ? `tailscale ${peer.online ? "online" : "offline"}` : undefined,
300
+ ]
301
+ .filter(Boolean)
302
+ .join(" · ")
303
+
304
+ return { state: reachable ? "running" : "stopped", endpoint, detail, items, checkedAt }
305
+ }
306
+ }
@@ -31,11 +31,19 @@ export interface ServiceActionContext {
31
31
  confirm: (title: string, message: string) => Promise<boolean>
32
32
  }
33
33
 
34
+ export type ServiceCategory = "container" | "database" | "runtime" | "ai" | "web" | "remote"
35
+
34
36
  export interface ServiceAdapter {
35
37
  id: string
36
38
  name: string
37
39
  /** Grouping label shown in the Services view. */
38
- category: "container" | "database" | "runtime" | "ai" | "web"
40
+ category: ServiceCategory
41
+ /**
42
+ * Minimum time between polled status checks. Remote and slow adapters set
43
+ * this so an open dashboard does not generate constant network traffic; an
44
+ * explicit refresh always checks immediately.
45
+ */
46
+ refreshIntervalMs?: number
39
47
 
40
48
  /** Cheap check: is this service relevant to this project/host at all? */
41
49
  detect: () => Promise<boolean>
@@ -1,5 +1,6 @@
1
1
  import { spawn, type SpawnOptions } from "node:child_process"
2
2
  import { connect } from "node:net"
3
+ import type { ScopedLogger } from "./logger.js"
3
4
 
4
5
  /**
5
6
  * Small, safe process helpers.
@@ -17,6 +18,11 @@ export interface ExecOptions {
17
18
  maxBuffer?: number
18
19
  input?: string
19
20
  signal?: AbortSignal
21
+ /**
22
+ * When given, each run is logged at debug level: executable, arguments, cwd,
23
+ * duration, exit code and failure reason. The environment is never logged.
24
+ */
25
+ logger?: ScopedLogger
20
26
  }
21
27
 
22
28
  export interface ExecResult {
@@ -41,7 +47,15 @@ export function exec(
41
47
  args: readonly string[] = [],
42
48
  options: ExecOptions = {},
43
49
  ): Promise<ExecResult> {
44
- const { cwd, env, timeoutMs = DEFAULT_EXEC_TIMEOUT_MS, maxBuffer = 1024 * 1024, input, signal } = options
50
+ const {
51
+ cwd,
52
+ env,
53
+ timeoutMs = DEFAULT_EXEC_TIMEOUT_MS,
54
+ maxBuffer = 1024 * 1024,
55
+ input,
56
+ signal,
57
+ logger,
58
+ } = options
45
59
 
46
60
  return new Promise<ExecResult>((resolve) => {
47
61
  const startedAt = Date.now()
@@ -64,7 +78,17 @@ export function exec(
64
78
  settled = true
65
79
  clearTimeout(timer)
66
80
  signal?.removeEventListener("abort", onAbort)
67
- resolve({ ...result, durationMs: Date.now() - startedAt })
81
+ const durationMs = Date.now() - startedAt
82
+ logger?.debug("exec", {
83
+ command,
84
+ args,
85
+ cwd,
86
+ durationMs,
87
+ code: result.code,
88
+ ...(result.timedOut ? { timedOut: true } : {}),
89
+ ...(result.spawnError ? { error: result.spawnError.message } : {}),
90
+ })
91
+ resolve({ ...result, durationMs })
68
92
  }
69
93
 
70
94
  const timer = setTimeout(() => {
@@ -0,0 +1,316 @@
1
+ import { spawn } from "node:child_process"
2
+ import { isAbsolute, resolve } from "node:path"
3
+ import type { CommandConfigEntry } from "../core/config.js"
4
+ import { runScriptArgs, type PackageManager } from "../utils/packageManager.js"
5
+
6
+ /**
7
+ * Project verification: the quality gate behind `devcc verify`.
8
+ *
9
+ * Steps are resolved from config (a command id, a package.json script, or an
10
+ * inline command) and run sequentially. The loop is independent of how a step
11
+ * executes, so the CLI can spawn directly while the TUI runs the same steps
12
+ * through the ProcessManager and gets log capture for free.
13
+ */
14
+
15
+ export interface VerifyStepConfig {
16
+ id: string
17
+ name?: string
18
+ command: string
19
+ args?: string[]
20
+ cwd?: string
21
+ }
22
+
23
+ /** A command id or package.json script name, or an inline step. */
24
+ export type VerifyEntry = string | VerifyStepConfig
25
+
26
+ export interface VerifyStep {
27
+ id: string
28
+ name: string
29
+ command: string
30
+ args: string[]
31
+ cwd?: string
32
+ source: "command" | "script" | "inline"
33
+ }
34
+
35
+ export interface ResolveVerifyContext {
36
+ commands: readonly CommandConfigEntry[]
37
+ scripts: readonly { name: string }[]
38
+ packageManager: PackageManager
39
+ }
40
+
41
+ export interface ResolvedVerify {
42
+ steps: VerifyStep[]
43
+ issues: string[]
44
+ }
45
+
46
+ /**
47
+ * Turn config entries into runnable steps. A string names a configured
48
+ * command first, then a package.json script, so `verify: ["lint", "test"]`
49
+ * works without repeating what `package.json` already says.
50
+ */
51
+ export function resolveVerifySteps(
52
+ entries: readonly VerifyEntry[],
53
+ context: ResolveVerifyContext,
54
+ ): ResolvedVerify {
55
+ const steps: VerifyStep[] = []
56
+ const issues: string[] = []
57
+
58
+ for (const entry of entries) {
59
+ if (typeof entry !== "string") {
60
+ steps.push({
61
+ id: entry.id,
62
+ name: entry.name ?? entry.id,
63
+ command: entry.command,
64
+ args: entry.args ?? [],
65
+ cwd: entry.cwd,
66
+ source: "inline",
67
+ })
68
+ continue
69
+ }
70
+
71
+ const command = context.commands.find((candidate) => candidate.id === entry)
72
+ if (command) {
73
+ steps.push({
74
+ id: entry,
75
+ name: command.title,
76
+ command: command.command,
77
+ args: command.args ?? [],
78
+ cwd: command.cwd,
79
+ source: "command",
80
+ })
81
+ continue
82
+ }
83
+
84
+ if (context.scripts.some((script) => script.name === entry)) {
85
+ steps.push({
86
+ id: entry,
87
+ name: entry,
88
+ command: context.packageManager,
89
+ args: runScriptArgs(context.packageManager, entry),
90
+ source: "script",
91
+ })
92
+ continue
93
+ }
94
+
95
+ issues.push(`verify: "${entry}" is neither a command id nor a package.json script`)
96
+ }
97
+
98
+ const seen = new Set<string>()
99
+ for (const step of steps) {
100
+ if (seen.has(step.id)) issues.push(`verify: step "${step.id}" is listed more than once`)
101
+ seen.add(step.id)
102
+ }
103
+
104
+ return { steps, issues }
105
+ }
106
+
107
+ /** Config issues about `verify`, which must block a run rather than silently shrink it. */
108
+ export function isVerifyIssue(issue: string): boolean {
109
+ return issue.startsWith("verify") || issue.startsWith('"verify"')
110
+ }
111
+
112
+ export type VerifyStepStatus = "pending" | "running" | "passed" | "failed" | "skipped"
113
+
114
+ export interface VerifyStepResult {
115
+ id: string
116
+ name: string
117
+ command: string
118
+ args: string[]
119
+ status: VerifyStepStatus
120
+ exitCode: number | null
121
+ durationMs: number
122
+ stdout: string
123
+ stderr: string
124
+ /** Why the step could not run or was stopped (spawn error, signal, cancellation). */
125
+ error?: string
126
+ }
127
+
128
+ export interface VerifyReport {
129
+ ok: boolean
130
+ passed: number
131
+ failed: number
132
+ skipped: number
133
+ total: number
134
+ durationMs: number
135
+ steps: VerifyStepResult[]
136
+ }
137
+
138
+ export interface StepOutcome {
139
+ exitCode: number | null
140
+ stdout?: string
141
+ stderr?: string
142
+ error?: string
143
+ }
144
+
145
+ export type StepExecutor = (step: VerifyStep, signal: AbortSignal) => Promise<StepOutcome>
146
+
147
+ export interface RunVerificationOptions {
148
+ /** Called whenever a step changes state, with the full current list. */
149
+ onProgress?: (steps: readonly VerifyStepResult[]) => void
150
+ signal?: AbortSignal
151
+ }
152
+
153
+ /**
154
+ * Run every step in order. A failing step does not stop the run: the report
155
+ * shows every failure at once. Cancelling marks the remaining steps skipped.
156
+ */
157
+ export async function runVerification(
158
+ steps: readonly VerifyStep[],
159
+ execute: StepExecutor,
160
+ options: RunVerificationOptions = {},
161
+ ): Promise<VerifyReport> {
162
+ const startedAt = Date.now()
163
+ const signal = options.signal ?? new AbortController().signal
164
+ const results: VerifyStepResult[] = steps.map((step) => ({
165
+ id: step.id,
166
+ name: step.name,
167
+ command: step.command,
168
+ args: step.args,
169
+ status: "pending",
170
+ exitCode: null,
171
+ durationMs: 0,
172
+ stdout: "",
173
+ stderr: "",
174
+ }))
175
+ const progress = () => options.onProgress?.(results.map((result) => ({ ...result })))
176
+
177
+ for (const [index, step] of steps.entries()) {
178
+ const result = results[index] as VerifyStepResult
179
+ if (signal.aborted) {
180
+ result.status = "skipped"
181
+ result.error = "cancelled"
182
+ continue
183
+ }
184
+
185
+ result.status = "running"
186
+ progress()
187
+ const stepStart = Date.now()
188
+ let outcome: StepOutcome
189
+ try {
190
+ outcome = await execute(step, signal)
191
+ } catch (error) {
192
+ outcome = { exitCode: null, error: error instanceof Error ? error.message : String(error) }
193
+ }
194
+
195
+ result.durationMs = Date.now() - stepStart
196
+ result.exitCode = outcome.exitCode
197
+ result.stdout = outcome.stdout ?? ""
198
+ result.stderr = outcome.stderr ?? ""
199
+ if (outcome.error) result.error = outcome.error
200
+ result.status = outcome.exitCode === 0 && !outcome.error ? "passed" : "failed"
201
+ progress()
202
+ }
203
+ if (signal.aborted) progress()
204
+
205
+ const passed = results.filter((result) => result.status === "passed").length
206
+ const failed = results.filter((result) => result.status === "failed").length
207
+ const skipped = results.filter((result) => result.status === "skipped").length
208
+ return {
209
+ ok: failed === 0 && skipped === 0 && results.length > 0,
210
+ passed,
211
+ failed,
212
+ skipped,
213
+ total: results.length,
214
+ durationMs: Date.now() - startedAt,
215
+ steps: results,
216
+ }
217
+ }
218
+
219
+ /** One-line summary: `5/5 passed` or `3/5 passed, 2 failed (lint, test)`. */
220
+ export function summariseVerify(
221
+ report: Pick<VerifyReport, "passed" | "failed" | "skipped" | "total" | "steps">,
222
+ ): string {
223
+ const parts = [`${report.passed}/${report.total} passed`]
224
+ if (report.failed > 0) {
225
+ const names = report.steps.filter((step) => step.status === "failed").map((step) => step.name)
226
+ parts.push(`${report.failed} failed (${names.join(", ")})`)
227
+ }
228
+ if (report.skipped > 0) parts.push(`${report.skipped} skipped`)
229
+ return parts.join(", ")
230
+ }
231
+
232
+ // eslint-disable-next-line no-control-regex
233
+ const ANSI_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007]*(?:\u0007|\u001b\\)/gu
234
+
235
+ /** Remove terminal escape sequences, so JSON and logs stay plain text. */
236
+ export function stripAnsi(text: string): string {
237
+ return text.replace(ANSI_PATTERN, "")
238
+ }
239
+
240
+ /** Keep the end of a long output, where test runners and compilers put the verdict. */
241
+ export function keepTail(text: string, maxLength: number): string {
242
+ if (text.length <= maxLength) return text
243
+ return `…[${text.length - maxLength} characters truncated]\n${text.slice(-maxLength)}`
244
+ }
245
+
246
+ export interface SpawnExecutorOptions {
247
+ rootDir: string
248
+ env?: NodeJS.ProcessEnv
249
+ /** Characters of stdout/stderr kept per step (default 200k, from the end). */
250
+ maxOutput?: number
251
+ /** Live output, e.g. to mirror a failing step. */
252
+ onOutput?: (step: VerifyStep, chunk: string, stream: "stdout" | "stderr") => void
253
+ }
254
+
255
+ /**
256
+ * Executor for the CLI: `spawn(command, args)` in the project, colour disabled,
257
+ * output captured (tail kept) and ANSI-stripped. No shell, no timeout - a test
258
+ * suite takes as long as it takes - but an abort signal stops the child.
259
+ */
260
+ export function createSpawnExecutor(options: SpawnExecutorOptions): StepExecutor {
261
+ const maxOutput = options.maxOutput ?? 200_000
262
+ return (step, signal) =>
263
+ new Promise<StepOutcome>((resolvePromise) => {
264
+ const cwd = step.cwd
265
+ ? isAbsolute(step.cwd)
266
+ ? step.cwd
267
+ : resolve(options.rootDir, step.cwd)
268
+ : options.rootDir
269
+ let stdout = ""
270
+ let stderr = ""
271
+ const append = (current: string, chunk: string) => {
272
+ const next = current + chunk
273
+ return next.length > maxOutput * 2 ? next.slice(-maxOutput) : next
274
+ }
275
+
276
+ const child = spawn(step.command, step.args, {
277
+ cwd,
278
+ env: { ...(options.env ?? process.env), FORCE_COLOR: "0", NO_COLOR: "1" },
279
+ stdio: ["ignore", "pipe", "pipe"],
280
+ windowsHide: true,
281
+ })
282
+
283
+ const onAbort = () => child.kill("SIGTERM")
284
+ signal.addEventListener("abort", onAbort, { once: true })
285
+
286
+ child.stdout.setEncoding("utf8")
287
+ child.stdout.on("data", (chunk: string) => {
288
+ stdout = append(stdout, chunk)
289
+ options.onOutput?.(step, chunk, "stdout")
290
+ })
291
+ child.stderr.setEncoding("utf8")
292
+ child.stderr.on("data", (chunk: string) => {
293
+ stderr = append(stderr, chunk)
294
+ options.onOutput?.(step, chunk, "stderr")
295
+ })
296
+
297
+ let settled = false
298
+ const finish = (outcome: StepOutcome) => {
299
+ if (settled) return
300
+ settled = true
301
+ signal.removeEventListener("abort", onAbort)
302
+ resolvePromise({
303
+ ...outcome,
304
+ stdout: keepTail(stripAnsi(stdout), maxOutput),
305
+ stderr: keepTail(stripAnsi(stderr), maxOutput),
306
+ })
307
+ }
308
+
309
+ child.once("error", (error) => finish({ exitCode: null, error: error.message }))
310
+ child.once("close", (code, closeSignal) => {
311
+ if (signal.aborted) finish({ exitCode: code, error: "cancelled" })
312
+ else if (closeSignal) finish({ exitCode: code, error: `terminated by ${closeSignal}` })
313
+ else finish({ exitCode: code })
314
+ })
315
+ })
316
+ }