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