Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"@expo/downloader",
"@expo/turtle-spawn",
"@expo/template-file",
"create-eas-build-function"
"create-eas-build-function",
"@expo/vm-tools"
]
],
"linked": [],
Expand Down
5 changes: 5 additions & 0 deletions .changeset/vm-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@expo/vm-tools': minor
---

Add a macOS startup script loader that runs `/usr/local/share/vm-tools/startup.sh` once per VM boot.
25 changes: 25 additions & 0 deletions packages/vm-tools/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# @expo/vm-tools

A startup script loader for macOS VMs.

## Install

```sh
bunx @expo/vm-tools@latest install
```

Installs a per-user launchd agent and a standalone runner. The agent starts at the next GUI login;
installation does not load it or run a script. Automatic GUI login is required, and the Node
executable used for installation must remain available.

## Startup script

At login, vm-tools waits for `/usr/local/share/vm-tools/startup.sh` and runs it with
`/bin/bash` once per VM boot. Put the file in the VM or provide it through a shared directory.
The script does not need executable permission.

The runner waits for the script to exit and returns its exit status. Failed scripts are not
restarted. The script owns its environment, processes and cleanup; use `exec` for a foreground
command so termination signals reach it.

Output is written to `~/Library/Logs/vm-tools.log`.
2 changes: 2 additions & 0 deletions packages/vm-tools/bin/vm-tools
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env node
require('../dist/index').runCliAsync();
12 changes: 12 additions & 0 deletions packages/vm-tools/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Config } from '@jest/types';

import baseConfig from '../../jest/jest.shared.config';

const config: Config.InitialOptions = {
...baseConfig,
rootDir: 'src',
testMatch: ['**/__tests__/*.test.ts'],
clearMocks: true,
};

