diff --git a/cli/commands/open/command-help.ts b/cli/commands/open/command-help.ts index 7f4316c328..94e5cc65fd 100644 --- a/cli/commands/open/command-help.ts +++ b/cli/commands/open/command-help.ts @@ -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: [ { @@ -12,7 +12,11 @@ export const openHelp: CommandHelp = { }, { flag: "--env ", - 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" }, @@ -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", ], diff --git a/cli/commands/open/command.ts b/cli/commands/open/command.ts index f25af43863..644e389d67 100644 --- a/cli/commands/open/command.ts +++ b/cli/commands/open/command.ts @@ -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(), }) ); @@ -17,12 +19,61 @@ export type OpenOptions = InferSchema>; 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://..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); + } if (options.studio) { return `${DASHBOARD_BASE}/studio/${projectSlug}`; } diff --git a/cli/commands/open/handler.test.ts b/cli/commands/open/handler.test.ts index 73ad02a1f1..a390384364 100644 --- a/cli/commands/open/handler.test.ts +++ b/cli/commands/open/handler.test.ts @@ -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"; @@ -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", () => { @@ -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", () => { diff --git a/docs/api-reference/veryfront/cli.md b/docs/api-reference/veryfront/cli.md index 847402f68c..1b908ff0ea 100644 --- a/docs/api-reference/veryfront/cli.md +++ b/docs/api-reference/veryfront/cli.md @@ -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 | diff --git a/docs/getting-started/deploy-project.md b/docs/getting-started/deploy-project.md index 5245a60061..6e9fbd0acb 100644 --- a/docs/getting-started/deploy-project.md +++ b/docs/getting-started/deploy-project.md @@ -110,10 +110,20 @@ curl -sSf -N -X POST /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://.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). diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index 541290f51e..49bd3cac3f 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -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://..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. @@ -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://..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 diff --git a/tests/docs/guide-content.test.ts b/tests/docs/guide-content.test.ts index 63fc860aff..4896f86c79 100644 --- a/tests/docs/guide-content.test.ts +++ b/tests/docs/guide-content.test.ts @@ -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",