@apifuse/provider-sdk 2.2.0-beta.41 → 2.2.0-beta.42

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.
@@ -0,0 +1,113 @@
1
+ const ts = await loadTypeScript();
2
+ async function loadTypeScript() {
3
+ try {
4
+ return await import("typescript");
5
+ }
6
+ catch {
7
+ console.error("apifuse migrate-shape requires typescript; install it in the workspace running the CLI (bun add -d typescript)");
8
+ process.exit(1);
9
+ }
10
+ }
11
+ const OPERATION_HELPERS = new Set(["defineOperation", "defineStreamOperation"]);
12
+ const PROVIDER_CONTEXT_TYPE_NAME = "ProviderContext";
13
+ const PROVIDER_SDK_PROVIDER_SUBPATH = "@apifuse/provider-sdk/provider";
14
+ /**
15
+ * Migrate legacy `defineOperation(config)` calls in one source file to the
16
+ * curried `defineOperation<ProviderContext>()(config)` form.
17
+ *
18
+ * @param contextImportSpecifier module specifier the `ProviderContext` type
19
+ * import should come from when one has to be added — `"../index"` for
20
+ * operation modules, `"./index"` is never needed because index.ts declares
21
+ * the alias itself. Callers pass the correct relative path per file.
22
+ */
23
+ export function migrateOperationShape(sourceText, fileName, contextImportSpecifier) {
24
+ const source = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
25
+ const parseError = firstSyntaxError(source);
26
+ if (parseError !== undefined) {
27
+ return { status: "skipped", reason: parseError };
28
+ }
29
+ // Every legacy call site: a direct call to the bare helper identifier
30
+ // whose single argument is the config (i.e. NOT the curried form, whose
31
+ // outer call has zero arguments and optional type arguments).
32
+ const legacyCalls = [];
33
+ let sawCurried = false;
34
+ const visit = (node) => {
35
+ if (ts.isCallExpression(node) &&
36
+ ts.isIdentifier(node.expression) &&
37
+ OPERATION_HELPERS.has(node.expression.text)) {
38
+ if (node.arguments.length === 0) {
39
+ sawCurried = true; // already-migrated outer call
40
+ }
41
+ else {
42
+ legacyCalls.push(node);
43
+ }
44
+ }
45
+ ts.forEachChild(node, visit);
46
+ };
47
+ visit(source);
48
+ if (legacyCalls.length === 0) {
49
+ return { status: "unchanged", code: sourceText };
50
+ }
51
+ // A file mixing both forms almost certainly had a partial hand-migration;
52
+ // refuse rather than guess which convention the author wants.
53
+ if (sawCurried) {
54
+ return {
55
+ status: "skipped",
56
+ reason: "File mixes legacy defineOperation(config) with curried defineOperation<...>()(config); migrate it by hand.",
57
+ };
58
+ }
59
+ const declaresContextAlias = source.statements.some((statement) => ts.isTypeAliasDeclaration(statement) &&
60
+ statement.name.text === PROVIDER_CONTEXT_TYPE_NAME);
61
+ const importsContext = sourceText.includes(PROVIDER_CONTEXT_TYPE_NAME);
62
+ const needsImport = !declaresContextAlias && !importsContext;
63
+ const edits = [];
64
+ for (const call of legacyCalls) {
65
+ // `defineOperation(` -> `defineOperation<ProviderContext>()(`
66
+ const callee = call.expression;
67
+ edits.push({
68
+ start: callee.getEnd(),
69
+ end: callee.getEnd(),
70
+ text: `<${PROVIDER_CONTEXT_TYPE_NAME}>()`,
71
+ });
72
+ }
73
+ let importAdded = false;
74
+ if (needsImport) {
75
+ const lastImport = [...source.statements]
76
+ .reverse()
77
+ .find((statement) => ts.isImportDeclaration(statement));
78
+ const insertAt = lastImport ? lastImport.getEnd() : 0;
79
+ const importText = `${lastImport ? "\n" : ""}import type { ${PROVIDER_CONTEXT_TYPE_NAME} } from "${contextImportSpecifier}";${lastImport ? "" : "\n"}`;
80
+ edits.push({ start: insertAt, end: insertAt, text: importText });
81
+ importAdded = true;
82
+ }
83
+ const ordered = edits.sort((a, b) => b.start - a.start);
84
+ let code = sourceText;
85
+ for (const edit of ordered) {
86
+ code = code.slice(0, edit.start) + edit.text + code.slice(edit.end);
87
+ }
88
+ const verified = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
89
+ const outputError = firstSyntaxError(verified);
90
+ if (outputError !== undefined) {
91
+ return {
92
+ status: "skipped",
93
+ reason: `Transform produced source that does not parse (${outputError}); refusing to emit a partial migration.`,
94
+ };
95
+ }
96
+ return {
97
+ status: "migrated",
98
+ code,
99
+ rewrites: legacyCalls.length,
100
+ importAdded,
101
+ };
102
+ }
103
+ function firstSyntaxError(source) {
104
+ const diagnostics = source.parseDiagnostics;
105
+ if (diagnostics === undefined || diagnostics.length === 0)
106
+ return undefined;
107
+ const first = diagnostics[0];
108
+ if (first === undefined)
109
+ return undefined;
110
+ const message = ts.flattenDiagnosticMessageText(first.messageText, " ");
111
+ const { line } = source.getLineAndCharacterOfPosition(first.start);
112
+ return `${message} (line ${line + 1})`;
113
+ }
@@ -1,4 +1,13 @@
1
- import ts from "typescript";
1
+ const ts = await loadTypeScript();
2
+ async function loadTypeScript() {
3
+ try {
4
+ return await import("typescript");
5
+ }
6
+ catch {
7
+ console.error("apifuse migrate-shape requires typescript; install it in the workspace running the CLI (bun add -d typescript)");
8
+ process.exit(1);
9
+ }
10
+ }
2
11
  const DECLARATION_BUILDER_NAME = "buildProvider";
