@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 +34 -29
- package/dist/self-update.js +144 -23
- package/dist/service-unit.d.ts +32 -0
- package/dist/service-unit.js +106 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
288
|
-
fs.mkdirSync(
|
|
289
|
-
const
|
|
290
|
-
fs.writeFileSync(
|
|
291
|
-
print(`Wrote ${
|
|
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']);
|
package/dist/self-update.js
CHANGED
|
@@ -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
|
|
86
|
+
function readManifest(packageDir) {
|
|
86
87
|
try {
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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.
|
|
1
|
+
export declare const RUNNER_VERSION = "0.13.1";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bridge4dev/runner",
|
|
3
|
-
"version": "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": "
|
|
10
|
+
"devbridge-runner": "dist/index.js"
|
|
11
11
|
},
|
|
12
12
|
"keywords": [
|
|
13
13
|
"devbridge",
|