ts-procedures 8.3.0 → 8.5.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 (126) hide show
  1. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +26 -8
  2. package/agent_config/claude-code/skills/ts-procedures/templates/client.md +3 -3
  3. package/agent_config/claude-code/skills/ts-procedures/templates/hono.md +3 -3
  4. package/agent_config/claude-code/skills/ts-procedures/templates/procedure.md +3 -3
  5. package/agent_config/claude-code/skills/ts-procedures/templates/stream-procedure.md +3 -3
  6. package/build/client/call.js +1 -1
  7. package/build/client/call.js.map +1 -1
  8. package/build/client/index.d.ts +1 -1
  9. package/build/client/index.js +23 -1
  10. package/build/client/index.js.map +1 -1
  11. package/build/client/index.test.js +87 -0
  12. package/build/client/index.test.js.map +1 -1
  13. package/build/client/resolve-options.d.ts +5 -4
  14. package/build/client/resolve-options.js +18 -7
  15. package/build/client/resolve-options.js.map +1 -1
  16. package/build/client/resolve-options.test.js +53 -24
  17. package/build/client/resolve-options.test.js.map +1 -1
  18. package/build/client/stream.js +1 -1
  19. package/build/client/stream.js.map +1 -1
  20. package/build/client/types.d.ts +31 -3
  21. package/build/codegen/__fixtures__/make-envelope.d.ts +41 -0
  22. package/build/codegen/__fixtures__/make-envelope.js +38 -0
  23. package/build/codegen/__fixtures__/make-envelope.js.map +1 -0
  24. package/build/codegen/bin/cli.d.ts +15 -0
  25. package/build/codegen/bin/cli.js +46 -21
  26. package/build/codegen/bin/cli.js.map +1 -1
  27. package/build/codegen/bin/cli.test.js +54 -1
  28. package/build/codegen/bin/cli.test.js.map +1 -1
  29. package/build/codegen/bin/flag-specs.d.ts +10 -0
  30. package/build/codegen/bin/flag-specs.js +62 -0
  31. package/build/codegen/bin/flag-specs.js.map +1 -0
  32. package/build/codegen/bin/flag-specs.test.d.ts +1 -0
  33. package/build/codegen/bin/flag-specs.test.js +35 -0
  34. package/build/codegen/bin/flag-specs.test.js.map +1 -0
  35. package/build/codegen/collect-models.d.ts +48 -0
  36. package/build/codegen/collect-models.js +84 -0
  37. package/build/codegen/collect-models.js.map +1 -0
  38. package/build/codegen/collect-models.test.d.ts +1 -0
  39. package/build/codegen/collect-models.test.js +59 -0
  40. package/build/codegen/collect-models.test.js.map +1 -0
  41. package/build/codegen/emit-client-runtime.js +1 -0
  42. package/build/codegen/emit-client-runtime.js.map +1 -1
  43. package/build/codegen/emit-models.d.ts +26 -0
  44. package/build/codegen/emit-models.js +53 -0
  45. package/build/codegen/emit-models.js.map +1 -0
  46. package/build/codegen/emit-models.test.d.ts +1 -0
  47. package/build/codegen/emit-models.test.js +42 -0
  48. package/build/codegen/emit-models.test.js.map +1 -0
  49. package/build/codegen/emit-scope.d.ts +10 -0
  50. package/build/codegen/emit-scope.js +119 -34
  51. package/build/codegen/emit-scope.js.map +1 -1
  52. package/build/codegen/emit-types.d.ts +26 -1
  53. package/build/codegen/emit-types.js +27 -5
  54. package/build/codegen/emit-types.js.map +1 -1
  55. package/build/codegen/index.d.ts +15 -0
  56. package/build/codegen/index.js +5 -0
  57. package/build/codegen/index.js.map +1 -1
  58. package/build/codegen/model-refs.d.ts +27 -0
  59. package/build/codegen/model-refs.js +49 -0
  60. package/build/codegen/model-refs.js.map +1 -0
  61. package/build/codegen/model-refs.test.d.ts +1 -0
  62. package/build/codegen/model-refs.test.js +33 -0
  63. package/build/codegen/model-refs.test.js.map +1 -0
  64. package/build/codegen/pipeline.d.ts +7 -0
  65. package/build/codegen/pipeline.js +6 -1
  66. package/build/codegen/pipeline.js.map +1 -1
  67. package/build/codegen/schema-walk.d.ts +13 -0
  68. package/build/codegen/schema-walk.js +26 -0
  69. package/build/codegen/schema-walk.js.map +1 -0
  70. package/build/codegen/schema-walk.test.d.ts +1 -0
  71. package/build/codegen/schema-walk.test.js +35 -0
  72. package/build/codegen/schema-walk.test.js.map +1 -0
  73. package/build/codegen/targets/_shared/target-run.d.ts +15 -0
  74. package/build/codegen/targets/ts/run.js +37 -1
  75. package/build/codegen/targets/ts/run.js.map +1 -1
  76. package/build/codegen/targets/ts/shared-models.test.d.ts +1 -0
  77. package/build/codegen/targets/ts/shared-models.test.js +354 -0
  78. package/build/codegen/targets/ts/shared-models.test.js.map +1 -0
  79. package/build/doc-envelope.d.ts +13 -0
  80. package/build/doc-envelope.js +23 -0
  81. package/build/doc-envelope.js.map +1 -0
  82. package/build/doc-envelope.test.d.ts +1 -0
  83. package/build/doc-envelope.test.js +31 -0
  84. package/build/doc-envelope.test.js.map +1 -0
  85. package/build/exports.d.ts +2 -0
  86. package/build/exports.js +1 -0
  87. package/build/exports.js.map +1 -1
  88. package/docs/client-and-codegen.md +163 -0
  89. package/docs/handoffs/ajsc-named-type-collision.md +134 -0
  90. package/docs/handoffs/ajsc-named-type-support.md +181 -0
  91. package/docs/handoffs/shared-models-auto-resolve-response.md +181 -0
  92. package/docs/superpowers/plans/2026-06-05-dx-feedback-round.md +1292 -0
  93. package/docs/superpowers/plans/2026-06-06-shared-models-convention-and-diagnostics.md +659 -0
  94. package/docs/superpowers/specs/2026-06-05-dx-feedback-round-design.md +285 -0
  95. package/package.json +2 -2
  96. package/src/client/call.ts +1 -1
  97. package/src/client/index.test.ts +98 -0
  98. package/src/client/index.ts +32 -1
  99. package/src/client/resolve-options.test.ts +73 -26
  100. package/src/client/resolve-options.ts +23 -9
  101. package/src/client/stream.ts +1 -1
  102. package/src/client/types.ts +34 -3
  103. package/src/codegen/__fixtures__/make-envelope.ts +89 -0
  104. package/src/codegen/bin/cli.test.ts +65 -1
  105. package/src/codegen/bin/cli.ts +51 -22
  106. package/src/codegen/bin/flag-specs.test.ts +38 -0
  107. package/src/codegen/bin/flag-specs.ts +71 -0
  108. package/src/codegen/collect-models.test.ts +68 -0
  109. package/src/codegen/collect-models.ts +125 -0
  110. package/src/codegen/emit-client-runtime.ts +1 -0
  111. package/src/codegen/emit-models.test.ts +48 -0
  112. package/src/codegen/emit-models.ts +63 -0
  113. package/src/codegen/emit-scope.ts +145 -33
  114. package/src/codegen/emit-types.ts +48 -7
  115. package/src/codegen/index.ts +20 -0
  116. package/src/codegen/model-refs.test.ts +37 -0
  117. package/src/codegen/model-refs.ts +57 -0
  118. package/src/codegen/pipeline.ts +13 -1
  119. package/src/codegen/schema-walk.test.ts +37 -0
  120. package/src/codegen/schema-walk.ts +23 -0
  121. package/src/codegen/targets/_shared/target-run.ts +15 -0
  122. package/src/codegen/targets/ts/run.ts +50 -0
  123. package/src/codegen/targets/ts/shared-models.test.ts +391 -0
  124. package/src/doc-envelope.test.ts +35 -0
  125. package/src/doc-envelope.ts +30 -0
  126. package/src/exports.ts +2 -0
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  AdapterRequest,
3
+ ClientHeadersInit,
3
4
  ProcedureCallDefaults,
