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,260 @@
1
+ // =========================================================================================================================================
2
+ // src/lib/workspace-identity.ts - Workspace identity, registration, and lookup
3
+ // Uses workspace_id from chamba.yaml. Lock files in ~/.chamba/workspaces/<workspace_id>/
4
+ // =========================================================================================================================================
5
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { dirname, join } from "node:path";
8
+ import { readChambaYaml } from "./chamba-yaml.js";
9
+ import { AGENTS_DIR, CHAMBA_DIR, CHAMBA_YAML, CONTAINER_NAME_PREFIX, DEFAULT_PROFILE, GIT_MODE, GIT_MODES, LOCK_FILE, LOCK_VERSION, SHADOWS_DIR, WORKSPACES_DIR, } from "./constants.js";
10
+ /** Maps LockFile field names to their corresponding keys written in the .lock file. */
11
+ export const LOCK_KEYS = {
12
+ workspaceRoot: "root",
13
+ activeProfile: "profile",
14
+ gitMode: "git_mode",
15
+ webPort: "web_port",
16
+ version: "version",
17
+ };
18
+ /** Reverse lookup: file key -> LockFile field name, used during parsing. */
19
+ const FILE_KEY_TO_FIELD = Object.fromEntries(Object.entries(LOCK_KEYS).map(([field, key]) => [key, field]));
20
+ /** Pre-computed entries for writing - avoids re-casting on every write. */
21
+ const LOCK_ENTRIES = Object.entries(LOCK_KEYS);
22
+ // --- Path helpers ------------------------------------------------------------------------------------------------------------------------
23
+ /** Base directory for all workspace caches - ~/.chamba/workspaces/ */
24
+ export function getWorkspacesBaseDir() {
25
+ return join(homedir(), CHAMBA_DIR, WORKSPACES_DIR);
26
+ }
27
+ /** Cache directory for a specific workspace - ~/.chamba/workspaces/<workspace_id>/ */
28
+ export function getWorkspaceDir(workspaceId) {
29
+ return join(getWorkspacesBaseDir(), workspaceId);
30
+ }
31
+ /** Derive container and image name from workspace ID */
32
+ export function deriveContainerName(workspaceId) {
33
+ return `${CONTAINER_NAME_PREFIX}${workspaceId}`;
34
+ }
35
+ // --- Lock file ---------------------------------------------------------------------------------------------------------------------------
36
+ /** Parse a workspace .lock file into a LockFile object. Returns null if the file is missing or malformed. */
37
+ function parseLockFile(workspaceId) {
38
+ const lockPath = join(getWorkspaceDir(workspaceId), LOCK_FILE);
39
+ try {
40
+ const lines = readFileSync(lockPath, "utf8")
41
+ .trimEnd()
42
+ .split("\n")
43
+ .map((l) => l.trim())
44
+ .filter(Boolean);
45
+ const partial = {};
46
+ for (const line of lines) {
47
+ const eq = line.indexOf("=");
48
+ if (eq === -1)
49
+ continue;
50
+ const field = FILE_KEY_TO_FIELD[line.slice(0, eq)];
51
+ if (field)
52
+ partial[field] = line.slice(eq + 1);
53
+ }
54
+ if (!partial.workspaceRoot)
55
+ return null;
56
+ return {
57
+ workspaceRoot: partial.workspaceRoot,
58
+ activeProfile: partial.activeProfile ?? DEFAULT_PROFILE,
59
+ gitMode: partial.gitMode ?? GIT_MODE.local,
60
+ webPort: partial.webPort ?? "",
61
+ version: partial.version ?? "",
62
+ };
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /**
69
+ * Write a LockFile to disk, always writing all keys. Creates the workspace dir if needed.
70
+ *
71
+ * The shape version is stamped here rather than taken from the caller, so a lock this chamba writes
72
+ * always names the shape it was written in - and a key retired from LOCK_KEYS disappears from the file
73
+ * on the same write.
74
+ */
75
+ function writeLockFileInternal(workspaceId, data) {
76
+ const dir = getWorkspaceDir(workspaceId);
77
+ mkdirSync(dir, { recursive: true });
78
+ const stamped = { ...data, version: LOCK_VERSION };
79
+ const content = `${LOCK_ENTRIES.map(([field, key]) => `${key}=${stamped[field]}`).join("\n")}\n`;
80
+ writeFileSync(join(dir, LOCK_FILE), content);
81
+ }
82
+ /** Read a workspace's lock file. Returns the absolute workspace root path, or null if missing. */
83
+ export function readLockFile(workspaceId) {
84
+ return parseLockFile(workspaceId)?.workspaceRoot ?? null;
85
+ }
86
+ /** Write a workspace's lock file with the owning workspace root path. Preserves all other fields. */
87
+ export function writeLockFile(workspaceId, workspaceRoot) {
88
+ const existing = parseLockFile(workspaceId);
89
+ writeLockFileInternal(workspaceId, {
90
+ workspaceRoot,
91
+ activeProfile: existing?.activeProfile ?? DEFAULT_PROFILE,
92
+ gitMode: existing?.gitMode ?? GIT_MODE.local,
93
+ webPort: existing?.webPort ?? "",
94
+ version: LOCK_VERSION,
95
+ });
96
+ }
97
+ /** Read the active profile name. Returns null if lock file is missing. */
98
+ export function readActiveProfile(workspaceId) {
99
+ return parseLockFile(workspaceId)?.activeProfile ?? null;
100
+ }
101
+ /** Write the active profile name. Preserves workspace root path and git mode. */
102
+ export function writeActiveProfile(workspaceId, profile) {
103
+ const existing = parseLockFile(workspaceId);
104
+ if (!existing)
105
+ return;
106
+ writeLockFileInternal(workspaceId, { ...existing, activeProfile: profile });
107
+ }
108
+ /** Read the active git mode. Returns null if lock file is missing. Coerces unknown values to local. */
109
+ export function readGitMode(workspaceId) {
110
+ const parsed = parseLockFile(workspaceId);
111
+ if (!parsed)
112
+ return null;
113
+ const value = parsed.gitMode;
114
+ return GIT_MODES.includes(value) ? value : GIT_MODE.local;
115
+ }
116
+ /** Write the active git mode. Preserves workspace root path and active profile. */
117
+ export function writeGitMode(workspaceId, gitMode) {
118
+ const existing = parseLockFile(workspaceId);
119
+ if (!existing)
120
+ return;
121
+ writeLockFileInternal(workspaceId, { ...existing, gitMode });
122
+ }
123
+ /** Read the sticky web interface host port. Returns null when the lock is missing, unset, or not a positive integer. */
124
+ export function readWebPort(workspaceId) {
125
+ const value = parseLockFile(workspaceId)?.webPort;
126
+ if (!value)
127
+ return null;
128
+ const port = Number(value);
129
+ return Number.isInteger(port) && port > 0 ? port : null;
130
+ }
131
+ /**
132
+ * Write the sticky web interface host port, preserving all other fields. Returns false when the lock
133
+ * file is missing or unreadable, so the port was NOT persisted: callers must not hand out a port they
134
+ * failed to record, or the next workspace is handed the same one (it stays invisible to the
135
+ * assigned-port scan) and two workspaces end up on one URL.
136
+ */
137
+ export function writeWebPort(workspaceId, port) {
138
+ const existing = parseLockFile(workspaceId);
139
+ if (!existing)
140
+ return false;
141
+ writeLockFileInternal(workspaceId, { ...existing, webPort: String(port) });
142
+ return true;
143
+ }
144
+ /** Read the workspace shape version stamped in the lock. Empty string for a lock written without one, null when there is no lock. */
145
+ export function readLockVersion(workspaceId) {
146
+ return parseLockFile(workspaceId)?.version ?? null;
147
+ }
148
+ // --- Workspace directory initialization --------------------------------------------------------------------------------------------------
149
+ /** Initialize ~/.chamba/workspaces/<workspace_id>/ with lock file and subdirs. */
150
+ export function initWorkspaceDir(workspaceId, workspaceRoot, activeProfile = DEFAULT_PROFILE, gitMode = GIT_MODE.local) {
151
+ const dir = getWorkspaceDir(workspaceId);
152
+ mkdirSync(join(dir, AGENTS_DIR), { recursive: true });
153
+ mkdirSync(join(dir, SHADOWS_DIR), { recursive: true });
154
+ writeLockFileInternal(workspaceId, { workspaceRoot, activeProfile, gitMode, webPort: "", version: LOCK_VERSION });
155
+ }
156
+ // --- Listing -----------------------------------------------------------------------------------------------------------------------------
157
+ /** List all registered workspace IDs (directories with a .lock file) */
158
+ export function listWorkspaceIds() {
159
+ const base = getWorkspacesBaseDir();
160
+ if (!existsSync(base))
161
+ return [];
162
+ try {
163
+ return readdirSync(base).filter((name) => existsSync(join(base, name, LOCK_FILE)));
164
+ }
165
+ catch {
166
+ return [];
167
+ }
168
+ }
169
+ /** List all registered workspaces as WorkspaceContext objects. Skips entries whose lock target has no chamba.yaml. */
170
+ export function listWorkspaces() {
171
+ return listWorkspaceIds()
172
+ .map((workspaceId) => {
173
+ const lockPath = readLockFile(workspaceId);
174
+ if (!lockPath)
175
+ return null;
176
+ try {
177
+ const yaml = readChambaYaml(lockPath);
178
+ if (!yaml || yaml.workspace_id !== workspaceId)
179
+ return null;
180
+ return {
181
+ workspaceId,
182
+ workspaceRoot: lockPath,
183
+ containerName: deriveContainerName(workspaceId),
184
+ workspaceDir: getWorkspaceDir(workspaceId),
185
+ };
186
+ }
187
+ catch {
188
+ return null;
189
+ }
190
+ })
191
+ .filter((w) => w !== null);
192
+ }
193
+ // --- Resolution --------------------------------------------------------------------------------------------------------------------------
194
+ /**
195
+ * Walk up from the given path looking for chamba.yaml. If found, read workspace_id,
196
+ * verify lock file, and return WorkspaceContext. Returns null if no chamba.yaml found
197
+ * or if the workspace dir is not initialized.
198
+ */
199
+ export function resolveWorkspace(fromPath) {
200
+ let current = fromPath;
201
+ while (true) {
202
+ const yaml = readChambaYaml(current);
203
+ if (yaml) {
204
+ const lockPath = readLockFile(yaml.workspace_id);
205
+ // Workspace dir exists and lock matches this path
206
+ if (lockPath === current) {
207
+ return {
208
+ workspaceId: yaml.workspace_id,
209
+ workspaceRoot: current,
210
+ containerName: deriveContainerName(yaml.workspace_id),
211
+ workspaceDir: getWorkspaceDir(yaml.workspace_id),
212
+ };
213
+ }
214
+ // chamba.yaml found but workspace not initialized or lock mismatch - return null
215
+ // (caller handles onboarding / collision)
216
+ return null;
217
+ }
218
+ const parent = dirname(current);
219
+ if (parent === current)
220
+ break;
221
+ current = parent;
222
+ }
223
+ return null;
224
+ }
225
+ /**
226
+ * Walk up from the given path looking for chamba.yaml. Returns the directory containing it, or null.
227
+ */
228
+ export function findChambaYamlDir(fromPath) {
229
+ let current = fromPath;
230
+ while (true) {
231
+ if (existsSync(join(current, CHAMBA_YAML)))
232
+ return current;
233
+ const parent = dirname(current);
234
+ if (parent === current)
235
+ break;
236
+ current = parent;
237
+ }
238
+ return null;
239
+ }
240
+ // --- Collision and orphan detection ------------------------------------------------------------------------------------------------------
241
+ /** Check if a workspace_id's lock file points to a different path than expected. */
242
+ export function checkCollision(workspaceId, currentPath) {
243
+ const lockPath = readLockFile(workspaceId);
244
+ if (lockPath === null)
245
+ return "ok"; // no lock = no collision
246
+ return lockPath === currentPath ? "ok" : "collision";
247
+ }
248
+ /**
249
+ * Scan all workspace dirs for a lock file pointing to the given path.
250
+ * Used when a workspace_id has changed and we need to find the orphaned dir.
251
+ * Returns the orphan's workspace_id, or null.
252
+ */
253
+ export function findOrphanWorkspaceDir(currentPath) {
254
+ for (const id of listWorkspaceIds()) {
255
+ const lockPath = readLockFile(id);
256
+ if (lockPath === currentPath)
257
+ return id;
258
+ }
259
+ return null;
260
+ }
package/package.json CHANGED
@@ -1,26 +1,63 @@
1
1
  {
2
- "name": "chamba",
3
- "version": "0.3.0",
4
- "description": "A browser workspace beside your terminal coding agent - a rich parallel interface to the terminal chat.",
5
- "type": "module",
6
- "bin": {
7
- "chamba": "dist/cli.js"
8
- },
9
- "engines": {
10
- "node": ">=22.12"
11
- },
12
- "files": [
13
- "dist",
14
- "web",
15
- "inject",
16
- "skill",
17
- "README.md"
18
- ],
19
- "dependencies": {
20
- "hono": "4.12.30",
21
- "@hono/node-server": "1.19.14",
22
- "@hono/node-ws": "1.3.1",
23
- "ws": "8.21.1",
24
- "zod": "4.4.3"
25
- }
2
+ "name": "chamba",
3
+ "version": "0.4.0",
4
+ "description": "Run AI coding agents in a container, from your browser",
5
+ "type": "module",
6
+ "bin": {
7
+ "chamba": "./bin/chamba.js"
8
+ },
9
+ "dependencies": {
10
+ "@clack/prompts": "^1.1.0",
11
+ "ajv": "^8.18.0",
12
+ "fast-glob": "^3.3.3",
13
+ "js-yaml": "^4.1.1"
14
+ },
15
+ "devDependencies": {
16
+ "@biomejs/biome": "^2.4.10",
17
+ "@types/js-yaml": "^4.0.9",
18
+ "@types/node": "^25.4.0",
19
+ "tsx": "^4.21.0",
20
+ "typescript": "^5.9.3"
21
+ },
22
+ "scripts": {
23
+ "start": "node bin/chamba.js",
24
+ "build": "tsx scripts/build.ts",
25
+ "re:build": "rm -rf dist && pnpm build",
26
+ "prepare": "git config core.hooksPath .githooks",
27
+ "typecheck": "tsc --noEmit",
28
+ "lint": "biome check .",
29
+ "lint:fix": "biome check --write .",
30
+ "test": "node --import tsx/esm --test 'tests/*.test.ts'",
31
+ "test:docker": "node --import tsx/esm --test 'tests/docker/**/*.test.ts'",
32
+ "test:all": "pnpm test && pnpm test:docker",
33
+ "validate": "pnpm typecheck && pnpm lint && tsx scripts/check.ts && pnpm test",
34
+ "check": "pnpm validate && pnpm re:build",
35
+ "release": "tsx scripts/release.ts",
36
+ "prepublishOnly": "pnpm re:build"
37
+ },
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "license": "MIT",
42
+ "author": "Asaf Ratzon",
43
+ "packageManager": "pnpm@11.9.0",
44
+ "keywords": [
45
+ "ai",
46
+ "ai-agent",
47
+ "claude",
48
+ "opencode",
49
+ "codex",
50
+ "docker",
51
+ "sandbox",
52
+ "devcontainer",
53
+ "cli",
54
+ "developer-tools"
55
+ ],
56
+ "files": [
57
+ "bin/",
58
+ "templates/",
59
+ "schema/",
60
+ "LICENSE",
61
+ "dist/"
62
+ ]
26
63
  }
@@ -0,0 +1,65 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "chamba.yaml",
4
+ "description": "Configuration file for chamba - secure AI dev containers",
5
+ "type": "object",
6
+ "required": ["workspace_id"],
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "workspace_id": {
10
+ "type": "string",
11
+ "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$",
12
+ "minLength": 2,
13
+ "maxLength": 48,
14
+ "description": "Unique workspace identifier. Used for container naming and cache directory. Lowercase alphanumeric and hyphens only."
15
+ },
16
+ "env": {
17
+ "type": ["string", "array"],
18
+ "items": { "type": "string" },
19
+ "description": "Environment injected into the container at runtime. A scalar or a list; each entry is either an env-file path relative to chamba.yaml (e.g. '.env', loaded via --env-file; a missing file is skipped with a warning) or an inline 'KEY=VALUE' variable (contains '=', injected via -e). Inline vars override file-provided values. Changing env recreates the container on the next session."
20
+ },
21
+ "shadow_paths": {
22
+ "type": "array",
23
+ "items": { "type": "string" },
24
+ "description": "Gitignore-style patterns. Matching paths are hidden from the agent - the container gets an empty, isolated copy instead. Patterns without a '/' match at any depth (e.g. 'node_modules' matches all nested node_modules too). Patterns with a '/' match relative to the workspace root. Supports globs: * (any chars), ? (single char), {a,b} (alternatives)."
25
+ },
26
+ "profiles": {
27
+ "type": "object",
28
+ "description": "Named Dockerfile profiles. Each profile defines a dockerfile_hook with raw Dockerfile instructions appended to the base image.",
29
+ "additionalProperties": {
30
+ "type": "object",
31
+ "properties": {
32
+ "description": {
33
+ "type": "string",
34
+ "description": "Human-readable description of this profile shown as a hint during profile selection."
35
+ },
36
+ "dockerfile_hook": {
37
+ "type": "string",
38
+ "description": "Raw Dockerfile instructions appended after the base image layers. Use for installing additional runtimes, tools, or dependencies."
39
+ }
40
+ },
41
+ "additionalProperties": false
42
+ }
43
+ },
44
+ "ports": {
45
+ "type": "array",
46
+ "description": "Container ports published to the host, loopback-only (127.0.0.1). A host port already in use fails at startup. Give each workspace its own ports to avoid collisions between parallel workspaces.",
47
+ "items": {
48
+ "type": "object",
49
+ "required": ["port"],
50
+ "additionalProperties": false,
51
+ "properties": {
52
+ "port": {
53
+ "type": ["integer", "string"],
54
+ "description": "A bare integer identity-maps the port (e.g. 4820 -> 127.0.0.1:4820:4820). A quoted \"HOST:CONTAINER\" string maps them explicitly, host first as in docker (e.g. \"8080:3000\"). Both sides must be 1024-65535 (the CLI runs unprivileged). Container port 3899 is reserved for the chamba web agent interface."
55
+ },
56
+ "env": {
57
+ "type": "string",
58
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
59
+ "description": "Env var injected into the container carrying the host port number. Allowed only on identity entries (a bare port number)."
60
+ }
61
+ }
62
+ }
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,3 @@
1
+ # Never send a locally-installed webterm/node_modules to the build context - deps are
2
+ # installed in-image by npm ci, and a host install may carry a wrong-arch node-pty build.
3
+ webterm/node_modules
@@ -0,0 +1,173 @@
1
+ # =============================================================================
2
+ # chamba - Secure AI Dev Container (essentials base)
3
+ # =============================================================================
4
+ # Non-root user, git remote block, AI tools: opencode, claude, codex
5
+ # Base runtime: Node.js LTS + python3 (for build scripts)
6
+ #
7
+ # Additional runtimes (Go, Rust, Java, Bun, etc.) are added via
8
+ # dockerfile_hook in chamba.yaml profiles.
9
+ # =============================================================================
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Version pinning policy
13
+ # Infra tools whose major versions can change behavior are pinned exact so that
14
+ # fresh and --no-cache builds stay reproducible; bumping a pin edits this file,
15
+ # busts the build hash, and auto-prompts a rebuild. Deliberately left floating:
16
+ # apt packages (pinned by the Debian trixie release), Node (major-pinned via
17
+ # setup_24.x; minors carry security fixes), and the AI CLIs (always latest).
18
+ # ---------------------------------------------------------------------------
19
+
20
+ FROM debian:trixie-slim
21
+ LABEL chamba.managed=true
22
+
23
+ # Enable 24-bit color support for AI CLIs (suppresses color upgrade prompts)
24
+ ENV COLORTERM=truecolor
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Layer 1 - System packages
28
+ # ---------------------------------------------------------------------------
29
+ RUN apt-get update && apt-get install -y --no-install-recommends \
30
+ # Core utilities
31
+ git curl wget bash zsh make \
32
+ # Build essentials
33
+ build-essential pkg-config libssl-dev \
34
+ # Utilities
35
+ jq unzip zip tree htop procps lsb-release gnupg ca-certificates file bubblewrap \
36
+ # Modern search/navigation tools (fd-find installs the binary as 'fdfind'; symlinked to 'fd' below)
37
+ ripgrep fzf fd-find \
38
+ # Database clients
39
+ sqlite3 postgresql-client default-mysql-client redis-tools \
40
+ # Python (needed for build scripts and as a light default)
41
+ python3 python3-pip python3-venv pipx \
42
+ && rm -rf /var/lib/apt/lists/*
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Layer 2 - fd (from apt fd-find; Debian names the binary 'fdfind')
46
+ # Symlink to 'fd' so the tool is callable by its upstream name.
47
+ # ---------------------------------------------------------------------------
48
+ RUN ln -sf "$(command -v fdfind)" /usr/local/bin/fd
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Layer 3 - yq (GitHub release, pinned)
52
+ # ---------------------------------------------------------------------------
53
+ RUN ARCH=$(dpkg --print-architecture) && \
54
+ curl -fsSL "https://github.com/mikefarah/yq/releases/download/v4.53.3/yq_linux_${ARCH}" \
55
+ -o /usr/local/bin/yq && chmod +x /usr/local/bin/yq
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Layer 4 - GitHub CLI
59
+ # ---------------------------------------------------------------------------
60
+ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
61
+ | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && \
62
+ echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] \
63
+ https://cli.github.com/packages stable main" \
64
+ > /etc/apt/sources.list.d/github-cli.list && \
65
+ apt-get update && apt-get install -y gh && rm -rf /var/lib/apt/lists/*
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Layer 5 - Node.js 24 LTS (via NodeSource)
69
+ # Trixie's apt nodejs is 20.x; NodeSource is pinned to the current Active LTS (24.x).
70
+ # ---------------------------------------------------------------------------
71
+ RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && \
72
+ apt-get install -y nodejs && rm -rf /var/lib/apt/lists/*
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Layer 6 - Git remote block (security boundary)
76
+ # ---------------------------------------------------------------------------
77
+ RUN git config --system protocol.allow never && \
78
+ git config --system protocol.file.allow always
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Layer 7 - Global npm tools (pnpm pinned; AI CLIs always latest)
82
+ # pnpm is pinned exact: pnpm 11 changed where global settings are read from
83
+ # (~/.config/pnpm/config.yaml, no longer ~/.npmrc), so an unpinned major bump
84
+ # can silently break the baked store-dir config.
85
+ #
86
+ # The date is stamped in this same RUN, not further down, because it is the
87
+ # claim "the CLIs are this fresh" and startup.mjs skips its update for a day
88
+ # on the strength of it. Docker caches this layer until a line above changes,
89
+ # so an image rebuilt weeks later can carry CLIs weeks old; written here, the
90
+ # stamp is cached along with them and stays true. Written to a neutral path
91
+ # because /home/devuser does not exist yet - the next layer copies it in.
92
+ # ---------------------------------------------------------------------------
93
+ RUN npm install -g \
94
+ pnpm@11.6.0 \
95
+ opencode-ai \
96
+ @anthropic-ai/claude-code \
97
+ @openai/codex \
98
+ && npm cache clean --force \
99
+ && date -u +"%Y-%m-%dT%H:%M:%S.000Z" > /usr/local/share/ai-cli-installed
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Layer 8 - Non-root user
103
+ # ---------------------------------------------------------------------------
104
+ RUN groupadd --gid 1001 devuser && \
105
+ useradd --uid 1001 --gid devuser --shell /bin/bash --create-home devuser && \
106
+ mkdir -p /home/devuser/.local/state /home/devuser/.local/share /home/devuser/.local/share/pnpm /home/devuser/.local/bin /home/devuser/.config/pnpm && \
107
+ cp /usr/local/share/ai-cli-installed /home/devuser/.ai-cli-updated && \
108
+ chown -R devuser:devuser /home/devuser
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Layer 9 - Bake startup script + git mode helper + shared constants + pnpm config into image
112
+ # pnpm 11+ reads settings from ~/.config/pnpm/config.yaml; ~/.npmrc covers anyone pinning pnpm <= 10.
113
+ # ---------------------------------------------------------------------------
114
+ COPY npmrc /home/devuser/.npmrc
115
+ COPY pnpm-config.yaml /home/devuser/.config/pnpm/config.yaml
116
+ COPY startup.mjs /home/devuser/startup.mjs
117
+ COPY startup-git-mode.mjs /home/devuser/startup-git-mode.mjs
118
+ COPY runtime-constants.mjs /home/devuser/runtime-constants.mjs
119
+ RUN chown devuser:devuser /home/devuser/.npmrc /home/devuser/.config/pnpm/config.yaml /home/devuser/startup.mjs /home/devuser/startup-git-mode.mjs /home/devuser/runtime-constants.mjs
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Layer 10 - Bake git read-only wrapper into image
123
+ # Symlinked to /usr/local/bin/git by startup.mjs when git_mode=strict.
124
+ # ---------------------------------------------------------------------------
125
+ RUN mkdir -p /usr/local/share/chamba
126
+ COPY git-readonly-wrapper.mjs /usr/local/share/chamba/git-readonly
127
+ RUN chmod +x /usr/local/share/chamba/git-readonly
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Layer 11 - Bake the Claude status line script
131
+ # Referenced from ~/.claude/settings.json (statusLine.command) at session start.
132
+ # It prints nothing: what it is for is the per-session snapshot it writes, which
133
+ # the web interface's status strip and the context-usage helper read.
134
+ # ---------------------------------------------------------------------------
135
+ COPY claude-statusline.sh /usr/local/share/chamba/claude-statusline.sh
136
+ RUN chmod +x /usr/local/share/chamba/claude-statusline.sh
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Layer 12 - Bake context-usage helper
140
+ # Prints the context/quota snapshot written by the status line script, so
141
+ # agents can inspect their own usage. On PATH via the symlink.
142
+ # ---------------------------------------------------------------------------
143
+ COPY context-usage.sh /usr/local/share/chamba/context-usage.sh
144
+ RUN chmod +x /usr/local/share/chamba/context-usage.sh && \
145
+ ln -sf /usr/local/share/chamba/context-usage.sh /usr/local/bin/context-usage
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Layer 13 - Bake webterm (web agent interface) with deps preinstalled
149
+ # npm ci builds node-pty against this image's toolchain (Layer 1). chamba
150
+ # starts the server with the container; the launcher stays for switching
151
+ # which agent new browser sessions start with.
152
+ # ---------------------------------------------------------------------------
153
+ COPY webterm/ /usr/local/share/chamba/webterm/
154
+ RUN cd /usr/local/share/chamba/webterm && npm ci --omit=dev && npm cache clean --force
155
+ COPY webterm.sh /usr/local/share/chamba/webterm.sh
156
+ RUN chmod +x /usr/local/share/chamba/webterm.sh && \
157
+ ln -sf /usr/local/share/chamba/webterm.sh /usr/local/bin/webterm
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Layer 14 - Bake the web pane publish helper
161
+ # Publishes an HTML file as a page in the calling session's pane. On PATH via
162
+ # the symlink, so any process inside a session can use it.
163
+ # ---------------------------------------------------------------------------
164
+ COPY webpane.sh /usr/local/share/chamba/webpane.sh
165
+ RUN chmod +x /usr/local/share/chamba/webpane.sh && \
166
+ ln -sf /usr/local/share/chamba/webpane.sh /usr/local/bin/webpane
167
+
168
+ WORKDIR /workspace
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # NOTE: USER devuser is appended by chamba at build time (after profile hook)
172
+ # Do not add it here.
173
+ # ---------------------------------------------------------------------------