From 69f980613a2c34e1b740954d86668dede99bc529 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Mon, 2 Mar 2026 21:03:58 +0200 Subject: [PATCH 01/27] feat: add `base44 exec` command for running scripts with pre-authenticated SDK Allows users to run arbitrary Deno scripts with a pre-configured `base44` global that has the SDK authenticated as the current user. Supports three input modes: file path, inline eval (-e), and stdin pipe. The CLI exchanges the platform token for an app user token via the builder auth endpoint before spawning the Deno subprocess, so scripts run with the user's app-level permissions (no service role access). Co-Authored-By: Claude Opus 4.6 --- deno-runtime/exec.ts | 46 +++++++++++ infra/build.ts | 13 ++- src/cli/commands/exec.ts | 174 +++++++++++++++++++++++++++++++++++++++ src/cli/program.ts | 4 + 4 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 deno-runtime/exec.ts create mode 100644 src/cli/commands/exec.ts diff --git a/deno-runtime/exec.ts b/deno-runtime/exec.ts new file mode 100644 index 00000000..5805e00a --- /dev/null +++ b/deno-runtime/exec.ts @@ -0,0 +1,46 @@ +/** + * Deno Exec Wrapper + * + * This script is executed by Deno to run user scripts with the Base44 SDK + * pre-authenticated and available as a global `base44` variable. + * + * Environment variables: + * - SCRIPT_PATH: Absolute path (or file:// URL) to the user's script + * - BASE44_APP_ID: App identifier from .app.jsonc + * - BASE44_ACCESS_TOKEN: User's access token + * - BASE44_API_URL: API endpoint (default: https://app.base44.com) + */ + +export {}; + +const scriptPath = Deno.env.get("SCRIPT_PATH"); +const appId = Deno.env.get("BASE44_APP_ID"); +const accessToken = Deno.env.get("BASE44_ACCESS_TOKEN"); +const apiUrl = Deno.env.get("BASE44_API_URL"); + +if (!scriptPath) { + console.error("SCRIPT_PATH environment variable is required"); + Deno.exit(1); +} + +if (!appId || !accessToken) { + console.error("BASE44_APP_ID and BASE44_ACCESS_TOKEN are required"); + Deno.exit(1); +} + +import { createClient } from "npm:@base44/sdk"; + +const base44 = createClient({ + appId, + token: accessToken, + serverUrl: apiUrl || "https://app.base44.com", +}); + +(globalThis as any).base44 = base44; + +try { + await import(scriptPath); +} catch (error) { + console.error("Failed to execute script:", error); + Deno.exit(1); +} diff --git a/infra/build.ts b/infra/build.ts index 611f6243..c990fc50 100644 --- a/infra/build.ts +++ b/infra/build.ts @@ -34,9 +34,15 @@ const runAllBuilds = async () => { entrypoints: ["./deno-runtime/main.ts"], outdir: "./dist/deno-runtime", }); + const denoExec = await runBuild({ + entrypoints: ["./deno-runtime/exec.ts"], + outdir: "./dist/deno-runtime", + external: ["npm:@base44/sdk"], + }); return { cli, denoRuntime, + denoExec, }; }; @@ -54,8 +60,8 @@ if (process.argv.includes("--watch")) { const time = new Date().toLocaleTimeString(); console.log(chalk.dim(`[${time}]`), chalk.gray(`${filename} ${event}d`)); - const { cli, denoRuntime } = await runAllBuilds(); - for (const result of [cli, denoRuntime]) { + const { cli, denoRuntime, denoExec } = await runAllBuilds(); + for (const result of [cli, denoRuntime, denoExec]) { if (result.success && result.outputs.length > 0) { console.log( chalk.green(` ✓ Rebuilt`), @@ -75,9 +81,10 @@ if (process.argv.includes("--watch")) { // Keep process alive await new Promise(() => {}); } else { - const { cli, denoRuntime } = await runAllBuilds(); + const { cli, denoRuntime, denoExec } = await runAllBuilds(); console.log(chalk.green.bold(`\n✓ Build complete\n`)); console.log(chalk.dim(" Output:")); console.log(` ${formatOutput(cli.outputs)}`); console.log(` ${formatOutput(denoRuntime.outputs)}`); + console.log(` ${formatOutput(denoExec.outputs)}`); } diff --git a/src/cli/commands/exec.ts b/src/cli/commands/exec.ts new file mode 100644 index 00000000..5844a63a --- /dev/null +++ b/src/cli/commands/exec.ts @@ -0,0 +1,174 @@ +import { spawn, spawnSync } from "node:child_process"; +import { unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Command } from "commander"; +import type { CLIContext } from "@/cli/types.js"; +import { runCommand } from "@/cli/utils/index.js"; +import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import { getAppClient } from "@/core/clients/index.js"; +import { getBase44ApiUrl } from "@/core/config.js"; +import { + ApiError, + DependencyNotFoundError, + InvalidInputError, +} from "@/core/errors.js"; +import { getAppConfig } from "@/core/project/app-config.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const EXEC_WRAPPER_PATH = join(__dirname, "../deno-runtime/exec.js"); + +function verifyDenoIsInstalled(): void { + const result = spawnSync("deno", ["--version"]); + if (result.error) { + throw new DependencyNotFoundError( + "Deno is required to run scripts with exec", + { + hints: [ + { + message: + "Install Deno: https://docs.deno.com/runtime/getting-started/installation/", + }, + ], + }, + ); + } +} + +function readStdin(): Promise { + return new Promise((resolve, reject) => { + let data = ""; + process.stdin.setEncoding("utf-8"); + process.stdin.on("data", (chunk: string) => { + data += chunk; + }); + process.stdin.on("end", () => resolve(data)); + process.stdin.on("error", reject); + }); +} + +interface ExecOptions { + eval?: string; +} + +async function execAction( + scriptArg: string | undefined, + options: ExecOptions, + extraArgs: string[], +): Promise { + verifyDenoIsInstalled(); + + let scriptPath: string; + let tempFile: string | null = null; + + const isStdinPipe = !process.stdin.isTTY; + const hasFile = scriptArg !== undefined; + const hasEval = options.eval !== undefined; + + // Validate: exactly one input mode + const modeCount = [hasFile, hasEval, isStdinPipe].filter(Boolean).length; + if (modeCount === 0) { + throw new InvalidInputError( + "No script provided. Pass a file path, use -e for inline code, or pipe from stdin.", + { + hints: [ + { message: "File: base44 exec ./script.ts" }, + { message: 'Eval: base44 exec -e "console.log(1)"' }, + { message: "Stdin: echo 'code' | base44 exec" }, + ], + }, + ); + } + if (modeCount > 1) { + throw new InvalidInputError( + "Multiple input modes detected. Provide only one of: file path, -e flag, or stdin.", + ); + } + + if (hasFile) { + scriptPath = `file://${resolve(scriptArg!)}`; + } else { + // Eval or stdin mode: write to temp file + const code = hasEval ? options.eval! : await readStdin(); + tempFile = join(tmpdir(), `base44-exec-${Date.now()}.ts`); + writeFileSync(tempFile, code, "utf-8"); + scriptPath = `file://${tempFile}`; + } + + // Exchange the platform token for an app user token + const appConfig = getAppConfig(); + let appUserToken: string; + try { + const response = await getAppClient() + .get("auth/token") + .json<{ token: string }>(); + appUserToken = response.token; + } catch (error) { + throw await ApiError.fromHttpError( + error, + "exchanging platform token for app user token", + ); + } + + try { + const exitCode = await new Promise((resolvePromise) => { + const child = spawn( + "deno", + ["run", "--allow-all", EXEC_WRAPPER_PATH, ...extraArgs], + { + env: { + ...process.env, + SCRIPT_PATH: scriptPath, + BASE44_APP_ID: appConfig.id, + BASE44_ACCESS_TOKEN: appUserToken, + BASE44_API_URL: getBase44ApiUrl(), + }, + stdio: "inherit", + }, + ); + + child.on("close", (code) => { + resolvePromise(code ?? 1); + }); + }); + + if (exitCode !== 0) { + process.exitCode = exitCode; + } + } finally { + if (tempFile) { + try { + unlinkSync(tempFile); + } catch { + // Ignore cleanup errors + } + } + } + + return {}; +} + +export function getExecCommand(context: CLIContext): Command { + const cmd = new Command("exec") + .description( + "Run a script with the Base44 SDK pre-authenticated as the current user", + ) + .argument("[script]", "Path to a .ts or .js script file") + .option("-e, --eval ", "Evaluate inline code") + .allowUnknownOption(true) + .action(async (script: string | undefined, options: ExecOptions) => { + // Collect everything after "--" as extra args for the Deno process + const dashIndex = process.argv.indexOf("--"); + const extraArgs = + dashIndex !== -1 ? process.argv.slice(dashIndex + 1) : []; + + await runCommand( + () => execAction(script, options, extraArgs), + { requireAuth: true }, + context, + ); + }); + + return cmd; +} diff --git a/src/cli/program.ts b/src/cli/program.ts index e24279b0..9a1f6d72 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -16,6 +16,7 @@ import { getSiteCommand } from "@/cli/commands/site/index.js"; import { getTypesCommand } from "@/cli/commands/types/index.js"; import packageJson from "../../package.json"; import { getDevCommand } from "./commands/dev.js"; +import { getExecCommand } from "./commands/exec.js"; import { getEjectCommand } from "./commands/project/eject.js"; import type { CLIContext } from "./types.js"; @@ -66,6 +67,9 @@ export function createProgram(context: CLIContext): Command { // Register types command program.addCommand(getTypesCommand(context)); + // Register exec command + program.addCommand(getExecCommand(context)); + // Register development commands program.addCommand(getDevCommand(context), { hidden: true }); From 1adcbe32c286190b53c2a3eb628253eac3c27409 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Mon, 2 Mar 2026 21:14:01 +0200 Subject: [PATCH 02/27] fix: only consider stdin mode when no file or eval is provided When running in non-TTY environments (CI, editor integrations, agent tools), process.stdin.isTTY is false even when a file path or -e flag is given. This caused a false "Multiple input modes detected" error. Now stdin is only considered as an input mode when neither file nor eval is provided, so explicit input modes always take priority. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/exec.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/cli/commands/exec.ts b/src/cli/commands/exec.ts index 5844a63a..cd6faa57 100644 --- a/src/cli/commands/exec.ts +++ b/src/cli/commands/exec.ts @@ -62,13 +62,18 @@ async function execAction( let scriptPath: string; let tempFile: string | null = null; - const isStdinPipe = !process.stdin.isTTY; const hasFile = scriptArg !== undefined; const hasEval = options.eval !== undefined; + // Only consider stdin when no explicit input mode (file/eval) was given + const isStdinPipe = !hasFile && !hasEval && !process.stdin.isTTY; - // Validate: exactly one input mode - const modeCount = [hasFile, hasEval, isStdinPipe].filter(Boolean).length; - if (modeCount === 0) { + if (hasFile && hasEval) { + throw new InvalidInputError( + "Cannot use both a file path and -e flag. Provide only one input mode.", + ); + } + + if (!hasFile && !hasEval && !isStdinPipe) { throw new InvalidInputError( "No script provided. Pass a file path, use -e for inline code, or pipe from stdin.", { @@ -80,11 +85,6 @@ async function execAction( }, ); } - if (modeCount > 1) { - throw new InvalidInputError( - "Multiple input modes detected. Provide only one of: file path, -e flag, or stdin.", - ); - } if (hasFile) { scriptPath = `file://${resolve(scriptArg!)}`; From 495e1c1009e5a865867f88475e8cc89d4342eefa Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Mon, 2 Mar 2026 21:20:22 +0200 Subject: [PATCH 03/27] fix: add --node-modules-dir=auto for Deno 2.x npm: specifier support Deno 2.x requires explicit opt-in for npm: specifier resolution when no deno.json is present. Without this flag, `npm:@base44/sdk` fails with ERR_UNSUPPORTED_ESM_URL_SCHEME. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/exec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/commands/exec.ts b/src/cli/commands/exec.ts index cd6faa57..146ea60c 100644 --- a/src/cli/commands/exec.ts +++ b/src/cli/commands/exec.ts @@ -115,7 +115,7 @@ async function execAction( const exitCode = await new Promise((resolvePromise) => { const child = spawn( "deno", - ["run", "--allow-all", EXEC_WRAPPER_PATH, ...extraArgs], + ["run", "--allow-all", "--node-modules-dir=auto", EXEC_WRAPPER_PATH, ...extraArgs], { env: { ...process.env, From 7d50dc2050d5037f76ecd33109249f5ef373f824 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Mon, 2 Mar 2026 21:25:42 +0200 Subject: [PATCH 04/27] fix: copy exec wrapper out of node_modules before running with Deno Deno 2.x treats files inside node_modules/ as Node modules, which don't support npm: specifiers. Copy the exec wrapper to a temp location outside node_modules so Deno processes it as a regular Deno script where npm: imports work natively. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/exec.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/cli/commands/exec.ts b/src/cli/commands/exec.ts index 146ea60c..651dcb50 100644 --- a/src/cli/commands/exec.ts +++ b/src/cli/commands/exec.ts @@ -1,5 +1,5 @@ import { spawn, spawnSync } from "node:child_process"; -import { unlinkSync, writeFileSync } from "node:fs"; +import { copyFileSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -111,11 +111,17 @@ async function execAction( ); } + // Copy the exec wrapper out of node_modules to a temp location. + // Deno 2.x treats files inside node_modules as Node modules and + // doesn't support npm: specifiers in them. + const tempWrapper = join(tmpdir(), `base44-exec-wrapper-${Date.now()}.js`); + copyFileSync(EXEC_WRAPPER_PATH, tempWrapper); + try { const exitCode = await new Promise((resolvePromise) => { const child = spawn( "deno", - ["run", "--allow-all", "--node-modules-dir=auto", EXEC_WRAPPER_PATH, ...extraArgs], + ["run", "--allow-all", "--node-modules-dir=auto", tempWrapper, ...extraArgs], { env: { ...process.env, @@ -137,11 +143,13 @@ async function execAction( process.exitCode = exitCode; } } finally { - if (tempFile) { - try { - unlinkSync(tempFile); - } catch { - // Ignore cleanup errors + for (const f of [tempFile, tempWrapper]) { + if (f) { + try { + unlinkSync(f); + } catch { + // Ignore cleanup errors + } } } } From 9247e256a7a27059626f45c8b77d1080644b1740 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Mon, 2 Mar 2026 21:40:18 +0200 Subject: [PATCH 05/27] fix: call cleanup() after script completes so Deno can exit The SDK's analytics module starts a heartbeat setInterval on createClient() which keeps the Deno event loop alive indefinitely. Call base44.cleanup() after the user script finishes to clear the interval and allow the process to exit naturally. Co-Authored-By: Claude Opus 4.6 --- deno-runtime/exec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/deno-runtime/exec.ts b/deno-runtime/exec.ts index 5805e00a..aabc2727 100644 --- a/deno-runtime/exec.ts +++ b/deno-runtime/exec.ts @@ -43,4 +43,8 @@ try { } catch (error) { console.error("Failed to execute script:", error); Deno.exit(1); +} finally { + // Clean up the SDK client (clears analytics heartbeat interval, + // disconnects socket) so the process can exit naturally. + base44.cleanup(); } From d1eac96bbf03b8fb70c4ff3a782b3862a77e62cd Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Mon, 2 Mar 2026 22:09:26 +0200 Subject: [PATCH 06/27] fix: format spawn args array and add exec command tests Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/exec.ts | 8 ++++++- tests/cli/exec.spec.ts | 45 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 tests/cli/exec.spec.ts diff --git a/src/cli/commands/exec.ts b/src/cli/commands/exec.ts index 651dcb50..6e37acb1 100644 --- a/src/cli/commands/exec.ts +++ b/src/cli/commands/exec.ts @@ -121,7 +121,13 @@ async function execAction( const exitCode = await new Promise((resolvePromise) => { const child = spawn( "deno", - ["run", "--allow-all", "--node-modules-dir=auto", tempWrapper, ...extraArgs], + [ + "run", + "--allow-all", + "--node-modules-dir=auto", + tempWrapper, + ...extraArgs, + ], { env: { ...process.env, diff --git a/tests/cli/exec.spec.ts b/tests/cli/exec.spec.ts new file mode 100644 index 00000000..d00d45e8 --- /dev/null +++ b/tests/cli/exec.spec.ts @@ -0,0 +1,45 @@ +import { describe, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("exec command", () => { + const t = setupCLITests(); + + it("shows help with --help flag", async () => { + const result = await t.run("exec", "--help"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Run a script with the Base44 SDK"); + t.expectResult(result).toContain("[script]"); + t.expectResult(result).toContain("-e, --eval"); + }); + + it("fails when not in a project directory", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + + const result = await t.run("exec", "some-script.ts"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("No Base44 project found"); + }); + + it("fails when both file and -e flag are provided", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const result = await t.run("exec", "script.ts", "-e", "console.log(1)"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Cannot use both a file path and -e flag"); + }); + + it("fails when token exchange returns an error", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockError("get", `/api/apps/test-app-id/auth/token`, { + status: 500, + body: { error: "Internal server error" }, + }); + + const result = await t.run("exec", "-e", "console.log(1)"); + + t.expectResult(result).toFail(); + }); +}); From cf7deb94c369d611aafa9dc52f497ffdc7a5bd05 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Mon, 2 Mar 2026 22:14:21 +0200 Subject: [PATCH 07/27] fix: validate input args before checking for Deno Move verifyDenoIsInstalled() after input validation so users get the right error message (e.g. conflicting flags) even when Deno is not installed. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/exec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/exec.ts b/src/cli/commands/exec.ts index 6e37acb1..d81d33fc 100644 --- a/src/cli/commands/exec.ts +++ b/src/cli/commands/exec.ts @@ -57,8 +57,6 @@ async function execAction( options: ExecOptions, extraArgs: string[], ): Promise { - verifyDenoIsInstalled(); - let scriptPath: string; let tempFile: string | null = null; @@ -86,6 +84,8 @@ async function execAction( ); } + verifyDenoIsInstalled(); + if (hasFile) { scriptPath = `file://${resolve(scriptArg!)}`; } else { From 8d65f985d7033ddd2705710e849c7abe1e5c80d9 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Mon, 2 Mar 2026 22:56:02 +0200 Subject: [PATCH 08/27] fix: route function calls through app subdomain in exec Fetch the app's published URL (subdomain) via the existing getSiteUrl() API and pass it to the Deno exec wrapper as BASE44_APP_BASE_URL. The wrapper uses it as serverUrl when creating the SDK client so that functions.invoke() goes through the app domain instead of the platform domain, which rejects function calls. Co-Authored-By: Claude Opus 4.6 --- deno-runtime/exec.ts | 6 +++++- src/cli/commands/exec.ts | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/deno-runtime/exec.ts b/deno-runtime/exec.ts index aabc2727..cc24611b 100644 --- a/deno-runtime/exec.ts +++ b/deno-runtime/exec.ts @@ -9,6 +9,7 @@ * - BASE44_APP_ID: App identifier from .app.jsonc * - BASE44_ACCESS_TOKEN: User's access token * - BASE44_API_URL: API endpoint (default: https://app.base44.com) + * - BASE44_APP_BASE_URL: App's published URL / subdomain (optional, used for function calls) */ export {}; @@ -17,6 +18,7 @@ const scriptPath = Deno.env.get("SCRIPT_PATH"); const appId = Deno.env.get("BASE44_APP_ID"); const accessToken = Deno.env.get("BASE44_ACCESS_TOKEN"); const apiUrl = Deno.env.get("BASE44_API_URL"); +const appBaseUrl = Deno.env.get("BASE44_APP_BASE_URL"); if (!scriptPath) { console.error("SCRIPT_PATH environment variable is required"); @@ -30,10 +32,12 @@ if (!appId || !accessToken) { import { createClient } from "npm:@base44/sdk"; +// Use the app's published URL (subdomain) as serverUrl when available so that +// function invocations route through the app domain instead of the platform. const base44 = createClient({ appId, token: accessToken, - serverUrl: apiUrl || "https://app.base44.com", + serverUrl: appBaseUrl || apiUrl || "https://app.base44.com", }); (globalThis as any).base44 = base44; diff --git a/src/cli/commands/exec.ts b/src/cli/commands/exec.ts index d81d33fc..ab52761a 100644 --- a/src/cli/commands/exec.ts +++ b/src/cli/commands/exec.ts @@ -15,6 +15,7 @@ import { InvalidInputError, } from "@/core/errors.js"; import { getAppConfig } from "@/core/project/app-config.js"; +import { getSiteUrl } from "@/core/site/api.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const EXEC_WRAPPER_PATH = join(__dirname, "../deno-runtime/exec.js"); @@ -111,6 +112,15 @@ async function execAction( ); } + // Fetch the app's published URL (subdomain) so the SDK can route + // function invocations through the app domain instead of the platform. + let appBaseUrl: string | undefined; + try { + appBaseUrl = await getSiteUrl(); + } catch { + // Non-fatal: fall back to the platform API URL + } + // Copy the exec wrapper out of node_modules to a temp location. // Deno 2.x treats files inside node_modules as Node modules and // doesn't support npm: specifiers in them. @@ -135,6 +145,7 @@ async function execAction( BASE44_APP_ID: appConfig.id, BASE44_ACCESS_TOKEN: appUserToken, BASE44_API_URL: getBase44ApiUrl(), + ...(appBaseUrl ? { BASE44_APP_BASE_URL: appBaseUrl } : {}), }, stdio: "inherit", }, From 10dc5414434730229d23f51fb3653ccaf7724144 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 07:08:54 +0000 Subject: [PATCH 09/27] fix: address PR review comments on exec command - infra/build.ts: remove Bun build step for exec.ts; Deno runs TS natively so just copy the raw file - src/cli/commands/exec.ts: add explicit --stdin flag instead of inferring from TTY, run token exchange and site URL fetch in parallel, make site URL fatal (required), fix Deno install URL, update EXEC_WRAPPER_PATH to .ts extension - deno-runtime/exec.ts: always use appBaseUrl, remove apiUrl fallbacks, validate appBaseUrl is set - tests/cli/exec.spec.ts: update test assertions for --stdin flag and new error message Co-authored-by: Netanel Gilad --- deno-runtime/exec.ts | 13 ++++---- infra/build.ts | 19 +++++------- src/cli/commands/exec.ts | 67 +++++++++++++++++++--------------------- tests/cli/exec.spec.ts | 5 +-- 4 files changed, 50 insertions(+), 54 deletions(-) diff --git a/deno-runtime/exec.ts b/deno-runtime/exec.ts index cc24611b..2c273aa5 100644 --- a/deno-runtime/exec.ts +++ b/deno-runtime/exec.ts @@ -8,8 +8,7 @@ * - SCRIPT_PATH: Absolute path (or file:// URL) to the user's script * - BASE44_APP_ID: App identifier from .app.jsonc * - BASE44_ACCESS_TOKEN: User's access token - * - BASE44_API_URL: API endpoint (default: https://app.base44.com) - * - BASE44_APP_BASE_URL: App's published URL / subdomain (optional, used for function calls) + * - BASE44_APP_BASE_URL: App's published URL / subdomain (used for function calls) */ export {}; @@ -17,7 +16,6 @@ export {}; const scriptPath = Deno.env.get("SCRIPT_PATH"); const appId = Deno.env.get("BASE44_APP_ID"); const accessToken = Deno.env.get("BASE44_ACCESS_TOKEN"); -const apiUrl = Deno.env.get("BASE44_API_URL"); const appBaseUrl = Deno.env.get("BASE44_APP_BASE_URL"); if (!scriptPath) { @@ -30,14 +28,17 @@ if (!appId || !accessToken) { Deno.exit(1); } +if (!appBaseUrl) { + console.error("BASE44_APP_BASE_URL environment variable is required"); + Deno.exit(1); +} + import { createClient } from "npm:@base44/sdk"; -// Use the app's published URL (subdomain) as serverUrl when available so that -// function invocations route through the app domain instead of the platform. const base44 = createClient({ appId, token: accessToken, - serverUrl: appBaseUrl || apiUrl || "https://app.base44.com", + serverUrl: appBaseUrl, }); (globalThis as any).base44 = base44; diff --git a/infra/build.ts b/infra/build.ts index c990fc50..14f8606e 100644 --- a/infra/build.ts +++ b/infra/build.ts @@ -1,4 +1,4 @@ -import { watch } from "node:fs"; +import { copyFileSync, mkdirSync, watch } from "node:fs"; import type { BuildConfig } from "bun"; import chalk from "chalk"; @@ -34,15 +34,12 @@ const runAllBuilds = async () => { entrypoints: ["./deno-runtime/main.ts"], outdir: "./dist/deno-runtime", }); - const denoExec = await runBuild({ - entrypoints: ["./deno-runtime/exec.ts"], - outdir: "./dist/deno-runtime", - external: ["npm:@base44/sdk"], - }); + // Deno runs TypeScript natively, so just copy exec.ts as-is (no bundling needed) + mkdirSync("./dist/deno-runtime", { recursive: true }); + copyFileSync("./deno-runtime/exec.ts", "./dist/deno-runtime/exec.ts"); return { cli, denoRuntime, - denoExec, }; }; @@ -60,8 +57,8 @@ if (process.argv.includes("--watch")) { const time = new Date().toLocaleTimeString(); console.log(chalk.dim(`[${time}]`), chalk.gray(`${filename} ${event}d`)); - const { cli, denoRuntime, denoExec } = await runAllBuilds(); - for (const result of [cli, denoRuntime, denoExec]) { + const { cli, denoRuntime } = await runAllBuilds(); + for (const result of [cli, denoRuntime]) { if (result.success && result.outputs.length > 0) { console.log( chalk.green(` ✓ Rebuilt`), @@ -81,10 +78,10 @@ if (process.argv.includes("--watch")) { // Keep process alive await new Promise(() => {}); } else { - const { cli, denoRuntime, denoExec } = await runAllBuilds(); + const { cli, denoRuntime } = await runAllBuilds(); console.log(chalk.green.bold(`\n✓ Build complete\n`)); console.log(chalk.dim(" Output:")); console.log(` ${formatOutput(cli.outputs)}`); console.log(` ${formatOutput(denoRuntime.outputs)}`); - console.log(` ${formatOutput(denoExec.outputs)}`); + console.log(` ${chalk.cyan("./dist/deno-runtime/exec.ts")} (copied)`); } diff --git a/src/cli/commands/exec.ts b/src/cli/commands/exec.ts index ab52761a..3de25ad1 100644 --- a/src/cli/commands/exec.ts +++ b/src/cli/commands/exec.ts @@ -8,7 +8,6 @@ import type { CLIContext } from "@/cli/types.js"; import { runCommand } from "@/cli/utils/index.js"; import type { RunCommandResult } from "@/cli/utils/runCommand.js"; import { getAppClient } from "@/core/clients/index.js"; -import { getBase44ApiUrl } from "@/core/config.js"; import { ApiError, DependencyNotFoundError, @@ -18,7 +17,7 @@ import { getAppConfig } from "@/core/project/app-config.js"; import { getSiteUrl } from "@/core/site/api.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const EXEC_WRAPPER_PATH = join(__dirname, "../deno-runtime/exec.js"); +const EXEC_WRAPPER_PATH = join(__dirname, "../deno-runtime/exec.ts"); function verifyDenoIsInstalled(): void { const result = spawnSync("deno", ["--version"]); @@ -29,7 +28,7 @@ function verifyDenoIsInstalled(): void { hints: [ { message: - "Install Deno: https://docs.deno.com/runtime/getting-started/installation/", + "Install Deno: https://docs.deno.com/runtime/getting_started/installation/", }, ], }, @@ -51,6 +50,7 @@ function readStdin(): Promise { interface ExecOptions { eval?: string; + stdin?: boolean; } async function execAction( @@ -63,23 +63,24 @@ async function execAction( const hasFile = scriptArg !== undefined; const hasEval = options.eval !== undefined; - // Only consider stdin when no explicit input mode (file/eval) was given - const isStdinPipe = !hasFile && !hasEval && !process.stdin.isTTY; + const hasStdin = options.stdin === true; - if (hasFile && hasEval) { + const inputCount = [hasFile, hasEval, hasStdin].filter(Boolean).length; + + if (inputCount > 1) { throw new InvalidInputError( - "Cannot use both a file path and -e flag. Provide only one input mode.", + "Cannot use more than one input mode. Provide only one of: file path, -e, or --stdin.", ); } - if (!hasFile && !hasEval && !isStdinPipe) { + if (inputCount === 0) { throw new InvalidInputError( - "No script provided. Pass a file path, use -e for inline code, or pipe from stdin.", + "No script provided. Pass a file path, use -e for inline code, or use --stdin.", { hints: [ { message: "File: base44 exec ./script.ts" }, { message: 'Eval: base44 exec -e "console.log(1)"' }, - { message: "Stdin: echo 'code' | base44 exec" }, + { message: "Stdin: echo 'code' | base44 exec --stdin" }, ], }, ); @@ -97,34 +98,30 @@ async function execAction( scriptPath = `file://${tempFile}`; } - // Exchange the platform token for an app user token + // Exchange the platform token for an app user token, and fetch the app's + // published URL in parallel. Both are required to run the script. const appConfig = getAppConfig(); - let appUserToken: string; - try { - const response = await getAppClient() - .get("auth/token") - .json<{ token: string }>(); - appUserToken = response.token; - } catch (error) { - throw await ApiError.fromHttpError( - error, - "exchanging platform token for app user token", - ); - } - - // Fetch the app's published URL (subdomain) so the SDK can route - // function invocations through the app domain instead of the platform. - let appBaseUrl: string | undefined; - try { - appBaseUrl = await getSiteUrl(); - } catch { - // Non-fatal: fall back to the platform API URL - } + const [appUserToken, appBaseUrl] = await Promise.all([ + (async () => { + try { + const response = await getAppClient() + .get("auth/token") + .json<{ token: string }>(); + return response.token; + } catch (error) { + throw await ApiError.fromHttpError( + error, + "exchanging platform token for app user token", + ); + } + })(), + getSiteUrl(), + ]); // Copy the exec wrapper out of node_modules to a temp location. // Deno 2.x treats files inside node_modules as Node modules and // doesn't support npm: specifiers in them. - const tempWrapper = join(tmpdir(), `base44-exec-wrapper-${Date.now()}.js`); + const tempWrapper = join(tmpdir(), `base44-exec-wrapper-${Date.now()}.ts`); copyFileSync(EXEC_WRAPPER_PATH, tempWrapper); try { @@ -144,8 +141,7 @@ async function execAction( SCRIPT_PATH: scriptPath, BASE44_APP_ID: appConfig.id, BASE44_ACCESS_TOKEN: appUserToken, - BASE44_API_URL: getBase44ApiUrl(), - ...(appBaseUrl ? { BASE44_APP_BASE_URL: appBaseUrl } : {}), + BASE44_APP_BASE_URL: appBaseUrl, }, stdio: "inherit", }, @@ -181,6 +177,7 @@ export function getExecCommand(context: CLIContext): Command { ) .argument("[script]", "Path to a .ts or .js script file") .option("-e, --eval ", "Evaluate inline code") + .option("--stdin", "Read script from stdin") .allowUnknownOption(true) .action(async (script: string | undefined, options: ExecOptions) => { // Collect everything after "--" as extra args for the Deno process diff --git a/tests/cli/exec.spec.ts b/tests/cli/exec.spec.ts index d00d45e8..ec2dc40e 100644 --- a/tests/cli/exec.spec.ts +++ b/tests/cli/exec.spec.ts @@ -11,6 +11,7 @@ describe("exec command", () => { t.expectResult(result).toContain("Run a script with the Base44 SDK"); t.expectResult(result).toContain("[script]"); t.expectResult(result).toContain("-e, --eval"); + t.expectResult(result).toContain("--stdin"); }); it("fails when not in a project directory", async () => { @@ -22,13 +23,13 @@ describe("exec command", () => { t.expectResult(result).toContain("No Base44 project found"); }); - it("fails when both file and -e flag are provided", async () => { + it("fails when multiple input modes are provided", async () => { await t.givenLoggedInWithProject(fixture("basic")); const result = await t.run("exec", "script.ts", "-e", "console.log(1)"); t.expectResult(result).toFail(); - t.expectResult(result).toContain("Cannot use both a file path and -e flag"); + t.expectResult(result).toContain("Cannot use more than one input mode"); }); it("fails when token exchange returns an error", async () => { From a35af350823500ccf00bded602268ae70d3dc042 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Sun, 8 Mar 2026 12:28:12 +0200 Subject: [PATCH 10/27] refactor(exec): address PR review feedback - Replace --stdin flag with `-` argument (Unix convention) - Move exec business logic to core/exec/ module for separation of concerns - Extract getUserAppToken() and verifyDenoInstalled() to core - Use tmp-promise for temp file handling (consistency with codebase) - Share verifyDenoInstalled() between exec and function-manager - Move input validation to Commander preAction hook - Add mockAuthToken() to test utils and real Deno success test - Clarify Deno 1.x/2.x compatibility in comments Made-with: Cursor --- src/cli/commands/exec.ts | 157 +++++---------------- src/cli/dev/dev-server/function-manager.ts | 20 +-- src/core/exec/index.ts | 1 + src/core/exec/run-script.ts | 122 ++++++++++++++++ tests/cli/exec.spec.ts | 13 +- tests/cli/testkit/Base44APIMock.ts | 10 ++ 6 files changed, 185 insertions(+), 138 deletions(-) create mode 100644 src/core/exec/index.ts create mode 100644 src/core/exec/run-script.ts diff --git a/src/cli/commands/exec.ts b/src/cli/commands/exec.ts index 3de25ad1..5d1f6761 100644 --- a/src/cli/commands/exec.ts +++ b/src/cli/commands/exec.ts @@ -1,41 +1,15 @@ -import { spawn, spawnSync } from "node:child_process"; -import { copyFileSync, unlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { Command } from "commander"; import type { CLIContext } from "@/cli/types.js"; import { runCommand } from "@/cli/utils/index.js"; import type { RunCommandResult } from "@/cli/utils/runCommand.js"; -import { getAppClient } from "@/core/clients/index.js"; -import { - ApiError, - DependencyNotFoundError, - InvalidInputError, -} from "@/core/errors.js"; -import { getAppConfig } from "@/core/project/app-config.js"; -import { getSiteUrl } from "@/core/site/api.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { runScript } from "@/core/exec/index.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const EXEC_WRAPPER_PATH = join(__dirname, "../deno-runtime/exec.ts"); -function verifyDenoIsInstalled(): void { - const result = spawnSync("deno", ["--version"]); - if (result.error) { - throw new DependencyNotFoundError( - "Deno is required to run scripts with exec", - { - hints: [ - { - message: - "Install Deno: https://docs.deno.com/runtime/getting_started/installation/", - }, - ], - }, - ); - } -} - function readStdin(): Promise { return new Promise((resolve, reject) => { let data = ""; @@ -50,121 +24,62 @@ function readStdin(): Promise { interface ExecOptions { eval?: string; - stdin?: boolean; } -async function execAction( - scriptArg: string | undefined, - options: ExecOptions, - extraArgs: string[], -): Promise { - let scriptPath: string; - let tempFile: string | null = null; +function validateInput(command: Command): void { + const [scriptArg] = command.args; + const { eval: evalCode } = command.opts(); - const hasFile = scriptArg !== undefined; - const hasEval = options.eval !== undefined; - const hasStdin = options.stdin === true; + const hasStdin = scriptArg === "-"; + const hasFile = scriptArg !== undefined && !hasStdin; + const hasEval = evalCode !== undefined; const inputCount = [hasFile, hasEval, hasStdin].filter(Boolean).length; if (inputCount > 1) { throw new InvalidInputError( - "Cannot use more than one input mode. Provide only one of: file path, -e, or --stdin.", + "Cannot use more than one input mode. Provide only one of: file path, -e, or -.", ); } if (inputCount === 0) { throw new InvalidInputError( - "No script provided. Pass a file path, use -e for inline code, or use --stdin.", + "No script provided. Pass a file path, use -e for inline code, or - for stdin.", { hints: [ { message: "File: base44 exec ./script.ts" }, { message: 'Eval: base44 exec -e "console.log(1)"' }, - { message: "Stdin: echo 'code' | base44 exec --stdin" }, + { message: "Stdin: echo 'code' | base44 exec -" }, ], }, ); } +} - verifyDenoIsInstalled(); - - if (hasFile) { - scriptPath = `file://${resolve(scriptArg!)}`; - } else { - // Eval or stdin mode: write to temp file - const code = hasEval ? options.eval! : await readStdin(); - tempFile = join(tmpdir(), `base44-exec-${Date.now()}.ts`); - writeFileSync(tempFile, code, "utf-8"); - scriptPath = `file://${tempFile}`; +async function execAction( + scriptArg: string | undefined, + options: ExecOptions, + extraArgs: string[], +): Promise { + const hasStdin = scriptArg === "-"; + const hasFile = scriptArg !== undefined && !hasStdin; + + let code: string | undefined; + if (hasStdin) { + code = await readStdin(); + } else if (options.eval !== undefined) { + code = options.eval; } - // Exchange the platform token for an app user token, and fetch the app's - // published URL in parallel. Both are required to run the script. - const appConfig = getAppConfig(); - const [appUserToken, appBaseUrl] = await Promise.all([ - (async () => { - try { - const response = await getAppClient() - .get("auth/token") - .json<{ token: string }>(); - return response.token; - } catch (error) { - throw await ApiError.fromHttpError( - error, - "exchanging platform token for app user token", - ); - } - })(), - getSiteUrl(), - ]); - - // Copy the exec wrapper out of node_modules to a temp location. - // Deno 2.x treats files inside node_modules as Node modules and - // doesn't support npm: specifiers in them. - const tempWrapper = join(tmpdir(), `base44-exec-wrapper-${Date.now()}.ts`); - copyFileSync(EXEC_WRAPPER_PATH, tempWrapper); - - try { - const exitCode = await new Promise((resolvePromise) => { - const child = spawn( - "deno", - [ - "run", - "--allow-all", - "--node-modules-dir=auto", - tempWrapper, - ...extraArgs, - ], - { - env: { - ...process.env, - SCRIPT_PATH: scriptPath, - BASE44_APP_ID: appConfig.id, - BASE44_ACCESS_TOKEN: appUserToken, - BASE44_APP_BASE_URL: appBaseUrl, - }, - stdio: "inherit", - }, - ); - - child.on("close", (code) => { - resolvePromise(code ?? 1); - }); - }); + const { exitCode } = await runScript({ + filePath: hasFile ? scriptArg : undefined, + code, + extraArgs, + execWrapperPath: EXEC_WRAPPER_PATH, + }); - if (exitCode !== 0) { - process.exitCode = exitCode; - } - } finally { - for (const f of [tempFile, tempWrapper]) { - if (f) { - try { - unlinkSync(f); - } catch { - // Ignore cleanup errors - } - } - } + if (exitCode !== 0) { + process.exitCode = exitCode; } return {}; @@ -175,10 +90,10 @@ export function getExecCommand(context: CLIContext): Command { .description( "Run a script with the Base44 SDK pre-authenticated as the current user", ) - .argument("[script]", "Path to a .ts or .js script file") + .argument("[script]", "Path to a .ts/.js file, or - for stdin") .option("-e, --eval ", "Evaluate inline code") - .option("--stdin", "Read script from stdin") .allowUnknownOption(true) + .hook("preAction", validateInput) .action(async (script: string | undefined, options: ExecOptions) => { // Collect everything after "--" as extra args for the Deno process const dashIndex = process.argv.indexOf("--"); diff --git a/src/cli/dev/dev-server/function-manager.ts b/src/cli/dev/dev-server/function-manager.ts index 727a4083..bedb66e8 100644 --- a/src/cli/dev/dev-server/function-manager.ts +++ b/src/cli/dev/dev-server/function-manager.ts @@ -1,13 +1,10 @@ import type { ChildProcess } from "node:child_process"; -import { spawn, spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import getPort from "get-port"; -import { - DependencyNotFoundError, - InternalError, - InvalidInputError, -} from "@/core/errors.js"; +import { InternalError, InvalidInputError } from "@/core/errors.js"; +import { verifyDenoInstalled } from "@/core/exec/index.js"; import type { BackendFunction } from "@/core/resources/function/schema.js"; import type { Logger } from "../createDevLogger"; @@ -34,16 +31,7 @@ export class FunctionManager { this.logger = logger; if (functions.length > 0) { - this.verifyDenoIsInstalled(); - } - } - - private verifyDenoIsInstalled(): void { - const result = spawnSync("deno", ["--version"]); - if (result.error) { - throw new DependencyNotFoundError("Deno is required to run functions", { - hints: [{ message: "Install Deno from https://deno.com/download" }], - }); + verifyDenoInstalled(); } } diff --git a/src/core/exec/index.ts b/src/core/exec/index.ts new file mode 100644 index 00000000..3ce13535 --- /dev/null +++ b/src/core/exec/index.ts @@ -0,0 +1 @@ +export * from "./run-script.js"; diff --git a/src/core/exec/run-script.ts b/src/core/exec/run-script.ts new file mode 100644 index 00000000..b2a8e6c9 --- /dev/null +++ b/src/core/exec/run-script.ts @@ -0,0 +1,122 @@ +import { spawn, spawnSync } from "node:child_process"; +import { copyFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { file } from "tmp-promise"; +import { getAppClient } from "@/core/clients/index.js"; +import { ApiError, DependencyNotFoundError } from "@/core/errors.js"; +import { getAppConfig } from "@/core/project/app-config.js"; +import { getSiteUrl } from "@/core/site/api.js"; + +export interface RunScriptOptions { + filePath?: string; + code?: string; + extraArgs?: string[]; + execWrapperPath: string; +} + +export interface RunScriptResult { + exitCode: number; +} + +export function verifyDenoInstalled(): void { + const result = spawnSync("deno", ["--version"]); + if (result.error) { + throw new DependencyNotFoundError( + "Deno is required to run scripts with exec", + { + hints: [ + { + message: + "Install Deno: https://docs.deno.com/runtime/getting_started/installation/", + }, + ], + }, + ); + } +} + +export async function getUserAppToken(): Promise { + try { + const response = await getAppClient() + .get("auth/token") + .json<{ token: string }>(); + return response.token; + } catch (error) { + throw await ApiError.fromHttpError( + error, + "exchanging platform token for app user token", + ); + } +} + +export async function runScript( + options: RunScriptOptions, +): Promise { + const { filePath, code, extraArgs = [], execWrapperPath } = options; + + verifyDenoInstalled(); + + const cleanupFns: (() => void)[] = []; + + let scriptPath: string; + + if (filePath) { + scriptPath = `file://${resolve(filePath)}`; + } else if (code !== undefined) { + const tempScript = await file({ postfix: ".ts" }); + cleanupFns.push(tempScript.cleanup); + writeFileSync(tempScript.path, code, "utf-8"); + scriptPath = `file://${tempScript.path}`; + } else { + throw new Error("Either filePath or code must be provided"); + } + + const appConfig = getAppConfig(); + const [appUserToken, appBaseUrl] = await Promise.all([ + getUserAppToken(), + getSiteUrl(), + ]); + + // Copy the exec wrapper to a temp location outside node_modules. + // This works with both Deno 1.x and 2.x, but is required for Deno 2.x + // which treats files inside node_modules as Node modules and blocks + // npm: specifiers in them. + const tempWrapper = await file({ postfix: ".ts" }); + cleanupFns.push(tempWrapper.cleanup); + copyFileSync(execWrapperPath, tempWrapper.path); + + try { + const exitCode = await new Promise((resolvePromise) => { + const child = spawn( + "deno", + [ + "run", + "--allow-all", + "--node-modules-dir=auto", + tempWrapper.path, + ...extraArgs, + ], + { + env: { + ...process.env, + SCRIPT_PATH: scriptPath, + BASE44_APP_ID: appConfig.id, + BASE44_ACCESS_TOKEN: appUserToken, + BASE44_APP_BASE_URL: appBaseUrl, + }, + stdio: "inherit", + }, + ); + + child.on("close", (code) => { + resolvePromise(code ?? 1); + }); + }); + + return { exitCode }; + } finally { + for (const cleanup of cleanupFns) { + cleanup(); + } + } +} diff --git a/tests/cli/exec.spec.ts b/tests/cli/exec.spec.ts index ec2dc40e..7c178162 100644 --- a/tests/cli/exec.spec.ts +++ b/tests/cli/exec.spec.ts @@ -11,7 +11,7 @@ describe("exec command", () => { t.expectResult(result).toContain("Run a script with the Base44 SDK"); t.expectResult(result).toContain("[script]"); t.expectResult(result).toContain("-e, --eval"); - t.expectResult(result).toContain("--stdin"); + t.expectResult(result).toContain("or - for stdin"); }); it("fails when not in a project directory", async () => { @@ -43,4 +43,15 @@ describe("exec command", () => { t.expectResult(result).toFail(); }); + + it("executes inline code successfully with -e flag", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAuthToken("test-app-token"); + t.api.mockSiteUrl({ url: "https://test-app.base44.app" }); + + // Note: script output goes directly to terminal (stdio: inherit), not captured here + const result = await t.run("exec", "-e", "console.log('hello from exec')"); + + t.expectResult(result).toSucceed(); + }); }); diff --git a/tests/cli/testkit/Base44APIMock.ts b/tests/cli/testkit/Base44APIMock.ts index 9c68f21b..4575b0fc 100644 --- a/tests/cli/testkit/Base44APIMock.ts +++ b/tests/cli/testkit/Base44APIMock.ts @@ -210,6 +210,16 @@ export class Base44APIMock { return this; } + /** Mock GET /api/apps/{appId}/auth/token - Exchange platform token for app user token */ + mockAuthToken(token: string): this { + this.handlers.push( + http.get(`${BASE_URL}/api/apps/${this.appId}/auth/token`, () => + HttpResponse.json({ token }), + ), + ); + return this; + } + /** Mock PUT /api/apps/{appId}/agent-configs - Push agents */ mockAgentsPush(response: AgentsPushResponse): this { this.handlers.push( From 669fceccd286196bc8622c6bded307cb729ff912 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Tue, 10 Mar 2026 08:24:06 +0200 Subject: [PATCH 11/27] stdin --- src/cli/commands/exec.ts | 64 ++++++--------------------------- tests/cli/exec.spec.ts | 29 +++++++-------- tests/cli/testkit/CLITestkit.ts | 53 +++++++++++++++++++++++++++ tests/cli/testkit/index.ts | 4 +++ 4 files changed, 83 insertions(+), 67 deletions(-) diff --git a/src/cli/commands/exec.ts b/src/cli/commands/exec.ts index 5d1f6761..6b686751 100644 --- a/src/cli/commands/exec.ts +++ b/src/cli/commands/exec.ts @@ -22,57 +22,19 @@ function readStdin(): Promise { }); } -interface ExecOptions { - eval?: string; -} - -function validateInput(command: Command): void { - const [scriptArg] = command.args; - const { eval: evalCode } = command.opts(); - - const hasStdin = scriptArg === "-"; - const hasFile = scriptArg !== undefined && !hasStdin; - const hasEval = evalCode !== undefined; - - const inputCount = [hasFile, hasEval, hasStdin].filter(Boolean).length; - - if (inputCount > 1) { - throw new InvalidInputError( - "Cannot use more than one input mode. Provide only one of: file path, -e, or -.", - ); - } - - if (inputCount === 0) { - throw new InvalidInputError( - "No script provided. Pass a file path, use -e for inline code, or - for stdin.", - { - hints: [ - { message: "File: base44 exec ./script.ts" }, - { message: 'Eval: base44 exec -e "console.log(1)"' }, - { message: "Stdin: echo 'code' | base44 exec -" }, - ], - }, - ); +async function execAction(extraArgs: string[]): Promise { + if (process.stdin.isTTY) { + throw new InvalidInputError("No input provided. Pipe a script to stdin.", { + hints: [ + { message: "File: cat ./script.ts | base44 exec" }, + { message: 'Eval: echo "console.log(1)" | base44 exec' }, + ], + }); } -} -async function execAction( - scriptArg: string | undefined, - options: ExecOptions, - extraArgs: string[], -): Promise { - const hasStdin = scriptArg === "-"; - const hasFile = scriptArg !== undefined && !hasStdin; - - let code: string | undefined; - if (hasStdin) { - code = await readStdin(); - } else if (options.eval !== undefined) { - code = options.eval; - } + const code = await readStdin(); const { exitCode } = await runScript({ - filePath: hasFile ? scriptArg : undefined, code, extraArgs, execWrapperPath: EXEC_WRAPPER_PATH, @@ -90,18 +52,14 @@ export function getExecCommand(context: CLIContext): Command { .description( "Run a script with the Base44 SDK pre-authenticated as the current user", ) - .argument("[script]", "Path to a .ts/.js file, or - for stdin") - .option("-e, --eval ", "Evaluate inline code") - .allowUnknownOption(true) - .hook("preAction", validateInput) - .action(async (script: string | undefined, options: ExecOptions) => { + .action(async () => { // Collect everything after "--" as extra args for the Deno process const dashIndex = process.argv.indexOf("--"); const extraArgs = dashIndex !== -1 ? process.argv.slice(dashIndex + 1) : []; await runCommand( - () => execAction(script, options, extraArgs), + () => execAction(extraArgs), { requireAuth: true }, context, ); diff --git a/tests/cli/exec.spec.ts b/tests/cli/exec.spec.ts index 7c178162..c3ff1c87 100644 --- a/tests/cli/exec.spec.ts +++ b/tests/cli/exec.spec.ts @@ -9,27 +9,26 @@ describe("exec command", () => { t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Run a script with the Base44 SDK"); - t.expectResult(result).toContain("[script]"); - t.expectResult(result).toContain("-e, --eval"); - t.expectResult(result).toContain("or - for stdin"); }); - it("fails when not in a project directory", async () => { - await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + it("fails with helpful error when no stdin is piped", async () => { + await t.givenLoggedInWithProject(fixture("basic")); - const result = await t.run("exec", "some-script.ts"); + const result = await t.run("exec"); t.expectResult(result).toFail(); - t.expectResult(result).toContain("No Base44 project found"); + t.expectResult(result).toContain("No input provided"); + t.expectResult(result).toContain("cat ./script.ts | base44 exec"); }); - it("fails when multiple input modes are provided", async () => { - await t.givenLoggedInWithProject(fixture("basic")); + it("fails when not in a project directory", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + t.givenStdin("console.log(1)"); - const result = await t.run("exec", "script.ts", "-e", "console.log(1)"); + const result = await t.run("exec"); t.expectResult(result).toFail(); - t.expectResult(result).toContain("Cannot use more than one input mode"); + t.expectResult(result).toContain("No Base44 project found"); }); it("fails when token exchange returns an error", async () => { @@ -38,19 +37,21 @@ describe("exec command", () => { status: 500, body: { error: "Internal server error" }, }); + t.givenStdin("console.log(1)"); - const result = await t.run("exec", "-e", "console.log(1)"); + const result = await t.run("exec"); t.expectResult(result).toFail(); }); - it("executes inline code successfully with -e flag", async () => { + it("executes piped code successfully", async () => { await t.givenLoggedInWithProject(fixture("basic")); t.api.mockAuthToken("test-app-token"); t.api.mockSiteUrl({ url: "https://test-app.base44.app" }); + t.givenStdin("console.log('hello from exec')"); // Note: script output goes directly to terminal (stdio: inherit), not captured here - const result = await t.run("exec", "-e", "console.log('hello from exec')"); + const result = await t.run("exec"); t.expectResult(result).toSucceed(); }); diff --git a/tests/cli/testkit/CLITestkit.ts b/tests/cli/testkit/CLITestkit.ts index e81ad96a..ac3290ad 100644 --- a/tests/cli/testkit/CLITestkit.ts +++ b/tests/cli/testkit/CLITestkit.ts @@ -1,4 +1,5 @@ import { access, cp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { Readable } from "node:stream"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { Command } from "commander"; @@ -39,6 +40,7 @@ export class CLITestkit { private projectDir?: string; // Default latestVersion to null to skip npm version check in tests private testOverrides: TestOverrides = { latestVersion: null }; + private stdinContent: string | undefined = undefined; /** Typed API mock for Base44 endpoints */ readonly api: Base44APIMock; @@ -102,6 +104,11 @@ export class CLITestkit { this.testOverrides.latestVersion = version; } + /** Simulate piped stdin for the next run() call */ + givenStdin(content: string): void { + this.stdinContent = content; + } + // ─── WHEN METHODS ───────────────────────────────────────────── /** Execute CLI command */ @@ -122,6 +129,9 @@ export class CLITestkit { // Setup process.exit mock const { exitState, originalExit } = this.setupExitMock(); + // Setup stdin mock if content was provided via givenStdin() + const stdinCleanup = this.setupStdinMock(); + // Apply all API mocks before running this.api.apply(); @@ -171,6 +181,8 @@ export class CLITestkit { } finally { // Restore process.exit process.exit = originalExit; + // Restore stdin + stdinCleanup(); // Restore environment variables this.restoreEnvSnapshot(originalEnv); // Restore mocks @@ -251,6 +263,47 @@ export class CLITestkit { return { exitState, originalExit }; } + private setupStdinMock(): () => void { + const content = this.stdinContent; + this.stdinContent = undefined; + + const originalStdinDescriptor = Object.getOwnPropertyDescriptor( + process, + "stdin", + ); + + const mockStdin = new Readable({ read() {} }); + + if (content !== undefined) { + // Simulate piped input: not a TTY, push content then EOF + Object.defineProperty(mockStdin, "isTTY", { + value: false, + configurable: true, + }); + // Push synchronously so it's buffered before listeners attach + mockStdin.push(content); + mockStdin.push(null); + } else { + // Simulate interactive terminal: isTTY = true, no data + Object.defineProperty(mockStdin, "isTTY", { + value: true, + configurable: true, + }); + } + + Object.defineProperty(process, "stdin", { + value: mockStdin, + configurable: true, + writable: true, + }); + + return () => { + if (originalStdinDescriptor) { + Object.defineProperty(process, "stdin", originalStdinDescriptor); + } + }; + } + // ─── THEN METHODS ───────────────────────────────────────────── /** Create assertion helper for CLI result */ diff --git a/tests/cli/testkit/index.ts b/tests/cli/testkit/index.ts index 24ff6c6f..1079a9ee 100644 --- a/tests/cli/testkit/index.ts +++ b/tests/cli/testkit/index.ts @@ -37,6 +37,9 @@ export interface TestContext { givenLatestVersion: (version: string | null | undefined) => void; + /** Simulate piped stdin for the next run() call */ + givenStdin: (content: string) => void; + // ─── WHEN METHODS ────────────────────────────────────────── /** Execute CLI command */ @@ -128,6 +131,7 @@ export function setupCLITests(): TestContext { await getKit().givenProject(fixturePath); }, givenLatestVersion: (version) => getKit().givenLatestVersion(version), + givenStdin: (content) => getKit().givenStdin(content), // When methods run: (...args) => getKit().run(...args), From 976332504a603de47184cc175f409bda8f12fefd Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Tue, 17 Mar 2026 13:20:24 +0200 Subject: [PATCH 12/27] fix: resolve lint, knip, and test failures in exec command - Remove unused spawnSync import in function-manager.ts - Fix Biome formatting in TestAPIServer.ts (mockAuthToken, mockError) - Unexport internal-only symbols in run-script.ts (getUserAppToken, RunScriptOptions, RunScriptResult) - Fix exec "no stdin" test timeout: add empty-stdin check after readStdin() since process.stdin.isTTY is never true in child processes - Fix CLITestkit to close stdin immediately when no content is given, preventing hangs in all test commands Made-with: Cursor --- packages/cli/src/cli/commands/exec.ts | 15 ++++++++++++--- .../src/cli/dev/dev-server/function-manager.ts | 2 +- packages/cli/src/core/exec/run-script.ts | 6 +++--- packages/cli/tests/cli/testkit/CLITestkit.ts | 2 +- packages/cli/tests/cli/testkit/TestAPIServer.ts | 10 ++++------ 5 files changed, 21 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/cli/commands/exec.ts b/packages/cli/src/cli/commands/exec.ts index 6b686751..891b13e2 100644 --- a/packages/cli/src/cli/commands/exec.ts +++ b/packages/cli/src/cli/commands/exec.ts @@ -23,17 +23,26 @@ function readStdin(): Promise { } async function execAction(extraArgs: string[]): Promise { - if (process.stdin.isTTY) { - throw new InvalidInputError("No input provided. Pipe a script to stdin.", { + const noInputError = new InvalidInputError( + "No input provided. Pipe a script to stdin.", + { hints: [ { message: "File: cat ./script.ts | base44 exec" }, { message: 'Eval: echo "console.log(1)" | base44 exec' }, ], - }); + }, + ); + + if (process.stdin.isTTY) { + throw noInputError; } const code = await readStdin(); + if (!code.trim()) { + throw noInputError; + } + const { exitCode } = await runScript({ code, extraArgs, diff --git a/packages/cli/src/cli/dev/dev-server/function-manager.ts b/packages/cli/src/cli/dev/dev-server/function-manager.ts index 384a0835..af8bda81 100644 --- a/packages/cli/src/cli/dev/dev-server/function-manager.ts +++ b/packages/cli/src/cli/dev/dev-server/function-manager.ts @@ -1,5 +1,5 @@ import type { ChildProcess } from "node:child_process"; -import { spawn, spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import getPort from "get-port"; import { InternalError, InvalidInputError } from "@/core/errors.js"; import { verifyDenoInstalled } from "@/core/exec/index.js"; diff --git a/packages/cli/src/core/exec/run-script.ts b/packages/cli/src/core/exec/run-script.ts index b2a8e6c9..d8aabc79 100644 --- a/packages/cli/src/core/exec/run-script.ts +++ b/packages/cli/src/core/exec/run-script.ts @@ -7,14 +7,14 @@ import { ApiError, DependencyNotFoundError } from "@/core/errors.js"; import { getAppConfig } from "@/core/project/app-config.js"; import { getSiteUrl } from "@/core/site/api.js"; -export interface RunScriptOptions { +interface RunScriptOptions { filePath?: string; code?: string; extraArgs?: string[]; execWrapperPath: string; } -export interface RunScriptResult { +interface RunScriptResult { exitCode: number; } @@ -35,7 +35,7 @@ export function verifyDenoInstalled(): void { } } -export async function getUserAppToken(): Promise { +async function getUserAppToken(): Promise { try { const response = await getAppClient() .get("auth/token") diff --git a/packages/cli/tests/cli/testkit/CLITestkit.ts b/packages/cli/tests/cli/testkit/CLITestkit.ts index 6f017dcc..54f749b9 100644 --- a/packages/cli/tests/cli/testkit/CLITestkit.ts +++ b/packages/cli/tests/cli/testkit/CLITestkit.ts @@ -144,7 +144,7 @@ export class CLITestkit { env, reject: false, all: false, - ...(stdinContent !== undefined ? { input: stdinContent } : {}), + input: stdinContent ?? "", }); return { diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 40f03840..efdf4665 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -342,7 +342,9 @@ export class TestAPIServer { /** Mock GET /api/apps/{appId}/auth/token - Exchange platform token for app user token */ mockAuthToken(token: string): this { - return this.addRoute("GET", `/api/apps/${this.appId}/auth/token`, { token }); + return this.addRoute("GET", `/api/apps/${this.appId}/auth/token`, { + token, + }); } // ─── APP-SCOPED ENDPOINTS ──────────────────────────────── @@ -543,11 +545,7 @@ export class TestAPIServer { path: string, error: ErrorResponse, ): this { - return this.addErrorRoute( - method.toUpperCase() as Method, - path, - error, - ); + return this.addErrorRoute(method.toUpperCase() as Method, path, error); } /** From 17d94679b25853d986a582b5f56ab301d2848e8d Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Tue, 17 Mar 2026 13:26:37 +0200 Subject: [PATCH 13/27] fix: include dist/deno-runtime in npm package files The exec wrapper (dist/deno-runtime/exec.ts) was missing from the published package because the files array in package.json only included dist/cli and dist/assets. Made-with: Cursor --- packages/cli/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/package.json b/packages/cli/package.json index 6bb5566a..0ea7aecf 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -9,6 +9,7 @@ "files": [ "dist/cli", "dist/assets", + "dist/deno-runtime", "bin" ], "scripts": { From 02648fb584b2598871d27e280c1acd10d8a8c4b2 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Wed, 18 Mar 2026 09:45:36 +0200 Subject: [PATCH 14/27] fix: add Deno to CI and move exec wrapper into assets system - Add setup-deno step to test workflow so exec tests can spawn Deno - Move exec.ts into dist/assets/deno-runtime/ (alongside main.ts) so it's included in the assets tarball for binary distribution - Add getExecWrapperPath() to assets.ts and use it in exec command instead of resolving via import.meta.url (which doesn't work in compiled binaries) - Remove the separate dist/deno-runtime/ directory Made-with: Cursor --- .github/workflows/test.yml | 5 +++++ packages/cli/infra/build.ts | 19 ++++++++----------- packages/cli/package.json | 1 - packages/cli/src/cli/commands/exec.ts | 8 ++------ packages/cli/src/core/assets.ts | 4 ++++ 5 files changed, 19 insertions(+), 18 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3e21ea7e..bf9223be 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,6 +34,11 @@ jobs: restore-keys: | ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-version }}- + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - name: Install dependencies run: bun install --frozen-lockfile working-directory: . diff --git a/packages/cli/infra/build.ts b/packages/cli/infra/build.ts index 06ca3903..775b8c59 100644 --- a/packages/cli/infra/build.ts +++ b/packages/cli/infra/build.ts @@ -29,7 +29,8 @@ const copyDenoRuntime = () => { const outDir = "./dist/assets/deno-runtime"; mkdirSync(outDir, { recursive: true }); copyFileSync("./deno-runtime/main.ts", `${outDir}/main.ts`); - return `${outDir}/main.ts`; + copyFileSync("./deno-runtime/exec.ts", `${outDir}/exec.ts`); + return outDir; }; const runAllBuilds = async () => { @@ -37,13 +38,10 @@ const runAllBuilds = async () => { entrypoints: ["./src/cli/index.ts"], outdir: "./dist/cli", }); - const denoRuntimePath = copyDenoRuntime(); - // Deno runs TypeScript natively, so just copy exec.ts as-is (no bundling needed) - mkdirSync("./dist/deno-runtime", { recursive: true }); - copyFileSync("./deno-runtime/exec.ts", "./dist/deno-runtime/exec.ts"); + const denoRuntimeDir = copyDenoRuntime(); return { cli, - denoRuntimePath, + denoRuntimeDir, }; }; @@ -61,7 +59,7 @@ if (process.argv.includes("--watch")) { const time = new Date().toLocaleTimeString(); console.log(chalk.dim(`[${time}]`), chalk.gray(`${filename} ${event}d`)); - const { cli, denoRuntimePath } = await runAllBuilds(); + const { cli, denoRuntimeDir } = await runAllBuilds(); if (cli.success && cli.outputs.length > 0) { console.log( chalk.green(` ✓ Rebuilt`), @@ -72,7 +70,7 @@ if (process.argv.includes("--watch")) { console.log( chalk.green(` ✓ Copied`), chalk.dim(`→`), - chalk.cyan(denoRuntimePath), + chalk.cyan(denoRuntimeDir), ); }; @@ -85,10 +83,9 @@ if (process.argv.includes("--watch")) { // Keep process alive await new Promise(() => {}); } else { - const { cli, denoRuntimePath } = await runAllBuilds(); + const { cli, denoRuntimeDir } = await runAllBuilds(); console.log(chalk.green.bold(`\n✓ Build complete\n`)); console.log(chalk.dim(" Output:")); console.log(` ${formatOutput(cli.outputs)}`); - console.log(` ${chalk.cyan(denoRuntimePath)}`); - console.log(` ${chalk.cyan("./dist/deno-runtime/exec.ts")} (copied)`); + console.log(` ${chalk.cyan(denoRuntimeDir)}`); } diff --git a/packages/cli/package.json b/packages/cli/package.json index 0ea7aecf..6bb5566a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -9,7 +9,6 @@ "files": [ "dist/cli", "dist/assets", - "dist/deno-runtime", "bin" ], "scripts": { diff --git a/packages/cli/src/cli/commands/exec.ts b/packages/cli/src/cli/commands/exec.ts index 891b13e2..facba342 100644 --- a/packages/cli/src/cli/commands/exec.ts +++ b/packages/cli/src/cli/commands/exec.ts @@ -1,15 +1,11 @@ -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; import { Command } from "commander"; import type { CLIContext } from "@/cli/types.js"; import { runCommand } from "@/cli/utils/index.js"; import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import { getExecWrapperPath } from "@/core/assets.js"; import { InvalidInputError } from "@/core/errors.js"; import { runScript } from "@/core/exec/index.js"; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const EXEC_WRAPPER_PATH = join(__dirname, "../deno-runtime/exec.ts"); - function readStdin(): Promise { return new Promise((resolve, reject) => { let data = ""; @@ -46,7 +42,7 @@ async function execAction(extraArgs: string[]): Promise { const { exitCode } = await runScript({ code, extraArgs, - execWrapperPath: EXEC_WRAPPER_PATH, + execWrapperPath: getExecWrapperPath(), }); if (exitCode !== 0) { diff --git a/packages/cli/src/core/assets.ts b/packages/cli/src/core/assets.ts index 246bb23b..237b0990 100644 --- a/packages/cli/src/core/assets.ts +++ b/packages/cli/src/core/assets.ts @@ -17,6 +17,10 @@ export function getDenoWrapperPath(): string { return join(ASSETS_DIR, "deno-runtime", "main.ts"); } +export function getExecWrapperPath(): string { + return join(ASSETS_DIR, "deno-runtime", "exec.ts"); +} + /** * For the npm distribution: copy bundled assets to the standard location * on first run. Binary entry handles its own extraction separately. From a48b41bb03dc73234216226b63cb247a6de1a99d Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Wed, 18 Mar 2026 09:57:40 +0200 Subject: [PATCH 15/27] fix: migrate exec command to Base44Command after main merge Adapt exec.ts to use the new Base44Command class introduced in main (#420) instead of the removed runCommand utility. Made-with: Cursor --- packages/cli/src/cli/commands/exec.ts | 31 +++++++++------------------ packages/cli/src/cli/program.ts | 2 +- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/cli/commands/exec.ts b/packages/cli/src/cli/commands/exec.ts index facba342..084ce28e 100644 --- a/packages/cli/src/cli/commands/exec.ts +++ b/packages/cli/src/cli/commands/exec.ts @@ -1,7 +1,6 @@ -import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; -import { runCommand } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import type { Command } from "commander"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; import { getExecWrapperPath } from "@/core/assets.js"; import { InvalidInputError } from "@/core/errors.js"; import { runScript } from "@/core/exec/index.js"; @@ -18,7 +17,7 @@ function readStdin(): Promise { }); } -async function execAction(extraArgs: string[]): Promise { +async function execAction(): Promise { const noInputError = new InvalidInputError( "No input provided. Pipe a script to stdin.", { @@ -39,6 +38,9 @@ async function execAction(extraArgs: string[]): Promise { throw noInputError; } + const dashIndex = process.argv.indexOf("--"); + const extraArgs = dashIndex !== -1 ? process.argv.slice(dashIndex + 1) : []; + const { exitCode } = await runScript({ code, extraArgs, @@ -52,23 +54,10 @@ async function execAction(extraArgs: string[]): Promise { return {}; } -export function getExecCommand(context: CLIContext): Command { - const cmd = new Command("exec") +export function getExecCommand(): Command { + return new Base44Command("exec") .description( "Run a script with the Base44 SDK pre-authenticated as the current user", ) - .action(async () => { - // Collect everything after "--" as extra args for the Deno process - const dashIndex = process.argv.indexOf("--"); - const extraArgs = - dashIndex !== -1 ? process.argv.slice(dashIndex + 1) : []; - - await runCommand( - () => execAction(extraArgs), - { requireAuth: true }, - context, - ); - }); - - return cmd; + .action(execAction); } diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 3f722e26..7b74008e 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -76,7 +76,7 @@ export function createProgram(context: CLIContext): Command { program.addCommand(getTypesCommand()); // Register exec command - program.addCommand(getExecCommand(context)); + program.addCommand(getExecCommand()); // Register development commands program.addCommand(getDevCommand(), { hidden: true }); From cd7698d97d3e5c432a9afa375ae6724515969c17 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Wed, 18 Mar 2026 10:10:35 +0200 Subject: [PATCH 16/27] no rename --- packages/cli/infra/build.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cli/infra/build.ts b/packages/cli/infra/build.ts index 775b8c59..26dbaf07 100644 --- a/packages/cli/infra/build.ts +++ b/packages/cli/infra/build.ts @@ -38,10 +38,10 @@ const runAllBuilds = async () => { entrypoints: ["./src/cli/index.ts"], outdir: "./dist/cli", }); - const denoRuntimeDir = copyDenoRuntime(); + const denoRuntimePath = copyDenoRuntime(); return { cli, - denoRuntimeDir, + denoRuntimePath, }; }; @@ -59,7 +59,7 @@ if (process.argv.includes("--watch")) { const time = new Date().toLocaleTimeString(); console.log(chalk.dim(`[${time}]`), chalk.gray(`${filename} ${event}d`)); - const { cli, denoRuntimeDir } = await runAllBuilds(); + const { cli, denoRuntimePath } = await runAllBuilds(); if (cli.success && cli.outputs.length > 0) { console.log( chalk.green(` ✓ Rebuilt`), @@ -70,7 +70,7 @@ if (process.argv.includes("--watch")) { console.log( chalk.green(` ✓ Copied`), chalk.dim(`→`), - chalk.cyan(denoRuntimeDir), + chalk.cyan(denoRuntimePath), ); }; @@ -83,9 +83,9 @@ if (process.argv.includes("--watch")) { // Keep process alive await new Promise(() => {}); } else { - const { cli, denoRuntimeDir } = await runAllBuilds(); + const { cli, denoRuntimePath } = await runAllBuilds(); console.log(chalk.green.bold(`\n✓ Build complete\n`)); console.log(chalk.dim(" Output:")); console.log(` ${formatOutput(cli.outputs)}`); - console.log(` ${chalk.cyan(denoRuntimeDir)}`); + console.log(` ${chalk.cyan(denoRuntimePath)}`); } From 15d7c84aa52478ed5086922522fd70e04e2f61aa Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Wed, 18 Mar 2026 10:21:04 +0200 Subject: [PATCH 17/27] cleanups --- .../cli/dev/dev-server/function-manager.ts | 4 ++-- packages/cli/src/core/exec/run-script.ts | 24 ++++--------------- packages/cli/src/core/utils/dependencies.ts | 19 +++++++++++++++ packages/cli/src/core/utils/index.ts | 1 + 4 files changed, 26 insertions(+), 22 deletions(-) create mode 100644 packages/cli/src/core/utils/dependencies.ts diff --git a/packages/cli/src/cli/dev/dev-server/function-manager.ts b/packages/cli/src/cli/dev/dev-server/function-manager.ts index af8bda81..e8998a30 100644 --- a/packages/cli/src/cli/dev/dev-server/function-manager.ts +++ b/packages/cli/src/cli/dev/dev-server/function-manager.ts @@ -2,7 +2,7 @@ import type { ChildProcess } from "node:child_process"; import { spawn } from "node:child_process"; import getPort from "get-port"; import { InternalError, InvalidInputError } from "@/core/errors.js"; -import { verifyDenoInstalled } from "@/core/exec/index.js"; +import { verifyDenoInstalled } from "@/core/utils/index.js"; import type { BackendFunction } from "@/core/resources/function/schema.js"; import type { Logger } from "../createDevLogger"; @@ -31,7 +31,7 @@ export class FunctionManager { this.wrapperPath = wrapperPath; if (functions.length > 0) { - verifyDenoInstalled(); + verifyDenoInstalled("to run backend functions locally"); } } diff --git a/packages/cli/src/core/exec/run-script.ts b/packages/cli/src/core/exec/run-script.ts index d8aabc79..11f9f892 100644 --- a/packages/cli/src/core/exec/run-script.ts +++ b/packages/cli/src/core/exec/run-script.ts @@ -1,11 +1,12 @@ -import { spawn, spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { copyFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { file } from "tmp-promise"; import { getAppClient } from "@/core/clients/index.js"; -import { ApiError, DependencyNotFoundError } from "@/core/errors.js"; +import { ApiError } from "@/core/errors.js"; import { getAppConfig } from "@/core/project/app-config.js"; import { getSiteUrl } from "@/core/site/api.js"; +import { verifyDenoInstalled } from "@/core/utils/index.js"; interface RunScriptOptions { filePath?: string; @@ -18,23 +19,6 @@ interface RunScriptResult { exitCode: number; } -export function verifyDenoInstalled(): void { - const result = spawnSync("deno", ["--version"]); - if (result.error) { - throw new DependencyNotFoundError( - "Deno is required to run scripts with exec", - { - hints: [ - { - message: - "Install Deno: https://docs.deno.com/runtime/getting_started/installation/", - }, - ], - }, - ); - } -} - async function getUserAppToken(): Promise { try { const response = await getAppClient() @@ -54,7 +38,7 @@ export async function runScript( ): Promise { const { filePath, code, extraArgs = [], execWrapperPath } = options; - verifyDenoInstalled(); + verifyDenoInstalled("to run scripts with exec"); const cleanupFns: (() => void)[] = []; diff --git a/packages/cli/src/core/utils/dependencies.ts b/packages/cli/src/core/utils/dependencies.ts new file mode 100644 index 00000000..e569de12 --- /dev/null +++ b/packages/cli/src/core/utils/dependencies.ts @@ -0,0 +1,19 @@ +import { spawnSync } from "node:child_process"; +import { DependencyNotFoundError } from "@/core/errors.js"; + +export function verifyDenoInstalled(context: string): void { + const result = spawnSync("deno", ["--version"]); + if (result.error) { + throw new DependencyNotFoundError( + `Deno is required ${context}`, + { + hints: [ + { + message: + "Install Deno: https://docs.deno.com/runtime/getting_started/installation/", + }, + ], + }, + ); + } +} diff --git a/packages/cli/src/core/utils/index.ts b/packages/cli/src/core/utils/index.ts index fc7b908b..43158af3 100644 --- a/packages/cli/src/core/utils/index.ts +++ b/packages/cli/src/core/utils/index.ts @@ -1,2 +1,3 @@ +export * from "./dependencies.js"; export * from "./env.js"; export * from "./fs.js"; From 185b23e7d8d124e9bc53d2f116b4755be0b2d792 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Wed, 18 Mar 2026 10:24:22 +0200 Subject: [PATCH 18/27] remove extra args --- packages/cli/src/cli/commands/exec.ts | 4 ---- packages/cli/src/core/exec/run-script.ts | 4 +--- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/cli/src/cli/commands/exec.ts b/packages/cli/src/cli/commands/exec.ts index 084ce28e..709f3143 100644 --- a/packages/cli/src/cli/commands/exec.ts +++ b/packages/cli/src/cli/commands/exec.ts @@ -38,12 +38,8 @@ async function execAction(): Promise { throw noInputError; } - const dashIndex = process.argv.indexOf("--"); - const extraArgs = dashIndex !== -1 ? process.argv.slice(dashIndex + 1) : []; - const { exitCode } = await runScript({ code, - extraArgs, execWrapperPath: getExecWrapperPath(), }); diff --git a/packages/cli/src/core/exec/run-script.ts b/packages/cli/src/core/exec/run-script.ts index 11f9f892..1b55cf3c 100644 --- a/packages/cli/src/core/exec/run-script.ts +++ b/packages/cli/src/core/exec/run-script.ts @@ -11,7 +11,6 @@ import { verifyDenoInstalled } from "@/core/utils/index.js"; interface RunScriptOptions { filePath?: string; code?: string; - extraArgs?: string[]; execWrapperPath: string; } @@ -36,7 +35,7 @@ async function getUserAppToken(): Promise { export async function runScript( options: RunScriptOptions, ): Promise { - const { filePath, code, extraArgs = [], execWrapperPath } = options; + const { filePath, code, execWrapperPath } = options; verifyDenoInstalled("to run scripts with exec"); @@ -78,7 +77,6 @@ export async function runScript( "--allow-all", "--node-modules-dir=auto", tempWrapper.path, - ...extraArgs, ], { env: { From 2efd491ea11bbfedad2739f24fb9f6c8a736936e Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Wed, 18 Mar 2026 10:30:03 +0200 Subject: [PATCH 19/27] cleanup --- packages/cli/src/cli/commands/exec.ts | 6 +----- packages/cli/src/core/exec/run-script.ts | 6 +++--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/cli/commands/exec.ts b/packages/cli/src/cli/commands/exec.ts index 709f3143..236c92cc 100644 --- a/packages/cli/src/cli/commands/exec.ts +++ b/packages/cli/src/cli/commands/exec.ts @@ -1,7 +1,6 @@ import type { Command } from "commander"; import type { RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; -import { getExecWrapperPath } from "@/core/assets.js"; import { InvalidInputError } from "@/core/errors.js"; import { runScript } from "@/core/exec/index.js"; @@ -38,10 +37,7 @@ async function execAction(): Promise { throw noInputError; } - const { exitCode } = await runScript({ - code, - execWrapperPath: getExecWrapperPath(), - }); + const { exitCode } = await runScript({ code }); if (exitCode !== 0) { process.exitCode = exitCode; diff --git a/packages/cli/src/core/exec/run-script.ts b/packages/cli/src/core/exec/run-script.ts index 1b55cf3c..c8fdefe9 100644 --- a/packages/cli/src/core/exec/run-script.ts +++ b/packages/cli/src/core/exec/run-script.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import { copyFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { file } from "tmp-promise"; +import { getExecWrapperPath } from "@/core/assets.js"; import { getAppClient } from "@/core/clients/index.js"; import { ApiError } from "@/core/errors.js"; import { getAppConfig } from "@/core/project/app-config.js"; @@ -11,7 +12,6 @@ import { verifyDenoInstalled } from "@/core/utils/index.js"; interface RunScriptOptions { filePath?: string; code?: string; - execWrapperPath: string; } interface RunScriptResult { @@ -35,7 +35,7 @@ async function getUserAppToken(): Promise { export async function runScript( options: RunScriptOptions, ): Promise { - const { filePath, code, execWrapperPath } = options; + const { filePath, code } = options; verifyDenoInstalled("to run scripts with exec"); @@ -66,7 +66,7 @@ export async function runScript( // npm: specifiers in them. const tempWrapper = await file({ postfix: ".ts" }); cleanupFns.push(tempWrapper.cleanup); - copyFileSync(execWrapperPath, tempWrapper.path); + copyFileSync(getExecWrapperPath(), tempWrapper.path); try { const exitCode = await new Promise((resolvePromise) => { From 658445f4afca02b2321e86e584b21e68cc5be4d6 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Wed, 18 Mar 2026 10:42:38 +0200 Subject: [PATCH 20/27] cleaner --- packages/cli/src/cli/commands/site/open.ts | 2 +- packages/cli/src/core/exec/run-script.ts | 47 ++++++---------------- packages/cli/src/core/site/api.ts | 30 +------------- packages/cli/src/core/utils/app-info.ts | 40 ++++++++++++++++++ packages/cli/src/core/utils/index.ts | 1 + 5 files changed, 56 insertions(+), 64 deletions(-) create mode 100644 packages/cli/src/core/utils/app-info.ts diff --git a/packages/cli/src/cli/commands/site/open.ts b/packages/cli/src/cli/commands/site/open.ts index d1323229..ad2c0a3c 100644 --- a/packages/cli/src/cli/commands/site/open.ts +++ b/packages/cli/src/cli/commands/site/open.ts @@ -2,7 +2,7 @@ import type { Command } from "commander"; import open from "open"; import type { RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; -import { getSiteUrl } from "@/core/site/index.js"; +import { getSiteUrl } from "@/core/utils/index.js"; async function openAction( isNonInteractive: boolean, diff --git a/packages/cli/src/core/exec/run-script.ts b/packages/cli/src/core/exec/run-script.ts index c8fdefe9..ebe9b991 100644 --- a/packages/cli/src/core/exec/run-script.ts +++ b/packages/cli/src/core/exec/run-script.ts @@ -1,62 +1,39 @@ import { spawn } from "node:child_process"; import { copyFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; import { file } from "tmp-promise"; import { getExecWrapperPath } from "@/core/assets.js"; -import { getAppClient } from "@/core/clients/index.js"; -import { ApiError } from "@/core/errors.js"; import { getAppConfig } from "@/core/project/app-config.js"; -import { getSiteUrl } from "@/core/site/api.js"; -import { verifyDenoInstalled } from "@/core/utils/index.js"; +import { + getAppUserToken, + getSiteUrl, + verifyDenoInstalled, +} from "@/core/utils/index.js"; interface RunScriptOptions { - filePath?: string; - code?: string; + code: string; } interface RunScriptResult { exitCode: number; } -async function getUserAppToken(): Promise { - try { - const response = await getAppClient() - .get("auth/token") - .json<{ token: string }>(); - return response.token; - } catch (error) { - throw await ApiError.fromHttpError( - error, - "exchanging platform token for app user token", - ); - } -} - export async function runScript( options: RunScriptOptions, ): Promise { - const { filePath, code } = options; + const { code } = options; verifyDenoInstalled("to run scripts with exec"); const cleanupFns: (() => void)[] = []; - let scriptPath: string; - - if (filePath) { - scriptPath = `file://${resolve(filePath)}`; - } else if (code !== undefined) { - const tempScript = await file({ postfix: ".ts" }); - cleanupFns.push(tempScript.cleanup); - writeFileSync(tempScript.path, code, "utf-8"); - scriptPath = `file://${tempScript.path}`; - } else { - throw new Error("Either filePath or code must be provided"); - } + const tempScript = await file({ postfix: ".ts" }); + cleanupFns.push(tempScript.cleanup); + writeFileSync(tempScript.path, code, "utf-8"); + const scriptPath = `file://${tempScript.path}`; const appConfig = getAppConfig(); const [appUserToken, appBaseUrl] = await Promise.all([ - getUserAppToken(), + getAppUserToken(), getSiteUrl(), ]); diff --git a/packages/cli/src/core/site/api.ts b/packages/cli/src/core/site/api.ts index d6443539..9683316e 100644 --- a/packages/cli/src/core/site/api.ts +++ b/packages/cli/src/core/site/api.ts @@ -1,12 +1,8 @@ import type { KyResponse } from "ky"; -import { base44Client, getAppClient } from "@/core/clients/index.js"; +import { getAppClient } from "@/core/clients/index.js"; import { ApiError, SchemaValidationError } from "@/core/errors.js"; -import { getAppConfig } from "@/core/project/index.js"; import type { DeployResponse } from "@/core/site/schema.js"; -import { - DeployResponseSchema, - PublishedUrlResponseSchema, -} from "@/core/site/schema.js"; +import { DeployResponseSchema } from "@/core/site/schema.js"; import { readFile } from "@/core/utils/fs.js"; /** @@ -44,25 +40,3 @@ export async function uploadSite(archivePath: string): Promise { return result.data; } - -export async function getSiteUrl(projectId?: string): Promise { - const id = projectId ?? getAppConfig().id; - - let response: KyResponse; - try { - response = await base44Client.get(`api/apps/platform/${id}/published-url`); - } catch (error) { - throw await ApiError.fromHttpError(error, "fetching site URL"); - } - - const result = PublishedUrlResponseSchema.safeParse(await response.json()); - - if (!result.success) { - throw new SchemaValidationError( - "Invalid response from server", - result.error, - ); - } - - return result.data.url; -} diff --git a/packages/cli/src/core/utils/app-info.ts b/packages/cli/src/core/utils/app-info.ts new file mode 100644 index 00000000..dec3ddb4 --- /dev/null +++ b/packages/cli/src/core/utils/app-info.ts @@ -0,0 +1,40 @@ +import { base44Client, getAppClient } from "@/core/clients/index.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import { getAppConfig } from "@/core/project/index.js"; +import { PublishedUrlResponseSchema } from "@/core/site/schema.js"; + +export async function getAppUserToken(): Promise { + try { + const response = await getAppClient() + .get("auth/token") + .json<{ token: string }>(); + return response.token; + } catch (error) { + throw await ApiError.fromHttpError( + error, + "exchanging platform token for app user token", + ); + } +} + +export async function getSiteUrl(projectId?: string): Promise { + const id = projectId ?? getAppConfig().id; + + let response; + try { + response = await base44Client.get(`api/apps/platform/${id}/published-url`); + } catch (error) { + throw await ApiError.fromHttpError(error, "fetching site URL"); + } + + const result = PublishedUrlResponseSchema.safeParse(await response.json()); + + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + + return result.data.url; +} diff --git a/packages/cli/src/core/utils/index.ts b/packages/cli/src/core/utils/index.ts index 43158af3..268ff456 100644 --- a/packages/cli/src/core/utils/index.ts +++ b/packages/cli/src/core/utils/index.ts @@ -1,3 +1,4 @@ +export * from "./app-info.js"; export * from "./dependencies.js"; export * from "./env.js"; export * from "./fs.js"; From cce9fc0d90919079531f9b0362c368e14d9668f5 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Wed, 18 Mar 2026 11:49:16 +0200 Subject: [PATCH 21/27] test --- packages/cli/tests/cli/exec.spec.ts | 93 +++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 24 deletions(-) diff --git a/packages/cli/tests/cli/exec.spec.ts b/packages/cli/tests/cli/exec.spec.ts index c3ff1c87..b06bd8f9 100644 --- a/packages/cli/tests/cli/exec.spec.ts +++ b/packages/cli/tests/cli/exec.spec.ts @@ -1,58 +1,103 @@ -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; +import stripAnsi from "strip-ansi"; import { fixture, setupCLITests } from "./testkit/index.js"; describe("exec command", () => { const t = setupCLITests(); - it("shows help with --help flag", async () => { - const result = await t.run("exec", "--help"); - - t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Run a script with the Base44 SDK"); - }); - - it("fails with helpful error when no stdin is piped", async () => { + it("fails with helpful error when stdin is empty", async () => { await t.givenLoggedInWithProject(fixture("basic")); const result = await t.run("exec"); t.expectResult(result).toFail(); - t.expectResult(result).toContain("No input provided"); - t.expectResult(result).toContain("cat ./script.ts | base44 exec"); + expect(stripAnsi(result.stderr)).toBe( + "Error: No input provided. Pipe a script to stdin.\n" + + " Hint: File: cat ./script.ts | base44 exec\n" + + ' Hint: Eval: echo "console.log(1)" | base44 exec', + ); }); - it("fails when not in a project directory", async () => { - await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + it("fails with error message when token exchange fails", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockSiteUrl({ url: "https://test-app.base44.app" }); + t.api.mockError("get", `/api/apps/test-app-id/auth/token`, { + status: 500, + body: { detail: "Internal server error" }, + }); t.givenStdin("console.log(1)"); const result = await t.run("exec"); t.expectResult(result).toFail(); - t.expectResult(result).toContain("No Base44 project found"); + expect(stripAnsi(result.stderr)).toBe( + "Error: Error exchanging platform token for app user token: Internal server error\n" + + " Hint: Check your network connection and try again", + ); }); - it("fails when token exchange returns an error", async () => { + it("executes a piped script and captures its output", async () => { await t.givenLoggedInWithProject(fixture("basic")); - t.api.mockError("get", `/api/apps/test-app-id/auth/token`, { - status: 500, - body: { error: "Internal server error" }, - }); - t.givenStdin("console.log(1)"); + t.api.mockAuthToken("test-app-token"); + t.api.mockSiteUrl({ url: "https://test-app.base44.app" }); + t.givenStdin('console.log("hello from exec")'); const result = await t.run("exec"); - t.expectResult(result).toFail(); + t.expectResult(result).toSucceed(); + expect(result.stdout).toContain("hello from exec"); }); - it("executes piped code successfully", async () => { + it("makes the pre-authenticated base44 SDK available as a global", async () => { await t.givenLoggedInWithProject(fixture("basic")); t.api.mockAuthToken("test-app-token"); t.api.mockSiteUrl({ url: "https://test-app.base44.app" }); - t.givenStdin("console.log('hello from exec')"); + t.givenStdin( + 'if (typeof base44 === "undefined") { Deno.exit(1); }\n' + + 'console.log("sdk-available");', + ); - // Note: script output goes directly to terminal (stdio: inherit), not captured here const result = await t.run("exec"); t.expectResult(result).toSucceed(); + expect(result.stdout).toContain("sdk-available"); + }); + + it("sends SDK requests to the app base URL with correct auth", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAuthToken("test-app-token"); + t.api.mockSiteUrl({ url: t.api.baseUrl }); + + let capturedAuth: string | undefined; + t.api.mockRoute( + "GET", + `/api/apps/${t.api.appId}/entities/Task`, + (req, res) => { + capturedAuth = req.headers.authorization; + res.json([{ id: "1", title: "Test Task" }]); + }, + ); + + t.givenStdin( + "const tasks = await base44.entities.Task.list();\n" + + "console.log(JSON.stringify(tasks));", + ); + + const result = await t.run("exec"); + + t.expectResult(result).toSucceed(); + expect(result.stdout).toContain('{"id":"1","title":"Test Task"}'); + expect(capturedAuth).toBe("Bearer test-app-token"); + }); + + it("forwards a non-zero script exit code", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAuthToken("test-app-token"); + t.api.mockSiteUrl({ url: "https://test-app.base44.app" }); + t.givenStdin("Deno.exit(42)"); + + const result = await t.run("exec"); + + expect(result.exitCode).toBe(42); }); }); From a32ead45417e9297613921c05a785a8ebe385eb5 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Wed, 18 Mar 2026 12:00:55 +0200 Subject: [PATCH 22/27] lint fixes --- .../cli/dev/dev-server/function-manager.ts | 2 +- packages/cli/src/core/exec/run-script.ts | 7 +------ packages/cli/src/core/utils/app-info.ts | 2 +- packages/cli/src/core/utils/dependencies.ts | 19 ++++++++----------- packages/cli/tests/cli/exec.spec.ts | 2 +- 5 files changed, 12 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/cli/dev/dev-server/function-manager.ts b/packages/cli/src/cli/dev/dev-server/function-manager.ts index e8998a30..e18507a3 100644 --- a/packages/cli/src/cli/dev/dev-server/function-manager.ts +++ b/packages/cli/src/cli/dev/dev-server/function-manager.ts @@ -2,8 +2,8 @@ import type { ChildProcess } from "node:child_process"; import { spawn } from "node:child_process"; import getPort from "get-port"; import { InternalError, InvalidInputError } from "@/core/errors.js"; -import { verifyDenoInstalled } from "@/core/utils/index.js"; import type { BackendFunction } from "@/core/resources/function/schema.js"; +import { verifyDenoInstalled } from "@/core/utils/index.js"; import type { Logger } from "../createDevLogger"; const READY_TIMEOUT = 30000; diff --git a/packages/cli/src/core/exec/run-script.ts b/packages/cli/src/core/exec/run-script.ts index ebe9b991..eacc6bd4 100644 --- a/packages/cli/src/core/exec/run-script.ts +++ b/packages/cli/src/core/exec/run-script.ts @@ -49,12 +49,7 @@ export async function runScript( const exitCode = await new Promise((resolvePromise) => { const child = spawn( "deno", - [ - "run", - "--allow-all", - "--node-modules-dir=auto", - tempWrapper.path, - ], + ["run", "--allow-all", "--node-modules-dir=auto", tempWrapper.path], { env: { ...process.env, diff --git a/packages/cli/src/core/utils/app-info.ts b/packages/cli/src/core/utils/app-info.ts index dec3ddb4..698c846a 100644 --- a/packages/cli/src/core/utils/app-info.ts +++ b/packages/cli/src/core/utils/app-info.ts @@ -20,7 +20,7 @@ export async function getAppUserToken(): Promise { export async function getSiteUrl(projectId?: string): Promise { const id = projectId ?? getAppConfig().id; - let response; + let response: Response; try { response = await base44Client.get(`api/apps/platform/${id}/published-url`); } catch (error) { diff --git a/packages/cli/src/core/utils/dependencies.ts b/packages/cli/src/core/utils/dependencies.ts index e569de12..3b5be102 100644 --- a/packages/cli/src/core/utils/dependencies.ts +++ b/packages/cli/src/core/utils/dependencies.ts @@ -4,16 +4,13 @@ import { DependencyNotFoundError } from "@/core/errors.js"; export function verifyDenoInstalled(context: string): void { const result = spawnSync("deno", ["--version"]); if (result.error) { - throw new DependencyNotFoundError( - `Deno is required ${context}`, - { - hints: [ - { - message: - "Install Deno: https://docs.deno.com/runtime/getting_started/installation/", - }, - ], - }, - ); + throw new DependencyNotFoundError(`Deno is required ${context}`, { + hints: [ + { + message: + "Install Deno: https://docs.deno.com/runtime/getting_started/installation/", + }, + ], + }); } } diff --git a/packages/cli/tests/cli/exec.spec.ts b/packages/cli/tests/cli/exec.spec.ts index b06bd8f9..290147b6 100644 --- a/packages/cli/tests/cli/exec.spec.ts +++ b/packages/cli/tests/cli/exec.spec.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; import stripAnsi from "strip-ansi"; +import { describe, expect, it } from "vitest"; import { fixture, setupCLITests } from "./testkit/index.js"; describe("exec command", () => { From 5409d70c9d747f031dff448d127c1c4e2a16ecce Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Wed, 18 Mar 2026 14:12:16 +0200 Subject: [PATCH 23/27] move to project/api --- packages/cli/src/cli/commands/site/open.ts | 2 +- packages/cli/src/core/exec/run-script.ts | 7 ++-- packages/cli/src/core/project/api.ts | 40 +++++++++++++++++++++- packages/cli/src/core/utils/app-info.ts | 40 ---------------------- packages/cli/src/core/utils/index.ts | 1 - 5 files changed, 42 insertions(+), 48 deletions(-) delete mode 100644 packages/cli/src/core/utils/app-info.ts diff --git a/packages/cli/src/cli/commands/site/open.ts b/packages/cli/src/cli/commands/site/open.ts index ad2c0a3c..aa579d93 100644 --- a/packages/cli/src/cli/commands/site/open.ts +++ b/packages/cli/src/cli/commands/site/open.ts @@ -2,7 +2,7 @@ import type { Command } from "commander"; import open from "open"; import type { RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; -import { getSiteUrl } from "@/core/utils/index.js"; +import { getSiteUrl } from "@/core/project/index.js"; async function openAction( isNonInteractive: boolean, diff --git a/packages/cli/src/core/exec/run-script.ts b/packages/cli/src/core/exec/run-script.ts index eacc6bd4..60f1a999 100644 --- a/packages/cli/src/core/exec/run-script.ts +++ b/packages/cli/src/core/exec/run-script.ts @@ -2,12 +2,9 @@ import { spawn } from "node:child_process"; import { copyFileSync, writeFileSync } from "node:fs"; import { file } from "tmp-promise"; import { getExecWrapperPath } from "@/core/assets.js"; +import { getAppUserToken, getSiteUrl } from "@/core/project/api.js"; import { getAppConfig } from "@/core/project/app-config.js"; -import { - getAppUserToken, - getSiteUrl, - verifyDenoInstalled, -} from "@/core/utils/index.js"; +import { verifyDenoInstalled } from "@/core/utils/index.js"; interface RunScriptOptions { code: string; diff --git a/packages/cli/src/core/project/api.ts b/packages/cli/src/core/project/api.ts index ed6157f6..f3713269 100644 --- a/packages/cli/src/core/project/api.ts +++ b/packages/cli/src/core/project/api.ts @@ -2,13 +2,15 @@ import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import type { KyResponse } from "ky"; import { extract } from "tar"; -import { base44Client } from "@/core/clients/index.js"; +import { base44Client, getAppClient } from "@/core/clients/index.js"; import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import { getAppConfig } from "@/core/project/app-config.js"; import type { ProjectsResponse } from "@/core/project/schema.js"; import { CreateProjectResponseSchema, ProjectsResponseSchema, } from "@/core/project/schema.js"; +import { PublishedUrlResponseSchema } from "@/core/site/schema.js"; import { makeDirectory } from "@/core/utils/fs.js"; export async function createProject(projectName: string, description?: string) { @@ -83,3 +85,39 @@ export async function downloadProject(projectId: string, projectPath: string) { await makeDirectory(projectPath); await pipeline(nodeStream, extract({ cwd: projectPath })); } + +export async function getAppUserToken(): Promise { + try { + const response = await getAppClient() + .get("auth/token") + .json<{ token: string }>(); + return response.token; + } catch (error) { + throw await ApiError.fromHttpError( + error, + "exchanging platform token for app user token", + ); + } +} + +export async function getSiteUrl(projectId?: string): Promise { + const id = projectId ?? getAppConfig().id; + + let response: Response; + try { + response = await base44Client.get(`api/apps/platform/${id}/published-url`); + } catch (error) { + throw await ApiError.fromHttpError(error, "fetching site URL"); + } + + const result = PublishedUrlResponseSchema.safeParse(await response.json()); + + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + + return result.data.url; +} diff --git a/packages/cli/src/core/utils/app-info.ts b/packages/cli/src/core/utils/app-info.ts deleted file mode 100644 index 698c846a..00000000 --- a/packages/cli/src/core/utils/app-info.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { base44Client, getAppClient } from "@/core/clients/index.js"; -import { ApiError, SchemaValidationError } from "@/core/errors.js"; -import { getAppConfig } from "@/core/project/index.js"; -import { PublishedUrlResponseSchema } from "@/core/site/schema.js"; - -export async function getAppUserToken(): Promise { - try { - const response = await getAppClient() - .get("auth/token") - .json<{ token: string }>(); - return response.token; - } catch (error) { - throw await ApiError.fromHttpError( - error, - "exchanging platform token for app user token", - ); - } -} - -export async function getSiteUrl(projectId?: string): Promise { - const id = projectId ?? getAppConfig().id; - - let response: Response; - try { - response = await base44Client.get(`api/apps/platform/${id}/published-url`); - } catch (error) { - throw await ApiError.fromHttpError(error, "fetching site URL"); - } - - const result = PublishedUrlResponseSchema.safeParse(await response.json()); - - if (!result.success) { - throw new SchemaValidationError( - "Invalid response from server", - result.error, - ); - } - - return result.data.url; -} diff --git a/packages/cli/src/core/utils/index.ts b/packages/cli/src/core/utils/index.ts index 268ff456..43158af3 100644 --- a/packages/cli/src/core/utils/index.ts +++ b/packages/cli/src/core/utils/index.ts @@ -1,4 +1,3 @@ -export * from "./app-info.js"; export * from "./dependencies.js"; export * from "./env.js"; export * from "./fs.js"; From dee89b6e74b6f2ee39d2873122109bc13cbfec68 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Wed, 18 Mar 2026 14:13:30 +0200 Subject: [PATCH 24/27] move getAppConfig --- packages/cli/src/cli/commands/exec.ts | 3 ++- packages/cli/src/core/exec/run-script.ts | 7 +++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/cli/commands/exec.ts b/packages/cli/src/cli/commands/exec.ts index 236c92cc..2af8e589 100644 --- a/packages/cli/src/cli/commands/exec.ts +++ b/packages/cli/src/cli/commands/exec.ts @@ -3,6 +3,7 @@ import type { RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; import { InvalidInputError } from "@/core/errors.js"; import { runScript } from "@/core/exec/index.js"; +import { getAppConfig } from "@/core/project/index.js"; function readStdin(): Promise { return new Promise((resolve, reject) => { @@ -37,7 +38,7 @@ async function execAction(): Promise { throw noInputError; } - const { exitCode } = await runScript({ code }); + const { exitCode } = await runScript({ appId: getAppConfig().id, code }); if (exitCode !== 0) { process.exitCode = exitCode; diff --git a/packages/cli/src/core/exec/run-script.ts b/packages/cli/src/core/exec/run-script.ts index 60f1a999..cff5c11a 100644 --- a/packages/cli/src/core/exec/run-script.ts +++ b/packages/cli/src/core/exec/run-script.ts @@ -3,10 +3,10 @@ import { copyFileSync, writeFileSync } from "node:fs"; import { file } from "tmp-promise"; import { getExecWrapperPath } from "@/core/assets.js"; import { getAppUserToken, getSiteUrl } from "@/core/project/api.js"; -import { getAppConfig } from "@/core/project/app-config.js"; import { verifyDenoInstalled } from "@/core/utils/index.js"; interface RunScriptOptions { + appId: string; code: string; } @@ -17,7 +17,7 @@ interface RunScriptResult { export async function runScript( options: RunScriptOptions, ): Promise { - const { code } = options; + const { appId, code } = options; verifyDenoInstalled("to run scripts with exec"); @@ -28,7 +28,6 @@ export async function runScript( writeFileSync(tempScript.path, code, "utf-8"); const scriptPath = `file://${tempScript.path}`; - const appConfig = getAppConfig(); const [appUserToken, appBaseUrl] = await Promise.all([ getAppUserToken(), getSiteUrl(), @@ -51,7 +50,7 @@ export async function runScript( env: { ...process.env, SCRIPT_PATH: scriptPath, - BASE44_APP_ID: appConfig.id, + BASE44_APP_ID: appId, BASE44_ACCESS_TOKEN: appUserToken, BASE44_APP_BASE_URL: appBaseUrl, }, From d85d1e806664acf826c8526a969116865228dd2b Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Wed, 18 Mar 2026 14:23:27 +0200 Subject: [PATCH 25/27] non interactive --- packages/cli/src/cli/commands/exec.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/cli/commands/exec.ts b/packages/cli/src/cli/commands/exec.ts index 2af8e589..4cefc841 100644 --- a/packages/cli/src/cli/commands/exec.ts +++ b/packages/cli/src/cli/commands/exec.ts @@ -17,7 +17,9 @@ function readStdin(): Promise { }); } -async function execAction(): Promise { +async function execAction( + isNonInteractive: boolean, +): Promise { const noInputError = new InvalidInputError( "No input provided. Pipe a script to stdin.", { @@ -28,7 +30,7 @@ async function execAction(): Promise { }, ); - if (process.stdin.isTTY) { + if (!isNonInteractive) { throw noInputError; } @@ -52,5 +54,7 @@ export function getExecCommand(): Command { .description( "Run a script with the Base44 SDK pre-authenticated as the current user", ) - .action(execAction); + .action(async (_options: unknown, command: Base44Command) => { + return await execAction(command.isNonInteractive); + }); } From 99e33efb5e60aa767ac51539d4f515c033f6b265 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Wed, 18 Mar 2026 14:24:25 +0200 Subject: [PATCH 26/27] better example --- packages/cli/src/cli/commands/exec.ts | 5 ++++- packages/cli/tests/cli/exec.spec.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/cli/commands/exec.ts b/packages/cli/src/cli/commands/exec.ts index 4cefc841..6e5bf248 100644 --- a/packages/cli/src/cli/commands/exec.ts +++ b/packages/cli/src/cli/commands/exec.ts @@ -25,7 +25,10 @@ async function execAction( { hints: [ { message: "File: cat ./script.ts | base44 exec" }, - { message: 'Eval: echo "console.log(1)" | base44 exec' }, + { + message: + 'Eval: echo "const users = await base44.entities.User.list(); console.log(users)" | base44 exec', + }, ], }, ); diff --git a/packages/cli/tests/cli/exec.spec.ts b/packages/cli/tests/cli/exec.spec.ts index 290147b6..a09afbc9 100644 --- a/packages/cli/tests/cli/exec.spec.ts +++ b/packages/cli/tests/cli/exec.spec.ts @@ -14,7 +14,7 @@ describe("exec command", () => { expect(stripAnsi(result.stderr)).toBe( "Error: No input provided. Pipe a script to stdin.\n" + " Hint: File: cat ./script.ts | base44 exec\n" + - ' Hint: Eval: echo "console.log(1)" | base44 exec', + ' Hint: Eval: echo "const users = await base44.entities.User.list(); console.log(users)" | base44 exec', ); }); From 38d62d503cdf0f5824914a3ac850b0d9e4797781 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Wed, 18 Mar 2026 14:29:36 +0200 Subject: [PATCH 27/27] better help --- packages/cli/src/cli/commands/exec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/cli/src/cli/commands/exec.ts b/packages/cli/src/cli/commands/exec.ts index 6e5bf248..d49ca1dc 100644 --- a/packages/cli/src/cli/commands/exec.ts +++ b/packages/cli/src/cli/commands/exec.ts @@ -57,6 +57,16 @@ export function getExecCommand(): Command { .description( "Run a script with the Base44 SDK pre-authenticated as the current user", ) + .addHelpText( + "after", + ` +Examples: + Run a script file: + $ cat ./script.ts | base44 exec + + Inline script: + $ echo "const users = await base44.entities.User.list()" | base44 exec`, + ) .action(async (_options: unknown, command: Base44Command) => { return await execAction(command.isNonInteractive); });