diff --git a/docs/commands.md b/docs/commands.md index 8086d3c1..42b55203 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,18 +1,17 @@ # Adding & Modifying CLI Commands -**Keywords:** command, factory pattern, CLIContext, isNonInteractive, runCommand, runTask, spinner, theming, chalk, program.ts, register, banner, intro, outro +**Keywords:** command, factory pattern, Base44Command, isNonInteractive, runTask, spinner, theming, chalk, program.ts, register, banner, intro, outro -Commands live in `src/cli/commands//`. They use a **factory pattern** with dependency injection via `CLIContext`. +Commands live in `src/cli/commands//`. They use a **factory pattern** — each file exports a function that returns a `Base44Command`. ## Command File Template ```typescript // src/cli/commands//.ts -import { Command } from "commander"; import { log } from "@clack/prompts"; -import type { CLIContext } from "@/cli/types.js"; -import { runCommand, runTask, theme } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import type { Command } from "commander"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command, runTask, theme } from "@/cli/utils/index.js"; async function myAction(): Promise { const result = await runTask( @@ -32,21 +31,39 @@ async function myAction(): Promise { return { outroMessage: `Created ${theme.styles.bold(result.name)}` }; } -export function getMyCommand(context: CLIContext): Command { - return new Command("") +export function getMyCommand(): Command { + return new Base44Command("") .description("") .option("-f, --flag", "Some flag") - .action(async (options) => { - await runCommand(myAction, { requireAuth: true }, context); - }); + .action(myAction); } ``` **Key rules**: - Export a **factory function** (`getMyCommand`), not a static command instance -- The factory receives `CLIContext` (contains `errorReporter` and `isNonInteractive`) -- Commands must NOT call `intro()` or `outro()` directly -- `runCommand()` handles both -- Always pass `context` as the third argument to `runCommand()` +- Use `Base44Command` class +- Commands must NOT call `intro()` or `outro()` directly +- The action function must return `RunCommandResult` with an `outroMessage` + +## Base44Command Options + +Pass options as the second argument to the constructor: + +```typescript +new Base44Command("my-cmd") // All defaults +new Base44Command("my-cmd", { requireAuth: false }) // Skip auth check +new Base44Command("my-cmd", { requireAppConfig: false }) // Skip app config loading +new Base44Command("my-cmd", { fullBanner: true }) // ASCII art banner +new Base44Command("my-cmd", { requireAuth: false, requireAppConfig: false }) +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `requireAuth` | `true` | Check authentication before running, auto-triggers login if needed | +| `requireAppConfig` | `true` | Load `.app.jsonc` and cache for sync access via `getAppConfig()` | +| `fullBanner` | `false` | Show ASCII art banner instead of simple intro tag | + +When the CLI runs in non-interactive mode (CI, piped output), all clack UI (intro, outro, themed errors) is automatically skipped. Errors go to stderr as plain text. ## Registering a Command @@ -56,51 +73,32 @@ Add the import and registration in `src/cli/program.ts`: import { getMyCommand } from "@/cli/commands//.js"; // Inside createProgram(context): -program.addCommand(getMyCommand(context)); +program.addCommand(getMyCommand()); ``` -## runCommand Options - -```typescript -await runCommand(myAction, undefined, context); // Standard (loads app config) -await runCommand(myAction, { fullBanner: true }, context); // ASCII art banner -await runCommand(myAction, { requireAuth: true }, context); // Auto-login if needed -await runCommand(myAction, { requireAppConfig: false }, context); // Skip app config loading -await runCommand(myAction, { fullBanner: true, requireAuth: true }, context); -``` - -- `fullBanner` - Show ASCII art banner instead of simple tag (for special commands like `create`) -- `requireAuth` - Check authentication before running, auto-triggers login if needed -- `requireAppConfig` - Load `.app.jsonc` and cache for sync access (default: `true`) - -## CLIContext (Dependency Injection) +## CLIContext (Automatic Injection) ```typescript export interface CLIContext { errorReporter: ErrorReporter; isNonInteractive: boolean; + distribution: Distribution; } ``` - Created once in `runCLI()` at startup -- `isNonInteractive` is `true` when stdin/stdout are not a TTY (e.g., CI, piped output, AI agents). Use it to skip interactive prompts, browser opens, and animations. -- Passed to `createProgram(context)`, which passes it to each command factory -- Commands pass it to `runCommand()` for error reporting integration +- `isNonInteractive` is `true` when stdin/stdout are not a TTY (e.g., CI, piped output, AI agents). Controls quiet mode — when true, all clack UI is suppressed. ### Using `isNonInteractive` -Pass `context.isNonInteractive` to your action when the command has interactive behavior (browser opens, confirmation prompts, animations): +When a command needs to check `isNonInteractive` (e.g., to skip browser opens or confirmation prompts), access it from the command instance. Commander passes the command as the last argument to action handlers: ```typescript -export function getMyCommand(context: CLIContext): Command { - return new Command("open") +export function getMyCommand(): Command { + return new Base44Command("open") .description("Open something in browser") - .action(async () => { - await runCommand( - () => myAction(context.isNonInteractive), - { requireAuth: true }, - context, - ); + .action(async (_options: unknown, command: Base44Command) => { + return await myAction(command.isNonInteractive); }); } @@ -112,6 +110,32 @@ async function myAction(isNonInteractive: boolean): Promise { } ``` +### Guarding Interactive Commands + +Commands that use interactive prompts (`select`, `text`, `confirm`, `group` from `@clack/prompts`) **must** guard against non-interactive environments. If the user didn't provide enough flags to skip all prompts, error early: + +```typescript +export function getMyCommand(): Command { + return new Base44Command("my-cmd", { requireAppConfig: false }) + .option("-n, --name ", "Project name") + .option("-p, --path ", "Project path") + .action(async (options: MyOptions, command: Base44Command) => { + const skipPrompts = !!options.name && !!options.path; + if (!skipPrompts && command.isNonInteractive) { + throw new InvalidInputError( + "--name and --path are required in non-interactive mode", + ); + } + if (skipPrompts) { + return await createNonInteractive(options); + } + return await createInteractive(options); + }); +} +``` + +Commands that only use `log.*` (display-only, no input) don't need this guard. See `project/create.ts`, `project/link.ts`, and `project/eject.ts` for real examples. + ## runTask (Async Operations with Spinners) Use `runTask()` for any async operation that takes time: @@ -182,13 +206,13 @@ function validateInput(command: Command): void { } } -export function getMyCommand(context: CLIContext): Command { - return new Command("my-cmd") +export function getMyCommand(): Command { + return new Base44Command("my-cmd") .argument("[entries...]", "Input entries") .option("--flag-a ", "Alternative input") .hook("preAction", validateInput) .action(async (entries, options) => { - await runCommand(() => myAction(entries, options), { requireAuth: true }, context); + return await myAction(entries, options); }); } ``` @@ -197,9 +221,11 @@ Access `command.args` for positional arguments and `command.opts()` for options ## Rules (Command-Specific) -- **Command factory pattern** - Commands export `getXCommand(context)` functions, not static instances -- **Command wrapper** - All commands use `runCommand(fn, options, context)` utility +- **Command factory pattern** - Commands export `getXCommand()` functions (no parameters), not static instances +- **Use `Base44Command`** - All commands use `new Base44Command(name, options?)` +- **No context plumbing** - Context is injected automatically. Command files should never import `CLIContext`. - **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 +- **Guard interactive prompts** - Commands using `select`, `text`, `confirm`, or `group` from `@clack/prompts` must check `command.isNonInteractive` and throw `InvalidInputError` if required flags are missing. Never let prompts hang in CI. - **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. diff --git a/packages/cli/src/cli/commands/agents/index.ts b/packages/cli/src/cli/commands/agents/index.ts index 5bf65f1c..49d60bc5 100644 --- a/packages/cli/src/cli/commands/agents/index.ts +++ b/packages/cli/src/cli/commands/agents/index.ts @@ -1,11 +1,10 @@ import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; import { getAgentsPullCommand } from "./pull.js"; import { getAgentsPushCommand } from "./push.js"; -export function getAgentsCommand(context: CLIContext): Command { +export function getAgentsCommand(): Command { return new Command("agents") .description("Manage project agents") - .addCommand(getAgentsPushCommand(context)) - .addCommand(getAgentsPullCommand(context)); + .addCommand(getAgentsPushCommand()) + .addCommand(getAgentsPullCommand()); } diff --git a/packages/cli/src/cli/commands/agents/pull.ts b/packages/cli/src/cli/commands/agents/pull.ts index f92da337..6b5b262c 100644 --- a/packages/cli/src/cli/commands/agents/pull.ts +++ b/packages/cli/src/cli/commands/agents/pull.ts @@ -1,11 +1,10 @@ import { dirname, join } from "node:path"; import { log } from "@clack/prompts"; -import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; +import type { Command } from "commander"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command, runTask } from "@/cli/utils/index.js"; import { readProjectConfig } from "@/core/index.js"; import { fetchAgents, writeAgents } from "@/core/resources/agent/index.js"; -import { runCommand, runTask } from "../../utils/index.js"; -import type { RunCommandResult } from "../../utils/runCommand.js"; async function pullAgentsAction(): Promise { const { project } = await readProjectConfig(); @@ -50,12 +49,10 @@ async function pullAgentsAction(): Promise { }; } -export function getAgentsPullCommand(context: CLIContext): Command { - return new Command("pull") +export function getAgentsPullCommand(): Command { + return new Base44Command("pull") .description( "Pull agents from Base44 to local files (replaces all local agent configs)", ) - .action(async () => { - await runCommand(pullAgentsAction, { requireAuth: true }, context); - }); + .action(pullAgentsAction); } diff --git a/packages/cli/src/cli/commands/agents/push.ts b/packages/cli/src/cli/commands/agents/push.ts index 92f4c0c7..35a055b1 100644 --- a/packages/cli/src/cli/commands/agents/push.ts +++ b/packages/cli/src/cli/commands/agents/push.ts @@ -1,10 +1,9 @@ import { log } from "@clack/prompts"; -import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; +import type { Command } from "commander"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command, runTask } from "@/cli/utils/index.js"; import { readProjectConfig } from "@/core/index.js"; import { pushAgents } from "@/core/resources/agent/index.js"; -import { runCommand, runTask } from "../../utils/index.js"; -import type { RunCommandResult } from "../../utils/runCommand.js"; async function pushAgentsAction(): Promise { const { agents } = await readProjectConfig(); @@ -39,12 +38,10 @@ async function pushAgentsAction(): Promise { return { outroMessage: "Agents pushed to Base44" }; } -export function getAgentsPushCommand(context: CLIContext): Command { - return new Command("push") +export function getAgentsPushCommand(): Command { + return new Base44Command("push") .description( "Push local agents to Base44 (replaces all remote agent configs)", ) - .action(async () => { - await runCommand(pushAgentsAction, { requireAuth: true }, context); - }); + .action(pushAgentsAction); } diff --git a/packages/cli/src/cli/commands/auth/login-flow.ts b/packages/cli/src/cli/commands/auth/login-flow.ts index 01766e9f..5cb62f15 100644 --- a/packages/cli/src/cli/commands/auth/login-flow.ts +++ b/packages/cli/src/cli/commands/auth/login-flow.ts @@ -1,7 +1,7 @@ import { log } from "@clack/prompts"; import pWaitFor from "p-wait-for"; +import type { RunCommandResult } from "@/cli/types.js"; import { runTask } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; import { theme } from "@/cli/utils/theme.js"; import type { DeviceCodeResponse, diff --git a/packages/cli/src/cli/commands/auth/login.ts b/packages/cli/src/cli/commands/auth/login.ts index 623d1d11..860921ab 100644 --- a/packages/cli/src/cli/commands/auth/login.ts +++ b/packages/cli/src/cli/commands/auth/login.ts @@ -1,12 +1,12 @@ -import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; -import { runCommand } from "@/cli/utils/index.js"; +import type { Command } from "commander"; +import { Base44Command } from "@/cli/utils/index.js"; import { login } from "./login-flow.js"; -export function getLoginCommand(context: CLIContext): Command { - return new Command("login") +export function getLoginCommand(): Command { + return new Base44Command("login", { + requireAuth: false, + requireAppConfig: false, + }) .description("Authenticate with Base44") - .action(async () => { - await runCommand(login, { requireAppConfig: false }, context); - }); + .action(login); } diff --git a/packages/cli/src/cli/commands/auth/logout.ts b/packages/cli/src/cli/commands/auth/logout.ts index 540c15fd..2fd58a0e 100644 --- a/packages/cli/src/cli/commands/auth/logout.ts +++ b/packages/cli/src/cli/commands/auth/logout.ts @@ -1,7 +1,6 @@ -import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; -import { runCommand } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import type { Command } from "commander"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; import { deleteAuth } from "@/core/auth/index.js"; async function logout(): Promise { @@ -9,10 +8,11 @@ async function logout(): Promise { return { outroMessage: "Logged out successfully" }; } -export function getLogoutCommand(context: CLIContext): Command { - return new Command("logout") +export function getLogoutCommand(): Command { + return new Base44Command("logout", { + requireAuth: false, + requireAppConfig: false, + }) .description("Logout from current device") - .action(async () => { - await runCommand(logout, { requireAppConfig: false }, context); - }); + .action(logout); } diff --git a/packages/cli/src/cli/commands/auth/whoami.ts b/packages/cli/src/cli/commands/auth/whoami.ts index aad381c6..26a6e01f 100644 --- a/packages/cli/src/cli/commands/auth/whoami.ts +++ b/packages/cli/src/cli/commands/auth/whoami.ts @@ -1,7 +1,6 @@ -import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; -import { runCommand, theme } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import type { Command } from "commander"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command, theme } from "@/cli/utils/index.js"; import { readAuth } from "@/core/auth/index.js"; async function whoami(): Promise { @@ -9,14 +8,8 @@ async function whoami(): Promise { return { outroMessage: `Logged in as: ${theme.styles.bold(auth.email)}` }; } -export function getWhoamiCommand(context: CLIContext): Command { - return new Command("whoami") +export function getWhoamiCommand(): Command { + return new Base44Command("whoami", { requireAppConfig: false }) .description("Display current authenticated user") - .action(async () => { - await runCommand( - whoami, - { requireAuth: true, requireAppConfig: false }, - context, - ); - }); + .action(whoami); } diff --git a/packages/cli/src/cli/commands/connectors/index.ts b/packages/cli/src/cli/commands/connectors/index.ts index c4324722..62370189 100644 --- a/packages/cli/src/cli/commands/connectors/index.ts +++ b/packages/cli/src/cli/commands/connectors/index.ts @@ -1,13 +1,12 @@ import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; import { getConnectorsListAvailableCommand } from "./list-available.js"; import { getConnectorsPullCommand } from "./pull.js"; import { getConnectorsPushCommand } from "./push.js"; -export function getConnectorsCommand(context: CLIContext): Command { +export function getConnectorsCommand(): Command { return new Command("connectors") .description("Manage project connectors (OAuth integrations)") - .addCommand(getConnectorsListAvailableCommand(context)) - .addCommand(getConnectorsPullCommand(context)) - .addCommand(getConnectorsPushCommand(context)); + .addCommand(getConnectorsListAvailableCommand()) + .addCommand(getConnectorsPullCommand()) + .addCommand(getConnectorsPushCommand()); } diff --git a/packages/cli/src/cli/commands/connectors/list-available.ts b/packages/cli/src/cli/commands/connectors/list-available.ts index 10e7affc..9d940f15 100644 --- a/packages/cli/src/cli/commands/connectors/list-available.ts +++ b/packages/cli/src/cli/commands/connectors/list-available.ts @@ -1,13 +1,12 @@ import { log } from "@clack/prompts"; -import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; +import type { Command } from "commander"; +import type { RunCommandResult } from "@/cli/types.js"; import { + Base44Command, formatYaml, - runCommand, runTask, YAML_INDENT, } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; import { listAvailableIntegrations } from "@/core/resources/connector/index.js"; async function listAvailableAction(): Promise { @@ -37,12 +36,8 @@ async function listAvailableAction(): Promise { }; } -export function getConnectorsListAvailableCommand( - context: CLIContext, -): Command { - return new Command("list-available") +export function getConnectorsListAvailableCommand(): Command { + return new Base44Command("list-available") .description("List all available integration types") - .action(async () => { - await runCommand(listAvailableAction, { requireAuth: true }, context); - }); + .action(listAvailableAction); } diff --git a/packages/cli/src/cli/commands/connectors/pull.ts b/packages/cli/src/cli/commands/connectors/pull.ts index 8ae26f6e..b4ff3152 100644 --- a/packages/cli/src/cli/commands/connectors/pull.ts +++ b/packages/cli/src/cli/commands/connectors/pull.ts @@ -1,14 +1,13 @@ import { dirname, join } from "node:path"; import { log } from "@clack/prompts"; -import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; +import type { Command } from "commander"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command, runTask } from "@/cli/utils/index.js"; import { readProjectConfig } from "@/core/index.js"; import { pullAllConnectors, writeConnectors, } from "@/core/resources/connector/index.js"; -import { runCommand, runTask } from "../../utils/index.js"; -import type { RunCommandResult } from "../../utils/runCommand.js"; async function pullConnectorsAction(): Promise { const { project } = await readProjectConfig(); @@ -53,12 +52,10 @@ async function pullConnectorsAction(): Promise { }; } -export function getConnectorsPullCommand(context: CLIContext): Command { - return new Command("pull") +export function getConnectorsPullCommand(): Command { + return new Base44Command("pull") .description( "Pull connectors from Base44 to local files (replaces all local connector configs)", ) - .action(async () => { - await runCommand(pullConnectorsAction, { requireAuth: true }, context); - }); + .action(pullConnectorsAction); } diff --git a/packages/cli/src/cli/commands/connectors/push.ts b/packages/cli/src/cli/commands/connectors/push.ts index 28f6b94c..13048b89 100644 --- a/packages/cli/src/cli/commands/connectors/push.ts +++ b/packages/cli/src/cli/commands/connectors/push.ts @@ -1,8 +1,7 @@ import { log } from "@clack/prompts"; -import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; -import { runCommand, runTask, theme } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import type { Command } from "commander"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command, runTask, theme } from "@/cli/utils/index.js"; import { getConnectorsUrl } from "@/cli/utils/urls.js"; import { readProjectConfig } from "@/core/index.js"; import { @@ -133,16 +132,12 @@ async function pushConnectorsAction( return { outroMessage }; } -export function getConnectorsPushCommand(context: CLIContext): Command { - return new Command("push") +export function getConnectorsPushCommand(): Command { + return new Base44Command("push") .description( "Push local connectors to Base44 (overwrites connectors on Base44)", ) - .action(async () => { - await runCommand( - () => pushConnectorsAction(context.isNonInteractive), - { requireAuth: true }, - context, - ); + .action(async (_options: unknown, command: Base44Command) => { + return await pushConnectorsAction(command.isNonInteractive); }); } diff --git a/packages/cli/src/cli/commands/dashboard/index.ts b/packages/cli/src/cli/commands/dashboard/index.ts index 7d63e9b0..5fa050ba 100644 --- a/packages/cli/src/cli/commands/dashboard/index.ts +++ b/packages/cli/src/cli/commands/dashboard/index.ts @@ -1,9 +1,8 @@ import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; import { getDashboardOpenCommand } from "./open.js"; -export function getDashboardCommand(context: CLIContext): Command { +export function getDashboardCommand(): Command { return new Command("dashboard") .description("Manage app dashboard") - .addCommand(getDashboardOpenCommand(context)); + .addCommand(getDashboardOpenCommand()); } diff --git a/packages/cli/src/cli/commands/dashboard/open.ts b/packages/cli/src/cli/commands/dashboard/open.ts index b679a969..83af7941 100644 --- a/packages/cli/src/cli/commands/dashboard/open.ts +++ b/packages/cli/src/cli/commands/dashboard/open.ts @@ -1,8 +1,7 @@ -import { Command } from "commander"; +import type { Command } from "commander"; import open from "open"; -import type { CLIContext } from "@/cli/types.js"; -import { getDashboardUrl, runCommand } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command, getDashboardUrl } from "@/cli/utils/index.js"; async function openDashboard( isNonInteractive: boolean, @@ -16,14 +15,10 @@ async function openDashboard( return { outroMessage: `Dashboard opened at ${dashboardUrl}` }; } -export function getDashboardOpenCommand(context: CLIContext): Command { - return new Command("open") +export function getDashboardOpenCommand(): Command { + return new Base44Command("open") .description("Open the app dashboard in your browser") - .action(async () => { - await runCommand( - () => openDashboard(context.isNonInteractive), - { requireAuth: true }, - context, - ); + .action(async (_options: unknown, command: Base44Command) => { + return await openDashboard(command.isNonInteractive); }); } diff --git a/packages/cli/src/cli/commands/dev.ts b/packages/cli/src/cli/commands/dev.ts index 90df2add..7dc7b8de 100644 --- a/packages/cli/src/cli/commands/dev.ts +++ b/packages/cli/src/cli/commands/dev.ts @@ -1,8 +1,7 @@ -import { Command } from "commander"; +import type { Command } from "commander"; import { createDevServer } from "@/cli/dev/dev-server/main.js"; -import type { CLIContext } from "@/cli/types.js"; -import { runCommand, theme } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command, theme } from "@/cli/utils/index.js"; import { getDenoWrapperPath } from "@/core/assets.js"; import { readProjectConfig } from "@/core/project/config.js"; @@ -26,15 +25,9 @@ async function devAction(options: DevOptions): Promise { }; } -export function getDevCommand(context: CLIContext): Command { - return new Command("dev") +export function getDevCommand(): Command { + return new Base44Command("dev") .description("Start the development server") .option("-p, --port ", "Port for the development server") - .action(async (options: DevOptions) => { - await runCommand( - () => devAction(options), - { requireAuth: true }, - context, - ); - }); + .action(devAction); } diff --git a/packages/cli/src/cli/commands/entities/push.ts b/packages/cli/src/cli/commands/entities/push.ts index caabb4e3..ccc0eed8 100644 --- a/packages/cli/src/cli/commands/entities/push.ts +++ b/packages/cli/src/cli/commands/entities/push.ts @@ -1,8 +1,7 @@ import { log } from "@clack/prompts"; import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; -import { runCommand, runTask } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command, runTask } from "@/cli/utils/index.js"; import { readProjectConfig } from "@/core/index.js"; import { pushEntities } from "@/core/resources/entity/index.js"; @@ -41,14 +40,12 @@ async function pushEntitiesAction(): Promise { return { outroMessage: "Entities pushed to Base44" }; } -export function getEntitiesPushCommand(context: CLIContext): Command { +export function getEntitiesPushCommand(): Command { return new Command("entities") .description("Manage project entities") .addCommand( - new Command("push") + new Base44Command("push") .description("Push local entities to Base44") - .action(async () => { - await runCommand(pushEntitiesAction, { requireAuth: true }, context); - }), + .action(pushEntitiesAction), ); } diff --git a/packages/cli/src/cli/commands/functions/delete.ts b/packages/cli/src/cli/commands/functions/delete.ts index df1a995d..3f8b95ca 100644 --- a/packages/cli/src/cli/commands/functions/delete.ts +++ b/packages/cli/src/cli/commands/functions/delete.ts @@ -1,7 +1,6 @@ -import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; -import { runCommand, runTask } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import type { Command } from "commander"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command, runTask } from "@/cli/utils/index.js"; import { ApiError } from "@/core/errors.js"; import { deleteSingleFunction } from "@/core/resources/function/api.js"; @@ -57,17 +56,13 @@ function validateNames(command: Command): void { } } -export function getDeleteCommand(context: CLIContext): Command { - return new Command("delete") +export function getDeleteCommand(): Command { + return new Base44Command("delete") .description("Delete deployed functions") .argument("", "Function names to delete") .hook("preAction", validateNames) .action(async (rawNames: string[]) => { const names = parseNames(rawNames); - await runCommand( - () => deleteFunctionsAction(names), - { requireAuth: true }, - context, - ); + return deleteFunctionsAction(names); }); } diff --git a/packages/cli/src/cli/commands/functions/deploy.ts b/packages/cli/src/cli/commands/functions/deploy.ts index 0d5ae969..1fd32924 100644 --- a/packages/cli/src/cli/commands/functions/deploy.ts +++ b/packages/cli/src/cli/commands/functions/deploy.ts @@ -1,11 +1,9 @@ import { log } from "@clack/prompts"; -import { Command } from "commander"; +import type { Command } from "commander"; import { formatDeployResult } from "@/cli/commands/functions/formatDeployResult.js"; import { parseNames } from "@/cli/commands/functions/parseNames.js"; -import type { CLIContext } from "@/cli/types.js"; -import { runCommand } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; -import { theme } from "@/cli/utils/theme.js"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command, theme } from "@/cli/utils/index.js"; import { InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/index.js"; import { @@ -130,19 +128,13 @@ async function deployFunctionsAction( return { outroMessage: buildDeploySummary(results) }; } -export function getDeployCommand(context: CLIContext): Command { - return new Command("deploy") +export function getDeployCommand(): Command { + return new Base44Command("deploy") .description("Deploy functions to Base44") .argument("[names...]", "Function names to deploy (deploys all if omitted)") .option("--force", "Delete remote functions not found locally") .action(async (rawNames: string[], options: { force?: boolean }) => { - await runCommand( - () => { - const names = parseNames(rawNames); - return deployFunctionsAction(names, options); - }, - { requireAuth: true }, - context, - ); + const names = parseNames(rawNames); + return deployFunctionsAction(names, options); }); } diff --git a/packages/cli/src/cli/commands/functions/index.ts b/packages/cli/src/cli/commands/functions/index.ts index 1e724c69..010e23ff 100644 --- a/packages/cli/src/cli/commands/functions/index.ts +++ b/packages/cli/src/cli/commands/functions/index.ts @@ -1,15 +1,14 @@ import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; import { getDeleteCommand } from "./delete.js"; import { getDeployCommand } from "./deploy.js"; import { getListCommand } from "./list.js"; import { getPullCommand } from "./pull.js"; -export function getFunctionsCommand(context: CLIContext): Command { +export function getFunctionsCommand(): Command { return new Command("functions") .description("Manage backend functions") - .addCommand(getDeployCommand(context)) - .addCommand(getDeleteCommand(context)) - .addCommand(getListCommand(context)) - .addCommand(getPullCommand(context)); + .addCommand(getDeployCommand()) + .addCommand(getDeleteCommand()) + .addCommand(getListCommand()) + .addCommand(getPullCommand()); } diff --git a/packages/cli/src/cli/commands/functions/list.ts b/packages/cli/src/cli/commands/functions/list.ts index e0cae503..81c453eb 100644 --- a/packages/cli/src/cli/commands/functions/list.ts +++ b/packages/cli/src/cli/commands/functions/list.ts @@ -1,9 +1,7 @@ import { log } from "@clack/prompts"; -import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; -import { runCommand, runTask } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; -import { theme } from "@/cli/utils/theme.js"; +import type { Command } from "commander"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command, runTask, theme } from "@/cli/utils/index.js"; import { listDeployedFunctions } from "@/core/resources/function/api.js"; async function listFunctionsAction(): Promise { @@ -33,10 +31,8 @@ async function listFunctionsAction(): Promise { }; } -export function getListCommand(context: CLIContext): Command { - return new Command("list") +export function getListCommand(): Command { + return new Base44Command("list") .description("List all deployed functions") - .action(async () => { - await runCommand(listFunctionsAction, { requireAuth: true }, context); - }); + .action(listFunctionsAction); } diff --git a/packages/cli/src/cli/commands/functions/pull.ts b/packages/cli/src/cli/commands/functions/pull.ts index 3d952220..c4bcda59 100644 --- a/packages/cli/src/cli/commands/functions/pull.ts +++ b/packages/cli/src/cli/commands/functions/pull.ts @@ -1,9 +1,8 @@ import { dirname, join } from "node:path"; import { log } from "@clack/prompts"; -import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; -import { runCommand, runTask } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import type { Command } from "commander"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command, runTask } from "@/cli/utils/index.js"; import { readProjectConfig } from "@/core/index.js"; import { listDeployedFunctions } from "@/core/resources/function/api.js"; import { writeFunctions } from "@/core/resources/function/pull.js"; @@ -65,15 +64,9 @@ async function pullFunctionsAction( }; } -export function getPullCommand(context: CLIContext): Command { - return new Command("pull") +export function getPullCommand(): Command { + return new Base44Command("pull") .description("Pull deployed functions from Base44") .argument("[name]", "Function name to pull (pulls all if omitted)") - .action(async (name: string | undefined) => { - await runCommand( - () => pullFunctionsAction(name), - { requireAuth: true }, - context, - ); - }); + .action(pullFunctionsAction); } diff --git a/packages/cli/src/cli/commands/project/create.ts b/packages/cli/src/cli/commands/project/create.ts index a6c5fb82..b26ef101 100644 --- a/packages/cli/src/cli/commands/project/create.ts +++ b/packages/cli/src/cli/commands/project/create.ts @@ -1,18 +1,17 @@ import { basename, join, resolve } from "node:path"; import type { Option } from "@clack/prompts"; import { confirm, group, isCancel, log, select, text } from "@clack/prompts"; -import { Argument, Command } from "commander"; +import { Argument, type Command } from "commander"; import { execa } from "execa"; import kebabCase from "lodash/kebabCase"; -import type { CLIContext } from "@/cli/types.js"; +import type { RunCommandResult } from "@/cli/types.js"; import { + Base44Command, getDashboardUrl, onPromptCancel, - runCommand, runTask, theme, } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; import { InvalidInputError } from "@/core/errors.js"; import { deploySite, isDirEmpty, pushEntities } from "@/core/index.js"; import type { Template } from "@/core/project/index.js"; @@ -283,8 +282,11 @@ async function executeCreate({ return { outroMessage: "Your project is set up and ready to use" }; } -export function getCreateCommand(context: CLIContext): Command { - return new Command("create") +export function getCreateCommand(): Command { + return new Base44Command("create", { + requireAppConfig: false, + fullBanner: true, + }) .description("Create a new Base44 project") .addArgument(new Argument("name", "Project name").argOptional()) .option("-p, --path ", "Path where to create the project") @@ -303,26 +305,38 @@ Examples: $ base44 create my-app --path ./projects/my-app --deploy Creates a base44 project at ./project/my-app and deploys it`, ) .hook("preAction", validateNonInteractiveFlags) - .action(async (name: string | undefined, options: CreateOptions) => { - if (name && !options.path) { - options.path = `./${kebabCase(name)}`; - } - - const isNonInteractive = !!(options.name ?? name) && !!options.path; - - if (isNonInteractive) { - await runCommand( - () => - createNonInteractive({ name: options.name ?? name, ...options }), - { requireAuth: true, requireAppConfig: false }, - context, - ); - } else { - await runCommand( - () => createInteractive({ name, ...options }), - { fullBanner: true, requireAuth: true, requireAppConfig: false }, - context, - ); - } - }); + .action( + async ( + name: string | undefined, + options: CreateOptions, + command: Base44Command, + ) => { + if (name && !options.path) { + options.path = `./${kebabCase(name)}`; + } + + const skipPrompts = !!(options.name ?? name) && !!options.path; + + if (!skipPrompts && command.isNonInteractive) { + throw new InvalidInputError( + "Project name and --path are required in non-interactive mode", + { + hints: [ + { + message: "Usage: base44 create --path ", + }, + ], + }, + ); + } + + if (skipPrompts) { + return await createNonInteractive({ + name: options.name ?? name, + ...options, + }); + } + return await createInteractive({ name, ...options }); + }, + ); } diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index f0381b64..cbef0592 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -1,18 +1,18 @@ import { confirm, isCancel, log } from "@clack/prompts"; -import { Command } from "commander"; +import type { Command } from "commander"; import { filterPendingOAuth, promptOAuthFlows, } from "@/cli/commands/connectors/oauth-prompt.js"; import { formatDeployResult } from "@/cli/commands/functions/formatDeployResult.js"; -import type { CLIContext } from "@/cli/types.js"; +import type { RunCommandResult } from "@/cli/types.js"; import { + Base44Command, getConnectorsUrl, getDashboardUrl, - runCommand, theme, } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import { InvalidInputError } from "@/core/errors.js"; import { deployAll, hasResourcesToDeploy, @@ -124,22 +124,22 @@ export async function deployAction( return { outroMessage: "App deployed successfully" }; } -export function getDeployCommand(context: CLIContext): Command { - return new Command("deploy") +export function getDeployCommand(): Command { + return new Base44Command("deploy") .description( "Deploy all project resources (entities, functions, agents, connectors, and site)", ) .option("-y, --yes", "Skip confirmation prompt") - .action(async (options: DeployOptions) => { - await runCommand( - () => - deployAction({ - ...options, - isNonInteractive: context.isNonInteractive, - }), - { requireAuth: true }, - context, - ); + .action(async (options: DeployOptions, command: Base44Command) => { + if (command.isNonInteractive && !options.yes) { + throw new InvalidInputError( + "--yes is required in non-interactive mode", + ); + } + return await deployAction({ + ...options, + isNonInteractive: command.isNonInteractive, + }); }); } diff --git a/packages/cli/src/cli/commands/project/eject.ts b/packages/cli/src/cli/commands/project/eject.ts index cd7e3fc6..3d86ccb6 100644 --- a/packages/cli/src/cli/commands/project/eject.ts +++ b/packages/cli/src/cli/commands/project/eject.ts @@ -1,14 +1,13 @@ import { resolve } from "node:path"; import type { Option } from "@clack/prompts"; import { cancel, confirm, isCancel, log, select, text } from "@clack/prompts"; -import { Command } from "commander"; +import type { Command } from "commander"; import { execa } from "execa"; import kebabCase from "lodash/kebabCase"; import { deployAction } from "@/cli/commands/project/deploy.js"; import { CLIExitError } from "@/cli/errors.js"; -import type { CLIContext } from "@/cli/types.js"; -import { runCommand, runTask, theme } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command, runTask, theme } from "@/cli/utils/index.js"; import type { Project } from "@/core/index.js"; import { createProject, @@ -169,8 +168,8 @@ async function eject(options: EjectOptions): Promise { return { outroMessage: "Your new project is set and ready to use" }; } -export function getEjectCommand(context: CLIContext): Command { - return new Command("eject") +export function getEjectCommand(): Command { + return new Base44Command("eject", { requireAppConfig: false }) .description("Download the code for an existing Base44 project") .option("-p, --path ", "Path where to write the project") .option( @@ -178,11 +177,20 @@ export function getEjectCommand(context: CLIContext): Command { "Project ID to eject (skips interactive selection)", ) .option("-y, --yes", "Skip confirmation prompts") - .action(async (options: EjectOptions) => { - await runCommand( - () => eject({ ...options, isNonInteractive: context.isNonInteractive }), - { requireAuth: true, requireAppConfig: false }, - context, - ); + .action(async (options: EjectOptions, command: Base44Command) => { + if (command.isNonInteractive && !options.projectId) { + throw new InvalidInputError( + "--project-id is required in non-interactive mode", + ); + } + if (command.isNonInteractive && !options.path) { + throw new InvalidInputError( + "--path is required in non-interactive mode", + ); + } + return await eject({ + ...options, + isNonInteractive: command.isNonInteractive, + }); }); } diff --git a/packages/cli/src/cli/commands/project/link.ts b/packages/cli/src/cli/commands/project/link.ts index 67b8d72c..c3c17e00 100644 --- a/packages/cli/src/cli/commands/project/link.ts +++ b/packages/cli/src/cli/commands/project/link.ts @@ -1,16 +1,15 @@ import type { Option } from "@clack/prompts"; import { cancel, group, isCancel, log, select, text } from "@clack/prompts"; -import { Command } from "commander"; +import type { Command } from "commander"; import { CLIExitError } from "@/cli/errors.js"; -import type { CLIContext } from "@/cli/types.js"; +import type { RunCommandResult } from "@/cli/types.js"; import { + Base44Command, getDashboardUrl, onPromptCancel, - runCommand, runTask, theme, } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; import { ConfigExistsError, ConfigNotFoundError, @@ -245,8 +244,8 @@ async function link(options: LinkOptions): Promise { return { outroMessage: "Project linked" }; } -export function getLinkCommand(context: CLIContext): Command { - return new Command("link") +export function getLinkCommand(): Command { + return new Base44Command("link", { requireAppConfig: false }) .description( "Link a local project to a Base44 project (create new or link existing)", ) @@ -261,11 +260,13 @@ export function getLinkCommand(context: CLIContext): Command { "Project ID to link to an existing project (skips selection prompt)", ) .hook("preAction", validateNonInteractiveFlags) - .action(async (options: LinkOptions) => { - await runCommand( - () => link(options), - { requireAuth: true, requireAppConfig: false }, - context, - ); + .action(async (options: LinkOptions, command: Base44Command) => { + const skipPrompts = !!options.create || !!options.projectId; + if (!skipPrompts && command.isNonInteractive) { + throw new InvalidInputError( + "--create with --name, or --projectId, is required in non-interactive mode", + ); + } + return await link(options); }); } diff --git a/packages/cli/src/cli/commands/project/logs.ts b/packages/cli/src/cli/commands/project/logs.ts index 94a18a61..52e190c4 100644 --- a/packages/cli/src/cli/commands/project/logs.ts +++ b/packages/cli/src/cli/commands/project/logs.ts @@ -1,7 +1,7 @@ -import { Command, Option } from "commander"; -import type { CLIContext } from "@/cli/types.js"; -import { runCommand } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import type { Command } from "commander"; +import { Option } from "commander"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; import { ApiError, InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/index.js"; import type { @@ -205,8 +205,8 @@ async function logsAction(options: LogsOptions): Promise { return { outroMessage: "Fetched logs", stdout: logsOutput }; } -export function getLogsCommand(context: CLIContext): Command { - return new Command("logs") +export function getLogsCommand(): Command { + return new Base44Command("logs") .description("Fetch function logs for this app") .option( "--function ", @@ -231,11 +231,5 @@ export function getLogsCommand(context: CLIContext): Command { .addOption( new Option("--order ", "Sort order").choices(["asc", "desc"]), ) - .action(async (options: LogsOptions) => { - await runCommand( - () => logsAction(options), - { requireAuth: true }, - context, - ); - }); + .action(logsAction); } diff --git a/packages/cli/src/cli/commands/secrets/delete.ts b/packages/cli/src/cli/commands/secrets/delete.ts index 5aa23a32..d68ff7e2 100644 --- a/packages/cli/src/cli/commands/secrets/delete.ts +++ b/packages/cli/src/cli/commands/secrets/delete.ts @@ -1,8 +1,7 @@ -import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; +import type { Command } from "commander"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command, runTask } from "@/cli/utils/index.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 { await runTask( @@ -21,15 +20,9 @@ async function deleteSecretAction(key: string): Promise { }; } -export function getSecretsDeleteCommand(context: CLIContext): Command { - return new Command("delete") +export function getSecretsDeleteCommand(): Command { + return new Base44Command("delete") .description("Delete a secret") .argument("", "Secret name to delete") - .action(async (key: string) => { - await runCommand( - () => deleteSecretAction(key), - { requireAuth: true }, - context, - ); - }); + .action(deleteSecretAction); } diff --git a/packages/cli/src/cli/commands/secrets/index.ts b/packages/cli/src/cli/commands/secrets/index.ts index 46fb51ec..a146023d 100644 --- a/packages/cli/src/cli/commands/secrets/index.ts +++ b/packages/cli/src/cli/commands/secrets/index.ts @@ -1,13 +1,12 @@ 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 { +export function getSecretsCommand(): Command { return new Command("secrets") .description("Manage project secrets (environment variables)") - .addCommand(getSecretsListCommand(context)) - .addCommand(getSecretsSetCommand(context)) - .addCommand(getSecretsDeleteCommand(context)); + .addCommand(getSecretsListCommand()) + .addCommand(getSecretsSetCommand()) + .addCommand(getSecretsDeleteCommand()); } diff --git a/packages/cli/src/cli/commands/secrets/list.ts b/packages/cli/src/cli/commands/secrets/list.ts index b5a7d975..d44630c5 100644 --- a/packages/cli/src/cli/commands/secrets/list.ts +++ b/packages/cli/src/cli/commands/secrets/list.ts @@ -1,9 +1,8 @@ import { log } from "@clack/prompts"; -import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; +import type { Command } from "commander"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command, runTask } from "@/cli/utils/index.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 { const secrets = await runTask( @@ -32,10 +31,8 @@ async function listSecretsAction(): Promise { }; } -export function getSecretsListCommand(context: CLIContext): Command { - return new Command("list") +export function getSecretsListCommand(): Command { + return new Base44Command("list") .description("List secret names") - .action(async () => { - await runCommand(listSecretsAction, { requireAuth: true }, context); - }); + .action(listSecretsAction); } diff --git a/packages/cli/src/cli/commands/secrets/set.ts b/packages/cli/src/cli/commands/secrets/set.ts index 04c44fc0..ec773b70 100644 --- a/packages/cli/src/cli/commands/secrets/set.ts +++ b/packages/cli/src/cli/commands/secrets/set.ts @@ -1,12 +1,11 @@ import { resolve } from "node:path"; import { log } from "@clack/prompts"; -import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; +import type { Command } from "commander"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command, runTask } from "@/cli/utils/index.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 { const secrets: Record = {}; @@ -90,16 +89,10 @@ async function setSecretsAction( }; } -export function getSecretsSetCommand(context: CLIContext): Command { - return new Command("set") +export function getSecretsSetCommand(): Command { + return new Base44Command("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 to .env file") - .action(async (entries: string[], options: { envFile?: string }) => { - await runCommand( - () => setSecretsAction(entries, options), - { requireAuth: true }, - context, - ); - }); + .action(setSecretsAction); } diff --git a/packages/cli/src/cli/commands/site/deploy.ts b/packages/cli/src/cli/commands/site/deploy.ts index 4d717986..86ace905 100644 --- a/packages/cli/src/cli/commands/site/deploy.ts +++ b/packages/cli/src/cli/commands/site/deploy.ts @@ -1,10 +1,9 @@ import { resolve } from "node:path"; import { confirm, isCancel } from "@clack/prompts"; -import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; -import { runCommand, runTask } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; -import { ConfigNotFoundError } from "@/core/errors.js"; +import type { Command } from "commander"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command, runTask } from "@/cli/utils/index.js"; +import { ConfigNotFoundError, InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/project/index.js"; import { deploySite } from "@/core/site/index.js"; @@ -53,19 +52,19 @@ async function deployAction(options: DeployOptions): Promise { return { outroMessage: `Visit your site at: ${result.appUrl}` }; } -export function getSiteDeployCommand(context: CLIContext): Command { - return new Command("deploy") +export function getSiteDeployCommand(): Command { + return new Base44Command("deploy") .description("Deploy built site files to Base44 hosting") .option("-y, --yes", "Skip confirmation prompt") - .action(async (options: DeployOptions) => { - await runCommand( - () => - deployAction({ - ...options, - isNonInteractive: context.isNonInteractive, - }), - { requireAuth: true }, - context, - ); + .action(async (options: DeployOptions, command: Base44Command) => { + if (command.isNonInteractive && !options.yes) { + throw new InvalidInputError( + "--yes is required in non-interactive mode", + ); + } + return await deployAction({ + ...options, + isNonInteractive: command.isNonInteractive, + }); }); } diff --git a/packages/cli/src/cli/commands/site/index.ts b/packages/cli/src/cli/commands/site/index.ts index 775e816c..2850e360 100644 --- a/packages/cli/src/cli/commands/site/index.ts +++ b/packages/cli/src/cli/commands/site/index.ts @@ -1,11 +1,10 @@ import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; import { getSiteDeployCommand } from "./deploy.js"; import { getSiteOpenCommand } from "./open.js"; -export function getSiteCommand(context: CLIContext): Command { +export function getSiteCommand(): Command { return new Command("site") .description("Manage app site (frontend app)") - .addCommand(getSiteDeployCommand(context)) - .addCommand(getSiteOpenCommand(context)); + .addCommand(getSiteDeployCommand()) + .addCommand(getSiteOpenCommand()); } diff --git a/packages/cli/src/cli/commands/site/open.ts b/packages/cli/src/cli/commands/site/open.ts index 40d8d9e5..d1323229 100644 --- a/packages/cli/src/cli/commands/site/open.ts +++ b/packages/cli/src/cli/commands/site/open.ts @@ -1,8 +1,7 @@ -import { Command } from "commander"; +import type { Command } from "commander"; import open from "open"; -import type { CLIContext } from "@/cli/types.js"; -import { runCommand } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; import { getSiteUrl } from "@/core/site/index.js"; async function openAction( @@ -17,14 +16,10 @@ async function openAction( return { outroMessage: `Site opened at ${siteUrl}` }; } -export function getSiteOpenCommand(context: CLIContext): Command { - return new Command("open") +export function getSiteOpenCommand(): Command { + return new Base44Command("open") .description("Open the published site in your browser") - .action(async () => { - await runCommand( - () => openAction(context.isNonInteractive), - { requireAuth: true }, - context, - ); + .action(async (_options: unknown, command: Base44Command) => { + return await openAction(command.isNonInteractive); }); } diff --git a/packages/cli/src/cli/commands/types/generate.ts b/packages/cli/src/cli/commands/types/generate.ts index 862c4d1a..fe3a80a3 100644 --- a/packages/cli/src/cli/commands/types/generate.ts +++ b/packages/cli/src/cli/commands/types/generate.ts @@ -1,7 +1,6 @@ -import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; -import { runCommand, runTask } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import type { Command } from "commander"; +import type { RunCommandResult } from "@/cli/types.js"; +import { Base44Command, runTask } from "@/cli/utils/index.js"; import { readProjectConfig } from "@/core/index.js"; import { generateTypesFile, updateProjectConfig } from "@/core/types/index.js"; @@ -24,16 +23,10 @@ async function generateTypesAction(): Promise { }; } -export function getTypesGenerateCommand(context: CLIContext): Command { - return new Command("generate") +export function getTypesGenerateCommand(): Command { + return new Base44Command("generate", { requireAuth: false }) .description( "Generate TypeScript declaration file (types.d.ts) from project resources", ) - .action(async () => { - await runCommand( - () => generateTypesAction(), - { requireAuth: false }, - context, - ); - }); + .action(generateTypesAction); } diff --git a/packages/cli/src/cli/commands/types/index.ts b/packages/cli/src/cli/commands/types/index.ts index 91e86701..da17415b 100644 --- a/packages/cli/src/cli/commands/types/index.ts +++ b/packages/cli/src/cli/commands/types/index.ts @@ -1,9 +1,8 @@ import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; import { getTypesGenerateCommand } from "./generate.js"; -export function getTypesCommand(context: CLIContext): Command { +export function getTypesCommand(): Command { return new Command("types") .description("Manage TypeScript type generation") - .addCommand(getTypesGenerateCommand(context)); + .addCommand(getTypesGenerateCommand()); } diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index a6d4ec02..77901950 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -14,6 +14,7 @@ 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 { Base44Command } from "@/cli/utils/index.js"; import packageJson from "../../package.json"; import { getDevCommand } from "./commands/dev.js"; import { getEjectCommand } from "./commands/project/eject.js"; @@ -33,44 +34,51 @@ export function createProgram(context: CLIContext): Command { sortSubcommands: true, }); + // Inject CLIContext into all Base44Command instances before action runs + program.hook("preAction", (_, actionCommand) => { + if (actionCommand instanceof Base44Command) { + actionCommand.setContext(context); + } + }); + // Register authentication commands - program.addCommand(getLoginCommand(context)); - program.addCommand(getWhoamiCommand(context)); - program.addCommand(getLogoutCommand(context)); + program.addCommand(getLoginCommand()); + program.addCommand(getWhoamiCommand()); + program.addCommand(getLogoutCommand()); // Register project commands - program.addCommand(getCreateCommand(context)); - program.addCommand(getDashboardCommand(context)); - program.addCommand(getDeployCommand(context)); - program.addCommand(getLinkCommand(context)); - program.addCommand(getEjectCommand(context)); + program.addCommand(getCreateCommand()); + program.addCommand(getDashboardCommand()); + program.addCommand(getDeployCommand()); + program.addCommand(getLinkCommand()); + program.addCommand(getEjectCommand()); // Register entities commands - program.addCommand(getEntitiesPushCommand(context)); + program.addCommand(getEntitiesPushCommand()); // Register agents commands - program.addCommand(getAgentsCommand(context)); + program.addCommand(getAgentsCommand()); // Register connectors commands - program.addCommand(getConnectorsCommand(context)); + program.addCommand(getConnectorsCommand()); // Register functions commands - program.addCommand(getFunctionsCommand(context)); + program.addCommand(getFunctionsCommand()); // Register secrets commands - program.addCommand(getSecretsCommand(context)); + program.addCommand(getSecretsCommand()); // Register site commands - program.addCommand(getSiteCommand(context)); + program.addCommand(getSiteCommand()); // Register types command - program.addCommand(getTypesCommand(context)); + program.addCommand(getTypesCommand()); // Register development commands - program.addCommand(getDevCommand(context), { hidden: true }); + program.addCommand(getDevCommand(), { hidden: true }); // Register logs command - program.addCommand(getLogsCommand(context), { hidden: true }); + program.addCommand(getLogsCommand(), { hidden: true }); return program; } diff --git a/packages/cli/src/cli/types.ts b/packages/cli/src/cli/types.ts index b72d30f6..400958fd 100644 --- a/packages/cli/src/cli/types.ts +++ b/packages/cli/src/cli/types.ts @@ -7,3 +7,12 @@ export interface CLIContext { isNonInteractive: boolean; distribution: Distribution; } + +export interface RunCommandResult { + outroMessage?: string; + /** + * Raw text to write to stdout after the command UI (intro/outro) finishes. + * Useful for commands that produce machine-readable or pipeable output. + */ + stdout?: string; +} diff --git a/packages/cli/src/cli/utils/banner.ts b/packages/cli/src/cli/utils/banner.ts index d2b3ddc4..80ba8a60 100644 --- a/packages/cli/src/cli/utils/banner.ts +++ b/packages/cli/src/cli/utils/banner.ts @@ -1,5 +1,4 @@ import { printAnimatedLines } from "@/cli/utils/animate.js"; -import { theme } from "@/cli/utils/theme.js"; const BANNER_LINES = [ "██████╗ █████╗ ███████╗███████╗ ██╗ ██╗██╗ ██╗", @@ -14,10 +13,6 @@ const BANNER_LINES = [ * Print the Base44 banner with smooth animation if supported, * or fall back to static banner in non-interactive environments. */ -export async function printBanner(isNonInteractive: boolean): Promise { - if (isNonInteractive) { - console.log(theme.colors.base44Orange(BANNER_LINES.join("\n"))); - } else { - await printAnimatedLines(BANNER_LINES); - } +export async function printBanner(): Promise { + await printAnimatedLines(BANNER_LINES); } diff --git a/packages/cli/src/cli/utils/command/Base44Command.ts b/packages/cli/src/cli/utils/command/Base44Command.ts new file mode 100644 index 00000000..44b780d9 --- /dev/null +++ b/packages/cli/src/cli/utils/command/Base44Command.ts @@ -0,0 +1,163 @@ +import { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { ensureAppConfig, ensureAuth } from "@/cli/utils/command/middleware.js"; +import { + showCommandEnd, + showCommandStart, + showPlainError, + showThemedError, +} from "@/cli/utils/command/render.js"; +import { + formatPlainUpgradeMessage, + startUpgradeCheck, +} from "@/cli/utils/upgradeNotification.js"; + +interface Base44CommandOptions { + /** + * Require user authentication before running this command. + * If the user is not logged in, they will be prompted to login. + * @default true + */ + requireAuth?: boolean; + /** + * Initialize app config before running this command. + * Reads .app.jsonc and caches the appId for sync access via getAppConfig(). + * @default true + */ + requireAppConfig?: boolean; + /** + * Use the full ASCII art banner instead of the simple intro tag. + * Useful for commands like `create` that want more visual impact. + * @default false + */ + fullBanner?: boolean; +} + +/** + * A Command subclass that automatically wraps the action with the Base44 + * lifecycle: intro/banner, auth check, app config, outro, and error handling. + * + * When `isNonInteractive` is true (CI / piped output), all clack UI + * (intro, outro, themed errors) is skipped. Errors go to stderr as plain text. + * Action functions that need this value can access it via `command.isNonInteractive` + * (Commander passes the command instance as the last argument to action handlers). + * + * @param name - The command name (e.g. "deploy", "login") + * @param options - Optional configuration to override defaults + * + * @example + * // Standard command (auth required, loads app config) + * new Base44Command("deploy") + * + * @example + * // Skip auth and app config (e.g. login command) + * new Base44Command("login", { requireAuth: false, requireAppConfig: false }) + * + * @example + * // Full usage in a command file + * export function getMyCommand(): Command { + * return new Base44Command("my-command") + * .description("Does something") + * .option("-f, --flag", "Some flag") + * .action(async (options) => { + * // ... business logic ... + * return { outroMessage: "Done!" }; + * }); + * } + */ +export class Base44Command extends Command { + private _context?: CLIContext; + private _commandOptions: Required; + + constructor(name: string, options?: Base44CommandOptions) { + super(name); + this._commandOptions = { + requireAuth: options?.requireAuth ?? true, + requireAppConfig: options?.requireAppConfig ?? true, + fullBanner: options?.fullBanner ?? false, + }; + } + + /** + * Inject the CLI context. Called by the program-level `preAction` hook + * in `createProgram()` before any command action executes. + */ + setContext(context: CLIContext): void { + this._context = context; + } + + /** + * Whether the CLI is running in non-interactive mode (CI, piped output). + * Available for action functions that need to adjust behavior + * (e.g. skip browser opens, skip confirmation prompts). + * @public + */ + get isNonInteractive(): boolean { + return this._context?.isNonInteractive ?? false; + } + + private get context(): CLIContext { + if (!this._context) { + throw new Error( + "Base44Command context not set. Ensure the command is registered via createProgram().", + ); + } + return this._context; + } + + /** @public - called by Commander internally via command dispatch */ + override action( + // biome-ignore lint/suspicious/noExplicitAny: must match Commander.js action() signature + fn: (...args: any[]) => void | Promise, + ): this { + // biome-ignore lint/suspicious/noExplicitAny: must match Commander.js action() signature + return super.action(async (...args: any[]) => { + const quiet = this.context.isNonInteractive; + + if (!quiet) { + await showCommandStart(this._commandOptions.fullBanner); + } + + const upgradeCheckPromise = startUpgradeCheck(); + + try { + if (this._commandOptions.requireAuth) { + await ensureAuth(this.context.errorReporter); + } + if (this._commandOptions.requireAppConfig) { + await ensureAppConfig(this.context.errorReporter); + } + + const result = ((await fn(...args)) ?? {}) as RunCommandResult; + + if (!quiet) { + await showCommandEnd( + result, + upgradeCheckPromise, + this.context.distribution, + ); + } else { + if (result.outroMessage) { + process.stdout.write(`${result.outroMessage}\n`); + } + if (result.stdout) { + process.stdout.write(result.stdout); + } + const upgradeInfo = await upgradeCheckPromise; + if (upgradeInfo) { + process.stderr.write( + `${formatPlainUpgradeMessage(upgradeInfo, this.context.distribution)}\n`, + ); + } + } + } catch (error) { + if (quiet) { + showPlainError(error); + } else { + showThemedError(error, this.context); + } + throw error; + } + }); + } +} diff --git a/packages/cli/src/cli/utils/command/index.ts b/packages/cli/src/cli/utils/command/index.ts new file mode 100644 index 00000000..70788e23 --- /dev/null +++ b/packages/cli/src/cli/utils/command/index.ts @@ -0,0 +1 @@ +export * from "./Base44Command.js"; diff --git a/packages/cli/src/cli/utils/command/middleware.ts b/packages/cli/src/cli/utils/command/middleware.ts new file mode 100644 index 00000000..c1db3453 --- /dev/null +++ b/packages/cli/src/cli/utils/command/middleware.ts @@ -0,0 +1,37 @@ +import { log } from "@clack/prompts"; +import { login } from "@/cli/commands/auth/login-flow.js"; +import type { ErrorReporter } from "@/cli/telemetry/error-reporter.js"; +import { isLoggedIn, readAuth } from "@/core/auth/index.js"; +import { initAppConfig } from "@/core/project/index.js"; + +/** + * Check authentication status and trigger login flow if needed. + * Sets user context on the error reporter after successful auth. + */ +export async function ensureAuth(errorReporter: ErrorReporter): Promise { + const loggedIn = await isLoggedIn(); + + if (!loggedIn) { + log.info("You need to login first to continue."); + await login(); + } + + try { + const userInfo = await readAuth(); + errorReporter.setContext({ + user: { email: userInfo.email, name: userInfo.name }, + }); + } catch { + // User info is optional context for error reporting + } +} + +/** + * Load app config (.app.jsonc) and set appId on the error reporter. + */ +export async function ensureAppConfig( + errorReporter: ErrorReporter, +): Promise { + const appConfig = await initAppConfig(); + errorReporter.setContext({ appId: appConfig.id }); +} diff --git a/packages/cli/src/cli/utils/command/render.ts b/packages/cli/src/cli/utils/command/render.ts new file mode 100644 index 00000000..73541482 --- /dev/null +++ b/packages/cli/src/cli/utils/command/render.ts @@ -0,0 +1,83 @@ +import { intro, log, outro } from "@clack/prompts"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { printBanner } from "@/cli/utils/banner.js"; +import { theme } from "@/cli/utils/theme.js"; +import { printUpgradeNotification } from "@/cli/utils/upgradeNotification.js"; +import type { UpgradeInfo } from "@/cli/utils/version-check.js"; +import { isCLIError } from "@/core/errors.js"; + +/** + * Show the command start UI: intro banner or simple tag. + */ +export async function showCommandStart(fullBanner: boolean): Promise { + if (fullBanner) { + await printBanner(); + intro(""); + } else { + intro(theme.colors.base44OrangeBackground(" Base 44 ")); + } +} + +/** + * Show the command end UI: upgrade notification, outro message, and stdout. + */ +export async function showCommandEnd( + result: RunCommandResult, + upgradeCheckPromise: Promise, + distribution: CLIContext["distribution"], +): Promise { + await printUpgradeNotification(upgradeCheckPromise, distribution); + outro(result.outroMessage || ""); + + if (result.stdout) { + process.stdout.write(result.stdout); + } +} + +/** + * Display an error with clack-themed formatting (interactive mode). + */ +export function showThemedError(error: unknown, context: CLIContext): void { + const errorMessage = error instanceof Error ? error.message : String(error); + log.error(errorMessage); + + if (isCLIError(error)) { + if (error.details.length > 0) { + log.info(theme.format.details(error.details)); + } + + const hints = theme.format.agentHints(error.hints); + if (hints) { + log.error(hints); + } + } + + if (process.env.DEBUG === "1" && error instanceof Error && error.stack) { + log.error(theme.styles.dim(error.stack)); + } + + const errorContext = context.errorReporter.getErrorContext(); + outro(theme.format.errorContext(errorContext)); +} + +/** + * Display an error as plain text to stderr (non-interactive / CI mode). + */ +export function showPlainError(error: unknown): void { + const errorMessage = error instanceof Error ? error.message : String(error); + process.stderr.write(`Error: ${errorMessage}\n`); + + if (isCLIError(error)) { + for (const detail of error.details) { + process.stderr.write(` ${detail}\n`); + } + for (const hint of error.hints) { + const cmd = hint.command ? ` (${hint.command})` : ""; + process.stderr.write(` Hint: ${hint.message}${cmd}\n`); + } + } + + if (process.env.DEBUG === "1" && error instanceof Error && error.stack) { + process.stderr.write(`${error.stack}\n`); + } +} diff --git a/packages/cli/src/cli/utils/index.ts b/packages/cli/src/cli/utils/index.ts index 62e46eb0..7be367a8 100644 --- a/packages/cli/src/cli/utils/index.ts +++ b/packages/cli/src/cli/utils/index.ts @@ -1,6 +1,6 @@ export * from "./banner.js"; +export * from "./command/index.js"; export * from "./prompts.js"; -export * from "./runCommand.js"; export * from "./runTask.js"; export * from "./theme.js"; export * from "./urls.js"; diff --git a/packages/cli/src/cli/utils/runCommand.ts b/packages/cli/src/cli/utils/runCommand.ts deleted file mode 100644 index 99f4dc1f..00000000 --- a/packages/cli/src/cli/utils/runCommand.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { intro, log, outro } from "@clack/prompts"; -import { login } from "@/cli/commands/auth/login-flow.js"; -import type { CLIContext } from "@/cli/types.js"; -import { printBanner } from "@/cli/utils/banner.js"; -import { theme } from "@/cli/utils/theme.js"; -import { - printUpgradeNotification, - startUpgradeCheck, -} from "@/cli/utils/upgradeNotification.js"; -import { isLoggedIn, readAuth } from "@/core/auth/index.js"; -import { isCLIError } from "@/core/errors.js"; -import { initAppConfig } from "@/core/project/index.js"; - -interface RunCommandOptions { - /** - * Use the full ASCII art banner instead of the simple intro tag. - * Useful for commands like `create` that want more visual impact. - * @default false - */ - fullBanner?: boolean; - /** - * Require user authentication before running this command. - * If the user is not logged in, they will see an error message. - * @default false - */ - requireAuth?: boolean; - /** - * Initialize app config before running this command. - * Reads .app.jsonc and caches the appId for sync access via getAppConfig(). - * @default true - */ - requireAppConfig?: boolean; -} - -export interface RunCommandResult { - outroMessage?: string; - /** - * Raw text to write to stdout after the command UI (intro/outro) finishes. - * Useful for commands that produce machine-readable or pipeable output. - */ - stdout?: string; -} - -/** - * Wraps a command function with the Base44 intro/outro and error handling. - * All CLI commands should use this utility to ensure consistent branding. - * - * **Important**: Commands should NOT call `intro()` or `outro()` directly. - * This function handles both. Commands can return an optional `outroMessage` - * which will be displayed at the end. - * - * @param commandFn - The async function to execute. Returns `RunCommandResult` with optional `outroMessage`. - * @param options - Optional configuration for the command wrapper - * @param context - CLI context with dependencies (errorReporter, etc.) - * - * @example - * export function getMyCommand(context: CLIContext): Command { - * return new Command("my-command") - * .action(async () => { - * await runCommand( - * async () => { - * // ... do work ... - * return { outroMessage: "Done!" }; - * }, - * { requireAuth: true }, - * context - * ); - * }); - * } - */ -export async function runCommand( - commandFn: () => Promise, - options: RunCommandOptions | undefined, - context: CLIContext, -): Promise { - if (options?.fullBanner) { - await printBanner(context.isNonInteractive); - intro(""); - } else { - intro(theme.colors.base44OrangeBackground(" Base 44 ")); - } - const upgradeCheckPromise = startUpgradeCheck(); - - try { - // Check authentication if required - if (options?.requireAuth) { - const loggedIn = await isLoggedIn(); - - if (!loggedIn) { - log.info("You need to login first to continue."); - await login(); - } - - try { - const userInfo = await readAuth(); - context.errorReporter.setContext({ - user: { email: userInfo.email, name: userInfo.name }, - }); - } catch { - // User info is optional context for error reporting - } - } - - // Initialize app config unless explicitly disabled - if (options?.requireAppConfig !== false) { - const appConfig = await initAppConfig(); - context.errorReporter.setContext({ appId: appConfig.id }); - } - - const result = await commandFn(); - await printUpgradeNotification(upgradeCheckPromise, context.distribution); - - outro(result.outroMessage || ""); - - if (result.stdout) { - process.stdout.write(result.stdout); - } - } catch (error) { - displayError(error); - - const errorContext = context.errorReporter.getErrorContext(); - outro(theme.format.errorContext(errorContext)); - - // Re-throw for runCLI to handle (error reporting, exit code) - throw error; - } -} - -function displayError(error: unknown): void { - const errorMessage = error instanceof Error ? error.message : String(error); - log.error(errorMessage); - - if (isCLIError(error)) { - if (error.details.length > 0) { - log.info(theme.format.details(error.details)); - } - - const hints = theme.format.agentHints(error.hints); - if (hints) { - log.error(hints); - } - } - - if (process.env.DEBUG === "1" && error instanceof Error && error.stack) { - log.error(theme.styles.dim(error.stack)); - } -} diff --git a/packages/cli/src/cli/utils/upgradeNotification.ts b/packages/cli/src/cli/utils/upgradeNotification.ts index 912a31ab..7e8fd468 100644 --- a/packages/cli/src/cli/utils/upgradeNotification.ts +++ b/packages/cli/src/cli/utils/upgradeNotification.ts @@ -48,6 +48,17 @@ function formatUpgradeMessage( ].join("\n"); } +/** + * Format upgrade info as a plain-text one-liner for non-interactive / CI output. + */ +export function formatPlainUpgradeMessage( + info: UpgradeInfo, + distribution: Distribution, +): string { + const instruction = getUpgradeInstruction(detectInstallMethod(distribution)); + return `Update available: ${info.currentVersion} → ${info.latestVersion}. ${instruction}`; +} + export async function printUpgradeNotification( upgradeCheckPromise: Promise, distribution: Distribution, diff --git a/packages/cli/tests/cli/create.spec.ts b/packages/cli/tests/cli/create.spec.ts index c254cd86..02f33bac 100644 --- a/packages/cli/tests/cli/create.spec.ts +++ b/packages/cli/tests/cli/create.spec.ts @@ -7,6 +7,16 @@ describe("create command", () => { // ─── NON-INTERACTIVE MODE ───────────────────────────────────── + it("fails when name and path are missing in non-interactive mode", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + t.api.mockCreateApp({ id: "app-123", name: "test" }); + + const result = await t.run("create"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("required in non-interactive mode"); + }); + it("fails when --path is provided without name argument", async () => { await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); diff --git a/packages/cli/tests/cli/deploy.spec.ts b/packages/cli/tests/cli/deploy.spec.ts index c2f5cb92..bd59390d 100644 --- a/packages/cli/tests/cli/deploy.spec.ts +++ b/packages/cli/tests/cli/deploy.spec.ts @@ -4,6 +4,18 @@ import { fixture, setupCLITests } from "./testkit/index.js"; describe("deploy command (unified)", () => { const t = setupCLITests(); + it("fails when --yes is not provided in non-interactive mode", async () => { + await t.givenLoggedInWithProject(fixture("with-entities")); + t.api.mockEntitiesPush({ created: ["Task"], updated: [], deleted: [] }); + + const result = await t.run("deploy"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "--yes is required in non-interactive mode", + ); + }); + it("reports no resources when project is empty", async () => { await t.givenLoggedInWithProject(fixture("basic")); diff --git a/packages/cli/tests/cli/eject.spec.ts b/packages/cli/tests/cli/eject.spec.ts index 75dfa57b..763cdc7c 100644 --- a/packages/cli/tests/cli/eject.spec.ts +++ b/packages/cli/tests/cli/eject.spec.ts @@ -31,6 +31,24 @@ async function createTarFromFixture(fixturePath: string): Promise { describe("eject command", () => { const t = setupCLITests(); + it("fails when --path is missing in non-interactive mode", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + t.api.mockListProjects([ + { + id: "app-1", + name: "Test", + is_managed_source_code: true, + }, + ]); + + const result = await t.run("eject", "--project-id", "app-1"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "--path is required in non-interactive mode", + ); + }); + it("fails when project ID not found", async () => { await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); t.api.mockListProjects([ diff --git a/packages/cli/tests/cli/link.spec.ts b/packages/cli/tests/cli/link.spec.ts index 80cc1d18..e6e95a44 100644 --- a/packages/cli/tests/cli/link.spec.ts +++ b/packages/cli/tests/cli/link.spec.ts @@ -4,6 +4,15 @@ import { fixture, setupCLITests } from "./testkit/index.js"; describe("link command", () => { const t = setupCLITests(); + it("fails when neither --create nor --projectId in non-interactive mode", async () => { + await t.givenLoggedInWithProject(fixture("no-app-config")); + + const result = await t.run("link"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("required in non-interactive mode"); + }); + it("fails when not in a project directory", async () => { await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); diff --git a/packages/cli/tests/cli/site_deploy.spec.ts b/packages/cli/tests/cli/site_deploy.spec.ts index 4de0417f..578f2e95 100644 --- a/packages/cli/tests/cli/site_deploy.spec.ts +++ b/packages/cli/tests/cli/site_deploy.spec.ts @@ -4,6 +4,17 @@ import { fixture, setupCLITests } from "./testkit/index.js"; describe("site deploy command", () => { const t = setupCLITests(); + it("fails when --yes is not provided in non-interactive mode", async () => { + await t.givenLoggedInWithProject(fixture("with-site")); + + const result = await t.run("site", "deploy"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "--yes is required in non-interactive mode", + ); + }); + it("fails when no site configuration found", async () => { await t.givenLoggedInWithProject(fixture("basic")); diff --git a/packages/cli/tests/cli/version-check.spec.ts b/packages/cli/tests/cli/version-check.spec.ts index e9f826f5..63efab9f 100644 --- a/packages/cli/tests/cli/version-check.spec.ts +++ b/packages/cli/tests/cli/version-check.spec.ts @@ -11,7 +11,7 @@ describe("upgrade notification", () => { const result = await t.run("whoami"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Update available!"); + t.expectResult(result).toContain("Update available"); t.expectResult(result).toContain("1.0.0"); }); @@ -22,7 +22,7 @@ describe("upgrade notification", () => { const result = await t.run("whoami"); t.expectResult(result).toSucceed(); - t.expectResult(result).toNotContain("Update available!"); + t.expectResult(result).toNotContain("Update available"); }); it("does not display notification when check is not overridden", async () => {