chamba 0.7.0 → 0.8.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/LICENSE +53 -14
- package/README.md +28 -8
- package/dist/commands/advanced.js +7 -278
- package/dist/commands/dev.js +11 -612
- package/dist/commands/doctor.js +1 -29
- package/dist/commands/menu.js +1 -80
- package/dist/commands/onboard.js +6 -230
- package/dist/commands/settings.js +15 -349
- package/dist/lib/agent-commands.js +2 -0
- package/dist/lib/agent-context.js +6 -210
- package/dist/lib/browser.js +1 -40
- package/dist/lib/chamba-yaml.js +10 -191
- package/dist/lib/constants.js +1 -125
- package/dist/lib/dockerfile-builder.js +41 -261
- package/dist/lib/env.js +2 -78
- package/dist/lib/git-env.js +1 -21
- package/dist/lib/global-config.js +4 -66
- package/dist/lib/pnpm-store.js +1 -19
- package/dist/lib/ports.js +1 -210
- package/dist/lib/safe-rm.js +1 -36
- package/dist/lib/sessions.js +2 -34
- package/dist/lib/shadows.js +1 -176
- package/dist/lib/skills.js +2 -0
- package/dist/lib/templates.js +1 -0
- package/dist/lib/webterm.js +1 -305
- package/dist/lib/workspace-identity.js +4 -260
- package/package.json +4 -4
- package/templates/Dockerfile +12 -19
- package/templates/claude-statusline.sh +5 -7
- package/templates/context/baseline.md +2 -0
- package/templates/pane-apps/client/assets/c-BNa-DeUl.json +3552 -0
- package/templates/pane-apps/client/assets/cpp-B-T4NRoH.json +20239 -0
- package/templates/pane-apps/client/assets/csharp-BbxHChhA.json +6010 -0
- package/templates/pane-apps/client/assets/css-CWCxuwmz.json +1860 -0
- package/templates/pane-apps/client/assets/diff-B-2N89g8.json +154 -0
- package/templates/pane-apps/client/assets/docker-DWTvYW26.json +103 -0
- package/templates/pane-apps/client/assets/files-D0nJgFAA.js +12 -0
- package/templates/pane-apps/client/assets/files-h0K1vZyQ.css +1 -0
- package/templates/pane-apps/client/assets/go-B2hyYeNg.json +3156 -0
- package/templates/pane-apps/client/assets/graphql-Z6Id2aNO.json +1292 -0
- package/templates/pane-apps/client/assets/html-BtzvlIfu.json +2627 -0
- package/templates/pane-apps/client/assets/ini-Bn3Twap1.json +108 -0
- package/templates/pane-apps/client/assets/java-C7t2dwLO.json +1885 -0
- package/templates/pane-apps/client/assets/javascript-DuC2v0C0.json +5996 -0
- package/templates/pane-apps/client/assets/json-CwdkWeV5.json +206 -0
- package/templates/pane-apps/client/assets/jsonc-MYWDbTU3.json +206 -0
- package/templates/pane-apps/client/assets/jsx-Cn8EZuIC.json +5996 -0
- package/templates/pane-apps/client/assets/kotlin-CFNOz1gw.json +580 -0
- package/templates/pane-apps/client/assets/make-BVnlFCcl.json +629 -0
- package/templates/pane-apps/client/assets/markdown-B4Xk2WM_.json +3263 -0
- package/templates/pane-apps/client/assets/php-CAQzQmmI.json +4188 -0
- package/templates/pane-apps/client/assets/python-CYQee8m4.json +4174 -0
- package/templates/pane-apps/client/assets/reviews-BsoGZ35r.css +1 -0
- package/templates/pane-apps/client/assets/reviews-iW0Mbn4e.js +153 -0
- package/templates/pane-apps/client/assets/ruby-D7R58tus.json +3114 -0
- package/templates/pane-apps/client/assets/rust-BFBbFiei.json +1103 -0
- package/templates/pane-apps/client/assets/scss-TQRhBkPG.json +1874 -0
- package/templates/pane-apps/client/assets/shellscript-DieAkC4W.json +2375 -0
- package/templates/pane-apps/client/assets/specs-D9z3neR-.js +13 -0
- package/templates/pane-apps/client/assets/sql-Bxd030LV.json +634 -0
- package/templates/pane-apps/client/assets/toml-ChrKGzsV.json +430 -0
- package/templates/pane-apps/client/assets/tsx-D2e90MFw.json +5996 -0
- package/templates/pane-apps/client/assets/typescript-DonXQ_n1.json +5747 -0
- package/templates/pane-apps/client/assets/xml-B17r2RXF.json +382 -0
- package/templates/pane-apps/client/assets/yaml-CKn4h1zy.json +627 -0
- package/templates/pane-apps/client/reviews/index.html +15 -0
- package/templates/pane-apps/client/specs/index.html +3 -2
- package/templates/pane-apps/server/reviews.mjs +20 -0
- package/templates/pane-apps/server/specs.mjs +5 -1588
- package/templates/skills/chamba-statusline/SKILL.md +7 -19
- package/templates/skills/dx-review/SKILL.md +123 -0
- package/templates/skills/dx-review/references/acts.md +162 -0
- package/templates/skills/dx-spec/SKILL.md +223 -27
- package/templates/skills/dx-spec/references/principles-template.md +2 -0
- package/templates/skills/dx-spec/references/review-guide.md +4 -50
- package/templates/skills/dx-spec/references/spec-guide.md +24 -4
- package/templates/skills/dx-spec/references/stages.md +143 -58
- package/templates/skills/dx-spec-execute/SKILL.md +148 -35
- package/templates/startup.mjs +10 -4
- package/templates/tool-helper.sh +166 -0
- package/templates/webterm/README.md +60 -26
- package/templates/webterm/color.js +61 -0
- package/templates/webterm/config.js +35 -4
- package/templates/webterm/context/claude.md +0 -1
- package/templates/webterm/public/app/alerts.js +4 -4
- package/templates/webterm/public/app/anchored.js +81 -0
- package/templates/webterm/public/app/connection.js +9 -0
- package/templates/webterm/public/app/dom.js +2 -0
- package/templates/webterm/public/app/frames.js +51 -17
- package/templates/webterm/public/app/main.js +22 -8
- package/templates/webterm/public/app/new-session.js +13 -41
- package/templates/webterm/public/app/palette.js +58 -0
- package/templates/webterm/public/app/pane-shape.js +167 -0
- package/templates/webterm/public/app/pane-shell.js +199 -134
- package/templates/webterm/public/app/pane.js +33 -24
- package/templates/webterm/public/app/reviews-host.js +15 -0
- package/templates/webterm/public/app/specs-host.js +12 -218
- package/templates/webterm/public/app/state.js +5 -0
- package/templates/webterm/public/app/tabs.js +6 -2
- package/templates/webterm/public/app/theme.js +75 -27
- package/templates/webterm/public/app/tool-host.js +302 -0
- package/templates/webterm/public/app/workspace-color.js +192 -0
- package/templates/webterm/public/index.html +17 -3
- package/templates/webterm/public/styles.css +143 -21
- package/templates/webterm/server.js +198 -150
- package/templates/webterm/tools/commands.js +61 -0
- package/templates/webterm/tools/index.js +25 -0
- package/templates/webterm/tools/mount.js +337 -0
- package/templates/webterm/tools/paths.js +34 -0
- package/templates/webterm/tools/reviews.js +133 -0
- package/templates/webterm/tools/specs.js +154 -0
- package/templates/webterm/typed-line.js +70 -7
- package/templates/context/context-usage.md +0 -1
- package/templates/context-usage.sh +0 -266
- package/templates/pane-apps/client/assets/specs-B1970L17.css +0 -1
- package/templates/pane-apps/client/assets/specs-cEee_SPn.js +0 -23
- package/templates/skills/dx-spec-config/SKILL.md +0 -313
- package/templates/skills/dx-spec-config/references/principles-template.md +0 -12
- package/templates/specs.sh +0 -106
- package/templates/webterm/specs.js +0 -358
package/dist/commands/dev.js
CHANGED
|
@@ -1,612 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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, readWebAgent, readWebKey, resolveWebPort, 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
|
-
// checked 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() checking the web port and this create path (profile selection, a
|
|
247
|
-
// recreate confirm, the image build), so re-check 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 check host availability up front. The old container was already removed on
|
|
259
|
-
// every path that reaches here, so we never check 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-check 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 check
|
|
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 check,
|
|
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-checks 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-check.
|
|
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 hook (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
|
-
// Reopening the last conversation is not decided here. The container's own server does it, for the first
|
|
530
|
-
// session of a container start, reading the agent stores from the side of the mount that owns them
|
|
531
|
-
// (templates/webterm/resume.js). Which agent gets the offer is the one this workspace's web sessions start
|
|
532
|
-
// with, which is also the agent the interface is launched with.
|
|
533
|
-
const webAgent = readWebAgent(cacheDir);
|
|
534
|
-
if (startResult.status !== "connected") {
|
|
535
|
-
if (webPort !== null)
|
|
536
|
-
await launchWebInterface(containerName, webAgent, webPort, workdir);
|
|
537
|
-
}
|
|
538
|
-
else if (webPort !== null) {
|
|
539
|
-
// Connecting to a container that is already up: the interface it started with should still be
|
|
540
|
-
// serving. When it is not (crashed, or stopped by hand) the greeting would advertise a URL that
|
|
541
|
-
// does not answer, so start it again.
|
|
542
|
-
if (!(await webInterfaceAnswers(webPort))) {
|
|
543
|
-
await launchWebInterface(containerName, webAgent, webPort, workdir);
|
|
544
|
-
}
|
|
545
|
-
else {
|
|
546
|
-
// The interface has been serving since an earlier session, so the directory it was started with
|
|
547
|
-
// is that session's, not this one's. Move it, so the browser opens new sessions where this
|
|
548
|
-
// session was started from - the same promise the terminal below keeps.
|
|
549
|
-
await setWebDefaultCwd(webPort, readWebKey(containerName), workdir);
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
// --- Web mode: open the interface in the host's browser and hand the terminal back -----------------------------------------------------
|
|
553
|
-
// After the block above on every path, connect included: a healthy interface never reaches the launcher,
|
|
554
|
-
// and the browser should still open. The key is read back from the container fresh - nothing on the host
|
|
555
|
-
// stores one - and checked for shape before it becomes part of a URL handed to another program.
|
|
556
|
-
// Best-effort throughout: a host with no opener (SSH, headless) gets the URL printed instead.
|
|
557
|
-
// Nothing attaches here. The container and the interface keep running, and this process is done - the
|
|
558
|
-
// work happens in the browser, which is the whole point of the web door.
|
|
559
|
-
if (mode === "web") {
|
|
560
|
-
const key = webPort === null ? null : readWebKey(containerName);
|
|
561
|
-
if (webPort !== null && isWebKey(key)) {
|
|
562
|
-
const url = webInterfaceUrl(webPort, key);
|
|
563
|
-
if (openInBrowser(url))
|
|
564
|
-
log.info(`Web interface: opened in your browser\n ${url}`);
|
|
565
|
-
else
|
|
566
|
-
log.info(`Web interface: open this in your browser\n ${url}`);
|
|
567
|
-
outro("The container and the interface keep running. Run chamba again for the menu.");
|
|
568
|
-
}
|
|
569
|
-
else {
|
|
570
|
-
// No usable key or no port means no interface answered, which the block above has already
|
|
571
|
-
// reported; there is no URL worth handing out, since one without the key is refused at the door.
|
|
572
|
-
// Say so plainly rather than exiting silently - this door had one job.
|
|
573
|
-
log.warn('Web interface: there is nothing to open (see above).\n Pick "Container shell" from the menu to work in the terminal instead.');
|
|
574
|
-
outro("The container is running.");
|
|
575
|
-
}
|
|
576
|
-
return;
|
|
577
|
-
}
|
|
578
|
-
// --- Shell mode: connect -------------------------------------------------------------------------------------------------------------
|
|
579
|
-
const exec = spawnSync("docker", loginShellExecArgs(workdir, containerName), {
|
|
580
|
-
stdio: "inherit",
|
|
581
|
-
});
|
|
582
|
-
// --- Offer to stop this workspace's container (last shell closed) --------------------------------------------------------------------
|
|
583
|
-
// The container itself keeps running (CONTAINER_KEEP_ALIVE is PID 1) after the shell exits. When this was the last
|
|
584
|
-
// shell to it, offer to stop it to free memory. Stop-only (no rm) so the next session resumes fast
|
|
585
|
-
// via the "exited" -> docker start path; all platforms.
|
|
586
|
-
// Agent sessions in the web interface are live conversations the shell scan cannot see - they run
|
|
587
|
-
// inside the container, with no host client process - and they end with the container. So when the
|
|
588
|
-
// interface reports any, the prompt says how many and defaults to keeping the container; stopping it
|
|
589
|
-
// is still offered, because those sessions may be finished ones nobody has closed yet.
|
|
590
|
-
if (containerSessionCount(containerName) === 0) {
|
|
591
|
-
// The counts are behind the interface's key, so it is read from the container first - fresh, because
|
|
592
|
-
// a new one is created at every server start and nothing on the host keeps one.
|
|
593
|
-
const webSessions = (webPort === null ? null : await webSessionInfo(webPort, readWebKey(containerName)))?.sessions ?? 0;
|
|
594
|
-
if (webSessions > 0) {
|
|
595
|
-
const count = webSessions === 1 ? "1 agent session is" : `${webSessions} agent sessions are`;
|
|
596
|
-
const them = webSessions === 1 ? "it" : "them";
|
|
597
|
-
log.warn(`Web interface: ${count} open in the browser - stopping the container ends ${them}.`);
|
|
598
|
-
}
|
|
599
|
-
const stopNow = await confirm({
|
|
600
|
-
message: "Last session to this container closed. Stop it? (resumes fast)",
|
|
601
|
-
initialValue: webSessions === 0,
|
|
602
|
-
});
|
|
603
|
-
if (!isCancel(stopNow) && stopNow) {
|
|
604
|
-
log.info("Stopping container...");
|
|
605
|
-
spawnSync("docker", ["stop", containerName], { stdio: "pipe" });
|
|
606
|
-
log.info("Container stopped.");
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
// Trailing blank line so the last log does not sit flush against the next shell prompt.
|
|
610
|
-
process.stdout.write("\n");
|
|
611
|
-
process.exit(exec.status ?? 0);
|
|
612
|
-
}
|
|
1
|
+
import{spawnSync as A}from"node:child_process";import{createHash as ve}from"node:crypto";import{existsSync as Le}from"node:fs";import{join as j,relative as Ce}from"node:path";import{cancel as G,confirm as O,isCancel as W,log as r,outro as E,select as Ee}from"@clack/prompts";import{buildAgentContextDocs as Se,buildAgentMountArgs as ye,injectAgentContext as Pe}from"../lib/agent-context.js";import{openInBrowser as Re}from"../lib/browser.js";import{readChambaYaml as xe}from"../lib/chamba-yaml.js";import{CONTAINER_KEEP_ALIVE as Me,CONTAINER_STARTUP as We,CONTAINER_WORKSPACE as X,DEFAULT_PROFILE as re,GIT_MODE as _e,LABEL_ENV as ie,LABEL_GIT_MODE as se,LABEL_MANAGED as Ie,LABEL_PORTS as ae,LABEL_PROFILE as ce,LABEL_RUNTIME_ENV as le,LABEL_SHADOWS as de,LABEL_TIMEZONE as ue,RUNTIME_ENV as fe,WEB_CONTAINER_PORT as pe}from"../lib/constants.js";import{buildDockerfile as me,buildImageWithTempfile as Te,computeBuildHash as Ne,isImageStale as Oe}from"../lib/dockerfile-builder.js";import{envLabel as De,envRunArgs as Be,envWarnings as He,validateEnvConfig as ze}from"../lib/env.js";import{readWebRange as Fe}from"../lib/global-config.js";import{buildPnpmStoreMountArgs as Ue}from"../lib/pnpm-store.js";import{assertHostPortsAvailable as Ve,formatPortNotice as je,formatWebRange as Ge,portEnvArgs as Ze,portPublishArgs as Ke,portsLabel as he,validatePortsConfig as Ye}from"../lib/ports.js";import{containerSessionCount as qe,loginShellExecArgs as Je}from"../lib/sessions.js";import{buildShadowMountArgs as Qe,ensureShadowsInSync as Xe,expandShadowPatterns as et}from"../lib/shadows.js";import{buildSkillsMountArgs as tt}from"../lib/skills.js";import{buildWebpaneMountArgs as nt,buildWebtermStateMountArgs as ot,isWebKey as rt,readWebAgent as it,readWebKey as ee,resolveWebPort as st,setWebDefaultCwd as at,startWebtermAndVerify as ct,webInterfaceAnswers as lt,webInterfaceUrl as ge,webPortUsable as be,webSessionInfo as dt}from"../lib/webterm.js";import{readActiveProfile as ut,readGitMode as ft,writeActiveProfile as pt}from"../lib/workspace-identity.js";function mt(n,t){return t===n?X:`${X}/${Ce(n,t)}`}async function ht(n,t){const s=Object.keys(t);if(s.length<=1)return s[0]??re;const o=ut(n.workspaceId)??re,$=await Ee({message:"Profile:",options:s.map(i=>{const f=t[i]?.description,g=i===o,m=f&&g?`${f} \xB7 current`:f??(g?"current":void 0),d={value:i,label:i};return m&&(d.hint=m),d}),initialValue:o});W($)&&(G("Cancelled."),process.exit(0));const l=$;return l!==o&&pt(n.workspaceId,l),l}function gt(n){const t=`{{.State.Status}}|{{index .Config.Labels "${de}"}}|{{index .Config.Labels "${ce}"}}|{{index .Config.Labels "${le}"}}|{{index .Config.Labels "${se}"}}|{{index .Config.Labels "${ue}"}}|{{index .Config.Labels "${ae}"}}|{{index .Config.Labels "${ie}"}}`,s=A("docker",["inspect","--format",t,n],{encoding:"utf8",stdio:"pipe"});if(s.status!==0)return null;const o=y=>y==="<no value>"?"":y,[$="",l="",i="",f="",g="",m="",d="",S=""]=s.stdout.trim().split("|");return{status:$,shadowLabel:o(l),profileLabel:o(i),runtimeEnvLabel:o(f),gitModeLabel:o(g),timezoneLabel:o(m),portsLabel:o(d),envLabel:o(S)}}function we(n){return n.length===0?"":[...n].sort().join(",")}const bt=/^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+){1,2}$/;function wt(){let n;try{n=Intl.DateTimeFormat().resolvedOptions().timeZone}catch{return null}return typeof n!="string"||!bt.test(n)||n==="UTC"||n==="Etc/UTC"?null:n}function Ae(){const n=Object.entries(fe);if(n.length===0)return"";const t=n.map(([s,o])=>`${s}=${o}`).sort().join(",");return ve("sha256").update(t).digest("hex").slice(0,12)}function te(n){A("docker",["stop",n],{stdio:"pipe"}),A("docker",["rm",n],{stdio:"pipe"})}function At(n,t){return A("docker",["exec","-u","root",...t?[]:["-i","-t"],n,"node",We,"--summary"],{stdio:t?"pipe":"inherit"}).status===0}async function $e(n){const{containerName:t,workspaceRoot:s,cacheDir:o,templatesDir:$,activeProfile:l,profileHook:i,expandedShadows:f,envConfig:g,hasGit:m,gitMode:d,shadowPatterns:S,workspaceName:y,portMappings:_,webPort:h,noCache:a,quiet:c=!1}=n,D=c?"pipe":["ignore","ignore","inherit"],B=he(_),Z=De(g),H=Qe(o,f),z=Se(m,S,d),I=Be(g),F=ye(o),T=tt(o),K=Ue(o),Y=ot(o),U=nt(o),P=["-v",`${s}:${X}`,...H,...F,...T,...K,...Y,...U],C=wt(),q=["--label",`${Ie}=true`,"--label",`${de}=${we(f)}`,"--label",`${ce}=${l}`,"--label",`${le}=${Ae()}`,"--label",`${se}=${d}`,"--label",`${ue}=${C??""}`],R=[...Object.entries(fe).flatMap(([p,b])=>["-e",`${p}=${b}`]),...C===null?[]:["-e",`TZ=${C}`],"-e",`CHAMBA_WORKSPACE=${y}`,"-e",`CHAMBA_GIT_MODE=${d}`],u=gt(t);let N=u?.status??null;if(u!==null){const p=we(f),b=u.shadowLabel!==p,w=u.profileLabel!==l,J=u.runtimeEnvLabel!==Ae(),V=u.gitModeLabel!==d,Q=u.timezoneLabel!==(C??""),M=u.portsLabel!==B,L=u.envLabel!==Z;if(b||w||J||V||Q||M||L){let v;w?v=`Profile changed (${u.profileLabel} -> ${l})`:b?v="Shadow paths changed":V?v=`Git mode changed (${u.gitModeLabel||"<unset>"} -> ${d})`:Q?v=`Timezone changed (${u.timezoneLabel||"<unset>"} -> ${C??"<unset>"})`:M?v="Ports changed":L?v="Environment variables changed":v="Runtime environment updated";const ne=!c&&u.status==="running";if(ne){r.warn(`${v} - applying this needs to recreate the running container, which stops any open sessions.
|
|
2
|
+
Agent memory, settings, and workspace data are preserved.`);const oe=await O({message:"Recreate the container now?",initialValue:!0});(W(oe)||!oe)&&(G("Session cancelled - container left running."),process.exit(0))}te(t),N=null,w&&A("docker",["rmi",t],{stdio:"pipe"}),!c&&!ne&&r.info(`${v} - ${w?"rebuilding":"recreating container"}...`)}}Xe(o,f,s),Pe(o,z);let e=h;const k=async()=>{const p=me(j($,"Dockerfile"),i),b=await Te(p,$,t,a,c);b.status!==0&&(c||E("Failed to build container image."),process.exit(b.status));let w=_;h!==void 0&&!await be(h,t)&&(c||r.warn(`Web interface: host port ${h} was taken before the container could start - continuing without it.`),w=_.filter(L=>L.host!==h),e=void 0);try{await Ve(w,e)}catch(L){c||E(L instanceof Error?L.message:String(L)),process.exit(1)}c||r.info("Starting dev container...");const J=e!==void 0?["-e",`CHAMBA_WEB_URL=${ge(e,null)}`]:[],V=["run","-d","--name",t,...P,...I,...R,...J,...Ze(w),...Ke(w),"--security-opt","no-new-privileges:true",...q,"--label",`${ae}=${he(w)}`,"--label",`${ie}=${Z}`,t,...Me],M=A("docker",V,{stdio:c?"pipe":["ignore","ignore","pipe"]});M.status!==0&&(c||(process.stderr.write(M.stderr?.toString()??""),E("Failed to start dev container.")),process.exit(M.status??1))},x=p=>({status:p,webPort:e??null});if(N===null)return await k(),x("created");if(N==="exited"){c||r.info("Resuming dev container...");const p=A("docker",["start",t],{stdio:D});if(p.status!==0){c&&process.exit(p.status??1),r.warn(`This container could not start - a host path it was created against has likely moved or been removed
|
|
3
|
+
(for example the workspace directory was renamed).`);const b=await O({message:"Recreate it now? Your agent memory, settings, and workspace data are preserved.",initialValue:!0});return(W(b)||!b)&&(E("Failed to start dev container."),process.exit(p.status??1)),te(t),await k(),x("created")}return x("resumed")}else return x("connected")}async function ke(n,t,s,o){await ct(n,t,s,o)||r.warn(`Web interface: the server did not come up on port ${s}, so the URL in the greeting will not answer.
|
|
4
|
+
Run \`webterm ${t}\` in the container to see why, or just run \`${t}\` in the terminal.`)}async function Bt(n,t,s){const o=s?.mode??"web",$=process.cwd(),l=t.workspaceRoot,i=t.containerName,f=t.workspaceDir,g=j(n,"templates"),m=xe(l);m||(r.error("chamba.yaml not found or invalid."),process.exit(1));let d;try{d=Ye(m.ports??[])}catch(e){r.error(e instanceof Error?e.message:String(e)),process.exit(1)}const S=Fe(),y=new Set(d.map(e=>e.host)),_=async e=>!y.has(e)&&await be(e,i),h=await st(t.workspaceId,S,_);let a=null;h.ok?(h.movedFrom!==void 0&&r.info(`Web interface: port ${h.movedFrom} was not available - moved to ${h.port} and kept there.`),a=h.port,d.push({host:a,container:pe})):h.reason==="exhausted"?r.warn(`Web interface: no free port left in ${Ge(S)} - web interface skipped this session.
|
|
5
|
+
Widen the range in Settings > Ports.`):r.warn(`Web interface: the port assignment could not be recorded for this workspace - web interface skipped this session.
|
|
6
|
+
Check that the workspace cache dir is writable.`);const c=mt(l,$),D=m.profiles??{},B=await ht(t,D),H=D[B]?.dockerfile_hook,z=m.shadow_paths??[],{paths:I,skippedTracked:F}=et(z,l);I.length>0&&r.info(`Shadow paths active: ${I.join(", ")} (Settings > Shadow paths)`),F.length>0&&r.warn(`Skipped ${F.length} shadow path(s) tracked by git`);let T;try{T=ze(m.env,l)}catch(e){r.error(e instanceof Error?e.message:String(e)),process.exit(1)}for(const e of He(T))r.warn(e);const K=Le(j(l,".git")),Y=ft(t.workspaceId)??_e.local,U={containerName:i,workspaceRoot:l,cacheDir:f,templatesDir:g,activeProfile:B,profileHook:H,expandedShadows:I,envConfig:T,hasGit:K,gitMode:Y,shadowPatterns:z,workspaceName:t.workspaceId,portMappings:d,...a!==null&&{webPort:a},...s?.noCache!==void 0&&{noCache:s.noCache}};let P=await $e(U);a=P.webPort;const C=me(j(g,"Dockerfile"),H),q=Ne(C,g);let R=Oe(i,q);if(R){r.warn(`This container's image is out of date.
|
|
7
|
+
Please rebuild to update - this will not affect agent memory, settings, or your data.`);const e=await O({message:"Rebuild now? (Recommended)",initialValue:!0});W(e)&&(G("Session cancelled."),process.exit(0)),e&&(te(i),A("docker",["rmi",i],{stdio:"pipe"}),P=await $e(U),a=P.webPort,R=!1)}for(const e of d)e.container!==pe&&r.info(je(e));if(!At(i,R))if(R){const e=await O({message:"Startup checks failed (likely due to outdated image). Connect anyway?",initialValue:!0});(!e||W(e))&&(G("Session cancelled."),process.exit(0))}else E("Startup checks failed."),process.exit(1);const u=it(f);if(P.status!=="connected"?a!==null&&await ke(i,u,a,c):a!==null&&(await lt(a)?await at(a,ee(i),c):await ke(i,u,a,c)),o==="web"){const e=a===null?null:ee(i);if(a!==null&&rt(e)){const k=ge(a,e);Re(k)?r.info(`Web interface: opened in your browser
|
|
8
|
+
${k}`):r.info(`Web interface: open this in your browser
|
|
9
|
+
${k}`),E("The container and the interface keep running. Run chamba again for the menu.")}else r.warn(`Web interface: there is nothing to open (see above).
|
|
10
|
+
Pick "Container shell" from the menu to work in the terminal instead.`),E("The container is running.");return}const N=A("docker",Je(c,i),{stdio:"inherit"});if(qe(i)===0){const e=(a===null?null:await dt(a,ee(i)))?.sessions??0;if(e>0){const x=e===1?"1 agent session is":`${e} agent sessions are`,p=e===1?"it":"them";r.warn(`Web interface: ${x} open in the browser - stopping the container ends ${p}.`)}const k=await O({message:"Last session to this container closed. Stop it? (resumes fast)",initialValue:e===0});!W(k)&&k&&(r.info("Stopping container..."),A("docker",["stop",i],{stdio:"pipe"}),r.info("Container stopped."))}process.stdout.write(`
|
|
11
|
+
`),process.exit(N.status??0)}export{wt as hostTimezone,mt as resolveWorkdir,Bt as run,$e as startContainer};
|