postgresai 0.14.0-beta.12 → 0.14.0-beta.14

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 (42) hide show
  1. package/README.md +32 -0
  2. package/bin/postgres-ai.ts +1234 -170
  3. package/dist/bin/postgres-ai.js +2480 -410
  4. package/dist/sql/02.extensions.sql +8 -0
  5. package/dist/sql/{02.permissions.sql → 03.permissions.sql} +1 -0
  6. package/dist/sql/sql/02.extensions.sql +8 -0
  7. package/dist/sql/sql/{02.permissions.sql → 03.permissions.sql} +1 -0
  8. package/dist/sql/sql/uninit/01.helpers.sql +5 -0
  9. package/dist/sql/sql/uninit/02.permissions.sql +30 -0
  10. package/dist/sql/sql/uninit/03.role.sql +27 -0
  11. package/dist/sql/uninit/01.helpers.sql +5 -0
  12. package/dist/sql/uninit/02.permissions.sql +30 -0
  13. package/dist/sql/uninit/03.role.sql +27 -0
  14. package/lib/checkup.ts +69 -3
  15. package/lib/init.ts +184 -26
  16. package/lib/issues.ts +453 -7
  17. package/lib/mcp-server.ts +180 -3
  18. package/lib/metrics-embedded.ts +3 -3
  19. package/lib/supabase.ts +824 -0
  20. package/package.json +1 -1
  21. package/sql/02.extensions.sql +8 -0
  22. package/sql/{02.permissions.sql → 03.permissions.sql} +1 -0
  23. package/sql/uninit/01.helpers.sql +5 -0
  24. package/sql/uninit/02.permissions.sql +30 -0
  25. package/sql/uninit/03.role.sql +27 -0
  26. package/test/checkup.test.ts +240 -14
  27. package/test/config-consistency.test.ts +36 -0
  28. package/test/init.integration.test.ts +80 -71
  29. package/test/init.test.ts +501 -2
  30. package/test/issues.cli.test.ts +224 -0
  31. package/test/mcp-server.test.ts +551 -12
  32. package/test/supabase.test.ts +568 -0
  33. package/test/test-utils.ts +6 -0
  34. /package/dist/sql/{03.optional_rds.sql → 04.optional_rds.sql} +0 -0
  35. /package/dist/sql/{04.optional_self_managed.sql → 05.optional_self_managed.sql} +0 -0
  36. /package/dist/sql/{05.helpers.sql → 06.helpers.sql} +0 -0
  37. /package/dist/sql/sql/{03.optional_rds.sql → 04.optional_rds.sql} +0 -0
  38. /package/dist/sql/sql/{04.optional_self_managed.sql → 05.optional_self_managed.sql} +0 -0
  39. /package/dist/sql/sql/{05.helpers.sql → 06.helpers.sql} +0 -0
  40. /package/sql/{03.optional_rds.sql → 04.optional_rds.sql} +0 -0
  41. /package/sql/{04.optional_self_managed.sql → 05.optional_self_managed.sql} +0 -0
  42. /package/sql/{05.helpers.sql → 06.helpers.sql} +0 -0
package/test/init.test.ts CHANGED
@@ -1,5 +1,7 @@
1
- import { describe, test, expect, beforeAll } from "bun:test";
1
+ import { describe, test, expect, beforeAll, afterAll } from "bun:test";
2
2
  import { resolve } from "path";
3
+ import * as fs from "fs";
4
+ import * as os from "os";
3
5
 
4
6
  // Import from source directly since we're using Bun
5
7
  import * as init from "../lib/init";
@@ -87,7 +89,7 @@ describe("init module", () => {
87
89
  expect(roleStep.sql).toMatch(/create\s+user\s+"user ""with"" quotes ✓"/i);
88
90
  expect(roleStep.sql).toMatch(/alter\s+user\s+"user ""with"" quotes ✓"/i);
89
91
 
90
- const permStep = plan.steps.find((s: { name: string }) => s.name === "02.permissions");
92
+ const permStep = plan.steps.find((s: { name: string }) => s.name === "03.permissions");
91
93
  expect(permStep).toBeTruthy();
92
94
  expect(permStep.sql).toMatch(/grant connect on database "db name ""with"" quotes ✓" to "user ""with"" quotes ✓"/i);
93
95
  });
