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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"dev": "tsx src/index.ts",
"check": "tsc --noEmit",
"start": "node dist/index.js",
"check:templates": "pnpm build && node scripts/check-template-registry.mjs"
"check:templates": "pnpm build && node scripts/check-template-registry.mjs",
"smoke:init": "bash scripts/smoke-init.sh"
},
"dependencies": {
"commander": "^12.1.0"
Expand Down
37 changes: 37 additions & 0 deletions scripts/smoke-init.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail

repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT

cd "$repo_root"
pnpm build >/dev/null

cd "$tmp_dir"
node "$repo_root/dist/index.js" init oss-cli smoke-app --dry-run > dry-run.json
if [ -e smoke-app ]; then
echo "dry-run created files" >&2
exit 1
fi

node "$repo_root/dist/index.js" init oss-cli smoke-app --var AUTHOR_NAME="Smoke Tester" > init.json
test -f smoke-app/README.md
test -f smoke-app/package.json
grep -q "# smoke-app" smoke-app/README.md
grep -q "Smoke Tester" smoke-app/package.json
node "$repo_root/dist/index.js" init oss-cli smoke-app --dry-run > dry-run-existing.json

if node "$repo_root/dist/index.js" init oss-cli smoke-app > overwrite.json 2> overwrite.err; then
echo "init overwrote without --force" >&2
exit 1
fi
grep -q "Refusing to overwrite" overwrite.err

node "$repo_root/dist/index.js" init oss-cli smoke-app --force > force.json
node "$repo_root/dist/index.js" init python-api py-api > py.json
test -f py-api/src/py_api/main.py
node "$repo_root/dist/index.js" init next-app web-app > next.json
test -f web-app/src/app/page.tsx

echo "smoke-init ok"
252 changes: 243 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,79 @@
#!/usr/bin/env node
import { Command } from 'commander';
import { isTemplateKey, listTemplates } from './templates.js';
import { Command, InvalidArgumentError } from 'commander';
import { constants as fsConstants } from 'node:fs';
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { isTemplateKey, listTemplates, type TemplateKey } from './templates.js';

const program = new Command();
const sourceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');

type TemplateFile = {
source?: string;
destination: string;
content?: string;
};

type TemplateScaffold = {
key: TemplateKey;
files: TemplateFile[];
};

type InitOptions = {
dryRun?: boolean;
force?: boolean;
var?: string[];
};

type WritePlanItem = {
source: string;
destination: string;
existed: boolean;
bytes: number;
};

const templateScaffolds: Record<TemplateKey, TemplateScaffold> = {
'next-app': {
key: 'next-app',
files: [
{ source: 'templates/readme/README.template.md', destination: 'README.md' },
{ source: 'templates/contributors/CONTRIBUTING.template.md', destination: 'CONTRIBUTING.md' },
{ source: 'templates/security/SECURITY.template.md', destination: 'SECURITY.md' },
{ source: 'templates/github/pull_request_template.md', destination: '.github/pull_request_template.md' },
{ destination: 'package.json', content: nextPackageJsonTemplate() },
{ destination: 'src/app/page.tsx', content: "export default function Home() {\n return <main>{{PROJECT_NAME}}</main>;\n}\n" },
{ destination: 'src/app/layout.tsx', content: "export default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang=\"en\">\n <body>{children}</body>\n </html>\n );\n}\n" }
]
},
'oss-cli': {
key: 'oss-cli',
files: [
{ source: 'templates/readme/README.template.md', destination: 'README.md' },
{ source: 'templates/npm-package/package.json', destination: 'package.json' },
{ source: 'templates/contributors/CONTRIBUTING.template.md', destination: 'CONTRIBUTING.md' },
{ source: 'templates/contributors/CODE_OF_CONDUCT.template.md', destination: 'CODE_OF_CONDUCT.md' },
{ source: 'templates/license/LICENSE.MIT.template', destination: 'LICENSE' },
{ source: 'templates/security/SECURITY.template.md', destination: 'SECURITY.md' },
{ source: 'templates/release/CHANGELOG.template.md', destination: 'CHANGELOG.md' },
{ source: 'templates/release/ROADMAP.template.md', destination: 'ROADMAP.md' },
{ source: 'templates/github/pull_request_template.md', destination: '.github/pull_request_template.md' },
{ source: 'templates/github/dependabot.yml', destination: '.github/dependabot.yml' }
]
},
'python-api': {
key: 'python-api',
files: [
{ source: 'templates/readme/README.template.md', destination: 'README.md' },
{ source: 'templates/contributors/CONTRIBUTING.template.md', destination: 'CONTRIBUTING.md' },
{ source: 'templates/security/SECURITY.template.md', destination: 'SECURITY.md' },
{ source: 'templates/github/pull_request_template.md', destination: '.github/pull_request_template.md' },
{ destination: 'pyproject.toml', content: pythonProjectTemplate() },
{ destination: 'src/{{PACKAGE_MODULE}}/__init__.py', content: "__all__ = ['__version__']\n__version__ = '0.1.0'\n" },
{ destination: 'src/{{PACKAGE_MODULE}}/main.py', content: "from fastapi import FastAPI\n\napp = FastAPI(title=\"{{PROJECT_NAME}}\")\n\n\n@app.get('/health')\ndef health() -> dict[str, str]:\n return {'status': 'ok'}\n" }
]
}
};

program
.name('stackforge')
Expand All @@ -20,17 +91,180 @@ program
program
.command('init')
.description('Create a new project from a StackForge template.')
.argument('<template>', 'Template key, e.g. oss-cli, next-app, python-api')
.argument('<template>', 'Template key, e.g. oss-cli, next-app, python-api', parseTemplateKey)
.argument('[name]', 'Project directory/name')
.option('--dry-run', 'Print planned actions without writing files')
.action((template: string, name: string | undefined, options: { dryRun?: boolean }) => {
if (!isTemplateKey(template)) {
throw new Error(`Unknown template: ${template}. Run \`stackforge templates\` to list available templates.`);
.option('-f, --force', 'Overwrite existing files')
.option('--var <KEY=VALUE>', 'Template variable override. Can be repeated.', collectVars, [])
.action(async (template: TemplateKey, name: string | undefined, options: InitOptions) => {
const projectName = name ?? template;
const projectRoot = path.resolve(process.cwd(), projectName);
const variables = buildVariables(projectName, options.var ?? []);
const plan = await buildWritePlan(templateScaffolds[template], projectRoot, variables);
const existing = plan.filter((item) => item.existed);

if (existing.length > 0 && !options.force && !options.dryRun) {
console.error(JSON.stringify({
ok: false,
error: 'Refusing to overwrite existing files. Re-run with --force to overwrite.',
files: existing.map((item) => path.relative(process.cwd(), item.destination))
}, null, 2));
process.exitCode = 1;
return;
}

const projectName = name ?? template;
const mode = options.dryRun ? 'dry-run' : 'write';
console.log(JSON.stringify({ ok: true, command: 'init', template, projectName, mode }, null, 2));
if (!options.dryRun) {
for (const item of plan) {
await mkdir(path.dirname(item.destination), { recursive: true });
await writeFile(item.destination, item.source, 'utf8');
}
}

console.log(JSON.stringify({
ok: true,
command: 'init',
template,
projectName,
projectRoot,
mode: options.dryRun ? 'dry-run' : 'write',
force: Boolean(options.force),
files: plan.map((item) => ({
path: path.relative(process.cwd(), item.destination),
existed: item.existed,
bytes: item.bytes
}))
}, null, 2));
});

await program.parseAsync(process.argv);

function parseTemplateKey(value: string): TemplateKey {
if (isTemplateKey(value)) {
return value;
}

throw new InvalidArgumentError(`Unknown template "${value}". Run stackforge templates to list available templates.`);
}

function collectVars(value: string, previous: string[]): string[] {
return [...previous, value];
}

function buildVariables(projectName: string, overrides: string[]): Record<string, string> {
const packageSlug = slugify(projectName);
const values: Record<string, string> = {
PROJECT_NAME: projectName,
PACKAGE_NAME: packageSlug,
PACKAGE_MODULE: packageSlug.replaceAll('-', '_'),
PACKAGE_DESCRIPTION: `${projectName} generated by StackForge.`,
PROJECT_DESCRIPTION: `${projectName} generated by StackForge.`,
AUTHOR_NAME: 'StackForge User',
GITHUB_OWNER: 'rogerchappel',
GITHUB_REPO: packageSlug,
INSTALL_COMMAND: 'pnpm install',
USAGE_COMMAND: 'pnpm dev',
PRIMARY_VERIFICATION_COMMAND: 'pnpm test',
YEAR: String(new Date().getFullYear()),
LICENSE: 'MIT',
VULNERABILITY_REPORTING_INSTRUCTIONS: 'Ask maintainers for the private security reporting path before sharing details.',
RESPONSE_EXPECTATIONS: 'Maintainers review good-faith reports as capacity allows.',
IN_SCOPE_SECURITY_ITEM_1: `Vulnerabilities in ${projectName}.`,
IN_SCOPE_SECURITY_ITEM_2: 'Insecure default configuration shipped by this project.',
IN_SCOPE_SECURITY_ITEM_3: 'CI, release, or dependency guidance maintained by this project.',
DISCLOSURE_POLICY: 'Coordinate disclosure with maintainers before publishing vulnerability details.'
};

for (const override of overrides) {
const index = override.indexOf('=');
if (index <= 0) {
throw new InvalidArgumentError(`Invalid --var "${override}". Use KEY=VALUE.`);
}
values[override.slice(0, index)] = override.slice(index + 1);
}

return values;
}

async function buildWritePlan(
template: TemplateScaffold,
projectRoot: string,
variables: Record<string, string>
): Promise<WritePlanItem[]> {
const items: WritePlanItem[] = [];

for (const file of template.files) {
const rawContent = file.content ?? await readFile(path.join(sourceRoot, file.source ?? ''), 'utf8');
const renderedContent = render(rawContent, variables);
const renderedDestination = render(file.destination, variables);
const destination = path.join(projectRoot, renderedDestination);

items.push({
source: renderedContent,
destination,
existed: await pathExists(destination),
bytes: Buffer.byteLength(renderedContent, 'utf8')
});
}

return items;
}

function render(content: string, variables: Record<string, string>): string {
return content.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_match, key: string) => variables[key] ?? '');
}

async function pathExists(filePath: string): Promise<boolean> {
try {
await access(filePath, fsConstants.F_OK);
return true;
} catch {
return false;
}
}

function slugify(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'stackforge-project';
}

function nextPackageJsonTemplate(): string {
return `{
"name": "{{PACKAGE_NAME}}",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "latest",
"react": "latest",
"react-dom": "latest"
},
"devDependencies": {
"@types/node": "latest",
"@types/react": "latest",
"typescript": "latest"
}
}
`;
}

function pythonProjectTemplate(): string {
return `[project]
name = "{{PROJECT_NAME}}"
version = "0.1.0"
description = "{{PROJECT_DESCRIPTION}}"
requires-python = ">=3.11"
dependencies = ["fastapi>=0.115.0", "uvicorn>=0.34.0"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
`;
}