ts-procedures 8.2.1 → 8.4.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 (146) hide show
  1. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +31 -9
  2. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +3 -1
  3. package/agent_config/claude-code/skills/ts-procedures/patterns.md +30 -6
  4. package/agent_config/claude-code/skills/ts-procedures/templates/client.md +3 -3
  5. package/agent_config/claude-code/skills/ts-procedures/templates/hono.md +3 -3
  6. package/agent_config/claude-code/skills/ts-procedures/templates/procedure.md +3 -3
  7. package/agent_config/claude-code/skills/ts-procedures/templates/stream-procedure.md +3 -3
  8. package/agent_config/copilot/copilot-instructions.md +10 -6
  9. package/agent_config/cursor/cursorrules +10 -6
  10. package/build/client/call.js +1 -1
  11. package/build/client/call.js.map +1 -1
  12. package/build/client/index.d.ts +1 -1
  13. package/build/client/index.js +23 -1
  14. package/build/client/index.js.map +1 -1
  15. package/build/client/index.test.js +87 -0
  16. package/build/client/index.test.js.map +1 -1
  17. package/build/client/resolve-options.d.ts +5 -4
  18. package/build/client/resolve-options.js +18 -7
  19. package/build/client/resolve-options.js.map +1 -1
  20. package/build/client/resolve-options.test.js +53 -24
  21. package/build/client/resolve-options.test.js.map +1 -1
  22. package/build/client/stream.js +1 -1
  23. package/build/client/stream.js.map +1 -1
  24. package/build/client/types.d.ts +31 -3
  25. package/build/codegen/__fixtures__/make-envelope.d.ts +41 -0
  26. package/build/codegen/__fixtures__/make-envelope.js +38 -0
  27. package/build/codegen/__fixtures__/make-envelope.js.map +1 -0
  28. package/build/codegen/bin/cli.d.ts +11 -0
  29. package/build/codegen/bin/cli.js +30 -21
  30. package/build/codegen/bin/cli.js.map +1 -1
  31. package/build/codegen/bin/cli.test.js +36 -1
  32. package/build/codegen/bin/cli.test.js.map +1 -1
  33. package/build/codegen/bin/flag-specs.d.ts +10 -0
  34. package/build/codegen/bin/flag-specs.js +60 -0
  35. package/build/codegen/bin/flag-specs.js.map +1 -0
  36. package/build/codegen/bin/flag-specs.test.d.ts +1 -0
  37. package/build/codegen/bin/flag-specs.test.js +26 -0
  38. package/build/codegen/bin/flag-specs.test.js.map +1 -0
  39. package/build/codegen/collect-models.d.ts +37 -0
  40. package/build/codegen/collect-models.js +74 -0
  41. package/build/codegen/collect-models.js.map +1 -0
  42. package/build/codegen/collect-models.test.d.ts +1 -0
  43. package/build/codegen/collect-models.test.js +40 -0
  44. package/build/codegen/collect-models.test.js.map +1 -0
  45. package/build/codegen/emit-client-runtime.js +1 -0
  46. package/build/codegen/emit-client-runtime.js.map +1 -1
  47. package/build/codegen/emit-errors.integration.test.js +22 -0
  48. package/build/codegen/emit-errors.integration.test.js.map +1 -1
  49. package/build/codegen/emit-models.d.ts +26 -0
  50. package/build/codegen/emit-models.js +53 -0
  51. package/build/codegen/emit-models.js.map +1 -0
  52. package/build/codegen/emit-models.test.d.ts +1 -0
  53. package/build/codegen/emit-models.test.js +42 -0
  54. package/build/codegen/emit-models.test.js.map +1 -0
  55. package/build/codegen/emit-scope.d.ts +10 -0
  56. package/build/codegen/emit-scope.js +119 -34
  57. package/build/codegen/emit-scope.js.map +1 -1
  58. package/build/codegen/emit-types.d.ts +26 -1
  59. package/build/codegen/emit-types.js +27 -5
  60. package/build/codegen/emit-types.js.map +1 -1
  61. package/build/codegen/index.d.ts +5 -0
  62. package/build/codegen/index.js +2 -0
  63. package/build/codegen/index.js.map +1 -1
  64. package/build/codegen/model-refs.d.ts +27 -0
  65. package/build/codegen/model-refs.js +49 -0
  66. package/build/codegen/model-refs.js.map +1 -0
  67. package/build/codegen/model-refs.test.d.ts +1 -0
  68. package/build/codegen/model-refs.test.js +33 -0
  69. package/build/codegen/model-refs.test.js.map +1 -0
  70. package/build/codegen/pipeline.d.ts +3 -0
  71. package/build/codegen/pipeline.js +3 -1
  72. package/build/codegen/pipeline.js.map +1 -1
  73. package/build/codegen/schema-walk.d.ts +13 -0
  74. package/build/codegen/schema-walk.js +26 -0
  75. package/build/codegen/schema-walk.js.map +1 -0
  76. package/build/codegen/schema-walk.test.d.ts +1 -0
  77. package/build/codegen/schema-walk.test.js +35 -0
  78. package/build/codegen/schema-walk.test.js.map +1 -0
  79. package/build/codegen/targets/_shared/target-run.d.ts +5 -0
  80. package/build/codegen/targets/ts/run.js +28 -1
  81. package/build/codegen/targets/ts/run.js.map +1 -1
  82. package/build/codegen/targets/ts/shared-models.test.d.ts +1 -0
  83. package/build/codegen/targets/ts/shared-models.test.js +258 -0
  84. package/build/codegen/targets/ts/shared-models.test.js.map +1 -0
  85. package/build/doc-envelope.d.ts +13 -0
  86. package/build/doc-envelope.js +23 -0
  87. package/build/doc-envelope.js.map +1 -0
  88. package/build/doc-envelope.test.d.ts +1 -0
  89. package/build/doc-envelope.test.js +31 -0
  90. package/build/doc-envelope.test.js.map +1 -0
  91. package/build/exports.d.ts +2 -0
  92. package/build/exports.js +1 -0
  93. package/build/exports.js.map +1 -1
  94. package/build/implementations/http/error-taxonomy.d.ts +40 -0
  95. package/build/implementations/http/error-taxonomy.js +57 -5
  96. package/build/implementations/http/error-taxonomy.js.map +1 -1
  97. package/build/implementations/http/error-taxonomy.test.js +95 -1
  98. package/build/implementations/http/error-taxonomy.test.js.map +1 -1
  99. package/build/implementations/http/hono/handlers/http.js +19 -24
  100. package/build/implementations/http/hono/handlers/http.js.map +1 -1
  101. package/build/implementations/http/hono/handlers/http.test.js +64 -1
  102. package/build/implementations/http/hono/handlers/http.test.js.map +1 -1
  103. package/docs/client-and-codegen.md +109 -0
  104. package/docs/core.md +2 -0
  105. package/docs/handoffs/ajsc-named-type-collision.md +134 -0
  106. package/docs/handoffs/ajsc-named-type-support.md +181 -0
  107. package/docs/http-integrations.md +4 -0
  108. package/docs/superpowers/plans/2026-06-05-dx-feedback-round.md +1292 -0
  109. package/docs/superpowers/specs/2026-06-05-dx-feedback-round-design.md +285 -0
  110. package/package.json +2 -2
  111. package/src/client/call.ts +1 -1
  112. package/src/client/index.test.ts +98 -0
  113. package/src/client/index.ts +32 -1
  114. package/src/client/resolve-options.test.ts +73 -26
  115. package/src/client/resolve-options.ts +23 -9
  116. package/src/client/stream.ts +1 -1
  117. package/src/client/types.ts +34 -3
  118. package/src/codegen/__fixtures__/make-envelope.ts +89 -0
  119. package/src/codegen/bin/cli.test.ts +38 -1
  120. package/src/codegen/bin/cli.ts +33 -22
  121. package/src/codegen/bin/flag-specs.test.ts +27 -0
  122. package/src/codegen/bin/flag-specs.ts +69 -0
  123. package/src/codegen/collect-models.test.ts +46 -0
  124. package/src/codegen/collect-models.ts +108 -0
  125. package/src/codegen/emit-client-runtime.ts +1 -0
  126. package/src/codegen/emit-errors.integration.test.ts +26 -0
  127. package/src/codegen/emit-models.test.ts +48 -0
  128. package/src/codegen/emit-models.ts +63 -0
  129. package/src/codegen/emit-scope.ts +145 -33
  130. package/src/codegen/emit-types.ts +48 -7
  131. package/src/codegen/index.ts +7 -0
  132. package/src/codegen/model-refs.test.ts +37 -0
  133. package/src/codegen/model-refs.ts +57 -0
  134. package/src/codegen/pipeline.ts +6 -1
  135. package/src/codegen/schema-walk.test.ts +37 -0
  136. package/src/codegen/schema-walk.ts +23 -0
  137. package/src/codegen/targets/_shared/target-run.ts +5 -0
  138. package/src/codegen/targets/ts/run.ts +33 -0
  139. package/src/codegen/targets/ts/shared-models.test.ts +283 -0
  140. package/src/doc-envelope.test.ts +35 -0
  141. package/src/doc-envelope.ts +30 -0
  142. package/src/exports.ts +2 -0
  143. package/src/implementations/http/error-taxonomy.test.ts +111 -0
  144. package/src/implementations/http/error-taxonomy.ts +60 -5
  145. package/src/implementations/http/hono/handlers/http.test.ts +69 -1
  146. package/src/implementations/http/hono/handlers/http.ts +19 -21
