imprint-mcp 0.4.1 → 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.
package/README.md CHANGED
@@ -180,6 +180,8 @@ When an API call gets blocked, Imprint doesn't jump to DOM replay. It escalates
180
180
 
181
181
  The full order is `fetch → fetch-bootstrap → cdp-replay → stealth-fetch → playbook`; `auto` mode walks it and stops at the first backend that works.
182
182
 
183
+ For bot-protected sites, `imprint probe-backends <site> --tool <toolName>` writes a `backends.json` preference cache so cron and MCP start from the known-good backend instead of rediscovering blocked rungs. Use `imprint probe-backends <site> --all` to refresh every tool in a multi-tool site; `imprint mcp status` reports stale or invalid backend caches before they quietly fall back to the default ladder. CDP replay records both cold and warm timings when it succeeds: a timeout-safe cold start may rank by its fast warm runtime, but a cold start above the preferred threshold stays behind cold-safe backends in durable cache order.
184
+
183
185
  Every recording compiles to *both* `workflow.json` and `playbook.yaml`, so the ladder always has a DOM fallback.
184
186
 
185
187
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imprint-mcp",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Teach an AI agent how to use any website. Once. Records a real browser session + narration; generates a deterministic MCP tool plus a DOM-replay playbook fallback.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -63,6 +63,7 @@
63
63
  "playwright-extra": "^4.3.6",
64
64
  "puppeteer-extra-plugin-stealth": "^2.11.2",
65
65
  "redactum": "^1.1.0",
66
+ "semver": "^7.8.4",
66
67
  "yaml": "^2.8.4",
67
68
  "zod": "^3.24.0"
68
69
  },
@@ -72,6 +73,7 @@
72
73
  "@types/chrome-remote-interface": "^0.31.14",
73
74
  "@types/node": "^22.10.0",
74
75
  "@types/node-cron": "^3.0.11",
76
+ "@types/semver": "^7.7.1",
75
77
  "knip": "^5.0.0",
76
78
  "madge": "^8.0.0",
77
79
  "typescript": "^5.7.0"
package/src/cli.ts CHANGED
@@ -346,9 +346,13 @@ export const VERB_HELP: Record<string, VerbHelp> = {
346
346
  },
347
347
  'probe-backends': {
348
348
  summary: 'Try each backend once and cache the working order to backends.json.',
349
- usage: ['imprint probe-backends <site> [--tool <toolName>] [--out <path>] [--param k=v]…'],
349
+ usage: [
350
+ 'imprint probe-backends <site> [--tool <toolName>] [--out <path>] [--param k=v]…',
351
+ 'imprint probe-backends <site> --all [--param k=v]…',
352
+ ],
350
353
  flags: [
351
354
  { name: '--tool <toolName>', description: 'Select a generated tool for multi-tool sites.' },
355
+ { name: '--all', description: 'Probe every generated tool for the site.' },
352
356
  { name: '--out <path>', description: 'Override backends.json output path.' },
353
357
  { name: '--param k=v', description: 'Override a workflow parameter (repeatable).' },
354
358
  ],
@@ -451,6 +455,17 @@ export const VERB_HELP: Record<string, VerbHelp> = {
451
455
  ],
452
456
  example: 'imprint mcp status',
453
457
  },
458
+ update: {
459
+ summary: 'Check for updates and install the latest version of imprint.',
460
+ usage: ['imprint update [--check]'],
461
+ flags: [
462
+ {
463
+ name: '--check',
464
+ description: 'Only check whether an update is available; do not install.',
465
+ },
466
+ ],
467
+ example: 'imprint update',
468
+ },
454
469
  };
455
470
 
