@bridge4dev/runner 0.11.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ import { Supervisor } from './supervisor.js';
14
14
  import { readStatusFile, isPidAlive, writeStatusFile, STATUS_FRESH_MS } from './status-file.js';
15
15
  import { RunnerWsClient } from './ws-client.js';
16
16
  import { RUNNER_VERSION } from './version.js';
17
+ import { buildUnit, unitExecTarget, unitPath } from './service-unit.js';
17
18
  const execFileAsync = promisify(execFile);
18
19
  function print(line) {
19
20
  process.stdout.write(line + '\n');
@@ -236,17 +237,34 @@ async function cmdDaemon() {
236
237
  await new Promise(() => undefined);
237
238
  }
238
239
  // ─── status ──────────────────────────────────────────────────────────
239
- function cmdStatus() {
240
+ /** Long enough for a just-started daemon to connect and write its first record. */
241
+ const STATUS_WAIT_MS = 10_000;
242
+ const STATUS_POLL_MS = 250;
243
+ function currentStatus() {
244
+ const status = readStatusFile();
245
+ const fresh = status &&
246
+ isPidAlive(status.pid) &&
247
+ Date.now() - new Date(status.updatedAt).getTime() < STATUS_FRESH_MS;
248
+ return fresh ? status : null;
249
+ }
250
+ async function cmdStatus() {
240
251
  const config = loadConfig();
241
252
  if (!config) {
242
253
  print('NOT PAIRED — run: devbridge-runner pair <code> --api <url>');
243
254
  process.exit(1);
244
255
  }
245
- const status = readStatusFile();
246
- const fresh = status &&
247
- isPidAlive(status.pid) &&
248
- Date.now() - new Date(status.updatedAt).getTime() < STATUS_FRESH_MS;
249
- if (!fresh) {
256
+ // `install-service` ends by telling the user to run this, and people run it
257
+ // right away — usually chained with `&&`, a fraction of a second after systemd
258
+ // started the daemon, before it has connected or written its first record. It
259
+ // then answered «NOT RUNNING» about a runner that was starting perfectly well.
260
+ // So: wait briefly for the daemon to say something before declaring it dead.
261
+ let status = currentStatus();
262
+ const deadline = Date.now() + STATUS_WAIT_MS;
263
+ while (!status && Date.now() < deadline) {
264
+ await new Promise((resolve) => setTimeout(resolve, STATUS_POLL_MS));
265
+ status = currentStatus();
266
+ }
267
+ if (!status) {
250
268
  print(`NOT RUNNING — server "${config.server.name}" (${config.api.url})`);
251
269
  print('Start with: devbridge-runner install-service (or: devbridge-runner daemon)');
252
270
  process.exit(1);
@@ -262,33 +280,20 @@ function cmdStatus() {
262
280
  process.exit(1);
263
281
  }
264
282
  // ─── install-service ─────────────────────────────────────────────────
265
- function systemdUnit() {
266
- const script = fs.realpathSync(process.argv[1] ?? '');
267
- return [
268
- '[Unit]',
269
- 'Description=DevBridge Dev Runner',
270
- 'After=network-online.target',
271
- '',
272
- '[Service]',
273
- `ExecStart=${process.execPath} ${script} daemon`,
274
- 'Restart=always',
275
- 'RestartSec=5',
276
- 'CPUQuota=80%',
277
- 'MemoryMax=2G',
278
- '',
279
- '[Install]',
280
- 'WantedBy=default.target',
281
- ].join('\n');
282
- }
283
283
  async function cmdInstallService() {
284
284
  requireConfig(); // fail early if not paired
285
285
  if (process.platform !== 'linux')
286
286
  fail('install-service supports Linux/systemd only');
287
- const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user');
288
- fs.mkdirSync(unitDir, { recursive: true });
289
- const unitPath = path.join(unitDir, 'devbridge-runner.service');
290
- fs.writeFileSync(unitPath, systemdUnit() + '\n');
291
- print(`Wrote ${unitPath}`);
287
+ const target = unitPath();
288
+ fs.mkdirSync(path.dirname(target), { recursive: true });
289
+ const exec = unitExecTarget();
290
+ fs.writeFileSync(target, buildUnit(exec.execStart));
291
+ print(`Wrote ${target}`);
292
+ if (!exec.viaCommand) {
293
+ // Worth saying out loud: a unit pinned to a file inside the package directory
294
+ // breaks if that directory ever moves (which a package rename does).
295
+ print(`note: the service runs ${exec.execStart} directly — re-run install-service after reinstalling the package.`);
296
+ }
292
297
  try {
293
298
  await execFileAsync('systemctl', ['--user', 'daemon-reload']);
294
299
  await execFileAsync('systemctl', ['--user', 'enable', '--now', 'devbridge-runner']);
@@ -5,6 +5,7 @@ import { promisify } from 'node:util';
5
5
  import { log } from './log.js';
6
6
  import { stateDir } from './paths.js';
7
7
  import { RUNNER_VERSION } from './version.js';
8
+ import { buildUnit, unitIsBroken, unitPath } from './service-unit.js';
8
9
  const execFileAsync = promisify(execFile);
9
10
  const NPM_TIMEOUT_MS = 180_000;
10
11
  const VERIFY_TIMEOUT_MS = 30_000;
@@ -82,19 +83,100 @@ export function isTrustedTarballUrl(tarballUrl, apiUrl) {
82
83
  }
83
84
  return target.origin === api.origin && target.pathname.endsWith('.tgz');
84
85
  }
85
- function readVersion(packageDir) {
86
+ function readManifest(packageDir) {
86
87
  try {
87
- const parsed = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8'));
88
- return typeof parsed.version === 'string' ? parsed.version : null;
88
+ return JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8'));
89
89
  }
90
90
  catch {
91
91
  return null;
92
92
  }
93
93
  }
94
+ function readVersion(packageDir) {
95
+ const value = readManifest(packageDir)?.version;
96
+ return typeof value === 'string' ? value : null;
97
+ }
94
98
  /** The CLI entry point of an installed package, used to smoke-test the update. */
95
99
  function binPath(packageDir) {
96
100
  return path.join(packageDir, 'dist', 'index.js');
97
101
  }
102
+ /** The command every systemd unit on every user's machine points at. */
103
+ const COMMAND_NAME = 'devbridge-runner';
104
+ /**
105
+ * Which installed package currently owns the `devbridge-runner` command.
106
+ *
107
+ * Asked instead of assumed, because an update may RENAME the package (it did:
108
+ * `@devbridge/runner` → `@bridge4dev/runner`). After that the directory this
109
+ * process is running from belongs to the version being retired, while the
110
+ * command — the stable thing, referenced by a unit file we do not control —
111
+ * points at the new one. So we follow the command.
112
+ */
113
+ async function commandOwner(exec) {
114
+ let prefix;
115
+ try {
116
+ const result = await exec('npm', ['prefix', '-g'], {
117
+ timeout: VERIFY_TIMEOUT_MS,
118
+ env: npmEnv(),
119
+ });
120
+ prefix = result.stdout.trim();
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ if (!prefix)
126
+ return null;
127
+ const command = path.join(prefix, 'bin', COMMAND_NAME);
128
+ try {
129
+ if (!fs.existsSync(command))
130
+ return null;
131
+ // <dir>/dist/index.js → <dir>
132
+ const dir = path.resolve(path.dirname(fs.realpathSync(command)), '..');
133
+ const name = readManifest(dir)?.name;
134
+ return typeof name === 'string' ? { name, dir, command } : null;
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ }
140
+ function installArgs(source) {
141
+ // `--ignore-scripts` matches the documented install: this package and its whole
142
+ // tree have no install/postinstall scripts, so nothing legitimate is skipped —
143
+ // and an update pulled over the network gets no chance to run anything at
144
+ // install time. `--loglevel=error` because npm's ERESOLVE warning about zod is
145
+ // expected, harmless and long enough to bury the line that matters.
146
+ return ['install', '-g', '--ignore-scripts', '--loglevel=error', source];
147
+ }
148
+ /**
149
+ * Install a global package, clearing the way if a DIFFERENTLY-NAMED build of this
150
+ * same runner still owns the command.
151
+ *
152
+ * npm refuses to take over an existing bin symlink (`EEXIST`) — which is exactly
153
+ * what happens when the package is renamed: the new name cannot claim
154
+ * `devbridge-runner` while the old name holds it, so «Update runner» failed with
155
+ * a wall of unrelated zod warnings. Retiring the previous package first leaves
156
+ * one owner instead of two, which is also the only state the NEXT update can
157
+ * work from.
158
+ */
159
+ async function installGlobal(exec, source) {
160
+ try {
161
+ await exec('npm', installArgs(source), { timeout: NPM_TIMEOUT_MS, env: npmEnv() });
162
+ return;
163
+ }
164
+ catch (error) {
165
+ if (!/EEXIST/i.test(describe(error)))
166
+ throw error;
167
+ const owner = await commandOwner(exec);
168
+ if (!owner)
169
+ throw error;
170
+ log.warn('self-update: the command belongs to another package — retiring it', {
171
+ package: owner.name,
172
+ });
173
+ await exec('npm', ['uninstall', '-g', '--loglevel=error', owner.name], {
174
+ timeout: NPM_TIMEOUT_MS,
175
+ env: npmEnv(),
176
+ });
177
+ await exec('npm', installArgs(source), { timeout: NPM_TIMEOUT_MS, env: npmEnv() });
178
+ }
179
+ }
98
180
  /**
99
181
  * npm needs PATH/HOME and a writable cache; everything else is stripped, both to
100
182
  * keep provider credentials out of a child process that touches the network and
@@ -160,24 +242,22 @@ export async function selfUpdate(options) {
160
242
  return fail('Could not prepare a rollback copy of the current version — update aborted');
161
243
  }
162
244
  try {
163
- // `--ignore-scripts` matches the documented install: this package and its
164
- // whole tree have no install/postinstall scripts, so nothing legitimate is
165
- // skipped — and an update pulled over the network gets no chance to run
166
- // anything at install time.
167
- await exec('npm', ['install', '-g', '--ignore-scripts', options.tarballUrl], {
168
- timeout: NPM_TIMEOUT_MS,
169
- env: npmEnv(),
170
- });
245
+ await installGlobal(exec, options.tarballUrl);
171
246
  }
172
247
  catch (error) {
173
248
  return fail(`Install failed: ${describe(error)}`, { rollbackTarball });
174
249
  }
175
- const toVersion = readVersion(packageDir) ?? undefined;
250
+ // Where the new build actually landed. After a rename `packageDir` is the
251
+ // directory we just retired, so its manifest would report the OLD version —
252
+ // and the smoke test below would run code that no longer exists.
253
+ const installed = await commandOwner(exec);
254
+ const newPackageDir = installed?.dir ?? packageDir;
255
+ const toVersion = readVersion(newPackageDir) ?? undefined;
176
256
  // The real test: does the newly installed build start? `--version` loads the
177
257
  // whole module graph, so a half-downloaded package or a missing dependency
178
258
  // fails here rather than after the restart, when nobody could see it.
179
259
  try {
180
- const probe = await exec(process.execPath, [binPath(packageDir), '--version'], {
260
+ const probe = await exec(process.execPath, [binPath(newPackageDir), '--version'], {
181
261
  timeout: VERIFY_TIMEOUT_MS,
182
262
  env: npmEnv(),
183
263
  });
@@ -190,10 +270,10 @@ export async function selfUpdate(options) {
190
270
  const detail = describe(error);
191
271
  log.error('self-update: the new build did not start — rolling back', { error: detail });
192
272
  try {
193
- await exec('npm', ['install', '-g', '--ignore-scripts', rollbackTarball], {
194
- timeout: NPM_TIMEOUT_MS,
195
- env: npmEnv(),
196
- });
273
+ // Through the same door as the install above: if the failed update renamed
274
+ // the package, the command now belongs to the new name and putting the old
275
+ // one back hits the very same EEXIST.
276
+ await installGlobal(exec, rollbackTarball);
197
277
  return fail(`The new version did not start (${detail}). The previous version was restored and the runner keeps working.`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
198
278
  }
199
279
  catch (rollbackError) {
@@ -201,6 +281,28 @@ export async function selfUpdate(options) {
201
281
  `Restore it on the server with: npm install -g ${rollbackTarball}`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
202
282
  }
203
283
  }
284
+ // The service unit may be pinned to a file inside the directory this update
285
+ // just replaced — early versions wrote the resolved script path, and a package
286
+ // rename moves it. Then the restart we are about to ask for would fail with
287
+ // ENOENT and the runner would never come back. Repair it here, while the
288
+ // process is still alive to do it.
289
+ if (installed?.command && unitIsBroken()) {
290
+ try {
291
+ fs.mkdirSync(path.dirname(unitPath()), { recursive: true });
292
+ fs.writeFileSync(unitPath(), buildUnit(installed.command));
293
+ await exec('systemctl', ['--user', 'daemon-reload'], {
294
+ timeout: VERIFY_TIMEOUT_MS,
295
+ env: npmEnv(),
296
+ });
297
+ log.warn('self-update: the service unit pointed at the previous location — rewritten', {
298
+ execStart: installed.command,
299
+ });
300
+ }
301
+ catch (error) {
302
+ return fail(`The new version is installed, but the service still points at the previous location and ` +
303
+ `could not be repaired (${describe(error)}). Run \`devbridge-runner install-service\` on the server.`, { rollbackTarball, ...(toVersion ? { toVersion } : {}) });
304
+ }
305
+ }
204
306
  log.info('self-update: installed', { fromVersion, toVersion });
205
307
  return {
206
308
  ok: true,
@@ -210,12 +312,31 @@ export async function selfUpdate(options) {
210
312
  rollbackTarball,
211
313
  };
212
314
  }
315
+ /**
316
+ * The reason a child process failed, in 400 characters that are actually about
317
+ * the failure.
318
+ *
319
+ * npm puts its diagnosis at the END of stderr and everything it merely wants to
320
+ * mention at the beginning. Taking the first 400 characters is therefore exactly
321
+ * backwards: a real `npm error code EEXIST` was reported to the owner as a wall
322
+ * of ERESOLVE peer-dependency warnings about zod, which are harmless and always
323
+ * present. So: keep the `npm error` lines when there are any, and otherwise keep
324
+ * the tail rather than the head.
325
+ */
213
326
  function describe(error) {
214
- if (error instanceof Error) {
215
- const withStderr = error;
216
- const stderr = typeof withStderr.stderr === 'string' ? withStderr.stderr.trim() : '';
217
- return (stderr || error.message).slice(0, 400);
218
- }
219
- return String(error).slice(0, 400);
327
+ const raw = error instanceof Error
328
+ ? (() => {
329
+ const withStderr = error;
330
+ const stderr = typeof withStderr.stderr === 'string' ? withStderr.stderr.trim() : '';
331
+ return stderr || error.message;
332
+ })()
333
+ : String(error);
334
+ const npmErrors = raw
335
+ .split('\n')
336
+ .filter((line) => /^\s*npm (error|ERR!)/i.test(line))
337
+ .join('\n')
338
+ .trim();
339
+ const meaningful = npmErrors || raw;
340
+ return meaningful.length > 400 ? `…${meaningful.slice(-400)}` : meaningful;
220
341
  }
221
342
  //# sourceMappingURL=self-update.js.map
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The systemd user unit, and the one decision inside it that matters: what to
3
+ * exec.
4
+ *
5
+ * The first version baked `realpathSync(process.argv[1])` — the resolved file
6
+ * inside the installed package directory. That pins the service to a directory
7
+ * that a package RENAME deletes: `@devbridge/runner` → `@bridge4dev/runner` moved
8
+ * the file, the unit kept pointing at the old path, and `systemctl restart`
9
+ * failed with ENOENT. The runner simply never came back and the server went
10
+ * offline until someone logged in — the one outcome an update must never produce.
11
+ *
12
+ * So the unit execs the COMMAND (`<prefix>/bin/devbridge-runner`), which npm
13
+ * re-creates on every install whatever the package is called. A source checkout
14
+ * has no such symlink, and there the resolved script is the honest answer.
15
+ */
16
+ export declare const SERVICE_NAME = "devbridge-runner";
17
+ /** `<prefix>/bin/devbridge-runner` for an installed package, else the script. */
18
+ export declare function unitExecTarget(argv1?: string): {
19
+ execStart: string;
20
+ viaCommand: boolean;
21
+ };
22
+ export declare function unitPath(): string;
23
+ export declare function buildUnit(execStart?: string): string;
24
+ /**
25
+ * Does the installed unit point at something that no longer exists?
26
+ *
27
+ * Used after an update: if the package moved (a rename), a unit pinned to the old
28
+ * directory would kill the runner on the very restart the update asks for. Only a
29
+ * missing target counts — a unit the user edited on purpose is left alone.
30
+ */
31
+ export declare function unitIsBroken(readFile?: (p: string) => string): boolean;
32
+ //# sourceMappingURL=service-unit.d.ts.map
@@ -0,0 +1,106 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ /**
5
+ * The systemd user unit, and the one decision inside it that matters: what to
6
+ * exec.
7
+ *
8
+ * The first version baked `realpathSync(process.argv[1])` — the resolved file
9
+ * inside the installed package directory. That pins the service to a directory
10
+ * that a package RENAME deletes: `@devbridge/runner` → `@bridge4dev/runner` moved
11
+ * the file, the unit kept pointing at the old path, and `systemctl restart`
12
+ * failed with ENOENT. The runner simply never came back and the server went
13
+ * offline until someone logged in — the one outcome an update must never produce.
14
+ *
15
+ * So the unit execs the COMMAND (`<prefix>/bin/devbridge-runner`), which npm
16
+ * re-creates on every install whatever the package is called. A source checkout
17
+ * has no such symlink, and there the resolved script is the honest answer.
18
+ */
19
+ export const SERVICE_NAME = 'devbridge-runner';
20
+ const COMMAND_NAME = 'devbridge-runner';
21
+ /** `<prefix>/bin/devbridge-runner` for an installed package, else the script. */
22
+ export function unitExecTarget(argv1 = process.argv[1] ?? '') {
23
+ // Invoked through the command itself: that is already the stable path.
24
+ try {
25
+ if (fs.lstatSync(argv1).isSymbolicLink()) {
26
+ return { execStart: path.resolve(argv1), viaCommand: true };
27
+ }
28
+ }
29
+ catch {
30
+ /* fall through to the resolved script */
31
+ }
32
+ let script;
33
+ try {
34
+ script = fs.realpathSync(argv1);
35
+ }
36
+ catch {
37
+ return { execStart: argv1, viaCommand: false };
38
+ }
39
+ // Invoked as the script inside a global install: find the prefix that owns the
40
+ // command. `<prefix>/lib/node_modules/@scope/runner/dist/index.js` → `<prefix>`.
41
+ if (script.split(path.sep).includes('node_modules')) {
42
+ let dir = path.dirname(script);
43
+ for (let i = 0; i < 6; i++) {
44
+ const candidate = path.join(dir, 'bin', COMMAND_NAME);
45
+ if (fs.existsSync(candidate))
46
+ return { execStart: candidate, viaCommand: true };
47
+ const parent = path.dirname(dir);
48
+ if (parent === dir)
49
+ break;
50
+ dir = parent;
51
+ }
52
+ }
53
+ return { execStart: script, viaCommand: false };
54
+ }
55
+ export function unitPath() {
56
+ return path.join(os.homedir(), '.config', 'systemd', 'user', `${SERVICE_NAME}.service`);
57
+ }
58
+ export function buildUnit(execStart) {
59
+ const target = execStart ?? unitExecTarget().execStart;
60
+ // The command carries a `#!/usr/bin/env node` shebang, so it is exec'd directly;
61
+ // a bare script path needs the interpreter spelled out.
62
+ const command = target.endsWith('.js')
63
+ ? `${process.execPath} ${target} daemon`
64
+ : `${target} daemon`;
65
+ return ([
66
+ '[Unit]',
67
+ 'Description=DevBridge Dev Runner',
68
+ 'After=network-online.target',
69
+ '',
70
+ '[Service]',
71
+ `ExecStart=${command}`,
72
+ 'Restart=always',
73
+ 'RestartSec=5',
74
+ 'CPUQuota=80%',
75
+ 'MemoryMax=2G',
76
+ '',
77
+ '[Install]',
78
+ 'WantedBy=default.target',
79
+ ].join('\n') + '\n');
80
+ }
81
+ /**
82
+ * Does the installed unit point at something that no longer exists?
83
+ *
84
+ * Used after an update: if the package moved (a rename), a unit pinned to the old
85
+ * directory would kill the runner on the very restart the update asks for. Only a
86
+ * missing target counts — a unit the user edited on purpose is left alone.
87
+ */
88
+ export function unitIsBroken(readFile = (p) => fs.readFileSync(p, 'utf8')) {
89
+ let contents;
90
+ try {
91
+ contents = readFile(unitPath());
92
+ }
93
+ catch {
94
+ return false; // no unit of ours — nothing to repair
95
+ }
96
+ const line = contents.split('\n').find((l) => l.startsWith('ExecStart='));
97
+ if (!line)
98
+ return false;
99
+ const parts = line.slice('ExecStart='.length).trim().split(/\s+/);
100
+ // `ExecStart=/usr/bin/node /path/index.js daemon` or `ExecStart=/path/cmd daemon`
101
+ const target = parts[0]?.endsWith('node') ? parts[1] : parts[0];
102
+ if (!target)
103
+ return false;
104
+ return !fs.existsSync(target);
105
+ }
106
+ //# sourceMappingURL=service-unit.js.map
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.11.0";
1
+ export declare const RUNNER_VERSION = "0.13.1";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in sync with package.json by the release script (manual for now).
2
- export const RUNNER_VERSION = '0.11.0';
2
+ export const RUNNER_VERSION = '0.13.1';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.11.0",
3
+ "version": "0.13.1",
4
4
  "description": "DevBridge dev runner — connects a dev server to DevBridge and runs agent sessions (Claude Code / Codex)",
5
5
  "homepage": "https://bridge4.dev",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
8
  "main": "./dist/index.js",
9
9
  "bin": {
10
- "devbridge-runner": "./dist/index.js"
10
+ "devbridge-runner": "dist/index.js"
11
11
  },
12
12
  "keywords": [
13
13
  "devbridge",