generatesaas 1.9.3 → 1.11.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.
@@ -105,15 +105,22 @@ Creates:
105
105
  file's project-relative path with `.diff` appended (e.g.
106
106
  `references/diffs/packages/config/src/index.ts.diff`). Diffs cover `modified` +
107
107
  `added` files; `unmodified`, `deleted`, and `removed` files have none.
108
- - `references/update-manifest.json` - lists of added, modified, and removed files
108
+ - `references/update-manifest.json` - lists of added, modified, and removed files,
109
+ plus `renamed` (upstream renames detected by identical content: `[{from, to}]`)
110
+ and `sensitive` (database schema and `.env.example` changes that carry
111
+ follow-up work: migrations, new env vars)
109
112
 
110
113
  ### Step 2: Present Changelog
111
114
 
112
- Read `references/changelog.md`. It may be one of two shapes:
115
+ Read `references/changelog.md`. When the update spans multiple releases, it contains one `# vX.Y.Z` section per release, oldest first - read ALL of them, not just the last; a breaking change can sit in any intermediate release. A `[BREAKING]` marker on a section header is authoritative (it comes from the version index: a major bump or release notes flagged BREAKING) - always call those releases out explicitly.
116
+
117
+ Each release's notes may be one of two shapes:
113
118
 
114
119
  - **Curated** - contains explicit `## Breaking`, `## Migration`, `## Features`, `## Fixes` (or similar) sections. Use those sections verbatim; they are authoritative.
115
120
  - **Raw** - auto-generated release notes (a flat list of PR titles, no sections). This is the common case. You can group PR titles into features/fixes by reading them, but you **cannot** reliably infer breaking changes or migration steps from PR titles.
116
121
 
122
+ Also read `sensitive` in `references/update-manifest.json`: database schema changes mean a migration is likely required, and `.env.example` changes mean new or changed environment variables. Mention both in the summary and again in the final post-update steps.
123
+
117
124
  Present a clear, organized summary:
118
125
 
119
126
  > ## What's New in v{targetVersion}
@@ -276,6 +283,12 @@ If `.generatesaas/template/<path>` does not exist (backward compat), fall back t
276
283
 
277
284
  #### For Removed Files
278
285
 
286
+ **Check for renames first.** Look up the file in `renamed` in `references/update-manifest.json`. If it is the `from` of a rename, the same upstream content now lives at the `to` path (auto-created in Step 6). Present it as a rename, not an unrelated removal:
287
+
288
+ - If the user never modified the old file: delete it and note the rename - nothing is lost.
289
+ - If the user **modified** the old file: their customizations exist nowhere in the new version. Port them to the `to` path (show the proposed result) before deleting the old file. Never let a rename silently drop customizations.
290
+ - Imports of the old path elsewhere in the project must be updated to the new path - run the import search below for the old path either way.
291
+
279
292
  Before presenting each removed file, **search the entire project** for imports of it. Use grep to find `import ... from` or `require(...)` statements referencing the file's path (check relative paths, aliases, and package paths). This is critical - the classification script only scans files in the update, not user-created files that may also depend on the removed file.
280
293
 
281
294
  ```
@@ -377,6 +390,13 @@ After all files are processed:
377
390
 
378
391
  If no files were held back, skip creating this file.
379
392
 
393
+ > Note: the completion script runs a safety net that auto-holds-back upstream
394
+ > changes which were demonstrably never applied (the file was not touched during
395
+ > the entire update). Do NOT rely on it - it cannot detect a file you edited
396
+ > during a merge or that the user customized between updates - always write
397
+ > held-back.json yourself. Treat its warnings as a signal that your bookkeeping
398
+ > missed something.
399
+
380
400
  2. **Run the completion script:**
381
401
 
382
402
  ```bash
