chamba 0.7.0 → 0.8.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 (120) hide show
  1. package/LICENSE +53 -14
  2. package/README.md +28 -8
  3. package/dist/commands/advanced.js +7 -278
  4. package/dist/commands/dev.js +11 -612
  5. package/dist/commands/doctor.js +1 -29
  6. package/dist/commands/menu.js +1 -80
  7. package/dist/commands/onboard.js +6 -230
  8. package/dist/commands/settings.js +15 -349
  9. package/dist/lib/agent-commands.js +2 -0
  10. package/dist/lib/agent-context.js +6 -210
  11. package/dist/lib/browser.js +1 -40
  12. package/dist/lib/chamba-yaml.js +10 -191
  13. package/dist/lib/constants.js +1 -125
  14. package/dist/lib/dockerfile-builder.js +41 -261
  15. package/dist/lib/env.js +2 -78
  16. package/dist/lib/git-env.js +1 -21
  17. package/dist/lib/global-config.js +4 -66
  18. package/dist/lib/pnpm-store.js +1 -19
  19. package/dist/lib/ports.js +1 -210
  20. package/dist/lib/safe-rm.js +1 -36
  21. package/dist/lib/sessions.js +2 -34
  22. package/dist/lib/shadows.js +1 -176
  23. package/dist/lib/skills.js +2 -0
  24. package/dist/lib/templates.js +1 -0
  25. package/dist/lib/webterm.js +1 -305
  26. package/dist/lib/workspace-identity.js +4 -260
  27. package/package.json +4 -4
  28. package/templates/Dockerfile +12 -19
  29. package/templates/claude-statusline.sh +5 -7
  30. package/templates/context/baseline.md +2 -0
  31. package/templates/pane-apps/client/assets/c-BNa-DeUl.json +3552 -0
  32. package/templates/pane-apps/client/assets/cpp-B-T4NRoH.json +20239 -0
  33. package/templates/pane-apps/client/assets/csharp-BbxHChhA.json +6010 -0
  34. package/templates/pane-apps/client/assets/css-CWCxuwmz.json +1860 -0
  35. package/templates/pane-apps/client/assets/diff-B-2N89g8.json +154 -0
  36. package/templates/pane-apps/client/assets/docker-DWTvYW26.json +103 -0
  37. package/templates/pane-apps/client/assets/files-D0nJgFAA.js +12 -0
  38. package/templates/pane-apps/client/assets/files-h0K1vZyQ.css +1 -0
  39. package/templates/pane-apps/client/assets/go-B2hyYeNg.json +3156 -0
  40. package/templates/pane-apps/client/assets/graphql-Z6Id2aNO.json +1292 -0
  41. package/templates/pane-apps/client/assets/html-BtzvlIfu.json +2627 -0
  42. package/templates/pane-apps/client/assets/ini-Bn3Twap1.json +108 -0
  43. package/templates/pane-apps/client/assets/java-C7t2dwLO.json +1885 -0
  44. package/templates/pane-apps/client/assets/javascript-DuC2v0C0.json +5996 -0
  45. package/templates/pane-apps/client/assets/json-CwdkWeV5.json +206 -0
  46. package/templates/pane-apps/client/assets/jsonc-MYWDbTU3.json +206 -0
  47. package/templates/pane-apps/client/assets/jsx-Cn8EZuIC.json +5996 -0
  48. package/templates/pane-apps/client/assets/kotlin-CFNOz1gw.json +580 -0
  49. package/templates/pane-apps/client/assets/make-BVnlFCcl.json +629 -0
  50. package/templates/pane-apps/client/assets/markdown-B4Xk2WM_.json +3263 -0
  51. package/templates/pane-apps/client/assets/php-CAQzQmmI.json +4188 -0
  52. package/templates/pane-apps/client/assets/python-CYQee8m4.json +4174 -0
  53. package/templates/pane-apps/client/assets/reviews-BsoGZ35r.css +1 -0
  54. package/templates/pane-apps/client/assets/reviews-iW0Mbn4e.js +153 -0
  55. package/templates/pane-apps/client/assets/ruby-D7R58tus.json +3114 -0
  56. package/templates/pane-apps/client/assets/rust-BFBbFiei.json +1103 -0
  57. package/templates/pane-apps/client/assets/scss-TQRhBkPG.json +1874 -0
  58. package/templates/pane-apps/client/assets/shellscript-DieAkC4W.json +2375 -0
  59. package/templates/pane-apps/client/assets/specs-D9z3neR-.js +13 -0
  60. package/templates/pane-apps/client/assets/sql-Bxd030LV.json +634 -0
  61. package/templates/pane-apps/client/assets/toml-ChrKGzsV.json +430 -0
  62. package/templates/pane-apps/client/assets/tsx-D2e90MFw.json +5996 -0
  63. package/templates/pane-apps/client/assets/typescript-DonXQ_n1.json +5747 -0
  64. package/templates/pane-apps/client/assets/xml-B17r2RXF.json +382 -0
  65. package/templates/pane-apps/client/assets/yaml-CKn4h1zy.json +627 -0
  66. package/templates/pane-apps/client/reviews/index.html +15 -0
  67. package/templates/pane-apps/client/specs/index.html +3 -2
  68. package/templates/pane-apps/server/reviews.mjs +20 -0
  69. package/templates/pane-apps/server/specs.mjs +5 -1588
  70. package/templates/skills/chamba-statusline/SKILL.md +7 -19
  71. package/templates/skills/dx-review/SKILL.md +123 -0
  72. package/templates/skills/dx-review/references/acts.md +162 -0
  73. package/templates/skills/dx-spec/SKILL.md +223 -27
  74. package/templates/skills/dx-spec/references/principles-template.md +2 -0
  75. package/templates/skills/dx-spec/references/review-guide.md +4 -50
  76. package/templates/skills/dx-spec/references/spec-guide.md +24 -4
  77. package/templates/skills/dx-spec/references/stages.md +143 -58
  78. package/templates/skills/dx-spec-execute/SKILL.md +148 -35
  79. package/templates/startup.mjs +10 -4
  80. package/templates/tool-helper.sh +166 -0
  81. package/templates/webterm/README.md +60 -26
  82. package/templates/webterm/color.js +61 -0
  83. package/templates/webterm/config.js +35 -4
  84. package/templates/webterm/context/claude.md +0 -1
  85. package/templates/webterm/public/app/alerts.js +4 -4
  86. package/templates/webterm/public/app/anchored.js +81 -0
  87. package/templates/webterm/public/app/connection.js +9 -0
  88. package/templates/webterm/public/app/dom.js +2 -0
  89. package/templates/webterm/public/app/frames.js +51 -17
  90. package/templates/webterm/public/app/main.js +22 -8
  91. package/templates/webterm/public/app/new-session.js +13 -41
  92. package/templates/webterm/public/app/palette.js +58 -0
  93. package/templates/webterm/public/app/pane-shape.js +167 -0
  94. package/templates/webterm/public/app/pane-shell.js +199 -134
  95. package/templates/webterm/public/app/pane.js +33 -24
  96. package/templates/webterm/public/app/reviews-host.js +15 -0
  97. package/templates/webterm/public/app/specs-host.js +12 -218
  98. package/templates/webterm/public/app/state.js +5 -0
  99. package/templates/webterm/public/app/tabs.js +6 -2
  100. package/templates/webterm/public/app/theme.js +75 -27
  101. package/templates/webterm/public/app/tool-host.js +302 -0
  102. package/templates/webterm/public/app/workspace-color.js +192 -0
  103. package/templates/webterm/public/index.html +17 -3
  104. package/templates/webterm/public/styles.css +143 -21
  105. package/templates/webterm/server.js +198 -150
  106. package/templates/webterm/tools/commands.js +61 -0
  107. package/templates/webterm/tools/index.js +25 -0
  108. package/templates/webterm/tools/mount.js +337 -0
  109. package/templates/webterm/tools/paths.js +34 -0
  110. package/templates/webterm/tools/reviews.js +133 -0
  111. package/templates/webterm/tools/specs.js +154 -0
  112. package/templates/webterm/typed-line.js +70 -7
  113. package/templates/context/context-usage.md +0 -1
  114. package/templates/context-usage.sh +0 -266
  115. package/templates/pane-apps/client/assets/specs-B1970L17.css +0 -1
  116. package/templates/pane-apps/client/assets/specs-cEee_SPn.js +0 -23
  117. package/templates/skills/dx-spec-config/SKILL.md +0 -313
  118. package/templates/skills/dx-spec-config/references/principles-template.md +0 -12
  119. package/templates/specs.sh +0 -106
  120. package/templates/webterm/specs.js +0 -358
