create-gentiq-app 0.14.0 β†’ 0.15.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,7 +32,7 @@ pnpm create gentiq-app my-awesome-ai
32
32
  - ⚑ **AI Streaming**: Real-time agent responses via PydanticAI and Vercel AI SDK.
33
33
  - πŸ› οΈ **Admin Dashboard**: Built-in analytics, history logs, and user management.
34
34
  - πŸ’° **Atomic Balance**: Native tracking and enforcement of per-user token and request limits.
35
- - πŸ“‚ **Flexible Persistence**: Integrated MinIO/S3 for file storage and MongoDB for state.
35
+ - πŸ“‚ **Flexible Persistence**: SQLite + local filesystem out of the box; MongoDB + MinIO/S3 for production.
36
36
  - πŸ”’ **Enterprise-Grade Security**: Full JWT authentication suite and encrypted sessions.
37
37
 
38
38
  ---
@@ -45,11 +45,12 @@ When you initialize a new app, the CLI generates a modern, monorepo-friendly str
45
45
  my-awesome-ai/
46
46
  β”œβ”€β”€ backend/ # FastAPI + PydanticAI application
47
47
  β”‚ β”œβ”€β”€ main.py # App entry point
48
- β”‚ └── pyproject.toml # Python dependencies (UV managed)
48
+ β”‚ β”œβ”€β”€ pyproject.toml # Python dependencies (UV managed)
49
+ β”‚ β”œβ”€β”€ .env.example # Environment variable template
50
+ β”‚ └── .env # Generated, with real secrets β€” gitignored
49
51
  β”œβ”€β”€ frontend/ # Vite + React application
50
52
  β”‚ β”œβ”€β”€ src/ # UI components and hooks
51
53
  β”‚ └── package.json # Frontend dependencies
52
- β”œβ”€β”€ .env.example # Environment variable template
53
54
  └── README.md # Your project documentation
54
55
  ```
55
56
 
@@ -59,24 +60,27 @@ my-awesome-ai/
59
60
 
60
61
  Once your project is scaffolded, follow these steps to get running:
61
62
 
62
- ### 1. Environment Setup
63
- Copy the example environment file and add your API keys and modify other variables as desired:
64
- ```bash
65
- cp .env.example .env
66
- ```
63
+ The CLI does the setup for you: it installs the backend dependencies with `uv sync`,
64
+ installs the frontend dependencies with your package manager, and writes `backend/.env`
65
+ with freshly generated `JWT_SECRET_KEY` and `BACKEND_API_KEY` values. So once it
66
+ finishes, the project runs.
67
+
68
+ ### 1. Launch Backend
67
69
 
68
- ### 2. Launch Backend
69
- The backend uses `uv` for lightning-fast Python package management.
70
70
  ```bash
71
71
  cd backend
72
- uv sync
73
72
  uv run uvicorn main:app.api --reload --port 8000
74
73
  ```
75
74
 
76
- ### 3. Launch Frontend
75
+ The only value you have to supply yourself is the AI provider key. If you did not paste
76
+ one at the prompt, set `OPENAI_API_KEY` in `backend/.env` before starting β€” the agent
77
+ definition in `main.py` builds its provider client on import, so an absent key is a
78
+ startup error rather than a failed chat. Using a different provider? Change the model
79
+ string in `backend/main.py` and set that provider's key instead.
80
+
81
+ ### 2. Launch Frontend
77
82
  ```bash
78
83
  cd frontend
79
- npm install
80
84
  npm run dev
81
85
  ```
82
86
 
@@ -84,6 +88,27 @@ Your app is now live at `http://localhost:5173`!
84
88
 
85
89
  ---
86
90
 
