codebay 0.0.1 → 0.2.1

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 (204) hide show
  1. package/.mochi/import-css/index-0nwv3654.css +18 -0
  2. package/.mochi/import-css/index-xndx4980.css +54 -0
  3. package/.mochi/manifest.json +1612 -0
  4. package/.mochi/server-island.js +1 -0
  5. package/.mochi/svelte-client/HydratableIsland-wrq79y0w.js +1 -0
  6. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-7xgbmxxg.js +2 -0
  7. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-3cnbjpz2.js +13 -0
  8. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-2ntmdm3e.js +1 -0
  9. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-t08spym1.js +23 -0
  10. package/.mochi/svelte-client/chunk-04qv4w6w.js +2 -0
  11. package/.mochi/svelte-client/chunk-5jnepvzx.js +2 -0
  12. package/.mochi/svelte-client/chunk-hcztrd75.js +1 -0
  13. package/.mochi/svelte-client/chunk-qxwt25hn.js +4 -0
  14. package/.mochi/svelte-client/chunk-sjmcgh7f.js +2 -0
  15. package/.mochi/svelte-client/chunk-tr67wtwn.js +2 -0
  16. package/.mochi/svelte-compile/App-f0q5n4rk.server.js +1168 -0
  17. package/.mochi/svelte-compile/Avatars-p8gfef7x.server.js +332 -0
  18. package/.mochi/svelte-compile/ClientStats-z3tn9ad6.server.js +99 -0
  19. package/.mochi/svelte-compile/DefaultError-wh77vebf.server.js +52 -0
  20. package/.mochi/svelte-compile/Instance-atjwsczx.server.js +356 -0
  21. package/.mochi/svelte-compile/Settings-q88xw6h8.server.js +1173 -0
  22. package/.mochi/svelte-compile/chunk-4kwy539a.js +14 -0
  23. package/.mochi/svelte-compile/chunk-9bjk7yx6.js +81 -0
  24. package/.mochi/svelte-compile/chunk-btmcxhqy.js +58 -0
  25. package/.mochi/svelte-compile/chunk-sxqys3nw.js +1668 -0
  26. package/.mochi/svelte-compile/chunk-v6796ga4.js +3295 -0
  27. package/.mochi/svelte-compile/chunk-we0ggtws.js +34 -0
  28. package/.mochi/svelte-css/AppBar-3bxinv4pabv8l.css +1 -0
  29. package/.mochi/svelte-css/AppShell-36mpg5smnbmuw.css +1 -0
  30. package/.mochi/svelte-css/Avatar-24th63awm9mna.css +1 -0
  31. package/.mochi/svelte-css/AvatarEditor-1ryrs1md5g36y.css +1 -0
  32. package/.mochi/svelte-css/AvatarsView-3hq37u4dgtljn.css +1 -0
  33. package/.mochi/svelte-css/BackLink-2wlu0eovz9j86.css +1 -0
  34. package/.mochi/svelte-css/BranchBox-1yqu1q2mgnjwz.css +1 -0
  35. package/.mochi/svelte-css/Brand-1la7zw5uhcbl4.css +1 -0
  36. package/.mochi/svelte-css/Button-2vtbrzb5kgu25.css +1 -0
  37. package/.mochi/svelte-css/CheckmarkIcon-3qlazj2oc50jc.css +1 -0
  38. package/.mochi/svelte-css/ClientStats-19qzckq40p2q9.css +1 -0
  39. package/.mochi/svelte-css/CoinButton-1rej3sh3tkjud.css +1 -0
  40. package/.mochi/svelte-css/CredMenu-9hwgeptp7crb.css +1 -0
  41. package/.mochi/svelte-css/DashboardView-1cy4xpqhja9sx.css +1 -0
  42. package/.mochi/svelte-css/DefaultError-2b0daamydxgb2.css +1 -0
  43. package/.mochi/svelte-css/ErrorIcon-2x0tzsqxskw28.css +1 -0
  44. package/.mochi/svelte-css/FolderBrowser-1x428zb3f2kti.css +1 -0
  45. package/.mochi/svelte-css/HealthBox-er2her0jukwl.css +1 -0
  46. package/.mochi/svelte-css/IdeBar-2qoqi2ya9zdn3.css +1 -0
  47. package/.mochi/svelte-css/IdeLoader-26rlko8e00j3g.css +1 -0
  48. package/.mochi/svelte-css/InstanceCard-16f77aysm7lgp.css +1 -0
  49. package/.mochi/svelte-css/InstanceView-wq85bxtusont.css +1 -0
  50. package/.mochi/svelte-css/LoaderIcon-soq5wt3p05ye.css +1 -0
  51. package/.mochi/svelte-css/PortsBox-1t25b5yg7jva3.css +1 -0
  52. package/.mochi/svelte-css/SettingsCog-jftyadvo64qo.css +1 -0
  53. package/.mochi/svelte-css/SettingsView-1nfdwnm16cpp2.css +1 -0
  54. package/.mochi/svelte-css/Skeleton-8k69usiju43h.css +1 -0
  55. package/.mochi/svelte-css/StatusBadge-3mq0j7z6aq4j.css +1 -0
  56. package/.mochi/svelte-css/ThemePicker-1ir8jyocm8mvi.css +1 -0
  57. package/.mochi/svelte-css/ToastBar-3havgkmtofntq.css +1 -0
  58. package/.mochi/svelte-css/ToastIcon-2z5wrkc95khg0.css +1 -0
  59. package/.mochi/svelte-css/ToastMessage-2jfwb2og2lfa8.css +1 -0
  60. package/.mochi/svelte-css/ToastWrapper-1fbnym10rm2nv.css +1 -0
  61. package/.mochi/svelte-css/Toaster-kk60j193854n.css +1 -0
  62. package/.mochi/svelte-css/TopBar-2q87li6yt8ids.css +1 -0
  63. package/LICENSE +21 -0
  64. package/README.md +34 -3
  65. package/bin/codebay.ts +22 -0
  66. package/migrations/0001_init.sql +20 -0
  67. package/migrations/0002_port_forwards.sql +9 -0
  68. package/migrations/0003_options.sql +4 -0
  69. package/migrations/0004_instance_image.sql +1 -0
  70. package/package.json +87 -9
  71. package/public/sounds/done.wav +0 -0
  72. package/public/sounds/waiting.wav +0 -0
  73. package/src/__fixtures__/HelloWorld.svelte +4 -0
  74. package/src/additional-svelte-typings.d.ts +1 -0
  75. package/src/api.ts +61 -0
  76. package/src/avatars/anchor.ts +17 -0
  77. package/src/avatars/bear.ts +17 -0
  78. package/src/avatars/bee.ts +17 -0
  79. package/src/avatars/bob-marley.ts +17 -0
  80. package/src/avatars/car.ts +17 -0
  81. package/src/avatars/carpathians.ts +17 -0
  82. package/src/avatars/cat-body.ts +17 -0
  83. package/src/avatars/cat-sits.ts +17 -0
  84. package/src/avatars/cat.ts +17 -0
  85. package/src/avatars/celtic-cross.ts +17 -0
  86. package/src/avatars/cherry.ts +17 -0
  87. package/src/avatars/cotton.ts +17 -0
  88. package/src/avatars/crab.ts +17 -0
  89. package/src/avatars/crown.ts +17 -0
  90. package/src/avatars/diamond.ts +17 -0
  91. package/src/avatars/dog.ts +17 -0
  92. package/src/avatars/elephant.ts +17 -0
  93. package/src/avatars/fish.ts +17 -0
  94. package/src/avatars/fleur-de-lis.ts +17 -0
  95. package/src/avatars/flower.ts +17 -0
  96. package/src/avatars/fox.ts +17 -0
  97. package/src/avatars/frog.ts +17 -0
  98. package/src/avatars/ghost.ts +17 -0
  99. package/src/avatars/giraffe.ts +17 -0
  100. package/src/avatars/glasses.ts +17 -0
  101. package/src/avatars/heart.ts +17 -0
  102. package/src/avatars/index.ts +128 -0
  103. package/src/avatars/invader.ts +17 -0
  104. package/src/avatars/kangaroo.ts +17 -0
  105. package/src/avatars/key.ts +17 -0
  106. package/src/avatars/lightning.ts +17 -0
  107. package/src/avatars/mushroom.ts +17 -0
  108. package/src/avatars/name-prompts.ts +30 -0
  109. package/src/avatars/octopus.ts +17 -0
  110. package/src/avatars/owl.ts +17 -0
  111. package/src/avatars/penguin.ts +17 -0
  112. package/src/avatars/planet.ts +17 -0
  113. package/src/avatars/rabbit.ts +17 -0
  114. package/src/avatars/rat.ts +17 -0
  115. package/src/avatars/robot.ts +17 -0
  116. package/src/avatars/rocket.ts +17 -0
  117. package/src/avatars/sea.ts +17 -0
  118. package/src/avatars/serialize.ts +65 -0
  119. package/src/avatars/skull.ts +17 -0
  120. package/src/avatars/snail.ts +17 -0
  121. package/src/avatars/snake.ts +17 -0
  122. package/src/avatars/snakegame.ts +17 -0
  123. package/src/avatars/star.ts +17 -0
  124. package/src/avatars/svelte.ts +17 -0
  125. package/src/avatars/target.ts +17 -0
  126. package/src/avatars/tree.ts +17 -0
  127. package/src/avatars/types.ts +24 -0
  128. package/src/avatars/ukraine.ts +17 -0
  129. package/src/avatars/whale.ts +17 -0
  130. package/src/avatars/wheat.ts +17 -0
  131. package/src/avatars/yinyan.ts +17 -0
  132. package/src/components/AppBar.svelte +35 -0
  133. package/src/components/AppShell.svelte +302 -0
  134. package/src/components/Avatar.svelte +235 -0
  135. package/src/components/AvatarEditor.svelte +297 -0
  136. package/src/components/AvatarsView.svelte +115 -0
  137. package/src/components/BranchBox.svelte +32 -0
  138. package/src/components/Brand.svelte +41 -0
  139. package/src/components/Button.svelte +161 -0
  140. package/src/components/CoinButton.svelte +121 -0
  141. package/src/components/CredMenu.svelte +171 -0
  142. package/src/components/DashboardView.svelte +198 -0
  143. package/src/components/FolderBrowser.svelte +635 -0
  144. package/src/components/HealthBox.svelte +161 -0
  145. package/src/components/IdeBar.svelte +186 -0
  146. package/src/components/IdeLoader.svelte +135 -0
  147. package/src/components/InstanceCard.svelte +220 -0
  148. package/src/components/InstanceView.svelte +567 -0
  149. package/src/components/PortsBox.svelte +74 -0
  150. package/src/components/SettingsCog.svelte +22 -0
  151. package/src/components/SettingsView.svelte +1494 -0
  152. package/src/components/Skeleton.svelte +43 -0
  153. package/src/components/StatusBadge.svelte +54 -0
  154. package/src/components/ThemePicker.svelte +77 -0
  155. package/src/components/TopBar.svelte +64 -0
  156. package/src/components/UiShowcase.svelte +578 -0
  157. package/src/components/ui-showcase/ComponentDemo.svelte +85 -0
  158. package/src/container-injections/attention-hooks.ts +117 -0
  159. package/src/container-injections/claude-aliases.ts +43 -0
  160. package/src/container-injections/claude-code-credentials.ts +145 -0
  161. package/src/container-injections/claude-code-custom.ts +125 -0
  162. package/src/container-injections/claude-no-coauthor.ts +35 -0
  163. package/src/container-injections/claude-skip-permissions.ts +38 -0
  164. package/src/container-injections/claude-statusline.ts +109 -0
  165. package/src/container-injections/git-identity.ts +63 -0
  166. package/src/container-injections/git-safe-directory.ts +18 -0
  167. package/src/container-injections/github-credentials.ts +162 -0
  168. package/src/container-injections/host-env-vars.ts +108 -0
  169. package/src/container-injections/tmux.ts +67 -0
  170. package/src/fontsource.d.ts +3 -0
  171. package/src/index.ts +81 -0
  172. package/src/lib/auth.server.ts +95 -0
  173. package/src/lib/bridge.server.ts +34 -0
  174. package/src/lib/config.server.ts +140 -0
  175. package/src/lib/crypto.server.ts +12 -0
  176. package/src/lib/db.server.ts +196 -0
  177. package/src/lib/devcontainer.server.ts +511 -0
  178. package/src/lib/docker-client.server.ts +61 -0
  179. package/src/lib/docker.server.ts +139 -0
  180. package/src/lib/exec.server.ts +92 -0
  181. package/src/lib/git.server.ts +68 -0
  182. package/src/lib/health.server.ts +105 -0
  183. package/src/lib/injections.server.ts +72 -0
  184. package/src/lib/instances.server.ts +552 -0
  185. package/src/lib/links.ts +9 -0
  186. package/src/lib/picker.server.ts +46 -0
  187. package/src/lib/ports.server.ts +9 -0
  188. package/src/lib/proxy.server.ts +170 -0
  189. package/src/lib/repo-url.ts +60 -0
  190. package/src/lib/spawn.server.ts +14 -0
  191. package/src/lib/theme.server.ts +22 -0
  192. package/src/live.ts +50 -0
  193. package/src/pages/App.svelte +14 -0
  194. package/src/pages/Avatars.svelte +9 -0
  195. package/src/pages/Instance.svelte +9 -0
  196. package/src/pages/Settings.svelte +76 -0
  197. package/src/pages/UI.svelte +10 -0
  198. package/src/routes.ts +444 -0
  199. package/src/settings.ts +14 -0
  200. package/src/shell.html +100 -0
  201. package/src/sound.ts +69 -0
  202. package/src/theme.ts +24 -0
  203. package/src/types.ts +79 -0
  204. package/svelte.config.js +5 -0
