@tikoci/rosetta 0.8.9 → 0.8.11

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/src/setup.ts CHANGED
@@ -8,7 +8,18 @@
8
8
  */
9
9
 
10
10
  import { execSync } from "node:child_process";
11
- import { existsSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
11
+ import {
12
+ closeSync,
13
+ existsSync,
14
+ openSync,
15
+ readdirSync,
16
+ readSync,
17
+ renameSync,
18
+ statSync,
19
+ unlinkSync,
20
+ writeFileSync,
21
+ } from "node:fs";
22
+ import path from "node:path";
12
23
  import { gunzipSync } from "bun";
13
24
  import { detectMode, resolveBaseDir, resolveDbPath, resolveVersion, SCHEMA_VERSION } from "./paths.ts";
14
25
 
@@ -25,20 +36,51 @@ const MIN_PAGES = 100;
25
36
  const MIN_COMMANDS = 1000;
26
37
  const MIN_DECOMPRESSED_BYTES = 50 * 1024 * 1024; // 50 MB
27
38
  const SQLITE_MAGIC = "SQLite format 3\0";
39
+ const DOWNLOAD_LOCK_WAIT_MS = 90_000;
40
+ const DOWNLOAD_LOCK_POLL_MS = 250;
41
+ const DOWNLOAD_LOCK_STALE_MS = 15 * 60 * 1000;
42
+
43
+ type DbProbe = {
44
+ schemaVersion: number;
45
+ pages: number;
46
+ commands: number;
47
+ releaseTag: string | null;
48
+ };
49
+
50
+ type DownloadLockHandle = {
51
+ fd: number;
52
+ path: string;
53
+ };
28
54
 
29
55
  /** Check if a DB file exists and has actual page data.
30
56
  * Opens read-write — see probeDb's note: freshly written WAL-mode files can
31
57
  * fail to open readonly on macOS when the .shm file is missing. */
32
58
  function dbHasData(dbPath: string): boolean {
59
+ return hasMinimumDbContent(probeDb(dbPath));
60
+ }
61
+
62
+ function looksLikeSqliteFile(dbPath: string): boolean {
33
63
  if (!existsSync(dbPath)) return false;
64
+
65
+ let fd: number | null = null;
34
66
  try {
35
- const { default: sqlite } = require("bun:sqlite");
36
- const check = new sqlite(dbPath);
37
- const row = check.prepare("SELECT COUNT(*) AS c FROM pages").get() as { c: number };
38
- check.close();
39
- return row.c > 0;
67
+ const stats = statSync(dbPath);
68
+ if (!stats.isFile() || stats.size < SQLITE_MAGIC.length) return false;
69
+
70
+ fd = openSync(dbPath, "r");
71
+ const header = Buffer.alloc(SQLITE_MAGIC.length);
72
+ const bytesRead = readSync(fd, header, 0, header.byteLength, 0);
73
+ return bytesRead === header.byteLength && header.toString("utf8") === SQLITE_MAGIC;
40
74
  } catch {
41
75
  return false;
76
+ } finally {
77
+ if (fd !== null) {
78
+ try {
79
+ closeSync(fd);
80
+ } catch {
81
+ // best-effort cleanup
82
+ }
83
+ }
42
84
  }
43
85
  }
44
86
 
@@ -54,6 +96,8 @@ export function probeDb(dbPath: string): {
54
96
  commands: number;
55
97
  releaseTag: string | null;
56
98
  } | null {
99
+ if (!looksLikeSqliteFile(dbPath)) return null;
100
+
57
101
  try {
58
102
  const { default: sqlite } = require("bun:sqlite");
59
103
  const check = new sqlite(dbPath);
@@ -79,6 +123,155 @@ export function probeDb(dbPath: string): {
79
123
  }
80
124
  }
81
125
 
126
+ export function hasMinimumDbContent(probe: DbProbe | null): probe is DbProbe {
127
+ return !!probe && probe.pages >= MIN_PAGES && probe.commands >= MIN_COMMANDS;
128
+ }
129
+
130
+ export function isUsableDbProbe(probe: DbProbe | null): probe is DbProbe {
131
+ return hasMinimumDbContent(probe) && probe.schemaVersion === SCHEMA_VERSION;
132
+ }
133
+
134
+ function lockPathFor(dbPath: string): string {
135
+ return `${dbPath}.lock`;
136
+ }
137
+
138
+ function formatProbeSummary(probe: DbProbe): string {
139
+ const tagInfo = probe.releaseTag ? ` (release ${probe.releaseTag})` : "";
140
+ return `schema v${probe.schemaVersion}, ${probe.pages} pages, ${probe.commands} commands${tagInfo}`;
141
+ }
142
+
143
+ export function tryAcquireDownloadLock(dbPath: string): DownloadLockHandle | null {
144
+ const lockPath = lockPathFor(dbPath);
145
+
146
+ while (true) {
147
+ try {
148
+ const fd = openSync(lockPath, "wx");
149
+ writeFileSync(
150
+ fd,
151
+ `${JSON.stringify({
152
+ pid: process.pid,
153
+ created_at: new Date().toISOString(),
154
+ db_path: dbPath,
155
+ })}\n`,
156
+ );
157
+ return { fd, path: lockPath };
158
+ } catch (e) {
159
+ const code = e instanceof Error && "code" in e ? e.code : undefined;
160
+ if (code !== "EEXIST") throw e;
161
+
162
+ let ageMs: number | null = null;
163
+ try {
164
+ ageMs = Date.now() - statSync(lockPath).mtimeMs;
165
+ } catch {
166
+ ageMs = null;
167
+ }
168
+
169
+ if (ageMs !== null && ageMs > DOWNLOAD_LOCK_STALE_MS) {
170
+ tryUnlink(lockPath);
171
+ continue;
172
+ }
173
+
174
+ return null;
175
+ }
176
+ }
177
+ }
178
+
179
+ export function releaseDownloadLock(lock: DownloadLockHandle | null): void {
180
+ if (!lock) return;
181
+ try {
182
+ closeSync(lock.fd);
183
+ } catch {
184
+ // best-effort cleanup
185
+ }
186
+ tryUnlink(lock.path);
187
+ }
188
+
189
+ export async function waitForUsableDb(
190
+ dbPath: string,
191
+ log: (msg: string) => void = console.log,
192
+ timeoutMs = DOWNLOAD_LOCK_WAIT_MS,
193
+ ): Promise<boolean> {
194
+ const lockPath = lockPathFor(dbPath);
195
+ const deadline = Date.now() + timeoutMs;
196
+ let announced = false;
197
+
198
+ while (Date.now() < deadline) {
199
+ if (!existsSync(lockPath)) {
200
+ return isUsableDbProbe(probeDb(dbPath));
201
+ }
202
+
203
+ if (!announced) {
204
+ log(` Another rosetta process is preparing ${dbPath}; waiting...`);
205
+ announced = true;
206
+ }
207
+
208
+ const remaining = deadline - Date.now();
209
+ if (remaining <= 0) break;
210
+ await Bun.sleep(Math.min(remaining, DOWNLOAD_LOCK_POLL_MS));
211
+ }
212
+
213
+ return false;
214
+ }
215
+
216
+ function tryUnlinkDbSidecars(dbPath: string): void {
217
+ tryUnlink(`${dbPath}-wal`);
218
+ tryUnlink(`${dbPath}-shm`);
219
+ }
220
+
221
+ function cleanupDbArtifacts(dbPath: string): void {
222
+ tryUnlinkDbSidecars(dbPath);
223
+ tryUnlink(dbPath);
224
+ }
225
+
226
+ export function cleanupStaleTempArtifacts(dbPath: string, staleMs = DOWNLOAD_LOCK_STALE_MS): number {
227
+ const dir = path.dirname(dbPath);
228
+ const prefix = `${path.basename(dbPath)}.tmp.`;
229
+ const now = Date.now();
230
+ let removed = 0;
231
+
232
+ let entries: string[];
233
+ try {
234
+ entries = readdirSync(dir);
235
+ } catch {
236
+ return 0;
237
+ }
238
+
239
+ for (const entry of entries) {
240
+ if (!entry.startsWith(prefix)) continue;
241
+
242
+ const fullPath = path.join(dir, entry);
243
+ try {
244
+ const ageMs = now - statSync(fullPath).mtimeMs;
245
+ if (ageMs <= staleMs) continue;
246
+ } catch {
247
+ continue;
248
+ }
249
+
250
+ try {
251
+ unlinkSync(fullPath);
252
+ removed++;
253
+ } catch {
254
+ // best-effort cleanup
255
+ }
256
+ }
257
+
258
+ return removed;
259
+ }
260
+
261
+ function replaceDbFile(tmpPath: string, dbPath: string): void {
262
+ try {
263
+ renameSync(tmpPath, dbPath);
264
+ return;
265
+ } catch (e) {
266
+ const code = e instanceof Error && "code" in e ? e.code : undefined;
267
+ // Windows can return EBUSY, EPERM, or EEXIST when the destination is open.
268
+ if (code !== "EBUSY" && code !== "EEXIST" && code !== "EPERM") throw e;
269
+ }
270
+
271
+ tryUnlink(dbPath);
272
+ renameSync(tmpPath, dbPath);
273
+ }
274
+
82
275
  /** Build the version-pinned download URL. Falls back to /latest/ when no version.
83
276
  * Exported for test coverage. */
84
277
  export function dbDownloadUrls(version: string): string[] {
@@ -96,7 +289,7 @@ export function dbDownloadUrls(version: string): string[] {
96
289
  * Download ros-help.db.gz from GitHub Releases atomically:
97
290
  * 1. Try version-pinned URL first, fall back to /latest/ on 404.
98
291
  * 2. Decompress in memory, verify SQLite magic bytes + minimum size.
99
- * 3. Write to <dbPath>.tmp.<pid>, open it read-only, verify schema_version
292
+ * 3. Write to <dbPath>.tmp.<pid>, probe it with SQLite, verify schema_version
100
293
  * matches the running code and pages/commands counts look healthy.
101
294
  * 4. Atomically rename .tmp → dbPath, then delete stale .db-wal / .db-shm.
102
295
  *
@@ -107,122 +300,171 @@ export function dbDownloadUrls(version: string): string[] {
107
300
  export async function downloadDb(
108
301
  dbPath: string,
109
302
  log: (msg: string) => void = console.log,
110
- ) {
111
- const urls = dbDownloadUrls(RELEASE_VERSION);
112
- let lastError: Error | null = null;
113
-
114
- for (let i = 0; i < urls.length; i++) {
115
- const url = urls[i];
116
- const isLast = i === urls.length - 1;
117
- log(`Downloading database from GitHub Releases...`);
118
- log(` ${url}`);
119
-
120
- let response: Response;
121
- try {
122
- response = await fetch(url, { redirect: "follow" });
123
- } catch (e) {
124
- lastError = e as Error;
125
- log(` Network error: ${e}`);
126
- if (isLast) throw lastError;
127
- continue;
303
+ ): Promise<DbProbe> {
304
+ let lock = tryAcquireDownloadLock(dbPath);
305
+ if (!lock) {
306
+ const reused = await waitForUsableDb(dbPath, log);
307
+ if (reused) {
308
+ const probe = probeDb(dbPath);
309
+ if (probe) {
310
+ log(` Reused existing database: ${formatProbeSummary(probe)}`);
311
+ return probe;
312
+ }
128
313
  }
129
314
 
130
- if (response.status === 404 && !isLast) {
131
- log(` Not found at this URL, trying fallback...`);
132
- continue;
315
+ // Re-probe once the lock may have been released with a healthy DB
316
+ const fallbackProbe = probeDb(dbPath);
317
+ if (isUsableDbProbe(fallbackProbe)) {
318
+ log(` Reused existing database: ${formatProbeSummary(fallbackProbe)}`);
319
+ return fallbackProbe;
133
320
  }
134
- if (!response.ok) {
135
- lastError = new Error(`Download failed: ${response.status} ${response.statusText}`);
136
- if (isLast) throw lastError;
137
- log(` ${lastError.message} — trying fallback...`);
138
- continue;
139
- }
140
-
141
- const contentLength = response.headers.get("content-length");
142
- const totalMB = contentLength ? (Number(contentLength) / 1024 / 1024).toFixed(1) : "?";
143
- log(` Downloading ${totalMB} MB (compressed)...`);
144
-
145
- const compressed = new Uint8Array(await response.arrayBuffer());
146
- log(` Decompressing...`);
147
321
 
148
- let decompressed: Uint8Array;
149
- try {
150
- decompressed = gunzipSync(compressed);
151
- } catch (e) {
152
- lastError = new Error(`Gunzip failed (corrupt download or HTML error page): ${e}`);
153
- if (isLast) throw lastError;
154
- log(` ${lastError.message}`);
155
- continue;
156
- }
157
-
158
- // Validate magic bytes and minimum size before touching the filesystem.
159
- if (decompressed.byteLength < MIN_DECOMPRESSED_BYTES) {
160
- lastError = new Error(
161
- `Decompressed DB too small: ${decompressed.byteLength} bytes (expected ≥ ${MIN_DECOMPRESSED_BYTES})`,
322
+ lock = tryAcquireDownloadLock(dbPath);
323
+ if (!lock) {
324
+ throw new Error(
325
+ `Timed out waiting for another rosetta process to finish preparing ${dbPath}. ` +
326
+ `Close other rosetta clients and retry.`,
162
327
  );
163
- if (isLast) throw lastError;
164
- log(` ${lastError.message}`);
165
- continue;
166
- }
167
- const header = new TextDecoder().decode(decompressed.subarray(0, SQLITE_MAGIC.length));
168
- if (header !== SQLITE_MAGIC) {
169
- lastError = new Error("Downloaded payload is not a SQLite database (magic bytes mismatch)");
170
- if (isLast) throw lastError;
171
- log(` ${lastError.message}`);
172
- continue;
173
328
  }
329
+ }
174
330
 
175
- // Write to a temp file next to the canonical DB path, validate, then rename.
176
- const tmpPath = `${dbPath}.tmp.${process.pid}`;
177
- try {
178
- writeFileSync(tmpPath, decompressed);
179
- } catch (e) {
180
- lastError = new Error(`Write to ${tmpPath} failed: ${e}`);
181
- throw lastError;
182
- }
331
+ const urls = dbDownloadUrls(RELEASE_VERSION);
332
+ let lastError: Error | null = null;
183
333
 
184
- const probe = probeDb(tmpPath);
185
- if (!probe) {
186
- tryUnlink(tmpPath);
187
- lastError = new Error("Downloaded DB failed to open with SQLite");
188
- if (isLast) throw lastError;
189
- log(` ${lastError.message} — trying fallback...`);
190
- continue;
191
- }
192
- if (probe.schemaVersion !== SCHEMA_VERSION) {
193
- tryUnlink(tmpPath);
194
- lastError = new Error(
195
- `Downloaded DB schema=${probe.schemaVersion} does not match this rosetta build (expected ${SCHEMA_VERSION}). ` +
196
- `This usually means the cached package version is older than the published DB. ` +
197
- `Run \`bun pm cache rm\` and relaunch to pick up the latest package.`,
198
- );
199
- if (isLast) throw lastError;
200
- log(` ${lastError.message}`);
201
- continue;
202
- }
203
- if (probe.pages < MIN_PAGES || probe.commands < MIN_COMMANDS) {
204
- tryUnlink(tmpPath);
205
- lastError = new Error(
206
- `Downloaded DB content looks incomplete (pages=${probe.pages}, commands=${probe.commands})`,
207
- );
208
- if (isLast) throw lastError;
209
- log(` ${lastError.message} — trying fallback...`);
210
- continue;
334
+ try {
335
+ const cleaned = cleanupStaleTempArtifacts(dbPath);
336
+ if (cleaned > 0) {
337
+ log(` Removed ${cleaned} stale temp DB artifact${cleaned === 1 ? "" : "s"}.`);
211
338
  }
212
339
 
213
- // Validation passed drop stale WAL/SHM and atomically swap.
214
- tryUnlink(`${dbPath}-wal`);
215
- tryUnlink(`${dbPath}-shm`);
216
- renameSync(tmpPath, dbPath);
340
+ for (let i = 0; i < urls.length; i++) {
341
+ const url = urls[i];
342
+ const isLast = i === urls.length - 1;
343
+ log(`Downloading database from GitHub Releases...`);
344
+ log(` ${url}`);
345
+
346
+ let response: Response;
347
+ try {
348
+ response = await fetch(url, { redirect: "follow" });
349
+ } catch (e) {
350
+ lastError = e as Error;
351
+ log(` Network error: ${e}`);
352
+ if (isLast) throw lastError;
353
+ continue;
354
+ }
355
+
356
+ if (response.status === 404 && !isLast) {
357
+ log(` Not found at this URL, trying fallback...`);
358
+ continue;
359
+ }
360
+ if (!response.ok) {
361
+ lastError = new Error(`Download failed: ${response.status} ${response.statusText}`);
362
+ if (isLast) throw lastError;
363
+ log(` ${lastError.message} — trying fallback...`);
364
+ continue;
365
+ }
366
+
367
+ const contentLength = response.headers.get("content-length");
368
+ const totalMB = contentLength ? (Number(contentLength) / 1024 / 1024).toFixed(1) : "?";
369
+ log(` Downloading ${totalMB} MB (compressed)...`);
370
+
371
+ const compressed = new Uint8Array(await response.arrayBuffer());
372
+ log(` Decompressing...`);
373
+
374
+ let decompressed: Uint8Array;
375
+ try {
376
+ decompressed = gunzipSync(compressed);
377
+ } catch (e) {
378
+ lastError = new Error(`Gunzip failed (corrupt download or HTML error page): ${e}`);
379
+ if (isLast) throw lastError;
380
+ log(` ${lastError.message}`);
381
+ continue;
382
+ }
383
+
384
+ // Validate magic bytes and minimum size before touching the filesystem.
385
+ if (decompressed.byteLength < MIN_DECOMPRESSED_BYTES) {
386
+ lastError = new Error(
387
+ `Decompressed DB too small: ${decompressed.byteLength} bytes (expected ≥ ${MIN_DECOMPRESSED_BYTES})`,
388
+ );
389
+ if (isLast) throw lastError;
390
+ log(` ${lastError.message}`);
391
+ continue;
392
+ }
393
+ const header = new TextDecoder().decode(decompressed.subarray(0, SQLITE_MAGIC.length));
394
+ if (header !== SQLITE_MAGIC) {
395
+ lastError = new Error("Downloaded payload is not a SQLite database (magic bytes mismatch)");
396
+ if (isLast) throw lastError;
397
+ log(` ${lastError.message}`);
398
+ continue;
399
+ }
400
+
401
+ // Write to a temp file next to the canonical DB path, validate, then rename.
402
+ const tmpPath = `${dbPath}.tmp.${process.pid}`;
403
+ try {
404
+ writeFileSync(tmpPath, decompressed);
405
+ } catch (e) {
406
+ lastError = new Error(`Write to ${tmpPath} failed: ${e}`);
407
+ throw lastError;
408
+ }
409
+
410
+ const probe = probeDb(tmpPath);
411
+ if (!probe) {
412
+ cleanupDbArtifacts(tmpPath);
413
+ lastError = new Error("Downloaded DB failed to open with SQLite");
414
+ if (isLast) throw lastError;
415
+ log(` ${lastError.message} — trying fallback...`);
416
+ continue;
417
+ }
418
+ if (probe.schemaVersion !== SCHEMA_VERSION) {
419
+ cleanupDbArtifacts(tmpPath);
420
+ lastError = new Error(
421
+ `Downloaded DB schema=${probe.schemaVersion} does not match this rosetta build (expected ${SCHEMA_VERSION}). ` +
422
+ `This usually means the cached package version is older than the published DB. ` +
423
+ `Run \`bun pm cache rm\` and relaunch to pick up the latest package.`,
424
+ );
425
+ if (isLast) throw lastError;
426
+ log(` ${lastError.message}`);
427
+ continue;
428
+ }
429
+ if (probe.pages < MIN_PAGES || probe.commands < MIN_COMMANDS) {
430
+ cleanupDbArtifacts(tmpPath);
431
+ lastError = new Error(
432
+ `Downloaded DB content looks incomplete (pages=${probe.pages}, commands=${probe.commands})`,
433
+ );
434
+ if (isLast) throw lastError;
435
+ log(` ${lastError.message} — trying fallback...`);
436
+ continue;
437
+ }
438
+
439
+ // Validation passed — drop stale WAL/SHM and atomically swap.
440
+ try {
441
+ tryUnlinkDbSidecars(tmpPath);
442
+ tryUnlinkDbSidecars(dbPath);
443
+ replaceDbFile(tmpPath, dbPath);
444
+ } catch (e) {
445
+ const existingProbe = probeDb(dbPath);
446
+ if (hasMinimumDbContent(existingProbe) && existingProbe.schemaVersion === probe.schemaVersion && existingProbe.releaseTag === probe.releaseTag) {
447
+ cleanupDbArtifacts(tmpPath);
448
+ log(` Another rosetta process already installed the same database.`);
449
+ log(` Reused existing database: ${formatProbeSummary(existingProbe)}`);
450
+ return existingProbe;
451
+ }
452
+
453
+ cleanupDbArtifacts(tmpPath);
454
+ throw e;
455
+ }
456
+
457
+ const sizeMB = (decompressed.byteLength / 1024 / 1024).toFixed(1);
458
+ const tagInfo = probe.releaseTag ? ` (release ${probe.releaseTag})` : "";
459
+ log(` Wrote ${sizeMB} MB to ${dbPath}${tagInfo}`);
460
+ log(` Validated: schema v${probe.schemaVersion}, ${probe.pages} pages, ${probe.commands} commands.`);
461
+ return probe;
462
+ }
217
463
 
218
- const sizeMB = (decompressed.byteLength / 1024 / 1024).toFixed(1);
219
- const tagInfo = probe.releaseTag ? ` (release ${probe.releaseTag})` : "";
220
- log(` Wrote ${sizeMB} MB to ${dbPath}${tagInfo}`);
221
- log(` Validated: schema v${probe.schemaVersion}, ${probe.pages} pages, ${probe.commands} commands.`);
222
- return;
464
+ throw lastError ?? new Error("Database download failed for unknown reasons");
465
+ } finally {
466
+ releaseDownloadLock(lock);
223
467
  }
224
-
225
- throw lastError ?? new Error("Database download failed for unknown reasons");
226
468
  }
227
469
 
228
470
  /** Remove a file if it exists, swallowing all errors. */
@@ -241,17 +483,13 @@ function tryUnlink(p: string): void {
241
483
  */
242
484
  export async function refreshDb(log: (msg: string) => void = console.log): Promise<boolean> {
243
485
  const dbPath = resolveDbPath(import.meta.dirname);
486
+ let probe: DbProbe;
244
487
  try {
245
- await downloadDb(dbPath, log);
488
+ probe = await downloadDb(dbPath, log);
246
489
  } catch (e) {
247
490
  log(`✗ Refresh failed: ${e instanceof Error ? e.message : e}`);
248
491
  return false;
249
492
  }
250
- const probe = probeDb(dbPath);
251
- if (!probe) {
252
- log(`✗ Post-download probe failed`);
253
- return false;
254
- }
255
493
  const tagInfo = probe.releaseTag ? ` (release ${probe.releaseTag})` : "";
256
494
  log(`✓ Database ready${tagInfo}: ${probe.pages} pages, ${probe.commands} commands, schema v${probe.schemaVersion}`);
257
495
  return true;
@@ -260,6 +498,7 @@ export async function refreshDb(log: (msg: string) => void = console.log): Promi
260
498
  export async function runSetup(force = false) {
261
499
  const mode = detectMode(import.meta.dirname);
262
500
  const dbPath = resolveDbPath(import.meta.dirname);
501
+ let downloadedProbe: DbProbe | null = null;
263
502
 
264
503
  console.log(`rosetta ${RELEASE_VERSION}`);
265
504
  console.log(` ${link("https://github.com/tikoci/rosetta")}`);
@@ -272,7 +511,7 @@ export async function runSetup(force = false) {
272
511
  console.log(` (use --refresh or --setup --force to re-download)`);
273
512
  } else {
274
513
  try {
275
- await downloadDb(dbPath);
514
+ downloadedProbe = await downloadDb(dbPath);
276
515
  } catch (e) {
277
516
  console.error(`✗ Database download failed: ${e instanceof Error ? e.message : e}`);
278
517
  process.exit(1);
@@ -281,7 +520,7 @@ export async function runSetup(force = false) {
281
520
 
282
521
  // ── Validate DB ──
283
522
  console.log();
284
- const probe = probeDb(dbPath);
523
+ const probe = downloadedProbe ?? probeDb(dbPath);
285
524
  if (!probe) {
286
525
  console.error(`✗ Database validation failed: cannot open ${dbPath}`);
287
526
  const retryCmd = mode === "compiled" ? "rosetta" : mode === "package" ? "bunx @tikoci/rosetta" : "bun run src/setup.ts";
@@ -293,7 +532,7 @@ export async function runSetup(force = false) {
293
532
  `✗ DB schema version is ${probe.schemaVersion}, expected ${SCHEMA_VERSION}.`,
294
533
  );
295
534
  console.error(
296
- ` Cached package may be out of date. Run \`bun pm cache rm\` and relaunch.`,
535
+ ` Package may be out of date. Run: bunx @tikoci/rosetta@latest --refresh`,
297
536
  );
298
537
  process.exit(1);
299
538
  }