codebay 0.9.0 → 0.9.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.
@@ -4,8 +4,8 @@
4
4
  "bootstrapUrl": "/_mochi/client/HydratableIsland-k6q8esj4.js",
5
5
  "componentEntryUrls": {
6
6
  "AppShell_2rtnxn6pgp2rh": "/_mochi/client/_hydrate-AppShell_2rtnxn6pgp2rh-sg96rp17.js",
7
- "AvatarsView_257s9yq1wwr4f": "/_mochi/client/_hydrate-AvatarsView_257s9yq1wwr4f-92hepzby.js",
8
7
  "InstanceView_2ma45l9bybfhl": "/_mochi/client/_hydrate-InstanceView_2ma45l9bybfhl-p3f649gk.js",
8
+ "AvatarsView_257s9yq1wwr4f": "/_mochi/client/_hydrate-AvatarsView_257s9yq1wwr4f-92hepzby.js",
9
9
  "SettingsView_8hmarwmf8onq": "/_mochi/client/_hydrate-SettingsView_8hmarwmf8onq-dzmgmv78.js"
10
10
  },
11
11
  "cssFileUrls": {
@@ -13,8 +13,8 @@
13
13
  "$mochi/templates/ClientStats/ClientStats.svelte": "/_mochi/css/ClientStats-19qzckq40p2q9.css",
14
14
  "$mochi/templates/BackLink.svelte": "/_mochi/css/BackLink-2wlu0eovz9j86.css",
15
15
  "src/components/AppShell.svelte": "/_mochi/css/AppShell-36mpg5smnbmuw.css",
16
- "src/components/AvatarsView.svelte": "/_mochi/css/AvatarsView-2llo48w0wt0xn.css",
17
16
  "src/components/InstanceView.svelte": "/_mochi/css/InstanceView-3vzow3ttxzlh3.css",
17
+ "src/components/AvatarsView.svelte": "/_mochi/css/AvatarsView-2llo48w0wt0xn.css",
18
18
  "src/components/SettingsView.svelte": "/_mochi/css/SettingsView-2qfauz68axro1.css",
19
19
  "src/components/TerminalSplit.svelte": "/_mochi/css/TerminalSplit-2416xfuln89a3.css",
20
20
  "src/components/DashboardView.svelte": "/_mochi/css/DashboardView-1cy4xpqhja9sx.css",
@@ -26,35 +26,35 @@
26
26
  "node_modules/svelte-french-toast/dist/components/LoaderIcon.svelte": "/_mochi/css/LoaderIcon-soq5wt3p05ye.css",
27
27
  "node_modules/svelte-french-toast/dist/components/ErrorIcon.svelte": "/_mochi/css/ErrorIcon-2x0tzsqxskw28.css",
28
28
  "node_modules/svelte-french-toast/dist/components/ToastBar.svelte": "/_mochi/css/ToastBar-3havgkmtofntq.css",
29
- "src/components/Avatar.svelte": "/_mochi/css/Avatar-3pi30db6ozvf0.css",
30
- "src/components/AvatarEditor.svelte": "/_mochi/css/AvatarEditor-2mdq8ss346drw.css",
31
- "src/components/AppBar.svelte": "/_mochi/css/AppBar-3bxinv4pabv8l.css",
32
29
  "src/components/Skeleton.svelte": "/_mochi/css/Skeleton-8k69usiju43h.css",
33
30
  "src/components/StatusBadge.svelte": "/_mochi/css/StatusBadge-3mq0j7z6aq4j.css",
34
31
  "src/components/HealthBox.svelte": "/_mochi/css/HealthBox-er2her0jukwl.css",
32
+ "src/components/AvatarEditor.svelte": "/_mochi/css/AvatarEditor-2mdq8ss346drw.css",
33
+ "src/components/AppBar.svelte": "/_mochi/css/AppBar-3bxinv4pabv8l.css",
34
+ "src/components/Avatar.svelte": "/_mochi/css/Avatar-3pi30db6ozvf0.css",
35
35
  "src/components/Button.svelte": "/_mochi/css/Button-2vtbrzb5kgu25.css",
36
36
  "src/components/ThemePicker.svelte": "/_mochi/css/ThemePicker-1ir8jyocm8mvi.css",
37
37
  "src/components/CoinButton.svelte": "/_mochi/css/CoinButton-1rej3sh3tkjud.css",
38
- "src/components/TerminalPane.svelte": "/_mochi/css/TerminalPane-3bwgk6ulzq6b3.css",
38
+ "src/components/FolderBrowser.svelte": "/_mochi/css/FolderBrowser-2pxp9z1o7zixk.css",
39
39
  "src/components/TopBar.svelte": "/_mochi/css/TopBar-2q87li6yt8ids.css",
40
40
  "src/components/InstanceCard.svelte": "/_mochi/css/InstanceCard-3vnltxnbxkvay.css",
41
- "src/components/FolderBrowser.svelte": "/_mochi/css/FolderBrowser-2pxp9z1o7zixk.css",
41
+ "src/components/TerminalPane.svelte": "/_mochi/css/TerminalPane-3bwgk6ulzq6b3.css",
42
42
  "node_modules/svelte-french-toast/dist/components/ToastMessage.svelte": "/_mochi/css/ToastMessage-2jfwb2og2lfa8.css",
43
43
  "node_modules/svelte-french-toast/dist/components/ToastWrapper.svelte": "/_mochi/css/ToastWrapper-1fbnym10rm2nv.css",
44
44
  "src/components/Brand.svelte": "/_mochi/css/Brand-1bh2b800g5vrn.css",
45
+ "src/components/SettingsCog.svelte": "/_mochi/css/SettingsCog-jftyadvo64qo.css",
46
+ "src/components/CredMenu.svelte": "/_mochi/css/CredMenu-9hwgeptp7crb.css",
45
47
  "src/components/InstanceFilter.svelte": "/_mochi/css/InstanceFilter-3q6x0jt0hcgpz.css",
46
- "src/components/PortsBox.svelte": "/_mochi/css/PortsBox-1t25b5yg7jva3.css",
47
48
  "src/components/BranchBox.svelte": "/_mochi/css/BranchBox-1yqu1q2mgnjwz.css",
48
- "src/components/SettingsCog.svelte": "/_mochi/css/SettingsCog-jftyadvo64qo.css",
49
- "src/components/CredMenu.svelte": "/_mochi/css/CredMenu-9hwgeptp7crb.css"
49
+ "src/components/PortsBox.svelte": "/_mochi/css/PortsBox-1t25b5yg7jva3.css"
50
50
  },
