From cecc6e43cd3b025c4f60c578f9f2e27c4c8a6131 Mon Sep 17 00:00:00 2001 From: kidkender Date: Thu, 14 May 2026 22:46:37 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20release=20v1.0.8=20=E2=80=94=20Email,?= =?UTF-8?q?=20S3=20addons,=20upgrade=20command,=20ora=20spinner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Email addon (--email): nodemailer SMTP with Zod env, Fastify decorator, EmailService (send/welcome/passwordReset/notification), .env.example overlay - S3 addon (--s3): AWS SDK v3 with S3Client decorator, StorageService (upload/presignedUrl/delete/exists), supports R2/MinIO via S3_ENDPOINT - archgen upgrade command: re-applies all addons from .archgen-meta.json, skips unknown addons, updates meta version, supports --dry-run - ora spinner for archgen create with quiet/verbose support - Unit tests: 33 tests for email/s3 addon entries and dep injection, 14 tests for Upgrader.readMeta() and upgrade() — total 125 tests - Integration tests: email-smtp.test.ts (Gmail SMTP) and s3.test.ts (AWS S3) with describe.skipIf() for graceful CI skip when env vars absent --- CHANGELOG.md | 10 + README.md | 17 + cli/command/index.ts | 2 + cli/command/upgrade.ts | 32 + cli/index.ts | 7 + core/archgen.ts | 21 +- core/spinner.ts | 14 + core/upgrader.ts | 123 +++ package.json | 10 +- plugins/node/config.ts | 2 +- plugins/node/index.ts | 31 + .../node/template/addons/email/.env.example | 28 + .../template/addons/email/src/config/email.ts | 12 + .../email/src/modules/email/email.service.ts | 48 ++ .../email/src/modules/email/email.types.ts | 11 + .../addons/email/src/modules/email/index.ts | 2 + .../addons/email/src/plugins/email.plugin.ts | 31 + plugins/node/template/addons/s3/.env.example | 27 + .../template/addons/s3/src/config/storage.ts | 11 + .../addons/s3/src/modules/storage/index.ts | 2 + .../s3/src/modules/storage/storage.service.ts | 65 ++ .../s3/src/modules/storage/storage.types.ts | 20 + .../addons/s3/src/plugins/storage.plugin.ts | 29 + pnpm-lock.yaml | 812 +++++++++++++++++- tests/integration/email-smtp.test.ts | 116 +++ tests/integration/s3.test.ts | 108 +++ tests/unit/email-s3-addon.test.ts | 233 +++++ tests/unit/upgrader.test.ts | 205 +++++ types/index.ts | 2 + 29 files changed, 2023 insertions(+), 8 deletions(-) create mode 100644 cli/command/upgrade.ts create mode 100644 core/spinner.ts create mode 100644 core/upgrader.ts create mode 100644 plugins/node/template/addons/email/.env.example create mode 100644 plugins/node/template/addons/email/src/config/email.ts create mode 100644 plugins/node/template/addons/email/src/modules/email/email.service.ts create mode 100644 plugins/node/template/addons/email/src/modules/email/email.types.ts create mode 100644 plugins/node/template/addons/email/src/modules/email/index.ts create mode 100644 plugins/node/template/addons/email/src/plugins/email.plugin.ts create mode 100644 plugins/node/template/addons/s3/.env.example create mode 100644 plugins/node/template/addons/s3/src/config/storage.ts create mode 100644 plugins/node/template/addons/s3/src/modules/storage/index.ts create mode 100644 plugins/node/template/addons/s3/src/modules/storage/storage.service.ts create mode 100644 plugins/node/template/addons/s3/src/modules/storage/storage.types.ts create mode 100644 plugins/node/template/addons/s3/src/plugins/storage.plugin.ts create mode 100644 tests/integration/email-smtp.test.ts create mode 100644 tests/integration/s3.test.ts create mode 100644 tests/unit/email-s3-addon.test.ts create mode 100644 tests/unit/upgrader.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index efd4a4b..911ae56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.0.8] - 2026-05-14 + +### Added +- **Email addon** (`--email`) — injects `nodemailer` into Node.js projects: `src/config/email.ts` (Zod-validated SMTP env), `src/plugins/email.plugin.ts` (Fastify decorator), `src/modules/email/` with `EmailService` (`send`, `sendWelcome`, `sendPasswordReset`, `sendNotification`), `.env.example` overlay with `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM_ADDRESS` +- **S3 addon** (`--s3`) — injects `@aws-sdk/client-s3` + `@aws-sdk/s3-request-presigner` into Node.js projects: `src/config/storage.ts` (Zod-validated env with optional `S3_ENDPOINT` for R2/MinIO), `src/plugins/storage.plugin.ts` (Fastify decorator), `src/modules/storage/` with `StorageService` (`upload`, `getPresignedUrl`, `delete`, `exists`) +- **`archgen upgrade`** command — re-applies all addons from `.archgen-meta.json` using the latest templates; updates meta version to current CLI version; supports `--dry-run` +- **ora spinner** — animated spinner during `archgen create` for better UX; respects `--quiet` and `--verbose` flags +- **Integration tests** — `tests/integration/email-smtp.test.ts` (real Gmail SMTP) and `tests/integration/s3.test.ts` (real AWS S3); both skip gracefully via `describe.skipIf()` when env vars are absent +- **Unit tests** — `tests/unit/email-s3-addon.test.ts` (33 tests: addon entries + dep injection for email/s3) and `tests/unit/upgrader.test.ts` (14 tests: readMeta + upgrade behavior) + ## [1.0.7] - 2026-04-23 ### Added diff --git a/README.md b/README.md index f0dcb68..49b09c6 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,14 @@ Answer a few prompts. Your project is ready in under a second. - Optional WebSocket support with Socket.io + JWT auth - Optional OAuth2 (Google + GitHub) via `@fastify/oauth2` - Optional API documentation via Scalar + Swagger UI +- Optional Email support via SMTP (nodemailer) — send welcome, password reset, and custom emails +- Optional S3 storage (AWS S3 / R2 / MinIO) — upload, presigned URLs, delete, exists - Optional Claude Code setup — `CLAUDE.md` + pre-configured skills for Claude Code agent - Optional Cursor setup — `.cursor/skills/` with pre-configured skills for Cursor agent - Auto update notifier — hints when a new version is available - Interactive CLI prompts — no flags required - Post-scaffold addon injection with `archgen add` +- `archgen upgrade` — re-apply all addons from meta to get latest templates --- @@ -62,6 +65,8 @@ archgen create my-service --language python --author "John Doe" archgen create my-app --database postgresql archgen create my-app --claude-code # add Claude Code setup (CLAUDE.md + skills) archgen create my-app --cursor # add Cursor agent setup (.cursor/skills/) +archgen create my-app --email # add nodemailer SMTP support +archgen create my-app --s3 # add AWS S3 / R2 / MinIO support archgen create my-app --claude-code --cursor # add both AI agent setups archgen create my-app --force # overwrite existing directory archgen create my-app --dry-run # preview files without writing @@ -80,9 +85,19 @@ archgen add oauth # Google + GitHub OAuth2 routes archgen add api-docs # Scalar UI at /reference + Swagger UI at /docs archgen add claude-code # Claude Code setup (CLAUDE.md + .claude/skills/) archgen add cursor # Cursor agent setup (.cursor/skills/) +archgen add email # nodemailer SMTP email support +archgen add s3 # AWS S3 / R2 / MinIO storage support archgen add ci --dry-run # preview changes without writing ``` +### Upgrade existing project to latest templates + +```bash +cd my-existing-project +archgen upgrade # re-apply all addons from .archgen-meta.json +archgen upgrade --dry-run # preview what would change +``` + ### Other commands ```bash @@ -106,6 +121,8 @@ archgen doctor # check that required tools are installed | `--websocket` | Include Socket.io WebSocket support | `false` | | `--oauth` | Include Google + GitHub OAuth2 | `false` | | `--api-docs` | Include Scalar + Swagger API docs | `false` | +| `--email` | Include nodemailer SMTP email support | `false` | +| `--s3` | Include AWS S3 / R2 / MinIO storage | `false` | | `-a, --author` | Author name | `Your Name` | | `-d, --description` | Project description | — | | `--force` | Overwrite existing directory | `false` | diff --git a/cli/command/index.ts b/cli/command/index.ts index 6438786..2209fbf 100644 --- a/cli/command/index.ts +++ b/cli/command/index.ts @@ -19,6 +19,8 @@ export const createCommand = new Command("create") .option("--api-docs", "Include Scalar API reference UI (Node.js only)") .option("--claude-code", "Include Claude Code setup (CLAUDE.md + .claude/skills/)") .option("--cursor", "Include Cursor agent setup (.cursor/skills/)") + .option("--email", "Include Resend email service (Node.js only)") + .option("--s3", "Include AWS S3 / Cloudflare R2 storage service (Node.js only)") .option("--all", "Include all addons (docker + testing + ci)", false) .option("-a, --author ", "Author name") .option("-d, --description ", "Project description") diff --git a/cli/command/upgrade.ts b/cli/command/upgrade.ts new file mode 100644 index 0000000..2ffe04a --- /dev/null +++ b/cli/command/upgrade.ts @@ -0,0 +1,32 @@ +import { Command } from "commander"; +import { Upgrader } from "../../core/upgrader"; +import { ArchGenError } from "../../core/errors"; +import { logger } from "../../core/logger"; + +export const upgradeCommand = new Command("upgrade") + .option("--dry-run", "Preview what would be updated without writing files", false) + .description("Re-apply addons in the current project to the latest template version") + .addHelpText("after", ` +Examples: + $ archgen upgrade # upgrade all addons recorded in .archgen-meta.json + $ archgen upgrade --dry-run # preview changes without writing +`) + .action(async (options: { dryRun: boolean }) => { + const cwd = process.cwd(); + const upgrader = new Upgrader(); + + try { + const result = await upgrader.upgrade(cwd, options.dryRun); + + if (!options.dryRun && result.addons.length > 0) { + logger.info(`Upgraded addons: ${result.addons.join(", ")}`); + } + } catch (error) { + if (error instanceof ArchGenError) { + logger.error(error.message); + } else { + logger.error(`Unexpected error: ${(error as Error).message}`); + } + process.exit(1); + } + }); diff --git a/cli/index.ts b/cli/index.ts index 98f0208..587ce7a 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -5,9 +5,11 @@ import { createCommand } from "./command"; import { listCommand } from "./command/list"; import { infoCommand } from "./command/info"; import { addCommand } from "./command/add"; +import { upgradeCommand } from "./command/upgrade"; import { doctorCommand } from "./command/doctor"; import { completionCommand } from "./command/completion"; import { logger } from "../core/logger"; +import { setSpinnerLevel } from "../core/spinner"; import { checkForUpdate } from "../core/update-notifier"; import chalk from "chalk"; @@ -47,6 +49,8 @@ Examples: $ archgen info node $ archgen add docker $ archgen add ci --dry-run + $ archgen upgrade + $ archgen upgrade --dry-run $ archgen doctor $ archgen completion bash >> ~/.bashrc $ archgen completion zsh >> ~/.zshrc @@ -56,8 +60,10 @@ program.hook("preAction", (_thisCommand, actionCommand) => { const opts = program.opts<{ quiet?: boolean; verbose?: boolean }>(); if (opts.quiet) { logger.setLevel("quiet"); + setSpinnerLevel("quiet"); } else if (opts.verbose) { logger.setLevel("verbose"); + setSpinnerLevel("verbose"); } // Pass global flags down to subcommand options if (opts.quiet) actionCommand.setOptionValue("quiet", true); @@ -85,6 +91,7 @@ program.addCommand(createCommand); program.addCommand(listCommand); program.addCommand(infoCommand); program.addCommand(addCommand); +program.addCommand(upgradeCommand); program.addCommand(doctorCommand); program.addCommand(completionCommand); diff --git a/core/archgen.ts b/core/archgen.ts index ac2c062..306dbd5 100644 --- a/core/archgen.ts +++ b/core/archgen.ts @@ -5,6 +5,7 @@ import { join } from "path"; import { AddAddonOptions, GenerateOptions } from "../types"; import { FileSystem } from "./file-system"; import { logger } from "./logger"; +import { createSpinner } from "./spinner"; import { registry } from "./registry"; import { getNameError } from "./validation"; import { ArchGenError } from "./errors"; @@ -65,15 +66,16 @@ export class ArchGen { return; } - logger.info(`Creating project: ${projectName}`); + const spinner = createSpinner(`Creating project: ${projectName}...`); + spinner.start(); const start = performance.now(); try { await plugin.generate(projectName, resolvedOptions); } catch (error) { + spinner.fail(`Failed to create project`); try { if (this.fs.exists(targetPath)) { - logger.info("Rolling back: removing partially created project..."); await this.fs.removeDir(targetPath); } } catch (cleanupError) { @@ -85,14 +87,18 @@ export class ArchGen { ); } + spinner.succeed(`Project files generated`); + await this.writeMetaFile(targetPath, projectName, options); if (!options.skipGit) { + const gitSpinner = createSpinner("Initializing git repository..."); + gitSpinner.start(); try { - logger.step("Initializing git repository..."); execSync("git init", { cwd: targetPath, stdio: "ignore" }); + gitSpinner.succeed("Git repository initialized"); } catch { - logger.warn("git init skipped (git not found)"); + gitSpinner.warn("git init skipped (git not found)"); } } @@ -131,6 +137,13 @@ export class ArchGen { if (options.testing) enabledAddons.push("testing"); if (options.ci) enabledAddons.push("ci"); if (options.husky) enabledAddons.push("husky"); + if (options.websocket) enabledAddons.push("websocket"); + if (options.oauth) enabledAddons.push("oauth"); + if (options.apiDocs) enabledAddons.push("api-docs"); + if (options.claudeCode) enabledAddons.push("claude-code"); + if (options.cursor) enabledAddons.push("cursor"); + if (options.email) enabledAddons.push("email"); + if (options.s3) enabledAddons.push("s3"); const meta = { version: pkg.version, diff --git a/core/spinner.ts b/core/spinner.ts new file mode 100644 index 0000000..efb259c --- /dev/null +++ b/core/spinner.ts @@ -0,0 +1,14 @@ +import ora, { Ora } from "ora"; + +let level: "quiet" | "normal" | "verbose" = "normal"; + +export function setSpinnerLevel(newLevel: typeof level): void { + level = newLevel; +} + +export function createSpinner(text: string): Ora { + if (level === "quiet") { + return ora({ text, isSilent: true }); + } + return ora({ text, spinner: "dots" }); +} diff --git a/core/upgrader.ts b/core/upgrader.ts new file mode 100644 index 0000000..4fe9e73 --- /dev/null +++ b/core/upgrader.ts @@ -0,0 +1,123 @@ +import path from "path"; +import { readFileSync } from "fs"; +import { join } from "path"; +import { FileSystem } from "./file-system"; +import { logger } from "./logger"; +import { registry } from "./registry"; +import { ArchGenError } from "./errors"; + +interface ArchgenMeta { + version: string; + language: string; + addons: string[]; + database?: string | null; + generatedAt: string; + projectName: string; +} + +export interface UpgradeResult { + language: string; + projectName: string; + addons: string[]; + fromVersion: string; + toVersion: string; +} + +export class Upgrader { + private readonly fs: FileSystem; + + constructor() { + this.fs = new FileSystem(); + } + + async readMeta(projectPath: string): Promise { + const metaPath = path.join(projectPath, ".archgen-meta.json"); + + if (!this.fs.exists(metaPath)) { + throw new ArchGenError( + "NO_PLUGIN", + "No .archgen-meta.json found. This command must be run from an archgen-generated project root.", + ); + } + + try { + const raw = await this.fs.readFile(metaPath); + return JSON.parse(raw) as ArchgenMeta; + } catch { + throw new ArchGenError("GENERATE_FAILED", "Failed to parse .archgen-meta.json — file may be corrupted."); + } + } + + async upgrade(projectPath: string, dryRun = false): Promise { + const meta = await this.readMeta(projectPath); + + const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8")) as { version: string }; + const currentVersion = pkg.version; + + const plugin = registry.get(meta.language); + if (!plugin) { + throw new ArchGenError("NO_PLUGIN", `No plugin found for language: ${meta.language}`); + } + + if (!plugin.applyAddon) { + throw new ArchGenError("NO_ADDON_SUPPORT", `Plugin "${meta.language}" does not support addons.`); + } + + if (meta.addons.length === 0) { + logger.info("No addons recorded in .archgen-meta.json — nothing to upgrade."); + return { + language: meta.language, + projectName: meta.projectName, + addons: [], + fromVersion: meta.version, + toVersion: currentVersion, + }; + } + + logger.info(`Upgrading ${meta.projectName} (${meta.language}) from v${meta.version} → v${currentVersion}`); + logger.info(`Addons to re-apply: ${meta.addons.join(", ")}`); + + for (const addon of meta.addons) { + if (!plugin.addons?.includes(addon)) { + logger.warn(`Addon "${addon}" no longer exists in ${meta.language} plugin — skipping`); + continue; + } + + try { + logger.step(dryRun ? `[dry-run] Would upgrade: ${addon}` : `Upgrading addon: ${addon}...`); + await plugin.applyAddon(projectPath, addon, { dryRun }); + } catch (error) { + logger.warn(`Failed to upgrade addon "${addon}": ${(error as Error).message}`); + } + } + + if (!dryRun) { + await this.updateMetaVersion(projectPath, meta, currentVersion); + logger.success(`Upgrade complete. .archgen-meta.json updated to v${currentVersion}`); + } + + return { + language: meta.language, + projectName: meta.projectName, + addons: meta.addons, + fromVersion: meta.version, + toVersion: currentVersion, + }; + } + + private async updateMetaVersion( + projectPath: string, + meta: ArchgenMeta, + newVersion: string, + ): Promise { + const updated: ArchgenMeta = { + ...meta, + version: newVersion, + generatedAt: new Date().toISOString(), + }; + await this.fs.writeFile( + path.join(projectPath, ".archgen-meta.json"), + JSON.stringify(updated, null, 2), + ); + } +} diff --git a/package.json b/package.json index 88be6e9..2d4a2c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kidkender/archgen", - "version": "1.0.7", + "version": "1.0.8", "description": "Generate production-ready Node.js and Python project structures in seconds", "main": "dist/index.js", "module": "dist/index.mjs", @@ -23,6 +23,8 @@ "test:integration": "pnpm build && vitest run --config vitest.integration.config.ts", "test:docker": "pnpm build && vitest run --config vitest.integration.config.ts tests/integration/docker.test.ts", "test:e2e": "pnpm build && vitest run --config vitest.integration.config.ts tests/integration/e2e-addons.test.ts", + "test:email": "vitest run --config vitest.integration.config.ts tests/integration/email-smtp.test.ts", + "test:s3": "vitest run --config vitest.integration.config.ts tests/integration/s3.test.ts", "prepublishOnly": "pnpm build" }, "files": [ @@ -57,9 +59,14 @@ }, "packageManager": "pnpm@10.8.1", "devDependencies": { + "@aws-sdk/client-s3": "^3.1046.0", + "@aws-sdk/s3-request-presigner": "^3.1046.0", "@types/fs-extra": "^11.0.4", "@types/node": "^25.2.3", + "@types/nodemailer": "^8.0.0", "@types/prompts": "^2.4.9", + "dotenv": "^17.4.2", + "nodemailer": "^8.0.7", "tsup": "^8.5.1", "typescript": "^5.9.3", "vitest": "^4.1.0" @@ -68,6 +75,7 @@ "chalk": "^5.6.2", "commander": "^14.0.3", "fs-extra": "^11.3.3", + "ora": "^9.4.0", "prompts": "^2.4.2" } } \ No newline at end of file diff --git a/plugins/node/config.ts b/plugins/node/config.ts index a24df83..63a5cd5 100644 --- a/plugins/node/config.ts +++ b/plugins/node/config.ts @@ -3,5 +3,5 @@ import { PluginConfig } from "../../types"; export const nodeConfig: PluginConfig = { name: "node-typescript", description: "Node.js Typescript backend with Fastify", - addons: ["docker", "testing", "ci", "husky", "websocket", "oauth", "api-docs", "claude-code", "cursor"], + addons: ["docker", "testing", "ci", "husky", "websocket", "oauth", "api-docs", "claude-code", "cursor", "email", "s3"], }; diff --git a/plugins/node/index.ts b/plugins/node/index.ts index 28dd352..272b0ea 100644 --- a/plugins/node/index.ts +++ b/plugins/node/index.ts @@ -87,6 +87,16 @@ export class NodePlugin extends BasePlugin { path: path.join(addonsPath, "cursor"), label: "Cursor agent setup", }, + { + condition: !!options.email, + path: path.join(addonsPath, "email"), + label: "Email (Resend)", + }, + { + condition: !!options.s3, + path: path.join(addonsPath, "s3"), + label: "Storage (AWS S3)", + }, ]; } @@ -102,6 +112,11 @@ export class NodePlugin extends BasePlugin { extraDeps["@fastify/cookie"] = "^11.0.2"; } if (addon === "api-docs") extraDeps["@scalar/fastify-api-reference"] = "^1.25.0"; + if (addon === "email") extraDeps["nodemailer"] = "^6.9.0"; + if (addon === "s3") { + extraDeps["@aws-sdk/client-s3"] = "^3.600.0"; + extraDeps["@aws-sdk/s3-request-presigner"] = "^3.600.0"; + } if (Object.keys(extraDeps).length === 0) return; @@ -127,6 +142,11 @@ export class NodePlugin extends BasePlugin { extraDeps["@fastify/cookie"] = "^11.0.2"; } if (options.apiDocs) extraDeps["@scalar/fastify-api-reference"] = "^1.25.0"; + if (options.email) extraDeps["nodemailer"] = "^6.9.0"; + if (options.s3) { + extraDeps["@aws-sdk/client-s3"] = "^3.600.0"; + extraDeps["@aws-sdk/s3-request-presigner"] = "^3.600.0"; + } if (Object.keys(extraDeps).length === 0) return; @@ -200,6 +220,17 @@ export class NodePlugin extends BasePlugin { console.log(""); console.log(" Cursor — .cursor/skills/ ready, open project in Cursor to use agent skills"); } + if (options.email) { + console.log(""); + console.log(" Email (Resend) — set RESEND_API_KEY + EMAIL_FROM in your .env"); + console.log(" Register emailPlugin in src/app.ts, then inject EmailService into your routes"); + } + if (options.s3) { + console.log(""); + console.log(" Storage (S3) — set S3_BUCKET, S3_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY in .env"); + console.log(" For Cloudflare R2 / MinIO, set S3_ENDPOINT too"); + console.log(" Register storagePlugin in src/app.ts, then inject StorageService into your routes"); + } console.log(""); } } diff --git a/plugins/node/template/addons/email/.env.example b/plugins/node/template/addons/email/.env.example new file mode 100644 index 0000000..b0da9f0 --- /dev/null +++ b/plugins/node/template/addons/email/.env.example @@ -0,0 +1,28 @@ +# App +NODE_ENV=development +PORT=3000 + +# Database +DATABASE_URL=mysql://root:root@localhost:3306/{{PROJECT_NAME}} + +# Redis (optional) +REDIS_URL=redis://localhost:6379 + +# JWT +JWT_SECRET=your-super-secret-jwt-key-change-this-in-production +JWT_EXPIRES_IN=7d + +# CORS +CORS_ORIGIN=* + +# Rate Limit +RATE_LIMIT_MAX=100 +RATE_LIMIT_TIMEWINDOW=60000 + +# Email (SMTP) +MAIL_HOST=smtp.gmail.com +MAIL_PORT=587 +MAIL_USERNAME=your-email@gmail.com +MAIL_PASSWORD=your-app-password +MAIL_FROM_ADDRESS=your-email@gmail.com +MAIL_FROM_NAME={{PROJECT_NAME}} diff --git a/plugins/node/template/addons/email/src/config/email.ts b/plugins/node/template/addons/email/src/config/email.ts new file mode 100644 index 0000000..c592e41 --- /dev/null +++ b/plugins/node/template/addons/email/src/config/email.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +const emailSchema = z.object({ + MAIL_HOST: z.string().min(1, "MAIL_HOST is required"), + MAIL_PORT: z.coerce.number().int().default(587), + MAIL_USERNAME: z.string().min(1, "MAIL_USERNAME is required"), + MAIL_PASSWORD: z.string().min(1, "MAIL_PASSWORD is required"), + MAIL_FROM_ADDRESS: z.string().email("MAIL_FROM_ADDRESS must be a valid email"), + MAIL_FROM_NAME: z.string().default("{{PROJECT_NAME}}"), +}); + +export const emailEnv = emailSchema.parse(process.env); diff --git a/plugins/node/template/addons/email/src/modules/email/email.service.ts b/plugins/node/template/addons/email/src/modules/email/email.service.ts new file mode 100644 index 0000000..eadedd4 --- /dev/null +++ b/plugins/node/template/addons/email/src/modules/email/email.service.ts @@ -0,0 +1,48 @@ +import { FastifyInstance } from "fastify"; +import { emailEnv } from "../../config/email"; +import { SendEmailOptions, EmailResult } from "./email.types"; + +export class EmailService { + constructor(private readonly fastify: FastifyInstance) {} + + async send(options: SendEmailOptions): Promise { + const info = await this.fastify.mailer.sendMail({ + from: `"${emailEnv.MAIL_FROM_NAME}" <${emailEnv.MAIL_FROM_ADDRESS}>`, + to: Array.isArray(options.to) ? options.to.join(", ") : options.to, + subject: options.subject, + html: options.html, + ...(options.text ? { text: options.text } : {}), + ...(options.replyTo ? { replyTo: options.replyTo } : {}), + }); + + return { id: info.messageId }; + } + + async sendWelcome(to: string, name: string): Promise { + return this.send({ + to, + subject: `Welcome to {{PROJECT_NAME}}, ${name}!`, + html: ` +

