@@ -4,8 +4,39 @@ import { Args, Command, Flags } from '@oclif/core';
44import chalk from 'chalk' ;
55import fs from 'fs' ;
66import path from 'path' ;
7+ import { fileURLToPath } from 'url' ;
78import { printHeader , printSuccess , printError , printStep , printKV , printInfo } from '../utils/format.js' ;
89
10+ // ─── Version resolution ──────────────────────────────────────────────
11+ //
12+ // The CLI is published to npm together with every other `@objectstack/*`
13+ // package in this monorepo, so they all share the same release version.
14+ // We pin scaffolded dependencies to whatever version of the CLI is
15+ // running, which guarantees the generated `package.json` resolves
16+ // outside the workspace (and pins a tested, compatible matrix).
17+
18+ let cachedCliVersion : string | null = null ;
19+
20+ export function getCliVersion ( ) : string {
21+ if ( cachedCliVersion ) return cachedCliVersion ;
22+ try {
23+ const here = path . dirname ( fileURLToPath ( import . meta. url ) ) ;
24+ // dist/commands/init.js → ../../package.json (built layout)
25+ // src/commands/init.ts → ../../package.json (source layout, used by tests)
26+ const pkgPath = path . resolve ( here , '..' , '..' , 'package.json' ) ;
27+ const pkg = JSON . parse ( fs . readFileSync ( pkgPath , 'utf8' ) ) ;
28+ cachedCliVersion = String ( pkg . version || '0.0.0' ) ;
29+ } catch {
30+ cachedCliVersion = '0.0.0' ;
31+ }
32+ return cachedCliVersion ;
33+ }
34+
35+ /** Caret-pinned to the CLI's own version (e.g. `^6.5.0`). */
36+ function pkgVersion ( ) : string {
37+ return `^${ getCliVersion ( ) } ` ;
38+ }
39+
940export const TEMPLATES : Record < string , {
1041 description : string ;
1142 dependencies : Record < string , string > ;
@@ -16,15 +47,20 @@ export const TEMPLATES: Record<string, {
1647} > = {
1748 app : {
1849 description : 'Full application with objects, views, and actions' ,
19- dependencies : {
20- '@objectstack/spec' : 'workspace:*' ,
21- '@objectstack/runtime' : 'workspace:^' ,
22- '@objectstack/objectql' : 'workspace:^' ,
23- '@objectstack/driver-memory' : 'workspace:^' ,
50+ get dependencies ( ) {
51+ const v = pkgVersion ( ) ;
52+ return {
53+ '@objectstack/spec' : v ,
54+ '@objectstack/runtime' : v ,
55+ '@objectstack/objectql' : v ,
56+ '@objectstack/driver-memory' : v ,
57+ } ;
2458 } ,
25- devDependencies : {
26- '@objectstack/cli' : 'workspace:*' ,
27- 'typescript' : '^5.3.0' ,
59+ get devDependencies ( ) {
60+ return {
61+ '@objectstack/cli' : pkgVersion ( ) ,
62+ 'typescript' : '^5.3.0' ,
63+ } ;
2864 } ,
2965 scripts : {
3066 dev : 'objectstack dev' ,
@@ -88,12 +124,17 @@ export default ${toCamelCase(name)};
88124
89125 plugin : {
90126 description : 'Reusable plugin with objects and extensions' ,
91- dependencies : {
92- '@objectstack/spec' : 'workspace:*' ,
127+ get dependencies ( ) {
128+ return {
129+ '@objectstack/spec' : pkgVersion ( ) ,
130+ } ;
93131 } ,
94- devDependencies : {
95- 'typescript' : '^5.3.0' ,
96- 'vitest' : '^4.0.18' ,
132+ get devDependencies ( ) {
133+ return {
134+ '@objectstack/cli' : pkgVersion ( ) ,
135+ 'typescript' : '^5.3.0' ,
136+ 'vitest' : '^4.0.18' ,
137+ } ;
97138 } ,
98139 scripts : {
99140 build : 'objectstack compile' ,
@@ -142,12 +183,16 @@ export default ${toCamelCase(name)};
142183
143184 empty : {
144185 description : 'Minimal project with just a config file' ,
145- dependencies : {
146- '@objectstack/spec' : 'workspace:*' ,
186+ get dependencies ( ) {
187+ return {
188+ '@objectstack/spec' : pkgVersion ( ) ,
189+ } ;
147190 } ,
148- devDependencies : {
149- '@objectstack/cli' : 'workspace:*' ,
150- 'typescript' : '^5.3.0' ,
191+ get devDependencies ( ) {
192+ return {
193+ '@objectstack/cli' : pkgVersion ( ) ,
194+ 'typescript' : '^5.3.0' ,
195+ } ;
151196 } ,
152197 scripts : {
153198 build : 'objectstack compile' ,
@@ -183,27 +228,65 @@ function printWarning(msg: string) {
183228 console . log ( chalk . yellow ( ` ⚠ ${ msg } ` ) ) ;
184229}
185230
231+ /**
232+ * Detect the package manager that invoked this CLI by inspecting
233+ * `npm_config_user_agent` (set by every modern PM). Falls back to `npm`,
234+ * which is universally available and the safest default for `npx`-style
235+ * invocations.
236+ */
237+ export function detectPackageManager ( env : NodeJS . ProcessEnv = process . env ) : 'npm' | 'pnpm' | 'yarn' | 'bun' {
238+ const ua = env . npm_config_user_agent || '' ;
239+ if ( ua . startsWith ( 'pnpm' ) ) return 'pnpm' ;
240+ if ( ua . startsWith ( 'yarn' ) ) return 'yarn' ;
241+ if ( ua . startsWith ( 'bun' ) ) return 'bun' ;
242+ return 'npm' ;
243+ }
244+
245+ /**
246+ * Validate that `name` is a usable npm package name AND a safe directory
247+ * segment. Mirrors the subset of rules used by `npm init`/`create-vite`.
248+ */
249+ function validateProjectName ( name : string ) : string | null {
250+ if ( ! name ) return 'Project name is required' ;
251+ if ( name . length > 214 ) return 'Project name must be ≤ 214 characters' ;
252+ if ( / [ A - Z ] / . test ( name ) ) return 'Project name must be lowercase' ;
253+ if ( ! / ^ [ a - z 0 - 9 ] [ a - z 0 - 9 . _ - ] * $ / . test ( name ) ) {
254+ return 'Project name must start with a lowercase letter or digit and contain only [a-z0-9._-]' ;
255+ }
256+ if ( name . includes ( '/' ) || name . includes ( '\\' ) || name === '.' || name === '..' ) {
257+ return 'Project name must not contain path separators' ;
258+ }
259+ return null ;
260+ }
261+
186262export default class Init extends Command {
187263 static override id = 'init' ;
188264
189- static override description = 'Initialize a new ObjectStack project in the current directory ' ;
265+ static override description = 'Initialize a new ObjectStack project' ;
190266
191267 static override args = {
192- name : Args . string ( { description : 'Project name (defaults to directory name)' , required : false } ) ,
268+ name : Args . string ( {
269+ description : 'Project name. When provided, a new directory with this name is created; otherwise the current directory is used.' ,
270+ required : false ,
271+ } ) ,
193272 } ;
194273
195274 static override flags = {
196275 template : Flags . string ( { char : 't' , description : 'Template: app, plugin, empty' , default : 'app' } ) ,
197276 install : Flags . boolean ( { description : 'Install dependencies' , default : true , allowNo : true } ) ,
277+ 'package-manager' : Flags . string ( {
278+ char : 'p' ,
279+ description : 'Package manager to use for install (auto-detected from npm_config_user_agent)' ,
280+ options : [ 'npm' , 'pnpm' , 'yarn' , 'bun' ] ,
281+ } ) ,
198282 } ;
199283
200284 async run ( ) : Promise < void > {
201285 const { args, flags } = await this . parse ( Init ) ;
202286
203287 printHeader ( 'Init' ) ;
204288
205- const cwd = process . cwd ( ) ;
206- const projectName = args . name || path . basename ( cwd ) ;
289+ const startCwd = process . cwd ( ) ;
207290 const template = TEMPLATES [ flags . template ] ;
208291
209292 if ( ! template ) {
@@ -212,23 +295,68 @@ export default class Init extends Command {
212295 this . error ( `Unknown template: ${ flags . template } ` ) ;
213296 }
214297
298+ // Resolve target directory + project name.
299+ //
300+ // If a name is supplied, scaffold into ./<name>/ (created if missing).
301+ // This matches `npm create`, `pnpm create`, `vite`, etc. — the user's
302+ // confusion in the bug report came from `init my-app` overwriting the
303+ // current directory while the printed summary said "Project: my-app".
304+ //
305+ // If no name is supplied, scaffold into the current directory and use
306+ // its basename as the project name.
307+ let targetDir : string ;
308+ let projectName : string ;
309+ if ( args . name ) {
310+ const nameError = validateProjectName ( args . name ) ;
311+ if ( nameError ) {
312+ printError ( nameError ) ;
313+ this . error ( nameError ) ;
314+ }
315+ projectName = args . name ;
316+ targetDir = path . resolve ( startCwd , args . name ) ;
317+ if ( fs . existsSync ( targetDir ) ) {
318+ const entries = fs . readdirSync ( targetDir ) . filter ( ( e ) => e !== '.git' ) ;
319+ if ( entries . length > 0 ) {
320+ const msg = `Target directory ${ targetDir } is not empty` ;
321+ printError ( msg ) ;
322+ console . log ( chalk . dim ( ' Choose a different name or remove the existing directory first.' ) ) ;
323+ this . error ( msg ) ;
324+ }
325+ } else {
326+ fs . mkdirSync ( targetDir , { recursive : true } ) ;
327+ }
328+ } else {
329+ targetDir = startCwd ;
330+ projectName = path . basename ( startCwd ) ;
331+ const nameError = validateProjectName ( projectName ) ;
332+ if ( nameError ) {
333+ printError ( `Current directory name "${ projectName } " is not a valid project name. ${ nameError } ` ) ;
334+ console . log ( chalk . dim ( ' Re-run with an explicit name: `objectstack init my-app`' ) ) ;
335+ this . error ( nameError ) ;
336+ }
337+ }
338+
215339 // Check for existing config
216- if ( fs . existsSync ( path . join ( cwd , 'objectstack.config.ts' ) ) ) {
217- printError ( ' objectstack.config.ts already exists in this directory' ) ;
340+ if ( fs . existsSync ( path . join ( targetDir , 'objectstack.config.ts' ) ) ) {
341+ printError ( ` objectstack.config.ts already exists in ${ targetDir } ` ) ;
218342 console . log ( chalk . dim ( ' Use `objectstack generate` to add metadata to an existing project' ) ) ;
219- this . error ( 'objectstack.config.ts already exists in this directory ' ) ;
343+ this . error ( 'objectstack.config.ts already exists' ) ;
220344 }
221345
222346 printKV ( 'Project' , projectName ) ;
223347 printKV ( 'Template' , `${ flags . template } — ${ template . description } ` ) ;
224- printKV ( 'Directory' , cwd ) ;
348+ printKV ( 'Directory' , targetDir ) ;
225349 console . log ( '' ) ;
226350
227351 const createdFiles : string [ ] = [ ] ;
228352
353+ let installSucceeded = false ;
354+ let installAttempted = false ;
355+ let chosenPm : 'npm' | 'pnpm' | 'yarn' | 'bun' = 'npm' ;
356+
229357 try {
230358 // 1. Create package.json if missing
231- const pkgPath = path . join ( cwd , 'package.json' ) ;
359+ const pkgPath = path . join ( targetDir , 'package.json' ) ;
232360 if ( ! fs . existsSync ( pkgPath ) ) {
233361 const pkg = {
234362 name : projectName ,
@@ -247,11 +375,11 @@ export default class Init extends Command {
247375
248376 // 2. Create objectstack.config.ts
249377 const configContent = template . configContent ( projectName ) ;
250- fs . writeFileSync ( path . join ( cwd , 'objectstack.config.ts' ) , configContent ) ;
378+ fs . writeFileSync ( path . join ( targetDir , 'objectstack.config.ts' ) , configContent ) ;
251379 createdFiles . push ( 'objectstack.config.ts' ) ;
252380
253381 // 3. Create tsconfig.json if missing
254- const tsconfigPath = path . join ( cwd , 'tsconfig.json' ) ;
382+ const tsconfigPath = path . join ( targetDir , 'tsconfig.json' ) ;
255383 if ( ! fs . existsSync ( tsconfigPath ) ) {
256384 const tsconfig = {
257385 compilerOptions : {
@@ -275,7 +403,7 @@ export default class Init extends Command {
275403 // 4. Create src files
276404 for ( const [ filePath , contentFn ] of Object . entries ( template . srcFiles ) ) {
277405 const resolvedPath = filePath . replace ( '__name__' , projectName ) ;
278- const fullPath = path . join ( cwd , resolvedPath ) ;
406+ const fullPath = path . join ( targetDir , resolvedPath ) ;
279407 const dir = path . dirname ( fullPath ) ;
280408
281409 if ( ! fs . existsSync ( dir ) ) {
@@ -287,7 +415,7 @@ export default class Init extends Command {
287415 }
288416
289417 // 5. Create .gitignore if missing
290- const gitignorePath = path . join ( cwd , '.gitignore' ) ;
418+ const gitignorePath = path . join ( targetDir , '.gitignore' ) ;
291419 if ( ! fs . existsSync ( gitignorePath ) ) {
292420 fs . writeFileSync ( gitignorePath , `node_modules/\ndist/\n*.tsbuildinfo\n` ) ;
293421 createdFiles . push ( '.gitignore' ) ;
@@ -302,22 +430,45 @@ export default class Init extends Command {
302430
303431 // Install dependencies
304432 if ( flags . install ) {
305- printStep ( 'Installing dependencies...' ) ;
433+ chosenPm = ( flags [ 'package-manager' ] as typeof chosenPm | undefined ) ?? detectPackageManager ( ) ;
434+ printStep ( `Installing dependencies with ${ chosenPm } ...` ) ;
435+ installAttempted = true ;
306436 const { execSync } = await import ( 'child_process' ) ;
307437 try {
308- execSync ( 'pnpm install' , { stdio : 'inherit' , cwd } ) ;
438+ execSync ( `${ chosenPm } install` , { stdio : 'inherit' , cwd : targetDir } ) ;
439+ installSucceeded = true ;
309440 } catch {
310- printWarning ( ' Dependency installation failed. Run `pnpm install` manually.' ) ;
441+ printWarning ( ` Dependency installation with ${ chosenPm } failed. Run \` ${ chosenPm } install\ ` manually in ${ targetDir } .` ) ;
311442 }
312443 }
313444
314- printSuccess ( 'Project initialized!' ) ;
315- console . log ( '' ) ;
316- console . log ( chalk . bold ( ' Next steps:' ) ) ;
317- console . log ( chalk . dim ( ' objectstack validate # Check configuration' ) ) ;
318- console . log ( chalk . dim ( ' objectstack dev # Start development server' ) ) ;
319- console . log ( chalk . dim ( ' objectstack generate # Add objects, views, etc.' ) ) ;
320- console . log ( '' ) ;
445+ if ( ! installAttempted || installSucceeded ) {
446+ printSuccess ( 'Project initialized!' ) ;
447+ console . log ( '' ) ;
448+ console . log ( chalk . bold ( ' Next steps:' ) ) ;
449+ if ( targetDir !== startCwd ) {
450+ console . log ( chalk . dim ( ` cd ${ path . relative ( startCwd , targetDir ) || '.' } ` ) ) ;
451+ }
452+ const runCmd = chosenPm === 'npm' ? 'npx objectstack' : `${ chosenPm } exec objectstack` ;
453+ if ( ! installAttempted ) {
454+ console . log ( chalk . dim ( ` ${ chosenPm } install # Install dependencies` ) ) ;
455+ }
456+ console . log ( chalk . dim ( ` ${ runCmd } validate # Check configuration` ) ) ;
457+ console . log ( chalk . dim ( ` ${ runCmd } dev # Start development server` ) ) ;
458+ console . log ( chalk . dim ( ` ${ runCmd } generate # Add objects, views, etc.` ) ) ;
459+ console . log ( '' ) ;
460+ } else {
461+ // Install failed — surface clear remediation instead of pretending success.
462+ printError ( 'Project scaffolded, but dependency installation failed.' ) ;
463+ console . log ( '' ) ;
464+ console . log ( chalk . bold ( ' To finish setup:' ) ) ;
465+ if ( targetDir !== startCwd ) {
466+ console . log ( chalk . dim ( ` cd ${ path . relative ( startCwd , targetDir ) || '.' } ` ) ) ;
467+ }
468+ console . log ( chalk . dim ( ` ${ chosenPm } install` ) ) ;
469+ console . log ( '' ) ;
470+ this . error ( 'Dependency installation failed' ) ;
471+ }
321472
322473 } catch ( error : any ) {
323474 printError ( error . message || String ( error ) ) ;
0 commit comments