@pikku/openapi-parser 0.12.14 → 0.12.16

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # @pikku/openapi-parser
2
2
 
3
+ ## 0.12.16
4
+
5
+ ### Patch Changes
6
+
7
+ - 9f0d0eb: Migrate the `--oauth` addon scaffold off `OAuth2Client`. A scaffolded OAuth2
8
+ addon service used to construct `new OAuth2Client(config, appCredentialSecretId,
9
+ secrets)` and do its own token exchange/refresh — the responsibility better-auth
10
+ now owns via the credential service. The `pikku new addon --oauth` scaffold (and
11
+ the OpenAPI `--openapi` generator) now emit a service that receives a ready
12
+ access token: `services.ts` uses `createWireServices` + `wire.getCredential<{
13
+ accessToken: string }>(name)` and the service does a plain `fetch` with
14
+ `Authorization: Bearer ${accessToken}`, matching the existing per-user
15
+ bearer/apikey credential scaffold. With no remaining consumers, `OAuth2Client`
16
+ (`@pikku/core/oauth2`) and its test are removed; the `./oauth2` export keeps the
17
+ `OAuth2AppCredential` / `OAuth2Token` types.
18
+ - 8601505: Make `wireCredential` the single source of truth for an addon's OAuth2 config: `pikku-credentials.gen.ts` exports `CREDENTIAL_OAUTH2_CONFIGS`, generated services import from it, the OpenAPI importer emits a `wireCredential`, and the inspector now extracts `oauth2.additionalParams`.
19
+
20
+ ## 0.12.15
21
+
22
+ ### Patch Changes
23
+
24
+ - 854c342: Fix workspace addon integration: exclude nested pikku projects from inspection (prevents "More than one CoreUserSession/CoreConfig found" when a workspace addon is linked), widen the generated addon service `call()` data param to `unknown` so schema-less function inputs compile, and add `@pikku/inspector` + `@standard-schema/spec` to the generated addon devDependencies so its `.pikku` gen files typecheck.
25
+
3
26
  ## 0.12.14
4
27
 
5
28
  ### Patch Changes
package/dist/codegen.js CHANGED
@@ -62,7 +62,8 @@ function humanDescription(parsed) {
62
62
  if (summary && !GENERIC_SUMMARIES.has(summary.toLowerCase())) {
63
63
  return capitalize(summary);
64
64
  }
65
- return humanizeOperationId(parsed.operationId) ?? `${parsed.method.toUpperCase()} ${parsed.path}`;
65
+ return (humanizeOperationId(parsed.operationId) ??
66
+ `${parsed.method.toUpperCase()} ${parsed.path}`);
66
67
  }