3
12
  const PROVIDER_CONTEXT_TYPE_NAME = "ProviderContext";
4
13
  const PROVIDER_CONTEXT_OF_TYPE_NAME = "ProviderContextOf";
@@ -397,6 +406,12 @@ function ensureProviderContextType(source, call, shape, builderName) {
397
406
  const edits = [];
398
407
  const hasContextTypeAlias = source.statements.some((statement) => ts.isTypeAliasDeclaration(statement) &&
399
408
  statement.name.text === PROVIDER_CONTEXT_TYPE_NAME);
409
+ // A named import of `ProviderContext` (the deprecated SDK-root context
410
+ // type, imported by legacy sources) occupies the same name: adding the
411
+ // alias alongside it is TS2440. The import must yield to the derived
412
+ // alias — the whole point of the two-phase shape — so drop it and let the
413
+ // alias own the name.
414
+ const conflictingImportSpecifier = findNamedImportSpecifier(source, PROVIDER_CONTEXT_TYPE_NAME);
400
415
  if (!hasContextTypeAlias) {
401
416
  // After the builder statement — which is the variable statement when the
402
417
  // declaration was bound, or the rewritten export statement otherwise.
@@ -409,6 +424,9 @@ function ensureProviderContextType(source, call, shape, builderName) {
409
424
  end: insertAt,
410
425
  text: `\n\nexport type ${PROVIDER_CONTEXT_TYPE_NAME} = ${PROVIDER_CONTEXT_OF_TYPE_NAME}<typeof ${builderName}>;`,
411
426
  });
427
+ if (conflictingImportSpecifier !== undefined) {
428
+ edits.push(removeImportSpecifier(source, conflictingImportSpecifier));
429
+ }
412
430
  }
413
431
  }
414
432
  if (text.includes(PROVIDER_CONTEXT_OF_TYPE_NAME)) {
@@ -454,6 +472,51 @@ function findProviderSdkImport(source) {
454
472
  }
455
473
  return undefined;
456
474
  }
