generatesaas 0.7.1 → 0.8.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.
@@ -2,13 +2,168 @@
2
2
 
3
3
  /**
4
4
  * Classify project files for update.
5
- * Compares local file hashes against the manifest to determine which files
6
- * the user has customized and which are safe to auto-update.
5
+ * Compares local file hashes against the TEMPLATE hashes (not project hashes)
6
+ * to determine which files the user has customized and which are safe to auto-update.
7
+ * Also scans imports to detect cross-dependencies between categories.
7
8
  */
8
9
 
9
10
  const fs = require("node:fs");
10
11
  const path = require("node:path");
11
- const { findProjectRoot, hashFile, HASHES_FILE } = require("./_helpers.js");
12
+ const { findProjectRoot, hashFile, TEMPLATE_HASHES_FILE, HASHES_FILE, STAGING_DIR } = require("./_helpers.js");
13
+
14
+ // ── Import Scanning ──
15
+
16
+ /**
17
+ * Extract local import paths from file content.
18
+ * Only captures relative (./, ../), alias (~/, @/), and # imports — skips packages.
19
+ */
20
+ function extractImports(content, filePath) {
21
+ // For Vue SFCs, extract all script block contents (both <script> and <script setup>)
22
+ if (filePath.endsWith(".vue")) {
23
+ const blocks = [...content.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/g)];
24
+ content = blocks.map((m) => m[1]).join("\n");
25
+ }
26
+
27
+ const imports = new Set();
28
+
29
+ const patterns = [
30
+ /(?:import|export)\s[\s\S]*?from\s+['"]([^'"]+)['"]/g,
31
+ /(?:require|import)\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
32
+ ];
33
+
34
+ for (const pattern of patterns) {
35
+ let m;
36
+ while ((m = pattern.exec(content)) !== null) {
37
+ const p = m[1];
38
+ if (p.startsWith(".") || p.startsWith("~/") || p.startsWith("@/") || p.startsWith("#")) {
39
+ imports.add(p);
40
+ }
41
+ }
42
+ }
43
+
44
+ return [...imports];
45
+ }
46
+
47
+ /**
48
+ * Try to match an import specifier to a file path in the known set.
49
+ * Returns the matched path or null.
50
+ */
51
+ function matchImportToFile(importPath, fromFilePath, knownFileSet) {
52
+ let basePath;
53
+
54
+ if (importPath.startsWith("./") || importPath.startsWith("../")) {
55
+ basePath = path.normalize(path.join(path.dirname(fromFilePath), importPath));
56
+ } else if (importPath.startsWith("~/") || importPath.startsWith("@/") || importPath.startsWith("#")) {
57
+ // Alias — strip prefix. ~ and @ typically resolve from the app root.
58
+ basePath = importPath.replace(/^[~@#]\//, "");
59
+ } else {
60
+ return null;
61
+ }
62
+
63
+ // Normalize separators and strip leading ./
64
+ basePath = basePath.split(path.sep).join("/");
65
+ if (basePath.startsWith("./")) basePath = basePath.slice(2);
66
+
67
+ const extensions = ["", ".ts", ".js", ".vue", ".tsx", ".jsx", "/index.ts", "/index.js", "/index.vue"];
68
+ for (const ext of extensions) {
69
+ const candidate = basePath + ext;
70
+ if (knownFileSet.has(candidate)) return candidate;
71
+ }
72
+
73
+ // For alias imports, also try matching as a suffix of known files
74
+ if (!importPath.startsWith("./") && !importPath.startsWith("../")) {
75
+ const suffix = "/" + basePath;
76
+ for (const ext of extensions) {
77
+ const needle = suffix + ext;
78
+ for (const known of knownFileSet) {
79
+ if (known.endsWith(needle)) return known;
80
+ }
81
+ }
82
+ }
83
+
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Scan files for imports and build cross-dependency map between categories.
89
+ */
90
+ function buildCrossDependencies(root, classification, stagingDir) {
91
+ const unmodifiedSet = new Set(classification.unmodified);
92
+ const modifiedSet = new Set(classification.modified);
93
+
94
+ // All files involved in the update
95
+ const allUpdateFiles = new Set([
96
+ ...classification.unmodified,
97
+ ...classification.modified,
98
+ ...classification.new,
99
+ ...classification.deleted,
100
+ ...classification.removed,
101
+ ]);
102
+
103
+ const crossDeps = {
104
+ modifiedImportsUnmodified: {},
105
+ unmodifiedImportsModified: {},
106
+ newImportsModified: {},
107
+ };
108
+
109
+ function addDep(bucket, key, value) {
110
+ if (!bucket[key]) bucket[key] = [];
111
+ if (!bucket[key].includes(value)) bucket[key].push(value);
112
+ }
113
+
114
+ function scanFileContent(filePath, content, sourceCategory) {
115
+ const imports = extractImports(content, filePath);
116
+ for (const imp of imports) {
117
+ const resolved = matchImportToFile(imp, filePath, allUpdateFiles);
118
+ if (!resolved) continue;
119
+
120
+ if (sourceCategory === "modified" && unmodifiedSet.has(resolved)) {
121
+ addDep(crossDeps.modifiedImportsUnmodified, filePath, resolved);
122
+ }
123
+ if (sourceCategory === "unmodified" && modifiedSet.has(resolved)) {
124
+ addDep(crossDeps.unmodifiedImportsModified, filePath, resolved);
125
+ }
126
+ if (sourceCategory === "new" && modifiedSet.has(resolved)) {
127
+ addDep(crossDeps.newImportsModified, filePath, resolved);
128
+ }
129
+ }
130
+ }
131
+
132
+ function readSafe(fullPath) {
133
+ try { return fs.readFileSync(fullPath, "utf-8"); } catch { return null; }
134
+ }
135
+
136
+ // Scan modified files (user's current versions)
137
+ for (const f of classification.modified) {
138
+ const content = readSafe(path.join(root, f));
139
+ if (content) scanFileContent(f, content, "modified");
140
+ }
141
+
142
+ // Scan unmodified files — both current AND staging versions
143
+ // (staging is what will actually be placed, so its imports matter)
144
+ for (const f of classification.unmodified) {
145
+ const content = readSafe(path.join(root, f));
146
+ if (content) scanFileContent(f, content, "unmodified");
147
+
148
+ const stagingContent = readSafe(path.join(stagingDir, f));
149
+ if (stagingContent) scanFileContent(f, stagingContent, "unmodified");
150
+ }
151
+
152
+ // Scan new files from staging
153
+ for (const f of classification.new) {
154
+ const stagingContent = readSafe(path.join(stagingDir, f));
155
+ if (stagingContent) scanFileContent(f, stagingContent, "new");
156
+ }
157
+
158
+ // Remove empty entries
159
+ for (const key of Object.keys(crossDeps)) {
160
+ if (Object.keys(crossDeps[key]).length === 0) delete crossDeps[key];
161
+ }
162
+
163
+ return Object.keys(crossDeps).length > 0 ? crossDeps : undefined;
164
+ }
165
+
166
+ // ── Main ──
12
167
 
13
168
  function main() {
14
169
  const root = findProjectRoot();
@@ -17,7 +172,6 @@ function main() {
17
172
  process.exit(1);
18
173
  }
19
174
 
20
- // Determine the skill root
21
175
  const scriptDir = __dirname;
22
176
  const skillDir = path.dirname(scriptDir);
23
177
  const refsDir = path.join(skillDir, "references");
@@ -29,10 +183,28 @@ function main() {
29
183
  }
30
184
 
31
185
  const updateManifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
186
+
187
+ // Use template hashes (what the template looked like) for classification.
188
+ // This correctly detects user modifications even after previous merges,
189
+ // because merged files differ from the template while unmodified files match it.
190
+ // Falls back to project hashes for backward compatibility.
191
+ const templateHashesPath = path.join(root, TEMPLATE_HASHES_FILE);
32
192
  const hashesPath = path.join(root, HASHES_FILE);
33
- const fileHashes = fs.existsSync(hashesPath)
34
- ? JSON.parse(fs.readFileSync(hashesPath, "utf-8"))
35
- : {};
193
+
194
+ let baselineHashes;
195
+ let hashSource;
196
+ if (fs.existsSync(templateHashesPath)) {
197
+ baselineHashes = JSON.parse(fs.readFileSync(templateHashesPath, "utf-8"));
198
+ hashSource = "template-hashes.json";
199
+ } else if (fs.existsSync(hashesPath)) {
200
+ baselineHashes = JSON.parse(fs.readFileSync(hashesPath, "utf-8"));
201
+ hashSource = "hashes.json (fallback)";
202
+ } else {
203
+ baselineHashes = {};
204
+ hashSource = "none (treating all as modified)";
205
+ }
206
+
207
+ console.log(`Using ${hashSource} for classification baseline.`);
36
208
 
37
209
  const classification = {
38
210
  targetVersion: updateManifest.targetVersion,
@@ -46,21 +218,20 @@ function main() {
46
218
  // Classify modified files (upstream changed, check if user also changed)
47
219
  for (const filePath of updateManifest.modified) {
48
220
  const fullPath = path.join(root, filePath);
49
- const originalHash = fileHashes[filePath];
221
+ const baselineHash = baselineHashes[filePath];
50
222
 
51
223
  if (!fs.existsSync(fullPath)) {
52
- // User deleted this file
53
224
  classification.deleted.push(filePath);
54
- } else if (!originalHash) {
55
- // No original hash — treat as modified to be safe
225
+ } else if (!baselineHash) {
226
+ // No baseline hash — treat as modified to be safe
56
227
  classification.modified.push(filePath);
57
228
  } else {
58
229
  const currentHash = hashFile(fullPath);
59
- if (currentHash === originalHash) {
60
- // File unchanged by user — safe to auto-update
230
+ if (currentHash === baselineHash) {
231
+ // File matches the original template — safe to auto-update
61
232
  classification.unmodified.push(filePath);
62
233
  } else {
63
- // User has customized this file needs manual merge
234
+ // File differs from templateuser has customized
64
235
  classification.modified.push(filePath);
65
236
  }
66
237
  }
@@ -70,7 +241,6 @@ function main() {
70
241
  for (const filePath of updateManifest.added) {
71
242
  const fullPath = path.join(root, filePath);
72
243
  if (fs.existsSync(fullPath)) {
73
- // File already exists locally — treat as modified for safety
74
244
  classification.modified.push(filePath);
75
245
  } else {
76
246
  classification.new.push(filePath);
@@ -83,7 +253,13 @@ function main() {
83
253
  if (fs.existsSync(fullPath)) {
84
254
  classification.removed.push(filePath);
85
255
  }
86
- // If already gone locally, nothing to do
256
+ }
257
+
258
+ // Scan for cross-category dependencies
259
+ const stagingDir = path.join(root, STAGING_DIR);
260
+ const crossDeps = buildCrossDependencies(root, classification, stagingDir);
261
+ if (crossDeps) {
262
+ classification.crossDependencies = crossDeps;
87
263
  }
88
264
 
89
265
  // Write classification
@@ -94,6 +270,7 @@ function main() {
94
270
  );
95
271
 
96
272
  // Print summary
273
+ console.log("");
97
274
  console.log("File Classification Summary");
98
275
  console.log("===========================");
99
276
  console.log(`Target version: ${classification.targetVersion}`);
@@ -120,7 +297,31 @@ function main() {
120
297
  console.log("");
121
298
  }
122
299
 
123
- console.log("Next: Run apply-auto.js to update safe files, then manually merge modified files.");
300
+ if (crossDeps) {
301
+ if (crossDeps.modifiedImportsUnmodified) {
302
+ console.log("Cross-dependencies: modified files import from unmodified (auto-apply) files:");
303
+ for (const [mod, deps] of Object.entries(crossDeps.modifiedImportsUnmodified)) {
304
+ console.log(` ${mod} imports: ${deps.join(", ")}`);
305
+ }
306
+ console.log("");
307
+ }
308
+ if (crossDeps.unmodifiedImportsModified) {
309
+ console.log("Cross-dependencies: unmodified (auto-apply) files import from modified files:");
310
+ for (const [unmod, deps] of Object.entries(crossDeps.unmodifiedImportsModified)) {
311
+ console.log(` ${unmod} imports: ${deps.join(", ")}`);
312
+ }
313
+ console.log("");
314
+ }
315
+ if (crossDeps.newImportsModified) {
316
+ console.log("Cross-dependencies: new files import from modified files:");
317
+ for (const [newFile, deps] of Object.entries(crossDeps.newImportsModified)) {
318
+ console.log(` ${newFile} imports: ${deps.join(", ")}`);
319
+ }
320
+ console.log("");
321
+ }
322
+ }
323
+
324
+ console.log("Next: Review classification and cross-dependencies, then generate the update plan.");
124
325
  }
125
326
 
126
327
  try {
@@ -2,13 +2,18 @@
2
2
 
3
3
  /**
4
4
  * Complete the update process.
5
- * Re-hashes all project files, updates template hashes from staging,
6
- * updates .generatesaas/manifest.json, and cleans up staging + references.
5
+ * Updates the stored template directory from staging, re-hashes all project
6
+ * files, derives template-hashes.json from the template directory, updates
7
+ * .generatesaas/manifest.json, and cleans up staging + references.
8
+ *
9
+ * Reads .generatesaas/held-back.json (if present) to skip updating template
10
+ * files that were held back — their old template version is preserved so the
11
+ * next update generates correct diffs that include the missed changes.
7
12
  */
8
13
 
9
14
  const fs = require("node:fs");
10
15
  const path = require("node:path");
11
- const { findProjectRoot, hashFile, walkDir, MANIFEST_FILE, HASHES_FILE, TEMPLATE_HASHES_FILE, STAGING_DIR, STAGING_META_FILE, INTERNAL_DIR } = require("./_helpers.js");
16
+ const { findProjectRoot, hashFile, walkDir, ensureDir, MANIFEST_FILE, HASHES_FILE, TEMPLATE_HASHES_FILE, TEMPLATE_DIR, STAGING_DIR, STAGING_META_FILE, INTERNAL_DIR } = require("./_helpers.js");
12
17
 
13
18
  const HASH_EXCLUSIONS = new Set([
14
19
  ".git", "node_modules", ".pnpm-store", ".env", ".env.test",
@@ -56,7 +61,6 @@ function main() {
56
61
  process.exit(1);
57
62
  }
58
63
 
59
- // Determine the skill root
60
64
  const scriptDir = __dirname;
61
65
  const skillDir = path.dirname(scriptDir);
62
66
  const refsDir = path.join(skillDir, "references");
@@ -72,16 +76,65 @@ function main() {
72
76
 
73
77
  console.log(`Completing update to version ${targetVersion}...`);
74
78
 
75
- // Compute and write new template hashes from staging BEFORE cleanup
79
+ // Read held-back files list (created by the AI for skipped/held-back files)
80
+ const heldBackPath = path.join(root, INTERNAL_DIR, "held-back.json");
81
+ const heldBack = new Set(
82
+ fs.existsSync(heldBackPath)
83
+ ? JSON.parse(fs.readFileSync(heldBackPath, "utf-8"))
84
+ : []
85
+ );
86
+
87
+ if (heldBack.size > 0) {
88
+ console.log(`${heldBack.size} files were held back — preserving their old template versions.`);
89
+ }
90
+
91
+ // ── Update template directory from staging ──
76
92
  const stagingDir = path.join(root, STAGING_DIR);
93
+ const templateDir = path.join(root, TEMPLATE_DIR);
94
+
77
95
  if (fs.existsSync(stagingDir)) {
78
- console.log("Computing template hashes from staging...");
79
- const stagingFiles = walkDir(stagingDir, stagingDir);
80
- const templateHashes = {};
81
- for (const rel of stagingFiles.sort()) {
82
- templateHashes[rel] = hashFile(path.join(stagingDir, rel));
96
+ console.log("Updating template directory from staging...");
97
+ const stagingFiles = new Set(walkDir(stagingDir, stagingDir));
98
+
99
+ // Copy staging files to template dir (skip held-back old version stays)
100
+ let copied = 0;
101
+ let skipped = 0;
102
+ for (const rel of stagingFiles) {
103
+ if (heldBack.has(rel)) {
104
+ skipped++;
105
+ continue;
106
+ }
107
+ const src = path.join(stagingDir, rel);
108
+ const dest = path.join(templateDir, rel);
109
+ ensureDir(path.dirname(dest));
110
+ fs.copyFileSync(src, dest);
111
+ copied++;
112
+ }
113
+ console.log(` Copied ${copied} files to template dir (${skipped} held back).`);
114
+
115
+ // Remove files from template dir that were removed upstream
116
+ if (fs.existsSync(templateDir)) {
117
+ const templateFiles = walkDir(templateDir, templateDir);
118
+ let removed = 0;
119
+ for (const rel of templateFiles) {
120
+ if (!stagingFiles.has(rel) && !heldBack.has(rel)) {
121
+ fs.rmSync(path.join(templateDir, rel));
122
+ removed++;
123
+ }
124
+ }
125
+ if (removed > 0) {
126
+ console.log(` Removed ${removed} files no longer in template.`);
127
+ }
83
128
  }
129
+
130
+ // Derive template-hashes.json from the template directory (source of truth)
131
+ console.log("Computing template hashes from template directory...");
84
132
  const templateHashesPath = path.join(root, TEMPLATE_HASHES_FILE);
133
+ const allTemplateFiles = walkDir(templateDir, templateDir);
134
+ const templateHashes = {};
135
+ for (const rel of allTemplateFiles.sort()) {
136
+ templateHashes[rel] = hashFile(path.join(templateDir, rel));
137
+ }
85
138
  fs.mkdirSync(path.dirname(templateHashesPath), { recursive: true });
86
139
  fs.writeFileSync(templateHashesPath, JSON.stringify(templateHashes, null, "\t") + "\n", "utf-8");
87
140
  console.log(`Tracked ${Object.keys(templateHashes).length} template hashes in ${TEMPLATE_HASHES_FILE}`);
@@ -90,13 +143,18 @@ function main() {
90
143
  // Clean up staging
91
144
  if (fs.existsSync(stagingDir)) {
92
145
  fs.rmSync(stagingDir, { recursive: true, force: true });
93
- console.log("Cleaned up staging directory");
146
+ console.log("Cleaned up staging directory.");
94
147
  }
95
148
  const metaPath = path.join(root, STAGING_META_FILE);
96
149
  if (fs.existsSync(metaPath)) {
97
150
  fs.rmSync(metaPath);
98
151
  }
99
152
 
153
+ // Clean up held-back file
154
+ if (fs.existsSync(heldBackPath)) {
155
+ fs.rmSync(heldBackPath);
156
+ }
157
+
100
158
  // Clean up references BEFORE hashing to avoid tracking temp files
101
159
  if (fs.existsSync(refsDir)) {
102
160
  fs.rmSync(refsDir, { recursive: true, force: true });
@@ -9,7 +9,7 @@
9
9
  const fs = require("node:fs");
10
10
  const path = require("node:path");
11
11
  const { execFileSync } = require("node:child_process");
12
- const { findProjectRoot, ensureDir, hashFile, walkDir, TEMPLATE_HASHES_FILE, STAGING_DIR, STAGING_META_FILE } = require("./_helpers.js");
12
+ const { findProjectRoot, ensureDir, hashFile, walkDir, TEMPLATE_HASHES_FILE, TEMPLATE_DIR, STAGING_DIR, STAGING_META_FILE } = require("./_helpers.js");
13
13
 
14
14
  function main() {
15
15
  const root = findProjectRoot();
@@ -91,17 +91,30 @@ function main() {
91
91
  }
92
92
 
93
93
  // Generate and write diffs for modified files
94
+ // Uses stored template files when available for pure upstream diffs (old template → new template).
95
+ // Falls back to user file → staging if no stored template exists (backward compat).
94
96
  const diffsDir = path.join(refsDir, "diffs");
95
97
  ensureDir(diffsDir);
96
98
  let diffCount = 0;
97
99
 
100
+ const templateDir = path.join(root, TEMPLATE_DIR);
101
+
98
102
  for (const filePath of [...modified, ...added]) {
99
- const userFile = path.join(root, filePath);
100
103
  const stagingFile = path.join(stagingDir, filePath);
104
+ const templateFile = path.join(templateDir, filePath);
105
+ const userFile = path.join(root, filePath);
101
106
 
102
- if (!fs.existsSync(userFile)) continue;
107
+ if (!fs.existsSync(stagingFile)) continue;
108
+
109
+ let diff;
110
+ if (fs.existsSync(templateFile)) {
111
+ // Three-way capable: diff old template vs new template (pure upstream changes)
112
+ diff = generateDiff(templateFile, stagingFile, filePath);
113
+ } else if (fs.existsSync(userFile)) {
114
+ // Fallback: diff user file vs staging (old behavior)
115
+ diff = generateDiff(userFile, stagingFile, filePath);
116
+ }
103
117
 
104
- const diff = generateDiff(userFile, stagingFile, filePath);
105
118
  if (diff) {
106
119
  const diffPath = path.join(diffsDir, filePath + ".diff");
107
120
  ensureDir(path.dirname(diffPath));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "generatesaas",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "type": "module",
5
5
  "description": "CLI for scaffolding and managing GenerateSaaS projects",
6
6
  "bin": {