@bridge4dev/runner 0.11.0 → 0.22.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.
Files changed (45) hide show
  1. package/dist/adapters/claude.d.ts +15 -7
  2. package/dist/adapters/claude.js +1024 -70
  3. package/dist/adapters/codex.d.ts +18 -3
  4. package/dist/adapters/codex.js +224 -65
  5. package/dist/adapters/questions.d.ts +42 -0
  6. package/dist/adapters/questions.js +86 -0
  7. package/dist/adapters/types.d.ts +200 -4
  8. package/dist/attachments.d.ts +8 -1
  9. package/dist/attachments.js +22 -4
  10. package/dist/auto-resume.d.ts +18 -0
  11. package/dist/auto-resume.js +104 -0
  12. package/dist/commit-message.d.ts +51 -0
  13. package/dist/commit-message.js +224 -0
  14. package/dist/config.d.ts +29 -6
  15. package/dist/config.js +15 -0
  16. package/dist/crash-note.d.ts +54 -0
  17. package/dist/crash-note.js +105 -0
  18. package/dist/git.d.ts +71 -0
  19. package/dist/git.js +207 -10
  20. package/dist/gitops.d.ts +489 -12
  21. package/dist/gitops.js +1717 -96
  22. package/dist/index.js +435 -32
  23. package/dist/paths.d.ts +26 -0
  24. package/dist/paths.js +34 -0
  25. package/dist/policy.d.ts +63 -0
  26. package/dist/policy.js +412 -10
  27. package/dist/protocol.d.ts +382 -60
  28. package/dist/protocol.js +104 -1
  29. package/dist/recipe-schema.d.ts +310 -0
  30. package/dist/recipe-schema.js +103 -0
  31. package/dist/recipe.d.ts +94 -0
  32. package/dist/recipe.js +238 -0
  33. package/dist/self-update.d.ts +7 -0
  34. package/dist/self-update.js +171 -23
  35. package/dist/service-unit.d.ts +79 -0
  36. package/dist/service-unit.js +211 -0
  37. package/dist/supervisor.d.ts +108 -1
  38. package/dist/supervisor.js +1010 -56
  39. package/dist/verify-queue.d.ts +17 -0
  40. package/dist/verify-queue.js +100 -0
  41. package/dist/verify.d.ts +203 -0
  42. package/dist/verify.js +788 -0
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +2 -2
package/dist/recipe.js ADDED
@@ -0,0 +1,238 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { PROJECT_RECIPE_PATH, canonicalRecipeJson, parseProjectRecipe, recipeSteps, } from './recipe-schema.js';
5
+ import { evaluateRecipeCommand, maskString } from './policy.js';
6
+ /**
7
+ * `.devbridge/project.json` — read, never executed (session 14).
8
+ *
9
+ * The file in a repository is a PROPOSAL. Nothing in this module runs anything:
10
+ * it reads the file, says whether it parses, and hands back a fingerprint. The
11
+ * executable copy lives on the DevBridge side and gets there only when a human
12
+ * with manager rights approves it — because an agent can edit files in its own
13
+ * branch, and «the runner does what the file says» would be arbitrary command
14
+ * execution by diff.
15
+ */
16
+ /** A recipe file bigger than this is not a recipe. */
17
+ const RECIPE_MAX_BYTES = 64 * 1024;
18
+ /**
19
+ * A stable fingerprint of a recipe.
20
+ *
21
+ * `verify_start` carries it and the runner refuses a run whose recipe does not
22
+ * match: an approval is an approval of specific commands, and «the approved
23
+ * recipe» has to mean the same bytes on both sides.
24
+ */
25
+ export function recipeFingerprint(recipe) {
26
+ return crypto.createHash('sha256').update(canonicalRecipeJson(recipe)).digest('hex').slice(0, 32);
27
+ }
28
+ /**
29
+ * Read the proposal out of a checkout.
30
+ *
31
+ * `lstat` and not `stat`: a symlink at `.devbridge/project.json` pointing at
32
+ * `~/.aws/credentials` would otherwise be read and shown verbatim on the
33
+ * approval screen.
34
+ *
35
+ * The DIRECTORY is checked the same way, and separately (session 15): `lstat`
36
+ * on the file follows every symlink above it, so `.devbridge` itself being a
37
+ * link to somewhere else in the filesystem walked straight past the guard while
38
+ * the guard's own comment said it did not.
39
+ */
40
+ export function readRecipeProposal(root) {
41
+ const file = path.join(root, PROJECT_RECIPE_PATH);
42
+ const empty = {
43
+ path: file,
44
+ present: false,
45
+ raw: null,
46
+ recipe: null,
47
+ error: null,
48
+ sha: null,
49
+ steps: [],
50
+ commands: [],
51
+ refusals: [],
52
+ };
53
+ // Everything read here is shown to a human and hashed into an approval, so
54
+ // it has to come from inside the checkout — not from wherever a link points.
55
+ const insideCheckout = (() => {
56
+ try {
57
+ const realRoot = fs.realpathSync(root);
58
+ const realDir = fs.realpathSync(path.dirname(file));
59
+ const rel = path.relative(realRoot, realDir);
60
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
61
+ }
62
+ catch {
63
+ // No `.devbridge` directory at all — the ordinary case, and the read
64
+ // below reports it as «no recipe» on its own.
65
+ return true;
66
+ }
67
+ })();
68
+ if (!insideCheckout) {
69
+ return {
70
+ ...empty,
71
+ present: true,
72
+ error: `${PROJECT_RECIPE_PATH} points outside the repository`,
73
+ };
74
+ }
75
+ let raw;
76
+ try {
77
+ const stat = fs.lstatSync(file);
78
+ if (!stat.isFile()) {
79
+ return { ...empty, present: true, error: `${PROJECT_RECIPE_PATH} is not a regular file` };
80
+ }
81
+ if (stat.size > RECIPE_MAX_BYTES) {
82
+ return {
83
+ ...empty,
84
+ present: true,
85
+ error: `${PROJECT_RECIPE_PATH} is larger than ${Math.round(RECIPE_MAX_BYTES / 1024)} KB`,
86
+ };
87
+ }
88
+ raw = fs.readFileSync(file, 'utf8');
89
+ }
90
+ catch {
91
+ return empty;
92
+ }
93
+ const masked = maskString(raw).slice(0, RECIPE_MAX_BYTES);
94
+ let parsedJson;
95
+ try {
96
+ // JSONC in the plan's example is illustrative; the file itself is JSON.
97
+ // Comments are stripped rather than refused — a recipe people hand-write is
98
+ // exactly the kind of file that grows a `// why` line.
99
+ parsedJson = JSON.parse(stripJsonComments(raw));
100
+ }
101
+ catch (error) {
102
+ return {
103
+ ...empty,
104
+ present: true,
105
+ raw: masked,
106
+ error: `${PROJECT_RECIPE_PATH} is not valid JSON: ${String(error instanceof Error ? error.message : error).slice(0, 200)}`,
107
+ };
108
+ }
109
+ const parsed = parseProjectRecipe(parsedJson);
110
+ if (!parsed.ok) {
111
+ return { ...empty, present: true, raw: masked, error: parsed.error };
112
+ }
113
+ return {
114
+ path: file,
115
+ present: true,
116
+ raw: masked,
117
+ recipe: parsed.recipe,
118
+ error: null,
119
+ sha: recipeFingerprint(parsed.recipe),
120
+ steps: recipeSteps(parsed.recipe),
121
+ commands: recipeCommands(parsed.recipe),
122
+ refusals: recipeRefusals(parsed.recipe),
123
+ };
124
+ }
125
+ /**
126
+ * Every command in the recipe, in the order a person should read them —
127
+ * `preview.run` and `preview.stop` included, exactly as the refusal check
128
+ * already covers them.
129
+ */
130
+ export function recipeCommands(recipe) {
131
+ const entries = [];
132
+ for (const name of recipeSteps(recipe)) {
133
+ const run = recipe.steps?.[name]?.run;
134
+ if (run)
135
+ entries.push({ step: name, run });
136
+ }
137
+ if (recipe.preview?.run)
138
+ entries.push({ step: 'preview.run', run: recipe.preview.run });
139
+ if (recipe.preview?.stop)
140
+ entries.push({ step: 'preview.stop', run: recipe.preview.stop });
141
+ return entries.map(({ step, run }) => {
142
+ const masked = maskString(run);
143
+ return { step, run: masked, redacted: masked !== run };
144
+ });
145
+ }
146
+ /**
147
+ * Every command in the recipe that layer 1 would refuse, named by its step.
148
+ *
149
+ * Answered at read time, not at run time. A recipe whose `deploy` step contains
150
+ * `sudo systemctl restart` must fail the person reading the approval screen, not
151
+ * a build twenty minutes into a release.
152
+ */
153
+ export function recipeRefusals(recipe) {
154
+ const refusals = [];
155
+ // A command a person cannot read is a command they cannot approve. Masking is
156
+ // a rewrite, and the executed copy is the unmasked one — so a redaction here
157
+ // is not a cosmetic detail, it is the gap between what was shown and what
158
+ // will run. Blocking approval closes it, and moving the secret into `env`
159
+ // (which is never a command) is the way out.
160
+ for (const command of recipeCommands(recipe)) {
161
+ if (!command.redacted)
162
+ continue;
163
+ refusals.push({
164
+ step: command.step,
165
+ reason: 'the command contains something that looks like a secret, so it cannot be shown in full — move it into `env` and approve a command you can read',
166
+ });
167
+ }
168
+ for (const name of recipeSteps(recipe)) {
169
+ const run = recipe.steps?.[name]?.run;
170
+ if (!run)
171
+ continue;
172
+ const decision = evaluateRecipeCommand(run);
173
+ if (!decision.allowed)
174
+ refusals.push({ step: name, reason: decision.reason ?? 'refused' });
175
+ }
176
+ if (recipe.preview?.run) {
177
+ const decision = evaluateRecipeCommand(recipe.preview.run);
178
+ if (!decision.allowed) {
179
+ refusals.push({ step: 'preview.run', reason: decision.reason ?? 'refused' });
180
+ }
181
+ }
182
+ if (recipe.preview?.stop) {
183
+ const decision = evaluateRecipeCommand(recipe.preview.stop, {
184
+ isPreviewStop: true,
185
+ ...(recipe.preview.project ? { dockerProject: recipe.preview.project } : {}),
186
+ });
187
+ if (!decision.allowed) {
188
+ refusals.push({ step: 'preview.stop', reason: decision.reason ?? 'refused' });
189
+ }
190
+ }
191
+ return refusals;
192
+ }
193
+ /**
194
+ * Drop `//` and `/* *\/` comments without touching what is inside strings.
195
+ *
196
+ * A hand-rolled scanner rather than a regex: a regex that ignores string
197
+ * context eats the `//` in a URL, which is exactly what `health.url` is.
198
+ */
199
+ export function stripJsonComments(text) {
200
+ let out = '';
201
+ let inString = false;
202
+ let escaped = false;
203
+ for (let i = 0; i < text.length; i++) {
204
+ const ch = text[i];
205
+ if (inString) {
206
+ out += ch;
207
+ if (escaped)
208
+ escaped = false;
209
+ else if (ch === '\\')
210
+ escaped = true;
211
+ else if (ch === '"')
212
+ inString = false;
213
+ continue;
214
+ }
215
+ if (ch === '"') {
216
+ inString = true;
217
+ out += ch;
218
+ continue;
219
+ }
220
+ if (ch === '/' && text[i + 1] === '/') {
221
+ while (i < text.length && text[i] !== '\n')
222
+ i++;
223
+ out += '\n';
224
+ continue;
225
+ }
226
+ if (ch === '/' && text[i + 1] === '*') {
227
+ i += 2;
228
+ while (i < text.length && !(text[i] === '*' && text[i + 1] === '/'))
229
+ i++;
230
+ i++;
231
+ continue;
232
+ }
233
+ out += ch;
234
+ }
235
+ // A trailing comma is the other thing a hand-written file grows.
236
+ return out.replace(/,(\s*[}\]])/g, '$1');
237
+ }
238
+ //# sourceMappingURL=recipe.js.map
@@ -45,6 +45,13 @@ export interface SelfUpdateOptions {
45
45
  packageDir?: string | null;
46
46
  /** Test seam: is this process supervised (defaults to autodetect). */
47
47
  supervised?: boolean;
48
+ /**
49
+ * Test seam for the resource-limits drop-in. Injected rather than called
50
+ * directly because the real one writes into `$HOME` — a test that exercised
51
+ * the update path would otherwise reconfigure the developer's own service.
52
+ * Returns true when it wrote something (and therefore needs a daemon-reload).
53
+ */
54
+ writeLimits?: () => boolean;
48
55
  }
