diff --git a/.cspell.json b/.cspell.json index c9a8062..7ab02ee 100644 --- a/.cspell.json +++ b/.cspell.json @@ -8,5 +8,5 @@ "dictionaries": ["typescript", "node", "software-terms"], "import": ["@cspell/dict-typescript/cspell-ext.json", "@cspell/dict-node/cspell-ext.json", "@cspell/dict-software-terms"], "ignoreRegExpList": ["[0-9a-fA-F]{6}"], - "ignoreWords": ["ubiquibot", "Supabase", "supabase", "SUPABASE", "sonarjs", "mischeck"] + "ignoreWords": ["ubiquibot", "Supabase", "supabase", "SUPABASE", "sonarjs", "mischeck", "Typebox"] } diff --git a/.github/knip.ts b/.github/knip.ts index 0ea0901..1db99c4 100644 --- a/.github/knip.ts +++ b/.github/knip.ts @@ -6,7 +6,19 @@ const config: KnipConfig = { ignore: ["src/types/config.ts", "**/__mocks__/**", "**/__fixtures__/**"], ignoreExportsUsedInFile: true, // eslint can also be safely ignored as per the docs: https://knip.dev/guides/handling-issues#eslint--jest - ignoreDependencies: ["@mswjs/data", "@supabase/supabase-js", "@ubiquity-os/ubiquity-os-kernel", "ajv", "yaml", "simple-git", "@actions/core", "esbuild"], + ignoreDependencies: [ + "@mswjs/data", + "@supabase/supabase-js", + "@ubiquity-os/ubiquity-os-kernel", + "ajv", + "yaml", + "simple-git", + "@actions/core", + "esbuild", + "@ubiquity-os/plugin-sdk", + "markdown-it", + "@types/markdown-it", + ], eslint: true, }; diff --git a/README.md b/README.md index fea8d1d..0602cd9 100644 --- a/README.md +++ b/README.md @@ -48,3 +48,15 @@ The browser automatically URI encodes it: ``` ###### Example from `command-start-stop/manifest.json` + +## How to run + +1. Clone the repository +2. Run `yarn` to install dependencies +3. OAuth: Obtain your _GitHub OAuth App_ client ID and secret from your OAuth app settings, and set the callback URL to match the one given by Supabase when enabling GitHub provider OAuth. +4. Replace the hardcoded `SUPABASE_URL` and `SUPABASE_KEY` in `build/esbuild-build.ts` with your Supabase URL and key (Optionally use `.env` and use `process.env` instead.) +5. Run `yarn start` and visit `localhost:8080` in your browser. +6. Once logged in you should see the orgs that you own. +7. Select an org > select a config (dev | prod) > select a plugin > edit/add/remove > push to GitHub. + +TODO: Update readme with a better overview of the project. diff --git a/build/esbuild-build.ts b/build/esbuild-build.ts index 3d772f1..6927822 100644 --- a/build/esbuild-build.ts +++ b/build/esbuild-build.ts @@ -19,8 +19,9 @@ export const esbuildOptions: BuildOptions = { define: createEnvDefines([], { SUPABASE_STORAGE_KEY: generateSupabaseStorageKey(), NODE_ENV: process.env.NODE_ENV || "development", - SUPABASE_URL: "https://wfzpewmlyiozupulbuur.supabase.co", + SUPABASE_URL: process.env.SUPABASE_URL || "https://wfzpewmlyiozupulbuur.supabase.co", SUPABASE_ANON_KEY: + process.env.SUPABASE_ANON_KEY || /* cspell:disable-next-line */ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6IndmenBld21seWlvenVwdWxidXVyIiwicm9sZSI6ImFub24iLCJpYXQiOjE2OTU2NzQzMzksImV4cCI6MjAxMTI1MDMzOX0.SKIL3Q0NOBaMehH0ekFspwgcu3afp3Dl9EDzPqs1nKs", }), @@ -57,7 +58,7 @@ function createEnvDefines(environmentVariables: string[], generatedAtBuild: Reco } export function generateSupabaseStorageKey(): string | null { - const SUPABASE_URL = "https://wfzpewmlyiozupulbuur.supabase.co"; + const SUPABASE_URL = process.env.SUPABASE_URL || "https://wfzpewmlyiozupulbuur.supabase.co"; if (!SUPABASE_URL) { console.error("SUPABASE_URL environment variable is not set"); return null; diff --git a/package.json b/package.json index ab3f4ea..c57f2d1 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,11 @@ "dependencies": { "@octokit/rest": "^21.0.2", "@supabase/supabase-js": "^2.46.1", + "@ubiquity-os/plugin-sdk": "^1.0.11", "@ubiquity-os/ubiquity-os-kernel": "^2.5.3", "ajv": "^8.17.1", "dotenv": "^16.4.4", + "markdown-it": "^14.1.0", "yaml": "^2.6.0" }, "devDependencies": { @@ -50,6 +52,7 @@ "@jest/globals": "29.7.0", "@mswjs/data": "0.16.1", "@types/jest": "^29.5.12", + "@types/markdown-it": "^14.1.2", "@types/node": "20.14.5", "cspell": "8.14.4", "cypress": "13.6.6", diff --git a/static/index.html b/static/index.html index 79f76d5..fa71ad7 100644 --- a/static/index.html +++ b/static/index.html @@ -26,7 +26,7 @@ diff --git a/static/main.ts b/static/main.ts index fcd9aa7..9b7e82a 100644 --- a/static/main.ts +++ b/static/main.ts @@ -1,71 +1,39 @@ import { AuthService } from "./scripts/authentication"; -import { ManifestDecoder } from "./scripts/decode-manifest"; import { ManifestFetcher } from "./scripts/fetch-manifest"; import { ManifestRenderer } from "./scripts/render-manifest"; -import { OrgWithInstall } from "./types/github"; +import { renderOrgPicker } from "./scripts/rendering/org-select"; import { toastNotification } from "./utils/toaster"; async function handleAuth() { const auth = new AuthService(); await auth.renderGithubLoginButton(); - const token = await auth.getGitHubAccessToken(); - if (!token) { - // await auth.signInWithGithub(); force a login? - } - return auth; } export async function mainModule() { const auth = await handleAuth(); - const decoder = new ManifestDecoder(); const renderer = new ManifestRenderer(auth); - const search = window.location.search.substring(1); - - if (search) { - const decodedManifest = await decoder.decodeManifestFromSearch(search); - return renderer.renderManifest(decodedManifest); - } + renderer.manifestGuiBody.dataset.loading = "false"; try { - /** - * "ubiquity-os", "ubiquity-os-marketplace" === dev config - * "ubiquity" === prod config - */ const ubiquityOrgsToFetchOfficialConfigFrom = ["ubiquity-os"]; - const fetcher = new ManifestFetcher(ubiquityOrgsToFetchOfficialConfigFrom, auth.octokit, decoder); - const cache = fetcher.checkManifestCache(); - if (auth.isActiveSession()) { - const userOrgs = await auth.getGitHubUserOrgs(); - localStorage.setItem("userOrgs", JSON.stringify(userOrgs)); + const fetcher = new ManifestFetcher(ubiquityOrgsToFetchOfficialConfigFrom, auth.octokit); - const appInstallations = await auth.octokit?.apps.listInstallationsForAuthenticatedUser(); - const installs = appInstallations?.data.installations; + if (auth.isActiveSession()) { + renderer.manifestGuiBody.dataset.loading = "true"; + const killNotification = toastNotification("Fetching manifest data...", { type: "info", shouldAutoDismiss: true }); - const orgsWithInstalls = userOrgs.map((org) => { - const orgInstall = installs?.find((install) => { - if (install.account && "login" in install.account) { - return install.account.login.toLowerCase() === org.toLowerCase(); - } - return false; - }); - return { - org, - install: orgInstall, - }; - }) as OrgWithInstall[]; - renderer.renderOrgPicker(orgsWithInstalls.map((org) => org.org)); + const userOrgs = await auth.getGitHubUserOrgs(); const userOrgRepos = await auth.getGitHubUserOrgRepos(userOrgs); localStorage.setItem("orgRepos", JSON.stringify(userOrgRepos)); + renderOrgPicker(renderer, userOrgs); - if (Object.keys(cache).length === 0) { - const manifestCache = await fetcher.fetchMarketplaceManifests(); - localStorage.setItem("manifestCache", JSON.stringify(manifestCache)); - // this is going to extract URLs from our official config which we'll inject into `- plugin: ...` - await fetcher.fetchOfficialPluginConfig(); - } + await fetcher.fetchMarketplaceManifests(); + await fetcher.fetchOfficialPluginConfig(); + renderer.manifestGuiBody.dataset.loading = "false"; + killNotification(); } else { - renderer.renderOrgPicker([]); + renderOrgPicker(renderer, []); } } catch (error) { if (error instanceof Error) { @@ -76,10 +44,4 @@ export async function mainModule() { } } -mainModule() - .then(() => { - console.log("mainModule loaded"); - }) - .catch((error) => { - console.error(error); - }); +mainModule().catch(console.error); diff --git a/static/manifest-gui.css b/static/manifest-gui.css index 01ae511..1f88ee5 100644 --- a/static/manifest-gui.css +++ b/static/manifest-gui.css @@ -1,7 +1,8 @@ html { background-image: radial-gradient(#000000, #101010); - background-color: #101010; /* height: 100vh; */ + background-color: #101010; } + body { margin: 0; color: #fff; @@ -9,9 +10,6 @@ body { background-image: url(./grid-25.png); height: calc(100vh - 24px); } -controls button { - background: #101010; -} header { margin: 24px; @@ -20,73 +18,204 @@ header { white-space: nowrap; mask-image: linear-gradient(90deg, #000, transparent); } + header:hover { opacity: 1; } + header #uos-logo-container { padding-right: 12px; } + header > * { display: inline-block; vertical-align: middle; } + header h1 { margin: 0; font-weight: 400; font-size: 24px; } + header #uos-logo { height: 36px; } + header #uos-logo path { fill: #fff; } + +#controls { + position: fixed; + right: 24px; + top: 24px; +} + +#controls button { + background: #101010; +} + +#viewport { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: calc(100vh - 96px); +} + +#viewport-cell { + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + padding: clamp(16px, 2vw, 32px); + gap: 24px; +} + +.readme-container { + width: auto; + max-width: 700px; + user-select: text; + background-image: linear-gradient(0deg, #101010, #202020); + border-radius: 4px; + margin: 0 auto; + box-shadow: 0 24px 48px #000000; + overflow: auto; + padding: 16px 24px; + color: #fff; + font-family: "Proxima Nova", sans-serif; + line-height: 1.6; + opacity: 0.85; + max-height: calc(100vh - 192px); + scrollbar-width: thin; + scrollbar-color: #303030 #101010; +} + +.readme-container p { + color: #ccc; + margin-bottom: 16px; +} + +.readme-container a { + color: #1e90ff; + text-decoration: none; +} + +.readme-container a:hover { + text-decoration: underline; +} + +.readme-container code { + background-color: #202020; + padding: 2px 4px; + border-radius: 4px; + color: #1e90ff; +} + +.readme-container pre { + background-color: #202020; + padding: 16px; + border-radius: 4px; + overflow: auto; + color: #ccc; + margin-bottom: 16px; +} + #manifest-gui { + width: auto; + min-width: 400px; + max-width: 1300px; user-select: none; background-image: linear-gradient(0deg, #101010, #202020); - margin-top: 16px; - border-radius: 4px; margin: 0 auto; + border-radius: 4px; box-shadow: 0 24px 48px #000000; overflow: hidden; - opacity: 0.75; - transition: 0.25s opacity cubic-bezier(0, 1, 1, 1); opacity: 0; - width: 400px; + transition: opacity 0.25s cubic-bezier(0, 1, 1, 1); } + #manifest-gui.plugin-editor { - width: 800px; + width: 100%; } + #manifest-gui.rendered { opacity: 0.75; } + #manifest-gui:hover { opacity: 1; } -.table-data-header { + +#manifest-gui > tr > td > * { + vertical-align: middle; + margin: 8px; +} + +#manifest-gui thead td { + border-bottom: 1px solid #303030; +} + +#manifest-gui thead td:last-of-type { text-align: right; + padding: 8px 16px; + color: #808080; +} + +#manifest-gui tfoot tr td button { + width: calc(33% - 4px); + display: inline-block; + font-weight: 900; + margin: 0 2px; +} + +#manifest-gui pre { + margin: 0 0 16px; +} + +.config-input { + width: calc(100% - 32px); + padding: 8px 16px; + background-color: #101010; + color: #fff; + border-radius: 4px; + font-size: 16px; + font-family: "Proxima Nova", sans-serif; + border-bottom: 1px solid #303030; +} + +.config-input:hover { + border-color: #606060; +} + +.config-input:focus { + outline: none; + border-color: #808080; +} + +.table-data-header { color: grey; text-transform: capitalize; - text-rendering: geometricPrecision; /* padding-left:8px; */ + text-rendering: geometricPrecision; + display: flex; + justify-content: right; + border-top: 1px solid #303030; } -/* #manifest-gui .table-data-header>div{opacity:0;width:0;transition:1s width ease;overflow: hidden;} */ -/* #manifest-gui:hover .table-data-header>div{opacity:1;width:100px} */ -.table-data-header > div { + +.table-data-header { padding: 8px 16px; } + .table-data-value { user-select: text; } -.table-data-value > div { + +.table-data-value > input, +textarea { padding: 8px 16px; - border-bottom-left-radius: 4px; - border-left: 1px solid #303030; - border-bottom: 1px solid #303030; /* font-weight: bold; */ -} -#manifest-gui > tr > td > * { - vertical-align: middle; - margin: 8px; } + #controls { position: fixed; right: 24px; @@ -97,7 +226,7 @@ button { background: 0 0; color: #fff; font-size: 16px; - border: none; /* background-color: transparent; */ + border: none; padding: 8px 16px; border-radius: 4px; opacity: 0.75; @@ -111,30 +240,33 @@ button:hover { button:active { background-color: #606060; } -#manifest-gui thead td { - border-bottom: 1px solid #303030; /* text-align: end; */ -} -#manifest-gui thead td:last-of-type { - text-align: right; - padding: 8px 16px; - color: #808080; +button#reset-to-default { + background-color: #303030; + color: #fff; } -#viewport { - width: 100%; - display: table; - height: calc(100vh - ((36px + 8px) * 2) * 2); /* border-collapse: collapse; */ +button#reset-to-default:hover { + background-color: #404040; } -#viewport-cell { - height: 100%; - display: table-cell; - vertical-align: middle; /* width: 100%; */ +button#reset-to-default:active { + background-color: #505050; } -#manifest-gui tfoot tr td button { - width: calc(50% - 4px); - display: inline-block; - font-weight: 900; - margin: 0 2px; +button#reset-to-default::before { + content: "♻️"; +} +button#reset-to-default:hover::before { + content: "Use Defaults"; +} +button#reset-to-default:active::before { + content: "♻️♻️♻️♻️♻️"; +} + +button#remove.disabled { + background-color: #303030; + color: #808080; + cursor: not-allowed; + opacity: 0.5; + pointer-events: none; } button#remove::before { @@ -174,10 +306,6 @@ button#add:active::before { content: "+++"; } -#manifest-gui pre { - margin: 0 0 16px; -} - .picker-select { width: 100%; padding: 8px 16px; @@ -198,25 +326,74 @@ button#add:active::before { outline: none; border-color: #808080; } +select option { + background-color: #101010; + color: #fff; + font-family: "Proxima Nova", sans-serif; + display: flex; + justify-content: space-between; +} -.config-input { - width: calc(100% - 32px); - padding: 8px 16px; +.custom-select { + position: relative; + font-family: "Proxima Nova", sans-serif; + width: inherit; +} + +.select-selected { background-color: #101010; color: #fff; + padding: 8px 16px; border: 1px solid #303030; border-radius: 4px; - font-size: 16px; - font-family: "Proxima Nova", sans-serif; + cursor: pointer; + user-select: none; + position: relative; } -.config-input:hover { - border-color: #606060; +.select-selected::after { + content: ""; + position: absolute; + top: 18px; + right: 16px; + width: 0; + height: 0; + border: 6px solid transparent; + border-color: #fff transparent transparent transparent; } -.config-input:focus { - outline: none; - border-color: #808080; +.select-selected.select-arrow-active::after { + border-color: transparent transparent #fff transparent; + top: 12px; +} + +.select-items { + background-color: #101010; + border: 1px solid #303030; + border-radius: 8px; + z-index: 99; + width: inherit; + max-height: 400px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: #303030 #101010; +} + +.select-items .select-option { + color: #fff; + padding: 8px 16px; + display: flex; + justify-content: space-between; + cursor: pointer; + user-select: none; +} + +.select-items .select-option:hover { + background-color: #303030; +} + +.select-hide { + display: none; } .picker-container, @@ -259,23 +436,6 @@ button#add:active::before { margin-bottom: 16px; } -/* Select Elements */ -.picker-select { - width: 100%; - padding: 8px 16px; - background-color: #101010; - color: #fff; - border: 1px solid #303030; - border-radius: 4px; - font-size: 16px; - font-family: "Proxima Nova", sans-serif; -} - -.picker-select:focus { - outline: none; - border-color: #606060; -} - .save-button { appearance: none; background: #101010; diff --git a/static/scripts/authentication.ts b/static/scripts/authentication.ts index fd708e5..74e8660 100644 --- a/static/scripts/authentication.ts +++ b/static/scripts/authentication.ts @@ -65,7 +65,13 @@ export class AuthService { public async signInWithGithub(): Promise { const search = window.location.search; localStorage.setItem("manifest", search); - const { data } = await this.supabase.auth.signInWithOAuth({ provider: "github" }); + const { data } = await this.supabase.auth.signInWithOAuth({ + provider: "github", + options: { + scopes: "read:org read:user user:email repo", + redirectTo: `${window.location.href}`, + }, + }); if (!data) throw new Error("Failed to sign in with GitHub"); } diff --git a/static/scripts/config-parser.ts b/static/scripts/config-parser.ts index 9dfbe80..a02b21c 100644 --- a/static/scripts/config-parser.ts +++ b/static/scripts/config-parser.ts @@ -3,161 +3,182 @@ import { Plugin, PluginConfig } from "../types/plugins"; import { Octokit } from "@octokit/rest"; import { RequestError } from "@octokit/request-error"; import { toastNotification } from "../utils/toaster"; - -const CONFIG_PATH = ".github/.ubiquity-os.config.yml"; -const UBIQUITY_OS = ".ubiquity-os"; - +import { CONFIG_FULL_PATH, CONFIG_ORG_REPO } from "@ubiquity-os/plugin-sdk/constants"; + +/** + * Responsible for fetching, parsing, and updating the user's installed plugin configurations. + * + * - `configRepoExistenceCheck` checks if the user has a config repo and creates one if not + * - `repoFileExistenceCheck` checks if the user has a config file and creates one if not + * - `fetchUserInstalledConfig` fetches the user's installed config from the config repo + */ export class ConfigParser { repoConfig: string | null = null; repoConfigSha: string | null = null; newConfigYml: string | null = null; - async fetchUserInstalledConfig(org: string | null, repo: string | null, env: "development" | "production", octokit: Octokit | null) { - if (!org || !octokit) { - throw new Error("Missing required parameters"); + async configRepoExistenceCheck(org: string, repo: string, octokit: Octokit) { + if (!org || !repo) { + throw new Error("Organization or repo name not provided"); } - const content = this.loadConfig(); - if (!content) { - throw new Error("No content to push"); - } + let exists; try { - const existingConfig = await octokit.repos.getContent({ + await octokit.repos.get({ owner: org, - repo: repo ? repo : UBIQUITY_OS, - path: env === "production" ? CONFIG_PATH : CONFIG_PATH.replace(".yml", ".dev.yml"), - ref: repo ? "development" : "main", + repo, }); + exists = true; + } catch (error) { + console.log(error); + exists = false; + } - if (existingConfig && "content" in existingConfig.data) { - this.repoConfigSha = existingConfig.data.sha; - this.repoConfig = atob(existingConfig.data.content); - } - } catch (er) { - console.log(er); - if (er instanceof RequestError && er.status === 404) { - const msgParts = ["Could not find the", env, "config file in", repo ? repo : `your org: ${org}`, ", would you like to create one?"]; - - toastNotification(msgParts.join(" "), { - type: "success", - actionText: "Create", - action: async () => { - await this.handleMissingStorageBranchOrFile(octokit, org, repo); - }, + if (!exists) { + try { + await octokit.repos.createInOrg({ + name: repo, + description: "UbiquityOS Configuration Repo", + org, }); + + toastNotification("We noticed you don't have a '.ubiquity-os' config repo, so we created one for you.", { type: "success" }); + } catch (er) { + console.log(er); + throw new Error("Config repo creation failed"); } } + + return exists; } - parseConfig(config?: string | null): PluginConfig { - if (config && typeof config === "string") { - return YAML.parse(config); - } else { - return YAML.parse(this.loadConfig()); + async repoFileExistenceCheck(org: string, octokit: Octokit, repo: string, path: string) { + try { + const { data } = await octokit.repos.getContent({ + owner: org, + repo, + path, + }); + + return data; + } catch (error) { + console.error(error); } + + return null; } - async updateConfig(org: string, repo: string | null, env: "development" | "production", octokit: Octokit, option: "add" | "remove") { - let repoPlugins = this.parseConfig(this.repoConfig).plugins; - const newPlugins = this.parseConfig().plugins; + async fetchUserInstalledConfig(org: string, octokit: Octokit, repo = CONFIG_ORG_REPO, path = CONFIG_FULL_PATH) { + if (repo === CONFIG_ORG_REPO) { + await this.configRepoExistenceCheck(org, repo, octokit); + } - if (!newPlugins) { - throw new Error("No plugins found in the config"); + let existingConfig = await this.repoFileExistenceCheck(org, octokit, repo, path); + + if (!existingConfig) { + try { + this.newConfigYml = YAML.stringify({ plugins: [] }); + await octokit.repos.createOrUpdateFileContents({ + owner: org, + repo, + path, + message: `chore: creating config`, + content: btoa(this.newConfigYml), + }); + + toastNotification(`We couldn't locate your config file, so we created an empty one for you.`, { type: "success" }); + + existingConfig = await this.repoFileExistenceCheck(org, octokit, repo, path); + } catch (er) { + console.log(er); + throw new Error("Config file creation failed"); + } } - const newPluginNames = newPlugins.map((p) => p.uses[0].plugin); - if (newPluginNames.length === 0) { - throw new Error("No plugins found in the config"); + if (existingConfig && "content" in existingConfig) { + this.repoConfigSha = existingConfig.sha; + this.repoConfig = atob(existingConfig.content); + } else { + throw new Error("No existing config found"); } - if (option === "add") { - // update if it exists, add if it doesn't - newPlugins.forEach((newPlugin) => { - const existingPlugin = repoPlugins.find((p) => p.uses[0].plugin === newPlugin.uses[0].plugin); - if (existingPlugin) { - existingPlugin.uses[0].with = newPlugin.uses[0].with; - } else { - repoPlugins.push(newPlugin); - } - }); + return this.repoConfig; + } - this.newConfigYml = YAML.stringify({ plugins: repoPlugins }); - } else if (option === "remove") { - // remove only this plugin, keep all others - newPlugins.forEach((newPlugin) => { - const existingPlugin = repoPlugins.find((p) => p.uses[0].plugin === newPlugin.uses[0].plugin); - if (existingPlugin) { - repoPlugins = repoPlugins.filter((p) => p.uses[0].plugin !== newPlugin.uses[0].plugin); - } - }); - this.newConfigYml = YAML.stringify({ plugins: newPlugins }); + parseConfig(config?: string | null): PluginConfig { + if (config && typeof config === "string") { + return YAML.parse(config); + } else { + return YAML.parse(this.loadConfig()); } + } - this.saveConfig(); - return this.createOrUpdateFileContents(org, repo, env, octokit); + async updateConfig(org: string, octokit: Octokit, repo = CONFIG_ORG_REPO, path = CONFIG_FULL_PATH) { + return this.createOrUpdateFileContents(org, repo, path, octokit); } - async createOrUpdateFileContents(org: string, repo: string | null, env: "development" | "production", octokit: Octokit) { + async createOrUpdateFileContents(org: string, repo: string, path: string, octokit: Octokit) { const recentSha = await octokit.repos.getContent({ owner: org, - repo: repo ? repo : UBIQUITY_OS, - path: env === "production" ? CONFIG_PATH : CONFIG_PATH.replace(".yml", ".dev.yml"), - ref: repo ? "development" : "main", + repo: repo, + path, }); const sha = "sha" in recentSha.data ? recentSha.data.sha : null; - try { - return octokit.repos.createOrUpdateFileContents({ - owner: org, - repo: repo ? repo : UBIQUITY_OS, - path: env === "production" ? CONFIG_PATH : CONFIG_PATH.replace(".yml", ".dev.yml"), - message: `chore: updating ${env} config`, - content: btoa(`${this.newConfigYml}`), - sha: `${sha}`, - branch: repo ? "development" : "main", - }); - } catch (err) { - throw new Error(`Failed to create or update file contents:\n ${String(err)}`); + if (!sha) { + throw new Error("No sha found"); } + + if (!this.newConfigYml) { + throw new Error("No content to push"); + } + + this.repoConfig = this.newConfigYml; + + return octokit.repos.createOrUpdateFileContents({ + owner: org, + repo: repo, + path, + message: `chore: Plugin Installer UI - update`, + content: btoa(this.newConfigYml), + sha, + }); } addPlugin(plugin: Plugin) { - const config = this.loadConfig(); - const parsedConfig = YAML.parse(config); - if (!parsedConfig.plugins) { - parsedConfig.plugins = []; + const parsedConfig = this.parseConfig(this.repoConfig); + parsedConfig.plugins ??= []; + + const existingPlugin = parsedConfig.plugins.find((p) => p.uses[0].plugin === plugin.uses[0].plugin); + if (existingPlugin) { + existingPlugin.uses[0].with = plugin.uses[0].with; + } else { + parsedConfig.plugins.push(plugin); } - parsedConfig.plugins.push(plugin); + this.newConfigYml = YAML.stringify(parsedConfig); + this.repoConfig = this.newConfigYml; this.saveConfig(); } removePlugin(plugin: Plugin) { - const config = this.loadConfig(); - const parsedConfig = YAML.parse(config); + const parsedConfig = this.parseConfig(this.repoConfig); if (!parsedConfig.plugins) { - console.log("No plugins to remove"); + toastNotification("No plugins found in config", { type: "error" }); return; } + parsedConfig.plugins = parsedConfig.plugins.filter((p: Plugin) => p.uses[0].plugin !== plugin.uses[0].plugin); - console.log(parsedConfig); this.newConfigYml = YAML.stringify(parsedConfig); + this.repoConfig = this.newConfigYml; this.saveConfig(); } - /** - * Loads the current config from local storage or - * creates a new one if it doesn't exist. - * - * If a new config is created, it is also saved to local storage. - * When a new config is created, it is a blank JS object representing - * the ubiquity-os.config.yml file. - */ - loadConfig() { + loadConfig(): string { if (!this.newConfigYml) { - this.newConfigYml = localStorage.getItem("config"); + this.newConfigYml = localStorage.getItem("config") as string; } if (!this.newConfigYml) { @@ -168,7 +189,7 @@ export class ConfigParser { this.repoConfig = YAML.parse(this.newConfigYml); } - return this.newConfigYml as string; + return this.newConfigYml; } saveConfig() { diff --git a/static/scripts/decode-manifest.ts b/static/scripts/decode-manifest.ts deleted file mode 100644 index 623319e..0000000 --- a/static/scripts/decode-manifest.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Manifest, ManifestPreDecode } from "../types/plugins"; - -export class ManifestDecoder { - constructor() {} - - decodeManifestFromFetch(manifest: ManifestPreDecode) { - if (manifest.error) { - return null; - } - - const decodedManifest: Manifest = { - name: manifest.name, - description: manifest.description, - "ubiquity:listeners": manifest["ubiquity:listeners"], - configuration: manifest.configuration, - }; - - return decodedManifest; - } - - decodeManifestFromSearch(search: string) { - const parsed = this.stringUriParser(search); - - const encodedManifestEnvelope = parsed.find((pair) => pair["manifest"]); - if (!encodedManifestEnvelope) { - throw new Error("No encoded manifest found!"); - } - const encodedManifest = encodedManifestEnvelope["manifest"]; - const decodedManifest = decodeURI(encodedManifest); - - this.renderManifest(decodedManifest); - return JSON.parse(decodedManifest); - } - - stringUriParser(input: string): Array<{ [key: string]: string }> { - const buffer: Array<{ [key: string]: string }> = []; - const sections = input.split("&"); - for (const section of sections) { - const keyValues = section.split("="); - buffer.push({ [keyValues[0]]: keyValues[1] }); - } - return buffer; - } - - renderManifest(manifest: string) { - const dfg = document.createDocumentFragment(); - dfg.textContent = manifest; - } -} diff --git a/static/scripts/fetch-manifest.ts b/static/scripts/fetch-manifest.ts index f212b38..9a02094 100644 --- a/static/scripts/fetch-manifest.ts +++ b/static/scripts/fetch-manifest.ts @@ -1,28 +1,29 @@ import { Octokit } from "@octokit/rest"; -import { ManifestDecoder } from "./decode-manifest"; -import { ManifestPreDecode } from "../types/plugins"; +import { Manifest, ManifestPreDecode } from "../types/plugins"; +import { DEV_CONFIG_FULL_PATH, CONFIG_FULL_PATH, CONFIG_ORG_REPO } from "@ubiquity-os/plugin-sdk/constants"; +import { getOfficialPluginConfig } from "../utils/storage"; /** - * Given a list of repositories, fetch the manifest for each repository. + * Responsible for: + * - Mainly UbiquityOS Marketplace data fetching (config-parser fetches user configs) + * - Fetching the manifest.json files from the marketplace + * - Fetching the README.md files from the marketplace + * - Fetching the official plugin config from the orgs + * - Capturing the worker and action urls from the official plugin config (will be taken from the manifest directly soon) + * - Storing the fetched data in localStorage */ export class ManifestFetcher { private _orgs: string[]; private _octokit: Octokit | null; - private _decoder: ManifestDecoder; workerUrlRegex = /https:\/\/([a-z0-9-]+)\.ubiquity\.workers\.dev/g; actionUrlRegex = /[a-z0-9-]+\/[a-z0-9-]+(?:\/[^@]+)?@[a-z0-9-]+/g; workerUrls = new Set(); actionUrls = new Set(); - devYmlConfigPath = ".github/.ubiquity-os.config.dev.yml"; - prodYmlConfigPath = ".github/.ubiquity-os.config.yml"; - configRepo = ".ubiquity-os"; - - constructor(orgs: string[], octokit: Octokit | null, decoder: ManifestDecoder) { + constructor(orgs: string[], octokit: Octokit | null) { this._orgs = orgs; this._octokit = octokit; - this._decoder = decoder; } async fetchMarketplaceManifests() { @@ -34,12 +35,13 @@ export class ManifestFetcher { const manifestCache = this.checkManifestCache(); for (const repo of repos.data) { - const manifestUrl = `https://raw.githubusercontent.com/${org}/${repo.name}/development/manifest.json`; - const manifest = await this.fetchActionManifest(manifestUrl); - const decoded = this._decoder.decodeManifestFromFetch(manifest); + const manifestUrl = this.createGithubRawEndpoint(org, repo.name, "development", "manifest.json"); + const manifest = await this.fetchPluginManifest(manifestUrl); + const decoded = this.decodeManifestFromFetch(manifest); + const readme = await this.fetchPluginReadme(this.createGithubRawEndpoint(org, repo.name, "development", "README.md")); if (decoded) { - manifestCache[manifestUrl] = decoded; + manifestCache[manifestUrl] = { ...decoded, readme }; } } @@ -48,7 +50,6 @@ export class ManifestFetcher { } checkManifestCache(): Record { - // check if the manifest is already in the cache const manifestCache = localStorage.getItem("manifestCache"); if (manifestCache) { return JSON.parse(manifestCache); @@ -65,9 +66,9 @@ export class ManifestFetcher { } } - createActionEndpoint(owner: string, repo: string, branch: string) { + createGithubRawEndpoint(owner: string, repo: string, branch: string, path: string) { // no endpoint so we fetch the raw content from the owner/repo/branch - return `https://raw.githubusercontent.com/${owner}/${repo}/refs/heads/${branch}/manifest.json`; + return `https://raw.githubusercontent.com/${owner}/${repo}/refs/heads/${branch}/${path}`; } captureActionUrls(config: string) { @@ -79,7 +80,7 @@ export class ManifestFetcher { async fetchOfficialPluginConfig() { await this.fetchOrgsUbiquityOsConfigs(); - const officialPluginConfig = JSON.parse(localStorage.getItem("officialPluginConfig") || "{}") || {}; + const officialPluginConfig = getOfficialPluginConfig(); this.workerUrls.forEach((url) => { officialPluginConfig[url] = { workerUrl: url }; @@ -96,42 +97,53 @@ export class ManifestFetcher { return officialPluginConfig; } - async fetchWorkerManifest(workerUrl: string) { - const url = workerUrl + "/manifest.json"; + async fetchPluginManifest(actionUrl: string) { try { - const response = await fetch(url, { - headers: { - "Content-Type": "application/json", - }, - method: "GET", - }); + const response = await fetch(actionUrl); return await response.json(); } catch (e) { - let error = e; - try { - const res = await fetch(url.replace(/development/g, "main")); - return await res.json(); - } catch (e) { - error = e; - } - console.error(error); - if (error instanceof Error) { - return { workerUrl, error: error.message }; + if (e instanceof Error) { + return { actionUrl, error: e.message }; } - return { workerUrl, error: String(error) }; + console.error(e); + return { actionUrl, error: String(e) }; } } - async fetchActionManifest(actionUrl: string) { + async fetchPluginReadme(pluginUrl: string) { + async function handle404(result: string, octokit?: Octokit | null) { + if (result.includes("404: Not Found")) { + const [owner, repo] = pluginUrl.split("/").slice(3, 5); + const readme = await octokit?.repos.getContent({ + owner, + repo, + path: "README.md", + }); + + if (readme && "content" in readme.data) { + return atob(readme.data.content); + } else { + return "No README.md found"; + } + } + + return result; + } try { - const response = await fetch(actionUrl); - return await response.json(); + const response = await fetch(pluginUrl, { signal: new AbortController().signal }); + return await handle404(await response.text(), this._octokit); } catch (e) { - if (e instanceof Error) { - return { actionUrl, error: e.message }; + let error = e; + try { + const res = await fetch(pluginUrl.replace(/development/g, "main"), { signal: new AbortController().signal }); + return await handle404(await res.text(), this._octokit); + } catch (e) { + error = e; } - console.error(e); - return { actionUrl, error: String(e) }; + if (error instanceof Error) { + return error.message; + } + return String(error); } } @@ -146,8 +158,8 @@ export class ManifestFetcher { try { const { data: devConfig } = await this._octokit.repos.getContent({ owner: org, - repo: this.configRepo, - path: this.devYmlConfigPath, + repo: CONFIG_ORG_REPO, + path: DEV_CONFIG_FULL_PATH, }); if ("content" in devConfig) { @@ -160,8 +172,8 @@ export class ManifestFetcher { try { const { data: prodConfig } = await this._octokit.repos.getContent({ owner: org, - repo: this.configRepo, - path: this.prodYmlConfigPath, + repo: CONFIG_ORG_REPO, + path: CONFIG_FULL_PATH, }); if ("content" in prodConfig) { @@ -177,4 +189,19 @@ export class ManifestFetcher { this.captureActionUrls(config); } } + + decodeManifestFromFetch(manifest: ManifestPreDecode) { + if (manifest.error) { + return null; + } + + const decodedManifest: Manifest = { + name: manifest.name, + description: manifest.description, + "ubiquity:listeners": manifest["ubiquity:listeners"], + configuration: manifest.configuration, + }; + + return decodedManifest; + } } diff --git a/static/scripts/render-manifest.ts b/static/scripts/render-manifest.ts index a12d48d..09219f3 100644 --- a/static/scripts/render-manifest.ts +++ b/static/scripts/render-manifest.ts @@ -1,21 +1,13 @@ -import { Manifest } from "@ubiquity-os/ubiquity-os-kernel"; -import { ManifestCache, ManifestPreDecode, ManifestProps, Plugin } from "../types/plugins"; import { ConfigParser } from "./config-parser"; import { AuthService } from "./authentication"; -import AJV, { AnySchemaObject } from "ajv"; -import { createElement, createInputRow } from "../utils/ele-helpers"; -import { toastNotification } from "../utils/toaster"; - -const ajv = new AJV({ allErrors: true, coerceTypes: true, strict: true }); - -const TDV_CENTERED = "table-data-value centered"; -const PICKER_SELECT_STR = "picker-select"; -const NO_ORG_ERROR = "No selected org found"; - -type ExtendedHtmlElement = { - [key in keyof T]: T[key] extends HTMLElement["innerHTML"] ? string | null : T[key]; -}; - +import { ExtendedHtmlElement } from "../types/github"; +import { controlButtons } from "./rendering/control-buttons"; +import { createBackButton } from "./rendering/navigation"; + +/** + * More of a controller than a renderer, this is responsible for rendering the manifest GUI + * and managing the state of the GUI with the help of the rendering functions. + */ export class ManifestRenderer { private _manifestGui: HTMLElement; private _manifestGuiBody: ExtendedHtmlElement; @@ -23,7 +15,7 @@ export class ManifestRenderer { private _configDefaults: { [key: string]: { type: string; value: string; items: { type: string } | null } } = {}; private _auth: AuthService; private _backButton: HTMLButtonElement; - private _currentStep: "orgPicker" | "repoPicker" | "configSelector" | "pluginSelector" | "configEditor" = "orgPicker"; + private _currentStep: "orgPicker" | "repoPicker" | "pluginSelector" | "configEditor" = "orgPicker"; private _orgs: string[] = []; constructor(auth: AuthService) { @@ -37,622 +29,67 @@ export class ManifestRenderer { this._manifestGui = manifestGui as HTMLElement; this._manifestGuiBody = manifestGuiBody as HTMLElement; - this._controlButtons(true); - - this._backButton = createElement("button", { - id: "back-button", - class: "button", - textContent: "Back", - }) as HTMLButtonElement; + controlButtons({ hide: true }); + this.currentStep = "orgPicker"; const title = manifestGui.querySelector("#manifest-gui-title"); + this._backButton = createBackButton(this); title?.previousSibling?.appendChild(this._backButton); - this._backButton.style.display = "none"; - this._backButton.addEventListener("click", this._handleBackButtonClick.bind(this)); - } - - private _handleBackButtonClick(): void { - switch (this._currentStep) { - case "configSelector": { - this.renderOrgPicker(this._orgs); - break; - } - case "pluginSelector": { - const selectedConfig = localStorage.getItem("selectedConfig") as "development" | "production"; - this._renderConfigSelector(selectedConfig); - break; - } - case "configEditor": { - const selectedConfig = localStorage.getItem("selectedConfig") as "development" | "production"; - this._renderPluginSelector(selectedConfig); - break; - } - case "repoPicker": { - const orgRepos = JSON.parse(localStorage.getItem("orgRepos") || "{}"); - this.renderRepoPicker(orgRepos); - break; - } - case "orgPicker": { - const orgs = JSON.parse(localStorage.getItem("userOrgs") || "[]"); - this.renderOrgPicker(orgs); - break; - } - default: - break; - } } - // Event Handlers - - private _handleOrgSelection(event: Event): void { - const selectElement = event.target as HTMLSelectElement; - const selectedOrg = selectElement.value; - if (selectedOrg) { - localStorage.setItem("selectedOrg", selectedOrg); - const orgRepos = JSON.parse(localStorage.getItem("orgRepos") || "{}"); - this.renderRepoPicker(orgRepos); - } + get orgs(): string[] { + return this._orgs; } - private _handleRepoSelection(event: Event): void { - const selectElement = event.target as HTMLSelectElement; - const selectedRepo = selectElement.value; - if (selectedRepo) { - localStorage.setItem("selectedRepo", selectedRepo); - const selectedOrg = localStorage.getItem("selectedOrg"); - if (!selectedOrg) { - throw new Error(NO_ORG_ERROR); - } - this._renderConfigSelector(selectedOrg); - } - } - - private _handlePluginSelection(event: Event): void { - try { - const selectElement = event.target as HTMLSelectElement; - const selectedPluginManifest = selectElement.value; - if (selectedPluginManifest) { - localStorage.setItem("selectedPluginManifest", selectedPluginManifest); - this._renderConfigEditor(selectedPluginManifest); - } - } catch (error) { - console.error("Error handling plugin selection:", error); - alert("An error occurred while selecting the plugin."); - } - } - - private _handleConfigSelection(event: Event): void { - try { - const selectElement = event.target as HTMLSelectElement; - const selectedConfig = selectElement.value as "development" | "production"; - if (selectedConfig) { - const fetchOrgConfig = async () => { - const org = localStorage.getItem("selectedOrg"); - const octokit = this._auth.octokit; - if (!org || !octokit) { - throw new Error("No org or octokit found"); - } - const selectedRepo = localStorage.getItem("selectedRepo"); - await this._configParser.fetchUserInstalledConfig(org, selectedRepo, selectedConfig, octokit); - }; - localStorage.setItem("selectedConfig", selectedConfig); - this._renderPluginSelector(selectedConfig); - fetchOrgConfig().catch(console.error); - } - } catch (error) { - console.error("Error handling configuration selection:", error); - alert("An error occurred while selecting the configuration."); - } - } - - // UI Rendering - - private _controlButtons(hide: boolean): void { - const addButton = document.getElementById("add"); - const removeButton = document.getElementById("remove"); - if (addButton) { - addButton.style.display = hide ? "none" : "inline-block"; - } - if (removeButton) { - removeButton.style.display = hide ? "none" : "inline-block"; - } - - this._manifestGui?.classList.add("rendered"); - } - - public renderOrgPicker(orgs: string[]): void { + set orgs(orgs: string[]) { this._orgs = orgs; - this._currentStep = "orgPicker"; - this._controlButtons(true); - this._backButton.style.display = "none"; - this._manifestGui?.classList.add("rendering"); - this._manifestGuiBody.innerHTML = null; - - const pickerRow = document.createElement("tr"); - const pickerCell = document.createElement("td"); - pickerCell.colSpan = 4; - pickerCell.className = TDV_CENTERED; - - if (!orgs.length) { - const hasSession = this._auth.isActiveSession(); - if (hasSession) { - this._updateGuiTitle("No installations found"); - this._manifestGuiBody.appendChild(pickerRow); - this._manifestGui?.classList.add("rendered"); - } else { - this._updateGuiTitle("Please sign in to GitHub"); - } - return; - } - - this._updateGuiTitle("Select an Organization"); - - const orgSelect = createElement("select", { - id: "org-picker-select", - class: PICKER_SELECT_STR, - style: "width: 100%", - }); - - const defaultOption = createElement("option", { - value: null, - textContent: "Found installations...", - }); - orgSelect.appendChild(defaultOption); - - orgs.forEach((org) => { - const option = createElement("option", { - value: org, - textContent: org, - }); - orgSelect.appendChild(option); - }); - - orgSelect.addEventListener("change", this._handleOrgSelection.bind(this)); - pickerCell.appendChild(orgSelect); - pickerRow.appendChild(pickerCell); - this._manifestGuiBody.appendChild(pickerRow); - this._manifestGui?.classList.add("rendered"); } - public renderRepoPicker(repos: Record): void { - this._currentStep = "repoPicker"; - this._controlButtons(true); - this._backButton.style.display = "block"; - this._manifestGui?.classList.add("rendering"); - this._manifestGuiBody.innerHTML = null; - - if (!Reflect.ownKeys(repos).length) { - this._updateGuiTitle("No repositories found"); - this._manifestGuiBody.appendChild(document.createElement("tr")); - this._manifestGui?.classList.add("rendered"); - return; - } - - localStorage.setItem("orgRepos", JSON.stringify(repos)); - - const pickerRow = document.createElement("tr"); - const pickerCell = document.createElement("td"); - pickerCell.colSpan = 4; - pickerCell.className = TDV_CENTERED; - - this._updateGuiTitle("Select a Repository"); - const selectedOrg = localStorage.getItem("selectedOrg"); - - if (!selectedOrg) { - throw new Error(NO_ORG_ERROR); - } - - const orgConfigButton = createElement("button", { - id: "org-config-button", - class: "button", - textContent: "Select Organization Configuration", - }); - - orgConfigButton.addEventListener("click", () => { - this._renderConfigSelector(selectedOrg); - }); - - const orgConfigRow = document.createElement("tr"); - const orgConfigCell = document.createElement("td"); - orgConfigCell.colSpan = 4; - orgConfigCell.className = TDV_CENTERED; - orgConfigCell.appendChild(orgConfigButton); - orgConfigRow.appendChild(orgConfigCell); - this._manifestGuiBody.appendChild(orgConfigRow); - - const repoSelect = createElement("select", { - id: "repo-picker-select", - class: PICKER_SELECT_STR, - style: "width: 100%", - }); - - const defaultOption = createElement("option", { - value: null, - textContent: "Or select a repository...", - }); - repoSelect.appendChild(defaultOption); - - const orgRepos = repos[selectedOrg]; - - if (!orgRepos) { - throw new Error("No org repos found"); - } - - orgRepos.forEach((repo) => { - const option = createElement("option", { - value: repo, - textContent: repo, - }); - repoSelect.appendChild(option); - }); - - repoSelect.addEventListener("change", this._handleRepoSelection.bind(this)); - pickerCell.appendChild(repoSelect); - pickerRow.appendChild(pickerCell); - this._manifestGuiBody.appendChild(pickerRow); - this._manifestGui?.classList.add("rendered"); + get currentStep(): "orgPicker" | "repoPicker" | "pluginSelector" | "configEditor" { + return this._currentStep; } - private _renderConfigSelector(selectedOrg: string): void { - this._currentStep = "configSelector"; - this._backButton.style.display = "block"; - this._manifestGuiBody.innerHTML = null; - this._controlButtons(true); - - const pickerRow = document.createElement("tr"); - const pickerCell = document.createElement("td"); - pickerCell.colSpan = 2; - pickerCell.className = TDV_CENTERED; - - const configSelect = createElement("select", { - id: "config-selector-select", - class: PICKER_SELECT_STR, - }); - - const defaultOption = createElement("option", { - value: null, - textContent: "Select a configuration", - }); - configSelect.appendChild(defaultOption); - - const configs = ["development", "production"]; - configs.forEach((config) => { - const option = createElement("option", { - value: config, - textContent: config.charAt(0).toUpperCase() + config.slice(1), - }); - configSelect.appendChild(option); - }); - - configSelect.removeEventListener("change", this._handleConfigSelection.bind(this)); - configSelect.addEventListener("change", this._handleConfigSelection.bind(this)); - pickerCell.appendChild(configSelect); - pickerRow.appendChild(pickerCell); - this._updateGuiTitle(`Select a Configuration for ${selectedOrg}`); - this._manifestGuiBody.appendChild(pickerRow); + set currentStep(step: "orgPicker" | "repoPicker" | "pluginSelector" | "configEditor") { + this._currentStep = step; } - private _renderPluginSelector(selectedConfig: "development" | "production"): void { - this._currentStep = "pluginSelector"; - this._backButton.style.display = "block"; - this._manifestGuiBody.innerHTML = null; - this._controlButtons(true); - - const manifestCache = JSON.parse(localStorage.getItem("manifestCache") || "{}") as ManifestCache; - const pluginUrls = Object.keys(manifestCache); - - const pickerRow = document.createElement("tr"); - const pickerCell = document.createElement("td"); - pickerCell.colSpan = 2; - pickerCell.className = TDV_CENTERED; - - const pluginSelect = createElement("select", { - id: "plugin-selector-select", - class: PICKER_SELECT_STR, - }); - - const defaultOption = createElement("option", { - value: null, - textContent: "Select a plugin", - }); - pluginSelect.appendChild(defaultOption); - - const cleanManifestCache = Object.keys(manifestCache).reduce((acc, key) => { - if (manifestCache[key]?.name) { - acc[key] = manifestCache[key]; - } - return acc; - }, {} as ManifestCache); - - pluginUrls.forEach((url) => { - if (!cleanManifestCache[url]?.name) { - return; - } - const option = createElement("option", { - value: JSON.stringify(cleanManifestCache[url]), - textContent: cleanManifestCache[url]?.name, - }); - pluginSelect.appendChild(option); - }); - - pluginSelect.addEventListener("change", this._handlePluginSelection.bind(this)); - pickerCell.appendChild(pluginSelect); - pickerRow.appendChild(pickerCell); - - this._updateGuiTitle(`Select a Plugin for ${selectedConfig}`); - this._manifestGuiBody.appendChild(pickerRow); + get backButton(): HTMLButtonElement { + return this._backButton; } - renderManifest(decodedManifest: ManifestPreDecode) { - if (!decodedManifest) { - throw new Error("No decoded manifest found!"); - } - this._manifestGui?.classList.add("rendering"); - this._manifestGuiBody.innerHTML = null; - - const table = document.createElement("table"); - Object.entries(decodedManifest).forEach(([key, value]) => { - const row = document.createElement("tr"); - - const headerCell = document.createElement("td"); - headerCell.className = "table-data-header"; - headerCell.textContent = key.replace("ubiquity:", ""); - row.appendChild(headerCell); - - const valueCell = document.createElement("td"); - valueCell.className = "table-data-value"; - - if (typeof value === "string") { - valueCell.textContent = value; - } else { - const pre = document.createElement("pre"); - pre.textContent = JSON.stringify(value, null, 2); - valueCell.appendChild(pre); - } - - row.appendChild(valueCell); - table.appendChild(row); - }); - - this._manifestGuiBody.appendChild(table); - this._manifestGui?.classList.add("rendered"); + set backButton(button: HTMLButtonElement) { + this._backButton = button; } - private _boundConfigAdd = this._writeNewConfig.bind(this, "add"); - private _boundConfigRemove = this._writeNewConfig.bind(this, "remove"); - private _renderConfigEditor(manifestStr: string): void { - this._currentStep = "configEditor"; - this._backButton.style.display = "block"; - this._manifestGuiBody.innerHTML = null; - this._controlButtons(false); - - const pluginManifest = JSON.parse(manifestStr) as Manifest; - const configProps = pluginManifest.configuration?.properties || {}; - this._processProperties(configProps); - - const add = document.getElementById("add"); - const remove = document.getElementById("remove"); - if (!add || !remove) { - throw new Error("Add or remove button not found"); - } - add.addEventListener("click", this._boundConfigAdd); - remove.addEventListener("click", this._boundConfigRemove); - - this._updateGuiTitle(`Editing Configuration for ${pluginManifest.name}`); - this._manifestGui?.classList.add("plugin-editor"); - this._manifestGui?.classList.add("rendered"); + get manifestGui(): HTMLElement { + return this._manifestGui; } - private _updateGuiTitle(title: string): void { - const guiTitle = document.querySelector("#manifest-gui-title"); - if (!guiTitle) { - throw new Error("GUI Title not found"); - } - guiTitle.textContent = title; + set manifestGui(gui: HTMLElement) { + this._manifestGui = gui; } - // Configuration Parsing - - private _processProperties(props: Record, prefix: string | null = null) { - Object.keys(props).forEach((key) => { - const fullKey = prefix ? `${prefix}.${key}` : key; - const prop = props[key]; - - if (prop.type === "object" && prop.properties) { - this._processProperties(prop.properties, fullKey); - } else { - createInputRow(fullKey, prop, this._configDefaults); - } - }); + get manifestGuiBody(): ExtendedHtmlElement { + return this._manifestGuiBody; } - private _parseConfigInputs(configInputs: NodeListOf, manifest: Manifest): { [key: string]: unknown } { - const config: Record = {}; - const schema = manifest.configuration; - if (!schema) { - throw new Error("No schema found in manifest"); - } - const validate = ajv.compile(schema as AnySchemaObject); - - configInputs.forEach((input) => { - const key = input.getAttribute("data-config-key"); - if (!key) { - throw new Error("Input key is required"); - } - - const keys = key.split("."); - - let currentObj = config; - for (let i = 0; i < keys.length - 1; i++) { - const part = keys[i]; - if (!currentObj[part] || typeof currentObj[part] !== "object") { - currentObj[part] = {}; - } - currentObj = currentObj[part] as Record; - } - - let value: unknown; - const expectedType = input.getAttribute("data-type"); - - if (expectedType === "boolean") { - value = (input as HTMLInputElement).checked; - } else if (expectedType === "object" || expectedType === "array") { - try { - value = JSON.parse((input as HTMLTextAreaElement).value); - } catch (e) { - console.error(e); - throw new Error(`Invalid JSON input for ${expectedType} at key "${key}": ${input.value}`); - } - } else { - value = (input as HTMLInputElement).value; - } - - currentObj[keys[keys.length - 1]] = value; - }); - - if (validate(config)) { - return config; - } else { - throw new Error("Invalid configuration: " + JSON.stringify(validate.errors, null, 2)); - } + set manifestGuiBody(body: ExtendedHtmlElement) { + this._manifestGuiBody = body; } - private _writeNewConfig(option: "add" | "remove"): void { - const selectedManifest = localStorage.getItem("selectedPluginManifest"); - if (!selectedManifest) { - toastNotification("No selected plugin manifest found.", { - type: "error", - shouldAutoDismiss: true, - }); - throw new Error("No selected plugin manifest found"); - } - const pluginManifest = JSON.parse(selectedManifest) as Manifest; - const configInputs = document.querySelectorAll(".config-input"); - - const newConfig = this._parseConfigInputs(configInputs, pluginManifest); - - this._configParser.loadConfig(); - - const officialPluginConfig: Record = JSON.parse(localStorage.getItem("officialPluginConfig") || "{}"); - - const pluginName = pluginManifest.name; - - // this relies on the manifest matching the repo name - const normalizedPluginName = pluginName - .toLowerCase() - .replace(/ /g, "-") - .replace(/[^a-z0-9-]/g, "") - .replace(/-+/g, "-"); - - const pluginUrl = Object.keys(officialPluginConfig).find((url) => { - return url.includes(normalizedPluginName); - }); - - if (!pluginUrl) { - toastNotification(`No plugin URL found for ${pluginName}.`, { - type: "error", - shouldAutoDismiss: true, - }); - throw new Error("No plugin URL found"); - } - - const plugin: Plugin = { - uses: [ - { - plugin: pluginUrl, - with: newConfig, - }, - ], - }; - - if (option === "add") { - this._handleAddPlugin(plugin, pluginManifest); - } else { - this._handleRemovePlugin(plugin, pluginManifest); - } + get auth(): AuthService { + return this._auth; } - private _handleAddPlugin(plugin: Plugin, pluginManifest: Manifest): void { - this._configParser.addPlugin(plugin); - toastNotification(`Configuration for ${pluginManifest.name} saved successfully. Do you want to push to GitHub?`, { - type: "success", - actionText: "Push to GitHub", - action: async () => { - const octokit = this._auth.octokit; - if (!octokit) { - throw new Error("Octokit not found"); - } - - const org = localStorage.getItem("selectedOrg"); - const repo = localStorage.getItem("selectedRepo"); - const config = localStorage.getItem("selectedConfig") as "development" | "production"; - - if (!org) { - throw new Error(NO_ORG_ERROR); - } - - if (!config) { - throw new Error("No selected config found"); - } - - try { - await this._configParser.updateConfig(org, repo, config, octokit, "add"); - } catch (error) { - console.error("Error pushing config to GitHub:", error); - toastNotification("An error occurred while pushing the configuration to GitHub.", { - type: "error", - shouldAutoDismiss: true, - }); - return; - } - - toastNotification("Configuration pushed to GitHub successfully.", { - type: "success", - shouldAutoDismiss: true, - }); - }, - }); + get configParser(): ConfigParser { + return this._configParser; } - private _handleRemovePlugin(plugin: Plugin, pluginManifest: Manifest): void { - this._configParser.removePlugin(plugin); - toastNotification(`Configuration for ${pluginManifest.name} removed successfully. Do you want to push to GitHub?`, { - type: "success", - actionText: "Push to GitHub", - action: async () => { - const octokit = this._auth.octokit; - if (!octokit) { - throw new Error("Octokit not found"); - } - - const org = localStorage.getItem("selectedOrg"); - const repo = localStorage.getItem("selectedRepo"); - const config = localStorage.getItem("selectedConfig") as "development" | "production"; - - if (!org) { - throw new Error(NO_ORG_ERROR); - } - - if (!config) { - throw new Error("No selected config found"); - } - - try { - await this._configParser.updateConfig(org, repo, config, octokit, "remove"); - } catch (error) { - console.error("Error pushing config to GitHub:", error); - toastNotification("An error occurred while pushing the configuration to GitHub.", { - type: "error", - shouldAutoDismiss: true, - }); - return; - } + get configDefaults(): { [key: string]: { type: string; value: string; items: { type: string } | null } } { + return this._configDefaults; + } - toastNotification("Configuration pushed to GitHub successfully.", { - type: "success", - shouldAutoDismiss: true, - }); - }, - }); + set configDefaults(defaults: { [key: string]: { type: string; value: string; items: { type: string } | null } }) { + this._configDefaults = defaults; } } diff --git a/static/scripts/rendering/config-editor.ts b/static/scripts/rendering/config-editor.ts new file mode 100644 index 0000000..3c30f43 --- /dev/null +++ b/static/scripts/rendering/config-editor.ts @@ -0,0 +1,175 @@ +import { Manifest, Plugin } from "../../types/plugins"; +import { controlButtons } from "./control-buttons"; +import { ManifestRenderer } from "../render-manifest"; +import { processProperties } from "./input-parsing"; +import { addTrackedEventListener, getTrackedEventListeners, normalizePluginName, removeTrackedEventListener, updateGuiTitle } from "./utils"; +import { handleResetToDefault, writeNewConfig } from "./write-add-remove"; +import MarkdownIt from "markdown-it"; +import { getManifestCache } from "../../utils/storage"; +const md = new MarkdownIt(); + +/** + * Displays the plugin configuration editor. + * + * - `pluginManifest` should never be null or there was a problem fetching from the marketplace + * - `plugin` should only be passed in if you intend on replacing the default configuration with their installed configuration + * + * Allows for: + * - Adding a single plugin configuration + * - Removing a single plugin configuration + * - Resetting the plugin configuration to the schema default + * - Building multiple plugins like a "shopping cart" and they all get pushed at once in the background + * + * Compromises: + * - Typebox Unions get JSON.stringify'd and displayed as one string meaning `text-conversation-rewards` has a monster config for HTML tags + * - Plugin config objects are split like `plugin.config.key` and `plugin.config.key2` and `plugin.config.key3` and so on + */ +export function renderConfigEditor(renderer: ManifestRenderer, pluginManifest: Manifest | null, plugin?: Plugin["uses"][0]["with"]): void { + renderer.currentStep = "configEditor"; + renderer.backButton.style.display = "block"; + renderer.manifestGuiBody.innerHTML = null; + controlButtons({ hide: false }); + processProperties(renderer, pluginManifest, pluginManifest?.configuration.properties || {}, null); + const configInputs = document.querySelectorAll(".config-input"); + + // If plugin is passed in, we want to inject those values into the inputs + if (plugin) { + configInputs.forEach((input) => { + const key = input.getAttribute("data-config-key"); + if (!key) { + throw new Error("Input key is required"); + } + + const keys = key.split("."); + let currentObj = plugin; + for (let i = 0; i < keys.length; i++) { + if (!currentObj[keys[i]]) { + break; + } + currentObj = currentObj[keys[i]] as Record; + } + + let value: string; + + if (typeof currentObj === "object" || Array.isArray(currentObj)) { + value = JSON.stringify(currentObj, null, 2); + } else if (typeof currentObj === "boolean") { + value = currentObj ? "true" : "false"; + } else { + value = currentObj as string; + } + + if (input.tagName === "TEXTAREA") { + (input as HTMLTextAreaElement).value = value; + } else { + (input as HTMLInputElement).value = value; + } + + if (input.tagName === "INPUT" && (input as HTMLInputElement).type === "checkbox") { + (input as HTMLInputElement).checked = value === "true"; + } + }); + } + + const add = document.getElementById("add") as HTMLButtonElement; + const remove = document.getElementById("remove") as HTMLButtonElement; + const resetToDefaultButton = document.getElementById("reset-to-default") as HTMLButtonElement; + if (!add || !remove || !resetToDefaultButton) { + throw new Error("Buttons not found"); + } + + const parsedConfig = renderer.configParser.parseConfig(renderer.configParser.repoConfig || localStorage.getItem("config")); + // for when `resetToDefault` is called and no plugin gets passed in, we still want to show the remove button + const isInstalled = parsedConfig.plugins?.find((p) => p.uses[0].plugin.includes(normalizePluginName(pluginManifest?.name || ""))); + + loadListeners({ + renderer, + pluginManifest, + withPluginOrInstalled: !!(plugin || isInstalled), + add, + remove, + resetToDefaultButton, + }).catch(console.error); + + if (plugin || isInstalled) { + remove.disabled = false; + remove.classList.remove("disabled"); + } else { + remove.disabled = true; + remove.classList.add("disabled"); + } + + resetToDefaultButton.hidden = !!(plugin || isInstalled); + const manifestCache = getManifestCache(); + const pluginUrls = Object.keys(manifestCache); + const pluginUrl = pluginUrls.find((url) => { + return manifestCache[url].name === pluginManifest?.name; + }); + + if (!pluginUrl) { + throw new Error("Plugin URL not found"); + } + const readme = manifestCache[pluginUrl].readme; + + if (readme) { + const viewportCell = document.getElementById("viewport-cell"); + if (!viewportCell) { + throw new Error("Viewport cell not found"); + } + const readmeContainer = document.createElement("div"); + readmeContainer.className = "readme-container"; + readmeContainer.innerHTML = md.render(readme); + viewportCell.appendChild(readmeContainer); + } + + const org = localStorage.getItem("selectedOrg"); + + updateGuiTitle(`Editing Configuration for ${pluginManifest?.name} in ${org}`); + renderer.manifestGui?.classList.add("plugin-editor"); + renderer.manifestGui?.classList.add("rendered"); +} + +async function loadListeners({ + renderer, + pluginManifest, + withPluginOrInstalled, + add, + remove, + resetToDefaultButton, +}: { + renderer: ManifestRenderer; + pluginManifest: Manifest | null; + withPluginOrInstalled: boolean; + add: HTMLButtonElement; + remove: HTMLButtonElement; + resetToDefaultButton: HTMLButtonElement; +}) { + function addHandler() { + writeNewConfig(renderer, "add"); + } + function removeHandler() { + writeNewConfig(renderer, "remove"); + } + function resetToDefaultHandler() { + handleResetToDefault(renderer, pluginManifest); + } + + // ensure the listeners are removed before adding new ones + await (async () => { + getTrackedEventListeners(remove, "click")?.forEach((listener) => { + removeTrackedEventListener(remove, "click", listener); + }); + getTrackedEventListeners(add, "click")?.forEach((listener) => { + removeTrackedEventListener(add, "click", listener); + }); + getTrackedEventListeners(resetToDefaultButton, "click")?.forEach((listener) => { + removeTrackedEventListener(resetToDefaultButton, "click", listener); + }); + })(); + + addTrackedEventListener(resetToDefaultButton, "click", resetToDefaultHandler); + addTrackedEventListener(add, "click", addHandler); + if (withPluginOrInstalled) { + addTrackedEventListener(remove, "click", removeHandler); + } +} diff --git a/static/scripts/rendering/control-buttons.ts b/static/scripts/rendering/control-buttons.ts new file mode 100644 index 0000000..e95e630 --- /dev/null +++ b/static/scripts/rendering/control-buttons.ts @@ -0,0 +1,24 @@ +import { manifestGuiBody } from "../../utils/element-helpers"; + +export function controlButtons({ hide }: { hide: boolean }): void { + const addButton = document.getElementById("add"); + const removeButton = document.getElementById("remove"); + const resetToDefaultButton = document.getElementById("reset-to-default"); + const hideOrDisplay = hide ? "none" : "inline-block"; + if (addButton) { + addButton.style.display = hideOrDisplay; + } + if (removeButton) { + removeButton.style.display = hideOrDisplay; + } + + if (resetToDefaultButton) { + resetToDefaultButton.style.display = hideOrDisplay; + } + + if (!manifestGuiBody) { + return; + } + + manifestGuiBody.classList.add("rendered"); +} diff --git a/static/scripts/rendering/input-parsing.ts b/static/scripts/rendering/input-parsing.ts new file mode 100644 index 0000000..a8bd6c0 --- /dev/null +++ b/static/scripts/rendering/input-parsing.ts @@ -0,0 +1,115 @@ +import AJV, { AnySchemaObject } from "ajv"; +import { createInputRow } from "../../utils/element-helpers"; +import { ManifestRenderer } from "../render-manifest"; +import { Manifest } from "../../types/plugins"; + +// Without the raw Typebox Schema it was difficult to use Typebox which is why I've used AJV to validate the configuration. +const ajv = new AJV({ allErrors: true, coerceTypes: true, strict: true }); + +/** + * This creates the input rows for the configuration editor for any given plugin. + */ +export function processProperties( + renderer: ManifestRenderer, + manifest: Manifest | null | undefined, + props: Record, + prefix: string | null = null +) { + const required = manifest?.configuration?.required || []; + Object.keys(props).forEach((key) => { + const fullKey = prefix ? `${prefix}.${key}` : key; + const prop = props[key]; + if (!prop) { + return; + } + + if (prop.type === "object" && prop.properties) { + processProperties(renderer, manifest, prop.properties, fullKey); + } else if ("anyOf" in prop && Array.isArray(prop.anyOf)) { + if (prop.default) { + createInputRow(fullKey, prop, renderer.configDefaults, required.includes(fullKey)); + } else { + prop.anyOf?.forEach((subProp) => { + processProperties(renderer, manifest, subProp.properties || {}, fullKey); + }); + } + } else { + createInputRow(fullKey, prop, renderer.configDefaults); + } + }); +} + +/** + * This parse the inputs from the configuration editor and returns the configuration object. + * It also returns an array of missing required fields if any. + * + * It should become a priority to establish API like usage of `null` and `undefined` in our schemas so it's + * easier and less buggy when using the installer. + */ +export function parseConfigInputs( + configInputs: NodeListOf, + manifest: Manifest +): { config: Record; missing: string[] } { + const config: Record = {}; + const schema = manifest.configuration; + if (!schema) { + throw new Error("No schema found in manifest"); + } + const required = schema.required || []; + const validate = ajv.compile(schema as AnySchemaObject); + + configInputs.forEach((input) => { + const key = input.getAttribute("data-config-key"); + if (!key) { + throw new Error("Input key is required"); + } + + const keys = key.split("."); + + let currentObj = config; + for (let i = 0; i < keys.length - 1; i++) { + const part = keys[i]; + if (!currentObj[part] || typeof currentObj[part] !== "object") { + currentObj[part] = {}; + } + currentObj = currentObj[part] as Record; + } + + let value: unknown; + const expectedType = input.getAttribute("data-type"); + + if (expectedType === "boolean") { + value = (input as HTMLInputElement).checked; + } else if (expectedType === "object" || expectedType === "array") { + if (!input.value) { + value = expectedType === "object" ? {} : []; + } else + try { + value = JSON.parse((input as HTMLTextAreaElement).value); + } catch (e) { + console.error(e); + throw new Error(`Invalid JSON input for ${expectedType} at key "${key}": ${input.value}`); + } + } else { + value = (input as HTMLInputElement).value; + } + currentObj[keys[keys.length - 1]] = value; + }); + + if (validate(config)) { + const missing = []; + for (const key of required) { + const isBoolean = schema.properties && schema.properties[key] && schema.properties[key].type === "boolean"; + if ((isBoolean && config[key] === false) || config[key] === true) { + continue; + } + + if (!config[key] || config[key] === "undefined" || config[key] === "null") { + missing.push(key); + } + } + return { config, missing }; + } else { + throw new Error("Invalid configuration: " + JSON.stringify(validate.errors, null, 2)); + } +} diff --git a/static/scripts/rendering/navigation.ts b/static/scripts/rendering/navigation.ts new file mode 100644 index 0000000..a350b3d --- /dev/null +++ b/static/scripts/rendering/navigation.ts @@ -0,0 +1,34 @@ +import { createElement } from "../../utils/element-helpers"; +import { ManifestRenderer } from "../render-manifest"; +import { renderOrgPicker } from "./org-select"; +import { renderPluginSelector } from "./plugin-select"; +import { renderRepoPicker } from "./repo-select"; + +export function createBackButton(renderer: ManifestRenderer): HTMLButtonElement { + const backButton = createElement("button", { + id: "back-button", + class: "button", + textContent: "Back", + }) as HTMLButtonElement; + + backButton.style.display = "none"; + backButton.addEventListener("click", () => handleBackButtonClick(renderer)); + return backButton; +} + +function handleBackButtonClick(renderer: ManifestRenderer): void { + renderer.manifestGui?.classList.remove("plugin-editor"); + const readmeContainer = document.querySelector(".readme-container"); + if (readmeContainer) { + readmeContainer.remove(); + } + // "pluginSelector" | "configEditor" + const step = renderer.currentStep; + if (step === "repoPicker" || step === "orgPicker") { + renderOrgPicker(renderer, renderer.orgs); + } else if (step === "pluginSelector") { + renderRepoPicker(renderer, JSON.parse(localStorage.getItem("orgRepos") || "{}")); + } else if (step === "configEditor") { + renderPluginSelector(renderer); + } +} diff --git a/static/scripts/rendering/org-select.ts b/static/scripts/rendering/org-select.ts new file mode 100644 index 0000000..3062032 --- /dev/null +++ b/static/scripts/rendering/org-select.ts @@ -0,0 +1,89 @@ +import { createElement } from "../../utils/element-helpers"; +import { STRINGS } from "../../utils/strings"; +import { ManifestRenderer } from "../render-manifest"; +import { controlButtons } from "./control-buttons"; +import { renderRepoPicker } from "./repo-select"; +import { closeAllSelect, updateGuiTitle } from "./utils"; + +/** + * Renders the orgs for the authenticated user to select from. + */ +export function renderOrgPicker(renderer: ManifestRenderer, orgs: string[]) { + renderer.currentStep = "orgPicker"; + controlButtons({ hide: true }); + renderer.backButton.style.display = "none"; + renderer.manifestGui?.classList.add("rendering"); + renderer.manifestGuiBody.innerHTML = null; + renderer.orgs = orgs; + + const pickerRow = document.createElement("tr"); + const pickerCell = document.createElement("td"); + pickerCell.colSpan = 4; + pickerCell.className = STRINGS.TDV_CENTERED; + + const customSelect = createElement("div", { class: "custom-select", style: `display: ${orgs.length ? "block" : "none"}` }); + + const selectSelected = createElement("div", { + class: "select-selected", + textContent: "Select an organization", + }); + + const selectItems = createElement("div", { + class: "select-items select-hide", + }); + + customSelect.appendChild(selectSelected); + customSelect.appendChild(selectItems); + + pickerCell.appendChild(customSelect); + pickerRow.appendChild(pickerCell); + + renderer.manifestGuiBody.appendChild(pickerRow); + renderer.manifestGui?.classList.add("rendered"); + + if (!orgs.length) { + const hasSession = renderer.auth.isActiveSession(); + const isLoading = renderer.manifestGuiBody.dataset.loading === "true"; + + if (hasSession && !isLoading) { + updateGuiTitle("No organizations found"); + } else if (hasSession && isLoading) { + updateGuiTitle("Fetching organization data..."); + } else { + updateGuiTitle("Please sign in to GitHub"); + } + return; + } + + updateGuiTitle("Select an Organization"); + + orgs.forEach((org) => { + const optionDiv = createElement("div", { class: "select-option" }); + const textSpan = createElement("span", { textContent: org }); + + optionDiv.appendChild(textSpan); + optionDiv.addEventListener("click", () => { + selectSelected.textContent = org; + handleOrgSelection(renderer, org); + }); + + selectItems.appendChild(optionDiv); + }); + + selectSelected.addEventListener("click", (e) => { + e.stopPropagation(); + selectItems.classList.toggle(STRINGS.SELECT_HIDE); + selectSelected.classList.toggle(STRINGS.SELECT_ARROW_ACTIVE); + }); + + document.addEventListener("click", closeAllSelect); +} + +function handleOrgSelection(renderer: ManifestRenderer, org: string): void { + if (!org) { + throw new Error("No org selected"); + } + localStorage.setItem("selectedOrg", org); + const repos = JSON.parse(localStorage.getItem("orgRepos") || "{}"); + renderRepoPicker(renderer, repos); +} diff --git a/static/scripts/rendering/plugin-select.ts b/static/scripts/rendering/plugin-select.ts new file mode 100644 index 0000000..a7ee070 --- /dev/null +++ b/static/scripts/rendering/plugin-select.ts @@ -0,0 +1,97 @@ +import { ManifestCache, ManifestPreDecode, Plugin } from "../../types/plugins"; +import { createElement } from "../../utils/element-helpers"; +import { getManifestCache } from "../../utils/storage"; +import { STRINGS } from "../../utils/strings"; +import { ManifestRenderer } from "../render-manifest"; +import { renderConfigEditor } from "./config-editor"; +import { controlButtons } from "./control-buttons"; +import { closeAllSelect, normalizePluginName, updateGuiTitle } from "./utils"; + +/** + * Renders a dropdown of plugins taken from the marketplace with an installed indicator. + * The user can select a plugin and it will render the configuration editor for that plugin. + */ +export function renderPluginSelector(renderer: ManifestRenderer): void { + renderer.currentStep = "pluginSelector"; + renderer.backButton.style.display = "block"; + renderer.manifestGuiBody.innerHTML = null; + controlButtons({ hide: true }); + + const manifestCache = getManifestCache(); + const pluginUrls = Object.keys(manifestCache); + + const pickerRow = document.createElement("tr"); + const pickerCell = document.createElement("td"); + pickerCell.colSpan = 2; + pickerCell.className = STRINGS.TDV_CENTERED; + + const userConfig = renderer.configParser.repoConfig; + let installedPlugins: Plugin[] = []; + + if (userConfig) { + installedPlugins = renderer.configParser.parseConfig(userConfig).plugins; + } + + const cleanManifestCache = Object.keys(manifestCache).reduce((acc, key) => { + if (manifestCache[key]?.name) { + acc[key] = manifestCache[key]; + } + return acc; + }, {} as ManifestCache); + + const customSelect = createElement("div", { class: "custom-select" }); + + const selectSelected = createElement("div", { + class: "select-selected", + textContent: "Select a plugin", + }); + + const selectItems = createElement("div", { + class: "select-items select-hide", + }); + + customSelect.appendChild(selectSelected); + customSelect.appendChild(selectItems); + + pickerCell.appendChild(customSelect); + pickerRow.appendChild(pickerCell); + + renderer.manifestGuiBody.appendChild(pickerRow); + + pluginUrls.forEach((url) => { + if (!cleanManifestCache[url]?.name) { + return; + } + const normalizedName = normalizePluginName(cleanManifestCache[url].name); + const reg = new RegExp(normalizedName, "i"); + const installedPlugin: Plugin | undefined = installedPlugins.find((plugin) => plugin.uses[0].plugin.match(reg)); + const defaultForInstalled: ManifestPreDecode | null = cleanManifestCache[url]; + const optionText = defaultForInstalled.name; + const indicator = installedPlugin ? "🟢" : "🔴"; + + const optionDiv = createElement("div", { class: "select-option" }); + const textSpan = createElement("span", { textContent: optionText }); + const indicatorSpan = createElement("span", { textContent: indicator }); + + optionDiv.appendChild(textSpan); + optionDiv.appendChild(indicatorSpan); + + optionDiv.addEventListener("click", () => { + selectSelected.textContent = optionText; + closeAllSelect(); + localStorage.setItem("selectedPluginManifest", JSON.stringify(defaultForInstalled)); + renderConfigEditor(renderer, defaultForInstalled, installedPlugin?.uses[0].with); + }); + + selectItems.appendChild(optionDiv); + }); + + selectSelected.addEventListener("click", (e) => { + e.stopPropagation(); + closeAllSelect(); + selectItems.classList.toggle(STRINGS.SELECT_HIDE); + selectSelected.classList.toggle(STRINGS.SELECT_ARROW_ACTIVE); + }); + + updateGuiTitle(`Select a Plugin`); +} diff --git a/static/scripts/rendering/repo-select.ts b/static/scripts/rendering/repo-select.ts new file mode 100644 index 0000000..14da202 --- /dev/null +++ b/static/scripts/rendering/repo-select.ts @@ -0,0 +1,99 @@ +import { createElement } from "../../utils/element-helpers"; +import { STRINGS } from "../../utils/strings"; +import { toastNotification } from "../../utils/toaster"; +import { ManifestRenderer } from "../render-manifest"; +import { controlButtons } from "./control-buttons"; +import { renderPluginSelector } from "./plugin-select"; +import { updateGuiTitle } from "./utils"; + +export function renderRepoPicker(renderer: ManifestRenderer, repos: Record): void { + renderer.currentStep = "repoPicker"; + controlButtons({ hide: true }); + renderer.backButton.style.display = "block"; + renderer.manifestGui?.classList.add("rendering"); + renderer.manifestGuiBody.innerHTML = null; + + if (!Reflect.ownKeys(repos).length) { + updateGuiTitle("No repositories found"); + renderer.manifestGuiBody.appendChild(document.createElement("tr")); + renderer.manifestGui?.classList.add("rendered"); + return; + } + + localStorage.setItem("orgRepos", JSON.stringify(repos)); + + const pickerRow = document.createElement("tr"); + const pickerCell = document.createElement("td"); + pickerCell.colSpan = 4; + pickerCell.className = STRINGS.TDV_CENTERED; + + updateGuiTitle("Select a Repository"); + const selectedOrg = localStorage.getItem("selectedOrg"); + + if (!selectedOrg) { + throw new Error(`No selected org found in local storage`); + } + + const repoSelect = createElement("select", { + id: "repo-picker-select", + class: STRINGS.PICKER_SELECT, + style: "width: 100%", + }); + + const defaultOption = createElement("option", { + value: null, + textContent: "Select a repository...", + }); + repoSelect.appendChild(defaultOption); + + const orgRepos = repos[selectedOrg]; + + if (!orgRepos) { + throw new Error("No org repos found"); + } + + orgRepos.forEach((repo) => { + const option = createElement("option", { + value: repo, + textContent: repo, + }); + repoSelect.appendChild(option); + }); + + repoSelect.addEventListener("change", (event) => handleRepoSelection(event, renderer)); + pickerCell.appendChild(repoSelect); + pickerRow.appendChild(pickerCell); + renderer.manifestGuiBody.appendChild(pickerRow); + renderer.manifestGui?.classList.add("rendered"); +} + +async function fetchOrgConfig(renderer: ManifestRenderer, org: string, repo: string): Promise { + const kill = toastNotification(`Fetching ${org} config...`, { type: "info", shouldAutoDismiss: true }); + const octokit = renderer.auth.octokit; + if (!octokit) { + throw new Error("No org or octokit found"); + } + await renderer.configParser.fetchUserInstalledConfig(org, octokit, repo); + kill(); +} + +function handleRepoSelection(event: Event, renderer: ManifestRenderer): void { + const selectElement = event.target as HTMLSelectElement; + const selectedRepo = selectElement.value; + if (selectedRepo) { + localStorage.setItem("selectedRepo", selectedRepo); + const selectedOrg = localStorage.getItem("selectedOrg"); + if (!selectedOrg) { + throw new Error("No selected org found"); + } + + fetchOrgConfig(renderer, selectedOrg, selectedRepo) + .then(() => { + renderPluginSelector(renderer); + }) + .catch((error) => { + console.error(error); + toastNotification("Error fetching org config", { type: "error" }); + }); + } +} diff --git a/static/scripts/rendering/utils.ts b/static/scripts/rendering/utils.ts new file mode 100644 index 0000000..39604a7 --- /dev/null +++ b/static/scripts/rendering/utils.ts @@ -0,0 +1,56 @@ +import { STRINGS } from "../../utils/strings"; + +// this relies on the manifest matching the repo name +export function normalizePluginName(pluginName: string): string { + return pluginName + .toLowerCase() + .replace(/ /g, "-") + .replace(/[^a-z0-9-]/g, "") + .replace(/-+/g, "-"); +} + +export function updateGuiTitle(title: string): void { + const guiTitle = document.querySelector("#manifest-gui-title"); + if (!guiTitle) { + throw new Error("GUI Title not found"); + } + guiTitle.textContent = title; +} + +export function closeAllSelect() { + const selectItemsList = document.querySelectorAll(STRINGS.SELECT_ITEMS); + const selectSelectedList = document.querySelectorAll(STRINGS.SELECT_SELECTED); + selectItemsList.forEach((item) => { + item.classList.add(STRINGS.SELECT_HIDE); + }); + selectSelectedList.forEach((item) => { + item.classList.remove(STRINGS.SELECT_ARROW_ACTIVE); + }); +} + +const eventListenersMap = new WeakMap>(); +export function addTrackedEventListener(target: EventTarget, type: string, listener: EventListener) { + if (!eventListenersMap.has(target)) { + eventListenersMap.set(target, new Map()); + } + const listeners = eventListenersMap.get(target)?.get(type) || []; + if (!listeners.map((l) => l.name).includes(listener.name)) { + listeners.push(listener); + eventListenersMap.get(target)?.set(type, listeners); + target.addEventListener(type, listener); + } +} + +export function removeTrackedEventListener(target: EventTarget, type: string, listener: EventListener) { + const listeners = eventListenersMap.get(target)?.get(type) || []; + const index = listeners.findIndex((l) => l.name === listener.name); + if (index !== -1) { + listeners.splice(index, 1); + eventListenersMap?.get(target)?.set(type, listeners); + target.removeEventListener(type, listener); + } +} + +export function getTrackedEventListeners(target: EventTarget, type: string): EventListener[] { + return eventListenersMap.get(target)?.get(type) || []; +} diff --git a/static/scripts/rendering/write-add-remove.ts b/static/scripts/rendering/write-add-remove.ts new file mode 100644 index 0000000..7c41607 --- /dev/null +++ b/static/scripts/rendering/write-add-remove.ts @@ -0,0 +1,141 @@ +import { toastNotification } from "../../utils/toaster"; +import { ManifestRenderer } from "../render-manifest"; +import { Manifest, Plugin } from "../../types/plugins"; +import { parseConfigInputs } from "./input-parsing"; +import { getOfficialPluginConfig } from "../../utils/storage"; +import { renderConfigEditor } from "./config-editor"; +import { normalizePluginName } from "./utils"; + +/** + * Writes the new configuration to the config file. This does not push the config to GitHub + * only updates the local config. The actual push event is handled via a toast notification. + * + * - Acts as a "save" button for the configuration editor + * - Adds or removes a plugin configuration from the config file + */ +export function writeNewConfig(renderer: ManifestRenderer, option: "add" | "remove") { + const selectedManifest = localStorage.getItem("selectedPluginManifest"); + if (!selectedManifest) { + toastNotification("No selected plugin manifest found.", { + type: "error", + shouldAutoDismiss: true, + }); + throw new Error("No selected plugin manifest found"); + } + const pluginManifest = JSON.parse(selectedManifest) as Manifest; + const configInputs = document.querySelectorAll(".config-input"); + + const { config: newConfig, missing } = parseConfigInputs(configInputs, pluginManifest); + + if (missing.length) { + toastNotification("Please fill out all required fields.", { + type: "error", + shouldAutoDismiss: true, + }); + missing.forEach((key) => { + const ele = document.querySelector(`[data-config-key="${key}"]`) as HTMLInputElement | HTMLTextAreaElement | null; + if (ele) { + ele.style.border = "1px solid red"; + ele.focus(); + } else { + console.log(`Input element with key ${key} not found`); + } + }); + return; + } + + renderer.configParser.loadConfig(); + const normalizedPluginName = normalizePluginName(pluginManifest.name); + const officialPluginConfig: Record = getOfficialPluginConfig(); + const pluginUrl = Object.keys(officialPluginConfig).find((url) => { + return url.includes(normalizedPluginName); + }); + + if (!pluginUrl) { + toastNotification(`No plugin URL found for ${normalizedPluginName}.`, { + type: "error", + shouldAutoDismiss: true, + }); + throw new Error("No plugin URL found"); + } + + const plugin: Plugin = { + uses: [ + { + plugin: pluginUrl, + with: newConfig, + }, + ], + }; + + if (option === "add") { + handleAddPlugin(renderer, plugin, pluginManifest); + } else if (option === "remove") { + handleRemovePlugin(renderer, plugin, pluginManifest); + } +} + +function handleAddPlugin(renderer: ManifestRenderer, plugin: Plugin, pluginManifest: Manifest): void { + renderer.configParser.addPlugin(plugin); + toastNotification(`Configuration for ${pluginManifest.name} saved successfully. Do you want to push to GitHub?`, { + type: "success", + actionText: "Push to GitHub", + shouldAutoDismiss: true, + action: () => notificationConfigPush(renderer), + }); +} + +function handleRemovePlugin(renderer: ManifestRenderer, plugin: Plugin, pluginManifest: Manifest): void { + renderer.configParser.removePlugin(plugin); + toastNotification(`Configuration for ${pluginManifest.name} removed successfully. Do you want to push to GitHub?`, { + type: "success", + actionText: "Push to GitHub", + shouldAutoDismiss: true, + action: () => notificationConfigPush(renderer), + }); +} + +async function notificationConfigPush(renderer: ManifestRenderer) { + const octokit = renderer.auth.octokit; + if (!octokit) { + throw new Error("Octokit not found"); + } + + const org = localStorage.getItem("selectedOrg"); + + if (!org) { + throw new Error("No selected org found"); + } + + try { + const selectedRepo = localStorage.getItem("selectedRepo"); + if (!selectedRepo) { + throw new Error("No selected repo found"); + } + + await renderer.configParser.updateConfig(org, octokit, selectedRepo); + } catch (error) { + console.error("Error pushing config to GitHub:", error); + toastNotification("An error occurred while pushing the configuration to GitHub.", { + type: "error", + shouldAutoDismiss: true, + }); + return; + } + + toastNotification("Configuration pushed to GitHub successfully.", { + type: "success", + shouldAutoDismiss: true, + }); +} + +export function handleResetToDefault(renderer: ManifestRenderer, pluginManifest: Manifest | null) { + if (!pluginManifest) { + throw new Error("No plugin manifest found"); + } + renderConfigEditor(renderer, pluginManifest); + const readmeContainer = document.querySelector(".readme-container"); + if (readmeContainer) { + readmeContainer.remove(); + } +} diff --git a/static/types/github.ts b/static/types/github.ts index b9ef981..96a9b94 100644 --- a/static/types/github.ts +++ b/static/types/github.ts @@ -3,13 +3,6 @@ import { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods"; export type GitHubUserResponse = RestEndpointMethodTypes["users"]["getByUsername"]["response"]; export type GitHubUser = GitHubUserResponse["data"]; -export type OrgWithInstall = { - org: string; - install: { - account: { - login: string; - id: number; - type: "Organization" | "User"; - }; - }; +export type ExtendedHtmlElement = { + [key in keyof T]: T[key] extends HTMLElement["innerHTML"] ? string | null : T[key]; }; diff --git a/static/types/plugins.ts b/static/types/plugins.ts index 3fe14b8..7f3404a 100644 --- a/static/types/plugins.ts +++ b/static/types/plugins.ts @@ -31,13 +31,12 @@ export type Manifest = { }; configuration: { type: string; - properties: { - [key: string]: { - default: unknown; - type?: string; - }; + default: object; + items?: { + type: string; }; + properties?: Record; + required?: string[]; }; + readme?: string; }; - -export type ManifestProps = { type: string; default: string; items?: { type: string }; properties?: Record }; diff --git a/static/utils/ele-helpers.ts b/static/utils/element-helpers.ts similarity index 83% rename from static/utils/ele-helpers.ts rename to static/utils/element-helpers.ts index dc9116a..b7865ff 100644 --- a/static/utils/ele-helpers.ts +++ b/static/utils/element-helpers.ts @@ -1,16 +1,19 @@ -import { ManifestProps } from "../types/plugins"; +import { Manifest } from "../types/plugins"; const CONFIG_INPUT_STR = "config-input"; export const manifestGuiBody = document.getElementById("manifest-gui-body"); -export function createElement(tagName: TK, attributes: { [key: string]: string | null }): HTMLElementTagNameMap[TK] { +export function createElement( + tagName: TK, + attributes: { [key: string]: string | boolean | null } +): HTMLElementTagNameMap[TK] { const element = document.createElement(tagName); Object.keys(attributes).forEach((key) => { if (key === "textContent") { - element.textContent = attributes[key]; + element.textContent = attributes[key] as string; } else if (key in element) { - (element as Record)[key] = attributes[key]; + (element as Record)[key] = attributes[key]; } else { element.setAttribute(key, `${attributes[key]}`); } @@ -19,18 +22,20 @@ export function createElement(tagName: T } export function createInputRow( key: string, - prop: ManifestProps, - configDefaults: Record + prop: Manifest["configuration"], + configDefaults: Record, + required = false ): void { const row = document.createElement("tr"); const headerCell = document.createElement("td"); headerCell.className = "table-data-header"; - headerCell.textContent = key; + headerCell.textContent = key.replace(/([A-Z])/g, " $1"); row.appendChild(headerCell); const valueCell = document.createElement("td"); valueCell.className = "table-data-value"; + valueCell.ariaRequired = `${required}`; const input = createInput(key, prop.default, prop); valueCell.appendChild(input); @@ -44,7 +49,7 @@ export function createInputRow( items: prop.items ? { type: prop.items.type } : null, }; } -export function createInput(key: string, defaultValue: unknown, prop: ManifestProps): HTMLElement { +export function createInput(key: string, defaultValue: unknown, prop: Manifest["configuration"]): HTMLElement { if (!key) { throw new Error("Input name is required"); } diff --git a/static/utils/storage.ts b/static/utils/storage.ts new file mode 100644 index 0000000..71d4cdc --- /dev/null +++ b/static/utils/storage.ts @@ -0,0 +1,9 @@ +import { ManifestCache } from "../types/plugins"; + +export function getManifestCache(): ManifestCache { + return JSON.parse(localStorage.getItem("manifestCache") || "{}"); +} + +export function getOfficialPluginConfig() { + return JSON.parse(localStorage.getItem("officialPluginConfig") || "{}"); +} diff --git a/static/utils/strings.ts b/static/utils/strings.ts new file mode 100644 index 0000000..bb65ebe --- /dev/null +++ b/static/utils/strings.ts @@ -0,0 +1,8 @@ +export const STRINGS = { + TDV_CENTERED: "table-data-value centered", + SELECT_ITEMS: ".select-items", + SELECT_SELECTED: ".select-selected", + SELECT_HIDE: "select-hide", + SELECT_ARROW_ACTIVE: "select-arrow-active", + PICKER_SELECT: "picker-select", +}; diff --git a/static/utils/toaster.ts b/static/utils/toaster.ts index 5170afd..d726515 100644 --- a/static/utils/toaster.ts +++ b/static/utils/toaster.ts @@ -1,4 +1,4 @@ -import { createElement } from "./ele-helpers"; +import { createElement } from "./element-helpers"; export function toastNotification( message: string, @@ -10,7 +10,7 @@ export function toastNotification( duration?: number; killAll?: boolean; } = {} -): void { +): () => void { const { type = "info", actionText, action, shouldAutoDismiss = false, duration = 5000 } = options; const toastElement = createElement("div", { @@ -68,10 +68,21 @@ export function toastNotification( toastElement.classList.add("show"); }); - if (shouldAutoDismiss) { - setTimeout(() => { + function kill(withTimeout = false) { + if (withTimeout) { + setTimeout(() => { + toastElement.classList.remove("show"); + setTimeout(() => toastElement.remove(), 250); + }, duration); + } else { toastElement.classList.remove("show"); setTimeout(() => toastElement.remove(), 250); - }, duration); + } + } + + if (shouldAutoDismiss) { + kill(shouldAutoDismiss); } + + return kill; } diff --git a/yarn.lock b/yarn.lock index 8e0604d..34babbc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15,7 +15,22 @@ "@actions/http-client" "^2.0.1" uuid "^8.3.2" -"@actions/github@6.0.0": +"@actions/core@^1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@actions/core/-/core-1.11.1.tgz#ae683aac5112438021588030efb53b1adb86f172" + integrity sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A== + dependencies: + "@actions/exec" "^1.1.1" + "@actions/http-client" "^2.0.1" + +"@actions/exec@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@actions/exec/-/exec-1.1.1.tgz#2e43f28c54022537172819a7cf886c844221a611" + integrity sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w== + dependencies: + "@actions/io" "^1.0.1" + +"@actions/github@6.0.0", "@actions/github@^6.0.0": version "6.0.0" resolved "https://registry.yarnpkg.com/@actions/github/-/github-6.0.0.tgz#65883433f9d81521b782a64cc1fd45eef2191ea7" integrity sha512-alScpSVnYmjNEXboZjarjukQEzgCRmjMv6Xj47fsdnqGS73bjJNDpiiXmp8jr0UZLdUB6d9jW63IcmddUP+l0g== @@ -33,6 +48,11 @@ tunnel "^0.0.6" undici "^5.25.4" +"@actions/io@^1.0.1": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@actions/io/-/io-1.1.3.tgz#4cdb6254da7962b07473ff5c335f3da485d94d71" + integrity sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q== + "@ampproject/remapping@^2.2.0": version "2.3.0" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" @@ -1659,6 +1679,11 @@ resolved "https://registry.yarnpkg.com/@octokit/openapi-webhooks-types/-/openapi-webhooks-types-8.3.0.tgz#a7a4da00c0f27f7f5708eb3fcebefa08f8d51125" integrity sha512-vKLsoR4xQxg4Z+6rU/F65ItTUz/EXbD+j/d4mlq2GW8TsA4Tc8Kdma2JTAAJ5hrKWUQzkR/Esn2fjsqiVRYaQg== +"@octokit/openapi-webhooks-types@8.5.1": + version "8.5.1" + resolved "https://registry.yarnpkg.com/@octokit/openapi-webhooks-types/-/openapi-webhooks-types-8.5.1.tgz#de421dbd3efb586e908a152eed3f0ae50698a2f2" + integrity sha512-i3h1b5zpGSB39ffBbYdSGuAd0NhBAwPyA3QV3LYi/lx4lsbZiu7u2UHgXVUR6EpvOI8REOuVh1DZTRfHoJDvuQ== + "@octokit/plugin-paginate-graphql@^5.2.4": version "5.2.4" resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-graphql/-/plugin-paginate-graphql-5.2.4.tgz#b6afda7b3f24cb93d2ab822ec8eac664a5d325d0" @@ -1671,7 +1696,7 @@ dependencies: "@octokit/types" "^13.5.0" -"@octokit/plugin-paginate-rest@^11.0.0": +"@octokit/plugin-paginate-rest@^11.0.0", "@octokit/plugin-paginate-rest@^11.3.5": version "11.3.5" resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.3.5.tgz#a1929b3ba3dc7b63bc73bb6d3c7a3faf2a9c7649" integrity sha512-cgwIRtKrpwhLoBi0CUNuY83DPGRMaWVjqVI/bGKsLJ4PzyWZNaEmhHroI2xlrVXkk6nFv0IsZpOp+ZWSWUS2AQ== @@ -1704,7 +1729,7 @@ dependencies: "@octokit/types" "^12.6.0" -"@octokit/plugin-rest-endpoint-methods@^13.0.0": +"@octokit/plugin-rest-endpoint-methods@^13.0.0", "@octokit/plugin-rest-endpoint-methods@^13.2.6": version "13.2.6" resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.2.6.tgz#b9d343dbe88a6cb70cc7fa16faa98f0a29ffe654" integrity sha512-wMsdyHMjSfKjGINkdGKki06VEkgdEldIGstIEyGX0wbYHGByOwN/KiM+hAAlUwAtPkP3gvXtVQA9L3ITdV2tVw== @@ -1720,6 +1745,15 @@ "@octokit/types" "^13.0.0" bottleneck "^2.15.3" +"@octokit/plugin-retry@^7.1.2": + version "7.1.2" + resolved "https://registry.yarnpkg.com/@octokit/plugin-retry/-/plugin-retry-7.1.2.tgz#242e2d19a72a50b5113bb25d7d2c622ce0373fa0" + integrity sha512-XOWnPpH2kJ5VTwozsxGurw+svB2e61aWlmk5EVIYZPwFK5F9h4cyPyj9CIKRyMXMHSwpIsI3mPOdpMmrRhe7UQ== + dependencies: + "@octokit/request-error" "^6.0.0" + "@octokit/types" "^13.0.0" + bottleneck "^2.15.3" + "@octokit/plugin-throttling@9.3.1": version "9.3.1" resolved "https://registry.yarnpkg.com/@octokit/plugin-throttling/-/plugin-throttling-9.3.1.tgz#5648165e1e70e861625f3a16af6c55cafe861061" @@ -1728,6 +1762,14 @@ "@octokit/types" "^13.0.0" bottleneck "^2.15.3" +"@octokit/plugin-throttling@^9.3.2": + version "9.3.2" + resolved "https://registry.yarnpkg.com/@octokit/plugin-throttling/-/plugin-throttling-9.3.2.tgz#cc05180e45e769d6726c5faed157e9ad3b6ab8c0" + integrity sha512-FqpvcTpIWFpMMwIeSoypoJXysSAQ3R+ALJhXXSG1HTP3YZOIeLmcNcimKaXxTcws+Sh6yoRl13SJ5r8sXc1Fhw== + dependencies: + "@octokit/types" "^13.0.0" + bottleneck "^2.15.3" + "@octokit/request-error@^5.1.0": version "5.1.0" resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-5.1.0.tgz#ee4138538d08c81a60be3f320cd71063064a3b30" @@ -1807,6 +1849,15 @@ "@octokit/request-error" "^6.0.1" "@octokit/webhooks-methods" "^5.0.0" +"@octokit/webhooks@^13.3.0": + version "13.4.1" + resolved "https://registry.yarnpkg.com/@octokit/webhooks/-/webhooks-13.4.1.tgz#608929916b0e0e5755fa5ca1de7484d2113bc6a9" + integrity sha512-I5YPUtfWidh+OzyrlDahJsUpkpGK0kCTmDRbuqGmlCUzOtxdEkX3R4d6Cd08ijQYwkVXQJanPdbKuZBeV2NMaA== + dependencies: + "@octokit/openapi-webhooks-types" "8.5.1" + "@octokit/request-error" "^6.0.1" + "@octokit/webhooks-methods" "^5.0.0" + "@open-draft/deferred-promise@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz#4a822d10f6f0e316be4d67b4d4f8c9a124b073bd" @@ -1840,6 +1891,11 @@ resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.33.21.tgz#651191b5cc13c27ae0cb2150f3af5255597a9961" integrity sha512-1wU0VNSZQt13BmJvxYhHRVwDBnG8y5qrcyi3DnmEQzvfeRycUNneQSd6quyxrNbspM1pV/m4r4udO6o1tCuXjg== +"@sinclair/typebox@^0.33.21": + version "0.33.22" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.33.22.tgz#3339d85172509095a8384cb4b44834a7c9309d86" + integrity sha512-auUj4k+f4pyrIVf4GW5UKquSZFHJWri06QgARy9C0t9ZTjJLIuNIrr1yl9bWcJWJ1Gz1vOvYN1D+QPaIlNMVkQ== + "@sinonjs/commons@^3.0.0": version "3.0.1" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" @@ -2029,16 +2085,34 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== +"@types/linkify-it@^5": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-5.0.0.tgz#21413001973106cda1c3a9b91eedd4ccd5469d76" + integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q== + "@types/lodash@^4.14.172": version "4.14.202" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.202.tgz#f09dbd2fb082d507178b2f2a5c7e74bd72ff98f8" integrity sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ== +"@types/markdown-it@^14.1.2": + version "14.1.2" + resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-14.1.2.tgz#57f2532a0800067d9b934f3521429a2e8bfb4c61" + integrity sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog== + dependencies: + "@types/linkify-it" "^5" + "@types/mdurl" "^2" + "@types/md5@^2.3.0": version "2.3.5" resolved "https://registry.yarnpkg.com/@types/md5/-/md5-2.3.5.tgz#481cef0a896e3a5dcbfc5a8a8b02c05958af48a5" integrity sha512-/i42wjYNgE6wf0j2bcTX6kuowmdL/6PE4IVitMpm2eYKBUuYCprdcWVK+xEF0gcV6ufMCRhtxmReGfc6hIK7Jw== +"@types/mdurl@^2": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd" + integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg== + "@types/mute-stream@^0.0.4": version "0.0.4" resolved "https://registry.yarnpkg.com/@types/mute-stream/-/mute-stream-0.0.4.tgz#77208e56a08767af6c5e1237be8888e2f255c478" @@ -2207,6 +2281,27 @@ "@typescript-eslint/types" "8.8.1" eslint-visitor-keys "^3.4.3" +"@ubiquity-os/plugin-sdk@^1.0.11": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@ubiquity-os/plugin-sdk/-/plugin-sdk-1.0.11.tgz#b45029a0bd7469b19e71d4685d9ee8e7163afe38" + integrity sha512-BlZbqOfuBYMFyDEJfPc9HCrr5l8m3uNOXmPXr/M8/UFwZT+nHfZfB+AULoY0Goyx2BX1JaHd5bgDjJG1PwozPA== + dependencies: + "@actions/core" "^1.11.1" + "@actions/github" "^6.0.0" + "@octokit/core" "^6.1.2" + "@octokit/plugin-paginate-graphql" "^5.2.4" + "@octokit/plugin-paginate-rest" "^11.3.5" + "@octokit/plugin-rest-endpoint-methods" "^13.2.6" + "@octokit/plugin-retry" "^7.1.2" + "@octokit/plugin-throttling" "^9.3.2" + "@octokit/rest" "^21.0.2" + "@octokit/types" "^13.6.1" + "@octokit/webhooks" "^13.3.0" + "@sinclair/typebox" "^0.33.21" + "@ubiquity-os/ubiquity-os-logger" "^1.3.2" + dotenv "^16.4.5" + hono "^4.6.9" + "@ubiquity-os/ubiquity-os-kernel@^2.5.3": version "2.5.3" resolved "https://registry.yarnpkg.com/@ubiquity-os/ubiquity-os-kernel/-/ubiquity-os-kernel-2.5.3.tgz#622aacdbccc1bed7d9115a08c30eb35a2eb2f308" @@ -3317,7 +3412,7 @@ dot-prop@^5.1.0: dependencies: is-obj "^2.0.0" -dotenv@16.4.5: +dotenv@16.4.5, dotenv@^16.4.5: version "16.4.5" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f" integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg== @@ -3397,6 +3492,11 @@ enquirer@^2.3.6: ansi-colors "^4.1.1" strip-ansi "^6.0.1" +entities@^4.4.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + env-paths@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" @@ -4230,6 +4330,11 @@ hono@4.4.13: resolved "https://registry.yarnpkg.com/hono/-/hono-4.4.13.tgz#954e8f6e4bab14f3f9d7bac4eef4c56d23e7f900" integrity sha512-c6qqenclmQ6wpXzqiElMa2jt423PVCmgBreDfC5s2lPPpGk7d0lOymd8QTzFZyYC5mSSs6imiTMPip+gLwuW/g== +hono@^4.6.9: + version "4.6.11" + resolved "https://registry.yarnpkg.com/hono/-/hono-4.6.11.tgz#8aa9bea754cfd6e295800652a107dd850bcf3c54" + integrity sha512-f0LwJQFKdUUrCUAVowxSvNCjyzI7ZLt8XWYU/EApyeq5FfOvHFarBaE5rjU9HTNFk4RI0FkdB2edb3p/7xZjzQ== + hosted-git-info@^2.1.4: version "2.8.9" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" @@ -5195,6 +5300,13 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== +linkify-it@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.0.tgz#9ef238bfa6dc70bd8e7f9572b52d369af569b421" + integrity sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ== + dependencies: + uc.micro "^2.0.0" + lint-staged@15.2.7: version "15.2.7" resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-15.2.7.tgz#97867e29ed632820c0fb90be06cd9ed384025649" @@ -5400,6 +5512,18 @@ makeerror@1.0.12: dependencies: tmpl "1.0.5" +markdown-it@^14.1.0: + version "14.1.0" + resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-14.1.0.tgz#3c3c5992883c633db4714ccb4d7b5935d98b7d45" + integrity sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg== + dependencies: + argparse "^2.0.1" + entities "^4.4.0" + linkify-it "^5.0.0" + mdurl "^2.0.0" + punycode.js "^2.3.1" + uc.micro "^2.1.0" + md5@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f" @@ -5409,6 +5533,11 @@ md5@^2.3.0: crypt "0.0.2" is-buffer "~1.1.6" +mdurl@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0" + integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w== + memorystream@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/memorystream/-/memorystream-0.3.1.tgz#86d7090b30ce455d63fbae12dda51a47ddcaf9b2" @@ -5967,6 +6096,11 @@ pump@^3.0.0: end-of-stream "^1.1.0" once "^1.3.1" +punycode.js@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7" + integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA== + punycode@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" @@ -6838,6 +6972,11 @@ typescript@5.6.2: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.2.tgz#d1de67b6bef77c41823f822df8f0b3bcff60a5a0" integrity sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw== +uc.micro@^2.0.0, uc.micro@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee" + integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A== + unbox-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e"