diff --git a/.gitignore b/.gitignore index 877f6c1..d3cb9c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,57 @@ -node_modules -.npmrc +# ---------------------------- +# Dependencies +# ---------------------------- +node_modules/ + +# ---------------------------- +# Build Output +# ---------------------------- +dist/ +*.tsbuildinfo + +# ---------------------------- +# Logs +# ---------------------------- +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +*.log + +# ---------------------------- +# Environment Files +# ---------------------------- +.env +.env.local +.env.*.local + +# ---------------------------- +# Coverage / Testing +# ---------------------------- +coverage/ +.nyc_output/ + +# ---------------------------- +# IDE / Editor +# ---------------------------- +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store + +# ---------------------------- +# OS +# ---------------------------- +Thumbs.db .DS_Store + +# ---------------------------- +# npm pack artifacts +# ---------------------------- +*.tgz + +# ---------------------------- +# Local linking artifacts +# ---------------------------- +.npmrc diff --git a/index.js b/index.js deleted file mode 100755 index cb304af..0000000 --- a/index.js +++ /dev/null @@ -1,520 +0,0 @@ -#!/usr/bin/env node -import fs from "fs"; -import path from "path"; -import { execSync } from "child_process"; -import { pipeline } from "stream"; -import { promisify } from "util"; -import { createWriteStream } from "fs"; -import AdmZip from "adm-zip"; -import { randomBytes } from "crypto"; -import net from "net"; - -const streamPipeline = promisify(pipeline); - -const MIN_NODE_MAJOR = 18; -const nodeMajor = Number(process.versions.node.split(".")[0]); - -function ensureExecutable(filePath) { - if (fs.existsSync(filePath)) { - fs.chmodSync(filePath, 0o755); - } -} - -function isPortAvailable(port) { - return new Promise((resolve) => { - const server = net - .createServer() - .once("error", () => resolve(false)) - .once("listening", () => { - server.close(); - resolve(true); - }) - .listen(port); - }); -} - -function printHelp() { - console.log(` -create-seamless - -Scaffold a local Seamless Auth development environment. - -Usage: - npx create-seamless [project-name] [options] - -Options: - --auth Include the Seamless Auth server - --api Include the Express API example - --web Include the React web application - --no-git Skip git initialization - - --auth-port Auth server port (default: 5312) - --api-port API server port (default: 3000) - --web-port Web server port (default: 5001) - - -h, --help Show this help message - -If no component flags are provided, all components are included. - -Docs: - https://docs.seamlessauth.com -`); -} - -if (nodeMajor < MIN_NODE_MAJOR) { - console.error(` -❌ Seamless requires Node ${MIN_NODE_MAJOR}+. -You are running Node ${process.versions.node} - -Upgrade at https://nodejs.org -`); - process.exit(1); -} - -const args = process.argv.slice(2); - -if (args.includes("-h") || args.includes("--help")) { - printHelp(); - process.exit(0); -} -const projectName = args.find((a) => !a.startsWith("--")) ?? "seamless-app"; - -const hasFlag = (flag) => args.includes(`--${flag}`); -const getFlag = (flag, fallback) => { - const i = args.indexOf(`--${flag}`); - return i !== -1 ? args[i + 1] : fallback; -}; - -const includeAuth = hasFlag("auth"); -const includeWeb = hasFlag("web"); -const includeApi = hasFlag("api"); -const skipGit = hasFlag("no-git"); - -const authPort = getFlag("auth-port", "5312"); -const apiPort = getFlag("api-port", "3000"); -const webPort = getFlag("web-port", "5001"); - -const wantsSomething = includeAuth || includeWeb || includeApi; -const AUTH = wantsSomething ? includeAuth : true; -const WEB = wantsSomething ? includeWeb : true; -const API = wantsSomething ? includeApi : true; - -const REPOS = { - auth: "fells-code/seamless-auth-api", - web: "fells-code/seamless-auth-starter-react", - api: "fells-code/seamless-auth-starter-express", -}; - -const GENERATED_README = (projectName) => `# ${projectName} - -This project was generated with \`create-seamless\` and provides a fully local, -open source authentication stack built on **Seamless Auth**. - -It is designed for development environments where you want: - -- Passwordless authentication -- No hosted dependencies -- No redirects or third-party auth services -- A production-shaped local setup - ---- - -## Project layout - -\`\`\`text -. -├─ auth/ # Seamless Auth open source server -├─ api/ # Backend API server (optional) -├─ web/ # Frontend web application (optional) -├─ Docker-compose.yml # Docker compose for one command spin up of dev environment -└─ README.md -\`\`\` - ---- - -## Running the stack - -### Running with Docker (optional) - -This project includes a Docker Compose configuration that allows you to run the -entire Seamless Auth stack locally with a single command. - -### Requirements - -* Docker -* Docker Compose - -### Start the stack - -From the project root, run: - -\`\`\`bash -docker compose up -\`\`\` - -This will start the following services in development mode: - -* Postgres database -* Seamless Auth server -* API server -* Web UI - -All services are configured with hot reload. Changes to the source code will be -picked up automatically. - -### Access the application - -Once all services are running, open: - -\`\`\` -http://localhost:5001 -\`\`\` - -This is the main entry point for the web application. - -### Stopping the stack - -To stop all services: - -\`\`\`bash -docker compose down -\`\`\` - -This will shut down all containers while preserving the local database volume. - -Open separate terminals and run each service independently. - -### Auth server - -\`\`\`bash -cd auth -npm run dev -\`\`\` - -Default port: \`5312\` - ---- - -### API server - -\`\`\`bash -cd api -npm run dev -\`\`\` - -Default port: \`3000\` - ---- - -### Web application - -\`\`\`bash -cd web -npm run dev -\`\`\` - -Default port: \`5001\` - ---- - -## Documentation - -Full Seamless Auth documentation: - -https://seamlessauth.com/docs - ---- - -## Included open source projects - -- Seamless Auth Server - https://github.com/fells-code/seamless-auth-server - -- Seamless Auth React Starter - https://github.com/fells-code/seamless-auth-starter-react - -- Seamless Auth API Starter - https://github.com/fells-code/seamless-auth-starter-express - ---- - -## License - -This generated project inherits the licenses of the open source components it -includes. - -Review each subproject for its specific license before deploying to production. -`; - -const GENERATED_DOCKER_COMPOSE = ` -services: - db: - image: postgres:16 - container_name: seamless-db - ports: - - "5432:5432" - environment: - POSTGRES_USER: myuser - POSTGRES_PASSWORD: mypassword - POSTGRES_DB: postgres - volumes: - - pgdata:/var/lib/postgresql/data - - ./postgres_init:/docker-entrypoint-initdb.d - healthcheck: - test: ["CMD-SHELL", "pg_isready -U myuser -d postgres"] - interval: 5s - timeout: 5s - retries: 5 - - auth: - container_name: seamless-auth - build: - context: ./auth - dockerfile: Dockerfile.dev - ports: - - "${authPort}:${authPort}" - env_file: - - ./auth/.env - environment: - DB_HOST: db - ISSUER: http://auth:${authPort} - volumes: - - ./auth:/app - - /app/node_modules - depends_on: - db: - condition: service_healthy - - api: - container_name: seamless-api - build: ./api - ports: - - "${apiPort}:${apiPort}" - env_file: - - ./api/.env - environment: - AUTH_SERVER_URL: http://auth:${authPort} - DB_HOST: db - volumes: - - ./api:/app - - /app/node_modules - depends_on: - db: - condition: service_healthy - - web: - container_name: seamless-web - build: ./web - ports: - - "${webPort}:${webPort}" - env_file: - - ./web/.env - volumes: - - ./web:/app - - /app/node_modules - depends_on: - - auth - - api - -volumes: - pgdata: -`; - -function writeEnv(dir, values) { - const env = Object.entries(values) - .map(([k, v]) => `${k}=${v}`) - .join("\n"); - fs.writeFileSync(path.join(dir, ".env"), env + "\n"); -} - -async function downloadRepo(repo, dest) { - const url = `https://codeload.github.com/${repo}/zip/refs/heads/main`; - const res = await fetch(url); - if (!res.ok || !res.body) { - throw new Error(`Failed to download ${repo}`); - } - - const zipPath = path.join(dest, "_repo.zip"); - await streamPipeline(res.body, createWriteStream(zipPath)); - - const zip = new AdmZip(zipPath); - zip.extractAllTo(dest, true); - fs.unlinkSync(zipPath); - - const inner = fs.readdirSync(dest).find((f) => f.endsWith("-main")); - if (!inner) throw new Error("Unexpected repo structure"); - - const innerPath = path.join(dest, inner); - for (const file of fs.readdirSync(innerPath)) { - fs.renameSync(path.join(innerPath, file), path.join(dest, file)); - } - fs.rmdirSync(innerPath); -} - -(async () => { - const root = path.join(process.cwd(), projectName); - - if (fs.existsSync(root)) { - console.error("❌ Directory already exists."); - process.exit(1); - } - - fs.mkdirSync(root); - - console.log(`\nCreating Seamless project: ${projectName}\n`); - const API_SERVICE_TOKEN = randomBytes(32).toString("hex"); - - let dbHostPort = "5432"; - - if (!(await isPortAvailable(5432))) { - dbHostPort = "5433"; - console.log(` -⚠️ Port 5432 is already in use on your machine. - Using 5433 for Docker Postgres instead. -`); - } - - if (AUTH) { - const dir = path.join(root, "auth"); - fs.mkdirSync(dir); - console.log("Fetching Seamless Auth OSS..."); - await downloadRepo(REPOS.auth, dir); - - writeEnv(dir, { - PORT: authPort, - NODE_ENV: "development", - - VERSION: "1.0.0", - APP_NAME: "Seamless Auth Example", - APP_ID: "local-dev", - APP_ORIGIN: `http://localhost:${apiPort}`, - ISSUER: `http://localhost:${authPort}`, - - AUTH_MODE: "server", - DEMO: "true", - - DEFAULT_ROLES: "user,betaUser", - AVAILABLE_ROLES: "user,admin,betaUser,team", - - DB_LOGGING: "false", - DB_HOST: "localhost", - DB_PORT: dbHostPort, - DB_NAME: "seamless_auth", - DB_USER: "myuser", - DB_PASSWORD: "mypassword", - - ACCESS_TOKEN_TTL: "30m", - REFRESH_TOKEN_TTL: "1h", - RATE_LIMIT: "100", - DELAY_AFTER: "50", - - API_SERVICE_TOKEN: API_SERVICE_TOKEN, - - JWKS_ACTIVE_KID: "dev-main", - - RPID: "localhost", - ORIGINS: `http://localhost:${webPort}`, - }); - } - - if (API) { - const dir = path.join(root, "api"); - fs.mkdirSync(dir); - console.log("Fetching API starter..."); - await downloadRepo(REPOS.api, dir); - - writeEnv(dir, { - AUTH_SERVER_URL: `http://localhost:${authPort}`, - APP_ORIGIN: `http://localhost:${apiPort}`, - UI_ORIGIN: `http://localhost:${webPort}`, - COOKIE_SIGNING_KEY: randomBytes(32).toString("hex"), - API_SERVICE_TOKEN: API_SERVICE_TOKEN, - JWKS_KID: "dev-main", - - DB_HOST: "localhost", - DB_PORT: dbHostPort, - DB_NAME: "seamless_api", - DB_USER: "myuser", - DB_PASSWORD: "mypassword", - - SQL_LOGGING: "false", - }); - } - - if (WEB) { - const dir = path.join(root, "web"); - fs.mkdirSync(dir); - console.log("Fetching Web starter..."); - await downloadRepo(REPOS.web, dir); - - writeEnv(dir, { - VITE_AUTH_SERVER_URL: `http://localhost:${apiPort}/`, - VITE_API_URL: `http://localhost:${apiPort}/`, - PORT: webPort, - }); - } - - fs.writeFileSync(path.join(root, "README.md"), GENERATED_README(projectName)); - - if (!skipGit) { - execSync("git init", { cwd: root }); - } - - if (AUTH && API && WEB) { - const compose = GENERATED_DOCKER_COMPOSE.replace( - '"5432:5432"', - `"${dbHostPort}:5432"`, - ); - - fs.writeFileSync(path.join(root, "docker-compose.yml"), compose); - } - - if (AUTH) { - const authScriptPath = path.join(root, "auth", "validateEnvs.sh"); - ensureExecutable(authScriptPath); - } - - const pgDir = path.join(root, "postgres_init"); - fs.mkdirSync(pgDir); - fs.writeFileSync( - path.join(pgDir, "init.sql"), - ` -CREATE DATABASE seamless_auth; -CREATE DATABASE seamless_api; - `, - ); - - console.log(` -╔════════════════════════════════════════╗ -║ S E A M L E S S ║ -╚════════════════════════════════════════╝ - -Your local auth stack is ready. - -Start development: - - cd ${projectName} - - # terminal 1 - cd auth && npm i && npm run dev - - # terminal 2 - cd api && npm i && npm run dev - - # terminal 3 - cd web && npm i && npm run dev - - or if using Docker - - docker compose up - - If you already have Postgres running locally, - the generator may map Docker to port 5433 instead. - -Docs: https://docs.seamlessauth.com/docs -Happy hacking. 🚀 -`); -})().catch((err) => { - console.error("\n❌ Error:", err.message); - process.exit(1); -}); diff --git a/package-lock.json b/package-lock.json index 2e188c6..b78e393 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,26 @@ { "name": "create-seamless", - "version": "0.0.11", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "create-seamless", - "version": "0.0.11", + "version": "0.1.0", "license": "AGPL-3.0-only", "dependencies": { - "adm-zip": "^0.5.16" + "@clack/prompts": "^1.0.1", + "adm-zip": "^0.5.16", + "kleur": "^4.1.5" }, "bin": { - "create-seamlessauth": "index.js" + "create-seamless": "dist/index.js" }, "devDependencies": { "@types/adm-zip": "^0.5.7", - "@types/node": "^24.10.1" + "@types/node": "^24.10.13", + "tsx": "^4.21.0", + "typescript": "^5.9.3" } }, "node_modules/.pnpm/degit@2.8.4/node_modules/degit": { @@ -54,6 +58,469 @@ "node": ">=8.0.0" } }, + "node_modules/@clack/core": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.1.tgz", + "integrity": "sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.0.1.tgz", + "integrity": "sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.0.1", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@types/adm-zip": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", @@ -65,9 +532,9 @@ } }, "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "version": "24.10.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", + "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, "license": "MIT", "dependencies": { @@ -83,6 +550,141 @@ "node": ">=12.0" } }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", diff --git a/package.json b/package.json index 56214fd..3b1112a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "create-seamless", - "version": "0.0.11", + "version": "0.1.0", "description": "The starter script for Seamless Auth", "homepage": "https://github.com/fells-code/create-seamless#readme", "bugs": { @@ -12,25 +12,31 @@ }, "license": "AGPL-3.0-only", "author": "Fells Code, LLC", + "bin": { + "create-seamless": "dist/index.js" + }, "type": "module", + "scripts": { + "build": "tsc", + "dev": "tsx src/index.ts", + "clean": "rm -rf dist" + }, "main": "index.js", "files": [ - "index.js", "dist", + "templates", "README.md", "LICENSE" ], - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "bin": { - "create-seamlessauth": "index.js" - }, "dependencies": { - "adm-zip": "^0.5.16" + "@clack/prompts": "^1.0.1", + "adm-zip": "^0.5.16", + "kleur": "^4.1.5" }, "devDependencies": { "@types/adm-zip": "^0.5.7", - "@types/node": "^24.10.1" + "@types/node": "^24.10.13", + "tsx": "^4.21.0", + "typescript": "^5.9.3" } -} \ No newline at end of file +} diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..c58881d --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,86 @@ +import path from "path"; +import fs from "fs"; +import { runProjectSetupPrompts } from "../prompts/projectSetup.js"; +import { generateReactStarter } from "../generators/frontend/react.js"; +import { generateExpressStarter } from "../generators/backend/express.js"; +import { generateAuthServer } from "../generators/auth/auth.js"; +import { configureApiEnv, configureWebEnv } from "../core/configure.js"; +import { + configureAuthLocalEnv, + generateDockerCompose, +} from "../generators/docker/docker.js"; +import { printSuccessOutput } from "../core/output.js"; + +export async function runCLI(projectName?: string) { + const cwd = process.cwd(); + + let root = cwd; + + if (projectName) { + root = path.join(cwd, projectName); + + if (fs.existsSync(root)) { + throw new Error(`Directory already exists: ${projectName}`); + } + + fs.mkdirSync(root); + console.log(`Creating project in ${root}`); + } + + const files = fs.readdirSync(root); + + const isEmpty = files.length === 0; + + if (!isEmpty) { + console.log("Existing project detected."); + console.log("Integration flow coming next."); + return; + } + + const answers = await runProjectSetupPrompts(); + + if (answers.web && answers.webFramework === "react") { + await generateReactStarter({ root }); + } + + if (answers.api && answers.apiFramework === "express") { + await generateExpressStarter({ root }); + } + + let sharedConfig: any = {}; + + if (answers.authMode === "local") { + await generateAuthServer({ root }, "local"); + + sharedConfig = await configureAuthLocalEnv(root); + } + + if (answers.useDocker) { + const dockerShared = await generateDockerCompose(root, { + authMode: answers.authMode, + includeApi: answers.api, + includeWeb: answers.web, + }); + + if (answers.authMode === "docker") { + sharedConfig = dockerShared; + } + } + + if (answers.api) { + configureApiEnv(root, sharedConfig); + } + + if (answers.web) { + configureWebEnv(root); + } + + printSuccessOutput({ + projectName, + root, + webFramework: answers.webFramework, + apiFramework: answers.apiFramework, + authMode: answers.authMode, + useDocker: answers.useDocker, + }); +} diff --git a/src/core/configure.ts b/src/core/configure.ts new file mode 100644 index 0000000..d16660a --- /dev/null +++ b/src/core/configure.ts @@ -0,0 +1,32 @@ +import path from "path"; +import fs from "fs"; +import { parseEnv, writeEnv } from "./env.js"; +import { generateSecret } from "./secrets.js"; + +export function configureApiEnv(root: string, shared: any) { + const apiEnvPath = path.join(root, "api", ".env"); + + if (!fs.existsSync(apiEnvPath)) return; + + const env = parseEnv(apiEnvPath); + + env.AUTH_SERVER_URL = "http://localhost:5312"; + env.API_SERVICE_TOKEN = shared.apiToken; + env.JWKS_KID = shared.kid; + env.COOKIE_SIGNING_KEY = generateSecret(32); + + writeEnv(apiEnvPath, env); +} + +export function configureWebEnv(root: string) { + const webEnvPath = path.join(root, "web", ".env"); + + if (!fs.existsSync(webEnvPath)) return; + + const env = parseEnv(webEnvPath); + + env.VITE_AUTH_SERVER_URL = "http://localhost:5312"; + env.VITE_API_URL = "http://localhost:3000"; + + writeEnv(webEnvPath, env); +} diff --git a/src/core/env.ts b/src/core/env.ts new file mode 100644 index 0000000..a97ed76 --- /dev/null +++ b/src/core/env.ts @@ -0,0 +1,46 @@ +import fs from "fs"; + +export function parseEnv(filePath: string): Record { + const content = fs.readFileSync(filePath, "utf-8"); + + const lines = content.split("\n"); + + const env: Record = {}; + + for (const line of lines) { + if (!line || line.startsWith("#")) continue; + + const [key, ...rest] = line.split("="); + if (!key) continue; + + env[key.trim()] = rest.join("=").trim(); + } + + return env; +} + +export function parseEnvString(content: string): Record { + const lines = content.split("\n"); + + const env: Record = {}; + + for (const line of lines) { + if (!line || line.startsWith("#")) continue; + + const [key, ...rest] = line.split("="); + + if (!key) continue; + + env[key.trim()] = rest.join("=").trim(); + } + + return env; +} + +export function writeEnv(filePath: string, env: Record) { + const content = Object.entries(env) + .map(([k, v]) => `${k}=${v}`) + .join("\n"); + + fs.writeFileSync(filePath, content + "\n"); +} diff --git a/src/core/exec.ts b/src/core/exec.ts new file mode 100644 index 0000000..901e492 --- /dev/null +++ b/src/core/exec.ts @@ -0,0 +1,20 @@ +import { spawn } from "child_process"; + +export function runCommand( + command: string, + args: string[], + cwd: string, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: "inherit", + cwd, + shell: true, + }); + + child.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`${command} failed`)); + }); + }); +} diff --git a/src/core/fetch.ts b/src/core/fetch.ts new file mode 100644 index 0000000..4e076fe --- /dev/null +++ b/src/core/fetch.ts @@ -0,0 +1,12 @@ +export async function fetchEnvExample(): Promise { + const url = + "https://raw.githubusercontent.com/fells-code/seamless-auth-api/main/.env.example"; + + const res = await fetch(url); + + if (!res.ok) { + throw new Error("Failed to fetch auth env.example"); + } + + return await res.text(); +} diff --git a/src/core/help.ts b/src/core/help.ts new file mode 100644 index 0000000..e03284f --- /dev/null +++ b/src/core/help.ts @@ -0,0 +1,55 @@ +export function printHelp() { + console.log(` +create-seamless + +Seamless Auth CLI — scaffold and integrate passwordless authentication. + +──────────────────────────────────────────── + +USAGE + + npx create-seamless + npx create-seamless + npx create-seamless -h + npx create-seamless --help + +──────────────────────────────────────────── + +BEHAVIOR + + npx create-seamless + + Without a name: + • If directory is empty → create a new project + + npx create-seamless + + With a name: + • Creates a new directory + +──────────────────────────────────────────── + +WHAT YOU CAN BUILD + + • A web application starter with Seamless Auth + • An API server starter with Seamless Auth + • SeamlessAuth server (local or Docker) + +──────────────────────────────────────────── + +EXAMPLES + + npx create-seamless + → Interactive setup in current directory + + npx create-seamless my-app + → Create new project in ./my-app + +──────────────────────────────────────────── + +DOCS + + https://docs.seamlessauth.com + +`); +} diff --git a/src/core/inspect.ts b/src/core/inspect.ts new file mode 100644 index 0000000..a05b091 --- /dev/null +++ b/src/core/inspect.ts @@ -0,0 +1,16 @@ +import fs from "fs"; +import path from "path"; +import { detectPackageManager } from "./packageManager.js"; + +export async function inspectProject(root: string) { + const hasPackageJson = fs.existsSync(path.join(root, "package.json")); + + return { + root, + packageManager: detectPackageManager(root), + detected: { + packageJson: hasPackageJson, + anything: hasPackageJson, + }, + }; +} diff --git a/src/core/jwks.ts b/src/core/jwks.ts new file mode 100644 index 0000000..fed23b0 --- /dev/null +++ b/src/core/jwks.ts @@ -0,0 +1,20 @@ +import { generateKeyPairSync } from "crypto"; + +export function generateJWKS() { + const { publicKey, privateKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { + type: "spki", + format: "pem", + }, + privateKeyEncoding: { + type: "pkcs8", + format: "pem", + }, + }); + + return { + publicKey, + privateKey, + }; +} diff --git a/src/core/output.ts b/src/core/output.ts new file mode 100644 index 0000000..9ed688f --- /dev/null +++ b/src/core/output.ts @@ -0,0 +1,130 @@ +import kleur from "kleur"; + +export function printSuccessOutput(config: { + projectName?: string; + root: string; + webFramework: string | null; + apiFramework: string | null; + authMode: "local" | "docker"; + useDocker: boolean | symbol; +}) { + const { projectName, webFramework, apiFramework, authMode, useDocker } = + config; + + const title = kleur.bold().cyan("SEAMLESS"); + + console.log(` +╔════════════════════════════════════════╗ +║ ${title} ║ +╚════════════════════════════════════════╝ +`); + + console.log(kleur.green("✔ Your SeamlessAuth project is ready.\n")); + + if (projectName) { + console.log(kleur.dim("Project created in: ") + kleur.bold(projectName)); + console.log(kleur.cyan(`cd ${projectName}\n`)); + } + + console.log(kleur.bold("Project includes:\n")); + + if (webFramework) { + console.log( + " • " + + kleur.white("Web app") + + kleur.dim(` (${formatFramework(webFramework)})`), + ); + } + + if (apiFramework) { + console.log( + " • " + + kleur.white("API server") + + kleur.dim(` (${formatFramework(apiFramework)})`), + ); + } + + console.log( + " • " + + kleur.white("Auth server") + + kleur.dim(authMode === "local" ? " (local source)" : " (Docker image)"), + ); + + console.log(""); + + console.log(kleur.bold("Getting started:\n")); + + if (useDocker) { + console.log(kleur.cyan(" docker compose up\n")); + } else { + if (authMode === "local") { + console.log(kleur.dim("# Auth server")); + + console.log( + kleur.yellow( + " ⚠ Requires a local PostgreSQL instance running on localhost, port 5432\n", + ), + ); + + console.log(" cd auth"); + console.log(" npm install\n"); + + console.log(kleur.dim(" # Initialize database")); + console.log(" npm run db:create"); + console.log(" npm run db:migrate\n"); + + console.log(kleur.dim(" # Start auth server")); + console.log(" npm run dev\n"); + } + + if (apiFramework) { + console.log(kleur.dim("# API server")); + console.log(" cd api && npm install && npm run dev\n"); + } + + if (webFramework) { + console.log(kleur.dim("# Web app")); + console.log(" cd web && npm install && npm run dev\n"); + } + } + + console.log(kleur.bold("Available services:\n")); + + console.log(" Auth: " + kleur.cyan("http://localhost:5312")); + + if (apiFramework) { + console.log(" API: " + kleur.cyan("http://localhost:3000")); + } + + if (webFramework) { + console.log(" Web: " + kleur.cyan("http://localhost:5173")); + } + + console.log(""); + + console.log(kleur.bold("Notes:\n")); + + console.log(kleur.dim(" • Web connects to API automatically")); + console.log(kleur.dim(" • API connects to Auth automatically")); + console.log(kleur.dim(" • All secrets and keys are pre-configured\n")); + + console.log( + kleur.dim("Docs: ") + kleur.cyan("https://docs.seamlessauth.com\n"), + ); + + console.log(kleur.bold().green("Happy hacking. 🚀\n")); +} + +function formatFramework(name: string) { + const map: Record = { + react: "React", + express: "Express", + angular: "Angular", + next: "Next.js", + fastapi: "FastAPI", + fastify: "Fastify", + vue: "Vue", + }; + + return map[name] || name; +} diff --git a/src/core/packageManager.ts b/src/core/packageManager.ts new file mode 100644 index 0000000..f2af457 --- /dev/null +++ b/src/core/packageManager.ts @@ -0,0 +1,10 @@ +import fs from "fs"; +import path from "path"; + +export type PackageManager = "npm" | "pnpm" | "yarn"; + +export function detectPackageManager(root: string): PackageManager { + if (fs.existsSync(path.join(root, "pnpm-lock.yaml"))) return "pnpm"; + if (fs.existsSync(path.join(root, "yarn.lock"))) return "yarn"; + return "npm"; +} diff --git a/src/core/paths.ts b/src/core/paths.ts new file mode 100644 index 0000000..4df1c52 --- /dev/null +++ b/src/core/paths.ts @@ -0,0 +1,9 @@ +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const PROJECT_ROOT = path.resolve(__dirname, "../../"); + +export const TEMPLATE_ROOT = path.join(PROJECT_ROOT, "templates"); diff --git a/src/core/secrets.ts b/src/core/secrets.ts new file mode 100644 index 0000000..09b04f7 --- /dev/null +++ b/src/core/secrets.ts @@ -0,0 +1,9 @@ +import { randomBytes } from "crypto"; + +export function generateSecret(length = 32) { + return randomBytes(length).toString("hex"); +} + +export function generateKid() { + return "dev-" + randomBytes(6).toString("hex"); +} diff --git a/src/generators/auth/auth.ts b/src/generators/auth/auth.ts new file mode 100644 index 0000000..ef8aad5 --- /dev/null +++ b/src/generators/auth/auth.ts @@ -0,0 +1,64 @@ +import fs from "fs"; +import path from "path"; +import { runCommand } from "../../core/exec.js"; +import { writeEnv } from "../../utils/writeEnv.js"; + +const AUTH_REPO = "https://github.com/fells-code/seamless-auth-api"; +const AUTH_PORT = 5312; + +export async function generateAuthServer( + context: any, + mode: "local" | "docker" | Symbol, +) { + const { root } = context; + + if (mode === "local") { + await setupLocalAuth(root); + } else { + await setupDockerAuth(root); + } +} + +async function setupLocalAuth(root: string) { + const authDir = path.join(root, "auth"); + + console.log("Cloning SeamlessAuth server..."); + + await runCommand("git", ["clone", AUTH_REPO, "auth"], root); + + console.log("Writing auth environment..."); + + writeEnv(authDir, { + PORT: AUTH_PORT, + NODE_ENV: "development", + AUTH_MODE: "server", + ISSUER: `http://localhost:${AUTH_PORT}`, + }); + + console.log("Auth server ready in /auth"); +} + +async function setupDockerAuth(root: string) { + console.log("Creating docker-compose for SeamlessAuth..."); + + const dockerCompose = ` +services: + auth: + image: ghcr.io/fells-code/seamless-auth-api:v0.1.2 + container_name: seamless-auth + ports: + - "5312:5312" + environment: + PORT: 5312 + NODE_ENV: development + AUTH_MODE: server + ISSUER: http://localhost:5312 +`; + + fs.writeFileSync( + path.join(root, "docker-compose.yml"), + dockerCompose.trim() + "\n", + ); + + console.log("Docker setup ready."); +} diff --git a/src/generators/backend/express.ts b/src/generators/backend/express.ts new file mode 100644 index 0000000..55c31ab --- /dev/null +++ b/src/generators/backend/express.ts @@ -0,0 +1,24 @@ +import path from "path"; +import { + cloneRepo, + removeGitDir, + copyEnvExample, +} from "../../utils/repoUtils.js"; + +const API_STARTER_REPO = + "https://github.com/fells-code/seamless-auth-starter-express.git"; + +export async function generateExpressStarter(context: { root: string }) { + const { root } = context; + + const apiDir = path.join(root, "api"); + + console.log("Cloning Seamless Auth Express starter..."); + + await cloneRepo(API_STARTER_REPO, apiDir); + + removeGitDir(apiDir); + copyEnvExample(apiDir); + + console.log("API starter ready."); +} diff --git a/src/generators/docker/docker.ts b/src/generators/docker/docker.ts new file mode 100644 index 0000000..c42b172 --- /dev/null +++ b/src/generators/docker/docker.ts @@ -0,0 +1,288 @@ +import fs from "fs"; +import path from "path"; +import { fetchEnvExample } from "../../core/fetch.js"; +import { parseEnv, parseEnvString } from "../../core/env.js"; +import { generateSecret } from "../../core/secrets.js"; +import { generateJWKS } from "../../core/jwks.js"; + +export async function generateDockerCompose( + root: string, + options: { + authMode: "local" | "docker"; + includeApi: boolean | Symbol; + includeWeb: boolean | Symbol; + }, +) { + const { compose, shared } = await buildCompose(options, root); + + fs.writeFileSync( + path.join(root, "docker-compose.yml"), + compose.trim() + "\n", + ); + + console.log("Docker compose created."); + return shared; +} + +async function buildCompose(options: any, root: string) { + const { authMode, includeApi, includeWeb } = options; + + const { service: authBlock, shared } = await authService(authMode, root); + + return { + compose: ` +services: + db: + image: postgres:16 + container_name: seamless-db + ports: + - "5432:5432" + environment: + POSTGRES_USER: myuser + POSTGRES_PASSWORD: mypassword + POSTGRES_DB: postgres + volumes: + - pgdata:/var/lib/postgresql/data + +${authBlock} + +${includeApi ? apiService(shared) : ""} + +${includeWeb ? webService() : ""} + +volumes: + pgdata: +`, + shared, + }; +} + +async function authService(mode: "local" | "docker", root: string) { + if (mode === "local") { + const shared = await configureAuthLocalEnv(root); + + return { + service: ` + auth: + container_name: seamless-auth + build: + context: ./auth + dockerfile: Dockerfile.dev + ports: + - "5312:5312" + env_file: + - ./auth/.env + environment: + DB_HOST: db + ISSUER: http://auth:5312 + volumes: + - ./auth:/app + - /app/node_modules + depends_on: + - db +`, + shared, + }; + } + + return await authServiceDocker(); +} +function apiService(shared: any) { + return ` + api: + container_name: api + build: ./api + ports: + - "3000:3000" + env_file: + - ./api/.env + environment: + AUTH_SERVER_URL: http://auth:5312 + UI_ORIGIN: http://localhost:5173 + DB_HOST: db + API_SERVICE_TOKEN: ${shared.apiToken} + JWKS_KID: ${shared.kid} + volumes: + - ./api:/app + - /app/node_modules + depends_on: + - db + - auth +`; +} + +function webService() { + return ` + web: + container_name: web + build: ./web + ports: + - "5173:5173" + env_file: + - ./web/.env + environment: + VITE_API_URL: http://localhost:3000 + VITE_AUTH_SERVER_URL: http://localhost:3000/ + volumes: + - ./web:/app + - /app/node_modules + depends_on: + - auth + - api +`; +} + +async function authServiceDocker() { + const raw = await fetchEnvExample(); + const parsed = parseEnvString(raw); + + const { env, shared } = buildAuthEnv(parsed, "docker"); + + const envBlock = envToDockerBlock(env); + + return { + service: ` + auth: + image: ghcr.io/fells-code/seamless-auth-api:v0.1.5 + container_name: seamless-auth + ports: + - "5312:5312" + environment: +${envBlock} + depends_on: + - db +`, + shared, + }; +} + +function buildAuthEnv(env: Record, mode: "local" | "docker") { + const apiToken = generateSecret(32); + + env.PORT = "5312"; + env.NODE_ENV = mode === "docker" ? "production" : "development"; + + env.AUTH_MODE = "server"; + env.ISSUER = "http://auth:5312"; + + env.DB_HOST = mode === "docker" ? "db" : "localhost"; + env.DB_PORT = "5432"; + + env.API_SERVICE_TOKEN = apiToken; + + let kid = "main"; + env.JWKS_ACTIVE_KID = kid; + + if (mode === "docker") { + const jwks = buildJWKSConfig(); + + kid = jwks.kid; + + env.SEAMLESS_JWKS_ACTIVE_KID = jwks.kid; + env.JWKS_ACTIVE_KID = jwks.kid; + + env[`SEAMLESS_JWKS_KEY_${jwks.kid}_PRIVATE`] = jwks.privateKey; + env.JWKS_PUBLIC_KEYS = jwks.publicJwksJson; + } + + env.APP_ORIGIN = "http://localhost:3000"; + env.ORIGINS = "http://localhost:5173"; + + return { + env, + shared: { + apiToken, + kid, + }, + }; +} + +export function envToDockerBlock(env: Record) { + return Object.entries(env) + .map(([k, v]) => { + if (v.includes("\n")) { + return ` ${k}: |\n${indentMultiline(v, 8)}`; + } + + return ` ${k}: ${v}`; + }) + .join("\n"); +} + +function indentMultiline(value: string, spaces: number) { + const indent = " ".repeat(spaces); + return value + .split("\n") + .map((line) => `${indent}${line}`) + .join("\n"); +} + +export function buildJWKSConfig() { + const kid = "main"; + + const { publicKey, privateKey } = generateJWKS(); + + return { + kid, + privateKey, + publicKey, + publicJwksJson: JSON.stringify( + { + keys: [ + { + kid, + pem: publicKey, + }, + ], + }, + null, + 2, + ), + }; +} + +export async function configureAuthLocalEnv(root: string) { + const authDir = path.join(root, "auth"); + const envExamplePath = path.join(authDir, ".env.example"); + const envPath = path.join(authDir, ".env"); + + if (!fs.existsSync(envExamplePath)) { + throw new Error(".env.example not found in auth directory"); + } + + const raw = fs.readFileSync(envExamplePath, "utf-8"); + + const parsed = parseEnvString(raw); + + const { env, shared } = buildAuthEnv(parsed, "local"); + + writeEnvFile(envPath, env); + + return shared; +} + +function writeEnvFile(filePath: string, env: Record) { + const content = Object.entries(env) + .map(([k, v]) => { + if (v.includes("\n")) { + return `${k}="${escapeMultiline(v)}"`; + } + return `${k}=${v}`; + }) + .join("\n"); + + fs.writeFileSync(filePath, content + "\n"); +} + +function escapeMultiline(value: string) { + return value.replace(/\n/g, "\\n"); +} + +export function extractSharedFromExistingEnv(root: string) { + const env = parseEnv(path.join(root, "auth", ".env")); + + return { + apiToken: env.API_SERVICE_TOKEN, + kid: env.JWKS_ACTIVE_KID || "main", + }; +} diff --git a/src/generators/frontend/react.ts b/src/generators/frontend/react.ts new file mode 100644 index 0000000..238e431 --- /dev/null +++ b/src/generators/frontend/react.ts @@ -0,0 +1,23 @@ +import path from "path"; +import { + cloneRepo, + removeGitDir, + copyEnvExample, +} from "../../utils/repoUtils.js"; + +const WEB_STARTER_REPO = + "https://github.com/fells-code/seamless-auth-starter-react.git"; + +export async function generateReactStarter(context: { root: string }) { + const { root } = context; + const webDir = path.join(root, "web"); + + console.log("Cloning Seamless Auth React starter..."); + + await cloneRepo(WEB_STARTER_REPO, webDir); + + removeGitDir(webDir); + copyEnvExample(webDir); + + console.log("Web starter ready."); +} diff --git a/src/index.ts b/src/index.ts new file mode 100755 index 0000000..bb846d0 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +import { runCLI } from "./commands/init.js"; +import { printHelp } from "./core/help.js"; + +const args = process.argv.slice(2); + +const firstArg = args[0]; + +async function main() { + if (firstArg === "-h" || firstArg === "--help") { + printHelp(); + return; + } + + await runCLI(firstArg); +} + +main().catch((err) => { + console.error("Error:", err.message); + process.exit(1); +}); diff --git a/src/prompts/projectSetup.ts b/src/prompts/projectSetup.ts new file mode 100644 index 0000000..f90de51 --- /dev/null +++ b/src/prompts/projectSetup.ts @@ -0,0 +1,81 @@ +import { confirm, select, text } from "@clack/prompts"; + +type WebFramework = string | null; +type ApiFramework = string | null; +type AuthMode = "local" | "docker"; + +export async function runProjectSetupPrompts() { + const web = await confirm({ + message: "Do you want to create a web application?", + }); + + let webFramework: WebFramework = null; + + if (web) { + const result = await select({ + message: "Which framework?", + options: [ + { value: "react", label: "React (Vite)" }, + { value: "next", label: "Next.js (coming soon)", disabled: true }, + { value: "vue", label: "Vue (coming soon)", disabled: true }, + { value: "angular", label: "Angular (coming soon)", disabled: true }, + ], + }); + + webFramework = result as WebFramework; + } + + const api = await confirm({ + message: "Do you want to create an API server?", + }); + + let apiFramework: ApiFramework = null; + + if (api) { + const result = await select({ + message: "Which backend?", + options: [ + { value: "express", label: "Express" }, + { value: "next", label: "Next.js (coming soon)", disabled: true }, + { value: "fastify", label: "Fastify (coming soon)", disabled: true }, + { value: "fast-api", label: "FastAPI (coming soon)", disabled: true }, + ], + }); + + apiFramework = result as ApiFramework; + } + + const authMode = (await select({ + message: "How would you like to run SeamlessAuth?", + options: [ + { + value: "local", + label: "Local development server (npm run dev yourself)", + }, + { + value: "docker", + label: "Docker container (recommended - just run docker compose up)", + }, + ], + })) as AuthMode; + + let useDocker = await confirm({ + message: "Do you want to run your stack with Docker?", + }); + + if (authMode === "docker" && !useDocker) { + console.log( + "\nAuth server requires Docker — enabling Docker mode automatically.\n", + ); + useDocker = true; + } + + return { + web, + webFramework, + api, + apiFramework, + authMode, + useDocker, + }; +} diff --git a/src/utils/repoUtils.ts b/src/utils/repoUtils.ts new file mode 100644 index 0000000..412584b --- /dev/null +++ b/src/utils/repoUtils.ts @@ -0,0 +1,32 @@ +import fs from "fs"; +import path from "path"; +import { runCommand } from "../core/exec.js"; + +export async function cloneRepo(repoUrl: string, dest: string) { + const parentDir = path.dirname(dest); + const folderName = path.basename(dest); + + fs.mkdirSync(parentDir, { recursive: true }); + + await runCommand( + "git", + ["clone", "--depth", "1", repoUrl, folderName], + parentDir, + ); +} + +export function removeGitDir(projectRoot: string) { + const gitDir = path.join(projectRoot, ".git"); + if (fs.existsSync(gitDir)) { + fs.rmSync(gitDir, { recursive: true, force: true }); + } +} + +export function copyEnvExample(projectRoot: string) { + const envExample = path.join(projectRoot, ".env.example"); + const env = path.join(projectRoot, ".env"); + + if (fs.existsSync(envExample) && !fs.existsSync(env)) { + fs.copyFileSync(envExample, env); + } +} diff --git a/src/utils/writeEnv.ts b/src/utils/writeEnv.ts new file mode 100644 index 0000000..d175219 --- /dev/null +++ b/src/utils/writeEnv.ts @@ -0,0 +1,10 @@ +import path from "path"; +import fs from "fs"; + +export function writeEnv(dir: string, values: Record) { + const env = Object.entries(values) + .map(([k, v]) => `${k}=${v}`) + .join("\n"); + + fs.writeFileSync(path.join(dir, ".env"), env + "\n"); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c3444b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "declaration": false, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["node_modules", "templates"] +}