@workos/oagen-emitters 0.0.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/.github/workflows/release-please.yml +9 -1
  2. package/.husky/commit-msg +0 -0
  3. package/.husky/pre-commit +1 -0
  4. package/.husky/pre-push +1 -0
  5. package/.oxfmtrc.json +8 -1
  6. package/.prettierignore +1 -0
  7. package/.release-please-manifest.json +3 -0
  8. package/.vscode/settings.json +3 -0
  9. package/CHANGELOG.md +61 -0
  10. package/README.md +2 -2
  11. package/dist/index.d.mts +7 -0
  12. package/dist/index.d.mts.map +1 -0
  13. package/dist/index.mjs +4070 -0
  14. package/dist/index.mjs.map +1 -0
  15. package/package.json +14 -18
  16. package/release-please-config.json +11 -0
  17. package/smoke/sdk-dotnet.ts +17 -3
  18. package/smoke/sdk-elixir.ts +17 -3
  19. package/smoke/sdk-go.ts +21 -4
  20. package/smoke/sdk-kotlin.ts +23 -4
  21. package/smoke/sdk-node.ts +15 -3
  22. package/smoke/sdk-ruby.ts +17 -3
  23. package/smoke/sdk-rust.ts +16 -3
  24. package/src/node/client.ts +521 -206
  25. package/src/node/common.ts +74 -4
  26. package/src/node/config.ts +1 -0
  27. package/src/node/enums.ts +53 -9
  28. package/src/node/errors.ts +82 -3
  29. package/src/node/fixtures.ts +87 -16
  30. package/src/node/index.ts +66 -10
  31. package/src/node/manifest.ts +4 -2
  32. package/src/node/models.ts +251 -124
  33. package/src/node/naming.ts +107 -3
  34. package/src/node/resources.ts +1162 -108
  35. package/src/node/serializers.ts +512 -52
  36. package/src/node/tests.ts +650 -110
  37. package/src/node/type-map.ts +89 -11
  38. package/src/node/utils.ts +426 -113
  39. package/test/node/client.test.ts +1083 -20
  40. package/test/node/enums.test.ts +73 -4
  41. package/test/node/errors.test.ts +4 -21
  42. package/test/node/models.test.ts +499 -5
  43. package/test/node/naming.test.ts +14 -7
  44. package/test/node/resources.test.ts +1568 -9
  45. package/test/node/serializers.test.ts +241 -5
  46. package/tsconfig.json +2 -3
  47. package/{tsup.config.ts → tsdown.config.ts} +1 -1
  48. package/dist/index.d.ts +0 -5
  49. package/dist/index.js +0 -2158
