-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathinstall.ts
More file actions
989 lines (908 loc) · 34.8 KB
/
Copy pathinstall.ts
File metadata and controls
989 lines (908 loc) · 34.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
import { spawnSync } from 'node:child_process'
import { existsSync, unlinkSync, writeFileSync } from 'node:fs'
import { readdir } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import {
installMigrationsSchema,
MIGRATIONS_SCHEMA_SQL,
} from '@cipherstash/migrate'
import * as p from '@clack/prompts'
import pg from 'pg'
import { CliExit } from '@/cli/exit.js'
import {
detectPackageManager,
runnerArgv,
runnerCommand,
} from '@/commands/init/utils.js'
import { resolveDatabaseUrl } from '@/config/database-url.js'
import { findConfigFile, loadStashConfig } from '@/config/index.js'
import { isInteractive } from '@/config/tty.js'
import {
downloadEqlSql,
EQLInstaller,
loadBundledEqlSql,
resolveEqlVersion,
} from '@/installer/index.js'
import { messages } from '@/messages.js'
import { ensureEncryptionClient } from './client-scaffold.js'
import { offerStashConfig } from './config-scaffold.js'
import {
detectDrizzle,
detectPrismaNext,
detectSupabase,
detectSupabaseProject,
type SupabaseProjectInfo,
} from './detect.js'
import { rewriteEncryptedAlterColumns } from './rewrite-migrations.js'
import {
SUPABASE_EQL_MIGRATION_FILENAME,
writeSupabaseEqlMigration,
} from './supabase-migration.js'
const DEFAULT_MIGRATION_NAME = 'install-eql'
const DEFAULT_DRIZZLE_OUT = 'drizzle'
/**
* File-system-safe migration name: what drizzle-kit accepts, and shell-inert.
*
* Lives here rather than in `commands/eql/migration.ts` (the v3 generator) only
* because that module already imports from this one — keeping the constant on
* this side of the existing edge avoids an import cycle. Both generators share
* it; there must not be a second copy.
*/
export const SAFE_MIGRATION_NAME = /^[\w-]+$/
export interface InstallOptions {
force?: boolean
dryRun?: boolean
/**
* `undefined` means "auto-detect" (via {@link detectSupabase}). An explicit
* `true`/`false` from the user is preserved and skips detection.
*/
excludeOperatorFamily?: boolean
supabase?: boolean
drizzle?: boolean
latest?: boolean
name?: string
out?: string
/**
* Write the EQL install SQL into a Supabase migration file instead of
* running it directly against the database. Requires `--supabase`.
*/
migration?: boolean
/**
* Run the EQL install SQL directly against the database (current behavior).
* Requires `--supabase`. Mutually exclusive with `--migration`.
*/
direct?: boolean
/**
* Override the directory the Supabase migration file is written into.
* Defaults to `<cwd>/supabase/migrations`.
*/
migrationsDir?: string
/**
* Connection string passed via `--database-url`. Used for this run only —
* never persisted. See `src/config/database-url.ts`.
*/
databaseUrl?: string
/**
* How to handle a missing `stash.config.ts` — the caller owns this intent
* rather than it being inferred from whether a URL was supplied:
* - `'ensure'` — create it without asking (`stash init`, where the user has
* already committed to setting the project up).
* - `'offer'` — offer to create it (plain `stash eql install`). Default.
* - `'skip'` — never scaffold (a one-shot `eql install --database-url ...`).
*/
scaffoldConfig?: 'ensure' | 'offer' | 'skip'
/**
* EQL generation to install: `'3'` (default, native `eql_v3.*` domain
* schema) or `'2'` (composite `eql_v2_encrypted`). v3 currently supports the
* direct install path only — `--drizzle`, `--migration`, `--migrations-dir`,
* and `--latest` require an explicit `'2'`.
*/
eqlVersion?: string
}
/** Resolved install mode for the Supabase non-Drizzle branch. */
export type SupabaseInstallMode = 'migration' | 'direct'
/**
* Resolve the database URL + encryption-client path for the install.
*
* A pre-existing `stash.config.ts` is authoritative — later workflow commands
* (db push / schema build / encrypt) load the client through it — so we load
* it. Without one, `eql install` doesn't need a config: resolve the URL
* directly (flag → env → supabase → prompt). Decoupling install from the config
* is what lets `npx stash eql install --database-url ...` run in a bare project
* without the `stash` / `@cipherstash/stack` dependencies the config would
* otherwise import (#579).
*
* Whether a missing config gets scaffolded is the caller's explicit intent
* ({@link InstallOptions.scaffoldConfig}), not inferred from whether a URL was
* supplied — `stash init` passes a resolved URL but still wants a config, while
* a one-shot `--database-url` run wants the project left untouched. When no
* config is created, `clientPath` comes back `null` so the caller skips the
* client scaffold too.
*
* A one-shot run (`mode === 'skip'`, set when `--database-url` is passed alone)
* bypasses config loading entirely: it must leave the project untouched, so it
* neither honours nor scaffolds a config or client. This also means the flag
* always wins — loading a config here could pick up a parent-directory config
* with a hand-edited literal `databaseUrl` that silently overrides the flag and
* installs EQL against the wrong database.
*/
async function resolveInstallContext(
options: InstallOptions,
s: ReturnType<typeof p.spinner>,
): Promise<{ databaseUrl: string; clientPath: string | null }> {
const mode = options.scaffoldConfig ?? 'offer'
// A one-shot `--database-url` install leaves the project untouched: don't load
// an existing config (see doc comment re: parent-dir literal override) and
// don't scaffold. Resolve the URL directly and return a null clientPath.
const configPath = mode === 'skip' ? null : findConfigFile(process.cwd())
if (configPath) {
s.start('Loading stash.config.ts...')
// Pass the path we already located so loadStashConfig doesn't re-walk the
// filesystem to find it.
const config = await loadStashConfig(
{
databaseUrlFlag: options.databaseUrl,
supabase: options.supabase,
},
configPath,
)
s.stop('Configuration loaded.')
// A config with a hand-edited literal `databaseUrl` bypasses the resolver,
// so a `--database-url` flag would be silently ignored. Surface it rather
// than installing against a different database than the user asked for —
// especially since `findConfigFile` walks up into parent directories.
if (
options.databaseUrl !== undefined &&
config.databaseUrl !== options.databaseUrl.trim()
) {
p.log.warn(
`Ignoring --database-url: ${configPath} sets an explicit databaseUrl that takes precedence. Installing against the config's database.`,
)
}
return { databaseUrl: config.databaseUrl, clientPath: config.client }
}
const databaseUrl = await resolveDatabaseUrl({
databaseUrlFlag: options.databaseUrl,
supabase: options.supabase,
})
// A dry run must not write scaffold files, so it never enters the scaffold
// path (nor does an explicit `skip`).
if (mode === 'skip' || options.dryRun) {
return { databaseUrl, clientPath: null }
}
const clientPath = await offerStashConfig({ ensure: mode === 'ensure' })
return { databaseUrl, clientPath }
}
/**
* What `installCommand` actually did — so callers (notably `stash init`'s
* completion gate) can tell "EQL is now in the database" from "a migration
* file was written but nothing was applied yet".
*
* - `installed` / `already-installed`: EQL is present in the target database.
* - `migration-generated`: a migration file was written (Drizzle, or the
* Supabase `--migration` mode); the user still has to APPLY it
* (`drizzle-kit migrate` / `supabase db push`) before EQL exists in the DB.
* - `dry-run`: nothing was changed.
*
* Terminal error paths call `process.exit(1)` and never return an outcome.
*/
export type InstallOutcome =
| 'installed'
| 'already-installed'
| 'migration-generated'
| 'dry-run'
export async function installCommand(
options: InstallOptions,
): Promise<InstallOutcome> {
p.intro(runnerCommand(detectPackageManager(), 'stash eql install'))
// Validate mutually-exclusive / supabase-required flags BEFORE doing any
// I/O. `--migration` and `--direct` only make sense in the Supabase flow;
// they must NOT implicitly enable `--supabase`. (Strong product preference
// — auto-enabling here has bitten users before.)
const flagError = validateInstallFlags(options)
if (flagError) {
p.log.error(flagError)
p.outro('Installation aborted.')
process.exit(1)
}
// Prisma Next owns EQL installation via its own migration system, so the
// standalone installer is the wrong tool here. Refuse (before any DB I/O)
// unless --force. Fires fast so a user who typed the wrong command gets a
// pointer, not a half-applied install.
const prismaNextBlock = prismaNextInstallGuard(process.cwd(), options)
if (prismaNextBlock) {
p.log.error(prismaNextBlock)
p.outro('Installation aborted.')
process.exit(1)
}
const s = p.spinner()
// `eql install` only needs a database URL to install EQL. It does NOT require
// a stash.config.ts: an existing config is authoritative (later workflow
// commands rely on it), but without one we resolve the URL directly — so a
// standalone `npx stash eql install --database-url ...` works with zero
// project dependencies. A one-shot `--database-url` run leaves the project
// untouched; otherwise we offer to scaffold a config for the rest of the
// workflow (CIP-2986 / #579).
const { databaseUrl, clientPath } = await resolveInstallContext(options, s)
// Safety net: if the user ran `eql install` without first running `init`,
// scaffold the encryption client file so clientPath points somewhere real.
// No-op when the file already exists. Skipped for a one-shot `--database-url`
// install (clientPath === null) or a dry run, which leave the project
// untouched — an existing config still yields a clientPath, so guard on dryRun
// too.
if (clientPath && !options.dryRun) {
ensureEncryptionClient(clientPath, process.cwd(), databaseUrl)
}
// Auto-detect provider hints when the user didn't explicitly pass flags.
// CIP-2985.
const resolved = resolveProviderOptions(options, databaseUrl)
const eqlVersion: 2 | 3 = resolveEqlVersion(options.eqlVersion)
// v3 supports the direct install path only. Explicit --drizzle/--migration
// are rejected up-front by validateInstallFlags; auto-DETECTED drizzle or
// migration modes fall back to direct here rather than erroring.
const routing = routeInstallPathForEqlVersion(eqlVersion, resolved)
if (routing.notice) {
p.log.info(routing.notice)
}
if (routing.drizzle) {
await generateDrizzleMigration(s, {
name: options.name,
out: options.out,
dryRun: options.dryRun,
latest: options.latest,
supabase: resolved.supabase,
excludeOperatorFamily: resolved.excludeOperatorFamily,
})
// A Drizzle migration file was written — NOT applied. EQL only lands in
// the database once the user runs `drizzle-kit migrate`.
return 'migration-generated'
}
// Supabase non-Drizzle path: pick between writing a migration file and
// running SQL directly. Detection of `supabase/migrations/` only seeds the
// prompt default — it never enables `--supabase`. Direct install is the
// historical default and remains the fallback when nothing else applies.
// v3 skips the mode selection entirely: direct install only for now.
if (routing.useSupabaseInstallModeSelection) {
const projectInfo = detectSupabaseProject(
process.cwd(),
options.migrationsDir,
)
const mode = await resolveSupabaseInstallMode(options, projectInfo)
if (mode === 'migration') {
// CIP: --latest in the migration path is not yet implemented. Loading
// the bundled SQL works today; downloading from GitHub adds an extra
// moving part we'd rather defer until someone needs it.
if (options.latest) {
p.log.error(
'`eql install --supabase --migration --latest` is not yet supported. Please open an issue at https://github.com/cipherstash/stack/issues if you need this.',
)
p.outro('Installation aborted.')
process.exit(1)
}
await writeSupabaseMigrationFile(s, {
projectInfo,
force: options.force,
dryRun: options.dryRun,
})
// Migration file written, not applied — the user runs it via their
// Supabase migration workflow (`supabase db push`).
return 'migration-generated'
}
// mode === 'direct' — fall through to existing direct-install behavior.
}
if (options.dryRun) {
p.log.info('Dry run — no changes will be made.')
const source = options.latest
? 'Would download EQL install script from GitHub'
: `Would use bundled EQL${eqlVersion === 3 ? ' v3' : ''} install script`
p.note(`${source}\nWould execute the SQL against the database`, 'Dry Run')
p.outro('Dry run complete.')
return 'dry-run'
}
const installer = new EQLInstaller({
databaseUrl,
})
s.start('Checking database permissions...')
const permissions = await installer.checkPermissions()
// CIP-2989: if the role is not a superuser and neither --supabase nor
// --exclude-operator-family was passed, auto-fall back to the
// no-operator-family (OPE) install variant. This is the same thing an
// experienced user would do manually; doing it automatically avoids the
// "what flag do I need?" failure mode on Supabase/Neon/RDS.
let excludeOperatorFamily = resolved.excludeOperatorFamily
if (
!permissions.isSuperuser &&
!resolved.supabase &&
options.excludeOperatorFamily === undefined
) {
excludeOperatorFamily = true
s.stop(
'Role lacks superuser — falling back to the no-operator-family (OPE) install.',
)
} else if (!permissions.ok) {
s.stop('Insufficient database permissions.')
p.log.error('The connected database role is missing required permissions:')
for (const missing of permissions.missing) {
p.log.warn(` - ${missing}`)
}
p.note(
'EQL installation requires a role with CREATE SCHEMA,\nCREATE TYPE, and CREATE EXTENSION privileges.\n\nConnect with a superuser or admin role, or ask your\ndatabase administrator to grant the required permissions.',
'Required Permissions',
)
p.outro('Installation aborted.')
process.exit(1)
} else {
s.stop('Database permissions verified.')
}
if (!options.force) {
s.start('Checking if EQL is already installed...')
const installed = await installer.isInstalled({ eqlVersion })
s.stop(installed ? 'EQL is already installed.' : 'EQL is not installed.')
if (installed) {
p.log.info('Use --force to re-run the install script.')
p.outro('Nothing to do.')
return 'already-installed'
}
}
const source = options.latest ? 'from GitHub (latest)' : 'bundled'
s.start(
`Installing EQL ${eqlVersion === 3 ? 'v3 ' : ''}extensions (${source})...`,
)
await installer.install({
excludeOperatorFamily,
supabase: resolved.supabase,
latest: options.latest,
eqlVersion,
})
s.stop('EQL extensions installed.')
if (resolved.supabase) {
p.log.success('Supabase role permissions granted.')
}
s.start('Installing cs_migrations tracking schema...')
const migrationsDb = new pg.Client({ connectionString: databaseUrl })
try {
await migrationsDb.connect()
await installMigrationsSchema(migrationsDb)
s.stop('cs_migrations schema installed.')
} catch (err) {
s.stop('Failed to install cs_migrations schema.')
p.log.warn(
err instanceof Error
? err.message
: 'Encryption migration tracking is unavailable; `stash encrypt` commands will fail until this is resolved.',
)
} finally {
await migrationsDb.end()
}
printNextSteps()
p.outro('Done!')
return 'installed'
}
/**
* Merge explicit CLI flags with auto-detected hints.
*
* Rules:
* - `--supabase` explicitly passed wins.
* - `--supabase` not passed → if the database URL looks like Supabase, enable it.
* - `--drizzle` explicitly passed wins.
* - `--drizzle` not passed → if drizzle-orm/drizzle-kit/drizzle.config.* exists, enable it.
* - `--exclude-operator-family` explicitly passed wins.
*/
function resolveProviderOptions(
options: InstallOptions,
databaseUrl: string,
): {
supabase: boolean
drizzle: boolean
excludeOperatorFamily: boolean
} {
const supabase =
options.supabase === undefined
? detectSupabase(databaseUrl)
: options.supabase
if (options.supabase === undefined && supabase) {
p.log.info(
'Detected Supabase database from DATABASE_URL — enabling --supabase.',
)
}
const drizzle =
options.drizzle === undefined
? detectDrizzle(process.cwd())
: options.drizzle
if (options.drizzle === undefined && drizzle) {
p.log.info('Detected Drizzle in this project — enabling --drizzle.')
}
const excludeOperatorFamily = options.excludeOperatorFamily ?? false
return { supabase, drizzle, excludeOperatorFamily }
}
export function printNextSteps(): void {
p.note(
[
'Your project is set up. To encrypt your first column, pick the path',
'that fits and ask your agent (the one `stash init` handed off to,',
'or whichever agent you use):',
'',
' Migrate an existing populated column (preserves live data):',
' Ask: "Use the stash lifecycle to encrypt <table>.<column>."',
'',
' Add a new encrypted column from scratch (no plaintext predecessor):',
' Ask: "Add an encrypted <column> to <table>."',
'',
'The agent will edit your schema, generate the migration, wire the',
'application code, and run the relevant `stash encrypt` commands.',
'Reference: the stash-encryption / stash-cli skills (loaded in your',
"agent's workspace) and https://cipherstash.com/docs.",
].join('\n'),
'What next',
)
}
/**
* Generate a Drizzle migration that installs CipherStash EQL.
*
* Uses `drizzle-kit generate --custom` to scaffold an empty migration,
* then loads the EQL install SQL (bundled by default, or from GitHub with
* `--latest`) and writes it into the file.
*
* Exported for unit tests; not part of the CLI's public surface. Failures
* throw {@link CliExit} rather than calling `process.exit` so the telemetry
* `finally` in `main.ts` still runs (and the branches stay testable).
*/
export async function generateDrizzleMigration(
s: ReturnType<typeof p.spinner>,
options: {
name?: string
out?: string
dryRun?: boolean
latest?: boolean
supabase?: boolean
excludeOperatorFamily?: boolean
},
) {
const migrationName = options.name ?? DEFAULT_MIGRATION_NAME
if (!SAFE_MIGRATION_NAME.test(migrationName)) {
p.log.error(messages.eql.migrationBadName)
p.outro('Migration aborted.')
throw new CliExit(1)
}
const outDir = resolve(options.out ?? DEFAULT_DRIZZLE_OUT)
// Invoke via spawnSync with an argv array (no shell), so a `--name` carrying
// spaces or shell metacharacters is one inert token, never word-split or
// executed. `--out` is always passed so drizzle-kit WRITES where we then
// LOOK — otherwise a project whose drizzle.config.ts points elsewhere would
// have drizzle-kit write there while we search `drizzle/` and fail in step 2.
const pm = detectPackageManager()
const { command, prefixArgs } = runnerArgv(pm)
const drizzleArgs = [
...prefixArgs,
'drizzle-kit',
'generate',
'--custom',
`--name=${migrationName}`,
`--out=${outDir}`,
]
const drizzleCmd = `${runnerCommand(pm, '').trim()} ${drizzleArgs.slice(prefixArgs.length).join(' ')}`
if (options.dryRun) {
p.log.info('Dry run — no changes will be made.')
const source = options.latest
? 'Would download EQL install SQL from GitHub'
: 'Would use bundled EQL install SQL'
p.note(
`Would run: ${drizzleCmd}\n${source}\nWould write SQL to migration file in ${outDir}`,
'Dry Run',
)
p.outro('Dry run complete.')
return
}
let generatedMigrationPath: string | undefined
// Step 1: Generate a custom Drizzle migration
s.start('Generating custom Drizzle migration...')
const result = spawnSync(command, drizzleArgs, {
stdio: 'pipe',
encoding: 'utf-8',
})
if (result.status !== 0) {
s.stop('Failed to generate migration.')
const stderr = result.stderr?.trim()
p.log.error(
stderr ||
result.error?.message ||
`drizzle-kit exited with status ${result.status ?? 'unknown'}.`,
)
p.log.info('Make sure drizzle-kit is installed: npm install -D drizzle-kit')
p.outro('Migration aborted.')
throw new CliExit(1)
}
s.stop('Custom Drizzle migration generated.')
// Step 2: Find the generated migration file
s.start('Locating generated migration file...')
try {
generatedMigrationPath = await findGeneratedMigration(outDir, migrationName)
s.stop(`Found migration: ${generatedMigrationPath}`)
} catch (error) {
s.stop('Failed to locate migration file.')
p.log.error(error instanceof Error ? error.message : String(error))
p.log.info(
'If your drizzle.config.ts writes elsewhere, pass --out <dir> so it matches.',
)
p.outro('Migration aborted.')
throw new CliExit(1)
}
// Step 3: Load the EQL SQL (bundled or from GitHub). Thread supabase /
// excludeOperatorFamily through so the user's flag reaches the SQL
// selection — previously this path ignored both (CIP-2988).
let eqlSql: string
const sqlOptions = {
supabase: options.supabase ?? false,
excludeOperatorFamily: options.excludeOperatorFamily ?? false,
}
if (options.latest) {
s.start('Downloading EQL install script from GitHub (latest)...')
try {
eqlSql = await downloadEqlSql(sqlOptions)
s.stop('EQL install script downloaded.')
} catch (error) {
s.stop('Failed to download EQL install script.')
p.log.error(error instanceof Error ? error.message : String(error))
cleanupMigrationFile(generatedMigrationPath)
p.outro('Migration aborted.')
throw new CliExit(1)
}
} else {
s.start('Loading bundled EQL install script...')
try {
eqlSql = loadBundledEqlSql(sqlOptions)
s.stop('Bundled EQL install script loaded.')
} catch (error) {
s.stop('Failed to load bundled EQL install script.')
p.log.error(error instanceof Error ? error.message : String(error))
cleanupMigrationFile(generatedMigrationPath)
p.outro('Migration aborted.')
throw new CliExit(1)
}
}
// Step 4: Write the EQL SQL (and cs_migrations tracking schema) into
// the migration file. Bundling both means `drizzle-kit migrate` rolls
// everything needed for `stash encrypt ...` out to each environment
// in one go, rather than requiring an out-of-band `stash eql install`.
s.start('Writing EQL SQL into migration file...')
const migrationContents = `${eqlSql}\n\n-- CipherStash encryption-migration tracking schema.\n-- Tracks per-column phase + backfill progress for \`stash encrypt\`.\n${MIGRATIONS_SCHEMA_SQL.trim()}\n`
try {
writeFileSync(generatedMigrationPath, migrationContents, 'utf-8')
s.stop('EQL SQL written to migration file.')
} catch (error) {
s.stop('Failed to write migration file.')
p.log.error(error instanceof Error ? error.message : String(error))
cleanupMigrationFile(generatedMigrationPath)
p.outro('Migration aborted.')
throw new CliExit(1)
}
// Step 5: Sweep for sibling migrations that drizzle-kit may have emitted
// with `ALTER COLUMN ... SET DATA TYPE eql_v2_encrypted`. These fail in
// Postgres because there's no implicit cast from text/numeric to the
// encrypted type. Rewrite them into the ADD/UPDATE/DROP/RENAME sequence
// that works on both empty and populated tables. CIP-2991 + CIP-2994.
try {
const rewritten = await rewriteEncryptedAlterColumns(outDir, {
skip: generatedMigrationPath,
})
if (rewritten.length > 0) {
p.log.info(
`Rewrote ${rewritten.length} migration file(s) to use safe ADD+migrate+DROP for encrypted columns:`,
)
for (const file of rewritten) p.log.step(` - ${file}`)
}
} catch (error) {
p.log.warn(
`Could not rewrite ALTER COLUMN migrations: ${error instanceof Error ? error.message : String(error)}`,
)
}
p.log.success(`Migration created: ${generatedMigrationPath}`)
p.note(
`Run your Drizzle migrations to install EQL:\n\n ${runnerCommand(detectPackageManager(), '').trim()} drizzle-kit migrate`,
'Next Steps',
)
printNextSteps()
p.outro('Done!')
}
/**
* Validate flag combinations that we can detect without doing any I/O.
*
* Rules:
* - `--migration` and `--direct` are mutually exclusive.
* - `--migration`, `--direct`, and `--migrations-dir` each REQUIRE
* `--supabase`. They do NOT auto-imply it.
*
* Returns a user-facing error message, or `null` when the flags are valid.
*/
/**
* Route the install between the drizzle / supabase-migration / direct paths
* for the requested EQL generation. Pure — no I/O, no prompts — so the v3
* fallback behaviour is unit-testable.
*
* v3 supports the direct path only: auto-detected drizzle falls back to
* direct with a user-facing notice (explicit `--drizzle`/`--migration` are
* already rejected by {@link validateInstallFlags}), and the Supabase
* migration-vs-direct mode selection is skipped entirely.
*/
export function routeInstallPathForEqlVersion(
eqlVersion: 2 | 3,
resolved: { supabase: boolean; drizzle: boolean },
): {
drizzle: boolean
useSupabaseInstallModeSelection: boolean
notice?: string
} {
if (eqlVersion === 3) {
return {
drizzle: false,
useSupabaseInstallModeSelection: false,
notice: resolved.drizzle
? 'EQL v3 does not support the Drizzle migration path yet — installing directly.'
: undefined,
}
}
return {
drizzle: resolved.drizzle,
useSupabaseInstallModeSelection: resolved.supabase,
}
}
/**
* `stash eql install` is the wrong tool in a Prisma Next project: Prisma Next
* contributes a `migrations/cipherstash/` control space that installs the EQL
* bundle as part of `prisma-next migration apply`, in the same ledger as the
* app schema. Running the standalone installer applies EQL out-of-band from
* that ledger. `stash init --prisma-next` already skips the installer; this
* guards the manual-invocation path too.
*
* Returns the guidance string when the install should be blocked, else null.
* `--force` overrides (an escape hatch for a deliberate standalone install).
* Pure + cwd-injected so it unit-tests without a real project.
*/
export function prismaNextInstallGuard(
cwd: string,
options: Pick<InstallOptions, 'force'>,
): string | null {
if (options.force) return null
if (!detectPrismaNext(cwd)) return null
return (
`${messages.eql.prismaNextDetected} (found prisma-next.config.* or @cipherstash/prisma-next). ` +
'Prisma Next installs the EQL bundle through its own migration system — run ' +
'`prisma-next migration apply` instead of `stash eql install`. ' +
'Pass --force to run the standalone installer against this database anyway.'
)
}
export function validateInstallFlags(options: InstallOptions): string | null {
if (options.migration && options.direct) {
return '`--migration` and `--direct` are mutually exclusive. Pick one.'
}
if (
options.eqlVersion !== undefined &&
options.eqlVersion !== '2' &&
options.eqlVersion !== '3'
) {
return `Unknown \`--eql-version ${options.eqlVersion}\`. Supported values: 2, 3.`
}
// `--migration` / `--direct` / `--migrations-dir` require `--supabase`. Check
// this before the version gate below so a bare `--migration` still points at
// the missing `--supabase` (its more fundamental prerequisite) rather than
// the version.
const subFlag =
options.migration === true
? '--migration'
: options.direct === true
? '--direct'
: options.migrationsDir !== undefined
? '--migrations-dir'
: null
if (subFlag !== null && options.supabase !== true) {
return `\`${subFlag}\` requires \`--supabase\`. Re-run with \`eql install --supabase ${subFlag}\`.`
}
// v3 is the default and installs via the direct path only. The Drizzle /
// Supabase-migration / `--latest` paths are v2-only, so they require an
// explicit `--eql-version 2` — otherwise they'd silently resolve to a v3
// direct install that ignores what the user asked for. `--migrations-dir`
// only feeds the Supabase v2 migration-file path, so it's in the same bucket.
const resolvedVersion = resolveEqlVersion(options.eqlVersion)
if (resolvedVersion === 3) {
const v2OnlyFlag = options.drizzle
? '--drizzle'
: options.migration
? '--migration'
: options.latest
? '--latest'
: options.migrationsDir !== undefined
? '--migrations-dir'
: null
if (v2OnlyFlag) {
return options.eqlVersion === '3'
? `\`--eql-version 3\` does not support \`${v2OnlyFlag}\` yet — v3 currently installs via the direct path only.`
: `\`${v2OnlyFlag}\` requires EQL v2. Re-run with \`--eql-version 2 ${v2OnlyFlag}\` (v3 is the default and installs via the direct path only).`
}
}
return null
}
/**
* Pick the Supabase install mode purely from inputs. No I/O, no prompts —
* easy to unit-test and to reason about.
*
* - Explicit `--migration` or `--direct` always wins.
* - Otherwise, when stdin isn't a TTY, default to `migration` if the
* `supabase/migrations/` directory exists and `direct` otherwise. This is
* the same heuristic the prompt uses for its default — keeps interactive
* and non-interactive runs aligned.
* - When stdin IS a TTY and neither flag is set, returns `null` to signal
* that the caller should prompt.
*/
export function chooseSupabaseInstallMode(
options: Pick<InstallOptions, 'migration' | 'direct'>,
projectInfo: SupabaseProjectInfo,
isTTY: boolean,
): SupabaseInstallMode | null {
if (options.migration) return 'migration'
if (options.direct) return 'direct'
if (!isTTY) return projectInfo.hasMigrationsDir ? 'migration' : 'direct'
return null
}
/**
* Resolve the install mode, prompting the user when stdin is a TTY and
* neither sub-flag was passed. Pure logic lives in
* {@link chooseSupabaseInstallMode}; this is the I/O wrapper.
*/
async function resolveSupabaseInstallMode(
options: InstallOptions,
projectInfo: SupabaseProjectInfo,
): Promise<SupabaseInstallMode> {
const interactive = isInteractive()
const decided = chooseSupabaseInstallMode(options, projectInfo, interactive)
if (decided !== null) {
if (
!interactive &&
options.migration === undefined &&
options.direct === undefined
) {
// Make non-interactive choices visible — surprise auto-decisions are a
// common debugging headache.
p.log.info(
projectInfo.hasMigrationsDir
? `Detected ${projectInfo.migrationsDir} — defaulting to --migration in non-interactive mode.`
: 'No supabase/migrations directory found — defaulting to --direct in non-interactive mode.',
)
}
return decided
}
const defaultMode: SupabaseInstallMode = projectInfo.hasMigrationsDir
? 'migration'
: 'direct'
const choice = await p.select<SupabaseInstallMode>({
message: 'How should EQL be installed?',
initialValue: defaultMode,
options: [
{
value: 'migration',
label: 'Write a Supabase migration file',
hint: projectInfo.hasMigrationsDir
? 'recommended — works with `supabase db reset`'
: 'creates supabase/migrations/ if missing',
},
{
value: 'direct',
label: 'Run the SQL directly against the database',
hint: 'fastest, but `supabase db reset` will not re-install EQL',
},
],
})
if (p.isCancel(choice)) {
p.cancel('Installation cancelled.')
process.exit(0)
}
return choice
}
/**
* Write the `00000000000000_cipherstash_eql.sql` migration to the project's
* Supabase migrations directory. Mirrors the structure of the Drizzle
* migration helper for parity in the user-facing flow.
*/
async function writeSupabaseMigrationFile(
s: ReturnType<typeof p.spinner>,
opts: {
projectInfo: SupabaseProjectInfo
force?: boolean
dryRun?: boolean
},
): Promise<void> {
const { projectInfo, force, dryRun } = opts
const targetPath = join(
projectInfo.migrationsDir,
SUPABASE_EQL_MIGRATION_FILENAME,
)
if (dryRun) {
p.log.info('Dry run — no changes will be made.')
p.note(
[
`Would write Supabase migration to:\n ${targetPath}`,
'',
'Apply with one of:',
' supabase db reset # local',
' supabase migration up # remote (or push)',
].join('\n'),
'Dry Run',
)
p.outro('Dry run complete.')
return
}
s.start('Writing CipherStash EQL migration...')
let result: { path: string; overwritten: boolean }
try {
result = await writeSupabaseEqlMigration({
migrationsDir: projectInfo.migrationsDir,
force,
})
} catch (error) {
s.stop('Failed to write Supabase migration.')
const message = error instanceof Error ? error.message : String(error)
p.log.error(message)
if (!force && message.includes('already exists')) {
p.log.info(
'Re-run with --force to overwrite the existing migration file.',
)
}
p.outro('Installation aborted.')
process.exit(1)
}
s.stop(
result.overwritten
? `Overwrote ${result.path}`
: `Migration created: ${result.path}`,
)
p.note(
[
'Apply the migration to install EQL:',
'',
' supabase db reset # local — re-runs all migrations',
' supabase migration up # remote — applies pending migrations',
'',
'EQL is NOT installed yet. The SQL only runs when Supabase applies the migration.',
].join('\n'),
'Next Steps',
)
printNextSteps()
p.outro('Done!')
}
/**
* Find the most recently generated migration file matching the given name.
* Drizzle-kit generates flat SQL files like `0000_install-eql.sql`.
*/
export async function findGeneratedMigration(
outDir: string,
migrationName: string,
): Promise<string> {
if (!existsSync(outDir)) {
throw new Error(
`Drizzle output directory not found: ${outDir}\nMake sure drizzle-kit is configured correctly.`,
)
}
const entries = await readdir(outDir)
const matchingFiles = entries
.filter((entry) => entry.endsWith('.sql') && entry.includes(migrationName))
.sort()
if (matchingFiles.length === 0) {
throw new Error(
`Could not find a migration matching "${migrationName}" in ${outDir}`,
)
}
return join(outDir, matchingFiles[matchingFiles.length - 1])
}
/**
* Attempt to clean up a generated migration file on failure.
*/
export function cleanupMigrationFile(filePath: string | undefined): void {
if (!filePath) return
try {
if (existsSync(filePath)) {
unlinkSync(filePath)
p.log.info(`Cleaned up migration file: ${filePath}`)
}
} catch {
p.log.warn(`Could not clean up migration file: ${filePath}`)
}
}