@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.
Files changed (79) hide show
  1. package/AUTHORING.md +93 -0
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +133 -28
  4. package/bin/apifuse-check.ts +78 -71
  5. package/bin/apifuse-create.ts +12 -0
  6. package/bin/apifuse-dev.ts +24 -61
  7. package/bin/apifuse-pack-check.ts +87 -0
  8. package/bin/apifuse-pack-smoke.ts +122 -0
  9. package/bin/apifuse-perf.ts +33 -32
  10. package/bin/apifuse-record.ts +17 -7
  11. package/bin/apifuse-test.ts +6 -4
  12. package/bin/apifuse.ts +36 -35
  13. package/package.json +29 -9
  14. package/src/ceremonies/index.ts +768 -0
  15. package/src/cli/commands.ts +87 -0
  16. package/src/cli/create.ts +845 -0
  17. package/src/cli/templates/provider/Dockerfile.tpl +7 -0
  18. package/src/cli/templates/provider/README.md.tpl +41 -0
  19. package/src/cli/templates/provider/dev.ts.tpl +5 -0
  20. package/src/cli/templates/provider/index.test.ts.tpl +13 -0
  21. package/src/cli/templates/provider/index.ts.tpl +58 -0
  22. package/src/cli/templates/provider/start.ts.tpl +5 -0
  23. package/src/config/loader.ts +61 -1
  24. package/src/define.ts +565 -41
  25. package/src/dev.ts +2 -6
  26. package/src/errors.ts +42 -0
  27. package/src/index.ts +44 -38
  28. package/src/lint.ts +574 -0
  29. package/src/provider.ts +13 -0
  30. package/src/runtime/auth-flow.ts +67 -0
  31. package/src/runtime/credential.ts +95 -0
  32. package/src/runtime/env.ts +13 -0
  33. package/src/runtime/executor.ts +13 -14
  34. package/src/runtime/http.ts +36 -12
  35. package/src/runtime/insights.ts +3 -3
  36. package/src/runtime/key-derivation.ts +122 -0
  37. package/src/runtime/keyring.ts +148 -0
  38. package/src/runtime/namespace.ts +33 -0
  39. package/src/runtime/prevalidate.ts +252 -0
  40. package/src/runtime/tls.ts +41 -17
  41. package/src/runtime/waterfall.ts +0 -1
  42. package/src/schema.ts +77 -0
  43. package/src/serve.ts +1 -664
  44. package/src/server/index.ts +22 -0
  45. package/src/server/serve.ts +624 -0
  46. package/src/server/types.ts +78 -0
  47. package/src/stealth/profiles.ts +10 -93
  48. package/src/testing/run.ts +391 -32
  49. package/src/types.ts +390 -41
  50. package/bin/apifuse-init.ts +0 -387
  51. package/src/__tests__/auth.test.ts +0 -396
  52. package/src/__tests__/browser-auth.test.ts +0 -180
  53. package/src/__tests__/browser.test.ts +0 -632
  54. package/src/__tests__/define.test.ts +0 -225
  55. package/src/__tests__/errors.test.ts +0 -69
  56. package/src/__tests__/executor.test.ts +0 -214
  57. package/src/__tests__/http.test.ts +0 -238
  58. package/src/__tests__/insights.test.ts +0 -210
  59. package/src/__tests__/instrumentation.test.ts +0 -290
  60. package/src/__tests__/otlp.test.ts +0 -141
  61. package/src/__tests__/perf.test.ts +0 -60
  62. package/src/__tests__/providers-yaml.test.ts +0 -135
  63. package/src/__tests__/proxy.test.ts +0 -359
  64. package/src/__tests__/recipes.test.ts +0 -36
  65. package/src/__tests__/serve.test.ts +0 -233
  66. package/src/__tests__/session.test.ts +0 -231
  67. package/src/__tests__/state.test.ts +0 -100
  68. package/src/__tests__/stealth.test.ts +0 -57
  69. package/src/__tests__/testing.test.ts +0 -97
  70. package/src/__tests__/tls.test.ts +0 -345
  71. package/src/__tests__/types.test.ts +0 -142
  72. package/src/__tests__/utils.test.ts +0 -62
  73. package/src/__tests__/waterfall.test.ts +0 -270
  74. package/src/config/providers-yaml.ts +0 -370
  75. package/src/index.test.ts +0 -1
  76. package/src/protocol.ts +0 -183
  77. package/src/runtime/auth.ts +0 -245
  78. package/src/runtime/session.ts +0 -573
  79. 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
+ }