@gridfox/codegen 0.2.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/.env.example +3 -0
- package/README.md +1152 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +394 -0
- package/dist/cli/prompt.d.ts +4 -0
- package/dist/cli/prompt.js +89 -0
- package/dist/config/loadConfig.d.ts +2 -0
- package/dist/config/loadConfig.js +49 -0
- package/dist/config/schema.d.ts +21 -0
- package/dist/config/schema.js +17 -0
- package/dist/emit/formatter.d.ts +1 -0
- package/dist/emit/formatter.js +2 -0
- package/dist/emit/writer.d.ts +7 -0
- package/dist/emit/writer.js +37 -0
- package/dist/generate.d.ts +9 -0
- package/dist/generate.js +53 -0
- package/dist/generators/generateIndexFile.d.ts +2 -0
- package/dist/generators/generateIndexFile.js +12 -0
- package/dist/generators/generateRegistryFile.d.ts +2 -0
- package/dist/generators/generateRegistryFile.js +7 -0
- package/dist/generators/generateSdkClientFile.d.ts +2 -0
- package/dist/generators/generateSdkClientFile.js +46 -0
- package/dist/generators/generateSharedTypes.d.ts +1 -0
- package/dist/generators/generateSharedTypes.js +4 -0
- package/dist/generators/generateTableModule.d.ts +2 -0
- package/dist/generators/generateTableModule.js +49 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/input/apiTransport.d.ts +6 -0
- package/dist/input/apiTransport.js +21 -0
- package/dist/input/parseTablesPayload.d.ts +21 -0
- package/dist/input/parseTablesPayload.js +71 -0
- package/dist/input/readApiInput.d.ts +9 -0
- package/dist/input/readApiInput.js +17 -0
- package/dist/input/readInput.d.ts +21 -0
- package/dist/input/readInput.js +14 -0
- package/dist/model/internalTypes.d.ts +60 -0
- package/dist/model/internalTypes.js +1 -0
- package/dist/model/normalizeTables.d.ts +6 -0
- package/dist/model/normalizeTables.js +68 -0
- package/dist/model/zodSchemas.d.ts +120 -0
- package/dist/model/zodSchemas.js +47 -0
- package/dist/naming/fieldAliases.d.ts +1 -0
- package/dist/naming/fieldAliases.js +3 -0
- package/dist/naming/identifiers.d.ts +1 -0
- package/dist/naming/identifiers.js +11 -0
- package/dist/naming/reservedWords.d.ts +1 -0
- package/dist/naming/reservedWords.js +13 -0
- package/dist/naming/tableNames.d.ts +1 -0
- package/dist/naming/tableNames.js +3 -0
- package/dist/typing/mapFieldType.d.ts +8 -0
- package/dist/typing/mapFieldType.js +95 -0
- package/dist/typing/writability.d.ts +1 -0
- package/dist/typing/writability.js +2 -0
- package/dist/utils/sort.d.ts +11 -0
- package/dist/utils/sort.js +5 -0
- package/dist/validate/crudPlan.d.ts +23 -0
- package/dist/validate/crudPlan.js +189 -0
- package/dist/validate/renderCrudTest.d.ts +2 -0
- package/dist/validate/renderCrudTest.js +180 -0
- package/package.json +57 -0
package/dist/cli/main.js
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
// If users pass a TypeScript config file directly (e.g. --config foo.ts), Node
|
|
4
|
+
// can't import it by default. Ensure we run under tsx so the config can be loaded.
|
|
5
|
+
// We use an env var to avoid infinite respawn loops.
|
|
6
|
+
const shouldRespawnWithTsx = () => {
|
|
7
|
+
if (process.env.GRIDFOX_CODEGEN_TSX === '1')
|
|
8
|
+
return false;
|
|
9
|
+
const argv = process.argv.slice(2);
|
|
10
|
+
const configFlagIndex = argv.findIndex((arg) => arg === '--config');
|
|
11
|
+
if (configFlagIndex === -1)
|
|
12
|
+
return false;
|
|
13
|
+
const configPath = argv[configFlagIndex + 1];
|
|
14
|
+
if (typeof configPath !== 'string')
|
|
15
|
+
return false;
|
|
16
|
+
return !!configPath.match(/\.(?:mts|cts|ts)$/i);
|
|
17
|
+
};
|
|
18
|
+
if (shouldRespawnWithTsx()) {
|
|
19
|
+
const { spawn } = await import('node:child_process');
|
|
20
|
+
const args = ['--import', 'tsx', ...process.argv.slice(1)];
|
|
21
|
+
const child = spawn(process.execPath, args, {
|
|
22
|
+
stdio: 'inherit',
|
|
23
|
+
env: { ...process.env, GRIDFOX_CODEGEN_TSX: '1' }
|
|
24
|
+
});
|
|
25
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
26
|
+
child.on('exit', (code) => resolve(code ?? 0));
|
|
27
|
+
child.on('error', (err) => reject(err));
|
|
28
|
+
});
|
|
29
|
+
process.exit(exitCode);
|
|
30
|
+
}
|
|
31
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
32
|
+
import { tmpdir } from 'node:os';
|
|
33
|
+
import { cac } from 'cac';
|
|
34
|
+
import { execa } from 'execa';
|
|
35
|
+
import { loadConfig } from '../config/loadConfig.js';
|
|
36
|
+
import { readTablesInput } from '../input/readInput.js';
|
|
37
|
+
import { readTablesFromApi } from '../input/readApiInput.js';
|
|
38
|
+
import { generateFromTables, planGeneration } from '../generate.js';
|
|
39
|
+
import { sortedKeys } from '../utils/sort.js';
|
|
40
|
+
import { buildHeuristicPlan } from '../validate/crudPlan.js';
|
|
41
|
+
import { buildCrudTestSource } from '../validate/renderCrudTest.js';
|
|
42
|
+
import { canPrompt, promptConfirm, promptSecret, promptText } from './prompt.js';
|
|
43
|
+
const cli = cac('@gridfox/codegen');
|
|
44
|
+
const summarize = (statuses) => statuses.reduce((acc, status) => {
|
|
45
|
+
acc[status] += 1;
|
|
46
|
+
return acc;
|
|
47
|
+
}, { new: 0, changed: 0, unchanged: 0 });
|
|
48
|
+
const printError = (error) => {
|
|
49
|
+
if (error instanceof Error) {
|
|
50
|
+
const [headline, ...details] = error.message.split('\n');
|
|
51
|
+
console.error(`Error: ${headline}`);
|
|
52
|
+
if (details.length > 0) {
|
|
53
|
+
console.error(details.join('\n'));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
console.error('Error: unknown failure');
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const parsePositiveNumber = (value, label) => {
|
|
61
|
+
if (value === undefined || value === null || value === '') {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
const parsed = Number(value);
|
|
65
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
66
|
+
throw new Error(`Invalid ${label} value. Expected a positive number.`);
|
|
67
|
+
}
|
|
68
|
+
return parsed;
|
|
69
|
+
};
|
|
70
|
+
const compileRegex = (pattern, flagName) => {
|
|
71
|
+
if (!pattern) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
return new RegExp(pattern);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
const message = error instanceof Error ? error.message : 'invalid regex';
|
|
79
|
+
throw new Error(`Invalid ${flagName} regex: ${message}`);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const resolveApiKey = async (apiKeyFlag) => {
|
|
83
|
+
if (apiKeyFlag && apiKeyFlag.trim().length > 0) {
|
|
84
|
+
return apiKeyFlag.trim();
|
|
85
|
+
}
|
|
86
|
+
const fromEnv = process.env.GRIDFOX_API_KEY;
|
|
87
|
+
if (fromEnv && fromEnv.trim().length > 0) {
|
|
88
|
+
return fromEnv.trim();
|
|
89
|
+
}
|
|
90
|
+
const prompted = await promptSecret('Gridfox API key: ');
|
|
91
|
+
if (!prompted) {
|
|
92
|
+
throw new Error('Missing API key. Provide --api-key, set GRIDFOX_API_KEY, or enter a key at the prompt.');
|
|
93
|
+
}
|
|
94
|
+
return prompted;
|
|
95
|
+
};
|
|
96
|
+
const buildTempTsConfig = (workspaceDir) => JSON.stringify({
|
|
97
|
+
compilerOptions: {
|
|
98
|
+
target: 'ES2022',
|
|
99
|
+
module: 'NodeNext',
|
|
100
|
+
moduleResolution: 'NodeNext',
|
|
101
|
+
strict: true,
|
|
102
|
+
outDir: path.join(workspaceDir, '.compiled'),
|
|
103
|
+
rootDir: workspaceDir,
|
|
104
|
+
skipLibCheck: true,
|
|
105
|
+
lib: ['ES2022', 'DOM']
|
|
106
|
+
},
|
|
107
|
+
include: [path.join(workspaceDir, 'generated', '**/*.ts'), path.join(workspaceDir, 'src', '**/*.ts')]
|
|
108
|
+
}, null, 2);
|
|
109
|
+
cli
|
|
110
|
+
.command('generate', 'Generate TypeScript artifacts from Gridfox tables JSON')
|
|
111
|
+
.option('--input <path>', 'Path to tables JSON file')
|
|
112
|
+
.option('--output <path>', 'Output directory for generated files')
|
|
113
|
+
.option('--config <path>', 'Path to config file (.json, .ts, .mts, .cts, .mjs, .cjs)')
|
|
114
|
+
.option('--api-key <key>', 'Gridfox API key (or GRIDFOX_API_KEY)')
|
|
115
|
+
.option('--api-base-url <url>', 'Override Gridfox API base URL (or GRIDFOX_API_BASE_URL)')
|
|
116
|
+
.option('--api-timeout-ms <ms>', 'HTTP timeout for Gridfox API requests (or GRIDFOX_API_TIMEOUT_MS)')
|
|
117
|
+
.option('--multi-select-mode <mode>', 'Set multiSelect typing mode: union | stringArray')
|
|
118
|
+
.option('--client', 'Generate client.ts SDK wrapper (imports @gridfox/sdk)')
|
|
119
|
+
.option('--emit-sdk-client', 'Deprecated alias for --client')
|
|
120
|
+
.option('--emit-registry', 'Generate tables.ts registry')
|
|
121
|
+
.option('--emit-reverse-alias-map', 'Generate reverse alias map constants')
|
|
122
|
+
.option('--dry-run', 'Show planned file writes without writing files')
|
|
123
|
+
.option('--check', 'Exit non-zero when generated output is outdated')
|
|
124
|
+
.option('--no-format', 'Disable prettier formatting')
|
|
125
|
+
.action(async (flags) => {
|
|
126
|
+
try {
|
|
127
|
+
const interactive = canPrompt();
|
|
128
|
+
let outputFlag = typeof flags.output === 'string' ? flags.output : undefined;
|
|
129
|
+
let inputFlag = typeof flags.input === 'string' ? flags.input : undefined;
|
|
130
|
+
let apiKeyFlag = typeof flags.apiKey === 'string' ? flags.apiKey : undefined;
|
|
131
|
+
let clientFlag = flags.client ?? flags.emitSdkClient;
|
|
132
|
+
if (interactive && !flags.config) {
|
|
133
|
+
if (!outputFlag) {
|
|
134
|
+
outputFlag = await promptText('Output directory: ');
|
|
135
|
+
}
|
|
136
|
+
if (!inputFlag && !apiKeyFlag) {
|
|
137
|
+
const useApi = await promptConfirm('Use Gridfox API as input source?', Boolean(process.env.GRIDFOX_API_KEY?.trim()));
|
|
138
|
+
if (useApi) {
|
|
139
|
+
if (!process.env.GRIDFOX_API_KEY?.trim()) {
|
|
140
|
+
apiKeyFlag = await resolveApiKey(undefined);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
inputFlag = await promptText('Path to tables JSON input file: ');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (clientFlag === undefined) {
|
|
148
|
+
clientFlag = await promptConfirm('Emit typed SDK client wrapper (client.ts)?', false);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const config = await loadConfig(flags.config, {
|
|
152
|
+
input: inputFlag,
|
|
153
|
+
output: outputFlag,
|
|
154
|
+
apiKey: apiKeyFlag,
|
|
155
|
+
apiBaseUrl: flags.apiBaseUrl,
|
|
156
|
+
apiTimeoutMs: flags.apiTimeoutMs !== undefined ? Number(flags.apiTimeoutMs) : undefined,
|
|
157
|
+
multiSelectMode: flags.multiSelectMode,
|
|
158
|
+
emitClient: clientFlag,
|
|
159
|
+
emitRegistry: flags.emitRegistry,
|
|
160
|
+
emitReverseAliasMap: flags.emitReverseAliasMap,
|
|
161
|
+
format: flags.format
|
|
162
|
+
});
|
|
163
|
+
const hasApiConfig = Boolean(config.apiKey);
|
|
164
|
+
let tables;
|
|
165
|
+
if (config.input) {
|
|
166
|
+
tables = await readTablesInput(config.input);
|
|
167
|
+
}
|
|
168
|
+
else if (hasApiConfig) {
|
|
169
|
+
tables = await readTablesFromApi({
|
|
170
|
+
apiKey: config.apiKey,
|
|
171
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
172
|
+
apiTimeoutMs: config.apiTimeoutMs
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
throw new Error('Missing input source. Provide --input/config.input or --api-key (or GRIDFOX_API_KEY)');
|
|
177
|
+
}
|
|
178
|
+
if (flags.dryRun || flags.check) {
|
|
179
|
+
const { diff } = await planGeneration(tables, config);
|
|
180
|
+
const totals = summarize(diff.map((entry) => entry.status));
|
|
181
|
+
const statusesByPath = Object.fromEntries(diff.map((entry) => [entry.path, entry.status]));
|
|
182
|
+
console.log(`Planned files: ${diff.length} (new: ${totals.new}, changed: ${totals.changed}, unchanged: ${totals.unchanged})`);
|
|
183
|
+
for (const outputPath of sortedKeys(statusesByPath)) {
|
|
184
|
+
console.log(`[${statusesByPath[outputPath]}] ${outputPath}`);
|
|
185
|
+
}
|
|
186
|
+
if (flags.check && (totals.new > 0 || totals.changed > 0)) {
|
|
187
|
+
console.error('Error: generated files are out of date');
|
|
188
|
+
process.exitCode = 1;
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
await generateFromTables(tables, config);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
printError(error);
|
|
196
|
+
process.exitCode = 1;
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
cli
|
|
200
|
+
.command('validate', 'Run live CRUD validation against a Gridfox project')
|
|
201
|
+
.option('--api-key <key>', 'Gridfox API key (or GRIDFOX_API_KEY; prompts if missing)')
|
|
202
|
+
.option('--key <key>', 'Alias for --api-key')
|
|
203
|
+
.option('--api-base-url <url>', 'Override Gridfox API base URL (or GRIDFOX_API_BASE_URL)')
|
|
204
|
+
.option('--api-timeout-ms <ms>', 'HTTP timeout for Gridfox API requests (or GRIDFOX_API_TIMEOUT_MS)')
|
|
205
|
+
.option('--table <name>', 'Pin to a specific table name')
|
|
206
|
+
.option('--allow-table-regex <pattern>', 'Allow only table names matching regex when auto-selecting')
|
|
207
|
+
.option('--deny-table-regex <pattern>', 'Deny table names matching regex when auto-selecting')
|
|
208
|
+
.option('--plan-only', 'Compute and print plan without writing any records')
|
|
209
|
+
.option('--yes-live-writes', 'Acknowledge that validate will create/update/delete real records')
|
|
210
|
+
.option('--json', 'Emit machine-readable JSON output')
|
|
211
|
+
.option('--keep-temp', 'Keep temp workspace after run')
|
|
212
|
+
.option('--verbose', 'Print detailed progress information')
|
|
213
|
+
.action(async (flags) => {
|
|
214
|
+
const jsonOutput = Boolean(flags.json);
|
|
215
|
+
const interactive = canPrompt() && !jsonOutput;
|
|
216
|
+
const log = (...values) => {
|
|
217
|
+
if (!flags.verbose) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const line = `[validate] ${values.map((value) => String(value)).join(' ')}`;
|
|
221
|
+
if (jsonOutput) {
|
|
222
|
+
console.error(line);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
console.log(line);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
const emitJson = (payload) => {
|
|
229
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
230
|
+
};
|
|
231
|
+
const defaultDenyRegex = /audit|log|event|history/i;
|
|
232
|
+
let workspaceDir = null;
|
|
233
|
+
try {
|
|
234
|
+
let tableName = typeof flags.table === 'string' ? flags.table : process.env.GRIDFOX_REAL_TEST_TABLE;
|
|
235
|
+
const allowRegex = compileRegex(flags.allowTableRegex, '--allow-table-regex');
|
|
236
|
+
const denyRegex = compileRegex(flags.denyTableRegex, '--deny-table-regex');
|
|
237
|
+
let planOnly = Boolean(flags.planOnly);
|
|
238
|
+
let yesLiveWrites = Boolean(flags.yesLiveWrites);
|
|
239
|
+
if (!tableName && !allowRegex && !interactive) {
|
|
240
|
+
throw new Error('Validate requires either --table or --allow-table-regex for auto table selection');
|
|
241
|
+
}
|
|
242
|
+
if (!planOnly && !yesLiveWrites && !interactive) {
|
|
243
|
+
throw new Error('Live validation writes data. Re-run with --yes-live-writes or use --plan-only');
|
|
244
|
+
}
|
|
245
|
+
const apiKey = await resolveApiKey(flags.apiKey ?? flags.key);
|
|
246
|
+
const apiBaseUrl = flags.apiBaseUrl ?? process.env.GRIDFOX_API_BASE_URL;
|
|
247
|
+
const apiTimeoutMs = parsePositiveNumber(flags.apiTimeoutMs ?? process.env.GRIDFOX_API_TIMEOUT_MS, 'API timeout');
|
|
248
|
+
log('fetching tables from Gridfox API');
|
|
249
|
+
const tables = await readTablesFromApi({
|
|
250
|
+
apiKey,
|
|
251
|
+
apiBaseUrl,
|
|
252
|
+
apiTimeoutMs
|
|
253
|
+
});
|
|
254
|
+
if (!tableName && !allowRegex) {
|
|
255
|
+
if (!interactive) {
|
|
256
|
+
throw new Error('Validate requires either --table or --allow-table-regex for auto table selection');
|
|
257
|
+
}
|
|
258
|
+
const availableTables = tables.map((table) => table.name).sort((left, right) => left.localeCompare(right));
|
|
259
|
+
if (availableTables.length > 0) {
|
|
260
|
+
console.log(`Available tables: ${availableTables.join(', ')}`);
|
|
261
|
+
}
|
|
262
|
+
tableName = await promptText('Table name: ');
|
|
263
|
+
if (!tableName) {
|
|
264
|
+
throw new Error('Missing table name. Provide --table/--allow-table-regex or enter a table name at the prompt.');
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (!planOnly && !yesLiveWrites && interactive) {
|
|
268
|
+
planOnly = await promptConfirm('Run in plan-only mode (no live writes)?', true);
|
|
269
|
+
if (!planOnly) {
|
|
270
|
+
yesLiveWrites = await promptConfirm('This will create/update/delete real records in your project. Continue?', false);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (!planOnly && !yesLiveWrites) {
|
|
274
|
+
throw new Error('Live validation writes data. Re-run with --yes-live-writes or use --plan-only');
|
|
275
|
+
}
|
|
276
|
+
const candidateTables = tableName
|
|
277
|
+
? tables
|
|
278
|
+
: tables
|
|
279
|
+
.filter((table) => allowRegex.test(table.name))
|
|
280
|
+
.filter((table) => !(denyRegex ?? defaultDenyRegex).test(table.name));
|
|
281
|
+
if (candidateTables.length === 0) {
|
|
282
|
+
const allowText = allowRegex ? allowRegex.toString() : '<none>';
|
|
283
|
+
const denyText = (denyRegex ?? defaultDenyRegex).toString();
|
|
284
|
+
throw new Error(`No tables remain after applying filters (allow: ${allowText}, deny: ${denyText})`);
|
|
285
|
+
}
|
|
286
|
+
const plan = buildHeuristicPlan(candidateTables, {
|
|
287
|
+
tableName
|
|
288
|
+
});
|
|
289
|
+
if (!jsonOutput) {
|
|
290
|
+
console.log(`[validate] Selected table: ${plan.tableName}`);
|
|
291
|
+
console.log('[validate] CRUD plan:');
|
|
292
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
293
|
+
}
|
|
294
|
+
if (planOnly) {
|
|
295
|
+
if (jsonOutput) {
|
|
296
|
+
emitJson({
|
|
297
|
+
command: 'validate',
|
|
298
|
+
status: 'planned',
|
|
299
|
+
planOnly: true,
|
|
300
|
+
table: plan.tableName,
|
|
301
|
+
plan
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
workspaceDir = await mkdtemp(path.join(tmpdir(), 'gridfox-validate-'));
|
|
307
|
+
const generatedDir = path.join(workspaceDir, 'generated');
|
|
308
|
+
const srcDir = path.join(workspaceDir, 'src');
|
|
309
|
+
const compiledDir = path.join(workspaceDir, '.compiled');
|
|
310
|
+
const tsconfigPath = path.join(workspaceDir, 'tsconfig.json');
|
|
311
|
+
const testFilePath = path.join(srcDir, 'realCrudTest.ts');
|
|
312
|
+
log('temp workspace:', workspaceDir);
|
|
313
|
+
await mkdir(generatedDir, { recursive: true });
|
|
314
|
+
await mkdir(srcDir, { recursive: true });
|
|
315
|
+
log('generating TypeScript schema files');
|
|
316
|
+
await generateFromTables(candidateTables, {
|
|
317
|
+
output: generatedDir,
|
|
318
|
+
emitRegistry: true,
|
|
319
|
+
emitMetadata: true,
|
|
320
|
+
format: true
|
|
321
|
+
});
|
|
322
|
+
log('writing temp validation test source');
|
|
323
|
+
await writeFile(tsconfigPath, buildTempTsConfig(workspaceDir), 'utf8');
|
|
324
|
+
await writeFile(testFilePath, buildCrudTestSource(plan), 'utf8');
|
|
325
|
+
log('typechecking generated + validation test files');
|
|
326
|
+
await execa('npx', ['tsc', '-p', tsconfigPath], {
|
|
327
|
+
cwd: process.cwd(),
|
|
328
|
+
stdio: 'inherit'
|
|
329
|
+
});
|
|
330
|
+
log('running live CRUD validation');
|
|
331
|
+
await execa('node', [path.join(compiledDir, 'src', 'realCrudTest.js')], {
|
|
332
|
+
cwd: process.cwd(),
|
|
333
|
+
stdio: 'inherit',
|
|
334
|
+
env: {
|
|
335
|
+
...process.env,
|
|
336
|
+
GRIDFOX_API_KEY: apiKey,
|
|
337
|
+
GRIDFOX_API_BASE_URL: apiBaseUrl
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
if (jsonOutput) {
|
|
341
|
+
emitJson({
|
|
342
|
+
command: 'validate',
|
|
343
|
+
status: 'passed',
|
|
344
|
+
table: plan.tableName,
|
|
345
|
+
plan
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
console.log(`[validate] Passed for table "${plan.tableName}"`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
if (jsonOutput) {
|
|
354
|
+
const message = error instanceof Error ? error.message : 'unknown failure';
|
|
355
|
+
emitJson({
|
|
356
|
+
command: 'validate',
|
|
357
|
+
status: 'failed',
|
|
358
|
+
error: message
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
printError(error);
|
|
363
|
+
}
|
|
364
|
+
process.exitCode = 1;
|
|
365
|
+
}
|
|
366
|
+
finally {
|
|
367
|
+
if (workspaceDir) {
|
|
368
|
+
if (flags.keepTemp) {
|
|
369
|
+
const message = `Kept temp workspace: ${workspaceDir}`;
|
|
370
|
+
if (jsonOutput) {
|
|
371
|
+
console.error(`[validate] ${message}`);
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
console.log(message);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
await rm(workspaceDir, { recursive: true, force: true });
|
|
379
|
+
log('cleaned temp workspace');
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
cli.help();
|
|
385
|
+
if (process.argv.slice(2).length === 0) {
|
|
386
|
+
if (canPrompt()) {
|
|
387
|
+
process.argv.push('generate');
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
cli.outputHelp();
|
|
391
|
+
process.exitCode = 1;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
cli.parse();
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const canPrompt: () => boolean;
|
|
2
|
+
export declare const promptText: (question: string) => Promise<string>;
|
|
3
|
+
export declare const promptConfirm: (question: string, defaultValue?: boolean) => Promise<boolean>;
|
|
4
|
+
export declare const promptSecret: (question: string) => Promise<string>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
export const canPrompt = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
3
|
+
const ensurePromptSupported = (message) => {
|
|
4
|
+
if (!canPrompt()) {
|
|
5
|
+
throw new Error(message);
|
|
6
|
+
}
|
|
7
|
+
};
|
|
8
|
+
export const promptText = async (question) => {
|
|
9
|
+
ensurePromptSupported('Cannot prompt in non-interactive mode.');
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const rl = readline.createInterface({
|
|
12
|
+
input: process.stdin,
|
|
13
|
+
output: process.stdout,
|
|
14
|
+
terminal: true
|
|
15
|
+
});
|
|
16
|
+
const onSigint = () => {
|
|
17
|
+
rl.close();
|
|
18
|
+
reject(new Error('Prompt cancelled'));
|
|
19
|
+
};
|
|
20
|
+
rl.once('SIGINT', onSigint);
|
|
21
|
+
rl.question(question, (answer) => {
|
|
22
|
+
rl.off('SIGINT', onSigint);
|
|
23
|
+
rl.close();
|
|
24
|
+
resolve(answer.trim());
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
export const promptConfirm = async (question, defaultValue = false) => {
|
|
29
|
+
const suffix = defaultValue ? ' [Y/n]: ' : ' [y/N]: ';
|
|
30
|
+
while (true) {
|
|
31
|
+
const answer = (await promptText(`${question}${suffix}`)).toLowerCase();
|
|
32
|
+
if (!answer) {
|
|
33
|
+
return defaultValue;
|
|
34
|
+
}
|
|
35
|
+
if (answer === 'y' || answer === 'yes') {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
if (answer === 'n' || answer === 'no') {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
console.log('Please answer yes or no.');
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
export const promptSecret = async (question) => {
|
|
45
|
+
ensurePromptSupported('Cannot prompt for API key in non-interactive mode. Pass --api-key or set GRIDFOX_API_KEY.');
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const rl = readline.createInterface({
|
|
48
|
+
input: process.stdin,
|
|
49
|
+
output: process.stdout,
|
|
50
|
+
terminal: true
|
|
51
|
+
});
|
|
52
|
+
const originalWrite = rl._writeToOutput?.bind(rl);
|
|
53
|
+
rl.stdoutMuted = false;
|
|
54
|
+
rl._writeToOutput = (value) => {
|
|
55
|
+
if (!rl.stdoutMuted) {
|
|
56
|
+
if (originalWrite) {
|
|
57
|
+
originalWrite(value);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
process.stdout.write(value);
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (value.endsWith('\n')) {
|
|
65
|
+
process.stdout.write(value);
|
|
66
|
+
}
|
|
67
|
+
else if (value.trim().length > 0) {
|
|
68
|
+
process.stdout.write('*');
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
process.stdout.write(value);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const onSigint = () => {
|
|
75
|
+
rl.close();
|
|
76
|
+
reject(new Error('Prompt cancelled'));
|
|
77
|
+
};
|
|
78
|
+
rl.once('SIGINT', onSigint);
|
|
79
|
+
rl.question(question, (answer) => {
|
|
80
|
+
rl.off('SIGINT', onSigint);
|
|
81
|
+
rl.close();
|
|
82
|
+
process.stdout.write('\n');
|
|
83
|
+
resolve(answer.trim());
|
|
84
|
+
});
|
|
85
|
+
setImmediate(() => {
|
|
86
|
+
rl.stdoutMuted = true;
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { ConfigSchema } from './schema.js';
|
|
5
|
+
const defaults = {
|
|
6
|
+
input: undefined,
|
|
7
|
+
includeTables: undefined,
|
|
8
|
+
excludeTables: undefined,
|
|
9
|
+
apiKey: undefined,
|
|
10
|
+
apiBaseUrl: undefined,
|
|
11
|
+
apiTimeoutMs: undefined,
|
|
12
|
+
multiSelectMode: 'union',
|
|
13
|
+
emitClient: false,
|
|
14
|
+
emitRegistry: true,
|
|
15
|
+
emitSdkClient: undefined,
|
|
16
|
+
emitReverseAliasMap: false,
|
|
17
|
+
emitMetadata: true,
|
|
18
|
+
format: true
|
|
19
|
+
};
|
|
20
|
+
const readEnvConfig = () => {
|
|
21
|
+
const apiTimeoutMs = process.env.GRIDFOX_API_TIMEOUT_MS;
|
|
22
|
+
return {
|
|
23
|
+
apiKey: process.env.GRIDFOX_API_KEY,
|
|
24
|
+
apiBaseUrl: process.env.GRIDFOX_API_BASE_URL,
|
|
25
|
+
apiTimeoutMs: apiTimeoutMs !== undefined ? Number(apiTimeoutMs) : undefined
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
export const loadConfig = async (configPath, overrides = {}) => {
|
|
29
|
+
let configFromFile = {};
|
|
30
|
+
if (configPath) {
|
|
31
|
+
const extension = path.extname(configPath);
|
|
32
|
+
if (extension === '.json') {
|
|
33
|
+
const raw = await readFile(configPath, 'utf8');
|
|
34
|
+
configFromFile = JSON.parse(raw);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
const imported = await import(pathToFileURL(path.resolve(configPath)).href);
|
|
38
|
+
configFromFile = (imported.default ?? imported);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const definedOverrides = Object.fromEntries(Object.entries(overrides).filter(([, value]) => value !== undefined));
|
|
42
|
+
const merged = {
|
|
43
|
+
...defaults,
|
|
44
|
+
...readEnvConfig(),
|
|
45
|
+
...configFromFile,
|
|
46
|
+
...definedOverrides
|
|
47
|
+
};
|
|
48
|
+
return ConfigSchema.parse(merged);
|
|
49
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const ConfigSchema: z.ZodObject<{
|
|
3
|
+
input: z.ZodOptional<z.ZodString>;
|
|
4
|
+
output: z.ZodString;
|
|
5
|
+
includeTables: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
6
|
+
excludeTables: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
7
|
+
apiKey: z.ZodOptional<z.ZodString>;
|
|
8
|
+
apiBaseUrl: z.ZodOptional<z.ZodString>;
|
|
9
|
+
apiTimeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
10
|
+
multiSelectMode: z.ZodDefault<z.ZodEnum<{
|
|
11
|
+
union: "union";
|
|
12
|
+
stringArray: "stringArray";
|
|
13
|
+
}>>;
|
|
14
|
+
emitClient: z.ZodDefault<z.ZodBoolean>;
|
|
15
|
+
emitRegistry: z.ZodDefault<z.ZodBoolean>;
|
|
16
|
+
emitSdkClient: z.ZodOptional<z.ZodBoolean>;
|
|
17
|
+
emitReverseAliasMap: z.ZodDefault<z.ZodBoolean>;
|
|
18
|
+
emitMetadata: z.ZodDefault<z.ZodBoolean>;
|
|
19
|
+
format: z.ZodDefault<z.ZodBoolean>;
|
|
20
|
+
}, z.core.$strip>;
|
|
21
|
+
export type Config = z.infer<typeof ConfigSchema>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const ConfigSchema = z.object({
|
|
3
|
+
input: z.string().optional(),
|
|
4
|
+
output: z.string(),
|
|
5
|
+
includeTables: z.array(z.string()).optional(),
|
|
6
|
+
excludeTables: z.array(z.string()).optional(),
|
|
7
|
+
apiKey: z.string().min(1).optional(),
|
|
8
|
+
apiBaseUrl: z.string().url().optional(),
|
|
9
|
+
apiTimeoutMs: z.number().int().positive().optional(),
|
|
10
|
+
multiSelectMode: z.enum(['union', 'stringArray']).default('union'),
|
|
11
|
+
emitClient: z.boolean().default(false),
|
|
12
|
+
emitRegistry: z.boolean().default(true),
|
|
13
|
+
emitSdkClient: z.boolean().optional(),
|
|
14
|
+
emitReverseAliasMap: z.boolean().default(false),
|
|
15
|
+
emitMetadata: z.boolean().default(true),
|
|
16
|
+
format: z.boolean().default(true)
|
|
17
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const formatTypeScript: (source: string) => Promise<string>;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type GeneratedFileStatus = 'new' | 'changed' | 'unchanged';
|
|
2
|
+
export interface GeneratedFileDiff {
|
|
3
|
+
path: string;
|
|
4
|
+
status: GeneratedFileStatus;
|
|
5
|
+
}
|
|
6
|
+
export declare const diffGeneratedFiles: (baseDir: string, files: Record<string, string>) => Promise<GeneratedFileDiff[]>;
|
|
7
|
+
export declare const writeGeneratedFiles: (baseDir: string, files: Record<string, string>) => Promise<void>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { sortedKeys } from '../utils/sort.js';
|
|
4
|
+
const readCurrentFile = async (outputPath) => {
|
|
5
|
+
try {
|
|
6
|
+
return await readFile(outputPath, 'utf8');
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
export const diffGeneratedFiles = async (baseDir, files) => {
|
|
13
|
+
const diffs = [];
|
|
14
|
+
for (const relativePath of sortedKeys(files)) {
|
|
15
|
+
const outputPath = path.join(baseDir, relativePath);
|
|
16
|
+
const current = await readCurrentFile(outputPath);
|
|
17
|
+
const next = files[relativePath];
|
|
18
|
+
if (current === undefined) {
|
|
19
|
+
diffs.push({ path: relativePath, status: 'new' });
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
diffs.push({ path: relativePath, status: current === next ? 'unchanged' : 'changed' });
|
|
23
|
+
}
|
|
24
|
+
return diffs;
|
|
25
|
+
};
|
|
26
|
+
export const writeGeneratedFiles = async (baseDir, files) => {
|
|
27
|
+
await mkdir(baseDir, { recursive: true });
|
|
28
|
+
for (const relativePath of sortedKeys(files)) {
|
|
29
|
+
const content = files[relativePath];
|
|
30
|
+
const outputPath = path.join(baseDir, relativePath);
|
|
31
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
32
|
+
const current = await readCurrentFile(outputPath);
|
|
33
|
+
if (current !== content) {
|
|
34
|
+
await writeFile(outputPath, content, 'utf8');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { GridfoxCodegenConfig, RawTable } from './model/internalTypes.js';
|
|
2
|
+
import { type GeneratedFileDiff } from './emit/writer.js';
|
|
3
|
+
export declare const buildGeneratedFiles: (tables: RawTable[], config: GridfoxCodegenConfig) => Promise<Record<string, string>>;
|
|
4
|
+
export interface GenerationPlan {
|
|
5
|
+
files: Record<string, string>;
|
|
6
|
+
diff: GeneratedFileDiff[];
|
|
7
|
+
}
|
|
8
|
+
export declare const planGeneration: (tables: RawTable[], config: GridfoxCodegenConfig) => Promise<GenerationPlan>;
|
|
9
|
+
export declare const generateFromTables: (tables: RawTable[], config: GridfoxCodegenConfig) => Promise<Record<string, string>>;
|