4
5
  ProcedureCallOptions,
5
6
  RequestMeta,
@@ -79,15 +80,28 @@ export function resolveSignal(
79
80
  }
80
81
 
81
82
  /**
82
- * Merges headers with precedence: default < per-call. Returns undefined if
83
- * no headers would be set.
83
+ * Resolves a `ClientHeadersInit` value: a static record passes through, a
84
+ * function is invoked and (if async) awaited — re-evaluated on every call so
85
+ * values like a rotating bearer token never go stale.
84
86
  */
85
- export function resolveHeaders(
87
+ async function resolveHeadersValue(
88
+ h: ClientHeadersInit | undefined,
89
+ ): Promise<Record<string, string> | undefined> {
90
+ if (h == null) return undefined
91
+ return typeof h === 'function' ? await h() : h
92
+ }
93
+
94
+ /**
95
+ * Merges headers with precedence: default < per-call. Function-valued headers
96
+ * are evaluated (and awaited) per call. Returns undefined if no headers would
97
+ * be set.
98
+ */
99
+ export async function resolveHeaders(
86
100
  defaults: ProcedureCallDefaults | undefined,
87
101
  options: ProcedureCallOptions | undefined,
88
- ): Record<string, string> | undefined {
89
- const defaultHeaders = defaults?.headers
90
- const callHeaders = options?.headers
102
+ ): Promise<Record<string, string> | undefined> {
103
+ const defaultHeaders = await resolveHeadersValue(defaults?.headers)
104
+ const callHeaders = await resolveHeadersValue(options?.headers)
91
105
 
92
106
  if (!defaultHeaders && !callHeaders) return undefined
93
107
 
@@ -137,13 +151,13 @@ export interface ApplyRequestOptionsResult {
137
151
  * the error classifier in Task 6) can distinguish timeout from user abort without
138
152
  * losing provenance after `AbortSignal.any` collapses the originals.
139
153
  */
140
- export function applyRequestOptions(
154
+ export async function applyRequestOptions(
141
155
  request: AdapterRequest,
142
156
  defaults: ProcedureCallDefaults | undefined,
143
157
  options: ProcedureCallOptions | undefined,
144
- ): ApplyRequestOptionsResult {
158
+ ): Promise<ApplyRequestOptionsResult> {
145
159
  const signalSources = resolveSignalSources(defaults, options)
146
- const resolvedHeaders = resolveHeaders(defaults, options)
160
+ const resolvedHeaders = await resolveHeaders(defaults, options)
147
161
  const meta = resolveMeta(defaults, options)
148
162
 
149
163
  const headers =
@@ -186,7 +186,7 @@ export async function executeStream<TYield, TReturn = void>(
186
186
  let request = buildAdapterRequest(descriptor, resolvedBasePath)
187
187
 
188
188
  // 2. Apply request-level options (headers, signal, timeout, meta)
189
- const applied = applyRequestOptions(request, defaults, options)
189
+ const applied = await applyRequestOptions(request, defaults, options)
190
190
  request = applied.request
191
191
  const signalSources = applied.signalSources
192
192
 
@@ -193,16 +193,32 @@ export interface TypedStream<TYield, TReturn = void> extends AsyncIterable<TYiel
193
193
  * signal are provided, they're combined — whichever aborts first wins.
194
194
  * - `timeout`: Timeout in milliseconds. Combined with `signal` the same way.
195
195
  * A per-call `timeout: 0` disables an inherited default timeout.
196
- * - `headers`: Extra headers merged into the request. Per-call keys win over
197
- * default keys. Still subject to further mutation by `onBeforeRequest` hooks.
196
+ * - `headers`: Extra headers merged into the request, as a static record OR a
197
+ * (possibly async) function evaluated per request. Per-call keys win over
198
+ * default keys. Use the function form for values that change between calls
199
+ * (e.g. a rotating bearer token) so they don't go stale. Still subject to
200
+ * further mutation by `onBeforeRequest` hooks.
198
201
  * - `basePath`: Override the base path for this call. Per-call > default > config.
199
202
  * - `meta`: Per-request metadata typed via the {@link RequestMeta} interface.
200
203
  * Merged shallowly (per-call keys win over default keys).
201
204
  */
205
+ /**
206
+ * Request headers as a static record OR a (possibly async) function evaluated
207
+ * per request. Use the function form for values that change between calls — e.g.
208
+ * a rotating bearer token: `headers: () => ({ Authorization: `Bearer ${session.token}` })`.
209
+ * A static record captured at construction goes stale; a function is re-evaluated each call.
210
+ *
211
+ * Named `ClientHeadersInit` (not `HeadersInit`) to avoid shadowing the DOM lib's
212
+ * global `HeadersInit`, which has a different shape.
213
+ */
214
+ export type ClientHeadersInit =
215
+ | Record<string, string>
216
+ | (() => Record<string, string> | Promise<Record<string, string>>)
217
+
202
218
  export interface ProcedureCallDefaults {
203
219
  signal?: AbortSignal
204
220
  timeout?: number
205
- headers?: Record<string, string>
221
+ headers?: ClientHeadersInit
206
222
  basePath?: string
207
223
  meta?: RequestMeta
208
224
  }
@@ -302,6 +318,21 @@ export interface CreateClientConfig<TScopes> {
302
318
  * matches, falls back to `ClientHttpError` (transport error shape).
303
319
  */
304
320
  errorRegistry?: ErrorRegistry
321
+ /**
322
+ * Returns the current bearer token, re-evaluated per request (so a rotating
323
+ * token never goes stale). Wired internally to the `Authorization: Bearer
324
+ * <token>` header — a `null`/`undefined` token omits the header entirely.
325
+ *
326
+ * This is sugar over a function-valued `defaults.headers`; it composes with
327
+ * `defaults.headers` (both are applied, auth last) and is still subject to
328
+ * per-call `headers` and `onBeforeRequest`, which run after and can override.
329
+ *
330
+ * @example
331
+ * ```ts
332
+ * createClient({ adapter, basePath, auth: () => session.token, scopes })
333
+ * ```
334
+ */
335
+ auth?: () => string | null | undefined | Promise<string | null | undefined>
305
336
  }
306
337
 
307
338
  // ── Result Types ─────────────────────────────────────────
@@ -0,0 +1,89 @@
1
+ import type {
2
+ APIHttpRouteDoc,
3
+ DocEnvelope,
4
+ ErrorDoc,
5
+ HeaderDoc,
6
+ HttpMethod,
7
+ RPCHttpRouteDoc,
8
+ } from '../../implementations/types.js'
9
+
10
+ /**
11
+ * Typed builders for codegen test envelopes.
12
+ *
13
+ * These return real {@link DocEnvelope}/route-doc shapes with no `as unknown as`
14
+ * casts — the JSON Schema slots are `Record<string, unknown>`, so any plain
15
+ * schema object drops in directly. Callers pass small typed param objects and
16
+ * the builders fill in sensible defaults (method, empty `errors`, etc.).
17
+ */
18
+
19
+ export interface MakeApiRouteOptions {
20
+ name: string
21
+ scope?: string
22
+ method?: HttpMethod
23
+ /** Full resolved path. Defaults to `/${name}` when omitted. */
24
+ fullPath?: string
25
+ /** Route path (without pathPrefix). Defaults to `fullPath`. */
26
+ path?: string
27
+ successStatus?: number
28
+ errors?: string[]
29
+ jsonSchema?: APIHttpRouteDoc['jsonSchema']
30
+ }
31
+
32
+ /** Build a typed `kind: 'api'` route doc. */
33
+ export function makeApiRoute(opts: MakeApiRouteOptions): APIHttpRouteDoc {
34
+ const fullPath = opts.fullPath ?? `/${opts.name}`
35
+ return {
36
+ kind: 'api',
37
+ name: opts.name,
38
+ scope: opts.scope,
39
+ method: opts.method ?? 'get',
40
+ path: opts.path ?? fullPath,
41
+ fullPath,
42
+ successStatus: opts.successStatus,
43
+ errors: opts.errors ?? [],
44
+ jsonSchema: opts.jsonSchema ?? {},
45
+ }
46
+ }
47
+
48
+ export interface MakeRpcRouteOptions {
49
+ name: string
50
+ scope?: string | string[]
51
+ version?: number
52
+ /** Route path. Defaults to `/${name}` when omitted. */
53
+ path?: string
54
+ errors?: string[]
55
+ jsonSchema?: RPCHttpRouteDoc['jsonSchema']
56
+ }
57
+
58
+ /** Build a typed `kind: 'rpc'` route doc. */
59
+ export function makeRpcRoute(opts: MakeRpcRouteOptions): RPCHttpRouteDoc {
60
+ return {
61
+ kind: 'rpc',
62
+ name: opts.name,
63
+ scope: opts.scope ?? 'default',
64
+ version: opts.version ?? 1,
65
+ path: opts.path ?? `/${opts.name}`,
66
+ method: 'post',
67
+ errors: opts.errors ?? [],
68
+ jsonSchema: opts.jsonSchema ?? {},
69
+ }
70
+ }
71
+
72
+ export interface MakeEnvelopeOptions {
73
+ basePath?: string
74
+ headers?: HeaderDoc[]
75
+ errors?: ErrorDoc[]
76
+ }
77
+
78
+ /** Wrap route docs in a typed {@link DocEnvelope} with sensible defaults. */
79
+ export function makeEnvelope(
80
+ routes: DocEnvelope['routes'],
81
+ opts: MakeEnvelopeOptions = {},
82
+ ): DocEnvelope {
83
+ return {
84
+ basePath: opts.basePath ?? '/api',
85
+ headers: opts.headers ?? [],
86
+ errors: opts.errors ?? [],
87
+ routes,
88
+ }
89
+ }
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { vi } from 'vitest'
3
- import { parseArgs, loadConfigFile, extractConfigPath, printPostRunHints, warnIfKotlinNoOpFlags, type CodegenConfig } from './cli.js'
3
+ import { parseArgs, loadConfigFile, extractConfigPath, printPostRunHints, warnIfKotlinNoOpFlags, shouldShowHelp, type CodegenConfig } from './cli.js'
4
+ import { formatHelp } from './flag-specs.js'
4
5
 
5
6
  describe('parseArgs', () => {
6
7
  it('parses --url and --out', () => {
@@ -432,6 +433,23 @@ describe('cli — unsupported-unions flag', () => {
432
433
  })
433
434
  })
434
435
 
436
+ describe('--help handling', () => {
437
+ it('shouldShowHelp true for --help, -h, and bare (no args)', () => {
438
+ expect(shouldShowHelp(['--help'])).toBe(true)
439
+ expect(shouldShowHelp(['-h'])).toBe(true)
440
+ expect(shouldShowHelp([])).toBe(true)
441
+ })
442
+ it('shouldShowHelp false when real flags are present', () => {
443
+ expect(shouldShowHelp(['--url', 'http://x', '--out', 'gen'])).toBe(false)
444
+ })
445
+ it('help text covers the full flag surface', () => {
446
+ const help = formatHelp()
447
+ expect(help).toContain('--watch')
448
+ expect(help).toContain('--enum-style')
449
+ expect(help).toContain('--target')
450
+ })
451
+ })
452
+
435
453
  describe('cli — printPostRunHints', () => {
436
454
  it('prints a setup-guide pointer for the kotlin target', () => {
437
455
  const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
@@ -518,3 +536,49 @@ describe('cli — warnIfKotlinNoOpFlags', () => {
518
536
  }
519
537
  })
520
538
  })
539
+
540
+ describe('--share-models', () => {
541
+ it('defaults shareModels to true', () => {
542
+ expect(parseArgs(['--out', 'g', '--url', 'u']).shareModels).toBe(true)
543
+ })
544
+ it('--no-share-models sets it false', () => {
545
+ expect(parseArgs(['--out', 'g', '--url', 'u', '--no-share-models']).shareModels).toBe(false)
546
+ })
547
+ it('--share-models sets it true (overriding a config false)', () => {
548
+ expect(parseArgs(['--out', 'g', '--url', 'u', '--share-models'], { shareModels: false } as CodegenConfig).shareModels).toBe(true)
549
+ })
550
+ it('reads shareModels:false from config when no flag given', () => {
551
+ expect(parseArgs(['--out', 'g', '--url', 'u'], { shareModels: false } as CodegenConfig).shareModels).toBe(false)
552
+ })
553
+ it('reads sharedTypesImport from config', () => {
554
+ const cfg = { sharedTypesImport: { 'urn:msg': { module: '@shared/schemas', name: 'Message' } } }
555
+ expect(parseArgs(['--out', 'g', '--url', 'u'], cfg as CodegenConfig).sharedTypesImport).toEqual(cfg.sharedTypesImport)
556
+ })
557
+ })
558
+
559
+ describe('--shared-models-module and --strict-shared-models', () => {
560
+ it('parses --shared-models-module into sharedModelsModule', () => {
561
+ const parsed = parseArgs(['--out', 'gen', '--file', 'e.json', '--shared-models-module', '@app/schemas'])
562
+ expect(parsed.sharedModelsModule).toBe('@app/schemas')
563
+ })
564
+
565
+ it('parses --strict-shared-models as a boolean (default false)', () => {
566
+ expect(parseArgs(['--out', 'gen', '--file', 'e.json']).strictSharedModels).toBe(false)
567
+ expect(
568
+ parseArgs(['--out', 'gen', '--file', 'e.json', '--strict-shared-models']).strictSharedModels,
569
+ ).toBe(true)
570
+ })
571
+
572
+ it('CLI --shared-models-module overrides a config value', () => {
573
+ const parsed = parseArgs(
574
+ ['--out', 'gen', '--file', 'e.json', '--shared-models-module', '@cli/pkg'],
575
+ { sharedModelsModule: '@config/pkg' },
576
+ )
577
+ expect(parsed.sharedModelsModule).toBe('@cli/pkg')
578
+ })
579
+
580
+ it('strictSharedModels is seeded from config when the flag is absent', () => {
581
+ const parsed = parseArgs(['--out', 'gen', '--file', 'e.json'], { strictSharedModels: true })
582
+ expect(parsed.strictSharedModels).toBe(true)
583
+ })
584
+ })
@@ -4,6 +4,8 @@ import { resolve } from 'node:path'
4
4
  import { createHash } from 'node:crypto'
5
5
  import { generateClient, type GenerateClientOptions } from '../index.js'
6
6
  import type { AjscOptions } from '../emit-types.js'
7
+ import type { SharedTypesImportMap } from '../collect-models.js'
8
+ import { KNOWN_FLAGS, formatHelp } from './flag-specs.js'
7
9
 
8
10
  // ---------------------------------------------------------------------------
9
11
  // Types
@@ -26,6 +28,10 @@ export interface CodegenConfig {
26
28
  kotlin?: { package: string; serializer?: 'kotlinx' | 'none' }
27
29
  swift?: { serializer?: 'codable' | 'none'; accessLevel?: 'public' | 'internal' }
28
30
  unsupportedUnions?: 'throw' | 'fallback'
31
+ shareModels?: boolean
32
+ sharedTypesImport?: SharedTypesImportMap
33
+ sharedModelsModule?: string
34
+ strictSharedModels?: boolean
29
35
  }
30
36
 
31
37
  export interface ParsedArgs {
@@ -45,6 +51,10 @@ export interface ParsedArgs {
45
51
  kotlin?: { package: string; serializer?: 'kotlinx' | 'none' }
46
52
  swift?: { serializer?: 'codable' | 'none'; accessLevel?: 'public' | 'internal' }
47
53
  unsupportedUnions?: 'throw' | 'fallback'
54
+ shareModels: boolean
55
+ sharedTypesImport?: SharedTypesImportMap
56
+ sharedModelsModule?: string
57
+ strictSharedModels: boolean
48
58
  }
49
59
 
50
60
  // ---------------------------------------------------------------------------
@@ -73,30 +83,9 @@ export async function loadConfigFile(configPath?: string): Promise<CodegenConfig
73
83
  }
74
84
 
75
85
  // ---------------------------------------------------------------------------
76
- // Flag catalog + did-you-mean
86
+ // did-you-mean (the flag catalog now lives in ./flag-specs.ts)
77
87
  // ---------------------------------------------------------------------------
78
88
 
79
- /**
80
- * Every flag `parseArgs` recognises. Kept in one place so the unknown-flag
81
- * error can suggest the closest match for typos like `--targt` → `--target`.
82
- *
83
- * The list is also load-bearing: any new branch added to the parse loop
84
- * MUST also be added here, or the loop will throw on the very flag it just
85
- * accepted. (Kept literal — small enough that DRY-ing it isn't worth it.)
86
- */
87
- const KNOWN_FLAGS = [
88
- '--url', '--file', '--out', '--watch', '--interval',
89
- '--enum-style', '--depluralize', '--array-item-naming', '--uncountable-words',
90
- '--jsdoc', '--no-jsdoc',
91
- '--client-import-path', '--dry-run',
92
- '--namespace-types', '--no-namespace-types',
93
- '--self-contained', '--no-self-contained',
94
- '--service-name', '--clean-out-dir', '--no-clean-out-dir',
95
- '--target', '--kotlin-package', '--kotlin-serializer',
96
- '--swift-serializer', '--swift-access-level',
97
- '--unsupported-unions', '--config',
98
- ] as const
99
-
100
89
  /**
101
90
  * Levenshtein distance between two strings — small ad-hoc implementation
102
91
  * tuned for short flag names. We don't pull a dep just for typo suggestions.
@@ -183,6 +172,10 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
183
172
  let swiftSerializer: 'codable' | 'none' | undefined = config?.swift?.serializer
184
173
  let swiftAccessLevel: 'public' | 'internal' | undefined = config?.swift?.accessLevel
185
174
  let unsupportedUnions: 'throw' | 'fallback' | undefined = config?.unsupportedUnions
175
+ let shareModels = config?.shareModels ?? true
176
+ const sharedTypesImport = config?.sharedTypesImport
177
+ let sharedModelsModule: string | undefined = config?.sharedModelsModule
178
+ let strictSharedModels = config?.strictSharedModels ?? false
186
179
  let configPath: string | undefined
187
180
 
188
181
  for (let i = 0; i < argv.length; i++) {
@@ -269,6 +262,14 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
269
262
  } else {
270
263
  throw new Error(`Invalid --unsupported-unions value: ${val ?? '(missing)'} (expected 'throw' or 'fallback')`)
271
264
  }
265
+ } else if (arg === '--share-models') {
266
+ shareModels = true
267
+ } else if (arg === '--no-share-models') {
268
+ shareModels = false
269
+ } else if (arg === '--shared-models-module') {
270
+ sharedModelsModule = argv[++i]
271
+ } else if (arg === '--strict-shared-models') {
272
+ strictSharedModels = true
272
273
  } else if (arg === '--config') {
273
274
  configPath = argv[++i]
274
275
  } else if (arg !== undefined && arg.startsWith('--')) {
@@ -343,6 +344,10 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
343
344
  }
344
345
  : {}),
345
346
  ...(unsupportedUnions !== undefined ? { unsupportedUnions } : {}),
347
+ shareModels,
348
+ ...(sharedTypesImport !== undefined ? { sharedTypesImport } : {}),
349
+ ...(sharedModelsModule !== undefined ? { sharedModelsModule } : {}),
350
+ strictSharedModels,
346
351
  }
347
352
  }
348
353
 
@@ -430,6 +435,11 @@ async function runWithWatch(parsed: ParsedArgs): Promise<void> {
430
435
  ...(parsed.unsupportedUnions !== undefined ? { unsupportedUnions: parsed.unsupportedUnions } : {}),
431
436
  ...(parsed.swift?.serializer !== undefined ? { swiftSerializer: parsed.swift.serializer } : {}),
432
437
  ...(parsed.swift?.accessLevel !== undefined ? { swiftAccessLevel: parsed.swift.accessLevel } : {}),
438
+ shareModels: parsed.shareModels,
439
+ ...(parsed.sharedTypesImport !== undefined ? { sharedTypesImport: parsed.sharedTypesImport } : {}),
440
+ ...(parsed.sharedModelsModule !== undefined ? { sharedModelsModule: parsed.sharedModelsModule } : {}),
441
+ strictSharedModels: parsed.strictSharedModels,
442
+ logger: (message: string) => { console.log(message) },
433
443
  ...kotlinWiring,
434
444
  ...swiftWiring,
435
445
  })
@@ -488,8 +498,22 @@ export function warnIfKotlinNoOpFlags(parsed: {
488
498
  }
489
499
  }
490
500
 
501
+ /**
502
+ * True when the CLI should print usage and exit 0: an explicit --help/-h, or a
503
+ * bare invocation with no args. --help is handled here (not in parseArgs) so it
504
+ * never reaches the unknown-flag branch and never appears in did-you-mean.
505
+ */
506
+ export function shouldShowHelp(argv: string[]): boolean {
507
+ if (argv.length === 0) return true
508
+ return argv.includes('--help') || argv.includes('-h')
509
+ }
510
+
491
511
  async function main(): Promise<void> {
492
512
  const argv = process.argv.slice(2)
513
+ if (shouldShowHelp(argv)) {
514
+ console.log(formatHelp())
515
+ process.exit(0)
516
+ }
493
517
  const configPath = extractConfigPath(argv)
494
518
  const config = await loadConfigFile(configPath)
495
519
  if (config != null) {
@@ -544,6 +568,11 @@ async function main(): Promise<void> {
544
568
  ...(parsed.unsupportedUnions !== undefined ? { unsupportedUnions: parsed.unsupportedUnions } : {}),
545
569
  ...(parsed.swift?.serializer !== undefined ? { swiftSerializer: parsed.swift.serializer } : {}),
546
570
  ...(parsed.swift?.accessLevel !== undefined ? { swiftAccessLevel: parsed.swift.accessLevel } : {}),
571
+ shareModels: parsed.shareModels,
572
+ ...(parsed.sharedTypesImport !== undefined ? { sharedTypesImport: parsed.sharedTypesImport } : {}),
573
+ ...(parsed.sharedModelsModule !== undefined ? { sharedModelsModule: parsed.sharedModelsModule } : {}),
574
+ strictSharedModels: parsed.strictSharedModels,
575
+ logger: (message: string) => { console.log(message) },
547
576
  ...kotlinWiring,
548
577
  ...swiftWiring,
549
578
  })
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { FLAG_SPECS, KNOWN_FLAGS, formatHelp } from './flag-specs.js'
3
+
4
+ describe('flag-specs', () => {
5
+ it('derives KNOWN_FLAGS from the spec table', () => {
6
+ expect(KNOWN_FLAGS).toContain('--url')
7
+ expect(KNOWN_FLAGS).toContain('--service-name')
8
+ expect(KNOWN_FLAGS).toContain('--target')
9
+ for (const spec of FLAG_SPECS) expect(KNOWN_FLAGS).toContain(spec.name)
10
+ })
11
+
12
+ it('formatHelp lists every flag with its description, grouped', () => {
13
+ const help = formatHelp()
14
+ expect(help).toMatch(/Usage: ts-procedures-codegen/)
15
+ for (const spec of FLAG_SPECS) {
16
+ expect(help).toContain(spec.name)
17
+ expect(help).toContain(spec.description)
18
+ }
19
+ expect(help).toMatch(/Source/)
20
+ expect(help).toMatch(/Targets/)
21
+ })
22
+
23
+ it('does not list --help/-h as a real codegen flag in KNOWN_FLAGS', () => {
24
+ expect(KNOWN_FLAGS).not.toContain('--help')
25
+ expect(KNOWN_FLAGS).not.toContain('-h')
26
+ })
27
+
28
+ it('catalogs the shared-models convention + strict flags', () => {
29
+ expect(KNOWN_FLAGS).toContain('--shared-models-module')
30
+ expect(KNOWN_FLAGS).toContain('--strict-shared-models')
31
+ })
32
+
33
+ it('documents the new flags in --help output', () => {
34
+ const help = formatHelp()
35
+ expect(help).toContain('--shared-models-module')
36
+ expect(help).toContain('--strict-shared-models')
37
+ })
38
+ })
@@ -0,0 +1,71 @@
1
+ export interface FlagSpec {
2
+ name: string
3
+ arg?: string
4
+ description: string
5
+ group: 'Source' | 'Output' | 'Codegen' | 'Targets' | 'Misc'
6
+ default?: string
7
+ }
8
+
9
+ export const FLAG_SPECS: readonly FlagSpec[] = [
10
+ // Source
11
+ { name: '--url', arg: '<url>', description: 'Fetch the doc envelope from a running server', group: 'Source' },
12
+ { name: '--file', arg: '<path>', description: 'Read the doc envelope from a JSON file (see writeDocEnvelope)', group: 'Source' },
13
+ { name: '--config', arg: '<path>', description: 'Load options from a JSON config file', group: 'Source' },
14
+ // Output
15
+ { name: '--out', arg: '<dir>', description: 'Output directory for generated files', group: 'Output' },
16
+ { name: '--dry-run', description: 'Print what would be written without touching disk', group: 'Output' },
17
+ { name: '--clean-out-dir', description: 'Prune orphaned generator-owned files before writing', group: 'Output', default: 'on' },
18
+ { name: '--no-clean-out-dir', description: 'Disable orphan pruning', group: 'Output' },
19
+ { name: '--watch', description: 'Regenerate on envelope change', group: 'Output' },
20
+ { name: '--interval', arg: '<ms>', description: 'Poll interval for --watch (default 3000)', group: 'Output' },
21
+ // Codegen
22
+ { name: '--service-name', arg: '<name>', description: 'Service identifier driving generated names', group: 'Codegen', default: 'Api' },
23
+ { name: '--namespace-types', description: 'Wrap types in nested namespaces', group: 'Codegen', default: 'on' },
24
+ { name: '--no-namespace-types', description: 'Flat type names', group: 'Codegen' },
25
+ { name: '--self-contained', description: 'Bundle the client runtime into the output dir', group: 'Codegen', default: 'on' },
26
+ { name: '--no-self-contained', description: 'Import the client runtime from ts-procedures/client', group: 'Codegen' },
27
+ { name: '--client-import-path', arg: '<path>', description: 'Override the client runtime import path', group: 'Codegen' },
28
+ { name: '--share-models', description: 'Hoist $id-bearing schemas into a shared _models.ts', group: 'Codegen', default: 'on' },
29
+ { name: '--no-share-models', description: 'Inline every type per route (legacy behaviour)', group: 'Codegen' },
30
+ { name: '--shared-models-module', arg: '<module>', description: 'Re-export every $id model from one module (convention; sharedTypesImport overrides)', group: 'Codegen' },
31
+ { name: '--strict-shared-models', description: 'Fail if any $id model would be generated as a local twin', group: 'Codegen' },
32
+ { name: '--jsdoc', description: 'Emit JSDoc on generated types', group: 'Codegen', default: 'on' },
33
+ { name: '--no-jsdoc', description: 'Suppress JSDoc', group: 'Codegen' },
34
+ { name: '--enum-style', arg: '<union|enum>', description: 'How to emit enums (namespace mode)', group: 'Codegen' },
35
+ { name: '--depluralize', description: 'Depluralize extracted array-item type names', group: 'Codegen' },
36
+ { name: '--array-item-naming', arg: '<name|false>', description: 'Naming for extracted array-item types', group: 'Codegen' },
37
+ { name: '--uncountable-words', arg: '<csv>', description: 'Words exempt from depluralization', group: 'Codegen' },
38
+ // Targets
39
+ { name: '--target', arg: '<ts|kotlin|swift>', description: 'Output language', group: 'Targets', default: 'ts' },
40
+ { name: '--kotlin-package', arg: '<pkg>', description: 'Package for Kotlin output (required for --target kotlin)', group: 'Targets' },
41
+ { name: '--kotlin-serializer', arg: '<kotlinx|none>', description: 'Kotlin serialization annotations', group: 'Targets', default: 'kotlinx' },
42
+ { name: '--swift-serializer', arg: '<codable|none>', description: 'Swift Codable conformance', group: 'Targets', default: 'codable' },
43
+ { name: '--swift-access-level', arg: '<public|internal>', description: 'Swift access level', group: 'Targets', default: 'public' },
44
+ { name: '--unsupported-unions', arg: '<throw|fallback>', description: 'Behaviour for untagged oneOf schemas', group: 'Targets', default: 'throw' },
45
+ ]
46
+
47
+ export const KNOWN_FLAGS: readonly string[] = FLAG_SPECS.map((s) => s.name)
48
+
49
+ const GROUP_ORDER: FlagSpec['group'][] = ['Source', 'Output', 'Codegen', 'Targets', 'Misc']
50
+
51
+ export function formatHelp(): string {
52
+ const lines: string[] = []
53
+ lines.push('Usage: ts-procedures-codegen --out <dir> (--url <url> | --file <path>) [options]')
54
+ lines.push('')
55
+ lines.push('Generate a typed client from a ts-procedures doc envelope.')
56
+ lines.push('')
57
+ const col = Math.max(...FLAG_SPECS.map((s) => (s.name + (s.arg ? ' ' + s.arg : '')).length)) + 2
58
+ for (const group of GROUP_ORDER) {
59
+ const specs = FLAG_SPECS.filter((s) => s.group === group)
60
+ if (specs.length === 0) continue
61
+ lines.push(`${group}:`)
62
+ for (const s of specs) {
63
+ const left = s.name + (s.arg ? ' ' + s.arg : '')
64
+ const def = s.default ? ` (default: ${s.default})` : ''
65
+ lines.push(` ${left.padEnd(col)}${s.description}${def}`)
66
+ }
67
+ lines.push('')
68
+ }
69
+ lines.push(' -h, --help Show this help and exit')
70
+ return lines.join('\n')
71
+ }
@@ -0,0 +1,68 @@
1
+ import { it, expect } from 'vitest'
2
+ import { collectModels, resolveModelImports } from './collect-models.js'
3
+
4
+ const message = { type: 'object', $id: 'urn:msg', title: 'Message', properties: { id: { type: 'string' } } }
5
+
6
+ it('collects each $id subschema once, named from title', () => {
7
+ const routes = [
8
+ { jsonSchema: { response: message } },
9
+ { jsonSchema: { body: { type: 'object', properties: { msg: message } } } },
10
+ ] as any
11
+ const models = collectModels(routes)
12
+ expect(models.map((m) => m.name)).toEqual(['Message'])
13
+ expect(models[0]?.id).toBe('urn:msg')
14
+ })
15
+
16
+ it('throws on $id collision with divergent body', () => {
17
+ const a = { type: 'object', $id: 'urn:x', title: 'X', properties: { a: { type: 'string' } } }
18
+ const b = { type: 'object', $id: 'urn:x', title: 'X', properties: { b: { type: 'number' } } }
19
+ expect(() => collectModels([{ jsonSchema: { response: a } }, { jsonSchema: { body: b } }] as any)).toThrow(/urn:x/)
20
+ })
21
+
22
+ it('disambiguates distinct $ids that derive the same name', () => {
23
+ const m1 = { type: 'object', $id: 'urn:a/message', title: 'Message', properties: { a: { type: 'string' } } }
24
+ const m2 = { type: 'object', $id: 'urn:b/message', title: 'Message', properties: { b: { type: 'string' } } }
25
+ const models = collectModels([{ jsonSchema: { response: m1 } }, { jsonSchema: { body: m2 } }] as any)
26
+ expect(models.map((m) => m.name).sort()).toEqual(['Message', 'Message2'])
27
+ })
28
+
29
+ it('ignores schemas without $id', () => {
30
+ const routes = [{ jsonSchema: { response: { type: 'object', title: 'Loose', properties: {} } } }] as any
31
+ expect(collectModels(routes)).toEqual([])
32
+ })
33
+
34
+ it('does NOT throw for ordinary schemas without the reserved prefix', () => {
35
+ const routes = [
36
+ { jsonSchema: { response: { type: 'object', properties: { kind: { const: 'message' }, status: { enum: ['ok', 'error'] } } } } },
37
+ ] as any
38
+ expect(() => collectModels(routes)).not.toThrow()
39
+ })
40
+
41
+ it('resolveModelImports tags mapped models and leaves others generated', () => {
42
+ const models = [{ id: 'urn:msg', name: 'Message', schema: {} as any }]
43
+ const mapped = resolveModelImports(models, {
44
+ sharedTypesImport: { 'urn:msg': { module: '@shared/schemas', name: 'Message' } },
45
+ })
46
+ expect(mapped[0]?.import).toEqual({ module: '@shared/schemas', name: 'Message' })
47
+ expect(resolveModelImports(models)[0]?.import).toBeUndefined()
48
+ })
49
+
50
+ it('resolveModelImports falls back to sharedModelsModule when no map entry matches', () => {
51
+ const models = [{ id: 'urn:msg', name: 'Message', schema: {} as any }]
52
+ const resolved = resolveModelImports(models, { sharedModelsModule: '@app/schemas' })
53
+ expect(resolved[0]?.import).toEqual({ module: '@app/schemas', name: 'Message' })
54
+ })
55
+
56
+ it('resolveModelImports: explicit map entry wins over the convention', () => {
57
+ const models = [{ id: 'urn:msg', name: 'Message', schema: {} as any }]
58
+ const resolved = resolveModelImports(models, {
59
+ sharedTypesImport: { 'urn:msg': { module: '@override/pkg', name: 'Msg' } },
60
+ sharedModelsModule: '@app/schemas',
61
+ })
62
+ expect(resolved[0]?.import).toEqual({ module: '@override/pkg', name: 'Msg' })
63
+ })
64
+
65
+ it('resolveModelImports: empty-string convention is treated as unset (generated locally)', () => {
66
+ const models = [{ id: 'urn:msg', name: 'Message', schema: {} as any }]
67
+ expect(resolveModelImports(models, { sharedModelsModule: '' })[0]?.import).toBeUndefined()
68
+ })