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.
Files changed (120) hide show
  1. package/LICENSE +53 -14
  2. package/README.md +28 -8
  3. package/dist/commands/advanced.js +7 -278
  4. package/dist/commands/dev.js +11 -612
  5. package/dist/commands/doctor.js +1 -29
  6. package/dist/commands/menu.js +1 -80
  7. package/dist/commands/onboard.js +6 -230
  8. package/dist/commands/settings.js +15 -349
  9. package/dist/lib/agent-commands.js +2 -0
  10. package/dist/lib/agent-context.js +6 -210
  11. package/dist/lib/browser.js +1 -40
  12. package/dist/lib/chamba-yaml.js +10 -191
  13. package/dist/lib/constants.js +1 -125
  14. package/dist/lib/dockerfile-builder.js +41 -261
  15. package/dist/lib/env.js +2 -78
  16. package/dist/lib/git-env.js +1 -21
  17. package/dist/lib/global-config.js +4 -66
  18. package/dist/lib/pnpm-store.js +1 -19
  19. package/dist/lib/ports.js +1 -210
  20. package/dist/lib/safe-rm.js +1 -36
  21. package/dist/lib/sessions.js +2 -34
  22. package/dist/lib/shadows.js +1 -176
  23. package/dist/lib/skills.js +2 -0
  24. package/dist/lib/templates.js +1 -0
  25. package/dist/lib/webterm.js +1 -305
  26. package/dist/lib/workspace-identity.js +4 -260
  27. package/package.json +4 -4
  28. package/templates/Dockerfile +12 -19
  29. package/templates/claude-statusline.sh +5 -7
  30. package/templates/context/baseline.md +2 -0
  31. package/templates/pane-apps/client/assets/c-BNa-DeUl.json +3552 -0
  32. package/templates/pane-apps/client/assets/cpp-B-T4NRoH.json +20239 -0
  33. package/templates/pane-apps/client/assets/csharp-BbxHChhA.json +6010 -0
  34. package/templates/pane-apps/client/assets/css-CWCxuwmz.json +1860 -0
  35. package/templates/pane-apps/client/assets/diff-B-2N89g8.json +154 -0
  36. package/templates/pane-apps/client/assets/docker-DWTvYW26.json +103 -0
  37. package/templates/pane-apps/client/assets/files-D0nJgFAA.js +12 -0
  38. package/templates/pane-apps/client/assets/files-h0K1vZyQ.css +1 -0
  39. package/templates/pane-apps/client/assets/go-B2hyYeNg.json +3156 -0
  40. package/templates/pane-apps/client/assets/graphql-Z6Id2aNO.json +1292 -0
  41. package/templates/pane-apps/client/assets/html-BtzvlIfu.json +2627 -0
  42. package/templates/pane-apps/client/assets/ini-Bn3Twap1.json +108 -0
  43. package/templates/pane-apps/client/assets/java-C7t2dwLO.json +1885 -0
  44. package/templates/pane-apps/client/assets/javascript-DuC2v0C0.json +5996 -0
  45. package/templates/pane-apps/client/assets/json-CwdkWeV5.json +206 -0
  46. package/templates/pane-apps/client/assets/jsonc-MYWDbTU3.json +206 -0
  47. package/templates/pane-apps/client/assets/jsx-Cn8EZuIC.json +5996 -0
  48. package/templates/pane-apps/client/assets/kotlin-CFNOz1gw.json +580 -0
  49. package/templates/pane-apps/client/assets/make-BVnlFCcl.json +629 -0
  50. package/templates/pane-apps/client/assets/markdown-B4Xk2WM_.json +3263 -0
  51. package/templates/pane-apps/client/assets/php-CAQzQmmI.json +4188 -0
  52. package/templates/pane-apps/client/assets/python-CYQee8m4.json +4174 -0
  53. package/templates/pane-apps/client/assets/reviews-BsoGZ35r.css +1 -0
  54. package/templates/pane-apps/client/assets/reviews-iW0Mbn4e.js +153 -0
  55. package/templates/pane-apps/client/assets/ruby-D7R58tus.json +3114 -0
  56. package/templates/pane-apps/client/assets/rust-BFBbFiei.json +1103 -0
  57. package/templates/pane-apps/client/assets/scss-TQRhBkPG.json +1874 -0
  58. package/templates/pane-apps/client/assets/shellscript-DieAkC4W.json +2375 -0
  59. package/templates/pane-apps/client/assets/specs-D9z3neR-.js +13 -0
  60. package/templates/pane-apps/client/assets/sql-Bxd030LV.json +634 -0
  61. package/templates/pane-apps/client/assets/toml-ChrKGzsV.json +430 -0
  62. package/templates/pane-apps/client/assets/tsx-D2e90MFw.json +5996 -0
  63. package/templates/pane-apps/client/assets/typescript-DonXQ_n1.json +5747 -0
  64. package/templates/pane-apps/client/assets/xml-B17r2RXF.json +382 -0
  65. package/templates/pane-apps/client/assets/yaml-CKn4h1zy.json +627 -0
  66. package/templates/pane-apps/client/reviews/index.html +15 -0
  67. package/templates/pane-apps/client/specs/index.html +3 -2
  68. package/templates/pane-apps/server/reviews.mjs +20 -0
  69. package/templates/pane-apps/server/specs.mjs +5 -1588
  70. package/templates/skills/chamba-statusline/SKILL.md +7 -19
  71. package/templates/skills/dx-review/SKILL.md +123 -0
  72. package/templates/skills/dx-review/references/acts.md +162 -0
  73. package/templates/skills/dx-spec/SKILL.md +223 -27
  74. package/templates/skills/dx-spec/references/principles-template.md +2 -0
  75. package/templates/skills/dx-spec/references/review-guide.md +4 -50
  76. package/templates/skills/dx-spec/references/spec-guide.md +24 -4
  77. package/templates/skills/dx-spec/references/stages.md +143 -58
  78. package/templates/skills/dx-spec-execute/SKILL.md +148 -35
  79. package/templates/startup.mjs +10 -4
  80. package/templates/tool-helper.sh +166 -0
  81. package/templates/webterm/README.md +60 -26
  82. package/templates/webterm/color.js +61 -0
  83. package/templates/webterm/config.js +35 -4
  84. package/templates/webterm/context/claude.md +0 -1
  85. package/templates/webterm/public/app/alerts.js +4 -4
  86. package/templates/webterm/public/app/anchored.js +81 -0
  87. package/templates/webterm/public/app/connection.js +9 -0
  88. package/templates/webterm/public/app/dom.js +2 -0
  89. package/templates/webterm/public/app/frames.js +51 -17
  90. package/templates/webterm/public/app/main.js +22 -8
  91. package/templates/webterm/public/app/new-session.js +13 -41
  92. package/templates/webterm/public/app/palette.js +58 -0
  93. package/templates/webterm/public/app/pane-shape.js +167 -0
  94. package/templates/webterm/public/app/pane-shell.js +199 -134
  95. package/templates/webterm/public/app/pane.js +33 -24
  96. package/templates/webterm/public/app/reviews-host.js +15 -0
  97. package/templates/webterm/public/app/specs-host.js +12 -218
  98. package/templates/webterm/public/app/state.js +5 -0
  99. package/templates/webterm/public/app/tabs.js +6 -2
  100. package/templates/webterm/public/app/theme.js +75 -27
  101. package/templates/webterm/public/app/tool-host.js +302 -0
  102. package/templates/webterm/public/app/workspace-color.js +192 -0
  103. package/templates/webterm/public/index.html +17 -3
  104. package/templates/webterm/public/styles.css +143 -21
  105. package/templates/webterm/server.js +198 -150
  106. package/templates/webterm/tools/commands.js +61 -0
  107. package/templates/webterm/tools/index.js +25 -0
  108. package/templates/webterm/tools/mount.js +337 -0
  109. package/templates/webterm/tools/paths.js +34 -0
  110. package/templates/webterm/tools/reviews.js +133 -0
  111. package/templates/webterm/tools/specs.js +154 -0
  112. package/templates/webterm/typed-line.js +70 -7
  113. package/templates/context/context-usage.md +0 -1
  114. package/templates/context-usage.sh +0 -266
  115. package/templates/pane-apps/client/assets/specs-B1970L17.css +0 -1
  116. package/templates/pane-apps/client/assets/specs-cEee_SPn.js +0 -23
  117. package/templates/skills/dx-spec-config/SKILL.md +0 -313
  118. package/templates/skills/dx-spec-config/references/principles-template.md +0 -12
  119. package/templates/specs.sh +0 -106
  120. package/templates/webterm/specs.js +0 -358
