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,139 @@
1
+ import { getDocker, resetDocker } from './docker-client.server.ts';
2
+
3
+ function statusOf(err: unknown): number | undefined {
4
+ return (err as { statusCode?: number })?.statusCode;
5
+ }
6
+
7
+ export async function dockerAvailable(): Promise<boolean> {
8
+ try {
9
+ await (await getDocker()).ping();
10
+ return true;
11
+ } catch {
12
+ // Re-resolve on the next probe, in case the daemon moved or switched context.
13
+ resetDocker();
14
+ return false;
15
+ }
16
+ }
17
+
18
+ /** The daemon's arch, not the host's — images are pulled for the daemon, which may be a VM. */
19
+ export async function dockerArch(): Promise<string | null> {
20
+ try {
21
+ const arch = (await (await getDocker()).version()).Arch;
22
+ return arch || null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ export async function isRunning(containerId: string): Promise<boolean> {
29
+ try {
30
+ const info = await (await getDocker()).getContainer(containerId).inspect();
31
+ return info.State?.Running === true;
32
+ } catch {
33
+ return false; // missing container (404) or daemon unreachable
34
+ }
35
+ }
36
+
37
+ /** Reads Docker's live binding table, so it reflects what's actually exposed. */
38
+ export async function publishedContainerPorts(containerId: string): Promise<number[]> {
39
+ let ports: Record<string, { HostPort?: string }[] | null>;
40
+ try {
41
+ const info = await (await getDocker()).getContainer(containerId).inspect();
42
+ ports = (info.NetworkSettings?.Ports ?? {}) as Record<string, { HostPort?: string }[] | null>;
43
+ } catch {
44
+ return [];
45
+ }
46
+ const open = new Set<number>();
47
+ for (const [key, bindings] of Object.entries(ports)) {
48
+ if (!bindings || bindings.length === 0) continue; // exposed but not published
49
+ const port = Number.parseInt(key, 10); // "3000/tcp" → 3000
50
+ if (Number.isInteger(port) && port > 0) open.add(port);
51
+ }
52
+ return [...open];
53
+ }
54
+
55
+ /**
56
+ * Covers containers codebay has no DB row for, which would otherwise get their port
57
+ * handed out twice. Stopped containers don't hold a bind, hence the running-only default.
58
+ */
59
+ export async function hostPortsInUse(): Promise<number[]> {
60
+ try {
61
+ const containers = await (await getDocker()).listContainers();
62
+ const ports = new Set<number>();
63
+ for (const c of containers) {
64
+ for (const p of c.Ports ?? []) {
65
+ if (p.PublicPort) ports.add(p.PublicPort);
66
+ }
67
+ }
68
+ return [...ports];
69
+ } catch {
70
+ return [];
71
+ }
72
+ }
73
+
74
+ /** Goes through the raw modem because dockerode has no helper for `/build/prune`. */
75
+ export async function pruneBuildCache(): Promise<{ spaceReclaimed: number }> {
76
+ const docker = await getDocker();
77
+ const data = await new Promise<{ SpaceReclaimed?: number } | undefined>((resolve, reject) =>
78
+ docker.modem.dial(
79
+ {
80
+ path: '/build/prune?all=true',
81
+ method: 'POST',
82
+ statusCodes: { 200: true, 500: 'server error' }
83
+ },
84
+ (err: unknown, res: unknown) =>
85
+ err ? reject(err) : resolve(res as { SpaceReclaimed?: number } | undefined)
86
+ )
87
+ );
88
+ return { spaceReclaimed: data?.SpaceReclaimed ?? 0 };
89
+ }
90
+
91
+ export async function startContainer(containerId: string): Promise<boolean> {
92
+ try {
93
+ await (await getDocker()).getContainer(containerId).start();
94
+ return true;
95
+ } catch (err) {
96
+ return statusOf(err) === 304; // already started
97
+ }
98
+ }
99
+
100
+ export async function stopContainer(containerId: string): Promise<boolean> {
101
+ try {
102
+ await (await getDocker()).getContainer(containerId).stop();
103
+ return true;
104
+ } catch (err) {
105
+ return statusOf(err) === 304; // already stopped
106
+ }
107
+ }
108
+
109
+ /** Named volumes must be listed before removal and dropped by hand — the engine never reaps them. */
110
+ export async function removeContainer(containerId: string): Promise<boolean> {
111
+ const docker = await getDocker();
112
+ const container = docker.getContainer(containerId);
113
+
114
+ let volumeNames: string[];
115
+ try {
116
+ const info = await container.inspect();
117
+ volumeNames = (info.Mounts ?? [])
118
+ .filter((m) => m.Type === 'volume' && m.Name)
119
+ .map((m) => m.Name!);
120
+ } catch (err) {
121
+ if (statusOf(err) !== 404) throw err;
122
+ return true; // no such container — nothing to clean up
123
+ }
124
+
125
+ try {
126
+ await container.remove({ force: true, v: true });
127
+ } catch (err) {
128
+ if (statusOf(err) !== 404) return false; // already absent counts as success
129
+ }
130
+
131
+ for (const name of volumeNames) {
132
+ try {
133
+ await docker.getVolume(name).remove({ force: true });
134
+ } catch {
135
+ // Already dropped by `v: true` (404) or still shared with another container (409).
136
+ }
137
+ }
138
+ return true;
139
+ }
@@ -0,0 +1,92 @@
1
+ import { Writable } from 'node:stream';
2
+ import { getDocker } from './docker-client.server.ts';
3
+
4
+ export interface ExecTarget {
5
+ containerId: string;
6
+ remoteUser?: string | null;
7
+ }
8
+
9
+ function collector(): { stream: Writable; text: () => string } {
10
+ const chunks: Buffer[] = [];
11
+ return {
12
+ stream: new Writable({
13
+ write(chunk, _enc, cb) {
14
+ chunks.push(Buffer.from(chunk));
15
+ cb();
16
+ }
17
+ }),
18
+ text: () => Buffer.concat(chunks).toString('utf8')
19
+ };
20
+ }
21
+
22
+ const STDIN_ENV = '__CODEBAY_EXEC_STDIN';
23
+ const STDIN_VAR = 'CODEBAY_STDIN';
24
+
25
+ /**
26
+ * Real exec stdin is unusable on Bun — dockerode's hijack path needs an `upgrade` event
27
+ * Bun never emits, and `openStdin` needs full-duplex client HTTP Bun also lacks.
28
+ */
29
+ function wrapWithStdin(script: string): string {
30
+ // The carrier is an env var (never argv, so never in `ps`) and is unset so children don't inherit it.
31
+ return `${STDIN_VAR}="$${STDIN_ENV}"; unset ${STDIN_ENV}\n${script}`;
32
+ }
33
+
34
+ /**
35
+ * The one place the `bash -lc` container-exec pattern lives; every injection goes through it.
36
+ * `stdin` reaches the script as `$CODEBAY_STDIN`, and `args` as `$0`, `$1`, ….
37
+ */
38
+ export async function execInContainer(
39
+ target: ExecTarget,
40
+ opts: { script: string; stdin?: string; args?: string[]; capture?: boolean }
41
+ ): Promise<{ ok: boolean; stdout: string; error?: string }> {
42
+ const user = target.remoteUser?.trim() || 'root';
43
+ const hasStdin = opts.stdin !== undefined;
44
+ try {
45
+ const exec = await (await getDocker()).getContainer(target.containerId).exec({
46
+ Cmd: [
47
+ 'bash',
48
+ '-lc',
49
+ hasStdin ? wrapWithStdin(opts.script) : opts.script,
50
+ ...(opts.args ?? [])
51
+ ],
52
+ User: user,
53
+ Env: hasStdin ? [`${STDIN_ENV}=${opts.stdin}`] : undefined,
54
+ AttachStdout: true,
55
+ AttachStderr: true,
56
+ Tty: false // multiplexes stdout/stderr so the demux below can separate them
57
+ });
58
+ const stream = await exec.start({});
59
+
60
+ const out = collector();
61
+ const err = collector();
62
+ exec.modem.demuxStream(stream, out.stream, err.stream);
63
+ await new Promise<void>((resolve, reject) => {
64
+ stream.on('end', resolve);
65
+ stream.on('error', reject);
66
+ });
67
+
68
+ const code = (await exec.inspect()).ExitCode ?? 1;
69
+ const stdout = opts.capture ? out.text().trim() : '';
70
+ return code === 0
71
+ ? { ok: true, stdout }
72
+ : { ok: false, stdout, error: err.text().trim() || `exit ${code}` };
73
+ } catch (e) {
74
+ return { ok: false, stdout: '', error: (e as Error).message };
75
+ }
76
+ }
77
+
78
+ /** The shape every injection's `check()` uses: a script that echoes `1` or `0`. */
79
+ export async function checkPresence(
80
+ target: ExecTarget,
81
+ script: string,
82
+ args?: string[]
83
+ ): Promise<boolean> {
84
+ const res = await execInContainer(target, { script, args, capture: true });
85
+ return res.ok && res.stdout === '1';
86
+ }
87
+
88
+ /** Only for secret-only files; injections mixing secret and non-secret content roll their own. */
89
+ export function writeSecretFileScript(dirExpr: string, filename: string, mode = '600'): string {
90
+ const path = `${dirExpr}/${filename}`;
91
+ return `mkdir -p "${dirExpr}"; printf '%s' "$CODEBAY_STDIN" > "${path}"; chmod ${mode} "${path}";`;
92
+ }
@@ -0,0 +1,68 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { readGhToken } from '../container-injections/github-credentials.ts';
4
+ import { parseRepoUrl } from './repo-url.ts';
5
+
6
+ /** Reads the bind-mounted host copy, so polling the container's branch costs no `docker exec`. */
7
+ export async function readGitBranch(workspacePath: string): Promise<string | null> {
8
+ try {
9
+ const head = (await readFile(join(workspacePath, '.git', 'HEAD'), 'utf8')).trim();
10
+ const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/);
11
+ if (ref) return ref[1] ?? null;
12
+ // Detached HEAD: the file holds a raw commit SHA instead of a ref.
13
+ return head ? head.slice(0, 7) : null;
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * The token goes in env-scoped git config, so it lands in neither argv nor the clone's
21
+ * persisted `.git/config`; `GIT_TERMINAL_PROMPT=0` fails fast instead of blocking on a prompt.
22
+ */
23
+ export async function cloneRepo(
24
+ source: string,
25
+ dest: string,
26
+ onLog: (chunk: string) => void,
27
+ opts: { branch?: string } = {}
28
+ ): Promise<void> {
29
+ const parsed = parseRepoUrl(source);
30
+ if (!parsed) throw new Error(`Invalid repository URL: ${source}`);
31
+
32
+ const env: Record<string, string | undefined> = {
33
+ ...process.env,
34
+ GIT_TERMINAL_PROMPT: '0'
35
+ };
36
+
37
+ // Keyed by host so GitHub Enterprise Server clones authenticate too, not just github.com.
38
+ const found = await readGhToken(parsed.host);
39
+ if (found) {
40
+ const basic = Buffer.from(`x-access-token:${found.token}`).toString('base64');
41
+ env.GIT_CONFIG_COUNT = '1';
42
+ env.GIT_CONFIG_KEY_0 = `http.https://${parsed.host}/.extraheader`;
43
+ env.GIT_CONFIG_VALUE_0 = `Authorization: Basic ${basic}`;
44
+ }
45
+
46
+ const args = ['git', 'clone', '--progress'];
47
+ if (opts.branch?.trim()) args.push('--branch', opts.branch.trim());
48
+ args.push(parsed.cloneUrl, dest);
49
+
50
+ const proc = Bun.spawn(args, { stdout: 'pipe', stderr: 'pipe', env });
51
+
52
+ let stderrTail = '';
53
+ const decoder = new TextDecoder();
54
+ const pump = async (stream: ReadableStream<Uint8Array>, capture: boolean) => {
55
+ for await (const bytes of stream) {
56
+ const text = decoder.decode(bytes, { stream: true });
57
+ if (capture) stderrTail = (stderrTail + text).slice(-2000);
58
+ onLog(text);
59
+ }
60
+ };
61
+
62
+ await Promise.all([pump(proc.stdout, false), pump(proc.stderr, true)]);
63
+ const code = await proc.exited;
64
+ if (code !== 0) {
65
+ const tail = stderrTail.trim().split('\n').slice(-3).join(' ');
66
+ throw new Error(`git clone failed (exit ${code})${tail ? `: ${tail}` : ''}`);
67
+ }
68
+ }
@@ -0,0 +1,105 @@
1
+ import { type InstanceRow } from './db.server.ts';
2
+ import { isRunning, publishedContainerPorts } from './docker.server.ts';
3
+ import { broadcastHealth } from './instances.server.ts';
4
+ import { resolveInjections } from './injections.server.ts';
5
+ import type { InstanceHealth } from '../types.ts';
6
+
7
+ const REFRESH_MS = 5000;
8
+
9
+ interface Monitor {
10
+ snapshot: InstanceHealth | null;
11
+ timer: ReturnType<typeof setInterval>;
12
+ }
13
+
14
+ // Pin to globalThis so dev-mode hot reload doesn't orphan the interval timers.
15
+ const globalForHealth = globalThis as unknown as { __codebayHealth?: Map<string, Monitor> };
16
+ const monitors: Map<string, Monitor> = (globalForHealth.__codebayHealth ??= new Map());
17
+
18
+ async function codeServerAccessible(port: number): Promise<boolean> {
19
+ try {
20
+ // Any HTTP response at all (200/302/401/…) means the server is listening.
21
+ await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(2000) });
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ async function check(row: InstanceRow): Promise<InstanceHealth> {
29
+ const down: InstanceHealth = {
30
+ containerRunning: false,
31
+ codeServerAccessible: false,
32
+ injections: [],
33
+ openPorts: [],
34
+ checkedAt: Date.now()
35
+ };
36
+ if (!row.container_id || !(await isRunning(row.container_id))) return down;
37
+
38
+ // Probes as the recorded remote user, so `$HOME` resolves to the one the injection wrote to.
39
+ const target = { containerId: row.container_id, remoteUser: row.remote_user, instance: row };
40
+ const [accessible, openPorts, injectionResults] = await Promise.all([
41
+ codeServerAccessible(row.host_port),
42
+ publishedContainerPorts(row.container_id),
43
+ Promise.all(
44
+ resolveInjections()
45
+ .filter((i) => i.check)
46
+ .map(async (i) => ({
47
+ id: i.id,
48
+ label: i.label,
49
+ ok: await i.check!(target).catch(() => false)
50
+ }))
51
+ )
52
+ ]);
53
+ return {
54
+ containerRunning: true,
55
+ codeServerAccessible: accessible,
56
+ injections: injectionResults,
57
+ openPorts,
58
+ checkedAt: Date.now()
59
+ };
60
+ }
61
+
62
+ async function tick(row: InstanceRow): Promise<void> {
63
+ const snapshot = await check(row);
64
+ const mon = monitors.get(row.id);
65
+ if (mon) mon.snapshot = snapshot;
66
+ broadcastHealth(row.id, snapshot);
67
+ if (!snapshot.containerRunning) stopHealthMonitor(row.id);
68
+ }
69
+
70
+ function startHealthMonitor(row: InstanceRow): Monitor {
71
+ const existing = monitors.get(row.id);
72
+ if (existing) return existing;
73
+ const mon: Monitor = { snapshot: null, timer: setInterval(() => void tick(row), REFRESH_MS) };
74
+ monitors.set(row.id, mon);
75
+ void tick(row); // seed the first snapshot rather than making the UI wait a full interval
76
+ return mon;
77
+ }
78
+
79
+ export function stopHealthMonitor(id: string): void {
80
+ const mon = monitors.get(id);
81
+ if (!mon) return;
82
+ clearInterval(mon.timer);
83
+ monitors.delete(id);
84
+ }
85
+
86
+ /** Driven from the reconcile loop, so monitors track the container lifecycle automatically. */
87
+ export function syncHealthMonitors(rows: InstanceRow[]): void {
88
+ const running = new Set(
89
+ rows.filter((r) => r.status === 'running' && r.container_id).map((r) => r.id)
90
+ );
91
+ for (const row of rows) {
92
+ if (running.has(row.id) && !monitors.has(row.id)) startHealthMonitor(row);
93
+ }
94
+ for (const id of [...monitors.keys()]) {
95
+ if (!running.has(id)) stopHealthMonitor(id);
96
+ }
97
+ }
98
+
99
+ export function currentHealthSnapshots(): { id: string; health: InstanceHealth }[] {
100
+ const out: { id: string; health: InstanceHealth }[] = [];
101
+ for (const [id, mon] of monitors) {
102
+ if (mon.snapshot) out.push({ id, health: mon.snapshot });
103
+ }
104
+ return out;
105
+ }
@@ -0,0 +1,72 @@
1
+ import type { InstanceRow } from './db.server.ts';
2
+ import type { ExecTarget } from './exec.server.ts';
3
+ import { getOption } from './db.server.ts';
4
+ import { gitSafeDirectory } from '../container-injections/git-safe-directory.ts';
5
+ import { tmux } from '../container-injections/tmux.ts';
6
+ import { gitIdentity } from '../container-injections/git-identity.ts';
7
+ import { claudeCodeCredentials } from '../container-injections/claude-code-credentials.ts';
8
+ import { claudeCodeCustom } from '../container-injections/claude-code-custom.ts';
9
+ import { githubCredentials } from '../container-injections/github-credentials.ts';
10
+ import { attentionHooks } from '../container-injections/attention-hooks.ts';
11
+ import { claudeStatusline } from '../container-injections/claude-statusline.ts';
12
+ import { claudeSkipPermissions } from '../container-injections/claude-skip-permissions.ts';
13
+ import { claudeAliases } from '../container-injections/claude-aliases.ts';
14
+ import { claudeNoCoauthor } from '../container-injections/claude-no-coauthor.ts';
15
+ import { hostEnvVars } from '../container-injections/host-env-vars.ts';
16
+
17
+ /** Extends `ExecTarget` so exec-user semantics have a single source of truth. */
18
+ export interface ContainerTarget extends ExecTarget {
19
+ instance: InstanceRow;
20
+ }
21
+
22
+ /**
23
+ * Bundles host-side discovery, container `apply()`, and a live `check()` in one module,
24
+ * so what gets injected and what gets health-probed can never drift apart.
25
+ */
26
+ export interface Injection {
27
+ /** Doubles as the health-row key. */
28
+ id: string;
29
+ label: string;
30
+ /** Omit for injections with no host dependency — presence is what draws the setup-UI chip. */
31
+ auth?: {
32
+ /** Short instruction shown when unavailable, e.g. "run `gh auth login`". */
33
+ hint: string;
34
+ status(): Promise<{ available: boolean; source: string | null }>;
35
+ };
36
+ apply(target: ContainerTarget, log: (msg: string) => void): Promise<void>;
37
+ /** Omit to keep the injection out of the health list. */
38
+ check?(target: ContainerTarget): Promise<boolean>;
39
+ }
40
+
41
+ /** Split around the Claude slot, which `resolveInjections()` fills at call time. */
42
+ const BASE_INJECTIONS_HEAD: Injection[] = [
43
+ // Must stay first — every later git-touching injection depends on safe.directory.
44
+ gitSafeDirectory,
45
+ // The package install is the slowest injection, so start it before a folderOpen can find tmux missing.
46
+ tmux,
47
+ gitIdentity
48
+ ];
49
+
50
+ const BASE_INJECTIONS_TAIL: Injection[] = [
51
+ githubCredentials,
52
+ attentionHooks,
53
+ claudeStatusline,
54
+ claudeSkipPermissions,
55
+ claudeAliases,
56
+ claudeNoCoauthor,
57
+ hostEnvVars
58
+ ];
59
+
60
+ /** Resolved per call so toggling the custom-endpoint setting takes effect without a restart. */
61
+ export function resolveInjections(): Injection[] {
62
+ const claudeInjection =
63
+ getOption('custom_endpoint_enabled') === '1' ? claudeCodeCustom : claudeCodeCredentials;
64
+ return [...BASE_INJECTIONS_HEAD, claudeInjection, ...BASE_INJECTIONS_TAIL];
65
+ }
66
+
67
+ /** @deprecated Use `resolveInjections()`; this ignores settings and is kept for the tests. */
68
+ export const injections: Injection[] = [
69
+ ...BASE_INJECTIONS_HEAD,
70
+ claudeCodeCredentials,
71
+ ...BASE_INJECTIONS_TAIL
72
+ ];