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
5 changes: 5 additions & 0 deletions .changeset/dev-url-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Add HTTP Basic userinfo and repeatable `-H, --header` support to `eve dev` URL targets so the terminal UI can send credentials or routing headers to protected remote deployments.
8 changes: 7 additions & 1 deletion docs/guides/dev-tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,13 @@ Pass a URL and the TUI talks to a running deployment instead of starting a local
eve dev https://<your-app>
```

The bare URL is shorthand for `--url`; it cannot be combined with `--host`, `--port`, or `--no-ui`.
The bare URL is shorthand for `--url`; it cannot be combined with `--host`, `--port`, or `--no-ui`. For HTTP Basic auth, put credentials in the URL; eve sends them as a Basic `Authorization` header and strips them from the server URL before connecting:

```bash
eve dev https://user:pass@<your-app>
```

For bearer tokens or custom schemes, repeat `-H, --header` to attach request headers.

At startup the TUI asks Vercel to resolve the remote origin under the active scope. A resolved response is the authority for a project-scoped OIDC token—refreshing an expired development token when refresh credentials exist—or an automation-bypass secret. An unresolved host is probed anonymously. The TUI then requests `/eve/v1/info`, with a ten-second timeout. A successful response marks the remote ready. An eve OIDC challenge, Vercel Deployment Protection challenge, or `TRUSTED_SOURCES_ENVIRONMENT_MISMATCH` opens `/vc:login` automatically; ordinary network failures and server errors remain remote-availability errors and do not start an authentication flow. Esc or Ctrl-C cancels the authentication flow.

Expand Down
11 changes: 10 additions & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,14 @@ eve dev [options]
eve dev https://your-app.vercel.app
```

Pass a bare URL as the only argument and the UI connects to that server instead of booting a local one (same as `--url`), which lets you smoke-test a preview or production deployment. The interactive UI turns off in a non-TTY terminal.
Pass a bare URL and the UI connects to that server instead of booting a local one (same as `--url`), which lets you smoke-test a preview or production deployment. The interactive UI turns off in a non-TTY terminal.

| Flag | Type | Default | Description |
| ----------------------------------- | ------ | ------------------ | ----------------------------------------------------------------------------------------- |
| `--host <host>` | string | all interfaces | Host interface to bind |
| `--port <port>` | number | `$PORT`, then 3000 | Port to listen on |
| `-u, --url <url>` | string | none | Connect to an existing server URL instead of starting one |
| `-H, --header <header>` | string | none | Request header for a URL target, in `Name: value` form; repeat for multiple headers |
| `--no-ui` | flag | UI on | Start the server without an interactive UI |
| `--name <name>` | string | app folder name | Title shown in the terminal UI |
| `--input <text>` | string | none | Pre-fill the prompt input; bare local `/model` starts onboarding |
Expand All @@ -111,6 +112,14 @@ Pass a bare URL as the only argument and the UI connects to that server instead

A fresh `eve init` passes `--input /model`. That bare local input starts onboarding: the TUI installs the Vercel CLI if needed, asks you to log in if needed, then opens `/model`. Other input stays editable in the prompt.

For a URL target protected by HTTP Basic auth, put the credentials in the URL. Eve sends them as a Basic `Authorization` header and strips them from the server URL before connecting:

```bash
eve dev https://user:pass@your-app.example.com
```

For bearer tokens or custom schemes, pass explicit headers with `-H`.

Local dev records the last ready URL per resolved app root in `.eve/dev-server-state.v1.json`. A second interactive `eve dev` reconnects only when that URL is loopback and healthy; each terminal UI creates a fresh client session while sharing the server process. A stale or malformed record is replaced when eve starts a new server. Passing `--host`, `--port`, or a `PORT` environment value skips reconnection and reports a healthy recorded server instead.

Local dev keeps immutable runtime source snapshots under `.eve/dev-runtime/snapshots/` so in-flight sessions hold a consistent code revision while new prompts pick up rebuilds. On startup, `eve dev` prunes stale runtime snapshots and old local sandbox templates in the background. For manual cleanup, stop `eve dev` and delete `.eve/dev-runtime/snapshots/` or `.eve/sandbox-cache/local/templates/`.
Expand Down
43 changes: 43 additions & 0 deletions packages/eve/src/cli/dev/tui/tui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { runDevelopmentTui, type DevelopmentTuiTarget } from "./tui.js";

