Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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` |
Expand Down
2 changes: 2 additions & 0 deletions cli/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <n>", "Author name")
.option("-d, --description <desc>", "Project description")
Expand Down
32 changes: 32 additions & 0 deletions cli/command/upgrade.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
7 changes: 7 additions & 0 deletions cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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);

Expand Down
21 changes: 17 additions & 4 deletions core/archgen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand All @@ -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)");
}
}

Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions core/spinner.ts
Original file line number Diff line number Diff line change
@@ -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" });
}
123 changes: 123 additions & 0 deletions core/upgrader.ts
Original file line number Diff line number Diff line change
@@ -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<ArchgenMeta> {
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<UpgradeResult> {
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<void> {
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),
);
}
}
10 changes: 9 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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": [
Expand Down Expand Up @@ -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"
Expand All @@ -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"
}
}
Loading
Loading