@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 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 once (or skip it and use npx, as below).
24
+ # 1. Install the package. Use -g, not npx see the note below.
25
25
  npm install -g @firefunc-agent/runner
26
26
 
27
- # 1. 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
-
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
- npx @firefunc-agent/runner start
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 start` | Start the always-on daemon |
57
- | `firefunc-runner status` | Show the saved config |
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
- ## Run it as a service
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
- Keep it alive across reboots with launchd (macOS), systemd (Linux), or `pm2`:
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
- pm2 start "npx @firefunc-agent/runner start" --name firefunc-runner
75
- pm2 save
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 start Start the always-on daemon
13
- firefunc-runner status Show the saved config
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 start
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 cfg = {
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()}.\nRun "firefunc-runner start" to go online.\n`);
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
@@ -1,3 +1,4 @@
1
+ export declare function expandHome(p: string): string;
1
2
  export type RunnerConfig = {
2
3
  apiUrl: string;
3
4
  token: string;
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(`FATAL: ${msg}. Re-run "firefunc-runner connect <token>".`);
119
- process.exitCode = 1;
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`);
@@ -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.includes('oauth token has expired') ||
102
- t.includes('oauth token expired') ||
103
- t.includes('please run /login')) {
104
- return 'Claude Code could not authenticate. Fix the runner’s Claude auth — run `claude setup-token` and pass `--claude-oauth-token`, or clear a stale/invalid ANTHROPIC_API_KEY — then retry.';
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
+ }>;
@@ -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
- const repoPath = resolveRepoPath(job.repo, cfg);
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} set it in config.repos or reposDir`);
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 configured for ${job.repo}.`,
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 push = await exec('git', ['-C', worktree, 'push', '--force-with-lease', 'origin', `${branch}:${branch}`], { timeoutMs: 120_000 });
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;
@@ -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, '&amp;')
22
+ .replace(/</g, '&lt;')
23
+ .replace(/>/g, '&gt;')
24
+ .replace(/"/g, '&quot;');
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.5.1",
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.",