diff --git a/CHANGELOG.md b/CHANGELOG.md index e16aaceb..f2e18dab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - App visibility: `base44 visibility ` sets it on the server directly (accepts `--app-id` to target any app). Also configurable via `"visibility"` in `config.jsonc`, which `base44 deploy` applies. New projects scaffold `"visibility": "public"`. - `base44 build` runs the site's `buildCommand` with `VITE_BASE44_APP_ID` injected, so built bundles always carry the linked app's id. - `base44 deploy` (and `base44 site deploy`) can now build first: interactive runs ask, and `--build` / `--no-build` pre-answer the prompt. +- `base44 dev --remote` serves the frontend against the production backend: it runs `site.serveCommand` with `VITE_BASE44_APP_ID` and `VITE_BASE44_APP_BASE_URL` pointing at the app's own published URL, without starting the local backend. Fails if the app has no published URL. ### Fixed diff --git a/packages/cli/src/cli/commands/dev.ts b/packages/cli/src/cli/commands/dev.ts index 3018839f..c4b5e6d7 100644 --- a/packages/cli/src/cli/commands/dev.ts +++ b/packages/cli/src/cli/commands/dev.ts @@ -2,7 +2,10 @@ import type { Command } from "commander"; import { createDevLogger } from "@/cli/dev/createDevLogger.js"; import { createDevServer } from "@/cli/dev/dev-server/main.js"; import type { ServeRunner } from "@/cli/dev/dev-server/serve-runner.js"; -import { createServeCommandRunner } from "@/cli/dev/serve-command-runner.js"; +import { + createServeCommandRunner, + type ServeCommandRunnerOptions, +} from "@/cli/dev/serve-command-runner.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { type AppIdOptions, Base44Command, theme } from "@/cli/utils/index.js"; import { getDenoWrapperPath } from "@/core/assets.js"; @@ -13,6 +16,7 @@ import { readProjectConfig } from "@/core/project/config.js"; interface DevOptions { port?: string; + remote?: boolean; } interface LinkedApp { @@ -20,6 +24,11 @@ interface LinkedApp { projectRoot: string; } +type ConfiguredSite = Pick< + ServeCommandRunnerOptions, + "serveCommand" | "projectRoot" +>; + function localServerUrl(port: number): string { return `http://localhost:${port}`; } @@ -31,6 +40,12 @@ function validateDevOptions(command: Command): void { `base44 dev cannot be used with --app-id or ${BASE44_APP_ID_ENV_VAR}.`, ); } + const { port, remote } = command.opts(); + if (remote && port !== undefined) { + command.error( + "--port applies to the local backend, which --remote does not start.", + ); + } } function requireLinkedProject({ app }: CLIContext): LinkedApp { @@ -42,30 +57,25 @@ function requireLinkedProject({ app }: CLIContext): LinkedApp { return { id: app.id, projectRoot: app.projectRoot }; } -async function createConfiguredServeRunner( +async function resolveConfiguredSite( app: LinkedApp, - backendUrl: string, -): Promise { +): Promise { const { project } = await readProjectConfig(app.projectRoot); const serveCommand = project.site?.serveCommand; - if (!serveCommand) { - return undefined; - } - return createServeCommandRunner({ - serveCommand, - projectRoot: project.root, - appId: app.id, - appBaseUrl: backendUrl, - }); + return serveCommand ? { serveCommand, projectRoot: project.root } : undefined; +} + +function stopRunnerOnProcessSignals(runner: ServeRunner): void { + const stop = () => void runner.stop(); + process.on("SIGINT", stop); + process.on("SIGTERM", stop); } function startServeCommand( runner: ServeRunner, backend: { url: string; shutdown: () => Promise }, ): void { - const stop = () => void runner.stop(); - process.on("SIGINT", stop); - process.on("SIGTERM", stop); + stopRunnerOnProcessSignals(runner); // If the frontend dies, tear the whole dev environment down. runner.onExit(() => { @@ -78,11 +88,35 @@ function startServeCommand( runner.start(); } -async function devAction( +async function remoteDevAction(app: LinkedApp): Promise { + const site = await resolveConfiguredSite(app); + if (!site) { + throw new ConfigInvalidError( + "base44 dev --remote serves the frontend against the production backend, but this project has no site.serveCommand in base44/config.jsonc.", + ); + } + + const appBaseUrl = await getSiteUrl(); + const runner = createServeCommandRunner({ + ...site, + appId: app.id, + appBaseUrl, + }); + stopRunnerOnProcessSignals(runner); + runner.onExit((code) => process.exit(code ?? 1)); + runner.start(); + + return { + outroMessage: `Frontend dev server targets ${theme.styles.bold(appBaseUrl)} — every write hits your live app`, + }; +} + +async function localDevAction( ctx: CLIContext, + app: LinkedApp, options: DevOptions, ): Promise { - const app = requireLinkedProject(ctx); + const site = await resolveConfiguredSite(app); const siteUrlPromise = getSiteUrl().catch(() => undefined); const backend = await createDevServer({ @@ -97,22 +131,40 @@ async function devAction( }); const backendUrl = localServerUrl(backend.port); - const runner = await createConfiguredServeRunner(app, backendUrl); - if (runner) { + if (site) { + const runner = createServeCommandRunner({ + ...site, + appId: app.id, + appBaseUrl: backendUrl, + }); startServeCommand(runner, { url: backendUrl, shutdown: backend.shutdown }); } - const outroMessage = runner + const outroMessage = site ? "Open your app using the frontend dev server URL" : `Dev server is available at ${theme.colors.links(backendUrl)}`; return { outroMessage }; } +async function devAction( + ctx: CLIContext, + options: DevOptions, +): Promise { + const app = requireLinkedProject(ctx); + return options.remote + ? remoteDevAction(app) + : localDevAction(ctx, app, options); +} + export function getDevCommand(): Command { return new Base44Command("dev") .description("Start the development server") .option("-p, --port ", "Port for the development server") + .option( + "--remote", + "Serve the frontend against the production backend instead of a local one", + ) .hook("preAction", validateDevOptions) .action(devAction); } diff --git a/packages/cli/src/cli/dev/serve-command-runner.ts b/packages/cli/src/cli/dev/serve-command-runner.ts index 6e0d414f..ce073eea 100644 --- a/packages/cli/src/cli/dev/serve-command-runner.ts +++ b/packages/cli/src/cli/dev/serve-command-runner.ts @@ -2,7 +2,7 @@ import { createDevLogger } from "@/cli/dev/createDevLogger.js"; import { ServeRunner } from "@/cli/dev/dev-server/serve-runner.js"; import { theme } from "@/cli/utils/index.js"; -interface ServeCommandRunnerOptions { +export interface ServeCommandRunnerOptions { serveCommand: string; projectRoot: string; appId: string; diff --git a/packages/cli/tests/cli/dev.spec.ts b/packages/cli/tests/cli/dev.spec.ts index 4908c448..92959937 100644 --- a/packages/cli/tests/cli/dev.spec.ts +++ b/packages/cli/tests/cli/dev.spec.ts @@ -102,6 +102,59 @@ describe("dev command", () => { expect(output).toContain("Backend running on http://localhost:"); }); + it("--remote runs the serveCommand against the app's published URL", async () => { + await t.givenLoggedInWithProject(fixture("with-serve-command")); + t.api.mockSiteUrl({ url: "https://my-app.base44.app" }); + + const handle = await t.runLive("dev", "--remote"); + await handle.waitForOutput(/SERVE_APP=/); + await handle.stop(); + + const output = handle.stdout.join(""); + expect(output).toContain(`SERVE_APP=${t.api.appId}`); + expect(output).toContain("URL=https://my-app.base44.app"); + expect(output).not.toContain("Backend running on"); + }); + + it("--remote fails when the app has no published URL", async () => { + await t.givenLoggedInWithProject(fixture("with-serve-command")); + t.api.mockSiteUrlError({ status: 404, body: { detail: "App not found" } }); + + const result = await t.run("dev", "--remote"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("site URL"); + }); + + it("--remote rejects --port", async () => { + await t.givenLoggedInWithProject(fixture("with-serve-command")); + + const result = await t.run("dev", "--remote", "--port", "5000"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "--port applies to the local backend, which --remote does not start", + ); + }); + + it("--remote fails without a site.serveCommand", async () => { + await t.givenLoggedInWithProject(fixture("full-project")); + + const result = await t.run("dev", "--remote"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("no site.serveCommand"); + }); + + it("--remote exits when the frontend exits", async () => { + await t.givenLoggedInWithProject(fixture("with-exiting-serve-command")); + + const handle = await t.runLive("dev", "--remote"); + const result = await handle.waitForExit(); + + expect(result.exitCode).not.toBe(0); + }); + it("tears the dev server down when the frontend exits", async () => { // The fixture's serveCommand prints, then exits non-zero shortly after. await t.givenLoggedInWithProject(fixture("with-exiting-serve-command"));