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,76 @@
|
|
|
1
|
+
// runners/macos-guest.ts — the guest side of the macOS step 3: where the
|
|
2
|
+
// bundled guest agent lives, how it is uploaded over `tart exec` and how
|
|
3
|
+
// it is run (upload → install → status). The other step-3 concerns
|
|
4
|
+
// (host bridges, rules) live in their own modules; this one is the
|
|
5
|
+
// transport bits both need.
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { execVm } from '../lib/tart.js';
|
|
9
|
+
import { logger } from '../lib/logger.js';
|
|
10
|
+
/** The guest's agent install dir (image-fixed home `/Users/admin`). */
|
|
11
|
+
const GUEST_LIB_DIR = '/Users/admin/.local/lib/agent-dev-env';
|
|
12
|
+
/** The absolute guest-agent path. */
|
|
13
|
+
export const GUEST_AGENT = `${GUEST_LIB_DIR}/guest-agent-mac.js`;
|
|
14
|
+
/** The bundled macOS guest agent (single-file, node-only). */
|
|
15
|
+
function guestAgentPath() {
|
|
16
|
+
return fileURLToPath(new URL('../assets/guest/guest-agent-mac.js', import.meta.url));
|
|
17
|
+
}
|
|
18
|
+
/** Uploads the bundled guest agent and runs `install` (idempotent:
|
|
19
|
+
* launchd jobs reloaded, zprofile/ssh config marker-guarded, docker
|
|
20
|
+
* context refreshed). The host alias is passed when the gateway is
|
|
21
|
+
* known; the agent falls back to the guest's netstat gateway.
|
|
22
|
+
*
|
|
23
|
+
* @param context - The run context (ports).
|
|
24
|
+
* @param node - The guest's node binary.
|
|
25
|
+
* @param gateway - The Tart gateway, when determinable.
|
|
26
|
+
*/
|
|
27
|
+
export async function ensureGuestAgent(context, node, gateway) {
|
|
28
|
+
const upload = await execVm(context.vm, ['sh', '-c', `mkdir -p ${GUEST_LIB_DIR} && cat > ${GUEST_AGENT}`], { input: readFileSync(guestAgentPath(), 'utf8') });
|
|
29
|
+
if (upload.code !== 0) {
|
|
30
|
+
logger.warn(`could not upload the guest agent (tart exec: ${upload.code}).`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const installArgs = [
|
|
34
|
+
node,
|
|
35
|
+
GUEST_AGENT,
|
|
36
|
+
'install',
|
|
37
|
+
'--agent-port',
|
|
38
|
+
String(context.agentPort),
|
|
39
|
+
'--docker-port',
|
|
40
|
+
String(context.dockerPort),
|
|
41
|
+
];
|
|
42
|
+
if (gateway) {
|
|
43
|
+
installArgs.push('--host-alias', gateway);
|
|
44
|
+
}
|
|
45
|
+
const install = await execVm(context.vm, installArgs);
|
|
46
|
+
if (install.code !== 0) {
|
|
47
|
+
logger.warn(`guest agent install failed (tart exec: ${install.code}).`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
logger.ok('Guest agent installed (SSH agent + Docker bridges persistent).');
|
|
51
|
+
}
|
|
52
|
+
/** Runs `guest-agent status` and folds the bridge-status lines into the
|
|
53
|
+
* run state.
|
|
54
|
+
*
|
|
55
|
+
* @param context - The run context (VM name).
|
|
56
|
+
* @param state - The accumulated run state.
|
|
57
|
+
* @param node - The guest's node binary.
|
|
58
|
+
*/
|
|
59
|
+
export async function readGuestStatus(context, state, node) {
|
|
60
|
+
const res = await execVm(context.vm, [node, GUEST_AGENT, 'status']);
|
|
61
|
+
if (res.code !== 0) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
for (const line of res.stdout.split('\n')) {
|
|
65
|
+
const match = /^bridge-status:(ssh-agent|docker)=(up|down)$/.exec(line.trim());
|
|
66
|
+
if (!match) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (match[1] === 'ssh-agent') {
|
|
70
|
+
state.bridges.agent.guestUp = match[2] === 'up';
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
state.bridges.docker.guestUp = match[2] === 'up';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// runners/macos-rules.ts — the agent-rules step: render the bundled rules
|
|
2
|
+
// for this run and drive the guest agent's `rules --probe` / `rules
|
|
3
|
+
// --force` (the sha256 marker semantics live in the guest agent). Port
|
|
4
|
+
// of the shell's install_agent_rules (probe → confirm → overwrite, with
|
|
5
|
+
// the conflict default of no).
|
|
6
|
+
import { execVm } from '../lib/tart.js';
|
|
7
|
+
import { logger } from '../lib/logger.js';
|
|
8
|
+
import { confirmDefault } from '../lib/prompt.js';
|
|
9
|
+
import { GUEST_AGENT } from './macos-guest.js';
|
|
10
|
+
import { loadAgentRules, renderAgentRules } from './rules.js';
|
|
11
|
+
/** The agent-rules probe/confirm/apply.
|
|
12
|
+
*
|
|
13
|
+
* @param context - The run context (paths to substitute).
|
|
14
|
+
* @param state - The accumulated run state (bridge flags).
|
|
15
|
+
* @param node - The guest's node binary.
|
|
16
|
+
*/
|
|
17
|
+
export async function installAgentRules(context, state, node) {
|
|
18
|
+
const content = loadAgentRules();
|
|
19
|
+
if (!content) {
|
|
20
|
+
logger.warn('agent rules file not found (assets/rules/agent-rules.md).');
|
|
21
|
+
state.rules = 'failed';
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const rendered = renderAgentRules(content, { HOST_WORK_DIR: context.workDir, GUEST_MOUNT: context.guestMount }, state.bridges.agent.bridged && state.bridges.agent.guestUp);
|
|
25
|
+
const probe = await execVm(context.vm, [node, GUEST_AGENT, 'rules'], { input: rendered });
|
|
26
|
+
const action = /^rules:probe=(.+)$/.exec(probe.stdout.trim())?.[1];
|
|
27
|
+
if (probe.code !== 0 || !action) {
|
|
28
|
+
logger.warn('could not inspect the agent rules in the guest.');
|
|
29
|
+
state.rules = 'failed';
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
await applyRulesAction(context, state, node, rendered, action);
|
|
33
|
+
}
|
|
34
|
+
/** The probe outcome decision tree (shell parity: install/update ask
|
|
35
|
+
* with the y default, conflict asks with the n default). */
|
|
36
|
+
async function applyRulesAction(context, state, node, rendered, action) {
|
|
37
|
+
if (action === 'uptodate') {
|
|
38
|
+
logger.info('Agent rules are up to date in the guest.');
|
|
39
|
+
state.rules = 'uptodate';
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (action !== 'install' && action !== 'update' && action !== 'conflict') {
|
|
43
|
+
logger.warn('could not inspect the agent rules in the guest.');
|
|
44
|
+
state.rules = 'failed';
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const isConflict = action === 'conflict';
|
|
48
|
+
if (isConflict) {
|
|
49
|
+
logger.info('The guest has its own agent rules.');
|
|
50
|
+
}
|
|
51
|
+
const ask = isConflict
|
|
52
|
+
? "Overwrite the guest's agent rules with the sandbox rules?"
|
|
53
|
+
: 'Install/update the sandbox agent rules in the guest?';
|
|
54
|
+
if (!(await confirmDefault(ask, { default: isConflict ? 'n' : 'y', yes: context.options.yes }))) {
|
|
55
|
+
logger.info(isConflict
|
|
56
|
+
? "Keeping the guest's own agent rules."
|
|
57
|
+
: "Keeping the guest's agent rules as they are.");
|
|
58
|
+
state.rules = 'kept';
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const write = await execVm(context.vm, [node, GUEST_AGENT, 'rules', '--force'], {
|
|
62
|
+
input: rendered,
|
|
63
|
+
});
|
|
64
|
+
if (write.code !== 0) {
|
|
65
|
+
logger.warn('could not install the agent rules into the guest.');
|
|
66
|
+
state.rules = 'failed';
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (isConflict) {
|
|
70
|
+
state.rules = 'overwritten';
|
|
71
|
+
logger.ok("Overwrote the guest's agent rules.");
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
state.rules = action === 'install' ? 'installed' : 'updated';
|
|
75
|
+
logger.ok('Installed/updated the sandbox agent rules (opencode + Copilot CLI).');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// runners/macos-summary.ts — the "Sandbox is ready" block (port of the
|
|
2
|
+
// shell's print_summary, same labels, same wording, same color scheme).
|
|
3
|
+
// The lines are printed with the label column right-aligned to 12 chars
|
|
4
|
+
// and the value padded to 4 spaces — like `printf ' %-12s %s\n'`.
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { logger } from '../lib/logger.js';
|
|
7
|
+
import { vmIp, vmState } from '../lib/tart.js';
|
|
8
|
+
/** Prints the macOS summary block (VM/shared dir/keys/bridges/rules/
|
|
9
|
+
* settings/OpenChamber/stop hints).
|
|
10
|
+
*
|
|
11
|
+
* @param context - The run context.
|
|
12
|
+
* @param state - The accumulated run state.
|
|
13
|
+
*/
|
|
14
|
+
export async function printMacosSummary(context, state) {
|
|
15
|
+
const ip = await vmIp(context.vm);
|
|
16
|
+
const vmStateNow = (await vmState(context.vm)) ?? 'stopped';
|
|
17
|
+
logger.step('Sandbox is ready');
|
|
18
|
+
const ipDesc = ip ? `IP ${ip}` : 'IP unavailable';
|
|
19
|
+
summaryLine('VM', `${logger.bold(context.vm)} (${vmStateNow}, ${ipDesc})`);
|
|
20
|
+
if (context.workDir && existsSync(context.workDir)) {
|
|
21
|
+
summaryLine('Shared dir', `${context.workDir} (in the guest: ${context.guestMount})`);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
summaryLine('Shared dir', 'not shared');
|
|
25
|
+
}
|
|
26
|
+
if (state.mode === 'gui') {
|
|
27
|
+
summaryLine('Keys', 'system shortcuts go to the guest while the window is focused');
|
|
28
|
+
}
|
|
29
|
+
printBridgeLines(context, state);
|
|
30
|
+
printRulesLine(state.rules);
|
|
31
|
+
printSettingsLine(state.settings);
|
|
32
|
+
printOpenchamberLine(context, state, ip);
|
|
33
|
+
printStopLines(context, state);
|
|
34
|
+
}
|
|
35
|
+
/** One ` LABEL........ VALUE` line. */
|
|
36
|
+
function summaryLine(label, value) {
|
|
37
|
+
logger.out(` ${label.padEnd(12)}${value}`);
|
|
38
|
+
}
|
|
39
|
+
function printBridgeLines(context, state) {
|
|
40
|
+
const agent = state.bridges.agent;
|
|
41
|
+
if (agent.bridged) {
|
|
42
|
+
const value = agent.guestUp
|
|
43
|
+
? `host agent -> TCP ${context.agentPort} -> guest (/tmp/ssh-agent.sock)`
|
|
44
|
+
: `host bridge up (TCP ${context.agentPort}), guest bridge not running`;
|
|
45
|
+
summaryLine('SSH agent', agent.guestUp ? loggerOk(value) : loggerWarn(value));
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
summaryLine('SSH agent', 'not bridged');
|
|
49
|
+
}
|
|
50
|
+
const docker = state.bridges.docker;
|
|
51
|
+
if (docker.bridged) {
|
|
52
|
+
if (docker.engineUp) {
|
|
53
|
+
summaryLine('Docker', loggerOk(`host engine (v${docker.serverVersion}) -> TCP ${context.dockerPort} -> guest (context 'host')`));
|
|
54
|
+
}
|
|
55
|
+
else if (docker.guestUp) {
|
|
56
|
+
summaryLine('Docker', loggerWarn('bridge up, engine not reachable in the guest — is Docker running on the host?'));
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
summaryLine('Docker', loggerWarn(`host bridge up (TCP ${context.dockerPort}), guest bridge not running`));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
summaryLine('Docker', 'not bridged');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function printRulesLine(rules) {
|
|
67
|
+
switch (rules) {
|
|
68
|
+
case 'installed':
|
|
69
|
+
summaryLine('Agent rules', loggerOk('installed for opencode + Copilot'));
|
|
70
|
+
break;
|
|
71
|
+
case 'updated':
|
|
72
|
+
case 'overwritten':
|
|
73
|
+
summaryLine('Agent rules', loggerOk('updated for opencode + Copilot'));
|
|
74
|
+
break;
|
|
75
|
+
case 'uptodate':
|
|
76
|
+
summaryLine('Agent rules', 'up to date in the guest');
|
|
77
|
+
break;
|
|
78
|
+
case 'kept':
|
|
79
|
+
summaryLine('Agent rules', 'guest rules kept (not overwritten)');
|
|
80
|
+
break;
|
|
81
|
+
case 'failed':
|
|
82
|
+
summaryLine('Agent rules', loggerWarn('could not be installed'));
|
|
83
|
+
break;
|
|
84
|
+
default:
|
|
85
|
+
summaryLine('Agent rules', 'not installed');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function printSettingsLine(settings) {
|
|
89
|
+
switch (settings) {
|
|
90
|
+
case 'copied':
|
|
91
|
+
summaryLine('Settings', loggerOk('copied into the guest'));
|
|
92
|
+
break;
|
|
93
|
+
case 'uptodate':
|
|
94
|
+
summaryLine('Settings', 'already in the guest');
|
|
95
|
+
break;
|
|
96
|
+
case 'skipped':
|
|
97
|
+
summaryLine('Settings', 'not copied (--no-settings)');
|
|
98
|
+
break;
|
|
99
|
+
case 'none':
|
|
100
|
+
summaryLine('Settings', 'nothing to copy on the host');
|
|
101
|
+
break;
|
|
102
|
+
case 'failed':
|
|
103
|
+
summaryLine('Settings', loggerWarn('copy failed — re-run `agent-dev-env run` to retry'));
|
|
104
|
+
break;
|
|
105
|
+
default:
|
|
106
|
+
summaryLine('Settings', 'not copied');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function printOpenchamberLine(context, state, ip) {
|
|
110
|
+
if (state.openchamberUp && state.openchamberUrl) {
|
|
111
|
+
summaryLine('OpenChamber', loggerOk(`${state.openchamberUrl} (password: sandbox)`));
|
|
112
|
+
}
|
|
113
|
+
else if (ip) {
|
|
114
|
+
summaryLine('OpenChamber', loggerWarn(`not responding on http://${ip}:${context.openchamberPort}`));
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
summaryLine('OpenChamber', loggerWarn('not responding (VM IP unavailable)'));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function printStopLines(context, state) {
|
|
121
|
+
let hint = 'run "agent-dev-env stop macos"';
|
|
122
|
+
if (context.options.foreground && context.options.headless) {
|
|
123
|
+
hint = 'run "agent-dev-env stop macos" in another terminal';
|
|
124
|
+
}
|
|
125
|
+
else if (context.options.foreground && state.tartPid !== undefined) {
|
|
126
|
+
hint = 'press Cmd+C in this terminal, or run "agent-dev-env stop macos" in another terminal';
|
|
127
|
+
}
|
|
128
|
+
summaryLine('Stop', hint);
|
|
129
|
+
if (!context.options.foreground && state.tartPid !== undefined) {
|
|
130
|
+
summaryLine('Background', `VM keeps running after this script exits (tart log: ${state.tartLog})`);
|
|
131
|
+
if (state.bridges.agent.bridged) {
|
|
132
|
+
summaryLine('Bridge', `host listener on TCP ${context.agentPort} stays up — stop it with: agent-dev-env stop macos`);
|
|
133
|
+
}
|
|
134
|
+
if (state.bridges.docker.bridged) {
|
|
135
|
+
summaryLine('Bridge', `host Docker listener on TCP ${context.dockerPort} stays up — stop it with: agent-dev-env stop macos`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// color helpers — the shell's ${c_green}/${c_yellow} inline.
|
|
140
|
+
function loggerOk(text) {
|
|
141
|
+
return logger.color('green') + text + logger.reset();
|
|
142
|
+
}
|
|
143
|
+
function loggerWarn(text) {
|
|
144
|
+
return logger.color('yellow') + text + logger.reset();
|
|
145
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// runners/macos.ts — the macOS backend (Phase 3 of the port, tart only):
|
|
2
|
+
// image select (pull with confirm) + working VM clone, boot with the
|
|
3
|
+
// recommended settings, and the flow hooks the framework calls. The
|
|
4
|
+
// step-3 bridges live in macos-bridges.ts, the summary in
|
|
5
|
+
// macos-summary.ts — this module is the backend wiring.
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { isAlive, killTree, sleep, spawnDetached } from '../lib/exec.js';
|
|
10
|
+
import { registryRef, resolveOwner } from '../lib/ghcr.js';
|
|
11
|
+
import { logger } from '../lib/logger.js';
|
|
12
|
+
import { paths } from '../lib/paths.js';
|
|
13
|
+
import { PLATFORM_DEFAULTS } from '../lib/platform.js';
|
|
14
|
+
import { confirmDefault } from '../lib/prompt.js';
|
|
15
|
+
import { cloneVm, deleteVm, dirArg, pullImage, setVm, stopVm, tartAvailable, tartRunArgs, tartSetArgs, vmExists, vmIp, vmState, waitForVmState, } from '../lib/tart.js';
|
|
16
|
+
import { ensureUserSettings, restartOpenchamber } from '../settings/macos-copy.js';
|
|
17
|
+
import { ensureBridgeDir } from './bridges.js';
|
|
18
|
+
import { macosBridges } from './macos-bridges.js';
|
|
19
|
+
import { printMacosSummary } from './macos-summary.js';
|
|
20
|
+
import { offerOpenInBrowser, waitForOpenchamber } from './openchamber.js';
|
|
21
|
+
/** The macOS backend — tart only (no data footprint; Tart owns the VM
|
|
22
|
+
* store, our logs live under the logs/state dir).
|
|
23
|
+
*/
|
|
24
|
+
export const macosBackend = {
|
|
25
|
+
preflight: (context) => preflight(context),
|
|
26
|
+
ensureImageAndVm: ensureImageAndVm,
|
|
27
|
+
boot: boot,
|
|
28
|
+
setupBridges: macosBridges,
|
|
29
|
+
setupSettings: setupSettings,
|
|
30
|
+
verifyOpenchamber: verifyOpenchamber,
|
|
31
|
+
summarize: (context, state) => printMacosSummary(context, state),
|
|
32
|
+
finish: finish,
|
|
33
|
+
};
|
|
34
|
+
async function preflight(context) {
|
|
35
|
+
if (!tartAvailable()) {
|
|
36
|
+
logger.die("tart is not installed — run 'brew install cirruslabs/cli/tart' first.");
|
|
37
|
+
}
|
|
38
|
+
ensureBridgeDir();
|
|
39
|
+
if (context.options.reset) {
|
|
40
|
+
await teardownWorkingVm(context);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** `--reset`: stop + delete the working VM so the next step re-clones it
|
|
44
|
+
* fresh (a "start over" without touching the pristine image). */
|
|
45
|
+
async function teardownWorkingVm(context) {
|
|
46
|
+
const { vm } = context;
|
|
47
|
+
if (!(await vmExists(vm))) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if ((await vmState(vm)) === 'running') {
|
|
51
|
+
await stopVm(vm);
|
|
52
|
+
await waitForVmState(vm, 'stopped');
|
|
53
|
+
}
|
|
54
|
+
await deleteVm(vm);
|
|
55
|
+
logger.info(`Removed the working VM '${vm}' (--reset).`);
|
|
56
|
+
}
|
|
57
|
+
// --- step 1: image + working VM ---------------------------------------------
|
|
58
|
+
async function ensureImageAndVm(context, state) {
|
|
59
|
+
const { vm, image } = context;
|
|
60
|
+
const yes = context.options.yes;
|
|
61
|
+
if (await vmExists(vm)) {
|
|
62
|
+
logger.ok(`Working VM '${vm}' found (state: ${await vmState(vm)}).`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (await vmExists(image)) {
|
|
66
|
+
logger.info(`Sandbox image '${image}' is present.`);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
logger.info(`Sandbox image '${image}' is not pulled on this machine.`);
|
|
70
|
+
if (!(await confirmDefault('Pull it now?', { default: 'y', yes }))) {
|
|
71
|
+
logger.die("aborted — no sandbox image available. Run 'tart pull' manually when ready.");
|
|
72
|
+
}
|
|
73
|
+
await pullSandboxImage(context);
|
|
74
|
+
}
|
|
75
|
+
if ((await vmState(image)) === 'running') {
|
|
76
|
+
logger.die(`image VM '${image}' is running — stop it first: tart stop ${image}`);
|
|
77
|
+
}
|
|
78
|
+
const ask = `No working VM '${vm}' yet — clone it from the pristine image '${image}'?`;
|
|
79
|
+
if (await confirmDefault(ask, { default: 'y', yes })) {
|
|
80
|
+
logger.cmd(`tart clone ${image} ${vm}`);
|
|
81
|
+
const res = await cloneVm(image, vm);
|
|
82
|
+
if (res.code !== 0) {
|
|
83
|
+
throw new Error(`tart clone failed:\n${res.stderr.trim()}`);
|
|
84
|
+
}
|
|
85
|
+
state.created = true;
|
|
86
|
+
logger.ok(`Cloned '${vm}' from '${image}'.`);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
logger.die(`aborted — '${vm}' is required. Clone it manually with 'tart clone ${image} ${vm}'.`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function pullSandboxImage(context) {
|
|
93
|
+
const owner = await resolveOwner({ owner: context.options.owner, env: context.options.env });
|
|
94
|
+
const ref = registryRef(context.image, 'latest', owner);
|
|
95
|
+
const hint = PLATFORM_DEFAULTS[context.platform].downloadHint;
|
|
96
|
+
logger.info(`Pulling ${ref} (one-time, ${hint} download)...`);
|
|
97
|
+
const res = await pullImage(ref);
|
|
98
|
+
if (res.code !== 0) {
|
|
99
|
+
logger.die('pull failed — check your network connection (public GHCR images pull without a login).');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// --- step 2: boot -----------------------------------------------------------
|
|
103
|
+
async function boot(context, state) {
|
|
104
|
+
if ((await vmState(context.vm)) === 'running') {
|
|
105
|
+
state.vmAlreadyRunning = true;
|
|
106
|
+
const ask = `VM '${context.vm}' is already running — restart it?`;
|
|
107
|
+
if (await confirmDefault(ask, { default: 'n', yes: context.options.yes })) {
|
|
108
|
+
await stopAndWait(context);
|
|
109
|
+
await sleep(1000); // let tart release the VM lock before running it again
|
|
110
|
+
await launchVm(context, state);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
logger.ok(`Keeping the running VM — skipping 'tart run'.`);
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (state.created) {
|
|
118
|
+
await applyRecommendedSettings(context);
|
|
119
|
+
}
|
|
120
|
+
await launchVm(context, state);
|
|
121
|
+
}
|
|
122
|
+
async function stopAndWait(context) {
|
|
123
|
+
logger.cmd(`tart stop ${context.vm}`);
|
|
124
|
+
const stopped = await stopVm(context.vm);
|
|
125
|
+
if (stopped.code !== 0) {
|
|
126
|
+
logger.die(`'tart stop ${context.vm}' failed.`);
|
|
127
|
+
}
|
|
128
|
+
if (!(await waitForVmState(context.vm, 'stopped'))) {
|
|
129
|
+
logger.die(`timed out waiting for '${context.vm}' to stop.`);
|
|
130
|
+
}
|
|
131
|
+
logger.ok(`VM '${context.vm}' is stopped.`);
|
|
132
|
+
}
|
|
133
|
+
async function applyRecommendedSettings(context) {
|
|
134
|
+
const args = tartSetArgs(context.vm, context.cpuCount, context.memoryMb);
|
|
135
|
+
logger.cmd(`tart ${args.join(' ')}`);
|
|
136
|
+
const res = await setVm(args);
|
|
137
|
+
if (res.code !== 0) {
|
|
138
|
+
throw new Error(`tart set failed:\n${res.stderr.trim()}`);
|
|
139
|
+
}
|
|
140
|
+
logger.ok(`Applied recommended settings: ${context.cpuCount} CPUs / ` +
|
|
141
|
+
`${Math.round(context.memoryMb / 1024)} GB / 1280x800 display-refit.`);
|
|
142
|
+
}
|
|
143
|
+
async function launchVm(context, state) {
|
|
144
|
+
const { workDir } = context;
|
|
145
|
+
let shareArg;
|
|
146
|
+
if (workDir && existsSync(workDir)) {
|
|
147
|
+
shareArg = dirArg(context.mountName, workDir);
|
|
148
|
+
}
|
|
149
|
+
else if (workDir) {
|
|
150
|
+
logger.warn(`work directory '${workDir}' does not exist — skipping the shared-directory mount.`);
|
|
151
|
+
}
|
|
152
|
+
const args = tartRunArgs(context.vm, { headless: context.options.headless, dirArg: shareArg });
|
|
153
|
+
logger.cmd(`tart ${args.join(' ')}`);
|
|
154
|
+
state.tartLog = join(paths.logs, `tart-${context.vm}.log`);
|
|
155
|
+
if (context.options.foreground) {
|
|
156
|
+
const child = spawn('tart', args, { stdio: 'inherit' });
|
|
157
|
+
state.tartChild = child;
|
|
158
|
+
state.tartPid = child.pid;
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
logger.info(`Running the VM in the background (output: ${state.tartLog}).`);
|
|
162
|
+
state.tartPid = spawnDetached('tart', args, { logFile: state.tartLog });
|
|
163
|
+
}
|
|
164
|
+
await waitForBoot(context, state);
|
|
165
|
+
}
|
|
166
|
+
/** Polls `tart list` + the `tart run` process until the VM is up. */
|
|
167
|
+
async function waitForBoot(context, state) {
|
|
168
|
+
process.stdout.write(' Waiting for the VM to boot (up to 3 min)');
|
|
169
|
+
for (let attempt = 0; attempt < 90; attempt += 1) {
|
|
170
|
+
if (state.tartPid !== undefined && !isAlive(state.tartPid)) {
|
|
171
|
+
process.stdout.write('\n');
|
|
172
|
+
logger.die("'tart run' exited before the VM started.");
|
|
173
|
+
}
|
|
174
|
+
if ((await vmState(context.vm)) === 'running') {
|
|
175
|
+
const ip = await vmIp(context.vm);
|
|
176
|
+
state.vmIp = ip;
|
|
177
|
+
process.stdout.write(` ${logger.color('green')}done${logger.reset()}\n`);
|
|
178
|
+
logger.ok(ip ? `VM is running (IP: ${ip}).` : 'VM is running.');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
process.stdout.write('.');
|
|
182
|
+
await sleep(2000);
|
|
183
|
+
}
|
|
184
|
+
process.stdout.write('\n');
|
|
185
|
+
logger.die(`timed out waiting for '${context.vm}' to boot.`);
|
|
186
|
+
}
|
|
187
|
+
// --- step 4/5 hooks ---------------------------------------------------------
|
|
188
|
+
async function setupSettings(context, state) {
|
|
189
|
+
if (context.options.noSettings) {
|
|
190
|
+
logger.info('Skipping user settings copy (--no-settings).');
|
|
191
|
+
state.settings = 'skipped';
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
state.settings = await ensureUserSettings(context.vm, context.options.home, context.options.yes);
|
|
195
|
+
if (state.settings === 'copied') {
|
|
196
|
+
await restartOpenchamber(context.vm);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async function verifyOpenchamber(context, state) {
|
|
200
|
+
let ip = state.vmIp;
|
|
201
|
+
if (!ip) {
|
|
202
|
+
ip = await vmIp(context.vm);
|
|
203
|
+
state.vmIp = ip;
|
|
204
|
+
}
|
|
205
|
+
if (!ip) {
|
|
206
|
+
logger.warn('OpenChamber: VM IP unavailable — cannot probe.');
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
state.openchamberUrl = `http://${ip}:${context.openchamberPort}`;
|
|
210
|
+
state.openchamberUp = await waitForOpenchamber(state.openchamberUrl);
|
|
211
|
+
if (state.openchamberUp) {
|
|
212
|
+
await offerOpenInBrowser(state.openchamberUrl, context.options.yes);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// --- finish: foreground wait + bridge cleanup --------------------------------
|
|
216
|
+
async function finish(context, state) {
|
|
217
|
+
if (!context.options.foreground) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
await waitForForegroundVm(context, state);
|
|
221
|
+
// The VM stopped (or Cmd+C was pressed) — kill the bridges this run
|
|
222
|
+
// started; in background mode (and for a bridge that was already up
|
|
223
|
+
// from an earlier run) they stay up, outliving the CLI by design.
|
|
224
|
+
await cleanupRunBridge(state.bridges.agent.pid, 'agent bridge');
|
|
225
|
+
await cleanupRunBridge(state.bridges.docker.pid, 'Docker bridge');
|
|
226
|
+
}
|
|
227
|
+
/** @internal — kills one of this run's bridges. */
|
|
228
|
+
async function cleanupRunBridge(pid, label) {
|
|
229
|
+
if (pid === undefined) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
await killTree(pid);
|
|
233
|
+
logger.info(`Stopped the host ${label} (pid ${pid}).`);
|
|
234
|
+
}
|
|
235
|
+
async function waitForForegroundVm(context, state) {
|
|
236
|
+
const child = state.tartChild;
|
|
237
|
+
if (!child) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const exitCode = await new Promise((resolve) => {
|
|
241
|
+
const drain = () => {
|
|
242
|
+
// keep the wait going — `tart run` received the same signal
|
|
243
|
+
};
|
|
244
|
+
process.on('SIGINT', drain);
|
|
245
|
+
process.on('SIGTERM', drain);
|
|
246
|
+
child.on('close', (code) => {
|
|
247
|
+
process.removeListener('SIGINT', drain);
|
|
248
|
+
process.removeListener('SIGTERM', drain);
|
|
249
|
+
resolve(code);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
if (exitCode === 0) {
|
|
253
|
+
logger.info(`VM '${context.vm}' has stopped.`);
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
logger.warn(`VM '${context.vm}' exited with an error (see tart output above).`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// runners/openchamber.ts — the OpenChamber verification step (everything
|
|
2
|
+
// after "Step 5/5" in the shell runners): an HTTP probe with retries
|
|
3
|
+
// (curl -fsS --max-time 3 parity, via fetch + AbortSignal.timeout) and
|
|
4
|
+
// the open-in-browser offer. Shared by the backends — only the URL (VM
|
|
5
|
+
// IP vs 127.0.0.1 vs guest IP) differs per platform.
|
|
6
|
+
import { run } from '../lib/exec.js';
|
|
7
|
+
import { logger } from '../lib/logger.js';
|
|
8
|
+
import { confirmDefault } from '../lib/prompt.js';
|
|
9
|
+
/** Probes the OpenChamber URL until it answers (or the tries run out).
|
|
10
|
+
*
|
|
11
|
+
* @param url - e.g. `http://192.168.64.34:4000`.
|
|
12
|
+
* @param tries - Poll attempts (60 = up to 120 s at 2 s apart).
|
|
13
|
+
* @param delayMs - Delay between attempts.
|
|
14
|
+
* @returns True when OpenChamber answered within the window.
|
|
15
|
+
*/
|
|
16
|
+
export async function waitForOpenchamber(url, tries = 60, delayMs = 2000) {
|
|
17
|
+
logger.info(`Waiting for OpenChamber (up to ${Math.round((tries * delayMs) / 1000)} s)`);
|
|
18
|
+
for (let attempt = 0; attempt < tries; attempt += 1) {
|
|
19
|
+
if (await openchamberIsUp(url)) {
|
|
20
|
+
logger.ok(`OpenChamber is up: ${url} (default password: sandbox)`);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
if (attempt < tries - 1) {
|
|
24
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
logger.warn(`OpenChamber did not respond on ${url} within ${Math.round((tries * delayMs) / 1000)}s.`);
|
|
28
|
+
logger.warn('check it from inside the VM: openchamber status / openchamber logs');
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
/** @internal — one HTTP probe (fetch in Node 20+; signal via
|
|
32
|
+
* AbortSignal.timeout like curl --max-time 3). */
|
|
33
|
+
export async function openchamberIsUp(url) {
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
|
|
36
|
+
return res.ok;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** The open-in-browser offer (+ confirm). Bypassed with `yes` or when
|
|
43
|
+
* the URL is empty.
|
|
44
|
+
*
|
|
45
|
+
* @param url - The OpenChamber URL.
|
|
46
|
+
* @param yes - Skip the confirmation (--yes).
|
|
47
|
+
*/
|
|
48
|
+
export async function offerOpenInBrowser(url, yes) {
|
|
49
|
+
if (!url) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
logger.info(`OpenChamber URL: ${url}`);
|
|
53
|
+
if (!(await confirmDefault(`Open ${url} in your browser now?`, { default: 'y', yes }))) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const res = await run('open', [url]);
|
|
57
|
+
if (res.code !== 0) {
|
|
58
|
+
logger.warn(`could not open a browser — open ${url} manually.`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// runners/options.ts — `run` option resolution: commander flags merged over
|
|
2
|
+
// the SANDBOX_* environment variables onto the platform defaults (the same
|
|
3
|
+
// precedence chain the shell runners had: env holds the defaults, flags
|
|
4
|
+
// override them). Pure + unit-tested; the command layer just builds this.
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { PLATFORM_DEFAULTS } from '../lib/platform.js';
|
|
7
|
+
/** @internal — port env override validation (1-65535). */
|
|
8
|
+
export function parsePortEnv(value, name) {
|
|
9
|
+
const port = Number(value);
|
|
10
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
11
|
+
throw new Error(`invalid ${name} port: ${value} (expected 1-65535)`);
|
|
12
|
+
}
|
|
13
|
+
return port;
|
|
14
|
+
}
|
|
15
|
+
function parseIntEnv(value, name, def) {
|
|
16
|
+
const n = Number(value);
|
|
17
|
+
return Number.isInteger(n) && n > 0 ? n : def;
|
|
18
|
+
}
|
|
19
|
+
/** Resolves the run options for a platform from flags + env.
|
|
20
|
+
*
|
|
21
|
+
* @param platform - The target platform.
|
|
22
|
+
* @param flags - Overrides from the CLI flags.
|
|
23
|
+
* @param env - Environment (defaults to process.env).
|
|
24
|
+
* @param home - Host home directory (defaults to os.homedir()).
|
|
25
|
+
* @returns The resolved options.
|
|
26
|
+
* @throws Error on an invalid port override.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveRunOptions(platform, flags = {}, env = process.env, home = homedir()) {
|
|
29
|
+
const defs = PLATFORM_DEFAULTS[platform];
|
|
30
|
+
const workDirFlag = flags.workDir !== undefined ? flags.workDir : env.SANDBOX_WORK_DIR;
|
|
31
|
+
return {
|
|
32
|
+
platform,
|
|
33
|
+
image: flags.image ?? env.SANDBOX_IMAGE ?? defs.image,
|
|
34
|
+
vm: env.SANDBOX_VM ?? defs.vmName,
|
|
35
|
+
workDir: workDirFlag ?? defs.workDir ?? '',
|
|
36
|
+
mountName: env.SANDBOX_MOUNT_NAME ?? defs.mountName ?? 'dev',
|
|
37
|
+
agentPort: parsePortEnv(env.SANDBOX_AGENT_PORT ?? String(defs.agentPort), 'SANDBOX_AGENT_PORT'),
|
|
38
|
+
dockerPort: parsePortEnv(env.SANDBOX_DOCKER_PORT ?? String(defs.dockerPort), 'SANDBOX_DOCKER_PORT'),
|
|
39
|
+
openchamberPort: parsePortEnv(env.SANDBOX_OPENCHAMBER_PORT ?? String(defs.openchamberPort), 'SANDBOX_OPENCHAMBER_PORT'),
|
|
40
|
+
sshPort: parsePortEnv(env.SANDBOX_SSH_PORT ?? String(defs.sshPort ?? 22), 'SANDBOX_SSH_PORT'),
|
|
41
|
+
rdpPort: parsePortEnv(env.SANDBOX_RDP_PORT ?? String(defs.rdpPort ?? 3389), 'SANDBOX_RDP_PORT'),
|
|
42
|
+
winrmPort: parsePortEnv(env.SANDBOX_WINRM_PORT ?? String(defs.winrmPort ?? 5985), 'SANDBOX_WINRM_PORT'),
|
|
43
|
+
cpuCount: parseIntEnv(env.SANDBOX_CPU_COUNT ?? '', 'SANDBOX_CPU_COUNT', defs.cpuCount),
|
|
44
|
+
memoryMb: parseIntEnv(env.SANDBOX_MEMORY_MB ?? '', 'SANDBOX_MEMORY_MB', defs.memoryMb),
|
|
45
|
+
headless: flags.headless === true,
|
|
46
|
+
foreground: flags.foreground === true,
|
|
47
|
+
noAgent: flags.noAgent === true,
|
|
48
|
+
noDocker: flags.noDocker === true,
|
|
49
|
+
noSettings: flags.noSettings === true,
|
|
50
|
+
reset: flags.reset === true,
|
|
51
|
+
yes: flags.yes === true,
|
|
52
|
+
owner: flags.owner,
|
|
53
|
+
env,
|
|
54
|
+
home,
|
|
55
|
+
};
|
|
56
|
+
}
|