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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Asaf Ratzon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,67 +1,70 @@
|
|
|
1
1
|
# chamba
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Run AI coding agents in a container, one per workspace, and work with them from the browser.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Chamba is a personal tool.
|
|
6
|
+
It is built for one person's machine and published to npm only so that `npx` can fetch it - no support, no compatibility promises, no release notes.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
> Chamba is a fork of [totopo](https://www.npmjs.com/package/totopo), taken web-first.
|
|
9
|
+
> Totopo stays what it is: terminal-first, public, and maintained on its own.
|
|
10
|
+
> The two run side by side on one host - separate config files, separate caches, separate containers - so a directory can be managed by either.
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
npx chamba@latest skill install
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
Then, in your agent, run `/chamba` (or ask it to "open chamba").
|
|
14
|
-
It opens a browser workspace where you read rendered markdown, paste screenshots the agent can read, point at UI in the app tab, and view rich HTML pages the agent renders in the Illustration tab.
|
|
12
|
+
## Quick start
|
|
15
13
|
|
|
16
|
-
|
|
14
|
+
```bash
|
|
15
|
+
npx chamba
|
|
16
|
+
```
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Run it from your project directory.
|
|
19
|
+
The first run walks through a short setup and writes a `chamba.yaml` at the workspace root; every run after that opens the workspace menu.
|
|
19
20
|
|
|
20
|
-
##
|
|
21
|
+
## How it works
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
`
|
|
24
|
-
`pnpm test:e2e` runs the Playwright suite once; `pnpm test:e2e:flake` repeats it three times with no retries so an intermittent failure cannot hide, and is the e2e variant the gate runs on every push.
|
|
25
|
-
`pnpm test:e2e:video` runs the suite once and records a video of every flow under `test-results/e2e/`, for watching the browser interactions.
|
|
23
|
+
A **workspace** is any directory holding a `chamba.yaml`.
|
|
24
|
+
`npx chamba` walks up from where it runs to find the nearest one, so any subdirectory of a project reaches the same workspace.
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
| `packages/*/src/**/*.test.ts` | Unit tests colocated with each package (`shared`, `server`, `cli`, `inject`): pure schema and logic. | `pnpm test` (Vitest `unit` project, parallel). |
|
|
30
|
-
| `tests/integration/` | Integration: real server and CLI processes over real ports and the WebSocket transport (roundtrip, durability, annotations, attachments, issues, pages, references, binding, init, lifecycle). Shared helpers live in `tests/support/`. | `pnpm test` (Vitest `integration` project, sequential forks with long timeouts, since parallel port binding would flake). |
|
|
31
|
-
| `tests/e2e/` | End-to-end: a real browser drives the production web build against a real server and CLI, proving the human and agent exchange renders live. | `pnpm test:e2e` (Playwright, serial; `pretest:e2e` installs the browser first). |
|
|
32
|
-
| `tests/fixtures/` | Sample apps for the "Your app" surface: a Vite + React app on port 4001 and a Next.js app on port 4002. The e2e suite drives the React app as a cross-origin annotation target; both are runnable by hand for manual exploration. | `pnpm fixture:react`, `pnpm fixture:next` (see `tests/fixtures/README.md`). |
|
|
26
|
+
Each workspace gets one container, not one per session, so several terminals share it and reconnect fast.
|
|
27
|
+
The workspace directory is mounted at `/workspace`, and nothing outside it is.
|
|
33
28
|
|
|
34
|
-
|
|
29
|
+
`chamba.yaml` needs only a `workspace_id`.
|
|
30
|
+
The optional fields - `shadow_paths`, `env`, `ports`, `profiles` - are all described in the JSON schema the generated file points at, which editors read for completion and validation.
|
|
35
31
|
|
|
36
|
-
##
|
|
32
|
+
## What the container gives an agent
|
|
37
33
|
|
|
38
|
-
|
|
34
|
+
- **Its own filesystem.** Only the workspace is mounted, as a non-root user, with `no-new-privileges` set. No host credentials go in.
|
|
35
|
+
- **No git remote.** The default git mode blocks every remote operation while leaving local commits free. `strict` also blocks mutating git commands; `unrestricted` lifts the remote block, and only you can turn it on.
|
|
36
|
+
- **Hidden paths.** Shadow paths (`node_modules`, `.env*` by default) are overlaid with empty container-local copies, so an agent never reads the real thing.
|
|
37
|
+
- **The current CLIs.** claude, opencode and codex are kept on their latest versions, each with its config directory bind-mounted to the workspace cache so conversations and memory survive rebuilds.
|
|
38
|
+
- **A web interface.** The browser front-end relays the real agent TUI over a websocket - every session on one page, several agents at once, images and dictation. It starts with the container. Claude sessions also get a status strip above the composer: model, context used, quota left.
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
The menu has two doors into that container.
|
|
41
|
+
**Web interface** brings it up, opens the interface in your browser, and hands your terminal straight back - the work happens in the tab.
|
|
42
|
+
**Container shell** drops you into a shell inside the container and opens nothing; the greeting there still prints the interface's URL, because it is running either way.
|
|
41
43
|
|
|
42
|
-
|
|
44
|
+
## The web pane
|
|
43
45
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
```
|
|
46
|
+
Beside the terminal is a pane of pages, there from the first second of a session: until an agent has published anything it is a spine at the edge of the window, and opening it says what the pane is for and what to ask for.
|
|
47
|
+
An agent publishes a standalone HTML file into it - `webpane report.html --title "Port rework"` - and it shows up as a chip above the pane: a plan, a comparison table, a long answer, anything worth reading at your own pace instead of scrolling back for.
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
If nothing is serving `:4002` yet, it prints the command to start the fixture and waits; open a separate tab in the repo root, run `pnpm dev:next` (or `pnpm re:dev:next` for a clean reinstall first), and this tab continues on its own once the port is up.
|
|
49
|
+
A page can also ask.
|
|
50
|
+
A form marked `data-feedback` is wired up by the interface, and what you submit is written as JSON next to the page, with one line typed into the agent's terminal saying where to read it.
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
Pages are kept, one directory per conversation, so resuming a conversation opens with them again.
|
|
53
|
+
They live under `~/.chamba/workspaces/<id>/agents/webpane/` on the host, and nothing deletes them.
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
## Where state lives
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
- `pnpm fixtures:stop` - stop the fixture dev servers (React on `:4001`, Next on `:4002`). Run it where the fixture runs (host or container); it kills only the fixture processes, never Docker's port forwarder.
|
|
59
|
-
- `npx totopo` - start the workspace's sandbox session; runs from anywhere under the repo.
|
|
60
|
-
- `pnpm -s chamba open` - inside the session, opens chamba and prints its URL.
|
|
57
|
+
`chamba.yaml` lives in the workspace and can be committed.
|
|
58
|
+
Everything else is on your machine under `~/.chamba/`: one directory per workspace holding its lock file, agent config, and shadow overlays, plus a host-global `config` for settings that are yours rather than the project's.
|
|
61
59
|
|
|
62
|
-
|
|
63
|
-
Then open the printed chamba URL, point the "Your app" tab at `:4002`, and do real work: paste a screenshot, annotate, ask the agent to show an illustration page.
|
|
60
|
+
## Developing chamba
|
|
64
61
|
|
|
65
|
-
|
|
62
|
+
```bash
|
|
63
|
+
pnpm install
|
|
64
|
+
pnpm start # run the CLI from source (host only - Docker is not available in the dev container)
|
|
65
|
+
pnpm check # typecheck, lint, repo checks, tests, build
|
|
66
|
+
pnpm release # check, bump, commit and publish to npm
|
|
67
|
+
```
|
|
66
68
|
|
|
67
|
-
|
|
69
|
+
`pnpm release` is the whole release process: it reads the live version off npm, offers a bump, runs `pnpm check`, commits the bump and publishes.
|
|
70
|
+
`AGENTS.md` carries the conventions this repo is written to.
|
package/bin/chamba.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// =========================================================================================================================================
|
|
3
|
+
// bin/chamba.js - chamba entry point
|
|
4
|
+
// Run this from your workspace directory (or via npx chamba).
|
|
5
|
+
// =========================================================================================================================================
|
|
6
|
+
|
|
7
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { cancel, confirm, isCancel, log, select } from "@clack/prompts";
|
|
12
|
+
import { run as advancedMenu } from "../dist/commands/advanced.js";
|
|
13
|
+
import { run as dev } from "../dist/commands/dev.js";
|
|
14
|
+
import { run as doctor } from "../dist/commands/doctor.js";
|
|
15
|
+
import { run as menu } from "../dist/commands/menu.js";
|
|
16
|
+
import { run as onboard } from "../dist/commands/onboard.js";
|
|
17
|
+
import { resetImage, run as settingsMenu, stop } from "../dist/commands/settings.js";
|
|
18
|
+
import { repairChambaYaml } from "../dist/lib/chamba-yaml.js";
|
|
19
|
+
import { deriveContainerName, findChambaYamlDir, listWorkspaceIds, resolveWorkspace } from "../dist/lib/workspace-identity.js";
|
|
20
|
+
|
|
21
|
+
// --- Suppress Docker CLI hints ------------------------------------------------------------------------------------------------------------
|
|
22
|
+
// Docker prints a "What's next:" hint block (about `docker debug`) to stderr after commands like `docker run`. Our
|
|
23
|
+
// interactive docker calls inherit stderr, so the hint leaks between chamba's own log steps. chamba owns this process
|
|
24
|
+
// and never wants those hints, so force it off unconditionally; every docker child inherits this env.
|
|
25
|
+
process.env.DOCKER_CLI_HINTS = "false";
|
|
26
|
+
|
|
27
|
+
// --- Guard: inside container -------------------------------------------------------------------------------------------------------------
|
|
28
|
+
try {
|
|
29
|
+
if (execSync("whoami", { encoding: "utf8" }).trim() === "devuser") {
|
|
30
|
+
console.error("");
|
|
31
|
+
console.error(" You are running chamba from inside the dev container.");
|
|
32
|
+
console.error(" Open a terminal on your host machine and run:");
|
|
33
|
+
console.error("");
|
|
34
|
+
console.error(" chamba (or npx chamba from your workspace directory)");
|
|
35
|
+
console.error("");
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// whoami unavailable - not blocking
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- Paths -------------------------------------------------------------------------------------------------------------------------------
|
|
43
|
+
const packageDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
44
|
+
const cwd = process.cwd();
|
|
45
|
+
|
|
46
|
+
// --- Version -----------------------------------------------------------------------------------------------------------------------------
|
|
47
|
+
const { version } = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
|
|
48
|
+
|
|
49
|
+
// --- Guard: dist/ must exist -------------------------------------------------------------------------------------------------------------
|
|
50
|
+
if (!existsSync(new URL("../dist/commands/dev.js", import.meta.url))) {
|
|
51
|
+
console.error("");
|
|
52
|
+
console.error(" chamba: compiled output not found.");
|
|
53
|
+
console.error(" This should not happen with a published package.");
|
|
54
|
+
console.error(" If you are developing locally, run: pnpm build");
|
|
55
|
+
console.error("");
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- Auto-repair chamba.yaml if needed ---------------------------------------------------------------------------------------------------
|
|
60
|
+
// Every chamba workspace onboards fresh, so there is no old shape to detect and nothing to migrate.
|
|
61
|
+
const yamlDir = findChambaYamlDir(cwd);
|
|
62
|
+
if (yamlDir) {
|
|
63
|
+
const result = repairChambaYaml(yamlDir);
|
|
64
|
+
if (result.error) {
|
|
65
|
+
console.error("");
|
|
66
|
+
console.error(` ${result.error}`);
|
|
67
|
+
console.error("");
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
if (result.repairedYaml) {
|
|
71
|
+
log.info(result.message);
|
|
72
|
+
|
|
73
|
+
// Prompt to stop container if running (config changed)
|
|
74
|
+
const cn = deriveContainerName(result.repairedYaml.workspace_id);
|
|
75
|
+
const inspect = spawnSync("docker", ["inspect", "--format", "{{.State.Status}}", cn], {
|
|
76
|
+
encoding: "utf8",
|
|
77
|
+
stdio: "pipe",
|
|
78
|
+
});
|
|
79
|
+
if (inspect.status === 0 && inspect.stdout.trim() === "running") {
|
|
80
|
+
const shouldStop = await confirm({
|
|
81
|
+
message: "Stop the running container so repaired config applies on next session?",
|
|
82
|
+
});
|
|
83
|
+
if (isCancel(shouldStop) || !shouldStop) {
|
|
84
|
+
log.warn("Container still running with old config.");
|
|
85
|
+
} else {
|
|
86
|
+
spawnSync("docker", ["stop", cn], { stdio: "pipe" });
|
|
87
|
+
spawnSync("docker", ["rm", cn], { stdio: "pipe" });
|
|
88
|
+
log.info("Container stopped. Changes will apply on next session.");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- Resolve workspace from CWD (walk-up looking for chamba.yaml) ------------------------------------------------------------------------
|
|
95
|
+
let workspace;
|
|
96
|
+
try {
|
|
97
|
+
workspace = resolveWorkspace(cwd);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.error("");
|
|
100
|
+
console.error(` ${err instanceof Error ? err.message : err}`);
|
|
101
|
+
console.error("");
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- Onboarding (if not in a registered workspace) ---------------------------------------------------------------------------------------
|
|
106
|
+
if (!workspace) {
|
|
107
|
+
// If other workspaces already exist, let the user choose setup vs manage
|
|
108
|
+
if (listWorkspaceIds().length > 0) {
|
|
109
|
+
while (true) {
|
|
110
|
+
process.stdout.write("\n");
|
|
111
|
+
const choice = await select({
|
|
112
|
+
message: "What would you like to do?",
|
|
113
|
+
options: [
|
|
114
|
+
{ value: "setup", label: "Set up chamba for this directory" },
|
|
115
|
+
{ value: "advanced", label: "Advanced" },
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
if (isCancel(choice)) {
|
|
119
|
+
cancel();
|
|
120
|
+
process.exit(0);
|
|
121
|
+
}
|
|
122
|
+
if (choice === "advanced") {
|
|
123
|
+
// "Back" returns here to re-show this chooser; a terminal action (uninstall) returns
|
|
124
|
+
// undefined and exits, matching the main menu loop's handling below.
|
|
125
|
+
const result = await advancedMenu();
|
|
126
|
+
if (result === "back") continue;
|
|
127
|
+
process.exit(0);
|
|
128
|
+
}
|
|
129
|
+
// "setup" - leave the chooser and fall through to onboarding.
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const ctx = await onboard(cwd);
|
|
135
|
+
if (!ctx) process.exit(0);
|
|
136
|
+
workspace = ctx;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- Doctor (pre-check; silent on success, prints failed checks otherwise) ---------------------------------------------------------------
|
|
140
|
+
const doctorResult = await doctor();
|
|
141
|
+
if (!doctorResult.ok) {
|
|
142
|
+
console.error("");
|
|
143
|
+
console.error(" chamba can't start:");
|
|
144
|
+
for (const err of doctorResult.errors) {
|
|
145
|
+
console.error(` \x1b[2m•\x1b[0m ${err}`);
|
|
146
|
+
}
|
|
147
|
+
console.error("");
|
|
148
|
+
console.error(" Fix the issues above and re-run chamba.");
|
|
149
|
+
console.error("");
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- Interactive menu loop ---------------------------------------------------------------------------------------------------------------
|
|
154
|
+
const { containerName } = workspace;
|
|
155
|
+
let showMenu = true;
|
|
156
|
+
while (showMenu) {
|
|
157
|
+
showMenu = false;
|
|
158
|
+
|
|
159
|
+
// Refresh container state on each loop iteration
|
|
160
|
+
const dockerResult = spawnSync("docker", ["ps", "--filter", "name=chamba-", "--format", "{{.Names}}"], {
|
|
161
|
+
encoding: "utf8",
|
|
162
|
+
});
|
|
163
|
+
const activeNames = dockerResult.stdout ? dockerResult.stdout.trim().split("\n").filter(Boolean) : [];
|
|
164
|
+
const activeCount = activeNames.length;
|
|
165
|
+
const workspaceRunning = activeNames.some((n) => n === containerName);
|
|
166
|
+
|
|
167
|
+
const action = await menu({ ctx: workspace, activeCount, workspaceRunning, version });
|
|
168
|
+
|
|
169
|
+
switch (action) {
|
|
170
|
+
case "web":
|
|
171
|
+
await dev(packageDir, workspace, { mode: "web" });
|
|
172
|
+
break;
|
|
173
|
+
case "shell":
|
|
174
|
+
await dev(packageDir, workspace, { mode: "shell" });
|
|
175
|
+
break;
|
|
176
|
+
case "stop":
|
|
177
|
+
await stop(workspace.containerName);
|
|
178
|
+
break;
|
|
179
|
+
case "settings": {
|
|
180
|
+
const settingsResult = await settingsMenu(workspace);
|
|
181
|
+
if (settingsResult === "rebuild") {
|
|
182
|
+
// resetImage returns false when the user declines a running-container rebuild - skip the
|
|
183
|
+
// build and return to the menu, leaving the live session untouched.
|
|
184
|
+
if (await resetImage(workspace.containerName)) {
|
|
185
|
+
await dev(packageDir, workspace, { mode: "web" });
|
|
186
|
+
} else {
|
|
187
|
+
showMenu = true;
|
|
188
|
+
}
|
|
189
|
+
} else if (settingsResult === "clean-rebuild") {
|
|
190
|
+
if (await resetImage(workspace.containerName)) {
|
|
191
|
+
await dev(packageDir, workspace, { noCache: true, mode: "web" });
|
|
192
|
+
} else {
|
|
193
|
+
showMenu = true;
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
showMenu = true;
|
|
197
|
+
}
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
case "advanced": {
|
|
201
|
+
const result = await advancedMenu(workspace.workspaceId);
|
|
202
|
+
if (result === "back") showMenu = true;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
case "quit":
|
|
206
|
+
// Trailing blank line so the menu does not sit flush against the next shell prompt.
|
|
207
|
+
process.stdout.write("\n");
|
|
208
|
+
break;
|
|
209
|
+
default:
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// =========================================================================================================================================
|
|
2
|
+
// src/commands/advanced.ts - Advanced menu: less-common operations (stop containers, clear agent memory, remove images, uninstall)
|
|
3
|
+
// =========================================================================================================================================
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { cancel, confirm, isCancel, log, multiselect, outro, select, text } from "@clack/prompts";
|
|
9
|
+
import { AGENTS_DIR, CHAMBA_DIR, CHAMBA_YAML, CONTAINER_NAME_PREFIX, LABEL_MANAGED } from "../lib/constants.js";
|
|
10
|
+
import { safeRmSync } from "../lib/safe-rm.js";
|
|
11
|
+
import { listWorkspaces } from "../lib/workspace-identity.js";
|
|
12
|
+
// --- Helpers -----------------------------------------------------------------------------------------------------------------------------
|
|
13
|
+
/** Remove workspace cache dir and optionally chamba.yaml from the workspace root. Exported for testing. */
|
|
14
|
+
export function removeWorkspaceFiles(workspaceRoot, workspaceDir, removeChambaYaml) {
|
|
15
|
+
safeRmSync(workspaceDir, { recursive: true, force: true });
|
|
16
|
+
if (removeChambaYaml) {
|
|
17
|
+
const yamlPath = join(workspaceRoot, CHAMBA_YAML);
|
|
18
|
+
if (existsSync(yamlPath)) {
|
|
19
|
+
safeRmSync(yamlPath);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// Stop-only (no rm) so the next session resumes fast via the "exited" -> docker start path.
|
|
24
|
+
function stopContainer(name) {
|
|
25
|
+
spawnSync("docker", ["stop", name], { stdio: "pipe" });
|
|
26
|
+
}
|
|
27
|
+
// Stop and remove - only for flows that must recreate the container (image removal, uninstall).
|
|
28
|
+
function stopAndRemoveContainer(name) {
|
|
29
|
+
spawnSync("docker", ["stop", name], { stdio: "pipe" });
|
|
30
|
+
spawnSync("docker", ["rm", name], { stdio: "pipe" });
|
|
31
|
+
}
|
|
32
|
+
// --- Stop containers (multi-select across all workspaces) --------------------------------------------------------------------------------
|
|
33
|
+
async function stopContainers() {
|
|
34
|
+
const listResult = spawnSync("docker", ["ps", "--filter", `name=${CONTAINER_NAME_PREFIX}`, "--format", "{{.Names}}"], {
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
});
|
|
37
|
+
const running = (listResult.stdout ?? "").trim().split("\n").filter(Boolean);
|
|
38
|
+
if (running.length === 0) {
|
|
39
|
+
log.info("No running chamba containers.");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
// Always show the selector, even for a single container, so nothing is stopped without the user picking it.
|
|
43
|
+
const selected = await multiselect({
|
|
44
|
+
message: "Select containers to stop: (space to toggle, enter to confirm)",
|
|
45
|
+
options: running.map((name) => ({ value: name, label: name })),
|
|
46
|
+
required: false,
|
|
47
|
+
});
|
|
48
|
+
if (isCancel(selected)) {
|
|
49
|
+
cancel();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const toStop = selected;
|
|
53
|
+
if (toStop.length === 0) {
|
|
54
|
+
log.info("Nothing selected - no containers stopped.");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
for (const name of toStop) {
|
|
58
|
+
log.info(`Stopping ${name}...`);
|
|
59
|
+
stopContainer(name);
|
|
60
|
+
}
|
|
61
|
+
log.success("Done.");
|
|
62
|
+
}
|
|
63
|
+
// --- Clear agent memory (multi-select across all workspaces) -----------------------------------------------------------------------------
|
|
64
|
+
async function clearAgentMemory() {
|
|
65
|
+
const workspaces = listWorkspaces().filter((w) => existsSync(join(w.workspaceDir, AGENTS_DIR)));
|
|
66
|
+
if (workspaces.length === 0) {
|
|
67
|
+
log.info("No agent memory found.");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// Always show the selector, even for a single workspace, so memory is never cleared without the user picking it.
|
|
71
|
+
const selected = await multiselect({
|
|
72
|
+
message: "Select workspaces to clear agent memory for: (space to toggle, enter to confirm)",
|
|
73
|
+
options: workspaces.map((w) => ({ value: w.workspaceId, label: w.workspaceId, hint: w.workspaceRoot })),
|
|
74
|
+
required: false,
|
|
75
|
+
});
|
|
76
|
+
if (isCancel(selected)) {
|
|
77
|
+
cancel();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const toClear = selected;
|
|
81
|
+
if (toClear.length === 0) {
|
|
82
|
+
log.info("Nothing selected - no agent memory cleared.");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
for (const id of toClear) {
|
|
86
|
+
const w = workspaces.find((x) => x.workspaceId === id);
|
|
87
|
+
if (!w)
|
|
88
|
+
continue;
|
|
89
|
+
const inspectResult = spawnSync("docker", ["inspect", "--format", "{{.State.Status}}", w.containerName], {
|
|
90
|
+
encoding: "utf8",
|
|
91
|
+
stdio: "pipe",
|
|
92
|
+
});
|
|
93
|
+
const isRunning = inspectResult.status === 0 && inspectResult.stdout.trim() === "running";
|
|
94
|
+
if (isRunning) {
|
|
95
|
+
const confirmed = await confirm({
|
|
96
|
+
message: `Container for ${w.workspaceId} is running. Stop it to clear memory?`,
|
|
97
|
+
});
|
|
98
|
+
if (isCancel(confirmed) || !confirmed)
|
|
99
|
+
continue;
|
|
100
|
+
log.info(`Stopping ${w.containerName}...`);
|
|
101
|
+
stopContainer(w.containerName);
|
|
102
|
+
}
|
|
103
|
+
const agentsDir = join(w.workspaceDir, AGENTS_DIR);
|
|
104
|
+
safeRmSync(agentsDir, { recursive: true, force: true });
|
|
105
|
+
log.success(`Cleared agent memory for ${w.workspaceId}.`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// --- Remove images (multi-select across all workspaces) ----------------------------------------------------------------------------------
|
|
109
|
+
async function removeImages() {
|
|
110
|
+
const listResult = spawnSync("docker", ["images", "--filter", `label=${LABEL_MANAGED}=true`, "--format", "{{.Repository}}\t{{.ID}}"], {
|
|
111
|
+
encoding: "utf8",
|
|
112
|
+
});
|
|
113
|
+
const lines = (listResult.stdout ?? "").trim().split("\n").filter(Boolean);
|
|
114
|
+
if (lines.length === 0) {
|
|
115
|
+
log.info("No chamba images found.");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const images = lines.map((line) => {
|
|
119
|
+
const parts = line.split("\t");
|
|
120
|
+
const repo = parts[0] ?? "";
|
|
121
|
+
const id = parts[1] ?? "";
|
|
122
|
+
return { repo, id };
|
|
123
|
+
});
|
|
124
|
+
const selected = await multiselect({
|
|
125
|
+
message: "Select images to remove: (space to toggle, enter to confirm)",
|
|
126
|
+
options: images.map((img) => ({ value: img.repo, label: img.repo, hint: img.id.slice(0, 12) })),
|
|
127
|
+
required: false,
|
|
128
|
+
});
|
|
129
|
+
if (isCancel(selected)) {
|
|
130
|
+
cancel();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// Stop any running container using this image first
|
|
134
|
+
for (const repo of selected) {
|
|
135
|
+
const psResult = spawnSync("docker", ["ps", "--filter", `name=${repo}`, "--format", "{{.Names}}"], {
|
|
136
|
+
encoding: "utf8",
|
|
137
|
+
});
|
|
138
|
+
const containers = (psResult.stdout ?? "").trim().split("\n").filter(Boolean);
|
|
139
|
+
for (const c of containers) {
|
|
140
|
+
log.info(`Stopping container ${c} before removing image...`);
|
|
141
|
+
stopAndRemoveContainer(c);
|
|
142
|
+
}
|
|
143
|
+
log.info(`Removing image ${repo}...`);
|
|
144
|
+
spawnSync("docker", ["rmi", repo], { stdio: "pipe" });
|
|
145
|
+
}
|
|
146
|
+
log.success("Done.");
|
|
147
|
+
}
|
|
148
|
+
// --- Uninstall workspaces (multi-select, remove container + image + workspace dir) -------------------------------------------------------
|
|
149
|
+
async function uninstallWorkspaces(currentWorkspaceId) {
|
|
150
|
+
const workspaces = listWorkspaces();
|
|
151
|
+
if (workspaces.length === 0) {
|
|
152
|
+
log.info("No registered workspaces.");
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
// Show current workspace first if known
|
|
156
|
+
const sorted = currentWorkspaceId
|
|
157
|
+
? [...workspaces].sort((a, b) => (a.workspaceId === currentWorkspaceId ? -1 : b.workspaceId === currentWorkspaceId ? 1 : 0))
|
|
158
|
+
: workspaces;
|
|
159
|
+
log.info("Uninstalling a workspace stops and removes its container and image, and deletes its data from ~/.chamba/workspaces/. Your files are untouched.");
|
|
160
|
+
const selected = await multiselect({
|
|
161
|
+
message: "Select workspaces to uninstall: (space to toggle, enter to confirm)",
|
|
162
|
+
options: sorted.map((w) => ({
|
|
163
|
+
value: w.workspaceId,
|
|
164
|
+
label: w.workspaceId,
|
|
165
|
+
hint: w.workspaceRoot + (w.workspaceId === currentWorkspaceId ? " (current)" : ""),
|
|
166
|
+
})),
|
|
167
|
+
required: false,
|
|
168
|
+
});
|
|
169
|
+
if (isCancel(selected)) {
|
|
170
|
+
cancel();
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
const selectedIds = selected;
|
|
174
|
+
for (const id of selectedIds) {
|
|
175
|
+
const w = workspaces.find((x) => x.workspaceId === id);
|
|
176
|
+
if (!w)
|
|
177
|
+
continue;
|
|
178
|
+
// Stop and remove container if it exists (running or exited)
|
|
179
|
+
const psResult = spawnSync("docker", ["ps", "-a", "--filter", `name=${w.containerName}`, "--format", "{{.Names}}"], {
|
|
180
|
+
encoding: "utf8",
|
|
181
|
+
});
|
|
182
|
+
const containers = (psResult.stdout ?? "").trim().split("\n").filter(Boolean);
|
|
183
|
+
for (const c of containers) {
|
|
184
|
+
log.info(`Stopping and removing container ${c}...`);
|
|
185
|
+
stopAndRemoveContainer(c);
|
|
186
|
+
}
|
|
187
|
+
log.info(`Removing image ${w.containerName}...`);
|
|
188
|
+
spawnSync("docker", ["rmi", w.containerName], { stdio: "inherit" });
|
|
189
|
+
// Ask whether to also remove chamba.yaml from the workspace root
|
|
190
|
+
const yamlPath = join(w.workspaceRoot, CHAMBA_YAML);
|
|
191
|
+
let removeChambaYaml = false;
|
|
192
|
+
if (existsSync(yamlPath)) {
|
|
193
|
+
const ans = await confirm({
|
|
194
|
+
message: `Also remove chamba.yaml from ${w.workspaceRoot}?`,
|
|
195
|
+
initialValue: true,
|
|
196
|
+
});
|
|
197
|
+
removeChambaYaml = !isCancel(ans) && ans;
|
|
198
|
+
}
|
|
199
|
+
removeWorkspaceFiles(w.workspaceRoot, w.workspaceDir, removeChambaYaml);
|
|
200
|
+
log.success(`Uninstalled workspace ${w.workspaceId}.`);
|
|
201
|
+
}
|
|
202
|
+
return currentWorkspaceId !== undefined && selectedIds.includes(currentWorkspaceId);
|
|
203
|
+
}
|
|
204
|
+
// --- Uninstall chamba (global) -----------------------------------------------------------------------------------------------------------
|
|
205
|
+
async function uninstallChamba() {
|
|
206
|
+
const confirmed = await text({
|
|
207
|
+
message: 'Type "uninstall-chamba" to confirm full uninstall:',
|
|
208
|
+
validate: (v) => ((v ?? "").trim() !== "uninstall-chamba" ? 'Type exactly "uninstall-chamba" to confirm' : undefined),
|
|
209
|
+
});
|
|
210
|
+
if (isCancel(confirmed) || confirmed.trim() !== "uninstall-chamba") {
|
|
211
|
+
cancel("Uninstall cancelled.");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// Stop and remove all chamba containers
|
|
215
|
+
const psResult = spawnSync("docker", ["ps", "-a", "--filter", `name=${CONTAINER_NAME_PREFIX}`, "--format", "{{.Names}}"], {
|
|
216
|
+
encoding: "utf8",
|
|
217
|
+
});
|
|
218
|
+
const containers = (psResult.stdout ?? "").trim().split("\n").filter(Boolean);
|
|
219
|
+
for (const c of containers) {
|
|
220
|
+
log.info(`Stopping and removing container ${c}...`);
|
|
221
|
+
stopAndRemoveContainer(c);
|
|
222
|
+
}
|
|
223
|
+
// Remove all chamba images
|
|
224
|
+
const imagesResult = spawnSync("docker", ["images", "--filter", `label=${LABEL_MANAGED}=true`, "--format", "{{.Repository}}"], {
|
|
225
|
+
encoding: "utf8",
|
|
226
|
+
});
|
|
227
|
+
const imgs = (imagesResult.stdout ?? "").trim().split("\n").filter(Boolean);
|
|
228
|
+
for (const img of imgs) {
|
|
229
|
+
log.info(`Removing image ${img}...`);
|
|
230
|
+
spawnSync("docker", ["rmi", img], { stdio: "inherit" });
|
|
231
|
+
}
|
|
232
|
+
// Delete ~/.chamba/
|
|
233
|
+
const globalChambaDir = join(homedir(), CHAMBA_DIR);
|
|
234
|
+
if (existsSync(globalChambaDir)) {
|
|
235
|
+
log.info("Deleting ~/.chamba/...");
|
|
236
|
+
safeRmSync(globalChambaDir, { recursive: true, force: true });
|
|
237
|
+
}
|
|
238
|
+
outro("chamba uninstalled. Re-run npx chamba to set up again.");
|
|
239
|
+
}
|
|
240
|
+
// --- Advanced menu -----------------------------------------------------------------------------------------------------------------------
|
|
241
|
+
export async function run(currentWorkspaceId) {
|
|
242
|
+
while (true) {
|
|
243
|
+
const action = await select({
|
|
244
|
+
message: "Advanced:",
|
|
245
|
+
options: [
|
|
246
|
+
{ value: "stop-containers", label: "Stop containers", hint: "pick running containers" },
|
|
247
|
+
{ value: "clear-memory", label: "Clear agent memory", hint: "pick workspaces to clear" },
|
|
248
|
+
{ value: "remove-images", label: "Remove images", hint: "pick images to remove" },
|
|
249
|
+
{ value: "uninstall-workspace", label: "Uninstall workspaces", hint: "pick workspaces to remove" },
|
|
250
|
+
{ value: "uninstall", label: "Uninstall chamba", hint: "wipe ~/.chamba/ and all containers/images" },
|
|
251
|
+
{ value: "back", label: "← Back" },
|
|
252
|
+
],
|
|
253
|
+
});
|
|
254
|
+
if (isCancel(action) || action === "back") {
|
|
255
|
+
return "back";
|
|
256
|
+
}
|
|
257
|
+
switch (action) {
|
|
258
|
+
case "stop-containers":
|
|
259
|
+
await stopContainers();
|
|
260
|
+
break;
|
|
261
|
+
case "clear-memory":
|
|
262
|
+
await clearAgentMemory();
|
|
263
|
+
break;
|
|
264
|
+
case "remove-images":
|
|
265
|
+
await removeImages();
|
|
266
|
+
break;
|
|
267
|
+
case "uninstall-workspace": {
|
|
268
|
+
const currentDeleted = await uninstallWorkspaces(currentWorkspaceId);
|
|
269
|
+
if (currentDeleted)
|
|
270
|
+
return undefined;
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
case "uninstall":
|
|
274
|
+
await uninstallChamba();
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|