@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
@@ -1,9 +1,12 @@
1
1
  import { existsSync } from "node:fs"
2
2
  import { join } from "node:path"
3
+ import type { AgentConfig } from "../agents/agents.js"
3
4
  import type { ProcessDefinition, ProcessKind, ReadinessCheck } from "../processes/ManagedProcess.js"
5
+ import type { ServiceCategory } from "../services/types.js"
4
6
  import { errorMessage } from "../utils/errors.js"
5
7
  import type { LogLevel } from "../utils/logger.js"
6
8
  import { isLogLevel } from "../utils/logger.js"
9
+ import type { VerifyEntry } from "../verify/verify.js"
7
10
 
8
11
  /** A command declared in project config: runs an executable and streams its logs. */
9
12
  export interface CommandConfigEntry {
@@ -18,14 +21,70 @@ export interface CommandConfigEntry {
18
21
  confirm?: { title: string; message: string } | boolean
19
22
  }
20
23
 
21
- /** An extra port/HTTP endpoint to watch in the Services view. */
24
+ /**
25
+ * An extra endpoint to watch in the Services view.
26
+ *
27
+ * The default `type: "port"` is a TCP (or HTTP, with `healthPath`) probe.
28
+ * `type: "ollama"` watches an Ollama server at `baseUrl` - localhost, a LAN
29
+ * hostname or a Tailscale name - including its models.
30
+ */
22
31
  export interface ServiceConfigEntry {
23
32
  id: string
24
33
  name: string
34
+ /** Required for port services; derived from `baseUrl` for Ollama. */
25
35
  port: number
26
36
  host?: string
27
37
  healthPath?: string
28
- category?: "container" | "database" | "runtime" | "ai" | "web"
38
+ category?: ServiceCategory
39
+ type?: "port" | "ollama"
40
+ /** Ollama only, e.g. `http://gpu-server:11434`. */
41
+ baseUrl?: string
42
+ }
43
+
44
+ /** How to open a directory in an editor. `command` wins over `application`. */
45
+ export interface EditorConfig {
46
+ /** CLI launcher on PATH: `code`, `zed`, `cursor`, `webstorm`. */
47
+ command?: string
48
+ /** Extra arguments placed before the path. */
49
+ args?: string[]
50
+ /** macOS application name for `open -a`, e.g. `WebStorm`. */
51
+ application?: string
52
+ }
53
+
54
+ /**
55
+ * How to open a terminal. By default devcc uses Terminal on macOS.
56
+ * `application` names another macOS app; `command` + `args` launches anything
57
+ * else, with `{path}` in an argument replaced by the directory.
58
+ */
59
+ export interface TerminalConfig {
60
+ application?: string
61
+ command?: string
62
+ /** Arguments to open a directory (default `["{path}"]`). */
63
+ args?: string[]
64
+ /** Arguments that make `command` run a program, e.g. `["start", "--"]` for WezTerm; used for SSH. */
65
+ execArgs?: string[]
66
+ }
67
+
68
+ export interface WorktreesConfig {
69
+ /** Where new worktrees go, relative to the main worktree (default `..`). */
70
+ directory?: string
71
+ }
72
+
73
+ /**
74
+ * A development machine reached over SSH. `host` may be an alias from
75
+ * `~/.ssh/config`; devcc resolves it with `ssh -G` and never stores credentials.
76
+ */
77
+ export interface RemoteMachineConfig {
78
+ id: string
79
+ name: string
80
+ host: string
81
+ transport: "ssh"
82
+ /** Overrides the port from SSH config. */
83
+ port?: number
84
+ /** Overrides the user from SSH config. */
85
+ user?: string
86
+ /** Ids of `services` entries that run on this machine. */
87
+ services?: string[]
29
88
  }
30
89
 
31
90
  export interface RefreshIntervals {
@@ -52,12 +111,29 @@ export interface DevccConfig {
52
111
  /** Cap for each log source's ring buffer. */
53
112
  maxLogLines?: number
54
113
  logLevel?: LogLevel
114
+ /** Editor for "open in editor" (default: `$VISUAL`/`$EDITOR` is not used; see README). */
115
+ editor?: EditorConfig
116
+ terminal?: TerminalConfig
117
+ /** Override or add AI agents, keyed by id (`claude`, `codex`, or your own). */
118
+ agents?: Record<string, AgentConfig>
119
+ worktrees?: WorktreesConfig
120
+ /** Steps for `devcc verify`: command ids, package.json scripts, or inline steps. */
121
+ verify?: VerifyEntry[]
122
+ remoteMachines?: RemoteMachineConfig[]
55
123
  }
56
124
 
57
- export type ResolvedConfig = Required<
58
- Pick<DevccConfig, "processes" | "commands" | "services" | "projects" | "projectSearchDirs">
59
- > &
60
- Omit<DevccConfig, "processes" | "commands" | "services" | "projects" | "projectSearchDirs"> & {
125
+ type ListFields =
126
+ | "processes"
127
+ | "commands"
128
+ | "services"
129
+ | "projects"
130
+ | "projectSearchDirs"
131
+ | "verify"
132
+ | "remoteMachines"
133
+ | "agents"
134
+
135
+ export type ResolvedConfig = Required<Pick<DevccConfig, ListFields>> &
136
+ Omit<DevccConfig, ListFields> & {
61
137
  refresh: Required<RefreshIntervals>
62
138
  }
63
139
 
@@ -74,6 +150,9 @@ export const EMPTY_CONFIG: ResolvedConfig = {
74
150
  services: [],
75
151
  projects: [],
76
152
  projectSearchDirs: [],
153
+ verify: [],
154
+ remoteMachines: [],
155
+ agents: {},
77
156
  refresh: DEFAULT_REFRESH,
78
157
  }
79
158
 
@@ -231,15 +310,41 @@ export function validateConfig(raw: unknown): ValidationResult {
231
310
  continue
232
311
  }
233
312
  const id = asString(record.id)
234
- const port = asPositiveInt(record.port)
235
- if (!id || port === undefined) {
236
- issues.push(`services[${index}] needs "id" and a numeric "port"`)
313
+ const type = asString(record.type) ?? "port"
314
+ if (type !== "port" && type !== "ollama") {
315
+ issues.push(`services[${index}] has unknown type "${type}" (expected "port" or "ollama")`)
237
316
  continue
238
317
  }
239
- if (seenServiceIds.has(id)) {
318
+ if (id && seenServiceIds.has(id)) {
240
319
  issues.push(`services[${index}] duplicates id "${id}"`)
241
320
  continue
242
321
  }
322
+
323
+ if (type === "ollama") {
324
+ const baseUrl = asString(record.baseUrl)
325
+ const endpoint = baseUrl ? parseHttpUrl(baseUrl) : null
326
+ if (!id || !endpoint) {
327
+ issues.push(`services[${index}] (ollama) needs "id" and an http(s) "baseUrl"`)
328
+ continue
329
+ }
330
+ seenServiceIds.add(id)
331
+ services.push({
332
+ id,
333
+ name: asString(record.name) ?? `Ollama (${endpoint.host})`,
334
+ type: "ollama",
335
+ baseUrl: endpoint.baseUrl,
336
+ host: endpoint.host,
337
+ port: endpoint.port,
338
+ category: "ai",
339
+ })
340
+ continue
341
+ }
342
+
343
+ const port = asPositiveInt(record.port)
344
+ if (!id || port === undefined) {
345
+ issues.push(`services[${index}] needs "id" and a numeric "port"`)
346
+ continue
347
+ }
243
348
  seenServiceIds.add(id)
244
349
  services.push({
245
350
  id,
@@ -251,6 +356,15 @@ export function validateConfig(raw: unknown): ValidationResult {
251
356
  })
252
357
  }
253
358
 
359
+ const remoteMachines = asRemoteMachines(input.remoteMachines, seenServiceIds, issues)
360
+ const verify = asVerify(input.verify, issues)
361
+ const agents = asAgents(input.agents, issues)
362
+ const editor = asEditor(input.editor, issues)
363
+ const terminal = asTerminal(input.terminal, issues)
364
+ const worktreesRecord = asRecord(input.worktrees)
365
+ if (input.worktrees !== undefined && !worktreesRecord) issues.push(`"worktrees" must be an object`)
366
+ const worktrees = worktreesRecord ? { directory: asString(worktreesRecord.directory) } : undefined
367
+
254
368
  const refreshInput = asRecord(input.refresh) ?? {}
255
369
  const refresh: Required<RefreshIntervals> = {
256
370
  system: asPositiveInt(refreshInput.system) ?? DEFAULT_REFRESH.system,
@@ -273,6 +387,12 @@ export function validateConfig(raw: unknown): ValidationResult {
273
387
  refresh,
274
388
  maxLogLines: asPositiveInt(input.maxLogLines),
275
389
  logLevel: logLevel && isLogLevel(logLevel) ? logLevel : undefined,
390
+ editor,
391
+ terminal,
392
+ agents,
393
+ worktrees,
394
+ verify,
395
+ remoteMachines,
276
396
  },
277
397
  issues,
278
398
  }
@@ -291,7 +411,11 @@ export interface LoadedConfig extends ValidationResult {
291
411
  export async function loadConfig(rootDir: string): Promise<LoadedConfig> {
292
412
  const filePath = findConfigFile(rootDir)
293
413
  if (!filePath) return { filePath: null, config: { ...EMPTY_CONFIG }, issues: [] }
414
+ return loadConfigFile(filePath)
415
+ }
294
416
 
417
+ /** Load and validate one specific config file (e.g. a proposed config before it is written). */
418
+ export async function loadConfigFile(filePath: string): Promise<LoadedConfig> {
295
419
  try {
296
420
  const raw = await importConfigModule(filePath)
297
421
  const result = validateConfig(raw)
@@ -397,9 +521,149 @@ function asReadiness(value: unknown, processId: string, issues: string[]): Readi
397
521
  }
398
522
 
399
523
  function asServiceCategory(value: unknown): ServiceConfigEntry["category"] {
400
- const categories = ["container", "database", "runtime", "ai", "web"] as const
524
+ const categories = ["container", "database", "runtime", "ai", "web", "remote"] as const
401
525
  const name = asString(value)
402
526
  return name && (categories as readonly string[]).includes(name)
403
527
  ? (name as ServiceConfigEntry["category"])
404
528
  : "web"
405
529
  }
530
+
531
+ function parseHttpUrl(value: string): { baseUrl: string; host: string; port: number } | null {
532
+ try {
533
+ const url = new URL(value)
534
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null
535
+ const port = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80
536
+ return { baseUrl: `${url.protocol}//${url.host}`, host: url.hostname, port }
537
+ } catch {
538
+ return null
539
+ }
540
+ }
541
+
542
+ /**
543
+ * A value handed to `ssh` as an argument must never be readable as an option
544
+ * (`-oProxyCommand=...`), so hosts and users are held to a conservative charset.
545
+ */
546
+ const SSH_TOKEN = /^[A-Za-z0-9_][A-Za-z0-9._%+:[\]-]*$/u
547
+
548
+ function asRemoteMachines(value: unknown, serviceIds: Set<string>, issues: string[]): RemoteMachineConfig[] {
549
+ const machines: RemoteMachineConfig[] = []
550
+ const seen = new Set<string>()
551
+ for (const [index, entry] of asArray(value, "remoteMachines", issues).entries()) {
552
+ const record = asRecord(entry)
553
+ const id = asString(record?.id)
554
+ const host = asString(record?.host)
555
+ if (!record || !id || !host) {
556
+ issues.push(`remoteMachines[${index}] needs "id" and "host"`)
557
+ continue
558
+ }
559
+ if (seen.has(id)) {
560
+ issues.push(`remoteMachines[${index}] duplicates id "${id}"`)
561
+ continue
562
+ }
563
+ const transport = asString(record.transport) ?? "ssh"
564
+ if (transport !== "ssh") {
565
+ issues.push(`remoteMachines "${id}": transport "${transport}" is not supported (only "ssh")`)
566
+ continue
567
+ }
568
+ if (!SSH_TOKEN.test(host)) {
569
+ issues.push(`remoteMachines "${id}": host "${host}" is not a valid hostname or SSH alias`)
570
+ continue
571
+ }
572
+ const user = asString(record.user)
573
+ if (user !== undefined && !SSH_TOKEN.test(user)) {
574
+ issues.push(`remoteMachines "${id}": user "${user}" is not valid`)
575
+ continue
576
+ }
577
+ const services = asStringArray(record.services)
578
+ for (const serviceId of services ?? []) {
579
+ if (!serviceIds.has(serviceId)) {
580
+ issues.push(`remoteMachines "${id}": service "${serviceId}" is not a declared service`)
581
+ }
582
+ }
583
+ seen.add(id)
584
+ machines.push({
585
+ id,
586
+ name: asString(record.name) ?? id,
587
+ host,
588
+ transport: "ssh",
589
+ port: asPositiveInt(record.port),
590
+ user,
591
+ services,
592
+ })
593
+ }
594
+ return machines
595
+ }
596
+
597
+ function asVerify(value: unknown, issues: string[]): VerifyEntry[] {
598
+ const entries: VerifyEntry[] = []
599
+ for (const [index, entry] of asArray(value, "verify", issues).entries()) {
600
+ const name = asString(entry)
601
+ if (name) {
602
+ entries.push(name)
603
+ continue
604
+ }
605
+ const record = asRecord(entry)
606
+ const id = asString(record?.id)
607
+ const command = asString(record?.command)
608
+ if (!record || !id || !command) {
609
+ issues.push(`verify[${index}] must be a command id, a script name, or { id, command }`)
610
+ continue
611
+ }
612
+ entries.push({
613
+ id,
614
+ name: asString(record.name),
615
+ command,
616
+ args: asStringArray(record.args),
617
+ cwd: asString(record.cwd),
618
+ })
619
+ }
620
+ return entries
621
+ }
622
+
623
+ function asAgents(value: unknown, issues: string[]): Record<string, AgentConfig> {
624
+ if (value === undefined) return {}
625
+ const record = asRecord(value)
626
+ if (!record) {
627
+ issues.push(`"agents" must be an object keyed by agent id`)
628
+ return {}
629
+ }
630
+ const agents: Record<string, AgentConfig> = {}
631
+ for (const [id, entry] of Object.entries(record)) {
632
+ const agent = asRecord(entry)
633
+ if (!agent) {
634
+ issues.push(`agents.${id} must be an object`)
635
+ continue
636
+ }
637
+ agents[id] = {
638
+ name: asString(agent.name),
639
+ command: asString(agent.command),
640
+ args: asStringArray(agent.args),
641
+ enabled: agent.enabled === false ? false : undefined,
642
+ }
643
+ }
644
+ return agents
645
+ }
646
+
647
+ function asEditor(value: unknown, issues: string[]): EditorConfig | undefined {
648
+ if (value === undefined) return undefined
649
+ const record = asRecord(value)
650
+ const command = asString(record?.command)
651
+ const application = asString(record?.application)
652
+ if (!record || (!command && !application)) {
653
+ issues.push(`"editor" needs a "command" (e.g. "zed") or an "application" (e.g. "WebStorm")`)
654
+ return undefined
655
+ }
656
+ return { command, application, args: asStringArray(record.args) }
657
+ }
658
+
659
+ function asTerminal(value: unknown, issues: string[]): TerminalConfig | undefined {
660
+ if (value === undefined) return undefined
661
+ const record = asRecord(value)
662
+ const command = asString(record?.command)
663
+ const application = asString(record?.application)
664
+ if (!record || (!command && !application)) {
665
+ issues.push(`"terminal" needs a "command" or an "application"`)
666
+ return undefined
667
+ }
668
+ return { command, application, args: asStringArray(record.args), execArgs: asStringArray(record.execArgs) }
669
+ }
@@ -1,3 +1,6 @@
1
+ import type { AgentSession } from "../agents/agents.js"
2
+ import type { EnvironmentSnapshot } from "../environment/EnvironmentService.js"
3
+ import { EMPTY_ENVIRONMENT } from "../environment/EnvironmentService.js"
1
4
  import type { GitSnapshot } from "../git/GitService.js"
2
5
  import { EMPTY_GIT_SNAPSHOT } from "../git/GitService.js"
3
6
  import type { LogSource } from "../logs/LogBuffer.js"
@@ -8,6 +11,7 @@ import type { ProjectEntry } from "../projects/ProjectRegistry.js"
8
11
  import type { ServiceSnapshot } from "../services/types.js"
9
12
  import type { HostInfo, SystemMetrics, ToolVersions } from "../system/SystemMonitor.js"
10
13
  import type { PackageManagerDetection } from "../utils/packageManager.js"
14
+ import type { VerifyStepResult } from "../verify/verify.js"
11
15
 
12
16
  export const VIEW_IDS = [
13
17
  "overview",
@@ -15,6 +19,7 @@ export const VIEW_IDS = [
15
19
  "services",
16
20
  "scripts",
17
21
  "git",
22
+ "worktrees",
18
23
  "logs",
19
24
  "dependencies",
20
25
  "system",
@@ -60,6 +65,19 @@ export interface ScriptsState {
60
65
  runs: Record<string, ScriptRun>
61
66
  }
62
67
 
68
+ export interface VerifyState {
69
+ /** Steps resolved from config; empty when verification is not configured. */
70
+ configured: number
71
+ running: boolean
72
+ /** Progress of the current or most recent run. */
73
+ steps: VerifyStepResult[]
74
+ startedAt?: Date
75
+ finishedAt?: Date
76
+ ok?: boolean
77
+ }
78
+
79
+ export const EMPTY_VERIFY: VerifyState = { configured: 0, running: false, steps: [] }
80
+
63
81
  export interface UiState {
64
82
  view: ViewId
65
83
  overlay: OverlayKind
@@ -83,6 +101,10 @@ export interface AppState {
83
101
  scripts: ScriptsState
84
102
  git: GitSnapshot
85
103
  dependencies: DependencySnapshot
104
+ /** AI agent sessions started in this project, newest first. */
105
+ agents: AgentSession[]
106
+ environment: EnvironmentSnapshot
107
+ verify: VerifyState
86
108
  system: SystemMetrics | null
87
109
  logSources: LogSource[]
88
110
  notifications: Notification[]
@@ -119,6 +141,9 @@ export function createInitialState(project: ProjectState, width: number, height:
119
141
  scripts: { available: [], runs: {} },
120
142
  git: EMPTY_GIT_SNAPSHOT,
121
143
  dependencies: EMPTY_DEPENDENCY_SNAPSHOT,
144
+ agents: [],
145
+ environment: EMPTY_ENVIRONMENT,
146
+ verify: EMPTY_VERIFY,
122
147
  system: null,
123
148
  logSources: [],
124
149
  notifications: [],
@@ -0,0 +1,215 @@
1
+ import { existsSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { TypedEmitter } from "../core/events.js"
4
+ import { DevccError } from "../utils/errors.js"
5
+ import { exec } from "../utils/exec.js"
6
+ import type { ScopedLogger } from "../utils/logger.js"
7
+ import {
8
+ isDirenvLoaded,
9
+ MISE_ARGS,
10
+ miseErrorMessage,
11
+ parseDirenvStatus,
12
+ parseMiseLs,
13
+ type DirenvStatus,
14
+ type MiseStatus,
15
+ } from "./integrations.js"
16
+ import {
17
+ DEFAULT_TOOLS,
18
+ detectTool,
19
+ findExecutable,
20
+ firstLine,
21
+ type Exec,
22
+ type ToolDefinition,
23
+ type ToolStatus,
24
+ type Which,
25
+ } from "./tools.js"
26
+
27
+ export interface EnvironmentSnapshot {
28
+ tools: ToolStatus[]
29
+ mise: MiseStatus
30
+ direnv: DirenvStatus
31
+ /** Null until the first detection finishes. */
32
+ checkedAt: Date | null
33
+ checking: boolean
34
+ }
35
+
36
+ export const EMPTY_ENVIRONMENT: EnvironmentSnapshot = {
37
+ tools: [],
38
+ mise: { installed: false, runtimes: [] },
39
+ direnv: { installed: false, approval: "unknown", loaded: null },
40
+ checkedAt: null,
41
+ checking: false,
42
+ }
43
+
44
+ export type EnvironmentServiceEvents = {
45
+ "environment:changed": [EnvironmentSnapshot]
46
+ }
47
+
48
+ export interface EnvironmentServiceOptions {
49
+ rootDir: string
50
+ logger?: ScopedLogger
51
+ tools?: readonly ToolDefinition[]
52
+ /** Injected for tests. */
53
+ run?: Exec
54
+ which?: Which
55
+ env?: NodeJS.ProcessEnv
56
+ exists?: (path: string) => boolean
57
+ timeoutMs?: number
58
+ }
59
+
60
+ /**
61
+ * Machine and project environment health.
62
+ *
63
+ * Detection runs asynchronously, every external call has a timeout, and the
64
+ * result is cached until someone asks for a refresh: tool versions change
65
+ * rarely, so there is nothing to poll.
66
+ */
67
+ export class EnvironmentService extends TypedEmitter<EnvironmentServiceEvents> {
68
+ private snapshot: EnvironmentSnapshot = EMPTY_ENVIRONMENT
69
+ private pending: Promise<EnvironmentSnapshot> | null = null
70
+ private readonly run: Exec
71
+ private readonly which: Which
72
+ private readonly env: NodeJS.ProcessEnv
73
+
74
+ constructor(private readonly options: EnvironmentServiceOptions) {
75
+ super()
76
+ this.env = options.env ?? process.env
77
+ this.run = options.run ?? exec
78
+ this.which = options.which ?? ((command) => findExecutable(command, this.env.PATH ?? ""))
79
+ }
80
+
81
+ getSnapshot(): EnvironmentSnapshot {
82
+ return this.snapshot
83
+ }
84
+
85
+ /** Detect everything. Concurrent calls share one run. */
86
+ refresh(): Promise<EnvironmentSnapshot> {
87
+ if (this.pending) return this.pending
88
+ this.publish({ ...this.snapshot, checking: true })
89
+ this.pending = this.detect().finally(() => {
90
+ this.pending = null
91
+ })
92
+ return this.pending
93
+ }
94
+
95
+ /** The cached snapshot, detecting first if nothing has been checked yet. */
96
+ async ensure(): Promise<EnvironmentSnapshot> {
97
+ return this.snapshot.checkedAt ? this.snapshot : this.refresh()
98
+ }
99
+
100
+ /**
101
+ * `direnv allow` for this project's `.envrc`. Only ever called from an
102
+ * explicit, confirmed user action - devcc never approves an `.envrc` itself.
103
+ */
104
+ async allowDirenv(): Promise<void> {
105
+ const { rcPath, installed } = this.snapshot.direnv
106
+ if (!installed || !rcPath) {
107
+ throw new DevccError({ scope: "system", message: "No .envrc for direnv to allow" })
108
+ }
109
+ const result = await this.run("direnv", ["allow", rcPath], {
110
+ cwd: this.options.rootDir,
111
+ timeoutMs: 10_000,
112
+ logger: this.options.logger,
113
+ })
114
+ if (!result.ok) {
115
+ throw new DevccError({
116
+ scope: "system",
117
+ message: "direnv allow failed",
118
+ detail: result.stderr.trim() || undefined,
119
+ })
120
+ }
121
+ await this.refresh()
122
+ }
123
+
124
+ private async detect(): Promise<EnvironmentSnapshot> {
125
+ const startedAt = Date.now()
126
+ const definitions = this.options.tools ?? DEFAULT_TOOLS
127
+ const tools = await Promise.all(
128
+ definitions.map((definition) =>
129
+ detectTool(definition, {
130
+ run: (command, args, options) =>
131
+ this.run(command, args, { ...options, logger: this.options.logger }),
132
+ which: this.which,
133
+ timeoutMs: this.options.timeoutMs,
134
+ }),
135
+ ),
136
+ )
137
+
138
+ const installed = (id: string) => tools.find((tool) => tool.id === id)?.installed === true
139
+ const [mise, direnv] = await Promise.all([
140
+ this.detectMise(installed("mise")),
141
+ this.detectDirenv(installed("direnv")),
142
+ ])
143
+
144
+ this.options.logger?.debug("environment detected", {
145
+ durationMs: Date.now() - startedAt,
146
+ installed: tools.filter((tool) => tool.installed).map((tool) => tool.id),
147
+ })
148
+ return this.publish({ tools, mise, direnv, checkedAt: new Date(), checking: false })
149
+ }
150
+
151
+ private async detectMise(installed: boolean): Promise<MiseStatus> {
152
+ if (!installed) return { installed: false, runtimes: [] }
153
+ const result = await this.run("mise", MISE_ARGS, {
154
+ cwd: this.options.rootDir,
155
+ timeoutMs: this.options.timeoutMs ?? 5_000,
156
+ // stdin is closed by exec, so mise cannot stop to prompt (e.g. to trust a config).
157
+ logger: this.options.logger,
158
+ })
159
+ if (result.timedOut) return { installed: true, runtimes: [], error: "mise did not answer in time" }
160
+ if (!result.ok) return { installed: true, runtimes: [], error: miseErrorMessage(result.stderr) }
161
+ return { installed: true, runtimes: parseMiseLs(result.stdout) }
162
+ }
163
+
164
+ private async detectDirenv(installed: boolean): Promise<DirenvStatus> {
165
+ const exists = this.options.exists ?? existsSync
166
+ const localRc = [".envrc", ".env"]
167
+ .map((name) => join(this.options.rootDir, name))
168
+ .find((path) => exists(path))
169
+
170
+ if (!installed) {
171
+ return {
172
+ installed: false,
173
+ rcPath: localRc,
174
+ approval: "unknown",
175
+ loaded: localRc ? false : null,
176
+ error: localRc?.endsWith(".envrc")
177
+ ? "direnv is not installed; .envrc is not being loaded"
178
+ : undefined,
179
+ }
180
+ }
181
+
182
+ const options = {
183
+ cwd: this.options.rootDir,
184
+ timeoutMs: this.options.timeoutMs ?? 5_000,
185
+ logger: this.options.logger,
186
+ }
187
+ let result = await this.run("direnv", ["status", "--json"], options)
188
+ if (!result.ok && !result.timedOut) result = await this.run("direnv", ["status"], options)
189
+ if (!result.ok) {
190
+ return {
191
+ installed: true,
192
+ rcPath: localRc,
193
+ approval: "unknown",
194
+ loaded: null,
195
+ error: result.timedOut
196
+ ? "direnv did not answer in time"
197
+ : (firstLine(result.stderr) ?? "direnv status failed"),
198
+ }
199
+ }
200
+
201
+ const parsed = parseDirenvStatus(result.stdout)
202
+ return {
203
+ installed: true,
204
+ rcPath: parsed.rcPath,
205
+ approval: parsed.rcPath ? parsed.approval : "unknown",
206
+ loaded: parsed.rcPath ? isDirenvLoaded(parsed.rcPath, this.env) : null,
207
+ }
208
+ }
209
+
210
+ private publish(snapshot: EnvironmentSnapshot): EnvironmentSnapshot {
211
+ this.snapshot = snapshot
212
+ this.emit("environment:changed", snapshot)
213
+ return snapshot
214
+ }
215
+ }