Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
93d2ceb
feat(project): default the site block's build commands
ronnyrin Sep 22, 2026
de2f19d
feat(cli): install and serve a site through the CLI
ronnyrin Sep 22, 2026
8d9279d
fix(project): do not default outputDirectory, its absence is the signal
ronnyrin Sep 22, 2026
a58a92a
Merge remote-tracking branch 'origin/feat/site-config-defaults' into …
ronnyrin Sep 22, 2026
9c57bb8
fix(project): do not default outputDirectory, its absence is the signal
ronnyrin Sep 22, 2026
2642749
fix(cli): refuse an address site dev cannot deliver, and validate --port
ronnyrin Sep 22, 2026
1327012
Merge remote-tracking branch 'origin/feat/site-config-defaults' into …
ronnyrin Sep 22, 2026
de28d3f
fix(project): gate eject's build on the site block, not outputDirectory
ronnyrin Sep 22, 2026
9337161
Merge remote-tracking branch 'origin/feat/site-config-defaults' into …
ronnyrin Sep 22, 2026
14994d1
Merge remote-tracking branch 'origin/main' into feat/site-install-and…
ronnyrin Sep 22, 2026
db3e4a7
feat(site dev): default the bind address so a caller can pass nothing
ronnyrin Sep 23, 2026
ba6df59
refactor(schema): default the dev address in the config, not the command
ronnyrin Sep 23, 2026
88da65a
refactor(site dev): drop devHost and devPort, they were never project…
ronnyrin Sep 23, 2026
0d94269
refactor(site dev): the config is the only channel for the bind address
ronnyrin Sep 23, 2026
60b5706
style(tests): wrap a long assertion biome would reformat
ronnyrin Sep 23, 2026
fab0fe0
refactor(site dev): drop --backend-url, it had no caller
ronnyrin Sep 23, 2026
aea744f
refactor(site dev): run serveCommand as written, drop the address fields
ronnyrin Sep 24, 2026
f4434ef
refactor(cli): move `install` under `base44 site`
ronnyrin Sep 24, 2026
73e7fd3
Merge origin/main into feat/site-install-and-serve
ronnyrin Sep 24, 2026
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

### Added

- `base44 site install` runs the site's `installCommand` and nothing else, so a machine that only needs a project's dependencies no longer has to go through `create`, `scaffold` or `eject`. Local only: no login and no app id required.
- `base44 site dev` runs the site's `serveCommand` exactly as written, with no local backend and the frontend reaching its backend same-origin — the shape a hosted sandbox needs, where `base44 dev` (local backend) and `dev --remote` (the published app) are the developer-machine paths. It takes no arguments and appends nothing: where the dev server binds is the command's own business, and in a sandbox `@base44/vite-plugin` binds Base44 apps to `0.0.0.0:5173`. Serving is its whole job, so it falls back to `npm run dev` when the block names none.

- `base44 branches list --app-id <id> --json` lists main and active branch names for agents working outside Builder.

- Global `--branch <name>` targets sandbox commands at a specific app branch. Names resolve within the selected app; missing or ambiguous names fail. Other commands reject the flag explicitly; omitting it or using `--branch main` targets main.
Expand Down
7 changes: 1 addition & 6 deletions packages/cli/src/cli/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
createServeCommandRunner,
type ServeCommandRunnerOptions,
} from "@/cli/dev/serve-command-runner.js";
import { stopRunnerOnProcessSignals } from "@/cli/dev/stop-runner-on-signals.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 Down Expand Up @@ -65,12 +66,6 @@ async function resolveConfiguredSite(
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> },
Expand Down
58 changes: 58 additions & 0 deletions packages/cli/src/cli/commands/site/dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { Command } from "commander";
import { createServeCommandRunner } from "@/cli/dev/serve-command-runner.js";
import { stopRunnerOnProcessSignals } from "@/cli/dev/stop-runner-on-signals.js";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command } from "@/cli/utils/index.js";
import { ConfigInvalidError, InvalidInputError } from "@/core/errors.js";
import { readProjectConfig } from "@/core/project/index.js";

/**
* What to run when a project has a site but names no dev server. Not a schema
* default: `base44 dev` reads an absent `serveCommand` as "no frontend to run
* here", so only a command that exists purely to serve one may assume this.
*/
const DEFAULT_SERVE_COMMAND = "npm run dev";

