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
package/src/routes.ts ADDED
@@ -0,0 +1,444 @@
1
+ import {
2
+ Mochi,
3
+ apiError,
4
+ error,
5
+ fail,
6
+ json,
7
+ success,
8
+ type MochiApiEvent,
9
+ type MochiRouteValue
10
+ } from 'mochi-framework';
11
+ import { dockerArch, dockerAvailable, pruneBuildCache } from './lib/docker.server.ts';
12
+ import { devcontainerCliAvailable } from './lib/devcontainer.server.ts';
13
+ import { resolveInjections } from './lib/injections.server.ts';
14
+ import {
15
+ DEFAULT_HAIKU_MODEL,
16
+ DEFAULT_MODEL,
17
+ DEFAULT_OPUS_MODEL,
18
+ DEFAULT_SMALL_FAST_MODEL,
19
+ DEFAULT_SONNET_MODEL
20
+ } from './container-injections/claude-code-custom.ts';
21
+ import { hostEnvVarPresence, parseHostEnvVarNames } from './container-injections/host-env-vars.ts';
22
+ import { browse } from './lib/picker.server.ts';
23
+ import { pickNamePrompt } from './avatars/name-prompts.ts';
24
+ import {
25
+ addForwardedPort,
26
+ createInstance,
27
+ deleteAllInstances,
28
+ deleteDatabaseAndShutdown,
29
+ deleteInstance,
30
+ listInstances,
31
+ rebuildInstance,
32
+ rebuildRunningInstancesNoCache,
33
+ removeForwardedPort,
34
+ renameInstance,
35
+ sanitizeInstance,
36
+ startInstance,
37
+ stopInstance,
38
+ streamClose,
39
+ streamOpen,
40
+ subscribeLogs
41
+ } from './lib/instances.server.ts';
42
+ import {
43
+ deleteFolderHistory,
44
+ getInstance,
45
+ getOption,
46
+ listFolderHistory,
47
+ setOption
48
+ } from './lib/db.server.ts';
49
+ import { APP_VERSION, DEFAULT_COPY_IGNORE, DEFAULT_IMAGE } from './lib/config.server.ts';
50
+ import { wsUpgradeAllowed } from './lib/auth.server.ts';
51
+ import { clearAttention, setAttention } from './lib/bridge.server.ts';
52
+ import { timingSafeEqualStr } from './lib/crypto.server.ts';
53
+ import { proxyRoutes } from './lib/proxy.server.ts';
54
+
55
+ async function preflight() {
56
+ const [docker, cli, auth] = await Promise.all([
57
+ dockerAvailable(),
58
+ devcontainerCliAvailable(),
59
+ // Driven off the registry, so adding an injection extends the setup UI for free.
60
+ Promise.all(
61
+ resolveInjections()
62
+ .filter((i) => i.auth)
63
+ .map(async (i) => {
64
+ const status = await i.auth!.status();
65
+ return {
66
+ id: i.id,
67
+ label: i.label,
68
+ available: status.available,
69
+ source: status.source,
70
+ hint: i.auth!.hint
71
+ };
72
+ })
73
+ )
74
+ ]);
75
+ return { docker, cli, auth };
76
+ }
77
+
78
+ /** Lets a route handler just `throw` for both validation and business-logic failures. */
79
+ async function mutate(fn: () => Promise<unknown> | unknown): Promise<Response> {
80
+ try {
81
+ return json(await fn());
82
+ } catch (err) {
83
+ return apiError(400, (err as Error).message);
84
+ }
85
+ }
86
+
87
+ /** Always answers 200 on success, so the one route needing a 201 is written out by hand. */
88
+ function mutationRoute(
89
+ method: 'POST' | 'DELETE',
90
+ handler: (event: MochiApiEvent) => Promise<unknown> | unknown
91
+ ) {
92
+ return Mochi.api((event) => {
93
+ if (event.method !== method) return apiError(405, 'Method Not Allowed');
94
+ return mutate(() => handler(event));
95
+ });
96
+ }
97
+
98
+ function str(formData: FormData, key: string): string {
99
+ return String(formData.get(key) ?? '').trim();
100
+ }
101
+
102
+ /** An unchecked box is omitted from the form entirely, so presence is the only signal. */
103
+ function onChecked(formData: FormData, key: string): boolean {
104
+ return formData.get(key) === 'on';
105
+ }
106
+
107
+ export const routes: Record<string, MochiRouteValue> = {
108
+ // Dashboard and IDE share one shell so navigating between them keeps the iframes mounted;
109
+ // `snapshot` seeds the live list so neither renders a loading flash first.
110
+ '/': Mochi.page('./src/pages/App.svelte', {
111
+ serverProps: async () => ({
112
+ preflight: await preflight(),
113
+ initialPath: '/',
114
+ snapshot: await listInstances()
115
+ })
116
+ }),
117
+
118
+ '/ide/:id': Mochi.page('./src/pages/App.svelte', {
119
+ serverProps: async (_req, params) => {
120
+ if (!params.id || !getInstance(params.id)) error(404, 'Instance not found');
121
+ return {
122
+ preflight: await preflight(),
123
+ initialPath: `/ide/${params.id}`,
124
+ snapshot: await listInstances()
125
+ };
126
+ }
127
+ }),
128
+
129
+ '/instances/:id': Mochi.page('./src/pages/Instance.svelte', {
130
+ serverProps: (_req, params) => {
131
+ // Only validates existence — the view hydrates its data from the stream.
132
+ if (!params.id || !getInstance(params.id)) error(404, 'Instance not found');
133
+ // Lets the health panel's skeleton render one row per real check before the first snapshot.
134
+ return { id: params.id, injectionChecks: resolveInjections().filter((i) => i.check).length };
135
+ },
136
+ actions: {
137
+ restart: ({ params }) => {
138
+ try {
139
+ rebuildInstance(params.id!);
140
+ } catch (err) {
141
+ return fail(400, { error: (err as Error).message });
142
+ }
143
+ return success({});
144
+ }
145
+ }
146
+ }),
147
+
148
+ // Reached from the settings page's coin. The name hint is rolled here rather than in
149
+ // the editor so hydration can't re-roll it and swap the word out mid-load.
150
+ '/avatars': Mochi.page('./src/pages/Avatars.svelte', {
151
+ serverProps: () => ({ namePlaceholder: pickNamePrompt() })
152
+ }),
153
+
154
+ // Settings mutations are Form actions rather than JSON routes, so each control
155
+ // still works without JS and `enhance` only upgrades it.
156
+ '/settings': Mochi.page('./src/pages/Settings.svelte', {
157
+ serverProps: async () => {
158
+ // Only names ever reach the client; presence is sent separately, values never.
159
+ const hostEnvVarNames = parseHostEnvVarNames(getOption('host_env_var_names'));
160
+ return {
161
+ defaultImage: getOption('default_image') ?? DEFAULT_IMAGE,
162
+ builtinImage: DEFAULT_IMAGE,
163
+ disableBuildCache: getOption('disable_build_cache') === '1',
164
+ // An explicit empty string (as opposed to unset) means "copy everything".
165
+ copyIgnorePatterns: getOption('copy_ignore_patterns') ?? DEFAULT_COPY_IGNORE,
166
+ builtinCopyIgnore: DEFAULT_COPY_IGNORE,
167
+ dockerArch: await dockerArch(),
168
+ // Only whether each token is set — the page renders a placeholder from that.
169
+ manualTokensEnabled: getOption('manual_tokens_enabled') === '1',
170
+ githubTokenSet: !!getOption('manual_github_token'),
171
+ claudeTokenSet: !!getOption('manual_claude_code_token'),
172
+ // Custom endpoint (LiteLLM / Bedrock): toggle state, base URL (non-secret),
173
+ // whether the token is set (never the secret value), and model IDs prefilled
174
+ // from the module defaults when not yet customised.
175
+ customEndpointEnabled: getOption('custom_endpoint_enabled') === '1',
176
+ customEndpointBaseUrl: getOption('custom_endpoint_base_url') ?? '',
177
+ customEndpointTokenSet: !!getOption('custom_endpoint_token')?.trim(),
178
+ customEndpointOpusModel: getOption('custom_endpoint_opus_model') ?? DEFAULT_OPUS_MODEL,
179
+ customEndpointSonnetModel:
180
+ getOption('custom_endpoint_sonnet_model') ?? DEFAULT_SONNET_MODEL,
181
+ customEndpointHaikuModel: getOption('custom_endpoint_haiku_model') ?? DEFAULT_HAIKU_MODEL,
182
+ customEndpointSmallFastModel:
183
+ getOption('custom_endpoint_small_fast_model') ?? DEFAULT_SMALL_FAST_MODEL,
184
+ customEndpointModel: getOption('custom_endpoint_model') ?? DEFAULT_MODEL,
185
+ hostEnvVarsEnabled: getOption('host_env_vars_enabled') === '1',
186
+ hostEnvVarNames,
187
+ hostEnvVarPresence: hostEnvVarPresence(hostEnvVarNames),
188
+ version: APP_VERSION
189
+ };
190
+ },
191
+ actions: {
192
+ // Persist the default container image used when a source folder ships no devcontainer.json.
193
+ defaultImage: ({ formData }) => {
194
+ const image = str(formData, 'image');
195
+ if (!image) return fail(400, { error: 'image is required' });
196
+ setOption('default_image', image);
197
+ return success({ image });
198
+ },
199
+
200
+ disableBuildCache: ({ formData }) => {
201
+ const enabled = onChecked(formData, 'enabled');
202
+ setOption('disable_build_cache', enabled ? '1' : '0');
203
+ return success({ enabled });
204
+ },
205
+
206
+ // Unlike defaultImage, an empty value is valid — it means "copy everything".
207
+ copyIgnorePatterns: ({ formData }) => {
208
+ const patterns = str(formData, 'patterns');
209
+ setOption('copy_ignore_patterns', patterns);
210
+ return success({ patterns });
211
+ },
212
+
213
+ // Tokens are stored plaintext in the options table and never sent back to the client.
214
+ manualTokensToggle: ({ formData }) => {
215
+ const enabled = onChecked(formData, 'enabled');
216
+ setOption('manual_tokens_enabled', enabled ? '1' : '0');
217
+ return success({ enabled });
218
+ },
219
+ githubToken: ({ formData }) => {
220
+ const value = str(formData, 'githubToken');
221
+ setOption('manual_github_token', value);
222
+ return success({ set: value.length > 0 });
223
+ },
224
+ claudeToken: ({ formData }) => {
225
+ const value = str(formData, 'claudeToken');
226
+ setOption('manual_claude_code_token', value);
227
+ return success({ set: value.length > 0 });
228
+ },
229
+
230
+ customEndpointToggle: ({ formData }) => {
231
+ const enabled = onChecked(formData, 'enabled');
232
+ setOption('custom_endpoint_enabled', enabled ? '1' : '0');
233
+ return success({ enabled });
234
+ },
235
+ customBaseUrl: ({ formData }) => {
236
+ const value = str(formData, 'baseUrl');
237
+ setOption('custom_endpoint_base_url', value);
238
+ return success({ set: value.length > 0 });
239
+ },
240
+ customToken: ({ formData }) => {
241
+ const value = str(formData, 'token');
242
+ setOption('custom_endpoint_token', value);
243
+ return success({ set: value.length > 0 });
244
+ },
245
+ customModels: ({ formData }) => {
246
+ setOption('custom_endpoint_opus_model', str(formData, 'opusModel'));
247
+ setOption('custom_endpoint_sonnet_model', str(formData, 'sonnetModel'));
248
+ setOption('custom_endpoint_haiku_model', str(formData, 'haikuModel'));
249
+ setOption('custom_endpoint_small_fast_model', str(formData, 'smallFastModel'));
250
+ setOption('custom_endpoint_model', str(formData, 'defaultModel'));
251
+ return success({});
252
+ },
253
+
254
+ // Only names are stored; a value is never persisted nor accepted from the client.
255
+ hostEnvVarsToggle: ({ formData }) => {
256
+ const enabled = onChecked(formData, 'enabled');
257
+ setOption('host_env_vars_enabled', enabled ? '1' : '0');
258
+ const names = parseHostEnvVarNames(getOption('host_env_var_names'));
259
+ return success({ enabled, presence: hostEnvVarPresence(names) });
260
+ },
261
+ // Replaces the whole list, so both the add and remove forms post every name they want kept.
262
+ hostEnvVarNames: ({ formData }) => {
263
+ const raw = formData.getAll('names').map(String);
264
+ const names = [...new Set(raw.map((n) => n.trim()))].filter(Boolean);
265
+ for (const name of names) {
266
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
267
+ return fail(400, { error: `invalid variable name: ${name}` });
268
+ }
269
+ }
270
+ setOption('host_env_var_names', JSON.stringify(names));
271
+ const saved = parseHostEnvVarNames(getOption('host_env_var_names'));
272
+ return success({ presence: hostEnvVarPresence(saved) });
273
+ },
274
+
275
+ clearBuildCache: async () => {
276
+ const { spaceReclaimed } = await pruneBuildCache();
277
+ return success({ spaceReclaimed: spaceReclaimed ?? 0 });
278
+ },
279
+
280
+ rebuildAllNoCache: () => success({ count: rebuildRunningInstancesNoCache() }),
281
+
282
+ // The process exits mid-flight, so the client may see success or a dropped
283
+ // connection; SettingsView treats both as "shutting down".
284
+ shutdown: async () => {
285
+ await deleteDatabaseAndShutdown();
286
+ return success({});
287
+ }
288
+ }
289
+ }),
290
+
291
+ '/api/browse': Mochi.api(async ({ url }) => {
292
+ try {
293
+ return json(await browse(url.searchParams.get('path') ?? undefined));
294
+ } catch (err) {
295
+ return apiError(400, (err as Error).message);
296
+ }
297
+ }),
298
+
299
+ '/api/stream': Mochi.ws({
300
+ // Mochi.ws routes bypass the global basicAuth handle, so enforce origin + auth here.
301
+ upgrade: (req) => (wsUpgradeAllowed(req) ? {} : false),
302
+ open: streamOpen,
303
+ message: () => {},
304
+ close: streamClose
305
+ }),
306
+
307
+ '/api/instances': Mochi.api(async ({ method, request }) => {
308
+ if (method === 'GET') return json({ instances: await listInstances() });
309
+ if (method === 'POST') {
310
+ const body = (await request.json().catch(() => null)) as {
311
+ sourcePath?: string;
312
+ name?: string;
313
+ branch?: string;
314
+ } | null;
315
+ if (!body?.sourcePath) return apiError(400, 'sourcePath is required');
316
+ try {
317
+ const instance = await createInstance(body.sourcePath, body.name, { branch: body.branch });
318
+ return json({ instance: sanitizeInstance(instance) }, { status: 201 });
319
+ } catch (err) {
320
+ return apiError(400, (err as Error).message);
321
+ }
322
+ }
323
+ return apiError(405, 'Method Not Allowed');
324
+ }),
325
+
326
+ '/api/history': Mochi.api(async ({ method, request }) => {
327
+ if (method === 'GET') return json({ history: listFolderHistory() });
328
+ if (method === 'DELETE') {
329
+ return mutate(async () => {
330
+ const body = (await request.json().catch(() => null)) as { sourcePath?: string } | null;
331
+ if (!body?.sourcePath) throw new Error('sourcePath is required');
332
+ deleteFolderHistory(body.sourcePath);
333
+ return { ok: true };
334
+ });
335
+ }
336
+ return apiError(405, 'Method Not Allowed');
337
+ }),
338
+
339
+ '/api/instances/delete-all': mutationRoute('POST', async () => {
340
+ await deleteAllInstances();
341
+ return { ok: true };
342
+ }),
343
+
344
+ '/api/instances/:id/rename': mutationRoute('POST', async ({ params, request }) => {
345
+ const body = (await request.json().catch(() => null)) as { name?: string } | null;
346
+ if (!body?.name) throw new Error('name is required');
347
+ return { instance: sanitizeInstance(renameInstance(params.id!, body.name)) };
348
+ }),
349
+
350
+ // Both port routes only mutate the persisted set; /rebuild is what applies it.
351
+ '/api/instances/:id/ports': mutationRoute('POST', async ({ params, request }) => {
352
+ const body = (await request.json().catch(() => null)) as { port?: number } | null;
353
+ if (typeof body?.port !== 'number') throw new Error('port (number) is required');
354
+ return { instance: sanitizeInstance(await addForwardedPort(params.id!, body.port)) };
355
+ }),
356
+
357
+ '/api/instances/:id/ports/:port': mutationRoute('DELETE', ({ params }) => {
358
+ const port = Number.parseInt(params.port!, 10);
359
+ if (!Number.isInteger(port)) throw new Error('Invalid port');
360
+ return { instance: sanitizeInstance(removeForwardedPort(params.id!, port)) };
361
+ }),
362
+
363
+ '/api/instances/:id/rebuild': mutationRoute('POST', ({ params }) => ({
364
+ instance: sanitizeInstance(rebuildInstance(params.id!))
365
+ })),
366
+
367
+ '/api/instances/:id/start': mutationRoute('POST', async ({ params }) => ({
368
+ instance: sanitizeInstance(await startInstance(params.id!))
369
+ })),
370
+
371
+ '/api/instances/:id/stop': mutationRoute('POST', async ({ params }) => ({
372
+ instance: sanitizeInstance(await stopInstance(params.id!))
373
+ })),
374
+
375
+ '/api/instances/:id/delete': mutationRoute('POST', async ({ params }) => {
376
+ await deleteInstance(params.id!);
377
+ return { ok: true };
378
+ }),
379
+
380
+ '/api/instances/:id/attention/clear': mutationRoute('POST', ({ params }) => {
381
+ clearAttention(params.id!);
382
+ return { ok: true };
383
+ }),
384
+
385
+ // Token-authed instead of Basic Auth (see auth.server.ts); the token rides in a
386
+ // header rather than the query string so it stays out of request logs.
387
+ '/api/bridge/attention': Mochi.api(async ({ method, url, request }) => {
388
+ if (method !== 'POST') return apiError(405, 'Method Not Allowed');
389
+ const id = url.searchParams.get('id');
390
+ const token = request.headers.get('X-Bridge-Token');
391
+ const state = url.searchParams.get('state');
392
+ console.log(
393
+ `[bridge] attention POST id=${id ?? '(none)'} state=${state ?? '(none)'} hasToken=${!!token}`
394
+ );
395
+ if (!id || !token) {
396
+ console.warn('[bridge] rejected: missing id or token');
397
+ return apiError(400, 'id and token are required');
398
+ }
399
+ const row = getInstance(id);
400
+ // One uniform 403, constant-time — otherwise an attacker could enumerate instance ids.
401
+ if (!row || !row.bridge_token || !timingSafeEqualStr(token, row.bridge_token)) {
402
+ console.warn(
403
+ `[bridge] rejected id=${id}: ${!row ? 'no such instance' : !row.bridge_token ? 'instance has no bridge_token' : 'token mismatch'}`
404
+ );
405
+ return apiError(403, 'Forbidden');
406
+ }
407
+ if (state === 'done') setAttention(id, 'done');
408
+ else if (state === 'waiting') setAttention(id, 'waiting');
409
+ else clearAttention(id); // 'busy' / anything else → Claude resumed, dismiss the pulse
410
+ console.log(
411
+ `[bridge] accepted id=${id} → attention=${state === 'done' || state === 'waiting' ? state : 'cleared'}`
412
+ );
413
+ return json({ ok: true });
414
+ }),
415
+
416
+ '/api/instances/:id/logs': Mochi.ws<{ id: string; unsub?: () => void }>({
417
+ upgrade: (req, params) =>
418
+ wsUpgradeAllowed(req) && params.id && getInstance(params.id) ? { id: params.id } : false,
419
+ open(ws) {
420
+ ws.data.user.unsub = subscribeLogs(ws.data.user.id, (chunk) => {
421
+ try {
422
+ ws.send(chunk);
423
+ } catch {
424
+ /* socket closed */
425
+ }
426
+ });
427
+ },
428
+ message: () => {},
429
+ close(ws) {
430
+ ws.data.user.unsub?.();
431
+ }
432
+ }),
433
+
434
+ ...proxyRoutes,
435
+
436
+ ...(process.env.MODE === 'development'
437
+ ? {
438
+ '/debug': Mochi.page('./src/pages/UI.svelte', {
439
+ // Feed the showcase the real auth providers so CredMenu mirrors `/`.
440
+ serverProps: async () => ({ preflight: await preflight() })
441
+ })
442
+ }
443
+ : {})
444
+ };
@@ -0,0 +1,14 @@
1
+ // SSR-safe: every function no-ops when localStorage is absent.
2
+
3
+ const SOUND_KEY = 'codebay.sound';
4
+
5
+ /** Defaults to on — only an explicit 'off' disables it. */
6
+ export function soundEnabled(): boolean {
7
+ if (typeof localStorage === 'undefined') return true;
8
+ return localStorage.getItem(SOUND_KEY) !== 'off';
9
+ }
10
+
11
+ export function setSoundEnabled(on: boolean): void {
12
+ if (typeof localStorage === 'undefined') return;
13
+ localStorage.setItem(SOUND_KEY, on ? 'on' : 'off');
14
+ }
package/src/shell.html ADDED
@@ -0,0 +1,100 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Codebay</title>
7
+ <style>
8
+ /* Each themed variable is defined once as light-dark(light, dark). With no
9
+ data-theme attribute (the "auto" mode) `color-scheme: light dark` lets the
10
+ browser preference pick the value; an explicit choice pins color-scheme
11
+ below, which forces every light-dark() to that side. */
12
+ :root {
13
+ color-scheme: light dark;
14
+ --bg: light-dark(#d7d8d2, #1b1c15);
15
+ --bg-card: light-dark(#e7e8e2, #24261c);
16
+ --ink: light-dark(#14150f, #e8e9df);
17
+ --ink-soft: light-dark(#45463e, #b7b8a9);
18
+ --ink-faint: light-dark(#7d7e74, #85867a);
19
+ --rule: light-dark(#20211a, #46473a);
20
+ --rule-soft: light-dark(#b9bab2, #34352a);
21
+ --warn-bg: light-dark(#e4d98f, #4a3d12);
22
+ --warn-line: light-dark(#9c8729, #c9a83a);
23
+ --warn-ink: light-dark(#463b08, #f3e3a0);
24
+ --ok-bg: light-dark(#8fbf6e, #23421a);
25
+ --ok-line: light-dark(#4c7a2e, #6fa84a);
26
+ --ok-ink: light-dark(#1c3110, #cdeab8);
27
+ --danger: light-dark(#b23a2e, #e2564a);
28
+ --danger-soft: light-dark(#e3c6c1, #4a231e);
29
+ --screen-bg: #0d0e0a;
30
+ --screen-ink: #d8d9cf;
31
+ --screen-line: rgba(255, 255, 255, 0.04);
32
+ --attn-done: #22c55e;
33
+ --attn-waiting: #f59e0b;
34
+ --info: #388bfd;
35
+ --grid-line: light-dark(rgba(0, 0, 0, 0.035), rgba(255, 255, 255, 0.035));
36
+ /* --attn-done at reduced opacity — reads correctly on both themes' track color. */
37
+ --switch-on-bg: rgba(34, 197, 94, 0.32);
38
+ --font-display: 'Doto Variable', 'JetBrains Mono Variable', monospace;
39
+ --font-mono:
40
+ 'JetBrains Mono Variable', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
41
+ --font-serif: var(--font-display);
42
+ --font-sans: var(--font-mono);
43
+ }
44
+ :root[data-theme='light'] {
45
+ color-scheme: light;
46
+ }
47
+ :root[data-theme='dark'] {
48
+ color-scheme: dark;
49
+ }
50
+ * {
51
+ box-sizing: border-box;
52
+ }
53
+ /* The whole theme is square — enforce it globally (covers third-party
54
+ widgets like the toaster too). Intentional, not a workaround.
55
+ Exclude Mochi's dev-only debug bar so it keeps its own styling. */
56
+ *:not(#mochi-dev-toolbar):not(#mochi-dev-toolbar *) {
57
+ border-radius: 0 !important;
58
+ }
59
+ /* Shared "panel" chrome — the bordered, drop-shadowed card look used
60
+ throughout the app (health box, ports/log boxes, instance cards,
61
+ dropdowns, modals, the UI showcase). A component can still override
62
+ `box-shadow`/`border` locally (e.g. the folder-picker modal's bigger
63
+ 8px shadow) — Svelte's auto-scoping class gives any same-component
64
+ rule higher specificity than this global one, so local overrides
65
+ always win regardless of source order. */
66
+ .panel {
67
+ border: 1px solid var(--ink);
68
+ box-shadow: 4px 4px 0 var(--ink);
69
+ }
70
+ /* Shared dark header bar — the inverted label strip at the top of a panel. */
71
+ .panel-bar {
72
+ padding: 9px 14px;
73
+ background: var(--ink);
74
+ color: var(--bg);
75
+ font-family: var(--font-display);
76
+ font-weight: 700;
77
+ font-size: 12px;
78
+ text-transform: uppercase;
79
+ letter-spacing: 0.12em;
80
+ }
81
+ body {
82
+ margin: 0;
83
+ font-family: var(--font-sans);
84
+ /* Pale LCD panel with a faint pixel-grid matrix overlay. */
85
+ background:
86
+ repeating-linear-gradient(0deg, var(--grid-line) 0 1px, transparent 1px 3px),
87
+ repeating-linear-gradient(90deg, var(--grid-line) 0 1px, transparent 1px 3px), var(--bg);
88
+ color: var(--ink);
89
+ font-size: 14px;
90
+ line-height: 1.55;
91
+ min-height: 100vh;
92
+ -webkit-font-smoothing: antialiased;
93
+ }
94
+ </style>
95
+ {{mochi.head}} {{mochi.css}}
96
+ </head>
97
+ <body>
98
+ {{mochi.body}} {{mochi.script}}
99
+ </body>
100
+ </html>
package/src/sound.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { soundEnabled } from './settings.ts';
2
+
3
+ /** Regenerate these WAVs with `bun run gen:chimes`. */
4
+ const SOUNDS = {
5
+ done: '/sounds/done.wav', // brighter ascending pair
6
+ waiting: '/sounds/waiting.wav' // lower, more insistent
7
+ } as const;
8
+
9
+ type Chime = keyof typeof SOUNDS;
10
+
11
+ const elements: Partial<Record<Chime, HTMLAudioElement>> = {};
12
+ let unlocked = false;
13
+
14
+ function element(name: Chime): HTMLAudioElement | null {
15
+ if (typeof window === 'undefined') return null;
16
+ let el = elements[name];
17
+ if (!el) {
18
+ el = new Audio(SOUNDS[name]);
19
+ el.preload = 'auto';
20
+ elements[name] = el;
21
+ }
22
+ return el;
23
+ }
24
+
25
+ /** Must be called from a user-gesture handler — a silent play/pause is what satisfies the browser. */
26
+ export function unlockAudio(): void {
27
+ if (unlocked || typeof window === 'undefined') return;
28
+ unlocked = true;
29
+ for (const name of Object.keys(SOUNDS) as Chime[]) {
30
+ const el = element(name);
31
+ if (!el) continue;
32
+ const { volume } = el;
33
+ el.volume = 0;
34
+ void el
35
+ .play()
36
+ .then(() => {
37
+ el.pause();
38
+ el.currentTime = 0;
39
+ el.volume = volume;
40
+ })
41
+ .catch(() => {
42
+ el.volume = volume;
43
+ });
44
+ }
45
+ }
46
+
47
+ export function playChime(state: Chime): void {
48
+ if (!soundEnabled()) {
49
+ console.log(`[chime] playChime(${state}) — skipped (sound disabled in settings)`);
50
+ return;
51
+ }
52
+ const el = element(state);
53
+ if (!el) {
54
+ console.log(`[chime] playChime(${state}) — no audio element (SSR/no window?)`);
55
+ return;
56
+ }
57
+ if (!unlocked) {
58
+ console.warn(
59
+ `[chime] playChime(${state}) — audio not yet unlocked; browser may block until the page is interacted with`
60
+ );
61
+ }
62
+ el.currentTime = 0;
63
+ void el
64
+ .play()
65
+ .then(() => console.log(`[chime] playChime(${state}) — playing`))
66
+ .catch((err) =>
67
+ console.warn(`[chime] playChime(${state}) — play rejected:`, err?.message ?? err)
68
+ );
69
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Importable only from the Svelte build graph — `cookies` resolves through Mochi's
3
+ * isomorphic virtual module. Server entry files use `getRequestContext().cookies` instead.
4
+ */
5
+ import { cookies } from 'mochi-framework';
6
+
7
+ export type Theme = 'light' | 'dark' | 'auto';
8
+
9
+ const THEME_KEY = 'theme';
10
+
11
+ export function getTheme(): Theme {
12
+ const value = cookies.get(THEME_KEY);
13
+ return value === 'dark' || value === 'light' ? value : 'auto';
14
+ }
15
+
16
+ export function setTheme(theme: Theme): void {
17
+ cookies.set(THEME_KEY, theme, { expires: 400, path: '/' });
18
+ }
19
+
20
+ /** Auto removes the attribute entirely, letting `light-dark()` follow the browser preference. */
21
+ export function applyTheme(theme: Theme): void {
22
+ if (theme === 'auto') delete document.documentElement.dataset.theme;
23
+ else document.documentElement.dataset.theme = theme;
24
+ }