@@ -1,268 +1,48 @@
1
- // =========================================================================================================================================
2
- // src/lib/dockerfile-builder.ts - In-memory Dockerfile assembly and temp-file build
3
- // Combines base template + profile hook + USER instruction at build time
4
- // =========================================================================================================================================
5
- import { spawn, spawnSync } from "node:child_process";
6
- import { createHash, randomBytes } from "node:crypto";
7
- import { existsSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
8
- import { tmpdir } from "node:os";
9
- import { join } from "node:path";
10
- import { spinner } from "@clack/prompts";
11
- import { CONTAINER_HOME, CONTAINER_NAME_PREFIX, CONTAINER_STARTUP, CONTAINER_USER, LABEL_BUILD_HASH, LABEL_MANAGED, WEB_KEY_FILE_PATH, } from "./constants.js";
12
- // --- User shell config appended after USER instruction -----------------------------------------------------------------------------------
13
- const USER_SHELL_CONFIG = `
1
+ import{spawn as k,spawnSync as w}from"node:child_process";import{createHash as B,randomBytes as M}from"node:crypto";import{existsSync as N,readdirSync as C,readFileSync as d,unlinkSync as I,writeFileSync as P}from"node:fs";import{tmpdir as x}from"node:os";import{join as u}from"node:path";import{spinner as U}from"@clack/prompts";import{CONTAINER_HOME as s,CONTAINER_NAME_PREFIX as H,CONTAINER_STARTUP as O,CONTAINER_USER as D,LABEL_BUILD_HASH as g,LABEL_MANAGED as W,WEB_KEY_FILE_PATH as E}from"./constants.js";const F=`
14
2
  # ---------------------------------------------------------------------------
15
3
  # User shell config (PATH, prompt, welcome message, status alias)
16
4
  # ---------------------------------------------------------------------------
17
- ENV PATH="${CONTAINER_HOME}/.cargo/bin:${CONTAINER_HOME}/.bun/bin:${CONTAINER_HOME}/.local/bin:/usr/local/go/bin:\${PATH}"
5
+ ENV PATH="${s}/.cargo/bin:${s}/.bun/bin:${s}/.local/bin:/usr/local/go/bin:\${PATH}"
18
6
  # Prompt helper: print the working directory relative to the workspace root, so /workspace shows as
19
7
  # "/" and /workspace/src shows as "/src". Paths outside the workspace fall back to their full path.
20
- RUN echo '__chamba_pwd() { local p="\${PWD#/workspace}"; printf "/%s" "\${p#/}"; }' >> ${CONTAINER_HOME}/.bashrc && \\
21
- echo '# The web interface URL, key and all. The key is minted by the webterm server at every start and' >> ${CONTAINER_HOME}/.bashrc && \\
22
- echo '# published to the key file, so the greeting reads it back instead of holding a URL that goes stale.' >> ${CONTAINER_HOME}/.bashrc && \\
23
- echo '__chamba_web_url() { if [ -r ${WEB_KEY_FILE_PATH} ]; then echo "\${CHAMBA_WEB_URL}/?k=$(head -n 1 ${WEB_KEY_FILE_PATH})"; else echo "\${CHAMBA_WEB_URL}"; fi; }' \\
24
- >> ${CONTAINER_HOME}/.bashrc && \\
25
- echo 'export PS1="\\[\\033[01;32m\\][chamba@\${CHAMBA_WORKSPACE}]\\[\\033[00m\\] \\[\\033[01;34m\\]\\$(__chamba_pwd)\\[\\033[00m\\] \\[\\033[01;32m\\]❯\\[\\033[00m\\] "' \\
26
- >> ${CONTAINER_HOME}/.bashrc && \\
27
- echo 'echo ""' >> ${CONTAINER_HOME}/.bashrc && \\
28
- echo 'echo -e "\\033[32m●\\033[0m \\033[1mYou'"'"'re now in a chamba sandbox\\033[0m \\033[90m·\\033[0m \\033[1m\${CHAMBA_WORKSPACE}\\033[0m"' >> ${CONTAINER_HOME}/.bashrc && \\
29
- echo 'echo ""' >> ${CONTAINER_HOME}/.bashrc && \\
30
- echo 'echo -e " \\033[90m Run \\033[38;5;208mclaude\\033[90m, \\033[38;5;208mopencode\\033[90m, or \\033[38;5;208mcodex\\033[90m to start an agent.\\033[0m"' >> ${CONTAINER_HOME}/.bashrc && \\
31
- echo 'echo -e " \\033[90m Run \\033[97mstatus\\033[90m to see container details & installed versions.\\033[0m"' >> ${CONTAINER_HOME}/.bashrc && \\
32
- echo 'echo -e " \\033[90m Run \\033[97mexit\\033[90m to end the session and return to the host.\\033[0m"' >> ${CONTAINER_HOME}/.bashrc && \\
33
- echo 'echo ""' >> ${CONTAINER_HOME}/.bashrc && \\
34
- echo '# The web interface, where the agents usually run. CHAMBA_WEB_URL is injected by docker run whenever' >> ${CONTAINER_HOME}/.bashrc && \\
35
- echo '# a host port could be published for it, so its absence means this session has no web address.' >> ${CONTAINER_HOME}/.bashrc && \\
36
- echo '# The URL gets its own line: on a host that could not open a browser, this banner is how it is found.' >> ${CONTAINER_HOME}/.bashrc && \\
37
- echo 'if [ -n "$CHAMBA_WEB_URL" ]; then' >> ${CONTAINER_HOME}/.bashrc && \\
38
- echo ' echo -e "\\033[32m●\\033[0m \\033[90mThe web interface for this workspace:\\033[0m"' >> ${CONTAINER_HOME}/.bashrc && \\
39
- echo ' echo ""' >> ${CONTAINER_HOME}/.bashrc && \\
40
- echo ' echo -e " \\033[97m$(__chamba_web_url)\\033[0m"' >> ${CONTAINER_HOME}/.bashrc && \\
41
- echo ' echo ""' >> ${CONTAINER_HOME}/.bashrc && \\
42
- echo 'fi' >> ${CONTAINER_HOME}/.bashrc && \\
43
- echo 'alias status="node ${CONTAINER_STARTUP}"' >> ${CONTAINER_HOME}/.bashrc
8
+ RUN echo '__chamba_pwd() { local p="\${PWD#/workspace}"; printf "/%s" "\${p#/}"; }' >> ${s}/.bashrc && \\
9
+ echo '# The web interface URL, key and all. The key is minted by the webterm server at every start and' >> ${s}/.bashrc && \\
10
+ echo '# published to the key file, so the greeting reads it back instead of holding a URL that goes stale.' >> ${s}/.bashrc && \\
11
+ echo '__chamba_web_url() { if [ -r ${E} ]; then echo "\${CHAMBA_WEB_URL}/?k=$(head -n 1 ${E})"; else echo "\${CHAMBA_WEB_URL}"; fi; }' \\
12
+ >> ${s}/.bashrc && \\
13
+ echo 'export PS1="\\[\\033[01;32m\\][chamba@\${CHAMBA_WORKSPACE}]\\[\\033[00m\\] \\[\\033[01;34m\\]\\$(__chamba_pwd)\\[\\033[00m\\] \\[\\033[01;32m\\]\u276F\\[\\033[00m\\] "' \\
14
+ >> ${s}/.bashrc && \\
15
+ echo 'echo ""' >> ${s}/.bashrc && \\
16
+ echo 'echo -e "\\033[32m\u25CF\\033[0m \\033[1mYou'"'"'re now in a chamba sandbox\\033[0m \\033[90m\xB7\\033[0m \\033[1m\${CHAMBA_WORKSPACE}\\033[0m"' >> ${s}/.bashrc && \\
17
+ echo 'echo ""' >> ${s}/.bashrc && \\
18
+ echo 'echo -e " \\033[90m\u25B8 Run \\033[38;5;208mclaude\\033[90m, \\033[38;5;208mopencode\\033[90m, or \\033[38;5;208mcodex\\033[90m to start an agent.\\033[0m"' >> ${s}/.bashrc && \\
19
+ echo 'echo -e " \\033[90m\u25B8 Run \\033[97mstatus\\033[90m to see container details & installed versions.\\033[0m"' >> ${s}/.bashrc && \\
20
+ echo 'echo -e " \\033[90m\u25B8 Run \\033[97mexit\\033[90m to end the session and return to the host.\\033[0m"' >> ${s}/.bashrc && \\
21
+ echo 'echo ""' >> ${s}/.bashrc && \\
22
+ echo '# The web interface, where the agents usually run. CHAMBA_WEB_URL is injected by docker run whenever' >> ${s}/.bashrc && \\
23
+ echo '# a host port could be published for it, so its absence means this session has no web address.' >> ${s}/.bashrc && \\
24
+ echo '# The URL gets its own line: on a host that could not open a browser, this banner is how it is found.' >> ${s}/.bashrc && \\
25
+ echo 'if [ -n "$CHAMBA_WEB_URL" ]; then' >> ${s}/.bashrc && \\
26
+ echo ' echo -e "\\033[32m\u25CF\\033[0m \\033[90mThe web interface for this workspace:\\033[0m"' >> ${s}/.bashrc && \\
27
+ echo ' echo ""' >> ${s}/.bashrc && \\
28
+ echo ' echo -e " \\033[97m$(__chamba_web_url)\\033[0m"' >> ${s}/.bashrc && \\
29
+ echo ' echo ""' >> ${s}/.bashrc && \\
30
+ echo 'fi' >> ${s}/.bashrc && \\
31
+ echo 'alias status="node ${O}"' >> ${s}/.bashrc
44
32
 
45
33
  CMD ["/bin/bash"]
46
- `;
47
- // --- Build Dockerfile content ------------------------------------------------------------------------------------------------------------
48
- /**
49
- * Assemble a Dockerfile from the base template, optional profile hook, and USER instruction.
50
- * The resulting Dockerfile is never persisted - it's written to a temp file at build time.
51
- */
52
- export function buildDockerfile(baseTemplatePath, profileHook) {
53
- let content = readFileSync(baseTemplatePath, "utf8");
54
- // Append profile hook if provided
55
- if (profileHook?.trim()) {
56
- content += `\n# ---------------------------------------------------------------------------\n`;
57
- content += `# Profile hook (from chamba.yaml)\n`;
58
- content += `# ---------------------------------------------------------------------------\n`;
59
- content += profileHook.endsWith("\n") ? profileHook : `${profileHook}\n`;
60
- }
61
- // USER must come after profile hook and before shell config
62
- content += `\nUSER ${CONTAINER_USER}\n`;
63
- content += USER_SHELL_CONFIG;
64
- return content;
65
- }
66
- // --- Build hash for image staleness detection --------------------------------------------------------------------------------------------
67
- // Filenames (relative to the templates dir) of every artifact that the Dockerfile bakes into the image
68
- // via COPY. Edits to any of these change the build hash and trigger a rebuild prompt at session start.
69
- // Kept in sync with templates/Dockerfile by the bidirectional test in tests/dockerfile-builder.test.ts.
70
- export const BAKED_TEMPLATE_FILES = [
71
- "claude-statusline.sh",
72
- "context-usage.sh",
73
- "git-readonly-wrapper.mjs",
74
- "npmrc",
75
- "pnpm-config.yaml",
76
- "runtime-constants.mjs",
77
- "specs.sh",
78
- "startup-git-mode.mjs",
79
- "startup.mjs",
80
- "webpane.sh",
81
- "webterm.sh",
82
- ];
83
- // Directories (relative to the templates dir) baked into the image via a directory COPY. Hashed
84
- // recursively by computeBuildHash so editing any file inside triggers a rebuild prompt, with
85
- // node_modules skipped - a local install there must never flip the hash (it is .dockerignore'd too).
86
- export const BAKED_TEMPLATE_DIRS = ["pane-apps", "webterm"];
87
- // Relative paths of every regular file under dir (sorted, node_modules subtrees skipped). Missing
88
- // dirs yield [] for the same reason computeBuildHash tolerates missing files: minimal test contexts.
89
- function walkTemplateDir(root, prefix = "") {
90
- const files = [];
91
- let entries;
92
- try {
93
- entries = readdirSync(root, { withFileTypes: true });
94
- }
95
- catch {
96
- return files;
97
- }
98
- for (const entry of entries) {
99
- if (entry.name === "node_modules")
100
- continue;
101
- const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
102
- if (entry.isDirectory())
103
- files.push(...walkTemplateDir(join(root, entry.name), rel));
104
- else if (entry.isFile())
105
- files.push(rel);
106
- }
107
- return files.sort();
108
- }
109
- /**
110
- * Fingerprint everything the package contributes to the image: the assembled Dockerfile content
111
- * plus every file in BAKED_TEMPLATE_FILES, hashed in deterministic order.
112
- *
113
- * Tolerates missing files in buildContextDir. In production (real package install) all baked files
114
- * exist, so the existsSync branch never fires. In tests that build minimal images from a temp
115
- * contextDir, missing files naturally produce a different hash than production - exactly the
116
- * contract the staleness tests rely on.
117
- */
118
- export function computeBuildHash(dockerfileContent, buildContextDir) {
119
- const h = createHash("sha256");
120
- h.update("dockerfile:\n");
121
- h.update(dockerfileContent);
122
- for (const name of [...BAKED_TEMPLATE_FILES].sort()) {
123
- h.update(`\nfile:${name}\n`);
124
- const path = join(buildContextDir, name);
125
- if (existsSync(path)) {
126
- h.update(readFileSync(path));
127
- }
128
- }
129
- for (const dir of [...BAKED_TEMPLATE_DIRS].sort()) {
130
- for (const rel of walkTemplateDir(join(buildContextDir, dir))) {
131
- h.update(`\nfile:${dir}/${rel}\n`);
132
- h.update(readFileSync(join(buildContextDir, dir, rel)));
133
- }
134
- }
135
- return h.digest("hex");
136
- }
137
- // --- Image staleness detection -----------------------------------------------------------------------------------------------------------
138
- //
139
- // Called at session start; returns true if the running container's image is older than the current
140
- // package and needs a rebuild prompt.
141
- //
142
- // Mechanism: at build time, buildImageWithTempfile below stamps every image with a chamba.build-hash
143
- // label carrying computeBuildHash's fingerprint of the assembled Dockerfile + every baked template
144
- // file. At session start we recompute the expected hash from current package sources and compare.
145
- // Any change to templates/Dockerfile, the active profile hook, or any baked template file produces a
146
- // different hash -> rebuild prompt fires.
147
- //
148
- // When shipping a new bake-time artifact:
149
- // - Editing an existing template file or the Dockerfile -> auto-detected. No action.
150
- // - Adding a NEW templated COPY -> add the filename to BAKED_TEMPLATE_FILES above. The unit test in
151
- // tests/dockerfile-builder.test.ts will fail until you do.
152
- //
153
- // Cosmetic Dockerfile edits (comment-only, whitespace) DO trigger a rebuild for users on prior
154
- // images. This is intentional: the Dockerfile and template files are rarely edited, so any change
155
- // is treated as meaningful. A cosmetic edit here costs every user a rebuild, so it is worth asking
156
- // whether the change is worth making at all.
157
- /** Returns true if the container's stamped chamba.build-hash label does not match the expected hash. */
158
- export function isImageStale(containerName, expectedBuildHash) {
159
- const result = spawnSync("docker", ["inspect", "--format", `{{ index .Config.Labels "${LABEL_BUILD_HASH}" }}`, containerName], {
160
- encoding: "utf8",
161
- stdio: "pipe",
162
- });
163
- if (result.status !== 0)
164
- return true;
165
- return result.stdout.trim() !== expectedBuildHash;
166
- }
167
- // --- Build image with temp file ----------------------------------------------------------------------------------------------------------
168
- // Grey ANSI for the inline build percentage on the spinner line.
169
- const grey = (s) => `\x1b[90m${s}\x1b[0m`;
170
- /**
171
- * Run docker build behind a single clack spinner for interactive sessions.
172
- * All raw buildx output is captured, never streamed. BuildKit "[ n/N ]" step markers (from
173
- * --progress=plain) are parsed into a grey percentage on the spinner line when available; if nothing
174
- * parses, the spinner just keeps animating with the plain text. The captured log is written to stderr
175
- * only on failure, so a broken build stays diagnosable.
176
- */
177
- function runBuildWithSpinner(buildArgs) {
178
- return new Promise((resolve) => {
179
- const s = spinner();
180
- const baseMessage = "Docker rebuilding container..";
181
- s.start(baseMessage);
182
- let captured = "";
183
- let lastStep = 0;
184
- let totalSteps = 0;
185
- const onData = (chunk) => {
186
- const text = chunk.toString();
187
- captured += text;
188
- try {
189
- for (const m of text.matchAll(/\[\s*(\d+)\/(\d+)\]/g)) {
190
- const cur = Number(m[1]);
191
- const total = Number(m[2]);
192
- if (total > 0)
193
- totalSteps = total;
194
- if (cur > lastStep)
195
- lastStep = cur;
196
- }
197
- if (totalSteps > 0) {
198
- const pct = Math.min(100, Math.round((lastStep / totalSteps) * 100));
199
- s.message(`${baseMessage} ${grey(`${pct}%`)}`);
200
- }
201
- }
202
- catch {
203
- // Parsing must never affect the build; fall back to the plain animated spinner.
204
- }
205
- };
206
- const child = spawn("docker", buildArgs, { stdio: ["ignore", "pipe", "pipe"] });
207
- child.stdout?.on("data", onData);
208
- child.stderr?.on("data", onData);
209
- child.on("error", (err) => {
210
- s.stop("Docker build failed");
211
- process.stderr.write(`${err.message}\n`);
212
- resolve({ status: 1 });
213
- });
214
- child.on("close", (code) => {
215
- const status = code ?? 1;
216
- if (status === 0) {
217
- s.stop("Container image ready");
218
- }
219
- else {
220
- s.stop("Docker build failed");
221
- process.stderr.write(captured);
222
- }
223
- resolve({ status });
224
- });
225
- });
226
- }
227
- /**
228
- * Write Dockerfile content to a temp file, run docker build, then clean up.
229
- * Build context is always the templates directory so COPY instructions resolve correctly.
230
- * Quiet mode (tests) runs synchronously with captured output; interactive mode shows a spinner.
231
- */
232
- export async function buildImageWithTempfile(dockerfileContent, buildContextDir, imageName, noCache = false, quiet = false) {
233
- const tmpFile = join(tmpdir(), `${CONTAINER_NAME_PREFIX}Dockerfile-${randomBytes(8).toString("hex")}`);
234
- try {
235
- writeFileSync(tmpFile, dockerfileContent);
236
- const buildHash = computeBuildHash(dockerfileContent, buildContextDir);
237
- const buildArgs = [
238
- "build",
239
- "--label",
240
- `${LABEL_MANAGED}=true`,
241
- "--label",
242
- `${LABEL_BUILD_HASH}=${buildHash}`,
243
- "-f",
244
- tmpFile,
245
- "-t",
246
- imageName,
247
- ];
248
- if (noCache)
249
- buildArgs.push("--no-cache");
250
- // Quiet mode (tests): synchronous, output captured, no spinner.
251
- if (quiet) {
252
- buildArgs.push(buildContextDir);
253
- const result = spawnSync("docker", buildArgs, { stdio: "pipe" });
254
- return { status: result.status ?? 1 };
255
- }
256
- // Interactive mode: line-oriented progress so step markers are parseable, hidden behind a spinner.
257
- buildArgs.push("--progress=plain", buildContextDir);
258
- return await runBuildWithSpinner(buildArgs);
259
- }
260
- finally {
261
- try {
262
- unlinkSync(tmpFile);
263
- }
264
- catch {
265
- // temp file cleanup is best-effort
266
- }
267
- }
268
- }
34
+ `;function ne(o,t){let e=d(o,"utf8");return t?.trim()&&(e+=`
35
+ # ---------------------------------------------------------------------------
36
+ `,e+=`# Profile hook (from chamba.yaml)
37
+ `,e+=`# ---------------------------------------------------------------------------
38
+ `,e+=t.endsWith(`
39
+ `)?t:`${t}
40
+ `),e+=`
41
+ USER ${D}
42
+ `,e+=F,e}const j=["claude-statusline.sh","git-readonly-wrapper.mjs","npmrc","pnpm-config.yaml","runtime-constants.mjs","startup-git-mode.mjs","startup.mjs","tool-helper.sh","webpane.sh","webterm.sh"],K=["pane-apps","webterm"];function y(o,t=""){const e=[];let r;try{r=C(o,{withFileTypes:!0})}catch{return e}for(const n of r){if(n.name==="node_modules")continue;const a=t?`${t}/${n.name}`:n.name;n.isDirectory()?e.push(...y(u(o,n.name),a)):n.isFile()&&e.push(a)}return e.sort()}function v(o,t){const e=B("sha256");e.update(`dockerfile:
43
+ `),e.update(o);for(const r of[...j].sort()){e.update(`
44
+ file:${r}
45
+ `);const n=u(t,r);N(n)&&e.update(d(n))}for(const r of[...K].sort())for(const n of y(u(t,r)))e.update(`
46
+ file:${r}/${n}
47
+ `),e.update(d(u(t,r,n)));return e.digest("hex")}function re(o,t){const e=w("docker",["inspect","--format",`{{ index .Config.Labels "${g}" }}`,o],{encoding:"utf8",stdio:"pipe"});return e.status!==0?!0:e.stdout.trim()!==t}const G=new RegExp("\x1B\\[[0-9;]*m","g"),R=o=>`\x1B[90m${o}\x1B[0m`,T=60,X=8,Y=14;function L(o,t){return t<3?"":o.length>t?`${o.slice(0,t-2)}..`:o}function V(o,t){const e=o.replace(G,"").split(/[\r\n]/).map(r=>r.replace(/^#\d+\s+(\d+\.\d+\s+)?/,"").replace(/[^\x20-\x7e]+/g," ").replace(/\s+/g," ").trim()).filter(Boolean);return L(e.at(-1)??"",t)}function q(o,t,e,r){const n=r-1-Y;let a=o,i=o.length;t!==""&&i+2+t.length<=n&&(a+=` ${R(t)}`,i+=2+t.length);const c=Math.min(n-i-2,T);if(e!==""&&c>=X){const h=L(e,c);h!==""&&(a+=` ${R(h)}`)}return a}function z(o){return new Promise(t=>{const e=U({indicator:"timer"}),r="Docker rebuilding container",n=process.stdout.columns??80;e.start(r);let a="",i=0,c=0,h="";const f=m=>{const l=m.toString();a+=l;try{for(const $ of l.matchAll(/\[\s*(\d+)\/(\d+)\]/g)){const _=Number($[1]),A=Number($[2]);A>0&&(c=A),_>i&&(i=_)}const b=c>0?`${Math.min(100,Math.round(i/c*100))}%`:"";if(h=V(l,T)||h,b===""&&h==="")return;const S=Math.min(n,process.stdout.columns??80);e.message(q(r,b,h,S))}catch{}},p=k("docker",o,{stdio:["ignore","pipe","pipe"]});p.stdout?.on("data",f),p.stderr?.on("data",f),p.on("error",m=>{e.stop("Docker build failed"),process.stderr.write(`${m.message}
48
+ `),t({status:1})}),p.on("close",m=>{const l=m??1;l===0?e.stop("Container image ready"):(e.stop("Docker build failed"),process.stderr.write(a)),t({status:l})})})}async function ae(o,t,e,r=!1,n=!1){const a=u(x(),`${H}Dockerfile-${M(8).toString("hex")}`);try{P(a,o);const i=v(o,t),c=["build","--label",`${W}=true`,"--label",`${g}=${i}`,"-f",a,"-t",e];return r&&c.push("--no-cache"),n?(c.push(t),{status:w("docker",c,{stdio:"pipe"}).status??1}):(c.push("--progress=plain",t),await z(c))}finally{try{I(a)}catch{}}}export{K as BAKED_TEMPLATE_DIRS,j as BAKED_TEMPLATE_FILES,ne as buildDockerfile,ae as buildImageWithTempfile,V as buildTailLine,v as computeBuildHash,re as isImageStale,q as spinnerLine};
package/dist/lib/env.js CHANGED
@@ -1,78 +1,2 @@
1
- // =========================================================================================================================================
2
- // src/lib/env.ts - Resolve the `env` field into container environment injection (files + inline vars)
3
- // An `env` entry is either an env-file path (no '=') loaded via --env-file, or an inline KEY=VALUE injected via -e.
4
- // Config is the source of truth: a hashed label over inline vars + resolved file contents recreates the container on change.
5
- // =========================================================================================================================================
6
- import { createHash } from "node:crypto";
7
- import { existsSync, readFileSync } from "node:fs";
8
- import { join } from "node:path";
9
- // --- Constants ---------------------------------------------------------------------------------------------------------------------------
10
- // Inline var keys must be valid shell env identifiers - the same pattern the `ports` env field uses.
11
- const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
12
- // --- Config validation and normalization -------------------------------------------------------------------------------------------------
13
- /**
14
- * Validate and normalize the raw `env` field into inline vars and file entries. This is the single source of
15
- * truth for env semantics (unit-testable without the JSON Schema). A scalar or undefined is normalized to an
16
- * array; entries are trimmed and empties dropped. Classification is by presence of '=': an entry with '=' is
17
- * an inline KEY=VALUE var; an entry without '=' is an env-file path resolved relative to the workspace dir.
18
- * Throws on the first malformed inline entry with a clear message. Missing files never throw - they are
19
- * flagged exists:false and warned about at startup rather than failing the session.
20
- */
21
- export function validateEnvConfig(raw, workspaceDir) {
22
- const entries = (Array.isArray(raw) ? raw : raw === undefined ? [] : [raw]).map((e) => e.trim()).filter((e) => e.length > 0);
23
- const inlineVars = [];
24
- const files = [];
25
- for (const entry of entries) {
26
- const eq = entry.indexOf("=");
27
- if (eq === -1) {
28
- // No '=' -> file path, resolved relative to the workspace dir.
29
- const path = join(workspaceDir, entry);
30
- files.push({ path, exists: existsSync(path) });
31
- }
32
- else {
33
- // Has '=' -> inline KEY=VALUE. The key must be a valid env identifier; the value may be empty or contain further '='.
34
- const key = entry.slice(0, eq);
35
- if (!ENV_KEY_PATTERN.test(key)) {
36
- throw new Error(`env: invalid variable "${entry}" - the key must match ${ENV_KEY_PATTERN.source} (e.g. FOO=bar).`);
37
- }
38
- inlineVars.push(entry);
39
- }
40
- }
41
- return { inlineVars, files };
42
- }
43
- // --- Docker argument builders (pure) -----------------------------------------------------------------------------------------------------
44
- /**
45
- * Docker run args for env injection: --env-file for each existing file (missing files omitted), then
46
- * -e KEY=VALUE per inline var. Files come first so an inline var wins a duplicate key - docker applies later
47
- * -e over an earlier --env-file - which lets a chamba.yaml inline var override a value from an env file.
48
- */
49
- export function envRunArgs(cfg) {
50
- const fileArgs = cfg.files.filter((f) => f.exists).flatMap((f) => ["--env-file", f.path]);
51
- const inlineArgs = cfg.inlineVars.flatMap((v) => ["-e", v]);
52
- return [...fileArgs, ...inlineArgs];
53
- }
54
- /** One warning line per missing env file. A missing file is skipped, never fatal. */
55
- export function envWarnings(cfg) {
56
- return cfg.files.filter((f) => !f.exists).map((f) => `env "${f.path}" not found - skipping`);
57
- }
58
- // --- Fingerprint -------------------------------------------------------------------------------------------------------------------------
59
- /**
60
- * Deterministic fingerprint over the resolved env config, used as the container LABEL_ENV. Covers each inline
61
- * KEY=VALUE and, for each existing file, its path plus contents - so editing an inline var, editing a file's
62
- * contents, or repointing at a different file recreates the container on the next session. An empty config
63
- * (no inline vars and no existing files) fingerprints to "" so an env-less workspace never recreates on
64
- * account of this label. Hashed so no secret values leak into docker labels (which are visible via inspect).
65
- */
66
- export function envLabel(cfg) {
67
- if (cfg.inlineVars.length === 0 && cfg.files.every((f) => !f.exists))
68
- return "";
69
- const parts = [];
70
- for (const v of cfg.inlineVars)
71
- parts.push(`e:${v}`);
72
- for (const f of cfg.files) {
73
- if (!f.exists)
74
- continue;
75
- parts.push(`f:${f.path}\n${readFileSync(f.path, "utf8")}`);
76
- }
77
- return createHash("sha256").update(parts.join("\0")).digest("hex").slice(0, 12);
78
- }
1
+ import{createHash as l}from"node:crypto";import{existsSync as p,readFileSync as c}from"node:fs";import{join as u}from"node:path";const a=/^[A-Za-z_][A-Za-z0-9_]*$/;function m(e,n){const t=(Array.isArray(e)?e:e===void 0?[]:[e]).map(i=>i.trim()).filter(i=>i.length>0),s=[],o=[];for(const i of t){const f=i.indexOf("=");if(f===-1){const r=u(n,i);o.push({path:r,exists:p(r)})}else{const r=i.slice(0,f);if(!a.test(r))throw new Error(`env: invalid variable "${i}" - the key must match ${a.source} (e.g. FOO=bar).`);s.push(i)}}return{inlineVars:s,files:o}}function d(e){const n=e.files.filter(s=>s.exists).flatMap(s=>["--env-file",s.path]),t=e.inlineVars.flatMap(s=>["-e",s]);return[...n,...t]}function y(e){return e.files.filter(n=>!n.exists).map(n=>`env "${n.path}" not found - skipping`)}function A(e){if(e.inlineVars.length===0&&e.files.every(t=>!t.exists))return"";const n=[];for(const t of e.inlineVars)n.push(`e:${t}`);for(const t of e.files)t.exists&&n.push(`f:${t.path}
2
+ ${c(t.path,"utf8")}`);return l("sha256").update(n.join("\0")).digest("hex").slice(0,12)}export{A as envLabel,d as envRunArgs,y as envWarnings,m as validateEnvConfig};
@@ -1,21 +1 @@
1
- // =========================================================================================================================================
2
- // src/lib/git-env.ts - The environment every git call in chamba runs in
3
- // git takes its repo from GIT_DIR, GIT_WORK_TREE and GIT_INDEX_FILE before it takes it from -C or the working
4
- // directory, so a call made from inside a git hook would answer about that hook's repo instead of this one.
5
- // =========================================================================================================================================
6
- /**
7
- * The environment to run git in when the repo is chosen by path.
8
- *
9
- * git takes its repo from the environment as well as from the command line, and GIT_DIR, GIT_WORK_TREE and
10
- * GIT_INDEX_FILE win over `-C` and over the working directory. Anything chamba starts inherits whatever the
11
- * caller had set - a git hook sets all three - so a question asked about the workspace could be answered about
12
- * another repo entirely. Every git call here names its repo by path, so none of those variables is ever wanted.
13
- */
14
- export function gitEnv() {
15
- const env = { ...process.env };
16
- for (const key of Object.keys(env)) {
17
- if (key.startsWith("GIT_"))
18
- delete env[key];
19
- }
20
- return env;
21
- }
1
+ function n(){const e={...process.env};for(const t of Object.keys(e))t.startsWith("GIT_")&&delete e[t];return e}export{n as gitEnv};
@@ -1,66 +1,4 @@
1
- // =========================================================================================================================================
2
- // src/lib/global-config.ts - Host-global settings store (not tied to any workspace)
3
- // Lives at ~/.chamba/global/config as key=value lines, mirroring the per-workspace .lock idiom.
4
- // Settings live here when they are host-wide rather than per-workspace.
5
- // =========================================================================================================================================
6
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
- import { homedir } from "node:os";
8
- import { join } from "node:path";
9
- import { CHAMBA_DIR, GLOBAL_CONFIG_FILE, GLOBAL_DIR, WEB_RANGE_DEFAULT } from "./constants.js";
10
- import { formatWebRange, parseWebRange } from "./ports.js";
11
- // --- Keys --------------------------------------------------------------------------------------------------------------------------------
12
- /** Field names mapped to the keys written in the global config file. */
13
- export const GLOBAL_CONFIG_KEYS = {
14
- webRange: "web_range",
15
- };
16
- // --- Path --------------------------------------------------------------------------------------------------------------------------------
17
- /** Absolute path to the global config file - ~/.chamba/global/config */
18
- export function globalConfigPath() {
19
- return join(homedir(), CHAMBA_DIR, GLOBAL_DIR, GLOBAL_CONFIG_FILE);
20
- }
21
- // --- Parse / write -----------------------------------------------------------------------------------------------------------------------
22
- // Parse the config into an ordered map of raw key=value pairs. Returns an empty map when the file is
23
- // missing or unreadable - absence is not an error, it just means defaults apply. Unknown keys are kept
24
- // so a newer chamba's settings survive a write by an older one.
25
- function parseGlobalConfig() {
26
- const config = new Map();
27
- try {
28
- const lines = readFileSync(globalConfigPath(), "utf8")
29
- .trimEnd()
30
- .split("\n")
31
- .map((l) => l.trim())
32
- .filter(Boolean);
33
- for (const line of lines) {
34
- const eq = line.indexOf("=");
35
- if (eq === -1)
36
- continue;
37
- config.set(line.slice(0, eq), line.slice(eq + 1));
38
- }
39
- }
40
- catch {
41
- // Missing or unreadable - treat as empty.
42
- }
43
- return config;
44
- }
45
- // Write the raw key=value map back to ~/.chamba/global/config, creating ~/.chamba/global/ on demand.
46
- // Unlike the per-workspace lock (which no-ops when missing), the global config has no init step, so it
47
- // is created lazily on the first write.
48
- function writeGlobalConfig(config) {
49
- mkdirSync(join(homedir(), CHAMBA_DIR, GLOBAL_DIR), { recursive: true });
50
- const content = `${[...config].map(([key, value]) => `${key}=${value}`).join("\n")}\n`;
51
- writeFileSync(globalConfigPath(), content);
52
- }
53
- // --- Web agent interface -----------------------------------------------------------------------------------------------------------------
54
- /** Read the web interface host-port range. Falls back to the default when unset, missing, or invalid. */
55
- export function readWebRange() {
56
- const value = parseGlobalConfig().get(GLOBAL_CONFIG_KEYS.webRange);
57
- const parsed = value !== undefined ? parseWebRange(value) : null;
58
- // The default is a constant that always parses; the assertion just narrows the type.
59
- return parsed ?? parseWebRange(WEB_RANGE_DEFAULT);
60
- }
61
- /** Write the web interface host-port range. Creates the config file on demand and preserves all other keys. */
62
- export function writeWebRange(range) {
63
- const config = parseGlobalConfig();
64
- config.set(GLOBAL_CONFIG_KEYS.webRange, formatWebRange(range));
65
- writeGlobalConfig(config);
66
- }
1
+ import{mkdirSync as p,readFileSync as g,writeFileSync as u}from"node:fs";import{homedir as r}from"node:os";import{join as i}from"node:path";import{CHAMBA_DIR as c,GLOBAL_CONFIG_FILE as b,GLOBAL_DIR as a,WEB_RANGE_DEFAULT as d}from"./constants.js";import{formatWebRange as R,parseWebRange as f}from"./ports.js";const s={webRange:"web_range"};function l(){return i(r(),c,a,b)}function m(){const n=new Map;try{const e=g(l(),"utf8").trimEnd().split(`
2
+ `).map(o=>o.trim()).filter(Boolean);for(const o of e){const t=o.indexOf("=");t!==-1&&n.set(o.slice(0,t),o.slice(t+1))}}catch{}return n}function _(n){p(i(r(),c,a),{recursive:!0});const e=`${[...n].map(([o,t])=>`${o}=${t}`).join(`
3
+ `)}
4
+ `;u(l(),e)}function C(){const n=m().get(s.webRange);return(n!==void 0?f(n):null)??f(d)}function E(n){const e=m();e.set(s.webRange,R(n)),_(e)}export{s as GLOBAL_CONFIG_KEYS,l as globalConfigPath,C as readWebRange,E as writeWebRange};
@@ -1,19 +1 @@
1
- // =========================================================================================================================================
2
- // src/lib/pnpm-store.ts - Per-workspace pnpm global store mount
3
- // pnpm hardlinks from its global store into node_modules. Hardlinks cannot cross filesystems, so when the global store sits on the
4
- // container overlay FS while /workspace is a host bind mount, pnpm falls back to creating a per-project .pnpm-store inside the project -
5
- // which then ends up on the host repo. Mounting a host-side cache dir onto pnpm's default global store path puts the store on the same
6
- // device as the workspace and node_modules shadow, eliminating the fallback.
7
- // =========================================================================================================================================
8
- import { mkdirSync } from "node:fs";
9
- import { join } from "node:path";
10
- import { CONTAINER_PNPM_STORE, PNPM_STORE_DIR } from "./constants.js";
11
- /**
12
- * Lazily creates the host-side pnpm store directory under the workspace cache dir
13
- * and returns -v args mounting it onto pnpm's default global store path in the container.
14
- */
15
- export function buildPnpmStoreMountArgs(workspaceCacheDir) {
16
- const hostStore = join(workspaceCacheDir, PNPM_STORE_DIR);
17
- mkdirSync(hostStore, { recursive: true });
18
- return ["-v", `${hostStore}:${CONTAINER_PNPM_STORE}`];
19
- }
1
+ import{mkdirSync as t}from"node:fs";import{join as i}from"node:path";import{CONTAINER_PNPM_STORE as m,PNPM_STORE_DIR as n}from"./constants.js";function P(o){const r=i(o,n);return t(r,{recursive:!0}),["-v",`${r}:${m}`]}export{P as buildPnpmStoreMountArgs};