@open-mercato/shared 0.9.1-develop.7476.1.342138d35e → 0.9.1-develop.7496.1.3346415f94

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 (30) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/lib/bootstrap/dynamicLoader.js +46 -6
  3. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  4. package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js +6 -0
  5. package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js.map +7 -0
  6. package/dist/lib/bootstrap/next-subpath-resolve-hook.js +62 -0
  7. package/dist/lib/bootstrap/next-subpath-resolve-hook.js.map +7 -0
  8. package/dist/lib/crud/ids.js +13 -1
  9. package/dist/lib/crud/ids.js.map +2 -2
  10. package/dist/lib/openapi/generator.js +84 -67
  11. package/dist/lib/openapi/generator.js.map +2 -2
  12. package/dist/lib/version.js +1 -1
  13. package/dist/lib/version.js.map +1 -1
  14. package/dist/modules/registry.js.map +2 -2
  15. package/package.json +2 -2
  16. package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +2 -2
  17. package/src/lib/bootstrap/__tests__/dynamicLoader.appModuleOverrides.test.ts +2 -2
  18. package/src/lib/bootstrap/__tests__/dynamicLoader.cacheRecovery.test.ts +2 -2
  19. package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +2 -2
  20. package/src/lib/bootstrap/__tests__/dynamicLoader.externalSubpath.test.ts +141 -0
  21. package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +2 -2
  22. package/src/lib/bootstrap/__tests__/next-subpath-resolve-hook.test.ts +174 -0
  23. package/src/lib/bootstrap/dynamicLoader.ts +63 -5
  24. package/src/lib/bootstrap/next-subpath-resolve-hook-worker.ts +3 -0
  25. package/src/lib/bootstrap/next-subpath-resolve-hook.ts +130 -0
  26. package/src/lib/crud/__tests__/ids.test.ts +34 -1
  27. package/src/lib/crud/ids.ts +24 -0
  28. package/src/lib/openapi/__tests__/optional-path-params.test.ts +102 -0
  29. package/src/lib/openapi/generator.ts +106 -77
  30. package/src/modules/registry.ts +5 -0
@@ -1,4 +1,11 @@
1
- import { MAX_IDS_PER_REQUEST, mergeIdFilter, parseIdsParam, isIdsParamProvided } from '@open-mercato/shared/lib/crud/ids'
1
+ import {
2
+ MAX_IDS_PER_REQUEST,
3
+ mergeIdFilter,
4
+ parseIdsParam,
5
+ isIdsParamProvided,
6
+ isUuidListParam,
7
+ splitIdListParam,
8
+ } from '@open-mercato/shared/lib/crud/ids'
2
9
 
3
10
  describe('crud ids helpers', () => {
4
11
  const idA = '550e8400-e29b-41d4-a716-446655440001'
@@ -168,4 +175,30 @@ describe('crud ids helpers', () => {
168
175
  tenantId: 't1',
169
176
  })
170
177
  })
178
+
179
+ it('splitIdListParam trims entries and drops empty ones', () => {
180
+ expect(splitIdListParam(` ${idA} ,, ${idB} ,`)).toEqual([idA, idB])
181
+ expect(splitIdListParam(undefined)).toEqual([])
182
+ expect(splitIdListParam(['not', 'a', 'string'])).toEqual([])
183
+ })
184
+
185
+ it('isUuidListParam accepts absent, empty, and all-UUID lists', () => {
186
+ expect(isUuidListParam(undefined)).toBe(true)
187
+ expect(isUuidListParam('')).toBe(true)
188
+ expect(isUuidListParam(` ${idA}, ${idB.toUpperCase()} `)).toBe(true)
189
+ expect(isUuidListParam('00000000-0000-0000-0000-000000000000')).toBe(true)
190
+ })
191
+
192
+ it('isUuidListParam rejects malformed and mixed lists', () => {
193
+ expect(isUuidListParam('not-a-uuid')).toBe(false)
194
+ expect(isUuidListParam(`${idA},not-a-uuid`)).toBe(false)
195
+ expect(isUuidListParam(`${idA}0`)).toBe(false)
196
+ expect(isUuidListParam(`{${idA}}`)).toBe(false)
197
+ })
198
+
199
+ it('isUuidListParam fails closed on non-string values', () => {
200
+ expect(isUuidListParam(['not-a-uuid'])).toBe(false)
201
+ expect(isUuidListParam([idA])).toBe(false)
202
+ expect(isUuidListParam(null)).toBe(false)
203
+ })
171
204
  })
