@workos/oagen-emitters 0.0.1 → 0.2.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 (41) 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/.prettierignore +1 -0
  6. package/.release-please-manifest.json +3 -0
  7. package/.vscode/settings.json +3 -0
  8. package/CHANGELOG.md +54 -0
  9. package/README.md +2 -2
  10. package/dist/index.d.mts +7 -0
  11. package/dist/index.d.mts.map +1 -0
  12. package/dist/index.mjs +3522 -0
  13. package/dist/index.mjs.map +1 -0
  14. package/package.json +14 -18
  15. package/release-please-config.json +11 -0
  16. package/src/node/client.ts +437 -204
  17. package/src/node/common.ts +74 -4
  18. package/src/node/config.ts +1 -0
  19. package/src/node/enums.ts +50 -6
  20. package/src/node/errors.ts +78 -3
  21. package/src/node/fixtures.ts +84 -15
  22. package/src/node/index.ts +2 -2
  23. package/src/node/manifest.ts +4 -2
  24. package/src/node/models.ts +195 -79
  25. package/src/node/naming.ts +16 -1
  26. package/src/node/resources.ts +721 -106
  27. package/src/node/serializers.ts +510 -52
  28. package/src/node/tests.ts +621 -105
  29. package/src/node/type-map.ts +89 -11
  30. package/src/node/utils.ts +377 -114
  31. package/test/node/client.test.ts +979 -15
  32. package/test/node/enums.test.ts +0 -1
  33. package/test/node/errors.test.ts +4 -21
  34. package/test/node/models.test.ts +409 -2
  35. package/test/node/naming.test.ts +0 -3
  36. package/test/node/resources.test.ts +964 -7
  37. package/test/node/serializers.test.ts +212 -3
  38. package/tsconfig.json +2 -3
  39. package/{tsup.config.ts → tsdown.config.ts} +1 -1
  40. package/dist/index.d.ts +0 -5
  41. 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, serviceDirName, 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,261 @@ 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);
40
+ if (isServiceCoveredByExisting(service, ctx)) {
41
+ coveredServices.add(service.name);
42
+ }
43
+ }
44
+
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);
30
49
  const serviceDir = serviceDirName(resolvedName);
31
50
  lines.push(`import { ${resolvedName} } from './${serviceDir}/${fileName(resolvedName)}';`);
32
51
  }
33
52
 
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
-
46
- 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
53
  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 { path: 'src/workos.ts', content: lines.join('\n'), skipIfExists: true };
