From 8385cf5160abd3589bf9f86ff59c58c7bf0dc798 Mon Sep 17 00:00:00 2001 From: gmackie Date: Thu, 30 Apr 2026 16:08:18 -0700 Subject: [PATCH] feat(emulate): add --slug option to namespace portless aliases When running multiple projects with --portless, aliases like github.emulate collide because they are derived only from the service name. The second instance silently overwrites the first via --force, and shutdown of either instance removes the alias for both. --slug namespaces aliases to ..emulate, producing URLs like https://github.myapp.emulate.localhost. The slug can be set via CLI flag, or as a top-level `slug` field in emulate.config.yaml (CLI flag takes precedence). Without --slug, behavior is unchanged. Additional improvements: - Validate slug as a DNS label (lowercase alnum + hyphens, max 63 chars) - Register portless aliases after servers bind to avoid dangling aliases on port conflicts - Update init command output to suggest --portless when slug is configured Co-Authored-By: Claude Opus 4.6 --- README.md | 21 ++++++++++++++++- apps/web/app/docs/page.mdx | 10 ++++++++ packages/emulate/src/commands/init.ts | 12 +++++++++- packages/emulate/src/commands/start.ts | 32 +++++++++++++++++++++----- packages/emulate/src/index.ts | 5 +++- packages/emulate/src/portless.ts | 5 ++-- skills/emulate/SKILL.md | 9 +++++++- 7 files changed, 82 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9f5228c..9e3ee12 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ npx emulate list | `--seed` | auto-detect | Path to seed config (YAML or JSON) | | `--base-url` | none | Override advertised base URL (supports `{service}` template) | | `--portless` | off | Serve over HTTPS via portless (auto-registers aliases) | +| `--slug` | none | Namespace slug for portless URLs: `..emulate.localhost` | The port can also be set via `EMULATE_PORT` or `PORT` environment variables. @@ -77,7 +78,25 @@ slack https://slack.emulate.localhost If portless is not installed, emulate will prompt to install it (`npm i -g portless`). -The `--portless` flag overwrites any existing portless aliases matching `*.emulate`. Aliases are removed automatically when emulate shuts down. +Aliases are removed automatically when emulate shuts down. + +To run multiple projects concurrently with portless, use `--slug` to namespace the aliases: + +```bash +# Project A +npx emulate start --portless --slug project-a +# URLs: https://github.project-a.emulate.localhost + +# Project B +npx emulate start --portless --slug project-b +# URLs: https://github.project-b.emulate.localhost +``` + +The slug can also be set in the config file: + +```yaml +slug: project-a +``` For a custom base URL without portless (any reverse proxy), use `--base-url` or the `EMULATE_BASE_URL` env var: diff --git a/apps/web/app/docs/page.mdx b/apps/web/app/docs/page.mdx index 783729d..dd80232 100644 --- a/apps/web/app/docs/page.mdx +++ b/apps/web/app/docs/page.mdx @@ -73,6 +73,16 @@ npx emulate list auto-detect Path to seed config (YAML or JSON) + + --portless + off + Serve over HTTPS via portless (auto-registers aliases) + + + --slug + none + Namespace slug for portless URLs: <service>.<slug>.emulate.localhost + diff --git a/packages/emulate/src/commands/init.ts b/packages/emulate/src/commands/init.ts index d3dca5d..30a316c 100644 --- a/packages/emulate/src/commands/init.ts +++ b/packages/emulate/src/commands/init.ts @@ -5,6 +5,7 @@ import { SERVICE_REGISTRY, SERVICE_NAMES, DEFAULT_TOKENS, type ServiceName } fro interface InitOptions { service: string; + slug?: string; } export function initCommand(options: InitOptions): void { @@ -31,9 +32,18 @@ export function initCommand(options: InitOptions): void { config = { ...DEFAULT_TOKENS, ...entry.initConfig }; } + if (options.slug) { + if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(options.slug)) { + console.error("Invalid slug: must be a lowercase DNS label (a-z, 0-9, hyphens, max 63 chars)."); + process.exit(1); + } + config = { slug: options.slug, ...config }; + } + const content = yamlStringify(config); writeFileSync(fullPath, content, "utf-8"); console.log(`Created ${filename}`); - console.log(`\nRun 'npx emulate' to start the emulator.`); + const startCmd = options.slug ? "npx emulate start --portless" : "npx emulate"; + console.log(`\nRun '${startCmd}' to start the emulator.`); } diff --git a/packages/emulate/src/commands/start.ts b/packages/emulate/src/commands/start.ts index 06b896c..f143a6d 100644 --- a/packages/emulate/src/commands/start.ts +++ b/packages/emulate/src/commands/start.ts @@ -17,9 +17,11 @@ export interface StartOptions { seed?: string; baseUrl?: string; portless?: boolean; + slug?: string; } interface SeedConfig { + slug?: string; tokens?: Record; [service: string]: unknown; } @@ -89,6 +91,23 @@ export async function startCommand(options: StartOptions): Promise { const seedConfig = loaded?.config ?? null; const configSource = loaded?.source ?? null; + const slug = options.slug ?? seedConfig?.slug; + + if (slug !== undefined && typeof slug !== "string") { + console.error("slug must be a string."); + process.exit(1); + } + + if (slug && !/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(slug)) { + console.error("Invalid slug: must be a lowercase DNS label (a-z, 0-9, hyphens, max 63 chars)."); + process.exit(1); + } + + if (slug && !options.portless) { + console.error("--slug requires --portless."); + process.exit(1); + } + let services: ServiceName[]; if (options.service) { services = options.service.split(",").map((s) => s.trim()) as ServiceName[]; @@ -140,23 +159,20 @@ export async function startCommand(options: StartOptions): Promise { const port = (svcSeedConfig?.port as number | undefined) ?? basePort + i; if (options.portless) { - portlessAliases.push({ name: `${svc}.emulate`, port }); + const aliasName = slug ? `${svc}.${slug}.emulate` : `${svc}.emulate`; + portlessAliases.push({ name: aliasName, port }); } const seedBaseUrl = typeof svcSeedConfig?.baseUrl === "string" && svcSeedConfig.baseUrl.length > 0 ? svcSeedConfig.baseUrl : undefined; - const effectiveBaseUrl = options.portless ? portlessBaseUrl(svc) : options.baseUrl; + const effectiveBaseUrl = options.portless ? portlessBaseUrl(svc, slug) : options.baseUrl; const baseUrl = resolveBaseUrl({ service: svc, port, baseUrl: effectiveBaseUrl, seedBaseUrl }); prepared.push({ svc, entry, loadedSvc, svcSeedConfig, port, baseUrl }); } - if (portlessAliases.length > 0) { - registerAliases(portlessAliases); - } - const serviceUrls: Array<{ name: string; url: string }> = []; const stores: Store[] = []; const httpServers: ReturnType[] = []; @@ -192,6 +208,10 @@ export async function startCommand(options: StartOptions): Promise { httpServers.push(httpServer); } + if (portlessAliases.length > 0) { + registerAliases(portlessAliases); + } + printBanner(serviceUrls, tokens, configSource); const shutdown = () => { diff --git a/packages/emulate/src/index.ts b/packages/emulate/src/index.ts index 51b40a0..282c5a2 100644 --- a/packages/emulate/src/index.ts +++ b/packages/emulate/src/index.ts @@ -23,6 +23,7 @@ program .option("--seed ", "Path to seed config file") .option("--base-url ", "Override advertised base URL (supports {service} template)") .option("--portless", "Serve over HTTPS via portless (auto-registers aliases)") + .option("--slug ", "Namespace slug for portless URLs: ..emulate.localhost") .action(async (opts) => { const port = parseInt(opts.port, 10); if (Number.isNaN(port) || port < 1 || port > 65535) { @@ -35,6 +36,7 @@ program seed: opts.seed, baseUrl: opts.baseUrl, portless: opts.portless, + slug: opts.slug, }); }); @@ -42,8 +44,9 @@ program .command("init") .description("Generate a starter config file") .option("-s, --service ", "Service to generate config for", "all") + .option("--slug ", "Project slug for portless URLs (defaults to no slug)") .action((opts) => { - initCommand({ service: opts.service }); + initCommand({ service: opts.service, slug: opts.slug }); }); program diff --git a/packages/emulate/src/portless.ts b/packages/emulate/src/portless.ts index f691c38..4e29ac0 100644 --- a/packages/emulate/src/portless.ts +++ b/packages/emulate/src/portless.ts @@ -88,6 +88,7 @@ export function removeAliases(aliases: PortlessAlias[]): void { } } -export function portlessBaseUrl(serviceName: string): string { - return `https://${serviceName}.emulate.localhost`; +export function portlessBaseUrl(serviceName: string, slug?: string): string { + const host = slug ? `${serviceName}.${slug}.emulate.localhost` : `${serviceName}.emulate.localhost`; + return `https://${host}`; } diff --git a/skills/emulate/SKILL.md b/skills/emulate/SKILL.md index 267a7ae..4396ce7 100644 --- a/skills/emulate/SKILL.md +++ b/skills/emulate/SKILL.md @@ -60,6 +60,7 @@ npx emulate list | `--seed` | auto-detect | Path to seed config (YAML or JSON) | | `--base-url` | none | Override advertised base URL (supports `{service}` template) | | `--portless` | off | Serve over HTTPS via portless (auto-registers aliases) | +| `--slug` | none | Namespace slug for portless URLs: `..emulate.localhost` | The port can also be set via `EMULATE_PORT` or `PORT` environment variables. @@ -269,7 +270,13 @@ npx emulate start --portless This requires the portless proxy to be running (`portless proxy start`). If portless is not installed, emulate will prompt to install it. -The `--portless` flag overwrites any existing portless aliases matching `*.emulate`. Aliases are removed automatically when emulate shuts down. +Aliases are removed automatically when emulate shuts down. To run multiple projects concurrently, use `--slug`: + +```bash +npx emulate start --portless --slug myapp +# github https://github.myapp.emulate.localhost +# google https://google.myapp.emulate.localhost +``` For a single service behind portless: