diff --git a/.changeset/refuse-non-empty-scaffold-target.md b/.changeset/refuse-non-empty-scaffold-target.md new file mode 100644 index 00000000..01126144 --- /dev/null +++ b/.changeset/refuse-non-empty-scaffold-target.md @@ -0,0 +1,5 @@ +--- +'create-vercel-shop': minor +--- + +Refuse to scaffold the template into a directory that already contains files. Previously the CLI created the target with `mkdir({ recursive: true })` and copied the template straight in, so pointing it at an existing project silently overwrote same-named files before `install` and `git init` ran. The CLI now inspects the target first: an empty or missing directory proceeds as before, a non-empty one asks for confirmation on a TTY (defaulting to no) and exits with code 1 in non-interactive environments such as CI or a coding agent. Pass the new `--force` flag to scaffold into a non-empty directory anyway. `--no-template` is unaffected — adding agent assets to an existing project is its purpose. A target path that exists but is not a directory now reports a clear error instead of crashing. diff --git a/apps/cli/index.mjs b/apps/cli/index.mjs index 4200c498..1d9cede4 100644 --- a/apps/cli/index.mjs +++ b/apps/cli/index.mjs @@ -18,6 +18,7 @@ import { createInterface } from 'node:readline/promises'; import { pathToFileURL } from 'node:url'; export const NO_TEMPLATE_FLAG = '--no-template'; +export const FORCE_FLAG = '--force'; export const DEFAULT_PROJECT_NAME = 'my-shop'; export const TEMPLATE_TARBALL_URL = 'https://codeload.github.com/vercel/shop/tar.gz/refs/heads/main'; @@ -31,7 +32,11 @@ const PACKAGE_MANAGER_FLAGS = { '--use-pnpm': 'pnpm', '--use-yarn': 'yarn', }; -const INTERNAL_FLAGS = new Set([NO_TEMPLATE_FLAG, ...Object.keys(PACKAGE_MANAGER_FLAGS)]); +const INTERNAL_FLAGS = new Set([ + FORCE_FLAG, + NO_TEMPLATE_FLAG, + ...Object.keys(PACKAGE_MANAGER_FLAGS), +]); export function explicitPackageManager(args) { for (const arg of args) { @@ -75,12 +80,30 @@ export async function promptProjectName({ } } +export async function promptOverwrite({ + entries = [], + input = process.stdin, + output = process.stdout, + projectDir, +} = {}) { + const rl = createInterface({ input, output }); + try { + const answer = await rl.question( + `${projectDir} already contains ${describeEntries(entries)}. Scaffolding may overwrite existing files. Continue? (y/N) `, + ); + return /^y(es)?$/i.test(answer.trim()); + } finally { + rl.close(); + } +} + export function createExecutionPlan({ cliArgs, cwd = process.cwd(), execPath = process.env.npm_execpath ?? '', userAgent = process.env.npm_config_user_agent ?? '', } = {}) { + const force = cliArgs.includes(FORCE_FLAG); const noTemplate = cliArgs.includes(NO_TEMPLATE_FLAG); const packageManager = explicitPackageManager(cliArgs) ?? @@ -90,7 +113,7 @@ export function createExecutionPlan({ cliArgs.filter((arg) => !INTERNAL_FLAGS.has(arg)), ); - return { cwd, noTemplate, packageManager, positionalName }; + return { cwd, force, noTemplate, packageManager, positionalName }; } export async function readTemplateVersion(importMetaUrl = import.meta.url) { @@ -220,6 +243,25 @@ export async function fetchTemplate( } } +// Missing target is the happy path — it becomes an empty directory below. +// Anything else (a file at that path, EACCES, …) is surfaced to the caller so +// the CLI stops instead of copying the template on top of it. +export async function readTargetEntries(projectDir) { + try { + return await readdir(projectDir); + } catch (error) { + if (error?.code === 'ENOENT') return []; + throw error; + } +} + +function describeEntries(entries) { + const shown = [...entries].sort().slice(0, 5); + const remaining = entries.length - shown.length; + const listed = shown.join(', '); + return remaining > 0 ? `${listed}, and ${remaining} more` : listed; +} + export async function ensureProjectDir(projectDir) { await mkdir(projectDir, { recursive: true }); } @@ -259,6 +301,7 @@ export function printAgentSetupSummary() { export async function main({ cliArgs = process.argv.slice(2), + confirmOverwrite = promptOverwrite, cwd = process.cwd(), execPath = process.env.npm_execpath ?? '', importMetaUrl = import.meta.url, @@ -283,6 +326,41 @@ export async function main({ const projectDir = projectName ? resolve(plan.cwd, projectName) : plan.cwd; + // A full scaffold copies the template over whatever is already there, so it + // has to run against an empty (or newly created) directory. `--no-template` + // is exempt: it only adds agent assets, and existing projects are its whole + // point. + if (!plan.noTemplate) { + let entries; + try { + entries = await readTargetEntries(projectDir); + } catch (error) { + console.error(`\nCannot scaffold into ${projectDir}.`); + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } + + if (entries.length > 0) { + if (plan.force) { + console.warn( + `\nScaffolding into non-empty ${projectDir} because ${FORCE_FLAG} was passed. Existing files may be overwritten.`, + ); + } else if (!isTTY) { + console.error(`\n${projectDir} is not empty (${describeEntries(entries)}).`); + console.error( + `Scaffolding would overwrite files that are already there. Pick an empty target directory, or pass ${FORCE_FLAG} to scaffold into this one anyway.`, + ); + return 1; + } else { + const proceed = await confirmOverwrite({ entries, projectDir }); + if (!proceed) { + console.error('\nAborted. No files were written.'); + return 1; + } + } + } + } + await ensureProjectDir(projectDir); if (plan.noTemplate) { diff --git a/apps/cli/index.test.mjs b/apps/cli/index.test.mjs index f2fada0f..7f313c00 100644 --- a/apps/cli/index.test.mjs +++ b/apps/cli/index.test.mjs @@ -7,8 +7,10 @@ import test from 'node:test'; import { COMMANDS_TARBALL_PREFIX, createExecutionPlan, + FORCE_FLAG, inlineAgentAssets, main, + readTargetEntries, readTemplateVersion, SKILLS_TARBALL_PREFIX, } from './index.mjs'; @@ -36,6 +38,22 @@ test('createExecutionPlan finds the positional project name and ignores internal assert.equal(plan.packageManager, 'bun'); }); +test('createExecutionPlan parses --force without treating it as the project name', () => { + const plan = createExecutionPlan({ + cliArgs: [FORCE_FLAG, 'my-store'], + cwd: '/tmp/workspace', + }); + + assert.equal(plan.force, true); + assert.equal(plan.positionalName, 'my-store'); +}); + +test('createExecutionPlan defaults force to false', () => { + const plan = createExecutionPlan({ cliArgs: ['my-store'], cwd: '/tmp/workspace' }); + + assert.equal(plan.force, false); +}); + test('createExecutionPlan falls back to npm when nothing is detected', () => { const plan = createExecutionPlan({ cliArgs: [], @@ -218,6 +236,242 @@ test('main scaffolds, installs deps, inits git, and writes bootstrap metadata', } }); +test('readTargetEntries reports an absent directory as empty', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'create-vercel-shop-')); + + try { + assert.deepEqual(await readTargetEntries(join(tempRoot, 'nope')), []); + assert.deepEqual(await readTargetEntries(tempRoot), []); + + await writeFile(join(tempRoot, 'file.txt'), 'hi\n', 'utf8'); + assert.deepEqual(await readTargetEntries(tempRoot), ['file.txt']); + + await assert.rejects(() => readTargetEntries(join(tempRoot, 'file.txt'))); + } finally { + await rm(tempRoot, { force: true, recursive: true }); + } +}); + +test('main refuses to scaffold into a non-empty directory when not a TTY', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'create-vercel-shop-')); + const projectDir = join(tempRoot, 'existing-project'); + const existingFile = join(projectDir, 'package.json'); + const calls = []; + const scaffoldDirs = []; + + try { + await mkdir(projectDir, { recursive: true }); + await writeFile(existingFile, '{ "name": "mine" }\n', 'utf8'); + + const exitCode = await main({ + cliArgs: ['existing-project'], + confirmOverwrite: async () => { + throw new Error('should not prompt without a TTY'); + }, + cwd: tempRoot, + isTTY: false, + run: async (command, args, options = {}) => { + calls.push({ args, command, options }); + return 0; + }, + scaffold: async (dir) => { + scaffoldDirs.push(dir); + }, + }); + + assert.equal(exitCode, 1); + assert.deepEqual(scaffoldDirs, [], 'expected no scaffold into a non-empty directory'); + assert.deepEqual(calls, [], 'expected no install or git subprocesses'); + assert.equal(await readFile(existingFile, 'utf8'), '{ "name": "mine" }\n'); + } finally { + await rm(tempRoot, { force: true, recursive: true }); + } +}); + +test('main treats a directory holding only dotfiles as non-empty', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'create-vercel-shop-')); + const projectDir = join(tempRoot, 'existing-project'); + const scaffoldDirs = []; + + try { + await mkdir(join(projectDir, '.git'), { recursive: true }); + + const exitCode = await main({ + cliArgs: ['existing-project'], + cwd: tempRoot, + isTTY: false, + run: async () => 0, + scaffold: async (dir) => { + scaffoldDirs.push(dir); + }, + }); + + assert.equal(exitCode, 1); + assert.deepEqual(scaffoldDirs, []); + } finally { + await rm(tempRoot, { force: true, recursive: true }); + } +}); + +test('main scaffolds into an existing but empty directory without confirmation', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'create-vercel-shop-')); + const projectDir = join(tempRoot, 'existing-project'); + const scaffoldDirs = []; + + try { + await mkdir(projectDir, { recursive: true }); + + const exitCode = await main({ + cliArgs: ['existing-project'], + confirmOverwrite: async () => { + throw new Error('should not prompt for an empty directory'); + }, + cwd: tempRoot, + isTTY: true, + run: async () => 0, + scaffold: async (dir) => { + scaffoldDirs.push(dir); + }, + }); + + assert.equal(exitCode, 0); + assert.deepEqual(scaffoldDirs, [projectDir]); + } finally { + await rm(tempRoot, { force: true, recursive: true }); + } +}); + +test('main aborts a non-empty scaffold when the TTY confirmation is declined', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'create-vercel-shop-')); + const projectDir = join(tempRoot, 'existing-project'); + const confirmCalls = []; + const scaffoldDirs = []; + const calls = []; + + try { + await mkdir(projectDir, { recursive: true }); + await writeFile(join(projectDir, 'package.json'), '{}\n', 'utf8'); + + const exitCode = await main({ + cliArgs: ['existing-project'], + confirmOverwrite: async (options) => { + confirmCalls.push(options); + return false; + }, + cwd: tempRoot, + isTTY: true, + run: async (command, args, options = {}) => { + calls.push({ args, command, options }); + return 0; + }, + scaffold: async (dir) => { + scaffoldDirs.push(dir); + }, + }); + + assert.equal(exitCode, 1); + assert.equal(confirmCalls.length, 1); + assert.equal(confirmCalls[0].projectDir, projectDir); + assert.deepEqual(confirmCalls[0].entries, ['package.json']); + assert.deepEqual(scaffoldDirs, []); + assert.deepEqual(calls, []); + } finally { + await rm(tempRoot, { force: true, recursive: true }); + } +}); + +test('main scaffolds a non-empty directory when the TTY confirmation is accepted', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'create-vercel-shop-')); + const projectDir = join(tempRoot, 'existing-project'); + const scaffoldDirs = []; + let confirmCalls = 0; + + try { + await mkdir(projectDir, { recursive: true }); + await writeFile(join(projectDir, 'package.json'), '{}\n', 'utf8'); + + const exitCode = await main({ + cliArgs: ['existing-project'], + confirmOverwrite: async () => { + confirmCalls += 1; + return true; + }, + cwd: tempRoot, + isTTY: true, + run: async () => 0, + scaffold: async (dir) => { + scaffoldDirs.push(dir); + }, + }); + + assert.equal(exitCode, 0); + assert.equal(confirmCalls, 1); + assert.deepEqual(scaffoldDirs, [projectDir]); + } finally { + await rm(tempRoot, { force: true, recursive: true }); + } +}); + +test('main scaffolds a non-empty directory with --force and never prompts', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'create-vercel-shop-')); + const projectDir = join(tempRoot, 'existing-project'); + const scaffoldDirs = []; + + try { + await mkdir(projectDir, { recursive: true }); + await writeFile(join(projectDir, 'package.json'), '{}\n', 'utf8'); + + const exitCode = await main({ + cliArgs: ['existing-project', FORCE_FLAG], + confirmOverwrite: async () => { + throw new Error('should not prompt with --force'); + }, + cwd: tempRoot, + isTTY: true, + run: async () => 0, + scaffold: async (dir) => { + scaffoldDirs.push(dir); + }, + }); + + assert.equal(exitCode, 0); + assert.deepEqual(scaffoldDirs, [projectDir]); + } finally { + await rm(tempRoot, { force: true, recursive: true }); + } +}); + +test('main allows --no-template against a non-empty existing project', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'create-vercel-shop-')); + const projectDir = join(tempRoot, 'existing-project'); + const scaffoldCalls = []; + + try { + await mkdir(projectDir, { recursive: true }); + await writeFile(join(projectDir, 'package.json'), '{}\n', 'utf8'); + + const exitCode = await main({ + cliArgs: ['--no-template', 'existing-project'], + confirmOverwrite: async () => { + throw new Error('should not prompt for --no-template'); + }, + cwd: tempRoot, + isTTY: false, + run: async () => 0, + scaffold: async (dir, options) => { + scaffoldCalls.push({ dir, options }); + }, + }); + + assert.equal(exitCode, 0); + assert.deepEqual(scaffoldCalls, [ + { dir: projectDir, options: { includeTemplate: false } }, + ]); + } finally { + await rm(tempRoot, { force: true, recursive: true }); + } +}); + test('inlineAgentAssets copies skills to .agents/skills, links .claude/skills, and copies commands', async () => { const tempRoot = await mkdtemp(join(tmpdir(), 'create-vercel-shop-')); const stagingDir = join(tempRoot, 'staging'); diff --git a/apps/docs/content/docs/reference/troubleshooting.mdx b/apps/docs/content/docs/reference/troubleshooting.mdx index 4b1f7cb9..3e9ac058 100644 --- a/apps/docs/content/docs/reference/troubleshooting.mdx +++ b/apps/docs/content/docs/reference/troubleshooting.mdx @@ -62,6 +62,24 @@ To get near-instant updates, set up Shopify webhooks pointed at `/api/webhooks/s Multi-locale commerce requires Shopify Markets to be enabled. The template ships as single-locale by default. Run the [`enable-shopify-markets` skill](/docs/skills/enable-shopify-markets) to add regional locale routing and propagate Shopify's localized country, language, and currency context. The skill supports locale-prefixed, invisible cookie-based, and per-domain routing. +## Scaffold stops with "is not empty" + +`create-vercel-shop` copies the template over the target directory, so it refuses to run against a directory that already contains files rather than overwriting them. On a terminal it asks for confirmation first; in CI or from a coding agent it exits with code 1. + +Scaffold into a new or empty directory: + +```bash +npx create-vercel-shop@latest my-store +``` + +If you meant to write into the existing directory, opt in explicitly: + +```bash +npx create-vercel-shop@latest my-store --force +``` + +To add only the agent skills to a project you already have, use `--no-template` instead — it never overwrites application code. + ## Agent can't find context files Coding agents rely on `AGENTS.md` in the project root plus the inlined storefront skills. If an agent is missing context or skills: diff --git a/packages/plugin/skills/init-vercel-shop/SKILL.md b/packages/plugin/skills/init-vercel-shop/SKILL.md index ff60fe42..b5f9ea9d 100644 --- a/packages/plugin/skills/init-vercel-shop/SKILL.md +++ b/packages/plugin/skills/init-vercel-shop/SKILL.md @@ -15,6 +15,8 @@ Ask for the target directory if the user did not provide one. Always pass it exp ``` Preserve an explicitly requested package manager with `--use-pnpm`, `--use-npm`, `--use-yarn`, or `--use-bun`. + + The CLI enforces step 1: it exits with code 1 when the target is non-empty, because agents run it non-interactively. Treat that as a stop, report the conflicting files, and ask the user for a different target. Only add `--force` when the user has explicitly confirmed that overwriting the existing files is what they want. 3. Confirm the generated project contains `.vercel-shop/bootstrap.json`, `.agents/skills/`, `AGENTS.md`, `app/`, `components/`, `lib/shopify/`, and `package.json`. 4. Read the generated `AGENTS.md` before making further changes. 5. If `.agents/skills/` is missing, keep the generated project and restore the inlined agent skills from the project root: