feat: add non-interactive mode to app-store CLI (#27831)

This commit is contained in:
Hariom Balhara
2026-02-10 13:59:00 +01:00
committed by GitHub
parent 3fd922664f
commit 11f336811b
6 changed files with 432 additions and 14 deletions
+112
View File
@@ -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.
+92 -2
View File
@@ -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.",
},
+13
View File
@@ -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;
}