2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
node_modules
dist

View File

@@ -0,0 +1,11 @@
## How to build an App using the CLI
Refer to https://developer.cal.com/guides/how-to-build-an-app
## TODO
- Merge app-store:watch and app-store commands; introduce app-store --watch
- An app created through CLI should be able to completely skip API validation for testing purposes. Credentials should be created with no API specified specific to the app. It would allow us to test any app end to end not worrying about the corresponding API endpoint.
- Someone can add wrong directory name(which doesn't satisfy slug requirements) manually. How to handle it.
- Allow editing and updating app from the cal app itself - including assets uploading when developing locally.
- Follow the [App Contribution Guidelines](../app-store/CONTRIBUTING.md)

View File

@@ -0,0 +1,36 @@
{
"name": "@calcom/app-store-cli",
"private": true,
"sideEffects": false,
"version": "0.0.0",
"bin": "dist/cli.js",
"engines": {
"node": ">=10"
},
"scripts": {
"build": "ts-node --transpile-only src/build.ts",
"cli": "ts-node --transpile-only src/cli.tsx",
"watch": "ts-node --transpile-only src/build.ts --watch",
"generate": "ts-node --transpile-only src/build.ts",
"post-install": "yarn build"
},
"files": [
"dist/cli.js"
],
"dependencies": {
"@calcom/lib": "*",
"ink": "^3.2.0",
"ink-select-input": "^4.2.1",
"ink-text-input": "^4.0.3",
"meow": "^9.0.0",
"react": "^18.2.0"
},
"devDependencies": {
"@types/react": "18.0.26",
"chokidar": "^3.5.3",
"eslint-plugin-react": "^7.30.1",
"eslint-plugin-react-hooks": "^4.6.0",
"ts-node": "^10.9.1",
"typescript": "^4.9.4"
}
}

View File

@@ -0,0 +1,49 @@
import React, { FC } from "react";
import { SupportedCommands } from "src/types";
import Create from "./commandViews/Create";
import CreateTemplate from "./commandViews/Create";
import Delete from "./commandViews/Delete";
import DeleteTemplate from "./commandViews/DeleteTemplate";
import Edit from "./commandViews/Edit";
import EditTemplate from "./commandViews/EditTemplate";
export const App: FC<{
template: string;
command: SupportedCommands;
slug?: string;
}> = ({ command, template, slug }) => {
if (command === "create") {
return <Create template={template} />;
}
if (command === "edit") {
return <Edit slug={slug} />;
}
if (command === "edit-template") {
return <EditTemplate slug={slug} />;
}
if (command === "delete") {
if (!slug) {
throw new Error('Slug is required for "delete" command');
}
return <Delete slug={slug} />;
}
if (command === "create-template") {
return <CreateTemplate template={template} />;
}
if (command === "delete-template") {
if (!slug) {
throw new Error('Slug is required for "delete-template" command');
}
return <DeleteTemplate slug={slug} />;
}
return null;
};
export default App;

View File

@@ -0,0 +1,394 @@
import chokidar from "chokidar";
import fs from "fs";
// eslint-disable-next-line no-restricted-imports
import { debounce } from "lodash";
import path from "path";
import prettier from "prettier";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
import prettierConfig from "@calcom/config/prettier-preset";
import type { AppMeta } from "@calcom/types/App";
import { APP_STORE_PATH } from "./constants";
import { getAppName } from "./utils/getAppName";
let isInWatchMode = false;
if (process.argv[2] === "--watch") {
isInWatchMode = true;
}
const formatOutput = (source: string) =>
prettier.format(source, {
parser: "babel",
...prettierConfig,
});
const getVariableName = function (appName: string) {
return appName.replace(/[-.]/g, "_");
};
const getAppId = function (app: { name: string }) {
// Handle stripe separately as it's an old app with different dirName than slug/appId
return app.name === "stripepayment" ? "stripe" : app.name;
};
type App = Partial<AppMeta> & {
name: string;
path: string;
};
function generateFiles() {
const browserOutput = [`import dynamic from "next/dynamic"`];
const metadataOutput = [];
const bookerMetadataOutput = [];
const schemasOutput = [];
const appKeysSchemasOutput = [];
const serverOutput = [];
const appDirs: { name: string; path: string }[] = [];
fs.readdirSync(`${APP_STORE_PATH}`).forEach(function (dir) {
if (dir === "ee" || dir === "templates") {
fs.readdirSync(path.join(APP_STORE_PATH, dir)).forEach(function (subDir) {
if (fs.statSync(path.join(APP_STORE_PATH, dir, subDir)).isDirectory()) {
if (getAppName(subDir)) {
appDirs.push({
name: subDir,
path: path.join(dir, subDir),
});
}
}
});
} else {
if (fs.statSync(path.join(APP_STORE_PATH, dir)).isDirectory()) {
if (!getAppName(dir)) {
return;
}
appDirs.push({
name: dir,
path: dir,
});
}
}
});
function forEachAppDir(callback: (arg: App) => void, filter: (arg: App) => boolean = () => true) {
for (let i = 0; i < appDirs.length; i++) {
const configPath = path.join(APP_STORE_PATH, appDirs[i].path, "config.json");
const metadataPath = path.join(APP_STORE_PATH, appDirs[i].path, "_metadata.ts");
let app;
if (fs.existsSync(configPath)) {
app = JSON.parse(fs.readFileSync(configPath).toString());
} else if (fs.existsSync(metadataPath)) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
app = require(metadataPath).metadata;
} else {
app = {};
}
const finalApp = {
...app,
name: appDirs[i].name,
path: appDirs[i].path,
};
if (filter(finalApp)) {
callback(finalApp);
}
}
}
/**
* Windows has paths with backslashes, so we need to replace them with forward slashes
* .ts and .tsx files are imported without extensions
* If a file has index.ts or index.tsx, it can be imported after removing the index.ts* part
*/
function getModulePath(path: string, moduleName: string) {
return `./${path.replace(/\\/g, "/")}/${moduleName
.replace(/\/index\.ts|\/index\.tsx/, "")
.replace(/\.tsx$|\.ts$/, "")}`;
}
type ImportConfig =
| {
fileToBeImported: string;
importName?: string;
}
| [
{
fileToBeImported: string;
importName?: string;
},
{
fileToBeImported: string;
importName: string;
}
];
/**
* If importConfig is an array, only 2 items are allowed. First one is the main one and second one is the fallback
*/
function getExportedObject(
objectName: string,
{
lazyImport = false,
importConfig,
entryObjectKeyGetter = (app) => app.name,
}: {
lazyImport?: boolean;
importConfig: ImportConfig;
entryObjectKeyGetter?: (arg: App, importName?: string) => string;
},
filter?: (arg: App) => boolean
) {
const output: string[] = [];
const getLocalImportName = (
app: { name: string },
chosenConfig: ReturnType<typeof getChosenImportConfig>
) => `${getVariableName(app.name)}_${getVariableName(chosenConfig.fileToBeImported)}`;
const fileToBeImportedExists = (
app: { path: string },
chosenConfig: ReturnType<typeof getChosenImportConfig>
) => fs.existsSync(path.join(APP_STORE_PATH, app.path, chosenConfig.fileToBeImported));
addImportStatements();
createExportObject();
return output;
function addImportStatements() {
forEachAppDir((app) => {
const chosenConfig = getChosenImportConfig(importConfig, app);
if (fileToBeImportedExists(app, chosenConfig) && chosenConfig.importName) {
const importName = chosenConfig.importName;
if (!lazyImport) {
if (importName !== "default") {
// Import with local alias that will be used by createExportObject
output.push(
`import { ${importName} as ${getLocalImportName(app, chosenConfig)} } from "${getModulePath(
app.path,
chosenConfig.fileToBeImported
)}"`
);
} else {
// Default Import
output.push(
`import ${getLocalImportName(app, chosenConfig)} from "${getModulePath(
app.path,
chosenConfig.fileToBeImported
)}"`
);
}
}
}
}, filter);
}
function createExportObject() {
output.push(`export const ${objectName} = {`);
forEachAppDir((app) => {
const chosenConfig = getChosenImportConfig(importConfig, app);
if (fileToBeImportedExists(app, chosenConfig)) {
if (!lazyImport) {
const key = entryObjectKeyGetter(app);
output.push(`"${key}": ${getLocalImportName(app, chosenConfig)},`);
} else {
const key = entryObjectKeyGetter(app);
if (chosenConfig.fileToBeImported.endsWith(".tsx")) {
output.push(
`"${key}": dynamic(() => import("${getModulePath(
app.path,
chosenConfig.fileToBeImported
)}")),`
);
} else {
output.push(`"${key}": import("${getModulePath(app.path, chosenConfig.fileToBeImported)}"),`);
}
}
}
}, filter);
output.push(`};`);
}
function getChosenImportConfig(importConfig: ImportConfig, app: { path: string }) {
let chosenConfig;
if (!(importConfig instanceof Array)) {
chosenConfig = importConfig;
} else {
if (fs.existsSync(path.join(APP_STORE_PATH, app.path, importConfig[0].fileToBeImported))) {
chosenConfig = importConfig[0];
} else {
chosenConfig = importConfig[1];
}
}
return chosenConfig;
}
}
serverOutput.push(
...getExportedObject("apiHandlers", {
importConfig: {
fileToBeImported: "api/index.ts",
},
lazyImport: true,
})
);
metadataOutput.push(
...getExportedObject("appStoreMetadata", {
// Try looking for config.json and if it's not found use _metadata.ts to generate appStoreMetadata
importConfig: [
{
fileToBeImported: "config.json",
importName: "default",
},
{
fileToBeImported: "_metadata.ts",
importName: "metadata",
},
],
})
);
bookerMetadataOutput.push(
...getExportedObject(
"appStoreMetadata",
{
// Try looking for config.json and if it's not found use _metadata.ts to generate appStoreMetadata
importConfig: [
{
fileToBeImported: "config.json",
importName: "default",
},
{
fileToBeImported: "_metadata.ts",
importName: "metadata",
},
],
},
isBookerApp
)
);
schemasOutput.push(
...getExportedObject("appDataSchemas", {
// Import path must have / even for windows and not \
importConfig: {
fileToBeImported: "zod.ts",
importName: "appDataSchema",
},
// HACK: Key must be appId as this is used by eventType metadata and lookup is by appId
// This can be removed once we rename the ids of apps like stripe to that of their app folder name
entryObjectKeyGetter: (app) => getAppId(app),
})
);
appKeysSchemasOutput.push(
...getExportedObject("appKeysSchemas", {
importConfig: {
fileToBeImported: "zod.ts",
importName: "appKeysSchema",
},
// HACK: Key must be appId as this is used by eventType metadata and lookup is by appId
// This can be removed once we rename the ids of apps like stripe to that of their app folder name
entryObjectKeyGetter: (app) => getAppId(app),
})
);
browserOutput.push(
...getExportedObject("InstallAppButtonMap", {
importConfig: {
fileToBeImported: "components/InstallAppButton.tsx",
},
lazyImport: true,
})
);
// TODO: Make a component map creator that accepts ComponentName and does the rest.
// TODO: dailyvideo has a slug of daily-video, so that mapping needs to be taken care of. But it is an old app, so it doesn't need AppSettings
browserOutput.push(
...getExportedObject("AppSettingsComponentsMap", {
importConfig: {
fileToBeImported: "components/AppSettingsInterface.tsx",
},
lazyImport: true,
})
);
browserOutput.push(
...getExportedObject("EventTypeAddonMap", {
importConfig: {
fileToBeImported: "components/EventTypeAppCardInterface.tsx",
},
lazyImport: true,
})
);
browserOutput.push(
...getExportedObject("EventTypeSettingsMap", {
importConfig: {
fileToBeImported: "components/EventTypeAppSettingsInterface.tsx",
},
lazyImport: true,
})
);
const banner = `/**
This file is autogenerated using the command \`yarn app-store:build --watch\`.
Don't modify this file manually.
**/
`;
const filesToGenerate: [string, string[]][] = [
["apps.metadata.generated.ts", metadataOutput],
["apps.server.generated.ts", serverOutput],
["apps.browser.generated.tsx", browserOutput],
["apps.schemas.generated.ts", schemasOutput],
["apps.keys-schemas.generated.ts", appKeysSchemasOutput],
["bookerApps.metadata.generated.ts", bookerMetadataOutput],
];
filesToGenerate.forEach(([fileName, output]) => {
fs.writeFileSync(`${APP_STORE_PATH}/${fileName}`, formatOutput(`${banner}${output.join("\n")}`));
});
console.log(`Generated ${filesToGenerate.map(([fileName]) => fileName).join(", ")}`);
}
const debouncedGenerateFiles = debounce(generateFiles);
if (isInWatchMode) {
chokidar
.watch(APP_STORE_PATH)
.on("addDir", (dirPath) => {
const appName = getAppName(dirPath);
if (appName) {
console.log(`Added ${appName}`);
debouncedGenerateFiles();
}
})
.on("change", (filePath) => {
if (filePath.endsWith("config.json")) {
console.log("Config file changed");
debouncedGenerateFiles();
}
})
.on("unlinkDir", (dirPath) => {
const appName = getAppName(dirPath);
if (appName) {
console.log(`Removed ${appName}`);
debouncedGenerateFiles();
}
});
} else {
generateFiles();
}
function isBookerApp(app: App) {
// Right now there are only two types of Apps that booker needs.
// Note that currently payment apps' meta don't need to be accessed on booker. We just access from DB eventType.metadata
// 1. It is a location app(e.g. any Conferencing App)
// 2. It is a tag manager app(e.g. Google Analytics, GTM, Fathom)
return !!(app.appData?.location || app.appData?.tag);
}

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env node
import { render } from "ink";
import meow from "meow";
import React from "react";
import App from "./App";
import { SupportedCommands } from "./types";
const cli = meow(
`
Usage
$ 'app-store create' or 'app-store create-template' - Creates a new app or template
Options
[--template -t] Template to use.
$ 'app-store edit' or 'app-store edit-template' - Edit the App or Template identified by slug
Options
[--slug -s] Slug. This is the name of app dir for apps created with cli.
$ 'app-store delete' or 'app-store delete-template' - Deletes the app or template identified by slug
Options
[--slug -s] Slug. This is the name of app dir for apps created with cli.
`,
{
flags: {
slug: {
type: "string",
alias: "s",
},
template: {
type: "string",
alias: "t",
},
},
allowUnknownFlags: false,
}
);
if (cli.input.length !== 1) {
cli.showHelp();
}
const command = cli.input[0] as SupportedCommands;
const supportedCommands = [
"create",
"delete",
"edit",
"create-template",
"delete-template",
"edit-template",
] as const;
if (!supportedCommands.includes(command)) {
cli.showHelp();
}
let slug;
if (
command === "delete" ||
command === "edit" ||
command === "delete-template" ||
command === "edit-template"
) {
slug = cli.flags.slug;
if (!slug) {
console.log("--slug is required");
cli.showHelp(0);
}
}
render(<App slug={slug} template={cli.flags.template || ""} command={command} />);