Welcome, ${name}!

+

Thanks for joining {{PROJECT_NAME}}. We're glad to have you.

+ `, + }); + } + + async sendPasswordReset(to: string, resetLink: string): Promise { + return this.send({ + to, + subject: "Reset your password", + html: ` +

Password Reset Request

+

Click the link below to reset your password. This link expires in 1 hour.

+ Reset Password +

If you didn't request this, you can safely ignore this email.

+ `, + }); + } + + async sendNotification(to: string | string[], subject: string, message: string): Promise { + return this.send({ to, subject, html: `

${message}

` }); + } +} diff --git a/plugins/node/template/addons/email/src/modules/email/email.types.ts b/plugins/node/template/addons/email/src/modules/email/email.types.ts new file mode 100644 index 0000000..4ddb4f5 --- /dev/null +++ b/plugins/node/template/addons/email/src/modules/email/email.types.ts @@ -0,0 +1,11 @@ +export interface SendEmailOptions { + to: string | string[]; + subject: string; + html: string; + text?: string; + replyTo?: string; +} + +export interface EmailResult { + id: string; +} diff --git a/plugins/node/template/addons/email/src/modules/email/index.ts b/plugins/node/template/addons/email/src/modules/email/index.ts new file mode 100644 index 0000000..bda8076 --- /dev/null +++ b/plugins/node/template/addons/email/src/modules/email/index.ts @@ -0,0 +1,2 @@ +export { EmailService } from "./email.service"; +export type { SendEmailOptions, EmailResult } from "./email.types"; diff --git a/plugins/node/template/addons/email/src/plugins/email.plugin.ts b/plugins/node/template/addons/email/src/plugins/email.plugin.ts new file mode 100644 index 0000000..7a9a2de --- /dev/null +++ b/plugins/node/template/addons/email/src/plugins/email.plugin.ts @@ -0,0 +1,31 @@ +import fp from "fastify-plugin"; +import { FastifyPluginAsync } from "fastify"; +import nodemailer, { Transporter } from "nodemailer"; +import { emailEnv } from "../config/email"; + +declare module "fastify" { + interface FastifyInstance { + mailer: Transporter; + } +} + +const emailPlugin: FastifyPluginAsync = fp(async (fastify) => { + const transporter = nodemailer.createTransport({ + host: emailEnv.MAIL_HOST, + port: emailEnv.MAIL_PORT, + secure: emailEnv.MAIL_PORT === 465, + auth: { + user: emailEnv.MAIL_USERNAME, + pass: emailEnv.MAIL_PASSWORD, + }, + }); + + await transporter.verify(); + fastify.decorate("mailer", transporter); + + fastify.addHook("onClose", async () => { + transporter.close(); + }); +}); + +export default emailPlugin; diff --git a/plugins/node/template/addons/s3/.env.example b/plugins/node/template/addons/s3/.env.example new file mode 100644 index 0000000..3a5e553 --- /dev/null +++ b/plugins/node/template/addons/s3/.env.example @@ -0,0 +1,27 @@ +# App +NODE_ENV=development +PORT=3000 + +# Database +DATABASE_URL=mysql://root:root@localhost:3306/{{PROJECT_NAME}} + +# Redis (optional) +REDIS_URL=redis://localhost:6379 + +# JWT +JWT_SECRET=your-super-secret-jwt-key-change-this-in-production +JWT_EXPIRES_IN=7d + +# CORS +CORS_ORIGIN=* + +# Rate Limit +RATE_LIMIT_MAX=100 +RATE_LIMIT_TIMEWINDOW=60000 + +# Storage (AWS S3 / Cloudflare R2 / MinIO) +S3_BUCKET=your-bucket-name +S3_REGION=us-east-1 +# S3_ENDPOINT=https://your-r2-endpoint.r2.cloudflarestorage.com # Leave empty for AWS S3 +AWS_ACCESS_KEY_ID=your-access-key-id +AWS_SECRET_ACCESS_KEY=your-secret-access-key diff --git a/plugins/node/template/addons/s3/src/config/storage.ts b/plugins/node/template/addons/s3/src/config/storage.ts new file mode 100644 index 0000000..c8832dd --- /dev/null +++ b/plugins/node/template/addons/s3/src/config/storage.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +const storageSchema = z.object({ + S3_BUCKET: z.string().min(1, "S3_BUCKET is required"), + S3_REGION: z.string().min(1).default("us-east-1"), + S3_ENDPOINT: z.string().optional(), + AWS_ACCESS_KEY_ID: z.string().min(1, "AWS_ACCESS_KEY_ID is required"), + AWS_SECRET_ACCESS_KEY: z.string().min(1, "AWS_SECRET_ACCESS_KEY is required"), +}); + +export const storageEnv = storageSchema.parse(process.env); diff --git a/plugins/node/template/addons/s3/src/modules/storage/index.ts b/plugins/node/template/addons/s3/src/modules/storage/index.ts new file mode 100644 index 0000000..7107ad5 --- /dev/null +++ b/plugins/node/template/addons/s3/src/modules/storage/index.ts @@ -0,0 +1,2 @@ +export { StorageService } from "./storage.service"; +export type { UploadFileOptions, PresignedUrlOptions, DeleteFileOptions, StorageResult } from "./storage.types"; diff --git a/plugins/node/template/addons/s3/src/modules/storage/storage.service.ts b/plugins/node/template/addons/s3/src/modules/storage/storage.service.ts new file mode 100644 index 0000000..931d825 --- /dev/null +++ b/plugins/node/template/addons/s3/src/modules/storage/storage.service.ts @@ -0,0 +1,65 @@ +import { FastifyInstance } from "fastify"; +import { + PutObjectCommand, + DeleteObjectCommand, + GetObjectCommand, + HeadObjectCommand, +} from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { storageEnv } from "../../config/storage"; +import { UploadFileOptions, PresignedUrlOptions, DeleteFileOptions, StorageResult } from "./storage.types"; + +export class StorageService { + constructor(private readonly fastify: FastifyInstance) {} + + async upload(options: UploadFileOptions): Promise { + const command = new PutObjectCommand({ + Bucket: storageEnv.S3_BUCKET, + Key: options.key, + Body: options.body, + ContentType: options.contentType, + ...(options.acl ? { ACL: options.acl } : {}), + }); + + await this.fastify.s3.send(command); + + const url = storageEnv.S3_ENDPOINT + ? `${storageEnv.S3_ENDPOINT}/${storageEnv.S3_BUCKET}/${options.key}` + : `https://${storageEnv.S3_BUCKET}.s3.${storageEnv.S3_REGION}.amazonaws.com/${options.key}`; + + return { key: options.key, url }; + } + + async getPresignedUrl(options: PresignedUrlOptions): Promise { + const command = new GetObjectCommand({ + Bucket: storageEnv.S3_BUCKET, + Key: options.key, + }); + + return getSignedUrl(this.fastify.s3, command, { + expiresIn: options.expiresIn ?? 3600, + }); + } + + async delete(options: DeleteFileOptions): Promise { + const command = new DeleteObjectCommand({ + Bucket: storageEnv.S3_BUCKET, + Key: options.key, + }); + + await this.fastify.s3.send(command); + } + + async exists(key: string): Promise { + try { + const command = new HeadObjectCommand({ + Bucket: storageEnv.S3_BUCKET, + Key: key, + }); + await this.fastify.s3.send(command); + return true; + } catch { + return false; + } + } +} diff --git a/plugins/node/template/addons/s3/src/modules/storage/storage.types.ts b/plugins/node/template/addons/s3/src/modules/storage/storage.types.ts new file mode 100644 index 0000000..e714daf --- /dev/null +++ b/plugins/node/template/addons/s3/src/modules/storage/storage.types.ts @@ -0,0 +1,20 @@ +export interface UploadFileOptions { + key: string; + body: Buffer | Uint8Array | string; + contentType: string; + acl?: "private" | "public-read"; +} + +export interface PresignedUrlOptions { + key: string; + expiresIn?: number; +} + +export interface DeleteFileOptions { + key: string; +} + +export interface StorageResult { + key: string; + url: string; +} diff --git a/plugins/node/template/addons/s3/src/plugins/storage.plugin.ts b/plugins/node/template/addons/s3/src/plugins/storage.plugin.ts new file mode 100644 index 0000000..496b0fb --- /dev/null +++ b/plugins/node/template/addons/s3/src/plugins/storage.plugin.ts @@ -0,0 +1,29 @@ +import fp from "fastify-plugin"; +import { FastifyPluginAsync } from "fastify"; +import { S3Client } from "@aws-sdk/client-s3"; +import { storageEnv } from "../config/storage"; + +declare module "fastify" { + interface FastifyInstance { + s3: S3Client; + } +} + +const storagePlugin: FastifyPluginAsync = fp(async (fastify) => { + const client = new S3Client({ + region: storageEnv.S3_REGION, + ...(storageEnv.S3_ENDPOINT ? { endpoint: storageEnv.S3_ENDPOINT } : {}), + credentials: { + accessKeyId: storageEnv.AWS_ACCESS_KEY_ID, + secretAccessKey: storageEnv.AWS_SECRET_ACCESS_KEY, + }, + }); + + fastify.decorate("s3", client); + + fastify.addHook("onClose", async () => { + client.destroy(); + }); +}); + +export default storagePlugin; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45a7089..fa3765d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,19 +17,37 @@ importers: fs-extra: specifier: ^11.3.3 version: 11.3.4 + ora: + specifier: ^9.4.0 + version: 9.4.0 prompts: specifier: ^2.4.2 version: 2.4.2 devDependencies: + '@aws-sdk/client-s3': + specifier: ^3.1046.0 + version: 3.1046.0 + '@aws-sdk/s3-request-presigner': + specifier: ^3.1046.0 + version: 3.1046.0 '@types/fs-extra': specifier: ^11.0.4 version: 11.0.4 '@types/node': specifier: ^25.2.3 version: 25.5.0 + '@types/nodemailer': + specifier: ^8.0.0 + version: 8.0.0 '@types/prompts': specifier: ^2.4.9 version: 2.4.9 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + nodemailer: + specifier: ^8.0.7 + version: 8.0.7 tsup: specifier: ^8.5.1 version: 8.5.1(postcss@8.5.8)(typescript@5.9.3) @@ -42,6 +60,165 @@ importers: packages: + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/crc32c@5.2.0': + resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} + + '@aws-crypto/sha1-browser@5.2.0': + resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-s3@3.1046.0': + resolution: {integrity: sha512-Cwl2SPm0CLczqNLEW2AZR9G/W+Jap85K7uUVOTRHzG3pErVeYRKOnbzsbAqGHnSYBdaxZ9a58YVaWj67P8tF4w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.974.9': + resolution: {integrity: sha512-bXxosFunr+v/kqNb99r1NRkrVBha7CG036fRSpWGbC1A/e363XFQN6wcZMx7MYTdRr1tYwNnkrWX2xc1rT3BCQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/crc64-nvme@3.972.8': + resolution: {integrity: sha512-fVfUCL/Xh2zINYMPZvj+iBn6XWouQf0DAnjaWCI9MkmqXzL2Iy5FoQB8O7syFe6gN6AH1ecDDU58T51Ou0kFkA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.35': + resolution: {integrity: sha512-WkFQ8BedszVomhh/Zzs8WwnE/XBmTqZjoQVB8u/4zH6kZCjouXZpPpb93gD8m0EZmzAl7dxHE/y+yDpuKzNCMw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.37': + resolution: {integrity: sha512-ylx0ZJTU+2eNcvXQ69VNR3TVSYa/ibpvdK717/NxqR9aXRMn2QRWZaiI8aa5yY/fOWZ5mknSmxGaVxxtdwv3EA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.39': + resolution: {integrity: sha512-QhRSrdkk+Gq0AFIylpiI0N6lcJqFYV9Jtr4Luz5FpYOYbjJSfyTG6iLhnK/UPIgN1Jnon8WAmSC//16XYGvwkA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.39': + resolution: {integrity: sha512-1hU0NtC04QbFIuoBuF4aQ2A97GsSE5/A0ZJpDijwexsBREIQ4KPRYl3v/FfKCPBYsaTeGjkOFx5nLhWHY24LOw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.40': + resolution: {integrity: sha512-ZgrQaGkpyTlVSCCsffzijVg+KgftTAWYvI5Otc36J/4jNiHb+7MmBiJIR0a5AHLvifC92PiYHt5pijP0dswd1w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.35': + resolution: {integrity: sha512-hNj1rAwZWT1vfz54BwH8FUWxZuqStrM25Q5LEIwn2erHPMRVAjLlpZqEbCEEqS99eEEOhdeetnS0WeNa3iYeEQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.39': + resolution: {integrity: sha512-mwIPNPldyCZkvHozb6E0X/vuQLN1UCjcA6MwUf1gaO7EwghCmuNZXatq0L3zptKFvPC4Nds7+WFUkifI1XmbSw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.39': + resolution: {integrity: sha512-b9HT8CnpyPVn1hU14Q7ihjwSPlRzToYmRYJxRd5jNHEZ43lrIhoLaTT8JmfQQt5j5M8rTX1iN1X8mvu0SM1dXA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-bucket-endpoint@3.972.11': + resolution: {integrity: sha512-AhVDn+qObNacklqmBABnFa3YfVk08CzksuuecL/x+lo95dZxXuAkqJZLUsAEKQ3EiDd5E9wTUBjh0cSogmKMYA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-expect-continue@3.972.11': + resolution: {integrity: sha512-xpobcctR1AHSrvkiArgTyLffn78Lt9unPMpa/yic9RKn+bOf/5M55UIM6RaPL5xKzI06/GSsTDywTWvzEAbyyw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-flexible-checksums@3.974.17': + resolution: {integrity: sha512-Js24a6sdH9SU5DI5++nlQJayCuOweiiTjnCcAsY75/JtaXF+xysDQ6nRBYx6pUPNY22viRYmdDTFZDaA9AF46g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-host-header@3.972.11': + resolution: {integrity: sha512-CBC6+tVYaOJo7QXgN1zJ4Ba2f3/Cpy4eRViYFimXW/O5Mn8hBmgXXzHu4vy4ubT80YWnp8aCFygr7dTOa14yQg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-location-constraint@3.972.10': + resolution: {integrity: sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-logger@3.972.10': + resolution: {integrity: sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.972.12': + resolution: {integrity: sha512-5eltYxKB4MfdQv7/VhWxRbAVQKow5dz9votRFigTYrWJHMQXwLMltlbk7KFWSZh5NDBySfmjT7Jv/DWfYCmDng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.38': + resolution: {integrity: sha512-Yuv3urkJtd1/b3kIURzHwihc1SV6n1t+uiXffOD2OpylZ7+4/QnO2W73yhLZzK1Z762BaqwQ3IVRqAHWzNbQ4A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-ssec@3.972.10': + resolution: {integrity: sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-user-agent@3.972.39': + resolution: {integrity: sha512-MlNSvNsSVlMKKWaCzA0GP1nS4Cuq3WCXUN1vmMvd+Ctztib5kmRcpmTtKx9kikN8szAc+gcdp7uqJJervV2nQg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.7': + resolution: {integrity: sha512-jT2AXOODobQfTYGC2SChMSnZ/voIcRV/LHlY1suyhY1bdgP/voKkhEg8Ci1jiGQ4lBiaso5BEAV3ZWWpPTfmYA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/region-config-resolver@3.972.14': + resolution: {integrity: sha512-VuLXVmm7+lKVxqFcOItPkXhjbJ02iUfxkxheRu41SfWf6/xrZup2A2SwHZos/LeQGu3SBHeqTQht80Uo3ienPA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/s3-request-presigner@3.1046.0': + resolution: {integrity: sha512-oa4RUpBeuZu/yQX2wP5+LfujrgXmJc83/KubC+xQES6WW3t4IJmgllaJsysVuq9oidJ+h5rvUEPUPXK3ZzR1Rw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.26': + resolution: {integrity: sha512-2N62veqdMZBCwQUHsbhtnaovOFjOa5Dn3dAD1nRqFTUXR4QmirT3HZnfus/L1DS08Vm5CkoKmL0iMVt6YbqEag==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1046.0': + resolution: {integrity: sha512-9je8nZt+ntB8IjhpGNayU/AkBgvq/f4aFO2bH1LSNC5JX6K8zY4LUnr/ymqunePrwq+B5OVBpL7ILjYzMFSZAw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.8': + resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.996.9': + resolution: {integrity: sha512-ibx8Vd73rCTHekNGeXX8cpGWoBKbNAlwKHL3yjSxxttu5QnNDaSAM7/0MFYDjU31/F4lyrPoQcGirT0ew61xcg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.5': + resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-user-agent-browser@3.972.11': + resolution: {integrity: sha512-kq3RS6XQtHMrLFShbkem6h+8fxazB3jEIsbMC6aaSInOciRGE+eGAqTgJ+obO7Euo/pjM8thVqLiLISEH9X9DA==} + + '@aws-sdk/util-user-agent-node@3.973.25': + resolution: {integrity: sha512-066hKH/0nvV7x4ofV/iK9kz8r/qNfcR6rzuEOFqI2vQL/fcTTsDAbTw0jmXkyMzANK8ltQdALj19ns3zuOJiUw==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.972.23': + resolution: {integrity: sha512-A0YmgYFv+hTI9c17Ntvd2hSehm9bmJfkb+ggADBwVKA8H/3+Jx94SzR2qOB9bAA9WFeDqnfz9PKKQ+D+YAKomA==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + engines: {node: '>=18.0.0'} + '@emnapi/core@1.9.0': resolution: {integrity: sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==} @@ -223,6 +400,9 @@ packages: '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@nodable/entities@2.1.0': + resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} + '@oxc-project/runtime@0.115.0': resolution: {integrity: sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -447,6 +627,42 @@ packages: cpu: [x64] os: [win32] + '@smithy/core@3.24.2': + resolution: {integrity: sha512-IKS7qX59fAGCYBmt5JChcDswQDupZqT2Yn2ZBA3UgTlsjRNNkQzZobbn95xoAAdtTyJmBiJB3Y02qR3rgy3Zog==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.3.2': + resolution: {integrity: sha512-iYr9ekBjmZ+FwkiHEopqGscBbl78X62cq3p5Dd0eC+gNd7fybNZFQQdDuOQjTVmFymleuA8YRWZnuXWZ8B3kKA==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.4.2': + resolution: {integrity: sha512-3wF40g8OOCA5BnwQUvwtzZqYBbWWftDjpAlWIUo6Yld3ZzJaMAKqg7MWQBPjE8oLaqvZQUE7tVGlZPsae6A4bQ==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/node-http-handler@4.7.2': + resolution: {integrity: sha512-EdksTZ8UXYxGUgQ4mpIKrHoaj9WVGsp66TpZuixLAz1Jex8YDLnS4RH9ktGED5aOpN0OJlEtrsC9IGt76go1eA==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.4.2': + resolution: {integrity: sha512-1km1OjdLRFuITWpCPofjFqzZ+tbeWuB72ZhcYjbjkCxZ21tTPfIs4GUxRrelMyKMLxLghGD58RENnXorU/O8cw==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.14.1': + resolution: {integrity: sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -471,6 +687,9 @@ packages: '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/nodemailer@8.0.0': + resolution: {integrity: sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==} + '@types/prompts@2.4.9': resolution: {integrity: sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==} @@ -508,6 +727,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -515,6 +738,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -537,6 +763,14 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -568,6 +802,10 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} @@ -583,6 +821,13 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + fast-xml-builder@1.2.0: + resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} + + fast-xml-parser@5.7.2: + resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==} + hasBin: true + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -604,9 +849,21 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -699,9 +956,17 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + mlly@1.8.1: resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==} @@ -716,6 +981,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nodemailer@8.0.7: + resolution: {integrity: sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==} + engines: {node: '>=6.0.0'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -723,6 +992,18 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + ora@9.4.0: + resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==} + engines: {node: '>=20'} + + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -774,6 +1055,10 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + rolldown@1.0.0-rc.9: resolution: {integrity: sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -787,6 +1072,10 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -804,6 +1093,21 @@ packages: std-env@4.0.0: resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + stdin-discarder@0.3.2: + resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} + engines: {node: '>=18'} + + string-width@8.2.1: + resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} + engines: {node: '>=20'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strnum@2.3.0: + resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -961,8 +1265,379 @@ packages: engines: {node: '>=8'} hasBin: true + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + snapshots: + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + tslib: 2.8.1 + + '@aws-crypto/crc32c@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + tslib: 2.8.1 + + '@aws-crypto/sha1-browser@5.2.0': + dependencies: + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1046.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.9 + '@aws-sdk/credential-provider-node': 3.972.40 + '@aws-sdk/middleware-bucket-endpoint': 3.972.11 + '@aws-sdk/middleware-expect-continue': 3.972.11 + '@aws-sdk/middleware-flexible-checksums': 3.974.17 + '@aws-sdk/middleware-host-header': 3.972.11 + '@aws-sdk/middleware-location-constraint': 3.972.10 + '@aws-sdk/middleware-logger': 3.972.10 + '@aws-sdk/middleware-recursion-detection': 3.972.12 + '@aws-sdk/middleware-sdk-s3': 3.972.38 + '@aws-sdk/middleware-ssec': 3.972.10 + '@aws-sdk/middleware-user-agent': 3.972.39 + '@aws-sdk/region-config-resolver': 3.972.14 + '@aws-sdk/signature-v4-multi-region': 3.996.26 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.9 + '@aws-sdk/util-user-agent-browser': 3.972.11 + '@aws-sdk/util-user-agent-node': 3.973.25 + '@smithy/core': 3.24.2 + '@smithy/fetch-http-handler': 5.4.2 + '@smithy/node-http-handler': 4.7.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.974.9': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws-sdk/xml-builder': 3.972.23 + '@smithy/core': 3.24.2 + '@smithy/signature-v4': 5.4.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/crc64-nvme@3.972.8': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.35': + dependencies: + '@aws-sdk/core': 3.974.9 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.37': + dependencies: + '@aws-sdk/core': 3.974.9 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.2 + '@smithy/fetch-http-handler': 5.4.2 + '@smithy/node-http-handler': 4.7.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.39': + dependencies: + '@aws-sdk/core': 3.974.9 + '@aws-sdk/credential-provider-env': 3.972.35 + '@aws-sdk/credential-provider-http': 3.972.37 + '@aws-sdk/credential-provider-login': 3.972.39 + '@aws-sdk/credential-provider-process': 3.972.35 + '@aws-sdk/credential-provider-sso': 3.972.39 + '@aws-sdk/credential-provider-web-identity': 3.972.39 + '@aws-sdk/nested-clients': 3.997.7 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.2 + '@smithy/credential-provider-imds': 4.3.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.972.39': + dependencies: + '@aws-sdk/core': 3.974.9 + '@aws-sdk/nested-clients': 3.997.7 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.972.40': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.35 + '@aws-sdk/credential-provider-http': 3.972.37 + '@aws-sdk/credential-provider-ini': 3.972.39 + '@aws-sdk/credential-provider-process': 3.972.35 + '@aws-sdk/credential-provider-sso': 3.972.39 + '@aws-sdk/credential-provider-web-identity': 3.972.39 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.2 + '@smithy/credential-provider-imds': 4.3.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.972.35': + dependencies: + '@aws-sdk/core': 3.974.9 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.39': + dependencies: + '@aws-sdk/core': 3.974.9 + '@aws-sdk/nested-clients': 3.997.7 + '@aws-sdk/token-providers': 3.1046.0 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.972.39': + dependencies: + '@aws-sdk/core': 3.974.9 + '@aws-sdk/nested-clients': 3.997.7 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/middleware-bucket-endpoint@3.972.11': + dependencies: + '@aws-sdk/core': 3.974.9 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-expect-continue@3.972.11': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-flexible-checksums@3.974.17': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.974.9 + '@aws-sdk/crc64-nvme': 3.972.8 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-host-header@3.972.11': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-location-constraint@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.972.12': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.24.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.9 + '@aws-sdk/signature-v4-multi-region': 3.996.26 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.2 + '@smithy/signature-v4': 5.4.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-ssec@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.972.39': + dependencies: + '@aws-sdk/core': 3.974.9 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.9 + '@smithy/core': 3.24.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.7': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.9 + '@aws-sdk/middleware-host-header': 3.972.11 + '@aws-sdk/middleware-logger': 3.972.10 + '@aws-sdk/middleware-recursion-detection': 3.972.12 + '@aws-sdk/middleware-user-agent': 3.972.39 + '@aws-sdk/region-config-resolver': 3.972.14 + '@aws-sdk/signature-v4-multi-region': 3.996.26 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.9 + '@aws-sdk/util-user-agent-browser': 3.972.11 + '@aws-sdk/util-user-agent-node': 3.973.25 + '@smithy/core': 3.24.2 + '@smithy/fetch-http-handler': 5.4.2 + '@smithy/node-http-handler': 4.7.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.972.14': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/s3-request-presigner@3.1046.0': + dependencies: + '@aws-sdk/core': 3.974.9 + '@aws-sdk/signature-v4-multi-region': 3.996.26 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.26': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.2 + '@smithy/signature-v4': 5.4.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1046.0': + dependencies: + '@aws-sdk/core': 3.974.9 + '@aws-sdk/nested-clients': 3.997.7 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.973.8': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.996.9': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.5': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.972.11': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.973.25': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.39 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.23': + dependencies: + '@nodable/entities': 2.1.0 + '@smithy/types': 4.14.1 + fast-xml-parser: 5.7.2 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.4': {} + '@emnapi/core@1.9.0': dependencies: '@emnapi/wasi-threads': 1.2.0 @@ -1078,6 +1753,8 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@nodable/entities@2.1.0': {} + '@oxc-project/runtime@0.115.0': {} '@oxc-project/types@0.115.0': {} @@ -1206,6 +1883,54 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true + '@smithy/core@3.24.2': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.3.2': + dependencies: + '@smithy/core': 3.24.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.4.2': + dependencies: + '@smithy/core': 3.24.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/node-http-handler@4.7.2': + dependencies: + '@smithy/core': 3.24.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.4.2': + dependencies: + '@smithy/core': 3.24.2 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/types@4.14.1': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} '@tybys/wasm-util@0.10.1': @@ -1235,6 +1960,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/nodemailer@8.0.0': + dependencies: + '@types/node': 25.5.0 + '@types/prompts@2.4.9': dependencies: '@types/node': 25.5.0 @@ -1283,10 +2012,14 @@ snapshots: acorn@8.16.0: {} + ansi-regex@6.2.2: {} + any-promise@1.3.0: {} assertion-error@2.0.1: {} + bowser@2.14.1: {} + bundle-require@5.1.0(esbuild@0.27.4): dependencies: esbuild: 0.27.4 @@ -1302,6 +2035,12 @@ snapshots: dependencies: readdirp: 4.1.2 + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@3.4.0: {} + commander@14.0.3: {} commander@4.1.1: {} @@ -1318,6 +2057,8 @@ snapshots: detect-libc@2.1.2: {} + dotenv@17.4.2: {} + es-module-lexer@2.0.0: {} esbuild@0.27.4: @@ -1355,6 +2096,18 @@ snapshots: expect-type@1.3.0: {} + fast-xml-builder@1.2.0: + dependencies: + path-expression-matcher: 1.5.0 + xml-naming: 0.1.0 + + fast-xml-parser@5.7.2: + dependencies: + '@nodable/entities': 2.1.0 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.5.0 + strnum: 2.3.0 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -1374,8 +2127,14 @@ snapshots: fsevents@2.3.3: optional: true + get-east-asian-width@1.6.0: {} + graceful-fs@4.2.11: {} + is-interactive@2.0.0: {} + + is-unicode-supported@2.1.0: {} + joycon@3.1.1: {} jsonfile@6.2.0: @@ -1441,10 +2200,17 @@ snapshots: load-tsconfig@0.2.5: {} + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mimic-function@5.0.1: {} + mlly@1.8.1: dependencies: acorn: 8.16.0 @@ -1462,10 +2228,29 @@ snapshots: nanoid@3.3.11: {} + nodemailer@8.0.7: {} + object-assign@4.1.1: {} obug@2.1.1: {} + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + ora@9.4.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.4.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.3.2 + string-width: 8.2.1 + + path-expression-matcher@1.5.0: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -1501,6 +2286,11 @@ snapshots: resolve-from@5.0.0: {} + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + rolldown@1.0.0-rc.9: dependencies: '@oxc-project/types': 0.115.0 @@ -1555,6 +2345,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@4.1.0: {} + sisteransi@1.0.5: {} source-map-js@1.2.1: {} @@ -1565,6 +2357,19 @@ snapshots: std-env@4.0.0: {} + stdin-discarder@0.3.2: {} + + string-width@8.2.1: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strnum@2.3.0: {} + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -1600,8 +2405,7 @@ snapshots: ts-interface-checker@0.1.13: {} - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tsup@8.5.1(postcss@8.5.8)(typescript@5.9.3): dependencies: @@ -1683,3 +2487,7 @@ snapshots: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + + xml-naming@0.1.0: {} + + yoctocolors@2.1.2: {} diff --git a/tests/integration/email-smtp.test.ts b/tests/integration/email-smtp.test.ts new file mode 100644 index 0000000..81e209f --- /dev/null +++ b/tests/integration/email-smtp.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import nodemailer, { Transporter } from "nodemailer"; +import { config } from "dotenv"; + +config(); + +const REQUIRED_VARS = ["MAIL_HOST", "MAIL_PORT", "MAIL_USERNAME", "MAIL_PASSWORD", "MAIL_FROM_ADDRESS"]; + +const missingVars = REQUIRED_VARS.filter((v) => !process.env[v]); +const skip = missingVars.length > 0; + +describe.skipIf(skip)("Email — Gmail SMTP integration", () => { + let transporter: Transporter; + + beforeAll(() => { + transporter = nodemailer.createTransport({ + host: process.env.MAIL_HOST, + port: Number(process.env.MAIL_PORT ?? 587), + secure: Number(process.env.MAIL_PORT) === 465, + auth: { + user: process.env.MAIL_USERNAME, + pass: process.env.MAIL_PASSWORD, + }, + }); + }); + + it("verifies SMTP connection is reachable", async () => { + await expect(transporter.verify()).resolves.toBe(true); + }); + + it("sends a plain text email", async () => { + const info = await transporter.sendMail({ + from: `"archgen test" <${process.env.MAIL_FROM_ADDRESS}>`, + to: process.env.MAIL_FROM_ADDRESS, + subject: "[archgen] plain text test", + text: "This is a plain text integration test from archgen.", + }); + + expect(info.accepted).toContain(process.env.MAIL_FROM_ADDRESS); + expect(info.messageId).toBeTruthy(); + console.log(" messageId:", info.messageId); + }); + + it("sends an HTML email", async () => { + const info = await transporter.sendMail({ + from: `"archgen test" <${process.env.MAIL_FROM_ADDRESS}>`, + to: process.env.MAIL_FROM_ADDRESS, + subject: "[archgen] HTML email test", + html: ` +

