From 1116c2007db4a69dec95ab503cd97b30e7d4423c Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Tue, 14 Jul 2026 23:17:00 +0900 Subject: [PATCH 1/6] build: align @emulators/core version with vercel-labs Bump @emulators/core from ^0.6.0 to ^0.9.0 across all service packages so this repo's emulators dedup onto the same shared core as vercel-labs' @emulators/* packages (the two core versions are byte-identical, so no code changes were required). This is a prerequisite for running both sets of emulators in a single process. --- packages/asana/package.json | 2 +- packages/autumn/package.json | 2 +- packages/emulate/package.json | 2 +- packages/firebase/package.json | 2 +- packages/gitlab/package.json | 2 +- packages/kakao/package.json | 2 +- packages/linear/package.json | 2 +- packages/naver/package.json | 2 +- packages/posthog/package.json | 2 +- packages/spotify/package.json | 2 +- packages/supabase/package.json | 2 +- packages/toss-payments/package.json | 2 +- packages/workos/package.json | 2 +- packages/x/package.json | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) 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/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", From 8070c6611d07a605dfe84436af56eb267e770660 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Tue, 14 Jul 2026 23:17:10 +0900 Subject: [PATCH 2/6] feat: add single-process launcher for this repo + vercel-labs emulators Add scripts/emulate-all.ts, a single-process launcher that hosts this repo's services (via @pleaseai/emulate's createEmulator) alongside vercel-labs' @emulators/* services (via the shared core's createServer/serve) in one process. Services get stable per-service ports in separate ranges (this-repo 4000+, vercel-labs 4100+), share emulate.config.* seeding, guard against port-range overlap, and shut down gracefully. Export SERVICE_NAMES from @pleaseai/emulate's public API as the single source of truth for the service list, consumed by the launcher. Add the launcher's root devDependencies (bun's isolated linker requires them to be direct root deps): @emulators/core plus 12 vercel service packages, @pleaseai/emulate (workspace:*), picocolors, yaml, and the upstream emulate CLI for reference. Wire up the emulate:all script. --- bun.lock | 75 ++++++++-- package.json | 20 ++- packages/emulate/src/api.ts | 1 + scripts/emulate-all.ts | 274 ++++++++++++++++++++++++++++++++++++ 4 files changed, 354 insertions(+), 16 deletions(-) create mode 100644 scripts/emulate-all.ts 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/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/scripts/emulate-all.ts b/scripts/emulate-all.ts new file mode 100644 index 0000000..77e7998 --- /dev/null +++ b/scripts/emulate-all.ts @@ -0,0 +1,274 @@ +#!/usr/bin/env bun +import type { ServicePlugin, Store } 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 } 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 = {} + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg === '--port') { + args.port = Number.parseInt(argv[++i], 10) + } + else if (arg === '--vercel-port') { + args.vercelPort = Number.parseInt(argv[++i], 10) + } + else if (arg === '--service') { + args.service = argv[++i] + } + 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 && !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 && !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) { + return args.service.split(',').map(s => s.trim()).filter(Boolean) + } + if (config) { + const inferred = Object.keys(config).filter(k => PLEASE_SET.has(k) || VERCEL_SET.has(k)) + if (inferred.length > 0) { + return inferred + } + } + 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) => void + } + const plugin = mod.default + const baseUrl = `http://localhost:${port}` + const { app, store } = createServer(plugin, { port, baseUrl, tokens: buildTokens(config ?? undefined) }) + + plugin.seed?.(store, baseUrl) + const svcConfig = config?.[name] + if (svcConfig && mod.seedFromConfig) { + mod.seedFromConfig(store, baseUrl, svcConfig) + } + + const server = serve({ fetch: app.fetch, port }) + return { + name, + origin: 'vercel-labs', + port, + url: baseUrl, + close: () => new Promise((resolve, reject) => server.close(err => (err ? reject(err) : resolve()))), + } +} + +async function startPlease(name: string, port: number, config: SeedConfig | null): Promise { + const emulator: Emulator = await createEmulator({ service: name as never, 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) + } + } + + // Guard the two ranges against overlap, but only when both are actually in use. + const usesPlease = services.some(n => PLEASE_SET.has(n)) + const usesVercel = services.some(n => VERCEL_SET.has(n)) + 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...')) + await Promise.all(running.map(r => r.close().catch(() => {}))) + process.exit(code) + } + process.on('SIGINT', () => void shutdown(0)) + process.on('SIGTERM', () => void shutdown(0)) + + try { + for (const name of services) { + 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: ${err instanceof Error ? 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() From 307656edb58bbfe5621db4d4fbfbdac041194bdf Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Tue, 14 Jul 2026 23:48:10 +0900 Subject: [PATCH 3/6] fix: harden emulate-all launcher per code review - thread the WebhookDispatcher into vercel seedFromConfig so stripe webhook seed config is actually registered (was silently dropped) - error on non-numeric --port/--vercel-port instead of silently falling back to the default - warn (not silently start everything) when a config has no recognized service keys - dedupe --service names to avoid a same-port bind crash - name the failing service and keep the stack on startup errors - guard a missing default export; log per-service close failures --- scripts/emulate-all.ts | 44 +++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/scripts/emulate-all.ts b/scripts/emulate-all.ts index 77e7998..fc4f04f 100644 --- a/scripts/emulate-all.ts +++ b/scripts/emulate-all.ts @@ -1,5 +1,5 @@ #!/usr/bin/env bun -import type { ServicePlugin, Store } from '@emulators/core' +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. @@ -73,13 +73,23 @@ interface Args { 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 = Number.parseInt(argv[++i], 10) + args.port = intFlag('--port', argv[++i]) } else if (arg === '--vercel-port') { - args.vercelPort = Number.parseInt(argv[++i], 10) + args.vercelPort = intFlag('--vercel-port', argv[++i]) } else if (arg === '--service') { args.service = argv[++i] @@ -148,13 +158,16 @@ function loadConfig(explicit?: string): { config: SeedConfig, source: string } | function resolveServices(args: Args, config: SeedConfig | null): string[] { if (args.service) { - return args.service.split(',').map(s => s.trim()).filter(Boolean) + // Dedupe: two identical names would map to the same port and crash on bind. + return [...new Set(args.service.split(',').map(s => s.trim()).filter(Boolean))] } 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.error(pc.dim(' No known service keys in config — starting all services.')) } return [...SERVICE_NAMES, ...Object.keys(VERCEL_CATALOG)] } @@ -178,16 +191,21 @@ async function startVercel(name: string, port: number, config: SeedConfig | null const { pkg } = VERCEL_CATALOG[name] const mod = await import(pkg) as { default: ServicePlugin - seedFromConfig?: (store: Store, baseUrl: string, svcConfig: unknown) => void + 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 } = createServer(plugin, { port, baseUrl, tokens: buildTokens(config ?? undefined) }) + const { app, store, webhooks } = createServer(plugin, { port, baseUrl, tokens: buildTokens(config ?? undefined) }) plugin.seed?.(store, baseUrl) const svcConfig = config?.[name] if (svcConfig && mod.seedFromConfig) { - mod.seedFromConfig(store, baseUrl, svcConfig) + // 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 }) @@ -237,20 +255,28 @@ async function main(): Promise { const shutdown = async (code = 0): Promise => { console.log() console.log(pc.dim(' Shutting down...')) - await Promise.all(running.map(r => r.close().catch(() => {}))) + 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: ${err instanceof Error ? err.message : err}`) + console.error(`\n Failed to start ${starting}: ${err instanceof Error ? err.stack ?? err.message : err}`) await shutdown(1) return } From 56bc327fcf8a8eae8c89efdd7b4e19a30b74b1d3 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Wed, 15 Jul 2026 03:18:28 +0900 Subject: [PATCH 4/6] fix: reject server close with an Error and avoid void-return arrow Address the two genuine Codacy findings in the emulate-all launcher's server close handler (L217): - reject with a real Error when server.close reports one, instead of forwarding a possibly-undefined value (prefer-promise-reject-errors) - give the close callback a block body so the arrow no longer returns a void expression (no-confusing-void-expression) The remaining Codacy warnings are eslint-plugin-security false positives for a trusted dev CLI launcher; the repo's own ESLint config intentionally does not enforce those rules, and `eslint .` stays green. --- scripts/emulate-all.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/emulate-all.ts b/scripts/emulate-all.ts index fc4f04f..7ac8215 100644 --- a/scripts/emulate-all.ts +++ b/scripts/emulate-all.ts @@ -214,7 +214,16 @@ async function startVercel(name: string, port: number, config: SeedConfig | null origin: 'vercel-labs', port, url: baseUrl, - close: () => new Promise((resolve, reject) => server.close(err => (err ? reject(err) : resolve()))), + close: () => new Promise((resolve, reject) => { + server.close((err) => { + if (err) { + reject(err instanceof Error ? err : new Error(String(err))) + } + else { + resolve() + } + }) + }), } } From 84813f1e73f83f85128c98c4d3ee6da71f489b83 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Wed, 15 Jul 2026 03:53:04 +0900 Subject: [PATCH 5/6] chore: apply cubic/greptile review suggestions to emulate-all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - startVercel: await 'listening'/reject on 'error' so an occupied Vercel port rejects into main()'s try/catch (closing already-started services) instead of crashing via an async listen error (cubic P2) - parseArgs: reject `--service` with no value instead of silently starting every emulator (cubic P3) - basePortFrom/vercelBaseFrom: use `!== undefined` so an explicit port 0 is honored rather than falling through as falsy (greptile P2) - startPlease: narrow with `name as ServiceName` instead of `name as never`, restoring type-checking at the call site (greptile P2) - resolveServices: route the "no known service keys" notice through console.warn (stderr → warn) so it doesn't read as an error (greptile P2) --- scripts/emulate-all.ts | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/scripts/emulate-all.ts b/scripts/emulate-all.ts index 7ac8215..ec6382c 100644 --- a/scripts/emulate-all.ts +++ b/scripts/emulate-all.ts @@ -26,7 +26,7 @@ import type { ServicePlugin, Store, WebhookDispatcher } from '@emulators/core' * 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 } from '@pleaseai/emulate' +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' @@ -92,7 +92,14 @@ function parseArgs(argv: string[]): Args { args.vercelPort = intFlag('--vercel-port', argv[++i]) } else if (arg === '--service') { - args.service = argv[++i] + // A present-but-empty --service must not silently fall through to + // "start everything" — reject the malformed invocation. + const value = argv[++i] + if (value === undefined) { + 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] @@ -103,7 +110,7 @@ function parseArgs(argv: string[]): Args { /** Base port fallback: --port > EMULATE_PORT > PORT > 4000. */ function basePortFrom(args: Args): number { - if (args.port && !Number.isNaN(args.port)) { + if (args.port !== undefined && !Number.isNaN(args.port)) { return args.port } for (const raw of [process.env.EMULATE_PORT, process.env.PORT]) { @@ -117,7 +124,7 @@ function basePortFrom(args: Args): number { /** vercel-labs base port: --vercel-port > EMULATE_VERCEL_PORT > pleaseBase + 100. */ function vercelBaseFrom(args: Args, pleaseBase: number): number { - if (args.vercelPort && !Number.isNaN(args.vercelPort)) { + 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 @@ -167,7 +174,7 @@ function resolveServices(args: Args, config: SeedConfig | null): string[] { return inferred } // Config present but no key matched a service — make the fallback visible. - console.error(pc.dim(' No known service keys in config — starting all services.')) + console.warn(pc.yellow(' ⚠ No known service keys in config — starting all services.')) } return [...SERVICE_NAMES, ...Object.keys(VERCEL_CATALOG)] } @@ -209,6 +216,22 @@ async function startVercel(name: string, port: number, config: SeedConfig | null } 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', @@ -228,7 +251,7 @@ async function startVercel(name: string, port: number, config: SeedConfig | null } async function startPlease(name: string, port: number, config: SeedConfig | null): Promise { - const emulator: Emulator = await createEmulator({ service: name as never, port, seed: config ?? undefined }) + 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() } } From 1c102f9f627ec4ff3c61662c35003ed4937c33dd Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Wed, 15 Jul 2026 04:03:46 +0900 Subject: [PATCH 6/6] fix: reject empty --service and out-of-range base ports in emulate-all Follow-up to the review fixes, addressing cubic's re-review of the launcher: - parseArgs: reject `--service ""` (empty string is falsy, so it slipped past the `=== undefined` guard and started every emulator); the `!value` check also drops the "unnecessary conditional" warning since argv indexing is typed as string - resolveServices: reject a value that resolves to no names (e.g. `--service ,`) instead of starting nothing and idling - main: reject a base port of 0 or any range that spills past 65535 for the range in use. Honoring an explicit `--port 0` (from the earlier falsy-guard fix) otherwise produced bogus localhost:0 URLs; erroring loudly keeps that fix's "no silent override" intent while covering the CLI and env sources --- scripts/emulate-all.ts | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/scripts/emulate-all.ts b/scripts/emulate-all.ts index ec6382c..aaa7ab3 100644 --- a/scripts/emulate-all.ts +++ b/scripts/emulate-all.ts @@ -92,10 +92,12 @@ function parseArgs(argv: string[]): Args { args.vercelPort = intFlag('--vercel-port', argv[++i]) } else if (arg === '--service') { - // A present-but-empty --service must not silently fall through to - // "start everything" — reject the malformed invocation. + // 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 === undefined) { + if (!value) { console.error('--service requires a comma-separated list of service names') process.exit(1) } @@ -166,7 +168,12 @@ function loadConfig(explicit?: string): { config: SeedConfig, source: string } | 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. - return [...new Set(args.service.split(',').map(s => s.trim()).filter(Boolean))] + 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)) @@ -272,9 +279,25 @@ async function main(): Promise { } } - // Guard the two ranges against overlap, but only when both are actually in use. 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) {