View File

@@ -0,0 +1,7 @@
import React from "react";
import { AppForm } from "../components/AppCreateUpdateForm";
export default function Create(props: Omit<React.ComponentProps<typeof AppForm>, "action">) {
return <AppForm action="create" {...props} />;
}

View File

@@ -0,0 +1,7 @@
import React from "react";
import { AppForm } from "../components/AppCreateUpdateForm";
export default function CreateTemplate(props: Omit<React.ComponentProps<typeof AppForm>, "action">) {
return <AppForm action="create-template" {...props} />;
}

View File

@@ -0,0 +1,7 @@
import React from "react";
import DeleteForm from "../components/DeleteForm";
export default function Delete({ slug }: { slug: string }) {
return <DeleteForm slug={slug} action="delete" />;
}

View File

@@ -0,0 +1,7 @@
import React from "react";
import DeleteForm from "../components/DeleteForm";
export default function Delete({ slug }: { slug: string }) {
return <DeleteForm slug={slug} action="delete-template" />;
}

View File

@@ -0,0 +1,7 @@
import React from "react";
import { AppForm } from "../components/AppCreateUpdateForm";
export default function Edit(props: Omit<React.ComponentProps<typeof AppForm>, "action">) {
return <AppForm action="edit" {...props} />;
}

View File

