codebay 0.8.0 → 0.9.0
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/import-css/xterm-1yy1hf1g.css +252 -0
- package/.mochi/manifest.json +330 -211
- package/.mochi/svelte-client/HydratableIsland-k6q8esj4.js +1 -0
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-sg96rp17.js +2 -0
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-92hepzby.js +14 -0
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-p3f649gk.js +1 -0
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-dzmgmv78.js +29 -0
- package/.mochi/svelte-client/chunk-2j95sv83.js +1 -0
- package/.mochi/svelte-client/chunk-6rbs0mhx.js +1 -0
- package/.mochi/svelte-client/chunk-7wkvn2vx.js +2 -0
- package/.mochi/svelte-client/chunk-a2psjmrm.js +2 -0
- package/.mochi/svelte-client/chunk-m497q88s.js +2 -0
- package/.mochi/svelte-client/{chunk-fqg2qzj8.js → chunk-m4fzsd5b.js} +1 -1
- package/.mochi/svelte-client/chunk-mpv3qx28.js +36 -0
- package/.mochi/svelte-client/chunk-rs41by6w.js +2 -0
- package/.mochi/svelte-client/{chunk-qj7zy2d9.js → chunk-v65n8th6.js} +4 -4
- package/.mochi/svelte-compile/{App-6rcf0d74.server.js → App-7vjcp38v.server.js} +368 -37
- package/.mochi/svelte-compile/{Avatars-pzyz0tv3.server.js → Avatars-e2cfc95t.server.js} +130 -25
- package/.mochi/svelte-compile/{ClientStats-z3tn9ad6.server.js → ClientStats-4cphmrnp.server.js} +1 -1
- package/.mochi/svelte-compile/{DefaultError-wh77vebf.server.js → DefaultError-je52k17d.server.js} +1 -1
- package/.mochi/svelte-compile/{Instance-49ksrp11.server.js → Instance-h78n2dsv.server.js} +16 -13
- package/.mochi/svelte-compile/{Settings-sz5d6qj7.server.js → Settings-tav2asn7.server.js} +153 -127
- package/.mochi/svelte-compile/{chunk-fpe16ywq.js → chunk-60mpagr9.js} +17 -7
- package/.mochi/svelte-compile/chunk-9nqc2r0y.js +9706 -0
- package/.mochi/svelte-compile/{chunk-6ghzacpg.js → chunk-bpqwf1nw.js} +36 -4
- package/.mochi/svelte-compile/chunk-jx6h1xzp.js +20 -0
- package/.mochi/svelte-compile/{chunk-q8rbesaw.js → chunk-m8ygpyhr.js} +5 -4
- package/.mochi/svelte-compile/chunk-rtdne1m8.js +31 -0
- package/.mochi/svelte-compile/{chunk-we0ggtws.js → chunk-xpt1a7jm.js} +2 -1
- package/.mochi/svelte-css/AvatarEditor-2mdq8ss346drw.css +1 -0
- package/.mochi/svelte-css/{AvatarsView-2ughbzc26luyb.css → AvatarsView-2llo48w0wt0xn.css} +1 -1
- package/.mochi/svelte-css/FolderBrowser-2pxp9z1o7zixk.css +1 -0
- package/.mochi/svelte-css/{IdeBar-2qoqi2ya9zdn3.css → IdeBar-10xodudcx57qr.css} +1 -1
- package/.mochi/svelte-css/{SettingsView-3uycxa8z548m4.css → SettingsView-2qfauz68axro1.css} +1 -1
- package/.mochi/svelte-css/TerminalPane-3bwgk6ulzq6b3.css +1 -0
- package/.mochi/svelte-css/TerminalSplit-2416xfuln89a3.css +1 -0
- package/migrations/0005_instance_mode.sql +1 -0
- package/migrations/0006_terminal_split.sql +1 -0
- package/package.json +3 -1
- package/src/avatars/serialize.ts +35 -6
- package/src/components/AppShell.svelte +46 -8
- package/src/components/Avatar.svelte +3 -2
- package/src/components/AvatarEditor.svelte +96 -19
- package/src/components/AvatarsView.svelte +0 -0
- package/src/components/DashboardView.svelte +22 -7
- package/src/components/FolderBrowser.svelte +88 -8
- package/src/components/HealthBox.svelte +12 -5
- package/src/components/IdeBar.svelte +10 -0
- package/src/components/InstanceCard.svelte +3 -1
- package/src/components/InstanceView.svelte +1 -0
- package/src/components/SettingsView.svelte +108 -0
- package/src/components/TerminalPane.svelte +274 -0
- package/src/components/TerminalSplit.svelte +154 -0
- package/src/container-injections/claude-code-ide-extension.ts +2 -0
- package/src/container-injections/ttyd.ts +49 -0
- package/src/lib/config.server.ts +3 -0
- package/src/lib/db.server.ts +14 -4
- package/src/lib/devcontainer.server.ts +136 -22
- package/src/lib/health.server.ts +1 -1
- package/src/lib/injections.server.ts +15 -5
- package/src/lib/instances.server.ts +64 -10
- package/src/lib/proxy.server.ts +87 -11
- package/src/pages/App.svelte +5 -3
- package/src/pages/Settings.svelte +4 -0
- package/src/routes.ts +55 -6
- package/src/types.ts +18 -0
- package/.mochi/svelte-client/HydratableIsland-qyjx1d98.js +0 -1
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-fkna7bj2.js +0 -2
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-89wtkb86.js +0 -13
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-eamnbyqk.js +0 -1
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-kdra4mcb.js +0 -28
- package/.mochi/svelte-client/chunk-330mrspw.js +0 -2
- package/.mochi/svelte-client/chunk-4jgkak13.js +0 -1
- package/.mochi/svelte-client/chunk-k5m4kt20.js +0 -2
- package/.mochi/svelte-client/chunk-rpdphch7.js +0 -2
- package/.mochi/svelte-compile/chunk-bacb4b0r.js +0 -14
- package/.mochi/svelte-css/AvatarEditor-2ewwtejaml4e2.css +0 -1
- package/.mochi/svelte-css/FolderBrowser-1x428zb3f2kti.css +0 -1
- /package/.mochi/svelte-compile/{chunk-btmcxhqy.js → chunk-brjv9pyd.js} +0 -0
|
@@ -3,15 +3,18 @@ import { existsSync, statSync } from 'node:fs';
|
|
|
3
3
|
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
|
+
import { INSTALL_SCRIPT as TTYD_INSTALL_SCRIPT } from '../container-injections/ttyd.ts';
|
|
6
7
|
import { EXTENSION_ID as CLAUDE_CODE_EXTENSION_ID } from '../container-injections/claude-code-ide-extension.ts';
|
|
7
8
|
import {
|
|
8
9
|
CODE_SERVER_PORT,
|
|
9
10
|
DEFAULT_IMAGE,
|
|
10
11
|
PUBLISH_HOST,
|
|
12
|
+
TTYD_PORT,
|
|
11
13
|
devcontainerBin,
|
|
12
14
|
dockerEnv
|
|
13
15
|
} from './config.server.ts';
|
|
14
16
|
import { spawnCapture } from './spawn.server.ts';
|
|
17
|
+
import type { InstanceMode } from './db.server.ts';
|
|
15
18
|
import type { PortForward } from '../types.ts';
|
|
16
19
|
|
|
17
20
|
const CODE_SERVER_FEATURE = 'ghcr.io/coder/devcontainer-features/code-server:1';
|
|
@@ -49,11 +52,32 @@ const TMUX_FEATURE_INSTALL =
|
|
|
49
52
|
') || echo "codebay-tmux: install failed (non-fatal); the manager retries after the container starts"\n' +
|
|
50
53
|
'exit 0\n';
|
|
51
54
|
|
|
55
|
+
/** Relative to .devcontainer/. Only written for terminal-mode instances. */
|
|
56
|
+
const TTYD_FEATURE_DIR = 'codebay-ttyd';
|
|
57
|
+
|
|
58
|
+
const TTYD_FEATURE_METADATA = {
|
|
59
|
+
id: 'codebay-ttyd',
|
|
60
|
+
version: '1.0.0',
|
|
61
|
+
name: 'ttyd (Codebay, best-effort)',
|
|
62
|
+
description:
|
|
63
|
+
'Installs ttyd at image build time so the terminal is reachable in a firewalled container. Never fails the build.'
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Best-effort like tmux: build time is the only reliable moment to fetch in an egress-firewalled container. */
|
|
67
|
+
const TTYD_FEATURE_INSTALL =
|
|
68
|
+
'#!/bin/sh\n' +
|
|
69
|
+
'(\n' +
|
|
70
|
+
`${TTYD_INSTALL_SCRIPT}\n` +
|
|
71
|
+
') || echo "codebay-ttyd: install failed (non-fatal); the manager retries after the container starts"\n' +
|
|
72
|
+
'exit 0\n';
|
|
73
|
+
|
|
52
74
|
/** Manager-dropped files the project's own .gitignore won't cover, kept out of git status. */
|
|
53
75
|
const MANAGER_GIT_EXCLUDES = [
|
|
54
76
|
'/.devcontainer/code-server-settings.json',
|
|
55
77
|
'/.devcontainer/devcontainer-lock.json',
|
|
56
78
|
'/.devcontainer/codebay-tmux/',
|
|
79
|
+
'/.devcontainer/codebay-ttyd/',
|
|
80
|
+
'/.devcontainer/codebay-terminal.sh',
|
|
57
81
|
'/.vscode/tasks.json'
|
|
58
82
|
];
|
|
59
83
|
|
|
@@ -86,6 +110,9 @@ const CODE_SERVER_SETTINGS = {
|
|
|
86
110
|
|
|
87
111
|
const TMUX_SESSION = 'codebay';
|
|
88
112
|
|
|
113
|
+
/** The split view's right-hand pane: a plain shell, kept apart from the Claude session. */
|
|
114
|
+
const TMUX_SHELL_SESSION = 'codebay-shell';
|
|
115
|
+
|
|
89
116
|
/** Written to `$HOME` when the post-up injection sequence finishes; the terminal launcher waits on it. */
|
|
90
117
|
export const INJECTIONS_DONE_FILE = '.codebay-injections-done';
|
|
91
118
|
|
|
@@ -148,6 +175,52 @@ const CODE_SERVER_LAUNCH =
|
|
|
148
175
|
`nohup code-server --bind-addr 0.0.0.0:${CODE_SERVER_PORT} --auth none ` +
|
|
149
176
|
`--disable-workspace-trust \\"$PWD\\" >/tmp/code-server.log 2>&1 &"`;
|
|
150
177
|
|
|
178
|
+
/** Staged next to the config; ttyd runs it as its command so the nested quoting stays in a real file. */
|
|
179
|
+
const TTYD_LAUNCH_SCRIPT_FILE = 'codebay-terminal.sh';
|
|
180
|
+
|
|
181
|
+
/** The one `?arg=` value ttyd is allowed to act on; anything else falls through to Claude. */
|
|
182
|
+
export const TTYD_SHELL_ARG = 'shell';
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* ttyd runs this per browser connection. `tmux -A` attaches to the shared `codebay` session
|
|
186
|
+
* (or creates it), so Claude survives reconnects. No WAIT_FOR_IDE_BRIDGE — terminal mode has
|
|
187
|
+
* no code-server extension host to race. Kept in a file so the tmux command's own quoting isn't
|
|
188
|
+
* nested inside the postStartCommand's shell quoting.
|
|
189
|
+
*
|
|
190
|
+
* `$1` comes from the URL's `?arg=` (ttyd's --url-arg), so it is untrusted — hence a literal
|
|
191
|
+
* compare and never an eval. The shell branch sits above the injections wait so the split view's
|
|
192
|
+
* scratch shell opens immediately instead of blocking on Claude's boot sentinel.
|
|
193
|
+
*/
|
|
194
|
+
const TTYD_LAUNCH_SCRIPT =
|
|
195
|
+
'#!/usr/bin/env bash\n' +
|
|
196
|
+
'export SHELL="${SHELL:-/bin/bash}"\n' +
|
|
197
|
+
`if [ "$1" = "${TTYD_SHELL_ARG}" ]; then\n` +
|
|
198
|
+
' if command -v tmux >/dev/null 2>&1; then\n' +
|
|
199
|
+
` exec tmux new-session -A -s ${TMUX_SHELL_SESSION}\n` +
|
|
200
|
+
' fi\n' +
|
|
201
|
+
' exec "$SHELL" -l\n' +
|
|
202
|
+
'fi\n' +
|
|
203
|
+
WAIT_FOR_INJECTIONS +
|
|
204
|
+
'\n' +
|
|
205
|
+
`if command -v tmux >/dev/null 2>&1; then\n` +
|
|
206
|
+
` exec tmux new-session -A -s ${TMUX_SESSION} 'claude --dangerously-skip-permissions; exec "$SHELL" -l'\n` +
|
|
207
|
+
'fi\n' +
|
|
208
|
+
'claude --dangerously-skip-permissions\n' +
|
|
209
|
+
'exec "$SHELL" -l\n';
|
|
210
|
+
|
|
211
|
+
// ttyd defaults to read-only, so --writable is required for keyboard input. Guarded by `pgrep -x`
|
|
212
|
+
// (process name) so a folderOpen/rebuild can't stack a second daemon — never `pgrep -f`, whose
|
|
213
|
+
// cmdline match also matches this very shell, which would skip the launch every time.
|
|
214
|
+
const TTYD_LAUNCH =
|
|
215
|
+
`bash -c "` +
|
|
216
|
+
`export SHELL=\\"\${SHELL:-/bin/bash}\\"; ` +
|
|
217
|
+
`command -v ttyd >/dev/null 2>&1 || exit 0; ` +
|
|
218
|
+
`pgrep -x ttyd >/dev/null 2>&1 || ` +
|
|
219
|
+
// --url-arg forwards the connection URL's `?arg=` into the launcher's argv, so one ttyd serves
|
|
220
|
+
// both the Claude session and the split view's scratch shell (--writable is covered above).
|
|
221
|
+
`nohup ttyd --port ${TTYD_PORT} --writable --url-arg ` +
|
|
222
|
+
`bash \\"$PWD/.devcontainer/${TTYD_LAUNCH_SCRIPT_FILE}\\" >/tmp/ttyd.log 2>&1 &"`;
|
|
223
|
+
|
|
151
224
|
export async function devcontainerCliAvailable(): Promise<boolean> {
|
|
152
225
|
return (await spawnCapture([devcontainerBin(), '--version'])) !== null;
|
|
153
226
|
}
|
|
@@ -288,7 +361,15 @@ export async function readDeclaredContainerPorts(workspaceDir: string): Promise<
|
|
|
288
361
|
const ports = new Set<number>();
|
|
289
362
|
for (const entry of entries) {
|
|
290
363
|
const port = containerPortOf(entry);
|
|
291
|
-
|
|
364
|
+
// Both reserved internal ports are excluded regardless of mode, so neither can be
|
|
365
|
+
// double-mapped when writeOverrideConfig renders its own appPort entry for it.
|
|
366
|
+
if (
|
|
367
|
+
Number.isInteger(port) &&
|
|
368
|
+
port > 0 &&
|
|
369
|
+
port <= 65535 &&
|
|
370
|
+
port !== CODE_SERVER_PORT &&
|
|
371
|
+
port !== TTYD_PORT
|
|
372
|
+
) {
|
|
292
373
|
ports.add(port);
|
|
293
374
|
}
|
|
294
375
|
}
|
|
@@ -303,8 +384,10 @@ export async function writeOverrideConfig(
|
|
|
303
384
|
workspaceDir: string,
|
|
304
385
|
hostPort: number,
|
|
305
386
|
forwards: PortForward[] = [],
|
|
306
|
-
defaultImage: string = DEFAULT_IMAGE
|
|
387
|
+
defaultImage: string = DEFAULT_IMAGE,
|
|
388
|
+
mode: InstanceMode = 'ide'
|
|
307
389
|
): Promise<{ imageSource: string }> {
|
|
390
|
+
const isTerminal = mode === 'terminal';
|
|
308
391
|
const target = configPath(workspaceDir);
|
|
309
392
|
let config: DevcontainerConfig = {};
|
|
310
393
|
|
|
@@ -326,23 +409,30 @@ export async function writeOverrideConfig(
|
|
|
326
409
|
}
|
|
327
410
|
|
|
328
411
|
// A local feature is addressed relative to the config, which sits at one of two depths.
|
|
329
|
-
const
|
|
412
|
+
const featureKey = (dir: string) =>
|
|
413
|
+
`./${relative(dirname(target), join(workspaceDir, '.devcontainer', dir))}`;
|
|
414
|
+
const tmuxFeatureKey = featureKey(TMUX_FEATURE_DIR);
|
|
415
|
+
const ttydFeatureKey = featureKey(TTYD_FEATURE_DIR);
|
|
330
416
|
|
|
331
|
-
//
|
|
417
|
+
// 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.
|
|
332
419
|
config.features = {
|
|
333
420
|
...(config.features ?? {}),
|
|
334
|
-
|
|
421
|
+
...(isTerminal
|
|
422
|
+
? { [ttydFeatureKey]: {} }
|
|
423
|
+
: { [CODE_SERVER_FEATURE]: { host: '0.0.0.0', port: CODE_SERVER_PORT, auth: 'none' } }),
|
|
335
424
|
[tmuxFeatureKey]: {},
|
|
336
|
-
...(hadConfig
|
|
337
|
-
? {}
|
|
338
|
-
: {
|
|
425
|
+
...(isTerminal || !hadConfig
|
|
426
|
+
? { [NODE_FEATURE]: {}, [CLAUDE_CODE_FEATURE]: {}, [GITHUB_CLI_FEATURE]: {} }
|
|
427
|
+
: {})
|
|
339
428
|
};
|
|
340
429
|
|
|
430
|
+
const servedPort = isTerminal ? TTYD_PORT : CODE_SERVER_PORT;
|
|
341
431
|
// Rendered from scratch rather than merged, so removing a forward actually drops its mapping.
|
|
342
|
-
//
|
|
343
|
-
// reaches it only through the Basic-Auth-gated /p/:id/ proxy.
|
|
432
|
+
// The served port stays loopback even under HOST=0.0.0.0 — neither code-server nor ttyd has
|
|
433
|
+
// auth of its own, so the LAN reaches it only through the Basic-Auth-gated /p/:id/ proxy.
|
|
344
434
|
config.appPort = [
|
|
345
|
-
`127.0.0.1:${hostPort}:${
|
|
435
|
+
`127.0.0.1:${hostPort}:${servedPort}`,
|
|
346
436
|
...forwards.map((f) => `${PUBLISH_HOST}:${f.host_port}:${f.container_port}`)
|
|
347
437
|
];
|
|
348
438
|
|
|
@@ -351,26 +441,30 @@ export async function writeOverrideConfig(
|
|
|
351
441
|
runArgs.add(HOST_GATEWAY_ARG);
|
|
352
442
|
config.runArgs = [...runArgs];
|
|
353
443
|
|
|
444
|
+
const launch = isTerminal ? TTYD_LAUNCH : CODE_SERVER_LAUNCH;
|
|
354
445
|
const existing = config.postStartCommand;
|
|
355
446
|
config.postStartCommand =
|
|
356
|
-
typeof existing === 'string' && existing.trim()
|
|
357
|
-
? `${existing} && ${CODE_SERVER_LAUNCH}`
|
|
358
|
-
: CODE_SERVER_LAUNCH;
|
|
447
|
+
typeof existing === 'string' && existing.trim() ? `${existing} && ${launch}` : launch;
|
|
359
448
|
|
|
360
449
|
await mkdir(join(workspaceDir, '.devcontainer'), { recursive: true }).catch(() => {});
|
|
361
450
|
await writeFile(target, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
362
451
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
452
|
+
if (isTerminal) {
|
|
453
|
+
await writeTtydFeature(workspaceDir);
|
|
454
|
+
await writeTerminalLaunchScript(workspaceDir);
|
|
455
|
+
} else {
|
|
456
|
+
// Staged next to the config; CODE_SERVER_LAUNCH copies it into the user-data dir on first start.
|
|
457
|
+
await writeFile(
|
|
458
|
+
join(workspaceDir, '.devcontainer', CODE_SERVER_SETTINGS_FILE),
|
|
459
|
+
JSON.stringify(CODE_SERVER_SETTINGS, null, 2) + '\n',
|
|
460
|
+
'utf8'
|
|
461
|
+
);
|
|
462
|
+
// The VS Code Terminal task is code-server-only; ttyd runs its own launcher script instead.
|
|
463
|
+
await writeTerminalTask(workspaceDir);
|
|
464
|
+
}
|
|
369
465
|
|
|
370
466
|
await writeTmuxFeature(workspaceDir);
|
|
371
467
|
|
|
372
|
-
await writeTerminalTask(workspaceDir);
|
|
373
|
-
|
|
374
468
|
await writeLocalGitExclude(workspaceDir);
|
|
375
469
|
|
|
376
470
|
return { imageSource };
|
|
@@ -460,6 +554,26 @@ async function writeTmuxFeature(workspaceDir: string): Promise<void> {
|
|
|
460
554
|
await chmod(installPath, 0o755);
|
|
461
555
|
}
|
|
462
556
|
|
|
557
|
+
async function writeTtydFeature(workspaceDir: string): Promise<void> {
|
|
558
|
+
const dir = join(workspaceDir, '.devcontainer', TTYD_FEATURE_DIR);
|
|
559
|
+
await mkdir(dir, { recursive: true }).catch(() => {});
|
|
560
|
+
await writeFile(
|
|
561
|
+
join(dir, 'devcontainer-feature.json'),
|
|
562
|
+
JSON.stringify(TTYD_FEATURE_METADATA, null, 2) + '\n',
|
|
563
|
+
'utf8'
|
|
564
|
+
);
|
|
565
|
+
const installPath = join(dir, 'install.sh');
|
|
566
|
+
await writeFile(installPath, TTYD_FEATURE_INSTALL, 'utf8');
|
|
567
|
+
await chmod(installPath, 0o755);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Staged next to the config; TTYD_LAUNCH runs it as ttyd's command. */
|
|
571
|
+
async function writeTerminalLaunchScript(workspaceDir: string): Promise<void> {
|
|
572
|
+
const path = join(workspaceDir, '.devcontainer', TTYD_LAUNCH_SCRIPT_FILE);
|
|
573
|
+
await writeFile(path, TTYD_LAUNCH_SCRIPT, 'utf8');
|
|
574
|
+
await chmod(path, 0o755);
|
|
575
|
+
}
|
|
576
|
+
|
|
463
577
|
/** Replaces the managed task rather than skipping it, so a rebuild picks up command changes. */
|
|
464
578
|
async function writeTerminalTask(workspaceDir: string): Promise<void> {
|
|
465
579
|
const tasksPath = join(workspaceDir, '.vscode', 'tasks.json');
|
package/src/lib/health.server.ts
CHANGED
|
@@ -41,7 +41,7 @@ async function check(row: InstanceRow): Promise<InstanceHealth> {
|
|
|
41
41
|
codeServerAccessible(row.host_port),
|
|
42
42
|
publishedContainerPorts(row.container_id),
|
|
43
43
|
Promise.all(
|
|
44
|
-
resolveInjections()
|
|
44
|
+
resolveInjections(row.mode)
|
|
45
45
|
.filter((i) => i.check)
|
|
46
46
|
.map(async (i) => ({
|
|
47
47
|
id: i.id,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { InstanceRow } from './db.server.ts';
|
|
1
|
+
import type { InstanceMode, InstanceRow } from './db.server.ts';
|
|
2
2
|
import type { ExecTarget } from './exec.server.ts';
|
|
3
3
|
import { getOption } from './db.server.ts';
|
|
4
4
|
import { gitSafeDirectory } from '../container-injections/git-safe-directory.ts';
|
|
5
5
|
import { tmux } from '../container-injections/tmux.ts';
|
|
6
|
+
import { ttyd } from '../container-injections/ttyd.ts';
|
|
6
7
|
import { gitIdentity } from '../container-injections/git-identity.ts';
|
|
7
8
|
import { claudeCodeCredentials } from '../container-injections/claude-code-credentials.ts';
|
|
8
9
|
import { claudeCodeCustom } from '../container-injections/claude-code-custom.ts';
|
|
@@ -32,6 +33,8 @@ export interface Injection {
|
|
|
32
33
|
/** Doubles as the health-row key. */
|
|
33
34
|
id: string;
|
|
34
35
|
label: string;
|
|
36
|
+
/** Restrict to specific instance modes; absent means it applies to every mode. */
|
|
37
|
+
modes?: InstanceMode[];
|
|
35
38
|
/** Omit for injections with no host dependency — presence is what draws the setup-UI chip. */
|
|
36
39
|
auth?: {
|
|
37
40
|
/** Short instruction shown when unavailable, e.g. "run `gh auth login`". */
|
|
@@ -49,6 +52,8 @@ const BASE_INJECTIONS_HEAD: Injection[] = [
|
|
|
49
52
|
gitSafeDirectory,
|
|
50
53
|
// The package install is the slowest injection, so start it before a folderOpen can find tmux missing.
|
|
51
54
|
tmux,
|
|
55
|
+
// Terminal-mode only (filtered by resolveInjections); a download, so run it early like tmux.
|
|
56
|
+
ttyd,
|
|
52
57
|
gitIdentity
|
|
53
58
|
];
|
|
54
59
|
|
|
@@ -69,14 +74,19 @@ const BASE_INJECTIONS_TAIL: Injection[] = [
|
|
|
69
74
|
hostEnvVars
|
|
70
75
|
];
|
|
71
76
|
|
|
72
|
-
/**
|
|
73
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Resolved per call so toggling the custom-endpoint setting takes effect without a restart.
|
|
79
|
+
* `mode` drops injections that don't apply to it (e.g. ttyd off IDE instances, the IDE
|
|
80
|
+
* extension off terminal ones); omit it in mode-agnostic contexts to keep every injection.
|
|
81
|
+
*/
|
|
82
|
+
export function resolveInjections(mode?: InstanceMode): Injection[] {
|
|
74
83
|
const claudeInjection =
|
|
75
84
|
getOption('custom_endpoint_enabled') === '1' ? claudeCodeCustom : claudeCodeCredentials;
|
|
76
|
-
|
|
85
|
+
const all = [...BASE_INJECTIONS_HEAD, claudeInjection, ...BASE_INJECTIONS_TAIL];
|
|
86
|
+
return mode ? all.filter((i) => !i.modes || i.modes.includes(mode)) : all;
|
|
77
87
|
}
|
|
78
88
|
|
|
79
|
-
/** @deprecated Use `resolveInjections()`; this ignores settings and is kept for the tests. */
|
|
89
|
+
/** @deprecated Use `resolveInjections()`; this ignores settings/mode and is kept for the tests. */
|
|
80
90
|
export const injections: Injection[] = [
|
|
81
91
|
...BASE_INJECTIONS_HEAD,
|
|
82
92
|
claudeCodeCredentials,
|
|
@@ -6,7 +6,8 @@ import {
|
|
|
6
6
|
DEFAULT_COPY_IGNORE,
|
|
7
7
|
DEFAULT_IMAGE,
|
|
8
8
|
INSTANCES_DIR,
|
|
9
|
-
parseCopyIgnore
|
|
9
|
+
parseCopyIgnore,
|
|
10
|
+
TTYD_PORT
|
|
10
11
|
} from './config.server.ts';
|
|
11
12
|
import {
|
|
12
13
|
allForwards,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
recordFolder,
|
|
24
25
|
updateInstance,
|
|
25
26
|
usedPorts,
|
|
27
|
+
type InstanceMode,
|
|
26
28
|
type InstanceRow,
|
|
27
29
|
type InstanceStatus
|
|
28
30
|
} from './db.server.ts';
|
|
@@ -51,7 +53,13 @@ import { isRepoUrl, parseRepoUrl } from './repo-url.ts';
|
|
|
51
53
|
import { currentHealthSnapshots, stopHealthMonitor, syncHealthMonitors } from './health.server.ts';
|
|
52
54
|
import { pickFreePort } from './ports.server.ts';
|
|
53
55
|
import type { ServerWebSocket } from 'bun';
|
|
54
|
-
import
|
|
56
|
+
import {
|
|
57
|
+
isInstanceFilter,
|
|
58
|
+
normalizeMode,
|
|
59
|
+
type Instance,
|
|
60
|
+
type InstanceFilter,
|
|
61
|
+
type InstanceHealth
|
|
62
|
+
} from '../types.ts';
|
|
55
63
|
|
|
56
64
|
/**
|
|
57
65
|
* `bridge_token` authenticates the no-Basic-Auth `/api/bridge/` endpoint, so it
|
|
@@ -108,7 +116,11 @@ export type StreamEvent =
|
|
|
108
116
|
| { type: 'health'; data: { id: string; health: InstanceHealth } }
|
|
109
117
|
| { type: 'preflight'; data: { docker: boolean; cli: boolean } }
|
|
110
118
|
// `name` is the chosen sprite, or null for the default box logo. Lets the header swap live.
|
|
111
|
-
| { type: 'pet'; data: { name: string | null } }
|
|
119
|
+
| { type: 'pet'; data: { name: string | null } }
|
|
120
|
+
// The dashboard run-state filter, so a change in one tab propagates to every open client.
|
|
121
|
+
| { type: 'filter'; data: { value: InstanceFilter } }
|
|
122
|
+
// The global default editor surface, so the picker's toggle follows a settings change.
|
|
123
|
+
| { type: 'default-mode'; data: { mode: InstanceMode } };
|
|
112
124
|
|
|
113
125
|
interface StreamHub {
|
|
114
126
|
sockets: Set<ServerWebSocket<unknown>>;
|
|
@@ -159,6 +171,16 @@ export function broadcastPet(name: string | null): void {
|
|
|
159
171
|
broadcast({ type: 'pet', data: { name } });
|
|
160
172
|
}
|
|
161
173
|
|
|
174
|
+
/** Called when the filter changes so every open dashboard reflects it without a reload. */
|
|
175
|
+
export function broadcastFilter(value: InstanceFilter): void {
|
|
176
|
+
broadcast({ type: 'filter', data: { value } });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Settings opens in its own popup, so the dashboard behind it needs the new default pushed. */
|
|
180
|
+
export function broadcastDefaultMode(mode: InstanceMode): void {
|
|
181
|
+
broadcast({ type: 'default-mode', data: { mode } });
|
|
182
|
+
}
|
|
183
|
+
|
|
162
184
|
async function reconcileInstances(force = false): Promise<void> {
|
|
163
185
|
const list = await listInstances();
|
|
164
186
|
const listJson = JSON.stringify(list);
|
|
@@ -200,6 +222,14 @@ export function streamOpen(ws: ServerWebSocket<unknown>): void {
|
|
|
200
222
|
void backgroundPreflight().then((pf) => sendTo(ws, { type: 'preflight', data: pf }));
|
|
201
223
|
// Keeps a reconnecting client correct even if the pet changed while it was away.
|
|
202
224
|
sendTo(ws, { type: 'pet', data: { name: getOption('pet') || null } });
|
|
225
|
+
// Same, for the filter, so a client that reconnects picks up a change made while it was away.
|
|
226
|
+
const savedFilter = getOption('instance_filter');
|
|
227
|
+
sendTo(ws, {
|
|
228
|
+
type: 'filter',
|
|
229
|
+
data: { value: isInstanceFilter(savedFilter) ? savedFilter : 'all' }
|
|
230
|
+
});
|
|
231
|
+
// Same, for the default mode the picker's toggle seeds from.
|
|
232
|
+
sendTo(ws, { type: 'default-mode', data: { mode: getDefaultMode() } });
|
|
203
233
|
}
|
|
204
234
|
|
|
205
235
|
export function streamClose(ws: ServerWebSocket<unknown>): void {
|
|
@@ -291,13 +321,15 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
|
|
|
291
321
|
container_port: f.container_port,
|
|
292
322
|
host_port: f.host_port
|
|
293
323
|
}));
|
|
294
|
-
|
|
324
|
+
const surface = row.mode === 'terminal' ? 'ttyd terminal' : 'code-server';
|
|
325
|
+
appendLog(row.id, `Injecting ${surface} (host port ${row.host_port})\n`);
|
|
295
326
|
const defaultImage = getOption('default_image') ?? DEFAULT_IMAGE;
|
|
296
327
|
const { imageSource } = await writeOverrideConfig(
|
|
297
328
|
row.workspace_path,
|
|
298
329
|
row.host_port,
|
|
299
330
|
forwards,
|
|
300
|
-
defaultImage
|
|
331
|
+
defaultImage,
|
|
332
|
+
row.mode
|
|
301
333
|
);
|
|
302
334
|
updateInstance(row.id, { image_source: imageSource });
|
|
303
335
|
|
|
@@ -336,7 +368,7 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
|
|
|
336
368
|
instance: row
|
|
337
369
|
};
|
|
338
370
|
// Swallow per-injection failures so one bad injection can't abort the rest of provisioning.
|
|
339
|
-
for (const injection of resolveInjections()) {
|
|
371
|
+
for (const injection of resolveInjections(row.mode)) {
|
|
340
372
|
try {
|
|
341
373
|
await injection.apply(target, (msg) => appendLog(row.id, msg));
|
|
342
374
|
} catch (err) {
|
|
@@ -370,11 +402,16 @@ function uniqueName(desired: string, excludeId?: string): string {
|
|
|
370
402
|
}
|
|
371
403
|
}
|
|
372
404
|
|
|
405
|
+
/** The global fallback the picker's mode toggle starts on; a per-instance choice overrides it. */
|
|
406
|
+
export function getDefaultMode(): InstanceMode {
|
|
407
|
+
return normalizeMode(getOption('default_mode'));
|
|
408
|
+
}
|
|
409
|
+
|
|
373
410
|
/** `source` is either a local folder (copied) or a Git URL (cloned); boot runs in the background. */
|
|
374
411
|
export async function createInstance(
|
|
375
412
|
source: string,
|
|
376
413
|
name?: string,
|
|
377
|
-
opts: { branch?: string } = {}
|
|
414
|
+
opts: { branch?: string; mode?: InstanceMode } = {}
|
|
378
415
|
): Promise<InstanceRow> {
|
|
379
416
|
const parsedRepo = parseRepoUrl(source);
|
|
380
417
|
if (parsedRepo) {
|
|
@@ -399,7 +436,9 @@ export async function createInstance(
|
|
|
399
436
|
created_at: Date.now(),
|
|
400
437
|
bridge_token: crypto.randomUUID().replace(/-/g, ''),
|
|
401
438
|
remote_user: null,
|
|
402
|
-
image_source: null
|
|
439
|
+
image_source: null,
|
|
440
|
+
mode: opts.mode ?? getDefaultMode(),
|
|
441
|
+
terminal_split: 0
|
|
403
442
|
};
|
|
404
443
|
insertInstance(row);
|
|
405
444
|
// Strip the de-dup `#2` suffix so the recent-folders list keeps the base name.
|
|
@@ -455,6 +494,18 @@ export async function listInstances(): Promise<Instance[]> {
|
|
|
455
494
|
}));
|
|
456
495
|
}
|
|
457
496
|
|
|
497
|
+
/**
|
|
498
|
+
* Remembers whether the terminal's scratch-shell pane was left open. Deliberately skips
|
|
499
|
+
* `triggerReconcile()` — it's a per-instance UI preference read once at page load, so pushing the
|
|
500
|
+
* whole instance list to every socket on each toggle would be pure noise.
|
|
501
|
+
*/
|
|
502
|
+
export function setTerminalSplit(id: string, open: boolean): InstanceRow {
|
|
503
|
+
const row = getInstance(id);
|
|
504
|
+
if (!row) throw new Error('Instance not found');
|
|
505
|
+
updateInstance(id, { terminal_split: open ? 1 : 0 });
|
|
506
|
+
return getInstance(id)!;
|
|
507
|
+
}
|
|
508
|
+
|
|
458
509
|
export function renameInstance(id: string, name: string): InstanceRow {
|
|
459
510
|
const row = getInstance(id);
|
|
460
511
|
if (!row) throw new Error('Instance not found');
|
|
@@ -473,8 +524,11 @@ export async function addForwardedPort(id: string, containerPort: number): Promi
|
|
|
473
524
|
if (!Number.isInteger(containerPort) || containerPort < 1 || containerPort > 65535) {
|
|
474
525
|
throw new Error('Port must be an integer between 1 and 65535');
|
|
475
526
|
}
|
|
476
|
-
|
|
477
|
-
|
|
527
|
+
const reserved = row.mode === 'terminal' ? TTYD_PORT : CODE_SERVER_PORT;
|
|
528
|
+
if (containerPort === reserved) {
|
|
529
|
+
throw new Error(
|
|
530
|
+
`Port ${reserved} is reserved for ${row.mode === 'terminal' ? 'the terminal' : 'code-server'}`
|
|
531
|
+
);
|
|
478
532
|
}
|
|
479
533
|
if (listForwards(id).some((f) => f.container_port === containerPort)) {
|
|
480
534
|
throw new Error(`Port ${containerPort} is already forwarded`);
|
package/src/lib/proxy.server.ts
CHANGED
|
@@ -50,6 +50,25 @@ 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
|
+
/** The client's requested subprotocols, forwarded upstream — ttyd requires its `tty` one. */
|
|
56
|
+
subprotocols?: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
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
|
+
});
|
|
53
72
|
}
|
|
54
73
|
|
|
55
74
|
async function proxyHttp(event: MochiApiEvent, port: number, rest: string): Promise<Response> {
|
|
@@ -91,20 +110,53 @@ async function proxyHttp(event: MochiApiEvent, port: number, rest: string): Prom
|
|
|
91
110
|
});
|
|
92
111
|
}
|
|
93
112
|
|
|
94
|
-
function proxyUpgrade(event: MochiApiEvent, port: number, rest: string): Response {
|
|
113
|
+
async function proxyUpgrade(event: MochiApiEvent, port: number, rest: string): Promise<Response> {
|
|
114
|
+
const requested = event.request.headers.get('sec-websocket-protocol');
|
|
115
|
+
const subprotocols = requested
|
|
116
|
+
? requested
|
|
117
|
+
.split(',')
|
|
118
|
+
.map((s) => s.trim())
|
|
119
|
+
.filter(Boolean)
|
|
120
|
+
: 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
|
+
|
|
95
140
|
const data: MochiWsData<RelayState> = {
|
|
96
141
|
__mochiRoutePattern: PROXY_WS_PATTERN,
|
|
97
142
|
__mochiOpenedAt: performance.now(),
|
|
98
143
|
__mochiPath: event.url.pathname,
|
|
99
|
-
user:
|
|
144
|
+
user: state
|
|
100
145
|
};
|
|
146
|
+
const headers = selected ? { 'Sec-WebSocket-Protocol': selected } : undefined;
|
|
101
147
|
// Bun's Server is typed with a fixed WebSocketData; Mochi casts the same way for its own upgrades.
|
|
102
148
|
const ok = (
|
|
103
149
|
event.server as unknown as {
|
|
104
|
-
upgrade: (
|
|
150
|
+
upgrade: (
|
|
151
|
+
req: Request,
|
|
152
|
+
opts: { data: MochiWsData<RelayState>; headers?: Record<string, string> }
|
|
153
|
+
) => boolean;
|
|
105
154
|
}
|
|
106
|
-
).upgrade(event.request, { data });
|
|
107
|
-
if (!ok)
|
|
155
|
+
).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
|
+
}
|
|
108
160
|
// Bun already sent the real 101; this is ignored, but Mochi's resolve chain reads a status.
|
|
109
161
|
return new Response(null, { status: 101 });
|
|
110
162
|
}
|
|
@@ -127,7 +179,7 @@ export const proxyRoutes: Record<string, MochiRouteValue> = {
|
|
|
127
179
|
|
|
128
180
|
// An upgrade is a GET carrying `Upgrade: websocket`, so Bun routes it here too.
|
|
129
181
|
if (event.request.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
|
130
|
-
return proxyUpgrade(event, port, rest);
|
|
182
|
+
return await proxyUpgrade(event, port, rest);
|
|
131
183
|
}
|
|
132
184
|
return proxyHttp(event, port, rest);
|
|
133
185
|
}),
|
|
@@ -137,14 +189,27 @@ export const proxyRoutes: Record<string, MochiRouteValue> = {
|
|
|
137
189
|
upgrade: () => false,
|
|
138
190
|
open(ws: ServerWebSocket<MochiWsData<RelayState>>) {
|
|
139
191
|
const state = ws.data.user;
|
|
140
|
-
|
|
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));
|
|
141
198
|
client.binaryType = 'arraybuffer';
|
|
142
199
|
state.client = client;
|
|
143
200
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
state.
|
|
147
|
-
|
|
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
|
+
|
|
148
213
|
client.onmessage = (e) => {
|
|
149
214
|
try {
|
|
150
215
|
ws.send(e.data);
|
|
@@ -154,6 +219,17 @@ export const proxyRoutes: Record<string, MochiRouteValue> = {
|
|
|
154
219
|
};
|
|
155
220
|
client.onclose = (e) => safeClose(ws, e.code, e.reason);
|
|
156
221
|
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
|
+
}
|
|
157
233
|
},
|
|
158
234
|
message(ws: ServerWebSocket<MochiWsData<RelayState>>, message) {
|
|
159
235
|
const state = ws.data.user;
|
package/src/pages/App.svelte
CHANGED
|
@@ -2,20 +2,22 @@
|
|
|
2
2
|
import '@fontsource-variable/doto';
|
|
3
3
|
import '@fontsource-variable/jetbrains-mono';
|
|
4
4
|
import AppShell from '../components/AppShell.svelte';
|
|
5
|
-
import type { Instance, Preflight } from '../types.ts';
|
|
5
|
+
import type { Instance, InstanceFilter, Preflight } from '../types.ts';
|
|
6
6
|
import type { AvatarArt } from '../avatars/index.ts';
|
|
7
7
|
|
|
8
8
|
let {
|
|
9
9
|
preflight,
|
|
10
10
|
initialPath,
|
|
11
11
|
snapshot,
|
|
12
|
-
pet
|
|
12
|
+
pet,
|
|
13
|
+
filter
|
|
13
14
|
}: {
|
|
14
15
|
preflight: Preflight;
|
|
15
16
|
initialPath: string;
|
|
16
17
|
snapshot: Instance[];
|
|
17
18
|
pet?: AvatarArt;
|
|
19
|
+
filter: InstanceFilter;
|
|
18
20
|
} = $props();
|
|
19
21
|
</script>
|
|
20
22
|
|
|
21
|
-
<AppShell {preflight} {initialPath} {snapshot} {pet} mochi:hydrate />
|
|
23
|
+
<AppShell {preflight} {initialPath} {snapshot} {pet} {filter} mochi:hydrate />
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
import '@fontsource-variable/jetbrains-mono';
|
|
4
4
|
import SettingsView from '../components/SettingsView.svelte';
|
|
5
5
|
import type { AvatarArt } from '../avatars/index.ts';
|
|
6
|
+
import type { InstanceMode } from '../types.ts';
|
|
6
7
|
|
|
7
8
|
let {
|
|
8
9
|
pet,
|
|
10
|
+
defaultMode,
|
|
9
11
|
defaultImage,
|
|
10
12
|
builtinImage,
|
|
11
13
|
disableBuildCache,
|
|
@@ -38,6 +40,7 @@
|
|
|
38
40
|
version
|
|
39
41
|
}: {
|
|
40
42
|
pet?: AvatarArt;
|
|
43
|
+
defaultMode: InstanceMode;
|
|
41
44
|
defaultImage: string;
|
|
42
45
|
builtinImage: string;
|
|
43
46
|
disableBuildCache: boolean;
|
|
@@ -73,6 +76,7 @@
|
|
|
73
76
|
|
|
74
77
|
<SettingsView
|
|
75
78
|
{pet}
|
|
79
|
+
{defaultMode}
|
|
76
80
|
{defaultImage}
|
|
77
81
|
{builtinImage}
|
|
78
82
|
{disableBuildCache}
|