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/.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..118ae45 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# install.eighty4.tech (unfortunately not install.sh) + +## Development + +| | | +|----------|-------------------| +| backend | pnpm dev | +| frontend | pnpm dev | +| postgres | docker compose up | + +## Examples install scripts in the wild + +Here is a list of install scripts for popular applications: + +- homebrew +- pnpm +- rustup +- wasm-pack + +## todos + +- https://neon.tech/docs/guides/aws-lambda +- show profile avatar and login name of authed user +- logout button +- design framework + - buttons: primary, secondary, disabled, icon + - login button + - input, select + - repo link + - release table + - keyboard shortcut icons + - color theme + - minimized graph papers + - loading indicator +- #features text animation +- #github text animation + +## cicd + +- write playwright tests +- docker build after verified +- run e2e tests against docker image +- docker push +- trigger deploy to linode 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..f1fe982 --- /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" + }, + "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..dec5555 --- /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.json({newUser: authData.newUser}) + } 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/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..42a67c8 --- /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 & + 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..0cf0275 --- /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 +npx tsc --build --clean && tsc --build +npx vite build + +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..e36a937 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,28 @@ +{ + "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": { + "dev": "vite", + "dev:offline": "vite --mode offline", + "build": "./build.sh", + "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 0000000..4025543 Binary files /dev/null and b/frontend/public/inter.woff2 differ 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..f5de8db --- /dev/null +++ b/frontend/src/components/RepositoryNavigation.ts @@ -0,0 +1,127 @@ +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) => { + console.log(e.type) + 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 = (e: MouseEvent) => { + console.log(e.type) + 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..a6b4294 --- /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:offline": "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..87b6050 --- /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", + "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'], + }, +})