@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,133 @@
|
|
|
1
|
+
import { dirname } from "node:path"
|
|
2
|
+
import { firstLine } from "./tools.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pure parsers for mise and direnv. Both tools offer machine-readable output,
|
|
6
|
+
* so nothing here scrapes their human-oriented formatting.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// mise
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
export interface RuntimeVersion {
|
|
14
|
+
tool: string
|
|
15
|
+
version: string
|
|
16
|
+
/** What the config asked for, e.g. `22` or `lts`. */
|
|
17
|
+
requested?: string
|
|
18
|
+
/** Config file that pinned it. */
|
|
19
|
+
source?: string
|
|
20
|
+
installed: boolean
|
|
21
|
+
active: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MiseStatus {
|
|
25
|
+
installed: boolean
|
|
26
|
+
runtimes: RuntimeVersion[]
|
|
27
|
+
/** Set when mise is installed but could not report (e.g. untrusted config). */
|
|
28
|
+
error?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `mise ls --current --json`: an object keyed by tool name, each an array of version records. */
|
|
32
|
+
export const MISE_ARGS = ["ls", "--current", "--json"] as const
|
|
33
|
+
|
|
34
|
+
export function parseMiseLs(output: string): RuntimeVersion[] {
|
|
35
|
+
let data: unknown
|
|
36
|
+
try {
|
|
37
|
+
data = JSON.parse(output)
|
|
38
|
+
} catch {
|
|
39
|
+
return []
|
|
40
|
+
}
|
|
41
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) return []
|
|
42
|
+
|
|
43
|
+
const runtimes: RuntimeVersion[] = []
|
|
44
|
+
for (const [tool, records] of Object.entries(data as Record<string, unknown>)) {
|
|
45
|
+
if (!Array.isArray(records)) continue
|
|
46
|
+
for (const record of records) {
|
|
47
|
+
if (typeof record !== "object" || record === null) continue
|
|
48
|
+
const entry = record as Record<string, unknown>
|
|
49
|
+
const version = typeof entry.version === "string" ? entry.version : undefined
|
|
50
|
+
if (!version) continue
|
|
51
|
+
const source = entry.source as Record<string, unknown> | undefined
|
|
52
|
+
runtimes.push({
|
|
53
|
+
tool,
|
|
54
|
+
version,
|
|
55
|
+
requested: typeof entry.requested_version === "string" ? entry.requested_version : undefined,
|
|
56
|
+
source: typeof source?.path === "string" ? source.path : undefined,
|
|
57
|
+
installed: entry.installed !== false,
|
|
58
|
+
active: entry.active === true,
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return runtimes.sort((a, b) => a.tool.localeCompare(b.tool))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Turn a failed `mise ls` into a short, actionable message. */
|
|
66
|
+
export function miseErrorMessage(stderr: string): string {
|
|
67
|
+
if (/not trusted/iu.test(stderr)) return "config is not trusted - run `mise trust` if you expect it"
|
|
68
|
+
return firstLine(stderr.replace(/^mise\s+(ERROR|WARN)\s*/imu, "")) ?? "mise ls failed"
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// direnv
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
export type DirenvApproval = "allowed" | "not-allowed" | "denied" | "unknown"
|
|
76
|
+
|
|
77
|
+
export interface DirenvStatus {
|
|
78
|
+
installed: boolean
|
|
79
|
+
/** The `.envrc` direnv would load for this project, if any. */
|
|
80
|
+
rcPath?: string
|
|
81
|
+
approval: DirenvApproval
|
|
82
|
+
/**
|
|
83
|
+
* Whether devcc's own environment has this `.envrc` loaded (it was started
|
|
84
|
+
* from a direnv-enabled shell in the project). Null when there is nothing to load.
|
|
85
|
+
*/
|
|
86
|
+
loaded: boolean | null
|
|
87
|
+
error?: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** direnv's numeric allow states (`internal/cmd/rc.go`). */
|
|
91
|
+
const APPROVAL_CODES: Record<number, DirenvApproval> = { 0: "allowed", 1: "not-allowed", 2: "denied" }
|
|
92
|
+
|
|
93
|
+
function approvalFrom(value: unknown): DirenvApproval {
|
|
94
|
+
if (typeof value === "number") return APPROVAL_CODES[value] ?? "unknown"
|
|
95
|
+
if (typeof value === "boolean") return value ? "allowed" : "not-allowed"
|
|
96
|
+
if (typeof value === "string") {
|
|
97
|
+
const numeric = Number(value)
|
|
98
|
+
if (value.trim() !== "" && Number.isInteger(numeric)) return APPROVAL_CODES[numeric] ?? "unknown"
|
|
99
|
+
if (value === "true") return "allowed"
|
|
100
|
+
if (value === "false") return "not-allowed"
|
|
101
|
+
}
|
|
102
|
+
return "unknown"
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parse `direnv status --json`, falling back to the plain `direnv status`
|
|
107
|
+
* text for versions without JSON support.
|
|
108
|
+
*/
|
|
109
|
+
export function parseDirenvStatus(output: string): { rcPath?: string; approval: DirenvApproval } {
|
|
110
|
+
try {
|
|
111
|
+
const data = JSON.parse(output) as { state?: { foundRC?: { path?: unknown; allowed?: unknown } | null } }
|
|
112
|
+
const found = data.state?.foundRC
|
|
113
|
+
if (!found || typeof found.path !== "string") return { approval: "unknown" }
|
|
114
|
+
return { rcPath: found.path, approval: approvalFrom(found.allowed) }
|
|
115
|
+
} catch {
|
|
116
|
+
// Not JSON: older direnv. Fall through to the text format.
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const path = /^Found RC path (.+)$/mu.exec(output)?.[1]?.trim()
|
|
120
|
+
if (!path) return { approval: "unknown" }
|
|
121
|
+
const allowed = /^Found RC allowed (.+)$/mu.exec(output)?.[1]?.trim()
|
|
122
|
+
return { rcPath: path, approval: allowed === undefined ? "unknown" : approvalFrom(allowed) }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* direnv exports `DIRENV_DIR=-<dir>` into shells where it loaded an `.envrc`.
|
|
127
|
+
* If devcc inherited that for this `.envrc`'s directory, the environment is loaded.
|
|
128
|
+
*/
|
|
129
|
+
export function isDirenvLoaded(rcPath: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
130
|
+
const dir = env.DIRENV_DIR
|
|
131
|
+
if (!dir) return false
|
|
132
|
+
return dir.replace(/^-/u, "") === dirname(rcPath)
|
|
133
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { accessSync, constants } from "node:fs"
|
|
2
|
+
import { delimiter, isAbsolute, join } from "node:path"
|
|
3
|
+
import { exec, type ExecOptions, type ExecResult } from "../utils/exec.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tool detection for the Environment panel and `devcc env`.
|
|
7
|
+
*
|
|
8
|
+
* Observation only: devcc reports what is installed and whether it answers, it
|
|
9
|
+
* never installs or upgrades anything. A missing tool is a normal state, not
|
|
10
|
+
* an error.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type ToolHealth = "ready" | "warning" | "error"
|
|
14
|
+
|
|
15
|
+
export interface ToolStatus {
|
|
16
|
+
id: string
|
|
17
|
+
name: string
|
|
18
|
+
installed: boolean
|
|
19
|
+
version?: string
|
|
20
|
+
/** Resolved executable path. */
|
|
21
|
+
path?: string
|
|
22
|
+
/** Absent when the tool is not installed. */
|
|
23
|
+
status?: ToolHealth
|
|
24
|
+
message?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ToolDefinition {
|
|
28
|
+
id: string
|
|
29
|
+
name: string
|
|
30
|
+
command: string
|
|
31
|
+
/** Arguments that print a version (default `--version`). */
|
|
32
|
+
versionArgs?: string[]
|
|
33
|
+
/** Capture group 1 is the version; defaults to the first dotted number. */
|
|
34
|
+
versionPattern?: RegExp
|
|
35
|
+
/** Absolute paths to try when the command is not on PATH (e.g. app bundles). */
|
|
36
|
+
fallbackPaths?: string[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_TOOLS: readonly ToolDefinition[] = [
|
|
40
|
+
{ id: "node", name: "Node", command: "node" },
|
|
41
|
+
{ id: "pnpm", name: "pnpm", command: "pnpm" },
|
|
42
|
+
{ id: "npm", name: "npm", command: "npm" },
|
|
43
|
+
{ id: "bun", name: "Bun", command: "bun" },
|
|
44
|
+
{ id: "git", name: "Git", command: "git" },
|
|
45
|
+
{ id: "mise", name: "mise", command: "mise" },
|
|
46
|
+
{ id: "direnv", name: "direnv", command: "direnv", versionArgs: ["version"] },
|
|
47
|
+
{ id: "docker", name: "Docker", command: "docker" },
|
|
48
|
+
{ id: "cargo", name: "Rust/Cargo", command: "cargo" },
|
|
49
|
+
{ id: "claude", name: "Claude Code", command: "claude" },
|
|
50
|
+
{ id: "codex", name: "Codex", command: "codex" },
|
|
51
|
+
{
|
|
52
|
+
id: "tailscale",
|
|
53
|
+
name: "Tailscale",
|
|
54
|
+
command: "tailscale",
|
|
55
|
+
versionArgs: ["version"],
|
|
56
|
+
fallbackPaths: ["/Applications/Tailscale.app/Contents/MacOS/Tailscale"],
|
|
57
|
+
},
|
|
58
|
+
{ id: "ollama", name: "Ollama", command: "ollama" },
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
export const TOOL_TIMEOUT_MS = 3_000
|
|
62
|
+
|
|
63
|
+
/** First `x.y[.z…]` version in the output, or the pattern's first group. */
|
|
64
|
+
export function parseVersion(output: string, pattern?: RegExp): string | undefined {
|
|
65
|
+
const text = output.trim()
|
|
66
|
+
if (text === "") return undefined
|
|
67
|
+
if (pattern) {
|
|
68
|
+
const match = pattern.exec(text)
|
|
69
|
+
if (match?.[1]) return match[1]
|
|
70
|
+
}
|
|
71
|
+
return /\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?/u.exec(text)?.[0]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type Which = (command: string) => string | undefined
|
|
75
|
+
|
|
76
|
+
/** Look a command up on PATH the way a shell would, without running a shell. */
|
|
77
|
+
export function findExecutable(
|
|
78
|
+
command: string,
|
|
79
|
+
pathEnv: string = process.env.PATH ?? "",
|
|
80
|
+
isExecutable: (path: string) => boolean = canExecute,
|
|
81
|
+
): string | undefined {
|
|
82
|
+
if (command.includes("/")) return isAbsolute(command) && isExecutable(command) ? command : undefined
|
|
83
|
+
for (const dir of pathEnv.split(delimiter)) {
|
|
84
|
+
if (dir === "") continue
|
|
85
|
+
const candidate = join(dir, command)
|
|
86
|
+
if (isExecutable(candidate)) return candidate
|
|
87
|
+
}
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function canExecute(path: string): boolean {
|
|
92
|
+
try {
|
|
93
|
+
accessSync(path, constants.X_OK)
|
|
94
|
+
return true
|
|
95
|
+
} catch {
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type Exec = (command: string, args: readonly string[], options?: ExecOptions) => Promise<ExecResult>
|
|
101
|
+
|
|
102
|
+
export interface DetectToolDeps {
|
|
103
|
+
run?: Exec
|
|
104
|
+
which?: Which
|
|
105
|
+
timeoutMs?: number
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Detect one tool: resolve it on PATH, then ask for its version with a hard
|
|
110
|
+
* timeout. Never throws - every failure becomes diagnostic state.
|
|
111
|
+
*/
|
|
112
|
+
export async function detectTool(definition: ToolDefinition, deps: DetectToolDeps = {}): Promise<ToolStatus> {
|
|
113
|
+
const which = deps.which ?? ((command: string) => findExecutable(command))
|
|
114
|
+
const run = deps.run ?? exec
|
|
115
|
+
const timeoutMs = deps.timeoutMs ?? TOOL_TIMEOUT_MS
|
|
116
|
+
const base = { id: definition.id, name: definition.name }
|
|
117
|
+
|
|
118
|
+
const path =
|
|
119
|
+
which(definition.command) ?? definition.fallbackPaths?.find((candidate) => which(candidate) !== undefined)
|
|
120
|
+
if (!path) return { ...base, installed: false, message: "not installed" }
|
|
121
|
+
|
|
122
|
+
let result: ExecResult
|
|
123
|
+
try {
|
|
124
|
+
result = await run(path, definition.versionArgs ?? ["--version"], { timeoutMs })
|
|
125
|
+
} catch (error) {
|
|
126
|
+
return { ...base, installed: true, path, status: "error", message: String(error) }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (result.timedOut) {
|
|
130
|
+
return { ...base, installed: true, path, status: "warning", message: `no answer within ${timeoutMs}ms` }
|
|
131
|
+
}
|
|
132
|
+
if (result.spawnError) {
|
|
133
|
+
return { ...base, installed: true, path, status: "error", message: result.spawnError.message }
|
|
134
|
+
}
|
|
135
|
+
if (!result.ok) {
|
|
136
|
+
const reason =
|
|
137
|
+
firstLine(result.stderr) ?? firstLine(result.stdout) ?? `exited with code ${result.code ?? "?"}`
|
|
138
|
+
return { ...base, installed: true, path, status: "error", message: reason }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const version = parseVersion(result.stdout || result.stderr, definition.versionPattern)
|
|
142
|
+
return { ...base, installed: true, path, version, status: "ready" }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function firstLine(text: string): string | undefined {
|
|
146
|
+
const line = text
|
|
147
|
+
.split("\n")
|
|
148
|
+
.map((entry) => entry.trim())
|
|
149
|
+
.find((entry) => entry !== "")
|
|
150
|
+
return line
|
|
151
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { TypedEmitter } from "../core/events.js"
|
|
2
2
|
import { DevccError } from "../utils/errors.js"
|
|
3
|
+
import { existsSync, realpathSync } from "node:fs"
|
|
4
|
+
import { resolve } from "node:path"
|
|
3
5
|
import { exec, type ExecResult } from "../utils/exec.js"
|
|
4
6
|
import type { ScopedLogger } from "../utils/logger.js"
|
|
5
7
|
import {
|
|
@@ -10,12 +12,24 @@ import {
|
|
|
10
12
|
type GitCommit,
|
|
11
13
|
type GitStatus,
|
|
12
14
|
} from "./parse.js"
|
|
15
|
+
import {
|
|
16
|
+
parseWorktreeList,
|
|
17
|
+
removalBlocker,
|
|
18
|
+
validateBranchName,
|
|
19
|
+
validateRef,
|
|
20
|
+
validateWorktreePath,
|
|
21
|
+
worktreeAddArgs,
|
|
22
|
+
worktreeRemoveArgs,
|
|
23
|
+
type GitWorktree,
|
|
24
|
+
} from "./worktrees.js"
|
|
13
25
|
|
|
14
26
|
export interface GitSnapshot {
|
|
15
27
|
available: boolean
|
|
16
28
|
isRepository: boolean
|
|
17
29
|
status: GitStatus
|
|
18
30
|
commits: GitCommit[]
|
|
31
|
+
/** Every worktree of the repository, current one flagged. Empty outside a repo. */
|
|
32
|
+
worktrees: GitWorktree[]
|
|
19
33
|
version: string | null
|
|
20
34
|
lastRefreshedAt: Date | null
|
|
21
35
|
/** Set when the last refresh failed; the previous snapshot is kept. */
|
|
@@ -27,11 +41,20 @@ export const EMPTY_GIT_SNAPSHOT: GitSnapshot = {
|
|
|
27
41
|
isRepository: false,
|
|
28
42
|
status: EMPTY_GIT_STATUS,
|
|
29
43
|
commits: [],
|
|
44
|
+
worktrees: [],
|
|
30
45
|
version: null,
|
|
31
46
|
lastRefreshedAt: null,
|
|
32
47
|
error: null,
|
|
33
48
|
}
|
|
34
49
|
|
|
50
|
+
export interface CreateWorktreeInput {
|
|
51
|
+
branch: string
|
|
52
|
+
/** Start point when the branch does not exist yet (default: HEAD). */
|
|
53
|
+
base?: string
|
|
54
|
+
/** Absolute destination directory. */
|
|
55
|
+
path: string
|
|
56
|
+
}
|
|
57
|
+
|
|
35
58
|
export type GitServiceEvents = {
|
|
36
59
|
"git:changed": [GitSnapshot]
|
|
37
60
|
"git:error": [DevccError]
|
|
@@ -77,10 +100,11 @@ export class GitService extends TypedEmitter<GitServiceEvents> {
|
|
|
77
100
|
}
|
|
78
101
|
|
|
79
102
|
private async doRefresh(): Promise<GitSnapshot> {
|
|
80
|
-
const [versionResult, statusResult, logResult] = await Promise.all([
|
|
103
|
+
const [versionResult, statusResult, logResult, worktreeResult] = await Promise.all([
|
|
81
104
|
this.git(["--version"]),
|
|
82
105
|
this.git(["status", "--porcelain=v2", "--branch", "--untracked-files=all"]),
|
|
83
106
|
this.git(["log", `--max-count=${this.commitLimit}`, `--pretty=format:${LOG_FORMAT}`]),
|
|
107
|
+
this.git(["worktree", "list", "--porcelain"]),
|
|
84
108
|
])
|
|
85
109
|
|
|
86
110
|
const available = versionResult.spawnError === undefined
|
|
@@ -103,6 +127,7 @@ export class GitService extends TypedEmitter<GitServiceEvents> {
|
|
|
103
127
|
isRepository: false,
|
|
104
128
|
status: EMPTY_GIT_STATUS,
|
|
105
129
|
commits: [],
|
|
130
|
+
worktrees: [],
|
|
106
131
|
version,
|
|
107
132
|
lastRefreshedAt: new Date(),
|
|
108
133
|
error: notARepo ? null : statusResult.stderr.trim() || "git status failed",
|
|
@@ -115,6 +140,7 @@ export class GitService extends TypedEmitter<GitServiceEvents> {
|
|
|
115
140
|
status: parsePorcelainV2(statusResult.stdout),
|
|
116
141
|
// A fresh repository with no commits fails `git log`; an empty list is correct there.
|
|
117
142
|
commits: logResult.ok ? parseCommits(logResult.stdout) : [],
|
|
143
|
+
worktrees: worktreeResult.ok ? parseWorktreeList(worktreeResult.stdout, this.currentPath()) : [],
|
|
118
144
|
version,
|
|
119
145
|
lastRefreshedAt: new Date(),
|
|
120
146
|
error: null,
|
|
@@ -162,6 +188,66 @@ export class GitService extends TypedEmitter<GitServiceEvents> {
|
|
|
162
188
|
await this.mutate(["commit", "--message", trimmed], "git commit")
|
|
163
189
|
}
|
|
164
190
|
|
|
191
|
+
/** Fresh worktree list, independent of the polling snapshot. */
|
|
192
|
+
async listWorktrees(): Promise<GitWorktree[]> {
|
|
193
|
+
const result = await this.git(["worktree", "list", "--porcelain"])
|
|
194
|
+
if (!result.ok) {
|
|
195
|
+
throw new DevccError({
|
|
196
|
+
scope: "git",
|
|
197
|
+
message: "git worktree list failed",
|
|
198
|
+
detail: result.stderr.trim() || (result.spawnError ? "git executable not found" : undefined),
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
return parseWorktreeList(result.stdout, this.currentPath())
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Create a worktree. An existing local branch is checked out; otherwise the
|
|
206
|
+
* branch is created from `base` (default HEAD). Input is validated before git
|
|
207
|
+
* runs, and git's own ref check has the final word on the branch name.
|
|
208
|
+
*/
|
|
209
|
+
async createWorktree(input: CreateWorktreeInput): Promise<string> {
|
|
210
|
+
const branch = input.branch.trim()
|
|
211
|
+
const base = input.base?.trim() || undefined
|
|
212
|
+
const path = resolve(input.path.trim())
|
|
213
|
+
|
|
214
|
+
const problem =
|
|
215
|
+
validateBranchName(branch) ??
|
|
216
|
+
(base ? validateRef(base) : null) ??
|
|
217
|
+
validateWorktreePath(path, existsSync)
|
|
218
|
+
if (problem) throw new DevccError({ scope: "git", message: problem })
|
|
219
|
+
|
|
220
|
+
const refCheck = await this.git(["check-ref-format", "--branch", branch])
|
|
221
|
+
if (!refCheck.ok) {
|
|
222
|
+
throw new DevccError({ scope: "git", message: `"${branch}" is not a valid branch name` })
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const existing = await this.git(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`])
|
|
226
|
+
const createBranch = !existing.ok
|
|
227
|
+
await this.mutate(
|
|
228
|
+
worktreeAddArgs({ path, branch, base: createBranch ? base : undefined, createBranch }),
|
|
229
|
+
"git worktree add",
|
|
230
|
+
60_000,
|
|
231
|
+
)
|
|
232
|
+
return path
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Remove a worktree without `--force`. devcc refuses the main and current
|
|
237
|
+
* worktrees and ones with running work (`busy`); git refuses dirty ones, and
|
|
238
|
+
* that refusal is surfaced verbatim rather than overridden.
|
|
239
|
+
*/
|
|
240
|
+
async removeWorktree(path: string, busy: readonly string[] = []): Promise<void> {
|
|
241
|
+
const target = (await this.listWorktrees()).find((worktree) => worktree.path === path)
|
|
242
|
+
if (!target)
|
|
243
|
+
throw new DevccError({ scope: "git", message: `${path} is not a worktree of this repository` })
|
|
244
|
+
|
|
245
|
+
const blocker = removalBlocker(target, busy)
|
|
246
|
+
if (blocker) throw new DevccError({ scope: "git", message: blocker })
|
|
247
|
+
|
|
248
|
+
await this.mutate(worktreeRemoveArgs(target.path), "git worktree remove")
|
|
249
|
+
}
|
|
250
|
+
|
|
165
251
|
/** Read-only diff for the inspect panel. */
|
|
166
252
|
async diff(path: string, staged: boolean): Promise<string> {
|
|
167
253
|
const args = ["--no-pager", "diff", "--no-color"]
|
|
@@ -176,9 +262,15 @@ export class GitService extends TypedEmitter<GitServiceEvents> {
|
|
|
176
262
|
if (!result.ok) {
|
|
177
263
|
const detail = [result.stdout, result.stderr].filter(Boolean).join("\n").trim()
|
|
178
264
|
this.options.logger.warn(`${label} failed`, { code: result.code, detail })
|
|
265
|
+
// Prefer git's fatal/error line: progress chatter ("Preparing worktree…") can come first.
|
|
266
|
+
const lines = detail.split("\n")
|
|
267
|
+
const reason = (lines.find((line) => /^(fatal|error): /u.test(line)) ?? lines[0])?.replace(
|
|
268
|
+
/^(fatal|error): /u,
|
|
269
|
+
"",
|
|
270
|
+
)
|
|
179
271
|
const error = new DevccError({
|
|
180
272
|
scope: "git",
|
|
181
|
-
message: `${label} failed`,
|
|
273
|
+
message: reason ? `${label} failed: ${reason}` : `${label} failed`,
|
|
182
274
|
detail: detail || (result.timedOut ? "timed out" : undefined),
|
|
183
275
|
})
|
|
184
276
|
this.emit("git:error", error)
|
|
@@ -188,10 +280,20 @@ export class GitService extends TypedEmitter<GitServiceEvents> {
|
|
|
188
280
|
await this.refresh()
|
|
189
281
|
}
|
|
190
282
|
|
|
283
|
+
/** Real path of the project root, so git's resolved paths compare equal (macOS /var -> /private/var). */
|
|
284
|
+
private currentPath(): string {
|
|
285
|
+
try {
|
|
286
|
+
return realpathSync(this.options.rootDir)
|
|
287
|
+
} catch {
|
|
288
|
+
return resolve(this.options.rootDir)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
191
292
|
private git(args: readonly string[], timeoutMs?: number): Promise<ExecResult> {
|
|
192
293
|
return exec("git", args, {
|
|
193
294
|
cwd: this.options.rootDir,
|
|
194
295
|
timeoutMs: timeoutMs ?? this.timeoutMs,
|
|
296
|
+
logger: this.options.logger,
|
|
195
297
|
// Never let git open an editor or a credential prompt under the TUI.
|
|
196
298
|
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0", GIT_EDITOR: "true" },
|
|
197
299
|
})
|