diff --git a/CHANGELOG.md b/CHANGELOG.md index e3bc4cc5..55b87019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Added +- `base44 site install` runs the site's `installCommand` and nothing else, so a machine that only needs a project's dependencies no longer has to go through `create`, `scaffold` or `eject`. Local only: no login and no app id required. +- `base44 site dev` runs the site's `serveCommand` exactly as written, with no local backend and the frontend reaching its backend same-origin — the shape a hosted sandbox needs, where `base44 dev` (local backend) and `dev --remote` (the published app) are the developer-machine paths. It takes no arguments and appends nothing: where the dev server binds is the command's own business, and in a sandbox `@base44/vite-plugin` binds Base44 apps to `0.0.0.0:5173`. Serving is its whole job, so it falls back to `npm run dev` when the block names none. + - `base44 branches list --app-id --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/site/dev.ts b/packages/cli/src/cli/commands/site/dev.ts new file mode 100644 index 00000000..371f232d --- /dev/null +++ b/packages/cli/src/cli/commands/site/dev.ts @@ -0,0 +1,58 @@ +import type { Command } from "commander"; +import { createServeCommandRunner } from "@/cli/dev/serve-command-runner.js"; +import { stopRunnerOnProcessSignals } from "@/cli/dev/stop-runner-on-signals.js"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import { ConfigInvalidError, InvalidInputError } from "@/core/errors.js"; +import { readProjectConfig } from "@/core/project/index.js"; + +/** + * What to run when a project has a site but names no dev server. Not a schema + * default: `base44 dev` reads an absent `serveCommand` as "no frontend to run + * here", so only a command that exists purely to serve one may assume this. + */ +const DEFAULT_SERVE_COMMAND = "npm run dev"; + +async function siteDevAction(ctx: CLIContext): Promise { + const { app } = ctx; + // Same shape as `base44 build`: the framework's own app-context step has + // already refused with actionable hints, so this is the type's guard. + if (!app?.projectRoot) { + throw new ConfigInvalidError( + "base44 site dev requires a linked local project. Run it from a project with base44/.app.jsonc.", + ); + } + + const { project } = await readProjectConfig(app.projectRoot); + const site = project.site; + if (!site) { + throw new InvalidInputError( + "This project has no 'site' block in base44/config.jsonc, so there is no frontend to serve. Add one naming its serveCommand; site dev falls back to \"npm run dev\".", + ); + } + + // Run as written: where the dev server binds is the command's own business. + // In a sandbox @base44/vite-plugin binds 0.0.0.0:5173 for Base44 apps; any + // other serveCommand must bind the address the sandbox exposes itself. + const command = site.serveCommand ?? DEFAULT_SERVE_COMMAND; + + const runner = createServeCommandRunner({ + serveCommand: command, + projectRoot: project.root, + appId: app.id, + }); + stopRunnerOnProcessSignals(runner); + runner.onExit((code) => process.exit(code ?? 1)); + runner.start(); + + return { outroMessage: `Frontend dev server running '${command}'` }; +} + +export function getSiteDevCommand(): Command { + // The frontend alone, reaching its backend same-origin — what a hosted sandbox + // needs. `base44 dev` is the developer-machine command: it also runs the + // backend, locally or (with --remote) the app's published one. + return new Base44Command("dev", { requireAuth: false }) + .description("Run the site's dev server, with no local backend") + .action(siteDevAction); +} diff --git a/packages/cli/src/cli/commands/site/index.ts b/packages/cli/src/cli/commands/site/index.ts index 2850e360..e82b45da 100644 --- a/packages/cli/src/cli/commands/site/index.ts +++ b/packages/cli/src/cli/commands/site/index.ts @@ -1,10 +1,14 @@ import { Command } from "commander"; import { getSiteDeployCommand } from "./deploy.js"; +import { getSiteDevCommand } from "./dev.js"; +import { getSiteInstallCommand } from "./install.js"; import { getSiteOpenCommand } from "./open.js"; export function getSiteCommand(): Command { return new Command("site") .description("Manage app site (frontend app)") .addCommand(getSiteDeployCommand()) + .addCommand(getSiteDevCommand()) + .addCommand(getSiteInstallCommand()) .addCommand(getSiteOpenCommand()); } diff --git a/packages/cli/src/cli/commands/site/install.ts b/packages/cli/src/cli/commands/site/install.ts new file mode 100644 index 00000000..c9e8c3a2 --- /dev/null +++ b/packages/cli/src/cli/commands/site/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 getSiteInstallCommand(): Command { + // Local only: no app to resolve and no API to call, so a machine that has + // never logged in (a build sandbox) can still install a project. + return new Base44Command("install", { + requireAuth: false, + requireAppContext: false, + }) + .description("Install the site's dependencies with its configured command") + .action(installAction); +} 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/tests/cli/site_dev.spec.ts b/packages/cli/tests/cli/site_dev.spec.ts new file mode 100644 index 00000000..2c8a881a --- /dev/null +++ b/packages/cli/tests/cli/site_dev.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("site dev command", () => { + const t = setupCLITests(); + + it("runs the serveCommand as written, appending nothing", async () => { + // Where the dev server binds is the command's own business: in a sandbox the + // vite plugin binds it, so nothing is added to the line. + await t.givenLoggedInWithProject(fixture("with-npm-serve-command")); + + const handle = await t.runLive("site", "dev"); + await handle.waitForOutput(/ARGS=/); + await handle.stop(); + + expect(handle.stdout.join("")).toContain("ARGS= APP="); + }); + + it("runs a serveCommand that takes no forwarded arguments", async () => { + // A bare binary used to be refused because the address could not be + // appended to it. With nothing appended there is nothing to refuse. + await t.givenLoggedInWithProject(fixture("with-serve-command")); + + const handle = await t.runLive("site", "dev"); + // Wait on the child's own line: the startup message echoes the command, + // which also contains "SERVE_APP=". + await handle.waitForOutput(new RegExp(`SERVE_APP=${t.api.appId}`)); + await handle.stop(); + + expect(handle.stdout.join("")).toContain(`SERVE_APP=${t.api.appId}`); + }); + + it("takes no arguments", async () => { + await t.givenLoggedInWithProject(fixture("with-npm-serve-command")); + + const result = await t.run("site", "dev", "--port", "5999"); + + t.expectResult(result).toFail(); + }); + + it("serves without a login", async () => { + // The whole point of the command: a build sandbox that has never logged in. + await t.givenProject(fixture("with-npm-serve-command")); + + const handle = await t.runLive("site", "dev"); + await handle.waitForOutput(/ARGS=/); + await handle.stop(); + + expect(handle.stdout.join("")).toContain("ARGS="); + }); + + it("serves the frontend same-origin, with no backend url injected", async () => { + // A sandbox frontend reaches its backend through the vite plugin's /api + // proxy, so it must not be pointed anywhere else. + await t.givenLoggedInWithProject(fixture("with-npm-serve-command")); + + const handle = await t.runLive("site", "dev"); + await handle.waitForOutput(/ARGS=/); + await handle.stop(); + + const output = handle.stdout.join(""); + expect(output).toContain(`APP=${t.api.appId}`); + expect(output).toContain("URL=undefined"); + }); + + it("fails when the project has no site block", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const result = await t.run("site", "dev"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("no 'site' block"); + }); +}); diff --git a/packages/cli/tests/cli/site_install.spec.ts b/packages/cli/tests/cli/site_install.spec.ts new file mode 100644 index 00000000..72bc90da --- /dev/null +++ b/packages/cli/tests/cli/site_install.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("site install command", () => { + const t = setupCLITests(); + + it("runs the site's configured installCommand", async () => { + await t.givenLoggedInWithProject(fixture("with-installable-site")); + + const result = await t.run("site", "install"); + + t.expectResult(result).toSucceed(); + expect(await t.readProjectFile("install-marker.txt")).toBe("installed"); + }); + + it("installs without a login", async () => { + // A build sandbox that has never logged in must still be able to install. + await t.givenProject(fixture("with-installable-site")); + + const result = await t.run("site", "install"); + + t.expectResult(result).toSucceed(); + expect(await t.readProjectFile("install-marker.txt")).toBe("installed"); + }); + + it("fails when the installCommand fails", async () => { + await t.givenLoggedInWithProject(fixture("with-failing-install")); + + const result = await t.run("site", "install"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Install failed"); + }); + + it("fails when the project has no site block", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const result = await t.run("site", "install"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("No site install command found"); + }); +}); 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-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..461fb266 --- /dev/null +++ b/packages/cli/tests/fixtures/with-npm-serve-command/base44/config.jsonc @@ -0,0 +1,7 @@ +{ + "name": "Npm Serve Command Project", + // Empty on purpose: `site dev` falls back to `npm run dev`, which is what + // forwards an address through `--`. The schema does not default it — an + // absent serveCommand is how `base44 dev` knows to run the backend alone. + "site": {} +} 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);