postgresai 0.14.0-beta.11 → 0.14.0-beta.13

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.
@@ -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, buildInitPlan, 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,313 @@ 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 === "02.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
+ }
1018
+ if (errAny && typeof errAny === "object" && typeof errAny.httpStatus === "number") {
1019
+ if (errAny.httpStatus === 401) {
1020
+ console.error(" Hint: invalid or expired access token; generate a new one at https://supabase.com/dashboard/account/tokens");
1021
+ }
1022
+ if (errAny.httpStatus === 403) {
1023
+ console.error(" Hint: access denied; check your token permissions and project access");
1024
+ }
1025
+ if (errAny.httpStatus === 404) {
1026
+ console.error(" Hint: project not found; verify the project reference is correct");
1027
+ }
1028
+ if (errAny.httpStatus === 429) {
1029
+ console.error(" Hint: rate limited; wait a moment and try again");
1030
+ }
1031
+ }
1032
+ process.exitCode = 1;
1033
+ }
1034
+ }
1035
+ return;
1036
+ }
1037
+
674
1038
  let adminConn;
675
1039
  try {
676
1040
  adminConn = resolveAdminConnection({
@@ -686,21 +1050,27 @@ program
686
1050
  });
687
1051
  } catch (e) {
688
1052
  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 });
1053
+ if (jsonOutput) {
1054
+ outputError({ message: msg });
1055
+ } else {
1056
+ console.error(`Error: prepare-db: ${msg}`);
1057
+ // When connection details are missing, show full init help (options + examples).
1058
+ if (typeof msg === "string" && msg.startsWith("Connection is required.")) {
1059
+ console.error("");
1060
+ cmd.outputHelp({ error: true });
1061
+ }
1062
+ process.exitCode = 1;
694
1063
  }
695
- process.exitCode = 1;
696
1064
  return;
697
1065
  }
698
1066
 
699
1067
  const includeOptionalPermissions = !opts.skipOptionalPermissions;
700
1068
 
701
- console.log(`Connecting to: ${adminConn.display}`);
702
- console.log(`Monitoring user: ${opts.monitoringUser}`);
703
- console.log(`Optional permissions: ${includeOptionalPermissions ? "enabled" : "skipped"}`);
1069
+ if (!jsonOutput) {
1070
+ console.log(`Connecting to: ${adminConn.display}`);
1071
+ console.log(`Monitoring user: ${opts.monitoringUser}`);
1072
+ console.log(`Optional permissions: ${includeOptionalPermissions ? "enabled" : "skipped"}`);
1073
+ }
704
1074
 
705
1075
  // Use native pg client instead of requiring psql to be installed
706
1076
  let client: Client | undefined;
@@ -720,26 +1090,54 @@ program
720
1090
  database,
721
1091
  monitoringUser: opts.monitoringUser,
722
1092
  includeOptionalPermissions,
1093
+ provider: opts.provider,
723
1094
  });
724
1095
  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}`);
1096
+ if (jsonOutput) {
1097
+ outputJson({
1098
+ success: true,
1099
+ mode: "direct",
1100
+ action: "verify",
1101
+ database,
1102
+ monitoringUser: opts.monitoringUser,
1103
+ provider: opts.provider,
1104
+ verified: true,
1105
+ missingOptional: v.missingOptional,
1106
+ });
1107
+ } else {
1108
+ console.log(`✓ prepare-db verify: OK${opts.provider ? ` (provider: ${opts.provider})` : ""}`);
1109
+ if (v.missingOptional.length > 0) {
1110
+ console.log("⚠ Optional items missing:");
1111
+ for (const m of v.missingOptional) console.log(`- ${m}`);
1112
+ }
729
1113
  }
730
1114
  return;
731
1115
  }
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}`);
1116
+ if (jsonOutput) {
1117
+ outputJson({
1118
+ success: false,
1119
+ mode: "direct",
1120
+ action: "verify",
1121
+ database,
1122
+ monitoringUser: opts.monitoringUser,
1123
+ verified: false,
1124
+ missingRequired: v.missingRequired,
1125
+ missingOptional: v.missingOptional,
1126
+ });
1127
+ } else {
1128
+ console.error("✗ prepare-db verify failed: missing required items");
1129
+ for (const m of v.missingRequired) console.error(`- ${m}`);
1130
+ if (v.missingOptional.length > 0) {
1131
+ console.error("Optional items missing:");
1132
+ for (const m of v.missingOptional) console.error(`- ${m}`);
1133
+ }
737
1134
  }
738
1135
  process.exitCode = 1;
739
1136
  return;
740
1137
  }
741
1138
 
742
1139
  let monPassword: string;
