agent-dev-env 0.1.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/LICENSE +21 -0
- package/dist/assets/bridge/bridge.js +202 -0
- package/dist/assets/guest/guest-agent-mac.js +586 -0
- package/dist/assets/guest/guest-agent-ubuntu.js +481 -0
- package/dist/assets/guest/guest-agent-windows.js +466 -0
- package/dist/assets/images/mac/CHANGELOG.md +265 -0
- package/dist/assets/images/mac/README.md +63 -0
- package/dist/assets/images/mac/sandbox.pkr.hcl +468 -0
- package/dist/assets/images/mac/vars/sandbox-macos-tahoe.pkrvars.hcl +39 -0
- package/dist/assets/images/ubuntu-arm64-vmware/CHANGELOG.md +239 -0
- package/dist/assets/images/ubuntu-arm64-vmware/README.md +177 -0
- package/dist/assets/images/ubuntu-arm64-vmware/autoinstall/meta-data +2 -0
- package/dist/assets/images/ubuntu-arm64-vmware/autoinstall/user-data +34 -0
- package/dist/assets/images/ubuntu-arm64-vmware/sandbox.pkr.hcl +776 -0
- package/dist/assets/images/ubuntu-arm64-vmware/vars/sandbox-ubuntu-24-04-arm64-vmware.pkrvars.hcl +71 -0
- package/dist/assets/images/windows-arm64-qemu/CHANGELOG.md +209 -0
- package/dist/assets/images/windows-arm64-qemu/README.md +142 -0
- package/dist/assets/images/windows-arm64-qemu/autounattend.xml +325 -0
- package/dist/assets/images/windows-arm64-qemu/qemu-with-tpm.sh +170 -0
- package/dist/assets/images/windows-arm64-qemu/sandbox.pkr.hcl +1012 -0
- package/dist/assets/images/windows-arm64-qemu/vars/sandbox-windows-11-arm64-qemu.pkrvars.hcl +83 -0
- package/dist/assets/images/windows-arm64-vmware/CHANGELOG.md +270 -0
- package/dist/assets/images/windows-arm64-vmware/README.md +171 -0
- package/dist/assets/images/windows-arm64-vmware/autounattend.xml +311 -0
- package/dist/assets/images/windows-arm64-vmware/sandbox.pkr.hcl +1036 -0
- package/dist/assets/images/windows-arm64-vmware/vars/sandbox-windows-11-arm64-vmware.pkrvars.hcl +83 -0
- package/dist/assets/rules/agent-rules-linux.md +68 -0
- package/dist/assets/rules/agent-rules.md +71 -0
- package/dist/assets/watchdog/watch-build-ocr.swift +40 -0
- package/dist/assets/watchdog/watch-build.py +275 -0
- package/dist/cli.js +52 -0
- package/dist/commands/delete.js +179 -0
- package/dist/commands/doctor.js +165 -0
- package/dist/commands/list.js +38 -0
- package/dist/commands/not-yet.js +15 -0
- package/dist/commands/register.js +150 -0
- package/dist/commands/run.js +65 -0
- package/dist/commands/status.js +142 -0
- package/dist/commands/stop.js +186 -0
- package/dist/commands/sync.js +98 -0
- package/dist/lib/exec.js +250 -0
- package/dist/lib/ghcr.js +82 -0
- package/dist/lib/git.js +100 -0
- package/dist/lib/logger.js +87 -0
- package/dist/lib/network.js +63 -0
- package/dist/lib/paths.js +116 -0
- package/dist/lib/platform.js +87 -0
- package/dist/lib/prompt.js +66 -0
- package/dist/lib/qemu.js +373 -0
- package/dist/lib/regex.js +9 -0
- package/dist/lib/ssh.js +230 -0
- package/dist/lib/tart.js +210 -0
- package/dist/lib/template.js +35 -0
- package/dist/lib/vars.js +99 -0
- package/dist/lib/vmrun.js +334 -0
- package/dist/lifecycle/build-macos.js +23 -0
- package/dist/lifecycle/build-qemu.js +204 -0
- package/dist/lifecycle/build-shared.js +355 -0
- package/dist/lifecycle/build-ubuntu.js +184 -0
- package/dist/lifecycle/build-watchdog.js +146 -0
- package/dist/lifecycle/build-windows-vmware.js +112 -0
- package/dist/lifecycle/build.js +55 -0
- package/dist/lifecycle/catalog.js +165 -0
- package/dist/lifecycle/deploy.js +161 -0
- package/dist/lifecycle/tag.js +77 -0
- package/dist/lifecycle/watch-build.js +64 -0
- package/dist/runners/bridges.js +153 -0
- package/dist/runners/framework.js +66 -0
- package/dist/runners/macos-bridges.js +143 -0
- package/dist/runners/macos-guest.js +76 -0
- package/dist/runners/macos-rules.js +77 -0
- package/dist/runners/macos-summary.js +145 -0
- package/dist/runners/macos.js +258 -0
- package/dist/runners/openchamber.js +60 -0
- package/dist/runners/options.js +56 -0
- package/dist/runners/qemu-image.js +183 -0
- package/dist/runners/rules.js +49 -0
- package/dist/runners/ubuntu-bridges.js +154 -0
- package/dist/runners/ubuntu-guest.js +136 -0
- package/dist/runners/ubuntu-image.js +26 -0
- package/dist/runners/ubuntu-rules.js +86 -0
- package/dist/runners/ubuntu-shared.js +65 -0
- package/dist/runners/ubuntu-summary.js +141 -0
- package/dist/runners/ubuntu.js +117 -0
- package/dist/runners/vmware-common.js +158 -0
- package/dist/runners/vmware-image.js +242 -0
- package/dist/runners/windows-autologon.js +119 -0
- package/dist/runners/windows-bridges.js +136 -0
- package/dist/runners/windows-guest.js +213 -0
- package/dist/runners/windows-image.js +23 -0
- package/dist/runners/windows-qemu-summary.js +85 -0
- package/dist/runners/windows-qemu.js +189 -0
- package/dist/runners/windows-shared.js +68 -0
- package/dist/runners/windows-summary.js +104 -0
- package/dist/runners/windows.js +96 -0
- package/dist/settings/common.js +107 -0
- package/dist/settings/macos-copy.js +223 -0
- package/dist/settings/macos.js +41 -0
- package/dist/settings/ubuntu-copy.js +223 -0
- package/dist/settings/ubuntu.js +68 -0
- package/package.json +52 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// runners/windows-summary.ts — the Windows VMware "Sandbox is ready" block
|
|
2
|
+
// (port of the shell's print_summary in run-windows-vmware-sandbox.sh:
|
|
3
|
+
// same labels, same wording, same color scheme — with the CLI's 12-char
|
|
4
|
+
// label column, like the other summaries).
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { logger } from '../lib/logger.js';
|
|
7
|
+
import { imageRootDir, workingVmxPath } from '../lib/paths.js';
|
|
8
|
+
import { PLATFORM_DEFAULTS } from '../lib/platform.js';
|
|
9
|
+
import { resolveGuestCredentials } from './windows-guest.js';
|
|
10
|
+
/** Prints the Windows summary block (image/VM/IP/SSH/RDP/WinRM/shared
|
|
11
|
+
* dir/bridges/OpenChamber/state/stop hints).
|
|
12
|
+
*
|
|
13
|
+
* @param context - The run context.
|
|
14
|
+
* @param state - The accumulated run state.
|
|
15
|
+
*/
|
|
16
|
+
export async function printWindowsSummary(context, state) {
|
|
17
|
+
const workVmx = workingVmxPath(context.platform, context.image);
|
|
18
|
+
const stateDir = imageRootDir(context.platform, context.image);
|
|
19
|
+
const defs = PLATFORM_DEFAULTS['windows-vmware'];
|
|
20
|
+
const creds = state.vmIp
|
|
21
|
+
? resolveGuestCredentials(context.image, context.options.env, state.vmIp)
|
|
22
|
+
: undefined;
|
|
23
|
+
logger.step('Sandbox is ready');
|
|
24
|
+
summaryLine('Image:', state.imageArchive ?? 'not available');
|
|
25
|
+
summaryLine('VM:', workVmx);
|
|
26
|
+
summaryLine('Guest IP:', state.vmIp ? `${state.vmIp} (Fusion NAT, vmnet8)` : 'unavailable');
|
|
27
|
+
summaryLine('SSH:', `ssh ${creds?.username ?? 'Administrator'}@${state.vmIp ?? '?'} (password: ${creds?.password ?? 'sandbox1'})`);
|
|
28
|
+
summaryLine('RDP:', `${state.vmIp ?? '?'}:${defs.rdpPort ?? 3389} (${creds?.username ?? 'Administrator'} / ${creds?.password ?? 'sandbox1'})`);
|
|
29
|
+
summaryLine('WinRM:', `${state.vmIp ?? '?'}:${defs.winrmPort ?? 5985} (advanced use)`);
|
|
30
|
+
printSharedLine(context, state);
|
|
31
|
+
printBridgeLines(context, state);
|
|
32
|
+
printOpenchamberLine(context, state);
|
|
33
|
+
summaryLine('State:', `${stateDir} (extracted base + working clone; --reset re-clones)`);
|
|
34
|
+
printStopLines(context, state, workVmx);
|
|
35
|
+
}
|
|
36
|
+
/** One ` LABEL........ VALUE` line. */
|
|
37
|
+
function summaryLine(label, value) {
|
|
38
|
+
logger.out(` ${label.padEnd(12)}${value}`);
|
|
39
|
+
}
|
|
40
|
+
function printSharedLine(context, state) {
|
|
41
|
+
if (state.sharedFolderSkipped) {
|
|
42
|
+
summaryLine('Shared:', loggerWarn('not supported (Windows 11 ARM guest on Apple silicon — no HGFS driver in VMware Tools)'));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (context.workDir && existsSync(context.workDir)) {
|
|
46
|
+
summaryLine('Shared:', `\\\\vmware-host\\Shared Folders\\work -> ${context.workDir}`);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
summaryLine('Shared:', 'not shared');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function printBridgeLines(context, state) {
|
|
53
|
+
const agent = state.bridges.agent;
|
|
54
|
+
if (agent.bridged) {
|
|
55
|
+
const value = agent.guestUp
|
|
56
|
+
? `host agent -> TCP ${context.agentPort} -> guest \\\\.\\pipe\\openssh-ssh-agent`
|
|
57
|
+
: `host bridge up (TCP ${context.agentPort}), guest pipe not running`;
|
|
58
|
+
summaryLine('SSH agent:', agent.guestUp ? loggerOk(value) : loggerWarn(value));
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
summaryLine('SSH agent:', 'not bridged');
|
|
62
|
+
}
|
|
63
|
+
const docker = state.bridges.docker;
|
|
64
|
+
if (docker.bridged) {
|
|
65
|
+
if (docker.engineUp) {
|
|
66
|
+
summaryLine('Docker:', loggerOk(`host engine (v${docker.serverVersion}) -> TCP ${context.dockerPort} -> guest (context 'host')`));
|
|
67
|
+
}
|
|
68
|
+
else if (docker.guestUp) {
|
|
69
|
+
summaryLine('Docker:', loggerWarn('bridge up, engine not reachable in the guest — is Docker running on the host?'));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
summaryLine('Docker:', loggerWarn(`host bridge up (TCP ${context.dockerPort}), guest pipe not running`));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
summaryLine('Docker:', 'not bridged');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function printOpenchamberLine(context, state) {
|
|
80
|
+
if (state.openchamberUp && state.openchamberUrl) {
|
|
81
|
+
summaryLine('OpenChamber:', loggerOk(`${state.openchamberUrl} (password: sandbox)`));
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
summaryLine('OpenChamber:', loggerWarn(`not responding on http://${state.vmIp ?? '?'}:${context.openchamberPort}`));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function printStopLines(context, state, workVmx) {
|
|
88
|
+
summaryLine('Stop:', `run "agent-dev-env stop windows-vmware" (or: vmrun -T fusion stop '${workVmx}')`);
|
|
89
|
+
if (!context.options.foreground) {
|
|
90
|
+
if (state.bridges.agent.bridged) {
|
|
91
|
+
summaryLine('Bridge:', `host listener on TCP ${context.agentPort} stays up — stop it with: agent-dev-env stop windows-vmware`);
|
|
92
|
+
}
|
|
93
|
+
if (state.bridges.docker.bridged) {
|
|
94
|
+
summaryLine('Bridge:', `host Docker listener on TCP ${context.dockerPort} stays up — stop it with: agent-dev-env stop windows-vmware`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// color helpers — the shell's ${c_green}/${c_yellow} inline.
|
|
99
|
+
function loggerOk(text) {
|
|
100
|
+
return logger.color('green') + text + logger.reset();
|
|
101
|
+
}
|
|
102
|
+
function loggerWarn(text) {
|
|
103
|
+
return logger.color('yellow') + text + logger.reset();
|
|
104
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// runners/windows.ts — the Windows 11 (ARM64) VMware backend (Phase 5 of
|
|
2
|
+
// the port): vmrun-driven image/base/clone (windows-image.ts), boot with
|
|
3
|
+
// the guest-IP + sshd waits and the one-time auto-logon
|
|
4
|
+
// (windows-autologon.ts), the HGFS-share step (windows-shared.ts — skipped
|
|
5
|
+
// for ARM guests), the NAT-segment bridges + guest agent over the
|
|
6
|
+
// PowerShell transport (windows-bridges.ts / windows-guest.ts), and the
|
|
7
|
+
// summary. Port of run-windows-vmware-sandbox.sh §steps 0-6.
|
|
8
|
+
import { rmSync } from 'node:fs';
|
|
9
|
+
import { logger } from '../lib/logger.js';
|
|
10
|
+
import { imageRootDir } from '../lib/paths.js';
|
|
11
|
+
import { waitForSshd } from '../lib/ssh.js';
|
|
12
|
+
import { startVm } from '../lib/vmrun.js';
|
|
13
|
+
import { ensureBridgeDir } from './bridges.js';
|
|
14
|
+
import { offerOpenInBrowser, waitForOpenchamber } from './openchamber.js';
|
|
15
|
+
import { ensureAutologon } from './windows-autologon.js';
|
|
16
|
+
import { windowsBridges } from './windows-bridges.js';
|
|
17
|
+
import { resolveGuestCredentials } from './windows-guest.js';
|
|
18
|
+
import { ensureWindowsImage, windowsWorkingVmx } from './windows-image.js';
|
|
19
|
+
import { setupWindowsSharedFolder } from './windows-shared.js';
|
|
20
|
+
import { printWindowsSummary } from './windows-summary.js';
|
|
21
|
+
import { cleanupRunBridge, requireVmrun, stopRunningVm, waitForForegroundVmStop, waitGuestIp, } from './vmware-common.js';
|
|
22
|
+
const PLATFORM = 'windows-vmware';
|
|
23
|
+
/** The Windows VMware backend — vmrun + ssh2 (PowerShell in the guest;
|
|
24
|
+
* the state lives under the CLI's data dir).
|
|
25
|
+
*/
|
|
26
|
+
export const windowsBackend = {
|
|
27
|
+
preflight: (context) => preflight(context),
|
|
28
|
+
ensureImageAndVm: (context, state) => ensureWindowsImage(context, state),
|
|
29
|
+
boot: boot,
|
|
30
|
+
setupBridges: (context, state) => windowsBridges(context, state),
|
|
31
|
+
setupSettings: (context, state) => setupSettings(context, state),
|
|
32
|
+
verifyOpenchamber: (context, state) => verifyOpenchamber(context, state),
|
|
33
|
+
summarize: (context, state) => printWindowsSummary(context, state),
|
|
34
|
+
finish: finish,
|
|
35
|
+
};
|
|
36
|
+
async function preflight(context) {
|
|
37
|
+
requireVmrun();
|
|
38
|
+
ensureBridgeDir();
|
|
39
|
+
if (context.options.reset) {
|
|
40
|
+
const root = imageRootDir(PLATFORM, context.image);
|
|
41
|
+
logger.info(`Resetting the working VM (--reset) — deleting the extracted base and the working clone.`);
|
|
42
|
+
rmSync(root, { recursive: true, force: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// --- step 2: boot -----------------------------------------------------------
|
|
46
|
+
async function boot(context, state) {
|
|
47
|
+
const workVmx = windowsWorkingVmx(context.image);
|
|
48
|
+
await stopRunningVm(context, workVmx, PLATFORM);
|
|
49
|
+
logger.cmd(`vmrun -T fusion start ${workVmx} ${context.options.headless ? 'nogui' : 'gui'}`);
|
|
50
|
+
const start = await startVm(workVmx, context.options.headless ? 'nogui' : 'gui');
|
|
51
|
+
if (start.code !== 0) {
|
|
52
|
+
throw new Error(`vmrun start failed:\n${start.stderr.trim()}`);
|
|
53
|
+
}
|
|
54
|
+
logger.ok('VM started.');
|
|
55
|
+
state.vmIp = await waitGuestIp(workVmx);
|
|
56
|
+
logger.ok(`Guest IP: ${state.vmIp}`);
|
|
57
|
+
const creds = resolveGuestCredentials(context.image, context.options.env, state.vmIp);
|
|
58
|
+
process.stdout.write(' Waiting for the guest to boot (up to 10 min)');
|
|
59
|
+
if (await waitForSshd(creds)) {
|
|
60
|
+
process.stdout.write(` ${logger.color('green')}done${logger.reset()}\n`);
|
|
61
|
+
logger.ok(`VM is up: ssh ${creds.username}@${state.vmIp}`);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
process.stdout.write(` ${logger.color('yellow')}failed${logger.reset()}\n`);
|
|
65
|
+
logger.die(`timed out waiting for the guest to boot (no SSH on ${state.vmIp}:22).`);
|
|
66
|
+
}
|
|
67
|
+
await ensureAutologon(context, state, workVmx, creds);
|
|
68
|
+
await setupWindowsSharedFolder(context, state, workVmx);
|
|
69
|
+
}
|
|
70
|
+
// --- step 4/5 hooks ---------------------------------------------------------
|
|
71
|
+
async function setupSettings(context, state) {
|
|
72
|
+
logger.info('Windows guests have no user settings copy — skipping.');
|
|
73
|
+
state.settings = 'skipped';
|
|
74
|
+
}
|
|
75
|
+
async function verifyOpenchamber(context, state) {
|
|
76
|
+
if (!state.vmIp) {
|
|
77
|
+
logger.warn('OpenChamber: guest IP unavailable — cannot probe.');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
state.openchamberUrl = `http://${state.vmIp}:${context.openchamberPort}`;
|
|
81
|
+
state.openchamberUp = await waitForOpenchamber(state.openchamberUrl);
|
|
82
|
+
if (state.openchamberUp) {
|
|
83
|
+
await offerOpenInBrowser(state.openchamberUrl, context.options.yes);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// --- finish: foreground wait + bridge cleanup --------------------------------
|
|
87
|
+
async function finish(context, state) {
|
|
88
|
+
if (!context.options.foreground) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
await waitForForegroundVmStop(windowsWorkingVmx(context.image));
|
|
92
|
+
// The VM stopped (or Cmd+C was pressed) — kill the bridges this run
|
|
93
|
+
// started; in background mode they stay up, outliving the CLI by design.
|
|
94
|
+
await cleanupRunBridge(state.bridges.agent.pid, 'agent bridge');
|
|
95
|
+
await cleanupRunBridge(state.bridges.docker.pid, 'Docker bridge');
|
|
96
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// settings/common.ts — the parts of the user-settings copy shared by the
|
|
2
|
+
// macOS and Ubuntu backends: the outcome enum, the guest marker paths
|
|
3
|
+
// (green-field ~/.config/agent-dev-env/), the host candidate file set
|
|
4
|
+
// (identical on both — the host is always macOS), the .gitconfig
|
|
5
|
+
// sanitization and the guest-side marker scripts. The per-platform bits
|
|
6
|
+
// live in settings/macos.ts (version 9, /Users/admin, tart transport)
|
|
7
|
+
// and settings/ubuntu.ts (version 3, /home/admin, ssh2 transport).
|
|
8
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
/** @internal — Marker path inside the guest (relative to its home),
|
|
12
|
+
* embedded in the marker scripts. Exported for the co-located tests.
|
|
13
|
+
*/
|
|
14
|
+
export const SETTINGS_MARKER = '.config/agent-dev-env/settings-copied';
|
|
15
|
+
/** The marker's directory (relative to the guest home). */
|
|
16
|
+
const SETTINGS_MARKER_DIR = SETTINGS_MARKER.split('/').slice(0, -1).join('/');
|
|
17
|
+
/** The candidate settings paths, relative to $HOME — the same list as
|
|
18
|
+
* the shell's collect_settings_files (directories are copied whole). */
|
|
19
|
+
const SETTINGS_CANDIDATES = [
|
|
20
|
+
'.config/opencode/opencode.json',
|
|
21
|
+
'.config/opencode/opencode.jsonc',
|
|
22
|
+
'.config/opencode/tui.json',
|
|
23
|
+
'.config/opencode/tui.jsonc',
|
|
24
|
+
'.config/opencode/agents',
|
|
25
|
+
'.config/opencode/commands',
|
|
26
|
+
'.config/opencode/modes',
|
|
27
|
+
'.config/opencode/plugins',
|
|
28
|
+
'.config/opencode/skills',
|
|
29
|
+
'.config/opencode/tools',
|
|
30
|
+
'.config/opencode/themes',
|
|
31
|
+
'.config/opencode/package.json',
|
|
32
|
+
'.config/opencode/package-lock.json',
|
|
33
|
+
'.config/opencode/bun.lock',
|
|
34
|
+
'.local/share/opencode/auth.json',
|
|
35
|
+
'.opencodereview/config.json',
|
|
36
|
+
'.copilot/config.json',
|
|
37
|
+
'.copilot/skills',
|
|
38
|
+
'.vscode/extensions',
|
|
39
|
+
'Library/Application Support/Code/User/settings.json',
|
|
40
|
+
'Library/Application Support/Code/User/keybindings.json',
|
|
41
|
+
'Library/Application Support/Code/User/snippets',
|
|
42
|
+
'Library/Application Support/mcp-compress-router',
|
|
43
|
+
'.ssh/allowed_signers',
|
|
44
|
+
'.ssh/known_hosts',
|
|
45
|
+
'.gitconfig',
|
|
46
|
+
];
|
|
47
|
+
/** The host's user settings files, relative to $HOME — only existing
|
|
48
|
+
* entries, fixed list first, then the `~/.ssh/*.sh` helpers (sorted for
|
|
49
|
+
* deterministic output).
|
|
50
|
+
*
|
|
51
|
+
* @param home - The host home directory.
|
|
52
|
+
* @returns The list of settings paths (tarable with `-C` home).
|
|
53
|
+
*/
|
|
54
|
+
export function collectSettingsFiles(home = homedir()) {
|
|
55
|
+
const files = SETTINGS_CANDIDATES.filter((path) => existsSync(join(home, path)));
|
|
56
|
+
try {
|
|
57
|
+
const sshScripts = readdirSync(join(home, '.ssh'), { withFileTypes: true })
|
|
58
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.sh'))
|
|
59
|
+
.map((entry) => `.ssh/${entry.name}`)
|
|
60
|
+
.sort();
|
|
61
|
+
files.push(...sshScripts);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// no ~/.ssh — nothing to add
|
|
65
|
+
}
|
|
66
|
+
return files;
|
|
67
|
+
}
|
|
68
|
+
/** Rewrites host home paths to the guest home (the .gitconfig
|
|
69
|
+
* sanitization: `s|$HOME|$guest_home|g` parity, including `program`
|
|
70
|
+
* values git execs verbatim).
|
|
71
|
+
*
|
|
72
|
+
* @param content - The .gitconfig text.
|
|
73
|
+
* @param hostHome - The host home directory (e.g. /Users/ameshkov).
|
|
74
|
+
* @param guestHome - The guest home directory (/Users/admin or /home/admin).
|
|
75
|
+
* @returns The sanitized content.
|
|
76
|
+
*/
|
|
77
|
+
export function sanitizeGitconfig(content, hostHome, guestHome) {
|
|
78
|
+
return content.split(hostHome).join(guestHome);
|
|
79
|
+
}
|
|
80
|
+
/** The guest-side marker check (exit 0 when the guest's settings version
|
|
81
|
+
* marker is >= the version passed as `$1`) — `sh -s <version>` stdin.
|
|
82
|
+
*
|
|
83
|
+
* @returns The script text.
|
|
84
|
+
*/
|
|
85
|
+
export function guestSettingsCheckScript() {
|
|
86
|
+
return [
|
|
87
|
+
'version=$1',
|
|
88
|
+
`marker="$HOME/${SETTINGS_MARKER}"`,
|
|
89
|
+
'if [ -f "$marker" ] && [ "$(cat "$marker" 2>/dev/null)" -ge "$version" ] 2>/dev/null; then',
|
|
90
|
+
' exit 0',
|
|
91
|
+
'fi',
|
|
92
|
+
'exit 1',
|
|
93
|
+
'',
|
|
94
|
+
].join('\n');
|
|
95
|
+
}
|
|
96
|
+
/** The guest-side marker write — `sh -s <version>` stdin script.
|
|
97
|
+
*
|
|
98
|
+
* @returns The script text.
|
|
99
|
+
*/
|
|
100
|
+
export function guestSettingsMarkerScript() {
|
|
101
|
+
return [
|
|
102
|
+
'version=$1',
|
|
103
|
+
`mkdir -p "$HOME/${SETTINGS_MARKER_DIR}"`,
|
|
104
|
+
`printf '%s\\n' "$version" > "$HOME/${SETTINGS_MARKER}"`,
|
|
105
|
+
'',
|
|
106
|
+
].join('\n');
|
|
107
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// settings/macos-copy.ts — the IO half of the macOS user-settings copy:
|
|
2
|
+
// tar streams over `tart exec -i` (binary-safe pipe — tar may contain
|
|
3
|
+
// arbitrary content, so the text-oriented run() helpers cannot carry
|
|
4
|
+
// it), the marker check/write, the OpenChamber restart, and the two
|
|
5
|
+
// flows: `ensure` (run step, marker-gated) and `sync` (on demand).
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { cpSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { execVm } from '../lib/tart.js';
|
|
11
|
+
import { logger } from '../lib/logger.js';
|
|
12
|
+
import { confirm } from '../lib/prompt.js';
|
|
13
|
+
import { collectSettingsFiles, guestSettingsCheckScript, guestSettingsMarkerScript, guestUnpackCommand, GUEST_HOME, openchamberRestartScript, sanitizeGitconfig, SETTINGS_VERSION, } from './macos.js';
|
|
14
|
+
/** Streams a tar archive (built from `includes` under `cwd`) into the
|
|
15
|
+
* guest and unpacks it there. Binary-safe: the producer's stdout pipes
|
|
16
|
+
* straight into `tart exec`'s stdin.
|
|
17
|
+
*
|
|
18
|
+
* @param vm - The running VM name.
|
|
19
|
+
* @param cwd - The directory to archive from.
|
|
20
|
+
* @param includes - The paths (relative to cwd) to include.
|
|
21
|
+
* @param unpackCommand - The guest-side unpack command.
|
|
22
|
+
* @returns Resolves on success; rejects with the guest's stderr on fail.
|
|
23
|
+
*/
|
|
24
|
+
function streamTarIntoGuest(vm, cwd, includes, unpackCommand) {
|
|
25
|
+
const tar = spawn('tar', ['-C', cwd, '-cf', '-', ...includes]);
|
|
26
|
+
const guest = spawn('tart', ['exec', '-i', vm, 'sh', '-c', unpackCommand]);
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const codes = { tar: null, guest: null };
|
|
29
|
+
let guestError = '';
|
|
30
|
+
let settled = false;
|
|
31
|
+
guest.stderr.setEncoding('utf8');
|
|
32
|
+
guest.stderr.on('data', (chunk) => {
|
|
33
|
+
guestError += chunk;
|
|
34
|
+
});
|
|
35
|
+
const finish = () => {
|
|
36
|
+
if (settled || codes.tar === null || codes.guest === null) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
settled = true;
|
|
40
|
+
if (codes.tar === 0 && codes.guest === 0) {
|
|
41
|
+
resolve();
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
reject(new Error(`settings copy failed (tar ${codes.tar}, tart exec ${codes.guest})` +
|
|
45
|
+
(guestError ? `\n${guestError.trim()}` : '')));
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
registerExit(tar, 'tar', codes, finish);
|
|
49
|
+
registerExit(guest, 'guest', codes, finish);
|
|
50
|
+
tar.stdout.pipe(guest.stdin);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/** @internal — tracks one side's exit code and re-checks the pair. */
|
|
54
|
+
function registerExit(proc, name, codes, finish) {
|
|
55
|
+
const setCode = (code) => {
|
|
56
|
+
codes[name] = code;
|
|
57
|
+
finish();
|
|
58
|
+
};
|
|
59
|
+
proc.on('close', (code) => setCode(code ?? -1));
|
|
60
|
+
proc.on('error', () => setCode(-1));
|
|
61
|
+
}
|
|
62
|
+
/** @internal — true when the guest already has settings of the current
|
|
63
|
+
* version (numeric marker compare, like the shell's `-ge`). */
|
|
64
|
+
export async function guestSettingsUpToDate(vm) {
|
|
65
|
+
const res = await execVm(vm, ['sh', '-s', String(SETTINGS_VERSION)], {
|
|
66
|
+
input: guestSettingsCheckScript(),
|
|
67
|
+
});
|
|
68
|
+
return res.code === 0;
|
|
69
|
+
}
|
|
70
|
+
/** Copies the settings into the guest: archive 1 = everything except
|
|
71
|
+
* .gitconfig, archive 2 = the sanitized .gitconfig, then the version
|
|
72
|
+
* marker (the legacy two-archive dance — bsdtar stops at the first
|
|
73
|
+
* end-of-archive marker).
|
|
74
|
+
*
|
|
75
|
+
* @param vm - The running VM name.
|
|
76
|
+
* @param files - The settings paths (relative to the host home).
|
|
77
|
+
* @param home - The host home directory.
|
|
78
|
+
* @throws Error when the copy or the marker write fails.
|
|
79
|
+
*/
|
|
80
|
+
async function copySettingsToGuest(vm, files, home) {
|
|
81
|
+
const staging = mkdtempSync(join(tmpdir(), 'agent-dev-env-settings.'));
|
|
82
|
+
try {
|
|
83
|
+
const gitconfig = files.includes('.gitconfig');
|
|
84
|
+
const hasGitconfig = gitconfig && stageSanitizedGitconfig(home, staging);
|
|
85
|
+
const rest = files.filter((file) => file !== '.gitconfig');
|
|
86
|
+
if (rest.length > 0) {
|
|
87
|
+
await streamTarIntoGuest(vm, home, rest, guestUnpackCommand());
|
|
88
|
+
}
|
|
89
|
+
if (hasGitconfig) {
|
|
90
|
+
await streamTarIntoGuest(vm, staging, ['.gitconfig'], 'tar -C "$HOME" -xf -');
|
|
91
|
+
}
|
|
92
|
+
const marker = await execVm(vm, ['sh', '-s', String(SETTINGS_VERSION)], {
|
|
93
|
+
input: guestSettingsMarkerScript(),
|
|
94
|
+
});
|
|
95
|
+
if (marker.code !== 0) {
|
|
96
|
+
throw new Error('settings were copied, but the version marker could not be written — ' +
|
|
97
|
+
'they will be offered again on the next run.');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
rmSync(staging, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** @internal — sanitized .gitconfig in the staging dir; false when the
|
|
105
|
+
* host has no .gitconfig (or it could not be staged — the sanitized
|
|
106
|
+
* copy is preferred, the raw file is shipped as-is as a fallback, like
|
|
107
|
+
* the Ubuntu flow). Exported for the co-located tests.
|
|
108
|
+
*
|
|
109
|
+
* @param home - The host home directory.
|
|
110
|
+
* @param staging - The staging dir to write into.
|
|
111
|
+
* @returns True when a .gitconfig was staged.
|
|
112
|
+
*/
|
|
113
|
+
export function stageSanitizedGitconfig(home, staging) {
|
|
114
|
+
const source = join(home, '.gitconfig');
|
|
115
|
+
try {
|
|
116
|
+
const content = readFileSync(source, 'utf8');
|
|
117
|
+
const sanitized = sanitizeGitconfig(content, home, GUEST_HOME);
|
|
118
|
+
writeFileSync(join(staging, '.gitconfig'), sanitized);
|
|
119
|
+
// Preserve the source's permissions (git may exec helper scripts).
|
|
120
|
+
try {
|
|
121
|
+
const { mode } = statSync(source);
|
|
122
|
+
writeFileSync(join(staging, '.gitconfig'), sanitized, { mode: mode & 0o777 });
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// best-effort mode preserve
|
|
126
|
+
}
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
logger.warn('could not sanitize .gitconfig — shipping it as-is.');
|
|
131
|
+
try {
|
|
132
|
+
cpSync(source, join(staging, '.gitconfig'), { preserveTimestamps: true });
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
logger.warn('could not stage .gitconfig — skipping it.');
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Restarts OpenChamber so a fresh settings copy takes effect. Returns
|
|
142
|
+
* true when the restart succeeded (warns otherwise, like the shell).
|
|
143
|
+
*
|
|
144
|
+
* @param vm - The running VM name.
|
|
145
|
+
* @returns True when restarted.
|
|
146
|
+
*/
|
|
147
|
+
export async function restartOpenchamber(vm) {
|
|
148
|
+
const res = await execVm(vm, ['sh', '-s'], { input: openchamberRestartScript() });
|
|
149
|
+
if (res.code !== 0) {
|
|
150
|
+
logger.warn('could not restart OpenChamber — it will pick up the new settings on its next start.');
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
logger.ok('Restarted OpenChamber so it picks up the new user settings.');
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
/** Prints the file list and asks the standard confirmation.
|
|
157
|
+
*
|
|
158
|
+
* @param home - The host home directory (display paths).
|
|
159
|
+
* @param files - The settings paths.
|
|
160
|
+
* @param yes - Skip the confirmation.
|
|
161
|
+
* @returns True when the user confirmed.
|
|
162
|
+
*/
|
|
163
|
+
async function confirmSettingsCopy(home, files, yes) {
|
|
164
|
+
logger.info("Found on the host — will copy into the guest's home directory:");
|
|
165
|
+
for (const file of files) {
|
|
166
|
+
logger.info(` ${join(home, file)}`);
|
|
167
|
+
}
|
|
168
|
+
if (yes) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
return confirm('Copy these user settings into the guest?', { default: 'y' });
|
|
172
|
+
}
|
|
173
|
+
/** The run step: marker-gated copy (offered once per settings version).
|
|
174
|
+
*
|
|
175
|
+
* @param vm - The running VM name.
|
|
176
|
+
* @param home - The host home directory.
|
|
177
|
+
* @param yes - Skip confirmations.
|
|
178
|
+
* @returns The step outcome (see SettingsState).
|
|
179
|
+
*/
|
|
180
|
+
export async function ensureUserSettings(vm, home, yes) {
|
|
181
|
+
if (await guestSettingsUpToDate(vm)) {
|
|
182
|
+
logger.ok(`User settings are already in the guest (version ${SETTINGS_VERSION}) — skipping.`);
|
|
183
|
+
return 'uptodate';
|
|
184
|
+
}
|
|
185
|
+
const files = collectSettingsFiles(home);
|
|
186
|
+
if (files.length === 0) {
|
|
187
|
+
logger.info('No user settings found on the host (opencode config and auth, ' +
|
|
188
|
+
'OpenCodeReview config, Copilot config, VS Code config and extensions, ' +
|
|
189
|
+
'~/.ssh, ~/.gitconfig) — nothing to copy.');
|
|
190
|
+
return 'none';
|
|
191
|
+
}
|
|
192
|
+
if (!(await confirmSettingsCopy(home, files, yes))) {
|
|
193
|
+
logger.info('Skipped — re-run `agent-dev-env run` to copy them later.');
|
|
194
|
+
return 'declined';
|
|
195
|
+
}
|
|
196
|
+
await copySettingsToGuest(vm, files, home);
|
|
197
|
+
logger.ok(`Copied ${files.length} item(s) into the guest.`);
|
|
198
|
+
return 'copied';
|
|
199
|
+
}
|
|
200
|
+
/** The `sync` flow: always copies (no marker gate) + restarts OpenChamber.
|
|
201
|
+
*
|
|
202
|
+
* @param vm - The running VM name.
|
|
203
|
+
* @param home - The host home directory.
|
|
204
|
+
* @param yes - Skip confirmations.
|
|
205
|
+
* @returns The outcome (copied | none | declined | failed).
|
|
206
|
+
*/
|
|
207
|
+
export async function syncUserSettings(vm, home, yes) {
|
|
208
|
+
const files = collectSettingsFiles(home);
|
|
209
|
+
if (files.length === 0) {
|
|
210
|
+
logger.info('No user settings found on the host (opencode config and auth, ' +
|
|
211
|
+
'OpenCodeReview config, Copilot config, VS Code config and extensions, ' +
|
|
212
|
+
'~/.ssh, ~/.gitconfig) — nothing to copy.');
|
|
213
|
+
return 'none';
|
|
214
|
+
}
|
|
215
|
+
if (!(await confirmSettingsCopy(home, files, yes))) {
|
|
216
|
+
logger.info('Skipped — re-run `agent-dev-env sync` to copy them later.');
|
|
217
|
+
return 'declined';
|
|
218
|
+
}
|
|
219
|
+
await copySettingsToGuest(vm, files, home);
|
|
220
|
+
logger.ok(`Copied ${files.length} item(s) into the guest (version ${SETTINGS_VERSION}).`);
|
|
221
|
+
await restartOpenchamber(vm);
|
|
222
|
+
return 'copied';
|
|
223
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// settings/macos.ts — the macOS user-settings copy: pure builders
|
|
2
|
+
// (collection, .gitconfig sanitization, guest-side marker scripts) —
|
|
3
|
+
// port of scripts/lib/macos-settings.sh, with the marker moved to the
|
|
4
|
+
// green-field `~/.config/agent-dev-env/` path (AGENTS.md conventions).
|
|
5
|
+
//
|
|
6
|
+
// The IO half (tar streams over `tart exec`) lives in macos-copy.ts; this
|
|
7
|
+
// module stays free of process spawning so the logic is unit-testable.
|
|
8
|
+
// The macOS-specific bits are the version, the guest home and the guest
|
|
9
|
+
// unpack/restart scripts — the shared set (markers, candidate files,
|
|
10
|
+
// sanitization) lives in settings/common.ts.
|
|
11
|
+
export { collectSettingsFiles, guestSettingsCheckScript, guestSettingsMarkerScript, sanitizeGitconfig, } from './common.js';
|
|
12
|
+
/** Version of the settings copy. Bump when the file set or the copy logic
|
|
13
|
+
* changes: guests whose marker is older are offered the copy again.
|
|
14
|
+
*/
|
|
15
|
+
export const SETTINGS_VERSION = 9;
|
|
16
|
+
/** The sandbox user in the macOS base image — fixed, so host home paths
|
|
17
|
+
* are rewritten to it when the settings are copied (see sanitize).
|
|
18
|
+
*/
|
|
19
|
+
export const GUEST_HOME = '/Users/admin';
|
|
20
|
+
/** The guest-side tar unpack — `sh -c` command: extract + tighten ~/.ssh.
|
|
21
|
+
*
|
|
22
|
+
* @returns The command text.
|
|
23
|
+
*/
|
|
24
|
+
export function guestUnpackCommand() {
|
|
25
|
+
return 'tar -C "$HOME" -xf - || exit 1; chmod 700 "$HOME/.ssh" 2>/dev/null || true';
|
|
26
|
+
}
|
|
27
|
+
/** The guest-side OpenChamber restart — `sh -s` stdin script (sources
|
|
28
|
+
* ~/.zprofile first: openchamber is npm-global via nvm, login-shell
|
|
29
|
+
* PATH only).
|
|
30
|
+
*
|
|
31
|
+
* @returns The script text.
|
|
32
|
+
*/
|
|
33
|
+
export function openchamberRestartScript() {
|
|
34
|
+
return [
|
|
35
|
+
'if [ -f "$HOME/.zprofile" ]; then',
|
|
36
|
+
' . "$HOME/.zprofile" 2>/dev/null || true',
|
|
37
|
+
'fi',
|
|
38
|
+
'exec openchamber restart',
|
|
39
|
+
'',
|
|
40
|
+
].join('\n');
|
|
41
|
+
}
|