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/README.md +154 -13
- package/copy-template.js +3 -4
- package/index.js +427 -64
- package/package.json +8 -5
- package/skipped-entries.js +42 -0
- package/templates/starter/README.md +105 -40
- package/templates/starter/_gitignore +29 -0
- package/templates/starter/backend/.env.example +53 -0
- package/templates/starter/backend/README.md +27 -26
- package/templates/starter/backend/main.py +21 -22
- package/templates/starter/frontend/README.md +16 -44
- package/templates/starter/frontend/_gitignore +24 -0
- package/templates/starter/frontend/index.html +1 -1
- package/templates/starter/frontend/package.json +2 -2
- package/templates/starter/frontend/src/App.tsx +23 -161
- package/templates/starter/frontend/src/index.css +8 -16
- package/templates/starter/frontend/vite.config.ts +0 -3
- package/LICENSE +0 -201
- package/templates/starter/.env.example +0 -9
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
|
-
|
|
13
|
-
console.clear();
|
|
16
|
+
const PACKAGE_MANAGERS = ['pnpm', 'npm', 'yarn', 'bun'];
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
const HELP = `
|
|
19
|
+
${color.bold('create-gentiq-app')} — scaffold a full-stack Gentiq application.
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
${color.bold('Usage')}
|
|
22
|
+
npx create-gentiq-app@latest [directory] [options]
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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 =
|
|
42
|
-
|
|
43
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
379
|
+
envCreated = await writeEnvFile(path.join(projectPath, 'backend'), openaiKey);
|
|
88
380
|
|
|
89
|
-
|
|
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(
|
|
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.
|
|
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
|
+
}
|