flowcat 1.3.0 → 1.5.0
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/core/addTask.ts +121 -0
- package/core/aliasReconcile.ts +41 -0
- package/core/aliasRepo.ts +92 -0
- package/core/autoCommit.ts +84 -0
- package/core/commitMessage.ts +95 -0
- package/core/config.ts +110 -0
- package/core/constants.ts +8 -0
- package/core/dayAssignments.ts +198 -0
- package/core/edit.ts +59 -0
- package/core/format.ts +63 -0
- package/core/fsm.ts +28 -0
- package/core/git.ts +117 -0
- package/core/gitignore.ts +30 -0
- package/core/id.ts +41 -0
- package/core/initFlow.ts +122 -0
- package/core/json.ts +13 -0
- package/core/lock.ts +31 -0
- package/core/plugin.ts +99 -0
- package/core/pluginErrors.ts +26 -0
- package/core/pluginLoader.ts +222 -0
- package/core/pluginManager.ts +75 -0
- package/core/pluginRunner.ts +217 -0
- package/core/pr.ts +89 -0
- package/core/prWorkflow.ts +185 -0
- package/core/schemas/dayAssignment.ts +11 -0
- package/core/schemas/logEntry.ts +11 -0
- package/core/schemas/pr.ts +29 -0
- package/core/schemas/task.ts +41 -0
- package/core/search.ts +25 -0
- package/core/taskFactory.ts +29 -0
- package/core/taskOperations.ts +104 -0
- package/core/taskRepo.ts +109 -0
- package/core/taskResolver.ts +44 -0
- package/core/taskStore.ts +14 -0
- package/core/time.ts +63 -0
- package/core/types.ts +7 -0
- package/core/workspace.ts +133 -0
- package/dist/fc.mjs +87532 -0
- package/dist/highlights-eq9cgrbb.scm +604 -0
- package/dist/highlights-ghv9g403.scm +205 -0
- package/dist/highlights-hk7bwhj4.scm +284 -0
- package/dist/highlights-r812a2qc.scm +150 -0
- package/dist/highlights-x6tmsnaa.scm +115 -0
- package/dist/index.mjs +5221 -2724
- package/dist/injections-73j83es3.scm +27 -0
- package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
- package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
- package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
- package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
- package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
- package/package.json +25 -9
package/core/addTask.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { resolveGithubToken } from "./config";
|
|
4
|
+
import { withWorkspaceLock } from "./lock";
|
|
5
|
+
import { emitHook } from "./pluginManager";
|
|
6
|
+
import { buildPrAttachment, fetchGitHubPr, parseGitHubPrUrl } from "./pr";
|
|
7
|
+
import type { PrFetched } from "./schemas/pr";
|
|
8
|
+
import type { Task } from "./schemas/task";
|
|
9
|
+
import { taskSchema } from "./schemas/task";
|
|
10
|
+
import { buildTask } from "./taskFactory";
|
|
11
|
+
import { saveTask } from "./taskRepo";
|
|
12
|
+
import { createTaskInStore } from "./taskStore";
|
|
13
|
+
import type { TaskType } from "./types";
|
|
14
|
+
import { taskTypes } from "./types";
|
|
15
|
+
|
|
16
|
+
export type AddTaskInput = {
|
|
17
|
+
type: TaskType;
|
|
18
|
+
title?: string;
|
|
19
|
+
url?: string;
|
|
20
|
+
workspaceRoot: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type AddTaskResult = {
|
|
24
|
+
alias: string;
|
|
25
|
+
task: Task;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type AddTaskErrorCode = "INVALID_PR_URL" | "MISSING_PR_URL" | "MISSING_TITLE";
|
|
29
|
+
|
|
30
|
+
export class AddTaskError extends Error {
|
|
31
|
+
code: AddTaskErrorCode;
|
|
32
|
+
|
|
33
|
+
constructor(code: AddTaskErrorCode, message: string) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.code = code;
|
|
36
|
+
this.name = "AddTaskError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const addTaskInputSchema = z.object({
|
|
41
|
+
type: z.enum(taskTypes),
|
|
42
|
+
title: z.string().optional(),
|
|
43
|
+
url: z.string().optional(),
|
|
44
|
+
workspaceRoot: z.string(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const addTask = async (input: AddTaskInput): Promise<AddTaskResult> => {
|
|
48
|
+
const parsedInput = addTaskInputSchema.parse(input);
|
|
49
|
+
|
|
50
|
+
return withWorkspaceLock(parsedInput.workspaceRoot, async () => {
|
|
51
|
+
let prAttachment: Task["metadata"]["pr"] | undefined;
|
|
52
|
+
let prTitle: string | undefined;
|
|
53
|
+
let genericUrl: string | undefined;
|
|
54
|
+
|
|
55
|
+
if (parsedInput.type === "review" && !parsedInput.url) {
|
|
56
|
+
throw new AddTaskError("MISSING_PR_URL", "Review tasks require a PR URL");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (parsedInput.url) {
|
|
60
|
+
const parsed = parseGitHubPrUrl(parsedInput.url);
|
|
61
|
+
if (parsed) {
|
|
62
|
+
const token = await resolveGithubToken(parsedInput.workspaceRoot);
|
|
63
|
+
let fetched: PrFetched | null = null;
|
|
64
|
+
if (token) {
|
|
65
|
+
try {
|
|
66
|
+
fetched = await fetchGitHubPr(parsed, token);
|
|
67
|
+
} catch {
|
|
68
|
+
fetched = null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
prAttachment = buildPrAttachment(parsed, fetched ?? undefined);
|
|
72
|
+
prTitle = fetched?.title ?? `PR #${parsed.number} ${parsed.repo.owner}/${parsed.repo.name}`;
|
|
73
|
+
} else {
|
|
74
|
+
if (parsedInput.type === "review") {
|
|
75
|
+
throw new AddTaskError("INVALID_PR_URL", "Review tasks require a PR URL");
|
|
76
|
+
}
|
|
77
|
+
genericUrl = parsedInput.url;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const isExplicitTitle = Boolean(parsedInput.title);
|
|
82
|
+
let finalTitle = parsedInput.title ?? prTitle;
|
|
83
|
+
if (!finalTitle) {
|
|
84
|
+
throw new AddTaskError("MISSING_TITLE", "Title is required when the URL is not a PR");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (parsedInput.type === "review" && !isExplicitTitle) {
|
|
88
|
+
finalTitle = `Review: ${finalTitle}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const metadata = {
|
|
92
|
+
url: genericUrl,
|
|
93
|
+
pr: prAttachment,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const task = buildTask({
|
|
97
|
+
type: parsedInput.type,
|
|
98
|
+
title: finalTitle,
|
|
99
|
+
metadata,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
taskSchema.parse(task);
|
|
103
|
+
|
|
104
|
+
const result = await createTaskInStore(parsedInput.workspaceRoot, task);
|
|
105
|
+
|
|
106
|
+
// Emit hook and apply task modifications from plugins
|
|
107
|
+
const hookResult = await emitHook(parsedInput.workspaceRoot, "task:created", {
|
|
108
|
+
task: result.task,
|
|
109
|
+
alias: result.alias,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// If a plugin modified the task, save the updated version
|
|
113
|
+
if (hookResult.task) {
|
|
114
|
+
taskSchema.parse(hookResult.task);
|
|
115
|
+
await saveTask(parsedInput.workspaceRoot, "backlog", hookResult.task);
|
|
116
|
+
return { alias: result.alias, task: hookResult.task };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return result;
|
|
120
|
+
});
|
|
121
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { allocateAliasInMap, readAliases, writeAliases } from "./aliasRepo";
|
|
2
|
+
import { listAllTasks } from "./taskRepo";
|
|
3
|
+
|
|
4
|
+
export type AliasReconcileResult = {
|
|
5
|
+
updated: boolean;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const reconcileAliases = async (workspaceRoot: string): Promise<AliasReconcileResult> => {
|
|
9
|
+
const aliases = await readAliases(workspaceRoot);
|
|
10
|
+
const tasks = await listAllTasks(workspaceRoot);
|
|
11
|
+
|
|
12
|
+
const tasksById = new Map(tasks.map((stored) => [stored.task.id, stored.task]));
|
|
13
|
+
|
|
14
|
+
const normalizePrefix = (prefix: "T" | "R"): void => {
|
|
15
|
+
const next: Record<string, string> = {};
|
|
16
|
+
for (const [key, value] of Object.entries(aliases[prefix])) {
|
|
17
|
+
if (tasksById.has(value)) {
|
|
18
|
+
next[key] = value;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
aliases[prefix] = next;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
normalizePrefix("T");
|
|
26
|
+
normalizePrefix("R");
|
|
27
|
+
|
|
28
|
+
const existingIds = new Set<string>([...Object.values(aliases.T), ...Object.values(aliases.R)]);
|
|
29
|
+
|
|
30
|
+
const missing = tasks
|
|
31
|
+
.filter((stored) => !existingIds.has(stored.task.id))
|
|
32
|
+
.sort((a, b) => a.task.created_at.localeCompare(b.task.created_at));
|
|
33
|
+
|
|
34
|
+
for (const stored of missing) {
|
|
35
|
+
const prefix = stored.task.type === "review" ? "R" : "T";
|
|
36
|
+
allocateAliasInMap(aliases, prefix, stored.task.id);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await writeAliases(workspaceRoot, aliases);
|
|
40
|
+
return { updated: true };
|
|
41
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { readJsonFile, writeJsonAtomic } from "./json";
|
|
5
|
+
|
|
6
|
+
export type AliasPrefix = "T" | "R";
|
|
7
|
+
|
|
8
|
+
export type AliasMap = {
|
|
9
|
+
T: Record<string, string>;
|
|
10
|
+
R: Record<string, string>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const emptyAliases = (): AliasMap => ({ T: {}, R: {} });
|
|
14
|
+
|
|
15
|
+
const aliasFilePath = (workspaceRoot: string): string => path.join(workspaceRoot, "aliases.json");
|
|
16
|
+
|
|
17
|
+
const fileExists = async (filePath: string): Promise<boolean> => {
|
|
18
|
+
try {
|
|
19
|
+
await access(filePath);
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const readAliases = async (workspaceRoot: string): Promise<AliasMap> => {
|
|
27
|
+
const filePath = aliasFilePath(workspaceRoot);
|
|
28
|
+
if (!(await fileExists(filePath))) {
|
|
29
|
+
return emptyAliases();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const data = await readJsonFile<AliasMap>(filePath);
|
|
33
|
+
return {
|
|
34
|
+
T: data.T ?? {},
|
|
35
|
+
R: data.R ?? {},
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const writeAliases = async (workspaceRoot: string, aliases: AliasMap): Promise<void> => {
|
|
40
|
+
await writeJsonAtomic(aliasFilePath(workspaceRoot), aliases);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const parseAlias = (alias: string): { prefix: AliasPrefix; key: string } | null => {
|
|
44
|
+
const match = alias.match(/^([TR])(\d+)$/);
|
|
45
|
+
if (!match) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { prefix: match[1] as AliasPrefix, key: match[2] };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const formatAliasKey = (value: number): string => String(value).padStart(2, "0");
|
|
53
|
+
|
|
54
|
+
export const allocateAliasInMap = (
|
|
55
|
+
aliases: AliasMap,
|
|
56
|
+
prefix: AliasPrefix,
|
|
57
|
+
taskId: string,
|
|
58
|
+
): { alias: string; aliases: AliasMap } => {
|
|
59
|
+
const used = new Set(Object.keys(aliases[prefix]).map((key) => Number.parseInt(key, 10)));
|
|
60
|
+
|
|
61
|
+
let next = 1;
|
|
62
|
+
while (used.has(next)) {
|
|
63
|
+
next += 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const key = formatAliasKey(next);
|
|
67
|
+
aliases[prefix][key] = taskId;
|
|
68
|
+
|
|
69
|
+
return { alias: `${prefix}${key}`, aliases };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const allocateAlias = async (
|
|
73
|
+
workspaceRoot: string,
|
|
74
|
+
prefix: AliasPrefix,
|
|
75
|
+
taskId: string,
|
|
76
|
+
): Promise<{ alias: string; aliases: AliasMap }> => {
|
|
77
|
+
const aliases = await readAliases(workspaceRoot);
|
|
78
|
+
return allocateAliasInMap(aliases, prefix, taskId);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const resolveAlias = async (
|
|
82
|
+
workspaceRoot: string,
|
|
83
|
+
alias: string,
|
|
84
|
+
): Promise<string | null> => {
|
|
85
|
+
const parsed = parseAlias(alias);
|
|
86
|
+
if (!parsed) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const aliases = await readAliases(workspaceRoot);
|
|
91
|
+
return aliases[parsed.prefix][parsed.key] ?? null;
|
|
92
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import type { CommandAction } from "./commitMessage";
|
|
4
|
+
import { generateCommitMessage } from "./commitMessage";
|
|
5
|
+
import { resolveAutoCommitEnabled } from "./config";
|
|
6
|
+
import { autoCommit } from "./git";
|
|
7
|
+
import { findGitRoot } from "./workspace";
|
|
8
|
+
|
|
9
|
+
export const maybeAutoCommit = async (
|
|
10
|
+
workspaceRoot: string,
|
|
11
|
+
action: CommandAction,
|
|
12
|
+
): Promise<void> => {
|
|
13
|
+
const autoCommitEnabled = await resolveAutoCommitEnabled(workspaceRoot);
|
|
14
|
+
|
|
15
|
+
if (!autoCommitEnabled) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const gitRoot = await findGitRoot(workspaceRoot);
|
|
20
|
+
|
|
21
|
+
if (!gitRoot) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const message = generateCommitMessage(action);
|
|
26
|
+
|
|
27
|
+
// Determine which files might be affected by this action
|
|
28
|
+
let filesToStage: string[] = [];
|
|
29
|
+
|
|
30
|
+
switch (action.type) {
|
|
31
|
+
case "add":
|
|
32
|
+
case "start":
|
|
33
|
+
case "pause":
|
|
34
|
+
case "complete":
|
|
35
|
+
case "cancel":
|
|
36
|
+
filesToStage = [
|
|
37
|
+
path.relative(
|
|
38
|
+
gitRoot,
|
|
39
|
+
path.join(workspaceRoot, "tasks", action.task.status, `${action.task.id}.json`),
|
|
40
|
+
),
|
|
41
|
+
];
|
|
42
|
+
break;
|
|
43
|
+
|
|
44
|
+
case "log":
|
|
45
|
+
case "edit":
|
|
46
|
+
filesToStage = [
|
|
47
|
+
path.relative(
|
|
48
|
+
gitRoot,
|
|
49
|
+
path.join(workspaceRoot, "tasks", action.task.status, `${action.task.id}.json`),
|
|
50
|
+
),
|
|
51
|
+
];
|
|
52
|
+
break;
|
|
53
|
+
|
|
54
|
+
case "assign":
|
|
55
|
+
case "unassign":
|
|
56
|
+
filesToStage = [
|
|
57
|
+
path.relative(
|
|
58
|
+
gitRoot,
|
|
59
|
+
path.join(workspaceRoot, "tasks", action.task.status, `${action.task.id}.json`),
|
|
60
|
+
),
|
|
61
|
+
];
|
|
62
|
+
break;
|
|
63
|
+
|
|
64
|
+
case "alias":
|
|
65
|
+
filesToStage = [path.relative(gitRoot, path.join(workspaceRoot, "aliases.json"))];
|
|
66
|
+
break;
|
|
67
|
+
|
|
68
|
+
case "bulk":
|
|
69
|
+
// For bulk operations, stage all task files and aliases
|
|
70
|
+
filesToStage = [
|
|
71
|
+
path.relative(gitRoot, path.join(workspaceRoot, "tasks")),
|
|
72
|
+
path.relative(gitRoot, path.join(workspaceRoot, "aliases.json")),
|
|
73
|
+
];
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Filter out empty strings and problematic paths
|
|
78
|
+
filesToStage = filesToStage.filter((file) => file && !file.startsWith("..") && file !== "");
|
|
79
|
+
|
|
80
|
+
await autoCommit(gitRoot, {
|
|
81
|
+
message,
|
|
82
|
+
files: filesToStage.length > 0 ? filesToStage : undefined,
|
|
83
|
+
});
|
|
84
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Task } from "./schemas/task";
|
|
2
|
+
import type { TaskStatus } from "./types";
|
|
3
|
+
|
|
4
|
+
export type CommandAction =
|
|
5
|
+
| { type: "add"; task: Task }
|
|
6
|
+
| { type: "start"; task: Task }
|
|
7
|
+
| { type: "pause"; task: Task }
|
|
8
|
+
| { type: "complete"; task: Task }
|
|
9
|
+
| { type: "cancel"; task: Task }
|
|
10
|
+
| { type: "log"; task: Task; message: string }
|
|
11
|
+
| { type: "edit"; task: Task; changes: string[] }
|
|
12
|
+
| { type: "assign"; task: Task; date: string }
|
|
13
|
+
| { type: "unassign"; task: Task; date: string }
|
|
14
|
+
| { type: "alias"; oldAlias: string; newAlias: string }
|
|
15
|
+
| { type: "bulk"; action: string; count: number };
|
|
16
|
+
|
|
17
|
+
const statusEmoji: Record<TaskStatus, string> = {
|
|
18
|
+
backlog: "📋",
|
|
19
|
+
active: "🚀",
|
|
20
|
+
paused: "⏸️",
|
|
21
|
+
completed: "✅",
|
|
22
|
+
cancelled: "❌",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const formatTaskRef = (task: Task): string => {
|
|
26
|
+
return task.ref;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const generateCommitMessage = (action: CommandAction): string => {
|
|
30
|
+
switch (action.type) {
|
|
31
|
+
case "add": {
|
|
32
|
+
const { task } = action;
|
|
33
|
+
const emoji = statusEmoji[task.status];
|
|
34
|
+
const typeLabel = task.type === "review" ? "review" : "task";
|
|
35
|
+
return `${emoji} Add ${typeLabel}: ${task.title} [${formatTaskRef(task)}]`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
case "start": {
|
|
39
|
+
const { task } = action;
|
|
40
|
+
return `🚀 Start task: ${task.title} [${formatTaskRef(task)}]`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
case "pause": {
|
|
44
|
+
const { task } = action;
|
|
45
|
+
return `⏸️ Pause task: ${task.title} [${formatTaskRef(task)}]`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
case "complete": {
|
|
49
|
+
const { task } = action;
|
|
50
|
+
return `✅ Complete task: ${task.title} [${formatTaskRef(task)}]`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
case "cancel": {
|
|
54
|
+
const { task } = action;
|
|
55
|
+
return `❌ Cancel task: ${task.title} [${formatTaskRef(task)}]`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
case "log": {
|
|
59
|
+
const { task, message } = action;
|
|
60
|
+
const truncatedMessage = message.length > 50 ? `${message.substring(0, 47)}...` : message;
|
|
61
|
+
return `📝 Log: ${truncatedMessage} [${formatTaskRef(task)}]`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
case "edit": {
|
|
65
|
+
const { task, changes } = action;
|
|
66
|
+
const changesStr = changes.join(", ");
|
|
67
|
+
return `✏️ Edit ${changesStr} for task: ${task.title} [${formatTaskRef(task)}]`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
case "assign": {
|
|
71
|
+
const { task, date } = action;
|
|
72
|
+
return `📅 Assign task to ${date}: ${task.title} [${formatTaskRef(task)}]`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
case "unassign": {
|
|
76
|
+
const { task, date } = action;
|
|
77
|
+
return `📅 Unassign task from ${date}: ${task.title} [${formatTaskRef(task)}]`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
case "alias": {
|
|
81
|
+
const { oldAlias, newAlias } = action;
|
|
82
|
+
return `🏷️ Rename alias: ${oldAlias} → ${newAlias}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
case "bulk": {
|
|
86
|
+
const { action: bulkAction, count } = action;
|
|
87
|
+
return `📦 ${bulkAction}: ${count} task${count === 1 ? "" : "s"}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
default: {
|
|
91
|
+
const never: never = action;
|
|
92
|
+
throw new Error(`Unknown action type: ${never}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
package/core/config.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { access, mkdir } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { APP_NAME } from "./constants";
|
|
6
|
+
import { readJsonFile, writeJsonAtomic } from "./json";
|
|
7
|
+
|
|
8
|
+
export type PluginEntry = {
|
|
9
|
+
/** Path to plugin (relative to plugins dir, absolute, or package name) */
|
|
10
|
+
path: string;
|
|
11
|
+
/** Whether plugin is enabled (default: true) */
|
|
12
|
+
enabled?: boolean;
|
|
13
|
+
/** Plugin-specific configuration */
|
|
14
|
+
config?: Record<string, unknown>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type PluginSettings = {
|
|
18
|
+
/** Timeout for hook execution in ms (default: 30000) */
|
|
19
|
+
hookTimeout?: number;
|
|
20
|
+
/** Whether to continue on plugin errors (default: true) */
|
|
21
|
+
continueOnError?: boolean;
|
|
22
|
+
/** Log level for plugins: "debug" | "info" | "warn" | "error" */
|
|
23
|
+
logLevel?: "debug" | "info" | "warn" | "error";
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type PluginsConfig = {
|
|
27
|
+
/** Plugin directory override */
|
|
28
|
+
directory?: string;
|
|
29
|
+
/** Registered plugins */
|
|
30
|
+
entries?: PluginEntry[];
|
|
31
|
+
/** Global plugin settings */
|
|
32
|
+
settings?: PluginSettings;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type AppConfig = {
|
|
36
|
+
autoCommit?: boolean;
|
|
37
|
+
github?: {
|
|
38
|
+
token?: string;
|
|
39
|
+
};
|
|
40
|
+
plugins?: PluginsConfig;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const configFileExists = async (filePath: string): Promise<boolean> => {
|
|
44
|
+
try {
|
|
45
|
+
await access(filePath);
|
|
46
|
+
return true;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const resolveGlobalConfigPath = (): string => {
|
|
53
|
+
const configHome = process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config");
|
|
54
|
+
return path.join(configHome, APP_NAME, "config.json");
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const resolveWorkspaceConfigPath = (workspaceRoot: string): string => {
|
|
58
|
+
return path.join(workspaceRoot, "config.json");
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const readConfigFile = async (filePath: string): Promise<AppConfig> => {
|
|
62
|
+
if (!(await configFileExists(filePath))) {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (await readJsonFile<AppConfig>(filePath)) ?? {};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const writeConfigFile = async (filePath: string, config: AppConfig): Promise<void> => {
|
|
70
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
71
|
+
await writeJsonAtomic(filePath, config);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const updateConfigFile = async (
|
|
75
|
+
filePath: string,
|
|
76
|
+
updater: (config: AppConfig) => AppConfig,
|
|
77
|
+
): Promise<AppConfig> => {
|
|
78
|
+
const current = await readConfigFile(filePath);
|
|
79
|
+
const next = updater(current);
|
|
80
|
+
await writeConfigFile(filePath, next);
|
|
81
|
+
return next;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const resolveAutoCommitEnabled = async (workspaceRoot: string): Promise<boolean> => {
|
|
85
|
+
const workspaceConfig = await readConfigFile(resolveWorkspaceConfigPath(workspaceRoot));
|
|
86
|
+
if (workspaceConfig.autoCommit !== undefined) {
|
|
87
|
+
return workspaceConfig.autoCommit;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const globalConfig = await readConfigFile(resolveGlobalConfigPath());
|
|
91
|
+
return globalConfig.autoCommit ?? false;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const resolveGithubToken = async (workspaceRoot: string): Promise<string | null> => {
|
|
95
|
+
if (process.env.FLOWCAT_GITHUB_TOKEN) {
|
|
96
|
+
return process.env.FLOWCAT_GITHUB_TOKEN;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (process.env.IL_GITHUB_TOKEN) {
|
|
100
|
+
return process.env.IL_GITHUB_TOKEN;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const workspaceConfig = await readConfigFile(resolveWorkspaceConfigPath(workspaceRoot));
|
|
104
|
+
if (workspaceConfig.github?.token) {
|
|
105
|
+
return workspaceConfig.github.token;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const globalConfig = await readConfigFile(resolveGlobalConfigPath());
|
|
109
|
+
return globalConfig.github?.token ?? null;
|
|
110
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const APP_NAME = "flowcat";
|
|
2
|
+
export const APP_DIR = `.${APP_NAME}`;
|
|
3
|
+
export const LOCK_DIR = ".lock";
|
|
4
|
+
export const LOCK_FILE = "store.lock";
|
|
5
|
+
export const TASKS_DIR = "tasks";
|
|
6
|
+
export const PLUGINS_DIR = "plugins";
|
|
7
|
+
|
|
8
|
+
export const STATUS_ORDER = ["backlog", "active", "paused", "completed", "cancelled"] as const;
|