@znight/cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +482 -2
  3. package/bin/znight.mjs +17 -0
  4. package/dist/cli.d.mts +1 -0
  5. package/dist/cli.mjs +13 -0
  6. package/dist/cli.mjs.map +1 -0
  7. package/dist/dev/orphan-guard.d.mts +1 -0
  8. package/dist/dev/orphan-guard.mjs +47 -0
  9. package/dist/dev/orphan-guard.mjs.map +1 -0
  10. package/dist/index.d.mts +679 -0
  11. package/dist/index.mjs +1 -0
  12. package/dist/main-BWsiUsxN.mjs +3865 -0
  13. package/dist/main-BWsiUsxN.mjs.map +1 -0
  14. package/dist/orphan-Dn-2xMBd.mjs +45 -0
  15. package/dist/orphan-Dn-2xMBd.mjs.map +1 -0
  16. package/package.json +71 -4
  17. package/src/cli.ts +11 -0
  18. package/src/command.ts +217 -0
  19. package/src/commands/build.ts +159 -0
  20. package/src/commands/dev.ts +249 -0
  21. package/src/commands/generate.ts +304 -0
  22. package/src/commands/new.ts +245 -0
  23. package/src/commands/openapi.ts +160 -0
  24. package/src/commands/schematic.ts +110 -0
  25. package/src/commands/shared.ts +106 -0
  26. package/src/commands/start.ts +79 -0
  27. package/src/context.ts +126 -0
  28. package/src/dev/orphan-guard.ts +49 -0
  29. package/src/dev/orphan.ts +74 -0
  30. package/src/dev/supervisor.ts +175 -0
  31. package/src/errors.ts +64 -0
  32. package/src/index.ts +73 -0
  33. package/src/integrations/compiler.ts +360 -0
  34. package/src/integrations/openapi.ts +97 -0
  35. package/src/integrations/process.ts +180 -0
  36. package/src/integrations/prompts.ts +108 -0
  37. package/src/integrations/resolve.ts +38 -0
  38. package/src/integrations/vite.ts +362 -0
  39. package/src/integrations/watch.ts +243 -0
  40. package/src/main.ts +195 -0
  41. package/src/names.ts +106 -0
  42. package/src/output.ts +110 -0
  43. package/src/runtime.ts +86 -0
  44. package/src/scaffold/create.ts +65 -0
  45. package/src/scaffold/options.ts +103 -0
  46. package/src/scaffold/templates.ts +771 -0
  47. package/src/scaffold/versions.ts +25 -0
  48. package/src/schematics/index.ts +422 -0
  49. package/src/version.ts +20 -0
