@nordbyte/nordrelay 0.4.0 → 0.4.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.
@@ -0,0 +1,372 @@
1
+ import { formatAgentFeatureSummaryHTML, formatAgentFeatureSummaryPlain } from "./agent-feature-matrix.js";
2
+ import { totalArtifactSize } from "./artifacts.js";
3
+ import { escapeHTML } from "./format.js";
4
+ import { getAgentUpdateLogPath, getUpdateLogPath } from "./operations.js";
5
+ import { formatFileSize } from "./session-format.js";
6
+ export function renderChannelsAction(descriptors) {
7
+ const plain = [
8
+ "Channel adapters:",
9
+ ...descriptors.map((descriptor) => {
10
+ const status = descriptor.status === "available" ? "available" : "planned";
11
+ return `${descriptor.label}: ${status} · ${descriptor.capabilities.join(", ")}`;
12
+ }),
13
+ ].join("\n");
14
+ const html = [
15
+ "<b>Channel adapters:</b>",
16
+ ...descriptors.map((descriptor) => {
17
+ const statusIcon = descriptor.status === "available" ? "✅" : "🟡";
18
+ const notes = descriptor.notes ? `\n ${escapeHTML(descriptor.notes)}` : "";
19
+ return `${statusIcon} <b>${escapeHTML(descriptor.label)}</b> <code>${escapeHTML(descriptor.status)}</code>\n <code>${escapeHTML(descriptor.capabilities.join(", "))}</code>${notes}`;
20
+ }),
21
+ ].join("\n");
22
+ return { plain, html };
23
+ }
24
+ export function renderAgentsAction(descriptors, enabledAgents) {
25
+ const enabled = new Set(enabledAgents);
26
+ const plain = [
27
+ "Agent adapters:",
28
+ ...descriptors.flatMap((descriptor) => [
29
+ `${descriptor.label}: ${descriptor.status}${descriptor.status === "available" ? ` · ${enabled.has(descriptor.id) ? "enabled" : "disabled"}` : ""}`,
30
+ ...formatAgentFeatureSummaryPlain(descriptor.capabilities).map((line) => ` ${line}`),
31
+ ]),
32
+ ].join("\n");
33
+ const html = [
34
+ "<b>Agent adapters:</b>",
35
+ ...descriptors.map((descriptor) => {
36
+ const status = descriptor.status === "available" ? `${enabled.has(descriptor.id) ? "enabled" : "disabled"}` : "planned";
37
+ const notes = descriptor.notes ? `\n ${escapeHTML(descriptor.notes)}` : "";
38
+ return [
39
+ `${descriptor.status === "available" ? "✅" : "🟡"} <b>${escapeHTML(descriptor.label)}</b> <code>${escapeHTML(status)}</code>${notes}`,
40
+ ...formatAgentFeatureSummaryHTML(descriptor.capabilities).map((line) => ` ${line}`),
41
+ ].join("\n");
42
+ }),
43
+ ].join("\n");
44
+ return { plain, html };
45
+ }
46
+ export function renderAgentUpdatePickerAction(descriptors) {
47
+ const available = descriptors.filter((descriptor) => descriptor.status === "available");
48
+ const buttons = available.map((descriptor) => [{ label: `Update ${descriptor.label}`, action: `agent-update:start:${descriptor.id}` }]);
49
+ buttons.push([{ label: "Show update jobs", action: "agent-update:jobs" }]);
50
+ return {
51
+ plain: [
52
+ "Agent updates:",
53
+ ...available.map((descriptor) => `${descriptor.label}: /update ${descriptor.id}`),
54
+ "",
55
+ "Use /update jobs to list running and recent agent updates.",
56
+ ].join("\n"),
57
+ html: [
58
+ "<b>Agent updates:</b>",
59
+ ...available.map((descriptor) => `<b>${escapeHTML(descriptor.label)}:</b> <code>/update ${escapeHTML(descriptor.id)}</code>`),
60
+ "",
61
+ "Use <code>/update jobs</code> to list running and recent agent updates.",
62
+ ].join("\n"),
63
+ buttons,
64
+ };
65
+ }
66
+ export function parseAgentUpdateId(value) {
67
+ const normalized = value?.toLowerCase();
68
+ if (!normalized) {
69
+ return null;
70
+ }
71
+ if (normalized === "claude") {
72
+ return "claude-code";
73
+ }
74
+ return ["codex", "pi", "hermes", "openclaw", "claude-code"].includes(normalized)
75
+ ? normalized
76
+ : null;
77
+ }
78
+ export function renderAgentUpdateJobsAction(jobs) {
79
+ if (jobs.length === 0) {
80
+ return {
81
+ plain: "No agent update jobs yet. Use /update agents to start one.",
82
+ html: "No agent update jobs yet. Use <code>/update agents</code> to start one.",
83
+ };
84
+ }
85
+ const limited = jobs.slice(0, 10);
86
+ return {
87
+ plain: [
88
+ "Agent update jobs:",
89
+ ...limited.map((job) => `${job.id}: ${job.agentLabel} · ${job.status} · ${formatLocalDateTime(new Date(job.updatedAt))}`),
90
+ "",
91
+ "Use /update log <id>, /update cancel <id>, or /update input <id> <text>.",
92
+ ].join("\n"),
93
+ html: [
94
+ "<b>Agent update jobs:</b>",
95
+ ...limited.map((job) => `<code>${escapeHTML(job.id)}</code> ${escapeHTML(job.agentLabel)} · <b>${escapeHTML(job.status)}</b> · <code>${escapeHTML(formatLocalDateTime(new Date(job.updatedAt)))}</code>`),
96
+ "",
97
+ "Use <code>/update log &lt;id&gt;</code>, <code>/update cancel &lt;id&gt;</code>, or <code>/update input &lt;id&gt; &lt;text&gt;</code>.",
98
+ ].join("\n"),
99
+ };
100
+ }
101
+ export function renderAgentUpdateJobAction(job) {
102
+ const command = [job.command, ...job.args].join(" ");
103
+ const inputLine = job.canInput
104
+ ? `If the updater asks a question, reply with /update input ${job.id} <text>.`
105
+ : "This update job is no longer accepting input.";
106
+ const tail = trimLine(job.outputTail || "(waiting for output)", 1200);
107
+ return {
108
+ plain: [
109
+ `${job.agentLabel} update ${job.status}.`,
110
+ `ID: ${job.id}`,
111
+ `Method: ${job.method}`,
112
+ `Command: ${command}`,
113
+ `Started: ${formatLocalDateTime(new Date(job.startedAt))}`,
114
+ job.finishedAt ? `Finished: ${formatLocalDateTime(new Date(job.finishedAt))}` : undefined,
115
+ job.error ? `Error: ${job.error}` : undefined,
116
+ `Log: ${job.logPath}`,
117
+ `Agent update log: ${getAgentUpdateLogPath()}`,
118
+ inputLine,
119
+ "",
120
+ tail,
121
+ ].filter(Boolean).join("\n"),
122
+ html: [
123
+ `<b>${escapeHTML(job.agentLabel)} update ${escapeHTML(job.status)}.</b>`,
124
+ `<b>ID:</b> <code>${escapeHTML(job.id)}</code>`,
125
+ `<b>Method:</b> <code>${escapeHTML(job.method)}</code>`,
126
+ `<b>Command:</b> <code>${escapeHTML(command)}</code>`,
127
+ `<b>Started:</b> <code>${escapeHTML(formatLocalDateTime(new Date(job.startedAt)))}</code>`,
128
+ job.finishedAt ? `<b>Finished:</b> <code>${escapeHTML(formatLocalDateTime(new Date(job.finishedAt)))}</code>` : undefined,
129
+ job.error ? `<b>Error:</b> ${escapeHTML(job.error)}` : undefined,
130
+ `<b>Log:</b> <code>${escapeHTML(job.logPath)}</code>`,
131
+ `<b>Agent update log:</b> <code>${escapeHTML(getAgentUpdateLogPath())}</code>`,
132
+ escapeHTML(inputLine),
133
+ "",
134
+ `<pre>${escapeHTML(tail)}</pre>`,
135
+ ].filter(Boolean).join("\n"),
136
+ buttons: [
137
+ [
138
+ { label: "Full log", action: `agent-update:log:${job.id}` },
139
+ ...(job.canInput ? [{ label: "Cancel", action: `agent-update:cancel:${job.id}` }] : []),
140
+ ],
141
+ ],
142
+ };
143
+ }
144
+ export function renderAgentUpdateLogAction(result) {
145
+ const tail = trimLine(result.plain || "(empty)", 3000);
146
+ return {
147
+ plain: [
148
+ `${result.job.agentLabel} update log`,
149
+ `ID: ${result.job.id}`,
150
+ `Status: ${result.job.status}`,
151
+ `File: ${result.job.logPath}`,
152
+ "",
153
+ tail,
154
+ ].join("\n"),
155
+ html: [
156
+ `<b>${escapeHTML(result.job.agentLabel)} update log</b>`,
157
+ `<b>ID:</b> <code>${escapeHTML(result.job.id)}</code>`,
158
+ `<b>Status:</b> <code>${escapeHTML(result.job.status)}</code>`,
159
+ `<b>File:</b> <code>${escapeHTML(result.job.logPath)}</code>`,
160
+ "",
161
+ `<pre>${escapeHTML(tail)}</pre>`,
162
+ ].join("\n"),
163
+ };
164
+ }
165
+ export function renderSelfUpdateStartedAction(update) {
166
+ return {
167
+ plain: [
168
+ "Update started.",
169
+ `Method: ${update.method}`,
170
+ update.summary,
171
+ `Source: ${update.sourceRoot}`,
172
+ `Log: ${update.logPath}`,
173
+ "Use /logs update after the restart or inspect update.log on the host.",
174
+ "Use /update agents for agent CLI updates.",
175
+ ].join("\n"),
176
+ html: [
177
+ "<b>Update started.</b>",
178
+ `<b>Method:</b> <code>${escapeHTML(update.method)}</code>`,
179
+ escapeHTML(update.summary),
180
+ `<b>Source:</b> <code>${escapeHTML(update.sourceRoot)}</code>`,
181
+ `<b>Log:</b> <code>${escapeHTML(update.logPath)}</code>`,
182
+ `Use <code>/logs update</code> after the restart or inspect <code>${escapeHTML(getUpdateLogPath())}</code> on the host.`,
183
+ "Use <code>/update agents</code> for agent CLI updates.",
184
+ ].join("\n"),
185
+ };
186
+ }
187
+ export function parseLogsCommand(argument) {
188
+ const tokens = argument.split(/\s+/).filter(Boolean);
189
+ let target = "connector";
190
+ let lines = 80;
191
+ for (const token of tokens) {
192
+ const normalized = token.toLowerCase();
193
+ if (normalized === "connector" || normalized === "main") {
194
+ target = "connector";
195
+ continue;
196
+ }
197
+ if (normalized === "update" || normalized === "self-update" || normalized === "self") {
198
+ target = "update";
199
+ continue;
200
+ }
201
+ if (normalized === "agent" || normalized === "agents" || normalized === "agent-update" || normalized === "agent-updates") {
202
+ target = "agent-updates";
203
+ continue;
204
+ }
205
+ if (normalized === "all") {
206
+ target = "all";
207
+ continue;
208
+ }
209
+ const parsedLines = Number.parseInt(token, 10);
210
+ if (!Number.isNaN(parsedLines)) {
211
+ lines = parsedLines;
212
+ }
213
+ }
214
+ return { target, lines };
215
+ }
216
+ export function logTailRequests(target) {
217
+ if (target === "all") {
218
+ return [
219
+ { title: "Connector" },
220
+ { title: "Update", path: getUpdateLogPath() },
221
+ { title: "Agent updates", path: getAgentUpdateLogPath() },
222
+ ];
223
+ }
224
+ return [{ title: logTargetTitle(target), path: logTargetPath(target) }];
225
+ }
226
+ export function renderLogTailsAction(logs) {
227
+ return {
228
+ plain: logs.map(({ title, tail }) => renderLogTailPlain(title, tail)).join("\n\n"),
229
+ html: logs.map(({ title, tail }) => renderLogTailHTML(title, tail)).join("\n\n"),
230
+ };
231
+ }
232
+ export function renderArtifactReportsAction(reports) {
233
+ const lines = reports.slice(0, 5).map((report, index) => {
234
+ const size = formatFileSize(totalArtifactSize(report.artifacts));
235
+ const skipped = report.skippedCount > 0 ? `, ${report.skippedCount} skipped` : "";
236
+ return `${index + 1}. ${report.turnId} · ${formatRelativeTime(report.updatedAt)} · ${report.artifacts.length} file${report.artifacts.length === 1 ? "" : "s"} · ${size}${skipped}`;
237
+ });
238
+ const usage = "Tap an action below, or use /artifacts latest, /artifacts zip latest, /artifacts images, /artifacts docs, /artifacts search <text>, or /artifacts delete <turn-id>.";
239
+ return {
240
+ plain: ["Recent artifacts:", ...lines, "", usage].join("\n"),
241
+ html: ["<b>Recent artifacts:</b>", ...lines.map(escapeHTML), "", escapeHTML(usage)].join("\n"),
242
+ };
243
+ }
244
+ export function renderQueueListAction(queue, paused) {
245
+ if (queue.length === 0) {
246
+ return {
247
+ plain: paused ? "Queue is empty and paused." : "Queue is empty.",
248
+ html: escapeHTML(paused ? "Queue is empty and paused." : "Queue is empty."),
249
+ };
250
+ }
251
+ const lines = queue.map((item, index) => {
252
+ const age = formatRelativeTime(new Date(item.createdAt));
253
+ const attempts = item.attempts && item.attempts > 0 ? ` · attempts ${item.attempts}` : "";
254
+ const error = item.lastError ? ` · last error: ${trimLine(item.lastError, 80)}` : "";
255
+ const scheduled = item.notBefore && item.notBefore > Date.now()
256
+ ? `scheduled ${formatLocalDateTime(new Date(item.notBefore))}`
257
+ : index === 0 ? "next" : `after ${index} queued item${index === 1 ? "" : "s"}`;
258
+ return `${index + 1}. ${item.id} · ${age} · ${scheduled}${attempts}${error} · ${item.description}`;
259
+ });
260
+ return {
261
+ plain: [paused ? "Queued prompts (paused):" : "Queued prompts:", ...lines].join("\n"),
262
+ html: [paused ? "<b>Queued prompts:</b> <code>paused</code>" : "<b>Queued prompts:</b>", ...lines.map(escapeHTML)].join("\n"),
263
+ };
264
+ }
265
+ export function renderQueuedPromptDetailAction(item) {
266
+ const lines = [
267
+ "Queued prompt:",
268
+ `ID: ${item.id}`,
269
+ `Created: ${formatLocalDateTime(new Date(item.createdAt))}`,
270
+ item.notBefore ? `Scheduled: ${formatLocalDateTime(new Date(item.notBefore))}` : undefined,
271
+ `Attempts: ${item.attempts ?? 0}`,
272
+ item.lastError ? `Last error: ${item.lastError}` : undefined,
273
+ `Description: ${item.description}`,
274
+ ].filter((line) => Boolean(line));
275
+ return {
276
+ plain: lines.join("\n"),
277
+ html: [
278
+ "<b>Queued prompt:</b>",
279
+ `<b>ID:</b> <code>${escapeHTML(item.id)}</code>`,
280
+ `<b>Created:</b> <code>${escapeHTML(formatLocalDateTime(new Date(item.createdAt)))}</code>`,
281
+ item.notBefore ? `<b>Scheduled:</b> <code>${escapeHTML(formatLocalDateTime(new Date(item.notBefore)))}</code>` : undefined,
282
+ `<b>Attempts:</b> <code>${item.attempts ?? 0}</code>`,
283
+ item.lastError ? `<b>Last error:</b> ${escapeHTML(item.lastError)}` : undefined,
284
+ `<b>Description:</b> ${escapeHTML(item.description)}`,
285
+ ].filter((line) => Boolean(line)).join("\n"),
286
+ };
287
+ }
288
+ function logTargetTitle(target) {
289
+ if (target === "update") {
290
+ return "Update";
291
+ }
292
+ if (target === "agent-updates") {
293
+ return "Agent updates";
294
+ }
295
+ return "Connector";
296
+ }
297
+ function logTargetPath(target) {
298
+ if (target === "update") {
299
+ return getUpdateLogPath();
300
+ }
301
+ if (target === "agent-updates") {
302
+ return getAgentUpdateLogPath();
303
+ }
304
+ return undefined;
305
+ }
306
+ function renderLogTailPlain(title, tail) {
307
+ return [
308
+ `${title} log tail`,
309
+ `File: ${tail.filePath}`,
310
+ `Updated: ${tail.updatedAt ? formatLocalDateTime(tail.updatedAt) : "-"}`,
311
+ `Lines: ${tail.lineCount}/${tail.requestedLines}`,
312
+ "",
313
+ tail.plain || "(empty)",
314
+ ].join("\n");
315
+ }
316
+ function renderLogTailHTML(title, tail) {
317
+ const body = tail.plain
318
+ ? tail.plain.split("\n").map(renderLogLineHTML).join("\n")
319
+ : "<code>(empty)</code>";
320
+ return [
321
+ `<b>${escapeHTML(title)} log tail</b>`,
322
+ `<b>File:</b> <code>${escapeHTML(tail.filePath)}</code>`,
323
+ `<b>Updated:</b> <code>${escapeHTML(tail.updatedAt ? formatLocalDateTime(tail.updatedAt) : "-")}</code>`,
324
+ `<b>Lines:</b> <code>${tail.lineCount}/${tail.requestedLines}</code>`,
325
+ "",
326
+ body,
327
+ ].join("\n");
328
+ }
329
+ function renderLogLineHTML(line) {
330
+ const structured = line.match(/^(?<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}|unknown time\s*)\s+(?<level>INFO|WARN|ERROR)\s+(?<message>.*)$/);
331
+ if (structured?.groups) {
332
+ const level = structured.groups.level;
333
+ const levelHtml = level === "INFO" ? escapeHTML(level) : `<b>${escapeHTML(level)}</b>`;
334
+ return [
335
+ `<code>${escapeHTML(structured.groups.timestamp.trim())}</code>`,
336
+ levelHtml,
337
+ escapeHTML(structured.groups.message),
338
+ ].join(" ");
339
+ }
340
+ return escapeHTML(line);
341
+ }
342
+ function formatLocalDateTime(date) {
343
+ if (Number.isNaN(date.getTime())) {
344
+ return "-";
345
+ }
346
+ return [
347
+ `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`,
348
+ `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`,
349
+ ].join(" ");
350
+ }
351
+ function formatRelativeTime(date) {
352
+ const seconds = Math.max(0, Math.round((Date.now() - date.getTime()) / 1000));
353
+ if (seconds < 60) {
354
+ return `${seconds}s ago`;
355
+ }
356
+ const minutes = Math.round(seconds / 60);
357
+ if (minutes < 60) {
358
+ return `${minutes}m ago`;
359
+ }
360
+ const hours = Math.round(minutes / 60);
361
+ if (hours < 24) {
362
+ return `${hours}h ago`;
363
+ }
364
+ return `${Math.round(hours / 24)}d ago`;
365
+ }
366
+ function trimLine(text, maxLength) {
367
+ const normalized = text.replace(/\s+/g, " ").trim();
368
+ return normalized.length > maxLength ? `${normalized.slice(0, Math.max(0, maxLength - 3))}...` : normalized;
369
+ }
370
+ function pad2(value) {
371
+ return String(value).padStart(2, "0");
372
+ }
@@ -12,7 +12,8 @@ import { describePiCli, resolvePiCli } from "./pi-cli.js";
12
12
  const APP_NAME = "nordrelay";