@@ -51,6 +51,30 @@ export function parseIdsParam(raw: unknown, maxIds: number = MAX_IDS_PER_REQUEST
51
51
  return parsed.slice(0, safeMax)
52
52
  }
53
53
 
54
+ const UUID_SHAPE_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
55
+
56
+ /**
57
+ * Splits a comma-separated id list query param into its trimmed, non-empty entries.
58
+ */
59
+ export function splitIdListParam(raw: unknown): string[] {
60
+ if (typeof raw !== 'string') return []
61
+ return raw
62
+ .split(',')
63
+ .map((value) => value.trim())
64
+ .filter((value) => value.length > 0)
65
+ }
66
+
67
+ /**
68
+ * Whether every entry of a comma-separated id list query param is UUID-shaped, so it can be
69
+ * bound to a uuid column without a database error. Use it to reject a malformed or mixed list
70
+ * with a 400 instead of dropping the bad entries and widening the result set (#7265).
71
+ */
72
+ export function isUuidListParam(raw: unknown): boolean {
73
+ if (raw === undefined) return true
74
+ if (typeof raw !== 'string') return false
75
+ return splitIdListParam(raw).every((value) => UUID_SHAPE_REGEX.test(value))
76
+ }
77
+
54
78
  /**
55
79
  * Whether an `?ids=` param was supplied at all (a non-empty string, or repeated
56
80
  * `?ids=` occurrences), regardless of whether any value survived UUID
@@ -0,0 +1,102 @@
1
+ import { z } from 'zod'
2
+ import { buildOpenApiDocument } from '../generator'
3
+ import type { Module } from '../../../modules/registry'
4
+
5
+ type ParameterNode = { name?: string; in?: string; required?: boolean }
6
+ type OperationNode = { operationId?: string; parameters?: ParameterNode[] }
7
+
8
+ const noopHandler = async () => new Response(null)
9
+
10
+ function imageModule(extraApis: unknown[] = [], operationId?: string): Module {
11
+ return {
12
+ id: 'attachments',
13
+ apis: [
14
+ {
15
+ path: '/attachments/image/[id]/[[...slug]]',
16
+ handlers: { GET: noopHandler },
17
+ docs: {
18
+ methods: {
19
+ GET: {
20
+ summary: 'Serve image',
21
+ ...(operationId ? { operationId } : {}),
22
+ pathParams: z.object({ id: z.string().uuid() }),
23
+ responses: [{ status: 200, description: 'Image' }],
24
+ },
25
+ },
26
+ },
27
+ },
28
+ ...extraApis,
29
+ ],
30
+ } as unknown as Module
31
+ }
32
+
33
+ function operation(doc: ReturnType<typeof buildOpenApiDocument>, path: string): OperationNode | undefined {
34
+ return (doc.paths[path] as Record<string, OperationNode> | undefined)?.get
35
+ }
36
+
37
+ function collectPathParameterViolations(doc: ReturnType<typeof buildOpenApiDocument>): string[] {
38
+ const violations: string[] = []
39
+ const operationIds = new Set<string>()
40
+ for (const [path, item] of Object.entries(doc.paths)) {
41
+ const templateNames = Array.from(path.matchAll(/\{([^}]+)\}/g), (match) => match[1])
42
+ for (const [method, op] of Object.entries(item as Record<string, OperationNode>)) {
43
+ const pathParams = (op.parameters ?? []).filter((param) => param.in === 'path')
44
+ for (const param of pathParams) {
45
+ if (param.required !== true) violations.push(`${method} ${path}: path parameter ${param.name} is not required`)
46
+ if (!templateNames.includes(param.name ?? '')) violations.push(`${method} ${path}: path parameter ${param.name} is not in the template`)
47
+ }
48
+ for (const name of templateNames) {
49
+ if (!pathParams.some((param) => param.name === name)) violations.push(`${method} ${path}: template variable ${name} is not declared`)
50
+ }
51
+ if (op.operationId) {
52
+ if (operationIds.has(op.operationId)) violations.push(`${method} ${path}: duplicate operationId ${op.operationId}`)
53
+ operationIds.add(op.operationId)
54
+ }
55
+ }
56
+ }
57
+ return violations
58
+ }
59
+
60
+ describe('buildOpenApiDocument optional catch-all path parameters', () => {
61
+ it('emits id-only and id-plus-slug paths with only required path parameters', () => {
62
+ const doc = buildOpenApiDocument([imageModule()])
63
+
64
+ expect(collectPathParameterViolations(doc)).toEqual([])
65
+
66
+ const withSlug = operation(doc, '/attachments/image/{id}/{slug}')
67
+ const withoutSlug = operation(doc, '/attachments/image/{id}')
68
+ expect(withSlug?.parameters?.filter((param) => param.in === 'path').map((param) => param.name)).toEqual(['id', 'slug'])
69
+ expect(withoutSlug?.parameters?.filter((param) => param.in === 'path').map((param) => param.name)).toEqual(['id'])
70
+ })
71
+
72
+ it('keeps the published operationId on the slug path and gives the id-only path a distinct one', () => {
73
+ const doc = buildOpenApiDocument([imageModule()])
74
+
75
+ expect(operation(doc, '/attachments/image/{id}/{slug}')?.operationId).toBe('attachments_get_attachments_image_id_slug')
76
+ expect(operation(doc, '/attachments/image/{id}')?.operationId).toBe('attachments_get_attachments_image_id')
77
+ })
78
+
79
+ it('derives a distinct id-only operationId from an explicit operationId', () => {
80
+ const doc = buildOpenApiDocument([imageModule([], 'serveImage')])
81
+
82
+ expect(operation(doc, '/attachments/image/{id}/{slug}')?.operationId).toBe('serveImage')
83
+ expect(operation(doc, '/attachments/image/{id}')?.operationId).toBe('serveImage_without_slug')
84
+ })
85
+
86
+ it('never replaces a concrete route that owns the id-only path, whichever is registered first', () => {
87
+ const concreteRoute = {
88
+ path: '/attachments/image/[id]',
89
+ handlers: { GET: noopHandler },
90
+ docs: { methods: { GET: { summary: 'Concrete', operationId: 'concreteImage', responses: [{ status: 200, description: 'OK' }] } } },
91
+ }
92
+ const after = buildOpenApiDocument([imageModule([concreteRoute])])
93
+ const before = buildOpenApiDocument([
94
+ { id: 'other', apis: [concreteRoute] } as unknown as Module,
95
+ imageModule(),
96
+ ])
97
+
98
+ expect(operation(after, '/attachments/image/{id}')?.operationId).toBe('concreteImage')
99
+ expect(operation(before, '/attachments/image/{id}')?.operationId).toBe('concreteImage')
100
+ expect(collectPathParameterViolations(after)).toEqual([])
101
+ })
102
+ })
@@ -15,6 +15,12 @@ type PathParamInfo = {
15
15
  optional?: boolean
16
16
  }
17
17
 
18
+ type PathVariant = {
19
+ path: string
20
+ params: PathParamInfo[]
21
+ omittedParam?: string
22
+ }
23
+
18
24
  type ParameterLocation = 'query' | 'path' | 'header'
19
25
 
20
26
  export type JsonSchema = Record<string, unknown>
@@ -119,6 +125,20 @@ function normalizePath(path: string): { path: string; params: PathParamInfo[] }
119
125
  return { path: '/' + normalized, params }
120
126
  }
121
127
 
128
+ function expandOptionalPathParams(normalized: { path: string; params: PathParamInfo[] }): PathVariant[] {
129
+ const required = normalized.params.filter((param) => !param.optional)
130
+ const optionalParam = normalized.params.find((param) => param.optional)
131
+ const fullVariant: PathVariant = {
132
+ path: normalized.path,
133
+ params: normalized.params.map((param) => ({ ...param, optional: false })),
134
+ }
135
+ if (!optionalParam) return [fullVariant]
136
+ const placeholder = `/{${optionalParam.name}}`
137
+ if (!normalized.path.endsWith(placeholder)) return [fullVariant]
138
+ const basePath = normalized.path.slice(0, -placeholder.length) || '/'
139
+ return [fullVariant, { path: basePath, params: required, omittedParam: optionalParam.name }]
140
+ }
141
+
122
142
  function unwrap(schema?: ZodTypeAny): {
123
143
  schema: ZodTypeAny | undefined
124
144
  optional: boolean
@@ -681,14 +701,14 @@ function buildParameters(
681
701
 
682
702
  if (location === 'path' && pathParamNames && pathParamNames.length) {
683
703
  const merged = mergePathParamSchemas(schema, pathParamNames)
684
- for (const { name, schema: paramSchema, optional } of merged) {
704
+ for (const { name, schema: paramSchema } of merged) {
685
705
  const jsonSchema = zodToJsonSchema(paramSchema)
686
706
  const example = generateExample(paramSchema)
687
707
  const description = extractZodDescription(paramSchema)
688
708
  params.push({
689
709
  name,
690
710
  in: 'path',
691
- required: !optional,
711
+ required: true,
692
712
  schema: jsonSchema ?? { type: 'string' },
693
713
  example,
694
714
  ...(description ? { description } : {}),
@@ -738,7 +758,7 @@ function buildParameters(
738
758
  }
739
759
 
740
760
  function mergePathParamSchemas(schema: ZodTypeAny | undefined, params: PathParamInfo[]) {
741
- const merged: Array<{ name: string; schema: ZodTypeAny | undefined; optional: boolean }> = []
761
+ const merged: Array<{ name: string; schema: ZodTypeAny | undefined }> = []
742
762
  const map: Record<string, ZodTypeAny> = {}
743
763
  if (schema) {
744
764
  const { schema: unwrapped } = unwrap(schema)
@@ -753,7 +773,6 @@ function mergePathParamSchemas(schema: ZodTypeAny | undefined, params: PathParam
753
773
  merged.push({
754
774
  name: param.name,
755
775
  schema: map[param.name],
756
- optional: !!param.optional,
757
776
  })
758
777
  }
759
778
  return merged
@@ -1051,6 +1070,7 @@ export function buildOpenApiDocument(modules: Module[], options: OpenApiDocument
1051
1070
 
1052
1071
  const includeAccessControlMetadata = options.includeAccessControlMetadata !== false
1053
1072
  const tags = new Map<string, string | undefined>()
1073
+ const derivedOperations = new Set<string>()
1054
1074
 
1055
1075
  for (const moduleEntry of modules) {
1056
1076
  const defaultTag = moduleEntry.info?.title ?? toTitle(moduleEntry.id)
@@ -1060,85 +1080,94 @@ export function buildOpenApiDocument(modules: Module[], options: OpenApiDocument
1060
1080
  for (const api of apis) {
1061
1081
  const routeDoc = collectRouteDoc(api, moduleEntry.id)
1062
1082
  const moduleTag = routeDoc?.tag ?? defaultTag
1063
- const normalized = normalizePath((api as any).path ?? (api as any).path ?? '')
1064
- const pathKey = normalized.path
1065
- if (!doc.paths[pathKey]) doc.paths[pathKey] = {}
1066
1083
  const availableMethods: HttpMethod[] =
1067
1084
  'handlers' in api
1068
1085
  ? HTTP_METHODS.filter((method) => typeof (api as ModuleApiRouteFile).handlers?.[method] === 'function')
1069
1086
  : [api.method as HttpMethod]
1070
1087
 
1071
- for (const method of availableMethods) {
1072
- const methodLower = method.toLowerCase() as Lowercase<HttpMethod>
1073
- const existing = doc.paths[pathKey][methodLower]
1074
- if (existing) continue
1075
-
1076
- const metadata = 'handlers' in api ? (api as ModuleApiRouteFile).metadata?.[method] : undefined
1077
- const methodDoc = routeDoc?.methods?.[method]
1078
- const summary = methodDoc?.summary ?? routeDoc?.summary ?? `${method} ${pathKey}`
1079
- const baseDescription = methodDoc?.description ?? routeDoc?.description
1080
- const meta = metadata && typeof metadata === 'object' ? (metadata as Record<string, unknown>) : undefined
1081
- const requireFeatures = Array.isArray(meta?.['requireFeatures'])
1082
- ? (meta!['requireFeatures'] as string[])
1083
- : undefined
1084
- const requireRoles = Array.isArray(meta?.['requireRoles'])
1085
- ? (meta!['requireRoles'] as string[])
1086
- : undefined
1087
- const requireAuth = meta?.['requireAuth'] === true
1088
- const descriptionParts: string[] = []
1089
- if (baseDescription) descriptionParts.push(baseDescription)
1090
- if (includeAccessControlMetadata && Array.isArray(requireFeatures) && requireFeatures.length) {
1091
- descriptionParts.push(`Requires features: ${requireFeatures.join(', ')}`)
1092
- }
1093
- if (includeAccessControlMetadata && Array.isArray(requireRoles) && requireRoles.length) {
1094
- descriptionParts.push(`Requires roles: ${requireRoles.join(', ')}`)
1095
- }
1088
+ for (const normalized of expandOptionalPathParams(normalizePath((api as any).path ?? ''))) {
1089
+ const pathKey = normalized.path
1090
+ if (!doc.paths[pathKey]) doc.paths[pathKey] = {}
1091
+
1092
+ for (const method of availableMethods) {
1093
+ const methodLower = method.toLowerCase() as Lowercase<HttpMethod>
1094
+ const operationKey = `${methodLower} ${pathKey}`
1095
+ const existing = doc.paths[pathKey][methodLower]
1096
+ if (existing && (normalized.omittedParam || !derivedOperations.has(operationKey))) continue
1097
+ if (normalized.omittedParam) derivedOperations.add(operationKey)
1098
+ else derivedOperations.delete(operationKey)
1099
+
1100
+ const metadata = 'handlers' in api ? (api as ModuleApiRouteFile).metadata?.[method] : undefined
1101
+ const methodDoc = routeDoc?.methods?.[method]
1102
+ const summary = methodDoc?.summary ?? routeDoc?.summary ?? `${method} ${pathKey}`
1103
+ const baseDescription = methodDoc?.description ?? routeDoc?.description
1104
+ const meta = metadata && typeof metadata === 'object' ? (metadata as Record<string, unknown>) : undefined
1105
+ const requireFeatures = Array.isArray(meta?.['requireFeatures'])
1106
+ ? (meta!['requireFeatures'] as string[])
1107
+ : undefined
1108
+ const requireRoles = Array.isArray(meta?.['requireRoles'])
1109
+ ? (meta!['requireRoles'] as string[])
1110
+ : undefined
1111
+ const requireAuth = meta?.['requireAuth'] === true
1112
+ const descriptionParts: string[] = []
1113
+ if (baseDescription) descriptionParts.push(baseDescription)
1114
+ if (includeAccessControlMetadata && Array.isArray(requireFeatures) && requireFeatures.length) {
1115
+ descriptionParts.push(`Requires features: ${requireFeatures.join(', ')}`)
1116
+ }
1117
+ if (includeAccessControlMetadata && Array.isArray(requireRoles) && requireRoles.length) {
1118
+ descriptionParts.push(`Requires roles: ${requireRoles.join(', ')}`)
1119
+ }
1096
1120
 
1097
- const querySchema = methodDoc?.query
1098
- const pathSchema = methodDoc?.pathParams ?? routeDoc?.pathParams
1099
- const headerSchema = methodDoc?.headers
1100
- const requestBody = methodDoc?.requestBody
1101
- const examples = collectExamples(querySchema, requestBody?.schema, pathSchema, headerSchema, metadata)
1102
- const curlSample = buildCurlSample(
1103
- method,
1104
- pathKey,
1105
- normalized.params,
1106
- examples,
1107
- options.baseUrlForExamples ?? 'https://api.open-mercato.local',
1108
- metadata,
1109
- requestBody
1110
- )
1111
-
1112
- doc.paths[pathKey][methodLower] = {
1113
- operationId: methodDoc?.operationId ?? resolveOperationId(moduleEntry.id, pathKey, method),
1114
- summary,
1115
- description: descriptionParts.length ? descriptionParts.join('\n\n') : undefined,
1116
- tags: methodDoc?.tags ?? (moduleTag ? [moduleTag] : undefined),
1117
- deprecated: methodDoc?.deprecated,
1118
- externalDocs: methodDoc?.externalDocs,
1119
- parameters: [
1120
- ...buildParameters(pathSchema, 'path', normalized.params),
1121
- ...buildParameters(querySchema, 'query'),
1122
- ...buildParameters(headerSchema, 'header'),
1123
- ].filter(Boolean),
1124
- requestBody: buildRequestBody(requestBody),
1125
- responses: buildResponses(method, methodDoc?.responses, methodDoc?.errors, metadata),
1126
- security: buildSecurity(metadata, methodDoc, options.defaultSecurity),
1127
- 'x-codeSamples': methodDoc?.codeSamples ?? [
1128
- {
1129
- lang: 'curl',
1130
- label: 'cURL',
1131
- source: curlSample,
1132
- },
1133
- ],
1134
- ...(includeAccessControlMetadata && Array.isArray(requireFeatures) && requireFeatures.length
1135
- ? { 'x-require-features': requireFeatures }
1136
- : {}),
1137
- ...(includeAccessControlMetadata && Array.isArray(requireRoles) && requireRoles.length
1138
- ? { 'x-require-roles': requireRoles }
1139
- : {}),
1140
- ...(requireAuth ? { 'x-require-auth': true } : {}),
1141
- ...(methodDoc?.extensions ?? {}),
1121
+ const querySchema = methodDoc?.query
1122
+ const pathSchema = methodDoc?.pathParams ?? routeDoc?.pathParams
1123
+ const headerSchema = methodDoc?.headers
1124
+ const requestBody = methodDoc?.requestBody
1125
+ const examples = collectExamples(querySchema, requestBody?.schema, pathSchema, headerSchema, metadata)
1126
+ const curlSample = buildCurlSample(
1127
+ method,
1128
+ pathKey,
1129
+ normalized.params,
1130
+ examples,
1131
+ options.baseUrlForExamples ?? 'https://api.open-mercato.local',
1132
+ metadata,
1133
+ requestBody
1134
+ )
1135
+
1136
+ doc.paths[pathKey][methodLower] = {
1137
+ operationId: methodDoc?.operationId
1138
+ ? normalized.omittedParam
1139
+ ? `${methodDoc.operationId}_without_${normalizeOperationIdSegment(normalized.omittedParam)}`
1140
+ : methodDoc.operationId
1141
+ : resolveOperationId(moduleEntry.id, pathKey, method),
1142
+ summary,
1143
+ description: descriptionParts.length ? descriptionParts.join('\n\n') : undefined,
1144
+ tags: methodDoc?.tags ?? (moduleTag ? [moduleTag] : undefined),
1145
+ deprecated: methodDoc?.deprecated,
1146
+ externalDocs: methodDoc?.externalDocs,
1147
+ parameters: [
1148
+ ...buildParameters(pathSchema, 'path', normalized.params),
1149
+ ...buildParameters(querySchema, 'query'),
1150
+ ...buildParameters(headerSchema, 'header'),
1151
+ ].filter(Boolean),
1152
+ requestBody: buildRequestBody(requestBody),
1153
+ responses: buildResponses(method, methodDoc?.responses, methodDoc?.errors, metadata),
1154
+ security: buildSecurity(metadata, methodDoc, options.defaultSecurity),
1155
+ 'x-codeSamples': methodDoc?.codeSamples ?? [
1156
+ {
1157
+ lang: 'curl',
1158
+ label: 'cURL',
1159
+ source: curlSample,
1160
+ },
1161
+ ],
1162
+ ...(includeAccessControlMetadata && Array.isArray(requireFeatures) && requireFeatures.length
1163
+ ? { 'x-require-features': requireFeatures }
1164
+ : {}),
1165
+ ...(includeAccessControlMetadata && Array.isArray(requireRoles) && requireRoles.length
1166
+ ? { 'x-require-roles': requireRoles }
1167
+ : {}),
1168
+ ...(requireAuth ? { 'x-require-auth': true } : {}),
1169
+ ...(methodDoc?.extensions ?? {}),
1170
+ }
1142
1171
  }
1143
1172
  }
1144
1173
  }
@@ -178,6 +178,11 @@ export type ApiRouteManifestEntry = {
178
178
  export type ModuleCli = {
179
179
  command: string
180
180
  run: (argv: string[]) => Promise<void> | void
181
+ // Usage text printed by the `mercato` dispatcher when the command is invoked with --help/-h.
182
+ help?: string
183
+ // Set when the command parses --help/-h itself; the dispatcher then forwards the flag
184
+ // instead of intercepting it, so the command can print its own richer usage text.
185
+ handlesHelp?: boolean
181
186
  }
182
187
 
183
188
  export type ModuleSubscriber = {