cwresdev 0.1.9 → 0.2.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/bin/cwgit.js ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const path = require("node:path");
6
+ const { detectChanges, exportCsv, findWorkspaceRoot, initBase, resolveDocRoot } = require("../src/cwgit");
7
+
8
+ async function main() {
9
+ if (process.platform !== "win32") {
10
+ throw new Error("cwgit currently supports Windows only.");
11
+ }
12
+
13
+ const args = process.argv.slice(2);
14
+ const command = args[0];
15
+
16
+ if (!command || command === "--help" || command === "-h") {
17
+ process.stdout.write([
18
+ "cwgit <command>",
19
+ "",
20
+ "Commands:",
21
+ " init Snapshot the current project as the base version",
22
+ " changes Detect and export changed files as CSV",
23
+ "",
24
+ "Usage:",
25
+ " Run from inside a project folder (under the docRoot).",
26
+ " The tool locates the workspace root (package.json with cwrespro config)",
27
+ " and resolves docRoot from that config.",
28
+ "",
29
+ "Examples:",
30
+ " cd virtual_env/IC && cwgit init",
31
+ " cd virtual_env/IC && cwgit changes",
32
+ "",
33
+ ].join("\n"));
34
+ return;
35
+ }
36
+
37
+ const cwd = process.cwd();
38
+
39
+ // Find the workspace root by walking up
40
+ const workspaceRoot = findWorkspaceRoot(cwd);
41
+
42
+ if (!workspaceRoot) {
43
+ throw new Error(
44
+ "Could not find workspace root.\n" +
45
+ "Ensure a package.json with a \"cwrespro\" key or a cwrespro.config.json exists in an ancestor directory."
46
+ );
47
+ }
48
+
49
+ // Resolve docRoot from config
50
+ const docRoot = resolveDocRoot(workspaceRoot);
51
+
52
+ if (command === "init") {
53
+ const { fileCount, basePath } = await initBase(cwd, docRoot);
54
+ process.stdout.write(`[cwgit] Base snapshot created: ${fileCount} files tracked.\n`);
55
+ process.stdout.write(`[cwgit] Stored at: ${basePath}\n`);
56
+ process.stdout.write(`[cwgit] Tip: add .cwgit/ to .gitignore if using Git.\n`);
57
+ return;
58
+ }
59
+
60
+ if (command === "changes") {
61
+ const changes = await detectChanges(cwd, docRoot);
62
+
63
+ if (changes.length === 0) {
64
+ process.stdout.write("[cwgit] No changes detected.\n");
65
+ return;
66
+ }
67
+
68
+ const csvPath = await exportCsv(changes, cwd);
69
+
70
+ // Print summary
71
+ const deleted = changes.filter((c) => c.status === "D").length;
72
+ const modified = changes.filter((c) => c.status === "M").length;
73
+ const untracked = changes.filter((c) => c.status === "U").length;
74
+
75
+ process.stdout.write(`[cwgit] ${changes.length} change(s) detected:\n`);
76
+
77
+ if (modified > 0) {
78
+ process.stdout.write(` M (Modified): ${modified}\n`);
79
+ }
80
+
81
+ if (untracked > 0) {
82
+ process.stdout.write(` U (Untracked): ${untracked}\n`);
83
+ }
84
+
85
+ if (deleted > 0) {
86
+ process.stdout.write(` D (Deleted): ${deleted}\n`);
87
+ }
88
+
89
+ process.stdout.write(`[cwgit] CSV exported: ${csvPath}\n`);
90
+ return;
91
+ }
92
+
93
+ throw new Error(`Unknown command: "${command}". Use "cwgit --help" for usage.`);
94
+ }
95
+
96
+ main().catch((error) => {
97
+ process.stderr.write(`[cwgit] Error: ${error.message}\n`);
98
+ process.exit(1);
99
+ });
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "cwresdev",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
4
4
  "description": "",
5
5
  "license": "MIT",
6
6
  "private": false,
7
7
  "type": "commonjs",
8
8
  "main": "./src/index.js",
9
9
  "bin": {
10
- "cwrespro": "bin/cwrespro.js"
10
+ "cwrespro": "bin/cwrespro.js",
11
+ "cwgit": "bin/cwgit.js"
11
12
  },
