create-gentiq-app 0.14.0 → 0.15.0-beta.2

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/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.2",
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
+ }
@@ -0,0 +1,42 @@
1
+ // Entries that must never reach the published tarball or a scaffolded project.
2
+ //
3
+ // Shared deliberately. This list used to be duplicated in copy-template.js and
4
+ // index.js, and the copies had already drifted: only copy-template.js knew about
5
+ // lockfiles, so scaffolding from a clone copied a `uv.lock` resolved against
6
+ // `workspace:*`. One definition, two consumers.
7
+
8
+ // Build artefacts, virtualenvs and — critically — any `.env` a contributor left
9
+ // in the template while working on it. None of these belong in a fresh project.
10
+ const SKIPPED_NAMES = new Set([
11
+ 'node_modules',
12
+ '.venv',
13
+ '__pycache__',
14
+ '.ruff_cache',
15
+ '.pytest_cache',
16
+ 'dist',
17
+ '.vite',
18
+ '.storage',
19
+ '.env',
20
+ // The CLI rewrites the `gentiq` dependency to a published version range, so any
21
+ // lockfile resolved against `workspace:*` would be stale on arrival.
22
+ 'uv.lock',
23
+ 'package-lock.json',
24
+ 'pnpm-lock.yaml',
25
+ 'yarn.lock',
26
+ 'bun.lockb',
27
+ ]);
28
+
29
+ // The sqlite database cannot be matched by name. `_create_sqlite` builds its
30
+ // filename from the app, not the framework — `f"{app_name.lower()}.db"` — so the
31
+ // starter's `app_name="My Gentiq App"` writes `my gentiq app.db`, and the exact
32
+ // `gentiq.db` entry this replaces never once matched it. A contributor's local
33
+ // database — real accounts, real chat history — was therefore eligible for
34
+ // publishing, and `*.db` in .gitignore kept it out of `git status` where anyone
35
+ // would have noticed. Match the extension, plus the sidecars SQLite writes next
36
+ // to it under WAL or rollback journaling.
37
+ const SKIPPED_PATTERN = /\.db(-wal|-shm|-journal)?$/i;
38
+
39
+ /** @param {string} name A single path segment, not a full path. */
40
+ export function shouldSkipEntry(name) {
41
+ return SKIPPED_NAMES.has(name) || SKIPPED_PATTERN.test(name);
42
+ }