From 11f336811b10b7982377c04fb6c9789c2ad6e150 Mon Sep 17 00:00:00 2001
From: Hariom Balhara <1780212+hariombalhara@users.noreply.github.com>
Date: Tue, 10 Feb 2026 18:29:00 +0530
Subject: [PATCH] feat: add non-interactive mode to app-store CLI (#27831)
---
packages/app-store-cli/SAMPLE_COMMANDS.md | 112 +++++++++++
packages/app-store-cli/src/cli.tsx | 94 ++++++++-
.../src/components/AppCreateUpdateForm.tsx | 14 +-
packages/app-store-cli/src/constants.ts | 13 ++
.../src/validateCreateAppFlags.test.ts | 180 ++++++++++++++++++
.../src/validateCreateAppFlags.ts | 33 ++++
6 files changed, 432 insertions(+), 14 deletions(-)
create mode 100644 packages/app-store-cli/SAMPLE_COMMANDS.md
create mode 100644 packages/app-store-cli/src/validateCreateAppFlags.test.ts
create mode 100644 packages/app-store-cli/src/validateCreateAppFlags.ts
diff --git a/packages/app-store-cli/SAMPLE_COMMANDS.md b/packages/app-store-cli/SAMPLE_COMMANDS.md
new file mode 100644
index 0000000000..9268718b40
--- /dev/null
+++ b/packages/app-store-cli/SAMPLE_COMMANDS.md
@@ -0,0 +1,112 @@
+# Non-Interactive CLI Sample Commands
+
+The `app-store create` command supports a non-interactive mode when `--name`, `--description`, `--category`, and `--template` flags are all provided. This bypasses the interactive prompts and creates the app directly.
+
+## Available Categories
+
+`analytics`, `automation`, `calendar`, `conferencing`, `crm`, `messaging`, `payment`, `other`
+
+## Available Templates
+
+`basic`, `booking-pages-tag`, `event-type-app-card`, `event-type-location-video-static`, `general-app-settings`, `link-as-an-app`
+
+## Sample Commands
+
+### Link as an App (External Redirect)
+
+Apps that redirect users to an external URL (e.g., Amie, Vimcal, Pipedream).
+
+```bash
+yarn app-store create \
+ --name "My External App" \
+ --description "Redirects users to an external service" \
+ --template link-as-an-app \
+ --category other \
+ --publisher "Cal.com, Inc." \
+ --email "support@cal.com" \
+ --external-link-url "https://example.com/signup"
+```
+
+### Basic App
+
+A minimal installable app with no additional features.
+
+```bash
+yarn app-store create \
+ --name "My Basic App" \
+ --description "A simple installable app" \
+ --template basic \
+ --category other \
+ --publisher "Cal.com, Inc." \
+ --email "support@cal.com"
+```
+
+### Booking Pages Tag (Analytics)
+
+Analytics/tracking apps that inject scripts into booking pages (e.g., GA4, Fathom, Plausible).
+
+```bash
+yarn app-store create \
+ --name "My Analytics Tracker" \
+ --description "Tracks booking page analytics" \
+ --template booking-pages-tag \
+ --category analytics \
+ --publisher "Cal.com, Inc." \
+ --email "support@cal.com"
+```
+
+### Event Type App Card
+
+Apps that add a configurable card to event type settings (e.g., Giphy, QR Code).
+
+```bash
+yarn app-store create \
+ --name "My Event Type Card" \
+ --description "Adds a custom card to event type settings" \
+ --template event-type-app-card \
+ --category other \
+ --publisher "Cal.com, Inc." \
+ --email "support@cal.com"
+```
+
+### Event Type Location Video (Static)
+
+Video conferencing apps that add a static location option to event types (e.g., Facetime, Skype).
+
+```bash
+yarn app-store create \
+ --name "My Video App" \
+ --description "Adds a video conferencing location to event types" \
+ --template event-type-location-video-static \
+ --category conferencing \
+ --publisher "Cal.com, Inc." \
+ --email "support@cal.com"
+```
+
+### General App Settings
+
+Apps with global settings in the installed apps section (e.g., Weather in your Calendar).
+
+```bash
+yarn app-store create \
+ --name "My Settings App" \
+ --description "An app with global configuration settings" \
+ --template general-app-settings \
+ --category other \
+ --publisher "Cal.com, Inc." \
+ --email "support@cal.com"
+```
+
+## Flags Reference
+
+| Flag | Alias | Description | Required |
+|------|-------|-------------|----------|
+| `--name` | `-n` | App name | Yes |
+| `--description` | `-d` | App description | Yes |
+| `--category` | `-c` | App category | Yes |
+| `--template` | `-t` | Base template to use | Yes |
+| `--publisher` | `-p` | Publisher name | No (default: `"Your Name"`) |
+| `--email` | `-e` | Publisher email | No (default: `"email@example.com"`) |
+| `--external-link-url` | - | Redirect URL | Yes (for `link-as-an-app` template) |
+
+If `--name`, `--description`, `--category`, or `--template` are omitted, the CLI falls back to interactive mode.
diff --git a/packages/app-store-cli/src/cli.tsx b/packages/app-store-cli/src/cli.tsx
index 8395cf7d44..aac4769c2f 100644
--- a/packages/app-store-cli/src/cli.tsx
+++ b/packages/app-store-cli/src/cli.tsx
@@ -2,14 +2,24 @@
import { render } from "ink";
import meow from "meow";
import App from "./App";
+import { VALID_CATEGORY_VALUES } from "./constants";
+import { BaseAppFork, generateAppFiles, getSlugFromAppName } from "./core";
import type { SupportedCommands } from "./types";
+import Templates from "./utils/templates";
+import { validateCreateAppFlags } from "./validateCreateAppFlags";
const cli = meow(
`
Usage
$ 'app-store create' or 'app-store create-template' - Creates a new app or template
Options
- [--template -t] Template to use.
+ [--template -t] (required in non-interactive mode) Template to use: ${Templates.map((t) => t.value).join(", ")}.
+ [--name -n] (required) App name. Providing --name, --description, --category, and --template activates non-interactive mode.
+ [--description -d] (required) App description.
+ [--category -c] (required) App category: ${VALID_CATEGORY_VALUES.join(", ")}.
+ [--publisher -p] Publisher name (default: "Your Name").
+ [--email -e] Publisher email (default: "email@example.com").
+ [--external-link-url] (required for link-as-an-app template) External link URL.
$ 'app-store edit' or 'app-store edit-template' - Edit the App or Template identified by slug
@@ -31,6 +41,29 @@ const cli = meow(
type: "string",
alias: "t",
},
+ name: {
+ type: "string",
+ alias: "n",
+ },
+ description: {
+ type: "string",
+ alias: "d",
+ },
+ category: {
+ type: "string",
+ alias: "c",
+ },
+ publisher: {
+ type: "string",
+ alias: "p",
+ },
+ email: {
+ type: "string",
+ alias: "e",
+ },
+ externalLinkUrl: {
+ type: "string",
+ },
},
allowUnknownFlags: false,
}
@@ -68,4 +101,61 @@ if (
}
}
-render();
+const isCreateCommand = command === "create" || command === "create-template";
+const { name: appName, description, category, publisher, email, externalLinkUrl } = cli.flags;
+
+if (isCreateCommand && appName && description && category) {
+ const templateFlag = cli.flags.template || "";
+ const validTemplateValues = Templates.map((t) => t.value);
+
+ const validationError = validateCreateAppFlags({
+ template: templateFlag,
+ category,
+ externalLinkUrl,
+ validTemplateValues,
+ });
+
+ if (validationError) {
+ console.error(validationError);
+ process.exit(1);
+ }
+
+ const appSlug = getSlugFromAppName(appName);
+ const isTemplate = command === "create-template";
+
+ (async () => {
+ try {
+ await BaseAppFork.create({
+ category,
+ description,
+ name: appName,
+ slug: appSlug,
+ publisher: publisher || "Your Name",
+ email: email || "email@example.com",
+ template: templateFlag,
+ isTemplate,
+ externalLinkUrl,
+ });
+
+ await generateAppFiles();
+
+ console.log(`\nApp created successfully!`);
+ console.log(`Slug: ${appSlug}`);
+ console.log(`App URL: http://localhost:3000/apps/${appSlug}`);
+ console.log(`Name: ${appName}`);
+ console.log(`Description: ${description}`);
+ console.log(`Category: ${category}`);
+ if (externalLinkUrl) {
+ console.log(`External Link: ${externalLinkUrl}`);
+ }
+ console.log(
+ `\nNext Step: Enable the app from http://localhost:3000/settings/admin/apps as an admin user.`
+ );
+ } catch (error) {
+ console.error("Failed to create app:", error);
+ process.exit(1);
+ }
+ })();
+} else {
+ render();
+}
diff --git a/packages/app-store-cli/src/components/AppCreateUpdateForm.tsx b/packages/app-store-cli/src/components/AppCreateUpdateForm.tsx
index 5bf2912992..497b56a795 100644
--- a/packages/app-store-cli/src/components/AppCreateUpdateForm.tsx
+++ b/packages/app-store-cli/src/components/AppCreateUpdateForm.tsx
@@ -4,6 +4,7 @@ import { Box, Newline, Text, useApp } from "ink";
import SelectInput from "ink-select-input";
import TextInput from "ink-text-input";
import { useEffect, useState } from "react";
+import { APP_CATEGORIES } from "../constants";
import { BaseAppFork, generateAppFiles, getAppDirPath, getSlugFromAppName } from "../core";
import { getApp } from "../utils/getApp";
import Templates from "../utils/templates";
@@ -83,18 +84,7 @@ export const AppForm = ({
name: "category",
type: "select",
- // TODO: Refactor and reuse getAppCategories or type as Record to enforce consistency
- options: [
- // Manually sorted alphabetically
- { label: "Analytics", value: "analytics" },
- { label: "AI & Automation", value: "automation" },
- { label: "Calendar", value: "calendar" },
- { label: "Conferencing", value: "conferencing" },
- { label: "CRM", value: "crm" },
- { label: "Messaging", value: "messaging" },
- { label: "Payment", value: "payment" },
- { label: "Other", value: "other" },
- ],
+ options: [...APP_CATEGORIES],
defaultValue: "",
explainer: "This is how apps are categorized in App Store.",
},
diff --git a/packages/app-store-cli/src/constants.ts b/packages/app-store-cli/src/constants.ts
index c2b88aa910..85c0ff77d7 100644
--- a/packages/app-store-cli/src/constants.ts
+++ b/packages/app-store-cli/src/constants.ts
@@ -4,3 +4,16 @@ import path from "node:path";
export const APP_STORE_PATH = path.join(__dirname, "..", "..", "app-store");
export const TEMPLATES_PATH = path.join(APP_STORE_PATH, "templates");
export const IS_WINDOWS_PLATFORM = os.platform() === "win32";
+
+export const APP_CATEGORIES = [
+ { label: "Analytics", value: "analytics" },
+ { label: "AI & Automation", value: "automation" },
+ { label: "Calendar", value: "calendar" },
+ { label: "Conferencing", value: "conferencing" },
+ { label: "CRM", value: "crm" },
+ { label: "Messaging", value: "messaging" },
+ { label: "Payment", value: "payment" },
+ { label: "Other", value: "other" },
+] as const;
+
+export const VALID_CATEGORY_VALUES = APP_CATEGORIES.map((c) => c.value);
diff --git a/packages/app-store-cli/src/validateCreateAppFlags.test.ts b/packages/app-store-cli/src/validateCreateAppFlags.test.ts
new file mode 100644
index 0000000000..e43505cbfa
--- /dev/null
+++ b/packages/app-store-cli/src/validateCreateAppFlags.test.ts
@@ -0,0 +1,180 @@
+import { describe, expect, it } from "vitest";
+
+import { APP_CATEGORIES, VALID_CATEGORY_VALUES } from "./constants";
+import { validateCreateAppFlags } from "./validateCreateAppFlags";
+
+const VALID_TEMPLATES = [
+ "basic",
+ "booking-pages-tag",
+ "event-type-app-card",
+ "event-type-location-video-static",
+ "general-app-settings",
+ "link-as-an-app",
+];
+
+describe("validateCreateAppFlags", () => {
+ describe("template validation", () => {
+ it("returns error when template is empty", () => {
+ const result = validateCreateAppFlags({
+ template: "",
+ category: "other",
+ validTemplateValues: VALID_TEMPLATES,
+ });
+ expect(result).toContain("--template is required in non-interactive mode");
+ expect(result).toContain("basic");
+ });
+
+ it("returns error for invalid template", () => {
+ const result = validateCreateAppFlags({
+ template: "nonexistent-template",
+ category: "other",
+ validTemplateValues: VALID_TEMPLATES,
+ });
+ expect(result).toContain("Invalid template: nonexistent-template");
+ expect(result).toContain("Available templates:");
+ });
+
+ it("accepts all valid templates", () => {
+ for (const template of VALID_TEMPLATES) {
+ const result = validateCreateAppFlags({
+ template,
+ category: "other",
+ externalLinkUrl: template === "link-as-an-app" ? "https://example.com" : undefined,
+ validTemplateValues: VALID_TEMPLATES,
+ });
+ expect(result).toBeNull();
+ }
+ });
+ });
+
+ describe("category validation", () => {
+ it("returns error for invalid category", () => {
+ const result = validateCreateAppFlags({
+ template: "basic",
+ category: "invalid-category",
+ validTemplateValues: VALID_TEMPLATES,
+ });
+ expect(result).toContain("Invalid category: invalid-category");
+ expect(result).toContain("Available categories:");
+ });
+
+ it("accepts all valid categories", () => {
+ for (const category of VALID_CATEGORY_VALUES) {
+ const result = validateCreateAppFlags({
+ template: "basic",
+ category,
+ validTemplateValues: VALID_TEMPLATES,
+ });
+ expect(result).toBeNull();
+ }
+ });
+ });
+
+ describe("link-as-an-app external URL validation", () => {
+ it("returns error when link-as-an-app template is used without external URL", () => {
+ const result = validateCreateAppFlags({
+ template: "link-as-an-app",
+ category: "other",
+ validTemplateValues: VALID_TEMPLATES,
+ });
+ expect(result).toBe("--external-link-url is required when using the link-as-an-app template.");
+ });
+
+ it("returns error when externalLinkUrl is empty string for link-as-an-app", () => {
+ const result = validateCreateAppFlags({
+ template: "link-as-an-app",
+ category: "other",
+ externalLinkUrl: "",
+ validTemplateValues: VALID_TEMPLATES,
+ });
+ expect(result).toBe("--external-link-url is required when using the link-as-an-app template.");
+ });
+
+ it("accepts link-as-an-app with valid external URL", () => {
+ const result = validateCreateAppFlags({
+ template: "link-as-an-app",
+ category: "other",
+ externalLinkUrl: "https://example.com",
+ validTemplateValues: VALID_TEMPLATES,
+ });
+ expect(result).toBeNull();
+ });
+
+ it("does not require external URL for non-link-as-an-app templates", () => {
+ const result = validateCreateAppFlags({
+ template: "basic",
+ category: "other",
+ validTemplateValues: VALID_TEMPLATES,
+ });
+ expect(result).toBeNull();
+ });
+ });
+
+ describe("valid inputs", () => {
+ it("returns null for basic template with valid category", () => {
+ const result = validateCreateAppFlags({
+ template: "basic",
+ category: "analytics",
+ validTemplateValues: VALID_TEMPLATES,
+ });
+ expect(result).toBeNull();
+ });
+
+ it("returns null for link-as-an-app with external URL", () => {
+ const result = validateCreateAppFlags({
+ template: "link-as-an-app",
+ category: "conferencing",
+ externalLinkUrl: "https://x.com/calcom",
+ validTemplateValues: VALID_TEMPLATES,
+ });
+ expect(result).toBeNull();
+ });
+ });
+
+ describe("validation order", () => {
+ it("checks template before category", () => {
+ const result = validateCreateAppFlags({
+ template: "",
+ category: "invalid",
+ validTemplateValues: VALID_TEMPLATES,
+ });
+ expect(result).toContain("--template is required");
+ });
+
+ it("checks category before external URL", () => {
+ const result = validateCreateAppFlags({
+ template: "link-as-an-app",
+ category: "invalid",
+ validTemplateValues: VALID_TEMPLATES,
+ });
+ expect(result).toContain("Invalid category");
+ });
+ });
+});
+
+describe("constants", () => {
+ it("APP_CATEGORIES contains expected categories", () => {
+ const values = APP_CATEGORIES.map((c) => c.value);
+ expect(values).toContain("analytics");
+ expect(values).toContain("automation");
+ expect(values).toContain("calendar");
+ expect(values).toContain("conferencing");
+ expect(values).toContain("crm");
+ expect(values).toContain("messaging");
+ expect(values).toContain("payment");
+ expect(values).toContain("other");
+ });
+
+ it("APP_CATEGORIES has label and value for each entry", () => {
+ for (const cat of APP_CATEGORIES) {
+ expect(cat).toHaveProperty("label");
+ expect(cat).toHaveProperty("value");
+ expect(typeof cat.label).toBe("string");
+ expect(typeof cat.value).toBe("string");
+ }
+ });
+
+ it("VALID_CATEGORY_VALUES matches APP_CATEGORIES values", () => {
+ expect(VALID_CATEGORY_VALUES).toEqual(APP_CATEGORIES.map((c) => c.value));
+ });
+});
diff --git a/packages/app-store-cli/src/validateCreateAppFlags.ts b/packages/app-store-cli/src/validateCreateAppFlags.ts
new file mode 100644
index 0000000000..28fcda0c79
--- /dev/null
+++ b/packages/app-store-cli/src/validateCreateAppFlags.ts
@@ -0,0 +1,33 @@
+import { VALID_CATEGORY_VALUES } from "./constants";
+
+interface ValidateCreateAppFlagsInput {
+ template: string;
+ category: string;
+ externalLinkUrl?: string;
+ validTemplateValues: string[];
+}
+
+export function validateCreateAppFlags({
+ template,
+ category,
+ externalLinkUrl,
+ validTemplateValues,
+}: ValidateCreateAppFlagsInput): string | null {
+ if (!template) {
+ return `--template is required in non-interactive mode. Available templates: ${validTemplateValues.join(", ")}`;
+ }
+
+ if (!validTemplateValues.includes(template)) {
+ return `Invalid template: ${template}. Available templates: ${validTemplateValues.join(", ")}`;
+ }
+
+ if (!(VALID_CATEGORY_VALUES as readonly string[]).includes(category)) {
+ return `Invalid category: ${category}. Available categories: ${VALID_CATEGORY_VALUES.join(", ")}`;
+ }
+
+ if (template === "link-as-an-app" && !externalLinkUrl) {
+ return "--external-link-url is required when using the link-as-an-app template.";
+ }
+
+ return null;
+}