13
13
  const PACKAGE_NAME = "@nordbyte/nordrelay";
14
14
  const CODEX_PACKAGE_NAME = "@openai/codex";
15
- const PI_PACKAGE_NAME = "@mariozechner/pi-coding-agent";
15
+ const PI_PACKAGE_NAME = "@earendil-works/pi-coding-agent";
16
+ const LEGACY_PI_PACKAGE_NAME = "@mariozechner/pi-coding-agent";
16
17
  const HERMES_PACKAGE_NAME = "hermes-agent";
17
18
  const OPENCLAW_PACKAGE_NAME = "openclaw";
18
19
  const CLAUDE_CODE_PACKAGE_NAME = "@anthropic-ai/claude-code";
@@ -32,6 +33,9 @@ export function getConnectorLogPath() {
32
33
  export function getUpdateLogPath() {
33
34
  return path.join(getConnectorHome(), "update.log");
34
35
  }
36
+ export function getAgentUpdateLogPath(home = getConnectorHome()) {
37
+ return path.join(home, "agent-updates.log");
38
+ }
35
39
  export async function readConnectorState() {
36
40
  try {
37
41
  return JSON.parse(await readFile(getConnectorStatePath(), "utf8"));
@@ -74,6 +78,14 @@ export async function readFormattedLogTail(lines = 80, filePath = getConnectorLo
74
78
  };
75
79
  }
76
80
  }
81
+ export function clearLogFile(filePath = getConnectorLogPath()) {
82
+ mkdirSync(path.dirname(filePath), { recursive: true });
83
+ writeFileSync(filePath, "", "utf8");
84
+ return {
85
+ filePath,
86
+ clearedAt: new Date(),
87
+ };
88
+ }
77
89
  export async function getPackageVersion() {
78
90
  try {
79
91
  const pkg = JSON.parse(await readFile(path.join(getSourceRoot(), "package.json"), "utf8"));
@@ -93,7 +105,10 @@ export async function getVersionChecks(options = {}) {
93
105
  const codexVersionLabel = codexCli.path
94
106
  ? detectCliVersion(codexCli.path)
95
107
  : readInstalledPackageVersion(CODEX_PACKAGE_NAME) ?? "not installed";
96
- const piVersionLabel = piCli.path ? detectCliVersion(piCli.path) : "not installed";
108
+ const piVersionLabel = piCli.path
109
+ ? detectCliVersion(piCli.path)
110
+ : readInstalledPackageVersion(PI_PACKAGE_NAME) ?? readInstalledPackageVersion(LEGACY_PI_PACKAGE_NAME) ?? "not installed";
111
+ const legacyPiPackageVersion = readInstalledPackageVersion(LEGACY_PI_PACKAGE_NAME);
97
112
  const hermesVersionLabel = hermesCli.path ? detectCliVersion(hermesCli.path) : "not installed";
98
113
  const openClawVersionLabel = openClawCli.path ? detectCliVersion(openClawCli.path) : "not installed";
99
114
  const claudeCodeVersionLabel = claudeCodeCli.path
@@ -120,6 +135,7 @@ export async function getVersionChecks(options = {}) {
120
135
  installedLabel: piVersionLabel,
121
136
  installedVersion: extractVersion(piVersionLabel),
122
137
  notInstalled: piVersionLabel === "not installed",
138
+ detail: legacyPiPackageVersion ? `Legacy package ${LEGACY_PI_PACKAGE_NAME} is present; current package is ${PI_PACKAGE_NAME}.` : undefined,
123
139
  }),
124
140
  hermes: buildHermesVersionCheck(hermesVersionLabel),
125
141
  openclaw: buildVersionCheck({
@@ -139,10 +155,11 @@ export async function getVersionChecks(options = {}) {
139
155
  };
140
156
  }
141
157
  export async function getConnectorHealth(options = {}) {
142
- const state = await readConnectorState();
158
+ const rawState = await readConnectorState();
143
159
  const version = await getPackageVersion();
144
- const pidRunning = isProcessRunning(state.pid);
145
- const appPidRunning = isProcessRunning(state.appPid);
160
+ const pidRunning = isProcessRunning(rawState.pid);
161
+ const appPidRunning = isProcessRunning(rawState.appPid);
162
+ const state = normalizeConnectorState(rawState, pidRunning, appPidRunning);
146
163
  const codexCli = resolveCodexCli();
147
164
  const piCli = resolvePiCli(process.env, options.piCliPath);
148
165
  const hermesCli = resolveHermesCli(process.env, options.hermesCliPath);
@@ -247,6 +264,13 @@ function isProcessRunning(pid) {
247
264
  return false;
248
265
  }
249
266
  }
267
+ function normalizeConnectorState(state, pidRunning, appPidRunning) {
268
+ const stoppedSignal = state.signal === "SIGTERM" || state.signal === "SIGINT";
269
+ if (state.status === "error" && stoppedSignal && !state.error && !pidRunning && !appPidRunning) {
270
+ return { ...state, status: "stopped" };
271
+ }
272
+ return state;
273
+ }
250
274
  function redactSecrets(text) {
251
275
  return text.replace(SECRET_RE, "$1$2[redacted]");
252
276
  }
@@ -328,6 +352,7 @@ function buildVersionCheck(options) {
328
352
  installedVersion: null,
329
353
  latestVersion: null,
330
354
  status: "not-installed",
355
+ detail: options.detail,
331
356
  };
332
357
  }
333
358
  if (options.skipLatest) {
@@ -338,7 +363,7 @@ function buildVersionCheck(options) {
338
363
  installedVersion: options.installedVersion,
339
364
  latestVersion: null,
340
365
  status: options.installedVersion ? "unknown" : "unknown",
341
- detail: "Latest-version lookup is not available for this package source",
366
+ detail: options.detail ?? "Latest-version lookup is not available for this package source",
342
367
  };
343
368
  }
344
369
  const latest = detectLatestNpmVersion(options.packageName);
@@ -350,7 +375,7 @@ function buildVersionCheck(options) {
350
375
  installedVersion: options.installedVersion,
351
376
  latestVersion: latest.version,
352
377
  status: "unknown",
353
- detail: latest.error ?? "Could not parse installed version",
378
+ detail: [options.detail, latest.error ?? "Could not parse installed version"].filter(Boolean).join(" "),
354
379
  };
355
380
  }
356
381
  return {
@@ -360,7 +385,7 @@ function buildVersionCheck(options) {
360
385
  installedVersion: options.installedVersion,
361
386
  latestVersion: latest.version,
362
387
  status: compareVersions(options.installedVersion, latest.version) < 0 ? "outdated" : "current",
363
- detail: latest.error,
388
+ detail: [options.detail, latest.error].filter(Boolean).join(" ") || undefined,
364
389
  };
365
390
  }
366
391
  function detectLatestNpmVersion(packageName) {