Skip to content
Merged
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
11 changes: 9 additions & 2 deletions cli/commands/open/command-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { CommandHelp } from "../../help/types.ts";
export const openHelp: CommandHelp = {
name: "open",
category: "project",
description: "Open project URLs in the browser",
description: "Open the Cloud dashboard, or the deployed site with --site",
usage: "veryfront open [options]",
options: [
{
Expand All @@ -12,7 +12,11 @@ export const openHelp: CommandHelp = {
},
{
flag: "--env <name>",
description: "Open the project's Environments panel",
description: "Environment to open with --site; otherwise the Environments panel",
},
{
flag: "--site",
description: "Open the deployed site instead of a dashboard page (default env: production)",
},
{ flag: "--studio", description: "Open Veryfront Studio" },
{ flag: "--json", description: "Output URL as JSON instead of opening" },
Expand All @@ -21,6 +25,9 @@ export const openHelp: CommandHelp = {
"veryfront open",
"veryfront open --project my-project",
"veryfront open --env staging",
"veryfront open --site",
"veryfront open --site --env staging",
"veryfront open --site --json",
"veryfront open --studio",
"veryfront open --json",
],
Expand Down
51 changes: 51 additions & 0 deletions cli/commands/open/command.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { defineSchema, lazySchema } from "veryfront/schemas";
import type { InferSchema } from "veryfront/extensions/schema";
import { createArgParser } from "#cli/shared/args";
import { INVALID_ARGUMENT } from "veryfront/errors";

export const getOpenArgsSchema = defineSchema((v) =>
v.object({
env: v.string().optional(),
studio: v.boolean().default(false),
site: v.boolean().optional(),
projectSlug: v.string().optional(),
})
);
Expand All @@ -17,12 +19,61 @@ export type OpenOptions = InferSchema<ReturnType<typeof getOpenArgsSchema>>;
export const parseOpenArgs = createArgParser(OpenArgsSchema, {
env: { keys: ["env"], type: "string" },
studio: { keys: ["studio"], type: "boolean" },
site: { keys: ["site"], type: "boolean" },
projectSlug: { keys: ["project", "project-slug", "p"], type: "string" },
});

const DASHBOARD_BASE = "https://veryfront.com";

/** The environment `--site` targets when `--env` is absent. */
const DEFAULT_SITE_ENVIRONMENT = "production";

/**
* A single DNS label. `--site` is the only `open` path that puts a resolved
* value in the URL *authority* rather than its path, so it is the only one
* where a stray `/`, `?`, or `#` changes the origin: `evil.example/x` would
* build `https://evil.example/x.production.veryfront.com`, pushing the
* hard-coded suffix into the path and leaving a link Veryfront does not own.
* The slug is not always typed by the person running the command — it also
* comes from `veryfront.json` and the local project link, which arrive with a
* cloned repository — so it is validated rather than trusted.
*/
const SITE_HOSTNAME_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i;

/** The longest a single DNS label may be, as `push` also enforces. */
const MAX_HOSTNAME_LABEL_LENGTH = 63;

function assertSiteHostnameLabel(value: string, label: string): void {
if (
SITE_HOSTNAME_LABEL_PATTERN.test(value) &&
value.length <= MAX_HOSTNAME_LABEL_LENGTH
) {
return;
}

throw INVALID_ARGUMENT.create({
detail: `The ${label} "${value}" is not a DNS label, so it cannot name a deployed site. ` +
`Use letters, digits, and hyphens only.`,
});
}

/**
* The canonical Veryfront Cloud address of a deployed environment, the same
* `https://<slug>.<environment>.veryfront.com` form `deploy` falls back to when
* an environment carries no custom domain. `open` has no API token, so it
* cannot look a custom domain up; a project that has one reaches the same
* deployment through both names.
*/
function buildSiteUrl(projectSlug: string, environment: string): string {
assertSiteHostnameLabel(projectSlug, "project slug");
assertSiteHostnameLabel(environment, "environment");
return `https://${projectSlug}.${environment}.veryfront.com`;
}

export function buildUrl(projectSlug: string, options: OpenOptions): string {
if (options.site) {
return buildSiteUrl(projectSlug, options.env ?? DEFAULT_SITE_ENVIRONMENT);
}
Comment thread
kojiwakayama marked this conversation as resolved.
if (options.studio) {
return `${DASHBOARD_BASE}/studio/${projectSlug}`;
}
Expand Down
55 changes: 54 additions & 1 deletion cli/commands/open/handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import "#veryfront/schemas/_test-setup.ts";
import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts";
import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts";
import { describe, it } from "#veryfront/testing/bdd.ts";
import { join } from "veryfront/platform/path";
import { parseCliArgs } from "#cli/shared/args";
Expand Down Expand Up @@ -122,6 +122,53 @@ describe("Open Command", () => {
const url = buildUrl("custom-slug", { studio: false });
assertEquals(url, "https://veryfront.com/projects/custom-slug");
});

it("builds the deployed site URL with --site", () => {
const url = buildUrl("my-app", { studio: false, site: true });
assertEquals(url, "https://my-app.production.veryfront.com");
});

it("builds the deployed site URL for a named environment", () => {
const url = buildUrl("my-app", { env: "staging", studio: false, site: true });
assertEquals(url, "https://my-app.staging.veryfront.com");
});

it("keeps --site on the deployed site rather than a dashboard page", () => {
const url = buildUrl("my-app", { studio: true, site: true });
assertEquals(url, "https://my-app.production.veryfront.com");
});

it("refuses a project slug that would change the site origin", () => {
// `--site` is the only `open` path that interpolates into the URL
// authority, so a slug carrying `/`, `?`, or `#` pushes the hard-coded
// `.veryfront.com` suffix into the path and leaves an origin Veryfront
// does not own. The slug can come from a cloned repo's `veryfront.json`,
// so it is never trusted.
for (const slug of ["evil.example/x", "evil.example?x", "evil.example#x"]) {
assertThrows(
() => buildUrl(slug, { studio: false, site: true }),
Error,
"DNS label",
);
}
});

it("refuses an environment that would change the site origin", () => {
for (const env of ["attacker.example/", "a?b", "a#b"]) {
assertThrows(
() => buildUrl("my-app", { env, studio: false, site: true }),
Error,
"DNS label",
);
}
});

it("still builds dashboard URLs for a slug --site would reject", () => {
// Dashboard URLs put the slug in the path, where it cannot move the
// origin, so validation is scoped to `--site` and does not change them.
const url = buildUrl("evil.example/x", { studio: false });
assertEquals(url, "https://veryfront.com/projects/evil.example/x");
});
});

describe("JSON output", () => {
Expand All @@ -146,6 +193,12 @@ describe("Open Command", () => {
assertSuccess(result);
assertEquals(result.data.projectSlug, "my-project");
});

it("parses --site from raw open argv", () => {
const result = parseOpenArgs(parseCliArgs(["open", "--site"]));
assertSuccess(result);
assertEquals(result.data.site, true);
});
});

describe("resolveOpenProjectSlug", () => {
Expand Down
2 changes: 1 addition & 1 deletion docs/api-reference/veryfront/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ The CLI groups commands by category. Each command supports `--help` for its full
| `veryfront demo` | Interactive guided tour of Veryfront CLI |
| `veryfront init` | Initialize a new Veryfront project |
| `veryfront install` | Install AI assistant integrations (Cursor, Claude Code, etc.) |
| `veryfront open` | Open project URLs in the browser |
| `veryfront open` | Open the Cloud dashboard, or the deployed site with --site |
| `veryfront project` | Delete a cloud project and everything it owns |
| `veryfront start` | Run the production dashboard with proxy and TUI |
| `veryfront studio` | Open Veryfront Studio in browser |
Expand Down
18 changes: 14 additions & 4 deletions docs/getting-started/deploy-project.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,20 @@ curl -sSf -N -X POST <environment-url>/api/ag-ui \
-d '{"messages":[{"id":"1","role":"user","parts":[{"type":"text","text":"What is Veryfront in one sentence?"}]}]}'
```

`veryfront open` opens the project in the Cloud dashboard, where the deployment
is listed; `veryfront open --env production` opens the project's Environments
panel. Neither opens the deployed site, so use the environment URL Deploy printed
to check the running deployment.
If you did not record the URL Deploy printed, `veryfront open --site` opens the
deployed site, and `veryfront open --site --json` prints that URL for scripts:

```bash
veryfront open --site --json
```

```json
{ "success": true, "command": "open", "data": { "url": "https://<slug>.production.veryfront.com" } }
```

Without `--site`, `veryfront open` opens the project in the Cloud dashboard,
where the deployment is listed, and `veryfront open --env production` opens the
project's Environments panel. Neither opens the deployed site.

For an automated production workflow, see
[Deploy from CI](../guides/deploy-from-ci.md).
25 changes: 19 additions & 6 deletions docs/guides/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,23 @@ npx veryfront@latest deploy --branch feature-x --env staging
Deploy uses the last verified Push receipt and verifies the release was built
from that exact source digest before assigning it to the environment. If no Push
receipt exists, Deploy first runs a quiet Push so the first deployment still
works as one command. Deploy prints the environment URL; record it when
automation needs the deployed URL later. Use `npx veryfront@latest open` after
deployment to open the project in the Cloud dashboard, and
`npx veryfront@latest open --json` to print that dashboard URL. `open` resolves
the same project reference Push and Deploy use, including the local
`.veryfront/project.json` link. Dashboard URLs are built from the project slug,
works as one command. Deploy prints the environment URL.
`npx veryfront@latest open --site --env staging` opens that deployed environment
in a browser, and `npx veryfront@latest open --site --env staging --json` prints
its URL for automation. `--site` targets `production` unless `--env` names
another environment, so name the environment you deployed. Without `--site`, use
`npx veryfront@latest open` after deployment to open the project in the Cloud
dashboard, and `npx veryfront@latest open --json` to print that dashboard URL.

`--site` always builds the canonical
`https://<slug>.<environment>.veryfront.com` address, because `open` has no API
token with which to read the environment's configured domains. Deploy prints the
custom domain when the environment has one, so the two can differ in origin even
though both reach the same deployment. Automation that must use the custom domain
should record the URL Deploy printed rather than rebuild it from `open --site`.

`open` resolves the same project reference Push and Deploy use, including the
local `.veryfront/project.json` link. Dashboard URLs are built from the project slug,
so `open` skips an ID-only `VERYFRONT_PROJECT_ID` or `TENANT_PROJECT_ID`
reference and uses the link instead.

Expand Down Expand Up @@ -165,6 +176,8 @@ After `veryfront deploy`:
unconfirmed data-plane update is a warning after commit, not a failed deploy;
do not retry solely because of that warning.
- The environment URL Deploy printed serves the deployed page and API routes.
- `veryfront open --site` reaches that deployment at its canonical
`https://<slug>.<environment>.veryfront.com` address.
- `veryfront open` opens the project in the Cloud dashboard, not the deployed
site.
- The same page, API route, agent, workflow, task, or run path works in
Expand Down
15 changes: 15 additions & 0 deletions tests/docs/guide-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,21 @@ describe("guide content contracts", () => {
);
});

it("offers open --site as the way back to the deployed environment URL", async () => {
// The deploy docs tell readers to use the URL Deploy printed. A reader who
// did not record it needs a command that reproduces it, so both deploy
// pages must name `open --site` alongside the dashboard-only `open`.
for (
const path of [
"docs/getting-started/deploy-project.md",
"docs/guides/deploying.md",
]
) {
const text = await Deno.readTextFile(path);
assertStringIncludes(text, "open --site");
}
});

it("uses serve for local production builds", async () => {
const docs = [
"docs/getting-started/deploy-project.md",
Expand Down