@svgrid/enterprise 2.2.1 → 2.3.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.
Files changed (126) hide show
  1. package/README.md +95 -81
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +26642 -11252
  3. package/dist/designer/assets/GridMenus-BtWVk9Ab.js +7 -0
  4. package/dist/designer/assets/SvGridChartPanel-DWlBO2SD.js +10 -0
  5. package/dist/designer/assets/SvGridChartView-8c8Mb4j8.js +1 -0
  6. package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
  7. package/dist/designer/assets/index-DsDgp9Xq.js +78758 -0
  8. package/dist/designer/assets/index-tTY_Dx4P.css +1 -0
  9. package/dist/designer/assets/jszip.min-fkJdmAmj.js +2 -0
  10. package/dist/designer/assets/pdfmake-DeCsnyl9.js +242 -0
  11. package/dist/designer/assets/smart.export-BZlSCE8T.js +35 -0
  12. package/dist/designer/assets/vfs_fonts-eX2NpmfX.js +1 -0
  13. package/dist/designer/index.html +13 -0
  14. package/dist/node/studio.js +15304 -2708
  15. package/package.json +35 -11
  16. package/src/SvAlertRuleEditor.svelte +294 -0
  17. package/src/SvAlertsManager.svelte +210 -0
  18. package/src/SvAlertsPanel.svelte +129 -0
  19. package/src/SvBoard.svelte +3 -1
  20. package/src/SvExpressionEditor.svelte +341 -0
  21. package/src/SvGridAlerts.dom.test.ts +113 -0
  22. package/src/SvGridAlerts.svelte +265 -0
  23. package/src/SvGridBoard.svelte +2358 -0
  24. package/src/SvGridEditPanel.svelte +849 -794
  25. package/src/SvGridScheduler.svelte +5358 -4410
  26. package/src/SvPivotDesigner.svelte +3 -3
  27. package/src/SvRecordDetail.svelte +6 -2
  28. package/src/SvSchedule.svelte +3 -1
  29. package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +77 -74
  30. package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +90 -87
  31. package/src/{ai-export.test.ts → ai-export.dom.test.ts} +114 -110
  32. package/src/alerts/alert-engine-attach.ts +165 -0
  33. package/src/alerts/alert-engine.test.ts +135 -0
  34. package/src/alerts/alert-engine.ts +260 -0
  35. package/src/alerts/alert-formats.test.ts +87 -0
  36. package/src/alerts/alert-formats.ts +77 -0
  37. package/src/alerts/alert-observer.test.ts +189 -0
  38. package/src/alerts/alert-observer.ts +208 -0
  39. package/src/alerts/alert-scheduler.ts +96 -0
  40. package/src/alerts/alert-storage.test.ts +54 -0
  41. package/src/alerts/alert-storage.ts +116 -0
  42. package/src/alerts/alert-store.svelte.ts +80 -0
  43. package/src/alerts/alert-types.ts +94 -0
  44. package/src/alerts.ts +28 -0
  45. package/src/board.dom.test.ts +941 -0
  46. package/src/board.ts +36 -0
  47. package/src/export-ooxml.ts +4 -0
  48. package/src/export-xls.ts +3 -0
  49. package/src/export.ts +6 -0
  50. package/src/expressions/evaluate.test.ts +111 -0
  51. package/src/expressions/evaluate.ts +237 -0
  52. package/src/expressions/expression-columns.ts +133 -0
  53. package/src/expressions/expression-types.ts +84 -0
  54. package/src/expressions/parse.test.ts +106 -0
  55. package/src/expressions/parse.ts +610 -0
  56. package/src/import.test.ts +1 -1
  57. package/src/import.ts +3 -1
  58. package/src/index.ts +192 -46
  59. package/src/install.ts +12 -2
  60. package/src/pivot-enable.ts +44 -0
  61. package/src/pivot.test.ts +0 -1
  62. package/src/scheduler-assignments.test.ts +97 -0
  63. package/src/scheduler-assignments.ts +134 -0
  64. package/src/scheduler-axis.test.ts +108 -0
  65. package/src/scheduler-axis.ts +238 -0
  66. package/src/scheduler-booking.test.ts +57 -0
  67. package/src/scheduler-booking.ts +63 -0
  68. package/src/scheduler-config.ts +179 -0
  69. package/src/scheduler-dependencies.test.ts +155 -0
  70. package/src/scheduler-dependencies.ts +223 -0
  71. package/src/scheduler-freebusy.test.ts +41 -0
  72. package/src/scheduler-freebusy.ts +36 -0
  73. package/src/scheduler-heatmap.test.ts +39 -0
  74. package/src/scheduler-heatmap.ts +55 -0
  75. package/src/scheduler-resource-tree.test.ts +84 -0
  76. package/src/scheduler-resource-tree.ts +107 -0
  77. package/src/scheduler-slots.test.ts +53 -0
  78. package/src/scheduler-slots.ts +94 -0
  79. package/src/scheduler-summary.test.ts +47 -0
  80. package/src/scheduler-summary.ts +81 -0
  81. package/src/schema-designer.ts +1 -1
  82. package/src/sources/index.ts +1 -1
  83. package/src/sources/introspect-supabase.test.ts +13 -1
  84. package/src/sources/introspect-supabase.ts +20 -0
  85. package/src/studio/copilot-core.test.ts +45 -0
  86. package/src/studio/copilot-core.ts +65 -0
  87. package/src/studio/deploy-cli.test.ts +56 -0
  88. package/src/studio/deploy-cli.ts +100 -0
  89. package/src/studio/emit-project.test.ts +800 -24
  90. package/src/studio/emit-project.ts +1307 -135
  91. package/src/studio/emit-schema.test.ts +17 -0
  92. package/src/studio/emit-schema.ts +182 -41
  93. package/src/studio/index.ts +59 -1
  94. package/src/studio/init-flow.test.ts +239 -0
  95. package/src/studio/init-flow.ts +358 -0
  96. package/src/studio/introspect-openapi.test.ts +84 -0
  97. package/src/studio/introspect-openapi.ts +252 -0
  98. package/src/studio/project.test.ts +57 -1
  99. package/src/studio/project.ts +302 -8
  100. package/src/studio/samples/crm.ts +282 -258
  101. package/src/studio/samples/datasets.test.ts +84 -0
  102. package/src/studio/samples/datasets.ts +340 -0
  103. package/src/studio/samples/fleet.ts +213 -186
  104. package/src/studio/samples/insurance.ts +223 -195
  105. package/src/studio/samples/inventory.ts +213 -182
  106. package/src/studio/samples/live-data.test.ts +99 -98
  107. package/src/studio/samples/live-data.ts +8 -10
  108. package/src/studio/samples/projects.ts +212 -190
  109. package/src/studio/samples/samples.test.ts +268 -216
  110. package/src/studio/samples/shared.ts +22 -145
  111. package/src/studio/samples/starter.ts +251 -0
  112. package/src/studio/samples/support.ts +209 -184
  113. package/src/studio/screen-suites.test.ts +226 -0
  114. package/src/studio/screen-suites.ts +445 -0
  115. package/src/studio/ui-components-surface.test.ts +185 -0
  116. package/src/studio/ui-components.generated.ts +7154 -0
  117. package/src/studio/ui-components.ts +273 -3
  118. package/src/sveltekit/index.ts +1 -0
  119. package/src/sveltekit/sql-source.test.ts +13 -0
  120. package/src/sveltekit/sql-source.ts +12 -7
  121. package/src/sveltekit/transport-scope.test.ts +125 -0
  122. package/src/sveltekit/transport.ts +76 -2
  123. package/src/upgrade-prompt.ts +2 -2
  124. package/src/watermark.ts +2 -2
  125. package/src/ai.test.ts +0 -522
  126. package/src/ai.ts +0 -1388
