@doriandev/devcc 0.1.2 → 0.2.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 (136) hide show
  1. package/README.md +556 -386
  2. package/bin/devcc.mjs +50 -23
  3. package/dist/devcc/agents/agents.d.ts +112 -0
  4. package/dist/devcc/agents/agents.d.ts.map +1 -0
  5. package/dist/devcc/agents/agents.js +179 -0
  6. package/dist/devcc/agents/agents.js.map +1 -0
  7. package/dist/devcc/app/App.d.ts +15 -0
  8. package/dist/devcc/app/App.d.ts.map +1 -1
  9. package/dist/devcc/app/App.js +326 -17
  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 +179 -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 +24 -3
  17. package/dist/devcc/app/keymap.js.map +1 -1
  18. package/dist/devcc/automation.d.ts +31 -0
  19. package/dist/devcc/automation.d.ts.map +1 -0
  20. package/dist/devcc/automation.js +202 -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 +46 -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 +25 -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 +69 -3
  35. package/dist/devcc/core/config.d.ts.map +1 -1
  36. package/dist/devcc/core/config.js +185 -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 +13 -3
  63. package/dist/devcc/index.d.ts.map +1 -1
  64. package/dist/devcc/index.js +11 -1
  65. package/dist/devcc/index.js.map +1 -1
  66. package/dist/devcc/platform/launch.d.ts +30 -0
  67. package/dist/devcc/platform/launch.d.ts.map +1 -0
  68. package/dist/devcc/platform/launch.js +93 -0
  69. package/dist/devcc/platform/launch.js.map +1 -0
  70. package/dist/devcc/services/ServiceManager.d.ts +10 -2
  71. package/dist/devcc/services/ServiceManager.d.ts.map +1 -1
  72. package/dist/devcc/services/ServiceManager.js +18 -4
  73. package/dist/devcc/services/ServiceManager.js.map +1 -1
  74. package/dist/devcc/services/ollama.d.ts +64 -5
  75. package/dist/devcc/services/ollama.d.ts.map +1 -1
  76. package/dist/devcc/services/ollama.js +118 -17
  77. package/dist/devcc/services/ollama.js.map +1 -1
  78. package/dist/devcc/services/remote.d.ts +90 -0
  79. package/dist/devcc/services/remote.d.ts.map +1 -0
  80. package/dist/devcc/services/remote.js +233 -0
  81. package/dist/devcc/services/remote.js.map +1 -0
  82. package/dist/devcc/services/types.d.ts +8 -1
  83. package/dist/devcc/services/types.d.ts.map +1 -1
  84. package/dist/devcc/services/types.js.map +1 -1
  85. package/dist/devcc/utils/exec.d.ts +6 -0
  86. package/dist/devcc/utils/exec.d.ts.map +1 -1
  87. package/dist/devcc/utils/exec.js +12 -2
  88. package/dist/devcc/utils/exec.js.map +1 -1
  89. package/dist/devcc/verify/verify.d.ts +107 -0
  90. package/dist/devcc/verify/verify.d.ts.map +1 -0
  91. package/dist/devcc/verify/verify.js +203 -0
  92. package/dist/devcc/verify/verify.js.map +1 -0
  93. package/dist/devcc/views/OverviewView.d.ts.map +1 -1
  94. package/dist/devcc/views/OverviewView.js +55 -1
  95. package/dist/devcc/views/OverviewView.js.map +1 -1
  96. package/dist/devcc/views/ServicesView.d.ts +2 -0
  97. package/dist/devcc/views/ServicesView.d.ts.map +1 -1
  98. package/dist/devcc/views/ServicesView.js +15 -0
  99. package/dist/devcc/views/ServicesView.js.map +1 -1
  100. package/dist/devcc/views/SystemView.d.ts +10 -1
  101. package/dist/devcc/views/SystemView.d.ts.map +1 -1
  102. package/dist/devcc/views/SystemView.js +127 -19
  103. package/dist/devcc/views/SystemView.js.map +1 -1
  104. package/dist/devcc/views/WorktreesView.d.ts +48 -0
  105. package/dist/devcc/views/WorktreesView.d.ts.map +1 -0
  106. package/dist/devcc/views/WorktreesView.js +235 -0
  107. package/dist/devcc/views/WorktreesView.js.map +1 -0
  108. package/package.json +1 -1
  109. package/src/devcc/agents/agents.ts +282 -0
  110. package/src/devcc/app/App.ts +376 -20
  111. package/src/devcc/app/controlPlaneCommands.ts +197 -0
  112. package/src/devcc/app/keymap.ts +24 -3
  113. package/src/devcc/automation.ts +242 -0
  114. package/src/devcc/cli.ts +45 -3
  115. package/src/devcc/components/Sidebar.ts +1 -0
  116. package/src/devcc/components/primitives.ts +3 -0
  117. package/src/devcc/core/commands.ts +26 -0
  118. package/src/devcc/core/config.ts +271 -11
  119. package/src/devcc/core/state.ts +25 -0
  120. package/src/devcc/environment/EnvironmentService.ts +215 -0
  121. package/src/devcc/environment/integrations.ts +133 -0
  122. package/src/devcc/environment/tools.ts +151 -0
  123. package/src/devcc/git/GitService.ts +104 -2
  124. package/src/devcc/git/worktrees.ts +256 -0
  125. package/src/devcc/index.ts +57 -2
  126. package/src/devcc/platform/launch.ts +125 -0
  127. package/src/devcc/services/ServiceManager.ts +15 -3
  128. package/src/devcc/services/ollama.ts +162 -19
  129. package/src/devcc/services/remote.ts +306 -0
  130. package/src/devcc/services/types.ts +9 -1
  131. package/src/devcc/utils/exec.ts +26 -2
  132. package/src/devcc/verify/verify.ts +316 -0
  133. package/src/devcc/views/OverviewView.ts +76 -1
  134. package/src/devcc/views/ServicesView.ts +17 -0
  135. package/src/devcc/views/SystemView.ts +144 -22
  136. package/src/devcc/views/WorktreesView.ts +270 -0
