@svgrid/studio 0.2.0 → 0.4.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/LICENSE +74 -35
- package/README.md +42 -8
- package/dist/cli.js +265 -26
- package/dist/copilot.js +43 -0
- package/dist/designer-server.js +118 -21
- package/dist/dev.js +125 -0
- package/dist/driver-install.js +36 -0
- package/package.json +23 -4
package/LICENSE
CHANGED
|
@@ -1,46 +1,85 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
SVGRID STUDIO COMMERCIAL LICENSE
|
|
2
|
+
Version 1.0
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2026 jQWidgets Ltd
|
|
5
|
+
Project: @svgrid/studio
|
|
6
|
+
|
|
7
|
+
This software is licensed, not sold. Use requires a valid, paid SvGrid
|
|
8
|
+
Pro or Enterprise license. The authoritative agreement is the SvGrid
|
|
9
|
+
End User License Agreement at https://svgrid.com/eula; this file
|
|
10
|
+
summarises the terms that apply to this package.
|
|
4
11
|
|
|
5
12
|
------------------------------------------------------------------------
|
|
6
|
-
|
|
13
|
+
1. GRANT (with valid SvGrid Pro or Enterprise license)
|
|
7
14
|
------------------------------------------------------------------------
|
|
8
15
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
Subject to the Subscriber's compliance with this License and continued
|
|
17
|
+
payment of the applicable license fee, the Author grants the Subscriber
|
|
18
|
+
a non-exclusive, non-transferable, non-sublicensable license to:
|
|
19
|
+
|
|
20
|
+
(a) install and run the Software on developer workstations and on
|
|
21
|
+
build / CI machines belonging to the Subscriber;
|
|
22
|
+
(b) use the Software to generate application source code, and to
|
|
23
|
+
use, modify, and distribute that generated output as part of the
|
|
24
|
+
Subscriber's own applications;
|
|
25
|
+
(c) modify the Software for the Subscriber's internal use, provided
|
|
26
|
+
that all copyright notices and license terms are preserved.
|
|
27
|
+
|
|
28
|
+
The number of developer seats covered is determined by the
|
|
29
|
+
Subscriber's SvGrid Pro / Enterprise order. A "developer" is any
|
|
30
|
+
individual who runs the Software, or who writes or modifies source
|
|
31
|
+
code it generated.
|
|
13
32
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
its own MIT LICENSE) is proprietary and may not be copied, modified,
|
|
18
|
-
or redistributed without a paid license. Source is visible in this
|
|
19
|
-
repository for transparency and for paying customers; visibility does
|
|
20
|
-
not grant a license.
|
|
33
|
+
Code generated by the Software is owned by the Subscriber and is not
|
|
34
|
+
subject to Section 2, except that generated code which imports
|
|
35
|
+
`@svgrid/enterprise` still requires an Enterprise license at runtime.
|
|
21
36
|
|
|
22
37
|
------------------------------------------------------------------------
|
|
23
|
-
|
|
38
|
+
2. RESTRICTIONS
|
|
24
39
|
------------------------------------------------------------------------
|
|
25
40
|
|
|
26
|
-
|
|
41
|
+
The Subscriber may NOT:
|
|
27
42
|
|
|
28
|
-
|
|
43
|
+
(a) redistribute the Software as a standalone package, library, CLI,
|
|
44
|
+
or hosted service;
|
|
45
|
+
(b) sublicense, rent, lease, or sell the Software to third parties;
|
|
46
|
+
(c) expose the Software's code generation as a service to users
|
|
47
|
+
outside the Subscriber's organisation;
|
|
48
|
+
(d) use the Software to develop a directly competing application
|
|
49
|
+
builder, code generator, or data-grid product.
|
|
50
|
+
|
|
51
|
+
------------------------------------------------------------------------
|
|
52
|
+
3. SUBSCRIPTION AND TERMINATION
|
|
53
|
+
------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
The license is granted for the term specified in the Subscriber's
|
|
56
|
+
SvGrid Pro or Enterprise order. On non-renewal or material breach of
|
|
57
|
+
Sections 1 or 2, the license terminates automatically and the
|
|
58
|
+
Subscriber must cease all use of the Software and remove it from
|
|
59
|
+
development environments and CI infrastructure. Applications already
|
|
60
|
+
generated and shipped are not affected.
|
|
61
|
+
|
|
62
|
+
------------------------------------------------------------------------
|
|
63
|
+
4. WARRANTY DISCLAIMER AND LIABILITY
|
|
64
|
+
------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
67
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
68
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND
|
|
69
|
+
NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
|
|
70
|
+
CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM USE OF THE SOFTWARE,
|
|
71
|
+
WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE.
|
|
72
|
+
|
|
73
|
+
The Author's total cumulative liability under this License shall not
|
|
74
|
+
exceed the license fees paid by the Subscriber in the twelve months
|
|
75
|
+
preceding the event giving rise to the claim.
|
|
76
|
+
|
|
77
|
+
------------------------------------------------------------------------
|
|
78
|
+
5. CONTACT
|
|
79
|
+
------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
To purchase a license, request a quote, or report unauthorized use,
|
|
82
|
+
contact: boikom@jqwidgets.com
|
|
83
|
+
Pricing: https://svgrid.com/pricing
|
|
29
84
|
|
|
30
|
-
|
|
31
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
32
|
-
in the Software without restriction, including without limitation the rights
|
|
33
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
34
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
35
|
-
furnished to do so, subject to the following conditions:
|
|
36
|
-
|
|
37
|
-
The above copyright notice and this permission notice shall be included in all
|
|
38
|
-
copies or substantial portions of the Software.
|
|
39
|
-
|
|
40
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
41
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
42
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
43
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
44
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
45
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
46
|
-
SOFTWARE.
|
|
85
|
+
END OF LICENSE
|
package/README.md
CHANGED
|
@@ -1,7 +1,41 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://svgrid.com/brand/svgrid-logo-icon-1200.png" alt="SvGrid" width="100" height="100" />
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
+
<h1 align="center">@svgrid/studio</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center"><strong>Generate SvelteKit CRUD screens from your database schema.</strong></p>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://www.npmjs.com/package/@svgrid/studio"><img src="https://img.shields.io/npm/v/%40svgrid%2Fstudio.svg?label=%40svgrid%2Fstudio" alt="npm version" /></a>
|
|
11
|
+
<a href="https://svgrid.com/pricing/"><img src="https://img.shields.io/badge/license-commercial-blue.svg" alt="Commercial license" /></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<a href="https://svgrid.com">Website</a> ·
|
|
16
|
+
<a href="https://svgrid.com/docs/">Docs</a> ·
|
|
17
|
+
<a href="https://svgrid.com/pricing/">Pricing</a>
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
Not sure where to start? Let it ask:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npx @svgrid/studio init
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`init` walks you through it - where your data lives, which tables you want,
|
|
29
|
+
which pages each gets - and writes a runnable SvelteKit app: a searchable list,
|
|
30
|
+
an edit form and a record page per table, plus an overview dashboard. Point it
|
|
31
|
+
straight at a database and it installs the driver for you:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx @svgrid/studio init --db postgres --url "$DATABASE_URL" --out my-app
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or scaffold a single CRUD screen into an existing app from a **live database**
|
|
38
|
+
or a **Drizzle / Prisma schema** in **one command**:
|
|
5
39
|
|
|
6
40
|
```bash
|
|
7
41
|
# from a live database (PostgreSQL / Supabase / MySQL / SQL Server / SQLite)
|
|
@@ -14,9 +48,9 @@ npx @svgrid/studio add User --from prisma/schema.prisma
|
|
|
14
48
|
|
|
15
49
|
That generates, into your SvelteKit app:
|
|
16
50
|
|
|
17
|
-
- `src/lib/customers.schema.ts`
|
|
18
|
-
- `src/routes/api/customers/+server.ts`
|
|
19
|
-
- `src/routes/customers/+page.svelte`
|
|
51
|
+
- `src/lib/customers.schema.ts` - the entity schema + row type
|
|
52
|
+
- `src/routes/api/customers/+server.ts` - the API route
|
|
53
|
+
- `src/routes/customers/+page.svelte` - a working screen: grid + edit panel, with
|
|
20
54
|
search, server sort, pagination, and multi-select (optimistic) delete
|
|
21
55
|
|
|
22
56
|
Then:
|
|
@@ -79,11 +113,11 @@ in with any client (Drizzle `db.execute`, postgres.js, better-sqlite3, mysql2).
|
|
|
79
113
|
|
|
80
114
|
## Regeneration is safe
|
|
81
115
|
|
|
82
|
-
Re-running `add` replaces only the `svgrid:managed` regions of each file
|
|
116
|
+
Re-running `add` replaces only the `svgrid:managed` regions of each file - your
|
|
83
117
|
edits outside those markers are preserved.
|
|
84
118
|
|
|
85
119
|
## Requires
|
|
86
120
|
|
|
87
121
|
`@svgrid/grid` and `@svgrid/enterprise` in your project. The Studio is part of the
|
|
88
122
|
**Enterprise** license (soft-gate: it works unlicensed, with a nudge). See
|
|
89
|
-
[licensing](https://svgrid.com/pricing).
|
|
123
|
+
[licensing](https://svgrid.com/pricing/).
|
package/dist/cli.js
CHANGED
|
@@ -11,11 +11,14 @@
|
|
|
11
11
|
* the node:fs wiring.
|
|
12
12
|
*/
|
|
13
13
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { createInterface } from 'node:readline/promises';
|
|
16
|
+
import { dirname, resolve } from 'node:path';
|
|
17
|
+
import { buildStudioBugReport, createProject, deployCommands, emitStudioAppBundle, emitStudioFragment, introspectDatabase, introspectOpenApi, runtimeDeps, listDatabaseTables, missingEnvKeys, parseProject, resolveDeployTarget, resolveSchemas, runStudioAdd, runStudioAddApp, runStudioInit, serializeProject, setEntityDataSource, summarizeVerify, } from '@svgrid/enterprise/studio';
|
|
16
18
|
import { connect } from './db-connect.js';
|
|
17
|
-
import {
|
|
19
|
+
import { ensureDriverInstalled } from './driver-install.js';
|
|
18
20
|
import { startDesignerServer } from './designer-server.js';
|
|
21
|
+
import { ensureApp, startAppServer } from './dev.js';
|
|
19
22
|
const io = {
|
|
20
23
|
readFile: async (path) => {
|
|
21
24
|
try {
|
|
@@ -61,6 +64,30 @@ function parse(args) {
|
|
|
61
64
|
out.template = args[++i];
|
|
62
65
|
else if (a === '--no-open')
|
|
63
66
|
out.noOpen = true;
|
|
67
|
+
else if (a === '--target')
|
|
68
|
+
out.target = args[++i];
|
|
69
|
+
else if (a === '--dry-run')
|
|
70
|
+
out.dryRun = true;
|
|
71
|
+
else if (a === '--ai')
|
|
72
|
+
out.ai = true;
|
|
73
|
+
else if (a === '--app-port')
|
|
74
|
+
out.appPort = Number(args[++i]);
|
|
75
|
+
else if (a === '--fragment')
|
|
76
|
+
out.fragment = true;
|
|
77
|
+
else if (a === '--supabase-url')
|
|
78
|
+
out.supabaseUrl = args[++i];
|
|
79
|
+
else if (a === '--supabase-key')
|
|
80
|
+
out.supabaseKey = args[++i];
|
|
81
|
+
else if (a === '--dataset')
|
|
82
|
+
out.dataset = args[++i];
|
|
83
|
+
else if (a === '--theme')
|
|
84
|
+
out.theme = args[++i];
|
|
85
|
+
else if (a === '--dark')
|
|
86
|
+
out.dark = true;
|
|
87
|
+
else if (a === '--title')
|
|
88
|
+
out.title = args[++i];
|
|
89
|
+
else if (a === '-y' || a === '--yes')
|
|
90
|
+
out.yes = true;
|
|
64
91
|
else if (a === '-h' || a === '--help')
|
|
65
92
|
out.help = true;
|
|
66
93
|
else if (!a.startsWith('-'))
|
|
@@ -76,11 +103,34 @@ function parse(args) {
|
|
|
76
103
|
const HELP = `svgrid-studio - scaffold CRUD screens from a schema file or a live database
|
|
77
104
|
|
|
78
105
|
Usage:
|
|
106
|
+
svgrid-studio init # guided: pick your data, get a working CRUD app
|
|
79
107
|
svgrid-studio designer # open the visual designer in your browser
|
|
80
108
|
svgrid-studio add <name> --from <schema> # one table/model from a schema file
|
|
81
109
|
svgrid-studio add --all --from <schema> # every table/model, linked
|
|
82
110
|
svgrid-studio add <name> --db <dialect> --url <conn> # one table from a live database
|
|
83
111
|
svgrid-studio add --all --db <dialect> --url <conn> # every table from a live database
|
|
112
|
+
svgrid-studio openapi <file|url> # import an OpenAPI (JSON) spec -> studio.config.json
|
|
113
|
+
svgrid-studio eject [--fragment] # write the app (or a drop-in fragment) from studio.config.json
|
|
114
|
+
svgrid-studio dev # designer + the RUNNING app, side by side (HMR)
|
|
115
|
+
svgrid-studio deploy [--target <provider>] [--dry-run] # build + deploy via the provider CLI
|
|
116
|
+
|
|
117
|
+
Guided setup (init) - asks where your data lives, which tables you want, and
|
|
118
|
+
which pages each gets, then writes a runnable app + studio.config.json.
|
|
119
|
+
Running \`svgrid-studio\` with no arguments starts it too.
|
|
120
|
+
--db <dialect> --url <conn> skip the questions and read a live database
|
|
121
|
+
--supabase-url <url> --supabase-key <anon> read a Supabase project over its
|
|
122
|
+
REST API (no driver, works without a local database)
|
|
123
|
+
--dataset <id> start from sample data (customers-orders, products-categories,
|
|
124
|
+
projects-tasks, employees-departments, tickets-accounts)
|
|
125
|
+
--title <name> app name
|
|
126
|
+
--out <dir> folder to write the app into (default: .)
|
|
127
|
+
--theme <id> design-system preset --dark dark mode
|
|
128
|
+
-y, --yes take every default, ask nothing
|
|
129
|
+
|
|
130
|
+
Deploy (build first, then the provider CLI; target from --target, else
|
|
131
|
+
studio.config.json, else the adapter in svelte.config.js):
|
|
132
|
+
--target <p> vercel | netlify | cloudflare | node
|
|
133
|
+
--dry-run print the resolved commands without running anything
|
|
84
134
|
|
|
85
135
|
Designer (visual app builder, auto-saves to studio.config.json):
|
|
86
136
|
--template <id> open a ready-made sample app (crm | ecommerce | projects | support)
|
|
@@ -88,6 +138,8 @@ Designer (visual app builder, auto-saves to studio.config.json):
|
|
|
88
138
|
--out <dir> folder to write the generated app into (default: .)
|
|
89
139
|
--port <n> port to serve on (default: 4321)
|
|
90
140
|
--no-open don't open the browser
|
|
141
|
+
--ai enable the AI copilot (needs ANTHROPIC_API_KEY in the environment)
|
|
142
|
+
--app-port <n> dev: port for the generated app's dev server (default: designer port + 1)
|
|
91
143
|
|
|
92
144
|
Schema files: a Drizzle schema.ts or a Prisma schema.prisma (auto-detected).
|
|
93
145
|
Foreign keys become searchable relation lookups; enums become select fields.
|
|
@@ -107,11 +159,78 @@ Options:
|
|
|
107
159
|
-h, --help Show this help
|
|
108
160
|
|
|
109
161
|
Examples:
|
|
162
|
+
svgrid-studio init
|
|
163
|
+
svgrid-studio init --db postgres --url $DATABASE_URL --out my-app
|
|
110
164
|
svgrid-studio add customers --from src/lib/db/schema.ts
|
|
111
165
|
svgrid-studio add --all --from prisma/schema.prisma
|
|
112
166
|
svgrid-studio add customers --db postgres --url $DATABASE_URL --sql
|
|
113
167
|
svgrid-studio add --all --db supabase --url $DATABASE_URL
|
|
114
168
|
`;
|
|
169
|
+
/**
|
|
170
|
+
* Terminal prompts for the guided `init`.
|
|
171
|
+
*
|
|
172
|
+
* Lines are queued from a listener attached up front rather than read one at a
|
|
173
|
+
* time with `rl.question()`: on a pipe or a redirected file, readline delivers
|
|
174
|
+
* the whole buffer at once, and anything arriving before the next question is
|
|
175
|
+
* asked would simply be dropped. Once input runs out we answer with each
|
|
176
|
+
* question's default, so a scripted run finishes instead of hanging.
|
|
177
|
+
*/
|
|
178
|
+
function terminalPrompts() {
|
|
179
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
180
|
+
const waiting = [];
|
|
181
|
+
const buffered = [];
|
|
182
|
+
let ended = false;
|
|
183
|
+
rl.on('line', (line) => {
|
|
184
|
+
const next = waiting.shift();
|
|
185
|
+
if (next)
|
|
186
|
+
next(line);
|
|
187
|
+
else
|
|
188
|
+
buffered.push(line);
|
|
189
|
+
});
|
|
190
|
+
rl.on('close', () => {
|
|
191
|
+
ended = true;
|
|
192
|
+
for (const resolve of waiting.splice(0))
|
|
193
|
+
resolve(null);
|
|
194
|
+
});
|
|
195
|
+
const nextLine = () => {
|
|
196
|
+
if (buffered.length)
|
|
197
|
+
return Promise.resolve(buffered.shift());
|
|
198
|
+
if (ended)
|
|
199
|
+
return Promise.resolve(null);
|
|
200
|
+
return new Promise((resolve) => waiting.push(resolve));
|
|
201
|
+
};
|
|
202
|
+
return {
|
|
203
|
+
ask: async (question, def) => {
|
|
204
|
+
process.stdout.write(`${question}${def ? ` (${def})` : ''} `);
|
|
205
|
+
const line = await nextLine();
|
|
206
|
+
if (line === null) {
|
|
207
|
+
process.stdout.write(`${def ?? ''}\n`); // echo the default we fell back to
|
|
208
|
+
return def ?? '';
|
|
209
|
+
}
|
|
210
|
+
return line.trim() || def || '';
|
|
211
|
+
},
|
|
212
|
+
say: (line) => process.stdout.write(line + '\n'),
|
|
213
|
+
close: () => rl.close(),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/** Read an OpenAPI spec / REST response from a URL or a local file. */
|
|
217
|
+
const fetchText = async (source) => /^https?:\/\//.test(source) ? (await fetch(source)).text() : readFile(source, 'utf8');
|
|
218
|
+
/** The guided "build me a CRUD app" flow. */
|
|
219
|
+
async function runInit(flags) {
|
|
220
|
+
const prompts = terminalPrompts();
|
|
221
|
+
try {
|
|
222
|
+
const result = await runStudioInit(flags, prompts, {
|
|
223
|
+
ensureDriver: (dialect) => ensureDriverInstalled(dialect, process.cwd(), prompts.say),
|
|
224
|
+
connect: (dialect, url) => connect(dialect, url),
|
|
225
|
+
}, io, fetchText);
|
|
226
|
+
process.stdout.write('\nNext:\n');
|
|
227
|
+
for (const stepLine of result.nextSteps)
|
|
228
|
+
process.stdout.write(` ${stepLine}\n`);
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
prompts.close();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
115
234
|
function report(name, written, verifyLine) {
|
|
116
235
|
process.stdout.write(`Scaffolded "${name}":\n`);
|
|
117
236
|
for (const path of written)
|
|
@@ -121,6 +240,107 @@ function report(name, written, verifyLine) {
|
|
|
121
240
|
async function main() {
|
|
122
241
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
123
242
|
const opts = parse(rest);
|
|
243
|
+
// --- guided setup: the few-clicks path from nothing to a CRUD app ---------
|
|
244
|
+
// Bare `svgrid-studio` in a terminal starts here too: with no argument, the
|
|
245
|
+
// most useful thing we can do is walk the user through building an app.
|
|
246
|
+
if ((cmd === 'init' || (!cmd && process.stdin.isTTY)) && !opts.help) {
|
|
247
|
+
await runInit({
|
|
248
|
+
...(opts.outDir ? { out: opts.outDir } : {}),
|
|
249
|
+
...(opts.db ? { db: opts.db } : {}),
|
|
250
|
+
...(opts.url ? { url: opts.url } : {}),
|
|
251
|
+
...(opts.supabaseUrl ? { supabaseUrl: opts.supabaseUrl } : {}),
|
|
252
|
+
...(opts.supabaseKey ? { supabaseKey: opts.supabaseKey } : {}),
|
|
253
|
+
...(opts.dataset ? { dataset: opts.dataset } : {}),
|
|
254
|
+
...(opts.theme ? { theme: opts.theme } : {}),
|
|
255
|
+
...(opts.dark ? { dark: true } : {}),
|
|
256
|
+
...(opts.title ? { title: opts.title } : {}),
|
|
257
|
+
...(opts.yes ? { yes: true } : {}),
|
|
258
|
+
});
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
// --- eject: write the app (or a fragment) from studio.config.json ---------
|
|
262
|
+
if (cmd === 'eject' && !opts.help) {
|
|
263
|
+
const configPath = opts.config ?? 'studio.config.json';
|
|
264
|
+
const json = await io.readFile(configPath);
|
|
265
|
+
if (json == null) {
|
|
266
|
+
process.stderr.write(`eject: ${configPath} not found - run \`svgrid-studio designer\` first, or pass --config.\n`);
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
const project = parseProject(json);
|
|
270
|
+
const outDir = opts.outDir ?? '.';
|
|
271
|
+
const files = opts.fragment ? emitStudioFragment(project) : emitStudioAppBundle(project);
|
|
272
|
+
for (const f of files) {
|
|
273
|
+
const full = resolve(outDir, f.path);
|
|
274
|
+
await mkdir(dirname(full), { recursive: true });
|
|
275
|
+
await writeFile(full, f.contents);
|
|
276
|
+
}
|
|
277
|
+
process.stdout.write(`Wrote ${files.length} ${opts.fragment ? 'fragment' : 'app'} file(s) -> ${resolve(outDir)}\n`);
|
|
278
|
+
if (opts.fragment) {
|
|
279
|
+
const deps = runtimeDeps(project, files.map((f) => f.contents).join('\n'));
|
|
280
|
+
const install = Object.keys(deps).filter((d) => d !== '@svgrid/grid' && d !== '@svgrid/enterprise');
|
|
281
|
+
process.stdout.write(`Fragment: merge src/routes + src/lib into your app, import src/app.css, then:\n npm install ${install.join(' ') || '@svgrid/grid @svgrid/enterprise'}\nSee FRAGMENT.md for the full runbook.\n`);
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
// --- import an OpenAPI spec into a studio.config.json ---------------------
|
|
286
|
+
if (cmd === 'openapi' && !opts.help) {
|
|
287
|
+
const src = opts.from ?? rest.find((a) => !a.startsWith('-'));
|
|
288
|
+
if (!src) {
|
|
289
|
+
process.stderr.write('openapi: pass a spec - `svgrid-studio openapi ./openapi.json` (or a URL)\n');
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
const text = /^https?:\/\//.test(src) ? await (await fetch(src)).text() : await readFile(src, 'utf8');
|
|
293
|
+
const { entities, sources, warnings } = introspectOpenApi(text);
|
|
294
|
+
let project = createProject(entities, { title: 'API app' });
|
|
295
|
+
for (const [name, source] of Object.entries(sources))
|
|
296
|
+
project = setEntityDataSource(project, name, source);
|
|
297
|
+
const outPath = opts.config ?? 'studio.config.json';
|
|
298
|
+
await writeFile(outPath, serializeProject(project));
|
|
299
|
+
process.stdout.write(`Imported ${entities.length} REST resource(s) -> ${outPath}\n`);
|
|
300
|
+
for (const w of warnings)
|
|
301
|
+
process.stdout.write(` ! ${w}\n`);
|
|
302
|
+
process.stdout.write(`Next: svgrid-studio designer (open it visually) or svgrid-studio dev\n`);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
// --- live dev loop: designer + the real app, side by side ----------------
|
|
306
|
+
if (cmd === 'dev' && !opts.help) {
|
|
307
|
+
const configPath = opts.config ?? 'studio.config.json';
|
|
308
|
+
const outDir = opts.outDir ?? '.';
|
|
309
|
+
const port = Number.isFinite(opts.port) ? opts.port : 4321;
|
|
310
|
+
const appPort = Number.isFinite(opts.appPort) ? opts.appPort : port + 1;
|
|
311
|
+
const log = (l) => process.stdout.write(l + '\n');
|
|
312
|
+
const hasApp = await ensureApp(resolve(outDir), resolve(configPath), log);
|
|
313
|
+
let app = null;
|
|
314
|
+
if (hasApp) {
|
|
315
|
+
app = await startAppServer({ outDir, port: appPort, log });
|
|
316
|
+
log(`\n app: ${app.url} (Vite dev server - designer saves hot-reload it)`);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
log(`\n No app here yet - design something and hit "Save to folder"; the app server starts on the first save.`);
|
|
320
|
+
}
|
|
321
|
+
process.on('SIGINT', () => {
|
|
322
|
+
app?.stop();
|
|
323
|
+
process.exit(0);
|
|
324
|
+
});
|
|
325
|
+
await startDesignerServer({
|
|
326
|
+
configPath,
|
|
327
|
+
outDir,
|
|
328
|
+
port,
|
|
329
|
+
open: !opts.noOpen,
|
|
330
|
+
template: opts.template,
|
|
331
|
+
ai: !!opts.ai,
|
|
332
|
+
appUrl: app?.url,
|
|
333
|
+
onGenerated: async () => {
|
|
334
|
+
if (app)
|
|
335
|
+
await app.syncDeps();
|
|
336
|
+
else {
|
|
337
|
+
app = await startAppServer({ outDir, port: appPort, log });
|
|
338
|
+
log(` app: ${app.url} (started after first save)`);
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
124
344
|
// --- visual designer -----------------------------------------------------
|
|
125
345
|
if (cmd === 'designer' && !opts.help) {
|
|
126
346
|
await startDesignerServer({
|
|
@@ -129,10 +349,48 @@ async function main() {
|
|
|
129
349
|
port: Number.isFinite(opts.port) ? opts.port : 4321,
|
|
130
350
|
open: !opts.noOpen,
|
|
131
351
|
template: opts.template,
|
|
352
|
+
ai: !!opts.ai,
|
|
132
353
|
});
|
|
133
354
|
// Keep the process alive; the server holds the event loop until Ctrl+C.
|
|
134
355
|
return;
|
|
135
356
|
}
|
|
357
|
+
// --- deploy --------------------------------------------------------------
|
|
358
|
+
if (cmd === 'deploy' && !opts.help) {
|
|
359
|
+
const read = (p) => io.readFile(p);
|
|
360
|
+
const resolution = resolveDeployTarget({
|
|
361
|
+
flag: opts.target,
|
|
362
|
+
configJson: await read('studio.config.json'),
|
|
363
|
+
svelteConfig: (await read('svelte.config.js')) ?? (await read('svelte.config.ts')),
|
|
364
|
+
});
|
|
365
|
+
if (!resolution.ok) {
|
|
366
|
+
process.stderr.write(`deploy: ${resolution.reason}\n`);
|
|
367
|
+
process.exit(1);
|
|
368
|
+
}
|
|
369
|
+
const plan = deployCommands(resolution.provider);
|
|
370
|
+
process.stdout.write(`Deploying to ${plan.provider} (from ${resolution.source === 'flag' ? '--target' : resolution.source === 'config' ? 'studio.config.json' : 'svelte.config.js adapter'}).\n`);
|
|
371
|
+
// Preflight: env keys the app declares but the local .env doesn't set.
|
|
372
|
+
const missing = missingEnvKeys(await read('.env.example'), await read('.env'));
|
|
373
|
+
if (missing.length)
|
|
374
|
+
process.stdout.write(`! Unset env keys (set them on the host too): ${missing.join(', ')}\n`);
|
|
375
|
+
for (const note of plan.notes)
|
|
376
|
+
process.stdout.write(` ${note}\n`);
|
|
377
|
+
const commands = [plan.build, ...(plan.deploy ? [plan.deploy] : [])];
|
|
378
|
+
if (opts.dryRun) {
|
|
379
|
+
process.stdout.write(commands.map((c) => `> ${c}\n`).join(''));
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
for (const command of commands) {
|
|
383
|
+
process.stdout.write(`\n> ${command}\n`);
|
|
384
|
+
const res = spawnSync(command, { stdio: 'inherit', shell: true });
|
|
385
|
+
if (res.status !== 0) {
|
|
386
|
+
if (command !== plan.build) {
|
|
387
|
+
process.stderr.write(`deploy: "${command}" failed. If the provider CLI isn't set up yet, log in first (see the note above) and re-run.\n`);
|
|
388
|
+
}
|
|
389
|
+
process.exit(res.status ?? 1);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
136
394
|
if (cmd !== 'add' || opts.help) {
|
|
137
395
|
process.stdout.write(HELP);
|
|
138
396
|
process.exit(opts.help || !cmd ? 0 : 1);
|
|
@@ -147,29 +405,10 @@ async function main() {
|
|
|
147
405
|
// the user - they never need to know `pg`/`mysql2`/etc. must be present
|
|
148
406
|
// before --db works (mirrors the designer's "Install driver" flow).
|
|
149
407
|
const cwd = process.cwd();
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
process.
|
|
153
|
-
|
|
154
|
-
if (!result.ok) {
|
|
155
|
-
process.stderr.write(`svgrid-studio: could not install "${driver}" automatically.\n${result.output}\n` +
|
|
156
|
-
`Run \`${result.manager} install ${driver}\` yourself and try again.\n`);
|
|
157
|
-
process.exit(1);
|
|
158
|
-
}
|
|
159
|
-
process.stdout.write(`Installed ${driver}.\n`);
|
|
160
|
-
// The install child process can report success before the new module is
|
|
161
|
-
// reliably resolvable (seen on Windows - antivirus/file-sync tools can
|
|
162
|
-
// briefly hold the just-written files). Poll rather than racing straight
|
|
163
|
-
// into connect(), which would resolve the driver too early and crash.
|
|
164
|
-
const deadline = Date.now() + 5000;
|
|
165
|
-
while (!isDriverInstalled(opts.db, cwd) && Date.now() < deadline) {
|
|
166
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
167
|
-
}
|
|
168
|
-
if (!isDriverInstalled(opts.db, cwd)) {
|
|
169
|
-
process.stderr.write(`svgrid-studio: installed "${driver}" but it's still not resolvable from this directory.\n` +
|
|
170
|
-
`Something (antivirus, a file-sync tool) may be holding a lock on the new files. Try running the command again.\n`);
|
|
171
|
-
process.exit(1);
|
|
172
|
-
}
|
|
408
|
+
const driver = await ensureDriverInstalled(opts.db, cwd, (l) => process.stdout.write(l + '\n'));
|
|
409
|
+
if (!driver.ok) {
|
|
410
|
+
process.stderr.write(`svgrid-studio: ${driver.message}\n`);
|
|
411
|
+
process.exit(1);
|
|
173
412
|
}
|
|
174
413
|
const execute = await connect(opts.db, opts.url);
|
|
175
414
|
// A dialect dataSource emits a fully-connected +server.ts (driver + DATABASE_URL);
|
package/dist/copilot.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copilot backend for the CLI designer (`svgrid-studio designer --ai`): calls
|
|
3
|
+
* Anthropic's Messages API with the request built by the tested enterprise core
|
|
4
|
+
* and returns the validated, serialized project. Dependency-free by design -
|
|
5
|
+
* a plain fetch, no SDK - so the CLI keeps running on bare Node. The API key
|
|
6
|
+
* stays server-side (read from ANTHROPIC_API_KEY by the designer server).
|
|
7
|
+
*/
|
|
8
|
+
import { buildCopilotMessages, projectFromModelText, parseProject, serializeProject } from '@svgrid/enterprise/studio';
|
|
9
|
+
const API_URL = 'https://api.anthropic.com/v1/messages';
|
|
10
|
+
/** Overridable for teams pinning a model (SVGRID_COPILOT_MODEL). */
|
|
11
|
+
const DEFAULT_MODEL = 'claude-sonnet-5';
|
|
12
|
+
/** One copilot round: instruction + current project JSON -> updated project JSON. */
|
|
13
|
+
export async function runCopilot(prompt, projectJson, opts) {
|
|
14
|
+
const project = parseProject(projectJson);
|
|
15
|
+
const { system, messages } = buildCopilotMessages(prompt, project);
|
|
16
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
17
|
+
const res = await doFetch(API_URL, {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: {
|
|
20
|
+
'content-type': 'application/json',
|
|
21
|
+
'x-api-key': opts.apiKey,
|
|
22
|
+
'anthropic-version': '2023-06-01',
|
|
23
|
+
},
|
|
24
|
+
body: JSON.stringify({
|
|
25
|
+
model: opts.model ?? process.env.SVGRID_COPILOT_MODEL ?? DEFAULT_MODEL,
|
|
26
|
+
max_tokens: 32000,
|
|
27
|
+
system,
|
|
28
|
+
messages,
|
|
29
|
+
}),
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
const detail = await res.text().catch(() => '');
|
|
33
|
+
throw new Error(`Anthropic API ${res.status}: ${detail.slice(0, 300)}`);
|
|
34
|
+
}
|
|
35
|
+
const body = (await res.json());
|
|
36
|
+
const text = (body.content ?? [])
|
|
37
|
+
.filter((c) => c.type === 'text')
|
|
38
|
+
.map((c) => c.text ?? '')
|
|
39
|
+
.join('');
|
|
40
|
+
if (!text)
|
|
41
|
+
throw new Error('Anthropic API returned no text content.');
|
|
42
|
+
return serializeProject(projectFromModelText(text));
|
|
43
|
+
}
|
package/dist/designer-server.js
CHANGED
|
@@ -15,9 +15,11 @@ import { createReadStream, existsSync } from 'node:fs';
|
|
|
15
15
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
16
16
|
import { dirname, join, normalize, resolve, extname } from 'node:path';
|
|
17
17
|
import { createRequire } from 'node:module';
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
18
19
|
import { spawn } from 'node:child_process';
|
|
19
|
-
import {
|
|
20
|
+
import { parseProject, serializeProject, introspectDatabase, listDatabaseTables, probeConnection, linkRelationLabels, buildConnectionString, getSampleApp, starterProject as buildStarterProject, skipUserOwned, } from '@svgrid/enterprise/studio';
|
|
20
21
|
import { connect } from './db-connect.js';
|
|
22
|
+
import { runCopilot } from './copilot.js';
|
|
21
23
|
import { DRIVER_FOR, installDriver, isDriverInstalled } from './driver-install.js';
|
|
22
24
|
const MIME = {
|
|
23
25
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -32,6 +34,21 @@ const MIME = {
|
|
|
32
34
|
'.woff2': 'font/woff2',
|
|
33
35
|
'.map': 'application/json; charset=utf-8',
|
|
34
36
|
};
|
|
37
|
+
/** Quote a SQL identifier for the dialect (doubling the closing quote char). */
|
|
38
|
+
function quoteIdent(dialect, name) {
|
|
39
|
+
if (dialect === 'mysql')
|
|
40
|
+
return '`' + name.replace(/`/g, '``') + '`';
|
|
41
|
+
if (dialect === 'mssql')
|
|
42
|
+
return '[' + name.replace(/]/g, ']]') + ']';
|
|
43
|
+
return '"' + name.replace(/"/g, '""') + '"'; // postgres / supabase / sqlite
|
|
44
|
+
}
|
|
45
|
+
/** A read-only preview SELECT for one table (optionally schema-qualified). No
|
|
46
|
+
* params - it's a fixed `SELECT * ... LIMIT n` (MSSQL uses `TOP n`). */
|
|
47
|
+
function buildPreviewSql(dialect, table, schema, limit) {
|
|
48
|
+
const t = schema ? `${quoteIdent(dialect, schema)}.${quoteIdent(dialect, table)}` : quoteIdent(dialect, table);
|
|
49
|
+
const n = Math.max(1, Math.min(200, Math.floor(limit)));
|
|
50
|
+
return dialect === 'mssql' ? `SELECT TOP ${n} * FROM ${t}` : `SELECT * FROM ${t} LIMIT ${n}`;
|
|
51
|
+
}
|
|
35
52
|
/** The runtime deps the generated code imports, keyed by a source needle. */
|
|
36
53
|
const RUNTIME_DEPS = [
|
|
37
54
|
["from '@supabase/supabase-js'", '@supabase/supabase-js', '^2.45.0'],
|
|
@@ -50,26 +67,16 @@ function resolveDesignerDir() {
|
|
|
50
67
|
}
|
|
51
68
|
return dir;
|
|
52
69
|
}
|
|
53
|
-
/** The project a fresh session opens with: a chosen sample app (`--template`), or
|
|
54
|
-
*
|
|
70
|
+
/** The project a fresh session opens with: a chosen sample app (`--template`), or the
|
|
71
|
+
* default enterprise-ready Customers CRUD showcase (rich grid + export + popup form,
|
|
72
|
+
* seeded) - so the first screen is a complete, generate-and-copy-ready SvGrid app. */
|
|
55
73
|
function starterProject(templateId) {
|
|
56
74
|
if (templateId) {
|
|
57
75
|
const app = getSampleApp(templateId);
|
|
58
76
|
if (app)
|
|
59
77
|
return app.build();
|
|
60
78
|
}
|
|
61
|
-
|
|
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' });
|
|
79
|
+
return buildStarterProject();
|
|
73
80
|
}
|
|
74
81
|
function send(res, status, body, type = 'application/json; charset=utf-8') {
|
|
75
82
|
res.writeHead(status, { 'content-type': type });
|
|
@@ -95,8 +102,24 @@ function serveStatic(res, dir, urlPath) {
|
|
|
95
102
|
res.writeHead(200, { 'content-type': MIME[extname(filePath)] ?? 'application/octet-stream' });
|
|
96
103
|
createReadStream(filePath).pipe(res);
|
|
97
104
|
}
|
|
98
|
-
/** Write the generated app bundle to disk and add any runtime deps it imports.
|
|
99
|
-
|
|
105
|
+
/** Write the generated app bundle to disk and add any runtime deps it imports.
|
|
106
|
+
*
|
|
107
|
+
* Clobber guard: `.studio/manifest.json` records the hash of every file Studio
|
|
108
|
+
* last wrote. On regenerate, a file the USER has since edited (disk differs from
|
|
109
|
+
* the manifest) gets the new version written as `<file>.new` + reported as a
|
|
110
|
+
* conflict, instead of silently overwriting their work. Files Studio wrote and
|
|
111
|
+
* nobody touched are overwritten as before. */
|
|
112
|
+
export async function writeBundle(outDir, files) {
|
|
113
|
+
const manifestPath = join(outDir, '.studio', 'manifest.json');
|
|
114
|
+
let manifest = {};
|
|
115
|
+
try {
|
|
116
|
+
manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
/* first generate (or pre-manifest project) - guard activates from now on */
|
|
120
|
+
}
|
|
121
|
+
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
|
122
|
+
const conflicts = [];
|
|
100
123
|
let written = 0;
|
|
101
124
|
for (const f of files) {
|
|
102
125
|
const full = join(outDir, f.path);
|
|
@@ -105,10 +128,30 @@ async function writeBundle(outDir, files) {
|
|
|
105
128
|
// the make-or-break round-trip guarantee. See HANDLERS-DESIGN.md.
|
|
106
129
|
if (skipUserOwned(f, existsSync(full)))
|
|
107
130
|
continue;
|
|
131
|
+
const newHash = sha(f.contents);
|
|
132
|
+
if (existsSync(full)) {
|
|
133
|
+
const onDisk = await readFile(full, 'utf8').catch(() => null);
|
|
134
|
+
const diskHash = onDisk == null ? null : sha(onDisk);
|
|
135
|
+
if (diskHash === newHash) {
|
|
136
|
+
manifest[f.path] = newHash; // already identical - nothing to write
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const lastGenerated = manifest[f.path];
|
|
140
|
+
if (lastGenerated !== undefined && diskHash !== null && diskHash !== lastGenerated) {
|
|
141
|
+
// Locally modified since the last generate AND the regenerated content
|
|
142
|
+
// differs: park it next to the user's version instead of clobbering.
|
|
143
|
+
await writeFile(full + '.new', f.contents);
|
|
144
|
+
conflicts.push(f.path);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
108
148
|
await mkdir(dirname(full), { recursive: true });
|
|
109
149
|
await writeFile(full, f.contents);
|
|
150
|
+
manifest[f.path] = newHash;
|
|
110
151
|
written++;
|
|
111
152
|
}
|
|
153
|
+
await mkdir(dirname(manifestPath), { recursive: true });
|
|
154
|
+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
112
155
|
const allSource = files.map((f) => f.contents).join('\n');
|
|
113
156
|
const pkgPath = join(outDir, 'package.json');
|
|
114
157
|
if (existsSync(pkgPath)) {
|
|
@@ -128,7 +171,7 @@ async function writeBundle(outDir, files) {
|
|
|
128
171
|
// leave package.json untouched if it isn't valid JSON
|
|
129
172
|
}
|
|
130
173
|
}
|
|
131
|
-
return written;
|
|
174
|
+
return { written, conflicts };
|
|
132
175
|
}
|
|
133
176
|
/** Best-effort open the default browser (no dependency). */
|
|
134
177
|
function openBrowser(url) {
|
|
@@ -152,9 +195,33 @@ export async function startDesignerServer(opts) {
|
|
|
152
195
|
send(res, 500, JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
|
|
153
196
|
});
|
|
154
197
|
});
|
|
198
|
+
// Copilot is live only when BOTH the flag and the key are present; the SPA
|
|
199
|
+
// probes /api/capabilities and only then wires (and shows) the Copilot button.
|
|
200
|
+
const copilotKey = process.env.ANTHROPIC_API_KEY;
|
|
201
|
+
const copilotOn = !!opts.ai && !!copilotKey;
|
|
155
202
|
async function handle(req, res) {
|
|
156
203
|
const url = req.url ?? '/';
|
|
157
204
|
const path = url.split('?')[0];
|
|
205
|
+
if (path === '/api/capabilities' && req.method === 'GET') {
|
|
206
|
+
send(res, 200, JSON.stringify({ copilot: copilotOn, ...(opts.appUrl ? { appUrl: opts.appUrl } : {}) }));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (path === '/api/copilot' && req.method === 'POST') {
|
|
210
|
+
if (!copilotOn) {
|
|
211
|
+
send(res, 404, JSON.stringify({ error: 'Copilot is not enabled. Start with --ai and set ANTHROPIC_API_KEY.' }));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const body = await readBody(req);
|
|
215
|
+
const { prompt, project } = JSON.parse(body);
|
|
216
|
+
if (!prompt || !project) {
|
|
217
|
+
send(res, 400, JSON.stringify({ error: 'prompt and project are required' }));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
log(` copilot: ${prompt.slice(0, 80)}`);
|
|
221
|
+
const updated = await runCopilot(prompt, project, { apiKey: copilotKey });
|
|
222
|
+
send(res, 200, updated);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
158
225
|
if (path === '/api/project' && req.method === 'GET') {
|
|
159
226
|
const json = existsSync(configPath) ? await readFile(configPath, 'utf8') : null;
|
|
160
227
|
// Round-trip through parse/serialize so we hand the client a normalized,
|
|
@@ -179,9 +246,18 @@ export async function startDesignerServer(opts) {
|
|
|
179
246
|
send(res, 400, JSON.stringify({ error: 'no files to write' }));
|
|
180
247
|
return;
|
|
181
248
|
}
|
|
182
|
-
const
|
|
183
|
-
log(` generated ${
|
|
184
|
-
|
|
249
|
+
const { written, conflicts } = await writeBundle(outDir, files);
|
|
250
|
+
log(` generated ${written} files -> ${outDir}`);
|
|
251
|
+
if (conflicts.length) {
|
|
252
|
+
log(` ! ${conflicts.length} file(s) were edited by hand since the last generate - the new`);
|
|
253
|
+
log(` version was written NEXT TO yours as "<file>.new" (merge or replace, then delete it):`);
|
|
254
|
+
for (const c of conflicts)
|
|
255
|
+
log(` - ${c} -> ${c}.new`);
|
|
256
|
+
}
|
|
257
|
+
// `studio dev`: give the app-server loop a chance to install newly added
|
|
258
|
+
// deps + restart; plain file rewrites are picked up by Vite's HMR.
|
|
259
|
+
await opts.onGenerated?.();
|
|
260
|
+
send(res, 200, JSON.stringify({ ok: true, count: written, conflicts, outDir }));
|
|
185
261
|
return;
|
|
186
262
|
}
|
|
187
263
|
// Assemble a connection string from guided-form fields (host/port/user/...).
|
|
@@ -249,6 +325,23 @@ export async function startDesignerServer(opts) {
|
|
|
249
325
|
}
|
|
250
326
|
return;
|
|
251
327
|
}
|
|
328
|
+
if (action === 'preview') {
|
|
329
|
+
// "Preview data": run a real SELECT so the designer canvas shows the user's
|
|
330
|
+
// actual rows (the SQL equivalent of the Supabase/REST live preview).
|
|
331
|
+
const table = Array.isArray(reqBody.tables) ? reqBody.tables[0] : undefined;
|
|
332
|
+
if (!table) {
|
|
333
|
+
send(res, 400, JSON.stringify({ error: 'a table is required for preview' }));
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
const rows = await execute(buildPreviewSql(dialect, table, reqBody.schema, 25), []);
|
|
338
|
+
send(res, 200, JSON.stringify({ rows }));
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
send(res, 502, JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
252
345
|
if (action === 'introspect') {
|
|
253
346
|
const tables = Array.isArray(reqBody.tables) ? reqBody.tables : [];
|
|
254
347
|
const schemas = [];
|
|
@@ -297,6 +390,10 @@ export async function startDesignerServer(opts) {
|
|
|
297
390
|
log(`\n SvGrid Studio designer running at ${url}`);
|
|
298
391
|
log(` config: ${configPath}`);
|
|
299
392
|
log(` output: ${outDir}`);
|
|
393
|
+
if (copilotOn)
|
|
394
|
+
log(` copilot: ON (Anthropic)`);
|
|
395
|
+
else if (opts.ai)
|
|
396
|
+
log(` copilot: OFF - --ai was passed but ANTHROPIC_API_KEY is not set`);
|
|
300
397
|
log(` (edits auto-save; "Save to folder" writes the app. Ctrl+C to stop.)\n`);
|
|
301
398
|
if (opts.open)
|
|
302
399
|
openBrowser(url);
|
package/dist/dev.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `svgrid-studio dev` - the live loop: the visual designer AND the real generated
|
|
3
|
+
* app's Vite dev server running side by side. Designer saves write real files
|
|
4
|
+
* into the app folder (targeted, hash-guarded writes), Vite picks them up as
|
|
5
|
+
* HMR - so the designer edits the actual running app, not a simulation.
|
|
6
|
+
*
|
|
7
|
+
* Dependency-free like the rest of the CLI: node:child_process + the existing
|
|
8
|
+
* package-manager/driver plumbing.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { mkdir } from 'node:fs/promises';
|
|
13
|
+
import { join, resolve } from 'node:path';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import { emitStudioAppBundle, parseProject } from '@svgrid/enterprise/studio';
|
|
16
|
+
import { readFile } from 'node:fs/promises';
|
|
17
|
+
import { writeBundle } from './designer-server.js';
|
|
18
|
+
import { detectPackageManager } from './driver-install.js';
|
|
19
|
+
const isWin = process.platform === 'win32';
|
|
20
|
+
/** Ensure the folder holds a generated app; generate from studio.config.json when
|
|
21
|
+
* it doesn't. Returns false when there's nothing to run yet (no app, no config). */
|
|
22
|
+
export async function ensureApp(outDir, configPath, log) {
|
|
23
|
+
if (existsSync(join(outDir, 'package.json')))
|
|
24
|
+
return true;
|
|
25
|
+
if (!existsSync(configPath))
|
|
26
|
+
return false;
|
|
27
|
+
const project = parseProject(await readFile(configPath, 'utf8'));
|
|
28
|
+
await mkdir(outDir, { recursive: true });
|
|
29
|
+
const { written } = await writeBundle(outDir, emitStudioAppBundle(project));
|
|
30
|
+
log(` generated the app from ${configPath} (${written} files)`);
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
/** Deps declared in the app's package.json that aren't installed yet. Includes
|
|
34
|
+
* devDependencies - `vite`/`svelte-kit` live there and `dev` can't run without
|
|
35
|
+
* them (a wiped-then-partially-restored node_modules is a real scenario). */
|
|
36
|
+
export function missingDeps(outDir) {
|
|
37
|
+
try {
|
|
38
|
+
const req = createRequire(join(resolve(outDir), 'package.json'));
|
|
39
|
+
const pkg = req('./package.json');
|
|
40
|
+
return Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).filter((dep) => {
|
|
41
|
+
try {
|
|
42
|
+
req.resolve(join(dep, 'package.json'));
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
try {
|
|
47
|
+
req.resolve(dep);
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function install(outDir, log) {
|
|
61
|
+
const pm = detectPackageManager(outDir);
|
|
62
|
+
log(` installing dependencies (${pm} install)...`);
|
|
63
|
+
const res = spawnSync(`${pm} install`, { cwd: outDir, stdio: 'inherit', shell: true });
|
|
64
|
+
if (res.status !== 0)
|
|
65
|
+
throw new Error(`${pm} install failed (exit ${res.status ?? 'signal'})`);
|
|
66
|
+
}
|
|
67
|
+
function spawnVite(outDir, port, log) {
|
|
68
|
+
const pm = detectPackageManager(outDir);
|
|
69
|
+
// `-- --port` threads through the package script to vite; strictPort so a
|
|
70
|
+
// taken port errors loudly instead of silently drifting to another one.
|
|
71
|
+
const child = spawn(`${pm} run dev -- --port ${port} --strictPort`, { cwd: outDir, shell: true });
|
|
72
|
+
const prefix = (data) => {
|
|
73
|
+
for (const line of String(data).split(/\r?\n/))
|
|
74
|
+
if (line.trim())
|
|
75
|
+
log(` [app] ${line}`);
|
|
76
|
+
};
|
|
77
|
+
child.stdout?.on('data', prefix);
|
|
78
|
+
child.stderr?.on('data', prefix);
|
|
79
|
+
return child;
|
|
80
|
+
}
|
|
81
|
+
/** Kill the dev-server process tree (shell:true leaves grandchildren on Windows). */
|
|
82
|
+
function killTree(child) {
|
|
83
|
+
if (child.pid == null)
|
|
84
|
+
return;
|
|
85
|
+
if (isWin)
|
|
86
|
+
spawnSync(`taskkill /pid ${child.pid} /T /F`, { shell: true, stdio: 'ignore' });
|
|
87
|
+
else
|
|
88
|
+
child.kill('SIGTERM');
|
|
89
|
+
}
|
|
90
|
+
/** The `npm run dev` launcher must actually exist - a package can resolve while
|
|
91
|
+
* its .bin shim is gone (partially wiped node_modules), which fails with a
|
|
92
|
+
* cryptic "'vite' is not recognized". */
|
|
93
|
+
function viteBinMissing(outDir) {
|
|
94
|
+
const bin = join(outDir, 'node_modules', '.bin');
|
|
95
|
+
return !existsSync(join(bin, 'vite')) && !existsSync(join(bin, 'vite.cmd')) && !existsSync(join(bin, 'vite.CMD'));
|
|
96
|
+
}
|
|
97
|
+
/** Start the generated app's dev server (installing first when needed). */
|
|
98
|
+
export async function startAppServer(opts) {
|
|
99
|
+
const log = opts.log ?? ((l) => process.stdout.write(l + '\n'));
|
|
100
|
+
const outDir = resolve(opts.outDir);
|
|
101
|
+
if (!existsSync(join(outDir, 'node_modules')) || viteBinMissing(outDir) || missingDeps(outDir).length)
|
|
102
|
+
install(outDir, log);
|
|
103
|
+
let child = spawnVite(outDir, opts.port, log);
|
|
104
|
+
let stopped = false;
|
|
105
|
+
child.on('exit', (code) => {
|
|
106
|
+
if (!stopped && code !== 0 && code != null)
|
|
107
|
+
log(` [app] dev server exited with code ${code}`);
|
|
108
|
+
});
|
|
109
|
+
return {
|
|
110
|
+
url: `http://localhost:${opts.port}`,
|
|
111
|
+
async syncDeps() {
|
|
112
|
+
const missing = missingDeps(outDir);
|
|
113
|
+
if (!missing.length)
|
|
114
|
+
return; // nothing new - the running server HMRs the file writes
|
|
115
|
+
log(` new dependenc${missing.length === 1 ? 'y' : 'ies'} (${missing.join(', ')}) - installing + restarting the app server...`);
|
|
116
|
+
killTree(child);
|
|
117
|
+
install(outDir, log);
|
|
118
|
+
child = spawnVite(outDir, opts.port, log);
|
|
119
|
+
},
|
|
120
|
+
stop() {
|
|
121
|
+
stopped = true;
|
|
122
|
+
killTree(child);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
package/dist/driver-install.js
CHANGED
|
@@ -61,6 +61,42 @@ export function isDriverInstalled(dialect, cwd) {
|
|
|
61
61
|
return false;
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Make sure the dialect's driver is installed and actually resolvable, installing
|
|
66
|
+
* it if needed. Shared by `add --db` and the guided `init`, so a user connecting
|
|
67
|
+
* to Postgres never has to know `pg` must be present first.
|
|
68
|
+
*
|
|
69
|
+
* Never rejects: reports `{ ok: false, message }` the caller can print.
|
|
70
|
+
*/
|
|
71
|
+
export async function ensureDriverInstalled(dialect, cwd, log = () => { }) {
|
|
72
|
+
if (isDriverInstalled(dialect, cwd))
|
|
73
|
+
return { ok: true };
|
|
74
|
+
const driver = DRIVER_FOR[dialect];
|
|
75
|
+
log(`Installing ${driver} (needed to connect to ${dialect})...`);
|
|
76
|
+
const result = await installDriver(dialect, cwd);
|
|
77
|
+
if (!result.ok) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
message: `Could not install "${driver}" automatically.\n${result.output}\nRun \`${result.manager} install ${driver}\` yourself and try again.`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
log(`Installed ${driver}.`);
|
|
84
|
+
// The install child process can report success before the new module is
|
|
85
|
+
// reliably resolvable (seen on Windows - antivirus/file-sync tools can briefly
|
|
86
|
+
// hold the just-written files). Poll rather than racing straight into
|
|
87
|
+
// connect(), which would resolve the driver too early and crash.
|
|
88
|
+
const deadline = Date.now() + 5000;
|
|
89
|
+
while (!isDriverInstalled(dialect, cwd) && Date.now() < deadline) {
|
|
90
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
91
|
+
}
|
|
92
|
+
if (!isDriverInstalled(dialect, cwd)) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
message: `Installed "${driver}" but it's still not resolvable from this directory.\nSomething (antivirus, a file-sync tool) may be holding a lock on the new files. Try running the command again.`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return { ok: true };
|
|
99
|
+
}
|
|
64
100
|
/**
|
|
65
101
|
* Install the dialect's driver into `cwd`. No-ops (ok:true) when it's already
|
|
66
102
|
* present. Never rejects: a failed install resolves with `ok:false` and the
|
package/package.json
CHANGED
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
"type": "commercial",
|
|
5
5
|
"url": "https://svgrid.com/pricing"
|
|
6
6
|
},
|
|
7
|
-
"version": "0.
|
|
7
|
+
"version": "0.4.0",
|
|
8
8
|
"description": "SvGrid Studio CLI: scaffold a full CRUD screen (grid + edit panel + SvelteKit API route) from a Drizzle schema in one command.",
|
|
9
9
|
"license": "SEE LICENSE IN LICENSE",
|
|
10
10
|
"author": "jQWidgets <sales@jqwidgets.com>",
|
|
11
11
|
"homepage": "https://svgrid.com",
|
|
12
12
|
"repository": {
|
|
13
13
|
"type": "git",
|
|
14
|
-
"url": "https://github.com/sv-grid/sv-grid.git",
|
|
14
|
+
"url": "git+https://github.com/sv-grid/sv-grid.git",
|
|
15
15
|
"directory": "packages/studio"
|
|
16
16
|
},
|
|
17
17
|
"type": "module",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"access": "public"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@svgrid/enterprise": "^2.
|
|
30
|
+
"@svgrid/enterprise": "^2.4.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/node": "^22.10.7",
|
|
@@ -36,8 +36,27 @@
|
|
|
36
36
|
"engines": {
|
|
37
37
|
"node": ">=18"
|
|
38
38
|
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"svelte",
|
|
41
|
+
"svelte5",
|
|
42
|
+
"sveltekit",
|
|
43
|
+
"crud",
|
|
44
|
+
"scaffold",
|
|
45
|
+
"codegen",
|
|
46
|
+
"drizzle",
|
|
47
|
+
"prisma",
|
|
48
|
+
"admin panel",
|
|
49
|
+
"low-code",
|
|
50
|
+
"data app",
|
|
51
|
+
"data grid",
|
|
52
|
+
"datagrid",
|
|
53
|
+
"studio",
|
|
54
|
+
"svgrid",
|
|
55
|
+
"sv-grid"
|
|
56
|
+
],
|
|
39
57
|
"scripts": {
|
|
40
58
|
"build": "tsc -p tsconfig.json",
|
|
41
|
-
"test:types": "tsc -p tsconfig.json --noEmit"
|
|
59
|
+
"test:types": "tsc -p tsconfig.json --noEmit",
|
|
60
|
+
"lint": "eslint ./src"
|
|
42
61
|
}
|
|
43
62
|
}
|