describe("runDevelopmentTui", () => {
beforeEach(() => {
vi.restoreAllMocks();
mocks.runnerOptions.length = 0;
});

Expand All @@ -40,4 +41,46 @@ describe("runDevelopmentTui", () => {
expect(first.client).not.toBe(second.client);
expect(first.session).not.toBe(second.session);
});

it.each([
[
"remote",
{
kind: "remote",
serverUrl: "https://remote.example.com/",
workspaceRoot: "/tmp/app",
},
],
[
"local",
{
kind: "local",
serverUrl: "http://127.0.0.1:4321/",
workspaceRoot: "/tmp/app",
},
],
] satisfies Array<readonly [string, DevelopmentTuiTarget]>)(
"passes explicit headers to %s TUI client requests",
async (_name, target) => {
await runDevelopmentTui({
headers: {
authorization: "Basic dGVzdDpzZWNyZXQ=",
"x-tenant": "acme",
},
target,
});

const client = mocks.runnerOptions[0]?.client;
if (client === undefined) {
throw new Error("Expected a TUI client.");
}

const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null));
await client.fetch("/eve/v1/info");

const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers);
expect(headers.get("authorization")).toBe("Basic dGVzdDpzZWNyZXQ=");
expect(headers.get("x-tenant")).toBe("acme");
},
);
});
7 changes: 6 additions & 1 deletion packages/eve/src/cli/dev/tui/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export type { DevelopmentTuiTarget } from "./target.js";
export interface RunDevelopmentTuiInput extends TuiDisplayOptions {
/** The local server or remote URL used by this TUI session. */
readonly target: DevelopmentTuiTarget;
/** Additional request headers sent by this TUI client. */
readonly headers?: Readonly<Record<string, string>>;
/**
* Text to seed the prompt input with after the UI launches. A bare local
* `/model` starts fresh-agent onboarding. Applies to the first prompt only.
Expand Down Expand Up @@ -77,17 +79,20 @@ function prepareDevelopmentTarget(target: DevelopmentTuiTarget): PreparedDevelop
* the inline error region rather than crashing the command.
*/
export async function runDevelopmentTui(input: RunDevelopmentTuiInput): Promise<void> {
const { target, initialInput, onBootProgress, ...display } = input;
const { target, headers, initialInput, onBootProgress, ...display } = input;
const prepared = prepareDevelopmentTarget(target);
const { serverUrl } = target;
const headerOptions = headers === undefined ? {} : { headers };

const client = new Client(
prepared.kind === "local"
? resolveLocalDevelopmentClientOptions({
...headerOptions,
serverUrl,
token: () => resolveLinkedDevelopmentOidcToken(prepared.target.workspaceRoot),
})
: resolveRemoteDevelopmentClientOptions({
...headerOptions,
serverUrl,
credentials: prepared.remote.credentials,
}),
Expand Down
166 changes: 116 additions & 50 deletions packages/eve/src/cli/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ async function withInteractiveTerminal<T>(fn: () => Promise<T>): Promise<T> {
}
}

async function runInteractiveDev(
argv: string[],
runtime: NonNullable<Parameters<typeof runCli>[2]> = {},
) {
const runDevelopmentTui = vi.fn(async () => {});
await withInteractiveTerminal(() =>
runCli(argv, { error: () => {}, log: () => {} }, { ...runtime, runDevelopmentTui }),
);
return runDevelopmentTui;
}

describe("CLI command registration", () => {
it("lists the current project creation and Vercel commands", async () => {
const output: string[] = [];
Expand Down Expand Up @@ -85,15 +96,13 @@ describe("eve CLI malformed argument handling", () => {

describe("eve dev --input", () => {
it("forwards the initial draft to the interactive TUI", async () => {
const runDevelopmentTui = vi.fn(async () => {});

await withInteractiveTerminal(() =>
runCli(
["dev", "--url", "https://example.com", "--input", "/model"],
{ error: () => {}, log: () => {} },
{ runDevelopmentTui },
),
);
const runDevelopmentTui = await runInteractiveDev([
"dev",
"--url",
"https://example.com",
"--input",
"/model",
]);

expect(runDevelopmentTui).toHaveBeenCalledWith(
expect.objectContaining({
Expand Down Expand Up @@ -128,44 +137,113 @@ describe("eve dev --input", () => {
});

describe("eve dev --url protocol", () => {
it("uses the local TUI credential path only for this app's running dev server", async () => {
const runDevelopmentTui = vi.fn(async () => {});
it("lowers URL userinfo to a Basic authorization header and strips it from the target URL", async () => {
const runDevelopmentTui = await runInteractiveDev([
"dev",
"https://test%40user:p%20ss@example.com",
]);

await withInteractiveTerminal(() =>
runCli(
["dev", "--url", "http://127.0.0.1:2000"],
{ error: () => {}, log: () => {} },
{
isActiveDevelopmentServerForApp: async () => true,
runDevelopmentTui,
expect(runDevelopmentTui).toHaveBeenCalledWith(
expect.objectContaining({
headers: {
Authorization: `Basic ${btoa("test@user:p ss")}`,
},
),
target: {
kind: "remote",
serverUrl: "https://example.com/",
workspaceRoot: process.cwd(),
},
}),
);
});

it("prefers explicit authorization headers over URL userinfo", async () => {
const runDevelopmentTui = await runInteractiveDev([
"dev",
"https://user:pass@example.com",
"-H",
"Authorization: Bearer explicit-token",
]);

expect(runDevelopmentTui).toHaveBeenCalledWith(
expect.objectContaining({
headers: {
Authorization: "Bearer explicit-token",
},
target: {
kind: "local",
serverUrl: "http://127.0.0.1:2000/",
kind: "remote",
serverUrl: "https://example.com/",
workspaceRoot: process.cwd(),
},
}),
);
});

it("keeps an unverified loopback URL on the remote credential path", async () => {
const runDevelopmentTui = vi.fn(async () => {});
it("forwards repeatable request headers to the remote TUI", async () => {
const runDevelopmentTui = await runInteractiveDev([
"dev",
"--url",
"https://example.com",
"-H",
"Authorization: Basic dGVzdDpzZWNyZXQ=",
"--header",
"X-Tenant: acme",
]);

await withInteractiveTerminal(() =>
expect(runDevelopmentTui).toHaveBeenCalledWith(
expect.objectContaining({
headers: {
Authorization: "Basic dGVzdDpzZWNyZXQ=",
"X-Tenant": "acme",
},
target: {
kind: "remote",
serverUrl: "https://example.com/",
workspaceRoot: process.cwd(),
},
}),
);
});

it("rejects malformed request headers", async () => {
await expect(
runCli(
["dev", "--url", "http://127.0.0.1:2000"],
["dev", "--url", "https://example.com", "-H", "Authorization"],
{ error: () => {}, log: () => {} },
{
isActiveDevelopmentServerForApp: async () => false,
runDevelopmentTui,
},
{ runDevelopmentTui: vi.fn(async () => {}) },
),
).rejects.toThrow('Expected header in "Name: value" format');
});

it("rejects request headers without a URL target", async () => {
await expect(
runCli(["dev", "-H", "Authorization: Bearer dev-token"], {
error: () => {},
log: () => {},
}),
).rejects.toThrow("The --header option can only be used with --url or a bare URL.");
});

it("uses the local TUI credential path only for this app's running dev server", async () => {
const runDevelopmentTui = await runInteractiveDev(["dev", "--url", "http://127.0.0.1:2000"], {
isActiveDevelopmentServerForApp: async () => true,
});

expect(runDevelopmentTui).toHaveBeenCalledWith(
expect.objectContaining({
target: {
kind: "local",
serverUrl: "http://127.0.0.1:2000/",
workspaceRoot: process.cwd(),
},
}),
);
});

it("keeps an unverified loopback URL on the remote credential path", async () => {
const runDevelopmentTui = await runInteractiveDev(["dev", "--url", "http://127.0.0.1:2000"], {
isActiveDevelopmentServerForApp: async () => false,
});

expect(runDevelopmentTui).toHaveBeenCalledWith(
expect.objectContaining({
Expand Down Expand Up @@ -195,15 +273,13 @@ describe("eve eval --url protocol", () => {

describe("eve dev --logs", () => {
it("accepts sandbox as the initial TUI log mode", async () => {
const runDevelopmentTui = vi.fn(async () => {});

await withInteractiveTerminal(() =>
runCli(
["dev", "--url", "https://example.com", "--logs", "sandbox"],
{ error: () => {}, log: () => {} },
{ runDevelopmentTui },
),
);
const runDevelopmentTui = await runInteractiveDev([
"dev",
"--url",
"https://example.com",
"--logs",
"sandbox",
]);

expect(runDevelopmentTui).toHaveBeenCalledWith(
expect.objectContaining({
Expand Down Expand Up @@ -273,11 +349,7 @@ describe("eve dev local server ownership", () => {
}),
close: async () => {},
}));
const runDevelopmentTui = vi.fn(async () => {});

await withInteractiveTerminal(() =>
runCli(["dev"], { error: () => {}, log: () => {} }, { runDevelopmentTui, startHost }),
);
const runDevelopmentTui = await runInteractiveDev(["dev"], { startHost });

expect(startHost).toHaveBeenCalledWith(expect.any(String), {
existing: "attach-if-unconfigured",
Expand Down Expand Up @@ -308,13 +380,7 @@ describe("eve dev local server ownership", () => {
close,
}));

await withInteractiveTerminal(() =>
runCli(
["dev"],
{ error: () => {}, log: () => {} },
{ runDevelopmentTui: vi.fn(async () => {}), startHost },
),
);
await runInteractiveDev(["dev"], { startHost });
expect(close).toHaveBeenCalledOnce();
});
});
Expand Down
Loading
Loading