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.
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 -23
  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 -1582
  83. package/dist/server.js +0 -1831
  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,80 @@
1
+ // =========================================================================================================================================
2
+ // src/commands/menu.ts - chamba workspace menu
3
+ // Shows workspace status box with profile, non-git notice, and shadow info.
4
+ // =========================================================================================================================================
5
+ import { existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { styleText } from "node:util";
8
+ import { cancel, isCancel, log, select } from "@clack/prompts";
9
+ import { readChambaYaml } from "../lib/chamba-yaml.js";
10
+ import { DEFAULT_PROFILE } from "../lib/constants.js";
11
+ import { readActiveProfile } from "../lib/workspace-identity.js";
12
+ export async function run(args) {
13
+ const { ctx, activeCount, workspaceRunning, version } = args;
14
+ // --- Read workspace config -----------------------------------------------------------------------------------------------------------
15
+ const hasGit = existsSync(join(ctx.workspaceRoot, ".git"));
16
+ // Profiles come from chamba.yaml (the source of truth). The lock file's active profile can be stale (e.g. a
17
+ // profile since removed from chamba.yaml), so only trust it when it still exists. Mirror selectProfile() in
18
+ // dev.ts: a single profile is no real choice, so omit the line entirely rather than show noise.
19
+ let profileNames = [];
20
+ try {
21
+ profileNames = Object.keys(readChambaYaml(ctx.workspaceRoot)?.profiles ?? {});
22
+ }
23
+ catch {
24
+ // chamba.yaml is validated upstream; on any unexpected read error just omit the profile line.
25
+ }
26
+ const cachedProfile = readActiveProfile(ctx.workspaceId);
27
+ // Fall back to a profile that actually exists (default if present, else the first) so the box never
28
+ // shows a name absent from chamba.yaml. The line only renders when there are >1, so the list is non-empty there.
29
+ const fallbackProfile = profileNames.includes(DEFAULT_PROFILE) ? DEFAULT_PROFILE : (profileNames[0] ?? DEFAULT_PROFILE);
30
+ const activeProfile = cachedProfile && profileNames.includes(cachedProfile) ? cachedProfile : fallbackProfile;
31
+ // --- Status line ---------------------------------------------------------------------------------------------------------------------
32
+ // A single bold header line: version, then the workspace - with its container state shown only while
33
+ // running - then an optional profile. Anything else (other containers, git) drops to one quieter
34
+ // notice line below.
35
+ const boldSep = styleText(["bold", "gray"], " · ");
36
+ // Append the running state (green) only when the container is up. A stopped container is the resting
37
+ // default, so "container down" carries no signal - drop it and just name the workspace.
38
+ const workspaceSegment = workspaceRunning
39
+ ? `${styleText("bold", `${ctx.workspaceId} container`)} ${styleText(["bold", "green"], "up")}`
40
+ : styleText("bold", ctx.workspaceId);
41
+ const segments = [styleText("bold", `chamba v${version}`), workspaceSegment];
42
+ if (profileNames.length > 1)
43
+ segments.push(styleText("bold", `profile: ${activeProfile}`));
44
+ const header = segments.join(boldSep);
45
+ // --- Notices -------------------------------------------------------------------------------------------------------------------------
46
+ // One quieter line under the header, its parts joined by a gray dot separator. Order: other containers,
47
+ // git. It only renders when at least one part applies, so the common case is just the header.
48
+ const parts = [];
49
+ // activeCount includes this workspace's container when it is up, so subtract it to count only the others.
50
+ // The number always reads as "besides this one", and a lone running workspace shows nothing.
51
+ const others = activeCount - (workspaceRunning ? 1 : 0);
52
+ if (others > 0)
53
+ parts.push(`${others} other container${others === 1 ? "" : "s"} up`);
54
+ // No git means agent changes are not tracked. dev.ts only feeds this into the agent's context docs, so this
55
+ // is the only warning a person sees - keep it here when the workspace is not a git repo.
56
+ if (!hasGit)
57
+ parts.push("no git");
58
+ // The whole line is gray so it stays quieter than the bold header above it.
59
+ const notices = parts.length > 0 ? [styleText("gray", parts.join(" · "))] : [];
60
+ // Print the header and any notices as clack log lines. log.message already opens with a bar-gutter line, so
61
+ // it separates itself from whatever precedes it - no extra blank line needed. The gray gutter lines up with
62
+ // the select menu that follows.
63
+ log.message([header, ...notices]);
64
+ const options = [
65
+ // Two doors into the same container, web first because that is where the work happens. The shell is
66
+ // the service hatch: it opens no browser, and the greeting inside still names the interface's URL.
67
+ { value: "web", label: "Web interface", hint: "open this workspace in your browser" },
68
+ { value: "shell", label: "Container shell", hint: "a shell inside the container, no browser" },
69
+ ...(workspaceRunning ? [{ value: "stop", label: "Stop container", hint: "stops this workspace's container" }] : []),
70
+ { value: "settings", label: "Settings", hint: "git mode, shadow paths, web, rebuild" },
71
+ { value: "advanced", label: "Advanced", hint: "stop, clear, remove, uninstall" },
72
+ { value: "quit", label: "Quit" },
73
+ ];
74
+ const action = await select({ message: "Menu:", options });
75
+ if (isCancel(action)) {
76
+ cancel();
77
+ return "quit";
78
+ }
79
+ return action;
80
+ }
@@ -0,0 +1,229 @@
1
+ // =========================================================================================================================================
2
+ // src/commands/onboard.ts - First-time workspace setup
3
+ // Creates chamba.yaml, registers the workspace, and returns WorkspaceContext (or null if cancelled).
4
+ // =========================================================================================================================================
5
+ import { execSync } from "node:child_process";
6
+ import { existsSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { basename, join } from "node:path";
9
+ import { cancel, confirm, intro, isCancel, log, outro, select, text } from "@clack/prompts";
10
+ import { buildDefaultChambaYaml, readChambaYaml, slugifyForWorkspaceId, validateWorkspaceId, writeChambaYaml, } from "../lib/chamba-yaml.js";
11
+ import { CHAMBA_YAML } from "../lib/constants.js";
12
+ import { readWebRange } from "../lib/global-config.js";
13
+ import { safeRmSync } from "../lib/safe-rm.js";
14
+ import { ensureWebPort } from "../lib/webterm.js";
15
+ import { checkCollision, deriveContainerName, findChambaYamlDir, findOrphanWorkspaceDir, getWorkspaceDir, initWorkspaceDir, readLockFile, } from "../lib/workspace-identity.js";
16
+ /** Derive a unique workspace_id, appending -2, -3, etc. if the base collides with another workspace. */
17
+ function deriveUniqueWorkspaceId(baseId, workspaceRoot) {
18
+ if (checkCollision(baseId, workspaceRoot) === "ok")
19
+ return baseId;
20
+ for (let i = 2; i <= 99; i++) {
21
+ const candidate = `${baseId}-${i}`;
22
+ if (checkCollision(candidate, workspaceRoot) === "ok")
23
+ return candidate;
24
+ }
25
+ return baseId; // fallback (collision handled later in onboarding)
26
+ }
27
+ function tryGetGitRoot(cwd) {
28
+ try {
29
+ return execSync("git rev-parse --show-toplevel", { encoding: "utf8", cwd, stdio: "pipe" }).trim();
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ // Public entry point. Wraps the interactive setup so an aborted run (the user cancels a prompt, or an
36
+ // unexpected error) rolls back a chamba.yaml we created this session, leaving the directory as we found it.
37
+ export async function run(cwd) {
38
+ // Records a chamba.yaml this run creates (never a pre-existing one) so the finally can remove it.
39
+ const created = { yamlPath: null };
40
+ let result = null;
41
+ try {
42
+ result = await runSetup(cwd, created);
43
+ return result;
44
+ }
45
+ finally {
46
+ // Non-completion means cancelled (result stays null) or threw (also null). Success assigns result,
47
+ // so a created config survives only when setup actually finished.
48
+ if (!result && created.yamlPath)
49
+ safeRmSync(created.yamlPath, { force: true });
50
+ }
51
+ }
52
+ async function runSetup(cwd, created) {
53
+ const toTildePath = (p) => (p.startsWith(homedir()) ? p.replace(homedir(), "~") : p);
54
+ // --- Detect context ------------------------------------------------------------------------------------------------------------------
55
+ const gitRoot = tryGetGitRoot(cwd);
56
+ const searchRoot = gitRoot ?? cwd;
57
+ const yamlDir = findChambaYamlDir(searchRoot);
58
+ // --- Intro ---------------------------------------------------------------------------------------------------------------------------
59
+ process.stdout.write("\n");
60
+ intro("chamba · new workspace");
61
+ process.stdout.write("\n");
62
+ let workspaceRoot;
63
+ let yaml;
64
+ if (yamlDir) {
65
+ // --- chamba.yaml found: read and validate ----------------------------------------------------------------------------------------
66
+ let existing;
67
+ try {
68
+ existing = readChambaYaml(yamlDir);
69
+ }
70
+ catch (err) {
71
+ log.error(`Found ${join(yamlDir, CHAMBA_YAML)} but it is invalid: ${err instanceof Error ? err.message : err}`);
72
+ cancel("Setup cancelled.");
73
+ return null;
74
+ }
75
+ if (!existing) {
76
+ log.error(`Could not read ${join(yamlDir, CHAMBA_YAML)}.`);
77
+ cancel("Setup cancelled.");
78
+ return null;
79
+ }
80
+ workspaceRoot = yamlDir;
81
+ yaml = existing;
82
+ const ok = await confirm({ message: `Set up chamba for: ${toTildePath(workspaceRoot)}?` });
83
+ if (isCancel(ok) || !ok) {
84
+ cancel("Setup cancelled.");
85
+ return null;
86
+ }
87
+ }
88
+ else {
89
+ // --- No chamba.yaml: create one interactively ------------------------------------------------------------------------------------
90
+ // Choose workspace root
91
+ const suggestedRoot = gitRoot ?? cwd;
92
+ const options = [
93
+ { value: suggestedRoot, label: toTildePath(suggestedRoot), hint: gitRoot ? "git root" : "current directory" },
94
+ ];
95
+ if (gitRoot !== null && gitRoot !== cwd) {
96
+ options.push({ value: cwd, label: toTildePath(cwd), hint: "current directory" });
97
+ }
98
+ options.push({ value: "__custom__", label: "Enter a different path…" });
99
+ const rootChoice = await select({ message: "Workspace root:", options });
100
+ if (isCancel(rootChoice)) {
101
+ cancel("Setup cancelled.");
102
+ return null;
103
+ }
104
+ if (rootChoice === "__custom__") {
105
+ const customPath = await text({
106
+ message: "Workspace root path:",
107
+ placeholder: `e.g. ${suggestedRoot}`,
108
+ validate: (v) => {
109
+ const p = (v ?? "").trim();
110
+ if (p.length === 0)
111
+ return "Path cannot be empty";
112
+ if (!existsSync(p))
113
+ return `Directory not found: ${p}`;
114
+ return undefined;
115
+ },
116
+ });
117
+ if (isCancel(customPath)) {
118
+ cancel("Setup cancelled.");
119
+ return null;
120
+ }
121
+ workspaceRoot = customPath.trim();
122
+ }
123
+ else {
124
+ workspaceRoot = rootChoice;
125
+ }
126
+ // Ask for workspace ID
127
+ const defaultId = slugifyForWorkspaceId(basename(workspaceRoot));
128
+ const idInput = await text({
129
+ message: "Workspace ID:",
130
+ placeholder: defaultId,
131
+ defaultValue: defaultId,
132
+ validate: (v) => validateWorkspaceId((v ?? "").trim() || defaultId),
133
+ });
134
+ if (isCancel(idInput)) {
135
+ cancel("Setup cancelled.");
136
+ return null;
137
+ }
138
+ const inputId = idInput.trim() || defaultId;
139
+ // Auto-resolve collisions with numeric suffix
140
+ const workspaceId = deriveUniqueWorkspaceId(inputId, workspaceRoot);
141
+ // Build and write chamba.yaml
142
+ yaml = buildDefaultChambaYaml(workspaceId);
143
+ writeChambaYaml(workspaceRoot, yaml);
144
+ created.yamlPath = join(workspaceRoot, CHAMBA_YAML);
145
+ log.success(`Created ${toTildePath(created.yamlPath)}`);
146
+ }
147
+ // --- Non-git warning -----------------------------------------------------------------------------------------------------------------
148
+ const isNonGit = tryGetGitRoot(workspaceRoot) === null;
149
+ if (isNonGit) {
150
+ log.warn("No version control detected. Agent changes won't be tracked.");
151
+ const ack = await confirm({ message: "Continue without git?" });
152
+ if (isCancel(ack) || !ack) {
153
+ cancel("Setup cancelled.");
154
+ return null;
155
+ }
156
+ }
157
+ // --- Collision / orphan check --------------------------------------------------------------------------------------------------------
158
+ const workspaceId = yaml.workspace_id;
159
+ const collision = checkCollision(workspaceId, workspaceRoot);
160
+ if (collision === "collision") {
161
+ const existingLock = readLockFile(workspaceId);
162
+ log.error(`Workspace ID "${workspaceId}" is already used by another workspace:\n` +
163
+ ` ${existingLock}\n\n` +
164
+ `Choose a different workspace_id in chamba.yaml.`);
165
+ const newId = await text({
166
+ message: "New workspace ID:",
167
+ validate: (v) => {
168
+ const id = (v ?? "").trim();
169
+ const err = validateWorkspaceId(id);
170
+ if (err)
171
+ return err;
172
+ const c = checkCollision(id, workspaceRoot);
173
+ if (c === "collision")
174
+ return `"${id}" is also taken`;
175
+ return undefined;
176
+ },
177
+ });
178
+ if (isCancel(newId)) {
179
+ cancel("Setup cancelled.");
180
+ return null;
181
+ }
182
+ yaml.workspace_id = newId.trim();
183
+ writeChambaYaml(workspaceRoot, yaml);
184
+ log.info(`Updated workspace_id to "${yaml.workspace_id}"`);
185
+ }
186
+ else {
187
+ // Check for orphan (workspace_id changed in yaml but old dir still points here)
188
+ const orphanId = findOrphanWorkspaceDir(workspaceRoot);
189
+ if (orphanId && orphanId !== workspaceId) {
190
+ log.warn(`Found orphaned workspace cache "${orphanId}" pointing to this workspace.`);
191
+ const action = await select({
192
+ message: "How to handle the orphaned cache?",
193
+ options: [
194
+ { value: "realign", label: `Revert workspace_id to "${orphanId}"`, hint: "keeps existing cache" },
195
+ { value: "clean", label: `Use "${workspaceId}" (clean slate)`, hint: "deletes old cache" },
196
+ ],
197
+ });
198
+ if (isCancel(action)) {
199
+ cancel("Setup cancelled.");
200
+ return null;
201
+ }
202
+ if (action === "realign") {
203
+ yaml.workspace_id = orphanId;
204
+ writeChambaYaml(workspaceRoot, yaml);
205
+ log.info(`Reverted workspace_id to "${orphanId}"`);
206
+ }
207
+ else {
208
+ // Clean slate - remove orphaned dir
209
+ safeRmSync(getWorkspaceDir(orphanId), { recursive: true, force: true });
210
+ log.info(`Removed orphaned cache for "${orphanId}"`);
211
+ }
212
+ }
213
+ }
214
+ // --- Initialize workspace dir --------------------------------------------------------------------------------------------------------
215
+ const finalId = yaml.workspace_id;
216
+ initWorkspaceDir(finalId, workspaceRoot);
217
+ // A new workspace takes its sticky web port at creation time (the spec's second calm moment).
218
+ const assignedWebPort = ensureWebPort(finalId, readWebRange());
219
+ if (assignedWebPort.ok)
220
+ log.info(`Web agent interface port: ${assignedWebPort.port} (sticky for this workspace)`);
221
+ log.success(`Config written to ${toTildePath(getWorkspaceDir(finalId))}`);
222
+ outro("Setup complete.");
223
+ return {
224
+ workspaceId: finalId,
225
+ workspaceRoot,
226
+ containerName: deriveContainerName(finalId),
227
+ workspaceDir: getWorkspaceDir(finalId),
228
+ };
229
+ }
@@ -0,0 +1,349 @@
1
+ // =========================================================================================================================================
2
+ // src/commands/settings.ts - Settings submenu: git mode, shadow paths, ports, rebuild, reset config
3
+ // =========================================================================================================================================
4
+ import { spawnSync } from "node:child_process";
5
+ import { relative } from "node:path";
6
+ import { cancel, confirm, isCancel, log, multiselect, note, outro, path, select, text } from "@clack/prompts";
7
+ import { buildDefaultChambaYaml, readChambaYaml, writeChambaYaml } from "../lib/chamba-yaml.js";
8
+ import { GIT_MODE } from "../lib/constants.js";
9
+ import { readWebRange, writeWebRange } from "../lib/global-config.js";
10
+ import { formatWebRange, PORT_MAX, PORT_MIN, parseWebRange } from "../lib/ports.js";
11
+ import { countPatternHits } from "../lib/shadows.js";
12
+ import { readWebKey, reassignOutOfRangeWebPorts, webInterfaceUrl } from "../lib/webterm.js";
13
+ import { readGitMode, readWebPort, writeGitMode } from "../lib/workspace-identity.js";
14
+ // --- Shadow paths menu -------------------------------------------------------------------------------------------------------------------
15
+ async function shadowPathsMenu(ctx) {
16
+ const yaml = readChambaYaml(ctx.workspaceRoot);
17
+ if (!yaml) {
18
+ log.error("chamba.yaml not found or invalid.");
19
+ return;
20
+ }
21
+ const patterns = yaml.shadow_paths ?? [];
22
+ if (patterns.length > 0) {
23
+ const lines = patterns.map((p) => {
24
+ const hits = countPatternHits(p, ctx.workspaceRoot);
25
+ return ` ${p} (${hits} ${hits === 1 ? "match" : "matches"})`;
26
+ });
27
+ note(lines.join("\n"), "Shadow patterns");
28
+ }
29
+ else {
30
+ log.info("No shadow patterns configured.");
31
+ }
32
+ log.message("Shadow patterns block the agent from seeing matching host paths -\n" +
33
+ "the container gets an empty, isolated copy instead.\n" +
34
+ "Supports gitignore-style patterns (e.g. node_modules, .env*).\n" +
35
+ "Git-tracked paths are skipped to avoid worktree diversions.");
36
+ const options = [{ value: "add", label: "Add pattern or path" }];
37
+ if (patterns.length > 0) {
38
+ options.push({ value: "remove", label: "Remove patterns" });
39
+ }
40
+ options.push({ value: "back", label: "← Back" });
41
+ const action = await select({ message: "Shadow paths:", options });
42
+ if (isCancel(action) || action === "back")
43
+ return;
44
+ if (action === "add") {
45
+ await addShadowPattern(ctx);
46
+ }
47
+ else if (action === "remove") {
48
+ await removeShadowPatterns(ctx);
49
+ }
50
+ }
51
+ async function addShadowPattern(ctx) {
52
+ const yaml = readChambaYaml(ctx.workspaceRoot);
53
+ if (!yaml)
54
+ return;
55
+ const patterns = [...(yaml.shadow_paths ?? [])];
56
+ const typeChoice = await select({
57
+ message: "Add:",
58
+ options: [
59
+ { value: "pattern", label: "Pattern", hint: "gitignore-style (e.g. .env*, *.log)" },
60
+ { value: "path", label: "Specific path", hint: "pick a file or directory" },
61
+ ],
62
+ });
63
+ if (isCancel(typeChoice))
64
+ return;
65
+ if (typeChoice === "pattern") {
66
+ const input = await text({
67
+ message: "Pattern:",
68
+ placeholder: "e.g. .env* or node_modules",
69
+ validate: (v) => {
70
+ const p = (v ?? "").trim();
71
+ if (p.length === 0)
72
+ return "Pattern cannot be empty";
73
+ if (patterns.includes(p))
74
+ return "Pattern already exists";
75
+ return undefined;
76
+ },
77
+ });
78
+ if (isCancel(input))
79
+ return;
80
+ const pattern = input.trim();
81
+ const hits = countPatternHits(pattern, ctx.workspaceRoot);
82
+ patterns.push(pattern);
83
+ log.info(`Added: ${pattern} (${hits} ${hits === 1 ? "match" : "matches"})`);
84
+ }
85
+ else {
86
+ const selected = await path({
87
+ message: "Path to shadow:",
88
+ root: ctx.workspaceRoot,
89
+ directory: true,
90
+ });
91
+ if (isCancel(selected))
92
+ return;
93
+ const absPath = selected.trim();
94
+ const rel = relative(ctx.workspaceRoot, absPath);
95
+ if (!rel || rel.startsWith("..")) {
96
+ log.warn("Path must be inside the workspace directory. Skipped.");
97
+ return;
98
+ }
99
+ if (patterns.includes(rel)) {
100
+ log.warn(`"${rel}" is already in shadow patterns. Skipped.`);
101
+ return;
102
+ }
103
+ patterns.push(rel);
104
+ log.info(`Added: ${rel}`);
105
+ }
106
+ yaml.shadow_paths = patterns;
107
+ writeChambaYaml(ctx.workspaceRoot, yaml);
108
+ await promptStopContainer(ctx);
109
+ }
110
+ async function removeShadowPatterns(ctx) {
111
+ const yaml = readChambaYaml(ctx.workspaceRoot);
112
+ if (!yaml?.shadow_paths?.length)
113
+ return;
114
+ const toRemove = await multiselect({
115
+ message: "Select patterns to remove: (space to toggle, enter to confirm)",
116
+ options: yaml.shadow_paths.map((p) => ({ value: p, label: p })),
117
+ });
118
+ if (isCancel(toRemove))
119
+ return;
120
+ const removeSet = new Set(toRemove);
121
+ yaml.shadow_paths = yaml.shadow_paths.filter((p) => !removeSet.has(p));
122
+ writeChambaYaml(ctx.workspaceRoot, yaml);
123
+ log.success(`Removed ${removeSet.size} pattern(s).`);
124
+ await promptStopContainer(ctx);
125
+ }
126
+ // --- Git mode menu -----------------------------------------------------------------------------------------------------------------------
127
+ async function gitModeMenu(ctx) {
128
+ const current = readGitMode(ctx.workspaceId) ?? GIT_MODE.local;
129
+ note("Local - local mutations allowed; remote blocked\n" +
130
+ "Strict - read-only; mutations and remote blocked\n" +
131
+ "Unrestricted - no chamba-enforced restrictions", "Git mode");
132
+ const choice = await select({
133
+ message: "Git mode:",
134
+ options: [
135
+ {
136
+ value: GIT_MODE.local,
137
+ label: "Local",
138
+ hint: current === GIT_MODE.local ? "current · default" : "default",
139
+ },
140
+ { value: GIT_MODE.strict, label: "Strict", ...(current === GIT_MODE.strict ? { hint: "current" } : {}) },
141
+ {
142
+ value: GIT_MODE.unrestricted,
143
+ label: "Unrestricted",
144
+ ...(current === GIT_MODE.unrestricted ? { hint: "current" } : {}),
145
+ },
146
+ ],
147
+ initialValue: current,
148
+ });
149
+ if (isCancel(choice))
150
+ return;
151
+ if (choice === current)
152
+ return;
153
+ if (choice === GIT_MODE.unrestricted) {
154
+ const confirmed = await confirm({
155
+ message: "Unrestricted mode disables chamba's built-in git restrictions (allows remote push/pull/fetch). Continue?",
156
+ initialValue: false,
157
+ });
158
+ if (isCancel(confirmed) || !confirmed) {
159
+ log.info("Git mode unchanged.");
160
+ return;
161
+ }
162
+ }
163
+ writeGitMode(ctx.workspaceId, choice);
164
+ log.success(`Git mode set to ${choice}.`);
165
+ await promptStopContainer(ctx);
166
+ }
167
+ // --- Ports menu ---------------------------------------------------------------------------------------------------------------------------
168
+ // The web interface itself has no setting - it comes up with the container and the browser opens on it. What
169
+ // is left to choose is the host port range every workspace draws its sticky port from, so this screen is that
170
+ // one setting plus where this workspace currently lives.
171
+ async function portsMenu(ctx) {
172
+ const range = readWebRange();
173
+ const webPort = readWebPort(ctx.workspaceId);
174
+ // The URL only opens the interface with the key the running server minted, so the live one is shown
175
+ // whenever it can be read. With nothing running there is no key to show, and saying where it comes
176
+ // from beats printing half a URL.
177
+ const liveKey = webPort === null ? null : readWebKey(ctx.containerName);
178
+ const url = webPort === null
179
+ ? "no port assigned yet"
180
+ : liveKey !== null
181
+ ? webInterfaceUrl(webPort, liveKey)
182
+ : `${webInterfaceUrl(webPort, null)} (the key comes with the interface when it starts)`;
183
+ note(`web interface range: ${formatWebRange(range)} (all workspaces)\nthis workspace: ${url}`, "Ports");
184
+ log.message("The host port range the web interface draws from.\n" +
185
+ "Each workspace keeps one sticky port from it, so its URL never changes.\n" +
186
+ "Loopback-only, never reachable off this machine, and the URL carries a key\n" +
187
+ "that is minted fresh every time the interface starts.\n" +
188
+ "Ports your own project publishes are set in chamba.yaml, not here.");
189
+ const input = await text({
190
+ message: "Port range (START-END):",
191
+ placeholder: formatWebRange(range),
192
+ initialValue: formatWebRange(range),
193
+ validate: (v) => {
194
+ const raw = (v ?? "").trim();
195
+ if (!/^\d+-\d+$/.test(raw))
196
+ return "Use START-END, e.g. 3900-3999";
197
+ const parsed = parseWebRange(raw);
198
+ if (!parsed) {
199
+ const [start = 0, end = 0] = raw.split("-").map(Number);
200
+ if (start >= end)
201
+ return "Start must be below end";
202
+ return `Ports must be between ${PORT_MIN} and ${PORT_MAX}`;
203
+ }
204
+ return undefined;
205
+ },
206
+ });
207
+ if (isCancel(input))
208
+ return;
209
+ const newRange = parseWebRange(input.trim());
210
+ if (!newRange)
211
+ return; // Validate above guarantees this never fires; keeps the type narrow.
212
+ if (formatWebRange(newRange) === formatWebRange(range))
213
+ return;
214
+ writeWebRange(newRange);
215
+ // Only assignments now outside the range move; in-range ports stay sticky.
216
+ const moves = reassignOutOfRangeWebPorts(newRange);
217
+ for (const m of moves) {
218
+ log.info(`${m.workspaceId}: port ${m.from} -> ${m.to}`);
219
+ }
220
+ log.success(`Port range set to ${formatWebRange(newRange)}.`);
221
+ await promptStopContainer(ctx);
222
+ }
223
+ // --- Prompt to stop container ------------------------------------------------------------------------------------------------------------
224
+ async function promptStopContainer(ctx) {
225
+ const containerName = ctx.containerName;
226
+ const inspect = spawnSync("docker", ["inspect", "--format", "{{.State.Status}}", containerName], {
227
+ encoding: "utf8",
228
+ stdio: "pipe",
229
+ });
230
+ const containerStatus = inspect.status === 0 ? inspect.stdout.trim() : null;
231
+ if (containerStatus !== "running") {
232
+ log.info("Changes will apply on next session.");
233
+ return;
234
+ }
235
+ const shouldStop = await confirm({ message: "Stop the running container so changes apply on next session?" });
236
+ if (isCancel(shouldStop) || !shouldStop) {
237
+ log.warn("Container still running with old config - it will be recreated on next session.");
238
+ return;
239
+ }
240
+ log.info("Stopping container...");
241
+ spawnSync("docker", ["stop", containerName], { stdio: "pipe" });
242
+ spawnSync("docker", ["rm", containerName], { stdio: "pipe" });
243
+ log.info("Container removed. Open a new session to apply changes.");
244
+ }
245
+ // --- Reset chamba.yaml -------------------------------------------------------------------------------------------------------------------
246
+ async function resetChambaYaml(ctx) {
247
+ const yaml = readChambaYaml(ctx.workspaceRoot);
248
+ if (!yaml) {
249
+ log.error("chamba.yaml not found or invalid.");
250
+ return;
251
+ }
252
+ note("This will reset chamba.yaml to the minimal default (workspace_id + shadow_paths).\n" +
253
+ "Your workspace_id is preserved; shadow_paths reset to the defaults (node_modules, .env*).\n" +
254
+ "Any env, profiles, or ports settings will be removed - re-add them with your editor, which reads the schema the file points at.", "Reset chamba.yaml");
255
+ const confirmed = await confirm({ message: "Reset chamba.yaml to defaults?" });
256
+ if (isCancel(confirmed) || !confirmed)
257
+ return;
258
+ const freshYaml = buildDefaultChambaYaml(yaml.workspace_id);
259
+ writeChambaYaml(ctx.workspaceRoot, freshYaml);
260
+ log.success("chamba.yaml reset to defaults.");
261
+ await promptStopContainer(ctx);
262
+ }
263
+ // --- Settings submenu --------------------------------------------------------------------------------------------------------------------
264
+ export async function run(ctx) {
265
+ while (true) {
266
+ const currentGitMode = readGitMode(ctx.workspaceId) ?? GIT_MODE.local;
267
+ const options = [
268
+ { value: "git-mode", label: "Git mode", hint: `current: ${currentGitMode}` },
269
+ { value: "shadow-paths", label: "Shadow paths", hint: "manage shadow patterns" },
270
+ { value: "ports", label: "Ports", hint: `web interface range: ${formatWebRange(readWebRange())}` },
271
+ { value: "rebuild", label: "Rebuild container", hint: "force a fresh image build" },
272
+ { value: "clean-rebuild", label: "Clean rebuild", hint: "fresh build, no cache" },
273
+ { value: "reset", label: "Reset config", hint: "restore chamba.yaml to defaults" },
274
+ { value: "back", label: "← Back" },
275
+ ];
276
+ const action = await select({ message: "Settings:", options });
277
+ if (isCancel(action) || action === "back") {
278
+ return "back";
279
+ }
280
+ switch (action) {
281
+ case "git-mode":
282
+ await gitModeMenu(ctx);
283
+ break;
284
+ case "shadow-paths":
285
+ await shadowPathsMenu(ctx);
286
+ break;
287
+ case "ports":
288
+ await portsMenu(ctx);
289
+ break;
290
+ case "rebuild":
291
+ return "rebuild";
292
+ case "clean-rebuild":
293
+ return "clean-rebuild";
294
+ case "reset":
295
+ await resetChambaYaml(ctx);
296
+ break;
297
+ }
298
+ }
299
+ }
300
+ // --- Stop workspace container ------------------------------------------------------------------------------------------------------------
301
+ export async function stop(containerName) {
302
+ const inspectResult = spawnSync("docker", ["inspect", "--type", "container", containerName], { encoding: "utf8" });
303
+ if (inspectResult.status !== 0) {
304
+ log.info(`Container ${containerName} is not running.`);
305
+ return;
306
+ }
307
+ const confirmed = await confirm({ message: `Stop ${containerName}?` });
308
+ if (isCancel(confirmed) || !confirmed) {
309
+ cancel();
310
+ return;
311
+ }
312
+ log.info(`Stopping ${containerName}...`);
313
+ // Stop-only (no rm) so the next session resumes fast via the "exited" -> docker start path.
314
+ spawnSync("docker", ["stop", containerName], { stdio: "pipe" });
315
+ outro(`${containerName} stopped.`);
316
+ }
317
+ // --- Reset workspace image (stop container + remove image for fresh rebuild) -------------------------------------------------------------
318
+ // Returns false when the user declines a running-container rebuild, so the caller can skip the fresh build
319
+ // and leave the live session untouched.
320
+ export async function resetImage(containerName) {
321
+ const statusResult = spawnSync("docker", ["inspect", "--type", "container", "--format", "{{.State.Status}}", containerName], {
322
+ encoding: "utf8",
323
+ stdio: "pipe",
324
+ });
325
+ const containerExists = statusResult.status === 0;
326
+ const running = containerExists && statusResult.stdout.trim() === "running";
327
+ // A running container is a live session. A rebuild stops and removes it first, so confirm before
328
+ // killing it out from under the user rather than surprising them mid-session.
329
+ if (running) {
330
+ log.warn("Rebuilding stops and removes the running container.\nAgent memory, settings, and workspace data are preserved.");
331
+ const proceed = await confirm({ message: "Rebuild now?", initialValue: true });
332
+ if (isCancel(proceed) || !proceed) {
333
+ cancel("Rebuild cancelled - container left running.");
334
+ return false;
335
+ }
336
+ }
337
+ if (containerExists) {
338
+ log.info(`Stopping container ${containerName}...`);
339
+ spawnSync("docker", ["stop", containerName], { stdio: "pipe" });
340
+ spawnSync("docker", ["rm", containerName], { stdio: "pipe" });
341
+ }
342
+ const imageResult = spawnSync("docker", ["images", "-q", containerName], { encoding: "utf8", stdio: "pipe" });
343
+ if ((imageResult.stdout ?? "").trim().length > 0) {
344
+ log.info(`Removing image ${containerName}...`);
345
+ spawnSync("docker", ["rmi", containerName], { stdio: "pipe" });
346
+ }
347
+ log.info("Image removed - starting fresh build…");
348
+ return true;
349
+ }