Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/scaffold-gitignore-survives-publish.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 4 additions & 17 deletions packages/create-objectstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)) {
Expand Down
125 changes: 124 additions & 1 deletion packages/create-objectstack/src/template-consistency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '..', '..');
Expand Down Expand Up @@ -72,6 +74,127 @@ describe('blank template manifest engines.protocol (ADR-0087 D1)', () => {
});
});

// 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
// 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('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) => {
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 packed: 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(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');
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 })");
});

// 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(
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(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);
});

// 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');
});
});

// pnpm 11 turned an unapproved dependency build script from a warning into a
// hard error, so the template declaring nothing meant `npx create-objectstack`
// + `pnpm install` exited 1 for every user on a current pnpm (#3119). Both keys
Expand Down
51 changes: 51 additions & 0 deletions packages/create-objectstack/src/template-copy.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>([
['_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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ dist
.objectstack/
*.log
.DS_Store
.env
.env.*