@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.
Files changed (151) hide show
  1. package/README.md +589 -389
  2. package/bin/devcc.mjs +50 -23
  3. package/dist/devcc/agents/agents.d.ts +119 -0
  4. package/dist/devcc/agents/agents.d.ts.map +1 -0
  5. package/dist/devcc/agents/agents.js +182 -0
  6. package/dist/devcc/agents/agents.js.map +1 -0
  7. package/dist/devcc/app/App.d.ts +23 -0
  8. package/dist/devcc/app/App.d.ts.map +1 -1
  9. package/dist/devcc/app/App.js +451 -19
  10. package/dist/devcc/app/App.js.map +1 -1
  11. package/dist/devcc/app/controlPlaneCommands.d.ts +14 -0
  12. package/dist/devcc/app/controlPlaneCommands.d.ts.map +1 -0
  13. package/dist/devcc/app/controlPlaneCommands.js +204 -0
  14. package/dist/devcc/app/controlPlaneCommands.js.map +1 -0
  15. package/dist/devcc/app/keymap.d.ts.map +1 -1
  16. package/dist/devcc/app/keymap.js +25 -3
  17. package/dist/devcc/app/keymap.js.map +1 -1
  18. package/dist/devcc/automation.d.ts +52 -0
  19. package/dist/devcc/automation.d.ts.map +1 -0
  20. package/dist/devcc/automation.js +339 -0
  21. package/dist/devcc/automation.js.map +1 -0
  22. package/dist/devcc/cli.d.ts.map +1 -1
  23. package/dist/devcc/cli.js +73 -3
  24. package/dist/devcc/cli.js.map +1 -1
  25. package/dist/devcc/components/Sidebar.d.ts.map +1 -1
  26. package/dist/devcc/components/Sidebar.js +1 -0
  27. package/dist/devcc/components/Sidebar.js.map +1 -1
  28. package/dist/devcc/components/primitives.d.ts.map +1 -1
  29. package/dist/devcc/components/primitives.js +3 -0
  30. package/dist/devcc/components/primitives.js.map +1 -1
  31. package/dist/devcc/core/commands.d.ts +33 -0
  32. package/dist/devcc/core/commands.d.ts.map +1 -1
  33. package/dist/devcc/core/commands.js.map +1 -1
  34. package/dist/devcc/core/config.d.ts +71 -3
  35. package/dist/devcc/core/config.d.ts.map +1 -1
  36. package/dist/devcc/core/config.js +189 -5
  37. package/dist/devcc/core/config.js.map +1 -1
  38. package/dist/devcc/core/state.d.ts +19 -1
  39. package/dist/devcc/core/state.d.ts.map +1 -1
  40. package/dist/devcc/core/state.js +6 -0
  41. package/dist/devcc/core/state.js.map +1 -1
  42. package/dist/devcc/environment/EnvironmentService.d.ts +58 -0
  43. package/dist/devcc/environment/EnvironmentService.d.ts.map +1 -0
  44. package/dist/devcc/environment/EnvironmentService.js +159 -0
  45. package/dist/devcc/environment/EnvironmentService.js.map +1 -0
  46. package/dist/devcc/environment/integrations.d.ts +52 -0
  47. package/dist/devcc/environment/integrations.d.ts.map +1 -0
  48. package/dist/devcc/environment/integrations.js +94 -0
  49. package/dist/devcc/environment/integrations.js.map +1 -0
  50. package/dist/devcc/environment/tools.d.ts +51 -0
  51. package/dist/devcc/environment/tools.d.ts.map +1 -0
  52. package/dist/devcc/environment/tools.js +99 -0
  53. package/dist/devcc/environment/tools.js.map +1 -0
  54. package/dist/devcc/git/GitService.d.ts +26 -0
  55. package/dist/devcc/git/GitService.d.ts.map +1 -1
  56. package/dist/devcc/git/GitService.js +71 -2
  57. package/dist/devcc/git/GitService.js.map +1 -1
  58. package/dist/devcc/git/worktrees.d.ts +88 -0
  59. package/dist/devcc/git/worktrees.d.ts.map +1 -0
  60. package/dist/devcc/git/worktrees.js +226 -0
  61. package/dist/devcc/git/worktrees.js.map +1 -0
  62. package/dist/devcc/index.d.ts +15 -3
  63. package/dist/devcc/index.d.ts.map +1 -1
  64. package/dist/devcc/index.js +14 -2
  65. package/dist/devcc/index.js.map +1 -1
  66. package/dist/devcc/init/detect.d.ts +55 -0
  67. package/dist/devcc/init/detect.d.ts.map +1 -0
  68. package/dist/devcc/init/detect.js +207 -0
  69. package/dist/devcc/init/detect.js.map +1 -0
  70. package/dist/devcc/init/prompt.d.ts +25 -0
  71. package/dist/devcc/init/prompt.d.ts.map +1 -0
  72. package/dist/devcc/init/prompt.js +156 -0
  73. package/dist/devcc/init/prompt.js.map +1 -0
  74. package/dist/devcc/platform/launch.d.ts +30 -0
  75. package/dist/devcc/platform/launch.d.ts.map +1 -0
  76. package/dist/devcc/platform/launch.js +93 -0
  77. package/dist/devcc/platform/launch.js.map +1 -0
  78. package/dist/devcc/services/ServiceManager.d.ts +10 -2
  79. package/dist/devcc/services/ServiceManager.d.ts.map +1 -1
  80. package/dist/devcc/services/ServiceManager.js +18 -4
  81. package/dist/devcc/services/ServiceManager.js.map +1 -1
  82. package/dist/devcc/services/node.d.ts +11 -0
  83. package/dist/devcc/services/node.d.ts.map +1 -1
  84. package/dist/devcc/services/node.js +20 -18
  85. package/dist/devcc/services/node.js.map +1 -1
  86. package/dist/devcc/services/ollama.d.ts +64 -5
  87. package/dist/devcc/services/ollama.d.ts.map +1 -1
  88. package/dist/devcc/services/ollama.js +118 -17
  89. package/dist/devcc/services/ollama.js.map +1 -1
  90. package/dist/devcc/services/remote.d.ts +90 -0
  91. package/dist/devcc/services/remote.d.ts.map +1 -0
  92. package/dist/devcc/services/remote.js +233 -0
  93. package/dist/devcc/services/remote.js.map +1 -0
  94. package/dist/devcc/services/types.d.ts +8 -1
  95. package/dist/devcc/services/types.d.ts.map +1 -1
  96. package/dist/devcc/services/types.js.map +1 -1
  97. package/dist/devcc/utils/exec.d.ts +6 -0
  98. package/dist/devcc/utils/exec.d.ts.map +1 -1
  99. package/dist/devcc/utils/exec.js +12 -2
  100. package/dist/devcc/utils/exec.js.map +1 -1
  101. package/dist/devcc/verify/verify.d.ts +107 -0
  102. package/dist/devcc/verify/verify.d.ts.map +1 -0
  103. package/dist/devcc/verify/verify.js +203 -0
  104. package/dist/devcc/verify/verify.js.map +1 -0
  105. package/dist/devcc/views/OverviewView.d.ts.map +1 -1
  106. package/dist/devcc/views/OverviewView.js +58 -2
  107. package/dist/devcc/views/OverviewView.js.map +1 -1
  108. package/dist/devcc/views/ServicesView.d.ts +2 -0
  109. package/dist/devcc/views/ServicesView.d.ts.map +1 -1
  110. package/dist/devcc/views/ServicesView.js +15 -0
  111. package/dist/devcc/views/ServicesView.js.map +1 -1
  112. package/dist/devcc/views/SystemView.d.ts +10 -1
  113. package/dist/devcc/views/SystemView.d.ts.map +1 -1
  114. package/dist/devcc/views/SystemView.js +127 -19
  115. package/dist/devcc/views/SystemView.js.map +1 -1
  116. package/dist/devcc/views/WorktreesView.d.ts +48 -0
  117. package/dist/devcc/views/WorktreesView.d.ts.map +1 -0
  118. package/dist/devcc/views/WorktreesView.js +235 -0
  119. package/dist/devcc/views/WorktreesView.js.map +1 -0
  120. package/package.json +1 -1
  121. package/src/devcc/agents/agents.ts +292 -0
  122. package/src/devcc/app/App.ts +529 -22
  123. package/src/devcc/app/controlPlaneCommands.ts +227 -0
  124. package/src/devcc/app/keymap.ts +25 -3
  125. package/src/devcc/automation.ts +409 -0
  126. package/src/devcc/cli.ts +77 -3
  127. package/src/devcc/components/Sidebar.ts +1 -0
  128. package/src/devcc/components/primitives.ts +3 -0
  129. package/src/devcc/core/commands.ts +34 -0
  130. package/src/devcc/core/config.ts +275 -11
  131. package/src/devcc/core/state.ts +25 -0
  132. package/src/devcc/environment/EnvironmentService.ts +215 -0
  133. package/src/devcc/environment/integrations.ts +133 -0
  134. package/src/devcc/environment/tools.ts +151 -0
  135. package/src/devcc/git/GitService.ts +104 -2
  136. package/src/devcc/git/worktrees.ts +256 -0
  137. package/src/devcc/index.ts +66 -2
  138. package/src/devcc/init/detect.ts +268 -0
  139. package/src/devcc/init/prompt.ts +179 -0
  140. package/src/devcc/platform/launch.ts +125 -0
  141. package/src/devcc/services/ServiceManager.ts +15 -3
  142. package/src/devcc/services/node.ts +17 -14
  143. package/src/devcc/services/ollama.ts +162 -19
  144. package/src/devcc/services/remote.ts +306 -0
  145. package/src/devcc/services/types.ts +9 -1
  146. package/src/devcc/utils/exec.ts +26 -2
  147. package/src/devcc/verify/verify.ts +316 -0
  148. package/src/devcc/views/OverviewView.ts +84 -2
  149. package/src/devcc/views/ServicesView.ts +17 -0
  150. package/src/devcc/views/SystemView.ts +144 -22
  151. 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
- /** Refresh all detected adapters concurrently. Overlapping calls are skipped. */
106
- async refresh(): Promise<ServiceSnapshot[]> {
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
- const dependencies = dependencyNames(packageJson)
117
- const seenPorts = new Set<number>()
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. Uses the HTTP API for a model count when the port is open;
19
- * falls back to a plain port probe if the API call fails.
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 = "ollama"
23
- readonly name = "Ollama"
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.host = options.host ?? fromEnv?.host ?? DEFAULT_HOST
33
- this.port = options.port ?? fromEnv?.port ?? DEFAULT_PORT
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) return { state: "stopped", endpoint, detail: "not listening", checkedAt }
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: models === null ? "listening" : `${models.length} model${models.length === 1 ? "" : "s"}`,
52
- items: models?.slice(0, 6).map((name) => ({ name, state: "running" as const })),
122
+ detail,
123
+ items: models.slice(0, MAX_ITEMS).map(modelItem),
53
124
  checkedAt,
54
125
  }
55
126
  }
56
127
 
57
- private async listModels(): Promise<string[] | null> {
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(`http://${this.host}:${this.port}/api/tags`, {
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
- const body = (await response.json()) as OllamaTagsResponse
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}`