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.
Files changed (101) hide show
  1. package/.mochi/manifest.json +196 -170
  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-32j12hyg8v9u0.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 +28 -22
  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 +303 -64
  65. package/src/lib/db.server.ts +7 -3
  66. package/src/lib/devcontainer.server.ts +187 -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 +49 -6
  70. package/src/lib/tab-nav.ts +30 -0
  71. package/src/routes.ts +12 -0
  72. package/src/shell.html +60 -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,47 @@ 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
+ /**
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
- /** Only meaningful on a pristine, pre-injection config; code-server's own port is excluded. */
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 = configPath(workspaceDir);
456
- if (!existsSync(target)) return [];
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
- /** Operates on the copy, never the user's original, so rewriting and normalizing is safe. */
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 target = configPath(workspaceDir);
542
+ const canonical = findDevcontainerConfig(workspaceDir);
543
+ const target = overrideConfigPath(workspaceDir, canonical);
502
544
  let config: DevcontainerConfig = {};
503
545
 
504
- const hadConfig = existsSync(target);
546
+ const hadConfig = canonical !== null;
505
547
  const imageSource = hadConfig ? 'local' : defaultImage;
506
548
 
507
- if (hadConfig) {
508
- const raw = await readFile(target, 'utf8');
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 ${target}: ${(err as Error).message}`,
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
- // 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))}`;
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(workspaceDir);
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(workspaceDir);
640
+ await writeTmuxFeature(featureRoot);
594
641
 
595
- if (isTerminal && hadConfig) await writeClaudeFeature(workspaceDir);
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(workspaceDir: string): Promise<void> {
673
- const dir = join(workspaceDir, '.devcontainer', TMUX_FEATURE_DIR);
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(workspaceDir: string): Promise<void> {
687
- const dir = join(workspaceDir, '.devcontainer', CLAUDE_FEATURE_DIR);
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(workspaceDir: string): Promise<void> {
700
- const dir = join(workspaceDir, '.devcontainer', TTYD_FEATURE_DIR);
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
- export function devcontainerUpArgs(workspaceDir: string, opts: { noCache?: boolean } = {}) {
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: { noCache?: boolean } = {}
940
+ opts: UpOptions = {}
787
941
  ): Promise<UpResult> {
788
942
  const proc = Bun.spawn(devcontainerUpArgs(workspaceDir, opts), {
789
943
  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, 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),