codebay 0.10.1 → 0.11.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.
Files changed (101) hide show
  1. package/.mochi/manifest.json +199 -173
  2. package/.mochi/svelte-client/HydratableIsland-dammyce5.js +1 -0
  3. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-v65ctd99.js +2 -0
  4. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-htjfv8q9.js +14 -0
  5. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-v07seqge.js +1 -0
  6. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-hj9yvqc4.js +38 -0
  7. package/.mochi/svelte-client/chunk-0zkdsrth.js +2 -0
  8. package/.mochi/svelte-client/{chunk-bn731rda.js → chunk-566rqq4c.js} +2 -2
  9. package/.mochi/svelte-client/chunk-79aj59e0.js +2 -0
  10. package/.mochi/svelte-client/{chunk-wvtkwfsh.js → chunk-czkxd74n.js} +1 -1
  11. package/.mochi/svelte-client/{chunk-p20tkpde.js → chunk-hxydjtda.js} +1 -1
  12. package/.mochi/svelte-client/chunk-qx1defbm.js +2 -0
  13. package/.mochi/svelte-client/chunk-r3wxmmc0.js +2 -0
  14. package/.mochi/svelte-client/{chunk-j72r8pkk.js → chunk-vs6pd2b4.js} +2 -2
  15. package/.mochi/svelte-client/{chunk-t0ap7q89.js → chunk-wjagstre.js} +1 -1
  16. package/.mochi/svelte-client/{chunk-0fjx3z3r.js → chunk-y9yy1004.js} +1 -1
  17. package/.mochi/svelte-compile/{App-59cdj86f.server.js → App-mzn3dqjm.server.js} +182 -72
  18. package/.mochi/svelte-compile/{Avatars-fmagfyj2.server.js → Avatars-c0k5jx8q.server.js} +3 -6
  19. package/.mochi/svelte-compile/{Instance-s1gqwzmx.server.js → Instance-hj76qnha.server.js} +4 -4
  20. package/.mochi/svelte-compile/{Settings-k447mt0x.server.js → Settings-tvc5yp08.server.js} +39 -28
  21. package/.mochi/svelte-compile/chunk-ed46m58e.js +22 -0
  22. package/.mochi/svelte-compile/{chunk-th2dbqvc.js → chunk-gz6hv1h5.js} +5 -1
  23. package/.mochi/svelte-compile/chunk-zd49axkf.js +65 -0
  24. package/.mochi/svelte-css/{AppBar-3bxinv4pabv8l.css → AppBar-1gtu2me3fni8e.css} +1 -1
  25. package/.mochi/svelte-css/AvatarEditor-1mjvec6bwdkv8.css +1 -0
  26. package/.mochi/svelte-css/{AvatarsView-2llo48w0wt0xn.css → AvatarsView-26ya4ep5n1ze7.css} +1 -1
  27. package/.mochi/svelte-css/Button-208u50wkms8ya.css +1 -0
  28. package/.mochi/svelte-css/CoinButton-2727devjtsn10.css +1 -0
  29. package/.mochi/svelte-css/CredMenu-7qowd1oxujet.css +1 -0
  30. package/.mochi/svelte-css/DashboardView-17a0b8epjurhb.css +1 -0
  31. package/.mochi/svelte-css/FolderBrowser-i0uj58dph27f.css +1 -0
  32. package/.mochi/svelte-css/HealthBox-r0giuhjn2wj4.css +1 -0
  33. package/.mochi/svelte-css/IdeBar-1mtgemqfuvqzi.css +1 -0
  34. package/.mochi/svelte-css/InstanceCard-25z7nas7y98zk.css +1 -0
  35. package/.mochi/svelte-css/InstanceFilter-2vglmyy0yrbbc.css +1 -0
  36. package/.mochi/svelte-css/InstanceView-oozmmctp8m3p.css +1 -0
  37. package/.mochi/svelte-css/SettingsCog-1jdeprd3ilczz.css +1 -0
  38. package/.mochi/svelte-css/{SettingsView-n3oy75uonzp2.css → SettingsView-bccw34agd2bj.css} +1 -1
  39. package/.mochi/svelte-css/StatusBadge-2eisdtnrsnfdc.css +1 -0
  40. package/.mochi/svelte-css/TerminalPane-2ywc86rhtmr9l.css +1 -0
  41. package/.mochi/svelte-css/ThemePicker-3fw4synubier0.css +1 -0
  42. package/migrations/0008_config_migrated.sql +1 -0
  43. package/package.json +8 -8
  44. package/src/components/AppBar.svelte +2 -2
  45. package/src/components/AppShell.svelte +71 -7
  46. package/src/components/AvatarEditor.svelte +10 -10
  47. package/src/components/AvatarsView.svelte +0 -0
  48. package/src/components/Button.svelte +13 -9
  49. package/src/components/CoinButton.svelte +2 -2
  50. package/src/components/CredMenu.svelte +8 -6
  51. package/src/components/DashboardView.svelte +2 -2
  52. package/src/components/FolderBrowser.svelte +39 -36
  53. package/src/components/HealthBox.svelte +5 -4
  54. package/src/components/IdeBar.svelte +286 -112
  55. package/src/components/InstanceCard.svelte +7 -7
  56. package/src/components/InstanceFilter.svelte +6 -6
  57. package/src/components/InstanceView.svelte +27 -21
  58. package/src/components/SettingsCog.svelte +4 -3
  59. package/src/components/SettingsView.svelte +7 -11
  60. package/src/components/StatusBadge.svelte +8 -6
  61. package/src/components/TerminalPane.svelte +19 -7
  62. package/src/components/ThemePicker.svelte +150 -8
  63. package/src/components/TopBar.svelte +1 -1
  64. package/src/components/UiShowcase.svelte +300 -64
  65. package/src/lib/db.server.ts +7 -3
  66. package/src/lib/devcontainer.server.ts +181 -33
  67. package/src/lib/docker.server.ts +10 -0
  68. package/src/lib/git.server.ts +28 -0
  69. package/src/lib/instances.server.ts +47 -6
  70. package/src/lib/tab-nav.ts +30 -0
  71. package/src/routes.ts +12 -0
  72. package/src/shell.html +56 -23
  73. package/src/theme.ts +8 -1
  74. package/src/toast.ts +9 -0
  75. package/src/types.ts +7 -0
  76. package/.mochi/svelte-client/HydratableIsland-aebn1fa1.js +0 -1
  77. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-rx6arrv5.js +0 -2
  78. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-jpjdmgpe.js +0 -14
  79. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-c9y47dat.js +0 -1
  80. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-d61feq89.js +0 -38
  81. package/.mochi/svelte-client/chunk-8xvr02wh.js +0 -2
  82. package/.mochi/svelte-client/chunk-9143wy3j.js +0 -2
  83. package/.mochi/svelte-client/chunk-k37wj1jm.js +0 -2
  84. package/.mochi/svelte-client/chunk-pc0fm37c.js +0 -2
  85. package/.mochi/svelte-compile/chunk-3t7v4hk0.js +0 -52
  86. package/.mochi/svelte-compile/chunk-gkjgybb2.js +0 -15
  87. package/.mochi/svelte-css/AvatarEditor-2mdq8ss346drw.css +0 -1
  88. package/.mochi/svelte-css/Button-oharop2xz6am.css +0 -1
  89. package/.mochi/svelte-css/CoinButton-1rej3sh3tkjud.css +0 -1
  90. package/.mochi/svelte-css/CredMenu-9hwgeptp7crb.css +0 -1
  91. package/.mochi/svelte-css/DashboardView-1cy4xpqhja9sx.css +0 -1
  92. package/.mochi/svelte-css/FolderBrowser-2pxp9z1o7zixk.css +0 -1
  93. package/.mochi/svelte-css/HealthBox-er2her0jukwl.css +0 -1
  94. package/.mochi/svelte-css/IdeBar-3bgr3m5z0lpeg.css +0 -1
  95. package/.mochi/svelte-css/InstanceCard-3vnltxnbxkvay.css +0 -1
  96. package/.mochi/svelte-css/InstanceFilter-3q6x0jt0hcgpz.css +0 -1
  97. package/.mochi/svelte-css/InstanceView-1pd7j16bd2b16.css +0 -1
  98. package/.mochi/svelte-css/SettingsCog-jftyadvo64qo.css +0 -1
  99. package/.mochi/svelte-css/StatusBadge-3mq0j7z6aq4j.css +0 -1
  100. package/.mochi/svelte-css/TerminalPane-3bwgk6ulzq6b3.css +0 -1
  101. 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,44 @@ function stripJsonc(input: string): string {
416
427
  return out;
417
428
  }