67
68
  function getErrorClassesForResponses(errorResponses) {
68
69
  const classes = [];
@@ -293,10 +294,15 @@ export function generateAddonFromOpenAPI(spec, vars, flags) {
293
294
  }
294
295
  // Generate index.ts with all exports
295
296
  files['src/index.ts'] = generateIndexFile(functionExports, {
296
- upstreamAuth: upstreamAuthExport ? { name, export: upstreamAuthExport } : undefined,
297
+ upstreamAuth: upstreamAuthExport
298
+ ? { name, export: upstreamAuthExport }
299
+ : undefined,
297
300
  });
298
301
  // Generate typed API service class with route map
299
302
  files[`src/${name}-api.service.ts`] = generateServiceFile(spec, opPairs, vars, flags);
303
+ if (flags.oauth || flags.credential === 'oauth2') {
304
+ files[`src/${name}.credential.ts`] = generateCredentialFile(spec, vars);
305
+ }
300
306
  // Generate variable file for BASE_URL
301
307
  files[`src/${name}.variable.ts`] = generateVariableFile(spec, vars);
302
308
  return files;
@@ -935,8 +941,8 @@ function generateServiceFile(spec, opPairs, vars, flags) {
935
941
  // Per-user credential: no special imports needed, creds passed via constructor
936
942
  }
937
943
  else if (flags.oauth || flags.credential === 'oauth2') {
938
- lines.push("import { OAuth2Client } from '@pikku/core/oauth2'");
939
- lines.push("import type { TypedSecretService } from '#pikku/secrets/pikku-secrets.gen.js'");
944
+ // OAuth2: the access token is resolved via the credential service and passed
945
+ // via the constructor — no special imports needed.
940
946
  }
941
947
  else if (flags.secret) {
942
948
  lines.push(`import type { ${pascalName}Secrets } from './${name}.secret.js'`);
@@ -946,23 +952,6 @@ function generateServiceFile(spec, opPairs, vars, flags) {
946
952
  }
947
953
  lines.push(`import type { TypedVariablesService } from '#pikku/variables/pikku-variables.gen.js'`);
948
954
  lines.push('');
949
- if (flags.oauth) {
950
- // Use OAuth2 details from spec if available
951
- const oauthScheme = Object.values(spec.securitySchemes).find((s) => s.type === 'oauth2');
952
- const authUrl = oauthScheme?.flows?.authorizationUrl ??
953
- 'https://example.com/oauth2/authorize';
954
- const tokenUrl = oauthScheme?.flows?.tokenUrl ?? 'https://example.com/oauth2/token';
955
- const scopes = oauthScheme?.flows?.scopes
956
- ? Object.keys(oauthScheme.flows.scopes)
957
- : ['read', 'write'];
958
- lines.push(`export const ${screamingName}_OAUTH2_CONFIG = {`);
959
- lines.push(` tokenSecretId: '${screamingName}_TOKENS',`);
960
- lines.push(` authorizationUrl: ${JSON.stringify(authUrl)},`);
961
- lines.push(` tokenUrl: ${JSON.stringify(tokenUrl)},`);
962
- lines.push(` scopes: ${JSON.stringify(scopes)},`);
963
- lines.push('}');
964
- lines.push('');
965
- }
966
955
  // Generate route map from parsed operations
967
956
  const routes = {};
968
957
  for (const { parsed } of opPairs) {
@@ -1007,15 +996,9 @@ function generateServiceFile(spec, opPairs, vars, flags) {
1007
996
  lines.push(' }');
1008
997
  }
1009
998
  else if (flags.oauth || flags.credential === 'oauth2') {
1010
- lines.push(' private oauth: OAuth2Client');
1011
999
  lines.push('');
1012
- lines.push(` constructor(secrets: TypedSecretService, variables: TypedVariablesService) {`);
1000
+ lines.push(` constructor(private creds: { accessToken: string }, variables: TypedVariablesService) {`);
1013
1001
  lines.push(` this.baseUrl = variables.get('${screamingName}_BASE_URL') as string`);
1014
- lines.push(' this.oauth = new OAuth2Client(');
1015
- lines.push(` ${screamingName}_OAUTH2_CONFIG,`);
1016
- lines.push(` '${screamingName}_APP_CREDENTIALS',`);
1017
- lines.push(' secrets');
1018
- lines.push(' )');
1019
1002
  lines.push(' }');
1020
1003
  }
1021
1004
  else if (flags.secret) {
@@ -1035,10 +1018,11 @@ function generateServiceFile(spec, opPairs, vars, flags) {
1035
1018
  lines.push(' async call<T>(');
1036
1019
  lines.push(" method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',");
1037
1020
  lines.push(' path: string,');
1038
- lines.push(' data?: Record<string, unknown>');
1021
+ lines.push(' data?: unknown');
1039
1022
  lines.push(' ): Promise<T> {');
1023
+ lines.push(' const input = data as Record<string, unknown> | undefined');
1040
1024
  if (flags.camelCase) {
1041
- lines.push(' const rawData = data ? _toSnakeCase(data) : data');
1025
+ lines.push(' const rawData = input ? _toSnakeCase(input) : input');
1042
1026
  }
1043
1027
  lines.push(' const route = ROUTES[`${method} ${path}`]');
1044
1028
  lines.push(' let endpoint = path');
@@ -1052,7 +1036,7 @@ function generateServiceFile(spec, opPairs, vars, flags) {
1052
1036
  lines.push(' }');
1053
1037
  lines.push('');
1054
1038
  // When camelCase flag is on, use rawData (snake_case converted) for route matching
1055
- const dataVar = flags.camelCase ? 'rawData' : 'data';
1039
+ const dataVar = flags.camelCase ? 'rawData' : 'input';
1056
1040
  lines.push(` if (${dataVar} && route) {`);
1057
1041
  lines.push(' // Interpolate path params');
1058
1042
  lines.push(' for (const param of route.path) {');
@@ -1121,7 +1105,9 @@ function generateServiceFile(spec, opPairs, vars, flags) {
1121
1105
  lines.push(' })');
1122
1106
  }
1123
1107
  else if (flags.oauth || flags.credential === 'oauth2') {
1124
- lines.push(' const response = await this.oauth.request(url.toString(), {');
1108
+ lines.push(' headers.Authorization = `Bearer ${this.creds.accessToken}`');
1109
+ lines.push('');
1110
+ lines.push(' const response = await fetch(url.toString(), {');
1125
1111
  lines.push(' method,');
1126
1112
  lines.push(' headers,');
1127
1113
  lines.push(' body: body ? JSON.stringify(body) : undefined,');
@@ -1186,6 +1172,63 @@ function generateServiceFile(spec, opPairs, vars, flags) {
1186
1172
  lines.push('');
1187
1173
  return lines.join('\n');
1188
1174
  }
1175
+ function generateCredentialFile(spec, vars) {
1176
+ const { camelName, screamingName } = vars;
1177
+ const displayName = vars.displayName.replace(/'/g, '');
1178
+ const description = vars.description.replace(/'/g, '');
1179
+ const oauthScheme = Object.values(spec.securitySchemes).find((s) => s.type === 'oauth2');
1180
+ const authorizationUrl = oauthScheme?.flows?.authorizationUrl;
1181
+ const tokenUrl = oauthScheme?.flows?.tokenUrl;
1182
+ const scopes = oauthScheme?.flows?.scopes
1183
+ ? Object.keys(oauthScheme.flows.scopes)
1184
+ : ['read', 'write'];
1185
+ const lines = [];
1186
+ lines.push("import { z } from 'zod'");
1187
+ lines.push("import { wireCredential } from '@pikku/core/credential'");
1188
+ lines.push("import { wireSecret } from '@pikku/core/secret'");
1189
+ lines.push('');
1190
+ lines.push(`export const ${camelName}TokenSchema = z.object({`);
1191
+ lines.push(' accessToken: z.string(),');
1192
+ lines.push(' refreshToken: z.string().optional(),');
1193
+ lines.push('})');
1194
+ lines.push('');
1195
+ lines.push(`export const ${camelName}OAuthAppSchema = z.object({`);
1196
+ lines.push(" clientId: z.string().describe('OAuth2 app client ID'),");
1197
+ lines.push(" clientSecret: z.string().describe('OAuth2 app client secret'),");
1198
+ lines.push('})');
1199
+ lines.push('');
1200
+ if (!authorizationUrl || !tokenUrl) {
1201
+ lines.push('// TODO: this spec declares no oauth2 flow URLs, so the placeholder(s)');
1202
+ lines.push(`// below are not real. Replace them with ${displayName}'s endpoints —`);
1203
+ lines.push('// OAuth will fail against example.com.');
1204
+ }
1205
+ // oauth2 must stay an inline object literal: the inspector reads it via AST
1206
+ // (add-credential.ts) and silently drops a config behind an identifier.
1207
+ lines.push('wireCredential({');
1208
+ lines.push(` name: '${camelName}',`);
1209
+ lines.push(` displayName: '${displayName}',`);
1210
+ lines.push(` description: '${description}',`);
1211
+ lines.push(` type: 'wire',`);
1212
+ lines.push(` schema: ${camelName}TokenSchema,`);
1213
+ lines.push(' oauth2: {');
1214
+ lines.push(` appCredentialSecretId: '${screamingName}_OAUTH_APP',`);
1215
+ lines.push(` tokenSecretId: '${screamingName}_OAUTH_TOKENS',`);
1216
+ lines.push(` authorizationUrl: ${JSON.stringify(authorizationUrl ?? 'https://example.com/oauth2/authorize')},`);
1217
+ lines.push(` tokenUrl: ${JSON.stringify(tokenUrl ?? 'https://example.com/oauth2/token')},`);
1218
+ lines.push(` scopes: ${JSON.stringify(scopes)},`);
1219
+ lines.push(' },');
1220
+ lines.push('})');
1221
+ lines.push('');
1222
+ lines.push('wireSecret({');
1223
+ lines.push(` name: '${camelName}OAuthApp',`);
1224
+ lines.push(` displayName: '${displayName} OAuth App',`);
1225
+ lines.push(` description: 'OAuth2 app credentials for ${displayName}',`);
1226
+ lines.push(` secretId: '${screamingName}_OAUTH_APP',`);
1227
+ lines.push(` schema: ${camelName}OAuthAppSchema,`);
1228
+ lines.push('})');
1229
+ lines.push('');
1230
+ return lines.join('\n');
1231
+ }
1189
1232
  function generateVariableFile(spec, vars) {
1190
1233
  const { camelName, screamingName } = vars;
1191
1234
  const displayName = vars.displayName.replace(/'/g, '');
package/dist/naming.js CHANGED
@@ -2,18 +2,22 @@
2
2
  * Derives function/method names from HTTP method + path.
3
3
  * Uses operationId when available, otherwise generates from path segments.
4
4
  */
5
- /** JavaScript/TypeScript reserved words that cannot be used as identifiers */
5
+ /**
6
+ * Words that are illegal as a `const`/binding identifier in a strict-mode module
7
+ * (generated code is always ESM). Contextual keywords that are only special in a
8
+ * specific position — `get`/`set` (getters), `type`/`as`/`from`/`of` (type & import
9
+ * syntax), `any`/`number`/`object`/`string`/`symbol`/`declare`/`module`/`require`
10
+ * (type positions), and the legacy ES3 future-reserved words (`byte`, `char`, `int`,
11
+ * `long`, `short`, `float`, `double`, `boolean`, `goto`, `native`, `synchronized`,
12
+ * `transient`, `volatile`, `abstract`, `final`) — are all valid binding names and
13
+ * deliberately excluded, so `sheets.spreadsheets.get` stays `get`, not `_get`.
14
+ */
6
15
  const JS_RESERVED_WORDS = new Set([
7
- // ECMAScript reserved words
8
- 'abstract',
9
16
  'arguments',
10
17
  'await',
11
- 'boolean',
12
18
  'break',
13
- 'byte',
14
19
  'case',
15
20
  'catch',
16
- 'char',
17
21
  'class',
18
22
  'const',
19
23
  'continue',
@@ -21,29 +25,22 @@ const JS_RESERVED_WORDS = new Set([
21
25
  'default',
22
26
  'delete',
23
27
  'do',
24
- 'double',
25
28
  'else',
26
29
  'enum',
27
30
  'eval',
28
31
  'export',
29
32
  'extends',
30
33
  'false',
31
- 'final',
32
34
  'finally',
33
- 'float',
34
35
  'for',
35
36
  'function',
36
- 'goto',
37
37
  'if',
38
38
  'implements',
39
39
  'import',
40
40
  'in',
41
41
  'instanceof',
42
- 'int',
43
42
  'interface',
44
43
  'let',
45
- 'long',
46
- 'native',
47
44
  'new',
48
45
  'null',
49
46
  'package',
@@ -51,40 +48,21 @@ const JS_RESERVED_WORDS = new Set([
51
48
  'protected',
52
49
  'public',
53
50
  'return',
54
- 'short',
55
51
  'static',
56
52
  'super',
57
53
  'switch',
58
- 'synchronized',
59
54
  'this',
60
55
  'throw',
61
56
  'throws',
62
- 'transient',
63
57
  'true',
64
58
  'try',
65
59
  'typeof',
66
60
  'undefined',
67
61
  'var',
68
62
  'void',
69
- 'volatile',
70
63
  'while',
71
64
  'with',
72
65
  'yield',
73
- // TypeScript additional reserved words in strict mode
74
- 'as',
75
- 'any',
76
- 'declare',
77
- 'get',
78
- 'module',
79
- 'require',
80
- 'number',
81
- 'object',
82
- 'set',
83
- 'string',
84
- 'symbol',
85
- 'type',
86
- 'from',
87
- 'of',
88
66
  ]);
89
67
  /**
90
68
  * Ensure a name is a valid JS/TS identifier:
@@ -203,6 +181,35 @@ const METHOD_PREFIXES = {
203
181
  patch: 'update',
204
182
  delete: 'delete',
205
183
  };
184
+ /**
185
+ * Find the longest shared leading run of dot-separated operationId segments,
186
+ * always leaving at least one trailing segment on every operation. Only applies
187
+ * when every operation has a dotted operationId (e.g. Google's
188
+ * `sheets.spreadsheets.values.get`), so specs whose ids are camelCase or absent
189
+ * are left untouched. The shared prefix is redundant with the addon namespace,
190
+ * so stripping it turns `sheetsSpreadsheetsValuesGet` into `valuesGet`.
191
+ */
192
+ function commonOperationIdPrefix(operations) {
193
+ if (operations.length < 2) {
194
+ return [];
195
+ }
196
+ if (!operations.every((op) => op.operationId && op.operationId.includes('.'))) {
197
+ return [];
198
+ }
199
+ const segmented = operations.map((op) => op.operationId.split('.'));
200
+ const minLen = Math.min(...segmented.map((s) => s.length));
201
+ const prefix = [];
202
+ for (let i = 0; i < minLen - 1; i++) {
203
+ const seg = segmented[0][i];
204
+ if (segmented.every((s) => s[i] === seg)) {
205
+ prefix.push(seg);
206
+ }
207
+ else {
208
+ break;
209
+ }
210
+ }
211
+ return prefix;
212
+ }
206
213
  /**
207
214
  * Generate function names for a list of operations.
208
215
  * Handles collision detection and auto-deduplication.
@@ -215,10 +222,14 @@ export function generateOperationNames(operations, commonPrefix) {
215
222
  // only in casing (e.g. userGetOauth2Application vs userGetOAuth2Application)
216
223
  // would produce files that collide on disk.
217
224
  const usedNamesLower = new Map();
225
+ const opIdPrefix = commonOperationIdPrefix(operations);
218
226
  for (const op of operations) {
219
227
  let name;
220
228
  if (op.operationId) {
221
- name = fromOperationId(op.operationId);
229
+ const opId = opIdPrefix.length
230
+ ? op.operationId.split('.').slice(opIdPrefix.length).join('.')
231
+ : op.operationId;
232
+ name = fromOperationId(opId);
222
233
  }
223
234
  else {
224
235
  name = deriveNameFromPath(op.method, op.path, commonPrefix);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/openapi-parser",
3
- "version": "0.12.14",
3
+ "version": "0.12.16",
4
4
  "description": "OpenAPI spec parser and code generator for Pikku",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -801,7 +801,8 @@ describe('function description synthesis', () => {
801
801
  secret: false,
802
802
  mcp: true,
803
803
  })
804
- const funcFile = files['src/functions/contactsControllerGetContacts.function.ts']
804
+ const funcFile =
805
+ files['src/functions/contactsControllerGetContacts.function.ts']
805
806
  assert.ok(funcFile, 'function file should exist')
806
807
  assert.ok(
807
808
  funcFile.includes('description: "Contacts get contacts"'),
@@ -812,7 +813,9 @@ describe('function description synthesis', () => {
812
813
 
813
814
  test('falls back to "METHOD /path" when operationId is absent', () => {
814
815
  const spec = makeSpec({
815
- operations: [makeOp({ operationId: undefined, method: 'get', path: '/health' })],
816
+ operations: [
817
+ makeOp({ operationId: undefined, method: 'get', path: '/health' }),
818
+ ],
816
819
  })
817
820
 
818
821
  const files = generateAddonFromOpenAPI(spec, makeVars(), {
@@ -830,7 +833,10 @@ describe('function description synthesis', () => {
830
833
  test('prefers the spec description over synthesis', () => {
831
834
  const spec = makeSpec({
832
835
  operations: [
833
- makeOp({ operationId: 'getPet', description: 'Fetch a single pet by id' }),
836
+ makeOp({
837
+ operationId: 'getPet',
838
+ description: 'Fetch a single pet by id',
839
+ }),
834
840
  ],
835
841
  })
836
842
 
package/src/codegen.ts CHANGED
@@ -105,7 +105,10 @@ function humanDescription(parsed: ParsedOperation): string {
105
105
  if (summary && !GENERIC_SUMMARIES.has(summary.toLowerCase())) {
106
106
  return capitalize(summary)
107
107
  }
108
- return humanizeOperationId(parsed.operationId) ?? `${parsed.method.toUpperCase()} ${parsed.path}`
108
+ return (
109
+ humanizeOperationId(parsed.operationId) ??
110
+ `${parsed.method.toUpperCase()} ${parsed.path}`
111
+ )
109
112
  }
110
113
 
111
114
  function getErrorClassesForResponses(
@@ -433,7 +436,9 @@ export function generateAddonFromOpenAPI(
433
436
 
434
437
  // Generate index.ts with all exports
435
438
  files['src/index.ts'] = generateIndexFile(functionExports, {
436
- upstreamAuth: upstreamAuthExport ? { name, export: upstreamAuthExport } : undefined,
439
+ upstreamAuth: upstreamAuthExport
440
+ ? { name, export: upstreamAuthExport }
441
+ : undefined,
437
442
  })
438
443
 
439
444
  // Generate typed API service class with route map
@@ -444,6 +449,10 @@ export function generateAddonFromOpenAPI(
444
449
  flags
445
450
  )
446
451
 
452
+ if (flags.oauth || flags.credential === 'oauth2') {
453
+ files[`src/${name}.credential.ts`] = generateCredentialFile(spec, vars)
454
+ }
455
+
447
456
  // Generate variable file for BASE_URL
448
457
  files[`src/${name}.variable.ts`] = generateVariableFile(spec, vars)
449
458
 
@@ -1075,9 +1084,7 @@ function generateUpstreamAuthFile(
1075
1084
  )
1076
1085
  lines.push('}')
1077
1086
  lines.push('')
1078
- lines.push(
1079
- `export interface ${pascalName}UpstreamCredentials {`
1080
- )
1087
+ lines.push(`export interface ${pascalName}UpstreamCredentials {`)
1081
1088
  lines.push(' email?: string')
1082
1089
  lines.push(' password?: string')
1083
1090
  lines.push(' apiKey?: string')
@@ -1098,18 +1105,16 @@ function generateUpstreamAuthFile(
1098
1105
  " typeof value === 'string' && value ? value : typeof value === 'number' ? String(value) : undefined"
1099
1106
  )
1100
1107
  lines.push('')
1101
- lines.push('/** Decode a JWT payload without verifying — see file docblock. */')
1108
+ lines.push(
1109
+ '/** Decode a JWT payload without verifying — see file docblock. */'
1110
+ )
1102
1111
  lines.push('const decodeJwtPayload = (token: string): unknown => {')
1103
1112
  lines.push(" const part = token.split('.')[1]")
1104
1113
  lines.push(' if (!part) return undefined')
1105
1114
  lines.push(' try {')
1106
1115
  lines.push(" const pad = part + '==='.slice((part.length + 3) % 4)")
1107
- lines.push(
1108
- " const bin = atob(pad.replace(/-/g, '+').replace(/_/g, '/'))"
1109
- )
1110
- lines.push(
1111
- ' const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0))'
1112
- )
1116
+ lines.push(" const bin = atob(pad.replace(/-/g, '+').replace(/_/g, '/'))")
1117
+ lines.push(' const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0))')
1113
1118
  lines.push(' return JSON.parse(new TextDecoder().decode(bytes))')
1114
1119
  lines.push(' } catch {')
1115
1120
  lines.push(' // Not a decodable JWT — caller treats it as missing claims.')
@@ -1156,7 +1161,9 @@ function generateUpstreamAuthFile(
1156
1161
  lines.push(
1157
1162
  ` const response = await fetch(\`\${baseUrl.replace(/\\/+$/, '')}${delegated.loginPath}\`, {`
1158
1163
  )
1159
- lines.push(` method: ${JSON.stringify(delegated.loginMethod.toUpperCase())},`)
1164
+ lines.push(
1165
+ ` method: ${JSON.stringify(delegated.loginMethod.toUpperCase())},`
1166
+ )
1160
1167
  lines.push(' headers,')
1161
1168
  lines.push(
1162
1169
  ' body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined,'
@@ -1165,7 +1172,9 @@ function generateUpstreamAuthFile(
1165
1172
  lines.push(' if (!response.ok) return null')
1166
1173
  lines.push('')
1167
1174
  lines.push(' const data: unknown = await response.json()')
1168
- lines.push(` const token = str(pick(data, ${JSON.stringify(delegated.tokenPath)}))`)
1175
+ lines.push(
1176
+ ` const token = str(pick(data, ${JSON.stringify(delegated.tokenPath)}))`
1177
+ )
1169
1178
  lines.push(' if (!token) return null')
1170
1179
  lines.push('')
1171
1180
  if (claims.source === 'jwt') {
@@ -1178,7 +1187,9 @@ function generateUpstreamAuthFile(
1178
1187
  lines.push(
1179
1188
  ` const externalId = str(pick(claims, ${JSON.stringify(claims.externalId)}))`
1180
1189
  )
1181
- lines.push(` const email = str(pick(claims, ${JSON.stringify(claims.email)}))`)
1190
+ lines.push(
1191
+ ` const email = str(pick(claims, ${JSON.stringify(claims.email)}))`
1192
+ )
1182
1193
  lines.push(' if (!externalId || !email) return null')
1183
1194
  lines.push('')
1184
1195
  if (namePaths.length > 0) {
@@ -1254,10 +1265,8 @@ function generateServiceFile(
1254
1265
  if (flags.credential && flags.credential !== 'oauth2') {
1255
1266
  // Per-user credential: no special imports needed, creds passed via constructor
1256
1267
  } else if (flags.oauth || flags.credential === 'oauth2') {
1257
- lines.push("import { OAuth2Client } from '@pikku/core/oauth2'")
1258
- lines.push(
1259
- "import type { TypedSecretService } from '#pikku/secrets/pikku-secrets.gen.js'"
1260
- )
1268
+ // OAuth2: the access token is resolved via the credential service and passed
1269
+ // via the constructor — no special imports needed.
1261
1270
  } else if (flags.secret) {
1262
1271
  lines.push(
1263
1272
  `import type { ${pascalName}Secrets } from './${name}.secret.js'`
@@ -1275,29 +1284,6 @@ function generateServiceFile(
1275
1284
  )
1276
1285
  lines.push('')
1277
1286
 
1278
- if (flags.oauth) {
1279
- // Use OAuth2 details from spec if available
1280
- const oauthScheme = Object.values(spec.securitySchemes).find(
1281
- (s) => s.type === 'oauth2'
1282
- )
1283
- const authUrl =
1284
- oauthScheme?.flows?.authorizationUrl ??
1285
- 'https://example.com/oauth2/authorize'
1286
- const tokenUrl =
1287
- oauthScheme?.flows?.tokenUrl ?? 'https://example.com/oauth2/token'
1288
- const scopes = oauthScheme?.flows?.scopes
1289
- ? Object.keys(oauthScheme.flows.scopes)
1290
- : ['read', 'write']
1291
-
1292
- lines.push(`export const ${screamingName}_OAUTH2_CONFIG = {`)
1293
- lines.push(` tokenSecretId: '${screamingName}_TOKENS',`)
1294
- lines.push(` authorizationUrl: ${JSON.stringify(authUrl)},`)
1295
- lines.push(` tokenUrl: ${JSON.stringify(tokenUrl)},`)
1296
- lines.push(` scopes: ${JSON.stringify(scopes)},`)
1297
- lines.push('}')
1298
- lines.push('')
1299
- }
1300
-
1301
1287
  // Generate route map from parsed operations
1302
1288
  const routes: Record<string, RouteInfo> = {}
1303
1289
  for (const { parsed } of opPairs) {
@@ -1359,19 +1345,13 @@ function generateServiceFile(
1359
1345
  )
1360
1346
  lines.push(' }')
1361
1347
  } else if (flags.oauth || flags.credential === 'oauth2') {
1362
- lines.push(' private oauth: OAuth2Client')
1363
1348
  lines.push('')
1364
1349
  lines.push(
1365
- ` constructor(secrets: TypedSecretService, variables: TypedVariablesService) {`
1350
+ ` constructor(private creds: { accessToken: string }, variables: TypedVariablesService) {`
1366
1351
  )
1367
1352
  lines.push(
1368
1353
  ` this.baseUrl = variables.get('${screamingName}_BASE_URL') as string`
1369
1354
  )
1370
- lines.push(' this.oauth = new OAuth2Client(')
1371
- lines.push(` ${screamingName}_OAUTH2_CONFIG,`)
1372
- lines.push(` '${screamingName}_APP_CREDENTIALS',`)
1373
- lines.push(' secrets')
1374
- lines.push(' )')
1375
1355
  lines.push(' }')
1376
1356
  } else if (flags.secret) {
1377
1357
  lines.push('')
@@ -1397,10 +1377,11 @@ function generateServiceFile(
1397
1377
  lines.push(' async call<T>(')
1398
1378
  lines.push(" method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',")
1399
1379
  lines.push(' path: string,')
1400
- lines.push(' data?: Record<string, unknown>')
1380
+ lines.push(' data?: unknown')
1401
1381
  lines.push(' ): Promise<T> {')
1382
+ lines.push(' const input = data as Record<string, unknown> | undefined')
1402
1383
  if (flags.camelCase) {
1403
- lines.push(' const rawData = data ? _toSnakeCase(data) : data')
1384
+ lines.push(' const rawData = input ? _toSnakeCase(input) : input')
1404
1385
  }
1405
1386
  lines.push(' const route = ROUTES[`${method} ${path}`]')
1406
1387
  lines.push(' let endpoint = path')
@@ -1416,7 +1397,7 @@ function generateServiceFile(
1416
1397
  lines.push(' }')
1417
1398
  lines.push('')
1418
1399
  // When camelCase flag is on, use rawData (snake_case converted) for route matching
1419
- const dataVar = flags.camelCase ? 'rawData' : 'data'
1400
+ const dataVar = flags.camelCase ? 'rawData' : 'input'
1420
1401
  lines.push(` if (${dataVar} && route) {`)
1421
1402
  lines.push(' // Interpolate path params')
1422
1403
  lines.push(' for (const param of route.path) {')
@@ -1497,9 +1478,9 @@ function generateServiceFile(
1497
1478
  lines.push(' body: body ? JSON.stringify(body) : undefined,')
1498
1479
  lines.push(' })')
1499
1480
  } else if (flags.oauth || flags.credential === 'oauth2') {
1500
- lines.push(
1501
- ' const response = await this.oauth.request(url.toString(), {'
1502
- )
1481
+ lines.push(' headers.Authorization = `Bearer ${this.creds.accessToken}`')
1482
+ lines.push('')
1483
+ lines.push(' const response = await fetch(url.toString(), {')
1503
1484
  lines.push(' method,')
1504
1485
  lines.push(' headers,')
1505
1486
  lines.push(' body: body ? JSON.stringify(body) : undefined,')
@@ -1578,6 +1559,79 @@ function generateServiceFile(
1578
1559
  return lines.join('\n')
1579
1560
  }
1580
1561
 
1562
+ function generateCredentialFile(spec: ParsedSpec, vars: AddonVars): string {
1563
+ const { camelName, screamingName } = vars
1564
+ const displayName = vars.displayName.replace(/'/g, '')
1565
+ const description = vars.description.replace(/'/g, '')
1566
+
1567
+ const oauthScheme = Object.values(spec.securitySchemes).find(
1568
+ (s) => s.type === 'oauth2'
1569
+ )
1570
+ const authorizationUrl = oauthScheme?.flows?.authorizationUrl
1571
+ const tokenUrl = oauthScheme?.flows?.tokenUrl
1572
+ const scopes = oauthScheme?.flows?.scopes
1573
+ ? Object.keys(oauthScheme.flows.scopes)
1574
+ : ['read', 'write']
1575
+
1576
+ const lines: string[] = []
1577
+ lines.push("import { z } from 'zod'")
1578
+ lines.push("import { wireCredential } from '@pikku/core/credential'")
1579
+ lines.push("import { wireSecret } from '@pikku/core/secret'")
1580
+ lines.push('')
1581
+ lines.push(`export const ${camelName}TokenSchema = z.object({`)
1582
+ lines.push(' accessToken: z.string(),')
1583
+ lines.push(' refreshToken: z.string().optional(),')
1584
+ lines.push('})')
1585
+ lines.push('')
1586
+ lines.push(`export const ${camelName}OAuthAppSchema = z.object({`)
1587
+ lines.push(" clientId: z.string().describe('OAuth2 app client ID'),")
1588
+ lines.push(" clientSecret: z.string().describe('OAuth2 app client secret'),")
1589
+ lines.push('})')
1590
+ lines.push('')
1591
+
1592
+ if (!authorizationUrl || !tokenUrl) {
1593
+ lines.push(
1594
+ '// TODO: this spec declares no oauth2 flow URLs, so the placeholder(s)'
1595
+ )
1596
+ lines.push(
1597
+ `// below are not real. Replace them with ${displayName}'s endpoints —`
1598
+ )
1599
+ lines.push('// OAuth will fail against example.com.')
1600
+ }
1601
+
1602
+ // oauth2 must stay an inline object literal: the inspector reads it via AST
1603
+ // (add-credential.ts) and silently drops a config behind an identifier.
1604
+ lines.push('wireCredential({')
1605
+ lines.push(` name: '${camelName}',`)
1606
+ lines.push(` displayName: '${displayName}',`)
1607
+ lines.push(` description: '${description}',`)
1608
+ lines.push(` type: 'wire',`)
1609
+ lines.push(` schema: ${camelName}TokenSchema,`)
1610
+ lines.push(' oauth2: {')
1611
+ lines.push(` appCredentialSecretId: '${screamingName}_OAUTH_APP',`)
1612
+ lines.push(` tokenSecretId: '${screamingName}_OAUTH_TOKENS',`)
1613
+ lines.push(
1614
+ ` authorizationUrl: ${JSON.stringify(authorizationUrl ?? 'https://example.com/oauth2/authorize')},`
1615
+ )
1616
+ lines.push(
1617
+ ` tokenUrl: ${JSON.stringify(tokenUrl ?? 'https://example.com/oauth2/token')},`
1618
+ )
1619
+ lines.push(` scopes: ${JSON.stringify(scopes)},`)
1620
+ lines.push(' },')
1621
+ lines.push('})')
1622
+ lines.push('')
1623
+ lines.push('wireSecret({')
1624
+ lines.push(` name: '${camelName}OAuthApp',`)
1625
+ lines.push(` displayName: '${displayName} OAuth App',`)
1626
+ lines.push(` description: 'OAuth2 app credentials for ${displayName}',`)
1627
+ lines.push(` secretId: '${screamingName}_OAUTH_APP',`)
1628
+ lines.push(` schema: ${camelName}OAuthAppSchema,`)
1629
+ lines.push('})')
1630
+ lines.push('')
1631
+
1632
+ return lines.join('\n')
1633
+ }
1634
+
1581
1635
  function generateVariableFile(spec: ParsedSpec, vars: AddonVars): string {
1582
1636
  const { camelName, screamingName } = vars
1583
1637
  const displayName = vars.displayName.replace(/'/g, '')
package/src/naming.ts CHANGED
@@ -3,18 +3,22 @@
3
3
  * Uses operationId when available, otherwise generates from path segments.
4
4
  */
5
5
 
6
- /** JavaScript/TypeScript reserved words that cannot be used as identifiers */
6
+ /**
7
+ * Words that are illegal as a `const`/binding identifier in a strict-mode module
8
+ * (generated code is always ESM). Contextual keywords that are only special in a
9
+ * specific position — `get`/`set` (getters), `type`/`as`/`from`/`of` (type & import
10
+ * syntax), `any`/`number`/`object`/`string`/`symbol`/`declare`/`module`/`require`
11
+ * (type positions), and the legacy ES3 future-reserved words (`byte`, `char`, `int`,
12
+ * `long`, `short`, `float`, `double`, `boolean`, `goto`, `native`, `synchronized`,
13
+ * `transient`, `volatile`, `abstract`, `final`) — are all valid binding names and
14
+ * deliberately excluded, so `sheets.spreadsheets.get` stays `get`, not `_get`.
15
+ */
7
16
  const JS_RESERVED_WORDS = new Set([
8
- // ECMAScript reserved words
9
- 'abstract',
10
17
  'arguments',
11
18
  'await',
12
- 'boolean',
13
19
  'break',
14
- 'byte',
15
20
  'case',
16
21
  'catch',
17
- 'char',
18
22
  'class',
19
23
  'const',
20
24
  'continue',
@@ -22,29 +26,22 @@ const JS_RESERVED_WORDS = new Set([
22
26
  'default',
23
27
  'delete',
24
28
  'do',
25
- 'double',
26
29
  'else',
27
30
  'enum',
28
31
  'eval',
29
32
  'export',
30
33
  'extends',
31
34
  'false',
32
- 'final',
33
35
  'finally',
34
- 'float',
35
36
  'for',
36
37
  'function',
37
- 'goto',
38
38
  'if',
39
39
  'implements',
40
40
  'import',
41
41
  'in',
42
42
  'instanceof',
43
- 'int',
44
43
  'interface',
45
44
  'let',
46
- 'long',
47
- 'native',
48
45
  'new',
49
46
  'null',
50
47
  'package',
@@ -52,40 +49,21 @@ const JS_RESERVED_WORDS = new Set([
52
49
  'protected',
53
50
  'public',
54
51
  'return',
55
- 'short',
56
52
  'static',
57
53
  'super',
58
54
  'switch',
59
- 'synchronized',
60
55
  'this',
61
56
  'throw',
62
57
  'throws',
63
- 'transient',
64
58
  'true',
65
59
  'try',
66
60
  'typeof',
67
61
  'undefined',
68
62
  'var',
69
63
  'void',
70
- 'volatile',
71
64
  'while',
72
65
  'with',
73
66
  'yield',
74
- // TypeScript additional reserved words in strict mode
75
- 'as',
76
- 'any',
77
- 'declare',
78
- 'get',
79
- 'module',
80
- 'require',
81
- 'number',
82
- 'object',
83
- 'set',
84
- 'string',
85
- 'symbol',
86
- 'type',
87
- 'from',
88
- 'of',
89
67
  ])
90
68
 
91
69
  /**
@@ -234,6 +212,37 @@ interface OperationForNaming {
234
212
  operationId?: string
235
213
  }
236
214
 
215
+ /**
216
+ * Find the longest shared leading run of dot-separated operationId segments,
217
+ * always leaving at least one trailing segment on every operation. Only applies
218
+ * when every operation has a dotted operationId (e.g. Google's
219
+ * `sheets.spreadsheets.values.get`), so specs whose ids are camelCase or absent
220
+ * are left untouched. The shared prefix is redundant with the addon namespace,
221
+ * so stripping it turns `sheetsSpreadsheetsValuesGet` into `valuesGet`.
222
+ */
223
+ function commonOperationIdPrefix(operations: OperationForNaming[]): string[] {
224
+ if (operations.length < 2) {
225
+ return []
226
+ }
227
+ if (
228
+ !operations.every((op) => op.operationId && op.operationId.includes('.'))
229
+ ) {
230
+ return []
231
+ }
232
+ const segmented = operations.map((op) => op.operationId!.split('.'))
233
+ const minLen = Math.min(...segmented.map((s) => s.length))
234
+ const prefix: string[] = []
235
+ for (let i = 0; i < minLen - 1; i++) {
236
+ const seg = segmented[0][i]
237
+ if (segmented.every((s) => s[i] === seg)) {
238
+ prefix.push(seg)
239
+ } else {
240
+ break
241
+ }
242
+ }
243
+ return prefix
244
+ }
245
+
237
246
  export interface NamedOperation {
238
247
  method: string
239
248
  path: string
@@ -257,11 +266,16 @@ export function generateOperationNames(
257
266
  // would produce files that collide on disk.
258
267
  const usedNamesLower = new Map<string, number>()
259
268
 
269
+ const opIdPrefix = commonOperationIdPrefix(operations)
270
+
260
271
  for (const op of operations) {
261
272
  let name: string
262
273
 
263
274
  if (op.operationId) {
264
- name = fromOperationId(op.operationId)
275
+ const opId = opIdPrefix.length
276
+ ? op.operationId.split('.').slice(opIdPrefix.length).join('.')
277
+ : op.operationId
278
+ name = fromOperationId(opId)
265
279
  } else {
266
280
  name = deriveNameFromPath(op.method, op.path, commonPrefix)
267
281
  }