create-packkit 2.7.2 → 2.9.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 +30 -0
- package/package.json +5 -3
- package/src/cli/args.js +11 -0
- package/src/cli/index.js +124 -6
- package/src/cli/write.js +103 -8
- package/src/core/features/meta.js +25 -0
- package/src/core/index.js +6 -1
- package/src/core/monorepo.js +364 -0
- package/src/core/node-versions.js +1 -1
- package/src/core/options.js +9 -0
- package/src/core/presets.js +7 -0
- package/src/core/provenance.js +35 -0
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.
|
|
3
|
+
"version": "2.9.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
|
@@ -20,6 +20,7 @@ const OVERRIDE_FLAGS = {
|
|
|
20
20
|
description: 'description',
|
|
21
21
|
keywords: 'keywords',
|
|
22
22
|
repo: 'repo',
|
|
23
|
+
'monorepo-layout': 'monorepoLayout',
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
// Boolean options that default ON — a --no-<flag> turns them off.
|
|
@@ -43,6 +44,10 @@ export function parseCliArgs(argv) {
|
|
|
43
44
|
yes: { type: 'boolean', short: 'y' },
|
|
44
45
|
recommended: { type: 'boolean' },
|
|
45
46
|
here: { type: 'boolean' },
|
|
47
|
+
merge: { type: 'boolean' },
|
|
48
|
+
github: { type: 'boolean' },
|
|
49
|
+
'git-remote': { type: 'string' },
|
|
50
|
+
public: { type: 'boolean' },
|
|
46
51
|
'no-install': { type: 'boolean' },
|
|
47
52
|
'no-git': { type: 'boolean' },
|
|
48
53
|
minify: { type: 'boolean' },
|
|
@@ -104,6 +109,12 @@ export function parseCliArgs(argv) {
|
|
|
104
109
|
from: values.from,
|
|
105
110
|
name,
|
|
106
111
|
here: !!values.here,
|
|
112
|
+
merge: !!values.merge,
|
|
113
|
+
github: !!values.github,
|
|
114
|
+
gitRemote: values['git-remote'] || null,
|
|
115
|
+
// Publishing code outward is opt-in, and private is the safe default — a
|
|
116
|
+
// wrong guess here leaks source, so --public must be asked for explicitly.
|
|
117
|
+
private: !values.public,
|
|
107
118
|
yes: !!values.yes || !!values.recommended,
|
|
108
119
|
hasConfigFlags,
|
|
109
120
|
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 {
|
|
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 {
|
|
@@ -30,7 +41,7 @@ defaults in one shot. Every option is documented (with why-you'd-use-it) at:
|
|
|
30
41
|
|
|
31
42
|
Presets:
|
|
32
43
|
${PRESET_NAMES.join(' ')}
|
|
33
|
-
shortcuts: lib jslib rlib rapp vlib vapp slib sapp svc
|
|
44
|
+
shortcuts: lib jslib rlib rapp vlib vapp slib sapp svc fs
|
|
34
45
|
|
|
35
46
|
Getting started:
|
|
36
47
|
--preset <name> Start from a preset (skips the wizard)
|
|
@@ -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
|
|
@@ -49,6 +66,7 @@ Stack:
|
|
|
49
66
|
--server <hono|fastify|express> HTTP service framework
|
|
50
67
|
--pm <npm|pnpm|yarn|bun>
|
|
51
68
|
--monorepo pnpm + Turborepo workspace
|
|
69
|
+
--monorepo-layout <libraries|fullstack> Linked packages, or web+server+shared
|
|
52
70
|
|
|
53
71
|
Build & test:
|
|
54
72
|
--bundler <tsup|tsdown|unbuild|rollup|none>
|
|
@@ -86,6 +104,7 @@ Examples:
|
|
|
86
104
|
npx packkit ts-lib my-lib -y
|
|
87
105
|
npx packkit react-app my-app --e2e
|
|
88
106
|
npx packkit node-service api --server fastify --env
|
|
107
|
+
npx packkit fullstack acme --github # web + API + shared, repo created
|
|
89
108
|
npm create packkit@latest my-pkg -- --preset oss --pm pnpm
|
|
90
109
|
`;
|
|
91
110
|
|
|
@@ -118,6 +137,9 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
118
137
|
|
|
119
138
|
config.gitInit = args.git;
|
|
120
139
|
config.install = args.install;
|
|
140
|
+
// Core is version-agnostic (it also runs in the browser), so the surface
|
|
141
|
+
// that knows the version supplies it for packkit.json.
|
|
142
|
+
config.generatorVersion = pkgVersion();
|
|
121
143
|
|
|
122
144
|
// Node preflight: the generated project's tools (eslint, vite, vitest) hard-
|
|
123
145
|
// require this floor. npm only *warns* on engines, so catch it here — clearly,
|
|
@@ -144,14 +166,29 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
144
166
|
console.error('A package name is required (pass one, or run interactively).');
|
|
145
167
|
process.exit(1);
|
|
146
168
|
}
|
|
147
|
-
|
|
148
|
-
|
|
169
|
+
const occupied = existingEntries(targetDir);
|
|
170
|
+
if (occupied.length && !args.merge) {
|
|
171
|
+
const sample = occupied.slice(0, 4).join(', ') + (occupied.length > 4 ? ', …' : '');
|
|
172
|
+
console.error(
|
|
173
|
+
`Target directory "${basename(targetDir)}" is not empty (${sample}).\n` +
|
|
174
|
+
`Re-run with --merge to scaffold around what's there — existing files are never overwritten.`,
|
|
175
|
+
);
|
|
149
176
|
process.exit(1);
|
|
150
177
|
}
|
|
151
178
|
|
|
179
|
+
// Resolve the remote *before* generating: the repository URL is baked into
|
|
180
|
+
// package.json links and README badges at generate time, so creating the repo
|
|
181
|
+
// afterwards would ship a first commit pointing at nothing.
|
|
182
|
+
const remote = resolveRemote(args, config);
|
|
183
|
+
if (remote?.error) {
|
|
184
|
+
console.error(remote.error);
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
if (remote?.url && /^https?:/.test(remote.url)) config.repo = remote.url;
|
|
188
|
+
|
|
152
189
|
// Generate + write.
|
|
153
190
|
const { files, summary } = generate(config);
|
|
154
|
-
await writeProject(targetDir, files);
|
|
191
|
+
const { skipped } = await writeProject(targetDir, files, { merge: args.merge });
|
|
155
192
|
|
|
156
193
|
// Post steps.
|
|
157
194
|
if (config.gitInit) gitInit(targetDir);
|
|
@@ -162,6 +199,40 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
162
199
|
s.stop(ok ? 'Dependencies installed' : 'Install skipped (run it manually)');
|
|
163
200
|
}
|
|
164
201
|
|
|
202
|
+
// Create the remote last, so a failure here still leaves a complete local
|
|
203
|
+
// project behind — nothing to clean up, just a command to re-run.
|
|
204
|
+
let pushedTo = null;
|
|
205
|
+
if (remote) {
|
|
206
|
+
const s = p.spinner();
|
|
207
|
+
// A repo pushed without a lockfile fails CI immediately — actions/setup-node
|
|
208
|
+
// errors when its cache can't find one. Produce it without a full install.
|
|
209
|
+
if (!config.install) {
|
|
210
|
+
s.start('Writing a lockfile so CI passes on the first run');
|
|
211
|
+
const ok = writeLockfile(config.packageManager, targetDir);
|
|
212
|
+
s.stop(ok ? 'Lockfile written' : `No lockfile — run \`${config.packageManager} install\` and commit it, or CI will fail`);
|
|
213
|
+
}
|
|
214
|
+
commitAll(targetDir, 'Add lockfile');
|
|
215
|
+
s.start(remote.kind === 'github' ? `Creating ${remote.slug} on GitHub` : 'Pushing to origin');
|
|
216
|
+
const res =
|
|
217
|
+
remote.kind === 'github'
|
|
218
|
+
? createGithubRepo({
|
|
219
|
+
slug: remote.slug,
|
|
220
|
+
description: config.description,
|
|
221
|
+
private: args.private,
|
|
222
|
+
cwd: targetDir,
|
|
223
|
+
})
|
|
224
|
+
: pushToRemote(remote.url, targetDir);
|
|
225
|
+
s.stop(res.ok ? `Pushed to ${remote.url}` : 'Could not create the remote');
|
|
226
|
+
if (res.ok) pushedTo = remote.url;
|
|
227
|
+
else {
|
|
228
|
+
const retry =
|
|
229
|
+
remote.kind === 'github'
|
|
230
|
+
? `gh repo create ${remote.slug} --${args.private ? 'private' : 'public'} --source . --remote origin --push`
|
|
231
|
+
: `git remote add origin ${remote.url} && git push -u origin HEAD`;
|
|
232
|
+
console.error(`\n${res.error || 'The command failed.'}\n\nThe project is scaffolded. To retry:\n cd ${basename(targetDir)} && ${retry}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
165
236
|
const rel = args.here ? '.' : config.name;
|
|
166
237
|
const next = [
|
|
167
238
|
args.here ? null : `cd ${rel}`,
|
|
@@ -178,9 +249,19 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
178
249
|
? `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
250
|
: null,
|
|
180
251
|
`Requires Node >= ${floor}${nodeOk ? '' : ` — you're on ${process.version}, upgrade first`}.`,
|
|
252
|
+
skipped.length
|
|
253
|
+
? `Kept ${skipped.length} existing file${skipped.length > 1 ? 's' : ''} — Packkit's version was not written: ${skipped.join(', ')}`
|
|
254
|
+
: null,
|
|
255
|
+
// Said here too, not just in the README: this is the moment a red X appears
|
|
256
|
+
// on a repo that is ten seconds old, and the cause is not obvious.
|
|
257
|
+
pushedTo && config.workflows?.includes('npm-publish') && config.release === 'changesets'
|
|
258
|
+
? `The Release workflow will fail until npm credentials exist — set up npm Trusted Publishing, or add an NPM_TOKEN secret. See "Releasing" in the README.`
|
|
259
|
+
: null,
|
|
181
260
|
].filter(Boolean);
|
|
182
261
|
|
|
183
|
-
const done =
|
|
262
|
+
const done =
|
|
263
|
+
`Created ${summary.name} — ${summary.fileCount - skipped.length} files · ${summary.stack.join(' · ')}` +
|
|
264
|
+
(pushedTo ? `\n${pushedTo}` : '');
|
|
184
265
|
if (interactive) {
|
|
185
266
|
p.note(next.join('\n') || 'You are all set.', 'Next steps');
|
|
186
267
|
if (hints.length) p.log.info(hints.join('\n'));
|
|
@@ -199,6 +280,43 @@ function runWord(config) {
|
|
|
199
280
|
return config.packageManager === 'npm' ? 'npm run' : config.packageManager;
|
|
200
281
|
}
|
|
201
282
|
|
|
283
|
+
// owner/name for the repo to create: an explicit --repo URL wins, otherwise the
|
|
284
|
+
// authenticated gh account plus the project name.
|
|
285
|
+
function githubSlug(repoUrl, login, name) {
|
|
286
|
+
const m = /github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/*$/.exec(repoUrl || '');
|
|
287
|
+
if (m) return `${m[1]}/${m[2]}`;
|
|
288
|
+
return login ? `${login}/${name}` : null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Work out what remote to create, if any. Returns null (nothing to do),
|
|
293
|
+
* { error } to abort before writing anything, or the resolved target.
|
|
294
|
+
*/
|
|
295
|
+
function resolveRemote(args, config) {
|
|
296
|
+
if (!args.github && !args.gitRemote) return null;
|
|
297
|
+
if (!config.gitInit) {
|
|
298
|
+
return { error: 'Creating a repo needs a local git repo — drop --no-git.' };
|
|
299
|
+
}
|
|
300
|
+
if (args.gitRemote) return { kind: 'remote', url: args.gitRemote };
|
|
301
|
+
|
|
302
|
+
// Delegate to `gh` rather than calling the API: it already holds the user's
|
|
303
|
+
// credentials, so Packkit never reads, prompts for, or stores a token.
|
|
304
|
+
if (!hasCommand('gh')) {
|
|
305
|
+
return {
|
|
306
|
+
error:
|
|
307
|
+
'--github needs the GitHub CLI. Install it (https://cli.github.com), or use\n' +
|
|
308
|
+
'--git-remote <url> to push to a repo you have already created.',
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
const login = githubLogin();
|
|
312
|
+
if (!login) {
|
|
313
|
+
return { error: '--github needs an authenticated GitHub CLI. Run: gh auth login' };
|
|
314
|
+
}
|
|
315
|
+
const slug = githubSlug(config.repo, login, config.name);
|
|
316
|
+
if (!slug) return { error: 'Could not work out the repository name. Pass --repo <url>.' };
|
|
317
|
+
return { kind: 'github', slug, url: `https://github.com/${slug}` };
|
|
318
|
+
}
|
|
319
|
+
|
|
202
320
|
// Load a partial config from --from <file>, or a packkit.config.json in cwd.
|
|
203
321
|
function loadProfile(args) {
|
|
204
322
|
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
|
-
/**
|
|
7
|
-
|
|
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
|
|
25
|
+
return { written, skipped };
|
|
14
26
|
}
|
|
15
27
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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).
|
|
37
|
+
return readdirSync(dir).filter((e) => !IGNORED_ENTRIES.has(e));
|
|
21
38
|
} catch {
|
|
22
|
-
return
|
|
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
|
+
}
|
|
@@ -144,6 +144,31 @@ function readme(cfg) {
|
|
|
144
144
|
lines.push('## CLI', '', '```sh', `npx ${cfg.name} --help`, '```', '');
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
// Publishing needs a credential the repo doesn't have yet. The changesets
|
|
148
|
+
// workflow runs on every push, so without this note the first thing a new
|
|
149
|
+
// repo does is fail a job for a reason that's only explained in a YAML
|
|
150
|
+
// comment. Say it where someone will actually read it.
|
|
151
|
+
if (cfg.workflows?.includes('npm-publish')) {
|
|
152
|
+
const changesets = cfg.release === 'changesets';
|
|
153
|
+
lines.push(
|
|
154
|
+
'## Releasing',
|
|
155
|
+
'',
|
|
156
|
+
changesets
|
|
157
|
+
? 'Releases are handled by the `Release` workflow: push a changeset (`npx changeset`) and it opens a version PR, then publishes when that PR merges.'
|
|
158
|
+
: 'Pushing a `v*` tag triggers the `Publish` workflow, which publishes to npm with provenance.',
|
|
159
|
+
'',
|
|
160
|
+
'**Publishing needs npm credentials, which a new repository does not have.** Either:',
|
|
161
|
+
'',
|
|
162
|
+
'- Set up [npm Trusted Publishing](https://docs.npmjs.com/trusted-publishers) (OIDC) — no secret to store or rotate, and the recommended option; or',
|
|
163
|
+
'- Add an [npm automation token](https://docs.npmjs.com/creating-and-viewing-access-tokens) as an `NPM_TOKEN` repository secret.',
|
|
164
|
+
'',
|
|
165
|
+
changesets
|
|
166
|
+
? 'Until one of those is in place the `Release` workflow will fail with `ENEEDAUTH` on every push. That is expected on a brand-new repo.'
|
|
167
|
+
: 'Until one of those is in place, tag pushes will fail with `ENEEDAUTH`.',
|
|
168
|
+
'',
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
147
172
|
lines.push('## License', '', cfg.license === 'none' ? 'Unlicensed.' : `${cfg.license}${cfg.author ? ' © ' + cfg.author : ''}`, '');
|
|
148
173
|
return lines.join('\n');
|
|
149
174
|
}
|
package/src/core/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { deepMerge, toJson } from './render.js';
|
|
|
7
7
|
import { finalizePackageJson } from './pkg.js';
|
|
8
8
|
import features from './features/index.js';
|
|
9
9
|
import { buildMonorepo } from './monorepo.js';
|
|
10
|
+
import { provenance } from './provenance.js';
|
|
10
11
|
import { PRESETS, PRESET_NAMES, PRESET_INFO, PRESET_ALIASES, resolvePreset } from './presets.js';
|
|
11
12
|
|
|
12
13
|
export { OPTIONS, GROUPS, OPTION_HELP, defaultConfig, normalizeConfig, PRESETS, PRESET_NAMES, PRESET_INFO, PRESET_ALIASES, resolvePreset };
|
|
@@ -15,7 +16,10 @@ export { OPTIONS, GROUPS, OPTION_HELP, defaultConfig, normalizeConfig, PRESETS,
|
|
|
15
16
|
export function fromPreset(name, overrides = {}) {
|
|
16
17
|
const canonical = resolvePreset(name);
|
|
17
18
|
if (!canonical) throw new Error(`Unknown preset "${name}". Known: ${PRESET_NAMES.join(', ')}`);
|
|
18
|
-
|
|
19
|
+
const cfg = normalizeConfig({ ...PRESETS[canonical], ...overrides });
|
|
20
|
+
// Recorded in packkit.json so a project knows which preset it came from.
|
|
21
|
+
cfg.preset = canonical;
|
|
22
|
+
return cfg;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
/** Turn a config into a complete set of files. */
|
|
@@ -36,6 +40,7 @@ export function generate(input) {
|
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
files['package.json'] = toJson(finalizePackageJson(pkg));
|
|
43
|
+
files['packkit.json'] = provenance(cfg);
|
|
39
44
|
|
|
40
45
|
return {
|
|
41
46
|
config: cfg,
|
package/src/core/monorepo.js
CHANGED
|
@@ -6,8 +6,15 @@ import { toJson } from './render.js';
|
|
|
6
6
|
import community from './features/community.js';
|
|
7
7
|
import agents from './features/agents.js';
|
|
8
8
|
import gitfiles from './features/gitfiles.js';
|
|
9
|
+
import { provenance } from './provenance.js';
|
|
9
10
|
|
|
10
11
|
export function buildMonorepo(cfg) {
|
|
12
|
+
// Two genuinely different shapes: a set of publishable libraries, or a
|
|
13
|
+
// deployable app (web + server + shared code). They differ in workspace
|
|
14
|
+
// globs, whether anything publishes, and what `dev` means, so they don't
|
|
15
|
+
// usefully share a code path.
|
|
16
|
+
if (cfg.monorepoLayout === 'fullstack') return buildFullstack(cfg);
|
|
17
|
+
|
|
11
18
|
const files = {};
|
|
12
19
|
const pm = cfg.packageManager;
|
|
13
20
|
const scope = cfg.name.replace(/^@/, '').split('/')[0];
|
|
@@ -104,6 +111,7 @@ export function buildMonorepo(cfg) {
|
|
|
104
111
|
files['.prettierrc.json'] = toJson({ useTabs: true, singleQuote: true, semi: true, printWidth: 100, trailingComma: 'all' });
|
|
105
112
|
|
|
106
113
|
files['README.md'] = rootReadme(cfg, pm, core, utils);
|
|
114
|
+
files['packkit.json'] = provenance(cfg);
|
|
107
115
|
files['.github/workflows/ci.yml'] = ciWorkflow(cfg, pm);
|
|
108
116
|
|
|
109
117
|
// ---- packages ----
|
|
@@ -151,6 +159,362 @@ export function buildMonorepo(cfg) {
|
|
|
151
159
|
};
|
|
152
160
|
}
|
|
153
161
|
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Full-stack layout: apps/web + apps/server + packages/shared.
|
|
164
|
+
//
|
|
165
|
+
// The pieces already existed as standalone presets (react-app, node-service);
|
|
166
|
+
// what was missing was the composition — workspace wiring, one shared package
|
|
167
|
+
// both sides import, and a production story where the server serves the built
|
|
168
|
+
// web assets instead of needing a second host.
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
function buildFullstack(cfg) {
|
|
171
|
+
const files = {};
|
|
172
|
+
const pm = cfg.packageManager;
|
|
173
|
+
const scope = cfg.name.replace(/^@/, '').split('/')[0];
|
|
174
|
+
const shared = `@${scope}/shared`;
|
|
175
|
+
const wsProto = pm === 'pnpm' ? 'workspace:*' : '*';
|
|
176
|
+
const run = (s) => (pm === 'npm' ? `npm run ${s}` : `${pm} ${s}`);
|
|
177
|
+
|
|
178
|
+
for (const feat of [community, agents, gitfiles]) {
|
|
179
|
+
if (feat.active(cfg)) Object.assign(files, feat.apply(cfg).files);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
files['package.json'] = toJson({
|
|
183
|
+
name: cfg.name,
|
|
184
|
+
version: '0.0.0',
|
|
185
|
+
private: true,
|
|
186
|
+
type: 'module',
|
|
187
|
+
...(cfg.license !== 'none' ? { license: cfg.license } : {}),
|
|
188
|
+
...(pm === 'pnpm'
|
|
189
|
+
? { packageManager: 'pnpm@9.10.0' }
|
|
190
|
+
: { workspaces: ['apps/*', 'packages/*'] }),
|
|
191
|
+
scripts: {
|
|
192
|
+
dev: 'turbo dev',
|
|
193
|
+
build: 'turbo build',
|
|
194
|
+
// Production runs the built server, which also serves the web build.
|
|
195
|
+
start: `${pm === 'npm' ? 'npm --prefix apps/server run' : `${pm} --filter ./apps/server`} start`,
|
|
196
|
+
test: 'turbo test',
|
|
197
|
+
lint: 'turbo lint',
|
|
198
|
+
typecheck: 'turbo typecheck',
|
|
199
|
+
},
|
|
200
|
+
devDependencies: {
|
|
201
|
+
turbo: '^2.0.0',
|
|
202
|
+
typescript: '^5.9.3',
|
|
203
|
+
vitest: '^4.0.0',
|
|
204
|
+
eslint: '^10.0.0',
|
|
205
|
+
'@eslint/js': '^10.0.0',
|
|
206
|
+
'typescript-eslint': '^8.0.0',
|
|
207
|
+
prettier: '^3.3.0',
|
|
208
|
+
'@types/node': `^${cfg.nodeVersion}.0.0`,
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
if (pm === 'pnpm') {
|
|
213
|
+
files['pnpm-workspace.yaml'] = 'packages:\n - "apps/*"\n - "packages/*"\n';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
files['turbo.json'] = toJson({
|
|
217
|
+
$schema: 'https://turbo.build/schema.json',
|
|
218
|
+
tasks: {
|
|
219
|
+
build: { dependsOn: ['^build'], outputs: ['dist/**'] },
|
|
220
|
+
// Shared is built before the apps start, so both sides always import a
|
|
221
|
+
// current copy without a separate watch process.
|
|
222
|
+
dev: { dependsOn: ['^build'], cache: false, persistent: true },
|
|
223
|
+
test: { dependsOn: ['^build'] },
|
|
224
|
+
typecheck: { dependsOn: ['^build'] },
|
|
225
|
+
lint: {},
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
files['tsconfig.base.json'] = toJson({
|
|
230
|
+
$schema: 'https://json.schemastore.org/tsconfig',
|
|
231
|
+
compilerOptions: {
|
|
232
|
+
target: 'ES2022',
|
|
233
|
+
module: 'ESNext',
|
|
234
|
+
moduleResolution: 'Bundler',
|
|
235
|
+
lib: ['ES2022', 'DOM'],
|
|
236
|
+
strict: true,
|
|
237
|
+
esModuleInterop: true,
|
|
238
|
+
skipLibCheck: true,
|
|
239
|
+
declaration: true,
|
|
240
|
+
noEmit: true,
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
files['eslint.config.js'] = [
|
|
245
|
+
`import js from '@eslint/js';`,
|
|
246
|
+
`import tseslint from 'typescript-eslint';`,
|
|
247
|
+
``,
|
|
248
|
+
`export default tseslint.config(`,
|
|
249
|
+
`\tjs.configs.recommended,`,
|
|
250
|
+
`\t...tseslint.configs.recommended,`,
|
|
251
|
+
`\t{ ignores: ['**/dist'] },`,
|
|
252
|
+
`);`,
|
|
253
|
+
``,
|
|
254
|
+
].join('\n');
|
|
255
|
+
files['.prettierrc.json'] = toJson({ useTabs: true, singleQuote: true, semi: true, printWidth: 100, trailingComma: 'all' });
|
|
256
|
+
files['.github/workflows/ci.yml'] = ciWorkflow(cfg, pm);
|
|
257
|
+
files['README.md'] = fullstackReadme(cfg, pm, shared);
|
|
258
|
+
files['packkit.json'] = provenance(cfg);
|
|
259
|
+
|
|
260
|
+
// ---- packages/shared: the contract both sides compile against ----
|
|
261
|
+
files['packages/shared/package.json'] = toJson({
|
|
262
|
+
name: shared,
|
|
263
|
+
version: '0.0.0',
|
|
264
|
+
private: true,
|
|
265
|
+
type: 'module',
|
|
266
|
+
main: './dist/index.js',
|
|
267
|
+
types: './dist/index.d.ts',
|
|
268
|
+
exports: { '.': { types: './dist/index.d.ts', default: './dist/index.js' } },
|
|
269
|
+
scripts: {
|
|
270
|
+
build: 'tsup src/index.ts --format esm --dts --clean',
|
|
271
|
+
test: 'vitest run',
|
|
272
|
+
typecheck: 'tsc --noEmit',
|
|
273
|
+
lint: 'eslint .',
|
|
274
|
+
},
|
|
275
|
+
devDependencies: { tsup: '^8.0.0' },
|
|
276
|
+
});
|
|
277
|
+
files['packages/shared/tsconfig.json'] = toJson({ extends: '../../tsconfig.base.json', include: ['src'] });
|
|
278
|
+
files['packages/shared/src/index.ts'] = [
|
|
279
|
+
`/** The shape the server returns and the web app renders. Change it once. */`,
|
|
280
|
+
`export interface Health {`,
|
|
281
|
+
`\tok: boolean;`,
|
|
282
|
+
`\tservice: string;`,
|
|
283
|
+
`\tuptime: number;`,
|
|
284
|
+
`}`,
|
|
285
|
+
``,
|
|
286
|
+
`export function describeHealth(h: Health): string {`,
|
|
287
|
+
`\treturn h.ok ? \`\${h.service} is up (\${Math.round(h.uptime)}s)\` : \`\${h.service} is down\`;`,
|
|
288
|
+
`}`,
|
|
289
|
+
``,
|
|
290
|
+
].join('\n');
|
|
291
|
+
files['packages/shared/src/index.test.ts'] = exampleTest(
|
|
292
|
+
`import { describeHealth } from './index.js';`,
|
|
293
|
+
`expect(describeHealth({ ok: true, service: 'api', uptime: 12 })).toBe('api is up (12s)')`,
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
// ---- apps/server ----
|
|
297
|
+
files['apps/server/package.json'] = toJson({
|
|
298
|
+
name: `@${scope}/server`,
|
|
299
|
+
version: '0.0.0',
|
|
300
|
+
private: true,
|
|
301
|
+
type: 'module',
|
|
302
|
+
scripts: {
|
|
303
|
+
dev: 'tsx watch src/index.ts',
|
|
304
|
+
build: 'tsup src/index.ts --format esm --clean',
|
|
305
|
+
start: 'node dist/index.js',
|
|
306
|
+
test: 'vitest run',
|
|
307
|
+
typecheck: 'tsc --noEmit',
|
|
308
|
+
lint: 'eslint .',
|
|
309
|
+
},
|
|
310
|
+
dependencies: { hono: '^4.5.0', '@hono/node-server': '^2.0.0', [shared]: wsProto },
|
|
311
|
+
devDependencies: { tsx: '^4.0.0', tsup: '^8.0.0' },
|
|
312
|
+
});
|
|
313
|
+
files['apps/server/tsconfig.json'] = toJson({ extends: '../../tsconfig.base.json', include: ['src'] });
|
|
314
|
+
files['apps/server/src/app.ts'] = [
|
|
315
|
+
`import { Hono } from 'hono';`,
|
|
316
|
+
`import { serveStatic } from '@hono/node-server/serve-static';`,
|
|
317
|
+
`import type { Health } from '${shared}';`,
|
|
318
|
+
``,
|
|
319
|
+
`export const app = new Hono();`,
|
|
320
|
+
``,
|
|
321
|
+
`app.get('/api/health', (c) => {`,
|
|
322
|
+
`\tconst body: Health = { ok: true, service: '${cfg.name}', uptime: process.uptime() };`,
|
|
323
|
+
`\treturn c.json(body);`,
|
|
324
|
+
`});`,
|
|
325
|
+
``,
|
|
326
|
+
`// In production the API also serves the built web app, so one process and`,
|
|
327
|
+
`// one port covers the whole thing. In dev, Vite serves the app and proxies`,
|
|
328
|
+
`// /api here instead (see apps/web/vite.config.ts).`,
|
|
329
|
+
`if (process.env.NODE_ENV === 'production') {`,
|
|
330
|
+
`\tapp.use('/*', serveStatic({ root: '../web/dist' }));`,
|
|
331
|
+
`}`,
|
|
332
|
+
``,
|
|
333
|
+
].join('\n');
|
|
334
|
+
files['apps/server/src/index.ts'] = [
|
|
335
|
+
`import { serve } from '@hono/node-server';`,
|
|
336
|
+
`import { app } from './app.js';`,
|
|
337
|
+
``,
|
|
338
|
+
`const port = Number(process.env.PORT ?? 3000);`,
|
|
339
|
+
`serve({ fetch: app.fetch, port });`,
|
|
340
|
+
`console.log(\`Listening on http://localhost:\${port}\`);`,
|
|
341
|
+
``,
|
|
342
|
+
].join('\n');
|
|
343
|
+
files['apps/server/src/app.test.ts'] = [
|
|
344
|
+
`import { describe, it, expect } from 'vitest';`,
|
|
345
|
+
`import { app } from './app.js';`,
|
|
346
|
+
``,
|
|
347
|
+
`describe('api', () => {`,
|
|
348
|
+
`\tit('reports health', async () => {`,
|
|
349
|
+
`\t\tconst res = await app.request('/api/health');`,
|
|
350
|
+
`\t\texpect(res.status).toBe(200);`,
|
|
351
|
+
`\t\texpect(await res.json()).toMatchObject({ ok: true });`,
|
|
352
|
+
`\t});`,
|
|
353
|
+
`});`,
|
|
354
|
+
``,
|
|
355
|
+
].join('\n');
|
|
356
|
+
|
|
357
|
+
// ---- apps/web ----
|
|
358
|
+
files['apps/web/package.json'] = toJson({
|
|
359
|
+
name: `@${scope}/web`,
|
|
360
|
+
version: '0.0.0',
|
|
361
|
+
private: true,
|
|
362
|
+
type: 'module',
|
|
363
|
+
scripts: {
|
|
364
|
+
dev: 'vite',
|
|
365
|
+
build: 'vite build',
|
|
366
|
+
preview: 'vite preview',
|
|
367
|
+
test: 'vitest run',
|
|
368
|
+
typecheck: 'tsc --noEmit',
|
|
369
|
+
lint: 'eslint .',
|
|
370
|
+
},
|
|
371
|
+
dependencies: { react: '^19.0.0', 'react-dom': '^19.0.0', [shared]: wsProto },
|
|
372
|
+
devDependencies: {
|
|
373
|
+
vite: '^8.0.0',
|
|
374
|
+
'@vitejs/plugin-react': '^6.0.0',
|
|
375
|
+
// Without the React types, `turbo typecheck` fails on the very first run
|
|
376
|
+
// with TS7016/TS7026 on every JSX element.
|
|
377
|
+
'@types/react': '^19.0.0',
|
|
378
|
+
'@types/react-dom': '^19.0.0',
|
|
379
|
+
'@testing-library/react': '^16.0.0',
|
|
380
|
+
'@testing-library/dom': '^10.0.0',
|
|
381
|
+
jsdom: '^29.0.0',
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
files['apps/web/tsconfig.json'] = toJson({
|
|
385
|
+
extends: '../../tsconfig.base.json',
|
|
386
|
+
compilerOptions: { jsx: 'react-jsx' },
|
|
387
|
+
include: ['src'],
|
|
388
|
+
});
|
|
389
|
+
files['apps/web/vite.config.ts'] = [
|
|
390
|
+
`/// <reference types="vitest" />`,
|
|
391
|
+
`import { defineConfig } from 'vite';`,
|
|
392
|
+
`import react from '@vitejs/plugin-react';`,
|
|
393
|
+
``,
|
|
394
|
+
`export default defineConfig({`,
|
|
395
|
+
`\tplugins: [react()],`,
|
|
396
|
+
`\t// Same-origin /api in dev as in production, so no CORS and no base URL`,
|
|
397
|
+
`\t// juggling between environments.`,
|
|
398
|
+
`\tserver: { proxy: { '/api': 'http://localhost:3000' } },`,
|
|
399
|
+
`\ttest: { environment: 'jsdom' },`,
|
|
400
|
+
`});`,
|
|
401
|
+
``,
|
|
402
|
+
].join('\n');
|
|
403
|
+
files['apps/web/src/App.test.tsx'] = [
|
|
404
|
+
`import { describe, it, expect } from 'vitest';`,
|
|
405
|
+
`import { render, screen } from '@testing-library/react';`,
|
|
406
|
+
`import { App } from './App.js';`,
|
|
407
|
+
``,
|
|
408
|
+
`describe('App', () => {`,
|
|
409
|
+
`\tit('renders the service name', () => {`,
|
|
410
|
+
`\t\trender(<App />);`,
|
|
411
|
+
`\t\texpect(screen.getByRole('heading', { name: '${cfg.name}' })).toBeDefined();`,
|
|
412
|
+
`\t});`,
|
|
413
|
+
`});`,
|
|
414
|
+
``,
|
|
415
|
+
].join('\n');
|
|
416
|
+
files['apps/web/index.html'] = [
|
|
417
|
+
`<!doctype html>`,
|
|
418
|
+
`<html lang="en">`,
|
|
419
|
+
`\t<head>`,
|
|
420
|
+
`\t\t<meta charset="UTF-8" />`,
|
|
421
|
+
`\t\t<meta name="viewport" content="width=device-width, initial-scale=1.0" />`,
|
|
422
|
+
`\t\t<title>${cfg.name}</title>`,
|
|
423
|
+
`\t</head>`,
|
|
424
|
+
`\t<body>`,
|
|
425
|
+
`\t\t<div id="root"></div>`,
|
|
426
|
+
`\t\t<script type="module" src="/src/main.tsx"></script>`,
|
|
427
|
+
`\t</body>`,
|
|
428
|
+
`</html>`,
|
|
429
|
+
``,
|
|
430
|
+
].join('\n');
|
|
431
|
+
files['apps/web/src/main.tsx'] = [
|
|
432
|
+
`import { StrictMode } from 'react';`,
|
|
433
|
+
`import { createRoot } from 'react-dom/client';`,
|
|
434
|
+
`import { App } from './App.js';`,
|
|
435
|
+
``,
|
|
436
|
+
`createRoot(document.getElementById('root')!).render(`,
|
|
437
|
+
`\t<StrictMode>`,
|
|
438
|
+
`\t\t<App />`,
|
|
439
|
+
`\t</StrictMode>,`,
|
|
440
|
+
`);`,
|
|
441
|
+
``,
|
|
442
|
+
].join('\n');
|
|
443
|
+
files['apps/web/src/App.tsx'] = [
|
|
444
|
+
`import { useEffect, useState } from 'react';`,
|
|
445
|
+
`import { describeHealth, type Health } from '${shared}';`,
|
|
446
|
+
``,
|
|
447
|
+
`export function App() {`,
|
|
448
|
+
`\tconst [health, setHealth] = useState<Health | null>(null);`,
|
|
449
|
+
``,
|
|
450
|
+
`\tuseEffect(() => {`,
|
|
451
|
+
`\t\tfetch('/api/health')`,
|
|
452
|
+
`\t\t\t.then((r) => r.json() as Promise<Health>)`,
|
|
453
|
+
`\t\t\t.then(setHealth)`,
|
|
454
|
+
`\t\t\t.catch(() => setHealth({ ok: false, service: '${cfg.name}', uptime: 0 }));`,
|
|
455
|
+
`\t}, []);`,
|
|
456
|
+
``,
|
|
457
|
+
`\treturn (`,
|
|
458
|
+
`\t\t<main>`,
|
|
459
|
+
`\t\t\t<h1>${cfg.name}</h1>`,
|
|
460
|
+
`\t\t\t<p>{health ? describeHealth(health) : 'Checking…'}</p>`,
|
|
461
|
+
`\t\t</main>`,
|
|
462
|
+
`\t);`,
|
|
463
|
+
`}`,
|
|
464
|
+
``,
|
|
465
|
+
].join('\n');
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
config: cfg,
|
|
469
|
+
files,
|
|
470
|
+
postCommands: cfg.gitInit ? ['git init', 'git add -A', 'git commit -m "Initial commit from Packkit"'] : [],
|
|
471
|
+
summary: {
|
|
472
|
+
name: cfg.name,
|
|
473
|
+
fileCount: Object.keys(files).length,
|
|
474
|
+
stack: ['monorepo', 'full-stack', `${pm}+turbo`, 'React+Vite', 'Hono', 'TypeScript', 'vitest'],
|
|
475
|
+
workflows: ['ci'],
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function fullstackReadme(cfg, pm, shared) {
|
|
481
|
+
const install = pm === 'npm' ? 'npm install' : `${pm} install`;
|
|
482
|
+
const run = (s) => (pm === 'npm' ? `npm run ${s}` : `${pm} ${s}`);
|
|
483
|
+
return [
|
|
484
|
+
`# ${cfg.name}`,
|
|
485
|
+
'',
|
|
486
|
+
cfg.description || '_A full-stack monorepo scaffolded with [Packkit](https://danmat.github.io/create-packkit/)._',
|
|
487
|
+
'',
|
|
488
|
+
'## Layout',
|
|
489
|
+
'',
|
|
490
|
+
'```',
|
|
491
|
+
'apps/web React + Vite front end',
|
|
492
|
+
'apps/server Hono API (also serves the web build in production)',
|
|
493
|
+
'packages/shared types and helpers both sides import',
|
|
494
|
+
'```',
|
|
495
|
+
'',
|
|
496
|
+
'## Develop',
|
|
497
|
+
'',
|
|
498
|
+
'```sh',
|
|
499
|
+
install,
|
|
500
|
+
run('dev') + ' # web on :5173, api on :3000',
|
|
501
|
+
'```',
|
|
502
|
+
'',
|
|
503
|
+
`Vite proxies \`/api\` to the server, so requests are same-origin in development exactly as they are in production — no CORS, no environment-specific base URL.`,
|
|
504
|
+
'',
|
|
505
|
+
'## Production',
|
|
506
|
+
'',
|
|
507
|
+
'```sh',
|
|
508
|
+
run('build'),
|
|
509
|
+
run('start') + ' # one process serving the API and the built web app',
|
|
510
|
+
'```',
|
|
511
|
+
'',
|
|
512
|
+
`\`${shared}\` is built before either app starts, so a change to a shared type surfaces as a type error on both sides rather than at runtime.`,
|
|
513
|
+
'',
|
|
514
|
+
cfg.license !== 'none' ? `## License\n\n${cfg.license}${cfg.author ? ' © ' + cfg.author : ''}\n` : '',
|
|
515
|
+
].join('\n');
|
|
516
|
+
}
|
|
517
|
+
|
|
154
518
|
function addPackage(files, { name, dir, src, test, deps }) {
|
|
155
519
|
const pkg = {
|
|
156
520
|
name,
|
|
@@ -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-
|
|
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
|
|
package/src/core/options.js
CHANGED
|
@@ -54,6 +54,14 @@ export const OPTIONS = {
|
|
|
54
54
|
monorepo: {
|
|
55
55
|
group: 'core', type: 'boolean', label: 'Monorepo (pnpm/Turborepo workspace)', default: false,
|
|
56
56
|
},
|
|
57
|
+
monorepoLayout: {
|
|
58
|
+
group: 'core', type: 'select', label: 'Monorepo layout', default: 'libraries',
|
|
59
|
+
when: (cfg) => cfg.monorepo,
|
|
60
|
+
choices: [
|
|
61
|
+
{ value: 'libraries', label: 'Libraries — linked packages you publish' },
|
|
62
|
+
{ value: 'fullstack', label: 'Full-stack app — web + server + shared' },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
57
65
|
framework: {
|
|
58
66
|
group: 'core', type: 'select', label: 'Framework', default: 'none',
|
|
59
67
|
choices: [
|
|
@@ -209,6 +217,7 @@ export const OPTION_HELP = {
|
|
|
209
217
|
moduleFormat: 'How the package is consumed. ESM-only (default) is the modern, leanest choice — Node 20.19+/22.12+ can `require()` ESM. Pick dual only if you must support older CJS-only consumers; cjs-only is rarely needed.',
|
|
210
218
|
target: 'What you are building — mix and match: a library (importable package), a CLI (ships a bin), an HTTP service, or an app (Vite SPA).',
|
|
211
219
|
serviceFramework: 'For the service target: Hono (fast, web-standard, tiny — default), Fastify (batteries-included, plugins, schema validation), or Express (ubiquitous, huge ecosystem).',
|
|
220
|
+
monorepoLayout: 'What the workspace contains. "libraries" gives linked packages you publish (Changesets). "fullstack" gives apps/web (React+Vite) + apps/server (Hono) + packages/shared, wired together, with the server serving the web build in production.',
|
|
212
221
|
monorepo: 'Generate a pnpm + Turborepo workspace with two linked example packages and Changesets. Only worth it when ≥2 packages share code.',
|
|
213
222
|
framework: 'UI framework for component libraries and apps: React, Vue, or Svelte (or none for a plain package).',
|
|
214
223
|
packageManager: 'Which package manager the scripts, lockfile, and CI target: npm, pnpm, yarn, or bun.',
|
package/src/core/presets.js
CHANGED
|
@@ -19,6 +19,10 @@ export const PRESETS = {
|
|
|
19
19
|
release: 'none', workflows: ['ci'], deps: 'renovate', agents: true, vscode: true,
|
|
20
20
|
},
|
|
21
21
|
monorepo: { monorepo: true, language: 'ts', packageManager: 'pnpm' },
|
|
22
|
+
fullstack: {
|
|
23
|
+
monorepo: true, monorepoLayout: 'fullstack', language: 'ts', packageManager: 'pnpm',
|
|
24
|
+
framework: 'react', test: 'vitest', release: 'none', workflows: ['ci'],
|
|
25
|
+
},
|
|
22
26
|
oss: {
|
|
23
27
|
language: 'ts', target: ['library'], moduleFormat: 'esm', bundler: 'tsup',
|
|
24
28
|
test: 'vitest', coverage: true, lint: 'eslint-prettier', gitHooks: 'simple-git-hooks',
|
|
@@ -53,6 +57,8 @@ export const PRESET_ALIASES = {
|
|
|
53
57
|
slib: 'svelte-lib',
|
|
54
58
|
sapp: 'svelte-app',
|
|
55
59
|
svc: 'node-service',
|
|
60
|
+
fs: 'fullstack',
|
|
61
|
+
app: 'fullstack',
|
|
56
62
|
service: 'node-service',
|
|
57
63
|
};
|
|
58
64
|
|
|
@@ -78,6 +84,7 @@ export const PRESET_INFO = {
|
|
|
78
84
|
'svelte-app': 'Svelte SPA — Vite dev server, build, Testing Library.',
|
|
79
85
|
'node-service': 'Node HTTP service (Hono) — tsx dev, tsup build, Dockerfile.',
|
|
80
86
|
monorepo: 'pnpm + Turborepo workspace — two example packages, Changesets, CI.',
|
|
87
|
+
fullstack: 'Full-stack monorepo — React+Vite web, Hono API, shared package; server serves the web build in production.',
|
|
81
88
|
oss: 'Full open-source library — coverage, CodeQL, Codecov, Renovate, Changesets.',
|
|
82
89
|
minimal: 'Bare TS library — tsup only, no tests/lint/CI.',
|
|
83
90
|
full: 'Everything on — library + CLI, all workflows and extras.',
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// packkit.json — what this project was generated from.
|
|
2
|
+
//
|
|
3
|
+
// Packkit used to leave no trace, so a project couldn't answer "what did I
|
|
4
|
+
// start from?", couldn't be diffed against a newer template, and had no upgrade
|
|
5
|
+
// path. This records the answer next to the code.
|
|
6
|
+
//
|
|
7
|
+
// Only settings that differ from the defaults are stored, so the file reads as
|
|
8
|
+
// the decisions someone actually made rather than a dump of every option. It is
|
|
9
|
+
// deliberately free of timestamps and machine details: generation stays a pure
|
|
10
|
+
// function of the config, so the same config always produces the same bytes.
|
|
11
|
+
|
|
12
|
+
import { toJson } from './render.js';
|
|
13
|
+
import { defaultConfig } from './options.js';
|
|
14
|
+
|
|
15
|
+
// How this particular run was invoked, rather than what the project is.
|
|
16
|
+
// Replaying a config shouldn't re-run someone else's `git init` or install.
|
|
17
|
+
const TRANSIENT = new Set(['gitInit', 'install', 'generatorVersion', 'preset', 'name']);
|
|
18
|
+
|
|
19
|
+
export function provenance(cfg) {
|
|
20
|
+
const defaults = defaultConfig();
|
|
21
|
+
const settings = {};
|
|
22
|
+
for (const [key, value] of Object.entries(cfg)) {
|
|
23
|
+
// Skip derived helpers (isTs, hasApp, ext…) — they aren't inputs.
|
|
24
|
+
if (!(key in defaults) || TRANSIENT.has(key)) continue;
|
|
25
|
+
if (JSON.stringify(value) !== JSON.stringify(defaults[key])) settings[key] = value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return toJson({
|
|
29
|
+
$schema: 'https://danmat.github.io/create-packkit/packkit.schema.json',
|
|
30
|
+
generator: 'create-packkit',
|
|
31
|
+
...(cfg.generatorVersion ? { version: cfg.generatorVersion } : {}),
|
|
32
|
+
...(cfg.preset ? { preset: cfg.preset } : {}),
|
|
33
|
+
settings,
|
|
34
|
+
});
|
|
35
|
+
}
|