diff --git a/bun.lock b/bun.lock index 6c9155f..92c81f7 100644 --- a/bun.lock +++ b/bun.lock @@ -5,20 +5,37 @@ "": { "name": "emulate-monorepo", "devDependencies": { + "@emulators/apple": "0.9.0", + "@emulators/aws": "0.9.0", + "@emulators/clerk": "0.9.0", + "@emulators/core": "0.9.0", + "@emulators/github": "0.9.0", + "@emulators/google": "0.9.0", + "@emulators/microsoft": "0.9.0", + "@emulators/mongoatlas": "0.9.0", + "@emulators/okta": "0.9.0", + "@emulators/resend": "0.9.0", + "@emulators/slack": "0.9.0", + "@emulators/stripe": "0.9.0", + "@emulators/vercel": "0.9.0", + "@pleaseai/emulate": "workspace:*", "@pleaseai/eslint-config": "^0.0.4", "@types/bun": "^1.3.5", "@typescript/native-preview": "7.0.0-dev.20260604.1", + "emulate": "0.9.0", "eslint": "^10.4.1", "jiti": "^2.7.0", + "picocolors": "^1.1.1", "turbo": "^2", "typescript": "^6", + "yaml": "^2", }, }, "packages/asana": { "name": "@pleaseai/emulate-asana", "version": "0.1.0", "dependencies": { - "@emulators/core": "^0.6.0", + "@emulators/core": "^0.9.0", }, "devDependencies": { "tsup": "^8", @@ -29,7 +46,7 @@ "name": "@pleaseai/emulate-autumn", "version": "0.1.0", "dependencies": { - "@emulators/core": "^0.6.0", + "@emulators/core": "^0.9.0", }, "devDependencies": { "autumn-js": "1.2.28", @@ -44,7 +61,7 @@ "emulate": "./dist/index.js", }, "dependencies": { - "@emulators/core": "^0.6.0", + "@emulators/core": "^0.9.0", "@pleaseai/emulate-asana": "workspace:*", "@pleaseai/emulate-autumn": "workspace:*", "@pleaseai/emulate-firebase": "workspace:*", @@ -71,7 +88,7 @@ "name": "@pleaseai/emulate-firebase", "version": "0.1.0", "dependencies": { - "@emulators/core": "^0.6.0", + "@emulators/core": "^0.9.0", }, "devDependencies": { "tsup": "^8", @@ -82,7 +99,7 @@ "name": "@pleaseai/emulate-gitlab", "version": "0.1.0", "dependencies": { - "@emulators/core": "^0.6.0", + "@emulators/core": "^0.9.0", "graphql": "^16.13.2", }, "devDependencies": { @@ -94,7 +111,7 @@ "name": "@pleaseai/emulate-kakao", "version": "0.1.0", "dependencies": { - "@emulators/core": "^0.6.0", + "@emulators/core": "^0.9.0", }, "devDependencies": { "tsup": "^8", @@ -105,7 +122,7 @@ "name": "@pleaseai/emulate-linear", "version": "0.1.0", "dependencies": { - "@emulators/core": "^0.6.0", + "@emulators/core": "^0.9.0", "graphql": "^16.13.2", }, "devDependencies": { @@ -117,7 +134,7 @@ "name": "@pleaseai/emulate-naver", "version": "0.1.0", "dependencies": { - "@emulators/core": "^0.6.0", + "@emulators/core": "^0.9.0", }, "devDependencies": { "tsup": "^8", @@ -128,7 +145,7 @@ "name": "@pleaseai/emulate-posthog", "version": "0.1.0", "dependencies": { - "@emulators/core": "^0.6.0", + "@emulators/core": "^0.9.0", }, "devDependencies": { "tsup": "^8", @@ -139,7 +156,7 @@ "name": "@pleaseai/emulate-spotify", "version": "0.1.0", "dependencies": { - "@emulators/core": "^0.6.0", + "@emulators/core": "^0.9.0", }, "devDependencies": { "tsup": "^8", @@ -150,7 +167,7 @@ "name": "@pleaseai/emulate-supabase", "version": "0.1.0", "dependencies": { - "@emulators/core": "^0.6.0", + "@emulators/core": "^0.9.0", }, "devDependencies": { "tsup": "^8", @@ -161,7 +178,7 @@ "name": "@pleaseai/emulate-toss-payments", "version": "0.1.0", "dependencies": { - "@emulators/core": "^0.6.0", + "@emulators/core": "^0.9.0", }, "devDependencies": { "tsup": "^8", @@ -172,7 +189,7 @@ "name": "@pleaseai/emulate-workos", "version": "0.1.0", "dependencies": { - "@emulators/core": "^0.6.0", + "@emulators/core": "^0.9.0", "jose": "^6", }, "devDependencies": { @@ -185,7 +202,7 @@ "name": "@pleaseai/emulate-x", "version": "0.1.0", "dependencies": { - "@emulators/core": "^0.6.0", + "@emulators/core": "^0.9.0", }, "devDependencies": { "tsup": "^8", @@ -214,7 +231,31 @@ "@e18e/eslint-plugin": ["@e18e/eslint-plugin@0.4.1", "", { "dependencies": { "empathic": "^2.0.0", "module-replacements": "^3.0.0-beta.7", "semver": "^7.7.4" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0", "oxlint": "^1.61.0" }, "optionalPeers": ["eslint", "oxlint"] }, "sha512-Re00N8ad1HsNrzpuIX7Bhdr8RSaFWp6VgwJUEJF+47+D1CMcXoS7VNRkIG23e46pddhgxWU0cWk4wYiQIuMHqQ=="], - "@emulators/core": ["@emulators/core@0.6.0", "", { "dependencies": { "jose": "^6.2.2" } }, "sha512-HFGyF4yA3NoUCYq/5lg7YNXx5Cfaz8oMUz5gfJ/8X+tFdZbi2oKAmQoAzfXV9kiAUWvXlkiahZ1EZDpxeC36gA=="], + "@emulators/apple": ["@emulators/apple@0.9.0", "", { "dependencies": { "@emulators/core": "0.9.0", "jose": "^6" } }, "sha512-R5yHGEf2KspFnieYMAH/W1s3ElgNNoCuS07FDqbToLV5O5hab3WKUrwOgIrpqkjSdeZCU+DScfhbrcBbbAznHg=="], + + "@emulators/aws": ["@emulators/aws@0.9.0", "", { "dependencies": { "@emulators/core": "0.9.0" } }, "sha512-Idr0Fe0/uuly1z/cpFPePcat2Cbk0nwVfBv6yjy2RJsxF93CRJrvioeuztiZ/EDrrOOa0i1jaPdstIgHMkXx6Q=="], + + "@emulators/clerk": ["@emulators/clerk@0.9.0", "", { "dependencies": { "@emulators/core": "0.9.0", "jose": "^6" } }, "sha512-c6EPHVgduwNiUtZwNmLU72NxS34ldWjGu99wjDu3Yc3+YTT2PNAZlKw3XFEYTpwmsqgUAtRZMMIWdU33hdlO2A=="], + + "@emulators/core": ["@emulators/core@0.9.0", "", { "dependencies": { "jose": "^6.2.2" } }, "sha512-Li/QxGzxzS0MRbRvfSximcH7ABaoX0k13eGRRdrCRhHHmuS2KVlotiatfGL7hFX+6DJMyhYG0uQV6zTEBbT/0A=="], + + "@emulators/github": ["@emulators/github@0.9.0", "", { "dependencies": { "@emulators/core": "0.9.0" } }, "sha512-3WhwXNQylweeu9KL52VvruP6GwjX81S2St5koc+Lu7IWO0wS8jJw+WrGp6usZvOfd0YX+NQdQPUago8XqlxBEA=="], + + "@emulators/google": ["@emulators/google@0.9.0", "", { "dependencies": { "@emulators/core": "0.9.0", "jose": "^6" } }, "sha512-QP5ZOvfOWheS4rJd1rD0jRObKQsHef8cE3JKglWEUpw2tjaUKn+SXh+RjMIArq+o5aAnBYjhIT7PYiHUedCMng=="], + + "@emulators/microsoft": ["@emulators/microsoft@0.9.0", "", { "dependencies": { "@emulators/core": "0.9.0", "jose": "^6" } }, "sha512-T2/wpS3t4mGVnzHkILHYNZKK3jMzw72pF3pYm/sa1bFfSAEOLvuulHcBtuL8kaZfsnHTNrEpmcGW0J5VXtZaXg=="], + + "@emulators/mongoatlas": ["@emulators/mongoatlas@0.9.0", "", { "dependencies": { "@emulators/core": "0.9.0" } }, "sha512-bLZbWQtryif04AUclnB65RPHNKJaS2M2JpcLZEFETXXNiqxsGXiHRdZ0R+j4yf7mNEqdpb/BjOsqATmB+Ginxg=="], + + "@emulators/okta": ["@emulators/okta@0.9.0", "", { "dependencies": { "@emulators/core": "0.9.0", "jose": "^6" } }, "sha512-PJrbbUB53RNtYjHbOyTkGXOEblTica7ZimZbV9F6CwsL5KOoajY+d8TqYotH+Vrl4ndnorpLoDKNGYB42OPF6g=="], + + "@emulators/resend": ["@emulators/resend@0.9.0", "", { "dependencies": { "@emulators/core": "0.9.0" } }, "sha512-rXRyz1AZrND7pDELbcdBxOAxF722+GrCtsyZyc5w4OP/d2NCDnTiaSL1fdJOIaik1PjAGbFPme3y9W08o0Quuw=="], + + "@emulators/slack": ["@emulators/slack@0.9.0", "", { "dependencies": { "@emulators/core": "0.9.0" } }, "sha512-p8l1JS41UnpzPeO1Puxxx4xrVRAqAgc/7ao0P8+zLOv5kzPrQWfBFXz9jFmVnT9HKkPD4nvjT/JYxeZFcNtJSw=="], + + "@emulators/stripe": ["@emulators/stripe@0.9.0", "", { "dependencies": { "@emulators/core": "0.9.0" } }, "sha512-HJ2KcrWr+3c08otJqC/xdlO8DZZcBcW6jMEm+xkaQNIQBDlZf4JCH3tbemZ4FJPszUEEge9XArta4nQJa/FPtQ=="], + + "@emulators/vercel": ["@emulators/vercel@0.9.0", "", { "dependencies": { "@emulators/core": "0.9.0" } }, "sha512-7/0SMLpB2KVFj2YvaNVh1suh1gCG3miv6k5MHfeEfRtZoyGPInrbHp3ft/J1exKpMdE72mxDV5uglVust9aP/A=="], "@es-joy/jsdoccomment": ["@es-joy/jsdoccomment@0.84.0", "", { "dependencies": { "@types/estree": "^1.0.8", "@typescript-eslint/types": "^8.54.0", "comment-parser": "1.4.5", "esquery": "^1.7.0", "jsdoc-type-pratt-parser": "~7.1.1" } }, "sha512-0xew1CxOam0gV5OMjh2KjFQZsKL2bByX1+q4j3E73MpYIdyUxcZb/xQct9ccUb+ve5KGUYbCUxyPnYB7RbuP+w=="], @@ -566,6 +607,8 @@ "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="], + "emulate": ["emulate@0.9.0", "", { "dependencies": { "commander": "^14", "picocolors": "^1.1.1", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-wgOFQSrjAt0NqPDnYUFyCcCwAVw01aNS3M24SZ2C5ViLQW8ECi0qIyUsW+y2uvflBD48JFqomB+jRox+ktKK/w=="], + "enhanced-resolve": ["enhanced-resolve@5.22.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag=="], "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], @@ -1066,6 +1109,8 @@ "clean-regexp/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + "emulate/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "eslint-flat-config-utils/@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="], "eslint-plugin-jsdoc/@es-joy/jsdoccomment": ["@es-joy/jsdoccomment@0.86.0", "", { "dependencies": { "@types/estree": "^1.0.8", "@typescript-eslint/types": "^8.58.0", "comment-parser": "1.4.6", "esquery": "^1.7.0", "jsdoc-type-pratt-parser": "~7.2.0" } }, "sha512-ukZmRQ81WiTpDWO6D/cTBM7XbrNtutHKvAVnZN/8pldAwLoJArGOvkNyxPTBGsPjsoaQBJxlH+tE2TNA/92Qgw=="], diff --git a/package.json b/package.json index 70e7a7c..5a2b85e 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "packageManager": "bun@1.3.14", "scripts": { "build": "turbo run build", + "emulate:all": "bun scripts/emulate-all.ts", "test": "turbo run test", "test:coverage": "bun test --coverage --coverage-reporter=lcov --coverage-reporter=text", "type-check": "turbo run type-check", @@ -16,12 +17,29 @@ "clean": "turbo run clean" }, "devDependencies": { + "@emulators/apple": "0.9.0", + "@emulators/aws": "0.9.0", + "@emulators/clerk": "0.9.0", + "@emulators/core": "0.9.0", + "@emulators/github": "0.9.0", + "@emulators/google": "0.9.0", + "@emulators/microsoft": "0.9.0", + "@emulators/mongoatlas": "0.9.0", + "@emulators/okta": "0.9.0", + "@emulators/resend": "0.9.0", + "@emulators/slack": "0.9.0", + "@emulators/stripe": "0.9.0", + "@emulators/vercel": "0.9.0", + "@pleaseai/emulate": "workspace:*", "@pleaseai/eslint-config": "^0.0.4", "@types/bun": "^1.3.5", "@typescript/native-preview": "7.0.0-dev.20260604.1", + "emulate": "0.9.0", "eslint": "^10.4.1", "jiti": "^2.7.0", + "picocolors": "^1.1.1", "turbo": "^2", - "typescript": "^6" + "typescript": "^6", + "yaml": "^2" } } diff --git a/packages/asana/package.json b/packages/asana/package.json index baaaf99..3bab2ba 100644 --- a/packages/asana/package.json +++ b/packages/asana/package.json @@ -31,7 +31,7 @@ "type-check": "tsgo --noEmit" }, "dependencies": { - "@emulators/core": "^0.6.0" + "@emulators/core": "^0.9.0" }, "devDependencies": { "tsup": "^8", diff --git a/packages/autumn/package.json b/packages/autumn/package.json index c21fe8a..d5fe403 100644 --- a/packages/autumn/package.json +++ b/packages/autumn/package.json @@ -31,7 +31,7 @@ "type-check": "tsgo --noEmit" }, "dependencies": { - "@emulators/core": "^0.6.0" + "@emulators/core": "^0.9.0" }, "devDependencies": { "autumn-js": "1.2.28", diff --git a/packages/emulate/package.json b/packages/emulate/package.json index b8e0034..31a95c7 100644 --- a/packages/emulate/package.json +++ b/packages/emulate/package.json @@ -63,7 +63,7 @@ "type-check": "tsgo --noEmit" }, "dependencies": { - "@emulators/core": "^0.6.0", + "@emulators/core": "^0.9.0", "@pleaseai/emulate-asana": "workspace:*", "@pleaseai/emulate-autumn": "workspace:*", "@pleaseai/emulate-firebase": "workspace:*", diff --git a/packages/emulate/src/api.ts b/packages/emulate/src/api.ts index dbf4759..a9c526d 100644 --- a/packages/emulate/src/api.ts +++ b/packages/emulate/src/api.ts @@ -5,6 +5,7 @@ import { resolveBaseUrl } from './base-url.js' import { SERVICE_REGISTRY } from './registry.js' export type { ServiceName } from './registry.js' +export { SERVICE_NAMES } from './registry.js' export interface SeedConfig { tokens?: Record diff --git a/packages/firebase/package.json b/packages/firebase/package.json index 556c7d8..8133aac 100644 --- a/packages/firebase/package.json +++ b/packages/firebase/package.json @@ -31,7 +31,7 @@ "type-check": "tsgo --noEmit" }, "dependencies": { - "@emulators/core": "^0.6.0" + "@emulators/core": "^0.9.0" }, "devDependencies": { "tsup": "^8", diff --git a/packages/gitlab/package.json b/packages/gitlab/package.json index 2935d06..6b6bd50 100644 --- a/packages/gitlab/package.json +++ b/packages/gitlab/package.json @@ -31,7 +31,7 @@ "type-check": "tsgo --noEmit" }, "dependencies": { - "@emulators/core": "^0.6.0", + "@emulators/core": "^0.9.0", "graphql": "^16.13.2" }, "devDependencies": { diff --git a/packages/kakao/package.json b/packages/kakao/package.json index e43e4df..1865ad2 100644 --- a/packages/kakao/package.json +++ b/packages/kakao/package.json @@ -31,7 +31,7 @@ "type-check": "tsgo --noEmit" }, "dependencies": { - "@emulators/core": "^0.6.0" + "@emulators/core": "^0.9.0" }, "devDependencies": { "tsup": "^8", diff --git a/packages/linear/package.json b/packages/linear/package.json index 18c2f91..6f53e13 100644 --- a/packages/linear/package.json +++ b/packages/linear/package.json @@ -31,7 +31,7 @@ "type-check": "tsgo --noEmit" }, "dependencies": { - "@emulators/core": "^0.6.0", + "@emulators/core": "^0.9.0", "graphql": "^16.13.2" }, "devDependencies": { diff --git a/packages/naver/package.json b/packages/naver/package.json index 2e05bbe..50fad79 100644 --- a/packages/naver/package.json +++ b/packages/naver/package.json @@ -31,7 +31,7 @@ "type-check": "tsgo --noEmit" }, "dependencies": { - "@emulators/core": "^0.6.0" + "@emulators/core": "^0.9.0" }, "devDependencies": { "tsup": "^8", diff --git a/packages/posthog/package.json b/packages/posthog/package.json index 800d9a3..bc144b1 100644 --- a/packages/posthog/package.json +++ b/packages/posthog/package.json @@ -31,7 +31,7 @@ "type-check": "tsgo --noEmit" }, "dependencies": { - "@emulators/core": "^0.6.0" + "@emulators/core": "^0.9.0" }, "devDependencies": { "tsup": "^8", diff --git a/packages/spotify/package.json b/packages/spotify/package.json index d8ad780..18ca446 100644 --- a/packages/spotify/package.json +++ b/packages/spotify/package.json @@ -31,7 +31,7 @@ "type-check": "tsgo --noEmit" }, "dependencies": { - "@emulators/core": "^0.6.0" + "@emulators/core": "^0.9.0" }, "devDependencies": { "tsup": "^8", diff --git a/packages/supabase/package.json b/packages/supabase/package.json index b49eab8..335fddd 100644 --- a/packages/supabase/package.json +++ b/packages/supabase/package.json @@ -31,7 +31,7 @@ "type-check": "tsgo --noEmit" }, "dependencies": { - "@emulators/core": "^0.6.0" + "@emulators/core": "^0.9.0" }, "devDependencies": { "tsup": "^8", diff --git a/packages/toss-payments/package.json b/packages/toss-payments/package.json index e7983d3..53db348 100644 --- a/packages/toss-payments/package.json +++ b/packages/toss-payments/package.json @@ -31,7 +31,7 @@ "type-check": "tsgo --noEmit" }, "dependencies": { - "@emulators/core": "^0.6.0" + "@emulators/core": "^0.9.0" }, "devDependencies": { "tsup": "^8", diff --git a/packages/workos/package.json b/packages/workos/package.json index 357127b..8accdcd 100644 --- a/packages/workos/package.json +++ b/packages/workos/package.json @@ -31,7 +31,7 @@ "type-check": "tsgo --noEmit" }, "dependencies": { - "@emulators/core": "^0.6.0", + "@emulators/core": "^0.9.0", "jose": "^6" }, "devDependencies": { diff --git a/packages/x/package.json b/packages/x/package.json index 13b253f..e8322f0 100644 --- a/packages/x/package.json +++ b/packages/x/package.json @@ -31,7 +31,7 @@ "type-check": "tsgo --noEmit" }, "dependencies": { - "@emulators/core": "^0.6.0" + "@emulators/core": "^0.9.0" }, "devDependencies": { "tsup": "^8", diff --git a/scripts/emulate-all.ts b/scripts/emulate-all.ts new file mode 100644 index 0000000..aaa7ab3 --- /dev/null +++ b/scripts/emulate-all.ts @@ -0,0 +1,355 @@ +#!/usr/bin/env bun +import type { ServicePlugin, Store, WebhookDispatcher } from '@emulators/core' +/** + * Single-process launcher that runs THIS repo's emulators and the upstream + * vercel-labs `@emulators/*` emulators together — one command, one process. + * + * Both sides now target the same `@emulators/core` (0.9.x), which is hoisted to + * a single physical module. That lets this one process host every service: + * - this repo's services go through `@pleaseai/emulate`'s `createEmulator` + * - vercel-labs services are mounted directly on the shared core via + * `createServer` + `serve`. + * + * Every service gets a STABLE, distinct port from its own registry index (never + * from its position in the selection), and the two projects live in separate + * ranges so selecting a subset never renumbers anything and the ranges can't + * overlap: + * - this repo: base 4000 (kakao 4000, naver 4001, … x 4012) + * - vercel-labs: base 4100 (vercel 4100, github 4101, … mongoatlas 4111) + * + * bun run emulate:all # every service, both ranges + * bun run emulate:all -- --service kakao,github,stripe + * bun run emulate:all -- --port 5000 # this-repo base (vercel → 5100) + * bun run emulate:all -- --port 5000 --vercel-port 6000 + * bun run emulate:all -- --config ./emulate.config.yaml + * + * With an emulate.config.{yaml,yml,json} present (auto-detected), only the + * services keyed in it start, and their `:` sections seed them. + */ +import type { Emulator, SeedConfig, ServiceName } from '@pleaseai/emulate' +import { existsSync, readFileSync } from 'node:fs' +import process from 'node:process' +import { createServer, serve } from '@emulators/core' +import { createEmulator, SERVICE_NAMES } from '@pleaseai/emulate' +import pc from 'picocolors' +import { parse as parseYaml } from 'yaml' + +/** vercel-labs services, each a standalone `@emulators/*` package on the shared core. */ +const VERCEL_CATALOG: Record = { + vercel: { pkg: '@emulators/vercel', label: 'Vercel REST API' }, + github: { pkg: '@emulators/github', label: 'GitHub REST API' }, + google: { pkg: '@emulators/google', label: 'Google OAuth/OIDC + Gmail/Calendar/Drive' }, + slack: { pkg: '@emulators/slack', label: 'Slack Web API' }, + apple: { pkg: '@emulators/apple', label: 'Apple Sign In / OAuth' }, + microsoft: { pkg: '@emulators/microsoft', label: 'Microsoft Entra ID OAuth/OIDC' }, + okta: { pkg: '@emulators/okta', label: 'Okta OAuth/OIDC + management API' }, + aws: { pkg: '@emulators/aws', label: 'AWS S3 / SQS / IAM / STS' }, + resend: { pkg: '@emulators/resend', label: 'Resend email API' }, + stripe: { pkg: '@emulators/stripe', label: 'Stripe payments' }, + clerk: { pkg: '@emulators/clerk', label: 'Clerk auth / user management' }, + mongoatlas: { pkg: '@emulators/mongoatlas', label: 'MongoDB Atlas Admin + Data API' }, +} + +// Canonical order = the stable per-service port index within each range. +const PLEASE_ORDER: readonly string[] = [...SERVICE_NAMES] +const VERCEL_ORDER: readonly string[] = Object.keys(VERCEL_CATALOG) +const PLEASE_SET = new Set(PLEASE_ORDER) +const VERCEL_SET = new Set(VERCEL_ORDER) + +interface Running { + name: string + origin: 'this-repo' | 'vercel-labs' + port: number + url: string + close: () => Promise +} + +interface Args { + port?: number + vercelPort?: number + service?: string + config?: string +} + +function parseArgs(argv: string[]): Args { + const args: Args = {} + // A present-but-non-numeric port flag is a user error, not something to + // silently drop back to the default — fail loudly. + const intFlag = (flag: string, raw: string | undefined): number => { + const value = raw === undefined ? Number.NaN : Number.parseInt(raw, 10) + if (Number.isNaN(value)) { + console.error(`${flag} requires a numeric value (got: ${raw ?? ''})`) + process.exit(1) + } + return value + } + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg === '--port') { + args.port = intFlag('--port', argv[++i]) + } + else if (arg === '--vercel-port') { + args.vercelPort = intFlag('--vercel-port', argv[++i]) + } + else if (arg === '--service') { + // A missing or empty --service value must not silently fall through to + // "start everything" — reject the malformed invocation. A value made only + // of separators (e.g. ",") is caught later in resolveServices once it + // resolves to an empty name list. + const value = argv[++i] + if (!value) { + console.error('--service requires a comma-separated list of service names') + process.exit(1) + } + args.service = value + } + else if (arg === '--config') { + args.config = argv[++i] + } + } + return args +} + +/** Base port fallback: --port > EMULATE_PORT > PORT > 4000. */ +function basePortFrom(args: Args): number { + if (args.port !== undefined && !Number.isNaN(args.port)) { + return args.port + } + for (const raw of [process.env.EMULATE_PORT, process.env.PORT]) { + const port = raw ? Number.parseInt(raw, 10) : Number.NaN + if (!Number.isNaN(port)) { + return port + } + } + return 4000 +} + +/** vercel-labs base port: --vercel-port > EMULATE_VERCEL_PORT > pleaseBase + 100. */ +function vercelBaseFrom(args: Args, pleaseBase: number): number { + if (args.vercelPort !== undefined && !Number.isNaN(args.vercelPort)) { + return args.vercelPort + } + const env = process.env.EMULATE_VERCEL_PORT ? Number.parseInt(process.env.EMULATE_VERCEL_PORT, 10) : Number.NaN + return Number.isNaN(env) ? pleaseBase + 100 : env +} + +/** Stable port for a service: its base plus its fixed index in the canonical list. */ +function portFor(name: string, pleaseBase: number, vercelBase: number): number { + return PLEASE_SET.has(name) + ? pleaseBase + PLEASE_ORDER.indexOf(name) + : vercelBase + VERCEL_ORDER.indexOf(name) +} + +function loadConfig(explicit?: string): { config: SeedConfig, source: string } | null { + const candidates = explicit + ? [explicit] + : ['emulate.config.yaml', 'emulate.config.yml', 'emulate.config.json'] + for (const file of candidates) { + if (!existsSync(file)) { + if (explicit) { + console.error(`Config file not found: ${file}`) + process.exit(1) + } + continue + } + const content = readFileSync(file, 'utf-8') + try { + const config = file.endsWith('.json') ? JSON.parse(content) : parseYaml(content) + return { config, source: file } + } + catch (err) { + console.error(`Failed to parse ${file}: ${err instanceof Error ? err.message : err}`) + process.exit(1) + } + } + return null +} + +function resolveServices(args: Args, config: SeedConfig | null): string[] { + if (args.service) { + // Dedupe: two identical names would map to the same port and crash on bind. + const names = [...new Set(args.service.split(',').map(s => s.trim()).filter(Boolean))] + if (names.length === 0) { + console.error('--service did not list any service names (check for stray commas)') + process.exit(1) + } + return names + } + if (config) { + const inferred = Object.keys(config).filter(k => PLEASE_SET.has(k) || VERCEL_SET.has(k)) + if (inferred.length > 0) { + return inferred + } + // Config present but no key matched a service — make the fallback visible. + console.warn(pc.yellow(' ⚠ No known service keys in config — starting all services.')) + } + return [...SERVICE_NAMES, ...Object.keys(VERCEL_CATALOG)] +} + +/** Mirrors @pleaseai/emulate's token handling so vercel services share the convention. */ +function buildTokens(seed?: SeedConfig): Record { + const tokens: Record = {} + if (seed?.tokens) { + let id = 100 + for (const [token, user] of Object.entries(seed.tokens)) { + tokens[token] = { login: user.login, id: id++, scopes: user.scopes } + } + } + else { + tokens.test_token_admin = { login: 'admin', id: 2, scopes: [] } + } + return tokens +} + +async function startVercel(name: string, port: number, config: SeedConfig | null): Promise { + const { pkg } = VERCEL_CATALOG[name] + const mod = await import(pkg) as { + default: ServicePlugin + seedFromConfig?: (store: Store, baseUrl: string, svcConfig: unknown, webhooks?: WebhookDispatcher) => void + } + const plugin = mod.default + if (!plugin) { + throw new Error(`${pkg} has no default export (expected a ServicePlugin)`) + } + const baseUrl = `http://localhost:${port}` + const { app, store, webhooks } = createServer(plugin, { port, baseUrl, tokens: buildTokens(config ?? undefined) }) + + plugin.seed?.(store, baseUrl) + const svcConfig = config?.[name] + if (svcConfig && mod.seedFromConfig) { + // Thread the dispatcher through — some services (e.g. stripe) register + // webhook subscriptions from config only when it is provided. + mod.seedFromConfig(store, baseUrl, svcConfig, webhooks) + } + + const server = serve({ fetch: app.fetch, port }) + // serve() binds the port asynchronously — an occupied port surfaces on the + // server's 'error' event, not as a throw. Wait for readiness so that failure + // rejects startVercel and reaches main()'s try/catch (which closes the + // services already started) instead of crashing the whole launcher. + await new Promise((resolve, reject) => { + function onListening(): void { + server.off('error', onError) + resolve() + } + function onError(err: Error): void { + server.off('listening', onListening) + reject(err) + } + server.once('listening', onListening) + server.once('error', onError) + }) + return { + name, + origin: 'vercel-labs', + port, + url: baseUrl, + close: () => new Promise((resolve, reject) => { + server.close((err) => { + if (err) { + reject(err instanceof Error ? err : new Error(String(err))) + } + else { + resolve() + } + }) + }), + } +} + +async function startPlease(name: string, port: number, config: SeedConfig | null): Promise { + const emulator: Emulator = await createEmulator({ service: name as ServiceName, port, seed: config ?? undefined }) + return { name, origin: 'this-repo', port, url: emulator.url, close: () => emulator.close() } +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)) + const pleaseBase = basePortFrom(args) + const vercelBase = vercelBaseFrom(args, pleaseBase) + const loaded = loadConfig(args.config) + const config = loaded?.config ?? null + + const services = resolveServices(args, config) + for (const name of services) { + if (!PLEASE_SET.has(name) && !VERCEL_SET.has(name)) { + console.error(`Unknown service: ${name}`) + console.error(` this repo: ${PLEASE_ORDER.join(', ')}`) + console.error(` vercel-labs: ${VERCEL_ORDER.join(', ')}`) + process.exit(1) + } + } + + const usesPlease = services.some(n => PLEASE_SET.has(n)) + const usesVercel = services.some(n => VERCEL_SET.has(n)) + + // A base port of 0 (Bun would bind a random port, so the printed localhost:0 + // URLs and seeded endpoints are wrong) or one so high the range spills past + // 65535 is unusable. Reject it for whichever range is in use — this covers + // both --port/--vercel-port and the EMULATE_*_PORT env fallbacks. + const MAX_PORT = 65535 + for (const [flag, base, span, inUse] of [ + ['--port', pleaseBase, PLEASE_ORDER.length, usesPlease], + ['--vercel-port', vercelBase, VERCEL_ORDER.length, usesVercel], + ] as const) { + if (inUse && (base < 1 || base + span - 1 > MAX_PORT)) { + console.error(`${flag} base ${base} is out of range — it must be ≥ 1 and leave room for ${span} consecutive ports at or below ${MAX_PORT}.`) + process.exit(1) + } + } + + // Guard the two ranges against overlap, but only when both are actually in use. + if (usesPlease && usesVercel + && pleaseBase < vercelBase + VERCEL_ORDER.length + && vercelBase < pleaseBase + PLEASE_ORDER.length) { + console.error(`Port ranges overlap: this-repo [${pleaseBase}..${pleaseBase + PLEASE_ORDER.length - 1}] vs vercel-labs base ${vercelBase}.`) + console.error('Raise --vercel-port (or lower --port) so the ranges are disjoint.') + process.exit(1) + } + + const running: Running[] = [] + const shutdown = async (code = 0): Promise => { + console.log() + console.log(pc.dim(' Shutting down...')) + const results = await Promise.allSettled(running.map(r => r.close())) + for (let i = 0; i < results.length; i++) { + const res = results[i] + if (res.status === 'rejected') { + console.error(pc.dim(` Failed to close ${running[i].name}: ${res.reason instanceof Error ? res.reason.message : res.reason}`)) + } + } + process.exit(code) + } + process.on('SIGINT', () => void shutdown(0)) + process.on('SIGTERM', () => void shutdown(0)) + + let starting = '' + try { + for (const name of services) { + starting = name + const port = portFor(name, pleaseBase, vercelBase) + running.push(PLEASE_SET.has(name) ? await startPlease(name, port, config) : await startVercel(name, port, config)) + } + } + catch (err) { + console.error(`\n Failed to start ${starting}: ${err instanceof Error ? err.stack ?? err.message : err}`) + await shutdown(1) + return + } + + console.log() + console.log(` ${pc.bold('emulate:all')}${pc.dim(' — this repo + vercel-labs in one process')}`) + console.log() + if (loaded) { + console.log(pc.dim(` seed config: ${loaded.source}`)) + console.log() + } + for (const r of [...running].sort((a, b) => a.port - b.port)) { + const tag = r.origin === 'this-repo' ? pc.cyan('[this-repo] ') : pc.magenta('[vercel-labs]') + const label = r.origin === 'vercel-labs' ? pc.dim(VERCEL_CATALOG[r.name].label) : '' + console.log(` ${pc.green('●')} ${tag} ${pc.bold(r.name.padEnd(12))} ${r.url} ${label}`) + } + console.log() + console.log(pc.dim(' Press Ctrl+C to stop')) +} + +void main()