codebay 0.10.0 → 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 +193 -167
  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 +211 -38
  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 +117 -18
  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/',
@@ -150,6 +161,16 @@ export const INJECTIONS_DONE_FILE = '.codebay-injections-done';
150
161
  */
151
162
  export const TERMINAL_LAUNCHED_MARKER = '.codebay-terminal-launched';
152
163
 
164
+ /**
165
+ * Far above the ~107s a real injection phase measures: at 60s this expired mid-phase and released
166
+ * the launcher into the window where `claude-code-update`'s `npm install -g` had unlinked the
167
+ * binary. `provision()` writes the sentinel on its failure path too, so a dead boot never waits here.
168
+ */
169
+ const INJECTIONS_WAIT_SECONDS = 600;
170
+
171
+ /** Long enough to ride out an in-flight `npm install -g` of Claude Code. */
172
+ const CLAUDE_BINARY_WAIT_SECONDS = 60;
173
+
153
174
  /**
154
175
  * An auto-launched `claude` that starts mid-injection reads `~/.claude.json` before the trust
155
176
  * keys land and clobbers them on its next rewrite, so hold it until the sentinel appears.
@@ -157,7 +178,20 @@ export const TERMINAL_LAUNCHED_MARKER = '.codebay-terminal-launched';
157
178
  */
158
179
  const WAIT_FOR_INJECTIONS =
159
180
  `[ -e "$HOME/${INJECTIONS_DONE_FILE}" ] || echo "Waiting for codebay setup to finish…"; ` +
160
- `i=0; until [ -e "$HOME/${INJECTIONS_DONE_FILE}" ] || [ "$i" -ge 60 ]; do sleep 1; i=$((i + 1)); done; `;
181
+ `i=0; until [ -e "$HOME/${INJECTIONS_DONE_FILE}" ] || [ "$i" -ge ${INJECTIONS_WAIT_SECONDS} ]; do sleep 1; i=$((i + 1)); done; ` +
182
+ `[ -e "$HOME/${INJECTIONS_DONE_FILE}" ] || echo "codebay: setup did not finish in time — starting anyway"; `;
183
+
184
+ /**
185
+ * Never exec a binary that may not be there: the update injection unlinks `claude` while it
186
+ * reinstalls, and an IDE-mode project image is free to ship no Claude Code at all — both used to
187
+ * surface as a bare `command not found` under the "Waiting for codebay setup" line. The retry is
188
+ * skipped once the sentinel exists, since no injection can be mid-reinstall by then and a missing
189
+ * binary is permanent. No single quotes: this is spliced into the single-quoted tmux commands below.
190
+ */
191
+ const launchClaude = (permissionMode: ClaudePermissionMode): string =>
192
+ `if [ ! -e "$HOME/${INJECTIONS_DONE_FILE}" ]; then i=0; until command -v claude >/dev/null 2>&1 || [ "$i" -ge ${CLAUDE_BINARY_WAIT_SECONDS} ]; do sleep 1; i=$((i + 1)); done; fi; ` +
193
+ `if command -v claude >/dev/null 2>&1; then claude ${claudePermissionFlags(permissionMode)}; ` +
194
+ `else echo "codebay: claude is not installed in this container — check the boot log, then Rebuild"; fi; `;
161
195
 
