diff --git a/.changeset/gold-boxes-switch.md b/.changeset/gold-boxes-switch.md new file mode 100644 index 0000000..ac1b39c --- /dev/null +++ b/.changeset/gold-boxes-switch.md @@ -0,0 +1,17 @@ +--- +'@getcronit/pylon-dev': major +--- + +- Integrated `@getcronit/pylon-builder` directly into `@getcronit/pylon-dev`. + - Removed the `pylon-builder` package. + - The builder now utilizes the `esbuild` watch mode for development. This is a much faster and more efficient way to build the project. +- Implemented `pm2` for process management: + - `pm2` is now used to manage the `pylon-dev` server. After files are built, the server is restarted automatically. + - The stdout and stderr logs are logged directly with `consola`. +- Now builds a cross-environment client in `.pylon/client` using `gqty`. This will be used for pylon/pages. + +### Breaking Change: Removed Client Generation Feature + +- **What**: The client generation feature has been removed. +- **Why**: We have decided to use `gqty` directly to streamline the development process and reduce complexity. +- **How to Update**: Consumers should now use the [GQty CLI](https://gqty.dev/api-reference/cli#basic-usage) directly to generate their clients. Update your build scripts and development workflows to integrate `gqty` as described in the GQty documentation. diff --git a/.changeset/popular-pugs-serve.md b/.changeset/popular-pugs-serve.md new file mode 100644 index 0000000..2c83f93 --- /dev/null +++ b/.changeset/popular-pugs-serve.md @@ -0,0 +1,40 @@ +--- +'@getcronit/pylon': minor +'@getcronit/pylon-dev': minor +--- + +Extend plugin system with setup, middleware, and build functions. +The viewer is now integrated via a built-in `useViewer` plugin. + +Custom plugins can now access the app instance and register routes, middleware, and custom build steps. + +```ts +import {Plugin} from '@getcronit/pylon' + +export function myPlugin(): Plugin { + return { + setup(app) { + app.use((req, res, next) => { + console.log('Request:', req.url) + next() + }) + + app.get('/hello', (req, res) => { + res.send('Hello, World!') + }) + }, + middleware: (c, next) => { + // This middleware will be inserted higher in the middleware stack + console.log('Middleware:', c.req.url) + next() + }, + build: async () => { + // Custom esbuild build + const ctx = await esbuild.context(...) + + // Must return the context + return ctx + } + } +} +``` diff --git a/.changeset/real-horses-smash.md b/.changeset/real-horses-smash.md new file mode 100644 index 0000000..3e0c203 --- /dev/null +++ b/.changeset/real-horses-smash.md @@ -0,0 +1,5 @@ +--- +'@getcronit/pylon-dev': patch +--- + +Fix broken field descriptions in schema parsing diff --git a/.changeset/rotten-ravens-sin.md b/.changeset/rotten-ravens-sin.md new file mode 100644 index 0000000..4f5cec5 --- /dev/null +++ b/.changeset/rotten-ravens-sin.md @@ -0,0 +1,26 @@ +--- +'@getcronit/pylon': minor +--- + +Add `usePages` plugin to support file-based (Fullstack React) routing. https://github.com/getcronit/pylon/issues/69 + +```ts +import {app, usePages, PylonConfig} from '@getcronit/pylon' + +export const graphql = { + Query: { + hello: () => { + return 'Hello, world!' + }, + post: (slug: string) => { + return {title: `Post: ${slug}`, content: 'This is a blog post.'} + } + } +} + +export const config: PylonConfig = { + plugins: [usePages()] // Enables the Pages Router +} + +export default app +``` diff --git a/.changeset/shy-countries-help.md b/.changeset/shy-countries-help.md new file mode 100644 index 0000000..1ade958 --- /dev/null +++ b/.changeset/shy-countries-help.md @@ -0,0 +1,13 @@ +--- +'@getcronit/pylon': minor +--- + +Show a fallback page for the landing page and unhandled routes / 404s. + +This behavior can be disabled via the pylon config: + +```ts +export const config: PylonConfig = { + landingPage: false +} +``` diff --git a/.changeset/slimy-garlics-battle.md b/.changeset/slimy-garlics-battle.md new file mode 100644 index 0000000..6064894 --- /dev/null +++ b/.changeset/slimy-garlics-battle.md @@ -0,0 +1,13 @@ +--- +'create-pylon': patch +--- + +- Use `consola` for clearer interactive prompts and logs. +- Remove `--client`, `--client-path`, and `--client-port` flags in favor of [GQty CLI](https://gqty.dev/api-reference/cli#basic-usage) +- Improved package manager detection and dependency installation. https://github.com/getcronit/pylon/issues/73 +- Removed `--template` flag in favor of `--features` flag. Each runtime can now support multiple features which pre-configure the project for different use-cases. + Currently supported features: + - `pages`: React SSR Pages with file-based routing + - `auth`: OIDC Authentication (Primarily for ZITADEL but can be used with any OIDC provider) +- The success message now only shows the `deploy` script if it is available. +- Improved error handling and messaging. diff --git a/.changeset/soft-goats-run.md b/.changeset/soft-goats-run.md new file mode 100644 index 0000000..c50cebd --- /dev/null +++ b/.changeset/soft-goats-run.md @@ -0,0 +1,36 @@ +--- +'@getcronit/pylon': major +--- + +**Summary:** +This changeset introduces a major overhaul to the built-in authentication system. The new implementation automatically sets up `/auth/login`, `/auth/callback`, and `/auth/logout` routes, injects an `auth` object into the context, and manages token cookies. Role-based route protection is now enhanced via `authMiddleware` and the updated `requireAuth` decorator, configurable through the streamlined `useAuth` plugin. + +--- + +**Breaking Changes:** + +- **WHAT:** + The authentication configuration has been completely revamped. The previous manual setup is replaced by the `useAuth` plugin. Custom authentication route definitions are no longer necessary, and existing middleware or decorator usage may require adjustments. + +- **WHY:** + This change was implemented to simplify authentication setup, reduce boilerplate, improve security by automating context and cookie management, and offer better role-based access control. + +- **HOW:** + Consumers should: + 1. Remove any custom authentication route setups. + 2. Update their configuration to use the new `useAuth` plugin as shown below: + ```typescript + export const config: PylonConfig = { + plugins: [ + useAuth({ + issuer: 'https://test-0o6zvq.zitadel.cloud', + endpoint: '/auth', + keyPath: 'key.json' + }) + ] + } + ``` + 3. Replace previous authentication middleware or decorators with the updated `requireAuth` and `authMiddleware` APIs. + 4. Test the new authentication endpoints (`/auth/login`, `/auth/callback`, and `/auth/logout`) to ensure proper integration. + +Ensure you update your code accordingly to avoid disruptions in your authentication flow. diff --git a/.changeset/young-islands-bow.md b/.changeset/young-islands-bow.md new file mode 100644 index 0000000..22857fd --- /dev/null +++ b/.changeset/young-islands-bow.md @@ -0,0 +1,16 @@ +--- +'@getcronit/pylon': minor +--- + +- Option to disable the playground and introspection in the Pylon configuration. https://github.com/getcronit/pylon/issues/72 + +### Example + +To disable the playground and introspection, set the `graphiql` property to `false` in your Pylon configuration: + +```ts +export const config: PylonConfig = { + // Disable the playground and introspection + graphiql: false +} +``` diff --git a/.gitignore b/.gitignore index 5b1b17b..1e02664 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,6 @@ node_modules bun.lockb -.DS_Store \ No newline at end of file +.DS_Store + +examples diff --git a/docs/pages/docs/core-concepts/built-in-authentication-and-authorization.mdx b/docs/pages/docs/core-concepts/built-in-authentication-and-authorization.mdx index 5f8be38..7badc04 100644 --- a/docs/pages/docs/core-concepts/built-in-authentication-and-authorization.mdx +++ b/docs/pages/docs/core-concepts/built-in-authentication-and-authorization.mdx @@ -2,37 +2,59 @@ import {Callout} from '@components/callout' # Built-in Authentication and Authorization -Discover how Pylon simplifies user authentication and authorization with its comprehensive built-in features, empowering you to secure your web services effortlessly. +Pylon now offers an enhanced, streamlined authentication system. With this update, the auth endpoint automatically creates routes for **/auth/login**, **/auth/callback**, and **/auth/logout**. When a user authenticates, Pylon sets an `auth` object in the context variables and automatically manages a cookie with the token—simplifying session management and ensuring a secure experience. + +--- ## General Setup -Before diving into authentication and authorization with Pylon, it's essential to set up your environment and configure the necessary components. Pylon's built-in authentication system follows the OIDC standard and is currently tightly integrated with ZITADEL for user management and access control. +Before you begin, configure your environment to integrate with your authentication provider (e.g., ZITADEL). The new configuration uses the `useAuth` plugin to initialize authentication routes and settings. -1. **Environment Variables:** - Ensure you have the required environment variables set up in your project: +```typescript +import { + app, + PylonConfig, + requireAuth, + useAuth, + authMiddleware +} from '@getcronit/pylon' + +export const config: PylonConfig = { + plugins: [ + useAuth({ + issuer: 'https://test-0o6zvq.zitadel.cloud', + endpoint: '/auth', // optional, default is '/auth' + keyPath: 'key.json' // optional, default is 'key.json' + }) + ] +} +``` - ``` - AUTH_ISSUER=https://test-0o6zvq.zitadel.cloud - AUTH_PROJECT_ID= - ``` +**How it works:** -2. **Integration with ZITADEL:** - To enable Pylon to authenticate users and manage access control, you need to integrate it with ZITADEL. Follow the documentation provided by ZITADEL to set up projects, applications, keys, and roles. - [ZITADEL Projects Documentation](https://zitadel.com/docs/guides/manage/console/projects) +- **Auth Routes:** + The plugin automatically creates routes for: + + - `/auth/login` + - `/auth/callback` + - `/auth/logout` + +- **Context & Cookie:** + After authentication, an `auth` object is added to your context, and a cookie containing the token is set for subsequent requests. - Pylon requires a **API** application with the **Private JWT Key** type to - authenticate users and manage access control. + Ensure that your API application is configured to use a **Private JWT Key** + type for secure token management. +--- + ## Authentication Example -Pylon makes authentication seamless by providing a straightforward integration with ZITADEL. Here's how you can set up authentication in your Pylon project: +To protect sensitive data, use the `requireAuth` decorator. In the example below, any user trying to access the data must be authenticated: ```typescript -import {app, auth, requireAuth} from '@getcronit/pylon' - -// Define your sensitive data service +// Define a service for sensitive data class SensitiveData { @requireAuth() static async getData() { @@ -40,27 +62,26 @@ class SensitiveData { } } +// Expose the resolver via GraphQL export const graphql = { Query: { sensitiveData: SensitiveData.getData } } -app.use('*', auth.initialize()) - export default app ``` -In this example, the `requireAuth()` decorator ensures that users are authenticated before accessing sensitive data. You can also specify roles to restrict access to certain data based on user permissions. +In this setup, the `@requireAuth()` decorator ensures that only authenticated users can access the `getData` method. If the user is not authenticated, they will be redirected to the login flow at `/auth/login`. --- ## Authorization Example -Authorization in Pylon allows you to control access to specific resources based on user roles and permissions. Here's how you can implement authorization in your Pylon project: +If you need to restrict access based on roles, you can pass a roles array to the `requireAuth` decorator. For instance, the following example limits access to users with the `"admin"` role: ```typescript -// Define your sensitive data service +// Define a service for admin-only data class SensitiveData { @requireAuth({ roles: ['admin'] @@ -70,72 +91,63 @@ class SensitiveData { } } -// Define your GraphQL schema +// Expose the resolver via GraphQL export const graphql = { Query: { sensitiveAdminData: SensitiveData.getAdminData } } -app.use('*', auth.initialize()) - export default app ``` -In this example, the `requireAuth()` decorator ensures that only authenticated users with the "admin" role can access the `getAdminData()` function. You can customize roles and permissions according to your application's requirements. +Only authenticated users who have the `"admin"` role will be allowed to access `getAdminData()`. Roles should be managed in your authentication provider (e.g., ZITADEL) for centralized control over permissions. -Roles can be defined in ZITADEL and assigned to users to control access to specific resources. By integrating Pylon with ZITADEL, you can easily manage roles and permissions for your application. -For more information on setting up roles in ZITADEL, refer to the [ZITADEL Roles Documentation](https://zitadel.com/docs/guides/manage/console/roles). +--- -## Securing Routes +## Securing Routes with Middleware -Securing routes in Pylon involves enforcing authentication and, optionally, authorization for specific endpoints or routes. Here's how you can secure a route in your Pylon project: +In addition to securing individual resolvers, you can enforce authentication and authorization for entire routes using the new `authMiddleware`. For example, to secure a specific REST endpoint: ```typescript -import {auth, requireAuth} from '@getcronit/pylon' - -// Define your sensitive data service -class SensitiveData { - static async getData() { - return 'Sensitive Data' - } +import {authMiddleware} from '@getcronit/pylon' - @requireAuth({ +// Secure all routes under /admin to only allow users with the 'admin' role +app.use( + '/admin', + authMiddleware({ roles: ['admin'] }) - static async getAdminData() { - return 'Admin Data' - } -} +) -export const graphql = { - Query: { - sensitiveData: SensitiveData.getData, - sensitiveAdminData: SensitiveData.getAdminData +// Secure specific route to only allow users with the 'admin' role +app.get( + '/secure', + authMiddleware({ + roles: ['admin'] + }), + c => { + return c.json({data: 'sensitive'}) } -} - -// Enforce authentication for all routes -app.use('*', auth.initialize()) - -// Secure a specific route with authentication and authorization -app.use('/admin', auth.requireAuth({roles: ['admin']})) - +) export default app ``` -In this example, we're securing the `/admin` route to ensure that only authenticated users with the "admin" role can access it. By using the `requireAuth()` middleware from Pylon's authentication module, we enforce both authentication and authorization for this specific route. +In this case, any request to the `/admin` route will first pass through `authMiddleware`, ensuring that the user is authenticated and has the required `"admin"` role. +The same applies to the `/secure` route, which is secured with the `authMiddleware` middleware. -You can customize the route and the required roles according to your application's requirements. This ensures that sensitive endpoints are protected, providing a secure environment for your users' data and resources. +--- ## Further Resources -For detailed instructions on setting up projects, applications, keys, and roles in ZITADEL, refer to the ZITADEL documentation: +For additional guidance on integrating with your authentication provider, please refer to the following resources: - [ZITADEL Projects Documentation](https://zitadel.com/docs/guides/manage/console/projects) - [ZITADEL Applications Documentation](https://zitadel.com/docs/guides/manage/console/applications#api) - [ZITADEL Roles Documentation](https://zitadel.com/docs/guides/manage/console/roles) +--- + ## Conclusion -With Pylon's built-in authentication and authorization features, you can easily secure your web services and control access to sensitive data, providing a seamless and secure user experience. +With the new built-in authentication and authorization features, Pylon makes securing your web services simpler than ever. The automatic route creation, context management, and cookie handling streamline the login flow, while decorators and middleware give you granular control over access to your application’s data and routes. Enjoy a secure and seamless user experience with minimal configuration! diff --git a/package.json b/package.json index 604e018..575a52c 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,6 @@ "private": true, "workspaces": { "packages": [ - "packages/pylon-telemetry", "packages/*", "examples/*" ] @@ -13,7 +12,7 @@ "ci:release": "changeset publish", "ci:version": "changeset version && pnpm i --no-frozen-lockfile --lockfile-only --ignore-scripts", "clean": "pnpm -r --filter \"./packages/*\" exec -- rimraf dist", - "build": "pnpm -r --filter \"./packages/*\" run build" + "build": "pnpm -r --workspace-concurrency=1 --filter \"./packages/*\" run build" }, "devDependencies": { "@changesets/changelog-github": "^0.5.0", diff --git a/packages/create-pylon/CHANGELOG.md b/packages/create-pylon/CHANGELOG.md index 5938c57..5b56bb6 100644 --- a/packages/create-pylon/CHANGELOG.md +++ b/packages/create-pylon/CHANGELOG.md @@ -1,12 +1,5 @@ # create-pylon -## 1.1.5 - -### Patch Changes - -- [#102](https://github.com/getcronit/pylon/pull/102) [`f4f3dca`](https://github.com/getcronit/pylon/commit/f4f3dcaee59d03508a99ce7bd4a4ae8370d74a4a) Thanks [@schettn](https://github.com/schettn)! - feat: add `graphiql` option to config - fix: update internal consola imports - ## 1.1.4 ### Patch Changes diff --git a/packages/create-pylon/package.json b/packages/create-pylon/package.json index 520f418..c15883c 100644 --- a/packages/create-pylon/package.json +++ b/packages/create-pylon/package.json @@ -1,7 +1,7 @@ { "name": "create-pylon", "type": "module", - "version": "1.1.5", + "version": "1.1.4", "description": "CLI for creating a Pylon", "scripts": { "build": "rimraf ./dist && esbuild ./src/index.ts --bundle --platform=node --target=node18 --format=esm --minify --outdir=./dist --sourcemap=linked --packages=external" @@ -20,12 +20,13 @@ }, "homepage": "https://pylon.cronit.io", "dependencies": { - "@getcronit/pylon-telemetry": "workspace:^", - "@inquirer/prompts": "^5.4.0", "chalk": "^5.3.0", "commander": "^12.1.0", + "conf": "^13.1.0", "consola": "^3.2.3", - "hono": "^4" + "package-manager-detector": "^0.2.9", + "posthog-node": "^4.10.1", + "tinyexec": "^0.3.2" }, "engines": { "node": ">=18.0.0" diff --git a/packages/create-pylon/src/analytics.ts b/packages/create-pylon/src/analytics.ts new file mode 100644 index 0000000..b6c3da3 --- /dev/null +++ b/packages/create-pylon/src/analytics.ts @@ -0,0 +1,68 @@ +import {PostHog} from 'posthog-node' +import Conf from 'conf' +import {readFileSync} from 'fs' +import {randomUUID} from 'crypto' + +const schema = { + distinctId: { + type: 'string', + default: randomUUID() + } +} + +const config = new Conf<{ + distinctId: string +}>({ + projectName: 'pylon', + schema +}) + +export const distinctId = config.get('distinctId') +export const sessionId = randomUUID() + +export const analytics = new PostHog( + 'phc_KN4qCOcCdkXp6sHLIuMWGRfzZWuNht69oqv5Kw5rGxj', + { + host: 'https://eu.i.posthog.com', + disabled: process.env.PYLON_DISABLE_TELEMETRY === 'true' + } +) + +const getPylonDependencies = () => { + // Read the package.json file in the current directory + let packageJson + + try { + packageJson = JSON.parse(readFileSync('./package.json', 'utf8')) + } catch (error) { + packageJson = {} + } + + // Extract the dependencies + const dependencies: object = packageJson.dependencies || {} + const devDependencies: object = packageJson.devDependencies || {} + const peerDependencies: object = packageJson.peerDependencies || {} + + return {dependencies, devDependencies, peerDependencies} +} + +export const dependencies = getPylonDependencies() + +export const readPylonConfig = async () => { + try { + const config = await import(`${process.cwd()}/.pylon/config.js`) + const data = config.config + + // Sanitize the config values + const sanitizedData = JSON.parse(JSON.stringify(data)) as object + + // Check if the config is a empty object + if (Object.keys(sanitizedData).length === 0) { + return false + } + + return sanitizedData + } catch (error) { + return false + } +} diff --git a/packages/create-pylon/src/create-directory/files.ts b/packages/create-pylon/src/create-directory/files.ts new file mode 100644 index 0000000..a0b5ba0 --- /dev/null +++ b/packages/create-pylon/src/create-directory/files.ts @@ -0,0 +1,604 @@ +import {Runtime} from '.' + +const pylonVersion = '^2.0.0' +const pylonDevVersion = '^1.0.0' + +export const files: { + [key in Runtime | 'ALL']: { + path: string + content: string + specificRuntimes?: Runtime[] + }[] +} = { + ALL: [ + { + path: '.gitignore', + content: `# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +\*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +\*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +\*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +\*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.cache +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +.cache/ + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp +.cache + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.\* + +# wrangler project + +.dev.vars +.wrangler/ + +# Pylon project +.pylon +` + }, + { + path: '.dockerignore', + content: `node_modules +Dockerfile* +docker-compose* +.dockerignore +.git +.gitignore +README.md +LICENSE +.vscode +Makefile +helm-charts +.env +.editorconfig +.idea +coverage* +`, + specificRuntimes: ['node', 'bun'] + }, + { + path: '.github/workflows/publish.yml', + content: `name: publish + +on: [push] +env: + IMAGE_NAME: __PYLON_NAME__ + +jobs: + # Push image to GitHub Packages. + # See also https://docs.docker.com/docker-hub/builds/ + publish-container: + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Build image + run: docker build . --file Dockerfile --tag $IMAGE_NAME + + - name: Log into registry + run: echo "\${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u \${{ github.actor }} --password-stdin + + - name: Push image + run: | + IMAGE_ID=ghcr.io/\${{ github.repository_owner }}/$IMAGE_NAME + + # Change all uppercase to lowercase + IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]') + # Strip git ref prefix from version + VERSION=$(echo "\${{ github.ref }}" | sed -e 's,.*/\\(.*\\),\\1,') + # Strip "v" prefix from tag name + [[ "\${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') + # Use Docker \`latest\` tag convention + [ "$VERSION" == "main" ] && VERSION=latest + echo IMAGE_ID=$IMAGE_ID + echo VERSION=$VERSION + docker tag $IMAGE_NAME $IMAGE_ID:$VERSION + docker push $IMAGE_ID:$VERSION + +# SPDX-License-Identifier: (EUPL-1.2) +# Copyright © 2024 cronit KG`, + specificRuntimes: ['node', 'bun'] + } + ], + bun: [ + { + path: 'package.json', + content: `{ + "name": "__PYLON_NAME__", + "private": true, + "version": "0.0.1", + "type": "module", + "description": "Generated with \`npm create pylon\`", + "scripts": { + "dev": "pylon dev -c \\"bun run .pylon/index.js\\"", + "build": "pylon build" + }, + "dependencies": { + "@getcronit/pylon": "${pylonVersion}", + }, + "devDependencies": { + "@getcronit/pylon-dev": "${pylonDevVersion}", + "@types/bun": "^1.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/getcronit/pylon.git" + }, + "homepage": "https://pylon.cronit.io", + "packageManager": "bun" +} +` + }, + { + path: 'Dockerfile', + content: `# use the official Bun image +# see all versions at https://hub.docker.com/r/oven/bun/tags +FROM oven/bun:1 as base + +LABEL description="Offical docker image for Pylon services (Bun)" +LABEL org.opencontainers.image.source="https://github.com/getcronit/pylon" +LABEL maintainer="office@cronit.io" + +WORKDIR /usr/src/pylon + + +# install dependencies into temp directory +# this will cache them and speed up future builds +FROM base AS install +RUN mkdir -p /temp/dev +COPY package.json bun.lockb /temp/dev/ +RUN cd /temp/dev && bun install --frozen-lockfile + +# install with --production (exclude devDependencies) +RUN mkdir -p /temp/prod +COPY package.json bun.lockb /temp/prod/ +RUN cd /temp/prod && bun install --frozen-lockfile --production + +# copy node_modules from temp directory +# then copy all (non-ignored) project files into the image +FROM install AS prerelease +COPY --from=install /temp/dev/node_modules node_modules +COPY . . + +# [optional] tests & build +ENV NODE_ENV=production + +# Create .pylon folder (mkdir) +RUN mkdir -p .pylon +# RUN bun test +RUN bun run pylon build + +# copy production dependencies and source code into final image +FROM base AS release +COPY --from=install /temp/prod/node_modules node_modules +COPY --from=prerelease /usr/src/pylon/.pylon .pylon +COPY --from=prerelease /usr/src/pylon/package.json . + +# run the app +USER bun +EXPOSE 3000/tcp +ENTRYPOINT [ "bun", "run", "/usr/src/pylon/.pylon/index.js" ] +` + } + ], + node: [ + { + path: 'package.json', + content: `{ + "name": "__PYLON_NAME__", + "private": true, + "version": "0.0.1", + "type": "module", + "description": "Generated with \`npm create pylon\`", + "scripts": { + "dev": "pylon dev -c \\"node --enable-source-maps .pylon/index.js\\"", + "build": "pylon build" + }, + "dependencies": { + "@getcronit/pylon": "${pylonVersion}", + "@hono/node-server": "^1.12.2" + }, + "devDependencies": { + "@getcronit/pylon-dev": "${pylonDevVersion}" + }, + "repository": { + "type": "git", + "url": "https://github.com/getcronit/pylon.git" + }, + "homepage": "https://pylon.cronit.io" +} +` + }, + { + path: 'Dockerfile', + content: `# Use the official Node.js 20 image as the base +FROM node:20-alpine as base + +LABEL description="Offical docker image for Pylon services (Node.js)" +LABEL org.opencontainers.image.source="https://github.com/getcronit/pylon" +LABEL maintainer="office@cronit.io" + +WORKDIR /usr/src/pylon + +# install dependencies into a temp directory +# this will cache them and speed up future builds +FROM base AS install +RUN mkdir -p /temp/dev +COPY package.json package-lock.json /temp/dev/ +RUN cd /temp/dev && npm ci + +# install with --production (exclude devDependencies) +RUN mkdir -p /temp/prod +COPY package.json package-lock.json /temp/prod/ +RUN cd /temp/prod && npm ci --only=production + +# copy node_modules from temp directory +# then copy all (non-ignored) project files into the image +FROM install AS prerelease +COPY --from=install /temp/dev/node_modules node_modules +COPY . . + +# [optional] tests & build +ENV NODE_ENV=production + +# Create .pylon folder (mkdir) +RUN mkdir -p .pylon +# RUN npm test +RUN npm run pylon build + +# copy production dependencies and source code into final image +FROM base AS release +COPY --from=install /temp/prod/node_modules node_modules +COPY --from=prerelease /usr/src/pylon/.pylon .pylon +COPY --from=prerelease /usr/src/pylon/package.json . + +# run the app +USER node +EXPOSE 3000/tcp +ENTRYPOINT [ "node", "/usr/src/pylon/.pylon/index.js" ] +` + } + ], + 'cf-workers': [ + { + path: 'package.json', + content: `{ + "name": "__PYLON_NAME__", + "type": "module", + "description": "Generated with \`npm create pylon\`", + "version": "0.0.1", + "private": true, + "scripts": { + "deploy": "pylon build && wrangler deploy", + "dev": "pylon dev -c \\"wrangler dev\\"", + "cf-typegen": "wrangler types" + }, + "dependencies": { + "@getcronit/pylon": "${pylonVersion}", + }, + "devDependencies": { + "@getcronit/pylon-dev": "${pylonDevVersion}", + "@cloudflare/vitest-pool-workers": "^0.4.5", + "@cloudflare/workers-types": "^4.20240903.0", + "typescript": "^5.5.2", + "wrangler": "^3.60.3" + }, + "repository": { + "type": "git", + "url": "https://github.com/getcronit/pylon.git" + }, + "homepage": "https://pylon.cronit.io" +} +` + }, + { + path: 'wrangler.toml', + content: `#:schema node_modules/wrangler/config-schema.json +name = "__PYLON_NAME__" +main = ".pylon/index.js" +compatibility_date = "2024-09-03" +compatibility_flags = ["nodejs_compat_v2"] + +# Automatically place your workloads in an optimal location to minimize latency. +# If you are running back-end logic in a Worker, running it closer to your back-end infrastructure +# rather than the end user may result in better performance. +# Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement +# [placement] +# mode = "smart" + +# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables) +# Docs: +# - https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables +# Note: Use secrets to store sensitive data. +# - https://developers.cloudflare.com/workers/configuration/secrets/ +# [vars] +# MY_VARIABLE = "production_value" + +# Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#workers-ai +# [ai] +# binding = "AI" + +# Bind an Analytics Engine dataset. Use Analytics Engine to write analytics within your Pages Function. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#analytics-engine-datasets +# [[analytics_engine_datasets]] +# binding = "MY_DATASET" + +# Bind a headless browser instance running on Cloudflare's global network. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#browser-rendering +# [browser] +# binding = "MY_BROWSER" + +# Bind a D1 database. D1 is Cloudflare’s native serverless SQL database. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#d1-databases +# [[d1_databases]] +# binding = "MY_DB" +# database_name = "my-database" +# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# Bind a dispatch namespace. Use Workers for Platforms to deploy serverless functions programmatically on behalf of your customers. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#dispatch-namespace-bindings-workers-for-platforms +# [[dispatch_namespaces]] +# binding = "MY_DISPATCHER" +# namespace = "my-namespace" + +# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model. +# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#durable-objects +# [[durable_objects.bindings]] +# name = "MY_DURABLE_OBJECT" +# class_name = "MyDurableObject" + +# Durable Object migrations. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#migrations +# [[migrations]] +# tag = "v1" +# new_classes = ["MyDurableObject"] + +# Bind a Hyperdrive configuration. Use to accelerate access to your existing databases from Cloudflare Workers. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#hyperdrive +# [[hyperdrive]] +# binding = "MY_HYPERDRIVE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#kv-namespaces +# [[kv_namespaces]] +# binding = "MY_KV_NAMESPACE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind an mTLS certificate. Use to present a client certificate when communicating with another service. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#mtls-certificates +# [[mtls_certificates]] +# binding = "MY_CERTIFICATE" +# certificate_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues +# [[queues.producers]] +# binding = "MY_QUEUE" +# queue = "my-queue" + +# Bind a Queue consumer. Queue Consumers can retrieve tasks scheduled by Producers to act on them. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues +# [[queues.consumers]] +# queue = "my-queue" + +# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#r2-buckets +# [[r2_buckets]] +# binding = "MY_BUCKET" +# bucket_name = "my-bucket" + +# Bind another Worker service. Use this binding to call another Worker without network overhead. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings +# [[services]] +# binding = "MY_SERVICE" +# service = "my-service" + +# Bind a Vectorize index. Use to store and query vector embeddings for semantic search, classification and other vector search use-cases. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#vectorize-indexes +# [[vectorize]] +# binding = "MY_INDEX" +# index_name = "my-index" +` + } + ], + deno: [ + { + path: '.vscode/settings.json', + content: `{ + "deno.enablePaths": [ + "./" + ], + "editor.inlayHints.enabled": "off" +}` + }, + { + path: '.vscode/extensions.json', + content: `{ + "recommendations": [ + "denoland.vscode-deno" + ] +}` + }, + { + path: 'deno.json', + content: `{ + "imports": { + "@getcronit/pylon-dev": "npm:@getcronit/pylon-dev@${pylonDevVersion}", + "@getcronit/pylon": "npm:@getcronit/pylon@${pylonVersion}" + }, + "tasks": { + "dev": "pylon dev -c \\"deno run -A .pylon/index.js --config tsconfig.json\\"", + "build": "pylon build" + }, + "compilerOptions": { + "jsx": "precompile", + "jsxImportSource": "hono/jsx" + }, + "nodeModulesDir": "auto", + "packageManager": "deno" +} +` + } + ] +} diff --git a/packages/create-pylon/src/create-directory/index.ts b/packages/create-pylon/src/create-directory/index.ts new file mode 100644 index 0000000..c2ee111 --- /dev/null +++ b/packages/create-pylon/src/create-directory/index.ts @@ -0,0 +1,592 @@ +import fs from 'fs/promises' +import path from 'path' +import {files} from './files' + +export const runtimes = [ + { + key: 'bun', + name: 'Bun.js', + website: 'https://bunjs.dev', + supportedFeatures: ['auth', 'pages'] + }, + { + key: 'node', + name: 'Node.js', + website: 'https://nodejs.org', + supportedFeatures: ['auth', 'pages'] + }, + { + key: 'cf-workers', + name: 'Cloudflare Workers', + website: 'https://workers.cloudflare.com', + supportedFeatures: ['auth'] + }, + { + key: 'deno', + name: 'Deno', + website: 'https://deno.land' + } +] + +export const features = [ + { + key: 'auth', + name: 'Authentication', + website: 'https://pylon.cronit.io/docs/authentication' + }, + { + key: 'pages', + name: 'Pages', + website: 'https://pylon.cronit.io/docs/pages' + } +] + +export type Runtime = (typeof runtimes)[number]['key'] +export type Feature = (typeof features)[number]['key'] + +interface CreateDirectoryOptions { + variables: Record + destination: string + runtime: Runtime + features: Feature[] +} + +const makeIndexFile = (runtime: Runtime, features: Feature[]) => { + const pylonImports: string[] = ['app', 'PylonConfig'] + const pylonConfigPlugins: string[] = [] + + if (features.includes('auth')) { + pylonImports.push('useAuth') + pylonConfigPlugins.push( + "useAuth({issuer: 'https://test-0o6zvq.zitadel.cloud'})" + ) + } + + if (features.includes('pages')) { + pylonImports.push('usePages') + pylonConfigPlugins.push('usePages()') + } + + let content: string = '' + + // Add imports + content += `import {${pylonImports.join(', ')}} from '@getcronit/pylon'\n\n` + + if (runtime === 'node') { + content += `import {serve} from '@hono/node-server'\n` + } + + content += '\n\n' + + // Add graphql + content += `export const graphql = { + Query: { + hello: () => { + return 'Hello, world!' + } + }, + Mutation: {} +}` + + content += '\n\n' + + if (runtime === 'bun' || runtime === 'cf-workers') { + content += `export default app` + } else if (runtime === 'node') { + content += `serve(app, info => { + console.log(\`Server running at \${info.port}\`) +})` + } else if (runtime === 'deno') { + content += `Deno.serve({port: 3000}, app.fetch) +` + } + + content += '\n\n' + + content += `export const config: PylonConfig = { + plugins: [${pylonConfigPlugins.join(', ')}] +}` + + return content +} + +const makePylonDefinition = async (runtime: Runtime, features: Feature[]) => { + let data = `import '@getcronit/pylon' + +declare module '@getcronit/pylon' { + interface Bindings {} + + interface Variables {} +} + + +` + + if (features.includes('pages')) { + data += `import {useQuery} from './.pylon/client' + +declare module '@getcronit/pylon/pages' { + interface PageData extends ReturnType {} +}` + } + + return data +} + +const makeTsConfig = async (runtime: Runtime, features: Feature[]) => { + const data: any = { + extends: '@getcronit/pylon/tsconfig.pylon.json', + include: ['pylon.d.ts', 'src/**/*.ts'] + } + + if (runtime === 'cf-workers') { + data.include.push('worker-configuration.d.ts') + } + + if (features.includes('pages')) { + data.compilerOptions = { + baseUrl: '.', + paths: { + '@/*': ['./*'] + }, + jsx: 'react-jsx' // support JSX + } + + data.include.push('pages', 'components', '.pylon') + } + + return JSON.stringify(data, null, 2) +} + +const injectPagesFeatureFiles = async ( + files: { + path: string + content: string + }[] +) => { + const pagesFiles = [ + { + path: 'pages/layout.tsx', + content: `import '../globals.css' + +export default function RootLayout({children}: {children: React.ReactNode}) { + return ( + + {children} + + ) +} +` + }, + { + path: 'pages/page.tsx', + content: `import { Button } from '@/components/ui/button' +import { PageProps } from '@getcronit/pylon/pages' + +const Page: React.FC = props => { + return ( +
+ {props.data.hello} + +
+ ) +} + +export default Page +` + }, + { + path: 'globals.css', + content: `@import 'tailwindcss'; + +@plugin 'tailwindcss-animate'; + +@custom-variant dark (&:is(.dark *)); + +@theme { + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + + --color-chart-1: hsl(var(--chart-1)); + --color-chart-2: hsl(var(--chart-2)); + --color-chart-3: hsl(var(--chart-3)); + --color-chart-4: hsl(var(--chart-4)); + --color-chart-5: hsl(var(--chart-5)); + + --color-sidebar: hsl(var(--sidebar-background)); + --color-sidebar-foreground: hsl(var(--sidebar-foreground)); + --color-sidebar-primary: hsl(var(--sidebar-primary)); + --color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground)); + --color-sidebar-accent: hsl(var(--sidebar-accent)); + --color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground)); + --color-sidebar-border: hsl(var(--sidebar-border)); + --color-sidebar-ring: hsl(var(--sidebar-ring)); + + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); + + --animate-accordion-down: accordion-down 0.2s ease-out; + --animate-accordion-up: accordion-up 0.2s ease-out; + + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } + } + @keyframes accordion-up { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } + } +} + +/* + The default border color has changed to \`currentColor\` in Tailwind CSS v4, + so we've added these compatibility styles to make sure everything still + looks the same as it did with Tailwind CSS v3. + + If we ever want to remove these styles, we need to add an explicit border + color utility to any element that depends on these defaults. +*/ +@layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentColor); + } +} + +@layer utilities { + body { + font-family: Arial, Helvetica, sans-serif; + } +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + } + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + +/* + ---break--- +*/ + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} +` + }, + { + path: 'postcss.config.js', + content: `import tailwindPostCss from '@tailwindcss/postcss' + +export default { + plugins: [tailwindPostCss] +} +` + }, + { + path: 'components.json', + content: `{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +}` + }, + { + path: 'lib/utils.ts', + content: `import {clsx, type ClassValue} from 'clsx' +import {twMerge} from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +}` + }, + { + path: 'components/ui/button.tsx', + content: `import * as React from 'react' +import {Slot} from '@radix-ui/react-slot' +import {cva, type VariantProps} from 'class-variance-authority' + +import {cn} from '@/lib/utils' + +const buttonVariants = cva( + "inline-flexxx items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 focus-visible:ring-4 focus-visible:outline-1 aria-invalid:focus-visible:ring-0", + { + variants: { + variant: { + default: + 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90', + outline: + 'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground', + secondary: + 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline' + }, + size: { + default: 'h-9 px-4 py-2 has-[>svg]:px-3', + sm: 'h-8 rounded-md px-3 has-[>svg]:px-2.5', + lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', + icon: 'size-9' + } + }, + defaultVariants: { + variant: 'default', + size: 'default' + } + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<'button'> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : 'button' + + return ( + + ) +} + +export {Button, buttonVariants} +` + } + ] + + files.push(...pagesFiles) + + // Overwrite the package.json file and add the necessary dependencies + + const packageJsonFile = files.find(file => file.path === 'package.json') + + if (packageJsonFile) { + const packageJson = JSON.parse(packageJsonFile.content) + + packageJson.dependencies = { + ...packageJson.dependencies, + '@gqty/react': '^3.1.0', + gqty: '^3.4.0', + '@radix-ui/react-slot': '^1.1.2', + 'class-variance-authority': '^0.7.1', + clsx: '^2.1.1', + 'lucide-react': '^0.474.0', + react: '^19.0.0', + 'react-dom': '^19.0.0', + 'tailwind-merge': '^3.0.1', + tailwindcss: '^4.0.4', + 'tailwindcss-animate': '^1.0.7' + } + + packageJson.devDependencies = { + ...packageJson.devDependencies, + '@tailwindcss/postcss': '^4.0.6', + '@types/react': '^19.0.8' + } + + packageJsonFile.content = JSON.stringify(packageJson, null, 2) + } + + return files +} + +const injectVariablesInContent = ( + content: string, + variables: Record +) => { + let result = content + + Object.entries(variables).forEach(([key, value]) => { + result = result.replaceAll(key, value) + }) + + return result +} + +export const createDirectory = async (options: CreateDirectoryOptions) => { + const {destination, runtime, features} = options + + let runtimeFiles = files.ALL.concat(files[runtime] || []).filter(file => { + if (!file.specificRuntimes) { + return true + } + + return file.specificRuntimes.includes(runtime) + }) + + const indexFile = makeIndexFile(runtime, features) + const tsConfig = await makeTsConfig(runtime, features) + const pylonDefinition = await makePylonDefinition(runtime, features) + + runtimeFiles.push( + { + path: 'tsconfig.json', + content: tsConfig + }, + { + path: 'pylon.d.ts', + content: pylonDefinition + }, + { + path: 'src/index.ts', + content: indexFile + } + ) + + if (features.includes('pages')) { + runtimeFiles = await injectPagesFeatureFiles(runtimeFiles) + } + + for (const file of runtimeFiles) { + const filePath = path.join(destination, file.path) + + await fs.mkdir(path.dirname(filePath), {recursive: true}) + await fs.writeFile( + filePath, + injectVariablesInContent(file.content, options.variables) + ) + } +} diff --git a/packages/create-pylon/src/detect-pm.ts b/packages/create-pylon/src/detect-pm.ts deleted file mode 100644 index 82e476c..0000000 --- a/packages/create-pylon/src/detect-pm.ts +++ /dev/null @@ -1,145 +0,0 @@ -import * as fs from 'node:fs' -import * as path from 'node:path' -import process from 'node:process' -import {execSync} from 'node:child_process' -import {consola} from 'consola' - -// Helper function to check if a command exists -function isCommandAvailable(command: string): boolean { - try { - execSync(`${command} --version`, {stdio: 'ignore'}) - return true - } catch (e) { - console.error(e) - return false - } -} - -// Detect Bun -function isBun(): boolean { - // @ts-ignore: Bun may not be defined - return typeof Bun !== 'undefined' && isCommandAvailable('bun') -} - -// Detect npm -function isNpm(): boolean { - return process.env.npm_execpath?.includes('npm') ?? false -} - -// Detect Yarn -function isYarn(): boolean { - return process.env.npm_execpath?.includes('yarn') ?? false -} - -// Detect Deno -function isDeno(): boolean { - // @ts-ignore: Deno may not be defined - return typeof Deno !== 'undefined' && isCommandAvailable('deno') -} - -// Detect pnpm -function isPnpm(): boolean { - return process.env.npm_execpath?.includes('pnpm') ?? false -} - -// Detect based on lock files -function detectByLockFiles(cwd: string): PackageManager | null { - if (fs.existsSync(path.join(cwd, 'bun.lockb'))) { - return 'bun' - } - if (fs.existsSync(path.join(cwd, 'package-lock.json'))) { - return 'npm' - } - if (fs.existsSync(path.join(cwd, 'yarn.lock'))) { - return 'yarn' - } - if ( - fs.existsSync(path.join(cwd, 'deno.json')) || - fs.existsSync(path.join(cwd, 'deno.lock')) - ) { - return 'deno' - } - if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))) { - return 'pnpm' - } - return null -} - -export type PackageManager = - | 'bun' - | 'npm' - | 'yarn' - | 'pnpm' - | 'deno' - | 'unknown' - -// Main detection function -export function detectPackageManager({ - preferredPm, - cwd = process.cwd() -}: { - preferredPm?: PackageManager - cwd?: string -}): PackageManager { - // Check the preferred package manager first - if (preferredPm && isCommandAvailable(preferredPm)) { - return preferredPm - } - - // Proceed with detection logic - if (isBun()) { - return 'bun' - } - if (isNpm()) { - return 'npm' - } - if (isPnpm()) { - return 'pnpm' - } - if (isDeno()) { - return 'deno' - } - if (isYarn()) { - return 'yarn' - } - - // Fallback to lock file detection - const lockFileDetection = detectByLockFiles(cwd) - if (lockFileDetection) { - consola.info(`Detected package manager by lock file: ${lockFileDetection}`) - if (isCommandAvailable(lockFileDetection)) { - return lockFileDetection - } else { - consola.warn( - `Lock file detected, but ${lockFileDetection} is not installed.` - ) - } - } - - return 'unknown' -} - -type PackageManagerScript = - | 'bun' - | 'npm run' - | 'yarn' - | 'pnpm run' - | 'deno task' - -// Run script detection -export function getRunScript(pm: PackageManager): PackageManagerScript { - switch (pm) { - case 'bun': - return 'bun' - case 'npm': - return 'npm run' - case 'yarn': - return 'yarn' - case 'pnpm': - return 'pnpm run' - case 'deno': - return 'deno task' - default: - throw new Error('Unknown package manager') - } -} diff --git a/packages/create-pylon/src/index.ts b/packages/create-pylon/src/index.ts index 28a572f..35f208d 100644 --- a/packages/create-pylon/src/index.ts +++ b/packages/create-pylon/src/index.ts @@ -1,274 +1,24 @@ #!/usr/bin/env node -import {Option, program, type Command} from 'commander' -import {consola} from 'consola' -import {input, select, confirm} from '@inquirer/prompts' -import path from 'path' import chalk from 'chalk' +import {Option, program, type Command} from 'commander' +import consola from 'consola' import * as fs from 'fs' +import path from 'path' -import * as telemetry from '@getcronit/pylon-telemetry' - -import {fileURLToPath} from 'url' import {dirname} from 'path' +import {fileURLToPath} from 'url' -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) - -const version = (() => { - return JSON.parse( - fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8') - ).version as string -})() - -function mkdirp(dir: string) { - try { - fs.mkdirSync(dir, {recursive: true}) - } catch (e) { - if (e instanceof Error) { - if ('code' in e && e.code === 'EEXIST') return - } - throw e - } -} - -const runtimes: { - key: string - name: string - website: string - templates?: string[] -}[] = [ - { - key: 'bun', - name: 'Bun.js', - website: 'https://bunjs.dev', - templates: ['default'] - }, - { - key: 'node', - name: 'Node.js', - website: 'https://nodejs.org', - templates: ['default'] - }, - { - key: 'cf-workers', - name: 'Cloudflare Workers', - website: 'https://workers.cloudflare.com', - templates: ['default'] - }, - { - key: 'deno', - name: 'Deno', - website: 'https://deno.land', - templates: ['default'] - } -] - -const templates: { - key: string - name: string - description: string -}[] = [ - { - key: 'default', - name: 'Default', - description: 'Default template' - }, - { - key: 'database', - name: 'Database (Prisma)', - description: 'Template with Prisma ORM' - } -] - -const injectVariablesInContent = ( - content: string, - variables: Record -) => { - let result = content - - Object.entries(variables).forEach(([key, value]) => { - result = result.replaceAll(key, value) - }) - - return result -} -const readdirFilesSyncRecursive = (dir: string): string[] => { - const run = (dir: string): string[] => { - const result: string[] = [] - - const files = fs.readdirSync(dir) - - files.forEach(file => { - const filePath = path.join(dir, file) - - if (fs.statSync(filePath).isDirectory()) { - result.push(...run(filePath)) - } - - // Only add files - if (fs.statSync(filePath).isFile()) { - result.push(filePath) - } - }) - - return result - } - - return run(dir).map(file => { - return file.replace(dir, '.') - }) -} - -const createTemplate = async (options: { - name: string - runtime: string - template: string - target: string -}) => { - const {runtime, template, target} = options - - const runtimeName = runtimes.find(({key}) => key === runtime)?.name - const templateName = templates.find(({key}) => key === template)?.name - - if (!runtimeName) { - throw new Error(`Invalid runtime: ${runtime}`) - } - - if (!templateName) { - throw new Error(`Invalid template: ${template}`) - } - - // The templates are stored in the `templates` directory - const sharedTemplateDir = path.join(__dirname, '..', 'templates', 'shared') - - if (!fs.existsSync(sharedTemplateDir)) { - throw new Error(`Shared templates not found: ${sharedTemplateDir}`) - } - - const templateDir = path.join(__dirname, '..', 'templates', runtime, template) - - if (!fs.existsSync(templateDir)) { - throw new Error(`Template not found: ${templateDir}`) - } - - // The target directory is already created - const targetDirectoryPath = path.join(process.cwd(), target) - - consola.start(`Creating pylon in ${targetDirectoryPath}`) - - const inject = (content: string) => { - return injectVariablesInContent(content, { - __PYLON_NAME__: options.name - }) - } - - // Copy the shared template files - readdirFilesSyncRecursive(sharedTemplateDir).forEach(file => { - const source = path.join(sharedTemplateDir, file) - let target = path.join(targetDirectoryPath, file) - - // Create folder recursively and copy file - - const targetDir = path.dirname(target) - - // Skip the .github/workflows directory for cf-workers runtime - if ( - runtime === 'cf-workers' && - source.includes('.github/workflows/publish.yaml') - ) { - return - } - - if (!fs.existsSync(targetDir)) { - fs.mkdirSync(targetDir, {recursive: true}) - } - - // If the target ends with `.example`, remove the suffix. - // This is useful for `.gitignore.example` files because they are not published in - // the `create-pylon` package when named `.gitignore`. - if (target.endsWith('.example')) { - target = target.replace('.example', '') - } - - const injectedContent = inject(fs.readFileSync(source, 'utf-8')) - - fs.writeFileSync(target, injectedContent) - }) - - // Copy the runtime specific template files - readdirFilesSyncRecursive(templateDir).forEach(file => { - const source = path.join(templateDir, file) - let target = path.join(targetDirectoryPath, file) - - // Create folder recursively and copy file - const targetDir = path.dirname(target) - - if (!fs.existsSync(targetDir)) { - fs.mkdirSync(targetDir, {recursive: true}) - } - - // If the target ends with `.example`, remove the suffix. - // This is useful for `.gitignore.example` files because they are not published in - // the `create-pylon` package when named `.gitignore`. - if (target.endsWith('.example')) { - target = target.replace('.example', '') - } - - const injectedContent = inject(fs.readFileSync(source, 'utf-8')) - - fs.writeFileSync(target, injectedContent) - }) - - consola.success(`Pylon created`) -} - -import {spawnSync} from 'child_process' -import {detectPackageManager, getRunScript, PackageManager} from './detect-pm' - -const installDependencies = async (args: { - target: string - packageManager: PackageManager -}) => { - const target = path.resolve(args.target) - const packageManager = args.packageManager - - let command = '' - - switch (packageManager) { - case 'yarn': - command = 'yarn' - break - case 'npm': - command = 'npm install' - break - case 'pnpm': - command = 'pnpm install' - break - case 'bun': - command = 'bun install' - break - case 'deno': - command = 'deno install' - break - default: - throw new Error(`Invalid package manager: ${packageManager}`) - } - - consola.start(`Installing dependencies using ${packageManager}`) - - const proc = spawnSync(command, { - cwd: target, - shell: true, - stdio: 'inherit' - }) - - if (proc.status !== 0) { - throw new Error(`Failed to install dependencies`) - } +import {createDirectory, features, runtimes} from './create-directory' +import { + detectPackageManager, + getRunScript, + installPackage, + PackageManager +} from './install-pkg' +import {analytics, distinctId} from './analytics' - consola.success(`Dependencies installed`) -} +import {version} from '../package.json' program .name('create-pylon') @@ -280,38 +30,21 @@ program runtimes.map(({key}) => key) ) ) - .addOption(new Option('-t, --template