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.
- package/.mochi/manifest.json +199 -173
- package/.mochi/svelte-client/HydratableIsland-dammyce5.js +1 -0
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-v65ctd99.js +2 -0
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-htjfv8q9.js +14 -0
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-v07seqge.js +1 -0
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-hj9yvqc4.js +38 -0
- package/.mochi/svelte-client/chunk-0zkdsrth.js +2 -0
- package/.mochi/svelte-client/{chunk-bn731rda.js → chunk-566rqq4c.js} +2 -2
- package/.mochi/svelte-client/chunk-79aj59e0.js +2 -0
- package/.mochi/svelte-client/{chunk-wvtkwfsh.js → chunk-czkxd74n.js} +1 -1
- package/.mochi/svelte-client/{chunk-p20tkpde.js → chunk-hxydjtda.js} +1 -1
- package/.mochi/svelte-client/chunk-qx1defbm.js +2 -0
- package/.mochi/svelte-client/chunk-r3wxmmc0.js +2 -0
- package/.mochi/svelte-client/{chunk-j72r8pkk.js → chunk-vs6pd2b4.js} +2 -2
- package/.mochi/svelte-client/{chunk-t0ap7q89.js → chunk-wjagstre.js} +1 -1
- package/.mochi/svelte-client/{chunk-0fjx3z3r.js → chunk-y9yy1004.js} +1 -1
- package/.mochi/svelte-compile/{App-59cdj86f.server.js → App-mzn3dqjm.server.js} +182 -72
- package/.mochi/svelte-compile/{Avatars-fmagfyj2.server.js → Avatars-c0k5jx8q.server.js} +3 -6
- package/.mochi/svelte-compile/{Instance-s1gqwzmx.server.js → Instance-hj76qnha.server.js} +4 -4
- package/.mochi/svelte-compile/{Settings-k447mt0x.server.js → Settings-tvc5yp08.server.js} +39 -28
- package/.mochi/svelte-compile/chunk-ed46m58e.js +22 -0
- package/.mochi/svelte-compile/{chunk-th2dbqvc.js → chunk-gz6hv1h5.js} +5 -1
- package/.mochi/svelte-compile/chunk-zd49axkf.js +65 -0
- package/.mochi/svelte-css/{AppBar-3bxinv4pabv8l.css → AppBar-1gtu2me3fni8e.css} +1 -1
- package/.mochi/svelte-css/AvatarEditor-1mjvec6bwdkv8.css +1 -0
- package/.mochi/svelte-css/{AvatarsView-2llo48w0wt0xn.css → AvatarsView-26ya4ep5n1ze7.css} +1 -1
- package/.mochi/svelte-css/Button-208u50wkms8ya.css +1 -0
- package/.mochi/svelte-css/CoinButton-2727devjtsn10.css +1 -0
- package/.mochi/svelte-css/CredMenu-7qowd1oxujet.css +1 -0
- package/.mochi/svelte-css/DashboardView-17a0b8epjurhb.css +1 -0
- package/.mochi/svelte-css/FolderBrowser-i0uj58dph27f.css +1 -0
- package/.mochi/svelte-css/HealthBox-r0giuhjn2wj4.css +1 -0
- package/.mochi/svelte-css/IdeBar-1mtgemqfuvqzi.css +1 -0
- package/.mochi/svelte-css/InstanceCard-25z7nas7y98zk.css +1 -0
- package/.mochi/svelte-css/InstanceFilter-2vglmyy0yrbbc.css +1 -0
- package/.mochi/svelte-css/InstanceView-oozmmctp8m3p.css +1 -0
- package/.mochi/svelte-css/SettingsCog-1jdeprd3ilczz.css +1 -0
- package/.mochi/svelte-css/{SettingsView-n3oy75uonzp2.css → SettingsView-bccw34agd2bj.css} +1 -1
- package/.mochi/svelte-css/StatusBadge-2eisdtnrsnfdc.css +1 -0
- package/.mochi/svelte-css/TerminalPane-2ywc86rhtmr9l.css +1 -0
- package/.mochi/svelte-css/ThemePicker-3fw4synubier0.css +1 -0
- package/migrations/0008_config_migrated.sql +1 -0
- package/package.json +8 -8
- package/src/components/AppBar.svelte +2 -2
- package/src/components/AppShell.svelte +71 -7
- package/src/components/AvatarEditor.svelte +10 -10
- package/src/components/AvatarsView.svelte +0 -0
- package/src/components/Button.svelte +13 -9
- package/src/components/CoinButton.svelte +2 -2
- package/src/components/CredMenu.svelte +8 -6
- package/src/components/DashboardView.svelte +2 -2
- package/src/components/FolderBrowser.svelte +39 -36
- package/src/components/HealthBox.svelte +5 -4
- package/src/components/IdeBar.svelte +286 -112
- package/src/components/InstanceCard.svelte +7 -7
- package/src/components/InstanceFilter.svelte +6 -6
- package/src/components/InstanceView.svelte +27 -21
- package/src/components/SettingsCog.svelte +4 -3
- package/src/components/SettingsView.svelte +7 -11
- package/src/components/StatusBadge.svelte +8 -6
- package/src/components/TerminalPane.svelte +19 -7
- package/src/components/ThemePicker.svelte +150 -8
- package/src/components/TopBar.svelte +1 -1
- package/src/components/UiShowcase.svelte +300 -64
- package/src/lib/db.server.ts +7 -3
- package/src/lib/devcontainer.server.ts +181 -33
- package/src/lib/docker.server.ts +10 -0
- package/src/lib/git.server.ts +28 -0
- package/src/lib/instances.server.ts +47 -6
- package/src/lib/tab-nav.ts +30 -0
- package/src/routes.ts +12 -0
- package/src/shell.html +56 -23
- package/src/theme.ts +8 -1
- package/src/toast.ts +9 -0
- package/src/types.ts +7 -0
- package/.mochi/svelte-client/HydratableIsland-aebn1fa1.js +0 -1
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-rx6arrv5.js +0 -2
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-jpjdmgpe.js +0 -14
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-c9y47dat.js +0 -1
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-d61feq89.js +0 -38
- package/.mochi/svelte-client/chunk-8xvr02wh.js +0 -2
- package/.mochi/svelte-client/chunk-9143wy3j.js +0 -2
- package/.mochi/svelte-client/chunk-k37wj1jm.js +0 -2
- package/.mochi/svelte-client/chunk-pc0fm37c.js +0 -2
- package/.mochi/svelte-compile/chunk-3t7v4hk0.js +0 -52
- package/.mochi/svelte-compile/chunk-gkjgybb2.js +0 -15
- package/.mochi/svelte-css/AvatarEditor-2mdq8ss346drw.css +0 -1
- package/.mochi/svelte-css/Button-oharop2xz6am.css +0 -1
- package/.mochi/svelte-css/CoinButton-1rej3sh3tkjud.css +0 -1
- package/.mochi/svelte-css/CredMenu-9hwgeptp7crb.css +0 -1
- package/.mochi/svelte-css/DashboardView-1cy4xpqhja9sx.css +0 -1
- package/.mochi/svelte-css/FolderBrowser-2pxp9z1o7zixk.css +0 -1
- package/.mochi/svelte-css/HealthBox-er2her0jukwl.css +0 -1
- package/.mochi/svelte-css/IdeBar-3bgr3m5z0lpeg.css +0 -1
- package/.mochi/svelte-css/InstanceCard-3vnltxnbxkvay.css +0 -1
- package/.mochi/svelte-css/InstanceFilter-3q6x0jt0hcgpz.css +0 -1
- package/.mochi/svelte-css/InstanceView-1pd7j16bd2b16.css +0 -1
- package/.mochi/svelte-css/SettingsCog-jftyadvo64qo.css +0 -1
- package/.mochi/svelte-css/StatusBadge-3mq0j7z6aq4j.css +0 -1
- package/.mochi/svelte-css/TerminalPane-3bwgk6ulzq6b3.css +0 -1
- package/.mochi/svelte-css/ThemePicker-1ir8jyocm8mvi.css +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { chmod, cp, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { existsSync, statSync } from 'node:fs';
|
|
1
|
+
import { chmod, cp, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
3
3
|
import { basename, dirname, join, relative } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { INSTALL_SCRIPT as TMUX_INSTALL_SCRIPT } from '../container-injections/tmux.ts';
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
dockerEnv
|
|
19
19
|
} from './config.server.ts';
|
|
20
20
|
import { spawnCapture } from './spawn.server.ts';
|
|
21
|
+
import { isTrackedFile, restoreTrackedFile } from './git.server.ts';
|
|
21
22
|
import { getOption, type InstanceMode } from './db.server.ts';
|
|
22
23
|
import { claudePermissionFlags, type ClaudePermissionMode, type PortForward } from '../types.ts';
|
|
23
24
|
|
|
@@ -100,8 +101,18 @@ const CLAUDE_FEATURE_INSTALL =
|
|
|
100
101
|
|
|
101
102
|
/** Manager-dropped files the project's own .gitignore won't cover, kept out of git status. */
|
|
102
103
|
const MANAGER_GIT_EXCLUDES = [
|
|
104
|
+
'/.devcontainer/codebay.devcontainer.json',
|
|
105
|
+
'/.codebay.devcontainer.json',
|
|
103
106
|
'/.devcontainer/code-server-settings.json',
|
|
104
107
|
'/.devcontainer/devcontainer-lock.json',
|
|
108
|
+
'/.devcontainer-lock.json',
|
|
109
|
+
// The subfolder config form places the merged config, lock, and staged features one level deeper.
|
|
110
|
+
'/.devcontainer/*/codebay.devcontainer.json',
|
|
111
|
+
'/.devcontainer/*/devcontainer-lock.json',
|
|
112
|
+
'/.devcontainer/*/codebay-tmux/',
|
|
113
|
+
'/.devcontainer/*/codebay-ttyd/',
|
|
114
|
+
'/.devcontainer/*/codebay-claude/',
|
|
115
|
+
'*.codebay-backup',
|
|
105
116
|
'/.devcontainer/codebay-tmux/',
|
|
106
117
|
'/.devcontainer/codebay-ttyd/',
|
|
107
118
|
'/.devcontainer/codebay-claude/',
|
|
@@ -416,20 +427,44 @@ function stripJsonc(input: string): string {
|
|
|
416
427
|
return out;
|
|
417
428
|
}
|
|
418
429
|
|
|
419
|
-
/**
|
|
430
|
+
/**
|
|
431
|
+
* Follows the spec's precedence: `.devcontainer/devcontainer.json`, `.devcontainer.json`, then
|
|
432
|
+
* the one-level-deep `.devcontainer/<folder>/devcontainer.json` form (first folder alphabetically).
|
|
433
|
+
*/
|
|
420
434
|
export function findDevcontainerConfig(dir: string): string | null {
|
|
421
435
|
const nested = join(dir, '.devcontainer', 'devcontainer.json');
|
|
422
436
|
if (existsSync(nested)) return nested;
|
|
423
437
|
const flat = join(dir, '.devcontainer.json');
|
|
424
438
|
if (existsSync(flat)) return flat;
|
|
439
|
+
const root = join(dir, '.devcontainer');
|
|
440
|
+
let entries;
|
|
441
|
+
try {
|
|
442
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
443
|
+
} catch {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
for (const name of entries
|
|
447
|
+
.filter((e) => e.isDirectory())
|
|
448
|
+
.map((e) => e.name)
|
|
449
|
+
.sort()) {
|
|
450
|
+
const candidate = join(root, name, 'devcontainer.json');
|
|
451
|
+
if (existsSync(candidate)) return candidate;
|
|
452
|
+
}
|
|
425
453
|
return null;
|
|
426
454
|
}
|
|
427
455
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
456
|
+
const CODEBAY_CONFIG_NESTED = 'codebay.devcontainer.json';
|
|
457
|
+
const CODEBAY_CONFIG_FLAT = '.codebay.devcontainer.json';
|
|
458
|
+
|
|
459
|
+
/** 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
|
-
/**
|
|
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 =
|
|
456
|
-
if (!
|
|
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
|
-
/**
|
|
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
|
|
539
|
+
const canonical = findDevcontainerConfig(workspaceDir);
|
|
540
|
+
const target = overrideConfigPath(workspaceDir, canonical);
|
|
502
541
|
let config: DevcontainerConfig = {};
|
|
503
542
|
|
|
504
|
-
const hadConfig =
|
|
543
|
+
const hadConfig = canonical !== null;
|
|
505
544
|
const imageSource = hadConfig ? 'local' : defaultImage;
|
|
506
545
|
|
|
507
|
-
if (
|
|
508
|
-
const raw = await readFile(
|
|
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 ${
|
|
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
|
-
//
|
|
522
|
-
|
|
523
|
-
|
|
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(
|
|
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(
|
|
637
|
+
await writeTmuxFeature(featureRoot);
|
|
594
638
|
|
|
595
|
-
if (isTerminal && hadConfig) await writeClaudeFeature(
|
|
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(
|
|
673
|
-
const dir = join(
|
|
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(
|
|
687
|
-
const dir = join(
|
|
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(
|
|
700
|
-
const dir = join(
|
|
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(
|
|
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,
|
package/src/lib/docker.server.ts
CHANGED
|
@@ -106,6 +106,16 @@ export async function stopContainer(containerId: string): Promise<boolean> {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/** Rebuild-safe removal: volumes survive — the CLI's `--remove-existing-container` is a plain `docker rm -f`, no `-v`. */
|
|
110
|
+
export async function removeContainerOnly(containerId: string): Promise<boolean> {
|
|
111
|
+
try {
|
|
112
|
+
await (await getDocker()).getContainer(containerId).remove({ force: true });
|
|
113
|
+
return true;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return statusOf(err) === 404; // already absent counts as success
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
109
119
|
/** Named volumes must be listed before removal and dropped by hand — the engine never reaps them. */
|
|
110
120
|
export async function removeContainer(containerId: string): Promise<boolean> {
|
|
111
121
|
const docker = await getDocker();
|
package/src/lib/git.server.ts
CHANGED
|
@@ -16,6 +16,34 @@ export async function readGitBranch(workspacePath: string): Promise<string | nul
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** Exit-code probe — `spawnCapture` can't tell a silent success from a failure. */
|
|
20
|
+
async function gitExitCode(workspaceDir: string, args: string[]): Promise<number | null> {
|
|
21
|
+
try {
|
|
22
|
+
const proc = Bun.spawn(['git', '-C', workspaceDir, ...args], {
|
|
23
|
+
stdout: 'ignore',
|
|
24
|
+
stderr: 'ignore'
|
|
25
|
+
});
|
|
26
|
+
return await proc.exited;
|
|
27
|
+
} catch {
|
|
28
|
+
return null; // no git on the host
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `null` means git itself failed to answer (missing binary, corrupt repo) — not "untracked". */
|
|
33
|
+
export async function isTrackedFile(
|
|
34
|
+
workspaceDir: string,
|
|
35
|
+
relPath: string
|
|
36
|
+
): Promise<boolean | null> {
|
|
37
|
+
const code = await gitExitCode(workspaceDir, ['ls-files', '--error-unmatch', '--', relPath]);
|
|
38
|
+
if (code === 0) return true;
|
|
39
|
+
if (code === 1) return false; // ls-files --error-unmatch reserves 1 for "no such tracked file"
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function restoreTrackedFile(workspaceDir: string, relPath: string): Promise<boolean> {
|
|
44
|
+
return (await gitExitCode(workspaceDir, ['checkout', '--', relPath])) === 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
19
47
|
/**
|
|
20
48
|
* The token goes in env-scoped git config, so it lands in neither argv nor the clone's
|
|
21
49
|
* persisted `.git/config`; `GIT_TERMINAL_PROMPT=0` fails fast instead of blocking on a prompt.
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
hostPortsInUse,
|
|
34
34
|
isRunning,
|
|
35
35
|
removeContainer,
|
|
36
|
+
removeContainerOnly,
|
|
36
37
|
startContainer,
|
|
37
38
|
stopContainer
|
|
38
39
|
} from './docker.server.ts';
|
|
@@ -43,6 +44,7 @@ import {
|
|
|
43
44
|
INJECTIONS_DONE_FILE,
|
|
44
45
|
launchCommandFor,
|
|
45
46
|
readDeclaredContainerPorts,
|
|
47
|
+
restoreCanonicalConfig,
|
|
46
48
|
TERMINAL_LAUNCHED_MARKER,
|
|
47
49
|
writeOverrideConfig
|
|
48
50
|
} from './devcontainer.server.ts';
|
|
@@ -73,7 +75,8 @@ import {
|
|
|
73
75
|
normalizeMode,
|
|
74
76
|
type Instance,
|
|
75
77
|
type InstanceFilter,
|
|
76
|
-
type InstanceHealth
|
|
78
|
+
type InstanceHealth,
|
|
79
|
+
type Theme
|
|
77
80
|
} from '../types.ts';
|
|
78
81
|
|
|
79
82
|
/** The sprite a row shows: its persisted choice, or the id hash for rows created before avatars were stored. */
|
|
@@ -189,7 +192,9 @@ export type StreamEvent =
|
|
|
189
192
|
// The dashboard run-state filter, so a change in one tab propagates to every open client.
|
|
190
193
|
| { type: 'filter'; data: { value: InstanceFilter } }
|
|
191
194
|
// The global default editor surface, so the picker's toggle follows a settings change.
|
|
192
|
-
| { type: 'default-mode'; data: { mode: InstanceMode } }
|
|
195
|
+
| { type: 'default-mode'; data: { mode: InstanceMode } }
|
|
196
|
+
// The colour scheme, so a change in the settings popup repaints the window behind it.
|
|
197
|
+
| { type: 'theme'; data: { value: Theme } };
|
|
193
198
|
|
|
194
199
|
interface StreamHub {
|
|
195
200
|
sockets: Set<ServerWebSocket<unknown>>;
|
|
@@ -250,6 +255,14 @@ export function broadcastDefaultMode(mode: InstanceMode): void {
|
|
|
250
255
|
broadcast({ type: 'default-mode', data: { mode } });
|
|
251
256
|
}
|
|
252
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Same popup problem for the theme — but it lives in a cookie, not `options`, so there's
|
|
260
|
+
* nothing to seed a reconnecting client with: its own cookie already drives the SSR paint.
|
|
261
|
+
*/
|
|
262
|
+
export function broadcastTheme(value: Theme): void {
|
|
263
|
+
broadcast({ type: 'theme', data: { value } });
|
|
264
|
+
}
|
|
265
|
+
|
|
253
266
|
async function reconcileInstances(force = false): Promise<void> {
|
|
254
267
|
const list = await listInstances();
|
|
255
268
|
const listJson = JSON.stringify(list);
|
|
@@ -380,7 +393,7 @@ async function boot(row: InstanceRow, opts: { branch?: string } = {}): Promise<v
|
|
|
380
393
|
await provision(row);
|
|
381
394
|
}
|
|
382
395
|
|
|
383
|
-
/**
|
|
396
|
+
/** Reads the project's own `devcontainer.json`, which the config injection never touches. */
|
|
384
397
|
async function seedDeclaredPorts(row: InstanceRow): Promise<void> {
|
|
385
398
|
const existing = new Set(listForwards(row.id).map((f) => f.container_port));
|
|
386
399
|
for (const containerPort of await readDeclaredContainerPorts(row.workspace_path)) {
|
|
@@ -425,6 +438,22 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
|
|
|
425
438
|
let containerId: string | null = null;
|
|
426
439
|
inFlight.add(row.id);
|
|
427
440
|
try {
|
|
441
|
+
// Migrates instances created before the merged config moved to its own file — one-shot per
|
|
442
|
+
// instance, so a later rebuild can never claw back a config the user has since edited.
|
|
443
|
+
if (!row.config_migrated) {
|
|
444
|
+
const migrated = await restoreCanonicalConfig(row.workspace_path);
|
|
445
|
+
if (migrated === 'restored' || migrated === 'deleted') {
|
|
446
|
+
appendLog(
|
|
447
|
+
row.id,
|
|
448
|
+
'Restored the project devcontainer config (Codebay now keeps its merged config in a separate file)\n'
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
// 'unknown' means git itself failed to answer; leave the flag unset so the next rebuild retries.
|
|
452
|
+
if (migrated !== 'unknown') {
|
|
453
|
+
updateInstance(row.id, { config_migrated: 1 });
|
|
454
|
+
row.config_migrated = 1;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
428
457
|
// On a rebuild `devcontainer up --remove-existing-container` discards the old container's
|
|
429
458
|
// ~/.claude, so drain it first; a first boot has no container_id and skips straight past.
|
|
430
459
|
if (row.container_id) {
|
|
@@ -438,7 +467,7 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
|
|
|
438
467
|
}));
|
|
439
468
|
appendLog(row.id, `Injecting ${surfaceLabel(row.mode)} (host port ${row.host_port})\n`);
|
|
440
469
|
const defaultImage = getOption('default_image') ?? DEFAULT_IMAGE;
|
|
441
|
-
const { imageSource } = await writeOverrideConfig(
|
|
470
|
+
const { imageSource, configPath } = 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),
|