@murmurv2/cli 2.11.0

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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/bin/murmur-npm.mjs +6 -0
  4. package/package.json +42 -0
  5. package/runtime/bin/murmur-svc.exe +0 -0
  6. package/runtime/npm-runtime-manifest.json +299 -0
  7. package/runtime/package.json +1 -0
  8. package/runtime/packages/mcp-server/dist/src/index.js +1 -0
  9. package/runtime/packages/mcp-server/package.json +1 -0
  10. package/runtime/packages/setup/bin/murmur.mjs +26 -0
  11. package/runtime/packages/setup/dist/src/appdata.d.ts +9 -0
  12. package/runtime/packages/setup/dist/src/appdata.js +53 -0
  13. package/runtime/packages/setup/dist/src/cli.d.ts +9 -0
  14. package/runtime/packages/setup/dist/src/cli.js +130 -0
  15. package/runtime/packages/setup/dist/src/clients.d.ts +151 -0
  16. package/runtime/packages/setup/dist/src/clients.js +251 -0
  17. package/runtime/packages/setup/dist/src/commands.d.ts +35 -0
  18. package/runtime/packages/setup/dist/src/commands.js +115 -0
  19. package/runtime/packages/setup/dist/src/config.d.ts +49 -0
  20. package/runtime/packages/setup/dist/src/config.js +70 -0
  21. package/runtime/packages/setup/dist/src/doctor.d.ts +25 -0
  22. package/runtime/packages/setup/dist/src/doctor.js +174 -0
  23. package/runtime/packages/setup/dist/src/file-identity.d.ts +3 -0
  24. package/runtime/packages/setup/dist/src/file-identity.js +11 -0
  25. package/runtime/packages/setup/dist/src/index.d.ts +7 -0
  26. package/runtime/packages/setup/dist/src/index.js +7 -0
  27. package/runtime/packages/setup/dist/src/onboarding.d.ts +46 -0
  28. package/runtime/packages/setup/dist/src/onboarding.js +298 -0
  29. package/runtime/packages/setup/dist/src/outbox-attention.d.ts +51 -0
  30. package/runtime/packages/setup/dist/src/outbox-attention.js +98 -0
  31. package/runtime/packages/setup/dist/src/paths.d.ts +10 -0
  32. package/runtime/packages/setup/dist/src/paths.js +41 -0
  33. package/runtime/packages/setup/dist/src/platform/darwin.d.ts +17 -0
  34. package/runtime/packages/setup/dist/src/platform/darwin.js +270 -0
  35. package/runtime/packages/setup/dist/src/platform/linux.d.ts +9 -0
  36. package/runtime/packages/setup/dist/src/platform/linux.js +176 -0
  37. package/runtime/packages/setup/dist/src/platform/windows.d.ts +25 -0
  38. package/runtime/packages/setup/dist/src/platform/windows.js +189 -0
  39. package/runtime/packages/setup/dist/src/private-file.d.ts +5 -0
  40. package/runtime/packages/setup/dist/src/private-file.js +101 -0
  41. package/runtime/packages/setup/dist/src/reply-test.d.ts +25 -0
  42. package/runtime/packages/setup/dist/src/reply-test.js +75 -0
  43. package/runtime/packages/setup/dist/src/state.d.ts +3 -0
  44. package/runtime/packages/setup/dist/src/state.js +12 -0
  45. package/runtime/packages/setup/dist/src/status-line.d.ts +5 -0
  46. package/runtime/packages/setup/dist/src/status-line.js +19 -0
  47. package/runtime/packages/setup/dist/src/status.d.ts +137 -0
  48. package/runtime/packages/setup/dist/src/status.js +199 -0
  49. package/runtime/packages/setup/dist/src/types.d.ts +40 -0
  50. package/runtime/packages/setup/dist/src/types.js +1 -0
  51. package/runtime/packages/setup/dist/src/updates.d.ts +46 -0
  52. package/runtime/packages/setup/dist/src/updates.js +284 -0
  53. package/runtime/packages/setup/dist/src/verdict.d.ts +11 -0
  54. package/runtime/packages/setup/dist/src/verdict.js +159 -0
  55. package/runtime/packages/setup/package.json +1 -0
  56. package/runtime/plugins/claude-code/.claude-plugin/plugin.json +34 -0
  57. package/runtime/plugins/claude-code/.mcp.json +8 -0
  58. package/runtime/plugins/claude-code/scripts/configure-statusline.mjs +124 -0
  59. package/runtime/plugins/claude-code/scripts/statusline.mjs +210 -0
  60. package/runtime/plugins/claude-code/skills/inbox/SKILL.md +8 -0
  61. package/runtime/plugins/claude-code/skills/mark-read/SKILL.md +13 -0
  62. package/runtime/plugins/claude-code/skills/status/SKILL.md +13 -0
  63. package/runtime/scripts/ack-security.mjs +22 -0
  64. package/runtime/scripts/codex-app-server-wake.mjs +696 -0
  65. package/runtime/scripts/daemon-observation.mjs +48 -0
  66. package/runtime/scripts/doctor-protocol.mjs +96 -0
  67. package/runtime/scripts/lease.mjs +193 -0
  68. package/runtime/scripts/murmur-daemon.mjs +545 -0
  69. package/runtime/scripts/murmur-jetstream-advisory.mjs +11 -0
  70. package/runtime/scripts/murmur-shell-send.mjs +142 -0
  71. package/runtime/scripts/notify-router.mjs +309 -0
  72. package/runtime/scripts/runtime-capability.mjs +34 -0
  73. package/runtime/scripts/secure-state.mjs +105 -0
  74. package/runtime/scripts/wake-drain-claude.mjs +484 -0
  75. package/runtime/scripts/wake-monitor.mjs +653 -0
