From d7e1548943e7d005fb5abbcda6c1128c21458324 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:09:35 +0800 Subject: [PATCH 1/2] fix(create-objectstack): ship a .gitignore in scaffolded projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm pack` / `pnpm pack` strip `.gitignore` from a tarball unconditionally, at every depth. The blank template committed one at src/templates/blank/.gitignore and tsup faithfully copied it to dist/templates/blank/.gitignore, but `files: ["dist"]` publishing dropped it on the way to the registry — so the file was present in the repo, present in every local build, and absent from all 11 files of a real scaffold. Every project created with `npx create-objectstack` had no .gitignore at all, leaving node_modules/ and .env un-ignored. Verified against the published 15.1.1 tarball, which ships dist/templates/blank/.dockerignore and no .gitignore. Commit the template as `_gitignore` and restore the real name at copy time through a TEMPLATE_FILE_ALIASES map. Only .gitignore is aliased: the strip list is .gitignore and .npmrc, not "every dotfile" — .dockerignore packs fine and stays literal. The restored rules also cover .env / .env.*, which they never did. The template README has users write OS_AUTH_SECRET and OS_SECRET_KEY into a .env and docker-compose.yml calls that file "never committed", but only the prose said so — .dockerignore was the only file that listed it. copyDir moves to template-copy.ts so a test can import it without running the CLI (index.ts calls program.parse() on import), the same reason pkg-utils.ts exists. Regression test packs the real package, scaffolds from the extracted tarball with the real copy logic, and asserts every template file lands under its intended name. Source-level assertions cannot see this bug — the file only vanishes at publish — and reading the repo's own dist/ would be false green, since turbo's `test` task only dependsOn `^build` and excludes dist/** from its inputs. Both halves are guarded independently: reverting the rename fails the file-set check, dropping the alias fails the .gitignore assertion. Co-Authored-By: Claude Opus 4.8 --- .../scaffold-gitignore-survives-publish.md | 13 ++ packages/create-objectstack/src/index.ts | 21 +--- .../src/template-consistency.test.ts | 119 +++++++++++++++++- .../create-objectstack/src/template-copy.ts | 51 ++++++++ .../blank/{.gitignore => _gitignore} | 2 + 5 files changed, 188 insertions(+), 18 deletions(-) create mode 100644 .changeset/scaffold-gitignore-survives-publish.md create mode 100644 packages/create-objectstack/src/template-copy.ts rename packages/create-objectstack/src/templates/blank/{.gitignore => _gitignore} (80%) diff --git a/.changeset/scaffold-gitignore-survives-publish.md b/.changeset/scaffold-gitignore-survives-publish.md new file mode 100644 index 0000000000..67fb4c2b0f --- /dev/null +++ b/.changeset/scaffold-gitignore-survives-publish.md @@ -0,0 +1,13 @@ +--- +"create-objectstack": patch +--- + +Scaffolded projects ship with a `.gitignore` again — `npx create-objectstack` produced none, leaving `node_modules/` and `.env` un-ignored for every new user. + +`npm pack` / `pnpm pack` strip `.gitignore` from a tarball unconditionally, at every depth. The blank template committed one at `src/templates/blank/.gitignore` and the build faithfully copied it to `dist/templates/blank/.gitignore`, but `files: ["dist"]` publishing dropped it on the way to the registry — so the file was present in the repo, present in every local build, and absent from all 11 files of a real scaffold. Verified against the published 15.1.1 tarball, which ships `dist/templates/blank/.dockerignore` and no `.gitignore`. + +The template is now committed as `_gitignore` (a name npm does not strip) and restored to `.gitignore` when the template is copied, via a `TEMPLATE_FILE_ALIASES` map in the new `template-copy.ts`. Only `.gitignore` is aliased: the strip list is `.gitignore` and `.npmrc`, not "every dotfile" — `.dockerignore` packs fine and stays literal. + +The restored ignore rules also cover `.env` / `.env.*`, which they never did. The template README has users write `OS_AUTH_SECRET` and `OS_SECRET_KEY` into a `.env`, and `docker-compose.yml` calls that file "never committed" — but only the prose said so, and `.dockerignore` was the only file that listed it. + +A packing ratchet in `template-consistency.test.ts` guards both halves: it packs the real package, scaffolds from the extracted tarball with the real copy logic, and asserts every template file lands under its intended name. Source-level assertions cannot see this class of bug — the file only vanishes at publish. diff --git a/packages/create-objectstack/src/index.ts b/packages/create-objectstack/src/index.ts index 3532f07c40..e847fad9a6 100644 --- a/packages/create-objectstack/src/index.ts +++ b/packages/create-objectstack/src/index.ts @@ -7,8 +7,9 @@ * * 1. Bundled `blank` template * Lives at `dist/templates/blank/` (copied from `src/templates/blank/` - * by tsup `onSuccess`). Cloned via recursive fs copy. Always available - * offline. + * by tsup `onSuccess`). Cloned via recursive fs copy, which also restores + * the placeholder names npm strips at publish (see TEMPLATE_FILE_ALIASES). + * Always available offline. * * 2. Remote content templates (`todo`, `compliance`, `content`, * `contracts`, `procurement`) @@ -45,6 +46,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import * as tar from 'tar'; import { syncObjectStackDeps } from './pkg-utils.js'; +import { copyDir } from './template-copy.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -148,21 +150,6 @@ function detectPackageManager(): string { // ─── Loading: bundled (fs copy) ───────────────────────────────────── -function copyDir(src: string, dest: string, collected: string[], rel = '') { - fs.mkdirSync(dest, { recursive: true }); - for (const entry of fs.readdirSync(src, { withFileTypes: true })) { - const srcPath = path.join(src, entry.name); - const destPath = path.join(dest, entry.name); - const relPath = rel ? `${rel}/${entry.name}` : entry.name; - if (entry.isDirectory()) { - copyDir(srcPath, destPath, collected, relPath); - } else if (entry.isFile()) { - fs.copyFileSync(srcPath, destPath); - collected.push(relPath); - } - } -} - function loadBundled(templateDir: string, targetDir: string): string[] { const src = path.join(BUNDLED_TEMPLATES_DIR, templateDir); if (!fs.existsSync(src)) { diff --git a/packages/create-objectstack/src/template-consistency.test.ts b/packages/create-objectstack/src/template-consistency.test.ts index f748d22a31..7af1f32b9c 100644 --- a/packages/create-objectstack/src/template-consistency.test.ts +++ b/packages/create-objectstack/src/template-consistency.test.ts @@ -5,12 +5,14 @@ // `^6.0.0` while the registry was publishing 14.x, and the README advertised // a template set (`minimal-api`/`full-stack`/`plugin`) that never shipped. -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { syncObjectStackDeps } from './pkg-utils.js'; +import { copyDir, TEMPLATE_FILE_ALIASES } from './template-copy.js'; const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const repoRoot = path.resolve(pkgRoot, '..', '..'); @@ -72,6 +74,121 @@ describe('blank template manifest engines.protocol (ADR-0087 D1)', () => { }); }); +// Packing ratchet (#3120): every file the blank template ships must survive a +// real `npm pack` and land in a scaffold under its intended name. `.gitignore` +// did not — npm strips it from the tarball at every depth, so the file the +// build had faithfully copied to dist/templates/blank/ was dropped at publish +// and every scaffolded project came out with no `.gitignore`, leaving +// node_modules/ and the secret-bearing .env from the template README +// un-ignored for every new user. +// +// This bug is invisible to source-level assertions: the file is present in +// src/templates/, present in a local build, and only vanishes at publish. So +// pack for real and scaffold from the extracted tarball with the real copyDir. +// Reading the repo's own dist/ instead would be doubly false green — turbo's +// `test` task only dependsOn `^build` (not its own build) and excludes dist/** +// from its inputs, so dist/ here is routinely absent or stale, and a pass on it +// would be cached. +describe('blank template survives npm packing', () => { + const blankSrc = path.join(pkgRoot, 'src', 'templates', 'blank'); + + const walkRel = (dir: string, rel = ''): string[] => + fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const relPath = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) return walkRel(path.join(dir, entry.name), relPath); + return entry.isFile() ? [relPath] : []; + }); + + // The name a template file is expected to land under, i.e. its alias applied + // to the basename. + const scaffoldedAs = (rel: string): string => { + const parts = rel.split('/'); + const base = parts[parts.length - 1]; + parts[parts.length - 1] = TEMPLATE_FILE_ALIASES.get(base) ?? base; + return parts.join('/'); + }; + + let tmp: string; + let scaffolded: string[]; + let collected: string[]; + + beforeAll(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'create-objectstack-pack-')); + + // Stage exactly what a publish would: the *real* package.json (so the real + // `files` allowlist decides what ships) plus src/templates copied to + // dist/templates, which is what tsup.config.ts's onSuccess hook does. The + // test asserts that mirror below rather than assuming it. + fs.cpSync(path.join(pkgRoot, 'package.json'), path.join(tmp, 'package.json')); + fs.cpSync(path.join(pkgRoot, 'src', 'templates'), path.join(tmp, 'dist', 'templates'), { + recursive: true, + }); + + execFileSync('npm', ['pack', '--ignore-scripts'], { cwd: tmp, stdio: 'pipe' }); + const tgz = fs.readdirSync(tmp).find((f) => f.endsWith('.tgz')); + if (!tgz) throw new Error(`npm pack produced no tarball in ${tmp}`); + execFileSync('tar', ['xzf', tgz], { cwd: tmp }); + + // Scaffold from the *packed* template with the real copy logic. + const out = path.join(tmp, 'scaffold'); + collected = []; + copyDir(path.join(tmp, 'package', 'dist', 'templates', 'blank'), out, collected); + scaffolded = walkRel(out); + }, 120_000); + + afterAll(() => { + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it('stages dist/templates the way this suite mirrors it', () => { + const tsupConfig = fs.readFileSync(path.join(pkgRoot, 'tsup.config.ts'), 'utf8'); + expect( + tsupConfig.replace(/\s+/g, ' '), + 'the packing ratchet stages dist/templates by hand because dist/ is not ' + + 'built here; if the build stopped copying src/templates → dist/templates ' + + 'that mirror is now a fiction — update both together', + ).toContain("cpSync('src/templates', 'dist/templates', { recursive: true })"); + }); + + it('lands every template file — dotfiles included — in the scaffold', () => { + const expected = walkRel(blankSrc).map(scaffoldedAs).sort(); + expect( + scaffolded.sort(), + 'a file in src/templates/blank/ did not survive `npm pack` (npm strips ' + + '.gitignore and .npmrc from tarballs at every depth). Commit it under a ' + + 'placeholder name and map it back in TEMPLATE_FILE_ALIASES, the way ' + + '_gitignore → .gitignore works.', + ).toEqual(expected); + // What the CLI prints as "Created files:" must match what it actually wrote. + expect(collected.sort()).toEqual(expected); + }); + + // Not redundant with the file-set check above: that one applies the alias map + // to both sides, so emptying TEMPLATE_FILE_ALIASES makes it agree with itself + // on `_gitignore` and pass. Naming the destination literally is what pins the + // mapping down. + it('ships a .gitignore that ignores node_modules and the README secrets', () => { + const gitignore = path.join(tmp, 'scaffold', '.gitignore'); + expect(fs.existsSync(gitignore), 'scaffold has no .gitignore').toBe(true); + + const rules = fs.readFileSync(gitignore, 'utf8').split('\n').map((l) => l.trim()); + expect(rules).toContain('node_modules'); + // The template README has users write OS_AUTH_SECRET / OS_SECRET_KEY into a + // .env, and docker-compose.yml calls it "never committed" — so the ignore + // file, not just the prose, has to enforce that. + expect(rules).toContain('.env'); + }); + + it('leaves a literal template dotfile that packs fine alone', () => { + // .dockerignore is NOT stripped — verified against the published 15.1.1 + // tarball, which ships it while .gitignore is absent. It stays literal, so + // the alias map covers only what is genuinely broken. + expect(fs.existsSync(path.join(blankSrc, '.dockerignore'))).toBe(true); + expect(TEMPLATE_FILE_ALIASES.has('.dockerignore')).toBe(false); + expect(scaffolded).toContain('.dockerignore'); + }); +}); + describe('README template table', () => { it('lists exactly the templates in the TEMPLATES registry', () => { const readme = fs.readFileSync(path.join(pkgRoot, 'README.md'), 'utf8'); diff --git a/packages/create-objectstack/src/template-copy.ts b/packages/create-objectstack/src/template-copy.ts new file mode 100644 index 0000000000..21fe9f0b22 --- /dev/null +++ b/packages/create-objectstack/src/template-copy.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// Materializes a bundled template onto disk. Kept out of index.ts because that +// module calls `program.parse()` on import — anything a test needs must be +// importable without running the CLI. + +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Template files committed under a placeholder name, mapped to the name they + * must land under in a scaffolded project. + * + * `npm pack` / `pnpm pack` strip `.gitignore` (and `.npmrc`) from a tarball + * unconditionally, at every depth — so a template dotfile committed under its + * real name never reaches the registry. `src/templates/blank/.gitignore` was + * copied to `dist/templates/blank/.gitignore` by the build and then dropped at + * publish, and every project scaffolded from the published package came out + * with no `.gitignore` at all, leaving `node_modules/` and the `.env` the + * README tells users to fill with secrets un-ignored (#3120). + * + * The strip list is not "every dotfile": `.dockerignore` packs fine and stays + * literal. Don't add entries by guesswork — the packing ratchet in + * `template-consistency.test.ts` packs the real package and fails with the + * required alias if a template file goes missing from the tarball. + */ +export const TEMPLATE_FILE_ALIASES = new Map([ + ['_gitignore', '.gitignore'], +]); + +/** + * Recursively copy `src` onto `dest`, restoring aliased filenames, pushing the + * project-relative path of every written file onto `collected`. + */ +export function copyDir(src: string, dest: string, collected: string[], rel = '') { + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const srcPath = path.join(src, entry.name); + const outName = entry.isFile() + ? (TEMPLATE_FILE_ALIASES.get(entry.name) ?? entry.name) + : entry.name; + const destPath = path.join(dest, outName); + const relPath = rel ? `${rel}/${outName}` : outName; + if (entry.isDirectory()) { + copyDir(srcPath, destPath, collected, relPath); + } else if (entry.isFile()) { + fs.copyFileSync(srcPath, destPath); + collected.push(relPath); + } + } +} diff --git a/packages/create-objectstack/src/templates/blank/.gitignore b/packages/create-objectstack/src/templates/blank/_gitignore similarity index 80% rename from packages/create-objectstack/src/templates/blank/.gitignore rename to packages/create-objectstack/src/templates/blank/_gitignore index 3f9e701fda..b937aab578 100644 --- a/packages/create-objectstack/src/templates/blank/.gitignore +++ b/packages/create-objectstack/src/templates/blank/_gitignore @@ -3,3 +3,5 @@ dist .objectstack/ *.log .DS_Store +.env +.env.* From 190e09ca8a1c21b0da1ae2b9eb788fa1b02bdc29 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:14:10 +0800 Subject: [PATCH 2/2] test(create-objectstack): widen the packing ratchet to every bundled template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file-set check only walked src/templates/blank, so a second bundled template committing a raw .gitignore — or a stripped dotfile added beside src/templates/AGENTS.md — would reintroduce the bug silently, which is the one thing this ratchet exists to prevent. Assert the tarball carries src/templates/** verbatim instead (aliases are a scaffold-time concern, so no alias mapping on either side), and keep the scaffold-based check for the alias restoration. Verified red for all three reverts: the original rename, an emptied alias map, and a new bundled template with a raw .gitignore. Co-Authored-By: Claude Opus 4.8 --- .../src/template-consistency.test.ts | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/packages/create-objectstack/src/template-consistency.test.ts b/packages/create-objectstack/src/template-consistency.test.ts index 7af1f32b9c..96e47dd284 100644 --- a/packages/create-objectstack/src/template-consistency.test.ts +++ b/packages/create-objectstack/src/template-consistency.test.ts @@ -74,13 +74,12 @@ describe('blank template manifest engines.protocol (ADR-0087 D1)', () => { }); }); -// Packing ratchet (#3120): every file the blank template ships must survive a -// real `npm pack` and land in a scaffold under its intended name. `.gitignore` -// did not — npm strips it from the tarball at every depth, so the file the -// build had faithfully copied to dist/templates/blank/ was dropped at publish -// and every scaffolded project came out with no `.gitignore`, leaving -// node_modules/ and the secret-bearing .env from the template README -// un-ignored for every new user. +// Packing ratchet (#3120): every template file must survive a real `npm pack` +// and land in a scaffold under its intended name. `.gitignore` did not — npm +// strips it from the tarball at every depth, so the file the build had +// faithfully copied to dist/templates/blank/ was dropped at publish and every +// scaffolded project came out with no `.gitignore`, leaving node_modules/ and +// the secret-bearing .env from the template README un-ignored for every user. // // This bug is invisible to source-level assertions: the file is present in // src/templates/, present in a local build, and only vanishes at publish. So @@ -89,8 +88,9 @@ describe('blank template manifest engines.protocol (ADR-0087 D1)', () => { // `test` task only dependsOn `^build` (not its own build) and excludes dist/** // from its inputs, so dist/ here is routinely absent or stale, and a pass on it // would be cached. -describe('blank template survives npm packing', () => { - const blankSrc = path.join(pkgRoot, 'src', 'templates', 'blank'); +describe('templates survive npm packing', () => { + const templatesSrc = path.join(pkgRoot, 'src', 'templates'); + const blankSrc = path.join(templatesSrc, 'blank'); const walkRel = (dir: string, rel = ''): string[] => fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { @@ -109,6 +109,7 @@ describe('blank template survives npm packing', () => { }; let tmp: string; + let packed: string[]; let scaffolded: string[]; let collected: string[]; @@ -120,14 +121,13 @@ describe('blank template survives npm packing', () => { // dist/templates, which is what tsup.config.ts's onSuccess hook does. The // test asserts that mirror below rather than assuming it. fs.cpSync(path.join(pkgRoot, 'package.json'), path.join(tmp, 'package.json')); - fs.cpSync(path.join(pkgRoot, 'src', 'templates'), path.join(tmp, 'dist', 'templates'), { - recursive: true, - }); + fs.cpSync(templatesSrc, path.join(tmp, 'dist', 'templates'), { recursive: true }); execFileSync('npm', ['pack', '--ignore-scripts'], { cwd: tmp, stdio: 'pipe' }); const tgz = fs.readdirSync(tmp).find((f) => f.endsWith('.tgz')); if (!tgz) throw new Error(`npm pack produced no tarball in ${tmp}`); execFileSync('tar', ['xzf', tgz], { cwd: tmp }); + packed = walkRel(path.join(tmp, 'package', 'dist', 'templates')); // Scaffold from the *packed* template with the real copy logic. const out = path.join(tmp, 'scaffold'); @@ -150,15 +150,21 @@ describe('blank template survives npm packing', () => { ).toContain("cpSync('src/templates', 'dist/templates', { recursive: true })"); }); - it('lands every template file — dotfiles included — in the scaffold', () => { - const expected = walkRel(blankSrc).map(scaffoldedAs).sort(); + // Covers every bundled template, not just blank: the tarball must carry + // src/templates/** verbatim (aliases are a scaffold-time concern). + it('carries every src/templates file into the tarball', () => { expect( - scaffolded.sort(), - 'a file in src/templates/blank/ did not survive `npm pack` (npm strips ' + - '.gitignore and .npmrc from tarballs at every depth). Commit it under a ' + + packed.sort(), + 'a file under src/templates/ did not survive `npm pack`. npm strips ' + + '.gitignore and .npmrc from tarballs at every depth — commit it under a ' + 'placeholder name and map it back in TEMPLATE_FILE_ALIASES, the way ' + '_gitignore → .gitignore works.', - ).toEqual(expected); + ).toEqual(walkRel(templatesSrc).sort()); + }); + + it('restores the aliased names when scaffolding', () => { + const expected = walkRel(blankSrc).map(scaffoldedAs).sort(); + expect(scaffolded.sort()).toEqual(expected); // What the CLI prints as "Created files:" must match what it actually wrote. expect(collected.sort()).toEqual(expected); });