Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 27 additions & 0 deletions docs/api-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,33 @@ The `base44Client` automatically handles token refresh:
2. If expired, refreshes token and saves new tokens
3. On 401 response, attempts refresh and retries once

## Environment-Supplied Credentials (Stripe Projects Handoff)

When `BASE44_ACCESS_TOKEN` is set in the environment, `base44Client` uses it
verbatim as the bearer token, taking precedence over `~/.base44/auth/auth.json`
and **bypassing the OAuth refresh logic** (these tokens are provider-issued, not
Base44 OAuth tokens, so they cannot be refreshed via `/oauth/token`; on a 401 the
error simply propagates). `isLoggedIn()` also returns `true` when this var is set,
so `requireAuth` commands run without an interactive login. See
`getEnvAccessToken()` in `core/auth/config.js`.

This supports the Stripe Projects CLI handoff: it provisions a Base44 app and
writes the credentials to `.env` via `stripe projects env --pull`. The CLI loads
`.env`/`.env.local` from the working directory at startup (`loadProjectEnvFiles()`
in `core/utils/env.js`, wired through `cli/bootstrap-env.js` as the first import so
it runs before the HTTP clients capture `getBase44ApiUrl()`). Precedence: ambient
`process.env` > `.env.local` > `.env`; pre-set values are never overridden.

**Var name normalization.** `stripe projects env --pull` namespaces each var by
resource name, so the credentials arrive as e.g. `BASE44_PROJECTS_BASE44_APP_ID`
rather than bare `BASE44_APP_ID`. After loading, `loadProjectEnvFiles()` normalizes
the four credential keys (`BASE44_APP_ID`, `BASE44_ACCESS_TOKEN`,
`BASE44_REFRESH_TOKEN`, `BASE44_API_URL`): for each, if the bare name is unset and
exactly one `<PREFIX>_<KEY>` variable exists, its value is copied to the bare name.
This is prefix-agnostic (survives any resource name) and leaves the bare key unset
when ambiguous. The `base44 init` command then consumes `BASE44_APP_ID` to scaffold
a local project for that existing app.

## API Response Transformation (snake_case to camelCase)

