chamba 0.3.0 → 0.4.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 (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -44
  3. package/bin/chamba.js +212 -0
  4. package/dist/commands/advanced.js +278 -0
  5. package/dist/commands/dev.js +619 -0
  6. package/dist/commands/doctor.js +29 -0
  7. package/dist/commands/menu.js +80 -0
  8. package/dist/commands/onboard.js +229 -0
  9. package/dist/commands/settings.js +349 -0
  10. package/dist/lib/agent-context.js +177 -0
  11. package/dist/lib/browser.js +40 -0
  12. package/dist/lib/chamba-yaml.js +191 -0
  13. package/dist/lib/constants.js +135 -0
  14. package/dist/lib/dockerfile-builder.js +267 -0
  15. package/dist/lib/env.js +78 -0
  16. package/dist/lib/global-config.js +66 -0
  17. package/dist/lib/pnpm-store.js +19 -0
  18. package/dist/lib/ports.js +210 -0
  19. package/dist/lib/safe-rm.js +26 -0
  20. package/dist/lib/sessions.js +34 -0
  21. package/dist/lib/shadows.js +174 -0
  22. package/dist/lib/webterm.js +490 -0
  23. package/dist/lib/workspace-identity.js +260 -0
  24. package/package.json +61 -24
  25. package/schema/chamba.schema.json +65 -0
  26. package/templates/.dockerignore +3 -0
  27. package/templates/Dockerfile +173 -0
  28. package/templates/claude-statusline.sh +120 -0
  29. package/templates/context/baseline.md +13 -0
  30. package/templates/context/context-usage.md +1 -0
  31. package/templates/context/git-mode-local.md +1 -0
  32. package/templates/context/git-mode-strict.md +1 -0
  33. package/templates/context/git-mode-unrestricted.md +1 -0
  34. package/templates/context/git-unavailable.md +1 -0
  35. package/templates/context/shadow-paths.md +3 -0
  36. package/templates/context-usage.sh +249 -0
  37. package/templates/git-readonly-wrapper.mjs +309 -0
  38. package/templates/npmrc +2 -0
  39. package/templates/pnpm-config.yaml +9 -0
  40. package/templates/runtime-constants.mjs +18 -0
  41. package/templates/skills/chamba-statusline/SKILL.md +79 -0
  42. package/templates/skills/context-usage/SKILL.md +53 -0
  43. package/templates/skills/web-pane/SKILL.md +62 -0
  44. package/templates/startup-git-mode.mjs +145 -0
  45. package/templates/startup.mjs +333 -0
  46. package/templates/webpane.sh +126 -0
  47. package/templates/webterm/README.md +157 -0
  48. package/templates/webterm/artifacts.js +583 -0
  49. package/templates/webterm/config.js +269 -0
  50. package/templates/webterm/context/claude.md +14 -0
  51. package/templates/webterm/conversation.js +248 -0
  52. package/templates/webterm/package-lock.json +884 -0
  53. package/templates/webterm/package.json +17 -0
  54. package/templates/webterm/pane.js +156 -0
  55. package/templates/webterm/proc.js +89 -0
  56. package/templates/webterm/public/app/alerts.js +472 -0
  57. package/templates/webterm/public/app/cards.js +123 -0
  58. package/templates/webterm/public/app/clipboard.js +229 -0
  59. package/templates/webterm/public/app/composer.js +226 -0
  60. package/templates/webterm/public/app/connection.js +342 -0
  61. package/templates/webterm/public/app/dictation.js +98 -0
  62. package/templates/webterm/public/app/dom.js +37 -0
  63. package/templates/webterm/public/app/drafts.js +244 -0
  64. package/templates/webterm/public/app/frames.js +166 -0
  65. package/templates/webterm/public/app/main.js +82 -0
  66. package/templates/webterm/public/app/new-session.js +188 -0
  67. package/templates/webterm/public/app/note.js +24 -0
  68. package/templates/webterm/public/app/pane-frame.js +166 -0
  69. package/templates/webterm/public/app/pane.js +353 -0
  70. package/templates/webterm/public/app/state.js +51 -0
  71. package/templates/webterm/public/app/status-strip.js +170 -0
  72. package/templates/webterm/public/app/tabs.js +475 -0
  73. package/templates/webterm/public/app/terminal.js +102 -0
  74. package/templates/webterm/public/app/theme.js +46 -0
  75. package/templates/webterm/public/favicon.svg +21 -0
  76. package/templates/webterm/public/index.html +105 -0
  77. package/templates/webterm/public/styles.css +1193 -0
  78. package/templates/webterm/server.js +1142 -0
  79. package/templates/webterm/sessions.js +515 -0
  80. package/templates/webterm/snapshot.js +135 -0
  81. package/templates/webterm.sh +167 -0
  82. package/dist/cli.js +0 -1691
  83. package/dist/server.js +0 -1919
  84. package/inject/annotate.js +0 -18
  85. package/skill/README.md +0 -12
  86. package/skill/SKILL.md +0 -93
  87. package/web/assets/highlighted-body-OFNGDK62-Bn4Eu7CG.js +0 -1
  88. package/web/assets/index-B9DI4F1Z.js +0 -202
  89. package/web/assets/index-DK_n6CTo.css +0 -2
  90. package/web/assets/mermaid-GHXKKRXX-CEMduc-U.js +0 -1
  91. package/web/index.html +0 -28
@@ -0,0 +1,619 @@
1
+ // =========================================================================================================================================
2
+ // src/commands/dev.ts - Start the dev container and connect via docker exec
3
+ // In-memory Dockerfile build, profile selection, pattern-based shadows, env handling, runtime env injection.
4
+ // =========================================================================================================================================
5
+ import { spawnSync } from "node:child_process";
6
+ import { createHash } from "node:crypto";
7
+ import { existsSync } from "node:fs";
8
+ import { join, relative } from "node:path";
9
+ import { cancel, confirm, isCancel, log, outro, select } from "@clack/prompts";
10
+ import { buildAgentContextDocs, buildAgentMountArgs, injectAgentContext } from "../lib/agent-context.js";
11
+ import { openInBrowser } from "../lib/browser.js";
12
+ import { readChambaYaml } from "../lib/chamba-yaml.js";
13
+ import { CONTAINER_KEEP_ALIVE, CONTAINER_STARTUP, CONTAINER_WORKSPACE, DEFAULT_PROFILE, GIT_MODE, LABEL_ENV, LABEL_GIT_MODE, LABEL_MANAGED, LABEL_PORTS, LABEL_PROFILE, LABEL_RUNTIME_ENV, LABEL_SHADOWS, RUNTIME_ENV, WEB_CONTAINER_PORT, } from "../lib/constants.js";
14
+ import { buildDockerfile, buildImageWithTempfile, computeBuildHash, isImageStale } from "../lib/dockerfile-builder.js";
15
+ import { envLabel, envRunArgs, envWarnings, validateEnvConfig } from "../lib/env.js";
16
+ import { readWebRange } from "../lib/global-config.js";
17
+ import { buildPnpmStoreMountArgs } from "../lib/pnpm-store.js";
18
+ import { assertHostPortsAvailable, formatPortNotice, formatWebRange, portEnvArgs, portPublishArgs, portsLabel, validatePortsConfig, } from "../lib/ports.js";
19
+ import { containerSessionCount, loginShellExecArgs } from "../lib/sessions.js";
20
+ import { buildShadowMountArgs, ensureShadowsInSync, expandShadowPatterns } from "../lib/shadows.js";
21
+ import { buildWebpaneMountArgs, buildWebtermStateMountArgs, isWebKey, plantResumeMarker, readWebAgent, readWebKey, resolveWebPort, resumeCommandFor, setWebDefaultCwd, startWebtermAndVerify, webInterfaceAnswers, webInterfaceUrl, webPortUsable, webSessionInfo, } from "../lib/webterm.js";
22
+ import { readActiveProfile, readGitMode, writeActiveProfile } from "../lib/workspace-identity.js";
23
+ // --- Working directory resolution ---------------------------------------------------------------------------------------------------------
24
+ // Always open the session where chamba was invoked. The whole workspace root is bind-mounted at
25
+ // CONTAINER_WORKSPACE regardless, so this only sets the shell's opening directory. From a sub-dir,
26
+ // `cd /workspace` reaches the root - nothing is hidden either way. Exported for testing.
27
+ export function resolveWorkdir(workspaceDir, cwd) {
28
+ if (cwd === workspaceDir)
29
+ return CONTAINER_WORKSPACE;
30
+ return `${CONTAINER_WORKSPACE}/${relative(workspaceDir, cwd)}`;
31
+ }
32
+ // --- Profile selection -------------------------------------------------------------------------------------------------------------------
33
+ async function selectProfile(ctx, profiles) {
34
+ const profileNames = Object.keys(profiles);
35
+ if (profileNames.length <= 1) {
36
+ return profileNames[0] ?? DEFAULT_PROFILE;
37
+ }
38
+ const currentProfile = readActiveProfile(ctx.workspaceId) ?? DEFAULT_PROFILE;
39
+ const choice = await select({
40
+ message: "Profile:",
41
+ options: profileNames.map((name) => {
42
+ const description = profiles[name]?.description;
43
+ const isCurrent = name === currentProfile;
44
+ const hint = description && isCurrent ? `${description} · current` : (description ?? (isCurrent ? "current" : undefined));
45
+ const opt = { value: name, label: name };
46
+ if (hint)
47
+ opt.hint = hint;
48
+ return opt;
49
+ }),
50
+ initialValue: currentProfile,
51
+ });
52
+ if (isCancel(choice)) {
53
+ cancel("Cancelled.");
54
+ process.exit(0);
55
+ }
56
+ const selected = choice;
57
+ if (selected !== currentProfile) {
58
+ writeActiveProfile(ctx.workspaceId, selected);
59
+ }
60
+ return selected;
61
+ }
62
+ // Returns null when the container does not exist (docker inspect exits non-zero).
63
+ function inspectContainer(containerName) {
64
+ const fmt = `{{.State.Status}}|{{index .Config.Labels "${LABEL_SHADOWS}"}}|{{index .Config.Labels "${LABEL_PROFILE}"}}|{{index .Config.Labels "${LABEL_RUNTIME_ENV}"}}|{{index .Config.Labels "${LABEL_GIT_MODE}"}}|{{index .Config.Labels "${LABEL_PORTS}"}}|{{index .Config.Labels "${LABEL_ENV}"}}`;
65
+ const result = spawnSync("docker", ["inspect", "--format", fmt, containerName], { encoding: "utf8", stdio: "pipe" });
66
+ if (result.status !== 0)
67
+ return null;
68
+ const clean = (s) => (s === "<no value>" ? "" : s);
69
+ const [status = "", shadows = "", profile = "", runtimeEnv = "", gitMode = "", ports = "", env = ""] = result.stdout.trim().split("|");
70
+ return {
71
+ status,
72
+ shadowLabel: clean(shadows),
73
+ profileLabel: clean(profile),
74
+ runtimeEnvLabel: clean(runtimeEnv),
75
+ gitModeLabel: clean(gitMode),
76
+ portsLabel: clean(ports),
77
+ envLabel: clean(env),
78
+ };
79
+ }
80
+ // --- Shadow label ------------------------------------------------------------------------------------------------------------------------
81
+ function shadowLabel(paths) {
82
+ if (paths.length === 0)
83
+ return "";
84
+ return [...paths].sort().join(",");
85
+ }
86
+ // --- Runtime env fingerprint -------------------------------------------------------------------------------------------------------------
87
+ function runtimeEnvLabel() {
88
+ const entries = Object.entries(RUNTIME_ENV);
89
+ if (entries.length === 0)
90
+ return "";
91
+ const sorted = entries
92
+ .map(([k, v]) => `${k}=${v}`)
93
+ .sort()
94
+ .join(",");
95
+ return createHash("sha256").update(sorted).digest("hex").slice(0, 12);
96
+ }
97
+ // --- Stop and remove container -----------------------------------------------------------------------------------------------------------
98
+ function stopAndRemoveContainer(containerName) {
99
+ spawnSync("docker", ["stop", containerName], { stdio: "pipe" });
100
+ spawnSync("docker", ["rm", containerName], { stdio: "pipe" });
101
+ }
102
+ // --- Run startup checks (AI CLI update + readiness validation) ---------------------------------------------------------------------------
103
+ function runStartup(containerName, quiet) {
104
+ // The SPACE-to-skip prompt in startup.mjs needs raw-mode stdin (-i) and a PTY (-t).
105
+ // Omitted when quiet so test output stays pipe-capturable.
106
+ const ttyFlags = quiet ? [] : ["-i", "-t"];
107
+ const result = spawnSync("docker", ["exec", "-u", "root", ...ttyFlags, containerName, "node", CONTAINER_STARTUP, "--summary"], {
108
+ stdio: quiet ? "pipe" : "inherit",
109
+ });
110
+ return result.status === 0;
111
+ }
112
+ export async function startContainer(opts) {
113
+ const { containerName, workspaceRoot, cacheDir, templatesDir, activeProfile, profileHook, expandedShadows, envConfig, hasGit, gitMode, shadowPatterns, workspaceName, portMappings, webPort, noCache, quiet = false, } = opts;
114
+ // Used by `docker start` (resume). Echoes the container id/name to stdout on success; drop stdout to keep
115
+ // that noise out of the session start, but keep stderr so real errors still show.
116
+ const stdio = quiet ? "pipe" : ["ignore", "ignore", "inherit"];
117
+ // --- Published ports fingerprint (static config, no host I/O here) ----------------------------------------------------------------------
118
+ // Ports are declared, not resolved, so the label is a pure function of the config. Host availability is
119
+ // probed just-in-time on the create path (assertHostPortsAvailable), never on resume/connect.
120
+ const currentPortsLabel = portsLabel(portMappings);
121
+ // --- Env fingerprint (inline vars + resolved file contents) -----------------------------------------------------------------------------
122
+ // Covers inline vars and the contents of every existing env file, so editing either recreates the container.
123
+ const currentEnvLabel = envLabel(envConfig);
124
+ // --- Sync shadows and build mount args ------------------------------------------------------------------------------------------------
125
+ ensureShadowsInSync(cacheDir, expandedShadows, workspaceRoot);
126
+ const shadowMountArgs = buildShadowMountArgs(cacheDir, expandedShadows);
127
+ // --- Agent context -------------------------------------------------------------------------------------------------------------------
128
+ const agentDocs = buildAgentContextDocs(hasGit, shadowPatterns, gitMode);
129
+ // --- Env args (env files + inline vars) ----------------------------------------------------------------------------------------------
130
+ const envArgs = envRunArgs(envConfig);
131
+ // --- Build mount args ----------------------------------------------------------------------------------------------------------------
132
+ const agentMounts = buildAgentMountArgs(cacheDir);
133
+ const pnpmStoreMounts = buildPnpmStoreMountArgs(cacheDir);
134
+ // The web interface's own state (which agent new sessions start with) lives on the host side of a
135
+ // mount, so `webterm <agent>` in the container still holds after a recreate.
136
+ const webtermStateMounts = buildWebtermStateMountArgs(cacheDir);
137
+ // The web pane's pages and their feedback, one directory per conversation - mounted so they outlive
138
+ // the container and a resumed conversation opens its own history.
139
+ const webpaneMounts = buildWebpaneMountArgs(cacheDir);
140
+ // Shadow mounts must come AFTER the workspace mount to overlay correctly
141
+ const mountArgs = [
142
+ "-v",
143
+ `${workspaceRoot}:${CONTAINER_WORKSPACE}`,
144
+ ...shadowMountArgs,
145
+ ...agentMounts,
146
+ ...pnpmStoreMounts,
147
+ ...webtermStateMounts,
148
+ ...webpaneMounts,
149
+ ];
150
+ // --- Container labels ----------------------------------------------------------------------------------------------------------------
151
+ const labelArgs = [
152
+ "--label",
153
+ `${LABEL_MANAGED}=true`,
154
+ "--label",
155
+ `${LABEL_SHADOWS}=${shadowLabel(expandedShadows)}`,
156
+ "--label",
157
+ `${LABEL_PROFILE}=${activeProfile}`,
158
+ "--label",
159
+ `${LABEL_RUNTIME_ENV}=${runtimeEnvLabel()}`,
160
+ "--label",
161
+ `${LABEL_GIT_MODE}=${gitMode}`,
162
+ ];
163
+ // --- Runtime env vars -----------------------------------------------------------------------------------------------------------------
164
+ const runtimeEnvArgs = [
165
+ ...Object.entries(RUNTIME_ENV).flatMap(([k, v]) => ["-e", `${k}=${v}`]),
166
+ "-e",
167
+ `CHAMBA_WORKSPACE=${workspaceName}`,
168
+ "-e",
169
+ `CHAMBA_GIT_MODE=${gitMode}`,
170
+ ];
171
+ // --- Inspect container state ---------------------------------------------------------------------------------------------------------
172
+ const info = inspectContainer(containerName);
173
+ let containerStatus = info?.status ?? null;
174
+ // --- Recreate if shadow, profile, runtime env, git mode, or ports changed ------------------------------------------------------------
175
+ if (info !== null) {
176
+ const expectedShadowLabel = shadowLabel(expandedShadows);
177
+ const shadowChanged = info.shadowLabel !== expectedShadowLabel;
178
+ const profileChanged = info.profileLabel !== activeProfile;
179
+ const runtimeEnvChanged = info.runtimeEnvLabel !== runtimeEnvLabel();
180
+ const gitModeChanged = info.gitModeLabel !== gitMode;
181
+ // Both sides are "" when a workspace declares no ports, so a container without published ports never recreates on this label.
182
+ const portsChanged = info.portsLabel !== currentPortsLabel;
183
+ // Both sides are "" when a workspace declares no env, so a container without env injection never recreates on this label.
184
+ const envChanged = info.envLabel !== currentEnvLabel;
185
+ if (shadowChanged || profileChanged || runtimeEnvChanged || gitModeChanged || portsChanged || envChanged) {
186
+ // Describe the change once - reused for the confirm prompt and the recreate log line below.
187
+ let reason;
188
+ if (profileChanged)
189
+ reason = `Profile changed (${info.profileLabel} -> ${activeProfile})`;
190
+ else if (shadowChanged)
191
+ reason = "Shadow paths changed";
192
+ else if (gitModeChanged)
193
+ reason = `Git mode changed (${info.gitModeLabel || "<unset>"} -> ${gitMode})`;
194
+ else if (portsChanged)
195
+ reason = "Ports changed";
196
+ else if (envChanged)
197
+ reason = "Environment variables changed";
198
+ else
199
+ reason = "Runtime environment updated";
200
+ // A running container is a live session. Recreating it stops and removes the container, so confirm
201
+ // before killing it - a config change should not surprise the user mid-session. Exited containers
202
+ // have no live session, so skip the prompt. Non-interactive callers (quiet) keep the auto-recreate.
203
+ const prompted = !quiet && info.status === "running";
204
+ if (prompted) {
205
+ log.warn(`${reason} - applying this needs to recreate the running container, which stops any open sessions.\n` +
206
+ "Agent memory, settings, and workspace data are preserved.");
207
+ const recreate = await confirm({
208
+ message: "Recreate the container now?",
209
+ initialValue: true,
210
+ });
211
+ if (isCancel(recreate) || !recreate) {
212
+ cancel("Session cancelled - container left running.");
213
+ process.exit(0);
214
+ }
215
+ }
216
+ stopAndRemoveContainer(containerName);
217
+ containerStatus = null;
218
+ // Profile change means a different Dockerfile, so drop the image to force a fresh build.
219
+ if (profileChanged)
220
+ spawnSync("docker", ["rmi", containerName], { stdio: "pipe" });
221
+ // The confirm prompt above already stated the reason, so only print the progress line when we
222
+ // did not prompt (an exited container recreated without confirmation).
223
+ if (!quiet && !prompted)
224
+ log.info(`${reason} - ${profileChanged ? "rebuilding" : "recreating container"}...`);
225
+ }
226
+ }
227
+ // Refresh the agent context docs in the cache dir (bind-mounted into the container) once, before any
228
+ // create / resume / connect path below. Idempotent file writes with no dependency on the image build.
229
+ injectAgentContext(cacheDir, agentDocs);
230
+ // What the container ends up publishing for the web interface. Only the create path can change it (by
231
+ // dropping a mapping whose port was taken), and it is returned so the caller stops using the port it
232
+ // asked for. Resume and connect reuse a container whose mapping is already fixed, and any change to it
233
+ // would have recreated the container via the ports label.
234
+ let publishedWebPort = webPort;
235
+ // Build the image (if needed) and run a fresh container. Shared by the no-container path and the
236
+ // resume-recovery path below. Build/run failures are terminal, so they outro and exit here.
237
+ const createAndRun = async () => {
238
+ // The interactive build spinner owns the "rebuilding" message (see buildImageWithTempfile), so no log.step here.
239
+ const dockerfileContent = buildDockerfile(join(templatesDir, "Dockerfile"), profileHook);
240
+ const buildResult = await buildImageWithTempfile(dockerfileContent, templatesDir, containerName, noCache, quiet);
241
+ if (buildResult.status !== 0) {
242
+ if (!quiet)
243
+ outro("Failed to build container image.");
244
+ process.exit(buildResult.status);
245
+ }
246
+ // Minutes can pass between run() probing the web port and this create path (profile selection, a
247
+ // recreate confirm, the image build), so re-probe it here. A port taken in the meantime drops the
248
+ // mapping and the session continues without the interface. Only chamba.yaml ports are
249
+ // allowed to fail a session, which is what assertHostPortsAvailable below is for.
250
+ let published = portMappings;
251
+ if (webPort !== undefined && !(await webPortUsable(webPort, containerName))) {
252
+ if (!quiet) {
253
+ log.warn(`Web interface: host port ${webPort} was taken before the container could start - continuing without it.`);
254
+ }
255
+ published = portMappings.filter((m) => m.host !== webPort);
256
+ publishedWebPort = undefined;
257
+ }
258
+ // Ports are static config, so probe host availability up front. The old container was already removed on
259
+ // every path that reaches here, so we never probe our own live port. A taken host port fails clearly and
260
+ // names the entry, before docker run - so no doomed `created` container is left behind on a clash.
261
+ try {
262
+ await assertHostPortsAvailable(published, publishedWebPort);
263
+ }
264
+ catch (err) {
265
+ if (!quiet)
266
+ outro(err instanceof Error ? err.message : String(err));
267
+ process.exit(1);
268
+ }
269
+ if (!quiet)
270
+ log.info("Starting dev container...");
271
+ // Only set when the web port survived the re-probe above: this gates the greeting's URL line and the
272
+ // webterm launcher. Built here rather than with the other runtime env so a dropped mapping cannot
273
+ // leave a URL behind that nothing is listening on.
274
+ // Never stale either way: the web mapping is part of the ports label written just below, so a moved or
275
+ // dropped port recreates the container and rebuilds this. No dedicated label needed for the same reason.
276
+ const webEnvArgs = publishedWebPort !== undefined ? ["-e", `CHAMBA_WEB_URL=${webInterfaceUrl(publishedWebPort, null)}`] : [];
277
+ // portEnvArgs come after envArgs/runtimeEnvArgs/webEnvArgs so the published value wins any -e collision.
278
+ const runArgs = [
279
+ "run",
280
+ "-d",
281
+ "--name",
282
+ containerName,
283
+ ...mountArgs,
284
+ ...envArgs,
285
+ ...runtimeEnvArgs,
286
+ ...webEnvArgs,
287
+ ...portEnvArgs(published),
288
+ ...portPublishArgs(published),
289
+ "--security-opt",
290
+ "no-new-privileges:true",
291
+ ...labelArgs,
292
+ "--label",
293
+ // Labelled with what was actually published, not what was planned, so the next session compares
294
+ // like for like: a dropped web mapping recreates once when the port comes back, not every start.
295
+ `${LABEL_PORTS}=${portsLabel(published)}`,
296
+ "--label",
297
+ `${LABEL_ENV}=${currentEnvLabel}`,
298
+ containerName,
299
+ ...CONTAINER_KEEP_ALIVE,
300
+ ];
301
+ // Capture stderr so the real docker error is re-emitted on failure. A single run - the pre-flight probe
302
+ // above already rejected any taken host port, so there is no port-race retry loop.
303
+ const runStdio = quiet ? "pipe" : ["ignore", "ignore", "pipe"];
304
+ const runResult = spawnSync("docker", runArgs, { stdio: runStdio });
305
+ if (runResult.status !== 0) {
306
+ if (!quiet) {
307
+ process.stderr.write(runResult.stderr?.toString() ?? "");
308
+ outro("Failed to start dev container.");
309
+ }
310
+ process.exit(runResult.status ?? 1);
311
+ }
312
+ };
313
+ const result = (status) => ({ status, webPort: publishedWebPort ?? null });
314
+ if (containerStatus === null) {
315
+ // --- No container - build image and run --------------------------------------------------------------------------------------------
316
+ await createAndRun();
317
+ return result("created");
318
+ }
319
+ else if (containerStatus === "exited") {
320
+ // --- Container stopped - resume (recreate on a dangling-mount failure) -------------------------------------------------------------
321
+ if (!quiet)
322
+ log.info("Resuming dev container...");
323
+ const start = spawnSync("docker", ["start", containerName], { stdio });
324
+ if (start.status !== 0) {
325
+ // A resume reuses the bind mounts frozen at create time. When one no longer resolves on the host
326
+ // - a moved or renamed workspace directory, a deleted env file, a relocated cache dir - docker
327
+ // start fails. stderr is inherited, not captured, so do not parse the daemon error; treat any
328
+ // resume failure as recreate-worthy. Recreating rebinds every mount against current paths; agent
329
+ // memory, settings, and workspace data live in host bind mounts and cache dirs outside the
330
+ // container fs, so they survive. Non-interactive callers keep the hard fail.
331
+ if (quiet)
332
+ process.exit(start.status ?? 1); // Non-interactive: preserve the original silent hard fail.
333
+ log.warn("This container could not start - a host path it was created against has likely moved or been removed\n" +
334
+ " (for example the workspace directory was renamed).");
335
+ const recreate = await confirm({
336
+ message: "Recreate it now? Your agent memory, settings, and workspace data are preserved.",
337
+ initialValue: true,
338
+ });
339
+ if (isCancel(recreate) || !recreate) {
340
+ outro("Failed to start dev container.");
341
+ process.exit(start.status ?? 1);
342
+ }
343
+ stopAndRemoveContainer(containerName);
344
+ await createAndRun();
345
+ return result("created");
346
+ }
347
+ return result("resumed");
348
+ }
349
+ else {
350
+ // --- Container running - connect ---------------------------------------------------------------------------------------------------
351
+ return result("connected");
352
+ }
353
+ }
354
+ /**
355
+ * Start the web interface and confirm it really came up. The browser is where the agent runs, so a launch
356
+ * that failed silently would leave the user with an advertised URL that never answers and no agent
357
+ * anywhere. Nothing is started in its place: the message says what to run, and the choice is the user's.
358
+ * `workdir` is where its sessions open - the same directory the terminal session below lands in.
359
+ */
360
+ async function launchWebInterface(containerName, agent, webPort, workdir) {
361
+ if (await startWebtermAndVerify(containerName, agent, webPort, workdir))
362
+ return;
363
+ log.warn(`Web interface: the server did not come up on port ${webPort}, so the URL in the greeting will not answer.\n` +
364
+ ` Run \`webterm ${agent}\` in the container to see why, or just run \`${agent}\` in the terminal.`);
365
+ }
366
+ export async function run(packageDir, ctx, options) {
367
+ const mode = options?.mode ?? "web";
368
+ const cwd = process.cwd();
369
+ const workspaceDir = ctx.workspaceRoot;
370
+ const containerName = ctx.containerName;
371
+ const cacheDir = ctx.workspaceDir;
372
+ const templatesDir = join(packageDir, "templates");
373
+ // --- Read chamba.yaml ----------------------------------------------------------------------------------------------------------------
374
+ const yaml = readChambaYaml(workspaceDir);
375
+ if (!yaml) {
376
+ log.error("chamba.yaml not found or invalid.");
377
+ process.exit(1);
378
+ }
379
+ // --- Validate and normalize ports config (rules the schema cannot express) -----------------------------------------------------------
380
+ let portMappings;
381
+ try {
382
+ portMappings = validatePortsConfig(yaml.ports ?? []);
383
+ }
384
+ catch (err) {
385
+ log.error(err instanceof Error ? err.message : String(err));
386
+ process.exit(1);
387
+ }
388
+ // --- Web agent interface port (sticky per-workspace assignment) ----------------------------------------------------------------------
389
+ // Resolved on every session: the interface has no off switch, it is where the agent runs.
390
+ // Pushing the mapping into portMappings is the whole wiring: publish args, the availability probe,
391
+ // and the LABEL_PORTS recreate fingerprint all pick it up with no arg-builder changes.
392
+ // A session still opens without it, so every problem below drops it for this session and says why - it
393
+ // must never fail a session the way a chamba.yaml port does. The sticky assignment is left alone in all
394
+ // cases, so the next session picks the same port back up once whatever is in the way is gone.
395
+ const range = readWebRange();
396
+ // What disqualifies a candidate port: this workspace already publishes it from chamba.yaml, or
397
+ // something on the host holds it. Both move the sticky assignment rather than skipping the session -
398
+ // the port sticks to wherever it was last put, so the workspace settles on one that works instead of
399
+ // repeating the same conflict at every start. (A chamba.yaml entry can no longer target the
400
+ // container-side web port at all: validatePortsConfig reserves it.)
401
+ const declaredHosts = new Set(portMappings.map((m) => m.host));
402
+ const webPortUsableHere = async (port) => !declaredHosts.has(port) && (await webPortUsable(port, containerName));
403
+ const resolvedWebPort = await resolveWebPort(ctx.workspaceId, range, webPortUsableHere);
404
+ let webPort = null;
405
+ if (resolvedWebPort.ok) {
406
+ if (resolvedWebPort.movedFrom !== undefined) {
407
+ log.info(`Web interface: port ${resolvedWebPort.movedFrom} was not available - moved to ${resolvedWebPort.port} and kept there.`);
408
+ }
409
+ webPort = resolvedWebPort.port;
410
+ portMappings.push({ host: webPort, container: WEB_CONTAINER_PORT });
411
+ }
412
+ else if (resolvedWebPort.reason === "exhausted") {
413
+ log.warn(`Web interface: no free port left in ${formatWebRange(range)} - web interface skipped this session.\n` +
414
+ " Widen the range in Settings > Ports.");
415
+ }
416
+ else {
417
+ log.warn("Web interface: the port assignment could not be recorded for this workspace - web interface skipped this session.\n" +
418
+ " Check that the workspace cache dir is writable.");
419
+ }
420
+ // --- Resolve working directory -------------------------------------------------------------------------------------------------------
421
+ const workdir = resolveWorkdir(workspaceDir, cwd);
422
+ // --- Profile selection ---------------------------------------------------------------------------------------------------------------
423
+ const profiles = yaml.profiles ?? {};
424
+ const activeProfile = await selectProfile(ctx, profiles);
425
+ const profileConfig = profiles[activeProfile];
426
+ const profileHook = profileConfig?.dockerfile_hook;
427
+ // --- Shadow path expansion -----------------------------------------------------------------------------------------------------------
428
+ const shadowPatterns = yaml.shadow_paths ?? [];
429
+ const { paths: expandedShadows, skippedTracked } = expandShadowPatterns(shadowPatterns, workspaceDir);
430
+ if (expandedShadows.length > 0) {
431
+ log.info(`Shadow paths active: ${expandedShadows.join(", ")} (Settings > Shadow paths)`);
432
+ }
433
+ if (skippedTracked.length > 0) {
434
+ log.warn(`Skipped ${skippedTracked.length} shadow path(s) tracked by git`);
435
+ }
436
+ // --- Env (env files + inline vars) ---------------------------------------------------------------------------------------------------
437
+ let envConfig;
438
+ try {
439
+ envConfig = validateEnvConfig(yaml.env, workspaceDir);
440
+ }
441
+ catch (err) {
442
+ log.error(err instanceof Error ? err.message : String(err));
443
+ process.exit(1);
444
+ }
445
+ for (const warning of envWarnings(envConfig)) {
446
+ log.warn(warning);
447
+ }
448
+ const hasGit = existsSync(join(workspaceDir, ".git"));
449
+ // --- Git mode (per-workspace, host-side .lock) ---------------------------------------------------------------------------------------
450
+ const gitMode = readGitMode(ctx.workspaceId) ?? GIT_MODE.local;
451
+ // --- Start container -----------------------------------------------------------------------------------------------------------------
452
+ const containerOpts = {
453
+ containerName,
454
+ workspaceRoot: workspaceDir,
455
+ cacheDir,
456
+ templatesDir,
457
+ activeProfile,
458
+ profileHook,
459
+ expandedShadows,
460
+ envConfig,
461
+ hasGit,
462
+ gitMode,
463
+ shadowPatterns,
464
+ workspaceName: ctx.workspaceId,
465
+ portMappings,
466
+ ...(webPort !== null && { webPort }),
467
+ ...(options?.noCache !== undefined && { noCache: options.noCache }),
468
+ };
469
+ let startResult = await startContainer(containerOpts);
470
+ // The create path re-probes the web port and drops it when something took it while the image built, so
471
+ // from here on the published port is the only one worth talking to.
472
+ webPort = startResult.webPort;
473
+ // --- Stale image check - prompt user to rebuild if image is outdated ------------------------------------------------------------------
474
+ const dockerfileContent = buildDockerfile(join(templatesDir, "Dockerfile"), profileHook);
475
+ const expectedBuildHash = computeBuildHash(dockerfileContent, templatesDir);
476
+ let stale = isImageStale(containerName, expectedBuildHash);
477
+ if (stale) {
478
+ log.warn("This container's image is out of date.\nPlease rebuild to update - this will not affect agent memory, settings, or your data.");
479
+ const rebuild = await confirm({
480
+ message: "Rebuild now? (Recommended)",
481
+ initialValue: true,
482
+ });
483
+ if (isCancel(rebuild)) {
484
+ cancel("Session cancelled.");
485
+ process.exit(0);
486
+ }
487
+ if (rebuild) {
488
+ stopAndRemoveContainer(containerName);
489
+ spawnSync("docker", ["rmi", containerName], { stdio: "pipe" });
490
+ startResult = await startContainer(containerOpts);
491
+ webPort = startResult.webPort;
492
+ stale = false;
493
+ }
494
+ }
495
+ // --- Published ports notice (every session start: created / resumed / connected) -----------------------------------------------------
496
+ // Ports are static config, so the notice derives straight from the mappings - no .lock lookup needed.
497
+ // The web mapping is skipped here: its URL is announced by the container greeting, where it is actionable.
498
+ // Matched on the container port, which validatePortsConfig reserves - so it can only ever be chamba's own
499
+ // web mapping, and it is skipped whether or not the port survived the create path's re-probe.
500
+ for (const m of portMappings) {
501
+ if (m.container === WEB_CONTAINER_PORT)
502
+ continue;
503
+ log.info(formatPortNotice(m));
504
+ }
505
+ // --- Startup checks (AI CLI update + readiness validation) ----------------------------------------------------------------------------
506
+ if (!runStartup(containerName, stale)) {
507
+ if (stale) {
508
+ const connect = await confirm({
509
+ message: "Startup checks failed (likely due to outdated image). Connect anyway?",
510
+ initialValue: true,
511
+ });
512
+ if (!connect || isCancel(connect)) {
513
+ cancel("Session cancelled.");
514
+ process.exit(0);
515
+ }
516
+ }
517
+ else {
518
+ outro("Startup checks failed.");
519
+ process.exit(1);
520
+ }
521
+ }
522
+ // --- Once-per-container-start hooks (resume marker + web interface launch) -----------------------------------------------------------
523
+ // Deliberately after the startup checks above: those update the AI CLIs inside the container, and the
524
+ // interface spawns its agent the moment it comes up. Started any earlier, the browser would get a session
525
+ // running the version the image was built with - a claude too old to know the current models - and the only
526
+ // way out would be to wait for the update and start another session. The terminal never had this problem:
527
+ // the login shell attaches below, after the update.
528
+ //
529
+ // Runs on the created AND resumed (docker start) paths, never on connect to an already-running container -
530
+ // that is what makes the resume once-per-start. The marker is planted before the user's shell attaches,
531
+ // so the first web session to come up consumes it and resumes the most recent conversation; later
532
+ // sessions start fresh. Which agent it resumes is the one this workspace's web sessions start with,
533
+ // which is also the agent the interface is launched with.
534
+ const webAgent = readWebAgent(cacheDir);
535
+ if (startResult.status !== "connected") {
536
+ // No marker when there is nothing to resume: the command would fail and take the session with it,
537
+ // so the first session starts the agent fresh instead.
538
+ const resumeCommand = resumeCommandFor(webAgent, cacheDir, workdir);
539
+ if (resumeCommand !== null)
540
+ plantResumeMarker(containerName, resumeCommand);
541
+ if (webPort !== null)
542
+ await launchWebInterface(containerName, webAgent, webPort, workdir);
543
+ }
544
+ else if (webPort !== null) {
545
+ // Connecting to a container that is already up: the interface it started with should still be
546
+ // serving. When it is not (crashed, or stopped by hand) the greeting would advertise a URL that
547
+ // does not answer, so start it again. No resume marker here - this is not a container start, so
548
+ // the relaunched interface opens a fresh conversation rather than re-resuming an old one.
549
+ if (!(await webInterfaceAnswers(webPort))) {
550
+ await launchWebInterface(containerName, webAgent, webPort, workdir);
551
+ }
552
+ else {
553
+ // The interface has been serving since an earlier session, so the directory it was started with
554
+ // is that session's, not this one's. Move it, so the browser opens new sessions where this
555
+ // session was started from - the same promise the terminal below keeps.
556
+ await setWebDefaultCwd(webPort, readWebKey(containerName), workdir);
557
+ }
558
+ }
559
+ // --- Web mode: open the interface in the host's browser and hand the terminal back -----------------------------------------------------
560
+ // After the block above on every path, connect included: a healthy interface never reaches the launcher,
561
+ // and the browser should still open. The key is read back from the container fresh - nothing on the host
562
+ // stores one - and checked for shape before it becomes part of a URL handed to another program.
563
+ // Best-effort throughout: a host with no opener (SSH, headless) gets the URL printed instead.
564
+ // Nothing attaches here. The container and the interface keep running, and this process is done - the
565
+ // work happens in the browser, which is the whole point of the web door.
566
+ if (mode === "web") {
567
+ const key = webPort === null ? null : readWebKey(containerName);
568
+ if (webPort !== null && isWebKey(key)) {
569
+ const url = webInterfaceUrl(webPort, key);
570
+ if (openInBrowser(url))
571
+ log.info(`Web interface: opened in your browser\n ${url}`);
572
+ else
573
+ log.info(`Web interface: open this in your browser\n ${url}`);
574
+ outro("The container and the interface keep running. Run chamba again for the menu.");
575
+ }
576
+ else {
577
+ // No usable key or no port means no interface answered, which the block above has already
578
+ // reported; there is no URL worth handing out, since one without the key is refused at the door.
579
+ // Say so plainly rather than exiting silently - this door had one job.
580
+ log.warn('Web interface: there is nothing to open (see above).\n Pick "Container shell" from the menu to work in the terminal instead.');
581
+ outro("The container is running.");
582
+ }
583
+ return;
584
+ }
585
+ // --- Shell mode: connect -------------------------------------------------------------------------------------------------------------
586
+ const exec = spawnSync("docker", loginShellExecArgs(workdir, containerName), {
587
+ stdio: "inherit",
588
+ });
589
+ // --- Offer to stop this workspace's container (last shell closed) --------------------------------------------------------------------
590
+ // The container itself keeps running (CONTAINER_KEEP_ALIVE is PID 1) after the shell exits. When this was the last
591
+ // shell to it, offer to stop it to free memory. Stop-only (no rm) so the next session resumes fast
592
+ // via the "exited" -> docker start path; all platforms.
593
+ // Agent sessions in the web interface are live conversations the shell scan cannot see - they run
594
+ // inside the container, with no host client process - and they end with the container. So when the
595
+ // interface reports any, the prompt says how many and defaults to keeping the container; stopping it
596
+ // is still offered, because those sessions may be finished ones nobody has closed yet.
597
+ if (containerSessionCount(containerName) === 0) {
598
+ // The counts are behind the interface's key, so it is read from the container first - fresh, because
599
+ // a new one is minted at every server start and nothing on the host keeps one.
600
+ const webSessions = (webPort === null ? null : await webSessionInfo(webPort, readWebKey(containerName)))?.sessions ?? 0;
601
+ if (webSessions > 0) {
602
+ const count = webSessions === 1 ? "1 agent session is" : `${webSessions} agent sessions are`;
603
+ const them = webSessions === 1 ? "it" : "them";
604
+ log.warn(`Web interface: ${count} open in the browser - stopping the container ends ${them}.`);
605
+ }
606
+ const stopNow = await confirm({
607
+ message: "Last session to this container closed. Stop it? (resumes fast)",
608
+ initialValue: webSessions === 0,
609
+ });
610
+ if (!isCancel(stopNow) && stopNow) {
611
+ log.info("Stopping container...");
612
+ spawnSync("docker", ["stop", containerName], { stdio: "pipe" });
613
+ log.info("Container stopped.");
614
+ }
615
+ }
616
+ // Trailing blank line so the last log does not sit flush against the next shell prompt.
617
+ process.stdout.write("\n");
618
+ process.exit(exec.status ?? 0);
619
+ }
@@ -0,0 +1,29 @@
1
+ // =========================================================================================================================================
2
+ // src/commands/doctor.ts - Host readiness check for chamba
3
+ // Computes readiness checks and returns any failures; the caller decides what to print.
4
+ // =========================================================================================================================================
5
+ import { spawnSync } from "node:child_process";
6
+ // Returns true if the given CLI tool is resolvable in the system PATH
7
+ function commandExists(cmd) {
8
+ // Single command string (not an args array) under shell: true sidesteps Node's DEP0190; cmd is a hardcoded internal name.
9
+ const r = spawnSync(`command -v ${cmd}`, {
10
+ shell: true,
11
+ encoding: "utf8",
12
+ });
13
+ return r.status === 0;
14
+ }
15
+ export async function run() {
16
+ const errors = [];
17
+ function check(label, ok, detail) {
18
+ if (!ok)
19
+ errors.push(`${label}${detail ? `: ${detail}` : ""}`);
20
+ }
21
+ // --- Docker installed ----------------------------------------------------------------------------------------------------------------
22
+ const hasDocker = commandExists("docker");
23
+ check("Docker installed", hasDocker, hasDocker ? undefined : "'docker' not found in PATH");
24
+ // --- Docker running ------------------------------------------------------------------------------------------------------------------
25
+ const dockerInfo = spawnSync("docker", ["info"], { encoding: "utf8", stdio: "pipe" });
26
+ check("Docker running", dockerInfo.status === 0, dockerInfo.status === 0 ? undefined : "Docker daemon not responding");
27
+ // --- Report --------------------------------------------------------------------------------------------------------------------------
28
+ return { ok: errors.length === 0, errors };
29
+ }