imprint-mcp 0.4.0 → 0.4.2

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.
@@ -7,8 +7,9 @@
7
7
 
8
8
  import { createHash } from 'node:crypto';
9
9
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
10
- import { resolve as pathResolve } from 'node:path';
10
+ import { basename, resolve as pathResolve } from 'node:path';
11
11
  import { runWithLadder } from './backend-ladder.ts';
12
+ import type { CdpBrowserFetch } from './cdp-browser-fetch.ts';
12
13
  import { createLog } from './log.ts';
13
14
  import { imprintHomeDir } from './paths.ts';
14
15
  import { availableSitesHint } from './sites.ts';
@@ -42,6 +43,41 @@ interface ProbeBackendsResult {
42
43
  }
43
44
 
44
45
  const log = createLog('probe');
46
+ const DEFAULT_PREFERRED_MAX_MS = 90_000;
47
+
48
+ type BackendProbeCandidate = {
49
+ backend: ConcreteBackend;
50
+ durationMs: number;
51
+ rankingDurationMs?: number;
52
+ coldDurationMs?: number;
53
+ warmDurationMs?: number;
54
+ tooSlow: boolean;
55
+ };
56
+
57
+ type BackendRuntimeAttempt = {
58
+ backend: ConcreteBackend;
59
+ outcome: 'ok' | 'escalate' | 'failed' | 'unavailable';
60
+ detail: string;
61
+ durationMs: number;
62
+ };
63
+
64
+ export type BackendsCacheStatus =
65
+ | {
66
+ status: 'missing';
67
+ path: string | null;
68
+ remediation: string;
69
+ }
70
+ | {
71
+ status: 'ok';
72
+ path: string;
73
+ cache: BackendsCache;
74
+ }
75
+ | {
76
+ status: 'stale' | 'invalid';
77
+ path: string;
78
+ reason: string;
79
+ remediation: string;
80
+ };
45
81
 
46
82
  export async function probeBackends(opts: ProbeBackendsOptions): Promise<ProbeBackendsResult> {
47
83
  const assetRoot = opts.assetRoot ?? imprintHomeDir();
@@ -59,7 +95,36 @@ export async function probeBackends(opts: ProbeBackendsOptions): Promise<ProbeBa
59
95
  `No generated tool found for site "${opts.site}".\n${availableSitesHint(assetRoot, opts.site)}\n→ run \`imprint teach ${opts.site}\` or \`imprint emit ~/.imprint/${opts.site}/<toolName>/workflow.json\` first.`,
60
96
  );
61
97
  }
