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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ 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.4.0] - 2026-07-03

### Added
- **Config schema validation** — `GenerateOptions` is now validated with Zod (`core/schema.ts`) before any file is touched, including a language-aware database check (e.g. `--database sqlite` now fails fast for `--language node` instead of erroring deep inside a plugin)
- **Plugin lifecycle hooks** — `BasePlugin` gained `beforeGenerate`/`afterGenerate`/`beforeApplyAddon`/`afterApplyAddon` hooks; Node/Python plugins no longer override the monolithic `generate()`/`applyAddon()` methods directly

### Fixed
- **Node: `--oauth` + `--api-docs` silently dropped one addon's wiring** — both addons shipped a full `src/app.ts` overwrite, so combining them (or `archgen add`-ing one after the other) meant whichever ran last won and the other's Fastify plugin registration vanished, with its module files left orphaned on disk. Replaced with incremental patching against `// @addon-imports` / `// @addon-plugins` markers.
- **Node: `--database postgresql` + `--oauth`/`--email`/`--s3` silently dropped `.env.example` vars** — same overwrite-conflict pattern, now patched incrementally against a `# @addon-env` marker. Also fixes `MAIL_FROM_NAME={{PROJECT_NAME}}` shipping unsubstituted when spliced in after the addon's variable-substitution pass.
- **`archgen add`/`create` with a malformed `.archgenrc.json`** no longer silently ignores the preset — now warns with the file path and parse error before falling back to defaults
- **Python: `add observability --sentry` didn't inject `sentry-sdk`** — dependency map was duplicated and had drifted between `generate()` and `applyAddon()`; consolidated into one source of truth for both Node and Python
- **Build: stale template files could leak into `dist/`** — `tsup.config.ts`'s template-copy step never cleaned `dist/plugins` before re-copying, so deleted/renamed template source files silently lingered across builds (including published releases via `prepublishOnly`)

## [1.3.1] - 2026-06-16

### Security
Expand Down
20 changes: 1 addition & 19 deletions cli/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ import { logger } from "../../core/logger";
import { promptMissingOptions } from "../prompts";
import { findPresetFile, loadPreset, mergePreset } from "../../core/config-preset";

const VALID_NODE_DATABASES = ["mysql", "postgresql"];
const VALID_PYTHON_DATABASES = ["postgresql", "sqlite"];
const VALID_GO_DATABASES = ["postgresql"];

export const createCommand = new Command("create")
.argument("<project-name>", "Name of the project")
.option("-l, --language <lang>", "Language (node|python)")
Expand Down Expand Up @@ -53,23 +49,9 @@ export const createCommand = new Command("create")
logger.debug(`Loaded preset from ${presetFile}`);
}

// Database validation is deferred until after prompts resolve the language
// Database validation happens inside ArchGen.create() (Zod schema, language-aware)
const finalOptions = await promptMissingOptions(projectName, options);

if (finalOptions.database) {
const lang = finalOptions.language;
const valid =
lang === "python" ? VALID_PYTHON_DATABASES :
lang === "go" ? VALID_GO_DATABASES :
VALID_NODE_DATABASES;
if (!valid.includes(finalOptions.database)) {
logger.error(
`Invalid database "${finalOptions.database}" for ${lang}. Must be one of: ${valid.join(", ")}`,
);
process.exit(1);
}
}

const archgen = new ArchGen();
try {
await archgen.create(projectName, finalOptions);
Expand Down
8 changes: 8 additions & 0 deletions core/addon-requires.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ArchGenError } from "./errors";

/** Throws when a flag is set but the addon/condition it depends on is not satisfied. */
export function assertAddonRequires(flag: boolean | undefined, satisfied: boolean, message: string): void {
if (flag && !satisfied) {
throw new ArchGenError("ADDON_REQUIRES_MISSING", message);
}
}
4 changes: 3 additions & 1 deletion core/archgen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { logger } from "./logger";
import { createSpinner } from "./spinner";
import { registry } from "./registry";
import { getNameError } from "./validation";
import { validateGenerateOptions } from "./schema";
import { ArchGenError } from "./errors";