475
+ /** Named import specifier binding `localName` in any import declaration. */
476
+ function findNamedImportSpecifier(source, localName) {
477
+ for (const statement of source.statements) {
478
+ if (!ts.isImportDeclaration(statement))
479
+ continue;
480
+ const named = statement.importClause?.namedBindings;
481
+ if (named === undefined || !ts.isNamedImports(named))
482
+ continue;
483
+ for (const element of named.elements) {
484
+ if (element.name.text === localName)
485
+ return element;
486
+ }
487
+ }
488
+ return undefined;
489
+ }
490
+ /**
491
+ * Edit removing one specifier from its named-import list, absorbing one
492
+ * neighboring comma so the list stays well-formed. Callers guarantee the
493
+ * list has at least one other specifier (legacy sources always import
494
+ * defineProvider alongside the context type).
495
+ */
496
+ function removeImportSpecifier(source, specifier) {
497
+ const list = specifier.parent;
498
+ const index = list.elements.indexOf(specifier);
499
+ const text = source.getFullText();
500
+ let start = specifier.getFullStart();
501
+ let end = specifier.getEnd();
502
+ let cursor = end;
503
+ while (cursor < text.length && /\s/.test(text.charAt(cursor)))
504
+ cursor += 1;
505
+ if (text.charAt(cursor) === ",") {
506
+ end = cursor + 1;
507
+ }
508
+ else if (index > 0) {
509
+ const previous = list.elements[index - 1];
510
+ if (previous !== undefined) {
511
+ let back = previous.getEnd();
512
+ while (back < text.length && /\s/.test(text.charAt(back)))
513
+ back += 1;
514
+ if (text.charAt(back) === ",")
515
+ start = back;
516
+ }
517
+ }
518
+ return { start, end, text: "" };
519
+ }
457
520
  /**
458
521
  * `buildProvider` unless the module already binds that name, in which case a
459
522
  * numbered suffix keeps the transform from shadowing an existing binding.
@@ -9,7 +9,7 @@ import { validateFailClosedDeclaration } from "../declaration-validation.js";
9
9
  import { safeProviderErrorObservability } from "../error-observability.js";
10
10
  import { SDK_OWNED_PROVIDER_ERROR_CODES, SDK_RUNTIME_OWNED_ERROR_CODES, SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES, } from "../error-resolution.js";
11
11
  import { AuthError, isProviderError, isSessionExpiredError, isTransportError, isValidationError, ProviderError, } from "../errors.js";
12
- import { sanitizeDiagnosticText } from "../fixture-sanitization.js";
12
+ import { REDACTED_FIXTURE_VALUE, sanitizeDiagnosticText, } from "../fixture-sanitization.js";
13
13
  import { loadProviderLocaleCatalogs, localizeAuthTurn, } from "../i18n/catalog.js";
14
14
  import { categoryForStatus, sourceForCategory, isRetryableCategory, PROVIDER_OBSERVABILITY_TAXONOMY_VERSION, } from "../observability.js";
15
15
  import { createScratchpad } from "../runtime/auth-flow.js";
@@ -979,8 +979,47 @@ function extractRequestId(raw) {
979
979
  }
980
980
  const MAX_PROVIDER_ERROR_CAUSE_FRAMES = 5;
981
981
  const MAX_PROVIDER_ERROR_CAUSE_MESSAGE_LENGTH = 300;
982
+ const UNSTRUCTURED_PROVIDER_ERROR_CAUSE_MESSAGE = "[UNSTRUCTURED_UPSTREAM_TEXT]";
983
+ const PROVIDER_ERROR_CAUSE_RETAINED_URL_RUN = /https?:\/\/[^\s"'<>]+/giu;
984
+ const PROVIDER_ERROR_CAUSE_TOKEN_RUN = /\S+/gu;
985
+ const PROVIDER_ERROR_CAUSE_TOKEN_EDGE_PUNCTUATION = /^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu;
986
+ const STRUCTURALLY_SAFE_PROVIDER_ERROR_CAUSE_WORDS = new Set([
987
+ "completion",
988
+ "diagnostic",
989
+ "provider",
990
+ "rejected",
991
+ "returned",
992
+ "upstream",
993
+ ]);
994
+ /**
995
+ * Cause frames fail closed when sanitization leaves a plausible credential-shaped free-text run.
996
+ * Redaction sentinels and retained URLs are ignored. Every other whitespace token (or each side
997
+ * of a structured key=value token) with at least eight Unicode characters must reduce to this
998
+ * small vocabulary drawn from SDK diagnostics; counting punctuation keeps bare passwords opaque.
999
+ */
1000
+ function isStructurallySafeProviderErrorCauseMessage(message) {
1001
+ const classifiableMessage = message
1002
+ .replaceAll(REDACTED_FIXTURE_VALUE, " ")
1003
+ .replace(PROVIDER_ERROR_CAUSE_RETAINED_URL_RUN, " ");
1004
+ for (const match of classifiableMessage.matchAll(PROVIDER_ERROR_CAUSE_TOKEN_RUN)) {
1005
+ const token = match[0];
1006
+ for (const run of token.split("=")) {
1007
+ const diagnosticWord = run
1008
+ .replace(PROVIDER_ERROR_CAUSE_TOKEN_EDGE_PUNCTUATION, "")
1009
+ .toLowerCase();
1010
+ if (STRUCTURALLY_SAFE_PROVIDER_ERROR_CAUSE_WORDS.has(diagnosticWord))
1011
+ continue;
1012
+ if ([...run].length >= 8)
1013
+ return false;
1014
+ }
1015
+ }
1016
+ return true;
1017
+ }
982
1018
  function providerErrorCauseMessage(message) {
983
1019
  const sanitized = sanitizeDiagnosticText(message);
1020
+ if (!isStructurallySafeProviderErrorCauseMessage(sanitized)) {
1021
+ return UNSTRUCTURED_PROVIDER_ERROR_CAUSE_MESSAGE;
1022
+ }
984
1023
  return sanitized.length > MAX_PROVIDER_ERROR_CAUSE_MESSAGE_LENGTH
985
1024
  ? `${sanitized.slice(0, MAX_PROVIDER_ERROR_CAUSE_MESSAGE_LENGTH)}… [truncated]`
986
1025
  : sanitized;
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0-beta.41",
2
+ "version": "2.2.0-beta.42",
3
3
  "name": "@apifuse/provider-sdk",
4
4
  "private": false,
5
5
  "type": "module",
@@ -128,7 +128,8 @@
128
128
  "@microsoft/api-extractor": "^7.58.13",
129
129
  "@types/bun": "latest",
130
130
  "@types/node": "^25.9.3",
131
- "ajv": "^8.17"
131
+ "ajv": "^8.17",
132
+ "typescript": "6.0.3"
132
133
  },
