Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<service>.<slug>.emulate.localhost` |

The port can also be set via `EMULATE_PORT` or `PORT` environment variables.

Expand All @@ -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:

Expand Down
10 changes: 10 additions & 0 deletions apps/web/app/docs/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ npx emulate list
<td>auto-detect</td>
<td>Path to seed config (YAML or JSON)</td>
</tr>
<tr>
<td><code>--portless</code></td>
<td>off</td>
<td>Serve over HTTPS via portless (auto-registers aliases)</td>
</tr>
<tr>
<td><code>--slug</code></td>
<td>none</td>
<td>Namespace slug for portless URLs: <code>&lt;service&gt;.&lt;slug&gt;.emulate.localhost</code></td>
</tr>
</tbody>
</table>

Expand Down
12 changes: 11 additions & 1 deletion packages/emulate/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.`);
}
32 changes: 26 additions & 6 deletions packages/emulate/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ export interface StartOptions {
seed?: string;
baseUrl?: string;
portless?: boolean;
slug?: string;
}

interface SeedConfig {
slug?: string;
tokens?: Record<string, { login: string; scopes?: string[] }>;
[service: string]: unknown;
}
Expand Down Expand Up @@ -89,6 +91,23 @@ export async function startCommand(options: StartOptions): Promise<void> {
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[];
Expand Down Expand Up @@ -140,23 +159,20 @@ export async function startCommand(options: StartOptions): Promise<void> {
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<typeof serve>[] = [];
Expand Down Expand Up @@ -192,6 +208,10 @@ export async function startCommand(options: StartOptions): Promise<void> {
httpServers.push(httpServer);
}

if (portlessAliases.length > 0) {
registerAliases(portlessAliases);
}

printBanner(serviceUrls, tokens, configSource);

const shutdown = () => {
Expand Down
5 changes: 4 additions & 1 deletion packages/emulate/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ program
.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("--slug <slug>", "Namespace slug for portless URLs: <service>.<slug>.emulate.localhost")
.action(async (opts) => {
const port = parseInt(opts.port, 10);
if (Number.isNaN(port) || port < 1 || port > 65535) {
Expand All @@ -35,15 +36,17 @@ program
seed: opts.seed,
baseUrl: opts.baseUrl,
portless: opts.portless,
slug: opts.slug,
});
});

program
.command("init")
.description("Generate a starter config file")
.option("-s, --service <service>", "Service to generate config for", "all")
.option("--slug <slug>", "Project slug for portless URLs (defaults to no slug)")
.action((opts) => {
initCommand({ service: opts.service });
initCommand({ service: opts.service, slug: opts.slug });
});

program
Expand Down
5 changes: 3 additions & 2 deletions packages/emulate/src/portless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
9 changes: 8 additions & 1 deletion skills/emulate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<service>.<slug>.emulate.localhost` |

The port can also be set via `EMULATE_PORT` or `PORT` environment variables.

Expand Down Expand Up @@ -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:

Expand Down