@@ -1,20 +1,21 @@
1
- import type { ApiSpec, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
1
+ import type { ApiSpec, AuthScheme, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
2
+ import { fileName, resolveServiceDir, servicePropertyName, resolveInterfaceName, wireInterfaceName } from './naming.js';
2
3
  import {
3
- fileName,
4
- serviceDirName,
5
- servicePropertyName,
6
- resolveInterfaceName,
7
- resolveServiceName,
8
- buildServiceNameMap,
9
- wireInterfaceName,
10
- } from './naming.js';
11
- import { assignModelsToServices } from './utils.js';
4
+ docComment,
5
+ createServiceDirResolver,
6
+ isServiceCoveredByExisting,
7
+ isListMetadataModel,
8
+ isListWrapperModel,
9
+ } from './utils.js';
10
+ import { resolveResourceClassName } from './resources.js';
12
11
 
13
12
  export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
14
13
  const files: GeneratedFile[] = [];
15
14
 
16
15
  files.push(generateWorkOSClient(spec, ctx));
16
+ files.push(...generateServiceBarrels(spec, ctx));
17
17
  files.push(generateBarrel(spec, ctx));
18
+ files.push(generateWorkerBarrel(spec, ctx));
18
19
  files.push(generatePackageJson(ctx));
19
20
  files.push(generateTsConfig());
20
21
 
@@ -24,202 +25,265 @@ export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFil
24
25
  function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
25
26
  const lines: string[] = [];
26
27
 
27
- // Service imports
28
+ // Only import WorkOSBase for fresh generation (no existing WorkOS class).
29
+ // When integrating into an existing SDK, the existing WorkOS already has its
30
+ // own base class and the WorkOSBase file may not exist.
31
+ const hasExistingWorkOS = !!ctx.apiSurface?.classes?.['WorkOS'];
32
+ if (!hasExistingWorkOS) {
33
+ lines.push("import { WorkOSBase } from './common/workos-base';");
34
+ }
35
+
36
+ // Filter out services whose endpoints are already covered by existing
37
+ // hand-written service classes (e.g., Connections covered by SSO).
38
+ const coveredServices = new Set<string>();
28
39
  for (const service of spec.services) {
29
- const resolvedName = resolveServiceName(service, ctx);
30
- const serviceDir = serviceDirName(resolvedName);
31
- lines.push(`import { ${resolvedName} } from './${serviceDir}/${fileName(resolvedName)}';`);
40
+ if (isServiceCoveredByExisting(service, ctx)) {
41
+ coveredServices.add(service.name);
42
+ }
32
43
  }
33
44
 
34
- lines.push("import type { WorkOSOptions } from './common/interfaces/workos-options.interface';");
35
- lines.push("import type { PostOptions } from './common/interfaces/post-options.interface';");
36
- lines.push("import type { GetOptions } from './common/interfaces/get-options.interface';");
37
- lines.push("import { NoApiKeyProvidedException } from './common/exceptions/no-api-key-provided.exception';");
38
- lines.push("import { UnauthorizedException } from './common/exceptions/unauthorized.exception';");
39
- lines.push("import { NotFoundException } from './common/exceptions/not-found.exception';");
40
- lines.push("import { ConflictException } from './common/exceptions/conflict.exception';");
41
- lines.push("import { UnprocessableEntityException } from './common/exceptions/unprocessable-entity.exception';");
42
- lines.push("import { RateLimitExceededException } from './common/exceptions/rate-limit-exceeded.exception';");
43
- lines.push("import { GenericServerException } from './common/exceptions/generic-server.exception';");
44
- lines.push("import { BadRequestException } from './common/exceptions/bad-request.exception';");
45
+ // Service imports skip covered services
46
+ for (const service of spec.services) {
47
+ if (coveredServices.has(service.name)) continue;
48
+ const resolvedName = resolveResourceClassName(service, ctx);
49
+ const serviceDir = resolveServiceDir(resolvedName);
50
+ lines.push(`import { ${resolvedName} } from './${serviceDir}/${fileName(resolvedName)}';`);
51
+ }
45
52
 
46
53
  lines.push('');
47
- lines.push('export class WorkOS {');
48
- lines.push(' readonly baseURL: string;');
49
- lines.push(' readonly key: string;');
50
- lines.push(' private readonly options: WorkOSOptions;');
51
- lines.push('');
54
+ if (spec.description) {
55
+ lines.push(...docComment(spec.description));
56
+ }
57
+ const extendsClause = hasExistingWorkOS ? '' : ' extends WorkOSBase';
58
+ lines.push(`export class WorkOS${extendsClause} {`);
59
+
60
+ // Server URL constants from spec.servers
61
+ if (spec.servers && spec.servers.length > 0) {
62
+ for (const server of spec.servers) {
63
+ const constName = serverConstName(server.description ?? server.url);
64
+ if (server.description) {
65
+ lines.push(...docComment(server.description, 2));
66
+ }
67
+ lines.push(` static readonly ${constName} = '${server.url}';`);
68
+ }
69
+ lines.push('');
70
+ }
52
71
 
53
- // Resource accessors
72
+ // Resource accessors — skip services whose property already exists
73
+ // in the baseline WorkOS class (e.g., `portal` covers AdminPortal,
74
+ // `mfa` covers MultiFactorAuth).
75
+ const existingProps = new Set<string>();
76
+ const baselineWorkOS = ctx.apiSurface?.classes?.['WorkOS'] ?? ctx.apiSurface?.classes?.['WorkOSNode'];
77
+ if (baselineWorkOS?.properties) {
78
+ for (const name of Object.keys(baselineWorkOS.properties)) {
79
+ existingProps.add(name);
80
+ }
81
+ }
82
+ // Resource accessors — skip services whose endpoints are fully covered
83
+ // by existing hand-written services.
54
84
  for (const service of spec.services) {
55
- const resolvedName = resolveServiceName(service, ctx);
85
+ if (coveredServices.has(service.name)) continue;
86
+ const resolvedName = resolveResourceClassName(service, ctx);
56
87
  const propName = servicePropertyName(resolvedName);
88
+ if (existingProps.has(propName)) continue;
57
89
  lines.push(` readonly ${propName} = new ${resolvedName}(this);`);
58
90
  }
59
91
 
60
- lines.push('');
61
- lines.push(' constructor(keyOrOptions?: string | WorkOSOptions, maybeOptions?: WorkOSOptions) {');
62
- lines.push(" if (typeof keyOrOptions === 'object') {");
63
- lines.push(" this.key = keyOrOptions.apiKey ?? '';");
64
- lines.push(' this.options = keyOrOptions;');
65
- lines.push(' } else {');
66
- lines.push(" this.key = keyOrOptions ?? '';");
67
- lines.push(' this.options = maybeOptions ?? {};');
68
- lines.push(' }');
69
- lines.push('');
70
- lines.push(' if (!this.key) {');
71
- lines.push(" const envKey = typeof process !== 'undefined' ? process.env?.WORKOS_API_KEY : undefined;");
72
- lines.push(' if (envKey) this.key = envKey;');
73
- lines.push(' }');
74
- lines.push('');
75
- lines.push(" const protocol = this.options.https === false ? 'http' : 'https';");
76
- lines.push(" const hostname = this.options.apiHostname ?? 'api.workos.com';");
77
- lines.push(" const port = this.options.port ? `:${this.options.port}` : '';");
78
- lines.push(' this.baseURL = `${protocol}://${hostname}${port}`;');
79
- lines.push(' }');
92
+ // Auth override — only emit when auth is non-default (not bearer)
93
+ if (needsAuthOverride(spec.auth)) {
94
+ lines.push('');
95
+ lines.push(' protected override setAuthHeaders(headers: Record<string, string>): void {');
96
+ renderAuthOverride(lines, spec.auth!);
97
+ lines.push(' }');
98
+ }
80
99
 
81
- // HTTP methods
82
- lines.push('');
83
- lines.push(' async get<Result = any>(path: string, options: GetOptions = {}): Promise<{ data: Result }> {');
84
- lines.push(' this.ensureApiKey(options);');
85
- lines.push(' const url = this.buildUrl(path, options.query);');
86
- lines.push(' const response = await fetch(url, {');
87
- lines.push(" method: 'GET',");
88
- lines.push(' headers: this.buildHeaders(options),');
89
- lines.push(' });');
90
- lines.push(' await this.handleHttpError(response, path);');
91
- lines.push(' const data = await response.json() as Result;');
92
- lines.push(' return { data };');
93
- lines.push(' }');
100
+ lines.push('}');
94
101
 
95
- lines.push('');
96
- lines.push(
97
- ' async post<Result = any, Entity = any>(path: string, entity: Entity, options: PostOptions = {}): Promise<{ data: Result }> {',
98
- );
99
- lines.push(' this.ensureApiKey(options);');
100
- lines.push(' const url = this.buildUrl(path, options.query);');
101
- lines.push(' const response = await fetch(url, {');
102
- lines.push(" method: 'POST',");
103
- lines.push(' headers: this.buildHeaders(options),');
104
- lines.push(' body: JSON.stringify(entity),');
105
- lines.push(' });');
106
- lines.push(' await this.handleHttpError(response, path);');
107
- lines.push(' const data = await response.json() as Result;');
108
- lines.push(' return { data };');
109
- lines.push(' }');
102
+ return {
103
+ path: 'src/workos.ts',
104
+ content: lines.join('\n'),
105
+ skipIfExists: true,
106
+ };
107
+ }
110
108
 
