first commit
This commit is contained in:
49
calcom/packages/app-store-cli/src/App.tsx
Normal file
49
calcom/packages/app-store-cli/src/App.tsx
Normal 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;
|
||||
394
calcom/packages/app-store-cli/src/build.ts
Normal file
394
calcom/packages/app-store-cli/src/build.ts
Normal 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);
|
||||
}
|
||||
73
calcom/packages/app-store-cli/src/cli.tsx
Normal file
73
calcom/packages/app-store-cli/src/cli.tsx
Normal 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} />);
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
7
calcom/packages/app-store-cli/src/commandViews/Edit.tsx
Normal file
7
calcom/packages/app-store-cli/src/commandViews/Edit.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
108
calcom/packages/app-store-cli/src/components/DeleteForm.tsx
Normal file
108
calcom/packages/app-store-cli/src/components/DeleteForm.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
11
calcom/packages/app-store-cli/src/components/Label.tsx
Normal file
11
calcom/packages/app-store-cli/src/components/Label.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
calcom/packages/app-store-cli/src/components/Message.tsx
Normal file
29
calcom/packages/app-store-cli/src/components/Message.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
calcom/packages/app-store-cli/src/constants.ts
Normal file
6
calcom/packages/app-store-cli/src/constants.ts
Normal 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";
|
||||
140
calcom/packages/app-store-cli/src/core.ts
Normal file
140
calcom/packages/app-store-cli/src/core.ts
Normal 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`);
|
||||
};
|
||||
7
calcom/packages/app-store-cli/src/types.d.ts
vendored
Normal file
7
calcom/packages/app-store-cli/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
export type SupportedCommands =
|
||||
| "create"
|
||||
| "edit"
|
||||
| "delete"
|
||||
| "create-template"
|
||||
| "delete-template"
|
||||
| "edit-template";
|
||||
26
calcom/packages/app-store-cli/src/utils/execSync.ts
Normal file
26
calcom/packages/app-store-cli/src/utils/execSync.ts
Normal 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;
|
||||
26
calcom/packages/app-store-cli/src/utils/getApp.ts
Normal file
26
calcom/packages/app-store-cli/src/utils/getApp.ts
Normal 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;
|
||||
};
|
||||
23
calcom/packages/app-store-cli/src/utils/getAppName.ts
Normal file
23
calcom/packages/app-store-cli/src/utils/getAppName.ts
Normal 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;
|
||||
}
|
||||
29
calcom/packages/app-store-cli/src/utils/templates.ts
Normal file
29
calcom/packages/app-store-cli/src/utils/templates.ts
Normal 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;
|
||||
Reference in New Issue
Block a user