The Base44 API returns snake_case keys, but the CLI uses camelCase. Use Zod's `.transform()` to convert:
Expand Down
4 changes: 3 additions & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,9 @@ export function getMyCommand(): Command {
}
```

Commands that only use `logger.*` (display-only, no input) don't need this guard. See `project/create.ts`, `project/link.ts`, and `project/eject.ts` for real examples.
Commands that only use `logger.*` (display-only, no input) don't need this guard. See `project/create.ts`, `project/init.ts`, `project/link.ts`, and `project/eject.ts` for real examples.

`project/create.ts` and `project/init.ts` share their post-scaffold logic (push entities, deploy site, install skills, print summary) via `project/scaffold-shared.ts` — `create` mints a new app, `init` reuses an existing app id (from `--app-id` or `BASE44_APP_ID`).

## runTask (Async Operations with Spinners)

Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/cli/bootstrap-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { loadProjectEnvFiles } from "@/core/utils/env.js";

// Side-effect module: loads project-local .env / .env.local into process.env at
// the very start of the CLI process. This MUST run before any module that reads
// env-derived config at import time — notably the HTTP clients, which capture
// the API base URL (getBase44ApiUrl) when ky.create() runs at module load.
//
// Imported first in cli/index.ts so it initializes ahead of the program graph.
loadProjectEnvFiles();
147 changes: 12 additions & 135 deletions packages/cli/src/cli/commands/project/create.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,23 @@
import { basename, join, resolve } from "node:path";
import { basename, resolve } from "node:path";
import type { Option } from "@clack/prompts";
import { confirm, group, isCancel, select, text } from "@clack/prompts";
import { group, select, text } from "@clack/prompts";
import { Argument, type Command } from "commander";
import { execa } from "execa";
import kebabCase from "lodash/kebabCase";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import {
Base44Command,
getDashboardUrl,
onPromptCancel,
theme,
} from "@/cli/utils/index.js";
import { Base44Command, onPromptCancel, theme } from "@/cli/utils/index.js";
import { InvalidInputError } from "@/core/errors.js";
import { deploySite, isDirEmpty, pushEntities } from "@/core/index.js";
import { isDirEmpty } from "@/core/index.js";
import type { Template } from "@/core/project/index.js";
import {
createProjectFiles,
listTemplates,
readProjectConfig,
setAppConfig,
} from "@/core/project/index.js";

const DEFAULT_TEMPLATE_ID = "backend-only";
import {
completeProjectSetup,
DEFAULT_TEMPLATE_ID,
getTemplateById,
} from "./scaffold-shared.js";

interface CreateOptions {
name?: string;
Expand All @@ -31,18 +27,6 @@ interface CreateOptions {
skills?: boolean;
}

async function getTemplateById(templateId: string): Promise<Template> {
const templates = await listTemplates();
const template = templates.find((t) => t.id === templateId);
if (!template) {
const validIds = templates.map((t) => t.id).join(", ");
throw new InvalidInputError(`Template "${templateId}" not found.`, {
hints: [{ message: `Use one of: ${validIds}` }],
});
}
return template;
}

function validateNonInteractiveFlags(command: Command): void {
const { path } = command.opts<CreateOptions>();

Expand Down Expand Up @@ -179,117 +163,10 @@ async function executeCreate(
// Set app config in cache for sync access to getDashboardUrl and getAppClient
setAppConfig({ id: projectId, projectRoot: resolvedPath });

const { project, entities } = await readProjectConfig(resolvedPath);
let finalAppUrl: string | undefined;

if (entities.length > 0) {
let shouldPushEntities: boolean;

if (isInteractive) {
const result = await confirm({
message:
"Set up the backend data now? (This pushes the data models used by the template to Base44)",
});
shouldPushEntities = !isCancel(result) && result;
} else {
shouldPushEntities = !!deploy;
}

if (shouldPushEntities) {
await runTask(
`Pushing ${entities.length} data models to Base44...`,
async () => {
await pushEntities(entities);
},
{
successMessage: theme.colors.base44Orange(
"Data models pushed successfully",
),
errorMessage: "Failed to push data models",
},
);
}
}

if (project.site) {
const { installCommand, buildCommand, outputDirectory } = project.site;

let shouldDeploy: boolean;

if (isInteractive) {
const result = await confirm({
message: "Would you like to deploy the site now? (Hosted on Base44)",
});
shouldDeploy = !isCancel(result) && result;
} else {
shouldDeploy = !!deploy;
}

if (shouldDeploy && installCommand && buildCommand && outputDirectory) {
const { appUrl } = await runTask(
"Installing dependencies...",
async (updateMessage) => {
await execa({ cwd: resolvedPath, shell: true })`${installCommand}`;

updateMessage("Building project...");
await execa({ cwd: resolvedPath, shell: true })`${buildCommand}`;

updateMessage("Deploying site...");
return await deploySite(join(resolvedPath, outputDirectory));
},
{
successMessage: theme.colors.base44Orange(
"Site deployed successfully",
),
errorMessage: "Failed to deploy site",
},
);

finalAppUrl = appUrl;
}
}

// Add AI agent skills (--no-skills flag sets skills to false, otherwise defaults to true)
const shouldAddSkills = skills;

if (shouldAddSkills) {
try {
await runTask(
"Installing AI agent skills...",
async () => {
await execa("npx", ["-y", "skills", "add", "base44/skills", "-y"], {
cwd: resolvedPath,
shell: true,
});
},
{
successMessage: theme.colors.base44Orange(
"AI agent skills added successfully",
),
errorMessage:
"Failed to add AI agent skills - you can add them later with: npx skills add base44/skills",
},
);
} catch {
// Skills installation is non-critical (e.g., user may not have git installed)
// The error message is already shown by runTask, so we just continue
}
}

log.message(
`${theme.styles.header("Project")}: ${theme.colors.base44Orange(name)}`,
return await completeProjectSetup(
{ projectId, name, resolvedPath, deploy, skills, isInteractive },
{ log, runTask },
);
log.message(
`${theme.styles.header("Dashboard")}: ${theme.colors.links(getDashboardUrl(projectId))}`,
);

if (finalAppUrl) {
log.message(
`${theme.styles.header("Site")}: ${theme.colors.links(finalAppUrl)}`,
);
}

return { outroMessage: "Your project is set up and ready to use" };
}

async function createAction(
Expand Down
Loading
Loading