@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.
- package/README.md +556 -386
- package/bin/devcc.mjs +50 -23
- package/dist/devcc/agents/agents.d.ts +112 -0
- package/dist/devcc/agents/agents.d.ts.map +1 -0
- package/dist/devcc/agents/agents.js +179 -0
- package/dist/devcc/agents/agents.js.map +1 -0
- package/dist/devcc/app/App.d.ts +15 -0
- package/dist/devcc/app/App.d.ts.map +1 -1
- package/dist/devcc/app/App.js +326 -17
- package/dist/devcc/app/App.js.map +1 -1
- package/dist/devcc/app/controlPlaneCommands.d.ts +14 -0
- package/dist/devcc/app/controlPlaneCommands.d.ts.map +1 -0
- package/dist/devcc/app/controlPlaneCommands.js +179 -0
- package/dist/devcc/app/controlPlaneCommands.js.map +1 -0
- package/dist/devcc/app/keymap.d.ts.map +1 -1
- package/dist/devcc/app/keymap.js +24 -3
- package/dist/devcc/app/keymap.js.map +1 -1
- package/dist/devcc/automation.d.ts +31 -0
- package/dist/devcc/automation.d.ts.map +1 -0
- package/dist/devcc/automation.js +202 -0
- package/dist/devcc/automation.js.map +1 -0
- package/dist/devcc/cli.d.ts.map +1 -1
- package/dist/devcc/cli.js +46 -3
- package/dist/devcc/cli.js.map +1 -1
- package/dist/devcc/components/Sidebar.d.ts.map +1 -1
- package/dist/devcc/components/Sidebar.js +1 -0
- package/dist/devcc/components/Sidebar.js.map +1 -1
- package/dist/devcc/components/primitives.d.ts.map +1 -1
- package/dist/devcc/components/primitives.js +3 -0
- package/dist/devcc/components/primitives.js.map +1 -1
- package/dist/devcc/core/commands.d.ts +25 -0
- package/dist/devcc/core/commands.d.ts.map +1 -1
- package/dist/devcc/core/commands.js.map +1 -1
- package/dist/devcc/core/config.d.ts +69 -3
- package/dist/devcc/core/config.d.ts.map +1 -1
- package/dist/devcc/core/config.js +185 -5
- package/dist/devcc/core/config.js.map +1 -1
- package/dist/devcc/core/state.d.ts +19 -1
- package/dist/devcc/core/state.d.ts.map +1 -1
- package/dist/devcc/core/state.js +6 -0
- package/dist/devcc/core/state.js.map +1 -1
- package/dist/devcc/environment/EnvironmentService.d.ts +58 -0
- package/dist/devcc/environment/EnvironmentService.d.ts.map +1 -0
- package/dist/devcc/environment/EnvironmentService.js +159 -0
- package/dist/devcc/environment/EnvironmentService.js.map +1 -0
- package/dist/devcc/environment/integrations.d.ts +52 -0
- package/dist/devcc/environment/integrations.d.ts.map +1 -0
- package/dist/devcc/environment/integrations.js +94 -0
- package/dist/devcc/environment/integrations.js.map +1 -0
- package/dist/devcc/environment/tools.d.ts +51 -0
- package/dist/devcc/environment/tools.d.ts.map +1 -0
- package/dist/devcc/environment/tools.js +99 -0
- package/dist/devcc/environment/tools.js.map +1 -0
- package/dist/devcc/git/GitService.d.ts +26 -0
- package/dist/devcc/git/GitService.d.ts.map +1 -1
- package/dist/devcc/git/GitService.js +71 -2
- package/dist/devcc/git/GitService.js.map +1 -1
- package/dist/devcc/git/worktrees.d.ts +88 -0
- package/dist/devcc/git/worktrees.d.ts.map +1 -0
- package/dist/devcc/git/worktrees.js +226 -0
- package/dist/devcc/git/worktrees.js.map +1 -0
- package/dist/devcc/index.d.ts +13 -3
- package/dist/devcc/index.d.ts.map +1 -1
- package/dist/devcc/index.js +11 -1
- package/dist/devcc/index.js.map +1 -1
- package/dist/devcc/platform/launch.d.ts +30 -0
- package/dist/devcc/platform/launch.d.ts.map +1 -0
- package/dist/devcc/platform/launch.js +93 -0
- package/dist/devcc/platform/launch.js.map +1 -0
- package/dist/devcc/services/ServiceManager.d.ts +10 -2
- package/dist/devcc/services/ServiceManager.d.ts.map +1 -1
- package/dist/devcc/services/ServiceManager.js +18 -4
- package/dist/devcc/services/ServiceManager.js.map +1 -1
- package/dist/devcc/services/ollama.d.ts +64 -5
- package/dist/devcc/services/ollama.d.ts.map +1 -1
- package/dist/devcc/services/ollama.js +118 -17
- package/dist/devcc/services/ollama.js.map +1 -1
- package/dist/devcc/services/remote.d.ts +90 -0
- package/dist/devcc/services/remote.d.ts.map +1 -0
- package/dist/devcc/services/remote.js +233 -0
- package/dist/devcc/services/remote.js.map +1 -0
- package/dist/devcc/services/types.d.ts +8 -1
- package/dist/devcc/services/types.d.ts.map +1 -1
- package/dist/devcc/services/types.js.map +1 -1
- package/dist/devcc/utils/exec.d.ts +6 -0
- package/dist/devcc/utils/exec.d.ts.map +1 -1
- package/dist/devcc/utils/exec.js +12 -2
- package/dist/devcc/utils/exec.js.map +1 -1
- package/dist/devcc/verify/verify.d.ts +107 -0
- package/dist/devcc/verify/verify.d.ts.map +1 -0
- package/dist/devcc/verify/verify.js +203 -0
- package/dist/devcc/verify/verify.js.map +1 -0
- package/dist/devcc/views/OverviewView.d.ts.map +1 -1
- package/dist/devcc/views/OverviewView.js +55 -1
- package/dist/devcc/views/OverviewView.js.map +1 -1
- package/dist/devcc/views/ServicesView.d.ts +2 -0
- package/dist/devcc/views/ServicesView.d.ts.map +1 -1
- package/dist/devcc/views/ServicesView.js +15 -0
- package/dist/devcc/views/ServicesView.js.map +1 -1
- package/dist/devcc/views/SystemView.d.ts +10 -1
- package/dist/devcc/views/SystemView.d.ts.map +1 -1
- package/dist/devcc/views/SystemView.js +127 -19
- package/dist/devcc/views/SystemView.js.map +1 -1
- package/dist/devcc/views/WorktreesView.d.ts +48 -0
- package/dist/devcc/views/WorktreesView.d.ts.map +1 -0
- package/dist/devcc/views/WorktreesView.js +235 -0
- package/dist/devcc/views/WorktreesView.js.map +1 -0
- package/package.json +1 -1
- package/src/devcc/agents/agents.ts +282 -0
- package/src/devcc/app/App.ts +376 -20
- package/src/devcc/app/controlPlaneCommands.ts +197 -0
- package/src/devcc/app/keymap.ts +24 -3
- package/src/devcc/automation.ts +242 -0
- package/src/devcc/cli.ts +45 -3
- package/src/devcc/components/Sidebar.ts +1 -0
- package/src/devcc/components/primitives.ts +3 -0
- package/src/devcc/core/commands.ts +26 -0
- package/src/devcc/core/config.ts +271 -11
- package/src/devcc/core/state.ts +25 -0
- package/src/devcc/environment/EnvironmentService.ts +215 -0
- package/src/devcc/environment/integrations.ts +133 -0
- package/src/devcc/environment/tools.ts +151 -0
- package/src/devcc/git/GitService.ts +104 -2
- package/src/devcc/git/worktrees.ts +256 -0
- package/src/devcc/index.ts +57 -2
- package/src/devcc/platform/launch.ts +125 -0
- package/src/devcc/services/ServiceManager.ts +15 -3
- package/src/devcc/services/ollama.ts +162 -19
- package/src/devcc/services/remote.ts +306 -0
- package/src/devcc/services/types.ts +9 -1
- package/src/devcc/utils/exec.ts +26 -2
- package/src/devcc/verify/verify.ts +316 -0
- package/src/devcc/views/OverviewView.ts +76 -1
- package/src/devcc/views/ServicesView.ts +17 -0
- package/src/devcc/views/SystemView.ts +144 -22
- 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
|
|
19
|
-
*
|
|
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
|
|
23
|
-
readonly name
|
|
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.
|
|
33
|
-
this.
|
|
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)
|
|
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
|
|
52
|
-
items: models
|
|
122
|
+
detail,
|
|
123
|
+
items: models.slice(0, MAX_ITEMS).map(modelItem),
|
|
53
124
|
checkedAt,
|
|
54
125
|
}
|
|
55
126
|
}
|
|
56
127
|
|
|
57
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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>
|
package/src/devcc/utils/exec.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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(() => {
|