111
- lines.push('');
112
- lines.push(
113
- ' async put<Result = any, Entity = any>(path: string, entity: Entity, options: PostOptions = {}): Promise<{ data: Result }> {',
114
- );
115
- lines.push(' this.ensureApiKey(options);');
116
- lines.push(' const url = this.buildUrl(path, options.query);');
117
- lines.push(' const response = await fetch(url, {');
118
- lines.push(" method: 'PUT',");
119
- lines.push(' headers: this.buildHeaders(options),');
120
- lines.push(' body: JSON.stringify(entity),');
121
- lines.push(' });');
122
- lines.push(' await this.handleHttpError(response, path);');
123
- lines.push(' const data = await response.json() as Result;');
124
- lines.push(' return { data };');
125
- lines.push(' }');
109
+ /**
110
+ * Generate per-service barrel files (interfaces/index.ts) that re-export
111
+ * all interface and enum files for each service directory. This reduces
112
+ * the root barrel from ~200+ individual type exports to one wildcard
113
+ * re-export per service.
114
+ */
115
+ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
116
+ const files: GeneratedFile[] = [];
117
+ const { modelToService, resolveDir } = createServiceDirResolver(spec.models, spec.services, ctx);
118
+
119
+ // Group interface files by directory, tracking exported symbol names
120
+ // to prevent TS2308 duplicate export errors when two files in the same
121
+ // directory export the same symbol (e.g., FooResponse as a wire type
122
+ // from one file and a domain type from another).
123
+ const dirExports = new Map<string, string[]>();
124
+ const dirSymbols = new Map<string, Set<string>>();
125
+
126
+ // Pre-seed dirSymbols with names already exported by existing interface files.
127
+ // When the existing SDK has an interface file in a directory that already
128
+ // exports a name (e.g., AuditLogSchema from create-audit-log-schema-options),
129
+ // the generated model with the same name must be skipped to prevent the
130
+ // merger from adding a duplicate `export *` that causes TS2308.
131
+ if (ctx.apiSurface?.interfaces) {
132
+ for (const [name, iface] of Object.entries(ctx.apiSurface.interfaces)) {
133
+ const sourceFile = (iface as any).sourceFile as string | undefined;
134
+ if (!sourceFile) continue;
135
+ // Match paths like "src/audit-logs/interfaces/foo.interface.ts" to directory "audit-logs"
136
+ const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
137
+ if (match) {
138
+ const dirName = match[1];
139
+ if (!dirSymbols.has(dirName)) {
140
+ dirSymbols.set(dirName, new Set());
141
+ }
142
+ dirSymbols.get(dirName)!.add(name);
143
+ }
144
+ }
145
+ }
146
+ if (ctx.apiSurface?.enums) {
147
+ for (const [name, enumDef] of Object.entries(ctx.apiSurface.enums)) {
148
+ const sourceFile = (enumDef as any).sourceFile as string | undefined;
149
+ if (!sourceFile) continue;
150
+ const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
151
+ if (match) {
152
+ const dirName = match[1];
153
+ if (!dirSymbols.has(dirName)) {
154
+ dirSymbols.set(dirName, new Set());
155
+ }
156
+ dirSymbols.get(dirName)!.add(name);
157
+ }
158
+ }
159
+ }
160
+ if (ctx.apiSurface?.typeAliases) {
161
+ for (const [name, alias] of Object.entries(ctx.apiSurface.typeAliases)) {
162
+ const sourceFile = (alias as any).sourceFile as string | undefined;
163
+ if (!sourceFile) continue;
164
+ const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
165
+ if (match) {
166
+ const dirName = match[1];
167
+ if (!dirSymbols.has(dirName)) {
168
+ dirSymbols.set(dirName, new Set());
169
+ }
170
+ dirSymbols.get(dirName)!.add(name);
171
+ }
172
+ }
173
+ }
126
174
 
