@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.
- package/.claude/rules/use-bun-instead-of-node-vite-npm-pnpm.md +109 -0
- package/.claude/settings.local.json +12 -0
- package/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc +111 -0
- package/.devcontainer/Dockerfile +102 -0
- package/.devcontainer/devcontainer.json +58 -0
- package/.devcontainer/init-firewall.sh +137 -0
- package/.github/workflows/publish.yml +76 -0
- package/CLAUDE.md +44 -0
- package/README.md +15 -0
- package/index.ts +2 -0
- package/loop.sh +206 -0
- package/package.json +27 -0
- package/specs/chop.md +313 -0
- package/src/commands/add.ts +74 -0
- package/src/commands/archive.ts +72 -0
- package/src/commands/completion.ts +232 -0
- package/src/commands/done.ts +38 -0
- package/src/commands/edit.ts +228 -0
- package/src/commands/init.ts +72 -0
- package/src/commands/list.ts +48 -0
- package/src/commands/move.ts +92 -0
- package/src/commands/pop.ts +45 -0
- package/src/commands/purge.ts +41 -0
- package/src/commands/show.ts +32 -0
- package/src/commands/status.ts +43 -0
- package/src/config/paths.ts +61 -0
- package/src/errors.ts +56 -0
- package/src/index.ts +41 -0
- package/src/models/id-generator.ts +39 -0
- package/src/models/task.ts +98 -0
- package/src/storage/file-lock.ts +124 -0
- package/src/storage/storage-resolver.ts +63 -0
- package/src/storage/task-store.ts +173 -0
- package/src/types.ts +42 -0
- package/src/utils/display.ts +139 -0
- package/src/utils/git.ts +80 -0
- package/src/utils/prompts.ts +88 -0
- package/tests/errors.test.ts +86 -0
- package/tests/models/id-generator.test.ts +46 -0
- package/tests/models/task.test.ts +186 -0
- package/tests/storage/file-lock.test.ts +152 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { NotInitializedError } from "../errors.ts";
|
|
2
|
+
import {
|
|
3
|
+
getLocalStorageDir,
|
|
4
|
+
getTasksFilePath,
|
|
5
|
+
getArchivedTasksFilePath,
|
|
6
|
+
} from "../config/paths.ts";
|
|
7
|
+
import type { StorageLocation } from "../types.ts";
|
|
8
|
+
|
|
9
|
+
export interface StorageInfo {
|
|
10
|
+
location: StorageLocation;
|
|
11
|
+
tasksPath: string;
|
|
12
|
+
archivedPath: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Resolve which storage location to use for the current project
|
|
16
|
+
// Resolution order: local (.chop/) first, then global
|
|
17
|
+
export async function resolveStorage(): Promise<StorageInfo> {
|
|
18
|
+
// Check for local storage first
|
|
19
|
+
const localTasksPath = await getTasksFilePath("local");
|
|
20
|
+
const localFile = Bun.file(localTasksPath);
|
|
21
|
+
|
|
22
|
+
if (await localFile.exists()) {
|
|
23
|
+
return {
|
|
24
|
+
location: "local",
|
|
25
|
+
tasksPath: localTasksPath,
|
|
26
|
+
archivedPath: await getArchivedTasksFilePath("local"),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Check for global storage
|
|
31
|
+
const globalTasksPath = await getTasksFilePath("global");
|
|
32
|
+
const globalFile = Bun.file(globalTasksPath);
|
|
33
|
+
|
|
34
|
+
if (await globalFile.exists()) {
|
|
35
|
+
return {
|
|
36
|
+
location: "global",
|
|
37
|
+
tasksPath: globalTasksPath,
|
|
38
|
+
archivedPath: await getArchivedTasksFilePath("global"),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Neither exists
|
|
43
|
+
throw new NotInitializedError();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Check if the project has been initialized
|
|
47
|
+
export async function isInitialized(): Promise<boolean> {
|
|
48
|
+
try {
|
|
49
|
+
await resolveStorage();
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Check if local storage exists (for init command)
|
|
57
|
+
export async function hasLocalStorage(): Promise<boolean> {
|
|
58
|
+
const localDir = await getLocalStorageDir();
|
|
59
|
+
const localDirFile = Bun.file(localDir);
|
|
60
|
+
// Check if the directory exists by checking for tasks.json
|
|
61
|
+
const localTasksPath = await getTasksFilePath("local");
|
|
62
|
+
return await Bun.file(localTasksPath).exists();
|
|
63
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
import type { Task, TasksFile, StorageLocation } from "../types.ts";
|
|
4
|
+
import { withLock } from "./file-lock.ts";
|
|
5
|
+
import { resolveStorage, type StorageInfo } from "./storage-resolver.ts";
|
|
6
|
+
import { getLockFilePath, getTasksFilePath, getArchivedTasksFilePath } from "../config/paths.ts";
|
|
7
|
+
|
|
8
|
+
// Create an empty tasks file structure
|
|
9
|
+
function createEmptyTasksFile(): TasksFile {
|
|
10
|
+
return {
|
|
11
|
+
version: 1,
|
|
12
|
+
lastSequence: 0,
|
|
13
|
+
tasks: [],
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Task store class for managing tasks with file locking
|
|
18
|
+
export class TaskStore {
|
|
19
|
+
private storageInfo: StorageInfo;
|
|
20
|
+
|
|
21
|
+
private constructor(storageInfo: StorageInfo) {
|
|
22
|
+
this.storageInfo = storageInfo;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Create a TaskStore instance by resolving storage location
|
|
26
|
+
static async create(): Promise<TaskStore> {
|
|
27
|
+
const storageInfo = await resolveStorage();
|
|
28
|
+
return new TaskStore(storageInfo);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Create a TaskStore for a specific location (used by init)
|
|
32
|
+
static async createForLocation(location: StorageLocation): Promise<TaskStore> {
|
|
33
|
+
const tasksPath = await getTasksFilePath(location);
|
|
34
|
+
const archivedPath = await getArchivedTasksFilePath(location);
|
|
35
|
+
return new TaskStore({ location, tasksPath, archivedPath });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Get the lock file path
|
|
39
|
+
private get lockPath(): string {
|
|
40
|
+
return getLockFilePath(this.storageInfo.tasksPath);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Read tasks file (with lock)
|
|
44
|
+
async readTasks(): Promise<TasksFile> {
|
|
45
|
+
return withLock(this.lockPath, async () => {
|
|
46
|
+
const file = Bun.file(this.storageInfo.tasksPath);
|
|
47
|
+
if (!(await file.exists())) {
|
|
48
|
+
return createEmptyTasksFile();
|
|
49
|
+
}
|
|
50
|
+
const content = await file.text();
|
|
51
|
+
return JSON.parse(content) as TasksFile;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Write tasks file (with lock) - internal use only
|
|
56
|
+
private async writeTasks(data: TasksFile): Promise<void> {
|
|
57
|
+
await Bun.write(this.storageInfo.tasksPath, JSON.stringify(data, null, 2));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Atomic read-modify-write operation
|
|
61
|
+
async atomicUpdate<T>(
|
|
62
|
+
operation: (data: TasksFile) => { data: TasksFile; result: T }
|
|
63
|
+
): Promise<T> {
|
|
64
|
+
return withLock(this.lockPath, async () => {
|
|
65
|
+
const file = Bun.file(this.storageInfo.tasksPath);
|
|
66
|
+
let data: TasksFile;
|
|
67
|
+
|
|
68
|
+
if (await file.exists()) {
|
|
69
|
+
const content = await file.text();
|
|
70
|
+
data = JSON.parse(content) as TasksFile;
|
|
71
|
+
} else {
|
|
72
|
+
data = createEmptyTasksFile();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const { data: newData, result } = operation(data);
|
|
76
|
+
await this.writeTasks(newData);
|
|
77
|
+
return result;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Read archived tasks
|
|
82
|
+
async readArchivedTasks(): Promise<TasksFile> {
|
|
83
|
+
return withLock(this.lockPath, async () => {
|
|
84
|
+
const file = Bun.file(this.storageInfo.archivedPath);
|
|
85
|
+
if (!(await file.exists())) {
|
|
86
|
+
return createEmptyTasksFile();
|
|
87
|
+
}
|
|
88
|
+
const content = await file.text();
|
|
89
|
+
return JSON.parse(content) as TasksFile;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Write archived tasks
|
|
94
|
+
private async writeArchivedTasks(data: TasksFile): Promise<void> {
|
|
95
|
+
await Bun.write(this.storageInfo.archivedPath, JSON.stringify(data, null, 2));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Archive a task (move from tasks to archived)
|
|
99
|
+
async archiveTask(taskId: string): Promise<Task> {
|
|
100
|
+
return withLock(this.lockPath, async () => {
|
|
101
|
+
// Read both files
|
|
102
|
+
const tasksFile = Bun.file(this.storageInfo.tasksPath);
|
|
103
|
+
const tasksContent = await tasksFile.text();
|
|
104
|
+
const tasksData: TasksFile = JSON.parse(tasksContent);
|
|
105
|
+
|
|
106
|
+
const archivedFile = Bun.file(this.storageInfo.archivedPath);
|
|
107
|
+
let archivedData: TasksFile;
|
|
108
|
+
if (await archivedFile.exists()) {
|
|
109
|
+
const archivedContent = await archivedFile.text();
|
|
110
|
+
archivedData = JSON.parse(archivedContent);
|
|
111
|
+
} else {
|
|
112
|
+
archivedData = createEmptyTasksFile();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Find and remove the task from active tasks
|
|
116
|
+
const taskIndex = tasksData.tasks.findIndex((t) => t.id === taskId);
|
|
117
|
+
if (taskIndex === -1) {
|
|
118
|
+
throw new Error(`Task ${taskId} not found`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const task = tasksData.tasks.splice(taskIndex, 1)[0]!;
|
|
122
|
+
task.status = "archived";
|
|
123
|
+
task.updatedAt = new Date().toISOString();
|
|
124
|
+
|
|
125
|
+
// Add to archived
|
|
126
|
+
archivedData.tasks.push(task);
|
|
127
|
+
|
|
128
|
+
// Write both files
|
|
129
|
+
await this.writeTasks(tasksData);
|
|
130
|
+
await this.writeArchivedTasks(archivedData);
|
|
131
|
+
|
|
132
|
+
return task;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Purge all archived tasks
|
|
137
|
+
async purgeArchived(): Promise<number> {
|
|
138
|
+
return withLock(this.lockPath, async () => {
|
|
139
|
+
const archivedFile = Bun.file(this.storageInfo.archivedPath);
|
|
140
|
+
if (!(await archivedFile.exists())) {
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const content = await archivedFile.text();
|
|
145
|
+
const data: TasksFile = JSON.parse(content);
|
|
146
|
+
const count = data.tasks.length;
|
|
147
|
+
|
|
148
|
+
// Clear archived tasks
|
|
149
|
+
data.tasks = [];
|
|
150
|
+
await this.writeArchivedTasks(data);
|
|
151
|
+
|
|
152
|
+
return count;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Initialize storage (create directory and empty tasks file)
|
|
157
|
+
static async initialize(location: StorageLocation): Promise<void> {
|
|
158
|
+
const tasksPath = await getTasksFilePath(location);
|
|
159
|
+
const dir = dirname(tasksPath);
|
|
160
|
+
|
|
161
|
+
// Create directory if it doesn't exist
|
|
162
|
+
await mkdir(dir, { recursive: true });
|
|
163
|
+
|
|
164
|
+
// Create empty tasks file
|
|
165
|
+
const emptyTasks = createEmptyTasksFile();
|
|
166
|
+
await Bun.write(tasksPath, JSON.stringify(emptyTasks, null, 2));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Get storage info
|
|
170
|
+
getStorageInfo(): StorageInfo {
|
|
171
|
+
return this.storageInfo;
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Core task status
|
|
2
|
+
export type TaskStatus = "draft" | "open" | "in-progress" | "done" | "archived";
|
|
3
|
+
|
|
4
|
+
// Task model
|
|
5
|
+
export interface Task {
|
|
6
|
+
id: string; // format: "a1b2c3d-1"
|
|
7
|
+
title: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
status: TaskStatus;
|
|
10
|
+
dependsOn: string[]; // task IDs
|
|
11
|
+
createdAt: string; // ISO timestamp
|
|
12
|
+
updatedAt: string; // ISO timestamp
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Tasks file format (tasks.json)
|
|
16
|
+
export interface TasksFile {
|
|
17
|
+
version: 1;
|
|
18
|
+
lastSequence: number;
|
|
19
|
+
tasks: Task[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Storage location type
|
|
23
|
+
export type StorageLocation = "local" | "global";
|
|
24
|
+
|
|
25
|
+
// Project-specific config
|
|
26
|
+
export interface ProjectConfig {
|
|
27
|
+
storage: StorageLocation;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Global config file format
|
|
31
|
+
export interface Config {
|
|
32
|
+
defaultStorage: StorageLocation;
|
|
33
|
+
projects: Record<string, ProjectConfig>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Edit format for YAML editing
|
|
37
|
+
export interface TaskEditData {
|
|
38
|
+
title: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
status: TaskStatus;
|
|
41
|
+
depends_on: string[];
|
|
42
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Task } from "../types.ts";
|
|
2
|
+
import { isBlocked } from "../models/task.ts";
|
|
3
|
+
|
|
4
|
+
// ANSI color codes
|
|
5
|
+
const colors = {
|
|
6
|
+
reset: "\x1b[0m",
|
|
7
|
+
dim: "\x1b[2m",
|
|
8
|
+
green: "\x1b[32m",
|
|
9
|
+
yellow: "\x1b[33m",
|
|
10
|
+
blue: "\x1b[34m",
|
|
11
|
+
cyan: "\x1b[36m",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Status display styles
|
|
15
|
+
const statusStyles: Record<string, { text: string; color: string }> = {
|
|
16
|
+
draft: { text: "draft", color: colors.cyan },
|
|
17
|
+
open: { text: "open", color: colors.blue },
|
|
18
|
+
"in-progress": { text: "in-progress", color: colors.yellow },
|
|
19
|
+
done: { text: "done", color: colors.green },
|
|
20
|
+
archived: { text: "archived", color: colors.dim },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Format a task status for display
|
|
24
|
+
function formatStatus(status: string): string {
|
|
25
|
+
const style = statusStyles[status] || { text: status, color: colors.reset };
|
|
26
|
+
return `${style.color}${style.text}${colors.reset}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Get visible length of a string (excluding ANSI codes)
|
|
30
|
+
function visibleLength(str: string): number {
|
|
31
|
+
return str.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Pad a string to a specific width
|
|
35
|
+
function pad(str: string, width: number): string {
|
|
36
|
+
const len = visibleLength(str);
|
|
37
|
+
const padding = width - len;
|
|
38
|
+
return str + " ".repeat(Math.max(0, padding));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Truncate a string to a max visible length, accounting for ANSI codes
|
|
42
|
+
function truncate(str: string, maxLength: number): string {
|
|
43
|
+
if (maxLength <= 0) return "";
|
|
44
|
+
if (visibleLength(str) <= maxLength) return str;
|
|
45
|
+
|
|
46
|
+
// Simple truncation: iterate through and count visible chars
|
|
47
|
+
let result = "";
|
|
48
|
+
let visible = 0;
|
|
49
|
+
let i = 0;
|
|
50
|
+
|
|
51
|
+
while (i < str.length && visible < maxLength - 1) {
|
|
52
|
+
// Check for ANSI escape sequence
|
|
53
|
+
if (str[i] === "\x1b" && str[i + 1] === "[") {
|
|
54
|
+
const end = str.indexOf("m", i);
|
|
55
|
+
if (end !== -1) {
|
|
56
|
+
result += str.slice(i, end + 1);
|
|
57
|
+
i = end + 1;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
result += str[i];
|
|
62
|
+
visible++;
|
|
63
|
+
i++;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return result + "\u2026"; // ellipsis character
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Get terminal width or default
|
|
70
|
+
function getTerminalWidth(): number {
|
|
71
|
+
return process.stdout.columns || 80;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Format a single task for list display
|
|
75
|
+
export function formatTaskRow(task: Task, allTasks: Task[]): string {
|
|
76
|
+
const blocked = task.status === "open" && isBlocked(task, allTasks);
|
|
77
|
+
const blockedMarker = blocked ? `${colors.dim}[blocked]${colors.reset} ` : "";
|
|
78
|
+
|
|
79
|
+
const id = pad(task.id, 12);
|
|
80
|
+
const status = pad(formatStatus(task.status), 22); // Extra width for ANSI codes
|
|
81
|
+
|
|
82
|
+
// Calculate available width for title: terminal width - id (12) - status (12) - spaces (2)
|
|
83
|
+
const termWidth = getTerminalWidth();
|
|
84
|
+
const titleMaxWidth = termWidth - 12 - 12 - 2;
|
|
85
|
+
|
|
86
|
+
const blockedMarkerLen = visibleLength(blockedMarker);
|
|
87
|
+
const titleWidth = titleMaxWidth - blockedMarkerLen;
|
|
88
|
+
const truncatedTitle = truncate(task.title, titleWidth);
|
|
89
|
+
const title = blockedMarker + truncatedTitle;
|
|
90
|
+
|
|
91
|
+
return `${id} ${status} ${title}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Format task list as a table
|
|
95
|
+
export function formatTaskTable(tasks: Task[], allTasks: Task[]): string {
|
|
96
|
+
if (tasks.length === 0) {
|
|
97
|
+
return "No tasks found.";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const header = `${pad("ID", 12)} ${pad("STATUS", 12)} TITLE`;
|
|
101
|
+
const separator = "-".repeat(60);
|
|
102
|
+
const rows = tasks.map((task) => formatTaskRow(task, allTasks));
|
|
103
|
+
|
|
104
|
+
return [header, separator, ...rows].join("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Format a single task for detailed display (after pop, add, etc.)
|
|
108
|
+
export function formatTaskDetail(task: Task, allTasks?: Task[]): string {
|
|
109
|
+
const lines = [
|
|
110
|
+
`${colors.cyan}ID:${colors.reset} ${task.id}`,
|
|
111
|
+
`${colors.cyan}Title:${colors.reset} ${task.title}`,
|
|
112
|
+
`${colors.cyan}Status:${colors.reset} ${formatStatus(task.status)}`,
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
if (task.description) {
|
|
116
|
+
lines.push(`${colors.cyan}Description:${colors.reset}`);
|
|
117
|
+
lines.push(task.description.split("\n").map((l) => ` ${l}`).join("\n"));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (task.dependsOn.length > 0) {
|
|
121
|
+
lines.push(`${colors.cyan}Depends on:${colors.reset} ${task.dependsOn.join(", ")}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (allTasks && task.status === "open" && isBlocked(task, allTasks)) {
|
|
125
|
+
lines.push(`${colors.yellow}Status: BLOCKED${colors.reset}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return lines.join("\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Format success message
|
|
132
|
+
export function success(message: string): string {
|
|
133
|
+
return `${colors.green}${message}${colors.reset}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Format error message
|
|
137
|
+
export function error(message: string): string {
|
|
138
|
+
return message; // Errors go to stderr, keep simple
|
|
139
|
+
}
|
package/src/utils/git.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { $ } from "bun";
|
|
2
|
+
import { NotInGitRepoError } from "../errors.ts";
|
|
3
|
+
|
|
4
|
+
// Get the root directory of the current git repository
|
|
5
|
+
export async function getGitRepoRoot(): Promise<string> {
|
|
6
|
+
try {
|
|
7
|
+
const result = await $`git rev-parse --show-toplevel`.text();
|
|
8
|
+
return result.trim();
|
|
9
|
+
} catch {
|
|
10
|
+
throw new NotInGitRepoError();
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Get the git remote origin URL (if available)
|
|
15
|
+
export async function getGitRemoteUrl(): Promise<string | null> {
|
|
16
|
+
try {
|
|
17
|
+
const result = await $`git remote get-url origin`.text();
|
|
18
|
+
return result.trim();
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Normalize a remote URL to a consistent project ID format
|
|
25
|
+
// e.g., "git@github.com:user/repo.git" -> "github.com/user/repo"
|
|
26
|
+
// e.g., "https://github.com/user/repo.git" -> "github.com/user/repo"
|
|
27
|
+
function normalizeRemoteUrl(url: string): string {
|
|
28
|
+
// Remove .git suffix
|
|
29
|
+
let normalized = url.replace(/\.git$/, "");
|
|
30
|
+
|
|
31
|
+
// Handle SSH format: git@github.com:user/repo
|
|
32
|
+
if (normalized.startsWith("git@")) {
|
|
33
|
+
normalized = normalized.replace(/^git@/, "").replace(":", "/");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Handle HTTPS format: https://github.com/user/repo
|
|
37
|
+
if (normalized.startsWith("https://")) {
|
|
38
|
+
normalized = normalized.replace(/^https:\/\//, "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (normalized.startsWith("http://")) {
|
|
42
|
+
normalized = normalized.replace(/^http:\/\//, "");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return normalized;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Hash a string to create a short identifier
|
|
49
|
+
function hashString(str: string): string {
|
|
50
|
+
let hash = 0;
|
|
51
|
+
for (let i = 0; i < str.length; i++) {
|
|
52
|
+
const char = str.charCodeAt(i);
|
|
53
|
+
hash = (hash << 5) - hash + char;
|
|
54
|
+
hash = hash & hash; // Convert to 32-bit integer
|
|
55
|
+
}
|
|
56
|
+
return Math.abs(hash).toString(16).slice(0, 8);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Get a unique project identifier for the current repository
|
|
60
|
+
// Uses remote URL if available, otherwise uses repo path hash
|
|
61
|
+
export async function getProjectId(): Promise<string> {
|
|
62
|
+
const remoteUrl = await getGitRemoteUrl();
|
|
63
|
+
if (remoteUrl) {
|
|
64
|
+
return normalizeRemoteUrl(remoteUrl);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Fall back to repo path hash for local-only repos
|
|
68
|
+
const repoRoot = await getGitRepoRoot();
|
|
69
|
+
return hashString(repoRoot);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check if we're inside a git repository
|
|
73
|
+
export async function isInGitRepo(): Promise<boolean> {
|
|
74
|
+
try {
|
|
75
|
+
await $`git rev-parse --git-dir`.quiet();
|
|
76
|
+
return true;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import prompts from "prompts";
|
|
2
|
+
|
|
3
|
+
// Prompt for a yes/no confirmation
|
|
4
|
+
export async function confirm(message: string, defaultYes = false): Promise<boolean> {
|
|
5
|
+
const response = await prompts({
|
|
6
|
+
type: "confirm",
|
|
7
|
+
name: "value",
|
|
8
|
+
message,
|
|
9
|
+
initial: defaultYes,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// Handle cancellation (Ctrl+C)
|
|
13
|
+
if (response.value === undefined) {
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return response.value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Prompt for text input
|
|
21
|
+
export async function prompt(message: string, defaultValue?: string): Promise<string> {
|
|
22
|
+
const response = await prompts({
|
|
23
|
+
type: "text",
|
|
24
|
+
name: "value",
|
|
25
|
+
message,
|
|
26
|
+
initial: defaultValue,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Handle cancellation (Ctrl+C)
|
|
30
|
+
if (response.value === undefined) {
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return response.value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Prompt for a selection from a list of options
|
|
38
|
+
export async function select<T extends string>(
|
|
39
|
+
message: string,
|
|
40
|
+
options: { value: T; label: string }[]
|
|
41
|
+
): Promise<T> {
|
|
42
|
+
const response = await prompts({
|
|
43
|
+
type: "select",
|
|
44
|
+
name: "value",
|
|
45
|
+
message,
|
|
46
|
+
choices: options.map((opt) => ({
|
|
47
|
+
title: opt.label,
|
|
48
|
+
value: opt.value,
|
|
49
|
+
})),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Handle cancellation (Ctrl+C)
|
|
53
|
+
if (response.value === undefined) {
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return response.value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Prompt for selecting multiple tasks (for --depends-on without ID)
|
|
61
|
+
export async function selectTasks(
|
|
62
|
+
message: string,
|
|
63
|
+
tasks: { id: string; title: string }[]
|
|
64
|
+
): Promise<string[]> {
|
|
65
|
+
if (tasks.length === 0) {
|
|
66
|
+
console.log("No tasks available to select.");
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const response = await prompts({
|
|
71
|
+
type: "multiselect",
|
|
72
|
+
name: "value",
|
|
73
|
+
message,
|
|
74
|
+
choices: tasks.map((task) => ({
|
|
75
|
+
title: `${task.id} - ${task.title}`,
|
|
76
|
+
value: task.id,
|
|
77
|
+
})),
|
|
78
|
+
hint: "- Space to select. Return to submit",
|
|
79
|
+
instructions: false,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Handle cancellation (Ctrl+C)
|
|
83
|
+
if (response.value === undefined) {
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return response.value;
|
|
88
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
ChopError,
|
|
4
|
+
NotInGitRepoError,
|
|
5
|
+
NotInitializedError,
|
|
6
|
+
TaskNotFoundError,
|
|
7
|
+
LockError,
|
|
8
|
+
InvalidStatusError,
|
|
9
|
+
HasDependentsError,
|
|
10
|
+
AlreadyInitializedError,
|
|
11
|
+
} from "../src/errors.ts";
|
|
12
|
+
|
|
13
|
+
describe("ChopError", () => {
|
|
14
|
+
test("creates error with message prefix", () => {
|
|
15
|
+
const error = new ChopError("test message");
|
|
16
|
+
expect(error.message).toBe("Error: test message");
|
|
17
|
+
expect(error.name).toBe("ChopError");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("is an instance of Error", () => {
|
|
21
|
+
const error = new ChopError("test");
|
|
22
|
+
expect(error).toBeInstanceOf(Error);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("NotInGitRepoError", () => {
|
|
27
|
+
test("creates error with correct message", () => {
|
|
28
|
+
const error = new NotInGitRepoError();
|
|
29
|
+
expect(error.message).toBe("Error: Not in a git repository");
|
|
30
|
+
expect(error.name).toBe("ChopError");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("NotInitializedError", () => {
|
|
35
|
+
test("creates error with correct message", () => {
|
|
36
|
+
const error = new NotInitializedError();
|
|
37
|
+
expect(error.message).toBe("Error: Project not initialized. Run 'chop init'");
|
|
38
|
+
expect(error.name).toBe("ChopError");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("TaskNotFoundError", () => {
|
|
43
|
+
test("creates error with task id in message", () => {
|
|
44
|
+
const error = new TaskNotFoundError("abc123");
|
|
45
|
+
expect(error.message).toBe("Error: Task abc123 not found");
|
|
46
|
+
expect(error.name).toBe("ChopError");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("LockError", () => {
|
|
51
|
+
test("creates error with correct message", () => {
|
|
52
|
+
const error = new LockError();
|
|
53
|
+
expect(error.message).toBe("Error: Cannot acquire lock. Another process is accessing tasks");
|
|
54
|
+
expect(error.name).toBe("ChopError");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("InvalidStatusError", () => {
|
|
59
|
+
test("creates error with status in message", () => {
|
|
60
|
+
const error = new InvalidStatusError("invalid-status");
|
|
61
|
+
expect(error.message).toBe("Error: Invalid status: invalid-status. Use: open, in-progress, or done");
|
|
62
|
+
expect(error.name).toBe("ChopError");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("HasDependentsError", () => {
|
|
67
|
+
test("creates error with single dependent id", () => {
|
|
68
|
+
const error = new HasDependentsError(["task1"]);
|
|
69
|
+
expect(error.message).toBe("Error: Task has unarchived dependents: task1");
|
|
70
|
+
expect(error.name).toBe("ChopError");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("creates error with multiple dependent ids", () => {
|
|
74
|
+
const error = new HasDependentsError(["task1", "task2", "task3"]);
|
|
75
|
+
expect(error.message).toBe("Error: Task has unarchived dependents: task1, task2, task3");
|
|
76
|
+
expect(error.name).toBe("ChopError");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("AlreadyInitializedError", () => {
|
|
81
|
+
test("creates error with correct message", () => {
|
|
82
|
+
const error = new AlreadyInitializedError();
|
|
83
|
+
expect(error.message).toBe("Error: Project already initialized");
|
|
84
|
+
expect(error.name).toBe("ChopError");
|
|
85
|
+
});
|
|
86
|
+
});
|