Skip to content

Commit ad224b7

Browse files
fix(create-objectstack)!: refuse a current-dir scaffold that would overwrite existing files (#29267)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9CP9N29Ty9eo6hBrrMoNuhG)_ Fixes #29134 Clause-②: no (narrowing) — measured: on base `49aba82b` a no-name and an `""` run in a current directory holding `README.md` + `package.json` exited 0 and replaced both files; on head `791e47c3` the same runs exit 1, name both files, and leave the directory byte-identical. Nothing is widened. Metadata-protocol: none ## 维护者速读 - 改了什么:在当前目录运行 `create-objectstack`(不带名字或名字为空)时,若模板文件会覆盖目录里已有的文件,脚手架直接拒绝(退出码 1),列出这些文件,什么都不写。 - 为什么改:以前当前目录完全不检查,用户已有的 README.md 和 package.json 会被静默覆盖且退出码为 0;而指定名字的非空目录早就会被拒绝。 - 风险与回滚:只多拒绝一种情形;只含模板不写的文件(如 `.git/`、笔记文件)的目录照常生成。回滚即撤销本 PR。 - 先看:`packages/create-objectstack/src/index.ts` 的碰撞拒绝块;`src/template-copy.ts` 的 `findTemplateCollisions`;新测试 `src/scaffold-current-dir-collision.test.ts`。 - 两分钟验证:在含 README.md 的空项目目录里跑 `npx tsx packages/create-objectstack/src/index.ts --skip-install --skip-skills`,应看到退出码 1、列出 README.md,文件内容不变。 - 需要你做的:无,席位落地。 ## What changes - `packages/create-objectstack/src/template-copy.ts`: new read-only `findTemplateCollisions(src, dest)` walks the template the way `copyDir` does (same alias restoration, so `_gitignore` is checked as `.gitignore`) and returns every project-relative path that already exists where the template writes a file, or where a non-directory stands in place of a template directory. `copyDir` itself is unchanged apart from sharing the alias helper. - `packages/create-objectstack/src/index.ts`: before the first write (before `mkdirSync` and the copy), a non-empty target is checked; any collision prints `Directory is not empty and the template would overwrite N existing files: DIR`, lists each path, says nothing was written and names the two ways out (move the files aside, or scaffold into a new named directory), then exits 1. The named non-empty refusal (`Directory already exists and is not empty`) is untouched and still runs first; an empty target skips the walk. - Rule shape: the one the card prefers, scoped to collisions. A current directory holding only files the template does not write still scaffolds and keeps those files, which is what the existing comment and the "the directory already had contents" summary already intend. - `.changeset/29134-current-dir-overwrite-refusal.md`: `create-objectstack` patch, BREAKING banner, both declaration lines, ADR-0087 `not-required (no-migration-prescription)`. ## Tests New `src/scaffold-current-dir-collision.test.ts` (8 cases, real CLI through `tsx`, the no-build pattern of `scaffold-description.test.ts`): - no name and `""` in a directory with a colliding `README.md` / `package.json` plus a non-colliding `notes.txt`: exit 1, the whole tree byte-identical, both paths named, `notes.txt` not named; - a directory holding only `.git/HEAD` and `notes.txt`: scaffolds (exit 0, `package.json` name is the directory name), both files untouched; - an empty current directory: scaffolds as before (`Created files:`); - a named non-empty directory: the old refusal and message, nothing written; - `findTemplateCollisions` unit cases: the aliased `.gitignore`, a file standing where the template needs `src/`, a shared `src/` with only user files (no collision). No existing test needed editing. ## Acceptance notes - No new CLI flag: a `--force` (overwrite on purpose) is a product choice and is left to the maintainer; see the report's open question. - Out of the template copy, two writers are not covered by this check and already do not overwrite: `AGENTS.md` and `.github/copilot-instructions.md` are written with exclusive create. The package-manager install and the skills installer write what they write, as before. ## Verification <details><summary>Premise on base 49aba82 (card's reproduction)</summary> No-name run and `""` run in a temp `my-app/` holding `README.md` (`# my notes`) and `package.json` (`{"name":"mine"}`): both `EXIT=0`, `README.md` now starts `# My App`, `package.json` now `"name": "my-app"`. Named non-empty dir: `EXIT=1`, `Directory already exists and is not empty`. </details> <details><summary>Head 791e47c, same reproduction</summary> Both runs `EXIT=1`, files unchanged, output names `README.md` and `package.json` and says nothing was written. </details> <details><summary>Ablation of the refusal (negative pin)</summary> `scripts/ablation-replace.mjs` on `index.ts`, anchor `if (collisions.length > 0) {` x1 -> x0, replacement x0 -> x1, blob `c402bf41f8e7` -> `ee6ac763dfa4`. The test file goes red: `× refuses with no name …`, `× refuses with an empty name …`, `AssertionError: expected +0 to be 1`, `Tests 2 failed | 6 passed (8)`. Restored: blob == HEAD `c402bf41f8e7`, `git diff HEAD` empty. The suite runs `src/` through `tsx` and vitest with no `dist/` in the path, so no rebuild leg applies. </details> <details><summary>Package suite, typecheck, lint, gates, dogfood at 791e47c</summary> - `os-verify-lock.sh -c "pnpm --filter 'create-objectstack^...' build && pnpm --filter create-objectstack build && pnpm --filter create-objectstack test && pnpm --filter create-objectstack typecheck"`: VERDICT command-exit 0; `Test Files 23 passed (23)`, `Tests 356 passed (356)`; `tsc --noEmit` lists the new test file (count 1). - eslint over `packages/create-objectstack` with `--no-inline-config` (the `pnpm lint` command, package-scoped): exit 0. - `dispatch-gates.mjs --commands`: 57 derived families, all exit 0; `--ran` reconciliation: 57 derived, 57 run, 0 NOT-MEASURED (derived zero, every line carries its exit code). Three first exited 3 on prerequisites and were re-run green: `check:type-check-debt` and `check:dual-build-cjs-loads` after the dogfood closure build, `check-plugin-teardown-shape --self-test` after fetching its pinned fixture commit. - Full dogfood (owed by the narrowing arm): VERDICT command-exit 0, `Test Files 388 passed | 3 skipped (391)`, `Tests 3208 passed | 28 skipped (3236)`. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmQNsRpYUsG5efyi5Ur)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3e110d6 commit ad224b7

4 files changed

Lines changed: 249 additions & 6 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'create-objectstack': patch
3+
---
4+
5+
fix(create-objectstack)!: a scaffold into the current directory refuses, writing nothing, when a template file would overwrite one already there (#29134)
6+
7+
Clause-②: no (narrowing) — `create-objectstack` with no name (or an empty one) in a current directory holding a file the template writes (`README.md`, `package.json`, …) refuses with exit 1 and leaves the directory untouched, where it overwrote those files and exited 0 before. Nothing is widened.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema, no export of a published entry and no stored shape changes: the scaffolder's command line refuses one more case before it writes, so there is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** for a script that runs `create-objectstack` with no name inside a directory that already holds files the template writes.
13+
14+
- **Before.** A named directory that was not empty was refused (`Directory already exists and is not empty`, exit 1), but the current directory was not checked at all: the template was copied over whatever was there, so an existing project's `README.md` and `package.json` were replaced without a word and the run exited 0.
15+
- **Now.** Before anything is written, the run compares the template's files with what the current directory holds. If any template file would replace an existing file (or a file stands where the template needs a directory), it refuses with exit 1, names each of those paths, and writes nothing.
16+
- **Unchanged.** A current directory that holds only files the template does not write (a `.git/`, a notes file) still scaffolds, and those files are left as they were; the closing summary still says the directory already had contents. An empty current directory and a named directory behave as before.
17+
18+
**The remedy.** Move the named files aside and re-run, or scaffold into a new directory: `create-objectstack my-app`.

‎packages/create-objectstack/src/index.ts‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ import { detectPackageManager } from './detect-package-manager.js';
7171
import { fileURLToPath } from 'node:url';
7272

7373
import { syncObjectStackDeps } from './pkg-utils.js';
74-
import { copyDir } from './template-copy.js';
74+
import { copyDir, findTemplateCollisions } from './template-copy.js';
7575
import {
7676
deriveManifestId,
7777
sanitizeNamespace,
@@ -132,13 +132,17 @@ function printWarning(msg: string) { console.log(chalk.yellow(` ⚠ ${msg}`));
132132

133133
// ─── Loading: bundled (fs copy) ─────────────────────────────────────
134134

135-
function loadBundled(templateDir: string, targetDir: string): string[] {
135+
function bundledTemplateDir(templateDir: string): string {
136136
const src = path.join(BUNDLED_TEMPLATES_DIR, templateDir);
137137
if (!fs.existsSync(src)) {
138138
throw new Error(`Bundled template missing on disk: ${src}`);
139139
}
140+
return src;
141+
}
142+
143+
function loadBundled(templateDir: string, targetDir: string): string[] {
140144
const collected: string[] = [];
141-
copyDir(src, targetDir, collected);
145+
copyDir(bundledTemplateDir(templateDir), targetDir, collected);
142146
return collected;
143147
}
144148

@@ -560,6 +564,31 @@ const program = new Command()
560564
const targetWasEmpty = topLevelNames(targetDir).size === 0;
561565

562566
try {
567+
// The current directory gets the named directory's protection, scoped to
568+
// collisions: files the template does not write (a `.git/`, a notes
569+
// file) may stay, but a template file that would replace one the user
570+
// already has refuses the whole run — checked before the first write, so
571+
// the refusal leaves the directory exactly as it was. Without it a run
572+
// with no name in an existing project replaced its README.md and
573+
// package.json and exited 0.
574+
const collisions = targetWasEmpty
575+
? []
576+
: findTemplateCollisions(bundledTemplateDir(template.source.dir), targetDir);
577+
if (collisions.length > 0) {
578+
printError(
579+
`Directory is not empty and the template would overwrite ${collisions.length} existing ` +
580+
`${collisions.length === 1 ? 'file' : 'files'}: ${targetDir}`,
581+
);
582+
for (const rel of collisions) console.log(chalk.dim(` ${rel}`));
583+
console.log(
584+
chalk.dim(
585+
' Nothing was written. Move those files aside, or scaffold into a new directory: ' +
586+
'create-objectstack <name>',
587+
),
588+
);
589+
process.exit(1);
590+
}
591+
563592
fs.mkdirSync(targetDir, { recursive: true });
564593

565594
const createdFiles = loadBundled(template.source.dir, targetDir);
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
2+
//
3+
// A scaffold into the current directory (no name, or an empty one) used to
4+
// copy the template straight over whatever was there: an existing project's
5+
// README.md and package.json were replaced and the run exited 0, while a named
6+
// non-empty directory was refused. The current directory now gets the same
7+
// protection, scoped to collisions — a template file that would replace an
8+
// existing one refuses the run before anything is written; files the template
9+
// does not write may stay.
10+
//
11+
// Runs the real CLI through `tsx` (index.ts calls `program.parse()` on import,
12+
// so it cannot be imported), the no-build pattern scaffold-description.test.ts
13+
// uses.
14+
15+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
16+
import { spawnSync } from 'node:child_process';
17+
import fs from 'node:fs';
18+
import os from 'node:os';
19+
import path from 'node:path';
20+
import { fileURLToPath } from 'node:url';
21+
import { findTemplateCollisions } from './template-copy.js';
22+
23+
const HERE = path.dirname(fileURLToPath(import.meta.url));
24+
const PKG_ROOT = path.resolve(HERE, '..');
25+
const REPO_ROOT = path.resolve(PKG_ROOT, '..', '..');
26+
const TSX = path.join(REPO_ROOT, 'node_modules', '.bin', 'tsx');
27+
const INDEX_TS = path.join(PKG_ROOT, 'src', 'index.ts');
28+
const BLANK_TEMPLATE = path.join(PKG_ROOT, 'src', 'templates', 'blank');
29+
30+
const USER_README = '# My notes\n\nWritten by hand, not by the scaffolder.\n';
31+
const USER_PACKAGE_JSON = '{ "name": "my-existing-thing", "version": "3.1.4" }\n';
32+
const USER_NOTES = 'keep me\n';
33+
34+
let tmp: string;
35+
let projectDir: string;
36+
37+
beforeEach(() => {
38+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'create-objectstack-collide-'));
39+
projectDir = path.join(tmp, 'my-app');
40+
fs.mkdirSync(projectDir);
41+
});
42+
43+
afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
44+
45+
function run(cwd: string, nameArgs: string[]) {
46+
const result = spawnSync(
47+
TSX,
48+
[INDEX_TS, ...nameArgs, '--template', 'blank', '--skip-install', '--skip-skills'],
49+
{ cwd, encoding: 'utf8', env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' } },
50+
);
51+
return { status: result.status, out: `${result.stdout}${result.stderr}` };
52+
}
53+
54+
// Every path under `dir` with its bytes, so "nothing was written" is checked
55+
// against the whole tree, not only the files a test happens to name.
56+
function snapshot(dir: string): Record<string, string> {
57+
const out: Record<string, string> = {};
58+
const walk = (abs: string, rel: string) => {
59+
for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
60+
const childAbs = path.join(abs, entry.name);
61+
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
62+
if (entry.isDirectory()) {
63+
out[`${childRel}/`] = '';
64+
walk(childAbs, childRel);
65+
} else {
66+
out[childRel] = fs.readFileSync(childAbs, 'utf8');
67+
}
68+
}
69+
};
70+
walk(dir, '');
71+
return out;
72+
}
73+
74+
describe('scaffold into the current directory', () => {
75+
for (const [label, nameArgs] of [
76+
['no name', []],
77+
['an empty name', ['']],
78+
] as const) {
79+
it(`refuses with ${label} when template files would overwrite existing ones, writing nothing`, () => {
80+
fs.writeFileSync(path.join(projectDir, 'README.md'), USER_README);
81+
fs.writeFileSync(path.join(projectDir, 'package.json'), USER_PACKAGE_JSON);
82+
fs.writeFileSync(path.join(projectDir, 'notes.txt'), USER_NOTES);
83+
const before = snapshot(projectDir);
84+
85+
const { status, out } = run(projectDir, [...nameArgs]);
86+
87+
expect(status).toBe(1);
88+
expect(snapshot(projectDir)).toEqual(before);
89+
expect(out).toContain('the template would overwrite 2 existing files');
90+
expect(out).toMatch(/^\s+README\.md$/m);
91+
expect(out).toMatch(/^\s+package\.json$/m);
92+
expect(out).not.toMatch(/^\s+notes\.txt$/m);
93+
expect(out).not.toContain('Environment created!');
94+
}, 30_000);
95+
}
96+
97+
it('scaffolds when the directory only holds files the template does not write, leaving them untouched', () => {
98+
fs.mkdirSync(path.join(projectDir, '.git'));
99+
fs.writeFileSync(path.join(projectDir, '.git', 'HEAD'), 'ref: refs/heads/main\n');
100+
fs.writeFileSync(path.join(projectDir, 'notes.txt'), USER_NOTES);
101+
102+
const { status, out } = run(projectDir, []);
103+
104+
expect(status).toBe(0);
105+
expect(out).toContain('Environment created!');
106+
expect(out).toContain('the directory already had contents');
107+
expect(JSON.parse(fs.readFileSync(path.join(projectDir, 'package.json'), 'utf8')).name).toBe(
108+
'my-app',
109+
);
110+
expect(fs.readFileSync(path.join(projectDir, 'notes.txt'), 'utf8')).toBe(USER_NOTES);
111+
expect(fs.readFileSync(path.join(projectDir, '.git', 'HEAD'), 'utf8')).toBe(
112+
'ref: refs/heads/main\n',
113+
);
114+
}, 30_000);
115+
116+
it('scaffolds an empty current directory as before', () => {
117+
const { status, out } = run(projectDir, []);
118+
119+
expect(status).toBe(0);
120+
expect(out).toContain('Created files:');
121+
expect(fs.existsSync(path.join(projectDir, 'objectstack.config.ts'))).toBe(true);
122+
}, 30_000);
123+
});
124+
125+
describe('scaffold into a named directory', () => {
126+
it('still refuses a named non-empty directory with the old message', () => {
127+
fs.writeFileSync(path.join(projectDir, 'notes.txt'), USER_NOTES);
128+
const before = snapshot(projectDir);
129+
130+
const { status, out } = run(tmp, ['my-app']);
131+
132+
expect(status).toBe(1);
133+
expect(out).toContain(`Directory already exists and is not empty: ${projectDir}`);
134+
expect(snapshot(projectDir)).toEqual(before);
135+
}, 30_000);
136+
});
137+
138+
describe('findTemplateCollisions', () => {
139+
it('names aliased dotfiles by the name they land under', () => {
140+
fs.writeFileSync(path.join(projectDir, '.gitignore'), 'node_modules\n');
141+
expect(findTemplateCollisions(BLANK_TEMPLATE, projectDir)).toEqual(['.gitignore']);
142+
});
143+
144+
it('reports a file standing where the template needs a directory', () => {
145+
fs.writeFileSync(path.join(projectDir, 'src'), 'not a directory\n');
146+
expect(findTemplateCollisions(BLANK_TEMPLATE, projectDir)).toEqual(['src']);
147+
});
148+
149+
it('looks inside a directory the template shares with the user', () => {
150+
fs.mkdirSync(path.join(projectDir, 'src'));
151+
fs.writeFileSync(path.join(projectDir, 'src', 'mine.ts'), 'export {};\n');
152+
expect(findTemplateCollisions(BLANK_TEMPLATE, projectDir)).toEqual([]);
153+
});
154+
});

‎packages/create-objectstack/src/template-copy.ts‎

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,50 @@ export const TEMPLATE_FILE_ALIASES = new Map<string, string>([
2828
['_gitignore', '.gitignore'],
2929
]);
3030

31+
// The name a template entry lands under in the scaffolded project.
32+
function outputName(entry: fs.Dirent): string {
33+
return entry.isFile()
34+
? (TEMPLATE_FILE_ALIASES.get(entry.name) ?? entry.name)
35+
: entry.name;
36+
}
37+
38+
/**
39+
* The project-relative paths in `dest` that copying `src` onto it would
40+
* overwrite, or that stand where the template needs a directory. Empty means
41+
* `copyDir` can run without replacing anything the user already has.
42+
*
43+
* Read-only, and meant to run BEFORE the first write: a scaffold into a
44+
* directory that already has contents (the current directory is the one path
45+
* that allows that) must refuse with the directory byte-identical, not stop
46+
* halfway through the copy. Files the template does not write — a `.git/`, a
47+
* notes file — are not collisions and are left alone by `copyDir`.
48+
*/
49+
export function findTemplateCollisions(src: string, dest: string, rel = ''): string[] {
50+
const collisions: string[] = [];
51+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
52+
const outName = outputName(entry);
53+
const destPath = path.join(dest, outName);
54+
const relPath = rel ? `${rel}/${outName}` : outName;
55+
let existing: fs.Stats | undefined;
56+
try {
57+
existing = fs.lstatSync(destPath);
58+
} catch {
59+
existing = undefined;
60+
}
61+
if (entry.isDirectory()) {
62+
if (!existing) continue;
63+
if (existing.isDirectory()) {
64+
collisions.push(...findTemplateCollisions(path.join(src, entry.name), destPath, relPath));
65+
} else {
66+
collisions.push(relPath);
67+
}
68+
} else if (entry.isFile() && existing) {
69+
collisions.push(relPath);
70+
}
71+
}
72+
return collisions;
73+
}
74+
3175
/**
3276
* Recursively copy `src` onto `dest`, restoring aliased filenames, pushing the
3377
* project-relative path of every written file onto `collected`.
@@ -36,9 +80,7 @@ export function copyDir(src: string, dest: string, collected: string[], rel = ''
3680
fs.mkdirSync(dest, { recursive: true });
3781
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
3882
const srcPath = path.join(src, entry.name);
39-
const outName = entry.isFile()
40-
? (TEMPLATE_FILE_ALIASES.get(entry.name) ?? entry.name)
41-
: entry.name;
83+
const outName = outputName(entry);
4284
const destPath = path.join(dest, outName);
4385
const relPath = rel ? `${rel}/${outName}` : outName;
4486
if (entry.isDirectory()) {

0 commit comments

Comments
 (0)