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
@@ -10,9 +10,10 @@ import * as os from "os";
10
10
  import * as crypto from "node:crypto";
11
11
  import { Client } from "pg";
12
12
  import { startMcpServer } from "../lib/mcp-server";
13
- import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment } from "../lib/issues";
13
+ import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment, fetchActionItem, fetchActionItems, createActionItem, updateActionItem, type ConfigChange } from "../lib/issues";
14
14
  import { resolveBaseUrls } from "../lib/util";
15
- import { applyInitPlan, buildInitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, verifyInitSetup } from "../lib/init";
15
+ import { applyInitPlan, applyUninitPlan, buildInitPlan, buildUninitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, KNOWN_PROVIDERS, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, validateProvider, verifyInitSetup } from "../lib/init";
16
+ import { SupabaseClient, resolveSupabaseConfig, extractProjectRefFromUrl, applyInitPlanViaSupabase, verifyInitSetupViaSupabase, fetchPoolerDatabaseUrl, type PgCompatibleError } from "../lib/supabase";
16
17
  import * as pkce from "../lib/pkce";
17
18
  import * as authServer from "../lib/auth-server";
18
19
  import { maskSecret } from "../lib/util";
@@ -564,10 +565,15 @@ program
564
565
  .option("--monitoring-user <name>", "Monitoring role name to create/update", DEFAULT_MONITORING_USER)
565
566
  .option("--password <password>", "Monitoring role password (overrides PGAI_MON_PASSWORD)")
566
567
  .option("--skip-optional-permissions", "Skip optional permissions (RDS/self-managed extras)", false)
568
+ .option("--provider <provider>", "Database provider (e.g., supabase). Affects which steps are executed.")
567
569
  .option("--verify", "Verify that monitoring role/permissions are in place (no changes)", false)
568
570
  .option("--reset-password", "Reset monitoring role password only (no other changes)", false)
569
571
  .option("--print-sql", "Print SQL plan and exit (no changes applied)", false)
570
572
  .option("--print-password", "Print generated monitoring password (DANGEROUS in CI logs)", false)
573
+ .option("--supabase", "Use Supabase Management API instead of direct PostgreSQL connection", false)
574
+ .option("--supabase-access-token <token>", "Supabase Management API access token (or SUPABASE_ACCESS_TOKEN env)")
575
+ .option("--supabase-project-ref <ref>", "Supabase project reference (or SUPABASE_PROJECT_REF env)")
576
+ .option("--json", "Output result as JSON (machine-readable)", false)
571
577
  .addHelpText(
572
578
  "after",
573
579
  [
@@ -607,6 +613,17 @@ program
607
613
  "",
608
614
  "Offline SQL plan (no DB connection):",
609
615
  " postgresai prepare-db --print-sql",
616
+ "",
617
+ "Supabase mode (use Management API instead of direct connection):",
618
+ " postgresai prepare-db --supabase --supabase-project-ref <ref>",
619
+ " SUPABASE_ACCESS_TOKEN=... postgresai prepare-db --supabase --supabase-project-ref <ref>",
620
+ "",
621
+ " Generate a token at: https://supabase.com/dashboard/account/tokens",
622
+ " Find your project ref in: https://supabase.com/dashboard/project/<ref>",
623
+ "",
624
+ "Provider-specific behavior (for direct connections):",
625
+ " --provider supabase Skip role creation (create user in Supabase dashboard)",
626
+ " Skip ALTER USER (restricted by Supabase)",
610
627
  ].join("\n")
611
628
  )
