@svrnsec/pulse 0.3.0 → 0.4.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.
@@ -54,9 +54,9 @@ export function blake3HexStr(str) {
54
54
  * @param {string} p.nonce – server-issued challenge nonce (hex)
55
55
  * @returns {ProofPayload}
56
56
  */
57
- export function buildProof({ entropy, jitter, bio, canvas, audio, nonce }) {
57
+ export function buildProof({ entropy, jitter, bio, canvas, audio, enf, gpu, dram, llm, nonce }) {
58
58
  if (!nonce || typeof nonce !== 'string') {
59
- throw new Error('@sovereign/pulse: nonce is required for anti-replay protection');
59
+ throw new Error('@svrnsec/pulse: nonce is required for anti-replay protection');
60
60
  }
61
61
 
62
62
  // Hash the raw timing arrays IN-BROWSER so we can prove their integrity
@@ -138,12 +138,68 @@ export function buildProof({ entropy, jitter, bio, canvas, audio, nonce }) {
138
138
  jitterMeanMs: _round(audio.jitterMeanMs, 4),
139
139
  jitterP95Ms: _round(audio.jitterP95Ms, 4),
140
140
  },
141
+
142
+ // ── Electrical Network Frequency ─────────────────────────────────────
143
+ enf: enf ? {
144
+ available: enf.enfAvailable,
145
+ ripplePresent: enf.ripplePresent,
146
+ gridFrequency: enf.gridFrequency,
147
+ gridRegion: enf.gridRegion,
148
+ ripplePower: _round(enf.ripplePower, 4),
149
+ enfDeviation: _round(enf.enfDeviation, 3),
150
+ snr50hz: _round(enf.snr50hz, 2),
151
+ snr60hz: _round(enf.snr60hz, 2),
152
+ sampleRateHz: _round(enf.sampleRateHz, 1),
153
+ verdict: enf.verdict,
154
+ isVmIndicator: enf.isVmIndicator,
155
+ capturedAt: enf.temporalAnchor?.capturedAt ?? null,
156
+ } : null,
157
+
158
+ // ── WebGPU thermal variance ───────────────────────────────────────────
159
+ gpu: gpu ? {
160
+ available: gpu.gpuPresent,
161
+ isSoftware: gpu.isSoftware,
162
+ vendorString: gpu.vendorString,
163
+ dispatchCV: _round(gpu.dispatchCV, 4),
164
+ thermalGrowth: _round(gpu.thermalGrowth, 4),
165
+ verdict: gpu.verdict,
166
+ } : null,
167
+
168
+ // ── DRAM refresh cycle ────────────────────────────────────────────────
169
+ dram: dram ? {
170
+ refreshPresent: dram.refreshPresent,
171
+ refreshPeriodMs: _round(dram.refreshPeriodMs, 2),
172
+ peakPower: _round(dram.peakPower, 4),
173
+ verdict: dram.verdict,
174
+ } : null,
175
+
176
+ // ── LLM / AI agent behavioral fingerprint ────────────────────────────
177
+ llm: llm ? {
178
+ aiConf: _round(llm.aiConf, 3),
179
+ thinkTimePattern: llm.thinkTimePattern,
180
+ correctionRate: _round(llm.correctionRate, 3),
181
+ rhythmicity: _round(llm.rhythmicity, 3),
182
+ pauseDistribution: llm.pauseDistribution,
183
+ verdict: llm.verdict,
184
+ matchedModel: llm.matchedModel ?? null,
185
+ } : null,
141
186
  },
142
187
 
143
- // Top-level classification summary
188
+ // Top-level classification summary — all signal layers combined
144
189
  classification: {
145
- jitterScore: _round(jitter.score, 4),
146
- flags: jitter.flags ?? [],
190
+ jitterScore: _round(jitter.score, 4),
191
+ flags: jitter.flags ?? [],
192
+ enfVerdict: enf?.verdict ?? 'unavailable',
193
+ gpuVerdict: gpu?.verdict ?? 'unavailable',
194
+ dramVerdict: dram?.verdict ?? 'unavailable',
195
+ llmVerdict: llm?.verdict ?? 'unavailable',
196
+ // Combined VM confidence: any hard signal raises this
197
+ vmIndicators: [
198
+ enf?.isVmIndicator ? 'enf_no_grid' : null,
199
+ gpu?.isSoftware ? 'gpu_software' : null,
200
+ dram?.verdict === 'virtual' ? 'dram_no_refresh' : null,
201
+ llm?.aiConf > 0.7 ? 'llm_agent' : null,
202
+ ].filter(Boolean),
147
203
  },
148
204
  };
149
205
 
@@ -0,0 +1,263 @@
1
+ /**
2
+ * @svrnsec/pulse — Terminal Result Renderer
3
+ *
4
+ * Pretty-prints probe results to the terminal for Node.js server usage.
5
+ * Used by middleware and the CLI so developers see clean, actionable output
6
+ * during integration and debugging — not raw JSON.
7
+ *
8
+ * Zero dependencies. Pure ANSI escape codes.
9
+ * Automatically disabled when stdout is not a TTY or NO_COLOR is set.
10
+ */
11
+
12
+ /* ─── TTY guard ──────────────────────────────────────────────────────────── */
13
+
14
+ const isTTY = () =>
15
+ typeof process !== 'undefined' &&
16
+ process.stderr?.isTTY === true &&
17
+ process.env?.NO_COLOR == null;
18
+
19
+ const c = isTTY;
20
+
21
+ /* ─── ANSI color palette ─────────────────────────────────────────────────── */
22
+
23
+ const A = {
24
+ reset: '\x1b[0m',
25
+ bold: '\x1b[1m',
26
+ dim: '\x1b[2m',
27
+ // foreground — normal
28
+ red: '\x1b[31m',
29
+ green: '\x1b[32m',
30
+ yellow: '\x1b[33m',
31
+ blue: '\x1b[34m',
32
+ magenta: '\x1b[35m',
33
+ cyan: '\x1b[36m',
34
+ white: '\x1b[37m',
35
+ gray: '\x1b[90m',
36
+ // foreground — bright
37
+ bred: '\x1b[91m',
38
+ bgreen: '\x1b[92m',
39
+ byellow: '\x1b[93m',
40
+ bblue: '\x1b[94m',
41
+ bmagenta:'\x1b[95m',
42
+ bcyan: '\x1b[96m',
43
+ bwhite: '\x1b[97m',
44
+ };
45
+
46
+ const paint = (code, s) => c() ? `${code}${s}${A.reset}` : s;
47
+ const dim = (s) => paint(A.dim, s);
48
+ const bold = (s) => paint(A.bold, s);
49
+ const gray = (s) => paint(A.gray, s);
50
+ const cyan = (s) => paint(A.cyan, s);
51
+ const green = (s) => paint(A.bgreen, s);
52
+ const red = (s) => paint(A.bred, s);
53
+ const yel = (s) => paint(A.byellow, s);
54
+ const mag = (s) => paint(A.bmagenta,s);
55
+ const wh = (s) => paint(A.bwhite, s);
56
+
57
+ function stripAnsi(s) {
58
+ // eslint-disable-next-line no-control-regex
59
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
60
+ }
61
+ const visLen = (s) => stripAnsi(s).length;
62
+
63
+ /* ─── bar renderer ───────────────────────────────────────────────────────── */
64
+
65
+ /**
66
+ * Render a horizontal progress / confidence bar.
67
+ * @param {number} pct 0–1
68
+ * @param {number} width character width of the bar
69
+ * @param {string} fillCode ANSI color code for filled blocks
70
+ */
71
+ function bar(pct, width = 20, fillCode = A.bgreen) {
72
+ const filled = Math.round(Math.min(1, Math.max(0, pct)) * width);
73
+ const empty = width - filled;
74
+ const fill = c() ? `${fillCode}${'█'.repeat(filled)}${A.reset}` : '█'.repeat(filled);
75
+ const void_ = gray('░'.repeat(empty));
76
+ return fill + void_;
77
+ }
78
+
79
+ /* ─── verdict badge ──────────────────────────────────────────────────────── */
80
+
81
+ function verdictBadge(result) {
82
+ if (!result) return gray(' PENDING ');
83
+ const { valid, score, confidence } = result;
84
+
85
+ if (valid && confidence === 'high') return green(' ✓ PASS ');
86
+ if (valid && confidence === 'medium') return yel(' ⚠ PASS ');
87
+ if (!valid && score < 0.3) return red(' ✗ BLOCKED ');
88
+ return yel(' ⚠ REVIEW ');
89
+ }
90
+
91
+ /* ─── renderProbeResult ──────────────────────────────────────────────────── */
92
+
93
+ /**
94
+ * Print a formatted probe result card to stderr.
95
+ *
96
+ * @param {object} opts
97
+ * @param {object} opts.payload - ProofPayload from pulse()
98
+ * @param {string} opts.hash - BLAKE3 hex commitment
99
+ * @param {object} [opts.result] - ValidationResult (server-side verify)
100
+ * @param {object} [opts.enf] - EnfResult if available
101
+ * @param {object} [opts.gpu] - GpuEntropyResult if available
102
+ * @param {object} [opts.dram] - DramResult if available
103
+ * @param {object} [opts.llm] - LlmResult if available
104
+ * @param {number} [opts.elapsedMs] - total probe time
105
+ */
106
+ export function renderProbeResult({ payload, hash, result, enf, gpu, dram, llm, elapsedMs }) {
107
+ if (!c()) return;
108
+
109
+ const W = 54;
110
+ const hr = gray('─'.repeat(W));
111
+ const vbar = gray('│');
112
+
113
+ const row = (label, value, valueColor = A.bwhite) => {
114
+ const lbl = gray(label.padEnd(24));
115
+ const val = c() ? `${valueColor}${value}${A.reset}` : String(value);
116
+ const line = ` ${lbl}${val}`;
117
+ const pad = ' '.repeat(Math.max(0, W - visLen(line) - 2));
118
+ process.stderr.write(`${vbar}${line}${pad} ${vbar}\n`);
119
+ };
120
+
121
+ const blank = () => {
122
+ process.stderr.write(`${vbar}${' '.repeat(W + 2)}${vbar}\n`);
123
+ };
124
+
125
+ const section = (title) => {
126
+ const t = ` ${bold(title)}`;
127
+ const pad = ' '.repeat(Math.max(0, W - visLen(t) - 2));
128
+ process.stderr.write(`${vbar}${t}${pad} ${vbar}\n`);
129
+ };
130
+
131
+ const badge = verdictBadge(result);
132
+ const hashShort = hash ? hash.slice(0, 16) + '…' : 'pending';
133
+ const elapsed = elapsedMs ? `${(elapsedMs / 1000).toFixed(2)}s` : '—';
134
+
135
+ const sigs = payload?.signals ?? {};
136
+ const cls = payload?.classification ?? {};
137
+ const jScore = cls.jitterScore ?? 0;
138
+
139
+ // ── Physics signals ──────────────────────────────────────────────────────
140
+ const qe = sigs.entropy?.quantizationEntropy ?? 0;
141
+ const hurst = sigs.entropy?.hurstExponent ?? 0;
142
+ const cv = sigs.entropy?.timingsCV ?? 0;
143
+ const ejrClass = qe >= 1.08 ? A.bgreen : qe >= 0.95 ? A.byellow : A.bred;
144
+ const hwConf = result?.confidence === 'high' ? 1.0 : result?.confidence === 'medium' ? 0.65 : 0.3;
145
+ const vmConf = 1 - hwConf;
146
+
147
+ // ── ENF signals ──────────────────────────────────────────────────────────
148
+ const enfRegion = enf?.gridRegion === 'americas' ? '60 Hz Americas'
149
+ : enf?.gridRegion === 'emea_apac' ? '50 Hz EMEA/APAC'
150
+ : enf?.enfAvailable === false ? 'unavailable'
151
+ : '—';
152
+ const enfColor = enf?.ripplePresent ? A.bgreen : enf?.enfAvailable === false ? A.gray : A.byellow;
153
+
154
+ // ── GPU signals ──────────────────────────────────────────────────────────
155
+ const gpuStr = gpu?.gpuPresent
156
+ ? (gpu.isSoftware ? red('Software renderer') : green(gpu.vendorString ?? 'GPU detected'))
157
+ : gray('unavailable');
158
+
159
+ // ── DRAM signals ─────────────────────────────────────────────────────────
160
+ const dramStr = dram?.refreshPresent
161
+ ? green(`${(dram.refreshPeriodMs ?? 0).toFixed(1)} ms (DDR4 JEDEC ✓)`)
162
+ : dram ? red('No refresh cycle (VM)') : gray('unavailable');
163
+
164
+ // ── LLM signals ──────────────────────────────────────────────────────────
165
+ const llmStr = llm
166
+ ? (llm.aiConf > 0.7 ? red(`AI agent ${(llm.aiConf * 100).toFixed(0)}%`) : green(`Human ${((1 - llm.aiConf) * 100).toFixed(0)}%`))
167
+ : gray('no bio data');
168
+
169
+ // ── Render ───────────────────────────────────────────────────────────────
170
+ const topTitle = ` ${mag('SVRN')}${wh(':PULSE')} ${badge}`;
171
+ const topPad = ' '.repeat(Math.max(0, W - visLen(topTitle) - 2));
172
+ const topBorder = gray('╭' + '─'.repeat(W + 2) + '╮');
173
+ const botBorder = gray('╰' + '─'.repeat(W + 2) + '╯');
174
+
175
+ process.stderr.write('\n');
176
+ process.stderr.write(topBorder + '\n');
177
+ process.stderr.write(`${vbar}${topTitle}${topPad} ${vbar}\n`);
178
+ process.stderr.write(gray('├' + '─'.repeat(W + 2) + '┤') + '\n');
179
+ blank();
180
+
181
+ section('PHYSICS LAYER');
182
+ blank();
183
+ row('Jitter score', (jScore * 100).toFixed(1) + '%', jScore > 0.7 ? A.bgreen : jScore > 0.45 ? A.byellow : A.bred);
184
+ row('QE (entropy)', qe.toFixed(3), ejrClass);
185
+ row('Hurst exponent', hurst.toFixed(4), Math.abs(hurst - 0.5) < 0.1 ? A.bgreen : A.byellow);
186
+ row('Timing CV', cv.toFixed(4), cv > 0.08 ? A.bgreen : A.byellow);
187
+ row('Timer granularity',`${((sigs.entropy?.timerGranularityMs ?? 0) * 1000).toFixed(1)} µs`, A.bcyan);
188
+ blank();
189
+
190
+ const hwBar = bar(hwConf, 18, A.bgreen);
191
+ const vmBar = bar(vmConf, 18, A.bred);
192
+ row('HW confidence', hwBar + ' ' + (hwConf * 100).toFixed(0) + '%');
193
+ row('VM confidence', vmBar + ' ' + (vmConf * 100).toFixed(0) + '%');
194
+
195
+ blank();
196
+ process.stderr.write(gray('├' + '─'.repeat(W + 2) + '┤') + '\n');
197
+ blank();
198
+
199
+ section('SIGNAL LAYERS');
200
+ blank();
201
+ row('Grid (ENF)', enfRegion, enfColor);
202
+ process.stderr.write(`${vbar} ${gray('GPU (thermal)')}${' '.repeat(10)}${gpuStr} ${vbar}\n`);
203
+ process.stderr.write(`${vbar} ${gray('DRAM refresh')} ${' '.repeat(11)}${dramStr} ${vbar}\n`);
204
+ process.stderr.write(`${vbar} ${gray('Behavioral (LLM)')}${' '.repeat(7)}${llmStr} ${vbar}\n`);
205
+
206
+ blank();
207
+ process.stderr.write(gray('├' + '─'.repeat(W + 2) + '┤') + '\n');
208
+ blank();
209
+
210
+ section('PROOF');
211
+ blank();
212
+ row('BLAKE3', hashShort, A.bcyan);
213
+ row('Nonce', (payload?.nonce ?? '').slice(0, 16) + '…', A.gray);
214
+ row('Elapsed', elapsed, A.gray);
215
+ if (result) {
216
+ row('Server verdict', result.valid ? 'valid' : 'rejected', result.valid ? A.bgreen : A.bred);
217
+ row('Score', ((result.score ?? 0) * 100).toFixed(1) + '%', A.bwhite);
218
+ if ((result.riskFlags ?? []).length > 0) {
219
+ blank();
220
+ row('Risk flags', result.riskFlags.join(', '), A.byellow);
221
+ }
222
+ }
223
+ blank();
224
+ process.stderr.write(botBorder + '\n\n');
225
+ }
226
+
227
+ /* ─── renderError ────────────────────────────────────────────────────────── */
228
+
229
+ /**
230
+ * Print a formatted error card for pulse() failures.
231
+ * @param {Error|string} err
232
+ */
233
+ export function renderError(err) {
234
+ if (!c()) return;
235
+ const msg = err?.message ?? String(err);
236
+ const W = 54;
237
+ const vbar = gray('│');
238
+
239
+ process.stderr.write('\n');
240
+ process.stderr.write(red('╭' + '─'.repeat(W + 2) + '╮') + '\n');
241
+ process.stderr.write(`${red('│')} ${red('✗')} ${bold('SVRN:PULSE — probe failed')}${' '.repeat(Math.max(0, W - 28))} ${red('│')}\n`);
242
+ process.stderr.write(red('├' + '─'.repeat(W + 2) + '┤') + '\n');
243
+ process.stderr.write(`${vbar} ${gray(msg.slice(0, W - 2).padEnd(W))} ${vbar}\n`);
244
+ process.stderr.write(red('╰' + '─'.repeat(W + 2) + '╯') + '\n\n');
245
+ }
246
+
247
+ /* ─── renderUpdateBanner ─────────────────────────────────────────────────── */
248
+
249
+ /**
250
+ * Render a simple one-line update available hint inline (used by middleware).
251
+ * @param {string} latest
252
+ */
253
+ export function renderInlineUpdateHint(latest) {
254
+ if (!c()) return;
255
+ process.stderr.write(
256
+ gray(' ╴╴╴ ') +
257
+ yel('update available ') +
258
+ gray(latest) +
259
+ ' ' + cyan('npm i @svrnsec/pulse@latest') +
260
+ gray(' ╴╴╴') +
261
+ '\n'
262
+ );
263
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * @svrnsec/pulse — Update Notifier
3
+ *
4
+ * Checks the npm registry for a newer version and prints a styled terminal
5
+ * notice when one is available. Non-blocking — the check runs in the
6
+ * background and only displays if a newer version is found before the
7
+ * process exits.
8
+ *
9
+ * Zero dependencies. Pure Node.js https module.
10
+ * Silent in browser environments and when stdout is not a TTY.
11
+ */
12
+
13
+ import { createRequire } from 'module';
14
+
15
+ /* ─── version from package.json ─────────────────────────────────────────── */
16
+
17
+ let _currentVersion = '0.0.0';
18
+ try {
19
+ const require = createRequire(import.meta.url);
20
+ _currentVersion = require('../package.json').version;
21
+ } catch {}
22
+
23
+ export const CURRENT_VERSION = _currentVersion;
24
+
25
+ /* ─── ANSI helpers ───────────────────────────────────────────────────────── */
26
+
27
+ const isTTY = () =>
28
+ typeof process !== 'undefined' &&
29
+ process.stdout?.isTTY === true &&
30
+ process.env?.NO_COLOR == null &&
31
+ process.env?.PULSE_NO_UPDATE == null;
32
+
33
+ const isNode = () => typeof process !== 'undefined' && typeof window === 'undefined';
34
+
35
+ const C = {
36
+ reset: '\x1b[0m',
37
+ bold: '\x1b[1m',
38
+ dim: '\x1b[2m',
39
+ // foreground
40
+ black: '\x1b[30m',
41
+ red: '\x1b[31m',
42
+ green: '\x1b[32m',
43
+ yellow: '\x1b[33m',
44
+ blue: '\x1b[34m',
45
+ magenta: '\x1b[35m',
46
+ cyan: '\x1b[36m',
47
+ white: '\x1b[37m',
48
+ // bright foreground
49
+ bgray: '\x1b[90m',
50
+ bred: '\x1b[91m',
51
+ bgreen: '\x1b[92m',
52
+ byellow: '\x1b[93m',
53
+ bblue: '\x1b[94m',
54
+ bmagenta: '\x1b[95m',
55
+ bcyan: '\x1b[96m',
56
+ bwhite: '\x1b[97m',
57
+ // background
58
+ bgBlack: '\x1b[40m',
59
+ bgYellow: '\x1b[43m',
60
+ bgBlue: '\x1b[44m',
61
+ bgCyan: '\x1b[46m',
62
+ };
63
+
64
+ const c = isTTY;
65
+ const ft = (code, s) => c() ? `${code}${s}${C.reset}` : s;
66
+
67
+ /* ─── box renderer ───────────────────────────────────────────────────────── */
68
+
69
+ /**
70
+ * Render a bordered notification box to stderr.
71
+ * Uses box-drawing characters and ANSI colors when the terminal supports them.
72
+ */
73
+ function _box(lines, opts = {}) {
74
+ const { borderColor = C.yellow, titleColor = C.bwhite } = opts;
75
+ const pad = 2;
76
+ const width = Math.max(...lines.map(l => _visLen(l))) + pad * 2;
77
+ const hr = '─'.repeat(width);
78
+ const bc = (s) => c() ? `${borderColor}${s}${C.reset}` : s;
79
+
80
+ const out = [
81
+ bc(`╭${hr}╮`),
82
+ ...lines.map(l => {
83
+ const vis = _visLen(l);
84
+ const fill = ' '.repeat(Math.max(0, width - vis - pad * 2));
85
+ return bc('│') + ' '.repeat(pad) + (c() ? l : _stripAnsi(l)) + fill + ' '.repeat(pad) + bc('│');
86
+ }),
87
+ bc(`╰${hr}╯`),
88
+ ];
89
+
90
+ process.stderr.write('\n' + out.join('\n') + '\n\n');
91
+ }
92
+
93
+ /* ─── version comparison ─────────────────────────────────────────────────── */
94
+
95
+ function _semverGt(a, b) {
96
+ const pa = a.replace(/[^0-9.]/g, '').split('.').map(Number);
97
+ const pb = b.replace(/[^0-9.]/g, '').split('.').map(Number);
98
+ for (let i = 0; i < 3; i++) {
99
+ const da = pa[i] ?? 0, db = pb[i] ?? 0;
100
+ if (da > db) return true;
101
+ if (da < db) return false;
102
+ }
103
+ return false;
104
+ }
105
+
106
+ /* ─── registry fetch ─────────────────────────────────────────────────────── */
107
+
108
+ async function _fetchLatest(pkg) {
109
+ return new Promise((resolve) => {
110
+ let resolved = false;
111
+ const done = (v) => { if (!resolved) { resolved = true; resolve(v); } };
112
+
113
+ const timeout = setTimeout(() => done(null), 3_000);
114
+
115
+ try {
116
+ const https = require('https');
117
+ const req = https.get(
118
+ `https://registry.npmjs.org/${encodeURIComponent(pkg)}/latest`,
119
+ { headers: { 'Accept': 'application/json', 'User-Agent': `${pkg}/${_currentVersion}` } },
120
+ (res) => {
121
+ let body = '';
122
+ res.setEncoding('utf8');
123
+ res.on('data', d => body += d);
124
+ res.on('end', () => {
125
+ clearTimeout(timeout);
126
+ try { done(JSON.parse(body).version ?? null); } catch { done(null); }
127
+ });
128
+ }
129
+ );
130
+ req.on('error', () => { clearTimeout(timeout); done(null); });
131
+ req.end();
132
+ } catch {
133
+ clearTimeout(timeout);
134
+ done(null);
135
+ }
136
+ });
137
+ }
138
+
139
+ // Lazy require for Node.js https module (avoids bundler issues)
140
+ let _httpsReq = null;
141
+ function require(m) {
142
+ if (typeof globalThis.require === 'function') return globalThis.require(m);
143
+ // CJS interop — only used server-side
144
+ if (typeof process !== 'undefined') {
145
+ const mod = process.mainModule?.require ?? (() => null);
146
+ return mod(m);
147
+ }
148
+ return null;
149
+ }
150
+
151
+ /* ─── checkForUpdate ─────────────────────────────────────────────────────── */
152
+
153
+ /**
154
+ * Check npm for a newer version of @svrnsec/pulse.
155
+ * Call once at process startup — the result is shown before process exit
156
+ * (or immediately if already resolved).
157
+ *
158
+ * @param {object} [opts]
159
+ * @param {boolean} [opts.silent=false] suppress output even when update exists
160
+ * @param {string} [opts.pkg='@svrnsec/pulse']
161
+ * @returns {Promise<{ current: string, latest: string|null, updateAvailable: boolean }>}
162
+ */
163
+ export async function checkForUpdate(opts = {}) {
164
+ const { silent = false, pkg = '@svrnsec/pulse' } = opts;
165
+
166
+ if (!isNode()) return { current: _currentVersion, latest: null, updateAvailable: false };
167
+
168
+ const latest = await _fetchLatest(pkg);
169
+ const updateAvailable = latest != null && _semverGt(latest, _currentVersion);
170
+
171
+ if (updateAvailable && !silent && isTTY()) {
172
+ _showUpdateBox(_currentVersion, latest, pkg);
173
+ }
174
+
175
+ return { current: _currentVersion, latest, updateAvailable };
176
+ }
177
+
178
+ /* ─── notifyOnExit ───────────────────────────────────────────────────────── */
179
+
180
+ let _notifyRegistered = false;
181
+
182
+ /**
183
+ * Register a one-time process 'exit' listener that prints the update notice
184
+ * after your application's own output has finished. This is the least
185
+ * intrusive way to show the notification.
186
+ *
187
+ * Called automatically by the package initialiser — you do not need to call
188
+ * this manually unless you want to control the timing.
189
+ *
190
+ * @param {object} [opts]
191
+ * @param {string} [opts.pkg='@svrnsec/pulse']
192
+ */
193
+ export function notifyOnExit(opts = {}) {
194
+ if (!isNode() || _notifyRegistered) return;
195
+ _notifyRegistered = true;
196
+
197
+ const pkg = opts.pkg ?? '@svrnsec/pulse';
198
+ let _latest = null;
199
+
200
+ // Start the background check immediately
201
+ _fetchLatest(pkg).then(v => { _latest = v; }).catch(() => {});
202
+
203
+ // Show the box just before the process exits (after all user output)
204
+ process.on('exit', () => {
205
+ if (_latest && _semverGt(_latest, _currentVersion) && isTTY()) {
206
+ _showUpdateBox(_currentVersion, _latest, pkg);
207
+ }
208
+ });
209
+ }
210
+
211
+ /* ─── banner ─────────────────────────────────────────────────────────────── */
212
+
213
+ /**
214
+ * Print the @svrnsec/pulse ASCII banner to stderr.
215
+ * Called once at package initialisation in Node.js environments.
216
+ */
217
+ export function printBanner() {
218
+ if (!isNode() || !isTTY()) return;
219
+
220
+ const v = ft(C.bgray, `v${_currentVersion}`);
221
+ const tag = ft(C.bmagenta + C.bold, 'SVRN');
222
+ const pkg = ft(C.bwhite + C.bold, ':PULSE');
223
+
224
+ process.stderr.write(
225
+ '\n' +
226
+ ft(C.bgray, ' ┌─────────────────────────────────────┐') + '\n' +
227
+ ft(C.bgray, ' │') + ` ${tag}${pkg} ` + ft(C.bgray, '─ Physical Turing Test │') + '\n' +
228
+ ft(C.bgray, ' │') + ` ${ft(C.bgray, 'Hardware-Biological Symmetry Protocol')} ` + ft(C.bgray, '│') + '\n' +
229
+ ft(C.bgray, ' │') + ` ${ft(C.bcyan, 'npm i @svrnsec/pulse')} ${' '.repeat(16)}${v} ` + ft(C.bgray, '│') + '\n' +
230
+ ft(C.bgray, ' └─────────────────────────────────────┘') + '\n\n'
231
+ );
232
+ }
233
+
234
+ /* ─── _showUpdateBox ─────────────────────────────────────────────────────── */
235
+
236
+ function _showUpdateBox(current, latest, pkg) {
237
+ const arrow = ft(C.bgray, '→');
238
+ const oldV = ft(C.bred, current);
239
+ const newV = ft(C.bgreen + C.bold, latest);
240
+ const cmd = ft(C.bcyan + C.bold, `npm i ${pkg}@latest`);
241
+ const notice = ft(C.byellow + C.bold, ' UPDATE AVAILABLE ');
242
+
243
+ _box([
244
+ notice,
245
+ '',
246
+ ` ${oldV} ${arrow} ${newV}`,
247
+ '',
248
+ ` Run: ${cmd}`,
249
+ '',
250
+ ft(C.bgray, ` Changelog: https://github.com/ayronny14-alt/Svrn-Pulse-Security/releases`),
251
+ ], { borderColor: C.byellow });
252
+ }
253
+
254
+ /* ─── ANSI utilities ─────────────────────────────────────────────────────── */
255
+
256
+ // Measure visible length of string (strip ANSI escape codes)
257
+ function _visLen(s) {
258
+ return _stripAnsi(s).length;
259
+ }
260
+
261
+ function _stripAnsi(s) {
262
+ // eslint-disable-next-line no-control-regex
263
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
264
+ }