api-emulator 0.6.0 → 0.7.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/README.md +59 -20
- package/dist/api.js +6 -0
- package/dist/api.js.map +1 -1
- package/dist/index.js +480 -77
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -6,6 +6,11 @@ import { Command } from "commander";
|
|
|
6
6
|
import { isAbsolute, resolve } from "path";
|
|
7
7
|
|
|
8
8
|
// src/plugin-manifest.ts
|
|
9
|
+
function formatPluginFidelity(fidelity) {
|
|
10
|
+
if (!fidelity) return "unrated";
|
|
11
|
+
if (typeof fidelity === "string") return fidelity;
|
|
12
|
+
return fidelity.level;
|
|
13
|
+
}
|
|
9
14
|
function readPluginManifest(mod) {
|
|
10
15
|
return mod.manifest ?? {};
|
|
11
16
|
}
|
|
@@ -25,20 +30,21 @@ function validatePluginManifest(manifest, pluginName) {
|
|
|
25
30
|
async function loadExternalPluginModule(specifier) {
|
|
26
31
|
const modulePath = specifier.startsWith(".") || isAbsolute(specifier) ? resolve(specifier) : specifier;
|
|
27
32
|
const mod = await import(modulePath);
|
|
28
|
-
const
|
|
29
|
-
if (!
|
|
33
|
+
const plugin2 = mod.plugin ?? mod.default;
|
|
34
|
+
if (!plugin2 || typeof plugin2.register !== "function" || typeof plugin2.name !== "string") {
|
|
30
35
|
throw new Error(`Plugin "${specifier}" must export a ServicePlugin (as "plugin" or default export)`);
|
|
31
36
|
}
|
|
32
|
-
const name =
|
|
37
|
+
const name = plugin2.name;
|
|
33
38
|
const manifest = validatePluginManifest(readPluginManifest(mod), name);
|
|
34
39
|
return {
|
|
35
40
|
name,
|
|
36
41
|
label: manifest.label,
|
|
37
42
|
endpoints: manifest.endpoints,
|
|
43
|
+
fidelity: formatPluginFidelity(manifest.fidelity),
|
|
38
44
|
manifest,
|
|
39
45
|
async load() {
|
|
40
46
|
return {
|
|
41
|
-
plugin,
|
|
47
|
+
plugin: plugin2,
|
|
42
48
|
seedFromConfig: mod.seedFromConfig
|
|
43
49
|
};
|
|
44
50
|
},
|
|
@@ -118,6 +124,7 @@ import pc from "picocolors";
|
|
|
118
124
|
|
|
119
125
|
// src/portless.ts
|
|
120
126
|
import { execSync, spawnSync } from "child_process";
|
|
127
|
+
import { Socket } from "net";
|
|
121
128
|
import { createInterface } from "readline";
|
|
122
129
|
function isInteractive() {
|
|
123
130
|
return Boolean(process.stdin.isTTY) && !process.env.CI;
|
|
@@ -127,32 +134,60 @@ function hasPortless() {
|
|
|
127
134
|
return result.status === 0;
|
|
128
135
|
}
|
|
129
136
|
function promptYesNo(question) {
|
|
130
|
-
return new Promise((
|
|
137
|
+
return new Promise((resolve9) => {
|
|
131
138
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
132
139
|
rl.question(question, (answer) => {
|
|
133
140
|
rl.close();
|
|
134
141
|
const normalized = answer.trim().toLowerCase();
|
|
135
|
-
|
|
142
|
+
resolve9(normalized === "" || normalized === "y" || normalized === "yes");
|
|
136
143
|
});
|
|
137
144
|
});
|
|
138
145
|
}
|
|
139
|
-
function
|
|
140
|
-
const result = spawnSync("portless", ["
|
|
141
|
-
|
|
146
|
+
function portlessUrl(name) {
|
|
147
|
+
const result = spawnSync("portless", ["get", name], { encoding: "utf-8" });
|
|
148
|
+
if (result.status !== 0) return null;
|
|
149
|
+
const url = result.stdout.trim();
|
|
150
|
+
return url.length > 0 ? url : null;
|
|
142
151
|
}
|
|
152
|
+
async function canConnect(port) {
|
|
153
|
+
return new Promise((resolve9) => {
|
|
154
|
+
const socket = new Socket();
|
|
155
|
+
const done = (ok) => {
|
|
156
|
+
socket.destroy();
|
|
157
|
+
resolve9(ok);
|
|
158
|
+
};
|
|
159
|
+
socket.setTimeout(500);
|
|
160
|
+
socket.once("connect", () => done(true));
|
|
161
|
+
socket.once("error", () => done(false));
|
|
162
|
+
socket.once("timeout", () => done(false));
|
|
163
|
+
socket.connect(port, "127.0.0.1");
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
async function isProxyRunning() {
|
|
167
|
+
const url = portlessUrl("api-emulator-probe");
|
|
168
|
+
if (!url) return false;
|
|
169
|
+
try {
|
|
170
|
+
const parsed = new URL(url);
|
|
171
|
+
const port = parsed.port ? Number(parsed.port) : parsed.protocol === "http:" ? 80 : 443;
|
|
172
|
+
return await canConnect(port);
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
var portlessInstallCommand = "bun add --global portless";
|
|
143
178
|
async function ensurePortless() {
|
|
144
179
|
if (!hasPortless()) {
|
|
145
180
|
if (!isInteractive()) {
|
|
146
|
-
console.error(
|
|
181
|
+
console.error(`portless is required but not installed. Run: ${portlessInstallCommand}`);
|
|
147
182
|
process.exit(1);
|
|
148
183
|
}
|
|
149
|
-
const yes = await promptYesNo(
|
|
184
|
+
const yes = await promptYesNo(`portless is not installed. Install it now? (${portlessInstallCommand}) [Y/n] `);
|
|
150
185
|
if (!yes) {
|
|
151
186
|
console.error("Cannot continue without portless.");
|
|
152
187
|
process.exit(1);
|
|
153
188
|
}
|
|
154
189
|
try {
|
|
155
|
-
execSync(
|
|
190
|
+
execSync(portlessInstallCommand, { stdio: "inherit" });
|
|
156
191
|
} catch {
|
|
157
192
|
console.error("Failed to install portless.");
|
|
158
193
|
process.exit(1);
|
|
@@ -162,7 +197,7 @@ async function ensurePortless() {
|
|
|
162
197
|
process.exit(1);
|
|
163
198
|
}
|
|
164
199
|
}
|
|
165
|
-
if (!isProxyRunning()) {
|
|
200
|
+
if (!await isProxyRunning()) {
|
|
166
201
|
console.error("portless proxy is not running. Start it with: portless proxy start");
|
|
167
202
|
process.exit(1);
|
|
168
203
|
}
|
|
@@ -191,7 +226,7 @@ function removeAliases(aliases) {
|
|
|
191
226
|
}
|
|
192
227
|
}
|
|
193
228
|
function portlessBaseUrl(serviceName) {
|
|
194
|
-
return `https://${serviceName}.api-emulator.localhost`;
|
|
229
|
+
return portlessUrl(`${serviceName}.api-emulator`) ?? `https://${serviceName}.api-emulator.localhost`;
|
|
195
230
|
}
|
|
196
231
|
|
|
197
232
|
// src/base-url.ts
|
|
@@ -206,9 +241,9 @@ function resolveBaseUrl(opts) {
|
|
|
206
241
|
if (envBaseUrl) {
|
|
207
242
|
return envBaseUrl.replace(/\{service\}/g, opts.service);
|
|
208
243
|
}
|
|
209
|
-
const
|
|
210
|
-
if (
|
|
211
|
-
return
|
|
244
|
+
const portlessUrl2 = process.env.PORTLESS_URL;
|
|
245
|
+
if (portlessUrl2) {
|
|
246
|
+
return portlessUrl2.replace(/\{service\}/g, opts.service);
|
|
212
247
|
}
|
|
213
248
|
return `http://localhost:${opts.port}`;
|
|
214
249
|
}
|
|
@@ -279,18 +314,36 @@ function createServiceRuntime(options) {
|
|
|
279
314
|
seed();
|
|
280
315
|
},
|
|
281
316
|
close() {
|
|
282
|
-
return new Promise((
|
|
317
|
+
return new Promise((resolve9, reject) => {
|
|
283
318
|
httpServer.close((err) => {
|
|
284
319
|
if (err) reject(err);
|
|
285
|
-
else
|
|
320
|
+
else resolve9();
|
|
286
321
|
});
|
|
287
322
|
});
|
|
288
323
|
}
|
|
289
324
|
};
|
|
290
325
|
}
|
|
291
326
|
|
|
327
|
+
// src/cli-notifier.ts
|
|
328
|
+
import { spawn } from "child_process";
|
|
329
|
+
import { platform } from "os";
|
|
330
|
+
function escapeAppleScript(value) {
|
|
331
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
332
|
+
}
|
|
333
|
+
function appleScriptNotification({ title, message }) {
|
|
334
|
+
return `display notification "${escapeAppleScript(message)}" with title "${escapeAppleScript(title)}"`;
|
|
335
|
+
}
|
|
336
|
+
function notifyIfRequested(options) {
|
|
337
|
+
if (!options.enabled || platform() !== "darwin") return;
|
|
338
|
+
const child = spawn("/usr/bin/osascript", ["-e", appleScriptNotification(options)], {
|
|
339
|
+
detached: true,
|
|
340
|
+
stdio: "ignore"
|
|
341
|
+
});
|
|
342
|
+
child.unref();
|
|
343
|
+
}
|
|
344
|
+
|
|
292
345
|
// src/commands/start.ts
|
|
293
|
-
var pkg = { version: "0.
|
|
346
|
+
var pkg = { version: "0.7.0" };
|
|
294
347
|
function loadSeedConfig(seedPath) {
|
|
295
348
|
if (seedPath) {
|
|
296
349
|
const fullPath = resolve3(seedPath);
|
|
@@ -378,17 +431,17 @@ async function startCommand(options) {
|
|
|
378
431
|
if (options.portless) {
|
|
379
432
|
portlessAliases.push({ name: `${svc}.api-emulator`, port });
|
|
380
433
|
}
|
|
381
|
-
|
|
382
|
-
const effectiveBaseUrl = options.portless ? portlessBaseUrl(svc) : options.baseUrl;
|
|
383
|
-
const baseUrl = resolveBaseUrl({ service: svc, port, baseUrl: effectiveBaseUrl, seedBaseUrl });
|
|
384
|
-
prepared.push({ svc, pluginModule, loadedPlugin, svcSeedConfig, port, baseUrl });
|
|
434
|
+
prepared.push({ svc, pluginModule, loadedPlugin, svcSeedConfig, port });
|
|
385
435
|
}
|
|
386
436
|
if (portlessAliases.length > 0) {
|
|
387
437
|
registerAliases(portlessAliases);
|
|
388
438
|
}
|
|
389
439
|
const serviceUrls = [];
|
|
390
440
|
const runningServices = [];
|
|
391
|
-
for (const { svc, pluginModule, loadedPlugin, svcSeedConfig, port
|
|
441
|
+
for (const { svc, pluginModule, loadedPlugin, svcSeedConfig, port } of prepared) {
|
|
442
|
+
const seedBaseUrl = typeof svcSeedConfig?.baseUrl === "string" && svcSeedConfig.baseUrl.length > 0 ? svcSeedConfig.baseUrl : void 0;
|
|
443
|
+
const effectiveBaseUrl = options.portless ? portlessBaseUrl(svc) : options.baseUrl;
|
|
444
|
+
const baseUrl = resolveBaseUrl({ service: svc, port, baseUrl: effectiveBaseUrl, seedBaseUrl });
|
|
392
445
|
serviceUrls.push({ name: svc, url: baseUrl });
|
|
393
446
|
const running = createServiceRuntime({
|
|
394
447
|
service: svc,
|
|
@@ -402,6 +455,11 @@ async function startCommand(options) {
|
|
|
402
455
|
runningServices.push(running);
|
|
403
456
|
}
|
|
404
457
|
printBanner(serviceUrls, tokens, configSource);
|
|
458
|
+
notifyIfRequested({
|
|
459
|
+
enabled: options.notify,
|
|
460
|
+
title: "api-emulator",
|
|
461
|
+
message: `Server started with ${serviceUrls.length} service${serviceUrls.length === 1 ? "" : "s"}`
|
|
462
|
+
});
|
|
405
463
|
const shutdown = () => {
|
|
406
464
|
console.log(`
|
|
407
465
|
${pc.dim("Shutting down...")}`);
|
|
@@ -446,13 +504,143 @@ function printBanner(services, tokens, configSource) {
|
|
|
446
504
|
}
|
|
447
505
|
|
|
448
506
|
// src/commands/init.ts
|
|
449
|
-
import { writeFileSync as
|
|
450
|
-
import {
|
|
507
|
+
import { writeFileSync as writeFileSync3, existsSync as existsSync4 } from "fs";
|
|
508
|
+
import { homedir } from "os";
|
|
509
|
+
import { resolve as resolve5 } from "path";
|
|
451
510
|
import { stringify as yamlStringify } from "yaml";
|
|
511
|
+
|
|
512
|
+
// src/generated-manifest.ts
|
|
513
|
+
import { createHash } from "crypto";
|
|
514
|
+
import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
515
|
+
import { dirname, resolve as resolve4 } from "path";
|
|
516
|
+
var GENERATED_MANIFEST_FILE = ".api-emulator/manifest.json";
|
|
517
|
+
function checksum(content) {
|
|
518
|
+
return createHash("sha256").update(content).digest("hex");
|
|
519
|
+
}
|
|
520
|
+
function readGeneratedManifest(cwd = process.cwd()) {
|
|
521
|
+
const path = resolve4(cwd, GENERATED_MANIFEST_FILE);
|
|
522
|
+
if (!existsSync3(path)) return { version: 1, files: {} };
|
|
523
|
+
const parsed = JSON.parse(readFileSync3(path, "utf-8"));
|
|
524
|
+
if (parsed.version !== 1 || typeof parsed.files !== "object" || parsed.files === null) {
|
|
525
|
+
throw new Error(`Invalid ${GENERATED_MANIFEST_FILE}`);
|
|
526
|
+
}
|
|
527
|
+
return parsed;
|
|
528
|
+
}
|
|
529
|
+
function writeGeneratedManifest(manifest, cwd = process.cwd()) {
|
|
530
|
+
const path = resolve4(cwd, GENERATED_MANIFEST_FILE);
|
|
531
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
532
|
+
const files = Object.fromEntries(Object.entries(manifest.files).sort(([a], [b]) => a.localeCompare(b)));
|
|
533
|
+
writeFileSync2(path, `${JSON.stringify({ version: 1, files }, null, 2)}
|
|
534
|
+
`, "utf-8");
|
|
535
|
+
}
|
|
536
|
+
function writeGeneratedFile(relativePath, content, options) {
|
|
537
|
+
const cwd = options.cwd ?? process.cwd();
|
|
538
|
+
const path = resolve4(cwd, relativePath);
|
|
539
|
+
const manifest = readGeneratedManifest(cwd);
|
|
540
|
+
const existing = manifest.files[relativePath];
|
|
541
|
+
if (existsSync3(path)) {
|
|
542
|
+
const current = readFileSync3(path, "utf-8");
|
|
543
|
+
if (current === content) {
|
|
544
|
+
manifest.files[relativePath] = { checksum: checksum(content), source: options.source };
|
|
545
|
+
writeGeneratedManifest(manifest, cwd);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (!options.yes && (!existing || existing.checksum !== checksum(current))) {
|
|
549
|
+
throw new Error(`Refusing to overwrite user-edited file: ${relativePath}. Re-run with --yes to overwrite.`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
553
|
+
writeFileSync2(path, content, "utf-8");
|
|
554
|
+
manifest.files[relativePath] = { checksum: checksum(content), source: options.source };
|
|
555
|
+
writeGeneratedManifest(manifest, cwd);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// src/commands/init.ts
|
|
559
|
+
var AGENT_SKILL_DIRS = {
|
|
560
|
+
agents: ".agents/skills",
|
|
561
|
+
claude: ".claude/skills",
|
|
562
|
+
codex: ".codex/skills",
|
|
563
|
+
cursor: ".cursor/skills",
|
|
564
|
+
factory: ".agents/skills",
|
|
565
|
+
global: `${homedir()}/.agents/skills`,
|
|
566
|
+
"user-agents": `${homedir()}/.agents/skills`,
|
|
567
|
+
windsurf: ".windsurf/skills"
|
|
568
|
+
};
|
|
569
|
+
function parseAgentTargets(input, nonInteractive) {
|
|
570
|
+
const values = input?.split(",").map((value) => value.trim()).filter(Boolean) ?? [];
|
|
571
|
+
if (values.length > 0) return values;
|
|
572
|
+
return nonInteractive ? ["agents"] : ["agents"];
|
|
573
|
+
}
|
|
574
|
+
function pluginAuthoringSkill() {
|
|
575
|
+
return `---
|
|
576
|
+
name: api-emulator-plugin-authoring
|
|
577
|
+
description: Create, validate, and install api-emulator provider plugins.
|
|
578
|
+
---
|
|
579
|
+
|
|
580
|
+
# API Emulator Plugin Authoring
|
|
581
|
+
|
|
582
|
+
Use this skill when adding or validating a provider plugin for api-emulator.
|
|
583
|
+
|
|
584
|
+
## Workflow
|
|
585
|
+
|
|
586
|
+
1. Create a provider clone scaffold with \`npx -p api-emulator api clone create <provider>\`.
|
|
587
|
+
2. Add provider-shaped routes, deterministic seed state, and manifest fidelity metadata.
|
|
588
|
+
3. Run \`npx -p api-emulator api validate-plugin <provider>\`.
|
|
589
|
+
4. Run the relevant smoke or test command before calling the plugin done.
|
|
590
|
+
|
|
591
|
+
## Safety
|
|
592
|
+
|
|
593
|
+
- Never call production APIs from smoke tests.
|
|
594
|
+
- Use dummy credentials and temporary CLI homes.
|
|
595
|
+
- Prefer documented base URL overrides before patching SDKs or CLIs.
|
|
596
|
+
`;
|
|
597
|
+
}
|
|
598
|
+
function runtimeSkill() {
|
|
599
|
+
return `---
|
|
600
|
+
name: api-emulator-runtime
|
|
601
|
+
description: Run api-emulator locally with configured provider plugins.
|
|
602
|
+
---
|
|
603
|
+
|
|
604
|
+
# API Emulator Runtime
|
|
605
|
+
|
|
606
|
+
Use this skill when running local API replacements for development or tests.
|
|
607
|
+
|
|
608
|
+
## Commands
|
|
609
|
+
|
|
610
|
+
- \`npx -p api-emulator api list\`
|
|
611
|
+
- \`npx -p api-emulator api init\`
|
|
612
|
+
- \`npx -p api-emulator api install <plugin>\`
|
|
613
|
+
- \`npx -p api-emulator api clone create <provider>\`
|
|
614
|
+
- \`npx -p api-emulator api --service <service>\`
|
|
615
|
+
|
|
616
|
+
Keep seed data deterministic and reset emulator state between tests.
|
|
617
|
+
`;
|
|
618
|
+
}
|
|
619
|
+
function installAgentSkills(options) {
|
|
620
|
+
for (const target of parseAgentTargets(options.agents, options.nonInteractive)) {
|
|
621
|
+
const dir = AGENT_SKILL_DIRS[target];
|
|
622
|
+
if (!dir) {
|
|
623
|
+
throw new Error(`Unknown agent target: ${target}. Available: ${Object.keys(AGENT_SKILL_DIRS).join(", ")}`);
|
|
624
|
+
}
|
|
625
|
+
writeGeneratedFile(`${dir}/api-emulator-plugin-authoring/SKILL.md`, pluginAuthoringSkill(), {
|
|
626
|
+
source: "api init",
|
|
627
|
+
yes: options.yes
|
|
628
|
+
});
|
|
629
|
+
writeGeneratedFile(`${dir}/api-emulator-runtime/SKILL.md`, runtimeSkill(), {
|
|
630
|
+
source: "api init",
|
|
631
|
+
yes: options.yes
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
}
|
|
452
635
|
async function initCommand(options) {
|
|
636
|
+
if (options.agents || options.skillsOnly) {
|
|
637
|
+
installAgentSkills(options);
|
|
638
|
+
console.log("Installed api-emulator agent skills");
|
|
639
|
+
}
|
|
640
|
+
if (options.skillsOnly) return;
|
|
453
641
|
const filename = "api-emulator.config.yaml";
|
|
454
|
-
const fullPath =
|
|
455
|
-
if (
|
|
642
|
+
const fullPath = resolve5(filename);
|
|
643
|
+
if (existsSync4(fullPath)) {
|
|
456
644
|
console.error(`Config file already exists: ${filename}`);
|
|
457
645
|
process.exit(1);
|
|
458
646
|
}
|
|
@@ -474,7 +662,7 @@ async function initCommand(options) {
|
|
|
474
662
|
config = { ...DEFAULT_TOKENS, ...pluginModule.initConfig };
|
|
475
663
|
}
|
|
476
664
|
const content = yamlStringify(config);
|
|
477
|
-
|
|
665
|
+
writeFileSync3(fullPath, content, "utf-8");
|
|
478
666
|
console.log(`Created ${filename}`);
|
|
479
667
|
console.log(`
|
|
480
668
|
Run 'npx -p api-emulator api' to start the emulator.`);
|
|
@@ -488,18 +676,19 @@ async function listCommand(options = {}) {
|
|
|
488
676
|
for (const pluginModule of Object.values(pluginModules)) {
|
|
489
677
|
console.log(` ${pluginModule.name.padEnd(10)}${pluginModule.label}`);
|
|
490
678
|
console.log(` Endpoints: ${pluginModule.endpoints}`);
|
|
679
|
+
console.log(` Fidelity: ${pluginModule.fidelity}`);
|
|
491
680
|
console.log();
|
|
492
681
|
}
|
|
493
682
|
}
|
|
494
683
|
|
|
495
684
|
// src/commands/install.ts
|
|
496
685
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
497
|
-
import { existsSync as
|
|
498
|
-
import { resolve as
|
|
686
|
+
import { existsSync as existsSync6 } from "fs";
|
|
687
|
+
import { resolve as resolve7 } from "path";
|
|
499
688
|
|
|
500
689
|
// src/plugin-source-registry.ts
|
|
501
|
-
import { existsSync as
|
|
502
|
-
import { dirname, join, resolve as
|
|
690
|
+
import { existsSync as existsSync5, readdirSync, readFileSync as readFileSync4, statSync } from "fs";
|
|
691
|
+
import { dirname as dirname2, join, resolve as resolve6 } from "path";
|
|
503
692
|
var PLUGIN_SOURCES = {
|
|
504
693
|
cloudflare: {
|
|
505
694
|
name: "cloudflare",
|
|
@@ -517,17 +706,17 @@ var PACKAGE_ENTRY_DIRS = ["api-emulator", "trading-emulator"];
|
|
|
517
706
|
function candidateCatalogRoots(cwd = process.cwd()) {
|
|
518
707
|
const envRoots = process.env.API_EMULATOR_PLUGIN_CATALOGS?.split(",").map((value) => value.trim()).filter(Boolean) ?? [];
|
|
519
708
|
const roots = [...envRoots];
|
|
520
|
-
let current =
|
|
709
|
+
let current = resolve6(cwd);
|
|
521
710
|
for (; ; ) {
|
|
522
711
|
for (const dir of DEFAULT_CATALOG_DIRS) {
|
|
523
712
|
roots.push(join(current, dir));
|
|
524
|
-
roots.push(join(
|
|
713
|
+
roots.push(join(dirname2(current), dir));
|
|
525
714
|
}
|
|
526
|
-
const parent =
|
|
715
|
+
const parent = dirname2(current);
|
|
527
716
|
if (parent === current) break;
|
|
528
717
|
current = parent;
|
|
529
718
|
}
|
|
530
|
-
return [...new Set(roots.map((root) =>
|
|
719
|
+
return [...new Set(roots.map((root) => resolve6(root)))];
|
|
531
720
|
}
|
|
532
721
|
function sourceIdForRoot(root) {
|
|
533
722
|
const base = root.split(/[\\/]/).pop() ?? "local";
|
|
@@ -536,29 +725,29 @@ function sourceIdForRoot(root) {
|
|
|
536
725
|
return base;
|
|
537
726
|
}
|
|
538
727
|
function packageEntrySpecifier(packageRoot, pkg3) {
|
|
539
|
-
if (typeof pkg3.exports === "string") return
|
|
728
|
+
if (typeof pkg3.exports === "string") return resolve6(packageRoot, pkg3.exports);
|
|
540
729
|
if (typeof pkg3.exports === "object" && pkg3.exports !== null && "." in pkg3.exports && typeof pkg3.exports["."] === "string") {
|
|
541
|
-
return
|
|
730
|
+
return resolve6(packageRoot, pkg3.exports["."]);
|
|
542
731
|
}
|
|
543
732
|
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
|
|
733
|
+
return resolve6(packageRoot, pkg3.exports["."].import);
|
|
545
734
|
}
|
|
546
|
-
if (typeof pkg3.main === "string") return
|
|
735
|
+
if (typeof pkg3.main === "string") return resolve6(packageRoot, pkg3.main);
|
|
547
736
|
return packageRoot;
|
|
548
737
|
}
|
|
549
738
|
function discoverManifest(root, sourceId) {
|
|
550
739
|
const sources = [];
|
|
551
740
|
for (const manifestFile of CATALOG_MANIFEST_FILES) {
|
|
552
741
|
const manifestPath = join(root, manifestFile);
|
|
553
|
-
if (!
|
|
554
|
-
const manifest = JSON.parse(
|
|
742
|
+
if (!existsSync5(manifestPath)) continue;
|
|
743
|
+
const manifest = JSON.parse(readFileSync4(manifestPath, "utf-8"));
|
|
555
744
|
for (const [name, entry] of Object.entries(manifest.plugins ?? {})) {
|
|
556
745
|
sources.push({
|
|
557
746
|
name,
|
|
558
747
|
sourceId,
|
|
559
748
|
kind: entry.kind ?? (entry.packageName ? "package" : "file"),
|
|
560
749
|
packageName: entry.packageName,
|
|
561
|
-
specifier: entry.specifier.startsWith(".") ?
|
|
750
|
+
specifier: entry.specifier.startsWith(".") ? resolve6(root, entry.specifier) : entry.specifier,
|
|
562
751
|
description: entry.description ?? `${name} plugin from ${sourceId} catalog`
|
|
563
752
|
});
|
|
564
753
|
}
|
|
@@ -566,31 +755,18 @@ function discoverManifest(root, sourceId) {
|
|
|
566
755
|
return sources;
|
|
567
756
|
}
|
|
568
757
|
function discoverCatalog(root) {
|
|
569
|
-
if (!
|
|
758
|
+
if (!existsSync5(root) || !statSync(root).isDirectory()) return [];
|
|
570
759
|
const sourceId = sourceIdForRoot(root);
|
|
571
760
|
const sources = discoverManifest(root, sourceId);
|
|
572
761
|
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
573
762
|
if (!entry.isDirectory() || !entry.name.startsWith("@")) continue;
|
|
574
763
|
const name = entry.name.slice(1);
|
|
575
764
|
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
765
|
for (const entryDir of PACKAGE_ENTRY_DIRS) {
|
|
590
766
|
const packageRoot = join(pluginRoot, entryDir);
|
|
591
767
|
const packageJsonPath = join(packageRoot, "package.json");
|
|
592
|
-
if (!
|
|
593
|
-
const pkg3 = JSON.parse(
|
|
768
|
+
if (!existsSync5(packageJsonPath)) continue;
|
|
769
|
+
const pkg3 = JSON.parse(readFileSync4(packageJsonPath, "utf-8"));
|
|
594
770
|
sources.push({
|
|
595
771
|
name,
|
|
596
772
|
sourceId,
|
|
@@ -602,6 +778,19 @@ function discoverCatalog(root) {
|
|
|
602
778
|
});
|
|
603
779
|
break;
|
|
604
780
|
}
|
|
781
|
+
for (const entryFile of PLUGIN_ENTRY_FILES) {
|
|
782
|
+
const specifier = join(pluginRoot, entryFile);
|
|
783
|
+
if (existsSync5(specifier)) {
|
|
784
|
+
sources.push({
|
|
785
|
+
name,
|
|
786
|
+
sourceId,
|
|
787
|
+
kind: "file",
|
|
788
|
+
specifier,
|
|
789
|
+
description: `${name} plugin from ${sourceId} catalog`
|
|
790
|
+
});
|
|
791
|
+
break;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
605
794
|
}
|
|
606
795
|
return sources;
|
|
607
796
|
}
|
|
@@ -620,9 +809,9 @@ function resolvePluginSource(name) {
|
|
|
620
809
|
|
|
621
810
|
// src/commands/install.ts
|
|
622
811
|
function detectPackageManager() {
|
|
623
|
-
if (
|
|
624
|
-
if (
|
|
625
|
-
if (
|
|
812
|
+
if (existsSync6(resolve7("bun.lock")) || existsSync6(resolve7("bun.lockb"))) return "bun";
|
|
813
|
+
if (existsSync6(resolve7("pnpm-lock.yaml"))) return "pnpm";
|
|
814
|
+
if (existsSync6(resolve7("yarn.lock"))) return "yarn";
|
|
626
815
|
return "npm";
|
|
627
816
|
}
|
|
628
817
|
function installPackage(packageManager, packageName) {
|
|
@@ -660,12 +849,188 @@ async function installCommand(name, options = {}) {
|
|
|
660
849
|
console.log(`Recorded plugin in ${PLUGIN_LOCK_FILE}`);
|
|
661
850
|
}
|
|
662
851
|
|
|
852
|
+
// src/commands/validate-plugin.ts
|
|
853
|
+
import { existsSync as existsSync7, mkdtempSync, rmSync } from "fs";
|
|
854
|
+
import { tmpdir } from "os";
|
|
855
|
+
import { basename, extname, isAbsolute as isAbsolute2, join as join2, resolve as resolve8 } from "path";
|
|
856
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
857
|
+
function resolveSource(input) {
|
|
858
|
+
const source = resolvePluginSource(input);
|
|
859
|
+
if (source) return source;
|
|
860
|
+
const specifier = input.startsWith(".") || isAbsolute2(input) ? resolve8(input) : input;
|
|
861
|
+
return {
|
|
862
|
+
name: basename(input).replace(/\.[^.]+$/, ""),
|
|
863
|
+
sourceId: "specifier",
|
|
864
|
+
kind: "file",
|
|
865
|
+
specifier,
|
|
866
|
+
description: `${input} plugin specifier`
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
function assertLocalFile(path) {
|
|
870
|
+
if ((path.startsWith(".") || isAbsolute2(path)) && !existsSync7(resolve8(path))) {
|
|
871
|
+
throw new Error(`Plugin entry does not exist: ${path}`);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
function canBuild(specifier) {
|
|
875
|
+
return [".ts", ".tsx", ".js", ".mjs"].includes(extname(specifier));
|
|
876
|
+
}
|
|
877
|
+
function buildEntry(specifier) {
|
|
878
|
+
if (!canBuild(specifier)) return;
|
|
879
|
+
const outdir = mkdtempSync(join2(tmpdir(), "api-emulator-plugin-validate-"));
|
|
880
|
+
try {
|
|
881
|
+
const result = spawnSync3("bun", ["build", specifier, "--packages", "external", "--outdir", outdir], {
|
|
882
|
+
stdio: "pipe",
|
|
883
|
+
encoding: "utf-8"
|
|
884
|
+
});
|
|
885
|
+
if (result.error) {
|
|
886
|
+
if (result.error.code === "ENOENT") {
|
|
887
|
+
console.log(" build: skipped (bun not found)");
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
throw result.error;
|
|
891
|
+
}
|
|
892
|
+
if (result.status !== 0) {
|
|
893
|
+
throw new Error((result.stderr || result.stdout || "Plugin build failed").trim());
|
|
894
|
+
}
|
|
895
|
+
console.log(" build: ok");
|
|
896
|
+
} finally {
|
|
897
|
+
rmSync(outdir, { recursive: true, force: true });
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
async function loadPlugin(specifier, expectedName) {
|
|
901
|
+
const module = await loadExternalPluginModule(specifier);
|
|
902
|
+
if (module.name !== expectedName) {
|
|
903
|
+
throw new Error(`Plugin exported name "${module.name}" does not match requested plugin "${expectedName}"`);
|
|
904
|
+
}
|
|
905
|
+
await module.load();
|
|
906
|
+
console.log(" load: ok");
|
|
907
|
+
}
|
|
908
|
+
async function validatePluginCommand(input, options = {}) {
|
|
909
|
+
const source = resolveSource(input);
|
|
910
|
+
console.log(`Validating ${source.name} from ${source.sourceId}`);
|
|
911
|
+
console.log(` specifier: ${source.specifier}`);
|
|
912
|
+
if (source.packageName) console.log(` package: ${source.packageName}`);
|
|
913
|
+
assertLocalFile(source.specifier);
|
|
914
|
+
console.log(" entry: ok");
|
|
915
|
+
if (!options.skipBuild) {
|
|
916
|
+
buildEntry(source.specifier);
|
|
917
|
+
}
|
|
918
|
+
if (options.skipLoad) {
|
|
919
|
+
console.log(" load: skipped");
|
|
920
|
+
} else {
|
|
921
|
+
await loadPlugin(source.specifier, source.name);
|
|
922
|
+
}
|
|
923
|
+
console.log(`Plugin ${source.name} is valid`);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// src/commands/plugin.ts
|
|
927
|
+
import { existsSync as existsSync8, readFileSync as readFileSync5 } from "fs";
|
|
928
|
+
import { join as join3 } from "path";
|
|
929
|
+
function normalizePluginName(input) {
|
|
930
|
+
const name = input.trim().replace(/^@/, "");
|
|
931
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
|
|
932
|
+
throw new Error("Plugin names must use lowercase letters, numbers, and hyphens");
|
|
933
|
+
}
|
|
934
|
+
return name;
|
|
935
|
+
}
|
|
936
|
+
function pluginEntry(name, fidelity) {
|
|
937
|
+
return `export const manifest = {
|
|
938
|
+
name: "${name}",
|
|
939
|
+
label: "${name} API emulator",
|
|
940
|
+
endpoints: "GET /health",
|
|
941
|
+
fidelity: {
|
|
942
|
+
level: "${fidelity}",
|
|
943
|
+
endpoints: ["GET /health"],
|
|
944
|
+
seedableResources: [],
|
|
945
|
+
smoke: "not-started",
|
|
946
|
+
},
|
|
947
|
+
initConfig: {
|
|
948
|
+
${JSON.stringify(name)}: {},
|
|
949
|
+
},
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
export const plugin = {
|
|
953
|
+
name: "${name}",
|
|
954
|
+
register(app) {
|
|
955
|
+
app.get("/health", (c) => c.json({ ok: true, service: "${name}" }));
|
|
956
|
+
},
|
|
957
|
+
};
|
|
958
|
+
`;
|
|
959
|
+
}
|
|
960
|
+
function catalogContent(name, specifier, description) {
|
|
961
|
+
const catalogPath = "api-emulator.catalog.json";
|
|
962
|
+
const catalog = existsSync8(catalogPath) ? JSON.parse(readFileSync5(catalogPath, "utf-8")) : {};
|
|
963
|
+
catalog.plugins ??= {};
|
|
964
|
+
catalog.plugins[name] = {
|
|
965
|
+
kind: "file",
|
|
966
|
+
specifier,
|
|
967
|
+
description
|
|
968
|
+
};
|
|
969
|
+
return `${JSON.stringify(catalog, null, 2)}
|
|
970
|
+
`;
|
|
971
|
+
}
|
|
972
|
+
async function pluginCreateCommand(input, options = {}) {
|
|
973
|
+
const name = normalizePluginName(input);
|
|
974
|
+
const pluginDir = options.dir ?? `@${name}`;
|
|
975
|
+
const entryPath = join3(pluginDir, "api-emulator.mjs");
|
|
976
|
+
const catalogSpecifier = `./${entryPath}`;
|
|
977
|
+
writeGeneratedFile(entryPath, pluginEntry(name, options.fidelity ?? "stub"), {
|
|
978
|
+
source: "api clone create",
|
|
979
|
+
yes: options.yes
|
|
980
|
+
});
|
|
981
|
+
writeGeneratedFile(
|
|
982
|
+
"api-emulator.catalog.json",
|
|
983
|
+
catalogContent(name, catalogSpecifier, `${name} API emulator plugin`),
|
|
984
|
+
{
|
|
985
|
+
source: "api clone create",
|
|
986
|
+
yes: options.yes
|
|
987
|
+
}
|
|
988
|
+
);
|
|
989
|
+
console.log(`Created ${entryPath}`);
|
|
990
|
+
console.log(`Recorded ${name} in api-emulator.catalog.json`);
|
|
991
|
+
console.log(`Run 'npx -p api-emulator api validate-plugin ${name}' to check the clone.`);
|
|
992
|
+
}
|
|
993
|
+
|
|
663
994
|
// src/index.ts
|
|
664
|
-
var pkg2 = { version: "0.
|
|
995
|
+
var pkg2 = { version: "0.7.0" };
|
|
665
996
|
var defaultPort = process.env.API_EMULATOR_PORT ?? process.env.PORT ?? "4000";
|
|
666
997
|
var program = new Command();
|
|
667
|
-
program.name("api").description("Local API emulators you can run, share, and extend with plugins").version(pkg2.version);
|
|
668
|
-
|
|
998
|
+
program.name("api").description("Local API emulators you can run, share, and extend with plugins").version(pkg2.version).option("--notify", "Show a macOS notification for useful command milestones");
|
|
999
|
+
function notifyOption(command) {
|
|
1000
|
+
return command.option("--notify", "Show a macOS notification for useful command milestones");
|
|
1001
|
+
}
|
|
1002
|
+
function wantsNotify(command, opts) {
|
|
1003
|
+
return Boolean(opts.notify ?? command.optsWithGlobals().notify);
|
|
1004
|
+
}
|
|
1005
|
+
function wantsStartNotify(opts) {
|
|
1006
|
+
return opts.notify !== false;
|
|
1007
|
+
}
|
|
1008
|
+
function formatDuration(ms) {
|
|
1009
|
+
if (ms < 1e3) return `${ms}ms`;
|
|
1010
|
+
return `${(ms / 1e3).toFixed(1)}s`;
|
|
1011
|
+
}
|
|
1012
|
+
async function runCommandWithNotification(label, enabled, fn) {
|
|
1013
|
+
const startedAt = Date.now();
|
|
1014
|
+
try {
|
|
1015
|
+
await fn();
|
|
1016
|
+
notifyIfRequested({
|
|
1017
|
+
enabled,
|
|
1018
|
+
title: "api-emulator",
|
|
1019
|
+
message: `${label} finished in ${formatDuration(Date.now() - startedAt)}`
|
|
1020
|
+
});
|
|
1021
|
+
} catch (err) {
|
|
1022
|
+
notifyIfRequested({
|
|
1023
|
+
enabled,
|
|
1024
|
+
title: "api-emulator",
|
|
1025
|
+
message: `${label} failed after ${formatDuration(Date.now() - startedAt)}`
|
|
1026
|
+
});
|
|
1027
|
+
console.error(err instanceof Error ? err.message : err);
|
|
1028
|
+
process.exit(1);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
notifyOption(
|
|
1032
|
+
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").option("--no-notify", "Disable the macOS notification when the emulator server is ready")
|
|
1033
|
+
).action(async (opts) => {
|
|
669
1034
|
const port = parseInt(opts.port, 10);
|
|
670
1035
|
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
|
671
1036
|
console.error(`Invalid port: ${opts.port}`);
|
|
@@ -677,24 +1042,62 @@ program.command("start", { isDefault: true }).description("Start the emulator se
|
|
|
677
1042
|
seed: opts.seed,
|
|
678
1043
|
baseUrl: opts.baseUrl,
|
|
679
1044
|
portless: opts.portless,
|
|
680
|
-
plugin: opts.plugin
|
|
1045
|
+
plugin: opts.plugin,
|
|
1046
|
+
notify: wantsStartNotify(opts)
|
|
681
1047
|
});
|
|
682
1048
|
});
|
|
683
|
-
|
|
684
|
-
|
|
1049
|
+
notifyOption(
|
|
1050
|
+
program.command("init").description("Generate a starter config file").option("-s, --service <service>", "Service to generate config for", "all").option("--plugin <plugins>", "Comma-separated external plugin paths or package names").option("--agents <targets>", "Install agent skills for agents,user-agents,claude,codex,cursor,windsurf").option("--skills-only", "Only install agent skills").option("--yes", "Overwrite generated files that changed").option("--non-interactive", "Use defaults suitable for agents and CI")
|
|
1051
|
+
).action(async (opts, command) => {
|
|
1052
|
+
await runCommandWithNotification("init", wantsNotify(command, opts), async () => {
|
|
1053
|
+
await initCommand({
|
|
1054
|
+
service: opts.service,
|
|
1055
|
+
plugin: opts.plugin,
|
|
1056
|
+
agents: opts.agents,
|
|
1057
|
+
skillsOnly: opts.skillsOnly,
|
|
1058
|
+
yes: opts.yes,
|
|
1059
|
+
nonInteractive: opts.nonInteractive
|
|
1060
|
+
});
|
|
1061
|
+
});
|
|
685
1062
|
});
|
|
1063
|
+
function addPluginCreateCommand(command) {
|
|
1064
|
+
notifyOption(
|
|
1065
|
+
command.command("create <name>").description("Scaffold a provider clone").option("--dir <dir>", "Directory to create the clone in").option("--fidelity <level>", "Initial fidelity level", "stub").option("--yes", "Overwrite generated files that changed")
|
|
1066
|
+
).action(async (name, opts, actionCommand) => {
|
|
1067
|
+
await runCommandWithNotification("plugin create", wantsNotify(actionCommand, opts), async () => {
|
|
1068
|
+
await pluginCreateCommand(name, {
|
|
1069
|
+
dir: opts.dir,
|
|
1070
|
+
fidelity: opts.fidelity,
|
|
1071
|
+
yes: opts.yes
|
|
1072
|
+
});
|
|
1073
|
+
});
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
var plugin = program.command("plugin").description("Create and manage provider plugins");
|
|
1077
|
+
addPluginCreateCommand(plugin);
|
|
1078
|
+
var clone = program.command("clone").description("Create and manage provider clones");
|
|
1079
|
+
addPluginCreateCommand(clone);
|
|
686
1080
|
program.command("list").alias("list-services").description("List available services").option("--plugin <plugins>", "Comma-separated external plugin paths or package names").action(async (opts) => {
|
|
687
1081
|
await listCommand({ plugin: opts.plugin });
|
|
688
1082
|
});
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
1083
|
+
notifyOption(
|
|
1084
|
+
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")
|
|
1085
|
+
).action(async (plugin2, opts, command) => {
|
|
1086
|
+
await runCommandWithNotification("install", wantsNotify(command, opts), async () => {
|
|
1087
|
+
await installCommand(plugin2, {
|
|
692
1088
|
packageManager: opts.packageManager === false ? false : opts.packageManager
|
|
693
1089
|
});
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
1090
|
+
});
|
|
1091
|
+
});
|
|
1092
|
+
notifyOption(
|
|
1093
|
+
program.command("validate-plugin <plugin>").description("Validate a provider plugin by name, path, or package").option("--skip-build", "Skip entrypoint build validation").option("--skip-load", "Skip runtime module loading validation")
|
|
1094
|
+
).action(async (plugin2, opts, command) => {
|
|
1095
|
+
await runCommandWithNotification("validate-plugin", wantsNotify(command, opts), async () => {
|
|
1096
|
+
await validatePluginCommand(plugin2, {
|
|
1097
|
+
skipBuild: opts.skipBuild,
|
|
1098
|
+
skipLoad: opts.skipLoad
|
|
1099
|
+
});
|
|
1100
|
+
});
|
|
698
1101
|
});
|
|
699
1102
|
program.parse();
|
|
700
1103
|
//# sourceMappingURL=index.js.map
|