612
629
  .action(async (conn: string | undefined, opts: {
@@ -619,25 +636,63 @@ program
619
636
  monitoringUser: string;
620
637
  password?: string;
621
638
  skipOptionalPermissions?: boolean;
639
+ provider?: string;
622
640
  verify?: boolean;
623
641
  resetPassword?: boolean;
624
642
  printSql?: boolean;
625
643
  printPassword?: boolean;
644
+ supabase?: boolean;
645
+ supabaseAccessToken?: string;
646
+ supabaseProjectRef?: string;
647
+ json?: boolean;
626
648
  }, cmd: Command) => {
627
- if (opts.verify && opts.resetPassword) {
628
- console.error("✗ Provide only one of --verify or --reset-password");
649
+ // JSON output helper
650
+ const jsonOutput = opts.json;
651
+ const outputJson = (data: Record<string, unknown>) => {
652
+ console.log(JSON.stringify(data, null, 2));
653
+ };
654
+ const outputError = (error: {
655
+ message: string;
656
+ step?: string;
657
+ code?: string;
658
+ detail?: string;
659
+ hint?: string;
660
+ httpStatus?: number;
661
+ }) => {
662
+ if (jsonOutput) {
663
+ outputJson({
664
+ success: false,
665
+ mode: opts.supabase ? "supabase" : "direct",
666
+ error,
667
+ });
668
+ } else {
669
+ console.error(`Error: prepare-db${opts.supabase ? " (Supabase)" : ""}: ${error.message}`);
670
+ if (error.step) console.error(` Step: ${error.step}`);
671
+ if (error.code) console.error(` Code: ${error.code}`);
672
+ if (error.detail) console.error(` Detail: ${error.detail}`);
673
+ if (error.hint) console.error(` Hint: ${error.hint}`);
674
+ if (error.httpStatus) console.error(` HTTP Status: ${error.httpStatus}`);
675
+ }
629
676
  process.exitCode = 1;
677
+ };
678
+ if (opts.verify && opts.resetPassword) {
679
+ outputError({ message: "Provide only one of --verify or --reset-password" });
630
680
  return;
631
681
  }
632
682
  if (opts.verify && opts.printSql) {
633
- console.error("--verify cannot be combined with --print-sql");
634
- process.exitCode = 1;
683
+ outputError({ message: "--verify cannot be combined with --print-sql" });
635
684
  return;
636
685
  }
637
686
 
638
687
  const shouldPrintSql = !!opts.printSql;
639
688
  const redactPasswords = (sql: string): string => redactPasswordsInSql(sql);
640
689
 
690
+ // Validate provider and warn if unknown
691
+ const providerWarning = validateProvider(opts.provider);
692
+ if (providerWarning) {
693
+ console.warn(`⚠ ${providerWarning}`);
694
+ }
695
+
641
696
  // Offline mode: allow printing SQL without providing/using an admin connection.
642
697
  // Useful for audits/reviews; caller can provide -d/PGDATABASE.
643
698
  if (!conn && !opts.dbUrl && !opts.host && !opts.port && !opts.username && !opts.adminPassword) {
@@ -655,11 +710,13 @@ program
655
710
  monitoringUser: opts.monitoringUser,
656
711
  monitoringPassword: monPassword,
657
712
  includeOptionalPermissions,
713
+ provider: opts.provider,
658
714
  });
659
715
 
660
716
  console.log("\n--- SQL plan (offline; not connected) ---");
661
717
  console.log(`-- database: ${database}`);
662
718
  console.log(`-- monitoring user: ${opts.monitoringUser}`);
719
+ console.log(`-- provider: ${opts.provider ?? "self-managed"}`);
663
720
  console.log(`-- optional permissions: ${includeOptionalPermissions ? "enabled" : "skipped"}`);
664
721
  for (const step of plan.steps) {
665
722
  console.log(`\n-- ${step.name}${step.optional ? " (optional)" : ""}`);
@@ -671,6 +728,318 @@ program
671
728
  }
672
729
  }
673
730
 
731
+ // Supabase mode: use Supabase Management API instead of direct PG connection
732
+ if (opts.supabase) {
733
+ let supabaseConfig;
734
+ try {
735
+ // Try to extract project ref from connection URL if provided
736
+ let projectRef = opts.supabaseProjectRef;
737
+ if (!projectRef && conn) {
738
+ projectRef = extractProjectRefFromUrl(conn);
739
+ }
740
+ supabaseConfig = resolveSupabaseConfig({
741
+ accessToken: opts.supabaseAccessToken,
742
+ projectRef,
743
+ });
744
+ } catch (e) {
745
+ const msg = e instanceof Error ? e.message : String(e);
746
+ outputError({ message: msg });
747
+ return;
748
+ }
749
+
750
+ const includeOptionalPermissions = !opts.skipOptionalPermissions;
751
+
752
+ if (!jsonOutput) {
753
+ console.log(`Supabase mode: project ref ${supabaseConfig.projectRef}`);
754
+ console.log(`Monitoring user: ${opts.monitoringUser}`);
755
+ console.log(`Optional permissions: ${includeOptionalPermissions ? "enabled" : "skipped"}`);
756
+ }
757
+
758
+ const supabaseClient = new SupabaseClient(supabaseConfig);
759
+
760
+ // Fetch database URL for JSON output (non-blocking, best-effort)
761
+ let databaseUrl: string | null = null;
762
+ if (jsonOutput) {
763
+ databaseUrl = await fetchPoolerDatabaseUrl(supabaseConfig, opts.monitoringUser);
764
+ }
765
+
766
+ try {
767
+ // Get current database name
768
+ const database = await supabaseClient.getCurrentDatabase();
769
+ if (!database) {
770
+ throw new Error("Failed to resolve current database name");
771
+ }
772
+ if (!jsonOutput) {
773
+ console.log(`Database: ${database}`);
774
+ }
775
+
776
+ if (opts.verify) {
777
+ const v = await verifyInitSetupViaSupabase({
778
+ client: supabaseClient,
779
+ database,
780
+ monitoringUser: opts.monitoringUser,
781
+ includeOptionalPermissions,
782
+ });
783
+ if (v.ok) {
784
+ if (jsonOutput) {
785
+ const result: Record<string, unknown> = {
786
+ success: true,
787
+ mode: "supabase",
788
+ action: "verify",
789
+ database,
790
+ monitoringUser: opts.monitoringUser,
791
+ verified: true,
792
+ missingOptional: v.missingOptional,
793
+ };
794
+ if (databaseUrl) {
795
+ result.databaseUrl = databaseUrl;
796
+ }
797
+ outputJson(result);
798
+ } else {
799
+ console.log("✓ prepare-db verify: OK");
800
+ if (v.missingOptional.length > 0) {
801
+ console.log("⚠ Optional items missing:");
802
+ for (const m of v.missingOptional) console.log(`- ${m}`);
803
+ }
804
+ }
805
+ return;
806
+ }
807
+ if (jsonOutput) {
808
+ const result: Record<string, unknown> = {
809
+ success: false,
810
+ mode: "supabase",
811
+ action: "verify",
812
+ database,
813
+ monitoringUser: opts.monitoringUser,
814
+ verified: false,
815
+ missingRequired: v.missingRequired,
816
+ missingOptional: v.missingOptional,
817
+ };
818
+ if (databaseUrl) {
819
+ result.databaseUrl = databaseUrl;
820
+ }
821
+ outputJson(result);
822
+ } else {
823
+ console.error("✗ prepare-db verify failed: missing required items");
824
+ for (const m of v.missingRequired) console.error(`- ${m}`);
825
+ if (v.missingOptional.length > 0) {
826
+ console.error("Optional items missing:");
827
+ for (const m of v.missingOptional) console.error(`- ${m}`);
828
+ }
829
+ }
830
+ process.exitCode = 1;
831
+ return;
832
+ }
833
+
834
+ let monPassword: string;
835
+ let passwordGenerated = false;
836
+ try {
837
+ const resolved = await resolveMonitoringPassword({
838
+ passwordFlag: opts.password,
839
+ passwordEnv: process.env.PGAI_MON_PASSWORD,
840
+ monitoringUser: opts.monitoringUser,
841
+ });
842
+ monPassword = resolved.password;
843
+ passwordGenerated = resolved.generated;
844
+ if (resolved.generated) {
845
+ const canPrint = process.stdout.isTTY || !!opts.printPassword || jsonOutput;
846
+ if (canPrint) {
847
+ if (!jsonOutput) {
848
+ const shellSafe = monPassword.replace(/'/g, "'\\''");
849
+ console.error("");
850
+ console.error(`Generated monitoring password for ${opts.monitoringUser} (copy/paste):`);
851
+ console.error(`PGAI_MON_PASSWORD='${shellSafe}'`);
852
+ console.error("");
853
+ console.log("Store it securely (or rerun with --password / PGAI_MON_PASSWORD to set your own).");
854
+ }
855
+ // For JSON mode, password will be included in the success output below
856
+ } else {
857
+ console.error(
858
+ [
859
+ `✗ Monitoring password was auto-generated for ${opts.monitoringUser} but not printed in non-interactive mode.`,
860
+ "",
861
+ "Provide it explicitly:",
862
+ " --password <password> or PGAI_MON_PASSWORD=...",
863
+ "",
864
+ "Or (NOT recommended) print the generated password:",
865
+ " --print-password",
866
+ ].join("\n")
867
+ );
868
+ process.exitCode = 1;
869
+ return;
870
+ }
871
+ }
872
+ } catch (e) {
873
+ const msg = e instanceof Error ? e.message : String(e);
874
+ outputError({ message: msg });
875
+ return;
876
+ }
877
+
878
+ const plan = await buildInitPlan({
879
+ database,
880
+ monitoringUser: opts.monitoringUser,
881
+ monitoringPassword: monPassword,
882
+ includeOptionalPermissions,
883
+ });
884
+
885
+ // For Supabase mode, skip RDS and self-managed steps (they don't apply)
886
+ const supabaseApplicableSteps = plan.steps.filter(
887
+ (s) => s.name !== "03.optional_rds" && s.name !== "04.optional_self_managed"
888
+ );
889
+
890
+ const effectivePlan = opts.resetPassword
891
+ ? { ...plan, steps: supabaseApplicableSteps.filter((s) => s.name === "01.role") }
892
+ : { ...plan, steps: supabaseApplicableSteps };
893
+
894
+ if (shouldPrintSql) {
895
+ console.log("\n--- SQL plan ---");
896
+ for (const step of effectivePlan.steps) {
897
+ console.log(`\n-- ${step.name}${step.optional ? " (optional)" : ""}`);
898
+ console.log(redactPasswords(step.sql));
899
+ }
900
+ console.log("\n--- end SQL plan ---\n");
901
+ console.log("Note: passwords are redacted in the printed SQL output.");
902
+ return;
903
+ }
904
+
905
+ const { applied, skippedOptional } = await applyInitPlanViaSupabase({
906
+ client: supabaseClient,
907
+ plan: effectivePlan,
908
+ });
909
+
910
+ if (jsonOutput) {
911
+ const result: Record<string, unknown> = {
912
+ success: true,
913
+ mode: "supabase",
914
+ action: opts.resetPassword ? "reset-password" : "apply",
915
+ database,
916
+ monitoringUser: opts.monitoringUser,
917
+ applied,
918
+ skippedOptional,
919
+ warnings: skippedOptional.length > 0
920
+ ? ["Some optional steps were skipped (not supported or insufficient privileges)"]
921
+ : [],
922
+ };
923
+ if (passwordGenerated) {
924
+ result.generatedPassword = monPassword;
925
+ }
926
+ if (databaseUrl) {
927
+ result.databaseUrl = databaseUrl;
928
+ }
929
+ outputJson(result);
930
+ } else {
931
+ console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
932
+ if (skippedOptional.length > 0) {
933
+ console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
934
+ for (const s of skippedOptional) console.log(`- ${s}`);
935
+ }
936
+ if (process.stdout.isTTY) {
937
+ console.log(`Applied ${applied.length} steps`);
938
+ }
939
+ }
940
+ } catch (error) {
941
+ const errAny = error as PgCompatibleError;
942
+ let message = "";
943
+ if (error instanceof Error && error.message) {
944
+ message = error.message;
945
+ } else if (errAny && typeof errAny === "object" && typeof errAny.message === "string" && errAny.message) {
946
+ message = errAny.message;
947
+ } else {
948
+ message = String(error);
949
+ }
950
+ if (!message || message === "[object Object]") {
951
+ message = "Unknown error";
952
+ }
953
+
954
+ // Surface step name if this was a plan step failure
955
+ const stepMatch = typeof message === "string" ? message.match(/Failed at step "([^"]+)":/i) : null;
956
+ const failedStep = stepMatch?.[1];
957
+
958
+ // Build error object for JSON output
959
+ const errorObj: {
960
+ message: string;
961
+ step?: string;
962
+ code?: string;
963
+ detail?: string;
964
+ hint?: string;
965
+ httpStatus?: number;
966
+ } = { message };
967
+
968
+ if (failedStep) errorObj.step = failedStep;
969
+ if (errAny && typeof errAny === "object") {
970
+ if (typeof errAny.code === "string" && errAny.code) errorObj.code = errAny.code;
971
+ if (typeof errAny.detail === "string" && errAny.detail) errorObj.detail = errAny.detail;
972
+ if (typeof errAny.hint === "string" && errAny.hint) errorObj.hint = errAny.hint;
973
+ if (typeof errAny.httpStatus === "number") errorObj.httpStatus = errAny.httpStatus;
974
+ }
975
+
976
+ if (jsonOutput) {
977
+ outputJson({
978
+ success: false,
979
+ mode: "supabase",
980
+ error: errorObj,
981
+ });
982
+ process.exitCode = 1;
983
+ } else {
984
+ console.error(`Error: prepare-db (Supabase): ${message}`);
985
+
986
+ if (failedStep) {
987
+ console.error(` Step: ${failedStep}`);
988
+ }
989
+
990
+ // Surface PostgreSQL-compatible error details
991
+ if (errAny && typeof errAny === "object") {
992
+ if (typeof errAny.code === "string" && errAny.code) {
993
+ console.error(` Code: ${errAny.code}`);
994
+ }
995
+ if (typeof errAny.detail === "string" && errAny.detail) {
996
+ console.error(` Detail: ${errAny.detail}`);
997
+ }
998
+ if (typeof errAny.hint === "string" && errAny.hint) {
999
+ console.error(` Hint: ${errAny.hint}`);
1000
+ }
1001
+ if (typeof errAny.httpStatus === "number") {
1002
+ console.error(` HTTP Status: ${errAny.httpStatus}`);
1003
+ }
1004
+ }
1005
+
1006
+ // Provide context hints for common errors
1007
+ if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
1008
+ if (errAny.code === "42501") {
1009
+ if (failedStep === "01.role") {
1010
+ console.error(" Context: role creation/update requires CREATEROLE or superuser");
1011
+ } else if (failedStep === "03.permissions") {
1012
+ console.error(" Context: grants/view/search_path require sufficient GRANT/DDL privileges");
1013
+ }
1014
+ console.error(" Fix: ensure your Supabase access token has sufficient permissions");
1015
+ console.error(" Tip: run with --print-sql to review the exact SQL plan");
1016
+ }
1017
+ // Schema already exists (42P06) or other duplicate object errors
1018
+ if (errAny.code === "42P06" || (message.includes("already exists") && failedStep === "03.permissions")) {
1019
+ console.error(" Hint: postgres_ai schema or objects already exist from a previous setup.");
1020
+ console.error(" Fix: run 'postgresai unprepare-db <connection>' first to clean up, then retry prepare-db.");
1021
+ }
1022
+ }
1023
+ if (errAny && typeof errAny === "object" && typeof errAny.httpStatus === "number") {
1024
+ if (errAny.httpStatus === 401) {
1025
+ console.error(" Hint: invalid or expired access token; generate a new one at https://supabase.com/dashboard/account/tokens");
1026
+ }
1027
+ if (errAny.httpStatus === 403) {
1028
+ console.error(" Hint: access denied; check your token permissions and project access");
1029
+ }
1030
+ if (errAny.httpStatus === 404) {
1031
+ console.error(" Hint: project not found; verify the project reference is correct");
1032
+ }
1033
+ if (errAny.httpStatus === 429) {
1034
+ console.error(" Hint: rate limited; wait a moment and try again");
1035
+ }
1036
+ }
1037
+ process.exitCode = 1;
1038
+ }
1039
+ }
1040
+ return;
1041
+ }
1042
+
674
1043
  let adminConn;
