@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,197 @@
|
|
|
1
|
+
import type { AgentAdapter } from "../agents/agents.js"
|
|
2
|
+
import type { CommandContext, CommandDefinition } from "../core/commands.js"
|
|
3
|
+
import type { RemoteMachineConfig } from "../core/config.js"
|
|
4
|
+
import { isActive } from "../processes/ManagedProcess.js"
|
|
5
|
+
import { errorMessage } from "../utils/errors.js"
|
|
6
|
+
|
|
7
|
+
export interface ControlPlaneCommandOptions {
|
|
8
|
+
agents: readonly AgentAdapter[]
|
|
9
|
+
machines: readonly RemoteMachineConfig[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The directory commands act on: the selected worktree, else the project root. */
|
|
13
|
+
function targetPath(context: CommandContext): string {
|
|
14
|
+
return context.selectedWorktree()?.path ?? context.store.getState().project.rootDir
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Palette commands for worktrees, agents, verification, environment and remote
|
|
19
|
+
* machines. They resolve their target at run time (`selectedWorktree`), so
|
|
20
|
+
* "Launch Claude" from the Worktrees view acts on the highlighted worktree.
|
|
21
|
+
*/
|
|
22
|
+
export function createControlPlaneCommands(options: ControlPlaneCommandOptions): CommandDefinition[] {
|
|
23
|
+
const inRepository = (context: CommandContext) => context.store.getState().git.isRepository
|
|
24
|
+
|
|
25
|
+
const commands: CommandDefinition[] = [
|
|
26
|
+
// --- Worktrees ---------------------------------------------------------
|
|
27
|
+
{
|
|
28
|
+
id: "worktree.create",
|
|
29
|
+
title: "Worktree: create…",
|
|
30
|
+
category: "Worktree",
|
|
31
|
+
shortcut: "n",
|
|
32
|
+
keywords: ["new", "branch", "add", "git"],
|
|
33
|
+
when: inRepository,
|
|
34
|
+
execute: (context) => context.createWorktree(),
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "worktree.remove",
|
|
38
|
+
title: "Worktree: remove the selected worktree",
|
|
39
|
+
category: "Worktree",
|
|
40
|
+
shortcut: "d",
|
|
41
|
+
keywords: ["delete", "git"],
|
|
42
|
+
when: (context) => {
|
|
43
|
+
const worktree = context.selectedWorktree()
|
|
44
|
+
return worktree !== undefined && !worktree.isMain && !worktree.isCurrent
|
|
45
|
+
},
|
|
46
|
+
execute: async (context) => {
|
|
47
|
+
const worktree = context.selectedWorktree()
|
|
48
|
+
if (worktree) await context.removeWorktree(worktree)
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "worktree.openProject",
|
|
53
|
+
title: "Worktree: open in devcc",
|
|
54
|
+
category: "Worktree",
|
|
55
|
+
shortcut: "o",
|
|
56
|
+
keywords: ["switch", "project", "workspace"],
|
|
57
|
+
when: (context) => {
|
|
58
|
+
const worktree = context.selectedWorktree()
|
|
59
|
+
return worktree !== undefined && !worktree.isCurrent && !worktree.bare
|
|
60
|
+
},
|
|
61
|
+
execute: async (context) => {
|
|
62
|
+
const worktree = context.selectedWorktree()
|
|
63
|
+
if (worktree) await context.switchProject(worktree.path)
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "worktree.openTerminal",
|
|
68
|
+
title: "Worktree: open terminal",
|
|
69
|
+
category: "Worktree",
|
|
70
|
+
shortcut: "t",
|
|
71
|
+
keywords: ["shell", "console"],
|
|
72
|
+
execute: (context) => context.openInTerminal(targetPath(context)),
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "worktree.openEditor",
|
|
76
|
+
title: "Worktree: open in editor",
|
|
77
|
+
category: "Worktree",
|
|
78
|
+
shortcut: "e",
|
|
79
|
+
keywords: ["ide", "code", "zed", "cursor", "webstorm"],
|
|
80
|
+
execute: (context) => context.openInEditor(targetPath(context)),
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "worktree.list",
|
|
84
|
+
title: "Worktree: show worktrees and agents",
|
|
85
|
+
category: "Worktree",
|
|
86
|
+
keywords: ["list", "agents"],
|
|
87
|
+
when: inRepository,
|
|
88
|
+
execute: async (context) => {
|
|
89
|
+
context.navigate("worktrees")
|
|
90
|
+
await context.git.refresh()
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
// --- AI agents ---------------------------------------------------------
|
|
95
|
+
...options.agents.map((agent): CommandDefinition => ({
|
|
96
|
+
id: `agent.launch.${agent.id}`,
|
|
97
|
+
title: `AI: launch ${agent.name}`,
|
|
98
|
+
category: "AI",
|
|
99
|
+
shortcut: agent.id === "claude" ? "c" : agent.id === "codex" ? "x" : undefined,
|
|
100
|
+
keywords: ["agent", "ai", agent.id, agent.command],
|
|
101
|
+
execute: (context) => context.launchAgent(agent.id),
|
|
102
|
+
})),
|
|
103
|
+
{
|
|
104
|
+
id: "agent.stopAll",
|
|
105
|
+
title: "AI: stop all running agents",
|
|
106
|
+
category: "AI",
|
|
107
|
+
keywords: ["agent", "kill", "cancel"],
|
|
108
|
+
when: (context) => context.store.getState().agents.some((session) => isActive(session.status)),
|
|
109
|
+
confirm: { title: "Stop every running agent?", message: "Their work so far stays in the worktrees." },
|
|
110
|
+
execute: async (context) => {
|
|
111
|
+
const running = context.store.getState().agents.filter((session) => isActive(session.status))
|
|
112
|
+
await Promise.all(running.map((session) => context.agents.stop(session.id)))
|
|
113
|
+
context.notify("info", `Stopped ${running.length} agent(s)`)
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
// --- Verification ------------------------------------------------------
|
|
118
|
+
{
|
|
119
|
+
id: "verify.run",
|
|
120
|
+
title: "Project: verify (run every verification step)",
|
|
121
|
+
category: "Project",
|
|
122
|
+
shortcut: "V",
|
|
123
|
+
keywords: ["check", "ci", "test", "lint", "gate", "ship"],
|
|
124
|
+
execute: (context) => context.runVerify(),
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
// --- Environment -------------------------------------------------------
|
|
128
|
+
{
|
|
129
|
+
id: "environment.refresh",
|
|
130
|
+
title: "Environment: refresh tools, runtimes and direnv",
|
|
131
|
+
category: "Environment",
|
|
132
|
+
keywords: ["mise", "direnv", "versions", "doctor", "health"],
|
|
133
|
+
execute: async (context) => {
|
|
134
|
+
context.navigate("system")
|
|
135
|
+
const snapshot = await context.environment.refresh()
|
|
136
|
+
const problems = snapshot.tools.filter((tool) => tool.status === "error" || tool.status === "warning")
|
|
137
|
+
context.notify(
|
|
138
|
+
problems.length > 0 ? "warn" : "success",
|
|
139
|
+
problems.length > 0
|
|
140
|
+
? `Environment: ${problems.map((tool) => tool.name).join(", ")} need attention`
|
|
141
|
+
: "Environment checked",
|
|
142
|
+
)
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: "environment.direnvAllow",
|
|
147
|
+
title: "Environment: direnv allow this project's .envrc",
|
|
148
|
+
category: "Environment",
|
|
149
|
+
keywords: ["direnv", "envrc", "approve", "trust"],
|
|
150
|
+
when: (context) => {
|
|
151
|
+
const { direnv } = context.store.getState().environment
|
|
152
|
+
return direnv.installed && direnv.rcPath !== undefined && direnv.approval !== "allowed"
|
|
153
|
+
},
|
|
154
|
+
execute: async (context) => {
|
|
155
|
+
const { rcPath } = context.store.getState().environment.direnv
|
|
156
|
+
const confirmed = await context.confirm(
|
|
157
|
+
"Allow this .envrc?",
|
|
158
|
+
`direnv will run ${rcPath ?? ".envrc"} whenever a shell enters the project. Only allow files you have read.`,
|
|
159
|
+
)
|
|
160
|
+
if (!confirmed) return
|
|
161
|
+
try {
|
|
162
|
+
await context.environment.allowDirenv()
|
|
163
|
+
context.notify("success", "direnv: .envrc allowed")
|
|
164
|
+
} catch (error) {
|
|
165
|
+
context.notify("error", errorMessage(error))
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
// --- Remote machines -----------------------------------------------------
|
|
172
|
+
for (const machine of options.machines) {
|
|
173
|
+
commands.push({
|
|
174
|
+
id: `remote.ssh.${machine.id}`,
|
|
175
|
+
title: `Remote: SSH to ${machine.name}`,
|
|
176
|
+
category: "Remote",
|
|
177
|
+
keywords: ["ssh", "remote", "connect", machine.host],
|
|
178
|
+
execute: (context) => context.sshTo(machine.id),
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
commands.push({
|
|
182
|
+
id: "remote.refresh",
|
|
183
|
+
title: "Remote: refresh machine and Tailscale status",
|
|
184
|
+
category: "Remote",
|
|
185
|
+
keywords: ["ssh", "tailscale", "ollama", "reachability"],
|
|
186
|
+
when: (context) =>
|
|
187
|
+
context.remoteMachines().length > 0 ||
|
|
188
|
+
context.store.getState().services.some((service) => service.category === "remote"),
|
|
189
|
+
execute: async (context) => {
|
|
190
|
+
context.navigate("services")
|
|
191
|
+
await context.services.refresh()
|
|
192
|
+
context.notify("info", "Remote status refreshed")
|
|
193
|
+
},
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
return commands
|
|
197
|
+
}
|
package/src/devcc/app/keymap.ts
CHANGED
|
@@ -12,8 +12,10 @@ export const VIEW_SHORTCUTS: Readonly<Record<string, ViewId>> = {
|
|
|
12
12
|
"7": "dependencies",
|
|
13
13
|
"8": "system",
|
|
14
14
|
"9": "settings",
|
|
15
|
+
"0": "worktrees",
|
|
15
16
|
p: "processes",
|
|
16
17
|
g: "git",
|
|
18
|
+
w: "worktrees",
|
|
17
19
|
l: "logs",
|
|
18
20
|
d: "dependencies",
|
|
19
21
|
}
|
|
@@ -51,9 +53,10 @@ export const HELP_SECTIONS: HelpSection[] = [
|
|
|
51
53
|
{ keys: "Services", description: "things detected on this machine: Docker, databases, Ollama" },
|
|
52
54
|
{ keys: "Scripts", description: "package.json scripts, run through your package manager" },
|
|
53
55
|
{ keys: "Git", description: "branch, changes, commits; stage, commit, pull, push" },
|
|
56
|
+
{ keys: "Worktrees", description: "git worktrees, and the AI agents (Claude, Codex) working in them" },
|
|
54
57
|
{ keys: "Logs", description: "output of everything devcc started, per source or merged" },
|
|
55
58
|
{ keys: "Dependencies", description: "outdated packages, checked only when you ask" },
|
|
56
|
-
{ keys: "System", description: "
|
|
59
|
+
{ keys: "System", description: "environment health (tools, mise, direnv), CPU, memory, disk, host" },
|
|
57
60
|
{ keys: "Settings", description: "detection results, config issues, diagnostics history" },
|
|
58
61
|
],
|
|
59
62
|
},
|
|
@@ -74,8 +77,8 @@ export const HELP_SECTIONS: HelpSection[] = [
|
|
|
74
77
|
{ keys: "← / h", description: "previous section" },
|
|
75
78
|
{ keys: "→", description: "next section" },
|
|
76
79
|
{ keys: "tab / S-tab", description: "cycle sections" },
|
|
77
|
-
{ keys: "1…9", description: "jump to a section" },
|
|
78
|
-
{ keys: "p / g / l / d", description: "processes / git / logs / dependencies" },
|
|
80
|
+
{ keys: "1…9, 0", description: "jump to a section (0 is Worktrees)" },
|
|
81
|
+
{ keys: "p / g / w / l / d", description: "processes / git / worktrees / logs / dependencies" },
|
|
79
82
|
{ keys: "enter", description: "open or activate" },
|
|
80
83
|
{ keys: "esc", description: "close overlay / back" },
|
|
81
84
|
],
|
|
@@ -109,6 +112,18 @@ export const HELP_SECTIONS: HelpSection[] = [
|
|
|
109
112
|
{ keys: "r", description: "refresh status" },
|
|
110
113
|
],
|
|
111
114
|
},
|
|
115
|
+
{
|
|
116
|
+
title: "Worktrees & agents",
|
|
117
|
+
entries: [
|
|
118
|
+
{ keys: "n", description: "new worktree (branch, base, directory)" },
|
|
119
|
+
{ keys: "d", description: "remove the worktree (confirmed, never forced)" },
|
|
120
|
+
{ keys: "o / enter", description: "open the worktree in devcc; on an agent, its logs" },
|
|
121
|
+
{ keys: "t / e", description: "open a terminal / the editor in the worktree" },
|
|
122
|
+
{ keys: "c / x", description: "launch Claude Code / Codex with a task in the worktree" },
|
|
123
|
+
{ keys: "s", description: "stop the selected agent" },
|
|
124
|
+
{ keys: "r", description: "refresh the worktree list" },
|
|
125
|
+
],
|
|
126
|
+
},
|
|
112
127
|
{
|
|
113
128
|
title: "Logs",
|
|
114
129
|
entries: [
|
|
@@ -148,6 +163,7 @@ export const HELP_SECTIONS: HelpSection[] = [
|
|
|
148
163
|
{ keys: "ctrl+p / ctrl+o", description: "open / cycle projects" },
|
|
149
164
|
{ keys: "?", description: "this help" },
|
|
150
165
|
{ keys: "R", description: "refresh everything" },
|
|
166
|
+
{ keys: "V", description: "verify the project (every verify step)" },
|
|
151
167
|
{ keys: "q / ctrl+c", description: "quit" },
|
|
152
168
|
],
|
|
153
169
|
},
|
|
@@ -162,6 +178,11 @@ export const HELP_SECTIONS: HelpSection[] = [
|
|
|
162
178
|
{ keys: "services[]", description: "extra ports to watch: id, name, port, healthPath" },
|
|
163
179
|
{ keys: "projects[]", description: "extra paths offered by the project switcher" },
|
|
164
180
|
{ keys: "refresh", description: "{ system, services, git, clock } intervals in ms" },
|
|
181
|
+
{ keys: "verify[]", description: "command ids, script names or { id, command }: devcc verify" },
|
|
182
|
+
{ keys: "agents", description: "{ claude: { command, args }, codex: {...} } overrides" },
|
|
183
|
+
{ keys: "editor / terminal", description: "{ command } or { application } for open actions" },
|
|
184
|
+
{ keys: "remoteMachines[]", description: "id, name, host (an SSH alias works), services" },
|
|
185
|
+
{ keys: "services[] ollama", description: '{ id, type: "ollama", baseUrl }: remote models' },
|
|
165
186
|
],
|
|
166
187
|
},
|
|
167
188
|
]
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { loadConfig } from "./core/config.js"
|
|
2
|
+
import { EnvironmentService, type EnvironmentSnapshot } from "./environment/EnvironmentService.js"
|
|
3
|
+
import { GitService } from "./git/GitService.js"
|
|
4
|
+
import { worktreeLabel } from "./git/worktrees.js"
|
|
5
|
+
import { errorMessage } from "./utils/errors.js"
|
|
6
|
+
import { createLogger } from "./utils/logger.js"
|
|
7
|
+
import { detectPackageManager, readPackageJson, readScripts } from "./utils/packageManager.js"
|
|
8
|
+
import { tildify } from "./utils/paths.js"
|
|
9
|
+
import {
|
|
10
|
+
createSpawnExecutor,
|
|
11
|
+
isVerifyIssue,
|
|
12
|
+
resolveVerifySteps,
|
|
13
|
+
runVerification,
|
|
14
|
+
summariseVerify,
|
|
15
|
+
type StepExecutor,
|
|
16
|
+
type VerifyReport,
|
|
17
|
+
} from "./verify/verify.js"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Non-interactive commands for scripts and AI agents: `devcc verify`,
|
|
21
|
+
* `devcc worktree list`, `devcc env`. None of them load the TUI, so they run on
|
|
22
|
+
* plain Node without FFI. With `--json`, stdout carries exactly one JSON
|
|
23
|
+
* document and nothing else; diagnostics go to stderr.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface AutomationIo {
|
|
27
|
+
stdout: (text: string) => void
|
|
28
|
+
stderr: (text: string) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AutomationOptions {
|
|
32
|
+
rootDir: string
|
|
33
|
+
json: boolean
|
|
34
|
+
io?: AutomationIo
|
|
35
|
+
/** Injected for tests; defaults to spawning each step. */
|
|
36
|
+
executor?: StepExecutor
|
|
37
|
+
signal?: AbortSignal
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const EXIT_OK = 0
|
|
41
|
+
export const EXIT_FAILED = 1
|
|
42
|
+
/** Misconfiguration or misuse: nothing was verified. */
|
|
43
|
+
export const EXIT_USAGE = 2
|
|
44
|
+
|
|
45
|
+
const processIo: AutomationIo = {
|
|
46
|
+
stdout: (text) => process.stdout.write(text),
|
|
47
|
+
stderr: (text) => process.stderr.write(text),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function printJson(io: AutomationIo, value: unknown): void {
|
|
51
|
+
io.stdout(`${JSON.stringify(value, null, 2)}\n`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// devcc verify
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
export async function verifyCommand(options: AutomationOptions): Promise<number> {
|
|
59
|
+
const io = options.io ?? processIo
|
|
60
|
+
const { rootDir, json } = options
|
|
61
|
+
|
|
62
|
+
const packageJson = readPackageJson(rootDir)
|
|
63
|
+
const packageManager = detectPackageManager({ rootDir, packageJson, userAgent: undefined }).manager
|
|
64
|
+
const loaded = await loadConfig(rootDir)
|
|
65
|
+
const { steps, issues } = resolveVerifySteps(loaded.config.verify, {
|
|
66
|
+
commands: loaded.config.commands,
|
|
67
|
+
scripts: readScripts(packageJson),
|
|
68
|
+
packageManager,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// A dropped or unresolvable verify entry must fail the gate: silently running
|
|
72
|
+
// fewer checks than configured would let a broken project pass.
|
|
73
|
+
const blocking = [
|
|
74
|
+
...loaded.issues.filter((issue) => issue.startsWith("Failed to load") || isVerifyIssue(issue)),
|
|
75
|
+
...issues,
|
|
76
|
+
]
|
|
77
|
+
if (steps.length === 0 && blocking.length === 0) {
|
|
78
|
+
blocking.push(
|
|
79
|
+
loaded.filePath
|
|
80
|
+
? `No verification steps: add verify: ["lint", "test"] to ${tildify(loaded.filePath)}`
|
|
81
|
+
: 'No verification steps: create .devcc.ts with verify: ["lint", "test"]',
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
if (blocking.length > 0) {
|
|
85
|
+
if (json) printJson(io, { ok: false, error: "configuration", issues: blocking })
|
|
86
|
+
else for (const issue of blocking) io.stderr(`devcc verify: ${issue}\n`)
|
|
87
|
+
return EXIT_USAGE
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!json) io.stdout(`devcc verify · ${tildify(rootDir)} · ${steps.length} step(s)\n\n`)
|
|
91
|
+
|
|
92
|
+
const executor = options.executor ?? createSpawnExecutor({ rootDir })
|
|
93
|
+
let printed = 0
|
|
94
|
+
const report = await runVerification(steps, executor, {
|
|
95
|
+
signal: options.signal,
|
|
96
|
+
onProgress: (progress) => {
|
|
97
|
+
if (json) return
|
|
98
|
+
for (; printed < progress.length; printed += 1) {
|
|
99
|
+
const step = progress[printed]
|
|
100
|
+
if (!step || step.status === "pending") break
|
|
101
|
+
if (step.status === "running") {
|
|
102
|
+
io.stdout(`→ ${step.name}: ${[step.command, ...step.args].join(" ")}\n`)
|
|
103
|
+
break
|
|
104
|
+
}
|
|
105
|
+
io.stdout(stepLine(step))
|
|
106
|
+
if (step.status === "failed") io.stdout(failureOutput(step))
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
if (json) {
|
|
112
|
+
printJson(io, { ...report, rootDir, summary: summariseVerify(report) })
|
|
113
|
+
} else {
|
|
114
|
+
for (const step of report.steps.slice(printed)) if (step.status === "skipped") io.stdout(stepLine(step))
|
|
115
|
+
io.stdout(`\n${report.ok ? "✓" : "✗"} ${summariseVerify(report)} in ${seconds(report.durationMs)}\n`)
|
|
116
|
+
}
|
|
117
|
+
return report.ok ? EXIT_OK : EXIT_FAILED
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function stepLine(step: VerifyReport["steps"][number]): string {
|
|
121
|
+
const mark = step.status === "passed" ? "✓" : step.status === "skipped" ? "-" : "✗"
|
|
122
|
+
const detail =
|
|
123
|
+
step.status === "skipped"
|
|
124
|
+
? "skipped"
|
|
125
|
+
: [
|
|
126
|
+
seconds(step.durationMs),
|
|
127
|
+
step.exitCode !== null && step.exitCode !== 0 ? `exit ${step.exitCode}` : undefined,
|
|
128
|
+
step.error,
|
|
129
|
+
]
|
|
130
|
+
.filter(Boolean)
|
|
131
|
+
.join(" · ")
|
|
132
|
+
return `${mark} ${step.name} (${detail})\n`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The tail of a failed step's output, indented under it. */
|
|
136
|
+
function failureOutput(step: VerifyReport["steps"][number], maxLines = 60): string {
|
|
137
|
+
const text = [step.stdout, step.stderr]
|
|
138
|
+
.filter((part) => part.trim() !== "")
|
|
139
|
+
.join("\n")
|
|
140
|
+
.trimEnd()
|
|
141
|
+
if (text === "") return ""
|
|
142
|
+
const lines = text.split("\n")
|
|
143
|
+
const shown = lines.slice(-maxLines)
|
|
144
|
+
const header = lines.length > maxLines ? [`… ${lines.length - maxLines} earlier line(s) omitted`] : []
|
|
145
|
+
return `${[...header, ...shown].map((line) => ` ${line}`).join("\n")}\n`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function seconds(ms: number): string {
|
|
149
|
+
return `${(ms / 1000).toFixed(1)}s`
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// devcc worktree list
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
export async function worktreeListCommand(options: AutomationOptions): Promise<number> {
|
|
157
|
+
const io = options.io ?? processIo
|
|
158
|
+
const git = new GitService({ rootDir: options.rootDir, logger: createLogger(options.rootDir).child("git") })
|
|
159
|
+
try {
|
|
160
|
+
const worktrees = await git.listWorktrees()
|
|
161
|
+
if (options.json) {
|
|
162
|
+
printJson(io, { rootDir: options.rootDir, worktrees })
|
|
163
|
+
return EXIT_OK
|
|
164
|
+
}
|
|
165
|
+
const width = Math.max(...worktrees.map((worktree) => worktreeLabel(worktree).length), 6)
|
|
166
|
+
for (const worktree of worktrees) {
|
|
167
|
+
const flags = [
|
|
168
|
+
worktree.isMain ? "main" : undefined,
|
|
169
|
+
worktree.locked ? "locked" : undefined,
|
|
170
|
+
worktree.prunable ? "prunable" : undefined,
|
|
171
|
+
].filter(Boolean)
|
|
172
|
+
io.stdout(
|
|
173
|
+
`${worktree.isCurrent ? "*" : " "} ${worktreeLabel(worktree).padEnd(width)} ${tildify(worktree.path)}${
|
|
174
|
+
flags.length > 0 ? ` [${flags.join(", ")}]` : ""
|
|
175
|
+
}\n`,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
return EXIT_OK
|
|
179
|
+
} catch (error) {
|
|
180
|
+
const message = errorMessage(error)
|
|
181
|
+
if (options.json) printJson(io, { error: message, rootDir: options.rootDir, worktrees: [] })
|
|
182
|
+
else io.stderr(`devcc worktree list: ${message}\n`)
|
|
183
|
+
return EXIT_FAILED
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// devcc env
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
export async function envCommand(
|
|
192
|
+
options: AutomationOptions & { environment?: EnvironmentService },
|
|
193
|
+
): Promise<number> {
|
|
194
|
+
const io = options.io ?? processIo
|
|
195
|
+
const environment = options.environment ?? new EnvironmentService({ rootDir: options.rootDir })
|
|
196
|
+
const snapshot = await environment.refresh()
|
|
197
|
+
if (options.json) {
|
|
198
|
+
const { checking: _checking, ...rest } = snapshot
|
|
199
|
+
printJson(io, { rootDir: options.rootDir, ...rest })
|
|
200
|
+
} else {
|
|
201
|
+
io.stdout(formatEnvironment(snapshot))
|
|
202
|
+
}
|
|
203
|
+
return EXIT_OK
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function formatEnvironment(snapshot: EnvironmentSnapshot): string {
|
|
207
|
+
const lines: string[] = ["TOOLS"]
|
|
208
|
+
const nameWidth = Math.max(...snapshot.tools.map((tool) => tool.name.length), 4) + 2
|
|
209
|
+
for (const tool of snapshot.tools) {
|
|
210
|
+
const mark = !tool.installed ? "·" : tool.status === "ready" ? "✓" : tool.status === "warning" ? "⚠" : "✗"
|
|
211
|
+
const value = tool.installed ? (tool.version ?? "installed") : "not installed"
|
|
212
|
+
const note = tool.installed && tool.message ? ` ${tool.message}` : ""
|
|
213
|
+
lines.push(` ${mark} ${tool.name.padEnd(nameWidth)}${value}${note}`)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (snapshot.mise.installed) {
|
|
217
|
+
lines.push("", "RUNTIMES (mise)")
|
|
218
|
+
if (snapshot.mise.error) lines.push(` ⚠ ${snapshot.mise.error}`)
|
|
219
|
+
else if (snapshot.mise.runtimes.length === 0) lines.push(" no tools pinned for this project")
|
|
220
|
+
for (const runtime of snapshot.mise.runtimes) {
|
|
221
|
+
lines.push(
|
|
222
|
+
` ${runtime.tool.padEnd(nameWidth + 2)}${runtime.version}${runtime.installed ? "" : " (not installed)"}`,
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const { direnv } = snapshot
|
|
228
|
+
if (direnv.installed || direnv.rcPath) {
|
|
229
|
+
lines.push("", "DIRENV")
|
|
230
|
+
if (direnv.error) lines.push(` ⚠ ${direnv.error}`)
|
|
231
|
+
if (direnv.rcPath) {
|
|
232
|
+
lines.push(` .envrc ${tildify(direnv.rcPath)}`)
|
|
233
|
+
if (direnv.installed) {
|
|
234
|
+
lines.push(` approval ${direnv.approval}`)
|
|
235
|
+
lines.push(` loaded ${direnv.loaded ? "yes" : "no"}`)
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
lines.push(" no .envrc for this project")
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return `${lines.join("\n")}\n`
|
|
242
|
+
}
|
package/src/devcc/cli.ts
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* bypassed it.
|
|
9
9
|
*/
|
|
10
10
|
import { createRequire } from "node:module"
|
|
11
|
+
import { resolve } from "node:path"
|
|
11
12
|
import { pathToFileURL } from "node:url"
|
|
12
|
-
import {
|
|
13
|
+
import { envCommand, EXIT_USAGE, verifyCommand, worktreeListCommand } from "./automation.js"
|
|
13
14
|
import { errorMessage } from "./utils/errors.js"
|
|
14
15
|
import { findProjectRoot } from "./utils/paths.js"
|
|
15
16
|
|
|
@@ -17,6 +18,9 @@ interface CliArgs {
|
|
|
17
18
|
cwd?: string
|
|
18
19
|
help: boolean
|
|
19
20
|
version: boolean
|
|
21
|
+
json: boolean
|
|
22
|
+
/** Subcommand and its arguments, e.g. `["worktree", "list"]`. */
|
|
23
|
+
positionals: string[]
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
/**
|
|
@@ -37,11 +41,12 @@ function readVersion(): string {
|
|
|
37
41
|
const VERSION = readVersion()
|
|
38
42
|
|
|
39
43
|
function parseArgs(argv: readonly string[]): CliArgs {
|
|
40
|
-
const args: CliArgs = { help: false, version: false }
|
|
44
|
+
const args: CliArgs = { help: false, version: false, json: false, positionals: [] }
|
|
41
45
|
for (let index = 0; index < argv.length; index += 1) {
|
|
42
46
|
const arg = argv[index]
|
|
43
47
|
if (arg === "--help" || arg === "-h") args.help = true
|
|
44
48
|
else if (arg === "--version" || arg === "-v") args.version = true
|
|
49
|
+
else if (arg === "--json") args.json = true
|
|
45
50
|
else if (arg === "--cwd" || arg === "-C") {
|
|
46
51
|
const value = argv[index + 1]
|
|
47
52
|
if (value) {
|
|
@@ -50,6 +55,8 @@ function parseArgs(argv: readonly string[]): CliArgs {
|
|
|
50
55
|
}
|
|
51
56
|
} else if (arg?.startsWith("--cwd=")) {
|
|
52
57
|
args.cwd = arg.slice("--cwd=".length)
|
|
58
|
+
} else if (arg !== undefined && !arg.startsWith("-")) {
|
|
59
|
+
args.positionals.push(arg)
|
|
53
60
|
}
|
|
54
61
|
}
|
|
55
62
|
return args
|
|
@@ -58,13 +65,20 @@ function parseArgs(argv: readonly string[]): CliArgs {
|
|
|
58
65
|
const USAGE = `dev command center ${VERSION}
|
|
59
66
|
|
|
60
67
|
Usage:
|
|
61
|
-
devcc [options]
|
|
68
|
+
devcc [options] Open the dashboard
|
|
69
|
+
devcc verify [--json] Run the project's verify steps; exit 0 only if all pass
|
|
70
|
+
devcc worktree list [--json] List git worktrees
|
|
71
|
+
devcc env [--json] Report tools, mise runtimes and direnv status
|
|
62
72
|
|
|
63
73
|
Options:
|
|
64
74
|
-C, --cwd <dir> Project directory (default: nearest project root)
|
|
75
|
+
--json Machine-readable output (verify, worktree list, env)
|
|
65
76
|
-h, --help Show this help
|
|
66
77
|
-v, --version Show the version
|
|
67
78
|
|
|
79
|
+
Exit codes (verify):
|
|
80
|
+
0 every step passed 1 a step failed 2 nothing to verify / bad config
|
|
81
|
+
|
|
68
82
|
Environment:
|
|
69
83
|
DEVCC_LOG_LEVEL debug | info | warn | error | silent (default: info)
|
|
70
84
|
|
|
@@ -84,11 +98,24 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
|
|
|
84
98
|
return 0
|
|
85
99
|
}
|
|
86
100
|
|
|
101
|
+
const [command, ...rest] = args.positionals
|
|
102
|
+
if (command !== undefined) {
|
|
103
|
+
const rootDir = args.cwd ? resolve(args.cwd) : findProjectRoot()
|
|
104
|
+
const options = { rootDir, json: args.json }
|
|
105
|
+
if (command === "verify" && rest.length === 0) return runVerifyCli(options)
|
|
106
|
+
if (command === "worktree" && rest.length === 1 && rest[0] === "list") return worktreeListCommand(options)
|
|
107
|
+
if (command === "env" && rest.length === 0) return envCommand(options)
|
|
108
|
+
process.stderr.write(`devcc: unknown command "${args.positionals.join(" ")}"\n\n${USAGE}`)
|
|
109
|
+
return EXIT_USAGE
|
|
110
|
+
}
|
|
111
|
+
|
|
87
112
|
if (!process.stdout.isTTY) {
|
|
88
113
|
process.stderr.write("devcc needs an interactive terminal (stdout is not a TTY).\n")
|
|
89
114
|
return 1
|
|
90
115
|
}
|
|
91
116
|
|
|
117
|
+
// Loaded lazily: OpenTUI needs FFI, which the automation commands above do not.
|
|
118
|
+
const { App } = await import("./app/App.js")
|
|
92
119
|
const app = new App({ cwd: args.cwd ? args.cwd : findProjectRoot() })
|
|
93
120
|
|
|
94
121
|
try {
|
|
@@ -107,6 +134,21 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
|
|
|
107
134
|
}
|
|
108
135
|
}
|
|
109
136
|
|
|
137
|
+
/** Ctrl+C stops the running step instead of leaving it orphaned. */
|
|
138
|
+
async function runVerifyCli(options: { rootDir: string; json: boolean }): Promise<number> {
|
|
139
|
+
const controller = new AbortController()
|
|
140
|
+
const onSignal = () => controller.abort()
|
|
141
|
+
process.once("SIGINT", onSignal)
|
|
142
|
+
process.once("SIGTERM", onSignal)
|
|
143
|
+
try {
|
|
144
|
+
const code = await verifyCommand({ ...options, signal: controller.signal })
|
|
145
|
+
return controller.signal.aborted ? 130 : code
|
|
146
|
+
} finally {
|
|
147
|
+
process.off("SIGINT", onSignal)
|
|
148
|
+
process.off("SIGTERM", onSignal)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
110
152
|
const entry = process.argv[1]
|
|
111
153
|
const isDirectRun = entry !== undefined && import.meta.url === pathToFileURL(entry).href
|
|
112
154
|
|
|
@@ -16,6 +16,7 @@ export const SIDEBAR_ITEMS: readonly SidebarItem[] = [
|
|
|
16
16
|
{ id: "services", label: "Services" },
|
|
17
17
|
{ id: "scripts", label: "Scripts" },
|
|
18
18
|
{ id: "git", label: "Git" },
|
|
19
|
+
{ id: "worktrees", label: "Worktrees" },
|
|
19
20
|
{ id: "logs", label: "Logs" },
|
|
20
21
|
{ id: "dependencies", label: "Dependencies" },
|
|
21
22
|
{ id: "system", label: "System" },
|
|
@@ -98,6 +98,9 @@ export class RowList {
|
|
|
98
98
|
})
|
|
99
99
|
this.box.onSizeChange = () => {
|
|
100
100
|
const visible = this.visibleCount
|
|
101
|
+
// The first setRows can happen before layout, when the viewport is one
|
|
102
|
+
// row tall; re-derive the scroll position now that the real height is known.
|
|
103
|
+
this.ensureVisible()
|
|
101
104
|
this.paint()
|
|
102
105
|
if (visible !== this.lastVisibleCount) {
|
|
103
106
|
this.lastVisibleCount = visible
|