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,552 @@
1
+ import { rm, stat } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+ import {
4
+ CODE_SERVER_PORT,
5
+ DATA_DIR,
6
+ DEFAULT_COPY_IGNORE,
7
+ DEFAULT_IMAGE,
8
+ INSTANCES_DIR,
9
+ parseCopyIgnore
10
+ } from './config.server.ts';
11
+ import {
12
+ allForwards,
13
+ allInstances,
14
+ closeDb,
15
+ deleteForward,
16
+ deleteForwards,
17
+ deleteInstanceRow,
18
+ getInstance,
19
+ getOption,
20
+ insertForward,
21
+ insertInstance,
22
+ listForwards,
23
+ recordFolder,
24
+ updateInstance,
25
+ usedPorts,
26
+ type InstanceRow,
27
+ type InstanceStatus
28
+ } from './db.server.ts';
29
+ import {
30
+ dockerAvailable,
31
+ hostPortsInUse,
32
+ isRunning,
33
+ removeContainer,
34
+ startContainer,
35
+ stopContainer
36
+ } from './docker.server.ts';
37
+ import {
38
+ copyWorkspace,
39
+ devcontainerCliAvailable,
40
+ devcontainerUp,
41
+ readDeclaredContainerPorts,
42
+ writeOverrideConfig
43
+ } from './devcontainer.server.ts';
44
+ import { clearAttention, getAttention } from './bridge.server.ts';
45
+ import { proxyPathFor } from './proxy.server.ts';
46
+ import { resolveInjections } from './injections.server.ts';
47
+ import { cloneRepo, readGitBranch } from './git.server.ts';
48
+ import { isRepoUrl, parseRepoUrl } from './repo-url.ts';
49
+ import { currentHealthSnapshots, stopHealthMonitor, syncHealthMonitors } from './health.server.ts';
50
+ import { pickFreePort } from './ports.server.ts';
51
+ import type { ServerWebSocket } from 'bun';
52
+ import type { Instance, InstanceHealth } from '../types.ts';
53
+
54
+ /**
55
+ * `bridge_token` authenticates the no-Basic-Auth `/api/bridge/` endpoint, so it
56
+ * must never reach the browser — every row handed to a route goes through here.
57
+ */
58
+ export function sanitizeInstance(row: InstanceRow): Omit<InstanceRow, 'bridge_token'> {
59
+ const { bridge_token: _token, ...rest } = row;
60
+ return rest;
61
+ }
62
+
63
+ interface LiveState {
64
+ logs: string[];
65
+ subscribers: Set<(chunk: string) => void>;
66
+ }
67
+
68
+ const globalForReg = globalThis as unknown as { __codebayRegistry?: Map<string, LiveState> };
69
+ const registry: Map<string, LiveState> = (globalForReg.__codebayRegistry ??= new Map());
70
+
71
+ function live(id: string): LiveState {
72
+ let state = registry.get(id);
73
+ if (!state) {
74
+ state = { logs: [], subscribers: new Set() };
75
+ registry.set(id, state);
76
+ }
77
+ return state;
78
+ }
79
+
80
+ function appendLog(id: string, chunk: string): void {
81
+ const state = live(id);
82
+ state.logs.push(chunk);
83
+ if (state.logs.length > 2000) state.logs.splice(0, state.logs.length - 2000);
84
+ // Guard each send: a client that disconnected leaves a closed stream that throws.
85
+ for (const sub of [...state.subscribers]) {
86
+ try {
87
+ sub(chunk);
88
+ } catch {
89
+ state.subscribers.delete(sub);
90
+ }
91
+ }
92
+ }
93
+
94
+ /** Replay buffered logs and stream future ones; returns an unsubscribe fn. */
95
+ export function subscribeLogs(id: string, onChunk: (chunk: string) => void): () => void {
96
+ // Materializing an entry for an unknown id would let callers grow the registry unbounded.
97
+ if (!registry.has(id) && !getInstance(id)) return () => {};
98
+ const state = live(id);
99
+ for (const line of state.logs) onChunk(line);
100
+ state.subscribers.add(onChunk);
101
+ return () => state.subscribers.delete(onChunk);
102
+ }
103
+
104
+ export type StreamEvent =
105
+ | { type: 'instances'; data: Instance[] }
106
+ | { type: 'health'; data: { id: string; health: InstanceHealth } }
107
+ | { type: 'preflight'; data: { docker: boolean; cli: boolean } };
108
+
109
+ interface StreamHub {
110
+ sockets: Set<ServerWebSocket<unknown>>;
111
+ timer: ReturnType<typeof setInterval> | null;
112
+ lastListJson: string;
113
+ lastPreflightJson: string;
114
+ }
115
+
116
+ const globalForHub = globalThis as unknown as { __codebayHub?: StreamHub };
117
+ const hub: StreamHub = (globalForHub.__codebayHub ??= {
118
+ sockets: new Set(),
119
+ timer: null,
120
+ lastListJson: '',
121
+ lastPreflightJson: ''
122
+ });
123
+
124
+ /** Docker + CLI only; auth stays SSR-only. */
125
+ async function backgroundPreflight(): Promise<{ docker: boolean; cli: boolean }> {
126
+ const [docker, cli] = await Promise.all([dockerAvailable(), devcontainerCliAvailable()]);
127
+ return { docker, cli };
128
+ }
129
+
130
+ function broadcast(event: StreamEvent): void {
131
+ const frame = JSON.stringify(event);
132
+ for (const ws of [...hub.sockets]) {
133
+ try {
134
+ ws.send(frame);
135
+ } catch {
136
+ hub.sockets.delete(ws);
137
+ }
138
+ }
139
+ }
140
+
141
+ function sendTo(ws: ServerWebSocket<unknown>, event: StreamEvent): void {
142
+ try {
143
+ ws.send(JSON.stringify(event));
144
+ } catch {
145
+ hub.sockets.delete(ws);
146
+ }
147
+ }
148
+
149
+ export function broadcastHealth(id: string, health: InstanceHealth): void {
150
+ broadcast({ type: 'health', data: { id, health } });
151
+ }
152
+
153
+ async function reconcileInstances(force = false): Promise<void> {
154
+ const list = await listInstances();
155
+ const listJson = JSON.stringify(list);
156
+ if (force || listJson !== hub.lastListJson) {
157
+ hub.lastListJson = listJson;
158
+ broadcast({ type: 'instances', data: list });
159
+ }
160
+ }
161
+
162
+ /** Spawns a process, so only the periodic tick calls this — never `triggerReconcile`. */
163
+ async function reconcilePreflight(): Promise<void> {
164
+ const pf = await backgroundPreflight();
165
+ const pfJson = JSON.stringify(pf);
166
+ if (pfJson !== hub.lastPreflightJson) {
167
+ hub.lastPreflightJson = pfJson;
168
+ broadcast({ type: 'preflight', data: pf });
169
+ }
170
+ }
171
+
172
+ async function reconcileAndBroadcast(): Promise<void> {
173
+ await reconcileInstances();
174
+ await reconcilePreflight();
175
+ }
176
+
177
+ /**
178
+ * Deliberately skips preflight: this fires on every attention-bridge ping, so
179
+ * re-probing the CLI here would spawn a process per Claude tool-call boundary.
180
+ */
181
+ export function triggerReconcile(): void {
182
+ void reconcileInstances(true);
183
+ }
184
+
185
+ export function streamOpen(ws: ServerWebSocket<unknown>): void {
186
+ hub.sockets.add(ws);
187
+ if (!hub.timer) hub.timer = setInterval(() => void reconcileAndBroadcast(), 5000);
188
+ // Seed everything up front so a fresh or reconnected client is correct without waiting a tick.
189
+ void listInstances().then((list) => sendTo(ws, { type: 'instances', data: list }));
190
+ for (const snap of currentHealthSnapshots()) sendTo(ws, { type: 'health', data: snap });
191
+ void backgroundPreflight().then((pf) => sendTo(ws, { type: 'preflight', data: pf }));
192
+ }
193
+
194
+ export function streamClose(ws: ServerWebSocket<unknown>): void {
195
+ hub.sockets.delete(ws);
196
+ if (hub.sockets.size === 0 && hub.timer) {
197
+ clearInterval(hub.timer);
198
+ hub.timer = null;
199
+ }
200
+ }
201
+
202
+ // Bridges the gap between allocating a port and persisting it, so two concurrent
203
+ // allocations can't pick the same DB-free port; the TTL stops a failed insert from
204
+ // leaking that port out of the range forever.
205
+ const RESERVATION_TTL_MS = 60_000;
206
+ const globalForPorts = globalThis as unknown as { __codebayReservedPorts?: Map<number, number> };
207
+ const reservedPorts: Map<number, number> = (globalForPorts.__codebayReservedPorts ??= new Map());
208
+
209
+ async function allocatePort(): Promise<number> {
210
+ const dbPorts = new Set(usedPorts());
211
+ // A container can outlive its DB row, so ask Docker what's actually published too.
212
+ const dockerPorts = new Set(await hostPortsInUse());
213
+ const now = Date.now();
214
+ for (const [port, reservedAt] of reservedPorts) {
215
+ if (dbPorts.has(port) || now - reservedAt > RESERVATION_TTL_MS) reservedPorts.delete(port);
216
+ }
217
+ const port = pickFreePort([dbPorts, dockerPorts, new Set(reservedPorts.keys())]);
218
+ reservedPorts.set(port, now);
219
+ return port;
220
+ }
221
+
222
+ async function assertDir(path: string): Promise<void> {
223
+ let info;
224
+ try {
225
+ info = await stat(path);
226
+ } catch {
227
+ throw new Error(`Folder does not exist: ${path}`);
228
+ }
229
+ if (!info.isDirectory()) throw new Error(`Not a folder: ${path}`);
230
+ }
231
+
232
+ function failInstance(id: string, err: unknown): void {
233
+ const message = (err as Error).message;
234
+ updateInstance(id, { status: 'error', error: message });
235
+ appendLog(id, `\n✗ Error: ${message}\n`);
236
+ triggerReconcile();
237
+ }
238
+
239
+ async function boot(row: InstanceRow, opts: { branch?: string } = {}): Promise<void> {
240
+ try {
241
+ if (isRepoUrl(row.source_path)) {
242
+ appendLog(row.id, `Cloning ${row.source_path} → ${row.workspace_path}\n`);
243
+ await cloneRepo(row.source_path, row.workspace_path, (chunk) => appendLog(row.id, chunk), {
244
+ branch: opts.branch
245
+ });
246
+ } else {
247
+ appendLog(row.id, `Copying ${row.source_path} → ${row.workspace_path}\n`);
248
+ const ignore = parseCopyIgnore(getOption('copy_ignore_patterns') ?? DEFAULT_COPY_IGNORE);
249
+ await copyWorkspace(row.source_path, row.workspace_path, ignore);
250
+ }
251
+ await seedDeclaredPorts(row);
252
+ } catch (err) {
253
+ failInstance(row.id, err);
254
+ return;
255
+ }
256
+ await provision(row);
257
+ }
258
+
259
+ /** Must run before config injection, while the copied `devcontainer.json` is still pristine. */
260
+ async function seedDeclaredPorts(row: InstanceRow): Promise<void> {
261
+ const existing = new Set(listForwards(row.id).map((f) => f.container_port));
262
+ for (const containerPort of await readDeclaredContainerPorts(row.workspace_path)) {
263
+ if (existing.has(containerPort)) continue;
264
+ const hostPort = await allocatePort();
265
+ insertForward({
266
+ instance_id: row.id,
267
+ container_port: containerPort,
268
+ host_port: hostPort,
269
+ created_at: Date.now()
270
+ });
271
+ existing.add(containerPort);
272
+ appendLog(row.id, `Forwarding declared port ${containerPort} → localhost:${hostPort}\n`);
273
+ }
274
+ }
275
+
276
+ /** Never re-copies the workspace, so in-container edits survive a rebuild. */
277
+ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Promise<void> {
278
+ try {
279
+ const forwards = listForwards(row.id).map((f) => ({
280
+ container_port: f.container_port,
281
+ host_port: f.host_port
282
+ }));
283
+ appendLog(row.id, `Injecting code-server (host port ${row.host_port})\n`);
284
+ const defaultImage = getOption('default_image') ?? DEFAULT_IMAGE;
285
+ const { imageSource } = await writeOverrideConfig(
286
+ row.workspace_path,
287
+ row.host_port,
288
+ forwards,
289
+ defaultImage
290
+ );
291
+ updateInstance(row.id, { image_source: imageSource });
292
+
293
+ const noCache = opts.noCache || getOption('disable_build_cache') === '1';
294
+ if (noCache) appendLog(row.id, `Building without cache (--build-no-cache)\n`);
295
+
296
+ appendLog(row.id, `Starting devcontainer…\n`);
297
+ const result = await devcontainerUp(row.workspace_path, (chunk) => appendLog(row.id, chunk), {
298
+ noCache
299
+ });
300
+
301
+ if (result.outcome !== 'success' || !result.containerId) {
302
+ throw new Error(
303
+ result.message || result.description || `devcontainer up failed (${result.outcome})`
304
+ );
305
+ }
306
+
307
+ updateInstance(row.id, {
308
+ container_id: result.containerId,
309
+ remote_workspace_folder: result.remoteWorkspaceFolder ?? null,
310
+ remote_user: result.remoteUser ?? null,
311
+ status: 'running',
312
+ error: null
313
+ });
314
+
315
+ const target = {
316
+ containerId: result.containerId,
317
+ remoteUser: result.remoteUser,
318
+ instance: row
319
+ };
320
+ // Swallow per-injection failures so one bad injection can't abort the rest of provisioning.
321
+ for (const injection of resolveInjections()) {
322
+ try {
323
+ await injection.apply(target, (msg) => appendLog(row.id, msg));
324
+ } catch (err) {
325
+ appendLog(row.id, `⚠ ${injection.label} injection failed: ${(err as Error).message}\n`);
326
+ }
327
+ }
328
+
329
+ appendLog(row.id, `\n✓ Instance running — open it via the proxy at ${proxyPathFor(row.id)}\n`);
330
+ triggerReconcile();
331
+ } catch (err) {
332
+ failInstance(row.id, err);
333
+ }
334
+ }
335
+
336
+ /** The first instance keeps the bare name; later collisions get a `#2`, `#3`, … suffix. */
337
+ function uniqueName(desired: string, excludeId?: string): string {
338
+ const taken = new Set(
339
+ allInstances()
340
+ .filter((row) => row.id !== excludeId)
341
+ .map((row) => row.name)
342
+ );
343
+ if (!taken.has(desired)) return desired;
344
+ for (let n = 2; ; n++) {
345
+ const candidate = `${desired} #${n}`;
346
+ if (!taken.has(candidate)) return candidate;
347
+ }
348
+ }
349
+
350
+ /** `source` is either a local folder (copied) or a Git URL (cloned); boot runs in the background. */
351
+ export async function createInstance(
352
+ source: string,
353
+ name?: string,
354
+ opts: { branch?: string } = {}
355
+ ): Promise<InstanceRow> {
356
+ const parsedRepo = parseRepoUrl(source);
357
+ if (parsedRepo) {
358
+ // Normalize to the clean https clone URL so re-picks from history dedupe.
359
+ source = parsedRepo.cloneUrl;
360
+ } else {
361
+ await assertDir(source);
362
+ }
363
+ const id = crypto.randomUUID();
364
+ const folderName = parsedRepo?.repo || basename(source) || 'workspace';
365
+ const hostPort = await allocatePort();
366
+ const row: InstanceRow = {
367
+ id,
368
+ name: uniqueName(name?.trim() || folderName),
369
+ source_path: source,
370
+ workspace_path: join(INSTANCES_DIR, id, folderName),
371
+ host_port: hostPort,
372
+ container_id: null,
373
+ remote_workspace_folder: null,
374
+ status: 'creating',
375
+ error: null,
376
+ created_at: Date.now(),
377
+ bridge_token: crypto.randomUUID().replace(/-/g, ''),
378
+ remote_user: null,
379
+ image_source: null
380
+ };
381
+ insertInstance(row);
382
+ // Strip the de-dup `#2` suffix so the recent-folders list keeps the base name.
383
+ recordFolder(source, row.name.replace(/ #\d+$/, ''));
384
+ triggerReconcile();
385
+ void boot(row, { branch: opts.branch });
386
+ return row;
387
+ }
388
+
389
+ /** List instances, reconciling persisted status against the live Docker state. */
390
+ export async function listInstances(): Promise<Instance[]> {
391
+ const rows = allInstances();
392
+ // Polled rather than persisted, because the branch changes inside the container.
393
+ const branches = new Map<string, string | null>();
394
+ await Promise.all(
395
+ rows.map(async (row) => {
396
+ if (!row.container_id || row.status === 'creating' || row.status === 'error') return;
397
+ const running = await isRunning(row.container_id);
398
+ const next: InstanceStatus = running ? 'running' : 'stopped';
399
+ if (next !== row.status) {
400
+ updateInstance(row.id, { status: next });
401
+ row.status = next;
402
+ }
403
+ // The workspace is bind-mounted, so the host copy's .git/HEAD tracks the container.
404
+ branches.set(row.id, await readGitBranch(row.workspace_path));
405
+ })
406
+ );
407
+ // Reconcile is the only place that knows the current running set.
408
+ syncHealthMonitors(rows);
409
+ // Distinguishes a forward that's live from one still pending a rebuild.
410
+ const openPorts = new Map<string, Set<number>>();
411
+ for (const { id, health } of currentHealthSnapshots()) {
412
+ openPorts.set(id, new Set(health.openPorts));
413
+ }
414
+ const forwards = new Map<
415
+ string,
416
+ { container_port: number; host_port: number; open: boolean }[]
417
+ >();
418
+ for (const f of allForwards()) {
419
+ const list = forwards.get(f.instance_id) ?? [];
420
+ list.push({
421
+ container_port: f.container_port,
422
+ host_port: f.host_port,
423
+ open: openPorts.get(f.instance_id)?.has(f.container_port) ?? false
424
+ });
425
+ forwards.set(f.instance_id, list);
426
+ }
427
+ return rows.map((row) => ({
428
+ ...sanitizeInstance(row),
429
+ git_branch: branches.get(row.id) ?? null,
430
+ attention: getAttention(row.id),
431
+ forwarded_ports: forwards.get(row.id) ?? []
432
+ }));
433
+ }
434
+
435
+ export function renameInstance(id: string, name: string): InstanceRow {
436
+ const row = getInstance(id);
437
+ if (!row) throw new Error('Instance not found');
438
+ const trimmed = name.trim();
439
+ if (!trimmed) throw new Error('Name cannot be empty');
440
+ const unique = trimmed === row.name ? trimmed : uniqueName(trimmed, id);
441
+ updateInstance(id, { name: unique });
442
+ triggerReconcile();
443
+ return getInstance(id)!;
444
+ }
445
+
446
+ /** Only persists the mapping — `rebuildInstance` is what actually publishes it. */
447
+ export async function addForwardedPort(id: string, containerPort: number): Promise<InstanceRow> {
448
+ const row = getInstance(id);
449
+ if (!row) throw new Error('Instance not found');
450
+ if (!Number.isInteger(containerPort) || containerPort < 1 || containerPort > 65535) {
451
+ throw new Error('Port must be an integer between 1 and 65535');
452
+ }
453
+ if (containerPort === CODE_SERVER_PORT) {
454
+ throw new Error(`Port ${CODE_SERVER_PORT} is reserved for code-server`);
455
+ }
456
+ if (listForwards(id).some((f) => f.container_port === containerPort)) {
457
+ throw new Error(`Port ${containerPort} is already forwarded`);
458
+ }
459
+ insertForward({
460
+ instance_id: id,
461
+ container_port: containerPort,
462
+ host_port: await allocatePort(),
463
+ created_at: Date.now()
464
+ });
465
+ triggerReconcile();
466
+ return getInstance(id)!;
467
+ }
468
+
469
+ /** Only persists the removal — `rebuildInstance` is what actually unpublishes it. */
470
+ export function removeForwardedPort(id: string, containerPort: number): InstanceRow {
471
+ const row = getInstance(id);
472
+ if (!row) throw new Error('Instance not found');
473
+ deleteForward(id, containerPort);
474
+ triggerReconcile();
475
+ return getInstance(id)!;
476
+ }
477
+
478
+ /** Recreates the container, but not the workspace copy, so in-container edits survive. */
479
+ export function rebuildInstance(id: string, opts: { noCache?: boolean } = {}): InstanceRow {
480
+ const row = getInstance(id);
481
+ if (!row) throw new Error('Instance not found');
482
+ if (row.status === 'creating') return row; // a build is already in flight
483
+ updateInstance(id, { status: 'creating', error: null });
484
+ triggerReconcile();
485
+ const fresh = getInstance(id)!;
486
+ appendLog(
487
+ id,
488
+ opts.noCache ? `\n— Rebuilding without cache —\n` : `\n— Rebuilding to apply port changes —\n`
489
+ );
490
+ void provision(fresh, opts);
491
+ return fresh;
492
+ }
493
+
494
+ /** Returns how many rebuilds were kicked off; each runs in the background. */
495
+ export function rebuildRunningInstancesNoCache(): number {
496
+ const running = allInstances().filter((r) => r.status === 'running' && r.container_id);
497
+ for (const row of running) rebuildInstance(row.id, { noCache: true });
498
+ return running.length;
499
+ }
500
+
501
+ export async function startInstance(id: string): Promise<InstanceRow> {
502
+ const row = getInstance(id);
503
+ if (!row) throw new Error('Instance not found');
504
+ if (!row.container_id) throw new Error('Instance has no container yet');
505
+ const ok = await startContainer(row.container_id);
506
+ updateInstance(id, {
507
+ status: ok ? 'running' : 'error',
508
+ error: ok ? null : 'Failed to start container'
509
+ });
510
+ triggerReconcile();
511
+ return getInstance(id)!;
512
+ }
513
+
514
+ export async function stopInstance(id: string): Promise<InstanceRow> {
515
+ const row = getInstance(id);
516
+ if (!row) throw new Error('Instance not found');
517
+ const ok = row.container_id ? await stopContainer(row.container_id) : true;
518
+ updateInstance(id, {
519
+ status: ok ? 'stopped' : 'error',
520
+ error: ok ? null : 'Failed to stop container'
521
+ });
522
+ clearAttention(id);
523
+ triggerReconcile();
524
+ return getInstance(id)!;
525
+ }
526
+
527
+ export async function deleteInstance(id: string): Promise<void> {
528
+ const row = getInstance(id);
529
+ if (!row) return;
530
+ if (row.container_id) await removeContainer(row.container_id);
531
+ await rm(join(INSTANCES_DIR, id), { recursive: true, force: true });
532
+ deleteForwards(id);
533
+ deleteInstanceRow(id);
534
+ registry.delete(id);
535
+ stopHealthMonitor(id);
536
+ clearAttention(id);
537
+ triggerReconcile();
538
+ }
539
+
540
+ export async function deleteAllInstances(): Promise<void> {
541
+ for (const row of allInstances()) {
542
+ await deleteInstance(row.id);
543
+ }
544
+ }
545
+
546
+ /** The exit is deferred so the HTTP response can flush before the server dies. */
547
+ export async function deleteDatabaseAndShutdown(): Promise<void> {
548
+ await deleteAllInstances();
549
+ closeDb();
550
+ await rm(DATA_DIR, { recursive: true, force: true });
551
+ setTimeout(() => process.exit(0), 150);
552
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Forwarded ports are published on the machine running codebay, which is only
3
+ * `localhost` when the browser is on that same machine — under HOST=0.0.0.0 it
4
+ * isn't. SSR has no `window`, so the first paint falls back to localhost.
5
+ */
6
+ export function forwardedPortUrl(hostPort: number): string {
7
+ const host = typeof window === 'undefined' ? 'localhost' : window.location.hostname;
8
+ return `http://${host}:${hostPort}`;
9
+ }
@@ -0,0 +1,46 @@
1
+ import { readdir, stat } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { getOption, setOption } from './db.server';
6
+ import { findDevcontainerConfig } from './devcontainer.server.ts';
7
+ import type { BrowseResult, DirEntry } from '../types.ts';
8
+
9
+ const LAST_VIEWED_FOLDER = 'last_viewed_folder';
10
+
11
+ function hasDevcontainer(dir: string): boolean {
12
+ return findDevcontainerConfig(dir) !== null;
13
+ }
14
+
15
+ /** Persists every folder it lists, so an argument-less call resumes where the user left off. */
16
+ export async function browse(path?: string): Promise<BrowseResult> {
17
+ let target: string;
18
+ if (path && path.trim()) {
19
+ target = path;
20
+ } else {
21
+ const saved = getOption(LAST_VIEWED_FOLDER);
22
+ target = saved && existsSync(saved) ? saved : homedir();
23
+ }
24
+
25
+ const info = await stat(target);
26
+ if (!info.isDirectory()) throw new Error(`Not a folder: ${target}`);
27
+
28
+ const dirents = await readdir(target, { withFileTypes: true });
29
+ const entries: DirEntry[] = [];
30
+ for (const dirent of dirents) {
31
+ if (!dirent.isDirectory() || dirent.name.startsWith('.')) continue;
32
+ const full = join(target, dirent.name);
33
+ entries.push({ name: dirent.name, path: full, hasDevcontainer: hasDevcontainer(full) });
34
+ }
35
+ entries.sort((a, b) => a.name.localeCompare(b.name));
36
+
37
+ setOption(LAST_VIEWED_FOLDER, target);
38
+
39
+ const parent = dirname(target);
40
+ return {
41
+ path: target,
42
+ hasDevcontainer: hasDevcontainer(target),
43
+ parent: parent === target ? null : parent,
44
+ entries
45
+ };
46
+ }
@@ -0,0 +1,9 @@
1
+ import { PORT_BASE, PORT_MAX } from './config.server.ts';
2
+
3
+ /** Kept pure so `allocatePort`'s union logic is testable without a DB or Docker daemon. */
4
+ export function pickFreePort(usedSets: ReadonlySet<number>[]): number {
5
+ for (let port = PORT_BASE; port <= PORT_MAX; port++) {
6
+ if (usedSets.every((s) => !s.has(port))) return port;
7
+ }
8
+ throw new Error('No free host ports available.');
9
+ }