@da1z/chop 0.0.1

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 (42) hide show
  1. package/.claude/rules/use-bun-instead-of-node-vite-npm-pnpm.md +109 -0
  2. package/.claude/settings.local.json +12 -0
  3. package/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc +111 -0
  4. package/.devcontainer/Dockerfile +102 -0
  5. package/.devcontainer/devcontainer.json +58 -0
  6. package/.devcontainer/init-firewall.sh +137 -0
  7. package/.github/workflows/publish.yml +76 -0
  8. package/CLAUDE.md +44 -0
  9. package/README.md +15 -0
  10. package/index.ts +2 -0
  11. package/loop.sh +206 -0
  12. package/package.json +27 -0
  13. package/specs/chop.md +313 -0
  14. package/src/commands/add.ts +74 -0
  15. package/src/commands/archive.ts +72 -0
  16. package/src/commands/completion.ts +232 -0
  17. package/src/commands/done.ts +38 -0
  18. package/src/commands/edit.ts +228 -0
  19. package/src/commands/init.ts +72 -0
  20. package/src/commands/list.ts +48 -0
  21. package/src/commands/move.ts +92 -0
  22. package/src/commands/pop.ts +45 -0
  23. package/src/commands/purge.ts +41 -0
  24. package/src/commands/show.ts +32 -0
  25. package/src/commands/status.ts +43 -0
  26. package/src/config/paths.ts +61 -0
  27. package/src/errors.ts +56 -0
  28. package/src/index.ts +41 -0
  29. package/src/models/id-generator.ts +39 -0
  30. package/src/models/task.ts +98 -0
  31. package/src/storage/file-lock.ts +124 -0
  32. package/src/storage/storage-resolver.ts +63 -0
  33. package/src/storage/task-store.ts +173 -0
  34. package/src/types.ts +42 -0
  35. package/src/utils/display.ts +139 -0
  36. package/src/utils/git.ts +80 -0
  37. package/src/utils/prompts.ts +88 -0
  38. package/tests/errors.test.ts +86 -0
  39. package/tests/models/id-generator.test.ts +46 -0
  40. package/tests/models/task.test.ts +186 -0
  41. package/tests/storage/file-lock.test.ts +152 -0
  42. package/tsconfig.json +9 -0
