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..0be3bd8 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -6,7 +6,18 @@ 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, + writeLockfile, + commitAll, +} from './write.js'; const pkgVersion = () => { try { @@ -38,9 +49,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 +161,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 +194,40 @@ 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) { + 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' + ? 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 +244,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 +270,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..14e912c 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,76 @@ export function gitInit(cwd) { 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' }); + 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..c2b7779 --- /dev/null +++ b/test/scaffold.test.js @@ -0,0 +1,63 @@ +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, writeLockfile } 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('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' }); + assert.deepEqual(skipped, []); + assert.equal(written.length, 2); + assert.equal(await readFile(join(dir, 'a/b/c.txt'), 'utf8'), 'hi'); +});