export class ArchGen {
Expand All @@ -17,7 +18,8 @@ export class ArchGen {
this.fs = new FileSystem();
}

async create(projectName: string, options: GenerateOptions): Promise<void> {
async create(projectName: string, rawOptions: GenerateOptions): Promise<void> {
const options = validateGenerateOptions(rawOptions);
const basePath = options.output ? path.resolve(options.output) : process.cwd();

if (options.output && !this.fs.exists(basePath)) {
Expand Down
29 changes: 26 additions & 3 deletions core/base-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,25 @@ export abstract class BasePlugin implements Plugin {
};
}

/** Runs before any template files are processed. Throw here to abort generation (e.g. invalid option combos). */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected async beforeGenerate(projectName: string, options: GenerateOptions): Promise<void> {}

/** Runs once after generate() finishes writing files (or previewing them, on dry-run). */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected async afterGenerate(outputPath: string, options: GenerateOptions): Promise<void> {}

/** Runs before an addon's template files are processed. Throw here to abort (e.g. invalid option combos). */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected async beforeApplyAddon(projectPath: string, addon: string, options: AddAddonOptions): Promise<void> {}

/** Runs once after applyAddon() finishes writing files (or previewing them, on dry-run). */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected async afterApplyAddon(projectPath: string, addon: string, options: AddAddonOptions): Promise<void> {}

async generate(projectName: string, options: GenerateOptions): Promise<void> {
await this.beforeGenerate(projectName, options);

const outputPath = options.outputDir ?? path.join(process.cwd(), projectName);
const templateBasePath = path.join(__dirname, this.relativeTemplateDir, "base");
const addonsPath = path.join(__dirname, this.relativeTemplateDir, "addons");
Expand All @@ -82,14 +100,17 @@ export abstract class BasePlugin implements Plugin {
logger.info(`Would create ${files.length} files in ./${projectName}:`);
files.forEach((f) => console.log(` ${f}`));
console.log("");
return;
} else {
logger.success(`Project "${projectName}" generated successfully`);
this.showNextSteps(projectName, options);
}

logger.success(`Project "${projectName}" generated successfully`);
this.showNextSteps(projectName, options);
await this.afterGenerate(outputPath, options);
}

async applyAddon(projectPath: string, addon: string, options: AddAddonOptions): Promise<void> {
await this.beforeApplyAddon(projectPath, addon, options);

const dryRun = options.dryRun ?? false;
const addonsPath = path.join(__dirname, this.relativeTemplateDir, "addons");

Expand Down Expand Up @@ -121,5 +142,7 @@ export abstract class BasePlugin implements Plugin {
} else {
logger.success(`Addon "${addon}" applied successfully.`);
}

await this.afterApplyAddon(projectPath, addon, options);
}
}
4 changes: 3 additions & 1 deletion core/config-preset.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fs from "fs-extra";
import path from "path";
import { logger } from "./logger";

export interface ArchGenPreset {
language?: string;
Expand Down Expand Up @@ -37,7 +38,8 @@ export function findPresetFile(startDir: string = process.cwd()): string | null
export function loadPreset(filePath: string): ArchGenPreset {
try {
return fs.readJsonSync(filePath) as ArchGenPreset;
} catch {
} catch (error) {
logger.warn(`Ignoring ${filePath}: ${(error as Error).message}`);
return {};
}
}
Expand Down
4 changes: 3 additions & 1 deletion core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ export type ArchGenErrorCode =
| "NO_PLUGIN"
| "NO_ADDON_SUPPORT"
| "ADDON_FAILED"
| "OUTPUT_DIR_NOT_FOUND";
| "ADDON_REQUIRES_MISSING"
| "OUTPUT_DIR_NOT_FOUND"
| "VALIDATION_ERROR";

export class ArchGenError extends Error {
code: ArchGenErrorCode;
Expand Down
63 changes: 63 additions & 0 deletions core/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { z } from "zod";
import { GenerateOptions } from "../types";
import { ArchGenError } from "./errors";

const DATABASE_BY_LANGUAGE: Record<string, string[]> = {
node: ["mysql", "postgresql"],
python: ["postgresql", "sqlite"],
go: ["postgresql"],
};

export const generateOptionsSchema = z
.object({
language: z.string().min(1, "language is required"),
docker: z.boolean().optional(),
testing: z.boolean().optional(),
ci: z.boolean().optional(),
husky: z.boolean().optional(),
websocket: z.boolean().optional(),
oauth: z.boolean().optional(),
apiDocs: z.boolean().optional(),
author: z.string().optional(),
description: z.string().optional(),
force: z.boolean().optional(),
dryRun: z.boolean().optional(),
database: z.string().optional(),
skipGit: z.boolean().optional(),
output: z.string().optional(),
claudeCode: z.boolean().optional(),
cursor: z.boolean().optional(),
email: z.boolean().optional(),
s3: z.boolean().optional(),
queue: z.boolean().optional(),
preCommit: z.boolean().optional(),
observability: z.boolean().optional(),
sentry: z.boolean().optional(),
modulePath: z.string().optional(),
jwt: z.boolean().optional(),
outputDir: z.string().optional(),
})
.superRefine((options, ctx) => {
if (!options.database) return;
const validDatabases = DATABASE_BY_LANGUAGE[options.language];
if (validDatabases && !validDatabases.includes(options.database)) {
ctx.addIssue({
code: "custom",
path: ["database"],
message: `Invalid database "${options.database}" for ${options.language}. Must be one of: ${validDatabases.join(", ")}`,
});
}
});

function formatZodError(error: z.ZodError): string {
return error.issues.map((issue) => `${issue.path.join(".") || "options"}: ${issue.message}`).join("; ");
}

/** Validates GenerateOptions at the CLI/library boundary so bad input fails fast, before any file is touched. */
export function validateGenerateOptions(options: GenerateOptions): GenerateOptions {
const result = generateOptionsSchema.safeParse(options);
if (!result.success) {
throw new ArchGenError("VALIDATION_ERROR", formatZodError(result.error));
}
return result.data as GenerateOptions;
}
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@kidkender/archgen",
"version": "1.3.1",
"version": "1.4.0",
"description": "Generate production-ready Node.js, Python, and Go project structures in seconds",
"main": "dist/index.js",
"module": "dist/index.mjs",
Expand Down Expand Up @@ -77,7 +77,8 @@
"commander": "^14.0.3",
"fs-extra": "^11.3.3",
"ora": "^9.4.0",
"prompts": "^2.4.2"
"prompts": "^2.4.2",
"zod": "^4.4.3"
},
"pnpm": {
"overrides": {
Expand Down
8 changes: 5 additions & 3 deletions plugins/go/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import path from "path";
import { BasePlugin, AddonEntry } from "../../core/base-plugin";
import { TemplateVariables } from "../../core/template-engine";
import { AddAddonOptions, GenerateOptions, StackInfo } from "../../types";
import { GenerateOptions, StackInfo } from "../../types";
import { goConfig } from "./config";

export class GoPlugin extends BasePlugin {
Expand Down Expand Up @@ -60,15 +60,17 @@ export class GoPlugin extends BasePlugin {
];
}

async applyAddon(projectPath: string, addon: string, options: AddAddonOptions): Promise<void> {
protected async beforeApplyAddon(projectPath: string): Promise<void> {
try {
const goModContent = await this.fs.readFile(path.join(projectPath, "go.mod"));
const match = goModContent.match(/^module\s+(\S+)/m);
this._cachedModulePath = match ? match[1] : undefined;
} catch {
this._cachedModulePath = undefined;
}
await super.applyAddon(projectPath, addon, options);
}

protected async afterApplyAddon(): Promise<void> {
this._cachedModulePath = undefined;
}

Expand Down
Loading
Loading