@@ -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,22 @@ 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
+ })
@@ -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,8 @@ 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
29
33
  }
30
34
 
31
35
  export interface ParsedArgs {
@@ -45,6 +49,8 @@ export interface ParsedArgs {
45
49
  kotlin?: { package: string; serializer?: 'kotlinx' | 'none' }
46
50
  swift?: { serializer?: 'codable' | 'none'; accessLevel?: 'public' | 'internal' }
47
51
  unsupportedUnions?: 'throw' | 'fallback'
52
+ shareModels: boolean
53
+ sharedTypesImport?: SharedTypesImportMap
48
54
  }
49
55
 
50
56
  // ---------------------------------------------------------------------------
@@ -73,30 +79,9 @@ export async function loadConfigFile(configPath?: string): Promise<CodegenConfig
73
79
  }
74
80
 
75
81
  // ---------------------------------------------------------------------------
76
- // Flag catalog + did-you-mean
82
+ // did-you-mean (the flag catalog now lives in ./flag-specs.ts)
77
83
  // ---------------------------------------------------------------------------
78
84
 
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
85
  /**
101
86
  * Levenshtein distance between two strings — small ad-hoc implementation
102
87
  * tuned for short flag names. We don't pull a dep just for typo suggestions.
@@ -183,6 +168,8 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
183
168
  let swiftSerializer: 'codable' | 'none' | undefined = config?.swift?.serializer
184
169
  let swiftAccessLevel: 'public' | 'internal' | undefined = config?.swift?.accessLevel
185
170
  let unsupportedUnions: 'throw' | 'fallback' | undefined = config?.unsupportedUnions
171
+ let shareModels = config?.shareModels ?? true
172
+ const sharedTypesImport = config?.sharedTypesImport
186
173
  let configPath: string | undefined
187
174
 
188
175
  for (let i = 0; i < argv.length; i++) {
@@ -269,6 +256,10 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
269
256
  } else {
270
257
  throw new Error(`Invalid --unsupported-unions value: ${val ?? '(missing)'} (expected 'throw' or 'fallback')`)
271
258
  }
259
+ } else if (arg === '--share-models') {
260
+ shareModels = true
261
+ } else if (arg === '--no-share-models') {
262
+ shareModels = false
272
263
  } else if (arg === '--config') {
273
264
  configPath = argv[++i]
274
265
  } else if (arg !== undefined && arg.startsWith('--')) {
@@ -343,6 +334,8 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
343
334
  }
344
335
  : {}),
345
336
  ...(unsupportedUnions !== undefined ? { unsupportedUnions } : {}),
337
+ shareModels,
338
+ ...(sharedTypesImport !== undefined ? { sharedTypesImport } : {}),
346
339
  }
347
340
  }
348
341
 
@@ -430,6 +423,8 @@ async function runWithWatch(parsed: ParsedArgs): Promise<void> {
430
423
  ...(parsed.unsupportedUnions !== undefined ? { unsupportedUnions: parsed.unsupportedUnions } : {}),
431
424
  ...(parsed.swift?.serializer !== undefined ? { swiftSerializer: parsed.swift.serializer } : {}),
432
425
  ...(parsed.swift?.accessLevel !== undefined ? { swiftAccessLevel: parsed.swift.accessLevel } : {}),
426
+ shareModels: parsed.shareModels,
427
+ ...(parsed.sharedTypesImport !== undefined ? { sharedTypesImport: parsed.sharedTypesImport } : {}),
433
428
  ...kotlinWiring,
434
429
  ...swiftWiring,
435
430
  })
@@ -488,8 +483,22 @@ export function warnIfKotlinNoOpFlags(parsed: {
488
483
  }
489
484
  }
490
485
 
486
+ /**
487
+ * True when the CLI should print usage and exit 0: an explicit --help/-h, or a
488
+ * bare invocation with no args. --help is handled here (not in parseArgs) so it
489
+ * never reaches the unknown-flag branch and never appears in did-you-mean.
490
+ */
491
+ export function shouldShowHelp(argv: string[]): boolean {
492
+ if (argv.length === 0) return true
493
+ return argv.includes('--help') || argv.includes('-h')
494
+ }
495
+
491
496
  async function main(): Promise<void> {
492
497
  const argv = process.argv.slice(2)
498
+ if (shouldShowHelp(argv)) {
499
+ console.log(formatHelp())
500
+ process.exit(0)
501
+ }
493
502
  const configPath = extractConfigPath(argv)
494
503
  const config = await loadConfigFile(configPath)
495
504
  if (config != null) {
@@ -544,6 +553,8 @@ async function main(): Promise<void> {
544
553
  ...(parsed.unsupportedUnions !== undefined ? { unsupportedUnions: parsed.unsupportedUnions } : {}),
545
554
  ...(parsed.swift?.serializer !== undefined ? { swiftSerializer: parsed.swift.serializer } : {}),
546
555
  ...(parsed.swift?.accessLevel !== undefined ? { swiftAccessLevel: parsed.swift.accessLevel } : {}),
556
+ shareModels: parsed.shareModels,
557
+ ...(parsed.sharedTypesImport !== undefined ? { sharedTypesImport: parsed.sharedTypesImport } : {}),
547
558
  ...kotlinWiring,
548
559
  ...swiftWiring,
549
560
  })
@@ -0,0 +1,27 @@
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
+ })
@@ -0,0 +1,69 @@
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: '--jsdoc', description: 'Emit JSDoc on generated types', group: 'Codegen', default: 'on' },
31
+ { name: '--no-jsdoc', description: 'Suppress JSDoc', group: 'Codegen' },
32
+ { name: '--enum-style', arg: '<union|enum>', description: 'How to emit enums (namespace mode)', group: 'Codegen' },
33
+ { name: '--depluralize', description: 'Depluralize extracted array-item type names', group: 'Codegen' },
34
+ { name: '--array-item-naming', arg: '<name|false>', description: 'Naming for extracted array-item types', group: 'Codegen' },
35
+ { name: '--uncountable-words', arg: '<csv>', description: 'Words exempt from depluralization', group: 'Codegen' },
36
+ // Targets
37
+ { name: '--target', arg: '<ts|kotlin|swift>', description: 'Output language', group: 'Targets', default: 'ts' },
38
+ { name: '--kotlin-package', arg: '<pkg>', description: 'Package for Kotlin output (required for --target kotlin)', group: 'Targets' },
39
+ { name: '--kotlin-serializer', arg: '<kotlinx|none>', description: 'Kotlin serialization annotations', group: 'Targets', default: 'kotlinx' },
40
+ { name: '--swift-serializer', arg: '<codable|none>', description: 'Swift Codable conformance', group: 'Targets', default: 'codable' },
41
+ { name: '--swift-access-level', arg: '<public|internal>', description: 'Swift access level', group: 'Targets', default: 'public' },
42
+ { name: '--unsupported-unions', arg: '<throw|fallback>', description: 'Behaviour for untagged oneOf schemas', group: 'Targets', default: 'throw' },
43
+ ]
44
+
45
+ export const KNOWN_FLAGS: readonly string[] = FLAG_SPECS.map((s) => s.name)
46
+
47
+ const GROUP_ORDER: FlagSpec['group'][] = ['Source', 'Output', 'Codegen', 'Targets', 'Misc']
48
+
49
+ export function formatHelp(): string {
50
+ const lines: string[] = []
51
+ lines.push('Usage: ts-procedures-codegen --out <dir> (--url <url> | --file <path>) [options]')
52
+ lines.push('')
53
+ lines.push('Generate a typed client from a ts-procedures doc envelope.')
54
+ lines.push('')
55
+ const col = Math.max(...FLAG_SPECS.map((s) => (s.name + (s.arg ? ' ' + s.arg : '')).length)) + 2
56
+ for (const group of GROUP_ORDER) {
57
+ const specs = FLAG_SPECS.filter((s) => s.group === group)
58
+ if (specs.length === 0) continue
59
+ lines.push(`${group}:`)
60
+ for (const s of specs) {
61
+ const left = s.name + (s.arg ? ' ' + s.arg : '')
62
+ const def = s.default ? ` (default: ${s.default})` : ''
63
+ lines.push(` ${left.padEnd(col)}${s.description}${def}`)
64
+ }
65
+ lines.push('')
66
+ }
67
+ lines.push(' -h, --help Show this help and exit')
68
+ return lines.join('\n')
69
+ }
@@ -0,0 +1,46 @@
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, { 'urn:msg': { module: '@shared/schemas', name: 'Message' } })
44
+ expect(mapped[0]?.import).toEqual({ module: '@shared/schemas', name: 'Message' })
45
+ expect(resolveModelImports(models, {})[0]?.import).toBeUndefined()
46
+ })
@@ -0,0 +1,108 @@
1
+ import type { AnyHttpRouteDoc } from '../implementations/types.js'
2
+ import { toPascalCase } from './naming.js'
3
+ import { walkSubschemas } from './schema-walk.js'
4
+
5
+ /** A schema hoisted out of per-route inlining because it carries a `$id`. */
6
+ export interface CollectedModel {
7
+ /** The schema's `$id` — the stable identity key used for dedup and import mapping. */
8
+ id: string
9
+ /** PascalCase TypeScript identifier for the emitted model (disambiguated on collision). */
10
+ name: string
11
+ /** The model's JSON Schema (first-seen copy). */
12
+ schema: Record<string, unknown>
13
+ }
14
+
15
+ /** Maps a model `$id` to an external import (module + exported name). */
16
+ export interface SharedTypeImport {
17
+ module: string
18
+ name: string
19
+ }
20
+
21
+ /** `$id` → external import. Models with a matching entry are imported rather than generated. */
22
+ export type SharedTypesImportMap = Record<string, SharedTypeImport>
23
+
24
+ /** A {@link CollectedModel} tagged with its external import, if one was configured. */
25
+ export interface ResolvedModel extends CollectedModel {
26
+ import?: SharedTypeImport
27
+ }
28
+
29
+ /** Produces a stable structural key for collision detection (key order does not matter). */
30
+ function structuralKey(value: unknown): string {
31
+ return JSON.stringify(value, (_k, v) => {
32
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
33
+ const sorted: Record<string, unknown> = {}
34
+ for (const k of Object.keys(v as Record<string, unknown>).sort()) {
35
+ sorted[k] = (v as Record<string, unknown>)[k]
36
+ }
37
+ return sorted
38
+ }
39
+ return v
40
+ })
41
+ }
42
+
43
+ /** Derives a base PascalCase name from a schema's `title`, falling back to the last `$id` segment. */
44
+ function deriveBaseName(schema: Record<string, unknown>, id: string): string {
45
+ const title = typeof schema.title === 'string' ? schema.title : undefined
46
+ const source = title ?? id.split(/[/:#?]+/).filter(Boolean).pop() ?? id
47
+ const pascal = toPascalCase(source)
48
+ return pascal.length > 0 ? pascal : 'Model'
49
+ }
50
+
51
+ /**
52
+ * Deep-walks every route's `jsonSchema` and collects each subschema carrying a
53
+ * string `$id` exactly once (deduped by `$id`, first-seen order preserved).
54
+ *
55
+ * - Names are derived from `title` (or the last `$id` segment), PascalCased.
56
+ * - Distinct `$id`s that derive the same name are disambiguated deterministically
57
+ * by first-seen order (`Message`, `Message2`, `Message3`, …).
58
+ * - Two nodes sharing an `$id` but differing structurally throw (the `$id` is a
59
+ * broken identity contract).
60
+ */
61
+ export function collectModels(routes: AnyHttpRouteDoc[]): CollectedModel[] {
62
+ const byId = new Map<string, { schema: Record<string, unknown>; key: string }>()
63
+ const order: string[] = []
64
+
65
+ const visit = (obj: Record<string, unknown>): void => {
66
+ if (typeof obj.$id === 'string') {
67
+ const id = obj.$id
68
+ const key = structuralKey(obj)
69
+ const existing = byId.get(id)
70
+ if (existing) {
71
+ if (existing.key !== key) {
72
+ throw new Error(
73
+ `[ts-procedures-codegen] Conflicting schemas share $id "${id}". A $id must identify a single structural shape; found two divergent definitions.`
74
+ )
75
+ }
76
+ } else {
77
+ byId.set(id, { schema: obj, key })
78
+ order.push(id)
79
+ }
80
+ }
81
+ }
82
+
83
+ for (const route of routes) walkSubschemas(route.jsonSchema, visit)
84
+
85
+ const usedNames = new Map<string, number>()
86
+ return order.map((id) => {
87
+ const { schema } = byId.get(id)!
88
+ const base = deriveBaseName(schema, id)
89
+ const seen = usedNames.get(base) ?? 0
90
+ usedNames.set(base, seen + 1)
91
+ const name = seen === 0 ? base : `${base}${seen + 1}`
92
+ return { id, name, schema }
93
+ })
94
+ }
95
+
96
+ /**
97
+ * Tags each collected model with its external import when its `$id` is a key in
98
+ * the import map; models without a mapping keep `import` undefined (generated locally).
99
+ */
100
+ export function resolveModelImports(
101
+ models: CollectedModel[],
102
+ map: SharedTypesImportMap = {}
103
+ ): ResolvedModel[] {
104
+ return models.map((model) => {
105
+ const mapped = map[model.id]
106
+ return mapped ? { ...model, import: mapped } : { ...model }
107
+ })
108
+ }
@@ -18,6 +18,7 @@ const TYPES_IMPORT = `import type {
18
18
  ClientInstance,
19
19
  ProcedureCallDefaults,
20
20
  ProcedureCallOptions,
21
+ ClientHeadersInit,
21
22
  CreateClientConfig,
22
23
  RequestMeta,
23
24
  ErrorRegistry,