127
- lines.push('');
128
- lines.push(
129
- ' async patch<Result = any, Entity = any>(path: string, entity: Entity, options: PostOptions = {}): Promise<{ data: Result }> {',
130
- );
131
- lines.push(' this.ensureApiKey(options);');
132
- lines.push(' const url = this.buildUrl(path, options.query);');
133
- lines.push(' const response = await fetch(url, {');
134
- lines.push(" method: 'PATCH',");
135
- lines.push(' headers: this.buildHeaders(options),');
136
- lines.push(' body: JSON.stringify(entity),');
137
- lines.push(' });');
138
- lines.push(' await this.handleHttpError(response, path);');
139
- lines.push(' const data = await response.json() as Result;');
140
- lines.push(' return { data };');
141
- lines.push(' }');
175
+ // Build a global set of all symbols across all directories for
176
+ // cross-directory deduplication. This prevents adding a model to one
177
+ // directory's barrel when the same symbol already exists in another
178
+ // directory's barrel (e.g., Event in common vs events, DirectoryState
179
+ // in directory-sync vs common).
180
+ const globalExistingSymbols = new Set<string>();
181
+ for (const symbols of dirSymbols.values()) {
182
+ for (const sym of symbols) {
183
+ globalExistingSymbols.add(sym);
184
+ }
185
+ }
142
186
 
143
- lines.push('');
144
- lines.push(' async delete(path: string, options: GetOptions = {}): Promise<void> {');
145
- lines.push(' this.ensureApiKey(options);');
146
- lines.push(' const url = this.buildUrl(path);');
147
- lines.push(' const response = await fetch(url, {');
148
- lines.push(" method: 'DELETE',");
149
- lines.push(' headers: this.buildHeaders(options),');
150
- lines.push(' });');
151
- lines.push(' await this.handleHttpError(response, path);');
152
- lines.push(' }');
153
-
154
- // Private helpers
155
- lines.push('');
156
- lines.push(' private buildUrl(path: string, query?: Record<string, any>): string {');
157
- lines.push(' const url = new URL(path, this.baseURL);');
158
- lines.push(' if (query) {');
159
- lines.push(' for (const [key, value] of Object.entries(query)) {');
160
- lines.push(" if (value !== null && value !== undefined && value !== '') {");
161
- lines.push(' url.searchParams.set(key, String(value));');
162
- lines.push(' }');
163
- lines.push(' }');
164
- lines.push(' }');
165
- lines.push(' return url.toString();');
166
- lines.push(' }');
187
+ // Models -> service directories
188
+ // Skip list wrapper and list metadata models — they use shared List<T>/ListMetadata
189
+ // from common utils, so no per-resource interface file is generated.
190
+ for (const model of spec.models) {
191
+ if (isListMetadataModel(model) || isListWrapperModel(model)) continue;
192
+ const service = modelToService.get(model.name);
193
+ const dirName = resolveDir(service);
194
+ if (!dirExports.has(dirName)) {
195
+ dirExports.set(dirName, []);
196
+ // Only initialize dirSymbols if not already pre-seeded from baseline
197
+ if (!dirSymbols.has(dirName)) {
198
+ dirSymbols.set(dirName, new Set());
199
+ }
200
+ }
167
201
 
168
- lines.push('');
169
- lines.push(' private buildHeaders(options: any = {}): Record<string, string> {');
170
- lines.push(' const headers: Record<string, string> = {');
171
- lines.push(" 'Content-Type': 'application/json',");
172
- lines.push(` Authorization: \`Bearer \${this.key}\`,`);
173
- lines.push(' };');
174
- lines.push(" if (options.idempotencyKey) headers['Idempotency-Key'] = options.idempotencyKey;");
175
- lines.push(" if (options.warrantToken) headers['Warrant-Token'] = options.warrantToken;");
176
- lines.push(' return headers;');
177
- lines.push(' }');
202
+ // Each model file exports a domain interface and a wire interface.
203
+ // Track these symbols to detect cross-file collisions.
204
+ const domainName = resolveInterfaceName(model.name, ctx);
205
+ const wireName = wireInterfaceName(domainName);
206
+ const symbols = dirSymbols.get(dirName)!;
178
207
 
179
- lines.push('');
180
- lines.push(' private ensureApiKey(options: any = {}): void {');
181
- lines.push(' if (!this.key && !options.skipApiKeyCheck) {');
182
- lines.push(' throw new NoApiKeyProvidedException();');
183
- lines.push(' }');
184
- lines.push(' }');
208
+ if (globalExistingSymbols.has(domainName) || globalExistingSymbols.has(wireName)) {
209
+ // Skip this model's export to avoid duplicate symbol in the barrel
210
+ // (checks across ALL directories, not just the target one)
211
+ continue;
212
+ }
185
213
 
186
- lines.push('');
187
- lines.push(' private async handleHttpError(response: Response, path: string): Promise<void> {');
188
- lines.push(' if (response.ok) return;');
189
- lines.push('');
190
- lines.push(" const requestID = response.headers.get('x-request-id') ?? '';");
191
- lines.push(' let data: any = {};');
192
- lines.push(' try { data = await response.json(); } catch {}');
193
- lines.push(' const { message, code, errors } = data;');
194
- lines.push('');
195
- lines.push(' switch (response.status) {');
196
- lines.push(' case 400: throw new BadRequestException({ code, message, requestID });');
197
- lines.push(' case 401: throw new UnauthorizedException(requestID);');
198
- lines.push(' case 404: throw new NotFoundException({ code, message, path, requestID });');
199
- lines.push(' case 409: throw new ConflictException({ message, requestID });');
200
- lines.push(' case 422: throw new UnprocessableEntityException({ code, errors, message, requestID });');
201
- lines.push(' case 429: {');
202
- lines.push(" const retryAfter = Number(response.headers.get('retry-after')) || undefined;");
203
- lines.push(" throw new RateLimitExceededException(message ?? 'Too many requests', requestID, retryAfter);");
204
- lines.push(' }');
205
- lines.push(" default: throw new GenericServerException(response.status, message ?? 'Server error', requestID);");
206
- lines.push(' }');
207
- lines.push(' }');
214
+ symbols.add(domainName);
215
+ symbols.add(wireName);
216
+ // Also track in the global set so subsequent models in other directories
217
+ // don't re-export the same symbol (intra-generation cross-directory dedup).
218
+ globalExistingSymbols.add(domainName);
219
+ globalExistingSymbols.add(wireName);
220
+ dirExports.get(dirName)!.push(`export * from './${fileName(model.name)}.interface';`);
221
+ }
208
222
 
