create-gentiq-app 0.14.0-beta.7 β 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 +38 -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/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**:
|
|
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
|
-
β
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.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
|
+
}
|