async function siteDevAction(ctx: CLIContext): Promise<RunCommandResult> {
const { app } = ctx;
// Same shape as `base44 build`: the framework's own app-context step has
// already refused with actionable hints, so this is the type's guard.
if (!app?.projectRoot) {
throw new ConfigInvalidError(
"base44 site dev requires a linked local project. Run it from a project with base44/.app.jsonc.",
);
}

const { project } = await readProjectConfig(app.projectRoot);
const site = project.site;
if (!site) {
throw new InvalidInputError(
"This project has no 'site' block in base44/config.jsonc, so there is no frontend to serve. Add one naming its serveCommand; site dev falls back to \"npm run dev\".",
);
}

// Run as written: where the dev server binds is the command's own business.
// In a sandbox @base44/vite-plugin binds 0.0.0.0:5173 for Base44 apps; any
// other serveCommand must bind the address the sandbox exposes itself.
const command = site.serveCommand ?? DEFAULT_SERVE_COMMAND;
Comment thread
ronnyrin marked this conversation as resolved.

const runner = createServeCommandRunner({
serveCommand: command,
projectRoot: project.root,
appId: app.id,
});
stopRunnerOnProcessSignals(runner);
runner.onExit((code) => process.exit(code ?? 1));
runner.start();

return { outroMessage: `Frontend dev server running '${command}'` };
}

export function getSiteDevCommand(): Command {
// The frontend alone, reaching its backend same-origin — what a hosted sandbox
// needs. `base44 dev` is the developer-machine command: it also runs the
// backend, locally or (with --remote) the app's published one.
return new Base44Command("dev", { requireAuth: false })
.description("Run the site's dev server, with no local backend")
.action(siteDevAction);
}
4 changes: 4 additions & 0 deletions packages/cli/src/cli/commands/site/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { Command } from "commander";
import { getSiteDeployCommand } from "./deploy.js";
import { getSiteDevCommand } from "./dev.js";
import { getSiteInstallCommand } from "./install.js";
import { getSiteOpenCommand } from "./open.js";

export function getSiteCommand(): Command {
return new Command("site")
.description("Manage app site (frontend app)")
.addCommand(getSiteDeployCommand())
.addCommand(getSiteDevCommand())
.addCommand(getSiteInstallCommand())
.addCommand(getSiteOpenCommand());
}
47 changes: 47 additions & 0 deletions packages/cli/src/cli/commands/site/install.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { Command } from "commander";
import { execa } from "execa";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command, theme } from "@/cli/utils/index.js";
import { ConfigNotFoundError } from "@/core/errors.js";
import { readProjectConfig } from "@/core/project/index.js";

async function installAction({
runTask,
}: CLIContext): Promise<RunCommandResult> {
const { project } = await readProjectConfig();
const installCommand = project.site?.installCommand;
if (!installCommand) {
throw new ConfigNotFoundError("No site install command found.", {
hints: [
{
message:
'Add a \'site\' block to your config.jsonc (e.g., "site": { "installCommand": "npm ci" }). Inside one, installCommand defaults to "npm install".',
},
],
});
}

await runTask(
"Installing site dependencies...",
() => execa({ cwd: project.root, shell: true })`${installCommand}`,
{
successMessage: "Dependencies installed",
errorMessage: "Install failed",
},
);

return {
outroMessage: `Installed with ${theme.styles.bold(installCommand)}`,
};
}

export function getSiteInstallCommand(): Command {
// Local only: no app to resolve and no API to call, so a machine that has
// never logged in (a build sandbox) can still install a project.
return new Base44Command("install", {
requireAuth: false,
requireAppContext: false,
})
.description("Install the site's dependencies with its configured command")
.action(installAction);
}
7 changes: 5 additions & 2 deletions packages/cli/src/cli/dev/serve-command-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ export interface ServeCommandRunnerOptions {
serveCommand: string;
projectRoot: string;
appId: string;
appBaseUrl: string;
/** Omitted when the caller has no backend to name — a frontend that reaches its
* backend same-origin (the Base44 vite plugin proxies `/api`) must not be told
* one, or the SDK would call across origins instead. */
appBaseUrl?: string;
}

