diff --git a/packages/cli/src/cli/commands/agents/push.ts b/packages/cli/src/cli/commands/agents/push.ts index c20ba83e4..31215e4bc 100644 --- a/packages/cli/src/cli/commands/agents/push.ts +++ b/packages/cli/src/cli/commands/agents/push.ts @@ -1,13 +1,17 @@ import type { Command } from "commander"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; -import { Base44Command } from "@/cli/utils/index.js"; +import { Base44Command, confirmPush } from "@/cli/utils/index.js"; import { readProjectConfig } from "@/core/index.js"; import { pushAgents } from "@/core/resources/agent/index.js"; -async function pushAgentsAction({ - log, - runTask, -}: CLIContext): Promise { +interface PushOptions { + yes?: boolean; +} + +async function pushAgentsAction( + { isNonInteractive, log, runTask }: CLIContext, + options: PushOptions, +): Promise { const { agents } = await readProjectConfig(); log.info( @@ -16,6 +20,17 @@ async function pushAgentsAction({ : `Found ${agents.length} agents to push`, ); + const proceed = await confirmPush({ + isNonInteractive, + yes: options.yes, + log, + warning: + "This will replace all remote agent configs with your local agents and delete any not present locally.", + }); + if (!proceed) { + return { outroMessage: "Push cancelled" }; + } + const result = await runTask( "Pushing agents to Base44", async () => { @@ -45,5 +60,6 @@ export function getAgentsPushCommand(): Command { .description( "Push local agents to Base44 (replaces all remote agent configs)", ) + .option("-y, --yes", "Skip confirmation prompt") .action(pushAgentsAction); } diff --git a/packages/cli/src/cli/commands/connectors/push.ts b/packages/cli/src/cli/commands/connectors/push.ts index fd01ade55..a7dbf353d 100644 --- a/packages/cli/src/cli/commands/connectors/push.ts +++ b/packages/cli/src/cli/commands/connectors/push.ts @@ -2,7 +2,7 @@ import { resolve } from "node:path"; import type { Logger } from "@base44-cli/logger"; import type { Command } from "commander"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; -import { Base44Command, theme } from "@/cli/utils/index.js"; +import { Base44Command, confirmPush, theme } from "@/cli/utils/index.js"; import { getConnectorsUrl } from "@/cli/utils/urls.js"; import { readProjectConfig } from "@/core/index.js"; import { getAppContext } from "@/core/project/index.js"; @@ -22,6 +22,7 @@ import { interface PushOptions { dir?: string; + yes?: boolean; } /** @@ -133,6 +134,17 @@ async function pushConnectorsAction( } } + const proceed = await confirmPush({ + isNonInteractive, + yes: options.yes, + log, + warning: + "This will overwrite your app's connectors with your local copy and remove any not present locally.", + }); + if (!proceed) { + return { outroMessage: "Push cancelled" }; + } + const { results } = await runTask( "Pushing connectors to Base44", async () => { @@ -180,5 +192,6 @@ export function getConnectorsPushCommand(): Command { "--dir ", "Directory to read connector files from (default: ./connectors when using --app-id)", ) + .option("-y, --yes", "Skip confirmation prompt") .action(pushConnectorsAction); } diff --git a/packages/cli/src/cli/commands/entities/push.ts b/packages/cli/src/cli/commands/entities/push.ts index 3c459e91e..017531a76 100644 --- a/packages/cli/src/cli/commands/entities/push.ts +++ b/packages/cli/src/cli/commands/entities/push.ts @@ -1,13 +1,17 @@ import { Command } from "commander"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; -import { Base44Command } from "@/cli/utils/index.js"; +import { Base44Command, confirmPush } from "@/cli/utils/index.js"; import { readProjectConfig } from "@/core/index.js"; import { pushEntities } from "@/core/resources/entity/index.js"; -async function pushEntitiesAction({ - log, - runTask, -}: CLIContext): Promise { +interface PushOptions { + yes?: boolean; +} + +async function pushEntitiesAction( + { isNonInteractive, log, runTask }: CLIContext, + options: PushOptions, +): Promise { const { entities } = await readProjectConfig(); if (entities.length === 0) { @@ -17,6 +21,17 @@ async function pushEntitiesAction({ const entityNames = entities.map((e) => e.name).join(", "); log.info(`Found ${entities.length} entities to push: ${entityNames}`); + const proceed = await confirmPush({ + isNonInteractive, + yes: options.yes, + log, + warning: + "This will overwrite your app's entities with your local copy and delete any not present locally.", + }); + if (!proceed) { + return { outroMessage: "Push cancelled" }; + } + const result = await runTask( "Pushing entities to Base44", async () => { @@ -48,6 +63,7 @@ export function getEntitiesPushCommand(): Command { .addCommand( new Base44Command("push") .description("Push local entities to Base44") + .option("-y, --yes", "Skip confirmation prompt") .action(pushEntitiesAction), ); } diff --git a/packages/cli/src/cli/utils/confirm-push.ts b/packages/cli/src/cli/utils/confirm-push.ts new file mode 100644 index 000000000..91c6cf82f --- /dev/null +++ b/packages/cli/src/cli/utils/confirm-push.ts @@ -0,0 +1,34 @@ +import type { Logger } from "@base44-cli/logger"; +import { confirm, isCancel } from "@clack/prompts"; +import { InvalidInputError } from "@/core/errors.js"; + +interface ConfirmPushOptions { + isNonInteractive: boolean; + yes: boolean | undefined; + log: Logger; + warning: string; +} + +/** + * Guard a destructive push: in non-interactive mode require --yes (throws + * otherwise, so --json/CI never hang on a prompt); interactively, warn and + * ask for confirmation. Returns false when the user declines. + */ +export async function confirmPush({ + isNonInteractive, + yes, + log, + warning, +}: ConfirmPushOptions): Promise { + if (isNonInteractive && !yes) { + throw new InvalidInputError("--yes is required in non-interactive mode"); + } + if (yes) { + return true; + } + log.warn(warning); + const proceed = await confirm({ + message: "Are you sure you want to continue?", + }); + return !isCancel(proceed) && proceed; +} diff --git a/packages/cli/src/cli/utils/index.ts b/packages/cli/src/cli/utils/index.ts index 3cfa787aa..61779b6fd 100644 --- a/packages/cli/src/cli/utils/index.ts +++ b/packages/cli/src/cli/utils/index.ts @@ -1,6 +1,7 @@ export * from "@base44-cli/logger"; export * from "./banner.js"; export * from "./command/index.js"; +export * from "./confirm-push.js"; export * from "./json.js"; export * from "./prompts.js"; export * from "./runTask.js"; diff --git a/packages/cli/tests/cli/agents_push.spec.ts b/packages/cli/tests/cli/agents_push.spec.ts index b59704578..1ccd7e96f 100644 --- a/packages/cli/tests/cli/agents_push.spec.ts +++ b/packages/cli/tests/cli/agents_push.spec.ts @@ -8,7 +8,7 @@ describe("agents push command", () => { await t.givenLoggedInWithProject(fixture("basic")); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); - const result = await t.run("agents", "push"); + const result = await t.run("agents", "push", "--yes"); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("No local agents found"); @@ -17,7 +17,7 @@ describe("agents push command", () => { it("fails when not in a project directory", async () => { await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); - const result = await t.run("agents", "push"); + const result = await t.run("agents", "push", "--yes"); t.expectResult(result).toFail(); t.expectResult(result).toContain("No Base44 app ID found"); @@ -31,7 +31,7 @@ describe("agents push command", () => { deleted: [], }); - const result = await t.run("agents", "push"); + const result = await t.run("agents", "push", "--yes"); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Found 3 agents to push"); @@ -45,7 +45,7 @@ describe("agents push command", () => { deleted: ["old_agent"], }); - const result = await t.run("agents", "push"); + const result = await t.run("agents", "push", "--yes"); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Agents pushed successfully"); @@ -57,7 +57,7 @@ describe("agents push command", () => { it("fails with helpful error when agent has empty name", async () => { await t.givenLoggedInWithProject(fixture("invalid-agent")); - const result = await t.run("agents", "push"); + const result = await t.run("agents", "push", "--yes"); t.expectResult(result).toFail(); }); @@ -66,8 +66,19 @@ describe("agents push command", () => { await t.givenLoggedInWithProject(fixture("with-agents")); t.api.mockAgentsPushError({ status: 401, body: { error: "Unauthorized" } }); + const result = await t.run("agents", "push", "--yes"); + + t.expectResult(result).toFail(); + }); + + it("fails when --yes is not provided in non-interactive mode", async () => { + await t.givenLoggedInWithProject(fixture("with-agents")); + const result = await t.run("agents", "push"); t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "--yes is required in non-interactive mode", + ); }); }); diff --git a/packages/cli/tests/cli/authorization.spec.ts b/packages/cli/tests/cli/authorization.spec.ts index 20874d60e..e15a1e6ec 100644 --- a/packages/cli/tests/cli/authorization.spec.ts +++ b/packages/cli/tests/cli/authorization.spec.ts @@ -44,7 +44,7 @@ describe("token refresh on 401", () => { deleted: [], }); - const result = await t.run("agents", "push"); + const result = await t.run("agents", "push", "--yes"); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Agents pushed successfully"); @@ -59,7 +59,7 @@ describe("token refresh on 401", () => { deleted: [], }); - const result = await t.run("entities", "push"); + const result = await t.run("entities", "push", "--yes"); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Entities pushed"); @@ -78,8 +78,11 @@ describe("token refresh on 401", () => { res.status(401).json({ error: "Unauthorized" }); }); - const result = await t.run("agents", "push"); + const result = await t.run("agents", "push", "--yes"); t.expectResult(result).toFail(); + // Assert the real auth-failure reason, not just a non-zero exit — otherwise + // an unrelated early failure (e.g. a missing --yes guard) would pass this. + t.expectResult(result).toContain("Unauthorized"); }); }); diff --git a/packages/cli/tests/cli/connectors_push.spec.ts b/packages/cli/tests/cli/connectors_push.spec.ts index 9490011b2..b508de9fc 100644 --- a/packages/cli/tests/cli/connectors_push.spec.ts +++ b/packages/cli/tests/cli/connectors_push.spec.ts @@ -9,7 +9,7 @@ describe("connectors push command", () => { t.api.mockConnectorsList({ integrations: [] }); t.api.mockStripeStatus({ stripe_mode: null }); - const result = await t.run("connectors", "push"); + const result = await t.run("connectors", "push", "--yes"); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("No local connectors found"); @@ -18,12 +18,23 @@ describe("connectors push command", () => { it("fails when not in a project directory", async () => { await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); - const result = await t.run("connectors", "push"); + const result = await t.run("connectors", "push", "--yes"); t.expectResult(result).toFail(); t.expectResult(result).toContain("No Base44 app ID found"); }); + it("fails when --yes is not provided in non-interactive mode", async () => { + await t.givenLoggedInWithProject(fixture("with-connectors")); + + const result = await t.run("connectors", "push"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "--yes is required in non-interactive mode", + ); + }); + it("finds and lists connectors in project", async () => { await t.givenLoggedInWithProject(fixture("with-connectors")); t.api.mockConnectorsList({ integrations: [] }); @@ -34,7 +45,7 @@ describe("connectors push command", () => { already_authorized: true, }); - const result = await t.run("connectors", "push"); + const result = await t.run("connectors", "push", "--yes"); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Found 3 connectors to push"); @@ -50,7 +61,7 @@ describe("connectors push command", () => { already_authorized: true, }); - const result = await t.run("connectors", "push"); + const result = await t.run("connectors", "push", "--yes"); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("googlecalendar"); @@ -68,7 +79,7 @@ describe("connectors push command", () => { t.api.mockStripeStatus({ stripe_mode: null }); t.api.mockConnectorRemove({ status: "removed", integration_type: "slack" }); - const result = await t.run("connectors", "push"); + const result = await t.run("connectors", "push", "--yes"); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("slack"); @@ -84,7 +95,7 @@ describe("connectors push command", () => { body: { error: "Server error" }, }); - const result = await t.run("connectors", "push"); + const result = await t.run("connectors", "push", "--yes"); // Errors are handled per-connector, command still succeeds t.expectResult(result).toSucceed(); @@ -101,7 +112,7 @@ describe("connectors push command", () => { already_authorized: false, }); - const result = await t.run("connectors", "push"); + const result = await t.run("connectors", "push", "--yes"); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("needs authorization"); @@ -121,7 +132,7 @@ describe("connectors push command", () => { other_user_email: "other@example.com", }); - const result = await t.run("connectors", "push"); + const result = await t.run("connectors", "push", "--yes"); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Already connected by another user"); @@ -142,7 +153,7 @@ describe("connectors push command", () => { claim_url: "https://connect.stripe.com/setup/claim/xxx", }); - const result = await t.run("connectors", "push"); + const result = await t.run("connectors", "push", "--yes"); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Found 2 connectors to push"); @@ -161,7 +172,7 @@ describe("connectors push command", () => { already_authorized: true, }); - const result = await t.run("connectors", "push"); + const result = await t.run("connectors", "push", "--yes"); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Synced: slack, stripe"); @@ -173,7 +184,7 @@ describe("connectors push command", () => { t.api.mockStripeStatus({ stripe_mode: "sandbox" }); t.api.mockStripeRemove({ success: true }); - const result = await t.run("connectors", "push"); + const result = await t.run("connectors", "push", "--yes"); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Removed:"); @@ -194,7 +205,7 @@ describe("connectors push command", () => { body: { error: "Stripe install failed" }, }); - const result = await t.run("connectors", "push"); + const result = await t.run("connectors", "push", "--yes"); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Failed: stripe"); diff --git a/packages/cli/tests/cli/entities_push.spec.ts b/packages/cli/tests/cli/entities_push.spec.ts index 7b59e4bad..7d2885f4e 100644 --- a/packages/cli/tests/cli/entities_push.spec.ts +++ b/packages/cli/tests/cli/entities_push.spec.ts @@ -7,7 +7,7 @@ describe("entities push command", () => { it("warns when no entities found in project", async () => { await t.givenLoggedInWithProject(fixture("basic")); - const result = await t.run("entities", "push"); + const result = await t.run("entities", "push", "--yes"); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("No entities found in project"); @@ -16,7 +16,7 @@ describe("entities push command", () => { it("fails when not in a project directory", async () => { await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); - const result = await t.run("entities", "push"); + const result = await t.run("entities", "push", "--yes"); t.expectResult(result).toFail(); t.expectResult(result).toContain("No Base44 app ID found"); @@ -30,7 +30,7 @@ describe("entities push command", () => { deleted: [], }); - const result = await t.run("entities", "push"); + const result = await t.run("entities", "push", "--yes"); t.expectResult(result).toContain("Found 2 entities to push"); t.expectResult(result).toContain("Customer"); @@ -45,7 +45,7 @@ describe("entities push command", () => { deleted: [], }); - const result = await t.run("entities", "push"); + const result = await t.run("entities", "push", "--yes"); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Entities pushed successfully"); @@ -56,7 +56,7 @@ describe("entities push command", () => { it("fails with helpful error when entity is missing required fields", async () => { await t.givenLoggedInWithProject(fixture("invalid-entity")); - const result = await t.run("entities", "push"); + const result = await t.run("entities", "push", "--yes"); t.expectResult(result).toFail(); t.expectResult(result).toContain("name"); @@ -65,7 +65,7 @@ describe("entities push command", () => { it("fails with helpful error when config has invalid JSON", async () => { await t.givenLoggedInWithProject(fixture("invalid-json")); - const result = await t.run("entities", "push"); + const result = await t.run("entities", "push", "--yes"); t.expectResult(result).toFail(); t.expectResult(result).toContain("config.jsonc"); @@ -78,8 +78,19 @@ describe("entities push command", () => { body: { error: "Internal server error" }, }); + const result = await t.run("entities", "push", "--yes"); + + t.expectResult(result).toFail(); + }); + + it("fails when --yes is not provided in non-interactive mode", async () => { + await t.givenLoggedInWithProject(fixture("with-entities")); + const result = await t.run("entities", "push"); t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "--yes is required in non-interactive mode", + ); }); }); diff --git a/packages/cli/tests/cli/env-token-auth.spec.ts b/packages/cli/tests/cli/env-token-auth.spec.ts index a5d4c759b..0b8f0c2db 100644 --- a/packages/cli/tests/cli/env-token-auth.spec.ts +++ b/packages/cli/tests/cli/env-token-auth.spec.ts @@ -69,7 +69,7 @@ describe("env credential seeding", () => { res.status(200).json({ created: ["customer"], updated: [], deleted: [] }); }); - const result = await t.run("entities", "push"); + const result = await t.run("entities", "push", "--yes"); t.expectResult(result).toSucceed(); expect(authHeader).toBe(`Bearer ${jwt}`); @@ -102,7 +102,7 @@ describe("env credential seeding", () => { res.status(200).json({ created: ["customer"], updated: [], deleted: [] }); }); - const result = await t.run("entities", "push"); + const result = await t.run("entities", "push", "--yes"); t.expectResult(result).toSucceed(); expect(apiKeyHeader).toBe(workspaceApiKey); @@ -121,7 +121,7 @@ describe("env credential seeding", () => { body: { error: "Unauthorized", detail: "Invalid API key" }, }); - const result = await t.run("entities", "push"); + const result = await t.run("entities", "push", "--yes"); t.expectResult(result).toFail(); t.expectResult(result).toContain("workspace API key"); @@ -225,7 +225,7 @@ describe("env credential seeding", () => { res.status(200).json({ created: ["customer"], updated: [], deleted: [] }); }); - const result = await t.run("entities", "push"); + const result = await t.run("entities", "push", "--yes"); t.expectResult(result).toSucceed(); expect(apiKeyHeader).toBeUndefined();