export default config;
37 changes: 37 additions & 0 deletions packages/vm-tools/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "@expo/vm-tools",
"version": "27.1.0",
"description": "VM startup script loader",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <support@expo.io>",
"repository": {
"type": "git",
"url": "https://github.com/expo/eas-cli.git",
"directory": "packages/vm-tools"
},
"bin": "bin/vm-tools",
"files": [
"bin",
"dist",
"resources"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"test": "jest --config jest.config.ts",
"build": "ncc build src/main.ts -o dist --no-cache --no-source-map-register",
"typecheck": "tsc --noEmit",
"prepack": "yarn build",
"rebuild": "yarn build"
},
"devDependencies": {
"@types/jest": "^29.5.12",
"@types/node": "22.20.5",
"@vercel/ncc": "^0.38.1",
"jest": "^29.7.0",
"ts-jest": "^29.4.12",
"typescript": "^6.0.3"
}
}
16 changes: 16 additions & 0 deletions packages/vm-tools/resources/com.expo.vm-tools.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.expo.vm-tools</string>
<key>LimitLoadToSessionType</key><string>Aqua</string>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><false/>
<key>ExitTimeOut</key><integer>150</integer>
<key>WorkingDirectory</key><string>__HOME__</string>
<key>StandardOutPath</key><string>__HOME__/Library/Logs/vm-tools.log</string>
<key>StandardErrorPath</key><string>__HOME__/Library/Logs/vm-tools.log</string>
<key>ProgramArguments</key>
<array><string>__NODE__</string><string>__HOME__/.expo/vm-tools/run</string><string>daemon</string></array>
</dict>
</plist>
178 changes: 178 additions & 0 deletions packages/vm-tools/src/__tests__/main.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { ChildProcess, execFile, spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';

import { runStartupScriptOncePerBootAsync } from '../commands/daemon';
import { installStartupDaemonAsync } from '../commands/install';

jest.mock('node:child_process', () => {
const actual = jest.requireActual('node:child_process');
const { promisify } = jest.requireActual('node:util');
return {
...actual,
spawn: jest.fn(),
execFile: Object.assign(jest.fn(), {
[promisify.custom]: jest.fn(),
}),
};
});

let root: string;
let home: string;
let directory: string;
let child: ChildProcess;

beforeEach(async () => {
jest.resetAllMocks();
root = await fs.mkdtemp(path.join(os.tmpdir(), 'vm-tools-test-'));
home = path.join(root, 'guest & home');
directory = path.join(root, 'read only startup');
await fs.mkdir(home);
await fs.mkdir(directory);
jest.spyOn(os, 'homedir').mockReturnValue(home);
jest.mocked(promisify(execFile)).mockResolvedValue({ stdout: 'boot-one\n', stderr: '' });
child = Object.assign(new EventEmitter(), { kill: jest.fn() }) as unknown as ChildProcess;
jest.mocked(spawn).mockImplementation(() => {
setImmediate(() => child.emit('exit', 0, null));
return child;
});
});

afterEach(async () => {
jest.restoreAllMocks();
await fs.rm(root, { recursive: true, force: true });
});

test('install writes an offline runner and Aqua agent without running a payload', async () => {
const runnerPath = path.join(__dirname, '../main.ts');
await installStartupDaemonAsync(
runnerPath,
path.join(__dirname, '../../resources/com.expo.vm-tools.plist')
);
const plist = await fs.readFile(
path.join(home, 'Library/LaunchAgents/com.expo.vm-tools.plist'),
'utf8'
);
expect(plist).toContain('guest &amp; home');
expect(plist).toMatch(/<key>KeepAlive<\/key>\s*<false\/>/);
expect(plist).toMatch(/<key>LimitLoadToSessionType<\/key>\s*<string>Aqua<\/string>/);
expect(plist).toContain(process.execPath);
expect(plist).not.toContain('<key>Umask</key>');
expect(plist).toContain('<string>daemon</string>');
expect(plist).not.toContain('__NODE__');
expect(await fs.readFile(path.join(home, '.expo/vm-tools/run'))).toEqual(
await fs.readFile(runnerPath)
);
expect(spawn).not.toHaveBeenCalled();
expect(promisify(execFile)).not.toHaveBeenCalled();
});

test('waits for a nonexecutable script and executes it once with bash', async () => {
const pending = runStartupScriptOncePerBootAsync(directory);
await new Promise(resolve => setTimeout(resolve, 20));
expect(spawn).not.toHaveBeenCalled();
const script = path.join(directory, 'startup.sh');
await fs.writeFile(script, 'exit 0\n');
await expect(pending).resolves.toBe(0);
expect(spawn).toHaveBeenCalledWith('/bin/bash', [script], { stdio: 'inherit' });
await expect(runStartupScriptOncePerBootAsync(directory)).resolves.toBe(0);
expect(spawn).toHaveBeenCalledTimes(1);
});

test('waits for child exit, returns its error code, and does not retry', async () => {
await fs.writeFile(path.join(directory, 'startup.sh'), 'exit 7\n');
let launched!: () => void;
const started = new Promise<void>(resolve => {
launched = resolve;
});
jest.mocked(spawn).mockImplementation(() => {
launched();
return child;
});
let settled = false;
const pending = runStartupScriptOncePerBootAsync(directory).then(code => {
settled = true;
return code;
});
await started;
expect(settled).toBe(false);
child.emit('exit', 7, null);
await expect(pending).resolves.toBe(7);
await expect(runStartupScriptOncePerBootAsync(directory)).resolves.toBe(0);
expect(spawn).toHaveBeenCalledTimes(1);
});

test('a launch error consumes this boot instead of restarting the script', async () => {
await fs.writeFile(path.join(directory, 'startup.sh'), 'exit 0\n');
jest.mocked(spawn).mockImplementation(() => {
setImmediate(() => child.emit('error', new Error('spawn failed')));
return child;
});
await expect(runStartupScriptOncePerBootAsync(directory)).rejects.toThrow('spawn failed');
await expect(runStartupScriptOncePerBootAsync(directory)).resolves.toBe(0);
expect(spawn).toHaveBeenCalledTimes(1);
});

test('concurrent dispatchers execute only one payload', async () => {
await fs.writeFile(path.join(directory, 'startup.sh'), 'exit 0\n');
await expect(
Promise.all([
runStartupScriptOncePerBootAsync(directory),
runStartupScriptOncePerBootAsync(directory),
])
).resolves.toEqual([0, 0]);
expect(spawn).toHaveBeenCalledTimes(1);
});

test('forwards teardown signals to the foreground script and waits for its exit', async () => {
await fs.writeFile(path.join(directory, 'startup.sh'), 'exit 0\n');
let launched!: () => void;
const started = new Promise<void>(resolve => {
launched = resolve;
});
jest.mocked(spawn).mockImplementation(() => {
launched();
return child;
});
const pending = runStartupScriptOncePerBootAsync(directory);
await started;
process.emit('SIGTERM', 'SIGTERM');
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
child.emit('exit', null, 'SIGTERM');
await expect(pending).resolves.toBe(1);
await expect(runStartupScriptOncePerBootAsync(directory)).resolves.toBe(0);
expect(spawn).toHaveBeenCalledTimes(1);
});

test('teardown before launch releases this boot for a later invocation', async () => {
await fs.writeFile(path.join(directory, 'startup.sh'), 'exit 0\n');
const mkdir = fs.mkdir;
jest.spyOn(fs, 'mkdir').mockImplementation(async (target, options) => {
const result = await mkdir(target, options);
if (path.basename(String(target)) === 'started-boot-one') {
process.emit('SIGTERM', 'SIGTERM');
}
return result;
});
await expect(runStartupScriptOncePerBootAsync(directory)).rejects.toThrow(
'VM startup was interrupted.'
);
expect(spawn).not.toHaveBeenCalled();
await expect(fs.stat(path.join(home, '.expo/vm-tools/started-boot-one'))).rejects.toMatchObject({
code: 'ENOENT',
});
jest.mocked(fs.mkdir).mockRestore();
await expect(runStartupScriptOncePerBootAsync(directory)).resolves.toBe(0);
expect(spawn).toHaveBeenCalledTimes(1);
});

test('teardown cancels waiting for a missing mount without executing anything', async () => {
const pending = runStartupScriptOncePerBootAsync(directory);
const rejected = expect(pending).rejects.toThrow();
process.emit('SIGTERM', 'SIGTERM');
await rejected;
expect(spawn).not.toHaveBeenCalled();
});
72 changes: 72 additions & 0 deletions packages/vm-tools/src/commands/daemon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { execFile, spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import { setTimeout as sleepAsync } from 'node:timers/promises';
import { promisify } from 'node:util';

import { getVmStateDirectory } from '../utils/state';

const STARTUP_DIRECTORY = '/usr/local/share/vm-tools';
const STARTUP_SCRIPT = 'startup.sh';
const execFilePromise = promisify(execFile);

export async function runStartupScriptOncePerBootAsync(
directory = STARTUP_DIRECTORY
): Promise<number> {
const controller = new AbortController();
let child: ReturnType<typeof spawn> | undefined;
const stop = (signal: NodeJS.Signals): void => {
controller.abort(new Error('VM startup was interrupted.'));
child?.kill(signal);
};
process.on('SIGTERM', stop);
process.on('SIGINT', stop);
try {
const script = path.join(directory, STARTUP_SCRIPT);
while (true) {
controller.signal.throwIfAborted();
try {
if (!(await fs.stat(script)).isFile()) {
throw new Error('VM startup.sh must be a regular file.');
}
break;
} catch (error) {
if (!['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '')) {
throw error;
}
}
await sleepAsync(100, undefined, { signal: controller.signal });
}
const { stdout } = await execFilePromise('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid']);
const boot = stdout.trim();
if (!boot) {
throw new Error(
'Could not read the VM boot identifier (kern.bootsessionuuid); startup cannot continue.'
);
}
await fs.mkdir(getVmStateDirectory(), { recursive: true });
const marker = path.join(getVmStateDirectory(), `started-${boot}`);
try {
await fs.mkdir(marker);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
return 0;
}
throw error;
}
if (controller.signal.aborted) {
// Nothing ran yet, so release this boot for a later invocation.
await fs.rmdir(marker);
controller.signal.throwIfAborted();
}
const startupProcess = spawn('/bin/bash', [script], { stdio: 'inherit' });
child = startupProcess;
return await new Promise<number>((resolve, reject) => {
startupProcess.once('error', reject);
startupProcess.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
});
} finally {
process.off('SIGTERM', stop);
process.off('SIGINT', stop);
}
}
32 changes: 32 additions & 0 deletions packages/vm-tools/src/commands/install.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

import { getVmStateDirectory } from '../utils/state';

const LABEL = 'com.expo.vm-tools';

function escapeXml(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
}

export async function installStartupDaemonAsync(
runnerPath: string,
plistPath: string
): Promise<void> {
const home = os.homedir();
const directory = getVmStateDirectory();
await Promise.all(
[directory, path.join(home, 'Library/LaunchAgents'), path.join(home, 'Library/Logs')].map(
filename => fs.mkdir(filename, { recursive: true })
)
);
await fs.copyFile(runnerPath, path.join(directory, 'run'));
const plist = await fs.readFile(plistPath, 'utf8');
await fs.writeFile(
path.join(home, `Library/LaunchAgents/${LABEL}.plist`),
plist
.replaceAll('__HOME__', escapeXml(home))
.replaceAll('__NODE__', escapeXml(process.execPath))
);
}
Loading
Loading