From 8b5a872c41f04d5008090af88e879c59eed58f87 Mon Sep 17 00:00:00 2001 From: Adam McKee Date: Mon, 11 Dec 2023 17:43:55 -0600 Subject: [PATCH] install binary script gen with webapp --- .dockerignore | 11 + .github/workflows/playwright.yml | 52 + .github/workflows/verify.yml | 86 + .github/workflows/www.yml | 42 + .gitignore | 16 + Dockerfile | 19 + README.md | 32 + backend/.env | 10 + backend/package.json | 38 + backend/sql/v001-init-schema.sql | 24 + backend/src/Backend.ts | 50 + backend/src/Database.test.ts | 106 + backend/src/Database.ts | 66 + backend/src/Login.ts | 56 + backend/src/Middleware.ts | 73 + backend/src/Routes.ts | 49 + backend/src/Template.ts | 13 + backend/src/User.ts | 68 + backend/src/req.d.ts | 6 + backend/tsconfig.json | 31 + backend/vitest.config.ts | 8 + ci_verify.sh | 21 + cli/.gitignore | 1 + cli/build.zig | 38 + cli/src/main.zig | 21 + cloud/aws.md | 24 + cloud/backend.nomad | 30 + cloud/postgres.nomad | 27 + contract/package.json | 25 + contract/src/Contract.ts | 17 + contract/tsconfig.json | 21 + docker-compose.yml | 15 + e2e/package.json | 16 + e2e/playwright.config.ts | 38 + e2e/start_app.sh | 26 + e2e/tests/tests.spec.ts | 28 + frontend/.env | 4 + frontend/.env.offline | 3 + frontend/assets/svg/bug.svg | 1 + frontend/assets/svg/eighty4.svg | 4 + frontend/assets/svg/favicon.svg | 1 + frontend/assets/svg/flip.svg | 15 + frontend/assets/svg/github.svg | 1 + frontend/assets/svg/languages/cpp.svg | 22 + frontend/assets/svg/languages/go.svg | 14 + frontend/assets/svg/languages/rust.svg | 11 + frontend/assets/svg/languages/zig.svg | 20 + frontend/assets/svg/logo_i.svg | 16 + frontend/assets/svg/logo_sh.svg | 10 + frontend/assets/svg/systems/linux.svg | 3 + frontend/assets/svg/systems/macos.svg | 3 + frontend/assets/svg/systems/windows.svg | 4 + frontend/build.sh | 24 + frontend/index.html | 439 ++++ frontend/package.json | 31 + frontend/public/bug.svg | 1 + frontend/public/eighty4.svg | 1 + frontend/public/favicon.svg | 1 + frontend/public/flip.svg | 1 + frontend/public/github.svg | 1 + frontend/public/inter.woff2 | Bin 0 -> 46704 bytes frontend/public/languages/cpp.svg | 1 + frontend/public/languages/go.svg | 1 + frontend/public/languages/rust.svg | 1 + frontend/public/languages/zig.svg | 1 + frontend/public/logo_i.svg | 1 + frontend/public/logo_sh.svg | 1 + frontend/public/systems/linux.svg | 1 + frontend/public/systems/macos.svg | 1 + frontend/public/systems/windows.svg | 1 + frontend/src/app.ts | 49 + frontend/src/components/ConfigureBinary.ts | 55 + frontend/src/components/ConfigureScript.ts | 103 + frontend/src/components/InterfaceControl.css | 57 + frontend/src/components/InterfaceControl.ts | 77 + frontend/src/components/RepositoryLink.ts | 124 + .../src/components/RepositoryNavigation.ts | 125 + .../src/components/RepositoryPagination.ts | 47 + frontend/src/components/SpinIndicator.ts | 43 + frontend/src/components/SystemLogo.ts | 38 + frontend/src/components/define.ts | 21 + frontend/src/configure.ts | 31 + frontend/src/createGitHubGraphApiClient.ts | 8 + frontend/src/customizations.css | 55 + frontend/src/customizations.ts | 32 + frontend/src/dom.ts | 7 + frontend/src/download.ts | 61 + frontend/src/graphPaper.css | 57 + frontend/src/graphPaper.ts | 32 + frontend/src/login.css | 111 + frontend/src/login.ts | 162 ++ frontend/src/parse.ts | 33 + frontend/src/router.ts | 53 + frontend/src/search.ts | 73 + frontend/src/sessionCache.ts | 22 + frontend/src/ui.css | 96 + frontend/src/ui.ts | 21 + frontend/src/vite-env.d.ts | 12 + frontend/templateVersion.js | 12 + frontend/tsconfig.json | 36 + frontend/vite.config.ts | 30 + github/package.json | 27 + github/src/GitHubApiClient.ts | 163 ++ github/src/Model.ts | 27 + github/src/graphApiTypes.ts | 78 + github/tsconfig.json | 22 + github/vitest.config.ts | 7 + offline/package.json | 24 + offline/src/Offline.ts | 71 + offline/src/data.ts | 138 + offline/tsconfig.json | 28 + package.json | 11 + pnpm-lock.yaml | 2282 +++++++++++++++++ pnpm-workspace.yaml | 8 + template/gold/debian.bash.Dockerfile | 13 + template/gold/debian.fish.Dockerfile | 9 + template/gold/debian.zsh.Dockerfile | 8 + template/gold/install_test.sh | 13 + template/gold/integration_test.sh | 79 + template/gold/scripts/maestro.sh | 146 ++ template/package.json | 29 + template/src/Distrubtions.ts | 9 + template/src/Generate.test.ts | 40 + template/src/Generate.ts | 214 ++ template/src/Resolution.test.ts | 29 + template/src/Resolution.ts | 43 + template/src/Template.ts | 3 + template/tsconfig.json | 20 + template/vitest.config.ts | 7 + 129 files changed, 7075 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/playwright.yml create mode 100644 .github/workflows/verify.yml create mode 100644 .github/workflows/www.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 backend/.env create mode 100644 backend/package.json create mode 100644 backend/sql/v001-init-schema.sql create mode 100644 backend/src/Backend.ts create mode 100644 backend/src/Database.test.ts create mode 100644 backend/src/Database.ts create mode 100644 backend/src/Login.ts create mode 100644 backend/src/Middleware.ts create mode 100644 backend/src/Routes.ts create mode 100644 backend/src/Template.ts create mode 100644 backend/src/User.ts create mode 100644 backend/src/req.d.ts create mode 100644 backend/tsconfig.json create mode 100644 backend/vitest.config.ts create mode 100755 ci_verify.sh create mode 100644 cli/.gitignore create mode 100644 cli/build.zig create mode 100644 cli/src/main.zig create mode 100644 cloud/aws.md create mode 100644 cloud/backend.nomad create mode 100644 cloud/postgres.nomad create mode 100644 contract/package.json create mode 100644 contract/src/Contract.ts create mode 100644 contract/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100755 e2e/start_app.sh create mode 100644 e2e/tests/tests.spec.ts create mode 100644 frontend/.env create mode 100644 frontend/.env.offline create mode 100644 frontend/assets/svg/bug.svg create mode 100644 frontend/assets/svg/eighty4.svg create mode 100644 frontend/assets/svg/favicon.svg create mode 100644 frontend/assets/svg/flip.svg create mode 100644 frontend/assets/svg/github.svg create mode 100644 frontend/assets/svg/languages/cpp.svg create mode 100644 frontend/assets/svg/languages/go.svg create mode 100644 frontend/assets/svg/languages/rust.svg create mode 100644 frontend/assets/svg/languages/zig.svg create mode 100644 frontend/assets/svg/logo_i.svg create mode 100644 frontend/assets/svg/logo_sh.svg create mode 100644 frontend/assets/svg/systems/linux.svg create mode 100644 frontend/assets/svg/systems/macos.svg create mode 100644 frontend/assets/svg/systems/windows.svg create mode 100755 frontend/build.sh create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/public/bug.svg create mode 100644 frontend/public/eighty4.svg create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/flip.svg create mode 100644 frontend/public/github.svg create mode 100644 frontend/public/inter.woff2 create mode 100644 frontend/public/languages/cpp.svg create mode 100644 frontend/public/languages/go.svg create mode 100644 frontend/public/languages/rust.svg create mode 100644 frontend/public/languages/zig.svg create mode 100644 frontend/public/logo_i.svg create mode 100644 frontend/public/logo_sh.svg create mode 100644 frontend/public/systems/linux.svg create mode 100644 frontend/public/systems/macos.svg create mode 100644 frontend/public/systems/windows.svg create mode 100644 frontend/src/app.ts create mode 100644 frontend/src/components/ConfigureBinary.ts create mode 100644 frontend/src/components/ConfigureScript.ts create mode 100644 frontend/src/components/InterfaceControl.css create mode 100644 frontend/src/components/InterfaceControl.ts create mode 100644 frontend/src/components/RepositoryLink.ts create mode 100644 frontend/src/components/RepositoryNavigation.ts create mode 100644 frontend/src/components/RepositoryPagination.ts create mode 100644 frontend/src/components/SpinIndicator.ts create mode 100644 frontend/src/components/SystemLogo.ts create mode 100644 frontend/src/components/define.ts create mode 100644 frontend/src/configure.ts create mode 100644 frontend/src/createGitHubGraphApiClient.ts create mode 100644 frontend/src/customizations.css create mode 100644 frontend/src/customizations.ts create mode 100644 frontend/src/dom.ts create mode 100644 frontend/src/download.ts create mode 100644 frontend/src/graphPaper.css create mode 100644 frontend/src/graphPaper.ts create mode 100644 frontend/src/login.css create mode 100644 frontend/src/login.ts create mode 100644 frontend/src/parse.ts create mode 100644 frontend/src/router.ts create mode 100644 frontend/src/search.ts create mode 100644 frontend/src/sessionCache.ts create mode 100644 frontend/src/ui.css create mode 100644 frontend/src/ui.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/templateVersion.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 github/package.json create mode 100644 github/src/GitHubApiClient.ts create mode 100644 github/src/Model.ts create mode 100644 github/src/graphApiTypes.ts create mode 100644 github/tsconfig.json create mode 100644 github/vitest.config.ts create mode 100644 offline/package.json create mode 100644 offline/src/Offline.ts create mode 100644 offline/src/data.ts create mode 100644 offline/tsconfig.json create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 template/gold/debian.bash.Dockerfile create mode 100644 template/gold/debian.fish.Dockerfile create mode 100644 template/gold/debian.zsh.Dockerfile create mode 100755 template/gold/install_test.sh create mode 100755 template/gold/integration_test.sh create mode 100644 template/gold/scripts/maestro.sh create mode 100644 template/package.json create mode 100644 template/src/Distrubtions.ts create mode 100644 template/src/Generate.test.ts create mode 100644 template/src/Generate.ts create mode 100644 template/src/Resolution.test.ts create mode 100644 template/src/Resolution.ts create mode 100644 template/src/Template.ts create mode 100644 template/tsconfig.json create mode 100644 template/vitest.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..05ee288 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.idea +docker-compose.yml +node_modules +dist +*/tsconfig.tsbuildinfo +*/lib +backend/.env +backend/public +e2e +frontend/.env.offline +frontend/.env.production diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..b738b8f --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,52 @@ +name: e2e + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + ports: + - 5432:5432 + env: + POSTGRES_DB: eighty4 + POSTGRES_USER: eighty4 + POSTGRES_PASSWORD: eighty4 + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: sql + run: | + sudo apt-get install -y postgresql-client + PGPASSWORD=eighty4 psql -h localhost -U eighty4 -f backend/sql/v001-init-schema.sql eighty4 + - name: pnpm + run: | + corepack enable && corepack prepare pnpm@latest --activate + pnpm i + - name: playwright + env: + CI: true + run: | + pnpm exec playwright install --with-deps + pnpm exec playwright test + working-directory: e2e + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: e2e/playwright-report/ + retention-days: 30 diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..fd77c89 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,86 @@ +name: verify + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +concurrency: verify-ci + +jobs: + + verified: + runs-on: ubuntu-latest + needs: + - verify-backend + - verify-frontend + - verify-template + steps: + - uses: actions/checkout@v4 + - run: echo "44.481800,-88.054413" + + verify-backend: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + ports: + - 5432:5432 + env: + POSTGRES_DB: eighty4 + POSTGRES_USER: eighty4 + POSTGRES_PASSWORD: eighty4 + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: sql + run: | + sudo apt-get install -y postgresql-client + PGPASSWORD=eighty4 psql -h localhost -U eighty4 -f backend/sql/v001-init-schema.sql eighty4 + - name: verify + run: | + corepack enable && corepack prepare pnpm@latest --activate + pnpm i + pnpm build + pnpm test + working-directory: backend + + verify-frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: verify + run: | + apt-get update -y && apt-get install -y brotli + corepack enable && corepack prepare pnpm@latest --activate + pnpm i + pnpm build + pnpm svg + working-directory: frontend + + verify-template: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: verify + shell: 'script -q -e -c "bash {0}"' + run: | + corepack enable && corepack prepare pnpm@latest --activate + pnpm i + pnpm build + pnpm test + working-directory: template diff --git a/.github/workflows/www.yml b/.github/workflows/www.yml new file mode 100644 index 0000000..7505d2e --- /dev/null +++ b/.github/workflows/www.yml @@ -0,0 +1,42 @@ +name: deploy frontend to gh pages + +on: + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + steps: + - name: Checkout + uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Build + run: | + corepack enable && corepack prepare pnpm@latest --activate + pnpm i + pnpm build + working-directory: frontend + - name: Setup Pages + uses: actions/configure-pages@v4 + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: 'frontend/dist' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f04f34 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.DS_Store +.idea +node_modules +tsconfig.tsbuildinfo + +backend/lib +backend/public +contract/lib +e2e/blob-report +e2e/playwright/.cache +e2e/playwright-report +e2e/test-results +frontend/.env.production +frontend/dist +github/lib +template/lib diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..46e774d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM node:20-alpine as build + +WORKDIR /install.sh +COPY . . + +RUN corepack enable && corepack prepare pnpm@latest --activate +RUN pnpm i +RUN pnpm --filter @eighty4/install-frontend build && mv frontend/dist backend/public +RUN pnpm --filter @eighty4/install-backend build +RUN pnpm --filter @eighty4/install-backend --prod deploy dist + +FROM node:20-alpine + +WORKDIR /install.sh +COPY --from=build /install.sh/dist /install.sh + +EXPOSE 5741 + +CMD ["node", "lib/Server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..08b885a --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# install.eighty4.tech (unfortunately not install.sh) + +## Development + +| Package | Command | +|----------|----------| +| frontend | pnpm dev | +| backend | pnpm dev | + +Database features are currently disabled to migrate the backend to a serverless environment. + +### Offline development + +Auth and features are dependent on GitHub APIs and thus require network connectivity during development. + +These commands will stub API dependencies for offline development: + +| Package | Command | +|----------|------------------| +| frontend | pnpm dev:offline | +| offline | pnpm dev | + +Selfies and #installsh tweets while developing on an airplane, on safari, or in a James Cameron-funded submersible expedition are appreciated! + +## Examples install scripts in the wild + +Here is a list of install scripts for popular applications: + +- homebrew +- pnpm +- rustup +- wasm-pack diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..ff0d364 --- /dev/null +++ b/backend/.env @@ -0,0 +1,10 @@ +# https://github.com/settings/developers +GITHUB_CLIENT_ID="" +GITHUB_CLIENT_SECRET="" + +# https://node-postgres.com/features/connecting#environment-variables +PGHOST="localhost" +PGPORT="5432" +PGDATABASE="eighty4" +PGUSER="eighty4" +PGPASSWORD="eighty4" diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..d017469 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,38 @@ +{ + "name": "@eighty4/install-backend", + "version": "0.0.1", + "private": true, + "author": "Adam McKee ", + "license": "BSD-3-Clause", + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "dev": "tsx -r dotenv/config --watch src/Backend.ts", + "build": "tsc --build --clean && tsc --build" + }, + "dependencies": { + "@eighty4/install-contract": "workspace:^", + "@eighty4/install-github": "workspace:^", + "@eighty4/install-template": "workspace:^", + "cookie-parser": "^1.4.6", + "express": "5.0.0-beta.3", + "pg": "^8.11.5", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@types/cookie-parser": "^1.4.7", + "@types/express": "^4.17.21", + "@types/pg": "8.11.2", + "@types/uuid": "^9.0.8", + "dotenv": "^16.4.5", + "tsx": "^4.7.2", + "typescript": "^5.4.4", + "vitest": "^1.4.0" + }, + "files": [ + "public/**/*", + "lib/**/*", + "package.json" + ] +} diff --git a/backend/sql/v001-init-schema.sql b/backend/sql/v001-init-schema.sql new file mode 100644 index 0000000..8c1729a --- /dev/null +++ b/backend/sql/v001-init-schema.sql @@ -0,0 +1,24 @@ +drop schema if exists install_sh cascade; + +create schema install_sh; + +create table install_sh.users +( + id bigint primary key, + email varchar not null, + access_token varchar not null, + created_when timestamp not null default now(), + authed_when timestamp not null default now() +); + +create table install_sh.scripts +( + id bigserial primary key, + user_id integer not null references install_sh.users (id), + repo_owner varchar not null, + repo_name varchar not null, + template_version varchar not null, + created_when timestamp not null default now(), + generated_when timestamp not null default now(), + constraint script_user_repo_key unique (user_id, repo_owner, repo_name) +); diff --git a/backend/src/Backend.ts b/backend/src/Backend.ts new file mode 100644 index 0000000..d4bc7da --- /dev/null +++ b/backend/src/Backend.ts @@ -0,0 +1,50 @@ +// import net from 'node:net' +import cookieParser from 'cookie-parser' +import express from 'express' +import {authorizeGitHubUser, compressedStaticServer, printHttpLog} from './Middleware.js' +import {generateScriptRouteFn, getLoginResultRouteFn, getOAuthRedirectRouteFn} from './Routes.js' + +const parsePortEnvVariable = (key: string, def: number): number => { + const val = process.env[key] + if (typeof val === 'undefined') { + return def + } else { + try { + return parseInt(val, 10) + } catch (e) { + console.error(`env variable ${key} must be numeric`) + process.exit(1) + } + } +} + +const HTTP_PORT = parsePortEnvVariable('HTTP_PORT', 5741) +// const TCP_PORT = parsePortEnvVariable('TCP_PORT', 7411) + +const app = express() +app.use(cookieParser()) +app.use(express.json()) + +if (process.env['NODE_ENV'] === 'production') { + app.use(compressedStaticServer) + app.use(express.static('public')) +} + +app.use(printHttpLog) + +app.get('/login/oauth/github', getOAuthRedirectRouteFn) +app.get('/login/notify', getLoginResultRouteFn) +app.post('/api/script', authorizeGitHubUser, generateScriptRouteFn) + +app.listen(HTTP_PORT, () => { + console.log('install.sh http listening on', HTTP_PORT) +}) + +// const tcp = net.createServer((c) => { +// console.log('#feelthezig') +// c.end() +// }) +// +// tcp.listen(TCP_PORT, () => { +// console.log('install.sh tcp listening on', TCP_PORT) +// }) diff --git a/backend/src/Database.test.ts b/backend/src/Database.test.ts new file mode 100644 index 0000000..aa5d346 --- /dev/null +++ b/backend/src/Database.test.ts @@ -0,0 +1,106 @@ +import pg from 'pg' +import {describe, expect, it} from 'vitest' +import {loadGeneratedScripts, saveTemplateVersion, saveUserLogin} from './Database.js' + +const db = new pg.Pool() + +const randomUserId = () => (Math.floor(Math.random() * 10000000)) + +describe('Database', () => { + + describe('saveUserLogin', () => { + + it('saves new user', async () => { + const userId = randomUserId() + const newUser = await saveUserLogin(userId, 'adam@eighty4.tech', 'open sesame') + expect(newUser).toBe(true) + const result = await db.query('select * from install_sh.users where id = $1', [userId]) + expect(result.rows.length).toBe(1) + expect(result.rows[0].email).toBe('adam@eighty4.tech') + expect(result.rows[0].access_token).toBe('open sesame') + expect(result.rows[0].authed_when).toBeDefined() + expect(result.rows[0].authed_when).toStrictEqual(result.rows[0].created_when) + }) + + it('saves login for existing user', async () => { + const userId = randomUserId() + let newUser = await saveUserLogin(userId, 'adam@eighty4.tech', 'accessToken0') + expect(newUser).toBe(true) + const result1 = await db.query('select * from install_sh.users where id = $1', [userId]) + expect(result1.rows[0].email).toBe('adam@eighty4.tech') + expect(result1.rows[0].access_token).toBe('accessToken0') + newUser = await saveUserLogin(userId, 'mckee@eighty4.tech', 'accessToken1') + expect(newUser).toBe(false) + const result2 = await db.query('select * from install_sh.users where id = $1', [userId]) + expect(result2.rows[0].email).toBe('mckee@eighty4.tech') + expect(result2.rows[0].access_token).toBe('accessToken1') + expect(result2.rows[0].authed_when).not.toStrictEqual(result1.rows[0].created_when) + expect(result2.rows[0].created_when < result2.rows[0].authed_when).toBe(true) + }) + }) + + describe('loadGeneratedScripts', () => { + + it('returns empty array if no generated scripts for user', async () => { + const userId = randomUserId() + const result = await loadGeneratedScripts(userId) + expect(result).toStrictEqual([]) + }) + + it('retrieves generated scripts', async () => { + const userId = randomUserId() + await db.query('insert into install_sh.users (id, email, access_token) values ($1, $2, $3)', [userId, 'adam@eighty4.tech', 'open sesame'] as Array) + await db.query('insert into install_sh.scripts (user_id, repo_owner, repo_name, template_version) values ($1, $2, $3, $4)', [userId, 'eighty4', 'maestro', '1'] as Array) + await db.query('insert into install_sh.scripts (user_id, repo_owner, repo_name, template_version) values ($1, $2, $3, $4)', [userId, 'eighty4', 'cquill', '2'] as Array) + const result = await loadGeneratedScripts(userId) + expect(result).toHaveLength(2) + expect(result[0]).toStrictEqual({ + repository: { + owner: 'eighty4', + name: 'cquill', + }, + templateVersion: '2', + }) + expect(result[1]).toStrictEqual({ + repository: { + owner: 'eighty4', + name: 'maestro', + }, + templateVersion: '1', + }) + }) + }) + + describe('saveGeneratedScript', () => { + + it('saves generated script for new repository', async () => { + const userId = randomUserId() + await db.query('insert into install_sh.users (id, email, access_token) values ($1, $2, $3)', [userId, 'adam@eighty4.tech', 'open sesame']) + const repository = {owner: 'eighty4', name: 'maestro'} + await saveTemplateVersion(userId, repository, '1') + const result = await db.query('select * from install_sh.scripts where user_id = $1', [userId]) + expect(result.rows).toHaveLength(1) + const {template_version, repo_name, repo_owner, created_when, generated_when} = result.rows[0] + expect(repo_owner).toBe('eighty4') + expect(repo_name).toBe('maestro') + expect(template_version).toBe('1') + expect(created_when).toStrictEqual(generated_when) + }) + + it('updates generated script template version', async () => { + const userId = randomUserId() + await db.query('insert into install_sh.users (id, email, access_token) values ($1, $2, $3)', [userId, 'adam@eighty4.tech', 'open sesame']) + const repository = {owner: 'eighty4', name: 'maestro'} + await saveTemplateVersion(userId, repository, '1') + await new Promise((res) => setTimeout(res, 100)) + await saveTemplateVersion(userId, repository, '2') + const result = await db.query('select * from install_sh.scripts where user_id = $1', [userId]) + expect(result.rows).toHaveLength(1) + const {template_version, repo_name, repo_owner, created_when, generated_when} = result.rows[0] + expect(repo_owner).toBe('eighty4') + expect(repo_name).toBe('maestro') + expect(template_version).toBe('2') + expect(generated_when > created_when).toBe(true) + }) + }) +}) diff --git a/backend/src/Database.ts b/backend/src/Database.ts new file mode 100644 index 0000000..b3e8c86 --- /dev/null +++ b/backend/src/Database.ts @@ -0,0 +1,66 @@ +import pg from 'pg' +import type {GeneratedScript} from '@eighty4/install-contract' +import type {Repository} from '@eighty4/install-github' + +// https://node-postgres.com/features/connecting#environment-variables +const connectionPool = new pg.Pool({ + max: 20, + maxUses: 1000, +}) + +export async function saveUserLogin(userId: number, email: string, accessToken: string): Promise { + const result = await connectionPool.query({ + name: 'save-user', + text: ` + insert into install_sh.users (id, email, access_token) + values ($1, $2, $3) + on conflict (id) do update + set email = excluded.email, + access_token = excluded.access_token, + authed_when = now() + returning (created_when = authed_when) as new_user + `, + values: [userId, email, accessToken], + }) + return result.rows[0].new_user +} + +export async function loadGeneratedScripts(userId: number): Promise> { + const result = await connectionPool.query({ + name: 'select-scripts', + text: ` + select * + from install_sh.scripts + where user_id = $1 + order by generated_when desc + `, + values: [userId], + }) + const scripts: Array = [] + for (const row of result.rows) { + scripts.push({ + repository: { + owner: row.repo_owner, + name: row.repo_name, + }, + templateVersion: row.template_version, + }) + } + return scripts +} + +// todo add counter column +// todo save release tag +export async function saveTemplateVersion(userId: number, repository: Repository, templateVersion: string) { + await connectionPool.query({ + name: 'save-template-version', + text: ` + insert into install_sh.scripts (user_id, repo_owner, repo_name, template_version) + values ($1, $2, $3, $4) + on conflict on constraint script_user_repo_key do update + set template_version = excluded.template_version, + generated_when = now() + `, + values: [userId, repository.owner, repository.name, templateVersion], + }) +} diff --git a/backend/src/Login.ts b/backend/src/Login.ts new file mode 100644 index 0000000..78fb178 --- /dev/null +++ b/backend/src/Login.ts @@ -0,0 +1,56 @@ +import {v4 as createUuid} from 'uuid' +import {fetchAccessToken, fetchEmail, fetchUserId} from './User.js' + +// todo https://blog.logrocket.com/complete-guide-abortcontroller-node-js +class PromiseMap { + private readonly resultById: Record }> = {} + private readonly resultByTimestamp: Array<{ id: string, timestamp: number }> = [] + + add(id: string, promise: Promise) { + const timestamp = Date.now() + this.resultByTimestamp.push({id, timestamp}) + this.resultById[id] = {timestamp, promise} + } + + remove(id: string) { + delete this.resultById[id] + this.resultByTimestamp.splice(this.resultByTimestamp.findIndex((obj: any) => obj.id === id), 1) + } + + resolve(id: string): Promise { + const result = this.resultById[id] + this.remove(id) + if (!result) { + throw new Error('ain\'t shit here for you') + } + return result.promise + } +} + +export interface AuthedUser { + accessToken: string + email: string + userId: number +} + +const map = new PromiseMap() + +// todo error handling does not bubble up to resolveLogin(loginId) +async function loginSequence(code: string): Promise { + const accessToken = await fetchAccessToken(code) + const [email, userId] = await Promise.all([ + fetchEmail(accessToken), + fetchUserId(accessToken), + ]) + return {accessToken, email, userId} +} + +export async function initiateLogin(code: string): Promise { + const loginId = createUuid() + map.add(loginId, loginSequence(code)) + return loginId +} + +export async function resolveLogin(loginId: string): Promise { + return map.resolve(loginId) +} diff --git a/backend/src/Middleware.ts b/backend/src/Middleware.ts new file mode 100644 index 0000000..899eedb --- /dev/null +++ b/backend/src/Middleware.ts @@ -0,0 +1,73 @@ +import path from 'node:path' +import type {RequestHandler} from 'express' +import {verifyAccessToken} from './User.js' + +export const authorizeGitHubUser: RequestHandler = (req, res, next) => { + const accessToken = req.cookies.ght + verifyAccessToken(accessToken) + .then((user) => { + if (user === false) { + if (req.path.startsWith('/api/')) { + res.status(401).end() + } else { + res.redirect(301, 'http://localhost:5711') + } + } else { + req.user = user + next() + } + }) + .catch((e) => { + console.error('gh authorize error:', e.message) + if (req.path.startsWith('/api/')) { + res.status(500).end() + } else { + res.redirect(301, 'http://localhost:5711?error') + } + }) +} + +export const compressedStaticServer: RequestHandler = (req, res, next) => { + if (req.method === 'GET') { + const acceptEncoding = req.headers['accept-encoding'] + if (acceptEncoding) { + if ('/' === req.url) { + const headers: Record = {} + headers['Content-Type'] = 'text/html' + if (acceptEncoding.includes('br')) { + headers['Content-Encoding'] = 'br' + return res.sendFile(path.join(process.cwd(), 'public', 'index.html.br'), {headers}) + } else if (acceptEncoding.includes('gzip')) { + headers['Content-Encoding'] = 'gzip' + return res.sendFile(path.join(process.cwd(), 'public', 'index.html.gz'), {headers}) + } + } else if (/^\/(assets\/)?.*\.(css|js)$/.test(req.url)) { + const extension = req.url.substring(req.url.lastIndexOf('.') + 1) + const headers: Record = {} + headers['Content-Type'] = extension === 'js' ? 'text/javascript' : 'text/' + extension + if (acceptEncoding.includes('br')) { + headers['Content-Encoding'] = 'br' + return res.sendFile(path.join(process.cwd(), 'public', req.url + '.br'), {headers}) + } else if (acceptEncoding.includes('gzip')) { + headers['Content-Encoding'] = 'gzip' + return res.sendFile(path.join(process.cwd(), 'public', req.url + '.gz'), {headers}) + } + } + } + } + next() +} + +export const printHttpLog: RequestHandler = (req, res, next) => { + const start = Date.now() + res.on('finish', () => { + const uri = decodeURI(req.url) + const end = `${Date.now() - start}ms` + if (res.statusCode === 301) { + console.log(req.method, uri, res.statusCode, res.statusMessage, 'to', res.getHeaders().location, end) + } else { + console.log(req.method, uri, res.statusCode, res.statusMessage, end) + } + }) + next() +} diff --git a/backend/src/Routes.ts b/backend/src/Routes.ts new file mode 100644 index 0000000..09ddcd2 --- /dev/null +++ b/backend/src/Routes.ts @@ -0,0 +1,49 @@ +import type {RequestHandler} from 'express' +import {generateScript} from '@eighty4/install-template' +import {saveTemplateVersion} from './Database.js' +import {initiateLogin, resolveLogin} from './Login.js' +import {resolveTemplateRuntimeVersion} from './Template.js' + +const TEMPLATE_VERSION = resolveTemplateRuntimeVersion() + +export function createAccessTokenCookie(accessToken: string): string { + if (process.env.NODE_ENV === 'production') { + return `ght=${accessToken}; Secure; SameSite=Strict; Path=/` + } else { + return `ght=${accessToken}; SameSite=Strict; Path=/` + } +} + +export const getOAuthRedirectRouteFn: RequestHandler = async (req, res) => { + try { + const loginId = await initiateLogin(req.query.code as string) + res.redirect(301, 'http://localhost:5711?login=' + loginId) + } catch (e: any) { + console.error('auth redirect from github wtf', e.message) + res.redirect(301, 'http://localhost:5711?auth=failed') + } +} + +export const getLoginResultRouteFn: RequestHandler = async (req, res) => { + const loginId = req.query.login as string + if (!loginId || !loginId.length) { + res.status(400).send() + } else { + try { + const authData = await resolveLogin(loginId) + res.setHeader('Set-Cookie', createAccessTokenCookie(authData.accessToken)) + res.end() + } catch (e: any) { + console.error('auth resolve wtf', e.message) + res.status(500).send() + } + } +} + +export const generateScriptRouteFn: RequestHandler = async (req, res) => { + const script = generateScript(req.body) + await saveTemplateVersion(req.user!.userId, req.body.repository, TEMPLATE_VERSION) + res.set('Content-Type', 'text/plain') + res.set('Content-Disposition', 'inline; filename="draft.sh"') + res.send(script) +} diff --git a/backend/src/Template.ts b/backend/src/Template.ts new file mode 100644 index 0000000..5058dbb --- /dev/null +++ b/backend/src/Template.ts @@ -0,0 +1,13 @@ +import {readFileSync} from 'node:fs' + +export function resolveTemplateRuntimeVersion() { + try { + let packageJson = import.meta.resolve('../node_modules/@eighty4/install-template/package.json') + if (packageJson.startsWith('file:///')) { + packageJson = packageJson.substring(7) + } + return JSON.parse(readFileSync(packageJson).toString()).version + } catch (e) { + throw new Error('failed resolving version of @eighty4/install-template') + } +} diff --git a/backend/src/User.ts b/backend/src/User.ts new file mode 100644 index 0000000..97906a4 --- /dev/null +++ b/backend/src/User.ts @@ -0,0 +1,68 @@ +const client_id = process.env.GITHUB_CLIENT_ID +const client_secret = process.env.GITHUB_CLIENT_SECRET + +export interface GitHubUser { + userId: number + login: string + accessToken: string +} + +export async function fetchAccessToken(code: string): Promise { + const response = await fetch('https://github.com/login/oauth/access_token', { + method: 'post', + body: JSON.stringify({client_id, client_secret, code}), + headers: { + 'Content-Type': 'application/json', + }, + }) + const formData = await response.formData() + const accessToken = formData.get('access_token') as string | null + if (accessToken) { + return accessToken + } else { + throw new Error('wtf happened?') + } +} + +export async function fetchEmail(accessToken: string): Promise { + const response = await fetch('https://api.github.com/user/public_emails', { + headers: { + 'Authorization': 'Bearer ' + accessToken, + 'X-GitHub-Api-Version': '2022-11-28', + }, + }) + return (await response.json()).find((email: any) => email.primary).email +} + +export async function fetchUserId(accessToken: string): Promise { + const response = await fetch('https://api.github.com/user', { + headers: { + 'Authorization': 'Bearer ' + accessToken, + 'X-GitHub-Api-Version': '2022-11-28', + }, + }) + return (await response.json()).id +} + +export async function verifyAccessToken(accessToken: string): Promise { + if (!accessToken || !accessToken.length) { + return false + } + const response = await fetch(`https://api.github.com/applications/${client_id}/token`, { + method: 'POST', + headers: { + 'Authorization': `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString('base64')}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + body: JSON.stringify({access_token: accessToken}), + }) + if (response.status !== 200) { + return false + } + const payload = await response.json() + return { + userId: payload.user.id, + login: payload.user.login, + accessToken, + } +} diff --git a/backend/src/req.d.ts b/backend/src/req.d.ts new file mode 100644 index 0000000..a75b048 --- /dev/null +++ b/backend/src/req.d.ts @@ -0,0 +1,6 @@ +declare namespace Express { + export interface Request { + user?: { accessToken: string, login: string, userId: number }, + cookies: { ght?: string }, + } +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..7b7b304 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "composite": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": false, + "strict": true, + "skipLibCheck": true, + "outDir": "lib", + "rootDir": "src" + }, + "files": [ + "src/req.d.ts" + ], + "include": [ + "src/**/*.ts" + ], + "references": [ + { + "path": "../contract" + }, + { + "path": "../github" + }, + { + "path": "../template" + } + ] +} diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts new file mode 100644 index 0000000..3286c59 --- /dev/null +++ b/backend/vitest.config.ts @@ -0,0 +1,8 @@ +import {defineConfig} from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + setupFiles: ['dotenv/config'], + }, +}) diff --git a/ci_verify.sh b/ci_verify.sh new file mode 100755 index 0000000..bed5675 --- /dev/null +++ b/ci_verify.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -e + +# run through all the checks done for ci + +cd backend +echo '\n*** backend ***' +pnpm build +pnpm test +cd .. + +cd frontend +echo '\n*** frontend ***' +pnpm build +cd .. + +cd template +echo '\n*** template ***' +pnpm build +pnpm test +cd .. diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..2040c29 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1 @@ +zig-cache diff --git a/cli/build.zig b/cli/build.zig new file mode 100644 index 0000000..194972b --- /dev/null +++ b/cli/build.zig @@ -0,0 +1,38 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const exe = b.addExecutable(.{ + .name = "cli", + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + + const options = b.addOptions(); + options.addOption([4]u8, "install_sh_ip", .{ 127, 0, 0, 1 }); + options.addOption(u16, "install_sh_port", 2506); + exe.addOptions("build_options", options); + + b.installArtifact(exe); + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + const unit_tests = b.addTest(.{ + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + + const run_unit_tests = b.addRunArtifact(unit_tests); + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_unit_tests.step); +} diff --git a/cli/src/main.zig b/cli/src/main.zig new file mode 100644 index 0000000..99bab98 --- /dev/null +++ b/cli/src/main.zig @@ -0,0 +1,21 @@ +const options = @import("build_options"); +const std = @import("std"); + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + const allocator = gpa.allocator(); + const tcp_stream = makeTcpStream(allocator); + defer tcp_stream.close(); + const n = try tcp_stream.write("hello from cli"); + std.debug.print("tcp conn to install.sh sent {d} bytes\n", .{n}); +} + +fn makeTcpStream(allocator: std.mem.Allocator) std.net.Stream { + const address = std.net.Address.initIp4(options.install_sh_ip, options.install_sh_port); + if (std.net.tcpConnectToAddress(allocator, address)) |tcp_stream| { + return tcp_stream; + } else |err| { + std.debug.print("tcp conn to install.sh failed: {}\n", .{err}); + std.process.exit(1); + } +} diff --git a/cloud/aws.md b/cloud/aws.md new file mode 100644 index 0000000..f19cc9f --- /dev/null +++ b/cloud/aws.md @@ -0,0 +1,24 @@ +t4g arm instance +t4g.medium +$12 per month +2 cpu 4 gb + +t3 intel instance +t3.medium +$15 per month +2 cpu 4 gb + +CICD on AWS would be with CodeBuild or CodePipeline + https://aws.amazon.com/blogs/devops/build-arm-based-applications-using-codebuild/ + +install docker +configure systemd postgres and install.sh services + https://blog.container-solutions.com/running-docker-containers-with-systemd + +cicd update install.sh with ssh restart + https://unix.stackexchange.com/a/395781 + +send to grafana cloud +- machine metrics +- container metrics +- container logs diff --git a/cloud/backend.nomad b/cloud/backend.nomad new file mode 100644 index 0000000..94574b4 --- /dev/null +++ b/cloud/backend.nomad @@ -0,0 +1,30 @@ +job "install.backend" { + type = "service" + + group "install.backend" { + count = 1 + + task "install.backend" { + driver = "docker" + + config { + image = "84tech/install.backend:latest" + + auth { + config = "/Users/adam/.docker/config.json" + } + } + + env { + } + } + } + + update { + max_parallel = 1 + min_healthy_time = "5s" + healthy_deadline = "3m" + auto_revert = false + canary = 0 + } +} diff --git a/cloud/postgres.nomad b/cloud/postgres.nomad new file mode 100644 index 0000000..1a375b5 --- /dev/null +++ b/cloud/postgres.nomad @@ -0,0 +1,27 @@ +job "postgres" { + type = "service" + + group "postgres" { + count = 1 + + task "postgres" { + driver = "docker" + + config { + image = "postgres:16" + } + + env { + POSTGRES_PASSWORD = eighty4 + } + } + } + + update { + max_parallel = 1 + min_healthy_time = "5s" + healthy_deadline = "3m" + auto_revert = false + canary = 0 + } +} diff --git a/contract/package.json b/contract/package.json new file mode 100644 index 0000000..807708a --- /dev/null +++ b/contract/package.json @@ -0,0 +1,25 @@ +{ + "name": "@eighty4/install-contract", + "version": "0.0.1", + "author": "Adam McKee ", + "license": "BSD-3-Clause", + "type": "module", + "main": "lib/Contract.js", + "types": "lib/Contract.d.ts", + "scripts": { + "build": "tsc --build" + }, + "dependencies": { + "@eighty4/install-github": "workspace:^" + }, + "devDependencies": { + "typescript": "^5.4.4", + "vitest": "^1.4.0" + }, + "files": [ + "lib/**/*", + "src/**/*", + "package.json", + "tsconfig.json" + ] +} diff --git a/contract/src/Contract.ts b/contract/src/Contract.ts new file mode 100644 index 0000000..5d576ec --- /dev/null +++ b/contract/src/Contract.ts @@ -0,0 +1,17 @@ +import type {Repository} from '@eighty4/install-github' + +export interface GeneratedScript { + repository: Repository + templateVersion: string +} + +// response of GET /api/projects +export interface GeneratedScriptsResponse { + generatedScripts: Array + templateVersion: string +} + +// request of POST /api/project/script +export interface GeneratedScriptRequest { + generatedScript: GeneratedScript +} diff --git a/contract/tsconfig.json b/contract/tsconfig.json new file mode 100644 index 0000000..5c3b27d --- /dev/null +++ b/contract/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "composite": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "lib", + "rootDir": "src" + }, + "include": [ + "src/**/*.ts" + ], + "references": [ + { + "path": "../github" + } + ] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..44a78ef --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + postgres: + container_name: install-postgres + image: postgres:16 + ports: + - "5432:5432" + environment: + POSTGRES_DB: eighty4 + POSTGRES_USER: eighty4 + POSTGRES_PASSWORD: eighty4 + volumes: + - postgres_data:/var/lib/postgresql/data + +volumes: + postgres_data: diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..ff4d3e9 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,16 @@ +{ + "name": "@eighty4/install-e2e", + "version": "0.0.1", + "private": true, + "author": "Adam McKee ", + "license": "BSD-3-Clause", + "type": "module", + "scripts": { + "dev": "playwright test --ui", + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.43.0", + "@types/node": "^20.12.5" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..aae9a39 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,38 @@ +import {defineConfig, devices} from '@playwright/test' + +// https://playwright.dev/docs/test-configuration + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + // baseURL: 'http://127.0.0.1:3000', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: {...devices['Desktop Chrome']}, + }, + + { + name: 'firefox', + use: {...devices['Desktop Firefox']}, + }, + + { + name: 'webkit', + use: {...devices['Desktop Safari']}, + }, + ], + + webServer: { + command: './start_app.sh', + url: 'http://localhost:5711', + reuseExistingServer: !process.env.CI, + }, +}) diff --git a/e2e/start_app.sh b/e2e/start_app.sh new file mode 100755 index 0000000..6c3792c --- /dev/null +++ b/e2e/start_app.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +pushd .. + pnpm -r build +popd + +pushd ../frontend +# pnpm preview:offline & + pnpm dev:offline & + frontend_pid=$! +popd + +pushd ../offline + pnpm dev & + offline_pid=$! +popd + +function cleanup() +{ + kill $frontend_pid + kill $offline_pid +} + +sleep 1000000000 + +trap cleanup EXIT diff --git a/e2e/tests/tests.spec.ts b/e2e/tests/tests.spec.ts new file mode 100644 index 0000000..c9d268f --- /dev/null +++ b/e2e/tests/tests.spec.ts @@ -0,0 +1,28 @@ +import {test, expect} from '@playwright/test' + +test('cancel login', async ({page}) => { + await page.goto('http://localhost:5711/') + await page.click("#login") + await page.waitForSelector('#login-cancel') + await page.click("#login-cancel") + expect(await page.locator('html.out').count()).toBe(0) +}) + +test('login to #search route', async ({page}) => { + await page.goto('http://localhost:5711/') + await page.click("#login") + await page.waitForSelector('#login-redirect') + await page.click("#login-redirect") + await page.waitForSelector('#graph-paper') + await page.waitForSelector('#search-input') +}) + +test('#search to #configure/eighty4/maestro', async ({page}) => { + await page.goto('http://localhost:5711/') + await page.click("#login") + await page.waitForSelector('#login-redirect') + await page.click("#login-redirect") + await page.waitForSelector('#graph-paper') + await page.waitForSelector('#search-input') + await page.keyboard.insertText('maestro') +}) diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..b188ded --- /dev/null +++ b/frontend/.env @@ -0,0 +1,4 @@ +VITE_GITHUB_CLIENT_ID="" +VITE_GITHUB_GRAPH_ADDRESS="" +VITE_GITHUB_OAUTH_ADDRESS="https://github.com/login/oauth/authorize?client_id=$VITE_GITHUB_CLIENT_ID" +VITE_SCRIPT_TEMPLATE_VERSION="-1.-1.-1" diff --git a/frontend/.env.offline b/frontend/.env.offline new file mode 100644 index 0000000..5ca3bae --- /dev/null +++ b/frontend/.env.offline @@ -0,0 +1,3 @@ +# these endpoints are served from @eighty4/install-offline/src/Offline.ts +VITE_GITHUB_GRAPH_ADDRESS="http://localhost:5711/offline/github/graph" +VITE_GITHUB_OAUTH_ADDRESS="http://localhost:5711/offline/github/oauth" diff --git a/frontend/assets/svg/bug.svg b/frontend/assets/svg/bug.svg new file mode 100644 index 0000000..91bdea0 --- /dev/null +++ b/frontend/assets/svg/bug.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/assets/svg/eighty4.svg b/frontend/assets/svg/eighty4.svg new file mode 100644 index 0000000..659fd7d --- /dev/null +++ b/frontend/assets/svg/eighty4.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/assets/svg/favicon.svg b/frontend/assets/svg/favicon.svg new file mode 100644 index 0000000..0d5c0f5 --- /dev/null +++ b/frontend/assets/svg/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/assets/svg/flip.svg b/frontend/assets/svg/flip.svg new file mode 100644 index 0000000..797f926 --- /dev/null +++ b/frontend/assets/svg/flip.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/assets/svg/github.svg b/frontend/assets/svg/github.svg new file mode 100644 index 0000000..37fa923 --- /dev/null +++ b/frontend/assets/svg/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/assets/svg/languages/cpp.svg b/frontend/assets/svg/languages/cpp.svg new file mode 100644 index 0000000..583ab5e --- /dev/null +++ b/frontend/assets/svg/languages/cpp.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/assets/svg/languages/go.svg b/frontend/assets/svg/languages/go.svg new file mode 100644 index 0000000..683f0f8 --- /dev/null +++ b/frontend/assets/svg/languages/go.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/assets/svg/languages/rust.svg b/frontend/assets/svg/languages/rust.svg new file mode 100644 index 0000000..8975046 --- /dev/null +++ b/frontend/assets/svg/languages/rust.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/assets/svg/languages/zig.svg b/frontend/assets/svg/languages/zig.svg new file mode 100644 index 0000000..cb056de --- /dev/null +++ b/frontend/assets/svg/languages/zig.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/assets/svg/logo_i.svg b/frontend/assets/svg/logo_i.svg new file mode 100644 index 0000000..943b0e1 --- /dev/null +++ b/frontend/assets/svg/logo_i.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/assets/svg/logo_sh.svg b/frontend/assets/svg/logo_sh.svg new file mode 100644 index 0000000..cce4f2f --- /dev/null +++ b/frontend/assets/svg/logo_sh.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/assets/svg/systems/linux.svg b/frontend/assets/svg/systems/linux.svg new file mode 100644 index 0000000..91ffe3e --- /dev/null +++ b/frontend/assets/svg/systems/linux.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/assets/svg/systems/macos.svg b/frontend/assets/svg/systems/macos.svg new file mode 100644 index 0000000..27f1074 --- /dev/null +++ b/frontend/assets/svg/systems/macos.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/assets/svg/systems/windows.svg b/frontend/assets/svg/systems/windows.svg new file mode 100644 index 0000000..57db551 --- /dev/null +++ b/frontend/assets/svg/systems/windows.svg @@ -0,0 +1,4 @@ + + + diff --git a/frontend/build.sh b/frontend/build.sh new file mode 100755 index 0000000..093a532 --- /dev/null +++ b/frontend/build.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +set -e + +echo "VITE_SCRIPT_TEMPLATE_VERSION=\"$(node templateVersion.js)\"" > .env.production +pnpm build:tsc +pnpm build:vite + +mv dist/index.html dist/original.html + +docker run -i --rm 84tech/minhtml \ + --minify-css --minify-js \ + --do-not-minify-doctype \ + --ensure-spec-compliant-unquoted-attribute-values \ + --keep-spaces-between-attributes \ + < dist/original.html \ + > dist/index.html + +#rm dist/original.html + +#brotli -o dist/index.html.br dist/index.html +#find dist/assets -type f -maxdepth 1 \( -iname \*.css -o -iname \*.js \) -exec brotli -o {}.br {} ';' + +#gzip -S .gz -k dist/index.html +#find dist/assets -type f -maxdepth 1 \( -iname \*.css -o -iname \*.js \) -exec gzip -S .gz -k {} ';' diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9a6c13d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,439 @@ + + + + + + + + + + + + Install.sh + + + + +
+
+
+ an app +
+ + + +
+

