get-tbd 0.1.23 → 0.1.24

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/dist/bin.mjs CHANGED
@@ -8,7 +8,7 @@ import process$1 from "node:process";
8
8
  import matter from "gray-matter";
9
9
  import os, { homedir } from "node:os";
10
10
  import tty from "node:tty";
11
- import { access, chmod, cp, mkdir, readFile, readdir, rename, rm, stat, unlink } from "node:fs/promises";
11
+ import { access, chmod, cp, mkdir, readFile, readdir, rename, rm, rmdir, stat, unlink } from "node:fs/promises";
12
12
  import { Readable } from "node:stream";
13
13
  import { promisify } from "node:util";
14
14
  import crypto, { randomBytes } from "node:crypto";
@@ -14033,7 +14033,7 @@ function serializeIssue(issue) {
14033
14033
  * Package version, derived from git at build time.
14034
14034
  * Format: X.Y.Z for releases, X.Y.Z-dev.N.hash for dev builds.
14035
14035
  */
14036
- const VERSION$1 = "0.1.23";
14036
+ const VERSION$1 = "0.1.24";
14037
14037
 
14038
14038
  //#endregion
14039
14039
  //#region src/cli/lib/version.ts
@@ -99273,7 +99273,11 @@ async function migrateDataToWorktree(baseDir, removeSource = false) {
99273
99273
  await mkdir(correctIssuesPath, { recursive: true });
99274
99274
  await mkdir(correctMappingsPath, { recursive: true });
99275
99275
  for (const file of issueFiles) await cp(join(wrongIssuesPath, file), join(correctIssuesPath, file));
99276
- for (const file of mappingFiles) await cp(join(wrongMappingsPath, file), join(correctMappingsPath, file));
99276
+ for (const file of mappingFiles) if (file === "ids.yml") {
99277
+ const { loadIdMapping, mergeIdMappings, saveIdMapping } = await Promise.resolve().then(() => id_mapping_exports);
99278
+ const sourceMapping = await loadIdMapping(wrongPath);
99279
+ await saveIdMapping(correctPath, mergeIdMappings(await loadIdMapping(correctPath), sourceMapping));
99280
+ } else await cp(join(wrongMappingsPath, file), join(correctMappingsPath, file));
99277
99281
  const totalFiles = issueFiles.length + mappingFiles.length;
99278
99282
  await git("-C", worktreePath, "add", "-A");
99279
99283
  if (await git("-C", worktreePath, "diff", "--cached", "--quiet").then(() => false).catch(() => true)) await git("-C", worktreePath, "commit", "--no-verify", "-m", `tbd: migrate ${totalFiles} file(s) from incorrect location`);
@@ -99774,6 +99778,110 @@ async function listIssues(baseDir) {
99774
99778
  return issues;
99775
99779
  }
99776
99780
 
99781
+ //#endregion
99782
+ //#region src/utils/lockfile.ts
99783
+ /**
99784
+ * Directory-based mutual exclusion for concurrent file access.
99785
+ *
99786
+ * Note: Despite the name "lockfile", this is NOT a POSIX file lock (flock/fcntl).
99787
+ * It uses mkdir to create a lock *directory* as a coordination convention — no
99788
+ * OS-level file locking syscalls are involved. This makes it portable across all
99789
+ * filesystems, including NFS and other network mounts where flock/fcntl locks
99790
+ * are unreliable or unsupported.
99791
+ *
99792
+ * This is the same strategy used by:
99793
+ *
99794
+ * - **Git** for ref updates (e.g., `.git/refs/heads/main.lock`)
99795
+ * See: https://git-scm.com/docs/gitrepository-layout ("lockfile protocol")
99796
+ * - **npm** for package-lock.json concurrent access
99797
+ *
99798
+ * ## Why mkdir?
99799
+ *
99800
+ * `mkdir(2)` is atomic on all common filesystems (local and network): it either
99801
+ * creates the directory or returns EEXIST. Unlike `open(O_CREAT|O_EXCL)`,
99802
+ * a directory lock is trivially distinguishable from normal files.
99803
+ *
99804
+ * Node.js `fs.mkdir` maps directly to the mkdir(2) syscall, preserving
99805
+ * the atomicity guarantee:
99806
+ * https://nodejs.org/api/fs.html#fsmkdirpath-options-callback
99807
+ *
99808
+ * ## Lock lifecycle
99809
+ *
99810
+ * 1. **Acquire**: `mkdir(lockDir)` — fails with EEXIST if held by another process
99811
+ * 2. **Hold**: Execute the critical section
99812
+ * 3. **Release**: `rmdir(lockDir)` — in a finally block
99813
+ * 4. **Stale detection**: If lock mtime exceeds a threshold, assume the holder
99814
+ * crashed and break the lock. This is a heuristic — safe when the critical
99815
+ * section is short-lived (sub-second for file I/O).
99816
+ *
99817
+ * ## Degraded mode
99818
+ *
99819
+ * If the lock cannot be acquired within the timeout (e.g., due to a stuck
99820
+ * lockfile that isn't old enough to break), the critical section runs anyway.
99821
+ * Callers should design their critical sections to be safe without the lock
99822
+ * (e.g., using read-merge-write for append-only data).
99823
+ */
99824
+ const DEFAULT_TIMEOUT_MS = 2e3;
99825
+ const DEFAULT_POLL_MS = 50;
99826
+ const DEFAULT_STALE_MS = 5e3;
99827
+ /**
99828
+ * Execute `fn` while holding a lockfile.
99829
+ *
99830
+ * The lock is a directory at `lockPath` (typically `<target-file>.lock`).
99831
+ * Concurrent callers will wait up to `timeoutMs` for the lock, polling
99832
+ * every `pollMs`. Stale locks older than `staleMs` are broken automatically.
99833
+ *
99834
+ * If the lock cannot be acquired, `fn` is still executed (degraded mode).
99835
+ * This ensures a stuck lockfile never permanently blocks the CLI.
99836
+ *
99837
+ * @param lockPath - Path to use as the lock directory (e.g., "/path/to/ids.yml.lock")
99838
+ * @param fn - Critical section to execute under the lock
99839
+ * @param options - Timing parameters for lock acquisition
99840
+ * @returns The return value of `fn`
99841
+ *
99842
+ * @example
99843
+ * ```ts
99844
+ * await withLockfile('/path/to/ids.yml.lock', async () => {
99845
+ * const data = await readFile('/path/to/ids.yml', 'utf-8');
99846
+ * const updated = mergeEntries(data, newEntries);
99847
+ * await writeFile('/path/to/ids.yml', updated);
99848
+ * });
99849
+ * ```
99850
+ */
99851
+ async function withLockfile(lockPath, fn, options) {
99852
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
99853
+ const pollMs = options?.pollMs ?? DEFAULT_POLL_MS;
99854
+ const staleMs = options?.staleMs ?? DEFAULT_STALE_MS;
99855
+ const deadline = Date.now() + timeoutMs;
99856
+ let acquired = false;
99857
+ while (Date.now() < deadline) try {
99858
+ await mkdir(lockPath);
99859
+ acquired = true;
99860
+ break;
99861
+ } catch (error) {
99862
+ if (error.code !== "EEXIST") break;
99863
+ try {
99864
+ const lockStat = await stat(lockPath);
99865
+ if (Date.now() - lockStat.mtimeMs > staleMs) {
99866
+ try {
99867
+ await rmdir(lockPath);
99868
+ } catch {}
99869
+ continue;
99870
+ }
99871
+ } catch {
99872
+ continue;
99873
+ }
99874
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
99875
+ }
99876
+ try {
99877
+ return await fn();
99878
+ } finally {
99879
+ if (acquired) try {
99880
+ await rmdir(lockPath);
99881
+ } catch {}
99882
+ }
99883
+ }
99884
+
99777
99885
  //#endregion
99778
99886
  //#region src/lib/sort.ts
99779
99887
  /**
@@ -99918,15 +100026,54 @@ async function loadIdMapping(baseDir) {
99918
100026
  };
99919
100027
  }
99920
100028
  /**
99921
- * Save the ID mapping to disk.
100029
+ * Save the ID mapping to disk with mutual exclusion.
100030
+ *
100031
+ * Uses a lockfile to serialize concurrent writers, then performs read-merge-write
100032
+ * inside the lock. This prevents the lost-update problem when multiple `tbd create`
100033
+ * commands run in parallel.
100034
+ *
100035
+ * The merge is safe because ID mappings are append-only — entries are never
100036
+ * intentionally removed. Even if the lock acquisition fails (degraded mode),
100037
+ * the read-merge-write provides a fallback that preserves entries from other writers.
99922
100038
  */
99923
100039
  async function saveIdMapping(baseDir, mapping) {
99924
100040
  const filePath = getMappingPath(baseDir);
99925
100041
  await mkdir(dirname(filePath), { recursive: true });
99926
- const data = {};
99927
- const sortedKeys = naturalSort(Array.from(mapping.shortToUlid.keys()));
99928
- for (const key of sortedKeys) data[key] = mapping.shortToUlid.get(key);
99929
- await writeFile(filePath, stringifyYaml(data));
100042
+ await withLockfile(filePath + ".lock", async () => {
100043
+ let merged = mapping;
100044
+ let onDiskSize = 0;
100045
+ try {
100046
+ const onDisk = await loadIdMappingRaw(filePath);
100047
+ onDiskSize = onDisk.shortToUlid.size;
100048
+ if (onDiskSize > 0) merged = mergeIdMappings(mapping, onDisk);
100049
+ } catch {}
100050
+ if (merged.shortToUlid.size < onDiskSize) throw new Error(`Refusing to save ID mapping: would lose ${onDiskSize - merged.shortToUlid.size} entries (on-disk: ${onDiskSize}, proposed: ${merged.shortToUlid.size}). ID mappings are append-only — this indicates a bug.`);
100051
+ const data = {};
100052
+ const sortedKeys = naturalSort(Array.from(merged.shortToUlid.keys()));
100053
+ for (const key of sortedKeys) data[key] = merged.shortToUlid.get(key);
100054
+ await writeFile(filePath, stringifyYaml(data));
100055
+ });
100056
+ }
100057
+ /**
100058
+ * Load an ID mapping directly from a file path (internal helper for save merging).
100059
+ * Separated from loadIdMapping to avoid coupling the save path to baseDir resolution.
100060
+ */
100061
+ async function loadIdMappingRaw(filePath) {
100062
+ const { data: rawData } = parseYamlToleratingDuplicateKeys(await readFile(filePath, "utf-8"), filePath);
100063
+ const data = rawData ?? {};
100064
+ const parseResult = IdMappingYamlSchema.safeParse(data);
100065
+ if (!parseResult.success) throw new Error(`Invalid ID mapping format in ${filePath}: ${parseResult.error.message}`);
100066
+ const validData = parseResult.data;
100067
+ const shortToUlid = /* @__PURE__ */ new Map();
100068
+ const ulidToShort = /* @__PURE__ */ new Map();
100069
+ for (const [shortId, ulid] of Object.entries(validData)) {
100070
+ shortToUlid.set(shortId, ulid);
100071
+ ulidToShort.set(ulid, shortId);
100072
+ }
100073
+ return {
100074
+ shortToUlid,
100075
+ ulidToShort
100076
+ };
99930
100077
  }
99931
100078
  /**
99932
100079
  * Calculate the optimal short ID length based on existing ID count.
@@ -104456,7 +104603,9 @@ var DoctorHandler = class extends BaseCommand {
104456
104603
  healthChecks.push(await this.checkIdMappingDuplicates(options.fix));
104457
104604
  healthChecks.push(await this.checkTempFiles(options.fix));
104458
104605
  healthChecks.push(this.checkIssueValidity(this.issues));
104459
- healthChecks.push(await this.checkMissingMappings(options.fix));
104606
+ const parsedMaxHistory = options.maxHistory ? parseInt(options.maxHistory, 10) : 50;
104607
+ const maxHistory = Number.isNaN(parsedMaxHistory) || parsedMaxHistory < 0 ? 50 : parsedMaxHistory;
104608
+ healthChecks.push(await this.checkMissingMappings(options.fix, maxHistory));
104460
104609
  healthChecks.push(await this.checkWorktree(options.fix));
104461
104610
  healthChecks.push(await this.checkDataLocation(options.fix));
104462
104611
  healthChecks.push(await this.checkLocalSyncBranch());
@@ -104810,7 +104959,7 @@ var DoctorHandler = class extends BaseCommand {
104810
104959
  *
104811
104960
  * With --fix, creates missing mappings automatically.
104812
104961
  */
104813
- async checkMissingMappings(fix) {
104962
+ async checkMissingMappings(fix, maxHistory = 50) {
104814
104963
  if (this.issues.length === 0) return {
104815
104964
  name: "ID mapping coverage",
104816
104965
  status: "ok"
@@ -104827,25 +104976,41 @@ var DoctorHandler = class extends BaseCommand {
104827
104976
  status: "ok"
104828
104977
  };
104829
104978
  if (fix && !this.checkDryRun("Create missing ID mappings")) {
104830
- const { parseIdMappingFromYaml } = await Promise.resolve().then(() => id_mapping_exports);
104979
+ const { parseIdMappingFromYaml, mergeIdMappings } = await Promise.resolve().then(() => id_mapping_exports);
104831
104980
  let historicalMapping;
104832
104981
  try {
104833
104982
  const syncBranch = (await Promise.resolve().then(() => config_exports).then((m) => m.readConfig(this.cwd))).sync.branch;
104834
- const priorContent = await git("log", "-1", "--format=%H", syncBranch, "--", `${DATA_SYNC_DIR}/mappings/ids.yml`);
104835
- if (priorContent.trim()) {
104836
- const idsContent = await git("show", `${priorContent.trim()}:${DATA_SYNC_DIR}/mappings/ids.yml`);
104837
- if (idsContent) historicalMapping = parseIdMappingFromYaml(idsContent);
104838
- }
104983
+ const logArgs = ["log", "--format=%H"];
104984
+ if (maxHistory > 0) logArgs.push(`-${maxHistory}`);
104985
+ logArgs.push(syncBranch, "--", `${DATA_SYNC_DIR}/mappings/ids.yml`);
104986
+ const commitHashes = (await git(...logArgs)).trim().split("\n").filter(Boolean);
104987
+ for (const commitHash of commitHashes) try {
104988
+ const idsContent = await git("show", `${commitHash}:${DATA_SYNC_DIR}/mappings/ids.yml`);
104989
+ if (idsContent) {
104990
+ const versionMapping = parseIdMappingFromYaml(idsContent);
104991
+ if (!historicalMapping) historicalMapping = versionMapping;
104992
+ else historicalMapping = mergeIdMappings(historicalMapping, versionMapping);
104993
+ }
104994
+ } catch {}
104839
104995
  } catch {}
104996
+ const historicalCount = historicalMapping?.shortToUlid.size ?? 0;
104840
104997
  const result = reconcileMappings(missingIds, mapping, historicalMapping);
104841
104998
  await saveIdMapping(this.dataSyncDir, mapping);
104842
104999
  const parts = [];
104843
105000
  if (result.recovered.length > 0) parts.push(`recovered ${result.recovered.length} from git history`);
104844
105001
  if (result.created.length > 0) parts.push(`created ${result.created.length} new`);
105002
+ const details = [
105003
+ `Scanned ${maxHistory > 0 ? `up to ${maxHistory}` : "all"} git commits for ids.yml history`,
105004
+ `Found ${historicalCount} historical mapping(s) to use for recovery`,
105005
+ `${missingIds.length} issue(s) were missing short ID mappings`
105006
+ ];
105007
+ if (result.recovered.length > 0) details.push(`Recovered ${result.recovered.length} original short ID(s) from git history`);
105008
+ if (result.created.length > 0) details.push(`Generated ${result.created.length} new short ID(s) (originals not found in history)`);
104845
105009
  return {
104846
105010
  name: "ID mapping coverage",
104847
105011
  status: "ok",
104848
- message: parts.join(", ")
105012
+ message: parts.join(", "),
105013
+ details
104849
105014
  };
104850
105015
  }
104851
105016
  return {
@@ -105004,13 +105169,19 @@ var DoctorHandler = class extends BaseCommand {
105004
105169
  path: wrongIssuesPath,
105005
105170
  details: ["Cannot migrate: worktree must be repaired first.", "The worktree repair should have run before this check."]
105006
105171
  };
105007
- const result = await migrateDataToWorktree(this.cwd);
105008
- if (result.success) return {
105009
- name: "Data location",
105010
- status: "ok",
105011
- message: result.backupPath ? `migrated ${result.migratedCount} file(s), backed up to ${result.backupPath}` : `migrated ${result.migratedCount} file(s)`,
105012
- path: wrongIssuesPath
105013
- };
105172
+ const result = await migrateDataToWorktree(this.cwd, true);
105173
+ if (result.success) {
105174
+ const details = [];
105175
+ if (result.backupPath) details.push(`Backed up to ${result.backupPath}`);
105176
+ details.push(`Migrated ${result.migratedCount} file(s) from .tbd/data-sync/ to worktree`, "Source files removed after successful migration");
105177
+ return {
105178
+ name: "Data location",
105179
+ status: "ok",
105180
+ message: result.backupPath ? `migrated ${result.migratedCount} file(s), backed up to ${result.backupPath}` : `migrated ${result.migratedCount} file(s)`,
105181
+ path: wrongIssuesPath,
105182
+ details
105183
+ };
105184
+ }
105014
105185
  return {
105015
105186
  name: "Data location",
105016
105187
  status: "error",
@@ -105207,15 +105378,13 @@ var DoctorHandler = class extends BaseCommand {
105207
105378
  };
105208
105379
  if (consistency.localAhead > 0) return {
105209
105380
  name: "Sync consistency",
105210
- status: "warn",
105211
- message: `${consistency.localAhead} commit(s) ahead of remote`,
105212
- suggestion: "Run: tbd sync to push changes"
105381
+ status: "ok",
105382
+ message: `${consistency.localAhead} local commit(s) not yet pushed — run \`tbd sync\` to push`
105213
105383
  };
105214
105384
  if (consistency.localBehind > 0) return {
105215
105385
  name: "Sync consistency",
105216
- status: "warn",
105217
- message: `${consistency.localBehind} commit(s) behind remote`,
105218
- suggestion: "Run: tbd sync to pull changes"
105386
+ status: "ok",
105387
+ message: `${consistency.localBehind} remote commit(s) not yet pulled — run \`tbd sync\` to pull`
105219
105388
  };
105220
105389
  return {
105221
105390
  name: "Sync consistency",
@@ -105236,7 +105405,7 @@ var DoctorHandler = class extends BaseCommand {
105236
105405
  }
105237
105406
  }
105238
105407
  };
105239
- const doctorCommand = new Command("doctor").description("Diagnose and repair repository").option("--fix", "Attempt to fix issues").action(async (options, command) => {
105408
+ const doctorCommand = new Command("doctor").description("Diagnose and repair repository").option("--fix", "Attempt to fix issues").option("--max-history <n>", "Max git commits to scan for ID mapping recovery (0 = full history)", "50").action(async (options, command) => {
105240
105409
  await new DoctorHandler(command).run(options);
105241
105410
  });
105242
105411