103
+ }
110
104
 
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(' }');
105
+ /**
106
+ * Generate per-service barrel files (interfaces/index.ts) that re-export
107
+ * all interface and enum files for each service directory. This reduces
108
+ * the root barrel from ~200+ individual type exports to one wildcard
109
+ * re-export per service.
110
+ */
111
+ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
112
+ const files: GeneratedFile[] = [];
113
+ const { modelToService, resolveDir } = createServiceDirResolver(spec.models, spec.services, ctx);
114
+
115
+ // Group interface files by directory, tracking exported symbol names
116
+ // to prevent TS2308 duplicate export errors when two files in the same
117
+ // directory export the same symbol (e.g., FooResponse as a wire type
118
+ // from one file and a domain type from another).
119
+ const dirExports = new Map<string, string[]>();
120
+ const dirSymbols = new Map<string, Set<string>>();
121
+
122
+ // Pre-seed dirSymbols with names already exported by existing interface files.
123
+ // When the existing SDK has an interface file in a directory that already
124
+ // exports a name (e.g., AuditLogSchema from create-audit-log-schema-options),
125
+ // the generated model with the same name must be skipped to prevent the
126
+ // merger from adding a duplicate `export *` that causes TS2308.
127
+ if (ctx.apiSurface?.interfaces) {
128
+ for (const [name, iface] of Object.entries(ctx.apiSurface.interfaces)) {
129
+ const sourceFile = (iface as any).sourceFile as string | undefined;
130
+ if (!sourceFile) continue;
131
+ // Match paths like "src/audit-logs/interfaces/foo.interface.ts" to directory "audit-logs"
132
+ const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
133
+ if (match) {
134
+ const dirName = match[1];
135
+ if (!dirSymbols.has(dirName)) {
136
+ dirSymbols.set(dirName, new Set());
137
+ }
138
+ dirSymbols.get(dirName)!.add(name);
139
+ }
140
+ }
141
+ }
142
+ if (ctx.apiSurface?.enums) {
143
+ for (const [name, enumDef] of Object.entries(ctx.apiSurface.enums)) {
144
+ const sourceFile = (enumDef as any).sourceFile as string | undefined;
145
+ if (!sourceFile) continue;
146
+ const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
147
+ if (match) {
148
+ const dirName = match[1];
149
+ if (!dirSymbols.has(dirName)) {
150
+ dirSymbols.set(dirName, new Set());
151
+ }
152
+ dirSymbols.get(dirName)!.add(name);
153
+ }
154
+ }
155
+ }
156
+ if (ctx.apiSurface?.typeAliases) {
157
+ for (const [name, alias] of Object.entries(ctx.apiSurface.typeAliases)) {
158
+ const sourceFile = (alias as any).sourceFile as string | undefined;
159
+ if (!sourceFile) continue;
160
+ const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
161
+ if (match) {
162
+ const dirName = match[1];
163
+ if (!dirSymbols.has(dirName)) {
164
+ dirSymbols.set(dirName, new Set());
165
+ }
166
+ dirSymbols.get(dirName)!.add(name);
167
+ }
168
+ }
169
+ }
126
170
 
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(' }');
171
+ // Build a global set of all symbols across all directories for
172
+ // cross-directory deduplication. This prevents adding a model to one
173
+ // directory's barrel when the same symbol already exists in another
174
+ // directory's barrel (e.g., Event in common vs events, DirectoryState
175
+ // in directory-sync vs common).
176
+ const globalExistingSymbols = new Set<string>();
177
+ for (const symbols of dirSymbols.values()) {
178
+ for (const sym of symbols) {
179
+ globalExistingSymbols.add(sym);
180
+ }
181
+ }
142
182
 
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(' }');
183
+ // Models -> service directories
184
+ // Skip list wrapper and list metadata models — they use shared List<T>/ListMetadata
185
+ // from common utils, so no per-resource interface file is generated.
186
+ for (const model of spec.models) {
187
+ if (isListMetadataModel(model) || isListWrapperModel(model)) continue;
188
+ const service = modelToService.get(model.name);
189
+ const dirName = resolveDir(service);
190
+ if (!dirExports.has(dirName)) {
191
+ dirExports.set(dirName, []);
192
+ // Only initialize dirSymbols if not already pre-seeded from baseline
193
+ if (!dirSymbols.has(dirName)) {
194
+ dirSymbols.set(dirName, new Set());
195
+ }
196
+ }
167
197
 
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(' }');
198
+ // Each model file exports a domain interface and a wire interface.
199
+ // Track these symbols to detect cross-file collisions.
200
+ const domainName = resolveInterfaceName(model.name, ctx);
201
+ const wireName = wireInterfaceName(domainName);
202
+ const symbols = dirSymbols.get(dirName)!;
178
203
 
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(' }');
204
+ if (globalExistingSymbols.has(domainName) || globalExistingSymbols.has(wireName)) {
205
+ // Skip this model's export to avoid duplicate symbol in the barrel
206
+ // (checks across ALL directories, not just the target one)
207
+ continue;
208
+ }
185
209
 
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(' }');
210
+ symbols.add(domainName);
211
+ symbols.add(wireName);
212
+ // Also track in the global set so subsequent models in other directories
213
+ // don't re-export the same symbol (intra-generation cross-directory dedup).
214
+ globalExistingSymbols.add(domainName);
215
+ globalExistingSymbols.add(wireName);
216
+ dirExports.get(dirName)!.push(`export * from './${fileName(model.name)}.interface';`);
217
+ }
208
218
 
