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.
- package/LICENSE +21 -0
- package/README.md +54 -2
- package/bin/netjes.mjs +13 -0
- package/cli/agents.mjs +66 -0
- package/cli/commands.mjs +467 -0
- package/cli/help.mjs +89 -0
- package/cli/info.mjs +454 -0
- package/cli/main.mjs +167 -0
- package/cli/packages.mjs +19 -0
- package/cli/process-io.mjs +163 -0
- package/cli/routes.mjs +31 -0
- package/cli/scaffold.mjs +193 -0
- package/cli/suggest.mjs +50 -0
- package/cli/terminal.mjs +40 -0
- package/dist/actions.d.ts +98 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +176 -0
- package/dist/actions.js.map +1 -0
- package/dist/client-entry.d.ts +2 -0
- package/dist/client-entry.d.ts.map +1 -0
- package/dist/client-entry.js +102 -0
- package/dist/client-entry.js.map +1 -0
- package/dist/deferred.d.ts +39 -0
- package/dist/deferred.d.ts.map +1 -0
- package/dist/deferred.js +123 -0
- package/dist/deferred.js.map +1 -0
- package/dist/define-page.d.ts +15 -0
- package/dist/define-page.d.ts.map +1 -0
- package/dist/define-page.js +27 -0
- package/dist/define-page.js.map +1 -0
- package/dist/document.d.ts +18 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/document.js +16 -0
- package/dist/document.js.map +1 -0
- package/dist/draft-mode.d.ts +5 -0
- package/dist/draft-mode.d.ts.map +1 -0
- package/dist/draft-mode.js +6 -0
- package/dist/draft-mode.js.map +1 -0
- package/dist/dynamic.d.ts +20 -0
- package/dist/dynamic.d.ts.map +1 -0
- package/dist/dynamic.js +37 -0
- package/dist/dynamic.js.map +1 -0
- package/dist/form-data.d.ts +10 -0
- package/dist/form-data.d.ts.map +1 -0
- package/dist/form-data.js +10 -0
- package/dist/form-data.js.map +1 -0
- package/dist/form.d.ts +35 -0
- package/dist/form.d.ts.map +1 -0
- package/dist/form.js +80 -0
- package/dist/form.js.map +1 -0
- package/dist/href.d.ts +25 -0
- package/dist/href.d.ts.map +1 -0
- package/dist/href.js +71 -0
- package/dist/href.js.map +1 -0
- package/dist/image.d.ts +36 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +60 -0
- package/dist/image.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/link.d.ts +17 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/link.js +46 -0
- package/dist/link.js.map +1 -0
- package/dist/locale.d.ts +3 -0
- package/dist/locale.d.ts.map +1 -0
- package/dist/locale.js +6 -0
- package/dist/locale.js.map +1 -0
- package/dist/metadata.d.ts +83 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +100 -0
- package/dist/metadata.js.map +1 -0
- package/dist/modules.d.ts +74 -0
- package/dist/modules.d.ts.map +1 -0
- package/dist/modules.js +85 -0
- package/dist/modules.js.map +1 -0
- package/dist/nonce.d.ts +8 -0
- package/dist/nonce.d.ts.map +1 -0
- package/dist/nonce.js +12 -0
- package/dist/nonce.js.map +1 -0
- package/dist/payload.d.ts +169 -0
- package/dist/payload.d.ts.map +1 -0
- package/dist/payload.js +271 -0
- package/dist/payload.js.map +1 -0
- package/dist/public-config.d.ts +11 -0
- package/dist/public-config.d.ts.map +1 -0
- package/dist/public-config.js +10 -0
- package/dist/public-config.js.map +1 -0
- package/dist/query.d.ts +22 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +68 -0
- package/dist/query.js.map +1 -0
- package/dist/render-cleanup.d.ts +6 -0
- package/dist/render-cleanup.d.ts.map +1 -0
- package/dist/render-cleanup.js +7 -0
- package/dist/render-cleanup.js.map +1 -0
- package/dist/root.d.ts +12 -0
- package/dist/root.d.ts.map +1 -0
- package/dist/root.js +26 -0
- package/dist/root.js.map +1 -0
- package/dist/route-tree.d.ts +56 -0
- package/dist/route-tree.d.ts.map +1 -0
- package/dist/route-tree.js +86 -0
- package/dist/route-tree.js.map +1 -0
- package/dist/router.d.ts +118 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +938 -0
- package/dist/router.js.map +1 -0
- package/dist/schema.d.ts +35 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +74 -0
- package/dist/schema.js.map +1 -0
- package/dist/script.d.ts +23 -0
- package/dist/script.d.ts.map +1 -0
- package/dist/script.js +89 -0
- package/dist/script.js.map +1 -0
- package/dist/server-entry.d.ts +2 -0
- package/dist/server-entry.d.ts.map +1 -0
- package/dist/server-entry.js +4 -0
- package/dist/server-entry.js.map +1 -0
- package/dist/server-render.d.ts +19 -0
- package/dist/server-render.d.ts.map +1 -0
- package/dist/server-render.js +193 -0
- package/dist/server-render.js.map +1 -0
- package/dist/testing-setup.d.ts +2 -0
- package/dist/testing-setup.d.ts.map +1 -0
- package/dist/testing-setup.js +8 -0
- package/dist/testing-setup.js.map +1 -0
- package/dist/testing.d.ts +76 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +106 -0
- package/dist/testing.js.map +1 -0
- package/dist/vitals-config.d.ts +4 -0
- package/dist/vitals-config.d.ts.map +1 -0
- package/dist/vitals-config.js +5 -0
- package/dist/vitals-config.js.map +1 -0
- package/dist/vitals.d.ts +13 -0
- package/dist/vitals.d.ts.map +1 -0
- package/dist/vitals.js +60 -0
- package/dist/vitals.js.map +1 -0
- package/dist/zod.d.ts +19 -0
- package/dist/zod.d.ts.map +1 -0
- package/dist/zod.js +65 -0
- package/dist/zod.js.map +1 -0
- package/package.json +147 -4
- package/sidecar/server.mjs +325 -0
- package/skills/netjes/SKILL.md +93 -0
- package/skills/netjes/references/caching-and-streaming.md +68 -0
- package/skills/netjes/references/data-and-actions.md +98 -0
- package/skills/netjes/references/deployment.md +57 -0
- package/skills/netjes/references/routing.md +65 -0
- package/skills/netjes/references/testing.md +57 -0
- package/src/actions.tsx +241 -0
- package/src/client-entry.tsx +103 -0
- package/src/deferred.ts +134 -0
- package/src/define-page.tsx +41 -0
- package/src/document.tsx +47 -0
- package/src/draft-mode.ts +6 -0
- package/src/dynamic.tsx +64 -0
- package/src/form-data.ts +10 -0
- package/src/form.tsx +122 -0
- package/src/href.ts +79 -0
- package/src/image.tsx +91 -0
- package/src/index.ts +40 -0
- package/src/link.tsx +73 -0
- package/src/locale.ts +6 -0
- package/src/metadata.tsx +181 -0
- package/src/modules.ts +133 -0
- package/src/nonce.tsx +14 -0
- package/src/payload.ts +403 -0
- package/src/public-config.tsx +19 -0
- package/src/query.tsx +82 -0
- package/src/render-cleanup.ts +7 -0
- package/src/root.tsx +39 -0
- package/src/route-tree.tsx +139 -0
- package/src/router.tsx +1188 -0
- package/src/schema.ts +98 -0
- package/src/script.tsx +103 -0
- package/src/server-entry.tsx +4 -0
- package/src/server-render.tsx +223 -0
- package/src/testing-setup.ts +8 -0
- package/src/testing.tsx +177 -0
- package/src/virtual.d.ts +12 -0
- package/src/vitals-config.ts +6 -0
- package/src/vitals.tsx +68 -0
- package/src/zod.ts +72 -0
- package/template/.dockerignore +11 -0
- package/template/Dockerfile +58 -0
- package/template/Features/Home/HomePage.cs +32 -0
- package/template/Program.cs +28 -0
- package/template/Properties/launchSettings.json +13 -0
- package/template/README.md +24 -0
- package/template/SiteProps.cs +4 -0
- package/template/_AGENTS.md +64 -0
- package/template/_CLAUDE.md +1 -0
- package/template/__NAME__.csproj +11 -0
- package/template/_gitattributes +2 -0
- package/template/_gitignore +11 -0
- package/template/app/about/page.tsx +17 -0
- package/template/app/error.tsx +15 -0
- package/template/app/layout.tsx +33 -0
- package/template/app/not-found.tsx +14 -0
- package/template/app/page.tsx +14 -0
- package/template/appsettings.Development.json +3 -0
- package/template/appsettings.json +9 -0
- package/template/components/Greeting.test.tsx +25 -0
- package/template/components/Greeting.tsx +26 -0
- package/template/components/HelloForm.test.tsx +27 -0
- package/template/components/HelloForm.tsx +34 -0
- package/template/docker-compose.override.yml +15 -0
- package/template/docker-compose.prod.yml +26 -0
- package/template/docker-compose.yml +16 -0
- package/template/generated/netjes.ts +73 -0
- package/template/package.json +36 -0
- package/template/public/favicon.svg +6 -0
- package/template/styles/global.css +7 -0
- package/template/tsconfig.json +19 -0
- package/template/vite.config.ts +7 -0
- package/vite/plugin.d.ts +29 -0
- 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
|
+
}
|
package/cli/packages.mjs
ADDED
|
@@ -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
|
+
}
|
package/cli/scaffold.mjs
ADDED
|
@@ -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
|
+
}
|
package/cli/suggest.mjs
ADDED
|
@@ -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
|
+
}
|
package/cli/terminal.mjs
ADDED
|
@@ -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 {}
|