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/deno-runtime/exec.ts b/packages/cli/deno-runtime/exec.ts new file mode 100644 index 00000000..2c273aa5 --- /dev/null +++ b/packages/cli/deno-runtime/exec.ts @@ -0,0 +1,55 @@ +/** + * 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_APP_BASE_URL: App's published URL / subdomain (used for function calls) + */ + +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 appBaseUrl = Deno.env.get("BASE44_APP_BASE_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); +} + +if (!appBaseUrl) { + console.error("BASE44_APP_BASE_URL environment variable is required"); + Deno.exit(1); +} + +import { createClient } from "npm:@base44/sdk"; + +const base44 = createClient({ + appId, + token: accessToken, + serverUrl: appBaseUrl, +}); + +(globalThis as any).base44 = base44; + +try { + await import(scriptPath); +} 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(); +} diff --git a/packages/cli/infra/build.ts b/packages/cli/infra/build.ts index a276b97c..26dbaf07 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 () => { diff --git a/packages/cli/src/cli/commands/exec.ts b/packages/cli/src/cli/commands/exec.ts new file mode 100644 index 00000000..d49ca1dc --- /dev/null +++ b/packages/cli/src/cli/commands/exec.ts @@ -0,0 +1,73 @@ +import type { Command } from "commander"; +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) => { + 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); + }); +} + +async function execAction( + isNonInteractive: boolean, +): Promise { + const noInputError = new InvalidInputError( + "No input provided. Pipe a script to stdin.", + { + hints: [ + { message: "File: cat ./script.ts | base44 exec" }, + { + message: + 'Eval: echo "const users = await base44.entities.User.list(); console.log(users)" | base44 exec', + }, + ], + }, + ); + + if (!isNonInteractive) { + throw noInputError; + } + + const code = await readStdin(); + + if (!code.trim()) { + throw noInputError; + } + + const { exitCode } = await runScript({ appId: getAppConfig().id, code }); + + if (exitCode !== 0) { + process.exitCode = exitCode; + } + + return {}; +} + +export function getExecCommand(): Command { + return new Base44Command("exec") + .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); + }); +} diff --git a/packages/cli/src/cli/commands/site/open.ts b/packages/cli/src/cli/commands/site/open.ts index d1323229..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/site/index.js"; +import { getSiteUrl } from "@/core/project/index.js"; async function openAction( isNonInteractive: boolean, 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 ffb508ba..e18507a3 100644 --- a/packages/cli/src/cli/dev/dev-server/function-manager.ts +++ b/packages/cli/src/cli/dev/dev-server/function-manager.ts @@ -1,12 +1,9 @@ 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 { - DependencyNotFoundError, - InternalError, - InvalidInputError, -} from "@/core/errors.js"; +import { InternalError, InvalidInputError } from "@/core/errors.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; @@ -34,16 +31,7 @@ export class FunctionManager { this.wrapperPath = wrapperPath; 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("to run backend functions locally"); } } diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 77901950..7b74008e 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -17,6 +17,7 @@ import { getTypesCommand } from "@/cli/commands/types/index.js"; import { Base44Command } from "@/cli/utils/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"; @@ -74,6 +75,9 @@ export function createProgram(context: CLIContext): Command { // Register types command program.addCommand(getTypesCommand()); + // Register exec command + program.addCommand(getExecCommand()); + // Register development commands program.addCommand(getDevCommand(), { hidden: true }); 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. diff --git a/packages/cli/src/core/exec/index.ts b/packages/cli/src/core/exec/index.ts new file mode 100644 index 00000000..3ce13535 --- /dev/null +++ b/packages/cli/src/core/exec/index.ts @@ -0,0 +1 @@ +export * from "./run-script.js"; diff --git a/packages/cli/src/core/exec/run-script.ts b/packages/cli/src/core/exec/run-script.ts new file mode 100644 index 00000000..cff5c11a --- /dev/null +++ b/packages/cli/src/core/exec/run-script.ts @@ -0,0 +1,72 @@ +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 { verifyDenoInstalled } from "@/core/utils/index.js"; + +interface RunScriptOptions { + appId: string; + code: string; +} + +interface RunScriptResult { + exitCode: number; +} + +export async function runScript( + options: RunScriptOptions, +): Promise { + const { appId, code } = options; + + verifyDenoInstalled("to run scripts with exec"); + + const cleanupFns: (() => void)[] = []; + + const tempScript = await file({ postfix: ".ts" }); + cleanupFns.push(tempScript.cleanup); + writeFileSync(tempScript.path, code, "utf-8"); + const scriptPath = `file://${tempScript.path}`; + + const [appUserToken, appBaseUrl] = await Promise.all([ + getAppUserToken(), + 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(getExecWrapperPath(), tempWrapper.path); + + try { + const exitCode = await new Promise((resolvePromise) => { + const child = spawn( + "deno", + ["run", "--allow-all", "--node-modules-dir=auto", tempWrapper.path], + { + env: { + ...process.env, + SCRIPT_PATH: scriptPath, + BASE44_APP_ID: appId, + 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/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/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/dependencies.ts b/packages/cli/src/core/utils/dependencies.ts new file mode 100644 index 00000000..3b5be102 --- /dev/null +++ b/packages/cli/src/core/utils/dependencies.ts @@ -0,0 +1,16 @@ +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"; diff --git a/packages/cli/tests/cli/exec.spec.ts b/packages/cli/tests/cli/exec.spec.ts new file mode 100644 index 00000000..a09afbc9 --- /dev/null +++ b/packages/cli/tests/cli/exec.spec.ts @@ -0,0 +1,103 @@ +import stripAnsi from "strip-ansi"; +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("exec command", () => { + const t = setupCLITests(); + + 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(); + 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 "const users = await base44.entities.User.list(); console.log(users)" | base44 exec', + ); + }); + + 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(); + 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("executes a piped script and captures its output", 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")'); + + const result = await t.run("exec"); + + t.expectResult(result).toSucceed(); + expect(result.stdout).toContain("hello from exec"); + }); + + 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( + 'if (typeof base44 === "undefined") { Deno.exit(1); }\n' + + 'console.log("sdk-available");', + ); + + 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); + }); +}); diff --git a/packages/cli/tests/cli/testkit/CLITestkit.ts b/packages/cli/tests/cli/testkit/CLITestkit.ts index acd45994..54f749b9 100644 --- a/packages/cli/tests/cli/testkit/CLITestkit.ts +++ b/packages/cli/tests/cli/testkit/CLITestkit.ts @@ -46,6 +46,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; /** Real HTTP server for Base44 API endpoints */ readonly api: TestAPIServer; @@ -111,6 +112,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 ───────────────────────────────────────────── /** Spawn the CLI as a child process and execute the command */ @@ -130,11 +136,15 @@ export class CLITestkit { ? { file: getBinaryPath(), args } : { file: "node", args: [getNpmEntryPath(), ...args] }; + const stdinContent = this.stdinContent; + this.stdinContent = undefined; + const result = await execa(execArgs.file, execArgs.args, { cwd: this.projectDir ?? this.tempDir, env, reject: false, all: false, + input: stdinContent ?? "", }); return { diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 44185bd0..efdf4665 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -340,6 +340,13 @@ export class TestAPIServer { return this.addRoute("GET", "/oauth/userinfo", response); } + /** 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, + }); + } + // ─── APP-SCOPED ENDPOINTS ──────────────────────────────── mockEntitiesPush(response: EntitiesPushResponse): this { @@ -530,6 +537,17 @@ export class TestAPIServer { return this; } + /** + * Register a generic error response for any method/path combination. + */ + mockError( + method: "get" | "post" | "put" | "delete", + path: string, + error: ErrorResponse, + ): this { + return this.addErrorRoute(method.toUpperCase() as Method, path, error); + } + /** * Register a custom Express handler for advanced scenarios (e.g. stateful * responses that change behaviour across retries). diff --git a/packages/cli/tests/cli/testkit/index.ts b/packages/cli/tests/cli/testkit/index.ts index 5ca73764..51ec2a77 100644 --- a/packages/cli/tests/cli/testkit/index.ts +++ b/packages/cli/tests/cli/testkit/index.ts @@ -34,6 +34,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 */ @@ -116,6 +119,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),