@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
@@ -1,73 +1,216 @@
1
+ import { formatBytes } from "../utils/format.js"
1
2
  import { probeTcpPort } from "../utils/exec.js"
2
- import type { ServiceAdapter, ServiceStatus } from "./types.js"
3
+ import type { ServiceAdapter, ServiceStatus, ServiceStatusItem } from "./types.js"
3
4
 
4
5
  const DEFAULT_HOST = "127.0.0.1"
5
6
  const DEFAULT_PORT = 11434
7
+ /** Remote endpoints are checked at most this often while polling. */
8
+ const REMOTE_REFRESH_MS = 60_000
9
+ const MAX_ITEMS = 12
10
+
11
+ interface OllamaModelDetails {
12
+ parameter_size?: string
13
+ quantization_level?: string
14
+ family?: string
15
+ }
6
16
 
7
17
  interface OllamaTagsResponse {
8
- models?: { name?: string }[]
18
+ models?: { name?: string; size?: number; details?: OllamaModelDetails }[]
19
+ }
20
+
21
+ interface OllamaPsResponse {
22
+ models?: {
23
+ name?: string
24
+ size?: number
25
+ size_vram?: number
26
+ expires_at?: string
27
+ details?: OllamaModelDetails
28
+ }[]
29
+ }
30
+
31
+ export interface OllamaModel {
32
+ name: string
33
+ /** Bytes on disk. */
34
+ size?: number
35
+ parameterSize?: string
36
+ quantization?: string
37
+ family?: string
38
+ /** Loaded into memory right now. */
39
+ loaded: boolean
40
+ /** Bytes of VRAM in use, when loaded. */
41
+ vram?: number
42
+ /** When Ollama will unload it. */
43
+ expiresAt?: Date
9
44
  }
10
45
 
11
46
  export interface OllamaServiceOptions {
47
+ /** Defaults to `ollama`; set for extra endpoints. */
48
+ id?: string
49
+ name?: string
50
+ /** `http://gpu-server:11434`. Takes precedence over host/port and `OLLAMA_HOST`. */
51
+ baseUrl?: string
12
52
  host?: string
13
53
  port?: number
14
54
  timeoutMs?: number
55
+ /** Minimum time between polled checks (default: none locally, 60s for a baseUrl). */
56
+ refreshIntervalMs?: number
15
57
  }
16
58
 
17
59
  /**
18
- * Ollama adapter. Uses the HTTP API for a model count when the port is open;
19
- * falls back to a plain port probe if the API call fails.
60
+ * Ollama adapter, local or remote.
61
+ *
62
+ * Reachability is a TCP probe; when the port answers, `/api/tags` lists the
63
+ * models and `/api/ps` says which are loaded. Model names are never assumed.
20
64
  */
