create-rsc-kit 0.18.1 → 0.20.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/dist/git.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** Whether `dir` is already inside a git work tree - its own, or a parent's. */
2
+ export declare function insideRepository(dir: string): boolean;
package/dist/git.js ADDED
@@ -0,0 +1,6 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ /** Whether `dir` is already inside a git work tree - its own, or a parent's. */
3
+ export function insideRepository(dir) {
4
+ const result = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, stdio: 'pipe' });
5
+ return result.status === 0 && String(result.stdout).trim() === 'true';
6
+ }
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
9
9
  import { dirname, join, resolve } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { spawnSync } from 'node:child_process';
12
+ import { insideRepository } from './git.js';
12
13
  import { randomBytes } from 'node:crypto';
13
14
  import { argv, exit, stdout } from 'node:process';
14
15
  import { DEFAULT_COMPILER, HELP, HOSTS, publishedCore, assertInsideCwd, assertUsableName, defaultCore, parseArgs, VALIDATIONS } from './options.js';
@@ -48,8 +49,19 @@ catch (error) {
48
49
  stdout.write(`\n${bold('Cannot scaffold here.')}\n ${error.message}\n\n`);
49
50
  exit(1);
50
51
  }
51
- if (options.git)
52
- run('git', ['init', '--quiet'], options.dir);
52
+ // Not inside a repository that already exists. A new app scaffolded into a
53
+ // monorepo's apps/ got a repository of its own, nested and empty, and the
54
+ // monorepo then refused to add the directory - git reads a nested .git as a
55
+ // submodule with nothing in it. The new files belong to the repository that
56
+ // is there.
57
+ if (options.git) {
58
+ if (insideRepository(options.dir)) {
59
+ stdout.write(`\n${dim('Inside a git repository already; not creating another.')}\n`);
60
+ }
61
+ else {
62
+ run('git', ['init', '--quiet'], options.dir);
63
+ }
64
+ }
53
65
  if (options.install) {
54
66
  stdout.write(`\n${dim('Installing dependencies…')}\n`);
55
67
  const pm = options.host === 'node' ? 'npm' : 'bun';
@@ -158,6 +170,13 @@ function write(o) {
158
170
  ['src/components/Counter.tsx', t.counter(o)],
159
171
  ['tests/app.test.ts', t.smokeTest(o)],
160
172
  ];
173
+ // Bun's test runner reads the real server-only package, which throws on
174
+ // import; a preload stubs it so an action file that carries the line is
175
+ // still a function a test can call.
176
+ if (o.host !== 'node') {
177
+ files.push(['bunfig.toml', t.bunfig]);
178
+ files.push(['tests/preload.ts', t.testPreload]);
179
+ }
161
180
  if (o.tailwind)
162
181
  files.push(['src/app/styles.css', t.styles]);
163
182
  if (o.env) {
package/dist/init.js CHANGED
@@ -182,6 +182,59 @@ function combine(name, theirs, ours) {
182
182
  * that quietly replaces a working build script loses someone's trust
183
183
  * permanently, and it only has to be wrong once.
184
184
  */
185
+ /** Where this tool's section of an AGENTS.md begins and ends, so a second run finds it. */
186
+ const AGENTS_START = '<!-- rsc-kit:start -->';
187
+ const AGENTS_END = '<!-- rsc-kit:end -->';
188
+ /**
189
+ * This tool's instructions, added to an existing AGENTS.md or written as one.
190
+ *
191
+ * Someone else's instructions are not a file to rewrite, but a delimited
192
+ * section at the end is not a rewrite: theirs stay exactly as written above
193
+ * it, the markers say what is ours, and a second run finds the markers and
194
+ * leaves it be. A project that already has an AGENTS.md is the project whose
195
+ * agents most need to hear how this works.
196
+ */
197
+ function mergeAgents(dir, o) {
198
+ const path = join(dir, 'AGENTS.md');
199
+ if (!existsSync(path)) {
200
+ writeFileSync(path, t.agents(o));
201
+ return { kind: 'wrote', what: 'AGENTS.md' };
202
+ }
203
+ const existing = readFileSync(path, 'utf8');
204
+ if (existing.includes(AGENTS_START) || /rsc-kit/i.test(existing)) {
205
+ return { kind: 'skipped', what: 'AGENTS.md', detail: 'already covers rsc-kit' };
206
+ }
207
+ writeFileSync(path, existing.replace(/\s*$/, '\n\n') + `${AGENTS_START}\n${t.agents(o).trim()}\n${AGENTS_END}\n`);
208
+ return { kind: 'merged', what: 'AGENTS.md', detail: 'added a section at the end; yours is untouched above it' };
209
+ }
210
+ /**
211
+ * The rsc-kit server, added to an existing .mcp.json or written as a new one.
212
+ *
213
+ * The file is a map of servers under `mcpServers`; a project with other
214
+ * servers keeps them, and one that already lists rsc-kit is left alone. A
215
+ * file that does not parse is not one to rewrite.
216
+ */
217
+ function mergeMcp(dir, o) {
218
+ const path = join(dir, '.mcp.json');
219
+ if (!existsSync(path)) {
220
+ writeFileSync(path, t.mcp(o));
221
+ return { kind: 'wrote', what: '.mcp.json' };
222
+ }
223
+ let existing;
224
+ try {
225
+ existing = JSON.parse(readFileSync(path, 'utf8'));
226
+ }
227
+ catch {
228
+ return { kind: 'skipped', what: '.mcp.json', detail: 'already exists and is not JSON; add the rsc-kit server by hand' };
229
+ }
230
+ if (existing.mcpServers?.['rsc-kit']) {
231
+ return { kind: 'skipped', what: '.mcp.json', detail: 'already lists rsc-kit' };
232
+ }
233
+ const ours = JSON.parse(t.mcp(o)).mcpServers['rsc-kit'];
234
+ existing.mcpServers = { ...(existing.mcpServers ?? {}), 'rsc-kit': ours };
235
+ writeFileSync(path, JSON.stringify(existing, null, 2) + '\n');
236
+ return { kind: 'merged', what: '.mcp.json', detail: 'added the rsc-kit server beside the others' };
237
+ }
185
238
  function mergeScripts(o, found) {
186
239
  const pkg = found.packageJson;
187
240
  const scripts = pkg.scripts ?? {};
@@ -315,18 +368,17 @@ function manualPluginStep(o, existing) {
315
368
  function routes(o, dir) {
316
369
  const appDir = join(dir, o.sourceDir, 'app');
317
370
  const steps = [];
371
+ // The two files beside the tree are looked at whether or not the tree is
372
+ // here: a project that already has pages is the one whose agents and
373
+ // editor most need to know about this.
374
+ steps.push(mergeAgents(dir, o), mergeMcp(dir, o));
318
375
  if (existsSync(join(appDir, 'layout.tsx')) || existsSync(join(appDir, 'page.tsx'))) {
319
- return [{ kind: 'skipped', what: `${o.sourceDir}/app`, detail: 'a route tree is already here' }];
376
+ return [...steps, { kind: 'skipped', what: `${o.sourceDir}/app`, detail: 'a route tree is already here' }];
320
377
  }
321
378
  const files = [
322
379
  [join(o.sourceDir, 'app/layout.tsx'), t.layout(o)],
323
380
  [join(o.sourceDir, 'app/page.tsx'), t.page(o)],
324
381
  [join(o.sourceDir, 'components/Counter.tsx'), t.counter(o)],
325
- // Beside the route tree rather than merged into an existing AGENTS.md: a
326
- // file of someone else's instructions is not one to append to blind, and
327
- // the loop below skips it if it is already there.
328
- ['AGENTS.md', t.agents(o)],
329
- ['.mcp.json', t.mcp(o)],
330
382
  ];
331
383
  if (o.tailwind)
332
384
  files.push([join(o.sourceDir, 'app/styles.css'), t.styles]);
@@ -401,10 +453,10 @@ function tsconfig(o, found, dir) {
401
453
  writeFileSync(path, t.tsconfig(o));
402
454
  return [{ kind: 'wrote', what: 'tsconfig.json' }];
403
455
  }
404
- // Reported, never rewritten. Adding the entry means parsing and reprinting
405
- // the file, which loses the comments a tsconfig is allowed to have and the
406
- // formatting someone chose — a worse trade than one line of output, for a
407
- // file this does not own.
456
+ // Edited in place, never reprinted: parsing and printing the file would
457
+ // lose the comments a tsconfig is allowed to have and the formatting
458
+ // someone chose. The entry goes in as text, at the front of the include
459
+ // list that is there, or as a list of its own after the opening brace.
408
460
  const current = readFileSync(path, 'utf-8');
409
461
  // Comments are legal here and JSON.parse does not take them. Stripped
410
462
  // outside strings only: a glob like ".rsc-kit/**/*" holds "/*", and a
@@ -428,13 +480,25 @@ function tsconfig(o, found, dir) {
428
480
  // with a dot is outside it. So both cases need the entry, and a project with
429
481
  // no `include` needs `**\/*` written alongside it or it loses everything
430
482
  // else. Measured both ways.
483
+ const opened = /"include"\s*:\s*\[/.exec(current);
484
+ if (include && opened) {
485
+ const at = opened.index + opened[0].length;
486
+ const rest = current.slice(at);
487
+ // The list's own style: one entry per line, or all on one.
488
+ const separator = /^\s*\n/.test(rest) ? rest.match(/^\s*\n(\s*)/)[0] : ' ';
489
+ writeFileSync(path, current.slice(0, at) + `${separator}"${TYPES_GLOB}",` + (separator === ' ' ? ' ' : '') + rest.replace(/^\s*\n/, ''));
490
+ return [{ kind: 'merged', what: 'tsconfig.json', detail: `added "${TYPES_GLOB}" to "include"` }];
491
+ }
492
+ const brace = current.indexOf('{');
493
+ if (!include && brace !== -1) {
494
+ writeFileSync(path, current.slice(0, brace + 1) + `\n "include": ["**/*", "${TYPES_GLOB}"],` + current.slice(brace + 1));
495
+ return [{ kind: 'merged', what: 'tsconfig.json', detail: `added "include": ["**/*", "${TYPES_GLOB}"]` }];
496
+ }
431
497
  return [
432
498
  {
433
499
  kind: 'manual',
434
500
  what: 'tsconfig.json',
435
- detail: include
436
- ? `add "${TYPES_GLOB}" to "include", or typed routes fall back to string`
437
- : `add "include": ["**/*", "${TYPES_GLOB}"], or typed routes fall back to string`,
501
+ detail: `add "${TYPES_GLOB}" to "include", or typed routes fall back to string`,
438
502
  },
439
503
  ];
440
504
  }
@@ -483,7 +547,23 @@ function backend(o, found, dir) {
483
547
  const steps = [];
484
548
  const env = join(dir, '.env');
485
549
  if (existsSync(env)) {
486
- steps.push({ kind: 'skipped', what: '.env', detail: 'already exists; RSC_BACKEND and RSC_HOST_CALL_SECRET must be in it' });
550
+ // The two lines, added to what is there. A secret already present is
551
+ // the one the backend was given and is never regenerated; a backend
552
+ // already named is left as named.
553
+ const current = readFileSync(env, 'utf8');
554
+ const missing = [];
555
+ if (!/^\s*RSC_BACKEND=/m.test(current))
556
+ missing.push(`RSC_BACKEND=${o.backend}`);
557
+ if (!/^\s*RSC_HOST_CALL_SECRET=/m.test(current)) {
558
+ missing.push(`RSC_HOST_CALL_SECRET=${randomBytes(32).toString('base64url')}`);
559
+ }
560
+ if (missing.length === 0) {
561
+ steps.push({ kind: 'skipped', what: '.env', detail: 'already has RSC_BACKEND and RSC_HOST_CALL_SECRET' });
562
+ }
563
+ else {
564
+ writeFileSync(env, current.replace(/\s*$/, '\n\n') + missing.join('\n') + '\n');
565
+ steps.push({ kind: 'merged', what: '.env', detail: `added ${missing.map((line) => line.split('=')[0]).join(' and ')}` });
566
+ }
487
567
  }
488
568
  else {
489
569
  writeFileSync(env, t.backendEnv(o.backend, randomBytes(32).toString('base64url')));
@@ -515,9 +595,10 @@ const DEFAULT_BACKEND = 'http://127.0.0.1:8080';
515
595
  const INIT_HELP = `
516
596
  rsc-kit init — add RSC to the project in this directory
517
597
 
518
- Nothing existing is ever rewritten. New files are written, missing
519
- dependencies are added, and for anything already there the exact edit is
520
- printed for you to make.
598
+ Nothing existing is rewritten. New files are written; a file that is a list
599
+ gets our entry added to it - .mcp.json, AGENTS.md, tsconfig.json's include,
600
+ .env - with yours left as written; and for anything else already there the
601
+ exact edit is printed for you to make.
521
602
 
522
603
  Options
523
604
  --source-dir <dir> where app/ should live (detected, usually src)
@@ -66,6 +66,18 @@ export declare function layout(o: Options): string;
66
66
  * the app, and what gets chosen is a port, a spawned server, and a sleep. This
67
67
  * is the shape instead: the deployed handler, a Request in, a Response out.
68
68
  */
69
+ /** Bun's test settings: the preload below, before every test file. */
70
+ export declare const bunfig = "[test]\npreload = [\"./tests/preload.ts\"]\n";
71
+ /**
72
+ * What every test file sees first.
73
+ *
74
+ * `import 'server-only'` is honoured by the build - a client file importing
75
+ * the module fails to build rather than shipping a secret - and resolves to
76
+ * nothing on the server. Under bun test it is the real package, which throws
77
+ * on import, so an action or a route that carries the line would not be a
78
+ * function a test can call. Stubbed here, once.
79
+ */
80
+ export declare const testPreload = "import { mock } from 'bun:test'\n\n// The build honours this import and resolves it to nothing on the server;\n// the real package throws when imported, which is what a test would hit.\nmock.module('server-only', () => ({}))\nmock.module('client-only', () => ({}))\n";
69
81
  export declare function smokeTest(o: Options): string;
70
82
  export declare function page(o: Options): string;
71
83
  export declare function counter(o: Options): string;
package/dist/templates.js CHANGED
@@ -92,12 +92,17 @@ export function scripts(o) {
92
92
  // every asset — the static path resolves into Bun's virtual
93
93
  // filesystem, where the files on disk are not.
94
94
  //
95
+ // compile.mjs rather than index.mjs: the build writes it beside the
96
+ // server, and it is what puts the frozen pages inside the binary -
97
+ // imported by name, which a compile embeds, where the server's own
98
+ // computed import is invisible to it.
99
+ //
95
100
  // Into dist/, which is already ignored. Named after the project it
96
101
  // landed a 63MB binary in the root of, next to the source, with
97
102
  // nothing in .gitignore covering it.
98
103
  ...(o.host === 'bun'
99
104
  ? {
100
- compile: `${vite} build && bun build --compile .output/server/index.mjs --outfile dist/app`,
105
+ compile: `${vite} build && bun build --compile .output/server/compile.mjs --outfile dist/app`,
101
106
  }
102
107
  : {}),
103
108
  }),
@@ -326,6 +331,26 @@ export default function RootLayout({ children }: { children: ReactNode }) {
326
331
  * the app, and what gets chosen is a port, a spawned server, and a sleep. This
327
332
  * is the shape instead: the deployed handler, a Request in, a Response out.
328
333
  */
334
+ /** Bun's test settings: the preload below, before every test file. */
335
+ export const bunfig = `[test]
336
+ preload = ["./tests/preload.ts"]
337
+ `;
338
+ /**
339
+ * What every test file sees first.
340
+ *
341
+ * `import 'server-only'` is honoured by the build - a client file importing
342
+ * the module fails to build rather than shipping a secret - and resolves to
343
+ * nothing on the server. Under bun test it is the real package, which throws
344
+ * on import, so an action or a route that carries the line would not be a
345
+ * function a test can call. Stubbed here, once.
346
+ */
347
+ export const testPreload = `import { mock } from 'bun:test'
348
+
349
+ // The build honours this import and resolves it to nothing on the server;
350
+ // the real package throws when imported, which is what a test would hit.
351
+ mock.module('server-only', () => ({}))
352
+ mock.module('client-only', () => ({}))
353
+ `;
329
354
  export function smokeTest(o) {
330
355
  const runner = o.host === 'node'
331
356
  ? `import { before, describe, test } from 'node:test'
@@ -618,6 +643,13 @@ it before any page. ${o.env
618
643
  render waits for it). Do not import a bootstrap module from pages to get the
619
644
  same effect; it depends on nobody forgetting.
620
645
 
646
+ ## Forms
647
+
648
+ Uncontrolled. Inputs keep their value in the DOM, an initial value is
649
+ \`defaultValue\`, and the action reads \`FormData\`. Do not write \`useState\` +
650
+ \`value\`/\`onChange\` per input. Control one field only when the UI must react
651
+ as the user types, and bind that one with \`useField\`.
652
+
621
653
  ## Data
622
654
 
623
655
  Fetch in a server component and await it. There is no loader and no
@@ -691,7 +723,8 @@ not hand-parse \`Number(searchParams.get('page'))\`.
691
723
 
692
724
  \`${o.sourceDir}/app/**/route.ts\`, exporting \`GET\`, \`POST\` and so on.
693
725
  A real \`Request\` in, a real \`Response\` out. Await \`params\`,
694
- \`searchParams\` and \`body\` from the second argument.
726
+ \`searchParams\` and \`body\` from the second argument - never
727
+ \`new URL(request.url).searchParams\`, which the build cannot see.
695
728
 
696
729
  They run their directory's \`middleware.ts\`, and a \`GET\` that reads nothing
697
730
  from the request is answered from disk.
@@ -773,7 +806,8 @@ not render.
773
806
 
774
807
  A directory with a \`page.tsx\` is a route, so \`src/app/about/page.tsx\` is
775
808
  \`/about\` with nothing to register. \`[slug]\` is a parameter, and
776
- \`middleware.ts\` runs before anything at or below it renders.
809
+ \`middleware.ts\` runs before anything at or below it renders; its default export is one
810
+ check or a list of them, run in order and stopping at the first refusal.
777
811
 
778
812
  \`.rsc-kit/\` is the build's: the route types that make \`href\` checkable, and
779
813
  the ambient declarations. Rewritten every build, and gitignored.
@@ -820,6 +854,8 @@ import { createEnv } from '@t3-oss/env-core'
820
854
  // to start with PUBLIC_, and is read from import.meta.env, which is what Vite
821
855
  // exposes there. Add a variable: one line in the schema, and every reader is
822
856
  // typed.
857
+ const processEnv: Record<string, string | undefined> = typeof process === 'undefined' ? {} : process.env
858
+
823
859
  export const env = createEnv({
824
860
  server: {
825
861
  NODE_ENV: ${lib.opt},
@@ -830,11 +866,13 @@ export const env = createEnv({
830
866
  client: {
831
867
  // PUBLIC_SITE_URL: ${lib.url},
832
868
  },
833
- runtimeEnv: { ...process.env, ...import.meta.env },
869
+ // process is the server's; a "use client" file importing this for a
870
+ // PUBLIC_ value has only import.meta.env, and Vite fills the PUBLIC_ ones.
871
+ runtimeEnv: { ...processEnv, ...import.meta.env },
834
872
  emptyStringAsUndefined: true,
835
873
  // A build machine without the production variables: SKIP_ENV_VALIDATION=1
836
874
  // builds anyway, and the server that runs the build validates at startup.
837
- skipValidation: !!process.env.SKIP_ENV_VALIDATION,
875
+ skipValidation: !!processEnv.SKIP_ENV_VALIDATION,
838
876
  })
839
877
  `;
840
878
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-rsc-kit",
3
- "version": "0.18.1",
3
+ "version": "0.20.0",
4
4
  "type": "module",
5
5
  "description": "Scaffold an RSC app on Bun, Hono, Elysia or Node.",
6
6
  "bin": {