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,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;