loop-task 1.5.5 → 2.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/README.md +127 -21
- package/dist/board/App.js +1 -1
- package/dist/board/components/ActionButtons.js +1 -1
- package/dist/board/components/BoardButton.js +1 -1
- package/dist/board/components/ConfirmModal.js +1 -1
- package/dist/board/components/ContextHelpModal.js +1 -1
- package/dist/board/components/CreateForm.js +1 -1
- package/dist/board/components/CreateProjectModal.js +1 -1
- package/dist/board/components/DeleteProjectConfirm.js +1 -1
- package/dist/board/components/DetailView.js +1 -1
- package/dist/board/components/EditProjectModal.js +1 -1
- package/dist/board/components/FilterBar.js +1 -1
- package/dist/board/components/Footer.js +1 -1
- package/dist/board/components/Header.js +1 -1
- package/dist/board/components/HelpModal.js +1 -1
- package/dist/board/components/Inspector.js +1 -1
- package/dist/board/components/LogModal.js +1 -1
- package/dist/board/components/Navigator.js +1 -1
- package/dist/board/components/ProjectsModal.js +1 -1
- package/dist/board/components/ProjectsPage.js +1 -1
- package/dist/board/components/RunHistory.js +1 -1
- package/dist/board/components/SearchBox.js +1 -1
- package/dist/board/components/SearchSelect.js +1 -1
- package/dist/board/components/TaskBrowser.js +1 -1
- package/dist/board/components/TaskFilterBar.js +1 -1
- package/dist/board/components/TaskForm.js +1 -1
- package/dist/board/components/Timeline.js +1 -1
- package/dist/board/focus-context.js +1 -1
- package/dist/board/format.js +15 -6
- package/dist/board/index.js +1 -1
- package/dist/board/toast.js +1 -1
- package/dist/cli/import-validator.js +151 -0
- package/dist/cli/import-writer.js +77 -0
- package/dist/cli.js +111 -17
- package/dist/config/constants.js +52 -0
- package/dist/daemon/file-watcher.js +171 -0
- package/dist/daemon/http-server.js +605 -0
- package/dist/daemon/index.js +36 -6
- package/dist/daemon/manager.js +94 -13
- package/dist/daemon/projects.js +10 -0
- package/dist/daemon/server.js +21 -0
- package/dist/daemon/task-manager.js +7 -0
- package/dist/esm-loader.js +4 -1
- package/dist/hooks/useLoopFormValidation.js +140 -0
- package/dist/i18n/en.json +237 -17
- package/dist/shared/fs-utils.js +34 -1
- package/dist/tui/app.js +675 -151
- package/dist/tui/commands.js +114 -0
- package/dist/tui/components/ActionButtons.js +49 -0
- package/dist/tui/components/Button.js +18 -0
- package/dist/tui/components/ChainEditor.js +112 -0
- package/dist/tui/components/CommandBuilderField.js +143 -0
- package/dist/tui/components/CommandEditorModal.js +159 -0
- package/dist/tui/components/CommandInput.js +303 -0
- package/dist/tui/components/CommandsBrowserModal.js +77 -0
- package/dist/tui/components/ConfirmModal.js +9 -0
- package/dist/tui/components/ContextHelpModal.js +8 -0
- package/dist/tui/components/CreateForm.js +223 -0
- package/dist/tui/components/DebugPanel.js +8 -0
- package/dist/tui/components/ExportModal.js +37 -0
- package/dist/tui/components/FilterBar.js +13 -0
- package/dist/tui/components/FocusableButton.js +16 -0
- package/dist/tui/components/FocusableInput.js +69 -0
- package/dist/tui/components/FocusableList.js +46 -0
- package/dist/tui/components/FocusableSearchSelect.js +82 -0
- package/dist/tui/components/Footer.js +42 -0
- package/dist/tui/components/Header.js +40 -0
- package/dist/tui/components/HelpGuideModal.js +21 -0
- package/dist/tui/components/HelpModal.js +59 -0
- package/dist/tui/components/InlineCommandEditor.js +143 -0
- package/dist/tui/components/Inspector.js +31 -0
- package/dist/tui/components/LeftPanel.js +33 -0
- package/dist/tui/components/LogModal.js +121 -0
- package/dist/tui/components/Modal.js +11 -0
- package/dist/tui/components/Navigator.js +68 -0
- package/dist/tui/components/PatchEditForm.js +78 -0
- package/dist/tui/components/ProjectForm.js +64 -0
- package/dist/tui/components/ProjectsModal.js +40 -0
- package/dist/tui/components/ProjectsPage.js +99 -0
- package/dist/tui/components/RightPanel.js +34 -0
- package/dist/tui/components/RunHistory.js +108 -0
- package/dist/tui/components/SearchSelect.js +67 -0
- package/dist/tui/components/SelectModal.js +76 -0
- package/dist/tui/components/TabBar.js +20 -0
- package/dist/tui/components/TaskBrowser.js +96 -0
- package/dist/tui/components/TaskFilterBar.js +9 -0
- package/dist/tui/components/TaskForm.js +134 -0
- package/dist/tui/components/TaskPickerModal.js +60 -0
- package/dist/tui/components/Toast.js +37 -0
- package/dist/tui/components/WelcomeScreen.js +70 -0
- package/dist/tui/components/WizardForm.js +178 -0
- package/dist/tui/daemon.js +142 -0
- package/dist/tui/format.js +111 -0
- package/dist/tui/hooks/useBreakpoint.js +7 -0
- package/dist/tui/hooks/useHoverState.js +11 -0
- package/dist/tui/hooks/useLogStream.js +32 -0
- package/dist/tui/hooks/useLoopPolling.js +28 -0
- package/dist/tui/index.js +24 -0
- package/dist/tui/router.js +16 -0
- package/dist/tui/state.js +175 -0
- package/dist/tui/theme.js +96 -0
- package/dist/tui/types.js +1 -0
- package/dist/tui/utils/paste.js +10 -0
- package/dist/tui/utils/validation.js +56 -0
- package/package.json +13 -8
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
const PROJECT_FIELDS = [
|
|
2
|
+
{ name: "id", type: "string", nullable: false },
|
|
3
|
+
{ name: "name", type: "string", nullable: false },
|
|
4
|
+
{ name: "color", type: "string", nullable: false },
|
|
5
|
+
{ name: "createdAt", type: "string", nullable: false },
|
|
6
|
+
{ name: "isSystem", type: "boolean", nullable: false },
|
|
7
|
+
{ name: "isDefault", type: "boolean", nullable: false },
|
|
8
|
+
];
|
|
9
|
+
const TASK_FIELDS = [
|
|
10
|
+
{ name: "id", type: "string", nullable: false },
|
|
11
|
+
{ name: "name", type: "string", nullable: false },
|
|
12
|
+
{ name: "command", type: "string", nullable: false },
|
|
13
|
+
{ name: "commandArgs", type: "array", nullable: false },
|
|
14
|
+
{ name: "onSuccessTaskId", type: "string", nullable: true },
|
|
15
|
+
{ name: "onFailureTaskId", type: "string", nullable: true },
|
|
16
|
+
{ name: "createdAt", type: "string", nullable: false },
|
|
17
|
+
];
|
|
18
|
+
const LOOP_FIELDS = [
|
|
19
|
+
{ name: "id", type: "string", nullable: false },
|
|
20
|
+
{ name: "taskId", type: "string", nullable: true },
|
|
21
|
+
{ name: "command", type: "string", nullable: false },
|
|
22
|
+
{ name: "commandArgs", type: "array", nullable: false },
|
|
23
|
+
{ name: "interval", type: "number", nullable: false },
|
|
24
|
+
{ name: "intervalHuman", type: "string", nullable: false },
|
|
25
|
+
{ name: "immediate", type: "boolean", nullable: false },
|
|
26
|
+
{ name: "maxRuns", type: "number", nullable: true },
|
|
27
|
+
{ name: "verbose", type: "boolean", nullable: false },
|
|
28
|
+
{ name: "cwd", type: "string", nullable: false },
|
|
29
|
+
{ name: "description", type: "string", nullable: false },
|
|
30
|
+
{ name: "status", type: "string", nullable: false },
|
|
31
|
+
{ name: "createdAt", type: "string", nullable: false },
|
|
32
|
+
{ name: "sessionStartedAt", type: "string", nullable: true },
|
|
33
|
+
{ name: "runCount", type: "number", nullable: false },
|
|
34
|
+
{ name: "lastRunAt", type: "string", nullable: true },
|
|
35
|
+
{ name: "lastExitCode", type: "number", nullable: true },
|
|
36
|
+
{ name: "lastDuration", type: "number", nullable: true },
|
|
37
|
+
{ name: "nextRunAt", type: "string", nullable: true },
|
|
38
|
+
{ name: "remainingDelayMs", type: "number", nullable: true },
|
|
39
|
+
{ name: "pid", type: "number", nullable: false },
|
|
40
|
+
{ name: "maxRunsReached", type: "boolean", nullable: false },
|
|
41
|
+
{ name: "runHistory", type: "array", nullable: false },
|
|
42
|
+
{ name: "skippedCount", type: "number", nullable: false },
|
|
43
|
+
{ name: "projectId", type: "string", nullable: false },
|
|
44
|
+
{ name: "offset", type: "number", nullable: true },
|
|
45
|
+
];
|
|
46
|
+
const EXPECTED_VERSION = 2;
|
|
47
|
+
const REQUIRED_KEYS = ["loops", "tasks", "projects"];
|
|
48
|
+
function validateItems(items, fields, label) {
|
|
49
|
+
const errors = [];
|
|
50
|
+
for (let i = 0; i < items.length; i++) {
|
|
51
|
+
const item = items[i];
|
|
52
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
53
|
+
errors.push({ key: "item", message: `${label}[${i}]: must be an object` });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const record = item;
|
|
57
|
+
for (const field of fields) {
|
|
58
|
+
const value = record[field.name];
|
|
59
|
+
if (value === undefined || value === null) {
|
|
60
|
+
if (!field.nullable) {
|
|
61
|
+
errors.push({ key: "item", message: `${label}[${i}]: missing field '${field.name}'` });
|
|
62
|
+
}
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (field.nullable && value === null)
|
|
66
|
+
continue;
|
|
67
|
+
switch (field.type) {
|
|
68
|
+
case "string":
|
|
69
|
+
if (typeof value !== "string") {
|
|
70
|
+
errors.push({ key: "item", message: `${label}[${i}]: field '${field.name}' must be a string` });
|
|
71
|
+
}
|
|
72
|
+
break;
|
|
73
|
+
case "number":
|
|
74
|
+
if (typeof value !== "number") {
|
|
75
|
+
errors.push({ key: "item", message: `${label}[${i}]: field '${field.name}' must be a number` });
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
case "boolean":
|
|
79
|
+
if (typeof value !== "boolean") {
|
|
80
|
+
errors.push({ key: "item", message: `${label}[${i}]: field '${field.name}' must be a boolean` });
|
|
81
|
+
}
|
|
82
|
+
break;
|
|
83
|
+
case "array":
|
|
84
|
+
if (!Array.isArray(value)) {
|
|
85
|
+
errors.push({ key: "item", message: `${label}[${i}]: field '${field.name}' must be an array` });
|
|
86
|
+
}
|
|
87
|
+
break;
|
|
88
|
+
case "object":
|
|
89
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
90
|
+
errors.push({ key: "item", message: `${label}[${i}]: field '${field.name}' must be an object` });
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return errors;
|
|
97
|
+
}
|
|
98
|
+
export function validateExportFile(raw) {
|
|
99
|
+
const errors = [];
|
|
100
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
101
|
+
return { valid: false, errors: [{ key: "version", message: "Invalid export file: expected a JSON object" }] };
|
|
102
|
+
}
|
|
103
|
+
const data = raw;
|
|
104
|
+
if (!("version" in data)) {
|
|
105
|
+
return { valid: false, errors: [{ key: "version", message: "Missing export version. Expected: 2" }] };
|
|
106
|
+
}
|
|
107
|
+
if (data.version !== EXPECTED_VERSION) {
|
|
108
|
+
return {
|
|
109
|
+
valid: false,
|
|
110
|
+
errors: [{ key: "version", message: `Unsupported export version: ${data.version}. Expected: ${EXPECTED_VERSION}` }],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const missingKeys = [];
|
|
114
|
+
const nonArrayKeys = [];
|
|
115
|
+
for (const key of REQUIRED_KEYS) {
|
|
116
|
+
if (!(key in data)) {
|
|
117
|
+
missingKeys.push(key);
|
|
118
|
+
}
|
|
119
|
+
else if (!Array.isArray(data[key])) {
|
|
120
|
+
nonArrayKeys.push(key);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (missingKeys.length > 0) {
|
|
124
|
+
errors.push({ key: "missing_keys", message: `Missing required keys: ${missingKeys.join(", ")}` });
|
|
125
|
+
}
|
|
126
|
+
if (nonArrayKeys.length > 0) {
|
|
127
|
+
for (const key of nonArrayKeys) {
|
|
128
|
+
errors.push({ key: "non_array_keys", message: `field '${key}' must be an array` });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (errors.length > 0) {
|
|
132
|
+
return { valid: false, errors };
|
|
133
|
+
}
|
|
134
|
+
const itemErrors = [
|
|
135
|
+
...validateItems(data.loops, LOOP_FIELDS, "loops"),
|
|
136
|
+
...validateItems(data.tasks, TASK_FIELDS, "tasks"),
|
|
137
|
+
...validateItems(data.projects, PROJECT_FIELDS, "projects"),
|
|
138
|
+
];
|
|
139
|
+
if (itemErrors.length > 0) {
|
|
140
|
+
return { valid: false, errors: itemErrors };
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
valid: true,
|
|
144
|
+
errors: [],
|
|
145
|
+
data: {
|
|
146
|
+
loops: data.loops,
|
|
147
|
+
tasks: data.tasks,
|
|
148
|
+
projects: data.projects,
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { writeFileAtomic } from "../shared/fs-utils.js";
|
|
4
|
+
import { getDataDir } from "../config/paths.js";
|
|
5
|
+
const STORE_FILES = ["loops.json", "tasks.json", "projects.json"];
|
|
6
|
+
function backupFilePath(filePath) {
|
|
7
|
+
return `${filePath}.bak`;
|
|
8
|
+
}
|
|
9
|
+
export function atomicImportWrite(loops, tasks, projects) {
|
|
10
|
+
const dataDir = getDataDir();
|
|
11
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
12
|
+
const contents = {
|
|
13
|
+
"loops.json": JSON.stringify(loops, null, 2),
|
|
14
|
+
"tasks.json": JSON.stringify(tasks, null, 2),
|
|
15
|
+
"projects.json": JSON.stringify(projects, null, 2),
|
|
16
|
+
};
|
|
17
|
+
const backups = new Map();
|
|
18
|
+
for (const store of STORE_FILES) {
|
|
19
|
+
const storePath = path.join(dataDir, store);
|
|
20
|
+
if (fs.existsSync(storePath) && fs.statSync(storePath).isFile()) {
|
|
21
|
+
backups.set(store, fs.readFileSync(storePath, "utf-8"));
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
backups.set(store, null);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const writtenStores = [];
|
|
28
|
+
try {
|
|
29
|
+
for (const store of STORE_FILES) {
|
|
30
|
+
const storePath = path.join(dataDir, store);
|
|
31
|
+
writeFileAtomic(storePath, contents[store]);
|
|
32
|
+
writtenStores.push(store);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
const writeError = err;
|
|
37
|
+
for (const store of STORE_FILES) {
|
|
38
|
+
const storePath = path.join(dataDir, store);
|
|
39
|
+
const backup = backups.get(store) ?? null;
|
|
40
|
+
if (backup !== null) {
|
|
41
|
+
try {
|
|
42
|
+
writeFileAtomic(storePath, backup);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// best-effort restore
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
try {
|
|
50
|
+
if (fs.existsSync(storePath) && fs.statSync(storePath).isFile()) {
|
|
51
|
+
fs.unlinkSync(storePath);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// best-effort cleanup
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
success: false,
|
|
61
|
+
error: writeError.message,
|
|
62
|
+
writtenStores,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
for (const store of STORE_FILES) {
|
|
66
|
+
const bakPath = backupFilePath(path.join(dataDir, store));
|
|
67
|
+
try {
|
|
68
|
+
if (fs.existsSync(bakPath)) {
|
|
69
|
+
fs.unlinkSync(bakPath);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// best-effort cleanup
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { success: true, writtenStores: [...STORE_FILES] };
|
|
77
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { parseDuration } from "./duration.js";
|
|
|
8
8
|
import { startLoop } from "./client/commands.js";
|
|
9
9
|
import { listProjectsCli, createProjectCli, renameProjectCli, setProjectColorCli, deleteProjectCli, resolveProjectId, } from "./client/commands.js";
|
|
10
10
|
import { t } from "./i18n/index.js";
|
|
11
|
+
import { HTTP_API_PORT, HTTP_API_HOST } from "./config/constants.js";
|
|
11
12
|
const require = createRequire(import.meta.url);
|
|
12
13
|
const packageJson = require("../package.json");
|
|
13
14
|
const program = new Command();
|
|
@@ -77,6 +78,7 @@ program
|
|
|
77
78
|
.option("--cwd <dir>", t("cli.optCwd"))
|
|
78
79
|
.option("--project <name>", t("cli.optProject"))
|
|
79
80
|
.option("--offset <duration>", "Phase offset (e.g. 5m, 15m)")
|
|
81
|
+
.option("--description <desc>", "Description of the loop")
|
|
80
82
|
.action(async (intervalStr, cmdArgs, opts) => {
|
|
81
83
|
try {
|
|
82
84
|
const projectId = opts.project
|
|
@@ -90,6 +92,7 @@ program
|
|
|
90
92
|
cwd: opts.cwd ?? process.cwd(),
|
|
91
93
|
projectId,
|
|
92
94
|
offset: offsetMs,
|
|
95
|
+
description: opts.description ?? cmdArgs.join(" "),
|
|
93
96
|
});
|
|
94
97
|
await startLoop(built.options, built.intervalHuman);
|
|
95
98
|
}
|
|
@@ -108,6 +111,7 @@ program
|
|
|
108
111
|
.option("--max-runs <n>", t("cli.optMaxRuns"))
|
|
109
112
|
.option("--verbose", t("cli.optVerbose"))
|
|
110
113
|
.option("--cwd <dir>", t("cli.optCwd"))
|
|
114
|
+
.option("--description <desc>", "Description of the loop")
|
|
111
115
|
.action(async (intervalStr, cmdArgs, opts) => {
|
|
112
116
|
if (!intervalStr || !cmdArgs || cmdArgs.length === 0) {
|
|
113
117
|
program.help();
|
|
@@ -119,6 +123,7 @@ program
|
|
|
119
123
|
command: cmdArgs[0],
|
|
120
124
|
commandArgs: cmdArgs.slice(1),
|
|
121
125
|
cwd: opts.cwd ?? process.cwd(),
|
|
126
|
+
description: opts.description ?? cmdArgs.join(" "),
|
|
122
127
|
});
|
|
123
128
|
const controller = new AbortController();
|
|
124
129
|
process.on("SIGINT", () => controller.abort());
|
|
@@ -165,23 +170,112 @@ projectCmd
|
|
|
165
170
|
await deleteProjectCli(idOrName);
|
|
166
171
|
});
|
|
167
172
|
program.action(async () => {
|
|
168
|
-
|
|
169
|
-
const { spawn } = await import("node:child_process");
|
|
170
|
-
const child = spawn("bun", [process.argv[1]], {
|
|
171
|
-
stdio: "inherit",
|
|
172
|
-
env: { ...process.env },
|
|
173
|
-
shell: true,
|
|
174
|
-
});
|
|
175
|
-
child.on("error", () => {
|
|
176
|
-
console.error("The board requires the Bun runtime for OpenTUI native FFI.\n" +
|
|
177
|
-
"Install Bun: npm install -g bun\n" +
|
|
178
|
-
"Then run: loop-task");
|
|
179
|
-
process.exit(1);
|
|
180
|
-
});
|
|
181
|
-
child.on("exit", (code) => process.exit(code ?? 0));
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
const { launchBoard } = await import("./board/index.js");
|
|
173
|
+
const { launchBoard } = await import("./tui/index.js");
|
|
185
174
|
await launchBoard();
|
|
186
175
|
});
|
|
176
|
+
program
|
|
177
|
+
.command("status")
|
|
178
|
+
.description("Show status of all loops")
|
|
179
|
+
.option("--json", "Output as JSON")
|
|
180
|
+
.action(async (opts) => {
|
|
181
|
+
const { sendRequest } = await import("./client/ipc.js");
|
|
182
|
+
const response = await sendRequest({ type: "list" });
|
|
183
|
+
if (response.type !== "ok") {
|
|
184
|
+
console.error("Failed to get status");
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
const loops = response.data;
|
|
188
|
+
if (opts.json) {
|
|
189
|
+
console.log(JSON.stringify(loops, null, 2));
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
for (const loop of loops) {
|
|
193
|
+
const { describeLoop, statusLabel } = await import("./board/format.js");
|
|
194
|
+
console.log(`${loop.id} ${statusLabel(loop.status)} ${describeLoop(loop)}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
program
|
|
199
|
+
.command("export")
|
|
200
|
+
.description("Export all configs to a JSON file (or stdout)")
|
|
201
|
+
.argument("[file]", "Output file (defaults to stdout)")
|
|
202
|
+
.action(async (file) => {
|
|
203
|
+
const { sendRequest } = await import("./client/ipc.js");
|
|
204
|
+
const [loopsRes, tasksRes, projectsRes] = await Promise.all([
|
|
205
|
+
sendRequest({ type: "list" }),
|
|
206
|
+
sendRequest({ type: "task-list" }),
|
|
207
|
+
sendRequest({ type: "project-list" }),
|
|
208
|
+
]);
|
|
209
|
+
const exportData = {
|
|
210
|
+
version: 2,
|
|
211
|
+
exportedAt: new Date().toISOString(),
|
|
212
|
+
loops: loopsRes.type === "ok" ? loopsRes.data : [],
|
|
213
|
+
tasks: tasksRes.type === "ok" ? tasksRes.data : [],
|
|
214
|
+
projects: projectsRes.type === "ok" ? projectsRes.data : [],
|
|
215
|
+
};
|
|
216
|
+
const json = JSON.stringify(exportData, null, 2);
|
|
217
|
+
if (file) {
|
|
218
|
+
const { writeFile } = await import("node:fs/promises");
|
|
219
|
+
await writeFile(file, json, "utf-8");
|
|
220
|
+
console.log(`Exported to ${file}`);
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
console.log(json);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
program
|
|
227
|
+
.command("import")
|
|
228
|
+
.description("Restore a previously exported state file (inverse of loop-task export)")
|
|
229
|
+
.argument("<file>", "Input file")
|
|
230
|
+
.action(async (file) => {
|
|
231
|
+
const { readFile } = await import("node:fs/promises");
|
|
232
|
+
let content;
|
|
233
|
+
try {
|
|
234
|
+
content = await readFile(file, "utf-8");
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
const e = err;
|
|
238
|
+
console.error(`Failed to read file: ${e.message}`);
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
let parsed;
|
|
242
|
+
try {
|
|
243
|
+
parsed = JSON.parse(content);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
console.error("Invalid JSON in import file");
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
const { validateExportFile } = await import("./cli/import-validator.js");
|
|
250
|
+
const result = validateExportFile(parsed);
|
|
251
|
+
if (!result.valid) {
|
|
252
|
+
for (const error of result.errors) {
|
|
253
|
+
console.error(error.message);
|
|
254
|
+
}
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
const { atomicImportWrite } = await import("./cli/import-writer.js");
|
|
258
|
+
const writeResult = atomicImportWrite(result.data.loops, result.data.tasks, result.data.projects);
|
|
259
|
+
if (!writeResult.success) {
|
|
260
|
+
console.error(`Failed to write store: ${writeResult.error}`);
|
|
261
|
+
if (writeResult.writtenStores.length > 0) {
|
|
262
|
+
console.error("Rolled back previously written stores.");
|
|
263
|
+
}
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
console.log(`Imported ${result.data.loops.length} loops, ${result.data.tasks.length} tasks, ${result.data.projects.length} projects`);
|
|
267
|
+
console.log("Daemon will hot-reload automatically.");
|
|
268
|
+
});
|
|
269
|
+
program
|
|
270
|
+
.command("api")
|
|
271
|
+
.description("Show HTTP API server info (base URL, Swagger UI, OpenAPI spec)")
|
|
272
|
+
.action(() => {
|
|
273
|
+
const port = process.env.LOOP_CLI_HTTP_PORT ?? String(HTTP_API_PORT);
|
|
274
|
+
const baseUrl = `http://${HTTP_API_HOST}:${port}`;
|
|
275
|
+
console.log(`HTTP API Server`);
|
|
276
|
+
console.log(` Base URL: ${baseUrl}`);
|
|
277
|
+
console.log(` Swagger UI: ${baseUrl}/api/docs`);
|
|
278
|
+
console.log(` OpenAPI: ${baseUrl}/api/openapi.json`);
|
|
279
|
+
console.log(` Events: ${baseUrl}/api/events (SSE)`);
|
|
280
|
+
});
|
|
187
281
|
await program.parseAsync(process.argv);
|
package/dist/config/constants.js
CHANGED
|
@@ -13,6 +13,12 @@ export const LOG_TAIL_DEFAULT = 50;
|
|
|
13
13
|
export const BOARD_BREAKPOINT_WIDTH = 80;
|
|
14
14
|
export const HEADER_COMPACT_WIDTH = 60;
|
|
15
15
|
export const SEARCH_SELECT_HEIGHT = 6;
|
|
16
|
+
// ── Clipboard / bracketed paste ─────────────────────────────────────
|
|
17
|
+
// DECSET 2004: terminal wraps pasted text in ESC[200~ ... ESC[201~ so the
|
|
18
|
+
// app can insert it wholesale instead of processing each char as a keypress.
|
|
19
|
+
export const BRACKETED_PASTE_ENABLE = "\x1b[?2004h";
|
|
20
|
+
export const BRACKETED_PASTE_DISABLE = "\x1b[?2004l";
|
|
21
|
+
export const PASTE_MAX_CHARS = 4096;
|
|
16
22
|
export const HOVER_BG = "#1e3a5f";
|
|
17
23
|
export const PROJECT_COLORS = {
|
|
18
24
|
white: "#ffffff",
|
|
@@ -29,3 +35,49 @@ export const ENTITY_COLORS = {
|
|
|
29
35
|
task: "#a78bfa", // purple
|
|
30
36
|
project: "#34d399", // green
|
|
31
37
|
};
|
|
38
|
+
// ── Wizard step counts ──────────────────────────────────────────────
|
|
39
|
+
export const WIZARD_LOOP_REQUIRED_STEPS = 3;
|
|
40
|
+
export const WIZARD_LOOP_TOTAL_STEPS = 7;
|
|
41
|
+
export const WIZARD_TASK_REQUIRED_STEPS = 2;
|
|
42
|
+
export const WIZARD_TASK_TOTAL_STEPS = 4;
|
|
43
|
+
export const WIZARD_PROJECT_REQUIRED_STEPS = 1;
|
|
44
|
+
export const WIZARD_PROJECT_TOTAL_STEPS = 2;
|
|
45
|
+
// ── Command tiers ───────────────────────────────────────────────────
|
|
46
|
+
export const COMMAND_TIER_ACTION = "action";
|
|
47
|
+
export const COMMAND_TIER_CONFIRM = "confirm";
|
|
48
|
+
export const COMMAND_TIER_GLOBAL = "global";
|
|
49
|
+
// ── Command categories ─────────────────────────────────────────────
|
|
50
|
+
export const COMMAND_CATEGORY_GLOBAL = "global";
|
|
51
|
+
export const COMMAND_CATEGORY_FILTERS = "filters";
|
|
52
|
+
export const COMMAND_CATEGORY_LOOP = "loop";
|
|
53
|
+
export const COMMAND_CATEGORY_TASK = "task";
|
|
54
|
+
export const COMMAND_CATEGORY_PROJECT = "project";
|
|
55
|
+
// ── Confirm keywords ────────────────────────────────────────────────
|
|
56
|
+
export const CONFIRM_YES = "yes";
|
|
57
|
+
export const CONFIRM_CANCEL = "cancel";
|
|
58
|
+
// ── Panel focus types ───────────────────────────────────────────────
|
|
59
|
+
export const PANEL_LEFT = "left";
|
|
60
|
+
export const PANEL_RIGHT = "right";
|
|
61
|
+
// ── Command builder templates ──────────────────────────────────────
|
|
62
|
+
export const COMMAND_TEMPLATES = [
|
|
63
|
+
{ label: "npm run", command: "npm", args: "run" },
|
|
64
|
+
{ label: "npm test", command: "npm", args: "test" },
|
|
65
|
+
{ label: "pnpm build", command: "pnpm", args: "build" },
|
|
66
|
+
{ label: "pnpm test", command: "pnpm", args: "test" },
|
|
67
|
+
{ label: "yarn build", command: "yarn", args: "build" },
|
|
68
|
+
{ label: "dotnet build", command: "dotnet", args: "build" },
|
|
69
|
+
{ label: "dotnet test", command: "dotnet", args: "test" },
|
|
70
|
+
{ label: "docker compose up", command: "docker", args: "compose up" },
|
|
71
|
+
{ label: "make", command: "make", args: "" },
|
|
72
|
+
{ label: "shell script", command: "bash", args: "./script.sh" },
|
|
73
|
+
];
|
|
74
|
+
// ── Command input constants ─────────────────────────────────────────
|
|
75
|
+
export const COMMAND_INPUT_HEIGHT = 6;
|
|
76
|
+
export const COMMAND_INPUT_DROPDOWN_MAX_VISIBLE = 6;
|
|
77
|
+
// ── HTTP API server ─────────────────────────────────────────────────
|
|
78
|
+
export const HTTP_API_PORT = 8845;
|
|
79
|
+
export const HTTP_API_HOST = "127.0.0.1";
|
|
80
|
+
// ── Ctrl shortcut hints (for display) ───────────────────────────────
|
|
81
|
+
export const EXPORT_MAX_PREVIEW_LINES = 200;
|
|
82
|
+
export const CTRL_SHORTCUT_EDIT = "Ctrl+E";
|
|
83
|
+
export const CTRL_SHORTCUT_DELETE = "Ctrl+D";
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { loopsJson, tasksJson, projectsJson } from "../config/paths.js";
|
|
5
|
+
const DEBOUNCE_MS = 300;
|
|
6
|
+
const MTIME_POLL_MS = 2000;
|
|
7
|
+
export class FileWatcher {
|
|
8
|
+
watchers = new Map();
|
|
9
|
+
mtimeTimers = new Map();
|
|
10
|
+
loopManager = null;
|
|
11
|
+
taskManager = null;
|
|
12
|
+
projectManager = null;
|
|
13
|
+
setManagers(loopManager, taskManager, projectManager) {
|
|
14
|
+
this.loopManager = loopManager;
|
|
15
|
+
this.taskManager = taskManager;
|
|
16
|
+
this.projectManager = projectManager;
|
|
17
|
+
}
|
|
18
|
+
start() {
|
|
19
|
+
this.watch(loopsJson(), (content) => this.handleLoopsChange(content));
|
|
20
|
+
this.watch(tasksJson(), (content) => this.handleTasksChange(content));
|
|
21
|
+
this.watch(projectsJson(), (content) => this.handleProjectsChange(content));
|
|
22
|
+
}
|
|
23
|
+
stop() {
|
|
24
|
+
for (const [, entry] of this.watchers) {
|
|
25
|
+
if (entry.debounceTimer)
|
|
26
|
+
clearTimeout(entry.debounceTimer);
|
|
27
|
+
}
|
|
28
|
+
for (const [, timer] of this.mtimeTimers) {
|
|
29
|
+
clearInterval(timer);
|
|
30
|
+
}
|
|
31
|
+
this.watchers.clear();
|
|
32
|
+
this.mtimeTimers.clear();
|
|
33
|
+
}
|
|
34
|
+
registerSelfWrite(filePath, content) {
|
|
35
|
+
const hash = this.hash(content);
|
|
36
|
+
const entry = this.watchers.get(filePath);
|
|
37
|
+
if (entry) {
|
|
38
|
+
entry.lastHash = hash;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
watch(filePath, onChange) {
|
|
42
|
+
const dir = path.dirname(filePath);
|
|
43
|
+
const basename = path.basename(filePath);
|
|
44
|
+
this.watchers.set(filePath, {
|
|
45
|
+
lastHash: "",
|
|
46
|
+
debounceTimer: null,
|
|
47
|
+
lastMtime: 0,
|
|
48
|
+
});
|
|
49
|
+
try {
|
|
50
|
+
const initialContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : "";
|
|
51
|
+
this.watchers.get(filePath).lastHash = this.hash(initialContent);
|
|
52
|
+
this.watchers.get(filePath).lastMtime = fs.statSync(filePath).mtimeMs;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// file doesn't exist yet
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
fs.watch(dir, (eventType, filename) => {
|
|
59
|
+
if (filename !== basename)
|
|
60
|
+
return;
|
|
61
|
+
this.handleFileEvent(filePath, onChange);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// fs.watch not available, mtime polling will handle it
|
|
66
|
+
}
|
|
67
|
+
const mtimeTimer = setInterval(() => {
|
|
68
|
+
this.checkMtime(filePath, onChange);
|
|
69
|
+
}, MTIME_POLL_MS);
|
|
70
|
+
this.mtimeTimers.set(filePath, mtimeTimer);
|
|
71
|
+
}
|
|
72
|
+
handleFileEvent(filePath, onChange) {
|
|
73
|
+
const entry = this.watchers.get(filePath);
|
|
74
|
+
if (!entry)
|
|
75
|
+
return;
|
|
76
|
+
if (entry.debounceTimer) {
|
|
77
|
+
clearTimeout(entry.debounceTimer);
|
|
78
|
+
}
|
|
79
|
+
entry.debounceTimer = setTimeout(() => {
|
|
80
|
+
this.processChange(filePath, onChange);
|
|
81
|
+
entry.debounceTimer = null;
|
|
82
|
+
}, DEBOUNCE_MS);
|
|
83
|
+
}
|
|
84
|
+
checkMtime(filePath, onChange) {
|
|
85
|
+
const entry = this.watchers.get(filePath);
|
|
86
|
+
if (!entry)
|
|
87
|
+
return;
|
|
88
|
+
try {
|
|
89
|
+
const stat = fs.statSync(filePath);
|
|
90
|
+
if (stat.mtimeMs > entry.lastMtime) {
|
|
91
|
+
entry.lastMtime = stat.mtimeMs;
|
|
92
|
+
this.handleFileEvent(filePath, onChange);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// file might not exist
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
processChange(filePath, onChange) {
|
|
100
|
+
const entry = this.watchers.get(filePath);
|
|
101
|
+
if (!entry)
|
|
102
|
+
return;
|
|
103
|
+
let content;
|
|
104
|
+
try {
|
|
105
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const fileHash = this.hash(content);
|
|
111
|
+
if (fileHash === entry.lastHash) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
entry.lastHash = fileHash;
|
|
115
|
+
try {
|
|
116
|
+
const stat = fs.statSync(filePath);
|
|
117
|
+
entry.lastMtime = stat.mtimeMs;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// ignore
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
onChange(content);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
console.error(`[file-watcher] Error processing change to ${filePath}:`, error);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
hash(content) {
|
|
130
|
+
return createHash("sha1").update(content).digest("hex").slice(0, 16);
|
|
131
|
+
}
|
|
132
|
+
handleLoopsChange(content) {
|
|
133
|
+
if (!this.loopManager)
|
|
134
|
+
return;
|
|
135
|
+
let newLoops;
|
|
136
|
+
try {
|
|
137
|
+
newLoops = JSON.parse(content);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
console.error("[file-watcher] Malformed loops.json, keeping old state");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
this.loopManager.reconcile(newLoops);
|
|
144
|
+
}
|
|
145
|
+
handleTasksChange(content) {
|
|
146
|
+
if (!this.taskManager)
|
|
147
|
+
return;
|
|
148
|
+
let newTasks;
|
|
149
|
+
try {
|
|
150
|
+
newTasks = JSON.parse(content);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
console.error("[file-watcher] Malformed tasks.json, keeping old state");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
this.taskManager.reload(newTasks);
|
|
157
|
+
}
|
|
158
|
+
handleProjectsChange(content) {
|
|
159
|
+
if (!this.projectManager)
|
|
160
|
+
return;
|
|
161
|
+
let newProjects;
|
|
162
|
+
try {
|
|
163
|
+
newProjects = JSON.parse(content);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
console.error("[file-watcher] Malformed projects.json, keeping old state");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
this.projectManager.reload(newProjects);
|
|
170
|
+
}
|
|
171
|
+
}
|