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/cli/main.mjs ADDED
@@ -0,0 +1,167 @@
1
+ // @ts-check
2
+ // The netjes command line: parses the arguments and runs the command from the table in commands.mjs.
3
+ import { readdirSync, statSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { parseArgs } from 'node:util';
6
+ import { COMMANDS } from './commands.mjs';
7
+ import { GLOBAL_OPTIONS, commandHelp, overviewHelp } from './help.mjs';
8
+ import { packageVersion } from './scaffold.mjs';
9
+ import { suggest } from './suggest.mjs';
10
+ import { Interrupted, printError } from './terminal.mjs';
11
+
12
+ /**
13
+ * A process to run, with the terminal's stdin and stderr. Its stdout is the terminal's too, or with `stdout: 'stderr'`
14
+ * the CLI's stderr, for a command whose own output on stdout is data. `prompt`, there only with a terminal to ask in,
15
+ * resolves to null when no answer comes and rejects with Interrupted on Ctrl+C.
16
+ * @typedef {{ command: string; args: string[]; cwd: string; env: Record<string, string | undefined>; stdout?: 'stderr' }} SpawnCall
17
+ * @typedef {{
18
+ * cwd: string;
19
+ * env: Record<string, string | undefined>;
20
+ * stdout(text: string): void;
21
+ * stderr(text: string): void;
22
+ * spawn(call: SpawnCall): Promise<number>;
23
+ * capture?(call: SpawnCall): Promise<{ code: number; stdout: string; stderr: string }>;
24
+ * color?: { stdout: boolean; stderr: boolean };
25
+ * prompt?(question: string): Promise<string | null>;
26
+ * portFree?(port: number, host?: string): Promise<boolean>;
27
+ * }} CliIo
28
+ * @typedef {import('./commands.mjs').Command} Command
29
+ * @typedef {import('./commands.mjs').OptionSpec} OptionSpec
30
+ */
31
+
32
+ /** Words people try for a command that is called something else here. Suggestions only, never aliases. */
33
+ const MEANT = new Map([
34
+ ['doctor', 'info'],
35
+ ['init', 'new'],
36
+ ['create', 'new'],
37
+ ['serve', 'start'],
38
+ ['version', '--version'],
39
+ ]);
40
+
41
+ /** A mistake in the arguments: reported as one line on stderr, with exit code 1. */
42
+ class UsageError extends Error {}
43
+
44
+ /** @param {string | undefined} name */
45
+ const commandNamed = (name) => (name !== undefined && Object.hasOwn(COMMANDS, name) ? COMMANDS[name] : undefined);
46
+
47
+ /**
48
+ * parseArgs options for these specs; never strict, so that each mistake gets a message of our own.
49
+ * @param {Record<string, OptionSpec>} specs
50
+ * @param {string[]} args
51
+ */
52
+ function parse(specs, args) {
53
+ /** @type {Record<string, { type: 'string' | 'boolean'; short?: string }>} */
54
+ const options = Object.fromEntries(
55
+ Object.entries(specs).map(([name, { type, short }]) => [name, short ? { type, short } : { type }]),
56
+ );
57
+ return parseArgs({ args, options, allowPositionals: true, strict: false, tokens: true });
58
+ }
59
+
60
+ /**
61
+ * The options and arguments a command was given, or a UsageError for the first mistake in them. With `helping`, the
62
+ * arguments are `help [<command>]`, and the command's options are accepted as they are next to --help.
63
+ * @param {string[]} argv
64
+ * @param {string | undefined} name
65
+ * @param {Command | undefined} command
66
+ * @param {boolean} helping
67
+ */
68
+ function validate(argv, name, command, helping = false) {
69
+ /** @type {Record<string, OptionSpec>} */
70
+ const allowed = { ...GLOBAL_OPTIONS, ...command?.options };
71
+ const own = name !== undefined && !helping;
72
+ const prefix = helping ? 'netjes help' : own ? `netjes ${name}` : 'netjes';
73
+ const helpCommand = own ? `netjes help ${name}` : 'netjes --help';
74
+ const { values, positionals, tokens } = parse(allowed, argv);
75
+ const accepts = helping ? 1 : Object.keys(command?.arguments ?? {}).length;
76
+ // The first positional is the command itself (or help); `accepts` more may follow it.
77
+ let seen = 0;
78
+ for (const token of tokens) {
79
+ if (token.kind === 'positional') {
80
+ if (seen++ > accepts) throw new UsageError(`${prefix}: unexpected argument '${token.value}'; run \`${helpCommand}\` for its usage`);
81
+ } else if (token.kind === 'option') {
82
+ const spec = Object.hasOwn(allowed, token.name) ? allowed[token.name] : undefined;
83
+ if (!spec) {
84
+ if (own && (name === 'dev' || name === 'start') && (token.name === 'port' || token.name === 'p')) {
85
+ // The number the user typed (`--port 3000`, `--port=3000`, `-p3000`), else a placeholder.
86
+ const typed = [token.value, /^-p(\d+)$/.exec(argv[token.index] ?? '')?.[1], argv[token.index + 1]];
87
+ const port = typed.find((value) => value !== undefined && /^\d+$/.test(value)) ?? '<port>';
88
+ throw new UsageError(
89
+ `${prefix}: unknown option '${token.rawName}'; netjes has no port option, the app's URL sets it: ` +
90
+ `set ASPNETCORE_URLS=http://localhost:${port} when you run netjes ${name}`,
91
+ );
92
+ }
93
+ const meant = token.rawName.startsWith('--') ? suggest(token.name, Object.keys(allowed)) : undefined;
94
+ throw new UsageError(
95
+ `${prefix}: unknown option '${token.rawName}'; ` +
96
+ (meant ? `did you mean '--${meant}'?` : `run \`${helpCommand}\` for ${own ? 'its' : 'the'} options`),
97
+ );
98
+ }
99
+ if (spec.type === 'string' && (token.value === undefined || (!token.inlineValue && token.value.startsWith('-')))) {
100
+ throw new UsageError(`${prefix}: option '--${token.name}' needs a value: --${token.name} ${spec.value ?? '<value>'}`);
101
+ }
102
+ if (spec.type === 'boolean' && token.value !== undefined) {
103
+ throw new UsageError(`${prefix}: option '--${token.name}' takes no value`);
104
+ }
105
+ }
106
+ }
107
+ return { values, args: positionals.slice(1) };
108
+ }
109
+
110
+ /**
111
+ * @param {string[]} argv
112
+ * @param {CliIo} io
113
+ * @returns {Promise<number>}
114
+ */
115
+ export async function run(argv, io) {
116
+ try {
117
+ return await dispatch(argv, io);
118
+ } catch (error) {
119
+ if (error instanceof Interrupted) return 130;
120
+ if (!(error instanceof UsageError)) throw error;
121
+ printError(io, error.message);
122
+ return 1;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * @param {string[]} argv
128
+ * @param {CliIo} io
129
+ * @returns {Promise<number>}
130
+ */
131
+ async function dispatch(argv, io) {
132
+ // A first pass, knowing every option's type so that a value (--name Shop) is not taken for the command.
133
+ const every = Object.assign({}, GLOBAL_OPTIONS, ...Object.values(COMMANDS).map((command) => command.options));
134
+ const first = parse(every, argv);
135
+ if (first.values.version) {
136
+ io.stdout(`${await packageVersion()}\n`);
137
+ return 0;
138
+ }
139
+
140
+ const [word, ...rest] = first.positionals;
141
+ const helping = word === 'help';
142
+ // `netjes help help` is the overview, as `netjes help` is.
143
+ const name = helping ? (rest[0] === 'help' ? undefined : rest[0]) : word;
144
+ const command = commandNamed(name);
145
+ if (name !== undefined && !command) {
146
+ const meant = MEANT.get(name.toLowerCase()) ?? suggest(name, Object.keys(COMMANDS));
147
+ throw new UsageError(
148
+ `netjes: unknown command '${name}'; ` + (meant ? `did you mean \`netjes ${meant}\`?` : 'run `netjes --help` for the commands'),
149
+ );
150
+ }
151
+
152
+ const { values, args } = validate(argv, name, command, helping);
153
+ if (helping || values.help || !command || !name) {
154
+ io.stdout(command && name ? commandHelp(name, command) : overviewHelp(COMMANDS, await packageVersion()));
155
+ return 0;
156
+ }
157
+ const cwd = typeof values.cwd === 'string' ? path.resolve(io.cwd, values.cwd) : io.cwd;
158
+ if (typeof values.cwd === 'string' && !statSync(cwd, { throwIfNoEntry: false })?.isDirectory()) {
159
+ throw new UsageError(`netjes ${name}: --cwd ${values.cwd} is not a directory (${cwd})`);
160
+ }
161
+ if (command.project?.(values) && !readdirSync(cwd).some((entry) => entry.endsWith('.csproj'))) {
162
+ throw new UsageError(
163
+ `netjes ${name}: no .csproj in ${cwd}; run it in the app's folder, pass --cwd <dir>, or create an app with \`netjes new <dir>\``,
164
+ );
165
+ }
166
+ return command.run({ io, cwd, args, options: values });
167
+ }
@@ -0,0 +1,19 @@
1
+ // @ts-check
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * Where a package is installed for the app: its package.json in the nearest node_modules, as Node and the app's
7
+ * sidecar resolve it. Read directly rather than through require.resolve, which only finds package.json when the
8
+ * package exports it.
9
+ * @param {string} cwd
10
+ * @param {string} name
11
+ * @returns {string | null}
12
+ */
13
+ export function findPackage(cwd, name) {
14
+ for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
15
+ const manifest = path.join(dir, 'node_modules', name, 'package.json');
16
+ if (existsSync(manifest)) return manifest;
17
+ if (path.dirname(dir) === dir) return null;
18
+ }
19
+ }
@@ -0,0 +1,163 @@
1
+ // @ts-check
2
+ // The CliIo of a real run: this process's directory, environment and streams, and child processes.
3
+ import { execFile, spawn } from 'node:child_process';
4
+ import { existsSync, statSync } from 'node:fs';
5
+ import { createServer } from 'node:net';
6
+ import { constants } from 'node:os';
7
+ import path from 'node:path';
8
+ import { createInterface } from 'node:readline/promises';
9
+ import { DOTNET_DOWNLOAD, MIN_DOTNET, NODE_DOWNLOAD, NODE_RANGE } from './info.mjs';
10
+ import { Interrupted, colorSupport, printError } from './terminal.mjs';
11
+
12
+ const windows = process.platform === 'win32';
13
+
14
+ /** What to install when a command the CLI runs is missing. */
15
+ const INSTALL = new Map([
16
+ ['dotnet', `Install the .NET SDK, ${MIN_DOTNET}.0 or newer: ${DOTNET_DOWNLOAD}`],
17
+ ['npm', `Install a Node.js release that matches ${NODE_RANGE}, which includes npm: ${NODE_DOWNLOAD}`],
18
+ ]);
19
+
20
+ /**
21
+ * The exit code for a process that ended with `code` or by `signal`: 128 + the signal number, as shells report it.
22
+ * @param {number | null} code
23
+ * @param {NodeJS.Signals | null} signal
24
+ */
25
+ const exitCode = (code, signal) => code ?? (signal ? 128 + (constants.signals[signal] ?? 0) : 0);
26
+
27
+ /**
28
+ * Whether `file` is a file. False as well when it cannot be looked at, as in a PATH folder the user may not read, which
29
+ * cmd.exe skips.
30
+ * @param {string} file
31
+ */
32
+ function isAFile(file) {
33
+ try {
34
+ return statSync(file).isFile();
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Where cmd.exe would find `command` on the PATH in `env`: the first directory with `command` plus one of the PATHEXT
42
+ * extensions. Null when no directory has it. Variable names match in any case, as on Windows.
43
+ * @param {string} command
44
+ * @param {Record<string, string | undefined>} env
45
+ * @param {(file: string) => boolean} [isFile]
46
+ * @returns {string | null}
47
+ */
48
+ export function findOnWindowsPath(command, env, isFile = isAFile) {
49
+ /** @param {string} name */
50
+ const variable = (name) => Object.entries(env).find(([key]) => key.toUpperCase() === name)?.[1] ?? '';
51
+ const extensions = (variable('PATHEXT') || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
52
+ for (const folder of variable('PATH').split(';')) {
53
+ const directory = folder.trim().replace(/^"(.*)"$/, '$1');
54
+ if (!directory) continue;
55
+ for (const extension of extensions) {
56
+ const file = path.win32.join(directory, `${command}${extension.toLowerCase()}`);
57
+ if (isFile(file)) return file;
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * The command line that runs a .cmd file with `args` through cmd.exe: the file in quotes, and each argument as it is,
65
+ * or in quotes when it has more than letters, digits and path characters. Node joins `args` with spaces when it starts
66
+ * a shell, unquoted (DEP0190), so the CLI passes this one string instead. For the CLI's own arguments (npm install):
67
+ * cmd.exe still expands %VARIABLES% in quotes.
68
+ * @param {string} file
69
+ * @param {string[]} args
70
+ */
71
+ export function windowsCommandLine(file, args) {
72
+ return [`"${file}"`, ...args.map((arg) => (/^[\w@+=:,./\\-]+$/.test(arg) ? arg : `"${arg}"`))].join(' ');
73
+ }
74
+
75
+ /** @returns {import('./main.mjs').CliIo} */
76
+ export function processIo() {
77
+ /** @type {import('./main.mjs').CliIo} */
78
+ const io = {
79
+ cwd: process.cwd(),
80
+ env: process.env,
81
+ stdout: (text) => process.stdout.write(text),
82
+ stderr: (text) => process.stderr.write(text),
83
+ color: { stdout: colorSupport(process.env, process.stdout.isTTY), stderr: colorSupport(process.env, process.stderr.isTTY) },
84
+ spawn: ({ command, args, cwd, env, stdout }) =>
85
+ new Promise((resolve) => {
86
+ /** @type {import('node:child_process').StdioOptions} */
87
+ const stdio = ['inherit', stdout === 'stderr' ? 2 : 'inherit', 'inherit'];
88
+ const notInstalled = () =>
89
+ printError(io, `netjes: ${command} is not installed, or not on PATH. ${INSTALL.get(command) ?? ''}`.trimEnd());
90
+ // npm and npx are .cmd files on Windows, which Node starts only through cmd.exe. cmd.exe would report a missing
91
+ // one in a message of its own, with exit code 9009, so the CLI looks it up first, as cmd.exe does.
92
+ const shim = windows && command.startsWith('np') ? findOnWindowsPath(command, env) : undefined;
93
+ if (shim === null) {
94
+ notInstalled();
95
+ resolve(127);
96
+ return;
97
+ }
98
+ const child = shim
99
+ ? spawn(windowsCommandLine(shim, args), { cwd, env, stdio, shell: true })
100
+ : spawn(command, args, { cwd, env, stdio });
101
+ const forward = (/** @type {NodeJS.Signals} */ signal) => child.kill(signal);
102
+ process.on('SIGINT', forward);
103
+ process.on('SIGTERM', forward);
104
+ const done = (/** @type {number} */ code) => {
105
+ process.off('SIGINT', forward);
106
+ process.off('SIGTERM', forward);
107
+ resolve(code);
108
+ };
109
+ child.on('error', (error) => {
110
+ if (/** @type {NodeJS.ErrnoException} */ (error).code === 'ENOENT' && existsSync(cwd)) notInstalled();
111
+ else printError(io, `netjes: could not run ${command}: ${error.message}`);
112
+ done(127);
113
+ });
114
+ child.on('exit', (code, signal) => done(exitCode(code, signal)));
115
+ }),
116
+ capture: ({ command, args, cwd, env }) =>
117
+ new Promise((resolve) => {
118
+ execFile(command, args, { cwd, env }, (error, stdout, stderr) => {
119
+ if (!error) return resolve({ code: 0, stdout, stderr });
120
+ const code = typeof error.code === 'number' ? error.code : error.code === 'ENOENT' ? 127 : error.signal ? exitCode(null, error.signal) : 1;
121
+ resolve({ code, stdout, stderr });
122
+ });
123
+ }),
124
+ };
125
+ io.portFree = (port, host = '127.0.0.1') =>
126
+ new Promise((resolve) => {
127
+ const server = createServer();
128
+ server.once('error', () => resolve(false));
129
+ server.listen({ port, host, exclusive: true }, () => server.close(() => resolve(true)));
130
+ });
131
+ // Only with a terminal on both ends; in a script or CI a missing answer is an error instead of a hang.
132
+ if (process.stdin.isTTY && process.stdout.isTTY) io.prompt = terminalPrompt(process.stdin, process.stdout);
133
+ return io;
134
+ }
135
+
136
+ /**
137
+ * A prompt on a terminal: asks on `output`, reads the answer from `input`. Resolves to null when the input ends
138
+ * (Ctrl+D), and rejects with Interrupted on Ctrl+C.
139
+ * @param {NodeJS.ReadableStream} input
140
+ * @param {NodeJS.WritableStream} output
141
+ * @returns {NonNullable<import('./main.mjs').CliIo['prompt']>}
142
+ */
143
+ export function terminalPrompt(input, output) {
144
+ return async (question) => {
145
+ const readline = createInterface({ input, output, terminal: true });
146
+ // Cancelling the question ends the line it is on, so that what comes next starts on a line of its own.
147
+ const cancel = new AbortController();
148
+ let interrupted = false;
149
+ readline.once('SIGINT', () => {
150
+ interrupted = true;
151
+ cancel.abort();
152
+ });
153
+ readline.once('close', () => cancel.abort());
154
+ try {
155
+ return await readline.question(question, { signal: cancel.signal });
156
+ } catch {
157
+ if (interrupted) throw new Interrupted();
158
+ return null;
159
+ } finally {
160
+ readline.close();
161
+ }
162
+ };
163
+ }
package/cli/routes.mjs ADDED
@@ -0,0 +1,31 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @typedef {{ pattern: string; file: string; loader: boolean; action: boolean; props: string | null; cached: boolean; authorized: boolean }} RouteRow
5
+ * @typedef {{ method: string; route: string }} EndpointRow
6
+ * @typedef {{ kind: string; message: string; files: string[] }} RouteWarning
7
+ */
8
+
9
+ /** @param {string[][]} rows */
10
+ function table(rows) {
11
+ const widths = rows[0]?.map((_, column) => Math.max(...rows.map((row) => row[column]?.length ?? 0))) ?? [];
12
+ return rows.map((row) => row.map((cell, column) => (column === row.length - 1 ? cell : cell.padEnd(widths[column] ?? 0))).join(' ').trimEnd());
13
+ }
14
+
15
+ const yes = (/** @type {boolean} */ value) => (value ? 'yes' : '');
16
+
17
+ /**
18
+ * The route table the app reported (`NETJES_COMMAND=routes`), as text, with the app's route warnings after it.
19
+ * @param {{ routes: RouteRow[]; endpoints: EndpointRow[]; warnings?: RouteWarning[] }} report
20
+ */
21
+ export function formatRoutes(report) {
22
+ const pages = table([
23
+ ['Pattern', 'File', 'Loader', 'Action', 'Props', 'Cached', 'Auth'],
24
+ ...report.routes.map((r) => [r.pattern, r.file, yes(r.loader), yes(r.action), r.props ?? '', yes(r.cached), yes(r.authorized)]),
25
+ ]);
26
+ const endpoints = report.endpoints.length
27
+ ? ['', 'Endpoints', ...table(report.endpoints.map((e) => [e.method, e.route]))]
28
+ : [];
29
+ const warnings = report.warnings?.length ? ['', 'Warnings', ...report.warnings.map((w) => ` ${w.message}`)] : [];
30
+ return [...pages, ...endpoints, ...warnings].join('\n') + '\n';
31
+ }
@@ -0,0 +1,193 @@
1
+ // @ts-check
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url));
8
+ const TEMPLATE = path.join(PACKAGE_ROOT, 'template');
9
+ /** Skills for coding agents, copied to `.claude/skills/` in an app as they are. */
10
+ const SKILLS = path.join(PACKAGE_ROOT, 'skills');
11
+ /**
12
+ * Template files stored under another name: ones npm would drop or rename when publishing, and the app's guides for
13
+ * coding agents, which agents working on Netjes would otherwise apply to the template.
14
+ */
15
+ const RENAMED = new Map([
16
+ ['_gitignore', '.gitignore'],
17
+ ['_gitattributes', '.gitattributes'],
18
+ ['_AGENTS.md', 'AGENTS.md'],
19
+ ['_CLAUDE.md', 'CLAUDE.md'],
20
+ ]);
21
+
22
+ /**
23
+ * The ASCII words in a name, accents dropped (Café gives Cafe).
24
+ * @param {string} value
25
+ */
26
+ function words(value) {
27
+ return value
28
+ .normalize('NFD')
29
+ .replace(/\p{M}/gu, '')
30
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
31
+ .split(/[^A-Za-z0-9]+/)
32
+ .filter(Boolean);
33
+ }
34
+
35
+ /**
36
+ * Names for a project, from its directory or `--name`: a C# identifier, an npm package name, a title.
37
+ * @param {string} name
38
+ */
39
+ export function projectNames(name) {
40
+ const parts = words(name);
41
+ const pascal = parts.map((w) => w[0]?.toUpperCase() + w.slice(1)).join('') || 'App';
42
+ return {
43
+ dotnet: /^[0-9]/.test(pascal) ? `App${pascal}` : pascal,
44
+ npm: parts.map((w) => w.toLowerCase()).join('-') || 'app',
45
+ title: parts.map((w) => w[0]?.toUpperCase() + w.slice(1)).join(' ') || 'App',
46
+ };
47
+ }
48
+
49
+ /** A name `--name` accepts as it is: what the template can use as a file name, a namespace and an assembly name. */
50
+ const DOTNET_NAME = /^[A-Za-z][A-Za-z0-9]*$/;
51
+
52
+ /** The C# reserved keywords, which cannot name a namespace (CS1001). */
53
+ const CSHARP_KEYWORDS = new Set(
54
+ ('abstract as base bool break byte case catch char checked class const continue decimal default delegate do double ' +
55
+ 'else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal ' +
56
+ 'is lock long namespace new null object operator out override params private protected public readonly ref return ' +
57
+ 'sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked ' +
58
+ 'unsafe ushort using virtual void volatile while').split(' '),
59
+ );
60
+
61
+ // Types the template's C# uses by their simple names. A root namespace of the same name hides the type from that code,
62
+ // and the build fails (CS0118, CS0234); Program is the class the top-level statements in Program.cs make (CS0101).
63
+ const TEMPLATE_TYPES = new Set([
64
+ 'Program',
65
+ 'SiteProps',
66
+ 'Page',
67
+ 'ServerAction',
68
+ 'WebApplication',
69
+ 'ValueTask',
70
+ 'DateTimeOffset',
71
+ 'TimeProvider',
72
+ 'IEndpointRouteBuilder',
73
+ 'FromFormAttribute',
74
+ ]);
75
+
76
+ /**
77
+ * The names for a new app in `target`, or a message saying why there are none: `--name` as given when it is a .NET
78
+ * project name, else derived from the folder name; neither may clash with the template's own type names or with a
79
+ * package the app depends on (npm refuses to install a package into one of the same name).
80
+ * @param {string} target
81
+ * @param {string | undefined} name
82
+ * @returns {Promise<{ names: ReturnType<typeof projectNames> } | { problem: string }>}
83
+ */
84
+ export async function namesFor(target, name) {
85
+ if (name !== undefined && !DOTNET_NAME.test(name)) {
86
+ return {
87
+ problem: `--name '${name}' is not a valid .NET project name; use letters and digits, starting with a letter, e.g. --name ${projectNames(name).dotnet}`,
88
+ };
89
+ }
90
+ if (name !== undefined && CSHARP_KEYWORDS.has(name)) {
91
+ return { problem: `--name '${name}' is a C# keyword; choose another name, e.g. --name ${projectNames(name).dotnet}` };
92
+ }
93
+ if (name === undefined && words(path.basename(target)).length === 0) {
94
+ return { problem: `cannot make a project name from the folder name '${path.basename(target)}'; pass --name <Name>` };
95
+ }
96
+ const names = name === undefined ? projectNames(path.basename(target)) : { ...projectNames(name), dotnet: name };
97
+ if (TEMPLATE_TYPES.has(names.dotnet)) {
98
+ return { problem: `'${names.dotnet}' is the name of a type the app's code uses; choose another name with --name` };
99
+ }
100
+ const manifest = JSON.parse(await readFile(path.join(TEMPLATE, 'package.json'), 'utf8'));
101
+ if (Object.hasOwn({ ...manifest.dependencies, ...manifest.devDependencies }, names.npm)) {
102
+ return { problem: `the app's npm name would be '${names.npm}', a package it depends on; choose another folder name or --name` };
103
+ }
104
+ return { names };
105
+ }
106
+
107
+ /** The repository root when this package runs from a Netjes checkout, else null. */
108
+ export function checkoutRoot() {
109
+ const root = path.resolve(PACKAGE_ROOT, '..', '..');
110
+ return existsSync(path.join(root, 'src', 'Netjes', 'Netjes.csproj')) ? root : null;
111
+ }
112
+
113
+ /** @returns {Promise<string>} */
114
+ export async function packageVersion() {
115
+ const manifest = JSON.parse(await readFile(path.join(PACKAGE_ROOT, 'package.json'), 'utf8'));
116
+ return String(manifest.version);
117
+ }
118
+
119
+ /**
120
+ * Fills the name placeholders of a template file: `__NAME__` (the C# name), `__name__` (the npm name), `__TITLE__`.
121
+ * @param {string} text
122
+ * @param {ReturnType<typeof projectNames>} names
123
+ */
124
+ export function fillNames(text, names) {
125
+ return text.replaceAll('__NAME__', names.dotnet).replaceAll('__name__', names.npm).replaceAll('__TITLE__', names.title);
126
+ }
127
+
128
+ /** @param {string} file a path in the template @returns {Promise<string>} */
129
+ export const readTemplate = (file) => readFile(path.join(TEMPLATE, file), 'utf8');
130
+
131
+ /** @param {string} from @param {string} to */
132
+ const toPosix = (from, to) => path.relative(from, to).split(path.sep).join('/');
133
+
134
+ /**
135
+ * Copies the template into `target`, filling in the names and how Netjes is referenced: the local checkout
136
+ * (ProjectReference + a `file:` dependency) or the published packages.
137
+ * @param {string} target
138
+ * @param {{ name?: string; packages?: boolean }} options
139
+ */
140
+ export async function scaffold(target, options = {}) {
141
+ const found = await namesFor(target, options.name);
142
+ if ('problem' in found) throw new Error(found.problem);
143
+ const { names } = found;
144
+ const existing = await stat(target).catch(() => null);
145
+ if (existing && !existing.isDirectory()) throw new Error(`${target} is a file, not a folder`);
146
+ if (existing && (await readdir(target)).length > 0) throw new Error(`${target} already exists and is not empty; choose a new folder`);
147
+
148
+ const version = await packageVersion();
149
+ const root = options.packages ? null : checkoutRoot();
150
+ const references = root
151
+ ? ` <Import Project="${toPosix(target, root)}/src/Netjes/build/Netjes.targets" />\n\n` +
152
+ ` <ItemGroup>\n <ProjectReference Include="${toPosix(target, root)}/src/Netjes/Netjes.csproj" />\n </ItemGroup>\n`
153
+ : // Exact versions: the C# and npm halves speak one payload protocol, and a bare NuGet version is a minimum.
154
+ ` <ItemGroup>\n <PackageReference Include="Netjes" Version="[${version}]" />\n </ItemGroup>\n`;
155
+ const dependency = root ? `file:${toPosix(target, path.join(root, 'packages', 'netjes'))}` : version;
156
+
157
+ /** @param {string} text */
158
+ const fill = (text) =>
159
+ fillNames(text.replaceAll('<!--NETJES_REFERENCES-->\n', references).replaceAll('__NETJES_DEPENDENCY__', dependency), names);
160
+
161
+ /** @param {string} from @param {string} to */
162
+ async function copy(from, to) {
163
+ await mkdir(to, { recursive: true });
164
+ for (const entry of await readdir(from)) {
165
+ const source = path.join(from, entry);
166
+ const destination = path.join(to, fill(RENAMED.get(entry) ?? entry));
167
+ if ((await stat(source)).isDirectory()) await copy(source, destination);
168
+ else await writeFile(destination, fill(await readFile(source, 'utf8')));
169
+ }
170
+ }
171
+
172
+ await copy(TEMPLATE, target);
173
+ for (const skill of await skillFiles()) {
174
+ await mkdir(path.dirname(path.join(target, skill.path)), { recursive: true });
175
+ await writeFile(path.join(target, skill.path), skill.text);
176
+ }
177
+ return { names, mode: root ? 'checkout' : 'packages' };
178
+ }
179
+
180
+ /**
181
+ * The skill pack's files, by their path in an app (`.claude/skills/netjes/SKILL.md`, posix).
182
+ * @returns {Promise<{ path: string; text: string }[]>}
183
+ */
184
+ export async function skillFiles() {
185
+ const entries = await readdir(SKILLS, { recursive: true, withFileTypes: true });
186
+ const files = entries.filter((entry) => entry.isFile()).map((entry) => path.join(entry.parentPath, entry.name));
187
+ return Promise.all(
188
+ files.sort().map(async (file) => ({
189
+ path: ['.claude', 'skills', ...path.relative(SKILLS, file).split(path.sep)].join('/'),
190
+ text: await readFile(file, 'utf8'),
191
+ })),
192
+ );
193
+ }
@@ -0,0 +1,50 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Edit distance with adjacent transpositions (optimal string alignment), so `nwe` is one edit from `new`.
5
+ * @param {string} a
6
+ * @param {string} b
7
+ */
8
+ function distance(a, b) {
9
+ /** @type {number[][]} */
10
+ const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
11
+ for (let i = 1; i <= a.length; i++) {
12
+ for (let j = 1; j <= b.length; j++) {
13
+ const row = /** @type {number[]} */ (d[i]);
14
+ const above = /** @type {number[]} */ (d[i - 1]);
15
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
16
+ row[j] = Math.min(/** @type {number} */ (above[j]) + 1, /** @type {number} */ (row[j - 1]) + 1, /** @type {number} */ (above[j - 1]) + cost);
17
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
18
+ row[j] = Math.min(/** @type {number} */ (row[j]), /** @type {number} */ (d[i - 2]?.[j - 2]) + 1);
19
+ }
20
+ }
21
+ }
22
+ return /** @type {number} */ (d[a.length]?.[b.length]);
23
+ }
24
+
25
+ /**
26
+ * The candidate someone most likely meant by `input`, or undefined: the same word in another case, a close spelling,
27
+ * or the only candidate that starts with or contains it.
28
+ * @param {string} input
29
+ * @param {Iterable<string>} candidates
30
+ * @returns {string | undefined}
31
+ */
32
+ export function suggest(input, candidates) {
33
+ const typed = input.toLowerCase();
34
+ const all = [...candidates];
35
+ const exact = all.find((candidate) => candidate.toLowerCase() === typed);
36
+ if (exact) return exact;
37
+
38
+ const limit = typed.length <= 4 ? 1 : 2;
39
+ const close = all
40
+ .map((candidate) => ({ candidate, edits: distance(typed, candidate.toLowerCase()) }))
41
+ .filter(({ edits }) => edits <= limit)
42
+ .sort((a, b) => a.edits - b.edits);
43
+ if (close[0]) return close[0].candidate;
44
+
45
+ if (typed.length < 2) return undefined;
46
+ const starting = all.filter((candidate) => candidate.toLowerCase().startsWith(typed));
47
+ if (starting.length === 1) return starting[0];
48
+ const containing = typed.length >= 3 ? all.filter((candidate) => candidate.toLowerCase().includes(typed)) : [];
49
+ return containing.length === 1 ? containing[0] : undefined;
50
+ }
@@ -0,0 +1,40 @@
1
+ // @ts-check
2
+ // Colour for a terminal that wants it: never in pipes or files, never with NO_COLOR (https://no-color.org).
3
+
4
+ /**
5
+ * Whether to colour a stream: NO_COLOR turns it off, FORCE_COLOR on (or off with 0 or false), a dumb terminal off;
6
+ * otherwise only when the stream is a terminal.
7
+ * @param {Record<string, string | undefined>} env
8
+ * @param {boolean | undefined} tty
9
+ */
10
+ export function colorSupport(env, tty) {
11
+ if (env.NO_COLOR) return false;
12
+ const force = env.FORCE_COLOR;
13
+ if (force !== undefined && force !== '') return force !== '0' && force !== 'false';
14
+ if (env.TERM === 'dumb') return false;
15
+ return tty === true;
16
+ }
17
+
18
+ /**
19
+ * Styles that only apply when `on`.
20
+ * @param {boolean} on
21
+ */
22
+ export function paint(on) {
23
+ /** @param {number} open @param {number} close */
24
+ const style = (open, close) => (/** @type {string} */ text) => (on ? `\u001b[${open}m${text}\u001b[${close}m` : text);
25
+ return { bold: style(1, 22), dim: style(2, 22), red: style(31, 39), green: style(32, 39), yellow: style(33, 39), cyan: style(36, 39) };
26
+ }
27
+
28
+ /**
29
+ * Writes one message to stderr, its `netjes …:` label in red when stderr takes colour.
30
+ * @param {import('./main.mjs').CliIo} io
31
+ * @param {string} message
32
+ */
33
+ export function printError(io, message) {
34
+ const colon = message.indexOf(': ');
35
+ const { red } = paint(io.color?.stderr === true);
36
+ io.stderr(colon > 0 ? `${red(message.slice(0, colon + 1))}${message.slice(colon + 1)}\n` : `${red(message)}\n`);
37
+ }
38
+
39
+ /** Ctrl+C at a prompt. The command stops there, with exit code 130, as a shell reports SIGINT. */
40
+ export class Interrupted extends Error {}