"Be cool like Homebrew"

+

"Push bins like Rustup"

+

"Ship it like pnpm does"

+
+
+ +
+
+
+
+

tested

+

cross-platform

+

reproducible scripts

+

installs from your GitHub releases

+

supports Linux, MacOS, aarch64 & x86_64

+
+ for + + + + + rockstars +
+
+
+
+
+
+
+ + + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8ab1084 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,31 @@ +{ + "name": "@eighty4/install-frontend", + "version": "0.0.1", + "private": true, + "author": "Adam McKee ", + "license": "BSD-3-Clause", + "type": "module", + "main": "lib/Domain.js", + "types": "lib/Domain.d.ts", + "scripts": { + "build": "pnpm build:dist", + "build:dist": "./build.sh", + "build:tsc": "tsc --build --clean && tsc --build", + "build:vite": "vite build", + "dev": "vite", + "dev:offline": "vite --mode offline", + "preview": "vite preview", + "preview:offline": "vite preview --mode offline", + "svg": "npx -y svgo@latest -f assets/svg -o public -r" + }, + "dependencies": { + "@eighty4/install-contract": "workspace:^", + "@eighty4/install-github": "workspace:^", + "@eighty4/install-template": "workspace:^" + }, + "devDependencies": { + "@types/dom-view-transitions": "^1.0.4", + "typescript": "^5.4.4", + "vite": "^5.2.8" + } +} diff --git a/frontend/public/bug.svg b/frontend/public/bug.svg new file mode 100644 index 0000000..6fbf0e4 --- /dev/null +++ b/frontend/public/bug.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/eighty4.svg b/frontend/public/eighty4.svg new file mode 100644 index 0000000..233b015 --- /dev/null +++ b/frontend/public/eighty4.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..0d5c0f5 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flip.svg b/frontend/public/flip.svg new file mode 100644 index 0000000..974f557 --- /dev/null +++ b/frontend/public/flip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/github.svg b/frontend/public/github.svg new file mode 100644 index 0000000..cc622a4 --- /dev/null +++ b/frontend/public/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/inter.woff2 b/frontend/public/inter.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..40255432a3c6104bf0a82574eaa1d754ec55ab67 GIT binary patch literal 46704 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 Ang9R* literal 0 HcmV?d00001 diff --git a/frontend/public/languages/cpp.svg b/frontend/public/languages/cpp.svg new file mode 100644 index 0000000..ece4361 --- /dev/null +++ b/frontend/public/languages/cpp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/languages/go.svg b/frontend/public/languages/go.svg new file mode 100644 index 0000000..f1739af --- /dev/null +++ b/frontend/public/languages/go.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/languages/rust.svg b/frontend/public/languages/rust.svg new file mode 100644 index 0000000..5284140 --- /dev/null +++ b/frontend/public/languages/rust.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/languages/zig.svg b/frontend/public/languages/zig.svg new file mode 100644 index 0000000..e1e59e4 --- /dev/null +++ b/frontend/public/languages/zig.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/logo_i.svg b/frontend/public/logo_i.svg new file mode 100644 index 0000000..768f9be --- /dev/null +++ b/frontend/public/logo_i.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/logo_sh.svg b/frontend/public/logo_sh.svg new file mode 100644 index 0000000..20a8907 --- /dev/null +++ b/frontend/public/logo_sh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/systems/linux.svg b/frontend/public/systems/linux.svg new file mode 100644 index 0000000..733cfea --- /dev/null +++ b/frontend/public/systems/linux.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/systems/macos.svg b/frontend/public/systems/macos.svg new file mode 100644 index 0000000..4f22d6f --- /dev/null +++ b/frontend/public/systems/macos.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/systems/windows.svg b/frontend/public/systems/windows.svg new file mode 100644 index 0000000..2d5ff9f --- /dev/null +++ b/frontend/public/systems/windows.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/app.ts b/frontend/src/app.ts new file mode 100644 index 0000000..d244107 --- /dev/null +++ b/frontend/src/app.ts @@ -0,0 +1,49 @@ +import {initializeCustomizationControls} from './customizations.ts' +import {showLoginButton} from './login.ts' +import {getCookie, parseQueryParams} from './parse.ts' +import {handleCurrentRoute, subscribeRouterEvents} from './router.ts' +import './components/define.ts' + +if (document.readyState !== 'loading') { + onPageLoad() +} else { + document.addEventListener('DOMContentLoaded', onPageLoad) +} + +function onPageLoad() { + const params = parseQueryParams() + if (params['login']) { + handleLoginRequest(params['login']) + } else if (params['auth']) { + showLoginButton(params['auth']) + } else { + startApp() + } +} + +function handleLoginRequest(loginId: string) { + fetch('/login/notify?login=' + loginId) + .then((r) => { + if (r.status === 200) { + startApp() + } else { + showLoginButton('failed') + } + }) + .catch(e => { + console.log('await login error', e) + }) + window.history.replaceState(null, '', '/') +} + +function startApp() { + initializeCustomizationControls() + const ght = getCookie('ght') + if (ght) { + sessionStorage.setItem('ght', ght) + subscribeRouterEvents() + handleCurrentRoute() + } else { + showLoginButton() + } +} diff --git a/frontend/src/components/ConfigureBinary.ts b/frontend/src/components/ConfigureBinary.ts new file mode 100644 index 0000000..23049f5 --- /dev/null +++ b/frontend/src/components/ConfigureBinary.ts @@ -0,0 +1,55 @@ +import type {Binary} from '@eighty4/install-github' +import SystemLogo from './SystemLogo.ts' +import {cloneTemplate} from '../dom.ts' + +export default class ConfigureBinary extends HTMLElement { + + private static readonly TEMPLATE_ID = 'tmpl-configure-binary' + + static templateHTML(): string { + return ` + + ` + } + + readonly #bin: Binary + + readonly #shadow: ShadowRoot + + constructor(bin: Binary) { + super() + this.#bin = bin + this.#shadow = this.attachShadow({mode: 'open'}) + this.#shadow.appendChild(cloneTemplate(ConfigureBinary.TEMPLATE_ID)) + this.update() + } + + update() { + this.#shadow.querySelector('.filename')!.textContent = this.#bin.filename + this.#shadow.querySelector('.os')!.appendChild(new SystemLogo(this.#bin.os!, '#111')) + this.#shadow.querySelector('.arch')!.textContent = this.#bin.arch || '?' + } +} diff --git a/frontend/src/components/ConfigureScript.ts b/frontend/src/components/ConfigureScript.ts new file mode 100644 index 0000000..8b5587c --- /dev/null +++ b/frontend/src/components/ConfigureScript.ts @@ -0,0 +1,103 @@ +import type {Repository} from '@eighty4/install-github' +import ConfigureBinary from './ConfigureBinary.ts' +import {cloneTemplate} from '../dom.ts' + +export default class ConfigureScript extends HTMLElement { + + private static readonly AVATAR_SIZE = 30 + + private static readonly TEMPLATE_ID = 'tmpl-configure-script' + + static templateHTML(): string { + return ` + + ` + } + + readonly #repo: Repository + + readonly #shadow: ShadowRoot + + constructor(repo: Repository) { + super() + this.#repo = repo + this.#shadow = this.attachShadow({mode: 'open'}) + this.#shadow.appendChild(cloneTemplate(ConfigureScript.TEMPLATE_ID)) + this.update() + } + + update() { + const profilePicElem = this.#shadow.querySelector('.profile-pic') as HTMLImageElement + profilePicElem.alt = `${this.#repo.owner} profile picture` + profilePicElem.src = `https://github.com/${this.#repo.owner}.png?size=${ConfigureScript.AVATAR_SIZE}` + this.#shadow.querySelector('.commit')!.textContent = this.#repo.latestRelease?.commitHash || '' + this.#shadow.querySelector('.header')!.style.viewTransitionName = `repo-${this.#repo.owner}-${this.#repo.name}` + this.#shadow.querySelector('.name')!.textContent = `${this.#repo.owner}/${this.#repo.name}` + this.#shadow.querySelector('.version')!.textContent = this.#repo.latestRelease?.tag || '' + const binsBin = this.#shadow.querySelector('.binaries') as HTMLElement + if (this.#repo.latestRelease?.binaries.length) { + for (const bin of this.#repo.latestRelease.binaries) { + binsBin.appendChild(new ConfigureBinary(bin)) + } + } else { + binsBin.appendChild(new ConfigureBinary({ + filename: 'l3-linux-arm64', + os: 'Linux', + arch: 'aarch64', + contentType: 'mimes are silly', + })) + binsBin.appendChild(new ConfigureBinary({ + filename: 'l3-linux-amd64', + os: 'Linux', + arch: 'x86_64', + contentType: 'mimes are silly', + })) + } + console.log('ConfigureScript', this.#repo.latestRelease?.binaries) + } +} diff --git a/frontend/src/components/InterfaceControl.css b/frontend/src/components/InterfaceControl.css new file mode 100644 index 0000000..5e5e5d0 --- /dev/null +++ b/frontend/src/components/InterfaceControl.css @@ -0,0 +1,57 @@ +interface-control { + --key-out-bg: #666; + --key-in-bg: #444; + --key-border: #333; + --key-height: 2.5rem; +} + +html.flipped interface-control { + --key-out-bg: #aaa; + --key-in-bg: #ccc; + --key-border: #666; +} + +interface-control { + position: relative; + z-index: 1; + font-family: monospace; + font-style: italic; + font-size: 1rem; + user-select: none; + display: flex; + flex-direction: row; + align-items: center; + gap: 1rem; +} + +interface-control span { + cursor: pointer; +} + +interface-control .keyboard-key { + position: relative; + display: flex; + justify-content: center; + box-sizing: border-box; + height: 2.5rem; + min-width: 2.5rem; + padding: .5rem; + margin: 8px; + border-radius: .5rem; + color: var(--card-bg-color); + background: var(--key-in-bg); + border: 1px solid var(--key-border); +} + +interface-control .keyboard-key::after { + content: ''; + position: absolute; + z-index: -1; + top: -5px; + left: -5px; + right: -8px; + bottom: -8px; + border-radius: .5rem; + border: 2px solid var(--key-border); + background: var(--key-out-bg); +} diff --git a/frontend/src/components/InterfaceControl.ts b/frontend/src/components/InterfaceControl.ts new file mode 100644 index 0000000..bf1a814 --- /dev/null +++ b/frontend/src/components/InterfaceControl.ts @@ -0,0 +1,77 @@ +import './InterfaceControl.css' + +export default class InterfaceControl extends HTMLElement { + static observedAttributes = ['key', 'key-label', 'label'] + + private readonly keyElem: HTMLSpanElement + private readonly labelElem: HTMLSpanElement + + constructor() { + super() + this.keyElem = document.createElement('span') + this.keyElem.classList.add('keyboard-key') + this.labelElem = document.createElement('span') + this.appendChild(this.keyElem) + // this.appendChild(this.labelElem) + } + + attributeChangedCallback(name: any, _oldValue: string, newValue: string) { + if (name === 'key' && !this.attributes.getNamedItem('key-label')) { + this.keyElem.textContent = newValue + } else if (name === 'key-label') { + this.keyElem.textContent = newValue + } else if (name === 'label') { + this.labelElem.textContent = newValue + } + } + + connectedCallback() { + this.keyElem.textContent = this.keyLabel + this.labelElem.textContent = this.label + window.addEventListener('keyup', this.onKeyUp) + this.keyElem.addEventListener('click', this.onClick) + this.labelElem.addEventListener('click', this.onClick) + } + + disconnectedCallback() { + window.removeEventListener('keyup', this.onKeyUp) + this.keyElem.removeEventListener('click', this.onClick) + this.labelElem.removeEventListener('click', this.onClick) + } + + private onClick = () => this.activate() + + private onKeyUp = (e: KeyboardEvent) => { + if (e.key === this.key) { + this.activate() + } + } + + private activate() { + this.dispatchEvent(new CustomEvent('activate')) + } + + get key(): string { + return this.attributes.getNamedItem('key')?.textContent || '' + } + + get keyLabel(): string { + return this.attributes.getNamedItem('key-label')?.textContent || this.key + } + + get label(): string { + return this.attributes.getNamedItem('label')?.textContent || '' + } + + set key(v: string) { + this.setAttribute('key', v) + } + + set keyLabel(v: string) { + this.setAttribute('key-label', v) + } + + set label(v: string) { + this.setAttribute('label', v) + } +} diff --git a/frontend/src/components/RepositoryLink.ts b/frontend/src/components/RepositoryLink.ts new file mode 100644 index 0000000..a7e5b58 --- /dev/null +++ b/frontend/src/components/RepositoryLink.ts @@ -0,0 +1,124 @@ +import type {Repository} from '@eighty4/install-github' +import SystemLogo from './SystemLogo.ts' +import {configureRepositoryCache} from '../configure.ts' +import {cloneTemplate} from '../dom.ts' +import {pushConfigureRoute} from '../router.ts' + +export default class RepositoryLink extends HTMLElement { + + private static readonly AVATAR_SIZE = 20 + + private static readonly TEMPLATE_ID = 'tmpl-repo-link' + + static templateHTML(): string { + return ` + ` + } + + readonly #repo: Repository + + readonly #shadow: ShadowRoot + + constructor(readonly repo: Repository) { + super() + this.#repo = repo + this.#shadow = this.attachShadow({mode: 'open'}) + this.#shadow.appendChild(cloneTemplate(RepositoryLink.TEMPLATE_ID)) + this.update() + } + + connectedCallback() { + this.addEventListener('click', this.onClick) + } + + disconnectedCallback() { + this.removeEventListener('click', this.onClick) + } + + private onClick = () => { + configureRepositoryCache.write(this.#repo) + this.style.viewTransitionName = `repo-${this.#repo.owner}-${this.#repo.name}` + pushConfigureRoute(this.#repo) + } + + private update() { + const profilePicElem = this.#shadow.querySelector('.profile-pic') as HTMLImageElement + profilePicElem.alt = `${this.#repo.owner} profile picture` + profilePicElem.src = `https://github.com/${this.#repo.owner}.png?size=${RepositoryLink.AVATAR_SIZE}` + this.#shadow.querySelector('.name')!.textContent = `${this.#repo.owner}/${this.#repo.name}` + this.#shadow.querySelector('.update')!.textContent = this.#repo.latestRelease?.commitHash || '' + this.#shadow.querySelector('.version')!.textContent = this.#repo.latestRelease?.tag || '' + const osLogos = this.#shadow!.querySelector('.os-logos') as HTMLElement + // osLogos.appendChild(document.createTextNode(this.#repo.latestRelease?.binaries.length === 1 ? '1 binary' : this.#repo.latestRelease?.binaries.length + ' binaries')) + if (this.#repo.latestRelease?.binaries.some(b => b.os === 'Linux')) { + osLogos.appendChild(new SystemLogo('Linux')) + } + if (this.#repo.latestRelease?.binaries.some(b => b.os === 'MacOS')) { + osLogos.appendChild(new SystemLogo('MacOS')) + } + if (this.#repo.latestRelease?.binaries.some(b => b.os === 'Windows')) { + osLogos.appendChild(new SystemLogo('Windows')) + } + } +} diff --git a/frontend/src/components/RepositoryNavigation.ts b/frontend/src/components/RepositoryNavigation.ts new file mode 100644 index 0000000..9d4d229 --- /dev/null +++ b/frontend/src/components/RepositoryNavigation.ts @@ -0,0 +1,125 @@ +import {Repository} from '@eighty4/install-github' +import RepositoryLink from './RepositoryLink.ts' +// import RepositoryPagination from './RepositoryPagination.ts' +import {cloneTemplate} from '../dom.ts' + +export default class RepositoryNavigation extends HTMLElement { + + private static readonly TEMPLATE_ID = 'tmpl-repo-nav' + + static templateHTML(): string { + return ` + ` + } + + #links: HTMLDivElement + + // #pagination: RepositoryPagination + + #projects: Array = [] + + #shadow: ShadowRoot + + constructor() { + super() + this.#shadow = this.attachShadow({mode: 'open'}) + this.#shadow.appendChild(cloneTemplate(RepositoryNavigation.TEMPLATE_ID)) + this.#links = this.#shadow.querySelector('.repos') as HTMLDivElement + // this.#pagination = this.#shadow.querySelector('repository-pagination') as RepositoryPagination + } + + disconnectedCallback() { + this.clear() + } + + set projects(projects: Array) { + this.#projects = projects + this.update() + } + + private clear() { + for (const container of this.querySelectorAll('.container')) { + container.removeEventListener('mouseenter', this.onContainerMouseEnter) + container.removeEventListener('mouseleave', this.onContainerMouseLeave) + } + this.#links.innerHTML = '' + } + + private update() { + this.clear() + // const pageCount = Math.floor(this.#projects.length / 3) + 1 + // this.#pagination.pageCount = pageCount + if (this.#projects.length) { + const repos = this.#projects.slice(0, 3) + const links = repos.map((repo) => new RepositoryLink(repo)) + const children: Array = [] + for (let i = 0; i < (links.length * 2) - 1; i++) { + if (i % 2 === 0) { + const container = document.createElement('container') + container.classList.add('container') + container.appendChild(links[i === 0 ? 0 : i / 2]) + children.push(container) + container.addEventListener('mouseenter', this.onContainerMouseEnter) + container.addEventListener('mouseleave', this.onContainerMouseLeave) + } else { + const separator = document.createElement('div') + separator.classList.add('separator') + children.push(separator) + } + } + this.#links.append(...children) + } + } + + private onContainerMouseEnter = (e: MouseEvent) => { + for (let i = 0; i < this.#links.children.length; i++) { + const childElement = this.#links.children[i] + if (childElement === e.target) { + if (i !== 0) { + this.#links.children[i - 1].classList.toggle('hide') + } + if (i + 1 < this.#links.children.length) { + this.#links.children[i + 1].classList.toggle('hide') + } + break + } + } + } + + private onContainerMouseLeave = () => { + for (const separator of this.#links.querySelectorAll('.separator')) { + separator.classList.remove('hide') + } + } +} diff --git a/frontend/src/components/RepositoryPagination.ts b/frontend/src/components/RepositoryPagination.ts new file mode 100644 index 0000000..0d2e692 --- /dev/null +++ b/frontend/src/components/RepositoryPagination.ts @@ -0,0 +1,47 @@ +import {removeChildNodes} from '../dom.ts' + +export default class RepositoryPagination extends HTMLElement { + + #currentPage: number = 0 + + #pageCount: number = 0 + + get currentPage(): number { + return this.#currentPage + } + + get pageCount(): number { + return this.#pageCount + } + + set currentPage(currentPage: number) { + this.#currentPage = Math.min(currentPage, this.#pageCount) + this.update() + } + + set pageCount(pageCount: number) { + this.#currentPage = Math.min(this.#currentPage, pageCount) + this.#pageCount = pageCount + this.update() + } + + private update() { + if (this.pageCount < 2) { + removeChildNodes(this) + } else { + this.innerHTML = '

multiple pages of repos not yet implemented

' + // for (let i = this.childNodes.length - this.pageCount; i > 0; i--) { + // this.removeChild(this.childNodes[this.childNodes.length - 1]) + // } + // for (let i = this.pageCount - this.childNodes.length; i > 0; i--) { + // this.appendChild(this.createPaginationLink(this.childNodes.length + 1)) + // } + } + } + + // private createPaginationLink(i: number): HTMLButtonElement { + // const button = document.createElement('button') + // button.innerText = '' + i + // return button + // } +} diff --git a/frontend/src/components/SpinIndicator.ts b/frontend/src/components/SpinIndicator.ts new file mode 100644 index 0000000..fed7911 --- /dev/null +++ b/frontend/src/components/SpinIndicator.ts @@ -0,0 +1,43 @@ +import {cloneTemplate, removeChildNodes} from '../dom.ts' + +export default class SpinIndicator extends HTMLElement { + + private static readonly TEMPLATE_ID = 'tmpl-spin-indicator' + + static templateHTML(): string { + return ` + + ` + } + + constructor() { + super() + this.attachShadow({mode: 'open'}) + } + + connectedCallback() { + this.shadowRoot!.appendChild(cloneTemplate(SpinIndicator.TEMPLATE_ID)) + } + + disconnectedCallback() { + removeChildNodes(this.shadowRoot!) + } +} diff --git a/frontend/src/components/SystemLogo.ts b/frontend/src/components/SystemLogo.ts new file mode 100644 index 0000000..64ba885 --- /dev/null +++ b/frontend/src/components/SystemLogo.ts @@ -0,0 +1,38 @@ +import {OperatingSystem} from '@eighty4/install-template' + +export default class SystemLogo extends HTMLElement { + + static observedAttributes = ['color', 'os'] + + constructor(os?: OperatingSystem, color?: string) { + super() + if (color) this.setAttribute('color', color) + if (os) this.setAttribute('os', os) + this.style.aspectRatio = '1 / 1' + this.style.background = color || 'hotpink' + this.style.display = 'inline-block' + this.style.width = '1rem' + this.style.willChange = 'transform' + } + + connectedCallback() { + const os = this.os + this.style.mask = `url('/systems/${os.toLowerCase()}.svg') no-repeat 50%` + if (os === 'macos') { + this.style.transform = 'translateY(-1px)' + this.style.maskSize = '80%' + } else if (os === 'linux') { + this.style.maskSize = '100%' + } else { + this.style.maskSize = '85%' + } + } + + get os(): string { + const os = this.getAttribute('os') + if (os === null) { + throw new Error('asdf') + } + return os + } +} diff --git a/frontend/src/components/define.ts b/frontend/src/components/define.ts new file mode 100644 index 0000000..10e121f --- /dev/null +++ b/frontend/src/components/define.ts @@ -0,0 +1,21 @@ +import ConfigureBinary from './ConfigureBinary.ts' +import ConfigureScript from './ConfigureScript.ts' +import RepositoryLink from './RepositoryLink.ts' +import RepositoryNavigation from './RepositoryNavigation.ts' +import RepositoryPagination from './RepositoryPagination.ts' +import SpinIndicator from './SpinIndicator.ts' +import SystemLogo from './SystemLogo.ts' + +document.head.innerHTML += ConfigureBinary.templateHTML() + + ConfigureScript.templateHTML() + + RepositoryLink.templateHTML() + + RepositoryNavigation.templateHTML() + + SpinIndicator.templateHTML() + +customElements.define('configure-binary', ConfigureBinary) +customElements.define('configure-script', ConfigureScript) +customElements.define('repository-link', RepositoryLink) +customElements.define('repository-navigation', RepositoryNavigation) +customElements.define('repository-pagination', RepositoryPagination) +customElements.define('spin-indicator', SpinIndicator) +customElements.define('system-logo', SystemLogo) diff --git a/frontend/src/configure.ts b/frontend/src/configure.ts new file mode 100644 index 0000000..c8c1598 --- /dev/null +++ b/frontend/src/configure.ts @@ -0,0 +1,31 @@ +import {type Repository} from '@eighty4/install-github' +import createGitHubGraphApiClient from './createGitHubGraphApiClient.ts' +import {showGraphPaper} from './graphPaper.ts' +import {createSessionCache} from './sessionCache.ts' +import ConfigureScript from './components/ConfigureScript.ts' +import {removeChildNodes} from './dom.ts' + +export const configureRepositoryCache = createSessionCache('configure.repo') + +// todo error handling +export function openRepositoryConfig(repoOwner: string, repoName: string) { + showGraphPaper((graphPaper: HTMLElement) => { + graphPaper.classList.add('center') + let repository = configureRepositoryCache.read() + if (repository) { + configureRepositoryCache.clear() + showConfig(repository) + } else { + graphPaper.innerHTML = '' + createGitHubGraphApiClient() + .queryLatestRelease(repoOwner, repoName) + .then(showConfig) + .catch(console.error) + } + + function showConfig(repository: Repository) { + removeChildNodes(graphPaper) + graphPaper.appendChild(new ConfigureScript(repository)) + } + }) +} diff --git a/frontend/src/createGitHubGraphApiClient.ts b/frontend/src/createGitHubGraphApiClient.ts new file mode 100644 index 0000000..1accf99 --- /dev/null +++ b/frontend/src/createGitHubGraphApiClient.ts @@ -0,0 +1,8 @@ +import {GitHubApiClient} from '@eighty4/install-github' + +export default function (): GitHubApiClient { + const accessToken = sessionStorage.getItem('ght') as string + const maybeUrl = import.meta.env.VITE_GITHUB_GRAPH_ADDRESS + const url = maybeUrl.length ? maybeUrl : undefined + return new GitHubApiClient(accessToken, url) +} diff --git a/frontend/src/customizations.css b/frontend/src/customizations.css new file mode 100644 index 0000000..4549eed --- /dev/null +++ b/frontend/src/customizations.css @@ -0,0 +1,55 @@ +#controls { + color: var(--head-text-color); + grid-area: 5 / 1 / 7 / 3; + user-select: none; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2vmin; + transform: translateX(-100vw); +} + +.control { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: .25rem; +} + +.control button { + background: transparent; + border: none; + color: var(--head-text-color); + font-family: monospace; + font-size: 1.5vh; +} + +.control.active button { + font-size: 1.2vh; +} + +.control input { + display: none; +} + +.control label { + cursor: pointer; +} + +#flip-toggle { + mask: url('/flip.svg') no-repeat 50%; + mask-size: contain; + mask-clip: no-clip; + background: var(--head-text-color); + height: 2.75vh; + aspect-ratio: 23 / 38; + transform: rotate(40deg); + transform-origin: bottom; + transition: transform 150ms ease-in-out; +} + +html.flipped #flip-toggle { + transform: rotate(-40deg); +} diff --git a/frontend/src/customizations.ts b/frontend/src/customizations.ts new file mode 100644 index 0000000..b23d3af --- /dev/null +++ b/frontend/src/customizations.ts @@ -0,0 +1,32 @@ +import './customizations.css' + +export function initializeCustomizationControls() { + if (!localStorage.getItem('level')) { + return + } + const controls = createCustomizationControls() + initializeStyleControl(controls.querySelector('#flip-input') as HTMLInputElement) + document.querySelector('#grid header')!.appendChild(controls) +} + +function createCustomizationControls(): HTMLElement { + const controls = document.createElement('div') + controls.id = 'controls' + controls.ariaHidden = 'true' + controls.innerHTML = ` +
+ + +
` + return controls +} + +function initializeStyleControl(styleInput: HTMLInputElement) { + if (document.documentElement.classList.contains('flipped')) { + styleInput.checked = true + } + styleInput.addEventListener('click', () => { + const flipped = document.documentElement.classList.toggle('flipped') + localStorage.setItem('theme-flip', flipped ? '1' : '0') + }) +} diff --git a/frontend/src/dom.ts b/frontend/src/dom.ts new file mode 100644 index 0000000..0370cb2 --- /dev/null +++ b/frontend/src/dom.ts @@ -0,0 +1,7 @@ +export function cloneTemplate(id: string): Node { + return (document.getElementById(id) as HTMLTemplateElement).content.cloneNode(true) +} + +export function removeChildNodes(elem: Node): void { + while (elem.childNodes.length) elem.removeChild(elem.childNodes[0]) +} diff --git a/frontend/src/download.ts b/frontend/src/download.ts new file mode 100644 index 0000000..6febdd6 --- /dev/null +++ b/frontend/src/download.ts @@ -0,0 +1,61 @@ +import type {GeneratedScript, GeneratedScriptRequest} from '@eighty4/install-contract' +import {generateScript, type GenerateScriptOptions} from '@eighty4/install-template' + +export function downloadScript(options: GenerateScriptOptions) { + try { + downloadBlob(generateScript(options)) + } catch (e: any) { + // todo observability + document.body.style.background = 'orangered' + console.error('script template error', e.message) + return + } + saveTemplateVersion({ + repository: { + owner: options.repository.owner, + name: options.repository.name, + }, + templateVersion: import.meta.env.VITE_SCRIPT_TEMPLATE_VERSION, + }).then() +} + +function downloadBlob(text: string) { + downloadFile('install.sh', URL.createObjectURL(new Blob([text], {type: 'text/plain'}))) +} + +// todo evaluate downloadBlob vs downloadText methods +// function downloadText(text: string) { +// downloadFile('install.sh', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text)) +// } + +function downloadFile(filename: string, href: string) { + const link = document.createElement('a') as HTMLAnchorElement + link.download = filename + link.href = href + link.style.display = 'none' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) +} + +async function saveTemplateVersion(generatedScript: GeneratedScript): Promise { + const request: GeneratedScriptRequest = {generatedScript} + const response = await fetch('/api/project/script', { + headers: { + 'content-type': 'application/json', + }, + method: 'post', + body: JSON.stringify(request) + }) + if (response.status === 200) { + return + } else if (response.status === 401) { + // todo redirect to login + document.body.style.background = 'orangered' + console.error('login access token has expired') + } else { + document.body.style.background = 'orangered' + console.error(`/api/project/script returned an unexpected ${response.status} status`) + } + throw new Error('/api/projects ' + response.status) +} diff --git a/frontend/src/graphPaper.css b/frontend/src/graphPaper.css new file mode 100644 index 0000000..9f9283e --- /dev/null +++ b/frontend/src/graphPaper.css @@ -0,0 +1,57 @@ +#graph-paper-grid { + --graph-transition-duration: calc(var(--out-transition-duration) / 2); + grid-area: 3 / 3 / -3 / -3; + display: grid; + grid-template-columns: 2fr 0fr 2fr; + grid-template-rows: 2fr 0fr 2fr; + justify-content: center; + align-items: center; + transition: var(--graph-transition-duration); +} + +#graph-paper-grid.hide { + display: none; +} + +#graph-paper-grid:has(#graph-paper.full) { + grid-template-columns: 0fr 1fr 0fr; + grid-template-rows: 0fr 1fr 0fr; +} + +#graph-paper { + grid-area: 2 / 2 / -2 / -2; + width: 100%; + height: 100%; + background-color: var(--card-bg-color); + background-image: radial-gradient(circle, var(--card-graph-color) 1px, transparent 1px); + background-position: center; + background-size: 4vmin 4vmin; + box-sizing: border-box; + color: var(--card-text-color); + border: none; +} + +#graph-paper.center { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +#graph-paper.full { + animation: animate-graph-paper-open var(--graph-transition-duration); +} + +@keyframes animate-graph-paper-open { + from { + border: 0 solid transparent; + } + to { + border: 2vmin solid var(--card-bg-color); + } +} + +/*#graph-paper.remove {*/ +/* transform: scale(0);*/ +/* transition: transform 500ms;*/ +/*}*/ diff --git a/frontend/src/graphPaper.ts b/frontend/src/graphPaper.ts new file mode 100644 index 0000000..45354b0 --- /dev/null +++ b/frontend/src/graphPaper.ts @@ -0,0 +1,32 @@ +import {toggleLandingElements} from './ui.ts' +import './graphPaper.css' + +const gridDiv = document.getElementById('graph-paper-grid') as HTMLDivElement + +let clearGraphPaperFn: (() => void) | null + +export function onClearGraphPaper(fn: () => void) { + clearGraphPaperFn = fn +} + +export function showGraphPaper(readyFn?: (graphPaper: HTMLElement) => void): HTMLElement { + const graphPaper = document.getElementById('graph-paper') as HTMLElement + if (!gridDiv.classList.contains('hide')) { + clearGraphPaper(graphPaper) + if (readyFn) readyFn(graphPaper) + } else { + toggleLandingElements(false) + gridDiv.classList.remove('hide') + setTimeout(() => graphPaper.classList.add('full'), 25) + if (readyFn) graphPaper.addEventListener('animationend', () => readyFn(graphPaper), {once: true}) + } + return graphPaper +} + +function clearGraphPaper(graphPaper: HTMLElement) { + if (clearGraphPaperFn) clearGraphPaperFn() + clearGraphPaperFn = null + for (const elem of graphPaper.childNodes) { + elem.remove() + } +} diff --git a/frontend/src/login.css b/frontend/src/login.css new file mode 100644 index 0000000..07424f5 --- /dev/null +++ b/frontend/src/login.css @@ -0,0 +1,111 @@ +:root { + --diagonal-length-px: 0px; + --diagonal-rotate-rad: 0rad; +} + +#login { + --login-link-width: 10vmin; + --login-link-height: 5vmin; + --login-link-center-x: var(--login-link-width) / 2; + --login-link-center-y: var(--login-link-height) / 2; + --login-link-position-percent: 50%; + --login-link-previous-position-percent: 50%; + --login-link-moving-duration: 2s; + --login-link-moving-ratio: 1; + position: fixed; + top: calc(var(--login-link-position-percent) - var(--login-link-center-y)); + right: calc(var(--login-link-position-percent) - var(--login-link-center-x)); + z-index: calc(var(--bug-z-index) + 1); + width: var(--login-link-width); + height: var(--login-link-height); + display: flex; + justify-content: center; + align-items: center; + font-size: 2vmin; + border: .5vmin solid hotpink; + background: var(--black); + color: lawngreen; + user-select: none; + cursor: pointer; + transition: all calc(var(--login-link-moving-duration) / 4) ease-in-out; +} + +#login.moving { + animation: login-rotate, login-slide, login-slim; + animation-duration: var(--login-link-moving-duration); + transform-origin: center; + background: hotpink; + border-color: lawngreen; + color: transparent; +} + +#login:hover { + transform: scale(1.15); +} + +@keyframes login-rotate { + 0% { + transform: rotate(0) scale(1); + } + + 40% { + transform: rotate(var(--diagonal-rotate-rad)) scaleY(.7) scaleX(1.1); + } + + 60% { + transform: rotate(var(--diagonal-rotate-rad)) scaleY(.7) scaleX(1.1); + } + + 100% { + transform: rotate(0) scale(1); + } +} + +@keyframes login-slide { + 0% { + top: calc(var(--login-link-previous-position-percent) - var(--login-link-center-y)); + right: calc(var(--login-link-previous-position-percent) - var(--login-link-center-x)); + } + + 100% { + top: calc(var(--login-link-position-percent) - var(--login-link-center-y)); + right: calc(var(--login-link-position-percent) - var(--login-link-center-x)); + } +} + +#login-prompt { + grid-area: 2 / 5 / -2 / -2; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + font-size: 5vh; + font-weight: 500; + line-height: 7vh; + text-wrap: balance; + gap: 3vh; + color: var(--card-text-color); +} + +#login-prompt button { + font-size: 3vh; + font-weight: 500; + padding: 1vh 2vw; + cursor: pointer; + border-radius: 2px; +} + +#login-redirect { + border: 1px solid var(--card-text-color); +} + +#login-cancel { + border: 1px solid transparent; + opacity: .8; + transition: all 100ms ease-in-out; +} + +#login-cancel:hover { + border: 1px solid #666; + opacity: 1; +} diff --git a/frontend/src/login.ts b/frontend/src/login.ts new file mode 100644 index 0000000..b214c12 --- /dev/null +++ b/frontend/src/login.ts @@ -0,0 +1,162 @@ +import {toggleReaderMode} from './ui.ts' +import './login.css' + +const LOGIN_LINK_MOVE_THRESHOLD = .075 + +interface Point { + x: number + y: number +} + +export function showLoginButton(error?: string) { + if (error) { + alert(`There was an error during login. The error message is: + +"${error}" + +Please login again!`) + } + + let delaying = false + const loginLink = createLoginLink() + let splitDistance = 0 + let splitSlope = 0 + let splitYIntercept = 0 + let proximitySlope = 0 + let previousIntersectionPoint: Point = {x: document.body.clientWidth / 2, y: document.body.clientHeight / 2} + let previousPositionRatio = .15 + + function onResize() { + const {clientHeight, clientWidth} = document.body + splitDistance = calculateDistanceBetweenPoints({ + x: 0, + y: document.body.clientHeight, + }, { + x: document.body.clientWidth, + y: 0, + }) + splitSlope = -1 * document.body.clientHeight / document.body.clientWidth + splitYIntercept = document.body.clientHeight + proximitySlope = calculateInverseReciprocal(splitSlope) + document.documentElement.style.setProperty('--diagonal-length-px', `${splitDistance}px`) + document.documentElement.style.setProperty('--diagonal-rotate-rad', `${Math.atan(clientHeight / clientWidth) * -1}rad`) + } + + onResize() + window.addEventListener('resize', onResize) + + function onMouseEvent(e: MouseEvent) { + if (loginLink.classList.contains('moving') || delaying) { + return + } + const proximityPoint = {x: e.clientX, y: e.clientY} + const proximityYIntercept = calculateYInterceptForLine(proximityPoint, proximitySlope) + const intersectionPoint = calculateIntersectionPointOfTwoLines(splitSlope, splitYIntercept, proximitySlope, proximityYIntercept) + const intersectionPointDistance = calculateDistanceBetweenPoints(intersectionPoint, { + x: document.body.clientWidth, + y: 0, + }) + const positionRatio = intersectionPointDistance / splitDistance + const positionRatioDeltaWithPreviousRatio = Math.abs(positionRatio - previousPositionRatio) + if (positionRatioDeltaWithPreviousRatio < LOGIN_LINK_MOVE_THRESHOLD) { + return + } + const pixelsMoved = calculateDistanceBetweenPoints(intersectionPoint, previousIntersectionPoint) + loginLink.style.setProperty('--login-link-previous-position-percent', loginLink.style.getPropertyValue('--login-link-position-percent')) + const positionPercent = positionRatio * 100 + const clampedPositionPercent = positionPercent < 50 ? Math.max(positionPercent, 12) : Math.min(positionPercent, 88) + loginLink.style.setProperty('--login-link-position-percent', `${clampedPositionPercent}%`) + console.log(positionPercent, clampedPositionPercent) + loginLink.style.setProperty('--login-link-moving-duration', `${(pixelsMoved / splitDistance) * 2}s`) + previousIntersectionPoint = intersectionPoint + previousPositionRatio = positionRatio + loginLink.classList.add('moving') + } + + loginLink.addEventListener('animationend', () => { + delaying = true + loginLink.classList.remove('moving') + setTimeout(() => delaying = false, 100) + }) + window.addEventListener('mousemove', debounce(onMouseEvent, 84)) + + document.body.appendChild(loginLink) +} + +function createLoginLink(): HTMLDivElement { + const loginLink = document.createElement('div') + loginLink.id = 'login' + loginLink.innerText = 'Login' + loginLink.onclick = () => toggleReaderMode(true).then(showLoginPrompt) + return loginLink +} + +function showLoginPrompt() { + const loginPrompt = document.createElement('div') + loginPrompt.id = 'login-prompt' + loginPrompt.innerHTML = ` +

You'll be redirected to GitHub for authentication. Hold on to your bits.

+
+ + +
+ ` + const grid = document.getElementById('grid') as HTMLDivElement + grid.appendChild(loginPrompt) + const redirectButton = loginPrompt.querySelector('#login-redirect') as HTMLButtonElement + const cancelButton = loginPrompt.querySelector('#login-cancel') as HTMLButtonElement + redirectButton.addEventListener('click', redirectToLogin) + cancelButton.addEventListener('click', closeLoginPrompt) + + function closeLoginPrompt() { + redirectButton.removeEventListener('click', redirectToLogin) + cancelButton.removeEventListener('click', closeLoginPrompt) + grid.removeChild(loginPrompt) + toggleReaderMode(false).then() + } +} + +function redirectToLogin() { + document.location = import.meta.env.VITE_GITHUB_OAUTH_ADDRESS +} + +function calculateDistanceBetweenPoints(p1: Point, p2: Point): number { + const xd = p2.x - p1.x + const yd = p2.y - p1.y + return Math.sqrt((xd * xd) + (yd * yd)) +} + +function calculateYInterceptForLine(p: Point, m: number): number { + return p.y - (m * p.x) +} + +function calculateIntersectionPointOfTwoLines(m1: number, yi1: number, m2: number, yi2: number): Point { + const x = (yi1 - yi2) / (m2 - m1) + const y = m2 * x + yi2 + return {x, y} +} + +function calculateInverseReciprocal(m: number) { + return -1 * (1 / m) +} + +// function calculateDistanceFromLine(p: Point, lineSlope: number, lineYIntercept: number): number { +// return Math.abs((lineSlope * p.x) + (-1 * p.y) + lineYIntercept) / Math.sqrt((lineSlope * lineSlope) + (-1 * -1)) +// } + +function debounce(func: any, wait: number, immediate?: any) { + let timeout: any + return function () { + // @ts-ignore + const context = this + const args = arguments + const later = function () { + timeout = null + if (!immediate) func.apply(context, args) + } + const callNow = immediate && !timeout + clearTimeout(timeout) + timeout = setTimeout(later, wait) + if (callNow) func.apply(context, args) + } +} diff --git a/frontend/src/parse.ts b/frontend/src/parse.ts new file mode 100644 index 0000000..577cab5 --- /dev/null +++ b/frontend/src/parse.ts @@ -0,0 +1,33 @@ +export function getCookie(name: string): string | undefined { + if (document.cookie.length) { + for (const cookie of document.cookie.split(';')) { + const [key, value] = cookie.split('=') + if (key.trim() === name) { + return value.trim() + } + } + } +} + +export function parseDocumentCookies(): Record { + const result: Record = {} + if (document.cookie.length) { + for (const cookie of document.cookie.split(';')) { + const [key, value] = cookie.split('=') + result[key.trim()] = value.trim() + } + } + return result +} + +export function parseQueryParams(): Record { + const result: Record = {} + if (location.search.length) { + const keyValuePairs = location.search.substring(1).split('&') + for (const keyValuePair of keyValuePairs) { + const [key, value] = keyValuePair.split('=') + result[key] = value + } + } + return result +} diff --git a/frontend/src/router.ts b/frontend/src/router.ts new file mode 100644 index 0000000..0cf6abc --- /dev/null +++ b/frontend/src/router.ts @@ -0,0 +1,53 @@ +import type {Repository} from '@eighty4/install-github' +import {openRepositoryConfig} from './configure.ts' +import {findProgramRepository} from './search.ts' +import {toggleReaderMode} from './ui.ts' + +export function subscribeRouterEvents() { + window.addEventListener('hashchange', handleCurrentRoute) +} + +export function replaceCurrentRoute(route: string) { + console.log('redirect route', location.hash, route) + window.history.replaceState({}, '', route) + handleCurrentRoute() +} + +export function pushConfigureRoute(repo: any) { + repo = repo as Repository + if (repo.owner && repo.name) { + location.hash = `configure/${repo.owner}/${repo.name}` + } else { + location.hash = `configure/${repo}` + } +} + +export function handleCurrentRoute() { + console.log('handle route', location.hash) + if (location.hash.startsWith('#page/')) { + toggleReaderMode(true).then(/* todo use links to docs */) + } else if (location.hash.startsWith('#configure/')) { + const hashParts = location.hash.split('/') + if (hashParts.length !== 3) { + alert('wtf ' + location.hash) + // @ts-ignore + location = location.protocol + '//' + location.host + } + const [_, repoOwner, repoName] = hashParts + transition(() => openRepositoryConfig(repoOwner, repoName)) + } else if (location.hash === '#search') { + transition(() => findProgramRepository()) + } else if (location.hash === '') { + replaceCurrentRoute('#search') + } else { + console.error('wtf') + } +} + +function transition(fn: () => void) { + if (document.startViewTransition) { + document.startViewTransition(fn) + } else { + fn() + } +} diff --git a/frontend/src/search.ts b/frontend/src/search.ts new file mode 100644 index 0000000..1a50af5 --- /dev/null +++ b/frontend/src/search.ts @@ -0,0 +1,73 @@ +import {type Repository} from '@eighty4/install-github' +import {showGraphPaper} from './graphPaper.ts' +import {createSessionCache} from './sessionCache.ts' +import createGitHubGraphApiClient from './createGitHubGraphApiClient.ts' +import RepositoryNavigation from './components/RepositoryNavigation.ts' +import {removeChildNodes} from './dom.ts' + +const projectsCache = createSessionCache>('search.projects') + +export function findProgramRepository() { + showGraphPaper((graphPaper) => { + graphPaper.classList.add('center') + + let navigation: RepositoryNavigation + let projects = projectsCache.read() + + if (projects?.length) { + showProjects(projects) + } else { + graphPaper.innerHTML = '' + } + + createGitHubGraphApiClient().collectUserRepositories().then(onProjectsReceived).catch(onExceptionalCondition) + + function onProjectsReceived(repositories: Array) { + projectsCache.write(projects = repositories) + if (projects.length) { + showProjects(projects) + } else { + showGuideOnEmptyProjects() + } + } + + function showProjects(projects: Array) { + if (!navigation) { + navigation = new RepositoryNavigation() + removeChildNodes(graphPaper) + graphPaper.appendChild(navigation) + } + navigation.projects = projects + } + + function showGuideOnEmptyProjects() { + // todo separate instruction links from messaging screen, open instructions/guide from a call to action + // todo search/filter repositories + // todo show projects without releases + // todo link to fork a workflow template to create Zig/Rust/Golang workflows with release uploads + graphPaper.innerHTML = ` +
+

Oh, this is awkward

+

You don't have any GitHub releases with artifacts for Linux, MacOS or Windows.

+

Try these docs out and come back:

+

CLI

+

Create a release

+

Create a release asset

+

Rest API

+

Create a release

+

Create a release asset

+
+ ` + } + + function onExceptionalCondition(e: Error) { + console.error(e) + graphPaper.innerHTML = ` +
+

Oh, this is awkward

+
${e.stack}
+
+ ` + } + }) +} diff --git a/frontend/src/sessionCache.ts b/frontend/src/sessionCache.ts new file mode 100644 index 0000000..4ed564c --- /dev/null +++ b/frontend/src/sessionCache.ts @@ -0,0 +1,22 @@ +export interface SessionCache { + clear(): void + + read(): T | null + + write(data: T): void +} + +export function createSessionCache(key: string): SessionCache { + return { + clear() { + sessionStorage.removeItem(key) + }, + read() { + const data = sessionStorage.getItem(key) + return data === null ? null : JSON.parse(data) + }, + write(data: T) { + sessionStorage.setItem(key, JSON.stringify(data)) + }, + } +} diff --git a/frontend/src/ui.css b/frontend/src/ui.css new file mode 100644 index 0000000..ed25b27 --- /dev/null +++ b/frontend/src/ui.css @@ -0,0 +1,96 @@ +:root { + --out-transition-duration: 400ms; +} + +#controls, #explain, #features *, #header, #login, #logo, #tagline { + transition: transform var(--out-transition-duration) ease-in-out, opacity 100ms ease-in-out var(--out-transition-duration); +} + +#controls { + opacity: 0; + transition-delay: 150ms; +} + +html.out #controls { + opacity: 1; + transform: translateY(0); +} + +html.out #features * { + opacity: 0; + transform: translateX(100vw); +} + +html.out #explain, html.out #logo, html.out #tagline { + opacity: 0; + transform: translateX(-100vw); +} + +html.out #login { + opacity: 0; + transform: scale(50%); +} + +html.out #header { + opacity: 0; + transform: translateY(-100vh); +} + +/* + * UI Reader mode + */ + +#eighty4, #github { + transition: transform var(--out-transition-duration) ease-in-out; + transition-delay: 150ms; +} + +#triangle { + --triangle-shift-rotate-degs: -45deg; + --triangle-shift-scale-percent: 300%; + --triangle-shift-translate-x: -12.5vw; +} + +#triangle.shift { + animation: shift-triangle 600ms; + animation-delay: 150ms; + animation-fill-mode: forwards; +} + +#triangle.unshift { + animation: shift-triangle 600ms; + animation-delay: 150ms; + animation-fill-mode: forwards; + animation-direction: reverse; +} + +#triangle.shifted { + transform: rotate(var(--triangle-shift-rotate-degs)) scale(var(--triangle-shift-scale-percent)) translateX(var(--triangle-shift-translate-x)); +} + +html.reader #controls { + display: none; +} + +html.reader #eighty4 { + transform: translateY(50vh); +} + +html.reader #github { + transform: translateY(-50vh); +} + +/* todo dynamic rotate degrees based on aspect ratio */ +@keyframes shift-triangle { + 0% { + transform: rotate(0deg) scale(100%) translateX(0vw) translateY(0vh); + } + + 50% { + transform: rotate(var(--triangle-shift-rotate-degs)) scale(var(--triangle-shift-scale-percent)) translateX(0vw) translateY(0vh); + } + + 100% { + transform: rotate(var(--triangle-shift-rotate-degs)) scale(var(--triangle-shift-scale-percent)) translateX(var(--triangle-shift-translate-x)); + } +} diff --git a/frontend/src/ui.ts b/frontend/src/ui.ts new file mode 100644 index 0000000..ba2231e --- /dev/null +++ b/frontend/src/ui.ts @@ -0,0 +1,21 @@ +import './ui.css' + +const triangleDiv = document.getElementById('triangle') as HTMLDivElement + +export function toggleLandingElements(showOrHide?: boolean) { + const fn = typeof showOrHide === 'undefined' ? 'toggle' : showOrHide ? 'remove' : 'add' + document.documentElement.classList[fn]('out') +} + +export async function toggleReaderMode(openOrClose: boolean): Promise { + toggleLandingElements(!openOrClose) + document.documentElement.classList[openOrClose ? 'add' : 'remove']('reader') + triangleDiv.classList.toggle(openOrClose ? 'shift' : 'unshift') + return new Promise((res) => { + triangleDiv.addEventListener('animationend', () => { + triangleDiv.classList.toggle('shifted') + triangleDiv.classList.remove('shift', 'unshift') + res() + }, {once: true}) + }) +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..7efcf8d --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,12 @@ +/// + +interface ImportMetaEnv { + readonly VITE_GITHUB_CLIENT_ID: string + readonly VITE_GITHUB_GRAPH_ADDRESS: string + readonly VITE_GITHUB_OAUTH_ADDRESS: string + readonly VITE_SCRIPT_TEMPLATE_VERSION: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/frontend/templateVersion.js b/frontend/templateVersion.js new file mode 100644 index 0000000..57d47b1 --- /dev/null +++ b/frontend/templateVersion.js @@ -0,0 +1,12 @@ +import {readFileSync} from 'node:fs' + +try { + let packageJson = import.meta.resolve('./node_modules/@eighty4/install-template/package.json') + if (packageJson.startsWith('file:///')) { + packageJson = packageJson.substring(7) + } + const version = JSON.parse(readFileSync(packageJson).toString()).version + console.log(version) +} catch (e) { + throw new Error('failed resolving version of @eighty4/install-template: ' + e.message) +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..35f22a7 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "composite": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + + /* Linting */ + "verbatimModuleSyntax": false, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts"], + "references": [ + { + "path": "../contract" + }, + { + "path": "../github" + }, + { + "path": "../template" + } + ] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..0bd1cd7 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,30 @@ +import {type ConfigEnv, defineConfig, type ProxyOptions} from 'vite' + +export default defineConfig((env: ConfigEnv) => { + return { + build: { + emptyOutDir: true, + manifest: false, + outDir: 'dist', + }, + server: { + port: 5711, + proxy: buildProxyConfig(env.mode), + }, + } +}) + +function buildProxyConfig(mode: string): Record { + if (mode === 'offline') { + return { + '/api': 'http://localhost:7411', + '/login': 'http://localhost:7411', + '/offline': 'http://localhost:7411', + } + } else { + return { + '/api': 'http://localhost:5741', + '/login': 'http://localhost:5741', + } + } +} diff --git a/github/package.json b/github/package.json new file mode 100644 index 0000000..f56fb35 --- /dev/null +++ b/github/package.json @@ -0,0 +1,27 @@ +{ + "name": "@eighty4/install-github", + "version": "0.0.1", + "author": "Adam McKee ", + "license": "BSD-3-Clause", + "type": "module", + "main": "lib/GitHubApiClient.js", + "types": "lib/GitHubApiClient.d.ts", + "scripts": { + "test": "vitest run", + "build": "tsc --build" + }, + "dependencies": { + "@eighty4/install-template": "workspace:^" + }, + "devDependencies": { + "dotenv": "^16.4.5", + "typescript": "^5.4.4", + "vitest": "^1.4.0" + }, + "files": [ + "lib/**/*", + "src/**/*", + "package.json", + "tsconfig.json" + ] +} diff --git a/github/src/GitHubApiClient.ts b/github/src/GitHubApiClient.ts new file mode 100644 index 0000000..f65c2d5 --- /dev/null +++ b/github/src/GitHubApiClient.ts @@ -0,0 +1,163 @@ +import type {Repository} from './Model.js' +import {mapReleaseNode, RepositoryReleasesGraph, ViewerRepositoriesWithLatestReleaseGraph} from './graphApiTypes.js' + +export * from './Model.js' + +export type PageUserRepositoriesCallback = (repos: Array, complete: boolean) => void + +interface QueryUserRepositoriesResponse { + repositories: Array + endCursor: string + hasNextPage: boolean +} + +export class GitHubApiClient { + constructor(private readonly ghAccessToken: string, + private readonly ghGraphApiUrl: string = 'https://api.github.com/graphql') { + } + + async collectUserRepositories(): Promise> { + const result: Array = [] + let hasNextPage = true + let nextCursor: string | undefined = undefined + do { + const response = await this.internalQueryUserRepositories(40, nextCursor) + hasNextPage = response.hasNextPage + nextCursor = response.endCursor + result.push(...response.repositories) + } while (hasNextPage) + result.sort((a, b) => { + if (!!a.latestRelease) { + return -1 + } else { + return 1 + } + }) + return result + } + + async queryLatestRelease(repoOwner: string, repoName: string): Promise { + const result = await this.internalDoGraphApiQuery(` +query { + repository(owner: "${repoOwner}", name: "${repoName}") { + releases(first: 1, orderBy: {field: CREATED_AT, direction: DESC}) { + nodes { + createdAt + tagCommit { + abbreviatedOid + } + tagName + updatedAt + url + releaseAssets(first: 100) { + nodes { + name + contentType + } + } + } + } + } +}`) + if (result.data.repository === null) { + throw new Error(`${repoOwner}/${repoName} not found`) + } + const releases = result.data.repository.releases + if (releases.nodes.length === 0) { + throw new Error(`${repoOwner}/${repoName} has no releases`) + } + return { + owner: repoOwner, + name: repoName, + latestRelease: mapReleaseNode(releases.nodes[0]), + } + } + + pageUserRepositories(reposPerPage: number, callback: PageUserRepositoriesCallback): void { + this.internalPageUserRepositories(reposPerPage, callback).then().catch(console.error) + } + + private async internalPageUserRepositories(reposPerPage: number, callback: PageUserRepositoriesCallback): Promise { + let hasNextPage = true + let nextCursor: string | undefined = undefined + do { + const response = await this.internalQueryUserRepositories(reposPerPage, nextCursor) + hasNextPage = response.hasNextPage + nextCursor = response.endCursor + callback(response.repositories, !hasNextPage) + } while (hasNextPage) + } + + private async internalQueryUserRepositories(reposPerPage: number, cursor?: string): Promise { + const result = await this.internalDoGraphApiQuery(` +{ + viewer { + repositories( + first: ${reposPerPage}, after: ${cursor ? `"${cursor}"` : 'null'}, + affiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER] + ) { + nodes { + ... on Repository { + name + owner { + login + } + releases(first: 1, orderBy: {field: CREATED_AT, direction: DESC}) { + nodes { + createdAt + tagCommit { + abbreviatedOid + } + tagName + updatedAt + url + releaseAssets(first: 100) { + nodes { + name + contentType + } + } + } + } + } + } + pageInfo { + endCursor + hasNextPage + } + } + } +}`) + const repositories: Array = [] + for (const repo of result.data.viewer.repositories.nodes) { + repositories.push({ + owner: repo.owner.login, + name: repo.name, + latestRelease: repo.releases.nodes.length ? mapReleaseNode(repo.releases.nodes[0]) : undefined, + }) + } + const {endCursor, hasNextPage} = result.data.viewer.repositories.pageInfo + return {repositories, hasNextPage, endCursor} + } + + private async internalDoGraphApiQuery(query: string): Promise<{ data: T }> { + const response = await fetch(this.ghGraphApiUrl, { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + this.ghAccessToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({query}), + }) + if (response.status !== 200) { + throw new Error('internalDoGraphApiQuery status code ' + response.status) + } + const result = await response.json() + if (!result.data) { + console.error('internalDoGraphApiQuery response', JSON.stringify(result, null, 4)) + throw new Error('internalDoGraphApiQuery wtf') + } + console.log('internalDoGraphApiQuery', result) + return result + } +} diff --git a/github/src/Model.ts b/github/src/Model.ts new file mode 100644 index 0000000..41813e1 --- /dev/null +++ b/github/src/Model.ts @@ -0,0 +1,27 @@ +import type {Architecture, OperatingSystem} from '@eighty4/install-template' + +export interface Repository { + owner: string + name: string + latestRelease?: Release +} + +export interface Asset { + contentType: string + filename: string +} + +export interface Binary extends Asset { + arch?: Architecture + os?: OperatingSystem +} + +export interface Release { + createdAt: string + binaries: Array + otherAssets: Array + commitHash: string + tag: string + updatedAt: string + url: string +} diff --git a/github/src/graphApiTypes.ts b/github/src/graphApiTypes.ts new file mode 100644 index 0000000..a6548cd --- /dev/null +++ b/github/src/graphApiTypes.ts @@ -0,0 +1,78 @@ +import {resolveDistribution} from '@eighty4/install-template' +import type {Asset, Binary, Release} from './Model.js' + +export interface RepositoryReleasesGraph { + repository: { + releases: { + nodes: Array + } + } +} + +export interface ViewerRepositoriesWithLatestReleaseGraph { + viewer: { + repositories: { + nodes: Array + pageInfo: { + endCursor: string + hasNextPage: boolean + } + } + } +} + +export interface RepositoryNode { + name: string + owner: { + login: string + } + releases: { + nodes: Array + } +} + +export interface ReleaseNode { + createdAt: string + tagCommit: { + abbreviatedOid: string + } + tagName: string + updatedAt: string + url: string + releaseAssets: { + nodes: Array + } +} + +export interface ReleaseAssetNode { + name: string + contentType: string +} + +export function mapReleaseNode(release: ReleaseNode): Release { + const binaries: Array = [] + const otherAssets: Array = [] + for (const {name: filename, contentType} of release.releaseAssets.nodes) { + const distribution = resolveDistribution(filename, contentType) + if (distribution) { + binaries.push({ + filename, + contentType, + arch: distribution.arch, + os: distribution.os, + }) + } else { + otherAssets.push({filename, contentType}) + } + } + const {createdAt, updatedAt, url} = release + return { + commitHash: release.tagCommit.abbreviatedOid, + tag: release.tagName, + createdAt, + url, + updatedAt, + binaries, + otherAssets, + } +} diff --git a/github/tsconfig.json b/github/tsconfig.json new file mode 100644 index 0000000..8f3d67d --- /dev/null +++ b/github/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "composite": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": false, + "strict": true, + "skipLibCheck": true, + "outDir": "lib", + "rootDir": "src" + }, + "include": [ + "src/**/*.ts" + ], + "references": [ + { + "path": "../template" + } + ] +} diff --git a/github/vitest.config.ts b/github/vitest.config.ts new file mode 100644 index 0000000..486949c --- /dev/null +++ b/github/vitest.config.ts @@ -0,0 +1,7 @@ +import {defineConfig} from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}) diff --git a/offline/package.json b/offline/package.json new file mode 100644 index 0000000..6589826 --- /dev/null +++ b/offline/package.json @@ -0,0 +1,24 @@ +{ + "name": "@eighty4/install-offline", + "version": "0.0.1", + "private": true, + "author": "Adam McKee ", + "license": "BSD-3-Clause", + "type": "module", + "scripts": { + "dev": "DOTENV_CONFIG_PATH=../backend/.env tsx -r dotenv/config --watch src/Offline.ts" + }, + "dependencies": { + "@eighty4/install-backend": "workspace:*", + "@eighty4/install-github": "workspace:*", + "cookie-parser": "^1.4.6", + "express": "5.0.0-beta.1" + }, + "devDependencies": { + "@types/cookie-parser": "^1.4.7", + "@types/express": "^4.17.21", + "dotenv": "^16.4.5", + "tsx": "^4.7.2", + "typescript": "^5.4.4" + } +} diff --git a/offline/src/Offline.ts b/offline/src/Offline.ts new file mode 100644 index 0000000..8b5d160 --- /dev/null +++ b/offline/src/Offline.ts @@ -0,0 +1,71 @@ +import cookieParser from 'cookie-parser' +import express, {type RequestHandler} from 'express' +import {createAccessTokenCookie, generateScriptRouteFn} from '@eighty4/install-backend/lib/Routes.js' +import {lookupRepositoryReleasesGraph, lookupViewerRepositoriesWithLatestReleaseGraph} from './data.js' + +const HTTP_PORT = 7411 + +const app = express() +app.use(cookieParser()) +app.use(express.json()) +app.use((req, res, next) => { + const start = Date.now() + res.on('finish', () => { + const uri = decodeURI(req.url) + const end = `${Date.now() - start}ms` + if (res.statusCode === 301) { + console.log(req.method, uri, res.statusCode, res.statusMessage, 'to', res.getHeaders().location, end) + } else { + console.log(req.method, uri, res.statusCode, res.statusMessage, end) + } + }) + next() +}) + +const offlineAuthorizeGitHubUser: RequestHandler = (req, res, next) => { + const accessToken = req.cookies.ght + if (accessToken) { + req.user = { + accessToken, + userId: 1, + login: 'eighty4', + } + next() + } else { + res.redirect(301, 'http://localhost:5711?error') + } +} + +app.post('/offline/github/graph', (req, res) => { + if (/^\s*{\s*viewer\s*{\s*repositories\(/.test(req.body.query)) { + res.json(lookupViewerRepositoriesWithLatestReleaseGraph()).end() + } else { + const matches = /repository\(owner:\s"(?[a-z0-9-]+)",\sname:\s"(?[a-z0-9-]+)"\)/.exec(req.body.query) + if (matches && matches.groups) { + const {owner, name} = matches.groups + const result = lookupRepositoryReleasesGraph({owner, name}) + if (result) { + res.json(result).end() + } else { + res.end() + } + } else { + res.status(500).end() + } + } +}) + +app.get('/offline/github/oauth', async (req, res) => { + res.redirect('http://localhost:5711?login=1') +}) + +app.get('/login/notify', (req, res) => { + res.setHeader('Set-Cookie', createAccessTokenCookie('1234')) + res.json({newUser: true}) +}) + +app.post('/api/script', offlineAuthorizeGitHubUser, generateScriptRouteFn) + +app.listen(HTTP_PORT, () => { + console.log('install.sh http listening on', 7411) +}) diff --git a/offline/src/data.ts b/offline/src/data.ts new file mode 100644 index 0000000..c7b7761 --- /dev/null +++ b/offline/src/data.ts @@ -0,0 +1,138 @@ +import type {Release, Repository} from '@eighty4/install-github' +import { + ReleaseAssetNode, + ReleaseNode, + RepositoryNode, + RepositoryReleasesGraph, + ViewerRepositoriesWithLatestReleaseGraph, +} from '@eighty4/install-github/lib/graphApiTypes.js' + +export function lookupRepositoryReleasesGraph(repository: Repository): { data: RepositoryReleasesGraph } | undefined { + const repoKey = `${repository.owner}/${repository.name}` + if (repositories[repoKey]) { + return { + data: { + repository: mapRepository(repositories[repoKey]), + }, + } + } +} + +export function lookupViewerRepositoriesWithLatestReleaseGraph(): { data: ViewerRepositoriesWithLatestReleaseGraph } { + return { + data: { + viewer: { + repositories: { + nodes: Object.keys(repositories).map(repoName => repositories[repoName]).map(mapRepository), + pageInfo: { + endCursor: 'asdf', + hasNextPage: false, + }, + }, + }, + }, + } +} + +function mapRepository(repository: Repository): RepositoryNode { + return { + name: repository.name, + owner: { + login: repository.owner, + }, + releases: { + nodes: mapRelease(repository.latestRelease), + }, + } +} + +function mapRelease(release?: Release): Array { + if (!release) { + return [] + } else { + const assets: Array = [] + for (const binary of release.binaries) { + assets.push({ + contentType: binary.contentType, + name: binary.filename, + }) + } + for (const asset of release.otherAssets) { + assets.push({ + contentType: asset.contentType, + name: asset.filename, + }) + } + return [{ + createdAt: release.createdAt, + updatedAt: release.updatedAt, + url: release.url, + tagCommit: {abbreviatedOid: 'bbb3b25'}, + tagName: release.tag, + releaseAssets: { + nodes: assets, + }, + }] + } +} + +export const repositories: Record = { + 'eighty4/maestro': { + owner: 'eighty4', + name: 'maestro', + latestRelease: { + commitHash: 'bbb3b25', + createdAt: '2024-01-01', + binaries: [{ + arch: 'aarch64', + contentType: 'application/x-executable', + filename: 'maestro-linux-arm64', + os: 'Linux', + }, { + arch: 'x86_64', + contentType: 'application/x-executable', + filename: 'maestro-linux-amd64', + os: 'Linux', + }, { + arch: 'x86_64', + contentType: 'application/x-mach-binary', + filename: 'maestro-darwin-amd64', + os: 'MacOS', + }, { + arch: 'aarch64', + contentType: 'application/x-mach-binary', + filename: 'maestro-darwin-arm64', + os: 'MacOS', + }, { + arch: 'x86_64', + contentType: 'application/x-dosexec', + filename: 'maestro-windows-amd64', + os: 'Windows', + }], + otherAssets: [{ + filename: 'README.md', + contentType: 'text/plain', + }], + tag: '1.0.1', + updatedAt: '2024-02-01', + url: 'https://github.com/eighty4/maestro', + }, + }, + 'eighty4/unresolved': { + owner: 'eighty4', + name: 'unresolved', + latestRelease: { + commitHash: 'bbb3b25', + createdAt: '2024-01-01', + binaries: [{ + contentType: 'application/x-executable', + filename: 'maestro-linux-amd64', + os: 'Linux', + }], + otherAssets: [], + tag: '1.0.1', + updatedAt: '2024-02-01', + url: 'https://github.com/eighty4/maestro', + }, + }, +} diff --git a/offline/tsconfig.json b/offline/tsconfig.json new file mode 100644 index 0000000..1f664be --- /dev/null +++ b/offline/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "composite": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": false, + "strict": true, + "skipLibCheck": true, + "outDir": "lib", + "rootDir": "src" + }, + "files": [ + "../backend/src/req.d.ts" + ], + "include": [ + "src/**/*.ts" + ], + "references": [ + { + "path": "../backend" + }, + { + "path": "../github" + } + ] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ac7a059 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "@eighty4/install-root", + "version": "0.0.1", + "private": true, + "author": "Adam McKee ", + "license": "BSD-3-Clause", + "scripts": { + "build": "docker build -t 84tech/install-backend .", + "clean": "rm -rf ./*/node_modules ./*/lib ./*/tsconfig.tsbuildinfo cli/zig-cache dist backend/public e2e/playwright-report frontend/.env.production" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..16fef59 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2282 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + backend: + dependencies: + '@eighty4/install-contract': + specifier: workspace:^ + version: link:../contract + '@eighty4/install-github': + specifier: workspace:^ + version: link:../github + '@eighty4/install-template': + specifier: workspace:^ + version: link:../template + cookie-parser: + specifier: ^1.4.6 + version: 1.4.6 + express: + specifier: 5.0.0-beta.3 + version: 5.0.0-beta.3 + pg: + specifier: ^8.11.5 + version: 8.11.5 + uuid: + specifier: ^9.0.1 + version: 9.0.1 + devDependencies: + '@types/cookie-parser': + specifier: ^1.4.7 + version: 1.4.7 + '@types/express': + specifier: ^4.17.21 + version: 4.17.21 + '@types/pg': + specifier: 8.11.2 + version: 8.11.2 + '@types/uuid': + specifier: ^9.0.8 + version: 9.0.8 + dotenv: + specifier: ^16.4.5 + version: 16.4.5 + tsx: + specifier: ^4.7.2 + version: 4.7.2 + typescript: + specifier: ^5.4.4 + version: 5.4.4 + vitest: + specifier: ^1.4.0 + version: 1.4.0(@types/node@20.12.5) + + contract: + dependencies: + '@eighty4/install-github': + specifier: workspace:^ + version: link:../github + devDependencies: + typescript: + specifier: ^5.4.4 + version: 5.4.4 + vitest: + specifier: ^1.4.0 + version: 1.4.0(@types/node@20.12.5) + + e2e: + devDependencies: + '@playwright/test': + specifier: ^1.43.0 + version: 1.43.0 + '@types/node': + specifier: ^20.12.5 + version: 20.12.5 + + frontend: + dependencies: + '@eighty4/install-contract': + specifier: workspace:^ + version: link:../contract + '@eighty4/install-github': + specifier: workspace:^ + version: link:../github + '@eighty4/install-template': + specifier: workspace:^ + version: link:../template + devDependencies: + '@types/dom-view-transitions': + specifier: ^1.0.4 + version: 1.0.4 + typescript: + specifier: ^5.4.4 + version: 5.4.4 + vite: + specifier: ^5.2.8 + version: 5.2.8(@types/node@20.12.5) + + github: + dependencies: + '@eighty4/install-template': + specifier: workspace:^ + version: link:../template + devDependencies: + dotenv: + specifier: ^16.4.5 + version: 16.4.5 + typescript: + specifier: ^5.4.4 + version: 5.4.4 + vitest: + specifier: ^1.4.0 + version: 1.4.0(@types/node@20.12.5) + + offline: + dependencies: + '@eighty4/install-backend': + specifier: workspace:* + version: link:../backend + '@eighty4/install-github': + specifier: workspace:* + version: link:../github + cookie-parser: + specifier: ^1.4.6 + version: 1.4.6 + express: + specifier: 5.0.0-beta.1 + version: 5.0.0-beta.1 + devDependencies: + '@types/cookie-parser': + specifier: ^1.4.7 + version: 1.4.7 + '@types/express': + specifier: ^4.17.21 + version: 4.17.21 + dotenv: + specifier: ^16.4.5 + version: 16.4.5 + tsx: + specifier: ^4.7.2 + version: 4.7.2 + typescript: + specifier: ^5.4.4 + version: 5.4.4 + + template: + devDependencies: + '@types/node': + specifier: ^20.12.5 + version: 20.12.5 + typescript: + specifier: ^5.4.4 + version: 5.4.4 + vitest: + specifier: ^1.4.0 + version: 1.4.0(@types/node@20.12.5) + +packages: + + /@esbuild/aix-ppc64@0.19.12: + resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + requiresBuild: true + dev: true + optional: true + + /@esbuild/aix-ppc64@0.20.2: + resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm64@0.19.12: + resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm64@0.20.2: + resolution: {integrity: sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm@0.19.12: + resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm@0.20.2: + resolution: {integrity: sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64@0.19.12: + resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64@0.20.2: + resolution: {integrity: sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-arm64@0.19.12: + resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-arm64@0.20.2: + resolution: {integrity: sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-x64@0.19.12: + resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-x64@0.20.2: + resolution: {integrity: sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-arm64@0.19.12: + resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-arm64@0.20.2: + resolution: {integrity: sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-x64@0.19.12: + resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-x64@0.20.2: + resolution: {integrity: sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm64@0.19.12: + resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm64@0.20.2: + resolution: {integrity: sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm@0.19.12: + resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm@0.20.2: + resolution: {integrity: sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.19.12: + resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.20.2: + resolution: {integrity: sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.19.12: + resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.20.2: + resolution: {integrity: sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el@0.19.12: + resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el@0.20.2: + resolution: {integrity: sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64@0.19.12: + resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64@0.20.2: + resolution: {integrity: sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64@0.19.12: + resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64@0.20.2: + resolution: {integrity: sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x@0.19.12: + resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x@0.20.2: + resolution: {integrity: sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64@0.19.12: + resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64@0.20.2: + resolution: {integrity: sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64@0.19.12: + resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64@0.20.2: + resolution: {integrity: sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64@0.19.12: + resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64@0.20.2: + resolution: {integrity: sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64@0.19.12: + resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64@0.20.2: + resolution: {integrity: sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64@0.19.12: + resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64@0.20.2: + resolution: {integrity: sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32@0.19.12: + resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32@0.20.2: + resolution: {integrity: sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64@0.19.12: + resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64@0.20.2: + resolution: {integrity: sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@jest/schemas@29.6.3: + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@sinclair/typebox': 0.27.8 + dev: true + + /@jridgewell/sourcemap-codec@1.4.15: + resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + dev: true + + /@playwright/test@1.43.0: + resolution: {integrity: sha512-Ebw0+MCqoYflop7wVKj711ccbNlrwTBCtjY5rlbiY9kHL2bCYxq+qltK6uPsVBGGAOb033H2VO0YobcQVxoW7Q==} + engines: {node: '>=16'} + hasBin: true + dependencies: + playwright: 1.43.0 + dev: true + + /@rollup/rollup-android-arm-eabi@4.14.0: + resolution: {integrity: sha512-jwXtxYbRt1V+CdQSy6Z+uZti7JF5irRKF8hlKfEnF/xJpcNGuuiZMBvuoYM+x9sr9iWGnzrlM0+9hvQ1kgkf1w==} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-android-arm64@4.14.0: + resolution: {integrity: sha512-fI9nduZhCccjzlsA/OuAwtFGWocxA4gqXGTLvOyiF8d+8o0fZUeSztixkYjcGq1fGZY3Tkq4yRvHPFxU+jdZ9Q==} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-darwin-arm64@4.14.0: + resolution: {integrity: sha512-BcnSPRM76/cD2gQC+rQNGBN6GStBs2pl/FpweW8JYuz5J/IEa0Fr4AtrPv766DB/6b2MZ/AfSIOSGw3nEIP8SA==} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-darwin-x64@4.14.0: + resolution: {integrity: sha512-LDyFB9GRolGN7XI6955aFeI3wCdCUszFWumWU0deHA8VpR3nWRrjG6GtGjBrQxQKFevnUTHKCfPR4IvrW3kCgQ==} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-arm-gnueabihf@4.14.0: + resolution: {integrity: sha512-ygrGVhQP47mRh0AAD0zl6QqCbNsf0eTo+vgwkY6LunBcg0f2Jv365GXlDUECIyoXp1kKwL5WW6rsO429DBY/bA==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-arm64-gnu@4.14.0: + resolution: {integrity: sha512-x+uJ6MAYRlHGe9wi4HQjxpaKHPM3d3JjqqCkeC5gpnnI6OWovLdXTpfa8trjxPLnWKyBsSi5kne+146GAxFt4A==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-arm64-musl@4.14.0: + resolution: {integrity: sha512-nrRw8ZTQKg6+Lttwqo6a2VxR9tOroa2m91XbdQ2sUUzHoedXlsyvY1fN4xWdqz8PKmf4orDwejxXHjh7YBGUCA==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-powerpc64le-gnu@4.14.0: + resolution: {integrity: sha512-xV0d5jDb4aFu84XKr+lcUJ9y3qpIWhttO3Qev97z8DKLXR62LC3cXT/bMZXrjLF9X+P5oSmJTzAhqwUbY96PnA==} + cpu: [ppc64le] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-riscv64-gnu@4.14.0: + resolution: {integrity: sha512-SDDhBQwZX6LPRoPYjAZWyL27LbcBo7WdBFWJi5PI9RPCzU8ijzkQn7tt8NXiXRiFMJCVpkuMkBf4OxSxVMizAw==} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-s390x-gnu@4.14.0: + resolution: {integrity: sha512-RxB/qez8zIDshNJDufYlTT0ZTVut5eCpAZ3bdXDU9yTxBzui3KhbGjROK2OYTTor7alM7XBhssgoO3CZ0XD3qA==} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-x64-gnu@4.14.0: + resolution: {integrity: sha512-C6y6z2eCNCfhZxT9u+jAM2Fup89ZjiG5pIzZIDycs1IwESviLxwkQcFRGLjnDrP+PT+v5i4YFvlcfAs+LnreXg==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-x64-musl@4.14.0: + resolution: {integrity: sha512-i0QwbHYfnOMYsBEyjxcwGu5SMIi9sImDVjDg087hpzXqhBSosxkE7gyIYFHgfFl4mr7RrXksIBZ4DoLoP4FhJg==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-win32-arm64-msvc@4.14.0: + resolution: {integrity: sha512-Fq52EYb0riNHLBTAcL0cun+rRwyZ10S9vKzhGKKgeD+XbwunszSY0rVMco5KbOsTlwovP2rTOkiII/fQ4ih/zQ==} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-win32-ia32-msvc@4.14.0: + resolution: {integrity: sha512-e/PBHxPdJ00O9p5Ui43+vixSgVf4NlLsmV6QneGERJ3lnjIua/kim6PRFe3iDueT1rQcgSkYP8ZBBXa/h4iPvw==} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-win32-x64-msvc@4.14.0: + resolution: {integrity: sha512-aGg7iToJjdklmxlUlJh/PaPNa4PmqHfyRMLunbL3eaMO0gp656+q1zOKkpJ/CVe9CryJv6tAN1HDoR8cNGzkag==} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@sinclair/typebox@0.27.8: + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + dev: true + + /@types/body-parser@1.19.5: + resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} + dependencies: + '@types/connect': 3.4.38 + '@types/node': 20.12.5 + dev: true + + /@types/connect@3.4.38: + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + dependencies: + '@types/node': 20.12.5 + dev: true + + /@types/cookie-parser@1.4.7: + resolution: {integrity: sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==} + dependencies: + '@types/express': 4.17.21 + dev: true + + /@types/dom-view-transitions@1.0.4: + resolution: {integrity: sha512-oDuagM6G+xPLrLU4KeCKlr1oalMF5mJqV5pDPMDVIEaa8AkUW00i6u+5P02XCjdEEUQJC9dpnxqSLsZeAciSLQ==} + dev: true + + /@types/estree@1.0.5: + resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + dev: true + + /@types/express-serve-static-core@4.19.0: + resolution: {integrity: sha512-bGyep3JqPCRry1wq+O5n7oiBgGWmeIJXPjXXCo8EK0u8duZGSYar7cGqd3ML2JUsLGeB7fmc06KYo9fLGWqPvQ==} + dependencies: + '@types/node': 20.12.5 + '@types/qs': 6.9.14 + '@types/range-parser': 1.2.7 + '@types/send': 0.17.4 + dev: true + + /@types/express@4.17.21: + resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} + dependencies: + '@types/body-parser': 1.19.5 + '@types/express-serve-static-core': 4.19.0 + '@types/qs': 6.9.14 + '@types/serve-static': 1.15.7 + dev: true + + /@types/http-errors@2.0.4: + resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} + dev: true + + /@types/mime@1.3.5: + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + dev: true + + /@types/node@20.12.5: + resolution: {integrity: sha512-BD+BjQ9LS/D8ST9p5uqBxghlN+S42iuNxjsUGjeZobe/ciXzk2qb1B6IXc6AnRLS+yFJRpN2IPEHMzwspfDJNw==} + dependencies: + undici-types: 5.26.5 + dev: true + + /@types/pg@8.11.2: + resolution: {integrity: sha512-G2Mjygf2jFMU/9hCaTYxJrwdObdcnuQde1gndooZSOHsNSaCehAuwc7EIuSA34Do8Jx2yZ19KtvW8P0j4EuUXw==} + dependencies: + '@types/node': 20.12.5 + pg-protocol: 1.6.1 + pg-types: 4.0.2 + dev: true + + /@types/qs@6.9.14: + resolution: {integrity: sha512-5khscbd3SwWMhFqylJBLQ0zIu7c1K6Vz0uBIt915BI3zV0q1nfjRQD3RqSBcPaO6PHEF4ov/t9y89fSiyThlPA==} + dev: true + + /@types/range-parser@1.2.7: + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + dev: true + + /@types/send@0.17.4: + resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} + dependencies: + '@types/mime': 1.3.5 + '@types/node': 20.12.5 + dev: true + + /@types/serve-static@1.15.7: + resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==} + dependencies: + '@types/http-errors': 2.0.4 + '@types/node': 20.12.5 + '@types/send': 0.17.4 + dev: true + + /@types/uuid@9.0.8: + resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + dev: true + + /@vitest/expect@1.4.0: + resolution: {integrity: sha512-Jths0sWCJZ8BxjKe+p+eKsoqev1/T8lYcrjavEaz8auEJ4jAVY0GwW3JKmdVU4mmNPLPHixh4GNXP7GFtAiDHA==} + dependencies: + '@vitest/spy': 1.4.0 + '@vitest/utils': 1.4.0 + chai: 4.4.1 + dev: true + + /@vitest/runner@1.4.0: + resolution: {integrity: sha512-EDYVSmesqlQ4RD2VvWo3hQgTJ7ZrFQ2VSJdfiJiArkCerDAGeyF1i6dHkmySqk573jLp6d/cfqCN+7wUB5tLgg==} + dependencies: + '@vitest/utils': 1.4.0 + p-limit: 5.0.0 + pathe: 1.1.2 + dev: true + + /@vitest/snapshot@1.4.0: + resolution: {integrity: sha512-saAFnt5pPIA5qDGxOHxJ/XxhMFKkUSBJmVt5VgDsAqPTX6JP326r5C/c9UuCMPoXNzuudTPsYDZCoJ5ilpqG2A==} + dependencies: + magic-string: 0.30.9 + pathe: 1.1.2 + pretty-format: 29.7.0 + dev: true + + /@vitest/spy@1.4.0: + resolution: {integrity: sha512-Ywau/Qs1DzM/8Uc+yA77CwSegizMlcgTJuYGAi0jujOteJOUf1ujunHThYo243KG9nAyWT3L9ifPYZ5+As/+6Q==} + dependencies: + tinyspy: 2.2.1 + dev: true + + /@vitest/utils@1.4.0: + resolution: {integrity: sha512-mx3Yd1/6e2Vt/PUC98DcqTirtfxUyAZ32uK82r8rZzbtBeBo+nqgnjx/LvqQdWsrvNtm14VmurNgcf4nqY5gJg==} + dependencies: + diff-sequences: 29.6.3 + estree-walker: 3.0.3 + loupe: 2.3.7 + pretty-format: 29.7.0 + dev: true + + /accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + dev: false + + /acorn-walk@8.3.2: + resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} + engines: {node: '>=0.4.0'} + dev: true + + /acorn@8.11.3: + resolution: {integrity: sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + + /ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + dev: true + + /array-flatten@3.0.0: + resolution: {integrity: sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==} + dev: false + + /assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + dev: true + + /body-parser@2.0.0-beta.1: + resolution: {integrity: sha512-I1v2bt2OdYqtmk8nEFZuEf+9Opb30DphYwTPDbgg/OorSAoJOuTpWyDrZaSWQw7FdoevbBRCP2+9z/halXSWcA==} + engines: {node: '>= 0.10'} + dependencies: + bytes: 3.1.1 + content-type: 1.0.5 + debug: 2.6.9 + depd: 1.1.2 + http-errors: 1.8.1 + iconv-lite: 0.4.24 + on-finished: 2.3.0 + qs: 6.9.6 + raw-body: 2.4.2 + type-is: 1.6.18 + transitivePeerDependencies: + - supports-color + dev: false + + /body-parser@2.0.0-beta.2: + resolution: {integrity: sha512-oxdqeGYQcO5ovwwkC1A89R0Mf0v3+7smTVh0chGfzDeiK37bg5bYNtXDy3Nmzn6CShoIYk5+nHTyBoSZIWwnCA==} + engines: {node: '>= 0.10'} + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 3.1.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.5.2 + on-finished: 2.4.1 + qs: 6.11.0 + raw-body: 3.0.0-beta.1 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + dev: false + + /bytes@3.1.1: + resolution: {integrity: sha512-dWe4nWO/ruEOY7HkUJ5gFt1DCFV9zPRoJr8pV0/ASQermOZjtq8jMjOprC0Kd10GLN+l7xaUPvxzJFWtxGu8Fg==} + engines: {node: '>= 0.8'} + dev: false + + /bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + dev: false + + /cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + dev: true + + /call-bind@1.0.7: + resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} + engines: {node: '>= 0.4'} + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + set-function-length: 1.2.2 + dev: false + + /chai@4.4.1: + resolution: {integrity: sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==} + engines: {node: '>=4'} + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.3 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.0.8 + dev: true + + /check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + dependencies: + get-func-name: 2.0.2 + dev: true + + /content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + dependencies: + safe-buffer: 5.2.1 + dev: false + + /content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + dev: false + + /cookie-parser@1.4.6: + resolution: {integrity: sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==} + engines: {node: '>= 0.8.0'} + dependencies: + cookie: 0.4.1 + cookie-signature: 1.0.6 + dev: false + + /cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + dev: false + + /cookie@0.4.1: + resolution: {integrity: sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==} + engines: {node: '>= 0.6'} + dev: false + + /cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + dev: false + + /cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + dev: true + + /debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.0.0 + dev: false + + /debug@3.1.0: + resolution: {integrity: sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.0.0 + dev: false + + /debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + dev: true + + /deep-eql@4.1.3: + resolution: {integrity: sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==} + engines: {node: '>=6'} + dependencies: + type-detect: 4.0.8 + dev: true + + /define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + gopd: 1.0.1 + dev: false + + /depd@1.1.2: + resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} + engines: {node: '>= 0.6'} + dev: false + + /depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dev: false + + /destroy@1.0.4: + resolution: {integrity: sha512-3NdhDuEXnfun/z7x9GOElY49LoqVHoGScmOKwmxhsS8N5Y+Z8KyPPDnaSzqWgYt/ji4mqwfTS34Htrk0zPIXVg==} + dev: false + + /destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + dev: false + + /diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dev: true + + /dotenv@16.4.5: + resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} + engines: {node: '>=12'} + dev: true + + /ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + dev: false + + /encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + dev: false + + /es-define-property@1.0.0: + resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} + engines: {node: '>= 0.4'} + dependencies: + get-intrinsic: 1.2.4 + dev: false + + /es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + dev: false + + /esbuild@0.19.12: + resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/aix-ppc64': 0.19.12 + '@esbuild/android-arm': 0.19.12 + '@esbuild/android-arm64': 0.19.12 + '@esbuild/android-x64': 0.19.12 + '@esbuild/darwin-arm64': 0.19.12 + '@esbuild/darwin-x64': 0.19.12 + '@esbuild/freebsd-arm64': 0.19.12 + '@esbuild/freebsd-x64': 0.19.12 + '@esbuild/linux-arm': 0.19.12 + '@esbuild/linux-arm64': 0.19.12 + '@esbuild/linux-ia32': 0.19.12 + '@esbuild/linux-loong64': 0.19.12 + '@esbuild/linux-mips64el': 0.19.12 + '@esbuild/linux-ppc64': 0.19.12 + '@esbuild/linux-riscv64': 0.19.12 + '@esbuild/linux-s390x': 0.19.12 + '@esbuild/linux-x64': 0.19.12 + '@esbuild/netbsd-x64': 0.19.12 + '@esbuild/openbsd-x64': 0.19.12 + '@esbuild/sunos-x64': 0.19.12 + '@esbuild/win32-arm64': 0.19.12 + '@esbuild/win32-ia32': 0.19.12 + '@esbuild/win32-x64': 0.19.12 + dev: true + + /esbuild@0.20.2: + resolution: {integrity: sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/aix-ppc64': 0.20.2 + '@esbuild/android-arm': 0.20.2 + '@esbuild/android-arm64': 0.20.2 + '@esbuild/android-x64': 0.20.2 + '@esbuild/darwin-arm64': 0.20.2 + '@esbuild/darwin-x64': 0.20.2 + '@esbuild/freebsd-arm64': 0.20.2 + '@esbuild/freebsd-x64': 0.20.2 + '@esbuild/linux-arm': 0.20.2 + '@esbuild/linux-arm64': 0.20.2 + '@esbuild/linux-ia32': 0.20.2 + '@esbuild/linux-loong64': 0.20.2 + '@esbuild/linux-mips64el': 0.20.2 + '@esbuild/linux-ppc64': 0.20.2 + '@esbuild/linux-riscv64': 0.20.2 + '@esbuild/linux-s390x': 0.20.2 + '@esbuild/linux-x64': 0.20.2 + '@esbuild/netbsd-x64': 0.20.2 + '@esbuild/openbsd-x64': 0.20.2 + '@esbuild/sunos-x64': 0.20.2 + '@esbuild/win32-arm64': 0.20.2 + '@esbuild/win32-ia32': 0.20.2 + '@esbuild/win32-x64': 0.20.2 + dev: true + + /escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + dev: false + + /estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + dependencies: + '@types/estree': 1.0.5 + dev: true + + /etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + dev: false + + /execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + dependencies: + cross-spawn: 7.0.3 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + dev: true + + /express@5.0.0-beta.1: + resolution: {integrity: sha512-KPtBrlZoQu2Ps0Ce/Imqtq73AB0KBJ8Gx59yZQ3pmDJU2/LhcoZETo03oSgtTQufbcLXt/WBITk/jMjl/WMyrQ==} + engines: {node: '>= 4'} + dependencies: + accepts: 1.3.8 + array-flatten: 3.0.0 + body-parser: 2.0.0-beta.1 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.4.1 + cookie-signature: 1.0.6 + debug: 3.1.0 + depd: 1.1.2 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.1.2 + fresh: 0.5.2 + merge-descriptors: 1.0.1 + methods: 1.1.2 + mime-types: 2.1.35 + on-finished: 2.3.0 + parseurl: 1.3.3 + path-is-absolute: 1.0.1 + proxy-addr: 2.0.7 + qs: 6.9.6 + range-parser: 1.2.1 + router: 2.0.0-beta.1 + safe-buffer: 5.2.1 + send: 1.0.0-beta.1 + serve-static: 2.0.0-beta.1 + setprototypeof: 1.2.0 + statuses: 1.5.0 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + dev: false + + /express@5.0.0-beta.3: + resolution: {integrity: sha512-e7Qizw4gMBVe1Ky2oNi5C1h6oS8aWDcY2yYxvRMy5aMc6t2aqobuHpQRfR3LRC9NAW/c6081SeGWMGBorLXePg==} + engines: {node: '>= 4'} + dependencies: + accepts: 1.3.8 + array-flatten: 3.0.0 + body-parser: 2.0.0-beta.2 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.6.0 + cookie-signature: 1.0.6 + debug: 3.1.0 + depd: 2.0.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.2.0 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.1 + methods: 1.1.2 + mime-types: 2.1.35 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-is-absolute: 1.0.1 + proxy-addr: 2.0.7 + qs: 6.11.0 + range-parser: 1.2.1 + router: 2.0.0-beta.2 + safe-buffer: 5.2.1 + send: 1.0.0-beta.2 + serve-static: 2.0.0-beta.2 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + dev: false + + /finalhandler@1.1.2: + resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} + engines: {node: '>= 0.8'} + dependencies: + debug: 2.6.9 + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.3.0 + parseurl: 1.3.3 + statuses: 1.5.0 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + dev: false + + /finalhandler@1.2.0: + resolution: {integrity: sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==} + engines: {node: '>= 0.8'} + dependencies: + debug: 2.6.9 + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + dev: false + + /forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + dev: false + + /fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + dev: false + + /fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + dev: false + + /get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + dev: true + + /get-intrinsic@1.2.4: + resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + dev: false + + /get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + dev: true + + /get-tsconfig@4.7.3: + resolution: {integrity: sha512-ZvkrzoUA0PQZM6fy6+/Hce561s+faD1rsNwhnO5FelNjyy7EMGJ3Rz1AQ8GYDWjhRs/7dBLOEJvhK8MiEJOAFg==} + dependencies: + resolve-pkg-maps: 1.0.0 + dev: true + + /gopd@1.0.1: + resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} + dependencies: + get-intrinsic: 1.2.4 + dev: false + + /has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + dependencies: + es-define-property: 1.0.0 + dev: false + + /has-proto@1.0.3: + resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==} + engines: {node: '>= 0.4'} + dev: false + + /has-symbols@1.0.3: + resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} + engines: {node: '>= 0.4'} + dev: false + + /hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + dependencies: + function-bind: 1.1.2 + dev: false + + /http-errors@1.8.1: + resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} + engines: {node: '>= 0.6'} + dependencies: + depd: 1.1.2 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 1.5.0 + toidentifier: 1.0.1 + dev: false + + /http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + dev: false + + /human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + dev: true + + /iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + dependencies: + safer-buffer: 2.1.2 + dev: false + + /iconv-lite@0.5.2: + resolution: {integrity: sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==} + engines: {node: '>=0.10.0'} + dependencies: + safer-buffer: 2.1.2 + dev: false + + /inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + dev: false + + /ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + dev: false + + /is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + dev: false + + /is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: true + + /isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + dev: true + + /js-tokens@9.0.0: + resolution: {integrity: sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ==} + dev: true + + /jsonc-parser@3.2.1: + resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==} + dev: true + + /local-pkg@0.5.0: + resolution: {integrity: sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==} + engines: {node: '>=14'} + dependencies: + mlly: 1.6.1 + pkg-types: 1.0.3 + dev: true + + /loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + dependencies: + get-func-name: 2.0.2 + dev: true + + /magic-string@0.30.9: + resolution: {integrity: sha512-S1+hd+dIrC8EZqKyT9DstTH/0Z+f76kmmvZnkfQVmOpDEF9iVgdYif3Q/pIWHmCoo59bQVGW0kVL3e2nl+9+Sw==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + dev: true + + /media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + dev: false + + /merge-descriptors@1.0.1: + resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} + dev: false + + /merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + dev: true + + /methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + dev: false + + /mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + dev: false + + /mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + dev: false + + /mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + dev: true + + /mlly@1.6.1: + resolution: {integrity: sha512-vLgaHvaeunuOXHSmEbZ9izxPx3USsk8KCQ8iC+aTlp5sKRSoZvwhHh5L9VbKSaVC6sJDqbyohIS76E2VmHIPAA==} + dependencies: + acorn: 8.11.3 + pathe: 1.1.2 + pkg-types: 1.0.3 + ufo: 1.5.3 + dev: true + + /ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + dev: false + + /ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: true + + /ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + dev: false + + /nanoid@3.3.7: + resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + dev: true + + /negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + dev: false + + /npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + path-key: 4.0.0 + dev: true + + /object-inspect@1.13.1: + resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} + dev: false + + /obuf@1.1.2: + resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + dev: true + + /on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + dependencies: + ee-first: 1.1.1 + dev: false + + /on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + dependencies: + ee-first: 1.1.1 + dev: false + + /onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + dependencies: + mimic-fn: 4.0.0 + dev: true + + /p-limit@5.0.0: + resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} + engines: {node: '>=18'} + dependencies: + yocto-queue: 1.0.0 + dev: true + + /parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + dev: false + + /path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + dev: false + + /path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + dev: true + + /path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + dev: true + + /path-to-regexp@3.2.0: + resolution: {integrity: sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==} + dev: false + + /pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + dev: true + + /pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + dev: true + + /pg-cloudflare@1.1.1: + resolution: {integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==} + requiresBuild: true + dev: false + optional: true + + /pg-connection-string@2.6.4: + resolution: {integrity: sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==} + dev: false + + /pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + /pg-numeric@1.0.2: + resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} + engines: {node: '>=4'} + dev: true + + /pg-pool@3.6.2(pg@8.11.5): + resolution: {integrity: sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==} + peerDependencies: + pg: '>=8.0' + dependencies: + pg: 8.11.5 + dev: false + + /pg-protocol@1.6.1: + resolution: {integrity: sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==} + + /pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.0 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + dev: false + + /pg-types@4.0.2: + resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==} + engines: {node: '>=10'} + dependencies: + pg-int8: 1.0.1 + pg-numeric: 1.0.2 + postgres-array: 3.0.2 + postgres-bytea: 3.0.0 + postgres-date: 2.1.0 + postgres-interval: 3.0.0 + postgres-range: 1.1.4 + dev: true + + /pg@8.11.5: + resolution: {integrity: sha512-jqgNHSKL5cbDjFlHyYsCXmQDrfIX/3RsNwYqpd4N0Kt8niLuNoRNH+aazv6cOd43gPh9Y4DjQCtb+X0MH0Hvnw==} + engines: {node: '>= 8.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + dependencies: + pg-connection-string: 2.6.4 + pg-pool: 3.6.2(pg@8.11.5) + pg-protocol: 1.6.1 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.1.1 + dev: false + + /pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + dependencies: + split2: 4.2.0 + dev: false + + /picocolors@1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + dev: true + + /pkg-types@1.0.3: + resolution: {integrity: sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==} + dependencies: + jsonc-parser: 3.2.1 + mlly: 1.6.1 + pathe: 1.1.2 + dev: true + + /playwright-core@1.43.0: + resolution: {integrity: sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==} + engines: {node: '>=16'} + hasBin: true + dev: true + + /playwright@1.43.0: + resolution: {integrity: sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==} + engines: {node: '>=16'} + hasBin: true + dependencies: + playwright-core: 1.43.0 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /postcss@8.4.38: + resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.7 + picocolors: 1.0.0 + source-map-js: 1.2.0 + dev: true + + /postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + dev: false + + /postgres-array@3.0.2: + resolution: {integrity: sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==} + engines: {node: '>=12'} + dev: true + + /postgres-bytea@1.0.0: + resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} + engines: {node: '>=0.10.0'} + dev: false + + /postgres-bytea@3.0.0: + resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==} + engines: {node: '>= 6'} + dependencies: + obuf: 1.1.2 + dev: true + + /postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + dev: false + + /postgres-date@2.1.0: + resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==} + engines: {node: '>=12'} + dev: true + + /postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + dependencies: + xtend: 4.0.2 + dev: false + + /postgres-interval@3.0.0: + resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==} + engines: {node: '>=12'} + dev: true + + /postgres-range@1.1.4: + resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} + dev: true + + /pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.2.0 + dev: true + + /proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + dev: false + + /qs@6.11.0: + resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==} + engines: {node: '>=0.6'} + dependencies: + side-channel: 1.0.6 + dev: false + + /qs@6.9.6: + resolution: {integrity: sha512-TIRk4aqYLNoJUbd+g2lEdz5kLWIuTMRagAXxl78Q0RiVjAOugHmeKNGdd3cwo/ktpf9aL9epCfFqWDEKysUlLQ==} + engines: {node: '>=0.6'} + dev: false + + /range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + dev: false + + /raw-body@2.4.2: + resolution: {integrity: sha512-RPMAFUJP19WIet/99ngh6Iv8fzAbqum4Li7AD6DtGaW2RpMB/11xDoalPiJMTbu6I3hkbMVkATvZrqb9EEqeeQ==} + engines: {node: '>= 0.8'} + dependencies: + bytes: 3.1.1 + http-errors: 1.8.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + dev: false + + /raw-body@3.0.0-beta.1: + resolution: {integrity: sha512-XlSTHr67bCjSo5aOfAnN3x507zGvi3unF65BW57limYkc2ws/XB0mLUtJvvP7JGFeSPsYrlCv1ZrPGh0cwDxPQ==} + engines: {node: '>= 0.8'} + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.5.2 + unpipe: 1.0.0 + dev: false + + /react-is@18.2.0: + resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} + dev: true + + /resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + dev: true + + /rollup@4.14.0: + resolution: {integrity: sha512-Qe7w62TyawbDzB4yt32R0+AbIo6m1/sqO7UPzFS8Z/ksL5mrfhA0v4CavfdmFav3D+ub4QeAgsGEe84DoWe/nQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + dependencies: + '@types/estree': 1.0.5 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.14.0 + '@rollup/rollup-android-arm64': 4.14.0 + '@rollup/rollup-darwin-arm64': 4.14.0 + '@rollup/rollup-darwin-x64': 4.14.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.14.0 + '@rollup/rollup-linux-arm64-gnu': 4.14.0 + '@rollup/rollup-linux-arm64-musl': 4.14.0 + '@rollup/rollup-linux-powerpc64le-gnu': 4.14.0 + '@rollup/rollup-linux-riscv64-gnu': 4.14.0 + '@rollup/rollup-linux-s390x-gnu': 4.14.0 + '@rollup/rollup-linux-x64-gnu': 4.14.0 + '@rollup/rollup-linux-x64-musl': 4.14.0 + '@rollup/rollup-win32-arm64-msvc': 4.14.0 + '@rollup/rollup-win32-ia32-msvc': 4.14.0 + '@rollup/rollup-win32-x64-msvc': 4.14.0 + fsevents: 2.3.3 + dev: true + + /router@2.0.0-beta.1: + resolution: {integrity: sha512-GLoYgkhAGAiwVda5nt6Qd4+5RAPuQ4WIYLlZ+mxfYICI+22gnIB3eCfmhgV8+uJNPS1/39DOYi/vdrrz0/ouKA==} + engines: {node: '>= 0.10'} + dependencies: + array-flatten: 3.0.0 + methods: 1.1.2 + parseurl: 1.3.3 + path-to-regexp: 3.2.0 + setprototypeof: 1.2.0 + utils-merge: 1.0.1 + dev: false + + /router@2.0.0-beta.2: + resolution: {integrity: sha512-ascmzrv4IAB64SpWzFwYOA+jz6PaUbrzHLPsQrPjQ3uQTL2qlhwY9S2sRvvBMgUISQptQG457jcWWcWqtwrbag==} + engines: {node: '>= 0.10'} + dependencies: + array-flatten: 3.0.0 + is-promise: 4.0.0 + methods: 1.1.2 + parseurl: 1.3.3 + path-to-regexp: 3.2.0 + setprototypeof: 1.2.0 + utils-merge: 1.0.1 + dev: false + + /safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + dev: false + + /safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + dev: false + + /send@1.0.0-beta.1: + resolution: {integrity: sha512-OKTRokcl/oo34O8+6aUpj8Jf2Bjw2D0tZzmX0/RvyfVC9ZOZW+HPAWAlhS817IsRaCnzYX1z++h2kHFr2/KNRg==} + engines: {node: '>= 0.10'} + dependencies: + debug: 3.1.0 + destroy: 1.0.4 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 1.8.1 + mime-types: 2.1.35 + ms: 2.1.3 + on-finished: 2.3.0 + range-parser: 1.2.1 + statuses: 1.5.0 + transitivePeerDependencies: + - supports-color + dev: false + + /send@1.0.0-beta.2: + resolution: {integrity: sha512-k1yHu/FNK745PULKdsGpQ+bVSXYNwSk+bWnYzbxGZbt5obZc0JKDVANsCRuJD1X/EG15JtP9eZpwxkhUxIYEcg==} + engines: {node: '>= 0.10'} + dependencies: + debug: 3.1.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime-types: 2.1.35 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + dev: false + + /serve-static@2.0.0-beta.1: + resolution: {integrity: sha512-DEJ9on/tQeFO2Omj7ovT02lCp1YgP4Kb8W2lv2o/4keTFAbgc8HtH3yPd47++2wv9lvQeqiA7FHFDe5+8c4XpA==} + engines: {node: '>= 0.10'} + dependencies: + encodeurl: 1.0.2 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.0.0-beta.1 + transitivePeerDependencies: + - supports-color + dev: false + + /serve-static@2.0.0-beta.2: + resolution: {integrity: sha512-Ge718g4UJjzYoXFEGLY/VLSuTHp0kQcUV65QA98J8d3XREsVIHu53GBh9NWjDy4u2xwsSwRzu9nu7Q+b4o6Xyw==} + engines: {node: '>= 0.10'} + dependencies: + encodeurl: 1.0.2 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.0.0-beta.2 + transitivePeerDependencies: + - supports-color + dev: false + + /set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + dev: false + + /setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + dev: false + + /shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + dependencies: + shebang-regex: 3.0.0 + dev: true + + /shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + dev: true + + /side-channel@1.0.6: + resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + object-inspect: 1.13.1 + dev: false + + /siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + dev: true + + /signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + dev: true + + /source-map-js@1.2.0: + resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} + engines: {node: '>=0.10.0'} + dev: true + + /split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + dev: false + + /stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + dev: true + + /statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + dev: false + + /statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + dev: false + + /std-env@3.7.0: + resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} + dev: true + + /strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + dev: true + + /strip-literal@2.1.0: + resolution: {integrity: sha512-Op+UycaUt/8FbN/Z2TWPBLge3jWrP3xj10f3fnYxf052bKuS3EKs1ZQcVGjnEMdsNVAM+plXRdmjrZ/KgG3Skw==} + dependencies: + js-tokens: 9.0.0 + dev: true + + /tinybench@2.6.0: + resolution: {integrity: sha512-N8hW3PG/3aOoZAN5V/NSAEDz0ZixDSSt5b/a05iqtpgfLWMSVuCo7w0k2vVvEjdrIoeGqZzweX2WlyioNIHchA==} + dev: true + + /tinypool@0.8.3: + resolution: {integrity: sha512-Ud7uepAklqRH1bvwy22ynrliC7Dljz7Tm8M/0RBUW+YRa4YHhZ6e4PpgE+fu1zr/WqB1kbeuVrdfeuyIBpy4tw==} + engines: {node: '>=14.0.0'} + dev: true + + /tinyspy@2.2.1: + resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + engines: {node: '>=14.0.0'} + dev: true + + /toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + dev: false + + /tsx@4.7.2: + resolution: {integrity: sha512-BCNd4kz6fz12fyrgCTEdZHGJ9fWTGeUzXmQysh0RVocDY3h4frk05ZNCXSy4kIenF7y/QnrdiVpTsyNRn6vlAw==} + engines: {node: '>=18.0.0'} + hasBin: true + dependencies: + esbuild: 0.19.12 + get-tsconfig: 4.7.3 + optionalDependencies: + fsevents: 2.3.3 + dev: true + + /type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + dev: true + + /type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + dev: false + + /typescript@5.4.4: + resolution: {integrity: sha512-dGE2Vv8cpVvw28v8HCPqyb08EzbBURxDpuhJvTrusShUfGnhHBafDsLdS1EhhxyL6BJQE+2cT3dDPAv+MQ6oLw==} + engines: {node: '>=14.17'} + hasBin: true + dev: true + + /ufo@1.5.3: + resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==} + dev: true + + /undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + dev: true + + /unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + dev: false + + /utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + dev: false + + /uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + dev: false + + /vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + dev: false + + /vite-node@1.4.0(@types/node@20.12.5): + resolution: {integrity: sha512-VZDAseqjrHgNd4Kh8icYHWzTKSCZMhia7GyHfhtzLW33fZlG9SwsB6CEhgyVOWkJfJ2pFLrp/Gj1FSfAiqH9Lw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + dependencies: + cac: 6.7.14 + debug: 4.3.4 + pathe: 1.1.2 + picocolors: 1.0.0 + vite: 5.2.8(@types/node@20.12.5) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + dev: true + + /vite@5.2.8(@types/node@20.12.5): + resolution: {integrity: sha512-OyZR+c1CE8yeHw5V5t59aXsUPPVTHMDjEZz8MgguLL/Q7NblxhZUlTu9xSPqlsUO/y+X7dlU05jdhvyycD55DA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + '@types/node': 20.12.5 + esbuild: 0.20.2 + postcss: 8.4.38 + rollup: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + dev: true + + /vitest@1.4.0(@types/node@20.12.5): + resolution: {integrity: sha512-gujzn0g7fmwf83/WzrDTnncZt2UiXP41mHuFYFrdwaLRVQ6JYQEiME2IfEjU3vcFL3VKa75XhI3lFgn+hfVsQw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 1.4.0 + '@vitest/ui': 1.4.0 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + dependencies: + '@types/node': 20.12.5 + '@vitest/expect': 1.4.0 + '@vitest/runner': 1.4.0 + '@vitest/snapshot': 1.4.0 + '@vitest/spy': 1.4.0 + '@vitest/utils': 1.4.0 + acorn-walk: 8.3.2 + chai: 4.4.1 + debug: 4.3.4 + execa: 8.0.1 + local-pkg: 0.5.0 + magic-string: 0.30.9 + pathe: 1.1.2 + picocolors: 1.0.0 + std-env: 3.7.0 + strip-literal: 2.1.0 + tinybench: 2.6.0 + tinypool: 0.8.3 + vite: 5.2.8(@types/node@20.12.5) + vite-node: 1.4.0(@types/node@20.12.5) + why-is-node-running: 2.2.2 + transitivePeerDependencies: + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + dev: true + + /which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + dependencies: + isexe: 2.0.0 + dev: true + + /why-is-node-running@2.2.2: + resolution: {integrity: sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==} + engines: {node: '>=8'} + hasBin: true + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + dev: true + + /xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + dev: false + + /yocto-queue@1.0.0: + resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} + engines: {node: '>=12.20'} + dev: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..c876dd4 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +packages: + - backend + - contract + - e2e + - frontend + - github + - offline + - template diff --git a/template/gold/debian.bash.Dockerfile b/template/gold/debian.bash.Dockerfile new file mode 100644 index 0000000..b390f6b --- /dev/null +++ b/template/gold/debian.bash.Dockerfile @@ -0,0 +1,13 @@ +FROM debian + +ARG INSTALL_DEPENDENCY +RUN test -n "$INSTALL_DEPENDENCY" && \ + apt-get update && \ + apt-get install -y $INSTALL_DEPENDENCY + +ENV SHELL /bin/bash + +WORKDIR /gold +COPY gold/scripts scripts +COPY gold/install_test.sh install_test.sh +RUN chmod +x /gold/scripts/*.sh /gold/install_test.sh diff --git a/template/gold/debian.fish.Dockerfile b/template/gold/debian.fish.Dockerfile new file mode 100644 index 0000000..df96e62 --- /dev/null +++ b/template/gold/debian.fish.Dockerfile @@ -0,0 +1,9 @@ +ARG INSTALL_DEPENDENCY +FROM install.template.test.debian.bash.${INSTALL_DEPENDENCY:-UNSET} + +RUN apt-get install -y fish +RUN mkdir -p ~/.config/fish && touch ~/.config/fish/config.fish + +ENV SHELL /bin/fish + +CMD ["/bin/fish"] diff --git a/template/gold/debian.zsh.Dockerfile b/template/gold/debian.zsh.Dockerfile new file mode 100644 index 0000000..8381322 --- /dev/null +++ b/template/gold/debian.zsh.Dockerfile @@ -0,0 +1,8 @@ +ARG INSTALL_DEPENDENCY +FROM install.template.test.debian.bash.${INSTALL_DEPENDENCY:-UNSET} + +RUN apt-get install -y zsh + +ENV SHELL /bin/zsh + +CMD ["/bin/zsh"] diff --git a/template/gold/install_test.sh b/template/gold/install_test.sh new file mode 100755 index 0000000..c21343f --- /dev/null +++ b/template/gold/install_test.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env sh +set -e + +# runs in install.template.test.$os.$shell.$downloader containers to verify gold test scripts +# todo check expected MIME type + +script="$1" +binary="$2" +profile="$3" + +/gold/scripts/"$script" +. ~/"$profile" +command -v "$binary" diff --git a/template/gold/integration_test.sh b/template/gold/integration_test.sh new file mode 100755 index 0000000..f886034 --- /dev/null +++ b/template/gold/integration_test.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env sh + +# builds each install.template.test.$os.$shell.$downloader image +# and runs test for every combination of os, shell, downloader and gold test script + +set -e +docker -l warn build -q --build-arg="INSTALL_DEPENDENCY=curl" -t install.template.test.debian.bash.curl -f gold/debian.bash.Dockerfile . +docker -l warn build -q --build-arg="INSTALL_DEPENDENCY=wget" -t install.template.test.debian.bash.wget -f gold/debian.bash.Dockerfile . +docker -l warn build -q --build-arg="INSTALL_DEPENDENCY=curl" -t install.template.test.debian.fish.curl -f gold/debian.fish.Dockerfile . +docker -l warn build -q --build-arg="INSTALL_DEPENDENCY=wget" -t install.template.test.debian.fish.wget -f gold/debian.fish.Dockerfile . +docker -l warn build -q --build-arg="INSTALL_DEPENDENCY=curl" -t install.template.test.debian.zsh.curl -f gold/debian.zsh.Dockerfile . +docker -l warn build -q --build-arg="INSTALL_DEPENDENCY=wget" -t install.template.test.debian.zsh.wget -f gold/debian.zsh.Dockerfile . +set +e + +run_test() { + image_suffix="$1" + script="$2" + binary="$3" + shell_profile="$4" + docker run -it --rm "install.template.test.$image_suffix" /gold/install_test.sh "$script" "$binary" "$shell_profile" +} + +pass="\033[1;38;5;41m\0342\0234\0224\033[m" +fail="\033[41mx\033[m" + +status_print() { + exit_code="$1" + label="$2" + output="$3" + if test "$exit_code" = 0; then + echo " $pass $label" + else + echo " $fail $label" + printf " ---OUTPUT---\n ------------\n%s\n ------------\n\n" "$output" + fi +} + +shell_profile() { + _shell="$1" + case $_shell in + bash) _profile=".bash_profile" ;; + fish) _profile=".config/fish/config.fish" ;; + zsh) _profile=".zprofile" ;; + *) exit 1 ;; + esac + echo "$_profile" +} + +binary_name() { + _script="$1" + case $_script in + maestro.sh) _binary="maestro" ;; + *) exit 1 ;; + esac + echo "$_binary" +} + +oses="debian" +shells="bash fish zsh" +downloaders="curl wget" +scripts="maestro.sh" +result=0 + +for os in $oses; do + for shell in $shells; do + for downloader in $downloaders; do + for script in $scripts; do + output=$(run_test "$os.$shell.$downloader" "$script" "$(binary_name $script)" "$(shell_profile $shell)") + status=$? + status_print "$status" "$os $shell $downloader $script" "$output" + if [ $status -ne "0" ]; then + result=1 + fi + done + done + done +done + +exit $result diff --git a/template/gold/scripts/maestro.sh b/template/gold/scripts/maestro.sh new file mode 100644 index 0000000..5a07f97 --- /dev/null +++ b/template/gold/scripts/maestro.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env sh +set -e + +binary_name="maestro" +repository_name="eighty4/maestro" + +abandon_ship() { + if [ $# -gt 0 ]; then + echo "$1" >&2 + fi + echo "visit https://github.com/$repository_name for other setup methods." >&2 + exit 1 +} + +fetch_json() { + _json_url="$1" + _json="" + if command -v curl >/dev/null 2>&1; then + _json=$(curl -s "$_json_url") + elif command -v wget >/dev/null 2>&1; then + _json=$(wget -q -O - -o /dev/null "$_json_url") + else + abandon_ship "unable to download with curl or wget." + fi + echo "$_json" +} + +download_binary() { + _bin_url="$1" + _bin_path="$2" + if command -v curl >/dev/null 2>&1; then + curl -Ls "$_bin_url" -o "$_bin_path" + elif command -v wget >/dev/null 2>&1; then + wget -q -O "$_bin_path" -o /dev/null "$_bin_url" + else + abandon_ship "unable to download with curl or wget." + fi +} + +resolve_cpu() { + _cpu="" + case $(uname -m) in + armv6l | armv7l) abandon_ship "no prebuilt binary for 32-bit arm." ;; + x86_64 | amd64) _cpu="x86_64" ;; + aarch64 | arm64) _cpu="aarch64" ;; + *) abandon_ship "cpu architecture $_cpu is unsupported. visit https://github.com/eighty4/install/issues to submit a PR." ;; + esac + echo "$_cpu" +} + +resolve_os() { + _os="" + case $(uname -o) in + Darwin) _os="MacOS" ;; + GNU/Linux) _os="Linux" ;; + *) abandon_ship "operating system $_os is unsupported. visit https://github.com/eighty4/install/issues to submit a PR." ;; + esac + echo "$_os" +} + +resolve_shell_profile() { + _profile="" + case $SHELL in + */bash*) _profile=".bash_profile" ;; + */fish*) _profile=".config/fish/config.fish" ;; + */zsh*) _profile=".zprofile" ;; + *) _profile=".profile" ;; + esac + echo "$_profile" +} + +resolve_version() { + _version="" + _json=$(fetch_json "https://api.github.com/repos/$repository_name/releases/latest") + _version=$(echo "$_json" | grep \"tag_name\": | cut -d : -f 2 | cut -d \" -f 2) + if test -z "$_version"; then + _version="latest" + fi + echo "$_version" +} + +check_cmd() { + _cmd="$1" + if ! command -v "$_cmd" >/dev/null 2>&1; then + abandon_ship "unable to locate dependency program \`$_cmd\` on your machine." + fi +} + +resolve_filename() { + _cpu="$1" + _os="$2" + _filename="" + if test "$_cpu" = "x86_64" && test "$_os" = "MacOS"; then + _filename="maestro-darwin-amd64" + elif test "$_cpu" = "aarch64" && test "$_os" = "MacOS"; then + _filename="maestro-darwin-arm64" + elif test "$_cpu" = "x86_64" && test "$_os" = "Linux"; then + _filename="maestro-linux-amd64" + elif test "$_cpu" = "aarch64" && test "$_os" = "Linux"; then + _filename="maestro-linux-arm64" + else + abandon_ship "no prebuilt $_cpu binary for $_os" + fi + echo "$_filename" +} + +check_cmd chmod +check_cmd cut +check_cmd echo +check_cmd grep +check_cmd mkdir +check_cmd uname +shell_profile=$(resolve_shell_profile) +arch=$(resolve_cpu) +os=$(resolve_os) +filename=$(resolve_filename "$arch" "$os") + +latest_version=$(resolve_version) +echo "installing $binary_name@$latest_version" +echo "" + +install_path=".$binary_name/bin" +if [ "$os" = "linux" ]; then + install_path=".config/$install_path" +fi +install_dir="$HOME/$install_path" +mkdir -p "$install_dir" + +download_binary "https://github.com/$repository_name/releases/download/$latest_version/$filename" "$install_dir/$binary_name" +chmod +x "$install_dir/$binary_name" + +if ! grep .$binary_name/bin "$HOME/$shell_profile" >/dev/null 2>&1; then + { + echo ""; + echo "# added by https://install.eighty4.tech"; + echo "PATH=\"\$PATH:$install_dir"\" >> "$HOME/$shell_profile"; + } >> "$HOME/$shell_profile" +fi + +checkmark="\033[1;38;5;41m\0342\0234\0224\033[m" +echo "$checkmark binary installed at \033[1m~/$install_path\033[m" +echo "$checkmark \033[1m~/$shell_profile\033[m now adds $binary_name to PATH" +echo "" +echo "run these commands to verify install:" +echo " source ~/$shell_profile" +echo " $binary_name" diff --git a/template/package.json b/template/package.json new file mode 100644 index 0000000..b155750 --- /dev/null +++ b/template/package.json @@ -0,0 +1,29 @@ +{ + "name": "@eighty4/install-template", + "version": "0.0.1", + "author": "Adam McKee ", + "license": "BSD-3-Clause", + "type": "module", + "main": "lib/Template.js", + "types": "lib/Template.d.ts", + "scripts": { + "build": "tsc --build --clean && tsc --build", + "gold": "UPDATE_GOLD=1 vitest run", + "test": "npm run test:gold && npm run test:integration", + "test:gold": "vitest run", + "test:integration": "./gold/integration_test.sh", + "test:watch": "vitest" + }, + "devDependencies": { + "@types/node": "^20.12.5", + "typescript": "^5.4.4", + "vitest": "^1.4.0" + }, + "files": [ + "lib/**/*", + "src/**/*", + "gold/*.sh", + "package.json", + "tsconfig.json" + ] +} diff --git a/template/src/Distrubtions.ts b/template/src/Distrubtions.ts new file mode 100644 index 0000000..e8a807b --- /dev/null +++ b/template/src/Distrubtions.ts @@ -0,0 +1,9 @@ +// https://en.wikipedia.org/wiki/Uname#Examples +export type Architecture = 'aarch64' | 'arm' | 'x86_64' + +export type OperatingSystem = 'Linux' | 'MacOS' | 'Windows' + +export interface Distribution { + arch: Architecture + os: OperatingSystem +} diff --git a/template/src/Generate.test.ts b/template/src/Generate.test.ts new file mode 100644 index 0000000..a699354 --- /dev/null +++ b/template/src/Generate.test.ts @@ -0,0 +1,40 @@ +import {readFileSync, writeFileSync} from 'node:fs' +import {join} from 'node:path' +import {expect, test} from 'vitest' +import {generateScript, type GenerateScriptOptions} from './Generate' + +function generateScriptTest(goldFile: string, options: GenerateScriptOptions) { + const goldPath = join('gold', 'scripts', goldFile) + const result = generateScript(options) + if (process.env.UPDATE_GOLD === '1') { + writeFileSync(goldPath, result) + } else { + expect(result).toBe(readFileSync(goldPath).toString()) + } +} + +test('generate maestro.sh', () => generateScriptTest('maestro.sh', { + binaryName: 'maestro', + filenames: { + 'maestro-darwin-amd64': { + arch: 'x86_64', + os: 'MacOS', + }, + 'maestro-darwin-arm64': { + arch: 'aarch64', + os: 'MacOS', + }, + 'maestro-linux-amd64': { + arch: 'x86_64', + os: 'Linux', + }, + 'maestro-linux-arm64': { + arch: 'aarch64', + os: 'Linux' + } + }, + repository: { + owner: 'eighty4', + name: 'maestro', + }, +})) diff --git a/template/src/Generate.ts b/template/src/Generate.ts new file mode 100644 index 0000000..cc4b0f7 --- /dev/null +++ b/template/src/Generate.ts @@ -0,0 +1,214 @@ +import type {Architecture, Distribution, OperatingSystem} from './Distrubtions.js' + +export interface GenerateScriptOptions { + binaryName: string + filenames: Record + repository: { + owner: string + name: string + } +} + +function validateOptions(options: GenerateScriptOptions) { + if (!options) { + throw new Error('options param is required') + } else if (!options.binaryName || !options.binaryName.length) { + throw new Error('options.binaryName param is required') + } else if (!options.repository) { + throw new Error('options.repository param is required') + } else if (!options.repository.owner || !options.repository.owner.length) { + throw new Error('options.repository.owner param is required') + } else if (!options.repository.name || !options.repository.name.length) { + throw new Error('options.repository.name param is required') + } else if (!options.filenames || !Object.keys(options.filenames).length) { + throw new Error('options.filenames is required') + } +} + +function distributionSupport(distributions: Array): { + architectures: Array, + oses: Array +} { + const architectures: Array = [] + const oses: Array = [] + for (const distribution of distributions) { + if (!architectures.includes(distribution.arch)) { + architectures.push(distribution.arch) + } + if (!oses.includes(distribution.os)) { + oses.push(distribution.os) + } + } + return {architectures, oses} +} + +export function generateScript(options: GenerateScriptOptions): string { + validateOptions(options) + const {architectures, oses} = distributionSupport(Object.values(options.filenames)) + return `#!/usr/bin/env sh +set -e + +binary_name="${options.binaryName}" +repository_name="${options.repository.owner}/${options.repository.name}" + +abandon_ship() { + if [ $# -gt 0 ]; then + echo "$1" >&2 + fi + echo "visit https://github.com/$repository_name for other setup methods." >&2 + exit 1 +} + +fetch_json() { + _json_url="$1" + _json="" + if command -v curl >/dev/null 2>&1; then + _json=$(curl -s "$_json_url") + elif command -v wget >/dev/null 2>&1; then + _json=$(wget -q -O - -o /dev/null "$_json_url") + else + abandon_ship "unable to download with curl or wget." + fi + echo "$_json" +} + +download_binary() { + _bin_url="$1" + _bin_path="$2" + if command -v curl >/dev/null 2>&1; then + curl -Ls "$_bin_url" -o "$_bin_path" + elif command -v wget >/dev/null 2>&1; then + wget -q -O "$_bin_path" -o /dev/null "$_bin_url" + else + abandon_ship "unable to download with curl or wget." + fi +} + +resolve_cpu() { + _cpu="" + case $(uname -m) in + armv6l | armv7l) ${assignOrAbandon(architectures.includes('arm'), '_cpu', 'arm', '32-bit arm')} ;; + x86_64 | amd64) ${assignOrAbandon(architectures.includes('x86_64'), '_cpu', 'x86_64')} ;; + aarch64 | arm64) ${assignOrAbandon(architectures.includes('aarch64'), '_cpu', 'aarch64')} ;; + *) abandon_ship "cpu architecture $_cpu is unsupported. visit https://github.com/eighty4/install/issues to submit a PR." ;; + esac + echo "$_cpu" +} + +resolve_os() { + _os="" + case $(uname -o) in + Darwin) ${assignOrAbandon(oses.includes('MacOS'), '_os', 'MacOS')} ;; + GNU/Linux) ${assignOrAbandon(oses.includes('Linux'), '_os', 'Linux')} ;; + *) abandon_ship "operating system $_os is unsupported. visit https://github.com/eighty4/install/issues to submit a PR." ;; + esac + echo "$_os" +} + +resolve_shell_profile() { + _profile="" + case $SHELL in + */bash*) _profile=".bash_profile" ;; + */fish*) _profile=".config/fish/config.fish" ;; + */zsh*) _profile=".zprofile" ;; + *) _profile=".profile" ;; + esac + echo "$_profile" +} + +resolve_version() { + _version="" + _json=$(fetch_json "https://api.github.com/repos/$repository_name/releases/latest") + _version=$(echo "$_json" | grep \\"tag_name\\": | cut -d : -f 2 | cut -d \\" -f 2) + if test -z "$_version"; then + _version="latest" + fi + echo "$_version" +} + +check_cmd() { + _cmd="$1" + if ! command -v "$_cmd" >/dev/null 2>&1; then + abandon_ship "unable to locate dependency program \\\`$_cmd\\\` on your machine." + fi +} + +${resolveFilenameFunction(options.filenames)} + +check_cmd chmod +check_cmd cut +check_cmd echo +check_cmd grep +check_cmd mkdir +check_cmd uname +shell_profile=$(resolve_shell_profile) +arch=$(resolve_cpu) +os=$(resolve_os) +filename=$(resolve_filename "$arch" "$os") + +latest_version=$(resolve_version) +echo "installing $binary_name@$latest_version" +echo "" + +install_path=".$binary_name/bin" +if [ "$os" = "linux" ]; then + install_path=".config/$install_path" +fi +install_dir="$HOME/$install_path" +mkdir -p "$install_dir" + +download_binary "https://github.com/$repository_name/releases/download/$latest_version/$filename" "$install_dir/$binary_name" +chmod +x "$install_dir/$binary_name" + +if ! grep .$binary_name/bin "$HOME/$shell_profile" >/dev/null 2>&1; then + { + echo ""; + echo "# added by https://install.eighty4.tech"; + echo "PATH=\\"\\$PATH:$install_dir"\\" >> "$HOME/$shell_profile"; + } >> "$HOME/$shell_profile" +fi + +checkmark="\\033[1;38;5;41m\\0342\\0234\\0224\\033[m" +echo "$checkmark binary installed at \\033[1m~/$install_path\\033[m" +echo "$checkmark \\033[1m~/$shell_profile\\033[m now adds $binary_name to PATH" +echo "" +echo "run these commands to verify install:" +echo " source ~/$shell_profile" +echo " $binary_name" +` +} + +function assignOrAbandon(check: boolean, variable: string, assignment: string, label?: string) { + if (check) { + return `${variable}="${assignment}"` + } else { + return `abandon_ship "no prebuilt binary for ${label || assignment}."` + } +} + +function resolveFilenameFunction(filenamesToDistribution: Record) { + const filenames = Object.keys(filenamesToDistribution) + const chunks: Array = [] + chunks.push(` if ${renderCheckDistribution(filenamesToDistribution[filenames[0]])}; then`) + chunks.push(` _filename="${filenames[0]}"`) + if (filenames.length > 1) { + for (const filename of filenames.splice(1)) { + chunks.push(` elif ${renderCheckDistribution(filenamesToDistribution[filename])}; then`) + chunks.push(` _filename="${filename}"`) + } + } + chunks.push(' else') + chunks.push(' abandon_ship "no prebuilt $_cpu binary for $_os"') + chunks.push(' fi') + return `resolve_filename() { + _cpu="$1" + _os="$2" + _filename="" +${chunks.join('\n')} + echo "$_filename" +}` +} + +function renderCheckDistribution({arch, os}: Distribution): string { + return `test "$_cpu" = "${arch}" && test "$_os" = "${os}"` +} diff --git a/template/src/Resolution.test.ts b/template/src/Resolution.test.ts new file mode 100644 index 0000000..c63a3a0 --- /dev/null +++ b/template/src/Resolution.test.ts @@ -0,0 +1,29 @@ +import {expect, test} from 'vitest' +import {resolveDistribution} from './Resolution' + +test('resolveDistribution for Linux', () => { + expect(resolveDistribution('maestro-linux-386', 'application/x-executable')) + .toStrictEqual({arch: undefined, os: 'Linux'}) + expect(resolveDistribution('maestro-linux-amd64', 'application/x-executable')) + .toStrictEqual({arch: 'x86_64', os: 'Linux'}) + expect(resolveDistribution('maestro-linux-arm', 'application/x-executable')) + .toStrictEqual({arch: 'arm', os: 'Linux'}) + expect(resolveDistribution('maestro-linux-arm64', 'application/x-executable')) + .toStrictEqual({arch: 'aarch64', os: 'Linux'}) +}) + +test('resolveDistribution for MacOS', () => { + expect(resolveDistribution('maestro-darwin-amd64', 'application/x-mach-binary')) + .toStrictEqual({arch: 'x86_64', os: 'MacOS'}) + expect(resolveDistribution('maestro-darwin-arm64', 'application/x-mach-binary')) + .toStrictEqual({arch: 'aarch64', os: 'MacOS'}) +}) + +test('resolveDistribution for Windows', () => { + expect(resolveDistribution('maestro-windows-amd64', 'application/x-dosexec')) + .toStrictEqual({arch: 'x86_64', os: 'Windows'}) + expect(resolveDistribution('maestro-windows-arm', 'application/x-dosexec')) + .toStrictEqual({arch: 'arm', os: 'Windows'}) + expect(resolveDistribution('maestro-windows-arm64', 'application/x-dosexec')) + .toStrictEqual({arch: 'aarch64', os: 'Windows'}) +}) diff --git a/template/src/Resolution.ts b/template/src/Resolution.ts new file mode 100644 index 0000000..84a5221 --- /dev/null +++ b/template/src/Resolution.ts @@ -0,0 +1,43 @@ +import type {Architecture, Distribution, OperatingSystem} from './Distrubtions.js' + +const OS_BINARY_CONTENT_TYPES: Record = { + 'application/x-executable': 'Linux', + 'application/x-pie-executable': 'Linux', + 'application/x-mach-binary': 'MacOS', + 'application/x-dosexec': 'Windows', +} + +const ARCH_LABELS: Record> = { + 'aarch64': ['arm64', 'aarch64'], + 'arm': ['arm'], + 'x86_64': ['amd64', 'x64', 'x86_64'], +} + +const OS_ARCHITECTURES: Record> = { + 'Linux': ['x86_64', 'aarch64', 'arm'], + 'MacOS': ['x86_64', 'aarch64'], + 'Windows': ['x86_64', 'aarch64', 'arm'], +} + +export function resolveDistribution(filename: string, contentType: string): Partial | undefined { + const os = resolveOperatingSystem(contentType) + if (os) { + const arch = resolveArchitecture(os, filename) + return {arch, os} + } +} + +function resolveArchitecture(os: OperatingSystem, filename: string): Architecture | undefined { + const lowercaseFilename = filename.toLowerCase() + for (const arch of OS_ARCHITECTURES[os]) { + for (const archLabel of ARCH_LABELS[arch]) { + if (lowercaseFilename.includes(archLabel)) { + return arch + } + } + } +} + +function resolveOperatingSystem(contentType: string): OperatingSystem | undefined { + return OS_BINARY_CONTENT_TYPES[contentType] +} diff --git a/template/src/Template.ts b/template/src/Template.ts new file mode 100644 index 0000000..d8778ab --- /dev/null +++ b/template/src/Template.ts @@ -0,0 +1,3 @@ +export type {Architecture, Distribution, OperatingSystem} from './Distrubtions.js' +export {generateScript, type GenerateScriptOptions} from './Generate.js' +export {resolveDistribution} from './Resolution.js' diff --git a/template/tsconfig.json b/template/tsconfig.json new file mode 100644 index 0000000..95453fc --- /dev/null +++ b/template/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "composite": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": false, + "strict": true, + "skipLibCheck": true, + "outDir": "lib", + "rootDir": "src" + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.test.ts" + ] +} diff --git a/template/vitest.config.ts b/template/vitest.config.ts new file mode 100644 index 0000000..486949c --- /dev/null +++ b/template/vitest.config.ts @@ -0,0 +1,7 @@ +import {defineConfig} from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +})