turbine-orm 0.75.0 → 0.76.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.
Files changed (78) hide show
  1. package/README.md +48 -7
  2. package/dist/cjs/cli/compile-query.d.ts +22 -2
  3. package/dist/cjs/cli/compile-query.js +49 -5
  4. package/dist/cjs/cli/config.d.ts +2 -0
  5. package/dist/cjs/cli/config.js +1 -1
  6. package/dist/cjs/cli/destructive.js +78 -43
  7. package/dist/cjs/cli/index.d.ts +95 -1
  8. package/dist/cjs/cli/index.js +609 -145
  9. package/dist/cjs/cli/mcp.js +30 -1
  10. package/dist/cjs/cli/pii-predicate-guard.d.ts +25 -0
  11. package/dist/cjs/cli/pii-predicate-guard.js +72 -12
  12. package/dist/cjs/cli/rate-limit.js +38 -1
  13. package/dist/cjs/cli/studio.js +26 -5
  14. package/dist/cjs/cli/ui.d.ts +33 -0
  15. package/dist/cjs/cli/ui.js +53 -7
  16. package/dist/cjs/client.d.ts +13 -1
  17. package/dist/cjs/client.js +1 -1
  18. package/dist/cjs/errors.d.ts +12 -1
  19. package/dist/cjs/errors.js +11 -2
  20. package/dist/cjs/generate.d.ts +26 -0
  21. package/dist/cjs/generate.js +174 -27
  22. package/dist/cjs/index.d.ts +1 -1
  23. package/dist/cjs/index.js +1 -1
  24. package/dist/cjs/introspect.d.ts +17 -0
  25. package/dist/cjs/introspect.js +100 -1
  26. package/dist/cjs/mssql.d.ts +18 -0
  27. package/dist/cjs/mssql.js +20 -1
  28. package/dist/cjs/pipeline.js +44 -6
  29. package/dist/cjs/powql.js +51 -17
  30. package/dist/cjs/query/batched-loader.js +3 -3
  31. package/dist/cjs/query/builder.js +1 -1
  32. package/dist/cjs/query/relations.d.ts +5 -0
  33. package/dist/cjs/query/relations.js +141 -69
  34. package/dist/cjs/query/utils.d.ts +13 -0
  35. package/dist/cjs/query/utils.js +16 -0
  36. package/dist/cjs/serverless.d.ts +1 -1
  37. package/dist/cjs/serverless.js +1 -1
  38. package/dist/cjs/sqlite.d.ts +33 -1
  39. package/dist/cjs/sqlite.js +84 -3
  40. package/dist/cli/compile-query.d.ts +22 -2
  41. package/dist/cli/compile-query.js +50 -6
  42. package/dist/cli/config.d.ts +2 -0
  43. package/dist/cli/config.js +1 -1
  44. package/dist/cli/destructive.js +78 -43
  45. package/dist/cli/index.d.ts +95 -1
  46. package/dist/cli/index.js +604 -147
  47. package/dist/cli/mcp.js +30 -1
  48. package/dist/cli/pii-predicate-guard.d.ts +25 -0
  49. package/dist/cli/pii-predicate-guard.js +73 -13
  50. package/dist/cli/rate-limit.js +38 -1
  51. package/dist/cli/studio.js +27 -6
  52. package/dist/cli/ui.d.ts +33 -0
  53. package/dist/cli/ui.js +51 -7
  54. package/dist/client.d.ts +13 -1
  55. package/dist/client.js +1 -1
  56. package/dist/errors.d.ts +12 -1
  57. package/dist/errors.js +11 -2
  58. package/dist/generate.d.ts +26 -0
  59. package/dist/generate.js +172 -27
  60. package/dist/index.d.ts +1 -1
  61. package/dist/index.js +1 -1
  62. package/dist/introspect.d.ts +17 -0
  63. package/dist/introspect.js +98 -1
  64. package/dist/mssql.d.ts +18 -0
  65. package/dist/mssql.js +20 -1
  66. package/dist/pipeline.js +44 -6
  67. package/dist/powql.js +53 -19
  68. package/dist/query/batched-loader.js +4 -4
  69. package/dist/query/builder.js +2 -2
  70. package/dist/query/relations.d.ts +5 -0
  71. package/dist/query/relations.js +141 -70
  72. package/dist/query/utils.d.ts +13 -0
  73. package/dist/query/utils.js +15 -0
  74. package/dist/serverless.d.ts +1 -1
  75. package/dist/serverless.js +1 -1
  76. package/dist/sqlite.d.ts +33 -1
  77. package/dist/sqlite.js +85 -4
  78. package/package.json +2 -2
package/dist/cli/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * turbine init , Initialize a Turbine project
7
7
  * turbine generate | pull , Introspect database and generate TypeScript types
8
8
  * turbine migrate-from-prisma - Parse a schema.prisma and emit a Prisma->Turbine name map + report
9
- * turbine push - Apply schema-builder definitions to database (destructive ops gated)
9
+ * turbine push - Apply schema-builder definitions to database (--schema-file, destructive ops gated)
10
10
  * turbine migrate create <name> - Create a new SQL migration file (--auto | --from-diff | --recipe <name>)
11
11
  * turbine migrate up , Apply pending migrations
12
12
  * turbine migrate deploy , Apply pending migrations without prompts
@@ -30,13 +30,14 @@ import { tmpdir } from 'node:os';
30
30
  import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
31
31
  import { pathToFileURL } from 'node:url';
32
32
  import { detectPooler, poolerRefusalMessage } from '../connection-url.js';
33
- import { generate, generatePrismaMap } from '../generate.js';
33
+ import { generate, generatePrismaMap, resolveImportExtension } from '../generate.js';
34
34
  import { buildCreateIndexSql, buildCreateIndexStatements, buildDropIndexSql, collectDoctorProbeIndexNames, collectRelationProbeColumns, findMissingRelationIndexes, } from '../index-advisor.js';
35
35
  import { auditDoctorIndexes, collectStatsSnapshot, collectTableHeat, findInvalidIndexes, findRedundantIndexes, findUnusedIndexes, formatBytes, isSnapshotUsable, STATS_THRESHOLDS, scoreMissingIndex, } from '../index-stats.js';
36
36
  import { introspect } from '../introspect.js';
37
37
  import { collectDivergenceCandidateColumns, collectDivergenceOrderColumns, findPlanDivergence, PLAN_DIVERGENCE_THRESHOLDS, } from '../plan-divergence.js';
38
38
  import { applyFlipVerdicts, emptyFlipProbeResult, needsFlipProbe, probePlanFlips } from '../plan-flip-probe.js';
39
39
  import { fingerprintPrismaSchema } from '../prisma-schema-fingerprint.js';
40
+ import { closestName } from '../query/utils.js';
40
41
  import { snakeToCamel } from '../schema.js';
41
42
  import { DestructivePushRefusal, schemaDiff, schemaPush } from '../schema-sql.js';
42
43
  import { configTemplate, connectionStringHasPassword, DEFAULT_INIT_SEED_FILE, findConfigFile, loadConfigResult, looksLikeSchemaFilePath, resolveConfig, resolveSeedFile, unwrapModuleDefault, } from './config.js';
@@ -49,7 +50,7 @@ import { formatPrismaReport, summaryLines } from './prisma-report.js';
49
50
  import { DEFAULT_EXCLUDED_TABLES, resolvePrismaSchema } from './prisma-resolve.js';
50
51
  import { PrismaParseError, parsePrismaSchema, resolvePrismaDatasourceUrl, } from './prisma-schema.js';
51
52
  import { startStudio } from './studio.js';
52
- import { banner, blue, bold, box, cyan, dim, divider, elapsed, error, table as formatTable, gray, green, header, info, label, magenta, newline, red, redactUrl, Spinner, success, symbols, warn, yellow, } from './ui.js';
53
+ import { banner, blue, bold, box, cyan, dim, divider, elapsed, error, errorBanner, errorLine, table as formatTable, gray, green, header, info, label, magenta, newline, red, redactUrl, Spinner, success, symbols, warn, yellow, } from './ui.js';
53
54
  /**
54
55
  * Fail an argument-parse error the way every other CLI failure looks: banner,
55
56
  * red error line, then indented hint lines. The flag validators used to call
@@ -58,11 +59,227 @@ import { banner, blue, bold, box, cyan, dim, divider, elapsed, error, table as f
58
59
  * rather than "you forgot the recipe name".
59
60
  */
60
61
  function failArg(message, ...hints) {
61
- banner();
62
+ errorBanner();
62
63
  error(message);
63
64
  for (const hint of hints)
64
- console.log(` ${dim(hint)}`);
65
- newline();
65
+ errorLine(dim(hint));
66
+ errorLine();
67
+ process.exit(1);
68
+ }
69
+ // ---------------------------------------------------------------------------
70
+ // The flag surface, as data
71
+ // ---------------------------------------------------------------------------
72
+ /**
73
+ * Long flags accepted by EVERY command.
74
+ *
75
+ * Deliberately generous: these are connection/output overrides and diagnostics,
76
+ * so accepting one where it happens to be inert costs nothing. Nothing that
77
+ * changes what a command DOES to your data belongs here, which is why
78
+ * `--dry-run`, `--allow-destructive`, `--fix` and `--yes` are all per-command
79
+ * below.
80
+ */
81
+ const GLOBAL_FLAGS = [
82
+ '--url',
83
+ '--out',
84
+ '--schema',
85
+ '--include',
86
+ '--exclude',
87
+ '--verbose',
88
+ '--force',
89
+ '--help',
90
+ ];
91
+ /**
92
+ * The flags each command accepts, BEYOND {@link GLOBAL_FLAGS}. Long spellings
93
+ * only; short forms come from {@link FLAG_ALIAS}.
94
+ *
95
+ * This table is the CLI's flag surface as data, for the same reason
96
+ * `query/option-surface.ts` is the query surface as data: an unknown flag used
97
+ * to be DISCARDED by the parser's `default:` branch, so `push --dry-runn`
98
+ * executed for real, `migrate create x --autoo` wrote an empty template and
99
+ * reported success, and `doctor --fixx` wrote nothing and exited 0. A typo in a
100
+ * safety flag silently disarmed the safety. Every entry here is cross-checked
101
+ * against the parser's own `case` labels by
102
+ * `src/test/cli-unknown-flags.test.ts`, so adding a flag to one and not the
103
+ * other fails the build rather than stranding it.
104
+ */
105
+ const COMMAND_FLAGS = {
106
+ init: ['--yes', '--schema-file', '--skip-schema', '--with-schema', '--skip-seed', '--skip-push', '--skip-generate'],
107
+ generate: [
108
+ '--zod',
109
+ '--include-views',
110
+ '--no-timestamp',
111
+ '--import-ext',
112
+ '--keep-column-names',
113
+ '--legacy-to-many-uniques',
114
+ '--allow-empty',
115
+ ],
116
+ 'migrate-from-prisma': ['--no-db', '--if-db', '--allow-partial', '--no-timestamp'],
117
+ push: ['--schema-file', '--dry-run', '--allow-destructive'],
118
+ migrate: [
119
+ '--schema-file',
120
+ '--auto',
121
+ '--from-diff',
122
+ '--recipe',
123
+ '--step',
124
+ '--dry-run',
125
+ '--allow-drift',
126
+ '--allow-destructive',
127
+ ],
128
+ seed: [],
129
+ status: [],
130
+ doctor: [
131
+ '--fix',
132
+ '--json',
133
+ '--no-concurrently',
134
+ '--unused',
135
+ '--audit',
136
+ '--min-scans',
137
+ '--metrics-url',
138
+ '--no-plan-divergence',
139
+ '--allow-pooler',
140
+ ],
141
+ studio: ['--port', '--host', '--no-open', '--allow-remote', '--write', '--show-pii', '--demo'],
142
+ mcp: [],
143
+ observe: ['--port', '--host', '--no-open', '--allow-remote', '--metrics-url'],
144
+ skill: ['--print', '--agents', '--dir'],
145
+ help: [],
146
+ version: [],
147
+ };
148
+ /** Command spellings that dispatch to another command's handler. */
149
+ const COMMAND_ALIASES = {
150
+ gen: 'generate',
151
+ g: 'generate',
152
+ pull: 'generate',
153
+ migration: 'migrate',
154
+ m: 'migrate',
155
+ s: 'seed',
156
+ info: 'status',
157
+ };
158
+ /**
159
+ * Long flag to its alternative spelling, for MATCHING and for display.
160
+ *
161
+ * Kept out of {@link COMMAND_FLAGS} so a command lists each flag once: the
162
+ * "valid flags" block a rejection prints reads as one flag per idea, with its
163
+ * short form attached, rather than as a list twice as long.
164
+ */
165
+ const FLAG_ALIAS = {
166
+ '--url': '-u',
167
+ '--out': '-o',
168
+ '--schema': '-s',
169
+ '--step': '-n',
170
+ '--yes': '-y',
171
+ '--force': '-f',
172
+ '--verbose': '-v',
173
+ '--help': '-h',
174
+ '--import-ext': '--import-extension',
175
+ };
176
+ /**
177
+ * The canonical name of `command`, or undefined when nothing dispatches it.
178
+ *
179
+ * @internal exported for tests.
180
+ */
181
+ export function canonicalCommand(command) {
182
+ const resolved = COMMAND_ALIASES[command] ?? command;
183
+ return resolved in COMMAND_FLAGS ? resolved : undefined;
184
+ }
185
+ /** Every command name a user could reasonably have meant, canonical spellings only. */
186
+ export function knownCommands() {
187
+ return Object.keys(COMMAND_FLAGS);
188
+ }
189
+ /**
190
+ * The long flags `command` accepts (its own, then the global ones), or
191
+ * undefined when the command itself is unrecognized.
192
+ *
193
+ * An unknown command returns undefined rather than an empty list on purpose:
194
+ * `turbine genrate --url ...` should be told the COMMAND is misspelled, not
195
+ * handed a flag error for a flag that is perfectly valid on the command it
196
+ * meant.
197
+ *
198
+ * @internal exported for tests.
199
+ */
200
+ export function flagsForCommand(command) {
201
+ const canonical = canonicalCommand(command);
202
+ if (canonical === undefined)
203
+ return undefined;
204
+ return { own: COMMAND_FLAGS[canonical] ?? [], global: GLOBAL_FLAGS };
205
+ }
206
+ /** Every token (long + alias) `command` accepts, or undefined for an unknown command. */
207
+ function acceptedFlagTokens(command) {
208
+ const flags = flagsForCommand(command);
209
+ if (!flags)
210
+ return undefined;
211
+ const tokens = new Set();
212
+ for (const flag of [...flags.own, ...flags.global]) {
213
+ tokens.add(flag);
214
+ const alias = FLAG_ALIAS[flag];
215
+ if (alias)
216
+ tokens.add(alias);
217
+ }
218
+ return tokens;
219
+ }
220
+ /**
221
+ * Every flag token any command accepts, long spellings and aliases alike.
222
+ *
223
+ * Exists for `src/test/cli-arg-safety.test.ts`, which cross-checks it against
224
+ * the `case` labels in {@link parseArgs} in both directions. A flag in the
225
+ * parser but in no command's list is unreachable (the validator rejects it
226
+ * before the case runs); a flag in a list with no case is accepted and then
227
+ * silently ignored, which is the bug this whole surface exists to end.
228
+ *
229
+ * @internal exported for tests.
230
+ */
231
+ export function allFlagTokens() {
232
+ const tokens = new Set();
233
+ for (const command of knownCommands()) {
234
+ for (const token of acceptedFlagTokens(command) ?? [])
235
+ tokens.add(token);
236
+ }
237
+ return tokens;
238
+ }
239
+ /** `--url` rendered for the help block: `--url, -u`. */
240
+ function displayFlag(flag) {
241
+ const alias = FLAG_ALIAS[flag];
242
+ return alias ? `${flag}, ${alias}` : flag;
243
+ }
244
+ /**
245
+ * Reject the unknown flags on a recognized command, naming the closest real one.
246
+ *
247
+ * `closestName` rather than `suggestKey`: the second pass `suggestKey` adds is a
248
+ * camelCase-word-subsequence match, which does nothing for kebab-case flags.
249
+ * Long spellings ONLY are offered as candidates, because `nameCloseness` scores
250
+ * substring containment above edit distance and every short flag is a substring
251
+ * of something: `--no-opn` contains `-o`, which would otherwise outrank the
252
+ * `--no-open` the user obviously meant.
253
+ */
254
+ function failUnknownFlags(command, unknown) {
255
+ const flags = flagsForCommand(command);
256
+ const canonical = canonicalCommand(command) ?? command;
257
+ errorBanner();
258
+ const plural = unknown.length > 1 ? 's' : '';
259
+ error(`Unknown flag${plural} for ${cyan(`turbine ${canonical}`)}: ${unknown.map((f) => bold(f)).join(' ')}`);
260
+ errorLine();
261
+ if (flags) {
262
+ for (const flag of unknown) {
263
+ const suggestion = closestName(flag, [...flags.own, ...flags.global]);
264
+ if (!suggestion)
265
+ continue;
266
+ // With one bad flag the headline already names it; with several, each
267
+ // suggestion has to say which flag it is about.
268
+ const subject = unknown.length > 1 ? `${bold(flag)} ${symbols.arrow} ` : '';
269
+ errorLine(`${subject}${dim('Did you mean')} ${cyan(displayFlag(suggestion))}${dim('?')}`);
270
+ }
271
+ if (flags.own.length > 0) {
272
+ errorLine();
273
+ errorLine(dim(`Flags for ${canonical}:`));
274
+ errorLine(` ${flags.own.map(displayFlag).join(' ')}`);
275
+ }
276
+ errorLine();
277
+ errorLine(dim('Flags for every command:'));
278
+ errorLine(` ${flags.global.map(displayFlag).join(' ')}`);
279
+ }
280
+ errorLine();
281
+ errorLine(`${dim('Run')} ${cyan(`npx turbine ${canonical} --help`)} ${dim('for the full list.')}`);
282
+ errorLine();
66
283
  process.exit(1);
67
284
  }
68
285
  export function parseArgs(argv = process.argv.slice(2)) {
@@ -77,9 +294,20 @@ export function parseArgs(argv = process.argv.slice(2)) {
77
294
  result.subcommand = args[i];
78
295
  i++;
79
296
  }
297
+ // The flags this command accepts. `undefined` means the COMMAND is unknown,
298
+ // in which case nothing here validates flags: main() reports the misspelled
299
+ // command instead, which is the actual problem.
300
+ const accepted = acceptedFlagTokens(result.command);
301
+ const unknownFlags = [];
80
302
  for (; i < args.length; i++) {
81
303
  const arg = args[i];
82
304
  const next = args[i + 1];
305
+ // Checked BEFORE the switch, and on the same cursor, so a flag's VALUE
306
+ // (`--step -1`, `--host -x`) is consumed by its own case and never reaches
307
+ // this test. A bare `-`-prefixed token that is not a flag reaches it and is
308
+ // rejected: no command takes a negative-number positional.
309
+ if (accepted && arg.startsWith('-') && !accepted.has(arg))
310
+ unknownFlags.push(arg);
83
311
  switch (arg) {
84
312
  case '--url':
85
313
  case '-u':
@@ -96,6 +324,10 @@ export function parseArgs(argv = process.argv.slice(2)) {
96
324
  result.schema = next;
97
325
  i++;
98
326
  break;
327
+ case '--schema-file':
328
+ result.schemaFile = next;
329
+ i++;
330
+ break;
99
331
  case '--include':
100
332
  result.include = next?.split(',');
101
333
  i++;
@@ -267,6 +499,10 @@ export function parseArgs(argv = process.argv.slice(2)) {
267
499
  break;
268
500
  }
269
501
  }
502
+ // Reported once, after the whole line is parsed, so two typos on one command
503
+ // are two lines of one error rather than two runs of the CLI.
504
+ if (unknownFlags.length > 0)
505
+ failUnknownFlags(result.command, unknownFlags);
270
506
  return result;
271
507
  }
272
508
  // ---------------------------------------------------------------------------
@@ -277,35 +513,35 @@ export function parseArgs(argv = process.argv.slice(2)) {
277
513
  * Called when we know we need to load a `.ts` file but the loader isn't available.
278
514
  */
279
515
  function failMissingTsLoader(filePath, reason) {
280
- newline();
516
+ errorLine();
281
517
  error(`Cannot load TypeScript file: ${filePath}`);
282
- newline();
518
+ errorLine();
283
519
  if (reason === 'unsupported') {
284
- console.log(` ${dim('Your Node.js version does not support')} ${cyan('module.register()')}.`);
285
- console.log(` ${dim('Upgrade to Node.js')} ${cyan('20.6+')} ${dim('or use a')} ${cyan('.js')} ${dim('/')} ${cyan('.mjs')} ${dim('config file.')}`);
520
+ errorLine(`${dim('Your Node.js version does not support')} ${cyan('module.register()')}.`);
521
+ errorLine(`${dim('Upgrade to Node.js')} ${cyan('20.6+')} ${dim('or use a')} ${cyan('.js')} ${dim('/')} ${cyan('.mjs')} ${dim('config file.')}`);
286
522
  }
287
523
  else if (reason === 'failed') {
288
524
  // tsx IS installed but registering its loader threw. Report the real
289
525
  // cause, telling the user to install tsx here would be a misdiagnosis.
290
- console.log(` ${dim('tsx is installed, but registering its TypeScript loader failed:')}`);
291
- newline();
292
- console.log(` ${getTsLoaderError() ?? '(unknown error)'}`);
293
- newline();
294
- console.log(` ${dim('Try upgrading tsx:')} ${cyan('npm install --save-dev tsx@latest')}${dim(', or rename your file to')} ${cyan('.mjs')}.`);
526
+ errorLine(`${dim('tsx is installed, but registering its TypeScript loader failed:')}`);
527
+ errorLine();
528
+ errorLine(` ${getTsLoaderError() ?? '(unknown error)'}`);
529
+ errorLine();
530
+ errorLine(`${dim('Try upgrading tsx:')} ${cyan('npm install --save-dev tsx@latest')}${dim(', or rename your file to')} ${cyan('.mjs')}.`);
295
531
  }
296
532
  else {
297
- console.log(` ${dim('Loading .ts config / schema files requires')} ${cyan('tsx')} ${dim('to be installed.')}`);
298
- newline();
299
- console.log(` ${dim('Install it as a dev dependency:')}`);
300
- console.log(` ${cyan('npm install --save-dev tsx')}`);
301
- console.log(` ${dim('or')}`);
302
- console.log(` ${cyan('pnpm add -D tsx')}`);
303
- console.log(` ${dim('or')}`);
304
- console.log(` ${cyan('yarn add -D tsx')}`);
305
- newline();
306
- console.log(` ${dim('Alternatively, rename your file to')} ${cyan('.js')} ${dim('or')} ${cyan('.mjs')}.`);
307
- }
308
- newline();
533
+ errorLine(`${dim('Loading .ts config / schema files requires')} ${cyan('tsx')} ${dim('to be installed.')}`);
534
+ errorLine();
535
+ errorLine(`${dim('Install it as a dev dependency:')}`);
536
+ errorLine(` ${cyan('npm install --save-dev tsx')}`);
537
+ errorLine(` ${dim('or')}`);
538
+ errorLine(` ${cyan('pnpm add -D tsx')}`);
539
+ errorLine(` ${dim('or')}`);
540
+ errorLine(` ${cyan('yarn add -D tsx')}`);
541
+ errorLine();
542
+ errorLine(`${dim('Alternatively, rename your file to')} ${cyan('.js')} ${dim('or')} ${cyan('.mjs')}.`);
543
+ }
544
+ errorLine();
309
545
  process.exit(1);
310
546
  }
311
547
  // ---------------------------------------------------------------------------
@@ -354,20 +590,20 @@ export async function bootstrapCliConfig(args, overrides) {
354
590
  function requireUrl(config, options = {}) {
355
591
  if (!config.url) {
356
592
  error('No database URL provided.');
357
- newline();
358
- console.log(` ${dim('Set it in one of these ways:')}`);
359
- console.log(` ${dim('1.')} Add ${cyan('url')} to ${cyan('turbine.config.ts')}`);
593
+ errorLine();
594
+ errorLine(`${dim('Set it in one of these ways:')}`);
595
+ errorLine(` ${dim('1.')} Add ${cyan('url')} to ${cyan('turbine.config.ts')}`);
360
596
  // .env auto-load needs Node 20.12+ (process.loadEnvFile); be honest below it.
361
597
  const envFileNote = typeof process.loadEnvFile === 'function' ? '(auto-loaded)' : '(needs Node 20.12+ to auto-load)';
362
- console.log(` ${dim('2.')} Set ${cyan('DATABASE_URL')} in your environment or a ${cyan('.env')} file ${dim(envFileNote)}`);
363
- console.log(` ${dim('3.')} Pass ${cyan('--url')} flag`);
598
+ errorLine(` ${dim('2.')} Set ${cyan('DATABASE_URL')} in your environment or a ${cyan('.env')} file ${dim(envFileNote)}`);
599
+ errorLine(` ${dim('3.')} Pass ${cyan('--url')} flag`);
364
600
  const vars = options.datasourceVars ?? [];
365
601
  if (vars.length > 0) {
366
602
  const list = vars.map((v) => cyan(v)).join(', ');
367
603
  const plural = vars.length > 1 ? 'these variables' : 'this variable';
368
- console.log(` ${dim('4.')} Set ${list} ${dim(`(${plural}, declared by your schema.prisma datasource, ${vars.length > 1 ? 'are' : 'is'} unset)`)}`);
604
+ errorLine(` ${dim('4.')} Set ${list} ${dim(`(${plural}, declared by your schema.prisma datasource, ${vars.length > 1 ? 'are' : 'is'} unset)`)}`);
369
605
  }
370
- newline();
606
+ errorLine();
371
607
  process.exit(1);
372
608
  }
373
609
  return config.url;
@@ -376,7 +612,7 @@ async function loadSchemaFile(schemaFile) {
376
612
  const absPath = resolve(schemaFile);
377
613
  if (!existsSync(absPath)) {
378
614
  error(`Schema file not found: ${schemaFile}`);
379
- console.log(` ${dim('Create one with:')} ${cyan('turbine init')}`);
615
+ errorLine(`${dim('Create one with:')} ${cyan('turbine init')}`);
380
616
  process.exit(1);
381
617
  }
382
618
  // If this is a TypeScript file, ensure the tsx ESM loader is registered
@@ -405,11 +641,11 @@ async function loadSchemaFile(schemaFile) {
405
641
  catch (err) {
406
642
  error(`Failed to load schema file: ${schemaFile}`);
407
643
  if (err instanceof Error) {
408
- console.log(` ${dim(err.message)}`);
644
+ errorLine(`${dim(err.message)}`);
409
645
  // If the error is the classic ERR_UNKNOWN_FILE_EXTENSION, give a hint.
410
646
  if (err.message.includes('ERR_UNKNOWN_FILE_EXTENSION') || err.message.includes('Unknown file extension')) {
411
- newline();
412
- console.log(` ${dim('Hint: install')} ${cyan('tsx')} ${dim('to load .ts files:')} ${cyan('npm install --save-dev tsx')}`);
647
+ errorLine();
648
+ errorLine(`${dim('Hint: install')} ${cyan('tsx')} ${dim('to load .ts files:')} ${cyan('npm install --save-dev tsx')}`);
413
649
  }
414
650
  printCjsHintIfApplicable(err);
415
651
  }
@@ -428,9 +664,9 @@ function printCjsHintIfApplicable(err) {
428
664
  if (msg.includes('ERR_REQUIRE_ESM') ||
429
665
  msg.includes('require() of ES Module') ||
430
666
  msg.includes('Cannot require() ES Module')) {
431
- newline();
432
- console.log(` ${dim('Hint: add')} ${cyan('"type": "module"')} ${dim('to your')} ${cyan('package.json')}${dim('.')}`);
433
- console.log(` ${dim('Turbine is an ESM package; without it, Node/tsx tries to')} ${cyan('require()')} ${dim('it and fails.')}`);
667
+ errorLine();
668
+ errorLine(`${dim('Hint: add')} ${cyan('"type": "module"')} ${dim('to your')} ${cyan('package.json')}${dim('.')}`);
669
+ errorLine(`${dim('Turbine is an ESM package; without it, Node/tsx tries to')} ${cyan('require()')} ${dim('it and fails.')}`);
434
670
  }
435
671
  }
436
672
  /**
@@ -732,7 +968,54 @@ export default defineSeed(async (db) => {
732
968
  console.log('Done!');
733
969
  });
734
970
  `;
735
- const INIT_SCHEMA_TEMPLATE = `/**
971
+ /**
972
+ * The starter schema, with two real tables and a foreign key between them.
973
+ *
974
+ * Scaffolded ONLY when the target database has no tables of its own (see
975
+ * {@link initSchemaTemplate}). An all-commented-out schema next to an empty
976
+ * database is a dead end: `push` reports "Database already in sync",
977
+ * `generate` refuses to emit an empty client, and the next-steps text ends up
978
+ * describing a `db.users` that does not exist. Two tables rather than one
979
+ * because the relation is what the very next query in the quickstart reads.
980
+ */
981
+ const INIT_SCHEMA_EXAMPLE = `/**
982
+ * Turbine schema definition
983
+ *
984
+ * Define your database schema in TypeScript.
985
+ * Use \`npx turbine push\` to sync it to your database, then
986
+ * \`npx turbine generate\` to emit the typed client.
987
+ *
988
+ * This starter is a working example. Rename it, edit it, or replace it
989
+ * entirely: nothing here is special to Turbine.
990
+ *
991
+ * @see https://turbineorm.dev
992
+ */
993
+
994
+ import { defineSchema } from 'turbine-orm';
995
+
996
+ export default defineSchema({
997
+ users: {
998
+ id: { type: 'serial', primaryKey: true },
999
+ email: { type: 'text', notNull: true, unique: true },
1000
+ name: { type: 'text', notNull: true },
1001
+ created_at: { type: 'timestamptz', notNull: true, default: 'NOW()' },
1002
+ },
1003
+ posts: {
1004
+ id: { type: 'serial', primaryKey: true },
1005
+ user_id: { type: 'integer', notNull: true, references: 'users.id' },
1006
+ title: { type: 'text', notNull: true },
1007
+ published: { type: 'boolean', notNull: true, default: 'false' },
1008
+ created_at: { type: 'timestamptz', notNull: true, default: 'NOW()' },
1009
+ },
1010
+ });
1011
+ `;
1012
+ /**
1013
+ * The starter schema with nothing defined, for the one case where a real
1014
+ * example could collide: `init --with-schema` beside a database that already
1015
+ * has tables. There, an example `users` table is a schema diff against
1016
+ * somebody's real data, so the file stays a comment.
1017
+ */
1018
+ const INIT_SCHEMA_PLACEHOLDER = `/**
736
1019
  * Turbine schema definition
737
1020
  *
738
1021
  * Define your database schema in TypeScript.
@@ -744,15 +1027,27 @@ const INIT_SCHEMA_TEMPLATE = `/**
744
1027
  import { defineSchema } from 'turbine-orm';
745
1028
 
746
1029
  export default defineSchema({
747
- // Example:
1030
+ // Your database already has tables, so this file starts empty on purpose:
1031
+ // \`turbine push\` applies exactly what is declared here. Describe the tables
1032
+ // you want Turbine to own, or run \`npx turbine generate\` to work from the
1033
+ // database as it already is.
1034
+ //
748
1035
  // users: {
749
1036
  // id: { type: 'serial', primaryKey: true },
750
1037
  // email: { type: 'text', notNull: true, unique: true },
751
1038
  // name: { type: 'text', notNull: true },
752
- // created_at: { type: 'timestamp', default: 'NOW()' },
1039
+ // created_at: { type: 'timestamptz', notNull: true, default: 'NOW()' },
753
1040
  // },
754
1041
  });
755
1042
  `;
1043
+ /**
1044
+ * Which starter schema to scaffold.
1045
+ *
1046
+ * @internal exported for tests.
1047
+ */
1048
+ export function initSchemaTemplate(dbHasTables) {
1049
+ return dbHasTables ? INIT_SCHEMA_PLACEHOLDER : INIT_SCHEMA_EXAMPLE;
1050
+ }
756
1051
  /**
757
1052
  * Does this `.gitignore` text already ignore `.env`?
758
1053
  *
@@ -905,12 +1200,15 @@ function ensureInitScaffoldDirs(config) {
905
1200
  }
906
1201
  }
907
1202
  }
908
- function writeInitSchemaTemplate(config) {
1203
+ function writeInitSchemaTemplate(config, dbHasTables) {
909
1204
  const schemaDir = dirname(config.schemaFile);
910
1205
  if (schemaDir && !existsSync(schemaDir))
911
1206
  mkdirSync(schemaDir, { recursive: true });
912
- writeFileSync(config.schemaFile, INIT_SCHEMA_TEMPLATE, 'utf-8');
1207
+ writeFileSync(config.schemaFile, initSchemaTemplate(dbHasTables), 'utf-8');
913
1208
  success(`Created ${cyan(config.schemaFile)}`);
1209
+ if (!dbHasTables) {
1210
+ console.log(` ${dim('It defines a')} ${cyan('users')} ${dim('and a')} ${cyan('posts')} ${dim('table to start from.')}`);
1211
+ }
914
1212
  }
915
1213
  function writeInitSeedTemplate(seedFilePath) {
916
1214
  const seedDir = dirname(seedFilePath);
@@ -960,6 +1258,28 @@ async function runInitPush(config, url) {
960
1258
  success(`Applied ${bold(String(result.statementsExecuted))} statement(s)`);
961
1259
  }
962
1260
  }
1261
+ /**
1262
+ * The exact import line for the generated client, extension included.
1263
+ *
1264
+ * `import { turbine } from './generated/turbine'` is a hard TypeScript error
1265
+ * (TS2834) under `moduleResolution: NodeNext`, which is what this package ships
1266
+ * and what its own tsconfig uses: a relative import needs an explicit file
1267
+ * extension, and NodeNext does no directory-index resolution either. The
1268
+ * generator has always appended the extension to its OWN sibling imports; the
1269
+ * line printed at the reader was the one place it never reached.
1270
+ *
1271
+ * The extension comes from {@link resolveImportExtension}, the same resolver the
1272
+ * generator runs, so the printed line matches the files just written rather
1273
+ * than a second guess about the consumer's tsconfig. Under bundler resolution
1274
+ * it resolves to `''` and the directory form is correct as-is.
1275
+ *
1276
+ * @internal exported for tests.
1277
+ */
1278
+ export function generatedClientImport(config) {
1279
+ const dir = `./${config.out.replace(/^\.\//, '').replace(/\/+$/, '')}`;
1280
+ const { ext } = resolveImportExtension(config.out, config.importExtension);
1281
+ return `import { turbine } from '${ext === '' ? dir : `${dir}/index${ext}`}';`;
1282
+ }
963
1283
  /** Introspect the database and generate the typed client. */
964
1284
  async function runInitGenerate(config, url) {
965
1285
  const spinner = new Spinner('Introspecting database').start();
@@ -971,16 +1291,22 @@ async function runInitGenerate(config, url) {
971
1291
  exclude: config.exclude.length ? config.exclude : undefined,
972
1292
  relationNames: config.relationNames,
973
1293
  });
974
- spinner.succeed(`Found ${bold(String(Object.keys(schema.tables).length))} tables`);
1294
+ const tableNames = Object.keys(schema.tables);
1295
+ spinner.succeed(`Found ${bold(String(tableNames.length))} tables`);
975
1296
  const genSpinner = new Spinner('Generating TypeScript client').start();
976
1297
  const result = generate({ schema, outDir: config.out, connectionString: url });
977
1298
  genSpinner.succeed(`Generated ${bold(String(result.files.length))} files to ${cyan(`${config.out}/`)}`);
1299
+ // The accessor name the generator emits is snakeToCamel of the table name
1300
+ // (generate.ts, generateIndex), so this is the spelling that autocompletes.
1301
+ const first = tableNames[0];
1302
+ return { tableCount: tableNames.length, sampleAccessor: first ? snakeToCamel(first) : undefined };
978
1303
  }
979
1304
  catch (err) {
980
1305
  spinner.fail('Could not generate client');
981
1306
  if (err instanceof Error)
982
- console.log(` ${dim(redactUrl(err.message))}`);
1307
+ errorLine(dim(redactUrl(err.message)));
983
1308
  info(`Run generation later with: ${cyan('npx turbine generate')}`);
1309
+ return { tableCount: null };
984
1310
  }
985
1311
  }
986
1312
  /** Run the seed file. */
@@ -1204,6 +1530,10 @@ async function cmdInit(args, config) {
1204
1530
  newline();
1205
1531
  // TypeScript files this run actually created: they drive the tsx heads-up below.
1206
1532
  const tsFilesWritten = [];
1533
+ // What the generate step produced, if it ran. The next-steps text is derived
1534
+ // from this rather than assumed, so it cannot promise a `db.users` the
1535
+ // generated client does not have.
1536
+ let generated = { tableCount: null };
1207
1537
  // Execute the plan in order. `run` proceeds; `prompt` asks; `skip` reports.
1208
1538
  for (const step of plan) {
1209
1539
  if (step.action === 'skip') {
@@ -1231,7 +1561,7 @@ async function cmdInit(args, config) {
1231
1561
  break;
1232
1562
  }
1233
1563
  case 'schema':
1234
- writeInitSchemaTemplate(config);
1564
+ writeInitSchemaTemplate(config, state.dbHasTables === true);
1235
1565
  if (needsTsLoader(config.schemaFile))
1236
1566
  tsFilesWritten.push(config.schemaFile);
1237
1567
  break;
@@ -1244,7 +1574,7 @@ async function cmdInit(args, config) {
1244
1574
  await runInitPush(config, url);
1245
1575
  break;
1246
1576
  case 'generate':
1247
- await runInitGenerate(config, url);
1577
+ generated = await runInitGenerate(config, url);
1248
1578
  break;
1249
1579
  case 'seed-run':
1250
1580
  await runInitSeed(config);
@@ -1283,20 +1613,84 @@ async function cmdInit(args, config) {
1283
1613
  if (!tsxMissing && !canResolveTsx()) {
1284
1614
  console.log(` ${dim('Note: the TypeScript config requires')} ${cyan('tsx')}: ${cyan(tsxInstallCommand(detectPackageManager()))}`);
1285
1615
  }
1616
+ newline();
1617
+ console.log(` ${dim('3.')} Create migrations: ${cyan('npx turbine migrate create <name>')}`);
1618
+ console.log(` ${dim('4.')} Run migrations: ${cyan('npx turbine migrate up')}`);
1619
+ console.log(` ${dim('5.')} Seed your database: ${cyan('npx turbine seed')}`);
1620
+ }
1621
+ else if (generated.tableCount === null || generated.tableCount === 0) {
1622
+ // There is no typed client to import: either generate did not run (push /
1623
+ // generate declined or skipped), or it ran against a database with no
1624
+ // tables and emitted a client with no accessors. Printing
1625
+ // `await db.users.findMany()` here is the single most misleading line the
1626
+ // CLI produces, because the reader has no way to know it cannot work.
1627
+ console.log(` ${dim('1.')} Describe your tables in ${cyan(config.schemaFile)}`);
1628
+ console.log(` ${dim('2.')} Create them: ${cyan('npx turbine push')}`);
1629
+ console.log(` ${dim('3.')} Emit the client: ${cyan('npx turbine generate')}`);
1630
+ newline();
1631
+ console.log(` ${dim('Until a table exists, the generated client has no table accessors, so')}`);
1632
+ console.log(` ${dim('there is nothing to import yet. Working from a database you already')}`);
1633
+ console.log(` ${dim('have? Skip step 1 and run')} ${cyan('npx turbine generate')} ${dim('on its own.')}`);
1634
+ newline();
1635
+ console.log(` ${dim('Nothing to connect to yet?')} ${cyan('npx turbine studio --demo')} ${dim('needs no database.')}`);
1286
1636
  }
1287
1637
  else {
1638
+ const accessor = generated.sampleAccessor ?? 'users';
1288
1639
  console.log(` ${dim('1.')} Import the generated client:`);
1289
- console.log(` ${cyan(`import { turbine } from './${config.out.replace('./', '')}';`)}`);
1640
+ console.log(` ${cyan(generatedClientImport(config))}`);
1290
1641
  newline();
1291
1642
  console.log(` ${dim('2.')} Create a connection and query:`);
1292
1643
  console.log(` ${dim('const db = turbine();')}`);
1293
- console.log(` ${dim('const users = await db.users.findMany();')}`);
1644
+ console.log(` ${dim(`const rows = await db.${accessor}.findMany();`)}`);
1645
+ newline();
1646
+ console.log(` ${dim('3.')} Create migrations: ${cyan('npx turbine migrate create <name>')}`);
1647
+ console.log(` ${dim('4.')} Run migrations: ${cyan('npx turbine migrate up')}`);
1648
+ console.log(` ${dim('5.')} Seed your database: ${cyan('npx turbine seed')}`);
1294
1649
  }
1295
1650
  newline();
1296
- console.log(` ${dim('3.')} Create migrations: ${cyan('npx turbine migrate create <name>')}`);
1297
- console.log(` ${dim('4.')} Run migrations: ${cyan('npx turbine migrate up')}`);
1298
- console.log(` ${dim('5.')} Seed your database: ${cyan('npx turbine seed')}`);
1299
- newline();
1651
+ }
1652
+ // ---------------------------------------------------------------------------
1653
+ // `--schema` is the namespace, `--schema-file` is the file
1654
+ // ---------------------------------------------------------------------------
1655
+ /**
1656
+ * Refuse a `schema` that is plainly a FILE PATH, on every command that reads it
1657
+ * as a Postgres namespace.
1658
+ *
1659
+ * `--schema` / `-s` sets the namespace to introspect (default `public`). The
1660
+ * defineSchema() file is a different idea entirely, and mistaking the two is
1661
+ * silent on every command that used to accept it: `generate` introspects
1662
+ * `WHERE table_schema = './turbine/schema.ts'` and matches nothing, and `push`
1663
+ * reads `config.schemaFile` instead and reports
1664
+ * "Schema file not found: ./turbine/schema.ts", naming a path the reader never
1665
+ * typed while their schema file sits in the directory they ran it from.
1666
+ *
1667
+ * The check was written for `generate` and wired into `generate` alone, which is
1668
+ * how `push`, the command whose flag name the mistake is actually about, kept
1669
+ * the bad error. It is one function called from every `--schema` command now,
1670
+ * for the same reason `resolveColumnName` is one function: two copies of a rule
1671
+ * is how two commands come to disagree about whether an argument is valid.
1672
+ *
1673
+ * @internal exported for tests.
1674
+ */
1675
+ export function refuseSchemaFilePath(config, options = {}) {
1676
+ if (!looksLikeSchemaFilePath(config.schema))
1677
+ return;
1678
+ error(`The ${cyan('--schema')} value looks like a file path: ${cyan(config.schema)}`);
1679
+ errorLine();
1680
+ errorLine(`${dim('Did you mean')} ${cyan(`--schema-file ${config.schema}`)}${dim('?')}`);
1681
+ errorLine();
1682
+ errorLine(`${dim('--schema is the Postgres schema NAME to read')} ${dim('(default:')} ${cyan('public')}${dim(').')}`);
1683
+ errorLine(`${dim('--schema-file is the path to your')} ${cyan('defineSchema()')} ${dim('file')} ${dim(`(default: ${config.schemaFile}).`)}`);
1684
+ errorLine();
1685
+ errorLine(`${dim('Or set it once in')} ${cyan('turbine.config.ts')}${dim(':')}`);
1686
+ errorLine(` ${green('schema:')} ${cyan("'public'")}${dim(", // or omit, reads the 'public' schema")}`);
1687
+ errorLine(` ${green('schemaFile:')} ${cyan(`'${config.schema}'`)}${dim(', // your defineSchema() file (used by `turbine push`)')}`);
1688
+ if (options.escapeHatch) {
1689
+ errorLine();
1690
+ errorLine(`${dim('Re-run with')} ${cyan(options.escapeHatch)} ${dim('to use this literal schema name anyway.')}`);
1691
+ }
1692
+ errorLine();
1693
+ process.exit(1);
1300
1694
  }
1301
1695
  // ---------------------------------------------------------------------------
1302
1696
  // Command: generate (pull)
@@ -1305,24 +1699,11 @@ async function cmdGenerate(args, config) {
1305
1699
  banner();
1306
1700
  const url = requireUrl(config);
1307
1701
  const startTime = performance.now();
1308
- // Guard: `schema` is the Postgres NAMESPACE to introspect (default `public`),
1309
- // NOT the path to your schema-builder file, that goes in `schemaFile`. If the
1310
- // configured `schema` looks like a file path, introspection would silently
1311
- // match zero tables and emit an empty client. Fail loudly instead.
1312
- if (!args.allowEmpty && looksLikeSchemaFilePath(config.schema)) {
1313
- error(`The "schema" option looks like a file path: ${cyan(config.schema)}`);
1314
- newline();
1315
- console.log(` ${dim('"schema" is the Postgres schema NAME to introspect')} ${dim('(default:')} ${cyan('public')}${dim(').')}`);
1316
- console.log(` ${dim('The path to your defineSchema() file belongs in')} ${cyan('schemaFile')}${dim('.')}`);
1317
- newline();
1318
- console.log(` ${dim('Fix your')} ${cyan('turbine.config.ts')}${dim(':')}`);
1319
- console.log(` ${green('schema:')} ${cyan("'public'")}${dim(", // or omit, introspects the 'public' schema")}`);
1320
- console.log(` ${green('schemaFile:')} ${cyan(`'${config.schema}'`)}${dim(', // your defineSchema() file (used by `turbine push`)')}`);
1321
- newline();
1322
- console.log(` ${dim('Re-run with')} ${cyan('--allow-empty')} ${dim('to introspect this literal schema name anyway.')}`);
1323
- newline();
1324
- process.exit(1);
1325
- }
1702
+ // `--allow-empty` is generate's documented escape hatch for introspecting a
1703
+ // literal schema name that happens to look like a path, so it SKIPS the
1704
+ // refusal rather than merely being named by it.
1705
+ if (args.allowEmpty !== true)
1706
+ refuseSchemaFilePath(config, { escapeHatch: '--allow-empty' });
1326
1707
  label('Database', redactUrl(url));
1327
1708
  label('Schema', config.schema);
1328
1709
  label('Output', config.out);
@@ -1356,15 +1737,15 @@ async function cmdGenerate(args, config) {
1356
1737
  if (tableNames.length === 0 && !args.allowEmpty) {
1357
1738
  newline();
1358
1739
  error(`Introspection matched 0 tables in schema ${cyan(config.schema)}, refusing to generate an empty client.`);
1359
- newline();
1360
- console.log(` ${dim('Common causes:')}`);
1361
- console.log(` ${dim('•')} ${cyan('schema')} ${dim('points at the wrong Postgres namespace')} ${dim('(it is the schema NAME, default')} ${cyan('public')}${dim(').')}`);
1362
- console.log(` ${dim('•')} ${dim('You meant to set')} ${cyan('schemaFile')} ${dim('(your defineSchema() file), not')} ${cyan('schema')}${dim('.')}`);
1363
- console.log(` ${dim('•')} ${cyan('include')}/${cyan('exclude')} ${dim('filtered out every table.')}`);
1364
- console.log(` ${dim('•')} ${dim('The database has no tables yet, run')} ${cyan('turbine push')} ${dim('or a migration first.')}`);
1365
- newline();
1366
- console.log(` ${dim('If an empty client is genuinely what you want, re-run with')} ${cyan('--allow-empty')}${dim('.')}`);
1367
- newline();
1740
+ errorLine();
1741
+ errorLine(`${dim('Common causes:')}`);
1742
+ errorLine(` ${dim('•')} ${cyan('schema')} ${dim('points at the wrong Postgres namespace')} ${dim('(it is the schema NAME, default')} ${cyan('public')}${dim(').')}`);
1743
+ errorLine(` ${dim('•')} ${dim('You meant to set')} ${cyan('schemaFile')} ${dim('(your defineSchema() file), not')} ${cyan('schema')}${dim('.')}`);
1744
+ errorLine(` ${dim('•')} ${cyan('include')}/${cyan('exclude')} ${dim('filtered out every table.')}`);
1745
+ errorLine(` ${dim('•')} ${dim('The database has no tables yet, run')} ${cyan('turbine push')} ${dim('or a migration first.')}`);
1746
+ errorLine();
1747
+ errorLine(`${dim('If an empty client is genuinely what you want, re-run with')} ${cyan('--allow-empty')}${dim('.')}`);
1748
+ errorLine();
1368
1749
  process.exit(1);
1369
1750
  }
1370
1751
  // Print table summary
@@ -1402,9 +1783,11 @@ async function cmdGenerate(args, config) {
1402
1783
  newline();
1403
1784
  console.log(` ${bold('Usage:')}`);
1404
1785
  newline();
1405
- console.log(` ${cyan(`import { turbine } from './${config.out.replace('./', '')}';`)}`);
1786
+ console.log(` ${cyan(generatedClientImport(config))}`);
1406
1787
  console.log(` ${dim('const db = turbine({ connectionString: process.env.DATABASE_URL });')}`);
1407
- console.log(` ${dim('const user = await db.users.findUnique({ where: { id: 1 } });')}`);
1788
+ // Name a table the client just emitted rather than a hardcoded `users`.
1789
+ const sampleAccessor = snakeToCamel(tableNames[0] ?? 'users');
1790
+ console.log(` ${dim(`const row = await db.${sampleAccessor}.findFirst();`)}`);
1408
1791
  newline();
1409
1792
  }
1410
1793
  /**
@@ -1464,10 +1847,10 @@ async function cmdMigrateFromPrisma(args, config) {
1464
1847
  }
1465
1848
  if (!existsSync(prismaPath)) {
1466
1849
  error(`Prisma schema not found: ${cyan(prismaPath)}`);
1467
- newline();
1468
- console.log(` ${dim('Point at it with')} ${cyan('--schema <path>')} ${dim('(a .prisma file, or a directory of them;')}`);
1469
- console.log(` ${dim('default: prisma/schema.prisma, then prisma/schema/).')}`);
1470
- newline();
1850
+ errorLine();
1851
+ errorLine(`${dim('Point at it with')} ${cyan('--schema <path>')} ${dim('(a .prisma file, or a directory of them;')}`);
1852
+ errorLine(`${dim('default: prisma/schema.prisma, then prisma/schema/).')}`);
1853
+ errorLine();
1471
1854
  process.exit(1);
1472
1855
  }
1473
1856
  // Parse (fatal only on a construct we must understand).
@@ -1478,8 +1861,8 @@ async function cmdMigrateFromPrisma(args, config) {
1478
1861
  catch (err) {
1479
1862
  newline();
1480
1863
  error(`Could not read ${cyan(prismaPath)}`);
1481
- console.log(` ${red(err instanceof Error ? err.message : String(err))}`);
1482
- newline();
1864
+ errorLine(`${red(err instanceof Error ? err.message : String(err))}`);
1865
+ errorLine();
1483
1866
  process.exit(1);
1484
1867
  }
1485
1868
  let ast;
@@ -1490,8 +1873,8 @@ async function cmdMigrateFromPrisma(args, config) {
1490
1873
  if (err instanceof PrismaParseError) {
1491
1874
  newline();
1492
1875
  error(`Could not parse ${cyan(prismaPath)}`);
1493
- console.log(` ${red(err.message)}`);
1494
- newline();
1876
+ errorLine(`${red(err.message)}`);
1877
+ errorLine();
1495
1878
  process.exit(1);
1496
1879
  }
1497
1880
  throw err;
@@ -1570,7 +1953,7 @@ async function cmdMigrateFromPrisma(args, config) {
1570
1953
  const rel = relative(process.cwd(), outDir);
1571
1954
  if (rel.startsWith('..') || resolve(rel) !== outDir) {
1572
1955
  error(`Output directory must be within the project root. Got: ${config.out}`);
1573
- newline();
1956
+ errorLine();
1574
1957
  process.exit(1);
1575
1958
  }
1576
1959
  mkdirSync(outDir, { recursive: true });
@@ -1634,6 +2017,7 @@ async function cmdMigrateFromPrisma(args, config) {
1634
2017
  // ---------------------------------------------------------------------------
1635
2018
  async function cmdPush(args, config) {
1636
2019
  banner();
2020
+ refuseSchemaFilePath(config);
1637
2021
  const url = requireUrl(config);
1638
2022
  label('Database', redactUrl(url));
1639
2023
  label('Schema file', config.schemaFile);
@@ -1814,34 +2198,37 @@ async function cmdMigrate(args, config) {
1814
2198
  break;
1815
2199
  default:
1816
2200
  error(`Unknown migrate subcommand: ${sub}`);
1817
- console.log(` ${dim('Run')} ${cyan('npx turbine migrate help')} ${dim('for usage.')}`);
2201
+ errorLine(`${dim('Run')} ${cyan('npx turbine migrate help')} ${dim('for usage.')}`);
1818
2202
  process.exit(1);
1819
2203
  }
1820
2204
  }
1821
2205
  async function cmdMigrateCreate(args, config) {
1822
2206
  banner();
2207
+ // `--auto` / `--from-diff` diff the schema FILE against the namespace, so both
2208
+ // halves of the confusion are live on this one command.
2209
+ refuseSchemaFilePath(config);
1823
2210
  const name = args.positional[0];
1824
2211
  if (!name) {
1825
2212
  error('Migration name is required.');
1826
- newline();
1827
- console.log(` ${dim('Usage:')} ${cyan('npx turbine migrate create <name>')}`);
1828
- console.log(` ${dim('Example:')} ${cyan('npx turbine migrate create add_users_table')}`);
1829
- console.log(` ${dim('Auto:')} ${cyan('npx turbine migrate create my_change --auto')}`);
1830
- newline();
2213
+ errorLine();
2214
+ errorLine(`${dim('Usage:')} ${cyan('npx turbine migrate create <name>')}`);
2215
+ errorLine(`${dim('Example:')} ${cyan('npx turbine migrate create add_users_table')}`);
2216
+ errorLine(`${dim('Auto:')} ${cyan('npx turbine migrate create my_change --auto')}`);
2217
+ errorLine();
1831
2218
  process.exit(1);
1832
2219
  }
1833
2220
  // The scaffold strategies each own the file body, so combining them is
1834
2221
  // ambiguous. Refuse up front (before any of the strategy blocks run).
1835
2222
  if (args.fromDiff && args.recipe) {
1836
2223
  error('--from-diff cannot be combined with --recipe.');
1837
- console.log(` ${dim('Pick one: --from-diff generates from the schema diff, --recipe scaffolds a pattern.')}`);
1838
- newline();
2224
+ errorLine(`${dim('Pick one: --from-diff generates from the schema diff, --recipe scaffolds a pattern.')}`);
2225
+ errorLine();
1839
2226
  process.exit(1);
1840
2227
  }
1841
2228
  if (args.fromDiff && args.auto) {
1842
2229
  error('--from-diff cannot be combined with --auto.');
1843
- console.log(` ${dim('Both generate from the schema diff; --from-diff also flags destructive statements.')}`);
1844
- newline();
2230
+ errorLine(`${dim('Both generate from the schema diff; --from-diff also flags destructive statements.')}`);
2231
+ errorLine();
1845
2232
  process.exit(1);
1846
2233
  }
1847
2234
  if (args.auto) {
@@ -1995,12 +2382,12 @@ async function cmdMigrateCreate(args, config) {
1995
2382
  if (args.recipe) {
1996
2383
  if (!MIGRATION_RECIPES[args.recipe]) {
1997
2384
  error(`Unknown migration recipe: ${args.recipe}`);
1998
- newline();
1999
- console.log(` ${dim('Available recipes:')}`);
2385
+ errorLine();
2386
+ errorLine(`${dim('Available recipes:')}`);
2000
2387
  for (const [key, recipe] of Object.entries(MIGRATION_RECIPES)) {
2001
- console.log(` ${cyan(key)} ${dim(recipe.description)}`);
2388
+ errorLine(` ${cyan(key)} ${dim(recipe.description)}`);
2002
2389
  }
2003
- newline();
2390
+ errorLine();
2004
2391
  process.exit(1);
2005
2392
  }
2006
2393
  const file = createMigration(config.migrationsDir, name, undefined, { recipe: args.recipe });
@@ -2198,11 +2585,11 @@ async function cmdMigrateDeploy(args, config) {
2198
2585
  if (plan.mismatches.length > 0) {
2199
2586
  if (!args.allowDrift) {
2200
2587
  error('Deploy blocked by migration drift');
2201
- newline();
2588
+ errorLine();
2202
2589
  for (const line of formatChecksumMismatchError(plan.mismatches).split('\n')) {
2203
- console.log(` ${line.replace('[turbine] ', '')}`);
2590
+ errorLine(`${line.replace('[turbine] ', '')}`);
2204
2591
  }
2205
- newline();
2592
+ errorLine();
2206
2593
  process.exit(1);
2207
2594
  }
2208
2595
  warn('--allow-drift is set: checksum validation is DISABLED for this deploy.');
@@ -2533,6 +2920,7 @@ async function cmdSeed(_args, config) {
2533
2920
  // ---------------------------------------------------------------------------
2534
2921
  async function cmdStatus(_args, config) {
2535
2922
  banner();
2923
+ refuseSchemaFilePath(config);
2536
2924
  const url = requireUrl(config);
2537
2925
  label('Database', redactUrl(url));
2538
2926
  label('Schema', config.schema);
@@ -2672,6 +3060,7 @@ export function refusePoolerConnection(url, args) {
2672
3060
  }
2673
3061
  async function cmdDoctor(args, config) {
2674
3062
  const jsonMode = args.json === true;
3063
+ refuseSchemaFilePath(config);
2675
3064
  const url = requireUrl(config);
2676
3065
  // Before the banner, before introspect, before any pool is constructed.
2677
3066
  refusePoolerConnection(url, args);
@@ -3419,6 +3808,9 @@ export function isLoopbackHost(host) {
3419
3808
  async function cmdStudio(args, config) {
3420
3809
  banner();
3421
3810
  const demo = args.demo === true;
3811
+ // Demo mode reads no namespace at all, so there is nothing to mistake there.
3812
+ if (!demo)
3813
+ refuseSchemaFilePath(config);
3422
3814
  // Demo mode is self-contained (seeded in-memory database), so it never needs
3423
3815
  // a DATABASE_URL. The placeholder is only used for display.
3424
3816
  const url = demo ? 'demo://in-memory' : requireUrl(config);
@@ -3435,10 +3827,10 @@ async function cmdStudio(args, config) {
3435
3827
  if (!isLoopbackHost(host)) {
3436
3828
  if (!args.allowRemote) {
3437
3829
  error(`Studio refuses to bind to ${yellow(host)} without ${cyan('--allow-remote')}.`);
3438
- newline();
3439
- console.log(` ${dim('Loopback only by default')} ${dim('(127.0.0.1, localhost, ::1).')}`);
3440
- console.log(` ${dim('Pass')} ${cyan('--allow-remote')} ${dim('to opt in to network exposure.')}`);
3441
- newline();
3830
+ errorLine();
3831
+ errorLine(`${dim('Loopback only by default')} ${dim('(127.0.0.1, localhost, ::1).')}`);
3832
+ errorLine(`${dim('Pass')} ${cyan('--allow-remote')} ${dim('to opt in to network exposure.')}`);
3833
+ errorLine();
3442
3834
  process.exit(1);
3443
3835
  }
3444
3836
  // warn() prints; it returns void. Wrapping it in console.log() printed a
@@ -3546,6 +3938,7 @@ async function cmdStudio(args, config) {
3546
3938
  // Command: mcp, read-only JSON-RPC stdio server
3547
3939
  // ---------------------------------------------------------------------------
3548
3940
  async function cmdMcp(_args, config) {
3941
+ refuseSchemaFilePath(config);
3549
3942
  const url = requireUrl(config);
3550
3943
  await runMcpServer({
3551
3944
  url,
@@ -3564,10 +3957,10 @@ async function cmdObserve(args) {
3564
3957
  const url = process.env.TURBINE_OBSERVE_URL;
3565
3958
  if (!url) {
3566
3959
  error('TURBINE_OBSERVE_URL environment variable is required for the observe command.');
3567
- newline();
3568
- console.log(` ${dim('Set it to the Postgres connection string where metrics are stored.')}`);
3569
- console.log(` ${dim('Example:')} ${cyan('TURBINE_OBSERVE_URL=postgres://... npx turbine observe')}`);
3570
- newline();
3960
+ errorLine();
3961
+ errorLine(`${dim('Set it to the Postgres connection string where metrics are stored.')}`);
3962
+ errorLine(`${dim('Example:')} ${cyan('TURBINE_OBSERVE_URL=postgres://... npx turbine observe')}`);
3963
+ errorLine();
3571
3964
  process.exit(1);
3572
3965
  }
3573
3966
  const port = args.port ?? 4984;
@@ -3582,10 +3975,10 @@ async function cmdObserve(args) {
3582
3975
  if (!isLoopbackHost(host)) {
3583
3976
  if (!args.allowRemote) {
3584
3977
  error(`Observe refuses to bind to ${yellow(host)} without ${cyan('--allow-remote')}.`);
3585
- newline();
3586
- console.log(` ${dim('Loopback only by default')} ${dim('(127.0.0.1, localhost, ::1).')}`);
3587
- console.log(` ${dim('Pass')} ${cyan('--allow-remote')} ${dim('to opt in to network exposure.')}`);
3588
- newline();
3978
+ errorLine();
3979
+ errorLine(`${dim('Loopback only by default')} ${dim('(127.0.0.1, localhost, ::1).')}`);
3980
+ errorLine(`${dim('Pass')} ${cyan('--allow-remote')} ${dim('to opt in to network exposure.')}`);
3981
+ errorLine();
3589
3982
  process.exit(1);
3590
3983
  }
3591
3984
  // warn() prints and returns void; see the same guard in cmdStudio.
@@ -3650,6 +4043,8 @@ export function showSubcommandHelp(command) {
3650
4043
  doctor: showDoctorHelp,
3651
4044
  studio: showStudioHelp,
3652
4045
  mcp: showMcpHelp,
4046
+ observe: showObserveHelp,
4047
+ skill: showSkillHelp,
3653
4048
  };
3654
4049
  const fn = helpMap[command];
3655
4050
  if (fn) {
@@ -3677,6 +4072,7 @@ function showInitHelp() {
3677
4072
  console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string to embed in config`);
3678
4073
  console.log(` ${cyan('--force, -f')} Overwrite existing config file`);
3679
4074
  console.log(` ${cyan('--yes, -y')} Accept every step's default (non-interactive)`);
4075
+ console.log(` ${cyan('--schema-file')} ${dim('<path>')} Where to scaffold the schema file ${dim('(default: ./turbine/schema.ts)')}`);
3680
4076
  console.log(` ${cyan('--skip-schema')} Don't create the starter schema file`);
3681
4077
  console.log(` ${cyan('--with-schema')} Create it even if the database already has tables`);
3682
4078
  console.log(` ${cyan('--skip-seed')} Don't create the seed file or run the seed`);
@@ -3757,8 +4153,11 @@ function showPushHelp() {
3757
4153
  console.log(` Reads your ${cyan('turbine/schema.ts')} file, diffs against the live database,`);
3758
4154
  console.log(` and applies CREATE/ALTER statements.`);
3759
4155
  newline();
4156
+ console.log(` ${dim('Note:')} the file is ${cyan('--schema-file')}${dim('.')} ${cyan('--schema')} ${dim('is the Postgres namespace to write into.')}`);
4157
+ newline();
3760
4158
  console.log(` ${bold('Options:')}`);
3761
4159
  console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
4160
+ console.log(` ${cyan('--schema-file')} ${dim('<path>')} Your ${cyan('defineSchema()')} file ${dim('(default: ./turbine/schema.ts)')}`);
3762
4161
  console.log(` ${cyan('--dry-run')} Show SQL without executing`);
3763
4162
  console.log(` ${cyan('--allow-destructive')} Skip the interactive confirmation for data-destroying statements ${dim('(CI)')}`);
3764
4163
  console.log(` ${cyan('--verbose, -v')} Show detailed output`);
@@ -3780,6 +4179,7 @@ function showMigrateHelp() {
3780
4179
  newline();
3781
4180
  console.log(` ${bold('Options:')}`);
3782
4181
  console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
4182
+ console.log(` ${cyan('--schema-file')} ${dim('<path>')} Your ${cyan('defineSchema()')} file, for ${cyan('--auto')} / ${cyan('--from-diff')}`);
3783
4183
  console.log(` ${cyan('--auto')} Auto-generate UP/DOWN SQL from schema diff ${dim('(create only)')}`);
3784
4184
  console.log(` ${cyan('--from-diff')} Generate from schema diff, flagging destructive statements ${dim('(create only)')}`);
3785
4185
  console.log(` ${cyan('--recipe')} ${dim('<name>')} Scaffold a sanctioned migration pattern ${dim('(create only, e.g. backfill)')}`);
@@ -3932,6 +4332,51 @@ function showMcpHelp() {
3932
4332
  console.log(` ${cyan('--exclude')} ${dim('<tables>')} Comma-separated tables to exclude`);
3933
4333
  newline();
3934
4334
  }
4335
+ function showObserveHelp() {
4336
+ banner();
4337
+ console.log(` ${bold('turbine observe')}, Query metrics dashboard`);
4338
+ newline();
4339
+ console.log(` ${bold('Usage:')}`);
4340
+ console.log(` TURBINE_OBSERVE_URL=postgres://... npx turbine observe ${dim('[options]')}`);
4341
+ newline();
4342
+ console.log(` Reads the ${cyan('_turbine_metrics')} table written by ${cyan('db.$observe()')} and serves`);
4343
+ console.log(` per-minute count / avg / p50 / p95 / p99 / error aggregates. Read-only,`);
4344
+ console.log(` loopback-bound, behind a random per-session token, same model as Studio.`);
4345
+ newline();
4346
+ console.log(` ${dim('The connection string comes from')} ${cyan('TURBINE_OBSERVE_URL')}${dim(', not')} ${cyan('DATABASE_URL')}${dim(':')}`);
4347
+ console.log(` ${dim('metrics are meant to live in a different database from the one they measure.')}`);
4348
+ newline();
4349
+ console.log(` ${bold('Options:')}`);
4350
+ console.log(` ${cyan('--port')} ${dim('<n>')} HTTP port ${dim('(default: 4984)')}`);
4351
+ console.log(` ${cyan('--host')} ${dim('<addr>')} Bind address ${dim('(default: 127.0.0.1)')}`);
4352
+ console.log(` ${cyan('--no-open')} Don't auto-open the browser`);
4353
+ console.log(` ${cyan('--allow-remote')} Allow a non-loopback ${cyan('--host')} ${dim('(refused without it)')}`);
4354
+ newline();
4355
+ }
4356
+ function showSkillHelp() {
4357
+ banner();
4358
+ console.log(` ${bold('turbine skill')}, Install the agent query skill`);
4359
+ newline();
4360
+ console.log(` ${bold('Usage:')}`);
4361
+ console.log(` npx turbine skill ${dim('[options]')}`);
4362
+ newline();
4363
+ console.log(` Writes ${cyan('.claude/skills/turbine-orm/SKILL.md')}: how to write Turbine queries,`);
4364
+ console.log(` for a coding agent working in this project. The file ships in the package`);
4365
+ console.log(` rather than being generated here, and every factual claim in it is executed`);
4366
+ console.log(` against a live database on each release.`);
4367
+ newline();
4368
+ console.log(` ${bold('Options:')}`);
4369
+ console.log(` ${cyan('--print')} Write the skill to stdout instead of installing it`);
4370
+ console.log(` ${cyan('--agents')} Print the AGENTS.md / CLAUDE.md instructions block`);
4371
+ console.log(` ${cyan('--dir')} ${dim('<path>')} Skills root to install into ${dim('(default: .claude/skills)')}`);
4372
+ newline();
4373
+ console.log(` ${bold('Examples:')}`);
4374
+ console.log(` ${dim('$')} npx turbine skill`);
4375
+ console.log(` ${dim('$')} npx turbine skill --agents >> AGENTS.md`);
4376
+ newline();
4377
+ console.log(` ${dim('Also worth connecting:')} ${cyan('npx turbine mcp')}${dim(', the read-only MCP server.')}`);
4378
+ newline();
4379
+ }
3935
4380
  // ---------------------------------------------------------------------------
3936
4381
  // Help
3937
4382
  // ---------------------------------------------------------------------------
@@ -3962,7 +4407,8 @@ function showHelp() {
3962
4407
  console.log(` ${bold('Options:')}`);
3963
4408
  console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
3964
4409
  console.log(` ${cyan('--out, -o')} ${dim('<dir>')} Output directory ${dim('(default: ./generated/turbine)')}`);
3965
- console.log(` ${cyan('--schema, -s')} ${dim('<name>')} Postgres schema ${dim('(default: public)')}`);
4410
+ console.log(` ${cyan('--schema, -s')} ${dim('<name>')} Postgres schema NAME to read ${dim('(default: public)')}`);
4411
+ console.log(` ${cyan('--schema-file')} ${dim('<path>')} Your ${cyan('defineSchema()')} file ${dim('(push / migrate --auto / init)')}`);
3966
4412
  console.log(` ${cyan('--include')} ${dim('<tables>')} Comma-separated tables to include`);
3967
4413
  console.log(` ${cyan('--exclude')} ${dim('<tables>')} Comma-separated tables to exclude`);
3968
4414
  console.log(` ${cyan('--dry-run')} Show SQL without executing`);
@@ -4080,9 +4526,9 @@ function cmdSkill(args) {
4080
4526
  const source = root ? resolve(root, 'skills', 'turbine-orm', 'SKILL.md') : undefined;
4081
4527
  if (!source || !existsSync(source)) {
4082
4528
  error('Could not find the packaged skill inside turbine-orm.');
4083
- newline();
4084
- console.log(` ${dim('Read it online instead:')} ${cyan('https://turbineorm.dev/ai-agents')}`);
4085
- newline();
4529
+ errorLine();
4530
+ errorLine(`${dim('Read it online instead:')} ${cyan('https://turbineorm.dev/ai-agents')}`);
4531
+ errorLine();
4086
4532
  process.exit(1);
4087
4533
  }
4088
4534
  const body = readFileSync(source, 'utf8');
@@ -4180,6 +4626,7 @@ async function main() {
4180
4626
  url: args.url,
4181
4627
  out: args.out,
4182
4628
  schema: args.schema,
4629
+ schemaFile: args.schemaFile,
4183
4630
  include: args.include,
4184
4631
  exclude: args.exclude,
4185
4632
  importExtension: args.importExtension,
@@ -4256,50 +4703,60 @@ async function main() {
4256
4703
  case 'skill':
4257
4704
  cmdSkill(args);
4258
4705
  break;
4259
- default:
4706
+ default: {
4260
4707
  error(`Unknown command: ${bold(args.command)}`);
4261
- newline();
4262
- console.log(` ${dim('Run')} ${cyan('npx turbine help')} ${dim('for available commands.')}`);
4263
- newline();
4708
+ errorLine();
4709
+ // Only canonical spellings are offered. `nameCloseness` scores substring
4710
+ // containment above edit distance, so including the one-letter aliases
4711
+ // would answer "genrate" with "g" (contained, score 501) instead of
4712
+ // "generate" (one edit away, score 99).
4713
+ const suggestion = closestName(args.command, knownCommands());
4714
+ if (suggestion) {
4715
+ errorLine(`${dim('Did you mean')} ${cyan(`turbine ${suggestion}`)}${dim('?')}`);
4716
+ errorLine();
4717
+ }
4718
+ errorLine(`${dim('Run')} ${cyan('npx turbine help')} ${dim('for available commands.')}`);
4719
+ errorLine();
4264
4720
  process.exit(1);
4721
+ }
4265
4722
  }
4266
4723
  }
4267
4724
  catch (err) {
4268
4725
  if (err instanceof Error) {
4269
4726
  if (err.message.includes('ECONNREFUSED') || err.message.includes('connection')) {
4270
- newline();
4727
+ errorLine();
4271
4728
  error(`Could not connect to database`);
4272
- console.log(` ${dim(redactUrl(err.message))}`);
4273
- newline();
4274
- console.log(` ${dim('Check that:')}`);
4275
- console.log(` ${dim('1.')} Your database is running`);
4276
- console.log(` ${dim('2.')} The connection string is correct`);
4277
- console.log(` ${dim('3.')} Network/firewall allows the connection`);
4729
+ errorLine(`${dim(redactUrl(err.message))}`);
4730
+ errorLine();
4731
+ errorLine(`${dim('Check that:')}`);
4732
+ errorLine(` ${dim('1.')} Your database is running`);
4733
+ errorLine(` ${dim('2.')} The connection string is correct`);
4734
+ errorLine(` ${dim('3.')} Network/firewall allows the connection`);
4278
4735
  }
4279
4736
  else if (err.message.includes('authentication')) {
4280
- newline();
4737
+ errorLine();
4281
4738
  error(`Authentication failed`);
4282
- console.log(` ${dim(redactUrl(err.message))}`);
4739
+ errorLine(`${dim(redactUrl(err.message))}`);
4283
4740
  }
4284
4741
  else if (err.message.includes('does not exist')) {
4285
- newline();
4742
+ errorLine();
4286
4743
  error(`Database or schema not found`);
4287
- console.log(` ${dim(redactUrl(err.message))}`);
4744
+ errorLine(`${dim(redactUrl(err.message))}`);
4288
4745
  }
4289
4746
  else {
4290
- newline();
4747
+ errorLine();
4291
4748
  error(redactUrl(err.message));
4292
4749
  if (args.verbose && err.stack) {
4293
- newline();
4294
- console.log(dim(redactUrl(err.stack)));
4750
+ errorLine();
4751
+ console.error(dim(redactUrl(err.stack)));
4295
4752
  }
4296
4753
  }
4297
4754
  }
4298
4755
  else {
4299
- newline();
4756
+ errorLine();
4300
4757
  error(`Unexpected error: ${redactUrl(String(err))}`);
4301
4758
  }
4302
- newline();
4759
+ errorLine();
4303
4760
  process.exit(1);
4304
4761
  }
4305
4762
  }