91
+ ## Options
92
+
93
+ ```bash
94
+ npx create-gentiq-app@latest [directory] [options]
95
+ ```
96
+
97
+ | Flag | Effect |
98
+ | --- | --- |
99
+ | `--pm <name>` | Package manager for the frontend install: `pnpm`, `npm`, `yarn` or `bun`. Defaults to the one that invoked the CLI. |
100
+ | `--no-install` | Scaffold only β€” skip `uv sync` and the frontend install. |
101
+ | `--openai-key <key>` | Write this `OPENAI_API_KEY` into `backend/.env` instead of prompting. |
102
+ | `-y`, `--yes` | Accept every default and never prompt. Also the automatic behaviour when there is no TTY, so the CLI is usable from CI. |
103
+ | `-h`, `--help` | Show usage. |
104
+
105
+ Missing tools are reported, not fatal: without `uv` the backend install is skipped with
106
+ an install link, and a package manager that is not on `PATH` falls back to one that is.
107
+ The scaffold itself is complete either way, and the printed next steps name whatever is
108
+ left to run by hand.
109
+
110
+ ---
111
+
87
112
  ## Why Gentiq?
88
113
 
89
114
  Unlike generic boilerplates, Gentiq is built with **developer velocity** and **production scalability** in mind. It provides the "boring" parts of AI developmentβ€”authentication, rate limiting, and persistenceβ€”so you can focus on building your agents.
package/copy-template.js CHANGED
@@ -2,6 +2,8 @@ import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import { fileURLToPath } from 'url';
4
4
 
5
+ import { shouldSkipEntry } from './skipped-entries.js';
6
+
5
7
  const __filename = fileURLToPath(import.meta.url);
6
8
  const __dirname = path.dirname(__filename);
7
9
 