@@ -0,0 +1,256 @@
1
+ import { basename, isAbsolute, relative, resolve } from "node:path"
2
+
3
+ /**
4
+ * Pure helpers for `git worktree`.
5
+ *
6
+ * Parsing, argument construction and validation live here so they can be
7
+ * tested without a repository; `GitService` only runs the commands.
8
+ */
9
+
10
+ export interface GitWorktree {
11
+ /** Absolute path of the worktree's top-level directory. */
12
+ path: string
13
+ /** Short branch name (`feature/x`), absent when detached or bare. */
14
+ branch?: string
15
+ /** Full commit hash of HEAD; empty for a bare repository. */
16
+ head: string
17
+ bare: boolean
18
+ detached: boolean
19
+ locked: boolean
20
+ lockedReason?: string
21
+ prunable: boolean
22
+ prunableReason?: string
23
+ /** The first entry git reports: the repository's main worktree. */
24
+ isMain: boolean
25
+ /** The worktree devcc is running in. */
26
+ isCurrent: boolean
27
+ }
28
+
29
+ /**
30
+ * Parse `git worktree list --porcelain` (optionally with `-z`).
31
+ *
32
+ * Records are separated by a blank line (a double NUL with `-z`). Unknown keys
33
+ * are ignored and records without a `worktree` line are dropped, so output from
34
+ * a newer git with extra attributes still parses.
35
+ *
36
+ * Format reference: `man git-worktree`, "Porcelain Format".
37
+ */
38
+ export function parseWorktreeList(output: string, currentPath?: string): GitWorktree[] {
39
+ const nulSeparated = output.includes("\0")
40
+ const lines = nulSeparated ? output.split("\0") : output.split("\n").map((line) => line.replace(/\r$/u, ""))
41
+
42
+ const worktrees: GitWorktree[] = []
43
+ let record: GitWorktree | null = null
44
+
45
+ const flush = () => {
46
+ if (record && record.path !== "") worktrees.push(record)
47
+ record = null
48
+ }
49
+
50
+ for (const line of lines) {
51
+ if (line === "") {
52
+ flush()
53
+ continue
54
+ }
55
+
56
+ const space = line.indexOf(" ")
57
+ const key = space === -1 ? line : line.slice(0, space)
58
+ const value = space === -1 ? "" : line.slice(space + 1)
59
+
60
+ if (key === "worktree") {
61
+ flush()
62
+ record = {
63
+ path: value,
64
+ head: "",
65
+ bare: false,
66
+ detached: false,
67
+ locked: false,
68
+ prunable: false,
69
+ isMain: worktrees.length === 0,
70
+ isCurrent: false,
71
+ }
72
+ continue
73
+ }
74
+
75
+ // Attributes before any `worktree` line are malformed; skip them.
76
+ const current = record
77
+ if (!current) continue
78
+
79
+ switch (key) {
80
+ case "HEAD":
81
+ current.head = value
82
+ break
83
+ case "branch":
84
+ current.branch = value.replace(/^refs\/heads\//u, "")
85
+ break
86
+ case "bare":
87
+ current.bare = true
88
+ break
89
+ case "detached":
90
+ current.detached = true
91
+ break
92
+ case "locked":
93
+ current.locked = true
94
+ if (value) current.lockedReason = value
95
+ break
96
+ case "prunable":
97
+ current.prunable = true
98
+ if (value) current.prunableReason = value
99
+ break
100
+ default:
101
+ break
102
+ }
103
+ }
104
+ flush()
105
+
106
+ // The first record is the main worktree even if it followed malformed lines.
107
+ for (const [index, worktree] of worktrees.entries()) worktree.isMain = index === 0
108
+
109
+ return currentPath ? markCurrent(worktrees, currentPath) : worktrees
110
+ }
111
+
112
+ /**
113
+ * Flag the worktree containing `currentPath`. The deepest match wins, so a
114
+ * worktree nested inside the main checkout is attributed correctly.
115
+ */
116
+ export function markCurrent(worktrees: GitWorktree[], currentPath: string): GitWorktree[] {
117
+ let best: GitWorktree | undefined
118
+ for (const worktree of worktrees) {
119
+ if (worktree.bare || !isInside(currentPath, worktree.path)) continue
120
+ if (!best || worktree.path.length > best.path.length) best = worktree
121
+ }
122
+ return worktrees.map((worktree) => ({ ...worktree, isCurrent: worktree === best }))
123
+ }
124
+
125
+ /** True when `path` is `parent` or somewhere below it. */
126
+ export function isInside(path: string, parent: string): boolean {
127
+ const rel = relative(resolve(parent), resolve(path))
128
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))
129
+ }
130
+
131
+ /** Display label: the branch, or a short hash when detached. */
132
+ export function worktreeLabel(worktree: GitWorktree): string {
133
+ if (worktree.bare) return "(bare)"
134
+ if (worktree.branch) return worktree.branch
135
+ return worktree.head ? `detached @ ${worktree.head.slice(0, 7)}` : "(unknown)"
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Validation
140
+ // ---------------------------------------------------------------------------
141
+
142
+ // eslint-disable-next-line no-control-regex
143
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/u
144
+
145
+ /**
146
+ * Validate a branch name against git's ref rules (`man git-check-ref-format`),
147
+ * plus a leading-dash check so a name can never be read as an option.
148
+ * Returns a human-readable problem, or null when the name is acceptable.
149
+ */
150
+ export function validateBranchName(name: string): string | null {
151
+ if (name.trim() === "") return "Branch name is empty"
152
+ if (name !== name.trim() || /\s/u.test(name)) return "Branch name cannot contain whitespace"
153
+ if (CONTROL_CHARS.test(name)) return "Branch name cannot contain control characters"
154
+ if (name.startsWith("-")) return "Branch name cannot start with '-'"
155
+ if (name === "@") return "Branch name cannot be '@'"
156
+ if (/[~^:?*[\\]/u.test(name)) return "Branch name cannot contain ~ ^ : ? * [ or \\"
157
+ if (name.includes("..")) return "Branch name cannot contain '..'"
158
+ if (name.includes("@{")) return "Branch name cannot contain '@{'"
159
+ if (name.includes("//")) return "Branch name cannot contain '//'"
160
+ if (name.startsWith("/") || name.endsWith("/")) return "Branch name cannot start or end with '/'"
161
+ if (name.endsWith(".")) return "Branch name cannot end with '.'"
162
+ for (const segment of name.split("/")) {
163
+ if (segment.startsWith(".")) return "Branch name segments cannot start with '.'"
164
+ if (segment.endsWith(".lock")) return "Branch name segments cannot end with '.lock'"
165
+ }
166
+ return null
167
+ }
168
+
169
+ /** A base ref is passed straight to git, so only rule out option-like or broken input. */
170
+ export function validateRef(ref: string): string | null {
171
+ if (ref.trim() === "") return "Base ref is empty"
172
+ if (ref.startsWith("-")) return "Base ref cannot start with '-'"
173
+ if (/\s/u.test(ref) || CONTROL_CHARS.test(ref)) return "Base ref cannot contain whitespace"
174
+ return null
175
+ }
176
+
177
+ /**
178
+ * Validate a destination directory for a new worktree. `exists` is injected
179
+ * so the check is testable; git itself refuses a non-empty existing directory,
180
+ * but refusing early gives a clearer message.
181
+ */
182
+ export function validateWorktreePath(path: string, exists: (path: string) => boolean): string | null {
183
+ if (path.trim() === "") return "Worktree path is empty"
184
+ if (CONTROL_CHARS.test(path)) return "Worktree path cannot contain control characters"
185
+ if (!isAbsolute(path)) return "Worktree path must be absolute"
186
+ if (exists(path)) return `${path} already exists`
187
+ return null
188
+ }
189
+
190
+ /** Directory-safe form of a branch name: `feature/ai-agents` -> `feature-ai-agents`. */
191
+ export function branchSlug(branch: string): string {
192
+ return (
193
+ branch
194
+ .replace(/[^A-Za-z0-9._-]+/gu, "-")
195
+ .replace(/-+/gu, "-")
196
+ .replace(/^[-.]+|[-.]+$/gu, "") || "worktree"
197
+ )
198
+ }
199
+
200
+ /**
201
+ * Where a new worktree goes by default: `<directory>/<repo>-<branch>`, with
202
+ * `directory` relative to the main worktree (default: its parent).
203
+ */
204
+ export function defaultWorktreePath(mainWorktreePath: string, branch: string, directory = ".."): string {
205
+ return resolve(mainWorktreePath, directory, `${basename(mainWorktreePath)}-${branchSlug(branch)}`)
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Argument construction
210
+ // ---------------------------------------------------------------------------
211
+
212
+ export interface CreateWorktreeRequest {
213
+ /** Absolute destination directory. */
214
+ path: string
215
+ branch: string
216
+ /** Start point for a new branch. Ignored when checking out an existing branch. */
217
+ base?: string
218
+ /** True to create `branch` (`-b`); false to check out an existing one. */
219
+ createBranch: boolean
220
+ }
221
+
222
+ /**
223
+ * Arguments for `git worktree add`. Everything user-provided comes after `--`
224
+ * or as the value of `-b`, and each value is its own argv entry, so nothing can
225
+ * be interpreted as an option or split by a shell.
226
+ */
227
+ export function worktreeAddArgs(request: CreateWorktreeRequest): string[] {
228
+ if (request.createBranch) {
229
+ const args = ["worktree", "add", "-b", request.branch, "--", request.path]
230
+ if (request.base) args.push(request.base)
231
+ return args
232
+ }
233
+ return ["worktree", "add", "--", request.path, request.branch]
234
+ }
235
+
236
+ /** Arguments for `git worktree remove`. Never `--force`: git's safety check stands. */
237
+ export function worktreeRemoveArgs(path: string): string[] {
238
+ return ["worktree", "remove", "--", path]
239
+ }
240
+
241
+ /**
242
+ * Decide whether a worktree may be removed before asking git. `busy` lists
243
+ * directories where devcc is still running something (agents, processes).
244
+ * Returns the reason for refusing, or null when removal may proceed.
245
+ */
246
+ export function removalBlocker(worktree: GitWorktree, busy: readonly string[] = []): string | null {
247
+ if (worktree.isMain) return "The main worktree cannot be removed"
248
+ if (worktree.isCurrent) return "devcc is running in this worktree; open another project first"
249
+ if (worktree.bare) return "A bare repository is not a removable worktree"
250
+ if (worktree.locked) {
251
+ return `Worktree is locked${worktree.lockedReason ? `: ${worktree.lockedReason}` : ""}; unlock it with git first`
252
+ }
253
+ const running = busy.find((dir) => isInside(dir, worktree.path))
254
+ if (running) return `Something devcc started is still running in ${worktree.path}; stop it first`
255
+ return null
256
+ }
@@ -7,6 +7,17 @@
7
7
  */
8
8
 
9
9
  export { App, type AppOptions } from "./app/App.js"
10
+ export { createControlPlaneCommands } from "./app/controlPlaneCommands.js"
11
+ export {
12
+ AgentManager,
13
+ BUILTIN_AGENTS,
14
+ createAgentAdapters,
15
+ type AgentAdapter,
16
+ type AgentConfig,
17
+ type AgentLaunchOptions,
18
+ type AgentSession,
19
+ } from "./agents/agents.js"
20
+ export { envCommand, verifyCommand, worktreeListCommand } from "./automation.js"
10
21
  export { createBuiltinCommands, createConfigCommands, createScriptCommands } from "./app/builtinCommands.js"
11
22
  export { HELP_SECTIONS, VIEW_SHORTCUTS } from "./app/keymap.js"
12
23
  export { theme, glyphs, processStatusStyle, serviceStateStyle } from "./app/theme.js"
@@ -21,8 +32,12 @@ export {
21
32
  DEFAULT_REFRESH,
22
33
  type CommandConfigEntry,
23
34
  type DevccConfig,
35
+ type EditorConfig,
36
+ type RemoteMachineConfig,
24
37
  type ResolvedConfig,
25
38
  type ServiceConfigEntry,
39
+ type TerminalConfig,
40
+ type WorktreesConfig,
26
41
  } from "./core/config.js"
27
42
  export { TypedEmitter, Subscriptions, type Unsubscribe } from "./core/events.js"
28
43
  export {
@@ -48,7 +63,32 @@ export {
48
63
  type UpdateSeverity,
49
64
  } from "./deps/parse.js"
50
65
 
51
- export { GitService, type GitSnapshot } from "./git/GitService.js"
66
+ export { EnvironmentService, type EnvironmentSnapshot } from "./environment/EnvironmentService.js"
67
+ export {
68
+ parseDirenvStatus,
69
+ parseMiseLs,
70
+ type DirenvStatus,
71
+ type MiseStatus,
72
+ type RuntimeVersion,
73
+ } from "./environment/integrations.js"
74
+ export {
75
+ DEFAULT_TOOLS,
76
+ detectTool,
77
+ findExecutable,
78
+ type ToolDefinition,
79
+ type ToolStatus,
80
+ } from "./environment/tools.js"
81
+
82
+ export { GitService, type CreateWorktreeInput, type GitSnapshot } from "./git/GitService.js"
83
+ export {
84
+ defaultWorktreePath,
85
+ parseWorktreeList,
86
+ removalBlocker,
87
+ validateBranchName,
88
+ worktreeAddArgs,
89
+ worktreeRemoveArgs,
90
+ type GitWorktree,
91
+ } from "./git/worktrees.js"
52
92
  export {
53
93
  parseCommits,
54
94
  parsePorcelainV2,
@@ -96,7 +136,14 @@ export {
96
136
  export { ServiceManager } from "./services/ServiceManager.js"
97
137
  export { DockerService, findComposeFile, parseComposePs } from "./services/docker.js"
98
138
  export { PortService, detectNodeServices } from "./services/node.js"
99
- export { OllamaService } from "./services/ollama.js"
139
+ export { mergeOllamaModels, OllamaService, type OllamaModel } from "./services/ollama.js"
140
+ export {
141
+ parseTailscaleStatus,
142
+ RemoteMachineService,
143
+ TailscaleService,
144
+ type TailscaleStatus,
145
+ } from "./services/remote.js"
146
+ export { editorCommand, launchDetached, sshCommand, terminalCommand } from "./platform/launch.js"
100
147
  export { PostgresService, parsePostgresUrl } from "./services/postgres.js"
101
148
  export { RedisService, parseRedisUrl } from "./services/redis.js"
102
149
  export type { ServiceAdapter, ServiceSnapshot, ServiceState, ServiceStatus } from "./services/types.js"
@@ -113,3 +160,11 @@ export {
113
160
  type PackageManager,
114
161
  } from "./utils/packageManager.js"
115
162
  export { findProjectRoot } from "./utils/paths.js"
163
+ export {
164
+ createSpawnExecutor,
165
+ resolveVerifySteps,
166
+ runVerification,
167
+ type VerifyEntry,
168
+ type VerifyReport,
169
+ type VerifyStep,
170
+ } from "./verify/verify.js"
@@ -0,0 +1,125 @@
1
+ import { spawn } from "node:child_process"
2
+ import type { EditorConfig, RemoteMachineConfig, TerminalConfig } from "../core/config.js"
3
+ import { sshDestination } from "../services/remote.js"
4
+ import { DevccError } from "../utils/errors.js"
5
+
6
+ /**
7
+ * Opening things outside devcc: editors, terminals, SSH sessions.
8
+ *
9
+ * Every launch is an executable plus an argv array, detached from devcc so
10
+ * closing the dashboard never closes your editor. macOS conventions (`open`,
11
+ * `open -a`) are used only when running on macOS; elsewhere a configured
12
+ * command is required.
13
+ */
14
+
15
+ export interface LaunchCommand {
16
+ command: string
17
+ args: string[]
18
+ }
19
+
20
+ export type Platform = NodeJS.Platform
21
+
22
+ /** Replace `{path}` inside individual arguments. Never joins arguments into a string. */
23
+ export function substitutePath(args: readonly string[], path: string): string[] {
24
+ return args.map((arg) => arg.split("{path}").join(path))
25
+ }
26
+
27
+ export function editorCommand(
28
+ path: string,
29
+ editor: EditorConfig | undefined,
30
+ platform: Platform = process.platform,
31
+ ): LaunchCommand {
32
+ if (editor?.command) return { command: editor.command, args: [...(editor.args ?? []), path] }
33
+ if (editor?.application) {
34
+ if (platform !== "darwin") {
35
+ throw new DevccError({
36
+ scope: "command",
37
+ message: `editor.application only works on macOS; set editor.command`,
38
+ })
39
+ }
40
+ return { command: "open", args: ["-a", editor.application, path] }
41
+ }
42
+ throw new DevccError({
43
+ scope: "command",
44
+ message: "No editor configured",
45
+ detail: 'Set editor: { command: "code" } (or zed, cursor, webstorm) in .devcc.ts',
46
+ })
47
+ }
48
+
49
+ export function terminalCommand(
50
+ path: string,
51
+ terminal: TerminalConfig | undefined,
52
+ platform: Platform = process.platform,
53
+ ): LaunchCommand {
54
+ if (terminal?.command) {
55
+ const args = terminal.args ?? ["{path}"]
56
+ return { command: terminal.command, args: substitutePath(args, path) }
57
+ }
58
+ if (platform === "darwin") {
59
+ return { command: "open", args: ["-a", terminal?.application ?? "Terminal", path] }
60
+ }
61
+ throw new DevccError({
62
+ scope: "command",
63
+ message: "No terminal configured",
64
+ detail: 'Set terminal: { command: "wezterm", args: ["start", "--cwd", "{path}"] } in .devcc.ts',
65
+ })
66
+ }
67
+
68
+ /**
69
+ * An interactive SSH session needs a terminal. On macOS `open ssh://host`
70
+ * hands the URL to the default terminal, which runs `ssh` with the user's own
71
+ * config for that alias. Elsewhere the configured terminal runs `ssh`.
72
+ */
73
+ export function sshCommand(
74
+ machine: Pick<RemoteMachineConfig, "host" | "user" | "port">,
75
+ terminal: TerminalConfig | undefined,
76
+ platform: Platform = process.platform,
77
+ ): LaunchCommand {
78
+ if (terminal?.command && terminal.execArgs) {
79
+ const port = machine.port ? ["-p", String(machine.port)] : []
80
+ return {
81
+ command: terminal.command,
82
+ args: [...terminal.execArgs, "ssh", ...port, sshDestination(machine)],
83
+ }
84
+ }
85
+ if (platform === "darwin") {
86
+ const user = machine.user ? `${encodeURIComponent(machine.user)}@` : ""
87
+ const port = machine.port ? `:${machine.port}` : ""
88
+ return { command: "open", args: [`ssh://${user}${machine.host}${port}`] }
89
+ }
90
+ throw new DevccError({
91
+ scope: "command",
92
+ message: "No terminal configured for SSH",
93
+ detail: 'Set terminal: { command: "wezterm", execArgs: ["start", "--"] } in .devcc.ts',
94
+ })
95
+ }
96
+
97
+ /**
98
+ * Start a GUI or launcher process detached from devcc. Resolves once it has
99
+ * spawned (not when it exits); rejects with a readable error if it cannot start.
100
+ */
101
+ export function launchDetached(launch: LaunchCommand, cwd?: string): Promise<void> {
102
+ return new Promise((resolve, reject) => {
103
+ let child
104
+ try {
105
+ child = spawn(launch.command, launch.args, { cwd, detached: true, stdio: "ignore", windowsHide: true })
106
+ } catch (error) {
107
+ reject(toLaunchError(launch, error))
108
+ return
109
+ }
110
+ child.once("error", (error) => reject(toLaunchError(launch, error)))
111
+ child.once("spawn", () => {
112
+ child.unref()
113
+ resolve()
114
+ })
115
+ })
116
+ }
117
+
118
+ function toLaunchError(launch: LaunchCommand, error: unknown): DevccError {
119
+ const code = (error as NodeJS.ErrnoException).code
120
+ return new DevccError({
121
+ scope: "command",
122
+ message: code === "ENOENT" ? `\`${launch.command}\` was not found` : `Could not run ${launch.command}`,
123
+ cause: error,
124
+ })
125
+ }
@@ -35,6 +35,8 @@ export class ServiceManager extends TypedEmitter<ServiceManagerEvents> {
35
35
  private readonly adapters = new Map<string, ServiceAdapter>()
36
36
  private readonly detected = new Set<string>()
37
37
  private readonly snapshots = new Map<string, ServiceSnapshot>()
38
+ /** When each adapter was last asked for its status. */
39
+ private readonly checkedAt = new Map<string, number>()
38
40
  private timer: NodeJS.Timeout | null = null
39
41
  private refreshing = false
40
42
  private readonly intervalMs: number
@@ -92,7 +94,7 @@ export class ServiceManager extends TypedEmitter<ServiceManagerEvents> {
92
94
 
93
95
  start(): void {
94
96
  if (this.timer) return
95
- this.timer = setInterval(() => void this.refresh(), this.intervalMs)
97
+ this.timer = setInterval(() => void this.refresh({ polled: true }), this.intervalMs)
96
98
  this.timer.unref?.()
97
99
  }
98
100
 
@@ -102,14 +104,24 @@ export class ServiceManager extends TypedEmitter<ServiceManagerEvents> {
102
104
  this.timer = null
103
105
  }
104
106
 
105
- /** Refresh all detected adapters concurrently. Overlapping calls are skipped. */
106
- async refresh(): Promise<ServiceSnapshot[]> {
107
+ /**
108
+ * Refresh all detected adapters concurrently. Overlapping calls are skipped.
109
+ * A `polled` refresh (the timer) skips adapters whose `refreshIntervalMs`
110
+ * has not elapsed; an explicit refresh checks everything.
111
+ */
112
+ async refresh(options: { polled?: boolean } = {}): Promise<ServiceSnapshot[]> {
107
113
  if (this.refreshing) return this.list()
108
114
  this.refreshing = true
109
115
  try {
116
+ const now = Date.now()
110
117
  const targets = [...this.detected]
111
118
  .map((id) => this.adapters.get(id))
112
119
  .filter((a): a is ServiceAdapter => !!a)
120
+ .filter((adapter) => {
121
+ if (!options.polled || !adapter.refreshIntervalMs) return true
122
+ return now - (this.checkedAt.get(adapter.id) ?? 0) >= adapter.refreshIntervalMs
123
+ })
124
+ for (const adapter of targets) this.checkedAt.set(adapter.id, now)
113
125
 
114
126
  await Promise.all(
115
127
  targets.map(async (adapter) => {