@apifuse/provider-sdk 2.0.0-beta.1 → 2.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AUTHORING.md +93 -0
- package/CHANGELOG.md +21 -0
- package/README.md +133 -28
- package/bin/apifuse-check.ts +78 -71
- package/bin/apifuse-create.ts +12 -0
- package/bin/apifuse-dev.ts +24 -61
- package/bin/apifuse-pack-check.ts +87 -0
- package/bin/apifuse-pack-smoke.ts +122 -0
- package/bin/apifuse-perf.ts +33 -32
- package/bin/apifuse-record.ts +17 -7
- package/bin/apifuse-test.ts +6 -4
- package/bin/apifuse.ts +36 -35
- package/package.json +29 -9
- package/src/ceremonies/index.ts +768 -0
- package/src/cli/commands.ts +87 -0
- package/src/cli/create.ts +845 -0
- package/src/cli/templates/provider/Dockerfile.tpl +7 -0
- package/src/cli/templates/provider/README.md.tpl +41 -0
- package/src/cli/templates/provider/dev.ts.tpl +5 -0
- package/src/cli/templates/provider/index.test.ts.tpl +13 -0
- package/src/cli/templates/provider/index.ts.tpl +58 -0
- package/src/cli/templates/provider/start.ts.tpl +5 -0
- package/src/config/loader.ts +61 -1
- package/src/define.ts +565 -41
- package/src/dev.ts +2 -6
- package/src/errors.ts +42 -0
- package/src/index.ts +44 -38
- package/src/lint.ts +574 -0
- package/src/provider.ts +13 -0
- package/src/runtime/auth-flow.ts +67 -0
- package/src/runtime/credential.ts +95 -0
- package/src/runtime/env.ts +13 -0
- package/src/runtime/executor.ts +13 -14
- package/src/runtime/http.ts +36 -12
- package/src/runtime/insights.ts +3 -3
- package/src/runtime/key-derivation.ts +122 -0
- package/src/runtime/keyring.ts +148 -0
- package/src/runtime/namespace.ts +33 -0
- package/src/runtime/prevalidate.ts +252 -0
- package/src/runtime/tls.ts +41 -17
- package/src/runtime/waterfall.ts +0 -1
- package/src/schema.ts +77 -0
- package/src/serve.ts +1 -664
- package/src/server/index.ts +22 -0
- package/src/server/serve.ts +624 -0
- package/src/server/types.ts +78 -0
- package/src/stealth/profiles.ts +10 -93
- package/src/testing/run.ts +391 -32
- package/src/types.ts +390 -41
- package/bin/apifuse-init.ts +0 -387
- package/src/__tests__/auth.test.ts +0 -396
- package/src/__tests__/browser-auth.test.ts +0 -180
- package/src/__tests__/browser.test.ts +0 -632
- package/src/__tests__/define.test.ts +0 -225
- package/src/__tests__/errors.test.ts +0 -69
- package/src/__tests__/executor.test.ts +0 -214
- package/src/__tests__/http.test.ts +0 -238
- package/src/__tests__/insights.test.ts +0 -210
- package/src/__tests__/instrumentation.test.ts +0 -290
- package/src/__tests__/otlp.test.ts +0 -141
- package/src/__tests__/perf.test.ts +0 -60
- package/src/__tests__/providers-yaml.test.ts +0 -135
- package/src/__tests__/proxy.test.ts +0 -359
- package/src/__tests__/recipes.test.ts +0 -36
- package/src/__tests__/serve.test.ts +0 -233
- package/src/__tests__/session.test.ts +0 -231
- package/src/__tests__/state.test.ts +0 -100
- package/src/__tests__/stealth.test.ts +0 -57
- package/src/__tests__/testing.test.ts +0 -97
- package/src/__tests__/tls.test.ts +0 -345
- package/src/__tests__/types.test.ts +0 -142
- package/src/__tests__/utils.test.ts +0 -62
- package/src/__tests__/waterfall.test.ts +0 -270
- package/src/config/providers-yaml.ts +0 -370
- package/src/index.test.ts +0 -1
- package/src/protocol.ts +0 -183
- package/src/runtime/auth.ts +0 -245
- package/src/runtime/session.ts +0 -573
- package/src/runtime/state.ts +0 -124
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, relative, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
cancel,
|
|
9
|
+
intro,
|
|
10
|
+
isCancel,
|
|
11
|
+
note,
|
|
12
|
+
outro,
|
|
13
|
+
select,
|
|
14
|
+
text,
|
|
15
|
+
} from "@clack/prompts";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
|
|
18
|
+
import packageJson from "../../package.json";
|
|
19
|
+
|
|
20
|
+
export const PROVIDER_NAME_REGEX = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
21
|
+
export const CATEGORY_OPTIONS = [
|
|
22
|
+
"developer-tools",
|
|
23
|
+
"finance",
|
|
24
|
+
"commerce",
|
|
25
|
+
"productivity",
|
|
26
|
+
"marketing",
|
|
27
|
+
"data",
|
|
28
|
+
"communication",
|
|
29
|
+
"other",
|
|
30
|
+
] as const;
|
|
31
|
+
export const AUTH_MODE_OPTIONS = [
|
|
32
|
+
"none",
|
|
33
|
+
"platform-managed",
|
|
34
|
+
"credentials",
|
|
35
|
+
"oauth2",
|
|
36
|
+
] as const;
|
|
37
|
+
export const RUNTIME_OPTIONS = ["standard", "browser"] as const;
|
|
38
|
+
export const PRESET_OPTIONS = ["standalone", "monorepo"] as const;
|
|
39
|
+
|
|
40
|
+
export type CreateCategory = (typeof CATEGORY_OPTIONS)[number];
|
|
41
|
+
export type CreateAuthMode = (typeof AUTH_MODE_OPTIONS)[number];
|
|
42
|
+
export type CreateRuntime = (typeof RUNTIME_OPTIONS)[number];
|
|
43
|
+
export type CreatePreset = (typeof PRESET_OPTIONS)[number];
|
|
44
|
+
|
|
45
|
+
export type CreateConfigFile = Partial<CreateResolvedOptions> & {
|
|
46
|
+
name?: string;
|
|
47
|
+
outputDir?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const CREATE_CONFIG_SCHEMA = z.object({
|
|
51
|
+
authMode: z.enum(AUTH_MODE_OPTIONS).optional(),
|
|
52
|
+
category: z.enum(CATEGORY_OPTIONS).optional(),
|
|
53
|
+
displayName: z.string().optional(),
|
|
54
|
+
dryRun: z.boolean().optional(),
|
|
55
|
+
json: z.boolean().optional(),
|
|
56
|
+
name: z.string().optional(),
|
|
57
|
+
outputDir: z.string().optional(),
|
|
58
|
+
preset: z.enum(PRESET_OPTIONS).optional(),
|
|
59
|
+
runtime: z.enum(RUNTIME_OPTIONS).optional(),
|
|
60
|
+
sdkSpecifier: z.string().optional(),
|
|
61
|
+
yes: z.boolean().optional(),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export type CreateResolvedOptions = {
|
|
65
|
+
authMode: CreateAuthMode;
|
|
66
|
+
category: CreateCategory;
|
|
67
|
+
displayName: string;
|
|
68
|
+
dryRun: boolean;
|
|
69
|
+
json: boolean;
|
|
70
|
+
name: string;
|
|
71
|
+
outputDir?: string;
|
|
72
|
+
preset: CreatePreset;
|
|
73
|
+
runtime: CreateRuntime;
|
|
74
|
+
sdkSpecifier?: string;
|
|
75
|
+
yes: boolean;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type ProviderPlanFile = {
|
|
79
|
+
path: string;
|
|
80
|
+
content: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export type ProviderCreatePlan = {
|
|
84
|
+
displayName: string;
|
|
85
|
+
installCommand: string;
|
|
86
|
+
installCwd: string;
|
|
87
|
+
name: string;
|
|
88
|
+
nextDevCommand: string;
|
|
89
|
+
outputDir: string;
|
|
90
|
+
packageName: string;
|
|
91
|
+
preset: CreatePreset;
|
|
92
|
+
providerRoot: string;
|
|
93
|
+
validationCommands: string[];
|
|
94
|
+
files: ProviderPlanFile[];
|
|
95
|
+
workspaceRoot?: string;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const TEMPLATE_DIR = fileURLToPath(
|
|
99
|
+
new URL("./templates/provider/", import.meta.url),
|
|
100
|
+
);
|
|
101
|
+
const HELP_TEXT = `Usage: apifuse create <provider-name> [options]
|
|
102
|
+
Examples:
|
|
103
|
+
apifuse create my-provider
|
|
104
|
+
apifuse create my-provider --preset monorepo
|
|
105
|
+
apifuse create --config ./apifuse.create.json --json
|
|
106
|
+
|
|
107
|
+
Options:
|
|
108
|
+
--preset <standalone|monorepo>
|
|
109
|
+
--config <path>
|
|
110
|
+
--output-dir <path>
|
|
111
|
+
--display-name <name>
|
|
112
|
+
--category <category>
|
|
113
|
+
--auth-mode <mode>
|
|
114
|
+
--runtime <standard|browser>
|
|
115
|
+
--yes
|
|
116
|
+
--dry-run
|
|
117
|
+
--json
|
|
118
|
+
--sdk-specifier <specifier> # internal/testing override for standalone dependency resolution
|
|
119
|
+
--help, -h`;
|
|
120
|
+
|
|
121
|
+
export async function main() {
|
|
122
|
+
const args = process.argv.slice(2);
|
|
123
|
+
const normalizedArgs = normalizeArgs(args);
|
|
124
|
+
|
|
125
|
+
if (normalizedArgs.includes("--help") || normalizedArgs.includes("-h")) {
|
|
126
|
+
console.log(HELP_TEXT);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const parsed = parseArgs(normalizedArgs);
|
|
131
|
+
const config = parsed.configPath
|
|
132
|
+
? await loadConfig(parsed.configPath)
|
|
133
|
+
: undefined;
|
|
134
|
+
const resolved = await resolveCreateOptions(parsed, config, process.cwd());
|
|
135
|
+
const plan = await buildProviderCreatePlan(resolved, process.cwd());
|
|
136
|
+
|
|
137
|
+
if (resolved.dryRun) {
|
|
138
|
+
return printResult(plan, resolved.json, true);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
await writePlan(plan);
|
|
142
|
+
await installDependencies(plan, resolved.json);
|
|
143
|
+
await runBaselineValidation(plan, resolved.json);
|
|
144
|
+
printResult(plan, resolved.json, false);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function normalizeArgs(argv: string[]): string[] {
|
|
148
|
+
return argv[0] === "create" ? argv.slice(1) : argv;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
type ParsedArgs = {
|
|
152
|
+
authMode?: CreateAuthMode;
|
|
153
|
+
category?: CreateCategory;
|
|
154
|
+
configPath?: string;
|
|
155
|
+
displayName?: string;
|
|
156
|
+
dryRun: boolean;
|
|
157
|
+
json: boolean;
|
|
158
|
+
name?: string;
|
|
159
|
+
outputDir?: string;
|
|
160
|
+
preset?: CreatePreset;
|
|
161
|
+
runtime?: CreateRuntime;
|
|
162
|
+
sdkSpecifier?: string;
|
|
163
|
+
yes: boolean;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
function parseArgs(argv: string[]): ParsedArgs {
|
|
167
|
+
const parsed: ParsedArgs = {
|
|
168
|
+
dryRun: false,
|
|
169
|
+
json: false,
|
|
170
|
+
yes: false,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
174
|
+
const arg = argv[index];
|
|
175
|
+
if (!arg) continue;
|
|
176
|
+
|
|
177
|
+
const [flag, inlineValue] = arg.split("=", 2);
|
|
178
|
+
const value = inlineValue ?? argv[index + 1];
|
|
179
|
+
const consumeValue = () => {
|
|
180
|
+
if (inlineValue === undefined) {
|
|
181
|
+
index += 1;
|
|
182
|
+
}
|
|
183
|
+
return value;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
switch (flag) {
|
|
187
|
+
case "--preset":
|
|
188
|
+
parsed.preset = parseEnum("preset", consumeValue(), PRESET_OPTIONS);
|
|
189
|
+
break;
|
|
190
|
+
case "--config":
|
|
191
|
+
parsed.configPath = ensureValue(flag, consumeValue());
|
|
192
|
+
break;
|
|
193
|
+
case "--output-dir":
|
|
194
|
+
parsed.outputDir = ensureValue(flag, consumeValue());
|
|
195
|
+
break;
|
|
196
|
+
case "--display-name":
|
|
197
|
+
parsed.displayName = ensureValue(flag, consumeValue());
|
|
198
|
+
break;
|
|
199
|
+
case "--category":
|
|
200
|
+
parsed.category = parseEnum(
|
|
201
|
+
"category",
|
|
202
|
+
consumeValue(),
|
|
203
|
+
CATEGORY_OPTIONS,
|
|
204
|
+
);
|
|
205
|
+
break;
|
|
206
|
+
case "--auth-mode":
|
|
207
|
+
parsed.authMode = parseEnum(
|
|
208
|
+
"auth mode",
|
|
209
|
+
consumeValue(),
|
|
210
|
+
AUTH_MODE_OPTIONS,
|
|
211
|
+
);
|
|
212
|
+
break;
|
|
213
|
+
case "--runtime":
|
|
214
|
+
parsed.runtime = parseEnum("runtime", consumeValue(), RUNTIME_OPTIONS);
|
|
215
|
+
break;
|
|
216
|
+
case "--sdk-specifier":
|
|
217
|
+
parsed.sdkSpecifier = ensureValue(flag, consumeValue());
|
|
218
|
+
break;
|
|
219
|
+
case "--dry-run":
|
|
220
|
+
parsed.dryRun = true;
|
|
221
|
+
break;
|
|
222
|
+
case "--json":
|
|
223
|
+
parsed.json = true;
|
|
224
|
+
break;
|
|
225
|
+
case "--yes":
|
|
226
|
+
parsed.yes = true;
|
|
227
|
+
break;
|
|
228
|
+
default:
|
|
229
|
+
if (flag.startsWith("-")) {
|
|
230
|
+
throw new Error(`Unknown option: ${flag}`);
|
|
231
|
+
}
|
|
232
|
+
if (!parsed.name) {
|
|
233
|
+
parsed.name = arg;
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return parsed;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function ensureValue(flag: string, value: string | undefined): string {
|
|
244
|
+
if (!value) {
|
|
245
|
+
throw new Error(`Missing value for ${flag}`);
|
|
246
|
+
}
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function parseEnum<T extends readonly string[]>(
|
|
251
|
+
label: string,
|
|
252
|
+
value: string | undefined,
|
|
253
|
+
options: T,
|
|
254
|
+
): T[number] {
|
|
255
|
+
const resolvedValue = ensureValue(`--${label}`, value);
|
|
256
|
+
const matchedValue = options.find((option) => option === resolvedValue);
|
|
257
|
+
if (!matchedValue) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`Invalid ${label}: ${resolvedValue}. Expected one of: ${options.join(", ")}`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
return matchedValue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function loadConfig(configPath: string): Promise<CreateConfigFile> {
|
|
266
|
+
const resolvedPath = resolve(process.cwd(), configPath);
|
|
267
|
+
const raw = await readFile(resolvedPath, "utf8");
|
|
268
|
+
return CREATE_CONFIG_SCHEMA.parse(JSON.parse(raw));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function resolveCreateOptions(
|
|
272
|
+
parsed: ParsedArgs,
|
|
273
|
+
config: CreateConfigFile | undefined,
|
|
274
|
+
cwd: string,
|
|
275
|
+
): Promise<CreateResolvedOptions> {
|
|
276
|
+
const detectedWorkspaceRoot = findWorkspaceRoot(cwd);
|
|
277
|
+
const detectedPreset: CreatePreset = detectedWorkspaceRoot
|
|
278
|
+
? "monorepo"
|
|
279
|
+
: "standalone";
|
|
280
|
+
|
|
281
|
+
const partial: Partial<CreateResolvedOptions> = {
|
|
282
|
+
name: parsed.name ?? config?.name,
|
|
283
|
+
preset: parsed.preset ?? config?.preset ?? detectedPreset,
|
|
284
|
+
outputDir: parsed.outputDir ?? config?.outputDir,
|
|
285
|
+
displayName: parsed.displayName ?? config?.displayName,
|
|
286
|
+
category: parsed.category ?? config?.category,
|
|
287
|
+
authMode: parsed.authMode ?? config?.authMode,
|
|
288
|
+
runtime: parsed.runtime ?? config?.runtime,
|
|
289
|
+
sdkSpecifier:
|
|
290
|
+
parsed.sdkSpecifier ??
|
|
291
|
+
config?.sdkSpecifier ??
|
|
292
|
+
process.env.APIFUSE_SDK_SPECIFIER,
|
|
293
|
+
dryRun: parsed.dryRun,
|
|
294
|
+
json: parsed.json,
|
|
295
|
+
yes: parsed.yes,
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
if (partial.preset === "monorepo" && !detectedWorkspaceRoot) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
"Monorepo preset requires a workspace root with a providers/ directory.",
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (partial.yes) {
|
|
305
|
+
if (!partial.name) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
"--yes requires a provider name (positional or via config).",
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
name: validateProviderName(partial.name),
|
|
313
|
+
displayName: (partial.displayName ?? toDisplayName(partial.name)).trim(),
|
|
314
|
+
category: partial.category ?? "other",
|
|
315
|
+
authMode: partial.authMode ?? "none",
|
|
316
|
+
runtime: partial.runtime ?? "standard",
|
|
317
|
+
preset: partial.preset ?? detectedPreset,
|
|
318
|
+
outputDir: partial.outputDir,
|
|
319
|
+
dryRun: partial.dryRun ?? false,
|
|
320
|
+
json: partial.json ?? false,
|
|
321
|
+
sdkSpecifier: partial.sdkSpecifier,
|
|
322
|
+
yes: true,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (!partial.json) {
|
|
327
|
+
intro("Create a new ApiFuse provider");
|
|
328
|
+
note(
|
|
329
|
+
`Preset precedence: explicit flags > config file > workspace detection > standalone default\nDetected workspace preset: ${detectedPreset}`,
|
|
330
|
+
"Preset resolution",
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const name = validateProviderName(
|
|
335
|
+
partial.name ??
|
|
336
|
+
(await promptValue(
|
|
337
|
+
text({
|
|
338
|
+
message: "Provider name",
|
|
339
|
+
initialValue: undefined,
|
|
340
|
+
placeholder: "my-provider",
|
|
341
|
+
validate(value) {
|
|
342
|
+
try {
|
|
343
|
+
validateProviderName(value ?? "");
|
|
344
|
+
} catch (error) {
|
|
345
|
+
return error instanceof Error ? error.message : String(error);
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
}),
|
|
349
|
+
)),
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
name,
|
|
354
|
+
displayName: (
|
|
355
|
+
partial.displayName ??
|
|
356
|
+
(await promptValue(
|
|
357
|
+
text({
|
|
358
|
+
message: "Display name",
|
|
359
|
+
initialValue: toDisplayName(name),
|
|
360
|
+
validate(value) {
|
|
361
|
+
if (!(value?.trim() ?? "")) {
|
|
362
|
+
return "Display name is required.";
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
}),
|
|
366
|
+
))
|
|
367
|
+
).trim(),
|
|
368
|
+
category:
|
|
369
|
+
partial.category ??
|
|
370
|
+
(await promptValue(
|
|
371
|
+
select({
|
|
372
|
+
message: "Category",
|
|
373
|
+
options: CATEGORY_OPTIONS.map((value) => ({ label: value, value })),
|
|
374
|
+
initialValue: "other",
|
|
375
|
+
}),
|
|
376
|
+
)),
|
|
377
|
+
authMode:
|
|
378
|
+
partial.authMode ??
|
|
379
|
+
(await promptValue(
|
|
380
|
+
select({
|
|
381
|
+
message: "Auth mode",
|
|
382
|
+
options: AUTH_MODE_OPTIONS.map((value) => ({ label: value, value })),
|
|
383
|
+
initialValue: "none",
|
|
384
|
+
}),
|
|
385
|
+
)),
|
|
386
|
+
runtime:
|
|
387
|
+
partial.runtime ??
|
|
388
|
+
(await promptValue(
|
|
389
|
+
select({
|
|
390
|
+
message: "Runtime",
|
|
391
|
+
options: RUNTIME_OPTIONS.map((value) => ({ label: value, value })),
|
|
392
|
+
initialValue: "standard",
|
|
393
|
+
}),
|
|
394
|
+
)),
|
|
395
|
+
preset:
|
|
396
|
+
partial.preset ??
|
|
397
|
+
(await promptValue(
|
|
398
|
+
select({
|
|
399
|
+
message: "Generation preset",
|
|
400
|
+
options: PRESET_OPTIONS.map((value) => ({
|
|
401
|
+
label: value,
|
|
402
|
+
value,
|
|
403
|
+
hint:
|
|
404
|
+
value === "standalone"
|
|
405
|
+
? "Create a clean-room npm-ready provider package."
|
|
406
|
+
: "Create the provider inside the current ApiFuse monorepo.",
|
|
407
|
+
})),
|
|
408
|
+
initialValue: detectedPreset,
|
|
409
|
+
}),
|
|
410
|
+
)),
|
|
411
|
+
outputDir: partial.outputDir,
|
|
412
|
+
dryRun: partial.dryRun ?? false,
|
|
413
|
+
json: partial.json ?? false,
|
|
414
|
+
sdkSpecifier: partial.sdkSpecifier,
|
|
415
|
+
yes: false,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function promptValue<T>(prompt: Promise<T | symbol>): Promise<T> {
|
|
420
|
+
const result = await prompt;
|
|
421
|
+
if (isCancel(result)) {
|
|
422
|
+
cancel("Operation cancelled.");
|
|
423
|
+
process.exit(0);
|
|
424
|
+
}
|
|
425
|
+
return result;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export async function buildProviderCreatePlan(
|
|
429
|
+
options: CreateResolvedOptions,
|
|
430
|
+
cwd: string,
|
|
431
|
+
): Promise<ProviderCreatePlan> {
|
|
432
|
+
const workspaceRoot =
|
|
433
|
+
options.preset === "monorepo" ? findWorkspaceRoot(cwd) : undefined;
|
|
434
|
+
if (options.preset === "monorepo" && !workspaceRoot) {
|
|
435
|
+
throw new Error(
|
|
436
|
+
"Monorepo preset requires a workspace root with a providers/ directory.",
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
const resolvedWorkspaceRoot =
|
|
440
|
+
options.preset === "monorepo" ? workspaceRoot : undefined;
|
|
441
|
+
let providerRoot: string;
|
|
442
|
+
let installCwd: string;
|
|
443
|
+
|
|
444
|
+
if (options.outputDir) {
|
|
445
|
+
providerRoot = resolve(cwd, options.outputDir);
|
|
446
|
+
installCwd =
|
|
447
|
+
options.preset === "monorepo" && resolvedWorkspaceRoot
|
|
448
|
+
? resolvedWorkspaceRoot
|
|
449
|
+
: providerRoot;
|
|
450
|
+
} else if (options.preset === "monorepo" && resolvedWorkspaceRoot) {
|
|
451
|
+
providerRoot = resolve(resolvedWorkspaceRoot, "providers", options.name);
|
|
452
|
+
installCwd = resolvedWorkspaceRoot;
|
|
453
|
+
} else {
|
|
454
|
+
providerRoot = resolve(cwd, options.name);
|
|
455
|
+
installCwd = providerRoot;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (existsSync(providerRoot)) {
|
|
459
|
+
throw new Error(`Target directory already exists: ${providerRoot}`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const sdkSpecifier =
|
|
463
|
+
options.sdkSpecifier ??
|
|
464
|
+
(options.preset === "monorepo" ? "workspace:*" : `^${packageJson.version}`);
|
|
465
|
+
const relativeProviderRoot = relative(cwd, providerRoot) || options.name;
|
|
466
|
+
const nextDevCommand = `cd ${relativeProviderRoot} && bun run dev`;
|
|
467
|
+
const packageName =
|
|
468
|
+
options.preset === "monorepo"
|
|
469
|
+
? `@apifuse/provider-${options.name}`
|
|
470
|
+
: `apifuse-provider-${options.name}`;
|
|
471
|
+
|
|
472
|
+
const files: ProviderPlanFile[] = [
|
|
473
|
+
{
|
|
474
|
+
path: resolve(providerRoot, "index.ts"),
|
|
475
|
+
content: await renderTemplate("index.ts.tpl", {
|
|
476
|
+
PROVIDER_ID: options.name,
|
|
477
|
+
DISPLAY_NAME: escapeTemplate(options.displayName),
|
|
478
|
+
CATEGORY: options.category,
|
|
479
|
+
RUNTIME: options.runtime,
|
|
480
|
+
BROWSER_BLOCK:
|
|
481
|
+
options.runtime === "browser"
|
|
482
|
+
? ',\n browser: {\n engine: "nodriver",\n }'
|
|
483
|
+
: "",
|
|
484
|
+
SECRETS_BLOCK: renderSecretsBlock(options.authMode),
|
|
485
|
+
CREDENTIAL_BLOCK: renderCredentialBlock(options.authMode),
|
|
486
|
+
AUTH_BLOCK: renderAuthBlock(options.authMode),
|
|
487
|
+
}),
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
path: resolve(providerRoot, "package.json"),
|
|
491
|
+
content: renderPackageJson({
|
|
492
|
+
packageName,
|
|
493
|
+
sdkSpecifier,
|
|
494
|
+
}),
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
path: resolve(providerRoot, "Dockerfile"),
|
|
498
|
+
content: await renderTemplate("Dockerfile.tpl", {}),
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
path: resolve(providerRoot, "dev.ts"),
|
|
502
|
+
content: await renderTemplate("dev.ts.tpl", {}),
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
path: resolve(providerRoot, "start.ts"),
|
|
506
|
+
content: await renderTemplate("start.ts.tpl", {}),
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
path: resolve(providerRoot, "tsconfig.json"),
|
|
510
|
+
content: renderTsconfig(),
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
path: resolve(providerRoot, "README.md"),
|
|
514
|
+
content: await renderTemplate("README.md.tpl", {
|
|
515
|
+
DISPLAY_NAME: escapeTemplate(options.displayName),
|
|
516
|
+
}),
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
path: resolve(providerRoot, "__fixtures__", "raw.json"),
|
|
520
|
+
content: "{}\n",
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
path: resolve(providerRoot, "__tests__", "index.test.ts"),
|
|
524
|
+
content: await renderTemplate("index.test.ts.tpl", {
|
|
525
|
+
PROVIDER_ID: options.name,
|
|
526
|
+
}),
|
|
527
|
+
},
|
|
528
|
+
];
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
displayName: options.displayName,
|
|
532
|
+
files,
|
|
533
|
+
installCommand: "bun install",
|
|
534
|
+
installCwd,
|
|
535
|
+
name: options.name,
|
|
536
|
+
nextDevCommand,
|
|
537
|
+
outputDir: providerRoot,
|
|
538
|
+
packageName,
|
|
539
|
+
preset: options.preset,
|
|
540
|
+
providerRoot,
|
|
541
|
+
validationCommands: ["bun run check", "bun run test"],
|
|
542
|
+
workspaceRoot: resolvedWorkspaceRoot,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function renderTemplate(
|
|
547
|
+
fileName: string,
|
|
548
|
+
values: Record<string, string>,
|
|
549
|
+
): Promise<string> {
|
|
550
|
+
const templatePath = resolve(TEMPLATE_DIR, fileName);
|
|
551
|
+
const template = await readFile(templatePath, "utf8");
|
|
552
|
+
return template.replace(/\{\{([A-Z_]+)\}\}/g, (_match, key: string) => {
|
|
553
|
+
return values[key] ?? "";
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function renderPackageJson(input: {
|
|
558
|
+
packageName: string;
|
|
559
|
+
sdkSpecifier: string;
|
|
560
|
+
}): string {
|
|
561
|
+
return `${JSON.stringify(
|
|
562
|
+
{
|
|
563
|
+
name: input.packageName,
|
|
564
|
+
version: "1.0.0",
|
|
565
|
+
private: true,
|
|
566
|
+
type: "module",
|
|
567
|
+
main: "./index.ts",
|
|
568
|
+
scripts: {
|
|
569
|
+
dev: "apifuse dev .",
|
|
570
|
+
check: "apifuse check .",
|
|
571
|
+
"record:sample":
|
|
572
|
+
'apifuse record . --operation ping --params \'{"value":"hello"}\'',
|
|
573
|
+
test: "apifuse test .",
|
|
574
|
+
"perf:sample": "apifuse perf . --operation ping --runs 3",
|
|
575
|
+
start: "bun start.ts",
|
|
576
|
+
},
|
|
577
|
+
dependencies: {
|
|
578
|
+
"@apifuse/provider-sdk": input.sdkSpecifier,
|
|
579
|
+
},
|
|
580
|
+
devDependencies: {
|
|
581
|
+
"@types/bun": "latest",
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
null,
|
|
585
|
+
2,
|
|
586
|
+
)}\n`;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function renderTsconfig(): string {
|
|
590
|
+
return `${JSON.stringify(
|
|
591
|
+
{
|
|
592
|
+
compilerOptions: {
|
|
593
|
+
target: "ES2022",
|
|
594
|
+
module: "ES2022",
|
|
595
|
+
moduleResolution: "bundler",
|
|
596
|
+
strict: true,
|
|
597
|
+
noEmit: true,
|
|
598
|
+
skipLibCheck: true,
|
|
599
|
+
resolveJsonModule: true,
|
|
600
|
+
},
|
|
601
|
+
include: ["**/*.ts"],
|
|
602
|
+
exclude: ["node_modules"],
|
|
603
|
+
},
|
|
604
|
+
null,
|
|
605
|
+
2,
|
|
606
|
+
)}\n`;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function renderAuthBlock(authMode: CreateAuthMode): string {
|
|
610
|
+
switch (authMode) {
|
|
611
|
+
case "none":
|
|
612
|
+
return '{ mode: "none" }';
|
|
613
|
+
case "platform-managed":
|
|
614
|
+
return '{ mode: "platform-managed" }';
|
|
615
|
+
case "credentials":
|
|
616
|
+
return `{
|
|
617
|
+
mode: "credentials",
|
|
618
|
+
flow: {
|
|
619
|
+
start: async () => ({
|
|
620
|
+
kind: "input",
|
|
621
|
+
turnId: crypto.randomUUID(),
|
|
622
|
+
expectedInput: {
|
|
623
|
+
schema: {
|
|
624
|
+
type: "object",
|
|
625
|
+
required: ["username", "password"],
|
|
626
|
+
properties: {
|
|
627
|
+
username: { type: "string", minLength: 1 },
|
|
628
|
+
password: { type: "string", minLength: 1 },
|
|
629
|
+
},
|
|
630
|
+
additionalProperties: false,
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
hint: "Replace the generated credential prompt with the real upstream login flow.",
|
|
634
|
+
}),
|
|
635
|
+
continue: async (_ctx, input = {}) => ({
|
|
636
|
+
kind: "complete",
|
|
637
|
+
turnId: crypto.randomUUID(),
|
|
638
|
+
data: {
|
|
639
|
+
credential: {
|
|
640
|
+
username: String(input.username ?? ""),
|
|
641
|
+
password: String(input.password ?? ""),
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
hint: "Generated placeholder credential flow completed. Replace this with real auth logic.",
|
|
645
|
+
}),
|
|
646
|
+
},
|
|
647
|
+
}`;
|
|
648
|
+
case "oauth2":
|
|
649
|
+
return `{
|
|
650
|
+
mode: "oauth2",
|
|
651
|
+
flow: {
|
|
652
|
+
start: async () => ({
|
|
653
|
+
kind: "redirect",
|
|
654
|
+
turnId: crypto.randomUUID(),
|
|
655
|
+
data: {
|
|
656
|
+
authorizeUrl: "https://example.com/oauth/authorize",
|
|
657
|
+
},
|
|
658
|
+
hint: "Replace the generated OAuth authorize URL and token exchange flow.",
|
|
659
|
+
}),
|
|
660
|
+
continue: async () => ({
|
|
661
|
+
kind: "complete",
|
|
662
|
+
turnId: crypto.randomUUID(),
|
|
663
|
+
data: {
|
|
664
|
+
credential: {},
|
|
665
|
+
},
|
|
666
|
+
hint: "Generated placeholder OAuth flow completed. Replace this with the real token exchange logic.",
|
|
667
|
+
}),
|
|
668
|
+
},
|
|
669
|
+
}`;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function renderSecretsBlock(authMode: CreateAuthMode): string {
|
|
674
|
+
switch (authMode) {
|
|
675
|
+
case "platform-managed":
|
|
676
|
+
return 'secrets: [{ name: "EXAMPLE_API_KEY", required: true }],\n ';
|
|
677
|
+
case "oauth2":
|
|
678
|
+
return 'secrets: [{ name: "EXAMPLE_OAUTH_CLIENT_ID", required: true }, { name: "EXAMPLE_OAUTH_CLIENT_SECRET", required: true }],\n ';
|
|
679
|
+
default:
|
|
680
|
+
return "";
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function renderCredentialBlock(authMode: CreateAuthMode): string {
|
|
685
|
+
if (authMode !== "credentials") {
|
|
686
|
+
return "";
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return `credential: {
|
|
690
|
+
keys: ["username", "password"],
|
|
691
|
+
storesReusableSecret: true,
|
|
692
|
+
justification:
|
|
693
|
+
"The generated credential starter persists reusable login fields so contributors can replace the placeholder flow with a real upstream session exchange without rewriting the surrounding contract.",
|
|
694
|
+
},
|
|
695
|
+
`;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function validateProviderName(value: string): string {
|
|
699
|
+
const normalized = value.trim();
|
|
700
|
+
if (!normalized) {
|
|
701
|
+
throw new Error("Provider name is required.");
|
|
702
|
+
}
|
|
703
|
+
if (!PROVIDER_NAME_REGEX.test(normalized)) {
|
|
704
|
+
throw new Error("Use kebab-case, e.g. my-provider.");
|
|
705
|
+
}
|
|
706
|
+
return normalized;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export function toDisplayName(name: string): string {
|
|
710
|
+
return name
|
|
711
|
+
.split("-")
|
|
712
|
+
.filter(Boolean)
|
|
713
|
+
.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
|
|
714
|
+
.join(" ");
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function findWorkspaceRoot(cwd: string): string | undefined {
|
|
718
|
+
let currentDirectory = cwd;
|
|
719
|
+
|
|
720
|
+
while (true) {
|
|
721
|
+
if (existsSync(resolve(currentDirectory, "providers"))) {
|
|
722
|
+
return currentDirectory;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const parentDirectory = dirname(currentDirectory);
|
|
726
|
+
if (parentDirectory === currentDirectory) {
|
|
727
|
+
return undefined;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
currentDirectory = parentDirectory;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function writePlan(plan: ProviderCreatePlan): Promise<void> {
|
|
735
|
+
for (const file of plan.files) {
|
|
736
|
+
await mkdir(dirname(file.path), { recursive: true });
|
|
737
|
+
await writeFile(file.path, file.content);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async function installDependencies(
|
|
742
|
+
plan: ProviderCreatePlan,
|
|
743
|
+
jsonMode: boolean,
|
|
744
|
+
): Promise<void> {
|
|
745
|
+
await runCommand(plan.installCommand, plan.installCwd, jsonMode);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async function runBaselineValidation(
|
|
749
|
+
plan: ProviderCreatePlan,
|
|
750
|
+
jsonMode: boolean,
|
|
751
|
+
): Promise<void> {
|
|
752
|
+
for (const command of plan.validationCommands) {
|
|
753
|
+
await runCommand(command, plan.providerRoot, jsonMode);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async function runCommand(
|
|
758
|
+
command: string,
|
|
759
|
+
cwd: string,
|
|
760
|
+
jsonMode: boolean,
|
|
761
|
+
): Promise<void> {
|
|
762
|
+
const [binary, ...args] = command.split(" ");
|
|
763
|
+
if (!binary) {
|
|
764
|
+
throw new Error(`Cannot run empty command in ${cwd}`);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
await new Promise<void>((resolvePromise, rejectPromise) => {
|
|
768
|
+
const child = spawn(binary, args, {
|
|
769
|
+
cwd,
|
|
770
|
+
env: process.env,
|
|
771
|
+
stdio: jsonMode ? "pipe" : "inherit",
|
|
772
|
+
shell: false,
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
let stdout = "";
|
|
776
|
+
let stderr = "";
|
|
777
|
+
|
|
778
|
+
child.stdout?.on("data", (chunk) => {
|
|
779
|
+
stdout += chunk.toString();
|
|
780
|
+
});
|
|
781
|
+
child.stderr?.on("data", (chunk) => {
|
|
782
|
+
stderr += chunk.toString();
|
|
783
|
+
});
|
|
784
|
+
child.on("error", rejectPromise);
|
|
785
|
+
child.on("close", (code) => {
|
|
786
|
+
if (code === 0) {
|
|
787
|
+
resolvePromise();
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
rejectPromise(
|
|
791
|
+
new Error(
|
|
792
|
+
`Command failed (${command}) in ${cwd}${
|
|
793
|
+
stdout || stderr
|
|
794
|
+
? `\n${[stdout, stderr].filter(Boolean).join("\n")}`
|
|
795
|
+
: ""
|
|
796
|
+
}`,
|
|
797
|
+
),
|
|
798
|
+
);
|
|
799
|
+
});
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function printResult(
|
|
804
|
+
plan: ProviderCreatePlan,
|
|
805
|
+
jsonMode: boolean,
|
|
806
|
+
dryRun: boolean,
|
|
807
|
+
) {
|
|
808
|
+
const payload = {
|
|
809
|
+
success: true,
|
|
810
|
+
dryRun,
|
|
811
|
+
preset: plan.preset,
|
|
812
|
+
provider: {
|
|
813
|
+
name: plan.name,
|
|
814
|
+
displayName: plan.displayName,
|
|
815
|
+
packageName: plan.packageName,
|
|
816
|
+
outputDir: plan.outputDir,
|
|
817
|
+
},
|
|
818
|
+
install: {
|
|
819
|
+
cwd: plan.installCwd,
|
|
820
|
+
command: plan.installCommand,
|
|
821
|
+
},
|
|
822
|
+
validationCommands: plan.validationCommands,
|
|
823
|
+
nextDevCommand: plan.nextDevCommand,
|
|
824
|
+
files: plan.files.map(
|
|
825
|
+
(file) => relative(plan.providerRoot, file.path) || file.path,
|
|
826
|
+
),
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
if (jsonMode) {
|
|
830
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
outro(`${dryRun ? "Planned" : "Created"} ${plan.outputDir}`);
|
|
835
|
+
console.log(`\nPreset: ${plan.preset}`);
|
|
836
|
+
console.log(`Install: (cd ${plan.installCwd} && ${plan.installCommand})`);
|
|
837
|
+
for (const command of plan.validationCommands) {
|
|
838
|
+
console.log(`Validation: (cd ${plan.providerRoot} && ${command})`);
|
|
839
|
+
}
|
|
840
|
+
console.log(`Next local dev: ${plan.nextDevCommand}`);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function escapeTemplate(value: string): string {
|
|
844
|
+
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
|
845
|
+
}
|