@@ -15,10 +17,7 @@ async function copyTemplate() {
15
17
  await fs.emptyDir(dest);
16
18
 
17
19
  await fs.copy(src, dest, {
18
- filter: (src) => {
19
- const basename = path.basename(src);
20
- return !['node_modules', '.venv', '__pycache__', '.ruff_cache', 'dist', '.storage', 'gentiq.db', 'uv.lock', 'package-lock.json', 'pnpm-lock.yaml'].includes(basename);
21
- }
20
+ filter: (src) => !shouldSkipEntry(path.basename(src)),
22
21
  });
23
22
 
24
23
  console.log('Template copied successfully!');
package/index.js CHANGED
@@ -4,107 +4,470 @@ import * as p from '@clack/prompts';
4
4
  import color from 'picocolors';
5
5
  import fs from 'fs-extra';
6
6
  import path from 'path';
7
+ import crypto from 'node:crypto';
8
+ import { spawn } from 'node:child_process';
7
9
  import { fileURLToPath } from 'url';
8
10
 
11
+ import { shouldSkipEntry } from './skipped-entries.js';
12
+
9
13
  const __filename = fileURLToPath(import.meta.url);
10
14
  const __dirname = path.dirname(__filename);
11
15
 
12
- async function main() {
13
- console.clear();
16
+ const PACKAGE_MANAGERS = ['pnpm', 'npm', 'yarn', 'bun'];
14
17
 
15
- p.intro(`${color.bgBlue(color.black(' create-gentiq-app '))}`);
18
+ const HELP = `
19
+ ${color.bold('create-gentiq-app')} β€” scaffold a full-stack Gentiq application.
16
20
 
17
- // Determine target directory
18
- let targetDir = process.argv[2];
21
+ ${color.bold('Usage')}
22
+ npx create-gentiq-app@latest [directory] [options]
19
23
 
20
- if (!targetDir) {
21
- const dirResponse = await p.text({
22
- message: 'Where should we create your project?',
23
- placeholder: './my-gentiq-app',
24
- initialValue: './my-gentiq-app',
25
- validate(value) {
26
- if (value.length === 0) return 'Directory cannot be empty';
27
- },
24
+ ${color.bold('Options')}
25
+ --pm <name> Frontend package manager: pnpm | npm | yarn | bun
26
+ --install Install backend and frontend dependencies (default)
27
+ --no-install Scaffold only; skip \`uv sync\` and the frontend install
28
+ --openai-key <k> Write this OPENAI_API_KEY into backend/.env
29
+ -y, --yes Accept every default; never prompt (for CI)
30
+ -h, --help Show this message
31
+ `;
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Process helpers
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * Run a command, buffering its output so a spinner stays intact. Never rejects β€”
39
+ * a missing binary and a non-zero exit are both outcomes the caller reports on.
40
+ */
41
+ function run(command, args, cwd) {
42
+ return new Promise((resolve) => {
43
+ const child = spawn(command, args, {
44
+ cwd,
45
+ // Windows resolves `npm`/`pnpm` only through their .cmd shims.
46
+ shell: process.platform === 'win32',
47
+ stdio: ['ignore', 'pipe', 'pipe'],
48
+ env: { ...process.env, ADBLOCK: '1', DISABLE_OPENCOLLECTIVE: '1' },
28
49
  });
29
50
 
30
- if (p.isCancel(dirResponse)) {
31
- p.cancel('Operation cancelled');
32
- process.exit(0);
51
+ let output = '';
52
+ const collect = (chunk) => {
53
+ output += chunk.toString();
54
+ };
55
+ child.stdout.on('data', collect);
56
+ child.stderr.on('data', collect);
57
+
58
+ child.on('error', (error) =>
59
+ resolve({ ok: false, missing: error.code === 'ENOENT', output: output + error.message }),
60
+ );
61
+ child.on('close', (code) => resolve({ ok: code === 0, missing: false, output, code }));
62
+ });
63
+ }
64
+
65
+ const hasCommand = async (command) => (await run(command, ['--version'])).ok;
66
+
67
+ /** The package manager that invoked us, e.g. `pnpm create gentiq-app`. */
68
+ function detectPackageManager() {
69
+ const agent = process.env.npm_config_user_agent || '';
70
+ return PACKAGE_MANAGERS.find((pm) => agent.startsWith(`${pm}/`)) || null;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Version pinning
75
+ //
76
+ // The three published packages (`gentiq` on PyPI, `gentiq` on npm and this CLI)
77
+ // release in lockstep, so the CLI's own version names exactly the runtime this
78
+ // template was written against. Pinning to it is what keeps a scaffold produced
79
+ // by an older cached CLI from installing a newer, incompatible framework.
80
+ // ---------------------------------------------------------------------------
81
+
82
+ const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:-(alpha|beta|rc)\.?(\d+)?)?$/;
83
+
84
+ function versionRanges(version) {
85
+ const match = String(version).match(VERSION_RE);
86
+ if (!match) return null;
87
+
88
+ const [, major, minor, patch, kind, num] = match;
89
+ // 0.x treats the minor as the breaking-change axis, exactly as npm's `^` does.
90
+ const upper = major === '0' ? `0.${Number(minor) + 1}.0` : `${Number(major) + 1}.0.0`;
91
+ // PEP 440 spells pre-releases `0.15.0b1`, semver `0.15.0-beta.1`. Naming one in
92
+ // the specifier is also what allows uv to resolve it at all.
93
+ const pep440 = kind
94
+ ? `${major}.${minor}.${patch}${kind === 'alpha' ? 'a' : kind === 'beta' ? 'b' : 'rc'}${num ?? 0}`
95
+ : `${major}.${minor}.${patch}`;
96
+
97
+ return { npm: `^${version}`, pypi: `>=${pep440},<${upper}` };
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Scaffolding steps
102
+ // ---------------------------------------------------------------------------
103
+
104
+ function resolveTemplateDir() {
105
+ // Inside the repo the template lives at the root; the published tarball carries
106
+ // its own copy next to this file (see copy-template.js).
107
+ const candidates = [
108
+ path.join(__dirname, 'templates', 'starter'),
109
+ path.join(__dirname, '..', '..', 'templates', 'starter'),
110
+ ];
111
+ const found = candidates.find((dir) => fs.existsSync(dir));
112
+ if (!found) throw new Error('Could not locate the starter template.');
113
+ return found;
114
+ }
115
+
116
+ /**
117
+ * npm never ships a file named `.gitignore` inside a tarball, so the template
118
+ * carries them as `_gitignore` and they are renamed on the way out.
119
+ */
120
+ async function restoreGitignores(dir) {
121
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
122
+ const full = path.join(dir, entry.name);
123
+ if (entry.isDirectory()) {
124
+ await restoreGitignores(full);
125
+ } else if (entry.name === '_gitignore') {
126
+ await fs.move(full, path.join(dir, '.gitignore'), { overwrite: true });
33
127
  }
34
-
35
- targetDir = dirResponse;
128
+ }
129
+ }
130
+
131
+ /** Replace `KEY=...` in place, whether the line is currently commented out or not. */
132
+ function setEnvValue(content, key, value) {
133
+ const line = new RegExp(`^#?[ \\t]*${key}=.*$`, 'm');
134
+ return line.test(content)
135
+ ? content.replace(line, `${key}=${value}`)
136
+ : `${content.trimEnd()}\n${key}=${value}\n`;
137
+ }
138
+
139
+ /**
140
+ * Write `backend/.env` with real secrets. Without it the app fails closed: the
141
+ * JWT signing key has no default, and the server-to-server routes reject every
142
+ * request while BACKEND_API_KEY is unset. Generating them here is what makes the
143
+ * scaffold runnable before the developer has edited anything.
144
+ */
145
+ async function writeEnvFile(backendDir, openaiKey) {
146
+ const examplePath = path.join(backendDir, '.env.example');
147
+ const envPath = path.join(backendDir, '.env');
148
+ if (!fs.existsSync(examplePath) || fs.existsSync(envPath)) return false;
149
+
150
+ const secret = () => crypto.randomBytes(32).toString('hex');
151
+ let content = await fs.readFile(examplePath, 'utf8');
152
+ content = setEnvValue(content, 'JWT_SECRET_KEY', secret());
153
+ content = setEnvValue(content, 'BACKEND_API_KEY', secret());
154
+ if (openaiKey) content = setEnvValue(content, 'OPENAI_API_KEY', openaiKey);
155
+
156
+ await fs.writeFile(envPath, content, 'utf8');
157
+ return true;
158
+ }
159
+
160
+ /** Point the template's workspace dependencies at the published packages. */
161
+ async function pinDependencies(projectPath, ranges) {
162
+ const frontendPkgPath = path.join(projectPath, 'frontend', 'package.json');
163
+ if (fs.existsSync(frontendPkgPath)) {
164
+ const pkg = await fs.readJson(frontendPkgPath);
165
+ if (pkg.dependencies?.gentiq) {
166
+ pkg.dependencies.gentiq = ranges?.npm ?? 'latest';
167
+ }
168
+ await fs.writeJson(frontendPkgPath, pkg, { spaces: 2 });
169
+ }
170
+
171
+ const pyprojectPath = path.join(projectPath, 'backend', 'pyproject.toml');
172
+ if (ranges && fs.existsSync(pyprojectPath)) {
173
+ const toml = await fs.readFile(pyprojectPath, 'utf8');
174
+ await fs.writeFile(
175
+ pyprojectPath,
176
+ toml.replace(/"gentiq\[engines\][^"]*"/, `"gentiq[engines]${ranges.pypi}"`),
177
+ 'utf8',
178
+ );
179
+ }
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Dependency installation
184
+ // ---------------------------------------------------------------------------
185
+
186
+ async function installBackend(projectPath, results) {
187
+ const backendDir = path.join(projectPath, 'backend');
188
+ if (!fs.existsSync(backendDir)) return;
189
+
190
+ if (!(await hasCommand('uv'))) {
191
+ results.push({
192
+ name: 'backend',
193
+ status: 'skipped',
194
+ hint: `${color.bold('uv')} is not installed β€” see ${color.underline('https://docs.astral.sh/uv/getting-started/installation/')}, then run ${color.cyan('uv sync')} in ${color.cyan('backend/')}.`,
195
+ });
196
+ return;
197
+ }
198
+
199
+ const result = await run('uv', ['sync'], backendDir);
200
+ results.push({
201
+ name: 'backend',
202
+ status: result.ok ? 'ok' : 'failed',
203
+ command: 'uv sync',
204
+ output: result.output,
205
+ });
206
+ }
207
+
208
+ async function installFrontend(projectPath, packageManager, results) {
209
+ const frontendDir = path.join(projectPath, 'frontend');
210
+ if (!fs.existsSync(frontendDir)) return;
211
+
212
+ // The requested manager may not exist on this machine (`--pm pnpm` on a plain
213
+ // npm box), so fall back rather than failing the whole scaffold.
214
+ let pm = packageManager;
215
+ if (!(await hasCommand(pm))) {
216
+ const fallback = [];
217
+ for (const candidate of PACKAGE_MANAGERS) {
218
+ if (candidate !== pm && (await hasCommand(candidate))) fallback.push(candidate);
219
+ }
220
+ if (fallback.length === 0) {
221
+ results.push({
222
+ name: 'frontend',
223
+ status: 'skipped',
224
+ hint: `No package manager found β€” install Node.js, then run ${color.cyan('npm install')} in ${color.cyan('frontend/')}.`,
225
+ });
226
+ return;
227
+ }
228
+ pm = fallback[0];
229
+ }
230
+
231
+ const result = await run(pm, ['install'], frontendDir);
232
+ results.push({
233
+ name: 'frontend',
234
+ status: result.ok ? 'ok' : 'failed',
235
+ command: `${pm} install`,
236
+ output: result.output,
237
+ packageManager: pm,
238
+ });
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // CLI
243
+ // ---------------------------------------------------------------------------
244
+
245
+ function parseArgs(argv) {
246
+ const options = { targetDir: null, install: null, packageManager: null, openaiKey: null, yes: false, help: false };
247
+
248
+ for (let i = 0; i < argv.length; i++) {
249
+ const arg = argv[i];
250
+ if (arg === '-h' || arg === '--help') options.help = true;
251
+ else if (arg === '-y' || arg === '--yes') options.yes = true;
252
+ else if (arg === '--install') options.install = true;
253
+ else if (arg === '--no-install' || arg === '--skip-install') options.install = false;
254
+ else if (arg === '--pm' || arg === '--package-manager') options.packageManager = argv[++i];
255
+ else if (arg.startsWith('--pm=')) options.packageManager = arg.slice('--pm='.length);
256
+ else if (arg === '--openai-key') options.openaiKey = argv[++i];
257
+ else if (arg.startsWith('--openai-key=')) options.openaiKey = arg.slice('--openai-key='.length);
258
+ else if (!arg.startsWith('-') && !options.targetDir) options.targetDir = arg;
259
+ }
260
+
261
+ if (options.packageManager && !PACKAGE_MANAGERS.includes(options.packageManager)) {
262
+ console.error(`Unknown package manager "${options.packageManager}". Expected one of: ${PACKAGE_MANAGERS.join(', ')}.`);
263
+ process.exit(1);
264
+ }
265
+
266
+ return options;
267
+ }
268
+
269
+ const bail = (value) => {
270
+ if (p.isCancel(value)) {
271
+ p.cancel('Operation cancelled');
272
+ process.exit(0);
273
+ }
274
+ return value;
275
+ };
276
+
277
+ async function main() {
278
+ const options = parseArgs(process.argv.slice(2));
279
+
280
+ if (options.help) {
281
+ console.log(HELP);
282
+ return;
283
+ }
284
+
285
+ // Prompting requires a TTY on both ends; CI gets the defaults instead.
286
+ const interactive = !options.yes && process.stdin.isTTY && process.stdout.isTTY;
287
+
288
+ console.clear();
289
+ p.intro(`${color.bgBlue(color.black(' create-gentiq-app '))}`);
290
+
291
+ let targetDir = options.targetDir;
292
+ if (!targetDir) {
293
+ targetDir = interactive
294
+ ? bail(
295
+ await p.text({
296
+ message: 'Where should we create your project?',
297
+ placeholder: './my-gentiq-app',
298
+ initialValue: './my-gentiq-app',
299
+ validate: (value) => (value.length === 0 ? 'Directory cannot be empty' : undefined),
300
+ }),
301
+ )
302
+ : './my-gentiq-app';
36
303
  }
37
304
 
38
305
  const projectPath = path.resolve(process.cwd(), targetDir);
39
306
 
40
307
  if (fs.existsSync(projectPath) && fs.readdirSync(projectPath).length > 0) {
41
- const overwrite = await p.confirm({
42
- message: `Directory ${targetDir} is not empty. Continue anyway?`,
43
- initialValue: false,
44
- });
308
+ const overwrite = interactive
309
+ ? bail(
310
+ await p.confirm({
311
+ message: `Directory ${targetDir} is not empty. Continue anyway?`,
312
+ initialValue: false,
313
+ }),
314
+ )
315
+ : false;
45
316
 
46
- if (p.isCancel(overwrite) || !overwrite) {
317
+ if (!overwrite) {
47
318
  p.cancel('Operation cancelled');
48
319
  process.exit(0);
49
320
  }
50
321
  }
51
322
 
323
+ let packageManager = options.packageManager || detectPackageManager() || 'npm';
324
+ let install = options.install;
325
+ let openaiKey = options.openaiKey;
326
+
327
+ if (interactive) {
328
+ if (install === null) {
329
+ install = bail(
330
+ await p.confirm({
331
+ message: 'Install dependencies now?',
332
+ initialValue: true,
333
+ }),
334
+ );
335
+ }
336
+
337
+ if (install && !options.packageManager) {
338
+ packageManager = bail(
339
+ await p.select({
340
+ message: 'Which package manager should the frontend use?',
341
+ initialValue: packageManager,
342
+ options: PACKAGE_MANAGERS.map((pm) => ({ value: pm, label: pm })),
343
+ }),
344
+ );
345
+ }
346
+
347
+ if (!openaiKey) {
348
+ const answer = bail(
349
+ await p.text({
350
+ message: 'OpenAI API key? (optional β€” press Enter to add it to backend/.env later)',
351
+ placeholder: 'sk-proj-...',
352
+ defaultValue: '',
353
+ }),
354
+ );
355
+ openaiKey = answer.trim() || null;
356
+ }
357
+ }
358
+
359
+ if (install === null) install = true;
360
+
52
361
  const s = p.spinner();
53
362
  s.start('Scaffolding project...');
54
363
 
364
+ const installResults = [];
365
+ let envCreated = false;
366
+
55
367
  try {
56
- // 1. Copy files from template
57
- // In dev, the template is in ../../templates/starter
58
- // In prod when published, it might be in different relative path, but assuming we pack it together
59
- // Let's resolve the starter template path. Since this is in packages/cli, it's 2 levels up.
60
- let templateDir = path.join(__dirname, '..', '..', 'templates', 'starter');
61
-
62
- // Fallback if packed differently on npm
63
- if (!fs.existsSync(templateDir)) {
64
- templateDir = path.join(__dirname, 'templates', 'starter');
65
- }
368
+ const templateDir = resolveTemplateDir();
66
369
 
67
370
  await fs.copy(templateDir, projectPath, {
68
- filter: (src) => {
69
- const basename = path.basename(src);
70
- return !['node_modules', '.venv', '__pycache__', '.ruff_cache', 'dist'].includes(basename);
71
- }
371
+ filter: (src) => !shouldSkipEntry(path.basename(src)),
72
372
  });
73
373
 
74
- // 2. Adjust frontend package.json
75
- const frontendPkgPath = path.join(projectPath, 'frontend', 'package.json');
76
- if (fs.existsSync(frontendPkgPath)) {
77
- let pkgConfig = await fs.readJson(frontendPkgPath);
78
- // Remove workspace dependency, replace with latest
79
- pkgConfig.dependencies['gentiq'] = 'latest';
80
- await fs.writeJson(frontendPkgPath, pkgConfig, { spaces: 2 });
81
- }
374
+ await restoreGitignores(projectPath);
82
375
 
83
- // 3. Adjust backend pyproject.toml
84
- // Already removed tool.uv.sources in the template, so it relies on the 'gentiq' pip package by default.
85
- // If there's any other workspace specific replace, we can do it here.
376
+ const { version } = await fs.readJson(path.join(__dirname, 'package.json'));
377
+ await pinDependencies(projectPath, versionRanges(version));
86
378
 
87
- s.stop(`Project scaffolded successfully!`);
379
+ envCreated = await writeEnvFile(path.join(projectPath, 'backend'), openaiKey);
88
380
 
89
- p.note(
90
- `${color.cyan('cd')} ${targetDir}\n\n` +
91
- `${color.bold('Frontend:')}\n` +
92
- `${color.cyan('cd')} frontend\n` +
93
- `${color.cyan('npm')} install\n` +
94
- `${color.cyan('npm')} run dev\n\n` +
95
- `${color.bold('Backend:')}\n` +
96
- `${color.cyan('cd')} backend\n` +
97
- `${color.cyan('uv')} sync\n` +
98
- `${color.cyan('uv')} run uvicorn main:app.api --reload --port 8000`,
99
- 'Next Steps'
100
- );
101
-
102
- p.outro(`Good luck with your Gentiq app! Need help? Visit our docs.`);
381
+ s.stop('Project scaffolded successfully!');
103
382
  } catch (error) {
104
383
  s.stop('Failed to scaffold project');
105
384
  console.error(error);
106
385
  process.exit(1);
107
386
  }
387
+
388
+ if (install) {
389
+ s.start('Installing backend dependencies (uv sync)...');
390
+ await installBackend(projectPath, installResults);
391
+ const backend = installResults.find((r) => r.name === 'backend');
392
+ s.stop(
393
+ backend?.status === 'ok'
394
+ ? 'Backend dependencies installed'
395
+ : backend?.status === 'failed'
396
+ ? 'Backend dependencies failed to install'
397
+ : 'Backend dependencies skipped',
398
+ );
399
+
400
+ s.start(`Installing frontend dependencies (${packageManager} install)...`);
401
+ await installFrontend(projectPath, packageManager, installResults);
402
+ const frontend = installResults.find((r) => r.name === 'frontend');
403
+ if (frontend?.packageManager) packageManager = frontend.packageManager;
404
+ s.stop(
405
+ frontend?.status === 'ok'
406
+ ? 'Frontend dependencies installed'
407
+ : frontend?.status === 'failed'
408
+ ? 'Frontend dependencies failed to install'
409
+ : 'Frontend dependencies skipped',
410
+ );
411
+ }
412
+
413
+ // Surface why anything was skipped, and the tail of the log for anything that
414
+ // actually failed β€” a silent failure would leave a project that cannot start.
415
+ for (const result of installResults) {
416
+ if (result.status === 'skipped' && result.hint) {
417
+ p.note(result.hint, `${result.name}: skipped`);
418
+ } else if (result.status === 'failed') {
419
+ const tail = (result.output || '').trim().split('\n').slice(-12).join('\n');
420
+ p.note(
421
+ `${color.dim(`$ ${result.command}`)}\n\n${tail}\n\n${color.yellow(`Re-run ${color.cyan(result.command)} in ${color.cyan(`${result.name}/`)} once the cause is fixed.`)}`,
422
+ `${result.name}: install failed`,
423
+ );
424
+ }
425
+ }
426
+
427
+ const backendFailed = installResults.some((r) => r.name === 'backend' && r.status !== 'ok');
428
+ const frontendFailed = installResults.some((r) => r.name === 'frontend' && r.status !== 'ok');
429
+ const runFrontend = packageManager === 'npm' ? 'npm run dev' : `${packageManager} dev`;
430
+
431
+ if (envCreated) {
432
+ p.note(
433
+ `${color.cyan('backend/.env')} was created from ${color.cyan('.env.example')} with freshly generated\n` +
434
+ `${color.bold('JWT_SECRET_KEY')} and ${color.bold('BACKEND_API_KEY')} values. It is gitignored β€” keep it that way.`,
435
+ 'Secrets',
436
+ );
437
+ }
438
+
439
+ // The one thing the scaffold cannot provision for you. pydantic-ai builds the
440
+ // provider client while `main.py` defines the agent, so an absent key is an
441
+ // import-time crash rather than a failed chat β€” worth saying plainly.
442
+ if (!openaiKey) {
443
+ p.note(
444
+ `Set ${color.bold('OPENAI_API_KEY')} in ${color.cyan('backend/.env')} before starting the backend β€”\n` +
445
+ `without it the agent definition in ${color.cyan('main.py')} raises on import.\n\n` +
446
+ `Using another provider? Change the model string in ${color.cyan('backend/main.py')}\n` +
447
+ `and set that provider's key instead.`,
448
+ color.yellow('Action required'),
449
+ );
450
+ }
451
+
452
+ const steps = [`${color.cyan('cd')} ${targetDir}`, ''];
453
+
454
+ steps.push(color.bold('Backend:'), `${color.cyan('cd')} backend`);
455
+ if (!envCreated) {
456
+ steps.push(`${color.cyan('cp')} .env.example .env ${color.dim('# then fill in the required keys')}`);
457
+ }
458
+ if (!install || backendFailed) steps.push(`${color.cyan('uv')} sync`);
459
+ steps.push(`${color.cyan('uv')} run uvicorn main:app.api --reload --port 8000`, '');
460
+
461
+ steps.push(color.bold('Frontend (in a second terminal):'), `${color.cyan('cd')} frontend`);
462
+ if (!install || frontendFailed) steps.push(`${color.cyan(packageManager)} install`);
463
+ steps.push(color.cyan(runFrontend));
464
+
465
+ p.note(steps.join('\n'), 'Next Steps');
466
+
467
+ p.outro(`Good luck with your Gentiq app! Need help? Visit our docs.`);
108
468
  }
109
469
 
110
- main().catch(console.error);
470
+ main().catch((error) => {
471
+ console.error(error);
472
+ process.exit(1);
473
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-gentiq-app",
3
- "version": "0.14.0",
3
+ "version": "0.15.0-beta.1",
4
4
  "description": "CLI to scaffold a new Gentiq application",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,15 +8,18 @@
8
8
  },
9
9
  "files": [
10
10
  "index.js",
11
+ "skipped-entries.js",
11
12
  "copy-template.js",
12
13
  "templates"
13
14
  ],
15
+ "scripts": {
16
+ "start": "node index.js",
17
+ "prepublishOnly": "node copy-template.js",
18
+ "postpublish": "rm -rf templates"
19
+ },
14
20
  "dependencies": {
15
21
  "@clack/prompts": "^0.7.0",
16
22
  "picocolors": "^1.0.0",
17
23
  "fs-extra": "^11.2.0"
18
- },
19
- "scripts": {
20
- "start": "node index.js"
21
24
  }
22
- }
25
+ }