4runr-cursor-setup 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +52 -44
- package/dist/commands/commands.js +223 -13
- package/dist/commands/doctor.js +113 -0
- package/dist/commands/manifest.js +43 -0
- package/dist/commands/memory.js +16 -32
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -1,51 +1,59 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
const
|
|
2
|
+
import { listGroups, addGroup, removeGroup, GROUPS } from "./commands/commands.js";
|
|
3
|
+
import { doctor } from "./commands/doctor.js";
|
|
4
|
+
import { createRequire } from "module";
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const TOOL_VERSION = require("../package.json").version;
|
|
6
7
|
function usage() {
|
|
7
|
-
console.log("4runr-cursor-setup");
|
|
8
8
|
console.log("Usage:");
|
|
9
|
-
console.log("
|
|
10
|
-
console.log("
|
|
11
|
-
console.log("
|
|
12
|
-
console.log("
|
|
9
|
+
console.log(" list");
|
|
10
|
+
console.log(" add <group> [--force] [--dry-run]");
|
|
11
|
+
console.log(" remove <group> [--dry-run]");
|
|
12
|
+
console.log(" doctor [--fix] [--strict] [--json] [--dry-run]");
|
|
13
13
|
console.log("");
|
|
14
14
|
console.log("Groups: " + Object.keys(GROUPS).join(", "));
|
|
15
15
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
process.exit(
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
const cmd = args[0];
|
|
18
|
+
const cwd = process.cwd();
|
|
19
|
+
const flags = new Set(args.slice(1));
|
|
20
|
+
const has = (f) => flags.has(f);
|
|
21
|
+
function parseGroup(raw) {
|
|
22
|
+
if (!raw)
|
|
23
|
+
throw new Error("Missing group name");
|
|
24
|
+
const g = raw;
|
|
25
|
+
if (!(g in GROUPS))
|
|
26
|
+
throw new Error(`Unknown group: ${raw}`);
|
|
27
|
+
return g;
|
|
28
|
+
}
|
|
29
|
+
if (cmd === "list") {
|
|
30
|
+
listGroups();
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
if (cmd === "add") {
|
|
34
|
+
const group = parseGroup(args[1]);
|
|
35
|
+
addGroup({
|
|
36
|
+
cwd,
|
|
37
|
+
group,
|
|
38
|
+
force: has("--force"),
|
|
39
|
+
dryRun: has("--dry-run"),
|
|
40
|
+
});
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
if (cmd === "remove") {
|
|
44
|
+
const group = parseGroup(args[1]);
|
|
45
|
+
removeGroup({ cwd, group, dryRun: has("--dry-run") });
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
if (cmd === "doctor") {
|
|
49
|
+
doctor(cwd, TOOL_VERSION, {
|
|
50
|
+
fix: has("--fix"),
|
|
51
|
+
json: has("--json"),
|
|
52
|
+
strict: has("--strict"),
|
|
53
|
+
dryRun: has("--dry-run"),
|
|
54
|
+
});
|
|
55
|
+
// Exit with the exit code set by doctor (or 0 if not set)
|
|
56
|
+
process.exit(process.exitCode || 0);
|
|
47
57
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
process.exit(1);
|
|
51
|
-
});
|
|
58
|
+
usage();
|
|
59
|
+
process.exit(1);
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import * as url from "url";
|
|
4
|
+
import { createRequire } from "module";
|
|
4
5
|
import { ensureMemory } from "./memory.js";
|
|
6
|
+
import { loadManifest, saveManifest, createEmptyManifest, upsertFile, removeFile, sha256, } from "./manifest.js";
|
|
5
7
|
export const GROUPS = {
|
|
6
8
|
core: ["4runr-start.md", "4runr-close.md"],
|
|
7
9
|
planning: ["4runr-task.md", "4runr-phase.md"],
|
|
8
10
|
governance: ["4runr-decision.md", "4runr-scope-change.md"],
|
|
9
11
|
debugging: ["4runr-repro.md", "4runr-verify.md"],
|
|
10
12
|
};
|
|
13
|
+
function assertSafeRelPath(rel) {
|
|
14
|
+
const norm = rel.replace(/\\/g, "/");
|
|
15
|
+
if (norm.includes(".."))
|
|
16
|
+
throw new Error("Refusing unsafe path traversal: " + rel);
|
|
17
|
+
if (path.isAbsolute(rel))
|
|
18
|
+
throw new Error("Refusing absolute path: " + rel);
|
|
19
|
+
return norm;
|
|
20
|
+
}
|
|
11
21
|
const MANAGED_MARKER = "<!-- managed-by: 4runr-cursor-setup -->";
|
|
12
22
|
function ensureDir(p) {
|
|
13
23
|
fs.mkdirSync(p, { recursive: true });
|
|
@@ -46,6 +56,30 @@ function getTemplatesRoot() {
|
|
|
46
56
|
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
47
57
|
return path.resolve(here, "..", "templates");
|
|
48
58
|
}
|
|
59
|
+
function getToolVersion() {
|
|
60
|
+
const require = createRequire(import.meta.url);
|
|
61
|
+
// From dist/commands/*.js, go up two levels to reach package.json at root
|
|
62
|
+
return require("../../package.json").version;
|
|
63
|
+
}
|
|
64
|
+
function ensureManifest(cwd) {
|
|
65
|
+
let m = loadManifest(cwd);
|
|
66
|
+
if (!m) {
|
|
67
|
+
m = createEmptyManifest(getToolVersion());
|
|
68
|
+
saveManifest(cwd, m);
|
|
69
|
+
}
|
|
70
|
+
return m;
|
|
71
|
+
}
|
|
72
|
+
function assertPathUnderCommands(cwd, filePath) {
|
|
73
|
+
const resolved = path.resolve(cwd, filePath);
|
|
74
|
+
const commandsDir = path.resolve(cwd, ".cursor", "commands");
|
|
75
|
+
if (!resolved.startsWith(commandsDir + path.sep) && resolved !== commandsDir) {
|
|
76
|
+
throw new Error(`Path escapes .cursor/commands: ${filePath}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function getRelativePath(cwd, absolutePath) {
|
|
80
|
+
const rel = path.relative(cwd, absolutePath);
|
|
81
|
+
return assertSafeRelPath(rel);
|
|
82
|
+
}
|
|
49
83
|
export function listGroups() {
|
|
50
84
|
console.log("Available groups:");
|
|
51
85
|
Object.keys(GROUPS).forEach((g) => {
|
|
@@ -53,42 +87,218 @@ export function listGroups() {
|
|
|
53
87
|
});
|
|
54
88
|
}
|
|
55
89
|
export function addGroup(opts) {
|
|
56
|
-
const { cwd, group, force = false } = opts;
|
|
90
|
+
const { cwd, group, force = false, dryRun = false } = opts;
|
|
57
91
|
const templatesRoot = getTemplatesRoot();
|
|
58
92
|
const srcBase = path.join(templatesRoot, "commands", group);
|
|
59
93
|
const outBase = path.join(cwd, ".cursor", "commands");
|
|
60
|
-
|
|
94
|
+
if (dryRun) {
|
|
95
|
+
console.log(`PLAN add ${group} (dry-run)`);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
ensureDir(outBase);
|
|
99
|
+
}
|
|
100
|
+
// Load or ensure manifest exists (for dry-run, we only read it)
|
|
101
|
+
let manifest = loadManifest(cwd) || createEmptyManifest(getToolVersion());
|
|
102
|
+
if (!dryRun && !loadManifest(cwd)) {
|
|
103
|
+
// For real runs, ensure manifest exists on disk
|
|
104
|
+
manifest = ensureManifest(cwd);
|
|
105
|
+
}
|
|
61
106
|
const files = GROUPS[group];
|
|
62
107
|
let copied = 0;
|
|
108
|
+
const writtenPaths = new Set();
|
|
109
|
+
const operations = [];
|
|
63
110
|
for (const f of files) {
|
|
64
111
|
const src = path.join(srcBase, f);
|
|
65
112
|
const dst = path.join(outBase, f);
|
|
66
|
-
|
|
113
|
+
// Safety: ensure path is under .cursor/commands
|
|
114
|
+
assertPathUnderCommands(cwd, dst);
|
|
115
|
+
const relPath = getRelativePath(cwd, dst);
|
|
116
|
+
// Check for duplicates
|
|
117
|
+
if (writtenPaths.has(relPath)) {
|
|
118
|
+
const errorMsg = `ERROR duplicate destination path: ${relPath}`;
|
|
119
|
+
if (dryRun) {
|
|
120
|
+
operations.push(errorMsg);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
throw new Error(`Duplicate destination path: ${relPath}`);
|
|
124
|
+
}
|
|
125
|
+
writtenPaths.add(relPath);
|
|
126
|
+
if (!fs.existsSync(src)) {
|
|
127
|
+
const errorMsg = `ERROR template missing: ${src}`;
|
|
128
|
+
if (dryRun) {
|
|
129
|
+
operations.push(errorMsg);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
67
132
|
throw new Error("Template missing: " + src);
|
|
68
|
-
|
|
69
|
-
|
|
133
|
+
}
|
|
134
|
+
// Read template content to compute hash (needed for planning)
|
|
135
|
+
const templateBody = fs.readFileSync(src, "utf8");
|
|
136
|
+
const templateContent = templateBody.includes(MANAGED_MARKER) ? templateBody : `${MANAGED_MARKER}\n\n${templateBody}`;
|
|
137
|
+
const templateHash = sha256(templateContent);
|
|
138
|
+
const exists = fs.existsSync(dst);
|
|
139
|
+
const hasMarker = exists ? fileContainsMarker(dst) : false;
|
|
140
|
+
const inManifest = manifest.files.some((mf) => mf.path === relPath);
|
|
141
|
+
// Tool-managed = has marker AND in manifest
|
|
142
|
+
const isToolManaged = exists && hasMarker && inManifest;
|
|
143
|
+
if (!exists) {
|
|
144
|
+
// File doesn't exist - will create
|
|
145
|
+
if (dryRun) {
|
|
146
|
+
operations.push(`CREATE ${relPath}`);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
writeWithMarker(src, dst);
|
|
150
|
+
copied++;
|
|
151
|
+
const templateId = `commands/${group}/${f}`;
|
|
152
|
+
upsertFile(manifest, {
|
|
153
|
+
path: relPath,
|
|
154
|
+
templateId,
|
|
155
|
+
templateVersion: 1,
|
|
156
|
+
contentSha256: templateHash,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else if (isToolManaged && force) {
|
|
161
|
+
// File exists, is tool-managed (marker + manifest), and --force is set - will overwrite
|
|
162
|
+
if (dryRun) {
|
|
163
|
+
operations.push(`OVERWRITE ${relPath} (tool-managed, --force)`);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
writeWithMarker(src, dst);
|
|
167
|
+
copied++;
|
|
168
|
+
const templateId = `commands/${group}/${f}`;
|
|
169
|
+
upsertFile(manifest, {
|
|
170
|
+
path: relPath,
|
|
171
|
+
templateId,
|
|
172
|
+
templateVersion: 1,
|
|
173
|
+
contentSha256: templateHash,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
else if (isToolManaged && !force) {
|
|
178
|
+
// File exists, is tool-managed, but no --force - will skip
|
|
179
|
+
if (dryRun) {
|
|
180
|
+
operations.push(`SKIP ${relPath} (exists, tool-managed)`);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
ensureMarkerOnFile(dst);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
// File exists but NOT tool-managed (user-modified, orphan marker, or collision)
|
|
188
|
+
// Always skip, even with --force
|
|
189
|
+
if (dryRun) {
|
|
190
|
+
operations.push(`SKIP ${relPath} (exists, un-managed)`);
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
ensureMarkerOnFile(dst);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (dryRun) {
|
|
198
|
+
for (const op of operations) {
|
|
199
|
+
console.log(op);
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// Update manifest groups (add if not present)
|
|
204
|
+
if (!manifest.groups.includes(group)) {
|
|
205
|
+
manifest.groups.push(group);
|
|
70
206
|
}
|
|
207
|
+
manifest.updatedAt = new Date().toISOString();
|
|
208
|
+
saveManifest(cwd, manifest);
|
|
71
209
|
console.log('Installed group "' + group + '" into .cursor/commands (' + copied + " file(s) written).");
|
|
72
210
|
if (group === "core")
|
|
73
211
|
ensureMemory(cwd);
|
|
74
212
|
}
|
|
75
213
|
export function removeGroup(opts) {
|
|
76
|
-
const { cwd, group } = opts;
|
|
214
|
+
const { cwd, group, dryRun = false } = opts;
|
|
215
|
+
// Load manifest (doctor guarantees it exists, but handle gracefully)
|
|
216
|
+
const manifest = loadManifest(cwd);
|
|
217
|
+
if (!manifest) {
|
|
218
|
+
if (dryRun) {
|
|
219
|
+
console.log(`PLAN remove ${group} (dry-run)`);
|
|
220
|
+
console.log("SKIP manifest missing");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// If manifest doesn't exist, skip removal (nothing to remove)
|
|
224
|
+
console.log("SKIP manifest missing");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (dryRun) {
|
|
228
|
+
console.log(`PLAN remove ${group} (dry-run)`);
|
|
229
|
+
}
|
|
77
230
|
const outBase = path.join(cwd, ".cursor", "commands");
|
|
78
|
-
const files = GROUPS[group];
|
|
79
231
|
let removed = 0;
|
|
80
232
|
let skipped = 0;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
233
|
+
const operations = [];
|
|
234
|
+
// Find files in manifest that belong to this group
|
|
235
|
+
// templateId pattern: "commands/{group}/{filename}"
|
|
236
|
+
const groupPrefix = `commands/${group}/`;
|
|
237
|
+
const manifestFiles = manifest.files.filter((f) => f.templateId.startsWith(groupPrefix));
|
|
238
|
+
for (const manifestFile of manifestFiles) {
|
|
239
|
+
const dst = path.join(cwd, manifestFile.path);
|
|
240
|
+
// Safety: ensure path is under .cursor/commands
|
|
241
|
+
assertPathUnderCommands(cwd, dst);
|
|
242
|
+
if (!fs.existsSync(dst)) {
|
|
243
|
+
// File missing on disk
|
|
244
|
+
if (dryRun) {
|
|
245
|
+
operations.push(`IGNORE ${manifestFile.path} (missing on disk)`);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
// File missing on disk, remove from manifest
|
|
249
|
+
removeFile(manifest, manifestFile.path);
|
|
250
|
+
}
|
|
84
251
|
continue;
|
|
85
|
-
|
|
252
|
+
}
|
|
253
|
+
// Only delete files that have marker AND are in manifest
|
|
254
|
+
// Never delete just because it has a marker
|
|
86
255
|
if (!fileContainsMarker(dst)) {
|
|
87
256
|
skipped++;
|
|
257
|
+
if (dryRun) {
|
|
258
|
+
operations.push(`SKIP ${manifestFile.path} (not managed)`);
|
|
259
|
+
}
|
|
88
260
|
continue;
|
|
89
261
|
}
|
|
90
|
-
|
|
91
|
-
|
|
262
|
+
// File has marker and is in manifest - safe to delete
|
|
263
|
+
if (dryRun) {
|
|
264
|
+
operations.push(`DELETE ${manifestFile.path}`);
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
fs.unlinkSync(dst);
|
|
268
|
+
removeFile(manifest, manifestFile.path);
|
|
269
|
+
removed++;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// Check for orphan marker files (files with marker but not in manifest for this group)
|
|
273
|
+
if (fs.existsSync(outBase)) {
|
|
274
|
+
const manifestPaths = new Set(manifest.files.map((f) => f.path));
|
|
275
|
+
const entries = fs.readdirSync(outBase, { withFileTypes: true });
|
|
276
|
+
for (const entry of entries) {
|
|
277
|
+
if (entry.isFile()) {
|
|
278
|
+
const filePath = path.join(outBase, entry.name);
|
|
279
|
+
const relPath = path.relative(cwd, filePath).replace(/\\/g, "/");
|
|
280
|
+
// If file has marker but is not in manifest, it's an orphan
|
|
281
|
+
if (fileContainsMarker(filePath) && !manifestPaths.has(relPath)) {
|
|
282
|
+
if (dryRun) {
|
|
283
|
+
operations.push(`IGNORE ${relPath} (orphan marker, not in manifest)`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (dryRun) {
|
|
290
|
+
for (const op of operations) {
|
|
291
|
+
console.log(op);
|
|
292
|
+
}
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
// Remove group from manifest.groups if no files remain for this group
|
|
296
|
+
// Re-check after removals
|
|
297
|
+
const remainingGroupFiles = manifest.files.filter((f) => f.templateId.startsWith(groupPrefix));
|
|
298
|
+
if (remainingGroupFiles.length === 0 && manifest.groups.includes(group)) {
|
|
299
|
+
manifest.groups = manifest.groups.filter((g) => g !== group);
|
|
92
300
|
}
|
|
301
|
+
manifest.updatedAt = new Date().toISOString();
|
|
302
|
+
saveManifest(cwd, manifest);
|
|
93
303
|
console.log('Removed group "' + group + '" from .cursor/commands (' + removed + " file(s) deleted, " + skipped + " skipped).");
|
|
94
304
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { ensureMemory } from "./memory.js";
|
|
4
|
+
import { loadManifest, saveManifest, createEmptyManifest, MANIFEST_REL, sha256 } from "./manifest.js";
|
|
5
|
+
const REQUIRED = [
|
|
6
|
+
"docs/status.md",
|
|
7
|
+
"docs/todo.md",
|
|
8
|
+
"docs/decisions.md",
|
|
9
|
+
".cursor/rules/project.md",
|
|
10
|
+
];
|
|
11
|
+
const MANAGED_MARKER = "<!-- managed-by: 4runr-cursor-setup -->";
|
|
12
|
+
const COMMANDS_DIR = ".cursor/commands";
|
|
13
|
+
function fileContainsMarker(p) {
|
|
14
|
+
if (!fs.existsSync(p))
|
|
15
|
+
return false;
|
|
16
|
+
const s = fs.readFileSync(p, "utf8");
|
|
17
|
+
return s.includes(MANAGED_MARKER);
|
|
18
|
+
}
|
|
19
|
+
export function doctor(cwd, toolVersion, opts) {
|
|
20
|
+
// Run fixes first if requested
|
|
21
|
+
if (opts.fix) {
|
|
22
|
+
ensureMemory(cwd, { dryRun: !!opts.dryRun });
|
|
23
|
+
// Always ensure manifest exists when --fix is used
|
|
24
|
+
if (!loadManifest(cwd)) {
|
|
25
|
+
const nm = createEmptyManifest(toolVersion);
|
|
26
|
+
saveManifest(cwd, nm, !!opts.dryRun);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// Collect issues after fixes have run
|
|
30
|
+
const issues = [];
|
|
31
|
+
for (const rel of REQUIRED) {
|
|
32
|
+
if (!fs.existsSync(path.join(cwd, rel))) {
|
|
33
|
+
issues.push({ level: "error", code: "MISSING_FILE", detail: rel });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const m = loadManifest(cwd);
|
|
37
|
+
if (!m) {
|
|
38
|
+
issues.push({ level: "warn", code: "MISSING_MANIFEST", detail: MANIFEST_REL });
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
// Check manifest entries for missing files
|
|
42
|
+
for (const f of m.files) {
|
|
43
|
+
const filePath = path.join(cwd, f.path);
|
|
44
|
+
if (!fs.existsSync(filePath)) {
|
|
45
|
+
issues.push({
|
|
46
|
+
level: "warn",
|
|
47
|
+
code: "MANIFEST_ENTRY_MISSING_ON_DISK",
|
|
48
|
+
detail: f.path,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// Check for collisions: file exists but doesn't have marker or has wrong content
|
|
53
|
+
if (!fileContainsMarker(filePath)) {
|
|
54
|
+
issues.push({
|
|
55
|
+
level: "error",
|
|
56
|
+
code: "COLLISION_UNMANAGED_FILE",
|
|
57
|
+
detail: f.path,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
// Verify content hash matches (optional strict check)
|
|
62
|
+
const currentContent = fs.readFileSync(filePath, "utf8");
|
|
63
|
+
const currentHash = sha256(currentContent);
|
|
64
|
+
if (currentHash !== f.contentSha256) {
|
|
65
|
+
issues.push({
|
|
66
|
+
level: "warn",
|
|
67
|
+
code: "MANIFEST_FILE_MODIFIED",
|
|
68
|
+
detail: f.path,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Check for orphan marker files in .cursor/commands (files with marker but not in manifest)
|
|
75
|
+
const commandsDir = path.join(cwd, COMMANDS_DIR);
|
|
76
|
+
if (fs.existsSync(commandsDir)) {
|
|
77
|
+
const manifestPaths = new Set(m.files.map((f) => f.path));
|
|
78
|
+
const entries = fs.readdirSync(commandsDir, { withFileTypes: true });
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
if (entry.isFile()) {
|
|
81
|
+
const filePath = path.join(commandsDir, entry.name);
|
|
82
|
+
const relPath = path.relative(cwd, filePath).replace(/\\/g, "/");
|
|
83
|
+
// If file has marker but is not in manifest, it's an orphan
|
|
84
|
+
if (fileContainsMarker(filePath) && !manifestPaths.has(relPath)) {
|
|
85
|
+
issues.push({
|
|
86
|
+
level: "warn",
|
|
87
|
+
code: "ORPHAN_MARKER_FILE",
|
|
88
|
+
detail: relPath,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const hasErrors = issues.some(i => i.level === "error");
|
|
96
|
+
if (opts.json) {
|
|
97
|
+
console.log(JSON.stringify({ ok: !hasErrors, issues }, null, 2));
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
if (issues.length === 0)
|
|
101
|
+
console.log("✅ doctor: OK");
|
|
102
|
+
else {
|
|
103
|
+
console.log("doctor report:");
|
|
104
|
+
for (const i of issues) {
|
|
105
|
+
console.log(`- ${i.level.toUpperCase()} ${i.code}: ${i.detail}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (opts.strict && issues.length > 0)
|
|
110
|
+
process.exitCode = 1;
|
|
111
|
+
if (hasErrors)
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import crypto from "crypto";
|
|
4
|
+
export const MANIFEST_REL = ".cursor/4runr.manifest.json";
|
|
5
|
+
export function sha256(s) {
|
|
6
|
+
return crypto.createHash("sha256").update(s, "utf8").digest("hex");
|
|
7
|
+
}
|
|
8
|
+
export function loadManifest(cwd) {
|
|
9
|
+
const fp = path.join(cwd, MANIFEST_REL);
|
|
10
|
+
if (!fs.existsSync(fp))
|
|
11
|
+
return null;
|
|
12
|
+
return JSON.parse(fs.readFileSync(fp, "utf8"));
|
|
13
|
+
}
|
|
14
|
+
export function saveManifest(cwd, m, dryRun = false) {
|
|
15
|
+
if (dryRun)
|
|
16
|
+
return;
|
|
17
|
+
const fp = path.join(cwd, MANIFEST_REL);
|
|
18
|
+
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
19
|
+
fs.writeFileSync(fp, JSON.stringify(m, null, 2) + "\n", "utf8");
|
|
20
|
+
}
|
|
21
|
+
export function createEmptyManifest(toolVersion) {
|
|
22
|
+
return {
|
|
23
|
+
schemaVersion: 1,
|
|
24
|
+
tool: "4runr-cursor-setup",
|
|
25
|
+
toolVersion,
|
|
26
|
+
updatedAt: new Date().toISOString(),
|
|
27
|
+
groups: [],
|
|
28
|
+
files: [],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function upsertFile(m, rec) {
|
|
32
|
+
const i = m.files.findIndex(f => f.path === rec.path);
|
|
33
|
+
if (i >= 0)
|
|
34
|
+
m.files[i] = rec;
|
|
35
|
+
else
|
|
36
|
+
m.files.push(rec);
|
|
37
|
+
}
|
|
38
|
+
export function removeFile(m, relPath) {
|
|
39
|
+
m.files = m.files.filter(f => f.path !== relPath);
|
|
40
|
+
}
|
|
41
|
+
export function hasFile(m, relPath) {
|
|
42
|
+
return m.files.some(f => f.path === relPath);
|
|
43
|
+
}
|
package/dist/commands/memory.js
CHANGED
|
@@ -1,35 +1,19 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
return false;
|
|
10
|
-
ensureDir(path.dirname(dst));
|
|
11
|
-
fs.copyFileSync(src, dst);
|
|
12
|
-
return true;
|
|
13
|
-
}
|
|
14
|
-
function getTemplatesRoot() {
|
|
15
|
-
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
16
|
-
return path.resolve(here, "..", "templates");
|
|
17
|
-
}
|
|
18
|
-
export function ensureMemory(cwd) {
|
|
19
|
-
const root = getTemplatesRoot();
|
|
20
|
-
const srcBase = path.join(root, "memory");
|
|
21
|
-
const mappings = [
|
|
22
|
-
{ src: path.join(srcBase, "docs", "status.md"), dst: path.join(cwd, "docs", "status.md") },
|
|
23
|
-
{ src: path.join(srcBase, "docs", "todo.md"), dst: path.join(cwd, "docs", "todo.md") },
|
|
24
|
-
{ src: path.join(srcBase, "docs", "decisions.md"), dst: path.join(cwd, "docs", "decisions.md") },
|
|
25
|
-
{ src: path.join(srcBase, "cursor", "project.md"), dst: path.join(cwd, ".cursor", "rules", "project.md") },
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
export function ensureMemory(cwd, opts) {
|
|
4
|
+
const files = [
|
|
5
|
+
"docs/status.md",
|
|
6
|
+
"docs/todo.md",
|
|
7
|
+
"docs/decisions.md",
|
|
8
|
+
".cursor/rules/project.md",
|
|
26
9
|
];
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
if (
|
|
32
|
-
|
|
10
|
+
for (const rel of files) {
|
|
11
|
+
const fp = path.join(cwd, rel);
|
|
12
|
+
if (fs.existsSync(fp))
|
|
13
|
+
continue;
|
|
14
|
+
if (!opts?.dryRun) {
|
|
15
|
+
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
16
|
+
fs.writeFileSync(fp, "", "utf8");
|
|
17
|
+
}
|
|
33
18
|
}
|
|
34
|
-
console.log(`Memory ensured (created ${created} file(s), left existing untouched).`);
|
|
35
19
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "4runr-cursor-setup",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "
|
|
7
|
+
"test": "node --test test/*.test.js",
|
|
8
8
|
"dev": "tsx src/cli.ts",
|
|
9
9
|
"build": "tsc",
|
|
10
10
|
"prepublishOnly": "npm run build",
|