export function createServeCommandRunner({
Expand All @@ -20,7 +23,7 @@ export function createServeCommandRunner({
cwd: projectRoot,
env: {
VITE_BASE44_APP_ID: appId,
VITE_BASE44_APP_BASE_URL: appBaseUrl,
...(appBaseUrl ? { VITE_BASE44_APP_BASE_URL: appBaseUrl } : {}),
},
logger: createDevLogger("frontend", theme.colors.base44Orange),
});
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/cli/dev/stop-runner-on-signals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import process from "node:process";
import type { ServeRunner } from "@/cli/dev/dev-server/serve-runner.js";

/** Tear the dev server down on Ctrl-C and on a terminating signal. */
export function stopRunnerOnProcessSignals(runner: ServeRunner): void {
const stop = () => void runner.stop();
process.on("SIGINT", stop);
process.on("SIGTERM", stop);
}
74 changes: 74 additions & 0 deletions packages/cli/tests/cli/site_dev.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { fixture, setupCLITests } from "./testkit/index.js";

describe("site dev command", () => {
const t = setupCLITests();

it("runs the serveCommand as written, appending nothing", async () => {
// Where the dev server binds is the command's own business: in a sandbox the
// vite plugin binds it, so nothing is added to the line.
await t.givenLoggedInWithProject(fixture("with-npm-serve-command"));

const handle = await t.runLive("site", "dev");
await handle.waitForOutput(/ARGS=/);
await handle.stop();

expect(handle.stdout.join("")).toContain("ARGS= APP=");
});

it("runs a serveCommand that takes no forwarded arguments", async () => {
// A bare binary used to be refused because the address could not be
// appended to it. With nothing appended there is nothing to refuse.
await t.givenLoggedInWithProject(fixture("with-serve-command"));

const handle = await t.runLive("site", "dev");
// Wait on the child's own line: the startup message echoes the command,
// which also contains "SERVE_APP=".
await handle.waitForOutput(new RegExp(`SERVE_APP=${t.api.appId}`));
await handle.stop();

expect(handle.stdout.join("")).toContain(`SERVE_APP=${t.api.appId}`);
});

it("takes no arguments", async () => {
await t.givenLoggedInWithProject(fixture("with-npm-serve-command"));

const result = await t.run("site", "dev", "--port", "5999");

t.expectResult(result).toFail();
});

it("serves without a login", async () => {
// The whole point of the command: a build sandbox that has never logged in.
await t.givenProject(fixture("with-npm-serve-command"));

const handle = await t.runLive("site", "dev");
await handle.waitForOutput(/ARGS=/);
await handle.stop();

expect(handle.stdout.join("")).toContain("ARGS=");
});

it("serves the frontend same-origin, with no backend url injected", async () => {
// A sandbox frontend reaches its backend through the vite plugin's /api
// proxy, so it must not be pointed anywhere else.
await t.givenLoggedInWithProject(fixture("with-npm-serve-command"));

const handle = await t.runLive("site", "dev");
await handle.waitForOutput(/ARGS=/);
await handle.stop();

const output = handle.stdout.join("");
expect(output).toContain(`APP=${t.api.appId}`);
expect(output).toContain("URL=undefined");
});

it("fails when the project has no site block", async () => {
await t.givenLoggedInWithProject(fixture("basic"));

const result = await t.run("site", "dev");

t.expectResult(result).toFail();
t.expectResult(result).toContain("no 'site' block");
});
});
43 changes: 43 additions & 0 deletions packages/cli/tests/cli/site_install.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { fixture, setupCLITests } from "./testkit/index.js";

describe("site install command", () => {
const t = setupCLITests();

it("runs the site's configured installCommand", async () => {
await t.givenLoggedInWithProject(fixture("with-installable-site"));

const result = await t.run("site", "install");

t.expectResult(result).toSucceed();
expect(await t.readProjectFile("install-marker.txt")).toBe("installed");
});

it("installs without a login", async () => {
// A build sandbox that has never logged in must still be able to install.
await t.givenProject(fixture("with-installable-site"));

const result = await t.run("site", "install");

t.expectResult(result).toSucceed();
expect(await t.readProjectFile("install-marker.txt")).toBe("installed");
});

it("fails when the installCommand fails", async () => {
await t.givenLoggedInWithProject(fixture("with-failing-install"));

const result = await t.run("site", "install");

t.expectResult(result).toFail();
t.expectResult(result).toContain("Install failed");
});

it("fails when the project has no site block", async () => {
await t.givenLoggedInWithProject(fixture("basic"));

const result = await t.run("site", "install");

t.expectResult(result).toFail();
t.expectResult(result).toContain("No site install command found");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "Failing Install Project",
"site": {
"installCommand": "node -e \"process.exit(1)\""
}
}
5 changes: 5 additions & 0 deletions packages/cli/tests/fixtures/with-failing-install/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "failing-install-project",
"private": true,
"version": "0.0.0"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "Installable Site Project",
"site": {
"installCommand": "node -e \"require('fs').writeFileSync('install-marker.txt', 'installed')\""
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "Npm Serve Command Project",
// Empty on purpose: `site dev` falls back to `npm run dev`, which is what
// forwards an address through `--`. The schema does not default it — an
// absent serveCommand is how `base44 dev` knows to run the backend alone.
"site": {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "npm-serve-command-fixture",
"private": true,
"scripts": {
"dev": "node serve.js"
}
}
7 changes: 7 additions & 0 deletions packages/cli/tests/fixtures/with-npm-serve-command/serve.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Stands in for a real dev server: reports the arguments it was handed and the
// env it was given, then stays up until the runner stops it.
const args = process.argv.slice(2).join(" ");
console.log(
`ARGS=${args} APP=${process.env.VITE_BASE44_APP_ID} URL=${process.env.VITE_BASE44_APP_BASE_URL}`,
);
setInterval(() => {}, 1000);
Loading