162
196
  /**
163
197
  * claude scans `~/.claude/ide/*.lock` once at startup and never retries, so it must not race
@@ -192,7 +226,7 @@ const terminalTask = (permissionMode: ClaudePermissionMode) => ({
192
226
  type: 'shell',
193
227
  command:
194
228
  // `"$SHELL"` must reach tmux unexpanded; VS Code would substitute a `${…}` form first.
195
- `if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s ${TMUX_SESSION} '${WAIT_FOR_INJECTIONS}${WAIT_FOR_IDE_BRIDGE}${SOURCE_INJECTED_ENV}claude ${claudePermissionFlags(permissionMode)}; exec "$SHELL" -l'; fi; ` +
229
+ `if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s ${TMUX_SESSION} '${WAIT_FOR_INJECTIONS}${WAIT_FOR_IDE_BRIDGE}${SOURCE_INJECTED_ENV}${launchClaude(permissionMode)}exec "$SHELL" -l'; fi; ` +
196
230
  // Without tmux there's no run-once gate, and folderOpen re-fires on every workspace load.
197
231
  'MARK="$HOME/' +
198
232
  TERMINAL_LAUNCHED_MARKER +
@@ -200,7 +234,8 @@ const terminalTask = (permissionMode: ClaudePermissionMode) => ({
200
234
  WAIT_FOR_INJECTIONS +
201
235
  WAIT_FOR_IDE_BRIDGE +
202
236
  SOURCE_INJECTED_ENV +
203
- `claude ${claudePermissionFlags(permissionMode)}; exec \${env:SHELL} -l`,
237
+ launchClaude(permissionMode) +
238
+ 'exec ${env:SHELL} -l',
204
239
  presentation: { reveal: 'always', panel: 'shared', focus: true },
205
240
  runOptions: { runOn: 'folderOpen' },
206
241
  problemMatcher: []
@@ -273,11 +308,12 @@ const ttydLaunchScript = (permissionMode: ClaudePermissionMode): string =>
273
308
  WAIT_FOR_INJECTIONS +
274
309
  '\n' +
275
310
  `if command -v tmux >/dev/null 2>&1; then\n` +
276
- ` exec tmux new-session -A -s ${TMUX_SESSION} '${SOURCE_INJECTED_ENV}claude ${claudePermissionFlags(permissionMode)}; exec "$SHELL" -l'\n` +
311
+ ` exec tmux new-session -A -s ${TMUX_SESSION} '${SOURCE_INJECTED_ENV}${launchClaude(permissionMode)}exec "$SHELL" -l'\n` +
277
312
  'fi\n' +
278
313
  SOURCE_INJECTED_ENV +
279
314
  '\n' +
280
- `claude ${claudePermissionFlags(permissionMode)}\n` +
315
+ launchClaude(permissionMode) +
316
+ '\n' +
281
317
  'exec "$SHELL" -l\n';
282
318
 
283
319
  // ttyd defaults to read-only, so --writable is required for keyboard input. Guarded by `pgrep -x`
@@ -391,20 +427,44 @@ function stripJsonc(input: string): string {
391
427
  return out;
392
428
  }
393
429
 
394
- /** 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
+ */
395
434
  export function findDevcontainerConfig(dir: string): string | null {
396
435
  const nested = join(dir, '.devcontainer', 'devcontainer.json');
397
436
  if (existsSync(nested)) return nested;
398
437
  const flat = join(dir, '.devcontainer.json');
399
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
+ }
400
453
  return null;
401
454
  }
402
455
 
403
- function configPath(workspaceDir: string): string {
404
- // Falls back to the nested path because that's where we'd create one.
405
- return (
406
- findDevcontainerConfig(workspaceDir) ?? join(workspaceDir, '.devcontainer', 'devcontainer.json')
407
- );
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);
408
468
  }
409
469
 
