@ranger1/dx 0.1.96 → 0.1.97

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.
@@ -0,0 +1,549 @@
1
+ import { logger } from '../logger.js'
2
+ import { parseFlags } from './flags.js'
3
+
4
+ function isPlainObject(value) {
5
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
6
+ }
7
+
8
+ function assertOptionalString(value, path) {
9
+ if (value === undefined) return
10
+ if (typeof value !== 'string' || value.trim() === '') {
11
+ throw new Error(`${path} must be a non-empty string`)
12
+ }
13
+ }
14
+
15
+ function assertArrayOfObjects(value, path) {
16
+ if (value === undefined) return
17
+ if (!Array.isArray(value)) {
18
+ throw new Error(`${path} must be an array`)
19
+ }
20
+
21
+ value.forEach((entry, index) => {
22
+ if (!isPlainObject(entry)) {
23
+ throw new Error(`${path}[${index}] must be an object`)
24
+ }
25
+ })
26
+ }
27
+
28
+ function assertArray(value, path) {
29
+ if (value === undefined) return
30
+ if (!Array.isArray(value)) {
31
+ throw new Error(`${path} must be an array`)
32
+ }
33
+ }
34
+
35
+ function assertDescription(value, path) {
36
+ if (typeof value !== 'string' || value.trim() === '') {
37
+ throw new Error(`${path} must include a description`)
38
+ }
39
+ }
40
+
41
+ function assertFlagsExist(flags, knownFlags, path) {
42
+ if (!Array.isArray(flags) || flags.length === 0) {
43
+ throw new Error(`${path} must include at least one flag`)
44
+ }
45
+
46
+ flags.forEach((flag, index) => {
47
+ if (typeof flag !== 'string' || flag.trim() === '') {
48
+ throw new Error(`${path}[${index}] must be a non-empty string`)
49
+ }
50
+ if (!knownFlags.has(flag)) {
51
+ throw new Error(`${path}[${index}] references unknown flag: ${flag}`)
52
+ }
53
+ })
54
+ }
55
+
56
+ function extractCommandName(command) {
57
+ if (typeof command !== 'string' || command.trim() === '') {
58
+ return ''
59
+ }
60
+
61
+ const tokens = command.trim().split(/\s+/)
62
+
63
+ if (tokens[0] === 'dx') {
64
+ return tokens[1] ?? ''
65
+ }
66
+
67
+ return tokens[0] ?? ''
68
+ }
69
+
70
+ function assertRegisteredCommand(command, registeredCommands, path) {
71
+ const commandName = extractCommandName(command)
72
+
73
+ if (!commandName) {
74
+ throw new Error(`${path} must include a command`)
75
+ }
76
+
77
+ if (!registeredCommands.has(commandName)) {
78
+ throw new Error(`${path} references unknown command: ${commandName}`)
79
+ }
80
+ }
81
+
82
+ function validateHelpExamples(examples, path, context) {
83
+ const { registeredCommands, exampleValidator } = context
84
+
85
+ assertArrayOfObjects(examples, path)
86
+
87
+ examples?.forEach((example, index) => {
88
+ const entryPath = `${path}[${index}]`
89
+ assertOptionalString(example.command, `${entryPath}.command`)
90
+ assertDescription(example.description, `${entryPath}.description`)
91
+ assertRegisteredCommand(example.command, registeredCommands, `${entryPath}.command`)
92
+
93
+ const result = exampleValidator(example.command)
94
+ if (!result?.ok) {
95
+ throw new Error(result?.reason || `${entryPath}.command failed validation`)
96
+ }
97
+ })
98
+ }
99
+
100
+ function validateHelpOptions(options, path, knownFlags) {
101
+ assertArrayOfObjects(options, path)
102
+
103
+ options?.forEach((option, index) => {
104
+ const entryPath = `${path}[${index}]`
105
+ assertFlagsExist(option.flags, knownFlags, `${entryPath}.flags`)
106
+ assertDescription(option.description, `${entryPath}.description`)
107
+ })
108
+ }
109
+
110
+ function validateCommandHelp(commandName, help, context) {
111
+ const { registeredCommands, usageValidator } = context
112
+
113
+ if (!isPlainObject(help)) {
114
+ throw new Error(`help.commands.${commandName} must be an object`)
115
+ }
116
+
117
+ if (!registeredCommands.has(commandName)) {
118
+ throw new Error(`help.commands.${commandName} references unknown command`)
119
+ }
120
+
121
+ assertOptionalString(help.summary, `help.commands.${commandName}.summary`)
122
+ assertArray(help.notes, `help.commands.${commandName}.notes`)
123
+
124
+ help.notes?.forEach((note, index) => {
125
+ assertOptionalString(note, `help.commands.${commandName}.notes[${index}]`)
126
+ })
127
+
128
+ validateHelpOptions(
129
+ help.options,
130
+ `help.commands.${commandName}.options`,
131
+ context.knownFlags,
132
+ )
133
+ validateHelpExamples(help.examples, `help.commands.${commandName}.examples`, context)
134
+
135
+ if (help.usage !== undefined) {
136
+ assertOptionalString(help.usage, `help.commands.${commandName}.usage`)
137
+ const result = usageValidator(commandName, help.usage)
138
+
139
+ if (!result?.ok) {
140
+ throw new Error(result?.reason || `help.commands.${commandName}.usage failed validation`)
141
+ }
142
+ }
143
+ }
144
+
145
+ function validateTargetHelp(commandName, targetName, help, commands, context) {
146
+ const entryPath = `help.targets.${commandName}.${targetName}`
147
+
148
+ if (!isPlainObject(help)) {
149
+ throw new Error(`${entryPath} must be an object`)
150
+ }
151
+
152
+ if (!context.registeredCommands.has(commandName) || !isPlainObject(commands?.[commandName])) {
153
+ throw new Error(`help.targets.${commandName} references unknown command`)
154
+ }
155
+
156
+ if (!Object.prototype.hasOwnProperty.call(commands[commandName], targetName)) {
157
+ throw new Error(`${entryPath} references unknown target`)
158
+ }
159
+
160
+ assertOptionalString(help.summary, `${entryPath}.summary`)
161
+ assertArray(help.notes, `${entryPath}.notes`)
162
+
163
+ help.notes?.forEach((note, index) => {
164
+ assertOptionalString(note, `${entryPath}.notes[${index}]`)
165
+ })
166
+
167
+ validateHelpOptions(help.options, `${entryPath}.options`, context.knownFlags)
168
+ validateHelpExamples(help.examples, `${entryPath}.examples`, context)
169
+ }
170
+
171
+ export function validateHelpConfig(commands, context = {}) {
172
+ const {
173
+ registeredCommands = [],
174
+ knownFlags = new Map(),
175
+ usageValidator = () => ({ ok: true }),
176
+ exampleValidator = () => ({ ok: true }),
177
+ } = context
178
+
179
+ if (!isPlainObject(commands)) {
180
+ throw new Error('commands must be an object')
181
+ }
182
+
183
+ const normalizedContext = {
184
+ registeredCommands: new Set(registeredCommands),
185
+ knownFlags,
186
+ usageValidator,
187
+ exampleValidator,
188
+ }
189
+
190
+ const help = commands.help
191
+ if (help === undefined) {
192
+ return commands
193
+ }
194
+
195
+ if (!isPlainObject(help)) {
196
+ throw new Error('help must be an object')
197
+ }
198
+
199
+ assertOptionalString(help.summary, 'help.summary')
200
+ validateHelpOptions(help.globalOptions, 'help.globalOptions', normalizedContext.knownFlags)
201
+ validateHelpExamples(help.examples, 'help.examples', normalizedContext)
202
+
203
+ if (help.commands !== undefined) {
204
+ if (!isPlainObject(help.commands)) {
205
+ throw new Error('help.commands must be an object')
206
+ }
207
+
208
+ Object.entries(help.commands).forEach(([commandName, commandHelp]) => {
209
+ validateCommandHelp(commandName, commandHelp, normalizedContext)
210
+ })
211
+ }
212
+
213
+ if (help.targets !== undefined) {
214
+ if (!isPlainObject(help.targets)) {
215
+ throw new Error('help.targets must be an object')
216
+ }
217
+
218
+ Object.entries(help.targets).forEach(([commandName, targetHelp]) => {
219
+ if (!isPlainObject(targetHelp)) {
220
+ throw new Error(`help.targets.${commandName} must be an object`)
221
+ }
222
+
223
+ Object.entries(targetHelp).forEach(([targetName, targetEntryHelp]) => {
224
+ validateTargetHelp(commandName, targetName, targetEntryHelp, commands, normalizedContext)
225
+ })
226
+ })
227
+ }
228
+
229
+ return commands
230
+ }
231
+
232
+ export function buildStrictHelpValidationContext(cli) {
233
+ const knownFlags = new Map()
234
+ for (const definitions of Object.values(cli?.flagDefinitions || {})) {
235
+ if (!Array.isArray(definitions)) continue
236
+ for (const definition of definitions) {
237
+ if (!definition?.flag) continue
238
+ knownFlags.set(definition.flag, definition)
239
+ }
240
+ }
241
+
242
+ return {
243
+ registeredCommands: Object.keys(cli?.commandHandlers || {}),
244
+ knownFlags,
245
+ usageValidator: (commandName, usageText) =>
246
+ validateUsageAgainstRuntime(commandName, usageText, cli),
247
+ exampleValidator: commandText => validateExampleCommandAgainstCli(commandText, cli),
248
+ }
249
+ }
250
+
251
+ export function validateExampleCommandAgainstCli(commandText, cli) {
252
+ let tokens
253
+
254
+ try {
255
+ tokens = shellLikeSplit(commandText)
256
+ } catch (error) {
257
+ return { ok: false, reason: error.message }
258
+ }
259
+
260
+ if (tokens[0] !== cli.invocation) {
261
+ return { ok: false, reason: `example must start with ${cli.invocation}` }
262
+ }
263
+
264
+ const commandName = tokens[1]
265
+ if (!commandName) {
266
+ return { ok: false, reason: 'example must include a top-level command' }
267
+ }
268
+
269
+ if (!cli.commandHandlers?.[commandName]) {
270
+ return { ok: false, reason: `unknown command: ${commandName}` }
271
+ }
272
+
273
+ return runCliInputValidation(cli, tokens.slice(1))
274
+ }
275
+
276
+ export function validateUsageAgainstRuntime(commandName, usageText, cli) {
277
+ let tokens
278
+
279
+ try {
280
+ tokens = shellLikeSplit(usageText)
281
+ } catch (error) {
282
+ return { ok: false, reason: error.message }
283
+ }
284
+
285
+ if (tokens[0] !== cli.invocation) {
286
+ return { ok: false, reason: `usage must start with ${cli.invocation}` }
287
+ }
288
+
289
+ if (tokens[1] !== commandName) {
290
+ return {
291
+ ok: false,
292
+ reason: `usage for ${commandName} must start with "${cli.invocation} ${commandName}"`,
293
+ }
294
+ }
295
+
296
+ const placeholderTokens = tokens
297
+ .slice(2)
298
+ .filter(token => isPlaceholderToken(token) && !isEnvironmentPlaceholder(token))
299
+ const runtimeMaxPositionals = getRuntimeMaxPositionals(commandName)
300
+
301
+ if (
302
+ Number.isInteger(runtimeMaxPositionals) &&
303
+ placeholderTokens.length > runtimeMaxPositionals
304
+ ) {
305
+ return {
306
+ ok: false,
307
+ reason: `usage for ${commandName} advertises ${placeholderTokens.length} positionals but runtime allows ${runtimeMaxPositionals}`,
308
+ }
309
+ }
310
+
311
+ if (mentionsPositionalEnvironmentPlaceholder(tokens)) {
312
+ return {
313
+ ok: false,
314
+ reason: `usage for ${commandName} must use environment flags instead of positional env placeholders`,
315
+ }
316
+ }
317
+
318
+ if (commandName === 'start' && !tokens.includes('<service>')) {
319
+ return { ok: false, reason: 'usage for start must include <service>' }
320
+ }
321
+
322
+ return { ok: true }
323
+ }
324
+
325
+ function getRuntimeMaxPositionals(commandName) {
326
+ switch (commandName) {
327
+ case 'help':
328
+ return 1
329
+ case 'build':
330
+ case 'package':
331
+ case 'clean':
332
+ case 'cache':
333
+ return 1
334
+ case 'db':
335
+ return 2
336
+ case 'test':
337
+ return 3
338
+ case 'worktree':
339
+ return 3
340
+ case 'start':
341
+ return 1
342
+ case 'lint':
343
+ case 'status':
344
+ return 0
345
+ default:
346
+ return null
347
+ }
348
+ }
349
+
350
+ function runCliInputValidation(cli, args) {
351
+ if (typeof cli?.validateInputs !== 'function') {
352
+ return validateArgumentTokens(args, cli)
353
+ }
354
+
355
+ const exitSpy = process.exit
356
+ const originalLogger = {
357
+ error: logger.error,
358
+ info: logger.info,
359
+ }
360
+ const messages = []
361
+ const originalState = {
362
+ args: cli.args,
363
+ command: cli.command,
364
+ flags: cli.flags,
365
+ subcommand: cli.subcommand,
366
+ }
367
+
368
+ try {
369
+ process.exit = code => {
370
+ throw new Error(`process.exit:${code}`)
371
+ }
372
+ logger.error = message => {
373
+ messages.push(String(message))
374
+ }
375
+ logger.info = message => {
376
+ messages.push(String(message))
377
+ }
378
+
379
+ cli.args = [...args]
380
+ cli.flags = parseFlags(cli.args)
381
+ cli.command = cli.args[0]
382
+ cli.subcommand = cli.args[1]
383
+ cli.validateInputs()
384
+ return { ok: true }
385
+ } catch (error) {
386
+ if (String(error?.message || '').startsWith('process.exit:')) {
387
+ return { ok: false, reason: messages.join(' | ') || error.message }
388
+ }
389
+ throw error
390
+ } finally {
391
+ process.exit = exitSpy
392
+ logger.error = originalLogger.error
393
+ logger.info = originalLogger.info
394
+ cli.args = originalState.args
395
+ cli.command = originalState.command
396
+ cli.flags = originalState.flags
397
+ cli.subcommand = originalState.subcommand
398
+ }
399
+ }
400
+
401
+ function validateArgumentTokens(args, cli) {
402
+ const tokens = Array.isArray(args) ? [...args] : []
403
+ const commandName = tokens[0]
404
+ if (!commandName) {
405
+ return { ok: false, reason: 'example must include a top-level command' }
406
+ }
407
+
408
+ const definitions = cli?.flagDefinitions || {}
409
+ const knownFlags = new Map()
410
+ for (const entries of Object.values(definitions)) {
411
+ if (!Array.isArray(entries)) continue
412
+ for (const entry of entries) {
413
+ if (!entry?.flag) continue
414
+ knownFlags.set(entry.flag, Boolean(entry.expectsValue))
415
+ }
416
+ }
417
+
418
+ const positionalArgs = []
419
+ for (let index = 1; index < tokens.length; index += 1) {
420
+ const token = tokens[index]
421
+ if (token === '--') break
422
+ if (!token.startsWith('-')) {
423
+ positionalArgs.push(token)
424
+ continue
425
+ }
426
+
427
+ if (!knownFlags.has(token)) {
428
+ return { ok: false, reason: `检测到未识别的选项: ${token}` }
429
+ }
430
+
431
+ if (knownFlags.get(token)) {
432
+ const next = tokens[index + 1]
433
+ if (next === undefined || next.startsWith('-')) {
434
+ return { ok: false, reason: `选项 ${token} 需要提供参数值` }
435
+ }
436
+ index += 1
437
+ }
438
+ }
439
+
440
+ const maxByCommand = {
441
+ help: 1,
442
+ build: 1,
443
+ package: 1,
444
+ db: 2,
445
+ test: 3,
446
+ worktree: 3,
447
+ start: 1,
448
+ lint: 0,
449
+ clean: 1,
450
+ cache: 1,
451
+ status: 0,
452
+ }
453
+
454
+ const max = maxByCommand[commandName]
455
+ if (Number.isInteger(max) && positionalArgs.length > max) {
456
+ return { ok: false, reason: `命令 ${commandName} 存在未识别的额外参数: ${positionalArgs.slice(max).join(', ')}` }
457
+ }
458
+
459
+ return { ok: true }
460
+ }
461
+
462
+ function isPlaceholderToken(token) {
463
+ return (
464
+ (token.startsWith('<') && token.endsWith('>')) ||
465
+ (token.startsWith('[') && token.endsWith(']'))
466
+ )
467
+ }
468
+
469
+ function isEnvironmentPlaceholder(token) {
470
+ return token === '[环境标志]'
471
+ }
472
+
473
+ function mentionsPositionalEnvironmentPlaceholder(tokens) {
474
+ return tokens.some(token => {
475
+ const normalized = token.replace(/^[<[|]+|[\]>|]+$/g, '').toLowerCase()
476
+ return normalized === 'env' || normalized === 'environment'
477
+ })
478
+ }
479
+
480
+ export function shellLikeSplit(text) {
481
+ const tokens = []
482
+ let current = ''
483
+ let quote = null
484
+ let tokenStarted = false
485
+
486
+ for (let index = 0; index < text.length; index += 1) {
487
+ const character = text[index]
488
+
489
+ if (quote) {
490
+ if (character === '\\') {
491
+ const next = text[index + 1]
492
+ if (next === quote || next === '\\') {
493
+ current += next
494
+ tokenStarted = true
495
+ index += 1
496
+ continue
497
+ }
498
+ }
499
+
500
+ if (character === quote) {
501
+ quote = null
502
+ continue
503
+ }
504
+
505
+ current += character
506
+ tokenStarted = true
507
+ continue
508
+ }
509
+
510
+ if (character === '"' || character === "'") {
511
+ quote = character
512
+ tokenStarted = true
513
+ continue
514
+ }
515
+
516
+ if (character === '\\') {
517
+ const next = text[index + 1]
518
+ if (next !== undefined) {
519
+ current += next
520
+ tokenStarted = true
521
+ index += 1
522
+ continue
523
+ }
524
+ }
525
+
526
+ if (/\s/.test(character)) {
527
+ if (tokenStarted) {
528
+ tokens.push(current)
529
+ current = ''
530
+ tokenStarted = false
531
+ }
532
+ continue
533
+ }
534
+
535
+ current += character
536
+ tokenStarted = true
537
+ }
538
+
539
+ if (quote) {
540
+ const quoteName = quote === '"' ? 'double' : 'single'
541
+ throw new Error(`Unclosed ${quoteName} quote in "${text}"`)
542
+ }
543
+
544
+ if (tokenStarted) {
545
+ tokens.push(current)
546
+ }
547
+
548
+ return tokens
549
+ }