create-packkit 2.7.2 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,6 +31,36 @@ npx create-packkit --preset full my-pkg --pm pnpm
31
31
 
32
32
  Then `cd`, and you already have a working project — `build`, `test`, and `lint` all pass out of the box.
33
33
 
34
+ ## Create the repo, not just the folder
35
+
36
+ Packkit can create the remote and push the first commit, so you don't have to make an empty repo in a browser first:
37
+
38
+ ```sh
39
+ # create it on GitHub (private) and push
40
+ npx create-packkit ts-lib my-lib --github
41
+
42
+ # public instead
43
+ npx create-packkit ts-lib my-lib --github --public
44
+
45
+ # any other host — GitLab, Bitbucket, Gitea, self-hosted
46
+ npx create-packkit ts-lib my-lib --git-remote git@bitbucket.org:me/my-lib.git
47
+ ```
48
+
49
+ `--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`**.
50
+
51
+ 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.
52
+
53
+ ### Scaffolding into a repo you already have
54
+
55
+ Already cloned an empty repo, or started some work? `--merge` scaffolds around what's there:
56
+
57
+ ```sh
58
+ git clone git@github.com:me/my-lib.git && cd my-lib
59
+ npx create-packkit ts-lib my-lib --here --merge
60
+ ```
61
+
62
+ **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.)
63
+
34
64
  ## Or configure it on the web
35
65
 
36
66
  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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-packkit",
3
- "version": "2.7.2",
3
+ "version": "2.8.0",
4
4
  "description": "Highly configurable scaffolder for modern npm packages and CLIs — pick your stack (TS/JS, bundler, tests, linter, CI, releases) from a CLI or a web configurator.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,8 @@
9
9
  "exports": {
10
10
  ".": "./src/core/index.js",
11
11
  "./core": "./src/core/index.js",
12
- "./cli": "./src/cli/index.js"
12
+ "./cli": "./src/cli/index.js",
13
+ "./scaffold": "./src/cli/write.js"
13
14
  },
14
15
  "files": [
15
16
  "bin",
@@ -28,7 +29,8 @@
28
29
  "integration": "node scripts/integration.mjs",
29
30
  "build:web": "esbuild src/core/index.js --bundle --format=esm --outfile=docs/packkit-core.js",
30
31
  "update:node": "node scripts/update-node-versions.mjs",
31
- "gen:reference": "node scripts/gen-reference.mjs"
32
+ "gen:reference": "node scripts/gen-reference.mjs",
33
+ "sync:mcp": "node scripts/sync-mcp-version.mjs"
32
34
  },
