chamba 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 -24
- 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 -1691
- package/dist/server.js +0 -1919
- 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,267 @@
|
|
|
1
|
+
// =========================================================================================================================================
|
|
2
|
+
// src/lib/dockerfile-builder.ts - In-memory Dockerfile assembly and temp-file build
|
|
3
|
+
// Combines base template + profile hook + USER instruction at build time
|
|
4
|
+
// =========================================================================================================================================
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
7
|
+
import { existsSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { spinner } from "@clack/prompts";
|
|
11
|
+
import { CONTAINER_HOME, CONTAINER_NAME_PREFIX, CONTAINER_STARTUP, CONTAINER_USER, LABEL_BUILD_HASH, LABEL_MANAGED, WEB_KEY_FILE_PATH, } from "./constants.js";
|
|
12
|
+
// --- User shell config appended after USER instruction -----------------------------------------------------------------------------------
|
|
13
|
+
const USER_SHELL_CONFIG = `
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# User shell config (PATH, prompt, welcome message, status alias)
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
ENV PATH="${CONTAINER_HOME}/.cargo/bin:${CONTAINER_HOME}/.bun/bin:${CONTAINER_HOME}/.local/bin:/usr/local/go/bin:\${PATH}"
|
|
18
|
+
# Prompt helper: print the working directory relative to the workspace root, so /workspace shows as
|
|
19
|
+
# "/" and /workspace/src shows as "/src". Paths outside the workspace fall back to their full path.
|
|
20
|
+
RUN echo '__chamba_pwd() { local p="\${PWD#/workspace}"; printf "/%s" "\${p#/}"; }' >> ${CONTAINER_HOME}/.bashrc && \\
|
|
21
|
+
echo '# The web interface URL, key and all. The key is minted by the webterm server at every start and' >> ${CONTAINER_HOME}/.bashrc && \\
|
|
22
|
+
echo '# published to the key file, so the greeting reads it back instead of holding a URL that goes stale.' >> ${CONTAINER_HOME}/.bashrc && \\
|
|
23
|
+
echo '__chamba_web_url() { if [ -r ${WEB_KEY_FILE_PATH} ]; then echo "\${CHAMBA_WEB_URL}/?k=$(head -n 1 ${WEB_KEY_FILE_PATH})"; else echo "\${CHAMBA_WEB_URL}"; fi; }' \\
|
|
24
|
+
>> ${CONTAINER_HOME}/.bashrc && \\
|
|
25
|
+
echo 'export PS1="\\[\\033[01;32m\\][chamba@\${CHAMBA_WORKSPACE}]\\[\\033[00m\\] \\[\\033[01;34m\\]\\$(__chamba_pwd)\\[\\033[00m\\] \\[\\033[01;32m\\]❯\\[\\033[00m\\] "' \\
|
|
26
|
+
>> ${CONTAINER_HOME}/.bashrc && \\
|
|
27
|
+
echo 'echo ""' >> ${CONTAINER_HOME}/.bashrc && \\
|
|
28
|
+
echo 'echo -e "\\033[32m●\\033[0m \\033[1mYou'"'"'re now in a chamba sandbox\\033[0m \\033[90m·\\033[0m \\033[1m\${CHAMBA_WORKSPACE}\\033[0m"' >> ${CONTAINER_HOME}/.bashrc && \\
|
|
29
|
+
echo 'echo ""' >> ${CONTAINER_HOME}/.bashrc && \\
|
|
30
|
+
echo 'echo -e " \\033[90m▸ Run \\033[38;5;208mclaude\\033[90m, \\033[38;5;208mopencode\\033[90m, or \\033[38;5;208mcodex\\033[90m to start an agent.\\033[0m"' >> ${CONTAINER_HOME}/.bashrc && \\
|
|
31
|
+
echo 'echo -e " \\033[90m▸ Run \\033[97mstatus\\033[90m to see container details & installed versions.\\033[0m"' >> ${CONTAINER_HOME}/.bashrc && \\
|
|
32
|
+
echo 'echo -e " \\033[90m▸ Run \\033[97mexit\\033[90m to end the session and return to the host.\\033[0m"' >> ${CONTAINER_HOME}/.bashrc && \\
|
|
33
|
+
echo 'echo ""' >> ${CONTAINER_HOME}/.bashrc && \\
|
|
34
|
+
echo '# The web interface, where the agents usually run. CHAMBA_WEB_URL is injected by docker run whenever' >> ${CONTAINER_HOME}/.bashrc && \\
|
|
35
|
+
echo '# a host port could be published for it, so its absence means this session has no web address.' >> ${CONTAINER_HOME}/.bashrc && \\
|
|
36
|
+
echo '# The URL gets its own line: on a host that could not open a browser, this banner is how it is found.' >> ${CONTAINER_HOME}/.bashrc && \\
|
|
37
|
+
echo 'if [ -n "$CHAMBA_WEB_URL" ]; then' >> ${CONTAINER_HOME}/.bashrc && \\
|
|
38
|
+
echo ' echo -e "\\033[32m●\\033[0m \\033[90mThe web interface for this workspace:\\033[0m"' >> ${CONTAINER_HOME}/.bashrc && \\
|
|
39
|
+
echo ' echo ""' >> ${CONTAINER_HOME}/.bashrc && \\
|
|
40
|
+
echo ' echo -e " \\033[97m$(__chamba_web_url)\\033[0m"' >> ${CONTAINER_HOME}/.bashrc && \\
|
|
41
|
+
echo ' echo ""' >> ${CONTAINER_HOME}/.bashrc && \\
|
|
42
|
+
echo 'fi' >> ${CONTAINER_HOME}/.bashrc && \\
|
|
43
|
+
echo 'alias status="node ${CONTAINER_STARTUP}"' >> ${CONTAINER_HOME}/.bashrc
|
|
44
|
+
|
|
45
|
+
CMD ["/bin/bash"]
|
|
46
|
+
`;
|
|
47
|
+
// --- Build Dockerfile content ------------------------------------------------------------------------------------------------------------
|
|
48
|
+
/**
|
|
49
|
+
* Assemble a Dockerfile from the base template, optional profile hook, and USER instruction.
|
|
50
|
+
* The resulting Dockerfile is never persisted - it's written to a temp file at build time.
|
|
51
|
+
*/
|
|
52
|
+
export function buildDockerfile(baseTemplatePath, profileHook) {
|
|
53
|
+
let content = readFileSync(baseTemplatePath, "utf8");
|
|
54
|
+
// Append profile hook if provided
|
|
55
|
+
if (profileHook?.trim()) {
|
|
56
|
+
content += `\n# ---------------------------------------------------------------------------\n`;
|
|
57
|
+
content += `# Profile hook (from chamba.yaml)\n`;
|
|
58
|
+
content += `# ---------------------------------------------------------------------------\n`;
|
|
59
|
+
content += profileHook.endsWith("\n") ? profileHook : `${profileHook}\n`;
|
|
60
|
+
}
|
|
61
|
+
// USER must come after profile hook and before shell config
|
|
62
|
+
content += `\nUSER ${CONTAINER_USER}\n`;
|
|
63
|
+
content += USER_SHELL_CONFIG;
|
|
64
|
+
return content;
|
|
65
|
+
}
|
|
66
|
+
// --- Build hash for image staleness detection --------------------------------------------------------------------------------------------
|
|
67
|
+
// Filenames (relative to the templates dir) of every artifact that the Dockerfile bakes into the image
|
|
68
|
+
// via COPY. Edits to any of these change the build hash and trigger a rebuild prompt at session start.
|
|
69
|
+
// Kept in sync with templates/Dockerfile by the bidirectional test in tests/dockerfile-builder.test.ts.
|
|
70
|
+
export const BAKED_TEMPLATE_FILES = [
|
|
71
|
+
"claude-statusline.sh",
|
|
72
|
+
"context-usage.sh",
|
|
73
|
+
"git-readonly-wrapper.mjs",
|
|
74
|
+
"npmrc",
|
|
75
|
+
"pnpm-config.yaml",
|
|
76
|
+
"runtime-constants.mjs",
|
|
77
|
+
"startup-git-mode.mjs",
|
|
78
|
+
"startup.mjs",
|
|
79
|
+
"webpane.sh",
|
|
80
|
+
"webterm.sh",
|
|
81
|
+
];
|
|
82
|
+
// Directories (relative to the templates dir) baked into the image via a directory COPY. Hashed
|
|
83
|
+
// recursively by computeBuildHash so editing any file inside triggers a rebuild prompt, with
|
|
84
|
+
// node_modules skipped - a local install there must never flip the hash (it is .dockerignore'd too).
|
|
85
|
+
export const BAKED_TEMPLATE_DIRS = ["webterm"];
|
|
86
|
+
// Relative paths of every regular file under dir (sorted, node_modules subtrees skipped). Missing
|
|
87
|
+
// dirs yield [] for the same reason computeBuildHash tolerates missing files: minimal test contexts.
|
|
88
|
+
function walkTemplateDir(root, prefix = "") {
|
|
89
|
+
const files = [];
|
|
90
|
+
let entries;
|
|
91
|
+
try {
|
|
92
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return files;
|
|
96
|
+
}
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (entry.name === "node_modules")
|
|
99
|
+
continue;
|
|
100
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
101
|
+
if (entry.isDirectory())
|
|
102
|
+
files.push(...walkTemplateDir(join(root, entry.name), rel));
|
|
103
|
+
else if (entry.isFile())
|
|
104
|
+
files.push(rel);
|
|
105
|
+
}
|
|
106
|
+
return files.sort();
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Fingerprint everything the package contributes to the image: the assembled Dockerfile content
|
|
110
|
+
* plus every file in BAKED_TEMPLATE_FILES, hashed in deterministic order.
|
|
111
|
+
*
|
|
112
|
+
* Tolerates missing files in buildContextDir. In production (real package install) all baked files
|
|
113
|
+
* exist, so the existsSync branch never fires. In tests that build minimal images from a temp
|
|
114
|
+
* contextDir, missing files naturally produce a different hash than production - exactly the
|
|
115
|
+
* contract the staleness tests rely on.
|
|
116
|
+
*/
|
|
117
|
+
export function computeBuildHash(dockerfileContent, buildContextDir) {
|
|
118
|
+
const h = createHash("sha256");
|
|
119
|
+
h.update("dockerfile:\n");
|
|
120
|
+
h.update(dockerfileContent);
|
|
121
|
+
for (const name of [...BAKED_TEMPLATE_FILES].sort()) {
|
|
122
|
+
h.update(`\nfile:${name}\n`);
|
|
123
|
+
const path = join(buildContextDir, name);
|
|
124
|
+
if (existsSync(path)) {
|
|
125
|
+
h.update(readFileSync(path));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const dir of [...BAKED_TEMPLATE_DIRS].sort()) {
|
|
129
|
+
for (const rel of walkTemplateDir(join(buildContextDir, dir))) {
|
|
130
|
+
h.update(`\nfile:${dir}/${rel}\n`);
|
|
131
|
+
h.update(readFileSync(join(buildContextDir, dir, rel)));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return h.digest("hex");
|
|
135
|
+
}
|
|
136
|
+
// --- Image staleness detection -----------------------------------------------------------------------------------------------------------
|
|
137
|
+
//
|
|
138
|
+
// Called at session start; returns true if the running container's image is older than the current
|
|
139
|
+
// package and needs a rebuild prompt.
|
|
140
|
+
//
|
|
141
|
+
// Mechanism: at build time, buildImageWithTempfile below stamps every image with a chamba.build-hash
|
|
142
|
+
// label carrying computeBuildHash's fingerprint of the assembled Dockerfile + every baked template
|
|
143
|
+
// file. At session start we recompute the expected hash from current package sources and compare.
|
|
144
|
+
// Any change to templates/Dockerfile, the active profile hook, or any baked template file produces a
|
|
145
|
+
// different hash -> rebuild prompt fires.
|
|
146
|
+
//
|
|
147
|
+
// When shipping a new bake-time artifact:
|
|
148
|
+
// - Editing an existing template file or the Dockerfile -> auto-detected. No action.
|
|
149
|
+
// - Adding a NEW templated COPY -> add the filename to BAKED_TEMPLATE_FILES above. The unit test in
|
|
150
|
+
// tests/dockerfile-builder.test.ts will fail until you do.
|
|
151
|
+
//
|
|
152
|
+
// Cosmetic Dockerfile edits (comment-only, whitespace) DO trigger a rebuild for users on prior
|
|
153
|
+
// images. This is intentional: the Dockerfile and template files are rarely edited, so any change
|
|
154
|
+
// is treated as meaningful. A cosmetic edit here costs every user a rebuild, so it is worth asking
|
|
155
|
+
// whether the change is worth making at all.
|
|
156
|
+
/** Returns true if the container's stamped chamba.build-hash label does not match the expected hash. */
|
|
157
|
+
export function isImageStale(containerName, expectedBuildHash) {
|
|
158
|
+
const result = spawnSync("docker", ["inspect", "--format", `{{ index .Config.Labels "${LABEL_BUILD_HASH}" }}`, containerName], {
|
|
159
|
+
encoding: "utf8",
|
|
160
|
+
stdio: "pipe",
|
|
161
|
+
});
|
|
162
|
+
if (result.status !== 0)
|
|
163
|
+
return true;
|
|
164
|
+
return result.stdout.trim() !== expectedBuildHash;
|
|
165
|
+
}
|
|
166
|
+
// --- Build image with temp file ----------------------------------------------------------------------------------------------------------
|
|
167
|
+
// Grey ANSI for the inline build percentage on the spinner line.
|
|
168
|
+
const grey = (s) => `\x1b[90m${s}\x1b[0m`;
|
|
169
|
+
/**
|
|
170
|
+
* Run docker build behind a single clack spinner for interactive sessions.
|
|
171
|
+
* All raw buildx output is captured, never streamed. BuildKit "[ n/N ]" step markers (from
|
|
172
|
+
* --progress=plain) are parsed into a grey percentage on the spinner line when available; if nothing
|
|
173
|
+
* parses, the spinner just keeps animating with the plain text. The captured log is written to stderr
|
|
174
|
+
* only on failure, so a broken build stays diagnosable.
|
|
175
|
+
*/
|
|
176
|
+
function runBuildWithSpinner(buildArgs) {
|
|
177
|
+
return new Promise((resolve) => {
|
|
178
|
+
const s = spinner();
|
|
179
|
+
const baseMessage = "Docker rebuilding container..";
|
|
180
|
+
s.start(baseMessage);
|
|
181
|
+
let captured = "";
|
|
182
|
+
let lastStep = 0;
|
|
183
|
+
let totalSteps = 0;
|
|
184
|
+
const onData = (chunk) => {
|
|
185
|
+
const text = chunk.toString();
|
|
186
|
+
captured += text;
|
|
187
|
+
try {
|
|
188
|
+
for (const m of text.matchAll(/\[\s*(\d+)\/(\d+)\]/g)) {
|
|
189
|
+
const cur = Number(m[1]);
|
|
190
|
+
const total = Number(m[2]);
|
|
191
|
+
if (total > 0)
|
|
192
|
+
totalSteps = total;
|
|
193
|
+
if (cur > lastStep)
|
|
194
|
+
lastStep = cur;
|
|
195
|
+
}
|
|
196
|
+
if (totalSteps > 0) {
|
|
197
|
+
const pct = Math.min(100, Math.round((lastStep / totalSteps) * 100));
|
|
198
|
+
s.message(`${baseMessage} ${grey(`${pct}%`)}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// Parsing must never affect the build; fall back to the plain animated spinner.
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
const child = spawn("docker", buildArgs, { stdio: ["ignore", "pipe", "pipe"] });
|
|
206
|
+
child.stdout?.on("data", onData);
|
|
207
|
+
child.stderr?.on("data", onData);
|
|
208
|
+
child.on("error", (err) => {
|
|
209
|
+
s.stop("Docker build failed");
|
|
210
|
+
process.stderr.write(`${err.message}\n`);
|
|
211
|
+
resolve({ status: 1 });
|
|
212
|
+
});
|
|
213
|
+
child.on("close", (code) => {
|
|
214
|
+
const status = code ?? 1;
|
|
215
|
+
if (status === 0) {
|
|
216
|
+
s.stop("Container image ready");
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
s.stop("Docker build failed");
|
|
220
|
+
process.stderr.write(captured);
|
|
221
|
+
}
|
|
222
|
+
resolve({ status });
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Write Dockerfile content to a temp file, run docker build, then clean up.
|
|
228
|
+
* Build context is always the templates directory so COPY instructions resolve correctly.
|
|
229
|
+
* Quiet mode (tests) runs synchronously with captured output; interactive mode shows a spinner.
|
|
230
|
+
*/
|
|
231
|
+
export async function buildImageWithTempfile(dockerfileContent, buildContextDir, imageName, noCache = false, quiet = false) {
|
|
232
|
+
const tmpFile = join(tmpdir(), `${CONTAINER_NAME_PREFIX}Dockerfile-${randomBytes(8).toString("hex")}`);
|
|
233
|
+
try {
|
|
234
|
+
writeFileSync(tmpFile, dockerfileContent);
|
|
235
|
+
const buildHash = computeBuildHash(dockerfileContent, buildContextDir);
|
|
236
|
+
const buildArgs = [
|
|
237
|
+
"build",
|
|
238
|
+
"--label",
|
|
239
|
+
`${LABEL_MANAGED}=true`,
|
|
240
|
+
"--label",
|
|
241
|
+
`${LABEL_BUILD_HASH}=${buildHash}`,
|
|
242
|
+
"-f",
|
|
243
|
+
tmpFile,
|
|
244
|
+
"-t",
|
|
245
|
+
imageName,
|
|
246
|
+
];
|
|
247
|
+
if (noCache)
|
|
248
|
+
buildArgs.push("--no-cache");
|
|
249
|
+
// Quiet mode (tests): synchronous, output captured, no spinner.
|
|
250
|
+
if (quiet) {
|
|
251
|
+
buildArgs.push(buildContextDir);
|
|
252
|
+
const result = spawnSync("docker", buildArgs, { stdio: "pipe" });
|
|
253
|
+
return { status: result.status ?? 1 };
|
|
254
|
+
}
|
|
255
|
+
// Interactive mode: line-oriented progress so step markers are parseable, hidden behind a spinner.
|
|
256
|
+
buildArgs.push("--progress=plain", buildContextDir);
|
|
257
|
+
return await runBuildWithSpinner(buildArgs);
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
try {
|
|
261
|
+
unlinkSync(tmpFile);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// temp file cleanup is best-effort
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
package/dist/lib/env.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// =========================================================================================================================================
|
|
2
|
+
// src/lib/env.ts - Resolve the `env` field into container environment injection (files + inline vars)
|
|
3
|
+
// An `env` entry is either an env-file path (no '=') loaded via --env-file, or an inline KEY=VALUE injected via -e.
|
|
4
|
+
// Config is the source of truth: a hashed label over inline vars + resolved file contents recreates the container on change.
|
|
5
|
+
// =========================================================================================================================================
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
// --- Constants ---------------------------------------------------------------------------------------------------------------------------
|
|
10
|
+
// Inline var keys must be valid shell env identifiers - the same pattern the `ports` env field uses.
|
|
11
|
+
const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
12
|
+
// --- Config validation and normalization -------------------------------------------------------------------------------------------------
|
|
13
|
+
/**
|
|
14
|
+
* Validate and normalize the raw `env` field into inline vars and file entries. This is the single source of
|
|
15
|
+
* truth for env semantics (unit-testable without the JSON Schema). A scalar or undefined is normalized to an
|
|
16
|
+
* array; entries are trimmed and empties dropped. Classification is by presence of '=': an entry with '=' is
|
|
17
|
+
* an inline KEY=VALUE var; an entry without '=' is an env-file path resolved relative to the workspace dir.
|
|
18
|
+
* Throws on the first malformed inline entry with a clear message. Missing files never throw - they are
|
|
19
|
+
* flagged exists:false and warned about at startup rather than failing the session.
|
|
20
|
+
*/
|
|
21
|
+
export function validateEnvConfig(raw, workspaceDir) {
|
|
22
|
+
const entries = (Array.isArray(raw) ? raw : raw === undefined ? [] : [raw]).map((e) => e.trim()).filter((e) => e.length > 0);
|
|
23
|
+
const inlineVars = [];
|
|
24
|
+
const files = [];
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
const eq = entry.indexOf("=");
|
|
27
|
+
if (eq === -1) {
|
|
28
|
+
// No '=' -> file path, resolved relative to the workspace dir.
|
|
29
|
+
const path = join(workspaceDir, entry);
|
|
30
|
+
files.push({ path, exists: existsSync(path) });
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
// Has '=' -> inline KEY=VALUE. The key must be a valid env identifier; the value may be empty or contain further '='.
|
|
34
|
+
const key = entry.slice(0, eq);
|
|
35
|
+
if (!ENV_KEY_PATTERN.test(key)) {
|
|
36
|
+
throw new Error(`env: invalid variable "${entry}" - the key must match ${ENV_KEY_PATTERN.source} (e.g. FOO=bar).`);
|
|
37
|
+
}
|
|
38
|
+
inlineVars.push(entry);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { inlineVars, files };
|
|
42
|
+
}
|
|
43
|
+
// --- Docker argument builders (pure) -----------------------------------------------------------------------------------------------------
|
|
44
|
+
/**
|
|
45
|
+
* Docker run args for env injection: --env-file for each existing file (missing files omitted), then
|
|
46
|
+
* -e KEY=VALUE per inline var. Files come first so an inline var wins a duplicate key - docker applies later
|
|
47
|
+
* -e over an earlier --env-file - which lets a chamba.yaml inline var override a value from an env file.
|
|
48
|
+
*/
|
|
49
|
+
export function envRunArgs(cfg) {
|
|
50
|
+
const fileArgs = cfg.files.filter((f) => f.exists).flatMap((f) => ["--env-file", f.path]);
|
|
51
|
+
const inlineArgs = cfg.inlineVars.flatMap((v) => ["-e", v]);
|
|
52
|
+
return [...fileArgs, ...inlineArgs];
|
|
53
|
+
}
|
|
54
|
+
/** One warning line per missing env file. A missing file is skipped, never fatal. */
|
|
55
|
+
export function envWarnings(cfg) {
|
|
56
|
+
return cfg.files.filter((f) => !f.exists).map((f) => `env "${f.path}" not found - skipping`);
|
|
57
|
+
}
|
|
58
|
+
// --- Fingerprint -------------------------------------------------------------------------------------------------------------------------
|
|
59
|
+
/**
|
|
60
|
+
* Deterministic fingerprint over the resolved env config, used as the container LABEL_ENV. Covers each inline
|
|
61
|
+
* KEY=VALUE and, for each existing file, its path plus contents - so editing an inline var, editing a file's
|
|
62
|
+
* contents, or repointing at a different file recreates the container on the next session. An empty config
|
|
63
|
+
* (no inline vars and no existing files) fingerprints to "" so an env-less workspace never recreates on
|
|
64
|
+
* account of this label. Hashed so no secret values leak into docker labels (which are visible via inspect).
|
|
65
|
+
*/
|
|
66
|
+
export function envLabel(cfg) {
|
|
67
|
+
if (cfg.inlineVars.length === 0 && cfg.files.every((f) => !f.exists))
|
|
68
|
+
return "";
|
|
69
|
+
const parts = [];
|
|
70
|
+
for (const v of cfg.inlineVars)
|
|
71
|
+
parts.push(`e:${v}`);
|
|
72
|
+
for (const f of cfg.files) {
|
|
73
|
+
if (!f.exists)
|
|
74
|
+
continue;
|
|
75
|
+
parts.push(`f:${f.path}\n${readFileSync(f.path, "utf8")}`);
|
|
76
|
+
}
|
|
77
|
+
return createHash("sha256").update(parts.join("\0")).digest("hex").slice(0, 12);
|
|
78
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// =========================================================================================================================================
|
|
2
|
+
// src/lib/global-config.ts - Host-global settings store (not tied to any workspace)
|
|
3
|
+
// Lives at ~/.chamba/global/config as key=value lines, mirroring the per-workspace .lock idiom.
|
|
4
|
+
// Settings live here when they are host-wide rather than per-workspace.
|
|
5
|
+
// =========================================================================================================================================
|
|
6
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { CHAMBA_DIR, GLOBAL_CONFIG_FILE, GLOBAL_DIR, WEB_RANGE_DEFAULT } from "./constants.js";
|
|
10
|
+
import { formatWebRange, parseWebRange } from "./ports.js";
|
|
11
|
+
// --- Keys --------------------------------------------------------------------------------------------------------------------------------
|
|
12
|
+
/** Field names mapped to the keys written in the global config file. */
|
|
13
|
+
export const GLOBAL_CONFIG_KEYS = {
|
|
14
|
+
webRange: "web_range",
|
|
15
|
+
};
|
|
16
|
+
// --- Path --------------------------------------------------------------------------------------------------------------------------------
|
|
17
|
+
/** Absolute path to the global config file - ~/.chamba/global/config */
|
|
18
|
+
export function globalConfigPath() {
|
|
19
|
+
return join(homedir(), CHAMBA_DIR, GLOBAL_DIR, GLOBAL_CONFIG_FILE);
|
|
20
|
+
}
|
|
21
|
+
// --- Parse / write -----------------------------------------------------------------------------------------------------------------------
|
|
22
|
+
// Parse the config into an ordered map of raw key=value pairs. Returns an empty map when the file is
|
|
23
|
+
// missing or unreadable - absence is not an error, it just means defaults apply. Unknown keys are kept
|
|
24
|
+
// so a newer chamba's settings survive a write by an older one.
|
|
25
|
+
function parseGlobalConfig() {
|
|
26
|
+
const config = new Map();
|
|
27
|
+
try {
|
|
28
|
+
const lines = readFileSync(globalConfigPath(), "utf8")
|
|
29
|
+
.trimEnd()
|
|
30
|
+
.split("\n")
|
|
31
|
+
.map((l) => l.trim())
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
for (const line of lines) {
|
|
34
|
+
const eq = line.indexOf("=");
|
|
35
|
+
if (eq === -1)
|
|
36
|
+
continue;
|
|
37
|
+
config.set(line.slice(0, eq), line.slice(eq + 1));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Missing or unreadable - treat as empty.
|
|
42
|
+
}
|
|
43
|
+
return config;
|
|
44
|
+
}
|
|
45
|
+
// Write the raw key=value map back to ~/.chamba/global/config, creating ~/.chamba/global/ on demand.
|
|
46
|
+
// Unlike the per-workspace lock (which no-ops when missing), the global config has no init step, so it
|
|
47
|
+
// is created lazily on the first write.
|
|
48
|
+
function writeGlobalConfig(config) {
|
|
49
|
+
mkdirSync(join(homedir(), CHAMBA_DIR, GLOBAL_DIR), { recursive: true });
|
|
50
|
+
const content = `${[...config].map(([key, value]) => `${key}=${value}`).join("\n")}\n`;
|
|
51
|
+
writeFileSync(globalConfigPath(), content);
|
|
52
|
+
}
|
|
53
|
+
// --- Web agent interface -----------------------------------------------------------------------------------------------------------------
|
|
54
|
+
/** Read the web interface host-port range. Falls back to the default when unset, missing, or invalid. */
|
|
55
|
+
export function readWebRange() {
|
|
56
|
+
const value = parseGlobalConfig().get(GLOBAL_CONFIG_KEYS.webRange);
|
|
57
|
+
const parsed = value !== undefined ? parseWebRange(value) : null;
|
|
58
|
+
// The default is a constant that always parses; the assertion just narrows the type.
|
|
59
|
+
return parsed ?? parseWebRange(WEB_RANGE_DEFAULT);
|
|
60
|
+
}
|
|
61
|
+
/** Write the web interface host-port range. Creates the config file on demand and preserves all other keys. */
|
|
62
|
+
export function writeWebRange(range) {
|
|
63
|
+
const config = parseGlobalConfig();
|
|
64
|
+
config.set(GLOBAL_CONFIG_KEYS.webRange, formatWebRange(range));
|
|
65
|
+
writeGlobalConfig(config);
|
|
66
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// =========================================================================================================================================
|
|
2
|
+
// src/lib/pnpm-store.ts - Per-workspace pnpm global store mount
|
|
3
|
+
// pnpm hardlinks from its global store into node_modules. Hardlinks cannot cross filesystems, so when the global store sits on the
|
|
4
|
+
// container overlay FS while /workspace is a host bind mount, pnpm falls back to creating a per-project .pnpm-store inside the project -
|
|
5
|
+
// which then ends up on the host repo. Mounting a host-side cache dir onto pnpm's default global store path puts the store on the same
|
|
6
|
+
// device as the workspace and node_modules shadow, eliminating the fallback.
|
|
7
|
+
// =========================================================================================================================================
|
|
8
|
+
import { mkdirSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { CONTAINER_PNPM_STORE, PNPM_STORE_DIR } from "./constants.js";
|
|
11
|
+
/**
|
|
12
|
+
* Lazily creates the host-side pnpm store directory under the workspace cache dir
|
|
13
|
+
* and returns -v args mounting it onto pnpm's default global store path in the container.
|
|
14
|
+
*/
|
|
15
|
+
export function buildPnpmStoreMountArgs(workspaceCacheDir) {
|
|
16
|
+
const hostStore = join(workspaceCacheDir, PNPM_STORE_DIR);
|
|
17
|
+
mkdirSync(hostStore, { recursive: true });
|
|
18
|
+
return ["-v", `${hostStore}:${CONTAINER_PNPM_STORE}`];
|
|
19
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// =========================================================================================================================================
|
|
2
|
+
// src/lib/ports.ts - Publish static, loopback-only host ports into the container
|
|
3
|
+
// A `ports` entry is either a bare integer (identity map, 127.0.0.1:N:N) or a "HOST:CONTAINER" string. Config is the
|
|
4
|
+
// source of truth: a host port already in use is a clear, hard failure that names the offending entry.
|
|
5
|
+
// =========================================================================================================================================
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { createServer } from "node:net";
|
|
9
|
+
import { WEB_CONTAINER_PORT } from "./constants.js";
|
|
10
|
+
// --- Constants ---------------------------------------------------------------------------------------------------------------------------
|
|
11
|
+
// The CLI runs as a non-root user, so the availability probe (net bind) cannot bind ports below 1024.
|
|
12
|
+
export const PORT_MIN = 1024;
|
|
13
|
+
export const PORT_MAX = 65535;
|
|
14
|
+
// Publish and probe on loopback only - never 0.0.0.0. Nothing on the LAN may reach these sessions.
|
|
15
|
+
export const PORT_LOOPBACK_HOST = "127.0.0.1";
|
|
16
|
+
// --- Config validation and normalization -------------------------------------------------------------------------------------------------
|
|
17
|
+
/** A single port number is a usable, unprivileged host/container port. */
|
|
18
|
+
export function inRange(port) {
|
|
19
|
+
return Number.isInteger(port) && port >= PORT_MIN && port <= PORT_MAX;
|
|
20
|
+
}
|
|
21
|
+
/** Human-readable label for an entry, used in error and notice messages. */
|
|
22
|
+
function describe(m) {
|
|
23
|
+
return m.host === m.container ? `port ${m.host}` : `mapping "${m.host}:${m.container}"`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Validate and normalize the raw `ports` entries into host->container mappings. This is the single source of
|
|
27
|
+
* truth for ports semantics (unit-testable without the JSON Schema). Throws on the first problem with a clear,
|
|
28
|
+
* actionable message. The JSON Schema enforces coarse structure (types, the env pattern, no unknown keys); the
|
|
29
|
+
* range and cross-entry rules live here.
|
|
30
|
+
*/
|
|
31
|
+
export function validatePortsConfig(entries) {
|
|
32
|
+
const mappings = [];
|
|
33
|
+
const seenHosts = new Set();
|
|
34
|
+
const seenEnvs = new Set();
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
let host;
|
|
37
|
+
let container;
|
|
38
|
+
if (typeof entry.port === "number") {
|
|
39
|
+
// Bare integer -> identity map (host === container).
|
|
40
|
+
if (!inRange(entry.port)) {
|
|
41
|
+
throw new Error(`ports: port ${entry.port} must be between ${PORT_MIN} and ${PORT_MAX}. ` +
|
|
42
|
+
'Did you mean a "HOST:CONTAINER" mapping? Quote it, e.g. "8080:3000".');
|
|
43
|
+
}
|
|
44
|
+
host = entry.port;
|
|
45
|
+
container = entry.port;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
// "HOST:CONTAINER" string -> explicit map, same order as docker (host first).
|
|
49
|
+
const match = /^(\d+):(\d+)$/.exec(entry.port);
|
|
50
|
+
if (!match) {
|
|
51
|
+
throw new Error(`ports: invalid port "${entry.port}". Use a bare integer (e.g. 4820) or a "HOST:CONTAINER" mapping (e.g. "8080:3000").`);
|
|
52
|
+
}
|
|
53
|
+
host = Number(match[1]);
|
|
54
|
+
container = Number(match[2]);
|
|
55
|
+
if (!inRange(host) || !inRange(container)) {
|
|
56
|
+
throw new Error(`ports: mapping "${entry.port}" is out of range - host and container ports must both be between ${PORT_MIN} and ${PORT_MAX}.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// The web agent interface always binds this port inside the container, so a second publisher would
|
|
60
|
+
// make whichever bound first win and the web URL could front the user's service. Reserved on every
|
|
61
|
+
// host: the interface comes up with every container, and nothing in chamba.yaml may take its port.
|
|
62
|
+
if (container === WEB_CONTAINER_PORT) {
|
|
63
|
+
throw new Error(`ports: container port ${WEB_CONTAINER_PORT} is reserved for the chamba web agent interface. ` +
|
|
64
|
+
"Publish your service on a different container port.");
|
|
65
|
+
}
|
|
66
|
+
if (seenHosts.has(host)) {
|
|
67
|
+
throw new Error(`ports: duplicate host port ${host}. Each entry must publish a distinct host port.`);
|
|
68
|
+
}
|
|
69
|
+
seenHosts.add(host);
|
|
70
|
+
const mapping = { host, container };
|
|
71
|
+
if (entry.env !== undefined) {
|
|
72
|
+
if (host !== container) {
|
|
73
|
+
throw new Error(`ports: ${describe(mapping)} declares env "${entry.env}", but env is only allowed on identity entries (a bare port number).`);
|
|
74
|
+
}
|
|
75
|
+
if (seenEnvs.has(entry.env)) {
|
|
76
|
+
throw new Error(`ports: duplicate env "${entry.env}". Each entry must inject a distinct env var.`);
|
|
77
|
+
}
|
|
78
|
+
seenEnvs.add(entry.env);
|
|
79
|
+
mapping.env = entry.env;
|
|
80
|
+
}
|
|
81
|
+
mappings.push(mapping);
|
|
82
|
+
}
|
|
83
|
+
return mappings;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Parse a "START-END" range string. Returns null when the shape is wrong, either bound is out of the
|
|
87
|
+
* usable port range, or start is not strictly below end. The settings menu turns each null into a
|
|
88
|
+
* specific validation message; readWebRange falls back to the default.
|
|
89
|
+
*/
|
|
90
|
+
export function parseWebRange(value) {
|
|
91
|
+
const match = /^(\d+)-(\d+)$/.exec(value.trim());
|
|
92
|
+
if (!match)
|
|
93
|
+
return null;
|
|
94
|
+
const start = Number(match[1]);
|
|
95
|
+
const end = Number(match[2]);
|
|
96
|
+
if (!inRange(start) || !inRange(end))
|
|
97
|
+
return null;
|
|
98
|
+
if (start >= end)
|
|
99
|
+
return null;
|
|
100
|
+
return { start, end };
|
|
101
|
+
}
|
|
102
|
+
/** Format a range back to its stored "START-END" form. */
|
|
103
|
+
export function formatWebRange(range) {
|
|
104
|
+
return `${range.start}-${range.end}`;
|
|
105
|
+
}
|
|
106
|
+
// --- Availability probing (host I/O) -----------------------------------------------------------------------------------------------------
|
|
107
|
+
/** True if nothing on the host holds a loopback socket for this port (docker-proxy / userland-proxy=true, or a plain squatter). */
|
|
108
|
+
export function canBind(port) {
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
const server = createServer();
|
|
111
|
+
server.once("error", () => resolve(false));
|
|
112
|
+
server.once("listening", () => server.close(() => resolve(true)));
|
|
113
|
+
server.listen(port, PORT_LOOPBACK_HOST);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/** Host ports in a `docker ps` {{.Ports}} column: "127.0.0.1:4820->4820/tcp", "[::]:5432->5432/tcp". */
|
|
117
|
+
export function parsePublishedPorts(psOutput) {
|
|
118
|
+
const ports = new Set();
|
|
119
|
+
// The host port precedes "->".
|
|
120
|
+
for (const match of psOutput.matchAll(/:(\d+)->/g)) {
|
|
121
|
+
const n = Number(match[1]);
|
|
122
|
+
if (Number.isInteger(n))
|
|
123
|
+
ports.add(n);
|
|
124
|
+
}
|
|
125
|
+
return ports;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Host ports currently published by any running container. On native Linux with userland-proxy=false no host
|
|
129
|
+
* socket exists (DNAT), so canBind alone would wrongly report a published port as free - this covers that case.
|
|
130
|
+
* Returns an empty set when docker is unavailable (the bind probe still applies).
|
|
131
|
+
*/
|
|
132
|
+
export function dockerPublishedPorts() {
|
|
133
|
+
const result = spawnSync("docker", ["ps", "--format", "{{.Ports}}"], { encoding: "utf8", stdio: "pipe" });
|
|
134
|
+
if (result.status !== 0 || !result.stdout)
|
|
135
|
+
return new Set();
|
|
136
|
+
return parsePublishedPorts(result.stdout);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Host ports published by one named container, and only while it is running - a stopped container holds no
|
|
140
|
+
* port even though its config still declares one. Lets a caller tell "this port is taken by my own live
|
|
141
|
+
* container" (fine) from "taken by something else" (not fine). Empty when docker is unavailable.
|
|
142
|
+
*/
|
|
143
|
+
export function containerPublishedPorts(containerName) {
|
|
144
|
+
const result = spawnSync("docker", ["ps", "--filter", `name=^${containerName}$`, "--format", "{{.Ports}}"], {
|
|
145
|
+
encoding: "utf8",
|
|
146
|
+
stdio: "pipe",
|
|
147
|
+
});
|
|
148
|
+
if (result.status !== 0 || !result.stdout)
|
|
149
|
+
return new Set();
|
|
150
|
+
return parsePublishedPorts(result.stdout);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Check that every mapped host port is free before we create the container. Called only on the create path - the
|
|
154
|
+
* old container has already been removed, so we never probe our own live port. Throws a clear error naming the taken
|
|
155
|
+
* host port and its entry on the first collision. Doing this here, rather than letting `docker run` fail, buys two
|
|
156
|
+
* things: the error names the offending entry (docker's raw message does not), and no doomed `created` container is
|
|
157
|
+
* left behind on a clash.
|
|
158
|
+
*
|
|
159
|
+
* `webPort` names the chamba-assigned web interface mapping, which is skipped: chamba picked that port itself, so the
|
|
160
|
+
* caller drops the mapping and runs the session without the interface rather than failing a session over it. Only chamba.yaml
|
|
161
|
+
* ports - config the user wrote and expects to be honoured - are hard failures here.
|
|
162
|
+
*/
|
|
163
|
+
export async function assertHostPortsAvailable(mappings, webPort) {
|
|
164
|
+
if (mappings.length === 0)
|
|
165
|
+
return;
|
|
166
|
+
const dockerPorts = dockerPublishedPorts();
|
|
167
|
+
for (const m of mappings) {
|
|
168
|
+
// Matched on both sides, so only chamba's own mapping is skipped - never a user entry that happens
|
|
169
|
+
// to sit on the same host port.
|
|
170
|
+
if (webPort !== undefined && m.host === webPort && m.container === WEB_CONTAINER_PORT)
|
|
171
|
+
continue;
|
|
172
|
+
const free = !dockerPorts.has(m.host) && (await canBind(m.host));
|
|
173
|
+
if (!free) {
|
|
174
|
+
const via = m.host === m.container ? "" : ` (from the "${m.host}:${m.container}" mapping)`;
|
|
175
|
+
throw new Error(`ports: host port ${m.host}${via} is already in use on the host. ` +
|
|
176
|
+
"Free it, or pick a different host port in chamba.yaml.");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// --- Docker argument builders (pure) -----------------------------------------------------------------------------------------------------
|
|
181
|
+
/** Loopback-only publish flags: 127.0.0.1:HOST:CONTAINER per mapping. */
|
|
182
|
+
export function portPublishArgs(mappings) {
|
|
183
|
+
return mappings.flatMap((m) => ["-p", `${PORT_LOOPBACK_HOST}:${m.host}:${m.container}`]);
|
|
184
|
+
}
|
|
185
|
+
/** Env injection flags carrying the host port number, only for identity entries that declare an env var. */
|
|
186
|
+
export function portEnvArgs(mappings) {
|
|
187
|
+
return mappings.flatMap((m) => (m.env ? ["-e", `${m.env}=${m.host}`] : []));
|
|
188
|
+
}
|
|
189
|
+
// --- Fingerprint -------------------------------------------------------------------------------------------------------------------------
|
|
190
|
+
/**
|
|
191
|
+
* Deterministic fingerprint over the normalized mappings (host, container, env), used as the container LABEL_PORTS.
|
|
192
|
+
* Editing a port, mapping, or env recreates the container; a workspace with no ports fingerprints to "" so a
|
|
193
|
+
* container without published ports never recreates on account of this label.
|
|
194
|
+
*/
|
|
195
|
+
export function portsLabel(mappings) {
|
|
196
|
+
if (mappings.length === 0)
|
|
197
|
+
return "";
|
|
198
|
+
const parts = mappings.map((m) => `${m.host}:${m.container}:${m.env ?? ""}`).sort();
|
|
199
|
+
return createHash("sha256").update(parts.join(",")).digest("hex").slice(0, 12);
|
|
200
|
+
}
|
|
201
|
+
// --- Session notice ----------------------------------------------------------------------------------------------------------------------
|
|
202
|
+
/**
|
|
203
|
+
* One-line startup notice per mapping. Identity: `port 4820 open` (plus ` (ENV)` when an env is injected).
|
|
204
|
+
* Mapping: `port 8080 -> 3000 open` (ASCII arrow - a UI string literal, no Unicode).
|
|
205
|
+
*/
|
|
206
|
+
export function formatPortNotice(m) {
|
|
207
|
+
if (m.host === m.container)
|
|
208
|
+
return `port ${m.host} open${m.env ? ` (${m.env})` : ""}`;
|
|
209
|
+
return `port ${m.host} -> ${m.container} open`;
|
|
210
|
+
}
|