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,145 @@
1
+ // =============================================================================
2
+ // startup-git-mode.mjs -- Git mode application + verification for startup.mjs
3
+ // Baked into the container image alongside startup.mjs at /home/devuser/.
4
+ //
5
+ // Strict / local / unrestricted are read from the CHAMBA_GIT_MODE env var injected by
6
+ // dev.ts. As root we apply the requested state to /etc/gitconfig and the
7
+ // /usr/local/bin/git symlink; as devuser we only verify that the state already
8
+ // matches (the previous root invocation is what put it there).
9
+ // Must use only Node.js built-ins -- no external packages available in container.
10
+ // =============================================================================
11
+
12
+ import { execSync } from "node:child_process";
13
+ import { lstatSync, readlinkSync, symlinkSync, unlinkSync } from "node:fs";
14
+ import { GIT_MODE, GIT_WRAPPER_PATH, GIT_WRAPPER_SOURCE } from "./runtime-constants.mjs";
15
+
16
+ const VALID_GIT_MODES = Object.values(GIT_MODE);
17
+
18
+ function lstatExists(path) {
19
+ try {
20
+ lstatSync(path);
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ function isWrapperSymlinkInPlace() {
28
+ if (!lstatExists(GIT_WRAPPER_PATH)) return false;
29
+ try {
30
+ const st = lstatSync(GIT_WRAPPER_PATH);
31
+ if (!st.isSymbolicLink()) return false;
32
+ return readlinkSync(GIT_WRAPPER_PATH) === GIT_WRAPPER_SOURCE;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ function removeWrapperIfPresent() {
39
+ if (!lstatExists(GIT_WRAPPER_PATH)) return;
40
+ try {
41
+ unlinkSync(GIT_WRAPPER_PATH);
42
+ } catch {
43
+ // Already gone or inaccessible -- subsequent verification will catch it
44
+ }
45
+ }
46
+
47
+ function applyAsRoot(gitMode, protocolValue, fail) {
48
+ // Use absolute path to bypass /usr/local/bin/git (which may already be the read-only
49
+ // wrapper from a prior strict-mode session and would block this legitimate setup write).
50
+ try {
51
+ execSync(`/usr/bin/git config --system protocol.allow ${protocolValue}`, { stdio: "pipe" });
52
+ } catch {
53
+ fail("git mode", `failed to set protocol.allow=${protocolValue}`);
54
+ }
55
+
56
+ if (gitMode === GIT_MODE.strict) {
57
+ if (!isWrapperSymlinkInPlace()) {
58
+ // Remove any pre-existing /usr/local/bin/git (stale symlink, leftover binary)
59
+ // so symlinkSync below doesn't EEXIST.
60
+ removeWrapperIfPresent();
61
+ try {
62
+ symlinkSync(GIT_WRAPPER_SOURCE, GIT_WRAPPER_PATH);
63
+ } catch {
64
+ fail("git wrapper", `failed to install ${GIT_WRAPPER_PATH}`);
65
+ }
66
+ }
67
+ return;
68
+ }
69
+ removeWrapperIfPresent();
70
+ }
71
+
72
+ function verifyProtocol(gitMode, protocolValue, run, ok, fail) {
73
+ const gitProtocol = run("git config --system protocol.allow");
74
+ if (gitProtocol === protocolValue) {
75
+ ok("git mode", `${gitMode} (protocol.allow=${protocolValue})`);
76
+ } else {
77
+ fail("git mode", `expected protocol.allow=${protocolValue}, found ${gitProtocol ?? "<unset>"}`);
78
+ }
79
+ }
80
+
81
+ function verifyWrapper(gitMode, ok, fail, skip) {
82
+ if (gitMode === GIT_MODE.strict) {
83
+ if (isWrapperSymlinkInPlace()) {
84
+ ok("git read-only wrapper", `${GIT_WRAPPER_PATH} -> ${GIT_WRAPPER_SOURCE}`);
85
+ } else {
86
+ fail("git read-only wrapper", `not installed at ${GIT_WRAPPER_PATH}`);
87
+ }
88
+ return;
89
+ }
90
+ if (lstatExists(GIT_WRAPPER_PATH)) {
91
+ fail("git read-only wrapper", `should be absent in ${gitMode} mode`);
92
+ } else {
93
+ skip("git read-only wrapper", `not active in ${gitMode} mode`);
94
+ }
95
+ }
96
+
97
+ function verifyStrictWrapperRejects(ok, fail) {
98
+ // Probe the wrapper with a representative mutating command. The classifier
99
+ // rejects before forking real git, so we should see our marker on stderr.
100
+ let probeStderr = "";
101
+ let probeExit = 0;
102
+ try {
103
+ execSync(`${GIT_WRAPPER_PATH} commit -m probe`, { stdio: ["pipe", "pipe", "pipe"] });
104
+ } catch (err) {
105
+ probeStderr = (err.stderr ?? "").toString();
106
+ probeExit = err.status ?? 1;
107
+ }
108
+ if (probeExit !== 0 && probeStderr.includes("blocked in strict mode")) {
109
+ ok("strict wrapper rejects mutation", "'git commit' blocked");
110
+ } else {
111
+ fail("strict wrapper rejects mutation", "wrapper did not produce the expected error");
112
+ }
113
+ }
114
+
115
+ function verifyRemoteBlocked(gitMode, ok, fail, skip) {
116
+ if (gitMode === GIT_MODE.unrestricted) {
117
+ skip("remote push", "allowed in unrestricted mode (network probe skipped)");
118
+ return;
119
+ }
120
+ try {
121
+ execSync("/usr/bin/git -C /workspace push", { stdio: "pipe" });
122
+ fail("remote push blocked", "git push succeeded -- remote access is NOT blocked");
123
+ } catch {
124
+ ok("remote push blocked", "remote push not possible");
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Apply (when root) and verify the git mode requested via CHAMBA_GIT_MODE.
130
+ * Reports through the caller-provided ok/fail/skip helpers so all output flows
131
+ * through the main startup script's section formatting and error counter.
132
+ */
133
+ export function checkGitMode({ ok, fail, skip, run, isRoot }) {
134
+ const gitMode = VALID_GIT_MODES.includes(process.env.CHAMBA_GIT_MODE) ? process.env.CHAMBA_GIT_MODE : GIT_MODE.local;
135
+ const protocolValue = gitMode === GIT_MODE.unrestricted ? "always" : "never";
136
+
137
+ if (isRoot) {
138
+ applyAsRoot(gitMode, protocolValue, fail);
139
+ }
140
+
141
+ verifyProtocol(gitMode, protocolValue, run, ok, fail);
142
+ verifyWrapper(gitMode, ok, fail, skip);
143
+ if (gitMode === GIT_MODE.strict) verifyStrictWrapperRejects(ok, fail);
144
+ verifyRemoteBlocked(gitMode, ok, fail, skip);
145
+ }
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env node
2
+ // =============================================================================
3
+ // startup.mjs -- Container startup: AI CLI updates + readiness checks
4
+ // Baked into the container image at /home/devuser/startup.mjs
5
+ // Must run as root (npm global install requires root).
6
+ // Must use only Node.js built-ins -- no external packages available in container.
7
+ // =============================================================================
8
+
9
+ import { execSync, spawn } from "node:child_process";
10
+ import { readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
11
+ import { checkGitMode } from "./startup-git-mode.mjs";
12
+
13
+ const run = (cmd) => {
14
+ try {
15
+ return execSync(cmd, {
16
+ encoding: "utf8",
17
+ stdio: ["pipe", "pipe", "pipe"],
18
+ }).trim();
19
+ } catch {
20
+ return null;
21
+ }
22
+ };
23
+
24
+ // -- ANSI helpers -------------------------------------------------------------
25
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
26
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
27
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
28
+ const blue = (s) => `\x1b[34m${s}\x1b[0m`;
29
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
30
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
31
+ const grey = (s) => `\x1b[90m${s}\x1b[0m`;
32
+
33
+ let errors = 0;
34
+
35
+ // Summary mode (passed by the host at session start): still run every check and side-effect, but stay
36
+ // quiet -- suppress the section dump and surface only failures. The 'status' alias runs without it,
37
+ // so it keeps the full verbose output as the on-demand "details" view.
38
+ const summary = process.argv.includes("--summary");
39
+
40
+ const ok = summary ? () => {} : (label, detail) => console.log(`${green("✓")} ${label.padEnd(24)}${detail ? dim(detail) : ""}`);
41
+ const skip = summary ? () => {} : (label, detail) => console.log(`${grey("–")} ${grey(label.padEnd(24))}${detail ? grey(detail) : ""}`);
42
+ const fail = (label, detail) => {
43
+ console.log(`${red("✗")} ${label.padEnd(24)}${detail || ""}`);
44
+ errors++;
45
+ };
46
+ // Non-fatal notice: always prints (even in summary), but does NOT increment errors, so it never aborts
47
+ // the session. Used for things the user should know about but that must not block connecting (e.g. a
48
+ // transient AI CLI update failure -- the existing versions still work).
49
+ const warn = (label, detail) => console.log(`${yellow("!")} ${label.padEnd(24)}${detail ? yellow(detail) : ""}`);
50
+ const section = summary ? () => {} : (title) => console.log(`\n${bold(title)}`);
51
+
52
+ // -- Header -------------------------------------------------------------------
53
+ if (!summary) console.log(`\n${bold("chamba - Sandbox for AI Agents")}\n`);
54
+
55
+ // -- AI CLI update (requires root - skipped when run via 'status' alias as devuser) -
56
+ section("AI CLI update");
57
+
58
+ const isRoot = process.getuid?.() === 0;
59
+ const TIMESTAMP_FILE = "/home/devuser/.ai-cli-updated";
60
+ const THROTTLE_MS = 24 * 60 * 60 * 1000;
61
+
62
+ let lastUpdate = 0;
63
+ let timestampFileExists = false;
64
+ try {
65
+ const raw = readFileSync(TIMESTAMP_FILE, "utf8").trim();
66
+ lastUpdate = new Date(raw).getTime();
67
+ timestampFileExists = true;
68
+ } catch {
69
+ // File missing or unreadable -- treat as never updated
70
+ }
71
+
72
+ // -- Context snapshot cleanup (best-effort, silent) ---------------------------
73
+ // claude-statusline.sh writes one small per-session snapshot to this directory on every
74
+ // prompt render (see the context-usage helper). Delete week-old entries here -- covers both
75
+ // stale *.json snapshots and orphaned .tmp.* files. Any failure is swallowed so cleanup
76
+ // can never affect readiness checks.
77
+ const SNAPSHOT_DIR = "/home/devuser/.claude/context-usage";
78
+ const SNAPSHOT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
79
+ try {
80
+ for (const name of readdirSync(SNAPSHOT_DIR)) {
81
+ const path = `${SNAPSHOT_DIR}/${name}`;
82
+ try {
83
+ if (Date.now() - statSync(path).mtimeMs > SNAPSHOT_MAX_AGE_MS) unlinkSync(path);
84
+ } catch {
85
+ // Skip entries that vanish or cannot be read/removed
86
+ }
87
+ }
88
+ } catch {
89
+ // Directory missing (no snapshots yet) -- nothing to clean
90
+ }
91
+
92
+ // Print npm's output indented under the bullet above, so its flush-left "changed N packages" summary
93
+ // reads as part of chamba's output rather than looking like it leaked from unrelated logging.
94
+ const printIndented = (text) => {
95
+ for (const line of text.split("\n")) {
96
+ if (line.trim()) console.log(grey(` ${line}`));
97
+ }
98
+ };
99
+
100
+ // Run the global npm install behind a one-line braille spinner so the user can see the (otherwise
101
+ // silent, buffered) install is alive on a TTY. npm output is captured and printed indented only once it
102
+ // finishes, keeping the live view to a single animated line. A failed update is NON-FATAL: it reports
103
+ // via warn() (no errors++), so a transient registry/network hiccup never blocks the session -- the user
104
+ // keeps the existing CLI versions. On a non-TTY (tests, piped) the spinner is skipped for clean output.
105
+ const doUpdate = (label) =>
106
+ new Promise((resolve) => {
107
+ const animate = process.stdout.isTTY;
108
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
109
+ let frame = 0;
110
+ let timer;
111
+ if (animate) {
112
+ const draw = () => {
113
+ process.stdout.write(`\r${blue(frames[frame])} ${dim(label)}`);
114
+ frame = (frame + 1) % frames.length;
115
+ };
116
+ draw();
117
+ timer = setInterval(draw, 80);
118
+ } else {
119
+ console.log(`${blue("●")} ${dim(label)}`);
120
+ }
121
+
122
+ const child = spawn("npm", ["install", "-g", "opencode-ai@latest", "@anthropic-ai/claude-code@latest", "@openai/codex@latest"], {
123
+ stdio: ["ignore", "pipe", "pipe"],
124
+ });
125
+ let out = "";
126
+ let errOut = "";
127
+ child.stdout.setEncoding("utf8");
128
+ child.stderr.setEncoding("utf8");
129
+ child.stdout.on("data", (d) => {
130
+ out += d;
131
+ });
132
+ child.stderr.on("data", (d) => {
133
+ errOut += d;
134
+ });
135
+
136
+ // Settle exactly once (spawn can emit both 'error' and 'close'). Clear the spinner line and leave
137
+ // a static bullet in its place so scrollback reads cleanly, then print the captured output.
138
+ let done = false;
139
+ const finish = (report) => {
140
+ if (done) return;
141
+ done = true;
142
+ if (timer) clearInterval(timer);
143
+ if (animate) process.stdout.write(`\r\x1b[K${blue("●")} ${dim(label)}\n`);
144
+ report();
145
+ resolve();
146
+ };
147
+
148
+ child.on("error", (err) => {
149
+ finish(() => {
150
+ printIndented(String(err?.message ?? err));
151
+ warn("AI CLIs", "update failed -- continuing with existing versions");
152
+ });
153
+ });
154
+ child.on("close", (code) => {
155
+ finish(() => {
156
+ if (code === 0) {
157
+ printIndented(out);
158
+ writeFileSync(TIMESTAMP_FILE, `${new Date().toISOString()}\n`);
159
+ ok("AI CLIs", "updated");
160
+ } else {
161
+ printIndented(`${out}${errOut}`);
162
+ warn("AI CLIs", "update failed -- continuing with existing versions");
163
+ }
164
+ });
165
+ });
166
+ });
167
+
168
+ // SPACE within `seconds` -> skip. Any other input is ignored. Ctrl+C exits 130. Non-TTY -> no skip.
169
+ const promptSkipUpdate = (seconds) =>
170
+ new Promise((resolve) => {
171
+ if (!process.stdin.isTTY) {
172
+ resolve(false);
173
+ return;
174
+ }
175
+ let remaining = seconds;
176
+ let tick;
177
+ let timer;
178
+ const line = (s) => `\r\x1b[K${blue("●")} ${dim(`Updating AI CLIs in ${s}s... press SPACE to skip`)}`;
179
+ const cleanup = () => {
180
+ clearInterval(tick);
181
+ clearTimeout(timer);
182
+ process.stdin.removeListener("data", onData);
183
+ process.stdin.setRawMode(false);
184
+ process.stdin.pause();
185
+ process.stdout.write("\r\x1b[K");
186
+ };
187
+ const onData = (chunk) => {
188
+ for (const byte of chunk) {
189
+ if (byte === 0x03) {
190
+ cleanup();
191
+ process.exit(130);
192
+ }
193
+ if (byte === 0x20) {
194
+ cleanup();
195
+ resolve(true);
196
+ return;
197
+ }
198
+ }
199
+ };
200
+ process.stdin.setRawMode(true);
201
+ process.stdin.resume();
202
+ process.stdin.on("data", onData);
203
+ process.stdout.write(line(remaining));
204
+ tick = setInterval(() => {
205
+ remaining -= 1;
206
+ if (remaining > 0) process.stdout.write(line(remaining));
207
+ }, 1000);
208
+ timer = setTimeout(() => {
209
+ cleanup();
210
+ resolve(false);
211
+ }, seconds * 1000);
212
+ });
213
+
214
+ if (Number.isFinite(lastUpdate) && Date.now() - lastUpdate < THROTTLE_MS) {
215
+ ok("AI CLIs", "up to date");
216
+ } else if (!isRoot) {
217
+ skip("AI CLIs", "update skipped (requires root)");
218
+ } else {
219
+ // About to show update activity (the skip prompt and/or install/update output). In summary mode that is the
220
+ // first thing after the host's "Starting dev container...", so add a blank line to separate it.
221
+ if (summary) console.log("");
222
+ if (!timestampFileExists) {
223
+ await doUpdate("Installing AI CLIs...");
224
+ } else if (await promptSkipUpdate(3)) {
225
+ skip("AI CLIs", "update skipped by user");
226
+ } else {
227
+ await doUpdate("Updating AI CLIs to latest...");
228
+ }
229
+ }
230
+
231
+ // -- Security -----------------------------------------------------------------
232
+ section("Security");
233
+
234
+ const idOutput = run("id devuser");
235
+ if (idOutput?.includes("uid=1001")) {
236
+ ok("non-root user", "devuser (uid=1001)");
237
+ } else {
238
+ fail("non-root user", "devuser not found or wrong uid -- container is misconfigured");
239
+ }
240
+
241
+ // -- Git mode (strict / local / unrestricted) - applied + verified by separate module --
242
+ checkGitMode({ ok, fail, skip, run, isRoot });
243
+
244
+ // -- AI tools -----------------------------------------------------------------
245
+ section("AI tools");
246
+
247
+ const checkTool = (cmd) => {
248
+ const out = run(`${cmd} --version`);
249
+ if (out !== null && out.trim() !== "") {
250
+ const version = out.split("\n")[0];
251
+ ok(cmd, version);
252
+ return;
253
+ }
254
+ const which = run(`which ${cmd}`);
255
+ if (which !== null) {
256
+ ok(cmd, "installed");
257
+ } else {
258
+ fail(cmd, "not found -- rebuild container");
259
+ }
260
+ };
261
+
262
+ checkTool("opencode");
263
+ checkTool("claude");
264
+ checkTool("codex");
265
+
266
+ // -- Runtimes -----------------------------------------------------------------
267
+ section("Runtimes");
268
+
269
+ const checkRuntime = (label, version) => {
270
+ if (version !== null) ok(label, version);
271
+ else skip(label, "not installed");
272
+ };
273
+
274
+ // Always present (base image)
275
+ ok("node", run("node --version") ?? "not found");
276
+ ok("npm", `v${run("npm --version") ?? "not found"}`);
277
+ const pnpmVer = run("pnpm --version");
278
+ ok("pnpm", pnpmVer ? `v${pnpmVer}` : "not found");
279
+ ok("python3", run("python3 --version") ?? "not found");
280
+ ok("pipx", run("pipx --version") ?? "not found");
281
+
282
+ // Optional (installed via profile hooks)
283
+ const bunVer = run("bun --version");
284
+ checkRuntime("bun", bunVer ? `v${bunVer}` : null);
285
+ checkRuntime("go", run("go version"));
286
+ checkRuntime("cargo", run("cargo --version"));
287
+ checkRuntime("java", run("java --version")?.split("\n")[0] ?? null);
288
+
289
+ // -- Dev tools ----------------------------------------------------------------
290
+ section("Dev tools");
291
+
292
+ ok("gh", run("gh --version")?.split("\n")[0] ?? "not found");
293
+ ok("rg", run("rg --version")?.split("\n")[0] ?? "not found");
294
+ ok("fd", run("fd --version") ?? "not found");
295
+ ok("fzf", run("fzf --version") ?? "not found");
296
+ ok("jq", run("jq --version") ?? "not found");
297
+ ok("yq", run("yq --version") ?? "not found");
298
+
299
+ // -- Database tools -----------------------------------------------------------
300
+ section("Database tools");
301
+
302
+ ok("sqlite3", run("sqlite3 --version")?.split(" ").slice(0, 2).join(" ") ?? "not found");
303
+ ok("psql", run("psql --version") ?? "not found");
304
+ ok("mysql", run("mysql --version") ?? "not found");
305
+ ok("redis-cli", run("redis-cli --version") ?? "not found");
306
+
307
+ // -- API keys -----------------------------------------------------------------
308
+ section("API keys");
309
+
310
+ // Direct console.log, so it must be gated explicitly to stay quiet in summary mode (kept for 'status').
311
+ if (!summary) {
312
+ console.log(
313
+ `${blue("●")} ${dim("API keys are injected via the env list in chamba.yaml - add your '.env' path, or an inline KEY=VALUE.")}`,
314
+ );
315
+ }
316
+
317
+ // -- Summary ------------------------------------------------------------------
318
+ if (errors === 0) {
319
+ // Summary mode stays silent on success -- the welcome block is printed by the shell on connect.
320
+ if (!summary) {
321
+ const workspaceSuffix = process.env.CHAMBA_WORKSPACE ? ` - workspace: ${bold(process.env.CHAMBA_WORKSPACE)}` : "";
322
+ console.log(`\n${blue("●")} ${bold("chamba dev container ready")}${workspaceSuffix}`);
323
+ console.log(
324
+ `${grey(" To adjust settings, ask any agent about")} ${bold("chamba.yaml")} ${grey("- it lives in the workspace root.")}\n`,
325
+ );
326
+ console.log(`${green("●")} ${bold("Ready.")}`);
327
+ console.log(`${grey("Type 'status' to re-run the readiness check.")}\n`);
328
+ }
329
+ } else {
330
+ console.log(`\n${red("●")} ${bold(`${errors} error(s) - see above. Rebuild the container to fix.`)}\n`);
331
+ if (summary) console.log(`${grey("Run 'status' for full readiness details.")}\n`);
332
+ process.exit(1);
333
+ }
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env bash
2
+ # webpane - publish an HTML file as a page in this session's web pane.
3
+ # Baked into the image at /usr/local/share/chamba/webpane.sh, on PATH as `webpane`.
4
+ #
5
+ # Usable by anything running inside a web session: an agent, a script, a shell. The page appears in the pane
6
+ # beside the terminal straight away, and stays - pages are files, filed under the conversation they belong to,
7
+ # and a resumed conversation opens with its own pages again.
8
+ #
9
+ # The caller says who it is by its own pid and nothing more. The server walks up the process tree from there
10
+ # until it reaches a session it started, which is what decides where the page lands: a session key passed as
11
+ # an argument would be a value the server has to trust, and every process in this container could pass one.
12
+ set -euo pipefail
13
+
14
+ # The interface's own port and key file. Both take the same overrides the server does, so a server started
15
+ # on another port (a hand-run, a test) is still reachable with the same helper.
16
+ CONTAINER_PORT="${WEBTERM_PORT:-3899}"
17
+ KEY_FILE="${WEBTERM_KEY_FILE:-/tmp/webterm.key}"
18
+
19
+ usage() {
20
+ echo "Usage: webpane <file.html> [--title \"A title\"]"
21
+ echo ""
22
+ echo "Publishes a standalone HTML page to the web pane of the session this runs in."
23
+ echo "The page's own <title> is what the pane shows; --title names the file it is saved as."
24
+ echo ""
25
+ echo "A page may carry a form: give it data-feedback, and what the user submits is written"
26
+ echo "next to the page as JSON. Your terminal gets a line saying where."
27
+ }
28
+
29
+ FILE=""
30
+ TITLE=""
31
+ while [ $# -gt 0 ]; do
32
+ case "$1" in
33
+ --title)
34
+ shift
35
+ TITLE="${1:-}"
36
+ ;;
37
+ --title=*)
38
+ TITLE="${1#--title=}"
39
+ ;;
40
+ -h | --help)
41
+ usage
42
+ exit 0
43
+ ;;
44
+ *)
45
+ if [ -z "$FILE" ]; then
46
+ FILE="$1"
47
+ else
48
+ echo "webpane: unexpected argument \"$1\"" >&2
49
+ usage >&2
50
+ exit 1
51
+ fi
52
+ ;;
53
+ esac
54
+ shift || true
55
+ done
56
+
57
+ if [ -z "$FILE" ]; then
58
+ usage
59
+ exit 0
60
+ fi
61
+
62
+ if [ ! -f "$FILE" ] || [ ! -r "$FILE" ]; then
63
+ echo "webpane: cannot read \"$FILE\" - name an HTML file that exists." >&2
64
+ exit 1
65
+ fi
66
+
67
+ # The name the page is saved under when the caller did not choose one: the file's own name, which is almost
68
+ # always what the author meant anyway.
69
+ if [ -z "$TITLE" ]; then
70
+ TITLE="$(basename "$FILE")"
71
+ TITLE="${TITLE%.*}"
72
+ fi
73
+
74
+ # The title travels as an HTTP header, so it is one line of ordinary text before it goes anywhere. The server
75
+ # cleans it again for its own reasons; this is what keeps a stray newline from turning into a curl error the
76
+ # caller cannot make sense of.
77
+ TITLE="$(printf '%s' "$TITLE" | tr -d '[:cntrl:]')"
78
+
79
+ if [ ! -r "$KEY_FILE" ]; then
80
+ echo "webpane: the web interface is not running in this container, so there is no pane to publish to." >&2
81
+ exit 1
82
+ fi
83
+
84
+ BODY_FILE="$(mktemp)"
85
+ trap 'rm -f "$BODY_FILE"' EXIT
86
+
87
+ # $$ is this script's own pid. The server climbs from it to whichever session started it.
88
+ STATUS="$(
89
+ curl -sS -o "$BODY_FILE" -w '%{http_code}' \
90
+ --connect-timeout 3 --max-time 30 \
91
+ -X POST \
92
+ -H "Content-Type: text/html" \
93
+ -H "X-Webterm-Key: $(head -n 1 "$KEY_FILE")" \
94
+ -H "X-Webpane-Pid: $$" \
95
+ -H "X-Webpane-Title: ${TITLE}" \
96
+ --data-binary "@${FILE}" \
97
+ "http://127.0.0.1:${CONTAINER_PORT}/pane/publish" 2>/dev/null
98
+ )" || {
99
+ echo "webpane: could not reach the web interface on this container's port ${CONTAINER_PORT}." >&2
100
+ exit 1
101
+ }
102
+
103
+ # The server answers with JSON; jq is in the image, and the raw body is the fallback if it ever is not.
104
+ field() {
105
+ if command -v jq >/dev/null 2>&1; then
106
+ jq -r "${1} // empty" <"$BODY_FILE" 2>/dev/null
107
+ fi
108
+ }
109
+
110
+ if [ "$STATUS" = "200" ]; then
111
+ NAME="$(field .id)"
112
+ SHOWN="$(field .title)"
113
+ echo "Published \"${SHOWN:-$TITLE}\" to the web pane${NAME:+ as ${NAME}}."
114
+ exit 0
115
+ fi
116
+
117
+ REASON="$(field .error)"
118
+ if [ -z "$REASON" ]; then
119
+ case "$STATUS" in
120
+ 413) REASON="the page is too large" ;;
121
+ 403) REASON="the key on disk is not the one the interface is using" ;;
122
+ *) REASON="the interface answered ${STATUS}" ;;
123
+ esac
124
+ fi
125
+ echo "webpane: nothing was published - ${REASON}." >&2
126
+ exit 1