archgen email addon — integration test

+

Sent at ${new Date().toISOString()}

+
    +
  • SMTP host: ${process.env.MAIL_HOST}
  • +
  • Port: ${process.env.MAIL_PORT}
  • +
+ `, + }); + + expect(info.accepted).toContain(process.env.MAIL_FROM_ADDRESS); + expect(info.messageId).toBeTruthy(); + console.log(" messageId:", info.messageId); + }); + + it("sends welcome email template", async () => { + const name = "Duck"; + const projectName = "my-api"; + + const info = await transporter.sendMail({ + from: `"${projectName}" <${process.env.MAIL_FROM_ADDRESS}>`, + to: process.env.MAIL_FROM_ADDRESS, + subject: `Welcome to ${projectName}, ${name}!`, + html: ` +

Welcome, ${name}!

+

Thanks for joining ${projectName}. We're glad to have you.

+ `, + }); + + expect(info.accepted).toContain(process.env.MAIL_FROM_ADDRESS); + }); + + it("sends password reset email template", async () => { + const resetLink = "https://example.com/reset?token=abc123"; + + const info = await transporter.sendMail({ + from: `"archgen test" <${process.env.MAIL_FROM_ADDRESS}>`, + to: process.env.MAIL_FROM_ADDRESS, + subject: "Reset your password", + html: ` +

