@myvillage/cli 2.13.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "2.13.1",
3
+ "version": "2.14.0",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,10 +24,10 @@ import path from 'path';
24
24
  import { getConfig } from '../utils/config.js';
25
25
  import { getAccessToken, isTokenExpired } from '../utils/auth.js';
26
26
  import { refreshAccessToken } from '../utils/api.js';
27
- import { listLocalAgents, readAgentConfig, getAgentDir, writeAgentConfig } from '../utils/local-agent.js';
27
+ import { listLocalAgents, readAgentConfig, getAgentDir } from '../utils/local-agent.js';
28
28
  import { startDaemon, stopDaemon, isDaemonRunning, getDaemonInfo } from './daemon.js';
29
29
  import { shipLogsOnce, forgetLogCursor } from './log-shipper.js';
30
- import { writeSyncedModel } from '../utils/agent-models.js';
30
+ import { writePublishedAgentConfig, readSyncedVersion } from '../utils/agent-config-sync.js';
31
31
 
32
32
  const RUNNER_STATE_FILE = path.join(os.homedir(), '.myvillage', 'runner.json');
33
33
  const DEFAULT_HEARTBEAT_MS = 20_000;
@@ -128,20 +128,6 @@ async function request(networkBaseUrl, pathname, method, body, headers) {
128
128
 
129
129
  // ── Config sync (materialise an assigned agent from the control plane) ───────
130
130
 
131
- // Records which published config version this machine last wrote, so we only
132
- // re-write files when the definition actually changed.
133
- function syncedVersionPath(agentDir) {
134
- return path.join(agentDir, '.config-version');
135
- }
136
-
137
- function readSyncedVersion(agentDir) {
138
- try {
139
- return Number(fs.readFileSync(syncedVersionPath(agentDir), 'utf8').trim()) || 0;
140
- } catch {
141
- return 0;
142
- }
143
- }
144
-
145
131
  /**
146
132
  * Write an assigned agent's files from its published definition, creating the
147
133
  * agent directory if this machine has never run it. Returns true when the agent
@@ -167,53 +153,12 @@ async function syncAgentConfig(ctx, assignment) {
167
153
  log(`could not fetch config for ${name} (${res.status}) — leaving local files alone`);
168
154
  return exists;
169
155
  }
170
- const c = res.json.data;
171
-
172
- fs.mkdirSync(agentDir, { recursive: true });
173
- if (typeof c.promptMd === 'string' && c.promptMd) {
174
- fs.writeFileSync(path.join(agentDir, 'prompt.md'), c.promptMd);
175
- }
176
- if (typeof c.toolsYaml === 'string' && c.toolsYaml) {
177
- fs.writeFileSync(path.join(agentDir, 'tools.yaml'), c.toolsYaml);
178
- }
179
-
180
- // The trained models this agent was given travel with its config.
181
- if (c.models && typeof c.models === 'object') {
182
- for (const [slug, entry] of Object.entries(c.models)) {
183
- const result = writeSyncedModel(slug, entry);
184
- if (result === 'written') log(`synced model ${slug} for ${name}`);
185
- else if (result === 'kept-local') log(`kept this machine's own model ${slug} for ${name}`);
186
- else if (result === 'invalid') log(`skipped model ${slug} for ${name}: its files didn't check out`);
187
- }
156
+ const { models } = writePublishedAgentConfig(name, { ...res.json.data, version: configVersion });
157
+ for (const { slug, result } of models) {
158
+ if (result === 'written') log(`synced model ${slug} for ${name}`);
159
+ else if (result === 'kept-local') log(`kept this machine's own model ${slug} for ${name}`);
160
+ else if (result === 'invalid') log(`skipped model ${slug} for ${name}: its files didn't check out`);
188
161
  }
189
-
190
- // Compose agent.config.yaml from server-known identity + published schedule
191
- // and brain, preserving anything already set locally that the control plane
192
- // does not own.
193
- let existingCfg = {};
194
- try { existingCfg = readAgentConfig(name) || {}; } catch { /* first sync */ }
195
- const cfg = {
196
- ...existingCfg,
197
- name,
198
- display_name: c.displayName || existingCfg.display_name || name,
199
- description: c.description ?? existingCfg.description ?? '',
200
- man: {
201
- ...(existingCfg.man || {}),
202
- agent_id: c.agentId,
203
- ...(c.villageAgentId ? { village_agent_id: c.villageAgentId } : {}),
204
- handle: c.handle || name,
205
- },
206
- ...(c.schedule ? { schedule: c.schedule } : {}),
207
- ...(c.brain ? { brain: c.brain } : {}),
208
- };
209
- writeAgentConfig(name, cfg);
210
-
211
- // Keep an empty .env so the daemon's loader and 0600 expectations hold, but
212
- // never overwrite secrets a person put here.
213
- const envPath = path.join(agentDir, '.env');
214
- if (!fs.existsSync(envPath)) fs.writeFileSync(envPath, '', { mode: 0o600 });
215
-
216
- fs.writeFileSync(syncedVersionPath(agentDir), String(configVersion));
217
162
  log(`synced ${name} config v${configVersion}${exists ? '' : ' (new on this machine)'}`);
