hub-runner 0.0.0-stage → 0.1.1
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/README.md +36 -2
- package/bin/hub-runner.mjs +65 -0
- package/lib/device.mjs +7 -0
- package/lib/launchd.mjs +85 -0
- package/lib/login.mjs +86 -0
- package/lib/paths.mjs +12 -0
- package/lib/state.mjs +29 -0
- package/main.mjs +15694 -0
- package/package.json +22 -3
package/README.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
-
#
|
|
1
|
+
# hub-runner
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Runs a hub workspace's Mac bots on this Mac. It runs under launchd, so no app has to be open.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npm i -g hub-runner --min-release-age=0
|
|
7
|
+
hub-runner login
|
|
8
|
+
hub-runner start
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`login` signs in with Vercel, asks which workspace this Mac runs bots for, and stores a session of the runner's own in `~/.hub/local/credentials.json`. The Vercel token is revoked once the hub has the session.
|
|
12
|
+
|
|
13
|
+
| Command | What it does |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `hub-runner start [--take-over]` | Starts the runner under launchd (`~/Library/LaunchAgents/dev.hub.runner.plist`). It starts again at every login and is not restarted after a crash: run `start` again. `--take-over` claims the Mac slot from another Mac. |
|
|
16
|
+
| `hub-runner stop` | Stops it. |
|
|
17
|
+
| `hub-runner restart` | Lets the open turns finish, then starts it again. |
|
|
18
|
+
| `hub-runner status` | The runner's state, pid, version, bots, and whether launchd has it loaded. Exits 1 when it isn't running. |
|
|
19
|
+
| `hub-runner logs [-f]` | The log, `~/.hub/local/runner.log`. |
|
|
20
|
+
| `hub-runner logout` | Stops it and deletes the stored session. |
|
|
21
|
+
|
|
22
|
+
## Update
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
npm i -g hub-runner@latest --min-release-age=0 && hub-runner restart
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`restart` lets the turns open now finish, so no bot is cut off.
|
|
29
|
+
|
|
30
|
+
`--min-release-age=0` lets the newest release install when your npm config holds packages back for a few days (`min-release-age`). It applies to this one install, and hub-runner has no dependencies, so nothing else skips the wait.
|
|
31
|
+
|
|
32
|
+
## Notes
|
|
33
|
+
|
|
34
|
+
- Node 24 or later, and `pnpm` on your PATH when you run `start`: the runner uses both to set the bots up.
|
|
35
|
+
- The hub is `https://hosted-hub.playground-vercel.tools`; set `HUB_URL` when you run `login` for another one.
|
|
36
|
+
- The HubHosted app only shows the runner's state. Quitting, signing out of or replacing the app doesn't touch the runner.
|
|
37
|
+
- If the runner doesn't start, `hub-runner status` and `hub-runner logs` say why.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
5
|
+
import { credentialsFile, logFile } from '../lib/paths.mjs';
|
|
6
|
+
import { kickstart, loaded, start, stop } from '../lib/launchd.mjs';
|
|
7
|
+
import { login } from '../lib/login.mjs';
|
|
8
|
+
import { alive, live, read, statuses, stillRunning } from '../lib/state.mjs';
|
|
9
|
+
|
|
10
|
+
const USAGE = `hub-runner login | start [--take-over] | stop | restart | status | logs [-f] | logout
|
|
11
|
+
|
|
12
|
+
login sign in with Vercel and choose the workspace this Mac runs bots for
|
|
13
|
+
start start the runner under launchd (it starts again at every login); --take-over claims it from another Mac
|
|
14
|
+
stop stop the runner
|
|
15
|
+
restart let the open turns finish, then start the runner again (after \`npm i -g hub-runner@latest --min-release-age=0\`)
|
|
16
|
+
status what the runner is doing; exits 1 when it isn't running
|
|
17
|
+
logs the runner's log; -f follows it
|
|
18
|
+
logout stop the runner and forget the sign-in`;
|
|
19
|
+
|
|
20
|
+
/** SIGUSR2 drains the runner (open turns finish, nothing new starts) and it exits 6; then launchd starts it again. */
|
|
21
|
+
async function restart() {
|
|
22
|
+
const runner = live();
|
|
23
|
+
if (runner) {
|
|
24
|
+
console.log(`waiting for open turns on pid ${runner.pid} to finish`);
|
|
25
|
+
process.kill(runner.pid, 'SIGUSR2');
|
|
26
|
+
while (alive(runner.pid)) await sleep(250);
|
|
27
|
+
const { exitCode } = read(runner.pid);
|
|
28
|
+
if (exitCode !== 6) throw new Error(`the runner ended during the drain with exit code ${exitCode}, not 6: see \`hub-runner logs\`, then run \`hub-runner start\``);
|
|
29
|
+
}
|
|
30
|
+
kickstart();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function status() {
|
|
34
|
+
const [latest] = statuses();
|
|
35
|
+
const running = latest && stillRunning(latest) && latest.state === 'running';
|
|
36
|
+
const state = !latest ? 'never started' : latest.exitCode === undefined && !stillRunning(latest) ? 'killed' : latest.exitCode !== undefined ? `${latest.state} (exit ${latest.exitCode})` : latest.state;
|
|
37
|
+
console.log([state, latest?.pid && `pid ${latest.pid}`, latest?.build && `version ${latest.build}`, loaded() ? 'loaded in launchd' : 'not loaded in launchd', latest?.bots?.length && `bots ${latest.bots.map((b) => b.name).join(' ')}`].filter(Boolean).join(', '));
|
|
38
|
+
if (latest?.setup) console.log(`workspace setup failed at ${latest.setup.commit}: ${latest.setup.reason}`);
|
|
39
|
+
if (latest?.holder) console.log(`held by ${latest.holder.deviceName}`);
|
|
40
|
+
process.exitCode = running ? 0 : 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function logs(follow) {
|
|
44
|
+
if (!existsSync(logFile())) throw new Error(`no log yet at ${logFile()}`);
|
|
45
|
+
if (!follow) return process.stdout.write(readFileSync(logFile()));
|
|
46
|
+
spawn('tail', ['-n', '200', '-f', logFile()], { stdio: 'inherit' }).on('exit', (code) => process.exit(code ?? 0));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const [command, ...args] = process.argv.slice(2);
|
|
50
|
+
try {
|
|
51
|
+
if (process.getuid() === 0) throw new Error('run hub-runner without sudo: the runner is a launchd job of your own user, and root has none');
|
|
52
|
+
switch (command) {
|
|
53
|
+
case 'login': await login(); break;
|
|
54
|
+
case 'start': start({ takeOver: args.includes('--take-over') }); console.log('started: see `hub-runner status`'); break;
|
|
55
|
+
case 'stop': stop(); break;
|
|
56
|
+
case 'restart': await restart(); break;
|
|
57
|
+
case 'status': status(); break;
|
|
58
|
+
case 'logs': logs(args.includes('-f')); break;
|
|
59
|
+
case 'logout': stop(); rmSync(credentialsFile(), { force: true }); break;
|
|
60
|
+
default: console.error(USAGE); process.exitCode = command ? 2 : 0;
|
|
61
|
+
}
|
|
62
|
+
} catch (e) {
|
|
63
|
+
console.error(e.message);
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
}
|
package/lib/device.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
|
|
4
|
+
/** What the hub shows for this Mac: its System Settings name, else its hostname (a Mac has the first; CI hasn't). */
|
|
5
|
+
export function deviceName() {
|
|
6
|
+
try { return execFileSync('scutil', ['--get', 'ComputerName'], { encoding: 'utf8' }).trim() || hostname(); } catch { return hostname(); }
|
|
7
|
+
}
|
package/lib/launchd.mjs
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The runner's launchd job: a plist in the user's LaunchAgents, loaded in the GUI domain, so it starts at login and
|
|
2
|
+
// outlives every app. `RunAtLoad` without `KeepAlive`: a crash waits for `hub-runner start`.
|
|
3
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
4
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { dirname } from 'node:path';
|
|
6
|
+
import { LABEL, credentialsFile, logFile, mainFile, plistFile, runnerDir, version } from './paths.mjs';
|
|
7
|
+
import { deviceName } from './device.mjs';
|
|
8
|
+
|
|
9
|
+
const xml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
10
|
+
const domain = () => `gui/${process.getuid()}`;
|
|
11
|
+
const launchctl = (args, { check = true } = {}) => {
|
|
12
|
+
const run = spawnSync('launchctl', args, { encoding: 'utf8' });
|
|
13
|
+
if (run.error) throw run.error;
|
|
14
|
+
if (check && run.status !== 0) throw new Error(`launchctl ${args.join(' ')} failed (${run.status}): ${(run.stderr || run.stdout).trim()}`);
|
|
15
|
+
return run.status === 0;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const loaded = () => launchctl(['print', `${domain()}/${LABEL}`], { check: false });
|
|
19
|
+
|
|
20
|
+
const EXIT_TIMEOUT_S = 60;
|
|
21
|
+
|
|
22
|
+
// `bootout` returns while the old runner may still be draining (up to ExitTimeOut), and a `bootstrap` before launchd
|
|
23
|
+
// drops the job fails with error 5. So wait for the job to leave the domain.
|
|
24
|
+
function bootout() {
|
|
25
|
+
launchctl(['bootout', `${domain()}/${LABEL}`]);
|
|
26
|
+
const until = Date.now() + (EXIT_TIMEOUT_S + 5) * 1000;
|
|
27
|
+
const tick = new Int32Array(new SharedArrayBuffer(4));
|
|
28
|
+
while (loaded()) {
|
|
29
|
+
if (Date.now() > until) throw new Error(`launchd still holds ${LABEL} ${EXIT_TIMEOUT_S + 5}s after bootout: see \`hub-runner logs\``);
|
|
30
|
+
Atomics.wait(tick, 0, 0, 250);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function plist({ takeOver }) {
|
|
35
|
+
const pnpm = execFileSync('/bin/sh', ['-c', 'command -v pnpm'], { encoding: 'utf8' }).trim();
|
|
36
|
+
const env = { HUB_PNPM: pnpm, PATH: process.env.PATH, HUB_RUNNER_DIR: runnerDir(), HUB_RUNNER_BUILD: version(), HUB_DEVICE_NAME: deviceName(), ...(takeOver ? { HUB_RUNNER_TAKE_OVER: '1' } : {}) };
|
|
37
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
38
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
39
|
+
<plist version="1.0">
|
|
40
|
+
<dict>
|
|
41
|
+
<key>Label</key>
|
|
42
|
+
<string>${LABEL}</string>
|
|
43
|
+
<key>ProgramArguments</key>
|
|
44
|
+
<array>
|
|
45
|
+
<string>${xml(process.execPath)}</string>
|
|
46
|
+
<string>${xml(mainFile())}</string>
|
|
47
|
+
</array>
|
|
48
|
+
<key>WorkingDirectory</key>
|
|
49
|
+
<string>${xml(runnerDir())}</string>
|
|
50
|
+
<key>EnvironmentVariables</key>
|
|
51
|
+
<dict>
|
|
52
|
+
${Object.entries(env).map(([k, v]) => ` <key>${k}</key>\n <string>${xml(v)}</string>`).join('\n')}
|
|
53
|
+
</dict>
|
|
54
|
+
<key>RunAtLoad</key>
|
|
55
|
+
<true/>
|
|
56
|
+
<key>ExitTimeOut</key>
|
|
57
|
+
<integer>${EXIT_TIMEOUT_S}</integer>
|
|
58
|
+
<key>StandardOutPath</key>
|
|
59
|
+
<string>${xml(logFile())}</string>
|
|
60
|
+
<key>StandardErrorPath</key>
|
|
61
|
+
<string>${xml(logFile())}</string>
|
|
62
|
+
</dict>
|
|
63
|
+
</plist>
|
|
64
|
+
`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function start({ takeOver = false } = {}) {
|
|
68
|
+
if (!existsSync(credentialsFile())) throw new Error('not signed in: run `hub-runner login` first');
|
|
69
|
+
mkdirSync(runnerDir(), { recursive: true });
|
|
70
|
+
mkdirSync(dirname(plistFile()), { recursive: true });
|
|
71
|
+
writeFileSync(plistFile(), plist({ takeOver }));
|
|
72
|
+
if (loaded()) bootout();
|
|
73
|
+
launchctl(['bootstrap', domain(), plistFile()]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function stop() {
|
|
77
|
+
if (loaded()) bootout();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Starts the loaded job again, or loads it from the plist the last `start` wrote. */
|
|
81
|
+
export function kickstart() {
|
|
82
|
+
if (loaded()) launchctl(['kickstart', '-k', `${domain()}/${LABEL}`]);
|
|
83
|
+
else if (existsSync(plistFile())) launchctl(['bootstrap', domain(), plistFile()]);
|
|
84
|
+
else throw new Error('not started yet: run `hub-runner start`');
|
|
85
|
+
}
|
package/lib/login.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// `hub-runner login`: Sign in with Vercel (the device flow, with the Vercel CLI's client the app uses too:
|
|
2
|
+
// native/Sources/HubCore/VercelOAuth.swift), pick the account's workspace, and have the hub store a session of the
|
|
3
|
+
// runner's own for it. The Vercel token is revoked once the hub has the session: the runner never holds it.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { chmodSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname } from 'node:path';
|
|
8
|
+
import { createInterface } from 'node:readline/promises';
|
|
9
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
10
|
+
import { DEFAULT_HUB, credentialsFile } from './paths.mjs';
|
|
11
|
+
|
|
12
|
+
const CLIENT_ID = 'cl_HYyOPBNtFMfHhaUn9L4QPfTZz6TP47bp';
|
|
13
|
+
const HUB_TEAM = 'team_TtmJZYmD3tcLBLqWOhoVawd1';
|
|
14
|
+
const api = () => (process.env.HUB_VERCEL_API || 'https://api.vercel.com').replace(/\/$/, '');
|
|
15
|
+
|
|
16
|
+
async function exchange(url, init) {
|
|
17
|
+
const res = await fetch(url, init);
|
|
18
|
+
const body = await res.json().catch(() => ({}));
|
|
19
|
+
if (res.ok) return body;
|
|
20
|
+
const error = typeof body.error === 'string' ? body.error : body.error?.code ?? `http_${res.status}`;
|
|
21
|
+
throw Object.assign(new Error(body.error_description ?? body.error?.message ?? body.error ?? `${init.method ?? 'GET'} ${new URL(url).pathname} answered ${res.status}`), { code: error, status: res.status });
|
|
22
|
+
}
|
|
23
|
+
const form = (url, fields) => exchange(url, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(fields) });
|
|
24
|
+
const json = (url, method, token, extra = {}, body) => exchange(url, { method, headers: { authorization: `Bearer ${token}`, ...extra, ...(body ? { 'content-type': 'application/json' } : {}) }, body: body && JSON.stringify(body) });
|
|
25
|
+
|
|
26
|
+
async function deviceFlow(say) {
|
|
27
|
+
const start = await form(`${api()}/login/oauth/device-authorization`, { client_id: CLIENT_ID, scope: 'openid offline_access' });
|
|
28
|
+
say(`Open ${start.verification_uri_complete} and approve the code ${start.user_code}.`);
|
|
29
|
+
if (process.platform === 'darwin') spawn('open', [start.verification_uri_complete], { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
|
|
30
|
+
let interval = start.interval ?? 5;
|
|
31
|
+
const until = Date.now() + (start.expires_in ?? 600) * 1000;
|
|
32
|
+
while (Date.now() < until) {
|
|
33
|
+
await sleep(interval * 1000);
|
|
34
|
+
try {
|
|
35
|
+
return await form(`${api()}/login/oauth/token`, { client_id: CLIENT_ID, grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: start.device_code });
|
|
36
|
+
} catch (e) {
|
|
37
|
+
if (e.code === 'authorization_pending') continue;
|
|
38
|
+
if (e.code === 'slow_down') { interval += 5; continue; }
|
|
39
|
+
throw e;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
throw new Error('the sign-in code expired: run `hub-runner login` again');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The hub is protected by Vercel; its project's automation secret, as the app reads it, lets the runner through. */
|
|
46
|
+
async function protectionBypass(hub, token) {
|
|
47
|
+
const project = new URL(hub).hostname.split('.')[0], query = `?teamId=${HUB_TEAM}`;
|
|
48
|
+
const secretOf = (j) => Object.entries(j.protectionBypass ?? {}).find(([, v]) => v.scope === 'automation-bypass')?.[0];
|
|
49
|
+
const existing = secretOf(await json(`${api()}/v9/projects/${project}${query}`, 'GET', token));
|
|
50
|
+
if (existing) return existing;
|
|
51
|
+
const created = secretOf(await json(`${api()}/v1/projects/${project}/protection-bypass${query}`, 'PATCH', token, {}, {}));
|
|
52
|
+
if (!created) throw new Error(`Vercel gave no automation secret for ${project}`);
|
|
53
|
+
return created;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function pick(workspaces, say) {
|
|
57
|
+
if (workspaces.length === 1) return workspaces[0];
|
|
58
|
+
say('Workspaces:');
|
|
59
|
+
workspaces.forEach((w, i) => say(` ${i + 1}. ${w.name}`));
|
|
60
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
61
|
+
try {
|
|
62
|
+
for (;;) {
|
|
63
|
+
const chosen = workspaces[Number(await rl.question('Which one runs on this Mac? ')) - 1];
|
|
64
|
+
if (chosen) return chosen;
|
|
65
|
+
}
|
|
66
|
+
} finally { rl.close(); }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function login({ say = console.log } = {}) {
|
|
70
|
+
const hub = (process.env.HUB_URL || DEFAULT_HUB).replace(/\/$/, '');
|
|
71
|
+
const tokens = await deviceFlow(say);
|
|
72
|
+
const bypass = await protectionBypass(hub, tokens.access_token);
|
|
73
|
+
const headers = { 'x-vercel-protection-bypass': bypass };
|
|
74
|
+
const { workspaces } = await json(`${hub}/api/workspaces`, 'GET', tokens.access_token, headers);
|
|
75
|
+
if (!workspaces.length) throw new Error('this Vercel account has no hub workspace: sign in to the Hub app once to create one');
|
|
76
|
+
const workspace = await pick(workspaces, say);
|
|
77
|
+
const session = randomBytes(32).toString('hex');
|
|
78
|
+
await json(`${hub}/api/workspaces/${encodeURIComponent(workspace.id)}/session`, 'POST', tokens.access_token, headers, { session });
|
|
79
|
+
await form(`${api()}/login/oauth/token/revoke`, { client_id: CLIENT_ID, token: tokens.refresh_token });
|
|
80
|
+
const file = credentialsFile();
|
|
81
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
82
|
+
writeFileSync(`${file}.tmp`, JSON.stringify({ hubUrl: hub, session, workspace: workspace.id, bypass }), { mode: 0o600 });
|
|
83
|
+
chmodSync(`${file}.tmp`, 0o600);
|
|
84
|
+
renameSync(`${file}.tmp`, file);
|
|
85
|
+
say(`Signed in to ${workspace.name}. Run \`hub-runner start\`.`);
|
|
86
|
+
}
|
package/lib/paths.mjs
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const LABEL = 'dev.hub.runner';
|
|
6
|
+
export const DEFAULT_HUB = 'https://hosted-hub.playground-vercel.tools';
|
|
7
|
+
export const runnerDir = () => process.env.HUB_RUNNER_DIR || join(homedir(), '.hub', 'local');
|
|
8
|
+
export const credentialsFile = () => join(runnerDir(), 'credentials.json');
|
|
9
|
+
export const logFile = () => join(runnerDir(), 'runner.log');
|
|
10
|
+
export const plistFile = () => join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
11
|
+
export const mainFile = () => new URL('../main.mjs', import.meta.url).pathname;
|
|
12
|
+
export const version = () => JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
package/lib/state.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The runner's status files (hosted-hub/runner/status.ts): status/<pid>.json in its dir, one per process.
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { runnerDir } from './paths.mjs';
|
|
6
|
+
|
|
7
|
+
export const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { if (e.code === 'EPERM') return true; if (e.code === 'ESRCH') return false; throw e; } };
|
|
8
|
+
|
|
9
|
+
/** When `pid` started, in ms; undefined when no such process. `lstart` has whole seconds. */
|
|
10
|
+
const startedAt = (pid) => {
|
|
11
|
+
try { return Date.parse(execFileSync('/bin/ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', env: { ...process.env, LC_ALL: 'C' }, stdio: ['ignore', 'pipe', 'ignore'] }).trim()) || undefined; } catch { return undefined; }
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Whether the process that wrote a status file still runs: its pid is alive and started before the file was last written (a pid reused later started after). */
|
|
15
|
+
export const stillRunning = (s) => s.exitCode === undefined && alive(s.pid) && (startedAt(s.pid) ?? Infinity) <= s.writtenAt + 1000;
|
|
16
|
+
|
|
17
|
+
/** Every status file, newest write first, with when it was written. */
|
|
18
|
+
export function statuses() {
|
|
19
|
+
const dir = join(runnerDir(), 'status');
|
|
20
|
+
let names;
|
|
21
|
+
try { names = readdirSync(dir).filter((n) => n.endsWith('.json')); } catch (e) { if (e.code === 'ENOENT') return []; throw e; }
|
|
22
|
+
return names.map((n) => join(dir, n)).map((path) => ({ path, mtime: statSync(path).mtimeMs })).sort((a, b) => b.mtime - a.mtime)
|
|
23
|
+
.map(({ path, mtime }) => ({ ...JSON.parse(readFileSync(path, 'utf8')), writtenAt: mtime }));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The runner that is running now: its status file has no exit code and its process is the one that wrote it. */
|
|
27
|
+
export const live = () => statuses().find(stillRunning);
|
|
28
|
+
|
|
29
|
+
export const read = (pid) => JSON.parse(readFileSync(join(runnerDir(), 'status', `${pid}.json`), 'utf8'));
|