675
1044
  try {
676
1045
  adminConn = resolveAdminConnection({
@@ -686,21 +1055,27 @@ program
686
1055
  });
687
1056
  } catch (e) {
688
1057
  const msg = e instanceof Error ? e.message : String(e);
689
- console.error(`Error: prepare-db: ${msg}`);
690
- // When connection details are missing, show full init help (options + examples).
691
- if (typeof msg === "string" && msg.startsWith("Connection is required.")) {
692
- console.error("");
693
- cmd.outputHelp({ error: true });
1058
+ if (jsonOutput) {
1059
+ outputError({ message: msg });
1060
+ } else {
1061
+ console.error(`Error: prepare-db: ${msg}`);
1062
+ // When connection details are missing, show full init help (options + examples).
1063
+ if (typeof msg === "string" && msg.startsWith("Connection is required.")) {
1064
+ console.error("");
1065
+ cmd.outputHelp({ error: true });
1066
+ }
1067
+ process.exitCode = 1;
694
1068
  }
695
- process.exitCode = 1;
696
1069
  return;
697
1070
  }
698
1071
 
699
1072
  const includeOptionalPermissions = !opts.skipOptionalPermissions;
700
1073
 
701
- console.log(`Connecting to: ${adminConn.display}`);
702
- console.log(`Monitoring user: ${opts.monitoringUser}`);
703
- console.log(`Optional permissions: ${includeOptionalPermissions ? "enabled" : "skipped"}`);
1074
+ if (!jsonOutput) {
1075
+ console.log(`Connecting to: ${adminConn.display}`);
1076
+ console.log(`Monitoring user: ${opts.monitoringUser}`);
1077
+ console.log(`Optional permissions: ${includeOptionalPermissions ? "enabled" : "skipped"}`);
1078
+ }
704
1079
 
705
1080
  // Use native pg client instead of requiring psql to be installed
706
1081
  let client: Client | undefined;
@@ -720,26 +1095,54 @@ program
720
1095
  database,
721
1096
  monitoringUser: opts.monitoringUser,
722
1097
  includeOptionalPermissions,
1098
+ provider: opts.provider,
723
1099
  });
