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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- App visibility: `base44 visibility <public|private|workspace>` 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

Expand Down
94 changes: 73 additions & 21 deletions packages/cli/src/cli/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -13,13 +16,19 @@ import { readProjectConfig } from "@/core/project/config.js";

interface DevOptions {
port?: string;
remote?: boolean;
}

interface LinkedApp {
id: string;
projectRoot: string;
}

type ConfiguredSite = Pick<
ServeCommandRunnerOptions,
"serveCommand" | "projectRoot"
>;

function localServerUrl(port: number): string {
return `http://localhost:${port}`;
}
Expand All @@ -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<DevOptions>();
if (remote && port !== undefined) {
command.error(
"--port applies to the local backend, which --remote does not start.",
);
}
}

function requireLinkedProject({ app }: CLIContext): LinkedApp {
Expand All @@ -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<ServeRunner | undefined> {
): Promise<ConfiguredSite | undefined> {
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> },
): 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(() => {
Expand All @@ -78,11 +88,35 @@ function startServeCommand(
runner.start();
}

async function devAction(
async function remoteDevAction(app: LinkedApp): Promise<RunCommandResult> {
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<RunCommandResult> {
const app = requireLinkedProject(ctx);
const site = await resolveConfiguredSite(app);
const siteUrlPromise = getSiteUrl().catch(() => undefined);

const backend = await createDevServer({
Expand All @@ -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<RunCommandResult> {
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 <number>", "Port for the development server")
.option(
"--remote",
"Serve the frontend against the production backend instead of a local one",
)
.hook("preAction", validateDevOptions)
.action(devAction);
}
2 changes: 1 addition & 1 deletion packages/cli/src/cli/dev/serve-command-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
53 changes: 53 additions & 0 deletions packages/cli/tests/cli/dev.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
Loading