12
13
  "files": [
13
14
  "bin",
package/src/cwgit.js ADDED
@@ -0,0 +1,362 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+
7
+ const CWGIT_DIR = ".cwgit";
8
+ const IGNORED_DIRS = new Set([".cwgit", "node_modules", ".git"]);
9
+ const HASH_STREAM_THRESHOLD = 5 * 1024 * 1024; // 5 MB
10
+
11
+ // CSV-injection-dangerous prefixes (OWASP)
12
+ const CSV_DANGEROUS_CHARS = new Set(["=", "+", "-", "@", "\t", "\r"]);
13
+
14
+ /**
15
+ * Walk up from startDir to find the nearest directory containing a package.json
16
+ * with a "cwrespro" key or a cwrespro.config.json file.
17
+ */
18
+ function findWorkspaceRoot(startDir) {
19
+ let current = path.resolve(startDir);
20
+ const { root } = path.parse(current);
21
+
22
+ while (true) {
23
+ const pkgPath = path.join(current, "package.json");
24
+
25
+ if (fs.existsSync(pkgPath)) {
26
+ try {
27
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
28
+
29
+ if (pkg.cwrespro && typeof pkg.cwrespro === "object") {
30
+ return current;
31
+ }
32
+ } catch (_) {
33
+ // malformed package.json — skip
34
+ }
35
+ }
36
+
37
+ const configPath = path.join(current, "cwrespro.config.json");
38
+
39
+ if (fs.existsSync(configPath)) {
40
+ return current;
41
+ }
42
+
43
+ if (current === root) {
44
+ return null;
45
+ }
46
+
47
+ current = path.dirname(current);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Resolve the docRoot from the workspace root's config.
53
+ * Returns absolute docRoot path.
54
+ */
55
+ function resolveDocRoot(workspaceRoot) {
56
+ const configPath = path.join(workspaceRoot, "cwrespro.config.json");
57
+
58
+ if (fs.existsSync(configPath)) {
59
+ try {
60
+ const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
61
+
62
+ if (cfg.docRoot) {
63
+ return path.resolve(workspaceRoot, cfg.docRoot);
64
+ }
65
+ } catch (_) {
66
+ // fall through to package.json
67
+ }
68
+ }
69
+
70
+ const pkgPath = path.join(workspaceRoot, "package.json");
71
+
72
+ if (fs.existsSync(pkgPath)) {
73
+ try {
74
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
75
+
76
+ if (pkg.cwrespro && pkg.cwrespro.docRoot) {
77
+ return path.resolve(workspaceRoot, pkg.cwrespro.docRoot);
78
+ }
79
+ } catch (_) {
80
+ // fall through to default
81
+ }
82
+ }
83
+
84
+ return path.resolve(workspaceRoot, "virtual_env");
85
+ }
86
+
87
+ /**
88
+ * Compute SHA-256 hash of a file.
89
+ * Uses one-shot API for small files, streaming for large files.
90
+ */
91
+ async function hashFile(filePath) {
92
+ const stat = await fs.promises.stat(filePath);
93
+
94
+ if (stat.size <= HASH_STREAM_THRESHOLD) {
95
+ const data = await fs.promises.readFile(filePath);
96
+ return crypto.createHash("sha256").update(data).digest("hex");
97
+ }
98
+
99
+ // Stream for large files (videos, images, etc.)
100
+ return new Promise((resolve, reject) => {
101
+ const hash = crypto.createHash("sha256");
102
+ const stream = fs.createReadStream(filePath);
103
+
104
+ stream.on("data", (chunk) => hash.update(chunk));
105
+ stream.on("end", () => resolve(hash.digest("hex")));
106
+ stream.on("error", reject);
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Recursively collect all files in a directory.
112
+ * Skips symlinks, IGNORED_DIRS, and validates paths stay within boundary.
113
+ */
114
+ async function collectFiles(dir, boundary) {
115
+ const results = [];
116
+ const resolvedBoundary = path.resolve(boundary);
117
+
118
+ async function walk(current) {
119
+ let entries;
120
+
121
+ try {
122
+ entries = await fs.promises.readdir(current, { withFileTypes: true });
123
+ } catch (err) {
124
+ if (err.code === "EACCES" || err.code === "EPERM") {
125
+ return; // skip inaccessible directories
126
+ }
127
+
128
+ throw err;
129
+ }
130
+
131
+ for (const entry of entries) {
132
+ if (IGNORED_DIRS.has(entry.name)) {
133
+ continue;
134
+ }
135
+
136
+ const fullPath = path.join(current, entry.name);
137
+ const resolved = path.resolve(fullPath);
138
+
139
+ // Path traversal guard
140
+ if (!resolved.startsWith(resolvedBoundary + path.sep) && resolved !== resolvedBoundary) {
141
+ continue;
142
+ }
143
+
144
+ // Use lstat to detect symlinks (don't follow them)
145
+ let stat;
146
+
147
+ try {
148
+ stat = await fs.promises.lstat(fullPath);
149
+ } catch (err) {
150
+ if (err.code === "EBUSY" || err.code === "EPERM" || err.code === "ENOENT") {
151
+ continue; // skip locked/deleted files
152
+ }
153
+
154
+ throw err;
155
+ }
156
+
157
+ // Skip symlinks for security
158
+ if (stat.isSymbolicLink()) {
159
+ continue;
160
+ }
161
+
162
+ if (stat.isDirectory()) {
163
+ await walk(fullPath);
164
+ } else if (stat.isFile()) {
165
+ results.push(fullPath);
166
+ }
167
+ }
168
+ }
169
+
170
+ await walk(dir);
171
+ return results;
172
+ }
173
+
174
+ /**
175
+ * Initialize (or re-initialize) the base snapshot for a project.
176
+ */
177
+ async function initBase(projectDir, docRoot) {
178
+ const resolvedProject = path.resolve(projectDir);
179
+ const resolvedDocRoot = path.resolve(docRoot);
180
+
181
+ // Validate projectDir is inside or equal to docRoot
182
+ if (resolvedProject !== resolvedDocRoot && !resolvedProject.startsWith(resolvedDocRoot + path.sep)) {
183
+ throw new Error(`Project directory must be inside docRoot.\n Project: ${resolvedProject}\n DocRoot: ${resolvedDocRoot}`);
184
+ }
185
+
186
+ const relPath = resolvedProject === resolvedDocRoot
187
+ ? "."
188
+ : path.relative(resolvedDocRoot, resolvedProject);
189
+
190
+ const cwgitDir = path.join(resolvedDocRoot, CWGIT_DIR, relPath);
191
+ const basePath = path.join(cwgitDir, "base.json");
192
+ const tmpPath = basePath + ".tmp";
193
+
194
+ // Collect and hash files
195
+ const filePaths = await collectFiles(resolvedProject, resolvedProject);
196
+ const fileCount = filePaths.length;
197
+
198
+ process.stderr.write(`[cwgit] Hashing ${fileCount} files...\n`);
199
+
200
+ const files = {};
201
+ let processed = 0;
202
+
203
+ for (const filePath of filePaths) {
204
+ const relFilePath = path.relative(resolvedProject, filePath).replace(/\\/g, "/");
205
+
206
+ try {
207
+ files[relFilePath] = await hashFile(filePath);
208
+ } catch (err) {
209
+ if (err.code === "EBUSY" || err.code === "EPERM" || err.code === "ENOENT") {
210
+ process.stderr.write(`[cwgit] Warning: skipped inaccessible file: ${relFilePath}\n`);
211
+ continue;
212
+ }
213
+
214
+ throw err;
215
+ }
216
+
217
+ processed += 1;
218
+
219
+ if (processed % 100 === 0) {
220
+ process.stderr.write(`[cwgit] ${processed}/${fileCount} files hashed\n`);
221
+ }
222
+ }
223
+
224
+ const baseData = {
225
+ version: 1,
226
+ createdAt: new Date().toISOString(),
227
+ project: relPath,
228
+ files,
229
+ };
230
+
231
+ // Atomic write: write to tmp then rename
232
+ await fs.promises.mkdir(cwgitDir, { recursive: true });
233
+ await fs.promises.writeFile(tmpPath, JSON.stringify(baseData, null, 2), "utf8");
234
+ await fs.promises.rename(tmpPath, basePath);
235
+
236
+ return { fileCount: Object.keys(files).length, basePath };
237
+ }
238
+
239
+ /**
240
+ * Detect changes between current state and base snapshot.
241
+ * Returns array of { file, status } where status is M, U, or D.
242
+ */
243
+ async function detectChanges(projectDir, docRoot) {
244
+ const resolvedProject = path.resolve(projectDir);
245
+ const resolvedDocRoot = path.resolve(docRoot);
246
+
247
+ if (resolvedProject !== resolvedDocRoot && !resolvedProject.startsWith(resolvedDocRoot + path.sep)) {
248
+ throw new Error(`Project directory must be inside docRoot.\n Project: ${resolvedProject}\n DocRoot: ${resolvedDocRoot}`);
249
+ }
250
+
251
+ const relPath = resolvedProject === resolvedDocRoot
252
+ ? "."
253
+ : path.relative(resolvedDocRoot, resolvedProject);
254
+
255
+ const basePath = path.join(resolvedDocRoot, CWGIT_DIR, relPath, "base.json");
256
+
257
+ if (!fs.existsSync(basePath)) {
258
+ throw new Error(`No base snapshot found. Run "cwgit init" first.\n Expected: ${basePath}`);
259
+ }
260
+
261
+ const baseData = JSON.parse(await fs.promises.readFile(basePath, "utf8"));
262
+ const baseFiles = baseData.files || {};
263
+
264
+ // Collect current files
265
+ const currentPaths = await collectFiles(resolvedProject, resolvedProject);
266
+ const currentFiles = {};
267
+
268
+ process.stderr.write(`[cwgit] Scanning ${currentPaths.length} files...\n`);
269
+
270
+ for (const filePath of currentPaths) {
271
+ const relFilePath = path.relative(resolvedProject, filePath).replace(/\\/g, "/");
272
+
273
+ try {
274
+ currentFiles[relFilePath] = await hashFile(filePath);
275
+ } catch (err) {
276
+ if (err.code === "EBUSY" || err.code === "EPERM" || err.code === "ENOENT") {
277
+ process.stderr.write(`[cwgit] Warning: skipped inaccessible file: ${relFilePath}\n`);
278
+ continue;
279
+ }
280
+
281
+ throw err;
282
+ }
283
+ }
284
+
285
+ const changes = [];
286
+
287
+ // Check for modified and deleted files
288
+ for (const [file, hash] of Object.entries(baseFiles)) {
289
+ if (!(file in currentFiles)) {
290
+ changes.push({ file, status: "D" });
291
+ } else if (currentFiles[file] !== hash) {
292
+ changes.push({ file, status: "M" });
293
+ }
294
+ }
295
+
296
+ // Check for new/untracked files
297
+ for (const file of Object.keys(currentFiles)) {
298
+ if (!(file in baseFiles)) {
299
+ changes.push({ file, status: "U" });
300
+ }
301
+ }
302
+
303
+ // Sort: D first, then M, then U; alphabetical within each group
304
+ changes.sort((a, b) => {
305
+ const order = { D: 0, M: 1, U: 2 };
306
+
307
+ if (order[a.status] !== order[b.status]) {
308
+ return order[a.status] - order[b.status];
309
+ }
310
+
311
+ return a.file.localeCompare(b.file);
312
+ });
313
+
314
+ return changes;
315
+ }
316
+
317
+ /**
318
+ * Sanitize a CSV cell value to prevent formula injection.
319
+ */
320
+ function sanitizeCsvCell(value) {
321
+ if (typeof value !== "string" || value.length === 0) {
322
+ return value;
323
+ }
324
+
325
+ if (CSV_DANGEROUS_CHARS.has(value[0])) {
326
+ return "'" + value;
327
+ }
328
+
329
+ return value;
330
+ }
331
+
332
+ /**
333
+ * Export changes to a CSV file.
334
+ */
335
+ async function exportCsv(changes, outputDir) {
336
+ const csvPath = path.join(outputDir, "cwgit-changes.csv");
337
+ const lines = ["file,status"];
338
+
339
+ for (const { file, status } of changes) {
340
+ const safeFile = sanitizeCsvCell(file);
341
+ // Escape double quotes in file path and wrap in quotes if contains comma
342
+ const escaped = safeFile.includes(",") || safeFile.includes('"')
343
+ ? `"${safeFile.replace(/"/g, '""')}"`
344
+ : safeFile;
345
+ lines.push(`${escaped},${status}`);
346
+ }
347
+
348
+ await fs.promises.writeFile(csvPath, lines.join("\n") + "\n", "utf8");
349
+ return csvPath;
350
+ }
351
+
352
+ module.exports = {
353
+ CWGIT_DIR,
354
+ collectFiles,
355
+ detectChanges,
356
+ exportCsv,
357
+ findWorkspaceRoot,
358
+ hashFile,
359
+ initBase,
360
+ resolveDocRoot,
361
+ sanitizeCsvCell,
362
+ };
package/src/server.js CHANGED
@@ -231,6 +231,11 @@ async function startDevServer(config) {
231
231
  });
232
232
  });
233
233
 
234
+ // Block HTTP access to .cwgit tracking data
235
+ app.use("/.cwgit", (req, res) => {
236
+ res.status(404).type("text/plain").send("Not Found");
237
+ });
238
+
234
239
  app.get(CLIENT_SCRIPT_ROUTE, (req, res) => {
235
240
  res.type("application/javascript");
236
241
  res.send(clientScript);
@@ -340,9 +345,15 @@ async function startDevServer(config) {
340
345
  return (filePath) => filePath.replace(/\\/g, "/") === normalized;
341
346
  });
342
347
 
348
+ // Always ignore .cwgit tracking folder (prevents reload loops)
349
+ const cwgitIgnore = (filePath) => {
350
+ const normalized = filePath.replace(/\\/g, "/");
351
+ return normalized.includes("/.cwgit/") || normalized.endsWith("/.cwgit");
352
+ };
353
+
343
354
  const watcher = chokidar.watch(config.docRoot, {
344
355
  ignoreInitial: true,
345
- ignored,
356
+ ignored: [cwgitIgnore, ...ignored],
346
357
  });
347
358
 
348
359
  watcher.on("all", (eventName, filePath) => {