209
- lines.push('}');
223
+ // Enums -> service directories
224
+ for (const enumDef of spec.enums) {
225
+ const enumService = findEnumService(enumDef.name, spec.services);
226
+ const dirName = resolveDir(enumService);
227
+ if (!dirExports.has(dirName)) {
228
+ dirExports.set(dirName, []);
229
+ if (!dirSymbols.has(dirName)) {
230
+ dirSymbols.set(dirName, new Set());
231
+ }
232
+ }
210
233
 
211
- return { path: 'src/workos.ts', content: lines.join('\n'), skipIfExists: true, integrateTarget: false };
212
- }
234
+ const symbols = dirSymbols.get(dirName)!;
235
+ if (globalExistingSymbols.has(enumDef.name)) continue;
213
236
 
214
- // Names exported from common utilities that must not be re-exported from model interfaces
215
- const RESERVED_BARREL_NAMES = new Set(['List', 'ListResponse', 'ListMetadata', 'AutoPaginatable', 'PaginationOptions']);
237
+ symbols.add(enumDef.name);
238
+ globalExistingSymbols.add(enumDef.name);
239
+ dirExports.get(dirName)!.push(`export * from './${fileName(enumDef.name)}.interface';`);
240
+ }
241
+
242
+ for (const [dirName, exports] of dirExports) {
243
+ // Deduplicate (an enum and model could theoretically share a file name)
244
+ const uniqueExports = [...new Set(exports)];
245
+ uniqueExports.sort();
246
+ files.push({
247
+ path: `src/${dirName}/interfaces/index.ts`,
248
+ content: uniqueExports.join('\n'),
249
+ skipIfExists: true,
250
+ });
251
+ }
252
+
253
+ return files;
254
+ }
216
255
 
