@svgrid/studio 0.1.0

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 ADDED
@@ -0,0 +1,89 @@
1
+ # @svgrid/studio
2
+
3
+ Scaffold a full CRUD screen for SvelteKit from a **live database** or a
4
+ **Drizzle / Prisma schema** in **one command**.
5
+
6
+ ```bash
7
+ # from a live database (PostgreSQL / Supabase / MySQL / SQL Server / SQLite)
8
+ npx @svgrid/studio add customers --db postgres --url "$DATABASE_URL"
9
+
10
+ # or from a schema file - a Drizzle schema.ts or a Prisma schema.prisma
11
+ npx @svgrid/studio add customers --from src/lib/db/schema.ts
12
+ npx @svgrid/studio add User --from prisma/schema.prisma
13
+ ```
14
+
15
+ That generates, into your SvelteKit app:
16
+
17
+ - `src/lib/customers.schema.ts` — the entity schema + row type
18
+ - `src/routes/api/customers/+server.ts` — the API route
19
+ - `src/routes/customers/+page.svelte` — a working screen: grid + edit panel, with
20
+ search, server sort, pagination, and multi-select (optimistic) delete
21
+
22
+ Then:
23
+
24
+ ```bash
25
+ npm run dev # open /customers
26
+ ```
27
+
28
+ ## Connect to a database
29
+
30
+ Install the driver for your database, then point `--db` / `--url` at it. The CLI
31
+ reads the table's columns from the catalog and wires the generated route to that
32
+ driver. No driver is bundled.
33
+
34
+ | Database | Install | Flag |
35
+ | --- | --- | --- |
36
+ | PostgreSQL | `pg` | `--db postgres` |
37
+ | Supabase | `pg` | `--db supabase` |
38
+ | MySQL / MariaDB | `mysql2` | `--db mysql` |
39
+ | SQL Server | `mssql` | `--db mssql` |
40
+ | SQLite | `better-sqlite3` | `--db sqlite` |
41
+
42
+ ```bash
43
+ npm i pg
44
+ npx @svgrid/studio add --all --db postgres --url "$DATABASE_URL" # every table
45
+ npx @svgrid/studio add todos --db sqlite --url ./data.db # one table
46
+ ```
47
+
48
+ ## Scaffold the whole app from your schema
49
+
50
+ `--all --from` reads **every** table/model in a schema file and generates a
51
+ screen for each, plus a nav layout and home page. Foreign keys are followed
52
+ across the file - a Drizzle `.references()` or a Prisma `@relation` becomes a
53
+ searchable lookup, and enums become select fields:
54
+
55
+ ```bash
56
+ npx @svgrid/studio add --all --from src/lib/db/schema.ts # every Drizzle table
57
+ npx @svgrid/studio add --all --from prisma/schema.prisma # every Prisma model
58
+ ```
59
+
60
+ ## Options
61
+
62
+ ```
63
+ svgrid-studio add <name> [--from <schema> | --db <dialect> --url <conn>] [options]
64
+
65
+ --from <path> Drizzle (.ts) or Prisma (.prisma) schema file to introspect
66
+ --db <dialect> Connect to a live database (postgres|supabase|mysql|mssql|sqlite)
67
+ --url <conn> Connection string / file path for --db
68
+ --all Scaffold a screen for every table/model (with --from or --db)
69
+ --table <name> Which table/model to use (defaults to <name>)
70
+ --sql Use createSqlDataSource with an execute() stub (instead of a driver)
71
+ --route <seg> Route segment (default: <name> / table name)
72
+ --api <path> API route path (default: /api/<route>)
73
+ ```
74
+
75
+ With `--db`, the generated route is fully connected to that driver via
76
+ `process.env.DATABASE_URL`. With `--from` (no `--db`) it starts in-memory so the
77
+ screen runs immediately; add `--sql` for a `createSqlDataSource` stub you fill
78
+ in with any client (Drizzle `db.execute`, postgres.js, better-sqlite3, mysql2).
79
+
80
+ ## Regeneration is safe
81
+
82
+ Re-running `add` replaces only the `svgrid:managed` regions of each file — your
83
+ edits outside those markers are preserved.
84
+
85
+ ## Requires
86
+
87
+ `@svgrid/grid` and `@svgrid/enterprise` in your project. The Studio is part of the
88
+ **Enterprise** license (soft-gate: it works unlicensed, with a nudge). See
89
+ [licensing](https://svgrid.com/pricing).
package/dist/cli.js ADDED
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SvGrid Studio CLI. Scaffold a full CRUD screen from a Drizzle schema in one
4
+ * command - the "CRUD app in seconds" entry point.
5
+ *
6
+ * npx @svgrid/studio add customers --from src/lib/db/schema.ts
7
+ * npx @svgrid/studio add orders --from src/lib/db/schema.ts --sql
8
+ *
9
+ * All the work (introspect -> scaffold -> merge-write -> verify) lives in the
10
+ * tested `runStudioAdd` orchestrator; this file is just argument parsing plus
11
+ * the node:fs wiring.
12
+ */
13
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
14
+ import { dirname } from 'node:path';
15
+ import { introspectDatabase, listDatabaseTables, resolveSchemas, runStudioAdd, runStudioAddApp, summarizeVerify, } from '@svgrid/enterprise/studio';
16
+ import { connect } from './db-connect.js';
17
+ import { startDesignerServer } from './designer-server.js';
18
+ const io = {
19
+ readFile: async (path) => {
20
+ try {
21
+ return await readFile(path, 'utf8');
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ },
27
+ writeFile: async (path, contents) => {
28
+ await mkdir(dirname(path), { recursive: true });
29
+ await writeFile(path, contents);
30
+ },
31
+ };
32
+ function parse(args) {
33
+ const out = {};
34
+ const positional = [];
35
+ for (let i = 0; i < args.length; i++) {
36
+ const a = args[i];
37
+ if (a === '--from')
38
+ out.from = args[++i];
39
+ else if (a === '--table')
40
+ out.table = args[++i];
41
+ else if (a === '--route')
42
+ out.route = args[++i];
43
+ else if (a === '--api')
44
+ out.apiRoute = args[++i];
45
+ else if (a === '--sql')
46
+ out.dataSource = 'sql';
47
+ else if (a === '--db')
48
+ out.db = args[++i];
49
+ else if (a === '--url')
50
+ out.url = args[++i];
51
+ else if (a === '--all')
52
+ out.all = true;
53
+ else if (a === '--config')
54
+ out.config = args[++i];
55
+ else if (a === '--out')
56
+ out.outDir = args[++i];
57
+ else if (a === '--port')
58
+ out.port = Number(args[++i]);
59
+ else if (a === '--template')
60
+ out.template = args[++i];
61
+ else if (a === '--no-open')
62
+ out.noOpen = true;
63
+ else if (a === '-h' || a === '--help')
64
+ out.help = true;
65
+ else if (!a.startsWith('-'))
66
+ positional.push(a);
67
+ }
68
+ const name = positional[0];
69
+ if (name && !out.route)
70
+ out.route = name;
71
+ if (name && !out.table)
72
+ out.table = name;
73
+ return out;
74
+ }
75
+ const HELP = `svgrid-studio - scaffold CRUD screens from a schema file or a live database
76
+
77
+ Usage:
78
+ svgrid-studio designer # open the visual designer in your browser
79
+ svgrid-studio add <name> --from <schema> # one table/model from a schema file
80
+ svgrid-studio add --all --from <schema> # every table/model, linked
81
+ svgrid-studio add <name> --db <dialect> --url <conn> # one table from a live database
82
+ svgrid-studio add --all --db <dialect> --url <conn> # every table from a live database
83
+
84
+ Designer (visual app builder, auto-saves to studio.config.json):
85
+ --template <id> open a ready-made sample app (crm | ecommerce | projects | support)
86
+ --config <path> studio.config.json to load + auto-save (default: ./studio.config.json)
87
+ --out <dir> folder to write the generated app into (default: .)
88
+ --port <n> port to serve on (default: 4321)
89
+ --no-open don't open the browser
90
+
91
+ Schema files: a Drizzle schema.ts or a Prisma schema.prisma (auto-detected).
92
+ Foreign keys become searchable relation lookups; enums become select fields.
93
+
94
+ Databases: postgres | supabase | mysql | mssql | sqlite
95
+ (the matching driver - pg / mysql2 / mssql / better-sqlite3 - must be installed)
96
+
97
+ Options:
98
+ --from <path> Drizzle (.ts) or Prisma (.prisma) schema file to introspect
99
+ --db <dialect> Connect to a live database and read its catalog
100
+ --url <conn> Connection string / file path for --db
101
+ --all Scaffold a screen for every table/model (+ nav & home)
102
+ --table <name> Which table/model to use (defaults to <name>)
103
+ --sql Wire a real SQL data source (default with --db)
104
+ --route <seg> Route segment (default: <name> / table name)
105
+ --api <path> API route path (default: /api/<route>)
106
+ -h, --help Show this help
107
+
108
+ Examples:
109
+ svgrid-studio add customers --from src/lib/db/schema.ts
110
+ svgrid-studio add --all --from prisma/schema.prisma
111
+ svgrid-studio add customers --db postgres --url $DATABASE_URL --sql
112
+ svgrid-studio add --all --db supabase --url $DATABASE_URL
113
+ `;
114
+ function report(name, written, verifyLine) {
115
+ process.stdout.write(`Scaffolded "${name}":\n`);
116
+ for (const path of written)
117
+ process.stdout.write(` + ${path}\n`);
118
+ process.stdout.write(` ${verifyLine}\n`);
119
+ }
120
+ async function main() {
121
+ const [cmd, ...rest] = process.argv.slice(2);
122
+ const opts = parse(rest);
123
+ // --- visual designer -----------------------------------------------------
124
+ if (cmd === 'designer' && !opts.help) {
125
+ await startDesignerServer({
126
+ configPath: opts.config ?? 'studio.config.json',
127
+ outDir: opts.outDir ?? '.',
128
+ port: Number.isFinite(opts.port) ? opts.port : 4321,
129
+ open: !opts.noOpen,
130
+ template: opts.template,
131
+ });
132
+ // Keep the process alive; the server holds the event loop until Ctrl+C.
133
+ return;
134
+ }
135
+ if (cmd !== 'add' || opts.help) {
136
+ process.stdout.write(HELP);
137
+ process.exit(opts.help || !cmd ? 0 : 1);
138
+ }
139
+ // --- live database -------------------------------------------------------
140
+ if (opts.db) {
141
+ if (!opts.url) {
142
+ process.stderr.write('svgrid-studio: --url is required with --db\n\n' + HELP);
143
+ process.exit(1);
144
+ }
145
+ const execute = await connect(opts.db, opts.url);
146
+ // A dialect dataSource emits a fully-connected +server.ts (driver + DATABASE_URL);
147
+ // --sql keeps the generic execute() stub instead.
148
+ const ds = opts.dataSource === 'sql' ? 'sql' : opts.db;
149
+ if (opts.all) {
150
+ // Whole app: every table + a nav layout and home page.
151
+ const tables = await listDatabaseTables(opts.db, execute);
152
+ if (tables.length === 0) {
153
+ process.stderr.write('svgrid-studio: no tables found\n');
154
+ process.exit(1);
155
+ }
156
+ const schemas = [];
157
+ for (const table of tables) {
158
+ schemas.push(await introspectDatabase({ dialect: opts.db, table, execute }));
159
+ }
160
+ const res = await runStudioAddApp(schemas, { dataSource: ds }, io);
161
+ report(`app (${schemas.length} entities)`, res.written, summarizeVerify(res.verify));
162
+ process.stdout.write(`\nRun \`npm run dev\` and open /\n`);
163
+ return;
164
+ }
165
+ const table = opts.table ?? opts.route;
166
+ const schema = await introspectDatabase({ dialect: opts.db, table, execute });
167
+ const res = await runStudioAdd({ schema, route: opts.route ?? table, dataSource: ds }, io);
168
+ report(table, res.written, summarizeVerify(res.verify));
169
+ process.stdout.write(`\nRun \`npm run dev\` and open /${opts.route ?? table}\n`);
170
+ return;
171
+ }
172
+ // --- schema file (Drizzle or Prisma) -------------------------------------
173
+ if (!opts.from) {
174
+ process.stderr.write('svgrid-studio: pass --from <schema.ts|schema.prisma> or --db <dialect> --url <conn>\n\n' + HELP);
175
+ process.exit(1);
176
+ }
177
+ if (opts.all) {
178
+ // Whole app from every table/model in the file, linked by foreign keys.
179
+ const schemas = await resolveSchemas(opts.from, io);
180
+ if (schemas.length === 0) {
181
+ process.stderr.write('svgrid-studio: no tables/models found in ' + opts.from + '\n');
182
+ process.exit(1);
183
+ }
184
+ const res = await runStudioAddApp(schemas, { dataSource: opts.dataSource }, io);
185
+ report(`app (${schemas.length} entities)`, res.written, summarizeVerify(res.verify));
186
+ process.stdout.write(`\nRun \`npm run dev\` and open /\n`);
187
+ return;
188
+ }
189
+ const result = await runStudioAdd({ from: opts.from, table: opts.table, route: opts.route, apiRoute: opts.apiRoute, dataSource: opts.dataSource }, io);
190
+ report(result.schema.name, result.written, summarizeVerify(result.verify));
191
+ process.stdout.write(`\nRun \`npm run dev\` and open /${opts.route ?? result.schema.name}\n`);
192
+ }
193
+ main().catch((err) => {
194
+ process.stderr.write(`svgrid-studio: ${err instanceof Error ? err.message : String(err)}\n`);
195
+ process.exit(1);
196
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Connect to a live database by dynamically importing the matching driver, and
3
+ * return a neutral `execute(sql, params)` the Studio introspection core uses.
4
+ *
5
+ * The driver is an optional peer - it must be installed in the project running
6
+ * the CLI (`pg` / `mysql2` / `mssql` / `better-sqlite3`). The module specifier
7
+ * is held in a variable so it isn't a hard dependency of this package. Shared by
8
+ * the `add --db` command and the `designer` connect wizard (`/api/introspect`).
9
+ */
10
+ import { createRequire } from 'node:module';
11
+ import { pathToFileURL } from 'node:url';
12
+ import { join } from 'node:path';
13
+ /**
14
+ * Load a driver from the *project's* node_modules (the current working
15
+ * directory), not this CLI's. In an `npx`/global install the CLI lives in a
16
+ * cache far from the user's project, so resolving relative to `import()` would
17
+ * miss a driver the user installed. Resolving from cwd finds it.
18
+ */
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ async function loadDriver(spec) {
21
+ const requireFromCwd = createRequire(pathToFileURL(join(process.cwd(), 'noop.js')));
22
+ const resolved = requireFromCwd.resolve(spec);
23
+ return import(pathToFileURL(resolved).href);
24
+ }
25
+ export async function connect(dialect, url) {
26
+ if (dialect === 'postgres' || dialect === 'supabase') {
27
+ const pg = await loadDriver('pg');
28
+ const Client = pg.Client ?? pg.default?.Client;
29
+ const client = new Client({ connectionString: url });
30
+ await client.connect();
31
+ return async (sql, params) => (await client.query(sql, params)).rows;
32
+ }
33
+ if (dialect === 'mysql') {
34
+ const mysql = await loadDriver('mysql2/promise');
35
+ const conn = await mysql.createConnection(url);
36
+ return async (sql, params) => {
37
+ const [rows] = await conn.execute(sql, params);
38
+ return rows;
39
+ };
40
+ }
41
+ if (dialect === 'mssql') {
42
+ const mod = await loadDriver('mssql');
43
+ const mssql = mod.default ?? mod;
44
+ const pool = await mssql.connect(url);
45
+ return async (text, params) => {
46
+ const req = pool.request();
47
+ params.forEach((p, i) => req.input(`p${i + 1}`, p));
48
+ return (await req.query(text)).recordset;
49
+ };
50
+ }
51
+ // sqlite (better-sqlite3, synchronous)
52
+ const mod = await loadDriver('better-sqlite3');
53
+ const Database = mod.default ?? mod;
54
+ const db = new Database(url);
55
+ return async (sql, params) => db.prepare(sql).all(...params);
56
+ }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * The local server behind `npx @svgrid/studio designer`. A tiny zero-dep
3
+ * `node:http` server that:
4
+ * - serves the pre-built designer SPA from `@svgrid/enterprise/dist/designer`
5
+ * - GET /api/project -> the saved studio.config.json (or a starter project)
6
+ * - POST /api/project -> auto-save the design back to studio.config.json
7
+ * - POST /api/generate -> write the generated app bundle into the launch folder
8
+ *
9
+ * All the model + codegen work is reused from `@svgrid/enterprise/studio` (the
10
+ * Svelte-free Node bundle); this file is just HTTP + node:fs wiring. Keeping it
11
+ * dependency-free means the CLI runs on bare Node.
12
+ */
13
+ import { createServer } from 'node:http';
14
+ import { createReadStream, existsSync } from 'node:fs';
15
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
16
+ import { dirname, join, normalize, resolve, extname } from 'node:path';
17
+ import { createRequire } from 'node:module';
18
+ import { spawn } from 'node:child_process';
19
+ import { createProject, parseProject, serializeProject, introspectDatabase, listDatabaseTables, probeConnection, linkRelationLabels, buildConnectionString, getSampleApp, } from '@svgrid/enterprise/studio';
20
+ import { connect } from './db-connect.js';
21
+ import { DRIVER_FOR, installDriver, isDriverInstalled } from './driver-install.js';
22
+ const MIME = {
23
+ '.html': 'text/html; charset=utf-8',
24
+ '.js': 'text/javascript; charset=utf-8',
25
+ '.mjs': 'text/javascript; charset=utf-8',
26
+ '.css': 'text/css; charset=utf-8',
27
+ '.json': 'application/json; charset=utf-8',
28
+ '.svg': 'image/svg+xml',
29
+ '.ico': 'image/x-icon',
30
+ '.png': 'image/png',
31
+ '.woff': 'font/woff',
32
+ '.woff2': 'font/woff2',
33
+ '.map': 'application/json; charset=utf-8',
34
+ };
35
+ /** The runtime deps the generated code imports, keyed by a source needle. */
36
+ const RUNTIME_DEPS = [
37
+ ["from '@supabase/supabase-js'", '@supabase/supabase-js', '^2.45.0'],
38
+ ["import pg from 'pg'", 'pg', '^8.11.0'],
39
+ ["from 'mysql2/promise'", 'mysql2', '^3.9.0'],
40
+ ["import mssql from 'mssql'", 'mssql', '^10.0.0'],
41
+ ["import Database from 'better-sqlite3'", 'better-sqlite3', '^11.0.0'],
42
+ ];
43
+ /** Resolve the shipped static designer bundle (`dist/designer`). */
44
+ function resolveDesignerDir() {
45
+ const require = createRequire(import.meta.url);
46
+ const pkgJson = require.resolve('@svgrid/enterprise/package.json');
47
+ const dir = join(dirname(pkgJson), 'dist', 'designer');
48
+ if (!existsSync(join(dir, 'index.html'))) {
49
+ throw new Error('The designer bundle is missing. Reinstall @svgrid/enterprise, or run its build (`npm run build`).');
50
+ }
51
+ return dir;
52
+ }
53
+ /** The project a fresh session opens with: a chosen sample app (`--template`), or
54
+ * a minimal tasks starter. */
55
+ function starterProject(templateId) {
56
+ if (templateId) {
57
+ const app = getSampleApp(templateId);
58
+ if (app)
59
+ return app.build();
60
+ }
61
+ const tasks = {
62
+ name: 'tasks',
63
+ label: 'Task',
64
+ idField: 'id',
65
+ fields: [
66
+ { field: 'id', type: 'number', primaryKey: true, readonly: true },
67
+ { field: 'title', type: 'text', required: true },
68
+ { field: 'status', type: 'enum', options: [{ value: 'todo', label: 'To do' }, { value: 'doing', label: 'Doing' }, { value: 'done', label: 'Done' }] },
69
+ { field: 'due', type: 'date' },
70
+ ],
71
+ };
72
+ return createProject([tasks], { title: 'My App' });
73
+ }
74
+ function send(res, status, body, type = 'application/json; charset=utf-8') {
75
+ res.writeHead(status, { 'content-type': type });
76
+ res.end(body);
77
+ }
78
+ function readBody(req) {
79
+ return new Promise((resolveBody, reject) => {
80
+ const chunks = [];
81
+ req.on('data', (c) => chunks.push(c));
82
+ req.on('end', () => resolveBody(Buffer.concat(chunks).toString('utf8')));
83
+ req.on('error', reject);
84
+ });
85
+ }
86
+ /** Serve a file from the static bundle (SPA fallback to index.html). */
87
+ function serveStatic(res, dir, urlPath) {
88
+ // Strip the query, decode, and normalize away any `..` traversal.
89
+ const clean = normalize(decodeURIComponent(urlPath.split('?')[0])).replace(/^(\.\.[/\\])+/, '');
90
+ let filePath = join(dir, clean);
91
+ if (!filePath.startsWith(dir))
92
+ filePath = join(dir, 'index.html');
93
+ if (clean === '/' || clean === '\\' || !existsSync(filePath))
94
+ filePath = join(dir, 'index.html');
95
+ res.writeHead(200, { 'content-type': MIME[extname(filePath)] ?? 'application/octet-stream' });
96
+ createReadStream(filePath).pipe(res);
97
+ }
98
+ /** Write the generated app bundle to disk and add any runtime deps it imports. */
99
+ async function writeBundle(outDir, files) {
100
+ for (const f of files) {
101
+ const full = join(outDir, f.path);
102
+ await mkdir(dirname(full), { recursive: true });
103
+ await writeFile(full, f.contents);
104
+ }
105
+ const allSource = files.map((f) => f.contents).join('\n');
106
+ const pkgPath = join(outDir, 'package.json');
107
+ if (existsSync(pkgPath)) {
108
+ try {
109
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
110
+ let changed = false;
111
+ for (const [needle, dep, version] of RUNTIME_DEPS) {
112
+ if (allSource.includes(needle)) {
113
+ pkg.dependencies = { ...pkg.dependencies, [dep]: version };
114
+ changed = true;
115
+ }
116
+ }
117
+ if (changed)
118
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
119
+ }
120
+ catch {
121
+ // leave package.json untouched if it isn't valid JSON
122
+ }
123
+ }
124
+ return files.length;
125
+ }
126
+ /** Best-effort open the default browser (no dependency). */
127
+ function openBrowser(url) {
128
+ const cmd = process.platform === 'win32' ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open';
129
+ const args = process.platform === 'win32' ? ['/c', 'start', '""', url] : [url];
130
+ try {
131
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
132
+ }
133
+ catch {
134
+ // ignore - the URL is printed anyway
135
+ }
136
+ }
137
+ /** Start the designer server. Resolves once it is listening. */
138
+ export async function startDesignerServer(opts) {
139
+ const log = opts.log ?? ((line) => process.stdout.write(line + '\n'));
140
+ const dir = resolveDesignerDir();
141
+ const configPath = resolve(process.cwd(), opts.configPath);
142
+ const outDir = resolve(process.cwd(), opts.outDir);
143
+ const server = createServer((req, res) => {
144
+ void handle(req, res).catch((err) => {
145
+ send(res, 500, JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
146
+ });
147
+ });
148
+ async function handle(req, res) {
149
+ const url = req.url ?? '/';
150
+ const path = url.split('?')[0];
151
+ if (path === '/api/project' && req.method === 'GET') {
152
+ const json = existsSync(configPath) ? await readFile(configPath, 'utf8') : null;
153
+ // Round-trip through parse/serialize so we hand the client a normalized,
154
+ // validated project (or the starter / chosen sample when there's no saved config).
155
+ const project = json ? parseProject(json) : starterProject(opts.template);
156
+ send(res, 200, serializeProject(project));
157
+ return;
158
+ }
159
+ if (path === '/api/project' && req.method === 'POST') {
160
+ const body = await readBody(req);
161
+ const project = parseProject(body); // validate before persisting
162
+ await mkdir(dirname(configPath), { recursive: true });
163
+ await writeFile(configPath, serializeProject(project));
164
+ send(res, 200, JSON.stringify({ ok: true, saved: configPath }));
165
+ return;
166
+ }
167
+ if (path === '/api/generate' && req.method === 'POST') {
168
+ const body = await readBody(req);
169
+ const parsed = JSON.parse(body);
170
+ const files = Array.isArray(parsed.files) ? parsed.files : [];
171
+ if (files.length === 0) {
172
+ send(res, 400, JSON.stringify({ error: 'no files to write' }));
173
+ return;
174
+ }
175
+ const count = await writeBundle(outDir, files);
176
+ log(` generated ${count} files -> ${outDir}`);
177
+ send(res, 200, JSON.stringify({ ok: true, count, outDir }));
178
+ return;
179
+ }
180
+ // Assemble a connection string from guided-form fields (host/port/user/...).
181
+ if (path === '/api/build-connection' && req.method === 'POST') {
182
+ const body = await readBody(req);
183
+ const reqBody = JSON.parse(body);
184
+ if (!reqBody.dialect) {
185
+ send(res, 400, JSON.stringify({ error: 'dialect is required' }));
186
+ return;
187
+ }
188
+ try {
189
+ const url = buildConnectionString(reqBody.dialect, reqBody.parts ?? {});
190
+ send(res, 200, JSON.stringify({ url }));
191
+ }
192
+ catch (err) {
193
+ send(res, 400, JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
194
+ }
195
+ return;
196
+ }
197
+ // Install the dialect's driver into the user's project (auto-detects the PM).
198
+ if (path === '/api/install-driver' && req.method === 'POST') {
199
+ const body = await readBody(req);
200
+ const reqBody = JSON.parse(body);
201
+ if (!reqBody.dialect) {
202
+ send(res, 400, JSON.stringify({ error: 'dialect is required' }));
203
+ return;
204
+ }
205
+ const cwd = process.cwd();
206
+ log(` installing ${DRIVER_FOR[reqBody.dialect]} driver...`);
207
+ const result = await installDriver(reqBody.dialect, cwd);
208
+ log(result.ok ? ` driver ready (${result.package})` : ` driver install failed (${result.package})`);
209
+ send(res, result.ok ? 200 : 500, JSON.stringify(result));
210
+ return;
211
+ }
212
+ if (path === '/api/introspect' && req.method === 'POST') {
213
+ const body = await readBody(req);
214
+ const reqBody = JSON.parse(body);
215
+ const { dialect, url, action } = reqBody;
216
+ if (!dialect || !url) {
217
+ send(res, 400, JSON.stringify({ error: 'dialect and url are required' }));
218
+ return;
219
+ }
220
+ let execute;
221
+ try {
222
+ execute = await connect(dialect, url);
223
+ }
224
+ catch (err) {
225
+ // Missing driver / bad connection string - report it, don't crash. When
226
+ // the driver is simply absent, flag it so the UI can offer to install it.
227
+ const msg = err instanceof Error ? err.message : String(err);
228
+ const driver = DRIVER_FOR[dialect];
229
+ const missingDriver = /cannot find|module not found|err_module/i.test(msg) && !isDriverInstalled(dialect, process.cwd());
230
+ const hint = missingDriver ? ` Install the "${driver}" driver in this project (npm i ${driver}).` : '';
231
+ send(res, 502, JSON.stringify({ error: `Could not connect: ${msg}.${hint}`, missingDriver, driver }));
232
+ return;
233
+ }
234
+ if (action === 'test') {
235
+ // "Test connection": prove connectivity + show real row counts per table.
236
+ try {
237
+ const probe = await probeConnection(dialect, execute, { counts: true });
238
+ send(res, 200, JSON.stringify(probe));
239
+ }
240
+ catch (err) {
241
+ send(res, 502, JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
242
+ }
243
+ return;
244
+ }
245
+ if (action === 'introspect') {
246
+ const tables = Array.isArray(reqBody.tables) ? reqBody.tables : [];
247
+ const schemas = [];
248
+ const errors = [];
249
+ // Resilient per table: one bad table (odd type, permissions) shouldn't
250
+ // sink the whole import - collect what worked and report the rest.
251
+ for (const table of tables) {
252
+ try {
253
+ schemas.push(await introspectDatabase({ dialect, table, execute }));
254
+ }
255
+ catch (err) {
256
+ errors.push({ table, message: err instanceof Error ? err.message : String(err) });
257
+ }
258
+ }
259
+ // Resolve each relation's label field against the other picked tables.
260
+ send(res, 200, JSON.stringify({ schemas: linkRelationLabels(schemas), errors }));
261
+ return;
262
+ }
263
+ // Default: list the tables so the wizard can offer a pick list.
264
+ const tables = await listDatabaseTables(dialect, execute);
265
+ send(res, 200, JSON.stringify({ tables }));
266
+ return;
267
+ }
268
+ if (path.startsWith('/api/')) {
269
+ send(res, 404, JSON.stringify({ error: 'not found' }));
270
+ return;
271
+ }
272
+ serveStatic(res, dir, url);
273
+ }
274
+ const url = `http://localhost:${opts.port}`;
275
+ await new Promise((ready, fail) => {
276
+ server.once('error', (err) => {
277
+ if (err.code === 'EADDRINUSE') {
278
+ fail(new Error(`Port ${opts.port} is already in use. Pick another with --port <n>.`));
279
+ }
280
+ else {
281
+ fail(err);
282
+ }
283
+ });
284
+ server.listen(opts.port, () => ready());
285
+ });
286
+ if (opts.template && !getSampleApp(opts.template)) {
287
+ const { sampleApps } = await import('@svgrid/enterprise/studio');
288
+ log(` (unknown --template "${opts.template}"; try: ${sampleApps.map((s) => s.id).join(', ')})`);
289
+ }
290
+ log(`\n SvGrid Studio designer running at ${url}`);
291
+ log(` config: ${configPath}`);
292
+ log(` output: ${outDir}`);
293
+ log(` (edits auto-save; "Save to folder" writes the app. Ctrl+C to stop.)\n`);
294
+ if (opts.open)
295
+ openBrowser(url);
296
+ return { url, close: () => server.close() };
297
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Install the database driver a dialect needs, into the *user's* project. Powers
3
+ * the designer's "Install driver" button and the CLI's auto-offer, so a user
4
+ * connecting to Postgres never has to know they must `npm i pg` first - Studio
5
+ * detects the project's package manager and runs it for them.
6
+ *
7
+ * Node-only (spawns the package manager); kept out of the Svelte-free enterprise
8
+ * bundle. The driver package names mirror `DRIVER_PACKAGE` in
9
+ * `@svgrid/enterprise/studio`.
10
+ */
11
+ import { spawn } from 'node:child_process';
12
+ import { existsSync } from 'node:fs';
13
+ import { createRequire } from 'node:module';
14
+ import { join as joinNative } from 'node:path';
15
+ import { pathToFileURL as toFileURL } from 'node:url';
16
+ /** The npm driver package each dialect connects through. */
17
+ export const DRIVER_FOR = {
18
+ postgres: 'pg',
19
+ supabase: 'pg',
20
+ mysql: 'mysql2',
21
+ mssql: 'mssql',
22
+ sqlite: 'better-sqlite3',
23
+ };
24
+ /** Pick the package manager from the project's lockfile (npm is the fallback). */
25
+ export function detectPackageManager(cwd) {
26
+ if (existsSync(joinNative(cwd, 'pnpm-lock.yaml')))
27
+ return 'pnpm';
28
+ if (existsSync(joinNative(cwd, 'yarn.lock')))
29
+ return 'yarn';
30
+ if (existsSync(joinNative(cwd, 'bun.lockb')) || existsSync(joinNative(cwd, 'bun.lock')))
31
+ return 'bun';
32
+ return 'npm';
33
+ }
34
+ /** The install args to add `pkg` as a dev dependency for a given manager. */
35
+ export function installArgs(pm, pkg) {
36
+ switch (pm) {
37
+ case 'pnpm':
38
+ return ['add', '-D', pkg];
39
+ case 'yarn':
40
+ return ['add', '--dev', pkg];
41
+ case 'bun':
42
+ return ['add', '-d', pkg];
43
+ default:
44
+ return ['install', '--save-dev', pkg];
45
+ }
46
+ }
47
+ /** Is the driver already resolvable from the project? (skip a needless install). */
48
+ export function isDriverInstalled(dialect, cwd) {
49
+ const pkg = DRIVER_FOR[dialect];
50
+ try {
51
+ const requireFromCwd = createRequire(toFileURL(joinNative(cwd, 'noop.js')));
52
+ requireFromCwd.resolve(pkg);
53
+ return true;
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
59
+ /**
60
+ * Install the dialect's driver into `cwd`. No-ops (ok:true) when it's already
61
+ * present. Never rejects: a failed install resolves with `ok:false` and the
62
+ * captured output so the UI can show it.
63
+ */
64
+ export function installDriver(dialect, cwd) {
65
+ const pkg = DRIVER_FOR[dialect];
66
+ const manager = detectPackageManager(cwd);
67
+ if (isDriverInstalled(dialect, cwd)) {
68
+ return Promise.resolve({ ok: true, alreadyInstalled: true, manager, package: pkg, output: '' });
69
+ }
70
+ return new Promise((resolve) => {
71
+ let output = '';
72
+ // shell:true on Windows so `npm`/`pnpm` (.cmd shims) resolve on PATH.
73
+ const child = spawn(manager, installArgs(manager, pkg), {
74
+ cwd,
75
+ shell: process.platform === 'win32',
76
+ });
77
+ const capture = (buf) => {
78
+ output += buf.toString();
79
+ if (output.length > 8000)
80
+ output = output.slice(-8000);
81
+ };
82
+ child.stdout?.on('data', capture);
83
+ child.stderr?.on('data', capture);
84
+ child.on('error', (err) => resolve({ ok: false, alreadyInstalled: false, manager, package: pkg, output: `${output}\n${String(err)}`.trim() }));
85
+ child.on('close', (code) => resolve({ ok: code === 0, alreadyInstalled: false, manager, package: pkg, output: output.trim() }));
86
+ });
87
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@svgrid/studio",
3
+ "version": "0.1.0",
4
+ "description": "SvGrid Studio CLI: scaffold a full CRUD screen (grid + edit panel + SvelteKit API route) from a Drizzle schema in one command.",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "author": "jQWidgets <sales@jqwidgets.com>",
7
+ "homepage": "https://svgrid.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/sv-grid/sv-grid.git",
11
+ "directory": "packages/studio"
12
+ },
13
+ "type": "module",
14
+ "bin": {
15
+ "svgrid-studio": "dist/cli.js"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.json",
24
+ "test:types": "tsc -p tsconfig.json --noEmit"
25
+ },
26
+ "dependencies": {
27
+ "@svgrid/enterprise": "workspace:*"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^22.10.7",
31
+ "typescript": "6.0.3"
32
+ },
33
+ "engines": {
34
+ "node": ">=18"
35
+ }
36
+ }