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; +}