@@ -0,0 +1,239 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { runStudioInit, parseSelection, type DbGateway, type PromptIO } from './init-flow.js'
3
+ import type { StudioIO } from './cli.js'
4
+ import { parseProject, validateProject, type GridConfig } from './project.js'
5
+
6
+ /** A scripted terminal: answers are consumed in order, defaults fill the rest. */
7
+ function scriptedPrompts(answers: string[]) {
8
+ const asked: string[] = []
9
+ const said: string[] = []
10
+ const queue = [...answers]
11
+ const io: PromptIO = {
12
+ ask: async (question, def) => {
13
+ asked.push(question)
14
+ const next = queue.shift()
15
+ return next === undefined ? (def ?? '') : next
16
+ },
17
+ say: (line) => said.push(line),
18
+ }
19
+ return { io, asked, said, get remaining() { return queue.length } }
20
+ }
21
+
22
+ /** An in-memory filesystem. */
23
+ function memoryIo() {
24
+ const files = new Map<string, string>()
25
+ const io: StudioIO = {
26
+ readFile: async (path) => files.get(path) ?? null,
27
+ writeFile: async (path, contents) => { files.set(path, contents) },
28
+ }
29
+ return { io, files }
30
+ }
31
+
32
+ /** A fake Postgres holding two tables, answering the real catalog queries
33
+ * (which alias their columns to name / type / nullable / pk). */
34
+ function fakeDb(): DbGateway {
35
+ const columns: Record<string, { name: string; type: string; nullable: number; pk: number }[]> = {
36
+ customers: [
37
+ { name: 'id', type: 'integer', nullable: 0, pk: 1 },
38
+ { name: 'name', type: 'text', nullable: 0, pk: 0 },
39
+ { name: 'city', type: 'text', nullable: 1, pk: 0 },
40
+ ],
41
+ orders: [
42
+ { name: 'id', type: 'integer', nullable: 0, pk: 1 },
43
+ { name: 'total', type: 'numeric', nullable: 1, pk: 0 },
44
+ ],
45
+ }
46
+ return {
47
+ ensureDriver: async () => ({ ok: true }),
48
+ connect: async () => async (sql: string, params: unknown[]) => {
49
+ if (/count\(\*\)/i.test(sql)) return [{ n: 7 }]
50
+ if (/information_schema\.tables/i.test(sql)) return Object.keys(columns).map((name) => ({ name }))
51
+ if (/information_schema\.columns/i.test(sql)) return columns[String(params[0] ?? '')] ?? []
52
+ return [] // no foreign keys
53
+ },
54
+ }
55
+ }
56
+
57
+ describe('parseSelection', () => {
58
+ const items = ['customers', 'orders', 'invoices']
59
+
60
+ it('takes everything by default', () => {
61
+ expect(parseSelection('', items)).toEqual(items)
62
+ expect(parseSelection('all', items)).toEqual(items)
63
+ expect(parseSelection('*', items)).toEqual(items)
64
+ })
65
+
66
+ it('takes nothing for "none"', () => {
67
+ expect(parseSelection('none', items)).toEqual([])
68
+ })
69
+
70
+ it('accepts names, numbers, and a mix - without duplicates', () => {
71
+ expect(parseSelection('orders', items)).toEqual(['orders'])
72
+ expect(parseSelection('1, 3', items)).toEqual(['customers', 'invoices'])
73
+ expect(parseSelection('2, invoices', items)).toEqual(['orders', 'invoices'])
74
+ expect(parseSelection('orders, 2', items)).toEqual(['orders'])
75
+ })
76
+
77
+ it('ignores names and numbers that do not exist', () => {
78
+ expect(parseSelection('nope, 9, orders', items)).toEqual(['orders'])
79
+ })
80
+ })
81
+
82
+ describe('runStudioInit', () => {
83
+ it('builds a runnable app from a starter dataset with all defaults', async () => {
84
+ const prompts = scriptedPrompts([])
85
+ const fs = memoryIo()
86
+ const result = await runStudioInit({ yes: true }, prompts.io, null, fs.io)
87
+
88
+ expect(result.project.entities.length).toBeGreaterThanOrEqual(2)
89
+ expect(validateProject(result.project).filter((i) => i.level === 'error')).toEqual([])
90
+ expect(result.written).toContain('studio.config.json')
91
+ expect(result.written.some((p) => p.endsWith('package.json'))).toBe(true)
92
+ expect(result.written.some((p) => p.includes('+page.svelte'))).toBe(true)
93
+ // --yes asks nothing.
94
+ expect(prompts.asked).toEqual([])
95
+ // The written config re-parses into the same project.
96
+ expect(parseProject(fs.files.get('studio.config.json')!)).toEqual(result.project)
97
+ })
98
+
99
+ it('asks the questions in order when driven interactively', async () => {
100
+ const prompts = scriptedPrompts([
101
+ '1', // source: sample data
102
+ '2', // dataset: products & categories
103
+ 'y', // full CRUD suite
104
+ '1', // editing: popup form
105
+ '1', // theme
106
+ 'n', // dark mode
107
+ 'Catalog', // app name
108
+ '.', // out dir
109
+ ])
110
+ const fs = memoryIo()
111
+ const result = await runStudioInit({}, prompts.io, null, fs.io)
112
+
113
+ expect(prompts.asked).toEqual([
114
+ 'Pick a number:', // source
115
+ 'Pick a number:', // dataset
116
+ '\nGenerate a list, an edit form and a record page for each table? (Y/n)',
117
+ 'Pick a number:', // editing mode
118
+ 'Pick a number:', // theme
119
+ 'Dark mode? (y/N)',
120
+ '\nApp name:',
121
+ 'Write it where?',
122
+ ])
123
+ expect(result.project.title).toBe('Catalog')
124
+ expect(result.project.entities.map((e) => e.name)).toEqual(['categories', 'products'])
125
+ })
126
+
127
+ it('honours the out directory', async () => {
128
+ const prompts = scriptedPrompts([])
129
+ const fs = memoryIo()
130
+ const result = await runStudioInit({ yes: true, out: 'apps/shop' }, prompts.io, null, fs.io)
131
+
132
+ expect(result.written.every((p) => p.startsWith('apps/shop/'))).toBe(true)
133
+ expect(result.nextSteps[0]).toBe('cd apps/shop')
134
+ expect(fs.files.has('apps/shop/studio.config.json')).toBe(true)
135
+ })
136
+
137
+ it('stores a dataset in PGlite when that source is picked', async () => {
138
+ const prompts = scriptedPrompts(['3', '1', 'y', '1', '1', 'n', 'Shop', '.'])
139
+ const fs = memoryIo()
140
+ const result = await runStudioInit({}, prompts.io, null, fs.io)
141
+
142
+ expect(result.project.dataSource).toBe('pglite')
143
+ const first = result.project.entities[0]!.name
144
+ expect(result.project.dataSources?.[first]).toMatchObject({ kind: 'pglite', table: first })
145
+ // The seed rides along, so the generated app boots with data.
146
+ expect((result.project.dataSources?.[first] as { seed?: unknown[] }).seed?.length).toBeGreaterThan(0)
147
+ })
148
+
149
+ it('reads tables from a live database', async () => {
150
+ const prompts = scriptedPrompts(['all', 'y', '1', '1', 'n', 'Ops', '.'])
151
+ const fs = memoryIo()
152
+ const result = await runStudioInit(
153
+ { db: 'postgres', url: 'postgres://localhost/app' },
154
+ prompts.io,
155
+ fakeDb(),
156
+ fs.io,
157
+ )
158
+
159
+ expect(result.project.entities.map((e) => e.name)).toEqual(['customers', 'orders'])
160
+ expect(result.project.dataSource).toBe('sql')
161
+ expect(result.project.dataSources?.customers).toEqual({ kind: 'sql', table: 'customers', dialect: 'postgres' })
162
+ // --db + --url skip the connection questions entirely.
163
+ expect(prompts.asked[0]).toBe('Which tables? (all, or a comma list of names/numbers)')
164
+ })
165
+
166
+ it('imports only the picked tables', async () => {
167
+ const prompts = scriptedPrompts(['orders', 'y', '1', '1', 'n', 'Ops', '.'])
168
+ const fs = memoryIo()
169
+ const result = await runStudioInit({ db: 'postgres', url: 'postgres://x' }, prompts.io, fakeDb(), fs.io)
170
+ expect(result.project.entities.map((e) => e.name)).toEqual(['orders'])
171
+ })
172
+
173
+ it('fails clearly when the driver cannot be installed', async () => {
174
+ const prompts = scriptedPrompts(['all'])
175
+ const fs = memoryIo()
176
+ const db: DbGateway = { ...fakeDb(), ensureDriver: async () => ({ ok: false, message: 'pg is not installed' }) }
177
+ await expect(runStudioInit({ db: 'postgres', url: 'postgres://x' }, prompts.io, db, fs.io))
178
+ .rejects.toThrow('pg is not installed')
179
+ })
180
+
181
+ it('fails clearly when no table is picked', async () => {
182
+ const prompts = scriptedPrompts(['none'])
183
+ const fs = memoryIo()
184
+ await expect(runStudioInit({ db: 'postgres', url: 'postgres://x' }, prompts.io, fakeDb(), fs.io))
185
+ .rejects.toThrow('No tables picked')
186
+ })
187
+
188
+ it('refuses a database when no gateway is available', async () => {
189
+ const prompts = scriptedPrompts([])
190
+ const fs = memoryIo()
191
+ await expect(runStudioInit({ db: 'postgres', url: 'postgres://x' }, prompts.io, null, fs.io))
192
+ .rejects.toThrow('not available here')
193
+ })
194
+
195
+ it('rejects an unknown dataset id by name', async () => {
196
+ const prompts = scriptedPrompts([])
197
+ const fs = memoryIo()
198
+ await expect(runStudioInit({ yes: true, dataset: 'nope' }, prompts.io, null, fs.io))
199
+ .rejects.toThrow(/Unknown dataset "nope"/)
200
+ })
201
+
202
+ it('builds an app from a REST endpoint', async () => {
203
+ const prompts = scriptedPrompts(['4', 'https://api.example.com/widgets', 'widgets', 'y', '1', '1', 'n', 'API app', '.'])
204
+ const fs = memoryIo()
205
+ const rows = JSON.stringify({ data: [{ id: 1, name: 'Widget', price: 9.5 }] })
206
+ const result = await runStudioInit({}, prompts.io, null, fs.io, async () => rows)
207
+
208
+ expect(result.project.entities.map((e) => e.name)).toEqual(['widgets'])
209
+ expect(result.project.dataSources?.widgets).toMatchObject({ kind: 'rest', baseUrl: 'https://api.example.com', path: 'widgets' })
210
+ })
211
+
212
+ it('applies the picked editing mode to the generated grids', async () => {
213
+ const prompts = scriptedPrompts(['1', '1', 'y', '2', '1', 'n', 'Inline', '.'])
214
+ const fs = memoryIo()
215
+ const result = await runStudioInit({}, prompts.io, null, fs.io)
216
+
217
+ const listScreen = result.project.screens.find((s) => s.id === result.project.entities[0]!.name)!
218
+ const grid = listScreen.blocks.find((b) => b.config.kind === 'grid')!.config as GridConfig
219
+ expect(grid.editing).toBe('inline')
220
+ })
221
+
222
+ it('applies the chosen theme', async () => {
223
+ const prompts = scriptedPrompts([])
224
+ const fs = memoryIo()
225
+ const result = await runStudioInit({ yes: true, theme: 'material', dark: true }, prompts.io, null, fs.io)
226
+ expect(result.project.theme?.preset).toBe('material')
227
+ expect(result.project.theme?.mode).toBe('dark')
228
+ })
229
+
230
+ it('keeps user-owned files that already exist', async () => {
231
+ const fs = memoryIo()
232
+ await runStudioInit({ yes: true }, scriptedPrompts([]).io, null, fs.io)
233
+ const handlers = [...fs.files.keys()].find((p) => p.endsWith('handlers.ts'))
234
+ if (!handlers) return // no user-owned companion in this project shape
235
+ fs.files.set(handlers, '// my own code')
236
+ await runStudioInit({ yes: true }, scriptedPrompts([]).io, null, fs.io)
237
+ expect(fs.files.get(handlers)).toBe('// my own code')
238
+ })
239
+ })
@@ -0,0 +1,358 @@
1
+ /**
2
+ * runStudioInit - the guided "build a CRUD app" flow behind `svgrid-studio init`.
3
+ *
4
+ * Asks a handful of questions (where the data lives, which tables, which pages,
5
+ * what it looks like), then builds a project with `crudAppFromSchemas` - the same
6
+ * generator the designer's "New app" wizard uses - and writes a runnable app plus
7
+ * its `studio.config.json`.
8
+ *
9
+ * Prompts, the filesystem, and the database are all injected, so the orchestration
10
+ * is pure and unit-testable and this file stays node-free (no process / fs / driver
11
+ * imports). The CLI bin supplies readline, node:fs, and the DB drivers.
12
+ */
13
+ import type { EntitySchema } from '../schema.js'
14
+ import type { StudioIO } from './cli.js'
15
+ import { crudAppFromSchemas, type CrudEditingMode, type CrudScreenKind, type CrudSuiteOptions } from './screen-suites.js'
16
+ import { starterDatasets, getStarterDataset } from './samples/datasets.js'
17
+ import { introspectDatabase, listDatabaseTables, countTableRows, type DbExecute, type SqlDialectName } from './introspect-db.js'
18
+ import { introspectOpenApi } from './introspect-openapi.js'
19
+ import { introspectJson } from './introspect.js'
20
+ import { buildConnectionString, isFileDialect, type SqlConnectionParts } from './db-connect-string.js'
21
+ import { emitStudioAppBundle } from './emit-project.js'
22
+ import { mergeManaged, skipUserOwned } from './scaffold.js'
23
+ import { getStudioTheme, studioThemes } from './themes.js'
24
+ import { serializeProject, type DataSourceKind, type EntityDataSource, type ProjectTheme, type StudioProject } from './project.js'
25
+
26
+ /** Terminal I/O, injected so the flow can be scripted in tests. */
27
+ export type PromptIO = {
28
+ /** Ask a question; `def` is used when the user just hits Enter. */
29
+ ask: (question: string, def?: string) => Promise<string>
30
+ /** Print a line (progress, headings, results). */
31
+ say: (line: string) => void
32
+ }
33
+
34
+ /** Live-database access, injected by the bin (drivers are node-only). */
35
+ export type DbGateway = {
36
+ /** Make sure the dialect's driver is installed; report why not if it isn't. */
37
+ ensureDriver: (dialect: SqlDialectName) => Promise<{ ok: boolean; message?: string }>
38
+ connect: (dialect: SqlDialectName, url: string) => Promise<DbExecute>
39
+ }
40
+
41
+ /** Read a REST endpoint or an OpenAPI document (injected: the bin uses fetch/fs). */
42
+ export type FetchText = (source: string) => Promise<string>
43
+
44
+ export type InitFlags = {
45
+ /** Where to write the app. Default `.`. */
46
+ out?: string
47
+ /** Skip the source question and go straight to a live database. */
48
+ db?: SqlDialectName
49
+ /** Connection string for `db`. */
50
+ url?: string
51
+ /** Pick a starter dataset by id, skipping the source question. */
52
+ dataset?: string
53
+ /** Theme preset id. */
54
+ theme?: string
55
+ dark?: boolean
56
+ /** App title (skips the question). */
57
+ title?: string
58
+ /** Take every default; ask nothing that has one. */
59
+ yes?: boolean
60
+ }
61
+
62
+ export type InitResult = {
63
+ project: StudioProject
64
+ /** Paths written, in order. */
65
+ written: string[]
66
+ /** What to tell the user to do next. */
67
+ nextSteps: string[]
68
+ }
69
+
70
+ const SOURCES = [
71
+ { key: 'sample', label: 'Sample data', hint: 'a seeded pair of related tables - see a working app immediately' },
72
+ { key: 'db', label: 'An existing database', hint: 'Postgres, MySQL, SQL Server or SQLite - reads your tables' },
73
+ { key: 'pglite', label: 'Local Postgres in the browser (PGlite)', hint: 'real SQL, persists, no server to set up' },
74
+ { key: 'rest', label: 'A REST API or OpenAPI spec', hint: 'infers the tables from the endpoint' },
75
+ ] as const
76
+ type SourceKey = (typeof SOURCES)[number]['key']
77
+
78
+ const DIALECTS: SqlDialectName[] = ['postgres', 'supabase', 'mysql', 'mssql', 'sqlite']
79
+
80
+ /** Ask a numbered-list question; returns the chosen index. */
81
+ async function pick(prompts: PromptIO, heading: string, items: { label: string; hint?: string }[], def = 1): Promise<number> {
82
+ prompts.say('')
83
+ prompts.say(heading)
84
+ items.forEach((item, i) => prompts.say(` ${i + 1}. ${item.label}${item.hint ? ` - ${item.hint}` : ''}`))
85
+ const answer = (await prompts.ask('Pick a number:', String(def))).trim()
86
+ const n = Number(answer)
87
+ return Number.isFinite(n) && n >= 1 && n <= items.length ? n - 1 : def - 1
88
+ }
89
+
90
+ const isYes = (answer: string, def: boolean): boolean => {
91
+ const a = answer.trim().toLowerCase()
92
+ if (!a) return def
93
+ return a === 'y' || a === 'yes'
94
+ }
95
+
96
+ /** Parse "all", "none", a comma list of names, or a list of 1-based numbers. */
97
+ export function parseSelection(answer: string, items: string[]): string[] {
98
+ const raw = answer.trim()
99
+ if (!raw || raw.toLowerCase() === 'all' || raw === '*') return items
100
+ if (raw.toLowerCase() === 'none') return []
101
+ const picked: string[] = []
102
+ for (const part of raw.split(',').map((p) => p.trim()).filter(Boolean)) {
103
+ const n = Number(part)
104
+ if (Number.isFinite(n) && n >= 1 && n <= items.length) {
105
+ const item = items[n - 1]!
106
+ if (!picked.includes(item)) picked.push(item)
107
+ continue
108
+ }
109
+ const match = items.find((i) => i.toLowerCase() === part.toLowerCase())
110
+ if (match && !picked.includes(match)) picked.push(match)
111
+ }
112
+ return picked
113
+ }
114
+
115
+ /** The rows of a REST response: a bare array, or the usual wrapper key. */
116
+ function unwrapRows(body: unknown): Record<string, unknown>[] {
117
+ if (Array.isArray(body)) return body as Record<string, unknown>[]
118
+ if (body && typeof body === 'object') {
119
+ for (const key of ['data', 'items', 'results', 'rows', 'records']) {
120
+ const v = (body as Record<string, unknown>)[key]
121
+ if (Array.isArray(v)) return v as Record<string, unknown>[]
122
+ }
123
+ return [body as Record<string, unknown>]
124
+ }
125
+ return []
126
+ }
127
+
128
+ type Gathered = {
129
+ schemas: EntitySchema[]
130
+ sources: Record<string, EntityDataSource>
131
+ seed: Record<string, Record<string, unknown>[]>
132
+ kind: DataSourceKind
133
+ title?: string
134
+ }
135
+
136
+ /** Step 1-2: where does the data come from, and what tables does it have? */
137
+ async function gatherData(flags: InitFlags, prompts: PromptIO, db: DbGateway | null, fetchText: FetchText | null): Promise<Gathered> {
138
+ let source: SourceKey = 'sample'
139
+ if (flags.db) source = 'db'
140
+ else if (flags.dataset) source = 'sample'
141
+ else if (!flags.yes) source = SOURCES[await pick(prompts, 'Where should the app get its data?', [...SOURCES])]!.key
142
+
143
+ if (source === 'db') return gatherFromDatabase(flags, prompts, db)
144
+ if (source === 'rest') return gatherFromRest(prompts, fetchText)
145
+
146
+ // Sample data, stored in memory or in PGlite.
147
+ let datasetId = flags.dataset ?? starterDatasets[0]!.id
148
+ if (!flags.dataset && !flags.yes) {
149
+ const i = await pick(prompts, 'Pick a starter dataset:', starterDatasets.map((d) => ({ label: d.name, hint: d.description })))
150
+ datasetId = starterDatasets[i]!.id
151
+ }
152
+ const dataset = getStarterDataset(datasetId)
153
+ if (!dataset) throw new Error(`Unknown dataset "${datasetId}". Try: ${starterDatasets.map((d) => d.id).join(', ')}`)
154
+ const { entities, seed } = dataset.build()
155
+ const kind: DataSourceKind = source === 'pglite' ? 'pglite' : 'memory'
156
+ const sources: Record<string, EntityDataSource> = {}
157
+ for (const e of entities) sources[e.name] = kind === 'pglite' ? { kind: 'pglite', table: e.name } : { kind: 'memory' }
158
+ prompts.say(` Using ${dataset.name} (${entities.length} tables).`)
159
+ return { schemas: entities, sources, seed, kind, title: dataset.name }
160
+ }
161
+
162
+ async function gatherFromDatabase(flags: InitFlags, prompts: PromptIO, db: DbGateway | null): Promise<Gathered> {
163
+ if (!db) throw new Error('Connecting to a database is not available here.')
164
+ let dialect = flags.db
165
+ if (!dialect) {
166
+ const i = await pick(prompts, 'Which database?', DIALECTS.map((d) => ({ label: d })))
167
+ dialect = DIALECTS[i]!
168
+ }
169
+ let url = flags.url ?? ''
170
+ if (!url) {
171
+ if (isFileDialect(dialect)) {
172
+ url = (await prompts.ask('Database file:', './data.db')).trim()
173
+ } else {
174
+ const answer = (await prompts.ask('Connection string (blank to enter host/port instead):', '')).trim()
175
+ url = answer || buildConnectionString(dialect, await askConnectionParts(prompts))
176
+ }
177
+ }
178
+ if (!url) throw new Error('A connection string is required.')
179
+
180
+ const driver = await db.ensureDriver(dialect)
181
+ if (!driver.ok) throw new Error(driver.message ?? `Could not install the ${dialect} driver.`)
182
+
183
+ prompts.say(' Connecting...')
184
+ const execute = await db.connect(dialect, url)
185
+ const tables = await listDatabaseTables(dialect, execute)
186
+ if (tables.length === 0) throw new Error('Connected, but the database has no tables.')
187
+
188
+ const counts = await countTableRows(dialect, execute, tables)
189
+ prompts.say('')
190
+ prompts.say(`Found ${tables.length} ${tables.length === 1 ? 'table' : 'tables'}:`)
191
+ counts.forEach((t, i) => prompts.say(` ${i + 1}. ${t.name}${t.rows == null ? '' : ` (${t.rows} rows)`}`))
192
+ const answer = flags.yes ? 'all' : await prompts.ask('Which tables? (all, or a comma list of names/numbers)', 'all')
193
+ const picked = parseSelection(answer, tables)
194
+ if (picked.length === 0) throw new Error('No tables picked - nothing to build.')
195
+
196
+ const schemas: EntitySchema[] = []
197
+ const sources: Record<string, EntityDataSource> = {}
198
+ for (const table of picked) {
199
+ try {
200
+ const schema = await introspectDatabase({ dialect, table, execute })
201
+ schemas.push(schema)
202
+ sources[schema.name] = { kind: 'sql', table, dialect }
203
+ } catch (e) {
204
+ // One unreadable table must never sink the run.
205
+ prompts.say(` ! Skipped ${table}: ${e instanceof Error ? e.message : String(e)}`)
206
+ }
207
+ }
208
+ if (schemas.length === 0) throw new Error('None of the picked tables could be read.')
209
+ prompts.say(` Read ${schemas.length} ${schemas.length === 1 ? 'table' : 'tables'}.`)
210
+ return { schemas, sources, seed: {}, kind: 'sql' }
211
+ }
212
+
213
+ async function askConnectionParts(prompts: PromptIO): Promise<SqlConnectionParts> {
214
+ return {
215
+ host: (await prompts.ask('Host:', 'localhost')).trim(),
216
+ port: (await prompts.ask('Port:', '')).trim(),
217
+ database: (await prompts.ask('Database:', 'app')).trim(),
218
+ user: (await prompts.ask('User:', 'postgres')).trim(),
219
+ password: (await prompts.ask('Password:', '')).trim(),
220
+ }
221
+ }
222
+
223
+ async function gatherFromRest(prompts: PromptIO, fetchText: FetchText | null): Promise<Gathered> {
224
+ if (!fetchText) throw new Error('Reading a REST endpoint is not available here.')
225
+ const src = (await prompts.ask('OpenAPI spec (file or URL), or a REST endpoint returning rows:', '')).trim()
226
+ if (!src) throw new Error('A spec path/URL or endpoint is required.')
227
+ const text = await fetchText(src)
228
+
229
+ // An OpenAPI document describes every resource; a plain endpoint gives one.
230
+ if (/"(openapi|swagger|paths)"\s*:/.test(text)) {
231
+ const { entities, sources, warnings } = introspectOpenApi(text)
232
+ for (const w of warnings) prompts.say(` ! ${w}`)
233
+ if (entities.length === 0) throw new Error('The spec described no collection resources.')
234
+ prompts.say(` Read ${entities.length} REST ${entities.length === 1 ? 'resource' : 'resources'}.`)
235
+ return { schemas: entities, sources, seed: {}, kind: 'rest' }
236
+ }
237
+
238
+ const rows = unwrapRows(JSON.parse(text) as unknown)
239
+ if (rows.length === 0) throw new Error('The endpoint returned no rows to read a shape from.')
240
+ const url = new URL(src)
241
+ const path = url.pathname.replace(/^\/+|\/+$/g, '')
242
+ const suggested = path.split('/').filter(Boolean).pop() || 'items'
243
+ const name = ((await prompts.ask('Table name:', suggested)).trim() || suggested).replace(/\W+/g, '_')
244
+ const schema = introspectJson(name, rows)
245
+ prompts.say(` Read ${schema.fields.length} fields from ${rows.length} rows.`)
246
+ return {
247
+ schemas: [schema],
248
+ sources: { [name]: { kind: 'rest', baseUrl: url.origin, path, method: 'GET', params: [] } },
249
+ seed: {},
250
+ kind: 'rest',
251
+ }
252
+ }
253
+
254
+ /** Step 3: which pages does each table get, and how are rows edited? */
255
+ async function askScreens(schemas: EntitySchema[], flags: InitFlags, prompts: PromptIO): Promise<Record<string, CrudSuiteOptions>> {
256
+ const perEntity: Record<string, CrudSuiteOptions> = {}
257
+ let editing: CrudEditingMode = 'form'
258
+ let full = true
259
+
260
+ if (!flags.yes) {
261
+ full = isYes(await prompts.ask('\nGenerate a list, an edit form and a record page for each table? (Y/n)', 'y'), true)
262
+ const modes: { label: string; value: CrudEditingMode }[] = [
263
+ { label: 'A popup form', value: 'form' },
264
+ { label: 'In the grid (like a spreadsheet)', value: 'inline' },
265
+ { label: 'On the record page', value: 'detail' },
266
+ ]
267
+ editing = modes[await pick(prompts, 'How should rows be edited?', modes)]!.value
268
+ }
269
+
270
+ for (const schema of schemas) {
271
+ const hasChildren = schemas.some((e) => e.fields.some((f) => f.type === 'relation' && f.relation?.entity === schema.name))
272
+ const screens: CrudScreenKind[] = full
273
+ ? hasChildren || editing === 'detail' ? ['list', 'form', 'detail'] : ['list', 'form']
274
+ : ['list']
275
+ perEntity[schema.name] = { screens, editing }
276
+ }
277
+ return perEntity
278
+ }
279
+
280
+ /** Step 4: the app's look. */
281
+ async function askTheme(flags: InitFlags, prompts: PromptIO): Promise<ProjectTheme | undefined> {
282
+ let preset = flags.theme?.trim().toLowerCase()
283
+ if (preset && !getStudioTheme(preset)) {
284
+ prompts.say(` ! Unknown theme "${preset}" - using tailwind.`)
285
+ preset = undefined
286
+ }
287
+ if (!preset && !flags.yes) {
288
+ const i = await pick(prompts, 'Theme:', studioThemes.map((t) => ({ label: t.name, hint: t.id })))
289
+ preset = studioThemes[i]?.id
290
+ }
291
+ preset ??= 'tailwind'
292
+ const dark = flags.dark ?? (flags.yes ? false : isYes(await prompts.ask('Dark mode? (y/N)', 'n'), false))
293
+ return { preset, mode: dark ? 'dark' : 'light' }
294
+ }
295
+
296
+ /** Merge-write the generated files, preserving edits outside managed regions. */
297
+ async function writeAll(files: { path: string; contents: string; userOwned?: boolean }[], outDir: string, io: StudioIO): Promise<string[]> {
298
+ const written: string[] = []
299
+ const prefix = !outDir || outDir === '.' ? '' : outDir.replace(/[\\/]+$/, '') + '/'
300
+ for (const file of files) {
301
+ const path = prefix + file.path
302
+ const existing = await io.readFile(path)
303
+ if (skipUserOwned(file, existing != null)) continue
304
+ await io.writeFile(path, mergeManaged(existing, file.contents))
305
+ written.push(path)
306
+ }
307
+ return written
308
+ }
309
+
310
+ /**
311
+ * The whole guided flow: ask, build, write. Returns the project and what was
312
+ * written so the caller can print next steps (or assert them in a test).
313
+ */
314
+ export async function runStudioInit(
315
+ flags: InitFlags,
316
+ prompts: PromptIO,
317
+ db: DbGateway | null,
318
+ io: StudioIO,
319
+ fetchText: FetchText | null = null,
320
+ ): Promise<InitResult> {
321
+ prompts.say('SvGrid Studio - let\'s build your app.')
322
+
323
+ const data = await gatherData(flags, prompts, db, fetchText)
324
+ const perEntity = await askScreens(data.schemas, flags, prompts)
325
+ const theme = await askTheme(flags, prompts)
326
+
327
+ const defaultTitle = flags.title ?? data.title ?? 'My app'
328
+ const title = flags.yes || flags.title ? defaultTitle : (await prompts.ask('\nApp name:', defaultTitle)).trim() || defaultTitle
329
+ const outDir = flags.out ?? (flags.yes ? '.' : (await prompts.ask('Write it where?', '.')).trim() || '.')
330
+
331
+ const project = crudAppFromSchemas(data.schemas, {
332
+ title,
333
+ dataSource: data.kind,
334
+ sources: data.sources,
335
+ seed: data.seed,
336
+ perEntity,
337
+ overviewDashboard: true,
338
+ ...(theme ? { theme } : {}),
339
+ })
340
+
341
+ const files = emitStudioAppBundle(project)
342
+ const written = await writeAll(files, outDir, io)
343
+ // The design itself, so `svgrid-studio designer` reopens exactly this app.
344
+ const configPath = (!outDir || outDir === '.' ? '' : outDir.replace(/[\\/]+$/, '') + '/') + 'studio.config.json'
345
+ await io.writeFile(configPath, serializeProject(project))
346
+ written.push(configPath)
347
+
348
+ prompts.say('')
349
+ prompts.say(`Built "${title}": ${data.schemas.length} ${data.schemas.length === 1 ? 'table' : 'tables'}, ${project.screens.length} screens, ${written.length} files.`)
350
+
351
+ const nextSteps = [
352
+ ...(outDir && outDir !== '.' ? [`cd ${outDir}`] : []),
353
+ 'npm install',
354
+ 'npm run dev',
355
+ 'svgrid-studio designer # keep editing it visually',
356
+ ]
357
+ return { project, written, nextSteps }
358
+ }