From 93d2ceb50b94d496807cfcb89629492ff0bb49d7 Mon Sep 17 00:00:00 2001 From: ronnyr Date: Tue, 22 Sep 2026 12:18:05 +0300 Subject: [PATCH 01/12] feat(project): default the site block's build commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `site` block had to spell out every command, and the platform that runs these projects kept its own copy of the same values to fill the gaps. Defaults live here now, matching what `base44 create` scaffolds: install `npm install`, build `npm run build`, output `./dist`. A block only names what its project does differently. `serveCommand` is deliberately left undefaulted: `base44 dev` reads its absence as "no frontend to run here" and runs the backend alone, and a default would spawn a dev server for every site block — one that fails immediately takes the backend down with it. Commands that exist only to serve a frontend default it themselves. `site` itself stays optional, so "is there a site?" still keys on the block: a backend-only project omits it and has no site. Inside a block the build fields always resolve, which moves two refusals from "this field is missing" to "there is no site block" — `base44 build` and `site deploy` reword their hints, and `deploy` reads the block rather than one field. Projects that declared a partial block change behaviour: they now build and deploy on the defaults instead of being refused. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018G9vtxAUJV6aZ7FYv5cRjL --- CHANGELOG.md | 1 + docs/resources.md | 2 +- .../src/cli/commands/project/site-build.ts | 2 +- packages/cli/src/cli/commands/site/deploy.ts | 2 +- packages/cli/src/core/project/deploy.ts | 4 ++- packages/cli/src/core/project/schema.ts | 16 +++++++-- packages/cli/tests/cli/build.spec.ts | 19 ++++------- packages/cli/tests/core/project.spec.ts | 33 +++++++++++++++++++ .../with-site-defaults/base44/config.jsonc | 5 +++ .../fixtures/with-site-defaults/package.json | 7 ++++ 10 files changed, 71 insertions(+), 20 deletions(-) create mode 100644 packages/cli/tests/fixtures/with-site-defaults/base44/config.jsonc create mode 100644 packages/cli/tests/fixtures/with-site-defaults/package.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 314365c3..95ffbb38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Changed +- The `site` block's build commands now default to the conventions `base44 create` scaffolds, so a block only has to name what a project does differently: `installCommand` `npm install`, `buildCommand` `npm run build`, `outputDirectory` `./dist`. `site` itself stays optional — a backend-only project omits it and still has no site — and `serveCommand` is not defaulted, so `base44 dev` still runs the backend alone for a project that names no dev server. Projects that declared a partial block change behaviour: `base44 build` and `deploy --build` now build them instead of refusing, and `base44 deploy` deploys a site. The "add `site.`" errors now fire only when there is no `site` block at all. - `base44 sandbox` help now explains that writes are committed but not checkpointed: a Restore or Revert in the builder rolls the app back to the last checkpoint and discards everything after it, so run `base44 sandbox checkpoint` after each unit of work and before stopping. The note appears on `sandbox`, `sandbox write`, `sandbox edit`, `sandbox run`, and `sandbox checkpoint`. - The `backend-and-client` template now scaffolds the same client convention editor-created apps use: `@base44/vite-plugin` + `src/lib/app-params.js`, with the SDK client on same-origin `/api` (`serverUrl: ''`). Under `base44 dev` the plugin proxies `/api` to the local dev backend, so scaffolded apps get local entities and functions; the app id is injected via `VITE_BASE44_APP_ID` by `base44 dev`, `base44 dev --remote`, and the build/deploy commands instead of being baked into source. diff --git a/docs/resources.md b/docs/resources.md index 91a83a01..b413c4f4 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -122,7 +122,7 @@ const viaDeployments = deploymentsApiEnabled(); ``` - Gate on → the deployments API, see [deployments.md](deployments.md). Whether the build carries a worker changes what that flow sends, never which flow runs, and a worker brings its own assets directory — so the command may pass a null `outputDir`. -- Gate off → the legacy tar.gz path: tar.gz `site.outputDirectory` and upload via `POST /api/apps/{app_id}/deploy-dist`. This is the flow that requires the config field, and the one that raises "No site configuration found." +- Gate off → the legacy tar.gz path: tar.gz `site.outputDirectory` and upload via `POST /api/apps/{app_id}/deploy-dist`. This is the flow that requires the config field — it defaults to `./dist` inside a `site` block, so "No site configuration found." is raised only for a project with no block at all. Each flow validates its own inputs, so the decision itself is a boolean and needs nothing from the tree. diff --git a/packages/cli/src/cli/commands/project/site-build.ts b/packages/cli/src/cli/commands/project/site-build.ts index ef96e0b4..82e20876 100644 --- a/packages/cli/src/cli/commands/project/site-build.ts +++ b/packages/cli/src/cli/commands/project/site-build.ts @@ -19,7 +19,7 @@ export async function runSiteBuild( hints: [ { message: - 'Add \'site.buildCommand\' to your config.jsonc (e.g., "site": { "buildCommand": "npm run build" })', + 'Add a \'site\' block to your config.jsonc (e.g., "site": { "buildCommand": "npm run build" }). Inside one, buildCommand defaults to "npm run build".', }, ], }); diff --git a/packages/cli/src/cli/commands/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index c5d2c267..eaf3af9f 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -126,7 +126,7 @@ async function deployTarball( hints: [ { message: - 'Add \'site.outputDirectory\' to your config.jsonc (e.g., "site": { "outputDirectory": "dist" })', + 'Add a \'site\' block to your config.jsonc (e.g., "site": { "outputDirectory": "dist" }). Inside one, outputDirectory defaults to "./dist".', }, ], }); diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 4a671197..ddfc03f6 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -40,7 +40,9 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { connectors, authConfig, } = projectData; - const hasSite = Boolean(project.site?.outputDirectory); + // The block, not the field: `outputDirectory` always resolves now (it defaults), + // so a project declares it has a site by having the block at all. + const hasSite = project.site !== undefined; const hasEntities = entities.length > 0; const hasFunctions = functions.length > 0; const hasActors = actors.length > 0; diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 42041acf..849fa89a 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -12,11 +12,21 @@ export const TemplatesConfigSchema = z.object({ }); export type Template = z.infer; +// Defaults are the conventions `base44 create` scaffolds, so a `site` block only +// has to name what this project does differently. `site` itself stays optional: +// a backend-only project omits it and has no site at all, which is what every +// "is there a site?" check keys on. +// +// `serveCommand` is deliberately NOT defaulted. `base44 dev` reads its absence as +// "this project has no frontend to run here" and runs the backend alone; with a +// default it would spawn one for every site block, and a dev server that fails +// immediately takes the backend down with it. Commands that exist only to serve a +// frontend default it themselves, where the intent is unambiguous. const SiteConfigSchema = z.object({ - buildCommand: z.string().optional(), + buildCommand: z.string().optional().default("npm run build"), serveCommand: z.string().optional(), - outputDirectory: z.string().optional(), - installCommand: z.string().optional(), + outputDirectory: z.string().optional().default("./dist"), + installCommand: z.string().optional().default("npm install"), }); const PluginMetadataSchema = z.object({ diff --git a/packages/cli/tests/cli/build.spec.ts b/packages/cli/tests/cli/build.spec.ts index a7d758a1..bb05dc6c 100644 --- a/packages/cli/tests/cli/build.spec.ts +++ b/packages/cli/tests/cli/build.spec.ts @@ -15,13 +15,15 @@ describe("build command", () => { ); }); - it("fails when the project has no site.buildCommand", async () => { - await t.givenLoggedInWithProject(fixture("with-site")); + it("falls back to the default buildCommand when the site block sets none", async () => { + await t.givenLoggedInWithProject(fixture("with-site-defaults")); const result = await t.run("build"); - t.expectResult(result).toFail(); - t.expectResult(result).toContain("No site build command found"); + t.expectResult(result).toSucceed(); + expect(await t.readProjectFile("build-env.txt")).toBe( + `BUILD_APP=${t.api.appId}`, + ); }); it("fails when the buildCommand fails", async () => { @@ -104,15 +106,6 @@ describe("deploy --build", () => { t.expectResult(result).toContain("Build failed"); }); - it("--build fails when the project has no site.buildCommand", async () => { - await t.givenLoggedInWithProject(fixture("with-site")); - - const result = await t.run("deploy", "--yes", "--build"); - - t.expectResult(result).toFail(); - t.expectResult(result).toContain("No site build command found"); - }); - it("--build fails when the project has no site configuration", async () => { await t.givenLoggedInWithProject(fixture("with-entities")); diff --git a/packages/cli/tests/core/project.spec.ts b/packages/cli/tests/core/project.spec.ts index 9e9e0def..8bfeb474 100644 --- a/packages/cli/tests/core/project.spec.ts +++ b/packages/cli/tests/core/project.spec.ts @@ -23,6 +23,39 @@ describe("readProjectConfig", () => { expect(result.agents).toEqual([]); }); + it("defaults the site commands a block does not name", async () => { + const result = await readProjectConfig(resolve(FIXTURES_DIR, "with-site")); + + // The one field the fixture names survives; the rest come from the schema. + expect(result.project.site).toEqual({ + outputDirectory: "site-output", + buildCommand: "npm run build", + installCommand: "npm install", + }); + }); + + it("defaults every site command for an empty block", async () => { + const result = await readProjectConfig( + resolve(FIXTURES_DIR, "with-site-defaults"), + ); + + expect(result.project.site).toEqual({ + outputDirectory: "./dist", + buildCommand: "npm run build", + installCommand: "npm install", + }); + + // Not defaulted: `base44 dev` runs the backend alone without one. + expect(result.project.site?.serveCommand).toBeUndefined(); + }); + + it("leaves a project with no site block without a site", async () => { + // What every "is there a site?" check keys on — a backend-only project. + const result = await readProjectConfig(resolve(FIXTURES_DIR, "basic")); + + expect(result.project.site).toBeUndefined(); + }); + it("reads project with entities", async () => { const result = await readProjectConfig( resolve(FIXTURES_DIR, "with-entities"), diff --git a/packages/cli/tests/fixtures/with-site-defaults/base44/config.jsonc b/packages/cli/tests/fixtures/with-site-defaults/base44/config.jsonc new file mode 100644 index 00000000..0f911f76 --- /dev/null +++ b/packages/cli/tests/fixtures/with-site-defaults/base44/config.jsonc @@ -0,0 +1,5 @@ +{ + "name": "Site Defaults Project", + // A site block that names nothing: every command comes from the schema defaults. + "site": {} +} diff --git a/packages/cli/tests/fixtures/with-site-defaults/package.json b/packages/cli/tests/fixtures/with-site-defaults/package.json new file mode 100644 index 00000000..761301ac --- /dev/null +++ b/packages/cli/tests/fixtures/with-site-defaults/package.json @@ -0,0 +1,7 @@ +{ + "name": "site-defaults-fixture", + "private": true, + "scripts": { + "build": "node -e \"require('fs').writeFileSync('build-env.txt', 'BUILD_APP=' + process.env.VITE_BASE44_APP_ID)\"" + } +} From de2f19db8e535dfafc93b2fc369533e90b6cd441 Mon Sep 17 00:00:00 2001 From: ronnyr Date: Tue, 22 Sep 2026 12:30:47 +0300 Subject: [PATCH 02/12] feat(cli): install and serve a site through the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `installCommand` and `serveCommand` were fields no command would run on their own: install only happened inside `create`, `scaffold` and `eject`, and serve only inside `base44 dev`, which also boots a local backend on 4400 and points the frontend at it. A host that already has a backend and just needs this project installed and served had to compose npm itself. `base44 install` runs `installCommand` and nothing else — local only, so a machine that has never logged in can use it. `base44 site dev` runs `serveCommand` against a backend the caller names, and since serving is its whole job it falls back to `npm run dev` where the schema deliberately does not. `--host`/`--port` are appended through the project's new `devHostFlag` (`--host`, or Next's `--hostname`). Only an `npm run` invocation can take them — `--` is what forwards arguments, and a bare `vite` would read it as its own — so anything else passes through untouched and says so rather than silently serving an unreachable address. `base44 dev` and `dev --remote` are unchanged; the signal handling they had is now shared rather than copied. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018G9vtxAUJV6aZ7FYv5cRjL --- CHANGELOG.md | 5 + packages/cli/src/cli/commands/dev.ts | 7 +- .../cli/src/cli/commands/project/build.ts | 5 +- .../cli/src/cli/commands/project/install.ts | 47 +++++++++ packages/cli/src/cli/commands/site/dev.ts | 96 +++++++++++++++++++ packages/cli/src/cli/commands/site/index.ts | 2 + .../cli/src/cli/dev/serve-command-runner.ts | 7 +- .../cli/src/cli/dev/stop-runner-on-signals.ts | 9 ++ packages/cli/src/cli/program.ts | 2 + packages/cli/src/core/project/schema.ts | 5 + packages/cli/src/core/site/index.ts | 1 + packages/cli/src/core/site/serve-command.ts | 45 +++++++++ packages/cli/tests/cli/build.spec.ts | 9 ++ packages/cli/tests/cli/install.spec.ts | 24 +++++ packages/cli/tests/cli/site_dev.spec.ts | 90 +++++++++++++++++ packages/cli/tests/core/project.spec.ts | 2 + packages/cli/tests/core/serve-command.spec.ts | 75 +++++++++++++++ .../with-installable-site/base44/config.jsonc | 6 ++ .../base44/config.jsonc | 6 ++ .../with-npm-serve-command/package.json | 7 ++ .../fixtures/with-npm-serve-command/serve.js | 7 ++ 21 files changed, 448 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/cli/commands/project/install.ts create mode 100644 packages/cli/src/cli/commands/site/dev.ts create mode 100644 packages/cli/src/cli/dev/stop-runner-on-signals.ts create mode 100644 packages/cli/src/core/site/serve-command.ts create mode 100644 packages/cli/tests/cli/install.spec.ts create mode 100644 packages/cli/tests/cli/site_dev.spec.ts create mode 100644 packages/cli/tests/core/serve-command.spec.ts create mode 100644 packages/cli/tests/fixtures/with-installable-site/base44/config.jsonc create mode 100644 packages/cli/tests/fixtures/with-npm-serve-command/base44/config.jsonc create mode 100644 packages/cli/tests/fixtures/with-npm-serve-command/package.json create mode 100644 packages/cli/tests/fixtures/with-npm-serve-command/serve.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 95ffbb38..f8188f76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`, and are reported as dropped when the command is not an `npm run` invocation that can forward them. +- `site.devHostFlag` says how a project's dev server spells its bind-address flag (`--host` by default, `--hostname` for Next, which exits on `--host`). Read only when `site dev` is asked to bind an address; the port flag is not configurable because every dev server spells it `--port`. + - `base44 branches list --app-id --json` lists main and active branch names for agents working outside Builder. - Global `--branch ` 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. diff --git a/packages/cli/src/cli/commands/dev.ts b/packages/cli/src/cli/commands/dev.ts index c4b5e6d7..92614eae 100644 --- a/packages/cli/src/cli/commands/dev.ts +++ b/packages/cli/src/cli/commands/dev.ts @@ -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"; @@ -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 }, diff --git a/packages/cli/src/cli/commands/project/build.ts b/packages/cli/src/cli/commands/project/build.ts index 747329ce..715174b5 100644 --- a/packages/cli/src/cli/commands/project/build.ts +++ b/packages/cli/src/cli/commands/project/build.ts @@ -26,7 +26,10 @@ async function buildAction(ctx: CLIContext): Promise { } 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); } diff --git a/packages/cli/src/cli/commands/project/install.ts b/packages/cli/src/cli/commands/project/install.ts new file mode 100644 index 00000000..94f29956 --- /dev/null +++ b/packages/cli/src/cli/commands/project/install.ts @@ -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 { + 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); +} diff --git a/packages/cli/src/cli/commands/site/dev.ts b/packages/cli/src/cli/commands/site/dev.ts new file mode 100644 index 00000000..08f0ef8f --- /dev/null +++ b/packages/cli/src/cli/commands/site/dev.ts @@ -0,0 +1,96 @@ +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 { InvalidInputError } from "@/core/errors.js"; +import { readProjectConfig } from "@/core/project/index.js"; +import { + DEFAULT_SERVE_COMMAND, + forwardsServeAddress, + withServeAddress, +} from "@/core/site/serve-command.js"; + +interface SiteDevOptions extends AppIdOptions { + backendUrl?: string; + host?: string; + port?: string; +} + +async function siteDevAction( + ctx: CLIContext, + options: SiteDevOptions, +): Promise { + const { app, log } = ctx; + if (!app) { + throw new InvalidInputError("No app id resolved for this project."); + } + + const port = options.port === undefined ? undefined : Number(options.port); + if (port !== undefined && !Number.isInteger(port)) { + throw new InvalidInputError( + `--port must be a whole number: ${options.port}`, + ); + } + + 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; inside it serveCommand defaults to \"npm run dev\".", + ); + } + + // Serving is this command's whole job, so an unnamed dev server is the + // convention rather than "nothing to run". + const serveCommand = site.serveCommand ?? DEFAULT_SERVE_COMMAND; + const command = withServeAddress(serveCommand, { + host: options.host, + port, + hostFlag: site.devHostFlag, + }); + // Said out loud rather than silently dropped: a caller that asked for an + // address and did not get one would otherwise find out from a preview that + // never loads. + if ( + (options.host || port !== undefined) && + !forwardsServeAddress(serveCommand) + ) { + log.warn( + `serveCommand '${serveCommand}' is not an 'npm run' invocation, so --host/--port were not passed to it. Put the address in serveCommand itself.`, + ); + } + + 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 ", + "Backend the frontend should call, injected as VITE_BASE44_APP_BASE_URL. Omit for a frontend that reaches its backend same-origin.", + ) + .option("--host
", "Address to bind, e.g. 0.0.0.0") + .option("--port ", "Port to bind") + .action(siteDevAction); +} diff --git a/packages/cli/src/cli/commands/site/index.ts b/packages/cli/src/cli/commands/site/index.ts index 2850e360..68b96ea4 100644 --- a/packages/cli/src/cli/commands/site/index.ts +++ b/packages/cli/src/cli/commands/site/index.ts @@ -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()); } diff --git a/packages/cli/src/cli/dev/serve-command-runner.ts b/packages/cli/src/cli/dev/serve-command-runner.ts index ce073eea..7a8603d4 100644 --- a/packages/cli/src/cli/dev/serve-command-runner.ts +++ b/packages/cli/src/cli/dev/serve-command-runner.ts @@ -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({ @@ -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), }); diff --git a/packages/cli/src/cli/dev/stop-runner-on-signals.ts b/packages/cli/src/cli/dev/stop-runner-on-signals.ts new file mode 100644 index 00000000..31b73810 --- /dev/null +++ b/packages/cli/src/cli/dev/stop-runner-on-signals.ts @@ -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); +} diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 070dfc74..647b098e 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -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"; @@ -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()); diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 849fa89a..45bf178a 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -27,6 +27,11 @@ const SiteConfigSchema = z.object({ serveCommand: z.string().optional(), outputDirectory: z.string().optional().default("./dist"), installCommand: z.string().optional().default("npm install"), + // How this project's dev server spells its bind-address flag: `--host` for + // Vite/Astro/Nuxt, `--hostname` for Next, which exits on `--host`. Read only + // when a caller asks `site dev` to bind a specific address. The port flag is + // not configurable — every one of them spells it `--port`. + devHostFlag: z.string().optional().default("--host"), }); const PluginMetadataSchema = z.object({ diff --git a/packages/cli/src/core/site/index.ts b/packages/cli/src/core/site/index.ts index f36431f2..5fa1e16d 100644 --- a/packages/cli/src/core/site/index.ts +++ b/packages/cli/src/core/site/index.ts @@ -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"; diff --git a/packages/cli/src/core/site/serve-command.ts b/packages/cli/src/core/site/serve-command.ts new file mode 100644 index 00000000..6ad8b2dc --- /dev/null +++ b/packages/cli/src/core/site/serve-command.ts @@ -0,0 +1,45 @@ +/** + * 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"; + +/** An npm script invocation, optionally prefixed to a subdirectory. Only these + * forward extra args to the underlying dev server through `--`. */ +const NPM_RUN_COMMAND = /^npm(\s+--prefix\s+\S+)?\s+run\s+\S+$/; + +export 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; +} + +/** + * `serveCommand` with a bind address appended, or unchanged when there is + * nothing to append or no way to append it. + * + * Only an `npm run` invocation gets the arguments: `--` is what forwards them + * to the script, and a bare binary (`vite`, `next dev`) would read a literal + * `--` as its own. Callers that need a specific address on a command outside + * that shape have to put it in `serveCommand` themselves. + */ +export function withServeAddress( + serveCommand: string, + { host, port, hostFlag }: ServeAddress, +): string { + const args = [ + ...(host ? [hostFlag, host] : []), + ...(port === undefined ? [] : ["--port", String(port)]), + ]; + if (args.length === 0 || !NPM_RUN_COMMAND.test(serveCommand.trim())) { + return serveCommand; + } + return `${serveCommand} -- ${args.join(" ")}`; +} + +export function forwardsServeAddress(serveCommand: string): boolean { + return NPM_RUN_COMMAND.test(serveCommand.trim()); +} diff --git a/packages/cli/tests/cli/build.spec.ts b/packages/cli/tests/cli/build.spec.ts index bb05dc6c..defa0833 100644 --- a/packages/cli/tests/cli/build.spec.ts +++ b/packages/cli/tests/cli/build.spec.ts @@ -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")); diff --git a/packages/cli/tests/cli/install.spec.ts b/packages/cli/tests/cli/install.spec.ts new file mode 100644 index 00000000..247a0df3 --- /dev/null +++ b/packages/cli/tests/cli/install.spec.ts @@ -0,0 +1,24 @@ +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("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"); + }); +}); diff --git a/packages/cli/tests/cli/site_dev.spec.ts b/packages/cli/tests/cli/site_dev.spec.ts new file mode 100644 index 00000000..a337af64 --- /dev/null +++ b/packages/cli/tests/cli/site_dev.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("site dev command", () => { + const t = setupCLITests(); + + it("serves the frontend against the given backend, with no local backend", async () => { + await t.givenLoggedInWithProject(fixture("with-npm-serve-command")); + + const handle = await t.runLive( + "site", + "dev", + "--backend-url", + "https://preview.example/api", + ); + await handle.waitForOutput(/ARGS=/); + await handle.stop(); + + const output = handle.stdout.join(""); + expect(output).toContain(`APP=${t.api.appId}`); + expect(output).toContain("URL=https://preview.example/api"); + // The point of the command: the caller's backend, not one started here. + expect(output).not.toContain("Backend running on"); + }); + + it("binds the address it is given", async () => { + await t.givenLoggedInWithProject(fixture("with-npm-serve-command")); + + const handle = await t.runLive( + "site", + "dev", + "--backend-url", + "https://preview.example/api", + "--host", + "0.0.0.0", + "--port", + "5173", + ); + await handle.waitForOutput(/ARGS=/); + await handle.stop(); + + expect(handle.stdout.join("")).toContain("ARGS=--host 0.0.0.0 --port 5173"); + }); + + it("warns when the serveCommand cannot take the address", async () => { + // A bare binary would read `--` as its own argument, so the address is + // dropped — loudly, since the caller asked for a reachable server. + await t.givenLoggedInWithProject(fixture("with-serve-command")); + + const handle = await t.runLive( + "site", + "dev", + "--backend-url", + "https://preview.example/api", + "--host", + "0.0.0.0", + ); + await handle.waitForOutput(/SERVE_APP=/); + const result = await handle.stop(); + + t.expectResult(result).toContain("were not passed to it"); + }); + + it("injects no backend url when the caller names none", async () => { + // A frontend that reaches its backend same-origin must not be handed one. + 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", + "--backend-url", + "https://preview.example/api", + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("no 'site' block"); + }); +}); diff --git a/packages/cli/tests/core/project.spec.ts b/packages/cli/tests/core/project.spec.ts index 8bfeb474..849a028d 100644 --- a/packages/cli/tests/core/project.spec.ts +++ b/packages/cli/tests/core/project.spec.ts @@ -31,6 +31,7 @@ describe("readProjectConfig", () => { outputDirectory: "site-output", buildCommand: "npm run build", installCommand: "npm install", + devHostFlag: "--host", }); }); @@ -43,6 +44,7 @@ describe("readProjectConfig", () => { outputDirectory: "./dist", buildCommand: "npm run build", installCommand: "npm install", + devHostFlag: "--host", }); // Not defaulted: `base44 dev` runs the backend alone without one. diff --git a/packages/cli/tests/core/serve-command.spec.ts b/packages/cli/tests/core/serve-command.spec.ts new file mode 100644 index 00000000..89b43df0 --- /dev/null +++ b/packages/cli/tests/core/serve-command.spec.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_SERVE_COMMAND, + forwardsServeAddress, + withServeAddress, +} from "@/core/site/serve-command.js"; + +describe("DEFAULT_SERVE_COMMAND", () => { + it("can take a bind address", () => { + // What `site dev` falls back to, so it has to be a command that accepts one. + expect(forwardsServeAddress(DEFAULT_SERVE_COMMAND)).toBe(true); + }); +}); + +describe("withServeAddress", () => { + it("appends the address to an npm script through --", () => { + expect( + withServeAddress("npm run dev", { + host: "0.0.0.0", + port: 5173, + hostFlag: "--host", + }), + ).toBe("npm run dev -- --host 0.0.0.0 --port 5173"); + }); + + it("uses the project's own spelling of the host flag", () => { + // Next exits on --host. + expect( + withServeAddress("npm run dev", { + host: "0.0.0.0", + hostFlag: "--hostname", + }), + ).toBe("npm run dev -- --hostname 0.0.0.0"); + }); + + it("appends a prefixed npm script too", () => { + expect( + withServeAddress("npm --prefix site run dev", { + port: 4173, + hostFlag: "--host", + }), + ).toBe("npm --prefix site run dev -- --port 4173"); + }); + + it("leaves the command alone when there is no address to add", () => { + expect(withServeAddress("npm run dev", { hostFlag: "--host" })).toBe( + "npm run dev", + ); + }); + + it("leaves a command that cannot forward arguments alone", () => { + // `vite -- --host` would read the -- as vite's own argument. + expect( + withServeAddress("vite", { host: "0.0.0.0", hostFlag: "--host" }), + ).toBe("vite"); + }); + + it.each([ + "npm run dev", + "npm --prefix site run dev", + " npm run dev ", + ])("forwards for %s", (command) => { + expect(forwardsServeAddress(command)).toBe(true); + }); + + it.each([ + "vite", + "next dev", + "npm run", + "yarn dev", + "npm install", + ])("does not forward for %s", (command) => { + expect(forwardsServeAddress(command)).toBe(false); + }); +}); diff --git a/packages/cli/tests/fixtures/with-installable-site/base44/config.jsonc b/packages/cli/tests/fixtures/with-installable-site/base44/config.jsonc new file mode 100644 index 00000000..3996512f --- /dev/null +++ b/packages/cli/tests/fixtures/with-installable-site/base44/config.jsonc @@ -0,0 +1,6 @@ +{ + "name": "Installable Site Project", + "site": { + "installCommand": "node -e \"require('fs').writeFileSync('install-marker.txt', 'installed')\"" + } +} diff --git a/packages/cli/tests/fixtures/with-npm-serve-command/base44/config.jsonc b/packages/cli/tests/fixtures/with-npm-serve-command/base44/config.jsonc new file mode 100644 index 00000000..5e7f3354 --- /dev/null +++ b/packages/cli/tests/fixtures/with-npm-serve-command/base44/config.jsonc @@ -0,0 +1,6 @@ +{ + "name": "Npm Serve Command Project", + // Empty on purpose: serveCommand defaults to `npm run dev`, which is what + // forwards an address through `--`. + "site": {} +} diff --git a/packages/cli/tests/fixtures/with-npm-serve-command/package.json b/packages/cli/tests/fixtures/with-npm-serve-command/package.json new file mode 100644 index 00000000..c69747ea --- /dev/null +++ b/packages/cli/tests/fixtures/with-npm-serve-command/package.json @@ -0,0 +1,7 @@ +{ + "name": "npm-serve-command-fixture", + "private": true, + "scripts": { + "dev": "node serve.js" + } +} diff --git a/packages/cli/tests/fixtures/with-npm-serve-command/serve.js b/packages/cli/tests/fixtures/with-npm-serve-command/serve.js new file mode 100644 index 00000000..a0028762 --- /dev/null +++ b/packages/cli/tests/fixtures/with-npm-serve-command/serve.js @@ -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); From 8d9279dc337dac53b6de760a2e6966443e195ad5 Mon Sep 17 00:00:00 2001 From: ronnyr Date: Tue, 22 Sep 2026 13:08:25 +0300 Subject: [PATCH 03/12] fix(project): do not default outputDirectory, its absence is the signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found that defaulting `outputDirectory` turns a field whose absence means "nothing built to upload" into one that always resolves, and two callers act on that: `deployAll` still keyed the upload on the field, so `base44 deploy` on a `"site": {"serveCommand": ...}` project (the shape of this repo's own with-serve-command fixture) would resolve `./dist` and either fail a previously-green deploy after pushing every other resource, or publish whatever happened to sit in `./dist` — a backend bundle, typically — as the app's site. `eject`'s guard `installCommand && buildCommand` became dead, so `eject --yes` would run a network install, a build and a deploy for any ejected app with a site block, unprompted. So `outputDirectory` joins `serveCommand` as deliberately undefaulted, which is the same rule applied consistently: only the two commands whose absence means nothing default. `hasResourcesToDeploy` needs no change and drops out of the diff; eject now keys on `outputDirectory` like every other caller. Added the deploy test that would have caught the first one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018G9vtxAUJV6aZ7FYv5cRjL --- CHANGELOG.md | 2 +- docs/deployments.md | 2 +- docs/resources.md | 2 +- .../cli/src/cli/commands/project/eject.ts | 13 ++++++----- packages/cli/src/cli/commands/site/deploy.ts | 2 +- packages/cli/src/core/project/deploy.ts | 4 +--- packages/cli/src/core/project/schema.ts | 22 +++++++++---------- packages/cli/tests/cli/deploy.spec.ts | 12 ++++++++++ packages/cli/tests/core/project.spec.ts | 5 +++-- 9 files changed, 38 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95ffbb38..a5510080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ ### Changed -- The `site` block's build commands now default to the conventions `base44 create` scaffolds, so a block only has to name what a project does differently: `installCommand` `npm install`, `buildCommand` `npm run build`, `outputDirectory` `./dist`. `site` itself stays optional — a backend-only project omits it and still has no site — and `serveCommand` is not defaulted, so `base44 dev` still runs the backend alone for a project that names no dev server. Projects that declared a partial block change behaviour: `base44 build` and `deploy --build` now build them instead of refusing, and `base44 deploy` deploys a site. The "add `site.`" errors now fire only when there is no `site` block at all. +- The `site` block's two commands now default to the conventions `base44 create` scaffolds, so a block only has to name what a project does differently: `installCommand` `npm install` and `buildCommand` `npm run build`. `site` itself stays optional, and `serveCommand` and `outputDirectory` are deliberately not defaulted — their absence says "no frontend to run here" and "nothing built to upload", so `base44 dev` still runs the backend alone and `base44 deploy` still skips the site step. For a project that declared a partial block, `base44 build` and `deploy --build` now build it instead of refusing, and the deploy flow may offer to build first. - `base44 sandbox` help now explains that writes are committed but not checkpointed: a Restore or Revert in the builder rolls the app back to the last checkpoint and discards everything after it, so run `base44 sandbox checkpoint` after each unit of work and before stopping. The note appears on `sandbox`, `sandbox write`, `sandbox edit`, `sandbox run`, and `sandbox checkpoint`. - The `backend-and-client` template now scaffolds the same client convention editor-created apps use: `@base44/vite-plugin` + `src/lib/app-params.js`, with the SDK client on same-origin `/api` (`serverUrl: ''`). Under `base44 dev` the plugin proxies `/api` to the local dev backend, so scaffolded apps get local entities and functions; the app id is injected via `VITE_BASE44_APP_ID` by `base44 dev`, `base44 dev --remote`, and the build/deploy commands instead of being baked into source. diff --git a/docs/deployments.md b/docs/deployments.md index f01ab6f1..026742c4 100644 --- a/docs/deployments.md +++ b/docs/deployments.md @@ -55,7 +55,7 @@ Entry = `main` from the wrangler config. With `no_bundle: true`, every file unde ## Command UX -**`base44 site deploy [--git-hash ] [--concurrency ] [--build|--no-build]`** — the optional build step is `maybeBuildBeforeDeploy` (`--build` forces it, `--no-build` skips it, otherwise an interactive ask whenever `site.buildCommand` exists). It runs **before** the deploy confirmation, so the prompt is the last gate before anything leaves the machine. Progress: "Found N static assets (M new)" → "Uploaded X of Y assets" → "Deploying worker (K modules)…" (only when there is one) → outro `Deployment (commit )`. Under `--json`, stdout is a single `{deploymentId, gitHash}` document. +**`base44 site deploy [--git-hash ] [--concurrency ] [--build|--no-build]`** — the optional build step is `maybeBuildBeforeDeploy` (`--build` forces it, `--no-build` skips it, otherwise an interactive ask whenever the project has a `site` block, since `buildCommand` defaults inside one). It runs **before** the deploy confirmation, so the prompt is the last gate before anything leaves the machine. Progress: "Found N static assets (M new)" → "Uploaded X of Y assets" → "Deploying worker (K modules)…" (only when there is one) → outro `Deployment (commit )`. Under `--json`, stdout is a single `{deploymentId, gitHash}` document. **"Site" is the only word for it in user-facing copy.** A site is whatever we deploy, worker or no worker, so the prompt, spinner, success line and errors say "site" and never distinguish the two — the distinction is ours, not the user's, and a deploy that reports itself differently depending on the build reads as two products. Internally the code says "worker" for the thing that may or may not be there. diff --git a/docs/resources.md b/docs/resources.md index b413c4f4..91a83a01 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -122,7 +122,7 @@ const viaDeployments = deploymentsApiEnabled(); ``` - Gate on → the deployments API, see [deployments.md](deployments.md). Whether the build carries a worker changes what that flow sends, never which flow runs, and a worker brings its own assets directory — so the command may pass a null `outputDir`. -- Gate off → the legacy tar.gz path: tar.gz `site.outputDirectory` and upload via `POST /api/apps/{app_id}/deploy-dist`. This is the flow that requires the config field — it defaults to `./dist` inside a `site` block, so "No site configuration found." is raised only for a project with no block at all. +- Gate off → the legacy tar.gz path: tar.gz `site.outputDirectory` and upload via `POST /api/apps/{app_id}/deploy-dist`. This is the flow that requires the config field, and the one that raises "No site configuration found." Each flow validates its own inputs, so the decision itself is a boolean and needs nothing from the tree. diff --git a/packages/cli/src/cli/commands/project/eject.ts b/packages/cli/src/cli/commands/project/eject.ts index ca87fd43..1d836bbf 100644 --- a/packages/cli/src/cli/commands/project/eject.ts +++ b/packages/cli/src/cli/commands/project/eject.ts @@ -155,11 +155,12 @@ async function eject( ); const { project } = await readProjectConfig(resolvedPath); - const installCommand = project.site?.installCommand; - const buildCommand = project.site?.buildCommand; + const site = project.site; - // Only offer deploy if the project has build commands configured - if (installCommand && buildCommand) { + // The commands default inside a `site` block, so `outputDirectory` is what says + // there is built output to upload. Without it `--yes` would install and build + // for a project with nothing to deploy. + if (site?.outputDirectory) { const shouldDeploy = options.yes ? true : await confirm({ @@ -170,10 +171,10 @@ async function eject( await runTask( "Installing dependencies...", async (updateMessage) => { - await execa({ cwd: resolvedPath, shell: true })`${installCommand}`; + await execa({ cwd: resolvedPath, shell: true })`${site.installCommand}`; updateMessage("Building project..."); - await execa({ cwd: resolvedPath, shell: true })`${buildCommand}`; + await execa({ cwd: resolvedPath, shell: true })`${site.buildCommand}`; }, { successMessage: theme.colors.base44Orange( diff --git a/packages/cli/src/cli/commands/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index eaf3af9f..c5d2c267 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -126,7 +126,7 @@ async function deployTarball( hints: [ { message: - 'Add a \'site\' block to your config.jsonc (e.g., "site": { "outputDirectory": "dist" }). Inside one, outputDirectory defaults to "./dist".', + 'Add \'site.outputDirectory\' to your config.jsonc (e.g., "site": { "outputDirectory": "dist" })', }, ], }); diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index ddfc03f6..4a671197 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -40,9 +40,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { connectors, authConfig, } = projectData; - // The block, not the field: `outputDirectory` always resolves now (it defaults), - // so a project declares it has a site by having the block at all. - const hasSite = project.site !== undefined; + const hasSite = Boolean(project.site?.outputDirectory); const hasEntities = entities.length > 0; const hasFunctions = functions.length > 0; const hasActors = actors.length > 0; diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 849fa89a..76080d5d 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -13,20 +13,20 @@ export const TemplatesConfigSchema = z.object({ export type Template = z.infer; // Defaults are the conventions `base44 create` scaffolds, so a `site` block only -// has to name what this project does differently. `site` itself stays optional: -// a backend-only project omits it and has no site at all, which is what every -// "is there a site?" check keys on. +// has to name what this project does differently. // -// `serveCommand` is deliberately NOT defaulted. `base44 dev` reads its absence as -// "this project has no frontend to run here" and runs the backend alone; with a -// default it would spawn one for every site block, and a dev server that fails -// immediately takes the backend down with it. Commands that exist only to serve a -// frontend default it themselves, where the intent is unambiguous. +// Only the two commands default. `serveCommand` and `outputDirectory` carry a +// meaning in their absence that a default would erase: no frontend to run here, +// and nothing built to upload. `base44 dev` runs the backend alone without the +// first, and `base44 deploy` skips the site step without the second — a default +// would spawn a dev server for every site block and upload whatever happened to +// sit in ./dist. Commands that exist only to serve or build a site supply their +// own fallback, where the intent is unambiguous. const SiteConfigSchema = z.object({ - buildCommand: z.string().optional().default("npm run build"), + buildCommand: z.string().default("npm run build"), serveCommand: z.string().optional(), - outputDirectory: z.string().optional().default("./dist"), - installCommand: z.string().optional().default("npm install"), + outputDirectory: z.string().optional(), + installCommand: z.string().default("npm install"), }); const PluginMetadataSchema = z.object({ diff --git a/packages/cli/tests/cli/deploy.spec.ts b/packages/cli/tests/cli/deploy.spec.ts index 1c4208bb..ab3b3313 100644 --- a/packages/cli/tests/cli/deploy.spec.ts +++ b/packages/cli/tests/cli/deploy.spec.ts @@ -70,6 +70,18 @@ describe("deploy command (unified)", () => { t.expectResult(help).toNotContain("--concurrency"); }); + it("does not deploy a site for a block that never named an output directory", async () => { + // A defaulted command must never turn into an upload: `./dist` here would + // publish whatever happened to be built, or fail a deploy that used to pass. + await t.givenLoggedInWithProject(fixture("with-serve-command")); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("No resources found to deploy"); + t.expectResult(result).toNotContain("Site from"); + }); + it("reports no resources when project is empty", async () => { await t.givenLoggedInWithProject(fixture("basic")); diff --git a/packages/cli/tests/core/project.spec.ts b/packages/cli/tests/core/project.spec.ts index 8bfeb474..9df7ed73 100644 --- a/packages/cli/tests/core/project.spec.ts +++ b/packages/cli/tests/core/project.spec.ts @@ -40,13 +40,14 @@ describe("readProjectConfig", () => { ); expect(result.project.site).toEqual({ - outputDirectory: "./dist", buildCommand: "npm run build", installCommand: "npm install", }); - // Not defaulted: `base44 dev` runs the backend alone without one. + // Neither defaults: their absence says "no frontend to run here" and + // "nothing built to upload", which a default would erase. expect(result.project.site?.serveCommand).toBeUndefined(); + expect(result.project.site?.outputDirectory).toBeUndefined(); }); it("leaves a project with no site block without a site", async () => { From 9c57bb802ae3d94be01c62898032e2ea2c3ae9a6 Mon Sep 17 00:00:00 2001 From: ronnyr Date: Tue, 22 Sep 2026 13:08:25 +0300 Subject: [PATCH 04/12] fix(project): do not default outputDirectory, its absence is the signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found that defaulting `outputDirectory` turns a field whose absence means "nothing built to upload" into one that always resolves, and two callers act on that: `deployAll` still keyed the upload on the field, so `base44 deploy` on a `"site": {"serveCommand": ...}` project (the shape of this repo's own with-serve-command fixture) would resolve `./dist` and either fail a previously-green deploy after pushing every other resource, or publish whatever happened to sit in `./dist` — a backend bundle, typically — as the app's site. `eject`'s guard `installCommand && buildCommand` became dead, so `eject --yes` would run a network install, a build and a deploy for any ejected app with a site block, unprompted. So `outputDirectory` joins `serveCommand` as deliberately undefaulted, which is the same rule applied consistently: only the two commands whose absence means nothing default. `hasResourcesToDeploy` needs no change and drops out of the diff; eject now keys on `outputDirectory` like every other caller. Added the deploy test that would have caught the first one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018G9vtxAUJV6aZ7FYv5cRjL --- CHANGELOG.md | 2 +- docs/deployments.md | 2 +- docs/resources.md | 2 +- .../cli/src/cli/commands/project/eject.ts | 19 +++++++++++----- packages/cli/src/cli/commands/site/deploy.ts | 2 +- packages/cli/src/core/project/deploy.ts | 4 +--- packages/cli/src/core/project/schema.ts | 22 +++++++++---------- packages/cli/tests/cli/deploy.spec.ts | 12 ++++++++++ packages/cli/tests/core/project.spec.ts | 5 +++-- 9 files changed, 44 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95ffbb38..a5510080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ ### Changed -- The `site` block's build commands now default to the conventions `base44 create` scaffolds, so a block only has to name what a project does differently: `installCommand` `npm install`, `buildCommand` `npm run build`, `outputDirectory` `./dist`. `site` itself stays optional — a backend-only project omits it and still has no site — and `serveCommand` is not defaulted, so `base44 dev` still runs the backend alone for a project that names no dev server. Projects that declared a partial block change behaviour: `base44 build` and `deploy --build` now build them instead of refusing, and `base44 deploy` deploys a site. The "add `site.`" errors now fire only when there is no `site` block at all. +- The `site` block's two commands now default to the conventions `base44 create` scaffolds, so a block only has to name what a project does differently: `installCommand` `npm install` and `buildCommand` `npm run build`. `site` itself stays optional, and `serveCommand` and `outputDirectory` are deliberately not defaulted — their absence says "no frontend to run here" and "nothing built to upload", so `base44 dev` still runs the backend alone and `base44 deploy` still skips the site step. For a project that declared a partial block, `base44 build` and `deploy --build` now build it instead of refusing, and the deploy flow may offer to build first. - `base44 sandbox` help now explains that writes are committed but not checkpointed: a Restore or Revert in the builder rolls the app back to the last checkpoint and discards everything after it, so run `base44 sandbox checkpoint` after each unit of work and before stopping. The note appears on `sandbox`, `sandbox write`, `sandbox edit`, `sandbox run`, and `sandbox checkpoint`. - The `backend-and-client` template now scaffolds the same client convention editor-created apps use: `@base44/vite-plugin` + `src/lib/app-params.js`, with the SDK client on same-origin `/api` (`serverUrl: ''`). Under `base44 dev` the plugin proxies `/api` to the local dev backend, so scaffolded apps get local entities and functions; the app id is injected via `VITE_BASE44_APP_ID` by `base44 dev`, `base44 dev --remote`, and the build/deploy commands instead of being baked into source. diff --git a/docs/deployments.md b/docs/deployments.md index f01ab6f1..026742c4 100644 --- a/docs/deployments.md +++ b/docs/deployments.md @@ -55,7 +55,7 @@ Entry = `main` from the wrangler config. With `no_bundle: true`, every file unde ## Command UX -**`base44 site deploy [--git-hash ] [--concurrency ] [--build|--no-build]`** — the optional build step is `maybeBuildBeforeDeploy` (`--build` forces it, `--no-build` skips it, otherwise an interactive ask whenever `site.buildCommand` exists). It runs **before** the deploy confirmation, so the prompt is the last gate before anything leaves the machine. Progress: "Found N static assets (M new)" → "Uploaded X of Y assets" → "Deploying worker (K modules)…" (only when there is one) → outro `Deployment (commit )`. Under `--json`, stdout is a single `{deploymentId, gitHash}` document. +**`base44 site deploy [--git-hash ] [--concurrency ] [--build|--no-build]`** — the optional build step is `maybeBuildBeforeDeploy` (`--build` forces it, `--no-build` skips it, otherwise an interactive ask whenever the project has a `site` block, since `buildCommand` defaults inside one). It runs **before** the deploy confirmation, so the prompt is the last gate before anything leaves the machine. Progress: "Found N static assets (M new)" → "Uploaded X of Y assets" → "Deploying worker (K modules)…" (only when there is one) → outro `Deployment (commit )`. Under `--json`, stdout is a single `{deploymentId, gitHash}` document. **"Site" is the only word for it in user-facing copy.** A site is whatever we deploy, worker or no worker, so the prompt, spinner, success line and errors say "site" and never distinguish the two — the distinction is ours, not the user's, and a deploy that reports itself differently depending on the build reads as two products. Internally the code says "worker" for the thing that may or may not be there. diff --git a/docs/resources.md b/docs/resources.md index b413c4f4..91a83a01 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -122,7 +122,7 @@ const viaDeployments = deploymentsApiEnabled(); ``` - Gate on → the deployments API, see [deployments.md](deployments.md). Whether the build carries a worker changes what that flow sends, never which flow runs, and a worker brings its own assets directory — so the command may pass a null `outputDir`. -- Gate off → the legacy tar.gz path: tar.gz `site.outputDirectory` and upload via `POST /api/apps/{app_id}/deploy-dist`. This is the flow that requires the config field — it defaults to `./dist` inside a `site` block, so "No site configuration found." is raised only for a project with no block at all. +- Gate off → the legacy tar.gz path: tar.gz `site.outputDirectory` and upload via `POST /api/apps/{app_id}/deploy-dist`. This is the flow that requires the config field, and the one that raises "No site configuration found." Each flow validates its own inputs, so the decision itself is a boolean and needs nothing from the tree. diff --git a/packages/cli/src/cli/commands/project/eject.ts b/packages/cli/src/cli/commands/project/eject.ts index ca87fd43..5a3d9d2b 100644 --- a/packages/cli/src/cli/commands/project/eject.ts +++ b/packages/cli/src/cli/commands/project/eject.ts @@ -155,11 +155,12 @@ async function eject( ); const { project } = await readProjectConfig(resolvedPath); - const installCommand = project.site?.installCommand; - const buildCommand = project.site?.buildCommand; + const site = project.site; - // Only offer deploy if the project has build commands configured - if (installCommand && buildCommand) { + // The commands default inside a `site` block, so `outputDirectory` is what says + // there is built output to upload. Without it `--yes` would install and build + // for a project with nothing to deploy. + if (site?.outputDirectory) { const shouldDeploy = options.yes ? true : await confirm({ @@ -170,10 +171,16 @@ async function eject( await runTask( "Installing dependencies...", async (updateMessage) => { - await execa({ cwd: resolvedPath, shell: true })`${installCommand}`; + await execa({ + cwd: resolvedPath, + shell: true, + })`${site.installCommand}`; updateMessage("Building project..."); - await execa({ cwd: resolvedPath, shell: true })`${buildCommand}`; + await execa({ + cwd: resolvedPath, + shell: true, + })`${site.buildCommand}`; }, { successMessage: theme.colors.base44Orange( diff --git a/packages/cli/src/cli/commands/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index eaf3af9f..c5d2c267 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -126,7 +126,7 @@ async function deployTarball( hints: [ { message: - 'Add a \'site\' block to your config.jsonc (e.g., "site": { "outputDirectory": "dist" }). Inside one, outputDirectory defaults to "./dist".', + 'Add \'site.outputDirectory\' to your config.jsonc (e.g., "site": { "outputDirectory": "dist" })', }, ], }); diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index ddfc03f6..4a671197 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -40,9 +40,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { connectors, authConfig, } = projectData; - // The block, not the field: `outputDirectory` always resolves now (it defaults), - // so a project declares it has a site by having the block at all. - const hasSite = project.site !== undefined; + const hasSite = Boolean(project.site?.outputDirectory); const hasEntities = entities.length > 0; const hasFunctions = functions.length > 0; const hasActors = actors.length > 0; diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 849fa89a..76080d5d 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -13,20 +13,20 @@ export const TemplatesConfigSchema = z.object({ export type Template = z.infer; // Defaults are the conventions `base44 create` scaffolds, so a `site` block only -// has to name what this project does differently. `site` itself stays optional: -// a backend-only project omits it and has no site at all, which is what every -// "is there a site?" check keys on. +// has to name what this project does differently. // -// `serveCommand` is deliberately NOT defaulted. `base44 dev` reads its absence as -// "this project has no frontend to run here" and runs the backend alone; with a -// default it would spawn one for every site block, and a dev server that fails -// immediately takes the backend down with it. Commands that exist only to serve a -// frontend default it themselves, where the intent is unambiguous. +// Only the two commands default. `serveCommand` and `outputDirectory` carry a +// meaning in their absence that a default would erase: no frontend to run here, +// and nothing built to upload. `base44 dev` runs the backend alone without the +// first, and `base44 deploy` skips the site step without the second — a default +// would spawn a dev server for every site block and upload whatever happened to +// sit in ./dist. Commands that exist only to serve or build a site supply their +// own fallback, where the intent is unambiguous. const SiteConfigSchema = z.object({ - buildCommand: z.string().optional().default("npm run build"), + buildCommand: z.string().default("npm run build"), serveCommand: z.string().optional(), - outputDirectory: z.string().optional().default("./dist"), - installCommand: z.string().optional().default("npm install"), + outputDirectory: z.string().optional(), + installCommand: z.string().default("npm install"), }); const PluginMetadataSchema = z.object({ diff --git a/packages/cli/tests/cli/deploy.spec.ts b/packages/cli/tests/cli/deploy.spec.ts index 1c4208bb..ab3b3313 100644 --- a/packages/cli/tests/cli/deploy.spec.ts +++ b/packages/cli/tests/cli/deploy.spec.ts @@ -70,6 +70,18 @@ describe("deploy command (unified)", () => { t.expectResult(help).toNotContain("--concurrency"); }); + it("does not deploy a site for a block that never named an output directory", async () => { + // A defaulted command must never turn into an upload: `./dist` here would + // publish whatever happened to be built, or fail a deploy that used to pass. + await t.givenLoggedInWithProject(fixture("with-serve-command")); + + const result = await t.run("deploy", "-y"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("No resources found to deploy"); + t.expectResult(result).toNotContain("Site from"); + }); + it("reports no resources when project is empty", async () => { await t.givenLoggedInWithProject(fixture("basic")); diff --git a/packages/cli/tests/core/project.spec.ts b/packages/cli/tests/core/project.spec.ts index 8bfeb474..9df7ed73 100644 --- a/packages/cli/tests/core/project.spec.ts +++ b/packages/cli/tests/core/project.spec.ts @@ -40,13 +40,14 @@ describe("readProjectConfig", () => { ); expect(result.project.site).toEqual({ - outputDirectory: "./dist", buildCommand: "npm run build", installCommand: "npm install", }); - // Not defaulted: `base44 dev` runs the backend alone without one. + // Neither defaults: their absence says "no frontend to run here" and + // "nothing built to upload", which a default would erase. expect(result.project.site?.serveCommand).toBeUndefined(); + expect(result.project.site?.outputDirectory).toBeUndefined(); }); it("leaves a project with no site block without a site", async () => { From 2642749bf5d8146408e0aa8f31636dc42d84ebdf Mon Sep 17 00:00:00 2001 From: ronnyr Date: Tue, 22 Sep 2026 13:27:57 +0300 Subject: [PATCH 05/12] fix(cli): refuse an address site dev cannot deliver, and validate --port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on this PR, plus the merge of #634's outputDirectory change. `--host`/`--port` were composed only onto an `npm run` invocation and otherwise warned and continued with exit 0. For this command's audience — a hosted sandbox — a warning in a stream nobody reads is not a signal: the dev server comes up on whatever address it likes and the CLI reports success. An address that cannot be delivered is now a refusal. `pnpm`, `yarn` and `bun run` join the forwarding shapes, since all three pass trailing arguments to a script and dropping the address for them was the common case. `withServeAddress` returns whether it dropped the address, so the caller cannot mistake "no address to append" for "the address went nowhere" — the difference being a preview that never loads. That removes the second evaluation of the same regex at the call site, and the predicate it needed. The script token was `\S+`, which matches `dev;evil` and `$(id)`: the predicate answered "this forwards arguments" for a line whose second command is what received them. It is a charset now. The composition also used the untrimmed string while the test used the trimmed one. `--port` went through `Number()`, so an empty string became port 0 — a random port, silently — and `0x10`, `1e3` and `5173.0` were all accepted. It is parsed in a Commander argParser now, digits only and in range. `devHostFlag` does not default in the schema, for the same reason `serveCommand` and `outputDirectory` do not: `site dev` is the only reader, and a default permanently erases the difference between a project that wrote `--host` and one that wrote nothing. `site dev` supplies the fallback. Also: knip was failing on an unused exported type, invisible because none of the five code-checking workflows run on a PR that targets another branch. Tests added for the unauthenticated path on both new commands, `--port` rejection, a failing install command and a custom `devHostFlag`; one assertion that could never fail is gone, and the fixture no longer claims the schema defaults `serveCommand`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018G9vtxAUJV6aZ7FYv5cRjL --- CHANGELOG.md | 4 +- packages/cli/src/cli/commands/site/dev.ts | 57 ++++++----- packages/cli/src/core/site/serve-command.ts | 71 ++++++++++---- packages/cli/tests/cli/install.spec.ts | 19 ++++ packages/cli/tests/cli/site_dev.spec.ts | 62 ++++++++---- packages/cli/tests/core/project.spec.ts | 8 +- packages/cli/tests/core/serve-command.spec.ts | 95 ++++++++++++------- .../with-failing-install/base44/config.jsonc | 6 ++ .../with-failing-install/package.json | 5 + .../base44/config.jsonc | 7 ++ .../with-hostname-serve-command/package.json | 7 ++ .../with-hostname-serve-command/serve.js | 7 ++ .../base44/config.jsonc | 5 +- 13 files changed, 251 insertions(+), 102 deletions(-) create mode 100644 packages/cli/tests/fixtures/with-failing-install/base44/config.jsonc create mode 100644 packages/cli/tests/fixtures/with-failing-install/package.json create mode 100644 packages/cli/tests/fixtures/with-hostname-serve-command/base44/config.jsonc create mode 100644 packages/cli/tests/fixtures/with-hostname-serve-command/package.json create mode 100644 packages/cli/tests/fixtures/with-hostname-serve-command/serve.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 577458b9..325af2e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,8 @@ - `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`, and are reported as dropped when the command is not an `npm run` invocation that can forward them. -- `site.devHostFlag` says how a project's dev server spells its bind-address flag (`--host` by default, `--hostname` for Next, which exits on `--host`). Read only when `site dev` is asked to bind an address; the port flag is not configurable because every dev server spells it `--port`. +- `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.devHostFlag` says how a project's dev server spells its bind-address flag (`--hostname` for Next, which exits on `--host`). Read only when `site dev` is asked to bind an address, which is why it is not a schema default; the port flag is not configurable because every dev server spells it `--port`. - `base44 branches list --app-id --json` lists main and active branch names for agents working outside Builder. diff --git a/packages/cli/src/cli/commands/site/dev.ts b/packages/cli/src/cli/commands/site/dev.ts index 08f0ef8f..936ff038 100644 --- a/packages/cli/src/cli/commands/site/dev.ts +++ b/packages/cli/src/cli/commands/site/dev.ts @@ -1,35 +1,45 @@ import type { Command } from "commander"; +import { InvalidArgumentError } 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 { InvalidInputError } from "@/core/errors.js"; +import { ConfigInvalidError, InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/project/index.js"; import { DEFAULT_SERVE_COMMAND, - forwardsServeAddress, withServeAddress, } from "@/core/site/serve-command.js"; interface SiteDevOptions extends AppIdOptions { backendUrl?: string; host?: string; - port?: string; + port?: number; +} + +function parsePort(value: string): number { + // `Number()` is not port validation: it turns "", " ", "0x10" and "1e3" into + // numbers, and an empty string into 0 — a random port, silently. + if (!/^\d+$/.test(value)) { + throw new InvalidArgumentError("must be a whole number"); + } + const port = Number(value); + if (port < 1 || port > 65535) { + throw new InvalidArgumentError("must be between 1 and 65535"); + } + return port; } async function siteDevAction( ctx: CLIContext, options: SiteDevOptions, ): Promise { - const { app, log } = ctx; - if (!app) { - throw new InvalidInputError("No app id resolved for this project."); - } - - const port = options.port === undefined ? undefined : Number(options.port); - if (port !== undefined && !Number.isInteger(port)) { - throw new InvalidInputError( - `--port must be a whole number: ${options.port}`, + 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.", ); } @@ -37,27 +47,24 @@ async function siteDevAction( 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; inside it serveCommand defaults to \"npm run dev\".", + "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 the // convention rather than "nothing to run". const serveCommand = site.serveCommand ?? DEFAULT_SERVE_COMMAND; - const command = withServeAddress(serveCommand, { + const { command, droppedAddress } = withServeAddress(serveCommand, { host: options.host, - port, + port: options.port, hostFlag: site.devHostFlag, }); - // Said out loud rather than silently dropped: a caller that asked for an - // address and did not get one would otherwise find out from a preview that - // never loads. - if ( - (options.host || port !== undefined) && - !forwardsServeAddress(serveCommand) - ) { - log.warn( - `serveCommand '${serveCommand}' is not an 'npm run' invocation, so --host/--port were not passed to it. Put the address in serveCommand itself.`, + // 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 --host/--port cannot be passed to it. Put the address in serveCommand itself, or use an 'npm run', 'pnpm', 'yarn' or 'bun run' script.`, ); } @@ -91,6 +98,6 @@ export function getSiteDevCommand(): Command { "Backend the frontend should call, injected as VITE_BASE44_APP_BASE_URL. Omit for a frontend that reaches its backend same-origin.", ) .option("--host
", "Address to bind, e.g. 0.0.0.0") - .option("--port ", "Port to bind") + .option("--port ", "Port to bind", parsePort) .action(siteDevAction); } diff --git a/packages/cli/src/core/site/serve-command.ts b/packages/cli/src/core/site/serve-command.ts index 6ad8b2dc..f64d0bf2 100644 --- a/packages/cli/src/core/site/serve-command.ts +++ b/packages/cli/src/core/site/serve-command.ts @@ -5,41 +5,72 @@ */ export const DEFAULT_SERVE_COMMAND = "npm run dev"; -/** An npm script invocation, optionally prefixed to a subdirectory. Only these - * forward extra args to the underlying dev server through `--`. */ -const NPM_RUN_COMMAND = /^npm(\s+--prefix\s+\S+)?\s+run\s+\S+$/; +/** What most dev servers call the bind-address flag. Next spells it `--hostname` + * and exits on `--host`, which is what `site.devHostFlag` is for. */ +const DEFAULT_HOST_FLAG = "--host"; -export interface ServeAddress { +// 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; + hostFlag?: string; +} + +function runnerFor(serveCommand: string) { + return SCRIPT_RUNNERS.find((runner) => runner.pattern.test(serveCommand)); } /** - * `serveCommand` with a bind address appended, or unchanged when there is - * nothing to append or no way to append it. + * `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. * - * Only an `npm run` invocation gets the arguments: `--` is what forwards them - * to the script, and a bare binary (`vite`, `next dev`) would read a literal - * `--` as its own. Callers that need a specific address on a command outside - * that shape have to put it in `serveCommand` themselves. + * 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, -): string { +): { command: string; droppedAddress: boolean } { + const command = serveCommand.trim(); const args = [ - ...(host ? [hostFlag, host] : []), + ...(host ? [hostFlag ?? DEFAULT_HOST_FLAG, host] : []), ...(port === undefined ? [] : ["--port", String(port)]), ]; - if (args.length === 0 || !NPM_RUN_COMMAND.test(serveCommand.trim())) { - return serveCommand; + if (args.length === 0) { + return { command, droppedAddress: false }; } - return `${serveCommand} -- ${args.join(" ")}`; -} - -export function forwardsServeAddress(serveCommand: string): boolean { - return NPM_RUN_COMMAND.test(serveCommand.trim()); + const runner = runnerFor(command); + if (!runner) { + return { command, droppedAddress: true }; + } + return { + command: `${command}${runner.separator}${args.join(" ")}`, + droppedAddress: false, + }; } diff --git a/packages/cli/tests/cli/install.spec.ts b/packages/cli/tests/cli/install.spec.ts index 247a0df3..3d254461 100644 --- a/packages/cli/tests/cli/install.spec.ts +++ b/packages/cli/tests/cli/install.spec.ts @@ -13,6 +13,25 @@ describe("install command", () => { 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")); diff --git a/packages/cli/tests/cli/site_dev.spec.ts b/packages/cli/tests/cli/site_dev.spec.ts index a337af64..1cfdc10d 100644 --- a/packages/cli/tests/cli/site_dev.spec.ts +++ b/packages/cli/tests/cli/site_dev.spec.ts @@ -19,8 +19,6 @@ describe("site dev command", () => { const output = handle.stdout.join(""); expect(output).toContain(`APP=${t.api.appId}`); expect(output).toContain("URL=https://preview.example/api"); - // The point of the command: the caller's backend, not one started here. - expect(output).not.toContain("Backend running on"); }); it("binds the address it is given", async () => { @@ -42,23 +40,55 @@ describe("site dev command", () => { expect(handle.stdout.join("")).toContain("ARGS=--host 0.0.0.0 --port 5173"); }); - it("warns when the serveCommand cannot take the address", async () => { - // A bare binary would read `--` as its own argument, so the address is - // dropped — loudly, since the caller asked for a reachable server. + it("refuses an address the serveCommand cannot take", async () => { + // A bare binary would read `--` as its own argument. Failing is the point: + // the caller asked for a reachable server, and a warning plus exit 0 would + // leave a sandbox serving on some other port and reporting success. await t.givenLoggedInWithProject(fixture("with-serve-command")); - const handle = await t.runLive( - "site", - "dev", - "--backend-url", - "https://preview.example/api", - "--host", - "0.0.0.0", - ); - await handle.waitForOutput(/SERVE_APP=/); - const result = await handle.stop(); + const result = await t.run("site", "dev", "--host", "0.0.0.0"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("takes no forwarded arguments"); + }); + + it.each([ + "", + " ", + "0", + "0x10", + "1e3", + "5173.0", + "-1", + "70000", + "abc", + ])("refuses --port %j", async (port) => { + await t.givenLoggedInWithProject(fixture("with-npm-serve-command")); + + const result = await t.run("site", "dev", "--port", port); + + 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("uses the project's own spelling of the host flag", async () => { + await t.givenLoggedInWithProject(fixture("with-hostname-serve-command")); + + const handle = await t.runLive("site", "dev", "--host", "0.0.0.0"); + await handle.waitForOutput(/ARGS=/); + await handle.stop(); - t.expectResult(result).toContain("were not passed to it"); + expect(handle.stdout.join("")).toContain("ARGS=--hostname 0.0.0.0"); }); it("injects no backend url when the caller names none", async () => { diff --git a/packages/cli/tests/core/project.spec.ts b/packages/cli/tests/core/project.spec.ts index 98255752..82de10ed 100644 --- a/packages/cli/tests/core/project.spec.ts +++ b/packages/cli/tests/core/project.spec.ts @@ -31,7 +31,6 @@ describe("readProjectConfig", () => { outputDirectory: "site-output", buildCommand: "npm run build", installCommand: "npm install", - devHostFlag: "--host", }); }); @@ -43,13 +42,14 @@ describe("readProjectConfig", () => { expect(result.project.site).toEqual({ buildCommand: "npm run build", installCommand: "npm install", - devHostFlag: "--host", }); - // Neither defaults: their absence says "no frontend to run here" and - // "nothing built to upload", which a default would erase. + // None of the three defaults. The first two say "no frontend to run here" + // and "nothing built to upload"; the third is a flag spelling only the + // command that needs it should assume. expect(result.project.site?.serveCommand).toBeUndefined(); expect(result.project.site?.outputDirectory).toBeUndefined(); + expect(result.project.site?.devHostFlag).toBeUndefined(); }); it("leaves a project with no site block without a site", async () => { diff --git a/packages/cli/tests/core/serve-command.spec.ts b/packages/cli/tests/core/serve-command.spec.ts index 89b43df0..be5bb98e 100644 --- a/packages/cli/tests/core/serve-command.spec.ts +++ b/packages/cli/tests/core/serve-command.spec.ts @@ -1,26 +1,32 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_SERVE_COMMAND, - forwardsServeAddress, withServeAddress, } from "@/core/site/serve-command.js"; -describe("DEFAULT_SERVE_COMMAND", () => { - it("can take a bind address", () => { - // What `site dev` falls back to, so it has to be a command that accepts one. - expect(forwardsServeAddress(DEFAULT_SERVE_COMMAND)).toBe(true); - }); -}); +const ADDRESS = { host: "0.0.0.0", port: 5173, hostFlag: "--host" }; describe("withServeAddress", () => { it("appends the address to an npm script through --", () => { - expect( - withServeAddress("npm run dev", { - host: "0.0.0.0", - port: 5173, - hostFlag: "--host", - }), - ).toBe("npm run dev -- --host 0.0.0.0 --port 5173"); + expect(withServeAddress("npm run dev", ADDRESS)).toEqual({ + command: "npm run dev -- --host 0.0.0.0 --port 5173", + droppedAddress: false, + }); + }); + + it("appends to pnpm, yarn and bun without the -- npm needs", () => { + // Those three pass anything after the script name straight through; npm + // consumes it itself. + for (const runner of [ + "pnpm dev", + "pnpm run dev", + "yarn dev", + "bun run dev", + ]) { + expect(withServeAddress(runner, ADDRESS).command).toBe( + `${runner} --host 0.0.0.0 --port 5173`, + ); + } }); it("uses the project's own spelling of the host flag", () => { @@ -29,47 +35,70 @@ describe("withServeAddress", () => { withServeAddress("npm run dev", { host: "0.0.0.0", hostFlag: "--hostname", - }), + }).command, ).toBe("npm run dev -- --hostname 0.0.0.0"); }); + it("falls back to --host when the project names no spelling", () => { + expect(withServeAddress("npm run dev", { host: "0.0.0.0" }).command).toBe( + "npm run dev -- --host 0.0.0.0", + ); + }); + it("appends a prefixed npm script too", () => { expect( withServeAddress("npm --prefix site run dev", { port: 4173, hostFlag: "--host", - }), + }).command, ).toBe("npm --prefix site run dev -- --port 4173"); }); it("leaves the command alone when there is no address to add", () => { - expect(withServeAddress("npm run dev", { hostFlag: "--host" })).toBe( - "npm run dev", + expect(withServeAddress(" npm run dev ", { hostFlag: "--host" })).toEqual( + { + command: "npm run dev", + droppedAddress: false, + }, ); }); - it("leaves a command that cannot forward arguments alone", () => { - // `vite -- --host` would read the -- as vite's own argument. - expect( - withServeAddress("vite", { host: "0.0.0.0", hostFlag: "--host" }), - ).toBe("vite"); + it("trims the command it composes, not just the one it tests", () => { + expect(withServeAddress(" npm run dev ", ADDRESS).command).toBe( + "npm run dev -- --host 0.0.0.0 --port 5173", + ); }); - it.each([ - "npm run dev", - "npm --prefix site run dev", - " npm run dev ", - ])("forwards for %s", (command) => { - expect(forwardsServeAddress(command)).toBe(true); + it("can take the address on the command site dev falls back to", () => { + expect( + withServeAddress(DEFAULT_SERVE_COMMAND, ADDRESS).droppedAddress, + ).toBe(false); }); it.each([ + // `vite -- --host` would read the -- as vite's own argument. "vite", "next dev", "npm run", - "yarn dev", - "npm install", - ])("does not forward for %s", (command) => { - expect(forwardsServeAddress(command)).toBe(false); + // Already pins an address of its own; appending would send two. + "npm run dev -- --port 3000", + "npm run build && npm run dev", + ])("reports the address as dropped for %s", (command) => { + expect(withServeAddress(command, ADDRESS)).toEqual({ + command, + droppedAddress: true, + }); + }); + + it.each([ + // A `\S+` script token would match all of these and hand the address to the + // second command instead of the dev server. + "npm run dev;evil", + "npm run dev&&evil", + "npm run dev|evil", + "npm run $(id)", + "npm --prefix $(id) run dev", + ])("does not treat %s as a forwarding shape", (command) => { + expect(withServeAddress(command, ADDRESS).droppedAddress).toBe(true); }); }); diff --git a/packages/cli/tests/fixtures/with-failing-install/base44/config.jsonc b/packages/cli/tests/fixtures/with-failing-install/base44/config.jsonc new file mode 100644 index 00000000..8718df3f --- /dev/null +++ b/packages/cli/tests/fixtures/with-failing-install/base44/config.jsonc @@ -0,0 +1,6 @@ +{ + "name": "Failing Install Project", + "site": { + "installCommand": "node -e \"process.exit(1)\"" + } +} diff --git a/packages/cli/tests/fixtures/with-failing-install/package.json b/packages/cli/tests/fixtures/with-failing-install/package.json new file mode 100644 index 00000000..e60b421b --- /dev/null +++ b/packages/cli/tests/fixtures/with-failing-install/package.json @@ -0,0 +1,5 @@ +{ + "name": "failing-install-project", + "private": true, + "version": "0.0.0" +} diff --git a/packages/cli/tests/fixtures/with-hostname-serve-command/base44/config.jsonc b/packages/cli/tests/fixtures/with-hostname-serve-command/base44/config.jsonc new file mode 100644 index 00000000..bf42b287 --- /dev/null +++ b/packages/cli/tests/fixtures/with-hostname-serve-command/base44/config.jsonc @@ -0,0 +1,7 @@ +{ + "name": "Hostname Serve Command Project", + "site": { + // Next spells it this way and exits on `--host`. + "devHostFlag": "--hostname" + } +} diff --git a/packages/cli/tests/fixtures/with-hostname-serve-command/package.json b/packages/cli/tests/fixtures/with-hostname-serve-command/package.json new file mode 100644 index 00000000..09a2876e --- /dev/null +++ b/packages/cli/tests/fixtures/with-hostname-serve-command/package.json @@ -0,0 +1,7 @@ +{ + "name": "hostname-serve-command-fixture", + "private": true, + "scripts": { + "dev": "node serve.js" + } +} diff --git a/packages/cli/tests/fixtures/with-hostname-serve-command/serve.js b/packages/cli/tests/fixtures/with-hostname-serve-command/serve.js new file mode 100644 index 00000000..a0028762 --- /dev/null +++ b/packages/cli/tests/fixtures/with-hostname-serve-command/serve.js @@ -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); diff --git a/packages/cli/tests/fixtures/with-npm-serve-command/base44/config.jsonc b/packages/cli/tests/fixtures/with-npm-serve-command/base44/config.jsonc index 5e7f3354..461fb266 100644 --- a/packages/cli/tests/fixtures/with-npm-serve-command/base44/config.jsonc +++ b/packages/cli/tests/fixtures/with-npm-serve-command/base44/config.jsonc @@ -1,6 +1,7 @@ { "name": "Npm Serve Command Project", - // Empty on purpose: serveCommand defaults to `npm run dev`, which is what - // forwards an address through `--`. + // 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": {} } From de28d3f16c7b75df88bbffb0af8024e46aa606ba Mon Sep 17 00:00:00 2001 From: ronnyr Date: Tue, 22 Sep 2026 13:49:48 +0300 Subject: [PATCH 06/12] fix(project): gate eject's build on the site block, not outputDirectory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both commands default inside a `site` block, so install and build always work there now — the earlier `outputDirectory` check was guarding a config neither writer produces. `ensure_cli_configs` and `eject_service` both emit the full block including `outputDirectory: "./dist"`, and the eject ZIP comes from the latter, so the check could never be the condition that differed. Having a site block is the whole question: a backend-only project has nothing to build. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018G9vtxAUJV6aZ7FYv5cRjL --- packages/cli/src/cli/commands/project/eject.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/cli/commands/project/eject.ts b/packages/cli/src/cli/commands/project/eject.ts index 5a3d9d2b..873de128 100644 --- a/packages/cli/src/cli/commands/project/eject.ts +++ b/packages/cli/src/cli/commands/project/eject.ts @@ -157,10 +157,9 @@ async function eject( const { project } = await readProjectConfig(resolvedPath); const site = project.site; - // The commands default inside a `site` block, so `outputDirectory` is what says - // there is built output to upload. Without it `--yes` would install and build - // for a project with nothing to deploy. - if (site?.outputDirectory) { + // Both commands default inside a `site` block, so having one is the whole + // condition: a backend-only project has nothing to build. + if (site) { const shouldDeploy = options.yes ? true : await confirm({ From db3e4a7f1938afce3dbef14d111bf97fe28a45d6 Mon Sep 17 00:00:00 2001 From: ronnyr Date: Wed, 23 Sep 2026 12:14:47 +0300 Subject: [PATCH 07/12] feat(site dev): default the bind address so a caller can pass nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `site dev` needed `--host 0.0.0.0 --port 5173` from every caller, which meant the platform retyped its own constants into a command line — the duplication this command exists to remove. Both now default in the CLI, so a hosted sandbox runs `base44 site dev` with no arguments and no knowledge of the values. `0.0.0.0` is the default because the command exists for a sandbox, whose preview is reached from outside the container; a loopback default would make it unreachable. On a developer's own machine that also exposes the dev server to the LAN, which is what `site.devHost` is for. `base44 dev` is untouched — it binds nothing. `site.devHost` and `site.devPort` are optional, and resolution is flag, then config, then default, so a project can narrow the bind without losing the ability to override it per run. Neither defaults in the schema, for the same reason `serveCommand` and `outputDirectory` do not: the single command that reads them supplies the fallback, so a project that said nothing stays distinguishable from one that chose these values. `devPort` is range-checked at parse time, which the flag's own parser could not do for a config value. One behaviour change: a `serveCommand` that takes no forwarded arguments, a bare `vite`, is now refused on every `site dev` rather than only when an address was asked for. There is always an address now, and a sandbox that silently serves on a different port is the failure this refusal exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018G9vtxAUJV6aZ7FYv5cRjL --- CHANGELOG.md | 1 + packages/cli/src/cli/commands/site/dev.ts | 24 ++++++++---- packages/cli/src/core/project/schema.ts | 5 +++ packages/cli/src/core/site/serve-command.ts | 10 +++++ packages/cli/tests/cli/site_dev.spec.ts | 38 ++++++++++++++++++- .../with-dev-address/base44/config.jsonc | 8 ++++ .../fixtures/with-dev-address/package.json | 7 ++++ .../tests/fixtures/with-dev-address/serve.js | 7 ++++ 8 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 packages/cli/tests/fixtures/with-dev-address/base44/config.jsonc create mode 100644 packages/cli/tests/fixtures/with-dev-address/package.json create mode 100644 packages/cli/tests/fixtures/with-dev-address/serve.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 325af2e1..622fb98c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - `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` and `site.devPort` say where `site dev` binds. Both are optional: with neither, `site dev` binds `0.0.0.0:5173`, the convention a hosted sandbox needs, so a caller can run `base44 site dev` with no arguments and no knowledge of the defaults. A flag still overrides the config. `base44 dev` is unaffected — it binds nothing. - `site.devHostFlag` says how a project's dev server spells its bind-address flag (`--hostname` for Next, which exits on `--host`). Read only when `site dev` is asked to bind an address, which is why it is not a schema default; the port flag is not configurable because every dev server spells it `--port`. - `base44 branches list --app-id --json` lists main and active branch names for agents working outside Builder. diff --git a/packages/cli/src/cli/commands/site/dev.ts b/packages/cli/src/cli/commands/site/dev.ts index 936ff038..93ea635d 100644 --- a/packages/cli/src/cli/commands/site/dev.ts +++ b/packages/cli/src/cli/commands/site/dev.ts @@ -7,6 +7,8 @@ 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_DEV_HOST, + DEFAULT_DEV_PORT, DEFAULT_SERVE_COMMAND, withServeAddress, } from "@/core/site/serve-command.js"; @@ -51,12 +53,13 @@ async function siteDevAction( ); } - // Serving is this command's whole job, so an unnamed dev server is the - // convention rather than "nothing to run". + // Serving is this command's whole job, so an unnamed dev server, and an + // unnamed address, are conventions rather than "nothing to run". A caller that + // wants none of these decisions can run `base44 site dev` with no arguments. const serveCommand = site.serveCommand ?? DEFAULT_SERVE_COMMAND; const { command, droppedAddress } = withServeAddress(serveCommand, { - host: options.host, - port: options.port, + host: options.host ?? site.devHost ?? DEFAULT_DEV_HOST, + port: options.port ?? site.devPort ?? DEFAULT_DEV_PORT, hostFlag: site.devHostFlag, }); // An address that cannot be delivered is a failure, not a warning: the caller @@ -64,7 +67,7 @@ async function siteDevAction( // port and a zero exit status saying everything worked. if (droppedAddress) { throw new InvalidInputError( - `serveCommand '${serveCommand}' takes no forwarded arguments, so --host/--port cannot be passed to it. Put the address in serveCommand itself, or use an 'npm run', 'pnpm', 'yarn' or 'bun run' script.`, + `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.`, ); } @@ -97,7 +100,14 @@ export function getSiteDevCommand(): Command { "--backend-url ", "Backend the frontend should call, injected as VITE_BASE44_APP_BASE_URL. Omit for a frontend that reaches its backend same-origin.", ) - .option("--host
", "Address to bind, e.g. 0.0.0.0") - .option("--port ", "Port to bind", parsePort) + .option( + "--host
", + `Address to bind (default: site.devHost, else ${DEFAULT_DEV_HOST})`, + ) + .option( + "--port ", + `Port to bind (default: site.devPort, else ${DEFAULT_DEV_PORT})`, + parsePort, + ) .action(siteDevAction); } diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 0898ee3c..62a2bb7b 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -33,6 +33,11 @@ const SiteConfigSchema = z.object({ // not configurable — every one of them spells it `--port`. Undefaulted for the // same reason as the two above: `site dev` supplies the fallback. devHostFlag: z.string().optional(), + // Where `site dev` binds. Undefaulted here for the same reason as the fields + // above: the one command that reads them supplies the fallback, so a project + // that says nothing is distinguishable from one that chose these values. + devHost: z.string().optional(), + devPort: z.number().int().min(1).max(65535).optional(), }); const PluginMetadataSchema = z.object({ diff --git a/packages/cli/src/core/site/serve-command.ts b/packages/cli/src/core/site/serve-command.ts index f64d0bf2..a32f995f 100644 --- a/packages/cli/src/core/site/serve-command.ts +++ b/packages/cli/src/core/site/serve-command.ts @@ -9,6 +9,16 @@ export const DEFAULT_SERVE_COMMAND = "npm run dev"; * and exits on `--host`, which is what `site.devHostFlag` is for. */ const DEFAULT_HOST_FLAG = "--host"; +/** + * Where `site dev` binds when nothing says otherwise. `0.0.0.0` because the + * command exists for a hosted sandbox, whose preview is reached from outside the + * container — a loopback default would make it unreachable. On a developer's own + * machine that also exposes the dev server to the LAN, so `site.devHost` is + * there to narrow it. `base44 dev` is unaffected: it binds nothing. + */ +export const DEFAULT_DEV_HOST = "0.0.0.0"; +export const DEFAULT_DEV_PORT = 5173; + // 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. diff --git a/packages/cli/tests/cli/site_dev.spec.ts b/packages/cli/tests/cli/site_dev.spec.ts index 1cfdc10d..cd202375 100644 --- a/packages/cli/tests/cli/site_dev.spec.ts +++ b/packages/cli/tests/cli/site_dev.spec.ts @@ -21,6 +21,42 @@ describe("site dev command", () => { expect(output).toContain("URL=https://preview.example/api"); }); + it("binds the sandbox convention when nothing names an address", async () => { + // The point of the defaults: apper runs `base44 site dev` with no arguments + // and no knowledge of what they would have been. + 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=--host 0.0.0.0 --port 5173"); + }); + + it("lets the project name its own address", async () => { + await t.givenLoggedInWithProject(fixture("with-dev-address")); + + const handle = await t.runLive("site", "dev"); + await handle.waitForOutput(/ARGS=/); + await handle.stop(); + + expect(handle.stdout.join("")).toContain( + "ARGS=--host 127.0.0.1 --port 4321", + ); + }); + + it("lets a flag override what the project named", async () => { + await t.givenLoggedInWithProject(fixture("with-dev-address")); + + const handle = await t.runLive("site", "dev", "--port", "5999"); + await handle.waitForOutput(/ARGS=/); + await handle.stop(); + + expect(handle.stdout.join("")).toContain( + "ARGS=--host 127.0.0.1 --port 5999", + ); + }); + it("binds the address it is given", async () => { await t.givenLoggedInWithProject(fixture("with-npm-serve-command")); @@ -46,7 +82,7 @@ describe("site dev command", () => { // leave a sandbox serving on some other port and reporting success. await t.givenLoggedInWithProject(fixture("with-serve-command")); - const result = await t.run("site", "dev", "--host", "0.0.0.0"); + const result = await t.run("site", "dev"); t.expectResult(result).toFail(); t.expectResult(result).toContain("takes no forwarded arguments"); diff --git a/packages/cli/tests/fixtures/with-dev-address/base44/config.jsonc b/packages/cli/tests/fixtures/with-dev-address/base44/config.jsonc new file mode 100644 index 00000000..518d0ce8 --- /dev/null +++ b/packages/cli/tests/fixtures/with-dev-address/base44/config.jsonc @@ -0,0 +1,8 @@ +{ + "name": "Dev Address Project", + "site": { + // A project that wants to bind somewhere other than the sandbox convention. + "devHost": "127.0.0.1", + "devPort": 4321 + } +} diff --git a/packages/cli/tests/fixtures/with-dev-address/package.json b/packages/cli/tests/fixtures/with-dev-address/package.json new file mode 100644 index 00000000..0538221a --- /dev/null +++ b/packages/cli/tests/fixtures/with-dev-address/package.json @@ -0,0 +1,7 @@ +{ + "name": "dev-address-fixture", + "private": true, + "scripts": { + "dev": "node serve.js" + } +} diff --git a/packages/cli/tests/fixtures/with-dev-address/serve.js b/packages/cli/tests/fixtures/with-dev-address/serve.js new file mode 100644 index 00000000..a0028762 --- /dev/null +++ b/packages/cli/tests/fixtures/with-dev-address/serve.js @@ -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); From ba6df59dd2f41c23d4d3716a9685dc1df94e0fdd Mon Sep 17 00:00:00 2001 From: ronnyr Date: Wed, 23 Sep 2026 12:29:47 +0300 Subject: [PATCH 08/12] refactor(schema): default the dev address in the config, not the command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `devHost`, `devPort` and `devHostFlag` were defaulted in `site dev`, which split the site block across two files: two fields defaulting in the schema and three in a command, with nothing telling a reader which was which. They belong in the schema, because the rule that keeps `serveCommand` and `outputDirectory` out of it does not apply to them. That rule is about absence carrying meaning — `base44 dev` reads a missing `serveCommand` as "no frontend to run here", and `deploy` reads a missing `outputDirectory` as "nothing built to upload", so defaulting either erases a question a reader asks. Nothing asks that of these three. `site dev` is their only reader and it wants a value. So the block is now self-describing: every convention with a default has it in one place, and the two fields without one are the two that mean something by being absent. `withServeAddress` loses its internal host-flag fallback, since the caller can no longer arrive without one, and the test for that case goes with it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018G9vtxAUJV6aZ7FYv5cRjL --- CHANGELOG.md | 3 +-- packages/cli/src/cli/commands/site/dev.ts | 13 ++++------ packages/cli/src/core/project/schema.ts | 24 ++++++++++--------- packages/cli/src/core/site/serve-command.ts | 18 ++------------ packages/cli/tests/core/project.spec.ts | 13 ++++++---- packages/cli/tests/core/serve-command.spec.ts | 6 ----- 6 files changed, 29 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 622fb98c..5f436523 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,7 @@ - `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` and `site.devPort` say where `site dev` binds. Both are optional: with neither, `site dev` binds `0.0.0.0:5173`, the convention a hosted sandbox needs, so a caller can run `base44 site dev` with no arguments and no knowledge of the defaults. A flag still overrides the config. `base44 dev` is unaffected — it binds nothing. -- `site.devHostFlag` says how a project's dev server spells its bind-address flag (`--hostname` for Next, which exits on `--host`). Read only when `site dev` is asked to bind an address, which is why it is not a schema default; the port flag is not configurable because every dev server spells it `--port`. +- `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` — so a caller can run `base44 site dev` with no arguments and no knowledge of the values, which is what a hosted sandbox needs. A flag still overrides the config. `base44 dev` is unaffected: it binds nothing. The port flag itself is not configurable, because every dev server spells it `--port`. - `base44 branches list --app-id --json` lists main and active branch names for agents working outside Builder. diff --git a/packages/cli/src/cli/commands/site/dev.ts b/packages/cli/src/cli/commands/site/dev.ts index 93ea635d..14c1881b 100644 --- a/packages/cli/src/cli/commands/site/dev.ts +++ b/packages/cli/src/cli/commands/site/dev.ts @@ -7,8 +7,6 @@ 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_DEV_HOST, - DEFAULT_DEV_PORT, DEFAULT_SERVE_COMMAND, withServeAddress, } from "@/core/site/serve-command.js"; @@ -58,8 +56,8 @@ async function siteDevAction( // wants none of these decisions can run `base44 site dev` with no arguments. const serveCommand = site.serveCommand ?? DEFAULT_SERVE_COMMAND; const { command, droppedAddress } = withServeAddress(serveCommand, { - host: options.host ?? site.devHost ?? DEFAULT_DEV_HOST, - port: options.port ?? site.devPort ?? DEFAULT_DEV_PORT, + host: options.host ?? site.devHost, + port: options.port ?? site.devPort, hostFlag: site.devHostFlag, }); // An address that cannot be delivered is a failure, not a warning: the caller @@ -100,13 +98,10 @@ export function getSiteDevCommand(): Command { "--backend-url ", "Backend the frontend should call, injected as VITE_BASE44_APP_BASE_URL. Omit for a frontend that reaches its backend same-origin.", ) - .option( - "--host
", - `Address to bind (default: site.devHost, else ${DEFAULT_DEV_HOST})`, - ) + .option("--host
", "Address to bind, overriding site.devHost") .option( "--port ", - `Port to bind (default: site.devPort, else ${DEFAULT_DEV_PORT})`, + "Port to bind, overriding site.devPort", parsePort, ) .action(siteDevAction); diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 62a2bb7b..6ef1935a 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -27,17 +27,19 @@ const SiteConfigSchema = z.object({ serveCommand: z.string().optional(), outputDirectory: z.string().optional(), installCommand: z.string().default("npm install"), - // How this project's dev server spells its bind-address flag: `--host` for - // Vite/Astro/Nuxt, `--hostname` for Next, which exits on `--host`. Read only - // when a caller asks `site dev` to bind a specific address. The port flag is - // not configurable — every one of them spells it `--port`. Undefaulted for the - // same reason as the two above: `site dev` supplies the fallback. - devHostFlag: z.string().optional(), - // Where `site dev` binds. Undefaulted here for the same reason as the fields - // above: the one command that reads them supplies the fallback, so a project - // that says nothing is distinguishable from one that chose these values. - devHost: z.string().optional(), - devPort: z.number().int().min(1).max(65535).optional(), + // 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 is not configurable — every dev + // server spells it `--port`. + // + // These do default, unlike the two above, because no reader asks whether they + // were declared: `site dev` is the only one, and it wants a value rather than + // an answer about absence. `0.0.0.0` is the address a hosted sandbox needs, + // since its preview is reached from outside the container; narrow it with + // `devHost` on a machine 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({ diff --git a/packages/cli/src/core/site/serve-command.ts b/packages/cli/src/core/site/serve-command.ts index a32f995f..81126945 100644 --- a/packages/cli/src/core/site/serve-command.ts +++ b/packages/cli/src/core/site/serve-command.ts @@ -5,20 +5,6 @@ */ export const DEFAULT_SERVE_COMMAND = "npm run dev"; -/** What most dev servers call the bind-address flag. Next spells it `--hostname` - * and exits on `--host`, which is what `site.devHostFlag` is for. */ -const DEFAULT_HOST_FLAG = "--host"; - -/** - * Where `site dev` binds when nothing says otherwise. `0.0.0.0` because the - * command exists for a hosted sandbox, whose preview is reached from outside the - * container — a loopback default would make it unreachable. On a developer's own - * machine that also exposes the dev server to the LAN, so `site.devHost` is - * there to narrow it. `base44 dev` is unaffected: it binds nothing. - */ -export const DEFAULT_DEV_HOST = "0.0.0.0"; -export const DEFAULT_DEV_PORT = 5173; - // 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. @@ -48,7 +34,7 @@ interface ServeAddress { host?: string; port?: number; /** How this dev server spells its bind-address flag. */ - hostFlag?: string; + hostFlag: string; } function runnerFor(serveCommand: string) { @@ -69,7 +55,7 @@ export function withServeAddress( ): { command: string; droppedAddress: boolean } { const command = serveCommand.trim(); const args = [ - ...(host ? [hostFlag ?? DEFAULT_HOST_FLAG, host] : []), + ...(host ? [hostFlag, host] : []), ...(port === undefined ? [] : ["--port", String(port)]), ]; if (args.length === 0) { diff --git a/packages/cli/tests/core/project.spec.ts b/packages/cli/tests/core/project.spec.ts index 82de10ed..41088014 100644 --- a/packages/cli/tests/core/project.spec.ts +++ b/packages/cli/tests/core/project.spec.ts @@ -31,6 +31,9 @@ describe("readProjectConfig", () => { outputDirectory: "site-output", buildCommand: "npm run build", installCommand: "npm install", + devHostFlag: "--host", + devHost: "0.0.0.0", + devPort: 5173, }); }); @@ -42,14 +45,16 @@ describe("readProjectConfig", () => { expect(result.project.site).toEqual({ buildCommand: "npm run build", installCommand: "npm install", + devHostFlag: "--host", + devHost: "0.0.0.0", + devPort: 5173, }); - // None of the three defaults. The first two say "no frontend to run here" - // and "nothing built to upload"; the third is a flag spelling only the - // command that needs it should assume. + // These two never default: their absence answers a question a reader asks. + // `base44 dev` reads the first as "no frontend to run here" and `deploy` + // reads the second as "nothing built to upload". expect(result.project.site?.serveCommand).toBeUndefined(); expect(result.project.site?.outputDirectory).toBeUndefined(); - expect(result.project.site?.devHostFlag).toBeUndefined(); }); it("leaves a project with no site block without a site", async () => { diff --git a/packages/cli/tests/core/serve-command.spec.ts b/packages/cli/tests/core/serve-command.spec.ts index be5bb98e..c197dd11 100644 --- a/packages/cli/tests/core/serve-command.spec.ts +++ b/packages/cli/tests/core/serve-command.spec.ts @@ -39,12 +39,6 @@ describe("withServeAddress", () => { ).toBe("npm run dev -- --hostname 0.0.0.0"); }); - it("falls back to --host when the project names no spelling", () => { - expect(withServeAddress("npm run dev", { host: "0.0.0.0" }).command).toBe( - "npm run dev -- --host 0.0.0.0", - ); - }); - it("appends a prefixed npm script too", () => { expect( withServeAddress("npm --prefix site run dev", { From 88da65ac86fde718e1eabda78361982caffb4886 Mon Sep 17 00:00:00 2001 From: ronnyr Date: Wed, 23 Sep 2026 12:33:06 +0300 Subject: [PATCH 09/12] refactor(site dev): drop devHost and devPort, they were never project config Two of the three fields should not have existed. Nothing outside `site dev` and the fixture added alongside them ever read `devHost` or `devPort`; they were added to answer an objection about run-scoped values landing in a committed file, and that objection was withdrawn once it turned out the sandbox uses one fixed address. The fields outlived the argument for them. An address is not a fact about a project. Nobody writing a repo has a view on which port one particular run should listen on, so the convention belongs to the command that has to pick one, with `--host`/`--port` for a caller that needs something else. `devHostFlag` stays, and stays in the schema, because it is the opposite case: only the project knows whether its dev server wants `--host` or `--hostname`, and the caller that needs it passes no arguments at all, so config is the only channel. That leaves the site block with one field per question it can actually answer. Net 41 lines removed, including a fixture that only existed to exercise the fields it shipped with. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018G9vtxAUJV6aZ7FYv5cRjL --- CHANGELOG.md | 2 +- packages/cli/src/cli/commands/site/dev.ts | 22 ++++++++++++------- packages/cli/src/core/project/schema.ts | 17 +++++--------- packages/cli/tests/cli/site_dev.spec.ts | 20 +++-------------- packages/cli/tests/core/project.spec.ts | 4 ---- .../with-dev-address/base44/config.jsonc | 8 ------- .../fixtures/with-dev-address/package.json | 7 ------ .../tests/fixtures/with-dev-address/serve.js | 7 ------ 8 files changed, 23 insertions(+), 64 deletions(-) delete mode 100644 packages/cli/tests/fixtures/with-dev-address/base44/config.jsonc delete mode 100644 packages/cli/tests/fixtures/with-dev-address/package.json delete mode 100644 packages/cli/tests/fixtures/with-dev-address/serve.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f436523..bbe08795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - `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` — so a caller can run `base44 site dev` with no arguments and no knowledge of the values, which is what a hosted sandbox needs. A flag still overrides the config. `base44 dev` is unaffected: it binds nothing. The port flag itself is not configurable, because every dev server spells it `--port`. +- `site.devHostFlag` says how this project's dev server spells the bind-address flag, `--host` by default and `--hostname` for Next, which exits on `--host`. It is config rather than a flag because only the project knows it and the caller that needs it passes no arguments. `site dev` binds `0.0.0.0:5173` unless `--host`/`--port` say otherwise, so a hosted sandbox can run `base44 site dev` with nothing else. `base44 dev` is unaffected: it binds nothing. - `base44 branches list --app-id --json` lists main and active branch names for agents working outside Builder. diff --git a/packages/cli/src/cli/commands/site/dev.ts b/packages/cli/src/cli/commands/site/dev.ts index 14c1881b..f80a552d 100644 --- a/packages/cli/src/cli/commands/site/dev.ts +++ b/packages/cli/src/cli/commands/site/dev.ts @@ -17,6 +17,16 @@ interface SiteDevOptions extends AppIdOptions { port?: number; } +/** + * Where this command binds when the caller says nothing. `0.0.0.0` because the + * command exists for a hosted sandbox, whose preview is reached from outside the + * container, so loopback would make it unreachable. Not config: no project has a + * say in which address a given run should listen on, and a sandbox that needs + * something else can pass a flag. + */ +const DEFAULT_DEV_HOST = "0.0.0.0"; +const DEFAULT_DEV_PORT = 5173; + function parsePort(value: string): number { // `Number()` is not port validation: it turns "", " ", "0x10" and "1e3" into // numbers, and an empty string into 0 — a random port, silently. @@ -56,8 +66,8 @@ async function siteDevAction( // wants none of these decisions can run `base44 site dev` with no arguments. const serveCommand = site.serveCommand ?? DEFAULT_SERVE_COMMAND; const { command, droppedAddress } = withServeAddress(serveCommand, { - host: options.host ?? site.devHost, - port: options.port ?? site.devPort, + host: options.host ?? DEFAULT_DEV_HOST, + port: options.port ?? DEFAULT_DEV_PORT, hostFlag: site.devHostFlag, }); // An address that cannot be delivered is a failure, not a warning: the caller @@ -98,11 +108,7 @@ export function getSiteDevCommand(): Command { "--backend-url ", "Backend the frontend should call, injected as VITE_BASE44_APP_BASE_URL. Omit for a frontend that reaches its backend same-origin.", ) - .option("--host
", "Address to bind, overriding site.devHost") - .option( - "--port ", - "Port to bind, overriding site.devPort", - parsePort, - ) + .option("--host
", "Address to bind (default: 0.0.0.0)") + .option("--port ", "Port to bind (default: 5173)", parsePort) .action(siteDevAction); } diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 6ef1935a..a2335f00 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -27,19 +27,12 @@ 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 is not configurable — every dev - // server spells it `--port`. - // - // These do default, unlike the two above, because no reader asks whether they - // were declared: `site dev` is the only one, and it wants a value rather than - // an answer about absence. `0.0.0.0` is the address a hosted sandbox needs, - // since its preview is reached from outside the container; narrow it with - // `devHost` on a machine where binding the LAN matters. + // How this project's dev server spells the bind-address flag: `--host` for + // Vite/Astro/Nuxt, `--hostname` for Next, which exits on `--host`. Config + // rather than a flag because only the project knows it, and the caller that + // needs it — a sandbox — passes no arguments at all. The port flag is not + // configurable: every dev server spells it `--port`. 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({ diff --git a/packages/cli/tests/cli/site_dev.spec.ts b/packages/cli/tests/cli/site_dev.spec.ts index cd202375..683279b0 100644 --- a/packages/cli/tests/cli/site_dev.spec.ts +++ b/packages/cli/tests/cli/site_dev.spec.ts @@ -33,28 +33,14 @@ describe("site dev command", () => { expect(handle.stdout.join("")).toContain("ARGS=--host 0.0.0.0 --port 5173"); }); - it("lets the project name its own address", async () => { - await t.givenLoggedInWithProject(fixture("with-dev-address")); - - const handle = await t.runLive("site", "dev"); - await handle.waitForOutput(/ARGS=/); - await handle.stop(); - - expect(handle.stdout.join("")).toContain( - "ARGS=--host 127.0.0.1 --port 4321", - ); - }); - - it("lets a flag override what the project named", async () => { - await t.givenLoggedInWithProject(fixture("with-dev-address")); + it("lets a flag override the default", async () => { + await t.givenLoggedInWithProject(fixture("with-npm-serve-command")); const handle = await t.runLive("site", "dev", "--port", "5999"); await handle.waitForOutput(/ARGS=/); await handle.stop(); - expect(handle.stdout.join("")).toContain( - "ARGS=--host 127.0.0.1 --port 5999", - ); + expect(handle.stdout.join("")).toContain("ARGS=--host 0.0.0.0 --port 5999"); }); it("binds the address it is given", async () => { diff --git a/packages/cli/tests/core/project.spec.ts b/packages/cli/tests/core/project.spec.ts index 41088014..e0c56e60 100644 --- a/packages/cli/tests/core/project.spec.ts +++ b/packages/cli/tests/core/project.spec.ts @@ -32,8 +32,6 @@ describe("readProjectConfig", () => { buildCommand: "npm run build", installCommand: "npm install", devHostFlag: "--host", - devHost: "0.0.0.0", - devPort: 5173, }); }); @@ -46,8 +44,6 @@ describe("readProjectConfig", () => { buildCommand: "npm run build", installCommand: "npm install", devHostFlag: "--host", - devHost: "0.0.0.0", - devPort: 5173, }); // These two never default: their absence answers a question a reader asks. diff --git a/packages/cli/tests/fixtures/with-dev-address/base44/config.jsonc b/packages/cli/tests/fixtures/with-dev-address/base44/config.jsonc deleted file mode 100644 index 518d0ce8..00000000 --- a/packages/cli/tests/fixtures/with-dev-address/base44/config.jsonc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "Dev Address Project", - "site": { - // A project that wants to bind somewhere other than the sandbox convention. - "devHost": "127.0.0.1", - "devPort": 4321 - } -} diff --git a/packages/cli/tests/fixtures/with-dev-address/package.json b/packages/cli/tests/fixtures/with-dev-address/package.json deleted file mode 100644 index 0538221a..00000000 --- a/packages/cli/tests/fixtures/with-dev-address/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "dev-address-fixture", - "private": true, - "scripts": { - "dev": "node serve.js" - } -} diff --git a/packages/cli/tests/fixtures/with-dev-address/serve.js b/packages/cli/tests/fixtures/with-dev-address/serve.js deleted file mode 100644 index a0028762..00000000 --- a/packages/cli/tests/fixtures/with-dev-address/serve.js +++ /dev/null @@ -1,7 +0,0 @@ -// 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); From 0d94269790de13076b2f8f04dd7dee29ac5cd4f8 Mon Sep 17 00:00:00 2001 From: ronnyr Date: Wed, 23 Sep 2026 12:40:03 +0300 Subject: [PATCH 10/12] refactor(site dev): the config is the only channel for the bind address `--host` and `--port` are gone. The address now comes from `site.devHost` and `site.devPort`, which default in the schema to `0.0.0.0` and `5173`, so `base44 site dev` takes no arguments for it and a caller needs to know none of the values. Changing where it binds means editing base44/config.jsonc. That removes the last thing the platform was composing. It also removes the flag's own port parser: Zod range-checks `devPort` at parse time, which the parser could not do for a config value, so validation now covers both the file and the one place the value is read. `withServeAddress` takes host and port as required, since a caller can no longer arrive without them, which deletes its no-address branch and the test for it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018G9vtxAUJV6aZ7FYv5cRjL --- CHANGELOG.md | 2 +- packages/cli/src/cli/commands/site/dev.ts | 38 ++------------ packages/cli/src/core/project/schema.ts | 16 ++++-- packages/cli/src/core/site/serve-command.ts | 12 ++--- packages/cli/tests/cli/site_dev.spec.ts | 49 +++++-------------- packages/cli/tests/core/project.spec.ts | 4 ++ packages/cli/tests/core/serve-command.spec.ts | 15 ++---- .../with-dev-address/base44/config.jsonc | 8 +++ .../fixtures/with-dev-address/package.json | 7 +++ .../tests/fixtures/with-dev-address/serve.js | 7 +++ 10 files changed, 61 insertions(+), 97 deletions(-) create mode 100644 packages/cli/tests/fixtures/with-dev-address/base44/config.jsonc create mode 100644 packages/cli/tests/fixtures/with-dev-address/package.json create mode 100644 packages/cli/tests/fixtures/with-dev-address/serve.js diff --git a/CHANGELOG.md b/CHANGELOG.md index bbe08795..78e9a67d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - `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.devHostFlag` says how this project's dev server spells the bind-address flag, `--host` by default and `--hostname` for Next, which exits on `--host`. It is config rather than a flag because only the project knows it and the caller that needs it passes no arguments. `site dev` binds `0.0.0.0:5173` unless `--host`/`--port` say otherwise, so a hosted sandbox can run `base44 site dev` with nothing else. `base44 dev` is unaffected: it binds nothing. +- `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 --json` lists main and active branch names for agents working outside Builder. diff --git a/packages/cli/src/cli/commands/site/dev.ts b/packages/cli/src/cli/commands/site/dev.ts index f80a552d..e78536ae 100644 --- a/packages/cli/src/cli/commands/site/dev.ts +++ b/packages/cli/src/cli/commands/site/dev.ts @@ -1,5 +1,4 @@ import type { Command } from "commander"; -import { InvalidArgumentError } 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"; @@ -13,31 +12,6 @@ import { interface SiteDevOptions extends AppIdOptions { backendUrl?: string; - host?: string; - port?: number; -} - -/** - * Where this command binds when the caller says nothing. `0.0.0.0` because the - * command exists for a hosted sandbox, whose preview is reached from outside the - * container, so loopback would make it unreachable. Not config: no project has a - * say in which address a given run should listen on, and a sandbox that needs - * something else can pass a flag. - */ -const DEFAULT_DEV_HOST = "0.0.0.0"; -const DEFAULT_DEV_PORT = 5173; - -function parsePort(value: string): number { - // `Number()` is not port validation: it turns "", " ", "0x10" and "1e3" into - // numbers, and an empty string into 0 — a random port, silently. - if (!/^\d+$/.test(value)) { - throw new InvalidArgumentError("must be a whole number"); - } - const port = Number(value); - if (port < 1 || port > 65535) { - throw new InvalidArgumentError("must be between 1 and 65535"); - } - return port; } async function siteDevAction( @@ -61,13 +35,13 @@ async function siteDevAction( ); } - // Serving is this command's whole job, so an unnamed dev server, and an - // unnamed address, are conventions rather than "nothing to run". A caller that - // wants none of these decisions can run `base44 site dev` with no arguments. + // 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: options.host ?? DEFAULT_DEV_HOST, - port: options.port ?? DEFAULT_DEV_PORT, + host: site.devHost, + port: site.devPort, hostFlag: site.devHostFlag, }); // An address that cannot be delivered is a failure, not a warning: the caller @@ -108,7 +82,5 @@ export function getSiteDevCommand(): Command { "--backend-url ", "Backend the frontend should call, injected as VITE_BASE44_APP_BASE_URL. Omit for a frontend that reaches its backend same-origin.", ) - .option("--host
", "Address to bind (default: 0.0.0.0)") - .option("--port ", "Port to bind (default: 5173)", parsePort) .action(siteDevAction); } diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index a2335f00..e35248e0 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -27,12 +27,18 @@ const SiteConfigSchema = z.object({ serveCommand: z.string().optional(), outputDirectory: z.string().optional(), installCommand: z.string().default("npm install"), - // How this project's dev server spells the bind-address flag: `--host` for - // Vite/Astro/Nuxt, `--hostname` for Next, which exits on `--host`. Config - // rather than a flag because only the project knows it, and the caller that - // needs it — a sandbox — passes no arguments at all. The port flag is not - // configurable: every dev server spells it `--port`. + // 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({ diff --git a/packages/cli/src/core/site/serve-command.ts b/packages/cli/src/core/site/serve-command.ts index 81126945..fec6f256 100644 --- a/packages/cli/src/core/site/serve-command.ts +++ b/packages/cli/src/core/site/serve-command.ts @@ -31,8 +31,8 @@ const SCRIPT_RUNNERS: { pattern: RegExp; separator: string }[] = [ interface ServeAddress { /** Address to bind, e.g. `0.0.0.0` so something outside the machine can reach it. */ - host?: string; - port?: number; + host: string; + port: number; /** How this dev server spells its bind-address flag. */ hostFlag: string; } @@ -54,13 +54,7 @@ export function withServeAddress( { host, port, hostFlag }: ServeAddress, ): { command: string; droppedAddress: boolean } { const command = serveCommand.trim(); - const args = [ - ...(host ? [hostFlag, host] : []), - ...(port === undefined ? [] : ["--port", String(port)]), - ]; - if (args.length === 0) { - return { command, droppedAddress: false }; - } + const args = [hostFlag, host, "--port", String(port)]; const runner = runnerFor(command); if (!runner) { return { command, droppedAddress: true }; diff --git a/packages/cli/tests/cli/site_dev.spec.ts b/packages/cli/tests/cli/site_dev.spec.ts index 683279b0..3c0abb64 100644 --- a/packages/cli/tests/cli/site_dev.spec.ts +++ b/packages/cli/tests/cli/site_dev.spec.ts @@ -33,33 +33,24 @@ describe("site dev command", () => { expect(handle.stdout.join("")).toContain("ARGS=--host 0.0.0.0 --port 5173"); }); - it("lets a flag override the default", async () => { - await t.givenLoggedInWithProject(fixture("with-npm-serve-command")); + it("binds what the config names, the only way to change it", async () => { + await t.givenLoggedInWithProject(fixture("with-dev-address")); - const handle = await t.runLive("site", "dev", "--port", "5999"); + const handle = await t.runLive("site", "dev"); await handle.waitForOutput(/ARGS=/); await handle.stop(); - expect(handle.stdout.join("")).toContain("ARGS=--host 0.0.0.0 --port 5999"); + expect(handle.stdout.join("")).toContain( + "ARGS=--host 127.0.0.1 --port 4321", + ); }); - it("binds the address it is given", async () => { + it("takes no address flags at all", async () => { await t.givenLoggedInWithProject(fixture("with-npm-serve-command")); - const handle = await t.runLive( - "site", - "dev", - "--backend-url", - "https://preview.example/api", - "--host", - "0.0.0.0", - "--port", - "5173", - ); - await handle.waitForOutput(/ARGS=/); - await handle.stop(); + const result = await t.run("site", "dev", "--port", "5999"); - expect(handle.stdout.join("")).toContain("ARGS=--host 0.0.0.0 --port 5173"); + t.expectResult(result).toFail(); }); it("refuses an address the serveCommand cannot take", async () => { @@ -74,24 +65,6 @@ describe("site dev command", () => { t.expectResult(result).toContain("takes no forwarded arguments"); }); - it.each([ - "", - " ", - "0", - "0x10", - "1e3", - "5173.0", - "-1", - "70000", - "abc", - ])("refuses --port %j", async (port) => { - await t.givenLoggedInWithProject(fixture("with-npm-serve-command")); - - const result = await t.run("site", "dev", "--port", port); - - 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")); @@ -106,11 +79,11 @@ describe("site dev command", () => { it("uses the project's own spelling of the host flag", async () => { await t.givenLoggedInWithProject(fixture("with-hostname-serve-command")); - const handle = await t.runLive("site", "dev", "--host", "0.0.0.0"); + const handle = await t.runLive("site", "dev"); await handle.waitForOutput(/ARGS=/); await handle.stop(); - expect(handle.stdout.join("")).toContain("ARGS=--hostname 0.0.0.0"); + expect(handle.stdout.join("")).toContain("ARGS=--hostname 0.0.0.0 --port 5173"); }); it("injects no backend url when the caller names none", async () => { diff --git a/packages/cli/tests/core/project.spec.ts b/packages/cli/tests/core/project.spec.ts index e0c56e60..41088014 100644 --- a/packages/cli/tests/core/project.spec.ts +++ b/packages/cli/tests/core/project.spec.ts @@ -32,6 +32,8 @@ describe("readProjectConfig", () => { buildCommand: "npm run build", installCommand: "npm install", devHostFlag: "--host", + devHost: "0.0.0.0", + devPort: 5173, }); }); @@ -44,6 +46,8 @@ describe("readProjectConfig", () => { buildCommand: "npm run build", installCommand: "npm install", devHostFlag: "--host", + devHost: "0.0.0.0", + devPort: 5173, }); // These two never default: their absence answers a question a reader asks. diff --git a/packages/cli/tests/core/serve-command.spec.ts b/packages/cli/tests/core/serve-command.spec.ts index c197dd11..93ffa53e 100644 --- a/packages/cli/tests/core/serve-command.spec.ts +++ b/packages/cli/tests/core/serve-command.spec.ts @@ -34,27 +34,20 @@ describe("withServeAddress", () => { expect( withServeAddress("npm run dev", { host: "0.0.0.0", + port: 5173, hostFlag: "--hostname", }).command, - ).toBe("npm run dev -- --hostname 0.0.0.0"); + ).toBe("npm run dev -- --hostname 0.0.0.0 --port 5173"); }); it("appends a prefixed npm script too", () => { expect( withServeAddress("npm --prefix site run dev", { + host: "0.0.0.0", port: 4173, hostFlag: "--host", }).command, - ).toBe("npm --prefix site run dev -- --port 4173"); - }); - - it("leaves the command alone when there is no address to add", () => { - expect(withServeAddress(" npm run dev ", { hostFlag: "--host" })).toEqual( - { - command: "npm run dev", - droppedAddress: false, - }, - ); + ).toBe("npm --prefix site run dev -- --host 0.0.0.0 --port 4173"); }); it("trims the command it composes, not just the one it tests", () => { diff --git a/packages/cli/tests/fixtures/with-dev-address/base44/config.jsonc b/packages/cli/tests/fixtures/with-dev-address/base44/config.jsonc new file mode 100644 index 00000000..8191985d --- /dev/null +++ b/packages/cli/tests/fixtures/with-dev-address/base44/config.jsonc @@ -0,0 +1,8 @@ +{ + "name": "Dev Address Project", + "site": { + // The only way to change where `site dev` binds: it takes no flags for it. + "devHost": "127.0.0.1", + "devPort": 4321 + } +} diff --git a/packages/cli/tests/fixtures/with-dev-address/package.json b/packages/cli/tests/fixtures/with-dev-address/package.json new file mode 100644 index 00000000..0538221a --- /dev/null +++ b/packages/cli/tests/fixtures/with-dev-address/package.json @@ -0,0 +1,7 @@ +{ + "name": "dev-address-fixture", + "private": true, + "scripts": { + "dev": "node serve.js" + } +} diff --git a/packages/cli/tests/fixtures/with-dev-address/serve.js b/packages/cli/tests/fixtures/with-dev-address/serve.js new file mode 100644 index 00000000..a0028762 --- /dev/null +++ b/packages/cli/tests/fixtures/with-dev-address/serve.js @@ -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); From 60b5706e39558c989420e189b149cdaeacc8d08c Mon Sep 17 00:00:00 2001 From: ronnyr Date: Wed, 23 Sep 2026 13:38:48 +0300 Subject: [PATCH 11/12] style(tests): wrap a long assertion biome would reformat Left over from the last edit to this spec; caught by lint, not by the suite. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018G9vtxAUJV6aZ7FYv5cRjL --- packages/cli/tests/cli/site_dev.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/tests/cli/site_dev.spec.ts b/packages/cli/tests/cli/site_dev.spec.ts index 3c0abb64..6c655b97 100644 --- a/packages/cli/tests/cli/site_dev.spec.ts +++ b/packages/cli/tests/cli/site_dev.spec.ts @@ -83,7 +83,9 @@ describe("site dev command", () => { await handle.waitForOutput(/ARGS=/); await handle.stop(); - expect(handle.stdout.join("")).toContain("ARGS=--hostname 0.0.0.0 --port 5173"); + expect(handle.stdout.join("")).toContain( + "ARGS=--hostname 0.0.0.0 --port 5173", + ); }); it("injects no backend url when the caller names none", async () => { From fab0fe0b99b12e6da43b77af801f0c3f7b6ee146 Mon Sep 17 00:00:00 2001 From: ronnyr Date: Wed, 23 Sep 2026 16:03:22 +0300 Subject: [PATCH 12/12] refactor(site dev): drop --backend-url, it had no caller The option pointed the frontend at a named backend via VITE_BASE44_APP_BASE_URL. Nothing ever passed it: the sandbox runs `base44 site dev` bare and relies on the frontend reaching its backend same-origin through the vite plugin's /api proxy, and `base44 dev` and `dev --remote` set the same variable internally without going through it. It was the last option on a command meant to take none. The serve runner keeps its optional `appBaseUrl`, since both `dev` paths still use it, so bringing the option back for a real caller is a few lines. The two same-origin and named-backend tests collapse into one, and the CHANGELOG entry drops both this and the `--host`/`--port` flags removed earlier, which it still described. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018G9vtxAUJV6aZ7FYv5cRjL --- CHANGELOG.md | 2 +- packages/cli/src/cli/commands/site/dev.ts | 32 +++++------------------ packages/cli/tests/cli/site_dev.spec.ts | 29 +++----------------- 3 files changed, 12 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78e9a67d..146f43bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - `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. +- `base44 site dev` runs the site's `serveCommand` with no local backend, 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. Serving is its whole job, so it falls back to `npm run dev` when the block names none, and it binds the address from the config, forwarded to an `npm run`, `pnpm`, `yarn` or `bun run` script; a command that cannot take it 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 --json` lists main and active branch names for agents working outside Builder. diff --git a/packages/cli/src/cli/commands/site/dev.ts b/packages/cli/src/cli/commands/site/dev.ts index e78536ae..eaaad9e3 100644 --- a/packages/cli/src/cli/commands/site/dev.ts +++ b/packages/cli/src/cli/commands/site/dev.ts @@ -2,7 +2,7 @@ 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 { Base44Command } from "@/cli/utils/index.js"; import { ConfigInvalidError, InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/project/index.js"; import { @@ -10,14 +10,7 @@ import { withServeAddress, } from "@/core/site/serve-command.js"; -interface SiteDevOptions extends AppIdOptions { - backendUrl?: string; -} - -async function siteDevAction( - ctx: CLIContext, - options: SiteDevOptions, -): Promise { +async function siteDevAction(ctx: CLIContext): Promise { 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. @@ -57,30 +50,19 @@ async function siteDevAction( 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}'`, - }; + return { outroMessage: `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. + // 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 against a given backend (no local backend)", - ) - .option( - "--backend-url ", - "Backend the frontend should call, injected as VITE_BASE44_APP_BASE_URL. Omit for a frontend that reaches its backend same-origin.", - ) + .description("Run the site's dev server, with no local backend") .action(siteDevAction); } diff --git a/packages/cli/tests/cli/site_dev.spec.ts b/packages/cli/tests/cli/site_dev.spec.ts index 6c655b97..8a141589 100644 --- a/packages/cli/tests/cli/site_dev.spec.ts +++ b/packages/cli/tests/cli/site_dev.spec.ts @@ -4,23 +4,6 @@ import { fixture, setupCLITests } from "./testkit/index.js"; describe("site dev command", () => { const t = setupCLITests(); - it("serves the frontend against the given backend, with no local backend", async () => { - await t.givenLoggedInWithProject(fixture("with-npm-serve-command")); - - const handle = await t.runLive( - "site", - "dev", - "--backend-url", - "https://preview.example/api", - ); - await handle.waitForOutput(/ARGS=/); - await handle.stop(); - - const output = handle.stdout.join(""); - expect(output).toContain(`APP=${t.api.appId}`); - expect(output).toContain("URL=https://preview.example/api"); - }); - it("binds the sandbox convention when nothing names an address", async () => { // The point of the defaults: apper runs `base44 site dev` with no arguments // and no knowledge of what they would have been. @@ -88,8 +71,9 @@ describe("site dev command", () => { ); }); - it("injects no backend url when the caller names none", async () => { - // A frontend that reaches its backend same-origin must not be handed one. + 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"); @@ -104,12 +88,7 @@ describe("site dev command", () => { it("fails when the project has no site block", async () => { await t.givenLoggedInWithProject(fixture("basic")); - const result = await t.run( - "site", - "dev", - "--backend-url", - "https://preview.example/api", - ); + const result = await t.run("site", "dev"); t.expectResult(result).toFail(); t.expectResult(result).toContain("no 'site' block");