@@ -150,6 +152,42 @@ describe("init module", () => {
150
152
  expect(plan.steps.some((s: { optional?: boolean }) => s.optional)).toBe(true);
151
153
  });
152
154
 
155
+ test("buildInitPlan skips role creation for supabase provider", async () => {
156
+ const plan = await init.buildInitPlan({
157
+ database: "mydb",
158
+ monitoringUser: DEFAULT_MONITORING_USER,
159
+ monitoringPassword: "pw",
160
+ includeOptionalPermissions: false,
161
+ provider: "supabase",
162
+ });
163
+ expect(plan.steps.some((s) => s.name === "01.role")).toBe(false);
164
+ expect(plan.steps.some((s) => s.name === "03.permissions")).toBe(true);
165
+ });
166
+
167
+ test("buildInitPlan removes ALTER USER for supabase provider", async () => {
168
+ const plan = await init.buildInitPlan({
169
+ database: "mydb",
170
+ monitoringUser: DEFAULT_MONITORING_USER,
171
+ monitoringPassword: "pw",
172
+ includeOptionalPermissions: false,
173
+ provider: "supabase",
174
+ });
175
+ const permStep = plan.steps.find((s) => s.name === "03.permissions");
176
+ expect(permStep).toBeDefined();
177
+ expect(permStep!.sql.toLowerCase()).not.toMatch(/alter user/);
178
+ });
179
+
180
+ test("buildInitPlan includes role creation for unknown provider", async () => {
181
+ const plan = await init.buildInitPlan({
182
+ database: "mydb",
183
+ monitoringUser: DEFAULT_MONITORING_USER,
184
+ monitoringPassword: "pw",
185
+ includeOptionalPermissions: false,
186
+ provider: "some-custom-provider",
187
+ });
188
+ expect(plan.steps.some((s) => s.name === "01.role")).toBe(true);
189
+ });
190
+
153
191
  test("resolveAdminConnection accepts positional URI", () => {
154
192
  const r = init.resolveAdminConnection({ conn: "postgresql://u:p@h:5432/d" });
155
193
  expect(r.clientConfig.connectionString).toBeTruthy();
@@ -288,6 +326,91 @@ describe("init module", () => {
288
326
  expect(calls[calls.length - 1].toLowerCase()).toBe("rollback;");
289
327
  });
290
328
 
329
+ test("verifyInitSetup skips search_path check for supabase provider", async () => {
330
+ const calls: string[] = [];
331
+ const client = {
332
+ query: async (sql: string, params?: any) => {
333
+ calls.push(String(sql));
334
+
335
+ if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
336
+ return { rowCount: 1, rows: [] };
337
+ }
338
+ if (String(sql).toLowerCase() === "rollback;") {
339
+ return { rowCount: 1, rows: [] };
340
+ }
341
+ // Return empty rolconfig - would fail without provider=supabase
342
+ if (String(sql).includes("select rolconfig")) {
343
+ return { rowCount: 1, rows: [{ rolconfig: null }] };
344
+ }
345
+ if (String(sql).includes("from pg_catalog.pg_roles")) {
346
+ return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
347
+ }
348
+ if (String(sql).includes("has_database_privilege")) {
349
+ return { rowCount: 1, rows: [{ ok: true }] };
350
+ }
351
+ if (String(sql).includes("pg_has_role")) {
352
+ return { rowCount: 1, rows: [{ ok: true }] };
353
+ }
354
+ if (String(sql).includes("has_table_privilege")) {
355
+ return { rowCount: 1, rows: [{ ok: true }] };
356
+ }
357
+ if (String(sql).includes("to_regclass")) {
358
+ return { rowCount: 1, rows: [{ ok: true }] };
359
+ }
360
+ if (String(sql).includes("has_function_privilege")) {
361
+ return { rowCount: 1, rows: [{ ok: true }] };
362
+ }
363
+ if (String(sql).includes("has_schema_privilege")) {
364
+ return { rowCount: 1, rows: [{ ok: true }] };
365
+ }
366
+
367
+ throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
368
+ },
369
+ };
370
+
371
+ // With provider=supabase, should pass even without search_path
372
+ const r = await init.verifyInitSetup({
373
+ client: client as any,
374
+ database: "mydb",
375
+ monitoringUser: DEFAULT_MONITORING_USER,
376
+ includeOptionalPermissions: false,
377
+ provider: "supabase",
378
+ });
379
+ expect(r.ok).toBe(true);
380
+ expect(r.missingRequired.length).toBe(0);
381
+ // Should not have queried for rolconfig since we skip search_path check
382
+ expect(calls.some((c) => c.includes("select rolconfig"))).toBe(false);
383
+ });
384
+
385
+ test("buildInitPlan preserves comments when filtering ALTER USER", async () => {
386
+ const plan = await init.buildInitPlan({
387
+ database: "mydb",
388
+ monitoringUser: DEFAULT_MONITORING_USER,
389
+ monitoringPassword: "pw",
390
+ includeOptionalPermissions: false,
391
+ provider: "supabase",
392
+ });
393
+ const permStep = plan.steps.find((s) => s.name === "03.permissions");
394
+ expect(permStep).toBeDefined();
395
+ // Should have removed ALTER USER but kept comments
396
+ expect(permStep!.sql.toLowerCase()).not.toMatch(/^\s*alter\s+user/m);
397
+ // Should still have comment lines
398
+ expect(permStep!.sql).toMatch(/^--/m);
399
+ });
400
+
401
+ test("validateProvider returns null for known providers", () => {
402
+ expect(init.validateProvider(undefined)).toBe(null);
403
+ expect(init.validateProvider("self-managed")).toBe(null);
404
+ expect(init.validateProvider("supabase")).toBe(null);
405
+ });
406
+
407
+ test("validateProvider returns warning for unknown providers", () => {
408
+ const warning = init.validateProvider("unknown-provider");
409
+ expect(warning).not.toBe(null);
410
+ expect(warning).toMatch(/Unknown provider/);
411
+ expect(warning).toMatch(/unknown-provider/);
412
+ });
413
+
291
414
  test("redactPasswordsInSql redacts password literals with embedded quotes", async () => {
292
415
  const plan = await init.buildInitPlan({
293
416
  database: "mydb",
@@ -300,6 +423,179 @@ describe("init module", () => {
300
423
  const redacted = init.redactPasswordsInSql(step.sql);
301
424
  expect(redacted).toMatch(/password '<redacted>'/i);
302
425
  });
426
+
427
+ // Tests for buildUninitPlan
428
+ test("buildUninitPlan generates correct steps with dropRole=true", async () => {
429
+ const plan = await init.buildUninitPlan({
430
+ database: "mydb",
431
+ monitoringUser: DEFAULT_MONITORING_USER,
432
+ dropRole: true,
433
+ });
434
+
435
+ expect(plan.database).toBe("mydb");
436
+ expect(plan.monitoringUser).toBe(DEFAULT_MONITORING_USER);
437
+ expect(plan.dropRole).toBe(true);
438
+ expect(plan.steps.length).toBe(3);
439
+ expect(plan.steps.map((s) => s.name)).toEqual([
440
+ "01.drop_helpers",
441
+ "02.revoke_permissions",
442
+ "03.drop_role",
443
+ ]);
444
+ });
445
+
446
+ test("buildUninitPlan skips role drop when dropRole=false", async () => {
447
+ const plan = await init.buildUninitPlan({
448
+ database: "mydb",
449
+ monitoringUser: DEFAULT_MONITORING_USER,
450
+ dropRole: false,
451
+ });
452
+
453
+ expect(plan.dropRole).toBe(false);
454
+ expect(plan.steps.length).toBe(2);
455
+ expect(plan.steps.map((s) => s.name)).toEqual([
456
+ "01.drop_helpers",
457
+ "02.revoke_permissions",
458
+ ]);
459
+ });
460
+
461
+ test("buildUninitPlan skips role drop for supabase provider", async () => {
462
+ const plan = await init.buildUninitPlan({
463
+ database: "mydb",
464
+ monitoringUser: DEFAULT_MONITORING_USER,
465
+ dropRole: true,
466
+ provider: "supabase",
467
+ });
468
+
469
+ // Even with dropRole=true, supabase provider skips role operations
470
+ expect(plan.steps.length).toBe(2);
471
+ expect(plan.steps.some((s) => s.name === "03.drop_role")).toBe(false);
472
+ });
473
+
474
+ test("buildUninitPlan handles special characters in identifiers", async () => {
475
+ const monitoringUser = 'user "with" quotes';
476
+ const database = 'db "name"';
477
+ const plan = await init.buildUninitPlan({
478
+ database,
479
+ monitoringUser,
480
+ dropRole: true,
481
+ });
482
+
483
+ // Check that identifiers are properly quoted in SQL
484
+ const dropHelpersStep = plan.steps.find((s) => s.name === "01.drop_helpers");
485
+ expect(dropHelpersStep).toBeTruthy();
486
+
487
+ const revokeStep = plan.steps.find((s) => s.name === "02.revoke_permissions");
488
+ expect(revokeStep).toBeTruthy();
489
+ expect(revokeStep!.sql).toContain('"user ""with"" quotes"');
490
+ expect(revokeStep!.sql).toContain('"db ""name"""');
491
+
492
+ const dropRoleStep = plan.steps.find((s) => s.name === "03.drop_role");
493
+ expect(dropRoleStep).toBeTruthy();
494
+ // Uses ROLE_LITERAL (single-quoted) for format('%I', ...) in dynamic SQL
495
+ expect(dropRoleStep!.sql).toContain("'user \"with\" quotes'");
496
+ });
497
+
498
+ test("buildUninitPlan rejects identifiers with null bytes", async () => {
499
+ await expect(
500
+ init.buildUninitPlan({
501
+ database: "mydb",
502
+ monitoringUser: "bad\0user",
503
+ dropRole: true,
504
+ })
505
+ ).rejects.toThrow(/Identifier cannot contain null bytes/);
506
+ });
507
+
508
+ test("applyUninitPlan continues on errors and reports them", async () => {
509
+ const plan = {
510
+ monitoringUser: DEFAULT_MONITORING_USER,
511
+ database: "mydb",
512
+ dropRole: true,
513
+ steps: [
514
+ { name: "01.drop_helpers", sql: "drop function if exists postgres_ai.test()" },
515
+ { name: "02.revoke_permissions", sql: "select 1/0" }, // Will fail
516
+ { name: "03.drop_role", sql: "select 1" },
517
+ ],
518
+ };
519
+
520
+ const calls: string[] = [];
521
+ const client = {
522
+ query: async (sql: string) => {
523
+ calls.push(sql);
524
+ if (sql === "begin;") return { rowCount: 1 };
525
+ if (sql === "commit;") return { rowCount: 1 };
526
+ if (sql === "rollback;") return { rowCount: 1 };
527
+ if (sql.includes("1/0")) throw new Error("division by zero");
528
+ return { rowCount: 1 };
529
+ },
530
+ };
531
+
532
+ const result = await init.applyUninitPlan({ client: client as any, plan: plan as any });
533
+
534
+ // Should have applied steps 1 and 3, with step 2 in errors
535
+ expect(result.applied).toContain("01.drop_helpers");
536
+ expect(result.applied).toContain("03.drop_role");
537
+ expect(result.applied).not.toContain("02.revoke_permissions");
538
+ expect(result.errors.length).toBe(1);
539
+ expect(result.errors[0]).toMatch(/02\.revoke_permissions.*division by zero/);
540
+ });
541
+
542
+ test("buildInitPlan includes 02.extensions step with pg_stat_statements", async () => {
543
+ const plan = await init.buildInitPlan({
544
+ database: "mydb",
545
+ monitoringUser: DEFAULT_MONITORING_USER,
546
+ monitoringPassword: "pw",
547
+ includeOptionalPermissions: false,
548
+ });
549
+
550
+ const extStep = plan.steps.find((s) => s.name === "02.extensions");
551
+ expect(extStep).toBeTruthy();
552
+ // Should create pg_stat_statements with IF NOT EXISTS
553
+ expect(extStep!.sql).toMatch(/create extension if not exists pg_stat_statements/i);
554
+ });
555
+
556
+ test("buildInitPlan creates extensions before permissions", async () => {
557
+ const plan = await init.buildInitPlan({
558
+ database: "mydb",
559
+ monitoringUser: DEFAULT_MONITORING_USER,
560
+ monitoringPassword: "pw",
561
+ includeOptionalPermissions: false,
562
+ });
563
+
564
+ const stepNames = plan.steps.map((s) => s.name);
565
+ const extIndex = stepNames.indexOf("02.extensions");
566
+ const permIndex = stepNames.indexOf("03.permissions");
567
+ expect(extIndex).toBeGreaterThanOrEqual(0);
568
+ expect(permIndex).toBeGreaterThanOrEqual(0);
569
+ // Extensions should come before permissions
570
+ expect(extIndex).toBeLessThan(permIndex);
571
+ });
572
+
573
+ test("buildInitPlan uses IF NOT EXISTS for postgres_ai schema (idempotent)", async () => {
574
+ const plan = await init.buildInitPlan({
575
+ database: "mydb",
576
+ monitoringUser: DEFAULT_MONITORING_USER,
577
+ monitoringPassword: "pw",
578
+ includeOptionalPermissions: false,
579
+ });
580
+
581
+ const permStep = plan.steps.find((s) => s.name === "03.permissions");
582
+ expect(permStep).toBeTruthy();
583
+ // Should use IF NOT EXISTS for idempotent behavior
584
+ expect(permStep!.sql).toMatch(/create schema if not exists postgres_ai/i);
585
+ });
586
+
587
+ test("buildUninitPlan does NOT drop pg_stat_statements extension", async () => {
588
+ const plan = await init.buildUninitPlan({
589
+ database: "mydb",
590
+ monitoringUser: DEFAULT_MONITORING_USER,
591
+ dropRole: true,
592
+ });
593
+
594
+ // Check all steps - none should drop pg_stat_statements
595
+ for (const step of plan.steps) {
596
+ expect(step.sql.toLowerCase()).not.toMatch(/drop extension.*pg_stat_statements/);
597
+ }
598
+ });
303
599
  });
304
600
 
305
601
  describe("CLI commands", () => {
@@ -317,6 +613,41 @@ describe("CLI commands", () => {
317
613
  expect(r.stdout).toMatch(new RegExp(`grant connect on database "mydb" to "${DEFAULT_MONITORING_USER}"`, "i"));
318
614
  });
319
615
 
616
+ test("cli: prepare-db --print-sql with --provider supabase skips role step", () => {
617
+ const r = runCli(["prepare-db", "--print-sql", "-d", "mydb", "--password", "monpw", "--provider", "supabase"]);
618
+ expect(r.status).toBe(0);
619
+ expect(r.stdout).toMatch(/provider: supabase/);
620
+ // Should not have 01.role step
621
+ expect(r.stdout).not.toMatch(/-- 01\.role/);
622
+ // Should have 02.extensions and 03.permissions steps
623
+ expect(r.stdout).toMatch(/-- 02\.extensions/);
624
+ expect(r.stdout).toMatch(/-- 03\.permissions/);
625
+ });
626
+
627
+ test("cli: prepare-db warns about unknown provider", () => {
628
+ const r = runCli(["prepare-db", "--print-sql", "-d", "mydb", "--password", "monpw", "--provider", "unknown-cloud"]);
629
+ expect(r.status).toBe(0);
630
+ // Should warn about unknown provider
631
+ expect(r.stderr).toMatch(/Unknown provider.*unknown-cloud/);
632
+ });
633
+
634
+ test("cli: prepare-db --reset-password with supabase provider would have no role step", async () => {
635
+ // When using supabase provider, the role creation step is skipped.
636
+ // This means --reset-password (which only runs 01.role) would have no steps.
637
+ // The CLI should error in this case. We test the underlying plan logic here.
638
+ const plan = await (await import("../lib/init")).buildInitPlan({
639
+ database: "mydb",
640
+ monitoringUser: "mon",
641
+ monitoringPassword: "pw",
642
+ includeOptionalPermissions: false,
643
+ provider: "supabase",
644
+ });
645
+ // Simulate what --reset-password does: filter to only 01.role step
646
+ const resetPasswordSteps = plan.steps.filter((s) => s.name === "01.role");
647
+ // For supabase, this should be empty (role creation is skipped)
648
+ expect(resetPasswordSteps.length).toBe(0);
649
+ });
650
+
320
651
  test("pgai wrapper forwards to postgresai CLI", () => {
321
652
  const r = runPgai(["--help"]);
322
653
  expect(r.status).toBe(0);
@@ -414,4 +745,172 @@ describe("CLI commands", () => {
414
745
  expect(r.status).not.toBe(0);
415
746
  expect(r.stderr).toMatch(/Cannot use --api-key with --demo mode/);
416
747
  });
748
+
749
+ // Tests for unprepare-db command
750
+ test("cli: unprepare-db with missing connection prints help/options", () => {
751
+ const r = runCli(["unprepare-db"]);
752
+ expect(r.status).not.toBe(0);
753
+ expect(r.stderr).toMatch(/--print-sql/);
754
+ expect(r.stderr).toMatch(/--monitoring-user/);
755
+ });
756
+
757
+ test("cli: unprepare-db --print-sql works without connection (offline mode)", () => {
758
+ const r = runCli(["unprepare-db", "--print-sql", "-d", "mydb"]);
759
+ expect(r.status).toBe(0);
760
+ expect(r.stdout).toMatch(/SQL plan \(offline; not connected\)/);
761
+ expect(r.stdout).toMatch(/drop schema if exists postgres_ai/i);
762
+ });
763
+
764
+ test("cli: unprepare-db --print-sql with --keep-role skips role drop", () => {
765
+ const r = runCli(["unprepare-db", "--print-sql", "-d", "mydb", "--keep-role"]);
766
+ expect(r.status).toBe(0);
767
+ expect(r.stdout).toMatch(/drop role: false/);
768
+ // Should not have 03.drop_role step
769
+ expect(r.stdout).not.toMatch(/-- 03\.drop_role/);
770
+ // Should have 01 and 02 steps
771
+ expect(r.stdout).toMatch(/-- 01\.drop_helpers/);
772
+ expect(r.stdout).toMatch(/-- 02\.revoke_permissions/);
773
+ });
774
+
775
+ test("cli: unprepare-db --print-sql with --provider supabase skips role step", () => {
776
+ const r = runCli(["unprepare-db", "--print-sql", "-d", "mydb", "--provider", "supabase"]);
777
+ expect(r.status).toBe(0);
778
+ expect(r.stdout).toMatch(/provider: supabase/);
779
+ // Should not have 03.drop_role step
780
+ expect(r.stdout).not.toMatch(/-- 03\.drop_role/);
781
+ });
782
+
783
+ test("cli: unprepare-db command exists and shows help", () => {
784
+ const r = runCli(["unprepare-db", "--help"]);
785
+ expect(r.status).toBe(0);
786
+ expect(r.stdout).toMatch(/--keep-role/);
787
+ expect(r.stdout).toMatch(/--print-sql/);
788
+ expect(r.stdout).toMatch(/--force/);
789
+ });
790
+ });
791
+
792
+ // Check if Docker is available for imageTag tests
793
+ function isDockerAvailable(): boolean {
794
+ try {
795
+ const result = Bun.spawnSync(["docker", "info"], { timeout: 5000 });
796
+ return result.exitCode === 0;
797
+ } catch {
798
+ return false;
799
+ }
800
+ }
801
+
802
+ const dockerAvailable = isDockerAvailable();
803
+
804
+ describe.skipIf(!dockerAvailable)("imageTag priority behavior", () => {
805
+ // Tests for the imageTag priority: --tag flag > PGAI_TAG env var > pkg.version
806
+ // This verifies the fix that prevents stale .env PGAI_TAG from being used
807
+ // These tests require Docker and spawn subprocesses so need longer timeout
808
+
809
+ let tempDir: string;
810
+
811
+ beforeAll(() => {
812
+ tempDir = fs.mkdtempSync(resolve(os.tmpdir(), "pgai-test-"));
813
+ });
814
+
815
+ afterAll(() => {
816
+ if (tempDir && fs.existsSync(tempDir)) {
817
+ fs.rmSync(tempDir, { recursive: true, force: true });
818
+ }
819
+ });
820
+
821
+ test("stale .env PGAI_TAG is NOT used - CLI version takes precedence", () => {
822
+ // Create a stale .env with an old tag value
823
+ const testDir = resolve(tempDir, "stale-tag-test");
824
+ fs.mkdirSync(testDir, { recursive: true });
825
+ fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=beta\n");
826
+ // Create minimal docker-compose.yml so resolvePaths() finds it
827
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
828
+
829
+ // Run from the test directory (so resolvePaths finds docker-compose.yml)
830
+ // Note: Command may hang on Docker check in CI without Docker, so we use a timeout
831
+ const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
832
+ const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
833
+ const result = Bun.spawnSync([bunBin, cliPath, "mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"], {
834
+ env: { ...process.env, PGAI_TAG: undefined },
835
+ cwd: testDir,
836
+ timeout: 30000, // Kill subprocess after 30s if it hangs on Docker
837
+ });
838
+
839
+ // Read the .env that was written
840
+ const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
841
+
842
+ // The .env should NOT contain the stale "beta" tag - it should use pkg.version
843
+ expect(envContent).not.toMatch(/PGAI_TAG=beta/);
844
+ // It should contain the CLI version (0.0.0-dev.0 in dev)
845
+ expect(envContent).toMatch(/PGAI_TAG=\d+\.\d+\.\d+|PGAI_TAG=0\.0\.0-dev/);
846
+ }, 60000);
847
+
848
+ test("--tag flag takes priority over pkg.version", () => {
849
+ const testDir = resolve(tempDir, "tag-flag-test");
850
+ fs.mkdirSync(testDir, { recursive: true });
851
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
852
+
853
+ const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
854
+ const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
855
+ const result = Bun.spawnSync([bunBin, cliPath, "mon", "local-install", "--tag", "v1.2.3-custom", "--db-url", "postgresql://u:p@h:5432/d", "--yes"], {
856
+ env: { ...process.env, PGAI_TAG: undefined },
857
+ cwd: testDir,
858
+ timeout: 30000,
859
+ });
860
+
861
+ const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
862
+ expect(envContent).toMatch(/PGAI_TAG=v1\.2\.3-custom/);
863
+
864
+ // Verify stdout confirms the tag being used
865
+ const stdout = new TextDecoder().decode(result.stdout);
866
+ expect(stdout).toMatch(/Using image tag: v1\.2\.3-custom/);
867
+ }, 60000);
868
+
869
+ test("PGAI_TAG env var is intentionally ignored (Bun auto-loads .env)", () => {
870
+ // Note: We do NOT use process.env.PGAI_TAG because Bun auto-loads .env files,
871
+ // which would cause stale .env values to pollute the environment.
872
+ // Users should use --tag flag to override, not env vars.
873
+ const testDir = resolve(tempDir, "env-var-ignored-test");
874
+ fs.mkdirSync(testDir, { recursive: true });
875
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
876
+
877
+ const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
878
+ const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
879
+ const result = Bun.spawnSync([bunBin, cliPath, "mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"], {
880
+ env: { ...process.env, PGAI_TAG: "v2.0.0-from-env" },
881
+ cwd: testDir,
882
+ timeout: 30000,
883
+ });
884
+
885
+ const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
886
+ // PGAI_TAG env var should be IGNORED - uses pkg.version instead
887
+ expect(envContent).not.toMatch(/PGAI_TAG=v2\.0\.0-from-env/);
888
+ expect(envContent).toMatch(/PGAI_TAG=\d+\.\d+\.\d+|PGAI_TAG=0\.0\.0-dev/);
889
+ }, 60000);
890
+
891
+ test("existing registry and password are preserved while tag is updated", () => {
892
+ const testDir = resolve(tempDir, "preserve-test");
893
+ fs.mkdirSync(testDir, { recursive: true });
894
+ // Create .env with stale tag but valid registry and password
895
+ fs.writeFileSync(resolve(testDir, ".env"),
896
+ "PGAI_TAG=stale-tag\nPGAI_REGISTRY=my.registry.com\nGF_SECURITY_ADMIN_PASSWORD=secret123\n");
897
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), "version: '3'\nservices: {}\n");
898
+
899
+ const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
900
+ const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
901
+ const result = Bun.spawnSync([bunBin, cliPath, "mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"], {
902
+ env: { ...process.env, PGAI_TAG: undefined },
903
+ cwd: testDir,
904
+ timeout: 30000,
905
+ });
906
+
907
+ const envContent = fs.readFileSync(resolve(testDir, ".env"), "utf8");
908
+
909
+ // Tag should be updated (not stale-tag)
910
+ expect(envContent).not.toMatch(/PGAI_TAG=stale-tag/);
911
+
912
+ // But registry and password should be preserved
913
+ expect(envContent).toMatch(/PGAI_REGISTRY=my\.registry\.com/);
914
+ expect(envContent).toMatch(/GF_SECURITY_ADMIN_PASSWORD=secret123/);
915
+ }, 60000);
417
916
  });