api-emulator 0.5.0 → 0.5.2

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,30 @@ 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 {
11
+ label: mod.label,
12
+ endpoints: mod.endpoints,
13
+ initConfig: mod.initConfig,
14
+ contract: mod.contract,
15
+ ...mod.manifest
16
+ };
17
+ }
18
+ function validatePluginManifest(manifest, pluginName) {
19
+ if (manifest.name && manifest.name !== pluginName) {
20
+ throw new Error(`Plugin manifest name "${manifest.name}" does not match plugin name "${pluginName}"`);
21
+ }
22
+ return {
23
+ ...manifest,
24
+ label: manifest.label ?? `${pluginName} (external plugin)`,
25
+ endpoints: manifest.endpoints ?? "",
26
+ initConfig: manifest.initConfig ?? {}
27
+ };
28
+ }
29
+
30
+ // src/external-plugin-adapter.ts
7
31
  async function loadExternalPluginModule(specifier) {
8
32
  const modulePath = specifier.startsWith(".") || isAbsolute(specifier) ? resolve(specifier) : specifier;
9
33
  const mod = await import(modulePath);
@@ -12,10 +36,12 @@ async function loadExternalPluginModule(specifier) {
12
36
  throw new Error(`Plugin "${specifier}" must export a ServicePlugin (as "plugin" or default export)`);
13
37
  }
14
38
  const name = plugin.name;
39
+ const manifest = validatePluginManifest(readPluginManifest(mod), name);
15
40
  return {
16
41
  name,
17
- label: mod.label ?? `${name} (external plugin)`,
18
- endpoints: mod.endpoints ?? "",
42
+ label: manifest.label,
43
+ endpoints: manifest.endpoints,
44
+ manifest,
19
45
  async load() {
20
46
  return {
21
47
  plugin,
@@ -23,439 +49,39 @@ async function loadExternalPluginModule(specifier) {
23
49
  };
24
50
  },
25
51
  defaultFallback: mod.defaultFallback ?? (() => ({ login: "admin", id: 1, scopes: [] })),
26
- initConfig: mod.initConfig ?? {}
52
+ initConfig: manifest.initConfig
27
53
  };
28
54
  }
29
55
 
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
- }
56
+ // src/plugin-lock.ts
57
+ import { existsSync, readFileSync, writeFileSync } from "fs";
58
+ import { resolve as resolve2 } from "path";
59
+ var PLUGIN_LOCK_FILE = "api-emulator.lock";
60
+ function createEmptyPluginLock() {
61
+ return { version: 1, plugins: {} };
62
+ }
63
+ function readPluginLock(cwd = process.cwd()) {
64
+ const path = resolve2(cwd, PLUGIN_LOCK_FILE);
65
+ if (!existsSync(path)) return createEmptyPluginLock();
66
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
67
+ if (parsed.version !== 1 || typeof parsed.plugins !== "object" || parsed.plugins === null) {
68
+ throw new Error(`Invalid ${PLUGIN_LOCK_FILE}`);
457
69
  }
458
- };
70
+ return parsed;
71
+ }
72
+ function writePluginLock(lock, cwd = process.cwd()) {
73
+ const sortedPlugins = Object.fromEntries(Object.entries(lock.plugins).sort(([a], [b]) => a.localeCompare(b)));
74
+ const content = `${JSON.stringify({ version: 1, plugins: sortedPlugins }, null, 2)}
75
+ `;
76
+ writeFileSync(resolve2(cwd, PLUGIN_LOCK_FILE), content, "utf-8");
77
+ }
78
+ function getLockedPluginSpecifiers(cwd = process.cwd()) {
79
+ return Object.values(readPluginLock(cwd).plugins).map((entry) => entry.specifier);
80
+ }
81
+
82
+ // src/default-plugin-catalog.ts
83
+ var DEFAULT_PLUGIN_NAMES = [];
84
+ var DEFAULT_PLUGIN_REGISTRY = {};
459
85
 
460
86
  // src/registry.ts
461
87
  var DEFAULT_TOKENS = {
@@ -470,8 +96,10 @@ var DEFAULT_TOKENS = {
470
96
  }
471
97
  }
472
98
  };