1140
+ let passwordGenerated = false;
743
1141
  try {
744
1142
  const resolved = await resolveMonitoringPassword({
745
1143
  passwordFlag: opts.password,
@@ -747,17 +1145,21 @@ program
747
1145
  monitoringUser: opts.monitoringUser,
748
1146
  });
749
1147
  monPassword = resolved.password;
1148
+ passwordGenerated = resolved.generated;
750
1149
  if (resolved.generated) {
751
- const canPrint = process.stdout.isTTY || !!opts.printPassword;
1150
+ const canPrint = process.stdout.isTTY || !!opts.printPassword || jsonOutput;
752
1151
  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).");
1152
+ if (!jsonOutput) {
1153
+ // Print secrets to stderr to reduce the chance they end up in piped stdout logs.
1154
+ const shellSafe = monPassword.replace(/'/g, "'\\''");
1155
+ console.error("");
1156
+ console.error(`Generated monitoring password for ${opts.monitoringUser} (copy/paste):`);
1157
+ // Quote for shell copy/paste safety.
1158
+ console.error(`PGAI_MON_PASSWORD='${shellSafe}'`);
1159
+ console.error("");
1160
+ console.log("Store it securely (or rerun with --password / PGAI_MON_PASSWORD to set your own).");
1161
+ }
1162
+ // For JSON mode, password will be included in the success output below
761
1163
  } else {
762
1164
  console.error(
763
1165
  [
@@ -776,8 +1178,7 @@ program
776
1178
  }
777
1179
  } catch (e) {
778
1180
  const msg = e instanceof Error ? e.message : String(e);
779
- console.error(`✗ ${msg}`);
780
- process.exitCode = 1;
1181
+ outputError({ message: msg });
781
1182
  return;
782
1183
  }
783
1184
 
@@ -786,12 +1187,21 @@ program
786
1187
  monitoringUser: opts.monitoringUser,
787
1188
  monitoringPassword: monPassword,
788
1189
  includeOptionalPermissions,
1190
+ provider: opts.provider,
789
1191
  });
790
1192
 
1193
+ // For reset-password, we only want the role step. But if provider skips role creation,
1194
+ // reset-password doesn't make sense - warn the user.
791
1195
  const effectivePlan = opts.resetPassword
792
1196
  ? { ...plan, steps: plan.steps.filter((s) => s.name === "01.role") }
793
1197
  : plan;
794
1198
 