51
51
  "clientFiles": {
52
52
  "/_mochi/css/DefaultError-2b0daamydxgb2.css": "svelte-css/DefaultError-2b0daamydxgb2.css",
53
53
  "/_mochi/css/ClientStats-19qzckq40p2q9.css": "svelte-css/ClientStats-19qzckq40p2q9.css",
54
54
  "/_mochi/css/BackLink-2wlu0eovz9j86.css": "svelte-css/BackLink-2wlu0eovz9j86.css",
55
55
  "/_mochi/css/AppShell-36mpg5smnbmuw.css": "svelte-css/AppShell-36mpg5smnbmuw.css",
56
- "/_mochi/css/AvatarsView-2llo48w0wt0xn.css": "svelte-css/AvatarsView-2llo48w0wt0xn.css",
57
56
  "/_mochi/css/InstanceView-3vzow3ttxzlh3.css": "svelte-css/InstanceView-3vzow3ttxzlh3.css",
57
+ "/_mochi/css/AvatarsView-2llo48w0wt0xn.css": "svelte-css/AvatarsView-2llo48w0wt0xn.css",
58
58
  "/_mochi/css/SettingsView-2qfauz68axro1.css": "svelte-css/SettingsView-2qfauz68axro1.css",
59
59
  "/_mochi/css/TerminalSplit-2416xfuln89a3.css": "svelte-css/TerminalSplit-2416xfuln89a3.css",
60
60
  "/_mochi/css/DashboardView-1cy4xpqhja9sx.css": "svelte-css/DashboardView-1cy4xpqhja9sx.css",
@@ -66,27 +66,27 @@
66
66
  "/_mochi/css/LoaderIcon-soq5wt3p05ye.css": "svelte-css/LoaderIcon-soq5wt3p05ye.css",
67
67
  "/_mochi/css/ErrorIcon-2x0tzsqxskw28.css": "svelte-css/ErrorIcon-2x0tzsqxskw28.css",
68
68
  "/_mochi/css/ToastBar-3havgkmtofntq.css": "svelte-css/ToastBar-3havgkmtofntq.css",
69
- "/_mochi/css/Avatar-3pi30db6ozvf0.css": "svelte-css/Avatar-3pi30db6ozvf0.css",
70
- "/_mochi/css/AvatarEditor-2mdq8ss346drw.css": "svelte-css/AvatarEditor-2mdq8ss346drw.css",
71
- "/_mochi/css/AppBar-3bxinv4pabv8l.css": "svelte-css/AppBar-3bxinv4pabv8l.css",
72
69
  "/_mochi/css/Skeleton-8k69usiju43h.css": "svelte-css/Skeleton-8k69usiju43h.css",
73
70
  "/_mochi/css/StatusBadge-3mq0j7z6aq4j.css": "svelte-css/StatusBadge-3mq0j7z6aq4j.css",
74
71
  "/_mochi/css/HealthBox-er2her0jukwl.css": "svelte-css/HealthBox-er2her0jukwl.css",
72
+ "/_mochi/css/AvatarEditor-2mdq8ss346drw.css": "svelte-css/AvatarEditor-2mdq8ss346drw.css",
73
+ "/_mochi/css/AppBar-3bxinv4pabv8l.css": "svelte-css/AppBar-3bxinv4pabv8l.css",
74
+ "/_mochi/css/Avatar-3pi30db6ozvf0.css": "svelte-css/Avatar-3pi30db6ozvf0.css",
75
75
  "/_mochi/css/Button-2vtbrzb5kgu25.css": "svelte-css/Button-2vtbrzb5kgu25.css",
76
76
  "/_mochi/css/ThemePicker-1ir8jyocm8mvi.css": "svelte-css/ThemePicker-1ir8jyocm8mvi.css",
77
77
  "/_mochi/css/CoinButton-1rej3sh3tkjud.css": "svelte-css/CoinButton-1rej3sh3tkjud.css",
78
- "/_mochi/css/TerminalPane-3bwgk6ulzq6b3.css": "svelte-css/TerminalPane-3bwgk6ulzq6b3.css",
78
+ "/_mochi/css/FolderBrowser-2pxp9z1o7zixk.css": "svelte-css/FolderBrowser-2pxp9z1o7zixk.css",
79
79
  "/_mochi/css/TopBar-2q87li6yt8ids.css": "svelte-css/TopBar-2q87li6yt8ids.css",
80
80
  "/_mochi/css/InstanceCard-3vnltxnbxkvay.css": "svelte-css/InstanceCard-3vnltxnbxkvay.css",
81
- "/_mochi/css/FolderBrowser-2pxp9z1o7zixk.css": "svelte-css/FolderBrowser-2pxp9z1o7zixk.css",
81
+ "/_mochi/css/TerminalPane-3bwgk6ulzq6b3.css": "svelte-css/TerminalPane-3bwgk6ulzq6b3.css",
82
82
  "/_mochi/css/ToastMessage-2jfwb2og2lfa8.css": "svelte-css/ToastMessage-2jfwb2og2lfa8.css",
83
83
  "/_mochi/css/ToastWrapper-1fbnym10rm2nv.css": "svelte-css/ToastWrapper-1fbnym10rm2nv.css",
84
84
  "/_mochi/css/Brand-1bh2b800g5vrn.css": "svelte-css/Brand-1bh2b800g5vrn.css",
85
- "/_mochi/css/InstanceFilter-3q6x0jt0hcgpz.css": "svelte-css/InstanceFilter-3q6x0jt0hcgpz.css",
86
- "/_mochi/css/PortsBox-1t25b5yg7jva3.css": "svelte-css/PortsBox-1t25b5yg7jva3.css",
87
- "/_mochi/css/BranchBox-1yqu1q2mgnjwz.css": "svelte-css/BranchBox-1yqu1q2mgnjwz.css",
88
85
  "/_mochi/css/SettingsCog-jftyadvo64qo.css": "svelte-css/SettingsCog-jftyadvo64qo.css",
89
86
  "/_mochi/css/CredMenu-9hwgeptp7crb.css": "svelte-css/CredMenu-9hwgeptp7crb.css",
87
+ "/_mochi/css/InstanceFilter-3q6x0jt0hcgpz.css": "svelte-css/InstanceFilter-3q6x0jt0hcgpz.css",
88
+ "/_mochi/css/BranchBox-1yqu1q2mgnjwz.css": "svelte-css/BranchBox-1yqu1q2mgnjwz.css",
89
+ "/_mochi/css/PortsBox-1t25b5yg7jva3.css": "svelte-css/PortsBox-1t25b5yg7jva3.css",
90
90
  "/_mochi/import-css/index-0nwv3654.css": "import-css/index-0nwv3654.css",
91
91
  "/_mochi/import-css/index-xndx4980.css": "import-css/index-xndx4980.css",
92
92
  "/_mochi/import-css/xterm-1yy1hf1g.css": "import-css/xterm-1yy1hf1g.css",
@@ -99,8 +99,8 @@
99
99
  "/_mochi/client/chunk-v65n8th6.js": "svelte-client/chunk-v65n8th6.js",
100
100
  "/_mochi/client/chunk-rs41by6w.js": "svelte-client/chunk-rs41by6w.js",
101
101
  "/_mochi/client/_hydrate-AppShell_2rtnxn6pgp2rh-sg96rp17.js": "svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-sg96rp17.js",
102
- "/_mochi/client/_hydrate-AvatarsView_257s9yq1wwr4f-92hepzby.js": "svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-92hepzby.js",
103
102
  "/_mochi/client/_hydrate-InstanceView_2ma45l9bybfhl-p3f649gk.js": "svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-p3f649gk.js",
103
+ "/_mochi/client/_hydrate-AvatarsView_257s9yq1wwr4f-92hepzby.js": "svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-92hepzby.js",
104
104
  "/_mochi/client/_hydrate-SettingsView_8hmarwmf8onq-dzmgmv78.js": "svelte-client/_hydrate-SettingsView_8hmarwmf8onq-dzmgmv78.js",
105
105
  "/_mochi/client/chunk-mpv3qx28.js": "svelte-client/chunk-mpv3qx28.js",
106
106
  "/_mochi/client/chunk-2j95sv83.js": "svelte-client/chunk-2j95sv83.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebay",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Web UI for spinning up isolated devcontainer instances from any local folder, each with a browser-based VS Code (code-server) — built for parallel Claude Code workflows.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,67 @@
1
+ import { checkPresence, execInContainer } from '../lib/exec.server.ts';
2
+ import type { Injection } from '../lib/injections.server.ts';
3
+
4
+ /**
5
+ * Sniffs before installing, because a project-supplied image usually ships its own Claude Code —
6
+ * and because the alternative (the upstream node feature) installs via nvm, which hard-fails on
7
+ * any image that sets NPM_CONFIG_PREFIX. Neither branch here touches nvm: npm when the image has
8
+ * Node, else the standalone installer, which needs no Node at all.
9
+ *
10
+ * `$1` is the remote user (the injection passes it; the build-time feature falls back to the
11
+ * devcontainer CLI's `$_REMOTE_USER`) — the standalone installer puts the binary under that
12
+ * user's `$HOME`, so running it as root would strand it in `/root`.
13
+ */
14
+ export const INSTALL_SCRIPT =
15
+ 'if command -v claude >/dev/null 2>&1; then exit 0; fi\n' +
16
+ 'if command -v npm >/dev/null 2>&1 && npm install -g @anthropic-ai/claude-code@latest >/dev/null 2>&1; then\n' +
17
+ ' p="$(npm prefix -g 2>/dev/null)"\n' +
18
+ // npm's global bin is off PATH whenever the image sets its own NPM_CONFIG_PREFIX, so a bare
19
+ // `command -v` would miss an install that just succeeded — and so would the terminal launcher.
20
+ ' [ -n "$p" ] && [ -x "$p/bin/claude" ] && ln -sf "$p/bin/claude" /usr/local/bin/claude 2>/dev/null\n' +
21
+ ' if command -v claude >/dev/null 2>&1 || [ -x "$p/bin/claude" ]; then exit 0; fi\n' +
22
+ 'fi\n' +
23
+ 'u="${1:-${_REMOTE_USER:-root}}"\n' +
24
+ 'h="$(getent passwd "$u" 2>/dev/null | cut -d: -f6)"; [ -n "$h" ] || h="$HOME"\n' +
25
+ 'f="$(mktemp)"\n' +
26
+ 'if command -v curl >/dev/null 2>&1; then curl -fsSL https://claude.ai/install.sh -o "$f"\n' +
27
+ 'elif command -v wget >/dev/null 2>&1; then wget -qO "$f" https://claude.ai/install.sh\n' +
28
+ 'else echo "no curl/wget to fetch the Claude Code installer" >&2; exit 1; fi\n' +
29
+ 'chmod 0755 "$f"\n' +
30
+ // -m keeps the HOME set here, so the installer targets the remote user's home, not root's.
31
+ 'if [ "$(id -un)" = "$u" ]; then "$f" >/dev/null 2>&1\n' +
32
+ 'else HOME="$h" su -m "$u" -c "\'$f\'" >/dev/null 2>&1; fi\n' +
33
+ 'rm -f "$f"\n' +
34
+ // The installer's ~/.local/bin is on no other user's PATH, and often not even on its own.
35
+ 'ln -sf "$h/.local/bin/claude" /usr/local/bin/claude 2>/dev/null || true\n' +
36
+ 'command -v claude >/dev/null 2>&1 || [ -x "$h/.local/bin/claude" ]\n';
37
+
38
+ const CHECK_SCRIPT = 'command -v claude >/dev/null 2>&1 && echo 1 || echo 0';
39
+
40
+ /**
41
+ * The runtime fallback to the build-time `codebay-claude` feature, mirroring tmux/ttyd. Terminal
42
+ * mode only: there the launcher *is* `claude`, so a missing binary leaves a bare shell — IDE-mode
43
+ * instances on a project image keep owning their own tooling.
44
+ */
45
+ export const claudeCodeInstall: Injection = {
46
+ id: 'claude-code-install',
47
+ label: 'Claude Code',
48
+ modes: ['terminal'],
49
+
50
+ async apply(target, log) {
51
+ log('Checking Claude Code is installed…\n');
52
+ // Omitting remoteUser runs as root, which the global npm install and the symlink need.
53
+ const install = await execInContainer(
54
+ { containerId: target.containerId },
55
+ { script: INSTALL_SCRIPT, args: ['claude-install', target.remoteUser ?? ''] }
56
+ );
57
+ log(
58
+ install.ok
59
+ ? '✓ Claude Code available\n'
60
+ : `⚠ Claude Code install failed: ${install.error} — the terminal opens a plain shell\n`
61
+ );
62
+ },
63
+
64
+ async check(target) {
65
+ return checkPresence(target, CHECK_SCRIPT);
66
+ }
67
+ };
@@ -4,6 +4,7 @@ import { basename, dirname, join, relative } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { INSTALL_SCRIPT as TMUX_INSTALL_SCRIPT } from '../container-injections/tmux.ts';
6
6
  import { INSTALL_SCRIPT as TTYD_INSTALL_SCRIPT } from '../container-injections/ttyd.ts';
7
+ import { INSTALL_SCRIPT as CLAUDE_INSTALL_SCRIPT } from '../container-injections/claude-code-install.ts';
7
8
  import { EXTENSION_ID as CLAUDE_CODE_EXTENSION_ID } from '../container-injections/claude-code-ide-extension.ts';
8
9
  import {
9
10
  CODE_SERVER_PORT,
@@ -19,7 +20,11 @@ import type { PortForward } from '../types.ts';
19
20
 
20
21
  const CODE_SERVER_FEATURE = 'ghcr.io/coder/devcontainer-features/code-server:1';
21
22
 
22
- /** Installs the Claude Code CLI into the default image. Needs Node — supplied by NODE_FEATURE. */
23
+ /**
24
+ * Installs the Claude Code CLI into the default image. Needs Node — supplied by NODE_FEATURE.
25
+ * Default-image only: NODE_FEATURE installs via nvm, which refuses to run when the image sets
26
+ * NPM_CONFIG_PREFIX, so a project-supplied image gets the sniffing CLAUDE_FEATURE_DIR instead.
27
+ */
23
28
  const CLAUDE_CODE_FEATURE = 'ghcr.io/anthropics/devcontainer-features/claude-code:1.0';
24
29
 
25
30
  /** Node.js — required by the Claude Code feature, which no longer bundles it. */
@@ -71,12 +76,32 @@ const TTYD_FEATURE_INSTALL =
71
76
  ') || echo "codebay-ttyd: install failed (non-fatal); the manager retries after the container starts"\n' +
72
77
  'exit 0\n';
73
78
 
79
+ /** Relative to .devcontainer/. Only written for terminal-mode instances on a project-supplied config. */
80
+ const CLAUDE_FEATURE_DIR = 'codebay-claude';
81
+
82
+ const CLAUDE_FEATURE_METADATA = {
83
+ id: 'codebay-claude',
84
+ version: '1.0.0',
85
+ name: 'Claude Code (Codebay, best-effort)',
86
+ description:
87
+ "Installs Claude Code at image build time when the project's own image doesn't already ship it. Never fails the build."
88
+ };
89
+
90
+ /** Best-effort like tmux/ttyd: build time is the only reliable moment to fetch in an egress-firewalled container. */
91
+ const CLAUDE_FEATURE_INSTALL =
92
+ '#!/bin/sh\n' +
93
+ '(\n' +
94
+ `${CLAUDE_INSTALL_SCRIPT}\n` +
95
+ ') || echo "codebay-claude: install failed (non-fatal); the manager retries after the container starts"\n' +
96
+ 'exit 0\n';
97
+
74
98
  /** Manager-dropped files the project's own .gitignore won't cover, kept out of git status. */
75
99
  const MANAGER_GIT_EXCLUDES = [
76
100
  '/.devcontainer/code-server-settings.json',
77
101
  '/.devcontainer/devcontainer-lock.json',
78
102
  '/.devcontainer/codebay-tmux/',
79
103
  '/.devcontainer/codebay-ttyd/',
104
+ '/.devcontainer/codebay-claude/',
80
105
  '/.devcontainer/codebay-terminal.sh',
81
106
  '/.vscode/tasks.json'
82
107
  ];
@@ -413,18 +438,26 @@ export async function writeOverrideConfig(
413
438
  `./${relative(dirname(target), join(workspaceDir, '.devcontainer', dir))}`;
414
439
  const tmuxFeatureKey = featureKey(TMUX_FEATURE_DIR);
415
440
  const ttydFeatureKey = featureKey(TTYD_FEATURE_DIR);
441
+ const claudeFeatureKey = featureKey(CLAUDE_FEATURE_DIR);
416
442
 
417
443
  // Terminal mode swaps code-server for ttyd; otherwise (tmux, tooling) the two are identical.
418
- // Node/Claude/gh: always in terminal mode (its launcher *is* `claude`), else only the default image.
444
+ // Claude Code: the default image needs the upstream features, while terminal mode on a
445
+ // project-supplied image gets the local one, which installs only what that image lacks — the
446
+ // upstream Node feature would run nvm, which any image setting NPM_CONFIG_PREFIX breaks on.
447
+ // IDE mode on a project config stays hands-off: the project owns its tooling.
448
+ const needsClaude = isTerminal || !hadConfig;
419
449
  config.features = {
420
450
  ...(config.features ?? {}),
421
451
  ...(isTerminal
422
452
  ? { [ttydFeatureKey]: {} }
423
453
  : { [CODE_SERVER_FEATURE]: { host: '0.0.0.0', port: CODE_SERVER_PORT, auth: 'none' } }),
424
454
  [tmuxFeatureKey]: {},
425
- ...(isTerminal || !hadConfig
426
- ? { [NODE_FEATURE]: {}, [CLAUDE_CODE_FEATURE]: {}, [GITHUB_CLI_FEATURE]: {} }
427
- : {})
455
+ ...(needsClaude ? { [GITHUB_CLI_FEATURE]: {} } : {}),
456
+ ...(!hadConfig
457
+ ? { [NODE_FEATURE]: {}, [CLAUDE_CODE_FEATURE]: {} }
458
+ : isTerminal
459
+ ? { [claudeFeatureKey]: {} }
460
+ : {})
428
461
  };
429
462
 
430
463
  const servedPort = isTerminal ? TTYD_PORT : CODE_SERVER_PORT;
@@ -465,6 +498,8 @@ export async function writeOverrideConfig(
465
498
 
466
499
  await writeTmuxFeature(workspaceDir);
467
500
 
501
+ if (isTerminal && hadConfig) await writeClaudeFeature(workspaceDir);
502
+
468
503
  await writeLocalGitExclude(workspaceDir);
469
504
 
470
505
  return { imageSource };
@@ -554,6 +589,19 @@ async function writeTmuxFeature(workspaceDir: string): Promise<void> {
554
589
  await chmod(installPath, 0o755);
555
590
  }
556
591
 
592
+ async function writeClaudeFeature(workspaceDir: string): Promise<void> {
593
+ const dir = join(workspaceDir, '.devcontainer', CLAUDE_FEATURE_DIR);
594
+ await mkdir(dir, { recursive: true }).catch(() => {});
595
+ await writeFile(
596
+ join(dir, 'devcontainer-feature.json'),
597
+ JSON.stringify(CLAUDE_FEATURE_METADATA, null, 2) + '\n',
598
+ 'utf8'
599
+ );
600
+ const installPath = join(dir, 'install.sh');
601
+ await writeFile(installPath, CLAUDE_FEATURE_INSTALL, 'utf8');
602
+ await chmod(installPath, 0o755);
603
+ }
604
+
557
605
  async function writeTtydFeature(workspaceDir: string): Promise<void> {
558
606
  const dir = join(workspaceDir, '.devcontainer', TTYD_FEATURE_DIR);
559
607
  await mkdir(dir, { recursive: true }).catch(() => {});
@@ -8,6 +8,7 @@ import { gitIdentity } from '../container-injections/git-identity.ts';
8
8
  import { claudeCodeCredentials } from '../container-injections/claude-code-credentials.ts';
9
9
  import { claudeCodeCustom } from '../container-injections/claude-code-custom.ts';
10
10
  import { claudeCodeIdeExtension } from '../container-injections/claude-code-ide-extension.ts';
11
+ import { claudeCodeInstall } from '../container-injections/claude-code-install.ts';
11
12
  import { claudeCodeUpdate } from '../container-injections/claude-code-update.ts';
12
13
  import { claudeCodeModels } from '../container-injections/claude-code-models.ts';
13
14
  import { githubCredentials } from '../container-injections/github-credentials.ts';
@@ -54,6 +55,8 @@ const BASE_INJECTIONS_HEAD: Injection[] = [
54
55
  tmux,
55
56
  // Terminal-mode only (filtered by resolveInjections); a download, so run it early like tmux.
56
57
  ttyd,
58
+ // Terminal-mode only, and ahead of every claude-* step in the tail, which all assume a binary.
59
+ claudeCodeInstall,
57
60
  gitIdentity
58
61
  ];
59
62
 
@@ -524,6 +524,10 @@ export async function addForwardedPort(id: string, containerPort: number): Promi
524
524
  if (!Number.isInteger(containerPort) || containerPort < 1 || containerPort > 65535) {
525
525
  throw new Error('Port must be an integer between 1 and 65535');
526
526
  }
527
+ // Forwards publish on PUBLISH_HOST (0.0.0.0 under HOST=0.0.0.0), so forwarding the port the
528
+ // instance is actually served on would republish code-server/ttyd — neither of which has auth
529
+ // of its own — outside the Basic-Auth-gated proxy. Scoped to the live surface on purpose: in
530
+ // terminal mode nothing listens on 8080, and it's far too common an app port to reserve.
527
531
  const reserved = row.mode === 'terminal' ? TTYD_PORT : CODE_SERVER_PORT;
528
532
  if (containerPort === reserved) {
529
533
  throw new Error(
@@ -50,33 +50,17 @@ interface RelayState {
50
50
  client?: WebSocket;
51
51
  /** Frames received from the browser before the upstream socket is open. */
52
52
  pending: Frame[];
53
- /** Frames the upstream sent before the browser socket existed (connect-first path only). */
54
- inbound?: Frame[];
55
53
  /** The client's requested subprotocols, forwarded upstream — ttyd requires its `tty` one. */
56
54
  subprotocols?: string[];
57
55
  }
58
56
 
59
- /** Opens the upstream socket and buffers anything it emits until the browser socket is wired up. */
60
- function connectUpstream(
61
- url: string,
62
- subprotocols: string[]
63
- ): Promise<{ client: WebSocket; inbound: Frame[] }> {
64
- return new Promise((resolve, reject) => {
65
- const client = new WebSocket(url, subprotocols);
66
- client.binaryType = 'arraybuffer';
67
- const inbound: Frame[] = [];
68
- client.onmessage = (e) => inbound.push(e.data as Frame);
69
- client.onopen = () => resolve({ client, inbound });
70
- client.onerror = () => reject(new Error('upstream websocket error'));
71
- });
72
- }
73
-
74
57
  async function proxyHttp(event: MochiApiEvent, port: number, rest: string): Promise<Response> {
75
58
  const headers = new Headers(event.request.headers);
76
59
  headers.set('host', `127.0.0.1:${port}`);
77
60
  headers.delete('accept-encoding'); // ask for an unencoded body so we can stream it verbatim
78
61
  // code-server runs with `--auth none`, so forwarding this would only leak the app password.
79
- // `cookie` is deliberately kept: the manager sets none, so every cookie here is code-server's.
62
+ // `cookie` is deliberately kept: the manager's only same-origin cookie is the UI theme, so
63
+ // anything else here belongs to the upstream and has to survive the round trip.
80
64
  headers.delete('authorization');
81
65
  const hasBody = event.method !== 'GET' && event.method !== 'HEAD';
82
66
  let upstream: Response;
@@ -110,7 +94,7 @@ async function proxyHttp(event: MochiApiEvent, port: number, rest: string): Prom
110
94
  });
111
95
  }
112
96
 
113
- async function proxyUpgrade(event: MochiApiEvent, port: number, rest: string): Promise<Response> {
97
+ function proxyUpgrade(event: MochiApiEvent, port: number, rest: string): Response {
114
98
  const requested = event.request.headers.get('sec-websocket-protocol');
115
99
  const subprotocols = requested
116
100
  ? requested
@@ -118,32 +102,18 @@ async function proxyUpgrade(event: MochiApiEvent, port: number, rest: string): P
118
102
  .map((s) => s.trim())
119
103
  .filter(Boolean)
120
104
  : undefined;
121
- const upstreamWsUrl = `ws://127.0.0.1:${port}${rest}${event.url.search}`;
122
-
123
- const state: RelayState = { upstreamWsUrl, pending: [], subprotocols };
124
- let selected: string | undefined;
125
-
126
- // When subprotocols are in play, open the upstream first so the 101 echoes the one it actually
127
- // negotiated instead of guessing its first offer — a mismatch would leave the browser and
128
- // upstream disagreeing on the protocol. code-server offers none and connects lazily in open().
129
- if (subprotocols) {
130
- try {
131
- const { client, inbound } = await connectUpstream(upstreamWsUrl, subprotocols);
132
- state.client = client;
133
- state.inbound = inbound;
134
- selected = client.protocol || undefined;
135
- } catch {
136
- return new Response('Upstream WebSocket unreachable', { status: 502 });
137
- }
138
- }
139
-
140
105
  const data: MochiWsData<RelayState> = {
141
106
  __mochiRoutePattern: PROXY_WS_PATTERN,
142
107
  __mochiOpenedAt: performance.now(),
143
108
  __mochiPath: event.url.pathname,
144
- user: state
109
+ user: {
110
+ upstreamWsUrl: `ws://127.0.0.1:${port}${rest}${event.url.search}`,
111
+ pending: [],
112
+ subprotocols
113
+ }
145
114
  };
146
- const headers = selected ? { 'Sec-WebSocket-Protocol': selected } : undefined;
115
+ // Echo the first offered subprotocol back so the browser's handshake confirms it (ttyd offers `tty`).
116
+ const headers = subprotocols?.[0] ? { 'Sec-WebSocket-Protocol': subprotocols[0] } : undefined;
147
117
  // Bun's Server is typed with a fixed WebSocketData; Mochi casts the same way for its own upgrades.
148
118
  const ok = (
149
119
  event.server as unknown as {
@@ -153,10 +123,7 @@ async function proxyUpgrade(event: MochiApiEvent, port: number, rest: string): P
153
123
  ) => boolean;
154
124
  }
155
125
  ).upgrade(event.request, { data, headers });
156
- if (!ok) {
157
- if (state.client) safeClose(state.client, 1000, ''); // don't orphan the pre-opened upstream
158
- return new Response('WebSocket upgrade failed', { status: 426 });
159
- }
126
+ if (!ok) return new Response('WebSocket upgrade failed', { status: 426 });
160
127
  // Bun already sent the real 101; this is ignored, but Mochi's resolve chain reads a status.
161
128
  return new Response(null, { status: 101 });
162
129
  }
@@ -179,7 +146,7 @@ export const proxyRoutes: Record<string, MochiRouteValue> = {
179
146
 
180
147
  // An upgrade is a GET carrying `Upgrade: websocket`, so Bun routes it here too.
181
148
  if (event.request.headers.get('upgrade')?.toLowerCase() === 'websocket') {
182
- return await proxyUpgrade(event, port, rest);
149
+ return proxyUpgrade(event, port, rest);
183
150
  }
184
151
  return proxyHttp(event, port, rest);
185
152
  }),
@@ -189,27 +156,16 @@ export const proxyRoutes: Record<string, MochiRouteValue> = {
189
156
  upgrade: () => false,
190
157
  open(ws: ServerWebSocket<MochiWsData<RelayState>>) {
191
158
  const state = ws.data.user;
192
- // Pre-opened during the handshake when a subprotocol was negotiated; otherwise connect now.
193
- const client =
194
- state.client ??
195
- (state.subprotocols
196
- ? new WebSocket(state.upstreamWsUrl, state.subprotocols)
197
- : new WebSocket(state.upstreamWsUrl));
159
+ const client = state.subprotocols
160
+ ? new WebSocket(state.upstreamWsUrl, state.subprotocols)
161
+ : new WebSocket(state.upstreamWsUrl);
198
162
  client.binaryType = 'arraybuffer';
199
163
  state.client = client;
200
164
 
201
- // Drain whatever the upstream sent before this socket existed (connect-first path only).
202
- if (state.inbound) {
203
- for (const frame of state.inbound) {
204
- try {
205
- ws.send(frame as never);
206
- } catch {
207
- /* browser socket closed */
208
- }
209
- }
210
- state.inbound = undefined;
211
- }
212
-
165
+ client.onopen = () => {
166
+ for (const frame of state.pending) sendFrame(client, frame);
167
+ state.pending = [];
168
+ };
213
169
  client.onmessage = (e) => {
214
170
  try {
215
171
  ws.send(e.data);
@@ -219,17 +175,6 @@ export const proxyRoutes: Record<string, MochiRouteValue> = {
219
175
  };
220
176
  client.onclose = (e) => safeClose(ws, e.code, e.reason);
221
177
  client.onerror = () => safeClose(ws, 1011, 'upstream error');
222
-
223
- // A pre-opened socket already fired `onopen`, so flush now; a lazy one flushes on open.
224
- if (client.readyState === WebSocket.OPEN) {
225
- for (const frame of state.pending) sendFrame(client, frame);
226
- state.pending = [];
227
- } else {
228
- client.onopen = () => {
229
- for (const frame of state.pending) sendFrame(client, frame);
230
- state.pending = [];
231
- };
232
- }
233
178
  },
234
179
  message(ws: ServerWebSocket<MochiWsData<RelayState>>, message) {
235
180
  const state = ws.data.user;