@@ -39,14 +39,17 @@ function hashFile(filePath) {
39
39
 
40
40
  // ── File Walking ──
41
41
 
42
- // Broader than strictly needed, but aligned with the CLI's exclusions.ts to ensure
43
- // hash consistency across update cycles. The staging tarball is pre-filtered by
44
- // exclusions.ts, so most of these only matter when walking the live project root.
42
+ // MUST stay in sync with the CLI's template/exclusions.ts: exactly
43
+ // EXCLUDED_NAMES + SNAPSHOT_EXCLUDED_NAMES plus the two project-root extras
44
+ // ("data", INTERNAL_DIR). A drift silently corrupts classification - a path the
45
+ // CLI stages but this walker skips never gets diffed or hashed. The
46
+ // exclusions-parity test in the CLI repo asserts this set equality.
45
47
  const WALK_EXCLUSIONS = new Set([
46
48
  ".git", "node_modules", ".pnpm-store", ".env", ".env.test",
47
49
  ".turbo", ".nuxt", ".output", ".data", "dist", "data",
48
50
  ".next", ".svelte-kit", ".wrangler",
49
51
  ".devcontainer", "playwright-report", "test-results",
52
+ "pnpm-lock.yaml",
50
53
  INTERNAL_DIR,
51
54
  ]);
52
55
 
@@ -86,6 +89,8 @@ module.exports = {
86
89
  ensureDir,
87
90
  hashFile,
88
91
  walkDir,
92
+ shouldExcludeWalk,
93
+ WALK_EXCLUSIONS,
89
94
  INTERNAL_DIR,
90
95
  MANIFEST_FILE,
91
96
  HASHES_FILE,
@@ -15,43 +15,43 @@ const fs = require("node:fs");
15
15
  const path = require("node:path");
16
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");
17
17
 
18
- const HASH_EXCLUSIONS = new Set([
19
- ".git", "node_modules", ".pnpm-store", ".env", ".env.test",
20
- ".turbo", ".nuxt", ".output", ".data", "dist", "data",
21
- ".next", ".svelte-kit", ".wrangler",
22
- ".devcontainer", "playwright-report", "test-results",
23
- INTERNAL_DIR,
24
- ]);
25
-
26
- /** Check if a relative path should be excluded from hashing. */
27
- function shouldExcludeFromHash(relativePath) {
28
- const parts = relativePath.split("/");
29
- for (const part of parts) {
30
- if (HASH_EXCLUSIONS.has(part)) return true;
31
- if (part.startsWith(".env") && !part.includes("example")) return true;
32
- }
33
- return false;
34
- }
35
-
36
- /** Recursively collect all file paths for project hashing. */
37
- function collectProjectFiles(dir, baseDir) {
38
- const files = [];
39
- const entries = fs.readdirSync(dir, { withFileTypes: true });
40
-
41
- for (const entry of entries) {
42
- const fullPath = path.join(dir, entry.name);
43
- const rel = path.relative(baseDir, fullPath);
44
-
45
- if (shouldExcludeFromHash(rel)) continue;
46
-
47
- if (entry.isDirectory()) {
48
- files.push(...collectProjectFiles(fullPath, baseDir));
49
- } else if (entry.isFile()) {
50
- files.push(fullPath);
51
- }
18
+ /**
19
+ * Safety net for the held-back list. An upstream change was demonstrably never
20
+ * applied when the file (a) really changed upstream (old template hash differs
21
+ * from staging), (b) still exists on disk, and (c) was not touched during the
22
+ * entire update (its disk hash still equals the previous hashes.json entry).
23
+ * Such files MUST be held back - otherwise the template baseline advances past
24
+ * a change the project never received and the next update's diff silently
25
+ * drops it. Returns the detected paths (sorted) and adds them to `heldBack`.
26
+ *
27
+ * This is a net, not a replacement for the AI-written held-back.json: a file
28
+ * the user customized since the last update and then kept during this one
29
+ * cannot be distinguished from a merged file by hashes alone.
30
+ */
31
+ function applyHeldBackSafetyNet(root, stagingDir, stagingFiles, heldBack) {
32
+ const templateHashesPath = path.join(root, TEMPLATE_HASHES_FILE);
33
+ const hashesPath = path.join(root, HASHES_FILE);
34
+ const oldTemplateHashes = fs.existsSync(templateHashesPath)
35
+ ? JSON.parse(fs.readFileSync(templateHashesPath, "utf-8"))
36
+ : {};
37
+ const prevProjectHashes = fs.existsSync(hashesPath)
38
+ ? JSON.parse(fs.readFileSync(hashesPath, "utf-8"))
39
+ : {};
40
+
41
+ const netted = [];
42
+ for (const rel of stagingFiles) {
43
+ if (heldBack.has(rel)) continue;
44
+ const oldHash = oldTemplateHashes[rel];
45
+ const prevHash = prevProjectHashes[rel];
46
+ if (!oldHash || !prevHash) continue;
47
+ const diskPath = path.join(root, rel);
48
+ if (!fs.existsSync(diskPath)) continue;
49
+ if (hashFile(path.join(stagingDir, rel)) === oldHash) continue;
50
+ if (hashFile(diskPath) !== prevHash) continue;
51
+ heldBack.add(rel);
52
+ netted.push(rel);
52
53
  }
53
-
54
- return files;
54
+ return netted.sort();
55
55
  }
56
56
 
57
57
  /**
@@ -133,6 +133,15 @@ function main() {
133
133
  console.log("Updating template directory from staging...");
134
134
  const stagingFiles = new Set(walkDir(stagingDir, stagingDir));
135
135
 
136
+ // Safety net: auto-hold-back upstream changes that were demonstrably
137
+ // never applied but are missing from held-back.json.
138
+ const netted = applyHeldBackSafetyNet(root, stagingDir, stagingFiles, heldBack);
139
+ if (netted.length > 0) {
140
+ console.warn(`Safety net: ${netted.length} upstream change(s) were never applied and were missing from held-back.json.`);
141
+ console.warn("Holding them back so the next update re-surfaces them:");
142
+ for (const rel of netted) console.warn(` - ${rel}`);
143
+ }
144
+
136
145
  // Validate held-back entries (non-fatal). A held-back file is supposed to be
137
146
  // a real upstream change the user chose to skip - preserving its old template
138
147
  // version so the next update re-surfaces the missed change. Warn on entries
@@ -219,11 +228,10 @@ function main() {
219
228
 
220
229
  // Re-hash all project files
221
230
  console.log("Hashing project files...");
222
- const files = collectProjectFiles(root, root);
231
+ const files = walkDir(root, root);
223
232
  const fileHashes = {};
224
- for (const file of files.sort()) {
225
- const rel = path.relative(root, file);
226
- fileHashes[rel] = hashFile(file);
233
+ for (const rel of files.sort()) {
234
+ fileHashes[rel] = hashFile(path.join(root, rel));
227
235
  }
228
236
 
229
237
  // Update version in manifest
@@ -9,7 +9,15 @@
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, TEMPLATE_DIR, STAGING_DIR, STAGING_META_FILE } = require("./_helpers.js");
12
+ const { findProjectRoot, ensureDir, hashFile, walkDir, shouldExcludeWalk, TEMPLATE_HASHES_FILE, TEMPLATE_DIR, STAGING_DIR, STAGING_META_FILE } = require("./_helpers.js");
13
+
14
+ // Paths whose upstream changes always carry follow-up work for the user:
15
+ // database schema changes may need a migration, .env.example changes may need
16
+ // new environment variables. Surfaced as `sensitive` in update-manifest.json.
17
+ const SENSITIVE_PATTERNS = [
18
+ /^packages\/database\/src\/db\//,
19
+ /(^|\/)\.env\.example$/,
20
+ ];
13
21
 
14
22
  function main() {
15
23
  const root = findProjectRoot();
@@ -36,11 +44,17 @@ function main() {
36
44
 
37
45
  console.log(`Preparing update: ${currentVersion} → ${targetVersion}`);
38
46
 
39
- // Read old template hashes
47
+ // Read old template hashes. Entries that are no longer part of the template
48
+ // universe (e.g. pnpm-lock.yaml after an exclusions update) are dropped so
49
+ // they don't surface as phantom "removed upstream" files.
40
50
  const templateHashesPath = path.join(root, TEMPLATE_HASHES_FILE);
41
- const oldHashes = fs.existsSync(templateHashesPath)
51
+ const oldHashesRaw = fs.existsSync(templateHashesPath)
42
52
  ? JSON.parse(fs.readFileSync(templateHashesPath, "utf-8"))
43
53
  : {};
54
+ const oldHashes = {};
55
+ for (const [filePath, hash] of Object.entries(oldHashesRaw)) {
56
+ if (!shouldExcludeWalk(filePath)) oldHashes[filePath] = hash;
57
+ }
44
58
 
45
59
  // Walk staging dir and compute new template hashes
46
60
  const stagingFiles = walkDir(stagingDir, stagingDir);
@@ -74,7 +88,25 @@ function main() {
74
88
  modified.sort();
75
89
  removed.sort();
76
90
 
91
+ // Detect upstream renames: a removed file whose baseline content reappears
92
+ // verbatim at exactly one added path (and vice versa). Surfaced so user
93
+ // customizations on the old path can be ported before it is deleted -
94
+ // otherwise a rename reads as an unrelated add + remove and customizations
95
+ // on the old path are silently lost.
96
+ const renamed = detectRenames(oldHashes, newHashes, added, removed);
97
+
98
+ // Flag changes that always carry follow-up work (migrations, new env vars).
99
+ const sensitive = [...modified, ...added]
100
+ .filter((filePath) => SENSITIVE_PATTERNS.some((pattern) => pattern.test(filePath)))
101
+ .sort();
102
+
77
103
  console.log(` Added: ${added.length}, Modified: ${modified.length}, Removed: ${removed.length}`);
104
+ if (renamed.length > 0) {
105
+ console.log(` Renamed: ${renamed.length} (see update-manifest.json - port customizations before deleting the old paths)`);
106
+ }
107
+ if (sensitive.length > 0) {
108
+ console.log(` Sensitive (schema / env example): ${sensitive.length}`);
109
+ }
78
110
 
79
111
  // Determine output directory
80
112
  const scriptDir = __dirname;
@@ -124,13 +156,15 @@ function main() {
124
156
  }
125
157
  console.log(`Written: ${diffCount} diff files`);
126
158
 
127
- // Write update manifest (same format as before)
159
+ // Write update manifest (same format as before, plus renamed + sensitive)
128
160
  const updateManifest = {
129
161
  currentVersion,
130
162
  targetVersion,
131
163
  added,
132
164
  modified,
133
165
  removed,
166
+ renamed,
167
+ sensitive,
134
168
  };
135
169
  fs.writeFileSync(
136
170
  path.join(refsDir, "update-manifest.json"),
@@ -142,6 +176,35 @@ function main() {
142
176
  console.log("\nNext: Review references/changelog.md, then run classify-files.js");
143
177
  }
144
178
 
179
+ /**
180
+ * Detect upstream renames by exact content hash: a removed path whose baseline
181
+ * hash matches exactly one added path's staging hash (1:1 both ways - ambiguous
182
+ * matches are skipped). Returns [{ from, to }] sorted by old path.
183
+ */
184
+ function detectRenames(oldHashes, newHashes, added, removed) {
185
+ if (added.length === 0 || removed.length === 0) return [];
186
+
187
+ const addedByHash = new Map();
188
+ for (const filePath of added) {
189
+ const hash = newHashes[filePath];
190
+ addedByHash.set(hash, (addedByHash.get(hash) || []).concat(filePath));
191
+ }
192
+ const removedByHash = new Map();
193
+ for (const filePath of removed) {
194
+ const hash = oldHashes[filePath];
195
+ removedByHash.set(hash, (removedByHash.get(hash) || []).concat(filePath));
196
+ }
197
+
198
+ const renamed = [];
199
+ for (const [hash, fromPaths] of removedByHash) {
200
+ const toPaths = addedByHash.get(hash);
201
+ if (fromPaths.length === 1 && toPaths && toPaths.length === 1) {
202
+ renamed.push({ from: fromPaths[0], to: toPaths[0] });
203
+ }
204
+ }
205
+ return renamed.sort((a, b) => a.from.localeCompare(b.from));
206
+ }
207
+
145
208
  /** Generate a unified diff between two files. Returns null if diff is unavailable. */
146
209
  function generateDiff(userFile, stagingFile, relativePath) {
147
210
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "generatesaas",
3
- "version": "1.9.3",
3
+ "version": "1.11.0",
4
4
  "type": "module",
5
5
  "description": "CLI for scaffolding and managing GenerateSaaS projects",
6
6
  "bin": {