create-packkit 2.8.0 → 3.0.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 +20 -2
- package/package.json +17 -3
- package/src/cli/args.js +1 -0
- package/src/cli/index.js +11 -1
- package/src/core/features/meta.js +25 -0
- package/src/core/index.js +47 -8
- package/src/core/monorepo.js +364 -0
- package/src/core/options.js +53 -22
- package/src/core/presets.js +7 -0
- package/src/core/provenance.js +35 -0
- package/src/core/render.js +6 -0
- package/src/embedded/contract.js +64 -0
- package/src/embedded/index.js +451 -0
- package/src/embedded/paths.js +88 -0
- package/src/embedded/pkg-merge.js +89 -0
- package/src/embedded/writer.js +151 -0
- package/src/index.js +12 -0
- package/types/core.d.ts +96 -0
- package/types/embedded.d.ts +92 -0
- package/types/index.d.ts +6 -0
- package/types/writer.d.ts +25 -0
package/README.md
CHANGED
|
@@ -193,14 +193,32 @@ There's also an [`llms.txt`](llms.txt) (served at [danmat.github.io/create-packk
|
|
|
193
193
|
{ "mcpServers": { "packkit": { "command": "npx", "args": ["-y", "packkit-mcp"] } } }
|
|
194
194
|
```
|
|
195
195
|
|
|
196
|
+
## Embed Packkit in your own app
|
|
197
|
+
|
|
198
|
+
Packkit ships a typed, side-effect-free API so a Node application can use it as a
|
|
199
|
+
project-generation engine — generate in memory, add your own deployment files,
|
|
200
|
+
and write to disk when you're ready. No prompts, installs, git, or network.
|
|
201
|
+
|
|
202
|
+
```js
|
|
203
|
+
import { createProject, extendProject, writeGeneratedProject } from 'create-packkit/embedded';
|
|
204
|
+
|
|
205
|
+
const project = createProject({ preset: 'react-app', name: 'weather-dashboard' });
|
|
206
|
+
const extended = extendProject(project, { files: { '.github/workflows/deploy.yml': deployYaml } });
|
|
207
|
+
await writeGeneratedProject({ project: extended, destination: '/tmp/weather-dashboard' });
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Full guide, including diagnostics, reproducible definitions, digests, and the
|
|
211
|
+
provider-neutral deployment contract: **[Embedding Packkit](docs/EMBEDDING.md)**.
|
|
212
|
+
|
|
196
213
|
## How it works
|
|
197
214
|
|
|
198
215
|
Packkit is a pure `config → { files }` **core** that runs in both Node and the browser:
|
|
199
216
|
|
|
200
217
|
- the **CLI** writes the files to disk, runs `git init`, and installs dependencies;
|
|
201
|
-
- the **web configurator** zips the same files client-side (no server)
|
|
218
|
+
- the **web configurator** zips the same files client-side (no server);
|
|
219
|
+
- the **embedded API** ([`create-packkit/embedded`](docs/EMBEDDING.md)) hands the file map to a host application.
|
|
202
220
|
|
|
203
|
-
|
|
221
|
+
All three drive from one options schema ([`src/core/options.js`](src/core/options.js)), so they always stay in sync.
|
|
204
222
|
|
|
205
223
|
## Staying fresh
|
|
206
224
|
|
package/package.json
CHANGED
|
@@ -1,20 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-packkit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.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": {
|
|
7
7
|
"create-packkit": "bin/cli.js"
|
|
8
8
|
},
|
|
9
|
+
"types": "./types/index.d.ts",
|
|
9
10
|
"exports": {
|
|
10
|
-
".":
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./types/index.d.ts",
|
|
13
|
+
"default": "./src/index.js"
|
|
14
|
+
},
|
|
11
15
|
"./core": "./src/core/index.js",
|
|
16
|
+
"./embedded": {
|
|
17
|
+
"types": "./types/embedded.d.ts",
|
|
18
|
+
"default": "./src/embedded/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./writer": {
|
|
21
|
+
"types": "./types/writer.d.ts",
|
|
22
|
+
"default": "./src/embedded/writer.js"
|
|
23
|
+
},
|
|
12
24
|
"./cli": "./src/cli/index.js",
|
|
13
|
-
"./scaffold": "./src/cli/write.js"
|
|
25
|
+
"./scaffold": "./src/cli/write.js",
|
|
26
|
+
"./package.json": "./package.json"
|
|
14
27
|
},
|
|
15
28
|
"files": [
|
|
16
29
|
"bin",
|
|
17
30
|
"src",
|
|
31
|
+
"types",
|
|
18
32
|
"README.md",
|
|
19
33
|
"LICENSE",
|
|
20
34
|
"llms.txt"
|
package/src/cli/args.js
CHANGED
package/src/cli/index.js
CHANGED
|
@@ -41,7 +41,7 @@ defaults in one shot. Every option is documented (with why-you'd-use-it) at:
|
|
|
41
41
|
|
|
42
42
|
Presets:
|
|
43
43
|
${PRESET_NAMES.join(' ')}
|
|
44
|
-
shortcuts: lib jslib rlib rapp vlib vapp slib sapp svc
|
|
44
|
+
shortcuts: lib jslib rlib rapp vlib vapp slib sapp svc fs
|
|
45
45
|
|
|
46
46
|
Getting started:
|
|
47
47
|
--preset <name> Start from a preset (skips the wizard)
|
|
@@ -66,6 +66,7 @@ Stack:
|
|
|
66
66
|
--server <hono|fastify|express> HTTP service framework
|
|
67
67
|
--pm <npm|pnpm|yarn|bun>
|
|
68
68
|
--monorepo pnpm + Turborepo workspace
|
|
69
|
+
--monorepo-layout <libraries|fullstack> Linked packages, or web+server+shared
|
|
69
70
|
|
|
70
71
|
Build & test:
|
|
71
72
|
--bundler <tsup|tsdown|unbuild|rollup|none>
|
|
@@ -103,6 +104,7 @@ Examples:
|
|
|
103
104
|
npx packkit ts-lib my-lib -y
|
|
104
105
|
npx packkit react-app my-app --e2e
|
|
105
106
|
npx packkit node-service api --server fastify --env
|
|
107
|
+
npx packkit fullstack acme --github # web + API + shared, repo created
|
|
106
108
|
npm create packkit@latest my-pkg -- --preset oss --pm pnpm
|
|
107
109
|
`;
|
|
108
110
|
|
|
@@ -135,6 +137,9 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
135
137
|
|
|
136
138
|
config.gitInit = args.git;
|
|
137
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();
|
|
138
143
|
|
|
139
144
|
// Node preflight: the generated project's tools (eslint, vite, vitest) hard-
|
|
140
145
|
// require this floor. npm only *warns* on engines, so catch it here — clearly,
|
|
@@ -247,6 +252,11 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
247
252
|
skipped.length
|
|
248
253
|
? `Kept ${skipped.length} existing file${skipped.length > 1 ? 's' : ''} — Packkit's version was not written: ${skipped.join(', ')}`
|
|
249
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,
|
|
250
260
|
].filter(Boolean);
|
|
251
261
|
|
|
252
262
|
const done =
|
|
@@ -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,36 +16,74 @@ 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
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Run every active feature, collecting its files and package.json fragment.
|
|
27
|
+
* Returns the raw material — file map, the fragments in contribution order,
|
|
28
|
+
* and which feature produced each file — so callers that need provenance (the
|
|
29
|
+
* embedded API's collision detection) get it without a second pass, while
|
|
30
|
+
* `generate` folds it into the same output as before.
|
|
31
|
+
*/
|
|
32
|
+
export function assemble(cfg) {
|
|
26
33
|
const files = {};
|
|
34
|
+
const fileSources = {};
|
|
35
|
+
const fragments = [];
|
|
27
36
|
let pkg = {};
|
|
28
37
|
|
|
29
38
|
for (const feat of features) {
|
|
30
39
|
if (!feat.active(cfg)) continue;
|
|
31
40
|
const out = feat.apply(cfg) || {};
|
|
32
41
|
if (out.files) {
|
|
33
|
-
for (const [path, contents] of Object.entries(out.files))
|
|
42
|
+
for (const [path, contents] of Object.entries(out.files)) {
|
|
43
|
+
(fileSources[path] ||= []).push(feat.id);
|
|
44
|
+
files[path] = contents;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (out.pkg) {
|
|
48
|
+
fragments.push({ source: feat.id, pkg: out.pkg });
|
|
49
|
+
pkg = deepMerge(pkg, out.pkg);
|
|
34
50
|
}
|
|
35
|
-
if (out.pkg) pkg = deepMerge(pkg, out.pkg);
|
|
36
51
|
}
|
|
52
|
+
return { files, fileSources, fragments, pkg };
|
|
53
|
+
}
|
|
37
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Generate, keeping the provenance assemble() produced. One assembly pass feeds
|
|
57
|
+
* both the public files and the embedded API's conflict diagnostics, so the
|
|
58
|
+
* bytes callers get and the provenance they inspect come from the same run.
|
|
59
|
+
*/
|
|
60
|
+
export function generateTracked(input, diagnostics = null) {
|
|
61
|
+
const cfg = normalizeConfig(input, diagnostics);
|
|
62
|
+
if (cfg.monorepo) {
|
|
63
|
+
// The monorepo generator is a separate path with no per-feature fragments.
|
|
64
|
+
return { ...buildMonorepo(cfg), fileSources: {}, fragments: [] };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const { files, fileSources, fragments, pkg } = assemble(cfg);
|
|
38
68
|
files['package.json'] = toJson(finalizePackageJson(pkg));
|
|
69
|
+
files['packkit.json'] = provenance(cfg);
|
|
39
70
|
|
|
40
71
|
return {
|
|
41
72
|
config: cfg,
|
|
42
73
|
files,
|
|
43
74
|
postCommands: postCommands(cfg),
|
|
44
75
|
summary: summarize(cfg, files),
|
|
76
|
+
fileSources,
|
|
77
|
+
fragments,
|
|
45
78
|
};
|
|
46
79
|
}
|
|
47
80
|
|
|
81
|
+
/** Turn a config into a complete set of files. */
|
|
82
|
+
export function generate(input) {
|
|
83
|
+
const { config, files, postCommands, summary } = generateTracked(input);
|
|
84
|
+
return { config, files, postCommands, summary };
|
|
85
|
+
}
|
|
86
|
+
|
|
48
87
|
function postCommands(cfg) {
|
|
49
88
|
const install = {
|
|
50
89
|
npm: 'npm install',
|
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,
|