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.
- package/.mochi/manifest.json +193 -167
- 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 +211 -38
- package/src/lib/docker.server.ts +10 -0
- package/src/lib/git.server.ts +28 -0
- package/src/lib/instances.server.ts +117 -18
- 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/',
|
|
@@ -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
|
|
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}
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
/**
|
|
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 =
|
|
431
|
-
if (!
|
|
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
|
-
/**
|
|
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
|
|
539
|
+
const canonical = findDevcontainerConfig(workspaceDir);
|
|
540
|
+
const target = overrideConfigPath(workspaceDir, canonical);
|
|
477
541
|
let config: DevcontainerConfig = {};
|
|
478
542
|
|
|
479
|
-
const hadConfig =
|
|
543
|
+
const hadConfig = canonical !== null;
|
|
480
544
|
const imageSource = hadConfig ? 'local' : defaultImage;
|
|
481
545
|
|
|
482
|
-
if (
|
|
483
|
-
const raw = await readFile(
|
|
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 ${
|
|
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
|
-
//
|
|
497
|
-
|
|
498
|
-
|
|
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(
|
|
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(
|
|
637
|
+
await writeTmuxFeature(featureRoot);
|
|
569
638
|
|
|
570
|
-
if (isTerminal && hadConfig) await writeClaudeFeature(
|
|
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(
|
|
648
|
-
const dir = join(
|
|
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(
|
|
662
|
-
const dir = join(
|
|
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(
|
|
675
|
-
const dir = join(
|
|
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(
|
|
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,
|
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.
|