724
1100
  if (v.ok) {
725
- console.log("✓ prepare-db verify: OK");
726
- if (v.missingOptional.length > 0) {
727
- console.log("⚠ Optional items missing:");
728
- for (const m of v.missingOptional) console.log(`- ${m}`);
1101
+ if (jsonOutput) {
1102
+ outputJson({
1103
+ success: true,
1104
+ mode: "direct",
1105
+ action: "verify",
1106
+ database,
1107
+ monitoringUser: opts.monitoringUser,
1108
+ provider: opts.provider,
1109
+ verified: true,
1110
+ missingOptional: v.missingOptional,
1111
+ });
1112
+ } else {
1113
+ console.log(`✓ prepare-db verify: OK${opts.provider ? ` (provider: ${opts.provider})` : ""}`);
1114
+ if (v.missingOptional.length > 0) {
1115
+ console.log("⚠ Optional items missing:");
1116
+ for (const m of v.missingOptional) console.log(`- ${m}`);
1117
+ }
729
1118
  }
730
1119
  return;
731
1120
  }
732
- console.error("✗ prepare-db verify failed: missing required items");
733
- for (const m of v.missingRequired) console.error(`- ${m}`);
734
- if (v.missingOptional.length > 0) {
735
- console.error("Optional items missing:");
736
- for (const m of v.missingOptional) console.error(`- ${m}`);
1121
+ if (jsonOutput) {
1122
+ outputJson({
1123
+ success: false,
1124
+ mode: "direct",
1125
+ action: "verify",
1126
+ database,
1127
+ monitoringUser: opts.monitoringUser,
1128
+ verified: false,
1129
+ missingRequired: v.missingRequired,
1130
+ missingOptional: v.missingOptional,
1131
+ });
1132
+ } else {
1133
+ console.error("✗ prepare-db verify failed: missing required items");
1134
+ for (const m of v.missingRequired) console.error(`- ${m}`);
1135
+ if (v.missingOptional.length > 0) {
1136
+ console.error("Optional items missing:");
1137
+ for (const m of v.missingOptional) console.error(`- ${m}`);
1138
+ }
737
1139
  }
738
1140
  process.exitCode = 1;
739
1141
  return;
740
1142
  }
741
1143
 
742
1144
  let monPassword: string;
1145
+ let passwordGenerated = false;
743
1146
  try {
744
1147
  const resolved = await resolveMonitoringPassword({
745
1148
  passwordFlag: opts.password,
@@ -747,17 +1150,21 @@ program
747
1150
  monitoringUser: opts.monitoringUser,
748
1151
  });
749
1152
  monPassword = resolved.password;
1153
+ passwordGenerated = resolved.generated;
750
1154
  if (resolved.generated) {
751
- const canPrint = process.stdout.isTTY || !!opts.printPassword;
1155
+ const canPrint = process.stdout.isTTY || !!opts.printPassword || jsonOutput;
752
1156
  if (canPrint) {
753
- // Print secrets to stderr to reduce the chance they end up in piped stdout logs.
754
- const shellSafe = monPassword.replace(/'/g, "'\\''");
755
- console.error("");
756
- console.error(`Generated monitoring password for ${opts.monitoringUser} (copy/paste):`);
757
- // Quote for shell copy/paste safety.
758
- console.error(`PGAI_MON_PASSWORD='${shellSafe}'`);
759
- console.error("");
760
- console.log("Store it securely (or rerun with --password / PGAI_MON_PASSWORD to set your own).");
1157
+ if (!jsonOutput) {
1158
+ // Print secrets to stderr to reduce the chance they end up in piped stdout logs.
1159
+ const shellSafe = monPassword.replace(/'/g, "'\\''");
1160
+ console.error("");
1161
+ console.error(`Generated monitoring password for ${opts.monitoringUser} (copy/paste):`);
1162
+ // Quote for shell copy/paste safety.
1163
+ console.error(`PGAI_MON_PASSWORD='${shellSafe}'`);
1164
+ console.error("");
1165
+ console.log("Store it securely (or rerun with --password / PGAI_MON_PASSWORD to set your own).");
1166
+ }
1167
+ // For JSON mode, password will be included in the success output below
761
1168
  } else {
762
1169
  console.error(
763
1170
  [
@@ -776,8 +1183,7 @@ program
776
1183
  }
777
1184
  } catch (e) {
778
1185
  const msg = e instanceof Error ? e.message : String(e);
779
- console.error(`✗ ${msg}`);
780
- process.exitCode = 1;
1186
+ outputError({ message: msg });
781
1187
  return;
782
1188
  }
783
1189
 
@@ -786,12 +1192,21 @@ program
786
1192
  monitoringUser: opts.monitoringUser,
787
1193
  monitoringPassword: monPassword,
788
1194
  includeOptionalPermissions,
1195
+ provider: opts.provider,
789
1196
  });
790
1197
 
1198
+ // For reset-password, we only want the role step. But if provider skips role creation,
1199
+ // reset-password doesn't make sense - warn the user.
791
1200
  const effectivePlan = opts.resetPassword
792
1201
  ? { ...plan, steps: plan.steps.filter((s) => s.name === "01.role") }
793
1202
  : plan;
794
1203
 
1204
+ if (opts.resetPassword && effectivePlan.steps.length === 0) {
1205
+ console.error(`✗ --reset-password not supported for provider "${opts.provider}" (role creation is skipped)`);
1206
+ process.exitCode = 1;
1207
+ return;
1208
+ }
1209
+
795
1210
  if (shouldPrintSql) {
796
1211
  console.log("\n--- SQL plan ---");
797
1212
  for (const step of effectivePlan.steps) {
@@ -805,14 +1220,33 @@ program
805
1220
 
806
1221
  const { applied, skippedOptional } = await applyInitPlan({ client, plan: effectivePlan });
807
1222
 
808
- console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
809
- if (skippedOptional.length > 0) {
810
- console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
811
- for (const s of skippedOptional) console.log(`- ${s}`);
812
- }
813
- // Keep output compact but still useful
814
- if (process.stdout.isTTY) {
815
- console.log(`Applied ${applied.length} steps`);
1223
+ if (jsonOutput) {
1224
+ const result: Record<string, unknown> = {
1225
+ success: true,
1226
+ mode: "direct",
1227
+ action: opts.resetPassword ? "reset-password" : "apply",
1228
+ database,
1229
+ monitoringUser: opts.monitoringUser,
1230
+ applied,
1231
+ skippedOptional,
1232
+ warnings: skippedOptional.length > 0
1233
+ ? ["Some optional steps were skipped (not supported or insufficient privileges)"]
1234
+ : [],
1235
+ };
1236
+ if (passwordGenerated) {
1237
+ result.generatedPassword = monPassword;
1238
+ }
1239
+ outputJson(result);
1240
+ } else {
1241
+ console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
1242
+ if (skippedOptional.length > 0) {
1243
+ console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
1244
+ for (const s of skippedOptional) console.log(`- ${s}`);
1245
+ }
1246
+ // Keep output compact but still useful
1247
+ if (process.stdout.isTTY) {
1248
+ console.log(`Applied ${applied.length} steps`);
1249
+ }
816
1250
  }
817
1251
  } catch (error) {
818
1252
  const errAny = error as any;
@@ -827,47 +1261,374 @@ program
827
1261
  if (!message || message === "[object Object]") {
828
1262
  message = "Unknown error";
829
1263
  }
830
- console.error(`Error: prepare-db: ${message}`);
1264
+
831
1265
  // If this was a plan step failure, surface the step name explicitly to help users diagnose quickly.
832
1266
  const stepMatch =
833
1267
  typeof message === "string" ? message.match(/Failed at step "([^"]+)":/i) : null;
834
1268
  const failedStep = stepMatch?.[1];
835
- if (failedStep) {
836
- console.error(` Step: ${failedStep}`);
837
- }
1269
+
1270
+ // Build error object for JSON output
1271
+ const errorObj: {
1272
+ message: string;
1273
+ step?: string;
1274
+ code?: string;
1275
+ detail?: string;
1276
+ hint?: string;
1277
+ } = { message };
1278
+
1279
+ if (failedStep) errorObj.step = failedStep;
838
1280
  if (errAny && typeof errAny === "object") {
839
- if (typeof errAny.code === "string" && errAny.code) {
840
- console.error(` Code: ${errAny.code}`);
1281
+ if (typeof errAny.code === "string" && errAny.code) errorObj.code = errAny.code;
1282
+ if (typeof errAny.detail === "string" && errAny.detail) errorObj.detail = errAny.detail;
1283
+ if (typeof errAny.hint === "string" && errAny.hint) errorObj.hint = errAny.hint;
1284
+ }
1285
+
1286
+ if (jsonOutput) {
1287
+ outputJson({
1288
+ success: false,
1289
+ mode: "direct",
1290
+ error: errorObj,
1291
+ });
1292
+ process.exitCode = 1;
1293
+ } else {
1294
+ console.error(`Error: prepare-db: ${message}`);
1295
+ if (failedStep) {
1296
+ console.error(` Step: ${failedStep}`);
1297
+ }
1298
+ if (errAny && typeof errAny === "object") {
1299
+ if (typeof errAny.code === "string" && errAny.code) {
1300
+ console.error(` Code: ${errAny.code}`);
1301
+ }
1302
+ if (typeof errAny.detail === "string" && errAny.detail) {
1303
+ console.error(` Detail: ${errAny.detail}`);
1304
+ }
1305
+ if (typeof errAny.hint === "string" && errAny.hint) {
1306
+ console.error(` Hint: ${errAny.hint}`);
1307
+ }
1308
+ }
1309
+ if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
1310
+ if (errAny.code === "42501") {
1311
+ if (failedStep === "01.role") {
1312
+ console.error(" Context: role creation/update requires CREATEROLE or superuser");
1313
+ } else if (failedStep === "03.permissions") {
1314
+ console.error(" Context: grants/view/search_path require sufficient GRANT/DDL privileges");
1315
+ }
1316
+ console.error(" Fix: connect as a superuser (or a role with CREATEROLE and sufficient GRANT privileges)");
1317
+ console.error(" Fix: on managed Postgres, use the provider's admin/master user");
1318
+ console.error(" Tip: run with --print-sql to review the exact SQL plan");
1319
+ }
1320
+ // Schema already exists (42P06) or other duplicate object errors
1321
+ if (errAny.code === "42P06" || (message.includes("already exists") && failedStep === "03.permissions")) {
1322
+ console.error(" Hint: postgres_ai schema or objects already exist from a previous setup.");
1323
+ console.error(" Fix: run 'postgresai unprepare-db <connection>' first to clean up, then retry prepare-db.");
1324
+ }
1325
+ if (errAny.code === "ECONNREFUSED") {
1326
+ console.error(" Hint: check host/port and ensure Postgres is reachable from this machine");
1327
+ }
1328
+ if (errAny.code === "ENOTFOUND") {
1329
+ console.error(" Hint: DNS resolution failed; double-check the host name");
1330
+ }
1331
+ if (errAny.code === "ETIMEDOUT") {
1332
+ console.error(" Hint: connection timed out; check network/firewall rules");
1333
+ }
1334
+ }
1335
+ process.exitCode = 1;
1336
+ }
1337
+ } finally {
1338
+ if (client) {
1339
+ try {
1340
+ await client.end();
1341
+ } catch {
1342
+ // ignore
1343
+ }
1344
+ }
1345
+ }
1346
+ });
1347
+
1348
+ program
1349
+ .command("unprepare-db [conn]")
1350
+ .description("remove monitoring setup: drop monitoring user, views, schema, and revoke permissions")
1351
+ .option("--db-url <url>", "PostgreSQL connection URL (admin) (deprecated; pass it as positional arg)")
1352
+ .option("-h, --host <host>", "PostgreSQL host (psql-like)")
1353
+ .option("-p, --port <port>", "PostgreSQL port (psql-like)")
1354
+ .option("-U, --username <username>", "PostgreSQL user (psql-like)")
1355
+ .option("-d, --dbname <dbname>", "PostgreSQL database name (psql-like)")
1356
+ .option("--admin-password <password>", "Admin connection password (otherwise uses PGPASSWORD if set)")
1357
+ .option("--monitoring-user <name>", "Monitoring role name to remove", DEFAULT_MONITORING_USER)
1358
+ .option("--keep-role", "Keep the monitoring role (only revoke permissions and drop objects)", false)
1359
+ .option("--provider <provider>", "Database provider (e.g., supabase). Affects which steps are executed.")
1360
+ .option("--print-sql", "Print SQL plan and exit (no changes applied)", false)
1361
+ .option("--force", "Skip confirmation prompt", false)
1362
+ .option("--json", "Output result as JSON (machine-readable)", false)
1363
+ .addHelpText(
1364
+ "after",
1365
+ [
1366
+ "",
1367
+ "Examples:",
1368
+ " postgresai unprepare-db postgresql://admin@host:5432/dbname",
1369
+ " postgresai unprepare-db \"dbname=dbname host=host user=admin\"",
1370
+ " postgresai unprepare-db -h host -p 5432 -U admin -d dbname",
1371
+ "",
1372
+ "Admin password:",
1373
+ " --admin-password <password> or PGPASSWORD=... (libpq standard)",
1374
+ "",
1375
+ "Keep role but remove objects/permissions:",
1376
+ " postgresai unprepare-db <conn> --keep-role",
1377
+ "",
1378
+ "Inspect SQL without applying changes:",
1379
+ " postgresai unprepare-db <conn> --print-sql",
1380
+ "",
1381
+ "Offline SQL plan (no DB connection):",
1382
+ " postgresai unprepare-db --print-sql",
1383
+ "",
1384
+ "Skip confirmation prompt:",
1385
+ " postgresai unprepare-db <conn> --force",
1386
+ ].join("\n")
1387
+ )
1388
+ .action(async (conn: string | undefined, opts: {
1389
+ dbUrl?: string;
1390
+ host?: string;
1391
+ port?: string;
1392
+ username?: string;
1393
+ dbname?: string;
1394
+ adminPassword?: string;
1395
+ monitoringUser: string;
1396
+ keepRole?: boolean;
1397
+ provider?: string;
1398
+ printSql?: boolean;
1399
+ force?: boolean;
1400
+ json?: boolean;
1401
+ }, cmd: Command) => {
1402
+ // JSON output helper
1403
+ const jsonOutput = opts.json;
1404
+ const outputJson = (data: Record<string, unknown>) => {
1405
+ console.log(JSON.stringify(data, null, 2));
1406
+ };
1407
+ const outputError = (error: {
1408
+ message: string;
1409
+ step?: string;
1410
+ code?: string;
1411
+ detail?: string;
1412
+ hint?: string;
1413
+ }) => {
1414
+ if (jsonOutput) {
1415
+ outputJson({
1416
+ success: false,
1417
+ error,
1418
+ });
1419
+ } else {
1420
+ console.error(`Error: unprepare-db: ${error.message}`);
1421
+ if (error.step) console.error(` Step: ${error.step}`);
1422
+ if (error.code) console.error(` Code: ${error.code}`);
1423
+ if (error.detail) console.error(` Detail: ${error.detail}`);
1424
+ if (error.hint) console.error(` Hint: ${error.hint}`);
1425
+ }
1426
+ process.exitCode = 1;
1427
+ };
1428
+
1429
+ const shouldPrintSql = !!opts.printSql;
1430
+ const dropRole = !opts.keepRole;
1431
+
1432
+ // Validate provider and warn if unknown
1433
+ const providerWarning = validateProvider(opts.provider);
1434
+ if (providerWarning) {
1435
+ console.warn(`⚠ ${providerWarning}`);
1436
+ }
1437
+
1438
+ // Offline mode: allow printing SQL without providing/using an admin connection.
1439
+ if (!conn && !opts.dbUrl && !opts.host && !opts.port && !opts.username && !opts.adminPassword) {
1440
+ if (shouldPrintSql) {
1441
+ const database = (opts.dbname ?? process.env.PGDATABASE ?? "postgres").trim();
1442
+
1443
+ const plan = await buildUninitPlan({
1444
+ database,
1445
+ monitoringUser: opts.monitoringUser,
1446
+ dropRole,
1447
+ provider: opts.provider,
1448
+ });
1449
+
1450
+ console.log("\n--- SQL plan (offline; not connected) ---");
1451
+ console.log(`-- database: ${database}`);
1452
+ console.log(`-- monitoring user: ${opts.monitoringUser}`);
1453
+ console.log(`-- provider: ${opts.provider ?? "self-managed"}`);
1454
+ console.log(`-- drop role: ${dropRole}`);
1455
+ for (const step of plan.steps) {
1456
+ console.log(`\n-- ${step.name}`);
1457
+ console.log(step.sql);
1458
+ }
1459
+ console.log("\n--- end SQL plan ---\n");
1460
+ return;
1461
+ }
1462
+ }
1463
+
1464
+ let adminConn;
1465
+ try {
1466
+ adminConn = resolveAdminConnection({
1467
+ conn,
1468
+ dbUrlFlag: opts.dbUrl,
1469
+ host: opts.host ?? process.env.PGHOST,
1470
+ port: opts.port ?? process.env.PGPORT,
1471
+ username: opts.username ?? process.env.PGUSER,
1472
+ dbname: opts.dbname ?? process.env.PGDATABASE,
1473
+ adminPassword: opts.adminPassword,
1474
+ envPassword: process.env.PGPASSWORD,
1475
+ });
1476
+ } catch (e) {
1477
+ const msg = e instanceof Error ? e.message : String(e);
1478
+ if (jsonOutput) {
1479
+ outputError({ message: msg });
1480
+ } else {
1481
+ console.error(`Error: unprepare-db: ${msg}`);
1482
+ if (typeof msg === "string" && msg.startsWith("Connection is required.")) {
1483
+ console.error("");
1484
+ cmd.outputHelp({ error: true });
841
1485
  }
842
- if (typeof errAny.detail === "string" && errAny.detail) {
843
- console.error(` Detail: ${errAny.detail}`);
1486
+ process.exitCode = 1;
1487
+ }
1488
+ return;
1489
+ }
1490
+
1491
+ if (!jsonOutput) {
1492
+ console.log(`Connecting to: ${adminConn.display}`);
1493
+ console.log(`Monitoring user: ${opts.monitoringUser}`);
1494
+ console.log(`Drop role: ${dropRole}`);
1495
+ }
1496
+
1497
+ // Confirmation prompt (unless --force or --json)
1498
+ if (!opts.force && !jsonOutput && !shouldPrintSql) {
1499
+ const answer = await new Promise<string>((resolve) => {
1500
+ const readline = getReadline();
1501
+ readline.question(
1502
+ `This will remove the monitoring setup for user "${opts.monitoringUser}"${dropRole ? " and drop the role" : ""}. Continue? [y/N] `,
1503
+ (ans) => resolve(ans.trim().toLowerCase())
1504
+ );
1505
+ });
1506
+ if (answer !== "y" && answer !== "yes") {
1507
+ console.log("Aborted.");
1508
+ return;
1509
+ }
1510
+ }
1511
+
1512
+ let client: Client | undefined;
1513
+ try {
1514
+ const connResult = await connectWithSslFallback(Client, adminConn);
1515
+ client = connResult.client;
1516
+
1517
+ const dbRes = await client.query("select current_database() as db");
1518
+ const database = dbRes.rows?.[0]?.db;
1519
+ if (typeof database !== "string" || !database) {
1520
+ throw new Error("Failed to resolve current database name");
1521
+ }
1522
+
1523
+ const plan = await buildUninitPlan({
1524
+ database,
1525
+ monitoringUser: opts.monitoringUser,
1526
+ dropRole,
1527
+ provider: opts.provider,
1528
+ });
1529
+
1530
+ if (shouldPrintSql) {
1531
+ console.log("\n--- SQL plan ---");
1532
+ for (const step of plan.steps) {
1533
+ console.log(`\n-- ${step.name}`);
1534
+ console.log(step.sql);
1535
+ }
1536
+ console.log("\n--- end SQL plan ---\n");
1537
+ return;
1538
+ }
1539
+
1540
+ const { applied, errors } = await applyUninitPlan({ client, plan });
1541
+
1542
+ if (jsonOutput) {
1543
+ outputJson({
1544
+ success: errors.length === 0,
1545
+ action: "unprepare",
1546
+ database,
1547
+ monitoringUser: opts.monitoringUser,
1548
+ dropRole,
1549
+ applied,
1550
+ errors,
1551
+ });
1552
+ if (errors.length > 0) {
1553
+ process.exitCode = 1;
844
1554
  }
845
- if (typeof errAny.hint === "string" && errAny.hint) {
846
- console.error(` Hint: ${errAny.hint}`);
1555
+ } else {
1556
+ if (errors.length === 0) {
1557
+ console.log("✓ unprepare-db completed");
1558
+ console.log(`Applied ${applied.length} steps`);
1559
+ } else {
1560
+ console.log("⚠ unprepare-db completed with errors");
1561
+ console.log(`Applied ${applied.length} steps`);
1562
+ console.log("Errors:");
1563
+ for (const err of errors) {
1564
+ console.log(` - ${err}`);
1565
+ }
1566
+ process.exitCode = 1;
847
1567
  }
848
1568
  }
849
- if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
850
- if (errAny.code === "42501") {
851
- if (failedStep === "01.role") {
852
- console.error(" Context: role creation/update requires CREATEROLE or superuser");
853
- } else if (failedStep === "02.permissions") {
854
- console.error(" Context: grants/view/search_path require sufficient GRANT/DDL privileges");
1569
+ } catch (error) {
1570
+ const errAny = error as any;
1571
+ let message = "";
1572
+ if (error instanceof Error && error.message) {
1573
+ message = error.message;
1574
+ } else if (errAny && typeof errAny === "object" && typeof errAny.message === "string" && errAny.message) {
1575
+ message = errAny.message;
1576
+ } else {
1577
+ message = String(error);
1578
+ }
1579
+ if (!message || message === "[object Object]") {
1580
+ message = "Unknown error";
1581
+ }
1582
+
1583
+ const errorObj: {
1584
+ message: string;
1585
+ code?: string;
1586
+ detail?: string;
1587
+ hint?: string;
1588
+ } = { message };
1589
+
1590
+ if (errAny && typeof errAny === "object") {
1591
+ if (typeof errAny.code === "string" && errAny.code) errorObj.code = errAny.code;
1592
+ if (typeof errAny.detail === "string" && errAny.detail) errorObj.detail = errAny.detail;
1593
+ if (typeof errAny.hint === "string" && errAny.hint) errorObj.hint = errAny.hint;
1594
+ }
1595
+
1596
+ if (jsonOutput) {
1597
+ outputJson({
1598
+ success: false,
1599
+ error: errorObj,
1600
+ });
1601
+ process.exitCode = 1;
1602
+ } else {
1603
+ console.error(`Error: unprepare-db: ${message}`);
1604
+ if (errAny && typeof errAny === "object") {
1605
+ if (typeof errAny.code === "string" && errAny.code) {
1606
+ console.error(` Code: ${errAny.code}`);
1607
+ }
1608
+ if (typeof errAny.detail === "string" && errAny.detail) {
1609
+ console.error(` Detail: ${errAny.detail}`);
1610
+ }
1611
+ if (typeof errAny.hint === "string" && errAny.hint) {
1612
+ console.error(` Hint: ${errAny.hint}`);
855
1613
  }
856
- console.error(" Fix: connect as a superuser (or a role with CREATEROLE and sufficient GRANT privileges)");
857
- console.error(" Fix: on managed Postgres, use the provider's admin/master user");
858
- console.error(" Tip: run with --print-sql to review the exact SQL plan");
859
- }
860
- if (errAny.code === "ECONNREFUSED") {
861
- console.error(" Hint: check host/port and ensure Postgres is reachable from this machine");
862
- }
863
- if (errAny.code === "ENOTFOUND") {
864
- console.error(" Hint: DNS resolution failed; double-check the host name");
865
1614
  }
866
- if (errAny.code === "ETIMEDOUT") {
867
- console.error(" Hint: connection timed out; check network/firewall rules");
1615
+ if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
1616
+ if (errAny.code === "42501") {
1617
+ console.error(" Context: dropping roles/objects requires sufficient privileges");
1618
+ console.error(" Fix: connect as a superuser (or a role with appropriate DROP privileges)");
1619
+ }
1620
+ if (errAny.code === "ECONNREFUSED") {
1621
+ console.error(" Hint: check host/port and ensure Postgres is reachable from this machine");
1622
+ }
1623
+ if (errAny.code === "ENOTFOUND") {
1624
+ console.error(" Hint: DNS resolution failed; double-check the host name");
1625
+ }
1626
+ if (errAny.code === "ETIMEDOUT") {
1627
+ console.error(" Hint: connection timed out; check network/firewall rules");
1628
+ }
868
1629
  }
1630
+ process.exitCode = 1;
869
1631
  }
870
- process.exitCode = 1;
871
1632
  } finally {
872
1633
  if (client) {
873
1634
  try {
@@ -876,6 +1637,7 @@ program
876
1637
  // ignore
877
1638
  }
878
1639
  }
1640
+ closeReadline();
879
1641
  }
880
1642
  });
881
1643
 
@@ -1062,7 +1824,7 @@ async function resolveOrInitPaths(): Promise<PathResolution> {
1062
1824
  */
1063
1825
  function isDockerRunning(): boolean {
1064
1826
  try {
1065
- const result = spawnSync("docker", ["info"], { stdio: "pipe" });
1827
+ const result = spawnSync("docker", ["info"], { stdio: "pipe", timeout: 5000 });
1066
1828
  return result.status === 0;
1067
1829
  } catch {
1068
1830
  return false;
@@ -1074,7 +1836,7 @@ function isDockerRunning(): boolean {
1074
1836
  */
1075
1837
  function getComposeCmd(): string[] | null {
1076
1838
  const tryCmd = (cmd: string, args: string[]): boolean =>
1077
- spawnSync(cmd, args, { stdio: "ignore" }).status === 0;
1839
+ spawnSync(cmd, args, { stdio: "ignore", timeout: 5000 }).status === 0;
1078
1840
  if (tryCmd("docker-compose", ["version"])) return ["docker-compose"];
1079
1841
  if (tryCmd("docker", ["compose", "version"])) return ["docker", "compose"];
1080
1842
  return null;
@@ -1088,7 +1850,7 @@ function checkRunningContainers(): { running: boolean; containers: string[] } {
1088
1850
  const result = spawnSync(
1089
1851
  "docker",
1090
1852
  ["ps", "--filter", "name=grafana-with-datasources", "--filter", "name=pgwatch", "--format", "{{.Names}}"],
1091
- { stdio: "pipe", encoding: "utf8" }
1853
+ { stdio: "pipe", encoding: "utf8", timeout: 5000 }
1092
1854
  );
1093
1855
 
1094
1856
  if (result.status === 0 && result.stdout) {
@@ -1204,25 +1966,25 @@ mon
1204
1966
  // Update .env with custom tag if provided
1205
1967
  const envFile = path.resolve(projectDir, ".env");
1206
1968
 
1207
- // Build .env content, preserving important existing values
1208
- // Read existing .env first to preserve CI/custom settings
1209
- let existingTag: string | null = null;
1969
+ // Build .env content, preserving important existing values (registry, password)
1970
+ // Note: PGAI_TAG is intentionally NOT preserved - the CLI version should always match Docker images
1210
1971
  let existingRegistry: string | null = null;
1211
1972
  let existingPassword: string | null = null;
1212
1973
 
1213
1974
  if (fs.existsSync(envFile)) {
1214
1975
  const existingEnv = fs.readFileSync(envFile, "utf8");
1215
- // Extract existing values
1216
- const tagMatch = existingEnv.match(/^PGAI_TAG=(.+)$/m);
1217
- if (tagMatch) existingTag = tagMatch[1].trim();
1976
+ // Extract existing values (except tag - always use CLI version)
1218
1977
  const registryMatch = existingEnv.match(/^PGAI_REGISTRY=(.+)$/m);
1219
1978
  if (registryMatch) existingRegistry = registryMatch[1].trim();
1220
1979
  const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
1221
1980
  if (pwdMatch) existingPassword = pwdMatch[1].trim();
1222
1981
  }
1223
1982
 
1224
- // Priority: CLI --tag flag > PGAI_TAG env var > existing .env > package version
1225
- const imageTag = opts.tag || process.env.PGAI_TAG || existingTag || pkg.version;
1983
+ // Priority: CLI --tag flag > package version
1984
+ // Note: We intentionally do NOT use process.env.PGAI_TAG here because Bun auto-loads .env files,
1985
+ // which would cause stale .env values to override the CLI version. The CLI version should always
1986
+ // match the Docker images. Users can override with --tag if needed.
1987
+ const imageTag = opts.tag || pkg.version;
1226
1988
 
1227
1989
  const envLines: string[] = [`PGAI_TAG=${imageTag}`];
1228
1990
  if (existingRegistry) {
@@ -1550,6 +2312,16 @@ const MONITORING_CONTAINERS = [
1550
2312
  "postgres-reports",
1551
2313
  ];
1552
2314
 
2315
+ /**
2316
+ * Network cleanup constants.
2317
+ * Docker Compose creates a default network named "{project}_default".
2318
+ * In CI environments, network cleanup can fail if containers are slow to disconnect.
2319
+ */
2320
+ const COMPOSE_PROJECT_NAME = "postgres_ai";
2321
+ const DOCKER_NETWORK_NAME = `${COMPOSE_PROJECT_NAME}_default`;
2322
+ /** Delay before retrying network cleanup (allows container network disconnections to complete) */
2323
+ const NETWORK_CLEANUP_DELAY_MS = 2000;
2324
+
1553
2325
  /** Remove orphaned containers that docker compose down might miss */
1554
2326
  async function removeOrphanedContainers(): Promise<void> {
1555
2327
  for (const container of MONITORING_CONTAINERS) {
@@ -1565,7 +2337,33 @@ mon
1565
2337
  .command("stop")
1566
2338
  .description("stop monitoring services")
1567
2339
  .action(async () => {
1568
- const code = await runCompose(["down"]);
2340
+ // Multi-stage cleanup strategy for reliable shutdown in CI environments:
2341
+ // Stage 1: Standard compose down with orphan removal
2342
+ // Stage 2: Force remove any orphaned containers, then retry compose down
2343
+ // Stage 3: Force remove the Docker network directly
2344
+ // This handles edge cases where containers are slow to disconnect from networks.
2345
+ let code = await runCompose(["down", "--remove-orphans"]);
2346
+
2347
+ // Stage 2: If initial cleanup fails, try removing orphaned containers first
2348
+ if (code !== 0) {
2349
+ await removeOrphanedContainers();
2350
+ // Wait a moment for container network disconnections to complete
2351
+ await new Promise(resolve => setTimeout(resolve, NETWORK_CLEANUP_DELAY_MS));
2352
+ // Retry compose down
2353
+ code = await runCompose(["down", "--remove-orphans"]);
2354
+ }
2355
+
2356
+ // Final cleanup: force remove the network if it still exists
2357
+ if (code !== 0) {
2358
+ try {
2359
+ await execFilePromise("docker", ["network", "rm", DOCKER_NETWORK_NAME]);
2360
+ // Network removal succeeded - cleanup is complete
2361
+ code = 0;
2362
+ } catch {
2363
+ // Network doesn't exist or couldn't be removed, ignore
2364
+ }
2365
+ }
2366
+
1569
2367
  if (code !== 0) process.exitCode = code;
1570
2368
  });
1571
2369
 
@@ -2524,22 +3322,44 @@ const issues = program.command("issues").description("issues management");
2524
3322
  issues
2525
3323
  .command("list")
2526
3324
  .description("list issues")
3325
+ .option("--status <status>", "filter by status: open, closed, or all (default: all)")
3326
+ .option("--limit <n>", "max number of issues to return (default: 20)", parseInt)
3327
+ .option("--offset <n>", "number of issues to skip (default: 0)", parseInt)
2527
3328
  .option("--debug", "enable debug output")
2528
3329
  .option("--json", "output raw JSON")
2529
- .action(async (opts: { debug?: boolean; json?: boolean }) => {
3330
+ .action(async (opts: { status?: string; limit?: number; offset?: number; debug?: boolean; json?: boolean }) => {
3331
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching issues...");
2530
3332
  try {
2531
3333
  const rootOpts = program.opts<CliOptions>();
2532
3334
  const cfg = config.readConfig();
2533
3335
  const { apiKey } = getConfig(rootOpts);
2534
3336
  if (!apiKey) {
3337
+ spinner.stop();
2535
3338
  console.error("API key is required. Run 'pgai auth' first or set --api-key.");
2536
3339
  process.exitCode = 1;
2537
3340
  return;
2538
3341
  }
3342
+ const orgId = cfg.orgId ?? undefined;
2539
3343
 
2540
3344
  const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
2541
3345
 
2542
- const result = await fetchIssues({ apiKey, apiBaseUrl, debug: !!opts.debug });
3346
+ let statusFilter: "open" | "closed" | undefined;
3347
+ if (opts.status === "open") {
3348
+ statusFilter = "open";
3349
+ } else if (opts.status === "closed") {
3350
+ statusFilter = "closed";
3351
+ }
3352
+
3353
+ const result = await fetchIssues({
3354
+ apiKey,
3355
+ apiBaseUrl,
3356
+ orgId,
3357
+ status: statusFilter,
3358
+ limit: opts.limit,
3359
+ offset: opts.offset,
3360
+ debug: !!opts.debug,
3361
+ });
3362
+ spinner.stop();
2543
3363
  const trimmed = Array.isArray(result)
2544
3364
  ? (result as any[]).map((r) => ({
2545
3365
  id: (r as any).id,
@@ -2550,6 +3370,7 @@ issues
2550
3370
  : result;
2551
3371
  printResult(trimmed, opts.json);
2552
3372
  } catch (err) {
3373
+ spinner.stop();
2553
3374
  const message = err instanceof Error ? err.message : String(err);
2554
3375
  console.error(message);
2555
3376
  process.exitCode = 1;
@@ -2562,11 +3383,13 @@ issues
2562
3383
  .option("--debug", "enable debug output")
2563
3384
  .option("--json", "output raw JSON")
2564
3385
  .action(async (issueId: string, opts: { debug?: boolean; json?: boolean }) => {
3386
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching issue...");
2565
3387
  try {
2566
3388
  const rootOpts = program.opts<CliOptions>();
2567
3389
  const cfg = config.readConfig();
2568
3390
  const { apiKey } = getConfig(rootOpts);
2569
3391
  if (!apiKey) {
3392
+ spinner.stop();
2570
3393
  console.error("API key is required. Run 'pgai auth' first or set --api-key.");
2571
3394
  process.exitCode = 1;
2572
3395
  return;
@@ -2576,15 +3399,19 @@ issues
2576
3399
 
2577
3400
  const issue = await fetchIssue({ apiKey, apiBaseUrl, issueId, debug: !!opts.debug });
2578
3401
  if (!issue) {
3402
+ spinner.stop();
2579
3403
  console.error("Issue not found");
2580
3404
  process.exitCode = 1;
2581
3405
  return;
2582
3406
  }
2583
3407
 
3408
+ spinner.update("Fetching comments...");
2584
3409
  const comments = await fetchIssueComments({ apiKey, apiBaseUrl, issueId, debug: !!opts.debug });
3410
+ spinner.stop();
2585
3411
  const combined = { issue, comments };
2586
3412
  printResult(combined, opts.json);
2587
3413
  } catch (err) {
3414
+ spinner.stop();
2588
3415
  const message = err instanceof Error ? err.message : String(err);
2589
3416
  console.error(message);
2590
3417
  process.exitCode = 1;
@@ -2598,22 +3425,24 @@ issues
2598
3425
  .option("--debug", "enable debug output")
2599
3426
  .option("--json", "output raw JSON")
2600
3427
  .action(async (issueId: string, content: string, opts: { parent?: string; debug?: boolean; json?: boolean }) => {
2601
- try {
2602
- // Interpret escape sequences in content (e.g., \n -> newline)
2603
- if (opts.debug) {
2604
- // eslint-disable-next-line no-console
2605
- console.log(`Debug: Original content: ${JSON.stringify(content)}`);
2606
- }
2607
- content = interpretEscapes(content);
2608
- if (opts.debug) {
2609
- // eslint-disable-next-line no-console
2610
- console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
2611
- }
3428
+ // Interpret escape sequences in content (e.g., \n -> newline)
3429
+ if (opts.debug) {
3430
+ // eslint-disable-next-line no-console
3431
+ console.log(`Debug: Original content: ${JSON.stringify(content)}`);
3432
+ }
3433
+ content = interpretEscapes(content);
3434
+ if (opts.debug) {
3435
+ // eslint-disable-next-line no-console
3436
+ console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
3437
+ }
2612
3438
 
3439
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Posting comment...");
3440
+ try {
2613
3441
  const rootOpts = program.opts<CliOptions>();
2614
3442
  const cfg = config.readConfig();
2615
3443
  const { apiKey } = getConfig(rootOpts);
2616
3444
  if (!apiKey) {
3445
+ spinner.stop();
2617
3446
  console.error("API key is required. Run 'pgai auth' first or set --api-key.");
2618
3447
  process.exitCode = 1;
2619
3448
  return;
@@ -2629,8 +3458,10 @@ issues
2629
3458
  parentCommentId: opts.parent,
2630
3459
  debug: !!opts.debug,
2631
3460
  });
3461
+ spinner.stop();
2632
3462
  printResult(result, opts.json);
2633
3463
  } catch (err) {
3464
+ spinner.stop();
2634
3465
  const message = err instanceof Error ? err.message : String(err);
2635
3466
  console.error(message);
2636
3467
  process.exitCode = 1;
@@ -2642,7 +3473,7 @@ issues
2642
3473
  .description("create a new issue")
2643
3474
  .option("--org-id <id>", "organization id (defaults to config orgId)", (v) => parseInt(v, 10))
2644
3475
  .option("--project-id <id>", "project id", (v) => parseInt(v, 10))
2645
- .option("--description <text>", "issue description (supports \\\\n)")
3476
+ .option("--description <text>", "issue description (use \\n for newlines)")
2646
3477
  .option(
2647
3478
  "--label <label>",
2648
3479
  "issue label (repeatable)",
@@ -2655,34 +3486,35 @@ issues
2655
3486
  .option("--debug", "enable debug output")
2656
3487
  .option("--json", "output raw JSON")
2657
3488
  .action(async (rawTitle: string, opts: { orgId?: number; projectId?: number; description?: string; label?: string[]; debug?: boolean; json?: boolean }) => {
2658
- try {
2659
- const rootOpts = program.opts<CliOptions>();
2660
- const cfg = config.readConfig();
2661
- const { apiKey } = getConfig(rootOpts);
2662
- if (!apiKey) {
2663
- console.error("API key is required. Run 'pgai auth' first or set --api-key.");
2664
- process.exitCode = 1;
2665
- return;
2666
- }
3489
+ const rootOpts = program.opts<CliOptions>();
3490
+ const cfg = config.readConfig();
3491
+ const { apiKey } = getConfig(rootOpts);
3492
+ if (!apiKey) {
3493
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
3494
+ process.exitCode = 1;
3495
+ return;
3496
+ }
2667
3497
 
2668
- const title = interpretEscapes(String(rawTitle || "").trim());
2669
- if (!title) {
2670
- console.error("title is required");
2671
- process.exitCode = 1;
2672
- return;
2673
- }
3498
+ const title = interpretEscapes(String(rawTitle || "").trim());
3499
+ if (!title) {
3500
+ console.error("title is required");
3501
+ process.exitCode = 1;
3502
+ return;
3503
+ }
2674
3504
 
2675
- const orgId = typeof opts.orgId === "number" && !Number.isNaN(opts.orgId) ? opts.orgId : cfg.orgId;
2676
- if (typeof orgId !== "number") {
2677
- console.error("org_id is required. Either pass --org-id or run 'pgai auth' to store it in config.");
2678
- process.exitCode = 1;
2679
- return;
2680
- }
3505
+ const orgId = typeof opts.orgId === "number" && !Number.isNaN(opts.orgId) ? opts.orgId : cfg.orgId;
3506
+ if (typeof orgId !== "number") {
3507
+ console.error("org_id is required. Either pass --org-id or run 'pgai auth' to store it in config.");
3508
+ process.exitCode = 1;
3509
+ return;
3510
+ }
2681
3511
 
2682
- const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
2683
- const labels = Array.isArray(opts.label) && opts.label.length > 0 ? opts.label.map(String) : undefined;
2684
- const projectId = typeof opts.projectId === "number" && !Number.isNaN(opts.projectId) ? opts.projectId : undefined;
3512
+ const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
3513
+ const labels = Array.isArray(opts.label) && opts.label.length > 0 ? opts.label.map(String) : undefined;
3514
+ const projectId = typeof opts.projectId === "number" && !Number.isNaN(opts.projectId) ? opts.projectId : undefined;
2685
3515
 
3516
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Creating issue...");
3517
+ try {
2686
3518
  const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
2687
3519
  const result = await createIssue({
2688
3520
  apiKey,
@@ -2694,8 +3526,10 @@ issues
2694
3526
  labels,
2695
3527
  debug: !!opts.debug,
2696
3528
  });
3529
+ spinner.stop();
2697
3530
  printResult(result, opts.json);
2698
3531
  } catch (err) {
3532
+ spinner.stop();
2699
3533
  const message = err instanceof Error ? err.message : String(err);
2700
3534
  console.error(message);
2701
3535
  process.exitCode = 1;
@@ -2705,8 +3539,8 @@ issues
2705
3539
  issues
2706
3540
  .command("update <issueId>")
2707
3541
  .description("update an existing issue (title/description/status/labels)")
2708
- .option("--title <text>", "new title (supports \\\\n)")
2709
- .option("--description <text>", "new description (supports \\\\n)")
3542
+ .option("--title <text>", "new title (use \\n for newlines)")
3543
+ .option("--description <text>", "new description (use \\n for newlines)")
2710
3544
  .option("--status <value>", "status: open|closed|0|1")
2711
3545
  .option(
2712
3546
  "--label <label>",
@@ -2721,49 +3555,50 @@ issues
2721
3555
  .option("--debug", "enable debug output")
2722
3556
  .option("--json", "output raw JSON")
2723
3557
  .action(async (issueId: string, opts: { title?: string; description?: string; status?: string; label?: string[]; clearLabels?: boolean; debug?: boolean; json?: boolean }) => {
2724
- try {
2725
- const rootOpts = program.opts<CliOptions>();
2726
- const cfg = config.readConfig();
2727
- const { apiKey } = getConfig(rootOpts);
2728
- if (!apiKey) {
2729
- console.error("API key is required. Run 'pgai auth' first or set --api-key.");
2730
- process.exitCode = 1;
2731
- return;
2732
- }
3558
+ const rootOpts = program.opts<CliOptions>();
3559
+ const cfg = config.readConfig();
3560
+ const { apiKey } = getConfig(rootOpts);
3561
+ if (!apiKey) {
3562
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
3563
+ process.exitCode = 1;
3564
+ return;
3565
+ }
2733
3566
 
2734
- const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3567
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
2735
3568
 
2736
- const title = opts.title !== undefined ? interpretEscapes(String(opts.title)) : undefined;
2737
- const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
2738
-
2739
- let status: number | undefined = undefined;
2740
- if (opts.status !== undefined) {
2741
- const raw = String(opts.status).trim().toLowerCase();
2742
- if (raw === "open") status = 0;
2743
- else if (raw === "closed") status = 1;
2744
- else {
2745
- const n = Number(raw);
2746
- if (!Number.isFinite(n)) {
2747
- console.error("status must be open|closed|0|1");
2748
- process.exitCode = 1;
2749
- return;
2750
- }
2751
- status = n;
2752
- }
2753
- if (status !== 0 && status !== 1) {
2754
- console.error("status must be 0 (open) or 1 (closed)");
3569
+ const title = opts.title !== undefined ? interpretEscapes(String(opts.title)) : undefined;
3570
+ const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
3571
+
3572
+ let status: number | undefined = undefined;
3573
+ if (opts.status !== undefined) {
3574
+ const raw = String(opts.status).trim().toLowerCase();
3575
+ if (raw === "open") status = 0;
3576
+ else if (raw === "closed") status = 1;
3577
+ else {
3578
+ const n = Number(raw);
3579
+ if (!Number.isFinite(n)) {
3580
+ console.error("status must be open|closed|0|1");
2755
3581
  process.exitCode = 1;
2756
3582
  return;
2757
3583
  }
3584
+ status = n;
2758
3585
  }
2759
-
2760
- let labels: string[] | undefined = undefined;
2761
- if (opts.clearLabels) {
2762
- labels = [];
2763
- } else if (Array.isArray(opts.label) && opts.label.length > 0) {
2764
- labels = opts.label.map(String);
3586
+ if (status !== 0 && status !== 1) {
3587
+ console.error("status must be 0 (open) or 1 (closed)");
3588
+ process.exitCode = 1;
3589
+ return;
2765
3590
  }
3591
+ }
3592
+
3593
+ let labels: string[] | undefined = undefined;
3594
+ if (opts.clearLabels) {
3595
+ labels = [];
3596
+ } else if (Array.isArray(opts.label) && opts.label.length > 0) {
3597
+ labels = opts.label.map(String);
3598
+ }
2766
3599
 
3600
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Updating issue...");
3601
+ try {
2767
3602
  const result = await updateIssue({
2768
3603
  apiKey,
2769
3604
  apiBaseUrl,
@@ -2774,8 +3609,10 @@ issues
2774
3609
  labels,
2775
3610
  debug: !!opts.debug,
2776
3611
  });
3612
+ spinner.stop();
2777
3613
  printResult(result, opts.json);
2778
3614
  } catch (err) {
3615
+ spinner.stop();
2779
3616
  const message = err instanceof Error ? err.message : String(err);
2780
3617
  console.error(message);
2781
3618
  process.exitCode = 1;
@@ -2788,21 +3625,91 @@ issues
2788
3625
  .option("--debug", "enable debug output")
2789
3626
  .option("--json", "output raw JSON")
2790
3627
  .action(async (commentId: string, content: string, opts: { debug?: boolean; json?: boolean }) => {
3628
+ if (opts.debug) {
3629
+ // eslint-disable-next-line no-console
3630
+ console.log(`Debug: Original content: ${JSON.stringify(content)}`);
3631
+ }
3632
+ content = interpretEscapes(content);
3633
+ if (opts.debug) {
3634
+ // eslint-disable-next-line no-console
3635
+ console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
3636
+ }
3637
+
3638
+ const rootOpts = program.opts<CliOptions>();
3639
+ const cfg = config.readConfig();
3640
+ const { apiKey } = getConfig(rootOpts);
3641
+ if (!apiKey) {
3642
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
3643
+ process.exitCode = 1;
3644
+ return;
3645
+ }
3646
+
3647
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Updating comment...");
2791
3648
  try {
2792
- if (opts.debug) {
2793
- // eslint-disable-next-line no-console
2794
- console.log(`Debug: Original content: ${JSON.stringify(content)}`);
2795
- }
2796
- content = interpretEscapes(content);
2797
- if (opts.debug) {
2798
- // eslint-disable-next-line no-console
2799
- console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
3649
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3650
+
3651
+ const result = await updateIssueComment({
3652
+ apiKey,
3653
+ apiBaseUrl,
3654
+ commentId,
3655
+ content,
3656
+ debug: !!opts.debug,
3657
+ });
3658
+ spinner.stop();
3659
+ printResult(result, opts.json);
3660
+ } catch (err) {
3661
+ spinner.stop();
3662
+ const message = err instanceof Error ? err.message : String(err);
3663
+ console.error(message);
3664
+ process.exitCode = 1;
3665
+ }
3666
+ });
3667
+
3668
+ // Action Items management (subcommands of issues)
3669
+ issues
3670
+ .command("action-items <issueId>")
3671
+ .description("list action items for an issue")
3672
+ .option("--debug", "enable debug output")
3673
+ .option("--json", "output raw JSON")
3674
+ .action(async (issueId: string, opts: { debug?: boolean; json?: boolean }) => {
3675
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching action items...");
3676
+ try {
3677
+ const rootOpts = program.opts<CliOptions>();
3678
+ const cfg = config.readConfig();
3679
+ const { apiKey } = getConfig(rootOpts);
3680
+ if (!apiKey) {
3681
+ spinner.stop();
3682
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
3683
+ process.exitCode = 1;
3684
+ return;
2800
3685
  }
2801
3686
 
3687
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3688
+
3689
+ const result = await fetchActionItems({ apiKey, apiBaseUrl, issueId, debug: !!opts.debug });
3690
+ spinner.stop();
3691
+ printResult(result, opts.json);
3692
+ } catch (err) {
3693
+ spinner.stop();
3694
+ const message = err instanceof Error ? err.message : String(err);
3695
+ console.error(message);
3696
+ process.exitCode = 1;
3697
+ }
3698
+ });
3699
+
3700
+ issues
3701
+ .command("view-action-item <actionItemIds...>")
3702
+ .description("view action item(s) with all details (supports multiple IDs)")
3703
+ .option("--debug", "enable debug output")
3704
+ .option("--json", "output raw JSON")
3705
+ .action(async (actionItemIds: string[], opts: { debug?: boolean; json?: boolean }) => {
3706
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching action item(s)...");
3707
+ try {
2802
3708
  const rootOpts = program.opts<CliOptions>();
2803
3709
  const cfg = config.readConfig();
2804
3710
  const { apiKey } = getConfig(rootOpts);
2805
3711
  if (!apiKey) {
3712
+ spinner.stop();
2806
3713
  console.error("API key is required. Run 'pgai auth' first or set --api-key.");
2807
3714
  process.exitCode = 1;
2808
3715
  return;
@@ -2810,15 +3717,172 @@ issues
2810
3717
 
2811
3718
  const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
2812
3719
 
2813
- const result = await updateIssueComment({
3720
+ const result = await fetchActionItem({ apiKey, apiBaseUrl, actionItemIds, debug: !!opts.debug });
3721
+ if (result.length === 0) {
3722
+ spinner.stop();
3723
+ console.error("Action item(s) not found");
3724
+ process.exitCode = 1;
3725
+ return;
3726
+ }
3727
+ spinner.stop();
3728
+ printResult(result, opts.json);
3729
+ } catch (err) {
3730
+ spinner.stop();
3731
+ const message = err instanceof Error ? err.message : String(err);
3732
+ console.error(message);
3733
+ process.exitCode = 1;
3734
+ }
3735
+ });
3736
+
3737
+ issues
3738
+ .command("create-action-item <issueId> <title>")
3739
+ .description("create a new action item for an issue")
3740
+ .option("--description <text>", "detailed description (use \\n for newlines)")
3741
+ .option("--sql-action <sql>", "SQL command to execute")
3742
+ .option("--config <json>", "config change as JSON: {\"parameter\":\"...\",\"value\":\"...\"} (repeatable)", (value: string, previous: ConfigChange[]) => {
3743
+ try {
3744
+ previous.push(JSON.parse(value) as ConfigChange);
3745
+ } catch {
3746
+ console.error(`Invalid JSON for --config: ${value}`);
3747
+ process.exit(1);
3748
+ }
3749
+ return previous;
3750
+ }, [] as ConfigChange[])
3751
+ .option("--debug", "enable debug output")
3752
+ .option("--json", "output raw JSON")
3753
+ .action(async (issueId: string, rawTitle: string, opts: { description?: string; sqlAction?: string; config?: ConfigChange[]; debug?: boolean; json?: boolean }) => {
3754
+ const rootOpts = program.opts<CliOptions>();
3755
+ const cfg = config.readConfig();
3756
+ const { apiKey } = getConfig(rootOpts);
3757
+ if (!apiKey) {
3758
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
3759
+ process.exitCode = 1;
3760
+ return;
3761
+ }
3762
+
3763
+ const title = interpretEscapes(String(rawTitle || "").trim());
3764
+ if (!title) {
3765
+ console.error("title is required");
3766
+ process.exitCode = 1;
3767
+ return;
3768
+ }
3769
+
3770
+ const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
3771
+ const sqlAction = opts.sqlAction;
3772
+ const configs = Array.isArray(opts.config) && opts.config.length > 0 ? opts.config : undefined;
3773
+
3774
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Creating action item...");
3775
+ try {
3776
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3777
+ const result = await createActionItem({
2814
3778
  apiKey,
2815
3779
  apiBaseUrl,
2816
- commentId,
2817
- content,
3780
+ issueId,
3781
+ title,
3782
+ description,
3783
+ sqlAction,
3784
+ configs,
2818
3785
  debug: !!opts.debug,
2819
3786
  });
2820
- printResult(result, opts.json);
3787
+ spinner.stop();
3788
+ printResult({ id: result }, opts.json);
3789
+ } catch (err) {
3790
+ spinner.stop();
3791
+ const message = err instanceof Error ? err.message : String(err);
3792
+ console.error(message);
3793
+ process.exitCode = 1;
3794
+ }
3795
+ });
3796
+
3797
+ issues
3798
+ .command("update-action-item <actionItemId>")
3799
+ .description("update an action item (title, description, status, sql_action, configs)")
3800
+ .option("--title <text>", "new title (use \\n for newlines)")
3801
+ .option("--description <text>", "new description (use \\n for newlines)")
3802
+ .option("--done", "mark as done")
3803
+ .option("--not-done", "mark as not done")
3804
+ .option("--status <value>", "status: waiting_for_approval|approved|rejected")
3805
+ .option("--status-reason <text>", "reason for status change")
3806
+ .option("--sql-action <sql>", "SQL command (use empty string to clear)")
3807
+ .option("--config <json>", "config change as JSON (repeatable, replaces all configs)", (value: string, previous: ConfigChange[]) => {
3808
+ try {
3809
+ previous.push(JSON.parse(value) as ConfigChange);
3810
+ } catch {
3811
+ console.error(`Invalid JSON for --config: ${value}`);
3812
+ process.exit(1);
3813
+ }
3814
+ return previous;
3815
+ }, [] as ConfigChange[])
3816
+ .option("--clear-configs", "clear all config changes")
3817
+ .option("--debug", "enable debug output")
3818
+ .option("--json", "output raw JSON")
3819
+ .action(async (actionItemId: string, opts: { title?: string; description?: string; done?: boolean; notDone?: boolean; status?: string; statusReason?: string; sqlAction?: string; config?: ConfigChange[]; clearConfigs?: boolean; debug?: boolean; json?: boolean }) => {
3820
+ const rootOpts = program.opts<CliOptions>();
3821
+ const cfg = config.readConfig();
3822
+ const { apiKey } = getConfig(rootOpts);
3823
+ if (!apiKey) {
3824
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
3825
+ process.exitCode = 1;
3826
+ return;
3827
+ }
3828
+
3829
+ const title = opts.title !== undefined ? interpretEscapes(String(opts.title)) : undefined;
3830
+ const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
3831
+
3832
+ let isDone: boolean | undefined = undefined;
3833
+ if (opts.done) isDone = true;
3834
+ else if (opts.notDone) isDone = false;
3835
+
3836
+ let status: string | undefined = undefined;
3837
+ if (opts.status !== undefined) {
3838
+ const validStatuses = ["waiting_for_approval", "approved", "rejected"];
3839
+ if (!validStatuses.includes(opts.status)) {
3840
+ console.error(`status must be one of: ${validStatuses.join(", ")}`);
3841
+ process.exitCode = 1;
3842
+ return;
3843
+ }
3844
+ status = opts.status;
3845
+ }
3846
+
3847
+ const statusReason = opts.statusReason;
3848
+ const sqlAction = opts.sqlAction;
3849
+
3850
+ let configs: ConfigChange[] | undefined = undefined;
3851
+ if (opts.clearConfigs) {
3852
+ configs = [];
3853
+ } else if (Array.isArray(opts.config) && opts.config.length > 0) {
3854
+ configs = opts.config;
3855
+ }
3856
+
3857
+ // Check that at least one update field is provided
3858
+ if (title === undefined && description === undefined &&
3859
+ isDone === undefined && status === undefined && statusReason === undefined &&
3860
+ sqlAction === undefined && configs === undefined) {
3861
+ console.error("At least one update option is required");
3862
+ process.exitCode = 1;
3863
+ return;
3864
+ }
3865
+
3866
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Updating action item...");
3867
+ try {
3868
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3869
+ await updateActionItem({
3870
+ apiKey,
3871
+ apiBaseUrl,
3872
+ actionItemId,
3873
+ title,
3874
+ description,
3875
+ isDone,
3876
+ status,
3877
+ statusReason,
3878
+ sqlAction,
3879
+ configs,
3880
+ debug: !!opts.debug,
3881
+ });
3882
+ spinner.stop();
3883
+ printResult({ success: true }, opts.json);
2821
3884
  } catch (err) {
3885
+ spinner.stop();
2822
3886
  const message = err instanceof Error ? err.message : String(err);
2823
3887
  console.error(message);
2824
3888
  process.exitCode = 1;