diff --git a/packages/cli/deno-runtime/exec.ts b/packages/cli/deno-runtime/exec.ts index 2c273aa5..7c23f50b 100644 --- a/packages/cli/deno-runtime/exec.ts +++ b/packages/cli/deno-runtime/exec.ts @@ -9,6 +9,7 @@ * - BASE44_APP_ID: App identifier from .app.jsonc * - BASE44_ACCESS_TOKEN: User's access token * - BASE44_APP_BASE_URL: App's published URL / subdomain (used for function calls) + * - BASE44_ADMIN: When "true", adds X-Bypass-RLS header for admin access */ export {}; @@ -17,6 +18,8 @@ const scriptPath = Deno.env.get("SCRIPT_PATH"); const appId = Deno.env.get("BASE44_APP_ID"); const accessToken = Deno.env.get("BASE44_ACCESS_TOKEN"); const appBaseUrl = Deno.env.get("BASE44_APP_BASE_URL"); +const isAdmin = Deno.env.get("BASE44_ADMIN") === "true"; +const dataEnv = Deno.env.get("BASE44_DATA_ENV"); if (!scriptPath) { console.error("SCRIPT_PATH environment variable is required"); @@ -35,10 +38,15 @@ if (!appBaseUrl) { import { createClient } from "npm:@base44/sdk"; +const customHeaders: Record = {}; +if (isAdmin) customHeaders["X-Bypass-RLS"] = "true"; +if (dataEnv) customHeaders["X-Data-Env"] = dataEnv; + const base44 = createClient({ appId, token: accessToken, serverUrl: appBaseUrl, + headers: customHeaders, }); (globalThis as any).base44 = base44; diff --git a/packages/cli/src/cli/commands/exec.ts b/packages/cli/src/cli/commands/exec.ts index d49ca1dc..3de13747 100644 --- a/packages/cli/src/cli/commands/exec.ts +++ b/packages/cli/src/cli/commands/exec.ts @@ -18,6 +18,7 @@ function readStdin(): Promise { } async function execAction( + options: { admin?: boolean; env?: string }, isNonInteractive: boolean, ): Promise { const noInputError = new InvalidInputError( @@ -43,7 +44,12 @@ async function execAction( throw noInputError; } - const { exitCode } = await runScript({ appId: getAppConfig().id, code }); + const { exitCode } = await runScript({ + appId: getAppConfig().id, + code, + admin: options.admin, + dataEnv: options.env, + }); if (exitCode !== 0) { process.exitCode = exitCode; @@ -57,6 +63,14 @@ export function getExecCommand(): Command { .description( "Run a script with the Base44 SDK pre-authenticated as the current user", ) + .option( + "--admin", + "Run with admin privileges (bypass RLS). Requires app owner/editor role.", + ) + .option( + "--env ", + "Data environment to use (dev, share, or production). Defaults to production.", + ) .addHelpText( "after", ` @@ -65,9 +79,17 @@ Examples: $ cat ./script.ts | base44 exec Inline script: - $ echo "const users = await base44.entities.User.list()" | base44 exec`, + $ echo "const users = await base44.entities.User.list()" | base44 exec + + Run with admin privileges (bypass RLS): + $ echo "const all = await base44.entities.Task.list()" | base44 exec --admin`, ) - .action(async (_options: unknown, command: Base44Command) => { - return await execAction(command.isNonInteractive); - }); + .action( + async ( + options: { admin?: boolean; env?: string }, + command: Base44Command, + ) => { + return await execAction(options, command.isNonInteractive); + }, + ); } diff --git a/packages/cli/src/core/exec/run-script.ts b/packages/cli/src/core/exec/run-script.ts index cff5c11a..52b6e755 100644 --- a/packages/cli/src/core/exec/run-script.ts +++ b/packages/cli/src/core/exec/run-script.ts @@ -8,6 +8,8 @@ import { verifyDenoInstalled } from "@/core/utils/index.js"; interface RunScriptOptions { appId: string; code: string; + admin?: boolean; + dataEnv?: string; } interface RunScriptResult { @@ -17,7 +19,7 @@ interface RunScriptResult { export async function runScript( options: RunScriptOptions, ): Promise { - const { appId, code } = options; + const { appId, code, admin, dataEnv } = options; verifyDenoInstalled("to run scripts with exec"); @@ -53,6 +55,8 @@ export async function runScript( BASE44_APP_ID: appId, BASE44_ACCESS_TOKEN: appUserToken, BASE44_APP_BASE_URL: appBaseUrl, + ...(admin ? { BASE44_ADMIN: "true" } : {}), + ...(dataEnv ? { BASE44_DATA_ENV: dataEnv } : {}), }, stdio: "inherit", }, diff --git a/packages/cli/tests/cli/exec.spec.ts b/packages/cli/tests/cli/exec.spec.ts index a09afbc9..8e589c23 100644 --- a/packages/cli/tests/cli/exec.spec.ts +++ b/packages/cli/tests/cli/exec.spec.ts @@ -100,4 +100,175 @@ describe("exec command", () => { expect(result.exitCode).toBe(42); }); + + describe("--admin flag", () => { + it("sends X-Bypass-RLS header when --admin is passed", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAuthToken("test-app-token"); + t.api.mockSiteUrl({ url: t.api.baseUrl }); + + let capturedHeaders: Record = {}; + t.api.mockRoute( + "GET", + `/api/apps/${t.api.appId}/entities/Task`, + (req, res) => { + capturedHeaders = req.headers as Record; + res.json([]); + }, + ); + + t.givenStdin("await base44.entities.Task.list();"); + + const result = await t.run("exec", "--admin"); + + t.expectResult(result).toSucceed(); + expect(capturedHeaders["x-bypass-rls"]).toBe("true"); + }); + + it("does NOT send X-Bypass-RLS header without --admin", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAuthToken("test-app-token"); + t.api.mockSiteUrl({ url: t.api.baseUrl }); + + let capturedHeaders: Record = {}; + t.api.mockRoute( + "GET", + `/api/apps/${t.api.appId}/entities/Task`, + (req, res) => { + capturedHeaders = req.headers as Record; + res.json([]); + }, + ); + + t.givenStdin("await base44.entities.Task.list();"); + + const result = await t.run("exec"); + + t.expectResult(result).toSucceed(); + expect(capturedHeaders["x-bypass-rls"]).toBeUndefined(); + }); + }); + + describe("--env flag", () => { + it("sends X-Data-Env header with value 'dev' when --env dev is passed", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAuthToken("test-app-token"); + t.api.mockSiteUrl({ url: t.api.baseUrl }); + + let capturedHeaders: Record = {}; + t.api.mockRoute( + "GET", + `/api/apps/${t.api.appId}/entities/Task`, + (req, res) => { + capturedHeaders = req.headers as Record; + res.json([]); + }, + ); + + t.givenStdin("await base44.entities.Task.list();"); + + const result = await t.run("exec", "--env", "dev"); + + t.expectResult(result).toSucceed(); + expect(capturedHeaders["x-data-env"]).toBe("dev"); + }); + + it("sends X-Data-Env header with value 'prod' when --env prod is passed", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAuthToken("test-app-token"); + t.api.mockSiteUrl({ url: t.api.baseUrl }); + + let capturedHeaders: Record = {}; + t.api.mockRoute( + "GET", + `/api/apps/${t.api.appId}/entities/Task`, + (req, res) => { + capturedHeaders = req.headers as Record; + res.json([]); + }, + ); + + t.givenStdin("await base44.entities.Task.list();"); + + const result = await t.run("exec", "--env", "prod"); + + t.expectResult(result).toSucceed(); + expect(capturedHeaders["x-data-env"]).toBe("prod"); + }); + + it("does NOT send X-Data-Env header without --env", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAuthToken("test-app-token"); + t.api.mockSiteUrl({ url: t.api.baseUrl }); + + let capturedHeaders: Record = {}; + t.api.mockRoute( + "GET", + `/api/apps/${t.api.appId}/entities/Task`, + (req, res) => { + capturedHeaders = req.headers as Record; + res.json([]); + }, + ); + + t.givenStdin("await base44.entities.Task.list();"); + + const result = await t.run("exec"); + + t.expectResult(result).toSucceed(); + expect(capturedHeaders["x-data-env"]).toBeUndefined(); + }); + }); + + describe("--admin and --env combined", () => { + it("sends both X-Bypass-RLS and X-Data-Env headers when both flags are passed", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAuthToken("test-app-token"); + t.api.mockSiteUrl({ url: t.api.baseUrl }); + + let capturedHeaders: Record = {}; + t.api.mockRoute( + "GET", + `/api/apps/${t.api.appId}/entities/Task`, + (req, res) => { + capturedHeaders = req.headers as Record; + res.json([]); + }, + ); + + t.givenStdin("await base44.entities.Task.list();"); + + const result = await t.run("exec", "--admin", "--env", "dev"); + + t.expectResult(result).toSucceed(); + expect(capturedHeaders["x-bypass-rls"]).toBe("true"); + expect(capturedHeaders["x-data-env"]).toBe("dev"); + }); + }); + + describe("env vars passed to Deno subprocess", () => { + it("passes BASE44_ADMIN env var when --admin is used", 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("ADMIN=" + Deno.env.get("BASE44_ADMIN"));'); + + const result = await t.run("exec", "--admin"); + + t.expectResult(result).toSucceed(); + expect(result.stdout).toContain("ADMIN=true"); + }); + + it("passes BASE44_DATA_ENV env var when --env is used", 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("ENV=" + Deno.env.get("BASE44_DATA_ENV"));'); + + const result = await t.run("exec", "--env", "dev"); + + t.expectResult(result).toSucceed(); + expect(result.stdout).toContain("ENV=dev"); + }); + }); });