@rshono/create 1.0.0-rc.0 → 1.0.0-rc.2

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
@@ -22,14 +22,14 @@ Six, each with a default, and each answerable by a flag instead:
22
22
  | Where should it go? | `my-rshono-app` | first positional argument, or `.` |
23
23
  | Where is it deployed? | `node` | `--deploy node\|bun\|deno\|cloudflare\|vercel\|netlify\|aws-lambda` |
24
24
  | Styling | plain CSS | `--tailwind` / `--no-tailwind` |
25
- | Formatting & linting | Prettier + oxlint | `--quality prettier-oxlint\|biome\|oxc\|none` |
25
+ | Formatting & linting | Prettier + oxlint | `--quality prettier-oxlint\|prettier-eslint\|biome\|oxc\|none` |
26
26
  | Install dependencies? | yes | `--no-install` |
27
27
  | Initialize git? | yes, unless nested | `--no-git` |
28
28
 
29
- Plus `--formatter` and `--linter` to set either half of the quality preset on its own, `--force` to
30
- scaffold into a directory that is not empty, `--dry-run` to see the file list and write nothing, and `-y`
31
- to take the defaults for everything not given. **A non-interactive terminal implies `-y`**, so this is one
32
- command in CI or from an agent:
29
+ Plus `--formatter` and `--linter` to set either half of the quality preset on its own, `--pm` to override
30
+ the package manager that was detected, `--force` to scaffold into a directory that is not empty,
31
+ `--dry-run` to see the file list and write nothing, and `-y` to take the defaults for everything not
32
+ given. **A non-interactive terminal implies `-y`**, so this is one command in CI or from an agent:
33
33
 
34
34
  ```bash
35
35
  npm create @rshono@latest my-app -y --deploy cloudflare --tailwind --quality biome
@@ -51,6 +51,7 @@ src/actions.ts a 'use server' action, called from a form that works wit
51
51
  src/components/ layout, home, greet-form ('use client'), 404, 500
52
52
  src/lib/env.ts both sides of the PUBLIC_ boundary in one place
53
53
  src/styles.css element-level CSS, or the Tailwind entry
54
+ pnpm-workspace.yaml pnpm only: which dependency install scripts this app runs (none of them)
54
55
  ```
55
56
 
56
57
  Then, if the dependencies were installed, the scaffold is run through its own formatter — so a fresh
@@ -83,10 +84,16 @@ import { plan, writePlan } from '@rshono/create';
83
84
 
84
85
  ## Two things worth knowing
85
86
 
86
- **There is no ESLint option.** Linting TypeScript with ESLint means `typescript-eslint`, whose peer range is
87
- `typescript >=4.8.4 <6.1.0`; rshono is built and tested against TypeScript 7. `npm install` fails outright
88
- on the conflict, and forcing past it would hand you a linter running against a compiler API it was never
89
- built for. When upstream widens the range, ESLint becomes one more entry in `features/quality.ts`.
87
+ **The ESLint preset pins TypeScript 6.** Linting TypeScript with ESLint means `typescript-eslint`, which
88
+ reads the compiler API directly rather than through a stable interface, so its peer range is
89
+ `typescript >=4.8.4 <6.1.0` below the TypeScript rshono is built and tested against. An app that chooses
90
+ ESLint therefore gets `typescript ~6.0.3`, the newest that range allows: `npm install` would otherwise fail
91
+ outright on the conflict, and forcing past it hands you a linter running against a compiler API it was never
92
+ built for. The framework's declarations compile identically under either version, which is what makes this
93
+ the app's pin and not the framework's — every other preset leaves TypeScript alone. What you trade for
94
+ type-aware rules is a compiler one major behind, and the JavaScript implementation rather than the native
95
+ one, so `typecheck` on a large app is several times slower. When upstream widens the range, the pin in
96
+ `features/quality.ts` is the only thing to delete.
90
97
 
91
98
  **Tailwind is four packages, a `postcss.config.mjs` and one rule in `rshono.config.ts`.** rshono compiles
92
99
  CSS natively and has no PostCSS in it — that is deliberate, and it means an app that wants a plugin chain
@@ -97,9 +104,14 @@ delete to go back to plain CSS.
97
104
 
98
105
  ```bash
99
106
  pnpm --filter @rshono/create build # codegen, then one bundled dist/cli.mjs with no runtime deps
100
- pnpm --filter @rshono/create test # the plan matrix — fast, no I/O
107
+ pnpm --filter @rshono/create test # builds, then the plan matrix and the CLI seconds, no installs
101
108
  CREATE_RSHONO_E2E=1 pnpm --filter @rshono/create test # also: pack, install and build real apps
102
109
  ```
103
110
 
111
+ `plan.test.mjs` is the fast one — the whole matrix of answers in memory, no directory touched.
112
+ `cli.test.mjs` spawns the real bin against temp directories, which is where argument parsing and the
113
+ refusal to overwrite somebody's files are checked. `e2e.test.mjs` is the opt-in one, and the only one that
114
+ installs anything.
115
+
104
116
  `@clack/prompts` (MIT) is bundled rather than depended on, so `npm create` downloads one tarball before it
105
117
  can ask its first question.
package/dist/api.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import {fileURLToPath as __rspack_fileURLToPath} from "node:url";
3
3
  import {dirname as __rspack_dirname} from "node:path";
4
4
  var __rspack_import_meta_dirname__ = __rspack_dirname(__rspack_fileURLToPath(import.meta.url));
5
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
5
+ import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join, posix, sep } from "node:path";
7
7
  import "node:child_process";
8
8
  // The require scope
@@ -31,18 +31,19 @@ var __webpack_exports__ = {};
31
31
  // EXPORTS