209
- lines.push('}');
219
+ // Enums -> service directories
220
+ for (const enumDef of spec.enums) {
221
+ const enumService = findEnumService(enumDef.name, spec.services);
222
+ const dirName = resolveDir(enumService);
223
+ if (!dirExports.has(dirName)) {
224
+ dirExports.set(dirName, []);
225
+ if (!dirSymbols.has(dirName)) {
226
+ dirSymbols.set(dirName, new Set());
227
+ }
228
+ }
210
229
 
211
- return { path: 'src/workos.ts', content: lines.join('\n'), skipIfExists: true, integrateTarget: false };
212
- }
230
+ const symbols = dirSymbols.get(dirName)!;
231
+ if (globalExistingSymbols.has(enumDef.name)) continue;
232
+
233
+ symbols.add(enumDef.name);
234
+ globalExistingSymbols.add(enumDef.name);
235
+ dirExports.get(dirName)!.push(`export * from './${fileName(enumDef.name)}.interface';`);
236
+ }
237
+
238
+ for (const [dirName, exports] of dirExports) {
239
+ // Deduplicate (an enum and model could theoretically share a file name)
240
+ const uniqueExports = [...new Set(exports)];
241
+ uniqueExports.sort();
242
+ files.push({
243
+ path: `src/${dirName}/interfaces/index.ts`,
244
+ content: uniqueExports.join('\n'),
245
+ skipIfExists: true,
246
+ });
247
+ }
213
248
 
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']);
249
+ return files;
250
+ }
216
251
 
