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
9 changes: 9 additions & 0 deletions .github/workflows/knip.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ jobs:
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
cache: true

- name: Cache Bun dependencies
uses: actions/cache@v5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-

- name: Install dependencies
run: bun install --frozen-lockfile
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ jobs:
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
cache: true

- name: Cache Bun dependencies
uses: actions/cache@v5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-

- name: Install dependencies
run: bun install --frozen-lockfile
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/manual-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ jobs:
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
cache: true

- name: Cache Bun dependencies
uses: actions/cache@v5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-

- name: Install dependencies
run: bun install --frozen-lockfile
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/preview-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ jobs:
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
cache: true

- name: Cache Bun dependencies
uses: actions/cache@v5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-

- name: Install dependencies
run: bun install --frozen-lockfile
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ jobs:
bun-version: latest
cache: true

- name: Cache Bun dependencies
uses: actions/cache@v5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-

- name: Install dependencies
run: bun install --frozen-lockfile

Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/typecheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ jobs:
bun-version: latest
cache: true

- name: Cache Bun dependencies
uses: actions/cache@v5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-

- name: Install dependencies
run: bun install --frozen-lockfile

Expand Down
2 changes: 1 addition & 1 deletion .npmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
npm_config_registry=https://registry.npmjs.org
registry=https://registry.npmjs.org/
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ The CLI will guide you through project setup. For step-by-step tutorials, see th
| [`connectors push`](https://docs.base44.com/developers/references/cli/commands/connectors-push) | Push local connectors to Base44 |
| [`entities push`](https://docs.base44.com/developers/references/cli/commands/entities-push) | Push local entities to Base44 |
| [`functions deploy`](https://docs.base44.com/developers/references/cli/commands/functions-deploy) | Deploy local functions to Base44 |
| [`secrets list`](https://docs.base44.com/developers/references/cli/commands/secrets-list) | List project secret names |
| [`secrets set`](https://docs.base44.com/developers/references/cli/commands/secrets-set) | Set one or more project secrets |
| [`secrets delete`](https://docs.base44.com/developers/references/cli/commands/secrets-delete) | Delete a project secret |
| [`site deploy`](https://docs.base44.com/developers/references/cli/commands/site-deploy) | Deploy built site files to Base44 hosting |
| [`site open`](https://docs.base44.com/developers/references/cli/commands/site-open) | Open the published site in your browser |
| [`types generate`](https://docs.base44.com/developers/references/cli/commands/types-generate) | Generate TypeScript types from project resources |
Expand Down
4 changes: 3 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,39 @@ theme.format.agentHints(hints) // "[Agent Hints]\n Run: ..."

When adding new theme properties, use **semantic names** (e.g., `links`, `header`) not color names.

## Input Validation with Commander Hooks

Use `.hook("preAction", validator)` to validate command input (required args, mutually exclusive options) **before** the action runs. This keeps validation separate from business logic.

```typescript
function validateInput(command: Command): void {
const { flagA, flagB } = command.opts<MyOptions>();
if (!command.args.length && !flagA) {
throw new InvalidInputError("Provide args or use --flag-a.");
}
if (command.args.length > 0 && flagA) {
throw new InvalidInputError("Provide args or --flag-a, but not both.");
}
}

export function getMyCommand(context: CLIContext): Command {
return new Command("my-cmd")
.argument("[entries...]", "Input entries")
.option("--flag-a <value>", "Alternative input")
.hook("preAction", validateInput)
.action(async (entries, options) => {
await runCommand(() => myAction(entries, options), { requireAuth: true }, context);
});
}
```

Access `command.args` for positional arguments and `command.opts()` for options inside the hook. See `secrets/set.ts` and `project/create.ts` for real examples.

## Rules (Command-Specific)

- **Command factory pattern** - Commands export `getXCommand(context)` functions, not static instances
- **Command wrapper** - All commands use `runCommand(fn, options, context)` utility
- **Task wrapper** - Use `runTask()` for async operations with spinners
- **Use theme for styling** - Never use `chalk` directly; import `theme` from `@/cli/utils/` and use semantic names
- **Use fs.ts utilities** - Always use `@/core/utils/fs.js` for file operations
- **Consistent copy across related commands** - User-facing messages (errors, success, hints) for commands in the same group should use consistent language and structure. When writing validation errors, outro messages, or spinner text, check sibling commands for parity so the product voice stays coherent.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"commander": "^12.1.0",
"common-tags": "^1.8.2",
"cors": "^2.8.5",
"dotenv": "17.3.1",
"ejs": "^3.1.10",
"execa": "^9.6.1",
"express": "^5.0.1",
Expand Down
35 changes: 35 additions & 0 deletions src/cli/commands/secrets/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Command } from "commander";
import type { CLIContext } from "@/cli/types.js";
import { deleteSecret } from "@/core/resources/secret/index.js";
import { runCommand, runTask } from "../../utils/index.js";
import type { RunCommandResult } from "../../utils/runCommand.js";

async function deleteSecretAction(key: string): Promise<RunCommandResult> {
await runTask(
`Deleting secret "${key}"`,
async () => {
return await deleteSecret(key);
},
{
successMessage: `Secret "${key}" deleted`,
errorMessage: `Failed to delete secret "${key}"`,
},
);

return {
outroMessage: "Secret deleted successfully.",
};
}

export function getSecretsDeleteCommand(context: CLIContext): Command {
return new Command("delete")
.description("Delete a secret")
.argument("<key>", "Secret name to delete")
.action(async (key: string) => {
await runCommand(
() => deleteSecretAction(key),
{ requireAuth: true },
context,
);
});
}
13 changes: 13 additions & 0 deletions src/cli/commands/secrets/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Command } from "commander";
import type { CLIContext } from "@/cli/types.js";
import { getSecretsDeleteCommand } from "./delete.js";
import { getSecretsListCommand } from "./list.js";
import { getSecretsSetCommand } from "./set.js";

export function getSecretsCommand(context: CLIContext): Command {
return new Command("secrets")
.description("Manage project secrets (environment variables)")
.addCommand(getSecretsListCommand(context))
.addCommand(getSecretsSetCommand(context))
.addCommand(getSecretsDeleteCommand(context));
}
41 changes: 41 additions & 0 deletions src/cli/commands/secrets/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { log } from "@clack/prompts";
import { Command } from "commander";
import type { CLIContext } from "@/cli/types.js";
import { listSecrets } from "@/core/resources/secret/index.js";
import { runCommand, runTask } from "../../utils/index.js";
import type { RunCommandResult } from "../../utils/runCommand.js";

async function listSecretsAction(): Promise<RunCommandResult> {
const secrets = await runTask(
"Fetching secrets from Base44",
async () => {
return await listSecrets();
},
{
successMessage: "Secrets fetched successfully",
errorMessage: "Failed to fetch secrets",
},
);

const names = Object.keys(secrets);

if (names.length === 0) {
return { outroMessage: "No secrets configured." };
}

for (const name of names) {
log.info(name);
}

return {
outroMessage: `Found ${names.length} secrets.`,
};
}

export function getSecretsListCommand(context: CLIContext): Command {
return new Command("list")
.description("List secret names")
.action(async () => {
await runCommand(listSecretsAction, { requireAuth: true }, context);
});
}
106 changes: 106 additions & 0 deletions src/cli/commands/secrets/set.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { resolve } from "node:path";
import { log } from "@clack/prompts";
import { Command } from "commander";
import type { CLIContext } from "@/cli/types.js";
import { InvalidInputError } from "@/core/errors.js";
import { setSecrets } from "@/core/resources/secret/index.js";
import { parseEnvFile } from "@/core/utils/index.js";
import { runCommand, runTask } from "../../utils/index.js";
import type { RunCommandResult } from "../../utils/runCommand.js";

function parseEntries(entries: string[]): Record<string, string> {
const secrets: Record<string, string> = {};

for (const entry of entries) {
const eqIndex = entry.indexOf("=");
if (eqIndex === -1) {
throw new InvalidInputError(
`Invalid format: "${entry}". Expected KEY=VALUE.`,
);
}

const key = entry.slice(0, eqIndex);
const value = entry.slice(eqIndex + 1);

if (!key) {
throw new InvalidInputError(
`Invalid format: "${entry}". Key cannot be empty.`,
);
}

secrets[key] = value;
}

return secrets;
}

function validateInput(command: Command): void {
const entries = command.args;
const { envFile } = command.opts<{ envFile?: string }>();
const hasEntries = entries.length > 0;
const hasEnvFile = Boolean(envFile);

if (!hasEntries && !hasEnvFile) {
throw new InvalidInputError(
"Provide KEY=VALUE pairs or use --env-file. Example: base44 secrets set KEY1=VALUE1 KEY2=VALUE2",
);
}

if (hasEntries && hasEnvFile) {
throw new InvalidInputError(
"Provide KEY=VALUE pairs or --env-file, but not both.",
);
}
}

async function setSecretsAction(
entries: string[],
options: { envFile?: string },
): Promise<RunCommandResult> {
let secrets: Record<string, string>;

if (options.envFile) {
secrets = await parseEnvFile(resolve(options.envFile as string));
if (Object.keys(secrets).length === 0) {
throw new InvalidInputError(
"The env file contains no valid KEY=VALUE entries.",
);
}
} else {
secrets = parseEntries(entries);
}

const names = Object.keys(secrets);

await runTask(
`Setting ${names.length} secrets`,
async () => {
return await setSecrets(secrets);
},
{
successMessage: `${names.length} secrets set successfully`,
errorMessage: "Failed to set secrets",
},
);

log.info(`Set: ${names.join(", ")}`);

return {
outroMessage: "Secrets set successfully.",
};
}

export function getSecretsSetCommand(context: CLIContext): Command {
return new Command("set")
.description("Set one or more secrets (KEY=VALUE format)")
.argument("[entries...]", "KEY=VALUE pairs (e.g. KEY1=VALUE1 KEY2=VALUE2)")
.option("--env-file <path>", "Path to .env file")
.hook("preAction", validateInput)
.action(async (entries: string[], options: { envFile?: string }) => {
await runCommand(
() => setSecretsAction(entries, options),
{ requireAuth: true },
context,
);
});
}
4 changes: 4 additions & 0 deletions src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { getCreateCommand } from "@/cli/commands/project/create.js";
import { getDeployCommand } from "@/cli/commands/project/deploy.js";
import { getLinkCommand } from "@/cli/commands/project/link.js";
import { getLogsCommand } from "@/cli/commands/project/logs.js";
import { getSecretsCommand } from "@/cli/commands/secrets/index.js";
import { getSiteCommand } from "@/cli/commands/site/index.js";
import { getTypesCommand } from "@/cli/commands/types/index.js";
import packageJson from "../../package.json";
Expand Down Expand Up @@ -56,6 +57,9 @@ export function createProgram(context: CLIContext): Command {
// Register functions commands
program.addCommand(getFunctionsDeployCommand(context));

// Register secrets commands
program.addCommand(getSecretsCommand(context));

// Register site commands
program.addCommand(getSiteCommand(context));

Expand Down
Loading
Loading