@@ -0,0 +1,243 @@
1
+ /**
2
+ * File watching for `znight generate --watch` (`fs.watch`, recursive on macOS, Windows and Linux with
3
+ * Node.js >= 20 / Bun). It covers what the Vite plugin and the Bun preload watch: the `include`
4
+ * directories of the project and of its source libraries (recursively), the project root
5
+ * (`package.json`, `tsconfig.json`, lockfiles) and the directories of the files the last compile
6
+ * depends on (`watchFiles`: library manifests, compiled libraries...), refreshed after every compile.
7
+ * Which events matter is the compiler's call (`includeRoots()`, `isRelevantChange()`); this module
8
+ * only turns `fs.watch` events, which carry no kind and report a moved directory without its files,
9
+ * into compiler changes.
10
+ *
11
+ * Paths handed to the compiler are absolute, real (symbolic links resolved) and POSIX-style, like the
12
+ * paths of the compiler itself (`watchFiles`, `includeRoots()`).
13
+ */
14
+ import type { ChangeKind, CompilerChanges } from './compiler.ts'
15
+
16
+ import { existsSync, type FSWatcher, realpathSync, statSync, watch } from 'node:fs'
17
+ import { join, posix, resolve } from 'node:path'
18
+
19
+ /** A directory to watch. */
20
+ export interface WatchTarget {
21
+ readonly path: string
22
+ readonly recursive: boolean
23
+ }
24
+
25
+ /** A running watcher. */
26
+ export interface FileWatcher {
27
+ close(): void
28
+ }
29
+
30
+ /**
31
+ * Starts watching `targets`; `onChange` receives the absolute path of every changed file or
32
+ * directory (created, modified, deleted or renamed), except in ignored directories
33
+ * ({@link isIgnoredPath}).
34
+ */
35
+ export type WatchFactory = (targets: readonly WatchTarget[], onChange: (file: string) => void) => FileWatcher
36
+
37
+ const IGNORED_SEGMENTS = new Set(['node_modules', 'dist', 'coverage'])
38
+
39
+ /** Native separators to forward slashes (the compiler's path style, also on Windows). */
40
+ export function toPosixPath(path: string): string {
41
+ return path.includes('\\') ? path.replaceAll('\\', '/') : path
42
+ }
43
+
44
+ /**
45
+ * The absolute, POSIX-style real path of `path` (symbolic links resolved and the on-disk casing:
46
+ * `/var` → `/private/var` on macOS), also when its last segments do not exist (anymore): the real
47
+ * path of the deepest existing parent, then the rest. Makes watcher paths comparable with the
48
+ * compiler's.
49
+ */
50
+ export function realPath(path: string): string {
51
+ const absolute = resolve(path)
52
+ try {
53
+ return toPosixPath(realpathSync.native(absolute))
54
+ } catch {
55
+ const parent = resolve(absolute, '..')
56
+ if (parent === absolute) return toPosixPath(absolute)
57
+ return `${realPath(parent).replace(/\/$/, '')}/${posix.basename(toPosixPath(absolute))}`
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Whether a path relative to a watched directory is never relevant: inside `node_modules`, `dist`,
63
+ * `coverage` or a dot directory (`.git`, `.znight`...).
64
+ */
65
+ export function isIgnoredPath(relativePath: string): boolean {
66
+ return relativePath.split(/[\\/]/).some(segment => IGNORED_SEGMENTS.has(segment) || segment.startsWith('.'))
67
+ }
68
+
69
+ /** `true` when `file` is `directory` or inside it (absolute POSIX paths). */
70
+ export function isWithin(directory: string, file: string): boolean {
71
+ return file === directory || file.startsWith(directory.endsWith('/') ? directory : `${directory}/`)
72
+ }
73
+
74
+ /** Drops the directories nested in another one; sorted, de-duplicated. */
75
+ function outermost(directories: Iterable<string>): string[] {
76
+ const sorted = [...new Set(directories)].sort()
77
+ return sorted.filter(directory => !sorted.some(other => other !== directory && isWithin(other, directory)))
78
+ }
79
+
80
+ /**
81
+ * Directories to watch: the include `roots` (`includeRoots()` of the compiler) recursively; the
82
+ * project `root` and the directories of `files` (the `watchFiles` of the last compile) that are
83
+ * outside them, non-recursively.
84
+ */
85
+ export function watchTargets(root: string, roots: readonly string[], files: Iterable<string> = []): WatchTarget[] {
86
+ const recursive = outermost(roots.map(toPosixPath))
87
+ const covered = (directory: string): boolean => recursive.some(base => isWithin(base, directory))
88
+ const flat = new Set<string>()
89
+ const directories = [toPosixPath(resolve(root))]
90
+ for (const file of files) directories.push(posix.dirname(toPosixPath(resolve(file))))
91
+ for (const directory of directories) if (!covered(directory)) flat.add(directory)
92
+ return [
93
+ ...recursive.map(path => ({ path, recursive: true })),
94
+ ...[...flat].sort().map(path => ({ path, recursive: false })),
95
+ ]
96
+ }
97
+
98
+ type PathKind = 'file' | 'directory' | 'missing'
99
+
100
+ function kindOf(path: string): PathKind {
101
+ try {
102
+ return statSync(path).isDirectory() ? 'directory' : 'file'
103
+ } catch {
104
+ return 'missing'
105
+ }
106
+ }
107
+
108
+ /** The known files inside `directory`. */
109
+ function knownWithin(known: ReadonlySet<string>, directory: string): string[] {
110
+ const prefix = directory.endsWith('/') ? directory : `${directory}/`
111
+ return [...known].filter(file => file.startsWith(prefix))
112
+ }
113
+
114
+ /**
115
+ * The kind of an `fs.watch` event on `file` (absolute, real, POSIX-style), which `fs.watch` does not
116
+ * tell, from the disk and the files of the last compile (`known`): `removed` when it is gone,
117
+ * `changed` for a known file, `added` otherwise (a new file, a directory created or moved there).
118
+ */
119
+ export function changeKind(file: string, known: ReadonlySet<string>): ChangeKind {
120
+ switch (kindOf(file)) {
121
+ case 'missing':
122
+ return 'removed'
123
+ case 'directory':
124
+ return 'added'
125
+ default:
126
+ return known.has(file) ? 'changed' : 'added'
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Classifies relevant paths (`isRelevantChange()` of the compiler) for the incremental compiler,
132
+ * given the files of the last compile: a deleted path is removed with the known files it contained
133
+ * (a directory moved away), an existing directory is added (the compiler re-runs the scan) and the
134
+ * known files it contains are re-read (or removed when gone), an existing file is changed when
135
+ * known, added otherwise.
136
+ */
137
+ export function classifyChanges(files: Iterable<string>, known: ReadonlySet<string>): CompilerChanges {
138
+ const changed = new Set<string>()
139
+ const added = new Set<string>()
140
+ const removed = new Set<string>()
141
+ for (const file of files) {
142
+ switch (kindOf(file)) {
143
+ case 'missing':
144
+ removed.add(file)
145
+ for (const inner of knownWithin(known, file)) removed.add(inner)
146
+ break
147
+ case 'directory':
148
+ added.add(file)
149
+ for (const inner of knownWithin(known, file)) (existsSync(inner) ? changed : removed).add(inner)
150
+ break
151
+ default:
152
+ if (known.has(file)) changed.add(file)
153
+ else added.add(file)
154
+ }
155
+ }
156
+ return {
157
+ changed: [...changed].filter(file => !removed.has(file)),
158
+ added: [...added].filter(file => !removed.has(file)),
159
+ removed: [...removed],
160
+ }
161
+ }
162
+
163
+ /** The default {@link WatchFactory}, based on `fs.watch`. */
164
+ export const watchFiles: WatchFactory = (targets, onChange) => {
165
+ const watchers: FSWatcher[] = []
166
+ for (const target of targets) {
167
+ if (!existsSync(target.path)) continue
168
+ let watcher: FSWatcher
169
+ try {
170
+ watcher = watch(target.path, { recursive: target.recursive }, (_event, filename) => {
171
+ // no file name (rare, Linux): something changed in the directory itself
172
+ if (filename === null) {
173
+ onChange(target.path)
174
+ return
175
+ }
176
+ const name = filename.toString()
177
+ if (!isIgnoredPath(name)) onChange(join(target.path, name))
178
+ })
179
+ } catch {
180
+ // removed meanwhile (or not watchable): the next compile refreshes the targets
181
+ continue
182
+ }
183
+ watcher.on('error', () => {
184
+ // a watched directory was removed: keep the other watchers running
185
+ })
186
+ watchers.push(watcher)
187
+ }
188
+ return {
189
+ close: () => {
190
+ for (const watcher of watchers) watcher.close()
191
+ },
192
+ }
193
+ }
194
+
195
+ const targetKey = (target: WatchTarget): string => `${target.recursive ? 'r' : 'f'}:${target.path}`
196
+
197
+ /**
198
+ * The watchers of a set of targets that changes after every compile: one watcher per target (started
199
+ * with a {@link WatchFactory}), so that unchanged targets keep their watcher.
200
+ */
201
+ export class TargetWatchers {
202
+ readonly #factory: WatchFactory
203
+ readonly #onChange: (file: string) => void
204
+ readonly #watchers = new Map<string, { readonly watcher: FileWatcher; readonly existed: boolean }>()
205
+
206
+ constructor(factory: WatchFactory, onChange: (file: string) => void) {
207
+ this.#factory = factory
208
+ this.#onChange = onChange
209
+ }
210
+
211
+ /** The watched targets. */
212
+ get targets(): WatchTarget[] {
213
+ return [...this.#watchers.keys()].map(key => ({ path: key.slice(2), recursive: key.startsWith('r') }))
214
+ }
215
+
216
+ /**
217
+ * Watches exactly `targets`: stops the watchers of the targets that are gone, starts the new ones,
218
+ * and restarts those whose directory was missing when they started, or was itself created, moved or
219
+ * deleted since (`touched`: the paths of the last changes).
220
+ */
221
+ update(targets: readonly WatchTarget[], touched: ReadonlySet<string> = new Set()): void {
222
+ const wanted = new Map(targets.map(target => [targetKey(target), target]))
223
+ for (const [key, entry] of this.#watchers) {
224
+ if (wanted.has(key)) continue
225
+ entry.watcher.close()
226
+ this.#watchers.delete(key)
227
+ }
228
+ for (const [key, target] of wanted) {
229
+ const entry = this.#watchers.get(key)
230
+ const exists = existsSync(target.path)
231
+ if (entry !== undefined) {
232
+ if (!exists || (entry.existed && !touched.has(target.path))) continue
233
+ entry.watcher.close()
234
+ }
235
+ this.#watchers.set(key, { watcher: this.#factory([target], this.#onChange), existed: exists })
236
+ }
237
+ }
238
+
239
+ close(): void {
240
+ for (const entry of this.#watchers.values()) entry.watcher.close()
241
+ this.#watchers.clear()
242
+ }
243
+ }
package/src/main.ts ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Entry point of the CLI: global options, command dispatch, help, and error reporting with exit
3
+ * codes (0 ok, 1 failure, 2 usage, 130 cancelled).
4
+ */
5
+ import type { Command } from './command.ts'
6
+ import type { CliContext, CliContextOptions } from './context.ts'
7
+
8
+ import { renderCommandHelp, table } from './command.ts'
9
+ import { buildCommand } from './commands/build.ts'
10
+ import { devCommand } from './commands/dev.ts'
11
+ import { generateCommand } from './commands/generate.ts'
12
+ import { newCommand } from './commands/new.ts'
13
+ import { openapiCommand } from './commands/openapi.ts'
14
+ import { schematicCommand } from './commands/schematic.ts'
15
+ import { startCommand } from './commands/start.ts'
16
+ import { createContext } from './context.ts'
17
+ import { CancelledError, CliError, ExitCode, errorMessage, UsageError } from './errors.ts'
18
+ import { suggest } from './names.ts'
19
+
20
+ /** Every command, in help order. */
21
+ export const COMMANDS: readonly Command[] = [
22
+ newCommand,
23
+ generateCommand,
24
+ schematicCommand,
25
+ devCommand,
26
+ buildCommand,
27
+ startCommand,
28
+ openapiCommand,
29
+ ]
30
+
31
+ /** Finds a command by name or alias. */
32
+ export function findCommand(name: string): Command | undefined {
33
+ return COMMANDS.find(command => command.meta.name === name || command.meta.aliases?.includes(name) === true)
34
+ }
35
+
36
+ interface GlobalOptions {
37
+ color?: boolean
38
+ debug: boolean
39
+ rest: string[]
40
+ }
41
+
42
+ /** Extracts `--color`, `--no-color` and `--debug` (anywhere before `--`). */
43
+ function extractGlobalOptions(argv: readonly string[]): GlobalOptions {
44
+ const separator = argv.indexOf('--')
45
+ const before = separator === -1 ? argv : argv.slice(0, separator)
46
+ const after = separator === -1 ? [] : argv.slice(separator)
47
+ const result: GlobalOptions = { debug: false, rest: [] }
48
+ for (const arg of before) {
49
+ if (arg === '--color') result.color = true
50
+ else if (arg === '--no-color') result.color = false
51
+ else if (arg === '--debug') result.debug = true
52
+ else result.rest.push(arg)
53
+ }
54
+ result.rest.push(...after)
55
+ return result
56
+ }
57
+
58
+ /**
59
+ * Runs the CLI with `argv` (without the executable and script, i.e. `process.argv.slice(2)`).
60
+ * Resolves with the exit code; never throws.
61
+ */
62
+ export async function main(argv: readonly string[], options: CliContextOptions = {}): Promise<number> {
63
+ const globals = extractGlobalOptions(argv)
64
+ const cli = createContext({
65
+ ...options,
66
+ ...(globals.color === undefined ? {} : { color: globals.color }),
67
+ ...(globals.debug ? { debug: true } : {}),
68
+ })
69
+ try {
70
+ return await dispatch(globals.rest, cli)
71
+ } catch (error) {
72
+ return reportError(error, cli)
73
+ }
74
+ }
75
+
76
+ async function dispatch(argv: readonly string[], cli: CliContext): Promise<number> {
77
+ const separator = argv.indexOf('--')
78
+ const args = separator === -1 ? [...argv] : argv.slice(0, separator)
79
+ const passthrough = separator === -1 ? [] : argv.slice(separator + 1)
80
+ const [first, ...rest] = args
81
+
82
+ if (first === undefined || first === '-h' || first === '--help') {
83
+ cli.reporter.log(renderRootHelp(cli))
84
+ return ExitCode.Ok
85
+ }
86
+ if (first === '-v' || first === '--version' || first === 'version') {
87
+ cli.reporter.log(cli.version)
88
+ return ExitCode.Ok
89
+ }
90
+ if (first === 'help') {
91
+ const target = rest[0] === undefined ? undefined : findCommand(rest[0])
92
+ if (rest[0] !== undefined && target === undefined) throw unknownCommand(rest[0])
93
+ cli.reporter.log(target === undefined ? renderRootHelp(cli) : renderCommandHelp(target, cli.colors))
94
+ return ExitCode.Ok
95
+ }
96
+ if (first.startsWith('-')) {
97
+ throw new UsageError(`Unknown option ${first}`, ['options go after the command: znight <command> [options]'])
98
+ }
99
+
100
+ let command = findCommand(first)
101
+ let commandArgs = rest
102
+ // `generate:service user` and `generate service user` are aliases of `g service user`
103
+ if (command === undefined && first.startsWith('generate:')) {
104
+ command = schematicCommand
105
+ commandArgs = [first.slice('generate:'.length), ...rest]
106
+ } else if (command === generateCommand && rest[0] !== undefined && !rest[0].startsWith('-')) {
107
+ command = schematicCommand
108
+ }
109
+ if (command === undefined) throw unknownCommand(first)
110
+
111
+ if (commandArgs.includes('-h') || commandArgs.includes('--help')) {
112
+ cli.reporter.log(renderCommandHelp(command, cli.colors))
113
+ return ExitCode.Ok
114
+ }
115
+ try {
116
+ return await command.execute(commandArgs, passthrough, cli)
117
+ } catch (error) {
118
+ if (error instanceof UsageError) {
119
+ throw new UsageError(error.message, [...error.hints, `run "znight ${command.meta.name} --help" for the usage`])
120
+ }
121
+ throw error
122
+ }
123
+ }
124
+
125
+ function unknownCommand(name: string): UsageError {
126
+ const candidate = suggest(
127
+ name,
128
+ COMMANDS.flatMap(command => [command.meta.name, ...(command.meta.aliases ?? [])]),
129
+ )
130
+ return new UsageError(`Unknown command "${name}"`, [
131
+ ...(candidate === undefined ? [] : [`did you mean "znight ${candidate}"?`]),
132
+ 'run "znight --help" for the list of commands',
133
+ ])
134
+ }
135
+
136
+ /** The help of `znight` (list of commands). */
137
+ export function renderRootHelp(cli: Pick<CliContext, 'colors' | 'version'>): string {
138
+ const { colors } = cli
139
+ const commands = table(
140
+ [
141
+ ['new <name>', newCommand.meta.description],
142
+ ['generate', 'Generate the DI container (--check in CI, --watch)'],
143
+ ['g <schematic> <name>', 'Generate a service, controller, consumer, config, guard, interceptor, factory or job'],
144
+ ['dev', 'Build in watch mode, restart the application on every change'],
145
+ ['build', buildCommand.meta.description],
146
+ ['start', startCommand.meta.description],
147
+ ['openapi', openapiCommand.meta.description],
148
+ ],
149
+ colors,
150
+ )
151
+ const options = table(
152
+ [
153
+ ['-h, --help', 'Show the help (also: znight <command> --help)'],
154
+ ['-v, --version', 'Show the version'],
155
+ ['--color, --no-color', 'Force or disable colors (NO_COLOR and FORCE_COLOR are honoured)'],
156
+ ['--debug', 'Print the stack trace of unexpected errors (or ZNIGHT_DEBUG=1)'],
157
+ ],
158
+ colors,
159
+ )
160
+ return [
161
+ `${colors.bold('znight')} ${colors.dim(`v${cli.version}`)} ${colors.dim('—')} compile-time dependency injection for TypeScript`,
162
+ '',
163
+ colors.bold('Usage'),
164
+ ` ${colors.cyan('znight <command> [options]')}`,
165
+ '',
166
+ colors.bold('Commands'),
167
+ ...commands,
168
+ '',
169
+ colors.bold('Global options'),
170
+ ...options,
171
+ '',
172
+ `Run ${colors.cyan('znight <command> --help')} for the options of a command.`,
173
+ '',
174
+ ].join('\n')
175
+ }
176
+
177
+ function reportError(error: unknown, cli: CliContext): number {
178
+ if (error instanceof CancelledError) {
179
+ cli.reporter.warn(error.message)
180
+ return error.exitCode
181
+ }
182
+ if (error instanceof CliError) {
183
+ cli.reporter.error(error.message, error.hints)
184
+ if (cli.debug && error.cause !== undefined) cli.reporter.logError(stackOf(error.cause))
185
+ return error.exitCode
186
+ }
187
+ cli.reporter.error(`Unexpected error: ${errorMessage(error)}`)
188
+ if (cli.debug) cli.reporter.logError(stackOf(error))
189
+ else cli.reporter.logError(cli.colors.dim(' run with --debug (or ZNIGHT_DEBUG=1) for the stack trace'))
190
+ return ExitCode.Failure
191
+ }
192
+
193
+ function stackOf(error: unknown): string {
194
+ return error instanceof Error && error.stack !== undefined ? error.stack : String(error)
195
+ }
package/src/names.ts ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Naming helpers shared by `znight new` and `znight g`: case conversions, npm package names,
3
+ * "did you mean" suggestions.
4
+ */
5
+
6
+ /** Splits an identifier into lower-case words: `UserProfile`, `user_profile`, `HTTPClient` → words. */
7
+ export function words(name: string): string[] {
8
+ return name
9
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
10
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
11
+ .split(/[^A-Za-z0-9]+/)
12
+ .filter(word => word !== '')
13
+ .map(word => word.toLowerCase())
14
+ }
15
+
16
+ /** `user-profile` */
17
+ export function kebabCase(name: string): string {
18
+ return words(name).join('-')
19
+ }
20
+
21
+ /** `UserProfile` */
22
+ export function pascalCase(name: string): string {
23
+ return words(name)
24
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
25
+ .join('')
26
+ }
27
+
28
+ /** `userProfile` */
29
+ export function camelCase(name: string): string {
30
+ const pascal = pascalCase(name)
31
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1)
32
+ }
33
+
34
+ /** `USER_PROFILE` */
35
+ export function constantCase(name: string): string {
36
+ return words(name)
37
+ .map(word => word.toUpperCase())
38
+ .join('_')
39
+ }
40
+
41
+ const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/
42
+
43
+ /**
44
+ * Validates an npm package name (lower case, URL-safe, at most 214 characters, optional scope).
45
+ * Returns a problem description, or `undefined` when the name is valid.
46
+ */
47
+ export function validatePackageName(name: string): string | undefined {
48
+ if (name === '') return 'the name cannot be empty'
49
+ if (name.length > 214) return 'the name cannot be longer than 214 characters'
50
+ if (name !== name.trim()) return 'the name cannot start or end with spaces'
51
+ if (name !== name.toLowerCase()) return 'the name must be lower case'
52
+ if (name.startsWith('.') || name.startsWith('_')) return 'the name cannot start with "." or "_"'
53
+ if (!PACKAGE_NAME.test(name)) {
54
+ return 'the name may only contain lower-case letters, digits, "-", ".", "_" and "~" (and an optional @scope/)'
55
+ }
56
+ return undefined
57
+ }
58
+
59
+ /** Turns any string into a valid package name candidate: `My App!` → `my-app`. */
60
+ export function toPackageName(name: string): string {
61
+ return name
62
+ .trim()
63
+ .toLowerCase()
64
+ .replace(/\s+/g, '-')
65
+ .replace(/^[._]+/, '')
66
+ .replace(/[^a-z0-9@/._~-]+/g, '-')
67
+ .replace(/-+/g, '-')
68
+ .replace(/^-|-$/g, '')
69
+ }
70
+
71
+ /** The unscoped part of a package name: `@acme/api` → `api`. */
72
+ export function unscopedName(name: string): string {
73
+ const slash = name.indexOf('/')
74
+ return name.startsWith('@') && slash !== -1 ? name.slice(slash + 1) : name
75
+ }
76
+
77
+ /** Levenshtein distance (small strings only). */
78
+ export function editDistance(a: string, b: string): number {
79
+ const previous = Array.from({ length: b.length + 1 }, (_, index) => index)
80
+ for (let i = 1; i <= a.length; i++) {
81
+ let diagonal = previous[0] ?? 0
82
+ previous[0] = i
83
+ for (let j = 1; j <= b.length; j++) {
84
+ const above = previous[j] ?? 0
85
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1
86
+ previous[j] = Math.min(above + 1, (previous[j - 1] ?? 0) + 1, diagonal + cost)
87
+ diagonal = above
88
+ }
89
+ }
90
+ return previous[b.length] ?? 0
91
+ }
92
+
93
+ /** The closest candidate within a distance of `max(2, length / 3)`, if any. */
94
+ export function suggest(input: string, candidates: Iterable<string>): string | undefined {
95
+ let best: string | undefined
96
+ let bestDistance = Number.POSITIVE_INFINITY
97
+ const limit = Math.max(2, Math.floor(input.length / 3))
98
+ for (const candidate of candidates) {
99
+ const distance = editDistance(input.toLowerCase(), candidate.toLowerCase())
100
+ if (distance < bestDistance && distance <= limit) {
101
+ best = candidate
102
+ bestDistance = distance
103
+ }
104
+ }
105
+ return best
106
+ }
package/src/output.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Terminal output: color detection (NO_COLOR, FORCE_COLOR, `--color` / `--no-color`, TTY), a small
3
+ * reporter writing consistent status lines, and formatting helpers.
4
+ */
5
+ import { stripVTControlCharacters } from 'node:util'
6
+ import picocolors from 'picocolors'
7
+
8
+ /** picocolors formatters (`colors.red('x')`...). */
9
+ export type Colors = ReturnType<typeof picocolors.createColors>
10
+
11
+ /** A writable text stream (`process.stdout`, or a test double). */
12
+ export interface OutputStream {
13
+ write(chunk: string): unknown
14
+ readonly isTTY?: boolean | undefined
15
+ readonly columns?: number | undefined
16
+ }
17
+
18
+ /** Inputs of {@link shouldUseColor}. */
19
+ export interface ColorInputs {
20
+ readonly env: Readonly<Record<string, string | undefined>>
21
+ readonly stream: OutputStream
22
+ /** `true` for `--color`, `false` for `--no-color`, `undefined` when neither was passed. */
23
+ readonly flag?: boolean | undefined
24
+ }
25
+
26
+ /**
27
+ * Whether colors should be written: `--no-color` and a non-empty `NO_COLOR` disable them,
28
+ * `--color` and `FORCE_COLOR` (other than `0`/`false`) force them, otherwise a TTY that is not
29
+ * `TERM=dumb` gets colors.
30
+ */
31
+ export function shouldUseColor({ env, stream, flag }: ColorInputs): boolean {
32
+ if (flag !== undefined) return flag
33
+ if (env['NO_COLOR'] !== undefined && env['NO_COLOR'] !== '') return false
34
+ const force = env['FORCE_COLOR']
35
+ if (force !== undefined) return force !== '0' && force !== 'false'
36
+ return stream.isTTY === true && env['TERM'] !== 'dumb'
37
+ }
38
+
39
+ /** picocolors formatters, enabled or not. */
40
+ export function createColors(enabled: boolean): Colors {
41
+ return picocolors.createColors(enabled)
42
+ }
43
+
44
+ /** Removes ANSI escape sequences. */
45
+ export function stripAnsi(text: string): string {
46
+ return stripVTControlCharacters(text)
47
+ }
48
+
49
+ /** Writes consistent status lines. Errors and warnings go to stderr, everything else to stdout. */
50
+ export interface Reporter {
51
+ readonly colors: Colors
52
+ /** A raw line on stdout. */
53
+ log(message?: string): void
54
+ /** `› message` */
55
+ info(message: string): void
56
+ /** `✓ message` */
57
+ success(message: string): void
58
+ /** `! message` (stderr) */
59
+ warn(message: string, hints?: readonly string[]): void
60
+ /** `✗ message` (stderr) */
61
+ error(message: string, hints?: readonly string[]): void
62
+ /** A raw line on stderr. */
63
+ logError(message?: string): void
64
+ }
65
+
66
+ /** Creates a {@link Reporter} writing to the given streams. */
67
+ export function createReporter(stdout: OutputStream, stderr: OutputStream, colors: Colors): Reporter {
68
+ const line = (stream: OutputStream, text: string): void => {
69
+ stream.write(`${text}\n`)
70
+ }
71
+ const withHints = (stream: OutputStream, hints: readonly string[] | undefined): void => {
72
+ for (const hint of hints ?? []) line(stream, ` ${colors.dim('=')} ${colors.bold('hint:')} ${hint}`)
73
+ }
74
+ return {
75
+ colors,
76
+ log: (message = '') => line(stdout, message),
77
+ info: message => line(stdout, `${colors.cyan('›')} ${message}`),
78
+ success: message => line(stdout, `${colors.green('✓')} ${message}`),
79
+ warn: (message, hints) => {
80
+ line(stderr, `${colors.yellow('!')} ${message}`)
81
+ withHints(stderr, hints)
82
+ },
83
+ error: (message, hints) => {
84
+ line(stderr, `${colors.red('✗')} ${colors.red(message)}`)
85
+ withHints(stderr, hints)
86
+ },
87
+ logError: (message = '') => line(stderr, message),
88
+ }
89
+ }
90
+
91
+ /** `850ms`, `1.24s`, `2m 05s`. */
92
+ export function formatDuration(ms: number): string {
93
+ if (ms < 1000) return `${Math.round(ms)}ms`
94
+ if (ms < 60_000) return `${(ms / 1000).toFixed(2)}s`
95
+ const minutes = Math.floor(ms / 60_000)
96
+ const seconds = Math.round((ms % 60_000) / 1000)
97
+ return `${minutes}m ${String(seconds).padStart(2, '0')}s`
98
+ }
99
+
100
+ /** `512 B`, `1.5 kB`, `2.31 MB` (SI units, like Vite). */
101
+ export function formatBytes(bytes: number): string {
102
+ if (bytes < 1000) return `${bytes} B`
103
+ if (bytes < 1_000_000) return `${(bytes / 1000).toFixed(2)} kB`
104
+ return `${(bytes / 1_000_000).toFixed(2)} MB`
105
+ }
106
+
107
+ /** `1 provider`, `3 providers`. */
108
+ export function plural(count: number, singular: string, pluralForm = `${singular}s`): string {
109
+ return `${count} ${count === 1 ? singular : pluralForm}`
110
+ }