@@ -0,0 +1,92 @@
1
+ import type { Command } from "commander";
2
+ import { TaskStore } from "../storage/task-store.ts";
3
+ import { findTaskById } from "../models/task.ts";
4
+ import { TaskNotFoundError, ChopError } from "../errors.ts";
5
+
6
+ async function moveTask(id: string, position: "top" | "bottom"): Promise<void> {
7
+ const store = await TaskStore.create();
8
+
9
+ await store.atomicUpdate((data) => {
10
+ const task = findTaskById(id, data.tasks);
11
+
12
+ if (!task) {
13
+ throw new TaskNotFoundError(id);
14
+ }
15
+
16
+ // Find and remove the task from its current position
17
+ const index = data.tasks.findIndex((t) => t.id === task.id);
18
+ data.tasks.splice(index, 1);
19
+
20
+ // Insert at new position
21
+ if (position === "top") {
22
+ data.tasks.unshift(task);
23
+ } else {
24
+ data.tasks.push(task);
25
+ }
26
+
27
+ task.updatedAt = new Date().toISOString();
28
+
29
+ return { data, result: task };
30
+ });
31
+
32
+ console.log(`Moved task ${id} to ${position}`);
33
+ }
34
+
35
+ export function registerMoveCommand(program: Command): void {
36
+ program
37
+ .command("move <id>")
38
+ .alias("mv")
39
+ .description("Move a task in the queue")
40
+ .option("-t, --top", "Move to top of queue")
41
+ .option("-b, --bottom", "Move to bottom of queue")
42
+ .action(async (id: string, options) => {
43
+ try {
44
+ if (!options.top && !options.bottom) {
45
+ throw new ChopError("Must specify --top or --bottom");
46
+ }
47
+
48
+ await moveTask(id, options.top ? "top" : "bottom");
49
+ } catch (error) {
50
+ if (error instanceof Error) {
51
+ console.error(error.message);
52
+ } else {
53
+ console.error("An unexpected error occurred");
54
+ }
55
+ process.exit(1);
56
+ }
57
+ });
58
+
59
+ // Shortcut: mt <id> = move <id> --top
60
+ program
61
+ .command("mt <id>")
62
+ .description("Move a task to top of queue (alias for move --top)")
63
+ .action(async (id: string) => {
64
+ try {
65
+ await moveTask(id, "top");
66
+ } catch (error) {
67
+ if (error instanceof Error) {
68
+ console.error(error.message);
69
+ } else {
70
+ console.error("An unexpected error occurred");
71
+ }
72
+ process.exit(1);
73
+ }
74
+ });
75
+
76
+ // Shortcut: mb <id> = move <id> --bottom
77
+ program
78
+ .command("mb <id>")
79
+ .description("Move a task to bottom of queue (alias for move --bottom)")
80
+ .action(async (id: string) => {
81
+ try {
82
+ await moveTask(id, "bottom");
83
+ } catch (error) {
84
+ if (error instanceof Error) {
85
+ console.error(error.message);
86
+ } else {
87
+ console.error("An unexpected error occurred");
88
+ }
89
+ process.exit(1);
90
+ }
91
+ });
92
+ }
@@ -0,0 +1,45 @@
1
+ import type { Command } from "commander";
2
+ import { TaskStore } from "../storage/task-store.ts";
3
+ import { getNextAvailableTask } from "../models/task.ts";
4
+ import { formatTaskDetail } from "../utils/display.ts";
5
+
6
+ export function registerPopCommand(program: Command): void {
7
+ program
8
+ .command("pop")
9
+ .alias("p")
10
+ .description("Get the next available task and mark it as in-progress")
11
+ .action(async () => {
12
+ try {
13
+ const store = await TaskStore.create();
14
+
15
+ const result = await store.atomicUpdate((data) => {
16
+ const task = getNextAvailableTask(data.tasks);
17
+
18
+ if (!task) {
19
+ return { data, result: null };
20
+ }
21
+
22
+ // Mark as in-progress
23
+ task.status = "in-progress";
24
+ task.updatedAt = new Date().toISOString();
25
+
26
+ return { data, result: task };
27
+ });
28
+
29
+ if (!result) {
30
+ console.log("No tasks available");
31
+ return;
32
+ }
33
+
34
+ const tasksData = await store.readTasks();
35
+ console.log(formatTaskDetail(result, tasksData.tasks));
36
+ } catch (error) {
37
+ if (error instanceof Error) {
38
+ console.error(error.message);
39
+ } else {
40
+ console.error("An unexpected error occurred");
41
+ }
42
+ process.exit(1);
43
+ }
44
+ });
45
+ }
@@ -0,0 +1,41 @@
1
+ import type { Command } from "commander";
2
+ import { TaskStore } from "../storage/task-store.ts";
3
+ import { confirm } from "../utils/prompts.ts";
4
+
5
+ export function registerPurgeCommand(program: Command): void {
6
+ program
7
+ .command("purge")
8
+ .description("Permanently delete all archived tasks")
9
+ .action(async () => {
10
+ try {
11
+ const store = await TaskStore.create();
12
+ const archivedData = await store.readArchivedTasks();
13
+
14
+ if (archivedData.tasks.length === 0) {
15
+ console.log("No archived tasks to purge.");
16
+ return;
17
+ }
18
+
19
+ console.log(`Found ${archivedData.tasks.length} archived task(s).`);
20
+ const confirmed = await confirm(
21
+ "Permanently delete all archived tasks? This cannot be undone.",
22
+ false
23
+ );
24
+
25
+ if (!confirmed) {
26
+ console.log("Purge cancelled.");
27
+ return;
28
+ }
29
+
30
+ const count = await store.purgeArchived();
31
+ console.log(`Purged ${count} archived task(s).`);
32
+ } catch (error) {
33
+ if (error instanceof Error) {
34
+ console.error(error.message);
35
+ } else {
36
+ console.error("An unexpected error occurred");
37
+ }
38
+ process.exit(1);
39
+ }
40
+ });
41
+ }
@@ -0,0 +1,32 @@
1
+ import type { Command } from "commander";
2
+ import { TaskStore } from "../storage/task-store.ts";
3
+ import { findTaskById } from "../models/task.ts";
4
+ import { formatTaskDetail } from "../utils/display.ts";
5
+ import { TaskNotFoundError } from "../errors.ts";
6
+
7
+ export function registerShowCommand(program: Command): void {
8
+ program
9
+ .command("show <id>")
10
+ .alias("s")
11
+ .description("Display full task info by ID")
12
+ .action(async (id: string) => {
13
+ try {
14
+ const store = await TaskStore.create();
15
+ const tasksData = await store.readTasks();
16
+ const task = findTaskById(id, tasksData.tasks);
17
+
18
+ if (!task) {
19
+ throw new TaskNotFoundError(id);
20
+ }
21
+
22
+ console.log(formatTaskDetail(task, tasksData.tasks));
23
+ } catch (error) {
24
+ if (error instanceof Error) {
25
+ console.error(error.message);
26
+ } else {
27
+ console.error("An unexpected error occurred");
28
+ }
29
+ process.exit(1);
30
+ }
31
+ });
32
+ }
@@ -0,0 +1,43 @@
1
+ import type { Command } from "commander";
2
+ import { TaskStore } from "../storage/task-store.ts";
3
+ import { findTaskById, isValidStatus } from "../models/task.ts";
4
+ import { TaskNotFoundError, InvalidStatusError } from "../errors.ts";
5
+ import type { TaskStatus } from "../types.ts";
6
+
7
+ export function registerStatusCommand(program: Command): void {
8
+ program
9
+ .command("status <id> <status>")
10
+ .description("Change task status (draft, open, in-progress, done)")
11
+ .action(async (id: string, status: string) => {
12
+ try {
13
+ // Validate status
14
+ if (!isValidStatus(status) || status === "archived") {
15
+ throw new InvalidStatusError(status);
16
+ }
17
+
18
+ const store = await TaskStore.create();
19
+
20
+ await store.atomicUpdate((data) => {
21
+ const task = findTaskById(id, data.tasks);
22
+
23
+ if (!task) {
24
+ throw new TaskNotFoundError(id);
25
+ }
26
+
27
+ task.status = status as TaskStatus;
28
+ task.updatedAt = new Date().toISOString();
29
+
30
+ return { data, result: task };
31
+ });
32
+
33
+ console.log(`Changed task ${id} status to ${status}`);
34
+ } catch (error) {
35
+ if (error instanceof Error) {
36
+ console.error(error.message);
37
+ } else {
38
+ console.error("An unexpected error occurred");
39
+ }
40
+ process.exit(1);
41
+ }
42
+ });
43
+ }
@@ -0,0 +1,61 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ import { getGitRepoRoot, getProjectId } from "../utils/git.ts";
4
+
5
+ // Get the global storage directory (~/.local/share/chop)
6
+ export function getGlobalStorageDir(): string {
7
+ return join(homedir(), ".local", "share", "chop");
8
+ }
9
+
10
+ // Get the global config directory (~/.config/chop)
11
+ export function getGlobalConfigDir(): string {
12
+ return join(homedir(), ".config", "chop");
13
+ }
14
+
15
+ // Get the local storage directory (.chop in repo root)
16
+ export async function getLocalStorageDir(): Promise<string> {
17
+ const repoRoot = await getGitRepoRoot();
18
+ return join(repoRoot, ".chop");
19
+ }
20
+
21
+ // Get the path to the tasks file for a given storage location
22
+ export async function getTasksFilePath(
23
+ location: "local" | "global"
24
+ ): Promise<string> {
25
+ if (location === "local") {
26
+ const localDir = await getLocalStorageDir();
27
+ return join(localDir, "tasks.json");
28
+ }
29
+
30
+ const projectId = await getProjectId();
31
+ return join(getGlobalStorageDir(), projectId, "tasks.json");
32
+ }
33
+
34
+ // Get the path to the archived tasks file
35
+ export async function getArchivedTasksFilePath(
36
+ location: "local" | "global"
37
+ ): Promise<string> {
38
+ if (location === "local") {
39
+ const localDir = await getLocalStorageDir();
40
+ return join(localDir, "tasks.archived.json");
41
+ }
42
+
43
+ const projectId = await getProjectId();
44
+ return join(getGlobalStorageDir(), projectId, "tasks.archived.json");
45
+ }
46
+
47
+ // Get the lock file path for a given tasks file
48
+ export function getLockFilePath(tasksFilePath: string): string {
49
+ return tasksFilePath + ".lock";
50
+ }
51
+
52
+ // Get the local config file path (.chop/config.json)
53
+ export async function getLocalConfigPath(): Promise<string> {
54
+ const localDir = await getLocalStorageDir();
55
+ return join(localDir, "config.json");
56
+ }
57
+
58
+ // Get the global config file path (~/.config/chop/config.json)
59
+ export function getGlobalConfigPath(): string {
60
+ return join(getGlobalConfigDir(), "config.json");
61
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,56 @@
1
+ // Base error class for chop
2
+ export class ChopError extends Error {
3
+ constructor(message: string) {
4
+ super(`Error: ${message}`);
5
+ this.name = "ChopError";
6
+ }
7
+ }
8
+
9
+ // Not in a git repository
10
+ export class NotInGitRepoError extends ChopError {
11
+ constructor() {
12
+ super("Not in a git repository");
13
+ }
14
+ }
15
+
16
+ // Project not initialized
17
+ export class NotInitializedError extends ChopError {
18
+ constructor() {
19
+ super("Project not initialized. Run 'chop init'");
20
+ }
21
+ }
22
+
23
+ // Task not found
24
+ export class TaskNotFoundError extends ChopError {
25
+ constructor(id: string) {
26
+ super(`Task ${id} not found`);
27
+ }
28
+ }
29
+
30
+ // Cannot acquire lock
31
+ export class LockError extends ChopError {
32
+ constructor() {
33
+ super("Cannot acquire lock. Another process is accessing tasks");
34
+ }
35
+ }
36
+
37
+ // Invalid task status
38
+ export class InvalidStatusError extends ChopError {
39
+ constructor(status: string) {
40
+ super(`Invalid status: ${status}. Use: open, in-progress, or done`);
41
+ }
42
+ }
43
+
44
+ // Task has unarchived dependents
45
+ export class HasDependentsError extends ChopError {
46
+ constructor(dependentIds: string[]) {
47
+ super(`Task has unarchived dependents: ${dependentIds.join(", ")}`);
48
+ }
49
+ }
50
+
51
+ // Already initialized
52
+ export class AlreadyInitializedError extends ChopError {
53
+ constructor() {
54
+ super("Project already initialized");
55
+ }
56
+ }
package/src/index.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { Command } from "commander";
2
+ import { registerInitCommand } from "./commands/init.ts";
3
+ import { registerAddCommand } from "./commands/add.ts";
4
+ import { registerListCommand } from "./commands/list.ts";
5
+ import { registerPopCommand } from "./commands/pop.ts";
6
+ import { registerDoneCommand } from "./commands/done.ts";
7
+ import { registerStatusCommand } from "./commands/status.ts";
8
+ import { registerMoveCommand } from "./commands/move.ts";
9
+ import { registerArchiveCommand } from "./commands/archive.ts";
10
+ import { registerPurgeCommand } from "./commands/purge.ts";
11
+ import { registerEditCommand } from "./commands/edit.ts";
12
+ import { registerShowCommand } from "./commands/show.ts";
13
+ import { registerCompletionCommand } from "./commands/completion.ts";
14
+
15
+ const program = new Command();
16
+
17
+ program
18
+ .name("chop")
19
+ .description("Queue-based task management CLI for developers")
20
+ .version("1.0.0");
21
+
22
+ // Register all commands
23
+ registerInitCommand(program);
24
+ registerAddCommand(program);
25
+ registerListCommand(program);
26
+ registerPopCommand(program);
27
+ registerDoneCommand(program);
28
+ registerStatusCommand(program);
29
+ registerMoveCommand(program);
30
+ registerArchiveCommand(program);
31
+ registerPurgeCommand(program);
32
+ registerEditCommand(program);
33
+ registerShowCommand(program);
34
+ registerCompletionCommand(program);
35
+
36
+ // Show help if no command provided
37
+ if (process.argv.length === 2) {
38
+ program.help();
39
+ }
40
+
41
+ program.parse();
@@ -0,0 +1,39 @@
1
+ // Generate a unique task ID in format: "a1b2c3d-N"
2
+ // where the first part is a 7-char random hash and N is the sequence number
3
+
4
+ export interface GeneratedId {
5
+ id: string;
6
+ newSequence: number;
7
+ }
8
+
9
+ // Generate a random 7-character hex hash
10
+ function generateHash(): string {
11
+ const randomBytes = crypto.getRandomValues(new Uint8Array(4));
12
+ let hash = "";
13
+ for (const byte of randomBytes) {
14
+ hash += byte.toString(16).padStart(2, "0");
15
+ }
16
+ return hash.slice(0, 7);
17
+ }
18
+
19
+ // Generate a new task ID
20
+ export function generateTaskId(lastSequence: number): GeneratedId {
21
+ const hash = generateHash();
22
+ const newSequence = lastSequence + 1;
23
+ return {
24
+ id: `${hash}-${newSequence}`,
25
+ newSequence,
26
+ };
27
+ }
28
+
29
+ // Parse a task ID into its components
30
+ export function parseTaskId(id: string): { hash: string; sequence: number } | null {
31
+ const match = id.match(/^([a-f0-9]{7})-(\d+)$/);
32
+ if (!match) {
33
+ return null;
34
+ }
35
+ return {
36
+ hash: match[1]!,
37
+ sequence: parseInt(match[2]!, 10),
38
+ };
39
+ }
@@ -0,0 +1,98 @@
1
+ import type { Task, TaskStatus, TasksFile } from "../types.ts";
2
+ import { generateTaskId } from "./id-generator.ts";
3
+
4
+ export interface CreateTaskOptions {
5
+ title: string;
6
+ description?: string;
7
+ dependsOn?: string[];
8
+ status?: "draft" | "open";
9
+ }
10
+
11
+ // Create a new task
12
+ export function createTask(
13
+ lastSequence: number,
14
+ options: CreateTaskOptions
15
+ ): { task: Task; newSequence: number } {
16
+ const { id, newSequence } = generateTaskId(lastSequence);
17
+ const now = new Date().toISOString();
18
+
19
+ const task: Task = {
20
+ id,
21
+ title: options.title,
22
+ description: options.description,
23
+ status: options.status ?? "open",
24
+ dependsOn: options.dependsOn || [],
25
+ createdAt: now,
26
+ updatedAt: now,
27
+ };
28
+
29
+ return { task, newSequence };
30
+ }
31
+
32
+ // Check if a task is blocked (has incomplete dependencies)
33
+ export function isBlocked(task: Task, tasks: Task[]): boolean {
34
+ if (task.dependsOn.length === 0) {
35
+ return false;
36
+ }
37
+
38
+ return task.dependsOn.some((depId) => {
39
+ const dep = tasks.find((t) => t.id === depId);
40
+ // Task is blocked if dependency exists and is not done/archived
41
+ return dep && dep.status !== "done" && dep.status !== "archived";
42
+ });
43
+ }
44
+
45
+ // Find tasks that depend on a given task (direct dependents)
46
+ export function findDependents(taskId: string, tasks: Task[]): Task[] {
47
+ return tasks.filter((t) => t.dependsOn.includes(taskId));
48
+ }
49
+
50
+ // Find all tasks that depend on a given task (recursive)
51
+ export function findAllDependents(taskId: string, tasks: Task[]): Task[] {
52
+ const allDependents = new Set<Task>();
53
+ const visited = new Set<string>();
54
+
55
+ function collectDependents(id: string): void {
56
+ if (visited.has(id)) return;
57
+ visited.add(id);
58
+
59
+ const directDependents = findDependents(id, tasks);
60
+ for (const dep of directDependents) {
61
+ allDependents.add(dep);
62
+ collectDependents(dep.id);
63
+ }
64
+ }
65
+
66
+ collectDependents(taskId);
67
+ return Array.from(allDependents);
68
+ }
69
+
70
+ // Find a task by ID (supports partial matching)
71
+ export function findTaskById(id: string, tasks: Task[]): Task | undefined {
72
+ // Exact match first
73
+ const exact = tasks.find((t) => t.id === id);
74
+ if (exact) return exact;
75
+
76
+ // Partial match (starts with)
77
+ const partial = tasks.filter((t) => t.id.startsWith(id));
78
+ if (partial.length === 1) {
79
+ return partial[0];
80
+ }
81
+
82
+ return undefined;
83
+ }
84
+
85
+ // Validate a status string
86
+ export function isValidStatus(status: string): status is TaskStatus {
87
+ return ["draft", "open", "in-progress", "done", "archived"].includes(status);
88
+ }
89
+
90
+ // Get the first available (open, unblocked) task
91
+ export function getNextAvailableTask(tasks: Task[]): Task | undefined {
92
+ for (const task of tasks) {
93
+ if (task.status === "open" && !isBlocked(task, tasks)) {
94
+ return task;
95
+ }
96
+ }
97
+ return undefined;
98
+ }
@@ -0,0 +1,124 @@
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ openSync,
5
+ readFileSync,
6
+ unlinkSync,
7
+ writeSync,
8
+ } from "node:fs";
9
+ import { LockError } from "../errors.ts";
10
+
11
+ // Retry delays in milliseconds (exponential backoff)
12
+ const RETRY_DELAYS = [100, 200, 400, 800, 1600];
13
+
14
+ // Stale lock threshold (60 seconds)
15
+ const STALE_LOCK_MS = 60_000;
16
+
17
+ interface LockInfo {
18
+ pid: number;
19
+ timestamp: number;
20
+ }
21
+
22
+ // Sleep for a given number of milliseconds
23
+ function sleep(ms: number): Promise<void> {
24
+ return new Promise((resolve) => setTimeout(resolve, ms));
25
+ }
26
+
27
+ // Check if a lock file is stale
28
+ function isLockStale(lockPath: string): boolean {
29
+ try {
30
+ const content = readFileSync(lockPath, "utf-8");
31
+ const lockInfo: LockInfo = JSON.parse(content);
32
+ const age = Date.now() - lockInfo.timestamp;
33
+ return age > STALE_LOCK_MS;
34
+ } catch {
35
+ // If we can't read the lock file, assume it's stale
36
+ return true;
37
+ }
38
+ }
39
+
40
+ // Try to acquire a lock file
41
+ function tryAcquireLock(lockPath: string): boolean {
42
+ try {
43
+ // O_CREAT | O_EXCL | O_WRONLY - create exclusively, fail if exists
44
+ const fd = openSync(lockPath, "wx");
45
+ const lockInfo: LockInfo = {
46
+ pid: process.pid,
47
+ timestamp: Date.now(),
48
+ };
49
+ // Write to fd before closing to avoid race condition where lock file
50
+ // exists but is empty
51
+ writeSync(fd, JSON.stringify(lockInfo));
52
+ closeSync(fd);
53
+ return true;
54
+ } catch (error: unknown) {
55
+ // File already exists (another process has the lock)
56
+ if (
57
+ error &&
58
+ typeof error === "object" &&
59
+ "code" in error &&
60
+ error.code === "EEXIST"
61
+ ) {
62
+ return false;
63
+ }
64
+ // Some other error - rethrow
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ // Release a lock file
70
+ function releaseLock(lockPath: string): void {
71
+ try {
72
+ if (existsSync(lockPath)) {
73
+ unlinkSync(lockPath);
74
+ }
75
+ } catch {
76
+ // Ignore errors when releasing - the lock might already be gone
77
+ }
78
+ }
79
+
80
+ // Acquire a lock with retries
81
+ async function acquireLock(lockPath: string): Promise<void> {
82
+ // First attempt
83
+ if (tryAcquireLock(lockPath)) {
84
+ return;
85
+ }
86
+
87
+ // Check if existing lock is stale and remove it
88
+ if (isLockStale(lockPath)) {
89
+ releaseLock(lockPath);
90
+ if (tryAcquireLock(lockPath)) {
91
+ return;
92
+ }
93
+ }
94
+
95
+ // Retry with exponential backoff
96
+ for (const delay of RETRY_DELAYS) {
97
+ await sleep(delay);
98
+
99
+ // Check for stale lock again
100
+ if (isLockStale(lockPath)) {
101
+ releaseLock(lockPath);
102
+ }
103
+
104
+ if (tryAcquireLock(lockPath)) {
105
+ return;
106
+ }
107
+ }
108
+
109
+ // Failed to acquire lock after all retries
110
+ throw new LockError();
111
+ }
112
+
113
+ // Execute an operation with a file lock
114
+ export async function withLock<T>(
115
+ lockPath: string,
116
+ operation: () => Promise<T>
117
+ ): Promise<T> {
118
+ await acquireLock(lockPath);
119
+ try {
120
+ return await operation();
121
+ } finally {
122
+ releaseLock(lockPath);
123
+ }
124
+ }