456
471
  function printVerbHelp(verb: string): void {
@@ -621,7 +636,7 @@ async function main(argv: string[]): Promise<number> {
621
636
 
622
637
  case 'doctor': {
623
638
  const { doctor, reportDoctor } = await import('./imprint/doctor.ts');
624
- const report = reportDoctor(doctor());
639
+ const report = reportDoctor(await doctor());
625
640
  for (const line of report.lines) console.log(line);
626
641
  return report.ok ? 0 : 1;
627
642
  }
@@ -1201,6 +1216,7 @@ async function main(argv: string[]): Promise<number> {
1201
1216
  const { values } = parseArgs({
1202
1217
  args: argv.slice(2),
1203
1218
  options: {
1219
+ all: { type: 'boolean' },
1204
1220
  out: { type: 'string' },
1205
1221
  tool: { type: 'string' },
1206
1222
  param: { type: 'string', multiple: true },
@@ -1209,7 +1225,30 @@ async function main(argv: string[]): Promise<number> {
1209
1225
  });
1210
1226
  const overrides = tryParseParamKV(values.param);
1211
1227
  if (overrides === null) return 2;
1212
- const { probeBackends } = await import('./imprint/probe-backends.ts');
1228
+ if (values.all && values.tool) {
1229
+ console.error('error: --all cannot be combined with --tool');
1230
+ return 2;
1231
+ }
1232
+ if (values.all && values.out) {
1233
+ console.error('error: --all cannot be combined with --out');
1234
+ return 2;
1235
+ }
1236
+ const { probeAllBackends, probeBackends } = await import('./imprint/probe-backends.ts');
1237
+ if (values.all) {
1238
+ const results = await probeAllBackends({
1239
+ site,
1240
+ paramOverrides: Object.keys(overrides).length > 0 ? overrides : undefined,
1241
+ });
1242
+ for (const result of results) {
1243
+ console.log(`[imprint] probed → ${result.outPath}`);
1244
+ console.log(`[imprint] preferred order: ${result.cache.preferredOrder.join(' → ')}`);
1245
+ }
1246
+ console.log('');
1247
+ console.log(
1248
+ '[imprint] cron + mcp-server now skip futile rungs at startup using these caches.',
1249
+ );
1250
+ return 0;
1251
+ }
1213
1252
  const result = await probeBackends({
1214
1253
  site,
1215
1254
  outPath: values.out,
@@ -1482,6 +1521,38 @@ async function main(argv: string[]): Promise<number> {
1482
1521
  return 0;
1483
1522
  }
1484
1523
 
1524
+ case 'update': {
1525
+ const { checkForUpdate, performUpdate } = await import('./imprint/update.ts');
1526
+ const checkOnly = argv.slice(1).includes('--check');
1527
+ if (checkOnly) {
1528
+ const result = await checkForUpdate();
1529
+ if (!result) {
1530
+ console.error('Could not reach npm registry.');
1531
+ return 1;
1532
+ }
1533
+ console.log(`Current: v${result.current}`);
1534
+ console.log(`Latest: v${result.latest}`);
1535
+ if (result.updateAvailable) {
1536
+ console.log('\nUpdate available — run `imprint update` to install.');
1537
+ } else {
1538
+ console.log('\nAlready up to date.');
1539
+ }
1540
+ return 0;
1541
+ }
1542
+ console.log('Checking for updates...');
1543
+ const result = await performUpdate();
1544
+ if (result.from === result.to && result.ok) {
1545
+ console.log(`imprint v${result.from} is already the latest version.`);
1546
+ return 0;
1547
+ }
1548
+ if (result.ok) {
1549
+ console.log(`Updated imprint: v${result.from} → v${result.to}`);
1550
+ return 0;
1551
+ }
1552
+ console.error(`Update failed: ${result.error}`);
1553
+ return 1;
1554
+ }
1555
+
1485
1556
  default: {
1486
1557
  const suggestion = closestVerb(verb);
1487
1558
  const tail = suggestion ? `did you mean \`imprint ${suggestion}\`?` : 'run `imprint --help`';
@@ -317,7 +317,8 @@ export async function runWithLadder(
317
317
  result = await runCdpReplay(tool, params, options?.cdpPool);
318
318
  break;
319
319
  case 'stealth-fetch': {
320
- const sf = ensureStealthFetch(tool, stealthCache);
320
+ const paramsWithDefaults = withWorkflowDefaults(tool.workflow, params);
321
+ const sf = await ensureStealthFetch(tool, stealthCache, paramsWithDefaults);
321
322
  // When the workflow declares a bootstrap block, mint its declared
322
323
  // session-token state (CSRF cookies etc.) from the SAME stealth
323
324
  // session that provides the transport cookies. Without this, a
@@ -327,7 +328,7 @@ export async function runWithLadder(
327
328
  const initialState = tool.workflow.bootstrap
328
329
  ? await stealthBootstrapState(sf, tool.workflow.bootstrap)
329
330
  : undefined;
330
- result = await tool.toolFn(params, { fetchImpl: sf.fetchImpl, initialState });
331
+ result = await tool.toolFn(paramsWithDefaults, { fetchImpl: sf.fetchImpl, initialState });
331
332
  break;
332
333
  }
333
334
  case 'playbook': {
@@ -1213,8 +1214,21 @@ async function stealthBootstrapState(
1213
1214
  return state;
1214
1215
  }
1215
1216
 
1216
- function ensureStealthFetch(tool: ResolvedTool, cache: Map<string, StealthFetch>): StealthFetch {
1217
- const cached = cache.get(tool.site);
1217
+ async function ensureStealthFetch(
1218
+ tool: ResolvedTool,
1219
+ cache: Map<string, StealthFetch>,
1220
+ params: Record<string, string | number | boolean>,
1221
+ ): Promise<StealthFetch> {
1222
+ const credentials = (await loadCredentialStore(tool.site)) ?? {
1223
+ site: tool.site,
1224
+ cookies: [],
1225
+ values: {},
1226
+ };
1227
+ const bootstrapUrl = tool.workflow.bootstrap?.url
1228
+ ? substituteString(tool.workflow.bootstrap.url, params, credentials, [], 'url')
1229
+ : undefined;
1230
+ const cacheKey = bootstrapUrl ? `${tool.site}:${bootstrapUrl}` : tool.site;
1231
+ const cached = cache.get(cacheKey);
1218
1232
  if (cached) return cached;
1219
1233
  const sf = createStealthFetch({
1220
1234
  baseUrl: pickBaseUrl(tool),
@@ -1223,9 +1237,9 @@ function ensureStealthFetch(tool: ResolvedTool, cache: Map<string, StealthFetch>
1223
1237
  // minted in the same session as the anti-bot cookies. Otherwise the
1224
1238
  // stealth rung can't satisfy a `${state.X}` the workflow bootstrap was
1225
1239
  // supposed to provide, and escalation from fetch-bootstrap dead-ends.
1226
- bootstrapUrl: tool.workflow.bootstrap?.url,
1240
+ bootstrapUrl,
1227
1241
  });
1228
- cache.set(tool.site, sf);
1242
+ cache.set(cacheKey, sf);
1229
1243
  return sf;
1230
1244
  }
1231
1245
 
@@ -16,7 +16,7 @@ import { loadJsonFile } from './load-json.ts';
16
16
  import { createLog, isDebug } from './log.ts';
17
17
  import { evaluateNotifyWhen, notify } from './notify.ts';
18
18
  import { imprintHomeDir } from './paths.ts';
19
- import { loadBackendsCache } from './probe-backends.ts';
19
+ import { loadBackendsCache, persistRuntimeBackendsCache } from './probe-backends.ts';
20
20
  import { checkSiteCredentialsReady } from './runtime.ts';
21
21
  import { availableSitesHint } from './sites.ts';
22
22
  import type { StealthFetch } from './stealth-fetch.ts';
@@ -100,6 +100,19 @@ async function runOnce(
100
100
  }
101
101
 
102
102
  if (result.ok) {
103
+ try {
104
+ const cache = persistRuntimeBackendsCache({
105
+ tool,
106
+ assetRoot,
107
+ usedBackend,
108
+ attempts,
109
+ });
110
+ if (cache) log(` learned backend order: ${cache.preferredOrder.join(' → ')}`);
111
+ } catch (err) {
112
+ log(
113
+ ` warning: could not persist backend order: ${err instanceof Error ? err.message : String(err)}`,
114
+ );
115
+ }
103
116
  const data = typeof result.data === 'string' ? result.data : JSON.stringify(result.data);
104
117
  // Cap the inline preview at ~500 chars; full payload available via
105
118
  // IMPRINT_DEBUG=1. Long-running daemons flood stderr otherwise.
@@ -8,6 +8,7 @@ import { join as pathJoin } from 'node:path';
8
8
  import { findChromium } from './chromium.ts';
9
9
  import { defaultHermesConfigPath } from './install.ts';
10
10
  import { getProviderStatuses } from './llm.ts';
11
+ import { checkForUpdate } from './update.ts';
11
12
  import { VERSION } from './version.ts';
12
13
 
13
14
  export interface CheckResult {
@@ -17,9 +18,10 @@ export interface CheckResult {
17
18
  fix?: string;
18
19
  }
19
20
 
20
- export function doctor(): CheckResult[] {
21
+ export async function doctor(): Promise<CheckResult[]> {
21
22
  return [
22
23
  checkBun(),
24
+ await checkLatestVersion(),
23
25
  checkChromium(),
24
26
  checkPlaywrightChromium(),
25
27
  checkVirtualDisplay(),
@@ -31,6 +33,22 @@ export function doctor(): CheckResult[] {
31
33
  ];
32
34
  }
33
35
 
36
+ async function checkLatestVersion(): Promise<CheckResult> {
37
+ const result = await checkForUpdate();
38
+ if (!result) {
39
+ return { name: 'Latest version', ok: true, detail: `v${VERSION} (could not reach registry)` };
40
+ }
41
+ if (!result.updateAvailable) {
42
+ return { name: 'Latest version', ok: true, detail: `v${VERSION} (up to date)` };
43
+ }
44
+ return {
45
+ name: 'Latest version',
46
+ ok: true,
47
+ detail: `v${result.current} → v${result.latest} available`,
48
+ fix: 'run: imprint update',
49
+ };
50
+ }
51
+
34
52
  function checkBun(): CheckResult {
35
53
  const v = process.versions.bun;
36
54
  if (!v) {
@@ -27,6 +27,7 @@ import {
27
27
  import * as p from '@clack/prompts';
28
28
  import YAML from 'yaml';
29
29
  import { imprintHomeDir, localSiteDir } from './paths.ts';
30
+ import { type BackendsCacheStatus, loadBackendsCacheStatus } from './probe-backends.ts';
30
31
  import {
31
32
  type WorkflowState,
32
33
  loadTeachState,
@@ -38,7 +39,7 @@ import {
38
39
 
39
40
  type McpClient = 'claude-code' | 'codex' | 'claude-desktop' | 'openclaw' | 'hermes';
40
41
  type LocalDeleteMode = 'none' | 'tool' | 'site';
41
- type IssueKind = 'missing-session' | 'stale-registration';
42
+ type IssueKind = 'missing-session' | 'stale-registration' | 'stale-backends' | 'invalid-backends';
42
43
 
43
44
  const CLIENTS: McpClient[] = ['claude-code', 'codex', 'claude-desktop', 'openclaw', 'hermes'];
44
45
  const DISABLED_STORE_VERSION = 1;
@@ -81,6 +82,15 @@ interface LocalToolStatus {
81
82
  hasPlaybook: boolean;
82
83
  hasBackends: boolean;
83
84
  hasCron: boolean;
85
+ backendCache: PublicBackendsCacheStatus;
86
+ }
87
+
88
+ interface PublicBackendsCacheStatus {
89
+ status: BackendsCacheStatus['status'];
90
+ path: string | null;
91
+ preferredOrder?: string[];
92
+ reason?: string;
93
+ remediation?: string;
84
94
  }
85
95
 
86
96
  interface LocalWorkflowStatus {
@@ -475,7 +485,13 @@ function cmdStatus(argv: string[]): number {
475
485
  const status = scanMcpStatus({ site });
476
486
  if (flags.json === true) console.log(JSON.stringify(status, null, 2));
477
487
  else console.log(formatMcpStatus(status));
478
- return status.issues.some((i) => i.kind === 'stale-registration' || i.kind === 'missing-session')
488
+ return status.issues.some(
489
+ (i) =>
490
+ i.kind === 'stale-registration' ||
491
+ i.kind === 'missing-session' ||
492
+ i.kind === 'stale-backends' ||
493
+ i.kind === 'invalid-backends',
494
+ )
479
495
  ? 1
480
496
  : 0;
481
497
  }
@@ -691,6 +707,11 @@ function issueFixHint(issue: McpIssue): string | null {
691
707
  return `choose "Fix an issue" or run: imprint mcp delete ${issue.name ?? `imprint-${issue.site}`} --client ${issue.client ?? 'all'} --yes`;
692
708
  case 'missing-session':
693
709
  return `choose "Fix an issue" or run: imprint mcp prune-state --site ${issue.site} --missing-session --yes`;
710
+ case 'stale-backends':
711
+ case 'invalid-backends':
712
+ return issue.path
713
+ ? `run: imprint probe-backends ${issue.site}${issue.workflow ? ` --tool ${issue.workflow}` : ''}`
714
+ : `run: imprint probe-backends ${issue.site}${issue.workflow ? ` --tool ${issue.workflow}` : ''}`;
694
715
  }
695
716
  return null;
696
717
  }
@@ -712,26 +733,32 @@ function scanLocalSites(ctx: MaintenanceContext): LocalSiteStatus[] {
712
733
  if (entry === 'node_modules' || entry.startsWith('.')) continue;
713
734
  const dir = pathJoin(ctx.imprintHome, entry);
714
735
  if (!safeIsDir(dir)) continue;
715
- sites.push(scanLocalSite(entry, dir));
736
+ sites.push(scanLocalSite(entry, dir, ctx.imprintHome));
716
737
  }
717
738
  return sites;
718
739
  }
719
740
 
720
- function scanLocalSite(site: string, dir: string): LocalSiteStatus {
741
+ function scanLocalSite(site: string, dir: string, imprintHome: string): LocalSiteStatus {
721
742
  const tools: LocalToolStatus[] = [];
722
743
  for (const entry of readdirSync(dir).sort()) {
723
744
  if (entry === 'sessions' || entry === '_shared' || entry.startsWith('.')) continue;
724
745
  const toolDir = pathJoin(dir, entry);
725
746
  if (!safeIsDir(toolDir)) continue;
747
+ const toolName = workflowJsonToolName(toolDir) ?? entry;
748
+ const cacheStatus = loadBackendsCacheStatus(site, imprintHome, toolDir, {
749
+ warn: false,
750
+ toolName,
751
+ });
726
752
  tools.push({
727
753
  site,
728
- toolName: entry,
754
+ toolName,
729
755
  dir: toolDir,
730
756
  complete: existsSync(pathJoin(toolDir, 'index.ts')),
731
757
  hasWorkflow: existsSync(pathJoin(toolDir, 'workflow.json')),
732
758
  hasPlaybook: existsSync(pathJoin(toolDir, 'playbook.yaml')),
733
759
  hasBackends: existsSync(pathJoin(toolDir, 'backends.json')),
734
760
  hasCron: existsSync(pathJoin(toolDir, 'cron.json')),
761
+ backendCache: publicBackendsCacheStatus(cacheStatus),
735
762
  });
736
763
  }
737
764
 
@@ -746,6 +773,29 @@ function scanLocalSite(site: string, dir: string): LocalSiteStatus {
746
773
  return { site, dir, tools, workflows };
747
774
  }
748
775
 
776
+ function publicBackendsCacheStatus(status: BackendsCacheStatus): PublicBackendsCacheStatus {
777
+ if (status.status === 'ok') {
778
+ return {
779
+ status: status.status,
780
+ path: status.path,
781
+ preferredOrder: status.cache.preferredOrder,
782
+ };
783
+ }
784
+ if (status.status === 'missing') {
785
+ return {
786
+ status: status.status,
787
+ path: status.path,
788
+ remediation: status.remediation,
789
+ };
790
+ }
791
+ return {
792
+ status: status.status,
793
+ path: status.path,
794
+ reason: status.reason,
795
+ remediation: status.remediation,
796
+ };
797
+ }
798
+
749
799
  function workflowStatus(
750
800
  site: string,
751
801
  name: string,
@@ -795,6 +845,21 @@ function collectIssues(opts: {
795
845
  const sitesByName = new Map(opts.sites.map((s) => [s.site, s]));
796
846
 
797
847
  for (const site of opts.sites) {
848
+ for (const tool of site.tools) {
849
+ if (tool.backendCache.status === 'stale' || tool.backendCache.status === 'invalid') {
850
+ issues.push({
851
+ kind: tool.backendCache.status === 'stale' ? 'stale-backends' : 'invalid-backends',
852
+ site: site.site,
853
+ workflow: tool.toolName,
854
+ path: tool.backendCache.path ?? undefined,
855
+ message:
856
+ tool.backendCache.status === 'stale'
857
+ ? `${site.site}/${tool.toolName} has a stale backends.json; runtime will fall back to the default ladder until reprobed`
858
+ : `${site.site}/${tool.toolName} has an invalid backends.json; runtime will fall back to the default ladder until reprobed`,
859
+ });
860
+ }
861
+ }
862
+
798
863
  for (const wf of site.workflows) {
799
864
  if (wf.missingSession) {
800
865
  issues.push({
@@ -1091,7 +1156,7 @@ function pruneTeachState(
1091
1156
  for (const site of sites) {
1092
1157
  const statePath = teachStatePath(site);
1093
1158
  if (!existsSync(statePath)) continue;
1094
- const status = scanLocalSite(site, localSiteDir(site));
1159
+ const status = scanLocalSite(site, localSiteDir(site), ctx.imprintHome);
1095
1160
  const remove = new Set(
1096
1161
  status.workflows
1097
1162
  .filter(
@@ -20,7 +20,7 @@ import { resolveLadder, runWithLadder } from './backend-ladder.ts';
20
20
  import type { CdpBrowserFetch } from './cdp-browser-fetch.ts';
21
21
  import { createLog } from './log.ts';
22
22
  import { imprintHomeDir } from './paths.ts';
23
- import { loadBackendsCache } from './probe-backends.ts';
23
+ import { loadBackendsCacheStatus, persistRuntimeBackendsCache } from './probe-backends.ts';
24
24
  import { checkSiteCredentialsReady } from './runtime.ts';
25
25
  import { availableSitesHint } from './sites.ts';
26
26
  import type { StealthFetch } from './stealth-fetch.ts';
@@ -173,7 +173,7 @@ function buildServer(
173
173
 
174
174
  try {
175
175
  const ladder = resolveLadder('auto', tool.preferredOrder);
176
- const { result, usedBackend } = await runWithLadder(
176
+ const { result, usedBackend, attempts } = await runWithLadder(
177
177
  ladder,
178
178
  tool,
179
179
  args,
@@ -209,6 +209,24 @@ function buildServer(
209
209
  content: [{ type: 'text', text: `${text}\n(backend: ${usedBackend})` }],
210
210
  };
211
211
  }
212
+ try {
213
+ const cache = persistRuntimeBackendsCache({
214
+ tool,
215
+ assetRoot,
216
+ usedBackend,
217
+ attempts,
218
+ });
219
+ if (cache) {
220
+ tool.preferredOrder = cache.preferredOrder;
221
+ log(
222
+ ` learned backend order for ${tool.workflow.toolName}: ${cache.preferredOrder.join(' → ')}`,
223
+ );
224
+ }
225
+ } catch (err) {
226
+ log(
227
+ ` warning: could not persist backend order for ${tool.workflow.toolName}: ${err instanceof Error ? err.message : String(err)}`,
228
+ );
229
+ }
212
230
  const text =
213
231
  typeof result.data === 'string' ? result.data : JSON.stringify(result.data, null, 2);
214
232
  return { content: [{ type: 'text', text: `${text}\n\n(backend: ${usedBackend})` }] };
@@ -250,12 +268,19 @@ export async function runMcpServer(opts: RunMcpServerOptions): Promise<void> {
250
268
  const discovered = await discoverTools(assetRoot, opts.site, '[imprint mcp]');
251
269
  const tools: ResolvedTool[] = discovered.map((t) => {
252
270
  const playbookPath = pathResolve(t.dir, 'playbook.yaml');
253
- const cache = loadBackendsCache(t.site, assetRoot, t.dir);
271
+ const cacheStatus = loadBackendsCacheStatus(t.site, assetRoot, t.dir, {
272
+ toolName: t.workflow.toolName,
273
+ });
274
+ if (cacheStatus.status === 'stale' || cacheStatus.status === 'invalid') {
275
+ log(
276
+ ` ${t.workflow.toolName}: ${cacheStatus.status} backends.json (${cacheStatus.reason}); run \`${cacheStatus.remediation}\``,
277
+ );
278
+ }
254
279
  return {
255
280
  ...t,
256
281
  inputSchema: buildJsonSchema(t.workflow.parameters),
257
282
  playbookPath: existsSync(playbookPath) ? playbookPath : undefined,
258
- preferredOrder: cache?.preferredOrder,
283
+ preferredOrder: cacheStatus.status === 'ok' ? cacheStatus.cache.preferredOrder : undefined,
259
284
  };
260
285
  });
261
286
  if (tools.length === 0) {
@@ -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
+ }