codebay 0.10.1 → 0.11.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 +196 -170
- package/.mochi/svelte-client/HydratableIsland-dammyce5.js +1 -0
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-v65ctd99.js +2 -0
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-htjfv8q9.js +14 -0
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-v07seqge.js +1 -0
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-hj9yvqc4.js +38 -0
- package/.mochi/svelte-client/chunk-0zkdsrth.js +2 -0
- package/.mochi/svelte-client/{chunk-bn731rda.js → chunk-566rqq4c.js} +2 -2
- package/.mochi/svelte-client/chunk-79aj59e0.js +2 -0
- package/.mochi/svelte-client/{chunk-wvtkwfsh.js → chunk-czkxd74n.js} +1 -1
- package/.mochi/svelte-client/{chunk-p20tkpde.js → chunk-hxydjtda.js} +1 -1
- package/.mochi/svelte-client/chunk-qx1defbm.js +2 -0
- package/.mochi/svelte-client/chunk-r3wxmmc0.js +2 -0
- package/.mochi/svelte-client/{chunk-j72r8pkk.js → chunk-vs6pd2b4.js} +2 -2
- package/.mochi/svelte-client/{chunk-t0ap7q89.js → chunk-wjagstre.js} +1 -1
- package/.mochi/svelte-client/{chunk-0fjx3z3r.js → chunk-y9yy1004.js} +1 -1
- package/.mochi/svelte-compile/{App-59cdj86f.server.js → App-mzn3dqjm.server.js} +182 -72
- package/.mochi/svelte-compile/{Avatars-fmagfyj2.server.js → Avatars-c0k5jx8q.server.js} +3 -6
- package/.mochi/svelte-compile/{Instance-s1gqwzmx.server.js → Instance-hj76qnha.server.js} +4 -4
- package/.mochi/svelte-compile/{Settings-k447mt0x.server.js → Settings-tvc5yp08.server.js} +39 -28
- package/.mochi/svelte-compile/chunk-ed46m58e.js +22 -0
- package/.mochi/svelte-compile/{chunk-th2dbqvc.js → chunk-gz6hv1h5.js} +5 -1
- package/.mochi/svelte-compile/chunk-zd49axkf.js +65 -0
- package/.mochi/svelte-css/{AppBar-3bxinv4pabv8l.css → AppBar-1gtu2me3fni8e.css} +1 -1
- package/.mochi/svelte-css/AvatarEditor-1mjvec6bwdkv8.css +1 -0
- package/.mochi/svelte-css/{AvatarsView-2llo48w0wt0xn.css → AvatarsView-26ya4ep5n1ze7.css} +1 -1
- package/.mochi/svelte-css/Button-208u50wkms8ya.css +1 -0
- package/.mochi/svelte-css/CoinButton-2727devjtsn10.css +1 -0
- package/.mochi/svelte-css/CredMenu-7qowd1oxujet.css +1 -0
- package/.mochi/svelte-css/DashboardView-17a0b8epjurhb.css +1 -0
- package/.mochi/svelte-css/FolderBrowser-i0uj58dph27f.css +1 -0
- package/.mochi/svelte-css/HealthBox-r0giuhjn2wj4.css +1 -0
- package/.mochi/svelte-css/IdeBar-1mtgemqfuvqzi.css +1 -0
- package/.mochi/svelte-css/InstanceCard-25z7nas7y98zk.css +1 -0
- package/.mochi/svelte-css/InstanceFilter-2vglmyy0yrbbc.css +1 -0
- package/.mochi/svelte-css/InstanceView-32j12hyg8v9u0.css +1 -0
- package/.mochi/svelte-css/SettingsCog-1jdeprd3ilczz.css +1 -0
- package/.mochi/svelte-css/{SettingsView-n3oy75uonzp2.css → SettingsView-bccw34agd2bj.css} +1 -1
- package/.mochi/svelte-css/StatusBadge-2eisdtnrsnfdc.css +1 -0
- package/.mochi/svelte-css/TerminalPane-2ywc86rhtmr9l.css +1 -0
- package/.mochi/svelte-css/ThemePicker-3fw4synubier0.css +1 -0
- package/migrations/0008_config_migrated.sql +1 -0
- package/package.json +8 -8
- package/src/components/AppBar.svelte +2 -2
- package/src/components/AppShell.svelte +71 -7
- package/src/components/AvatarEditor.svelte +10 -10
- package/src/components/AvatarsView.svelte +0 -0
- package/src/components/Button.svelte +13 -9
- package/src/components/CoinButton.svelte +2 -2
- package/src/components/CredMenu.svelte +8 -6
- package/src/components/DashboardView.svelte +2 -2
- package/src/components/FolderBrowser.svelte +39 -36
- package/src/components/HealthBox.svelte +5 -4
- package/src/components/IdeBar.svelte +286 -112
- package/src/components/InstanceCard.svelte +7 -7
- package/src/components/InstanceFilter.svelte +6 -6
- package/src/components/InstanceView.svelte +28 -22
- package/src/components/SettingsCog.svelte +4 -3
- package/src/components/SettingsView.svelte +7 -11
- package/src/components/StatusBadge.svelte +8 -6
- package/src/components/TerminalPane.svelte +19 -7
- package/src/components/ThemePicker.svelte +150 -8
- package/src/components/TopBar.svelte +1 -1
- package/src/components/UiShowcase.svelte +303 -64
- package/src/lib/db.server.ts +7 -3
- package/src/lib/devcontainer.server.ts +187 -33
- package/src/lib/docker.server.ts +10 -0
- package/src/lib/git.server.ts +28 -0
- package/src/lib/instances.server.ts +49 -6
- package/src/lib/tab-nav.ts +30 -0
- package/src/routes.ts +12 -0
- package/src/shell.html +60 -23
- package/src/theme.ts +8 -1
- package/src/toast.ts +9 -0
- package/src/types.ts +7 -0
- package/.mochi/svelte-client/HydratableIsland-aebn1fa1.js +0 -1
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-rx6arrv5.js +0 -2
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-jpjdmgpe.js +0 -14
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-c9y47dat.js +0 -1
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-d61feq89.js +0 -38
- package/.mochi/svelte-client/chunk-8xvr02wh.js +0 -2
- package/.mochi/svelte-client/chunk-9143wy3j.js +0 -2
- package/.mochi/svelte-client/chunk-k37wj1jm.js +0 -2
- package/.mochi/svelte-client/chunk-pc0fm37c.js +0 -2
- package/.mochi/svelte-compile/chunk-3t7v4hk0.js +0 -52
- package/.mochi/svelte-compile/chunk-gkjgybb2.js +0 -15
- package/.mochi/svelte-css/AvatarEditor-2mdq8ss346drw.css +0 -1
- package/.mochi/svelte-css/Button-oharop2xz6am.css +0 -1
- package/.mochi/svelte-css/CoinButton-1rej3sh3tkjud.css +0 -1
- package/.mochi/svelte-css/CredMenu-9hwgeptp7crb.css +0 -1
- package/.mochi/svelte-css/DashboardView-1cy4xpqhja9sx.css +0 -1
- package/.mochi/svelte-css/FolderBrowser-2pxp9z1o7zixk.css +0 -1
- package/.mochi/svelte-css/HealthBox-er2her0jukwl.css +0 -1
- package/.mochi/svelte-css/IdeBar-3bgr3m5z0lpeg.css +0 -1
- package/.mochi/svelte-css/InstanceCard-3vnltxnbxkvay.css +0 -1
- package/.mochi/svelte-css/InstanceFilter-3q6x0jt0hcgpz.css +0 -1
- package/.mochi/svelte-css/InstanceView-1pd7j16bd2b16.css +0 -1
- package/.mochi/svelte-css/SettingsCog-jftyadvo64qo.css +0 -1
- package/.mochi/svelte-css/StatusBadge-3mq0j7z6aq4j.css +0 -1
- package/.mochi/svelte-css/TerminalPane-3bwgk6ulzq6b3.css +0 -1
- package/.mochi/svelte-css/ThemePicker-1ir8jyocm8mvi.css +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { chmod, cp, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { existsSync, statSync } from 'node:fs';
|
|
1
|
+
import { chmod, cp, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync, readdirSync, 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';
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
dockerEnv
|
|
19
19
|
} from './config.server.ts';
|
|
20
20
|
import { spawnCapture } from './spawn.server.ts';
|
|
21
|
+
import { isTrackedFile, restoreTrackedFile } from './git.server.ts';
|
|
21
22
|
import { getOption, type InstanceMode } from './db.server.ts';
|
|
22
23
|
import { claudePermissionFlags, type ClaudePermissionMode, type PortForward } from '../types.ts';
|
|
23
24
|
|
|
@@ -100,8 +101,18 @@ const CLAUDE_FEATURE_INSTALL =
|
|
|
100
101
|
|
|
101
102
|
/** Manager-dropped files the project's own .gitignore won't cover, kept out of git status. */
|
|
102
103
|
const MANAGER_GIT_EXCLUDES = [
|
|
104
|
+
'/.devcontainer/codebay.devcontainer.json',
|
|
105
|
+
'/.codebay.devcontainer.json',
|
|
103
106
|
'/.devcontainer/code-server-settings.json',
|
|
104
107
|
'/.devcontainer/devcontainer-lock.json',
|
|
108
|
+
'/.devcontainer-lock.json',
|
|
109
|
+
// The subfolder config form places the merged config, lock, and staged features one level deeper.
|
|
110
|
+
'/.devcontainer/*/codebay.devcontainer.json',
|
|
111
|
+
'/.devcontainer/*/devcontainer-lock.json',
|
|
112
|
+
'/.devcontainer/*/codebay-tmux/',
|
|
113
|
+
'/.devcontainer/*/codebay-ttyd/',
|
|
114
|
+
'/.devcontainer/*/codebay-claude/',
|
|
115
|
+
'*.codebay-backup',
|
|
105
116
|
'/.devcontainer/codebay-tmux/',
|
|
106
117
|
'/.devcontainer/codebay-ttyd/',
|
|
107
118
|
'/.devcontainer/codebay-claude/',
|
|
@@ -416,20 +427,47 @@ function stripJsonc(input: string): string {
|
|
|
416
427
|
return out;
|
|
417
428
|
}
|
|
418
429
|
|
|
419
|
-
/**
|
|
430
|
+
/**
|
|
431
|
+
* Follows the spec's precedence: `.devcontainer/devcontainer.json`, `.devcontainer.json`, then
|
|
432
|
+
* the one-level-deep `.devcontainer/<folder>/devcontainer.json` form (first folder alphabetically).
|
|
433
|
+
*/
|
|
420
434
|
export function findDevcontainerConfig(dir: string): string | null {
|
|
421
435
|
const nested = join(dir, '.devcontainer', 'devcontainer.json');
|
|
422
436
|
if (existsSync(nested)) return nested;
|
|
423
437
|
const flat = join(dir, '.devcontainer.json');
|
|
424
438
|
if (existsSync(flat)) return flat;
|
|
439
|
+
const root = join(dir, '.devcontainer');
|
|
440
|
+
let entries;
|
|
441
|
+
try {
|
|
442
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
443
|
+
} catch {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
for (const name of entries
|
|
447
|
+
.filter((e) => e.isDirectory())
|
|
448
|
+
.map((e) => e.name)
|
|
449
|
+
.sort()) {
|
|
450
|
+
const candidate = join(root, name, 'devcontainer.json');
|
|
451
|
+
if (existsSync(candidate)) return candidate;
|
|
452
|
+
}
|
|
425
453
|
return null;
|
|
426
454
|
}
|
|
427
455
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
456
|
+
const CODEBAY_CONFIG_NESTED = 'codebay.devcontainer.json';
|
|
457
|
+
const CODEBAY_CONFIG_FLAT = '.codebay.devcontainer.json';
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Sits in the same directory as the project's own config, which is what `--config` points the CLI at:
|
|
461
|
+
* relative dockerfile/compose/feature paths resolve against that directory either way.
|
|
462
|
+
*/
|
|
463
|
+
export function overrideConfigPath(
|
|
464
|
+
workspaceDir: string,
|
|
465
|
+
canonical: string | null = findDevcontainerConfig(workspaceDir)
|
|
466
|
+
): string {
|
|
467
|
+
if (!canonical) return join(workspaceDir, '.devcontainer', CODEBAY_CONFIG_NESTED);
|
|
468
|
+
return basename(canonical) === '.devcontainer.json'
|
|
469
|
+
? join(workspaceDir, CODEBAY_CONFIG_FLAT)
|
|
470
|
+
: join(dirname(canonical), CODEBAY_CONFIG_NESTED);
|
|
433
471
|
}
|
|
434
472
|
|
|
435
473
|
type DevcontainerConfig = {
|
|
@@ -450,10 +488,10 @@ function containerPortOf(entry: number | string): number {
|
|
|
450
488
|
return Number.parseInt(last, 10);
|
|
451
489
|
}
|
|
452
490
|
|
|
453
|
-
/**
|
|
491
|
+
/** Reads the project's own config (never Codebay's merged one); reserved ports are excluded. */
|
|
454
492
|
export async function readDeclaredContainerPorts(workspaceDir: string): Promise<number[]> {
|
|
455
|
-
const target =
|
|
456
|
-
if (!
|
|
493
|
+
const target = findDevcontainerConfig(workspaceDir);
|
|
494
|
+
if (!target) return [];
|
|
457
495
|
let config: DevcontainerConfig;
|
|
458
496
|
try {
|
|
459
497
|
config = JSON.parse(stripJsonc(await readFile(target, 'utf8'))) as DevcontainerConfig;
|
|
@@ -487,7 +525,10 @@ export async function readDeclaredContainerPorts(workspaceDir: string): Promise<
|
|
|
487
525
|
/** Maps `host.docker.internal` to the host so the in-container attention bridge resolves. */
|
|
488
526
|
const HOST_GATEWAY_ARG = '--add-host=host.docker.internal:host-gateway';
|
|
489
527
|
|
|
490
|
-
/**
|
|
528
|
+
/**
|
|
529
|
+
* Merges the project's config (read-only) into a Codebay-owned sibling file that `devcontainer up
|
|
530
|
+
* --override-config` boots from, so the project's own devcontainer.json never shows as modified in git.
|
|
531
|
+
*/
|
|
491
532
|
export async function writeOverrideConfig(
|
|
492
533
|
workspaceDir: string,
|
|
493
534
|
hostPort: number,
|
|
@@ -496,31 +537,37 @@ export async function writeOverrideConfig(
|
|
|
496
537
|
mode: InstanceMode = 'ide',
|
|
497
538
|
permissionMode: ClaudePermissionMode = 'default',
|
|
498
539
|
envVars: { name: string; value: string }[] = []
|
|
499
|
-
): Promise<{ imageSource: string }> {
|
|
540
|
+
): Promise<{ imageSource: string; configPath: string | null; overrideConfigPath: string }> {
|
|
500
541
|
const isTerminal = mode === 'terminal';
|
|
501
|
-
const
|
|
542
|
+
const canonical = findDevcontainerConfig(workspaceDir);
|
|
543
|
+
const target = overrideConfigPath(workspaceDir, canonical);
|
|
502
544
|
let config: DevcontainerConfig = {};
|
|
503
545
|
|
|
504
|
-
const hadConfig =
|
|
546
|
+
const hadConfig = canonical !== null;
|
|
505
547
|
const imageSource = hadConfig ? 'local' : defaultImage;
|
|
506
548
|
|
|
507
|
-
if (
|
|
508
|
-
const raw = await readFile(
|
|
549
|
+
if (canonical) {
|
|
550
|
+
const raw = await readFile(canonical, 'utf8');
|
|
509
551
|
try {
|
|
510
552
|
config = JSON.parse(stripJsonc(raw)) as DevcontainerConfig;
|
|
511
553
|
} catch (err) {
|
|
512
554
|
throw new Error(
|
|
513
|
-
`Could not parse existing devcontainer.json at ${
|
|
555
|
+
`Could not parse existing devcontainer.json at ${canonical}: ${(err as Error).message}`,
|
|
514
556
|
{ cause: err }
|
|
515
557
|
);
|
|
516
558
|
}
|
|
559
|
+
// A config restoreCanonicalConfig couldn't clean (injection committed, or no .git to restore
|
|
560
|
+
// from) must not feed its baked-in injections back into the merge.
|
|
561
|
+
if (raw.includes(OVERRIDE_FINGERPRINT)) stripLegacyInjections(config);
|
|
517
562
|
} else {
|
|
518
563
|
config.image = defaultImage;
|
|
519
564
|
}
|
|
520
565
|
|
|
521
|
-
//
|
|
522
|
-
|
|
523
|
-
|
|
566
|
+
// Local features are staged next to a subfolder-form config (the CLI only promises to resolve
|
|
567
|
+
// `./`-descendant feature paths), and under `.devcontainer/` for the two classic forms.
|
|
568
|
+
const featureRoot =
|
|
569
|
+
dirname(target) === workspaceDir ? join(workspaceDir, '.devcontainer') : dirname(target);
|
|
570
|
+
const featureKey = (dir: string) => `./${relative(dirname(target), join(featureRoot, dir))}`;
|
|
524
571
|
const tmuxFeatureKey = featureKey(TMUX_FEATURE_DIR);
|
|
525
572
|
const ttydFeatureKey = featureKey(TTYD_FEATURE_DIR);
|
|
526
573
|
const claudeFeatureKey = featureKey(CLAUDE_FEATURE_DIR);
|
|
@@ -577,7 +624,7 @@ export async function writeOverrideConfig(
|
|
|
577
624
|
await writeFile(target, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
578
625
|
|
|
579
626
|
if (isTerminal) {
|
|
580
|
-
await writeTtydFeature(
|
|
627
|
+
await writeTtydFeature(featureRoot);
|
|
581
628
|
await writeTerminalLaunchScript(workspaceDir, permissionMode);
|
|
582
629
|
} else {
|
|
583
630
|
// Staged next to the config; codeServerLaunch copies it into the user-data dir on first start.
|
|
@@ -590,13 +637,113 @@ export async function writeOverrideConfig(
|
|
|
590
637
|
await writeTerminalTask(workspaceDir, permissionMode);
|
|
591
638
|
}
|
|
592
639
|
|
|
593
|
-
await writeTmuxFeature(
|
|
640
|
+
await writeTmuxFeature(featureRoot);
|
|
594
641
|
|
|
595
|
-
if (isTerminal && hadConfig) await writeClaudeFeature(
|
|
642
|
+
if (isTerminal && hadConfig) await writeClaudeFeature(featureRoot);
|
|
596
643
|
|
|
597
644
|
await writeLocalGitExclude(workspaceDir);
|
|
598
645
|
|
|
599
|
-
return { imageSource };
|
|
646
|
+
return { imageSource, configPath: canonical, overrideConfigPath: target };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** `codebay-tmux` has been injected into every config this manager has ever written. */
|
|
650
|
+
const OVERRIDE_FINGERPRINT = TMUX_FEATURE_DIR;
|
|
651
|
+
|
|
652
|
+
/** Where a destructive restore stashes the pre-restore file, in case it held user edits. */
|
|
653
|
+
const LEGACY_BACKUP_SUFFIX = '.codebay-backup';
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Removes what the legacy in-place scheme baked into a config that git couldn't restore — left as
|
|
657
|
+
* merge input, the stale features would resurrect and the launcher would chain onto itself.
|
|
658
|
+
*/
|
|
659
|
+
function stripLegacyInjections(config: DevcontainerConfig): void {
|
|
660
|
+
if (config.features) {
|
|
661
|
+
for (const key of Object.keys(config.features)) {
|
|
662
|
+
if (
|
|
663
|
+
key === CODE_SERVER_FEATURE ||
|
|
664
|
+
key.includes(TMUX_FEATURE_DIR) ||
|
|
665
|
+
key.includes(TTYD_FEATURE_DIR) ||
|
|
666
|
+
key.includes(CLAUDE_FEATURE_DIR)
|
|
667
|
+
) {
|
|
668
|
+
delete config.features[key];
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
if (typeof config.postStartCommand === 'string') {
|
|
673
|
+
const cmd = config.postStartCommand;
|
|
674
|
+
// The legacy launcher was always appended last, as a `bash -c "…nohup code-server/ttyd…"` tail.
|
|
675
|
+
const start = cmd.lastIndexOf('bash -c "');
|
|
676
|
+
if (start !== -1 && /nohup (?:code-server|ttyd) /.test(cmd.slice(start))) {
|
|
677
|
+
const head = cmd
|
|
678
|
+
.slice(0, start)
|
|
679
|
+
.replace(/\s*&&\s*$/, '')
|
|
680
|
+
.trim();
|
|
681
|
+
if (head) config.postStartCommand = head;
|
|
682
|
+
else delete config.postStartCommand;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Undoes the legacy scheme that overwrote the project's own devcontainer.json in place: restores
|
|
689
|
+
* the tracked file (or deletes the one Codebay created) so the instance's git status comes clean.
|
|
690
|
+
* `'unknown'` means git itself failed to answer, so the caller should retry on the next rebuild.
|
|
691
|
+
*/
|
|
692
|
+
export async function restoreCanonicalConfig(
|
|
693
|
+
workspaceDir: string
|
|
694
|
+
): Promise<'restored' | 'deleted' | 'none' | 'unknown'> {
|
|
695
|
+
const canonical = findDevcontainerConfig(workspaceDir);
|
|
696
|
+
if (!canonical) return 'none';
|
|
697
|
+
|
|
698
|
+
let content: string;
|
|
699
|
+
try {
|
|
700
|
+
content = await readFile(canonical, 'utf8');
|
|
701
|
+
} catch {
|
|
702
|
+
return 'none';
|
|
703
|
+
}
|
|
704
|
+
if (!content.includes(OVERRIDE_FINGERPRINT)) return 'none';
|
|
705
|
+
|
|
706
|
+
// Only a real .git directory can vouch for the file's provenance (same check as the excludes).
|
|
707
|
+
const gitDir = join(workspaceDir, '.git');
|
|
708
|
+
if (!existsSync(gitDir) || !statSync(gitDir).isDirectory()) return 'none';
|
|
709
|
+
|
|
710
|
+
const relPath = relative(workspaceDir, canonical);
|
|
711
|
+
const tracked = await isTrackedFile(workspaceDir, relPath);
|
|
712
|
+
if (tracked === null) return 'unknown'; // never destroy a config on a guess
|
|
713
|
+
|
|
714
|
+
// The file may hold user edits on top of the injection — stash it before anything destructive.
|
|
715
|
+
const backup = canonical + LEGACY_BACKUP_SUFFIX;
|
|
716
|
+
const dropBackup = () => unlink(backup).catch(() => {});
|
|
717
|
+
try {
|
|
718
|
+
await writeFile(backup, content, 'utf8');
|
|
719
|
+
} catch {
|
|
720
|
+
// Best-effort: a failed backup shouldn't block the migration itself.
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (tracked) {
|
|
724
|
+
if (!(await restoreTrackedFile(workspaceDir, relPath))) {
|
|
725
|
+
await dropBackup();
|
|
726
|
+
return 'unknown';
|
|
727
|
+
}
|
|
728
|
+
const after = await readFile(canonical, 'utf8').catch(() => null);
|
|
729
|
+
if (after === content) {
|
|
730
|
+
// The injection was committed inside the instance, so git has nothing cleaner to offer;
|
|
731
|
+
// stripLegacyInjections keeps it out of the merge instead.
|
|
732
|
+
await dropBackup();
|
|
733
|
+
return 'none';
|
|
734
|
+
}
|
|
735
|
+
return 'restored';
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Fingerprinted but untracked: Codebay created it for a config-less project — remove it so the
|
|
739
|
+
// next merge falls back to the default image again.
|
|
740
|
+
try {
|
|
741
|
+
await unlink(canonical);
|
|
742
|
+
} catch {
|
|
743
|
+
await dropBackup();
|
|
744
|
+
return 'unknown';
|
|
745
|
+
}
|
|
746
|
+
return 'deleted';
|
|
600
747
|
}
|
|
601
748
|
|
|
602
749
|
/**
|
|
@@ -669,8 +816,8 @@ async function writeLocalGitExclude(workspaceDir: string): Promise<void> {
|
|
|
669
816
|
await writeFile(excludePath, existing ? `${existing}\n\n${block}` : block, 'utf8');
|
|
670
817
|
}
|
|
671
818
|
|
|
672
|
-
async function writeTmuxFeature(
|
|
673
|
-
const dir = join(
|
|
819
|
+
async function writeTmuxFeature(featureRoot: string): Promise<void> {
|
|
820
|
+
const dir = join(featureRoot, TMUX_FEATURE_DIR);
|
|
674
821
|
await mkdir(dir, { recursive: true }).catch(() => {});
|
|
675
822
|
await writeFile(
|
|
676
823
|
join(dir, 'devcontainer-feature.json'),
|
|
@@ -683,8 +830,8 @@ async function writeTmuxFeature(workspaceDir: string): Promise<void> {
|
|
|
683
830
|
await chmod(installPath, 0o755);
|
|
684
831
|
}
|
|
685
832
|
|
|
686
|
-
async function writeClaudeFeature(
|
|
687
|
-
const dir = join(
|
|
833
|
+
async function writeClaudeFeature(featureRoot: string): Promise<void> {
|
|
834
|
+
const dir = join(featureRoot, CLAUDE_FEATURE_DIR);
|
|
688
835
|
await mkdir(dir, { recursive: true }).catch(() => {});
|
|
689
836
|
await writeFile(
|
|
690
837
|
join(dir, 'devcontainer-feature.json'),
|
|
@@ -696,8 +843,8 @@ async function writeClaudeFeature(workspaceDir: string): Promise<void> {
|
|
|
696
843
|
await chmod(installPath, 0o755);
|
|
697
844
|
}
|
|
698
845
|
|
|
699
|
-
async function writeTtydFeature(
|
|
700
|
-
const dir = join(
|
|
846
|
+
async function writeTtydFeature(featureRoot: string): Promise<void> {
|
|
847
|
+
const dir = join(featureRoot, TTYD_FEATURE_DIR);
|
|
701
848
|
await mkdir(dir, { recursive: true }).catch(() => {});
|
|
702
849
|
await writeFile(
|
|
703
850
|
join(dir, 'devcontainer-feature.json'),
|
|
@@ -758,7 +905,10 @@ export interface UpResult {
|
|
|
758
905
|
description?: string;
|
|
759
906
|
}
|
|
760
907
|
|
|
761
|
-
|
|
908
|
+
/** `configPath` is the project's own config (`--config`), `overrideConfigPath` the Codebay-owned one. */
|
|
909
|
+
type UpOptions = { noCache?: boolean; configPath?: string; overrideConfigPath?: string };
|
|
910
|
+
|
|
911
|
+
export function devcontainerUpArgs(workspaceDir: string, opts: UpOptions = {}) {
|
|
762
912
|
const args = [
|
|
763
913
|
devcontainerBin(),
|
|
764
914
|
'up',
|
|
@@ -766,6 +916,10 @@ export function devcontainerUpArgs(workspaceDir: string, opts: { noCache?: boole
|
|
|
766
916
|
workspaceDir,
|
|
767
917
|
'--remove-existing-container'
|
|
768
918
|
];
|
|
919
|
+
// --config rejects any basename but devcontainer.json/.devcontainer.json, so the Codebay-owned
|
|
920
|
+
// file rides --override-config; --config only pins which path relative resolution is based on.
|
|
921
|
+
if (opts.configPath) args.push('--config', opts.configPath);
|
|
922
|
+
if (opts.overrideConfigPath) args.push('--override-config', opts.overrideConfigPath);
|
|
769
923
|
// Only takes effect because --remove-existing-container drops the container before the build.
|
|
770
924
|
if (opts.noCache) args.push('--build-no-cache');
|
|
771
925
|
return args;
|
|
@@ -783,7 +937,7 @@ export function devcontainerUpEnv(): Record<string, string | undefined> {
|
|
|
783
937
|
export async function devcontainerUp(
|
|
784
938
|
workspaceDir: string,
|
|
785
939
|
onLog: (chunk: string) => void,
|
|
786
|
-
opts:
|
|
940
|
+
opts: UpOptions = {}
|
|
787
941
|
): Promise<UpResult> {
|
|
788
942
|
const proc = Bun.spawn(devcontainerUpArgs(workspaceDir, opts), {
|
|
789
943
|
cwd: workspaceDir,
|
package/src/lib/docker.server.ts
CHANGED
|
@@ -106,6 +106,16 @@ export async function stopContainer(containerId: string): Promise<boolean> {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/** Rebuild-safe removal: volumes survive — the CLI's `--remove-existing-container` is a plain `docker rm -f`, no `-v`. */
|
|
110
|
+
export async function removeContainerOnly(containerId: string): Promise<boolean> {
|
|
111
|
+
try {
|
|
112
|
+
await (await getDocker()).getContainer(containerId).remove({ force: true });
|
|
113
|
+
return true;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return statusOf(err) === 404; // already absent counts as success
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
109
119
|
/** Named volumes must be listed before removal and dropped by hand — the engine never reaps them. */
|
|
110
120
|
export async function removeContainer(containerId: string): Promise<boolean> {
|
|
111
121
|
const docker = await getDocker();
|
package/src/lib/git.server.ts
CHANGED
|
@@ -16,6 +16,34 @@ export async function readGitBranch(workspacePath: string): Promise<string | nul
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** Exit-code probe — `spawnCapture` can't tell a silent success from a failure. */
|
|
20
|
+
async function gitExitCode(workspaceDir: string, args: string[]): Promise<number | null> {
|
|
21
|
+
try {
|
|
22
|
+
const proc = Bun.spawn(['git', '-C', workspaceDir, ...args], {
|
|
23
|
+
stdout: 'ignore',
|
|
24
|
+
stderr: 'ignore'
|
|
25
|
+
});
|
|
26
|
+
return await proc.exited;
|
|
27
|
+
} catch {
|
|
28
|
+
return null; // no git on the host
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `null` means git itself failed to answer (missing binary, corrupt repo) — not "untracked". */
|
|
33
|
+
export async function isTrackedFile(
|
|
34
|
+
workspaceDir: string,
|
|
35
|
+
relPath: string
|
|
36
|
+
): Promise<boolean | null> {
|
|
37
|
+
const code = await gitExitCode(workspaceDir, ['ls-files', '--error-unmatch', '--', relPath]);
|
|
38
|
+
if (code === 0) return true;
|
|
39
|
+
if (code === 1) return false; // ls-files --error-unmatch reserves 1 for "no such tracked file"
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function restoreTrackedFile(workspaceDir: string, relPath: string): Promise<boolean> {
|
|
44
|
+
return (await gitExitCode(workspaceDir, ['checkout', '--', relPath])) === 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
19
47
|
/**
|
|
20
48
|
* The token goes in env-scoped git config, so it lands in neither argv nor the clone's
|
|
21
49
|
* persisted `.git/config`; `GIT_TERMINAL_PROMPT=0` fails fast instead of blocking on a prompt.
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
hostPortsInUse,
|
|
34
34
|
isRunning,
|
|
35
35
|
removeContainer,
|
|
36
|
+
removeContainerOnly,
|
|
36
37
|
startContainer,
|
|
37
38
|
stopContainer
|
|
38
39
|
} from './docker.server.ts';
|
|
@@ -43,6 +44,7 @@ import {
|
|
|
43
44
|
INJECTIONS_DONE_FILE,
|
|
44
45
|
launchCommandFor,
|
|
45
46
|
readDeclaredContainerPorts,
|
|
47
|
+
restoreCanonicalConfig,
|
|
46
48
|
TERMINAL_LAUNCHED_MARKER,
|
|
47
49
|
writeOverrideConfig
|
|
48
50
|
} from './devcontainer.server.ts';
|
|
@@ -73,7 +75,8 @@ import {
|
|
|
73
75
|
normalizeMode,
|
|
74
76
|
type Instance,
|
|
75
77
|
type InstanceFilter,
|
|
76
|
-
type InstanceHealth
|
|
78
|
+
type InstanceHealth,
|
|
79
|
+
type Theme
|
|
77
80
|
} from '../types.ts';
|
|
78
81
|
|
|
79
82
|
/** The sprite a row shows: its persisted choice, or the id hash for rows created before avatars were stored. */
|
|
@@ -189,7 +192,9 @@ export type StreamEvent =
|
|
|
189
192
|
// The dashboard run-state filter, so a change in one tab propagates to every open client.
|
|
190
193
|
| { type: 'filter'; data: { value: InstanceFilter } }
|
|
191
194
|
// The global default editor surface, so the picker's toggle follows a settings change.
|
|
192
|
-
| { type: 'default-mode'; data: { mode: InstanceMode } }
|
|
195
|
+
| { type: 'default-mode'; data: { mode: InstanceMode } }
|
|
196
|
+
// The colour scheme, so a change in the settings popup repaints the window behind it.
|
|
197
|
+
| { type: 'theme'; data: { value: Theme } };
|
|
193
198
|
|
|
194
199
|
interface StreamHub {
|
|
195
200
|
sockets: Set<ServerWebSocket<unknown>>;
|
|
@@ -250,6 +255,14 @@ export function broadcastDefaultMode(mode: InstanceMode): void {
|
|
|
250
255
|
broadcast({ type: 'default-mode', data: { mode } });
|
|
251
256
|
}
|
|
252
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Same popup problem for the theme — but it lives in a cookie, not `options`, so there's
|
|
260
|
+
* nothing to seed a reconnecting client with: its own cookie already drives the SSR paint.
|
|
261
|
+
*/
|
|
262
|
+
export function broadcastTheme(value: Theme): void {
|
|
263
|
+
broadcast({ type: 'theme', data: { value } });
|
|
264
|
+
}
|
|
265
|
+
|
|
253
266
|
async function reconcileInstances(force = false): Promise<void> {
|
|
254
267
|
const list = await listInstances();
|
|
255
268
|
const listJson = JSON.stringify(list);
|
|
@@ -380,7 +393,7 @@ async function boot(row: InstanceRow, opts: { branch?: string } = {}): Promise<v
|
|
|
380
393
|
await provision(row);
|
|
381
394
|
}
|
|
382
395
|
|
|
383
|
-
/**
|
|
396
|
+
/** Reads the project's own `devcontainer.json`, which the config injection never touches. */
|
|
384
397
|
async function seedDeclaredPorts(row: InstanceRow): Promise<void> {
|
|
385
398
|
const existing = new Set(listForwards(row.id).map((f) => f.container_port));
|
|
386
399
|
for (const containerPort of await readDeclaredContainerPorts(row.workspace_path)) {
|
|
@@ -425,6 +438,22 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
|
|
|
425
438
|
let containerId: string | null = null;
|
|
426
439
|
inFlight.add(row.id);
|
|
427
440
|
try {
|
|
441
|
+
// Migrates instances created before the merged config moved to its own file — one-shot per
|
|
442
|
+
// instance, so a later rebuild can never claw back a config the user has since edited.
|
|
443
|
+
if (!row.config_migrated) {
|
|
444
|
+
const migrated = await restoreCanonicalConfig(row.workspace_path);
|
|
445
|
+
if (migrated === 'restored' || migrated === 'deleted') {
|
|
446
|
+
appendLog(
|
|
447
|
+
row.id,
|
|
448
|
+
'Restored the project devcontainer config (Codebay now keeps its merged config in a separate file)\n'
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
// 'unknown' means git itself failed to answer; leave the flag unset so the next rebuild retries.
|
|
452
|
+
if (migrated !== 'unknown') {
|
|
453
|
+
updateInstance(row.id, { config_migrated: 1 });
|
|
454
|
+
row.config_migrated = 1;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
428
457
|
// On a rebuild `devcontainer up --remove-existing-container` discards the old container's
|
|
429
458
|
// ~/.claude, so drain it first; a first boot has no container_id and skips straight past.
|
|
430
459
|
if (row.container_id) {
|
|
@@ -438,7 +467,7 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
|
|
|
438
467
|
}));
|
|
439
468
|
appendLog(row.id, `Injecting ${surfaceLabel(row.mode)} (host port ${row.host_port})\n`);
|
|
440
469
|
const defaultImage = getOption('default_image') ?? DEFAULT_IMAGE;
|
|
441
|
-
const { imageSource } = await writeOverrideConfig(
|
|
470
|
+
const { imageSource, configPath, overrideConfigPath } = await writeOverrideConfig(
|
|
442
471
|
row.workspace_path,
|
|
443
472
|
row.host_port,
|
|
444
473
|
forwards,
|
|
@@ -452,10 +481,22 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
|
|
|
452
481
|
const noCache = opts.noCache || getOption('disable_build_cache') === '1';
|
|
453
482
|
if (noCache) appendLog(row.id, `Building without cache (--build-no-cache)\n`);
|
|
454
483
|
|
|
484
|
+
// The CLI's --remove-existing-container matches by the devcontainer.config_file label, and
|
|
485
|
+
// deliberately ignores a workspace match whose label differs — so a container left by a build
|
|
486
|
+
// that labelled itself differently would survive and keep the host port. Drop it ourselves.
|
|
487
|
+
if (row.container_id && !(await removeContainerOnly(row.container_id))) {
|
|
488
|
+
appendLog(
|
|
489
|
+
row.id,
|
|
490
|
+
`⚠ Could not remove the previous container (${row.container_id.slice(0, 12)}) — if the boot fails on a busy port, remove it manually\n`
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
455
494
|
appendLog(row.id, `Starting devcontainer…\n`);
|
|
456
495
|
const upStart = Date.now();
|
|
457
496
|
const result = await devcontainerUp(row.workspace_path, (chunk) => appendLog(row.id, chunk), {
|
|
458
|
-
noCache
|
|
497
|
+
noCache,
|
|
498
|
+
configPath: configPath ?? undefined,
|
|
499
|
+
overrideConfigPath
|
|
459
500
|
});
|
|
460
501
|
appendLog(row.id, `⏱ devcontainer up: ${elapsed(upStart)}\n`);
|
|
461
502
|
|
|
@@ -592,7 +633,9 @@ export async function createInstance(
|
|
|
592
633
|
// Kept synchronous through insertInstance so two concurrent creations can't claim the same free sprite.
|
|
593
634
|
avatar: pickUniqueAvatar(id, allInstances().map(effectiveAvatarName)).name,
|
|
594
635
|
mode: opts.mode ?? getDefaultMode(),
|
|
595
|
-
terminal_split: 0
|
|
636
|
+
terminal_split: 0,
|
|
637
|
+
// Born under the separate-config scheme, so there is never a legacy injection to undo.
|
|
638
|
+
config_migrated: 1
|
|
596
639
|
};
|
|
597
640
|
insertInstance(row);
|
|
598
641
|
// Strip the de-dup `#2` suffix so the recent-folders list keeps the base name.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Digit jumps follow the browser convention: 1–8 are absolute, 9 is always the last tab. */
|
|
2
|
+
function digitTarget(digit: number, count: number): number {
|
|
3
|
+
return digit === 9 ? count - 1 : digit - 1;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Index the tab strip should move to for a key, or `null` to leave the event alone.
|
|
8
|
+
* Pure so the arrow/Home/End/digit math is testable without a DOM.
|
|
9
|
+
*/
|
|
10
|
+
export function nextTabIndex(current: number, key: string, count: number): number | null {
|
|
11
|
+
if (count <= 0) return null;
|
|
12
|
+
// A `current` outside the strip (nothing focused yet) still resolves, since the
|
|
13
|
+
// arrow branches below normalise through a modulo of `count`.
|
|
14
|
+
const from = current < 0 || current >= count ? 0 : current;
|
|
15
|
+
switch (key) {
|
|
16
|
+
case 'ArrowRight':
|
|
17
|
+
return (from + 1) % count;
|
|
18
|
+
case 'ArrowLeft':
|
|
19
|
+
return (from - 1 + count) % count;
|
|
20
|
+
case 'Home':
|
|
21
|
+
return 0;
|
|
22
|
+
case 'End':
|
|
23
|
+
return count - 1;
|
|
24
|
+
default: {
|
|
25
|
+
if (!/^[1-9]$/.test(key)) return null;
|
|
26
|
+
const target = digitTarget(Number(key), count);
|
|
27
|
+
return target < count ? target : null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/routes.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
broadcastDefaultMode,
|
|
36
36
|
broadcastFilter,
|
|
37
37
|
broadcastPet,
|
|
38
|
+
broadcastTheme,
|
|
38
39
|
createInstance,
|
|
39
40
|
getDefaultMode,
|
|
40
41
|
deleteAllInstances,
|
|
@@ -68,6 +69,7 @@ import { timingSafeEqualStr } from './lib/crypto.server.ts';
|
|
|
68
69
|
import { proxyRoutes } from './lib/proxy.server.ts';
|
|
69
70
|
import {
|
|
70
71
|
isInstanceFilter,
|
|
72
|
+
isTheme,
|
|
71
73
|
normalizeMode,
|
|
72
74
|
normalizePermissionMode,
|
|
73
75
|
normalizeEffortLevel,
|
|
@@ -514,6 +516,16 @@ export const routes: Record<string, MochiRouteValue> = {
|
|
|
514
516
|
return json({ value: body.value });
|
|
515
517
|
}),
|
|
516
518
|
|
|
519
|
+
// The cookie the picker just wrote is already shared browser-wide; this only tells
|
|
520
|
+
// the tabs behind the settings popup to repaint without a reload.
|
|
521
|
+
'/api/settings/theme': Mochi.api(async ({ method, request }) => {
|
|
522
|
+
if (method !== 'POST') return apiError(405, 'Method Not Allowed');
|
|
523
|
+
const body = (await request.json().catch(() => null)) as { value?: string } | null;
|
|
524
|
+
if (!body || !isTheme(body.value)) return apiError(400, 'Invalid theme');
|
|
525
|
+
broadcastTheme(body.value);
|
|
526
|
+
return json({ value: body.value });
|
|
527
|
+
}),
|
|
528
|
+
|
|
517
529
|
'/api/stream': Mochi.ws({
|
|
518
530
|
// Mochi.ws routes bypass the global basicAuth handle, so enforce origin + auth here.
|
|
519
531
|
upgrade: (req) => (wsUpgradeAllowed(req) ? {} : false),
|