total-recall-brain 3.24.2 → 3.25.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/docs/ARCHITECTURE.md +5 -5
- package/frontend/dist/assets/{index-BQL-opnK.js → index-DWCNvgnv.js} +128 -128
- package/frontend/dist/index.html +1 -1
- package/frontend/src/api/mesh.ts +24 -0
- package/frontend/src/api/system.ts +26 -0
- package/frontend/src/api/update.ts +16 -0
- package/frontend/src/components/TailnetEnroll.spec.tsx +118 -0
- package/frontend/src/components/TailnetEnroll.tsx +237 -0
- package/frontend/src/components/brand/BrandMark.tsx +1 -1
- package/frontend/src/index.css +1 -1
- package/frontend/src/pages/SettingsPage.spec.tsx +31 -0
- package/frontend/src/pages/SettingsPage.tsx +59 -2
- package/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/SKILL.md +3 -2
- package/scaffold/.agent/skills/total-recall/references/api-reference.md +1 -1
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +1 -1
- package/scaffold/.agent/skills/total-recall/skills-registry/index.yaml +3 -2461
- package/src/cli/mesh.mjs +9 -0
- package/src/core/server-restart.mjs +130 -0
- package/src/server/route-manifest.json +8 -0
- package/src/server/routes/mesh.mjs +40 -0
- package/src/server/routes/system.mjs +38 -1
- package/src/server/routes/update.mjs +67 -8
package/src/cli/mesh.mjs
CHANGED
|
@@ -108,6 +108,15 @@ export default async function meshCli(argv = []) {
|
|
|
108
108
|
const vaultRoot = path.join(brainDir, 'memory-vault');
|
|
109
109
|
const command = args.shift();
|
|
110
110
|
|
|
111
|
+
// `--help` after a subcommand must never EXECUTE that subcommand. Only the
|
|
112
|
+
// bare `mesh --help` was handled, so `mesh preauthkey --help` fell straight
|
|
113
|
+
// into the preauthkey branch and MINTED A LIVE ENROLLMENT KEY for someone who
|
|
114
|
+
// had asked for usage text — a credential issued by a help flag.
|
|
115
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
116
|
+
printHelp();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
111
120
|
try {
|
|
112
121
|
if (command === 'status') {
|
|
113
122
|
const info = await describeHeadscaleAvailability(brainDir);
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server-restart — restart the brain server so freshly installed code is the
|
|
3
|
+
* code that is actually running.
|
|
4
|
+
*
|
|
5
|
+
* Updating did not restart anything. `POST /api/update/run` installed
|
|
6
|
+
* total-recall-brain@latest and returned a summary; the server kept serving the
|
|
7
|
+
* modules Node had already loaded, and the update banner cleared — so a finished
|
|
8
|
+
* update looked identical to a running one. Only the daemon had a restart
|
|
9
|
+
* control; the server had none.
|
|
10
|
+
*
|
|
11
|
+
* The hard rule here: NEVER exit unless a supervisor will bring us back.
|
|
12
|
+
* Exiting an unsupervised server is not a restart, it is an outage, so
|
|
13
|
+
* supervision is proven (the job's reported PID is ours) rather than assumed.
|
|
14
|
+
*/
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
const LAUNCHD_LABELS = ['com.totalrecall.brain', 'com.totalrecall.server'];
|
|
20
|
+
const SYSTEMD_UNITS = ['total-recall-server', 'total-recall-server.service'];
|
|
21
|
+
|
|
22
|
+
function launchdPidFor(label) {
|
|
23
|
+
try {
|
|
24
|
+
const out = execFileSync('launchctl', ['list', label], { encoding: 'utf8', stdio: 'pipe' });
|
|
25
|
+
const m = out.match(/"PID"\s*=\s*(\d+)/);
|
|
26
|
+
return m ? Number(m[1]) : null;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function systemdPidFor(unit) {
|
|
33
|
+
for (const scope of [['--user'], []]) {
|
|
34
|
+
try {
|
|
35
|
+
const out = execFileSync('systemctl', [...scope, 'show', '-p', 'MainPID', '--value', unit], {
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
stdio: 'pipe',
|
|
38
|
+
});
|
|
39
|
+
const pid = Number(String(out).trim());
|
|
40
|
+
if (pid > 0) return { pid, scope: scope.length ? 'user' : 'system' };
|
|
41
|
+
} catch {
|
|
42
|
+
/* unit not present in this scope */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Prove that a supervisor owns THIS process and will restart it.
|
|
50
|
+
* @returns {{supervised: boolean, kind: string|null, label: string|null, reason: string}}
|
|
51
|
+
*/
|
|
52
|
+
export function detectSupervisor(pid = process.pid) {
|
|
53
|
+
// An explicit opt-in for hosts running under a supervisor we cannot query
|
|
54
|
+
// (docker restart policies, pm2, runit). The operator asserts respawn.
|
|
55
|
+
if (process.env.TR_SUPERVISED === '1') {
|
|
56
|
+
return { supervised: true, kind: 'declared', label: 'TR_SUPERVISED=1', reason: 'declared by operator' };
|
|
57
|
+
}
|
|
58
|
+
if (process.platform === 'darwin') {
|
|
59
|
+
for (const label of LAUNCHD_LABELS) {
|
|
60
|
+
const jobPid = launchdPidFor(label);
|
|
61
|
+
if (jobPid === pid) {
|
|
62
|
+
return { supervised: true, kind: 'launchd', label, reason: `launchd job ${label} owns pid ${pid}` };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { supervised: false, kind: null, label: null, reason: 'no launchd job reports this pid' };
|
|
66
|
+
}
|
|
67
|
+
for (const unit of SYSTEMD_UNITS) {
|
|
68
|
+
const found = systemdPidFor(unit);
|
|
69
|
+
if (found && found.pid === pid) {
|
|
70
|
+
return { supervised: true, kind: 'systemd', label: unit, reason: `systemd ${found.scope} unit ${unit} owns pid ${pid}` };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { supervised: false, kind: null, label: null, reason: 'no systemd unit reports this pid' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Schedule a graceful self-restart.
|
|
78
|
+
*
|
|
79
|
+
* Sends SIGTERM to ourselves rather than calling process.exit, so the existing
|
|
80
|
+
* shutdown path runs: the HTTP server drains, background work finishes, and the
|
|
81
|
+
* server PID lock is RELEASED. Skipping that lock release would leave the
|
|
82
|
+
* respawned instance hitting the singleton guard and exiting immediately —
|
|
83
|
+
* turning a restart into a permanent stop.
|
|
84
|
+
*
|
|
85
|
+
* The delay lets the HTTP response flush before the socket goes away.
|
|
86
|
+
*
|
|
87
|
+
* @returns {{scheduled: boolean, supervisor: object, delayMs?: number, reason?: string}}
|
|
88
|
+
*/
|
|
89
|
+
export function requestSelfRestart({ delayMs = 750, force = false } = {}) {
|
|
90
|
+
const supervisor = detectSupervisor();
|
|
91
|
+
if (!supervisor.supervised && !force) {
|
|
92
|
+
return {
|
|
93
|
+
scheduled: false,
|
|
94
|
+
supervisor,
|
|
95
|
+
reason:
|
|
96
|
+
'refusing to exit: no supervisor would restart this process, so exiting would take the server down permanently. '
|
|
97
|
+
+ 'Run it under launchd/systemd, or set TR_SUPERVISED=1 if another supervisor guarantees respawn.',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const timer = setTimeout(() => {
|
|
101
|
+
process.kill(process.pid, 'SIGTERM');
|
|
102
|
+
}, delayMs);
|
|
103
|
+
timer.unref?.();
|
|
104
|
+
return { scheduled: true, supervisor, delayMs };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The version recorded in a package root's manifest, read from DISK rather than
|
|
109
|
+
* from the ESM import cache.
|
|
110
|
+
*
|
|
111
|
+
* This is how we decide whether an update actually replaced the code backing
|
|
112
|
+
* this process. `summary.updated > 0` cannot answer that: it counts registered
|
|
113
|
+
* projects, and updating some other project's node_modules is no reason to
|
|
114
|
+
* bounce this server. Comparing the manifest on disk against the version this
|
|
115
|
+
* process booted with is directly observable and right in every install shape —
|
|
116
|
+
* source tree (never changes from an npm install, so no restart), or a package
|
|
117
|
+
* installed under a consumer repo (npm replaces the directory we are running
|
|
118
|
+
* from, so restart).
|
|
119
|
+
*
|
|
120
|
+
* @param {string} rootDir package root to read
|
|
121
|
+
* @returns {string|null}
|
|
122
|
+
*/
|
|
123
|
+
export function packageVersionOnDisk(rootDir) {
|
|
124
|
+
try {
|
|
125
|
+
const raw = fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8');
|
|
126
|
+
return JSON.parse(raw).version || null;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -347,6 +347,10 @@
|
|
|
347
347
|
"method": "GET",
|
|
348
348
|
"path": "/api/mesh/nodes"
|
|
349
349
|
},
|
|
350
|
+
{
|
|
351
|
+
"method": "POST",
|
|
352
|
+
"path": "/api/mesh/preauthkey"
|
|
353
|
+
},
|
|
350
354
|
{
|
|
351
355
|
"method": "GET",
|
|
352
356
|
"path": "/api/network/audit"
|
|
@@ -575,6 +579,10 @@
|
|
|
575
579
|
"method": "POST",
|
|
576
580
|
"path": "/api/secrets/usage"
|
|
577
581
|
},
|
|
582
|
+
{
|
|
583
|
+
"method": "POST",
|
|
584
|
+
"path": "/api/server/restart"
|
|
585
|
+
},
|
|
578
586
|
{
|
|
579
587
|
"method": "GET",
|
|
580
588
|
"path": "/api/sessions"
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
readSshConfig,
|
|
17
17
|
resolveNodeAccess,
|
|
18
18
|
} from '../../core/mesh-access.mjs';
|
|
19
|
+
import { createHeadscalePreAuthKey, resolveHeadscaleConfig } from '../../core/headscale-client.mjs';
|
|
19
20
|
import { throttledFetch } from '../../core/throttled-fetch.mjs';
|
|
20
21
|
import { defaultVaultRoot } from '../../core/vfs-documents.mjs';
|
|
21
22
|
import {
|
|
@@ -264,6 +265,45 @@ router.post('/api/mesh/enroll', requireAuth, requireScope('config:write'), async
|
|
|
264
265
|
}
|
|
265
266
|
});
|
|
266
267
|
|
|
268
|
+
/**
|
|
269
|
+
* POST /api/mesh/preauthkey
|
|
270
|
+
* Mint an enrollment key for a NEW device (a phone, a tablet, another laptop).
|
|
271
|
+
*
|
|
272
|
+
* /api/mesh/enroll only ever enrolls THIS host, so adding a phone had no API at
|
|
273
|
+
* all and the only route in was the CLI — which a phone user does not have.
|
|
274
|
+
*
|
|
275
|
+
* Body: { reusable?: boolean, ephemeral?: boolean, ttlMinutes?: number }
|
|
276
|
+
*/
|
|
277
|
+
router.post('/api/mesh/preauthkey', requireAuth, requireScope('config:write'), async (req, res) => {
|
|
278
|
+
try {
|
|
279
|
+
const reusable = req.body?.reusable === true;
|
|
280
|
+
const ephemeral = req.body?.ephemeral === true;
|
|
281
|
+
// Short by default: an enrollment key is a bearer credential, and the window
|
|
282
|
+
// only has to cover walking to the device. Capped so a UI cannot mint a
|
|
283
|
+
// long-lived key by accident.
|
|
284
|
+
const requested = Number(req.body?.ttlMinutes);
|
|
285
|
+
const ttlMinutes = Number.isFinite(requested) ? Math.min(Math.max(requested, 1), 60) : 15;
|
|
286
|
+
|
|
287
|
+
const { key, expiration } = await createHeadscalePreAuthKey(
|
|
288
|
+
{ reusable, ephemeral, ttlMinutes },
|
|
289
|
+
BRAIN_DIR,
|
|
290
|
+
);
|
|
291
|
+
res.json({
|
|
292
|
+
success: true,
|
|
293
|
+
key,
|
|
294
|
+
expiration,
|
|
295
|
+
reusable,
|
|
296
|
+
ephemeral,
|
|
297
|
+
ttl_minutes: ttlMinutes,
|
|
298
|
+
// Surfaced so the UI can show the exact control-server URL to type into a
|
|
299
|
+
// phone; never include the token, only the URL.
|
|
300
|
+
login_server: await resolveHeadscaleConfig(BRAIN_DIR).then((c) => c.url).catch(() => null),
|
|
301
|
+
});
|
|
302
|
+
} catch (err) {
|
|
303
|
+
res.status(500).json({ success: false, message: err.message || 'could not mint enrollment key' });
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
267
307
|
/**
|
|
268
308
|
* This host's I/O capability profile for agent UI generation
|
|
269
309
|
* (screen, touch, mic, speaker, keyboard, camera, headless, …).
|
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* GET /api/logs/:type — Read last 200 lines of server or daemon log
|
|
5
5
|
* POST /api/diagnostics/agents — Run upgrade --agents diagnostics
|
|
6
|
-
*
|
|
6
|
+
* GET /api/pairing — Reachable URLs for the mobile pairing QR
|
|
7
7
|
* GET /api/tasks/failed — List failed tasks from the DLQ
|
|
8
8
|
* POST /api/tasks/:id/retry — Re-queue a failed task
|
|
9
9
|
* GET /api/usage — Usage ledger + cost summary
|
|
10
|
+
* GET /api/usage/providers — Spend as the providers report it
|
|
11
|
+
* POST /api/daemon/restart — Restart the Active Intelligence daemon
|
|
12
|
+
* POST /api/server/restart — Restart this server (supervised hosts only)
|
|
10
13
|
*/
|
|
11
14
|
|
|
12
15
|
import { Router } from 'express';
|
|
@@ -217,5 +220,39 @@ router.post('/api/daemon/restart', requireAuth, requireScope('config:write'), as
|
|
|
217
220
|
}
|
|
218
221
|
});
|
|
219
222
|
|
|
223
|
+
/**
|
|
224
|
+
* POST /api/server/restart
|
|
225
|
+
* Restarts the brain server itself, so freshly installed code is the code that
|
|
226
|
+
* is actually running.
|
|
227
|
+
*
|
|
228
|
+
* `force` is deliberately NOT accepted from HTTP. Forcing an exit on a host
|
|
229
|
+
* where nothing would respawn us is not a restart, it is an outage, and an API
|
|
230
|
+
* caller must not be able to cause one. Hosts under a supervisor we cannot
|
|
231
|
+
* query (docker restart policies, pm2, runit) opt in with TR_SUPERVISED=1.
|
|
232
|
+
*/
|
|
233
|
+
router.post('/api/server/restart', requireAuth, requireScope('config:write'), async (_req, res) => {
|
|
234
|
+
try {
|
|
235
|
+
const { requestSelfRestart } = await import('../../core/server-restart.mjs');
|
|
236
|
+
const result = requestSelfRestart();
|
|
237
|
+
if (!result.scheduled) {
|
|
238
|
+
return res.status(409).json({
|
|
239
|
+
success: false,
|
|
240
|
+
scheduled: false,
|
|
241
|
+
supervisor: result.supervisor,
|
|
242
|
+
message: result.reason,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
logger.info('server', `Restart requested via API — ${result.supervisor.reason}`);
|
|
246
|
+
res.json({
|
|
247
|
+
success: true,
|
|
248
|
+
scheduled: true,
|
|
249
|
+
supervisor: result.supervisor,
|
|
250
|
+
message: `Server restarting (${result.supervisor.label}). It should be back in a few seconds.`,
|
|
251
|
+
});
|
|
252
|
+
} catch (err) {
|
|
253
|
+
serverError(res, err);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
220
257
|
export default router;
|
|
221
258
|
export { router as systemRouter };
|
|
@@ -13,9 +13,35 @@ import {
|
|
|
13
13
|
needsUpdate,
|
|
14
14
|
isPackageAutoUpdateEnabled,
|
|
15
15
|
} from '../../core/package-auto-update.mjs';
|
|
16
|
+
import { packageVersionOnDisk, requestSelfRestart } from '../../core/server-restart.mjs';
|
|
16
17
|
|
|
17
18
|
const router = Router();
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Install into the host root and WAIT for it.
|
|
22
|
+
*
|
|
23
|
+
* This used to be `detached: true` + `unref()`, which meant the endpoint
|
|
24
|
+
* answered "update complete" while npm was still writing files — the same
|
|
25
|
+
* class of lie as reporting an update finished while the old code kept
|
|
26
|
+
* running. We need the exit anyway: the restart decision below reads the
|
|
27
|
+
* manifest on disk, and reading it before npm has replaced it would see the
|
|
28
|
+
* old version and skip the restart.
|
|
29
|
+
*
|
|
30
|
+
* @returns {Promise<{ok: boolean, code?: number, error?: string}>}
|
|
31
|
+
*/
|
|
32
|
+
function installIntoHostRoot(latest) {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const proc = spawn('npm', ['install', `${PACKAGE_NAME}@${latest}`, '--save'], {
|
|
35
|
+
cwd: ROOT,
|
|
36
|
+
stdio: 'ignore',
|
|
37
|
+
});
|
|
38
|
+
// spawn reports a missing binary or bad cwd as an 'error' EVENT, not a
|
|
39
|
+
// throw, and an unhandled 'error' on an EventEmitter kills the server.
|
|
40
|
+
proc.on('error', (err) => resolve({ ok: false, error: err.message }));
|
|
41
|
+
proc.on('close', (code) => resolve({ ok: code === 0, code }));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
19
45
|
router.get('/api/update/check', requireAuth, async (_req, res) => {
|
|
20
46
|
try {
|
|
21
47
|
// Host package (this install)
|
|
@@ -75,6 +101,11 @@ router.post('/api/update/run', requireAuth, requireScope('config:write'), async
|
|
|
75
101
|
const dryRun = Boolean(req.body?.dryRun);
|
|
76
102
|
const force = req.body?.force !== false;
|
|
77
103
|
const roots = Array.isArray(req.body?.roots) ? req.body.roots : undefined;
|
|
104
|
+
const restartWanted = req.body?.restart !== false;
|
|
105
|
+
|
|
106
|
+
// The version this process is actually RUNNING, captured before anything is
|
|
107
|
+
// installed over it.
|
|
108
|
+
const versionBefore = packageVersionOnDisk(ROOT);
|
|
78
109
|
|
|
79
110
|
// Fire multi-project updater (awaits — install can take minutes; client should timeout high)
|
|
80
111
|
const summary = await runPackageAutoUpdate({
|
|
@@ -86,17 +117,13 @@ router.post('/api/update/run', requireAuth, requireScope('config:write'), async
|
|
|
86
117
|
save: true,
|
|
87
118
|
});
|
|
88
119
|
|
|
89
|
-
// Also
|
|
120
|
+
// Also install into ROOT when this host is a consumer (non-source)
|
|
90
121
|
const hostInfo = inspectProjectPackage(ROOT);
|
|
122
|
+
let hostInstall = null;
|
|
91
123
|
if (!hostInfo.isSourceTree && (hostInfo.declared || hostInfo.installed) && !dryRun) {
|
|
92
124
|
const latest = summary.latest || (await fetchLatestNpmVersionAsync());
|
|
93
125
|
if (latest && needsUpdate(hostInfo.installed, latest)) {
|
|
94
|
-
|
|
95
|
-
cwd: ROOT,
|
|
96
|
-
detached: true,
|
|
97
|
-
stdio: 'ignore',
|
|
98
|
-
});
|
|
99
|
-
proc.unref();
|
|
126
|
+
hostInstall = await installIntoHostRoot(latest);
|
|
100
127
|
}
|
|
101
128
|
}
|
|
102
129
|
|
|
@@ -114,17 +141,49 @@ router.post('/api/update/run', requireAuth, requireScope('config:write'), async
|
|
|
114
141
|
if (failed) parts.push(`${failed} failed`);
|
|
115
142
|
const detail = parts.length ? parts.join(', ') : 'no projects checked';
|
|
116
143
|
|
|
144
|
+
// Restart only when the update replaced the code backing THIS process.
|
|
145
|
+
// Some other project's node_modules changing is no reason to bounce the
|
|
146
|
+
// server, and a source tree is never touched by an npm install at all.
|
|
147
|
+
const versionAfter = packageVersionOnDisk(ROOT);
|
|
148
|
+
const selfReplaced = Boolean(versionBefore && versionAfter && versionBefore !== versionAfter);
|
|
149
|
+
let restart = {
|
|
150
|
+
scheduled: false,
|
|
151
|
+
required: selfReplaced,
|
|
152
|
+
reason: selfReplaced
|
|
153
|
+
? 'restart not requested'
|
|
154
|
+
: 'this server is already running the installed code',
|
|
155
|
+
};
|
|
156
|
+
if (selfReplaced && !dryRun && restartWanted) {
|
|
157
|
+
const outcome = requestSelfRestart();
|
|
158
|
+
restart = {
|
|
159
|
+
scheduled: outcome.scheduled,
|
|
160
|
+
required: true,
|
|
161
|
+
supervisor: outcome.supervisor,
|
|
162
|
+
reason: outcome.scheduled
|
|
163
|
+
? `restarting into v${versionAfter} (${outcome.supervisor.label})`
|
|
164
|
+
: outcome.reason,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const restartNote = restart.scheduled
|
|
169
|
+
? ` Restarting into v${versionAfter} now.`
|
|
170
|
+
: restart.required
|
|
171
|
+
? ` This server is still running v${versionBefore} — restart it to pick up v${versionAfter}.`
|
|
172
|
+
: '';
|
|
173
|
+
|
|
117
174
|
res.json({
|
|
118
175
|
success: ok,
|
|
119
176
|
updating: !dryRun && ok,
|
|
120
177
|
message: dryRun
|
|
121
178
|
? `Dry-run complete — ${detail}`
|
|
122
179
|
: ok
|
|
123
|
-
? `Package auto-update finished for registered projects (${detail}). Latest: ${summary?.latest || 'n/a'}`
|
|
180
|
+
? `Package auto-update finished for registered projects (${detail}). Latest: ${summary?.latest || 'n/a'}.${restartNote}`
|
|
124
181
|
: summary?.reason === 'npm-view-failed'
|
|
125
182
|
? 'Could not resolve latest version from npm (network or registry). Try again.'
|
|
126
183
|
: `Package auto-update finished with errors (${detail}). Latest: ${summary?.latest || 'n/a'}`,
|
|
127
184
|
summary,
|
|
185
|
+
host_install: hostInstall,
|
|
186
|
+
restart,
|
|
128
187
|
});
|
|
129
188
|
} catch (err) {
|
|
130
189
|
serverError(res, err);
|