@@ -0,0 +1,199 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import path from "node:path";
3
+ import { realpath } from "node:fs/promises";
4
+ import { createHash } from "node:crypto";
5
+ import { loadConfig, readJson, safeError } from "./config.js";
6
+ import { readOutboxAttention } from "./outbox-attention.js";
7
+ const measured = (at) => ({ measuredAt: at, unknownReason: null });
8
+ const unknown = (reason) => ({ measuredAt: null, unknownReason: reason });
9
+ export const pairFingerprint = (config, peerId) => createHash("sha256").update(JSON.stringify([
10
+ config.agentId, config.keys.signing.publicKey, config.keys.encryption.publicKey, peerId,
11
+ config.peers[peerId]?.signing.publicKey, config.peers[peerId]?.encryption.publicKey,
12
+ ])).digest("hex");
13
+ export function validPairProof(proof, config, peerId, now) {
14
+ const at = Date.parse(proof?.verifiedAt);
15
+ return proof?.peerId === peerId && proof?.localAgentId === config.agentId
16
+ && proof?.keyFingerprint === pairFingerprint(config, peerId)
17
+ && Number.isFinite(at) && now - at >= -5000 && now - at <= 86400000;
18
+ }
19
+ export async function readStatus({ context: c, adapter, now = Date.now }) {
20
+ const started = now();
21
+ const at = new Date(started).toISOString();
22
+ let config = null;
23
+ let configError = null;
24
+ try {
25
+ config = await loadConfig(c);
26
+ }
27
+ catch (e) {
28
+ configError = safeError(e);
29
+ }
30
+ let service;
31
+ try {
32
+ service = await adapter.status(c);
33
+ }
34
+ catch {
35
+ service = { state: "unknown", manager: adapter.manager,
36
+ since: null, pid: null, lastExitCode: null, observedStorePath: null, restartCount: null,
37
+ restartWindowMs: null, detail: "service.measurement-failed" };
38
+ }
39
+ const restartsLastHour = service.restartWindowMs === 3600000 && Number.isSafeInteger(service.restartCount)
40
+ && service.restartCount !== null && service.restartCount >= 0 ? service.restartCount : null;
41
+ if (restartsLastHour !== null && restartsLastHour >= 5)
42
+ service = { ...service, state: "failed", detail: "service.restart-loop" };
43
+ let observation = null;
44
+ let runtimeMeasurement = unknown("runtime.observation-unavailable");
45
+ try {
46
+ const raw = await readJson(path.join(c.dataDir, "daemon-observation.json"));
47
+ const timestamp = Date.parse(raw.measuredAt);
48
+ const storePath = await realpath(c.storePath);
49
+ if (raw.schema === "murmur.runtime/1" && service.state === "running" && raw.pid === service.pid
50
+ && raw.agentId === config?.agentId && raw.storePath === storePath && service.observedStorePath === storePath
51
+ && Number.isFinite(timestamp) && started - timestamp >= -5000 && started - timestamp <= 15000
52
+ && ["connected", "disconnected", "unauthorized", "unknown"].includes(raw.broker?.state)
53
+ && typeof raw.wake?.enabled === "boolean" && ["hook", "monitor", "none"].includes(raw.wake?.mode)) {
54
+ observation = raw;
55
+ runtimeMeasurement = measured(raw.measuredAt);
56
+ }
57
+ else
58
+ runtimeMeasurement = unknown("runtime.observation-unverified-or-stale");
59
+ }
60
+ catch { /* Absent legacy telemetry is unknown, never reconstructed from config. */ }
61
+ const inbox = { unread: null, total: null, lastAt: null,
62
+ unknownReason: null, measurements: { store: unknown("store.unavailable"), cursor: unknown("inbox.cursor-unavailable") } };
63
+ const outbox = { pending: null, inflight: null, delivered: null,
64
+ failed: null, dlq: null, oldestPendingAt: null,
65
+ lastError: null, lastErrorAt: null, unknownReason: null,
66
+ measurements: { store: unknown("store.unavailable") } };
67
+ const wake = { enabled: observation?.wake?.enabled ?? null, mode: observation?.wake?.mode ?? null,
68
+ responder: observation?.wake?.responder ?? null, storedOnly: null,
69
+ pendingUndelivered: null, lastDeliveredAt: null,
70
+ lastFault: null, lastFaultAt: null, unknownReason: null,
71
+ measurements: { store: unknown("store.unavailable"), runtime: runtimeMeasurement } };
72
+ const peers = {
73
+ list: config ? Object.keys(config.peers).map((agentId) => ({ agentId, paired: null, lastInboundAt: null, lastOutboundAt: null })) : null,
74
+ unknownReason: configError, measurements: { config: config ? measured(at) : unknown(configError ?? "config.unavailable"), store: unknown("store.unavailable"), proof: unknown("peers.proof-unavailable") },
75
+ };
76
+ let deliveries = null;
77
+ let attention = { unknownReason: 'store.unavailable' };
78
+ let db;
79
+ try {
80
+ db = new DatabaseSync(c.storePath, { readOnly: true });
81
+ db.exec("PRAGMA busy_timeout=1000; BEGIN");
82
+ const summary = db.prepare("SELECT COUNT(*) AS total, MAX(created_at) AS lastAt, COALESCE(MAX(rowid),0) AS tip FROM local_messages WHERE direction='inbound'").get();
83
+ inbox.total = Number(summary.total);
84
+ inbox.lastAt = summary.lastAt;
85
+ inbox.measurements.store = measured(at);
86
+ try {
87
+ const cursor = await readJson(path.join(c.dataDir, "read-state.json"));
88
+ if (cursor.schema !== "murmur.read/1" || cursor.agentId !== config?.agentId || !Number.isSafeInteger(cursor.rowid)
89
+ || cursor.rowid < 0 || cursor.rowid > Number(summary.tip))
90
+ throw new Error("inbox.cursor-invalid");
91
+ inbox.unread = Number(db.prepare("SELECT COUNT(*) AS n FROM local_messages WHERE direction='inbound' AND rowid>?").get(cursor.rowid).n);
92
+ inbox.measurements.cursor = measured(at);
93
+ }
94
+ catch { /* A missing cursor is not an empty inbox. */ }
95
+ const counts = Object.fromEntries(db.prepare("SELECT status, COUNT(*) AS n FROM outbox GROUP BY status").all().map((r) => [r.status, Number(r.n)]));
96
+ outbox.pending = counts.pending ?? 0;
97
+ outbox.inflight = counts.sent ?? 0;
98
+ outbox.delivered = counts.acked ?? 0;
99
+ outbox.failed = counts.failed ?? 0;
100
+ outbox.dlq = counts.dlq ?? 0;
101
+ outbox.oldestPendingAt = db.prepare("SELECT MIN(created_at) AS at FROM outbox WHERE status IN ('pending','sent','failed')").get().at;
102
+ const error = db.prepare("SELECT last_error,updated_at FROM outbox WHERE last_error IS NOT NULL AND last_error!='' ORDER BY updated_at DESC LIMIT 1").get();
103
+ outbox.lastError = error ? safeError(error.last_error) : null;
104
+ outbox.lastErrorAt = error?.updated_at ?? null;
105
+ outbox.measurements.store = measured(at);
106
+ if (config) {
107
+ try {
108
+ attention = await readOutboxAttention(c, config.agentId, db);
109
+ }
110
+ catch (error) {
111
+ attention = { unknownReason: safeError(error) };
112
+ }
113
+ }
114
+ try {
115
+ const rows = Object.fromEntries(db.prepare("SELECT wake_status,COUNT(*) AS n FROM local_messages WHERE direction='inbound' GROUP BY wake_status").all().map((r) => [r.wake_status, Number(r.n)]));
116
+ wake.storedOnly = rows["stored-only"] ?? 0;
117
+ wake.pendingUndelivered = (rows.pending ?? 0) + (rows.inflight ?? 0) + (rows.failed ?? 0);
118
+ wake.lastDeliveredAt = db.prepare("SELECT MAX(wake_updated_at) AS at FROM local_messages WHERE direction='inbound' AND wake_status='handled'").get().at;
119
+ const fault = db.prepare("SELECT wake_error,wake_updated_at FROM local_messages WHERE direction='inbound' AND wake_status IN ('failed','dlq') AND wake_error IS NOT NULL ORDER BY wake_updated_at DESC LIMIT 1").get();
120
+ wake.lastFault = fault ? safeError(fault.wake_error) : null;
121
+ wake.lastFaultAt = fault?.wake_updated_at ?? null;
122
+ wake.measurements.store = measured(at);
123
+ }
124
+ catch {
125
+ wake.measurements.store = unknown("wake.store-schema-unavailable");
126
+ }
127
+ for (const peer of peers.list ?? []) {
128
+ peer.lastInboundAt = db.prepare("SELECT MAX(created_at) AS at FROM local_messages WHERE direction='inbound' AND sender=?").get(peer.agentId).at;
129
+ peer.lastOutboundAt = db.prepare("SELECT MAX(created_at) AS at FROM outbox WHERE json_extract(envelope_json,'$.recipients[0]')=?").get(peer.agentId).at;
130
+ }
131
+ peers.measurements.store = measured(at);
132
+ const outbound = db.prepare("SELECT msg_id AS msgId,json_extract(envelope_json,'$.recipients[0]') AS peer,'outbound' AS direction,CASE status WHEN 'acked' THEN 'delivered' WHEN 'sent' THEN 'inflight' ELSE status END AS state,updated_at AS at,attempts,last_error AS error FROM outbox ORDER BY updated_at DESC LIMIT 20").all();
133
+ const inbound = db.prepare("SELECT msg_id AS msgId,sender AS peer,'inbound' AS direction,'delivered' AS state,created_at AS at,0 AS attempts,NULL AS error FROM local_messages WHERE direction='inbound' ORDER BY created_at DESC LIMIT 20").all();
134
+ deliveries = [...outbound, ...inbound].sort((a, b) => String(b.at).localeCompare(String(a.at))).slice(0, 20).map((r) => ({ ...r, error: r.error ? safeError(r.error) : null }));
135
+ db.exec("COMMIT");
136
+ }
137
+ catch {
138
+ // Keep independently completed measurements; each source names its uncertainty.
139
+ }
140
+ finally {
141
+ db?.close();
142
+ }
143
+ if (config) {
144
+ try {
145
+ const proofs = await readJson(path.join(c.dataDir, "pair-proofs.json"));
146
+ for (const peer of peers.list ?? [])
147
+ peer.paired = validPairProof(proofs[peer.agentId], config, peer.agentId, started) ? true : null;
148
+ peers.measurements.proof = measured(at);
149
+ }
150
+ catch { /* No roundtrip evidence: every pairing stays unknown. */ }
151
+ }
152
+ const reason = (sources) => Object.entries(sources).filter(([, v]) => v.unknownReason).map(([name, v]) => `${name}:${v.unknownReason}`).join("; ") || null;
153
+ inbox.unknownReason = reason(inbox.measurements);
154
+ outbox.unknownReason = reason(outbox.measurements);
155
+ wake.unknownReason = reason(wake.measurements);
156
+ peers.unknownReason = reason(peers.measurements);
157
+ return {
158
+ schema: "murmur.status/1", generatedAt: at, agentId: config?.agentId ?? null, serviceName: c.serviceName,
159
+ service: { ...service, lastFailureAt: null, restartsLastHour, restartFailureThreshold: 5,
160
+ unknownReason: service.state === "unknown" ? service.detail ?? "service.unavailable" : null,
161
+ measurements: { manager: service.state === "unknown" ? unknown(service.detail ?? "service.unavailable") : measured(at), history: unknown("service.history-unavailable") } },
162
+ broker: { url: config?.natsUrl ?? null, state: observation?.broker?.state ?? "unknown",
163
+ connectedAt: observation?.broker?.connectedAt ?? null, lastError: observation?.broker?.lastError ?? null,
164
+ lastErrorAt: observation?.broker?.lastErrorAt ?? null, unknownReason: runtimeMeasurement.unknownReason,
165
+ measurements: { runtime: runtimeMeasurement } },
166
+ peers, inbox, deliveries,
167
+ outbox: {
168
+ attention,
169
+ queue: { pending: outbox.pending, inflight: outbox.inflight, delivered: outbox.delivered,
170
+ failed: outbox.failed, dlq: outbox.dlq, oldestPendingAt: outbox.oldestPendingAt,
171
+ unknownReason: outbox.measurements.store.unknownReason },
172
+ faults: { lastError: outbox.lastError, lastErrorAt: outbox.lastErrorAt,
173
+ unknownReason: outbox.measurements.store.unknownReason, source: "durable-outbox" },
174
+ },
175
+ wake: {
176
+ config: { enabled: config ? config.wake?.enabled !== false : null,
177
+ mode: config ? configuredWakeMode(config) : null,
178
+ responder: config ? configuredWakeMode(config) === "monitor" ? "codex" : config.onReceive ? null : "none" : null,
179
+ unknownReason: configError },
180
+ effective: { enabled: wake.enabled,
181
+ needsRestart: config && typeof wake.enabled === "boolean" ? (config.wake?.enabled !== false) !== wake.enabled : null,
182
+ observedAt: runtimeMeasurement.measuredAt, unknownReason: runtimeMeasurement.unknownReason },
183
+ delivery: { pendingUndelivered: wake.pendingUndelivered, storedOnly: wake.storedOnly,
184
+ lastDeliveredAt: wake.lastDeliveredAt, unknownReason: wake.measurements.store.unknownReason },
185
+ faults: {
186
+ lastFault: observation?.wake?.lastFault && (!wake.lastFaultAt || observation.wake.lastFaultAt > wake.lastFaultAt)
187
+ ? safeError(observation.wake.lastFault) : wake.lastFault,
188
+ lastFaultAt: observation?.wake?.lastFault && (!wake.lastFaultAt || observation.wake.lastFaultAt > wake.lastFaultAt)
189
+ ? observation.wake.lastFaultAt : wake.lastFaultAt,
190
+ unknownReason: reason({ store: wake.measurements.store, runtime: runtimeMeasurement }),
191
+ measurements: { store: wake.measurements.store, runtime: runtimeMeasurement },
192
+ },
193
+ },
194
+ };
195
+ }
196
+ export function configuredWakeMode(config) {
197
+ return config.wake?.mode === "codex_app_server" || Object.values(config.wake?.peers ?? {}).some(p => p.mode === "codex_app_server")
198
+ ? "monitor" : config.onReceive ? "hook" : "none";
199
+ }
@@ -0,0 +1,40 @@
1
+ /** Absolute paths resolved by the shared engine, independent of caller cwd. */
2
+ export interface ServiceContext {
3
+ dataDir: string;
4
+ configPath: string;
5
+ storePath: string;
6
+ repoRoot: string;
7
+ nodePath: string;
8
+ logDir: string;
9
+ serviceName: string;
10
+ }
11
+ export type ServiceManager = "windows-service" | "scheduled-task" | "launchd" | "systemd" | "none";
12
+ export interface ServiceSnapshot {
13
+ state: "running" | "stopped" | "failed" | "unknown";
14
+ manager: ServiceManager;
15
+ since: string | null;
16
+ pid: number | null;
17
+ lastExitCode: number | null;
18
+ /** Observed open database, not a guess from configuration or process arguments. */
19
+ observedStorePath: string | null;
20
+ /** Restarts in the stated interval. Null if the platform cannot measure it. */
21
+ restartCount: number | null;
22
+ restartWindowMs: number | null;
23
+ detail?: string;
24
+ }
25
+ export interface ClientDetection {
26
+ id: "claude-code" | "claude-desktop" | "codex-cli" | "codex-desktop";
27
+ installed: boolean;
28
+ configPath: string | null;
29
+ format: "json" | "toml";
30
+ detail?: string;
31
+ }
32
+ export interface PlatformAdapter {
33
+ manager: ServiceManager;
34
+ status(context: ServiceContext): Promise<ServiceSnapshot>;
35
+ install(context: ServiceContext): Promise<void>;
36
+ start(context: ServiceContext): Promise<void>;
37
+ stop(context: ServiceContext): Promise<void>;
38
+ uninstall?(context: ServiceContext): Promise<void>;
39
+ detectClients(context: ServiceContext): Promise<ClientDetection[]>;
40
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ export declare const UPDATE_INTERVAL_MS: number;
2
+ export declare const UPDATE_TIMEOUT_MS = 4000;
3
+ export declare const UPDATE_ENDPOINT = "https://api.github.com/repos/alexfrmn/murmur/releases/latest";
4
+ export declare function compareReleaseVersions(left: string, right: string): number;
5
+ export declare function readVersion(manifest?: URL): Promise<{
6
+ schema: "murmur.version/1";
7
+ product: string;
8
+ version: string;
9
+ source: "root-package-json";
10
+ comparison: "declared-release-version";
11
+ }>;
12
+ export interface UpdateResult {
13
+ schema: 'murmur.updates/1';
14
+ channel: 'stable';
15
+ currentVersion: string | null;
16
+ versionSource: 'root-package-json';
17
+ comparison: 'declared-release-version';
18
+ enabled: boolean;
19
+ state: 'up-to-date' | 'available' | 'unknown';
20
+ reason: string;
21
+ latestVersion: string | null;
22
+ releaseUrl: string | null;
23
+ action: 'open-release-page' | null;
24
+ checkedAt: string | null;
25
+ lastSuccessAt: string | null;
26
+ nextCheckAt: string | null;
27
+ cached: boolean;
28
+ stale: boolean;
29
+ checkIntervalMs: number;
30
+ timeoutMs: number;
31
+ }
32
+ type Options = {
33
+ directory?: string;
34
+ now?: () => number;
35
+ fetch?: typeof fetch;
36
+ manifest?: URL;
37
+ timeoutMs?: number;
38
+ env?: NodeJS.ProcessEnv;
39
+ };
40
+ export declare function updateDirectory(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, home?: string): string;
41
+ export declare function setUpdateChecks(enabled: boolean, options?: Options): Promise<{
42
+ schema: string;
43
+ enabled: boolean;
44
+ }>;
45
+ export declare function checkUpdates(options?: Options): Promise<UpdateResult>;
46
+ export {};
@@ -0,0 +1,284 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { randomUUID } from 'node:crypto';
6
+ export const UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000;
7
+ export const UPDATE_TIMEOUT_MS = 4000;
8
+ export const UPDATE_ENDPOINT = 'https://api.github.com/repos/alexfrmn/murmur/releases/latest';
9
+ const RELEASE_BASE = 'https://github.com/alexfrmn/murmur/releases/tag/';
10
+ const MAX_BYTES = 128 * 1024;
11
+ const versionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
12
+ function versionParts(value) {
13
+ if (typeof value !== 'string' || value.length > 100)
14
+ return null;
15
+ const match = versionPattern.exec(value);
16
+ return match ? match.slice(1, 4).map(BigInt) : null;
17
+ }
18
+ export function compareReleaseVersions(left, right) {
19
+ const a = versionParts(left), b = versionParts(right);
20
+ if (!a || !b)
21
+ throw new Error('updates.version-invalid');
22
+ for (let i = 0; i < 3; i++)
23
+ if (a[i] !== b[i])
24
+ return a[i] > b[i] ? 1 : -1;
25
+ return 0;
26
+ }
27
+ export async function readVersion(manifest = new URL('../../../../package.json', import.meta.url)) {
28
+ const pkg = JSON.parse(await fs.readFile(manifest, 'utf8'));
29
+ if (pkg.name !== 'murmur' || !versionParts(pkg.version))
30
+ throw new Error('version.root-manifest-invalid');
31
+ return { schema: 'murmur.version/1', product: 'Murmur', version: pkg.version,
32
+ source: 'root-package-json', comparison: 'declared-release-version' };
33
+ }
34
+ export function updateDirectory(env = process.env, platform = process.platform, home = homedir()) {
35
+ const paths = platform === 'win32' ? path.win32 : path.posix;
36
+ const parent = platform === 'win32' ? (env.LOCALAPPDATA || paths.join(home, 'AppData', 'Local'))
37
+ : platform === 'darwin' ? paths.join(home, 'Library', 'Application Support')
38
+ : (env.XDG_STATE_HOME || paths.join(home, '.local', 'state'));
39
+ if (!paths.isAbsolute(parent))
40
+ throw new Error('updates.state-path-invalid');
41
+ return paths.join(parent, 'Murmur', 'updates');
42
+ }
43
+ function date(value) {
44
+ if (typeof value !== 'string')
45
+ return null;
46
+ const n = Date.parse(value);
47
+ return Number.isFinite(n) && new Date(n).toISOString() === value ? n : null;
48
+ }
49
+ async function readJson(file) {
50
+ let handle;
51
+ try {
52
+ if (!(await fs.lstat(file)).isFile())
53
+ throw new Error('updates.state-invalid');
54
+ handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
55
+ const stat = await handle.stat();
56
+ if (!stat.isFile() || stat.size > MAX_BYTES)
57
+ throw new Error('updates.state-invalid');
58
+ const buffer = Buffer.alloc(MAX_BYTES + 1);
59
+ let size = 0;
60
+ while (size < buffer.length) {
61
+ const read = await handle.read(buffer, size, buffer.length - size, null);
62
+ if (read.bytesRead === 0)
63
+ break;
64
+ size += read.bytesRead;
65
+ }
66
+ if (size > MAX_BYTES)
67
+ throw new Error('updates.state-invalid');
68
+ return JSON.parse(buffer.subarray(0, size).toString('utf8'));
69
+ }
70
+ catch (error) {
71
+ if (error.code === 'ENOENT')
72
+ return undefined;
73
+ throw error;
74
+ }
75
+ finally {
76
+ await handle?.close();
77
+ }
78
+ }
79
+ async function prepare(directory) {
80
+ if (!path.isAbsolute(directory))
81
+ throw new Error('updates.state-path-invalid');
82
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
83
+ if (!(await fs.lstat(directory)).isDirectory())
84
+ throw new Error('updates.state-invalid');
85
+ }
86
+ async function writeJson(directory, name, value) {
87
+ const temporary = path.join(directory, `.tmp-${randomUUID()}`);
88
+ try {
89
+ await fs.writeFile(temporary, JSON.stringify(value) + '\n', { flag: 'wx', mode: 0o600 });
90
+ await fs.rename(temporary, path.join(directory, name));
91
+ }
92
+ finally {
93
+ await fs.unlink(temporary).catch(() => { });
94
+ }
95
+ }
96
+ async function preference(directory) {
97
+ const value = await readJson(path.join(directory, 'preferences.json'));
98
+ if (value === undefined)
99
+ return true;
100
+ if (!value || value.schema !== 'murmur.update-preferences/1' || typeof value.enabled !== 'boolean') {
101
+ throw new Error('updates.preferences-invalid');
102
+ }
103
+ return value.enabled;
104
+ }
105
+ async function lock(directory) {
106
+ const file = path.join(directory, 'check.lock');
107
+ try {
108
+ await fs.mkdir(file);
109
+ }
110
+ catch (error) {
111
+ if (error.code !== 'EEXIST')
112
+ throw error;
113
+ // The request is bounded to four seconds. Recover an abandoned empty lock,
114
+ // but never wait for another client or let it stall application startup.
115
+ const stat = await fs.lstat(file);
116
+ if (!stat.isDirectory() || Date.now() - stat.mtimeMs < 60_000)
117
+ throw new Error('updates.check-in-progress');
118
+ await fs.rmdir(file);
119
+ await fs.mkdir(file);
120
+ }
121
+ return async () => { await fs.rmdir(file); };
122
+ }
123
+ export async function setUpdateChecks(enabled, options = {}) {
124
+ const directory = options.directory ?? updateDirectory(options.env);
125
+ await prepare(directory);
126
+ const unlock = await lock(directory);
127
+ try {
128
+ const result = { schema: 'murmur.update-preferences/1', enabled };
129
+ await writeJson(directory, 'preferences.json', result);
130
+ return result;
131
+ }
132
+ finally {
133
+ await unlock();
134
+ }
135
+ }
136
+ function validCache(value) {
137
+ if (!value || value.schema !== 'murmur.update-cache/1' || date(value.checkedAt) === null ||
138
+ (value.lastSuccessAt !== null && date(value.lastSuccessAt) === null))
139
+ return false;
140
+ if (value.reason === 'updates.checked') {
141
+ return value.lastSuccessAt === value.checkedAt && value.release && versionParts(value.release.version) !== null &&
142
+ value.release.tag === `v${value.release.version}`;
143
+ }
144
+ return value.release === null && ['updates.network-error', 'updates.timeout', 'updates.rate-limited',
145
+ 'updates.http-error', 'updates.release-invalid', 'updates.interrupted'].includes(value.reason);
146
+ }
147
+ async function requestRelease(fetcher, timeoutMs) {
148
+ const controller = new AbortController();
149
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
150
+ try {
151
+ const response = await fetcher(UPDATE_ENDPOINT, { signal: controller.signal, redirect: 'error',
152
+ headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28',
153
+ 'User-Agent': 'Murmur-update-check' } });
154
+ if (!response.ok) {
155
+ await response.body?.cancel();
156
+ throw new Error(response.status === 403 || response.status === 429 ? 'updates.rate-limited' : 'updates.http-error');
157
+ }
158
+ if (!response.body)
159
+ throw new Error('updates.release-invalid');
160
+ const reader = response.body.getReader();
161
+ const chunks = [];
162
+ let size = 0;
163
+ try {
164
+ while (true) {
165
+ const { done, value } = await reader.read();
166
+ if (done)
167
+ break;
168
+ size += value.byteLength;
169
+ if (size > MAX_BYTES) {
170
+ await reader.cancel();
171
+ throw new Error('updates.release-invalid');
172
+ }
173
+ chunks.push(value);
174
+ }
175
+ }
176
+ finally {
177
+ reader.releaseLock();
178
+ }
179
+ let data;
180
+ try {
181
+ data = JSON.parse(Buffer.concat(chunks).toString('utf8'));
182
+ }
183
+ catch {
184
+ throw new Error('updates.release-invalid');
185
+ }
186
+ const version = typeof data.tag_name === 'string' ? data.tag_name.slice(1) : null;
187
+ if (data.draft !== false || data.prerelease !== false || !versionParts(version) ||
188
+ data.tag_name !== `v${version}` ||
189
+ ![RELEASE_BASE + encodeURIComponent(data.tag_name), RELEASE_BASE + data.tag_name].includes(data.html_url) ||
190
+ typeof data.published_at !== 'string' || !Number.isFinite(Date.parse(data.published_at))) {
191
+ throw new Error('updates.release-invalid');
192
+ }
193
+ return { version: version, tag: data.tag_name };
194
+ }
195
+ catch (error) {
196
+ if (controller.signal.aborted)
197
+ throw new Error('updates.timeout');
198
+ if (['updates.rate-limited', 'updates.http-error', 'updates.release-invalid'].includes(error.message))
199
+ throw error;
200
+ throw new Error('updates.network-error');
201
+ }
202
+ finally {
203
+ clearTimeout(timer);
204
+ }
205
+ }
206
+ export async function checkUpdates(options = {}) {
207
+ const now = options.now ?? Date.now;
208
+ const result = { schema: 'murmur.updates/1', channel: 'stable', currentVersion: null,
209
+ versionSource: 'root-package-json', comparison: 'declared-release-version', enabled: true,
210
+ state: 'unknown', reason: 'updates.not-checked', latestVersion: null, releaseUrl: null, action: null,
211
+ checkedAt: null, lastSuccessAt: null, nextCheckAt: null, cached: false, stale: false,
212
+ checkIntervalMs: UPDATE_INTERVAL_MS, timeoutMs: UPDATE_TIMEOUT_MS };
213
+ try {
214
+ result.currentVersion = (await readVersion(options.manifest)).version;
215
+ }
216
+ catch {
217
+ return { ...result, reason: 'updates.current-version-invalid' };
218
+ }
219
+ if ((options.env ?? process.env).MURMUR_UPDATE_CHECK === '0')
220
+ return { ...result, enabled: false, reason: 'updates.disabled' };
221
+ let directory;
222
+ try {
223
+ directory = options.directory ?? updateDirectory(options.env);
224
+ result.enabled = await preference(directory);
225
+ }
226
+ catch {
227
+ return { ...result, enabled: false, reason: 'updates.preferences-unavailable' };
228
+ }
229
+ if (!result.enabled)
230
+ return { ...result, reason: 'updates.disabled' };
231
+ let unlock;
232
+ try {
233
+ await prepare(directory);
234
+ unlock = await lock(directory);
235
+ // Re-read under the cross-process lock: disabling and competing checks cannot
236
+ // race the decision to initiate the next network request.
237
+ if (!await preference(directory))
238
+ return { ...result, enabled: false, reason: 'updates.disabled' };
239
+ const saved = await readJson(path.join(directory, 'cache.json'));
240
+ if (saved !== undefined && !validCache(saved))
241
+ return { ...result, reason: 'updates.cache-invalid' };
242
+ let cache = saved ?? null;
243
+ const at = now();
244
+ if (cache && (date(cache.checkedAt) > at || (cache.lastSuccessAt !== null && date(cache.lastSuccessAt) > date(cache.checkedAt)))) {
245
+ return { ...result, reason: 'updates.cache-invalid' };
246
+ }
247
+ if (cache && at - date(cache.checkedAt) < UPDATE_INTERVAL_MS)
248
+ result.cached = true;
249
+ else {
250
+ result.stale = cache !== null;
251
+ cache = { schema: 'murmur.update-cache/1', checkedAt: new Date(at).toISOString(),
252
+ lastSuccessAt: cache?.lastSuccessAt ?? null, release: null, reason: 'updates.interrupted' };
253
+ // Persist the attempt before fetching. Network failure or a killed client
254
+ // must not cause every status refresh to hit GitHub again.
255
+ await writeJson(directory, 'cache.json', cache);
256
+ try {
257
+ cache.release = await requestRelease(options.fetch ?? fetch, options.timeoutMs ?? UPDATE_TIMEOUT_MS);
258
+ cache.reason = 'updates.checked';
259
+ cache.lastSuccessAt = cache.checkedAt;
260
+ }
261
+ catch (error) {
262
+ cache.reason = error.message;
263
+ }
264
+ await writeJson(directory, 'cache.json', cache);
265
+ }
266
+ result.checkedAt = cache.checkedAt;
267
+ result.lastSuccessAt = cache.lastSuccessAt;
268
+ result.nextCheckAt = new Date(date(cache.checkedAt) + UPDATE_INTERVAL_MS).toISOString();
269
+ if (!cache.release)
270
+ return { ...result, reason: cache.reason, stale: cache.lastSuccessAt !== null };
271
+ const newer = compareReleaseVersions(cache.release.version, result.currentVersion) > 0;
272
+ return { ...result, stale: false, state: newer ? 'available' : 'up-to-date',
273
+ reason: newer ? 'updates.newer-release' : 'updates.no-newer-release', latestVersion: cache.release.version,
274
+ releaseUrl: newer ? RELEASE_BASE + encodeURIComponent(cache.release.tag) : null,
275
+ action: newer ? 'open-release-page' : null };
276
+ }
277
+ catch (error) {
278
+ return { ...result, state: 'unknown', reason: error.message === 'updates.check-in-progress'
279
+ ? 'updates.check-in-progress' : 'updates.cache-unavailable' };
280
+ }
281
+ finally {
282
+ await unlock?.().catch(() => { });
283
+ }
284
+ }
@@ -0,0 +1,11 @@
1
+ export interface StatusVerdict {
2
+ level: "grey" | "red" | "yellow" | "green";
3
+ unread: boolean;
4
+ code: string;
5
+ missing: string[];
6
+ missingWhy: Record<string, string>;
7
+ }
8
+ /** Shared policy, including 2.11 dead-letter acknowledgement and effective pause. */
9
+ export declare function statusVerdict(input: unknown, now?: number): StatusVerdict;
10
+ /** A failed diagnostic stage prevents later stages from claiming measurements. */
11
+ export declare function doctorChainValid(input: unknown): boolean;