From 4c0336b72e6d888c4083f33296e0478bbc72f3db Mon Sep 17 00:00:00 2001 From: DanMat Date: Tue, 21 Jul 2026 16:06:26 -0400 Subject: [PATCH 1/2] feat: create the repo and scaffold into non-empty directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packkit assumed a greenfield empty directory, but the real workflow is "the repo already exists" — either you made it, or you cloned it. Two halves of the same gap. Create the repo --github creates the remote and pushes; --git-remote does the same for GitLab/Bitbucket/Gitea/self-hosted. Shells out to `gh` rather than calling the API, so Packkit never reads, prompts for, or stores a token. Opt-in, and private unless --public — a wrong default here leaks source. The remote is resolved *before* generating, because the repository URL is baked into package.json links and README badges at generate time. Deriving it up front is what makes those badges point somewhere real in the first commit. Creation runs last, so a failure leaves a complete local project and a printed retry command rather than a half-state. Scaffold into a non-empty directory --merge writes what's absent and never overwrites; collisions are kept and reported. Separately, a directory holding only .git now counts as empty: `git clone` of a fresh repo left .git behind and the old check aborted on it, breaking create -> clone -> scaffold outright. That was the abort behind the reported "backup, move, restore" workaround. Both surfaces are exposed over MCP (merge/github/public), which is what moves packkit-mcp to 0.2.0. It now imports the shared side-effect layer via a new create-packkit/scaffold export instead of duplicating it, so "never overwrite" has one implementation. That import means packkit-mcp needs create-packkit >= 2.8.0, so sync-mcp-version.mjs now fails the release if the version being cut falls outside the range mcp declares — a patch bump would otherwise publish an MCP server that cannot install. Co-Authored-By: Claude Opus 4.8 --- README.md | 30 ++++++++++ ROADMAP.md | 4 +- mcp/package.json | 4 +- mcp/server.js | 71 +++++++++++++++------- package.json | 3 +- scripts/sync-mcp-version.mjs | 26 +++++++++ server.json | 4 +- src/cli/args.js | 10 ++++ src/cli/index.js | 110 +++++++++++++++++++++++++++++++++-- src/cli/write.js | 90 +++++++++++++++++++++++++--- test/scaffold.test.js | 57 ++++++++++++++++++ 11 files changed, 367 insertions(+), 42 deletions(-) create mode 100644 test/scaffold.test.js diff --git a/README.md b/README.md index 2f2c71d..9d1b86a 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,36 @@ npx create-packkit --preset full my-pkg --pm pnpm Then `cd`, and you already have a working project — `build`, `test`, and `lint` all pass out of the box. +## Create the repo, not just the folder + +Packkit can create the remote and push the first commit, so you don't have to make an empty repo in a browser first: + +```sh +# create it on GitHub (private) and push +npx create-packkit ts-lib my-lib --github + +# public instead +npx create-packkit ts-lib my-lib --github --public + +# any other host — GitLab, Bitbucket, Gitea, self-hosted +npx create-packkit ts-lib my-lib --git-remote git@bitbucket.org:me/my-lib.git +``` + +`--github` shells out to the [GitHub CLI](https://cli.github.com), so **Packkit never asks for, reads, or stores a token** — `gh` already holds your credentials. Created repos are **private unless you pass `--public`**. + +This also fixes your links: the repository URL is baked into `package.json` and the README's CI badges when the files are generated, so letting Packkit resolve it up front means the badges point somewhere real from the first commit. + +### Scaffolding into a repo you already have + +Already cloned an empty repo, or started some work? `--merge` scaffolds around what's there: + +```sh +git clone git@github.com:me/my-lib.git && cd my-lib +npx create-packkit ts-lib my-lib --here --merge +``` + +**Existing files are never overwritten.** Anything that collides is left alone and reported, so you can diff at your leisure. (A directory containing only `.git` counts as empty — a fresh clone scaffolds without needing `--merge` at all.) + ## Or configure it on the web No install needed: **[danmat.github.io/create-packkit](https://danmat.github.io/create-packkit/)** — tick the options, preview the file tree, and **download a zip** (or copy the equivalent `npx create-packkit` command). Everything runs in your browser. diff --git a/ROADMAP.md b/ROADMAP.md index b0d2731..c8cfe4b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -45,8 +45,8 @@ Planned and considered features for Packkit. Not commitments — a backlog to pu - [x] ~~**Share a config as a URL** — encode the selection in the query string.~~ **Shipped (2.3).** "Share link" button; the diff-from-defaults is encoded and restored on load. - [x] ~~**More service frameworks** — Fastify / Express alongside Hono.~~ **Shipped (2.4).** `--server `; framework-aware app/server/test. - [x] ~~**Postinstall doctor** — check the local Node / package-manager versions match `engines`, warn if not.~~ **Shipped (2.5).** `--doctor`; `npm run doctor` (+ postinstall for private projects), warn-only. -- [ ] **Create the repo, not just the folder** — `--github [owner/name]` provisions the remote and pushes, delegating to the `gh` CLI so Packkit never handles a token (`glab` later); `--git-remote ` is the universal escape hatch for Bitbucket/Gitea/self-hosted. Opt-in, **private by default**. Also fixes badge accuracy: the repo URL is currently typed by hand and feeds `package.json` links + README badges, so deriving it from `owner/name` **before** generating makes existing output correct. Everything Packkit already emits — CI workflows, release automation, badges — is inert until a remote exists. -- [ ] **Scaffold into a non-empty directory** — `--here` aborts outright when the target has *any* entry, including a bare `.git/`. So the common flow (create empty repo → clone → scaffold) fails on the clone, which is what forces the backup/move/restore dance. Add a merge mode: classify existing files, write what's absent, skip or `.packkit-new` what collides, never clobber. +- [x] ~~**Create the repo, not just the folder**~~ — **Shipped (2.8).** `--github` provisions the remote and pushes via the `gh` CLI, so Packkit never handles a token (`glab` later); `--git-remote ` covers Bitbucket/Gitea/self-hosted. Opt-in, **private** unless `--public`. Resolving the URL *before* generating also fixes badge accuracy — `package.json` links and CI badges now point somewhere real from the first commit. Exposed over MCP too (`github`, `public`). +- [x] ~~**Scaffold into a non-empty directory**~~ — **Shipped (2.8).** `--merge` writes what's absent and never overwrites; collisions are kept and reported. A directory holding only `.git` now counts as empty, so the create → clone → scaffold flow works without any flag — that abort was what forced the backup/move/restore dance. - [ ] **Vue/Svelte app scaffolds with a router** — the app targets are minimal SPAs; add vue-router / SvelteKit-style routing (plus a React Router option). - [ ] **Multiple entry points** — `exports` subpaths (e.g. `./utils`) with per-entry builds (tsup multi-entry). diff --git a/mcp/package.json b/mcp/package.json index 98507bc..9660629 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,6 +1,6 @@ { "name": "packkit-mcp", - "version": "0.1.3", + "version": "0.2.0", "mcpName": "io.github.DanMat/packkit-mcp", "description": "MCP server for Packkit — let AI agents scaffold modern npm packages, CLIs, services, and apps as a native tool.", "type": "module", @@ -34,6 +34,6 @@ "homepage": "https://danmat.github.io/create-packkit/", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", - "create-packkit": "^2.7.0" + "create-packkit": "^2.8.0" } } diff --git a/mcp/server.js b/mcp/server.js index 8fcfb0e..bd8efba 100644 --- a/mcp/server.js +++ b/mcp/server.js @@ -2,10 +2,17 @@ // Packkit MCP server — exposes Packkit scaffolding as Model Context Protocol // tools so agents (Claude Desktop, Cursor, etc.) can generate projects natively. -import { mkdir, writeFile } from 'node:fs/promises'; -import { existsSync, readdirSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { join, resolve } from 'node:path'; +import { + writeProject, + existingEntries, + gitInit, + installDeps, + hasCommand, + githubLogin, + createGithubRepo, + commitAll, +} from 'create-packkit/scaffold'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; @@ -57,6 +64,9 @@ const TOOLS = [ directory: { type: 'string', description: 'Parent directory to create the project in (default: current working directory)' }, install: { type: 'boolean', description: 'Install dependencies (default false)' }, git: { type: 'boolean', description: 'git init + initial commit (default false)' }, + merge: { type: 'boolean', description: 'Scaffold into a non-empty directory. Existing files are never overwritten — colliding ones are skipped and reported. Use this when the target is an already-cloned repo.' }, + github: { type: 'boolean', description: 'Create the repository on GitHub and push to it, using the `gh` CLI. Requires git. Private unless "public" is set.' }, + public: { type: 'boolean', description: 'When creating the repository, make it public (default: private)' }, }, required: ['name'], }, @@ -66,19 +76,11 @@ const TOOLS = [ const text = (t) => ({ content: [{ type: 'text', text: t }] }); const fail = (t) => ({ content: [{ type: 'text', text: t }], isError: true }); -async function writeProject(targetDir, files) { - for (const [rel, contents] of Object.entries(files)) { - const full = join(targetDir, rel); - await mkdir(dirname(full), { recursive: true }); - await writeFile(full, contents); - } -} - function fileTree(files) { return Object.keys(files).sort().map((p) => ` ${p}`).join('\n'); } -const server = new Server({ name: 'packkit', version: '0.1.3' }, { capabilities: { tools: {} } }); +const server = new Server({ name: 'packkit', version: '0.2.0' }, { capabilities: { tools: {} } }); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })); @@ -99,25 +101,50 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { if (!config.name) return fail('A "name" is required.'); const parent = args.directory ? resolve(args.directory) : process.cwd(); const targetDir = join(parent, config.name); - if (existsSync(targetDir) && readdirSync(targetDir).length > 0) { - return fail(`Target directory "${targetDir}" is not empty.`); + const occupied = existingEntries(targetDir); + if (occupied.length && !args.merge) { + return fail( + `Target directory "${targetDir}" is not empty (${occupied.slice(0, 4).join(', ')}). ` + + 'Pass merge: true to scaffold around the existing files — they are never overwritten.', + ); } + + // Creating the repo has to be settled before generating: the repository + // URL is baked into package.json links and README badges. + let slug = null; + if (args.github) { + if (!args.git) return fail('github: true also needs git: true — there must be a commit to push.'); + if (!hasCommand('gh')) return fail('github: true needs the GitHub CLI (https://cli.github.com).'); + const login = githubLogin(); + if (!login) return fail('github: true needs an authenticated GitHub CLI. Run: gh auth login'); + slug = `${login}/${config.name}`; + config.repo = `https://github.com/${slug}`; + } + const { files, summary } = generate(config); - await writeProject(targetDir, files); + const { written, skipped } = await writeProject(targetDir, files, { merge: !!args.merge }); const steps = []; + if (skipped.length) steps.push(`kept ${skipped.length} existing file(s): ${skipped.join(', ')}`); if (args.git) { - spawnSync('git', ['init', '--quiet'], { cwd: targetDir }); - spawnSync('git', ['add', '-A'], { cwd: targetDir }); - spawnSync('git', ['commit', '-m', 'Initial commit from Packkit', '--quiet'], { cwd: targetDir }); + gitInit(targetDir); steps.push('git initialized'); } if (args.install) { - const ok = spawnSync(config.packageManager, ['install'], { cwd: targetDir }).status === 0; - steps.push(ok ? 'dependencies installed' : 'install failed (run it manually)'); + steps.push(installDeps(config.packageManager, targetDir) ? 'dependencies installed' : 'install failed (run it manually)'); + } + if (slug) { + if (args.install) commitAll(targetDir, 'Add lockfile'); + const res = createGithubRepo({ + slug, + description: config.description, + private: !args.public, + cwd: targetDir, + }); + steps.push(res.ok ? `pushed to ${config.repo}` : `repo creation failed: ${res.error}`); } return text( - `Created ${summary.name} at ${targetDir}\n${summary.fileCount} files · ${summary.stack.join(' · ')}` + + `Created ${summary.name} at ${targetDir}\n${written.length} files · ${summary.stack.join(' · ')}` + (steps.length ? `\n${steps.join('; ')}` : ''), ); } diff --git a/package.json b/package.json index 02b2708..6837001 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "exports": { ".": "./src/core/index.js", "./core": "./src/core/index.js", - "./cli": "./src/cli/index.js" + "./cli": "./src/cli/index.js", + "./scaffold": "./src/cli/write.js" }, "files": [ "bin", diff --git a/scripts/sync-mcp-version.mjs b/scripts/sync-mcp-version.mjs index 2bd709d..a3ba37f 100644 --- a/scripts/sync-mcp-version.mjs +++ b/scripts/sync-mcp-version.mjs @@ -50,3 +50,29 @@ if (next !== src) { console.log( changed.length ? `Synced to ${version}: ${changed.join(', ')}` : `Already in sync at ${version}.`, ); + +// packkit-mcp imports from create-packkit (`create-packkit/scaffold`), so a +// release whose version falls outside the range mcp declares would publish an +// MCP server that cannot install. Caught here — before anything is published — +// rather than as a confusing resolution error at the end of the release. +const range = pkg.dependencies?.['create-packkit']; +const rootVersion = JSON.parse(readFileSync('package.json', 'utf8')).version; +if (range && !satisfiesCaret(range, rootVersion)) { + console.error( + `\npackkit-mcp requires create-packkit "${range}", but this repo is at ${rootVersion}.\n` + + `The next release must be large enough to satisfy it (a minor bump if the floor is a new minor),\n` + + `or lower the range in mcp/package.json.`, + ); + process.exit(1); +} + +/** Minimal `^X.Y.Z` check — the only range style this repo uses. */ +function satisfiesCaret(range, version) { + const m = /^\^?(\d+)\.(\d+)\.(\d+)/.exec(range); + const v = /^(\d+)\.(\d+)\.(\d+)/.exec(version); + if (!m || !v) return true; // unrecognised range — don't block the release + const [, rMaj, rMin, rPat] = m.map(Number); + const [, vMaj, vMin, vPat] = v.map(Number); + if (vMaj !== rMaj) return false; + return vMin > rMin || (vMin === rMin && vPat >= rPat); +} diff --git a/server.json b/server.json index 739d974..80af1a1 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.DanMat/packkit-mcp", "title": "Packkit", "description": "Scaffold modern npm packages, CLIs, services and apps as a native tool for AI agents.", - "version": "0.1.3", + "version": "0.2.0", "repository": { "url": "https://github.com/DanMat/create-packkit", "source": "github", @@ -14,7 +14,7 @@ "registryType": "npm", "registryBaseUrl": "https://registry.npmjs.org", "identifier": "packkit-mcp", - "version": "0.1.3", + "version": "0.2.0", "runtimeHint": "npx", "transport": { "type": "stdio" diff --git a/src/cli/args.js b/src/cli/args.js index 3e809d8..013822c 100644 --- a/src/cli/args.js +++ b/src/cli/args.js @@ -43,6 +43,10 @@ export function parseCliArgs(argv) { yes: { type: 'boolean', short: 'y' }, recommended: { type: 'boolean' }, here: { type: 'boolean' }, + merge: { type: 'boolean' }, + github: { type: 'boolean' }, + 'git-remote': { type: 'string' }, + public: { type: 'boolean' }, 'no-install': { type: 'boolean' }, 'no-git': { type: 'boolean' }, minify: { type: 'boolean' }, @@ -104,6 +108,12 @@ export function parseCliArgs(argv) { from: values.from, name, here: !!values.here, + merge: !!values.merge, + github: !!values.github, + gitRemote: values['git-remote'] || null, + // Publishing code outward is opt-in, and private is the safe default — a + // wrong guess here leaks source, so --public must be asked for explicitly. + private: !values.public, yes: !!values.yes || !!values.recommended, hasConfigFlags, install: !values['no-install'], diff --git a/src/cli/index.js b/src/cli/index.js index b20eeaa..d8017b8 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -6,7 +6,17 @@ import { generate, fromPreset, normalizeConfig, PRESET_NAMES, OPTIONS, OPTION_HE import { engineFloor, meetsNodeFloor } from '../core/node.js'; import { parseCliArgs } from './args.js'; import { runWizard } from './wizard.js'; -import { writeProject, dirIsEmptyOrMissing, gitInit, installDeps } from './write.js'; +import { + writeProject, + existingEntries, + gitInit, + installDeps, + hasCommand, + githubLogin, + createGithubRepo, + pushToRemote, + commitAll, +} from './write.js'; const pkgVersion = () => { try { @@ -38,9 +48,15 @@ Getting started: -y, --yes Accept defaults / preset, no prompts (one-shot) --recommended Alias for -y --here Scaffold into the current directory + --merge Scaffold into a non-empty directory (never overwrites) --no-install Skip dependency install --no-git Skip git init +Create the repo: + --github Create it on GitHub and push (uses the gh CLI) + --git-remote Push to an existing remote (GitLab, Bitbucket, self-hosted) + --public Make the created repo public (default: private) + Stack: --language --module ESM-only is the default @@ -144,14 +160,29 @@ export async function run(argv = process.argv.slice(2)) { console.error('A package name is required (pass one, or run interactively).'); process.exit(1); } - if (!dirIsEmptyOrMissing(targetDir)) { - console.error(`Target directory "${basename(targetDir)}" is not empty. Aborting.`); + const occupied = existingEntries(targetDir); + if (occupied.length && !args.merge) { + const sample = occupied.slice(0, 4).join(', ') + (occupied.length > 4 ? ', …' : ''); + console.error( + `Target directory "${basename(targetDir)}" is not empty (${sample}).\n` + + `Re-run with --merge to scaffold around what's there — existing files are never overwritten.`, + ); process.exit(1); } + // Resolve the remote *before* generating: the repository URL is baked into + // package.json links and README badges at generate time, so creating the repo + // afterwards would ship a first commit pointing at nothing. + const remote = resolveRemote(args, config); + if (remote?.error) { + console.error(remote.error); + process.exit(1); + } + if (remote?.url && /^https?:/.test(remote.url)) config.repo = remote.url; + // Generate + write. const { files, summary } = generate(config); - await writeProject(targetDir, files); + const { skipped } = await writeProject(targetDir, files, { merge: args.merge }); // Post steps. if (config.gitInit) gitInit(targetDir); @@ -162,6 +193,33 @@ export async function run(argv = process.argv.slice(2)) { s.stop(ok ? 'Dependencies installed' : 'Install skipped (run it manually)'); } + // Create the remote last, so a failure here still leaves a complete local + // project behind — nothing to clean up, just a command to re-run. + let pushedTo = null; + if (remote) { + if (config.install) commitAll(targetDir, 'Add lockfile'); + const s = p.spinner(); + s.start(remote.kind === 'github' ? `Creating ${remote.slug} on GitHub` : 'Pushing to origin'); + const res = + remote.kind === 'github' + ? createGithubRepo({ + slug: remote.slug, + description: config.description, + private: args.private, + cwd: targetDir, + }) + : pushToRemote(remote.url, targetDir); + s.stop(res.ok ? `Pushed to ${remote.url}` : 'Could not create the remote'); + if (res.ok) pushedTo = remote.url; + else { + const retry = + remote.kind === 'github' + ? `gh repo create ${remote.slug} --${args.private ? 'private' : 'public'} --source . --remote origin --push` + : `git remote add origin ${remote.url} && git push -u origin HEAD`; + console.error(`\n${res.error || 'The command failed.'}\n\nThe project is scaffolded. To retry:\n cd ${basename(targetDir)} && ${retry}`); + } + } + const rel = args.here ? '.' : config.name; const next = [ args.here ? null : `cd ${rel}`, @@ -178,9 +236,14 @@ export async function run(argv = process.argv.slice(2)) { ? `This is a ${config.framework} component library — \`${runWord(config)} dev\` rebuilds on change (there's no dev server). For a runnable app, scaffold the "${config.framework}-app" preset instead.` : null, `Requires Node >= ${floor}${nodeOk ? '' : ` — you're on ${process.version}, upgrade first`}.`, + skipped.length + ? `Kept ${skipped.length} existing file${skipped.length > 1 ? 's' : ''} — Packkit's version was not written: ${skipped.join(', ')}` + : null, ].filter(Boolean); - const done = `Created ${summary.name} — ${summary.fileCount} files · ${summary.stack.join(' · ')}`; + const done = + `Created ${summary.name} — ${summary.fileCount - skipped.length} files · ${summary.stack.join(' · ')}` + + (pushedTo ? `\n${pushedTo}` : ''); if (interactive) { p.note(next.join('\n') || 'You are all set.', 'Next steps'); if (hints.length) p.log.info(hints.join('\n')); @@ -199,6 +262,43 @@ function runWord(config) { return config.packageManager === 'npm' ? 'npm run' : config.packageManager; } +// owner/name for the repo to create: an explicit --repo URL wins, otherwise the +// authenticated gh account plus the project name. +function githubSlug(repoUrl, login, name) { + const m = /github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/*$/.exec(repoUrl || ''); + if (m) return `${m[1]}/${m[2]}`; + return login ? `${login}/${name}` : null; +} + +/** + * Work out what remote to create, if any. Returns null (nothing to do), + * { error } to abort before writing anything, or the resolved target. + */ +function resolveRemote(args, config) { + if (!args.github && !args.gitRemote) return null; + if (!config.gitInit) { + return { error: 'Creating a repo needs a local git repo — drop --no-git.' }; + } + if (args.gitRemote) return { kind: 'remote', url: args.gitRemote }; + + // Delegate to `gh` rather than calling the API: it already holds the user's + // credentials, so Packkit never reads, prompts for, or stores a token. + if (!hasCommand('gh')) { + return { + error: + '--github needs the GitHub CLI. Install it (https://cli.github.com), or use\n' + + '--git-remote to push to a repo you have already created.', + }; + } + const login = githubLogin(); + if (!login) { + return { error: '--github needs an authenticated GitHub CLI. Run: gh auth login' }; + } + const slug = githubSlug(config.repo, login, config.name); + if (!slug) return { error: 'Could not work out the repository name. Pass --repo .' }; + return { kind: 'github', slug, url: `https://github.com/${slug}` }; +} + // Load a partial config from --from , or a packkit.config.json in cwd. function loadProfile(args) { const path = args.from || (existsSync('packkit.config.json') ? 'packkit.config.json' : null); diff --git a/src/cli/write.js b/src/cli/write.js index a9539f8..35e67fd 100644 --- a/src/cli/write.js +++ b/src/cli/write.js @@ -3,26 +3,48 @@ import { existsSync, readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { spawnSync } from 'node:child_process'; -/** Write a { path: contents } map under `targetDir`. Returns the count. */ -export async function writeProject(targetDir, files) { +/** + * Write a { path: contents } map under `targetDir`. + * In `merge` mode an existing file is never touched — it's reported as skipped, + * so scaffolding into a repo that already has files can't destroy work. + * Returns { written, skipped } as arrays of relative paths. + */ +export async function writeProject(targetDir, files, { merge = false } = {}) { + const written = []; + const skipped = []; for (const [rel, contents] of Object.entries(files)) { const full = join(targetDir, rel); + if (merge && existsSync(full)) { + skipped.push(rel); + continue; + } await mkdir(dirname(full), { recursive: true }); await writeFile(full, contents); + written.push(rel); } - return Object.keys(files).length; + return { written, skipped }; } -/** True if the directory doesn't exist or contains no entries. */ -export function dirIsEmptyOrMissing(dir) { - if (!existsSync(dir)) return true; +// Entries that don't make a directory "occupied": VCS metadata and OS noise. +// `git clone` of a fresh empty repo leaves .git/ behind, and treating that as +// non-empty broke the most common flow — create the repo, clone it, scaffold in. +const IGNORED_ENTRIES = new Set(['.git', '.DS_Store', 'Thumbs.db', '.hg', '.svn']); + +/** Real (non-ignorable) entries in `dir`. Empty when the dir is missing. */ +export function existingEntries(dir) { + if (!existsSync(dir)) return []; try { - return readdirSync(dir).length === 0; + return readdirSync(dir).filter((e) => !IGNORED_ENTRIES.has(e)); } catch { - return false; + return []; } } +/** True if the directory doesn't exist or holds nothing that would be clobbered. */ +export function dirIsEmptyOrMissing(dir) { + return existingEntries(dir).length === 0; +} + /** Run a command in `cwd` (arg array — no shell parsing). Never throws. */ export function run(cmd, args, cwd, { quiet = true } = {}) { const res = spawnSync(cmd, args, { @@ -44,3 +66,55 @@ export function gitInit(cwd) { export function installDeps(pm, cwd) { return run(pm, ['install'], cwd, { quiet: false }); } + +/** Run a command and capture its output. Never throws. */ +function capture(cmd, args, cwd) { + const res = spawnSync(cmd, args, { cwd, encoding: 'utf8', shell: process.platform === 'win32' }); + return { + ok: res.status === 0, + stdout: (res.stdout || '').trim(), + stderr: (res.stderr || '').trim(), + }; +} + +/** True if `cmd` is on PATH. */ +export function hasCommand(cmd) { + return capture(process.platform === 'win32' ? 'where' : 'which', [cmd], process.cwd()).ok; +} + +/** The authenticated GitHub login, or null if gh isn't installed or logged in. */ +export function githubLogin() { + const res = capture('gh', ['api', 'user', '-q', '.login'], process.cwd()); + return res.ok && res.stdout ? res.stdout : null; +} + +/** + * Create the GitHub repo and push to it, via the `gh` CLI. + * + * Deliberately shells out rather than calling the REST API: `gh` already owns + * the user's credentials, so Packkit never reads, prompts for, or stores a + * token. Returns { ok, error }. + */ +export function createGithubRepo({ slug, description, private: isPrivate = true, cwd }) { + const args = ['repo', 'create', slug, isPrivate ? '--private' : '--public', + '--source', '.', '--remote', 'origin', '--push']; + if (description) args.push('--description', description); + const res = capture('gh', args, cwd); + return { ok: res.ok, error: res.stderr || res.stdout }; +} + +/** Point `origin` at an existing remote URL and push the current branch. */ +export function pushToRemote(url, cwd) { + const added = capture('git', ['remote', 'add', 'origin', url], cwd); + if (!added.ok) return { ok: false, error: added.stderr }; + const branch = capture('git', ['rev-parse', '--abbrev-ref', 'HEAD'], cwd); + const res = capture('git', ['push', '-u', 'origin', branch.ok ? branch.stdout : 'main'], cwd); + return { ok: res.ok, error: res.stderr }; +} + +/** Stage and commit everything if the tree is dirty. No-op on a clean tree. */ +export function commitAll(cwd, message) { + if (!capture('git', ['status', '--porcelain'], cwd).stdout) return false; + run('git', ['add', '-A'], cwd); + return run('git', ['commit', '-m', message, '--quiet'], cwd); +} diff --git a/test/scaffold.test.js b/test/scaffold.test.js new file mode 100644 index 0000000..0acb5c8 --- /dev/null +++ b/test/scaffold.test.js @@ -0,0 +1,57 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { writeProject, existingEntries, dirIsEmptyOrMissing } from '../src/cli/write.js'; + +const tmp = () => mkdtemp(join(tmpdir(), 'packkit-')); + +test('a directory holding only .git counts as empty', async () => { + // The create-repo → clone → scaffold flow leaves a bare .git behind, and + // treating that as "not empty" used to abort the most common way in. + const dir = await tmp(); + await mkdir(join(dir, '.git')); + await writeFile(join(dir, '.DS_Store'), ''); + assert.deepEqual(existingEntries(dir), []); + assert.equal(dirIsEmptyOrMissing(dir), true); +}); + +test('real files make a directory non-empty', async () => { + const dir = await tmp(); + await mkdir(join(dir, '.git')); + await writeFile(join(dir, 'README.md'), 'mine'); + assert.deepEqual(existingEntries(dir), ['README.md']); + assert.equal(dirIsEmptyOrMissing(dir), false); +}); + +test('missing directory is empty', async () => { + assert.equal(dirIsEmptyOrMissing(join(await tmp(), 'nope')), true); +}); + +test('merge never overwrites, and reports what it kept', async () => { + const dir = await tmp(); + await writeFile(join(dir, 'README.md'), 'ORIGINAL'); + await mkdir(join(dir, 'src')); + await writeFile(join(dir, 'src/index.ts'), 'export const mine = 1;'); + + const { written, skipped } = await writeProject( + dir, + { 'README.md': 'generated', 'src/index.ts': 'generated', 'tsconfig.json': '{}' }, + { merge: true }, + ); + + assert.deepEqual(skipped.sort(), ['README.md', 'src/index.ts']); + assert.deepEqual(written, ['tsconfig.json']); + assert.equal(await readFile(join(dir, 'README.md'), 'utf8'), 'ORIGINAL'); + assert.equal(await readFile(join(dir, 'src/index.ts'), 'utf8'), 'export const mine = 1;'); + assert.equal(await readFile(join(dir, 'tsconfig.json'), 'utf8'), '{}'); +}); + +test('without merge every file is written, nested dirs created', async () => { + const dir = await tmp(); + const { written, skipped } = await writeProject(dir, { 'a/b/c.txt': 'hi', 'd.txt': 'yo' }); + assert.deepEqual(skipped, []); + assert.equal(written.length, 2); + assert.equal(await readFile(join(dir, 'a/b/c.txt'), 'utf8'), 'hi'); +}); From d189a4e22f04a06bfec8717cad5db6b7a51ac9d7 Mon Sep 17 00:00:00 2001 From: DanMat Date: Tue, 21 Jul 2026 16:14:28 -0400 Subject: [PATCH 2/2] fix: write a lockfile before pushing a new repo Found by creating a real repo: --github with --no-install pushed a project with no lockfile, and actions/setup-node's `cache: npm` errors outright when it can't find one. Every repo Packkit created that way was red on its very first CI run, which rather defeats the point of creating the repo for you. When a remote is being created and the install was skipped, write the lockfile without installing (~15s vs a full install). Yarn has no mode that works across its v1/v2+ split, so it warns instead of failing silently. Verified end to end: a freshly created private repo now goes green. Co-Authored-By: Claude Opus 4.8 --- src/cli/index.js | 10 +++++++++- src/cli/write.js | 21 +++++++++++++++++++++ test/scaffold.test.js | 8 +++++++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/cli/index.js b/src/cli/index.js index d8017b8..0be3bd8 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -15,6 +15,7 @@ import { githubLogin, createGithubRepo, pushToRemote, + writeLockfile, commitAll, } from './write.js'; @@ -197,8 +198,15 @@ export async function run(argv = process.argv.slice(2)) { // project behind — nothing to clean up, just a command to re-run. let pushedTo = null; if (remote) { - if (config.install) commitAll(targetDir, 'Add lockfile'); const s = p.spinner(); + // A repo pushed without a lockfile fails CI immediately — actions/setup-node + // errors when its cache can't find one. Produce it without a full install. + if (!config.install) { + s.start('Writing a lockfile so CI passes on the first run'); + const ok = writeLockfile(config.packageManager, targetDir); + s.stop(ok ? 'Lockfile written' : `No lockfile — run \`${config.packageManager} install\` and commit it, or CI will fail`); + } + commitAll(targetDir, 'Add lockfile'); s.start(remote.kind === 'github' ? `Creating ${remote.slug} on GitHub` : 'Pushing to origin'); const res = remote.kind === 'github' diff --git a/src/cli/write.js b/src/cli/write.js index 35e67fd..14e912c 100644 --- a/src/cli/write.js +++ b/src/cli/write.js @@ -67,6 +67,27 @@ export function installDeps(pm, cwd) { return run(pm, ['install'], cwd, { quiet: false }); } +// Resolving the dependency graph without downloading it. Yarn has no equivalent +// that works across its v1/v2+ split, so it's left out and reported instead. +const LOCKFILE_ONLY = { + npm: ['install', '--package-lock-only', '--ignore-scripts'], + pnpm: ['install', '--lockfile-only', '--ignore-scripts'], + bun: ['install', '--lockfile-only'], +}; + +/** + * Write a lockfile without installing. + * + * A pushed repo with no lockfile fails CI on its very first run — `cache: npm` + * in actions/setup-node errors outright when it can't find one. So when we're + * about to create a remote but the install was skipped, produce the lockfile + * anyway. Returns false when the package manager has no such mode. + */ +export function writeLockfile(pm, cwd) { + const args = LOCKFILE_ONLY[pm]; + return args ? run(pm, args, cwd) : false; +} + /** Run a command and capture its output. Never throws. */ function capture(cmd, args, cwd) { const res = spawnSync(cmd, args, { cwd, encoding: 'utf8', shell: process.platform === 'win32' }); diff --git a/test/scaffold.test.js b/test/scaffold.test.js index 0acb5c8..c2b7779 100644 --- a/test/scaffold.test.js +++ b/test/scaffold.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { mkdtemp, mkdir, writeFile, readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { writeProject, existingEntries, dirIsEmptyOrMissing } from '../src/cli/write.js'; +import { writeProject, existingEntries, dirIsEmptyOrMissing, writeLockfile } from '../src/cli/write.js'; const tmp = () => mkdtemp(join(tmpdir(), 'packkit-')); @@ -48,6 +48,12 @@ test('merge never overwrites, and reports what it kept', async () => { assert.equal(await readFile(join(dir, 'tsconfig.json'), 'utf8'), '{}'); }); +test('writeLockfile reports failure for package managers without a lockfile-only mode', async () => { + // Yarn has no mode that works across its v1/v2+ split, so the caller has to + // warn rather than silently push a repo whose first CI run will fail. + assert.equal(writeLockfile('yarn', await tmp()), false); +}); + test('without merge every file is written, nested dirs created', async () => { const dir = await tmp(); const { written, skipped } = await writeProject(dir, { 'a/b/c.txt': 'hi', 'd.txt': 'yo' });