@@ -0,0 +1,511 @@
1
+ import { chmod, cp, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { existsSync, statSync } from 'node:fs';
3
+ import { basename, dirname, join, relative } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { INSTALL_SCRIPT as TMUX_INSTALL_SCRIPT } from '../container-injections/tmux.ts';
6
+ import {
7
+ CODE_SERVER_PORT,
8
+ DEFAULT_IMAGE,
9
+ PUBLISH_HOST,
10
+ devcontainerBin,
11
+ dockerEnv
12
+ } from './config.server.ts';
13
+ import { spawnCapture } from './spawn.server.ts';
14
+ import type { PortForward } from '../types.ts';
15
+
16
+ const CODE_SERVER_FEATURE = 'ghcr.io/coder/devcontainer-features/code-server:1';
17
+
18
+ /** Installs the Claude Code CLI into the default image. Needs Node — supplied by NODE_FEATURE. */
19
+ const CLAUDE_CODE_FEATURE = 'ghcr.io/anthropics/devcontainer-features/claude-code:1.0';
20
+
21
+ /** Node.js — required by the Claude Code feature, which no longer bundles it. */
22
+ const NODE_FEATURE = 'ghcr.io/devcontainers/features/node:1';
23
+
24
+ /** GitHub CLI (`gh`) — the default image doesn't ship it. Paired with the github-credentials injection. */
25
+ const GITHUB_CLI_FEATURE = 'ghcr.io/devcontainers/features/github-cli:1';
26
+
27
+ const CODE_SERVER_SETTINGS_FILE = 'code-server-settings.json';
28
+
29
+ /** Relative to .devcontainer/. */
30
+ const TMUX_FEATURE_DIR = 'codebay-tmux';
31
+
32
+ const TMUX_FEATURE_METADATA = {
33
+ id: 'codebay-tmux',
34
+ version: '1.0.0',
35
+ name: 'tmux (Codebay, best-effort)',
36
+ description:
37
+ 'Installs tmux at image build time so the Terminal task can run in a persistent session. Never fails the build.'
38
+ };
39
+
40
+ /**
41
+ * Build time is the only reliable moment to fetch packages in a container that
42
+ * firewalls egress after start; failures are swallowed so this can't break a build.
43
+ */
44
+ const TMUX_FEATURE_INSTALL =
45
+ '#!/bin/sh\n' +
46
+ '(\n' +
47
+ `${TMUX_INSTALL_SCRIPT}\n` +
48
+ ') || echo "codebay-tmux: install failed (non-fatal); the manager retries after the container starts"\n' +
49
+ 'exit 0\n';
50
+
51
+ /** Manager-dropped files the project's own .gitignore won't cover, kept out of git status. */
52
+ const MANAGER_GIT_EXCLUDES = [
53
+ '/.devcontainer/code-server-settings.json',
54
+ '/.devcontainer/devcontainer-lock.json',
55
+ '/.devcontainer/codebay-tmux/',
56
+ '/.vscode/tasks.json'
57
+ ];
58
+
59
+ /** Bound the manager-owned block so a rebuild can replace it without touching the user's own lines. */
60
+ const EXCLUDE_MARKER_START = '# >>> codebay (auto-generated) >>>';
61
+ const EXCLUDE_MARKER_END = '# <<< codebay <<<';
62
+
63
+ const CODE_SERVER_SETTINGS = {
64
+ 'workbench.colorTheme': 'Default Dark Modern',
65
+ 'workbench.secondarySideBar.defaultVisibility': 'hidden',
66
+ 'chat.commandCenter.enabled': false,
67
+ // Instances are throwaway sandboxes — never nag to install recommended extensions.
68
+ 'extensions.ignoreRecommendations': true,
69
+ 'task.allowAutomaticTasks': 'on',
70
+ // Automatic tasks are gated behind Workspace Trust, which the bare default image lacks.
71
+ 'security.workspace.trust.enabled': false,
72
+ 'security.workspace.trust.startupPrompt': 'never',
73
+ 'security.workspace.trust.banner': 'never',
74
+ // The browser intercepts Ctrl+Shift+V for devtools, so this is the usable clipboard path.
75
+ 'terminal.integrated.copyOnSelection': true,
76
+ 'terminal.integrated.rightClickBehavior': 'paste'
77
+ };
78
+
79
+ const TMUX_SESSION = 'codebay';
80
+
81
+ /**
82
+ * Runs under tmux so Claude survives the browser closing — code-server reaps the
83
+ * detached PTY, which only kills the tmux client. `-A` doubles as the run-once gate.
84
+ */
85
+ const TERMINAL_TASK = {
86
+ label: 'Terminal',
87
+ type: 'shell',
88
+ command:
89
+ // `"$SHELL"` must reach tmux unexpanded; VS Code would substitute a `${…}` form first.
90
+ `if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s ${TMUX_SESSION} 'claude --dangerously-skip-permissions; exec "$SHELL" -l'; fi; ` +
91
+ // Without tmux there's no run-once gate, and folderOpen re-fires on every workspace load.
92
+ 'MARK="$HOME/.codebay-terminal-launched"; [ -e "$MARK" ] && exit 0; touch "$MARK"; ' +
93
+ 'claude --dangerously-skip-permissions; exec ${env:SHELL} -l',
94
+ presentation: { reveal: 'always', panel: 'shared', focus: true },
95
+ runOptions: { runOn: 'folderOpen' },
96
+ problemMatcher: []
97
+ };
98
+
99
+ const CODE_SERVER_APPLY_SETTINGS =
100
+ `mkdir -p ~/.local/share/code-server/User && ` +
101
+ `cp -f \\"$PWD/.devcontainer/${CODE_SERVER_SETTINGS_FILE}\\" ` +
102
+ `~/.local/share/code-server/User/settings.json 2>/dev/null;`;
103
+
104
+ const CODE_SERVER_LAUNCH =
105
+ `bash -c "${CODE_SERVER_APPLY_SETTINGS} ` +
106
+ // The bare default image may not export SHELL, which the Terminal task needs.
107
+ `export SHELL=\\"\${SHELL:-/bin/bash}\\"; ` +
108
+ `pgrep -f 'code-server.*${CODE_SERVER_PORT}' >/dev/null 2>&1 || ` +
109
+ `nohup code-server --bind-addr 0.0.0.0:${CODE_SERVER_PORT} --auth none ` +
110
+ `--disable-workspace-trust \\"$PWD\\" >/tmp/code-server.log 2>&1 &"`;
111
+
112
+ export async function devcontainerCliAvailable(): Promise<boolean> {
113
+ return (await spawnCapture([devcontainerBin(), '--version'])) !== null;
114
+ }
115
+
116
+ export async function copyWorkspace(
117
+ source: string,
118
+ dest: string,
119
+ ignore: Set<string>
120
+ ): Promise<void> {
121
+ await mkdir(dest, { recursive: true });
122
+ await cp(source, dest, {
123
+ recursive: true,
124
+ dereference: false,
125
+ filter: (src) => !ignore.has(basename(src))
126
+ });
127
+ }
128
+
129
+ /** Strip // and /* *\/ comments and trailing commas from JSONC, respecting string literals. */
130
+ function stripJsonc(input: string): string {
131
+ let out = '';
132
+ let inString = false;
133
+ let inLine = false;
134
+ let inBlock = false;
135
+ // Tracked inline rather than by regex, so commas inside string values are never touched.
136
+ let lastComma = -1;
137
+ for (let i = 0; i < input.length; i++) {
138
+ const ch = input[i];
139
+ const next = input[i + 1];
140
+ if (inLine) {
141
+ if (ch === '\n') {
142
+ inLine = false;
143
+ out += ch;
144
+ }
145
+ continue;
146
+ }
147
+ if (inBlock) {
148
+ if (ch === '*' && next === '/') {
149
+ inBlock = false;
150
+ i++;
151
+ }
152
+ continue;
153
+ }
154
+ if (inString) {
155
+ out += ch;
156
+ if (ch === '\\') {
157
+ out += next ?? '';
158
+ i++;
159
+ } else if (ch === '"') {
160
+ inString = false;
161
+ }
162
+ continue;
163
+ }
164
+ if (ch === '"') {
165
+ inString = true;
166
+ out += ch;
167
+ lastComma = -1;
168
+ continue;
169
+ }
170
+ if (ch === '/' && next === '/') {
171
+ inLine = true;
172
+ i++;
173
+ continue;
174
+ }
175
+ if (ch === '/' && next === '*') {
176
+ inBlock = true;
177
+ i++;
178
+ continue;
179
+ }
180
+ if (ch === ',') {
181
+ out += ch;
182
+ lastComma = out.length - 1;
183
+ continue;
184
+ }
185
+ if (ch === '}' || ch === ']') {
186
+ if (lastComma !== -1) {
187
+ out = out.slice(0, lastComma) + out.slice(lastComma + 1);
188
+ lastComma = -1;
189
+ }
190
+ out += ch;
191
+ continue;
192
+ }
193
+ out += ch;
194
+ // Whitespace and stripped comments must not clear the pending comma, but real tokens do.
195
+ if (ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r') lastComma = -1;
196
+ }
197
+ return out;
198
+ }
199
+
200
+ /** Follows the CLI's precedence: `.devcontainer/devcontainer.json`, then `.devcontainer.json`. */
201
+ export function findDevcontainerConfig(dir: string): string | null {
202
+ const nested = join(dir, '.devcontainer', 'devcontainer.json');
203
+ if (existsSync(nested)) return nested;
204
+ const flat = join(dir, '.devcontainer.json');
205
+ if (existsSync(flat)) return flat;
206
+ return null;
207
+ }
208
+
209
+ function configPath(workspaceDir: string): string {
210
+ // Falls back to the nested path because that's where we'd create one.
211
+ return (
212
+ findDevcontainerConfig(workspaceDir) ?? join(workspaceDir, '.devcontainer', 'devcontainer.json')
213
+ );
214
+ }
215
+
216
+ type DevcontainerConfig = {
217
+ image?: string;
218
+ features?: Record<string, unknown>;
219
+ appPort?: number | string | (number | string)[];
220
+ forwardPorts?: (number | string)[];
221
+ postStartCommand?: unknown;
222
+ runArgs?: string[];
223
+ [key: string]: unknown;
224
+ };
225
+
226
+ /** An entry may be a bare port or a `host:container` string. */
227
+ function containerPortOf(entry: number | string): number {
228
+ if (typeof entry === 'number') return entry;
229
+ const last = entry.split(':').pop() ?? '';
230
+ return Number.parseInt(last, 10);
231
+ }
232
+
233
+ /** Only meaningful on a pristine, pre-injection config; code-server's own port is excluded. */
234
+ export async function readDeclaredContainerPorts(workspaceDir: string): Promise<number[]> {
235
+ const target = configPath(workspaceDir);
236
+ if (!existsSync(target)) return [];
237
+ let config: DevcontainerConfig;
238
+ try {
239
+ config = JSON.parse(stripJsonc(await readFile(target, 'utf8'))) as DevcontainerConfig;
240
+ } catch {
241
+ return []; // unparseable here surfaces as a clear error later in writeOverrideConfig
242
+ }
243
+
244
+ const entries: (number | string)[] = [];
245
+ if (Array.isArray(config.forwardPorts)) entries.push(...config.forwardPorts);
246
+ if (Array.isArray(config.appPort)) entries.push(...config.appPort);
247
+ else if (config.appPort !== undefined) entries.push(config.appPort);
248
+
249
+ const ports = new Set<number>();
250
+ for (const entry of entries) {
251
+ const port = containerPortOf(entry);
252
+ if (Number.isInteger(port) && port > 0 && port <= 65535 && port !== CODE_SERVER_PORT) {
253
+ ports.add(port);
254
+ }
255
+ }
256
+ return [...ports];
257
+ }
258
+
259
+ /** Maps `host.docker.internal` to the host so the in-container attention bridge resolves. */
260
+ const HOST_GATEWAY_ARG = '--add-host=host.docker.internal:host-gateway';
261
+
262
+ /** Operates on the copy, never the user's original, so rewriting and normalizing is safe. */
263
+ export async function writeOverrideConfig(
264
+ workspaceDir: string,
265
+ hostPort: number,
266
+ forwards: PortForward[] = [],
267
+ defaultImage: string = DEFAULT_IMAGE
268
+ ): Promise<{ imageSource: string }> {
269
+ const target = configPath(workspaceDir);
270
+ let config: DevcontainerConfig = {};
271
+
272
+ const hadConfig = existsSync(target);
273
+ const imageSource = hadConfig ? 'local' : defaultImage;
274
+
275
+ if (hadConfig) {
276
+ const raw = await readFile(target, 'utf8');
277
+ try {
278
+ config = JSON.parse(stripJsonc(raw)) as DevcontainerConfig;
279
+ } catch (err) {
280
+ throw new Error(
281
+ `Could not parse existing devcontainer.json at ${target}: ${(err as Error).message}`,
282
+ { cause: err }
283
+ );
284
+ }
285
+ } else {
286
+ config.image = defaultImage;
287
+ }
288
+
289
+ // A local feature is addressed relative to the config, which sits at one of two depths.
290
+ const tmuxFeatureKey = `./${relative(dirname(target), join(workspaceDir, '.devcontainer', TMUX_FEATURE_DIR))}`;
291
+
292
+ // Node/Claude/gh only for the default image — projects with their own config own their tooling.
293
+ config.features = {
294
+ ...(config.features ?? {}),
295
+ [CODE_SERVER_FEATURE]: { host: '0.0.0.0', port: CODE_SERVER_PORT, auth: 'none' },
296
+ [tmuxFeatureKey]: {},
297
+ ...(hadConfig
298
+ ? {}
299
+ : { [NODE_FEATURE]: {}, [CLAUDE_CODE_FEATURE]: {}, [GITHUB_CLI_FEATURE]: {} })
300
+ };
301
+
302
+ // Rendered from scratch rather than merged, so removing a forward actually drops its mapping.
303
+ // code-server stays loopback even under HOST=0.0.0.0 — it has no auth of its own, so the LAN
304
+ // reaches it only through the Basic-Auth-gated /p/:id/ proxy.
305
+ config.appPort = [
306
+ `127.0.0.1:${hostPort}:${CODE_SERVER_PORT}`,
307
+ ...forwards.map((f) => `${PUBLISH_HOST}:${f.host_port}:${f.container_port}`)
308
+ ];
309
+
310
+ // host.docker.internal isn't automatic on Colima/Linux Docker.
311
+ const runArgs = new Set(Array.isArray(config.runArgs) ? config.runArgs : []);
312
+ runArgs.add(HOST_GATEWAY_ARG);
313
+ config.runArgs = [...runArgs];
314
+
315
+ const existing = config.postStartCommand;
316
+ config.postStartCommand =
317
+ typeof existing === 'string' && existing.trim()
318
+ ? `${existing} && ${CODE_SERVER_LAUNCH}`
319
+ : CODE_SERVER_LAUNCH;
320
+
321
+ await mkdir(join(workspaceDir, '.devcontainer'), { recursive: true }).catch(() => {});
322
+ await writeFile(target, JSON.stringify(config, null, 2) + '\n', 'utf8');
323
+
324
+ // Staged next to the config; CODE_SERVER_LAUNCH copies it into the user-data dir on first start.
325
+ await writeFile(
326
+ join(workspaceDir, '.devcontainer', CODE_SERVER_SETTINGS_FILE),
327
+ JSON.stringify(CODE_SERVER_SETTINGS, null, 2) + '\n',
328
+ 'utf8'
329
+ );
330
+
331
+ await writeTmuxFeature(workspaceDir);
332
+
333
+ await writeTerminalTask(workspaceDir);
334
+
335
+ await writeLocalGitExclude(workspaceDir);
336
+
337
+ return { imageSource };
338
+ }
339
+
340
+ /**
341
+ * The container has no global gitignore, so files the host hides globally would
342
+ * otherwise surface in its git status. Mirrors git's own resolution order.
343
+ */
344
+ async function readHostGlobalExcludes(): Promise<string> {
345
+ const expand = (p: string) => (p.startsWith('~/') ? join(homedir(), p.slice(2)) : p);
346
+ const configured = await spawnCapture([
347
+ 'git',
348
+ 'config',
349
+ '--global',
350
+ '--get',
351
+ 'core.excludesFile'
352
+ ]);
353
+ const path = configured
354
+ ? expand(configured)
355
+ : process.env.XDG_CONFIG_HOME
356
+ ? join(process.env.XDG_CONFIG_HOME, 'git', 'ignore')
357
+ : join(homedir(), '.config', 'git', 'ignore');
358
+ if (!existsSync(path)) return '';
359
+ try {
360
+ return await readFile(path, 'utf8');
361
+ } catch {
362
+ return '';
363
+ }
364
+ }
365
+
366
+ function stripManagedBlock(text: string): string {
367
+ const start = text.indexOf(EXCLUDE_MARKER_START);
368
+ if (start === -1) return text;
369
+ const endMarker = text.indexOf(EXCLUDE_MARKER_END, start);
370
+ // A truncated block (no closing marker) is dropped from the opening marker onward.
371
+ if (endMarker === -1) return text.slice(0, start);
372
+ return text.slice(0, start) + text.slice(endMarker + EXCLUDE_MARKER_END.length);
373
+ }
374
+
375
+ /** Uses `.git/info/exclude` rather than `.gitignore` so the project's own file stays untouched. */
376
+ async function writeLocalGitExclude(workspaceDir: string): Promise<void> {
377
+ const gitDir = join(workspaceDir, '.git');
378
+ // Only a real .git directory has info/exclude; a submodule/worktree .git file doesn't.
379
+ if (!existsSync(gitDir) || !statSync(gitDir).isDirectory()) return;
380
+
381
+ const infoDir = join(gitDir, 'info');
382
+ const excludePath = join(infoDir, 'exclude');
383
+
384
+ let existing = '';
385
+ if (existsSync(excludePath)) {
386
+ try {
387
+ existing = await readFile(excludePath, 'utf8');
388
+ } catch {
389
+ existing = '';
390
+ }
391
+ }
392
+ existing = stripManagedBlock(existing).trim();
393
+
394
+ const globalExcludes = (await readHostGlobalExcludes()).trim();
395
+ const block = [
396
+ EXCLUDE_MARKER_START,
397
+ '# Files the manager injects into the copied workspace.',
398
+ ...MANAGER_GIT_EXCLUDES,
399
+ ...(globalExcludes
400
+ ? ['# Replicated from the host global git excludes (core.excludesFile).', globalExcludes]
401
+ : []),
402
+ EXCLUDE_MARKER_END,
403
+ ''
404
+ ].join('\n');
405
+
406
+ await mkdir(infoDir, { recursive: true }).catch(() => {});
407
+ await writeFile(excludePath, existing ? `${existing}\n\n${block}` : block, 'utf8');
408
+ }
409
+
410
+ async function writeTmuxFeature(workspaceDir: string): Promise<void> {
411
+ const dir = join(workspaceDir, '.devcontainer', TMUX_FEATURE_DIR);
412
+ await mkdir(dir, { recursive: true }).catch(() => {});
413
+ await writeFile(
414
+ join(dir, 'devcontainer-feature.json'),
415
+ JSON.stringify(TMUX_FEATURE_METADATA, null, 2) + '\n',
416
+ 'utf8'
417
+ );
418
+ const installPath = join(dir, 'install.sh');
419
+ await writeFile(installPath, TMUX_FEATURE_INSTALL, 'utf8');
420
+ // writeFile's mode only applies on creation; chmod covers rewrites too.
421
+ await chmod(installPath, 0o755);
422
+ }
423
+
424
+ /** Replaces the managed task rather than skipping it, so a rebuild picks up command changes. */
425
+ async function writeTerminalTask(workspaceDir: string): Promise<void> {
426
+ const tasksPath = join(workspaceDir, '.vscode', 'tasks.json');
427
+ let config: { version?: string; tasks?: unknown[] } = {};
428
+
429
+ if (existsSync(tasksPath)) {
430
+ try {
431
+ config = JSON.parse(stripJsonc(await readFile(tasksPath, 'utf8')));
432
+ } catch {
433
+ config = {};
434
+ }
435
+ }
436
+
437
+ config.version = config.version ?? '2.0.0';
438
+ const tasks = Array.isArray(config.tasks) ? config.tasks : [];
439
+
440
+ const isManagedTask = (t: unknown) =>
441
+ typeof t === 'object' &&
442
+ t !== null &&
443
+ (t as Record<string, unknown>).label === TERMINAL_TASK.label &&
444
+ ((t as Record<string, { runOn?: string }>).runOptions?.runOn ?? '') === 'folderOpen';
445
+ config.tasks = [...tasks.filter((t) => !isManagedTask(t)), TERMINAL_TASK];
446
+
447
+ await mkdir(join(workspaceDir, '.vscode'), { recursive: true }).catch(() => {});
448
+ await writeFile(tasksPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
449
+ }
450
+
451
+ export interface UpResult {
452
+ outcome: string;
453
+ containerId?: string;
454
+ remoteUser?: string;
455
+ remoteWorkspaceFolder?: string;
456
+ message?: string;
457
+ description?: string;
458
+ }
459
+
460
+ /** Streams all output to `onLog` and returns the parsed final result line. */
461
+ export async function devcontainerUp(
462
+ workspaceDir: string,
463
+ onLog: (chunk: string) => void,
464
+ opts: { noCache?: boolean } = {}
465
+ ): Promise<UpResult> {
466
+ const args = [
467
+ devcontainerBin(),
468
+ 'up',
469
+ '--workspace-folder',
470
+ workspaceDir,
471
+ '--remove-existing-container'
472
+ ];
473
+ // Only takes effect because --remove-existing-container drops the container before the build.
474
+ if (opts.noCache) args.push('--build-no-cache');
475
+ const proc = Bun.spawn(args, {
476
+ cwd: workspaceDir,
477
+ stdout: 'pipe',
478
+ stderr: 'pipe',
479
+ env: dockerEnv()
480
+ });
481
+
482
+ let stdoutText = '';
483
+ const pump = async (stream: ReadableStream<Uint8Array>, capture: boolean) => {
484
+ const decoder = new TextDecoder();
485
+ for await (const bytes of stream) {
486
+ const text = decoder.decode(bytes, { stream: true });
487
+ if (capture) stdoutText += text;
488
+ onLog(text);
489
+ }
490
+ };
491
+
492
+ await Promise.all([pump(proc.stdout, true), pump(proc.stderr, false)]);
493
+ await proc.exited;
494
+
495
+ // The CLI prints log lines plus a final JSON result; find the last JSON object.
496
+ const lines = stdoutText
497
+ .split('\n')
498
+ .map((l) => l.trim())
499
+ .filter(Boolean);
500
+ for (let i = lines.length - 1; i >= 0; i--) {
501
+ const line = lines[i];
502
+ if (line && line.startsWith('{')) {
503
+ try {
504
+ return JSON.parse(line) as UpResult;
505
+ } catch {
506
+ // keep scanning earlier lines
507
+ }
508
+ }
509
+ }
510
+ throw new Error('devcontainer up did not return a result. See logs for details.');
511
+ }
@@ -0,0 +1,61 @@
1
+ import Docker from 'dockerode';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { DOCKER_HOST, dockerEnv } from './config.server.ts';
5
+ import { spawnCapture } from './spawn.server.ts';
6
+
7
+ /** Pinned to globalThis so dev-mode hot reload doesn't reopen connections. */
8
+ const g = globalThis as unknown as { __codebayDocker?: Promise<Docker> };
9
+
10
+ export function getDocker(): Promise<Docker> {
11
+ return (g.__codebayDocker ??= resolveDocker());
12
+ }
13
+
14
+ /** Without this the pinned client would target a dead socket for the whole process lifetime. */
15
+ export function resetDocker(): void {
16
+ g.__codebayDocker = undefined;
17
+ }
18
+
19
+ async function resolveDocker(): Promise<Docker> {
20
+ const host = DOCKER_HOST || (await dockerContextHost()) || '';
21
+ return new Docker(host ? parseDockerHost(host) : { socketPath: '/var/run/docker.sock' });
22
+ }
23
+
24
+ /** dockerode ignores `docker context`, but the devcontainer CLI honors it — so read it here. */
25
+ async function dockerContextHost(): Promise<string> {
26
+ return (
27
+ (await spawnCapture(
28
+ ['docker', 'context', 'inspect', '--format', '{{.Endpoints.docker.Host}}'],
29
+ { env: dockerEnv() }
30
+ )) ?? ''
31
+ );
32
+ }
33
+
34
+ /** Missing files yield undefined so the connection fails with a clear TLS error, not here. */
35
+ function tlsMaterials(): { ca?: Buffer; cert?: Buffer; key?: Buffer } {
36
+ const dir = process.env.DOCKER_CERT_PATH?.trim() || join(process.env.HOME ?? '', '.docker');
37
+ const read = (name: string): Buffer | undefined => {
38
+ try {
39
+ return readFileSync(join(dir, name));
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ };
44
+ return { ca: read('ca.pem'), cert: read('cert.pem'), key: read('key.pem') };
45
+ }
46
+
47
+ /** Windows `npipe://` is unsupported — this app runs on macOS/Linux. */
48
+ function parseDockerHost(host: string): Docker.DockerOptions {
49
+ if (host.startsWith('unix://')) {
50
+ return { socketPath: host.slice('unix://'.length) };
51
+ }
52
+ const tls = (process.env.DOCKER_TLS_VERIFY ?? '') !== '';
53
+ const url = new URL(host.includes('://') ? host : `tcp://${host}`);
54
+ const base: Docker.DockerOptions = {
55
+ host: url.hostname,
56
+ port: url.port ? Number.parseInt(url.port, 10) : tls ? 2376 : 2375,
57
+ protocol: tls ? 'https' : 'http'
58
+ };
59
+ // A TLS daemon needs the client cert/key (and CA) or the handshake fails.
60
+ return tls ? { ...base, ...tlsMaterials() } : base;
61
+ }