netjes 0.0.0-stage → 0.0.1

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.
Files changed (222) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -2
  3. package/bin/netjes.mjs +13 -0
  4. package/cli/agents.mjs +66 -0
  5. package/cli/commands.mjs +467 -0
  6. package/cli/help.mjs +89 -0
  7. package/cli/info.mjs +454 -0
  8. package/cli/main.mjs +167 -0
  9. package/cli/packages.mjs +19 -0
  10. package/cli/process-io.mjs +163 -0
  11. package/cli/routes.mjs +31 -0
  12. package/cli/scaffold.mjs +193 -0
  13. package/cli/suggest.mjs +50 -0
  14. package/cli/terminal.mjs +40 -0
  15. package/dist/actions.d.ts +98 -0
  16. package/dist/actions.d.ts.map +1 -0
  17. package/dist/actions.js +176 -0
  18. package/dist/actions.js.map +1 -0
  19. package/dist/client-entry.d.ts +2 -0
  20. package/dist/client-entry.d.ts.map +1 -0
  21. package/dist/client-entry.js +102 -0
  22. package/dist/client-entry.js.map +1 -0
  23. package/dist/deferred.d.ts +39 -0
  24. package/dist/deferred.d.ts.map +1 -0
  25. package/dist/deferred.js +123 -0
  26. package/dist/deferred.js.map +1 -0
  27. package/dist/define-page.d.ts +15 -0
  28. package/dist/define-page.d.ts.map +1 -0
  29. package/dist/define-page.js +27 -0
  30. package/dist/define-page.js.map +1 -0
  31. package/dist/document.d.ts +18 -0
  32. package/dist/document.d.ts.map +1 -0
  33. package/dist/document.js +16 -0
  34. package/dist/document.js.map +1 -0
  35. package/dist/draft-mode.d.ts +5 -0
  36. package/dist/draft-mode.d.ts.map +1 -0
  37. package/dist/draft-mode.js +6 -0
  38. package/dist/draft-mode.js.map +1 -0
  39. package/dist/dynamic.d.ts +20 -0
  40. package/dist/dynamic.d.ts.map +1 -0
  41. package/dist/dynamic.js +37 -0
  42. package/dist/dynamic.js.map +1 -0
  43. package/dist/form-data.d.ts +10 -0
  44. package/dist/form-data.d.ts.map +1 -0
  45. package/dist/form-data.js +10 -0
  46. package/dist/form-data.js.map +1 -0
  47. package/dist/form.d.ts +35 -0
  48. package/dist/form.d.ts.map +1 -0
  49. package/dist/form.js +80 -0
  50. package/dist/form.js.map +1 -0
  51. package/dist/href.d.ts +25 -0
  52. package/dist/href.d.ts.map +1 -0
  53. package/dist/href.js +71 -0
  54. package/dist/href.js.map +1 -0
  55. package/dist/image.d.ts +36 -0
  56. package/dist/image.d.ts.map +1 -0
  57. package/dist/image.js +60 -0
  58. package/dist/image.js.map +1 -0
  59. package/dist/index.d.ts +18 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +14 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/link.d.ts +17 -0
  64. package/dist/link.d.ts.map +1 -0
  65. package/dist/link.js +46 -0
  66. package/dist/link.js.map +1 -0
  67. package/dist/locale.d.ts +3 -0
  68. package/dist/locale.d.ts.map +1 -0
  69. package/dist/locale.js +6 -0
  70. package/dist/locale.js.map +1 -0
  71. package/dist/metadata.d.ts +83 -0
  72. package/dist/metadata.d.ts.map +1 -0
  73. package/dist/metadata.js +100 -0
  74. package/dist/metadata.js.map +1 -0
  75. package/dist/modules.d.ts +74 -0
  76. package/dist/modules.d.ts.map +1 -0
  77. package/dist/modules.js +85 -0
  78. package/dist/modules.js.map +1 -0
  79. package/dist/nonce.d.ts +8 -0
  80. package/dist/nonce.d.ts.map +1 -0
  81. package/dist/nonce.js +12 -0
  82. package/dist/nonce.js.map +1 -0
  83. package/dist/payload.d.ts +169 -0
  84. package/dist/payload.d.ts.map +1 -0
  85. package/dist/payload.js +271 -0
  86. package/dist/payload.js.map +1 -0
  87. package/dist/public-config.d.ts +11 -0
  88. package/dist/public-config.d.ts.map +1 -0
  89. package/dist/public-config.js +10 -0
  90. package/dist/public-config.js.map +1 -0
  91. package/dist/query.d.ts +22 -0
  92. package/dist/query.d.ts.map +1 -0
  93. package/dist/query.js +68 -0
  94. package/dist/query.js.map +1 -0
  95. package/dist/render-cleanup.d.ts +6 -0
  96. package/dist/render-cleanup.d.ts.map +1 -0
  97. package/dist/render-cleanup.js +7 -0
  98. package/dist/render-cleanup.js.map +1 -0
  99. package/dist/root.d.ts +12 -0
  100. package/dist/root.d.ts.map +1 -0
  101. package/dist/root.js +26 -0
  102. package/dist/root.js.map +1 -0
  103. package/dist/route-tree.d.ts +56 -0
  104. package/dist/route-tree.d.ts.map +1 -0
  105. package/dist/route-tree.js +86 -0
  106. package/dist/route-tree.js.map +1 -0
  107. package/dist/router.d.ts +118 -0
  108. package/dist/router.d.ts.map +1 -0
  109. package/dist/router.js +938 -0
  110. package/dist/router.js.map +1 -0
  111. package/dist/schema.d.ts +35 -0
  112. package/dist/schema.d.ts.map +1 -0
  113. package/dist/schema.js +74 -0
  114. package/dist/schema.js.map +1 -0
  115. package/dist/script.d.ts +23 -0
  116. package/dist/script.d.ts.map +1 -0
  117. package/dist/script.js +89 -0
  118. package/dist/script.js.map +1 -0
  119. package/dist/server-entry.d.ts +2 -0
  120. package/dist/server-entry.d.ts.map +1 -0
  121. package/dist/server-entry.js +4 -0
  122. package/dist/server-entry.js.map +1 -0
  123. package/dist/server-render.d.ts +19 -0
  124. package/dist/server-render.d.ts.map +1 -0
  125. package/dist/server-render.js +193 -0
  126. package/dist/server-render.js.map +1 -0
  127. package/dist/testing-setup.d.ts +2 -0
  128. package/dist/testing-setup.d.ts.map +1 -0
  129. package/dist/testing-setup.js +8 -0
  130. package/dist/testing-setup.js.map +1 -0
  131. package/dist/testing.d.ts +76 -0
  132. package/dist/testing.d.ts.map +1 -0
  133. package/dist/testing.js +106 -0
  134. package/dist/testing.js.map +1 -0
  135. package/dist/vitals-config.d.ts +4 -0
  136. package/dist/vitals-config.d.ts.map +1 -0
  137. package/dist/vitals-config.js +5 -0
  138. package/dist/vitals-config.js.map +1 -0
  139. package/dist/vitals.d.ts +13 -0
  140. package/dist/vitals.d.ts.map +1 -0
  141. package/dist/vitals.js +60 -0
  142. package/dist/vitals.js.map +1 -0
  143. package/dist/zod.d.ts +19 -0
  144. package/dist/zod.d.ts.map +1 -0
  145. package/dist/zod.js +65 -0
  146. package/dist/zod.js.map +1 -0
  147. package/package.json +147 -4
  148. package/sidecar/server.mjs +325 -0
  149. package/skills/netjes/SKILL.md +93 -0
  150. package/skills/netjes/references/caching-and-streaming.md +68 -0
  151. package/skills/netjes/references/data-and-actions.md +98 -0
  152. package/skills/netjes/references/deployment.md +57 -0
  153. package/skills/netjes/references/routing.md +65 -0
  154. package/skills/netjes/references/testing.md +57 -0
  155. package/src/actions.tsx +241 -0
  156. package/src/client-entry.tsx +103 -0
  157. package/src/deferred.ts +134 -0
  158. package/src/define-page.tsx +41 -0
  159. package/src/document.tsx +47 -0
  160. package/src/draft-mode.ts +6 -0
  161. package/src/dynamic.tsx +64 -0
  162. package/src/form-data.ts +10 -0
  163. package/src/form.tsx +122 -0
  164. package/src/href.ts +79 -0
  165. package/src/image.tsx +91 -0
  166. package/src/index.ts +40 -0
  167. package/src/link.tsx +73 -0
  168. package/src/locale.ts +6 -0
  169. package/src/metadata.tsx +181 -0
  170. package/src/modules.ts +133 -0
  171. package/src/nonce.tsx +14 -0
  172. package/src/payload.ts +403 -0
  173. package/src/public-config.tsx +19 -0
  174. package/src/query.tsx +82 -0
  175. package/src/render-cleanup.ts +7 -0
  176. package/src/root.tsx +39 -0
  177. package/src/route-tree.tsx +139 -0
  178. package/src/router.tsx +1188 -0
  179. package/src/schema.ts +98 -0
  180. package/src/script.tsx +103 -0
  181. package/src/server-entry.tsx +4 -0
  182. package/src/server-render.tsx +223 -0
  183. package/src/testing-setup.ts +8 -0
  184. package/src/testing.tsx +177 -0
  185. package/src/virtual.d.ts +12 -0
  186. package/src/vitals-config.ts +6 -0
  187. package/src/vitals.tsx +68 -0
  188. package/src/zod.ts +72 -0
  189. package/template/.dockerignore +11 -0
  190. package/template/Dockerfile +58 -0
  191. package/template/Features/Home/HomePage.cs +32 -0
  192. package/template/Program.cs +28 -0
  193. package/template/Properties/launchSettings.json +13 -0
  194. package/template/README.md +24 -0
  195. package/template/SiteProps.cs +4 -0
  196. package/template/_AGENTS.md +64 -0
  197. package/template/_CLAUDE.md +1 -0
  198. package/template/__NAME__.csproj +11 -0
  199. package/template/_gitattributes +2 -0
  200. package/template/_gitignore +11 -0
  201. package/template/app/about/page.tsx +17 -0
  202. package/template/app/error.tsx +15 -0
  203. package/template/app/layout.tsx +33 -0
  204. package/template/app/not-found.tsx +14 -0
  205. package/template/app/page.tsx +14 -0
  206. package/template/appsettings.Development.json +3 -0
  207. package/template/appsettings.json +9 -0
  208. package/template/components/Greeting.test.tsx +25 -0
  209. package/template/components/Greeting.tsx +26 -0
  210. package/template/components/HelloForm.test.tsx +27 -0
  211. package/template/components/HelloForm.tsx +34 -0
  212. package/template/docker-compose.override.yml +15 -0
  213. package/template/docker-compose.prod.yml +26 -0
  214. package/template/docker-compose.yml +16 -0
  215. package/template/generated/netjes.ts +73 -0
  216. package/template/package.json +36 -0
  217. package/template/public/favicon.svg +6 -0
  218. package/template/styles/global.css +7 -0
  219. package/template/tsconfig.json +19 -0
  220. package/template/vite.config.ts +7 -0
  221. package/vite/plugin.d.ts +29 -0
  222. package/vite/plugin.mjs +291 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dennis Verhoeven
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,55 @@
1
- # Temporary Holding Version
1
+ # netjes
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The npm half of [Netjes](https://github.com/DennisVerhoeven/netjes), a React framework for building apps and sites on ASP.NET Core: pages in `app/` with the Next.js App Router conventions, C# loaders and server actions, server rendering, client navigation. This package holds the Vite plugin, the client router and components, the Node sidecar that renders pages for the .NET app, the `netjes` command and the app template. The ASP.NET Core half is the [`Netjes`](https://www.nuget.org/packages/Netjes) NuGet package, released at the same version.
4
+
5
+ ## Start an app
6
+
7
+ You need Node.js `^22.22.2 || ^24.15.0 || >=26.0.0` (Node.js 22.22.2 or a later 22, 24.15.0 or a later 24, or 26 or newer) and the .NET 10 SDK.
8
+
9
+ ```bash
10
+ npx netjes new my-app
11
+ cd my-app
12
+ npm run dev # http://localhost:5080
13
+ ```
14
+
15
+ `netjes new` writes an ASP.NET Core app with React pages, Tailwind CSS, TanStack Query, zod, Vitest and Docker files, and references `netjes` and `Netjes` at the same exact version.
16
+
17
+ ## Add it to a project
18
+
19
+ ```bash
20
+ npm install netjes@0.0.1 react react-dom zod @tanstack/react-query
21
+ npm install -D vite tailwindcss @tailwindcss/vite
22
+ ```
23
+
24
+ ```ts
25
+ // vite.config.ts
26
+ import netjes from 'netjes/vite';
27
+ import { defineConfig } from 'vite';
28
+
29
+ export default defineConfig({ plugins: [netjes()] });
30
+ ```
31
+
32
+ Tailwind CSS and the TanStack Query client are on by default; `netjes({ tailwind: false, query: false })` turns them off, and then they need not be installed. `vitest`, `jsdom`, `@testing-library/react` and `web-vitals` are optional peers, used when installed. Use the version of `netjes` that matches the app's `Netjes` NuGet package; `npx netjes info` checks it.
33
+
34
+ ## Entry points
35
+
36
+ | Import | What it holds |
37
+ |---|---|
38
+ | `netjes` | Components and hooks: `Link`, `Form`, `Image`, `Script`, `definePage`, `useAction`, `useRouter` and the rest |
39
+ | `netjes/vite` | The Vite plugin |
40
+ | `netjes/testing` | `createTestApp()`, for rendering pages and components in Vitest |
41
+ | `netjes/vitals` | `useReportWebVitals` and `<WebVitals />` |
42
+ | `netjes/zod` | `deferred(schema)`, for values that loaders stream with `Page.Defer` |
43
+ | `netjes` (bin) | The command: `new`, `dev`, `build`, `start`, `generate`, `routes`, `export`, `info`, `agents` |
44
+
45
+ Each import is an ES module with its type declarations, so an app's `tsc` reads the declarations and checks only the app's own code, also under stricter compiler options such as `exactOptionalPropertyTypes` or `noPropertyAccessFromIndexSignature` (see [TypeScript setup](https://github.com/DennisVerhoeven/netjes/blob/main/docs/reference/vite-plugin.md#typescript-setup)). The TypeScript sources are in the package too, for the source maps: an editor's go to definition and a stack trace lead to them.
46
+
47
+ ## Documentation
48
+
49
+ - [Getting started](https://github.com/DennisVerhoeven/netjes/blob/main/docs/getting-started.md)
50
+ - [All guides](https://github.com/DennisVerhoeven/netjes/blob/main/docs/README.md)
51
+ - [React API](https://github.com/DennisVerhoeven/netjes/blob/main/docs/reference/react-api.md)
52
+ - [Vite plugin](https://github.com/DennisVerhoeven/netjes/blob/main/docs/reference/vite-plugin.md)
53
+ - [The netjes command](https://github.com/DennisVerhoeven/netjes/blob/main/docs/reference/cli.md)
54
+
55
+ Report bugs in the [issue tracker](https://github.com/DennisVerhoeven/netjes/issues), and security problems privately, as [SECURITY.md](https://github.com/DennisVerhoeven/netjes/blob/main/SECURITY.md) describes. MIT licensed.
package/bin/netjes.mjs ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ import { run } from '../cli/main.mjs';
4
+ import { processIo } from '../cli/process-io.mjs';
5
+
6
+ // A reader that goes away early (`netjes help | head -1`) is not an error: what netjes writes after that is dropped.
7
+ for (const stream of [process.stdout, process.stderr]) {
8
+ stream.on('error', (error) => {
9
+ if (/** @type {NodeJS.ErrnoException} */ (error).code !== 'EPIPE') throw error;
10
+ });
11
+ }
12
+
13
+ process.exitCode = await run(process.argv.slice(2), processIo());
package/cli/agents.mjs ADDED
@@ -0,0 +1,66 @@
1
+ // @ts-check
2
+ // `netjes agents`: adds what `netjes new` gives coding agents (AGENTS.md, CLAUDE.md and the Netjes skill) to an
3
+ // existing app, keeping any of those files that exist unless told to replace them.
4
+ import { existsSync, statSync } from 'node:fs';
5
+ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { fillNames, projectNames, readTemplate, skillFiles } from './scaffold.mjs';
8
+
9
+ /**
10
+ * The app's names: from `--name`, else its one project file (whose name is its root namespace), else its directory,
11
+ * with a warning on stderr, because AGENTS.md would then name a project that may not exist.
12
+ * @param {import('./main.mjs').CliIo} io
13
+ * @param {string} dir
14
+ * @param {string | undefined} name
15
+ */
16
+ async function namesOf(io, dir, name) {
17
+ if (name !== undefined) return projectNames(name);
18
+ const projects = (await readdir(dir)).filter((entry) => entry.endsWith('.csproj'));
19
+ const project = projects.length === 1 ? path.basename(projects[0] ?? '', '.csproj') : undefined;
20
+ if (project) return { ...projectNames(project), dotnet: project };
21
+ const folder = path.basename(dir);
22
+ io.stderr(
23
+ `netjes agents: ${projects.length ? `more than one .csproj in ${dir}` : `no .csproj in ${dir}`}, so the app is named after the folder ("${folder}"). ` +
24
+ 'Run it in the app\'s folder, or set the name with --name <Name>.\n',
25
+ );
26
+ return projectNames(folder);
27
+ }
28
+
29
+ /**
30
+ * @param {import('./main.mjs').CliIo} io
31
+ * @param {string} dir
32
+ * @param {{ name?: string; force: boolean }} options
33
+ * @returns {Promise<number>}
34
+ */
35
+ export async function agents(io, dir, options) {
36
+ if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) {
37
+ io.stderr(`netjes agents: ${dir} is not a directory\n`);
38
+ return 1;
39
+ }
40
+ const names = await namesOf(io, dir, options.name);
41
+ const files = [
42
+ { path: 'AGENTS.md', text: fillNames(await readTemplate('_AGENTS.md'), names) },
43
+ { path: 'CLAUDE.md', text: await readTemplate('_CLAUDE.md') },
44
+ ...(await skillFiles()),
45
+ ];
46
+
47
+ io.stdout(`In ${dir}:\n`);
48
+ /** @type {string[]} */
49
+ const kept = [];
50
+ for (const file of files) {
51
+ const target = path.join(dir, ...file.path.split('/'));
52
+ if (existsSync(target) && !options.force) {
53
+ kept.push(file.path);
54
+ io.stdout(` kept ${file.path}\n`);
55
+ continue;
56
+ }
57
+ await mkdir(path.dirname(target), { recursive: true });
58
+ await writeFile(target, file.text);
59
+ io.stdout(` wrote ${file.path}\n`);
60
+ }
61
+ if (kept.length) io.stdout('Files that exist are kept; --force replaces them.\n');
62
+ if (kept.includes('CLAUDE.md') && !/^@AGENTS\.md\s*$/m.test(await readFile(path.join(dir, 'CLAUDE.md'), 'utf8'))) {
63
+ io.stdout('Add a line `@AGENTS.md` to CLAUDE.md, so Claude Code reads AGENTS.md too.\n');
64
+ }
65
+ return 0;
66
+ }
@@ -0,0 +1,467 @@
1
+ // @ts-check
2
+ // The netjes commands, one entry each. Commands that need the C# side run the app itself: in command mode
3
+ // (NETJES_COMMAND=generate|routes|export) it binds a throwaway loopback port, writes its result and exits. Only export
4
+ // starts the Node sidecar, to render the pages.
5
+ import { existsSync, statSync } from 'node:fs';
6
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
7
+ import { tmpdir } from 'node:os';
8
+ import path from 'node:path';
9
+ import { agents } from './agents.mjs';
10
+ import { checkProject, checkTools, formatChecks } from './info.mjs';
11
+ import { findPackage } from './packages.mjs';
12
+ import { formatRoutes } from './routes.mjs';
13
+ import { packageVersion, scaffold } from './scaffold.mjs';
14
+ import { printError } from './terminal.mjs';
15
+
16
+ /**
17
+ * @typedef {import('./main.mjs').CliIo} CliIo
18
+ * @typedef {{ io: CliIo; cwd: string; args: string[]; options: Record<string, string | boolean | undefined> }} CommandContext
19
+ * @typedef {{ type: 'string' | 'boolean'; short?: string; value?: string; description: string; default?: string }} OptionSpec
20
+ * @typedef {{
21
+ * summary: string;
22
+ * details?: string;
23
+ * args?: string;
24
+ * arguments?: Record<string, { description: string; default?: string }>;
25
+ * options: Record<string, OptionSpec>;
26
+ * examples: string[];
27
+ * project?(options: CommandContext['options']): boolean;
28
+ * run(context: CommandContext): Promise<number>;
29
+ * }} Command
30
+ */
31
+
32
+ const LIFETIME_LOGS = 'Logging__LogLevel__Microsoft.Hosting.Lifetime';
33
+
34
+ /**
35
+ * Runs the app once in command mode; the app writes its result and stops. It binds a throwaway port, so the host's
36
+ * own messages ("Now listening on", "Press Ctrl+C to shut down") would only mislead: they are left out unless the
37
+ * environment sets their level.
38
+ * @param {CliIo} io
39
+ */
40
+ const commandEnv = (io) => ({ ASPNETCORE_URLS: 'http://127.0.0.1:0', [LIFETIME_LOGS]: io.env[LIFETIME_LOGS] ?? 'Warning' });
41
+
42
+ /**
43
+ * @param {CliIo} io
44
+ * @param {Record<string, string>} extra
45
+ */
46
+ function appEnv(io, extra) {
47
+ return { ...baseEnv(io), ASPNETCORE_ENVIRONMENT: io.env.ASPNETCORE_ENVIRONMENT ?? 'Development', ...extra };
48
+ }
49
+
50
+ // The variables the CLI sets itself to put the app into command mode.
51
+ const COMMAND_VARIABLES = new Set(['NETJES_COMMAND', 'NETJES_OUTPUT', 'NETJES_CHECK']);
52
+
53
+ /**
54
+ * The CLI's environment without its own command variables: one left over in a shell or CI job (NETJES_COMMAND=export)
55
+ * must not turn the app a command starts into another command. Each command sets the ones it needs. Every other
56
+ * variable passes through, including settings the sidecar reads (NETJES_VITE_HOST, NETJES_VITE_ORIGIN) and Netjes__
57
+ * configuration keys. Names match case-insensitively, as environment variables do on Windows.
58
+ * @param {CliIo} io
59
+ * @returns {Record<string, string | undefined>}
60
+ */
61
+ function baseEnv(io) {
62
+ return Object.fromEntries(Object.entries(io.env).filter(([key]) => !COMMAND_VARIABLES.has(key.toUpperCase())));
63
+ }
64
+
65
+ /**
66
+ * @param {CliIo} io
67
+ * @param {string} cwd
68
+ * @param {{ check?: boolean }} options
69
+ */
70
+ function generate(io, cwd, options = {}) {
71
+ return io.spawn({
72
+ command: 'dotnet',
73
+ args: ['run', '--no-launch-profile'],
74
+ cwd,
75
+ env: appEnv(io, { ...commandEnv(io), NETJES_COMMAND: 'generate', ...(options.check ? { NETJES_CHECK: '1' } : {}) }),
76
+ });
77
+ }
78
+
79
+ /**
80
+ * The app's own vite entry point. `npx vite` would download vite@latest when the app has none installed, bypassing the
81
+ * lockfile, so resolve it from the app's node_modules and run it with node instead.
82
+ * @param {string} cwd
83
+ * @returns {Promise<string | null>}
84
+ */
85
+ async function findVite(cwd) {
86
+ const manifest = findPackage(cwd, 'vite');
87
+ if (!manifest) return null;
88
+ try {
89
+ const { bin } = JSON.parse(await readFile(manifest, 'utf8'));
90
+ const entry = typeof bin === 'string' ? bin : bin?.vite;
91
+ return typeof entry === 'string' ? path.resolve(path.dirname(manifest), entry) : null;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Types, then the client and server bundles; stops at the first step that fails.
99
+ * @param {CliIo} io
100
+ * @param {string} cwd
101
+ */
102
+ async function build(io, cwd) {
103
+ const vite = await findVite(cwd);
104
+ if (!vite) {
105
+ printError(io, 'netjes: vite is not installed in this project. Run `npm install` first.');
106
+ return 1;
107
+ }
108
+ for (const step of [
109
+ () => generate(io, cwd),
110
+ () => io.spawn({ command: process.execPath, args: [vite, 'build'], cwd, env: baseEnv(io) }),
111
+ () => io.spawn({ command: process.execPath, args: [vite, 'build', '--ssr'], cwd, env: baseEnv(io) }),
112
+ ]) {
113
+ const code = await step();
114
+ if (code !== 0) return code;
115
+ }
116
+ return 0;
117
+ }
118
+
119
+ /**
120
+ * `target` as the user would type it from `from`: relative when it is below, else absolute.
121
+ * @param {string} from
122
+ * @param {string} target
123
+ */
124
+ function displayPath(from, target) {
125
+ const relative = path.relative(from, target);
126
+ return relative.startsWith('..') || path.isAbsolute(relative) ? target : relative || '.';
127
+ }
128
+
129
+ /**
130
+ * A path quoted for the shell when a shell would split or expand it.
131
+ * @param {string} value
132
+ */
133
+ function shellPath(value) {
134
+ if (/^[\w./\\:@%+=,-]+$/.test(value)) return value;
135
+ return process.platform === 'win32' ? `"${value}"` : `'${value.replaceAll("'", "'\\''")}'`;
136
+ }
137
+
138
+ const VITE_DEV_PORT_KEY = 'NETJES:VITEDEVPORT';
139
+
140
+ /**
141
+ * The Vite dev server's port as the app will read it (Netjes:ViteDevPort): from the environment, else from the
142
+ * appsettings file of the app's environment, else from appsettings.json, else 5173. Null when one of those files
143
+ * cannot be parsed; the app reports a taken port itself. One the app sets in code is not seen here.
144
+ * @param {CliIo} io
145
+ * @param {string} cwd
146
+ * @returns {Promise<number | null>}
147
+ */
148
+ async function viteDevPort(io, cwd) {
149
+ for (const [key, value] of Object.entries(io.env)) {
150
+ if (key.toUpperCase().replaceAll('__', ':') === VITE_DEV_PORT_KEY && /^\d+$/.test(value ?? '')) return Number(value);
151
+ }
152
+ for (const file of [`appsettings.${io.env.ASPNETCORE_ENVIRONMENT ?? 'Development'}.json`, 'appsettings.json']) {
153
+ /** @type {string} */
154
+ let text;
155
+ try {
156
+ text = await readFile(path.join(cwd, file), 'utf8');
157
+ } catch {
158
+ continue;
159
+ }
160
+ /** @type {unknown} */
161
+ let settings;
162
+ try {
163
+ // As ASP.NET Core reads them: a byte order mark, comments and trailing commas are allowed.
164
+ settings = JSON.parse(
165
+ text
166
+ .replace(/^\uFEFF/, '')
167
+ .replace(/"(?:[^"\\]|\\.)*"|\/\/[^\n]*|\/\*[\s\S]*?\*\/|,(?=\s*[}\]])/g, (token) => (token.startsWith('"') ? token : '')),
168
+ );
169
+ } catch {
170
+ return null;
171
+ }
172
+ const port = settingsPort(settings, '');
173
+ if (port !== undefined) return port;
174
+ }
175
+ return 5173;
176
+ }
177
+
178
+ /**
179
+ * Netjes:ViteDevPort in parsed appsettings, under its keys in any case, nested or joined with colons.
180
+ * @param {unknown} settings
181
+ * @param {string} prefix
182
+ * @returns {number | undefined}
183
+ */
184
+ function settingsPort(settings, prefix) {
185
+ if (typeof settings !== 'object' || settings === null) return undefined;
186
+ /** @type {number | undefined} */
187
+ let port;
188
+ for (const [key, value] of Object.entries(settings)) {
189
+ const name = `${prefix}${key}`.toUpperCase();
190
+ if (name === VITE_DEV_PORT_KEY) port = /^\d+$/.test(String(value)) ? Number(value) : port;
191
+ else if (VITE_DEV_PORT_KEY.startsWith(`${name}:`)) port = settingsPort(value, `${name}:`) ?? port;
192
+ }
193
+ return port;
194
+ }
195
+
196
+ /**
197
+ * Where things are in a new app, as `netjes new` points them out.
198
+ * @param {string} name
199
+ */
200
+ function layout(name) {
201
+ const rows = [
202
+ ['app/', 'the pages: page.tsx is /, about/page.tsx is /about, layout.tsx wraps them'],
203
+ ['components/', 'React components, each with a Vitest test'],
204
+ ['Features/', 'the C# side, one folder per feature: loaders and server actions'],
205
+ ['Program.cs', `the ASP.NET Core app (${name}.csproj)`],
206
+ ['generated/netjes.ts', 'types and zod schemas from C#, rewritten by netjes generate; never edit it'],
207
+ ['Dockerfile', 'and the docker-compose files: Docker for development and production'],
208
+ ];
209
+ const width = Math.max(...rows.map(([entry]) => (entry ?? '').length)) + 2;
210
+ return rows.map(([entry, what]) => ` ${(entry ?? '').padEnd(width)}${what}`).join('\n');
211
+ }
212
+
213
+ /** @type {Record<string, Command>} */
214
+ export const COMMANDS = {
215
+ new: {
216
+ summary: 'Create an app: ASP.NET Core with React pages, Tailwind CSS, TanStack Query, zod and Vitest',
217
+ args: '<dir>',
218
+ arguments: { '<dir>': { description: 'The folder to create the app in; it must not exist yet, or be empty' } },
219
+ options: {
220
+ name: {
221
+ type: 'string',
222
+ value: '<Name>',
223
+ description: 'The .NET project name and root namespace',
224
+ default: 'the folder name in PascalCase, e.g. my-shop gives MyShop',
225
+ },
226
+ packages: {
227
+ type: 'boolean',
228
+ description: 'Use the Netjes NuGet and npm packages even when netjes runs from a Netjes checkout',
229
+ default: 'off; outside a checkout the packages are used anyway',
230
+ },
231
+ 'skip-install': { type: 'boolean', description: 'Do not run npm install in the new app', default: 'off' },
232
+ },
233
+ examples: ['netjes new my-shop', 'netjes new my-shop --name Shop --skip-install'],
234
+ async run({ io, cwd, args, options }) {
235
+ const dir = args[0] ?? (await io.prompt?.('Folder for the new app: '))?.trim();
236
+ if (!dir) {
237
+ printError(io, 'netjes new: which folder? e.g. `netjes new my-app`');
238
+ return 1;
239
+ }
240
+ const target = path.resolve(cwd, dir);
241
+ const shown = shellPath(displayPath(io.cwd, target));
242
+ try {
243
+ const { names, mode } = await scaffold(target, {
244
+ ...(typeof options.name === 'string' ? { name: options.name } : {}),
245
+ packages: options.packages === true,
246
+ });
247
+ const source = mode === 'checkout' ? 'referencing this Netjes checkout' : `using the Netjes ${await packageVersion()} packages`;
248
+ io.stdout(`Created ${names.dotnet} in ${shown} (${source})\n\n${layout(names.dotnet)}\n`);
249
+ } catch (error) {
250
+ printError(io, `netjes new: ${error instanceof Error ? error.message : String(error)}`);
251
+ return 1;
252
+ }
253
+ if (!options['skip-install']) {
254
+ const code = await io.spawn({ command: 'npm', args: ['install'], cwd: target, env: baseEnv(io) });
255
+ if (code !== 0) {
256
+ printError(
257
+ io,
258
+ `netjes new: npm install failed (exit code ${code}). The app is in ${target}: fix what npm reported, then run \`npm install\` there.`,
259
+ );
260
+ return code;
261
+ }
262
+ }
263
+ const steps = [`cd ${shown}`, ...(options['skip-install'] ? ['npm install'] : []), 'npm run dev then open http://localhost:5080'];
264
+ io.stdout(`\nNext:\n${steps.map((step) => ` ${step}\n`).join('')}`);
265
+ return 0;
266
+ },
267
+ },
268
+
269
+ dev: {
270
+ summary: 'Run the app in Development: dotnet watch with C# hot reload, Vite HMR for pages',
271
+ details:
272
+ 'Serves http://localhost:5080 unless ASPNETCORE_URLS says otherwise, with the Vite dev server on port 5173 unless ' +
273
+ 'the app sets Netjes:ViteDevPort. Stop it with Ctrl+C.',
274
+ options: {
275
+ 'no-watch': { type: 'boolean', description: 'Run once with dotnet run instead of dotnet watch', default: 'off' },
276
+ },
277
+ examples: ['netjes dev', 'ASPNETCORE_URLS=http://localhost:3000 netjes dev'],
278
+ project: () => true,
279
+ async run({ io, cwd, options }) {
280
+ // The app needs the sidecar from netjes and the Vite dev server; without them it fails at startup, in a stack trace.
281
+ const missing = ['netjes', 'vite'].filter((name) => !findPackage(cwd, name));
282
+ if (missing.length) {
283
+ printError(io, `netjes dev: ${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} not installed in this project. Run \`npm install\` first.`);
284
+ return 1;
285
+ }
286
+ const urls = io.env.ASPNETCORE_URLS ?? 'http://localhost:5080';
287
+ // A port that is taken fails the app at startup, in a stack trace; check the one local URL it will bind.
288
+ const port = Number(/^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):(\d+)\/?$/i.exec(urls)?.[1]);
289
+ if (port && io.portFree && !(await io.portFree(port))) {
290
+ printError(
291
+ io,
292
+ `netjes dev: port ${port} is in use (${urls}); stop what is using it, or pick another port: ASPNETCORE_URLS=http://localhost:${port + 1} netjes dev`,
293
+ );
294
+ return 1;
295
+ }
296
+ // The app's Node process runs the Vite dev server on a port of its own, which must be free too. Vite binds
297
+ // localhost, which may resolve to ::1 only, unless NETJES_VITE_HOST says otherwise; check it the same way.
298
+ const vitePort = await viteDevPort(io, cwd);
299
+ if (vitePort && io.portFree && !(await io.portFree(vitePort, io.env.NETJES_VITE_HOST || 'localhost'))) {
300
+ printError(
301
+ io,
302
+ `netjes dev: port ${vitePort} is in use, and the Vite dev server needs it; stop what is using it, or move Vite to another port: Netjes__ViteDevPort=${vitePort + 1} netjes dev`,
303
+ );
304
+ return 1;
305
+ }
306
+ return io.spawn({
307
+ command: 'dotnet',
308
+ args: options['no-watch'] ? ['run', '--no-launch-profile'] : ['watch', 'run', '--no-launch-profile'],
309
+ cwd,
310
+ env: appEnv(io, { ASPNETCORE_URLS: urls }),
311
+ });
312
+ },
313
+ },
314
+
315
+ build: {
316
+ summary: 'Generate the types, then build the client and server bundles into dist/',
317
+ details: 'Uses the vite installed in the project (never a downloaded one), so run npm install first.',
318
+ options: {},
319
+ examples: ['netjes build'],
320
+ project: () => true,
321
+ run: ({ io, cwd }) => build(io, cwd),
322
+ },
323
+
324
+ start: {
325
+ summary: 'Run the app in Production, from a build (netjes build) or a published app',
326
+ options: {
327
+ dll: {
328
+ type: 'string',
329
+ value: '<path>',
330
+ description: "Run a published app's .dll (dotnet publish) from the .dll's folder, instead of the project",
331
+ default: 'dotnet run -c Release in the project',
332
+ },
333
+ },
334
+ examples: ['netjes start', 'netjes start --dll bin/publish/MyShop.dll'],
335
+ project: (options) => typeof options.dll !== 'string',
336
+ async run({ io, cwd, options }) {
337
+ const env = { ...baseEnv(io), ASPNETCORE_ENVIRONMENT: io.env.ASPNETCORE_ENVIRONMENT ?? 'Production' };
338
+ if (typeof options.dll === 'string') {
339
+ // The content root is the current folder, so the published app runs from the folder it was published to: it
340
+ // serves the published dist/ and reads the published appsettings*.json, not the project's.
341
+ const dll = path.resolve(cwd, options.dll);
342
+ // Spawning in a folder that is not there fails with a bare ENOENT, and dotnet's own message for a missing file
343
+ // is hard to read.
344
+ if (!statSync(dll, { throwIfNoEntry: false })?.isFile()) {
345
+ io.stderr(`netjes start: --dll ${options.dll} is not a file (${dll})\n`);
346
+ return 1;
347
+ }
348
+ return io.spawn({ command: 'dotnet', args: [dll], cwd: path.dirname(dll), env });
349
+ }
350
+ // A hint, not an error: an app can keep its build elsewhere (NetjesOptions.DistDirectory).
351
+ if (!existsSync(path.join(cwd, 'dist', 'server', 'server-entry.js'))) {
352
+ io.stderr('netjes start: dist/ has no build yet; if the app fails to start, run `netjes build` first.\n');
353
+ }
354
+ return io.spawn({ command: 'dotnet', args: ['run', '--no-launch-profile', '-c', 'Release'], cwd, env });
355
+ },
356
+ },
357
+
358
+ generate: {
359
+ summary: 'Write generated/netjes.ts from the C# types',
360
+ details: 'Runs the app once in command mode: it binds no real port, starts no Node sidecar and needs no build.',
361
+ options: {
362
+ check: {
363
+ type: 'boolean',
364
+ description: 'Write nothing, and exit 1 when generated/netjes.ts is out of date (for CI)',
365
+ default: 'off',
366
+ },
367
+ },
368
+ examples: ['netjes generate', 'netjes generate --check'],
369
+ project: () => true,
370
+ run: ({ io, cwd, options }) => generate(io, cwd, { check: options.check === true }),
371
+ },
372
+
373
+ routes: {
374
+ summary: 'Print the route table and its warnings, as the app sees them',
375
+ details: 'Runs the app once in command mode, like generate. Warnings do not change the exit code.',
376
+ options: {
377
+ json: { type: 'boolean', description: 'Print the report as JSON', default: 'off: a table' },
378
+ },
379
+ examples: ['netjes routes', 'netjes routes --json'],
380
+ project: () => true,
381
+ async run({ io, cwd, options }) {
382
+ const dir = await mkdtemp(path.join(tmpdir(), 'netjes-routes-'));
383
+ const output = path.join(dir, 'routes.json');
384
+ try {
385
+ const code = await io.spawn({
386
+ command: 'dotnet',
387
+ args: ['run', '--no-launch-profile'],
388
+ cwd,
389
+ env: appEnv(io, { ...commandEnv(io), NETJES_COMMAND: 'routes', NETJES_OUTPUT: output }),
390
+ // stdout is for the report, so that `netjes routes --json | jq` works; the build and app output go to stderr.
391
+ stdout: 'stderr',
392
+ });
393
+ if (code !== 0) return code;
394
+ const report = await readFile(output, 'utf8');
395
+ io.stdout(options.json ? `${report}\n` : formatRoutes(JSON.parse(report)));
396
+ return 0;
397
+ } finally {
398
+ await rm(dir, { recursive: true, force: true });
399
+ }
400
+ },
401
+ },
402
+
403
+ export: {
404
+ summary: 'Build, then write every page as static HTML into a folder',
405
+ args: '[dir]',
406
+ arguments: { '[dir]': { description: 'The folder to write the site to, inside the app', default: 'out' } },
407
+ options: {
408
+ 'no-build': { type: 'boolean', description: 'Export the build already in dist/', default: 'off: build first' },
409
+ },
410
+ examples: ['netjes export', 'netjes export site --no-build'],
411
+ project: () => true,
412
+ async run({ io, cwd, args, options }) {
413
+ if (!options['no-build']) {
414
+ const code = await build(io, cwd);
415
+ if (code !== 0) return code;
416
+ }
417
+ return io.spawn({
418
+ command: 'dotnet',
419
+ args: ['run', '--no-launch-profile', '-c', 'Release'],
420
+ cwd,
421
+ env: {
422
+ ...baseEnv(io),
423
+ ...commandEnv(io),
424
+ ASPNETCORE_ENVIRONMENT: io.env.ASPNETCORE_ENVIRONMENT ?? 'Production',
425
+ NETJES_COMMAND: 'export',
426
+ NETJES_OUTPUT: path.resolve(cwd, args[0] ?? 'out'),
427
+ },
428
+ });
429
+ },
430
+ },
431
+
432
+ info: {
433
+ summary: 'Check the tools and the project setup',
434
+ options: {},
435
+ examples: ['netjes info', 'netjes info --cwd ../my-shop'],
436
+ async run({ io, cwd }) {
437
+ const version = await packageVersion();
438
+ const tools = await checkTools(io, cwd, baseEnv(io));
439
+ const project = await checkProject(cwd, version);
440
+ const color = io.color?.stdout === true;
441
+ io.stdout(`netjes ${version} in ${cwd}\n\n${formatChecks(tools, color)}\n${formatChecks(project, color)}`);
442
+ return [...tools, ...project].some((check) => check.status === 'fail') ? 1 : 0;
443
+ },
444
+ },
445
+
446
+ agents: {
447
+ summary: 'Add AGENTS.md, CLAUDE.md and the Netjes skill for coding agents to an existing app',
448
+ args: '[dir]',
449
+ arguments: { '[dir]': { description: 'The app folder', default: 'the current folder' } },
450
+ options: {
451
+ name: {
452
+ type: 'string',
453
+ value: '<Name>',
454
+ description: "The app's name in AGENTS.md",
455
+ default: 'the name of its .csproj, else the folder name',
456
+ },
457
+ force: { type: 'boolean', description: 'Replace the files that exist', default: 'off: keep them' },
458
+ },
459
+ examples: ['netjes agents', 'netjes agents ../my-shop --force'],
460
+ async run({ io, cwd, args, options }) {
461
+ return agents(io, path.resolve(cwd, args[0] ?? '.'), {
462
+ ...(typeof options.name === 'string' ? { name: options.name } : {}),
463
+ force: options.force === true,
464
+ });
465
+ },
466
+ },
467
+ };