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 +200 -31
- package/dist/bin.mjs.map +1 -1
- package/dist/cli.mjs +54 -28
- package/dist/cli.mjs.map +1 -1
- package/dist/docs/templates/research-brief.md +46 -5
- package/dist/{id-mapping-JGow6Jk4.mjs → id-mapping-DjVJIO4M.mjs} +150 -7
- package/dist/id-mapping-DjVJIO4M.mjs.map +1 -0
- package/dist/{id-mapping-0-R0X8zb.mjs → id-mapping-LjnDSEhN.mjs} +2 -2
- package/dist/index.mjs +1 -1
- package/dist/{src-7qUDeWJf.mjs → src-BrM6xcdG.mjs} +2 -2
- package/dist/{src-7qUDeWJf.mjs.map → src-BrM6xcdG.mjs.map} +1 -1
- package/dist/tbd +200 -31
- package/package.json +1 -1
- package/dist/id-mapping-JGow6Jk4.mjs.map +0 -1
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.
|
|
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)
|
|
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
|
-
|
|
99927
|
-
|
|
99928
|
-
|
|
99929
|
-
|
|
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
|
-
|
|
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
|
|
104835
|
-
if (
|
|
104836
|
-
|
|
104837
|
-
|
|
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)
|
|
105009
|
-
|
|
105010
|
-
|
|
105011
|
-
|
|
105012
|
-
|
|
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: "
|
|
105211
|
-
message: `${consistency.localAhead} commit(s)
|
|
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: "
|
|
105217
|
-
message: `${consistency.localBehind} commit(s)
|
|
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
|
|