Skip to content

Commit 8fab8a7

Browse files
authored
fix(cli): make objectstack init produce a working project
1 parent f925265 commit 8fab8a7

3 files changed

Lines changed: 305 additions & 41 deletions

File tree

CHANGELOG.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed — `objectstack init` produces a working project
11+
12+
`npx @objectstack/cli init my-app` used to scaffold a project that
13+
could not install or run:
14+
15+
- The generated `package.json` pinned every dependency with the
16+
`workspace:*` / `workspace:^` protocol, so pnpm/npm/yarn/bun all
17+
rejected the install with
18+
`ERR_PNPM_WORKSPACE_PKG_NOT_FOUND` outside the monorepo.
19+
- The `plugin` template omitted `@objectstack/cli` from
20+
`devDependencies`, so `npm run dev` failed with
21+
`sh: objectstack: command not found` even when install succeeded.
22+
- `objectstack init my-app` scaffolded into the **current** directory
23+
while the summary printed `Project: my-app / Directory: <cwd>`
24+
misleading users into clobbering an unrelated folder.
25+
- When dependency installation failed, the command still printed
26+
`✓ Project initialized!` followed by `Next steps: objectstack dev`,
27+
none of which worked without `node_modules`.
28+
- The install step was hard-coded to `pnpm install` regardless of how
29+
the CLI was invoked.
30+
31+
The `init` command now:
32+
33+
- Pins every scaffolded `@objectstack/*` dependency to the CLI's own
34+
version (e.g. `^6.5.0`), resolved at runtime from the CLI's
35+
`package.json`. The version pin tracks every release automatically
36+
and matches the package set that was just `npx`'d.
37+
- Always includes `@objectstack/cli` in `devDependencies` so generated
38+
npm scripts can resolve the `objectstack` binary.
39+
- Creates `./<name>/` when a project name is supplied (matching
40+
`npm create`, `pnpm create`, `vite`, etc.), refusing to overwrite a
41+
non-empty target directory. Omitting the name keeps the previous
42+
current-directory behaviour.
43+
- Validates the project name (lowercase, npm-safe) before doing any I/O.
44+
- Auto-detects the invoking package manager from
45+
`npm_config_user_agent` (npm / pnpm / yarn / bun), with a new
46+
`--package-manager` flag for explicit overrides.
47+
- Treats install failure as a hard error — prints a clear `cd` +
48+
`<pm> install` remediation path and exits non-zero instead of
49+
pretending the project is ready.
50+
- Tailors `Next steps` to include `cd <dir>` and `<pm> exec
51+
objectstack …` so the printed commands actually work from where the
52+
user is standing.
53+
1054
### Changed — `@object-ui/*` upgraded to v6.0
1155

1256
Bundled UI assets (`@object-ui/console`, `@object-ui/studio`,

packages/cli/src/commands/init.ts

Lines changed: 192 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,39 @@ import { Args, Command, Flags } from '@oclif/core';
44
import chalk from 'chalk';
55
import fs from 'fs';
66
import path from 'path';
7+
import { fileURLToPath } from 'url';
78
import { 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+
940
export 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-z0-9][a-z0-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+
186262
export 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

Comments
 (0)