33
35
  "repository": {
34
36
  "type": "git",
package/src/cli/args.js CHANGED
@@ -43,6 +43,10 @@ export function parseCliArgs(argv) {
43
43
  yes: { type: 'boolean', short: 'y' },
44
44
  recommended: { type: 'boolean' },
45
45
  here: { type: 'boolean' },
46
+ merge: { type: 'boolean' },
47
+ github: { type: 'boolean' },
48
+ 'git-remote': { type: 'string' },
49
+ public: { type: 'boolean' },
46
50
  'no-install': { type: 'boolean' },
47
51
  'no-git': { type: 'boolean' },
48
52
  minify: { type: 'boolean' },
@@ -104,6 +108,12 @@ export function parseCliArgs(argv) {
104
108
  from: values.from,
105
109
  name,
106
110
  here: !!values.here,
111
+ merge: !!values.merge,
112
+ github: !!values.github,
113
+ gitRemote: values['git-remote'] || null,
114
+ // Publishing code outward is opt-in, and private is the safe default — a
115
+ // wrong guess here leaks source, so --public must be asked for explicitly.
116
+ private: !values.public,
107
117
  yes: !!values.yes || !!values.recommended,
108
118
  hasConfigFlags,
109
119
  install: !values['no-install'],
package/src/cli/index.js CHANGED
@@ -6,7 +6,18 @@ import { generate, fromPreset, normalizeConfig, PRESET_NAMES, OPTIONS, OPTION_HE
6
6
  import { engineFloor, meetsNodeFloor } from '../core/node.js';
7
7
  import { parseCliArgs } from './args.js';
8
8
  import { runWizard } from './wizard.js';
9
- import { writeProject, dirIsEmptyOrMissing, gitInit, installDeps } from './write.js';
9
+ import {
10
+ writeProject,
11
+ existingEntries,
12
+ gitInit,
13
+ installDeps,
14
+ hasCommand,
15
+ githubLogin,
16
+ createGithubRepo,
17
+ pushToRemote,
18
+ writeLockfile,
19
+ commitAll,
20
+ } from './write.js';
10
21
 
11
22
  const pkgVersion = () => {
12
23
  try {
@@ -38,9 +49,15 @@ Getting started:
38
49
  -y, --yes Accept defaults / preset, no prompts (one-shot)
39
50
  --recommended Alias for -y
40
51
  --here Scaffold into the current directory
52
+ --merge Scaffold into a non-empty directory (never overwrites)
41
53
  --no-install Skip dependency install
42
54
  --no-git Skip git init
43
55
 
56
+ Create the repo:
57
+ --github Create it on GitHub and push (uses the gh CLI)
58
+ --git-remote <url> Push to an existing remote (GitLab, Bitbucket, self-hosted)
59
+ --public Make the created repo public (default: private)
60
+
44
61
  Stack:
45
62
  --language <ts|js>
46
63
  --module <esm|cjs|dual> ESM-only is the default
@@ -144,14 +161,29 @@ export async function run(argv = process.argv.slice(2)) {
144
161
  console.error('A package name is required (pass one, or run interactively).');
145
162
  process.exit(1);
146
163
  }
147
- if (!dirIsEmptyOrMissing(targetDir)) {
148
- console.error(`Target directory "${basename(targetDir)}" is not empty. Aborting.`);
164
+ const occupied = existingEntries(targetDir);
165
+ if (occupied.length && !args.merge) {
166
+ const sample = occupied.slice(0, 4).join(', ') + (occupied.length > 4 ? ', …' : '');
167
+ console.error(
168
+ `Target directory "${basename(targetDir)}" is not empty (${sample}).\n` +
169
+ `Re-run with --merge to scaffold around what's there — existing files are never overwritten.`,
170
+ );
149
171
  process.exit(1);
150
172
  }
151
173
 
174
+ // Resolve the remote *before* generating: the repository URL is baked into
175
+ // package.json links and README badges at generate time, so creating the repo
176
+ // afterwards would ship a first commit pointing at nothing.
177
+ const remote = resolveRemote(args, config);
178
+ if (remote?.error) {
179
+ console.error(remote.error);
180
+ process.exit(1);
181
+ }
182
+ if (remote?.url && /^https?:/.test(remote.url)) config.repo = remote.url;
183
+
152
184
  // Generate + write.
153
185
  const { files, summary } = generate(config);
154
- await writeProject(targetDir, files);
186
+ const { skipped } = await writeProject(targetDir, files, { merge: args.merge });
155
187
 
156
188
  // Post steps.
157
189
  if (config.gitInit) gitInit(targetDir);
@@ -162,6 +194,40 @@ export async function run(argv = process.argv.slice(2)) {
162
194
  s.stop(ok ? 'Dependencies installed' : 'Install skipped (run it manually)');
163
195
  }
164
196
 
197
+ // Create the remote last, so a failure here still leaves a complete local
198
+ // project behind — nothing to clean up, just a command to re-run.
199
+ let pushedTo = null;
200
+ if (remote) {
201
+ const s = p.spinner();
202
+ // A repo pushed without a lockfile fails CI immediately — actions/setup-node
203
+ // errors when its cache can't find one. Produce it without a full install.
204
+ if (!config.install) {
205
+ s.start('Writing a lockfile so CI passes on the first run');
206
+ const ok = writeLockfile(config.packageManager, targetDir);
207
+ s.stop(ok ? 'Lockfile written' : `No lockfile — run \`${config.packageManager} install\` and commit it, or CI will fail`);
208
+ }
209
+ commitAll(targetDir, 'Add lockfile');
210
+ s.start(remote.kind === 'github' ? `Creating ${remote.slug} on GitHub` : 'Pushing to origin');
211
+ const res =
212
+ remote.kind === 'github'
213
+ ? createGithubRepo({
214
+ slug: remote.slug,
215
+ description: config.description,
216
+ private: args.private,
217
+ cwd: targetDir,
218
+ })
219
+ : pushToRemote(remote.url, targetDir);
220
+ s.stop(res.ok ? `Pushed to ${remote.url}` : 'Could not create the remote');
221
+ if (res.ok) pushedTo = remote.url;
222
+ else {
223
+ const retry =
224
+ remote.kind === 'github'
225
+ ? `gh repo create ${remote.slug} --${args.private ? 'private' : 'public'} --source . --remote origin --push`
226
+ : `git remote add origin ${remote.url} && git push -u origin HEAD`;
227
+ console.error(`\n${res.error || 'The command failed.'}\n\nThe project is scaffolded. To retry:\n cd ${basename(targetDir)} && ${retry}`);
228
+ }
229
+ }
230
+
165
231
  const rel = args.here ? '.' : config.name;
166
232
  const next = [
167
233
  args.here ? null : `cd ${rel}`,
@@ -178,9 +244,14 @@ export async function run(argv = process.argv.slice(2)) {
178
244
  ? `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.`
179
245
  : null,
180
246
  `Requires Node >= ${floor}${nodeOk ? '' : ` — you're on ${process.version}, upgrade first`}.`,
247
+ skipped.length
248
+ ? `Kept ${skipped.length} existing file${skipped.length > 1 ? 's' : ''} — Packkit's version was not written: ${skipped.join(', ')}`
249
+ : null,
181
250
  ].filter(Boolean);
182
251
 
183
- const done = `Created ${summary.name} — ${summary.fileCount} files · ${summary.stack.join(' · ')}`;
252
+ const done =
253
+ `Created ${summary.name} — ${summary.fileCount - skipped.length} files · ${summary.stack.join(' · ')}` +
254
+ (pushedTo ? `\n${pushedTo}` : '');
184
255
  if (interactive) {
185
256
  p.note(next.join('\n') || 'You are all set.', 'Next steps');
186
257
  if (hints.length) p.log.info(hints.join('\n'));
@@ -199,6 +270,43 @@ function runWord(config) {
199
270
  return config.packageManager === 'npm' ? 'npm run' : config.packageManager;
200
271
  }
201
272
 
273
+ // owner/name for the repo to create: an explicit --repo URL wins, otherwise the
274
+ // authenticated gh account plus the project name.
275
+ function githubSlug(repoUrl, login, name) {
276
+ const m = /github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/*$/.exec(repoUrl || '');
277
+ if (m) return `${m[1]}/${m[2]}`;
278
+ return login ? `${login}/${name}` : null;
279
+ }
280
+
281
+ /**
282
+ * Work out what remote to create, if any. Returns null (nothing to do),
283
+ * { error } to abort before writing anything, or the resolved target.
284
+ */
285
+ function resolveRemote(args, config) {
286
+ if (!args.github && !args.gitRemote) return null;
287
+ if (!config.gitInit) {
288
+ return { error: 'Creating a repo needs a local git repo — drop --no-git.' };
289
+ }
290
+ if (args.gitRemote) return { kind: 'remote', url: args.gitRemote };
291
+
292
+ // Delegate to `gh` rather than calling the API: it already holds the user's
293
+ // credentials, so Packkit never reads, prompts for, or stores a token.
294
+ if (!hasCommand('gh')) {
295
+ return {
296
+ error:
297
+ '--github needs the GitHub CLI. Install it (https://cli.github.com), or use\n' +
298
+ '--git-remote <url> to push to a repo you have already created.',
299
+ };
300
+ }
301
+ const login = githubLogin();
302
+ if (!login) {
303
+ return { error: '--github needs an authenticated GitHub CLI. Run: gh auth login' };
304
+ }
305
+ const slug = githubSlug(config.repo, login, config.name);
306
+ if (!slug) return { error: 'Could not work out the repository name. Pass --repo <url>.' };
307
+ return { kind: 'github', slug, url: `https://github.com/${slug}` };
308
+ }
309
+
202
310
  // Load a partial config from --from <file>, or a packkit.config.json in cwd.
203
311
  function loadProfile(args) {
204
312
  const path = args.from || (existsSync('packkit.config.json') ? 'packkit.config.json' : null);
package/src/cli/write.js CHANGED
@@ -3,26 +3,48 @@ import { existsSync, readdirSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
5
 
6
- /** Write a { path: contents } map under `targetDir`. Returns the count. */
7
- export async function writeProject(targetDir, files) {
6
+ /**
7
+ * Write a { path: contents } map under `targetDir`.
8
+ * In `merge` mode an existing file is never touched — it's reported as skipped,
9
+ * so scaffolding into a repo that already has files can't destroy work.
10
+ * Returns { written, skipped } as arrays of relative paths.
11
+ */
12
+ export async function writeProject(targetDir, files, { merge = false } = {}) {
13
+ const written = [];
14
+ const skipped = [];
8
15
  for (const [rel, contents] of Object.entries(files)) {
9
16
  const full = join(targetDir, rel);
17
+ if (merge && existsSync(full)) {
18
+ skipped.push(rel);
19
+ continue;
20
+ }
10
21
  await mkdir(dirname(full), { recursive: true });
11
22
  await writeFile(full, contents);
23
+ written.push(rel);
12
24
  }
13
- return Object.keys(files).length;
25
+ return { written, skipped };
14
26
  }
15
27
 
16
- /** True if the directory doesn't exist or contains no entries. */
17
- export function dirIsEmptyOrMissing(dir) {
18
- if (!existsSync(dir)) return true;
28
+ // Entries that don't make a directory "occupied": VCS metadata and OS noise.
29
+ // `git clone` of a fresh empty repo leaves .git/ behind, and treating that as
30
+ // non-empty broke the most common flow — create the repo, clone it, scaffold in.
31
+ const IGNORED_ENTRIES = new Set(['.git', '.DS_Store', 'Thumbs.db', '.hg', '.svn']);
32
+
33
+ /** Real (non-ignorable) entries in `dir`. Empty when the dir is missing. */
34
+ export function existingEntries(dir) {
35
+ if (!existsSync(dir)) return [];
19
36
  try {
20
- return readdirSync(dir).length === 0;
37
+ return readdirSync(dir).filter((e) => !IGNORED_ENTRIES.has(e));
21
38
  } catch {
22
- return false;
39
+ return [];
23
40
  }
24
41
  }
25
42
 
43
+ /** True if the directory doesn't exist or holds nothing that would be clobbered. */
44
+ export function dirIsEmptyOrMissing(dir) {
45
+ return existingEntries(dir).length === 0;
46
+ }
47
+
26
48
  /** Run a command in `cwd` (arg array — no shell parsing). Never throws. */
27
49
  export function run(cmd, args, cwd, { quiet = true } = {}) {
28
50
  const res = spawnSync(cmd, args, {
@@ -44,3 +66,76 @@ export function gitInit(cwd) {
44
66
  export function installDeps(pm, cwd) {
45
67
  return run(pm, ['install'], cwd, { quiet: false });
46
68
  }
69
+
70
+ // Resolving the dependency graph without downloading it. Yarn has no equivalent
71
+ // that works across its v1/v2+ split, so it's left out and reported instead.
72
+ const LOCKFILE_ONLY = {
73
+ npm: ['install', '--package-lock-only', '--ignore-scripts'],
74
+ pnpm: ['install', '--lockfile-only', '--ignore-scripts'],
75
+ bun: ['install', '--lockfile-only'],
76
+ };
77
+
78
+ /**
79
+ * Write a lockfile without installing.
80
+ *
81
+ * A pushed repo with no lockfile fails CI on its very first run — `cache: npm`
82
+ * in actions/setup-node errors outright when it can't find one. So when we're
83
+ * about to create a remote but the install was skipped, produce the lockfile
84
+ * anyway. Returns false when the package manager has no such mode.
85
+ */
86
+ export function writeLockfile(pm, cwd) {
87
+ const args = LOCKFILE_ONLY[pm];
88
+ return args ? run(pm, args, cwd) : false;
89
+ }
90
+
91
+ /** Run a command and capture its output. Never throws. */
92
+ function capture(cmd, args, cwd) {
93
+ const res = spawnSync(cmd, args, { cwd, encoding: 'utf8', shell: process.platform === 'win32' });
94
+ return {
95
+ ok: res.status === 0,
96
+ stdout: (res.stdout || '').trim(),
97
+ stderr: (res.stderr || '').trim(),
98
+ };
99
+ }
100
+
101
+ /** True if `cmd` is on PATH. */
102
+ export function hasCommand(cmd) {
103
+ return capture(process.platform === 'win32' ? 'where' : 'which', [cmd], process.cwd()).ok;
104
+ }
105
+
106
+ /** The authenticated GitHub login, or null if gh isn't installed or logged in. */
107
+ export function githubLogin() {
108
+ const res = capture('gh', ['api', 'user', '-q', '.login'], process.cwd());
109
+ return res.ok && res.stdout ? res.stdout : null;
110
+ }
111
+
112
+ /**
113
+ * Create the GitHub repo and push to it, via the `gh` CLI.
114
+ *
115
+ * Deliberately shells out rather than calling the REST API: `gh` already owns
116
+ * the user's credentials, so Packkit never reads, prompts for, or stores a
117
+ * token. Returns { ok, error }.
118
+ */
119
+ export function createGithubRepo({ slug, description, private: isPrivate = true, cwd }) {
120
+ const args = ['repo', 'create', slug, isPrivate ? '--private' : '--public',
121
+ '--source', '.', '--remote', 'origin', '--push'];
122
+ if (description) args.push('--description', description);
123
+ const res = capture('gh', args, cwd);
124
+ return { ok: res.ok, error: res.stderr || res.stdout };
125
+ }
126
+
127
+ /** Point `origin` at an existing remote URL and push the current branch. */
128
+ export function pushToRemote(url, cwd) {
129
+ const added = capture('git', ['remote', 'add', 'origin', url], cwd);
130
+ if (!added.ok) return { ok: false, error: added.stderr };
131
+ const branch = capture('git', ['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
132
+ const res = capture('git', ['push', '-u', 'origin', branch.ok ? branch.stdout : 'main'], cwd);
133
+ return { ok: res.ok, error: res.stderr };
134
+ }
135
+
136
+ /** Stage and commit everything if the tree is dirty. No-op on a clean tree. */
137
+ export function commitAll(cwd, message) {
138
+ if (!capture('git', ['status', '--porcelain'], cwd).stdout) return false;
139
+ run('git', ['add', '-A'], cwd);
140
+ return run('git', ['commit', '-m', message, '--quiet'], cwd);
141
+ }
@@ -1,5 +1,5 @@
1
1
  // AUTO-GENERATED by scripts/update-node-versions.mjs — do not edit by hand.
2
- // Source: nodejs.org/dist + nodejs/Release schedule, refreshed 2026-07-14.
2
+ // Source: nodejs.org/dist + nodejs/Release schedule, refreshed 2026-07-20.
3
3
  // Offered lines track the Node release schedule; each version is that line's
4
4
  // newest published patch. Run `node scripts/update-node-versions.mjs` to refresh.
5
5