62
- const outPath = opts.outPath ?? pathResolve(tool.dir, 'backends.json');
98
+ return await probeResolvedTool(opts, assetRoot, tool, opts.outPath);
99
+ }
100
+
101
+ export async function probeAllBackends(
102
+ opts: Omit<ProbeBackendsOptions, 'outPath' | 'toolName'>,
103
+ ): Promise<ProbeBackendsResult[]> {
104
+ const assetRoot = opts.assetRoot ?? imprintHomeDir();
105
+ const discovered = await discoverTools(assetRoot, opts.site, '[imprint probe]');
106
+ if (discovered.length === 0) {
107
+ throw new Error(
108
+ `No generated tools found for site "${opts.site}".\n${availableSitesHint(assetRoot, opts.site)}\n→ run \`imprint teach ${opts.site}\` or \`imprint emit ~/.imprint/${opts.site}/<toolName>/workflow.json\` first.`,
109
+ );
110
+ }
111
+
112
+ const results: ProbeBackendsResult[] = [];
113
+ for (const tool of [...discovered].sort((a, b) =>
114
+ a.workflow.toolName.localeCompare(b.workflow.toolName),
115
+ )) {
116
+ results.push(await probeResolvedTool(opts, assetRoot, tool));
117
+ }
118
+ return results;
119
+ }
120
+
121
+ async function probeResolvedTool(
122
+ opts: Pick<ProbeBackendsOptions, 'site' | 'paramOverrides'>,
123
+ assetRoot: string,
124
+ tool: ResolvedTool,
125
+ explicitOutPath?: string,
126
+ ): Promise<ProbeBackendsResult> {
127
+ const outPath = explicitOutPath ?? pathResolve(tool.dir, 'backends.json');
63
128
 
64
129
  const params = resolveParams(tool, opts.paramOverrides);
65
130
 
@@ -72,59 +137,96 @@ export async function probeBackends(opts: ProbeBackendsOptions): Promise<ProbeBa
72
137
  // falls through fetch-bootstrap (~30-60s) before reaching the spliced-in
73
138
  // cdp-replay rung, wasting time on every call.
74
139
  const stealthCache = new Map<string, StealthFetch>();
140
+ const cdpPool = new Map<string, CdpBrowserFetch>();
75
141
  const allBackends: ConcreteBackend[] = workflowNeedsBootstrap(tool.workflow)
76
142
  ? ['fetch', 'fetch-bootstrap', 'cdp-replay', 'stealth-fetch', 'playbook']
77
143
  : ['fetch', 'stealth-fetch', 'playbook'];
78
144
  const results: BackendsCache['results'] = {};
79
- const working: ConcreteBackend[] = [];
80
-
81
- for (const backend of allBackends) {
82
- log(`probing ${backend}…`);
83
- const t0 = Date.now();
84
- const { result, attempts } = await runWithLadder(
85
- [backend],
86
- tool,
87
- params,
88
- assetRoot,
89
- stealthCache,
90
- );
91
- const durationMs = Date.now() - t0;
92
- const attempt = attempts[0];
145
+ const working: BackendProbeCandidate[] = [];
146
+ const preferredMaxMs = preferredBackendMaxMs();
93
147
 
94
- if (!attempt) {
95
- results[backend] = { outcome: 'skipped', detail: 'no attempt recorded' };
96
- continue;
97
- }
148
+ try {
149
+ for (const backend of allBackends) {
150
+ log(`probing ${backend}…`);
151
+ const t0 = Date.now();
152
+ const { result, attempts } = await runWithLadder(
153
+ [backend],
154
+ tool,
155
+ params,
156
+ assetRoot,
157
+ stealthCache,
158
+ backend === 'cdp-replay' ? { cdpPool, skipBootstrapSplice: true } : undefined,
159
+ );
160
+ const durationMs = Date.now() - t0;
161
+ const attempt = attempts[0];
98
162
 
99
- if (attempt.outcome === 'unavailable') {
100
- results[backend] = { outcome: 'unavailable', detail: attempt.detail };
101
- log(` ${backend}: unavailable (${attempt.detail})`);
102
- continue;
103
- }
163
+ if (!attempt) {
164
+ results[backend] = { outcome: 'skipped', detail: 'no attempt recorded' };
165
+ continue;
166
+ }
104
167
 
105
- if (result.ok) {
106
- results[backend] = { outcome: 'ok', durationMs };
107
- working.push(backend);
108
- log(` ${backend}: OK in ${durationMs}ms`);
109
- continue;
110
- }
168
+ if (attempt.outcome === 'unavailable') {
169
+ results[backend] = { outcome: 'unavailable', detail: attempt.detail };
170
+ log(` ${backend}: unavailable (${attempt.detail})`);
171
+ continue;
172
+ }
111
173
 
112
- if (result.error === 'FORBIDDEN') {
113
- results[backend] = {
114
- outcome: 'forbidden',
115
- durationMs,
116
- detail: result.message.slice(0, 200),
117
- };
118
- log(` ${backend}: FORBIDDEN`);
119
- } else {
120
- results[backend] = {
121
- outcome: 'failed',
122
- durationMs,
123
- error: result.error,
124
- detail: result.message.slice(0, 200),
125
- };
126
- log(` ${backend}: ${result.error} — ${result.message.slice(0, 100)}`);
174
+ if (result.ok) {
175
+ const warm =
176
+ backend === 'cdp-replay'
177
+ ? await probeWarmCdpReplay(tool, params, assetRoot, stealthCache, cdpPool)
178
+ : null;
179
+ const tooSlow = durationMs > preferredMaxMs;
180
+ const rankingDurationMs = warm?.ok ? warm.durationMs : durationMs;
181
+ const detailParts: string[] = [];
182
+ if (tooSlow)
183
+ detailParts.push(`cold start exceeded preferred backend threshold ${preferredMaxMs}ms`);
184
+ if (warm?.ok) detailParts.push(`warm cdp-replay succeeded in ${warm.durationMs}ms`);
185
+ else if (warm) detailParts.push(`warm cdp-replay failed: ${warm.detail}`);
186
+ results[backend] = {
187
+ outcome: 'ok',
188
+ durationMs,
189
+ ...(backend === 'cdp-replay'
190
+ ? {
191
+ coldDurationMs: durationMs,
192
+ ...(warm?.ok ? { warmDurationMs: warm.durationMs, rankingDurationMs } : {}),
193
+ }
194
+ : {}),
195
+ ...(tooSlow ? { tooSlow: true } : {}),
196
+ ...(detailParts.length ? { detail: detailParts.join('; ') } : {}),
197
+ };
198
+ working.push({
199
+ backend,
200
+ durationMs,
201
+ ...(backend === 'cdp-replay' ? { coldDurationMs: durationMs } : {}),
202
+ ...(warm?.ok ? { warmDurationMs: warm.durationMs, rankingDurationMs } : {}),
203
+ tooSlow,
204
+ });
205
+ log(
206
+ ` ${backend}: OK in ${durationMs}ms${warm?.ok ? ` (warm ${warm.durationMs}ms)` : ''}${tooSlow ? ' (cold slow)' : ''}`,
207
+ );
208
+ continue;
209
+ }
210
+
211
+ if (result.error === 'FORBIDDEN') {
212
+ results[backend] = {
213
+ outcome: 'forbidden',
214
+ durationMs,
215
+ detail: result.message.slice(0, 200),
216
+ };
217
+ log(` ${backend}: FORBIDDEN`);
218
+ } else {
219
+ results[backend] = {
220
+ outcome: 'failed',
221
+ durationMs,
222
+ error: result.error,
223
+ detail: result.message.slice(0, 200),
224
+ };
225
+ log(` ${backend}: ${result.error} — ${result.message.slice(0, 100)}`);
226
+ }
127
227
  }
228
+ } finally {
229
+ await closeProbeCdpPool(cdpPool);
128
230
  }
129
231
 
130
232
  if (working.length === 0) {
@@ -135,23 +237,71 @@ export async function probeBackends(opts: ProbeBackendsOptions): Promise<ProbeBa
135
237
  );
136
238
  }
137
239
 
240
+ const preferredOrder = rankSuccessfulBackends(working);
138
241
  const cache: BackendsCache = {
139
242
  probedAt: new Date().toISOString(),
140
243
  imprintVersion: VERSION,
141
244
  schemaVersion: 2,
142
245
  workflowHash: workflowHash(tool.workflow),
143
246
  capabilityHash: capabilityHash(tool.workflow),
144
- preferredOrder: working,
247
+ preferredOrder,
145
248
  results,
146
249
  };
147
250
  BackendsCacheSchema.parse(cache); // catch schema drift early
148
251
 
149
252
  writeFileSync(outPath, `${JSON.stringify(cache, null, 2)}\n`);
150
- log(`wrote ${outPath} — preferred: ${working.join(' → ')}`);
253
+ log(`wrote ${outPath} — preferred: ${preferredOrder.join(' → ')}`);
151
254
 
152
255
  return { cache, outPath };
153
256
  }
154
257
 
258
+ export function rankSuccessfulBackends(candidates: BackendProbeCandidate[]): ConcreteBackend[] {
259
+ return [...candidates]
260
+ .sort((a, b) => {
261
+ if (a.tooSlow !== b.tooSlow) return a.tooSlow ? 1 : -1;
262
+ return effectiveRankingDuration(a) - effectiveRankingDuration(b);
263
+ })
264
+ .map((c) => c.backend);
265
+ }
266
+
267
+ function effectiveRankingDuration(candidate: BackendProbeCandidate): number {
268
+ return candidate.rankingDurationMs ?? candidate.warmDurationMs ?? candidate.durationMs;
269
+ }
270
+
271
+ function backendResultTooSlow(result: BackendsCache['results'][string] | undefined): boolean {
272
+ return result?.outcome === 'ok' && result.tooSlow === true;
273
+ }
274
+
275
+ async function probeWarmCdpReplay(
276
+ tool: ResolvedTool,
277
+ params: Record<string, string | number | boolean>,
278
+ assetRoot: string,
279
+ stealthCache: Map<string, StealthFetch>,
280
+ cdpPool: Map<string, CdpBrowserFetch>,
281
+ ): Promise<{ ok: true; durationMs: number } | { ok: false; detail: string } | null> {
282
+ if (!cdpPool.has(tool.site)) return null;
283
+ log('probing cdp-replay warm reuse…');
284
+ const t0 = Date.now();
285
+ const { result } = await runWithLadder(['cdp-replay'], tool, params, assetRoot, stealthCache, {
286
+ cdpPool,
287
+ skipBootstrapSplice: true,
288
+ });
289
+ const durationMs = Date.now() - t0;
290
+ if (result.ok) return { ok: true, durationMs };
291
+ return { ok: false, detail: `${result.error}: ${result.message.slice(0, 160)}` };
292
+ }
293
+
294
+ async function closeProbeCdpPool(cdpPool: Map<string, CdpBrowserFetch>): Promise<void> {
295
+ const sessions = [...cdpPool.values()];
296
+ cdpPool.clear();
297
+ await Promise.allSettled(sessions.map((session) => session.close()));
298
+ }
299
+
300
+ function preferredBackendMaxMs(): number {
301
+ const raw = Number(process.env.IMPRINT_BACKEND_PREFERRED_MAX_MS ?? DEFAULT_PREFERRED_MAX_MS);
302
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_PREFERRED_MAX_MS;
303
+ }
304
+
155
305
  function workflowNeedsBootstrap(workflow: ResolvedTool['workflow']): boolean {
156
306
  if (workflow.bootstrap) return true;
157
307
  return workflow.requests.some((r) =>
@@ -177,16 +327,19 @@ function capabilityHash(workflow: ResolvedTool['workflow']): string {
177
327
  return createHash('sha256').update(JSON.stringify(caps)).digest('hex');
178
328
  }
179
329
 
180
- /** Read backends.json. Returns null on missing/malformed runtime
181
- * falls back to the default ladder; a stale cache must never break cron. */
182
- export function loadBackendsCache(
330
+ /** Read backends.json with status information. Runtime can still fall back to
331
+ * the default ladder, while status commands can explain why a cache was not
332
+ * usable. */
333
+ export function loadBackendsCacheStatus(
183
334
  site: string,
184
335
  _assetRoot: string,
185
336
  toolDir?: string,
186
- ): BackendsCache | null {
187
- if (!toolDir) return null;
337
+ opts: { warn?: boolean; toolName?: string } = {},
338
+ ): BackendsCacheStatus {
339
+ const remediation = backendsCacheRemediation(site, opts.toolName ?? toolDirName(toolDir));
340
+ if (!toolDir) return { status: 'missing', path: null, remediation };
188
341
  const path = pathResolve(toolDir, 'backends.json');
189
- if (!existsSync(path)) return null;
342
+ if (!existsSync(path)) return { status: 'missing', path, remediation };
190
343
  try {
191
344
  const raw = JSON.parse(readFileSync(path, 'utf8'));
192
345
  const parsed = BackendsCacheSchema.parse(raw);
@@ -195,20 +348,129 @@ export function loadBackendsCache(
195
348
  if (existsSync(workflowPath)) {
196
349
  const currentHash = workflowHashSync(readFileSync(workflowPath, 'utf8'));
197
350
  if (currentHash !== parsed.workflowHash) {
198
- process.stderr.write(
199
- `[imprint] backends.json at ${path} is stale for current workflow — ignoring (run \`imprint probe-backends ${site}\` to regenerate)\n`,
200
- );
201
- return null;
351
+ const reason = 'workflow hash changed';
352
+ if (opts.warn !== false) {
353
+ process.stderr.write(
354
+ `[imprint] backends.json at ${path} is stale for current workflow — ignoring (run \`${remediation}\` to regenerate)\n`,
355
+ );
356
+ }
357
+ return { status: 'stale', path, reason, remediation };
202
358
  }
203
359
  }
204
360
  }
205
- return parsed;
361
+ return { status: 'ok', path, cache: parsed };
206
362
  } catch (err) {
207
- process.stderr.write(
208
- `[imprint] backends.json at ${path} failed to parse — ignoring (run \`imprint probe-backends ${site}\` to regenerate): ${err instanceof Error ? err.message : String(err)}\n`,
209
- );
210
- return null;
363
+ const reason = err instanceof Error ? err.message : String(err);
364
+ if (opts.warn !== false) {
365
+ process.stderr.write(
366
+ `[imprint] backends.json at ${path} failed to parse — ignoring (run \`${remediation}\` to regenerate): ${reason}\n`,
367
+ );
368
+ }
369
+ return { status: 'invalid', path, reason, remediation };
370
+ }
371
+ }
372
+
373
+ /** Read backends.json. Returns null on missing/malformed — runtime
374
+ * falls back to the default ladder; a stale cache must never break cron. */
375
+ export function loadBackendsCache(
376
+ site: string,
377
+ _assetRoot: string,
378
+ toolDir?: string,
379
+ ): BackendsCache | null {
380
+ const status = loadBackendsCacheStatus(site, _assetRoot, toolDir);
381
+ return status.status === 'ok' ? status.cache : null;
382
+ }
383
+
384
+ export function persistRuntimeBackendsCache(opts: {
385
+ tool: ResolvedTool;
386
+ assetRoot: string;
387
+ usedBackend: ConcreteBackend;
388
+ attempts: BackendRuntimeAttempt[];
389
+ }): BackendsCache | null {
390
+ const status = loadBackendsCacheStatus(opts.tool.site, opts.assetRoot, opts.tool.dir, {
391
+ warn: false,
392
+ toolName: opts.tool.workflow.toolName,
393
+ });
394
+ const results: BackendsCache['results'] =
395
+ status.status === 'ok' ? { ...status.cache.results } : {};
396
+
397
+ for (const attempt of opts.attempts) {
398
+ if (attempt.outcome === 'ok') {
399
+ const tooSlow = attempt.durationMs > preferredBackendMaxMs();
400
+ results[attempt.backend] = {
401
+ outcome: 'ok',
402
+ durationMs: attempt.durationMs,
403
+ ...(tooSlow
404
+ ? {
405
+ tooSlow: true,
406
+ detail: `exceeded preferred backend threshold ${preferredBackendMaxMs()}ms`,
407
+ }
408
+ : {}),
409
+ };
410
+ } else if (attempt.outcome === 'unavailable') {
411
+ results[attempt.backend] = { outcome: 'unavailable', detail: attempt.detail };
412
+ } else if (attempt.detail.startsWith('FORBIDDEN:')) {
413
+ results[attempt.backend] = {
414
+ outcome: 'forbidden',
415
+ durationMs: attempt.durationMs,
416
+ detail: attempt.detail.slice(0, 200),
417
+ };
418
+ } else {
419
+ const error = attempt.detail.split(':')[0] || 'UNKNOWN';
420
+ results[attempt.backend] = {
421
+ outcome: 'failed',
422
+ durationMs: attempt.durationMs,
423
+ error,
424
+ detail: attempt.detail.slice(0, 200),
425
+ };
426
+ }
211
427
  }
428
+
429
+ const existingPreferred = status.status === 'ok' ? status.cache.preferredOrder : [];
430
+ const observedOkAttempts = opts.attempts
431
+ .filter((a) => a.outcome === 'ok')
432
+ .sort((a, b) => a.durationMs - b.durationMs);
433
+ const observedOk = observedOkAttempts.map((a) => a.backend);
434
+ const slowObservedOk = observedOkAttempts
435
+ .filter((a) => a.durationMs > preferredBackendMaxMs())
436
+ .map((a) => a.backend);
437
+ const fastObservedOk = observedOk.filter((backend) => !slowObservedOk.includes(backend));
438
+ const usedOkAttempt = observedOkAttempts.find((a) => a.backend === opts.usedBackend);
439
+ const usedBackendTooSlow =
440
+ usedOkAttempt !== undefined && usedOkAttempt.durationMs > preferredBackendMaxMs();
441
+ const existingFast = existingPreferred.filter(
442
+ (backend) => !backendResultTooSlow(results[backend]),
443
+ );
444
+ const existingSlow = existingPreferred.filter((backend) =>
445
+ backendResultTooSlow(results[backend]),
446
+ );
447
+ const structuralFallbacks: ConcreteBackend[] = existsSync(
448
+ pathResolve(opts.tool.dir, 'playbook.yaml'),
449
+ )
450
+ ? ['playbook']
451
+ : [];
452
+ const preferredOrder = uniqueBackends([
453
+ ...(usedOkAttempt && !usedBackendTooSlow ? [opts.usedBackend] : []),
454
+ ...existingFast,
455
+ ...fastObservedOk,
456
+ ...existingSlow,
457
+ ...slowObservedOk,
458
+ ...(usedOkAttempt && usedBackendTooSlow ? [opts.usedBackend] : []),
459
+ ...structuralFallbacks,
460
+ ]);
461
+ const cache: BackendsCache = {
462
+ probedAt: new Date().toISOString(),
463
+ imprintVersion: VERSION,
464
+ schemaVersion: 2,
465
+ workflowHash: workflowHash(opts.tool.workflow),
466
+ capabilityHash: capabilityHash(opts.tool.workflow),
467
+ preferredOrder,
468
+ results,
469
+ };
470
+
471
+ BackendsCacheSchema.parse(cache);
472
+ writeFileSync(pathResolve(opts.tool.dir, 'backends.json'), `${JSON.stringify(cache, null, 2)}\n`);
473
+ return cache;
212
474
  }
213
475
 
214
476
  function workflowHashSync(workflowJson: string): string {
@@ -217,6 +479,27 @@ function workflowHashSync(workflowJson: string): string {
217
479
  .digest('hex');
218
480
  }
219
481
 
482
+ function backendsCacheRemediation(site: string, toolName?: string): string {
483
+ return toolName
484
+ ? `imprint probe-backends ${site} --tool ${toolName}`
485
+ : `imprint probe-backends ${site}`;
486
+ }
487
+
488
+ function toolDirName(toolDir?: string): string | undefined {
489
+ return toolDir ? basename(toolDir) : undefined;
490
+ }
491
+
492
+ function uniqueBackends(backends: ConcreteBackend[]): ConcreteBackend[] {
493
+ const seen = new Set<ConcreteBackend>();
494
+ const out: ConcreteBackend[] = [];
495
+ for (const backend of backends) {
496
+ if (seen.has(backend)) continue;
497
+ seen.add(backend);
498
+ out.push(backend);
499
+ }
500
+ return out;
501
+ }
502
+
220
503
  /** Param priority: caller overrides → cron.json → workflow defaults. */
221
504
  function resolveParams(
222
505
  tool: ResolvedTool,
@@ -380,6 +380,18 @@ const BackendProbeResultSchema = z.discriminatedUnion('outcome', [
380
380
  z.object({
381
381
  outcome: z.literal('ok'),
382
382
  durationMs: z.number(),
383
+ /** Optional cdp-replay cold-start measurement. `durationMs` remains the
384
+ * first-call duration for backward compatibility. */
385
+ coldDurationMs: z.number().optional(),
386
+ /** Optional cdp-replay warm-pool measurement from a second call against the
387
+ * same pooled Chrome. Used to explain why CDP may outrank stealth when its
388
+ * cold start is still under the operator timeout. */
389
+ warmDurationMs: z.number().optional(),
390
+ /** Effective duration used for preference ranking when it differs from the
391
+ * first-call duration, e.g. warm cdp-replay. */
392
+ rankingDurationMs: z.number().optional(),
393
+ tooSlow: z.boolean().optional(),
394
+ detail: z.string().optional(),
383
395
  }),
384
396
  z.object({
385
397
  outcome: z.literal('forbidden'),
@@ -0,0 +1,73 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import semver from 'semver';
3
+ import { VERSION } from './version.ts';
4
+
5
+ const PACKAGE_NAME = 'imprint-mcp';
6
+ const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
7
+
8
+ interface UpdateCheckResult {
9
+ current: string;
10
+ latest: string;
11
+ updateAvailable: boolean;
12
+ }
13
+
14
+ interface UpdateResult {
15
+ ok: boolean;
16
+ from: string;
17
+ to: string;
18
+ error?: string;
19
+ }
20
+
21
+ export async function checkForUpdate(): Promise<UpdateCheckResult | null> {
22
+ try {
23
+ const res = await fetch(REGISTRY_URL, {
24
+ headers: { accept: 'application/json' },
25
+ signal: AbortSignal.timeout(5_000),
26
+ });
27
+ if (!res.ok) return null;
28
+ const data = (await res.json()) as { version?: string };
29
+ const latest = data.version;
30
+ if (!latest) return null;
31
+ return { current: VERSION, latest, updateAvailable: semver.gt(latest, VERSION) };
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ const IS_COMPILED = typeof (globalThis as Record<string, unknown>).__IMPRINT_VERSION__ === 'string';
38
+
39
+ export async function performUpdate(): Promise<UpdateResult> {
40
+ const check = await checkForUpdate();
41
+ if (!check) {
42
+ return { ok: false, from: VERSION, to: VERSION, error: 'could not reach npm registry' };
43
+ }
44
+ if (!check.updateAvailable) {
45
+ return { ok: true, from: VERSION, to: VERSION };
46
+ }
47
+
48
+ const result = IS_COMPILED
49
+ ? spawnSync(
50
+ 'bash',
51
+ [
52
+ '-c',
53
+ 'curl -fsSL https://raw.githubusercontent.com/ashaychangwani/imprint/main/scripts/install.sh | bash',
54
+ ],
55
+ { stdio: 'pipe', timeout: 60_000 },
56
+ )
57
+ : spawnSync('bun', ['install', '-g', `${PACKAGE_NAME}@latest`], {
58
+ stdio: 'pipe',
59
+ timeout: 60_000,
60
+ });
61
+
62
+ if (result.status !== 0) {
63
+ const stderr = result.stderr?.toString().trim();
64
+ return {
65
+ ok: false,
66
+ from: check.current,
67
+ to: check.latest,
68
+ error: stderr || result.error?.message || `install exited with code ${result.status}`,
69
+ };
70
+ }
71
+
72
+ return { ok: true, from: check.current, to: check.latest };
73
+ }