418
429
 
419
- /** Follows the CLI's precedence: `.devcontainer/devcontainer.json`, then `.devcontainer.json`. */
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
- function configPath(workspaceDir: string): string {
429
- // Falls back to the nested path because that's where we'd create one.
430
- return (
431
- findDevcontainerConfig(workspaceDir) ?? join(workspaceDir, '.devcontainer', 'devcontainer.json')
432
- );
456
+ const CODEBAY_CONFIG_NESTED = 'codebay.devcontainer.json';
457
+ const CODEBAY_CONFIG_FLAT = '.codebay.devcontainer.json';
458
+
459
+ /** Sits in the same directory as the project's own config so relative paths resolve identically. */
460
+ export function overrideConfigPath(
461
+ workspaceDir: string,
462
+ canonical: string | null = findDevcontainerConfig(workspaceDir)
463
+ ): string {
464
+ if (!canonical) return join(workspaceDir, '.devcontainer', CODEBAY_CONFIG_NESTED);
465
+ return basename(canonical) === '.devcontainer.json'
466
+ ? join(workspaceDir, CODEBAY_CONFIG_FLAT)
467
+ : join(dirname(canonical), CODEBAY_CONFIG_NESTED);
433
468
  }
434
469
 
435
470
  type DevcontainerConfig = {
@@ -450,10 +485,10 @@ function containerPortOf(entry: number | string): number {
450
485
  return Number.parseInt(last, 10);
451
486
  }
452
487
 
453
- /** Only meaningful on a pristine, pre-injection config; code-server's own port is excluded. */
488
+ /** Reads the project's own config (never Codebay's merged one); reserved ports are excluded. */
454
489
  export async function readDeclaredContainerPorts(workspaceDir: string): Promise<number[]> {
455
- const target = configPath(workspaceDir);
456
- if (!existsSync(target)) return [];
490
+ const target = findDevcontainerConfig(workspaceDir);
491
+ if (!target) return [];
457
492
  let config: DevcontainerConfig;
458
493
  try {
459
494
  config = JSON.parse(stripJsonc(await readFile(target, 'utf8'))) as DevcontainerConfig;
@@ -487,7 +522,10 @@ export async function readDeclaredContainerPorts(workspaceDir: string): Promise<
487
522
  /** Maps `host.docker.internal` to the host so the in-container attention bridge resolves. */
488
523
  const HOST_GATEWAY_ARG = '--add-host=host.docker.internal:host-gateway';
489
524
 
490
- /** Operates on the copy, never the user's original, so rewriting and normalizing is safe. */
525
+ /**
526
+ * Merges the project's config (read-only) into a Codebay-owned sibling file that `devcontainer up
527
+ * --config` boots from, so the project's own devcontainer.json never shows as modified in git.
528
+ */
491
529
  export async function writeOverrideConfig(
492
530
  workspaceDir: string,
493
531
  hostPort: number,
@@ -496,31 +534,37 @@ export async function writeOverrideConfig(
496
534
  mode: InstanceMode = 'ide',
497
535
  permissionMode: ClaudePermissionMode = 'default',
498
536
  envVars: { name: string; value: string }[] = []
499
- ): Promise<{ imageSource: string }> {
537
+ ): Promise<{ imageSource: string; configPath: string }> {
500
538
  const isTerminal = mode === 'terminal';
501
- const target = configPath(workspaceDir);
539
+ const canonical = findDevcontainerConfig(workspaceDir);
540
+ const target = overrideConfigPath(workspaceDir, canonical);
502
541
  let config: DevcontainerConfig = {};
503
542
 
504
- const hadConfig = existsSync(target);
543
+ const hadConfig = canonical !== null;
505
544
  const imageSource = hadConfig ? 'local' : defaultImage;
506
545
 
507
- if (hadConfig) {
508
- const raw = await readFile(target, 'utf8');
546
+ if (canonical) {
547
+ const raw = await readFile(canonical, 'utf8');
509
548
  try {
510
549
  config = JSON.parse(stripJsonc(raw)) as DevcontainerConfig;
511
550
  } catch (err) {
512
551
  throw new Error(
513
- `Could not parse existing devcontainer.json at ${target}: ${(err as Error).message}`,
552
+ `Could not parse existing devcontainer.json at ${canonical}: ${(err as Error).message}`,
514
553
  { cause: err }
515
554
  );
516
555
  }
556
+ // A config restoreCanonicalConfig couldn't clean (injection committed, or no .git to restore
557
+ // from) must not feed its baked-in injections back into the merge.
558
+ if (raw.includes(OVERRIDE_FINGERPRINT)) stripLegacyInjections(config);
517
559
  } else {
518
560
  config.image = defaultImage;
519
561
  }
520
562
 
521
- // A local feature is addressed relative to the config, which sits at one of two depths.
522
- const featureKey = (dir: string) =>
523
- `./${relative(dirname(target), join(workspaceDir, '.devcontainer', dir))}`;
563
+ // Local features are staged next to a subfolder-form config (the CLI only promises to resolve
564
+ // `./`-descendant feature paths), and under `.devcontainer/` for the two classic forms.
565
+ const featureRoot =
566
+ dirname(target) === workspaceDir ? join(workspaceDir, '.devcontainer') : dirname(target);
567
+ const featureKey = (dir: string) => `./${relative(dirname(target), join(featureRoot, dir))}`;
524
568
  const tmuxFeatureKey = featureKey(TMUX_FEATURE_DIR);
525
569
  const ttydFeatureKey = featureKey(TTYD_FEATURE_DIR);
526
570
  const claudeFeatureKey = featureKey(CLAUDE_FEATURE_DIR);
@@ -577,7 +621,7 @@ export async function writeOverrideConfig(
577
621
  await writeFile(target, JSON.stringify(config, null, 2) + '\n', 'utf8');
578
622
 
579
623
  if (isTerminal) {
580
- await writeTtydFeature(workspaceDir);
624
+ await writeTtydFeature(featureRoot);
581
625
  await writeTerminalLaunchScript(workspaceDir, permissionMode);
582
626
  } else {
583
627
  // Staged next to the config; codeServerLaunch copies it into the user-data dir on first start.
@@ -590,13 +634,113 @@ export async function writeOverrideConfig(
590
634
  await writeTerminalTask(workspaceDir, permissionMode);
591
635
  }
592
636
 
593
- await writeTmuxFeature(workspaceDir);
637
+ await writeTmuxFeature(featureRoot);
594
638
 
595
- if (isTerminal && hadConfig) await writeClaudeFeature(workspaceDir);
639
+ if (isTerminal && hadConfig) await writeClaudeFeature(featureRoot);
596
640
 
597
641
  await writeLocalGitExclude(workspaceDir);
598
642
 
599
- return { imageSource };
643
+ return { imageSource, configPath: target };
644
+ }
645
+
646
+ /** `codebay-tmux` has been injected into every config this manager has ever written. */
647
+ const OVERRIDE_FINGERPRINT = TMUX_FEATURE_DIR;
648
+
649
+ /** Where a destructive restore stashes the pre-restore file, in case it held user edits. */
650
+ const LEGACY_BACKUP_SUFFIX = '.codebay-backup';
651
+
652
+ /**
653
+ * Removes what the legacy in-place scheme baked into a config that git couldn't restore — left as
654
+ * merge input, the stale features would resurrect and the launcher would chain onto itself.
655
+ */
656
+ function stripLegacyInjections(config: DevcontainerConfig): void {
657
+ if (config.features) {
658
+ for (const key of Object.keys(config.features)) {
659
+ if (
660
+ key === CODE_SERVER_FEATURE ||
661
+ key.includes(TMUX_FEATURE_DIR) ||
662
+ key.includes(TTYD_FEATURE_DIR) ||
663
+ key.includes(CLAUDE_FEATURE_DIR)
664
+ ) {
665
+ delete config.features[key];
666
+ }
667
+ }
668
+ }
669
+ if (typeof config.postStartCommand === 'string') {
670
+ const cmd = config.postStartCommand;
671
+ // The legacy launcher was always appended last, as a `bash -c "…nohup code-server/ttyd…"` tail.
672
+ const start = cmd.lastIndexOf('bash -c "');
673
+ if (start !== -1 && /nohup (?:code-server|ttyd) /.test(cmd.slice(start))) {
674
+ const head = cmd
675
+ .slice(0, start)
676
+ .replace(/\s*&&\s*$/, '')
677
+ .trim();
678
+ if (head) config.postStartCommand = head;
679
+ else delete config.postStartCommand;
680
+ }
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Undoes the legacy scheme that overwrote the project's own devcontainer.json in place: restores
686
+ * the tracked file (or deletes the one Codebay created) so the instance's git status comes clean.
687
+ * `'unknown'` means git itself failed to answer, so the caller should retry on the next rebuild.
688
+ */
689
+ export async function restoreCanonicalConfig(
690
+ workspaceDir: string
691
+ ): Promise<'restored' | 'deleted' | 'none' | 'unknown'> {
692
+ const canonical = findDevcontainerConfig(workspaceDir);
693
+ if (!canonical) return 'none';
694
+
695
+ let content: string;
696
+ try {
697
+ content = await readFile(canonical, 'utf8');
698
+ } catch {
699
+ return 'none';
700
+ }
701
+ if (!content.includes(OVERRIDE_FINGERPRINT)) return 'none';
702
+
703
+ // Only a real .git directory can vouch for the file's provenance (same check as the excludes).
704
+ const gitDir = join(workspaceDir, '.git');
705
+ if (!existsSync(gitDir) || !statSync(gitDir).isDirectory()) return 'none';
706
+
707
+ const relPath = relative(workspaceDir, canonical);
708
+ const tracked = await isTrackedFile(workspaceDir, relPath);
709
+ if (tracked === null) return 'unknown'; // never destroy a config on a guess
710
+
711
+ // The file may hold user edits on top of the injection — stash it before anything destructive.
712
+ const backup = canonical + LEGACY_BACKUP_SUFFIX;
713
+ const dropBackup = () => unlink(backup).catch(() => {});
714
+ try {
715
+ await writeFile(backup, content, 'utf8');
716
+ } catch {
717
+ // Best-effort: a failed backup shouldn't block the migration itself.
718
+ }
719
+
720
+ if (tracked) {
721
+ if (!(await restoreTrackedFile(workspaceDir, relPath))) {
722
+ await dropBackup();
723
+ return 'unknown';
724
+ }
725
+ const after = await readFile(canonical, 'utf8').catch(() => null);
726
+ if (after === content) {
727
+ // The injection was committed inside the instance, so git has nothing cleaner to offer;
728
+ // stripLegacyInjections keeps it out of the merge instead.
729
+ await dropBackup();
730
+ return 'none';
731
+ }
732
+ return 'restored';
733
+ }
734
+
735
+ // Fingerprinted but untracked: Codebay created it for a config-less project — remove it so the
736
+ // next merge falls back to the default image again.
737
+ try {
738
+ await unlink(canonical);
739
+ } catch {
740
+ await dropBackup();
741
+ return 'unknown';
742
+ }
743
+ return 'deleted';
600
744
  }
601
745
 
602
746
  /**
@@ -669,8 +813,8 @@ async function writeLocalGitExclude(workspaceDir: string): Promise<void> {
669
813
  await writeFile(excludePath, existing ? `${existing}\n\n${block}` : block, 'utf8');
670
814
  }
671
815
 
672
- async function writeTmuxFeature(workspaceDir: string): Promise<void> {
673
- const dir = join(workspaceDir, '.devcontainer', TMUX_FEATURE_DIR);
816
+ async function writeTmuxFeature(featureRoot: string): Promise<void> {
817
+ const dir = join(featureRoot, TMUX_FEATURE_DIR);
674
818
  await mkdir(dir, { recursive: true }).catch(() => {});
675
819
  await writeFile(
676
820
  join(dir, 'devcontainer-feature.json'),
@@ -683,8 +827,8 @@ async function writeTmuxFeature(workspaceDir: string): Promise<void> {
683
827
  await chmod(installPath, 0o755);
684
828
  }
685
829
 
686
- async function writeClaudeFeature(workspaceDir: string): Promise<void> {
687
- const dir = join(workspaceDir, '.devcontainer', CLAUDE_FEATURE_DIR);
830
+ async function writeClaudeFeature(featureRoot: string): Promise<void> {
831
+ const dir = join(featureRoot, CLAUDE_FEATURE_DIR);
688
832
  await mkdir(dir, { recursive: true }).catch(() => {});
689
833
  await writeFile(
690
834
  join(dir, 'devcontainer-feature.json'),
@@ -696,8 +840,8 @@ async function writeClaudeFeature(workspaceDir: string): Promise<void> {
696
840
  await chmod(installPath, 0o755);
697
841
  }
698
842
 
699
- async function writeTtydFeature(workspaceDir: string): Promise<void> {
700
- const dir = join(workspaceDir, '.devcontainer', TTYD_FEATURE_DIR);
843
+ async function writeTtydFeature(featureRoot: string): Promise<void> {
844
+ const dir = join(featureRoot, TTYD_FEATURE_DIR);
701
845
  await mkdir(dir, { recursive: true }).catch(() => {});
702
846
  await writeFile(
703
847
  join(dir, 'devcontainer-feature.json'),
@@ -758,7 +902,10 @@ export interface UpResult {
758
902
  description?: string;
759
903
  }
760
904
 
761
- export function devcontainerUpArgs(workspaceDir: string, opts: { noCache?: boolean } = {}) {
905
+ export function devcontainerUpArgs(
906
+ workspaceDir: string,
907
+ opts: { noCache?: boolean; configPath?: string } = {}
908
+ ) {
762
909
  const args = [
763
910
  devcontainerBin(),
764
911
  'up',
@@ -766,6 +913,7 @@ export function devcontainerUpArgs(workspaceDir: string, opts: { noCache?: boole
766
913
  workspaceDir,
767
914
  '--remove-existing-container'
768
915
  ];
916
+ if (opts.configPath) args.push('--config', opts.configPath);
769
917
  // Only takes effect because --remove-existing-container drops the container before the build.
770
918
  if (opts.noCache) args.push('--build-no-cache');
771
919
  return args;
@@ -783,7 +931,7 @@ export function devcontainerUpEnv(): Record<string, string | undefined> {
783
931
  export async function devcontainerUp(
784
932
  workspaceDir: string,
785
933
  onLog: (chunk: string) => void,
786
- opts: { noCache?: boolean } = {}
934
+ opts: { noCache?: boolean; configPath?: string } = {}
787
935
  ): Promise<UpResult> {
788
936
  const proc = Bun.spawn(devcontainerUpArgs(workspaceDir, opts), {
789
937
  cwd: workspaceDir,
@@ -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();
@@ -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
- /** Must run before config injection, while the copied `devcontainer.json` is still pristine. */
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 } = await writeOverrideConfig(
442
471
  row.workspace_path,
443
472
  row.host_port,
444
473
  forwards,
@@ -452,10 +481,20 @@ 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, which
485
+ // no longer matches containers built from the pre-separate-config path — drop them ourselves.
486
+ if (row.container_id && !(await removeContainerOnly(row.container_id))) {
487
+ appendLog(
488
+ row.id,
489
+ `⚠ Could not remove the previous container (${row.container_id.slice(0, 12)}) — if the boot fails on a busy port, remove it manually\n`
490
+ );
491
+ }
492
+
455
493
  appendLog(row.id, `Starting devcontainer…\n`);
456
494
  const upStart = Date.now();
457
495
  const result = await devcontainerUp(row.workspace_path, (chunk) => appendLog(row.id, chunk), {
458
- noCache
496
+ noCache,
497
+ configPath
459
498
  });
460
499
  appendLog(row.id, `⏱ devcontainer up: ${elapsed(upStart)}\n`);
461
500
 
@@ -592,7 +631,9 @@ export async function createInstance(
592
631
  // Kept synchronous through insertInstance so two concurrent creations can't claim the same free sprite.
593
632
  avatar: pickUniqueAvatar(id, allInstances().map(effectiveAvatarName)).name,
594
633
  mode: opts.mode ?? getDefaultMode(),
595
- terminal_split: 0
634
+ terminal_split: 0,
635
+ // Born under the separate-config scheme, so there is never a legacy injection to undo.
636
+ config_migrated: 1
596
637
  };
597
638
  insertInstance(row);
598
639
  // 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),