api-emulator 0.5.1 → 0.6.0

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/dist/index.js CHANGED
@@ -4,6 +4,24 @@ import { Command } from "commander";
4
4
 
5
5
  // src/external-plugin-adapter.ts
6
6
  import { isAbsolute, resolve } from "path";
7
+
8
+ // src/plugin-manifest.ts
9
+ function readPluginManifest(mod) {
10
+ return mod.manifest ?? {};
11
+ }
12
+ function validatePluginManifest(manifest, pluginName) {
13
+ if (manifest.name && manifest.name !== pluginName) {
14
+ throw new Error(`Plugin manifest name "${manifest.name}" does not match plugin name "${pluginName}"`);
15
+ }
16
+ return {
17
+ ...manifest,
18
+ label: manifest.label ?? `${pluginName} (external plugin)`,
19
+ endpoints: manifest.endpoints ?? "",
20
+ initConfig: manifest.initConfig ?? {}
21
+ };
22
+ }
23
+
24
+ // src/external-plugin-adapter.ts
7
25
  async function loadExternalPluginModule(specifier) {
8
26
  const modulePath = specifier.startsWith(".") || isAbsolute(specifier) ? resolve(specifier) : specifier;
9
27
  const mod = await import(modulePath);
@@ -12,10 +30,12 @@ async function loadExternalPluginModule(specifier) {
12
30
  throw new Error(`Plugin "${specifier}" must export a ServicePlugin (as "plugin" or default export)`);
13
31
  }
14
32
  const name = plugin.name;
33
+ const manifest = validatePluginManifest(readPluginManifest(mod), name);
15
34
  return {
16
35
  name,
17
- label: mod.label ?? `${name} (external plugin)`,
18
- endpoints: mod.endpoints ?? "",
36
+ label: manifest.label,
37
+ endpoints: manifest.endpoints,
38
+ manifest,
19
39
  async load() {
20
40
  return {
21
41
  plugin,
@@ -23,439 +43,39 @@ async function loadExternalPluginModule(specifier) {
23
43
  };
24
44
  },
25
45
  defaultFallback: mod.defaultFallback ?? (() => ({ login: "admin", id: 1, scopes: [] })),
26
- initConfig: mod.initConfig ?? {}
46
+ initConfig: manifest.initConfig
27
47
  };
28
48
  }
29
49
 
30
- // src/default-plugin-catalog.ts
31
- var DEFAULT_PLUGIN_NAME_LIST = [
32
- "vercel",
33
- "github",
34
- "google",
35
- "slack",
36
- "apple",
37
- "microsoft",
38
- "okta",
39
- "aws",
40
- "resend",
41
- "stripe",
42
- "mongoatlas",
43
- "clerk"
44
- ];
45
- var DEFAULT_PLUGIN_NAMES = DEFAULT_PLUGIN_NAME_LIST;
46
- var DEFAULT_PLUGIN_REGISTRY = {
47
- vercel: {
48
- name: "vercel",
49
- label: "Vercel REST API emulator",
50
- endpoints: "projects, deployments, domains, env vars, users, teams, file uploads, protection bypass",
51
- async load() {
52
- const mod = await import("@emulators/vercel");
53
- return { plugin: mod.vercelPlugin, seedFromConfig: mod.seedFromConfig };
54
- },
55
- defaultFallback(cfg) {
56
- const firstLogin = cfg?.users?.[0]?.username ?? "admin";
57
- return { login: firstLogin, id: 1, scopes: [] };
58
- },
59
- initConfig: {
60
- vercel: {
61
- users: [{ username: "developer", name: "Developer", email: "dev@example.com" }],
62
- teams: [{ slug: "my-team", name: "My Team" }],
63
- projects: [{ name: "my-app", team: "my-team", framework: "nextjs" }],
64
- integrations: [
65
- {
66
- client_id: "oac_example_client_id",
67
- client_secret: "example_client_secret",
68
- name: "My Vercel App",
69
- redirect_uris: ["http://localhost:3000/api/auth/callback/vercel"]
70
- }
71
- ]
72
- }
73
- }
74
- },
75
- github: {
76
- name: "github",
77
- label: "GitHub REST API emulator",
78
- endpoints: "users, repos, issues, PRs, comments, reviews, labels, milestones, branches, git data, orgs, teams, releases, webhooks, search, actions, checks, rate limit",
79
- async load() {
80
- const mod = await import("@emulators/github");
81
- return {
82
- plugin: mod.githubPlugin,
83
- seedFromConfig: mod.seedFromConfig,
84
- createAppKeyResolver(store) {
85
- return (appId) => {
86
- try {
87
- const gh = mod.getGitHubStore(store);
88
- const ghApp = gh.apps.all().find((a) => a.app_id === appId);
89
- if (!ghApp) return null;
90
- return { privateKey: ghApp.private_key, slug: ghApp.slug, name: ghApp.name };
91
- } catch {
92
- return null;
93
- }
94
- };
95
- }
96
- };
97
- },
98
- defaultFallback(cfg) {
99
- const firstLogin = cfg?.users?.[0]?.login ?? "admin";
100
- return { login: firstLogin, id: 1, scopes: ["repo", "user", "admin:org", "admin:repo_hook"] };
101
- },
102
- initConfig: {
103
- github: {
104
- users: [
105
- {
106
- login: "octocat",
107
- name: "The Octocat",
108
- email: "octocat@github.com",
109
- bio: "I am the Octocat",
110
- company: "GitHub",
111
- location: "San Francisco"
112
- }
113
- ],
114
- orgs: [{ login: "my-org", name: "My Organization", description: "A test organization" }],
115
- repos: [
116
- {
117
- owner: "octocat",
118
- name: "hello-world",
119
- description: "My first repository",
120
- language: "JavaScript",
121
- topics: ["hello", "world"],
122
- auto_init: true
123
- },
124
- {
125
- owner: "my-org",
126
- name: "org-repo",
127
- description: "An organization repository",
128
- language: "TypeScript",
129
- auto_init: true
130
- }
131
- ],
132
- oauth_apps: [
133
- {
134
- client_id: "Iv1.example_client_id",
135
- client_secret: "example_client_secret",
136
- name: "My App",
137
- redirect_uris: ["http://localhost:3000/api/auth/callback/github"]
138
- }
139
- ]
140
- }
141
- }
142
- },
143
- google: {
144
- name: "google",
145
- label: "Google OAuth 2.0 / OpenID Connect + Gmail, Calendar, and Drive emulator",
146
- endpoints: "OAuth authorize, token exchange, userinfo, OIDC discovery, token revocation, Gmail messages/drafts/threads/labels/history/settings, Calendar lists/events/freebusy, Drive files/uploads",
147
- async load() {
148
- const mod = await import("@emulators/google");
149
- return { plugin: mod.googlePlugin, seedFromConfig: mod.seedFromConfig };
150
- },
151
- defaultFallback(cfg) {
152
- const firstEmail = cfg?.users?.[0]?.email ?? "testuser@gmail.com";
153
- return { login: firstEmail, id: 1, scopes: ["openid", "email", "profile"] };
154
- },
155
- initConfig: {
156
- google: {
157
- users: [
158
- {
159
- email: "testuser@example.com",
160
- name: "Test User",
161
- picture: "https://lh3.googleusercontent.com/a/default-user",
162
- email_verified: true
163
- }
164
- ],
165
- oauth_clients: [
166
- {
167
- client_id: "example-client-id.apps.googleusercontent.com",
168
- client_secret: "GOCSPX-example_secret",
169
- name: "Code App (Google)",
170
- redirect_uris: ["http://localhost:3000/api/auth/callback/google"]
171
- }
172
- ],
173
- labels: [
174
- {
175
- id: "Label_ops",
176
- user_email: "testuser@example.com",
177
- name: "Ops/Review",
178
- color_background: "#DDEEFF",
179
- color_text: "#111111"
180
- }
181
- ],
182
- messages: [
183
- {
184
- id: "msg_welcome",
185
- user_email: "testuser@example.com",
186
- from: "welcome@example.com",
187
- to: "testuser@example.com",
188
- subject: "Welcome to the Gmail emulator",
189
- body_text: "You can now test Gmail, Calendar, and Drive flows locally.",
190
- label_ids: ["INBOX", "UNREAD", "CATEGORY_UPDATES"],
191
- date: "2025-01-04T10:00:00.000Z"
192
- }
193
- ],
194
- calendars: [
195
- {
196
- id: "primary",
197
- user_email: "testuser@example.com",
198
- summary: "testuser@example.com",
199
- primary: true,
200
- selected: true,
201
- time_zone: "UTC"
202
- }
203
- ],
204
- calendar_events: [
205
- {
206
- id: "evt_kickoff",
207
- user_email: "testuser@example.com",
208
- calendar_id: "primary",
209
- summary: "Project Kickoff",
210
- start_date_time: "2025-01-10T09:00:00.000Z",
211
- end_date_time: "2025-01-10T09:30:00.000Z"
212
- }
213
- ],
214
- drive_items: [
215
- {
216
- id: "drv_docs",
217
- user_email: "testuser@example.com",
218
- name: "Docs",
219
- mime_type: "application/vnd.google-apps.folder",
220
- parent_ids: ["root"]
221
- }
222
- ]
223
- }
224
- }
225
- },
226
- slack: {
227
- name: "slack",
228
- label: "Slack API emulator",
229
- endpoints: "auth, chat, conversations, users, reactions, team, OAuth, incoming webhooks",
230
- async load() {
231
- const mod = await import("@emulators/slack");
232
- return { plugin: mod.slackPlugin, seedFromConfig: mod.seedFromConfig };
233
- },
234
- defaultFallback() {
235
- return { login: "U000000001", id: 1, scopes: ["chat:write", "channels:read", "users:read", "reactions:write"] };
236
- },
237
- initConfig: {
238
- slack: {
239
- team: { name: "My Workspace", domain: "my-workspace" },
240
- users: [{ name: "developer", real_name: "Developer", email: "dev@example.com" }],
241
- channels: [
242
- { name: "general", topic: "General discussion" },
243
- { name: "random", topic: "Random stuff" }
244
- ],
245
- bots: [{ name: "my-bot" }],
246
- oauth_apps: [
247
- {
248
- client_id: "12345.67890",
249
- client_secret: "example_client_secret",
250
- name: "My Slack App",
251
- redirect_uris: ["http://localhost:3000/api/auth/callback/slack"]
252
- }
253
- ]
254
- }
255
- }
256
- },
257
- apple: {
258
- name: "apple",
259
- label: "Apple Sign In / OAuth emulator",
260
- endpoints: "OAuth authorize, token exchange, JWKS",
261
- async load() {
262
- const mod = await import("@emulators/apple");
263
- return { plugin: mod.applePlugin, seedFromConfig: mod.seedFromConfig };
264
- },
265
- defaultFallback(cfg) {
266
- const firstEmail = cfg?.users?.[0]?.email ?? "testuser@icloud.com";
267
- return { login: firstEmail, id: 1, scopes: ["openid", "email", "name"] };
268
- },
269
- initConfig: {
270
- apple: {
271
- users: [{ email: "testuser@icloud.com", name: "Test User" }],
272
- oauth_clients: [
273
- {
274
- client_id: "com.example.app",
275
- team_id: "TEAM001",
276
- name: "My Apple App",
277
- redirect_uris: ["http://localhost:3000/api/auth/callback/apple"]
278
- }
279
- ]
280
- }
281
- }
282
- },
283
- microsoft: {
284
- name: "microsoft",
285
- label: "Microsoft Entra ID OAuth 2.0 / OpenID Connect emulator",
286
- endpoints: "OAuth authorize, token exchange, userinfo, OIDC discovery, Graph /me, logout, token revocation",
287
- async load() {
288
- const mod = await import("@emulators/microsoft");
289
- return { plugin: mod.microsoftPlugin, seedFromConfig: mod.seedFromConfig };
290
- },
291
- defaultFallback(cfg) {
292
- const firstEmail = cfg?.users?.[0]?.email ?? "testuser@outlook.com";
293
- return { login: firstEmail, id: 1, scopes: ["openid", "email", "profile", "User.Read"] };
294
- },
295
- initConfig: {
296
- microsoft: {
297
- users: [{ email: "testuser@outlook.com", name: "Test User" }],
298
- oauth_clients: [
299
- {
300
- client_id: "example-client-id",
301
- client_secret: "example-client-secret",
302
- name: "My Microsoft App",
303
- redirect_uris: ["http://localhost:3000/api/auth/callback/microsoft-entra-id"]
304
- }
305
- ]
306
- }
307
- }
308
- },
309
- okta: {
310
- name: "okta",
311
- label: "Okta OAuth 2.0 / OpenID Connect + management API emulator",
312
- endpoints: "OIDC discovery, JWKS, OAuth authorize/token/userinfo/introspect/revoke/logout, users, groups, apps, authorization servers",
313
- async load() {
314
- const mod = await import("@emulators/okta");
315
- return { plugin: mod.oktaPlugin, seedFromConfig: mod.seedFromConfig };
316
- },
317
- defaultFallback(cfg) {
318
- const firstLogin = cfg?.users?.[0]?.login ?? cfg?.users?.[0]?.email ?? "testuser@okta.local";
319
- return { login: firstLogin, id: 1, scopes: ["openid", "profile", "email", "groups"] };
320
- },
321
- initConfig: {
322
- okta: {
323
- users: [{ login: "testuser@okta.local", email: "testuser@okta.local", first_name: "Test", last_name: "User" }],
324
- groups: [{ name: "Everyone", description: "All users", type: "BUILT_IN", okta_id: "00g_everyone" }],
325
- authorization_servers: [{ id: "default", name: "default", audiences: ["api://default"] }],
326
- oauth_clients: [
327
- {
328
- client_id: "okta-test-client",
329
- client_secret: "okta-test-secret",
330
- name: "Sample OIDC Client",
331
- redirect_uris: ["http://localhost:3000/callback"],
332
- auth_server_id: "default"
333
- }
334
- ]
335
- }
336
- }
337
- },
338
- aws: {
339
- name: "aws",
340
- label: "AWS cloud service emulator",
341
- endpoints: "S3 (buckets, objects), SQS (queues, messages), IAM (users, roles, access keys), STS (assume role, caller identity)",
342
- async load() {
343
- const mod = await import("@emulators/aws");
344
- return { plugin: mod.awsPlugin, seedFromConfig: mod.seedFromConfig };
345
- },
346
- defaultFallback() {
347
- return { login: "admin", id: 1, scopes: ["s3:*", "sqs:*", "iam:*", "sts:*"] };
348
- },
349
- initConfig: {
350
- aws: {
351
- region: "us-east-1",
352
- s3: { buckets: [{ name: "my-app-bucket" }, { name: "my-app-uploads" }] },
353
- sqs: { queues: [{ name: "my-app-events" }, { name: "my-app-dlq" }] },
354
- iam: {
355
- users: [{ user_name: "developer", create_access_key: true }],
356
- roles: [{ role_name: "lambda-execution-role", description: "Role for Lambda function execution" }]
357
- }
358
- }
359
- }
360
- },
361
- resend: {
362
- name: "resend",
363
- label: "Resend email API emulator",
364
- endpoints: "emails, domains, contacts, API keys, inbox UI",
365
- async load() {
366
- const mod = await import("@emulators/resend");
367
- return { plugin: mod.resendPlugin, seedFromConfig: mod.seedFromConfig };
368
- },
369
- defaultFallback() {
370
- return { login: "re_test_admin", id: 1, scopes: [] };
371
- },
372
- initConfig: {
373
- resend: {
374
- domains: [{ name: "example.com", region: "us-east-1" }],
375
- contacts: [{ email: "test@example.com", first_name: "Test", last_name: "User" }]
376
- }
377
- }
378
- },
379
- stripe: {
380
- name: "stripe",
381
- label: "Stripe payments emulator",
382
- endpoints: "customers, payment methods, customer sessions, payment intents, charges, products, prices, checkout sessions, webhooks",
383
- async load() {
384
- const mod = await import("@emulators/stripe");
385
- return { plugin: mod.stripePlugin, seedFromConfig: mod.seedFromConfig };
386
- },
387
- defaultFallback() {
388
- return { login: "sk_test_admin", id: 1, scopes: [] };
389
- },
390
- initConfig: {
391
- stripe: {
392
- customers: [{ email: "test@example.com", name: "Test Customer" }],
393
- products: [{ name: "Pro Plan", description: "Monthly pro subscription" }],
394
- prices: [{ product_name: "Pro Plan", currency: "usd", unit_amount: 2e3 }]
395
- }
396
- }
397
- },
398
- mongoatlas: {
399
- name: "mongoatlas",
400
- label: "MongoDB Atlas service emulator",
401
- endpoints: "Atlas Admin API v2 (projects, clusters, database users, databases, collections), Atlas Data API v1 (findOne, find, insertOne, insertMany, updateOne, updateMany, deleteOne, deleteMany, aggregate)",
402
- async load() {
403
- const mod = await import("@emulators/mongoatlas");
404
- return { plugin: mod.mongoatlasPlugin, seedFromConfig: mod.seedFromConfig };
405
- },
406
- defaultFallback() {
407
- return { login: "admin", id: 1, scopes: [] };
408
- },
409
- initConfig: {
410
- mongoatlas: {
411
- projects: [{ name: "Project0" }],
412
- clusters: [{ name: "Cluster0", project: "Project0" }],
413
- database_users: [{ username: "admin", project: "Project0" }],
414
- databases: [{ cluster: "Cluster0", name: "test", collections: ["items"] }]
415
- }
416
- }
417
- },
418
- clerk: {
419
- name: "clerk",
420
- label: "Clerk authentication and user management emulator",
421
- endpoints: "OIDC discovery, JWKS, OAuth authorize/token/userinfo, users, email addresses, organizations, memberships, invitations, sessions",
422
- async load() {
423
- const mod = await import("@emulators/clerk");
424
- return { plugin: mod.clerkPlugin, seedFromConfig: mod.seedFromConfig };
425
- },
426
- defaultFallback(cfg) {
427
- const firstEmail = cfg?.users?.[0]?.email_addresses?.[0] ?? "test@example.com";
428
- return { login: firstEmail, id: 1, scopes: [] };
429
- },
430
- initConfig: {
431
- clerk: {
432
- users: [
433
- {
434
- first_name: "Test",
435
- last_name: "User",
436
- email_addresses: ["test@example.com"],
437
- password: "clerk_test_password"
438
- }
439
- ],
440
- organizations: [
441
- {
442
- name: "My Company",
443
- slug: "my-company",
444
- members: [{ email: "test@example.com", role: "admin" }]
445
- }
446
- ],
447
- oauth_applications: [
448
- {
449
- client_id: "clerk_emulate_client",
450
- client_secret: "clerk_emulate_secret",
451
- name: "api-emulator App",
452
- redirect_uris: ["http://localhost:3000/api/auth/callback/clerk"]
453
- }
454
- ]
455
- }
456
- }
50
+ // src/plugin-lock.ts
51
+ import { existsSync, readFileSync, writeFileSync } from "fs";
52
+ import { resolve as resolve2 } from "path";
53
+ var PLUGIN_LOCK_FILE = "api-emulator.lock";
54
+ function createEmptyPluginLock() {
55
+ return { version: 1, plugins: {} };
56
+ }
57
+ function readPluginLock(cwd = process.cwd()) {
58
+ const path = resolve2(cwd, PLUGIN_LOCK_FILE);
59
+ if (!existsSync(path)) return createEmptyPluginLock();
60
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
61
+ if (parsed.version !== 1 || typeof parsed.plugins !== "object" || parsed.plugins === null) {
62
+ throw new Error(`Invalid ${PLUGIN_LOCK_FILE}`);
457
63
  }
458
- };
64
+ return parsed;
65
+ }
66
+ function writePluginLock(lock, cwd = process.cwd()) {
67
+ const sortedPlugins = Object.fromEntries(Object.entries(lock.plugins).sort(([a], [b]) => a.localeCompare(b)));
68
+ const content = `${JSON.stringify({ version: 1, plugins: sortedPlugins }, null, 2)}
69
+ `;
70
+ writeFileSync(resolve2(cwd, PLUGIN_LOCK_FILE), content, "utf-8");
71
+ }
72
+ function getLockedPluginSpecifiers(cwd = process.cwd()) {
73
+ return Object.values(readPluginLock(cwd).plugins).map((entry) => entry.specifier);
74
+ }
75
+
76
+ // src/default-plugin-catalog.ts
77
+ var DEFAULT_PLUGIN_NAMES = [];
78
+ var DEFAULT_PLUGIN_REGISTRY = {};
459
79
 
460
80
  // src/registry.ts
461
81
  var DEFAULT_TOKENS = {
@@ -470,8 +90,10 @@ var DEFAULT_TOKENS = {
470
90
  }
471
91
  }
472
92
  };
473
- async function resolvePluginModules(pluginSpecifiers = []) {
474
- const results = await Promise.all(pluginSpecifiers.map(loadExternalPluginModule));
93
+ async function resolvePluginModules(pluginSpecifiers = [], options = {}) {
94
+ const installedSpecifiers = options.includeInstalled ? getLockedPluginSpecifiers() : [];
95
+ const allSpecifiers = [...installedSpecifiers, ...pluginSpecifiers];
96
+ const results = await Promise.all(allSpecifiers.map(loadExternalPluginModule));
475
97
  const externalEntries = {};
476
98
  for (const pluginModule of results) {
477
99
  if (pluginModule.name in DEFAULT_PLUGIN_REGISTRY) {
@@ -489,8 +111,8 @@ function getDefaultPluginNames() {
489
111
  }
490
112
 
491
113
  // src/commands/start.ts
492
- import { readFileSync, existsSync } from "fs";
493
- import { resolve as resolve2 } from "path";
114
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
115
+ import { resolve as resolve3 } from "path";
494
116
  import { parse as parseYaml } from "yaml";
495
117
  import pc from "picocolors";
496
118
 
@@ -505,12 +127,12 @@ function hasPortless() {
505
127
  return result.status === 0;
506
128
  }
507
129
  function promptYesNo(question) {
508
- return new Promise((resolve4) => {
130
+ return new Promise((resolve7) => {
509
131
  const rl = createInterface({ input: process.stdin, output: process.stdout });
510
132
  rl.question(question, (answer) => {
511
133
  rl.close();
512
134
  const normalized = answer.trim().toLowerCase();
513
- resolve4(normalized === "" || normalized === "y" || normalized === "yes");
135
+ resolve7(normalized === "" || normalized === "y" || normalized === "yes");
514
136
  });
515
137
  });
516
138
  }
@@ -580,7 +202,7 @@ function resolveBaseUrl(opts) {
580
202
  if (opts.baseUrl) {
581
203
  return opts.baseUrl.replace(/\{service\}/g, opts.service);
582
204
  }
583
- const envBaseUrl = process.env.API_EMULATOR_BASE_URL ?? process.env.EMULATE_BASE_URL;
205
+ const envBaseUrl = process.env.API_EMULATOR_BASE_URL;
584
206
  if (envBaseUrl) {
585
207
  return envBaseUrl.replace(/\{service\}/g, opts.service);
586
208
  }
@@ -592,7 +214,11 @@ function resolveBaseUrl(opts) {
592
214
  }
593
215
 
594
216
  // src/service-runtime.ts
595
- import { createServer } from "@emulators/core";
217
+ import {
218
+ createServer,
219
+ createStoreFixture,
220
+ fixtureStoreSnapshot
221
+ } from "@api-emulator/core";
596
222
  import { serve } from "@hono/node-server";
597
223
  function createAuthTokens(seedConfig) {
598
224
  const tokens = {};
@@ -631,15 +257,32 @@ function createServiceRuntime(options) {
631
257
  service,
632
258
  url: baseUrl,
633
259
  store,
260
+ snapshot() {
261
+ return store.snapshot();
262
+ },
263
+ restore(fixture) {
264
+ store.restore(fixtureStoreSnapshot(fixture));
265
+ },
266
+ exportFixture(options2 = {}) {
267
+ const interactions = store.getData("api-emulator:interactions");
268
+ return createStoreFixture(service, store.snapshot(), {
269
+ ...options2,
270
+ interactions: options2.interactions ?? interactions
271
+ });
272
+ },
273
+ resetToFixture(fixture) {
274
+ store.reset();
275
+ store.restore(fixtureStoreSnapshot(fixture));
276
+ },
634
277
  reset() {
635
278
  store.reset();
636
279
  seed();
637
280
  },
638
281
  close() {
639
- return new Promise((resolve4, reject) => {
282
+ return new Promise((resolve7, reject) => {
640
283
  httpServer.close((err) => {
641
284
  if (err) reject(err);
642
- else resolve4();
285
+ else resolve7();
643
286
  });
644
287
  });
645
288
  }
@@ -647,15 +290,15 @@ function createServiceRuntime(options) {
647
290
  }
648
291
 
649
292
  // src/commands/start.ts
650
- var pkg = { version: "0.5.1" };
293
+ var pkg = { version: "0.6.0" };
651
294
  function loadSeedConfig(seedPath) {
652
295
  if (seedPath) {
653
- const fullPath = resolve2(seedPath);
654
- if (!existsSync(fullPath)) {
296
+ const fullPath = resolve3(seedPath);
297
+ if (!existsSync2(fullPath)) {
655
298
  console.error(`Seed file not found: ${fullPath}`);
656
299
  process.exit(1);
657
300
  }
658
- const content = readFileSync(fullPath, "utf-8");
301
+ const content = readFileSync2(fullPath, "utf-8");
659
302
  try {
660
303
  const config = fullPath.endsWith(".json") ? JSON.parse(content) : parseYaml(content);
661
304
  return { config, source: seedPath };
@@ -664,21 +307,11 @@ function loadSeedConfig(seedPath) {
664
307
  process.exit(1);
665
308
  }
666
309
  }
667
- const autoFiles = [
668
- "api-emulator.config.yaml",
669
- "api-emulator.config.yml",
670
- "api-emulator.config.json",
671
- "emulate.config.yaml",
672
- "emulate.config.yml",
673
- "emulate.config.json",
674
- "service-emulator.config.yaml",
675
- "service-emulator.config.yml",
676
- "service-emulator.config.json"
677
- ];
310
+ const autoFiles = ["api-emulator.config.yaml", "api-emulator.config.yml", "api-emulator.config.json"];
678
311
  for (const file of autoFiles) {
679
- const fullPath = resolve2(file);
680
- if (existsSync(fullPath)) {
681
- const content = readFileSync(fullPath, "utf-8");
312
+ const fullPath = resolve3(file);
313
+ if (existsSync2(fullPath)) {
314
+ const content = readFileSync2(fullPath, "utf-8");
682
315
  try {
683
316
  const config = fullPath.endsWith(".json") ? JSON.parse(content) : parseYaml(content);
684
317
  return { config, source: file };
@@ -706,7 +339,7 @@ async function startCommand(options) {
706
339
  const pluginSpecifiers = options.plugin?.split(",").map((s) => s.trim()).filter(Boolean) ?? [];
707
340
  let allPluginModules;
708
341
  try {
709
- allPluginModules = await resolvePluginModules(pluginSpecifiers);
342
+ allPluginModules = await resolvePluginModules(pluginSpecifiers, { includeInstalled: true });
710
343
  } catch (err) {
711
344
  console.error(`Failed to load plugins: ${err instanceof Error ? err.message : err}`);
712
345
  process.exit(1);
@@ -804,25 +437,27 @@ function printBanner(services, tokens, configSource) {
804
437
  if (configSource) {
805
438
  lines.push(` ${pc.dim("Config:")} ${configSource}`);
806
439
  } else {
807
- lines.push(` ${pc.dim("Config:")} defaults ${pc.dim("(run")} npx api-emulator init ${pc.dim("to customize)")}`);
440
+ lines.push(
441
+ ` ${pc.dim("Config:")} defaults ${pc.dim("(run")} npx -p api-emulator api init ${pc.dim("to customize)")}`
442
+ );
808
443
  }
809
444
  lines.push("");
810
445
  console.log(lines.join("\n"));
811
446
  }
812
447
 
813
448
  // src/commands/init.ts
814
- import { writeFileSync, existsSync as existsSync2 } from "fs";
815
- import { resolve as resolve3 } from "path";
449
+ import { writeFileSync as writeFileSync2, existsSync as existsSync3 } from "fs";
450
+ import { resolve as resolve4 } from "path";
816
451
  import { stringify as yamlStringify } from "yaml";
817
452
  async function initCommand(options) {
818
453
  const filename = "api-emulator.config.yaml";
819
- const fullPath = resolve3(filename);
820
- if (existsSync2(fullPath)) {
454
+ const fullPath = resolve4(filename);
455
+ if (existsSync3(fullPath)) {
821
456
  console.error(`Config file already exists: ${filename}`);
822
457
  process.exit(1);
823
458
  }
824
459
  const pluginSpecifiers = options.plugin?.split(",").map((s) => s.trim()).filter(Boolean) ?? [];
825
- const pluginModules = await resolvePluginModules(pluginSpecifiers);
460
+ const pluginModules = await resolvePluginModules(pluginSpecifiers, { includeInstalled: true });
826
461
  const availableServices = Object.keys(pluginModules);
827
462
  let config;
828
463
  if (options.service === "all") {
@@ -839,16 +474,16 @@ async function initCommand(options) {
839
474
  config = { ...DEFAULT_TOKENS, ...pluginModule.initConfig };
840
475
  }
841
476
  const content = yamlStringify(config);
842
- writeFileSync(fullPath, content, "utf-8");
477
+ writeFileSync2(fullPath, content, "utf-8");
843
478
  console.log(`Created ${filename}`);
844
479
  console.log(`
845
- Run 'npx api-emulator' to start the emulator.`);
480
+ Run 'npx -p api-emulator api' to start the emulator.`);
846
481
  }
847
482
 
848
483
  // src/commands/list.ts
849
484
  async function listCommand(options = {}) {
850
485
  const pluginSpecifiers = options.plugin?.split(",").map((s) => s.trim()).filter(Boolean) ?? [];
851
- const pluginModules = await resolvePluginModules(pluginSpecifiers);
486
+ const pluginModules = await resolvePluginModules(pluginSpecifiers, { includeInstalled: true });
852
487
  console.log("\nAvailable services:\n");
853
488
  for (const pluginModule of Object.values(pluginModules)) {
854
489
  console.log(` ${pluginModule.name.padEnd(10)}${pluginModule.label}`);
@@ -857,11 +492,179 @@ async function listCommand(options = {}) {
857
492
  }
858
493
  }
859
494
 
495
+ // src/commands/install.ts
496
+ import { spawnSync as spawnSync2 } from "child_process";
497
+ import { existsSync as existsSync5 } from "fs";
498
+ import { resolve as resolve6 } from "path";
499
+
500
+ // src/plugin-source-registry.ts
501
+ import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync3, statSync } from "fs";
502
+ import { dirname, join, resolve as resolve5 } from "path";
503
+ var PLUGIN_SOURCES = {
504
+ cloudflare: {
505
+ name: "cloudflare",
506
+ sourceId: "public",
507
+ kind: "package",
508
+ packageName: "@api-emulator/cloudflare",
509
+ specifier: "@api-emulator/cloudflare",
510
+ description: "Workers-style bindings, D1, KV, R2, queues, durable objects, and service bindings"
511
+ }
512
+ };
513
+ var DEFAULT_CATALOG_DIRS = ["api-emulator-plugins", "api-emulator-internal"];
514
+ var CATALOG_MANIFEST_FILES = ["api-emulator.catalog.json"];
515
+ var PLUGIN_ENTRY_FILES = ["api-emulator.mjs", "api-emulator/index.mjs"];
516
+ var PACKAGE_ENTRY_DIRS = ["api-emulator", "trading-emulator"];
517
+ function candidateCatalogRoots(cwd = process.cwd()) {
518
+ const envRoots = process.env.API_EMULATOR_PLUGIN_CATALOGS?.split(",").map((value) => value.trim()).filter(Boolean) ?? [];
519
+ const roots = [...envRoots];
520
+ let current = resolve5(cwd);
521
+ for (; ; ) {
522
+ for (const dir of DEFAULT_CATALOG_DIRS) {
523
+ roots.push(join(current, dir));
524
+ roots.push(join(dirname(current), dir));
525
+ }
526
+ const parent = dirname(current);
527
+ if (parent === current) break;
528
+ current = parent;
529
+ }
530
+ return [...new Set(roots.map((root) => resolve5(root)))];
531
+ }
532
+ function sourceIdForRoot(root) {
533
+ const base = root.split(/[\\/]/).pop() ?? "local";
534
+ if (base === "api-emulator-plugins") return "public";
535
+ if (base === "api-emulator-internal") return "internal";
536
+ return base;
537
+ }
538
+ function packageEntrySpecifier(packageRoot, pkg3) {
539
+ if (typeof pkg3.exports === "string") return resolve5(packageRoot, pkg3.exports);
540
+ if (typeof pkg3.exports === "object" && pkg3.exports !== null && "." in pkg3.exports && typeof pkg3.exports["."] === "string") {
541
+ return resolve5(packageRoot, pkg3.exports["."]);
542
+ }
543
+ if (typeof pkg3.exports === "object" && pkg3.exports !== null && "." in pkg3.exports && typeof pkg3.exports["."] === "object" && pkg3.exports["."] !== null && "import" in pkg3.exports["."] && typeof pkg3.exports["."].import === "string") {
544
+ return resolve5(packageRoot, pkg3.exports["."].import);
545
+ }
546
+ if (typeof pkg3.main === "string") return resolve5(packageRoot, pkg3.main);
547
+ return packageRoot;
548
+ }
549
+ function discoverManifest(root, sourceId) {
550
+ const sources = [];
551
+ for (const manifestFile of CATALOG_MANIFEST_FILES) {
552
+ const manifestPath = join(root, manifestFile);
553
+ if (!existsSync4(manifestPath)) continue;
554
+ const manifest = JSON.parse(readFileSync3(manifestPath, "utf-8"));
555
+ for (const [name, entry] of Object.entries(manifest.plugins ?? {})) {
556
+ sources.push({
557
+ name,
558
+ sourceId,
559
+ kind: entry.kind ?? (entry.packageName ? "package" : "file"),
560
+ packageName: entry.packageName,
561
+ specifier: entry.specifier.startsWith(".") ? resolve5(root, entry.specifier) : entry.specifier,
562
+ description: entry.description ?? `${name} plugin from ${sourceId} catalog`
563
+ });
564
+ }
565
+ }
566
+ return sources;
567
+ }
568
+ function discoverCatalog(root) {
569
+ if (!existsSync4(root) || !statSync(root).isDirectory()) return [];
570
+ const sourceId = sourceIdForRoot(root);
571
+ const sources = discoverManifest(root, sourceId);
572
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
573
+ if (!entry.isDirectory() || !entry.name.startsWith("@")) continue;
574
+ const name = entry.name.slice(1);
575
+ const pluginRoot = join(root, entry.name);
576
+ for (const entryFile of PLUGIN_ENTRY_FILES) {
577
+ const specifier = join(pluginRoot, entryFile);
578
+ if (existsSync4(specifier)) {
579
+ sources.push({
580
+ name,
581
+ sourceId,
582
+ kind: "file",
583
+ specifier,
584
+ description: `${name} plugin from ${sourceId} catalog`
585
+ });
586
+ break;
587
+ }
588
+ }
589
+ for (const entryDir of PACKAGE_ENTRY_DIRS) {
590
+ const packageRoot = join(pluginRoot, entryDir);
591
+ const packageJsonPath = join(packageRoot, "package.json");
592
+ if (!existsSync4(packageJsonPath)) continue;
593
+ const pkg3 = JSON.parse(readFileSync3(packageJsonPath, "utf-8"));
594
+ sources.push({
595
+ name,
596
+ sourceId,
597
+ kind: "package",
598
+ packageName: pkg3.name,
599
+ packageRoot,
600
+ specifier: packageEntrySpecifier(packageRoot, pkg3),
601
+ description: pkg3.description ?? `${name} package from ${sourceId} catalog`
602
+ });
603
+ break;
604
+ }
605
+ }
606
+ return sources;
607
+ }
608
+ function pluginSources() {
609
+ const sources = { ...PLUGIN_SOURCES };
610
+ for (const root of candidateCatalogRoots()) {
611
+ for (const source of discoverCatalog(root)) {
612
+ sources[source.name] ??= source;
613
+ }
614
+ }
615
+ return sources;
616
+ }
617
+ function resolvePluginSource(name) {
618
+ return pluginSources()[name] ?? null;
619
+ }
620
+
621
+ // src/commands/install.ts
622
+ function detectPackageManager() {
623
+ if (existsSync5(resolve6("bun.lock")) || existsSync5(resolve6("bun.lockb"))) return "bun";
624
+ if (existsSync5(resolve6("pnpm-lock.yaml"))) return "pnpm";
625
+ if (existsSync5(resolve6("yarn.lock"))) return "yarn";
626
+ return "npm";
627
+ }
628
+ function installPackage(packageManager, packageName) {
629
+ const args = packageManager === "bun" ? ["add", "-D", packageName] : packageManager === "pnpm" ? ["add", "-D", packageName] : packageManager === "yarn" ? ["add", "-D", packageName] : ["install", "-D", packageName];
630
+ const result = spawnSync2(packageManager, args, { stdio: "inherit" });
631
+ if (result.error) {
632
+ throw result.error;
633
+ }
634
+ if (result.status !== 0) {
635
+ throw new Error(`${packageManager} ${args.join(" ")} failed`);
636
+ }
637
+ }
638
+ async function installCommand(name, options = {}) {
639
+ const source = resolvePluginSource(name);
640
+ if (!source) {
641
+ throw new Error(`Unknown plugin source: ${name}`);
642
+ }
643
+ const packageManager = options.packageManager === void 0 ? detectPackageManager() : options.packageManager;
644
+ if (packageManager && source.packageName) {
645
+ installPackage(packageManager, source.packageName);
646
+ } else if (packageManager && source.kind === "package" && !source.packageName) {
647
+ throw new Error(`Plugin source "${name}" is a local package without a package name`);
648
+ }
649
+ const lock = readPluginLock();
650
+ lock.plugins[source.name] = {
651
+ name: source.name,
652
+ source: source.kind === "file" ? "specifier" : "registry",
653
+ sourceId: source.sourceId,
654
+ packageName: source.packageName,
655
+ specifier: packageManager && source.packageName ? source.packageName : source.specifier,
656
+ version: "latest"
657
+ };
658
+ writePluginLock(lock);
659
+ console.log(`Installed ${source.name} from ${source.sourceId}`);
660
+ console.log(`Recorded plugin in ${PLUGIN_LOCK_FILE}`);
661
+ }
662
+
860
663
  // src/index.ts
861
- var pkg2 = { version: "0.5.1" };
862
- var defaultPort = process.env.API_EMULATOR_PORT ?? process.env.EMULATE_PORT ?? process.env.PORT ?? "4000";
664
+ var pkg2 = { version: "0.6.0" };
665
+ var defaultPort = process.env.API_EMULATOR_PORT ?? process.env.PORT ?? "4000";
863
666
  var program = new Command();
864
- program.name("api-emulator").description("Local API emulators you can run, share, and extend with plugins").version(pkg2.version);
667
+ program.name("api").description("Local API emulators you can run, share, and extend with plugins").version(pkg2.version);
865
668
  program.command("start", { isDefault: true }).description("Start the emulator server").option("-p, --port <port>", "Base port", defaultPort).option("-s, --service <services>", "Comma-separated services to enable").option("--seed <file>", "Path to seed config file").option("--base-url <url>", "Override advertised base URL (supports {service} template)").option("--portless", "Serve over HTTPS via portless (auto-registers aliases)").option("--plugin <plugins>", "Comma-separated external plugin paths or package names").action(async (opts) => {
866
669
  const port = parseInt(opts.port, 10);
867
670
  if (Number.isNaN(port) || port < 1 || port > 65535) {
@@ -883,5 +686,15 @@ program.command("init").description("Generate a starter config file").option("-s
883
686
  program.command("list").alias("list-services").description("List available services").option("--plugin <plugins>", "Comma-separated external plugin paths or package names").action(async (opts) => {
884
687
  await listCommand({ plugin: opts.plugin });
885
688
  });
689
+ program.command("install <plugin>").description("Install a provider plugin by name").option("--package-manager <name>", "Package manager to use").option("--no-package-manager", "Only record the plugin in api-emulator.lock").action(async (plugin, opts) => {
690
+ try {
691
+ await installCommand(plugin, {
692
+ packageManager: opts.packageManager === false ? false : opts.packageManager
693
+ });
694
+ } catch (err) {
695
+ console.error(err instanceof Error ? err.message : err);
696
+ process.exit(1);
697
+ }
698
+ });
886
699
  program.parse();
887
700
  //# sourceMappingURL=index.js.map