49
56
  /**
50
57
  * Directory of the installed runner package, or null when it is running from a
@@ -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, limitsOverridePath, unitIsBroken, unitPath, writeLimitsOverride, LIMITS_VERSION, } 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,55 @@ 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
+ }
306
+ // The resource policy is versioned separately from the code, because the unit
307
+ // is written once at install time and the numbers baked into it before 0.21.0
308
+ // are what killed live sessions (QA-112 BLOCKER-1). An update is the only
309
+ // moment we are guaranteed to be running on the server with the right to fix
310
+ // that, so it happens here — and the restart below is what makes it effective.
311
+ //
312
+ // Deliberately NOT fatal: a runner that refuses to update because it could not
313
+ // improve its own limits is strictly worse than one that updates without the
314
+ // improvement. It is logged loudly and `doctor` reports it.
315
+ try {
316
+ if ((options.writeLimits ?? writeLimitsOverride)()) {
317
+ await exec('systemctl', ['--user', 'daemon-reload'], {
318
+ timeout: VERIFY_TIMEOUT_MS,
319
+ env: npmEnv(),
320
+ });
321
+ log.warn('self-update: resource limits drop-in written', {
322
+ path: limitsOverridePath(),
323
+ version: LIMITS_VERSION,
324
+ });
325
+ }
326
+ }
327
+ catch (error) {
328
+ log.error('self-update: could not write the resource limits drop-in', {
329
+ error: describe(error),
330
+ hint: 'run `devbridge-runner doctor --fix` on the server',
331
+ });
332
+ }
204
333
  log.info('self-update: installed', { fromVersion, toVersion });
205
334
  return {
206
335
  ok: true,
@@ -210,12 +339,31 @@ export async function selfUpdate(options) {
210
339
  rollbackTarball,
211
340
  };
212
341
  }
342
+ /**
343
+ * The reason a child process failed, in 400 characters that are actually about
344
+ * the failure.
345
+ *
346
+ * npm puts its diagnosis at the END of stderr and everything it merely wants to
347
+ * mention at the beginning. Taking the first 400 characters is therefore exactly
348
+ * backwards: a real `npm error code EEXIST` was reported to the owner as a wall
349
+ * of ERESOLVE peer-dependency warnings about zod, which are harmless and always
350
+ * present. So: keep the `npm error` lines when there are any, and otherwise keep
351
+ * the tail rather than the head.
352
+ */
213
353
  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);
