chamba 0.3.1 → 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.
- package/LICENSE +21 -0
- package/README.md +47 -44
- package/bin/chamba.js +212 -0
- package/dist/commands/advanced.js +278 -0
- package/dist/commands/dev.js +619 -0
- package/dist/commands/doctor.js +29 -0
- package/dist/commands/menu.js +80 -0
- package/dist/commands/onboard.js +229 -0
- package/dist/commands/settings.js +349 -0
- package/dist/lib/agent-context.js +177 -0
- package/dist/lib/browser.js +40 -0
- package/dist/lib/chamba-yaml.js +191 -0
- package/dist/lib/constants.js +135 -0
- package/dist/lib/dockerfile-builder.js +267 -0
- package/dist/lib/env.js +78 -0
- package/dist/lib/global-config.js +66 -0
- package/dist/lib/pnpm-store.js +19 -0
- package/dist/lib/ports.js +210 -0
- package/dist/lib/safe-rm.js +26 -0
- package/dist/lib/sessions.js +34 -0
- package/dist/lib/shadows.js +174 -0
- package/dist/lib/webterm.js +490 -0
- package/dist/lib/workspace-identity.js +260 -0
- package/package.json +61 -23
- package/schema/chamba.schema.json +65 -0
- package/templates/.dockerignore +3 -0
- package/templates/Dockerfile +173 -0
- package/templates/claude-statusline.sh +120 -0
- package/templates/context/baseline.md +13 -0
- package/templates/context/context-usage.md +1 -0
- package/templates/context/git-mode-local.md +1 -0
- package/templates/context/git-mode-strict.md +1 -0
- package/templates/context/git-mode-unrestricted.md +1 -0
- package/templates/context/git-unavailable.md +1 -0
- package/templates/context/shadow-paths.md +3 -0
- package/templates/context-usage.sh +249 -0
- package/templates/git-readonly-wrapper.mjs +309 -0
- package/templates/npmrc +2 -0
- package/templates/pnpm-config.yaml +9 -0
- package/templates/runtime-constants.mjs +18 -0
- package/templates/skills/chamba-statusline/SKILL.md +79 -0
- package/templates/skills/context-usage/SKILL.md +53 -0
- package/templates/skills/web-pane/SKILL.md +62 -0
- package/templates/startup-git-mode.mjs +145 -0
- package/templates/startup.mjs +333 -0
- package/templates/webpane.sh +126 -0
- package/templates/webterm/README.md +157 -0
- package/templates/webterm/artifacts.js +583 -0
- package/templates/webterm/config.js +269 -0
- package/templates/webterm/context/claude.md +14 -0
- package/templates/webterm/conversation.js +248 -0
- package/templates/webterm/package-lock.json +884 -0
- package/templates/webterm/package.json +17 -0
- package/templates/webterm/pane.js +156 -0
- package/templates/webterm/proc.js +89 -0
- package/templates/webterm/public/app/alerts.js +472 -0
- package/templates/webterm/public/app/cards.js +123 -0
- package/templates/webterm/public/app/clipboard.js +229 -0
- package/templates/webterm/public/app/composer.js +226 -0
- package/templates/webterm/public/app/connection.js +342 -0
- package/templates/webterm/public/app/dictation.js +98 -0
- package/templates/webterm/public/app/dom.js +37 -0
- package/templates/webterm/public/app/drafts.js +244 -0
- package/templates/webterm/public/app/frames.js +166 -0
- package/templates/webterm/public/app/main.js +82 -0
- package/templates/webterm/public/app/new-session.js +188 -0
- package/templates/webterm/public/app/note.js +24 -0
- package/templates/webterm/public/app/pane-frame.js +166 -0
- package/templates/webterm/public/app/pane.js +353 -0
- package/templates/webterm/public/app/state.js +51 -0
- package/templates/webterm/public/app/status-strip.js +170 -0
- package/templates/webterm/public/app/tabs.js +475 -0
- package/templates/webterm/public/app/terminal.js +102 -0
- package/templates/webterm/public/app/theme.js +46 -0
- package/templates/webterm/public/favicon.svg +21 -0
- package/templates/webterm/public/index.html +105 -0
- package/templates/webterm/public/styles.css +1193 -0
- package/templates/webterm/server.js +1142 -0
- package/templates/webterm/sessions.js +515 -0
- package/templates/webterm/snapshot.js +135 -0
- package/templates/webterm.sh +167 -0
- package/dist/cli.js +0 -1582
- package/dist/server.js +0 -1831
- package/inject/annotate.js +0 -18
- package/skill/README.md +0 -12
- package/skill/SKILL.md +0 -93
- package/web/assets/highlighted-body-OFNGDK62-Bn4Eu7CG.js +0 -1
- package/web/assets/index-B9DI4F1Z.js +0 -202
- package/web/assets/index-DK_n6CTo.css +0 -2
- package/web/assets/mermaid-GHXKKRXX-CEMduc-U.js +0 -1
- package/web/index.html +0 -28
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// =========================================================================================================================================
|
|
2
|
+
// src/lib/safe-rm.ts - Safe wrapper around rmSync
|
|
3
|
+
// All deletions in chamba go through safeRmSync. A biome lint rule (noRestrictedImports) bans
|
|
4
|
+
// importing rmSync from node:fs everywhere else, enforcing this as the single deletion point.
|
|
5
|
+
// =========================================================================================================================================
|
|
6
|
+
// biome-ignore lint/style/noRestrictedImports: safeRmSync is the only approved user of rmSync
|
|
7
|
+
import { rmSync } from "node:fs";
|
|
8
|
+
import { homedir, tmpdir } from "node:os";
|
|
9
|
+
import { basename, join, resolve, sep } from "node:path";
|
|
10
|
+
import { CHAMBA_DIR, CHAMBA_YAML, CONTAINER_NAME_PREFIX } from "./constants.js";
|
|
11
|
+
const CHAMBA_HOME = resolve(join(homedir(), CHAMBA_DIR));
|
|
12
|
+
const TEST_TMP_PREFIX = join(tmpdir(), `${CONTAINER_NAME_PREFIX}test-`);
|
|
13
|
+
/**
|
|
14
|
+
* Safe wrapper around rmSync. Throws if the path is outside a chamba-owned location:
|
|
15
|
+
* - ~/.chamba/ (workspace caches, agents, shadows, global config)
|
|
16
|
+
* - A file named chamba.yaml (workspace config file in any user workspace root)
|
|
17
|
+
* - <tmpdir>/chamba-test-* (test temp directories)
|
|
18
|
+
*/
|
|
19
|
+
export function safeRmSync(path, options) {
|
|
20
|
+
const r = resolve(path);
|
|
21
|
+
const ok = r === CHAMBA_HOME || r.startsWith(CHAMBA_HOME + sep) || basename(r) === CHAMBA_YAML || r.startsWith(TEST_TMP_PREFIX);
|
|
22
|
+
if (!ok) {
|
|
23
|
+
throw new Error(`safeRmSync: refusing to delete '${r}' - must be under ~/.chamba/, named chamba.yaml, or a test temp dir`);
|
|
24
|
+
}
|
|
25
|
+
rmSync(r, options);
|
|
26
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// =========================================================================================================================================
|
|
2
|
+
// src/lib/sessions.ts - Host-side detection of live interactive container sessions.
|
|
3
|
+
// dev.ts connects each session with `docker exec -it ... <container> bash --login`. The host-side exec
|
|
4
|
+
// client process is the proxy for one live session: it dies on clean exit, closed terminal, or kill, but
|
|
5
|
+
// survives a sleep-freeze. Orphaned in-container shells (host side gone, PTY left open by dockerd) have no
|
|
6
|
+
// host client and are correctly ignored - which is why counting on the host, not inside the container, is
|
|
7
|
+
// the reliable signal for "last session closed".
|
|
8
|
+
// =========================================================================================================================================
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
// The interactive session shell, as argv. Single source of truth: both the connect command and the
|
|
11
|
+
// detector's match needle derive from this, so they cannot drift apart.
|
|
12
|
+
export const CONTAINER_LOGIN_SHELL = ["bash", "--login"];
|
|
13
|
+
// --- Connect -----------------------------------------------------------------------------------------------------------------------------
|
|
14
|
+
// Build the `docker exec` argv that opens an interactive session in `containerName` at `workdir`.
|
|
15
|
+
// dev.ts spawns exactly this; sessionMatchNeedle matches against it, so detection stays in lockstep.
|
|
16
|
+
export function loginShellExecArgs(workdir, containerName) {
|
|
17
|
+
return ["exec", "-it", "-w", workdir, containerName, ...CONTAINER_LOGIN_SHELL];
|
|
18
|
+
}
|
|
19
|
+
// --- Detection ---------------------------------------------------------------------------------------------------------------------------
|
|
20
|
+
// The substring identifying one container's session client in a host `pgrep -f` scan: the container name
|
|
21
|
+
// followed by the login shell args (e.g. "chamba-orot-io bash --login"). Specific enough to exclude other
|
|
22
|
+
// `docker exec <c> ...` shapes. Container ids are kebab-case (schema-constrained), so there are no regex
|
|
23
|
+
// metacharacters to escape.
|
|
24
|
+
export function sessionMatchNeedle(containerName) {
|
|
25
|
+
return `${containerName} ${CONTAINER_LOGIN_SHELL.join(" ")}`;
|
|
26
|
+
}
|
|
27
|
+
// Count live interactive sessions to a single container by scanning HOST processes for the exec client.
|
|
28
|
+
// `pgrep -f` (portable across macOS/BSD and Linux; `-c` is Linux-only) excludes its own PID. A missing or
|
|
29
|
+
// errored pgrep yields no stdout -> 0, preserving the old "errored -> 0" safety. At a clean exit the just-
|
|
30
|
+
// left client is already reaped before this runs, so the last exit reaches 0.
|
|
31
|
+
export function containerSessionCount(containerName) {
|
|
32
|
+
const r = spawnSync("pgrep", ["-f", sessionMatchNeedle(containerName)], { encoding: "utf8", stdio: "pipe" });
|
|
33
|
+
return (r.stdout ?? "").split("\n").filter(Boolean).length;
|
|
34
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// =========================================================================================================================================
|
|
2
|
+
// src/lib/shadows.ts - Gitignore-style shadow path expansion and sync
|
|
3
|
+
// Expands patterns like "node_modules", ".env*" into concrete paths, then syncs shadow directories.
|
|
4
|
+
// =========================================================================================================================================
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join, relative } from "node:path";
|
|
8
|
+
import fg from "fast-glob";
|
|
9
|
+
import { CONTAINER_WORKSPACE, SHADOWS_DIR } from "./constants.js";
|
|
10
|
+
import { safeRmSync } from "./safe-rm.js";
|
|
11
|
+
/**
|
|
12
|
+
* Expand gitignore-style patterns into concrete relative paths.
|
|
13
|
+
*
|
|
14
|
+
* Patterns without a directory separator are treated as recursive (prepended with **/)
|
|
15
|
+
* following gitignore convention. Patterns with a / are matched relative to the workspace root.
|
|
16
|
+
* Matched directories are not recursed into (e.g. node_modules matches once, not its children).
|
|
17
|
+
*
|
|
18
|
+
* Paths that git tracks (or whose descendants git tracks) are dropped from the result and
|
|
19
|
+
* reported in skippedTracked. Shadowing tracked content is a no-op (agents can `git show` it)
|
|
20
|
+
* and breaks `git stash`/`pop` workflows.
|
|
21
|
+
*/
|
|
22
|
+
export function expandShadowPatterns(patterns, workspaceRoot) {
|
|
23
|
+
if (patterns.length === 0)
|
|
24
|
+
return { paths: [], skippedTracked: [] };
|
|
25
|
+
// Convert gitignore-style patterns to fast-glob patterns
|
|
26
|
+
const globPatterns = patterns.map((p) => (p.includes("/") ? p : `**/${p}`));
|
|
27
|
+
// Build ignore list: skip .git and contents of any matched directory
|
|
28
|
+
const ignorePatterns = ["**/.git", ...globPatterns.map((p) => `${p}/**/*`)];
|
|
29
|
+
const results = fg.sync(globPatterns, {
|
|
30
|
+
cwd: workspaceRoot,
|
|
31
|
+
onlyFiles: false,
|
|
32
|
+
dot: true,
|
|
33
|
+
ignore: ignorePatterns,
|
|
34
|
+
});
|
|
35
|
+
const expanded = removeNestedPaths(results.sort());
|
|
36
|
+
const { kept, dropped } = filterGitTrackedPaths(expanded, workspaceRoot);
|
|
37
|
+
return { paths: kept, skippedTracked: dropped };
|
|
38
|
+
}
|
|
39
|
+
// --- Hit counting (for menu UX) ----------------------------------------------------------------------------------------------------------
|
|
40
|
+
/** Count how many paths a pattern would match in the workspace (post git-tracked filter). */
|
|
41
|
+
export function countPatternHits(pattern, workspaceRoot) {
|
|
42
|
+
return expandShadowPatterns([pattern], workspaceRoot).paths.length;
|
|
43
|
+
}
|
|
44
|
+
// --- Git-tracked filtering ---------------------------------------------------------------------------------------------------------------
|
|
45
|
+
/**
|
|
46
|
+
* Partition expanded shadow paths into those safe to shadow and those tracked by git.
|
|
47
|
+
* A path is dropped if it equals a tracked file, or (for directories) any tracked file
|
|
48
|
+
* lives anywhere beneath it. Returns the input unchanged when no .git is present.
|
|
49
|
+
*/
|
|
50
|
+
function filterGitTrackedPaths(paths, workspaceRoot) {
|
|
51
|
+
if (paths.length === 0)
|
|
52
|
+
return { kept: [], dropped: [] };
|
|
53
|
+
// .git may be a file (worktrees) or a directory; existsSync handles both
|
|
54
|
+
if (!existsSync(join(workspaceRoot, ".git")))
|
|
55
|
+
return { kept: paths, dropped: [] };
|
|
56
|
+
// Pass shadow paths as pathspecs so git enumerates only tracked files within them.
|
|
57
|
+
// Output then scales with shadow scope (typically tiny), not the size of the repo index.
|
|
58
|
+
const result = spawnSync("git", ["-C", workspaceRoot, "ls-files", "-z", "--", ...paths], {
|
|
59
|
+
encoding: "utf8",
|
|
60
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
61
|
+
});
|
|
62
|
+
if (result.status !== 0)
|
|
63
|
+
return { kept: paths, dropped: [] };
|
|
64
|
+
const matched = result.stdout.split("\0").filter((s) => s.length > 0);
|
|
65
|
+
if (matched.length === 0)
|
|
66
|
+
return { kept: paths, dropped: [] };
|
|
67
|
+
const matchedSet = new Set(matched);
|
|
68
|
+
const kept = [];
|
|
69
|
+
const dropped = [];
|
|
70
|
+
for (const p of paths) {
|
|
71
|
+
const prefix = `${p}/`;
|
|
72
|
+
if (matchedSet.has(p) || matched.some((f) => f.startsWith(prefix))) {
|
|
73
|
+
dropped.push(p);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
kept.push(p);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { kept, dropped };
|
|
80
|
+
}
|
|
81
|
+
// --- Shadow sync -------------------------------------------------------------------------------------------------------------------------
|
|
82
|
+
/**
|
|
83
|
+
* Ensures the shadows/ directory matches the given expanded paths.
|
|
84
|
+
* - Creates missing shadow entries (empty dir or empty file, matching source path type)
|
|
85
|
+
* - If `freshPaths` is provided, those entries are deleted and recreated (clean slate)
|
|
86
|
+
* - Removes shadow entries not in the expanded set
|
|
87
|
+
* - Cleans up empty parent directories
|
|
88
|
+
*/
|
|
89
|
+
export function ensureShadowsInSync(workspaceDir, expandedPaths, workspaceRoot, freshPaths) {
|
|
90
|
+
const expected = new Set(expandedPaths);
|
|
91
|
+
const shadowsDir = join(workspaceDir, SHADOWS_DIR);
|
|
92
|
+
// Create shadows/ root if needed
|
|
93
|
+
mkdirSync(shadowsDir, { recursive: true });
|
|
94
|
+
// Remove stale entries
|
|
95
|
+
removeStaleEntries(shadowsDir, shadowsDir, expected);
|
|
96
|
+
// Create or refresh expected entries
|
|
97
|
+
for (const relPath of expected) {
|
|
98
|
+
const sourcePath = join(workspaceRoot, relPath);
|
|
99
|
+
const shadowPath = join(shadowsDir, relPath);
|
|
100
|
+
if (freshPaths?.has(relPath) && existsSync(shadowPath)) {
|
|
101
|
+
safeRmSync(shadowPath, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
if (!existsSync(shadowPath)) {
|
|
104
|
+
createShadowEntry(sourcePath, shadowPath);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// --- Mount args --------------------------------------------------------------------------------------------------------------------------
|
|
109
|
+
/** Build -v args for shadow mounts from expanded paths. */
|
|
110
|
+
export function buildShadowMountArgs(workspaceDir, expandedPaths) {
|
|
111
|
+
const args = [];
|
|
112
|
+
for (const relPath of expandedPaths) {
|
|
113
|
+
args.push("-v", `${join(workspaceDir, SHADOWS_DIR, relPath)}:${CONTAINER_WORKSPACE}/${relPath}`);
|
|
114
|
+
}
|
|
115
|
+
return args;
|
|
116
|
+
}
|
|
117
|
+
// --- Helpers -----------------------------------------------------------------------------------------------------------------------------
|
|
118
|
+
function createShadowEntry(sourcePath, shadowPath) {
|
|
119
|
+
if (existsSync(sourcePath) && !lstatSync(sourcePath).isDirectory()) {
|
|
120
|
+
mkdirSync(dirname(shadowPath), { recursive: true });
|
|
121
|
+
writeFileSync(shadowPath, "");
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
mkdirSync(shadowPath, { recursive: true });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Walk the shadows/ tree and remove any entry whose relative path is not in the expected set.
|
|
129
|
+
* After removing leaf entries, prunes empty parent directories.
|
|
130
|
+
*/
|
|
131
|
+
function removeStaleEntries(baseDir, currentDir, expected) {
|
|
132
|
+
if (!existsSync(currentDir))
|
|
133
|
+
return;
|
|
134
|
+
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
|
135
|
+
const fullPath = join(currentDir, entry.name);
|
|
136
|
+
const rel = relative(baseDir, fullPath);
|
|
137
|
+
if (expected.has(rel)) {
|
|
138
|
+
// This path is expected - keep it
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (entry.isDirectory()) {
|
|
142
|
+
// Check if any expected path is nested under this directory
|
|
143
|
+
const hasExpectedChild = [...expected].some((p) => isDescendantOf(p, rel));
|
|
144
|
+
if (hasExpectedChild) {
|
|
145
|
+
// Recurse into the directory to clean stale children
|
|
146
|
+
removeStaleEntries(baseDir, fullPath, expected);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
// No expected paths under here - remove entirely
|
|
150
|
+
safeRmSync(fullPath, { recursive: true, force: true });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
// Stale file - remove
|
|
155
|
+
safeRmSync(fullPath, { force: true });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// Prune if this directory is now empty (and is not the shadows root)
|
|
159
|
+
if (currentDir !== baseDir && readdirSync(currentDir).length === 0) {
|
|
160
|
+
safeRmSync(currentDir, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Drop paths that descend from another path in the input (keeps only the outermost path per subtree),
|
|
165
|
+
* so nested matches (e.g. node_modules inside a shadowed .next dir) don't become redundant bind mounts.
|
|
166
|
+
*/
|
|
167
|
+
function removeNestedPaths(paths) {
|
|
168
|
+
const unique = [...new Set(paths)];
|
|
169
|
+
return unique.filter((p) => !unique.some((a) => a !== p && isDescendantOf(p, a)));
|
|
170
|
+
}
|
|
171
|
+
// Expects forward-slash paths (fast-glob output and shadows/ relative paths on POSIX).
|
|
172
|
+
function isDescendantOf(child, ancestor) {
|
|
173
|
+
return child.startsWith(`${ancestor}/`);
|
|
174
|
+
}
|