@pikku/openapi-parser 0.12.15 → 0.12.17

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,61 @@
1
1
  # @pikku/openapi-parser
2
2
 
3
+ ## 0.12.17
4
+
5
+ ### Patch Changes
6
+
7
+ - a7b26c5: rename the inspected declarations to `define*`: `wireScope` → `defineScope`, `wireSecret` → `defineSecret`, `wireVariable` → `defineVariable`, `wireCredential` → `defineCredential`
8
+
9
+ `wire*` meant two unrelated things. A transport wiring attaches a function to
10
+ something that can invoke it — `wireHTTP`, `wireChannel`, `wireScheduler`,
11
+ `wireQueueWorker` and the rest — and the thing it wires runs. These four wire
12
+ nothing: they are no-ops that exist only so the call typechecks, they are
13
+ tree-shaken out of the build, and their whole job is to be found by the
14
+ inspector's AST pass and turned into a type union. One word for both left the
15
+ declaration reading like a registration with a runtime.
16
+
17
+ So the vocabulary splits: **`wire*` is a transport, `define*` is an inspected
18
+ declaration.**
19
+
20
+ ```ts
21
+ import { defineScope } from '@pikku/core/scope'
22
+ import { defineSecret } from '@pikku/core/secret'
23
+ import { defineVariable } from '@pikku/core/variable'
24
+ import { defineCredential } from '@pikku/core/credential'
25
+
26
+ defineScope({ admin: { scopes: { invoices: { scopes: { create: {} } } } } })
27
+ ```
28
+
29
+ **Breaking:** no alias is kept. Rename the four call sites; the module subpaths
30
+ (`@pikku/core/scope`, `/secret`, `/variable`) are unchanged.
31
+
32
+ The inspector matches these by identifier text, so a stale `wire*` call is not a
33
+ type error — it is silently not extracted, and the generated union comes back
34
+ empty. That fails as "this scope isn't declared" on code that was fine a moment
35
+ ago, nowhere near the declaration. Grep for the old names rather than trusting a
36
+ clean build.
37
+
38
+ An addon published with `.pikku` output generated before this release re-exports
39
+ `wireSecret` from `@pikku/core/secret` and will not typecheck against this core
40
+ until it is rebuilt and republished.
41
+
42
+ ## 0.12.16
43
+
44
+ ### Patch Changes
45
+
46
+ - 9f0d0eb: Migrate the `--oauth` addon scaffold off `OAuth2Client`. A scaffolded OAuth2
47
+ addon service used to construct `new OAuth2Client(config, appCredentialSecretId,
48
+ secrets)` and do its own token exchange/refresh — the responsibility better-auth
49
+ now owns via the credential service. The `pikku new addon --oauth` scaffold (and
50
+ the OpenAPI `--openapi` generator) now emit a service that receives a ready
51
+ access token: `services.ts` uses `createWireServices` + `wire.getCredential<{
52
+ accessToken: string }>(name)` and the service does a plain `fetch` with
53
+ `Authorization: Bearer ${accessToken}`, matching the existing per-user
54
+ bearer/apikey credential scaffold. With no remaining consumers, `OAuth2Client`
55
+ (`@pikku/core/oauth2`) and its test are removed; the `./oauth2` export keeps the
56
+ `OAuth2AppCredential` / `OAuth2Token` types.
57
+ - 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`.
58
+
3
59
  ## 0.12.15
4
60
 
5
61
  ### Patch Changes
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 - present Yasser Fadl and Pikku contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # @pikku/openapi-parser
2
+
3
+ Parses OpenAPI specs into a normalised shape and generates Pikku addon code
4
+ from them — operations, parameters, error responses and security schemes.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ npm install -D @pikku/openapi-parser
10
+ ```
11
+
12
+ ## Usage
13
+
14
+ ```typescript
15
+ import { parseOpenAPISpec, computeContractHash } from '@pikku/openapi-parser'
16
+
17
+ const spec = await parseOpenAPISpec(specPath)
18
+ const hash = computeContractHash(spec)
19
+ ```
20
+
21
+ The contract hash lets generated addons detect when an upstream spec changed.
22
+
23
+ ## Docs
24
+
25
+ https://pikku.dev/docs
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) {
@@ -1122,7 +1105,9 @@ function generateServiceFile(spec, opPairs, vars, flags) {
1122
1105
  lines.push(' })');
1123
1106
  }
1124
1107
  else if (flags.oauth || flags.credential === 'oauth2') {
1125
- 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(), {');
1126
1111
  lines.push(' method,');
1127
1112
  lines.push(' headers,');
1128
1113
  lines.push(' body: body ? JSON.stringify(body) : undefined,');
@@ -1187,6 +1172,63 @@ function generateServiceFile(spec, opPairs, vars, flags) {
1187
1172
  lines.push('');
1188
1173
  return lines.join('\n');
1189
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 { defineCredential } from '@pikku/core/credential'");
1188
+ lines.push("import { defineSecret } 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('defineCredential({');
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('defineSecret({');
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
+ }
1190
1232
  function generateVariableFile(spec, vars) {
1191
1233
  const { camelName, screamingName } = vars;
1192
1234
  const displayName = vars.displayName.replace(/'/g, '');
@@ -1194,7 +1236,7 @@ function generateVariableFile(spec, vars) {
1194
1236
  const defaultUrl = serverUrls[0];
1195
1237
  const lines = [];
1196
1238
  lines.push("import { z } from 'zod'");
1197
- lines.push("import { wireVariable } from '@pikku/core/variable'");
1239
+ lines.push("import { defineVariable } from '@pikku/core/variable'");
1198
1240
  lines.push('');
1199
1241
  const schemaVarName = `${camelName}BaseUrlSchema`;
1200
1242
  if (serverUrls.length > 0) {
@@ -1205,7 +1247,7 @@ function generateVariableFile(spec, vars) {
1205
1247
  lines.push(`export const ${schemaVarName} = z.string().describe('Base URL for the ${displayName} API')`);
1206
1248
  }
1207
1249
  lines.push('');
1208
- lines.push(`wireVariable({`);
1250
+ lines.push(`defineVariable({`);
1209
1251
  lines.push(` name: '${screamingName}_BASE_URL',`);
1210
1252
  lines.push(` displayName: '${displayName} Base URL',`);
1211
1253
  lines.push(` description: 'The base URL for the ${displayName} API.',`);
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.15",
3
+ "version": "0.12.17",
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('')
@@ -1399,9 +1379,7 @@ function generateServiceFile(
1399
1379
  lines.push(' path: string,')
1400
1380
  lines.push(' data?: unknown')
1401
1381
  lines.push(' ): Promise<T> {')
1402
- lines.push(
1403
- ' const input = data as Record<string, unknown> | undefined'
1404
- )
1382
+ lines.push(' const input = data as Record<string, unknown> | undefined')
1405
1383
  if (flags.camelCase) {
1406
1384
  lines.push(' const rawData = input ? _toSnakeCase(input) : input')
1407
1385
  }
@@ -1500,9 +1478,9 @@ function generateServiceFile(
1500
1478
  lines.push(' body: body ? JSON.stringify(body) : undefined,')
1501
1479
  lines.push(' })')
1502
1480
  } else if (flags.oauth || flags.credential === 'oauth2') {
1503
- lines.push(
1504
- ' const response = await this.oauth.request(url.toString(), {'
1505
- )
1481
+ lines.push(' headers.Authorization = `Bearer ${this.creds.accessToken}`')
1482
+ lines.push('')
1483
+ lines.push(' const response = await fetch(url.toString(), {')
1506
1484
  lines.push(' method,')
1507
1485
  lines.push(' headers,')
1508
1486
  lines.push(' body: body ? JSON.stringify(body) : undefined,')
@@ -1581,6 +1559,79 @@ function generateServiceFile(
1581
1559
  return lines.join('\n')
1582
1560
  }
1583
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 { defineCredential } from '@pikku/core/credential'")
1579
+ lines.push("import { defineSecret } 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('defineCredential({')
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('defineSecret({')
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
+
1584
1635
  function generateVariableFile(spec: ParsedSpec, vars: AddonVars): string {
1585
1636
  const { camelName, screamingName } = vars
1586
1637
  const displayName = vars.displayName.replace(/'/g, '')
@@ -1589,7 +1640,7 @@ function generateVariableFile(spec: ParsedSpec, vars: AddonVars): string {
1589
1640
 
1590
1641
  const lines: string[] = []
1591
1642
  lines.push("import { z } from 'zod'")
1592
- lines.push("import { wireVariable } from '@pikku/core/variable'")
1643
+ lines.push("import { defineVariable } from '@pikku/core/variable'")
1593
1644
  lines.push('')
1594
1645
 
1595
1646
  const schemaVarName = `${camelName}BaseUrlSchema`
@@ -1606,7 +1657,7 @@ function generateVariableFile(spec: ParsedSpec, vars: AddonVars): string {
1606
1657
  }
1607
1658
 
1608
1659
  lines.push('')
1609
- lines.push(`wireVariable({`)
1660
+ lines.push(`defineVariable({`)
1610
1661
  lines.push(` name: '${screamingName}_BASE_URL',`)
1611
1662
  lines.push(` displayName: '${displayName} Base URL',`)
1612
1663
  lines.push(` description: 'The base URL for the ${displayName} API.',`)
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
  }