agent-dev-env 0.1.0-canary.2.fbbd044

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/dist/assets/bridge/bridge.js +202 -0
  4. package/dist/assets/guest/guest-agent-mac.js +586 -0
  5. package/dist/assets/guest/guest-agent-ubuntu.js +481 -0
  6. package/dist/assets/guest/guest-agent-windows.js +466 -0
  7. package/dist/assets/images/mac/CHANGELOG.md +265 -0
  8. package/dist/assets/images/mac/README.md +63 -0
  9. package/dist/assets/images/mac/sandbox.pkr.hcl +468 -0
  10. package/dist/assets/images/mac/vars/sandbox-macos-tahoe.pkrvars.hcl +39 -0
  11. package/dist/assets/images/ubuntu-arm64-vmware/CHANGELOG.md +239 -0
  12. package/dist/assets/images/ubuntu-arm64-vmware/README.md +177 -0
  13. package/dist/assets/images/ubuntu-arm64-vmware/autoinstall/meta-data +2 -0
  14. package/dist/assets/images/ubuntu-arm64-vmware/autoinstall/user-data +34 -0
  15. package/dist/assets/images/ubuntu-arm64-vmware/sandbox.pkr.hcl +776 -0
  16. package/dist/assets/images/ubuntu-arm64-vmware/vars/sandbox-ubuntu-24-04-arm64-vmware.pkrvars.hcl +71 -0
  17. package/dist/assets/images/windows-arm64-qemu/CHANGELOG.md +209 -0
  18. package/dist/assets/images/windows-arm64-qemu/README.md +142 -0
  19. package/dist/assets/images/windows-arm64-qemu/autounattend.xml +325 -0
  20. package/dist/assets/images/windows-arm64-qemu/qemu-with-tpm.sh +170 -0
  21. package/dist/assets/images/windows-arm64-qemu/sandbox.pkr.hcl +1012 -0
  22. package/dist/assets/images/windows-arm64-qemu/vars/sandbox-windows-11-arm64-qemu.pkrvars.hcl +83 -0
  23. package/dist/assets/images/windows-arm64-vmware/CHANGELOG.md +270 -0
  24. package/dist/assets/images/windows-arm64-vmware/README.md +171 -0
  25. package/dist/assets/images/windows-arm64-vmware/autounattend.xml +311 -0
  26. package/dist/assets/images/windows-arm64-vmware/sandbox.pkr.hcl +1036 -0
  27. package/dist/assets/images/windows-arm64-vmware/vars/sandbox-windows-11-arm64-vmware.pkrvars.hcl +83 -0
  28. package/dist/assets/rules/agent-rules-linux.md +68 -0
  29. package/dist/assets/rules/agent-rules.md +71 -0
  30. package/dist/assets/watchdog/watch-build-ocr.swift +40 -0
  31. package/dist/assets/watchdog/watch-build.py +275 -0
  32. package/dist/cli.js +52 -0
  33. package/dist/commands/delete.js +179 -0
  34. package/dist/commands/doctor.js +165 -0
  35. package/dist/commands/list.js +38 -0
  36. package/dist/commands/not-yet.js +15 -0
  37. package/dist/commands/register.js +150 -0
  38. package/dist/commands/run.js +65 -0
  39. package/dist/commands/status.js +142 -0
  40. package/dist/commands/stop.js +186 -0
  41. package/dist/commands/sync.js +98 -0
  42. package/dist/lib/exec.js +250 -0
  43. package/dist/lib/ghcr.js +82 -0
  44. package/dist/lib/git.js +100 -0
  45. package/dist/lib/logger.js +87 -0
  46. package/dist/lib/network.js +63 -0
  47. package/dist/lib/paths.js +116 -0
  48. package/dist/lib/platform.js +87 -0
  49. package/dist/lib/prompt.js +66 -0
  50. package/dist/lib/qemu.js +373 -0
  51. package/dist/lib/regex.js +9 -0
  52. package/dist/lib/ssh.js +230 -0
  53. package/dist/lib/tart.js +210 -0
  54. package/dist/lib/template.js +35 -0
  55. package/dist/lib/vars.js +99 -0
  56. package/dist/lib/vmrun.js +334 -0
  57. package/dist/lifecycle/build-macos.js +23 -0
  58. package/dist/lifecycle/build-qemu.js +204 -0
  59. package/dist/lifecycle/build-shared.js +355 -0
  60. package/dist/lifecycle/build-ubuntu.js +184 -0
  61. package/dist/lifecycle/build-watchdog.js +146 -0
  62. package/dist/lifecycle/build-windows-vmware.js +112 -0
  63. package/dist/lifecycle/build.js +55 -0
  64. package/dist/lifecycle/catalog.js +165 -0
  65. package/dist/lifecycle/deploy.js +161 -0
  66. package/dist/lifecycle/tag.js +77 -0
  67. package/dist/lifecycle/watch-build.js +64 -0
  68. package/dist/runners/bridges.js +156 -0
  69. package/dist/runners/framework.js +66 -0
  70. package/dist/runners/macos-bridges.js +143 -0
  71. package/dist/runners/macos-guest.js +76 -0
  72. package/dist/runners/macos-rules.js +77 -0
  73. package/dist/runners/macos-summary.js +145 -0
  74. package/dist/runners/macos.js +258 -0
  75. package/dist/runners/openchamber.js +60 -0
  76. package/dist/runners/options.js +56 -0
  77. package/dist/runners/qemu-image.js +183 -0
  78. package/dist/runners/rules.js +49 -0
  79. package/dist/runners/ubuntu-bridges.js +154 -0
  80. package/dist/runners/ubuntu-guest.js +136 -0
  81. package/dist/runners/ubuntu-image.js +26 -0
  82. package/dist/runners/ubuntu-rules.js +86 -0
  83. package/dist/runners/ubuntu-shared.js +65 -0
  84. package/dist/runners/ubuntu-summary.js +141 -0
  85. package/dist/runners/ubuntu.js +117 -0
  86. package/dist/runners/vmware-common.js +158 -0
  87. package/dist/runners/vmware-image.js +242 -0
  88. package/dist/runners/windows-autologon.js +119 -0
  89. package/dist/runners/windows-bridges.js +136 -0
  90. package/dist/runners/windows-guest.js +213 -0
  91. package/dist/runners/windows-image.js +23 -0
  92. package/dist/runners/windows-qemu-summary.js +85 -0
  93. package/dist/runners/windows-qemu.js +189 -0
  94. package/dist/runners/windows-shared.js +68 -0
  95. package/dist/runners/windows-summary.js +104 -0
  96. package/dist/runners/windows.js +96 -0
  97. package/dist/settings/common.js +107 -0
  98. package/dist/settings/macos-copy.js +223 -0
  99. package/dist/settings/macos.js +41 -0
  100. package/dist/settings/ubuntu-copy.js +223 -0
  101. package/dist/settings/ubuntu.js +68 -0
  102. package/package.json +52 -0
