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,210 @@
1
+ // tart.ts — tart invocation wrappers, the unified replacement for the
2
+ // scattered `tart list`/`ip`/`clone`/`set`/`run`/`stop`/`exec`/`pull`
3
+ // calls in the macOS runner/stop/delete/sync shell scripts (and the
4
+ // parsing in commands/status.ts).
5
+ //
6
+ // The arg builders and parsers are pure and unit-tested; the wrappers
7
+ // themselves stay thin (run + error translation). Throwing happens at
8
+ // the caller boundary like the shell's `|| die` — the wrappers report
9
+ // raw results, the runners turn them into the legacy messages.
10
+ import { commandExists, run, sleep } from './exec.js';
11
+ /** True when tart is on PATH (doctor/run preflight check). */
12
+ export function tartAvailable() {
13
+ return commandExists('tart');
14
+ }
15
+ /** @internal — Parses `tart list` rows into a name → state map
16
+ * (exported for the co-located unit tests; callers use listVms()).
17
+ *
18
+ * `tart list` prints one row per VM: `source name disk used … state` —
19
+ * the name is the second column, the state the last one. Header lines
20
+ * (starting with `Source`) and blank lines are skipped.
21
+ *
22
+ * @param output - The raw stdout of `tart list`.
23
+ * @returns Map of VM name → state (e.g. `running`, `stopped`).
24
+ */
25
+ export function parseTartList(output) {
26
+ const vms = new Map();
27
+ for (const line of output.split('\n')) {
28
+ const trimmed = line.trim();
29
+ if (!trimmed || /^Source\s/.test(trimmed)) {
30
+ continue;
31
+ }
32
+ const fields = trimmed.split(/\s+/);
33
+ if (fields.length >= 2) {
34
+ vms.set(fields[1], fields[fields.length - 1]);
35
+ }
36
+ }
37
+ return vms;
38
+ }
39
+ /** Runs `tart list` and returns the parsed name → state map.
40
+ *
41
+ * @returns The parsed VM map (empty on failure — callers check).
42
+ */
43
+ export async function listVms() {
44
+ const res = await run('tart', ['list']);
45
+ return res.code === 0 ? parseTartList(res.stdout) : new Map();
46
+ }
47
+ /** Whether a VM by this name exists in the tart store.
48
+ *
49
+ * @param name - The VM/image name.
50
+ * @returns True when present in `tart list`.
51
+ */
52
+ export async function vmExists(name) {
53
+ return (await listVms()).has(name);
54
+ }
55
+ /** The VM's state from `tart list` (running/stopped/…).
56
+ *
57
+ * @param name - The VM/image name.
58
+ * @returns The state, or undefined when the VM does not exist.
59
+ */
60
+ export async function vmState(name) {
61
+ return (await listVms()).get(name);
62
+ }
63
+ /** Polls `tart list` until the VM reaches the wanted state.
64
+ *
65
+ * @param name - The VM/image name.
66
+ * @param want - The state to wait for (`running`/`stopped`).
67
+ * @param tries - Poll attempts (60 = up to 2 min at 2 s apart).
68
+ * @param delayMs - Delay between attempts.
69
+ * @returns True when the state was reached.
70
+ */
71
+ export async function waitForVmState(name, want, tries = 60, delayMs = 2000) {
72
+ for (let attempt = 0; attempt < tries; attempt += 1) {
73
+ if ((await vmState(name)) === want) {
74
+ return true;
75
+ }
76
+ await sleep(delayMs);
77
+ }
78
+ return false;
79
+ }
80
+ /** The VM's IP from `tart ip` (empty when not reachable yet).
81
+ *
82
+ * @param name - The VM name.
83
+ * @returns The IP, or undefined when tart cannot report one.
84
+ */
85
+ export async function vmIp(name) {
86
+ const res = await run('tart', ['ip', name]);
87
+ return res.code === 0 && res.stdout.trim() ? res.stdout.trim() : undefined;
88
+ }
89
+ /** The host's address on Tart's VM network: always `.1` of the VM's /24.
90
+ *
91
+ * @param ip - The VM's IP (e.g. `192.168.64.34`).
92
+ * @returns The gateway IP, or undefined when the IP is not an IPv4 /24.
93
+ */
94
+ export function gatewayFromVmIp(ip) {
95
+ const parts = ip.split('.');
96
+ if (parts.length !== 4) {
97
+ return undefined;
98
+ }
99
+ return `${parts[0]}.${parts[1]}.${parts[2]}.1`;
100
+ }
101
+ /** Args for `tart set` — the runner's recommended VM settings.
102
+ *
103
+ * @param vm - The VM name.
104
+ * @param cpuCount - CPUs for the freshly cloned VM.
105
+ * @param memoryMb - RAM for the freshly cloned VM, in MB.
106
+ * @returns The argv for `tart set`.
107
+ */
108
+ export function tartSetArgs(vm, cpuCount, memoryMb) {
109
+ return [
110
+ 'set',
111
+ vm,
112
+ '--cpu',
113
+ String(cpuCount),
114
+ '--memory',
115
+ String(memoryMb),
116
+ '--display',
117
+ '1280x800',
118
+ '--display-refit',
119
+ ];
120
+ }
121
+ /** Args for `tart run`: the VM's launch flags (legacy order preserved).
122
+ *
123
+ * @param vm - The VM name.
124
+ * @param options - headless vs GUI flags + the optional `--dir` share.
125
+ * @returns The argv for `tart run`.
126
+ */
127
+ export function tartRunArgs(vm, options) {
128
+ const flags = options.headless
129
+ ? ['--no-graphics', '--no-audio']
130
+ : ['--capture-system-keys', '--no-audio'];
131
+ const dir = options.dirArg ? [options.dirArg] : [];
132
+ return ['run', ...flags, ...dir, vm];
133
+ }
134
+ /** The `--dir=<mount>:<hostDir>` argument for `tart run`.
135
+ *
136
+ * @param mountName - Mount name inside the guest.
137
+ * @param hostDir - Host directory to share.
138
+ * @returns The `--dir` argument value.
139
+ */
140
+ export function dirArg(mountName, hostDir) {
141
+ return `--dir=${mountName}:${hostDir}`;
142
+ }
143
+ /** Runs `tart clone` — the working VM is always a clone of the image.
144
+ *
145
+ * @param source - The pristine image VM name.
146
+ * @param dest - The working VM name.
147
+ * @returns The raw result (non-zero on failure, caller decides).
148
+ */
149
+ export function cloneVm(source, dest) {
150
+ return run('tart', ['clone', source, dest]);
151
+ }
152
+ /** Runs `tart set` with the given argv (tartSetArgs).
153
+ *
154
+ * @param args - Full argv from tartSetArgs.
155
+ * @returns The raw result.
156
+ */
157
+ export function setVm(args) {
158
+ return run('tart', args);
159
+ }
160
+ /** Runs `tart pull` of a registry ref (GHCR).
161
+ *
162
+ * @param registryRef - e.g. `ghcr.io/<owner>/<image>:latest`.
163
+ * @param options - run() overrides (timeouts etc.).
164
+ * @returns The raw result.
165
+ */
166
+ export function pullImage(registryRef, options = {}) {
167
+ return run('tart', ['pull', registryRef], options);
168
+ }
169
+ /** Runs `tart stop` (graceful; tart force-stops after its own timeout).
170
+ *
171
+ * @param vm - The VM name.
172
+ * @returns The raw result.
173
+ */
174
+ export function stopVm(vm) {
175
+ return run('tart', ['stop', vm]);
176
+ }
177
+ /** Runs `tart delete` — removes the VM from the store, disk included.
178
+ *
179
+ * @param vm - The VM name.
180
+ * @returns The raw result.
181
+ */
182
+ export function deleteVm(vm) {
183
+ return run('tart', ['delete', vm]);
184
+ }
185
+ /** Runs a command inside the guest via `tart exec` (stdin piping with
186
+ * `-i`).
187
+ *
188
+ * @param vm - The VM name (must be running).
189
+ * @param argv - The command + args to run inside the guest.
190
+ * @param options - `input` (implies `-i`) + run() overrides.
191
+ * @returns The raw result.
192
+ */
193
+ export function execVm(vm, argv, options = {}) {
194
+ const interactive = options.input !== undefined ? ['-i'] : [];
195
+ return run('tart', ['exec', ...interactive, vm, ...argv], options);
196
+ }
197
+ /** Reports the path of the node binary inside a running guest.
198
+ *
199
+ * Node is installed via nvm and aliased as the default, so it is only on
200
+ * PATH in login shells — resolve once per run and reuse the absolute path
201
+ * for the guest-agent invocations.
202
+ *
203
+ * @param vm - The running VM name.
204
+ * @returns The absolute node path, or undefined when not found.
205
+ */
206
+ export async function findGuestNode(vm) {
207
+ const res = await execVm(vm, ['sh', '-lc', 'command -v node']);
208
+ const node = res.code === 0 ? res.stdout.trim() : '';
209
+ return node && node.startsWith('/') ? node : undefined;
210
+ }
@@ -0,0 +1,35 @@
1
+ // template.ts — placeholder substitution shared by every piece of
2
+ // scaffolding the runners render from templates.
3
+ //
4
+ // The shell scripts substituted with sed (`s|{{TOKEN}}|value|g`); this is
5
+ // the unified replacement for all of them. Tokens are {{UPPER_SNAKE}}; an
6
+ // unknown token is left untouched (sed only replaced the tokens it was
7
+ // told about, too). The guest-side bridge assets migrated from `__X__`
8
+ // placeholders to {{TOKEN}} in the same unification.
9
+ import { escapeRegex } from './regex.js';
10
+ const TOKEN_RE = /\{\{([A-Za-z0-9_]+)\}\}/g;
11
+ /** Replaces every `{{TOKEN}}` occurrence with the substitution
12
+ * value. Tokens not present in `substitutions` are left as-is.
13
+ * @param template - The template text.
14
+ * @param substitutions - Token → value map.
15
+ * @returns The rendered text.
16
+ */
17
+ export function render(template, substitutions) {
18
+ return template.replace(TOKEN_RE, (match, key) => key in substitutions && substitutions[key] !== undefined ? String(substitutions[key]) : match);
19
+ }
20
+ /** Drops the given heading line and everything after it the
21
+ * sed `/^<heading>$/,$d` the macOS/Ubuntu runners use to remove the SSH
22
+ * agent section from the agent rules when no bridge is up. A heading
23
+ * that is not present leaves the content unchanged.
24
+ * @param content - The document.
25
+ * @param headingLine - The heading to drop from (e.g. `## SSH agent bridge`).
26
+ * @returns The content up to (but excluding) the heading.
27
+ */
28
+ export function dropSectionFrom(content, headingLine) {
29
+ const re = new RegExp(`^${escapeRegex(headingLine)}$`, 'm');
30
+ const idx = content.search(re);
31
+ if (idx === -1) {
32
+ return content;
33
+ }
34
+ return content.slice(0, idx);
35
+ }
@@ -0,0 +1,99 @@
1
+ // vars.ts — reading values out of Packer vars files (*.pkrvars.hcl).
2
+ //
3
+ // Port of the shell `read_var` sed pattern (scripts/build.sh, every runner):
4
+ //
5
+ // ^[[:space:]]*NAME[[:space:]]*=[[:space:]]*"([^"]*)"[[:space:]]*$
6
+ //
7
+ // extended with number and boolean parsing (disk_size = 160, a flag =
8
+ // true), and with port validation for the port-like vars. The vars files
9
+ // are the single source of truth for versions and resources, so everything
10
+ // that needs a value (image_version for deploy/tag/list, disk_size/ports
11
+ // for doctor, guest passwords for the runners) reads them through here.
12
+ import { escapeRegex } from './regex.js';
13
+ /** Reads a quoted-string assignment: `name = "value"`.
14
+ *
15
+ * @param text - Vars file content.
16
+ * @param name - The variable name.
17
+ * @returns The value, or undefined when not present as a quoted string.
18
+ */
19
+ export function readQuotedVar(text, name) {
20
+ const re = new RegExp(`^[ \\t]*${escapeRegex(name)}[ \\t]*=[ \\t]*"([^"]*)"[ \\t]*$`, 'm');
21
+ const m = text.match(re);
22
+ return m?.[1];
23
+ }
24
+ /** @internal — Integer assignment: `name = 160`.
25
+ * @param text - Vars file content.
26
+ * @param name - The variable name.
27
+ * @returns The value, or undefined when not present as an integer.
28
+ */
29
+ export function readNumberVar(text, name) {
30
+ const re = new RegExp(`^[ \\t]*${escapeRegex(name)}[ \\t]*=[ \\t]*(\\d+)[ \\t]*$`, 'm');
31
+ const m = text.match(re);
32
+ return m ? Number(m[1]) : undefined;
33
+ }
34
+ /** @internal — Boolean assignment: `name = true|false`.
35
+ * @param text - Vars file content.
36
+ * @param name - The variable name.
37
+ * @returns The value, or undefined when not present as a boolean.
38
+ */
39
+ export function readBoolVar(text, name) {
40
+ const re = new RegExp(`^[ \\t]*${escapeRegex(name)}[ \\t]*=[ \\t]*(true|false)[ \\t]*$`, 'm');
41
+ const m = text.match(re);
42
+ return m ? m[1] === 'true' : undefined;
43
+ }
44
+ /** @internal — Any assignment, string first (the shell only ever read
45
+ * quoted strings).
46
+ * @param text - Vars file content.
47
+ * @param name - The variable name.
48
+ * @returns The string/number/boolean value, or undefined when absent.
49
+ */
50
+ export function readVar(text, name) {
51
+ return readQuotedVar(text, name) ?? readNumberVar(text, name) ?? readBoolVar(text, name);
52
+ }
53
+ /** @internal — Port assignment (number 1-65535).
54
+ * @param text - Vars file content.
55
+ * @param name - The variable name.
56
+ * @returns The port, or undefined when absent.
57
+ * @throws RangeError when the port is out of range.
58
+ */
59
+ export function readPortVar(text, name) {
60
+ const v = readNumberVar(text, name);
61
+ if (v !== undefined && (v < 1 || v > 65535)) {
62
+ throw new RangeError(`port '${name}' is out of range (1-65535): ${v}`);
63
+ }
64
+ return v;
65
+ }
66
+ /** @internal — Quoted string that must exist — the pattern
67
+ * deploy.sh/tag.sh use for image_version, with the same error message
68
+ * shape.
69
+ * @param text - Vars file content.
70
+ * @param name - The variable name.
71
+ * @param file - The source path, included in the error message.
72
+ * @returns The quoted value.
73
+ * @throws Error when the variable is missing.
74
+ */
75
+ export function requireQuotedVar(text, name, file) {
76
+ const v = readQuotedVar(text, name);
77
+ if (v === undefined) {
78
+ throw new Error(`Could not read ${name} from ${file}`);
79
+ }
80
+ return v;
81
+ }
82
+ const ASSIGNMENT_RE = /^[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(?:"([^"]*)"|(\d+)|(true|false))[ \t]*$/gm;
83
+ /** Parses every assignment in a vars file (last value wins for duplicate
84
+ * keys).
85
+ *
86
+ * @param text - Vars file content.
87
+ * @returns The name → value map.
88
+ */
89
+ export function parseVars(text) {
90
+ const out = {};
91
+ for (const m of text.matchAll(ASSIGNMENT_RE)) {
92
+ const [, name, quoted, number, bool] = m;
93
+ if (name !== undefined) {
94
+ out[name] =
95
+ quoted !== undefined ? quoted : number !== undefined ? Number(number) : bool === 'true';
96
+ }
97
+ }
98
+ return out;
99
+ }
@@ -0,0 +1,334 @@
1
+ // vmrun.ts — vmrun resolution + the VMware helpers the runners, stop and
2
+ // delete flows share. Phase 1 landed the binary resolution + `list`;
3
+ // Phase 4 adds the hardware-upgrade/guest-IP/tools-state/shared-folder
4
+ // helpers for the VMware backends (the port of scripts/lib/vmware.sh).
5
+ //
6
+ // The pure parsers stay unit-testable (vmrun writes plain text); the IO
7
+ // wrappers stay thin. Hangs are bounded with withTimeout — vmrun's
8
+ // getGuestIPAddress/checkToolsState can hang past their own timeouts, and
9
+ // upgradevm never exits by design (it blocks after doing the work).
10
+ import { readFileSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { isExecutable, run, sleep, which, withTimeout } from './exec.js';
13
+ const DEFAULT_FUSION_APP = '/Applications/VMware Fusion.app';
14
+ /** Resolves the vmrun binary: PATH first, then the Fusion app bundle —
15
+ * the same order as scripts/lib/vmware.sh.
16
+ *
17
+ * @param env - Environment (FUSION_APP_PATH override).
18
+ * @returns The vmrun path, or undefined when not found.
19
+ */
20
+ export function findVmrun(env = process.env) {
21
+ const onPath = which('vmrun', env);
22
+ if (onPath) {
23
+ return onPath;
24
+ }
25
+ const fusionApp = env.FUSION_APP_PATH ?? DEFAULT_FUSION_APP;
26
+ const candidate = join(fusionApp, 'Contents', 'Public', 'vmrun');
27
+ return isExecutable(candidate) ? candidate : undefined;
28
+ }
29
+ /** `vmrun -T fusion <args>`, resolving the binary when not given. */
30
+ function vmrunCommand(args, options = {}) {
31
+ const vmrun = options.vmrun ?? findVmrun(options.env);
32
+ if (!vmrun) {
33
+ throw new Error('vmrun not found — install VMware Fusion (free for personal use) or set FUSION_APP_PATH.');
34
+ }
35
+ return { cmd: vmrun, argv: ['-T', 'fusion', ...args] };
36
+ }
37
+ /** Lists the paths of the running VMs, from `vmrun -T fusion list`.
38
+ *
39
+ * @param options - vmrun path / env overrides.
40
+ * @returns The running VMs' vmx paths.
41
+ * @throws Error when vmrun is missing or the list fails.
42
+ */
43
+ export async function listRunningVms(options = {}) {
44
+ const { cmd, argv } = vmrunCommand(['list'], options);
45
+ const res = await run(cmd, argv);
46
+ if (res.code !== 0) {
47
+ throw new Error(`vmrun list failed:\n${res.stderr.trim()}`);
48
+ }
49
+ return res.stdout
50
+ .split('\n')
51
+ .map((line) => line.trim())
52
+ .filter((line) => line && !/^Total running VMs:/i.test(line));
53
+ }
54
+ /** Normalizes a vmx path for comparison (vmrun list output vs our
55
+ * derived path — whitespace/case aside, the path itself must match).
56
+ *
57
+ * @param path - The path to normalize.
58
+ * @returns The path with duplicate slashes collapsed.
59
+ */
60
+ function normalizeVmxPath(path) {
61
+ return path.replace(/\/+/g, '/');
62
+ }
63
+ /** Whether the working VM is in Fusion's running-VM list.
64
+ *
65
+ * @param vmx - The vmx path to look up.
66
+ * @param options - vmrun path / env overrides.
67
+ * @returns True when the VM is running.
68
+ */
69
+ export async function isVmRunning(vmx, options = {}) {
70
+ try {
71
+ const running = await listRunningVms(options);
72
+ return running.some((path) => normalizeVmxPath(path) === normalizeVmxPath(vmx));
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ /** Waits until the VM disappears from the running list.
79
+ *
80
+ * @param vmx - The working VM vmx.
81
+ * @param tries - Poll attempts (30 = up to 1 min at 2 s apart).
82
+ * @param delayMs - Delay between attempts.
83
+ * @returns True when the VM stopped within the window.
84
+ */
85
+ export async function waitForVmNotRunning(vmx, tries = 30, delayMs = 2000) {
86
+ for (let attempt = 0; attempt < tries; attempt += 1) {
87
+ if (!(await isVmRunning(vmx))) {
88
+ return true;
89
+ }
90
+ await sleep(delayMs);
91
+ }
92
+ return false;
93
+ }
94
+ /** @internal — the strict last-line extraction of a vmrun answer
95
+ * (handles a trailing newline like the shell's `tail -n1`).
96
+ *
97
+ * @param output - The raw vmrun output.
98
+ * @returns The last non-empty trimmed line, or undefined.
99
+ */
100
+ function lastOutputLine(output) {
101
+ const lines = output
102
+ .split('\n')
103
+ .map((line) => line.trim())
104
+ .filter((line) => line !== '');
105
+ return lines.length > 0 ? lines[lines.length - 1] : undefined;
106
+ }
107
+ /** @internal — the `virtualhw.version` line from a vmx text. */
108
+ export function parseVmwareHwVersion(content) {
109
+ const match = /^[ \t]*virtualhw\.version[ \t]*=[ \t]*"([0-9]+)"/m.exec(content);
110
+ return match?.[1];
111
+ }
112
+ /** The working VM's hardware version (sed port; reads the vmx).
113
+ *
114
+ * @param vmx - The vmx path.
115
+ * @returns The version string, or undefined when absent.
116
+ */
117
+ export function vmwareHwVersion(vmx) {
118
+ try {
119
+ return parseVmwareHwVersion(readFileSync(vmx, 'utf8'));
120
+ }
121
+ catch {
122
+ return undefined;
123
+ }
124
+ }
125
+ /** @internal — rewrites the displayname key of a vmx (case-insensitive:
126
+ * replaces every `displayname =` / `displayName =` line with the new
127
+ * name, appends one at the end when the vmx has none — the awk port).
128
+ * A second, case-variant key would make Fusion refuse the VM.
129
+ *
130
+ * @param content - The vmx text.
131
+ * @param name - The VM's display name.
132
+ * @returns The rewritten text.
133
+ */
134
+ export function rewriteVmxDisplayName(content, name) {
135
+ const hasTrailingNewline = content.endsWith('\n');
136
+ const lines = content.split('\n');
137
+ if (hasTrailingNewline) {
138
+ lines.pop(); // the split artifact of the trailing newline
139
+ }
140
+ let seen = false;
141
+ const out = [];
142
+ for (const line of lines) {
143
+ if (/^[ \t]*displayname[ \t]*=/i.test(line)) {
144
+ out.push(`displayname = "${name}"`);
145
+ seen = true;
146
+ continue;
147
+ }
148
+ out.push(line);
149
+ }
150
+ if (!seen) {
151
+ out.push(`displayname = "${name}"`);
152
+ }
153
+ return out.join('\n') + (hasTrailingNewline ? '\n' : '');
154
+ }
155
+ /** Sets the vmx displayName (the name Fusion's library shows) —
156
+ * `vmrun clone` inherits the base's name, so the working clone would
157
+ * otherwise be indistinguishable from the pristine image.
158
+ *
159
+ * @param vmx - The vmx path.
160
+ * @param name - The display name.
161
+ * @returns True when the file was rewritten.
162
+ */
163
+ export function setVmDisplayName(vmx, name) {
164
+ try {
165
+ writeFileSync(vmx, rewriteVmxDisplayName(readFileSync(vmx, 'utf8'), name));
166
+ return true;
167
+ }
168
+ catch {
169
+ return false;
170
+ }
171
+ }
172
+ function vmrunRaw(args, options = {}) {
173
+ const { cmd, argv } = vmrunCommand(args, options);
174
+ return run(cmd, argv);
175
+ }
176
+ /** Copies the base VM into the working VM (`vmrun clone <src> <dst>
177
+ * full`).
178
+ *
179
+ * @param sourceVmx - The pristine (base) vmx.
180
+ * @param destinationVmx - The working vmx.
181
+ * @param options - vmrun path / env overrides.
182
+ * @returns The raw result.
183
+ */
184
+ export function cloneVm(sourceVmx, destinationVmx, options = {}) {
185
+ return vmrunRaw(['clone', sourceVmx, destinationVmx, 'full'], options);
186
+ }
187
+ /** Starts the VM (`vmrun start <vmx> gui|nogui`).
188
+ *
189
+ * @param vmx - The working vmx.
190
+ * @param mode - gui (Fusion window) or nogui (headless).
191
+ * @param options - vmrun path / env overrides.
192
+ * @returns The raw result.
193
+ */
194
+ export function startVm(vmx, mode = 'gui', options = {}) {
195
+ return vmrunRaw(['start', vmx, mode], options);
196
+ }
197
+ /** Stops the VM (`vmrun stop <vmx> [soft]`; soft = graceful via VMware
198
+ * Tools).
199
+ *
200
+ * @param vmx - The working vmx.
201
+ * @param mode - soft (default) or a hard power-off.
202
+ * @param options - vmrun path / env overrides.
203
+ * @returns The raw result.
204
+ */
205
+ export function stopVm(vmx, mode = 'soft', options = {}) {
206
+ const args = mode === 'soft' ? ['stop', vmx, 'soft'] : ['stop', vmx];
207
+ return vmrunRaw(args, options);
208
+ }
209
+ /** The stop flow of the legacy stop script: graceful `soft` stop, hard
210
+ * power-off after a minute, both with the wait-for-stopped poll.
211
+ *
212
+ * @param vmx - The working vmx.
213
+ * @param options - vmrun path / env overrides.
214
+ * @returns True when the VM was (or already is) stopped.
215
+ */
216
+ export async function stopVmGraceful(vmx, options = {}) {
217
+ if (!(await isVmRunning(vmx, options))) {
218
+ return true;
219
+ }
220
+ await stopVm(vmx, 'soft', options);
221
+ if (await waitForVmNotRunning(vmx, 30, 2000)) {
222
+ return true;
223
+ }
224
+ await stopVm(vmx, 'hard', options);
225
+ return waitForVmNotRunning(vmx, 30, 2000);
226
+ }
227
+ /** @internal — strict dotted-quad parse of `getGuestIPAddress` output
228
+ * (the error text "The VMware Tools are not running…" must never become
229
+ * the guest IP).
230
+ *
231
+ * @param output - The raw vmrun output (last line is the answer).
232
+ * @returns The IP, or undefined when not a valid dotted quad.
233
+ */
234
+ export function parseGuestIpAddress(output) {
235
+ const ip = lastOutputLine(output) ?? '';
236
+ return /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.test(ip) && ip !== '0.0.0.0'
237
+ ? ip
238
+ : undefined;
239
+ }
240
+ /** The guest IP as reported by open-vm-tools, bounded (vmrun has no
241
+ * timeout of its own — the perl alarm wrapper port).
242
+ *
243
+ * @param vmx - The working vmx.
244
+ * @param options - vmrun path / env overrides.
245
+ * @returns The dotted-quad IP, or undefined before the tools answer.
246
+ */
247
+ export async function getGuestIpAddress(vmx, options = {}) {
248
+ const { cmd, argv } = vmrunCommand(['getGuestIPAddress', vmx], options);
249
+ try {
250
+ const res = await withTimeout(run(cmd, argv), 30_000, 'vmrun getGuestIPAddress timed out');
251
+ return res.code === 0 ? parseGuestIpAddress(res.stdout) : undefined;
252
+ }
253
+ catch {
254
+ return undefined;
255
+ }
256
+ }
257
+ /** @internal — the last non-empty vmrun line (checkToolsState's answer).
258
+ *
259
+ * @param output - The raw vmrun output.
260
+ * @returns The trimmed last line, or undefined when empty.
261
+ */
262
+ export function parseToolsState(output) {
263
+ return lastOutputLine(output);
264
+ }
265
+ /** The VMware Tools state vmrun reports (`running`/`notrunning`),
266
+ * bounded like the IP fetch.
267
+ *
268
+ * @param vmx - The working vmx.
269
+ * @param options - vmrun path / env overrides.
270
+ * @returns The state string, or undefined on failure/timeout.
271
+ */
272
+ export async function checkToolsState(vmx, options = {}) {
273
+ const { cmd, argv } = vmrunCommand(['checkToolsState', vmx], options);
274
+ try {
275
+ const res = await withTimeout(run(cmd, argv), 30_000, 'vmrun checkToolsState timed out');
276
+ return res.code === 0 ? parseToolsState(res.stdout) : undefined;
277
+ }
278
+ catch {
279
+ return undefined;
280
+ }
281
+ }
282
+ /** The "Already exists" case from addSharedFolder (a share registered by
283
+ * a previous run is fine).
284
+ *
285
+ * @param err - The command's combined output.
286
+ * @returns True when the share is already registered.
287
+ */
288
+ export function sharedFolderAlreadyExists(err) {
289
+ return /already exists/i.test(err);
290
+ }
291
+ /** Registers a shared folder with vmrun (`addSharedFolder <vmx> <name>
292
+ * <path>`); the caller retries — the tools state can flip back right
293
+ * after checkToolsState reports running.
294
+ *
295
+ * @param vmx - The working vmx.
296
+ * @param name - Share name inside the guest.
297
+ * @param path - Host directory to share.
298
+ * @param options - vmrun path / env overrides.
299
+ * @returns The raw result.
300
+ */
301
+ export function addSharedFolder(vmx, name, path, options = {}) {
302
+ return vmrunRaw(['addSharedFolder', vmx, name, path], options);
303
+ }
304
+ /** Enables the registered shares for the running VM (best effort —
305
+ * `enableSharedFolders <vmx> runtime`), like the shell's `|| true`.
306
+ *
307
+ * @param vmx - The working vmx.
308
+ * @param options - vmrun path / env overrides.
309
+ */
310
+ export async function enableSharedFolders(vmx, options = {}) {
311
+ const { cmd, argv } = vmrunCommand(['enableSharedFolders', vmx, 'runtime'], options);
312
+ await run(cmd, argv);
313
+ }
314
+ /** Upgrades the VM to the hardware version the installed Fusion supports
315
+ * (vmrun upgradevm — which never exits, hence the 180 s cap; the work
316
+ * finishes before it blocks). The caller records .hw-version so it runs
317
+ * once per VM version.
318
+ *
319
+ * @param vmx - The working vmx.
320
+ * @param options - vmrun path / env overrides.
321
+ * @returns The resulting hardware version (or undefined when unreadable).
322
+ */
323
+ export async function upgradeVmHardware(vmx, options = {}) {
324
+ const before = vmwareHwVersion(vmx);
325
+ const { cmd, argv } = vmrunCommand(['upgradevm', vmx], options);
326
+ try {
327
+ await withTimeout(run(cmd, argv), 180_000, 'vmrun upgradevm timed out');
328
+ }
329
+ catch {
330
+ // upgradevm never exits — the timeout is expected; the vmx is
331
+ // written before it blocks.
332
+ }
333
+ return vmwareHwVersion(vmx) ?? before;
334
+ }