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.
- package/README.md +32 -0
- package/bin/postgres-ai.ts +1234 -170
- package/dist/bin/postgres-ai.js +2480 -410
- package/dist/sql/02.extensions.sql +8 -0
- package/dist/sql/{02.permissions.sql → 03.permissions.sql} +1 -0
- package/dist/sql/sql/02.extensions.sql +8 -0
- package/dist/sql/sql/{02.permissions.sql → 03.permissions.sql} +1 -0
- package/dist/sql/sql/uninit/01.helpers.sql +5 -0
- package/dist/sql/sql/uninit/02.permissions.sql +30 -0
- package/dist/sql/sql/uninit/03.role.sql +27 -0
- package/dist/sql/uninit/01.helpers.sql +5 -0
- package/dist/sql/uninit/02.permissions.sql +30 -0
- package/dist/sql/uninit/03.role.sql +27 -0
- package/lib/checkup.ts +69 -3
- package/lib/init.ts +184 -26
- package/lib/issues.ts +453 -7
- package/lib/mcp-server.ts +180 -3
- package/lib/metrics-embedded.ts +3 -3
- package/lib/supabase.ts +824 -0
- package/package.json +1 -1
- package/sql/02.extensions.sql +8 -0
- package/sql/{02.permissions.sql → 03.permissions.sql} +1 -0
- package/sql/uninit/01.helpers.sql +5 -0
- package/sql/uninit/02.permissions.sql +30 -0
- package/sql/uninit/03.role.sql +27 -0
- package/test/checkup.test.ts +240 -14
- package/test/config-consistency.test.ts +36 -0
- package/test/init.integration.test.ts +80 -71
- package/test/init.test.ts +501 -2
- package/test/issues.cli.test.ts +224 -0
- package/test/mcp-server.test.ts +551 -12
- package/test/supabase.test.ts +568 -0
- package/test/test-utils.ts +6 -0
- /package/dist/sql/{03.optional_rds.sql → 04.optional_rds.sql} +0 -0
- /package/dist/sql/{04.optional_self_managed.sql → 05.optional_self_managed.sql} +0 -0
- /package/dist/sql/{05.helpers.sql → 06.helpers.sql} +0 -0
- /package/dist/sql/sql/{03.optional_rds.sql → 04.optional_rds.sql} +0 -0
- /package/dist/sql/sql/{04.optional_self_managed.sql → 05.optional_self_managed.sql} +0 -0
- /package/dist/sql/sql/{05.helpers.sql → 06.helpers.sql} +0 -0
- /package/sql/{03.optional_rds.sql → 04.optional_rds.sql} +0 -0
- /package/sql/{04.optional_self_managed.sql → 05.optional_self_managed.sql} +0 -0
- /package/sql/{05.helpers.sql → 06.helpers.sql} +0 -0
package/bin/postgres-ai.ts
CHANGED
|
@@ -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
|
-
|
|
628
|
-
|
|
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
|
-
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
console.error(
|
|
693
|
-
|
|
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
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
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
|
-
|
|
836
|
-
|
|
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
|
-
|
|
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
|
-
|
|
843
|
-
|
|
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
|
-
|
|
846
|
-
|
|
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
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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 === "
|
|
867
|
-
|
|
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
|
-
//
|
|
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 >
|
|
1225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
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 (
|
|
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
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
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
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
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
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
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
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
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 (
|
|
2709
|
-
.option("--description <text>", "new description (
|
|
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
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
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
|
-
|
|
3567
|
+
const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
|
|
2735
3568
|
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
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
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
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
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
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
|
|
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
|
-
|
|
2817
|
-
|
|
3780
|
+
issueId,
|
|
3781
|
+
title,
|
|
3782
|
+
description,
|
|
3783
|
+
sqlAction,
|
|
3784
|
+
configs,
|
|
2818
3785
|
debug: !!opts.debug,
|
|
2819
3786
|
});
|
|
2820
|
-
|
|
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;
|