diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 00000000..df0be79e --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,28 @@ +{ + "name": "@ragestudio/comty-cli", + "description": "Command line interface for Comty Services", + "version": "0.1.2", + "publishConfig": { + "access": "public" + }, + "main": "./src/index.js", + "type": "module", + "license": "MIT", + "bin": { + "comty-cli": "./src/index.js" + }, + "scripts": { + "start": "node ./src/index.js" + }, + "dependencies": { + "7zip-min": "^2.0.0", + "@inquirer/prompts": "^7.4.0", + "axios": "^1.8.3", + "commander": "^13.1.0", + "comty.js": "^0.60.7", + "form-data": "^4.0.2", + "formdata-node": "^6.0.3", + "glob": "^11.0.1", + "yocto-spinner": "^0.2.1" + } +} diff --git a/packages/cli/src/classes/cache.js b/packages/cli/src/classes/cache.js new file mode 100644 index 00000000..c5ced76e --- /dev/null +++ b/packages/cli/src/classes/cache.js @@ -0,0 +1,30 @@ +import path from "node:path" +import fs from "node:fs" + +import Config from "./config.js" + +export default class Cache { + static cachePath = path.resolve(Config.appWorkdir, "cache") + + static async initialize() { + if (!fs.existsSync(this.cachePath)) { + await fs.promises.mkdir(this.cachePath, { recursive: true }) + } + } + + static async destroyTemporalDir(tempDirId) { + const tempDir = path.resolve(this.cachePath, tempDirId) + + await fs.promises.rm(tempDir, { + recursive: true, + }) + } + + static async createTemporalDir() { + const tempDir = path.join(this.cachePath, `temp-${Date.now()}`) + + await fs.promises.mkdir(tempDir, { recursive: true }) + + return [tempDir, async () => await Cache.destroyTemporalDir(tempDir)] + } +} diff --git a/packages/cli/src/classes/config.js b/packages/cli/src/classes/config.js new file mode 100644 index 00000000..54c30777 --- /dev/null +++ b/packages/cli/src/classes/config.js @@ -0,0 +1,51 @@ +import fs from "node:fs" +import path from "node:path" +import os from "node:os" + +export default class Config { + static appWorkdir = path.resolve(os.homedir(), ".comty-cli") + static configFilePath = path.resolve(Config.appWorkdir, "config.json") + + data = {} + + async initialize() { + if (!fs.existsSync(path.dirname(Config.configFilePath))) { + fs.mkdirSync(path.dirname(Config.configFilePath), { + recursive: true, + }) + } + + if (!fs.existsSync(Config.configFilePath)) { + fs.writeFileSync(Config.configFilePath, JSON.stringify({})) + } + + this.data = JSON.parse( + await fs.promises.readFile(Config.configFilePath, "utf8"), + ) + } + + async write() { + await fs.promises.writeFile( + Config.configFilePath, + JSON.stringify(this.data), + ) + } + + get(key) { + return this.data[key] + } + + async set(key, value) { + this.data[key] = value + + await this.write() + + return value + } + + async delete(key) { + delete this.data[key] + + await this.write() + } +} diff --git a/packages/cli/src/commands/auth/index.js b/packages/cli/src/commands/auth/index.js new file mode 100644 index 00000000..d2cb0b62 --- /dev/null +++ b/packages/cli/src/commands/auth/index.js @@ -0,0 +1,8 @@ +import authorizeAccount from "../../utils/authorizeAccount.js" + +export default { + cmd: "auth", + fn: async () => { + await authorizeAccount() + }, +} diff --git a/packages/cli/src/commands/publish/index.js b/packages/cli/src/commands/publish/index.js new file mode 100644 index 00000000..3366744b --- /dev/null +++ b/packages/cli/src/commands/publish/index.js @@ -0,0 +1,100 @@ +import fs from "node:fs" +import path from "node:path" +import yoctoSpinner from "yocto-spinner" +import { fileFromPath } from "formdata-node/file-from-path" +import FormData from "form-data" + +import Cache from "../../classes/cache.js" +import getUploadsPaths from "../../utils/getUploadsPaths.js" +import compressFiles from "../../utils/compressFiles.js" + +import Request from "comty.js/dist/request.js" + +export default { + cmd: "publish", + arguments: [ + { + argument: "", + description: "Set the current working directory", + }, + ], + fn: async (customCwd) => { + const token = global.config.get("auth").token + const projectFolder = customCwd ?? process.cwd() + const pkgJsonPath = path.join(projectFolder, "package.json") + + let pkgJSON = null + + if (!fs.existsSync(pkgJsonPath)) { + console.error("package.json not found") + return 1 + } + + pkgJSON = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")) + console.log(`⚙️ Publishing ${pkgJSON.name}@${pkgJSON.version}`) + + const [temporalDir, destroyTemporalDir] = + await Cache.createTemporalDir() + + const spinner = yoctoSpinner({ text: "Loading…" }).start() + + try { + spinner.text = "Reading files..." + const paths = await getUploadsPaths(pkgJsonPath) + + const originPath = path.join(temporalDir, "origin") + const bundlePath = path.join(temporalDir, "bundle.7z") + + // copy files and dirs to origin path + spinner.text = "Copying files and directories" + for await (const file of paths) { + await fs.promises.cp( + file, + path.join(originPath, path.basename(file)), + { recursive: true }, + ) + } + + spinner.text = "Compressing files" + await compressFiles(`${originPath}/*`, bundlePath) + + // PUT to registry + const bodyData = new FormData() + + //bodyData.append("pkg", JSON.stringify(pkgJSON)) + bodyData.append("bundle", fs.createReadStream(bundlePath)) + + spinner.text = "Publishing extension" + const response = await Request.default({ + method: "PUT", + url: "/extensions/publish", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "multipart/form-data", + pkg: JSON.stringify(pkgJSON), + }, + data: bodyData, + }).catch((error) => { + throw new Error( + `Failed to publish extension: ${error.response?.data?.error ?? error.message}`, + ) + }) + + // cleanup + spinner.text = "Cleaning up" + await destroyTemporalDir() + + spinner.success(`Ok!`) + + console.log(response.data) + return 0 + } catch (error) { + await destroyTemporalDir() + + spinner.error(`${error.message}`) + console.error(error) + + return 1 + } + }, +} diff --git a/packages/cli/src/commands/template/index.js b/packages/cli/src/commands/template/index.js new file mode 100644 index 00000000..01e91bd0 --- /dev/null +++ b/packages/cli/src/commands/template/index.js @@ -0,0 +1,3 @@ +export default { + cmd: "template", +} diff --git a/packages/cli/src/index.js b/packages/cli/src/index.js new file mode 100755 index 00000000..810a263b --- /dev/null +++ b/packages/cli/src/index.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +import fs from "node:fs" +import path from "node:path" +import { Command } from "commander" +import { createClient } from "comty.js" +import SessionModel from "comty.js/dist/models/session/index.js" + +import Cache from "./classes/cache.js" +import Config from "./classes/config.js" + +import readCommandsFiles from "./utils/readCommandsFiles.js" +import importDefaults from "./utils/importDefaults.js" +import buildCommands from "./utils/buildCommands.js" +import authorizeAccount from "./utils/authorizeAccount.js" + +const commandsPath = path.resolve(import.meta.dirname, "commands") +const packageJsonPath = path.resolve(import.meta.dirname, "../package.json") + +// only for development +if (process.env.NODE_ENV === "development") { + process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0 +} + +async function main() { + let packageJson = await fs.promises.readFile(packageJsonPath, "utf8") + packageJson = JSON.parse(packageJson) + + global.config = new Config() + global.comtyClient = createClient({ + origin: + process.env.NODE_ENV === "production" + ? "https://api.comty.app" + : "https://indev.comty.app/api", + }) + + await global.config.initialize() + await Cache.initialize() + + if (!global.config.get("auth")) { + console.log("No auth found, authentication required...") + + await authorizeAccount() + } + + SessionModel.default.token = global.config.get("auth").token + + let program = new Command() + + program + .name(packageJson.name) + .description(packageJson.description) + .version(packageJson.version) + + let commands = await readCommandsFiles(commandsPath) + commands = await importDefaults(commands) + program = await buildCommands(commands, program) + + program.parse() + + return 0 +} + +main() diff --git a/packages/cli/src/utils/authorizeAccount.js b/packages/cli/src/utils/authorizeAccount.js new file mode 100644 index 00000000..4719749e --- /dev/null +++ b/packages/cli/src/utils/authorizeAccount.js @@ -0,0 +1,25 @@ +import * as prompts from "@inquirer/prompts" +import AuthModel from "comty.js/dist/models/auth/index.js" + +export default function authorizeAccount() { + return new Promise(async (resolve, reject) => { + const username = await prompts.input({ + message: "username or email >", + }) + + const password = await prompts.password({ + message: "password >", + }) + + try { + const result = await AuthModel.default.login({ username, password }) + console.log("✅ Logged in successfully") + + await global.config.set("auth", result) + resolve(result) + } catch (error) { + console.error(`⛔ Failed to login: ${error.response.data.error}`) + authorizeAccount() + } + }) +} diff --git a/packages/cli/src/utils/buildCommands.js b/packages/cli/src/utils/buildCommands.js new file mode 100644 index 00000000..7efa0a13 --- /dev/null +++ b/packages/cli/src/utils/buildCommands.js @@ -0,0 +1,30 @@ +export default async function buildCommands(commands, program) { + for await (const command of commands) { + if (typeof command.fn !== "function") { + continue + } + + const commandInstance = program.command(command.cmd).action(command.fn) + + if (command.description) { + commandInstance.description(command.description) + } + + if (command.options) { + for (const option of command.options) { + commandInstance.option(option.option, option.description) + } + } + + if (command.arguments) { + for (const argument of command.arguments) { + commandInstance.argument( + argument.argument, + argument.description, + ) + } + } + } + + return program +} diff --git a/packages/cli/src/utils/compressFiles.js b/packages/cli/src/utils/compressFiles.js new file mode 100644 index 00000000..9845e57b --- /dev/null +++ b/packages/cli/src/utils/compressFiles.js @@ -0,0 +1,21 @@ +import fs from "node:fs" +import sevenzip from "7zip-min" + +export default async function compressFiles(origin, output) { + // check if out file exists + if (fs.existsSync(output)) { + fs.unlinkSync(output) + } + + await new Promise((resolve, reject) => { + sevenzip.pack(origin, output, (err) => { + if (err) { + return reject(err) + } + + return resolve(output) + }) + }) + + return output +} diff --git a/packages/cli/src/utils/getUploadsPaths.js b/packages/cli/src/utils/getUploadsPaths.js new file mode 100644 index 00000000..37d5ccaf --- /dev/null +++ b/packages/cli/src/utils/getUploadsPaths.js @@ -0,0 +1,23 @@ +import fs from "node:fs" +import path from "node:path" +import { glob } from "glob" + +export default async function getUploadsPaths(pkgJsonPath) { + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")) + const projectFolder = path.dirname(pkgJsonPath) + //const gitIgnore = await readGitIgnore(projectFolder) + + let globs = [] + + if (Array.isArray(pkgJson.files)) { + globs.push(...pkgJson.files) + } + + globs = globs.map((glob) => path.resolve(projectFolder, glob)) + + globs = await glob(globs, { cwd: projectFolder }) + + globs.push(pkgJsonPath) + + return globs +} diff --git a/packages/cli/src/utils/importDefaults.js b/packages/cli/src/utils/importDefaults.js new file mode 100644 index 00000000..a3d70a44 --- /dev/null +++ b/packages/cli/src/utils/importDefaults.js @@ -0,0 +1,10 @@ +export default async function importDefaults(commands) { + const result = [] + + for await (const command of commands) { + const commandModule = await import(command) + result.push(commandModule.default) + } + + return result +} diff --git a/packages/cli/src/utils/readCommandsFiles.js b/packages/cli/src/utils/readCommandsFiles.js new file mode 100644 index 00000000..c1b3ca60 --- /dev/null +++ b/packages/cli/src/utils/readCommandsFiles.js @@ -0,0 +1,18 @@ +import fs from "node:fs" +import path from "node:path" + +export default async function readCommandsFiles(from) { + const result = [] + let files = await fs.promises.readdir(from) + + for (const file of files) { + const filePath = path.join(from, file) + const stat = await fs.promises.stat(filePath) + + if (stat.isDirectory()) { + result.push(path.resolve(from, file, "index.js")) + } + } + + return result +} diff --git a/packages/cli/src/utils/readGitIgnore.js b/packages/cli/src/utils/readGitIgnore.js new file mode 100644 index 00000000..07ccb408 --- /dev/null +++ b/packages/cli/src/utils/readGitIgnore.js @@ -0,0 +1,12 @@ +import fs from "node:fs" +import path from "node:path" + +export default async function readGitIgnore(projectFolder) { + const gitIgnorePath = path.join(projectFolder, ".gitignore") + + if (!fs.existsSync(gitIgnorePath)) { + return [] + } + + return fs.readFileSync(gitIgnorePath, "utf8").split("\n").filter(Boolean) +}