354
+ const raw = error instanceof Error
355
+ ? (() => {
356
+ const withStderr = error;
357
+ const stderr = typeof withStderr.stderr === 'string' ? withStderr.stderr.trim() : '';
358
+ return stderr || error.message;
359
+ })()
360
+ : String(error);
361
+ const npmErrors = raw
362
+ .split('\n')
363
+ .filter((line) => /^\s*npm (error|ERR!)/i.test(line))
364
+ .join('\n')
365
+ .trim();
366
+ const meaningful = npmErrors || raw;
367
+ return meaningful.length > 400 ? `…${meaningful.slice(-400)}` : meaningful;
220
368
  }
221
369
  //# sourceMappingURL=self-update.js.map
@@ -0,0 +1,79 @@
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(home?: string): string;
23
+ export declare function buildUnit(execStart?: string): string;
24
+ /**
25
+ * Resource policy for the service, and why it does not live in the unit above.
26
+ *
27
+ * The unit is written ONCE, at install time. Until 0.21.0 it carried
28
+ * `CPUQuota=80%` and `MemoryMax=2G` — and those two lines cost a production
29
+ * server every session it was running (QA-112 BLOCKER-1, 2026-07-30):
30
+ *
31
+ * - every agent the runner starts, and every `pnpm build`/`tsc`/`vitest` that
32
+ * agent runs, is a CHILD of this service and therefore inside this cgroup.
33
+ * Three measured `claude` processes are 512/577/646 MB and one workspace
34
+ * `pnpm -r typecheck` peaks at 1571 MB, against a ceiling of 2048 MB;
35
+ * - `OOMPolicy` defaults to `stop`, so the kernel killing ONE of those children
36
+ * tore down the whole service — and with it every other session on the
37
+ * machine. `Restart=always` then brought the daemon back with an empty
38
+ * session map, which is what the user sees as
39
+ * «Runner reconnected. The session was resumed»;
40
+ * - `CPUQuota=80%` is 0.8 of ONE core for all of the above, which is separately
41
+ * what starves the event loop until the gateway's heartbeat gives up on it.
42
+ *
43
+ * So the policy is versioned and shipped as a drop-in instead. A drop-in can
44
+ * RESET a directive the main unit set (`MemoryMax=` with no value clears it),
45
+ * which is the only way to fix the servers that already have the bad numbers
46
+ * baked in — and it never overwrites a unit the operator edited by hand.
47
+ */
48
+ export declare const LIMITS_VERSION = 2;
49
+ export declare function limitsOverridePath(home?: string): string;
50
+ /**
51
+ * `CPUQuota` worth keeping: enough headroom that a runaway build cannot make the
52
+ * box unreachable, but never so little that ordinary work is throttled.
53
+ *
54
+ * One core is reserved for the system (sshd, journald, the runner's own event
55
+ * loop). Below four cores there is nothing to reserve without crippling the
56
+ * agents, so no quota is set at all — an unusable dev server is a worse failure
57
+ * than a busy one.
58
+ */
59
+ export declare function cpuQuotaPercent(cpuCount?: number): number | null;
60
+ export declare function buildLimitsOverride(cpuCount?: number): string;
61
+ /**
62
+ * Is the shipped resource policy missing or from an older runner?
63
+ *
64
+ * Deliberately version-based rather than content-based: an operator may add
65
+ * their own directives to our file, and re-writing on every start would fight
66
+ * them. Only the version number decides.
67
+ */
68
+ export declare function limitsOverrideIsOutdated(readFile?: (p: string) => string, home?: string): boolean;
69
+ /** Write the drop-in. Returns false when nothing needed doing. */
70
+ export declare function writeLimitsOverride(force?: boolean, home?: string): boolean;
71
+ /**
72
+ * Does the installed unit point at something that no longer exists?
73
+ *
74
+ * Used after an update: if the package moved (a rename), a unit pinned to the old
75
+ * directory would kill the runner on the very restart the update asks for. Only a
76
+ * missing target counts — a unit the user edited on purpose is left alone.
77
+ */
78
+ export declare function unitIsBroken(readFile?: (p: string) => string): boolean;
79
+ //# sourceMappingURL=service-unit.d.ts.map