217
252
  function generateBarrel(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
218
253
  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';
254
+ const { modelToService, resolveDir } = createServiceDirResolver(spec.models, spec.services, ctx);
255
+
256
+ // Track all exported names to prevent duplicates.
257
+ // Pre-seed with names already exported by the existing SDK to avoid generating
258
+ // duplicate exports that would conflict with existing `export *` statements.
259
+ const exportedNames = new Set<string>();
260
+ if (ctx.apiSurface?.interfaces) {
261
+ for (const name of Object.keys(ctx.apiSurface.interfaces)) {
262
+ exportedNames.add(name);
263
+ }
264
+ }
265
+ if (ctx.apiSurface?.classes) {
266
+ for (const name of Object.keys(ctx.apiSurface.classes)) {
267
+ exportedNames.add(name);
268
+ }
269
+ }
270
+
271
+ // Collect names already exported by the existing SDK (via export * or named exports).
272
+ // When an explicit `export type { Foo }` would shadow a wildcard re-export that
273
+ // already provides a hand-written version of Foo (e.g., a discriminated union),
274
+ // we must skip the explicit export so the wildcard wins.
275
+ const existingSdkExports = new Set<string>();
276
+ if (ctx.apiSurface?.exports) {
277
+ for (const names of Object.values(ctx.apiSurface.exports)) {
278
+ for (const name of names) {
279
+ existingSdkExports.add(name);
280
+ }
281
+ }
282
+ }
223
283
 
224
284
  // Common exports
225
285
  lines.push("export * from './common/exceptions';");
@@ -230,51 +290,178 @@ function generateBarrel(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
230
290
  lines.push("export type { PostOptions } from './common/interfaces/post-options.interface';");
231
291
  lines.push("export type { GetOptions } from './common/interfaces/get-options.interface';");
232
292
  lines.push('');
293
+ for (const name of [
294
+ 'AutoPaginatable',
295
+ 'List',
296
+ 'ListMetadata',
297
+ 'ListResponse',
298
+ 'PaginationOptions',
299
+ 'WorkOSOptions',
300
+ 'PostOptions',
301
+ 'GetOptions',
302
+ ]) {
303
+ exportedNames.add(name);
304
+ }
233
305
 
234
- // Per-service exports: interfaces + resource class
306
+ // Identify services whose endpoints are fully covered by existing hand-written
307
+ // classes — their resource class should not be re-exported from the barrel.
308
+ const coveredServicesBarrel = new Set<string>();
235
309
  for (const service of spec.services) {
236
- const resolvedName = resolveServiceName(service, ctx);
237
- const serviceDir = serviceDirName(resolvedName);
310
+ if (isServiceCoveredByExisting(service, ctx)) {
311
+ coveredServicesBarrel.add(service.name);
312
+ }
313
+ }
238
314
 
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
- );
315
+ // Track directories that have already been wildcard-exported
316
+ const exportedDirs = new Set<string>();
317
+
318
+ // Per-service exports: service barrel + resource class
319
+ for (const service of spec.services) {
320
+ const resolvedName = resolveResourceClassName(service, ctx);
321
+ const serviceDir = serviceDirName(resolvedName);
322
+ // The interfaces directory may differ from the resource class directory when
323
+ // a service's class name is remapped (e.g., WebhooksEndpoints class lives in
324
+ // webhooks-endpoints/ but its model interfaces live in webhooks/).
325
+ const interfacesDir = resolveDir(service.name);
326
+
327
+ // Check if this service has any models or enums (i.e., a barrel was generated).
328
+ // Exclude list wrapper and list metadata models — these are skipped during
329
+ // interface generation (they use shared List<T>/ListMetadata), so they don't
330
+ // have corresponding .interface.ts files in the output.
331
+ const serviceModels = spec.models.filter((m) => {
332
+ if (modelToService.get(m.name) !== service.name) return false;
333
+ if (isListMetadataModel(m) || isListWrapperModel(m)) return false;
334
+ return true;
335
+ });
336
+ const serviceEnums = spec.enums.filter((e) => {
337
+ const enumService = findEnumService(e.name, spec.services);
338
+ return enumService === service.name;
339
+ });
340
+
341
+ // Check whether any model or enum in this service conflicts with existingSdkExports.
342
+ // If so, fall back to individual exports to avoid shadowing hand-written types.
343
+ const hasConflict =
344
+ serviceModels.some((m) => existingSdkExports.has(resolveInterfaceName(m.name, ctx))) ||
345
+ serviceEnums.some((e) => existingSdkExports.has(e.name));
346
+
347
+ if ((serviceModels.length > 0 || serviceEnums.length > 0) && !exportedDirs.has(interfacesDir) && !hasConflict) {
348
+ exportedDirs.add(interfacesDir);
349
+ lines.push(`export * from './${interfacesDir}/interfaces';`);
350
+ // Track the individual names so they don't get re-exported below
351
+ for (const model of serviceModels) {
352
+ exportedNames.add(resolveInterfaceName(model.name, ctx));
353
+ exportedNames.add(wireInterfaceName(resolveInterfaceName(model.name, ctx)));
354
+ }
355
+ for (const enumDef of serviceEnums) {
356
+ exportedNames.add(enumDef.name);
357
+ }
358
+ } else if (!hasConflict) {
359
+ // Fallback: emit individual model exports (e.g., when no models/enums exist)
360
+ for (const model of serviceModels) {
361
+ const name = resolveInterfaceName(model.name, ctx);
362
+ const wireName = wireInterfaceName(name);
363
+ if (exportedNames.has(name) || exportedNames.has(wireName)) continue;
364
+ if (existingSdkExports.has(name)) continue;
365
+ exportedNames.add(name);
366
+ exportedNames.add(wireName);
367
+ lines.push(
368
+ `export type { ${name}, ${wireName} } from './${interfacesDir}/interfaces/${fileName(model.name)}.interface';`,
369
+ );
370
+ }
248
371
  }
249
372
 
250
- // Resource class
251
- lines.push(`export { ${resolvedName} } from './${serviceDir}/${fileName(resolvedName)}';`);
373
+ // Resource class — skip if already exported or if service is fully covered
374
+ // by existing hand-written classes
375
+ if (coveredServicesBarrel.has(service.name)) {
376
+ // Emit a comment indicating this service is covered by an existing class
377
+ lines.push(`// ${resolvedName} is covered by an existing hand-written class — not re-exported.`);
378
+ } else if (!exportedNames.has(resolvedName)) {
379
+ exportedNames.add(resolvedName);
380
+ lines.push(`export { ${resolvedName} } from './${serviceDir}/${fileName(resolvedName)}';`);
381
+ }
252
382
  lines.push('');
253
383
  }
254
384
 
255
- // Unassigned models (common), skipping reserved names
385
+ // Unassigned models (common) use barrel if any exist
256
386
  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';`);
387
+ const commonEnums = spec.enums.filter((e) => {
388
+ const enumService = findEnumService(e.name, spec.services);
389
+ return !enumService;
390
+ });
391
+
392
+ const commonHasConflict =
393
+ unassignedModels.some((m) => existingSdkExports.has(resolveInterfaceName(m.name, ctx))) ||
394
+ commonEnums.some((e) => existingSdkExports.has(e.name));
395
+
396
+ if ((unassignedModels.length > 0 || commonEnums.length > 0) && !exportedDirs.has('common') && !commonHasConflict) {
397
+ exportedDirs.add('common');
398
+ lines.push("export * from './common/interfaces';");
399
+ for (const model of unassignedModels) {
400
+ exportedNames.add(resolveInterfaceName(model.name, ctx));
401
+ exportedNames.add(wireInterfaceName(resolveInterfaceName(model.name, ctx)));
402
+ }
403
+ for (const enumDef of commonEnums) {
404
+ exportedNames.add(enumDef.name);
405
+ }
406
+ } else {
407
+ // Fallback: individual model exports
408
+ for (const model of unassignedModels) {
409
+ const name = resolveInterfaceName(model.name, ctx);
410
+ const wireName = wireInterfaceName(name);
411
+ if (exportedNames.has(name) || exportedNames.has(wireName)) continue;
412
+ if (existingSdkExports.has(name)) continue;
413
+ exportedNames.add(name);
414
+ exportedNames.add(wireName);
415
+ lines.push(`export type { ${name}, ${wireName} } from './common/interfaces/${fileName(model.name)}.interface';`);
416
+ }
262
417
  }
263
418
 
264
- // Enum exports
419
+ // Enum exports — only for enums not already covered by a service/common barrel.
420
+ // Skip duplicates and names already covered by existing SDK wildcards.
421
+ // Use value export (`export { ... }`) for actual TS enums so consumers
422
+ // can use them as runtime values (e.g., ConnectionType.GoogleOAuth).
423
+ // Use type-only export (`export type { ... }`) for string literal unions.
265
424
  for (const enumDef of spec.enums) {
266
- // Find which service directory the enum landed in
425
+ if (exportedNames.has(enumDef.name)) continue;
426
+ if (existingSdkExports.has(enumDef.name)) continue;
427
+ exportedNames.add(enumDef.name);
267
428
  const enumService = findEnumService(enumDef.name, spec.services);
268
429
  const dir = resolveDir(enumService);
269
- lines.push(`export type { ${enumDef.name} } from './${dir}/interfaces/${fileName(enumDef.name)}.interface';`);
430
+ if (!exportedDirs.has(dir)) {
431
+ const baselineEnum = ctx.apiSurface?.enums?.[enumDef.name];
432
+ const exportKeyword = baselineEnum?.members ? 'export' : 'export type';
433
+ lines.push(
434
+ `${exportKeyword} { ${enumDef.name} } from './${dir}/interfaces/${fileName(enumDef.name)}.interface';`,
435
+ );
436
+ }
270
437
  }
271
438
 
272
439
  lines.push('');
273
- lines.push("export { WorkOS } from './workos';");
440
+ // Only emit the WorkOS re-export for standalone generation (no existing SDK).
441
+ // When integrating into an existing SDK, the existing barrel already exports
442
+ // WorkOS (often as a subclass alias like `export { WorkOSNode as WorkOS }`),
443
+ // and adding a second export with the same name causes a duplicate identifier error.
444
+ if (!ctx.apiSurface && !exportedNames.has('WorkOS')) {
445
+ exportedNames.add('WorkOS');
446
+ lines.push("export { WorkOS } from './workos';");
447
+ }
274
448
 
275
449
  return { path: 'src/index.ts', content: lines.join('\n'), skipIfExists: true };
276
450
  }