@@ -0,0 +1,142 @@
1
+ // commands/status.ts — `agent-dev-env status [platform]`: live status of
2
+ // one or all platforms. New command (no shell equivalent): reports whether
3
+ // the default image is pulled, whether the working VM exists, and whether
4
+ // it is running, per platform. Read-only and informational: missing
5
+ // tooling is reported, not fatal.
6
+ import { existsSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { defaultImageFor, imageVersion } from '../lifecycle/catalog.js';
9
+ import { isAlive, readPidFile, run } from '../lib/exec.js';
10
+ import { logger } from '../lib/logger.js';
11
+ import { qemuImagePath, qemuPidFile, qemuStateDir, qemuWorkingDir } from '../lib/qemu.js';
12
+ import { imageRootDir } from '../lib/paths.js';
13
+ import { PLATFORM_DEFAULTS, PLATFORMS } from '../lib/platform.js';
14
+ import { listVms, tartAvailable, vmIp } from '../lib/tart.js';
15
+ import { findVmrun, listRunningVms } from '../lib/vmrun.js';
16
+ /** Prints the live status of one platform (or all platforms when none is
17
+ * given).
18
+ *
19
+ * @param platformArg - The platform to report on; all when omitted.
20
+ * @returns Exit code: always 0 (status is informational).
21
+ */
22
+ export async function statusCmd(platformArg) {
23
+ const targets = platformArg ? [platformArg] : [...PLATFORMS];
24
+ for (const [i, platform] of targets.entries()) {
25
+ if (i > 0) {
26
+ logger.out('');
27
+ }
28
+ await printStatus(platform);
29
+ }
30
+ return 0;
31
+ }
32
+ /** Prints one platform's status. */
33
+ async function printStatus(platform) {
34
+ logger.title(platform);
35
+ let image;
36
+ try {
37
+ image = defaultImageFor(platform);
38
+ }
39
+ catch (err) {
40
+ logger.warn(err.message);
41
+ return;
42
+ }
43
+ let version = '?';
44
+ try {
45
+ version = imageVersion(image);
46
+ }
47
+ catch {
48
+ // vars file without image_version — show '?' and let deploy/tag fail.
49
+ }
50
+ const defs = PLATFORM_DEFAULTS[platform];
51
+ const details = [`image: ${image.name} (v${version})`];
52
+ switch (platform) {
53
+ case 'macos':
54
+ await readMacosStatus(image.name, defs.vmName, details);
55
+ break;
56
+ case 'windows-qemu':
57
+ await readQemuStatus(image, details);
58
+ break;
59
+ case 'windows-vmware':
60
+ case 'ubuntu-vmware':
61
+ await readVmwareStatus(platform, image, details);
62
+ break;
63
+ }
64
+ for (const line of details) {
65
+ logger.info(line);
66
+ }
67
+ }
68
+ async function readMacosStatus(imageName, vmName, details) {
69
+ if (!tartAvailable()) {
70
+ details.push('tart: not installed (brew install cirruslabs/cli/tart)');
71
+ return;
72
+ }
73
+ const vms = await listVms();
74
+ const imageState = vms.get(imageName);
75
+ details.push(imageState ? `image: pulled (${imageState})` : 'image: not pulled');
76
+ const vmState = vms.get(vmName);
77
+ if (!vmState) {
78
+ details.push(`VM: ${vmName} — not created`);
79
+ return;
80
+ }
81
+ if (vmState === 'running') {
82
+ const ip = await vmIp(vmName);
83
+ details.push(`VM: ${vmName} — running${ip ? ` (${ip})` : ''}`);
84
+ }
85
+ else {
86
+ details.push(`VM: ${vmName} — ${vmState}`);
87
+ }
88
+ }
89
+ async function readQemuStatus(image, details) {
90
+ const stateDir = qemuStateDir(image.name);
91
+ const workingDir = qemuWorkingDir(image.name);
92
+ const pidFile = qemuPidFile(image.name);
93
+ details.push(existsSync(qemuImagePath(image.name)) ? 'image: pulled' : 'image: not pulled');
94
+ let running = false;
95
+ let pid;
96
+ pid = readPidFile(pidFile);
97
+ if (pid !== undefined) {
98
+ running = isAlive(pid);
99
+ }
100
+ else if (existsSync(stateDir)) {
101
+ // A VM started without a pidfile (or a stale one): the overlay path is
102
+ // unique to this sandbox — same fallback as the stop script.
103
+ const pgrep = await run('pgrep', ['-f', `qemu-system-aarch64.*${stateDir}`]);
104
+ running = pgrep.code === 0 && pgrep.stdout.trim() !== '';
105
+ }
106
+ if (!existsSync(workingDir)) {
107
+ details.push(`VM: ${image.name} — not created (no state at ${stateDir})`);
108
+ }
109
+ else {
110
+ details.push(running ? `VM: ${image.name} — running (qemu pid ${pid ?? '?'})` : 'VM: stopped');
111
+ }
112
+ }
113
+ async function readVmwareStatus(platform, image, details) {
114
+ const stateDir = imageRootDir(platform, image.name);
115
+ const archive = join(stateDir, 'image', `${image.name}.tar.gz`);
116
+ const baseDir = join(stateDir, 'base');
117
+ const workingVmx = join(stateDir, 'working', `${image.name}.vmx`);
118
+ details.push(existsSync(archive) || existsSync(baseDir) ? 'image: pulled' : 'image: not pulled');
119
+ if (!existsSync(workingVmx)) {
120
+ details.push(`VM: ${image.name} — not created (no state at ${stateDir})`);
121
+ return;
122
+ }
123
+ const vmrun = findVmrun();
124
+ if (!vmrun) {
125
+ details.push('VM: state unknown — vmrun not found (VMware Fusion missing?)');
126
+ return;
127
+ }
128
+ let running = false;
129
+ try {
130
+ const runningVms = await listRunningVms({ vmrun });
131
+ const wanted = normalizePath(workingVmx);
132
+ running = runningVms.some((p) => normalizePath(p) === wanted);
133
+ }
134
+ catch (err) {
135
+ details.push(`vmrun list failed: ${err.message}`);
136
+ return;
137
+ }
138
+ details.push(running ? `VM: ${image.name} — running` : 'VM: stopped');
139
+ }
140
+ function normalizePath(p) {
141
+ return p.replace(/\/+/g, '/');
142
+ }
@@ -0,0 +1,186 @@
1
+ // commands/stop.ts — `agent-dev-env stop <platform>`: stop the VM
2
+ // ('tart stop' / 'vmrun stop' / the qemu+swtpm pidfiles) and kill the
3
+ // host bridges the runner left up (pidfile-managed bridge.js processes;
4
+ // a foreign listener on the port is left alone). macOS + Ubuntu VMware in
5
+ // Phases 3/4, Windows VMware in Phase 5, Windows QEMU in Phase 6.
6
+ import { existsSync } from 'node:fs';
7
+ import { run } from '../lib/exec.js';
8
+ import { logger } from '../lib/logger.js';
9
+ import { workingVmxPath } from '../lib/paths.js';
10
+ import { stopQemu, stopSwtpm } from '../lib/qemu.js';
11
+ import { stopVm, tartAvailable, vmExists, vmState, waitForVmState } from '../lib/tart.js';
12
+ import { findVmrun, isVmRunning, stopVmGraceful } from '../lib/vmrun.js';
13
+ import { stopHostBridge } from '../runners/bridges.js';
14
+ import { resolveRunOptions } from '../runners/options.js';
15
+ /** Stops a sandbox platform (VM + host bridges).
16
+ *
17
+ * @param platform - The platform to stop.
18
+ * @returns The process exit code.
19
+ */
20
+ export async function stopCmd(platform) {
21
+ if (platform === 'macos') {
22
+ await stopMacos();
23
+ return 0;
24
+ }
25
+ if (platform === 'windows-qemu') {
26
+ await stopQemuSandbox();
27
+ return 0;
28
+ }
29
+ await stopVmware(platform);
30
+ return 0;
31
+ }
32
+ /** The QEMU stop flow (the stop-windows-qemu-sandbox.sh port): qemu via
33
+ * the runner's qemu.pid (with the overlay-path pgrep fallback), swtpm
34
+ * (it otherwise holds the TPM state lock), then the host bridges.
35
+ */
36
+ export async function stopQemuSandbox() {
37
+ const options = resolveRunOptions('windows-qemu');
38
+ const image = options.image;
39
+ logger.title(`Stopping Windows QEMU sandbox: ${image}`);
40
+ logger.step('Stopping qemu');
41
+ await stopQemu(image);
42
+ logger.step('Stopping swtpm');
43
+ await stopSwtpm(image);
44
+ logger.step('Host bridges (SSH agent, Docker)');
45
+ await stopBridgeForPort('ssh-agent', options.agentPort);
46
+ await stopBridgeForPort('docker', options.dockerPort);
47
+ logger.step('Sandbox stopped');
48
+ }
49
+ /** The macOS stop flow — shared with `delete` (which stops first).
50
+ */
51
+ export async function stopMacos() {
52
+ const options = resolveRunOptions('macos');
53
+ requireTart();
54
+ const { vm } = options;
55
+ logger.title(`Stopping macOS sandbox: ${vm}`);
56
+ await stopTartVm(vm);
57
+ logger.step('Host bridges (SSH agent, Docker)');
58
+ await stopBridgeForPort('ssh-agent', options.agentPort);
59
+ await stopBridgeForPort('docker', options.dockerPort);
60
+ const state = (await vmState(vm)) ?? 'stopped';
61
+ logger.step('Sandbox stopped');
62
+ logger.out(` ${'VM'.padEnd(12)}${logger.bold(vm)} (${state})`);
63
+ logger.out(` ${'Bridges'.padEnd(12)}host listeners on TCP ${options.agentPort} and ` +
64
+ `${options.dockerPort} stopped`);
65
+ }
66
+ /** The VMware stop flow — shared by the Ubuntu and Windows backends (the
67
+ * legacy stop scripts; the guest-side services/bridges stop with the VM).
68
+ *
69
+ * @param platform - The VMware platform to stop.
70
+ */
71
+ export async function stopVmware(platform) {
72
+ const options = resolveRunOptions(platform);
73
+ requireVmrun();
74
+ const vmx = workingVmxPath(platform, options.image);
75
+ logger.title(`${platform === 'ubuntu-vmware' ? 'Ubuntu' : 'Windows'} VMware sandbox: ${vmx}`);
76
+ await stopVmwareVmx(vmx);
77
+ logger.step('Host bridges (SSH agent, Docker)');
78
+ await stopBridgeForPort('ssh-agent', options.agentPort);
79
+ await stopBridgeForPort('docker', options.dockerPort);
80
+ const running = await isVmRunning(vmx);
81
+ logger.step('Sandbox stopped');
82
+ logger.out(` ${'VM'.padEnd(12)}${logger.bold(vmx)} (${running ? 'running' : 'stopped'})`);
83
+ logger.out(` ${'Bridges'.padEnd(12)}host listeners on TCP ${options.agentPort} and ` +
84
+ `${options.dockerPort} stopped`);
85
+ }
86
+ /** Stops the macOS working VM with the legacy wait-for-stopped flow. */
87
+ async function stopTartVm(vm) {
88
+ if (!(await vmExists(vm))) {
89
+ logger.warn(`VM '${vm}' does not exist (was it deleted?) — skipping 'tart stop'.`);
90
+ return;
91
+ }
92
+ if ((await vmState(vm)) !== 'running') {
93
+ logger.info(`VM '${vm}' is already stopped.`);
94
+ return;
95
+ }
96
+ logger.cmd(`tart stop ${vm}`);
97
+ const res = await stopVm(vm);
98
+ if (res.code !== 0) {
99
+ logger.warn(`'tart stop ${vm}' failed — the VM may already be stopping.`);
100
+ }
101
+ process.stdout.write(` Waiting for '${vm}' to stop`);
102
+ if (await waitForVmState(vm, 'stopped')) {
103
+ process.stdout.write(` ${logger.color('green')}stopped${logger.reset()}\n`);
104
+ logger.ok(`VM '${vm}' is stopped.`);
105
+ }
106
+ else {
107
+ process.stdout.write(` ${logger.color('yellow')}failed${logger.reset()}\n`);
108
+ logger.warn(`'${vm}' is still running — force it with 'tart stop ${vm} --timeout 1'.`);
109
+ }
110
+ }
111
+ /** Stops the VMware working VM: graceful `soft` first, hard power-off as
112
+ * a fallback after a minute (both wait for the running-VM list).
113
+ */
114
+ async function stopVmwareVmx(vmx) {
115
+ if (!existsSync(vmx)) {
116
+ logger.warn(`No working VM (${vmx}) — was the sandbox ever run?`);
117
+ return;
118
+ }
119
+ if (!(await isVmRunning(vmx))) {
120
+ logger.info('VM is already stopped.');
121
+ return;
122
+ }
123
+ logger.cmd(`vmrun -T fusion stop ${vmx} soft`);
124
+ process.stdout.write(' Waiting for a graceful shutdown (up to 1 min)');
125
+ if (await stopVmGraceful(vmx)) {
126
+ process.stdout.write(` ${logger.color('green')}stopped${logger.reset()}\n`);
127
+ logger.ok(`VM is stopped (${vmx}).`);
128
+ }
129
+ else {
130
+ process.stdout.write(` ${logger.color('yellow')}failed${logger.reset()}\n`);
131
+ logger.warn("The VM is still running — stop it manually with 'vmrun -T fusion stop'.");
132
+ }
133
+ }
134
+ /** Kills the bridge we manage on the port (pidfile first), otherwise
135
+ * reports the listener state like the legacy stop script.
136
+ */
137
+ async function stopBridgeForPort(role, port) {
138
+ const pid = await stopHostBridge(role);
139
+ if (pid !== undefined) {
140
+ logger.ok(`Stopped the host bridge on TCP ${port} (pid ${pid}).`);
141
+ return;
142
+ }
143
+ const pids = await listenerPids(port);
144
+ if (pids.length === 0) {
145
+ logger.info(`No listener on TCP port ${port} — nothing to stop.`);
146
+ return;
147
+ }
148
+ for (const listener of pids) {
149
+ const command = await processCommand(listener);
150
+ if (!command.includes('bridge.js')) {
151
+ logger.warn(`Listener on TCP ${port} (pid ${listener}) is not an agent-dev-env ` +
152
+ 'bridge — leaving it alone.');
153
+ continue;
154
+ }
155
+ await run('kill', [String(listener)]);
156
+ logger.ok(`Stopped the host bridge on TCP ${port} (pid ${listener}).`);
157
+ }
158
+ }
159
+ /** @internal — pids listening on the given TCP port (lsof -ti). */
160
+ async function listenerPids(port) {
161
+ const res = await run('lsof', ['-tiTCP:' + port, '-sTCP:LISTEN']);
162
+ if (res.code !== 0) {
163
+ return [];
164
+ }
165
+ return res.stdout
166
+ .split('\n')
167
+ .map((line) => Number.parseInt(line.trim(), 10))
168
+ .filter((pid) => Number.isInteger(pid) && pid > 0);
169
+ }
170
+ /** @internal — the command line of a pid (`ps -p pid -o command=`). */
171
+ async function processCommand(pid) {
172
+ const res = await run('ps', ['-p', String(pid), '-o', 'command=']);
173
+ return res.code === 0 ? res.stdout.trim() : '';
174
+ }
175
+ /** tart prereq (same message as the runner). */
176
+ function requireTart() {
177
+ if (!tartAvailable()) {
178
+ logger.die("tart is not installed — run 'brew install cirruslabs/cli/tart' first.");
179
+ }
180
+ }
181
+ /** vmrun prereq (same message as the runner). */
182
+ function requireVmrun() {
183
+ if (!findVmrun()) {
184
+ logger.die('vmrun not found — install VMware Fusion (free for personal use) or set FUSION_APP_PATH.');
185
+ }
186
+ }
@@ -0,0 +1,98 @@
1
+ // commands/sync.ts — `agent-dev-env sync <platform> [--yes]`: copy the
2
+ // host's user settings into the guest on demand (no VM restart), update
3
+ // the version marker and restart OpenChamber. macos | ubuntu-vmware per
4
+ // the plan; macOS lands in Phase 3 (tart transport), Ubuntu in Phase 4
5
+ // (ssh2 transport).
6
+ import { existsSync } from 'node:fs';
7
+ import { sleep } from '../lib/exec.js';
8
+ import { logger } from '../lib/logger.js';
9
+ import { workingVmxPath } from '../lib/paths.js';
10
+ import { tartAvailable, vmExists, vmState } from '../lib/tart.js';
11
+ import { findVmrun, getGuestIpAddress, isVmRunning } from '../lib/vmrun.js';
12
+ import { openSshSession } from '../lib/ssh.js';
13
+ import { syncUserSettings } from '../settings/macos-copy.js';
14
+ import { syncUserSettings as syncUbuntuSettings } from '../settings/ubuntu-copy.js';
15
+ import { resolveGuestCredentials } from '../runners/ubuntu-guest.js';
16
+ import { resolveRunOptions } from '../runners/options.js';
17
+ import { notYet } from './not-yet.js';
18
+ /** Syncs the host's user settings into the guest.
19
+ *
20
+ * @param platform - The platform to sync.
21
+ * @param options - --yes flag.
22
+ * @returns The process exit code.
23
+ */
24
+ export async function syncCmd(platform, options) {
25
+ if (platform === 'macos') {
26
+ await syncMacos(options);
27
+ return 0;
28
+ }
29
+ if (platform === 'ubuntu-vmware') {
30
+ await syncUbuntu(options);
31
+ return 0;
32
+ }
33
+ return notYet('sync', platform);
34
+ }
35
+ /** The macOS sync flow (VM must be running — `tart exec` needs it). */
36
+ async function syncMacos(options) {
37
+ if (!tartAvailable()) {
38
+ logger.die("tart is not installed — run 'brew install cirruslabs/cli/tart' first.");
39
+ }
40
+ const runOptions = resolveRunOptions('macos', { yes: options.yes });
41
+ const { vm } = runOptions;
42
+ logger.title(`Syncing user settings into ${vm}`);
43
+ if (!(await vmExists(vm))) {
44
+ logger.die(`working VM '${vm}' not found — run 'agent-dev-env run macos' first.`);
45
+ }
46
+ if ((await vmState(vm)) !== 'running') {
47
+ logger.die(`VM '${vm}' is not running — start it with 'agent-dev-env run macos' first.`);
48
+ }
49
+ const outcome = await syncUserSettings(vm, runOptions.home, runOptions.yes === true);
50
+ if (outcome === 'copied') {
51
+ logger.ok(`Done — settings synced into '${vm}'.`);
52
+ }
53
+ }
54
+ /** The Ubuntu sync flow (VM must be running + sshd up — the settings
55
+ * travel over the ssh2 session).
56
+ */
57
+ async function syncUbuntu(options) {
58
+ if (!findVmrun()) {
59
+ logger.die('vmrun not found — install VMware Fusion (free for personal use) or set FUSION_APP_PATH.');
60
+ }
61
+ const runOptions = resolveRunOptions('ubuntu-vmware', { yes: options.yes });
62
+ const vmx = workingVmxPath('ubuntu-vmware', runOptions.image);
63
+ logger.title(`Syncing user settings into ${vmx}`);
64
+ if (!existsSync(vmx)) {
65
+ logger.die(`working VM '${vmx}' not found — run 'agent-dev-env run ubuntu-vmware' first.`);
66
+ }
67
+ if (!(await isVmRunning(vmx))) {
68
+ logger.die(`VM '${vmx}' is not running — start it with 'agent-dev-env run ubuntu-vmware' first.`);
69
+ }
70
+ const ip = await waitGuestIp(vmx);
71
+ const creds = resolveGuestCredentials(runOptions.image, runOptions.env, ip);
72
+ const session = await openSshSession(creds);
73
+ try {
74
+ const outcome = await syncUbuntuSettings(session, runOptions.home, runOptions.yes === true, creds.password);
75
+ if (outcome === 'copied') {
76
+ logger.ok(`Done — settings synced into '${vmx}'.`);
77
+ }
78
+ }
79
+ finally {
80
+ session.end();
81
+ }
82
+ }
83
+ /** Waits for the guest IP (bounded per call; the sync cannot proceed
84
+ * without it).
85
+ *
86
+ * @param vmx - The working VM vmx.
87
+ * @returns The guest IP.
88
+ */
89
+ async function waitGuestIp(vmx) {
90
+ for (let attempt = 0; attempt < 60; attempt += 1) {
91
+ const ip = await getGuestIpAddress(vmx);
92
+ if (ip) {
93
+ return ip;
94
+ }
95
+ await sleep(2000);
96
+ }
97
+ return logger.die('timed out waiting for the guest IP — are open-vm-tools running in the guest?');
98
+ }
@@ -0,0 +1,250 @@
1
+ // exec.ts — process helpers, the unified replacement for the shell
2
+ // scripts' repeated spawn/nohup/alarm/lsof glue:
3
+ //
4
+ // run() spawn + wait, captured stdout/stderr (replaces
5
+ // backticks/$(...) subshells)
6
+ // runChecked() run, throw on non-zero (replaces `cmd || die`)
7
+ // commandExists() `command -v`
8
+ // which() full path of a command on PATH
9
+ // isAlive() kill -0
10
+ // readPidFile() pid files the runners/stop scripts share
11
+ // spawnDetached() nohup-style detached process with a log file
12
+ // killTree() kill -0 pid && recursive pkill -P
13
+ // withTimeout() the perl alarm wrapper (upgradevm, ip polls, ...)
14
+ import { spawn } from 'node:child_process';
15
+ import { accessSync, constants as fsConstants, openSync, readFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ /** Spawns a command and waits for it to exit, capturing stdout/stderr.
18
+ *
19
+ * @param cmd - The command to run.
20
+ * @param args - Command-line arguments.
21
+ * @param options - cwd/env/input/timeout overrides.
22
+ * @returns The exit code + captured output (never rejects; spawn failures
23
+ * yield code -1 with the error in stderr).
24
+ */
25
+ export function run(cmd, args = [], options = {}) {
26
+ return new Promise((resolve) => {
27
+ const child = spawnChild(cmd, args, options);
28
+ const output = { stdout: '', stderr: '' };
29
+ attachStdio(child, output);
30
+ writeInput(child, options.input);
31
+ const timer = options.timeoutMs !== undefined ? armTimeout(child, options.timeoutMs) : undefined;
32
+ let settled = false;
33
+ const finish = (result) => {
34
+ if (settled) {
35
+ return;
36
+ }
37
+ settled = true;
38
+ if (timer !== undefined) {
39
+ clearTimeout(timer);
40
+ }
41
+ resolve(result);
42
+ };
43
+ child.on('error', (err) => {
44
+ finish({ code: -1, stdout: output.stdout, stderr: err.message, signal: null });
45
+ });
46
+ child.on('close', (code, signal) => {
47
+ finish({
48
+ code: code ?? -1,
49
+ stdout: output.stdout,
50
+ stderr: output.stderr,
51
+ signal,
52
+ });
53
+ });
54
+ });
55
+ }
56
+ function spawnChild(cmd, args, options) {
57
+ return spawn(cmd, args, {
58
+ cwd: options.cwd,
59
+ env: options.env ? { ...process.env, ...options.env } : process.env,
60
+ stdio: ['pipe', 'pipe', 'pipe'],
61
+ });
62
+ }
63
+ function attachStdio(child, output) {
64
+ child.stdout.setEncoding('utf8');
65
+ child.stderr.setEncoding('utf8');
66
+ child.stdout.on('data', (d) => {
67
+ output.stdout += d;
68
+ });
69
+ child.stderr.on('data', (d) => {
70
+ output.stderr += d;
71
+ });
72
+ }
73
+ function writeInput(child, input) {
74
+ child.stdin.on('error', () => {
75
+ // EPIPE when the child exits before reading stdin — not fatal.
76
+ });
77
+ if (input !== undefined) {
78
+ child.stdin.write(input);
79
+ }
80
+ child.stdin.end();
81
+ }
82
+ function armTimeout(child, ms) {
83
+ return setTimeout(() => {
84
+ child.kill('SIGKILL');
85
+ }, ms);
86
+ }
87
+ /** run() with a strict non-zero contract; throws with the command's
88
+ * stderr on failure (the `cmd || die` replacement).
89
+ * @param cmd - The command to run.
90
+ * @param args - Command-line arguments.
91
+ * @param options - cwd/env/input/timeout overrides.
92
+ * @returns The result on success.
93
+ * @throws Error with the stderr detail on a non-zero exit.
94
+ */
95
+ export async function runChecked(cmd, args = [], options = {}) {
96
+ const res = await run(cmd, args, options);
97
+ if (res.code !== 0) {
98
+ const detail = (res.stderr.trim() || res.stdout.trim()).trim();
99
+ throw new Error(`command failed (${res.code}): ${cmd}${args.length ? ` ${args.join(' ')}` : ''}` +
100
+ (detail ? `\n${detail}` : ''));
101
+ }
102
+ return res;
103
+ }
104
+ /** True when the file exists and is executable (access X_OK). */
105
+ export function isExecutable(path) {
106
+ try {
107
+ accessSync(path, fsConstants.X_OK);
108
+ return true;
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ }
114
+ /** `command -v`-style lookup.
115
+ *
116
+ * @param command - The command name (or a path with `/`).
117
+ * @param env - Environment to resolve PATH from (defaults to process.env).
118
+ * @returns The full path, or undefined when not found.
119
+ */
120
+ export function which(command, env = process.env) {
121
+ if (command.includes('/')) {
122
+ return isExecutable(command) ? command : undefined;
123
+ }
124
+ for (const dir of (env.PATH ?? '').split(':')) {
125
+ if (!dir) {
126
+ continue;
127
+ }
128
+ const candidate = join(dir, command);
129
+ if (isExecutable(candidate)) {
130
+ return candidate;
131
+ }
132
+ }
133
+ return undefined;
134
+ }
135
+ /** Whether the command resolves on PATH.
136
+ *
137
+ * @param command - The command name.
138
+ * @param env - Environment to resolve PATH from (defaults to process.env).
139
+ * @returns True when found executable.
140
+ */
141
+ export function commandExists(command, env = process.env) {
142
+ return which(command, env) !== undefined;
143
+ }
144
+ /** Delays the promise (the shell's `sleep`).
145
+ *
146
+ * @param ms - Milliseconds to wait.
147
+ * @returns A promise that resolves after the delay.
148
+ */
149
+ export function sleep(ms) {
150
+ return new Promise((resolve) => setTimeout(resolve, ms));
151
+ }
152
+ /** kill -0: whether the process is alive (EPERM counts as alive).
153
+ *
154
+ * @param pid - The process id.
155
+ * @returns True when a process with this pid exists.
156
+ */
157
+ export function isAlive(pid) {
158
+ if (!Number.isInteger(pid) || pid <= 0) {
159
+ return false;
160
+ }
161
+ try {
162
+ process.kill(pid, 0);
163
+ return true;
164
+ }
165
+ catch (err) {
166
+ return err.code === 'EPERM';
167
+ }
168
+ }
169
+ /** Reads a pid file.
170
+ *
171
+ * @param path - The pid file path.
172
+ * @returns The pid, or undefined when missing/invalid.
173
+ */
174
+ export function readPidFile(path) {
175
+ try {
176
+ const pid = Number.parseInt(readFileSync(path, 'utf8').trim(), 10);
177
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
178
+ }
179
+ catch {
180
+ return undefined;
181
+ }
182
+ }
183
+ /** nohup-equivalent: a detached process that survives the
184
+ * CLI exiting. Returns the pid for the runner's pid file.
185
+ * @param cmd - The command to run detached.
186
+ * @param args - Command-line arguments.
187
+ * @param options - Log file / cwd / env overrides.
188
+ * @returns The spawned pid.
189
+ */
190
+ export function spawnDetached(cmd, args, options = {}) {
191
+ let logFd;
192
+ if (options.logFile) {
193
+ logFd = openSync(options.logFile, 'a');
194
+ }
195
+ const child = spawn(cmd, args, {
196
+ detached: true,
197
+ cwd: options.cwd,
198
+ env: options.env ? { ...process.env, ...options.env } : process.env,
199
+ stdio: logFd !== undefined ? ['ignore', logFd, logFd] : 'ignore',
200
+ });
201
+ // detached:true alone still keeps the parent's event loop alive until
202
+ // the child exits — without unref() the CLI would hang past its work
203
+ // (run macos printed the "ready" summary and never exited). unref()
204
+ // lets the CLI exit while the daemon (tart run, bridge forwarders,
205
+ // qemu, watchdog) keeps running under its pid file.
206
+ child.unref();
207
+ return child.pid ?? -1;
208
+ }
209
+ /** Kills a process and all of its descendants (recursive
210
+ * pgrep -P).
211
+ * @param pid - The root process id.
212
+ * @param signal - Signal to send (default SIGTERM).
213
+ */
214
+ export async function killTree(pid, signal = 'SIGTERM') {
215
+ if (!isAlive(pid)) {
216
+ return;
217
+ }
218
+ const out = await run('pgrep', ['-P', String(pid)]);
219
+ for (const line of out.stdout.split('\n')) {
220
+ const child = Number.parseInt(line.trim(), 10);
221
+ if (Number.isInteger(child) && child > 0) {
222
+ await killTree(child, signal);
223
+ }
224
+ }
225
+ try {
226
+ process.kill(pid, signal);
227
+ }
228
+ catch {
229
+ // already gone
230
+ }
231
+ }
232
+ /** Rejects when the promise does not settle in time (perl alarm
233
+ * equivalent — vmrun/getGuestIPAddress can hang past their own
234
+ * timeouts).
235
+ * @param promise - The promise to race against the timer.
236
+ * @param ms - Timeout in milliseconds.
237
+ * @param message - Optional timeout error message.
238
+ * @returns The promise's value when it settles in time.
239
+ */
240
+ export function withTimeout(promise, ms, message) {
241
+ let timer;
242
+ const timeout = new Promise((_, reject) => {
243
+ timer = setTimeout(() => reject(new Error(message ?? `timed out after ${ms} ms`)), ms);
244
+ });
245
+ return Promise.race([promise, timeout]).finally(() => {
246
+ if (timer !== undefined) {
247
+ clearTimeout(timer);
248
+ }
249
+ });
250
+ }