package/dist/lib/ports.js CHANGED
@@ -1,210 +1 @@
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 check (net bind) cannot bind ports below 1024.
12
- export const PORT_MIN = 1024;
13
- export const PORT_MAX = 65535;
14
- // Publish and check 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 check 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 check 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
- }
1
+ import{spawnSync as d}from"node:child_process";import{createHash as b}from"node:crypto";import{createServer as m}from"node:net";import{WEB_CONTAINER_PORT as u}from"./constants.js";const f=1024,h=65535,l="127.0.0.1";function a(t){return Number.isInteger(t)&&t>=f&&t<=h}function w(t){return t.host===t.container?`port ${t.host}`:`mapping "${t.host}:${t.container}"`}function y(t){const e=[],r=new Set,n=new Set;for(const o of t){let s,i;if(typeof o.port=="number"){if(!a(o.port))throw new Error(`ports: port ${o.port} must be between ${f} and ${h}. Did you mean a "HOST:CONTAINER" mapping? Quote it, e.g. "8080:3000".`);s=o.port,i=o.port}else{const p=/^(\d+):(\d+)$/.exec(o.port);if(!p)throw new Error(`ports: invalid port "${o.port}". Use a bare integer (e.g. 4820) or a "HOST:CONTAINER" mapping (e.g. "8080:3000").`);if(s=Number(p[1]),i=Number(p[2]),!a(s)||!a(i))throw new Error(`ports: mapping "${o.port}" is out of range - host and container ports must both be between ${f} and ${h}.`)}if(i===u)throw new Error(`ports: container port ${u} is reserved for the chamba web agent interface. Publish your service on a different container port.`);if(r.has(s))throw new Error(`ports: duplicate host port ${s}. Each entry must publish a distinct host port.`);r.add(s);const c={host:s,container:i};if(o.env!==void 0){if(s!==i)throw new Error(`ports: ${w(c)} declares env "${o.env}", but env is only allowed on identity entries (a bare port number).`);if(n.has(o.env))throw new Error(`ports: duplicate env "${o.env}". Each entry must inject a distinct env var.`);n.add(o.env),c.env=o.env}e.push(c)}return e}function O(t){const e=/^(\d+)-(\d+)$/.exec(t.trim());if(!e)return null;const r=Number(e[1]),n=Number(e[2]);return!a(r)||!a(n)||r>=n?null:{start:r,end:n}}function R(t){return`${t.start}-${t.end}`}function v(t){return new Promise(e=>{const r=m();r.once("error",()=>e(!1)),r.once("listening",()=>r.close(()=>e(!0))),r.listen(t,l)})}function $(t){const e=new Set;for(const r of t.matchAll(/:(\d+)->/g)){const n=Number(r[1]);Number.isInteger(n)&&e.add(n)}return e}function g(){const t=d("docker",["ps","--format","{{.Ports}}"],{encoding:"utf8",stdio:"pipe"});return t.status!==0||!t.stdout?new Set:$(t.stdout)}function S(t){const e=d("docker",["ps","--filter",`name=^${t}$`,"--format","{{.Ports}}"],{encoding:"utf8",stdio:"pipe"});return e.status!==0||!e.stdout?new Set:$(e.stdout)}async function T(t,e){if(t.length===0)return;const r=g();for(const n of t){if(e!==void 0&&n.host===e&&n.container===u)continue;if(!(!r.has(n.host)&&await v(n.host))){const s=n.host===n.container?"":` (from the "${n.host}:${n.container}" mapping)`;throw new Error(`ports: host port ${n.host}${s} is already in use on the host. Free it, or pick a different host port in chamba.yaml.`)}}}function A(t){return t.flatMap(e=>["-p",`${l}:${e.host}:${e.container}`])}function H(t){return t.flatMap(e=>e.env?["-e",`${e.env}=${e.host}`]:[])}function I(t){if(t.length===0)return"";const e=t.map(r=>`${r.host}:${r.container}:${r.env??""}`).sort();return b("sha256").update(e.join(",")).digest("hex").slice(0,12)}function _(t){return t.host===t.container?`port ${t.host} open${t.env?` (${t.env})`:""}`:`port ${t.host} -> ${t.container} open`}export{l as PORT_LOOPBACK_HOST,h as PORT_MAX,f as PORT_MIN,T as assertHostPortsAvailable,v as canBind,S as containerPublishedPorts,g as dockerPublishedPorts,_ as formatPortNotice,R as formatWebRange,a as inRange,$ as parsePublishedPorts,O as parseWebRange,H as portEnvArgs,A as portPublishArgs,I as portsLabel,y as validatePortsConfig};
@@ -1,36 +1 @@
1
- // =========================================================================================================================================
2
- // src/lib/safe-rm.ts - Safe wrapper around rmSync
3
- // All deletions in chamba go through safeRmSync. A biome lint rule (noRestrictedImports) bans
4
- // importing rmSync from node:fs everywhere else, enforcing this as the single deletion point.
5
- // =========================================================================================================================================
6
- // biome-ignore lint/style/noRestrictedImports: safeRmSync is the only approved user of rmSync
7
- import { rmSync } from "node:fs";
8
- import { homedir, tmpdir } from "node:os";
9
- import { basename, join, resolve, sep } from "node:path";
10
- import { CHAMBA_DIR, CHAMBA_YAML, CONTAINER_NAME_PREFIX, PACKAGE_ROOT } from "./constants.js";
11
- const CHAMBA_HOME = resolve(join(homedir(), CHAMBA_DIR));
12
- const TEST_TMP_PREFIX = join(tmpdir(), `${CONTAINER_NAME_PREFIX}test-`);
13
- // chamba's own build outputs, inside the package itself. The build stages the pane apps into the
14
- // templates tree, and it clears the previous staging before it copies: every asset in that dist is
15
- // named by its own content hash, so merging would keep every past build's files for ever, and the
16
- // image build hash covers each of them.
17
- const BUILD_OUTPUTS = [resolve(join(PACKAGE_ROOT, "dist")), resolve(join(PACKAGE_ROOT, "templates", "pane-apps"))];
18
- /**
19
- * Safe wrapper around rmSync. Throws if the path is outside a chamba-owned location:
20
- * - ~/.chamba/ (workspace caches, agents, shadows, global config)
21
- * - A file named chamba.yaml (workspace config file in any user workspace root)
22
- * - <tmpdir>/chamba-test-* (test temp directories)
23
- * - chamba's own build outputs inside the package (dist/, templates/pane-apps/)
24
- */
25
- export function safeRmSync(path, options) {
26
- const r = resolve(path);
27
- const ok = r === CHAMBA_HOME ||
28
- r.startsWith(CHAMBA_HOME + sep) ||
29
- basename(r) === CHAMBA_YAML ||
30
- r.startsWith(TEST_TMP_PREFIX) ||
31
- BUILD_OUTPUTS.some((output) => r === output || r.startsWith(output + sep));
32
- if (!ok) {
33
- throw new Error(`safeRmSync: refusing to delete '${r}' - must be under ~/.chamba/, named chamba.yaml, a test temp dir, or a build output of this package`);
34
- }
35
- rmSync(r, options);
36
- }
1
+ import{rmSync as p}from"node:fs";import{homedir as c,tmpdir as A}from"node:os";import{basename as f,join as o,resolve as e,sep as s}from"node:path";import{CHAMBA_DIR as _,CHAMBA_YAML as d,CONTAINER_NAME_PREFIX as h,PACKAGE_ROOT as m}from"./constants.js";const a=e(o(c(),_)),E=o(A(),`${h}test-`),M=[e(o(m,"dist")),e(o(m,"templates","pane-apps"))];function C(i,n){const t=e(i);if(!(t===a||t.startsWith(a+s)||f(t)===d||t.startsWith(E)||M.some(r=>t===r||t.startsWith(r+s))))throw new Error(`safeRmSync: refusing to delete '${t}' - must be under ~/.chamba/, named chamba.yaml, a test temp dir, or a build output of this package`);p(t,n)}export{C as safeRmSync};
@@ -1,34 +1,2 @@
1
- // =========================================================================================================================================
2
- // src/lib/sessions.ts - Host-side detection of live interactive container sessions.
3
- // dev.ts connects each session with `docker exec -it ... <container> bash --login`. The host-side exec
4
- // client process is the proxy for one live session: it dies on clean exit, closed terminal, or kill, but
5
- // survives a sleep-freeze. Orphaned in-container shells (host side gone, PTY left open by dockerd) have no
6
- // host client and are correctly ignored - which is why counting on the host, not inside the container, is
7
- // the reliable signal for "last session closed".
8
- // =========================================================================================================================================
9
- import { spawnSync } from "node:child_process";
10
- // The interactive session shell, as argv. Single source of truth: both the connect command and the
11
- // detector's match needle derive from this, so they cannot drift apart.
12
- export const CONTAINER_LOGIN_SHELL = ["bash", "--login"];
13
- // --- Connect -----------------------------------------------------------------------------------------------------------------------------
14
- // Build the `docker exec` argv that opens an interactive session in `containerName` at `workdir`.
15
- // dev.ts spawns exactly this; sessionMatchNeedle matches against it, so detection stays in lockstep.
16
- export function loginShellExecArgs(workdir, containerName) {
17
- return ["exec", "-it", "-w", workdir, containerName, ...CONTAINER_LOGIN_SHELL];
18
- }
19
- // --- Detection ---------------------------------------------------------------------------------------------------------------------------
20
- // The substring identifying one container's session client in a host `pgrep -f` scan: the container name
21
- // followed by the login shell args (e.g. "chamba-orot-io bash --login"). Specific enough to exclude other
22
- // `docker exec <c> ...` shapes. Container ids are kebab-case (schema-constrained), so there are no regex
23
- // metacharacters to escape.
24
- export function sessionMatchNeedle(containerName) {
25
- return `${containerName} ${CONTAINER_LOGIN_SHELL.join(" ")}`;
26
- }
27
- // Count live interactive sessions to a single container by scanning HOST processes for the exec client.
28
- // `pgrep -f` (portable across macOS/BSD and Linux; `-c` is Linux-only) excludes its own PID. A missing or
29
- // errored pgrep yields no stdout -> 0, preserving the old "errored -> 0" safety. At a clean exit the just-
30
- // left client is already reaped before this runs, so the last exit reaches 0.
31
- export function containerSessionCount(containerName) {
32
- const r = spawnSync("pgrep", ["-f", sessionMatchNeedle(containerName)], { encoding: "utf8", stdio: "pipe" });
33
- return (r.stdout ?? "").split("\n").filter(Boolean).length;
34
- }
1
+ import{spawnSync as o}from"node:child_process";const e=["bash","--login"];function s(n,t){return["exec","-it","-w",n,t,...e]}function r(n){return`${n} ${e.join(" ")}`}function c(n){return(o("pgrep",["-f",r(n)],{encoding:"utf8",stdio:"pipe"}).stdout??"").split(`
2
+ `).filter(Boolean).length}export{e as CONTAINER_LOGIN_SHELL,c as containerSessionCount,s as loginShellExecArgs,r as sessionMatchNeedle};
@@ -1,176 +1 @@
1
- // =========================================================================================================================================
2
- // src/lib/shadows.ts - Gitignore-style shadow path expansion and sync
3
- // Expands patterns like "node_modules", ".env*" into concrete paths, then syncs shadow directories.
4
- // =========================================================================================================================================
5
- import { spawnSync } from "node:child_process";
6
- import { existsSync, lstatSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
7
- import { dirname, join, relative } from "node:path";
8
- import fg from "fast-glob";
9
- import { CONTAINER_WORKSPACE, SHADOWS_DIR } from "./constants.js";
10
- import { gitEnv } from "./git-env.js";
11
- import { safeRmSync } from "./safe-rm.js";
12
- /**
13
- * Expand gitignore-style patterns into concrete relative paths.
14
- *
15
- * Patterns without a directory separator are treated as recursive (prepended with **&#47;)
16
- * following gitignore convention. Patterns with a / are matched relative to the workspace root.
17
- * Matched directories are not recursed into (e.g. node_modules matches once, not its children).
18
- *
19
- * Paths that git tracks (or whose descendants git tracks) are dropped from the result and
20
- * reported in skippedTracked. Shadowing tracked content is a no-op (agents can `git show` it)
21
- * and breaks `git stash`/`pop` workflows.
22
- */
23
- export function expandShadowPatterns(patterns, workspaceRoot) {
24
- if (patterns.length === 0)
25
- return { paths: [], skippedTracked: [] };
26
- // Convert gitignore-style patterns to fast-glob patterns
27
- const globPatterns = patterns.map((p) => (p.includes("/") ? p : `**/${p}`));
28
- // Build ignore list: skip .git and contents of any matched directory
29
- const ignorePatterns = ["**/.git", ...globPatterns.map((p) => `${p}/**/*`)];
30
- const results = fg.sync(globPatterns, {
31
- cwd: workspaceRoot,
32
- onlyFiles: false,
33
- dot: true,
34
- ignore: ignorePatterns,
35
- });
36
- const expanded = removeNestedPaths(results.sort());
37
- const { kept, dropped } = filterGitTrackedPaths(expanded, workspaceRoot);
38
- return { paths: kept, skippedTracked: dropped };
39
- }
40
- // --- Hit counting (for menu UX) ----------------------------------------------------------------------------------------------------------
41
- /** Count how many paths a pattern would match in the workspace (post git-tracked filter). */
42
- export function countPatternHits(pattern, workspaceRoot) {
43
- return expandShadowPatterns([pattern], workspaceRoot).paths.length;
44
- }
45
- // --- Git-tracked filtering ---------------------------------------------------------------------------------------------------------------
46
- /**
47
- * Partition expanded shadow paths into those safe to shadow and those tracked by git.
48
- * A path is dropped if it equals a tracked file, or (for directories) any tracked file
49
- * lives anywhere beneath it. Returns the input unchanged when no .git is present.
50
- */
51
- function filterGitTrackedPaths(paths, workspaceRoot) {
52
- if (paths.length === 0)
53
- return { kept: [], dropped: [] };
54
- // .git may be a file (worktrees) or a directory; existsSync handles both
55
- if (!existsSync(join(workspaceRoot, ".git")))
56
- return { kept: paths, dropped: [] };
57
- // Pass shadow paths as pathspecs so git enumerates only tracked files within them.
58
- // Output then scales with shadow scope (typically tiny), not the size of the repo index.
59
- const result = spawnSync("git", ["-C", workspaceRoot, "ls-files", "-z", "--", ...paths], {
60
- encoding: "utf8",
61
- stdio: ["ignore", "pipe", "pipe"],
62
- env: gitEnv(),
63
- });
64
- if (result.status !== 0)
65
- return { kept: paths, dropped: [] };
66
- const matched = result.stdout.split("\0").filter((s) => s.length > 0);
67
- if (matched.length === 0)
68
- return { kept: paths, dropped: [] };
69
- const matchedSet = new Set(matched);
70
- const kept = [];
71
- const dropped = [];
72
- for (const p of paths) {
73
- const prefix = `${p}/`;
74
- if (matchedSet.has(p) || matched.some((f) => f.startsWith(prefix))) {
75
- dropped.push(p);
76
- }
77
- else {
78
- kept.push(p);
79
- }
80
- }
81
- return { kept, dropped };
82
- }
83
- // --- Shadow sync -------------------------------------------------------------------------------------------------------------------------
84
- /**
85
- * Ensures the shadows/ directory matches the given expanded paths.
86
- * - Creates missing shadow entries (empty dir or empty file, matching source path type)
87
- * - If `freshPaths` is provided, those entries are deleted and recreated (clean slate)
88
- * - Removes shadow entries not in the expanded set
89
- * - Cleans up empty parent directories
90
- */
91
- export function ensureShadowsInSync(workspaceDir, expandedPaths, workspaceRoot, freshPaths) {
92
- const expected = new Set(expandedPaths);
93
- const shadowsDir = join(workspaceDir, SHADOWS_DIR);
94
- // Create shadows/ root if needed
95
- mkdirSync(shadowsDir, { recursive: true });
96
- // Remove stale entries
97
- removeStaleEntries(shadowsDir, shadowsDir, expected);
98
- // Create or refresh expected entries
99
- for (const relPath of expected) {
100
- const sourcePath = join(workspaceRoot, relPath);
101
- const shadowPath = join(shadowsDir, relPath);
102
- if (freshPaths?.has(relPath) && existsSync(shadowPath)) {
103
- safeRmSync(shadowPath, { recursive: true, force: true });
104
- }
105
- if (!existsSync(shadowPath)) {
106
- createShadowEntry(sourcePath, shadowPath);
107
- }
108
- }
109
- }
110
- // --- Mount args --------------------------------------------------------------------------------------------------------------------------
111
- /** Build -v args for shadow mounts from expanded paths. */
112
- export function buildShadowMountArgs(workspaceDir, expandedPaths) {
113
- const args = [];
114
- for (const relPath of expandedPaths) {
115
- args.push("-v", `${join(workspaceDir, SHADOWS_DIR, relPath)}:${CONTAINER_WORKSPACE}/${relPath}`);
116
- }
117
- return args;
118
- }
119
- // --- Helpers -----------------------------------------------------------------------------------------------------------------------------
120
- function createShadowEntry(sourcePath, shadowPath) {
121
- if (existsSync(sourcePath) && !lstatSync(sourcePath).isDirectory()) {
122
- mkdirSync(dirname(shadowPath), { recursive: true });
123
- writeFileSync(shadowPath, "");
124
- }
125
- else {
126
- mkdirSync(shadowPath, { recursive: true });
127
- }
128
- }
129
- /**
130
- * Walk the shadows/ tree and remove any entry whose relative path is not in the expected set.
131
- * After removing leaf entries, prunes empty parent directories.
132
- */
133
- function removeStaleEntries(baseDir, currentDir, expected) {
134
- if (!existsSync(currentDir))
135
- return;
136
- for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
137
- const fullPath = join(currentDir, entry.name);
138
- const rel = relative(baseDir, fullPath);
139
- if (expected.has(rel)) {
140
- // This path is expected - keep it
141
- continue;
142
- }
143
- if (entry.isDirectory()) {
144
- // Check if any expected path is nested under this directory
145
- const hasExpectedChild = [...expected].some((p) => isDescendantOf(p, rel));
146
- if (hasExpectedChild) {
147
- // Recurse into the directory to clean stale children
148
- removeStaleEntries(baseDir, fullPath, expected);
149
- }
150
- else {
151
- // No expected paths under here - remove entirely
152
- safeRmSync(fullPath, { recursive: true, force: true });
153
- }
154
- }
155
- else {
156
- // Stale file - remove
157
- safeRmSync(fullPath, { force: true });
158
- }
159
- }
160
- // Prune if this directory is now empty (and is not the shadows root)
161
- if (currentDir !== baseDir && readdirSync(currentDir).length === 0) {
162
- safeRmSync(currentDir, { recursive: true, force: true });
163
- }
164
- }
165
- /**
166
- * Drop paths that descend from another path in the input (keeps only the outermost path per subtree),
167
- * so nested matches (e.g. node_modules inside a shadowed .next dir) don't become redundant bind mounts.
168
- */
169
- function removeNestedPaths(paths) {
170
- const unique = [...new Set(paths)];
171
- return unique.filter((p) => !unique.some((a) => a !== p && isDescendantOf(p, a)));
172
- }
173
- // Expects forward-slash paths (fast-glob output and shadows/ relative paths on POSIX).
174
- function isDescendantOf(child, ancestor) {
175
- return child.startsWith(`${ancestor}/`);
176
- }
1
+ import{spawnSync as y}from"node:child_process";import{existsSync as d,lstatSync as v,mkdirSync as p,readdirSync as a,writeFileSync as k}from"node:fs";import{dirname as w,join as u,relative as x}from"node:path";import P from"fast-glob";import{CONTAINER_WORKSPACE as E,SHADOWS_DIR as m}from"./constants.js";import{gitEnv as $}from"./git-env.js";import{safeRmSync as l}from"./safe-rm.js";function C(t,e){if(t.length===0)return{paths:[],skippedTracked:[]};const n=t.map(f=>f.includes("/")?f:`**/${f}`),r=["**/.git",...n.map(f=>`${f}/**/*`)],o=P.sync(n,{cwd:e,onlyFiles:!1,dot:!0,ignore:r}),s=O(o.sort()),{kept:c,dropped:i}=T(s,e);return{paths:c,skippedTracked:i}}function j(t,e){return C([t],e).paths.length}function T(t,e){if(t.length===0)return{kept:[],dropped:[]};if(!d(u(e,".git")))return{kept:t,dropped:[]};const n=y("git",["-C",e,"ls-files","-z","--",...t],{encoding:"utf8",stdio:["ignore","pipe","pipe"],env:$()});if(n.status!==0)return{kept:t,dropped:[]};const r=n.stdout.split("\0").filter(i=>i.length>0);if(r.length===0)return{kept:t,dropped:[]};const o=new Set(r),s=[],c=[];for(const i of t){const f=`${i}/`;o.has(i)||r.some(g=>g.startsWith(f))?c.push(i):s.push(i)}return{kept:s,dropped:c}}function q(t,e,n){const r=new Set(e),o=u(t,m);p(o,{recursive:!0}),h(o,o,r);for(const s of r){const c=u(o,s);d(c)||A(u(n,s),c)}}function z(t,e){const n=[];for(const r of e)n.push("-v",`${u(t,m,r)}:${E}/${r}`);return n}function A(t,e){d(t)&&!v(t).isDirectory()?(p(w(e),{recursive:!0}),k(e,"")):p(e,{recursive:!0})}function h(t,e,n){if(d(e)){for(const r of a(e,{withFileTypes:!0})){const o=u(e,r.name),s=x(t,o);n.has(s)||(r.isDirectory()?[...n].some(i=>S(i,s))?h(t,o,n):l(o,{recursive:!0,force:!0}):l(o,{force:!0}))}e!==t&&a(e).length===0&&l(e,{recursive:!0,force:!0})}}function O(t){const e=[...new Set(t)];return e.filter(n=>!e.some(r=>r!==n&&S(n,r)))}function S(t,e){return t.startsWith(`${e}/`)}export{z as buildShadowMountArgs,j as countPatternHits,q as ensureShadowsInSync,C as expandShadowPatterns};
@@ -0,0 +1,2 @@
1
+ import{copyFileSync as b,existsSync as f,mkdirSync as d,readdirSync as h,readFileSync as m,renameSync as w,statSync as k,writeFileSync as M}from"node:fs";import{dirname as y,join as s}from"node:path";import{load as T}from"js-yaml";import{AGENTS_DIR as p,CONTAINER_HOME as g,PACKAGE_ROOT as O,WEB_AGENTS as $}from"./constants.js";import{safeRmSync as I}from"./safe-rm.js";import{renderTemplate as L,writeFileEnsuringDir as N}from"./templates.js";const A=[{audience:["claude"],hostSubpath:s("claude","skills"),container:`${g}/.claude/skills`,insideAgentMount:!0},{audience:["codex","opencode"],hostSubpath:s("shared","skills"),container:`${g}/.agents/skills`,insideAgentMount:!1}],F="skills-manifest.json",_=/^---\r?\n([\s\S]*?)\r?\n---/;function S(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function j(e){const r=_.exec(m(e,"utf8"));if(!r)return null;const t=T(r[1]??""),o=S(t)?t.metadata:void 0,n=S(o)?o.audience:void 0;if(n==null)return null;const c=typeof n=="string"?n.split(","):n;if(!Array.isArray(c)||c.some(a=>typeof a!="string"))throw new Error(`${e}: metadata.audience is neither a name nor a list of names`);const i=c.map(a=>String(a).trim()).filter(a=>a.length>0),l=i.filter(a=>!$.includes(a));if(l.length>0)throw new Error(`${e}: metadata.audience names ${l.join(", ")}, which chamba does not run`);return i.length>0?i:null}function x(e,r){return e===null||r.audience.some(t=>e.includes(t))}function B(e){return A.flatMap(r=>{const t=s(e,p,r.hostSubpath);return d(t,{recursive:!0}),["-v",`${t}:${r.container}:ro`]})}function Y(e,r){const t=s(O,"templates","skills");if(!f(t))return;const o=h(t,{withFileTypes:!0}).filter(i=>i.isDirectory()&&f(s(t,i.name,"SKILL.md"))).map(i=>({name:i.name,audience:j(s(t,i.name,"SKILL.md"))})),n=s(e,p,F),c=K(n);for(const i of A){const l=s(e,p,i.hostSubpath),a=o.filter(u=>x(u.audience,i)).map(u=>u.name);R(l,c[i.hostSubpath]??[],a);for(const u of a)E(s(t,u),s(l,u),r);c[i.hostSubpath]=[...a].sort()}G(n,c)}function E(e,r,t){for(const o of h(e,{withFileTypes:!0})){const n=s(e,o.name),c=s(r,o.name),i=k(n);i.isDirectory()?E(n,c,t):i.isFile()&&(o.name.endsWith(".md")?N(c,L(m(n,"utf8"),t)):(d(y(c),{recursive:!0}),b(n,c)))}}function R(e,r,t){for(const o of r){if(t.includes(o))continue;const n=s(e,o);f(n)&&I(n,{recursive:!0,force:!0})}}const D=/^[A-Za-z0-9._-]+$/;function K(e){try{const r=JSON.parse(m(e,"utf8"));if(S(r))return Object.fromEntries(Object.entries(r).map(([t,o])=>[t,(Array.isArray(o)?o:[]).filter(n=>typeof n=="string"&&D.test(n))]))}catch{}return{}}function G(e,r){d(y(e),{recursive:!0});const t=`${e}.writing`;M(t,`${JSON.stringify(r,null,2)}
2
+ `),w(t,e)}export{A as SKILL_HOMES,B as buildSkillsMountArgs,Y as injectSkills,j as readSkillAudience};
@@ -0,0 +1 @@
1
+ import{mkdirSync as n,readFileSync as i,writeFileSync as o}from"node:fs";import{dirname as m,join as p}from"node:path";import{PACKAGE_ROOT as c}from"./constants.js";function f(r){return i(p(c,"templates","context",`${r}.md`),"utf8").trimEnd()}function x(r,e){return r.replace(/\{\{(\w+)\}\}/g,(u,t)=>e[t]??`{{${t}}}`)}function s(r,e){n(m(r),{recursive:!0}),o(r,e)}export{f as loadTemplate,x as renderTemplate,s as writeFileEnsuringDir};