21
65
  export class OllamaService implements ServiceAdapter {
22
- readonly id = "ollama"
23
- readonly name = "Ollama"
66
+ readonly id: string
67
+ readonly name: string
24
68
  readonly category = "ai" as const
69
+ readonly refreshIntervalMs: number | undefined
25
70
 
71
+ private readonly baseUrl: string
26
72
  private readonly host: string
27
73
  private readonly port: number
28
74
  private readonly timeoutMs: number
75
+ private readonly remote: boolean
29
76
 
30
77
  constructor(options: OllamaServiceOptions = {}) {
78
+ const fromUrl = options.baseUrl ? parseBaseUrl(options.baseUrl) : null
31
79
  const fromEnv = parseHostPort(process.env.OLLAMA_HOST)
32
- this.host = options.host ?? fromEnv?.host ?? DEFAULT_HOST
33
- this.port = options.port ?? fromEnv?.port ?? DEFAULT_PORT
80
+ this.remote = options.baseUrl !== undefined
81
+ this.id = options.id ?? "ollama"
82
+ this.host = fromUrl?.host ?? options.host ?? fromEnv?.host ?? DEFAULT_HOST
83
+ this.port = fromUrl?.port ?? options.port ?? fromEnv?.port ?? DEFAULT_PORT
84
+ this.baseUrl = options.baseUrl?.replace(/\/+$/u, "") ?? `http://${this.host}:${this.port}`
85
+ this.name = options.name ?? (this.remote ? `Ollama (${this.host})` : "Ollama")
34
86
  this.timeoutMs = options.timeoutMs ?? 1_500
87
+ this.refreshIntervalMs = options.refreshIntervalMs ?? (this.remote ? REMOTE_REFRESH_MS : undefined)
35
88
  }
36
89
 
90
+ /** A configured endpoint is always shown, so "unreachable" is visible; the local one only when running. */
37
91
  async detect(): Promise<boolean> {
92
+ if (this.remote) return true
38
93
  return probeTcpPort(this.port, this.host)
39
94
  }
40
95
 
41
96
  async getStatus(): Promise<ServiceStatus> {
42
97
  const checkedAt = new Date()
43
98
  const endpoint = `${this.host}:${this.port}`
44
- const listening = await probeTcpPort(this.port, this.host)
45
- if (!listening) return { state: "stopped", endpoint, detail: "not listening", checkedAt }
99
+ const listening = await probeTcpPort(this.port, this.host, this.remote ? 2_000 : 700)
100
+ if (!listening) {
101
+ return { state: "stopped", endpoint, detail: this.remote ? "unreachable" : "not listening", checkedAt }
102
+ }
103
+
104
+ const [tags, ps] = await Promise.all([
105
+ this.getJson<OllamaTagsResponse>("/api/tags"),
106
+ this.getJson<OllamaPsResponse>("/api/ps"),
107
+ ])
108
+ if (!tags) return { state: "degraded", endpoint, detail: "listening, API not responding", checkedAt }
109
+
110
+ const models = mergeOllamaModels(tags, ps)
111
+ const loaded = models.filter((model) => model.loaded).length
112
+ const detail = [
113
+ `${models.length} model${models.length === 1 ? "" : "s"}`,
114
+ ps ? `${loaded} loaded` : undefined,
115
+ ]
116
+ .filter(Boolean)
117
+ .join(" · ")
46
118
 
47
- const models = await this.listModels()
48
119
  return {
49
120
  state: "running",
50
121
  endpoint,
51
- detail: models === null ? "listening" : `${models.length} model${models.length === 1 ? "" : "s"}`,
52
- items: models?.slice(0, 6).map((name) => ({ name, state: "running" as const })),
122
+ detail,
123
+ items: models.slice(0, MAX_ITEMS).map(modelItem),
53
124
  checkedAt,
54
125
  }
55
126
  }
56
127
 
57
- private async listModels(): Promise<string[] | null> {
128
+ /** Models with metadata, for the CLI and tests. Null when unreachable. */
129
+ async listModels(): Promise<OllamaModel[] | null> {
130
+ const [tags, ps] = await Promise.all([
131
+ this.getJson<OllamaTagsResponse>("/api/tags"),
132
+ this.getJson<OllamaPsResponse>("/api/ps"),
133
+ ])
134
+ return tags ? mergeOllamaModels(tags, ps) : null
135
+ }
136
+
137
+ private async getJson<T>(path: string): Promise<T | null> {
58
138
  try {
59
- const response = await fetch(`http://${this.host}:${this.port}/api/tags`, {
60
- signal: AbortSignal.timeout(this.timeoutMs),
61
- })
139
+ const response = await fetch(`${this.baseUrl}${path}`, { signal: AbortSignal.timeout(this.timeoutMs) })
62
140
  if (!response.ok) return null
63
- const body = (await response.json()) as OllamaTagsResponse
64
- return (body.models ?? []).map((model) => model.name ?? "unnamed")
141
+ return (await response.json()) as T
65
142
  } catch {
66
143
  return null
67
144
  }
68
145
  }
69
146
  }
70
147
 
148
+ /** Combine `/api/tags` with `/api/ps`: loaded models first, then by name. */
149
+ export function mergeOllamaModels(tags: OllamaTagsResponse, ps: OllamaPsResponse | null): OllamaModel[] {
150
+ const running = new Map(
151
+ (ps?.models ?? []).filter((model) => model.name).map((model) => [model.name as string, model]),
152
+ )
153
+ const models: OllamaModel[] = (tags.models ?? [])
154
+ .filter((model) => typeof model.name === "string")
155
+ .map((model) => {
156
+ const name = model.name as string
157
+ const live = running.get(name)
158
+ running.delete(name)
159
+ return {
160
+ name,
161
+ size: model.size,
162
+ parameterSize: model.details?.parameter_size,
163
+ quantization: model.details?.quantization_level,
164
+ family: model.details?.family,
165
+ loaded: live !== undefined,
166
+ vram: live?.size_vram,
167
+ expiresAt: parseDate(live?.expires_at),
168
+ }
169
+ })
170
+ // A model can be loaded but missing from tags (e.g. pulled mid-refresh).
171
+ for (const [name, live] of running) {
172
+ models.push({
173
+ name,
174
+ size: live.size,
175
+ parameterSize: live.details?.parameter_size,
176
+ quantization: live.details?.quantization_level,
177
+ family: live.details?.family,
178
+ loaded: true,
179
+ vram: live.size_vram,
180
+ expiresAt: parseDate(live.expires_at),
181
+ })
182
+ }
183
+ return models.sort((a, b) => Number(b.loaded) - Number(a.loaded) || a.name.localeCompare(b.name))
184
+ }
185
+
186
+ function modelItem(model: OllamaModel): ServiceStatusItem {
187
+ const meta = [model.parameterSize, model.quantization, model.size ? formatBytes(model.size) : undefined]
188
+ if (model.loaded && model.vram) meta.push(`${formatBytes(model.vram)} VRAM`)
189
+ return {
190
+ name: model.name,
191
+ state: model.loaded ? "running" : "stopped",
192
+ detail: [model.loaded ? "loaded" : undefined, ...meta].filter(Boolean).join(" · "),
193
+ }
194
+ }
195
+
196
+ function parseDate(value: string | undefined): Date | undefined {
197
+ if (!value) return undefined
198
+ const date = new Date(value)
199
+ return Number.isNaN(date.getTime()) ? undefined : date
200
+ }
201
+
202
+ /** A full base URL: a missing port means the scheme's default, exactly as `fetch` will treat it. */
203
+ export function parseBaseUrl(value: string): { host: string; port: number } | null {
204
+ try {
205
+ const parsed = new URL(value)
206
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null
207
+ return { host: parsed.hostname, port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)) }
208
+ } catch {
209
+ return null
210
+ }
211
+ }
212
+
213
+ /** `OLLAMA_HOST`-style `host[:port]`, where a missing port means Ollama's 11434. */
71
214
  export function parseHostPort(value: string | undefined): { host: string; port: number } | null {
72
215
  if (!value) return null
73
216
  const withScheme = value.includes("://") ? value : `http://${value}`
@@ -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(() => {