1199
+ if (opts.resetPassword && effectivePlan.steps.length === 0) {
1200
+ console.error(`✗ --reset-password not supported for provider "${opts.provider}" (role creation is skipped)`);
1201
+ process.exitCode = 1;
1202
+ return;
1203
+ }
1204
+
795
1205
  if (shouldPrintSql) {
796
1206
  console.log("\n--- SQL plan ---");
797
1207
  for (const step of effectivePlan.steps) {
@@ -805,14 +1215,33 @@ program
805
1215
 
806
1216
  const { applied, skippedOptional } = await applyInitPlan({ client, plan: effectivePlan });
807
1217
 
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`);
1218
+ if (jsonOutput) {
1219
+ const result: Record<string, unknown> = {
1220
+ success: true,
1221
+ mode: "direct",
1222
+ action: opts.resetPassword ? "reset-password" : "apply",
1223
+ database,
1224
+ monitoringUser: opts.monitoringUser,
1225
+ applied,
1226
+ skippedOptional,
1227
+ warnings: skippedOptional.length > 0
1228
+ ? ["Some optional steps were skipped (not supported or insufficient privileges)"]
1229
+ : [],
1230
+ };
1231
+ if (passwordGenerated) {
1232
+ result.generatedPassword = monPassword;
1233
+ }
1234
+ outputJson(result);
1235
+ } else {
1236
+ console.log(opts.resetPassword ? "✓ prepare-db password reset completed" : "✓ prepare-db completed");
1237
+ if (skippedOptional.length > 0) {
1238
+ console.log("⚠ Some optional steps were skipped (not supported or insufficient privileges):");
1239
+ for (const s of skippedOptional) console.log(`- ${s}`);
1240
+ }
1241
+ // Keep output compact but still useful
1242
+ if (process.stdout.isTTY) {
1243
+ console.log(`Applied ${applied.length} steps`);
1244
+ }
816
1245
  }
817
1246
  } catch (error) {
818
1247
  const errAny = error as any;
@@ -827,47 +1256,74 @@ program
827
1256
  if (!message || message === "[object Object]") {
828
1257
  message = "Unknown error";
829
1258
  }
830
- console.error(`Error: prepare-db: ${message}`);
1259
+
831
1260
  // If this was a plan step failure, surface the step name explicitly to help users diagnose quickly.
832
1261
  const stepMatch =
833
1262
  typeof message === "string" ? message.match(/Failed at step "([^"]+)":/i) : null;
834
1263
  const failedStep = stepMatch?.[1];
835
- if (failedStep) {
836
- console.error(` Step: ${failedStep}`);
837
- }
1264
+
1265
+ // Build error object for JSON output
1266
+ const errorObj: {
1267
+ message: string;
1268
+ step?: string;
1269
+ code?: string;
1270
+ detail?: string;
1271
+ hint?: string;
1272
+ } = { message };
1273
+
1274
+ if (failedStep) errorObj.step = failedStep;
838
1275
  if (errAny && typeof errAny === "object") {
839
- if (typeof errAny.code === "string" && errAny.code) {
840
- console.error(` Code: ${errAny.code}`);
841
- }
842
- if (typeof errAny.detail === "string" && errAny.detail) {
843
- console.error(` Detail: ${errAny.detail}`);
844
- }
845
- if (typeof errAny.hint === "string" && errAny.hint) {
846
- console.error(` Hint: ${errAny.hint}`);
847
- }
1276
+ if (typeof errAny.code === "string" && errAny.code) errorObj.code = errAny.code;
1277
+ if (typeof errAny.detail === "string" && errAny.detail) errorObj.detail = errAny.detail;
1278
+ if (typeof errAny.hint === "string" && errAny.hint) errorObj.hint = errAny.hint;
848
1279
  }
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");
855
- }
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");
1280
+
1281
+ if (jsonOutput) {
1282
+ outputJson({
1283
+ success: false,
1284
+ mode: "direct",
1285
+ error: errorObj,
1286
+ });
1287
+ process.exitCode = 1;
1288
+ } else {
1289
+ console.error(`Error: prepare-db: ${message}`);
1290
+ if (failedStep) {
1291
+ console.error(` Step: ${failedStep}`);
862
1292
  }
863
- if (errAny.code === "ENOTFOUND") {
864
- console.error(" Hint: DNS resolution failed; double-check the host name");
1293
+ if (errAny && typeof errAny === "object") {
1294
+ if (typeof errAny.code === "string" && errAny.code) {
1295
+ console.error(` Code: ${errAny.code}`);
1296
+ }
1297
+ if (typeof errAny.detail === "string" && errAny.detail) {
1298
+ console.error(` Detail: ${errAny.detail}`);
1299
+ }
1300
+ if (typeof errAny.hint === "string" && errAny.hint) {
1301
+ console.error(` Hint: ${errAny.hint}`);
1302
+ }
865
1303
  }
866
- if (errAny.code === "ETIMEDOUT") {
867
- console.error(" Hint: connection timed out; check network/firewall rules");
1304
+ if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
1305
+ if (errAny.code === "42501") {
1306
+ if (failedStep === "01.role") {
1307
+ console.error(" Context: role creation/update requires CREATEROLE or superuser");
1308
+ } else if (failedStep === "02.permissions") {
1309
+ console.error(" Context: grants/view/search_path require sufficient GRANT/DDL privileges");
1310
+ }
1311
+ console.error(" Fix: connect as a superuser (or a role with CREATEROLE and sufficient GRANT privileges)");
1312
+ console.error(" Fix: on managed Postgres, use the provider's admin/master user");
1313
+ console.error(" Tip: run with --print-sql to review the exact SQL plan");
1314
+ }
1315
+ if (errAny.code === "ECONNREFUSED") {
1316
+ console.error(" Hint: check host/port and ensure Postgres is reachable from this machine");
1317
+ }
1318
+ if (errAny.code === "ENOTFOUND") {
1319
+ console.error(" Hint: DNS resolution failed; double-check the host name");
1320
+ }
1321
+ if (errAny.code === "ETIMEDOUT") {
1322
+ console.error(" Hint: connection timed out; check network/firewall rules");
1323
+ }
868
1324
  }
1325
+ process.exitCode = 1;
869
1326
  }
870
- process.exitCode = 1;
871
1327
  } finally {
872
1328
  if (client) {
873
1329
  try {
@@ -1204,25 +1660,25 @@ mon
1204
1660
  // Update .env with custom tag if provided
1205
1661
  const envFile = path.resolve(projectDir, ".env");
1206
1662
 
1207
- // Build .env content, preserving important existing values
1208
- // Read existing .env first to preserve CI/custom settings
1209
- let existingTag: string | null = null;
1663
+ // Build .env content, preserving important existing values (registry, password)
1664
+ // Note: PGAI_TAG is intentionally NOT preserved - the CLI version should always match Docker images
1210
1665
  let existingRegistry: string | null = null;
1211
1666
  let existingPassword: string | null = null;
1212
1667
 
1213
1668
  if (fs.existsSync(envFile)) {
1214
1669
  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();
1670
+ // Extract existing values (except tag - always use CLI version)
1218
1671
  const registryMatch = existingEnv.match(/^PGAI_REGISTRY=(.+)$/m);
1219
1672
  if (registryMatch) existingRegistry = registryMatch[1].trim();
1220
1673
  const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
1221
1674
  if (pwdMatch) existingPassword = pwdMatch[1].trim();
1222
1675
  }
1223
1676
 
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;
1677
+ // Priority: CLI --tag flag > package version
1678
+ // Note: We intentionally do NOT use process.env.PGAI_TAG here because Bun auto-loads .env files,
1679
+ // which would cause stale .env values to override the CLI version. The CLI version should always
1680
+ // match the Docker images. Users can override with --tag if needed.
1681
+ const imageTag = opts.tag || pkg.version;
1226
1682
 
1227
1683
  const envLines: string[] = [`PGAI_TAG=${imageTag}`];
1228
1684
  if (existingRegistry) {
@@ -1550,6 +2006,16 @@ const MONITORING_CONTAINERS = [
1550
2006
  "postgres-reports",
1551
2007
  ];
1552
2008
 
2009
+ /**
2010
+ * Network cleanup constants.
2011
+ * Docker Compose creates a default network named "{project}_default".
2012
+ * In CI environments, network cleanup can fail if containers are slow to disconnect.
2013
+ */
2014
+ const COMPOSE_PROJECT_NAME = "postgres_ai";
2015
+ const DOCKER_NETWORK_NAME = `${COMPOSE_PROJECT_NAME}_default`;
2016
+ /** Delay before retrying network cleanup (allows container network disconnections to complete) */
2017
+ const NETWORK_CLEANUP_DELAY_MS = 2000;
2018
+
1553
2019
  /** Remove orphaned containers that docker compose down might miss */
1554
2020
  async function removeOrphanedContainers(): Promise<void> {
1555
2021
  for (const container of MONITORING_CONTAINERS) {
@@ -1565,7 +2031,33 @@ mon
1565
2031
  .command("stop")
1566
2032
  .description("stop monitoring services")
1567
2033
  .action(async () => {
1568
- const code = await runCompose(["down"]);
2034
+ // Multi-stage cleanup strategy for reliable shutdown in CI environments:
2035
+ // Stage 1: Standard compose down with orphan removal
2036
+ // Stage 2: Force remove any orphaned containers, then retry compose down
2037
+ // Stage 3: Force remove the Docker network directly
2038
+ // This handles edge cases where containers are slow to disconnect from networks.
2039
+ let code = await runCompose(["down", "--remove-orphans"]);
2040
+
2041
+ // Stage 2: If initial cleanup fails, try removing orphaned containers first
2042
+ if (code !== 0) {
2043
+ await removeOrphanedContainers();
2044
+ // Wait a moment for container network disconnections to complete
2045
+ await new Promise(resolve => setTimeout(resolve, NETWORK_CLEANUP_DELAY_MS));
2046
+ // Retry compose down
2047
+ code = await runCompose(["down", "--remove-orphans"]);
2048
+ }
2049
+
2050
+ // Final cleanup: force remove the network if it still exists
2051
+ if (code !== 0) {
2052
+ try {
2053
+ await execFilePromise("docker", ["network", "rm", DOCKER_NETWORK_NAME]);
2054
+ // Network removal succeeded - cleanup is complete
2055
+ code = 0;
2056
+ } catch {
2057
+ // Network doesn't exist or couldn't be removed, ignore
2058
+ }
2059
+ }
2060
+
1569
2061
  if (code !== 0) process.exitCode = code;
1570
2062
  });
1571
2063
 
@@ -2524,22 +3016,44 @@ const issues = program.command("issues").description("issues management");
2524
3016
  issues
2525
3017
  .command("list")
2526
3018
  .description("list issues")
3019
+ .option("--status <status>", "filter by status: open, closed, or all (default: all)")
3020
+ .option("--limit <n>", "max number of issues to return (default: 20)", parseInt)
3021
+ .option("--offset <n>", "number of issues to skip (default: 0)", parseInt)
2527
3022
  .option("--debug", "enable debug output")
2528
3023
  .option("--json", "output raw JSON")
2529
- .action(async (opts: { debug?: boolean; json?: boolean }) => {
3024
+ .action(async (opts: { status?: string; limit?: number; offset?: number; debug?: boolean; json?: boolean }) => {
3025
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching issues...");
2530
3026
  try {
2531
3027
  const rootOpts = program.opts<CliOptions>();
2532
3028
  const cfg = config.readConfig();
2533
3029
  const { apiKey } = getConfig(rootOpts);
2534
3030
  if (!apiKey) {
3031
+ spinner.stop();
2535
3032
  console.error("API key is required. Run 'pgai auth' first or set --api-key.");
2536
3033
  process.exitCode = 1;
2537
3034
  return;
2538
3035
  }
3036
+ const orgId = cfg.orgId ?? undefined;
2539
3037
 
2540
3038
  const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
2541
3039
 
2542
- const result = await fetchIssues({ apiKey, apiBaseUrl, debug: !!opts.debug });
3040
+ let statusFilter: "open" | "closed" | undefined;
3041
+ if (opts.status === "open") {
3042
+ statusFilter = "open";
3043
+ } else if (opts.status === "closed") {
3044
+ statusFilter = "closed";
3045
+ }
3046
+
3047
+ const result = await fetchIssues({
3048
+ apiKey,
3049
+ apiBaseUrl,
3050
+ orgId,
3051
+ status: statusFilter,
3052
+ limit: opts.limit,
3053
+ offset: opts.offset,
3054
+ debug: !!opts.debug,
3055
+ });
3056
+ spinner.stop();
2543
3057
  const trimmed = Array.isArray(result)
2544
3058
  ? (result as any[]).map((r) => ({
2545
3059
  id: (r as any).id,
@@ -2550,6 +3064,7 @@ issues
2550
3064
  : result;
2551
3065
  printResult(trimmed, opts.json);
2552
3066
  } catch (err) {
3067
+ spinner.stop();
2553
3068
  const message = err instanceof Error ? err.message : String(err);
2554
3069
  console.error(message);
2555
3070
  process.exitCode = 1;
@@ -2562,11 +3077,13 @@ issues
2562
3077
  .option("--debug", "enable debug output")
2563
3078
  .option("--json", "output raw JSON")
2564
3079
  .action(async (issueId: string, opts: { debug?: boolean; json?: boolean }) => {
3080
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching issue...");
2565
3081
  try {
2566
3082
  const rootOpts = program.opts<CliOptions>();
2567
3083
  const cfg = config.readConfig();
2568
3084
  const { apiKey } = getConfig(rootOpts);
2569
3085
  if (!apiKey) {
3086
+ spinner.stop();
2570
3087
  console.error("API key is required. Run 'pgai auth' first or set --api-key.");
2571
3088
  process.exitCode = 1;
2572
3089
  return;
@@ -2576,15 +3093,19 @@ issues
2576
3093
 
2577
3094
  const issue = await fetchIssue({ apiKey, apiBaseUrl, issueId, debug: !!opts.debug });
2578
3095
  if (!issue) {
3096
+ spinner.stop();
2579
3097
  console.error("Issue not found");
2580
3098
  process.exitCode = 1;
2581
3099
  return;
2582
3100
  }
2583
3101
 
3102
+ spinner.update("Fetching comments...");
2584
3103
  const comments = await fetchIssueComments({ apiKey, apiBaseUrl, issueId, debug: !!opts.debug });
3104
+ spinner.stop();
2585
3105
  const combined = { issue, comments };
2586
3106
  printResult(combined, opts.json);
2587
3107
  } catch (err) {
3108
+ spinner.stop();
2588
3109
  const message = err instanceof Error ? err.message : String(err);
2589
3110
  console.error(message);
2590
3111
  process.exitCode = 1;
@@ -2598,22 +3119,24 @@ issues
2598
3119
  .option("--debug", "enable debug output")
2599
3120
  .option("--json", "output raw JSON")
2600
3121
  .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
- }
3122
+ // Interpret escape sequences in content (e.g., \n -> newline)
3123
+ if (opts.debug) {
3124
+ // eslint-disable-next-line no-console
3125
+ console.log(`Debug: Original content: ${JSON.stringify(content)}`);
3126
+ }
3127
+ content = interpretEscapes(content);
3128
+ if (opts.debug) {
3129
+ // eslint-disable-next-line no-console
3130
+ console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
3131
+ }
2612
3132
 
3133
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Posting comment...");
3134
+ try {
2613
3135
  const rootOpts = program.opts<CliOptions>();
2614
3136
  const cfg = config.readConfig();
2615
3137
  const { apiKey } = getConfig(rootOpts);
2616
3138
  if (!apiKey) {
3139
+ spinner.stop();
2617
3140
  console.error("API key is required. Run 'pgai auth' first or set --api-key.");
2618
3141
  process.exitCode = 1;
2619
3142
  return;
@@ -2629,8 +3152,10 @@ issues
2629
3152
  parentCommentId: opts.parent,
2630
3153
  debug: !!opts.debug,
2631
3154
  });
3155
+ spinner.stop();
2632
3156
  printResult(result, opts.json);
2633
3157
  } catch (err) {
3158
+ spinner.stop();
2634
3159
  const message = err instanceof Error ? err.message : String(err);
2635
3160
  console.error(message);
2636
3161
  process.exitCode = 1;
@@ -2642,7 +3167,7 @@ issues
2642
3167
  .description("create a new issue")
2643
3168
  .option("--org-id <id>", "organization id (defaults to config orgId)", (v) => parseInt(v, 10))
2644
3169
  .option("--project-id <id>", "project id", (v) => parseInt(v, 10))
2645
- .option("--description <text>", "issue description (supports \\\\n)")
3170
+ .option("--description <text>", "issue description (use \\n for newlines)")
2646
3171
  .option(
2647
3172
  "--label <label>",
2648
3173
  "issue label (repeatable)",
@@ -2655,34 +3180,35 @@ issues
2655
3180
  .option("--debug", "enable debug output")
2656
3181
  .option("--json", "output raw JSON")
2657
3182
  .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
- }
3183
+ const rootOpts = program.opts<CliOptions>();
3184
+ const cfg = config.readConfig();
3185
+ const { apiKey } = getConfig(rootOpts);
3186
+ if (!apiKey) {
3187
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
3188
+ process.exitCode = 1;
3189
+ return;
3190
+ }
2667
3191
 
2668
- const title = interpretEscapes(String(rawTitle || "").trim());
2669
- if (!title) {
2670
- console.error("title is required");
2671
- process.exitCode = 1;
2672
- return;
2673
- }
3192
+ const title = interpretEscapes(String(rawTitle || "").trim());
3193
+ if (!title) {
3194
+ console.error("title is required");
3195
+ process.exitCode = 1;
3196
+ return;
3197
+ }
2674
3198
 
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
- }
3199
+ const orgId = typeof opts.orgId === "number" && !Number.isNaN(opts.orgId) ? opts.orgId : cfg.orgId;
3200
+ if (typeof orgId !== "number") {
3201
+ console.error("org_id is required. Either pass --org-id or run 'pgai auth' to store it in config.");
3202
+ process.exitCode = 1;
3203
+ return;
3204
+ }
2681
3205
 
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;
3206
+ const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
3207
+ const labels = Array.isArray(opts.label) && opts.label.length > 0 ? opts.label.map(String) : undefined;
3208
+ const projectId = typeof opts.projectId === "number" && !Number.isNaN(opts.projectId) ? opts.projectId : undefined;
2685
3209
 
3210
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Creating issue...");
3211
+ try {
2686
3212
  const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
2687
3213
  const result = await createIssue({
2688
3214
  apiKey,
@@ -2694,8 +3220,10 @@ issues
2694
3220
  labels,
2695
3221
  debug: !!opts.debug,
2696
3222
  });
3223
+ spinner.stop();
2697
3224
  printResult(result, opts.json);
2698
3225
  } catch (err) {
3226
+ spinner.stop();
2699
3227
  const message = err instanceof Error ? err.message : String(err);
2700
3228
  console.error(message);
2701
3229
  process.exitCode = 1;
@@ -2705,8 +3233,8 @@ issues
2705
3233
  issues
2706
3234
  .command("update <issueId>")
2707
3235
  .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)")
3236
+ .option("--title <text>", "new title (use \\n for newlines)")
3237
+ .option("--description <text>", "new description (use \\n for newlines)")
2710
3238
  .option("--status <value>", "status: open|closed|0|1")
2711
3239
  .option(
2712
3240
  "--label <label>",
@@ -2721,49 +3249,50 @@ issues
2721
3249
  .option("--debug", "enable debug output")
2722
3250
  .option("--json", "output raw JSON")
2723
3251
  .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
- }
3252
+ const rootOpts = program.opts<CliOptions>();
3253
+ const cfg = config.readConfig();
3254
+ const { apiKey } = getConfig(rootOpts);
3255
+ if (!apiKey) {
3256
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
3257
+ process.exitCode = 1;
3258
+ return;
3259
+ }
2733
3260
 
2734
- const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3261
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
2735
3262
 
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)");
3263
+ const title = opts.title !== undefined ? interpretEscapes(String(opts.title)) : undefined;
3264
+ const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
3265
+
3266
+ let status: number | undefined = undefined;
3267
+ if (opts.status !== undefined) {
3268
+ const raw = String(opts.status).trim().toLowerCase();
3269
+ if (raw === "open") status = 0;
3270
+ else if (raw === "closed") status = 1;
3271
+ else {
3272
+ const n = Number(raw);
3273
+ if (!Number.isFinite(n)) {
3274
+ console.error("status must be open|closed|0|1");
2755
3275
  process.exitCode = 1;
2756
3276
  return;
2757
3277
  }
3278
+ status = n;
2758
3279
  }
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);
3280
+ if (status !== 0 && status !== 1) {
3281
+ console.error("status must be 0 (open) or 1 (closed)");
3282
+ process.exitCode = 1;
3283
+ return;
2765
3284
  }
3285
+ }
3286
+
3287
+ let labels: string[] | undefined = undefined;
3288
+ if (opts.clearLabels) {
3289
+ labels = [];
3290
+ } else if (Array.isArray(opts.label) && opts.label.length > 0) {
3291
+ labels = opts.label.map(String);
3292
+ }
2766
3293
 
3294
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Updating issue...");
3295
+ try {
2767
3296
  const result = await updateIssue({
2768
3297
  apiKey,
2769
3298
  apiBaseUrl,
@@ -2774,8 +3303,10 @@ issues
2774
3303
  labels,
2775
3304
  debug: !!opts.debug,
2776
3305
  });
3306
+ spinner.stop();
2777
3307
  printResult(result, opts.json);
2778
3308
  } catch (err) {
3309
+ spinner.stop();
2779
3310
  const message = err instanceof Error ? err.message : String(err);
2780
3311
  console.error(message);
2781
3312
  process.exitCode = 1;
@@ -2788,21 +3319,91 @@ issues
2788
3319
  .option("--debug", "enable debug output")
2789
3320
  .option("--json", "output raw JSON")
2790
3321
  .action(async (commentId: string, content: string, opts: { debug?: boolean; json?: boolean }) => {
3322
+ if (opts.debug) {
3323
+ // eslint-disable-next-line no-console
3324
+ console.log(`Debug: Original content: ${JSON.stringify(content)}`);
3325
+ }
3326
+ content = interpretEscapes(content);
3327
+ if (opts.debug) {
3328
+ // eslint-disable-next-line no-console
3329
+ console.log(`Debug: Interpreted content: ${JSON.stringify(content)}`);
3330
+ }
3331
+
3332
+ const rootOpts = program.opts<CliOptions>();
3333
+ const cfg = config.readConfig();
3334
+ const { apiKey } = getConfig(rootOpts);
3335
+ if (!apiKey) {
3336
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
3337
+ process.exitCode = 1;
3338
+ return;
3339
+ }
3340
+
3341
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Updating comment...");
2791
3342
  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)}`);
3343
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3344
+
3345
+ const result = await updateIssueComment({
3346
+ apiKey,
3347
+ apiBaseUrl,
3348
+ commentId,
3349
+ content,
3350
+ debug: !!opts.debug,
3351
+ });
3352
+ spinner.stop();
3353
+ printResult(result, opts.json);
3354
+ } catch (err) {
3355
+ spinner.stop();
3356
+ const message = err instanceof Error ? err.message : String(err);
3357
+ console.error(message);
3358
+ process.exitCode = 1;
3359
+ }
3360
+ });
3361
+
3362
+ // Action Items management (subcommands of issues)
3363
+ issues
3364
+ .command("action-items <issueId>")
3365
+ .description("list action items for an issue")
3366
+ .option("--debug", "enable debug output")
3367
+ .option("--json", "output raw JSON")
3368
+ .action(async (issueId: string, opts: { debug?: boolean; json?: boolean }) => {
3369
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching action items...");
3370
+ try {
3371
+ const rootOpts = program.opts<CliOptions>();
3372
+ const cfg = config.readConfig();
3373
+ const { apiKey } = getConfig(rootOpts);
3374
+ if (!apiKey) {
3375
+ spinner.stop();
3376
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
3377
+ process.exitCode = 1;
3378
+ return;
2800
3379
  }
2801
3380
 
3381
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3382
+
3383
+ const result = await fetchActionItems({ apiKey, apiBaseUrl, issueId, debug: !!opts.debug });
3384
+ spinner.stop();
3385
+ printResult(result, opts.json);
3386
+ } catch (err) {
3387
+ spinner.stop();
3388
+ const message = err instanceof Error ? err.message : String(err);
3389
+ console.error(message);
3390
+ process.exitCode = 1;
3391
+ }
3392
+ });
3393
+
3394
+ issues
3395
+ .command("view-action-item <actionItemIds...>")
3396
+ .description("view action item(s) with all details (supports multiple IDs)")
3397
+ .option("--debug", "enable debug output")
3398
+ .option("--json", "output raw JSON")
3399
+ .action(async (actionItemIds: string[], opts: { debug?: boolean; json?: boolean }) => {
3400
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching action item(s)...");
3401
+ try {
2802
3402
  const rootOpts = program.opts<CliOptions>();
2803
3403
  const cfg = config.readConfig();
2804
3404
  const { apiKey } = getConfig(rootOpts);
2805
3405
  if (!apiKey) {
3406
+ spinner.stop();
2806
3407
  console.error("API key is required. Run 'pgai auth' first or set --api-key.");
2807
3408
  process.exitCode = 1;
2808
3409
  return;
@@ -2810,15 +3411,172 @@ issues
2810
3411
 
2811
3412
  const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
2812
3413
 
2813
- const result = await updateIssueComment({
3414
+ const result = await fetchActionItem({ apiKey, apiBaseUrl, actionItemIds, debug: !!opts.debug });
3415
+ if (result.length === 0) {
3416
+ spinner.stop();
3417
+ console.error("Action item(s) not found");
3418
+ process.exitCode = 1;
3419
+ return;
3420
+ }
3421
+ spinner.stop();
3422
+ printResult(result, opts.json);
3423
+ } catch (err) {
3424
+ spinner.stop();
3425
+ const message = err instanceof Error ? err.message : String(err);
3426
+ console.error(message);
3427
+ process.exitCode = 1;
3428
+ }
3429
+ });
3430
+
3431
+ issues
3432
+ .command("create-action-item <issueId> <title>")
3433
+ .description("create a new action item for an issue")
3434
+ .option("--description <text>", "detailed description (use \\n for newlines)")
3435
+ .option("--sql-action <sql>", "SQL command to execute")
3436
+ .option("--config <json>", "config change as JSON: {\"parameter\":\"...\",\"value\":\"...\"} (repeatable)", (value: string, previous: ConfigChange[]) => {
3437
+ try {
3438
+ previous.push(JSON.parse(value) as ConfigChange);
3439
+ } catch {
3440
+ console.error(`Invalid JSON for --config: ${value}`);
3441
+ process.exit(1);
3442
+ }
3443
+ return previous;
3444
+ }, [] as ConfigChange[])
3445
+ .option("--debug", "enable debug output")
3446
+ .option("--json", "output raw JSON")
3447
+ .action(async (issueId: string, rawTitle: string, opts: { description?: string; sqlAction?: string; config?: ConfigChange[]; debug?: boolean; json?: boolean }) => {
3448
+ const rootOpts = program.opts<CliOptions>();
3449
+ const cfg = config.readConfig();
3450
+ const { apiKey } = getConfig(rootOpts);
3451
+ if (!apiKey) {
3452
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
3453
+ process.exitCode = 1;
3454
+ return;
3455
+ }
3456
+
3457
+ const title = interpretEscapes(String(rawTitle || "").trim());
3458
+ if (!title) {
3459
+ console.error("title is required");
3460
+ process.exitCode = 1;
3461
+ return;
3462
+ }
3463
+
3464
+ const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
3465
+ const sqlAction = opts.sqlAction;
3466
+ const configs = Array.isArray(opts.config) && opts.config.length > 0 ? opts.config : undefined;
3467
+
3468
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Creating action item...");
3469
+ try {
3470
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3471
+ const result = await createActionItem({
2814
3472
  apiKey,
2815
3473
  apiBaseUrl,
2816
- commentId,
2817
- content,
3474
+ issueId,
3475
+ title,
3476
+ description,
3477
+ sqlAction,
3478
+ configs,
2818
3479
  debug: !!opts.debug,
2819
3480
  });
2820
- printResult(result, opts.json);
3481
+ spinner.stop();
3482
+ printResult({ id: result }, opts.json);
2821
3483
  } catch (err) {
3484
+ spinner.stop();
3485
+ const message = err instanceof Error ? err.message : String(err);
3486
+ console.error(message);
3487
+ process.exitCode = 1;
3488
+ }
3489
+ });
3490
+
3491
+ issues
3492
+ .command("update-action-item <actionItemId>")
3493
+ .description("update an action item (title, description, status, sql_action, configs)")
3494
+ .option("--title <text>", "new title (use \\n for newlines)")
3495
+ .option("--description <text>", "new description (use \\n for newlines)")
3496
+ .option("--done", "mark as done")
3497
+ .option("--not-done", "mark as not done")
3498
+ .option("--status <value>", "status: waiting_for_approval|approved|rejected")
3499
+ .option("--status-reason <text>", "reason for status change")
3500
+ .option("--sql-action <sql>", "SQL command (use empty string to clear)")
3501
+ .option("--config <json>", "config change as JSON (repeatable, replaces all configs)", (value: string, previous: ConfigChange[]) => {
3502
+ try {
3503
+ previous.push(JSON.parse(value) as ConfigChange);
3504
+ } catch {
3505
+ console.error(`Invalid JSON for --config: ${value}`);
3506
+ process.exit(1);
3507
+ }
3508
+ return previous;
3509
+ }, [] as ConfigChange[])
3510
+ .option("--clear-configs", "clear all config changes")
3511
+ .option("--debug", "enable debug output")
3512
+ .option("--json", "output raw JSON")
3513
+ .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 }) => {
3514
+ const rootOpts = program.opts<CliOptions>();
3515
+ const cfg = config.readConfig();
3516
+ const { apiKey } = getConfig(rootOpts);
3517
+ if (!apiKey) {
3518
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
3519
+ process.exitCode = 1;
3520
+ return;
3521
+ }
3522
+
3523
+ const title = opts.title !== undefined ? interpretEscapes(String(opts.title)) : undefined;
3524
+ const description = opts.description !== undefined ? interpretEscapes(String(opts.description)) : undefined;
3525
+
3526
+ let isDone: boolean | undefined = undefined;
3527
+ if (opts.done) isDone = true;
3528
+ else if (opts.notDone) isDone = false;
3529
+
3530
+ let status: string | undefined = undefined;
3531
+ if (opts.status !== undefined) {
3532
+ const validStatuses = ["waiting_for_approval", "approved", "rejected"];
3533
+ if (!validStatuses.includes(opts.status)) {
3534
+ console.error(`status must be one of: ${validStatuses.join(", ")}`);
3535
+ process.exitCode = 1;
3536
+ return;
3537
+ }
3538
+ status = opts.status;
3539
+ }
3540
+
3541
+ const statusReason = opts.statusReason;
3542
+ const sqlAction = opts.sqlAction;
3543
+
3544
+ let configs: ConfigChange[] | undefined = undefined;
3545
+ if (opts.clearConfigs) {
3546
+ configs = [];
3547
+ } else if (Array.isArray(opts.config) && opts.config.length > 0) {
3548
+ configs = opts.config;
3549
+ }
3550
+
3551
+ // Check that at least one update field is provided
3552
+ if (title === undefined && description === undefined &&
3553
+ isDone === undefined && status === undefined && statusReason === undefined &&
3554
+ sqlAction === undefined && configs === undefined) {
3555
+ console.error("At least one update option is required");
3556
+ process.exitCode = 1;
3557
+ return;
3558
+ }
3559
+
3560
+ const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Updating action item...");
3561
+ try {
3562
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
3563
+ await updateActionItem({
3564
+ apiKey,
3565
+ apiBaseUrl,
3566
+ actionItemId,
3567
+ title,
3568
+ description,
3569
+ isDone,
3570
+ status,
3571
+ statusReason,
3572
+ sqlAction,
3573
+ configs,
3574
+ debug: !!opts.debug,
3575
+ });
3576
+ spinner.stop();
3577
+ printResult({ success: true }, opts.json);
3578
+ } catch (err) {
3579
+ spinner.stop();
2822
3580
  const message = err instanceof Error ? err.message : String(err);
2823
3581
  console.error(message);
2824
3582
  process.exitCode = 1;