Skip to content

Commit d7e1548

Browse files
os-zhuangclaude
andcommitted
fix(create-objectstack): ship a .gitignore in scaffolded projects
`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 <noreply@anthropic.com>
1 parent 5f05de2 commit d7e1548

5 files changed

Lines changed: 188 additions & 18 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"create-objectstack": patch
3+
---
4+
5+
Scaffolded projects ship with a `.gitignore` again — `npx create-objectstack` produced none, leaving `node_modules/` and `.env` un-ignored for every new user.
6+
7+
`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`.
8+
9+
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.
10+
11+
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.
12+
13+
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.

packages/create-objectstack/src/index.ts

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
*
88
* 1. Bundled `blank` template
99
* Lives at `dist/templates/blank/` (copied from `src/templates/blank/`
10-
* by tsup `onSuccess`). Cloned via recursive fs copy. Always available
11-
* offline.
10+
* by tsup `onSuccess`). Cloned via recursive fs copy, which also restores
11+
* the placeholder names npm strips at publish (see TEMPLATE_FILE_ALIASES).
12+
* Always available offline.
1213
*
1314
* 2. Remote content templates (`todo`, `compliance`, `content`,
1415
* `contracts`, `procurement`)
@@ -45,6 +46,7 @@ import { mkdtemp, rm } from 'node:fs/promises';
4546
import * as tar from 'tar';
4647

4748
import { syncObjectStackDeps } from './pkg-utils.js';
49+
import { copyDir } from './template-copy.js';
4850

4951
const __filename = fileURLToPath(import.meta.url);
5052
const __dirname = path.dirname(__filename);
@@ -148,21 +150,6 @@ function detectPackageManager(): string {
148150

149151
// ─── Loading: bundled (fs copy) ─────────────────────────────────────
150152

151-
function copyDir(src: string, dest: string, collected: string[], rel = '') {
152-
fs.mkdirSync(dest, { recursive: true });
153-
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
154-
const srcPath = path.join(src, entry.name);
155-
const destPath = path.join(dest, entry.name);
156-
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
157-
if (entry.isDirectory()) {
158-
copyDir(srcPath, destPath, collected, relPath);
159-
} else if (entry.isFile()) {
160-
fs.copyFileSync(srcPath, destPath);
161-
collected.push(relPath);
162-
}
163-
}
164-
}
165-
166153
function loadBundled(templateDir: string, targetDir: string): string[] {
167154
const src = path.join(BUNDLED_TEMPLATES_DIR, templateDir);
168155
if (!fs.existsSync(src)) {

packages/create-objectstack/src/template-consistency.test.ts

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
// `^6.0.0` while the registry was publishing 14.x, and the README advertised
66
// a template set (`minimal-api`/`full-stack`/`plugin`) that never shipped.
77

8-
import { describe, it, expect } from 'vitest';
8+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
99
import fs from 'node:fs';
10+
import os from 'node:os';
1011
import path from 'node:path';
1112
import { execFileSync } from 'node:child_process';
1213
import { fileURLToPath } from 'node:url';
1314
import { syncObjectStackDeps } from './pkg-utils.js';
15+
import { copyDir, TEMPLATE_FILE_ALIASES } from './template-copy.js';
1416

1517
const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
1618
const repoRoot = path.resolve(pkgRoot, '..', '..');
@@ -72,6 +74,121 @@ describe('blank template manifest engines.protocol (ADR-0087 D1)', () => {
7274
});
7375
});
7476

77+
// Packing ratchet (#3120): every file the blank template ships must survive a
78+
// real `npm pack` and land in a scaffold under its intended name. `.gitignore`
79+
// did not — npm strips it from the tarball at every depth, so the file the
80+
// build had faithfully copied to dist/templates/blank/ was dropped at publish
81+
// and every scaffolded project came out with no `.gitignore`, leaving
82+
// node_modules/ and the secret-bearing .env from the template README
83+
// un-ignored for every new user.
84+
//
85+
// This bug is invisible to source-level assertions: the file is present in
86+
// src/templates/, present in a local build, and only vanishes at publish. So
87+
// pack for real and scaffold from the extracted tarball with the real copyDir.
88+
// Reading the repo's own dist/ instead would be doubly false green — turbo's
89+
// `test` task only dependsOn `^build` (not its own build) and excludes dist/**
90+
// from its inputs, so dist/ here is routinely absent or stale, and a pass on it
91+
// would be cached.
92+
describe('blank template survives npm packing', () => {
93+
const blankSrc = path.join(pkgRoot, 'src', 'templates', 'blank');
94+
95+
const walkRel = (dir: string, rel = ''): string[] =>
96+
fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
97+
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
98+
if (entry.isDirectory()) return walkRel(path.join(dir, entry.name), relPath);
99+
return entry.isFile() ? [relPath] : [];
100+
});
101+
102+
// The name a template file is expected to land under, i.e. its alias applied
103+
// to the basename.
104+
const scaffoldedAs = (rel: string): string => {
105+
const parts = rel.split('/');
106+
const base = parts[parts.length - 1];
107+
parts[parts.length - 1] = TEMPLATE_FILE_ALIASES.get(base) ?? base;
108+
return parts.join('/');
109+
};
110+
111+
let tmp: string;
112+
let scaffolded: string[];
113+
let collected: string[];
114+
115+
beforeAll(() => {
116+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'create-objectstack-pack-'));
117+
118+
// Stage exactly what a publish would: the *real* package.json (so the real
119+
// `files` allowlist decides what ships) plus src/templates copied to
120+
// dist/templates, which is what tsup.config.ts's onSuccess hook does. The
121+
// test asserts that mirror below rather than assuming it.
122+
fs.cpSync(path.join(pkgRoot, 'package.json'), path.join(tmp, 'package.json'));
123+
fs.cpSync(path.join(pkgRoot, 'src', 'templates'), path.join(tmp, 'dist', 'templates'), {
124+
recursive: true,
125+
});
126+
127+
execFileSync('npm', ['pack', '--ignore-scripts'], { cwd: tmp, stdio: 'pipe' });
128+
const tgz = fs.readdirSync(tmp).find((f) => f.endsWith('.tgz'));
129+
if (!tgz) throw new Error(`npm pack produced no tarball in ${tmp}`);
130+
execFileSync('tar', ['xzf', tgz], { cwd: tmp });
131+
132+
// Scaffold from the *packed* template with the real copy logic.
133+
const out = path.join(tmp, 'scaffold');
134+
collected = [];
135+
copyDir(path.join(tmp, 'package', 'dist', 'templates', 'blank'), out, collected);
136+
scaffolded = walkRel(out);
137+
}, 120_000);
138+
139+
afterAll(() => {
140+
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
141+
});
142+
143+
it('stages dist/templates the way this suite mirrors it', () => {
144+
const tsupConfig = fs.readFileSync(path.join(pkgRoot, 'tsup.config.ts'), 'utf8');
145+
expect(
146+
tsupConfig.replace(/\s+/g, ' '),
147+
'the packing ratchet stages dist/templates by hand because dist/ is not ' +
148+
'built here; if the build stopped copying src/templates → dist/templates ' +
149+
'that mirror is now a fiction — update both together',
150+
).toContain("cpSync('src/templates', 'dist/templates', { recursive: true })");
151+
});
152+
153+
it('lands every template file — dotfiles included — in the scaffold', () => {
154+
const expected = walkRel(blankSrc).map(scaffoldedAs).sort();
155+
expect(
156+
scaffolded.sort(),
157+
'a file in src/templates/blank/ did not survive `npm pack` (npm strips ' +
158+
'.gitignore and .npmrc from tarballs at every depth). Commit it under a ' +
159+
'placeholder name and map it back in TEMPLATE_FILE_ALIASES, the way ' +
160+
'_gitignore → .gitignore works.',
161+
).toEqual(expected);
162+
// What the CLI prints as "Created files:" must match what it actually wrote.
163+
expect(collected.sort()).toEqual(expected);
164+
});
165+
166+
// Not redundant with the file-set check above: that one applies the alias map
167+
// to both sides, so emptying TEMPLATE_FILE_ALIASES makes it agree with itself
168+
// on `_gitignore` and pass. Naming the destination literally is what pins the
169+
// mapping down.
170+
it('ships a .gitignore that ignores node_modules and the README secrets', () => {
171+
const gitignore = path.join(tmp, 'scaffold', '.gitignore');
172+
expect(fs.existsSync(gitignore), 'scaffold has no .gitignore').toBe(true);
173+
174+
const rules = fs.readFileSync(gitignore, 'utf8').split('\n').map((l) => l.trim());
175+
expect(rules).toContain('node_modules');
176+
// The template README has users write OS_AUTH_SECRET / OS_SECRET_KEY into a
177+
// .env, and docker-compose.yml calls it "never committed" — so the ignore
178+
// file, not just the prose, has to enforce that.
179+
expect(rules).toContain('.env');
180+
});
181+
182+
it('leaves a literal template dotfile that packs fine alone', () => {
183+
// .dockerignore is NOT stripped — verified against the published 15.1.1
184+
// tarball, which ships it while .gitignore is absent. It stays literal, so
185+
// the alias map covers only what is genuinely broken.
186+
expect(fs.existsSync(path.join(blankSrc, '.dockerignore'))).toBe(true);
187+
expect(TEMPLATE_FILE_ALIASES.has('.dockerignore')).toBe(false);
188+
expect(scaffolded).toContain('.dockerignore');
189+
});
190+
});
191+
75192
describe('README template table', () => {
76193
it('lists exactly the templates in the TEMPLATES registry', () => {
77194
const readme = fs.readFileSync(path.join(pkgRoot, 'README.md'), 'utf8');
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
2+
//
3+
// Materializes a bundled template onto disk. Kept out of index.ts because that
4+
// module calls `program.parse()` on import — anything a test needs must be
5+
// importable without running the CLI.
6+
7+
import fs from 'node:fs';
8+
import path from 'node:path';
9+
10+
/**
11+
* Template files committed under a placeholder name, mapped to the name they
12+
* must land under in a scaffolded project.
13+
*
14+
* `npm pack` / `pnpm pack` strip `.gitignore` (and `.npmrc`) from a tarball
15+
* unconditionally, at every depth — so a template dotfile committed under its
16+
* real name never reaches the registry. `src/templates/blank/.gitignore` was
17+
* copied to `dist/templates/blank/.gitignore` by the build and then dropped at
18+
* publish, and every project scaffolded from the published package came out
19+
* with no `.gitignore` at all, leaving `node_modules/` and the `.env` the
20+
* README tells users to fill with secrets un-ignored (#3120).
21+
*
22+
* The strip list is not "every dotfile": `.dockerignore` packs fine and stays
23+
* literal. Don't add entries by guesswork — the packing ratchet in
24+
* `template-consistency.test.ts` packs the real package and fails with the
25+
* required alias if a template file goes missing from the tarball.
26+
*/
27+
export const TEMPLATE_FILE_ALIASES = new Map<string, string>([
28+
['_gitignore', '.gitignore'],
29+
]);
30+
31+
/**
32+
* Recursively copy `src` onto `dest`, restoring aliased filenames, pushing the
33+
* project-relative path of every written file onto `collected`.
34+
*/
35+
export function copyDir(src: string, dest: string, collected: string[], rel = '') {
36+
fs.mkdirSync(dest, { recursive: true });
37+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
38+
const srcPath = path.join(src, entry.name);
39+
const outName = entry.isFile()
40+
? (TEMPLATE_FILE_ALIASES.get(entry.name) ?? entry.name)
41+
: entry.name;
42+
const destPath = path.join(dest, outName);
43+
const relPath = rel ? `${rel}/${outName}` : outName;
44+
if (entry.isDirectory()) {
45+
copyDir(srcPath, destPath, collected, relPath);
46+
} else if (entry.isFile()) {
47+
fs.copyFileSync(srcPath, destPath);
48+
collected.push(relPath);
49+
}
50+
}
51+
}

packages/create-objectstack/src/templates/blank/.gitignore renamed to packages/create-objectstack/src/templates/blank/_gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@ dist
33
.objectstack/
44
*.log
55
.DS_Store
6+
.env
7+
.env.*

0 commit comments

Comments
 (0)