277
451
 
452
+ /**
453
+ * Generate a worker-compatible barrel file that re-exports everything from
454
+ * the main barrel. This keeps type exports in sync automatically.
455
+ */
456
+ function generateWorkerBarrel(_spec: ApiSpec, _ctx: EmitterContext): GeneratedFile {
457
+ const lines: string[] = [];
458
+
459
+ // Re-export everything from the main index — keeps type exports in sync
460
+ lines.push("export * from './index';");
461
+
462
+ return { path: 'src/index.worker.ts', content: lines.join('\n'), skipIfExists: true };
463
+ }
464
+
278
465
  function findEnumService(enumName: string, services: Service[]): string | undefined {
279
466
  for (const service of services) {
280
467
  for (const op of service.operations) {
@@ -297,6 +484,51 @@ function findEnumService(enumName: string, services: Service[]): string | undefi
297
484
  return undefined;
298
485
  }
299
486
 
487
+ /**
488
+ * Determine whether the spec's auth scheme requires overriding the
489
+ * default bearer auth in WorkOSBase.setAuthHeaders().
490
+ */
491
+ function needsAuthOverride(auth?: AuthScheme[]): boolean {
492
+ if (!auth || auth.length === 0) return false;
493
+ const scheme = auth[0];
494
+ // bearer and oauth2 match the base class default — no override needed
495
+ return scheme.kind === 'apiKey';
496
+ }
497
+
498
+ /**
499
+ * Render the body of a setAuthHeaders override for non-default auth schemes.
500
+ * Only called when needsAuthOverride() returns true.
501
+ */
502
+ function renderAuthOverride(lines: string[], auth: AuthScheme[]): void {
503
+ const scheme = auth[0];
504
+ if (scheme.kind !== 'apiKey') return;
505
+ switch (scheme.in) {
506
+ case 'header':
507
+ lines.push(` headers['${scheme.name}'] = this.key;`);
508
+ break;
509
+ case 'query':
510
+ lines.push(` // Auth key sent as query parameter '${scheme.name}' (see buildUrl)`);
511
+ break;
512
+ case 'cookie':
513
+ lines.push(` headers['Cookie'] = \`${scheme.name}=\${this.key}\`;`);
514
+ break;
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Convert a server description or URL into a SCREAMING_SNAKE_CASE constant name.
520
+ */
521
+ function serverConstName(description: string): string {
522
+ return (
523
+ 'SERVER_' +
524
+ description
525
+ .replace(/https?:\/\//g, '')
526
+ .replace(/[^a-zA-Z0-9]+/g, '_')
527
+ .replace(/^_|_$/g, '')
528
+ .toUpperCase()
529
+ );
530
+ }
531
+
300
532
  function generatePackageJson(ctx: EmitterContext): GeneratedFile {
301
533
  const pkg = {
302
534
  name: `@${ctx.namespace}/sdk`,
@@ -336,6 +568,7 @@ function generateTsConfig(): GeneratedFile {
336
568
  lib: ['ES2020'],
337
569
  declaration: true,
338
570
  strict: true,
571
+ exactOptionalPropertyTypes: true,
339
572
  esModuleInterop: true,
340
573
  skipLibCheck: true,
341
574
  forceConsistentCasingInFileNames: true,