first commit
This commit is contained in:
2
calcom/packages/app-store-cli/.gitignore
vendored
Normal file
2
calcom/packages/app-store-cli/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
11
calcom/packages/app-store-cli/README.md
Normal file
11
calcom/packages/app-store-cli/README.md
Normal 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)
|
||||
36
calcom/packages/app-store-cli/package.json
Normal file
36
calcom/packages/app-store-cli/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
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;
|
||||
21
calcom/packages/app-store-cli/tsconfig.json
Normal file
21
calcom/packages/app-store-cli/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user