410
470
  type DevcontainerConfig = {
@@ -425,10 +485,10 @@ function containerPortOf(entry: number | string): number {
425
485
  return Number.parseInt(last, 10);
426
486
  }
427
487
 
428
- /** 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. */
429
489
  export async function readDeclaredContainerPorts(workspaceDir: string): Promise<number[]> {
430
- const target = configPath(workspaceDir);
431
- if (!existsSync(target)) return [];
490
+ const target = findDevcontainerConfig(workspaceDir);
491
+ if (!target) return [];
432
492
  let config: DevcontainerConfig;
433
493
  try {
434
494
  config = JSON.parse(stripJsonc(await readFile(target, 'utf8'))) as DevcontainerConfig;
@@ -462,7 +522,10 @@ export async function readDeclaredContainerPorts(workspaceDir: string): Promise<
462
522
  /** Maps `host.docker.internal` to the host so the in-container attention bridge resolves. */
463
523
  const HOST_GATEWAY_ARG = '--add-host=host.docker.internal:host-gateway';
464
524
 
465
- /** 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
+ */
466
529
  export async function writeOverrideConfig(
467
530
  workspaceDir: string,
468
531
  hostPort: number,
@@ -471,31 +534,37 @@ export async function writeOverrideConfig(
471
534
  mode: InstanceMode = 'ide',
472
535
  permissionMode: ClaudePermissionMode = 'default',
473
536
  envVars: { name: string; value: string }[] = []
474
- ): Promise<{ imageSource: string }> {
537
+ ): Promise<{ imageSource: string; configPath: string }> {
475
538
  const isTerminal = mode === 'terminal';
476
- const target = configPath(workspaceDir);
539
+ const canonical = findDevcontainerConfig(workspaceDir);
540
+ const target = overrideConfigPath(workspaceDir, canonical);
477
541
  let config: DevcontainerConfig = {};
478
542
 
479
- const hadConfig = existsSync(target);
543
+ const hadConfig = canonical !== null;
480
544
  const imageSource = hadConfig ? 'local' : defaultImage;
481
545
 
482
- if (hadConfig) {
483
- const raw = await readFile(target, 'utf8');
546
+ if (canonical) {
547
+ const raw = await readFile(canonical, 'utf8');
484
548
  try {
485
549
  config = JSON.parse(stripJsonc(raw)) as DevcontainerConfig;
486
550
  } catch (err) {
487
551
  throw new Error(
488
- `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}`,
489
553
  { cause: err }
490
554
  );
491
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);
492
559
  } else {
493
560
  config.image = defaultImage;
494
561
  }
495
562
 
496
- // A local feature is addressed relative to the config, which sits at one of two depths.
497
- const featureKey = (dir: string) =>
498
- `./${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))}`;
499
568
  const tmuxFeatureKey = featureKey(TMUX_FEATURE_DIR);
500
569
  const ttydFeatureKey = featureKey(TTYD_FEATURE_DIR);
501
570
  const claudeFeatureKey = featureKey(CLAUDE_FEATURE_DIR);
@@ -552,7 +621,7 @@ export async function writeOverrideConfig(
552
621
  await writeFile(target, JSON.stringify(config, null, 2) + '\n', 'utf8');
553
622
 
554
623
  if (isTerminal) {
555
- await writeTtydFeature(workspaceDir);
624
+ await writeTtydFeature(featureRoot);
556
625
  await writeTerminalLaunchScript(workspaceDir, permissionMode);
557
626
  } else {
558
627
  // Staged next to the config; codeServerLaunch copies it into the user-data dir on first start.
@@ -565,13 +634,113 @@ export async function writeOverrideConfig(
565
634
  await writeTerminalTask(workspaceDir, permissionMode);
566
635
  }
567
636
 
568
- await writeTmuxFeature(workspaceDir);
637
+ await writeTmuxFeature(featureRoot);
569
638
 
570
- if (isTerminal && hadConfig) await writeClaudeFeature(workspaceDir);
639
+ if (isTerminal && hadConfig) await writeClaudeFeature(featureRoot);
571
640
 
572
641
  await writeLocalGitExclude(workspaceDir);
573
642
 
574
- 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';
575
744
  }
576
745
 
577
746
  /**
@@ -644,8 +813,8 @@ async function writeLocalGitExclude(workspaceDir: string): Promise<void> {
644
813
  await writeFile(excludePath, existing ? `${existing}\n\n${block}` : block, 'utf8');
645
814
  }
646
815
 
647
- async function writeTmuxFeature(workspaceDir: string): Promise<void> {
648
- const dir = join(workspaceDir, '.devcontainer', TMUX_FEATURE_DIR);
816
+ async function writeTmuxFeature(featureRoot: string): Promise<void> {
817
+ const dir = join(featureRoot, TMUX_FEATURE_DIR);
649
818
  await mkdir(dir, { recursive: true }).catch(() => {});
650
819
  await writeFile(
651
820
  join(dir, 'devcontainer-feature.json'),
@@ -658,8 +827,8 @@ async function writeTmuxFeature(workspaceDir: string): Promise<void> {
658
827
  await chmod(installPath, 0o755);
659
828
  }
660
829
 
661
- async function writeClaudeFeature(workspaceDir: string): Promise<void> {
662
- const dir = join(workspaceDir, '.devcontainer', CLAUDE_FEATURE_DIR);
830
+ async function writeClaudeFeature(featureRoot: string): Promise<void> {
831
+ const dir = join(featureRoot, CLAUDE_FEATURE_DIR);
663
832
  await mkdir(dir, { recursive: true }).catch(() => {});
664
833
  await writeFile(
665
834
  join(dir, 'devcontainer-feature.json'),
@@ -671,8 +840,8 @@ async function writeClaudeFeature(workspaceDir: string): Promise<void> {
671
840
  await chmod(installPath, 0o755);
672
841
  }
673
842
 
674
- async function writeTtydFeature(workspaceDir: string): Promise<void> {
675
- const dir = join(workspaceDir, '.devcontainer', TTYD_FEATURE_DIR);
843
+ async function writeTtydFeature(featureRoot: string): Promise<void> {
844
+ const dir = join(featureRoot, TTYD_FEATURE_DIR);
676
845
  await mkdir(dir, { recursive: true }).catch(() => {});
677
846
  await writeFile(
678
847
  join(dir, 'devcontainer-feature.json'),
@@ -733,7 +902,10 @@ export interface UpResult {
733
902
  description?: string;
734
903
  }
735
904
 
736
- export function devcontainerUpArgs(workspaceDir: string, opts: { noCache?: boolean } = {}) {
905
+ export function devcontainerUpArgs(
906
+ workspaceDir: string,
907
+ opts: { noCache?: boolean; configPath?: string } = {}
908
+ ) {
737
909
  const args = [
738
910
  devcontainerBin(),
739
911
  'up',
@@ -741,6 +913,7 @@ export function devcontainerUpArgs(workspaceDir: string, opts: { noCache?: boole
741
913
  workspaceDir,
742
914
  '--remove-existing-container'
743
915
  ];
916
+ if (opts.configPath) args.push('--config', opts.configPath);
744
917
  // Only takes effect because --remove-existing-container drops the container before the build.
745
918
  if (opts.noCache) args.push('--build-no-cache');
746
919
  return args;
@@ -758,7 +931,7 @@ export function devcontainerUpEnv(): Record<string, string | undefined> {
758
931
  export async function devcontainerUp(
759
932
  workspaceDir: string,
760
933
  onLog: (chunk: string) => void,
761
- opts: { noCache?: boolean } = {}
934
+ opts: { noCache?: boolean; configPath?: string } = {}
762
935
  ): Promise<UpResult> {
763
936
  const proc = Bun.spawn(devcontainerUpArgs(workspaceDir, opts), {
764
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.