473
- async function resolvePluginModules(pluginSpecifiers = []) {
474
- const results = await Promise.all(pluginSpecifiers.map(loadExternalPluginModule));
99
+ async function resolvePluginModules(pluginSpecifiers = [], options = {}) {
100
+ const installedSpecifiers = options.includeInstalled ? getLockedPluginSpecifiers() : [];
101
+ const allSpecifiers = [...installedSpecifiers, ...pluginSpecifiers];
102
+ const results = await Promise.all(allSpecifiers.map(loadExternalPluginModule));
475
103
  const externalEntries = {};
476
104
  for (const pluginModule of results) {
477
105
  if (pluginModule.name in DEFAULT_PLUGIN_REGISTRY) {
@@ -489,8 +117,8 @@ function getDefaultPluginNames() {
489
117
  }
490
118
 
491
119
  // src/commands/start.ts
492
- import { readFileSync, existsSync } from "fs";
493
- import { resolve as resolve2 } from "path";
120
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
121
+ import { resolve as resolve3 } from "path";
494
122
  import { parse as parseYaml } from "yaml";
495
123
  import pc from "picocolors";
496
124
 
@@ -505,12 +133,12 @@ function hasPortless() {
505
133
  return result.status === 0;
506
134
  }
507
135
  function promptYesNo(question) {
508
- return new Promise((resolve4) => {
136
+ return new Promise((resolve7) => {
509
137
  const rl = createInterface({ input: process.stdin, output: process.stdout });
510
138
  rl.question(question, (answer) => {
511
139
  rl.close();
512
140
  const normalized = answer.trim().toLowerCase();
513
- resolve4(normalized === "" || normalized === "y" || normalized === "yes");
141
+ resolve7(normalized === "" || normalized === "y" || normalized === "yes");
514
142
  });
515
143
  });
516
144
  }
@@ -592,7 +220,11 @@ function resolveBaseUrl(opts) {
592
220
  }
593
221
 
594
222
  // src/service-runtime.ts
595
- import { createServer } from "@emulators/core";
223
+ import {
224
+ createServer,
225
+ createStoreFixture,
226
+ fixtureStoreSnapshot
227
+ } from "@api-emulator/core";
596
228
  import { serve } from "@hono/node-server";
597
229
  function createAuthTokens(seedConfig) {
598
230
  const tokens = {};
@@ -631,15 +263,32 @@ function createServiceRuntime(options) {
631
263
  service,
632
264
  url: baseUrl,
633
265
  store,
266
+ snapshot() {
267
+ return store.snapshot();
268
+ },
269
+ restore(fixture) {
270
+ store.restore(fixtureStoreSnapshot(fixture));
271
+ },
272
+ exportFixture(options2 = {}) {
273
+ const interactions = store.getData("api-emulator:interactions");
274
+ return createStoreFixture(service, store.snapshot(), {
275
+ ...options2,
276
+ interactions: options2.interactions ?? interactions
277
+ });
278
+ },
279
+ resetToFixture(fixture) {
280
+ store.reset();
281
+ store.restore(fixtureStoreSnapshot(fixture));
282
+ },
634
283
  reset() {
635
284
  store.reset();
636
285
  seed();
637
286
  },
638
287
  close() {
639
- return new Promise((resolve4, reject) => {
288
+ return new Promise((resolve7, reject) => {
640
289
  httpServer.close((err) => {
641
290
  if (err) reject(err);
642
- else resolve4();
291
+ else resolve7();
643
292
  });
644
293
  });
645
294
  }
@@ -647,15 +296,15 @@ function createServiceRuntime(options) {
647
296
  }
648
297
 
649
298
  // src/commands/start.ts
650
- var pkg = { version: "0.5.0" };
299
+ var pkg = { version: "0.5.2" };
651
300
  function loadSeedConfig(seedPath) {
652
301
  if (seedPath) {
653
- const fullPath = resolve2(seedPath);
654
- if (!existsSync(fullPath)) {
302
+ const fullPath = resolve3(seedPath);
303
+ if (!existsSync2(fullPath)) {
655
304
  console.error(`Seed file not found: ${fullPath}`);
656
305
  process.exit(1);
657
306
  }
658
- const content = readFileSync(fullPath, "utf-8");
307
+ const content = readFileSync2(fullPath, "utf-8");
659
308
  try {
660
309
  const config = fullPath.endsWith(".json") ? JSON.parse(content) : parseYaml(content);
661
310
  return { config, source: seedPath };
@@ -676,9 +325,9 @@ function loadSeedConfig(seedPath) {
676
325
  "service-emulator.config.json"
677
326
  ];
678
327
  for (const file of autoFiles) {
679
- const fullPath = resolve2(file);
680
- if (existsSync(fullPath)) {
681
- const content = readFileSync(fullPath, "utf-8");
328
+ const fullPath = resolve3(file);
329
+ if (existsSync2(fullPath)) {
330
+ const content = readFileSync2(fullPath, "utf-8");
682
331
  try {
683
332
  const config = fullPath.endsWith(".json") ? JSON.parse(content) : parseYaml(content);
684
333
  return { config, source: file };
@@ -706,7 +355,7 @@ async function startCommand(options) {
706
355
  const pluginSpecifiers = options.plugin?.split(",").map((s) => s.trim()).filter(Boolean) ?? [];
707
356
  let allPluginModules;
708
357
  try {
709
- allPluginModules = await resolvePluginModules(pluginSpecifiers);
358
+ allPluginModules = await resolvePluginModules(pluginSpecifiers, { includeInstalled: true });
710
359
  } catch (err) {
711
360
  console.error(`Failed to load plugins: ${err instanceof Error ? err.message : err}`);
712
361
  process.exit(1);
@@ -804,25 +453,27 @@ function printBanner(services, tokens, configSource) {
804
453
  if (configSource) {
805
454
  lines.push(` ${pc.dim("Config:")} ${configSource}`);
806
455
  } else {
807
- lines.push(` ${pc.dim("Config:")} defaults ${pc.dim("(run")} npx api-emulator init ${pc.dim("to customize)")}`);
456
+ lines.push(
457
+ ` ${pc.dim("Config:")} defaults ${pc.dim("(run")} npx -p api-emulator api init ${pc.dim("to customize)")}`
458
+ );
808
459
  }
809
460
  lines.push("");
810
461
  console.log(lines.join("\n"));
811
462
  }
812
463
 
813
464
  // src/commands/init.ts
814
- import { writeFileSync, existsSync as existsSync2 } from "fs";
815
- import { resolve as resolve3 } from "path";
465
+ import { writeFileSync as writeFileSync2, existsSync as existsSync3 } from "fs";
466
+ import { resolve as resolve4 } from "path";
816
467
  import { stringify as yamlStringify } from "yaml";
817
468
  async function initCommand(options) {
818
469
  const filename = "api-emulator.config.yaml";
819
- const fullPath = resolve3(filename);
820
- if (existsSync2(fullPath)) {
470
+ const fullPath = resolve4(filename);
471
+ if (existsSync3(fullPath)) {
821
472
  console.error(`Config file already exists: ${filename}`);
822
473
  process.exit(1);
823
474
  }
824
475
  const pluginSpecifiers = options.plugin?.split(",").map((s) => s.trim()).filter(Boolean) ?? [];
825
- const pluginModules = await resolvePluginModules(pluginSpecifiers);
476
+ const pluginModules = await resolvePluginModules(pluginSpecifiers, { includeInstalled: true });
826
477
  const availableServices = Object.keys(pluginModules);
827
478
  let config;
828
479
  if (options.service === "all") {
@@ -839,16 +490,16 @@ async function initCommand(options) {
839
490
  config = { ...DEFAULT_TOKENS, ...pluginModule.initConfig };
840
491
  }
841
492
  const content = yamlStringify(config);
842
- writeFileSync(fullPath, content, "utf-8");
493
+ writeFileSync2(fullPath, content, "utf-8");
843
494
  console.log(`Created ${filename}`);
844
495
  console.log(`
845
- Run 'npx api-emulator' to start the emulator.`);
496
+ Run 'npx -p api-emulator api' to start the emulator.`);
846
497
  }
847
498
 
848
499
  // src/commands/list.ts
849
500
  async function listCommand(options = {}) {
850
501
  const pluginSpecifiers = options.plugin?.split(",").map((s) => s.trim()).filter(Boolean) ?? [];
851
- const pluginModules = await resolvePluginModules(pluginSpecifiers);
502
+ const pluginModules = await resolvePluginModules(pluginSpecifiers, { includeInstalled: true });
852
503
  console.log("\nAvailable services:\n");
853
504
  for (const pluginModule of Object.values(pluginModules)) {
854
505
  console.log(` ${pluginModule.name.padEnd(10)}${pluginModule.label}`);
@@ -857,11 +508,179 @@ async function listCommand(options = {}) {
857
508
  }
858
509
  }
859
510
 
511
+ // src/commands/install.ts
512
+ import { spawnSync as spawnSync2 } from "child_process";
513
+ import { existsSync as existsSync5 } from "fs";
514
+ import { resolve as resolve6 } from "path";
515
+
516
+ // src/plugin-source-registry.ts
517
+ import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync3, statSync } from "fs";
518
+ import { dirname, join, resolve as resolve5 } from "path";
519
+ var PLUGIN_SOURCES = {
520
+ cloudflare: {
521
+ name: "cloudflare",
522
+ sourceId: "public",
523
+ kind: "package",
524
+ packageName: "@api-emulator/cloudflare",
525
+ specifier: "@api-emulator/cloudflare",
526
+ description: "Workers-style bindings, D1, KV, R2, queues, durable objects, and service bindings"
527
+ }
528
+ };
529
+ var DEFAULT_CATALOG_DIRS = ["api-emulator-plugins", "api-emulator-internal"];
530
+ var CATALOG_MANIFEST_FILES = ["api-emulator.catalog.json"];
531
+ var PLUGIN_ENTRY_FILES = ["api-emulator.mjs", "api-emulator/index.mjs"];
532
+ var PACKAGE_ENTRY_DIRS = ["api-emulator", "trading-emulator"];
533
+ function candidateCatalogRoots(cwd = process.cwd()) {
534
+ const envRoots = process.env.API_EMULATOR_PLUGIN_CATALOGS?.split(",").map((value) => value.trim()).filter(Boolean) ?? [];
535
+ const roots = [...envRoots];
536
+ let current = resolve5(cwd);
537
+ for (; ; ) {
538
+ for (const dir of DEFAULT_CATALOG_DIRS) {
539
+ roots.push(join(current, dir));
540
+ roots.push(join(dirname(current), dir));
541
+ }
542
+ const parent = dirname(current);
543
+ if (parent === current) break;
544
+ current = parent;
545
+ }
546
+ return [...new Set(roots.map((root) => resolve5(root)))];
547
+ }
548
+ function sourceIdForRoot(root) {
549
+ const base = root.split(/[\\/]/).pop() ?? "local";
550
+ if (base === "api-emulator-plugins") return "public";
551
+ if (base === "api-emulator-internal") return "internal";
552
+ return base;
553
+ }
554
+ function packageEntrySpecifier(packageRoot, pkg3) {
555
+ if (typeof pkg3.exports === "string") return resolve5(packageRoot, pkg3.exports);
556
+ if (typeof pkg3.exports === "object" && pkg3.exports !== null && "." in pkg3.exports && typeof pkg3.exports["."] === "string") {
557
+ return resolve5(packageRoot, pkg3.exports["."]);
558
+ }
559
+ 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") {
560
+ return resolve5(packageRoot, pkg3.exports["."].import);
561
+ }
562
+ if (typeof pkg3.main === "string") return resolve5(packageRoot, pkg3.main);
563
+ return packageRoot;
564
+ }
565
+ function discoverManifest(root, sourceId) {
566
+ const sources = [];
567
+ for (const manifestFile of CATALOG_MANIFEST_FILES) {
568
+ const manifestPath = join(root, manifestFile);
569
+ if (!existsSync4(manifestPath)) continue;
570
+ const manifest = JSON.parse(readFileSync3(manifestPath, "utf-8"));
571
+ for (const [name, entry] of Object.entries(manifest.plugins ?? {})) {
572
+ sources.push({
573
+ name,
574
+ sourceId,
575
+ kind: entry.kind ?? (entry.packageName ? "package" : "file"),
576
+ packageName: entry.packageName,
577
+ specifier: entry.specifier.startsWith(".") ? resolve5(root, entry.specifier) : entry.specifier,
578
+ description: entry.description ?? `${name} plugin from ${sourceId} catalog`
579
+ });
580
+ }
581
+ }
582
+ return sources;
583
+ }
584
+ function discoverCatalog(root) {
585
+ if (!existsSync4(root) || !statSync(root).isDirectory()) return [];
586
+ const sourceId = sourceIdForRoot(root);
587
+ const sources = discoverManifest(root, sourceId);
588
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
589
+ if (!entry.isDirectory() || !entry.name.startsWith("@")) continue;
590
+ const name = entry.name.slice(1);
591
+ const pluginRoot = join(root, entry.name);
592
+ for (const entryFile of PLUGIN_ENTRY_FILES) {
593
+ const specifier = join(pluginRoot, entryFile);
594
+ if (existsSync4(specifier)) {
595
+ sources.push({
596
+ name,
597
+ sourceId,
598
+ kind: "file",
599
+ specifier,
600
+ description: `${name} plugin from ${sourceId} catalog`
601
+ });
602
+ break;
603
+ }
604
+ }
605
+ for (const entryDir of PACKAGE_ENTRY_DIRS) {
606
+ const packageRoot = join(pluginRoot, entryDir);
607
+ const packageJsonPath = join(packageRoot, "package.json");
608
+ if (!existsSync4(packageJsonPath)) continue;
609
+ const pkg3 = JSON.parse(readFileSync3(packageJsonPath, "utf-8"));
610
+ sources.push({
611
+ name,
612
+ sourceId,
613
+ kind: "package",
614
+ packageName: pkg3.name,
615
+ packageRoot,
616
+ specifier: packageEntrySpecifier(packageRoot, pkg3),
617
+ description: pkg3.description ?? `${name} package from ${sourceId} catalog`
618
+ });
619
+ break;
620
+ }
621
+ }
622
+ return sources;
623
+ }
624
+ function pluginSources() {
625
+ const sources = { ...PLUGIN_SOURCES };
626
+ for (const root of candidateCatalogRoots()) {
627
+ for (const source of discoverCatalog(root)) {
628
+ sources[source.name] ??= source;
629
+ }
630
+ }
631
+ return sources;
632
+ }
633
+ function resolvePluginSource(name) {
634
+ return pluginSources()[name] ?? null;
635
+ }
636
+
637
+ // src/commands/install.ts
638
+ function detectPackageManager() {
639
+ if (existsSync5(resolve6("bun.lock")) || existsSync5(resolve6("bun.lockb"))) return "bun";
640
+ if (existsSync5(resolve6("pnpm-lock.yaml"))) return "pnpm";
641
+ if (existsSync5(resolve6("yarn.lock"))) return "yarn";
642
+ return "npm";
643
+ }
644
+ function installPackage(packageManager, packageName) {
645
+ const args = packageManager === "bun" ? ["add", "-D", packageName] : packageManager === "pnpm" ? ["add", "-D", packageName] : packageManager === "yarn" ? ["add", "-D", packageName] : ["install", "-D", packageName];
646
+ const result = spawnSync2(packageManager, args, { stdio: "inherit" });
647
+ if (result.error) {
648
+ throw result.error;
649
+ }
650
+ if (result.status !== 0) {
651
+ throw new Error(`${packageManager} ${args.join(" ")} failed`);
652
+ }
653
+ }
654
+ async function installCommand(name, options = {}) {
655
+ const source = resolvePluginSource(name);
656
+ if (!source) {
657
+ throw new Error(`Unknown plugin source: ${name}`);
658
+ }
659
+ const packageManager = options.packageManager === void 0 ? detectPackageManager() : options.packageManager;
660
+ if (packageManager && source.packageName) {
661
+ installPackage(packageManager, source.packageName);
662
+ } else if (packageManager && source.kind === "package" && !source.packageName) {
663
+ throw new Error(`Plugin source "${name}" is a local package without a package name`);
664
+ }
665
+ const lock = readPluginLock();
666
+ lock.plugins[source.name] = {
667
+ name: source.name,
668
+ source: source.kind === "file" ? "specifier" : "registry",
669
+ sourceId: source.sourceId,
670
+ packageName: source.packageName,
671
+ specifier: packageManager && source.packageName ? source.packageName : source.specifier,
672
+ version: "latest"
673
+ };
674
+ writePluginLock(lock);
675
+ console.log(`Installed ${source.name} from ${source.sourceId}`);
676
+ console.log(`Recorded plugin in ${PLUGIN_LOCK_FILE}`);
677
+ }
678
+
860
679
  // src/index.ts
861
- var pkg2 = { version: "0.5.0" };
680
+ var pkg2 = { version: "0.5.2" };
862
681
  var defaultPort = process.env.API_EMULATOR_PORT ?? process.env.EMULATE_PORT ?? process.env.PORT ?? "4000";
863
682
  var program = new Command();
864
- program.name("api-emulator").description("Local API emulators you can run, share, and extend with plugins").version(pkg2.version);
683
+ program.name("api").description("Local API emulators you can run, share, and extend with plugins").version(pkg2.version);
865
684
  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
685
  const port = parseInt(opts.port, 10);
867
686
  if (Number.isNaN(port) || port < 1 || port > 65535) {
@@ -883,5 +702,15 @@ program.command("init").description("Generate a starter config file").option("-s
883
702
  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
703
  await listCommand({ plugin: opts.plugin });
885
704
  });
705
+ 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) => {
706
+ try {
707
+ await installCommand(plugin, {
708
+ packageManager: opts.packageManager === false ? false : opts.packageManager
709
+ });
710
+ } catch (err) {
711
+ console.error(err instanceof Error ? err.message : err);
712
+ process.exit(1);
713
+ }
714
+ });
886
715
  program.parse();
887
716
  //# sourceMappingURL=index.js.map