133
134
  "dependencies": {
134
135
  "@clack/prompts": "^1.5.1",
@@ -145,7 +146,6 @@
145
146
  "safe-regex": "^2.1",
146
147
  "socks": "^2.8.9",
147
148
  "tough-cookie": "^6.0.2",
148
- "typescript": "6.0.3",
149
149
  "wreq-js": "3.0.0",
150
150
  "zod": "^4.4.3"
151
151
  },
@@ -59,7 +59,7 @@ export const COMMAND_MANIFEST: Record<
59
59
  "migrate-shape": {
60
60
  name: "migrate-shape",
61
61
  summary:
62
- "Migrate a provider index.ts from the single-phase defineProvider shape to the two-phase declaration builder.",
62
+ "Migrate a provider to the phase-separated SDK: two-phase defineProvider in index.ts and curried defineOperation across sources.",
63
63
  usage: "apifuse migrate-shape [path] [--check] [--json]",
64
64
  examples: ["apifuse migrate-shape .", "apifuse migrate-shape . --check"],
65
65
  modulePath: "./apifuse-migrate-shape",
@@ -0,0 +1,184 @@
1
+ import type TS from "typescript";
2
+
3
+ const ts: typeof import("typescript") = await loadTypeScript();
4
+
5
+ async function loadTypeScript(): Promise<typeof import("typescript")> {
6
+ try {
7
+ return await import("typescript");
8
+ } catch {
9
+ console.error(
10
+ "apifuse migrate-shape requires typescript; install it in the workspace running the CLI (bun add -d typescript)",
11
+ );
12
+ process.exit(1);
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Operation authoring-shape migration.
18
+ *
19
+ * `defineOperation` / `defineStreamOperation` changed alongside the provider
20
+ * builder in 2.2.0-beta.37: the identity helper became a curried,
21
+ * context-typed factory.
22
+ *
23
+ * before: defineOperation({ ...config })
24
+ * after: defineOperation<ProviderContext>()({ ...config })
25
+ *
26
+ * The old call still type-checks against an older pin, but under the new SDK
27
+ * it returns the INNER FACTORY FUNCTION with the config swallowed as an
28
+ * ignored argument, so every operation in the map becomes a function and the
29
+ * provider fails `finalizeProvider` with a misleading "declares neither
30
+ * healthCheck nor healthCheckUnsupported" error. Measured fleet blast radius
31
+ * (2026-08-29): 53 of 84 repositories, 450 legacy call sites.
32
+ *
33
+ * Same contract as the provider-shape transform: rewrite only what is fully
34
+ * understood, report a reasoned skip otherwise, never emit a partial file.
35
+ */
36
+
37
+ export type OperationShapeMigration =
38
+ | {
39
+ readonly status: "migrated";
40
+ readonly code: string;
41
+ /** Number of call sites rewritten in this file. */
42
+ readonly rewrites: number;
43
+ /** True when a `ProviderContext` type import was added. */
44
+ readonly importAdded: boolean;
45
+ }
46
+ | { readonly status: "unchanged"; readonly code: string }
47
+ | { readonly status: "skipped"; readonly reason: string };
48
+
49
+ const OPERATION_HELPERS = new Set(["defineOperation", "defineStreamOperation"]);
50
+ const PROVIDER_CONTEXT_TYPE_NAME = "ProviderContext";
51
+ const PROVIDER_SDK_PROVIDER_SUBPATH = "@apifuse/provider-sdk/provider";
52
+
53
+ /**
54
+ * Migrate legacy `defineOperation(config)` calls in one source file to the
55
+ * curried `defineOperation<ProviderContext>()(config)` form.
56
+ *
57
+ * @param contextImportSpecifier module specifier the `ProviderContext` type
58
+ * import should come from when one has to be added — `"../index"` for
59
+ * operation modules, `"./index"` is never needed because index.ts declares
60
+ * the alias itself. Callers pass the correct relative path per file.
61
+ */
62
+ export function migrateOperationShape(
63
+ sourceText: string,
64
+ fileName: string,
65
+ contextImportSpecifier: string,
66
+ ): OperationShapeMigration {
67
+ const source = ts.createSourceFile(
68
+ fileName,
69
+ sourceText,
70
+ ts.ScriptTarget.Latest,
71
+ true,
72
+ ts.ScriptKind.TS,
73
+ );
74
+
75
+ const parseError = firstSyntaxError(source);
76
+ if (parseError !== undefined) {
77
+ return { status: "skipped", reason: parseError };
78
+ }
79
+
80
+ // Every legacy call site: a direct call to the bare helper identifier
81
+ // whose single argument is the config (i.e. NOT the curried form, whose
82
+ // outer call has zero arguments and optional type arguments).
83
+ const legacyCalls: TS.CallExpression[] = [];
84
+ let sawCurried = false;
85
+ const visit = (node: TS.Node): void => {
86
+ if (
87
+ ts.isCallExpression(node) &&
88
+ ts.isIdentifier(node.expression) &&
89
+ OPERATION_HELPERS.has(node.expression.text)
90
+ ) {
91
+ if (node.arguments.length === 0) {
92
+ sawCurried = true; // already-migrated outer call
93
+ } else {
94
+ legacyCalls.push(node);
95
+ }
96
+ }
97
+ ts.forEachChild(node, visit);
98
+ };
99
+ visit(source);
100
+
101
+ if (legacyCalls.length === 0) {
102
+ return { status: "unchanged", code: sourceText };
103
+ }
104
+
105
+ // A file mixing both forms almost certainly had a partial hand-migration;
106
+ // refuse rather than guess which convention the author wants.
107
+ if (sawCurried) {
108
+ return {
109
+ status: "skipped",
110
+ reason:
111
+ "File mixes legacy defineOperation(config) with curried defineOperation<...>()(config); migrate it by hand.",
112
+ };
113
+ }
114
+
115
+ const declaresContextAlias = source.statements.some(
116
+ (statement) =>
117
+ ts.isTypeAliasDeclaration(statement) &&
118
+ statement.name.text === PROVIDER_CONTEXT_TYPE_NAME,
119
+ );
120
+ const importsContext = sourceText.includes(PROVIDER_CONTEXT_TYPE_NAME);
121
+ const needsImport = !declaresContextAlias && !importsContext;
122
+
123
+ const edits: { start: number; end: number; text: string }[] = [];
124
+ for (const call of legacyCalls) {
125
+ // `defineOperation(` -> `defineOperation<ProviderContext>()(`
126
+ const callee = call.expression;
127
+ edits.push({
128
+ start: callee.getEnd(),
129
+ end: callee.getEnd(),
130
+ text: `<${PROVIDER_CONTEXT_TYPE_NAME}>()`,
131
+ });
132
+ }
133
+
134
+ let importAdded = false;
135
+ if (needsImport) {
136
+ const lastImport = [...source.statements]
137
+ .reverse()
138
+ .find((statement) => ts.isImportDeclaration(statement));
139
+ const insertAt = lastImport ? lastImport.getEnd() : 0;
140
+ const importText = `${lastImport ? "\n" : ""}import type { ${PROVIDER_CONTEXT_TYPE_NAME} } from "${contextImportSpecifier}";${lastImport ? "" : "\n"}`;
141
+ edits.push({ start: insertAt, end: insertAt, text: importText });
142
+ importAdded = true;
143
+ }
144
+
145
+ const ordered = edits.sort((a, b) => b.start - a.start);
146
+ let code = sourceText;
147
+ for (const edit of ordered) {
148
+ code = code.slice(0, edit.start) + edit.text + code.slice(edit.end);
149
+ }
150
+
151
+ const verified = ts.createSourceFile(
152
+ fileName,
153
+ code,
154
+ ts.ScriptTarget.Latest,
155
+ true,
156
+ ts.ScriptKind.TS,
157
+ );
158
+ const outputError = firstSyntaxError(verified);
159
+ if (outputError !== undefined) {
160
+ return {
161
+ status: "skipped",
162
+ reason: `Transform produced source that does not parse (${outputError}); refusing to emit a partial migration.`,
163
+ };
164
+ }
165
+
166
+ return {
167
+ status: "migrated",
168
+ code,
169
+ rewrites: legacyCalls.length,
170
+ importAdded,
171
+ };
172
+ }
173
+
174
+ function firstSyntaxError(source: TS.SourceFile): string | undefined {
175
+ const diagnostics = (
176
+ source as TS.SourceFile & { parseDiagnostics?: TS.DiagnosticWithLocation[] }
177
+ ).parseDiagnostics;
178
+ if (diagnostics === undefined || diagnostics.length === 0) return undefined;
179
+ const first = diagnostics[0];
180
+ if (first === undefined) return undefined;
181
+ const message = ts.flattenDiagnosticMessageText(first.messageText, " ");
182
+ const { line } = source.getLineAndCharacterOfPosition(first.start);
183
+ return `${message} (line ${line + 1})`;
184
+ }