hotsheet 0.18.1-beta.1 → 0.18.1-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +124 -21
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -15681,10 +15681,16 @@ __export(fsyncWrap_exports, {
15681
15681
  fsyncDbDir: () => fsyncDbDir,
15682
15682
  fsyncDbDirAsync: () => fsyncDbDirAsync,
15683
15683
  fsyncDir: () => fsyncDir,
15684
- fsyncDirAsync: () => fsyncDirAsync
15684
+ fsyncDirAsync: () => fsyncDirAsync,
15685
+ isUnsupportedFsyncError: () => isUnsupportedFsyncError
15685
15686
  });
15686
15687
  import { closeSync, existsSync as existsSync2, fsyncSync, openSync, promises as fsp2, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
15687
15688
  import { join as join2 } from "path";
15689
+ function isUnsupportedFsyncError(err, platform2 = process.platform) {
15690
+ if (platform2 !== "win32") return false;
15691
+ const code = err?.code;
15692
+ return code !== void 0 && WIN32_UNFLUSHABLE_CODES.has(code);
15693
+ }
15688
15694
  function fsyncDir(path, fsyncFn = fsyncSync) {
15689
15695
  if (!existsSync2(path)) return { filesFlushed: 0, errors: 0 };
15690
15696
  const counters = { filesFlushed: 0, errors: 0 };
@@ -15719,8 +15725,10 @@ function walkAndFsync(dir, counters, fsyncFn) {
15719
15725
  fsyncFn(fd);
15720
15726
  counters.filesFlushed++;
15721
15727
  } catch (err) {
15722
- console.error(`[fsyncWrap] fsync ${p} failed:`, err);
15723
- counters.errors++;
15728
+ if (!isUnsupportedFsyncError(err)) {
15729
+ console.error(`[fsyncWrap] fsync ${p} failed:`, err);
15730
+ counters.errors++;
15731
+ }
15724
15732
  } finally {
15725
15733
  if (fd !== null) {
15726
15734
  try {
@@ -15769,8 +15777,10 @@ async function walkAndFsyncAsync(dir, counters, fsyncFn) {
15769
15777
  await fsyncFn(handle);
15770
15778
  counters.filesFlushed++;
15771
15779
  } catch (err) {
15772
- console.error(`[fsyncWrap] fsync ${p} failed:`, err);
15773
- counters.errors++;
15780
+ if (!isUnsupportedFsyncError(err)) {
15781
+ console.error(`[fsyncWrap] fsync ${p} failed:`, err);
15782
+ counters.errors++;
15783
+ }
15774
15784
  } finally {
15775
15785
  if (handle !== null) {
15776
15786
  try {
@@ -15785,10 +15795,11 @@ async function walkAndFsyncAsync(dir, counters, fsyncFn) {
15785
15795
  async function fsyncDbDirAsync(dataDir, fsyncFn = defaultAsyncFsyncFn) {
15786
15796
  return fsyncDirAsync(join2(dataDir, "db"), fsyncFn);
15787
15797
  }
15788
- var defaultAsyncFsyncFn;
15798
+ var WIN32_UNFLUSHABLE_CODES, defaultAsyncFsyncFn;
15789
15799
  var init_fsyncWrap = __esm({
15790
15800
  "src/db/fsyncWrap.ts"() {
15791
15801
  "use strict";
15802
+ WIN32_UNFLUSHABLE_CODES = /* @__PURE__ */ new Set(["EPERM", "EACCES", "ENOTSUP", "EINVAL"]);
15792
15803
  defaultAsyncFsyncFn = (handle) => handle.sync();
15793
15804
  }
15794
15805
  });
@@ -16361,13 +16372,40 @@ function clearRecoveryMarker(dataDir) {
16361
16372
  } catch {
16362
16373
  }
16363
16374
  }
16375
+ function pendingRecoveryPath(dataDir) {
16376
+ return join7(dataDir, PENDING_RECOVERY_FILENAME);
16377
+ }
16378
+ function readPendingRecovery(dataDir) {
16379
+ const path = pendingRecoveryPath(dataDir);
16380
+ if (!existsSync6(path)) return null;
16381
+ try {
16382
+ const parsed = JSON.parse(readFileSync3(path, "utf8"));
16383
+ const result = external_exports.object({ attempts: external_exports.number() }).loose().safeParse(parsed);
16384
+ return { attempts: result.success ? result.data.attempts : 1 };
16385
+ } catch {
16386
+ return { attempts: 1 };
16387
+ }
16388
+ }
16389
+ function writePendingRecovery(dataDir, attempts) {
16390
+ try {
16391
+ writeFileSync2(pendingRecoveryPath(dataDir), JSON.stringify({ attempts, requestedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
16392
+ } catch (writeErr) {
16393
+ console.error("Could not write pending-recovery marker:", getErrorMessage(writeErr));
16394
+ }
16395
+ }
16396
+ function clearPendingRecovery(dataDir) {
16397
+ try {
16398
+ rmSync2(pendingRecoveryPath(dataDir), { force: true });
16399
+ } catch {
16400
+ }
16401
+ }
16364
16402
  function runWithDataDir(dataDir, fn) {
16365
16403
  return requestDataDir.run(dataDir, fn);
16366
16404
  }
16367
16405
  function getDataDir() {
16368
16406
  const contextDataDir = requestDataDir.getStore();
16369
16407
  if (contextDataDir !== void 0) return contextDataDir;
16370
- if (defaultDbPath !== null) return defaultDbPath.replace(/\/db$/, "");
16408
+ if (defaultDbPath !== null) return defaultDbPath.replace(/[\\/]db$/, "");
16371
16409
  throw new Error("Data directory not available. Call setDataDir() or use runWithDataDir().");
16372
16410
  }
16373
16411
  function setDataDir(dataDir) {
@@ -16439,6 +16477,8 @@ async function getDbForDir(dataDir) {
16439
16477
  async function getDbByPath(dbPath) {
16440
16478
  const existing = databases.get(dbPath);
16441
16479
  if (existing) return existing;
16480
+ const recovered = await completeDeferredRecovery(dbPath);
16481
+ if (recovered !== null) return recovered;
16442
16482
  let db;
16443
16483
  try {
16444
16484
  db = await openAndCacheDb(dbPath);
@@ -16461,8 +16501,16 @@ async function getDbByPath(dbPath) {
16461
16501
  }
16462
16502
  async function openAndCacheDb(dbPath, loadDataDir) {
16463
16503
  const db = createPglite(dbPath, loadDataDir !== void 0 ? { loadDataDir } : {});
16464
- await db.waitReady;
16465
- await initSchema(db);
16504
+ try {
16505
+ await db.waitReady;
16506
+ await initSchema(db);
16507
+ } catch (err) {
16508
+ try {
16509
+ await db.close();
16510
+ } catch {
16511
+ }
16512
+ throw err;
16513
+ }
16466
16514
  databases.set(dbPath, db);
16467
16515
  return db;
16468
16516
  }
@@ -16506,6 +16554,57 @@ async function tryRestoreFromSources(dbPath, dataDir) {
16506
16554
  }
16507
16555
  return null;
16508
16556
  }
16557
+ async function renameDirWithRetry(from, to) {
16558
+ const maxAttempts = 5;
16559
+ for (let attempt = 1; ; attempt++) {
16560
+ try {
16561
+ renameSync(from, to);
16562
+ return;
16563
+ } catch (renameErr) {
16564
+ const code = renameErr.code;
16565
+ const retryable = code === "EPERM" || code === "EBUSY" || code === "EACCES" || code === "ENOTEMPTY";
16566
+ if (!retryable || attempt >= maxAttempts) throw renameErr;
16567
+ await new Promise((resolve11) => setTimeout(resolve11, 100 * attempt));
16568
+ }
16569
+ }
16570
+ }
16571
+ async function completeDeferredRecovery(dbPath) {
16572
+ const dataDir = dbPath.replace(/[\\/]db$/, "");
16573
+ const pending = readPendingRecovery(dataDir);
16574
+ if (pending === null) return null;
16575
+ if (pending.attempts > MAX_DEFERRED_RECOVERY_ATTEMPTS) {
16576
+ console.error(`[db] deferred recovery gave up after ${String(pending.attempts)} attempts; leaving the corrupt cluster for manual rescue.`);
16577
+ clearPendingRecovery(dataDir);
16578
+ return null;
16579
+ }
16580
+ if (!existsSync6(dbPath)) {
16581
+ clearPendingRecovery(dataDir);
16582
+ return null;
16583
+ }
16584
+ console.error("[db] completing deferred recovery \u2014 a prior launch could not move the corrupt database aside in-process (Windows handle lock)\u2026");
16585
+ const corruptPath = `${dbPath}-corrupt-${Date.now()}`;
16586
+ try {
16587
+ await renameDirWithRetry(dbPath, corruptPath);
16588
+ } catch (renameErr) {
16589
+ writePendingRecovery(dataDir, pending.attempts + 1);
16590
+ console.error(`[db] deferred recovery could not move db/ yet: ${getErrorMessage(renameErr)}`);
16591
+ return null;
16592
+ }
16593
+ const restored = await tryRestoreFromSources(dbPath, dataDir);
16594
+ writeRecoveryMarker(dataDir, {
16595
+ corruptPath,
16596
+ recoveredAt: (/* @__PURE__ */ new Date()).toISOString(),
16597
+ errorMessage: "Database was corrupt and could not be preserved in-process (Windows handle lock); recovered on the next restart.",
16598
+ ...restored !== null ? { restoredFrom: restored.label, restoredTicketCount: restored.ticketCount } : {}
16599
+ });
16600
+ clearPendingRecovery(dataDir);
16601
+ if (restored !== null) {
16602
+ console.error(`[db] deferred recovery restored from ${restored.label} (${String(restored.ticketCount)} tickets).`);
16603
+ return restored.db;
16604
+ }
16605
+ console.error("[db] deferred recovery: no snapshot/backup could be loaded; starting with a fresh empty database.");
16606
+ return null;
16607
+ }
16509
16608
  async function recoverFromOpenFailure(dbPath, err, forceRecover) {
16510
16609
  const message = getErrorMessage(err);
16511
16610
  const stack = err instanceof Error ? err.stack : void 0;
@@ -16530,16 +16629,18 @@ async function recoverFromOpenFailure(dbPath, err, forceRecover) {
16530
16629
  }
16531
16630
  }
16532
16631
  }
16632
+ const dataDir = dbPath.replace(/[\\/]db$/, "");
16533
16633
  const corruptPath = `${dbPath}-corrupt-${Date.now()}`;
16534
16634
  console.error(`Database appears to be corrupt. Preserving as ${corruptPath} ...`);
16535
16635
  try {
16536
- renameSync(dbPath, corruptPath);
16636
+ await renameDirWithRetry(dbPath, corruptPath);
16537
16637
  } catch (renameErr) {
16538
16638
  const renameMessage = getErrorMessage(renameErr);
16539
- console.error(`Could not preserve corrupt database directory: ${renameMessage}. Aborting auto-recreate to avoid data loss.`);
16639
+ const prev = readPendingRecovery(dataDir);
16640
+ writePendingRecovery(dataDir, (prev?.attempts ?? 0) + 1);
16641
+ console.error(`Could not preserve corrupt database directory in-process: ${renameMessage}. Wrote a pending-recovery marker \u2014 Hot Sheet will auto-recover from the latest snapshot on the next restart.`);
16540
16642
  throw err;
16541
16643
  }
16542
- const dataDir = dbPath.replace(/[\\/]db$/, "");
16543
16644
  const restored = await tryRestoreFromSources(dbPath, dataDir);
16544
16645
  if (restored !== null) {
16545
16646
  writeRecoveryMarker(dataDir, {
@@ -16808,7 +16909,7 @@ async function migrateNoteIds(db) {
16808
16909
  }
16809
16910
  }
16810
16911
  }
16811
- var SCHEMA_VERSION, RECOVERY_MARKER_FILENAME, databases, defaultDbPath, requestDataDir;
16912
+ var SCHEMA_VERSION, RECOVERY_MARKER_FILENAME, PENDING_RECOVERY_FILENAME, MAX_DEFERRED_RECOVERY_ATTEMPTS, databases, defaultDbPath, requestDataDir;
16812
16913
  var init_connection = __esm({
16813
16914
  "src/db/connection.ts"() {
16814
16915
  "use strict";
@@ -16817,6 +16918,8 @@ var init_connection = __esm({
16817
16918
  init_pglite();
16818
16919
  SCHEMA_VERSION = 4;
16819
16920
  RECOVERY_MARKER_FILENAME = ".db-recovery-marker.json";
16921
+ PENDING_RECOVERY_FILENAME = ".db-pending-recovery.json";
16922
+ MAX_DEFERRED_RECOVERY_ATTEMPTS = 3;
16820
16923
  databases = /* @__PURE__ */ new Map();
16821
16924
  defaultDbPath = null;
16822
16925
  requestDataDir = new AsyncLocalStorage();
@@ -20394,7 +20497,7 @@ function claudeAllowRulePattern(dataDir) {
20394
20497
  return `mcp__${getMcpServerKey(dataDir)}__*`;
20395
20498
  }
20396
20499
  function projectRoot(dataDir) {
20397
- return dataDir.replace(/\/\.hotsheet\/?$/, "");
20500
+ return dataDir.replace(/[\\/]\.hotsheet[\\/]?$/, "");
20398
20501
  }
20399
20502
  function claudeDir(dataDir) {
20400
20503
  return join18(projectRoot(dataDir), ".claude");
@@ -20514,7 +20617,7 @@ function getChannelServerPath() {
20514
20617
  return { command: process.execPath, args: [distPath] };
20515
20618
  }
20516
20619
  function projectRoot2(dataDir) {
20517
- return dataDir.replace(/\/.hotsheet\/?$/, "");
20620
+ return dataDir.replace(/[\\/]\.hotsheet[\\/]?$/, "");
20518
20621
  }
20519
20622
  function registerChannel(dataDir) {
20520
20623
  const root2 = projectRoot2(dataDir);
@@ -33197,18 +33300,18 @@ function isDemoMode() {
33197
33300
  import { execFileSync as execFileSync4 } from "child_process";
33198
33301
  var SHELL_PATH_TIMEOUT_MS = 2e3;
33199
33302
  function mergePaths(currentPath, shellPath) {
33200
- const sep = ":";
33201
- const existing = currentPath.split(sep).filter((s) => s !== "");
33303
+ const sep2 = ":";
33304
+ const existing = currentPath.split(sep2).filter((s) => s !== "");
33202
33305
  const existingSet = new Set(existing);
33203
33306
  const additions = [];
33204
- for (const dir of shellPath.split(sep)) {
33307
+ for (const dir of shellPath.split(sep2)) {
33205
33308
  const trimmed = dir.trim();
33206
33309
  if (trimmed === "" || existingSet.has(trimmed)) continue;
33207
33310
  existingSet.add(trimmed);
33208
33311
  additions.push(trimmed);
33209
33312
  }
33210
33313
  if (additions.length === 0) return currentPath;
33211
- return [...additions, ...existing].join(sep);
33314
+ return [...additions, ...existing].join(sep2);
33212
33315
  }
33213
33316
  function readLoginShellPath(execOverride) {
33214
33317
  const shell = process.env.SHELL;
@@ -33280,7 +33383,7 @@ init_helpers();
33280
33383
  init_notify();
33281
33384
  import { existsSync as existsSync19, mkdirSync as mkdirSync11, readFileSync as readFileSync14, rmSync as rmSync7, writeFileSync as writeFileSync13 } from "fs";
33282
33385
  import { Hono as Hono2 } from "hono";
33283
- import { basename as basename3, extname, join as join26, resolve as resolve7 } from "path";
33386
+ import { basename as basename3, extname, join as join26, resolve as resolve7, sep } from "path";
33284
33387
  var attachmentRoutes = new Hono2();
33285
33388
  attachmentRoutes.post("/tickets/:id/attachments", async (c) => {
33286
33389
  const id = parseIntParam(c, "id");
@@ -33367,7 +33470,7 @@ attachmentRoutes.get("/attachments/file/*", (c) => {
33367
33470
  const dataDir = c.get("dataDir");
33368
33471
  const attachDir = resolve7(join26(dataDir, "attachments"));
33369
33472
  const fullPath = resolve7(join26(attachDir, filePath));
33370
- if (!fullPath.startsWith(attachDir + "/") && fullPath !== attachDir) {
33473
+ if (!fullPath.startsWith(attachDir + sep) && fullPath !== attachDir) {
33371
33474
  return c.json({ error: "Invalid path" }, 403);
33372
33475
  }
33373
33476
  if (!existsSync19(fullPath)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hotsheet",
3
- "version": "0.18.1-beta.1",
3
+ "version": "0.18.1-beta.3",
4
4
  "description": "A lightweight local project management tool. Create, categorize, and prioritize tickets with a fast bullet-list interface, then export an Up Next worklist for AI tools.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  "scripts": {
31
31
  "dev": "npm run build:client && npm run build:plugins && tsx --tsconfig tsconfig.json src/cli.ts --replace",
32
32
  "build": "tsup",
33
- "build:client": "mkdir -p dist/client/assets && cp src/client/assets/* dist/client/assets/ && npx esbuild src/client/app.tsx --bundle --format=iife --outfile=dist/client/app.global.js --target=es2020 --jsx=automatic --jsx-import-source=#jsx --alias:#jsx/jsx-runtime=./src/jsx-runtime.ts --define:__PLUGINS_ENABLED__=${PLUGINS_ENABLED:-true} --sourcemap && npx sass src/client/styles.scss dist/client/styles.css --style compressed --no-source-map && cat node_modules/@xterm/xterm/css/xterm.css >> dist/client/styles.css",
33
+ "build:client": "node scripts/build-client.mjs",
34
34
  "build:plugins": "for d in plugins/*/; do if [ -f \"$d/manifest.json\" ]; then name=$(basename $d); mkdir -p dist/plugins/$name && npx esbuild $d/src/index.ts --bundle --format=esm --outfile=dist/plugins/$name/index.js --platform=node --target=node20 && cp $d/manifest.json dist/plugins/$name/; fi; done",
35
35
  "test": "vitest run --coverage",
36
36
  "test:watch": "vitest",