diff --git a/bun.lockb b/bun.lockb index 6adfd27..812bfba 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 398cb75..3b33b6e 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "format:prettier": "prettier --write .", "format:cspell": "cspell **/*", "prepare": "node .husky/install.mjs", + "deploy": "tsx ./scripts/deploy.ts", "deploy-dev": "wrangler deploy --env dev", "deploy-production": "wrangler deploy --env production", "worker": "wrangler dev --env dev --port 8787", @@ -30,7 +31,8 @@ "knip-ci": "knip --no-exit-code --reporter json --config .github/knip.ts", "jest:test": "jest --coverage", "plugin:hello-world": "tsx tests/__mocks__/hello-world-plugin.ts", - "setup-kv": "bun --env-file=.dev.vars deploy/setup-kv-namespace.ts" + "setup-kv": "bun --env-file=.dev.vars deploy/setup-kv-namespace.ts", + "setup": "tsx ./scripts/setup.ts" }, "keywords": [ "typescript", @@ -52,7 +54,7 @@ "@octokit/webhooks": "13.3.0", "@octokit/webhooks-types": "7.5.1", "@sinclair/typebox": "^0.33.20", - "@ubiquity-os/plugin-sdk": "^1.0.11", + "@ubiquity-os/plugin-sdk": "^1.0.11", "dotenv": "16.4.5", "typebox-validators": "0.3.5", "yaml": "2.4.5" @@ -65,6 +67,7 @@ "@cspell/dict-software-terms": "3.4.6", "@cspell/dict-typescript": "3.1.5", "@eslint/js": "9.7.0", + "@inquirer/prompts": "^7.1.0", "@jest/globals": "29.7.0", "@mswjs/data": "0.16.1", "@mswjs/http-middleware": "0.10.1", @@ -72,6 +75,7 @@ "@swc/jest": "0.2.36", "@types/jest": "29.5.12", "@types/node": "20.14.10", + "@types/node-rsa": "^1.1.4", "cspell": "8.9.0", "esbuild": "0.23.0", "eslint": "9.7.0", @@ -84,7 +88,10 @@ "jest-junit": "16.0.0", "knip": "5.26.0", "lint-staged": "15.2.7", + "node-rsa": "^1.1.1", "npm-run-all": "4.1.5", + "open": "^10.1.0", + "ora": "^8.1.1", "prettier": "3.3.3", "smee-client": "^2.0.4", "toml": "3.0.0", diff --git a/scripts/deploy.ts b/scripts/deploy.ts new file mode 100644 index 0000000..7d593a6 --- /dev/null +++ b/scripts/deploy.ts @@ -0,0 +1,230 @@ +import { confirm, input, select } from "@inquirer/prompts"; +import { exec, execSync, spawn } from "child_process"; +import { readFileSync, unlinkSync, writeFileSync } from "fs"; +import ora from "ora"; +import path from "path"; +import { parse } from "dotenv"; +import toml from "toml"; +// @ts-expect-error No typings exist for this package +import * as tomlify from "tomlify-j0.4"; + +interface WranglerConfiguration { + name: string; + env: { + [env: string]: { + kv_namespaces?: { + id: string; + binding: string; + }[]; + }; + }; + kv_namespaces: { + id: string; + binding: string; + }[]; +} + +const WRANGLER_PATH = path.resolve(__dirname, "..", "node_modules/.bin/wrangler"); +const WRANGLER_TOML_PATH = path.resolve(__dirname, "..", "wrangler.toml"); +const BINDING_NAME = "PLUGIN_CHAIN_STATE"; + +function checkIfWranglerInstalled() { + return new Promise((resolve) => { + exec(`${WRANGLER_PATH} --version`, (err, stdout, stderr) => { + if (err || stderr) { + resolve(false); + } + resolve(true); + }); + }); +} + +function checkIfWranglerIsLoggedIn() { + return new Promise((resolve, reject) => { + exec(`${WRANGLER_PATH} whoami`, (err, stdout, stderr) => { + if (err) { + reject(err); + } + if (stdout.includes("You are not authenticated") || stderr.includes("You are not authenticated")) { + resolve(false); + } else { + resolve(true); + } + }); + }); +} + +function wranglerLogin() { + return new Promise((resolve, reject) => { + const loginProcess = spawn(WRANGLER_PATH, ["login"], { stdio: "inherit" }); + + loginProcess.on("close", (code) => { + if (code !== 0) { + reject(); + } else { + resolve(); + } + }); + }); +} + +function wranglerBulkSecrets(env: string | null, filePath: string) { + return new Promise((resolve, reject) => { + const args = env ? ["--env", env] : []; + const process = spawn(WRANGLER_PATH, ["secret", "bulk", filePath, ...args], { stdio: "inherit" }); + + process.on("close", (code) => { + if (code !== 0) { + reject(); + } else { + resolve(); + } + }); + process.on("error", (err) => { + reject(err); + }); + }); +} + +function wranglerDeploy(env: string | null) { + return new Promise((resolve, reject) => { + const args = env ? ["--env", env] : []; + const process = spawn(WRANGLER_PATH, ["deploy", ...args], { stdio: "inherit" }); + + process.on("close", (code) => { + if (code !== 0) { + reject(); + } else { + resolve(); + } + }); + }); +} + +function wranglerKvNamespace(projectName: string, namespace: string) { + const kvList = JSON.parse(execSync(`${WRANGLER_PATH} kv namespace list`).toString()) as { id: string; title: string }[]; + const existingNamespace = kvList.find((o) => o.title === namespace || o.title === `${projectName}-${namespace}`); + if (existingNamespace) { + return existingNamespace.id; + } + + const res = execSync(`${WRANGLER_PATH} kv namespace create ${namespace}`).toString(); + + const newId = res.match(/id = \s*"([^"]+)"/)?.[1]; + if (!newId) { + console.log(res); + throw new Error(`The new ID could not be found.`); + } + return newId; +} + +void (async () => { + const spinner = ora("Checking if Wrangler is installed").start(); + const wranglerInstalled = await checkIfWranglerInstalled(); + if (!wranglerInstalled) { + spinner.fail("Wrangler is not installed. Please install it before running this script"); + process.exit(1); + } else { + spinner.succeed("Wrangler is installed"); + } + + spinner.start("Checking if Wrangler is logged in"); + const wranglerLoggedIn = await checkIfWranglerIsLoggedIn(); + if (!wranglerLoggedIn) { + spinner.warn("Wrangler is not logged in. Please login to Wrangler"); + await wranglerLogin(); + spinner.succeed("Wrangler is now logged in"); + } else { + spinner.succeed("Wrangler is logged in"); + } + + spinner.start("Searching environments in wrangler.toml"); + const wranglerToml: WranglerConfiguration = toml.parse(readFileSync(WRANGLER_TOML_PATH, "utf-8")); + if (!wranglerToml) { + spinner.fail("Error parsing wrangler.toml"); + process.exit(1); + } + const envs = Object.keys(wranglerToml.env ?? {}); + let selectedEnv: string | null = null; + if (envs.length === 0) { + spinner.warn("No environments found, choosing default environment"); + } else if (envs.length === 1) { + spinner.warn(`Only one environment found: ${envs[0]}`); + selectedEnv = envs[0]; + } else if (envs.length > 1) { + spinner.stop(); + selectedEnv = await select({ + message: "Select the environment to deploy to:", + choices: envs, + }); + } + + const willSetSecrets = await confirm({ + message: "Do you want to set secrets?", + default: true, + }); + if (willSetSecrets) { + const envFile = await input({ + message: "Enter the name of the env file to use:", + default: `.${selectedEnv}.vars`, + }); + const spinner = ora("Setting secrets").render(); + try { + const env = readFileSync(path.resolve(__dirname, "..", envFile), { encoding: "utf-8" }); + const parsedEnv = parse(env); + if (parsedEnv) { + const tmpPath = path.resolve(__dirname, "..", `${envFile}.json.tmp`); + writeFileSync(tmpPath, JSON.stringify(parsedEnv)); + await wranglerBulkSecrets(selectedEnv, tmpPath); + unlinkSync(tmpPath); // deletes the temporary file + spinner.succeed("Secrets set successfully"); + } + } catch (err) { + spinner.fail(`Error setting secrets: ${err}`); + process.exit(1); + } + } + + spinner.start("Setting up KV namespace"); + try { + const kvNamespace = selectedEnv ? `${selectedEnv}-plugin-chain-state` : `plugin-chain-state`; + const namespaceId = wranglerKvNamespace(wranglerToml.name, kvNamespace); + if (selectedEnv) { + const existingBinding = wranglerToml.env[selectedEnv]?.kv_namespaces?.find((o) => o.binding === BINDING_NAME); + if (!existingBinding) { + wranglerToml.env[selectedEnv] = wranglerToml.env[selectedEnv] ?? {}; + wranglerToml.env[selectedEnv].kv_namespaces = wranglerToml.env[selectedEnv].kv_namespaces ?? []; + wranglerToml.env[selectedEnv].kv_namespaces?.push({ + id: namespaceId, + binding: BINDING_NAME, + }); + } else { + existingBinding.id = namespaceId; + } + } else { + const existingBinding = wranglerToml.kv_namespaces.find((o) => o.binding === BINDING_NAME); + if (!existingBinding) { + wranglerToml.kv_namespaces.push({ + id: namespaceId, + binding: BINDING_NAME, + }); + } else { + existingBinding.id = namespaceId; + } + } + writeFileSync(WRANGLER_TOML_PATH, tomlify.toToml(wranglerToml)); + spinner.succeed(`Using KV namespace ${kvNamespace} with ID: ${namespaceId}`); + } catch (err) { + spinner.fail(`Error setting up KV namespace: ${err}`); + process.exit(1); + } + + spinner.start("Deploying to Cloudflare Workers").stopAndPersist(); + try { + await wranglerDeploy(selectedEnv); + spinner.succeed("Deployed successfully"); + } catch (err) { + spinner.fail(`Error deploying: ${err}`); + process.exit(1); + } +})(); diff --git a/scripts/index.html b/scripts/index.html new file mode 100644 index 0000000..9e7b2d9 --- /dev/null +++ b/scripts/index.html @@ -0,0 +1,39 @@ + + + + + + Register a Github App + + + +
+

Click on the button and follow instructions

+
+
+ +
+
+ + + + diff --git a/scripts/redirect.html b/scripts/redirect.html new file mode 100644 index 0000000..09706f2 --- /dev/null +++ b/scripts/redirect.html @@ -0,0 +1,34 @@ + + + + + + Register a Github App + + + +
+

Successfully created a Github App!

+ +
+ +
+
+ + diff --git a/deploy/setup-kv-namespace.ts b/scripts/setup-kv-namespace.ts similarity index 100% rename from deploy/setup-kv-namespace.ts rename to scripts/setup-kv-namespace.ts diff --git a/scripts/setup.ts b/scripts/setup.ts new file mode 100644 index 0000000..97a971f --- /dev/null +++ b/scripts/setup.ts @@ -0,0 +1,168 @@ +import http from "http"; +import fs from "fs"; +import path from "path"; +import open from "open"; +import ora, { Ora } from "ora"; +import NodeRSA from "node-rsa"; +import { Octokit } from "@octokit/core"; +import { confirm, input } from "@inquirer/prompts"; + +const PORT = 3000; +const DEV_ENV_FILE = ".dev.vars"; + +const manifestTemplate = { + url: "https://github.com/ubiquity-os/ubiquity-os-kernel", + hook_attributes: { + url: "", + }, + redirect_url: `http://localhost:${PORT}/redirect`, + public: true, + default_permissions: { + actions: "write", + issues: "write", + pull_requests: "write", + contents: "write", + members: "read", + }, + default_events: ["issues", "issue_comment", "label", "pull_request", "push", "repository", "repository_dispatch"], +}; + +class GithubAppSetup { + private _octokit: Octokit; + private _server: http.Server; + private _spinner: Ora; + private _url = new URL(`http://localhost:3000`); + private _env = { + ENVIRONMENT: "production", + APP_ID: "", + APP_PRIVATE_KEY: "", + APP_WEBHOOK_SECRET: "", + WEBHOOK_PROXY_URL: `https://smee.io/ubiquityos-kernel-${this.generateRandomString(16)}`, + }; + + constructor() { + this._octokit = new Octokit(); + this._server = http.createServer(this.handleRequest.bind(this)); + this._spinner = ora("Waiting for Github App creation"); + } + + start() { + this._server.listen(this._url.port, () => { + void open(this._url.toString()); + console.log(`If it doesn't open automatically, open this website and follow instructions: ${this._url}`); + this._spinner.start(); + }); + } + + stop() { + this._spinner.stop(); + this._server.close(); + } + + async handleRequest(req: http.IncomingMessage, res: http.ServerResponse) { + try { + const url = new URL(`http://localhost${req.url}`); + if (url.pathname === "/") { + await this.handleIndexRequest(url, req, res); + } else if (url.pathname === "/redirect" && req.method === "GET") { + await this.handleRedirectRequest(url, req, res); + } else { + this.send404Response(res); + } + } catch (error) { + console.error(error); + this.send500Response(res); + } + } + + send404Response(res: http.ServerResponse) { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("404 Not Found"); + } + + send500Response(res: http.ServerResponse) { + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end("Server Error"); + } + + sendHtml(res: http.ServerResponse, content: string) { + res.writeHead(500, { "Content-Type": "text/html" }); + res.end(content); + } + + fileExists(file: string) { + try { + fs.accessSync(file); + return true; + } catch (error) { + return false; + } + } + + generateRandomString(length: number) { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let randomString = ""; + for (let i = 0; i < length; i++) { + randomString += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return randomString; + } + + saveEnv(file: string, env: Record) { + const envContent = Object.entries(env) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); + + fs.writeFileSync(path.join(__dirname, "..", file), envContent, { flag: "a" }); + } + + async handleIndexRequest(url: URL, req: http.IncomingMessage, res: http.ServerResponse) { + const manifest = { ...manifestTemplate }; + manifest.hook_attributes.url = this._env.WEBHOOK_PROXY_URL; + + const htmlContent = fs.readFileSync(path.join(__dirname, "index.html")).toString().replace("{{ MANIFEST }}", JSON.stringify(manifest)); + this.sendHtml(res, htmlContent); + } + + async handleRedirectRequest(url: URL, req: http.IncomingMessage, res: http.ServerResponse) { + const code = url.searchParams.get("code"); + if (!code) { + return this.send404Response(res); + } + + const { data } = await this._octokit.request("POST /app-manifests/{code}/conversions", { + code, + }); + + const htmlContent = fs.readFileSync(path.join(__dirname, "redirect.html")).toString().replace("{{ APP_URL }}", data.html_url); + this.sendHtml(res, htmlContent); + + this._server.close(); + this._spinner.succeed("Github App created successfully"); + + // convert from pkcs1 to pkcs8 + const privateKey = new NodeRSA(data.pem, "pkcs1-private-pem"); + const privateKeyPkcs8 = privateKey.exportKey("pkcs8-private-pem").replaceAll("\n", "\\n"); + + this._env.APP_ID = data.id.toString(); + this._env.APP_PRIVATE_KEY = privateKeyPkcs8; + this._env.APP_WEBHOOK_SECRET = data.webhook_secret ?? ""; + + const envFile = await input({ message: "Enter file name to save env:", default: DEV_ENV_FILE }); + if (this.fileExists(envFile) && !(await confirm({ message: "File already exist. Do you want to append to it?", default: false }))) { + return; + } + this.saveEnv(envFile, this._env); + + process.exit(); + } +} + +const setup = new GithubAppSetup(); +setup.start(); + +process.on("SIGINT", () => { + setup.stop(); + console.log("\nProcess interrupted. Exiting gracefully..."); + process.exit(); +});