@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,179 @@
|
|
|
1
|
+
import { basename } from "node:path"
|
|
2
|
+
import { readSnippet, renderStarterConfig, type ProjectFacts } from "./detect.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The prompt that lets a coding agent set up devcc for a project.
|
|
6
|
+
*
|
|
7
|
+
* It carries what devcc already detected (so the agent verifies rather than
|
|
8
|
+
* rediscovers), a draft to start from, the config reference, and firm rules:
|
|
9
|
+
* never invent commands, never guess hosts, keep verify non-interactive.
|
|
10
|
+
*
|
|
11
|
+
* Two delivery modes:
|
|
12
|
+
* - `write`: an interactive agent edits `.devcc.ts` itself and checks its work
|
|
13
|
+
* with `devcc config check` (used by `devcc init --agent`).
|
|
14
|
+
* - `print`: a headless agent prints the file between markers; devcc validates
|
|
15
|
+
* it and asks before writing (used from the dashboard, where the agent has no
|
|
16
|
+
* permission to edit files).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type SetupPromptMode = "write" | "print"
|
|
20
|
+
|
|
21
|
+
export const CONFIG_BEGIN_MARKER = "----- BEGIN .devcc.ts -----"
|
|
22
|
+
export const CONFIG_END_MARKER = "----- END .devcc.ts -----"
|
|
23
|
+
|
|
24
|
+
const REFERENCE = `
|
|
25
|
+
## .devcc.ts reference (all fields optional)
|
|
26
|
+
|
|
27
|
+
export default {
|
|
28
|
+
name?: string, // header label
|
|
29
|
+
processes?: [{ // long-running things devcc starts/stops
|
|
30
|
+
id, name?, command, args?: string[], cwd?, env?: Record<string, string>,
|
|
31
|
+
kind?: "service" | "task", autoStart?: boolean, url?: string, port?: number,
|
|
32
|
+
dependsOn?: string[], // ids that must be ready first
|
|
33
|
+
readiness?: { port?: number, host?: string, logPattern?: string, timeoutMs?: number },
|
|
34
|
+
restartOnCrash?: boolean,
|
|
35
|
+
}],
|
|
36
|
+
commands?: [{ // palette actions
|
|
37
|
+
id, title, category?, command, args?, cwd?, keywords?: string[],
|
|
38
|
+
confirm?: true | { title, message }, // REQUIRED for anything destructive
|
|
39
|
+
}],
|
|
40
|
+
verify?: (string | { id, name?, command, args?, cwd? })[],
|
|
41
|
+
// strings = command ids, then package.json script names
|
|
42
|
+
services?: [ // extra things to watch
|
|
43
|
+
{ id, name?, port, host?, healthPath? } |
|
|
44
|
+
{ id, name?, type: "ollama", baseUrl: "http://host:11434" },
|
|
45
|
+
],
|
|
46
|
+
agents?: { claude?: { command?, args? }, codex?: { command?, args? } },
|
|
47
|
+
editor?: { command: "code" | "zed" | "cursor" | "webstorm" } | { application: "WebStorm" },
|
|
48
|
+
terminal?: { application: "iTerm" } | { command, args?, execArgs? },
|
|
49
|
+
worktrees?: { directory?: string }, // relative to the main worktree, default ".."
|
|
50
|
+
remoteMachines?: [{ id, name?, host, transport: "ssh", port?, user?, services?: string[] }],
|
|
51
|
+
refresh?: { system?, services?, git?, clock? },
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
Everything is spawned as command + args with no shell: never put pipes, &&, env assignments or
|
|
55
|
+
quotes inside a single string. \`command: "pnpm", args: ["run", "dev"]\` is right;
|
|
56
|
+
\`command: "pnpm run dev"\` is wrong.`
|
|
57
|
+
|
|
58
|
+
function factsSection(facts: ProjectFacts): string {
|
|
59
|
+
const lines = [
|
|
60
|
+
`- root: ${facts.rootDir}`,
|
|
61
|
+
`- name: ${facts.name}`,
|
|
62
|
+
`- git repository: ${facts.hasGit ? "yes" : "no"}`,
|
|
63
|
+
`- languages: ${facts.languages.join(", ") || "none detected"}`,
|
|
64
|
+
`- package manager: ${facts.packageManager ? `${facts.packageManager.manager} (from ${facts.packageManager.source})` : "none (no package.json)"}`,
|
|
65
|
+
`- monorepo: ${facts.monorepo ?? "no"}`,
|
|
66
|
+
`- frameworks (by dependency): ${facts.frameworks.map((framework) => `${framework.name} (default port ${framework.port})`).join(", ") || "none recognised"}`,
|
|
67
|
+
`- notable files: ${facts.files.join(", ") || "none"}`,
|
|
68
|
+
`- existing devcc config: ${facts.configPath ? basename(facts.configPath) : "none"}`,
|
|
69
|
+
]
|
|
70
|
+
if (facts.scripts.length > 0) {
|
|
71
|
+
lines.push(`- package.json scripts:`)
|
|
72
|
+
for (const script of facts.scripts) lines.push(` - ${script.name}: ${script.command}`)
|
|
73
|
+
}
|
|
74
|
+
if (facts.skippedScripts.length > 0) {
|
|
75
|
+
lines.push(
|
|
76
|
+
`- left out of verify: ${facts.skippedScripts.map((script) => `${script.name} (${script.reason})`).join(", ")}`,
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
return lines.join("\n")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function buildSetupPrompt(facts: ProjectFacts, mode: SetupPromptMode): string {
|
|
83
|
+
const existing = facts.configPath ? readSnippet(facts.configPath, 6_000) : null
|
|
84
|
+
const draft = renderStarterConfig(facts)
|
|
85
|
+
|
|
86
|
+
const output =
|
|
87
|
+
mode === "write"
|
|
88
|
+
? `## Deliver
|
|
89
|
+
|
|
90
|
+
1. ${facts.configPath ? `Update \`${basename(facts.configPath)}\` in place, keeping anything the user wrote that is still correct.` : "Create `.devcc.ts` in the project root."}
|
|
91
|
+
2. Run \`devcc config check\` and fix every issue it reports. Do not stop until it exits 0.
|
|
92
|
+
3. If you added \`verify\`, run \`devcc verify\`. A step that fails because the project itself is broken
|
|
93
|
+
stays in the list - report it; do not delete checks to make the gate pass.
|
|
94
|
+
4. Finish with a short summary: what you configured, what you deliberately left out, and why.`
|
|
95
|
+
: `## Deliver
|
|
96
|
+
|
|
97
|
+
You cannot edit files in this session. Print the complete file, and nothing else between the markers,
|
|
98
|
+
exactly like this:
|
|
99
|
+
|
|
100
|
+
${CONFIG_BEGIN_MARKER}
|
|
101
|
+
export default {
|
|
102
|
+
...
|
|
103
|
+
}
|
|
104
|
+
${CONFIG_END_MARKER}
|
|
105
|
+
|
|
106
|
+
devcc validates it and asks the user before writing it${facts.configPath ? ` over \`${basename(facts.configPath)}\`` : ""}. After the markers, add
|
|
107
|
+
a short summary: what you configured, what you left out, and why.`
|
|
108
|
+
|
|
109
|
+
return `Set up devcc for this project: produce a solid .devcc.ts.
|
|
110
|
+
|
|
111
|
+
devcc (@doriandev/devcc) is a keyboard-driven terminal dashboard for local development. Its config
|
|
112
|
+
tells it which dev processes it may start, which checks \`devcc verify\` runs as the quality gate, and
|
|
113
|
+
which commands appear in its palette. You are configuring it for the repository in your working
|
|
114
|
+
directory.
|
|
115
|
+
|
|
116
|
+
## What devcc already detected
|
|
117
|
+
|
|
118
|
+
${factsSection(facts)}
|
|
119
|
+
|
|
120
|
+
## Draft from \`devcc init\`
|
|
121
|
+
|
|
122
|
+
Start from this, but verify every line against the repository - it only knows script names.
|
|
123
|
+
|
|
124
|
+
\`\`\`ts
|
|
125
|
+
${draft.trimEnd()}
|
|
126
|
+
\`\`\`
|
|
127
|
+
${existing ? `\n## Current config (${basename(facts.configPath ?? "")})\n\n\`\`\`ts\n${existing.trimEnd()}\n\`\`\`\n` : ""}
|
|
128
|
+
## Investigate before writing
|
|
129
|
+
|
|
130
|
+
- Read package.json (every workspace package in a monorepo), the README, CONTRIBUTING, AGENTS.md or
|
|
131
|
+
CLAUDE.md, CI workflows (.github/workflows), Makefile/justfile, docker compose files, framework
|
|
132
|
+
configs (next.config.*, vite.config.*, etc.) and .env.example.
|
|
133
|
+
- Find the real dev servers and their ports. Ports come from scripts, framework config or
|
|
134
|
+
.env.example - not from defaults when the project overrides them.
|
|
135
|
+
- CI shows what the team treats as the quality gate; mirror it in \`verify\` where it can run locally.
|
|
136
|
+
|
|
137
|
+
## Rules
|
|
138
|
+
|
|
139
|
+
- Only use commands that exist in this repository or are the standard CLI of a toolchain it uses. Never
|
|
140
|
+
invent scripts, binaries or ports.
|
|
141
|
+
- \`processes\`: long-running dev servers, watchers and local infra the developer starts by hand. Use
|
|
142
|
+
\`readiness\` with the real port (or a \`logPattern\` from the server's actual output) and \`dependsOn\`
|
|
143
|
+
when one service needs another. In a monorepo, one process per app with \`cwd\`. Do not set
|
|
144
|
+
\`autoStart\` unless the README says the project is always run that way.
|
|
145
|
+
- \`verify\`: fast to slow (format check, lint, typecheck, tests, build). Every step must be
|
|
146
|
+
non-interactive, exit on its own, and not modify files (no --watch, --fix or --write). Prefer
|
|
147
|
+
package.json script names over inline steps.
|
|
148
|
+
- \`commands\`: useful project tasks that are not already scripts devcc shows (migrations, seeding,
|
|
149
|
+
codegen, docker compose profiles). Anything that deletes, resets, migrates or deploys needs
|
|
150
|
+
\`confirm: true\`.
|
|
151
|
+
- Never add secrets, tokens, credentials or values from .env files. Never add \`remoteMachines\`,
|
|
152
|
+
hostnames or IP addresses unless the repository documents them for local development.
|
|
153
|
+
- Leave out \`editor\`, \`terminal\` and \`agents\` unless the repository states a preference; they are
|
|
154
|
+
personal. Leave out anything you are unsure of rather than guessing - a small correct config beats a
|
|
155
|
+
large wrong one.
|
|
156
|
+
- Use a plain \`export default { ... }\` object. Import \`defineConfig\` from "@doriandev/devcc" only if that
|
|
157
|
+
package is already a dependency of this project.
|
|
158
|
+
${REFERENCE}
|
|
159
|
+
|
|
160
|
+
${output}
|
|
161
|
+
`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Pull the config out of a headless agent's output: the last block between the
|
|
166
|
+
* markers, with a surrounding code fence removed. Null when there is none.
|
|
167
|
+
*/
|
|
168
|
+
export function extractConfigFromOutput(output: string): string | null {
|
|
169
|
+
const end = output.lastIndexOf(CONFIG_END_MARKER)
|
|
170
|
+
if (end === -1) return null
|
|
171
|
+
const begin = output.lastIndexOf(CONFIG_BEGIN_MARKER, end)
|
|
172
|
+
if (begin === -1) return null
|
|
173
|
+
const body = output
|
|
174
|
+
.slice(begin + CONFIG_BEGIN_MARKER.length, end)
|
|
175
|
+
.replace(/^\s*```[a-z]*\s*\n/u, "")
|
|
176
|
+
.replace(/\n\s*```\s*$/u, "")
|
|
177
|
+
.trim()
|
|
178
|
+
return body === "" ? null : `${body}\n`
|
|
179
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import type { EditorConfig, RemoteMachineConfig, TerminalConfig } from "../core/config.js"
|
|
3
|
+
import { sshDestination } from "../services/remote.js"
|
|
4
|
+
import { DevccError } from "../utils/errors.js"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Opening things outside devcc: editors, terminals, SSH sessions.
|
|
8
|
+
*
|
|
9
|
+
* Every launch is an executable plus an argv array, detached from devcc so
|
|
10
|
+
* closing the dashboard never closes your editor. macOS conventions (`open`,
|
|
11
|
+
* `open -a`) are used only when running on macOS; elsewhere a configured
|
|
12
|
+
* command is required.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface LaunchCommand {
|
|
16
|
+
command: string
|
|
17
|
+
args: string[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type Platform = NodeJS.Platform
|
|
21
|
+
|
|
22
|
+
/** Replace `{path}` inside individual arguments. Never joins arguments into a string. */
|
|
23
|
+
export function substitutePath(args: readonly string[], path: string): string[] {
|
|
24
|
+
return args.map((arg) => arg.split("{path}").join(path))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function editorCommand(
|
|
28
|
+
path: string,
|
|
29
|
+
editor: EditorConfig | undefined,
|
|
30
|
+
platform: Platform = process.platform,
|
|
31
|
+
): LaunchCommand {
|
|
32
|
+
if (editor?.command) return { command: editor.command, args: [...(editor.args ?? []), path] }
|
|
33
|
+
if (editor?.application) {
|
|
34
|
+
if (platform !== "darwin") {
|
|
35
|
+
throw new DevccError({
|
|
36
|
+
scope: "command",
|
|
37
|
+
message: `editor.application only works on macOS; set editor.command`,
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
return { command: "open", args: ["-a", editor.application, path] }
|
|
41
|
+
}
|
|
42
|
+
throw new DevccError({
|
|
43
|
+
scope: "command",
|
|
44
|
+
message: "No editor configured",
|
|
45
|
+
detail: 'Set editor: { command: "code" } (or zed, cursor, webstorm) in .devcc.ts',
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function terminalCommand(
|
|
50
|
+
path: string,
|
|
51
|
+
terminal: TerminalConfig | undefined,
|
|
52
|
+
platform: Platform = process.platform,
|
|
53
|
+
): LaunchCommand {
|
|
54
|
+
if (terminal?.command) {
|
|
55
|
+
const args = terminal.args ?? ["{path}"]
|
|
56
|
+
return { command: terminal.command, args: substitutePath(args, path) }
|
|
57
|
+
}
|
|
58
|
+
if (platform === "darwin") {
|
|
59
|
+
return { command: "open", args: ["-a", terminal?.application ?? "Terminal", path] }
|
|
60
|
+
}
|
|
61
|
+
throw new DevccError({
|
|
62
|
+
scope: "command",
|
|
63
|
+
message: "No terminal configured",
|
|
64
|
+
detail: 'Set terminal: { command: "wezterm", args: ["start", "--cwd", "{path}"] } in .devcc.ts',
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* An interactive SSH session needs a terminal. On macOS `open ssh://host`
|
|
70
|
+
* hands the URL to the default terminal, which runs `ssh` with the user's own
|
|
71
|
+
* config for that alias. Elsewhere the configured terminal runs `ssh`.
|
|
72
|
+
*/
|
|
73
|
+
export function sshCommand(
|
|
74
|
+
machine: Pick<RemoteMachineConfig, "host" | "user" | "port">,
|
|
75
|
+
terminal: TerminalConfig | undefined,
|
|
76
|
+
platform: Platform = process.platform,
|
|
77
|
+
): LaunchCommand {
|
|
78
|
+
if (terminal?.command && terminal.execArgs) {
|
|
79
|
+
const port = machine.port ? ["-p", String(machine.port)] : []
|
|
80
|
+
return {
|
|
81
|
+
command: terminal.command,
|
|
82
|
+
args: [...terminal.execArgs, "ssh", ...port, sshDestination(machine)],
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (platform === "darwin") {
|
|
86
|
+
const user = machine.user ? `${encodeURIComponent(machine.user)}@` : ""
|
|
87
|
+
const port = machine.port ? `:${machine.port}` : ""
|
|
88
|
+
return { command: "open", args: [`ssh://${user}${machine.host}${port}`] }
|
|
89
|
+
}
|
|
90
|
+
throw new DevccError({
|
|
91
|
+
scope: "command",
|
|
92
|
+
message: "No terminal configured for SSH",
|
|
93
|
+
detail: 'Set terminal: { command: "wezterm", execArgs: ["start", "--"] } in .devcc.ts',
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Start a GUI or launcher process detached from devcc. Resolves once it has
|
|
99
|
+
* spawned (not when it exits); rejects with a readable error if it cannot start.
|
|
100
|
+
*/
|
|
101
|
+
export function launchDetached(launch: LaunchCommand, cwd?: string): Promise<void> {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
let child
|
|
104
|
+
try {
|
|
105
|
+
child = spawn(launch.command, launch.args, { cwd, detached: true, stdio: "ignore", windowsHide: true })
|
|
106
|
+
} catch (error) {
|
|
107
|
+
reject(toLaunchError(launch, error))
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
child.once("error", (error) => reject(toLaunchError(launch, error)))
|
|
111
|
+
child.once("spawn", () => {
|
|
112
|
+
child.unref()
|
|
113
|
+
resolve()
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function toLaunchError(launch: LaunchCommand, error: unknown): DevccError {
|
|
119
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
120
|
+
return new DevccError({
|
|
121
|
+
scope: "command",
|
|
122
|
+
message: code === "ENOENT" ? `\`${launch.command}\` was not found` : `Could not run ${launch.command}`,
|
|
123
|
+
cause: error,
|
|
124
|
+
})
|
|
125
|
+
}
|
|
@@ -35,6 +35,8 @@ export class ServiceManager extends TypedEmitter<ServiceManagerEvents> {
|
|
|
35
35
|
private readonly adapters = new Map<string, ServiceAdapter>()
|
|
36
36
|
private readonly detected = new Set<string>()
|
|
37
37
|
private readonly snapshots = new Map<string, ServiceSnapshot>()
|
|
38
|
+
/** When each adapter was last asked for its status. */
|
|
39
|
+
private readonly checkedAt = new Map<string, number>()
|
|
38
40
|
private timer: NodeJS.Timeout | null = null
|
|
39
41
|
private refreshing = false
|
|
40
42
|
private readonly intervalMs: number
|
|
@@ -92,7 +94,7 @@ export class ServiceManager extends TypedEmitter<ServiceManagerEvents> {
|
|
|
92
94
|
|
|
93
95
|
start(): void {
|
|
94
96
|
if (this.timer) return
|
|
95
|
-
this.timer = setInterval(() => void this.refresh(), this.intervalMs)
|
|
97
|
+
this.timer = setInterval(() => void this.refresh({ polled: true }), this.intervalMs)
|
|
96
98
|
this.timer.unref?.()
|
|
97
99
|
}
|
|
98
100
|
|
|
@@ -102,14 +104,24 @@ export class ServiceManager extends TypedEmitter<ServiceManagerEvents> {
|
|
|
102
104
|
this.timer = null
|
|
103
105
|
}
|
|
104
106
|
|
|
105
|
-
/**
|
|
106
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Refresh all detected adapters concurrently. Overlapping calls are skipped.
|
|
109
|
+
* A `polled` refresh (the timer) skips adapters whose `refreshIntervalMs`
|
|
110
|
+
* has not elapsed; an explicit refresh checks everything.
|
|
111
|
+
*/
|
|
112
|
+
async refresh(options: { polled?: boolean } = {}): Promise<ServiceSnapshot[]> {
|
|
107
113
|
if (this.refreshing) return this.list()
|
|
108
114
|
this.refreshing = true
|
|
109
115
|
try {
|
|
116
|
+
const now = Date.now()
|
|
110
117
|
const targets = [...this.detected]
|
|
111
118
|
.map((id) => this.adapters.get(id))
|
|
112
119
|
.filter((a): a is ServiceAdapter => !!a)
|
|
120
|
+
.filter((adapter) => {
|
|
121
|
+
if (!options.polled || !adapter.refreshIntervalMs) return true
|
|
122
|
+
return now - (this.checkedAt.get(adapter.id) ?? 0) >= adapter.refreshIntervalMs
|
|
123
|
+
})
|
|
124
|
+
for (const adapter of targets) this.checkedAt.set(adapter.id, now)
|
|
113
125
|
|
|
114
126
|
await Promise.all(
|
|
115
127
|
targets.map(async (adapter) => {
|
|
@@ -73,7 +73,7 @@ export class PortService implements ServiceAdapter {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
interface FrameworkHint {
|
|
76
|
+
export interface FrameworkHint {
|
|
77
77
|
/** Dependency name to look for in package.json. */
|
|
78
78
|
dependency: string
|
|
79
79
|
id: string
|
|
@@ -108,20 +108,26 @@ function dependencyNames(packageJson: Record<string, unknown> | null): Set<strin
|
|
|
108
108
|
return names
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/** Frameworks this project depends on, in hint order, one per port. */
|
|
112
|
+
export function detectFrameworks(packageJson: Record<string, unknown> | null): FrameworkHint[] {
|
|
113
|
+
const dependencies = dependencyNames(packageJson)
|
|
114
|
+
const seenPorts = new Set<number>()
|
|
115
|
+
const found: FrameworkHint[] = []
|
|
116
|
+
for (const hint of FRAMEWORK_HINTS) {
|
|
117
|
+
if (!dependencies.has(hint.dependency) || seenPorts.has(hint.port)) continue
|
|
118
|
+
seenPorts.add(hint.port)
|
|
119
|
+
found.push(hint)
|
|
120
|
+
}
|
|
121
|
+
return found
|
|
122
|
+
}
|
|
123
|
+
|
|
111
124
|
/**
|
|
112
125
|
* Build port adapters for frameworks this project actually depends on.
|
|
113
126
|
* Nothing is assumed: a project with none of these gets no adapters.
|
|
114
127
|
*/
|
|
115
128
|
export function detectNodeServices(packageJson: Record<string, unknown> | null): PortService[] {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const adapters: PortService[] = []
|
|
119
|
-
|
|
120
|
-
for (const hint of FRAMEWORK_HINTS) {
|
|
121
|
-
if (!dependencies.has(hint.dependency)) continue
|
|
122
|
-
if (seenPorts.has(hint.port)) continue
|
|
123
|
-
seenPorts.add(hint.port)
|
|
124
|
-
adapters.push(
|
|
129
|
+
return detectFrameworks(packageJson).map(
|
|
130
|
+
(hint) =>
|
|
125
131
|
new PortService({
|
|
126
132
|
id: hint.id,
|
|
127
133
|
name: hint.name,
|
|
@@ -130,8 +136,5 @@ export function detectNodeServices(packageJson: Record<string, unknown> | null):
|
|
|
130
136
|
healthPath: hint.healthPath,
|
|
131
137
|
onlyWhenListening: true,
|
|
132
138
|
}),
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return adapters
|
|
139
|
+
)
|
|
137
140
|
}
|
|
@@ -1,73 +1,216 @@
|
|
|
1
|
+
import { formatBytes } from "../utils/format.js"
|
|
1
2
|
import { probeTcpPort } from "../utils/exec.js"
|
|
2
|
-
import type { ServiceAdapter, ServiceStatus } from "./types.js"
|
|
3
|
+
import type { ServiceAdapter, ServiceStatus, ServiceStatusItem } from "./types.js"
|
|
3
4
|
|
|
4
5
|
const DEFAULT_HOST = "127.0.0.1"
|
|
5
6
|
const DEFAULT_PORT = 11434
|
|
7
|
+
/** Remote endpoints are checked at most this often while polling. */
|
|
8
|
+
const REMOTE_REFRESH_MS = 60_000
|
|
9
|
+
const MAX_ITEMS = 12
|
|
10
|
+
|
|
11
|
+
interface OllamaModelDetails {
|
|
12
|
+
parameter_size?: string
|
|
13
|
+
quantization_level?: string
|
|
14
|
+
family?: string
|
|
15
|
+
}
|
|
6
16
|
|
|
7
17
|
interface OllamaTagsResponse {
|
|
8
|
-
models?: { name?: string }[]
|
|
18
|
+
models?: { name?: string; size?: number; details?: OllamaModelDetails }[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface OllamaPsResponse {
|
|
22
|
+
models?: {
|
|
23
|
+
name?: string
|
|
24
|
+
size?: number
|
|
25
|
+
size_vram?: number
|
|
26
|
+
expires_at?: string
|
|
27
|
+
details?: OllamaModelDetails
|
|
28
|
+
}[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface OllamaModel {
|
|
32
|
+
name: string
|
|
33
|
+
/** Bytes on disk. */
|
|
34
|
+
size?: number
|
|
35
|
+
parameterSize?: string
|
|
36
|
+
quantization?: string
|
|
37
|
+
family?: string
|
|
38
|
+
/** Loaded into memory right now. */
|
|
39
|
+
loaded: boolean
|
|
40
|
+
/** Bytes of VRAM in use, when loaded. */
|
|
41
|
+
vram?: number
|
|
42
|
+
/** When Ollama will unload it. */
|
|
43
|
+
expiresAt?: Date
|
|
9
44
|
}
|
|
10
45
|
|
|
11
46
|
export interface OllamaServiceOptions {
|
|
47
|
+
/** Defaults to `ollama`; set for extra endpoints. */
|
|
48
|
+
id?: string
|
|
49
|
+
name?: string
|
|
50
|
+
/** `http://gpu-server:11434`. Takes precedence over host/port and `OLLAMA_HOST`. */
|
|
51
|
+
baseUrl?: string
|
|
12
52
|
host?: string
|
|
13
53
|
port?: number
|
|
14
54
|
timeoutMs?: number
|
|
55
|
+
/** Minimum time between polled checks (default: none locally, 60s for a baseUrl). */
|
|
56
|
+
refreshIntervalMs?: number
|
|
15
57
|
}
|
|
16
58
|
|
|
17
59
|
/**
|
|
18
|
-
* Ollama adapter
|
|
19
|
-
*
|
|
60
|
+
* Ollama adapter, local or remote.
|
|
61
|
+
*
|
|
62
|
+
* Reachability is a TCP probe; when the port answers, `/api/tags` lists the
|
|
63
|
+
* models and `/api/ps` says which are loaded. Model names are never assumed.
|
|
20
64
|
*/
|
|
21
65
|
export class OllamaService implements ServiceAdapter {
|
|
22
|
-
readonly id
|
|
23
|
-
readonly name
|
|
66
|
+
readonly id: string
|
|
67
|
+
readonly name: string
|
|
24
68
|
readonly category = "ai" as const
|
|
69
|
+
readonly refreshIntervalMs: number | undefined
|
|
25
70
|
|
|
71
|
+
private readonly baseUrl: string
|
|
26
72
|
private readonly host: string
|
|
27
73
|
private readonly port: number
|
|
28
74
|
private readonly timeoutMs: number
|
|
75
|
+
private readonly remote: boolean
|
|
29
76
|
|
|
30
77
|
constructor(options: OllamaServiceOptions = {}) {
|
|
78
|
+
const fromUrl = options.baseUrl ? parseBaseUrl(options.baseUrl) : null
|
|
31
79
|
const fromEnv = parseHostPort(process.env.OLLAMA_HOST)
|
|
32
|
-
this.
|
|
33
|
-
this.
|
|
80
|
+
this.remote = options.baseUrl !== undefined
|
|
81
|
+
this.id = options.id ?? "ollama"
|
|
82
|
+
this.host = fromUrl?.host ?? options.host ?? fromEnv?.host ?? DEFAULT_HOST
|
|
83
|
+
this.port = fromUrl?.port ?? options.port ?? fromEnv?.port ?? DEFAULT_PORT
|
|
84
|
+
this.baseUrl = options.baseUrl?.replace(/\/+$/u, "") ?? `http://${this.host}:${this.port}`
|
|
85
|
+
this.name = options.name ?? (this.remote ? `Ollama (${this.host})` : "Ollama")
|
|
34
86
|
this.timeoutMs = options.timeoutMs ?? 1_500
|
|
87
|
+
this.refreshIntervalMs = options.refreshIntervalMs ?? (this.remote ? REMOTE_REFRESH_MS : undefined)
|
|
35
88
|
}
|
|
36
89
|
|
|
90
|
+
/** A configured endpoint is always shown, so "unreachable" is visible; the local one only when running. */
|
|
37
91
|
async detect(): Promise<boolean> {
|
|
92
|
+
if (this.remote) return true
|
|
38
93
|
return probeTcpPort(this.port, this.host)
|
|
39
94
|
}
|
|
40
95
|
|
|
41
96
|
async getStatus(): Promise<ServiceStatus> {
|
|
42
97
|
const checkedAt = new Date()
|
|
43
98
|
const endpoint = `${this.host}:${this.port}`
|
|
44
|
-
const listening = await probeTcpPort(this.port, this.host)
|
|
45
|
-
if (!listening)
|
|
99
|
+
const listening = await probeTcpPort(this.port, this.host, this.remote ? 2_000 : 700)
|
|
100
|
+
if (!listening) {
|
|
101
|
+
return { state: "stopped", endpoint, detail: this.remote ? "unreachable" : "not listening", checkedAt }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const [tags, ps] = await Promise.all([
|
|
105
|
+
this.getJson<OllamaTagsResponse>("/api/tags"),
|
|
106
|
+
this.getJson<OllamaPsResponse>("/api/ps"),
|
|
107
|
+
])
|
|
108
|
+
if (!tags) return { state: "degraded", endpoint, detail: "listening, API not responding", checkedAt }
|
|
109
|
+
|
|
110
|
+
const models = mergeOllamaModels(tags, ps)
|
|
111
|
+
const loaded = models.filter((model) => model.loaded).length
|
|
112
|
+
const detail = [
|
|
113
|
+
`${models.length} model${models.length === 1 ? "" : "s"}`,
|
|
114
|
+
ps ? `${loaded} loaded` : undefined,
|
|
115
|
+
]
|
|
116
|
+
.filter(Boolean)
|
|
117
|
+
.join(" · ")
|
|
46
118
|
|
|
47
|
-
const models = await this.listModels()
|
|
48
119
|
return {
|
|
49
120
|
state: "running",
|
|
50
121
|
endpoint,
|
|
51
|
-
detail
|
|
52
|
-
items: models
|
|
122
|
+
detail,
|
|
123
|
+
items: models.slice(0, MAX_ITEMS).map(modelItem),
|
|
53
124
|
checkedAt,
|
|
54
125
|
}
|
|
55
126
|
}
|
|
56
127
|
|
|
57
|
-
|
|
128
|
+
/** Models with metadata, for the CLI and tests. Null when unreachable. */
|
|
129
|
+
async listModels(): Promise<OllamaModel[] | null> {
|
|
130
|
+
const [tags, ps] = await Promise.all([
|
|
131
|
+
this.getJson<OllamaTagsResponse>("/api/tags"),
|
|
132
|
+
this.getJson<OllamaPsResponse>("/api/ps"),
|
|
133
|
+
])
|
|
134
|
+
return tags ? mergeOllamaModels(tags, ps) : null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private async getJson<T>(path: string): Promise<T | null> {
|
|
58
138
|
try {
|
|
59
|
-
const response = await fetch(
|
|
60
|
-
signal: AbortSignal.timeout(this.timeoutMs),
|
|
61
|
-
})
|
|
139
|
+
const response = await fetch(`${this.baseUrl}${path}`, { signal: AbortSignal.timeout(this.timeoutMs) })
|
|
62
140
|
if (!response.ok) return null
|
|
63
|
-
|
|
64
|
-
return (body.models ?? []).map((model) => model.name ?? "unnamed")
|
|
141
|
+
return (await response.json()) as T
|
|
65
142
|
} catch {
|
|
66
143
|
return null
|
|
67
144
|
}
|
|
68
145
|
}
|
|
69
146
|
}
|
|
70
147
|
|
|
148
|
+
/** Combine `/api/tags` with `/api/ps`: loaded models first, then by name. */
|
|
149
|
+
export function mergeOllamaModels(tags: OllamaTagsResponse, ps: OllamaPsResponse | null): OllamaModel[] {
|
|
150
|
+
const running = new Map(
|
|
151
|
+
(ps?.models ?? []).filter((model) => model.name).map((model) => [model.name as string, model]),
|
|
152
|
+
)
|
|
153
|
+
const models: OllamaModel[] = (tags.models ?? [])
|
|
154
|
+
.filter((model) => typeof model.name === "string")
|
|
155
|
+
.map((model) => {
|
|
156
|
+
const name = model.name as string
|
|
157
|
+
const live = running.get(name)
|
|
158
|
+
running.delete(name)
|
|
159
|
+
return {
|
|
160
|
+
name,
|
|
161
|
+
size: model.size,
|
|
162
|
+
parameterSize: model.details?.parameter_size,
|
|
163
|
+
quantization: model.details?.quantization_level,
|
|
164
|
+
family: model.details?.family,
|
|
165
|
+
loaded: live !== undefined,
|
|
166
|
+
vram: live?.size_vram,
|
|
167
|
+
expiresAt: parseDate(live?.expires_at),
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
// A model can be loaded but missing from tags (e.g. pulled mid-refresh).
|
|
171
|
+
for (const [name, live] of running) {
|
|
172
|
+
models.push({
|
|
173
|
+
name,
|
|
174
|
+
size: live.size,
|
|
175
|
+
parameterSize: live.details?.parameter_size,
|
|
176
|
+
quantization: live.details?.quantization_level,
|
|
177
|
+
family: live.details?.family,
|
|
178
|
+
loaded: true,
|
|
179
|
+
vram: live.size_vram,
|
|
180
|
+
expiresAt: parseDate(live.expires_at),
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
return models.sort((a, b) => Number(b.loaded) - Number(a.loaded) || a.name.localeCompare(b.name))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function modelItem(model: OllamaModel): ServiceStatusItem {
|
|
187
|
+
const meta = [model.parameterSize, model.quantization, model.size ? formatBytes(model.size) : undefined]
|
|
188
|
+
if (model.loaded && model.vram) meta.push(`${formatBytes(model.vram)} VRAM`)
|
|
189
|
+
return {
|
|
190
|
+
name: model.name,
|
|
191
|
+
state: model.loaded ? "running" : "stopped",
|
|
192
|
+
detail: [model.loaded ? "loaded" : undefined, ...meta].filter(Boolean).join(" · "),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function parseDate(value: string | undefined): Date | undefined {
|
|
197
|
+
if (!value) return undefined
|
|
198
|
+
const date = new Date(value)
|
|
199
|
+
return Number.isNaN(date.getTime()) ? undefined : date
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A full base URL: a missing port means the scheme's default, exactly as `fetch` will treat it. */
|
|
203
|
+
export function parseBaseUrl(value: string): { host: string; port: number } | null {
|
|
204
|
+
try {
|
|
205
|
+
const parsed = new URL(value)
|
|
206
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null
|
|
207
|
+
return { host: parsed.hostname, port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)) }
|
|
208
|
+
} catch {
|
|
209
|
+
return null
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** `OLLAMA_HOST`-style `host[:port]`, where a missing port means Ollama's 11434. */
|
|
71
214
|
export function parseHostPort(value: string | undefined): { host: string; port: number } | null {
|
|
72
215
|
if (!value) return null
|
|
73
216
|
const withScheme = value.includes("://") ? value : `http://${value}`
|