Skip to content

Commit 6761dd4

Browse files
committed
feat: write local init scaffolds
1 parent 1d9bcd1 commit 6761dd4

3 files changed

Lines changed: 282 additions & 10 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"dev": "tsx src/index.ts",
1414
"check": "tsc --noEmit",
1515
"start": "node dist/index.js",
16-
"check:templates": "pnpm build && node scripts/check-template-registry.mjs"
16+
"check:templates": "pnpm build && node scripts/check-template-registry.mjs",
17+
"smoke:init": "bash scripts/smoke-init.sh"
1718
},
1819
"dependencies": {
1920
"commander": "^12.1.0"

scripts/smoke-init.sh

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5+
tmp_dir="$(mktemp -d)"
6+
trap 'rm -rf "$tmp_dir"' EXIT
7+
8+
cd "$repo_root"
9+
pnpm build >/dev/null
10+
11+
cd "$tmp_dir"
12+
node "$repo_root/dist/index.js" init oss-cli smoke-app --dry-run > dry-run.json
13+
if [ -e smoke-app ]; then
14+
echo "dry-run created files" >&2
15+
exit 1
16+
fi
17+
18+
node "$repo_root/dist/index.js" init oss-cli smoke-app --var AUTHOR_NAME="Smoke Tester" > init.json
19+
test -f smoke-app/README.md
20+
test -f smoke-app/package.json
21+
grep -q "# smoke-app" smoke-app/README.md
22+
grep -q "Smoke Tester" smoke-app/package.json
23+
node "$repo_root/dist/index.js" init oss-cli smoke-app --dry-run > dry-run-existing.json
24+
25+
if node "$repo_root/dist/index.js" init oss-cli smoke-app > overwrite.json 2> overwrite.err; then
26+
echo "init overwrote without --force" >&2
27+
exit 1
28+
fi
29+
grep -q "Refusing to overwrite" overwrite.err
30+
31+
node "$repo_root/dist/index.js" init oss-cli smoke-app --force > force.json
32+
node "$repo_root/dist/index.js" init python-api py-api > py.json
33+
test -f py-api/src/py_api/main.py
34+
node "$repo_root/dist/index.js" init next-app web-app > next.json
35+
test -f web-app/src/app/page.tsx
36+
37+
echo "smoke-init ok"

src/index.ts

Lines changed: 243 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,79 @@
11
#!/usr/bin/env node
2-
import { Command } from 'commander';
3-
import { isTemplateKey, listTemplates } from './templates.js';
2+
import { Command, InvalidArgumentError } from 'commander';
3+
import { constants as fsConstants } from 'node:fs';
4+
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
5+
import path from 'node:path';
6+
import { fileURLToPath } from 'node:url';
7+
import { isTemplateKey, listTemplates, type TemplateKey } from './templates.js';
48

59
const program = new Command();
10+
const sourceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
11+
12+
type TemplateFile = {
13+
source?: string;
14+
destination: string;
15+
content?: string;
16+
};
17+
18+
type TemplateScaffold = {
19+
key: TemplateKey;
20+
files: TemplateFile[];
21+
};
22+
23+
type InitOptions = {
24+
dryRun?: boolean;
25+
force?: boolean;
26+
var?: string[];
27+
};
28+
29+
type WritePlanItem = {
30+
source: string;
31+
destination: string;
32+
existed: boolean;
33+
bytes: number;
34+
};
35+
36+
const templateScaffolds: Record<TemplateKey, TemplateScaffold> = {
37+
'next-app': {
38+
key: 'next-app',
39+
files: [
40+
{ source: 'templates/readme/README.template.md', destination: 'README.md' },
41+
{ source: 'templates/contributors/CONTRIBUTING.template.md', destination: 'CONTRIBUTING.md' },
42+
{ source: 'templates/security/SECURITY.template.md', destination: 'SECURITY.md' },
43+
{ source: 'templates/github/pull_request_template.md', destination: '.github/pull_request_template.md' },
44+
{ destination: 'package.json', content: nextPackageJsonTemplate() },
45+
{ destination: 'src/app/page.tsx', content: "export default function Home() {\n return <main>{{PROJECT_NAME}}</main>;\n}\n" },
46+
{ 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" }
47+
]
48+
},
49+
'oss-cli': {
50+
key: 'oss-cli',
51+
files: [
52+
{ source: 'templates/readme/README.template.md', destination: 'README.md' },
53+
{ source: 'templates/npm-package/package.json', destination: 'package.json' },
54+
{ source: 'templates/contributors/CONTRIBUTING.template.md', destination: 'CONTRIBUTING.md' },
55+
{ source: 'templates/contributors/CODE_OF_CONDUCT.template.md', destination: 'CODE_OF_CONDUCT.md' },
56+
{ source: 'templates/license/LICENSE.MIT.template', destination: 'LICENSE' },
57+
{ source: 'templates/security/SECURITY.template.md', destination: 'SECURITY.md' },
58+
{ source: 'templates/release/CHANGELOG.template.md', destination: 'CHANGELOG.md' },
59+
{ source: 'templates/release/ROADMAP.template.md', destination: 'ROADMAP.md' },
60+
{ source: 'templates/github/pull_request_template.md', destination: '.github/pull_request_template.md' },
61+
{ source: 'templates/github/dependabot.yml', destination: '.github/dependabot.yml' }
62+
]
63+
},
64+
'python-api': {
65+
key: 'python-api',
66+
files: [
67+
{ source: 'templates/readme/README.template.md', destination: 'README.md' },
68+
{ source: 'templates/contributors/CONTRIBUTING.template.md', destination: 'CONTRIBUTING.md' },
69+
{ source: 'templates/security/SECURITY.template.md', destination: 'SECURITY.md' },
70+
{ source: 'templates/github/pull_request_template.md', destination: '.github/pull_request_template.md' },
71+
{ destination: 'pyproject.toml', content: pythonProjectTemplate() },
72+
{ destination: 'src/{{PACKAGE_MODULE}}/__init__.py', content: "__all__ = ['__version__']\n__version__ = '0.1.0'\n" },
73+
{ 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" }
74+
]
75+
}
76+
};
677

778
program
879
.name('stackforge')
@@ -20,17 +91,180 @@ program
2091
program
2192
.command('init')
2293
.description('Create a new project from a StackForge template.')
23-
.argument('<template>', 'Template key, e.g. oss-cli, next-app, python-api')
94+
.argument('<template>', 'Template key, e.g. oss-cli, next-app, python-api', parseTemplateKey)
2495
.argument('[name]', 'Project directory/name')
2596
.option('--dry-run', 'Print planned actions without writing files')
26-
.action((template: string, name: string | undefined, options: { dryRun?: boolean }) => {
27-
if (!isTemplateKey(template)) {
28-
throw new Error(`Unknown template: ${template}. Run \`stackforge templates\` to list available templates.`);
97+
.option('-f, --force', 'Overwrite existing files')
98+
.option('--var <KEY=VALUE>', 'Template variable override. Can be repeated.', collectVars, [])
99+
.action(async (template: TemplateKey, name: string | undefined, options: InitOptions) => {
100+
const projectName = name ?? template;
101+
const projectRoot = path.resolve(process.cwd(), projectName);
102+
const variables = buildVariables(projectName, options.var ?? []);
103+
const plan = await buildWritePlan(templateScaffolds[template], projectRoot, variables);
104+
const existing = plan.filter((item) => item.existed);
105+
106+
if (existing.length > 0 && !options.force && !options.dryRun) {
107+
console.error(JSON.stringify({
108+
ok: false,
109+
error: 'Refusing to overwrite existing files. Re-run with --force to overwrite.',
110+
files: existing.map((item) => path.relative(process.cwd(), item.destination))
111+
}, null, 2));
112+
process.exitCode = 1;
113+
return;
29114
}
30115

31-
const projectName = name ?? template;
32-
const mode = options.dryRun ? 'dry-run' : 'write';
33-
console.log(JSON.stringify({ ok: true, command: 'init', template, projectName, mode }, null, 2));
116+
if (!options.dryRun) {
117+
for (const item of plan) {
118+
await mkdir(path.dirname(item.destination), { recursive: true });
119+
await writeFile(item.destination, item.source, 'utf8');
120+
}
121+
}
122+
123+
console.log(JSON.stringify({
124+
ok: true,
125+
command: 'init',
126+
template,
127+
projectName,
128+
projectRoot,
129+
mode: options.dryRun ? 'dry-run' : 'write',
130+
force: Boolean(options.force),
131+
files: plan.map((item) => ({
132+
path: path.relative(process.cwd(), item.destination),
133+
existed: item.existed,
134+
bytes: item.bytes
135+
}))
136+
}, null, 2));
34137
});
35138

36139
await program.parseAsync(process.argv);
140+
141+
function parseTemplateKey(value: string): TemplateKey {
142+
if (isTemplateKey(value)) {
143+
return value;
144+
}
145+
146+
throw new InvalidArgumentError(`Unknown template "${value}". Run stackforge templates to list available templates.`);
147+
}
148+
149+
function collectVars(value: string, previous: string[]): string[] {
150+
return [...previous, value];
151+
}
152+
153+
function buildVariables(projectName: string, overrides: string[]): Record<string, string> {
154+
const packageSlug = slugify(projectName);
155+
const values: Record<string, string> = {
156+
PROJECT_NAME: projectName,
157+
PACKAGE_NAME: packageSlug,
158+
PACKAGE_MODULE: packageSlug.replaceAll('-', '_'),
159+
PACKAGE_DESCRIPTION: `${projectName} generated by StackForge.`,
160+
PROJECT_DESCRIPTION: `${projectName} generated by StackForge.`,
161+
AUTHOR_NAME: 'StackForge User',
162+
GITHUB_OWNER: 'rogerchappel',
163+
GITHUB_REPO: packageSlug,
164+
INSTALL_COMMAND: 'pnpm install',
165+
USAGE_COMMAND: 'pnpm dev',
166+
PRIMARY_VERIFICATION_COMMAND: 'pnpm test',
167+
YEAR: String(new Date().getFullYear()),
168+
LICENSE: 'MIT',
169+
VULNERABILITY_REPORTING_INSTRUCTIONS: 'Ask maintainers for the private security reporting path before sharing details.',
170+
RESPONSE_EXPECTATIONS: 'Maintainers review good-faith reports as capacity allows.',
171+
IN_SCOPE_SECURITY_ITEM_1: `Vulnerabilities in ${projectName}.`,
172+
IN_SCOPE_SECURITY_ITEM_2: 'Insecure default configuration shipped by this project.',
173+
IN_SCOPE_SECURITY_ITEM_3: 'CI, release, or dependency guidance maintained by this project.',
174+
DISCLOSURE_POLICY: 'Coordinate disclosure with maintainers before publishing vulnerability details.'
175+
};
176+
177+
for (const override of overrides) {
178+
const index = override.indexOf('=');
179+
if (index <= 0) {
180+
throw new InvalidArgumentError(`Invalid --var "${override}". Use KEY=VALUE.`);
181+
}
182+
values[override.slice(0, index)] = override.slice(index + 1);
183+
}
184+
185+
return values;
186+
}
187+
188+
async function buildWritePlan(
189+
template: TemplateScaffold,
190+
projectRoot: string,
191+
variables: Record<string, string>
192+
): Promise<WritePlanItem[]> {
193+
const items: WritePlanItem[] = [];
194+
195+
for (const file of template.files) {
196+
const rawContent = file.content ?? await readFile(path.join(sourceRoot, file.source ?? ''), 'utf8');
197+
const renderedContent = render(rawContent, variables);
198+
const renderedDestination = render(file.destination, variables);
199+
const destination = path.join(projectRoot, renderedDestination);
200+
201+
items.push({
202+
source: renderedContent,
203+
destination,
204+
existed: await pathExists(destination),
205+
bytes: Buffer.byteLength(renderedContent, 'utf8')
206+
});
207+
}
208+
209+
return items;
210+
}
211+
212+
function render(content: string, variables: Record<string, string>): string {
213+
return content.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_match, key: string) => variables[key] ?? '');
214+
}
215+
216+
async function pathExists(filePath: string): Promise<boolean> {
217+
try {
218+
await access(filePath, fsConstants.F_OK);
219+
return true;
220+
} catch {
221+
return false;
222+
}
223+
}
224+
225+
function slugify(value: string): string {
226+
return value
227+
.trim()
228+
.toLowerCase()
229+
.replace(/[^a-z0-9]+/g, '-')
230+
.replace(/^-+|-+$/g, '') || 'stackforge-project';
231+
}
232+
233+
function nextPackageJsonTemplate(): string {
234+
return `{
235+
"name": "{{PACKAGE_NAME}}",
236+
"version": "0.1.0",
237+
"private": true,
238+
"scripts": {
239+
"dev": "next dev",
240+
"build": "next build",
241+
"start": "next start",
242+
"lint": "next lint"
243+
},
244+
"dependencies": {
245+
"next": "latest",
246+
"react": "latest",
247+
"react-dom": "latest"
248+
},
249+
"devDependencies": {
250+
"@types/node": "latest",
251+
"@types/react": "latest",
252+
"typescript": "latest"
253+
}
254+
}
255+
`;
256+
}
257+
258+
function pythonProjectTemplate(): string {
259+
return `[project]
260+
name = "{{PROJECT_NAME}}"
261+
version = "0.1.0"
262+
description = "{{PROJECT_DESCRIPTION}}"
263+
requires-python = ">=3.11"
264+
dependencies = ["fastapi>=0.115.0", "uvicorn>=0.34.0"]
265+
266+
[build-system]
267+
requires = ["hatchling"]
268+
build-backend = "hatchling.build"
269+
`;
270+
}

0 commit comments

Comments
 (0)