@firefunc-agent/runner 0.5.1 → 0.7.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/README.md +73 -17
- package/dist/api.d.ts +5 -0
- package/dist/api.js +18 -0
- package/dist/cli.js +115 -10
- package/dist/config.d.ts +1 -0
- package/dist/config.js +7 -0
- package/dist/daemon.js +2 -2
- package/dist/engines/claude.js +6 -4
- package/dist/git-auth.d.ts +23 -0
- package/dist/git-auth.js +74 -0
- package/dist/job.d.ts +3 -0
- package/dist/job.js +95 -6
- package/dist/service.d.ts +55 -0
- package/dist/service.js +363 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,20 +21,24 @@ This is the **self-hosted runner** for FireFunc's _Claude Code Channel_ engine.
|
|
|
21
21
|
## Quick start
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
# Install
|
|
24
|
+
# 1. Install the package. Use -g, not npx — see the note below.
|
|
25
25
|
npm install -g @firefunc-agent/runner
|
|
26
26
|
|
|
27
|
-
#
|
|
28
|
-
# it already contains your one-time token (ffr_…) and your API URL
|
|
29
|
-
|
|
30
|
-
# 2. On your machine — the copied command looks like this:
|
|
31
|
-
npx @firefunc-agent/runner connect ffr_xxxxx --api https://your-firefunc-host --repos-dir ~/code
|
|
27
|
+
# 2. In FireFunc: Engines → "Add a machine". Copy the WHOLE command it shows —
|
|
28
|
+
# it already contains your one-time token (ffr_…) and your API URL:
|
|
29
|
+
firefunc-runner connect ffr_xxxxx --api https://your-firefunc-host --repos-dir ~/code
|
|
32
30
|
# (or map a repo exactly: --repo acme/api=/Users/me/code/api)
|
|
33
31
|
|
|
34
|
-
# 3. Go online:
|
|
35
|
-
|
|
32
|
+
# 3. Go online — starts at login/sign-in and survives reboot:
|
|
33
|
+
firefunc-runner install
|
|
36
34
|
```
|
|
37
35
|
|
|
36
|
+
**`npm install -g`, not `npx`.** `npx` runs the package from a throwaway cache
|
|
37
|
+
(`~/.npm/_npx/<hash>/…`) that npm is free to delete. A background service has to
|
|
38
|
+
point at a path that still exists after a reboot, so `firefunc-runner install`
|
|
39
|
+
refuses to run from there and tells you this. Everything else — `connect`,
|
|
40
|
+
`start`, `status`, `logs` — works fine under `npx` if you prefer.
|
|
41
|
+
|
|
38
42
|
`--api` is required: this package has no hard-coded FireFunc host, so it only
|
|
39
43
|
ever talks to the deployment you point it at.
|
|
40
44
|
|
|
@@ -50,11 +54,16 @@ That's it. When your QA files a bug (labeled for FireFunc), it appears here with
|
|
|
50
54
|
|
|
51
55
|
## Commands
|
|
52
56
|
|
|
53
|
-
| Command | What it does
|
|
54
|
-
| ----------------------------------------- |
|
|
55
|
-
| `firefunc-runner connect <token> [flags]` | Save your token + settings (`~/.firefunc-runner/config.json`, mode 0600)
|
|
56
|
-
| `firefunc-runner
|
|
57
|
-
| `firefunc-runner
|
|
57
|
+
| Command | What it does |
|
|
58
|
+
| ----------------------------------------- | ------------------------------------------------------------------------- |
|
|
59
|
+
| `firefunc-runner connect <token> [flags]` | Save your token + settings (`~/.firefunc-runner/config.json`, mode 0600) |
|
|
60
|
+
| `firefunc-runner install` | Install as a background service — starts at login/boot, restarts on crash |
|
|
61
|
+
| `firefunc-runner start` | Run in THIS terminal (stops when you close it) — for watching it work |
|
|
62
|
+
| `firefunc-runner stop` | Stop the background service (stays stopped) |
|
|
63
|
+
| `firefunc-runner start-service` | Start the background service again |
|
|
64
|
+
| `firefunc-runner logs` | Recent runner output |
|
|
65
|
+
| `firefunc-runner uninstall` | Remove the background service |
|
|
66
|
+
| `firefunc-runner status` | Saved config **and** whether the service is installed + running |
|
|
58
67
|
|
|
59
68
|
### `connect` flags
|
|
60
69
|
|
|
@@ -66,15 +75,62 @@ That's it. When your QA files a bug (labeled for FireFunc), it appears here with
|
|
|
66
75
|
| `--repo <owner/repo=/path>` | — | Map one repo to an exact checkout (repeatable) |
|
|
67
76
|
| `--claude-bin <bin>` | `claude` | Claude Code binary |
|
|
68
77
|
|
|
69
|
-
##
|
|
78
|
+
## Always-on
|
|
79
|
+
|
|
80
|
+
`firefunc-runner start` runs in the foreground: it dies with the terminal and does
|
|
81
|
+
**not** come back after a reboot. That matters more than it sounds, because
|
|
82
|
+
FireFunc's queue is **pull** — nothing is pushed to your machine, the runner asks
|
|
83
|
+
for work every few seconds. A runner that is not running is not a slow runner;
|
|
84
|
+
it is an absent one, and work waits for a machine that will never ask again.
|
|
85
|
+
|
|
86
|
+
So install it once:
|
|
70
87
|
|
|
71
|
-
|
|
88
|
+
```bash
|
|
89
|
+
firefunc-runner install
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- **macOS** — a launchd LaunchAgent at `~/Library/LaunchAgents/com.firefunc.runner.plist`
|
|
93
|
+
- **Linux** — a systemd `--user` unit at `~/.config/systemd/user/firefunc-runner.service`,
|
|
94
|
+
plus `loginctl enable-linger` so it starts at **boot**, not at first login
|
|
95
|
+
- **Windows** — a Task Scheduler task (`FireFuncRunner`) that starts at sign-in.
|
|
96
|
+
No WSL, no admin rights, no service wrapper.
|
|
97
|
+
|
|
98
|
+
It starts at login/boot and restarts itself if it crashes. It does **not** restart
|
|
99
|
+
after a deliberate stop, or after FireFunc revokes the runner's token — that is a
|
|
100
|
+
clean exit, not a fault, and respawning it would just log the same rejection
|
|
101
|
+
forever.
|
|
102
|
+
|
|
103
|
+
Per-user, not system-wide, on purpose: the runner uses **your** `claude` /
|
|
104
|
+
`codex` / `gemini` login, **your** checkouts and **your** SSH keys. A root daemon
|
|
105
|
+
— or a Windows _Service_, which is the same problem — would have none of those,
|
|
106
|
+
and giving it them is exactly the concentration of access FireFunc is designed to
|
|
107
|
+
avoid. That is also why Windows gets a Scheduled Task and not a Service: no admin
|
|
108
|
+
rights needed, and it runs as you.
|
|
109
|
+
|
|
110
|
+
On Windows the task overrides four Task Scheduler defaults that would otherwise
|
|
111
|
+
break it silently: it refuses to start on battery and stops when unplugged
|
|
112
|
+
(fatal on a laptop), it is killed after 3 days, and it stops when the machine
|
|
113
|
+
goes idle.
|
|
72
114
|
|
|
73
115
|
```bash
|
|
74
|
-
|
|
75
|
-
|
|
116
|
+
firefunc-runner status # installed? running?
|
|
117
|
+
firefunc-runner logs # what has it been doing
|
|
118
|
+
firefunc-runner stop # stop it; stays stopped
|
|
119
|
+
firefunc-runner uninstall # remove the service entirely
|
|
76
120
|
```
|
|
77
121
|
|
|
122
|
+
Two things the installer bakes into the unit, because a service has no shell
|
|
123
|
+
profile: your current `PATH` (or the agent CLIs are not findable and every job
|
|
124
|
+
fails with `claude: command not found`) and `HOME`/`USERPROFILE` (or they cannot
|
|
125
|
+
find their own logins). If you install a new agent CLI somewhere new, re-run
|
|
126
|
+
`install`.
|
|
127
|
+
|
|
128
|
+
> **Tested:** the macOS path is verified end to end on a real Mac — installed,
|
|
129
|
+
> killed with `kill -9`, and confirmed launchd brought it back. The Linux and
|
|
130
|
+
> Windows paths are unit-tested (unit/XML contents and every `systemctl` /
|
|
131
|
+
> `schtasks` verb) but have **not** yet been run on a real machine of either
|
|
132
|
+
> kind. Please report anything that misbehaves.
|
|
133
|
+
|
|
78
134
|
## Licence
|
|
79
135
|
|
|
80
136
|
`@firefunc-agent/runner` is the **client** half of FireFunc, published so the machines
|
package/dist/api.d.ts
CHANGED
|
@@ -59,6 +59,11 @@ export declare class RunnerApi {
|
|
|
59
59
|
config(): Promise<{
|
|
60
60
|
maxParallel: number;
|
|
61
61
|
} | null>;
|
|
62
|
+
gitCredential(runId: string): Promise<{
|
|
63
|
+
token: string;
|
|
64
|
+
expiresAt: string;
|
|
65
|
+
repo: string;
|
|
66
|
+
} | null>;
|
|
62
67
|
event(runId: string, event: RunnerEvent, detail?: Record<string, unknown>): Promise<void>;
|
|
63
68
|
result(runId: string, payload: ResultPayload): Promise<void>;
|
|
64
69
|
}
|
package/dist/api.js
CHANGED
|
@@ -56,6 +56,24 @@ export class RunnerApi {
|
|
|
56
56
|
return null;
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
+
async gitCredential(runId) {
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch(`${this.apiUrl}/channel/runs/${runId}/git-credential`, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { ...this.headers(), 'content-type': 'application/json' },
|
|
64
|
+
body: '{}',
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok)
|
|
67
|
+
return null;
|
|
68
|
+
const body = (await res.json());
|
|
69
|
+
if (!body.ok || !body.token || !body.repo)
|
|
70
|
+
return null;
|
|
71
|
+
return { token: body.token, expiresAt: body.expiresAt ?? '', repo: body.repo };
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
59
77
|
async event(runId, event, detail) {
|
|
60
78
|
await fetch(`${this.apiUrl}/channel/runs/${runId}/events`, {
|
|
61
79
|
method: 'POST',
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { loadConfig, saveConfig, configPath } from './config.js';
|
|
2
|
+
import { expandHome, loadConfig, saveConfig, configPath } from './config.js';
|
|
3
3
|
import { startDaemon } from './daemon.js';
|
|
4
4
|
import { runBrief } from './brief.js';
|
|
5
|
+
import { currentPlatform, defaultServiceDeps, EPHEMERAL_INSTALL_MESSAGE, installService, isEphemeralInstall, readLogTail, renderLaunchdPlist, renderSystemdUnit, renderWindowsTaskXml, serviceStatus, servicePaths, startService, stopService, uninstallService, } from './service.js';
|
|
5
6
|
const HELP = `firefunc-runner — run YOUR local coding agent on work FireFunc routes to you.
|
|
6
7
|
(Claude Code, OpenAI Codex, Gemini CLI or Cursor CLI — whichever you have installed.)
|
|
7
8
|
|
|
@@ -9,10 +10,21 @@ Usage:
|
|
|
9
10
|
firefunc-runner connect <token> [options] Save your runner token + settings
|
|
10
11
|
firefunc-runner repo <owner/repo> <path> Point a repo at its local checkout
|
|
11
12
|
firefunc-runner brief <owner/repo> Draft a CLAUDE.md runner briefing as a draft PR
|
|
12
|
-
firefunc-runner
|
|
13
|
-
firefunc-runner
|
|
13
|
+
firefunc-runner install Install as a background service (survives reboot)
|
|
14
|
+
firefunc-runner start Run in THIS terminal (stops when you close it)
|
|
15
|
+
firefunc-runner stop Stop the background service
|
|
16
|
+
firefunc-runner start-service Start the background service again
|
|
17
|
+
firefunc-runner logs Show recent runner output
|
|
18
|
+
firefunc-runner uninstall Remove the background service
|
|
19
|
+
firefunc-runner status Show the saved config + service state
|
|
14
20
|
firefunc-runner --help
|
|
15
21
|
|
|
22
|
+
After "connect", run "install" once. The runner then starts at login/boot and
|
|
23
|
+
restarts itself if it crashes — no terminal needs to stay open. Use "start"
|
|
24
|
+
only when you want to watch it in the foreground.
|
|
25
|
+
Works on macOS (launchd), Linux (systemd) and Windows (Task Scheduler) — no
|
|
26
|
+
admin rights on any of them.
|
|
27
|
+
|
|
16
28
|
connect options:
|
|
17
29
|
--api <url> FireFunc API URL — REQUIRED. Copy the whole connect
|
|
18
30
|
command from the Engines screen; it fills this in.
|
|
@@ -23,6 +35,10 @@ connect options:
|
|
|
23
35
|
--claude-oauth-token <tok> Subscription token from "claude setup-token". Usually NOT
|
|
24
36
|
needed — the runner already uses your machine's "claude" login.
|
|
25
37
|
Set only if this machine has no interactive Claude login.
|
|
38
|
+
--force Replace an EXISTING connection on this machine. Without it,
|
|
39
|
+
connecting with a different token is refused: a runner holds
|
|
40
|
+
one token and serves one scope, and replacing it silently
|
|
41
|
+
took the machine off its old project.
|
|
26
42
|
--use-api-key Bill pay-as-you-go API credits via ANTHROPIC_API_KEY instead of
|
|
27
43
|
your subscription. OFF by default — the runner hides any
|
|
28
44
|
ANTHROPIC_API_KEY from claude so it can't silently charge the API.
|
|
@@ -64,7 +80,7 @@ Examples:
|
|
|
64
80
|
firefunc-runner connect ffr_xxx --repos-dir ~/code
|
|
65
81
|
firefunc-runner connect ffr_xxx --repo acme/api=/Users/me/code/api
|
|
66
82
|
firefunc-runner connect ffr_xxx --env AWS_PROFILE=app-ro --setup "npm ci" --bg "npm run dev"
|
|
67
|
-
firefunc-runner
|
|
83
|
+
firefunc-runner install
|
|
68
84
|
`;
|
|
69
85
|
function parseFlags(args) {
|
|
70
86
|
const out = {};
|
|
@@ -105,7 +121,7 @@ async function main() {
|
|
|
105
121
|
for (const r of f.repo ?? []) {
|
|
106
122
|
const [k, v] = r.split('=');
|
|
107
123
|
if (k && v)
|
|
108
|
-
repos[k] = v;
|
|
124
|
+
repos[k] = expandHome(v);
|
|
109
125
|
}
|
|
110
126
|
const claudeEnv = {};
|
|
111
127
|
for (const e of f.env ?? []) {
|
|
@@ -113,11 +129,24 @@ async function main() {
|
|
|
113
129
|
if (i > 0)
|
|
114
130
|
claudeEnv[e.slice(0, i)] = e.slice(i + 1);
|
|
115
131
|
}
|
|
116
|
-
const
|
|
132
|
+
const existing = loadConfig();
|
|
133
|
+
if (existing && existing.token !== token && !f.force) {
|
|
134
|
+
process.stderr.write(`This machine is already connected to FireFunc with a different runner token.\n\n` +
|
|
135
|
+
`Connecting again would REPLACE that token, so this machine would stop\n` +
|
|
136
|
+
`taking work for whatever project the old one served — and would discard\n` +
|
|
137
|
+
`its saved settings (repos, engine, env, setup and background commands).\n\n` +
|
|
138
|
+
`A runner holds ONE token and serves ONE scope. To serve another project,\n` +
|
|
139
|
+
`connect a different machine — or widen this one's scope in FireFunc.\n\n` +
|
|
140
|
+
`If replacing it is genuinely what you want:\n` +
|
|
141
|
+
` firefunc-runner connect <token> --api <url> --force\n`);
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const flagged = {
|
|
117
146
|
apiUrl,
|
|
118
147
|
token,
|
|
119
148
|
name: f.name?.[0],
|
|
120
|
-
reposDir: f['repos-dir']?.[0],
|
|
149
|
+
reposDir: f['repos-dir']?.[0] ? expandHome(f['repos-dir'][0]) : undefined,
|
|
121
150
|
repos: Object.keys(repos).length ? repos : undefined,
|
|
122
151
|
claudeBin: f['claude-bin']?.[0],
|
|
123
152
|
quiet: f.quiet ? true : undefined,
|
|
@@ -140,11 +169,17 @@ async function main() {
|
|
|
140
169
|
setupCommands: f.setup,
|
|
141
170
|
autoInstall: f['no-auto-install'] ? false : undefined,
|
|
142
171
|
setupTimeoutMinutes: f['setup-timeout']?.[0] ? Number(f['setup-timeout'][0]) : undefined,
|
|
143
|
-
envFile: f['env-file']?.[0],
|
|
172
|
+
envFile: f['env-file']?.[0] ? expandHome(f['env-file'][0]) : undefined,
|
|
144
173
|
backgroundCommands: f.bg,
|
|
145
174
|
};
|
|
175
|
+
const provided = Object.fromEntries(Object.entries(flagged).filter(([, v]) => v !== undefined));
|
|
176
|
+
const cfg = { ...(existing ?? {}), ...provided, apiUrl, token };
|
|
146
177
|
saveConfig(cfg);
|
|
147
|
-
process.stdout.write(`Saved to ${configPath()}.\
|
|
178
|
+
process.stdout.write(`Saved to ${configPath()}.\n` +
|
|
179
|
+
(existing
|
|
180
|
+
? 'Kept the settings you did not pass again (repos, engine, env, setup).\n'
|
|
181
|
+
: '') +
|
|
182
|
+
`Next: firefunc-runner install (background service — survives reboot)\n`);
|
|
148
183
|
return;
|
|
149
184
|
}
|
|
150
185
|
if (cmd === 'repo') {
|
|
@@ -163,7 +198,7 @@ async function main() {
|
|
|
163
198
|
process.exitCode = 1;
|
|
164
199
|
return;
|
|
165
200
|
}
|
|
166
|
-
cfg.repos = { ...(cfg.repos ?? {}), [name]: localPath };
|
|
201
|
+
cfg.repos = { ...(cfg.repos ?? {}), [name]: expandHome(localPath) };
|
|
167
202
|
saveConfig(cfg);
|
|
168
203
|
process.stdout.write(`Mapped ${name} → ${localPath}\nRestart the runner to apply: firefunc-runner start\n`);
|
|
169
204
|
return;
|
|
@@ -212,6 +247,19 @@ async function main() {
|
|
|
212
247
|
` autoInstall:${cfg.autoInstall === false ? ' off' : ' on (when no setup)'}\n` +
|
|
213
248
|
` background:${cfg.backgroundCommands?.length ? ' ' + cfg.backgroundCommands.join(' ; ') : ' (none)'}\n` +
|
|
214
249
|
` envFile: ${cfg.envFile ?? '(none)'}\n`);
|
|
250
|
+
const os = currentPlatform();
|
|
251
|
+
const deps = defaultServiceDeps('', os);
|
|
252
|
+
const svc = await serviceStatus(deps);
|
|
253
|
+
process.stdout.write(!os
|
|
254
|
+
? `\nservice: not supported on this platform — keep "firefunc-runner start" running\n`
|
|
255
|
+
: !svc.installed
|
|
256
|
+
? `\nservice: NOT INSTALLED — the runner stops when you close the terminal\n` +
|
|
257
|
+
` and does not come back after a reboot.\n` +
|
|
258
|
+
` Fix: firefunc-runner install\n`
|
|
259
|
+
: `\nservice: installed, ${svc.running ? 'RUNNING' : 'STOPPED'}\n` +
|
|
260
|
+
` unit: ${svc.unit}\n` +
|
|
261
|
+
` logs: ${svc.log} (firefunc-runner logs)\n` +
|
|
262
|
+
(svc.running ? '' : ` Start it: firefunc-runner start-service\n`));
|
|
215
263
|
return;
|
|
216
264
|
}
|
|
217
265
|
if (cmd === 'start') {
|
|
@@ -221,9 +269,66 @@ async function main() {
|
|
|
221
269
|
process.exitCode = 1;
|
|
222
270
|
return;
|
|
223
271
|
}
|
|
272
|
+
if (!process.env.FIREFUNC_RUNNER_SERVICE) {
|
|
273
|
+
process.stdout.write('Running in this terminal — it stops when you close the window and does\n' +
|
|
274
|
+
'not restart after a reboot. For an always-on runner: firefunc-runner install\n\n');
|
|
275
|
+
}
|
|
224
276
|
await startDaemon(cfg);
|
|
225
277
|
return;
|
|
226
278
|
}
|
|
279
|
+
if (cmd === 'install' ||
|
|
280
|
+
cmd === 'uninstall' ||
|
|
281
|
+
cmd === 'stop' ||
|
|
282
|
+
cmd === 'start-service' ||
|
|
283
|
+
cmd === 'logs') {
|
|
284
|
+
const os = currentPlatform();
|
|
285
|
+
const paths = servicePaths(os ?? 'linux');
|
|
286
|
+
if (cmd === 'logs') {
|
|
287
|
+
process.stdout.write(`${readLogTail(paths.log)}\n`);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const unitInput = {
|
|
291
|
+
nodePath: process.execPath,
|
|
292
|
+
scriptPath: process.argv[1] ?? '',
|
|
293
|
+
pathEnv: process.env.PATH ?? '',
|
|
294
|
+
logPath: paths.log,
|
|
295
|
+
home: process.env.HOME ?? process.env.USERPROFILE ?? '',
|
|
296
|
+
};
|
|
297
|
+
const unitBody = os === 'darwin'
|
|
298
|
+
? renderLaunchdPlist(unitInput)
|
|
299
|
+
: os === 'win32'
|
|
300
|
+
? renderWindowsTaskXml({
|
|
301
|
+
...unitInput,
|
|
302
|
+
userId: process.env.USERDOMAIN
|
|
303
|
+
? `${process.env.USERDOMAIN}\\${process.env.USERNAME ?? ''}`
|
|
304
|
+
: (process.env.USERNAME ?? ''),
|
|
305
|
+
})
|
|
306
|
+
: renderSystemdUnit(unitInput);
|
|
307
|
+
const deps = defaultServiceDeps(unitBody, os);
|
|
308
|
+
if (cmd === 'install') {
|
|
309
|
+
if (!loadConfig()) {
|
|
310
|
+
process.stderr.write('Not connected. Run "firefunc-runner connect <token>" first.\n');
|
|
311
|
+
process.exitCode = 1;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (isEphemeralInstall(process.argv[1] ?? '')) {
|
|
315
|
+
process.stderr.write(`${EPHEMERAL_INSTALL_MESSAGE}\n`);
|
|
316
|
+
process.exitCode = 1;
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const res = cmd === 'install'
|
|
321
|
+
? await installService(deps)
|
|
322
|
+
: cmd === 'uninstall'
|
|
323
|
+
? await uninstallService(deps)
|
|
324
|
+
: cmd === 'stop'
|
|
325
|
+
? await stopService(deps)
|
|
326
|
+
: await startService(deps);
|
|
327
|
+
process.stdout.write(`${res.message}\n`);
|
|
328
|
+
if (!res.ok)
|
|
329
|
+
process.exitCode = 1;
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
227
332
|
process.stderr.write(`Unknown command "${cmd}".\n\n${HELP}`);
|
|
228
333
|
process.exitCode = 1;
|
|
229
334
|
}
|
package/dist/config.d.ts
CHANGED
package/dist/config.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
|
+
export function expandHome(p) {
|
|
5
|
+
if (p === '~')
|
|
6
|
+
return homedir();
|
|
7
|
+
if (p.startsWith('~/') || p.startsWith('~\\'))
|
|
8
|
+
return join(homedir(), p.slice(2));
|
|
9
|
+
return p;
|
|
10
|
+
}
|
|
4
11
|
export function configPath() {
|
|
5
12
|
return process.env.FIREFUNC_RUNNER_CONFIG ?? join(homedir(), '.firefunc-runner', 'config.json');
|
|
6
13
|
}
|
package/dist/daemon.js
CHANGED
|
@@ -115,8 +115,8 @@ export async function startDaemon(cfg) {
|
|
|
115
115
|
onClaimError: async (err) => {
|
|
116
116
|
const msg = err.message;
|
|
117
117
|
if (/token rejected/.test(msg)) {
|
|
118
|
-
log(`
|
|
119
|
-
process.exitCode =
|
|
118
|
+
log(`STOPPING: ${msg}. Re-run "firefunc-runner connect <token>".`);
|
|
119
|
+
process.exitCode = 0;
|
|
120
120
|
return 'fatal';
|
|
121
121
|
}
|
|
122
122
|
log(`poll error: ${msg} — retrying in ${Math.round(backoffMs / 1000)}s`);
|
package/dist/engines/claude.js
CHANGED
|
@@ -98,10 +98,12 @@ export const claudeAdapter = {
|
|
|
98
98
|
if (t.includes('invalid x-api-key') ||
|
|
99
99
|
t.includes('invalid api key') ||
|
|
100
100
|
t.includes('authentication_error') ||
|
|
101
|
-
t
|
|
102
|
-
t.includes('
|
|
103
|
-
t.includes('please run /login')
|
|
104
|
-
|
|
101
|
+
/oauth\b.*\btoken\b.*\bexpired/.test(t) ||
|
|
102
|
+
t.includes('failed to authenticate') ||
|
|
103
|
+
t.includes('please run /login') ||
|
|
104
|
+
t.includes('not logged in') ||
|
|
105
|
+
/\b401\b/.test(t)) {
|
|
106
|
+
return 'Claude Code could not authenticate — its login has expired or is invalid. On the runner machine run `claude` and sign in (or `claude setup-token` and pass `--claude-oauth-token`); clear a stale ANTHROPIC_API_KEY if you set one. Then retry this item.';
|
|
105
107
|
}
|
|
106
108
|
return null;
|
|
107
109
|
},
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ExecResult } from './exec.js';
|
|
2
|
+
export type GitCredential = {
|
|
3
|
+
token: string;
|
|
4
|
+
expiresAt: string;
|
|
5
|
+
repo: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function redact(text: string, token?: string): string;
|
|
8
|
+
export declare function gitAuthEnv(token: string, repo: string): NodeJS.ProcessEnv;
|
|
9
|
+
export declare function cloneRepo(repo: string, dest: string, cred: GitCredential): Promise<ExecResult>;
|
|
10
|
+
export declare function pushBranch(worktree: string, branch: string, repo: string, cred: GitCredential): Promise<ExecResult>;
|
|
11
|
+
export declare function openDraftPr(args: {
|
|
12
|
+
repo: string;
|
|
13
|
+
head: string;
|
|
14
|
+
base: string;
|
|
15
|
+
title: string;
|
|
16
|
+
body: string;
|
|
17
|
+
cred: GitCredential;
|
|
18
|
+
}): Promise<{
|
|
19
|
+
url: string;
|
|
20
|
+
number: number;
|
|
21
|
+
} | {
|
|
22
|
+
error: string;
|
|
23
|
+
}>;
|
package/dist/git-auth.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { exec } from './exec.js';
|
|
2
|
+
export function redact(text, token) {
|
|
3
|
+
let out = text;
|
|
4
|
+
if (token)
|
|
5
|
+
out = out.split(token).join('***');
|
|
6
|
+
return out
|
|
7
|
+
.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, '***')
|
|
8
|
+
.replace(/\bghs_[A-Za-z0-9]{20,}\b/g, '***')
|
|
9
|
+
.replace(/(Authorization:\s*(?:Basic|Bearer)\s+)\S+/gi, '$1***')
|
|
10
|
+
.replace(/(https:\/\/)[^@\s/]+:[^@\s/]+@/g, '$1***@');
|
|
11
|
+
}
|
|
12
|
+
export function gitAuthEnv(token, repo) {
|
|
13
|
+
const basic = Buffer.from(`x-access-token:${token}`).toString('base64');
|
|
14
|
+
return {
|
|
15
|
+
GIT_CONFIG_COUNT: '3',
|
|
16
|
+
GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader',
|
|
17
|
+
GIT_CONFIG_VALUE_0: `Authorization: Basic ${basic}`,
|
|
18
|
+
GIT_CONFIG_KEY_1: 'credential.helper',
|
|
19
|
+
GIT_CONFIG_VALUE_1: '',
|
|
20
|
+
GIT_CONFIG_KEY_2: 'remote.origin.pushurl',
|
|
21
|
+
GIT_CONFIG_VALUE_2: `https://github.com/${repo}.git`,
|
|
22
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
23
|
+
GIT_ASKPASS: undefined,
|
|
24
|
+
SSH_ASKPASS: undefined,
|
|
25
|
+
GIT_TRACE: undefined,
|
|
26
|
+
GIT_TRACE_CURL: undefined,
|
|
27
|
+
GIT_TRACE_PACKET: undefined,
|
|
28
|
+
GIT_CURL_VERBOSE: undefined,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export async function cloneRepo(repo, dest, cred) {
|
|
32
|
+
const res = await exec('git', ['clone', '--no-tags', `https://github.com/${repo}.git`, dest], {
|
|
33
|
+
timeoutMs: 600_000,
|
|
34
|
+
env: gitAuthEnv(cred.token, repo),
|
|
35
|
+
});
|
|
36
|
+
return { ...res, stderr: redact(res.stderr, cred.token), stdout: redact(res.stdout, cred.token) };
|
|
37
|
+
}
|
|
38
|
+
export async function pushBranch(worktree, branch, repo, cred) {
|
|
39
|
+
const res = await exec('git', ['-C', worktree, 'push', '--force-with-lease', 'origin', `${branch}:${branch}`], { timeoutMs: 120_000, env: gitAuthEnv(cred.token, repo) });
|
|
40
|
+
return { ...res, stderr: redact(res.stderr, cred.token), stdout: redact(res.stdout, cred.token) };
|
|
41
|
+
}
|
|
42
|
+
export async function openDraftPr(args) {
|
|
43
|
+
const { repo, head, base, title, body, cred } = args;
|
|
44
|
+
const headers = {
|
|
45
|
+
authorization: `Bearer ${cred.token}`,
|
|
46
|
+
accept: 'application/vnd.github+json',
|
|
47
|
+
'x-github-api-version': '2022-11-28',
|
|
48
|
+
'content-type': 'application/json',
|
|
49
|
+
'user-agent': 'firefunc-runner',
|
|
50
|
+
};
|
|
51
|
+
const res = await fetch(`https://api.github.com/repos/${repo}/pulls`, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers,
|
|
54
|
+
body: JSON.stringify({ title, head, base, body, draft: true }),
|
|
55
|
+
});
|
|
56
|
+
if (res.ok) {
|
|
57
|
+
const j = (await res.json());
|
|
58
|
+
if (typeof j.html_url === 'string' && Number.isFinite(j.number))
|
|
59
|
+
return { url: j.html_url, number: Number(j.number) };
|
|
60
|
+
return { error: 'pr created but the response carried no url/number' };
|
|
61
|
+
}
|
|
62
|
+
if (res.status === 422) {
|
|
63
|
+
const owner = repo.split('/')[0];
|
|
64
|
+
const list = await fetch(`https://api.github.com/repos/${repo}/pulls?head=${encodeURIComponent(`${owner}:${head}`)}&state=open`, { headers });
|
|
65
|
+
if (list.ok) {
|
|
66
|
+
const rows = (await list.json());
|
|
67
|
+
const hit = rows.find((r) => typeof r.html_url === 'string' && Number.isFinite(r.number));
|
|
68
|
+
if (hit)
|
|
69
|
+
return { url: hit.html_url, number: Number(hit.number) };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { error: `github refused to open the pull request (${res.status})` };
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=git-auth.js.map
|
package/dist/job.d.ts
CHANGED
|
@@ -2,6 +2,9 @@ import type { RunnerApi, ClaimedJob } from './api.js';
|
|
|
2
2
|
import { type RunnerConfig } from './config.js';
|
|
3
3
|
import type { SessionViewer } from './viewer.js';
|
|
4
4
|
export declare function resolveRepoPath(repo: string | null | undefined, cfg: RunnerConfig): string | null;
|
|
5
|
+
export declare function cloneDestination(repo: string, cfg: RunnerConfig): string | null;
|
|
6
|
+
export declare function rememberRepoPath(repo: string, path: string, cfg: RunnerConfig): void;
|
|
7
|
+
export declare function hasGh(cwd: string): Promise<boolean>;
|
|
5
8
|
export declare function runJob(job: ClaimedJob, cfg: RunnerConfig, api: RunnerApi, viewer?: SessionViewer, opts?: {
|
|
6
9
|
devServerPort?: number;
|
|
7
10
|
}): Promise<void>;
|
package/dist/job.js
CHANGED
|
@@ -2,7 +2,8 @@ import { mkdtempSync, rmSync, existsSync, mkdirSync, writeFileSync, copyFileSync
|
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join, basename, dirname } from 'node:path';
|
|
4
4
|
import { exec, execOut, spawnBackground, killProcessTree } from './exec.js';
|
|
5
|
-
import { configPath } from './config.js';
|
|
5
|
+
import { configPath, loadConfig, saveConfig } from './config.js';
|
|
6
|
+
import { cloneRepo, openDraftPr, pushBranch, redact } from './git-auth.js';
|
|
6
7
|
import { createKeyedMutex } from './locks.js';
|
|
7
8
|
import { getAdapter, stripForeignEngineCreds } from './engines/index.js';
|
|
8
9
|
function log(msg) {
|
|
@@ -52,6 +53,40 @@ export function resolveRepoPath(repo, cfg) {
|
|
|
52
53
|
}
|
|
53
54
|
return null;
|
|
54
55
|
}
|
|
56
|
+
export function cloneDestination(repo, cfg) {
|
|
57
|
+
const name = basename(repo);
|
|
58
|
+
if (!name)
|
|
59
|
+
return null;
|
|
60
|
+
const root = cfg.reposDir ?? join(dirname(configPath()), 'repos');
|
|
61
|
+
try {
|
|
62
|
+
mkdirSync(root, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return join(root, name);
|
|
68
|
+
}
|
|
69
|
+
export function rememberRepoPath(repo, path, cfg) {
|
|
70
|
+
try {
|
|
71
|
+
const onDisk = loadConfig();
|
|
72
|
+
if (!onDisk)
|
|
73
|
+
return;
|
|
74
|
+
onDisk.repos = { ...(onDisk.repos ?? {}), [repo]: path };
|
|
75
|
+
saveConfig(onDisk);
|
|
76
|
+
cfg.repos = { ...(cfg.repos ?? {}), [repo]: path };
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function hasGh(cwd) {
|
|
82
|
+
try {
|
|
83
|
+
const res = await exec('gh', ['auth', 'status'], { cwd, timeoutMs: 15_000 });
|
|
84
|
+
return res.code === 0;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
55
90
|
function installCmdFor(dir) {
|
|
56
91
|
if (existsSync(join(dir, 'pnpm-lock.yaml')))
|
|
57
92
|
return 'pnpm install --frozen-lockfile';
|
|
@@ -96,12 +131,33 @@ export async function runJob(job, cfg, api, viewer, opts) {
|
|
|
96
131
|
log(`job ${job.runId} FAILED — ${error.replace(/\s+/g, ' ').slice(0, 300)}`);
|
|
97
132
|
return report({ status: 'failed', error });
|
|
98
133
|
};
|
|
99
|
-
|
|
134
|
+
let repoPath = resolveRepoPath(job.repo, cfg);
|
|
135
|
+
if (!repoPath && job.repo) {
|
|
136
|
+
const cred = await api.gitCredential(job.runId);
|
|
137
|
+
const dest = cloneDestination(job.repo, cfg);
|
|
138
|
+
if (cred && dest) {
|
|
139
|
+
log(`no local checkout for ${job.repo} — cloning into ${dest}`);
|
|
140
|
+
await api.event(job.runId, 'cloning', { repo: job.repo });
|
|
141
|
+
const cloned = await cloneRepo(job.repo, dest, cred);
|
|
142
|
+
if (cloned.code === 0) {
|
|
143
|
+
repoPath = dest;
|
|
144
|
+
rememberRepoPath(job.repo, dest, cfg);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
return report({
|
|
148
|
+
status: 'failed',
|
|
149
|
+
error: `Could not clone ${job.repo}: ${redact(cloned.stderr, cred.token).slice(0, 300)}`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
100
154
|
if (!repoPath) {
|
|
101
|
-
log(`no local checkout for ${job.repo}
|
|
155
|
+
log(`no local checkout for ${job.repo} and no credential to clone with`);
|
|
102
156
|
return report({
|
|
103
157
|
status: 'failed',
|
|
104
|
-
error: `Runner has no local checkout
|
|
158
|
+
error: `Runner has no local checkout for ${job.repo} and could not obtain a ` +
|
|
159
|
+
`credential to clone it. Either map it with \`firefunc-runner repo ` +
|
|
160
|
+
`${job.repo} <path>\`, set --repos-dir, or connect GitHub in FireFunc.`,
|
|
105
161
|
});
|
|
106
162
|
}
|
|
107
163
|
const adapter = getAdapter(job.engine ?? cfg.engine);
|
|
@@ -310,11 +366,44 @@ export async function runJob(job, cfg, api, viewer, opts) {
|
|
|
310
366
|
log(`agent already committed its changes for ${job.runId} — reconciling to a PR`);
|
|
311
367
|
}
|
|
312
368
|
await api.event(job.runId, 'pushing', { branch });
|
|
313
|
-
const
|
|
369
|
+
const gitCred = job.repo ? await api.gitCredential(job.runId) : null;
|
|
370
|
+
if (gitCred)
|
|
371
|
+
log(`using a FireFunc-issued git credential for ${gitCred.repo}`);
|
|
372
|
+
const push = gitCred
|
|
373
|
+
? await pushBranch(worktree, branch, gitCred.repo, gitCred)
|
|
374
|
+
: await exec('git', ['-C', worktree, 'push', '--force-with-lease', 'origin', `${branch}:${branch}`], { timeoutMs: 120_000 });
|
|
314
375
|
if (push.code !== 0) {
|
|
315
|
-
return fail(`git push failed: ${push.stderr.slice(0, 400)}`);
|
|
376
|
+
return fail(`git push failed: ${redact(push.stderr, gitCred?.token).slice(0, 400)}`);
|
|
316
377
|
}
|
|
317
378
|
const body = `Automated fix by FireFunc (self-hosted ${engineName} runner) for ${job.externalId ?? 'a reported bug'}.\n\nReview before merging.`;
|
|
379
|
+
if (gitCred) {
|
|
380
|
+
const pr = await openDraftPr({
|
|
381
|
+
repo: gitCred.repo,
|
|
382
|
+
head: branch,
|
|
383
|
+
base,
|
|
384
|
+
title,
|
|
385
|
+
body,
|
|
386
|
+
cred: gitCred,
|
|
387
|
+
});
|
|
388
|
+
if (!('error' in pr)) {
|
|
389
|
+
await api.event(job.runId, 'pr_opened', { url: pr.url, branch });
|
|
390
|
+
log(`opened PR for ${job.runId}: ${pr.url}`);
|
|
391
|
+
return report({
|
|
392
|
+
status: 'pr_opened',
|
|
393
|
+
pr: {
|
|
394
|
+
repo: job.repo ?? undefined,
|
|
395
|
+
number: pr.number,
|
|
396
|
+
url: pr.url,
|
|
397
|
+
branch,
|
|
398
|
+
summary: title,
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
const why = redact(pr.error, gitCred.token);
|
|
403
|
+
if (!(await hasGh(worktree)))
|
|
404
|
+
return fail(why);
|
|
405
|
+
log(`could not open the PR as FireFunc (${why}); falling back to gh`);
|
|
406
|
+
}
|
|
318
407
|
const create = await exec('gh', [
|
|
319
408
|
'pr',
|
|
320
409
|
'create',
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export type ServicePlatform = 'darwin' | 'linux' | 'win32';
|
|
2
|
+
export declare const SERVICE_LABEL = "com.firefunc.runner";
|
|
3
|
+
export declare const WINDOWS_TASK_NAME = "FireFuncRunner";
|
|
4
|
+
export type ServicePaths = {
|
|
5
|
+
unit: string;
|
|
6
|
+
log: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function servicePaths(os: ServicePlatform, home?: string): ServicePaths;
|
|
9
|
+
export type UnitInput = {
|
|
10
|
+
nodePath: string;
|
|
11
|
+
scriptPath: string;
|
|
12
|
+
pathEnv: string;
|
|
13
|
+
logPath: string;
|
|
14
|
+
home: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function renderLaunchdPlist(i: UnitInput): string;
|
|
17
|
+
export declare function renderSystemdUnit(i: UnitInput): string;
|
|
18
|
+
export declare function renderWindowsTaskXml(i: UnitInput & {
|
|
19
|
+
userId: string;
|
|
20
|
+
}): string;
|
|
21
|
+
export declare function isEphemeralInstall(scriptPath: string): boolean;
|
|
22
|
+
export declare const EPHEMERAL_INSTALL_MESSAGE: string;
|
|
23
|
+
export declare function currentPlatform(): ServicePlatform | null;
|
|
24
|
+
export type ServiceResult = {
|
|
25
|
+
ok: boolean;
|
|
26
|
+
message: string;
|
|
27
|
+
};
|
|
28
|
+
export type ServiceDeps = {
|
|
29
|
+
os: ServicePlatform | null;
|
|
30
|
+
paths: ServicePaths;
|
|
31
|
+
unitBody: string;
|
|
32
|
+
exec: (cmd: string, args: string[]) => Promise<{
|
|
33
|
+
code: number;
|
|
34
|
+
out: string;
|
|
35
|
+
}>;
|
|
36
|
+
ensureDir: (path: string) => void;
|
|
37
|
+
writeFile: (path: string, body: string, encoding: BufferEncoding) => void;
|
|
38
|
+
unitEncoding: BufferEncoding;
|
|
39
|
+
removeFile: (path: string) => void;
|
|
40
|
+
fileExists: (path: string) => boolean;
|
|
41
|
+
};
|
|
42
|
+
export declare function defaultServiceDeps(unitBody: string, os?: ServicePlatform | null): ServiceDeps;
|
|
43
|
+
export declare function installService(d: ServiceDeps): Promise<ServiceResult>;
|
|
44
|
+
export declare function uninstallService(d: ServiceDeps): Promise<ServiceResult>;
|
|
45
|
+
export declare function stopService(d: ServiceDeps): Promise<ServiceResult>;
|
|
46
|
+
export declare function startService(d: ServiceDeps): Promise<ServiceResult>;
|
|
47
|
+
export type ServiceStatus = {
|
|
48
|
+
installed: boolean;
|
|
49
|
+
running: boolean;
|
|
50
|
+
unit: string;
|
|
51
|
+
log: string;
|
|
52
|
+
detail?: string;
|
|
53
|
+
};
|
|
54
|
+
export declare function serviceStatus(d: ServiceDeps): Promise<ServiceStatus>;
|
|
55
|
+
export declare function readLogTail(logPath: string, lines?: number): string;
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir, platform } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
const run = promisify(execFile);
|
|
7
|
+
export const SERVICE_LABEL = 'com.firefunc.runner';
|
|
8
|
+
export const WINDOWS_TASK_NAME = 'FireFuncRunner';
|
|
9
|
+
export function servicePaths(os, home = homedir()) {
|
|
10
|
+
const log = join(home, '.firefunc-runner', 'runner.log');
|
|
11
|
+
if (os === 'darwin') {
|
|
12
|
+
return { unit: join(home, 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`), log };
|
|
13
|
+
}
|
|
14
|
+
if (os === 'win32') {
|
|
15
|
+
return { unit: join(home, '.firefunc-runner', 'firefunc-runner-task.xml'), log };
|
|
16
|
+
}
|
|
17
|
+
return { unit: join(home, '.config', 'systemd', 'user', 'firefunc-runner.service'), log };
|
|
18
|
+
}
|
|
19
|
+
function xmlEscape(s) {
|
|
20
|
+
return s
|
|
21
|
+
.replace(/&/g, '&')
|
|
22
|
+
.replace(/</g, '<')
|
|
23
|
+
.replace(/>/g, '>')
|
|
24
|
+
.replace(/"/g, '"');
|
|
25
|
+
}
|
|
26
|
+
export function renderLaunchdPlist(i) {
|
|
27
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
28
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
29
|
+
<plist version="1.0">
|
|
30
|
+
<dict>
|
|
31
|
+
<key>Label</key>
|
|
32
|
+
<string>${SERVICE_LABEL}</string>
|
|
33
|
+
<key>ProgramArguments</key>
|
|
34
|
+
<array>
|
|
35
|
+
<string>${xmlEscape(i.nodePath)}</string>
|
|
36
|
+
<string>${xmlEscape(i.scriptPath)}</string>
|
|
37
|
+
<string>start</string>
|
|
38
|
+
</array>
|
|
39
|
+
<key>EnvironmentVariables</key>
|
|
40
|
+
<dict>
|
|
41
|
+
<key>PATH</key>
|
|
42
|
+
<string>${xmlEscape(i.pathEnv)}</string>
|
|
43
|
+
<key>HOME</key>
|
|
44
|
+
<string>${xmlEscape(i.home)}</string>
|
|
45
|
+
<key>FIREFUNC_RUNNER_SERVICE</key>
|
|
46
|
+
<string>1</string>
|
|
47
|
+
</dict>
|
|
48
|
+
<key>RunAtLoad</key>
|
|
49
|
+
<true/>
|
|
50
|
+
<key>KeepAlive</key>
|
|
51
|
+
<dict>
|
|
52
|
+
<key>SuccessfulExit</key>
|
|
53
|
+
<false/>
|
|
54
|
+
</dict>
|
|
55
|
+
<key>ThrottleInterval</key>
|
|
56
|
+
<integer>30</integer>
|
|
57
|
+
<key>StandardOutPath</key>
|
|
58
|
+
<string>${xmlEscape(i.logPath)}</string>
|
|
59
|
+
<key>StandardErrorPath</key>
|
|
60
|
+
<string>${xmlEscape(i.logPath)}</string>
|
|
61
|
+
</dict>
|
|
62
|
+
</plist>
|
|
63
|
+
`;
|
|
64
|
+
}
|
|
65
|
+
export function renderSystemdUnit(i) {
|
|
66
|
+
return `[Unit]
|
|
67
|
+
Description=FireFunc runner — runs your local coding agent on work FireFunc routes to you
|
|
68
|
+
After=network-online.target
|
|
69
|
+
Wants=network-online.target
|
|
70
|
+
|
|
71
|
+
[Service]
|
|
72
|
+
Type=simple
|
|
73
|
+
ExecStart=${i.nodePath} ${i.scriptPath} start
|
|
74
|
+
Environment=PATH=${i.pathEnv}
|
|
75
|
+
Environment=HOME=${i.home}
|
|
76
|
+
Environment=FIREFUNC_RUNNER_SERVICE=1
|
|
77
|
+
Restart=on-failure
|
|
78
|
+
RestartSec=30
|
|
79
|
+
StandardOutput=append:${i.logPath}
|
|
80
|
+
StandardError=append:${i.logPath}
|
|
81
|
+
|
|
82
|
+
[Install]
|
|
83
|
+
WantedBy=default.target
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
86
|
+
export function renderWindowsTaskXml(i) {
|
|
87
|
+
const command = [
|
|
88
|
+
`set "PATH=${i.pathEnv}"`,
|
|
89
|
+
`set "FIREFUNC_RUNNER_SERVICE=1"`,
|
|
90
|
+
`"${i.nodePath}" "${i.scriptPath}" start >> "${i.logPath}" 2>&1`,
|
|
91
|
+
].join(' && ');
|
|
92
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
93
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
94
|
+
<RegistrationInfo>
|
|
95
|
+
<Description>FireFunc runner - runs your local coding agent on work FireFunc routes to you</Description>
|
|
96
|
+
<URI>\\${WINDOWS_TASK_NAME}</URI>
|
|
97
|
+
</RegistrationInfo>
|
|
98
|
+
<Triggers>
|
|
99
|
+
<LogonTrigger>
|
|
100
|
+
<Enabled>true</Enabled>
|
|
101
|
+
<UserId>${xmlEscape(i.userId)}</UserId>
|
|
102
|
+
</LogonTrigger>
|
|
103
|
+
</Triggers>
|
|
104
|
+
<Principals>
|
|
105
|
+
<Principal id="Author">
|
|
106
|
+
<UserId>${xmlEscape(i.userId)}</UserId>
|
|
107
|
+
<LogonType>InteractiveToken</LogonType>
|
|
108
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
109
|
+
</Principal>
|
|
110
|
+
</Principals>
|
|
111
|
+
<Settings>
|
|
112
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
113
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
114
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
115
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
116
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
117
|
+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
118
|
+
<IdleSettings>
|
|
119
|
+
<StopOnIdleEnd>false</StopOnIdleEnd>
|
|
120
|
+
<RestartOnIdle>false</RestartOnIdle>
|
|
121
|
+
</IdleSettings>
|
|
122
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
123
|
+
<Enabled>true</Enabled>
|
|
124
|
+
<Hidden>true</Hidden>
|
|
125
|
+
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
|
126
|
+
<WakeToRun>false</WakeToRun>
|
|
127
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
128
|
+
<Priority>7</Priority>
|
|
129
|
+
<RestartOnFailure>
|
|
130
|
+
<Interval>PT1M</Interval>
|
|
131
|
+
<Count>999</Count>
|
|
132
|
+
</RestartOnFailure>
|
|
133
|
+
</Settings>
|
|
134
|
+
<Actions Context="Author">
|
|
135
|
+
<Exec>
|
|
136
|
+
<Command>cmd.exe</Command>
|
|
137
|
+
<Arguments>/c "${xmlEscape(command)}"</Arguments>
|
|
138
|
+
</Exec>
|
|
139
|
+
</Actions>
|
|
140
|
+
</Task>
|
|
141
|
+
`;
|
|
142
|
+
}
|
|
143
|
+
export function isEphemeralInstall(scriptPath) {
|
|
144
|
+
return /[\\/]_npx[\\/]/.test(scriptPath);
|
|
145
|
+
}
|
|
146
|
+
export const EPHEMERAL_INSTALL_MESSAGE = `This copy is running from npx's temporary cache, which npm can delete at any\n` +
|
|
147
|
+
`time. A background service has to point at a path that will still be there\n` +
|
|
148
|
+
`after a reboot, so install the package properly first:\n\n` +
|
|
149
|
+
` npm install -g @firefunc-agent/runner\n` +
|
|
150
|
+
` firefunc-runner install\n\n` +
|
|
151
|
+
`Everything else (connect, start, status, logs) works fine under npx.`;
|
|
152
|
+
export function currentPlatform() {
|
|
153
|
+
const p = platform();
|
|
154
|
+
return p === 'darwin' || p === 'linux' || p === 'win32' ? p : null;
|
|
155
|
+
}
|
|
156
|
+
const UNSUPPORTED = `Installing as a background service is supported on macOS, Linux and Windows.\n` +
|
|
157
|
+
`This platform is none of those, so keep "firefunc-runner start" running in a\n` +
|
|
158
|
+
`terminal.`;
|
|
159
|
+
async function exec(cmd, args) {
|
|
160
|
+
try {
|
|
161
|
+
const { stdout, stderr } = await run(cmd, args);
|
|
162
|
+
return { code: 0, out: `${stdout}${stderr}` };
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
const e = err;
|
|
166
|
+
return { code: typeof e.code === 'number' ? e.code : 1, out: e.stderr ?? e.message ?? '' };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
export function defaultServiceDeps(unitBody, os = currentPlatform()) {
|
|
170
|
+
return {
|
|
171
|
+
os,
|
|
172
|
+
paths: servicePaths(os ?? 'linux'),
|
|
173
|
+
unitBody,
|
|
174
|
+
exec,
|
|
175
|
+
ensureDir: (path) => mkdirSync(path, { recursive: true }),
|
|
176
|
+
unitEncoding: os === 'win32' ? 'utf16le' : 'utf8',
|
|
177
|
+
writeFile: (path, body, encoding) => {
|
|
178
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
179
|
+
const text = encoding === 'utf16le' ? `\ufeff${body}` : body;
|
|
180
|
+
writeFileSync(path, text, { encoding, mode: 0o644 });
|
|
181
|
+
},
|
|
182
|
+
removeFile: (path) => rmSync(path, { force: true }),
|
|
183
|
+
fileExists: (path) => existsSync(path),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
export async function installService(d) {
|
|
187
|
+
if (!d.os)
|
|
188
|
+
return { ok: false, message: UNSUPPORTED };
|
|
189
|
+
d.ensureDir(dirname(d.paths.log));
|
|
190
|
+
d.writeFile(d.paths.unit, d.unitBody, d.unitEncoding);
|
|
191
|
+
if (d.os === 'darwin') {
|
|
192
|
+
const uid = process.getuid?.() ?? 501;
|
|
193
|
+
await d.exec('launchctl', ['bootout', `gui/${uid}/${SERVICE_LABEL}`]);
|
|
194
|
+
const boot = await d.exec('launchctl', ['bootstrap', `gui/${uid}`, d.paths.unit]);
|
|
195
|
+
if (boot.code !== 0) {
|
|
196
|
+
return { ok: false, message: `launchctl bootstrap failed:\n${boot.out}` };
|
|
197
|
+
}
|
|
198
|
+
await d.exec('launchctl', ['enable', `gui/${uid}/${SERVICE_LABEL}`]);
|
|
199
|
+
return {
|
|
200
|
+
ok: true,
|
|
201
|
+
message: `Installed. The runner starts at login and restarts if it crashes.\n` +
|
|
202
|
+
` unit: ${d.paths.unit}\n logs: ${d.paths.log}\n\n` +
|
|
203
|
+
` firefunc-runner logs watch it work\n` +
|
|
204
|
+
` firefunc-runner stop stop it (stays stopped until you start it)\n` +
|
|
205
|
+
` firefunc-runner uninstall remove the service entirely`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (d.os === 'win32') {
|
|
209
|
+
const create = await d.exec('schtasks', [
|
|
210
|
+
'/Create',
|
|
211
|
+
'/TN',
|
|
212
|
+
WINDOWS_TASK_NAME,
|
|
213
|
+
'/XML',
|
|
214
|
+
d.paths.unit,
|
|
215
|
+
'/F',
|
|
216
|
+
]);
|
|
217
|
+
if (create.code !== 0) {
|
|
218
|
+
return { ok: false, message: `schtasks /Create failed:\n${create.out}` };
|
|
219
|
+
}
|
|
220
|
+
const run = await d.exec('schtasks', ['/Run', '/TN', WINDOWS_TASK_NAME]);
|
|
221
|
+
const runNote = run.code === 0
|
|
222
|
+
? ''
|
|
223
|
+
: `\nNOTE: registered, but could not start it right now — it will start at your next sign-in.\n${run.out.trim()}`;
|
|
224
|
+
return {
|
|
225
|
+
ok: true,
|
|
226
|
+
message: `Installed. The runner starts when you sign in and restarts if it crashes.\n` +
|
|
227
|
+
` task: ${WINDOWS_TASK_NAME} (Task Scheduler)\n logs: ${d.paths.log}\n\n` +
|
|
228
|
+
` firefunc-runner logs watch it work\n` +
|
|
229
|
+
` firefunc-runner stop stop it (stays stopped until you start it)\n` +
|
|
230
|
+
` firefunc-runner uninstall remove the task entirely${runNote}`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
await d.exec('systemctl', ['--user', 'daemon-reload']);
|
|
234
|
+
const enable = await d.exec('systemctl', ['--user', 'enable', '--now', 'firefunc-runner']);
|
|
235
|
+
if (enable.code !== 0) {
|
|
236
|
+
return { ok: false, message: `systemctl enable failed:\n${enable.out}` };
|
|
237
|
+
}
|
|
238
|
+
const linger = await d.exec('loginctl', ['enable-linger', process.env.USER ?? '']);
|
|
239
|
+
const lingerNote = linger.code === 0
|
|
240
|
+
? ''
|
|
241
|
+
: `\nNOTE: "loginctl enable-linger" failed, so the runner will only run while\n` +
|
|
242
|
+
`you are logged in. Run it yourself with sudo to make it start at boot:\n` +
|
|
243
|
+
` sudo loginctl enable-linger ${process.env.USER ?? '$USER'}`;
|
|
244
|
+
return {
|
|
245
|
+
ok: true,
|
|
246
|
+
message: `Installed. The runner starts at boot and restarts if it crashes.\n` +
|
|
247
|
+
` unit: ${d.paths.unit}\n logs: ${d.paths.log}\n\n` +
|
|
248
|
+
` firefunc-runner logs watch it work\n` +
|
|
249
|
+
` firefunc-runner stop stop it (stays stopped until you start it)\n` +
|
|
250
|
+
` firefunc-runner uninstall remove the service entirely${lingerNote}`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
export async function uninstallService(d) {
|
|
254
|
+
if (!d.os)
|
|
255
|
+
return { ok: false, message: UNSUPPORTED };
|
|
256
|
+
if (!d.fileExists(d.paths.unit)) {
|
|
257
|
+
return { ok: true, message: 'No service installed — nothing to remove.' };
|
|
258
|
+
}
|
|
259
|
+
if (d.os === 'darwin') {
|
|
260
|
+
const uid = process.getuid?.() ?? 501;
|
|
261
|
+
await d.exec('launchctl', ['bootout', `gui/${uid}/${SERVICE_LABEL}`]);
|
|
262
|
+
}
|
|
263
|
+
else if (d.os === 'win32') {
|
|
264
|
+
await d.exec('schtasks', ['/End', '/TN', WINDOWS_TASK_NAME]);
|
|
265
|
+
await d.exec('schtasks', ['/Delete', '/TN', WINDOWS_TASK_NAME, '/F']);
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
await d.exec('systemctl', ['--user', 'disable', '--now', 'firefunc-runner']);
|
|
269
|
+
await d.exec('systemctl', ['--user', 'daemon-reload']);
|
|
270
|
+
}
|
|
271
|
+
d.removeFile(d.paths.unit);
|
|
272
|
+
return { ok: true, message: `Removed. ${d.paths.unit} deleted; the runner will not restart.` };
|
|
273
|
+
}
|
|
274
|
+
export async function stopService(d) {
|
|
275
|
+
if (!d.os)
|
|
276
|
+
return { ok: false, message: UNSUPPORTED };
|
|
277
|
+
if (!d.fileExists(d.paths.unit)) {
|
|
278
|
+
return {
|
|
279
|
+
ok: false,
|
|
280
|
+
message: 'No service installed. If you started the runner in a terminal, stop it there (Ctrl-C).',
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (d.os === 'darwin') {
|
|
284
|
+
const uid = process.getuid?.() ?? 501;
|
|
285
|
+
const r = await d.exec('launchctl', ['bootout', `gui/${uid}/${SERVICE_LABEL}`]);
|
|
286
|
+
if (r.code !== 0 && !/no such process|not find/i.test(r.out)) {
|
|
287
|
+
return { ok: false, message: `Could not stop the service:\n${r.out}` };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
else if (d.os === 'win32') {
|
|
291
|
+
await d.exec('schtasks', ['/End', '/TN', WINDOWS_TASK_NAME]);
|
|
292
|
+
const r = await d.exec('schtasks', ['/Change', '/TN', WINDOWS_TASK_NAME, '/DISABLE']);
|
|
293
|
+
if (r.code !== 0)
|
|
294
|
+
return { ok: false, message: `Could not stop the task:\n${r.out}` };
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
const r = await d.exec('systemctl', ['--user', 'stop', 'firefunc-runner']);
|
|
298
|
+
if (r.code !== 0)
|
|
299
|
+
return { ok: false, message: `Could not stop the service:\n${r.out}` };
|
|
300
|
+
}
|
|
301
|
+
return { ok: true, message: 'Stopped. Run "firefunc-runner start-service" to start it again.' };
|
|
302
|
+
}
|
|
303
|
+
export async function startService(d) {
|
|
304
|
+
if (!d.os)
|
|
305
|
+
return { ok: false, message: UNSUPPORTED };
|
|
306
|
+
if (!d.fileExists(d.paths.unit)) {
|
|
307
|
+
return { ok: false, message: 'No service installed. Run "firefunc-runner install" first.' };
|
|
308
|
+
}
|
|
309
|
+
if (d.os === 'darwin') {
|
|
310
|
+
const uid = process.getuid?.() ?? 501;
|
|
311
|
+
await d.exec('launchctl', ['bootout', `gui/${uid}/${SERVICE_LABEL}`]);
|
|
312
|
+
const r = await d.exec('launchctl', ['bootstrap', `gui/${uid}`, d.paths.unit]);
|
|
313
|
+
if (r.code !== 0)
|
|
314
|
+
return { ok: false, message: `Could not start the service:\n${r.out}` };
|
|
315
|
+
}
|
|
316
|
+
else if (d.os === 'win32') {
|
|
317
|
+
await d.exec('schtasks', ['/Change', '/TN', WINDOWS_TASK_NAME, '/ENABLE']);
|
|
318
|
+
const r = await d.exec('schtasks', ['/Run', '/TN', WINDOWS_TASK_NAME]);
|
|
319
|
+
if (r.code !== 0)
|
|
320
|
+
return { ok: false, message: `Could not start the task:\n${r.out}` };
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
const r = await d.exec('systemctl', ['--user', 'start', 'firefunc-runner']);
|
|
324
|
+
if (r.code !== 0)
|
|
325
|
+
return { ok: false, message: `Could not start the service:\n${r.out}` };
|
|
326
|
+
}
|
|
327
|
+
return { ok: true, message: 'Started.' };
|
|
328
|
+
}
|
|
329
|
+
export async function serviceStatus(d) {
|
|
330
|
+
const base = { unit: d.paths.unit, log: d.paths.log };
|
|
331
|
+
if (!d.os)
|
|
332
|
+
return { ...base, installed: false, running: false, detail: 'unsupported platform' };
|
|
333
|
+
const installed = d.fileExists(d.paths.unit);
|
|
334
|
+
if (!installed)
|
|
335
|
+
return { ...base, installed: false, running: false };
|
|
336
|
+
if (d.os === 'darwin') {
|
|
337
|
+
const uid = process.getuid?.() ?? 501;
|
|
338
|
+
const r = await d.exec('launchctl', ['print', `gui/${uid}/${SERVICE_LABEL}`]);
|
|
339
|
+
const running = r.code === 0 && /state\s*=\s*running/i.test(r.out);
|
|
340
|
+
return { ...base, installed: true, running };
|
|
341
|
+
}
|
|
342
|
+
if (d.os === 'win32') {
|
|
343
|
+
const r = await d.exec('schtasks', ['/Query', '/TN', WINDOWS_TASK_NAME, '/FO', 'LIST']);
|
|
344
|
+
if (r.code !== 0)
|
|
345
|
+
return { ...base, installed: true, running: false, detail: 'task not found' };
|
|
346
|
+
const status = /Status:\s*(\S+)/i.exec(r.out)?.[1] ?? '';
|
|
347
|
+
return {
|
|
348
|
+
...base,
|
|
349
|
+
installed: true,
|
|
350
|
+
running: /^running$/i.test(status),
|
|
351
|
+
detail: status ? status.toLowerCase() : undefined,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
const r = await d.exec('systemctl', ['--user', 'is-active', 'firefunc-runner']);
|
|
355
|
+
return { ...base, installed: true, running: r.out.trim() === 'active' };
|
|
356
|
+
}
|
|
357
|
+
export function readLogTail(logPath, lines = 80) {
|
|
358
|
+
if (!existsSync(logPath))
|
|
359
|
+
return '(no log yet — has the service been started?)';
|
|
360
|
+
const all = readFileSync(logPath, 'utf8').split('\n');
|
|
361
|
+
return all.slice(Math.max(0, all.length - lines)).join('\n');
|
|
362
|
+
}
|
|
363
|
+
//# sourceMappingURL=service.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firefunc-agent/runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "FireFunc self-hosted runner — an always-on daemon that runs YOUR local coding agent (Claude Code, Codex, Gemini CLI, Cursor CLI) on work FireFunc routes to it, opens pull requests, and reports back. Your machine, your subscriptions.",
|