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
59const 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
778program
879 . name ( 'stackforge' )
@@ -20,17 +91,180 @@ program
2091program
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
36139await 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 - Z 0 - 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 - z 0 - 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