From 74b1d7bc30804cb608f30e1066da02ff9a9a7976 Mon Sep 17 00:00:00 2001 From: joseferben Date: Wed, 28 Aug 2024 10:57:54 +0200 Subject: [PATCH 1/9] fix: disable www redirect --- web/app/config/middleware.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/app/config/middleware.ts b/web/app/config/middleware.ts index 16e817d..c150a35 100644 --- a/web/app/config/middleware.ts +++ b/web/app/config/middleware.ts @@ -13,7 +13,8 @@ export default defineMiddleware(async ({ app, config }) => { const database = getDatabase({ nodeEnv, database: config.database }); app.use(middleware.flyHeaders()); - app.use(middleware.forceWWW({ nodeEnv })); + // TODO needs more work + // app.use(middleware.forceWWW({ nodeEnv })); app.use(middleware.logging({ nodeEnv })); app.use(middleware.error({ nodeEnv })); app.use( From b3d3367417fa47077cdbd39741e1eebcb1cd0593 Mon Sep 17 00:00:00 2001 From: joseferben Date: Wed, 28 Aug 2024 11:13:57 +0200 Subject: [PATCH 2/9] fix: use express static middleware in prod --- web/app/config/middleware.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/web/app/config/middleware.ts b/web/app/config/middleware.ts index c150a35..6380dd3 100644 --- a/web/app/config/middleware.ts +++ b/web/app/config/middleware.ts @@ -1,4 +1,5 @@ import { env } from "app/config/env"; +import express from "express"; import { defineMiddleware, getDatabase, @@ -22,13 +23,8 @@ export default defineMiddleware(async ({ app, config }) => { redirects: config.http.redirects, }), ); - app.use( - config.http.staticPath, - middleware.staticFiles({ - nodeEnv, - dir: config.paths.public, - }), - ); + // TODO use built-in middleware + app.use(config.http.staticPath, express.static(config.paths.public)); app.use(middleware.rateLimit({ nodeEnv })); app.use(middleware.json()); app.use(middleware.urlencoded()); From 2e1f8886f95ec7bdde84956c4f9f5fefcb409b3b Mon Sep 17 00:00:00 2001 From: joseferben Date: Wed, 28 Aug 2024 11:18:18 +0200 Subject: [PATCH 3/9] fix: middleware stack --- web/app/config/middleware.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/web/app/config/middleware.ts b/web/app/config/middleware.ts index 6380dd3..9aaa29c 100644 --- a/web/app/config/middleware.ts +++ b/web/app/config/middleware.ts @@ -12,19 +12,16 @@ import basicAuth from "express-basic-auth"; export default defineMiddleware(async ({ app, config }) => { const { nodeEnv } = config; const database = getDatabase({ nodeEnv, database: config.database }); - - app.use(middleware.flyHeaders()); - // TODO needs more work - // app.use(middleware.forceWWW({ nodeEnv })); + app.use(middleware.forceWWW({ nodeEnv })); app.use(middleware.logging({ nodeEnv })); app.use(middleware.error({ nodeEnv })); + // TODO use built-in middleware + app.use(config.http.staticPath, express.static(config.paths.public)); app.use( middleware.redirect({ redirects: config.http.redirects, }), ); - // TODO use built-in middleware - app.use(config.http.staticPath, express.static(config.paths.public)); app.use(middleware.rateLimit({ nodeEnv })); app.use(middleware.json()); app.use(middleware.urlencoded()); From c8e88b291460f040e21d0e106048385b885ea447 Mon Sep 17 00:00:00 2001 From: joseferben Date: Fri, 30 Aug 2024 09:18:31 +0200 Subject: [PATCH 4/9] chore: plainstack --- .dockerignore | 9 - .github/workflows/ci.yml | 86 +-- .gitignore | 2 + create-plainweb/CHANGELOG.md => CHANGELOG.md | 101 ++- README.md | 6 +- biome.json | 4 +- bun.lockb | Bin 0 -> 76864 bytes create-plainweb/package.json | 52 -- create-plainweb/src/cli.ts | 14 - create-plainweb/src/copy-template.ts | 574 --------------- create-plainweb/src/create-plainweb.ts | 687 ------------------ create-plainweb/src/loading-indicator.ts | 37 - create-plainweb/src/utils.ts | 229 ------ create-plainweb/tsconfig.json | 20 - create-plainweb/tsup.config.ts | 13 - example/0-todo.tsx | 106 +++ example/1-background-job.tsx | 54 ++ knip.ts | 23 - package.json | 64 +- plainweb/.gitignore | 2 - plainweb/CHANGELOG.md | 107 --- plainweb/bin/admin.ts | 115 --- plainweb/package.json | 70 -- plainweb/src/admin/admin.ts | 34 - plainweb/src/admin/column.ts | 44 -- plainweb/src/admin/config.ts | 6 - .../src/admin/database/loaded-file-routes.ts | 14 - .../routes/[table]/components/table-row.tsx | 40 - .../admin/database/routes/[table]/edit.tsx | 112 --- .../admin/database/routes/[table]/index.tsx | 84 --- .../src/admin/database/routes/[table]/row.tsx | 42 -- .../src/admin/database/routes/index.test.ts | 60 -- plainweb/src/admin/database/routes/index.tsx | 20 - plainweb/src/admin/database/routes/sql.tsx | 9 - plainweb/src/admin/layout.tsx | 182 ----- plainweb/src/config.ts | 170 ----- plainweb/src/file-router.test.ts | 103 --- plainweb/src/file-router.ts | 212 ------ plainweb/src/flash-message.ts | 4 - plainweb/src/get-app.ts | 11 - plainweb/src/get-database.ts | 40 - plainweb/src/get-worker.ts | 22 - plainweb/src/handler.ts | 70 -- plainweb/src/id.ts | 11 - plainweb/src/index.ts | 32 - plainweb/src/isolate.ts | 39 - plainweb/src/log.test.ts | 15 - plainweb/src/log.ts | 80 -- plainweb/src/mail.ts | 48 -- plainweb/src/middleware.test.ts | 75 -- plainweb/src/middleware.ts | 210 ------ plainweb/src/migrate.test.ts | 224 ------ plainweb/src/migrate.ts | 134 ---- plainweb/src/plain-response.ts | 129 ---- plainweb/src/plainweb-fs.ts | 27 - plainweb/src/print-routes.ts | 22 - plainweb/src/task/database-task.test.ts | 118 --- plainweb/src/task/database-task.ts | 96 --- plainweb/src/task/define-task.ts | 122 ---- plainweb/src/task/inmemory-task.test.ts | 164 ----- plainweb/src/task/inmemory-task.ts | 51 -- plainweb/src/task/persisted-task.ts | 29 - plainweb/src/task/task-storage.ts | 24 - plainweb/src/task/task.ts | 24 - plainweb/src/task/work-tasks.ts | 110 --- plainweb/src/test-handler.ts | 21 - plainweb/styles.css | 3 - plainweb/tailwind.config.ts | 11 - plainweb/tsconfig.json | 25 - plainweb/tsup.config.ts | 26 - plainweb/vitest.config.ts | 6 - pnpm-workspace.yaml | 5 - src/bun.ts | 38 + src/database.ts | 78 ++ src/db.ts | 2 + src/entity.test.ts | 250 +++++++ src/entity.ts | 239 ++++++ src/env.ts | 11 + src/form.ts | 9 + src/job.ts | 107 +++ src/middleware/session.ts | 47 ++ src/plainstack.ts | 6 + template/.dockerignore | 3 - template/.gitignore | 40 - template/.vscode/settings.json | 22 - template/CHANGELOG.md | 115 --- template/Dockerfile | 44 -- template/README.md | 9 - template/app/cli/migrate.ts | 4 - template/app/cli/routes.ts | 4 - template/app/cli/serve.ts | 13 - template/app/components/document-logo.tsx | 14 - template/app/components/github-logo.tsx | 16 - template/app/config/database.ts | 5 - template/app/config/env.ts | 18 - template/app/config/middleware.ts | 29 - template/app/root.tsx | 34 - template/app/routes/health.tsx | 11 - template/app/routes/index.tsx | 38 - template/app/schema.ts | 9 - template/app/services/users.test.ts | 25 - template/app/services/users.ts | 16 - template/app/styles.css | 3 - template/biome.json | 28 - template/fly.toml | 44 -- template/migrations/0000_pink_guardsmen.sql | 5 - template/migrations/meta/0000_snapshot.json | 47 -- template/migrations/meta/_journal.json | 13 - template/package.json | 45 -- template/plainweb.config.ts | 20 - template/tailwind.config.ts | 8 - template/tsconfig.json | 13 - tsconfig.json | 38 + tsup.config.ts | 18 + turbo.json | 30 - web/.dockerignore | 7 - web/.gitignore | 4 - web/CHANGELOG.md | 113 --- web/Dockerfile | 43 -- web/app/cli/migrate.ts | 4 - web/app/cli/routes.ts | 4 - web/app/cli/serve.ts | 56 -- web/app/components/feature-section.tsx | 71 -- web/app/components/footer-section.tsx | 11 - web/app/components/hero-section.tsx | 44 -- web/app/components/icons.tsx | 314 -------- web/app/components/showcase.tsx | 295 -------- web/app/components/signup-section.tsx | 26 - web/app/config/database.ts | 5 - web/app/config/env.ts | 24 - web/app/config/middleware.ts | 42 -- web/app/layout.tsx | 100 --- web/app/routes/docs/[slug].tsx | 122 ---- web/app/routes/double-opt-in.test.ts | 57 -- web/app/routes/double-opt-in.tsx | 34 - web/app/routes/examples/button.tsx | 16 - web/app/routes/examples/streaming.tsx | 23 - web/app/routes/health.tsx | 7 - web/app/routes/index.tsx | 96 --- web/app/routes/robots.txt.tsx | 8 - web/app/routes/sitemap.xml.tsx | 20 - web/app/schema.ts | 31 - web/app/services/confetti.ts | 129 ---- web/app/services/contacts.ts | 90 --- web/app/services/page.test.ts | 30 - web/app/services/page.ts | 132 ---- web/app/services/render-code.ts | 32 - web/app/styles.css | 3 - web/app/tasks/double-opt-in.ts | 19 - web/documentation/0-motivation.md | 129 ---- web/documentation/1-getting-started.md | 48 -- web/documentation/10-deployment.md | 37 - web/documentation/11-recipes.md | 104 --- web/documentation/2-directory-structure.md | 126 ---- web/documentation/3-routing.md | 58 -- web/documentation/4-request-handling.md | 225 ------ web/documentation/5-database.md | 139 ---- web/documentation/6-environment-variables.md | 31 - web/documentation/7-testing.md | 117 --- web/documentation/8-task-queue.md | 103 --- web/documentation/9-logging.md | 50 -- web/fly.toml | 49 -- web/global.d.ts | 9 - web/migrations/0000_wooden_bromley.sql | 5 - .../0001_overrated_captain_stacy.sql | 3 - web/migrations/0002_chunky_hiroim.sql | 12 - web/migrations/0003_last_ben_urich.sql | 1 - web/migrations/0004_mute_natasha_romanoff.sql | 4 - web/migrations/0005_chemical_sersi.sql | 14 - web/migrations/meta/0000_snapshot.json | 44 -- web/migrations/meta/0001_snapshot.json | 51 -- web/migrations/meta/0002_snapshot.json | 112 --- web/migrations/meta/0003_snapshot.json | 116 --- web/migrations/meta/0004_snapshot.json | 140 ---- web/migrations/meta/0005_snapshot.json | 156 ---- web/migrations/meta/_journal.json | 48 -- web/package.json | 62 -- web/plainweb.config.ts | 38 - web/public/confetti.js | 87 --- web/public/favicon.ico | Bin 15406 -> 0 bytes web/public/images/bars.svg | 52 -- web/public/images/grug.png | Bin 673413 -> 0 bytes web/public/images/tech-logos/alpine.webp | Bin 13944 -> 0 bytes web/public/images/tech-logos/daisy.webp | Bin 15802 -> 0 bytes web/public/images/tech-logos/drizzle.webp | Bin 29694 -> 0 bytes web/public/images/tech-logos/express.webp | Bin 17870 -> 0 bytes web/public/images/tech-logos/fly.webp | Bin 15186 -> 0 bytes web/public/images/tech-logos/grug.webp | Bin 52024 -> 0 bytes web/public/images/tech-logos/htmx.webp | Bin 10046 -> 0 bytes web/public/images/tech-logos/node.webp | Bin 18128 -> 0 bytes web/public/images/tech-logos/prettier.webp | Bin 13738 -> 0 bytes web/public/images/tech-logos/sqlite.webp | Bin 25756 -> 0 bytes web/public/images/tech-logos/tailwind.webp | Bin 19934 -> 0 bytes web/public/images/tech-logos/typescript.webp | Bin 16854 -> 0 bytes web/public/images/tech-logos/zod.webp | Bin 12116 -> 0 bytes web/tailwind.config.ts | 11 - web/tsconfig.json | 13 - 197 files changed, 1119 insertions(+), 10190 deletions(-) delete mode 100644 .dockerignore rename create-plainweb/CHANGELOG.md => CHANGELOG.md (55%) create mode 100755 bun.lockb delete mode 100644 create-plainweb/package.json delete mode 100755 create-plainweb/src/cli.ts delete mode 100644 create-plainweb/src/copy-template.ts delete mode 100644 create-plainweb/src/create-plainweb.ts delete mode 100644 create-plainweb/src/loading-indicator.ts delete mode 100644 create-plainweb/src/utils.ts delete mode 100644 create-plainweb/tsconfig.json delete mode 100644 create-plainweb/tsup.config.ts create mode 100644 example/0-todo.tsx create mode 100644 example/1-background-job.tsx delete mode 100644 knip.ts delete mode 100644 plainweb/.gitignore delete mode 100644 plainweb/CHANGELOG.md delete mode 100755 plainweb/bin/admin.ts delete mode 100644 plainweb/package.json delete mode 100644 plainweb/src/admin/admin.ts delete mode 100644 plainweb/src/admin/column.ts delete mode 100644 plainweb/src/admin/config.ts delete mode 100644 plainweb/src/admin/database/loaded-file-routes.ts delete mode 100644 plainweb/src/admin/database/routes/[table]/components/table-row.tsx delete mode 100644 plainweb/src/admin/database/routes/[table]/edit.tsx delete mode 100644 plainweb/src/admin/database/routes/[table]/index.tsx delete mode 100644 plainweb/src/admin/database/routes/[table]/row.tsx delete mode 100644 plainweb/src/admin/database/routes/index.test.ts delete mode 100644 plainweb/src/admin/database/routes/index.tsx delete mode 100644 plainweb/src/admin/database/routes/sql.tsx delete mode 100644 plainweb/src/admin/layout.tsx delete mode 100644 plainweb/src/config.ts delete mode 100644 plainweb/src/file-router.test.ts delete mode 100644 plainweb/src/file-router.ts delete mode 100644 plainweb/src/flash-message.ts delete mode 100644 plainweb/src/get-app.ts delete mode 100644 plainweb/src/get-database.ts delete mode 100644 plainweb/src/get-worker.ts delete mode 100644 plainweb/src/handler.ts delete mode 100644 plainweb/src/id.ts delete mode 100644 plainweb/src/index.ts delete mode 100644 plainweb/src/isolate.ts delete mode 100644 plainweb/src/log.test.ts delete mode 100644 plainweb/src/log.ts delete mode 100644 plainweb/src/mail.ts delete mode 100644 plainweb/src/middleware.test.ts delete mode 100644 plainweb/src/middleware.ts delete mode 100644 plainweb/src/migrate.test.ts delete mode 100644 plainweb/src/migrate.ts delete mode 100644 plainweb/src/plain-response.ts delete mode 100644 plainweb/src/plainweb-fs.ts delete mode 100644 plainweb/src/print-routes.ts delete mode 100644 plainweb/src/task/database-task.test.ts delete mode 100644 plainweb/src/task/database-task.ts delete mode 100644 plainweb/src/task/define-task.ts delete mode 100644 plainweb/src/task/inmemory-task.test.ts delete mode 100644 plainweb/src/task/inmemory-task.ts delete mode 100644 plainweb/src/task/persisted-task.ts delete mode 100644 plainweb/src/task/task-storage.ts delete mode 100644 plainweb/src/task/task.ts delete mode 100644 plainweb/src/task/work-tasks.ts delete mode 100644 plainweb/src/test-handler.ts delete mode 100644 plainweb/styles.css delete mode 100644 plainweb/tailwind.config.ts delete mode 100644 plainweb/tsconfig.json delete mode 100644 plainweb/tsup.config.ts delete mode 100644 plainweb/vitest.config.ts delete mode 100644 pnpm-workspace.yaml create mode 100644 src/bun.ts create mode 100644 src/database.ts create mode 100644 src/db.ts create mode 100644 src/entity.test.ts create mode 100644 src/entity.ts create mode 100644 src/env.ts create mode 100644 src/form.ts create mode 100644 src/job.ts create mode 100644 src/middleware/session.ts create mode 100644 src/plainstack.ts delete mode 100644 template/.dockerignore delete mode 100644 template/.gitignore delete mode 100644 template/.vscode/settings.json delete mode 100644 template/CHANGELOG.md delete mode 100644 template/Dockerfile delete mode 100644 template/README.md delete mode 100644 template/app/cli/migrate.ts delete mode 100644 template/app/cli/routes.ts delete mode 100644 template/app/cli/serve.ts delete mode 100644 template/app/components/document-logo.tsx delete mode 100644 template/app/components/github-logo.tsx delete mode 100644 template/app/config/database.ts delete mode 100644 template/app/config/env.ts delete mode 100644 template/app/config/middleware.ts delete mode 100644 template/app/root.tsx delete mode 100644 template/app/routes/health.tsx delete mode 100644 template/app/routes/index.tsx delete mode 100644 template/app/schema.ts delete mode 100644 template/app/services/users.test.ts delete mode 100644 template/app/services/users.ts delete mode 100644 template/app/styles.css delete mode 100644 template/biome.json delete mode 100644 template/fly.toml delete mode 100644 template/migrations/0000_pink_guardsmen.sql delete mode 100644 template/migrations/meta/0000_snapshot.json delete mode 100644 template/migrations/meta/_journal.json delete mode 100644 template/package.json delete mode 100644 template/plainweb.config.ts delete mode 100644 template/tailwind.config.ts delete mode 100644 template/tsconfig.json create mode 100644 tsconfig.json create mode 100644 tsup.config.ts delete mode 100644 turbo.json delete mode 100644 web/.dockerignore delete mode 100644 web/.gitignore delete mode 100644 web/CHANGELOG.md delete mode 100644 web/Dockerfile delete mode 100644 web/app/cli/migrate.ts delete mode 100644 web/app/cli/routes.ts delete mode 100644 web/app/cli/serve.ts delete mode 100644 web/app/components/feature-section.tsx delete mode 100644 web/app/components/footer-section.tsx delete mode 100644 web/app/components/hero-section.tsx delete mode 100644 web/app/components/icons.tsx delete mode 100644 web/app/components/showcase.tsx delete mode 100644 web/app/components/signup-section.tsx delete mode 100644 web/app/config/database.ts delete mode 100644 web/app/config/env.ts delete mode 100644 web/app/config/middleware.ts delete mode 100644 web/app/layout.tsx delete mode 100644 web/app/routes/docs/[slug].tsx delete mode 100644 web/app/routes/double-opt-in.test.ts delete mode 100644 web/app/routes/double-opt-in.tsx delete mode 100644 web/app/routes/examples/button.tsx delete mode 100644 web/app/routes/examples/streaming.tsx delete mode 100644 web/app/routes/health.tsx delete mode 100644 web/app/routes/index.tsx delete mode 100644 web/app/routes/robots.txt.tsx delete mode 100644 web/app/routes/sitemap.xml.tsx delete mode 100644 web/app/schema.ts delete mode 100644 web/app/services/confetti.ts delete mode 100644 web/app/services/contacts.ts delete mode 100644 web/app/services/page.test.ts delete mode 100644 web/app/services/page.ts delete mode 100644 web/app/services/render-code.ts delete mode 100644 web/app/styles.css delete mode 100644 web/app/tasks/double-opt-in.ts delete mode 100644 web/documentation/0-motivation.md delete mode 100644 web/documentation/1-getting-started.md delete mode 100644 web/documentation/10-deployment.md delete mode 100644 web/documentation/11-recipes.md delete mode 100644 web/documentation/2-directory-structure.md delete mode 100644 web/documentation/3-routing.md delete mode 100644 web/documentation/4-request-handling.md delete mode 100644 web/documentation/5-database.md delete mode 100644 web/documentation/6-environment-variables.md delete mode 100644 web/documentation/7-testing.md delete mode 100644 web/documentation/8-task-queue.md delete mode 100644 web/documentation/9-logging.md delete mode 100644 web/fly.toml delete mode 100644 web/global.d.ts delete mode 100644 web/migrations/0000_wooden_bromley.sql delete mode 100644 web/migrations/0001_overrated_captain_stacy.sql delete mode 100644 web/migrations/0002_chunky_hiroim.sql delete mode 100644 web/migrations/0003_last_ben_urich.sql delete mode 100644 web/migrations/0004_mute_natasha_romanoff.sql delete mode 100644 web/migrations/0005_chemical_sersi.sql delete mode 100644 web/migrations/meta/0000_snapshot.json delete mode 100644 web/migrations/meta/0001_snapshot.json delete mode 100644 web/migrations/meta/0002_snapshot.json delete mode 100644 web/migrations/meta/0003_snapshot.json delete mode 100644 web/migrations/meta/0004_snapshot.json delete mode 100644 web/migrations/meta/0005_snapshot.json delete mode 100644 web/migrations/meta/_journal.json delete mode 100644 web/package.json delete mode 100644 web/plainweb.config.ts delete mode 100644 web/public/confetti.js delete mode 100644 web/public/favicon.ico delete mode 100644 web/public/images/bars.svg delete mode 100644 web/public/images/grug.png delete mode 100644 web/public/images/tech-logos/alpine.webp delete mode 100644 web/public/images/tech-logos/daisy.webp delete mode 100644 web/public/images/tech-logos/drizzle.webp delete mode 100644 web/public/images/tech-logos/express.webp delete mode 100644 web/public/images/tech-logos/fly.webp delete mode 100644 web/public/images/tech-logos/grug.webp delete mode 100644 web/public/images/tech-logos/htmx.webp delete mode 100644 web/public/images/tech-logos/node.webp delete mode 100644 web/public/images/tech-logos/prettier.webp delete mode 100644 web/public/images/tech-logos/sqlite.webp delete mode 100644 web/public/images/tech-logos/tailwind.webp delete mode 100644 web/public/images/tech-logos/typescript.webp delete mode 100644 web/public/images/tech-logos/zod.webp delete mode 100644 web/tailwind.config.ts delete mode 100644 web/tsconfig.json diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 112736b..0000000 --- a/.dockerignore +++ /dev/null @@ -1,9 +0,0 @@ -node_modules -db.sqlite3* -dist -.env -sqlite.db* -output.css -node_modules -.turbo -coverage diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26a19f2..39bbd41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,10 @@ on: jobs: check: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest] steps: - uses: actions/checkout@v4 name: checkout code 📥 @@ -19,7 +22,7 @@ jobs: - name: setup node 🟢 uses: actions/setup-node@v4 with: - node-version: "20.10.0" + node-version: "22.9.0" cache: "pnpm" - name: install dependencies 🔧 run: pnpm install @@ -33,82 +36,3 @@ jobs: CF_TURNSTILE_SITEKEY: test ADMIN_PASSWORD: test DB_URL: ":memory:" - - name: create-plainweb local 🪨 - run: ./create-plainweb/dist/cli.js my-plainweb-project --yes --no-git-init --debug --no-install - env: - npm_config_user_agent: ${{ github.actor }} - - name: ensure uses local plainweb (not npm) - run: | - echo " - \"my-plainweb-project\"" >> pnpm-workspace.yaml - sed -i 's/"plainweb": "[^"]*"/"plainweb": "workspace:*"/' my-plainweb-project/package.json - - name: install my-plainweb-project dependencies 🔧 - run: | - cd my-plainweb-project - pnpm install --frozen-lockfile=false - - name: run build, test, and check on my-plainweb-project 🚀 - run: | - cd my-plainweb-project - pnpm build - pnpm test - pnpm check - pnpm routes - - name: test server for my-plainweb-project 🌐 - run: | - cd my-plainweb-project - pnpm serve & - sleep 5 # Wait for the server to start - if curl -s http://localhost:3000 | grep -q "Let's go"; then - echo "Server started successfully and contains 'Let's go'" - else - echo "Server check failed" - exit 1 - fi - kill $! # Stop the server - rm -rf node_modules - - create-plainweb: - runs-on: ubuntu-latest - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - steps: - - name: install pnpm 📦 - uses: pnpm/action-setup@v4 - with: - version: 9.5.0 - - name: create-plainweb 🪨 - run: | - pnpm dlx create-plainweb@latest my-plainweb-project --yes --no-git-init --debug - env: - npm_config_user_agent: ${{ github.actor }} - - name: run build, test, and check 🚀 - run: | - cd my-plainweb-project - pnpm build - pnpm test - pnpm check - pnpm routes - - name: test server 🌐 - run: | - cd my-plainweb-project - pnpm serve & - sleep 5 # Wait for the server to start - if curl -s http://localhost:3000 | grep -q "Let's go"; then - echo "Server started successfully and contains 'Let's go'" - else - echo "Server check failed" - exit 1 - fi - kill $! # Stop the server - - uses: superfly/flyctl-actions/setup-flyctl@master - - name: deploy to fly 🚀 - run: | - cd my-plainweb-project - sed -i 's/app = '"'"'my-app'"'"'/app = '"'"'plainweb-template'"'"'/' fly.toml - sed -i '/\[http_service\]/,/\[/ s/auto_stop_machines = false/auto_stop_machines = true/' fly.toml - sed -i '/\[http_service\]/,/\[/ s/auto_start_machines = false/auto_start_machines = true/' fly.toml - sed -i '/\[http_service\]/,/\[/ s/min_machines_running = 1/min_machines_running = 0/' fly.toml - cat fly.toml - flyctl deploy --remote-only - env: - FLY_API_TOKEN: ${{ secrets.FLY_PLAINWEB_TEMPLATE_DEPLOY_TOKEN }} - - name: check fly deployment ⬆️ - run: curl -s https://plainweb-template.fly.dev | grep -q "Let's go" diff --git a/.gitignore b/.gitignore index 81fdb4b..fc35763 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ yarn-error.log* # Misc .DS_Store *.pem + +data.db* \ No newline at end of file diff --git a/create-plainweb/CHANGELOG.md b/CHANGELOG.md similarity index 55% rename from create-plainweb/CHANGELOG.md rename to CHANGELOG.md index 391c8bd..5f6fffb 100644 --- a/create-plainweb/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,196 +1,221 @@ -# create-plainweb +# plainweb + +## v0.0.36 + +[compare changes](https://github.com/joseferben/plainweb/compare/v0.0.35...v0.0.36) + +## v0.0.35 + +[compare changes](https://github.com/joseferben/plainweb/compare/v0.0.34...v0.0.35) + +## v0.0.34 + +[compare changes](https://github.com/joseferben/plainweb/compare/plainstack@0.0.33...v0.0.34) + +## 0.0.33 + +### Patch Changes + +- fix: don't start server twice ## 0.0.32 ### Patch Changes -- release: plainweb +- wait for http server ## 0.0.31 ### Patch Changes -- fix +- fix serve command ## 0.0.30 ### Patch Changes -- fix +- fixing env file loading ## 0.0.29 ### Patch Changes -- update +- fix loading of .env.test ## 0.0.28 ### Patch Changes -- use plainweb@0.0.13 +- fix build +- test rollback ## 0.0.27 ### Patch Changes -- use latest version +- set NODE_ENV=test ## 0.0.26 ### Patch Changes -- use recent plainweb version +- use built-in test runner ## 0.0.25 ### Patch Changes -- fix: downgrade esm dependency +- changes ## 0.0.24 ### Patch Changes -- fix: downgrade another deps for esm reasons +- fix import ## 0.0.23 ### Patch Changes -- fix: downgrade dependency to fix esm issue +- check for os windows ## 0.0.22 ### Patch Changes -- fix: downgrade execa to use without esm +- log debug module loading ## 0.0.21 ### Patch Changes -- chore: update several dependencies +- be more liberal when validating job name ## 0.0.20 ### Patch Changes -- b408ede: fix: default formatting of package.json +- 0d4320d: fix issue with windows filepaths ## 0.0.19 ### Patch Changes -- fix: issue where drizzle.config.ts would fail to load +- better error reporting during bootstrap ## 0.0.18 ### Patch Changes -- 364ff59: fix: better debugging for used plainweb version +- api revamp ## 0.0.17 ### Patch Changes -- - docs: add recipes for sitemap.xml and robots.txt - - docs: add icons - - docs: improve motivation bit - - chore: switch to pnpm - - feat: switch from prettier and eslint to biome - - chore: add knip - - feat: swtich from node test runner to vitest +- initial release plainstack +- Updated dependencies + - plainjobs@0.0.2 ## 0.0.16 ### Patch Changes -- write env file +- fix: remove security middleware ## 0.0.15 ### Patch Changes -- smaller fixes +- fix ## 0.0.14 ### Patch Changes -- many fixes +- fix test db urk ## 0.0.13 ### Patch Changes -- tasks +- smaller fixes regarding deployment ## 0.0.12 ### Patch Changes -- add utils for testing +- centralized config architecture ## 0.0.11 ### Patch Changes -- fixing routing issue +- - feat: admin dashboard preview + - feat: `pnpm routes` prints all http routes + - feat: prettier/eslint -> biome + - feat: plainweb.dev dark mode + - chore: updating several dependencies ## 0.0.10 ### Patch Changes -- fix +- b408ede: fix: default formatting of package.json ## 0.0.9 ### Patch Changes -- fixing +- fix: issue where drizzle.config.ts would fail to load ## 0.0.8 ### Patch Changes -- fixing +- - docs: add recipes for sitemap.xml and robots.txt + - docs: add icons + - docs: improve motivation bit + - chore: switch to pnpm + - feat: switch from prettier and eslint to biome + - chore: add knip + - feat: swtich from node test runner to vitest ## 0.0.7 ### Patch Changes -- fixing +- write env file ## 0.0.6 ### Patch Changes -- fixing esm +- smaller fixes ## 0.0.5 ### Patch Changes -- fixing +- fix tests ## 0.0.4 ### Patch Changes -- fixing +- tasks ## 0.0.3 ### Patch Changes -- fix modules issue +- add utils for testing ## 0.0.2 ### Patch Changes -- fix shebang +- fixing routing issue ## 0.0.1 diff --git a/README.md b/README.md index 0fdbb4a..8265bcb 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# plainweb +# plainstack The all-in-one web framework obsessing about velocity 🏎️. ## Getting Started -`npx create-plainweb` +`npm create plainstack` ## Documentation -[https://www.plainweb.dev/docs/getting-started](https://www.plainweb.dev/docs/getting-started) +[https://www.plainstack.dev/docs/getting-started](https://www.plainstack.dev/docs/getting-started) diff --git a/biome.json b/biome.json index c714255..cc9af19 100644 --- a/biome.json +++ b/biome.json @@ -5,11 +5,11 @@ }, "files": { "ignore": [ + "**/.env*", + "**/.out/**", "**/.git/**", "**/node_modules/**", "**/dist/**", - "**/.turbo/**", - "**/tsup.config.bundled*", "**/coverage/**" ] }, diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..5db44f970e7d3e015183f3a1d31087ab2c6fe356 GIT binary patch literal 76864 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 literal 0 HcmV?d00001 diff --git a/create-plainweb/package.json b/create-plainweb/package.json deleted file mode 100644 index c1e244b..0000000 --- a/create-plainweb/package.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "name": "create-plainweb", - "version": "0.0.32", - "description": "Create a new plainweb.dev project", - "files": [ - "dist/" - ], - "bin": { - "create-plainweb": "dist/cli.js" - }, - "typings": "dist/cli.d.ts", - "scripts": { - "build": "tsc --noEmit && tsup", - "dev": "tsx src/cli.ts" - }, - "keywords": [ - "plainweb", - "sqlite", - "typescript", - "htmx", - "express", - "typescript" - ], - "author": "Josef Erben ", - "license": "MIT", - "dependencies": { - "@inquirer/prompts": "^5.0.4", - "@remix-run/web-fetch": "^4.4.2", - "arg": "^5.0.2", - "chalk": "4.1.2", - "execa": "5.1.1", - "fs-extra": "^11.2.0", - "gunzip-maybe": "^1.4.2", - "ora": "^8.0.1", - "proxy-agent": "^6.3.0", - "recursive-readdir": "^2.2.3", - "semver": "^7.6.2", - "sort-package-json": "^1.55.0", - "strip-ansi": "^6.0.1", - "tar-fs": "^3.0.0" - }, - "devDependencies": { - "@types/fs-extra": "^11.0.4", - "@types/gunzip-maybe": "^1.4.2", - "@types/recursive-readdir": "^2.2.4", - "@types/semver": "^7.5.8", - "@types/tar-fs": "^2.0.4", - "tsup": "^8.0.2", - "tsx": "^4.16.2", - "typescript": "^5.3.3" - } -} diff --git a/create-plainweb/src/cli.ts b/create-plainweb/src/cli.ts deleted file mode 100755 index e270565..0000000 --- a/create-plainweb/src/cli.ts +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env node -import process from "node:process"; - -import { createPlainweb } from "./create-plainweb"; - -process.on("SIGINT", () => process.exit(0)); -process.on("SIGTERM", () => process.exit(0)); - -const argv = process.argv.slice(2).filter((arg) => arg !== "--"); - -createPlainweb(argv).then( - () => process.exit(0), - () => process.exit(1), -); diff --git a/create-plainweb/src/copy-template.ts b/create-plainweb/src/copy-template.ts deleted file mode 100644 index bd72dd2..0000000 --- a/create-plainweb/src/copy-template.ts +++ /dev/null @@ -1,574 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -// Adapted from https://github.com/remix-run/remix/blob/main/packages/create-remix/copy-template.ts -// MIT License Copyright (c) Remix Software Inc. 2020-2021 Copyright (c) Shopify Inc. 2022-2024 -import process from "node:process"; -import stream from "node:stream"; -import url from "node:url"; -import { promisify } from "node:util"; -import { type ReadableStream, fetch } from "@remix-run/web-fetch"; -import gunzip from "gunzip-maybe"; -import { ProxyAgent } from "proxy-agent"; -import tar from "tar-fs"; - -import { color, isUrl } from "./utils"; - -const defaultAgent = new ProxyAgent(); -const httpsAgent = new ProxyAgent(); -httpsAgent.protocol = "https:"; -function agent(url: string) { - return new URL(url).protocol === "https:" ? httpsAgent : defaultAgent; -} - -export async function copyTemplate( - template: string, - destPath: string, - options: CopyTemplateOptions, -): Promise<{ localTemplateDirectory: string } | undefined> { - const { log = () => {} } = options; - - /** - * Valid templates are: - * - local file or directory on disk - * - GitHub owner/repo shorthand - * - GitHub owner/repo/directory shorthand - * - full GitHub repo URL - * - any tarball URL - */ - - try { - if (isLocalFilePath(template)) { - log(`Using the template from local file at "${template}"`); - const filepath = template.startsWith("file://") - ? url.fileURLToPath(template) - : template; - const isLocalDir = await copyTemplateFromLocalFilePath( - filepath, - destPath, - ); - return isLocalDir ? { localTemplateDirectory: filepath } : undefined; - } - - if (isGithubRepoShorthand(template)) { - log(`Using the template from the "${template}" repo`); - await copyTemplateFromGithubRepoShorthand(template, destPath, options); - return; - } - - if (isValidGithubRepoUrl(template)) { - log(`Using the template from "${template}"`); - await copyTemplateFromGithubRepoUrl(template, destPath, options); - return; - } - - if (isUrl(template)) { - log(`Using the template from "${template}"`); - await copyTemplateFromGenericUrl(template, destPath, options); - return; - } - - throw new CopyTemplateError( - `"${color.bold(template)}" is an invalid template. Run ${color.bold( - "create-remix --help", - )} to see supported template formats.`, - ); - } catch (error) { - await options.onError(error); - } -} - -interface CopyTemplateOptions { - debug?: boolean; - token?: string; - onError(error: unknown): unknown; - log?(message: string): unknown; -} - -function isLocalFilePath(input: string): boolean { - try { - return ( - input.startsWith("file://") || - fs.existsSync( - path.isAbsolute(input) ? input : path.resolve(process.cwd(), input), - ) - ); - } catch (_) { - return false; - } -} - -async function copyTemplateFromRemoteTarball( - url: string, - destPath: string, - options: CopyTemplateOptions, -) { - return await downloadAndExtractTarball(destPath, url, options); -} - -async function copyTemplateFromGithubRepoShorthand( - repoShorthand: string, - destPath: string, - options: CopyTemplateOptions, -) { - const [owner, name, ...path] = repoShorthand.split("/"); - const filePath = path.length ? path.join("/") : null; - - if (!owner) { - throw new CopyTemplateError( - `The provided GitHub repo shorthand "${color.bold( - repoShorthand, - )}" is invalid.`, - ); - } - if (!name) { - throw new CopyTemplateError( - `The provided GitHub repo shorthand "${color.bold( - repoShorthand, - )}" is invalid.`, - ); - } - await downloadAndExtractRepoTarball( - { owner, name, filePath }, - destPath, - options, - ); -} - -async function copyTemplateFromGithubRepoUrl( - repoUrl: string, - destPath: string, - options: CopyTemplateOptions, -) { - await downloadAndExtractRepoTarball(getRepoInfo(repoUrl), destPath, options); -} - -async function copyTemplateFromGenericUrl( - url: string, - destPath: string, - options: CopyTemplateOptions, -) { - await copyTemplateFromRemoteTarball(url, destPath, options); -} - -async function copyTemplateFromLocalFilePath( - filePath: string, - destPath: string, -): Promise { - if (filePath.endsWith(".tar.gz") || filePath.endsWith(".tgz")) { - await extractLocalTarball(filePath, destPath); - return false; - } - if (fs.statSync(filePath).isDirectory()) { - // If our template is just a directory on disk, return true here, and we'll - // just copy directly from there instead of "extracting" to a temp - // directory first - return true; - } - throw new CopyTemplateError( - "The provided template is not a valid local directory or tarball.", - ); -} - -const pipeline = promisify(stream.pipeline); - -async function extractLocalTarball( - tarballPath: string, - destPath: string, -): Promise { - try { - await pipeline( - fs.createReadStream(tarballPath), - gunzip(), - tar.extract(destPath, { strip: 1 }), - ); - } catch (error: unknown) { - throw new CopyTemplateError( - `There was a problem extracting the file from the provided template. Template filepath: \`${tarballPath}\` Destination directory: \`${destPath}\` ${error}`, - ); - } -} - -interface TarballDownloadOptions { - debug?: boolean; - filePath?: string | null; - token?: string; -} - -async function downloadAndExtractRepoTarball( - repo: RepoInfo, - destPath: string, - options: TarballDownloadOptions, -) { - // If we have a direct file path we will also have the branch. We can skip the - // redirect and get the tarball URL directly. - if (repo.branch && repo.filePath) { - const tarballURL = `https://codeload.github.com/${repo.owner}/${repo.name}/tar.gz/${repo.branch}`; - return await downloadAndExtractTarball(destPath, tarballURL, { - ...options, - filePath: repo.filePath, - }); - } - - // If we don't know the branch, the GitHub API will figure out the default and - // redirect the request to the tarball. - // https://docs.github.com/en/rest/reference/repos#download-a-repository-archive-tar - let url = `https://api.github.com/repos/${repo.owner}/${repo.name}/tarball`; - if (repo.branch) { - url += `/${repo.branch}`; - } - - return await downloadAndExtractTarball(destPath, url, { - ...options, - filePath: repo.filePath ?? null, - }); -} - -interface DownloadAndExtractTarballOptions { - token?: string; - filePath?: string | null; -} - -async function downloadAndExtractTarball( - downloadPath: string, - tarballUrl: string, - { token, filePath }: DownloadAndExtractTarballOptions, -): Promise { - let resourceUrl = tarballUrl; - const headers: { Authorization?: string; Accept?: string } = {}; - const isGithubUrl = new URL(tarballUrl).host.endsWith("github.com"); - if (token && isGithubUrl) { - headers.Authorization = `token ${token}`; - } - if (isGithubReleaseAssetUrl(tarballUrl)) { - // We can download the asset via the GitHub api, but first we need to look - // up the asset id - const info = getGithubReleaseAssetInfo(tarballUrl); - headers.Accept = "application/vnd.github.v3+json"; - - const releaseUrl = - info.tag === "latest" - ? `https://api.github.com/repos/${info.owner}/${info.name}/releases/latest` - : `https://api.github.com/repos/${info.owner}/${info.name}/releases/tags/${info.tag}`; - - const response = await fetch(releaseUrl, { - agent: agent("https://api.github.com"), - headers, - }); - - if (response.status !== 200) { - throw new CopyTemplateError( - `There was a problem fetching the file from GitHub. The request responded with a ${response.status} status. Please try again later.`, - ); - } - - const body = (await response.json()) as { assets: GitHubApiReleaseAsset[] }; - if ( - !body || - typeof body !== "object" || - !body.assets || - !Array.isArray(body.assets) - ) { - throw new CopyTemplateError( - "There was a problem fetching the file from GitHub. No asset was " + - "found at that url. Please try again later.", - ); - } - - const assetId = body.assets.find((asset) => { - // If the release is "latest", the url won't match the download url - return info.tag === "latest" - ? asset?.browser_download_url?.includes(info.asset) - : asset?.browser_download_url === tarballUrl; - })?.id; - if (assetId == null) { - throw new CopyTemplateError( - "There was a problem fetching the file from GitHub. No asset was " + - "found at that url. Please try again later.", - ); - } - resourceUrl = `https://api.github.com/repos/${info.owner}/${info.name}/releases/assets/${assetId}`; - headers.Accept = "application/octet-stream"; - } - const response = await fetch(resourceUrl, { - agent: agent(resourceUrl), - headers, - }); - - if (!response.body || response.status !== 200) { - if (token) { - throw new CopyTemplateError( - `There was a problem fetching the file${ - isGithubUrl ? " from GitHub" : "" - }. The request responded with a ${response.status} status. Perhaps your \`--token\`is expired or invalid.`, - ); - } - throw new CopyTemplateError( - `There was a problem fetching the file${ - isGithubUrl ? " from GitHub" : "" - }. The request ` + - `responded with a ${response.status} status. Please try again later.`, - ); - } - - // file paths returned from GitHub are always unix style - if (filePath) { - filePath = filePath.split(path.sep).join(path.posix.sep); - } - - let filePathHasFiles = false; - - try { - const input = new stream.PassThrough(); - // Start reading stream into passthrough, don't await to avoid buffering - writeReadableStreamToWritable(response.body, input); - await pipeline( - input, - gunzip(), - tar.extract(downloadPath, { - map(header) { - const originalDirName = header.name.split("/")[0]; - header.name = header.name.replace(`${originalDirName}/`, ""); - - if (filePath) { - // Include trailing slash on startsWith when filePath doesn't include - // it so something like `templates/remix` doesn't inadvertently - // include `templates/remix-javascript/*` files - if ( - (filePath.endsWith(path.posix.sep) && - header.name.startsWith(filePath)) || - (!filePath.endsWith(path.posix.sep) && - header.name.startsWith(filePath + path.posix.sep)) - ) { - filePathHasFiles = true; - header.name = header.name.replace(filePath, ""); - } else { - header.name = "__IGNORE__"; - } - } - - return header; - }, - ignore(_filename, header) { - if (!header) { - throw Error("Header is undefined"); - } - return header.name === "__IGNORE__"; - }, - }), - ); - } catch (_) { - throw new CopyTemplateError( - `There was a problem extracting the file from the provided template. Template URL: \`${tarballUrl}\` Destination directory: \`${downloadPath}\``, - ); - } - - if (filePath && !filePathHasFiles) { - throw new CopyTemplateError( - `The path "${filePath}" was not found in this ${ - isGithubUrl ? "GitHub repo." : "tarball." - }`, - ); - } -} - -// Copied from remix-node/stream.ts -async function writeReadableStreamToWritable( - stream: typeof ReadableStream, - writable: stream.Writable, -) { - const reader = stream.getReader(); - const flushable = writable as { flush?: () => unknown }; - - try { - while (true) { - const { done, value } = await reader.read(); - - if (done) { - writable.end(); - break; - } - - writable.write(value); - if (typeof flushable.flush === "function") { - flushable.flush(); - } - } - } catch (error: unknown) { - writable.destroy(error as Error); - throw error; - } -} - -function isValidGithubRepoUrl( - input: string | URL, -): input is URL | GithubUrlString { - if (!isUrl(input)) { - return false; - } - try { - const url = new URL(input); - const pathSegments = url.pathname.slice(1).split("/"); - - return ( - url.protocol === "https:" && - url.hostname === "github.com" && - // The pathname must have at least 2 segments. If it has more than 2, the - // third must be "tree" and it must have at least 4 segments. - // https://github.com/:owner/:repo - // https://github.com/:owner/:repo/tree/:ref - pathSegments.length >= 2 && - (pathSegments.length > 2 - ? pathSegments[2] === "tree" && pathSegments.length >= 4 - : true) - ); - } catch (_) { - return false; - } -} - -function isGithubRepoShorthand(value: string) { - if (isUrl(value)) { - return false; - } - // This supports :owner/:repo and :owner/:repo/nested/path, e.g. - // remix-run/remix - // remix-run/remix/templates/express - // remix-run/examples/socket.io - return /^[\w-]+\/[\w-.]+(\/[\w-.]+)*$/.test(value); -} - -function isGithubReleaseAssetUrl(url: string) { - /** - * Accounts for the following formats: - * https://github.com/owner/repository/releases/download/v0.0.1/stack.tar.gz - * ~or~ - * https://github.com/owner/repository/releases/latest/download/stack.tar.gz - */ - return ( - url.startsWith("https://github.com") && - (url.includes("/releases/download/") || - url.includes("/releases/latest/download/")) - ); -} - -function getGithubReleaseAssetInfo(browserUrl: string): ReleaseAssetInfo { - /** - * https://github.com/owner/repository/releases/download/v0.0.1/stack.tar.gz - * ~or~ - * https://github.com/owner/repository/releases/latest/download/stack.tar.gz - */ - - const url = new URL(browserUrl); - let [, owner, name, , downloadOrLatest, tag, asset] = url.pathname.split("/"); - - if (downloadOrLatest === "latest" && tag === "download") { - // handle the GitHub URL quirk for latest releases - tag = "latest"; - } - - return { - browserUrl, - owner, - name, - asset, - tag, - }; -} - -function getRepoInfo(validatedGithubUrl: string): RepoInfo { - const url = new URL(validatedGithubUrl); - const [, owner, name, tree, branch, ...file] = url.pathname.split("/") as [ - _: string, - Owner: string, - Name: string, - Tree: string | undefined, - Branch: string | undefined, - FileInfo: string | undefined, - ]; - const filePath = file.join("/"); - - if (tree === undefined) { - return { - owner, - name, - branch: null, - filePath: null, - }; - } - - return { - owner, - name, - // If we've validated the GitHub URL and there is a tree, there will also be - // a branch - branch: branch, - filePath: filePath === "" || filePath === "/" ? null : filePath, - }; -} - -export class CopyTemplateError extends Error { - constructor(message: string) { - super(message); - this.name = "CopyTemplateError"; - } -} - -interface RepoInfo { - owner: string; - name: string; - branch?: string | null; - filePath?: string | null; -} - -// https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28#get-a-release-asset -interface GitHubApiReleaseAsset { - url: string; - browser_download_url: string; - id: number; - node_id: string; - name: string; - label: string; - state: "uploaded" | "open"; - content_type: string; - size: number; - download_count: number; - created_at: string; - updated_at: string; - uploader: null | GitHubApiUploader; -} - -interface GitHubApiUploader { - name: string | null; - email: string | null; - login: string; - id: number; - node_id: string; - avatar_url: string; - gravatar_id: string | null; - url: string; - html_url: string; - followers_url: string; - following_url: string; - gists_url: string; - starred_url: string; - subscriptions_url: string; - organizations_url: string; - repos_url: string; - events_url: string; - received_events_url: string; - type: string; - site_admin: boolean; - starred_at: string; -} - -interface ReleaseAssetInfo { - browserUrl: string; - owner: string; - name: string; - asset: string; - tag: string; -} - -type GithubUrlString = - | `https://github.com/${string}/${string}` - | `https://www.github.com/${string}/${string}`; diff --git a/create-plainweb/src/create-plainweb.ts b/create-plainweb/src/create-plainweb.ts deleted file mode 100644 index c218394..0000000 --- a/create-plainweb/src/create-plainweb.ts +++ /dev/null @@ -1,687 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import process from "node:process"; -import arg from "arg"; -import execa from "execa"; -import fse from "fs-extra"; -import semver from "semver"; -import sortPackageJSON from "sort-package-json"; -import stripAnsi from "strip-ansi"; - -import { confirm, input } from "@inquirer/prompts"; -import { CopyTemplateError, copyTemplate } from "./copy-template"; -import { renderLoadingIndicator } from "./loading-indicator"; -import { - IGNORED_TEMPLATE_DIRECTORIES, - color, - debug, - ensureDirectory, - error, - getDirectoryFilesRecursive, - info, - isInteractive, - isValidJsonObject, - log, - sleep, - strip, - stripDirectoryFromPath, - toValidProjectName, -} from "./utils"; - -const thisPlainwebVersion = "latest"; - -async function getContext(argv: string[]): Promise { - const flags = arg( - { - "--debug": Boolean, - "--plainweb-version": String, - "-v": "--plainweb-version", - "--template": String, - "--token": String, - "--yes": Boolean, - "-y": "--yes", - "--install": Boolean, - "--no-install": Boolean, - "--package-manager": String, - "--show-install-output": Boolean, - "--init-script": Boolean, - "--no-init-script": Boolean, - "--git-init": Boolean, - "--no-git-init": Boolean, - "--help": Boolean, - "-h": "--help", - "--version": Boolean, - "--V": "--version", - "--no-color": Boolean, - "--no-motion": Boolean, - "--overwrite": Boolean, - }, - { argv, permissive: true }, - ); - - let { - "--debug": debug = false, - "--help": help = false, - "--plainweb-version": selectedPlainwebVersion, - "--install": install, - "--no-install": noInstall, - "--package-manager": pkgManager, - "--show-install-output": showInstallOutput = false, - "--git-init": git, - "--no-git-init": noGit, - "--yes": yes, - "--version": versionRequested, - "--overwrite": overwrite, - } = flags; - - const cwd = flags._[0]; - const interactive = isInteractive(); - const projectName = cwd; - - if (!interactive) { - yes = true; - } - - if (selectedPlainwebVersion) { - if (semver.valid(selectedPlainwebVersion)) { - // do nothing, we're good - } else if (semver.coerce(selectedPlainwebVersion)) { - selectedPlainwebVersion = semver.coerce(selectedPlainwebVersion)?.version; - } else { - log( - `\n${color.warning( - `${selectedPlainwebVersion} is an invalid version specifier. Using Plainweb v${thisPlainwebVersion}.`, - )}`, - ); - selectedPlainwebVersion = undefined; - } - } - - if (debug) console.log("Selected plainweb version", selectedPlainwebVersion); - if (debug) console.log("This plainweb version", thisPlainwebVersion); - - const context: Context = { - tempDir: path.join( - await fs.promises.realpath(os.tmpdir()), - `create-plainweb--${Math.random().toString(36).substr(2, 8)}`, - ), - cwd, - overwrite, - interactive, - debug, - git: git ?? (noGit ? false : yes), - help, - install: install ?? (noInstall ? false : yes), - showInstallOutput, - pkgManager: validatePackageManager( - pkgManager ?? - // npm, pnpm, Yarn, and Bun set the user agent environment variable that can be used - // to determine which package manager ran the command. - (process.env.npm_config_user_agent ?? "npm").split("/")[0], - ), - projectName, - plainwebVersion: selectedPlainwebVersion || thisPlainwebVersion, - template: "joseferben/plainweb/template", - versionRequested, - }; - - return context; -} - -async function introStep(ctx: Context) { - log( - `\n${color.bgWhite(` ${color.black("plainweb")} `)} ${color.green( - color.bold(`v${ctx.plainwebVersion}`), - )} ${color.bold("🪨 Let's build a plainweb app...")}`, - ); - - if (!ctx.interactive) { - log(""); - info("Shell is not interactive.", [ - "Using default options. This is equivalent to running with the ", - color.reset("--yes"), - " flag.", - ]); - } -} - -async function projectNameStep(ctx: Context) { - // valid cwd is required if shell isn't interactive - if (!ctx.interactive && !ctx.cwd) { - error("Oh no!", "No project directory provided"); - throw new Error("No project directory provided"); - } - - if (ctx.cwd) { - await sleep(100); - info("Directory:", [ - "Using ", - color.reset(ctx.cwd), - " as project directory", - ]); - } - - if (!ctx.cwd) { - const name = await input({ - message: "Where should we create your new project?", - default: "./my-plainweb-project", - }); - ctx.cwd = name; - ctx.projectName = toValidProjectName(name); - return; - } - - let name = ctx.cwd; - if (name === "." || name === "./") { - const parts = process.cwd().split(path.sep); - name = parts[parts.length - 1]; - } else if (name.startsWith("./") || name.startsWith("../")) { - const parts = name.split("/"); - name = parts[parts.length - 1]; - } - ctx.projectName = toValidProjectName(name); -} - -async function copyTemplateToTempDirStep(ctx: Context) { - const template = ctx.template; - - await loadingIndicator({ - start: "Template copying...", - end: "Template copied", - while: async () => { - await ensureDirectory(ctx.tempDir); - if (ctx.debug) { - debug(`Extracting to: ${ctx.tempDir}`); - } - - const result = await copyTemplate(template, ctx.tempDir, { - debug: ctx.debug, - async onError(err) { - error( - "Oh no!", - err instanceof CopyTemplateError - ? err.message - : "Something went wrong. Run `create-plainweb --debug` to see more info.\n\n" + - "Open an issue to report the problem at " + - "https://github.com/joseferben/plainweb/issues/new", - ); - throw err; - }, - async log(message) { - if (ctx.debug) { - debug(message); - await sleep(500); - } - }, - }); - - if (result?.localTemplateDirectory) { - ctx.tempDir = path.resolve(result.localTemplateDirectory); - } - }, - ctx, - }); -} - -async function copyTempDirToAppDirStep(ctx: Context) { - await ensureDirectory(ctx.cwd); - - const files1 = await getDirectoryFilesRecursive(ctx.tempDir); - const files2 = await getDirectoryFilesRecursive(ctx.cwd); - const collisions = files1 - .filter((f) => files2.includes(f)) - .sort((a, b) => a.localeCompare(b)); - - if (collisions.length > 0) { - const getFileList = (prefix: string) => { - const moreFiles = collisions.length - 5; - const lines = ["", ...collisions.slice(0, 5)]; - if (moreFiles > 0) { - lines.push(`and ${moreFiles} more...`); - } - return lines.join(`\n${prefix}`); - }; - - if (ctx.overwrite) { - info( - "Overwrite:", - `overwriting files due to \`--overwrite\`:${getFileList(" ")}`, - ); - } else if (!ctx.interactive) { - error( - "Oh no!", - `Destination directory contains files that would be overwritten - and no \`--overwrite\` flag was included in a non-interactive - environment. The following files would be overwritten: - ${getFileList(" ")}`, - ); - throw new Error( - "File collisions detected in a non-interactive environment", - ); - } else { - if (ctx.debug) { - debug(`Colliding files:${getFileList(" ")}`); - } - - const overwrite = await confirm({ - message: `Your project directory contains files that will be overwritten by - this template (you can force with \`--overwrite\`) - Files that would be overwritten: - ${getFileList(" ")} - Do you wish to continue?`, - default: false, - }); - if (!overwrite) { - throw new Error("Exiting to avoid overwriting files"); - } - } - } - - await fse.copy(ctx.tempDir, ctx.cwd, { - filter(src, dest) { - // We never copy .git/ or node_modules/ directories since it's highly - // unlikely we want them copied - and because templates are primarily - // being pulled from git tarballs which won't have .git/ and shouldn't - // have node_modules/ - const file = stripDirectoryFromPath(ctx.tempDir, src); - const isIgnored = IGNORED_TEMPLATE_DIRECTORIES.includes(file); - if (isIgnored) { - if (ctx.debug) { - debug(`Skipping copy of ${file} directory from template`); - } - return false; - } - return true; - }, - }); - - await updatePackageJSON(ctx); -} - -async function installDependenciesQuestionStep(ctx: Context) { - if (ctx.install === undefined) { - const deps = await confirm({ - message: `Install dependencies with ${ctx.pkgManager}?`, - default: true, - }); - ctx.install = deps; - } -} - -async function installDependenciesStep(ctx: Context) { - const { install, pkgManager, showInstallOutput, cwd } = ctx; - - if (!install) { - await sleep(100); - info("Skipping install step.", [ - "Remember to install dependencies after setup with ", - color.reset(`${pkgManager} install`), - ".", - ]); - return; - } - - function runInstall() { - return installDependencies({ - cwd, - pkgManager, - showInstallOutput, - }); - } - - if (showInstallOutput) { - log(""); - info("Install", `Dependencies installing with ${pkgManager}...`); - log(""); - await runInstall(); - log(""); - return; - } - - log(""); - await loadingIndicator({ - start: `Dependencies installing with ${pkgManager}...`, - end: "Dependencies installed", - while: runInstall, - ctx, - }); -} - -async function gitInitQuestionStep(ctx: Context) { - if (fs.existsSync(path.join(ctx.cwd, ".git"))) { - info("Nice!", "Git has already been initialized"); - return; - } - - let git = ctx.git; - if (ctx.git === undefined) { - git = await confirm({ - message: "Initialize a new git repository?", - default: true, - }); - } - - ctx.git = git ?? false; -} - -async function gitInitStep(ctx: Context) { - if (!ctx.git) { - return; - } - - if (fs.existsSync(path.join(ctx.cwd, ".git"))) { - log(""); - info("Nice!", "Git has already been initialized"); - return; - } - - log(""); - await loadingIndicator({ - start: "Git initializing...", - end: "Git initialized", - while: async () => { - const options = { cwd: ctx.cwd, stdio: "ignore" } as const; - const commitMsg = "Initial commit from create-plainweb"; - try { - await execa("git", ["init"], options); - await execa("git", ["add", "."], options); - await execa("git", ["commit", "-m", commitMsg], options); - } catch (err) { - error("Oh no!", "Failed to initialize git."); - throw err; - } - }, - ctx, - }); -} - -async function createEnvStep(ctx: Context) { - const envPath = path.join(ctx.cwd, ".env"); - if (fs.existsSync(envPath)) { - log(""); - info("Nice!", "An .env file already exists"); - return; - } - - log(""); - await loadingIndicator({ - start: "Creating .env file...", - end: ".env file created", - while: async () => { - await fs.promises.writeFile( - envPath, - "NODE_ENV=development\nDB_URL=db.sqlite3\nPORT=3000", - "utf-8", - ); - }, - ctx, - }); -} - -async function createEnvTestStep(ctx: Context) { - const envPath = path.join(ctx.cwd, ".env.test"); - if (fs.existsSync(envPath)) { - log(""); - info("Nice!", "An .env.test file already exists"); - return; - } - - log(""); - await loadingIndicator({ - start: "Creating .env.test file...", - end: ".env.test file created", - while: async () => { - await fs.promises.writeFile( - envPath, - "NODE_ENV=test\nDB_URL=:memory:", - "utf-8", - ); - }, - ctx, - }); -} - -async function doneStep(ctx: Context) { - const projectDir = path.relative(process.cwd(), ctx.cwd); - - const max = process.stdout.columns; - const prefix = max < 80 ? " " : " ".repeat(9); - await sleep(200); - - log(`\n ${color.bgWhite(color.black(" done "))} That's it!`); - await sleep(100); - if (projectDir !== "") { - const enter = [ - `\n${prefix}Enter your project directory using`, - color.cyan(`cd .${path.sep}${projectDir}`), - ]; - const len = enter[0].length + stripAnsi(enter[1]).length; - log(enter.join(len > max ? `\n${prefix}` : " ")); - } - log( - `${prefix}Check out ${color.bold( - "README.md", - )} for development and deploy instructions.`, - ); -} - -type PackageManager = "npm" | "yarn" | "pnpm" | "bun"; - -const packageManagerExecScript: Record = { - npm: "npx", - yarn: "yarn", - pnpm: "pnpm exec", - bun: "bunx", -}; - -function validatePackageManager(pkgManager: string): PackageManager { - // biome-ignore lint/suspicious/noPrototypeBuiltins: - return packageManagerExecScript.hasOwnProperty(pkgManager) - ? (pkgManager as PackageManager) - : "npm"; -} - -async function installDependencies({ - pkgManager, - cwd, - showInstallOutput, -}: { - pkgManager: PackageManager; - cwd: string; - showInstallOutput: boolean; -}) { - try { - await execa(pkgManager, ["install"], { - cwd, - stdio: showInstallOutput ? "inherit" : "ignore", - }); - } catch (err) { - error("Oh no!", "Failed to install dependencies."); - throw err; - } -} - -async function updatePackageJSON(ctx: Context) { - const packageJSONPath = path.join(ctx.cwd, "package.json"); - if (!fs.existsSync(packageJSONPath)) { - const relativePath = path.relative(process.cwd(), ctx.cwd); - error( - "Oh no!", - `The provided template must be a Plainweb project with a \`package.json\` - file, but that file does not exist in ${color.bold(relativePath)}.`, - ); - throw new Error(`package.json does not exist in ${ctx.cwd}`); - } - - const contents = await fs.promises.readFile(packageJSONPath, "utf-8"); - let packageJSON: unknown; - try { - packageJSON = JSON.parse(contents); - if (!isValidJsonObject(packageJSON)) { - throw Error(); - } - } catch (err) { - error( - "Oh no!", - "The provided template must be a Plainweb project with a `package.json` " + - "file, but that file is invalid.", - ); - throw err; - } - - for (const pkgKey of ["dependencies", "devDependencies"] as const) { - const dependencies = packageJSON[pkgKey]; - if (!dependencies) continue; - - if (!isValidJsonObject(dependencies)) { - error( - "Oh no!", - `The provided template must be a Plainweb project with a \`package.json\` - file, but its ${pkgKey} value is invalid.`, - ); - throw new Error(`package.json ${pkgKey} are invalid`); - } - - for (const dependency in dependencies) { - if (dependency === "plainweb") { - dependencies[dependency] = semver.prerelease(ctx.plainwebVersion) - ? // Templates created from prereleases should pin to a specific version - ctx.plainwebVersion - : ctx.plainwebVersion === "latest" - ? ctx.plainwebVersion - : `^${ctx.plainwebVersion}`; - } - } - } - - packageJSON.name = ctx.projectName; - - fs.promises.writeFile( - packageJSONPath, - JSON.stringify(sortPackageJSON(packageJSON), null, 2), - "utf-8", - ); -} - -async function loadingIndicator(args: { - start: string; - end: string; - while: (...args: unknown[]) => Promise; - ctx: Context; -}) { - const { ctx, ...rest } = args; - await renderLoadingIndicator({ - ...rest, - }); -} - -function title(text: string) { - return `${align(color.bgWhite(` ${color.black(text)} `), "end", 7)} `; -} - -function printHelp(ctx: Context) { - const output = ` -${title("create-plainweb")} - -${color.heading("Usage")}: - -${color.dim("$")} ${color.greenBright("create-plainweb")} ${color.arg("")} ${color.arg("<...options>")} - -${color.heading("Values")}: - -${color.arg("projectDir")} ${color.dim("The Plainweb project directory")} - -${color.heading("Options")}: - -${color.arg("--help, -h")} ${color.dim("Print this help message and exit")} -${color.arg("--version, -V")} ${color.dim("Print the CLI version and exit")} -${color.arg("--no-color")} ${color.dim("Disable ANSI colors in console output")} - -${color.arg("--template ")} ${color.dim("The project template to use")} -${color.arg("--[no-]install")} ${color.dim("Whether or not to install dependencies after creation")} -${color.arg("--package-manager")} ${color.dim("The package manager to use")} -${color.arg("--show-install-output")} ${color.dim("Whether to show the output of the install process")} -${color.arg("--[no-]git-init")} ${color.dim("Whether or not to initialize a Git repository")} -${color.arg("--yes, -y")} ${color.dim("Skip all option prompts and run setup")} -${color.arg("--plainweb-version, -v")} ${color.dim("The version of Plainweb to use")} - -${color.heading("Creating a new project")}: - -Plainweb projects are created from the template. -`; - - log(output); -} - -function align(text: string, dir: "start" | "end" | "center", len: number) { - const pad = Math.max(len - strip(text).length, 0); - switch (dir) { - case "start": - return text + " ".repeat(pad); - case "end": - return " ".repeat(pad) + text; - case "center": - return ( - " ".repeat(Math.floor(pad / 2)) + text + " ".repeat(Math.floor(pad / 2)) - ); - default: - return text; - } -} - -export async function createPlainweb(argv: string[]) { - const ctx = await getContext(argv); - if (ctx.help) { - printHelp(ctx); - return; - } - if (ctx.versionRequested) { - log(thisPlainwebVersion); - return; - } - - const steps = [ - introStep, - projectNameStep, - copyTemplateToTempDirStep, - copyTempDirToAppDirStep, - gitInitQuestionStep, - installDependenciesQuestionStep, - installDependenciesStep, - gitInitStep, - createEnvStep, - createEnvTestStep, - doneStep, - ]; - - try { - for (const step of steps) { - await step(ctx); - } - } catch (err) { - if (ctx.debug) { - console.error(err); - } - throw err; - } -} - -interface Context { - tempDir: string; - cwd: string; - interactive: boolean; - debug: boolean; - git?: boolean; - help: boolean; - install?: boolean; - showInstallOutput: boolean; - pkgManager: PackageManager; - projectName?: string; - plainwebVersion: string; - stdin?: typeof process.stdin; - stdout?: typeof process.stdout; - template: "joseferben/plainweb/template"; - versionRequested?: boolean; - overwrite?: boolean; -} diff --git a/create-plainweb/src/loading-indicator.ts b/create-plainweb/src/loading-indicator.ts deleted file mode 100644 index fd8599b..0000000 --- a/create-plainweb/src/loading-indicator.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Adapted from https://github.com/withastro/cli-kit -// MIT License Copyright (c) 2022 Nate Moore -// Adapted from https://github.com/remix-run/remix/blob/main/packages/create-remix/copy-template.ts -// MIT License Copyright (c) Remix Software Inc. 2020-2021 Copyright (c) Shopify Inc. 2022-2024 -import process from "node:process"; -import ora from "ora"; - -import { color, sleep } from "./utils"; - -export async function renderLoadingIndicator({ - start, - end, - while: update = () => sleep(100), - stdin = process.stdin, - stdout = process.stdout, -}: { - start: string; - end: string; - while: (...args: unknown[]) => Promise; - noMotion?: boolean; - stdin?: NodeJS.ReadStream & { fd: 0 }; - stdout?: NodeJS.WriteStream & { fd: 1 }; -}) { - const act = update(); - const tooSlow = Object.create(null); - const result = await Promise.race([sleep(500).then(() => tooSlow), act]); - if (result === tooSlow) { - const spinner = ora({ - text: start, - spinner: "dots", - stream: stdout, - }).start(); - await act; - spinner.stop(); - } - stdout.write(`${" ".repeat(5)} ${color.green("✔")} ${color.green(end)}\n`); -} diff --git a/create-plainweb/src/utils.ts b/create-plainweb/src/utils.ts deleted file mode 100644 index 7d37b17..0000000 --- a/create-plainweb/src/utils.ts +++ /dev/null @@ -1,229 +0,0 @@ -import fs from "node:fs"; -// Adapted from https://github.com/remix-run/remix/blob/main/packages/create-remix/copy-template.ts -// MIT License Copyright (c) Remix Software Inc. 2020-2021 Copyright (c) Shopify Inc. 2022-2024 -import path from "node:path"; -import process from "node:process"; -import chalk, { type Chalk } from "chalk"; -import recursiveReaddir from "recursive-readdir"; - -// https://no-color.org/ -const SUPPORTS_COLOR = chalk.supportsColor && !process.env.NO_COLOR; - -export const color = { - supportsColor: SUPPORTS_COLOR, - heading: safeColor(chalk.bold), - arg: safeColor(chalk.yellowBright), - error: safeColor(chalk.red), - warning: safeColor(chalk.yellow), - hint: safeColor(chalk.blue), - bold: safeColor(chalk.bold), - black: safeColor(chalk.black), - white: safeColor(chalk.white), - blue: safeColor(chalk.blue), - cyan: safeColor(chalk.cyan), - red: safeColor(chalk.red), - yellow: safeColor(chalk.yellow), - green: safeColor(chalk.green), - blackBright: safeColor(chalk.blackBright), - whiteBright: safeColor(chalk.whiteBright), - blueBright: safeColor(chalk.blueBright), - cyanBright: safeColor(chalk.cyanBright), - redBright: safeColor(chalk.redBright), - yellowBright: safeColor(chalk.yellowBright), - greenBright: safeColor(chalk.greenBright), - bgBlack: safeColor(chalk.bgBlack), - bgWhite: safeColor(chalk.bgWhite), - bgBlue: safeColor(chalk.bgBlue), - bgCyan: safeColor(chalk.bgCyan), - bgRed: safeColor(chalk.bgRed), - bgYellow: safeColor(chalk.bgYellow), - bgGreen: safeColor(chalk.bgGreen), - bgBlackBright: safeColor(chalk.bgBlackBright), - bgWhiteBright: safeColor(chalk.bgWhiteBright), - bgBlueBright: safeColor(chalk.bgBlueBright), - bgCyanBright: safeColor(chalk.bgCyanBright), - bgRedBright: safeColor(chalk.bgRedBright), - bgYellowBright: safeColor(chalk.bgYellowBright), - bgGreenBright: safeColor(chalk.bgGreenBright), - gray: safeColor(chalk.gray), - dim: safeColor(chalk.dim), - reset: safeColor(chalk.reset), - inverse: safeColor(chalk.inverse), - hex: (color: string) => safeColor(chalk.hex(color)), - underline: chalk.underline, -}; - -function safeColor(style: Chalk) { - return SUPPORTS_COLOR ? style : (...text: unknown[]) => text.join(""); -} - -export function isInteractive() { - // Support explicit override for testing purposes - if ("CREATE_REMIX_FORCE_INTERACTIVE" in process.env) { - return true; - } - - // Adapted from https://github.com/sindresorhus/is-interactive - return Boolean( - process.stdout.isTTY && - process.env.TERM !== "dumb" && - !("CI" in process.env), - ); -} - -export function log(message: string) { - return process.stdout.write(`${message}\n`); -} - -const stderr = process.stderr; - -function logError(message: string) { - return stderr.write(`${message}\n`); -} - -function logBullet( - logger: typeof log | typeof logError, - colorizePrefix: (...text: unknown[]) => string, - colorizeText: (...text: unknown[]) => string, - symbol: string, - prefix: string, - text?: string | string[], -) { - const textParts = Array.isArray(text) ? text : [text || ""].filter(Boolean); - const formattedText = textParts - .map((textPart) => colorizeText(textPart)) - .join(""); - - if (process.stdout.columns < 80) { - logger( - `${" ".repeat(5)} ${colorizePrefix(symbol)} ${colorizePrefix(prefix)}`, - ); - logger(`${" ".repeat(9)}${formattedText}`); - } else { - logger( - `${" ".repeat(5)} ${colorizePrefix(symbol)} ${colorizePrefix( - prefix, - )} ${formattedText}`, - ); - } -} - -export function debug(prefix: string, text?: string | string[]) { - logBullet(log, color.yellow, color.dim, "●", prefix, text); -} - -export function info(prefix: string, text?: string | string[]) { - logBullet(log, color.cyan, color.dim, "◼", prefix, text); -} - -export function error(prefix: string, text?: string | string[]) { - log(""); - logBullet(logError, color.red, color.error, "▲", prefix, text); -} - -export function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export function toValidProjectName(projectName: string) { - if (isValidProjectName(projectName)) { - return projectName; - } - return projectName - .trim() - .toLowerCase() - .replace(/\s+/g, "-") - .replace(/^[._]/, "") - .replace(/[^a-z\d\-~]+/g, "-") - .replace(/^-+/, "") - .replace(/-+$/, ""); -} - -function isValidProjectName(projectName: string) { - return /^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$/.test( - projectName, - ); -} - -function identity(v: V) { - return v; -} - -export function strip(str: string) { - const pattern = [ - "[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)", - "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))", - ].join("|"); - const RGX = new RegExp(pattern, "g"); - return typeof str === "string" ? str.replace(RGX, "") : str; -} - -export function isValidJsonObject( - obj: unknown, -): obj is Record { - return !!(obj && typeof obj === "object" && !Array.isArray(obj)); -} - -async function directoryExists(p: string) { - try { - const stat = await fs.promises.stat(p); - return stat.isDirectory(); - } catch { - return false; - } -} - -async function fileExists(p: string) { - try { - const stat = await fs.promises.stat(p); - return stat.isFile(); - } catch { - return false; - } -} - -export async function ensureDirectory(dir: string) { - if (!(await directoryExists(dir))) { - await fs.promises.mkdir(dir, { recursive: true }); - } -} - -export function isUrl(value: string | URL) { - try { - new URL(value); - return true; - } catch (_) { - return false; - } -} - -export function stripDirectoryFromPath(dir: string, filePath: string) { - // Can't just do a regexp replace here since the windows paths mess it up :/ - let stripped = filePath; - if ( - (dir.endsWith(path.sep) && filePath.startsWith(dir)) || - (!dir.endsWith(path.sep) && filePath.startsWith(dir + path.sep)) - ) { - stripped = filePath.slice(dir.length); - if (stripped.startsWith(path.sep)) { - stripped = stripped.slice(1); - } - } - return stripped; -} - -// We do not copy these folders from templates so we can ignore them for comparisons -export const IGNORED_TEMPLATE_DIRECTORIES = [".git", "node_modules"]; - -export async function getDirectoryFilesRecursive(dir: string) { - const files = await recursiveReaddir(dir, [ - (file) => { - const strippedFile = stripDirectoryFromPath(dir, file); - const parts = strippedFile.split(path.sep); - return ( - parts.length > 1 && IGNORED_TEMPLATE_DIRECTORIES.includes(parts[0]) - ); - }, - ]); - return files.map((f) => stripDirectoryFromPath(dir, f)); -} diff --git a/create-plainweb/tsconfig.json b/create-plainweb/tsconfig.json deleted file mode 100644 index 446cec1..0000000 --- a/create-plainweb/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["node_modules", "dist"], - "compilerOptions": { - "declaration": true, - "declarationMap": true, - "esModuleInterop": true, - "incremental": false, - "isolatedModules": true, - "lib": ["ES2022"], - "module": "ES2022", - "moduleResolution": "Bundler", - "noUncheckedIndexedAccess": false, - "noEmit": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "strict": true, - "target": "ES2022" - } -} diff --git a/create-plainweb/tsup.config.ts b/create-plainweb/tsup.config.ts deleted file mode 100644 index 53e2c46..0000000 --- a/create-plainweb/tsup.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - target: "node18", - noExternal: ["ora"], - format: ["cjs"], - entry: ["src/cli.ts"], - outDir: "dist", - dts: true, - splitting: false, - sourcemap: true, - clean: true, -}); diff --git a/example/0-todo.tsx b/example/0-todo.tsx new file mode 100644 index 0000000..7082b13 --- /dev/null +++ b/example/0-todo.tsx @@ -0,0 +1,106 @@ +import { Hono } from "hono"; +import { jsxRenderer } from "hono/jsx-renderer"; +import { logger } from "hono/logger"; +import type { DB } from "kysely-codegen"; +import { form, store } from "plainstack"; +import { bunSqlite, secret } from "plainstack/bun"; +import { session } from "plainstack/session"; + +const { database, migrate } = bunSqlite(); + +await migrate(({ schema }) => { + return schema + .createTable("items") + .addColumn("id", "text", (col) => col.primaryKey().notNull()) + .addColumn("content", "text", (col) => col.notNull()) + .addColumn("created_at", "integer", (col) => col.notNull()) + .execute(); +}); + +const entities = await store(database); + +const app = new Hono(); + +app.use(logger()); +app.use(session({ encryptionKey: await secret() })); + +app.get( + "*", + jsxRenderer(({ children }) => { + return ( + + + + + + + + So many todos + + +
+

{children}

+
+ + + ); + }), +); + +app.get("/", async (c) => { + const info = c.var.session.get("info"); + const items = await entities("items").all(); + return c.render( +
+

Todo App

+ {info &&
{info}
} +
    + {items.map((item) => ( +
  • +
    + {item.content}{" "} +
    + +
    +
    +
  • + ))} +
+
+ + +
+
, + ); +}); + +app.post("/add", form(entities("items").zod), async (c) => { + const submission = c.req.valid("form"); + const data = submission.value; + await entities("items").create(data); + c.var.session.flash("info", "Item added"); + return c.redirect("/"); +}); + +app.post("/delete/:id", async (c) => { + await entities("items").delete(c.req.param("id")); + c.var.session.flash("info", "Item deleted"); + return c.redirect("/"); +}); + +export default app; diff --git a/example/1-background-job.tsx b/example/1-background-job.tsx new file mode 100644 index 0000000..e9cb3c4 --- /dev/null +++ b/example/1-background-job.tsx @@ -0,0 +1,54 @@ +import { Hono } from "hono"; +import { jsxRenderer } from "hono/jsx-renderer"; +import { logger } from "hono/logger"; +import { JobStatus } from "plainjob"; +import { job, perform, work } from "plainstack"; +import { bunSqlite } from "plainstack/bun"; + +const { queue } = bunSqlite(); + +const randomJob = job({ + name: "random", + run: async ({ data }) => { + if (Math.random() > 0.5) throw new Error("Random error"); + console.log("Processing job", data); + }, +}); + +void work(queue, { job: randomJob }, {}); + +const app = new Hono(); + +app.use(logger()); + +app.get( + "*", + jsxRenderer(({ children }) => { + return ( + + {children} + + ); + }), +); + +app.get("/", async (c) => { + return c.render( +
+

Job Queue

+
+ +
+
Pending: {queue.countJobs({ status: JobStatus.Pending })}
+
Done: {queue.countJobs({ status: JobStatus.Done })}
+
Failed: {queue.countJobs({ status: JobStatus.Failed })}
+
, + ); +}); + +app.post("/queue", async (c) => { + await perform(queue, randomJob, Math.random().toString()); + return c.redirect("/"); +}); + +export default app; diff --git a/knip.ts b/knip.ts deleted file mode 100644 index 891aa00..0000000 --- a/knip.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { KnipConfig } from "knip"; - -const config: KnipConfig = { - ignoreBinaries: ["routes", "serve"], - workspaces: { - web: { - entry: ["app/cli/**/*.ts", "app/routes/**/*.tsx", "public/**/*.js"], - ignoreBinaries: ["fly"], - }, - template: { - entry: ["app/cli/**/*.ts", "app/routes/**/*.tsx", "public/**/*.js"], - }, - plainweb: { - entry: ["src/index.ts", "bin/**/*.ts"], - ignoreDependencies: ["@vitest/coverage-v8", "dotenv", "zod"], - }, - "create-plainweb": { - entry: ["src/cli.ts"], - }, - }, -}; - -export default config; diff --git a/package.json b/package.json index 46ea1bf..eb07372 100644 --- a/package.json +++ b/package.json @@ -1,30 +1,48 @@ { - "name": "plainweb-project", - "private": true, - "scripts": { - "build": "turbo build", - "dev": "turbo dev", - "test": "NODE_ENV=test turbo test", - "cs": "changeset", - "pub": "pnpm check && changeset version && changeset publish", - "knip": "knip", - "lint": "biome check .", - "manypkg": "manypkg check", - "manypkg:fix": "manypkg fix", - "check": "turbo build test && biome check --write . && pnpm knip && manypkg check" + "name": "plainstack", + "version": "0.0.36", + "description": "The single-file TypeScript web framework", + "type": "module", + "main": "dist/plainstack.js", + "exports": { + ".": "./dist/plainstack.js", + "./bun": "./dist/bun.js", + "./session": "./dist/middleware/session.js" }, - "packageManager": "pnpm@9.5.0", - "engineStrict": true, - "engines": { - "pnpm": ">=9.5.0", - "node": ">=20.10.0" + "types": "dist/plainstack.d.ts", + "scripts": { + "build": "tsc --noEmit && tsup", + "publish": "changelogen --release" }, + "keywords": [ + "plainstack", + "sqlite", + "bun", + "hono", + "htmx", + "typescript", + "kysely" + ], + "author": "Josef Erben ", + "license": "MIT", "dependencies": { - "@biomejs/biome": "^1.8.3", - "@changesets/cli": "^2.27.3", - "@manypkg/cli": "^0.21.4", - "knip": "^5.25.1", - "turbo": "2.1.0", + "@conform-to/zod": "^1.2.2", + "@hono/conform-validator": "^1.0.0", + "@paralleldrive/cuid2": "^2.2.2", + "change-case": "^5.4.4", + "consola": "^3.2.3", + "hono": "^4.6.3", + "hono-sessions": "^0.5.8", + "kysely": "^0.27.4", + "kysely-bun-sqlite": "^0.3.2", + "kysely-codegen": "^0.16.5", + "plainjob": "^0.0.11", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/bun": "latest", + "changelogen": "^0.5.7", + "tsup": "^8.2.4", "typescript": "^5.3.3" } } diff --git a/plainweb/.gitignore b/plainweb/.gitignore deleted file mode 100644 index a005f7a..0000000 --- a/plainweb/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -output.css -tsup.config.bundled* \ No newline at end of file diff --git a/plainweb/CHANGELOG.md b/plainweb/CHANGELOG.md deleted file mode 100644 index d7a3aa1..0000000 --- a/plainweb/CHANGELOG.md +++ /dev/null @@ -1,107 +0,0 @@ -# plainweb - -## 0.0.16 - -### Patch Changes - -- fix: remove security middleware - -## 0.0.15 - -### Patch Changes - -- fix - -## 0.0.14 - -### Patch Changes - -- fix test db urk - -## 0.0.13 - -### Patch Changes - -- smaller fixes regarding deployment - -## 0.0.12 - -### Patch Changes - -- centralized config architecture - -## 0.0.11 - -### Patch Changes - -- - feat: admin dashboard preview - - feat: `pnpm routes` prints all http routes - - feat: prettier/eslint -> biome - - feat: plainweb.dev dark mode - - chore: updating several dependencies - -## 0.0.10 - -### Patch Changes - -- b408ede: fix: default formatting of package.json - -## 0.0.9 - -### Patch Changes - -- fix: issue where drizzle.config.ts would fail to load - -## 0.0.8 - -### Patch Changes - -- - docs: add recipes for sitemap.xml and robots.txt - - docs: add icons - - docs: improve motivation bit - - chore: switch to pnpm - - feat: switch from prettier and eslint to biome - - chore: add knip - - feat: swtich from node test runner to vitest - -## 0.0.7 - -### Patch Changes - -- write env file - -## 0.0.6 - -### Patch Changes - -- smaller fixes - -## 0.0.5 - -### Patch Changes - -- fix tests - -## 0.0.4 - -### Patch Changes - -- tasks - -## 0.0.3 - -### Patch Changes - -- add utils for testing - -## 0.0.2 - -### Patch Changes - -- fixing routing issue - -## 0.0.1 - -### Patch Changes - -- initial release diff --git a/plainweb/bin/admin.ts b/plainweb/bin/admin.ts deleted file mode 100755 index 5a220d4..0000000 --- a/plainweb/bin/admin.ts +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env -S pnpm dlx tsx - -import BetterSqlite3Database, { type Database } from "better-sqlite3"; -import { drizzle } from "drizzle-orm/better-sqlite3"; -import express from "express"; -import { log, printRoutes, unstable_admin } from "../src"; - -function migrate(connection: Database) { - const run = ` -DROP TABLE IF EXISTS users; -DROP TABLE IF EXISTS orders; -DROP TABLE IF EXISTS products; -DROP TABLE IF EXISTS order_items; - -CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT UNIQUE NOT NULL, - name TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE orders ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - status TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE products ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - price DECIMAL(10, 2) NOT NULL, - description TEXT -); - -CREATE TABLE order_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - order_id INTEGER NOT NULL, - product_id INTEGER NOT NULL, - quantity INTEGER NOT NULL, - unit_price DECIMAL(10, 2) NOT NULL, - subtotal DECIMAL(10, 2) NOT NULL, - discount DECIMAL(10, 2) DEFAULT 0, - tax DECIMAL(10, 2) DEFAULT 0, - total DECIMAL(10, 2) NOT NULL, - notes TEXT -);`; - connection.exec(run); -} - -function seed(connection: Database) { - const run = ` -INSERT INTO users (email, name) VALUES - ${Array(30) - .fill(0) - .map((_, i) => `('user${i + 1}@example.com', 'User ${i + 1}')`) - .join(",\n ")}; - -INSERT INTO products (name, price, description) VALUES - ${Array(25) - .fill(0) - .map( - (_, i) => - `('Product ${i + 1}', ${(Math.random() * 100).toFixed(2)}, 'Description for Product ${i + 1} with a very long description that should be truncated')`, - ) - .join(",\n ")}; - -INSERT INTO orders (user_id, status) VALUES - ${Array(35) - .fill(0) - .map( - () => - `(${Math.floor(Math.random() * 30) + 1}, '${["pending", "completed", "shipped", "cancelled"][Math.floor(Math.random() * 4)]}')`, - ) - .join(",\n ")}; - -INSERT INTO order_items (order_id, product_id, quantity, unit_price, subtotal, discount, tax, total, notes) VALUES - ${Array(40) - .fill(0) - .map(() => { - const quantity = Math.floor(Math.random() * 5) + 1; - const unitPrice = Number.parseFloat((Math.random() * 100).toFixed(2)); - const subtotal = quantity * unitPrice; - const discount = Number.parseFloat((Math.random() * 10).toFixed(2)); - const tax = Number.parseFloat((subtotal * 0.1).toFixed(2)); - const total = subtotal - discount + tax; - return `(${Math.floor(Math.random() * 35) + 1}, ${Math.floor(Math.random() * 25) + 1}, ${quantity}, ${unitPrice}, ${subtotal}, ${discount}, ${tax}, ${total}, 'Note for item ${Math.floor(Math.random() * 1000)}')`; - }) - .join(",\n ")}; - `; - connection.exec(run); -} - -async function start() { - process.env.BIN_ADMIN_TESTING = "1"; - log.info("Starting server..."); - const connection = new BetterSqlite3Database(":memory:"); - const database = drizzle(connection); - connection.pragma("journal_mode = WAL"); - migrate(connection); - seed(connection); - const app = express(); - app.use(express.urlencoded({ extended: true })); - app.use("/admin", await unstable_admin({ database, path: "/admin" })); - app.use("/public", express.static(`${process.cwd()}`)); - app.listen(3000); - // TODO config - // printRoutes(app); - app.use("/", (req, res) => { - res.redirect("/admin/database"); - }); - log.info("http://localhost:3000/admin/database"); -} - -void start(); diff --git a/plainweb/package.json b/plainweb/package.json deleted file mode 100644 index 6e70363..0000000 --- a/plainweb/package.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "name": "plainweb", - "version": "0.0.16", - "description": "A framework combining HTMX, SQLite and TypeScript for less complexity and more joy", - "type": "commonjs", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "scripts": { - "test": "NODE_ENV=test vitest run", - "build": "tsc --noEmit && pnpm build:tw && tsup", - "build:tw": "tailwindcss -i ./styles.css -o ./output.css --minify", - "dev:tw": "tailwindcss -i ./styles.css -o ./output.css --watch", - "dev:admin": "tsx --watch bin/admin.ts", - "dev": "npm-run-all --parallel dev:admin dev:tw" - }, - "keywords": [ - "plainweb", - "sqlite", - "typescript", - "htmx", - "express", - "typescript" - ], - "author": "Josef Erben ", - "license": "MIT", - "peerDependencies": { - "@kitajs/html": "^4.2.1", - "dotenv": "^16.4.5", - "drizzle-orm": "^0.32.0", - "tsx": "^4.10.3", - "zod": "^3.23.8" - }, - "dependencies": { - "@paralleldrive/cuid2": "^2.2.2", - "better-sqlite3": "^11.0.0", - "errorhandler": "^1.5.1", - "express": "^4.19.2", - "express-list-endpoints": "^7.1.0", - "express-rate-limit": "^7.3.1", - "morgan": "^1.10.0", - "node-mocks-http": "^1.14.1", - "nodemailer": "^6.9.13", - "winston": "^3.13.1" - }, - "devDependencies": { - "@kitajs/html": "^4.2.1", - "@kitajs/ts-html-plugin": "^4.0.1", - "@types/better-sqlite3": "^7.6.10", - "@types/errorhandler": "^1.5.3", - "@types/express": "^4.17.21", - "@types/morgan": "^1.9.9", - "@types/node": "^20.11.24", - "@types/nodemailer": "^6.4.15", - "@types/supertest": "^6.0.2", - "@vitest/coverage-v8": "^2.0.3", - "daisyui": "^4.11.1", - "dotenv": "^16.4.5", - "drizzle-orm": "^0.32.0", - "npm-run-all2": "^6.0.0", - "supertest": "^7.0.0", - "tailwindcss": "^3.4.3", - "tempy": "^3.1.0", - "tsup": "^8.0.2", - "tsx": "^4.16.2", - "typescript": "^5.3.3", - "vite-tsconfig-paths": "^4.3.2", - "vitest": "^2.0.1", - "zod": "^3.23.8" - } -} diff --git a/plainweb/src/admin/admin.ts b/plainweb/src/admin/admin.ts deleted file mode 100644 index 40c1be2..0000000 --- a/plainweb/src/admin/admin.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { config } from "admin/config"; -import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; -import express from "express"; -import { fileRouter } from "file-router"; -import { getLogger } from "log"; -import { databaseRoutes } from "./database/loaded-file-routes"; - -const log = getLogger("admin"); - -export async function adminRouter>(opts: { - database: BetterSQLite3Database; - path: string; - verbose?: number; -}): Promise { - const { database, path, verbose = 3 } = opts; - config.adminBasePath = path; - const router = express.Router(); - - // make database available to all routes - router.use((req, res, next) => { - res.locals.database = database; - next(); - }); - router.use( - "/database", - await fileRouter({ dir: "", loadedFileRoutes: databaseRoutes }), - ); - // redirect / to /database - router.use("/", (req, res) => { - log.info("redirecting / to /database"); - res.redirect(`${path}/database`); - }); - return router; -} diff --git a/plainweb/src/admin/column.ts b/plainweb/src/admin/column.ts deleted file mode 100644 index 6e05d40..0000000 --- a/plainweb/src/admin/column.ts +++ /dev/null @@ -1,44 +0,0 @@ -export interface Column { - cid: number; - name: string; - type: "INTEGER" | "REAL" | "TEXT" | "BLOB" | "TIMESTAMP"; - notnull: number; - dflt_value: string | null; - pk: number; -} - -export function columnType(sqliteType: Column["type"]): string { - switch (sqliteType) { - case "INTEGER": - return "number"; - case "REAL": - return "number"; - case "TEXT": - return "string"; - case "BLOB": - return "Buffer"; - case "TIMESTAMP": - return "Date"; - default: - return "any"; - } -} - -export function renderValue(value: unknown, tsType: string): string { - if (value === null) { - return "NULL"; - } - - switch (tsType) { - case "number": - return String(value); - case "string": - return value as string; - case "Buffer": - return (value as Buffer).toString("hex"); - case "Date": - return new Date(value as string).toISOString(); - default: - return (value as string).toString(); - } -} diff --git a/plainweb/src/admin/config.ts b/plainweb/src/admin/config.ts deleted file mode 100644 index 00f1fb2..0000000 --- a/plainweb/src/admin/config.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const config = { - verbose: 3, - adminBasePath: "/_", -}; - -// TODO add nice api to configure things across all of plainweb diff --git a/plainweb/src/admin/database/loaded-file-routes.ts b/plainweb/src/admin/database/loaded-file-routes.ts deleted file mode 100644 index d8b8ebc..0000000 --- a/plainweb/src/admin/database/loaded-file-routes.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { LoadedFileRoute } from "file-router"; -import { GET as editGet, POST as editPost } from "./routes/[table]/edit"; -import { GET as detailGET } from "./routes/[table]/index"; -import { GET as rowGET } from "./routes/[table]/row"; -import { GET as indexGET } from "./routes/index"; -import { GET as sqlGET, POST as sqlPOST } from "./routes/sql"; - -export const databaseRoutes = [ - { filePath: "/[table]/index.tsx", GET: detailGET }, - { filePath: "/[table]/edit.tsx", GET: editGet, POST: editPost }, - { filePath: "/[table]/row.tsx", GET: rowGET }, - { filePath: "/index.tsx", GET: indexGET }, - { filePath: "/sql.tsx", GET: sqlGET, POST: sqlPOST }, -] satisfies LoadedFileRoute[]; diff --git a/plainweb/src/admin/database/routes/[table]/components/table-row.tsx b/plainweb/src/admin/database/routes/[table]/components/table-row.tsx deleted file mode 100644 index 499ed6e..0000000 --- a/plainweb/src/admin/database/routes/[table]/components/table-row.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { type Column, columnType, renderValue } from "admin/column"; -import { config } from "admin/config"; - -interface TableRowProps { - tableName: string; - columns: Column[]; - row: Record; - editing?: boolean; -} - -export function TableRow({ editing, tableName, columns, row }: TableRowProps) { - return ( - - - - - - {columns.map((column) => { - const tsType = columnType(column.type); - const value = rowData[column.name]; - const safeFormattedValue = renderValue(value, tsType); - return ( - - {safeFormattedValue} - - - ); - })} - - - - - ); -}; diff --git a/plainweb/src/admin/database/routes/[table]/index.tsx b/plainweb/src/admin/database/routes/[table]/index.tsx deleted file mode 100644 index 1771954..0000000 --- a/plainweb/src/admin/database/routes/[table]/index.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import type { Column } from "admin/column"; -import { config } from "admin/config"; -import { AdminLayout, type NavigationItem } from "admin/layout"; -import { sql } from "drizzle-orm"; -import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; -import type { Handler } from "handler"; -import { TableRow } from "./components/table-row"; - -export const GET: Handler = async ({ req, res }) => { - const tableName = req.params.table as string; - const db = res.locals.database as BetterSQLite3Database< - Record - >; - - const columns = db.all( - sql`SELECT * from pragma_table_info(${tableName}) LIMIT 100`, - ); - - const rows = db.all>( - sql`SELECT * FROM ${sql.identifier(tableName)} LIMIT 100`, - ); - - const tables = await db - .select({ name: sql`name` }) - .from(sql`sqlite_master`) - .where(sql`type=${"table"} AND name NOT LIKE ${"sqlite_%"}`); - - const tableCounts: { tableName: string; count: number }[] = []; - for (const table of tables) { - const [rowCount] = db.all<{ count: number }>( - sql`SELECT COUNT(*) as count FROM ${sql.identifier(table.name)}`, - ); - - tableCounts.push({ tableName: table.name, count: rowCount?.count ?? 0 }); - } - - const subNavigationItems: NavigationItem[] = tableCounts.map( - ({ tableName, count }) => ({ - href: `${config.adminBasePath}/database/${tableName}`, - label: ( - - {tableName} - {count} - - ), - }), - ); - - return ( - -
- - - - - ))} - - - - {rows.map((row) => ( - - ))} - -
- {columns.map((column) => ( - - {column.name} -
-
-
- ); -}; diff --git a/plainweb/src/admin/database/routes/[table]/row.tsx b/plainweb/src/admin/database/routes/[table]/row.tsx deleted file mode 100644 index c7796bd..0000000 --- a/plainweb/src/admin/database/routes/[table]/row.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { Column } from "admin/column"; -import { config } from "admin/config"; -import { sql } from "drizzle-orm"; -import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; -import type { Handler } from "handler"; -import { TableRow } from "./components/table-row"; - -export const GET: Handler = async ({ req, res }) => { - const tableName = req.params.table as string; - const db = res.locals.database as BetterSQLite3Database< - Record - >; - const row = JSON.parse(decodeURIComponent(req.query.row as string)); - - const columns = db.all( - sql`SELECT * from pragma_table_info(${tableName}) LIMIT 100`, - ); - - config.verbose > 1 && console.log("[admin] [database]", "fetching row", row); - - const whereClause = Object.entries(row) - .map(([column, value]) => sql`${column} = ${value}`) - .join(" AND "); - - const query = sql`SELECT * FROM ${sql.identifier(tableName)} WHERE`; - - let i = 0; - for (const [column, value] of Object.entries(row)) { - query.append(sql`${sql.identifier(column)} = ${value}`); - if (i === Object.keys(row).length - 1) continue; - query.append(sql` AND `); - i++; - } - - const found = db.all>(query); - - if (found.length === 0) { - throw new Error(`Row not found for ${whereClause}`); - } - - return ; -}; diff --git a/plainweb/src/admin/database/routes/index.test.ts b/plainweb/src/admin/database/routes/index.test.ts deleted file mode 100644 index 7846625..0000000 --- a/plainweb/src/admin/database/routes/index.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { beforeAll, describe, expect, test } from "vitest"; - -import { config } from "admin/config"; -import BetterSqlite3Database from "better-sqlite3"; -import { - type BetterSQLite3Database, - drizzle, -} from "drizzle-orm/better-sqlite3"; -import express from "express"; -import { handleResponse } from "handler"; -import { isolate } from "isolate"; -import supertest from "supertest"; -import { GET } from "./index"; - -const connection = new BetterSqlite3Database(":memory:"); -const database = drizzle(connection); - -function app(database: BetterSQLite3Database) { - const app = express(); - app.use((req, res, next) => { - res.locals.database = database; - next(); - }); - - app.route("/").get(async (req, res) => { - const userResponse = await GET({ req, res }); - await handleResponse(res, userResponse); - }); - return app; -} - -function runMigrations(connection: BetterSqlite3Database.Database) { - const migrations = ` -CREATE TABLE users ( - email text PRIMARY KEY NOT NULL -); - -CREATE TABLE orders ( - id text PRIMARY KEY NOT NULL -); - `; - connection.exec(migrations); -} - -process.env.NODE_ENV = "test"; - -describe("admin database index.ts", () => { - beforeAll(() => runMigrations(connection)); - - test("get with tables", async () => { - await isolate(database, async (tx) => { - const response = await supertest(app(tx)).get("/"); - - expect(response.status).toBe(302); - expect(response.headers.location).toBe( - `${config.adminBasePath}/database/users`, - ); - }); - }); -}); diff --git a/plainweb/src/admin/database/routes/index.tsx b/plainweb/src/admin/database/routes/index.tsx deleted file mode 100644 index e85a990..0000000 --- a/plainweb/src/admin/database/routes/index.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { config } from "admin/config"; -import { sql } from "drizzle-orm"; -import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; -import type { Handler } from "handler"; -import { redirect } from "plain-response"; - -export const GET: Handler = async ({ req, res }) => { - const db = res.locals.database as BetterSQLite3Database; - - const tables = await db - .select({ name: sql`name` }) - .from(sql`sqlite_master`) - .where(sql`type=${"table"} AND name NOT LIKE ${"sqlite_%"}`); - - if (!tables[0]) { - return
No tables found
; - } - - return redirect(`${config.adminBasePath}/database/${tables[0].name}`); -}; diff --git a/plainweb/src/admin/database/routes/sql.tsx b/plainweb/src/admin/database/routes/sql.tsx deleted file mode 100644 index eaecc1b..0000000 --- a/plainweb/src/admin/database/routes/sql.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { Handler } from "../../../handler"; - -export const GET: Handler = async ({ req, res }) => { - throw new Error("Not implemented"); -}; - -export const POST: Handler = async ({ req, res }) => { - throw new Error("Not implemented"); -}; diff --git a/plainweb/src/admin/layout.tsx b/plainweb/src/admin/layout.tsx deleted file mode 100644 index ac7d78f..0000000 --- a/plainweb/src/admin/layout.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import type { PropsWithChildren } from "@kitajs/html"; -import { config } from "admin/config"; - -function DatabaseIcon() { - return ( - - Database - - - - - ); -} - -function MediaIcon() { - return ( - - Media - - - - - ); -} - -function TaskQueueIcons() { - return ( - - Task Queues - - - - ); -} - -function MainNavigation(props: { - active: "database" | "media" | "users"; -}) { - return ( - - ); -} - -function SubNavigation(props: { items: NavigationItem[]; path: string }) { - return ( - - ); -} - -export type NavigationItem = { - href: string; - label: JSX.Element; - icon?: JSX.Element; -}; - -export function AdminLayout( - props: PropsWithChildren<{ - active: "database" | "media" | "users"; - path: string; - subNavigation: NavigationItem[]; - description?: string; - title?: string; - }>, -) { - return ( - <> - {""} - - - - - {props.title || "plainweb"} - - {process.env.BIN_ADMIN_TESTING ? ( - - ) : ( - - )} -