@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
|
@@ -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
|
+
}
|
|
@@ -4,6 +4,7 @@ import { RowList, type ListRow } from "../components/primitives.js"
|
|
|
4
4
|
import { processGlyph, serviceGlyph } from "../components/StatusBadge.js"
|
|
5
5
|
import { chunk, dim, styled } from "../components/styled.js"
|
|
6
6
|
import { summariseStatus, summariseTracking } from "../git/parse.js"
|
|
7
|
+
import { worktreeLabel } from "../git/worktrees.js"
|
|
7
8
|
import { isActive } from "../processes/ManagedProcess.js"
|
|
8
9
|
import type { AppState, ViewId } from "../core/state.js"
|
|
9
10
|
import { fit, formatDuration, meter, toGigabytes } from "../utils/format.js"
|
|
@@ -51,6 +52,43 @@ export class OverviewView extends View {
|
|
|
51
52
|
),
|
|
52
53
|
)
|
|
53
54
|
rows.push(info("tracking", summariseTracking(state.git.status), labelWidth))
|
|
55
|
+
if (state.git.worktrees.length > 1) {
|
|
56
|
+
const current = state.git.worktrees.find((worktree) => worktree.isCurrent)
|
|
57
|
+
rows.push(
|
|
58
|
+
info(
|
|
59
|
+
"worktree",
|
|
60
|
+
`${current ? worktreeLabel(current) : "?"} · ${state.git.worktrees.length} worktrees (w)`,
|
|
61
|
+
labelWidth,
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --- Verification ------------------------------------------------------
|
|
68
|
+
if (state.verify.configured > 0) {
|
|
69
|
+
rows.push(blank())
|
|
70
|
+
rows.push(section("Verify"))
|
|
71
|
+
rows.push(verifyRow(state, labelWidth))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- AI agents ---------------------------------------------------------
|
|
75
|
+
if (state.agents.length > 0) {
|
|
76
|
+
rows.push(blank())
|
|
77
|
+
rows.push(section("AI agents"))
|
|
78
|
+
for (const agent of state.agents.slice(0, compact ? 2 : 4)) {
|
|
79
|
+
rows.push({
|
|
80
|
+
key: `agent-${agent.id}`,
|
|
81
|
+
disabled: true,
|
|
82
|
+
content: styled(
|
|
83
|
+
" ",
|
|
84
|
+
processGlyph(agent.status).chunks[0] ?? chunk(" "),
|
|
85
|
+
" ",
|
|
86
|
+
chunk(fit(agent.agentName, 16), theme.text),
|
|
87
|
+
dim(fit(agent.branch ?? agent.cwd, 26)),
|
|
88
|
+
dim(fit(agent.task, 40)),
|
|
89
|
+
),
|
|
90
|
+
})
|
|
91
|
+
}
|
|
54
92
|
}
|
|
55
93
|
|
|
56
94
|
// --- Development processes -------------------------------------------
|
|
@@ -135,6 +173,16 @@ export class OverviewView extends View {
|
|
|
135
173
|
rows.push(info("node", `${state.project.host.runtime}`, labelWidth))
|
|
136
174
|
}
|
|
137
175
|
}
|
|
176
|
+
const attention = environmentAttention(state)
|
|
177
|
+
if (attention) {
|
|
178
|
+
rows.push(
|
|
179
|
+
infoStyled(
|
|
180
|
+
"environment",
|
|
181
|
+
styled(chunk(attention, theme.warning), dim(" (8 for details)")),
|
|
182
|
+
labelWidth,
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
}
|
|
138
186
|
|
|
139
187
|
this.list.setRows(rows, -1)
|
|
140
188
|
}
|
|
@@ -144,7 +192,8 @@ export class OverviewView extends View {
|
|
|
144
192
|
{ keys: "↑↓", label: "navigate" },
|
|
145
193
|
{ keys: "p", label: "processes" },
|
|
146
194
|
{ keys: "g", label: "git" },
|
|
147
|
-
{ keys: "
|
|
195
|
+
{ keys: "w", label: "worktrees" },
|
|
196
|
+
{ keys: "V", label: "verify" },
|
|
148
197
|
{ keys: "/", label: "commands" },
|
|
149
198
|
{ keys: "?", label: "help" },
|
|
150
199
|
{ keys: "q", label: "quit" },
|
|
@@ -180,6 +229,32 @@ function infoStyled(label: string, value: ReturnType<typeof styled>, labelWidth
|
|
|
180
229
|
}
|
|
181
230
|
}
|
|
182
231
|
|
|
232
|
+
function verifyRow(state: AppState, labelWidth: number): ListRow {
|
|
233
|
+
const { verify } = state
|
|
234
|
+
if (verify.steps.length === 0) {
|
|
235
|
+
return info("status", `${verify.configured} step(s) configured - press V to run`, labelWidth)
|
|
236
|
+
}
|
|
237
|
+
const passed = verify.steps.filter((step) => step.status === "passed").length
|
|
238
|
+
const failed = verify.steps.filter((step) => step.status === "failed")
|
|
239
|
+
const running = verify.steps.find((step) => step.status === "running")
|
|
240
|
+
const color = verify.running ? theme.warning : verify.ok ? theme.success : theme.danger
|
|
241
|
+
const summary = verify.running
|
|
242
|
+
? `${passed}/${verify.steps.length} passed · running ${running?.name ?? "…"}`
|
|
243
|
+
: `${passed}/${verify.steps.length} passed${failed.length > 0 ? ` · failed: ${failed.map((step) => step.name).join(", ")}` : ""}`
|
|
244
|
+
return infoStyled("status", styled(chunk(summary, color)), labelWidth)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** One line when the environment needs a look, otherwise nothing. */
|
|
248
|
+
function environmentAttention(state: AppState): string | null {
|
|
249
|
+
const { tools, direnv, mise } = state.environment
|
|
250
|
+
const problems = tools
|
|
251
|
+
.filter((tool) => tool.status === "error" || tool.status === "warning")
|
|
252
|
+
.map((tool) => tool.name)
|
|
253
|
+
if (mise.error) problems.push("mise")
|
|
254
|
+
if (direnv.installed && direnv.rcPath && direnv.approval !== "allowed") problems.push(".envrc not allowed")
|
|
255
|
+
return problems.length > 0 ? problems.join(", ") : null
|
|
256
|
+
}
|
|
257
|
+
|
|
183
258
|
function usageColor(ratio: number): string {
|
|
184
259
|
if (ratio > 0.85) return theme.danger
|
|
185
260
|
if (ratio > 0.6) return theme.warning
|
|
@@ -5,11 +5,14 @@ import { RowList, type ListRow } from "../components/primitives.js"
|
|
|
5
5
|
import { serviceBadge, serviceGlyph } from "../components/StatusBadge.js"
|
|
6
6
|
import { chunk, dim, styled } from "../components/styled.js"
|
|
7
7
|
import { setSelection, type AppState, type ViewId } from "../core/state.js"
|
|
8
|
+
import { remoteServiceId } from "../services/remote.js"
|
|
8
9
|
import type { ServiceSnapshot } from "../services/types.js"
|
|
9
10
|
import { errorMessage } from "../utils/errors.js"
|
|
10
11
|
import { fit } from "../utils/format.js"
|
|
11
12
|
import { View, type ViewDeps } from "./View.js"
|
|
12
13
|
|
|
14
|
+
const REMOTE_PREFIX = remoteServiceId("")
|
|
15
|
+
|
|
13
16
|
interface Entry {
|
|
14
17
|
kind: "service" | "item" | "heading"
|
|
15
18
|
service?: ServiceSnapshot
|
|
@@ -117,6 +120,9 @@ export class ServicesView extends View {
|
|
|
117
120
|
case "d":
|
|
118
121
|
void this.composeDown(selected)
|
|
119
122
|
return true
|
|
123
|
+
case "o":
|
|
124
|
+
void this.ssh(selected)
|
|
125
|
+
return true
|
|
120
126
|
default:
|
|
121
127
|
return false
|
|
122
128
|
}
|
|
@@ -128,6 +134,7 @@ export class ServicesView extends View {
|
|
|
128
134
|
{ keys: "s", label: "start/stop" },
|
|
129
135
|
{ keys: "r", label: "restart" },
|
|
130
136
|
{ keys: "d", label: "compose down" },
|
|
137
|
+
{ keys: "o", label: "ssh (remote)" },
|
|
131
138
|
{ keys: "/", label: "commands" },
|
|
132
139
|
]
|
|
133
140
|
}
|
|
@@ -176,6 +183,16 @@ export class ServicesView extends View {
|
|
|
176
183
|
}
|
|
177
184
|
}
|
|
178
185
|
|
|
186
|
+
/** Open an SSH session to a remote machine in a terminal. */
|
|
187
|
+
private async ssh(index: number): Promise<void> {
|
|
188
|
+
const service = this.selectedService(index)
|
|
189
|
+
if (!service?.id.startsWith(REMOTE_PREFIX)) {
|
|
190
|
+
this.deps.app.notify("warn", "Select a remote machine first")
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
await this.deps.app.sshTo(service.id.slice(REMOTE_PREFIX.length))
|
|
194
|
+
}
|
|
195
|
+
|
|
179
196
|
/** `docker compose down` removes containers; always confirmed. */
|
|
180
197
|
private async composeDown(index: number): Promise<void> {
|
|
181
198
|
const service = this.selectedService(index)
|
|
@@ -1,17 +1,28 @@
|
|
|
1
|
+
import type { KeyEvent } from "@opentui/core"
|
|
1
2
|
import { theme } from "../app/theme.js"
|
|
2
3
|
import type { KeyHint } from "../components/Footer.js"
|
|
3
4
|
import { RowList, type ListRow } from "../components/primitives.js"
|
|
4
5
|
import { chunk, dim, styled } from "../components/styled.js"
|
|
5
6
|
import type { AppState, ViewId } from "../core/state.js"
|
|
7
|
+
import type { DirenvApproval } from "../environment/integrations.js"
|
|
8
|
+
import type { ToolStatus } from "../environment/tools.js"
|
|
6
9
|
import { fit, formatBytes, formatDuration, meter, toGigabytes } from "../utils/format.js"
|
|
10
|
+
import { tildify } from "../utils/paths.js"
|
|
7
11
|
import { View, type ViewDeps } from "./View.js"
|
|
8
12
|
|
|
9
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Environment health, live resource usage and host information.
|
|
15
|
+
*
|
|
16
|
+
* Tool, mise and direnv detection runs once in the background and on request
|
|
17
|
+
* ("Environment: refresh" in the palette); nothing here polls external tools.
|
|
18
|
+
*/
|
|
10
19
|
export class SystemView extends View {
|
|
11
20
|
readonly id: ViewId = "system"
|
|
12
21
|
readonly title = "System"
|
|
13
22
|
|
|
14
23
|
private readonly list: RowList
|
|
24
|
+
/** Kept across refreshes: metrics repaint every tick and must not reset the scroll. */
|
|
25
|
+
private offset = 0
|
|
15
26
|
|
|
16
27
|
constructor(deps: ViewDeps) {
|
|
17
28
|
super(deps)
|
|
@@ -21,28 +32,72 @@ export class SystemView extends View {
|
|
|
21
32
|
|
|
22
33
|
update(state: AppState): void {
|
|
23
34
|
const host = state.project.host
|
|
24
|
-
const tools = state.project.tools
|
|
25
35
|
const metrics = state.system
|
|
36
|
+
const environment = state.environment
|
|
26
37
|
const rows: ListRow[] = []
|
|
27
38
|
|
|
28
|
-
rows.push(heading("
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
39
|
+
rows.push(heading("Environment"))
|
|
40
|
+
if (!environment.checkedAt) {
|
|
41
|
+
rows.push(row("", "checking tools…"))
|
|
42
|
+
} else {
|
|
43
|
+
for (const tool of environment.tools) rows.push(toolRow(tool))
|
|
44
|
+
if (environment.checking) rows.push(row("", "re-checking…"))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { mise, direnv } = environment
|
|
48
|
+
if (mise.installed) {
|
|
49
|
+
rows.push(blank("mise"))
|
|
50
|
+
rows.push(heading("Runtimes (mise)"))
|
|
51
|
+
if (mise.error) rows.push(warningRow("mise", mise.error))
|
|
52
|
+
else if (mise.runtimes.length === 0) rows.push(row("", "no tools pinned for this project"))
|
|
53
|
+
for (const runtime of mise.runtimes) {
|
|
54
|
+
const note = [
|
|
55
|
+
runtime.requested && runtime.requested !== runtime.version
|
|
56
|
+
? `requested ${runtime.requested}`
|
|
57
|
+
: undefined,
|
|
58
|
+
runtime.installed ? undefined : "not installed - run `mise install`",
|
|
59
|
+
]
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.join(" · ")
|
|
62
|
+
rows.push({
|
|
63
|
+
key: `rt-${runtime.tool}-${runtime.version}`,
|
|
64
|
+
disabled: true,
|
|
65
|
+
content: styled(
|
|
66
|
+
dim(fit(runtime.tool, LABEL_WIDTH)),
|
|
67
|
+
chunk(fit(runtime.version, 14), runtime.installed ? theme.text : theme.warning),
|
|
68
|
+
dim(note),
|
|
69
|
+
),
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (direnv.installed || direnv.rcPath) {
|
|
75
|
+
rows.push(blank("direnv"))
|
|
76
|
+
rows.push(heading("direnv"))
|
|
77
|
+
if (direnv.error) rows.push(warningRow("status", direnv.error))
|
|
78
|
+
if (!direnv.rcPath) {
|
|
79
|
+
rows.push(row(".envrc", "none for this project"))
|
|
80
|
+
} else {
|
|
81
|
+
rows.push(row(".envrc", tildify(direnv.rcPath)))
|
|
82
|
+
if (direnv.installed) {
|
|
83
|
+
const approval = APPROVAL_LABELS[direnv.approval]
|
|
84
|
+
rows.push({
|
|
85
|
+
key: "direnv-approval",
|
|
86
|
+
disabled: true,
|
|
87
|
+
content: styled(
|
|
88
|
+
dim(fit("approval", LABEL_WIDTH)),
|
|
89
|
+
chunk(approval, direnv.approval === "allowed" ? theme.success : theme.warning),
|
|
90
|
+
direnv.approval === "allowed" ? dim("") : dim(" palette: Environment: direnv allow"),
|
|
91
|
+
),
|
|
92
|
+
})
|
|
93
|
+
rows.push(
|
|
94
|
+
row("loaded in devcc", direnv.loaded ? "yes" : "no - start devcc from a direnv-enabled shell"),
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
34
99
|
|
|
35
|
-
rows.push(blank())
|
|
36
|
-
rows.push(heading("Toolchain"))
|
|
37
|
-
rows.push(row("runtime", host.runtime))
|
|
38
|
-
rows.push(row("node", host.nodeVersion))
|
|
39
|
-
rows.push(
|
|
40
|
-
row("package manager", `${state.project.packageManager.manager} ${tools.packageManager ?? ""}`.trim()),
|
|
41
|
-
)
|
|
42
|
-
rows.push(row("git", tools.git ?? "not found"))
|
|
43
|
-
rows.push(row("docker", tools.docker ?? "not found"))
|
|
44
|
-
|
|
45
|
-
rows.push(blank())
|
|
100
|
+
rows.push(blank("usage"))
|
|
46
101
|
rows.push(heading("Usage"))
|
|
47
102
|
if (!metrics) {
|
|
48
103
|
rows.push(row("", "sampling…"))
|
|
@@ -67,15 +122,46 @@ export class SystemView extends View {
|
|
|
67
122
|
rows.push(row("load average", metrics.loadAverage.map((value) => value.toFixed(2)).join(" ")))
|
|
68
123
|
rows.push(row("host uptime", formatDuration(metrics.uptimeMs)))
|
|
69
124
|
rows.push(row("devcc uptime", formatDuration(metrics.processUptimeMs)))
|
|
70
|
-
rows.push(row("sampled", metrics.sampledAt.toLocaleTimeString()))
|
|
71
125
|
}
|
|
72
126
|
|
|
127
|
+
rows.push(blank("host"))
|
|
128
|
+
rows.push(heading("Host"))
|
|
129
|
+
rows.push(row("hostname", host.hostname))
|
|
130
|
+
rows.push(row("os", `${host.platform} ${host.release}`))
|
|
131
|
+
rows.push(row("cpu", `${host.cpuModel} (${host.cpuCount} cores)`))
|
|
132
|
+
rows.push(row("memory", `${toGigabytes(host.totalMemory)} GB total`))
|
|
133
|
+
rows.push(row("devcc runtime", host.runtime))
|
|
134
|
+
rows.push(row("package manager", state.project.packageManager.manager))
|
|
135
|
+
|
|
73
136
|
this.list.setRows(rows, -1)
|
|
137
|
+
if (this.offset > 0) this.list.scrollBy(this.offset)
|
|
138
|
+
this.offset = this.list.offset
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
override handleKey(key: KeyEvent): boolean {
|
|
142
|
+
switch (key.name) {
|
|
143
|
+
case "up":
|
|
144
|
+
case "k":
|
|
145
|
+
this.list.scrollBy(-1)
|
|
146
|
+
this.offset = this.list.offset
|
|
147
|
+
return true
|
|
148
|
+
case "down":
|
|
149
|
+
case "j":
|
|
150
|
+
this.list.scrollBy(1)
|
|
151
|
+
this.offset = this.list.offset
|
|
152
|
+
return true
|
|
153
|
+
case "r":
|
|
154
|
+
void this.deps.commands.run("environment.refresh", this.deps.app)
|
|
155
|
+
return true
|
|
156
|
+
default:
|
|
157
|
+
return false
|
|
158
|
+
}
|
|
74
159
|
}
|
|
75
160
|
|
|
76
161
|
override hints(): KeyHint[] {
|
|
77
162
|
return [
|
|
78
163
|
{ keys: "↑↓", label: "scroll" },
|
|
164
|
+
{ keys: "r", label: "re-check environment" },
|
|
79
165
|
{ keys: "/", label: "commands" },
|
|
80
166
|
{ keys: "?", label: "help" },
|
|
81
167
|
]
|
|
@@ -88,8 +174,44 @@ function heading(title: string): ListRow {
|
|
|
88
174
|
return { key: `h-${title}`, disabled: true, content: styled(chunk(title.toUpperCase(), theme.accent)) }
|
|
89
175
|
}
|
|
90
176
|
|
|
91
|
-
function blank(): ListRow {
|
|
92
|
-
return { key: `b-${
|
|
177
|
+
function blank(id: string): ListRow {
|
|
178
|
+
return { key: `b-${id}`, disabled: true, content: "" }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const APPROVAL_LABELS: Record<DirenvApproval, string> = {
|
|
182
|
+
allowed: "allowed",
|
|
183
|
+
"not-allowed": "not allowed yet",
|
|
184
|
+
denied: "denied",
|
|
185
|
+
unknown: "unknown",
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function toolRow(tool: ToolStatus): ListRow {
|
|
189
|
+
const mark = !tool.installed
|
|
190
|
+
? chunk("·", theme.textDim)
|
|
191
|
+
: tool.status === "ready"
|
|
192
|
+
? chunk("✓", theme.success)
|
|
193
|
+
: tool.status === "warning"
|
|
194
|
+
? chunk("⚠", theme.warning)
|
|
195
|
+
: chunk("✖", theme.danger)
|
|
196
|
+
const value = tool.installed ? (tool.version ?? "installed") : "not installed"
|
|
197
|
+
return {
|
|
198
|
+
key: `tool-${tool.id}`,
|
|
199
|
+
disabled: true,
|
|
200
|
+
content: styled(
|
|
201
|
+
dim(fit(tool.name, LABEL_WIDTH)),
|
|
202
|
+
chunk(fit(value, 16), tool.installed ? theme.text : theme.textDim),
|
|
203
|
+
mark,
|
|
204
|
+
tool.message && tool.installed ? chunk(` ${tool.message}`, theme.warning) : dim(""),
|
|
205
|
+
),
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function warningRow(label: string, message: string): ListRow {
|
|
210
|
+
return {
|
|
211
|
+
key: `warn-${label}`,
|
|
212
|
+
disabled: true,
|
|
213
|
+
content: styled(dim(fit(label, LABEL_WIDTH)), chunk(`⚠ ${message}`, theme.warning)),
|
|
214
|
+
}
|
|
93
215
|
}
|
|
94
216
|
|
|
95
217
|
function row(label: string, value: string): ListRow {
|