@myvillage/cli 2.13.2 → 2.14.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/package.json +1 -1
- package/src/commands/runner.js +111 -0
- package/src/index.js +21 -1
- package/src/utils/runner-service.js +230 -0
package/package.json
CHANGED
package/src/commands/runner.js
CHANGED
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { createRequire } from 'module';
|
|
5
5
|
import { runRunner, readRunnerState } from '../agent-runtime/runner.js';
|
|
6
|
+
import { getAccessToken } from '../utils/auth.js';
|
|
7
|
+
import {
|
|
8
|
+
describeRunnerService,
|
|
9
|
+
stageRunnerService,
|
|
10
|
+
runnerServiceStatus,
|
|
11
|
+
canSudo,
|
|
12
|
+
runWithSudo,
|
|
13
|
+
shellLine,
|
|
14
|
+
} from '../utils/runner-service.js';
|
|
6
15
|
|
|
7
16
|
function getCliVersion() {
|
|
8
17
|
try {
|
|
@@ -37,4 +46,106 @@ export function runnerStatusCommand() {
|
|
|
37
46
|
}
|
|
38
47
|
console.log(`Machine: ${state.name} (${state.machineId})`);
|
|
39
48
|
console.log(`Registered: ${state.registeredAt}`);
|
|
49
|
+
try {
|
|
50
|
+
const spec = describeRunnerService();
|
|
51
|
+
const { installed, running } = runnerServiceStatus(spec);
|
|
52
|
+
const label = !installed ? 'not installed (run `myvillage runner install-service`)' : running ? 'installed, running' : 'installed, not running';
|
|
53
|
+
console.log(`Service: ${label}`);
|
|
54
|
+
if (installed) console.log(`Log: ${spec.logFile}`);
|
|
55
|
+
} catch { /* unsupported platform: nothing to report */ }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function printCommands(commands, spec) {
|
|
59
|
+
for (const [i, argv] of commands.entries()) {
|
|
60
|
+
const note = spec.platform === 'launchd' && argv[1] === 'bootout' && i === 0 ? ' # fine if it says not found' : '';
|
|
61
|
+
console.log(` ${shellLine(argv)}${note}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function runnerInstallServiceCommand(options = {}) {
|
|
66
|
+
let spec;
|
|
67
|
+
try {
|
|
68
|
+
spec = describeRunnerService({
|
|
69
|
+
name: options.name,
|
|
70
|
+
kind: options.kind,
|
|
71
|
+
capacity: options.capacity,
|
|
72
|
+
interval: options.interval,
|
|
73
|
+
});
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error(err.message);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
if (process.getuid?.() === 0) {
|
|
79
|
+
console.error('Run this as the account the runner should run as, not as root. It will show or run the sudo steps itself.');
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (options.print) {
|
|
84
|
+
console.log(spec.content);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The service can't answer a login prompt, so the machine needs a credential
|
|
89
|
+
// before it boots unattended: a saved runner key, or a signed-in user.
|
|
90
|
+
const state = readRunnerState();
|
|
91
|
+
if (!state?.machineKey && !getAccessToken() && !process.env.MYVILLAGE_MACHINE_KEY) {
|
|
92
|
+
console.error('This machine has no runner credential yet. Run `myvillage login` (or `myvillage runner start` once) first.');
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const copy = stageRunnerService(spec);
|
|
97
|
+
console.log(`Runner service for "${spec.user}" (${spec.platform}):`);
|
|
98
|
+
console.log(` service file ${spec.staged} (copy: ${copy})`);
|
|
99
|
+
console.log(` installs to ${spec.target}`);
|
|
100
|
+
console.log(` log ${spec.logFile}`);
|
|
101
|
+
if (state?.pid) {
|
|
102
|
+
console.log('');
|
|
103
|
+
console.log(`Note: a runner is already running here (pid ${state.pid}). Stop it (Ctrl-C) once the service is up,`);
|
|
104
|
+
console.log('or the two will compete for the same agents.');
|
|
105
|
+
}
|
|
106
|
+
console.log('');
|
|
107
|
+
|
|
108
|
+
if (!options.yes && !canSudo()) {
|
|
109
|
+
console.log("This account isn't an admin, so an admin needs to run these once:");
|
|
110
|
+
printCommands(spec.install, spec);
|
|
111
|
+
console.log('');
|
|
112
|
+
console.log('Then check it with `myvillage runner status`.');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.log('Installing (sudo may ask for your password):');
|
|
117
|
+
printCommands(spec.install, spec);
|
|
118
|
+
const res = runWithSudo(spec.install, { ignoreFirstFailure: spec.platform === 'launchd' });
|
|
119
|
+
if (!res.ok) {
|
|
120
|
+
console.error(`\nFailed at: ${res.failed}`);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
const { running } = runnerServiceStatus(spec);
|
|
124
|
+
console.log(running
|
|
125
|
+
? '\nThe runner is installed and running. It will start at boot, before anyone logs in.'
|
|
126
|
+
: `\nInstalled, but it isn't running yet. Check ${spec.logFile}.`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function runnerUninstallServiceCommand(options = {}) {
|
|
130
|
+
let spec;
|
|
131
|
+
try {
|
|
132
|
+
spec = describeRunnerService();
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.error(err.message);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
if (!options.yes && !canSudo()) {
|
|
138
|
+
console.log("This account isn't an admin, so an admin needs to run these to remove the runner service:");
|
|
139
|
+
printCommands(spec.uninstall, spec);
|
|
140
|
+
console.log('Agents already running keep running until stopped (`myvillage agent stop <name>`).');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
console.log('Removing the runner service (sudo may ask for your password):');
|
|
144
|
+
printCommands(spec.uninstall, spec);
|
|
145
|
+
const res = runWithSudo(spec.uninstall);
|
|
146
|
+
if (!res.ok) {
|
|
147
|
+
console.error(`\nFailed at: ${res.failed}`);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
console.log('\nRemoved. Agents already running keep running until stopped (`myvillage agent stop <name>`).');
|
|
40
151
|
}
|
package/src/index.js
CHANGED
|
@@ -82,7 +82,12 @@ import {
|
|
|
82
82
|
agentRememberCommand,
|
|
83
83
|
agentRecipesListCommand,
|
|
84
84
|
} from './commands/agent-local.js';
|
|
85
|
-
import {
|
|
85
|
+
import {
|
|
86
|
+
runnerStartCommand,
|
|
87
|
+
runnerStatusCommand,
|
|
88
|
+
runnerInstallServiceCommand,
|
|
89
|
+
runnerUninstallServiceCommand,
|
|
90
|
+
} from './commands/runner.js';
|
|
86
91
|
import {
|
|
87
92
|
agentRegisterClientCommand,
|
|
88
93
|
agentListClientsCommand,
|
|
@@ -1115,6 +1120,21 @@ export function run() {
|
|
|
1115
1120
|
.command('status')
|
|
1116
1121
|
.description("Show this machine's runner registration")
|
|
1117
1122
|
.action(runnerStatusCommand);
|
|
1123
|
+
runnerCmd
|
|
1124
|
+
.command('install-service')
|
|
1125
|
+
.description('Start the runner at boot as this user (launchd on macOS, systemd on Linux)')
|
|
1126
|
+
.option('--name <name>', 'Machine name (default: the name it registered with)')
|
|
1127
|
+
.option('--kind <kind>', 'Machine kind: laptop|mac|pi|spark|cloud|other')
|
|
1128
|
+
.option('--capacity <n>', 'Soft cap on concurrently-run agents')
|
|
1129
|
+
.option('--interval <sec>', 'Reconcile/heartbeat interval in seconds')
|
|
1130
|
+
.option('--print', 'Only print the service file; install nothing')
|
|
1131
|
+
.option('--yes', 'Run the sudo steps even if this account does not look like an admin')
|
|
1132
|
+
.action(runnerInstallServiceCommand);
|
|
1133
|
+
runnerCmd
|
|
1134
|
+
.command('uninstall-service')
|
|
1135
|
+
.description('Stop the runner service and remove it')
|
|
1136
|
+
.option('--yes', 'Run the sudo steps even if this account does not look like an admin')
|
|
1137
|
+
.action(runnerUninstallServiceCommand);
|
|
1118
1138
|
|
|
1119
1139
|
program.parse();
|
|
1120
1140
|
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Install the fleet runner as a boot-time service, so a machine keeps running
|
|
2
|
+
// its assigned agents after a reboot or power cut with nobody logged in.
|
|
3
|
+
//
|
|
4
|
+
// macOS → a LaunchDaemon in /Library/LaunchDaemons that runs as this user
|
|
5
|
+
// Linux → a systemd unit in /etc/systemd/system that runs as this user
|
|
6
|
+
//
|
|
7
|
+
// Both are system-level on purpose: a per-user LaunchAgent or user unit only
|
|
8
|
+
// runs while someone is logged in. Installing one needs an admin, and the
|
|
9
|
+
// account that runs agents usually isn't one, so the service file is staged
|
|
10
|
+
// somewhere an admin can read it and the exact sudo commands are printed (or
|
|
11
|
+
// run, when this account can sudo).
|
|
12
|
+
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import os from 'os';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import { spawnSync } from 'child_process';
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
|
|
19
|
+
const LOG_FILE = path.join(os.homedir(), '.myvillage', 'logs', 'runner.log');
|
|
20
|
+
|
|
21
|
+
// The CLI's own entry point, resolved inside the installed package so it keeps
|
|
22
|
+
// working across `npm update -g` (the package directory doesn't move).
|
|
23
|
+
function cliEntryPath() {
|
|
24
|
+
return fs.realpathSync(fileURLToPath(new URL('../../bin/myvillage.js', import.meta.url)));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Prefer a stable `node` on PATH over process.execPath when they are the same
|
|
28
|
+
// binary: Homebrew's execPath points into a versioned Cellar dir that vanishes
|
|
29
|
+
// on the next `brew upgrade`, while /opt/homebrew/bin/node follows it.
|
|
30
|
+
function stableNodePath() {
|
|
31
|
+
const exec = fs.realpathSync(process.execPath);
|
|
32
|
+
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
|
33
|
+
if (!dir) continue;
|
|
34
|
+
const candidate = path.join(dir, 'node');
|
|
35
|
+
try {
|
|
36
|
+
if (fs.realpathSync(candidate) === exec) return candidate;
|
|
37
|
+
} catch { /* not here */ }
|
|
38
|
+
}
|
|
39
|
+
return process.execPath;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function servicePath(nodePath) {
|
|
43
|
+
const dirs = [path.dirname(nodePath)];
|
|
44
|
+
if (process.platform === 'darwin') dirs.push('/opt/homebrew/bin', '/opt/homebrew/sbin');
|
|
45
|
+
dirs.push('/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin');
|
|
46
|
+
return [...new Set(dirs)].join(':');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function runnerArgs(opts) {
|
|
50
|
+
const args = ['runner', 'start'];
|
|
51
|
+
if (opts.name) args.push('--name', String(opts.name));
|
|
52
|
+
if (opts.kind) args.push('--kind', String(opts.kind));
|
|
53
|
+
if (opts.capacity) args.push('--capacity', String(opts.capacity));
|
|
54
|
+
if (opts.interval) args.push('--interval', String(opts.interval));
|
|
55
|
+
return args;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const xml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
59
|
+
|
|
60
|
+
// systemd splits ExecStart on whitespace; quote anything that needs it.
|
|
61
|
+
const sdQuote = (s) => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(s) ? s : `"${String(s).replace(/(["\\])/g, '\\$1')}"`);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Describe the service for this machine and user. Pure apart from resolving
|
|
65
|
+
* paths, so it can be inspected (`--print`) before anything is installed.
|
|
66
|
+
*/
|
|
67
|
+
export function describeRunnerService(opts = {}) {
|
|
68
|
+
const user = os.userInfo().username;
|
|
69
|
+
const home = os.homedir();
|
|
70
|
+
const nodePath = stableNodePath();
|
|
71
|
+
const cliPath = cliEntryPath();
|
|
72
|
+
const program = [nodePath, cliPath, ...runnerArgs(opts)];
|
|
73
|
+
const env = { HOME: home, USER: user, LOGNAME: user, PATH: servicePath(nodePath), LANG: 'en_US.UTF-8' };
|
|
74
|
+
|
|
75
|
+
if (process.platform === 'darwin') {
|
|
76
|
+
const label = `ai.myvillage.runner.${user}`;
|
|
77
|
+
const target = `/Library/LaunchDaemons/${label}.plist`;
|
|
78
|
+
const content = `<?xml version="1.0" encoding="UTF-8"?>
|
|
79
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
80
|
+
<plist version="1.0">
|
|
81
|
+
<dict>
|
|
82
|
+
<key>Label</key>
|
|
83
|
+
<string>${xml(label)}</string>
|
|
84
|
+
<key>UserName</key>
|
|
85
|
+
<string>${xml(user)}</string>
|
|
86
|
+
<key>ProgramArguments</key>
|
|
87
|
+
<array>
|
|
88
|
+
${program.map((a) => `\t\t<string>${xml(a)}</string>`).join('\n')}
|
|
89
|
+
</array>
|
|
90
|
+
<key>WorkingDirectory</key>
|
|
91
|
+
<string>${xml(home)}</string>
|
|
92
|
+
<key>EnvironmentVariables</key>
|
|
93
|
+
<dict>
|
|
94
|
+
${Object.entries(env).map(([k, v]) => `\t\t<key>${xml(k)}</key>\n\t\t<string>${xml(v)}</string>`).join('\n')}
|
|
95
|
+
</dict>
|
|
96
|
+
<key>RunAtLoad</key>
|
|
97
|
+
<true/>
|
|
98
|
+
<key>KeepAlive</key>
|
|
99
|
+
<true/>
|
|
100
|
+
<key>ThrottleInterval</key>
|
|
101
|
+
<integer>10</integer>
|
|
102
|
+
<!-- Agent daemons outlive a runner restart; the runner re-adopts them. -->
|
|
103
|
+
<key>AbandonProcessGroup</key>
|
|
104
|
+
<true/>
|
|
105
|
+
<key>StandardOutPath</key>
|
|
106
|
+
<string>${xml(LOG_FILE)}</string>
|
|
107
|
+
<key>StandardErrorPath</key>
|
|
108
|
+
<string>${xml(LOG_FILE)}</string>
|
|
109
|
+
</dict>
|
|
110
|
+
</plist>
|
|
111
|
+
`;
|
|
112
|
+
const staged = `/Users/Shared/${label}.plist`;
|
|
113
|
+
return {
|
|
114
|
+
platform: 'launchd',
|
|
115
|
+
user,
|
|
116
|
+
label,
|
|
117
|
+
target,
|
|
118
|
+
staged,
|
|
119
|
+
content,
|
|
120
|
+
logFile: LOG_FILE,
|
|
121
|
+
install: [
|
|
122
|
+
['launchctl', 'bootout', `system/${label}`], // ignore failure: not loaded yet
|
|
123
|
+
['install', '-m', '644', '-o', 'root', '-g', 'wheel', staged, target],
|
|
124
|
+
['launchctl', 'bootstrap', 'system', target],
|
|
125
|
+
],
|
|
126
|
+
uninstall: [
|
|
127
|
+
['launchctl', 'bootout', `system/${label}`],
|
|
128
|
+
['rm', '-f', target],
|
|
129
|
+
],
|
|
130
|
+
statusCommand: ['launchctl', 'print', `system/${label}`],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (process.platform === 'linux') {
|
|
135
|
+
const unit = `myvillage-runner-${user}.service`;
|
|
136
|
+
const target = `/etc/systemd/system/${unit}`;
|
|
137
|
+
const content = `[Unit]
|
|
138
|
+
Description=MyVillage fleet runner (${user})
|
|
139
|
+
After=network-online.target
|
|
140
|
+
Wants=network-online.target
|
|
141
|
+
|
|
142
|
+
[Service]
|
|
143
|
+
Type=simple
|
|
144
|
+
User=${user}
|
|
145
|
+
WorkingDirectory=${home}
|
|
146
|
+
${Object.entries(env).map(([k, v]) => `Environment=${sdQuote(`${k}=${v}`)}`).join('\n')}
|
|
147
|
+
ExecStart=${program.map(sdQuote).join(' ')}
|
|
148
|
+
Restart=always
|
|
149
|
+
RestartSec=10
|
|
150
|
+
# Agent daemons outlive a runner restart; the runner re-adopts them.
|
|
151
|
+
KillMode=process
|
|
152
|
+
StandardOutput=append:${LOG_FILE}
|
|
153
|
+
StandardError=append:${LOG_FILE}
|
|
154
|
+
|
|
155
|
+
[Install]
|
|
156
|
+
WantedBy=multi-user.target
|
|
157
|
+
`;
|
|
158
|
+
const staged = path.join('/tmp', unit);
|
|
159
|
+
return {
|
|
160
|
+
platform: 'systemd',
|
|
161
|
+
user,
|
|
162
|
+
label: unit,
|
|
163
|
+
target,
|
|
164
|
+
staged,
|
|
165
|
+
content,
|
|
166
|
+
logFile: LOG_FILE,
|
|
167
|
+
install: [
|
|
168
|
+
['install', '-m', '644', '-o', 'root', '-g', 'root', staged, target],
|
|
169
|
+
['systemctl', 'daemon-reload'],
|
|
170
|
+
['systemctl', 'enable', unit],
|
|
171
|
+
['systemctl', 'restart', unit],
|
|
172
|
+
],
|
|
173
|
+
uninstall: [
|
|
174
|
+
['systemctl', 'disable', '--now', unit],
|
|
175
|
+
['rm', '-f', target],
|
|
176
|
+
['systemctl', 'daemon-reload'],
|
|
177
|
+
],
|
|
178
|
+
statusCommand: ['systemctl', 'is-active', unit],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
throw new Error(`Installing the runner as a service isn't supported on ${process.platform} yet (macOS and Linux only).`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Write the service file where an admin (including another account) can read it. */
|
|
186
|
+
export function stageRunnerService(spec) {
|
|
187
|
+
fs.mkdirSync(path.dirname(spec.logFile), { recursive: true });
|
|
188
|
+
fs.writeFileSync(spec.staged, spec.content, { mode: 0o644 });
|
|
189
|
+
fs.chmodSync(spec.staged, 0o644);
|
|
190
|
+
// Keep a private copy next to the runner's state for reference.
|
|
191
|
+
const copy = path.join(os.homedir(), '.myvillage', 'service', path.basename(spec.target));
|
|
192
|
+
fs.mkdirSync(path.dirname(copy), { recursive: true });
|
|
193
|
+
fs.writeFileSync(copy, spec.content, { mode: 0o644 });
|
|
194
|
+
return copy;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export const shellLine = (argv) => `sudo ${argv.map((a) => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`)).join(' ')}`;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Can this account run admin commands? macOS: member of `admin`. Linux: member
|
|
201
|
+
* of `sudo` or `wheel`, or root. A guess, not a guarantee; sudo still decides.
|
|
202
|
+
*/
|
|
203
|
+
export function canSudo() {
|
|
204
|
+
if (process.getuid?.() === 0) return true;
|
|
205
|
+
const res = spawnSync('id', ['-Gn'], { encoding: 'utf8' });
|
|
206
|
+
const groups = (res.stdout || '').trim().split(/\s+/);
|
|
207
|
+
return groups.includes(process.platform === 'darwin' ? 'admin' : 'sudo') || groups.includes('wheel');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Run each command under sudo with the terminal attached (sudo may prompt). */
|
|
211
|
+
export function runWithSudo(commands, { ignoreFirstFailure = false } = {}) {
|
|
212
|
+
for (const [i, argv] of commands.entries()) {
|
|
213
|
+
const res = spawnSync('sudo', argv, { stdio: 'inherit' });
|
|
214
|
+
if (res.status !== 0 && !(ignoreFirstFailure && i === 0)) {
|
|
215
|
+
return { ok: false, failed: shellLine(argv) };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return { ok: true };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Installed and running? Never needs sudo. */
|
|
222
|
+
export function runnerServiceStatus(spec) {
|
|
223
|
+
const installed = fs.existsSync(spec.target);
|
|
224
|
+
const res = spawnSync(spec.statusCommand[0], spec.statusCommand.slice(1), { encoding: 'utf8' });
|
|
225
|
+
const out = `${res.stdout || ''}${res.stderr || ''}`;
|
|
226
|
+
const running = spec.platform === 'launchd'
|
|
227
|
+
? res.status === 0 && /\bstate = running\b/.test(out)
|
|
228
|
+
: res.status === 0 && out.trim() === 'active';
|
|
229
|
+
return { installed, running };
|
|
230
|
+
}
|