217
256
  function generateBarrel(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
218
257
  const lines: string[] = [];
219
- const modelToService = assignModelsToServices(spec.models, spec.services);
220
- const serviceNameMap = buildServiceNameMap(spec.services, ctx);
221
- const resolveDir = (irService: string | undefined) =>
222
- irService ? serviceDirName(serviceNameMap.get(irService) ?? irService) : 'common';
258
+ const { modelToService, resolveDir } = createServiceDirResolver(spec.models, spec.services, ctx);
259
+
260
+ // Track all exported names to prevent duplicates.
261
+ // Pre-seed with names already exported by the existing SDK to avoid generating
262
+ // duplicate exports that would conflict with existing `export *` statements.
263
+ const exportedNames = new Set<string>();
264
+ if (ctx.apiSurface?.interfaces) {
265
+ for (const name of Object.keys(ctx.apiSurface.interfaces)) {
266
+ exportedNames.add(name);
267
+ }
268
+ }
269
+ if (ctx.apiSurface?.classes) {
270
+ for (const name of Object.keys(ctx.apiSurface.classes)) {
271
+ exportedNames.add(name);
272
+ }
273
+ }
274
+
275
+ // Collect names already exported by the existing SDK (via export * or named exports).
276
+ // When an explicit `export type { Foo }` would shadow a wildcard re-export that
277
+ // already provides a hand-written version of Foo (e.g., a discriminated union),
278
+ // we must skip the explicit export so the wildcard wins.
279
+ const existingSdkExports = new Set<string>();
280
+ if (ctx.apiSurface?.exports) {
281
+ for (const names of Object.values(ctx.apiSurface.exports)) {
282
+ for (const name of names) {
283
+ existingSdkExports.add(name);
284
+ }
285
+ }
286
+ }
223
287
 
224
288
  // Common exports
225
289
  lines.push("export * from './common/exceptions';");
@@ -230,49 +294,254 @@ function generateBarrel(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
230
294
  lines.push("export type { PostOptions } from './common/interfaces/post-options.interface';");
231
295
  lines.push("export type { GetOptions } from './common/interfaces/get-options.interface';");
232
296
  lines.push('');
297
+ for (const name of [
298
+ 'AutoPaginatable',
299
+ 'List',
300
+ 'ListMetadata',
301
+ 'ListResponse',
302
+ 'PaginationOptions',
303
+ 'WorkOSOptions',
304
+ 'PostOptions',
305
+ 'GetOptions',
306
+ ]) {
307
+ exportedNames.add(name);
308
+ }
233
309
 
234
- // Per-service exports: interfaces + resource class
310
+ // Identify services whose endpoints are fully covered by existing hand-written
311
+ // classes — their resource class should not be re-exported from the barrel.
312
+ const coveredServicesBarrel = new Set<string>();
235
313
  for (const service of spec.services) {
236
- const resolvedName = resolveServiceName(service, ctx);
237
- const serviceDir = serviceDirName(resolvedName);
314
+ if (isServiceCoveredByExisting(service, ctx)) {
315
+ coveredServicesBarrel.add(service.name);
316
+ }
317
+ }
238
318
 
239
- // Collect models that belong to this service, skipping reserved names
240
- const serviceModels = spec.models.filter((m) => modelToService.get(m.name) === service.name);
241
- for (const model of serviceModels) {
242
- const name = resolveInterfaceName(model.name, ctx);
243
- const wireName = wireInterfaceName(name);
244
- if (RESERVED_BARREL_NAMES.has(name) || RESERVED_BARREL_NAMES.has(wireName)) continue;
245
- lines.push(
246
- `export type { ${name}, ${wireName} } from './${serviceDir}/interfaces/${fileName(model.name)}.interface';`,
247
- );
319
+ // Track directories that have already been wildcard-exported
320
+ const exportedDirs = new Set<string>();
321
+
322
+ // Pre-compute all names per interfaces directory (generated + baseline) to detect
323
+ // cross-directory conflicts before emitting any star exports. A star export is
324
+ // unsafe when two different directories export the same name (e.g., Factor in
325
+ // both mfa/interfaces and user-management/interfaces).
326
+ const dirAllNames = new Map<string, Set<string>>();
327
+ for (const service of spec.services) {
328
+ const iDir = resolveDir(service.name);
329
+ if (!dirAllNames.has(iDir)) dirAllNames.set(iDir, new Set());
330
+ const names = dirAllNames.get(iDir)!;
331
+ for (const model of spec.models) {
332
+ if (modelToService.get(model.name) !== service.name) continue;
333
+ if (isListMetadataModel(model) || isListWrapperModel(model)) continue;
334
+ names.add(resolveInterfaceName(model.name, ctx));
335
+ names.add(wireInterfaceName(resolveInterfaceName(model.name, ctx)));
336
+ }
337
+ }
338
+ // Add baseline names per directory
339
+ if (ctx.apiSurface?.interfaces) {
340
+ for (const [name, iface] of Object.entries(ctx.apiSurface.interfaces)) {
341
+ const sourceFile = (iface as any).sourceFile as string | undefined;
342
+ if (!sourceFile) continue;
343
+ const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
344
+ if (match) {
345
+ const dirName = match[1];
346
+ if (!dirAllNames.has(dirName)) dirAllNames.set(dirName, new Set());
347
+ dirAllNames.get(dirName)!.add(name);
348
+ }
349
+ }
350
+ }
351
+ if (ctx.apiSurface?.typeAliases) {
352
+ for (const [name, alias] of Object.entries(ctx.apiSurface.typeAliases)) {
353
+ const sourceFile = (alias as any).sourceFile as string | undefined;
354
+ if (!sourceFile) continue;
355
+ const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
356
+ if (match) {
357
+ const dirName = match[1];
358
+ if (!dirAllNames.has(dirName)) dirAllNames.set(dirName, new Set());
359
+ dirAllNames.get(dirName)!.add(name);
360
+ }
361
+ }
362
+ }
363
+ // Detect directories with cross-directory name conflicts
364
+ const unsafeStarDirs = new Set<string>();
365
+ const allDirEntries = [...dirAllNames.entries()];
366
+ for (let i = 0; i < allDirEntries.length; i++) {
367
+ for (let j = i + 1; j < allDirEntries.length; j++) {
368
+ const [dirA, namesA] = allDirEntries[i];
369
+ const [dirB, namesB] = allDirEntries[j];
370
+ for (const name of namesA) {
371
+ if (namesB.has(name)) {
372
+ unsafeStarDirs.add(dirA);
373
+ unsafeStarDirs.add(dirB);
374
+ break;
375
+ }
376
+ }
377
+ }
378
+ }
379
+
380
+ // Per-service exports: service barrel + resource class
381
+ for (const service of spec.services) {
382
+ const resolvedName = resolveResourceClassName(service, ctx);
383
+ const serviceDir = resolveServiceDir(resolvedName);
384
+ // The interfaces directory may differ from the resource class directory when
385
+ // a service's class name is remapped (e.g., WebhooksEndpoints class lives in
386
+ // webhooks-endpoints/ but its model interfaces live in webhooks/).
387
+ const interfacesDir = resolveDir(service.name);
388
+
389
+ // Check if this service has any models or enums (i.e., a barrel was generated).
390
+ // Exclude list wrapper and list metadata models — these are skipped during
391
+ // interface generation (they use shared List<T>/ListMetadata), so they don't
392
+ // have corresponding .interface.ts files in the output.
393
+ const serviceModels = spec.models.filter((m) => {
394
+ if (modelToService.get(m.name) !== service.name) return false;
395
+ if (isListMetadataModel(m) || isListWrapperModel(m)) return false;
396
+ return true;
397
+ });
398
+ const serviceEnums = spec.enums.filter((e) => {
399
+ const enumService = findEnumService(e.name, spec.services);
400
+ return enumService === service.name;
401
+ });
402
+
403
+ // Check whether any model or enum in this service conflicts with names already
404
+ // exported (from earlier star exports or the existing SDK baseline). If so, fall
405
+ // back to individual named exports to avoid duplicate-export TS2308 errors.
406
+ const hasConflict =
407
+ serviceModels.some((m) => {
408
+ const name = resolveInterfaceName(m.name, ctx);
409
+ return existingSdkExports.has(name) || exportedNames.has(name) || exportedNames.has(wireInterfaceName(name));
410
+ }) || serviceEnums.some((e) => existingSdkExports.has(e.name) || exportedNames.has(e.name));
411
+
412
+ // Skip star export for covered services — their directory may have hand-written types
413
+ // (e.g., Factor in mfa/) that conflict with types in the covering service's directory.
414
+ const isCovered = coveredServicesBarrel.has(service.name);
415
+ if (
416
+ (serviceModels.length > 0 || serviceEnums.length > 0) &&
417
+ !exportedDirs.has(interfacesDir) &&
418
+ !hasConflict &&
419
+ !unsafeStarDirs.has(interfacesDir) &&
420
+ !isCovered
421
+ ) {
422
+ exportedDirs.add(interfacesDir);
423
+ lines.push(`export * from './${interfacesDir}/interfaces';`);
424
+ // Track the individual names so they don't get re-exported below
425
+ for (const model of serviceModels) {
426
+ exportedNames.add(resolveInterfaceName(model.name, ctx));
427
+ exportedNames.add(wireInterfaceName(resolveInterfaceName(model.name, ctx)));
428
+ }
429
+ for (const enumDef of serviceEnums) {
430
+ exportedNames.add(enumDef.name);
431
+ }
432
+ } else if (!hasConflict) {
433
+ // Fallback: emit individual model exports (e.g., when no models/enums exist)
434
+ for (const model of serviceModels) {
435
+ const name = resolveInterfaceName(model.name, ctx);
436
+ const wireName = wireInterfaceName(name);
437
+ if (exportedNames.has(name) || exportedNames.has(wireName)) continue;
438
+ if (existingSdkExports.has(name)) continue;
439
+ exportedNames.add(name);
440
+ exportedNames.add(wireName);
441
+ lines.push(
442
+ `export type { ${name}, ${wireName} } from './${interfacesDir}/interfaces/${fileName(model.name)}.interface';`,
443
+ );
444
+ }
248
445
  }
249
446
 
250
- // Resource class
251
- lines.push(`export { ${resolvedName} } from './${serviceDir}/${fileName(resolvedName)}';`);
447
+ // Resource class — skip if already exported or if service is fully covered
448
+ // by existing hand-written classes
449
+ if (coveredServicesBarrel.has(service.name)) {
450
+ // Emit a comment indicating this service is covered by an existing class
451
+ lines.push(`// ${resolvedName} is covered by an existing hand-written class — not re-exported.`);
452
+ } else if (!exportedNames.has(resolvedName)) {
453
+ exportedNames.add(resolvedName);
454
+ lines.push(`export { ${resolvedName} } from './${serviceDir}/${fileName(resolvedName)}';`);
455
+ }
252
456
  lines.push('');
253
457
  }
254
458
 
255
- // Unassigned models (common), skipping reserved names
459
+ // Unassigned models (common) use barrel if any exist
256
460
  const unassignedModels = spec.models.filter((m) => !modelToService.has(m.name));
257
- for (const model of unassignedModels) {
258
- const name = resolveInterfaceName(model.name, ctx);
259
- const wireName = wireInterfaceName(name);
260
- if (RESERVED_BARREL_NAMES.has(name) || RESERVED_BARREL_NAMES.has(wireName)) continue;
261
- lines.push(`export type { ${name}, ${wireName} } from './common/interfaces/${fileName(model.name)}.interface';`);
461
+ const commonEnums = spec.enums.filter((e) => {
462
+ const enumService = findEnumService(e.name, spec.services);
463
+ return !enumService;
464
+ });
465
+
466
+ const commonHasConflict =
467
+ unassignedModels.some((m) => existingSdkExports.has(resolveInterfaceName(m.name, ctx))) ||
468
+ commonEnums.some((e) => existingSdkExports.has(e.name));
469
+
470
+ if ((unassignedModels.length > 0 || commonEnums.length > 0) && !exportedDirs.has('common') && !commonHasConflict) {
471
+ exportedDirs.add('common');
472
+ lines.push("export * from './common/interfaces';");
473
+ for (const model of unassignedModels) {
474
+ exportedNames.add(resolveInterfaceName(model.name, ctx));
475
+ exportedNames.add(wireInterfaceName(resolveInterfaceName(model.name, ctx)));
476
+ }
477
+ for (const enumDef of commonEnums) {
478
+ exportedNames.add(enumDef.name);
479
+ }
480
+ } else {
481
+ // Fallback: individual model exports
482
+ for (const model of unassignedModels) {
483
+ const name = resolveInterfaceName(model.name, ctx);
484
+ const wireName = wireInterfaceName(name);
485
+ if (exportedNames.has(name) || exportedNames.has(wireName)) continue;
486
+ if (existingSdkExports.has(name)) continue;
487
+ exportedNames.add(name);
488
+ exportedNames.add(wireName);
489
+ lines.push(`export type { ${name}, ${wireName} } from './common/interfaces/${fileName(model.name)}.interface';`);
490
+ }
262
491
  }
263
492
 
264
- // Enum exports
493
+ // Enum exports — only for enums not already covered by a service/common barrel.
494
+ // Skip duplicates and names already covered by existing SDK wildcards.
495
+ // Use value export (`export { ... }`) for actual TS enums so consumers
496
+ // can use them as runtime values (e.g., ConnectionType.GoogleOAuth).
497
+ // Use type-only export (`export type { ... }`) for string literal unions.
265
498
  for (const enumDef of spec.enums) {
266
- // Find which service directory the enum landed in
499
+ if (exportedNames.has(enumDef.name)) continue;
500
+ if (existingSdkExports.has(enumDef.name)) continue;
501
+ exportedNames.add(enumDef.name);
267
502
  const enumService = findEnumService(enumDef.name, spec.services);
268
503
  const dir = resolveDir(enumService);
269
- lines.push(`export type { ${enumDef.name} } from './${dir}/interfaces/${fileName(enumDef.name)}.interface';`);
504
+ if (!exportedDirs.has(dir)) {
505
+ const baselineEnum = ctx.apiSurface?.enums?.[enumDef.name];
506
+ const exportKeyword = baselineEnum?.members ? 'export' : 'export type';
507
+ lines.push(
508
+ `${exportKeyword} { ${enumDef.name} } from './${dir}/interfaces/${fileName(enumDef.name)}.interface';`,
509
+ );
510
+ }
270
511
  }
271
512
 
272
513
  lines.push('');
273
- lines.push("export { WorkOS } from './workos';");
514
+ // Only emit the WorkOS re-export for standalone generation (no existing SDK).
515
+ // When integrating into an existing SDK, the existing barrel already exports
516
+ // WorkOS (often as a subclass alias like `export { WorkOSNode as WorkOS }`),
517
+ // and adding a second export with the same name causes a duplicate identifier error.
518
+ if (!ctx.apiSurface && !exportedNames.has('WorkOS')) {
519
+ exportedNames.add('WorkOS');
520
+ lines.push("export { WorkOS } from './workos';");
521
+ }
522
+
523
+ return {
524
+ path: 'src/index.ts',
525
+ content: lines.join('\n'),
526
+ skipIfExists: true,
527
+ };
528
+ }
529
+
530
+ /**
531
+ * Generate a worker-compatible barrel file that re-exports everything from
532
+ * the main barrel. This keeps type exports in sync automatically.
533
+ */
534
+ function generateWorkerBarrel(_spec: ApiSpec, _ctx: EmitterContext): GeneratedFile {
535
+ const lines: string[] = [];
274
536
 
275
- return { path: 'src/index.ts', content: lines.join('\n'), skipIfExists: true };
537
+ // Re-export everything from the main index keeps type exports in sync
538
+ lines.push("export * from './index';");
539
+
540
+ return {
541
+ path: 'src/index.worker.ts',
542
+ content: lines.join('\n'),
543
+ skipIfExists: true,
544
+ };
276
545
  }
277
546
 
278
547
  function findEnumService(enumName: string, services: Service[]): string | undefined {
@@ -297,6 +566,51 @@ function findEnumService(enumName: string, services: Service[]): string | undefi
297
566
  return undefined;
298
567
  }
299
568
 
569
+ /**
570
+ * Determine whether the spec's auth scheme requires overriding the
571
+ * default bearer auth in WorkOSBase.setAuthHeaders().
572
+ */
573
+ function needsAuthOverride(auth?: AuthScheme[]): boolean {
574
+ if (!auth || auth.length === 0) return false;
575
+ const scheme = auth[0];
576
+ // bearer and oauth2 match the base class default — no override needed
577
+ return scheme.kind === 'apiKey';
578
+ }
579
+
580
+ /**
581
+ * Render the body of a setAuthHeaders override for non-default auth schemes.
582
+ * Only called when needsAuthOverride() returns true.
583
+ */
584
+ function renderAuthOverride(lines: string[], auth: AuthScheme[]): void {
585
+ const scheme = auth[0];
586
+ if (scheme.kind !== 'apiKey') return;
587
+ switch (scheme.in) {
588
+ case 'header':
589
+ lines.push(` headers['${scheme.name}'] = this.key;`);
590
+ break;
591
+ case 'query':
592
+ lines.push(` // Auth key sent as query parameter '${scheme.name}' (see buildUrl)`);
593
+ break;
594
+ case 'cookie':
595
+ lines.push(` headers['Cookie'] = \`${scheme.name}=\${this.key}\`;`);
596
+ break;
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Convert a server description or URL into a SCREAMING_SNAKE_CASE constant name.
602
+ */
603
+ function serverConstName(description: string): string {
604
+ return (
605
+ 'SERVER_' +
606
+ description
607
+ .replace(/https?:\/\//g, '')
608
+ .replace(/[^a-zA-Z0-9]+/g, '_')
609
+ .replace(/^_|_$/g, '')
610
+ .toUpperCase()
611
+ );
612
+ }
613
+
300
614
  function generatePackageJson(ctx: EmitterContext): GeneratedFile {
301
615
  const pkg = {
302
616
  name: `@${ctx.namespace}/sdk`,
@@ -336,6 +650,7 @@ function generateTsConfig(): GeneratedFile {
336
650
  lib: ['ES2020'],
337
651
  declaration: true,
338
652
  strict: true,
653
+ exactOptionalPropertyTypes: true,
339
654
  esModuleInterop: true,
340
655
  skipLibCheck: true,
341
656
  forceConsistentCasingInFileNames: true,