Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
69f9806
feat: add `base44 exec` command for running scripts with pre-authenti…
netanelgilad Mar 2, 2026
1adcbe3
fix: only consider stdin mode when no file or eval is provided
netanelgilad Mar 2, 2026
495e1c1
fix: add --node-modules-dir=auto for Deno 2.x npm: specifier support
netanelgilad Mar 2, 2026
7d50dc2
fix: copy exec wrapper out of node_modules before running with Deno
netanelgilad Mar 2, 2026
9247e25
fix: call cleanup() after script completes so Deno can exit
netanelgilad Mar 2, 2026
d1eac96
fix: format spawn args array and add exec command tests
netanelgilad Mar 2, 2026
cf7deb9
fix: validate input args before checking for Deno
netanelgilad Mar 2, 2026
8d045dd
Merge branch 'main' into feat/exec-command
netanelgilad Mar 2, 2026
8d65f98
fix: route function calls through app subdomain in exec
netanelgilad Mar 2, 2026
10dc541
fix: address PR review comments on exec command
github-actions[bot] Mar 3, 2026
eda2b29
Merge remote-tracking branch 'origin/main' into feat/exec-command
netanelgilad Mar 8, 2026
a35af35
refactor(exec): address PR review feedback
netanelgilad Mar 8, 2026
669fcec
stdin
netanelgilad Mar 10, 2026
7bf3b1a
Merge main into feat/exec-command
github-actions[bot] Mar 17, 2026
9763325
fix: resolve lint, knip, and test failures in exec command
netanelgilad Mar 17, 2026
17d9467
fix: include dist/deno-runtime in npm package files
netanelgilad Mar 17, 2026
02648fb
fix: add Deno to CI and move exec wrapper into assets system
netanelgilad Mar 18, 2026
03703f4
Merge remote-tracking branch 'origin/main' into feat/exec-command
netanelgilad Mar 18, 2026
a48b41b
fix: migrate exec command to Base44Command after main merge
netanelgilad Mar 18, 2026
cd7698d
no rename
netanelgilad Mar 18, 2026
15d7c84
cleanups
netanelgilad Mar 18, 2026
185b23e
remove extra args
netanelgilad Mar 18, 2026
2efd491
cleanup
netanelgilad Mar 18, 2026
658445f
cleaner
netanelgilad Mar 18, 2026
cce9fc0
test
netanelgilad Mar 18, 2026
a32ead4
lint fixes
netanelgilad Mar 18, 2026
5409d70
move to project/api
netanelgilad Mar 18, 2026
dee89b6
move getAppConfig
netanelgilad Mar 18, 2026
d85d1e8
non interactive
netanelgilad Mar 18, 2026
99e33ef
better example
netanelgilad Mar 18, 2026
38d62d5
better help
netanelgilad Mar 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: .
Expand Down
55 changes: 55 additions & 0 deletions packages/cli/deno-runtime/exec.ts
Original file line number Diff line number Diff line change
@@ -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();
}
3 changes: 2 additions & 1 deletion packages/cli/infra/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
73 changes: 73 additions & 0 deletions packages/cli/src/cli/commands/exec.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<RunCommandResult> {
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);
});
}
2 changes: 1 addition & 1 deletion packages/cli/src/cli/commands/site/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 4 additions & 16 deletions packages/cli/src/cli/dev/dev-server/function-manager.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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");
}
}

Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 });

Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/core/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/core/exec/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./run-script.js";
72 changes: 72 additions & 0 deletions packages/cli/src/core/exec/run-script.ts
Original file line number Diff line number Diff line change
@@ -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<RunScriptResult> {
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<number>((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();
}
}
}
40 changes: 39 additions & 1 deletion packages/cli/src/core/project/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<string> {
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<string> {
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;
}
30 changes: 2 additions & 28 deletions packages/cli/src/core/site/api.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -44,25 +40,3 @@ export async function uploadSite(archivePath: string): Promise<DeployResponse> {

return result.data;
}

export async function getSiteUrl(projectId?: string): Promise<string> {
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;
}
Loading
Loading