feat: add non-interactive mode to app-store CLI (#27831)
This commit is contained in:
@@ -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.
|
||||
@@ -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(<App slug={slug} template={cli.flags.template || ""} command={command} />);
|
||||
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(<App slug={slug} template={cli.flags.template || ""} command={command} />);
|
||||
}
|
||||
|
||||
@@ -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<AppCategories,> 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.",
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user