Password Reset Request

+

Click the link below to reset your password. This link expires in 1 hour.

+ Reset Password + `, + }); + + expect(info.accepted).toContain(process.env.MAIL_FROM_ADDRESS); + }); + + it("rejects invalid recipient gracefully", async () => { + await expect( + transporter.sendMail({ + from: process.env.MAIL_FROM_ADDRESS, + to: "not-a-valid-email", + subject: "Should fail", + text: "test", + }), + ).rejects.toThrow(); + }); +}); + +if (skip) { + describe("Email — skipped (missing env vars)", () => { + it(`set ${missingVars.join(", ")} in .env to enable`, () => { + console.warn(` Skipped: missing env vars: ${missingVars.join(", ")}`); + }); + }); +} diff --git a/tests/integration/s3.test.ts b/tests/integration/s3.test.ts new file mode 100644 index 0000000..d91e46b --- /dev/null +++ b/tests/integration/s3.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + DeleteObjectCommand, + HeadObjectCommand, +} from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { config } from "dotenv"; + +config(); + +const REQUIRED_VARS = ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_BUCKET", "AWS_DEFAULT_REGION"]; +const missingVars = REQUIRED_VARS.filter((v) => !process.env[v]); +const skip = missingVars.length > 0; + +const TEST_KEY = `archgen-test/${Date.now()}-integration.txt`; +const TEST_CONTENT = `archgen S3 integration test — ${new Date().toISOString()}`; + +describe.skipIf(skip)("Storage — AWS S3 integration", () => { + let client: S3Client; + const bucket = process.env.AWS_BUCKET!; + + client = new S3Client({ + region: process.env.AWS_DEFAULT_REGION ?? "us-east-1", + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + }, + ...(process.env.S3_ENDPOINT ? { endpoint: process.env.S3_ENDPOINT } : {}), + }); + + afterAll(async () => { + // cleanup test file + try { + await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: TEST_KEY })); + } catch { + // best-effort cleanup + } + }); + + it("uploads a text file", async () => { + const command = new PutObjectCommand({ + Bucket: bucket, + Key: TEST_KEY, + Body: TEST_CONTENT, + ContentType: "text/plain", + }); + + const res = await client.send(command); + expect(res.$metadata.httpStatusCode).toBe(200); + console.log(` Uploaded: s3://${bucket}/${TEST_KEY}`); + }); + + it("verifies uploaded file exists via HeadObject", async () => { + const command = new HeadObjectCommand({ Bucket: bucket, Key: TEST_KEY }); + const res = await client.send(command); + expect(res.$metadata.httpStatusCode).toBe(200); + expect(res.ContentType).toBe("text/plain"); + }); + + it("downloads and reads the uploaded file content", async () => { + const command = new GetObjectCommand({ Bucket: bucket, Key: TEST_KEY }); + const res = await client.send(command); + + const body = await res.Body?.transformToString(); + expect(body).toBe(TEST_CONTENT); + }); + + it("generates a presigned URL (expires in 60s)", async () => { + const command = new GetObjectCommand({ Bucket: bucket, Key: TEST_KEY }); + const url = await getSignedUrl(client, command, { expiresIn: 60 }); + + expect(url).toContain(bucket); + expect(url).toContain(encodeURIComponent(TEST_KEY).replace(/%2F/g, "/")); + console.log(` Presigned URL: ${url.slice(0, 80)}...`); + }); + + it("presigned URL is accessible via fetch", async () => { + const command = new GetObjectCommand({ Bucket: bucket, Key: TEST_KEY }); + const url = await getSignedUrl(client, command, { expiresIn: 60 }); + + const res = await fetch(url); + expect(res.status).toBe(200); + const text = await res.text(); + expect(text).toBe(TEST_CONTENT); + }); + + it("deletes the test file", async () => { + const command = new DeleteObjectCommand({ Bucket: bucket, Key: TEST_KEY }); + const res = await client.send(command); + expect(res.$metadata.httpStatusCode).toBe(204); + }); + + it("confirms deleted file no longer exists", async () => { + const command = new HeadObjectCommand({ Bucket: bucket, Key: TEST_KEY }); + await expect(client.send(command)).rejects.toThrow(); + }); +}); + +if (skip) { + describe("Storage — skipped (missing env vars)", () => { + it(`set ${missingVars.join(", ")} in .env to enable`, () => { + console.warn(` Skipped: missing env vars: ${missingVars.join(", ")}`); + }); + }); +} diff --git a/tests/unit/email-s3-addon.test.ts b/tests/unit/email-s3-addon.test.ts new file mode 100644 index 0000000..51e927e --- /dev/null +++ b/tests/unit/email-s3-addon.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import path from "path"; + +const { mockFs, mockProcessTemplate, mockFsExtra } = vi.hoisted(() => { + const mockProcessTemplate = vi.fn().mockResolvedValue([]); + const mockFs = { + exists: vi.fn().mockReturnValue(true), + removeDir: vi.fn().mockResolvedValue(undefined), + ensureDir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(""), + copyFile: vi.fn().mockResolvedValue(undefined), + getAllFiles: vi.fn().mockResolvedValue([]), + }; + const mockFsExtra = { + readJson: vi.fn().mockResolvedValue({ dependencies: {} }), + writeJson: vi.fn().mockResolvedValue(undefined), + }; + return { mockFs, mockProcessTemplate, mockFsExtra }; +}); + +vi.mock("../../core/file-system", () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + FileSystem: vi.fn().mockImplementation(function (this: any) { return mockFs; }), +})); + +vi.mock("../../core/template-engine", () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TemplateEngine: vi.fn().mockImplementation(function (this: any) { + return { processTemplate: mockProcessTemplate }; + }), +})); + +vi.mock("fs-extra", () => ({ default: mockFsExtra, ...mockFsExtra })); + +import { NodePlugin } from "../../plugins/node"; + +// ─── generate() addon entries ──────────────────────────────────────────────── + +describe("email addon — generate()", () => { + let plugin: NodePlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + mockFsExtra.readJson.mockResolvedValue({ dependencies: {} }); + plugin = new NodePlugin(); + }); + + it("includes email in addon list", () => { + expect(plugin.addons).toContain("email"); + }); + + it("applies email addon when email=true", async () => { + await plugin.generate("my-app", { language: "node", email: true, outputDir: "/tmp/my-app" }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "email")))).toBe(true); + }); + + it("skips email addon when email=false", async () => { + await plugin.generate("my-app", { language: "node", email: false, outputDir: "/tmp/my-app" }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "email")))).toBe(false); + }); + + it("skips email addon when email is undefined", async () => { + await plugin.generate("my-app", { language: "node", outputDir: "/tmp/my-app" }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "email")))).toBe(false); + }); + + it("injects nodemailer into package.json when email=true", async () => { + mockFsExtra.readJson.mockResolvedValue({ dependencies: { fastify: "^5.0.0" } }); + await plugin.generate("my-app", { language: "node", email: true, outputDir: "/tmp/my-app" }); + expect(mockFsExtra.writeJson).toHaveBeenCalledWith( + expect.stringContaining("package.json"), + expect.objectContaining({ + dependencies: expect.objectContaining({ nodemailer: expect.any(String) }), + }), + expect.any(Object), + ); + }); + + it("does NOT inject nodemailer when email=false", async () => { + await plugin.generate("my-app", { language: "node", email: false, outputDir: "/tmp/my-app" }); + expect(mockFsExtra.writeJson).not.toHaveBeenCalled(); + }); + + it("does NOT inject nodemailer when dry-run", async () => { + await plugin.generate("my-app", { language: "node", email: true, outputDir: "/tmp/my-app", dryRun: true }); + expect(mockFsExtra.writeJson).not.toHaveBeenCalled(); + }); +}); + +describe("s3 addon — generate()", () => { + let plugin: NodePlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + mockFsExtra.readJson.mockResolvedValue({ dependencies: {} }); + plugin = new NodePlugin(); + }); + + it("includes s3 in addon list", () => { + expect(plugin.addons).toContain("s3"); + }); + + it("applies s3 addon when s3=true", async () => { + await plugin.generate("my-app", { language: "node", s3: true, outputDir: "/tmp/my-app" }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "s3")))).toBe(true); + }); + + it("skips s3 addon when s3=false", async () => { + await plugin.generate("my-app", { language: "node", s3: false, outputDir: "/tmp/my-app" }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "s3")))).toBe(false); + }); + + it("injects @aws-sdk/client-s3 into package.json when s3=true", async () => { + mockFsExtra.readJson.mockResolvedValue({ dependencies: {} }); + await plugin.generate("my-app", { language: "node", s3: true, outputDir: "/tmp/my-app" }); + expect(mockFsExtra.writeJson).toHaveBeenCalledWith( + expect.stringContaining("package.json"), + expect.objectContaining({ + dependencies: expect.objectContaining({ + "@aws-sdk/client-s3": expect.any(String), + "@aws-sdk/s3-request-presigner": expect.any(String), + }), + }), + expect.any(Object), + ); + }); + + it("does NOT inject aws-sdk when s3=false", async () => { + await plugin.generate("my-app", { language: "node", s3: false, outputDir: "/tmp/my-app" }); + expect(mockFsExtra.writeJson).not.toHaveBeenCalled(); + }); + + it("applies both email and s3 when both=true", async () => { + mockFsExtra.readJson.mockResolvedValue({ dependencies: {} }); + await plugin.generate("my-app", { language: "node", email: true, s3: true, outputDir: "/tmp/my-app" }); + const calls = mockProcessTemplate.mock.calls.map((c) => c[0] as string); + expect(calls.some((p) => p.includes(path.join("addons", "email")))).toBe(true); + expect(calls.some((p) => p.includes(path.join("addons", "s3")))).toBe(true); + expect(mockFsExtra.writeJson).toHaveBeenCalledWith( + expect.stringContaining("package.json"), + expect.objectContaining({ + dependencies: expect.objectContaining({ + nodemailer: expect.any(String), + "@aws-sdk/client-s3": expect.any(String), + }), + }), + expect.any(Object), + ); + }); +}); + +// ─── applyAddon() dep injection ────────────────────────────────────────────── + +describe("email addon — applyAddon()", () => { + let plugin: NodePlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + mockFs.readFile.mockResolvedValue(JSON.stringify({ name: "my-project" })); + mockFsExtra.readJson.mockResolvedValue({ dependencies: {} }); + plugin = new NodePlugin(); + }); + + it("injects nodemailer into package.json", async () => { + mockFsExtra.readJson.mockResolvedValue({ dependencies: { fastify: "^5.0.0" } }); + await plugin.applyAddon("/tmp/my-project", "email", { dryRun: false }); + expect(mockFsExtra.writeJson).toHaveBeenCalledWith( + expect.stringContaining("package.json"), + expect.objectContaining({ + dependencies: expect.objectContaining({ nodemailer: expect.any(String) }), + }), + expect.any(Object), + ); + }); + + it("preserves existing dependencies when injecting nodemailer", async () => { + mockFsExtra.readJson.mockResolvedValue({ dependencies: { fastify: "^5.0.0", zod: "^4.0.0" } }); + await plugin.applyAddon("/tmp/my-project", "email", { dryRun: false }); + const written = mockFsExtra.writeJson.mock.calls[0][1] as { dependencies: Record }; + expect(written.dependencies["fastify"]).toBe("^5.0.0"); + expect(written.dependencies["zod"]).toBe("^4.0.0"); + expect(written.dependencies["nodemailer"]).toBeDefined(); + }); + + it("does NOT write package.json when dry-run", async () => { + await plugin.applyAddon("/tmp/my-project", "email", { dryRun: true }); + expect(mockFsExtra.writeJson).not.toHaveBeenCalled(); + }); +}); + +describe("s3 addon — applyAddon()", () => { + let plugin: NodePlugin; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcessTemplate.mockResolvedValue([]); + mockFs.exists.mockReturnValue(true); + mockFs.readFile.mockResolvedValue(JSON.stringify({ name: "my-project" })); + mockFsExtra.readJson.mockResolvedValue({ dependencies: {} }); + plugin = new NodePlugin(); + }); + + it("injects @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner", async () => { + await plugin.applyAddon("/tmp/my-project", "s3", { dryRun: false }); + expect(mockFsExtra.writeJson).toHaveBeenCalledWith( + expect.stringContaining("package.json"), + expect.objectContaining({ + dependencies: expect.objectContaining({ + "@aws-sdk/client-s3": expect.any(String), + "@aws-sdk/s3-request-presigner": expect.any(String), + }), + }), + expect.any(Object), + ); + }); + + it("does NOT write package.json when dry-run", async () => { + await plugin.applyAddon("/tmp/my-project", "s3", { dryRun: true }); + expect(mockFsExtra.writeJson).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/upgrader.test.ts b/tests/unit/upgrader.test.ts new file mode 100644 index 0000000..b6d435f --- /dev/null +++ b/tests/unit/upgrader.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import path from "path"; + +// ─── Hoisted mocks ──────────────────────────────────────────────────────────── + +const { mockFs, mockApplyAddon, mockPlugin } = vi.hoisted(() => { + const mockApplyAddon = vi.fn().mockResolvedValue(undefined); + const mockPlugin = { + name: "node-typescript", + description: "Node.js plugin", + addons: ["docker", "testing", "ci", "email", "s3"], + generate: vi.fn(), + applyAddon: mockApplyAddon, + }; + const mockFs = { + exists: vi.fn().mockReturnValue(true), + readFile: vi.fn(), + writeFile: vi.fn().mockResolvedValue(undefined), + removeDir: vi.fn().mockResolvedValue(undefined), + ensureDir: vi.fn().mockResolvedValue(undefined), + copyFile: vi.fn().mockResolvedValue(undefined), + getAllFiles: vi.fn().mockResolvedValue([]), + }; + return { mockFs, mockApplyAddon, mockPlugin }; +}); + +vi.mock("../../core/file-system", () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + FileSystem: vi.fn().mockImplementation(function (this: any) { return mockFs; }), +})); + +vi.mock("../../core/registry", () => ({ + registry: { get: vi.fn().mockReturnValue(mockPlugin) }, +})); + +vi.mock("fs", () => ({ + readFileSync: vi.fn().mockReturnValue(JSON.stringify({ version: "1.0.8" })), +})); + +import { Upgrader } from "../../core/upgrader"; +import { ArchGenError } from "../../core/errors"; +import { registry } from "../../core/registry"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeMeta(overrides: Record = {}): string { + return JSON.stringify({ + version: "1.0.7", + language: "node", + addons: ["docker", "email"], + database: null, + generatedAt: "2026-05-14T00:00:00.000Z", + projectName: "my-api", + ...overrides, + }); +} + +// ─── readMeta ───────────────────────────────────────────────────────────────── + +describe("Upgrader.readMeta()", () => { + let upgrader: Upgrader; + + beforeEach(() => { + vi.clearAllMocks(); + mockFs.exists.mockReturnValue(true); + upgrader = new Upgrader(); + }); + + it("throws ArchGenError(NO_PLUGIN) when .archgen-meta.json does not exist", async () => { + mockFs.exists.mockReturnValue(false); + await expect(upgrader.readMeta("/tmp/my-project")).rejects.toMatchObject({ + code: "NO_PLUGIN", + }); + }); + + it("throws ArchGenError(GENERATE_FAILED) when meta file contains invalid JSON", async () => { + mockFs.readFile.mockResolvedValue("not valid json {{{"); + await expect(upgrader.readMeta("/tmp/my-project")).rejects.toMatchObject({ + code: "GENERATE_FAILED", + }); + }); + + it("returns parsed meta when file exists and is valid", async () => { + mockFs.readFile.mockResolvedValue(makeMeta()); + const meta = await upgrader.readMeta("/tmp/my-project"); + expect(meta.language).toBe("node"); + expect(meta.version).toBe("1.0.7"); + expect(meta.addons).toEqual(["docker", "email"]); + expect(meta.projectName).toBe("my-api"); + }); + + it("reads meta from correct path", async () => { + mockFs.readFile.mockResolvedValue(makeMeta()); + await upgrader.readMeta("/tmp/my-project"); + expect(mockFs.readFile).toHaveBeenCalledWith( + path.join("/tmp/my-project", ".archgen-meta.json"), + ); + }); +}); + +// ─── upgrade() ─────────────────────────────────────────────────────────────── + +describe("Upgrader.upgrade()", () => { + let upgrader: Upgrader; + + beforeEach(() => { + vi.clearAllMocks(); + mockFs.exists.mockReturnValue(true); + mockFs.readFile.mockResolvedValue(makeMeta()); + mockApplyAddon.mockResolvedValue(undefined); + vi.mocked(registry.get).mockReturnValue(mockPlugin); + upgrader = new Upgrader(); + }); + + it("throws ArchGenError(NO_PLUGIN) when plugin not found for language", async () => { + vi.mocked(registry.get).mockReturnValue(undefined); + await expect(upgrader.upgrade("/tmp/my-project")).rejects.toMatchObject({ + code: "NO_PLUGIN", + }); + }); + + it("calls applyAddon for each addon recorded in meta", async () => { + await upgrader.upgrade("/tmp/my-project"); + expect(mockApplyAddon).toHaveBeenCalledTimes(2); + expect(mockApplyAddon).toHaveBeenCalledWith("/tmp/my-project", "docker", expect.any(Object)); + expect(mockApplyAddon).toHaveBeenCalledWith("/tmp/my-project", "email", expect.any(Object)); + }); + + it("skips addons that no longer exist in plugin", async () => { + mockFs.readFile.mockResolvedValue(makeMeta({ addons: ["docker", "legacy-addon"] })); + await upgrader.upgrade("/tmp/my-project"); + expect(mockApplyAddon).toHaveBeenCalledTimes(1); + expect(mockApplyAddon).toHaveBeenCalledWith("/tmp/my-project", "docker", expect.any(Object)); + }); + + it("passes dryRun=false to applyAddon by default", async () => { + await upgrader.upgrade("/tmp/my-project"); + expect(mockApplyAddon).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + { dryRun: false }, + ); + }); + + it("passes dryRun=true to applyAddon when dry-run", async () => { + await upgrader.upgrade("/tmp/my-project", true); + expect(mockApplyAddon).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + { dryRun: true }, + ); + }); + + it("updates .archgen-meta.json version after upgrade", async () => { + await upgrader.upgrade("/tmp/my-project"); + expect(mockFs.writeFile).toHaveBeenCalledWith( + path.join("/tmp/my-project", ".archgen-meta.json"), + expect.stringContaining('"version": "1.0.8"'), + ); + }); + + it("does NOT write meta when dry-run", async () => { + await upgrader.upgrade("/tmp/my-project", true); + expect(mockFs.writeFile).not.toHaveBeenCalled(); + }); + + it("returns correct UpgradeResult", async () => { + const result = await upgrader.upgrade("/tmp/my-project"); + expect(result).toMatchObject({ + language: "node", + projectName: "my-api", + addons: ["docker", "email"], + fromVersion: "1.0.7", + toVersion: "1.0.8", + }); + }); + + it("returns empty addons and skips applyAddon when meta has no addons", async () => { + mockFs.readFile.mockResolvedValue(makeMeta({ addons: [] })); + const result = await upgrader.upgrade("/tmp/my-project"); + expect(mockApplyAddon).not.toHaveBeenCalled(); + expect(result.addons).toEqual([]); + }); + + it("continues upgrading remaining addons if one addon fails", async () => { + mockApplyAddon + .mockRejectedValueOnce(new Error("template error")) + .mockResolvedValue(undefined); + await upgrader.upgrade("/tmp/my-project"); + expect(mockApplyAddon).toHaveBeenCalledTimes(2); + expect(mockFs.writeFile).toHaveBeenCalled(); + }); + + it("throws ArchGenError(NO_ADDON_SUPPORT) when plugin has no applyAddon", async () => { + vi.mocked(registry.get).mockReturnValue({ + name: "node-typescript", + description: "test", + addons: [], + generate: vi.fn(), + }); + await expect(upgrader.upgrade("/tmp/my-project")).rejects.toMatchObject({ + code: "NO_ADDON_SUPPORT", + }); + }); +}); diff --git a/types/index.ts b/types/index.ts index 93e202b..dfeaaca 100644 --- a/types/index.ts +++ b/types/index.ts @@ -16,6 +16,8 @@ export interface GenerateOptions { output?: string; claudeCode?: boolean; cursor?: boolean; + email?: boolean; + s3?: boolean; /** Resolved absolute output path — set internally by ArchGen before calling plugin.generate() */ outputDir?: string; }