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