218
163
  return true;
219
164
  }
@@ -15,6 +15,7 @@ import {
15
15
  listCommunities,
16
16
  getAgentActivity,
17
17
  listMyAgents,
18
+ getAgentPublishedConfig,
18
19
  listMyUnlinkedAgentProfiles,
19
20
  createVillageAgent as apiCreateVillageAgent,
20
21
  listAgentTasks,
@@ -45,6 +46,7 @@ import {
45
46
  readAgentLogs,
46
47
  getLogFilePath,
47
48
  } from '../utils/local-agent.js';
49
+ import { writePublishedAgentConfig } from '../utils/agent-config-sync.js';
48
50
  import { scaffoldAgent, TOOL_CATALOG, resolveCatalogEntry } from '../utils/agent-scaffolder.js';
49
51
  import { loadAgentRecipe, listAgentRecipes } from '../utils/agent-recipes.js';
50
52
  import { formatLocalAgentList, formatLocalAgentStatus } from '../utils/formatters.js';
@@ -1315,12 +1317,12 @@ export async function agentRememberCommand(name, text, options = {}) {
1315
1317
  // already has the agent — this command scaffolds the missing local config
1316
1318
  // (~/.myvillage/agents/<handle>/) so `agent start` can launch the daemon.
1317
1319
  //
1318
- // What pulls down: the network identity (id, handle, displayName) and a
1319
- // link to the existing VillageAgent task queue.
1320
- // What doesn't: custom prompt edits, tools.yaml customizations, and any
1321
- // API keys. Those stay machine-local and you'll need to redo them if you
1322
- // want an exact replica. The scaffold defaults are a reasonable starting
1323
- // point that will boot.
1320
+ // What pulls down: the network identity (id, handle, displayName), a link to
1321
+ // the existing VillageAgent task queue, and — when the agent has a published
1322
+ // definition (Studio publishes on every save) — its prompt.md, tools.yaml,
1323
+ // schedule, model settings and trained models.
1324
+ // What doesn't: API keys and other .env secrets. Those stay machine-local.
1325
+ // An agent that was never published falls back to the scaffold defaults.
1324
1326
 
1325
1327
  async function fetchMyAgentByHandle(handle) {
1326
1328
  const result = await listMyAgents({ includeVillageAgent: true });
@@ -1365,8 +1367,17 @@ export async function agentPullCommand(handle, options = {}) {
1365
1367
  return;
1366
1368
  }
1367
1369
 
1368
- // Default scaffold: just the always-on MyVillage MCP tool. Users can add
1369
- // more via `agent add-tool` once they're up.
1370
+ // The published definition, if there is one. A failure here is not fatal:
1371
+ // the scaffold defaults still boot.
1372
+ let published = null;
1373
+ try {
1374
+ const c = await getAgentPublishedConfig(profile.id);
1375
+ if (c && c.version > 0) published = c;
1376
+ } catch { /* fall back to defaults */ }
1377
+
1378
+ // Scaffold first so the directory has everything `start` expects (routines/,
1379
+ // logs/, .env); the published definition is then written over the defaults.
1380
+ // Default scaffold: just the always-on MyVillage MCP tool.
1370
1381
  const agentDir = getAgentDir(agentName);
1371
1382
  try {
1372
1383
  scaffoldAgent(agentDir, {
@@ -1394,6 +1405,16 @@ export async function agentPullCommand(handle, options = {}) {
1394
1405
  }
1395
1406
  writeAgentConfig(agentName, cfg);
1396
1407
 
1408
+ let models = [];
1409
+ if (published) {
1410
+ try {
1411
+ ({ models } = writePublishedAgentConfig(agentName, published));
1412
+ } catch (err) {
1413
+ spinner.warn(`Pulled ${handle}, but its saved setup couldn't be written: ${err.message}`);
1414
+ published = null;
1415
+ }
1416
+ }
1417
+
1397
1418
  spinner.succeed(`Pulled ${handle} to ~/.myvillage/agents/${agentName}/`);
1398
1419
 
1399
1420
  console.log('');
@@ -1404,11 +1425,25 @@ export async function agentPullCommand(handle, options = {}) {
1404
1425
  } else {
1405
1426
  console.log(` - ${chalk.yellow('No VillageAgent shim found — one will be created on first start.')}`);
1406
1427
  }
1407
- console.log('');
1408
- console.log(brand.teal(' Defaults that may need tweaking:'));
1409
- console.log(` - ${brand.gold('prompt.md')} (system prompt — generic template)`);
1410
- console.log(` - ${brand.gold('tools.yaml')} (only the MyVillage tool is enabled)`);
1411
- console.log(` - ${brand.gold('agent.config.yaml')} (anthropic + claude-sonnet by default)`);
1428
+ if (published) {
1429
+ const brain = published.brain;
1430
+ console.log(` - Saved setup v${published.version}: ${published.promptMd ? 'prompt.md, ' : ''}${published.toolsYaml ? 'tools.yaml, ' : ''}schedule & model settings`);
1431
+ if (brain?.model) console.log(` - Model: ${brain.provider ? `${brain.provider} / ` : ''}${brain.model}`);
1432
+ const written = models.filter(m => m.result === 'written' || m.result === 'kept-local').map(m => m.slug);
1433
+ if (written.length) console.log(` - Trained models: ${written.join(', ')}`);
1434
+ const skipped = models.filter(m => m.result === 'invalid').map(m => m.slug);
1435
+ if (skipped.length) console.log(` - ${chalk.yellow(`Skipped models whose files didn't check out: ${skipped.join(', ')}`)}`);
1436
+ console.log('');
1437
+ console.log(brand.teal(' Not included:'));
1438
+ console.log(` - ${brand.gold('.env')} secrets (API keys etc.) stay on the machine they were set on.`);
1439
+ } else {
1440
+ console.log('');
1441
+ console.log(brand.teal(' No saved setup on the network yet, so these are defaults:'));
1442
+ console.log(` - ${brand.gold('prompt.md')} (system prompt — generic template)`);
1443
+ console.log(` - ${brand.gold('tools.yaml')} (only the MyVillage tool is enabled)`);
1444
+ console.log(` - ${brand.gold('agent.config.yaml')} (anthropic + claude-sonnet by default)`);
1445
+ console.log(` Open the agent in Studio and save once on the original machine to publish it.`);
1446
+ }
1412
1447
  console.log('');
1413
1448
  console.log(brand.teal(' Next:'));
1414
1449
  console.log(` ${brand.gold(`myvillage agent edit ${agentName}`)} Customize prompt / tools`);
@@ -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 { runnerStartCommand, runnerStatusCommand } from './commands/runner.js';
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,79 @@
1
+ // Write an agent's published definition (GET /agents/:id/config) to its local
2
+ // directory. Shared by the fleet runner, which keeps assigned agents in sync,
3
+ // and `agent pull`, which rehydrates an agent on a new machine.
4
+ //
5
+ // `.env` is never written from the server: secrets stay per-machine until they
6
+ // can be envelope-encrypted (see AgentConfig in the ed-platform schema).
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { readAgentConfig, getAgentDir, writeAgentConfig } from './local-agent.js';
11
+ import { writeSyncedModel } from './agent-models.js';
12
+
13
+ // Records which published config version this machine last wrote, so the
14
+ // runner only re-writes files when the definition actually changed.
15
+ export function syncedVersionPath(agentDir) {
16
+ return path.join(agentDir, '.config-version');
17
+ }
18
+
19
+ export function readSyncedVersion(agentDir) {
20
+ try {
21
+ return Number(fs.readFileSync(syncedVersionPath(agentDir), 'utf8').trim()) || 0;
22
+ } catch {
23
+ return 0;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Materialise a published config `c` into ~/.myvillage/agents/<name>/.
29
+ * Anything already set locally that the control plane does not own is kept.
30
+ * Returns { models: [{ slug, result }] } so callers can report what happened.
31
+ */
32
+ export function writePublishedAgentConfig(name, c) {
33
+ const agentDir = getAgentDir(name);
34
+ fs.mkdirSync(agentDir, { recursive: true });
35
+
36
+ if (typeof c.promptMd === 'string' && c.promptMd) {
37
+ fs.writeFileSync(path.join(agentDir, 'prompt.md'), c.promptMd);
38
+ }
39
+ if (typeof c.toolsYaml === 'string' && c.toolsYaml) {
40
+ fs.writeFileSync(path.join(agentDir, 'tools.yaml'), c.toolsYaml);
41
+ }
42
+
43
+ // The trained models this agent was given travel with its config.
44
+ const models = [];
45
+ if (c.models && typeof c.models === 'object') {
46
+ for (const [slug, entry] of Object.entries(c.models)) {
47
+ models.push({ slug, result: writeSyncedModel(slug, entry) });
48
+ }
49
+ }
50
+
51
+ // Compose agent.config.yaml from server-known identity + published schedule
52
+ // and brain, preserving anything already set locally that the control plane
53
+ // does not own.
54
+ let existingCfg = {};
55
+ try { existingCfg = readAgentConfig(name) || {}; } catch { /* first sync */ }
56
+ const cfg = {
57
+ ...existingCfg,
58
+ name,
59
+ display_name: c.displayName || existingCfg.display_name || name,
60
+ description: c.description ?? existingCfg.description ?? '',
61
+ man: {
62
+ ...(existingCfg.man || {}),
63
+ agent_id: c.agentId,
64
+ ...(c.villageAgentId ? { village_agent_id: c.villageAgentId } : {}),
65
+ handle: c.handle || name,
66
+ },
67
+ ...(c.schedule ? { schedule: c.schedule } : {}),
68
+ ...(c.brain ? { brain: c.brain } : {}),
69
+ };
70
+ writeAgentConfig(name, cfg);
71
+
72
+ // Keep an empty .env so the daemon's loader and 0600 expectations hold, but
73
+ // never overwrite secrets a person put here.
74
+ const envPath = path.join(agentDir, '.env');
75
+ if (!fs.existsSync(envPath)) fs.writeFileSync(envPath, '', { mode: 0o600 });
76
+
77
+ if (c.version) fs.writeFileSync(syncedVersionPath(agentDir), String(c.version));
78
+ return { models };
79
+ }
package/src/utils/api.js CHANGED
@@ -302,6 +302,14 @@ export async function listMyAgents(options = {}) {
302
302
  return response.data;
303
303
  }
304
304
 
305
+ // The agent's published definition (prompt, tools, schedule, model settings,
306
+ // trained models). Studio republishes it on every save.
307
+ export async function getAgentPublishedConfig(id) {
308
+ const client = getNetworkClient();
309
+ const response = await client.get(`/agents/${id}/config`);
310
+ return response.data?.data ?? response.data;
311
+ }
312
+
305
313
  export async function getAgent(id) {
306
314
  const client = getNetworkClient();
307
315
  const response = await client.get(`/agents/${id}`);
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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
+ }