@doriandev/devcc 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +589 -389
- package/bin/devcc.mjs +50 -23
- package/dist/devcc/agents/agents.d.ts +119 -0
- package/dist/devcc/agents/agents.d.ts.map +1 -0
- package/dist/devcc/agents/agents.js +182 -0
- package/dist/devcc/agents/agents.js.map +1 -0
- package/dist/devcc/app/App.d.ts +23 -0
- package/dist/devcc/app/App.d.ts.map +1 -1
- package/dist/devcc/app/App.js +451 -19
- package/dist/devcc/app/App.js.map +1 -1
- package/dist/devcc/app/controlPlaneCommands.d.ts +14 -0
- package/dist/devcc/app/controlPlaneCommands.d.ts.map +1 -0
- package/dist/devcc/app/controlPlaneCommands.js +204 -0
- package/dist/devcc/app/controlPlaneCommands.js.map +1 -0
- package/dist/devcc/app/keymap.d.ts.map +1 -1
- package/dist/devcc/app/keymap.js +25 -3
- package/dist/devcc/app/keymap.js.map +1 -1
- package/dist/devcc/automation.d.ts +52 -0
- package/dist/devcc/automation.d.ts.map +1 -0
- package/dist/devcc/automation.js +339 -0
- package/dist/devcc/automation.js.map +1 -0
- package/dist/devcc/cli.d.ts.map +1 -1
- package/dist/devcc/cli.js +73 -3
- package/dist/devcc/cli.js.map +1 -1
- package/dist/devcc/components/Sidebar.d.ts.map +1 -1
- package/dist/devcc/components/Sidebar.js +1 -0
- package/dist/devcc/components/Sidebar.js.map +1 -1
- package/dist/devcc/components/primitives.d.ts.map +1 -1
- package/dist/devcc/components/primitives.js +3 -0
- package/dist/devcc/components/primitives.js.map +1 -1
- package/dist/devcc/core/commands.d.ts +33 -0
- package/dist/devcc/core/commands.d.ts.map +1 -1
- package/dist/devcc/core/commands.js.map +1 -1
- package/dist/devcc/core/config.d.ts +71 -3
- package/dist/devcc/core/config.d.ts.map +1 -1
- package/dist/devcc/core/config.js +189 -5
- package/dist/devcc/core/config.js.map +1 -1
- package/dist/devcc/core/state.d.ts +19 -1
- package/dist/devcc/core/state.d.ts.map +1 -1
- package/dist/devcc/core/state.js +6 -0
- package/dist/devcc/core/state.js.map +1 -1
- package/dist/devcc/environment/EnvironmentService.d.ts +58 -0
- package/dist/devcc/environment/EnvironmentService.d.ts.map +1 -0
- package/dist/devcc/environment/EnvironmentService.js +159 -0
- package/dist/devcc/environment/EnvironmentService.js.map +1 -0
- package/dist/devcc/environment/integrations.d.ts +52 -0
- package/dist/devcc/environment/integrations.d.ts.map +1 -0
- package/dist/devcc/environment/integrations.js +94 -0
- package/dist/devcc/environment/integrations.js.map +1 -0
- package/dist/devcc/environment/tools.d.ts +51 -0
- package/dist/devcc/environment/tools.d.ts.map +1 -0
- package/dist/devcc/environment/tools.js +99 -0
- package/dist/devcc/environment/tools.js.map +1 -0
- package/dist/devcc/git/GitService.d.ts +26 -0
- package/dist/devcc/git/GitService.d.ts.map +1 -1
- package/dist/devcc/git/GitService.js +71 -2
- package/dist/devcc/git/GitService.js.map +1 -1
- package/dist/devcc/git/worktrees.d.ts +88 -0
- package/dist/devcc/git/worktrees.d.ts.map +1 -0
- package/dist/devcc/git/worktrees.js +226 -0
- package/dist/devcc/git/worktrees.js.map +1 -0
- package/dist/devcc/index.d.ts +15 -3
- package/dist/devcc/index.d.ts.map +1 -1
- package/dist/devcc/index.js +14 -2
- package/dist/devcc/index.js.map +1 -1
- package/dist/devcc/init/detect.d.ts +55 -0
- package/dist/devcc/init/detect.d.ts.map +1 -0
- package/dist/devcc/init/detect.js +207 -0
- package/dist/devcc/init/detect.js.map +1 -0
- package/dist/devcc/init/prompt.d.ts +25 -0
- package/dist/devcc/init/prompt.d.ts.map +1 -0
- package/dist/devcc/init/prompt.js +156 -0
- package/dist/devcc/init/prompt.js.map +1 -0
- package/dist/devcc/platform/launch.d.ts +30 -0
- package/dist/devcc/platform/launch.d.ts.map +1 -0
- package/dist/devcc/platform/launch.js +93 -0
- package/dist/devcc/platform/launch.js.map +1 -0
- package/dist/devcc/services/ServiceManager.d.ts +10 -2
- package/dist/devcc/services/ServiceManager.d.ts.map +1 -1
- package/dist/devcc/services/ServiceManager.js +18 -4
- package/dist/devcc/services/ServiceManager.js.map +1 -1
- package/dist/devcc/services/node.d.ts +11 -0
- package/dist/devcc/services/node.d.ts.map +1 -1
- package/dist/devcc/services/node.js +20 -18
- package/dist/devcc/services/node.js.map +1 -1
- package/dist/devcc/services/ollama.d.ts +64 -5
- package/dist/devcc/services/ollama.d.ts.map +1 -1
- package/dist/devcc/services/ollama.js +118 -17
- package/dist/devcc/services/ollama.js.map +1 -1
- package/dist/devcc/services/remote.d.ts +90 -0
- package/dist/devcc/services/remote.d.ts.map +1 -0
- package/dist/devcc/services/remote.js +233 -0
- package/dist/devcc/services/remote.js.map +1 -0
- package/dist/devcc/services/types.d.ts +8 -1
- package/dist/devcc/services/types.d.ts.map +1 -1
- package/dist/devcc/services/types.js.map +1 -1
- package/dist/devcc/utils/exec.d.ts +6 -0
- package/dist/devcc/utils/exec.d.ts.map +1 -1
- package/dist/devcc/utils/exec.js +12 -2
- package/dist/devcc/utils/exec.js.map +1 -1
- package/dist/devcc/verify/verify.d.ts +107 -0
- package/dist/devcc/verify/verify.d.ts.map +1 -0
- package/dist/devcc/verify/verify.js +203 -0
- package/dist/devcc/verify/verify.js.map +1 -0
- package/dist/devcc/views/OverviewView.d.ts.map +1 -1
- package/dist/devcc/views/OverviewView.js +58 -2
- package/dist/devcc/views/OverviewView.js.map +1 -1
- package/dist/devcc/views/ServicesView.d.ts +2 -0
- package/dist/devcc/views/ServicesView.d.ts.map +1 -1
- package/dist/devcc/views/ServicesView.js +15 -0
- package/dist/devcc/views/ServicesView.js.map +1 -1
- package/dist/devcc/views/SystemView.d.ts +10 -1
- package/dist/devcc/views/SystemView.d.ts.map +1 -1
- package/dist/devcc/views/SystemView.js +127 -19
- package/dist/devcc/views/SystemView.js.map +1 -1
- package/dist/devcc/views/WorktreesView.d.ts +48 -0
- package/dist/devcc/views/WorktreesView.d.ts.map +1 -0
- package/dist/devcc/views/WorktreesView.js +235 -0
- package/dist/devcc/views/WorktreesView.js.map +1 -0
- package/package.json +1 -1
- package/src/devcc/agents/agents.ts +292 -0
- package/src/devcc/app/App.ts +529 -22
- package/src/devcc/app/controlPlaneCommands.ts +227 -0
- package/src/devcc/app/keymap.ts +25 -3
- package/src/devcc/automation.ts +409 -0
- package/src/devcc/cli.ts +77 -3
- package/src/devcc/components/Sidebar.ts +1 -0
- package/src/devcc/components/primitives.ts +3 -0
- package/src/devcc/core/commands.ts +34 -0
- package/src/devcc/core/config.ts +275 -11
- package/src/devcc/core/state.ts +25 -0
- package/src/devcc/environment/EnvironmentService.ts +215 -0
- package/src/devcc/environment/integrations.ts +133 -0
- package/src/devcc/environment/tools.ts +151 -0
- package/src/devcc/git/GitService.ts +104 -2
- package/src/devcc/git/worktrees.ts +256 -0
- package/src/devcc/index.ts +66 -2
- package/src/devcc/init/detect.ts +268 -0
- package/src/devcc/init/prompt.ts +179 -0
- package/src/devcc/platform/launch.ts +125 -0
- package/src/devcc/services/ServiceManager.ts +15 -3
- package/src/devcc/services/node.ts +17 -14
- package/src/devcc/services/ollama.ts +162 -19
- package/src/devcc/services/remote.ts +306 -0
- package/src/devcc/services/types.ts +9 -1
- package/src/devcc/utils/exec.ts +26 -2
- package/src/devcc/verify/verify.ts +316 -0
- package/src/devcc/views/OverviewView.ts +84 -2
- package/src/devcc/views/ServicesView.ts +17 -0
- package/src/devcc/views/SystemView.ts +144 -22
- package/src/devcc/views/WorktreesView.ts +270 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { basename, isAbsolute, relative, resolve } from "node:path"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure helpers for `git worktree`.
|
|
5
|
+
*
|
|
6
|
+
* Parsing, argument construction and validation live here so they can be
|
|
7
|
+
* tested without a repository; `GitService` only runs the commands.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface GitWorktree {
|
|
11
|
+
/** Absolute path of the worktree's top-level directory. */
|
|
12
|
+
path: string
|
|
13
|
+
/** Short branch name (`feature/x`), absent when detached or bare. */
|
|
14
|
+
branch?: string
|
|
15
|
+
/** Full commit hash of HEAD; empty for a bare repository. */
|
|
16
|
+
head: string
|
|
17
|
+
bare: boolean
|
|
18
|
+
detached: boolean
|
|
19
|
+
locked: boolean
|
|
20
|
+
lockedReason?: string
|
|
21
|
+
prunable: boolean
|
|
22
|
+
prunableReason?: string
|
|
23
|
+
/** The first entry git reports: the repository's main worktree. */
|
|
24
|
+
isMain: boolean
|
|
25
|
+
/** The worktree devcc is running in. */
|
|
26
|
+
isCurrent: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse `git worktree list --porcelain` (optionally with `-z`).
|
|
31
|
+
*
|
|
32
|
+
* Records are separated by a blank line (a double NUL with `-z`). Unknown keys
|
|
33
|
+
* are ignored and records without a `worktree` line are dropped, so output from
|
|
34
|
+
* a newer git with extra attributes still parses.
|
|
35
|
+
*
|
|
36
|
+
* Format reference: `man git-worktree`, "Porcelain Format".
|
|
37
|
+
*/
|
|
38
|
+
export function parseWorktreeList(output: string, currentPath?: string): GitWorktree[] {
|
|
39
|
+
const nulSeparated = output.includes("\0")
|
|
40
|
+
const lines = nulSeparated ? output.split("\0") : output.split("\n").map((line) => line.replace(/\r$/u, ""))
|
|
41
|
+
|
|
42
|
+
const worktrees: GitWorktree[] = []
|
|
43
|
+
let record: GitWorktree | null = null
|
|
44
|
+
|
|
45
|
+
const flush = () => {
|
|
46
|
+
if (record && record.path !== "") worktrees.push(record)
|
|
47
|
+
record = null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const line of lines) {
|
|
51
|
+
if (line === "") {
|
|
52
|
+
flush()
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const space = line.indexOf(" ")
|
|
57
|
+
const key = space === -1 ? line : line.slice(0, space)
|
|
58
|
+
const value = space === -1 ? "" : line.slice(space + 1)
|
|
59
|
+
|
|
60
|
+
if (key === "worktree") {
|
|
61
|
+
flush()
|
|
62
|
+
record = {
|
|
63
|
+
path: value,
|
|
64
|
+
head: "",
|
|
65
|
+
bare: false,
|
|
66
|
+
detached: false,
|
|
67
|
+
locked: false,
|
|
68
|
+
prunable: false,
|
|
69
|
+
isMain: worktrees.length === 0,
|
|
70
|
+
isCurrent: false,
|
|
71
|
+
}
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Attributes before any `worktree` line are malformed; skip them.
|
|
76
|
+
const current = record
|
|
77
|
+
if (!current) continue
|
|
78
|
+
|
|
79
|
+
switch (key) {
|
|
80
|
+
case "HEAD":
|
|
81
|
+
current.head = value
|
|
82
|
+
break
|
|
83
|
+
case "branch":
|
|
84
|
+
current.branch = value.replace(/^refs\/heads\//u, "")
|
|
85
|
+
break
|
|
86
|
+
case "bare":
|
|
87
|
+
current.bare = true
|
|
88
|
+
break
|
|
89
|
+
case "detached":
|
|
90
|
+
current.detached = true
|
|
91
|
+
break
|
|
92
|
+
case "locked":
|
|
93
|
+
current.locked = true
|
|
94
|
+
if (value) current.lockedReason = value
|
|
95
|
+
break
|
|
96
|
+
case "prunable":
|
|
97
|
+
current.prunable = true
|
|
98
|
+
if (value) current.prunableReason = value
|
|
99
|
+
break
|
|
100
|
+
default:
|
|
101
|
+
break
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
flush()
|
|
105
|
+
|
|
106
|
+
// The first record is the main worktree even if it followed malformed lines.
|
|
107
|
+
for (const [index, worktree] of worktrees.entries()) worktree.isMain = index === 0
|
|
108
|
+
|
|
109
|
+
return currentPath ? markCurrent(worktrees, currentPath) : worktrees
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Flag the worktree containing `currentPath`. The deepest match wins, so a
|
|
114
|
+
* worktree nested inside the main checkout is attributed correctly.
|
|
115
|
+
*/
|
|
116
|
+
export function markCurrent(worktrees: GitWorktree[], currentPath: string): GitWorktree[] {
|
|
117
|
+
let best: GitWorktree | undefined
|
|
118
|
+
for (const worktree of worktrees) {
|
|
119
|
+
if (worktree.bare || !isInside(currentPath, worktree.path)) continue
|
|
120
|
+
if (!best || worktree.path.length > best.path.length) best = worktree
|
|
121
|
+
}
|
|
122
|
+
return worktrees.map((worktree) => ({ ...worktree, isCurrent: worktree === best }))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** True when `path` is `parent` or somewhere below it. */
|
|
126
|
+
export function isInside(path: string, parent: string): boolean {
|
|
127
|
+
const rel = relative(resolve(parent), resolve(path))
|
|
128
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Display label: the branch, or a short hash when detached. */
|
|
132
|
+
export function worktreeLabel(worktree: GitWorktree): string {
|
|
133
|
+
if (worktree.bare) return "(bare)"
|
|
134
|
+
if (worktree.branch) return worktree.branch
|
|
135
|
+
return worktree.head ? `detached @ ${worktree.head.slice(0, 7)}` : "(unknown)"
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Validation
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
// eslint-disable-next-line no-control-regex
|
|
143
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/u
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Validate a branch name against git's ref rules (`man git-check-ref-format`),
|
|
147
|
+
* plus a leading-dash check so a name can never be read as an option.
|
|
148
|
+
* Returns a human-readable problem, or null when the name is acceptable.
|
|
149
|
+
*/
|
|
150
|
+
export function validateBranchName(name: string): string | null {
|
|
151
|
+
if (name.trim() === "") return "Branch name is empty"
|
|
152
|
+
if (name !== name.trim() || /\s/u.test(name)) return "Branch name cannot contain whitespace"
|
|
153
|
+
if (CONTROL_CHARS.test(name)) return "Branch name cannot contain control characters"
|
|
154
|
+
if (name.startsWith("-")) return "Branch name cannot start with '-'"
|
|
155
|
+
if (name === "@") return "Branch name cannot be '@'"
|
|
156
|
+
if (/[~^:?*[\\]/u.test(name)) return "Branch name cannot contain ~ ^ : ? * [ or \\"
|
|
157
|
+
if (name.includes("..")) return "Branch name cannot contain '..'"
|
|
158
|
+
if (name.includes("@{")) return "Branch name cannot contain '@{'"
|
|
159
|
+
if (name.includes("//")) return "Branch name cannot contain '//'"
|
|
160
|
+
if (name.startsWith("/") || name.endsWith("/")) return "Branch name cannot start or end with '/'"
|
|
161
|
+
if (name.endsWith(".")) return "Branch name cannot end with '.'"
|
|
162
|
+
for (const segment of name.split("/")) {
|
|
163
|
+
if (segment.startsWith(".")) return "Branch name segments cannot start with '.'"
|
|
164
|
+
if (segment.endsWith(".lock")) return "Branch name segments cannot end with '.lock'"
|
|
165
|
+
}
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A base ref is passed straight to git, so only rule out option-like or broken input. */
|
|
170
|
+
export function validateRef(ref: string): string | null {
|
|
171
|
+
if (ref.trim() === "") return "Base ref is empty"
|
|
172
|
+
if (ref.startsWith("-")) return "Base ref cannot start with '-'"
|
|
173
|
+
if (/\s/u.test(ref) || CONTROL_CHARS.test(ref)) return "Base ref cannot contain whitespace"
|
|
174
|
+
return null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Validate a destination directory for a new worktree. `exists` is injected
|
|
179
|
+
* so the check is testable; git itself refuses a non-empty existing directory,
|
|
180
|
+
* but refusing early gives a clearer message.
|
|
181
|
+
*/
|
|
182
|
+
export function validateWorktreePath(path: string, exists: (path: string) => boolean): string | null {
|
|
183
|
+
if (path.trim() === "") return "Worktree path is empty"
|
|
184
|
+
if (CONTROL_CHARS.test(path)) return "Worktree path cannot contain control characters"
|
|
185
|
+
if (!isAbsolute(path)) return "Worktree path must be absolute"
|
|
186
|
+
if (exists(path)) return `${path} already exists`
|
|
187
|
+
return null
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Directory-safe form of a branch name: `feature/ai-agents` -> `feature-ai-agents`. */
|
|
191
|
+
export function branchSlug(branch: string): string {
|
|
192
|
+
return (
|
|
193
|
+
branch
|
|
194
|
+
.replace(/[^A-Za-z0-9._-]+/gu, "-")
|
|
195
|
+
.replace(/-+/gu, "-")
|
|
196
|
+
.replace(/^[-.]+|[-.]+$/gu, "") || "worktree"
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Where a new worktree goes by default: `<directory>/<repo>-<branch>`, with
|
|
202
|
+
* `directory` relative to the main worktree (default: its parent).
|
|
203
|
+
*/
|
|
204
|
+
export function defaultWorktreePath(mainWorktreePath: string, branch: string, directory = ".."): string {
|
|
205
|
+
return resolve(mainWorktreePath, directory, `${basename(mainWorktreePath)}-${branchSlug(branch)}`)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Argument construction
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
export interface CreateWorktreeRequest {
|
|
213
|
+
/** Absolute destination directory. */
|
|
214
|
+
path: string
|
|
215
|
+
branch: string
|
|
216
|
+
/** Start point for a new branch. Ignored when checking out an existing branch. */
|
|
217
|
+
base?: string
|
|
218
|
+
/** True to create `branch` (`-b`); false to check out an existing one. */
|
|
219
|
+
createBranch: boolean
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Arguments for `git worktree add`. Everything user-provided comes after `--`
|
|
224
|
+
* or as the value of `-b`, and each value is its own argv entry, so nothing can
|
|
225
|
+
* be interpreted as an option or split by a shell.
|
|
226
|
+
*/
|
|
227
|
+
export function worktreeAddArgs(request: CreateWorktreeRequest): string[] {
|
|
228
|
+
if (request.createBranch) {
|
|
229
|
+
const args = ["worktree", "add", "-b", request.branch, "--", request.path]
|
|
230
|
+
if (request.base) args.push(request.base)
|
|
231
|
+
return args
|
|
232
|
+
}
|
|
233
|
+
return ["worktree", "add", "--", request.path, request.branch]
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Arguments for `git worktree remove`. Never `--force`: git's safety check stands. */
|
|
237
|
+
export function worktreeRemoveArgs(path: string): string[] {
|
|
238
|
+
return ["worktree", "remove", "--", path]
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Decide whether a worktree may be removed before asking git. `busy` lists
|
|
243
|
+
* directories where devcc is still running something (agents, processes).
|
|
244
|
+
* Returns the reason for refusing, or null when removal may proceed.
|
|
245
|
+
*/
|
|
246
|
+
export function removalBlocker(worktree: GitWorktree, busy: readonly string[] = []): string | null {
|
|
247
|
+
if (worktree.isMain) return "The main worktree cannot be removed"
|
|
248
|
+
if (worktree.isCurrent) return "devcc is running in this worktree; open another project first"
|
|
249
|
+
if (worktree.bare) return "A bare repository is not a removable worktree"
|
|
250
|
+
if (worktree.locked) {
|
|
251
|
+
return `Worktree is locked${worktree.lockedReason ? `: ${worktree.lockedReason}` : ""}; unlock it with git first`
|
|
252
|
+
}
|
|
253
|
+
const running = busy.find((dir) => isInside(dir, worktree.path))
|
|
254
|
+
if (running) return `Something devcc started is still running in ${worktree.path}; stop it first`
|
|
255
|
+
return null
|
|
256
|
+
}
|
package/src/devcc/index.ts
CHANGED
|
@@ -7,6 +7,25 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
export { App, type AppOptions } from "./app/App.js"
|
|
10
|
+
export { createControlPlaneCommands } from "./app/controlPlaneCommands.js"
|
|
11
|
+
export {
|
|
12
|
+
AgentManager,
|
|
13
|
+
BUILTIN_AGENTS,
|
|
14
|
+
createAgentAdapters,
|
|
15
|
+
type AgentAdapter,
|
|
16
|
+
type AgentConfig,
|
|
17
|
+
type AgentLaunchOptions,
|
|
18
|
+
type AgentSession,
|
|
19
|
+
} from "./agents/agents.js"
|
|
20
|
+
export {
|
|
21
|
+
configCheckCommand,
|
|
22
|
+
envCommand,
|
|
23
|
+
initCommand,
|
|
24
|
+
verifyCommand,
|
|
25
|
+
worktreeListCommand,
|
|
26
|
+
} from "./automation.js"
|
|
27
|
+
export { detectProject, renderStarterConfig, starterConfig, type ProjectFacts } from "./init/detect.js"
|
|
28
|
+
export { buildSetupPrompt, extractConfigFromOutput, type SetupPromptMode } from "./init/prompt.js"
|
|
10
29
|
export { createBuiltinCommands, createConfigCommands, createScriptCommands } from "./app/builtinCommands.js"
|
|
11
30
|
export { HELP_SECTIONS, VIEW_SHORTCUTS } from "./app/keymap.js"
|
|
12
31
|
export { theme, glyphs, processStatusStyle, serviceStateStyle } from "./app/theme.js"
|
|
@@ -17,12 +36,17 @@ export {
|
|
|
17
36
|
loadConfig,
|
|
18
37
|
validateConfig,
|
|
19
38
|
findConfigFile,
|
|
39
|
+
loadConfigFile,
|
|
20
40
|
CONFIG_FILENAMES,
|
|
21
41
|
DEFAULT_REFRESH,
|
|
22
42
|
type CommandConfigEntry,
|
|
23
43
|
type DevccConfig,
|
|
44
|
+
type EditorConfig,
|
|
45
|
+
type RemoteMachineConfig,
|
|
24
46
|
type ResolvedConfig,
|
|
25
47
|
type ServiceConfigEntry,
|
|
48
|
+
type TerminalConfig,
|
|
49
|
+
type WorktreesConfig,
|
|
26
50
|
} from "./core/config.js"
|
|
27
51
|
export { TypedEmitter, Subscriptions, type Unsubscribe } from "./core/events.js"
|
|
28
52
|
export {
|
|
@@ -48,7 +72,32 @@ export {
|
|
|
48
72
|
type UpdateSeverity,
|
|
49
73
|
} from "./deps/parse.js"
|
|
50
74
|
|
|
51
|
-
export {
|
|
75
|
+
export { EnvironmentService, type EnvironmentSnapshot } from "./environment/EnvironmentService.js"
|
|
76
|
+
export {
|
|
77
|
+
parseDirenvStatus,
|
|
78
|
+
parseMiseLs,
|
|
79
|
+
type DirenvStatus,
|
|
80
|
+
type MiseStatus,
|
|
81
|
+
type RuntimeVersion,
|
|
82
|
+
} from "./environment/integrations.js"
|
|
83
|
+
export {
|
|
84
|
+
DEFAULT_TOOLS,
|
|
85
|
+
detectTool,
|
|
86
|
+
findExecutable,
|
|
87
|
+
type ToolDefinition,
|
|
88
|
+
type ToolStatus,
|
|
89
|
+
} from "./environment/tools.js"
|
|
90
|
+
|
|
91
|
+
export { GitService, type CreateWorktreeInput, type GitSnapshot } from "./git/GitService.js"
|
|
92
|
+
export {
|
|
93
|
+
defaultWorktreePath,
|
|
94
|
+
parseWorktreeList,
|
|
95
|
+
removalBlocker,
|
|
96
|
+
validateBranchName,
|
|
97
|
+
worktreeAddArgs,
|
|
98
|
+
worktreeRemoveArgs,
|
|
99
|
+
type GitWorktree,
|
|
100
|
+
} from "./git/worktrees.js"
|
|
52
101
|
export {
|
|
53
102
|
parseCommits,
|
|
54
103
|
parsePorcelainV2,
|
|
@@ -96,7 +145,14 @@ export {
|
|
|
96
145
|
export { ServiceManager } from "./services/ServiceManager.js"
|
|
97
146
|
export { DockerService, findComposeFile, parseComposePs } from "./services/docker.js"
|
|
98
147
|
export { PortService, detectNodeServices } from "./services/node.js"
|
|
99
|
-
export { OllamaService } from "./services/ollama.js"
|
|
148
|
+
export { mergeOllamaModels, OllamaService, type OllamaModel } from "./services/ollama.js"
|
|
149
|
+
export {
|
|
150
|
+
parseTailscaleStatus,
|
|
151
|
+
RemoteMachineService,
|
|
152
|
+
TailscaleService,
|
|
153
|
+
type TailscaleStatus,
|
|
154
|
+
} from "./services/remote.js"
|
|
155
|
+
export { editorCommand, launchDetached, sshCommand, terminalCommand } from "./platform/launch.js"
|
|
100
156
|
export { PostgresService, parsePostgresUrl } from "./services/postgres.js"
|
|
101
157
|
export { RedisService, parseRedisUrl } from "./services/redis.js"
|
|
102
158
|
export type { ServiceAdapter, ServiceSnapshot, ServiceState, ServiceStatus } from "./services/types.js"
|
|
@@ -113,3 +169,11 @@ export {
|
|
|
113
169
|
type PackageManager,
|
|
114
170
|
} from "./utils/packageManager.js"
|
|
115
171
|
export { findProjectRoot } from "./utils/paths.js"
|
|
172
|
+
export {
|
|
173
|
+
createSpawnExecutor,
|
|
174
|
+
resolveVerifySteps,
|
|
175
|
+
runVerification,
|
|
176
|
+
type VerifyEntry,
|
|
177
|
+
type VerifyReport,
|
|
178
|
+
type VerifyStep,
|
|
179
|
+
} from "./verify/verify.js"
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { basename, join } from "node:path"
|
|
3
|
+
import { findConfigFile } from "../core/config.js"
|
|
4
|
+
import type { ProcessDefinition } from "../processes/ManagedProcess.js"
|
|
5
|
+
import { detectFrameworks } from "../services/node.js"
|
|
6
|
+
import {
|
|
7
|
+
detectPackageManager,
|
|
8
|
+
readPackageJson,
|
|
9
|
+
readScripts,
|
|
10
|
+
runScriptArgs,
|
|
11
|
+
type PackageManager,
|
|
12
|
+
type PackageManagerDetection,
|
|
13
|
+
type PackageScript,
|
|
14
|
+
} from "../utils/packageManager.js"
|
|
15
|
+
import type { VerifyEntry } from "../verify/verify.js"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Project detection for `devcc init` and the agent setup prompt.
|
|
19
|
+
*
|
|
20
|
+
* Only facts that can be read from the repository are collected - nothing is
|
|
21
|
+
* executed and nothing is guessed. The starter config built from them is
|
|
22
|
+
* deliberately conservative: a script is only used when it exists.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface ProjectFacts {
|
|
26
|
+
rootDir: string
|
|
27
|
+
name: string
|
|
28
|
+
/** Existing config file, if any. */
|
|
29
|
+
configPath: string | null
|
|
30
|
+
hasGit: boolean
|
|
31
|
+
/** Null when there is no package.json. */
|
|
32
|
+
packageManager: PackageManagerDetection | null
|
|
33
|
+
scripts: PackageScript[]
|
|
34
|
+
frameworks: { name: string; dependency: string; port: number }[]
|
|
35
|
+
/** Script chosen to run the app in development. */
|
|
36
|
+
devScript?: string
|
|
37
|
+
/** Scripts suitable for `verify`, in the order they should run. */
|
|
38
|
+
verifyScripts: string[]
|
|
39
|
+
/** Scripts that look like verification but were left out, with the reason. */
|
|
40
|
+
skippedScripts: { name: string; reason: string }[]
|
|
41
|
+
monorepo: string | null
|
|
42
|
+
languages: ("node" | "rust" | "go" | "python")[]
|
|
43
|
+
/** Notable files present at the root (compose file, .envrc, mise.toml, Makefile, ...). */
|
|
44
|
+
files: string[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type Exists = (path: string) => boolean
|
|
48
|
+
|
|
49
|
+
const DEV_SCRIPTS = ["dev", "start:dev", "develop", "serve"] as const
|
|
50
|
+
|
|
51
|
+
/** Verify categories in run order; the first matching script name in each wins. */
|
|
52
|
+
const VERIFY_CATEGORIES: readonly (readonly string[])[] = [
|
|
53
|
+
["format:check", "format-check", "fmt:check", "prettier:check", "check:format", "lint:format"],
|
|
54
|
+
["lint"],
|
|
55
|
+
["typecheck", "type-check", "check-types", "types:check", "tsc"],
|
|
56
|
+
["test:ci", "test:run", "test:unit", "test"],
|
|
57
|
+
["build"],
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
/** Aggregate scripts used only when no individual checks exist. */
|
|
61
|
+
const AGGREGATE_SCRIPTS = ["verify", "ci", "check", "validate"] as const
|
|
62
|
+
|
|
63
|
+
const NOTABLE_FILES = [
|
|
64
|
+
"compose.yaml",
|
|
65
|
+
"compose.yml",
|
|
66
|
+
"docker-compose.yml",
|
|
67
|
+
"docker-compose.yaml",
|
|
68
|
+
".envrc",
|
|
69
|
+
"mise.toml",
|
|
70
|
+
".mise.toml",
|
|
71
|
+
".tool-versions",
|
|
72
|
+
".env.example",
|
|
73
|
+
"Makefile",
|
|
74
|
+
"turbo.json",
|
|
75
|
+
"nx.json",
|
|
76
|
+
"pnpm-workspace.yaml",
|
|
77
|
+
"Cargo.toml",
|
|
78
|
+
"go.mod",
|
|
79
|
+
"pyproject.toml",
|
|
80
|
+
"AGENTS.md",
|
|
81
|
+
"CLAUDE.md",
|
|
82
|
+
] as const
|
|
83
|
+
|
|
84
|
+
/** Why a script cannot be a verify step, or null when it can. */
|
|
85
|
+
export function unsuitableForVerify(command: string): string | null {
|
|
86
|
+
if (/--watch\b|\bwatch\b/u.test(command)) return "runs in watch mode"
|
|
87
|
+
if (/no test specified/u.test(command)) return "is npm's placeholder"
|
|
88
|
+
if (/--fix\b|--write\b/u.test(command)) return "modifies files"
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function detectProject(rootDir: string, exists: Exists = existsSync): ProjectFacts {
|
|
93
|
+
const packageJson = readPackageJson(rootDir)
|
|
94
|
+
const scripts = readScripts(packageJson)
|
|
95
|
+
const byName = new Map(scripts.map((script) => [script.name, script.command]))
|
|
96
|
+
|
|
97
|
+
const verifyScripts: string[] = []
|
|
98
|
+
const skippedScripts: { name: string; reason: string }[] = []
|
|
99
|
+
for (const category of VERIFY_CATEGORIES) {
|
|
100
|
+
for (const name of category) {
|
|
101
|
+
const command = byName.get(name)
|
|
102
|
+
if (command === undefined) continue
|
|
103
|
+
const reason = unsuitableForVerify(command)
|
|
104
|
+
if (reason) {
|
|
105
|
+
skippedScripts.push({ name, reason })
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
verifyScripts.push(name)
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (verifyScripts.length === 0) {
|
|
113
|
+
const aggregate = AGGREGATE_SCRIPTS.find((name) => {
|
|
114
|
+
const command = byName.get(name)
|
|
115
|
+
return command !== undefined && unsuitableForVerify(command) === null
|
|
116
|
+
})
|
|
117
|
+
if (aggregate) verifyScripts.push(aggregate)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const files = NOTABLE_FILES.filter((file) => exists(join(rootDir, file)))
|
|
121
|
+
const languages: ProjectFacts["languages"] = []
|
|
122
|
+
if (packageJson) languages.push("node")
|
|
123
|
+
if (files.includes("Cargo.toml")) languages.push("rust")
|
|
124
|
+
if (files.includes("go.mod")) languages.push("go")
|
|
125
|
+
if (files.includes("pyproject.toml")) languages.push("python")
|
|
126
|
+
|
|
127
|
+
const monorepo = files.includes("pnpm-workspace.yaml")
|
|
128
|
+
? "pnpm workspaces"
|
|
129
|
+
: files.includes("turbo.json")
|
|
130
|
+
? "Turborepo"
|
|
131
|
+
: files.includes("nx.json")
|
|
132
|
+
? "Nx"
|
|
133
|
+
: packageJson?.workspaces
|
|
134
|
+
? "package.json workspaces"
|
|
135
|
+
: null
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
rootDir,
|
|
139
|
+
name: typeof packageJson?.name === "string" ? packageJson.name : basename(rootDir),
|
|
140
|
+
configPath: findConfigFile(rootDir, exists),
|
|
141
|
+
hasGit: exists(join(rootDir, ".git")),
|
|
142
|
+
packageManager: packageJson ? detectPackageManager({ rootDir, packageJson, fileExists: exists }) : null,
|
|
143
|
+
scripts,
|
|
144
|
+
frameworks: detectFrameworks(packageJson).map((hint) => ({
|
|
145
|
+
name: hint.name,
|
|
146
|
+
dependency: hint.dependency,
|
|
147
|
+
port: hint.port,
|
|
148
|
+
})),
|
|
149
|
+
devScript: DEV_SCRIPTS.find((name) => byName.has(name)),
|
|
150
|
+
verifyScripts,
|
|
151
|
+
skippedScripts,
|
|
152
|
+
monorepo,
|
|
153
|
+
languages,
|
|
154
|
+
files,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Inline verify steps for non-Node projects, using each toolchain's standard commands. */
|
|
159
|
+
function toolchainVerify(facts: ProjectFacts): VerifyEntry[] {
|
|
160
|
+
if (facts.verifyScripts.length > 0) return facts.verifyScripts
|
|
161
|
+
if (facts.languages.includes("rust")) {
|
|
162
|
+
return [
|
|
163
|
+
{ id: "fmt", name: "cargo fmt", command: "cargo", args: ["fmt", "--check"] },
|
|
164
|
+
{ id: "clippy", name: "cargo clippy", command: "cargo", args: ["clippy", "--all-targets"] },
|
|
165
|
+
{ id: "test", name: "cargo test", command: "cargo", args: ["test"] },
|
|
166
|
+
]
|
|
167
|
+
}
|
|
168
|
+
if (facts.languages.includes("go")) {
|
|
169
|
+
return [
|
|
170
|
+
{ id: "vet", name: "go vet", command: "go", args: ["vet", "./..."] },
|
|
171
|
+
{ id: "test", name: "go test", command: "go", args: ["test", "./..."] },
|
|
172
|
+
{ id: "build", name: "go build", command: "go", args: ["build", "./..."] },
|
|
173
|
+
]
|
|
174
|
+
}
|
|
175
|
+
return []
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface StarterConfig {
|
|
179
|
+
processes: ProcessDefinition[]
|
|
180
|
+
verify: VerifyEntry[]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function starterConfig(facts: ProjectFacts): StarterConfig {
|
|
184
|
+
const processes: ProcessDefinition[] = []
|
|
185
|
+
const manager: PackageManager | undefined = facts.packageManager?.manager
|
|
186
|
+
if (manager && facts.devScript) {
|
|
187
|
+
const framework = facts.frameworks[0]
|
|
188
|
+
processes.push({
|
|
189
|
+
id: "dev",
|
|
190
|
+
name: framework?.name ?? `${manager} ${facts.devScript}`,
|
|
191
|
+
command: manager,
|
|
192
|
+
args: runScriptArgs(manager, facts.devScript),
|
|
193
|
+
...(framework ? { url: `localhost:${framework.port}`, readiness: { port: framework.port } } : {}),
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
return { processes, verify: toolchainVerify(facts) }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Render the starter as a `.devcc.ts` file. Plain object: no dependency on devcc is required. */
|
|
200
|
+
export function renderStarterConfig(facts: ProjectFacts, date = new Date()): string {
|
|
201
|
+
const starter = starterConfig(facts)
|
|
202
|
+
const lines = [
|
|
203
|
+
`/**`,
|
|
204
|
+
` * devcc configuration for ${facts.name}, generated by \`devcc init\` on ${date.toISOString().slice(0, 10)}.`,
|
|
205
|
+
` *`,
|
|
206
|
+
` * Everything here is optional. Check it with \`devcc config check\`; refine it with`,
|
|
207
|
+
` * \`devcc init --agent claude\` (or codex). Reference: https://www.npmjs.com/package/@doriandev/devcc`,
|
|
208
|
+
` */`,
|
|
209
|
+
`export default {`,
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
if (starter.processes.length > 0) {
|
|
213
|
+
lines.push(` processes: ${literal(starter.processes, 2)},`, ``)
|
|
214
|
+
} else {
|
|
215
|
+
lines.push(
|
|
216
|
+
` // processes: [{ id: "web", command: "pnpm", args: ["dev"], readiness: { port: 3000 } }],`,
|
|
217
|
+
``,
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (starter.verify.length > 0) {
|
|
222
|
+
lines.push(` // \`devcc verify\` runs these in order and exits 0 only if all pass.`)
|
|
223
|
+
lines.push(` verify: ${literal(starter.verify, 2)},`, ``)
|
|
224
|
+
} else {
|
|
225
|
+
lines.push(` // verify: ["lint", "test"],`, ``)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
lines.push(
|
|
229
|
+
` // editor: { command: "code" },`,
|
|
230
|
+
` // agents: { claude: { args: ["--permission-mode", "acceptEdits"] } },`,
|
|
231
|
+
`}`,
|
|
232
|
+
``,
|
|
233
|
+
)
|
|
234
|
+
return lines.join("\n")
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** A TypeScript object literal: identifier keys unquoted, values JSON-encoded. */
|
|
238
|
+
export function literal(value: unknown, indent = 0): string {
|
|
239
|
+
const pad = " ".repeat(indent)
|
|
240
|
+
const inner = " ".repeat(indent + 2)
|
|
241
|
+
if (Array.isArray(value)) {
|
|
242
|
+
if (value.every((item) => typeof item !== "object" || item === null)) {
|
|
243
|
+
return `[${value.map((item) => literal(item)).join(", ")}]`
|
|
244
|
+
}
|
|
245
|
+
return `[\n${value.map((item) => `${inner}${literal(item, indent + 2)},`).join("\n")}\n${pad}]`
|
|
246
|
+
}
|
|
247
|
+
if (typeof value === "object" && value !== null) {
|
|
248
|
+
const entries = Object.entries(value).filter(([, entry]) => entry !== undefined)
|
|
249
|
+
const rendered = entries.map(
|
|
250
|
+
([key, entry]) =>
|
|
251
|
+
`${/^[A-Za-z_$][\w$]*$/u.test(key) ? key : JSON.stringify(key)}: ${literal(entry, indent + 2)}`,
|
|
252
|
+
)
|
|
253
|
+
const oneLine = `{ ${rendered.join(", ")} }`
|
|
254
|
+
if (oneLine.length + indent <= 100 && !oneLine.includes("\n")) return oneLine
|
|
255
|
+
return `{\n${rendered.map((entry) => `${inner}${entry},`).join("\n")}\n${pad}}`
|
|
256
|
+
}
|
|
257
|
+
return JSON.stringify(value)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Short file contents for the prompt, so the agent starts from real data. */
|
|
261
|
+
export function readSnippet(path: string, maxLength = 2_000): string | null {
|
|
262
|
+
try {
|
|
263
|
+
const text = readFileSync(path, "utf8")
|
|
264
|
+
return text.length > maxLength ? `${text.slice(0, maxLength)}\n…` : text
|
|
265
|
+
} catch {
|
|
266
|
+
return null
|
|
267
|
+
}
|
|
268
|
+
}
|