32
32
  __webpack_require__.d(__webpack_exports__, {
33
33
  bp: () => (/* reexport */ DEPLOY_TARGET_NAMES),
34
+ ml: () => (/* reexport */ (/* inlined export .ESLINT_TYPESCRIPT */"~6.0.3")),
35
+ wC: () => (/* reexport */ FORMATTER_NAMES),
34
36
  e7: () => (/* reexport */ FRAMEWORK_DEPS),
37
+ At: () => (/* reexport */ LINTER_NAMES),
35
38
  Ab: () => (/* reexport */ PACKAGE_MANAGERS),
36
39
  pm: () => (/* reexport */ QUALITY_PRESETS),
37
40
  s_: () => (/* reexport */ RSHONO_RANGE),
38
41
  RM: () => (/* reexport */ TOOL_VERSIONS),
39
- Jh: () => (/* reexport */ buildPackageJson),
42
+ dh: () => (/* reexport */ conflictingEntries),
40
43
  P8: () => (/* reexport */ deployHint),
41
44
  Lv: () => (/* reexport */ detectPackageManager),
42
- WL: () => (/* reexport */ inspectTarget),
43
- p1: () => (/* reexport */ isDeployTarget),
44
45
  eW: () => (/* reexport */ isValidPackageName),
45
- ml: () => (/* reexport */ packageManager),
46
+ VZ: () => (/* reexport */ packageManager),
46
47
  et: () => (/* reexport */ plan_plan),
47
48
  N7: () => (/* reexport */ selectFeatures),
48
49
  PQ: () => (/* reexport */ toPackageName),
@@ -78,7 +79,11 @@ __webpack_require__.d(__webpack_exports__, {
78
79
 
79
80
  ;// CONCATENATED MODULE: ./src/generated/framework.ts
80
81
  // GENERATED by scripts/codegen.mjs from packages/core — do not edit. Run `pnpm --filter @rshono/create codegen`.
81
- /** The framework release a scaffolded app is pinned to: this package and rshono ship together. */ const RSHONO_VERSION = '1.0.0-rc.0';
82
+ /** The framework release a scaffolded app is pinned to: this package and rshono ship together. */ const RSHONO_VERSION = '1.0.0-rc.2';
83
+ /**
84
+ * The Node range rshono itself declares, restated in every scaffolded app's `engines` — so a CI image
85
+ * or a contributor on an older Node hears it from their package manager rather than from a stack trace.
86
+ */ const NODE_ENGINE = '>=22.1.0';
82
87
  /**
83
88
  * The dependency versions rshono is tested against, copied from its own manifest. Exact where it is
84
89
  * exact — React's RSC internals are coupled across builds, and a generated app has no workspace
@@ -124,7 +129,7 @@ __webpack_require__.d(__webpack_exports__, {
124
129
 
125
130
  ;// CONCATENATED MODULE: ./src/versions.ts
126
131
 
127
-
132
+ /** Passed straight through, so everything generated from the framework reaches the rest of the package here. */
128
133
  /** The framework range a scaffolded app gets. The two packages are released together, so this is ours. */ const RSHONO_RANGE = `^${RSHONO_VERSION}`;
129
134
  /**
130
135
  * Versions for the optional tooling the features can add — the one place in this package where a
@@ -145,8 +150,26 @@ __webpack_require__.d(__webpack_exports__, {
145
150
  '@biomejs/biome': '^2.5.6',
146
151
  oxlint: '^1.76.0',
147
152
  oxfmt: '^0.61.0',
153
+ eslint: '^10.8.0',
154
+ // ESLint's own recommended JavaScript rules, which typescript-eslint layers on top of rather than
155
+ // replaces, and the rules of hooks — the one class of React mistake no type checker sees.
156
+ '@eslint/js': '^10.0.1',
157
+ 'typescript-eslint': '^8.65.0',
158
+ 'eslint-plugin-react-hooks': '^7.1.1',
148
159
  wrangler: '^4.115.0'
149
160
  };
161
+ /**
162
+ * The TypeScript an ESLint app pins, in place of the framework's own — the one deliberate exception to
163
+ * {@link FRAMEWORK_DEPS}, and the reason it is spelled out here.
164
+ *
165
+ * typescript-eslint reads TypeScript's compiler API directly rather than through a stable interface, so
166
+ * it accepts `typescript >=4.8.4 <6.1.0` and nothing above. `~6.0.3` is the newest that satisfies it:
167
+ * patch releases of 6.0, no minor. The framework itself stays on the TypeScript it is tested against —
168
+ * rshono's declarations compile the same under either, which is what makes this pin an app's business
169
+ * and not the framework's.
170
+ *
171
+ * When upstream widens the range, this constant and the ESLint feature's use of it are what to delete.
172
+ */ const ESLINT_TYPESCRIPT = '~6.0.3';
150
173
 
151
174
  ;// CONCATENATED MODULE: ./src/features/deploy.ts
152
175
 
@@ -157,8 +180,11 @@ __webpack_require__.d(__webpack_exports__, {
157
180
  * Deliberately thin. The framework already knows how to arrange its own output for every platform, and
158
181
  * `rshono build` writes the one platform config that has to exist (`wrangler.jsonc`, with the
159
182
  * `compatibility_date` of the day it ran) if the project has none. Generating a second copy here would
160
- * be a copy that goes stale. So a target contributes a `deploy` script, the CLI it needs locally, and
161
- * the build artefacts its platform leaves in the project.
183
+ * be a copy that goes stale.
184
+ *
185
+ * So a target contributes only what is true of it: the command that runs or ships the build, the CLI
186
+ * that command needs installed locally, the directories its platform leaves behind for `.gitignore`, and
187
+ * a note for the step no command covers. Several contribute just one of those.
162
188
  */ const DEPLOY_FEATURES = {
163
189
  // Where a Node build goes from here is a Dockerfile or a process manager, neither of which this can
164
190
  // guess — so the target contributes only the command that runs what was built.
@@ -187,6 +213,11 @@ __webpack_require__.d(__webpack_exports__, {
187
213
  devDependencies: {
188
214
  wrangler: TOOL_VERSIONS.wrangler
189
215
  },
216
+ // wrangler brings workerd, whose install script only picks the platform binary out of the optional
217
+ // dependency that already carries it — `workerd --version` answers without it having run.
218
+ allowBuilds: {
219
+ workerd: false
220
+ },
190
221
  scripts: {
191
222
  deploy: 'rshono build && wrangler deploy'
192
223
  },
@@ -236,8 +267,10 @@ function deployFeature(target) {
236
267
  * deduplicates by `id`, so `formatter: 'biome', linter: 'biome'` contributes one set of files, one
237
268
  * dependency and one pair of scripts.
238
269
  *
239
- * Each tool brings its own `check` script alongside `format`/`lint`, because the writing half and the
240
- * CI half want different exit-code behaviour: `format` rewrites files, `check` fails instead.
270
+ * A formatter brings `format:check` beside `format`, because the writing half and the CI half want
271
+ * different exit-code behaviour: `format` rewrites files, `format:check` fails instead. A linter brings
272
+ * `lint:fix` beside `lint`, for the same reason in the other direction — `lint` is already the failing
273
+ * one. Biome adds a `check` of its own, which is the pair of them in a single pass.
241
274
  */ const PRETTIER = {
242
275
  id: 'prettier',
243
276
  overlays: [
@@ -277,6 +310,30 @@ const OXLINT = {
277
310
  'lint:fix': 'oxlint --fix'
278
311
  }
279
312
  };
313
+ /**
314
+ * The one feature that changes a dependency the framework otherwise decides: typescript-eslint cannot be
315
+ * installed alongside the TypeScript rshono is tested against, so an ESLint app pins the newest one its
316
+ * peer range accepts (see {@link ESLINT_TYPESCRIPT}). Every other preset leaves TypeScript alone.
317
+ *
318
+ * The rules are type-aware, which is the reason to reach for ESLint over a syntax-only linter at all —
319
+ * so the config it ships hands the whole program to the parser rather than linting file by file.
320
+ */ const ESLINT = {
321
+ id: 'eslint',
322
+ overlays: [
323
+ 'eslint'
324
+ ],
325
+ devDependencies: {
326
+ eslint: TOOL_VERSIONS.eslint,
327
+ '@eslint/js': TOOL_VERSIONS["@eslint/js"],
328
+ 'typescript-eslint': TOOL_VERSIONS["typescript-eslint"],
329
+ 'eslint-plugin-react-hooks': TOOL_VERSIONS["eslint-plugin-react-hooks"],
330
+ typescript: (/* inlined export .ESLINT_TYPESCRIPT */"~6.0.3")
331
+ },
332
+ scripts: {
333
+ lint: 'eslint .',
334
+ 'lint:fix': 'eslint . --fix'
335
+ }
336
+ };
280
337
  const BIOME = {
281
338
  id: 'biome',
282
339
  overlays: [
@@ -301,6 +358,7 @@ const FORMATTERS = {
301
358
  };
302
359
  const LINTERS = {
303
360
  oxlint: OXLINT,
361
+ eslint: ESLINT,
304
362
  biome: BIOME,
305
363
  none: null
306
364
  };
@@ -367,6 +425,22 @@ function stylingFeature(styling) {
367
425
 
368
426
  ;// CONCATENATED MODULE: ./src/options.ts
369
427
 
428
+ /*
429
+ * The names each option accepts, spelled once. The types below are derived from them, the CLI validates
430
+ * its flags against them and prints them in `--help`, and `pm.ts` recognises a package manager by them
431
+ * — so a name added here reaches all three without a second list to remember.
432
+ */ const FORMATTER_NAMES = [
433
+ 'prettier',
434
+ 'biome',
435
+ 'oxfmt',
436
+ 'none'
437
+ ];
438
+ const LINTER_NAMES = [
439
+ 'oxlint',
440
+ 'eslint',
441
+ 'biome',
442
+ 'none'
443
+ ];
370
444
  const PACKAGE_MANAGERS = [
371
445
  'npm',
372
446
  'pnpm',
@@ -377,9 +451,6 @@ const DEPLOY_TARGET_NAMES = DEPLOY_TARGETS.map((target)=>target.name);
377
451
  function deployHint(name) {
378
452
  return DEPLOY_TARGETS.find((target)=>target.name === name)?.hint ?? '';
379
453
  }
380
- function isDeployTarget(value) {
381
- return DEPLOY_TARGET_NAMES.includes(value);
382
- }
383
454
  const QUALITY_PRESETS = [
384
455
  {
385
456
  id: 'prettier-oxlint',
@@ -388,6 +459,13 @@ const QUALITY_PRESETS = [
388
459
  formatter: 'prettier',
389
460
  linter: 'oxlint'
390
461
  },
462
+ {
463
+ id: 'prettier-eslint',
464
+ label: 'Prettier + ESLint',
465
+ hint: 'type-aware rules — pins TypeScript 6, which is all typescript-eslint accepts',
466
+ formatter: 'prettier',
467
+ linter: 'eslint'
468
+ },
391
469
  {
392
470
  id: 'biome',
393
471
  label: 'Biome',
@@ -430,11 +508,16 @@ const QUALITY_PRESETS = [
430
508
  return /^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$/.test(name) && name.length <= 214;
431
509
  }
432
510
 
511
+ ;// CONCATENATED MODULE: external "node:fs"
512
+
513
+ ;// CONCATENATED MODULE: external "node:path"
514
+
433
515
  ;// CONCATENATED MODULE: ./src/pkg.ts
434
516
 
435
517
  /**
436
- * The scripts every app gets. `start` is not among them: it is the *Node* launcher, and the deploy
437
- * features each contribute the command their own platform runs what was built with.
518
+ * The scripts every app gets. `start` is not among them, because it means something different per
519
+ * platform: the three targets that run the build themselves node, bun, deno each contribute their
520
+ * own, and a platform target contributes a `deploy` instead, where its platform has one command to give.
438
521
  */ const BASE_SCRIPTS = {
439
522
  dev: 'rshono dev',
440
523
  build: 'rshono build',
@@ -454,6 +537,39 @@ const QUALITY_PRESETS = [
454
537
  function sorted(record) {
455
538
  return Object.fromEntries(Object.entries(record).sort(([a], [b])=>a < b ? -1 : 1));
456
539
  }
540
+ /**
541
+ * The install script every app inherits, from the framework rather than from anything it chose. tsx
542
+ * reads `rshono.config.ts` through esbuild, and esbuild's script only picks the platform binary out of
543
+ * the optional dependency that already carries it — rshono's own repo denies it for the same reason.
544
+ */ const BASE_ALLOW_BUILDS = {
545
+ esbuild: false
546
+ };
547
+ /**
548
+ * pnpm's settings for the new app — written for pnpm and for nobody else.
549
+ *
550
+ * It exists for one field. A dependency with an install script is a question pnpm will not answer on its
551
+ * own: it fails the install, and fails every `pnpm dev` after it, until the project has said whether the
552
+ * script should run. None of the ones an rshono app inherits need to (each is a native package whose
553
+ * binary arrives as an optional dependency), so a fresh app carries the answer rather than meeting
554
+ * `pnpm approve-builds` before it has rendered a page once.
555
+ *
556
+ * In this file rather than under a `pnpm` key in `package.json`, which pnpm 11 no longer reads, single-
557
+ * package projects included. What lands here is a decision about *this* app: a scaffolded file the app
558
+ * owns from then on, not something the framework reaches back into.
559
+ */ function buildPnpmSettings(features) {
560
+ const allowBuilds = {
561
+ ...BASE_ALLOW_BUILDS
562
+ };
563
+ for (const feature of features)Object.assign(allowBuilds, feature.allowBuilds);
564
+ return [
565
+ '# Which dependencies may run an install script. pnpm runs none it has not been told about, and',
566
+ '# fails the install rather than skip one quietly — so anything added later belongs here too.',
567
+ '# `false` means the script was looked at: these ship their real binary as an optional dependency.',
568
+ 'allowBuilds:',
569
+ ...Object.entries(sorted(allowBuilds)).map(([name, allowed])=>` ${name}: ${allowed}`),
570
+ ''
571
+ ].join('\n');
572
+ }
457
573
  /**
458
574
  * Assembles `package.json` from the answers and whatever the selected features contribute.
459
575
  *
@@ -485,10 +601,9 @@ function sorted(record) {
485
601
  version: '0.1.0',
486
602
  private: true,
487
603
  type: 'module',
488
- // The floor the framework declares. Stated here too so a CI image or a contributor on an older
489
- // Node finds out from their package manager rather than from a stack trace.
604
+ // Generated from the framework's own manifest, so the app's floor cannot drift below rshono's.
490
605
  engines: {
491
- node: '>=22.1.0'
606
+ node: NODE_ENGINE
492
607
  },
493
608
  scripts,
494
609
  dependencies: sorted(dependencies),
@@ -504,25 +619,25 @@ function sorted(record) {
504
619
  return `${JSON.stringify(ordered, null, 2)}\n`;
505
620
  }
506
621
 
507
- ;// CONCATENATED MODULE: external "node:fs"
508
-
509
- ;// CONCATENATED MODULE: external "node:path"
510
-
511
622
  ;// CONCATENATED MODULE: ./src/render.ts
512
623
 
513
- const TOKEN_PATTERN = /__[A-Z][A-Z\d_]*__/g;
624
+ /**
625
+ * `{{NAME}}`, and not the `__NAME__` this used to be. A template is a real file that real tools run
626
+ * over, and in markdown `__NAME__` *is* strong emphasis — Prettier rewrites it to `**NAME**`, which
627
+ * turns a token into literal text that no substitution will ever match again. `{{…}}` means nothing to
628
+ * any of the formats these templates are written in.
629
+ */ const TOKEN_PATTERN = /\{\{[A-Z][A-Z\d_]*\}\}/g;
514
630
  function tokensFor(answers, pm) {
515
631
  return {
516
- __PROJECT_NAME__: answers.packageName,
517
- __DEPLOY_TARGET__: answers.deploy,
518
- __DEPLOY_HINT__: deployHint(answers.deploy),
519
- __PM__: pm.name,
520
- __PM_RUN__: pm.run
632
+ '{{PROJECT_NAME}}': answers.packageName,
633
+ '{{DEPLOY_TARGET}}': answers.deploy,
634
+ '{{DEPLOY_HINT}}': deployHint(answers.deploy),
635
+ '{{PM_RUN}}': pm.run
521
636
  };
522
637
  }
523
638
  /**
524
639
  * Substitutes tokens, and throws on one it doesn't know — a typo in a template would otherwise ship a
525
- * literal `__PORJECT_NAME__` into somebody's new app, which no test of the generator's logic would
640
+ * literal `{{PORJECT_NAME}}` into somebody's new app, which no test of the generator's logic would
526
641
  * catch.
527
642
  */ function render(contents, tokens, source) {
528
643
  return contents.replace(TOKEN_PATTERN, (token)=>{
@@ -598,6 +713,7 @@ function readTemplateDir(dir) {
598
713
  const gitignore = files.get('.gitignore');
599
714
  if (gitignore) files.set('.gitignore', appendGitignore(gitignore, features));
600
715
  files.set('package.json', buildPackageJson(answers, features, pm));
716
+ if (pm.name === 'pnpm') files.set('pnpm-workspace.yaml', buildPnpmSettings(features));
601
717
  return {
602
718
  // Sorted, so both the write order and a test's snapshot are stable.
603
719
  files: new Map([
@@ -612,6 +728,7 @@ function readTemplateDir(dir) {
612
728
 
613
729
  ;// CONCATENATED MODULE: ./src/pm.ts
614
730
 
731
+
615
732
  const INSTALL = {
616
733
  npm: [
617
734
  'install'
@@ -631,7 +748,7 @@ const RUN = {
631
748
  bun: 'bun'
632
749
  };
633
750
  function isKnown(name) {
634
- return name === 'npm' || name === 'pnpm' || name === 'yarn' || name === 'bun';
751
+ return PACKAGE_MANAGERS.includes(name);
635
752
  }
636
753
  function packageManager(name, version) {
637
754
  return {
@@ -688,15 +805,20 @@ function run(pm, args, cwd) {
688
805
  '.vscode',
689
806
  'Thumbs.db'
690
807
  ]);
691
- function inspectTarget(dir) {
692
- if (!existsSync(dir)) return {
693
- exists: false,
694
- conflicts: []
695
- };
696
- return {
697
- exists: true,
698
- conflicts: readdirSync(dir).filter((entry)=>!IGNORED_ENTRIES.has(entry))
699
- };
808
+ /**
809
+ * What is already at the target path, which is what decides whether scaffolding into it is safe: the
810
+ * entries already there, ignoring the ones a fresh clone or an editor leaves behind.
811
+ *
812
+ * A path that does not exist yet is no conflict. A path that exists and is *not* a directory is not
813
+ * something `--force` should be able to write into, so it throws rather than reporting an empty list —
814
+ * otherwise `create-rshono README.md` gets as far as `mkdir` before failing on a raw ENOTDIR.
815
+ */ function conflictingEntries(dir) {
816
+ const stats = statSync(dir, {
817
+ throwIfNoEntry: false
818
+ });
819
+ if (!stats) return [];
820
+ if (!stats.isDirectory()) throw new Error(`${dir} already exists and is not a directory.`);
821
+ return readdirSync(dir).filter((entry)=>!IGNORED_ENTRIES.has(entry));
700
822
  }
701
823
  /**
702
824
  * Writes the plan. Directories are created as needed, and files are written with the plan's own
@@ -718,7 +840,16 @@ function inspectTarget(dir) {
718
840
  /**
719
841
  * The generator without the CLI around it: answers in, the exact set of files out. This is what the
720
842
  * test suite asserts on — the whole matrix of options, in memory, with no directory to clean up — and
721
- * what another tool would call to scaffold an app itself.
843
+ * what another tool would call to scaffold an app itself:
844
+ *
845
+ * ```ts
846
+ * import { packageManager, plan, writePlan } from '@rshono/create';
847
+ *
848
+ * const answers = { packageName: 'my-app', deploy: 'node', styling: 'css', formatter: 'prettier', linter: 'oxlint' };
849
+ * writePlan(plan(answers, packageManager('npm')), './my-app');
850
+ * ```
851
+ *
852
+ * `package.json` is assembled inside `plan`, so nothing here needs to be composed by hand.
722
853
  *
723
854
  * @packageDocumentation
724
855
  */
@@ -728,22 +859,22 @@ function inspectTarget(dir) {
728
859
 
729
860
 
730
861
 
731
-
732
862
  var __webpack_exports__DEPLOY_TARGET_NAMES = __webpack_exports__.bp;
863
+ var __webpack_exports__ESLINT_TYPESCRIPT = __webpack_exports__.ml;
864
+ var __webpack_exports__FORMATTER_NAMES = __webpack_exports__.wC;
733
865
  var __webpack_exports__FRAMEWORK_DEPS = __webpack_exports__.e7;
866
+ var __webpack_exports__LINTER_NAMES = __webpack_exports__.At;
734
867
  var __webpack_exports__PACKAGE_MANAGERS = __webpack_exports__.Ab;
735
868
  var __webpack_exports__QUALITY_PRESETS = __webpack_exports__.pm;
736
869
  var __webpack_exports__RSHONO_RANGE = __webpack_exports__.s_;
737
870
  var __webpack_exports__TOOL_VERSIONS = __webpack_exports__.RM;
738
- var __webpack_exports__buildPackageJson = __webpack_exports__.Jh;
871
+ var __webpack_exports__conflictingEntries = __webpack_exports__.dh;
739
872
  var __webpack_exports__deployHint = __webpack_exports__.P8;
740
873
  var __webpack_exports__detectPackageManager = __webpack_exports__.Lv;
741
- var __webpack_exports__inspectTarget = __webpack_exports__.WL;
742
- var __webpack_exports__isDeployTarget = __webpack_exports__.p1;
743
874
  var __webpack_exports__isValidPackageName = __webpack_exports__.eW;
744
- var __webpack_exports__packageManager = __webpack_exports__.ml;
875
+ var __webpack_exports__packageManager = __webpack_exports__.VZ;
745
876
  var __webpack_exports__plan = __webpack_exports__.et;
746
877
  var __webpack_exports__selectFeatures = __webpack_exports__.N7;
747
878
  var __webpack_exports__toPackageName = __webpack_exports__.PQ;
748
879
  var __webpack_exports__writePlan = __webpack_exports__.pD;
749
- export { __webpack_exports__DEPLOY_TARGET_NAMES as DEPLOY_TARGET_NAMES, __webpack_exports__FRAMEWORK_DEPS as FRAMEWORK_DEPS, __webpack_exports__PACKAGE_MANAGERS as PACKAGE_MANAGERS, __webpack_exports__QUALITY_PRESETS as QUALITY_PRESETS, __webpack_exports__RSHONO_RANGE as RSHONO_RANGE, __webpack_exports__TOOL_VERSIONS as TOOL_VERSIONS, __webpack_exports__buildPackageJson as buildPackageJson, __webpack_exports__deployHint as deployHint, __webpack_exports__detectPackageManager as detectPackageManager, __webpack_exports__inspectTarget as inspectTarget, __webpack_exports__isDeployTarget as isDeployTarget, __webpack_exports__isValidPackageName as isValidPackageName, __webpack_exports__packageManager as packageManager, __webpack_exports__plan as plan, __webpack_exports__selectFeatures as selectFeatures, __webpack_exports__toPackageName as toPackageName, __webpack_exports__writePlan as writePlan };
880
+ export { __webpack_exports__DEPLOY_TARGET_NAMES as DEPLOY_TARGET_NAMES, __webpack_exports__ESLINT_TYPESCRIPT as ESLINT_TYPESCRIPT, __webpack_exports__FORMATTER_NAMES as FORMATTER_NAMES, __webpack_exports__FRAMEWORK_DEPS as FRAMEWORK_DEPS, __webpack_exports__LINTER_NAMES as LINTER_NAMES, __webpack_exports__PACKAGE_MANAGERS as PACKAGE_MANAGERS, __webpack_exports__QUALITY_PRESETS as QUALITY_PRESETS, __webpack_exports__RSHONO_RANGE as RSHONO_RANGE, __webpack_exports__TOOL_VERSIONS as TOOL_VERSIONS, __webpack_exports__conflictingEntries as conflictingEntries, __webpack_exports__deployHint as deployHint, __webpack_exports__detectPackageManager as detectPackageManager, __webpack_exports__isValidPackageName as isValidPackageName, __webpack_exports__packageManager as packageManager, __webpack_exports__plan as plan, __webpack_exports__selectFeatures as selectFeatures, __webpack_exports__toPackageName as toPackageName, __webpack_exports__writePlan as writePlan };
package/dist/cli.mjs CHANGED
@@ -6,7 +6,7 @@ import { parseArgs, styleText as external_node_util_styleText } from "node:util"
6
6
  import node_process, { stdin as external_node_process_stdin, stdout as external_node_process_stdout } from "node:process";
7
7
  import node_readline from "node:readline";
8
8
  import "node:tty";
9
- import { existsSync as external_node_fs_existsSync, mkdirSync, readFileSync, readdirSync as external_node_fs_readdirSync, writeFileSync } from "node:fs";
9
+ import { mkdirSync, readFileSync, readdirSync as external_node_fs_readdirSync, statSync, writeFileSync } from "node:fs";
10
10
  import { basename, dirname as external_node_path_dirname, join as external_node_path_join, posix, relative as external_node_path_relative, resolve, sep } from "node:path";
11
11
  import { spawnSync } from "node:child_process";
12
12
  var __webpack_modules__ = ({
@@ -2943,7 +2943,11 @@ function hasGit(cwd) {
2943
2943
 
2944
2944
  ;// CONCATENATED MODULE: ./src/generated/framework.ts
2945
2945
  // GENERATED by scripts/codegen.mjs from packages/core — do not edit. Run `pnpm --filter @rshono/create codegen`.
2946
- /** The framework release a scaffolded app is pinned to: this package and rshono ship together. */ const RSHONO_VERSION = '1.0.0-rc.0';
2946
+ /** The framework release a scaffolded app is pinned to: this package and rshono ship together. */ const RSHONO_VERSION = '1.0.0-rc.2';
2947
+ /**
2948
+ * The Node range rshono itself declares, restated in every scaffolded app's `engines` — so a CI image
2949
+ * or a contributor on an older Node hears it from their package manager rather than from a stack trace.
2950
+ */ const NODE_ENGINE = '>=22.1.0';
2947
2951
  /**
2948
2952
  * The dependency versions rshono is tested against, copied from its own manifest. Exact where it is
2949
2953
  * exact — React's RSC internals are coupled across builds, and a generated app has no workspace
@@ -2989,19 +2993,32 @@ function hasGit(cwd) {
2989
2993
 
2990
2994
  ;// CONCATENATED MODULE: ./src/options.ts
2991
2995
 
2992
- const PACKAGE_MANAGERS = (/* unused pure expression or super */ null && ([
2996
+ /*
2997
+ * The names each option accepts, spelled once. The types below are derived from them, the CLI validates
2998
+ * its flags against them and prints them in `--help`, and `pm.ts` recognises a package manager by them
2999
+ * — so a name added here reaches all three without a second list to remember.
3000
+ */ const FORMATTER_NAMES = [
3001
+ 'prettier',
3002
+ 'biome',
3003
+ 'oxfmt',
3004
+ 'none'
3005
+ ];
3006
+ const LINTER_NAMES = [
3007
+ 'oxlint',
3008
+ 'eslint',
3009
+ 'biome',
3010
+ 'none'
3011
+ ];
3012
+ const PACKAGE_MANAGERS = [
2993
3013
  'npm',
2994
3014
  'pnpm',
2995
3015
  'yarn',
2996
3016
  'bun'
2997
- ]));
3017
+ ];
2998
3018
  const DEPLOY_TARGET_NAMES = DEPLOY_TARGETS.map((target)=>target.name);
2999
3019
  function deployHint(name) {
3000
3020
  return DEPLOY_TARGETS.find((target)=>target.name === name)?.hint ?? '';
3001
3021
  }
3002
- function isDeployTarget(value) {
3003
- return DEPLOY_TARGET_NAMES.includes(value);
3004
- }
3005
3022
  const QUALITY_PRESETS = [
3006
3023
  {
3007
3024
  id: 'prettier-oxlint',
@@ -3010,6 +3027,13 @@ const QUALITY_PRESETS = [
3010
3027
  formatter: 'prettier',
3011
3028
  linter: 'oxlint'
3012
3029
  },
3030
+ {
3031
+ id: 'prettier-eslint',
3032
+ label: 'Prettier + ESLint',
3033
+ hint: 'type-aware rules — pins TypeScript 6, which is all typescript-eslint accepts',
3034
+ formatter: 'prettier',
3035
+ linter: 'eslint'
3036
+ },
3013
3037
  {
3014
3038
  id: 'biome',
3015
3039
  label: 'Biome',
@@ -3081,7 +3105,7 @@ const QUALITY_PRESETS = [
3081
3105
 
3082
3106
  ;// CONCATENATED MODULE: ./src/versions.ts
3083
3107
 
3084
-
3108
+ /** Passed straight through, so everything generated from the framework reaches the rest of the package here. */
3085
3109
  /** The framework range a scaffolded app gets. The two packages are released together, so this is ours. */ const RSHONO_RANGE = `^${RSHONO_VERSION}`;
3086
3110
  /**
3087
3111
  * Versions for the optional tooling the features can add — the one place in this package where a
@@ -3102,8 +3126,26 @@ const QUALITY_PRESETS = [
3102
3126
  '@biomejs/biome': '^2.5.6',
3103
3127
  oxlint: '^1.76.0',
3104
3128
  oxfmt: '^0.61.0',
3129
+ eslint: '^10.8.0',
3130
+ // ESLint's own recommended JavaScript rules, which typescript-eslint layers on top of rather than
3131
+ // replaces, and the rules of hooks — the one class of React mistake no type checker sees.
3132
+ '@eslint/js': '^10.0.1',
3133
+ 'typescript-eslint': '^8.65.0',
3134
+ 'eslint-plugin-react-hooks': '^7.1.1',
3105
3135
  wrangler: '^4.115.0'
3106
3136
  };
3137
+ /**
3138
+ * The TypeScript an ESLint app pins, in place of the framework's own — the one deliberate exception to
3139
+ * {@link FRAMEWORK_DEPS}, and the reason it is spelled out here.
3140
+ *
3141
+ * typescript-eslint reads TypeScript's compiler API directly rather than through a stable interface, so
3142
+ * it accepts `typescript >=4.8.4 <6.1.0` and nothing above. `~6.0.3` is the newest that satisfies it:
3143
+ * patch releases of 6.0, no minor. The framework itself stays on the TypeScript it is tested against —
3144
+ * rshono's declarations compile the same under either, which is what makes this pin an app's business
3145
+ * and not the framework's.
3146
+ *
3147
+ * When upstream widens the range, this constant and the ESLint feature's use of it are what to delete.
3148
+ */ const ESLINT_TYPESCRIPT = '~6.0.3';
3107
3149
 
3108
3150
  ;// CONCATENATED MODULE: ./src/features/deploy.ts
3109
3151
 
@@ -3114,8 +3156,11 @@ const QUALITY_PRESETS = [
3114
3156
  * Deliberately thin. The framework already knows how to arrange its own output for every platform, and
3115
3157
  * `rshono build` writes the one platform config that has to exist (`wrangler.jsonc`, with the
3116
3158
  * `compatibility_date` of the day it ran) if the project has none. Generating a second copy here would
3117
- * be a copy that goes stale. So a target contributes a `deploy` script, the CLI it needs locally, and
3118
- * the build artefacts its platform leaves in the project.
3159
+ * be a copy that goes stale.
3160
+ *
3161
+ * So a target contributes only what is true of it: the command that runs or ships the build, the CLI
3162
+ * that command needs installed locally, the directories its platform leaves behind for `.gitignore`, and
3163
+ * a note for the step no command covers. Several contribute just one of those.
3119
3164
  */ const DEPLOY_FEATURES = {
3120
3165
  // Where a Node build goes from here is a Dockerfile or a process manager, neither of which this can
3121
3166
  // guess — so the target contributes only the command that runs what was built.
@@ -3144,6 +3189,11 @@ const QUALITY_PRESETS = [
3144
3189
  devDependencies: {
3145
3190
  wrangler: TOOL_VERSIONS.wrangler
3146
3191
  },
3192
+ // wrangler brings workerd, whose install script only picks the platform binary out of the optional
3193
+ // dependency that already carries it — `workerd --version` answers without it having run.
3194
+ allowBuilds: {
3195
+ workerd: false
3196
+ },
3147
3197
  scripts: {
3148
3198
  deploy: 'rshono build && wrangler deploy'
3149
3199
  },
@@ -3193,8 +3243,10 @@ function deployFeature(target) {
3193
3243
  * deduplicates by `id`, so `formatter: 'biome', linter: 'biome'` contributes one set of files, one
3194
3244
  * dependency and one pair of scripts.
3195
3245
  *
3196
- * Each tool brings its own `check` script alongside `format`/`lint`, because the writing half and the
3197
- * CI half want different exit-code behaviour: `format` rewrites files, `check` fails instead.
3246
+ * A formatter brings `format:check` beside `format`, because the writing half and the CI half want
3247
+ * different exit-code behaviour: `format` rewrites files, `format:check` fails instead. A linter brings
3248
+ * `lint:fix` beside `lint`, for the same reason in the other direction — `lint` is already the failing
3249
+ * one. Biome adds a `check` of its own, which is the pair of them in a single pass.
3198
3250
  */ const PRETTIER = {
3199
3251
  id: 'prettier',
3200
3252
  overlays: [
@@ -3234,6 +3286,30 @@ const OXLINT = {
3234
3286
  'lint:fix': 'oxlint --fix'
3235
3287
  }
3236
3288
  };
3289
+ /**
3290
+ * The one feature that changes a dependency the framework otherwise decides: typescript-eslint cannot be
3291
+ * installed alongside the TypeScript rshono is tested against, so an ESLint app pins the newest one its
3292
+ * peer range accepts (see {@link ESLINT_TYPESCRIPT}). Every other preset leaves TypeScript alone.
3293
+ *
3294
+ * The rules are type-aware, which is the reason to reach for ESLint over a syntax-only linter at all —
3295
+ * so the config it ships hands the whole program to the parser rather than linting file by file.
3296
+ */ const ESLINT = {
3297
+ id: 'eslint',
3298
+ overlays: [
3299
+ 'eslint'
3300
+ ],
3301
+ devDependencies: {
3302
+ eslint: TOOL_VERSIONS.eslint,
3303
+ '@eslint/js': TOOL_VERSIONS["@eslint/js"],
3304
+ 'typescript-eslint': TOOL_VERSIONS["typescript-eslint"],
3305
+ 'eslint-plugin-react-hooks': TOOL_VERSIONS["eslint-plugin-react-hooks"],
3306
+ typescript: (/* inlined export .ESLINT_TYPESCRIPT */"~6.0.3")
3307
+ },
3308
+ scripts: {
3309
+ lint: 'eslint .',
3310
+ 'lint:fix': 'eslint . --fix'
3311
+ }
3312
+ };
3237
3313
  const BIOME = {
3238
3314
  id: 'biome',
3239
3315
  overlays: [
@@ -3258,6 +3334,7 @@ const FORMATTERS = {
3258
3334
  };
3259
3335
  const LINTERS = {
3260
3336
  oxlint: OXLINT,
3337
+ eslint: ESLINT,
3261
3338
  biome: BIOME,
3262
3339
  none: null
3263
3340
  };
@@ -3325,8 +3402,9 @@ function stylingFeature(styling) {
3325
3402
  ;// CONCATENATED MODULE: ./src/pkg.ts
3326
3403
 
3327
3404
  /**
3328
- * The scripts every app gets. `start` is not among them: it is the *Node* launcher, and the deploy
3329
- * features each contribute the command their own platform runs what was built with.
3405
+ * The scripts every app gets. `start` is not among them, because it means something different per
3406
+ * platform: the three targets that run the build themselves node, bun, deno each contribute their
3407
+ * own, and a platform target contributes a `deploy` instead, where its platform has one command to give.
3330
3408
  */ const BASE_SCRIPTS = {
3331
3409
  dev: 'rshono dev',
3332
3410
  build: 'rshono build',
@@ -3346,6 +3424,39 @@ function stylingFeature(styling) {
3346
3424
  function sorted(record) {
3347
3425
  return Object.fromEntries(Object.entries(record).sort(([a], [b])=>a < b ? -1 : 1));
3348
3426
  }
3427
+ /**
3428
+ * The install script every app inherits, from the framework rather than from anything it chose. tsx
3429
+ * reads `rshono.config.ts` through esbuild, and esbuild's script only picks the platform binary out of
3430
+ * the optional dependency that already carries it — rshono's own repo denies it for the same reason.
3431
+ */ const BASE_ALLOW_BUILDS = {
3432
+ esbuild: false
3433
+ };
3434
+ /**
3435
+ * pnpm's settings for the new app — written for pnpm and for nobody else.
3436
+ *
3437
+ * It exists for one field. A dependency with an install script is a question pnpm will not answer on its
3438
+ * own: it fails the install, and fails every `pnpm dev` after it, until the project has said whether the
3439
+ * script should run. None of the ones an rshono app inherits need to (each is a native package whose
3440
+ * binary arrives as an optional dependency), so a fresh app carries the answer rather than meeting
3441
+ * `pnpm approve-builds` before it has rendered a page once.
3442
+ *
3443
+ * In this file rather than under a `pnpm` key in `package.json`, which pnpm 11 no longer reads, single-
3444
+ * package projects included. What lands here is a decision about *this* app: a scaffolded file the app
3445
+ * owns from then on, not something the framework reaches back into.
3446
+ */ function buildPnpmSettings(features) {
3447
+ const allowBuilds = {
3448
+ ...BASE_ALLOW_BUILDS
3449
+ };
3450
+ for (const feature of features)Object.assign(allowBuilds, feature.allowBuilds);
3451
+ return [
3452
+ '# Which dependencies may run an install script. pnpm runs none it has not been told about, and',
3453
+ '# fails the install rather than skip one quietly — so anything added later belongs here too.',
3454
+ '# `false` means the script was looked at: these ship their real binary as an optional dependency.',
3455
+ 'allowBuilds:',
3456
+ ...Object.entries(sorted(allowBuilds)).map(([name, allowed])=>` ${name}: ${allowed}`),
3457
+ ''
3458
+ ].join('\n');
3459
+ }
3349
3460
  /**
3350
3461
  * Assembles `package.json` from the answers and whatever the selected features contribute.
3351
3462
  *
@@ -3377,10 +3488,9 @@ function sorted(record) {
3377
3488
  version: '0.1.0',
3378
3489
  private: true,
3379
3490
  type: 'module',
3380
- // The floor the framework declares. Stated here too so a CI image or a contributor on an older
3381
- // Node finds out from their package manager rather than from a stack trace.
3491
+ // Generated from the framework's own manifest, so the app's floor cannot drift below rshono's.
3382
3492
  engines: {
3383
- node: '>=22.1.0'
3493
+ node: NODE_ENGINE
3384
3494
  },
3385
3495
  scripts,
3386
3496
  dependencies: sorted(dependencies),
@@ -3398,19 +3508,23 @@ function sorted(record) {
3398
3508
 
3399
3509
  ;// CONCATENATED MODULE: ./src/render.ts
3400
3510
 
3401
- const TOKEN_PATTERN = /__[A-Z][A-Z\d_]*__/g;
3511
+ /**
3512
+ * `{{NAME}}`, and not the `__NAME__` this used to be. A template is a real file that real tools run
3513
+ * over, and in markdown `__NAME__` *is* strong emphasis — Prettier rewrites it to `**NAME**`, which
3514
+ * turns a token into literal text that no substitution will ever match again. `{{…}}` means nothing to
3515
+ * any of the formats these templates are written in.
3516
+ */ const TOKEN_PATTERN = /\{\{[A-Z][A-Z\d_]*\}\}/g;
3402
3517
  function tokensFor(answers, pm) {
3403
3518
  return {
3404
- __PROJECT_NAME__: answers.packageName,
3405
- __DEPLOY_TARGET__: answers.deploy,
3406
- __DEPLOY_HINT__: deployHint(answers.deploy),
3407
- __PM__: pm.name,
3408
- __PM_RUN__: pm.run
3519
+ '{{PROJECT_NAME}}': answers.packageName,
3520
+ '{{DEPLOY_TARGET}}': answers.deploy,
3521
+ '{{DEPLOY_HINT}}': deployHint(answers.deploy),
3522
+ '{{PM_RUN}}': pm.run
3409
3523
  };
3410
3524
  }
3411
3525
  /**
3412
3526
  * Substitutes tokens, and throws on one it doesn't know — a typo in a template would otherwise ship a
3413
- * literal `__PORJECT_NAME__` into somebody's new app, which no test of the generator's logic would
3527
+ * literal `{{PORJECT_NAME}}` into somebody's new app, which no test of the generator's logic would
3414
3528
  * catch.
3415
3529
  */ function render(contents, tokens, source) {
3416
3530
  return contents.replace(TOKEN_PATTERN, (token)=>{
@@ -3486,6 +3600,7 @@ function readTemplateDir(dir) {
3486
3600
  const gitignore = files.get('.gitignore');
3487
3601
  if (gitignore) files.set('.gitignore', appendGitignore(gitignore, features));
3488
3602
  files.set('package.json', buildPackageJson(answers, features, pm));
3603
+ if (pm.name === 'pnpm') files.set('pnpm-workspace.yaml', buildPnpmSettings(features));
3489
3604
  return {
3490
3605
  // Sorted, so both the write order and a test's snapshot are stable.
3491
3606
  files: new Map([
@@ -3498,6 +3613,7 @@ function readTemplateDir(dir) {
3498
3613
 
3499
3614
  ;// CONCATENATED MODULE: ./src/pm.ts
3500
3615
 
3616
+
3501
3617
  const INSTALL = {
3502
3618
  npm: [
3503
3619
  'install'
@@ -3517,7 +3633,7 @@ const RUN = {
3517
3633
  bun: 'bun'
3518
3634
  };
3519
3635
  function isKnown(name) {
3520
- return name === 'npm' || name === 'pnpm' || name === 'yarn' || name === 'bun';
3636
+ return PACKAGE_MANAGERS.includes(name);
3521
3637
  }
3522
3638
  function packageManager(name, version) {
3523
3639
  return {
@@ -3611,15 +3727,20 @@ function run(pm, args, cwd) {
3611
3727
  '.vscode',
3612
3728
  'Thumbs.db'
3613
3729
  ]);
3614
- function inspectTarget(dir) {
3615
- if (!external_node_fs_existsSync(dir)) return {
3616
- exists: false,
3617
- conflicts: []
3618
- };
3619
- return {
3620
- exists: true,
3621
- conflicts: external_node_fs_readdirSync(dir).filter((entry)=>!IGNORED_ENTRIES.has(entry))
3622
- };
3730
+ /**
3731
+ * What is already at the target path, which is what decides whether scaffolding into it is safe: the
3732
+ * entries already there, ignoring the ones a fresh clone or an editor leaves behind.
3733
+ *
3734
+ * A path that does not exist yet is no conflict. A path that exists and is *not* a directory is not
3735
+ * something `--force` should be able to write into, so it throws rather than reporting an empty list —
3736
+ * otherwise `create-rshono README.md` gets as far as `mkdir` before failing on a raw ENOTDIR.
3737
+ */ function conflictingEntries(dir) {
3738
+ const stats = statSync(dir, {
3739
+ throwIfNoEntry: false
3740
+ });
3741
+ if (!stats) return [];
3742
+ if (!stats.isDirectory()) throw new Error(`${dir} already exists and is not a directory.`);
3743
+ return external_node_fs_readdirSync(dir).filter((entry)=>!IGNORED_ENTRIES.has(entry));
3623
3744
  }
3624
3745
  /**
3625
3746
  * Writes the plan. Directories are created as needed, and files are written with the plan's own
@@ -3647,8 +3768,9 @@ function inspectTarget(dir) {
3647
3768
 
3648
3769
 
3649
3770
 
3771
+
3650
3772
  const DEFAULT_DIRECTORY = 'my-rshono-app';
3651
- const HELP = `create-rshono — scaffold a new rshono app
3773
+ /** Every accepted name comes from `options.ts`, so the help cannot promise one the validation refuses. */ const HELP = `create-rshono — scaffold a new rshono app
3652
3774
 
3653
3775
  Usage:
3654
3776
  npm create @rshono@latest [directory] [options]
@@ -3658,9 +3780,9 @@ Options:
3658
3780
  -d, --deploy <target> ${DEPLOY_TARGET_NAMES.join(' | ')}
3659
3781
  --tailwind Tailwind CSS (--no-tailwind for plain CSS)
3660
3782
  --quality <preset> ${QUALITY_PRESETS.map((preset)=>preset.id).join(' | ')}
3661
- --formatter <name> prettier | biome | oxfmt | none (overrides --quality)
3662
- --linter <name> oxlint | biome | none (overrides --quality)
3663
- --pm <name> npm | pnpm | yarn | bun (default: whatever ran this)
3783
+ --formatter <name> ${FORMATTER_NAMES.join(' | ')} (overrides --quality)
3784
+ --linter <name> ${LINTER_NAMES.join(' | ')} (overrides --quality; eslint pins TypeScript 6)
3785
+ --pm <name> ${PACKAGE_MANAGERS.join(' | ')} (default: whatever ran this)
3664
3786
  --no-install write the files and stop
3665
3787
  --no-git do not initialize a repository
3666
3788
  --force scaffold into a directory that is not empty
@@ -3688,92 +3810,94 @@ function fail(message) {
3688
3810
  if (off) return false;
3689
3811
  return undefined;
3690
3812
  }
3691
- async function main() {
3692
- const { values, positionals } = parseArgs({
3693
- options: {
3694
- yes: {
3695
- type: 'boolean',
3696
- short: 'y'
3697
- },
3698
- deploy: {
3699
- type: 'string',
3700
- short: 'd'
3701
- },
3702
- tailwind: {
3703
- type: 'boolean'
3704
- },
3705
- 'no-tailwind': {
3706
- type: 'boolean'
3707
- },
3708
- quality: {
3709
- type: 'string'
3710
- },
3711
- formatter: {
3712
- type: 'string'
3713
- },
3714
- linter: {
3715
- type: 'string'
3716
- },
3717
- pm: {
3718
- type: 'string'
3719
- },
3720
- install: {
3721
- type: 'boolean'
3722
- },
3723
- 'no-install': {
3724
- type: 'boolean'
3725
- },
3726
- git: {
3727
- type: 'boolean'
3728
- },
3729
- 'no-git': {
3730
- type: 'boolean'
3731
- },
3732
- force: {
3733
- type: 'boolean'
3734
- },
3735
- 'dry-run': {
3736
- type: 'boolean'
3737
- },
3738
- help: {
3739
- type: 'boolean',
3740
- short: 'h'
3813
+ /**
3814
+ * `parseArgs` rejects an unknown flag with a message that names it but nothing else — so a mistyped
3815
+ * `--tailwnid` reads as a wall of text about positional arguments. Pointing at `--help` is the whole
3816
+ * addition.
3817
+ */ function parse() {
3818
+ try {
3819
+ return parseArgs({
3820
+ options: {
3821
+ yes: {
3822
+ type: 'boolean',
3823
+ short: 'y'
3824
+ },
3825
+ deploy: {
3826
+ type: 'string',
3827
+ short: 'd'
3828
+ },
3829
+ tailwind: {
3830
+ type: 'boolean'
3831
+ },
3832
+ 'no-tailwind': {
3833
+ type: 'boolean'
3834
+ },
3835
+ quality: {
3836
+ type: 'string'
3837
+ },
3838
+ formatter: {
3839
+ type: 'string'
3840
+ },
3841
+ linter: {
3842
+ type: 'string'
3843
+ },
3844
+ pm: {
3845
+ type: 'string'
3846
+ },
3847
+ install: {
3848
+ type: 'boolean'
3849
+ },
3850
+ 'no-install': {
3851
+ type: 'boolean'
3852
+ },
3853
+ git: {
3854
+ type: 'boolean'
3855
+ },
3856
+ 'no-git': {
3857
+ type: 'boolean'
3858
+ },
3859
+ force: {
3860
+ type: 'boolean'
3861
+ },
3862
+ 'dry-run': {
3863
+ type: 'boolean'
3864
+ },
3865
+ help: {
3866
+ type: 'boolean',
3867
+ short: 'h'
3868
+ },
3869
+ version: {
3870
+ type: 'boolean',
3871
+ short: 'v'
3872
+ }
3741
3873
  },
3742
- version: {
3743
- type: 'boolean',
3744
- short: 'v'
3745
- }
3746
- },
3747
- allowPositionals: true
3748
- });
3874
+ allowPositionals: true
3875
+ });
3876
+ } catch (error) {
3877
+ fail(`${error instanceof Error ? error.message : String(error)}\n\nRun with --help to see the options.`);
3878
+ }
3879
+ }
3880
+ async function main() {
3881
+ const { values, positionals } = parse();
3749
3882
  if (values.help) return console.log(HELP);
3750
- if (values.version) return console.log("1.0.0-rc.0");
3751
- // A pipe, a CI job or an agent gets the defaults rather than a prompt nothing can answer.
3752
- const interactive = Boolean(process.stdout.isTTY) && !values.yes;
3753
- const pmFlag = oneOf(values.pm, [
3754
- 'npm',
3755
- 'pnpm',
3756
- 'yarn',
3757
- 'bun'
3758
- ], 'pm');
3883
+ if (values.version) return console.log("1.0.0-rc.2");
3884
+ /*
3885
+ * A pipe, a CI job or an agent gets the defaults rather than a prompt nothing can answer. Both streams
3886
+ * have to be a terminal: the prompts draw on stdout but *read from stdin*, so `echo | npm create` with
3887
+ * stdout still attached would otherwise ask a question with nothing behind the keyboard.
3888
+ */ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !values.yes;
3889
+ const pmFlag = oneOf(values.pm, PACKAGE_MANAGERS, 'pm');
3759
3890
  const pm = pmFlag ? packageManager(pmFlag) : detectPackageManager();
3760
3891
  const deployFlag = oneOf(values.deploy, DEPLOY_TARGET_NAMES, 'deploy');
3761
- const formatterFlag = oneOf(values.formatter, [
3762
- 'prettier',
3763
- 'biome',
3764
- 'oxfmt',
3765
- 'none'
3766
- ], 'formatter');
3767
- const linterFlag = oneOf(values.linter, [
3768
- 'oxlint',
3769
- 'biome',
3770
- 'none'
3771
- ], 'linter');
3892
+ const formatterFlag = oneOf(values.formatter, FORMATTER_NAMES, 'formatter');
3893
+ const linterFlag = oneOf(values.linter, LINTER_NAMES, 'linter');
3772
3894
  const qualityFlag = oneOf(values.quality, QUALITY_PRESETS.map((preset)=>preset.id), 'quality');
3773
3895
  const tailwindFlag = tristate(values.tailwind, values['no-tailwind'], 'tailwind');
3774
3896
  const installFlag = tristate(values.install, values['no-install'], 'install');
3775
3897
  const gitFlag = tristate(values.git, values['no-git'], 'git');
3776
- intro(`create-rshono · rshono ${"1.0.0-rc.0"}`);
3898
+ // The framework version, not this package's: it is the one the app will be pinned to, and the one
3899
+ // worth reading here. `--version` reports create-rshono's own.
3900
+ intro(`create-rshono · rshono ${RSHONO_VERSION}`);
3777
3901
  // ── Where ───────────────────────────────────────────────────────────────────────────────────────
3778
3902
  let directory = positionals[0];
3779
3903
  if (!directory) {
@@ -3787,7 +3911,7 @@ async function main() {
3787
3911
  const targetDir = resolve(process.cwd(), directory);
3788
3912
  const packageName = toPackageName(directory === '.' ? basename(targetDir) : directory);
3789
3913
  if (!packageName || !isValidPackageName(packageName)) fail(`"${directory}" does not give a usable npm package name.`);
3790
- const conflicts = inspectTarget(targetDir).conflicts;
3914
+ const conflicts = conflictingEntries(targetDir);
3791
3915
  if (conflicts.length > 0 && !values.force) {
3792
3916
  const where = directory === '.' ? 'this directory' : `"${directory}"`;
3793
3917
  const listed = `${conflicts.slice(0, 3).join(', ')}${conflicts.length > 3 ? ', …' : ''}`;
@@ -3813,7 +3937,7 @@ async function main() {
3813
3937
  }))
3814
3938
  }));
3815
3939
  }
3816
- let styling = tailwindFlag === undefined ? 'css' : tailwindFlag ? 'tailwind' : 'css';
3940
+ let styling = tailwindFlag ? 'tailwind' : 'css';
3817
3941
  if (tailwindFlag === undefined && interactive) {
3818
3942
  styling = unwrap(await dist_select({
3819
3943
  message: 'Styling?',
@@ -3863,9 +3987,10 @@ async function main() {
3863
3987
  initialValue: true
3864
3988
  }));
3865
3989
  }
3866
- let git = gitFlag ?? !isInsideRepo(process.cwd());
3990
+ // Asked once, not once per use: this shells out to `git rev-parse`.
3991
+ const nested = isInsideRepo(process.cwd());
3992
+ let git = gitFlag ?? !nested;
3867
3993
  if (gitFlag === undefined && interactive) {
3868
- const nested = isInsideRepo(process.cwd());
3869
3994
  git = unwrap(await dist_confirm({
3870
3995
  message: nested ? 'Initialize a git repository? (this is already inside one)' : 'Initialize a git repository?',
3871
3996
  initialValue: !nested
@@ -3873,14 +3998,10 @@ async function main() {
3873
3998
  }
3874
3999
  const answers = {
3875
4000
  packageName,
3876
- targetDir,
3877
4001
  deploy,
3878
4002
  styling,
3879
4003
  formatter,
3880
- linter,
3881
- packageManager: pm.name,
3882
- install,
3883
- git
4004
+ linter
3884
4005
  };
3885
4006
  // ── Plan, then write ────────────────────────────────────────────────────────────────────────────
3886
4007
  const plan = plan_plan(answers, pm);
@@ -3897,14 +4018,17 @@ async function main() {
3897
4018
  if (install) {
3898
4019
  log.step(`Installing dependencies with ${pm.name}…`);
3899
4020
  installed = runInstall(pm, targetDir);
3900
- if (!installed) log.warn(`${pm.name} install failed — run it yourself and the rest will work.`);
4021
+ if (!installed) log.warn(`${pm.name} install failed — the files are all written, so run it yourself in the project.`);
3901
4022
  }
3902
4023
  /*
3903
4024
  * Format the scaffold with the tool it was scaffolded with, so a fresh project passes its own
3904
4025
  * `format:check` instead of reporting a diff nobody made. Needs the install, since the formatter is a
3905
4026
  * devDependency — hence the skip, rather than a failure, when there is none.
3906
- */ if (installed && formatter !== 'none') {
3907
- if (!runScript(pm, 'format', targetDir)) log.warn(`\`${pm.run} format\` failed the files are fine, the formatter is not.`);
4027
+ *
4028
+ * Whether there is a `format` script is the plan's answer, not the formatter answer's: `--formatter
4029
+ * none --linter biome` has no formatter and a `format` script all the same, because Biome brings one.
4030
+ */ if (installed && plan.features.some((feature)=>feature.scripts?.format)) {
4031
+ if (!runScript(pm, 'format', targetDir)) log.warn(`\`${pm.run} format\` failed — the scaffold is written, just not formatted.`);
3908
4032
  }
3909
4033
  if (git) {
3910
4034
  if (!hasGit(targetDir)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rshono/create",
3
- "version": "1.0.0-rc.0",
3
+ "version": "1.0.0-rc.2",
4
4
  "description": "Scaffold a new rshono app — Hono + Rspack + React Server Components",
5
5
  "author": "Lasse <lasse@lassetange.com> (https://www.lassetange.com)",
6
6
  "license": "ISC",
@@ -1,11 +1,11 @@
1
- # **PROJECT_NAME**
1
+ # {{PROJECT_NAME}}
2
2
 
3
3
  A [rshono](https://github.com/rshono/rshono) app — [Hono](https://hono.dev) + [Rspack](https://rspack.rs) + [React Server Components](https://react.dev/reference/rsc/server-components).
4
4
 
5
5
  ```bash
6
- __PM_RUN__ dev # dev server with HMR, http://localhost:3000
7
- __PM_RUN__ build # production build for __DEPLOY_TARGET__
8
- __PM_RUN__ typecheck # tsc --noEmit
6
+ {{PM_RUN}} dev # dev server with HMR, http://localhost:3000
7
+ {{PM_RUN}} build # production build for {{DEPLOY_TARGET}}
8
+ {{PM_RUN}} typecheck # tsc --noEmit
9
9
  ```
10
10
 
11
11
  `package.json` has the rest, including whatever your formatter and linter added.
@@ -35,7 +35,7 @@ compiles to `undefined` rather than shipping. `src/lib/env.ts` is where both sid
35
35
 
36
36
  ## Deploying
37
37
 
38
- This app is built for `__DEPLOY_TARGET__`: after `__PM_RUN__ build`, **DEPLOY_HINT**.
38
+ This app is built for `{{DEPLOY_TARGET}}`: after `{{PM_RUN}} build`, {{DEPLOY_HINT}}.
39
39
 
40
40
  Change `deploy` in `rshono.config.ts` to target somewhere else, or build for one place without editing the
41
41
  file: `rshono build --deploy vercel`, or `RSHONO_DEPLOY=vercel` in CI. `dev` always runs the Node dev
@@ -6,4 +6,4 @@
6
6
  # Only `PUBLIC_`-prefixed variables are compiled into the browser bundle. Anything else is server-only:
7
7
  # a stray read of it in client code compiles to `undefined` rather than shipping.
8
8
 
9
- PUBLIC_APP_NAME="__PROJECT_NAME__"
9
+ PUBLIC_APP_NAME="{{PROJECT_NAME}}"
@@ -10,7 +10,7 @@ import { defineConfig } from '@rshono/core';
10
10
  */
11
11
  export default defineConfig({
12
12
  /** Where `build` targets. Overridable per build with `--deploy` or `RSHONO_DEPLOY`. */
13
- deploy: '__DEPLOY_TARGET__',
13
+ deploy: '{{DEPLOY_TARGET}}',
14
14
 
15
15
  // The public origin, baked into prerendered pages' absolute URLs. Set it if you use `render: 'static'`
16
16
  // and build canonical tags, `og:url` or absolute links — there is no request to read a Host from.
@@ -9,7 +9,10 @@
9
9
  * The signature is the one `useActionState` expects: previous state first, then the form data.
10
10
  */
11
11
  export async function greet(_previous: string | null, formData: FormData): Promise<string> {
12
- const name = String(formData.get('name') ?? '').trim();
12
+ // A form field is a string or a File, never only a string — a file input posted under this name would
13
+ // stringify to "[object File]" rather than fail. Narrowing is the validation the endpoint owes itself.
14
+ const field = formData.get('name');
15
+ const name = typeof field === 'string' ? field.trim() : '';
13
16
  if (!name) return 'Type a name first.';
14
17
 
15
18
  // Where real work goes — a database write, an email, a queue push.
@@ -9,7 +9,7 @@
9
9
 
10
10
  /** Safe anywhere: `PUBLIC_` variables are the ones compiled into the browser bundle. */
11
11
  export const publicEnv = {
12
- appName: process.env.PUBLIC_APP_NAME ?? '__PROJECT_NAME__',
12
+ appName: process.env.PUBLIC_APP_NAME ?? '{{PROJECT_NAME}}',
13
13
  };
14
14
 
15
15
  /**
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://biomejs.dev/schemas/2.5.6/schema.json",
3
- "files": { "includes": ["**", "!dist", "!wrangler.jsonc"] },
3
+ "files": { "includes": ["**", "!dist", "!.wrangler", "!.vercel", "!.netlify", "!wrangler.jsonc"] },
4
4
  "formatter": { "indentStyle": "space", "indentWidth": 2, "lineWidth": 140 },
5
5
  "javascript": { "formatter": { "quoteStyle": "single", "jsxQuoteStyle": "double" } },
6
6
  "linter": { "enabled": true, "rules": { "preset": "recommended" } }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://biomejs.dev/schemas/2.5.6/schema.json",
3
- "files": { "includes": ["**", "!dist", "!wrangler.jsonc", "!**/*.css"] },
3
+ "files": { "includes": ["**", "!dist", "!.wrangler", "!.vercel", "!.netlify", "!wrangler.jsonc", "!**/*.css"] },
4
4
  "formatter": { "indentStyle": "space", "indentWidth": 2, "lineWidth": 140 },
5
5
  "javascript": { "formatter": { "quoteStyle": "single", "jsxQuoteStyle": "double" } },
6
6
  "linter": { "enabled": true, "rules": { "preset": "recommended" } }
@@ -0,0 +1,31 @@
1
+ import js from '@eslint/js';
2
+ import reactHooks from 'eslint-plugin-react-hooks';
3
+ import tseslint from 'typescript-eslint';
4
+
5
+ /**
6
+ * Flat config, with type-aware rules — the reason to run ESLint over a syntax-only linter. `projectService`
7
+ * builds the same program `tsc` does from tsconfig.json, so a rule can ask what a value actually *is*:
8
+ * an unawaited promise, a `catch` that swallows an error, a `String()` around something that is not one.
9
+ *
10
+ * The cost is that ESLint needs TypeScript to answer, which is why this app pins TypeScript 6 —
11
+ * typescript-eslint reads the compiler API directly and accepts nothing newer. `npm run typecheck` is
12
+ * still the thing that decides whether the app compiles; these rules only see what it sees.
13
+ */
14
+ export default tseslint.config(
15
+ // ESLint's own default ignores cover node_modules and nothing else, so the build output — and whatever
16
+ // the deploy target leaves beside it — would otherwise be linted as if you had written it.
17
+ { ignores: ['dist/**', '.wrangler/**', '.vercel/**', '.netlify/**'] },
18
+ js.configs.recommended,
19
+ tseslint.configs.recommendedTypeChecked,
20
+ // The rules of hooks: the one class of React mistake no type checker catches, and the reason a React
21
+ // app wants a linter at all.
22
+ reactHooks.configs.flat['recommended-latest'],
23
+ {
24
+ languageOptions: {
25
+ parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
26
+ },
27
+ },
28
+ // This file and any other plain JavaScript sits outside the TypeScript program, so the type-aware rules
29
+ // have nothing to run against and would report every file as unconfigured.
30
+ { files: ['**/*.{js,mjs,cjs}'], extends: [tseslint.configs.disableTypeChecked] },
31
+ );
@@ -3,5 +3,5 @@
3
3
  "printWidth": 140,
4
4
  "singleQuote": true,
5
5
  "trailingComma": "all",
6
- "ignorePatterns": ["wrangler.jsonc"]
6
+ "ignorePatterns": ["dist", ".wrangler", ".vercel", ".netlify", "wrangler.jsonc"]
7
7
  }
@@ -3,5 +3,5 @@
3
3
  "categories": {
4
4
  "correctness": "error"
5
5
  },
6
- "ignorePatterns": ["dist"]
6
+ "ignorePatterns": ["dist", ".wrangler", ".vercel", ".netlify"]
7
7
  }
@@ -1,10 +1,15 @@
1
1
  node_modules/
2
- dist/
3
2
  pnpm-lock.yaml
4
3
  package-lock.json
5
4
  yarn.lock
6
5
  bun.lock
7
6
 
7
+ # Build output: `rshono build`'s own, and whatever the deploy target assembles beside it.
8
+ dist/
9
+ .wrangler/
10
+ .vercel/
11
+ .netlify/
12
+
8
13
  # Written by `rshono build` for the Cloudflare target, in the shape the build decided. Yours to edit —
9
14
  # just not the formatter's to reflow on every build.
10
15
  wrangler.jsonc
@@ -10,7 +10,7 @@ import { defineConfig } from '@rshono/core';
10
10
  */
11
11
  export default defineConfig({
12
12
  /** Where `build` targets. Overridable per build with `--deploy` or `RSHONO_DEPLOY`. */
13
- deploy: '__DEPLOY_TARGET__',
13
+ deploy: '{{DEPLOY_TARGET}}',
14
14
 
15
15
  // The public origin, baked into prerendered pages' absolute URLs. Set it if you use `render: 'static'`
16
16
  // and build canonical tags, `og:url` or absolute links — there is no request to read a Host from.