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.
package/.mochi/manifest.json
CHANGED
|
@@ -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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
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
|
-
...(
|
|
426
|
-
|
|
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(
|
package/src/lib/proxy.server.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
109
|
+
user: {
|
|
110
|
+
upstreamWsUrl: `ws://127.0.0.1:${port}${rest}${event.url.search}`,
|
|
111
|
+
pending: [],
|
|
112
|
+
subprotocols
|
|
113
|
+
}
|
|
145
114
|
};
|
|
146
|
-
|
|
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
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
state.
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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;
|