@@ -0,0 +1,7 @@
import React from "react";
import { AppForm } from "../components/AppCreateUpdateForm";
export default function Edit(props: Omit<React.ComponentProps<typeof AppForm>, "action">) {
return <AppForm action="edit-template" {...props} />;
}

View File

@@ -0,0 +1,358 @@
import fs from "fs";
import { Box, Newline, Text, useApp } from "ink";
import SelectInput from "ink-select-input";
import TextInput from "ink-text-input";
import React, { useEffect, useState } from "react";
import type { AppMeta } from "@calcom/types/App";
import { getSlugFromAppName, BaseAppFork, generateAppFiles, getAppDirPath } from "../core";
import { getApp } from "../utils/getApp";
import Templates from "../utils/templates";
import Label from "./Label";
import { Message } from "./Message";
export const AppForm = ({
template: cliTemplate = "",
slug: givenSlug = "",
action,
}: {
template?: string;
slug?: string;
action: "create" | "edit" | "create-template" | "edit-template";
}) => {
cliTemplate = Templates.find((t) => t.value === cliTemplate)?.value || "";
const { exit } = useApp();
const isTemplate = action === "create-template" || action === "edit-template";
const isEditAction = action === "edit" || action === "edit-template";
let initialConfig = {
template: cliTemplate,
name: "",
description: "",
category: "",
publisher: "",
email: "",
};
const [app] = useState(() => getApp(givenSlug, isTemplate));
if ((givenSlug && action === "edit-template") || action === "edit")
try {
const config = JSON.parse(
fs.readFileSync(`${getAppDirPath(givenSlug, isTemplate)}/config.json`).toString()
) as AppMeta;
initialConfig = {
...config,
category: config.categories[0],
template: config.__template,
};
} catch (e) {}
const fields = [
{
label: "App Title",
name: "name",
type: "text",
explainer: "Keep it short and sweet like 'Google Meet'",
optional: false,
defaultValue: "",
},
{
label: "App Description",
name: "description",
type: "text",
explainer:
"A detailed description of your app. You can later modify DESCRIPTION.mdx to add markdown as well",
optional: false,
defaultValue: "",
},
// You can't edit the base template of an App or Template - You need to start fresh for that.
cliTemplate || isEditAction
? null
: {
label: "Choose a base Template",
name: "template",
type: "select",
options: Templates,
optional: false,
defaultValue: "",
},
{
optional: false,
label: "Category of App",
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: "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" },
],
defaultValue: "",
explainer: "This is how apps are categorized in App Store.",
},
{
optional: true,
label: "Publisher Name",
name: "publisher",
type: "text",
explainer: "Let users know who you are",
defaultValue: "Your Name",
},
{
optional: true,
label: "Publisher Email",
name: "email",
type: "text",
explainer: "Let users know how they can contact you.",
defaultValue: "email@example.com",
},
].filter((f) => f);
const [appInputData, setAppInputData] = useState(initialConfig);
const [inputIndex, setInputIndex] = useState(0);
const [slugFinalized, setSlugFinalized] = useState(false);
const field = fields[inputIndex];
const fieldLabel = field?.label || "";
const fieldName = field?.name || "";
let fieldValue = appInputData[fieldName as keyof typeof appInputData] || "";
let validationResult: Parameters<typeof Message>[0]["message"] | null = null;
const { name, category, description, publisher, email, template } = appInputData;
const [status, setStatus] = useState<"inProgress" | "done">("inProgress");
const formCompleted = inputIndex === fields.length;
if (field?.name === "appCategory") {
// Use template category as the default category
fieldValue = Templates.find((t) => t.value === appInputData["template"])?.category || "";
}
const slug = getSlugFromAppName(name) || givenSlug;
useEffect(() => {
// When all fields have been filled
(async () => {
if (formCompleted) {
await BaseAppFork.create({
category,
description,
name,
slug,
publisher,
email,
template,
editMode: isEditAction,
isTemplate,
oldSlug: givenSlug,
});
await generateAppFiles();
// FIXME: Even after CLI showing this message, it is stuck doing work before exiting
// So we ask the user to wait for some time
setStatus("done");
}
})();
}, [formCompleted]);
if (action === "edit" || action === "edit-template") {
if (!slug) {
return <Text>--slug is required</Text>;
}
if (!app) {
return (
<Message
message={{
text: `App with slug ${givenSlug} not found`,
type: "error",
}}
/>
);
}
}
if (status === "done") {
// HACK: This is a hack to exit the process manually because due to some reason cli isn't automatically exiting
setTimeout(() => {
exit();
}, 500);
}
if (formCompleted) {
return (
<Box flexDirection="column">
{status !== "done" && (
<Message
key="progressHeading"
message={{
text: isEditAction
? `Editing app with slug ${slug}`
: `Creating ${
action === "create-template" ? "template" : "app"
} with name '${name}' categorized in '${category}' using template '${template}'`,
type: "info",
showInProgressIndicator: true,
}}
/>
)}
{status === "done" && (
<Box flexDirection="column" paddingTop={2} paddingBottom={2}>
<Text bold>
Just wait for a few seconds for process to exit and then you are good to go. Your{" "}
{isTemplate ? "Template" : "App"} code exists at {getAppDirPath(slug, isTemplate)}
</Text>
<Text>
Tip : Go and change the logo of your {isTemplate ? "template" : "app"} by replacing{" "}
{`${getAppDirPath(slug, isTemplate)}/static/icon.svg`}
</Text>
<Newline />
<Text bold underline color="blue">
App Summary:
</Text>
<Box flexDirection="column">
<Box flexDirection="row">
<Text color="green">Slug: </Text>
<Text>{slug}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">{isTemplate ? "Template" : "App"} URL: </Text>
<Text>{`http://localhost:3000/apps/${slug}`}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Name: </Text>
<Text>{name}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Description: </Text>
<Text>{description}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Category: </Text>
<Text>{category}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Publisher Name: </Text>
<Text>{publisher}</Text>
</Box>
<Box flexDirection="row">
<Text color="green">Publisher Email: </Text>
<Text>{email}</Text>
</Box>
<Text bold>
Next Step: Enable the app from http://localhost:3000/settings/admin/apps as admin user (Email:
admin@example.com, Pass: ADMINadmin2022!)
</Text>
</Box>
</Box>
)}
<Text italic color="gray">
Note: You should not rename app directory manually. Use cli only to do that as it needs to be
updated in DB as well
</Text>
</Box>
);
}
if (slug && slug !== givenSlug && fs.existsSync(getAppDirPath(slug, isTemplate))) {
validationResult = {
text: `${
action === "create" ? "App" : "Template"
} with slug ${slug} already exists. If you want to edit it, use edit command`,
type: "error",
};
if (slugFinalized) {
return <Message message={validationResult} />;
}
}
const selectedOptionIndex =
field?.type === "select" ? field?.options?.findIndex((o) => o.value === fieldValue) : 0;
return (
<Box flexDirection="column">
<Box flexDirection="column">
{isEditAction ? (
<Message
message={{
text: `\nLet's edit your ${isTemplate ? "Template" : "App"}! We have prefilled the details.\n`,
}}
/>
) : (
<Message
message={{
text: `\nLet's create your ${
isTemplate ? "Template" : "App"
}! Start by providing the information that's asked\n`,
}}
/>
)}
<Box>
<Label>{`${fieldLabel}`}</Label>
{field?.type == "text" ? (
<TextInput
value={fieldValue}
placeholder={field?.defaultValue}
onSubmit={(value) => {
if (!value && !field.optional) {
return;
}
setSlugFinalized(true);
setInputIndex((index) => {
return index + 1;
});
}}
onChange={(value) => {
setAppInputData((appInputData) => {
return {
...appInputData,
[fieldName]: value,
};
});
}}
/>
) : (
<SelectInput<string>
items={field?.options}
itemComponent={(item) => {
const myItem = item as { value: string; label: string };
return (
<Box justifyContent="space-between">
<Box flexShrink={0} flexGrow={1}>
<Text color="blue">{myItem.value}: </Text>
</Box>
<Text>{item.label}</Text>
</Box>
);
}}
key={fieldName}
initialIndex={selectedOptionIndex === -1 ? 0 : selectedOptionIndex}
onSelect={(item) => {
setAppInputData((appInputData) => {
return {
...appInputData,
[fieldName]: item.value,
};
});
setInputIndex((index) => {
return index + 1;
});
}}
/>
)}
</Box>
<Box>
{validationResult ? (
<Message message={validationResult} />
) : (
<Text color="gray" italic>
{field?.explainer}
</Text>
)}
</Box>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,108 @@
import { Text } from "ink";
import TextInput from "ink-text-input";
import React, { useEffect, useState } from "react";
import { ImportantText } from "../components/ImportantText";
import { Message } from "../components/Message";
import { BaseAppFork, generateAppFiles } from "../core";
import { getApp } from "../utils/getApp";
export default function DeleteForm({ slug, action }: { slug: string; action: "delete" | "delete-template" }) {
const [confirmedAppSlug, setConfirmedAppSlug] = useState("");
const [state, setState] = useState<
| "INITIALIZED"
| "DELETION_CONFIRMATION_FAILED"
| "DELETION_CONFIRMATION_SUCCESSFUL"
| "DELETION_COMPLETED"
| "APP_NOT_EXISTS"
>("INITIALIZED");
const isTemplate = action === "delete-template";
const app = getApp(slug, isTemplate);
useEffect(() => {
if (!app) {
setState("APP_NOT_EXISTS");
}
}, []);
useEffect(() => {
if (state === "DELETION_CONFIRMATION_SUCCESSFUL") {
(async () => {
await BaseAppFork.delete({ slug, isTemplate });
await generateAppFiles();
// successMsg({ text: `App with slug ${slug} has been deleted`, done: true });
setState("DELETION_COMPLETED");
})();
}
}, [slug, state]);
if (state === "INITIALIZED") {
return (
<>
<ImportantText>
Type below the slug of the {isTemplate ? "Template" : "App"} that you want to delete.
</ImportantText>
<Text color="gray" italic>
It would cleanup the app directory and App table and Credential table.
</Text>
<TextInput
value={confirmedAppSlug}
onSubmit={(value) => {
if (value === slug) {
setState("DELETION_CONFIRMATION_SUCCESSFUL");
} else {
setState("DELETION_CONFIRMATION_FAILED");
}
}}
onChange={(val) => {
setConfirmedAppSlug(val);
}}
/>
</>
);
}
if (state === "APP_NOT_EXISTS") {
return (
<Message
message={{
text: `${isTemplate ? "Template" : "App"} with slug ${slug} doesn't exist`,
type: "error",
}}
/>
);
}
if (state === "DELETION_CONFIRMATION_SUCCESSFUL") {
return (
<Message
message={{
text: `Deleting ${isTemplate ? "Template" : "App"}`,
type: "info",
showInProgressIndicator: true,
}}
/>
);
}
if (state === "DELETION_COMPLETED") {
return (
<Message
message={{
text: `${
isTemplate ? "Template" : "App"
} with slug "${slug}" has been deleted. You might need to restart your dev environment`,
type: "success",
}}
/>
);
}
if (state === "DELETION_CONFIRMATION_FAILED") {
return (
<Message
message={{
text: `Slug doesn't match - Should have been ${slug}`,
type: "error",
}}
/>
);
}
return null;
}

View File

@@ -0,0 +1,6 @@
import { Text } from "ink";
import React from "react";
export function ImportantText({ children }: { children: React.ReactNode }) {
return <Text color="red">{children}</Text>;
}

View File

@@ -0,0 +1,11 @@
import { Box, Text } from "ink";
import React from "react";
export default function Label({ children }: { children: React.ReactNode }) {
return (
<Box>
<Text underline>{children}</Text>
<Text>: </Text>
</Box>
);
}

View File

@@ -0,0 +1,29 @@
import { Text } from "ink";
import React, { useEffect, useState } from "react";
export function Message({
message,
}: {
message: { text: string; type?: "info" | "error" | "success"; showInProgressIndicator?: boolean };
}) {
const color = message.type === "success" ? "green" : message.type === "error" ? "red" : "white";
const [progressText, setProgressText] = useState("...");
useEffect(() => {
if (message.showInProgressIndicator) {
const interval = setInterval(() => {
setProgressText((progressText) => {
return progressText.length > 3 ? "" : `${progressText}.`;
});
}, 1000);
return () => {
clearInterval(interval);
};
}
}, [message.showInProgressIndicator]);
return (
<Text color={color}>
{message.text}
{message.showInProgressIndicator && progressText}
</Text>
);
}

View File

@@ -0,0 +1,6 @@
import os from "os";
import path from "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";

View File

@@ -0,0 +1,140 @@
import fs from "fs";
import path from "path";
import { APP_STORE_PATH, TEMPLATES_PATH, IS_WINDOWS_PLATFORM } from "./constants";
import execSync from "./utils/execSync";
const slugify = (str: string) => {
// A valid dir name
// A valid URL path
// It is okay to not be a valid variable name. This is so that we can use hyphens which look better then underscores in URL and as directory name
return str.replace(/[^a-zA-Z0-9]/g, "-").toLowerCase();
};
export function getSlugFromAppName(appName: string): string {
if (!appName) {
return appName;
}
return slugify(appName);
}
export function getAppDirPath(slug: string, isTemplate: boolean) {
if (!isTemplate) {
return path.join(APP_STORE_PATH, `${slug}`);
}
return path.join(TEMPLATES_PATH, `${slug}`);
}
const updatePackageJson = ({
slug,
appDescription,
appDirPath,
}: {
slug: string;
appDescription: string;
appDirPath: string;
}) => {
const packageJsonConfig = JSON.parse(fs.readFileSync(`${appDirPath}/package.json`).toString());
packageJsonConfig.name = `@calcom/${slug}`;
packageJsonConfig.description = appDescription;
// packageJsonConfig.description = `@calcom/${appName}`;
fs.writeFileSync(`${appDirPath}/package.json`, JSON.stringify(packageJsonConfig, null, 2));
};
const workspaceDir = path.resolve(__dirname, "..", "..", "..");
export const BaseAppFork = {
create: async function ({
category,
editMode = false,
description,
name,
slug,
publisher,
email,
template,
isTemplate,
oldSlug,
}: {
category: string;
editMode?: boolean;
description: string;
name: string;
slug: string;
publisher: string;
email: string;
template: string;
isTemplate: boolean;
oldSlug?: string;
}) {
const appDirPath = getAppDirPath(slug, isTemplate);
if (!editMode) {
await execSync(IS_WINDOWS_PLATFORM ? `mkdir ${appDirPath}` : `mkdir -p ${appDirPath}`);
await execSync(
IS_WINDOWS_PLATFORM
? `xcopy "${TEMPLATES_PATH}\\${template}\\*" "${appDirPath}" /e /i`
: `cp -r ${TEMPLATES_PATH}/${template}/* ${appDirPath}`
);
} else {
if (!oldSlug) {
throw new Error("oldSlug is required when editMode is true");
}
if (oldSlug !== slug) {
// We need to rename only if they are different
const oldAppDirPath = getAppDirPath(oldSlug, isTemplate);
await execSync(
IS_WINDOWS_PLATFORM ? `move ${oldAppDirPath} ${appDirPath}` : `mv ${oldAppDirPath} ${appDirPath}`
);
}
}
updatePackageJson({ slug, appDirPath, appDescription: description });
const categoryToVariantMap = {
video: "conferencing",
};
let config = {
name: name,
// Plan to remove it. DB already has it and name of dir is also the same.
slug: slug,
type: `${slug}_${category}`,
logo: `icon.svg`,
variant: categoryToVariantMap[category as keyof typeof categoryToVariantMap] || category,
categories: [category],
publisher: publisher,
email: email,
description: description,
// TODO: Use this to avoid edit and delete on the apps created outside of cli
__createdUsingCli: true,
isTemplate,
// Store the template used to create an app
__template: template,
};
const currentConfig = JSON.parse(fs.readFileSync(`${appDirPath}/config.json`).toString());
config = {
...currentConfig,
...config,
};
fs.writeFileSync(`${appDirPath}/config.json`, JSON.stringify(config, null, 2));
fs.writeFileSync(
`${appDirPath}/DESCRIPTION.md`,
fs
.readFileSync(`${appDirPath}/DESCRIPTION.md`)
.toString()
.replace(/_DESCRIPTION_/g, description)
.replace(/_APP_DIR_/g, slug)
);
// New monorepo package has been added, so we need to run yarn again
await execSync("yarn");
},
delete: async function ({ slug, isTemplate }: { slug: string; isTemplate: boolean }) {
const appDirPath = getAppDirPath(slug, isTemplate);
await execSync(IS_WINDOWS_PLATFORM ? `rd /s /q ${appDirPath}` : `rm -rf ${appDirPath}`);
},
};
export const generateAppFiles = async () => {
await execSync(`yarn ts-node --transpile-only src/build.ts`);
};

View File

@@ -0,0 +1,7 @@
export type SupportedCommands =
| "create"
| "edit"
| "delete"
| "create-template"
| "delete-template"
| "edit-template";

View File

@@ -0,0 +1,26 @@
import child_process from "child_process";
const execSync = async (cmd: string) => {
const silent = process.env.DEBUG === "1" ? false : true;
if (!silent) {
console.log(`${process.cwd()}$: ${cmd}`);
}
const result: string = await new Promise((resolve, reject) => {
child_process.exec(cmd, (err, stdout, stderr) => {
if (err) {
reject(err);
console.log(err);
}
if (stderr && !silent) {
console.log(stderr);
}
resolve(stdout);
});
});
if (!silent) {
console.log(result.toString());
}
return cmd;
};
export default execSync;

View File

@@ -0,0 +1,26 @@
import fs from "fs";
import path from "path";
import { APP_STORE_PATH, TEMPLATES_PATH } from "../constants";
import { getAppName } from "./getAppName";
export const getApp = (slug: string, isTemplate: boolean) => {
const base = isTemplate ? TEMPLATES_PATH : APP_STORE_PATH;
const foundApp = fs
.readdirSync(base)
.filter((dir) => {
if (fs.statSync(path.join(base, dir)).isDirectory() && getAppName(dir)) {
return true;
}
return false;
})
.find((appName) => appName === slug);
if (foundApp) {
try {
return JSON.parse(fs.readFileSync(path.join(base, foundApp, "config.json")).toString());
} catch (e) {
return {};
}
}
return null;
};

View File

@@ -0,0 +1,23 @@
import path from "path";
import { APP_STORE_PATH } from "../constants";
export function getAppName(candidatePath) {
function isValidAppName(candidatePath) {
if (
!candidatePath.startsWith("_") &&
candidatePath !== "ee" &&
!candidatePath.includes("/") &&
!candidatePath.includes("\\")
) {
return candidatePath;
}
}
if (isValidAppName(candidatePath)) {
// Already a dirname of an app
return candidatePath;
}
// Get dirname of app from full path
const dirName = path.relative(APP_STORE_PATH, candidatePath);
return isValidAppName(dirName) ? dirName : null;
}

View File

@@ -0,0 +1,29 @@
import fs from "fs";
import path from "path";
import { TEMPLATES_PATH } from "../constants";
import { getAppName } from "./getAppName";
const Templates = fs
.readdirSync(TEMPLATES_PATH)
.filter((dir) => {
if (fs.statSync(path.join(TEMPLATES_PATH, dir)).isDirectory() && getAppName(dir)) {
return true;
}
return false;
})
.map((dir) => {
try {
const config = JSON.parse(fs.readFileSync(path.join(TEMPLATES_PATH, dir, "config.json")).toString());
return {
label: `${config.description}`,
value: dir,
category: config.categories[0],
};
} catch (e) {
// config.json might not exist
return null;
}
})
.filter((item) => !!item) as { label: string; value: string; category: string }[];
export default Templates;

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"strict": true,
"module": "commonjs",
"jsx": "react",
"esModuleInterop": true,
"outDir": "dist",
"noEmitOnError": false,
"target": "ES2020",
"baseUrl": ".",
"resolveJsonModule": true
},
"include": [
"next-env.d.ts",
"../../packages/types/*.d.ts",
"../../packages/types/next-auth.d.ts",
"./src/**/*.ts",
"./src/**/*.tsx",
"../lib/**/*.ts"
]
}