qualty 0.1.21 → 0.1.22
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/bin/qualty.js +218 -56
- package/package.json +1 -1
package/bin/qualty.js
CHANGED
|
@@ -17,9 +17,8 @@ import { createInterface } from "node:readline/promises";
|
|
|
17
17
|
import process from "node:process";
|
|
18
18
|
import { rewriteUrlForLocalRun, runLocalCi, summarizeStorageStateFile, logAuthStateDiagnostics } from "./local-runner.js";
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
const DEFAULT_QUALTY_API_URL = "http://127.0.0.1:8000";
|
|
20
|
+
/** CLI backend URL is fixed by Qualty distribution. */
|
|
21
|
+
const DEFAULT_QUALTY_API_URL = "https://qualty-api-production.up.railway.app";
|
|
23
22
|
|
|
24
23
|
const PROJECT_CONFIG_FILENAME = ".qualty.json";
|
|
25
24
|
const QUALTY_SHARED_CREDS_DIR = join(homedir(), ".qualty");
|
|
@@ -66,6 +65,10 @@ function projectIdFromConfig(projectCfg, fallback = "") {
|
|
|
66
65
|
return String(fallback || projectCfg.default_project_id || "").trim();
|
|
67
66
|
}
|
|
68
67
|
|
|
68
|
+
function orgIdFromConfig(projectCfg, fallback = "") {
|
|
69
|
+
return String(fallback || projectCfg.default_org_id || "").trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
69
72
|
function authProfileFromConfig(projectCfg, fallback = "") {
|
|
70
73
|
return String(fallback || projectCfg.default_auth_profile || "").trim();
|
|
71
74
|
}
|
|
@@ -94,9 +97,39 @@ function writeProjectConfig(updates) {
|
|
|
94
97
|
writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
95
98
|
}
|
|
96
99
|
|
|
100
|
+
function normalizeApiUrl(raw, { label = "API URL" } = {}) {
|
|
101
|
+
const trimmed = String(raw || "").trim();
|
|
102
|
+
if (!trimmed) {
|
|
103
|
+
throw new Error(`${label} is empty.`);
|
|
104
|
+
}
|
|
105
|
+
let parsed;
|
|
106
|
+
try {
|
|
107
|
+
parsed = new URL(trimmed);
|
|
108
|
+
} catch {
|
|
109
|
+
throw new Error(`${label} is not a valid URL: ${trimmed}`);
|
|
110
|
+
}
|
|
111
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
112
|
+
throw new Error(`${label} must use http or https (got ${parsed.protocol}).`);
|
|
113
|
+
}
|
|
114
|
+
return trimmed.replace(/\/$/, "");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function qualtyBaseUrlFromEnv() {
|
|
118
|
+
return String(process.env.QUALTY_BASE_URL || process.env.QUALTY_API_URL || "").trim();
|
|
119
|
+
}
|
|
120
|
+
|
|
97
121
|
function resolveApiUrl(args = {}) {
|
|
98
|
-
|
|
99
|
-
|
|
122
|
+
const fromFlag = String(args.url || args["api-url"] || "").trim();
|
|
123
|
+
const fromEnv = qualtyBaseUrlFromEnv();
|
|
124
|
+
const raw = fromFlag || fromEnv || DEFAULT_QUALTY_API_URL;
|
|
125
|
+
const label = fromFlag
|
|
126
|
+
? "--url"
|
|
127
|
+
: process.env.QUALTY_BASE_URL
|
|
128
|
+
? "QUALTY_BASE_URL"
|
|
129
|
+
: fromEnv
|
|
130
|
+
? "QUALTY_API_URL"
|
|
131
|
+
: "default API URL";
|
|
132
|
+
return normalizeApiUrl(raw, { label });
|
|
100
133
|
}
|
|
101
134
|
|
|
102
135
|
function resolveToken(args = {}) {
|
|
@@ -106,7 +139,7 @@ function resolveToken(args = {}) {
|
|
|
106
139
|
|
|
107
140
|
function resolveOrgId(args = {}) {
|
|
108
141
|
const projectCfg = readProjectConfig();
|
|
109
|
-
return args.org
|
|
142
|
+
return orgIdFromConfig(projectCfg, args.org);
|
|
110
143
|
}
|
|
111
144
|
|
|
112
145
|
function maskSecret(secret) {
|
|
@@ -145,8 +178,8 @@ function usage() {
|
|
|
145
178
|
"Usage:",
|
|
146
179
|
" qualty setup [--token <bearer-token>] [--org <org-id>]",
|
|
147
180
|
" qualty auth setup [--project <project-id>] [--profile <profile-name>] [--headed]",
|
|
148
|
-
" qualty auth export-ci --project <project-id> [--profile <profile-name>] [--org <org-id>]",
|
|
149
|
-
" [--suite-id <suite-uuid>] [--repo <owner/repo>]",
|
|
181
|
+
" qualty auth export-ci [--project <project-id>] [--profile <profile-name>] [--org <org-id>]",
|
|
182
|
+
" [--url <api-base-url>] [--suite-id <suite-uuid>] [--repo <owner/repo>]",
|
|
150
183
|
" [--output <path>] [--no-file] [--copy] [--gzip] [--include-token]",
|
|
151
184
|
" [--set-secret | --set-github] gh: secrets (token, API URL, auth) + variables (org, project, profile, …)",
|
|
152
185
|
" [--cli-repo <owner/repo>] [--cli-ref <branch>] Optional CI variables",
|
|
@@ -161,9 +194,11 @@ function usage() {
|
|
|
161
194
|
"",
|
|
162
195
|
"Env vars:",
|
|
163
196
|
" QUALTY_API_TOKEN Bearer token used for auth",
|
|
164
|
-
"
|
|
165
|
-
" QUALTY_ORG_ID Org context override (default: .qualty.json default_org_id)",
|
|
197
|
+
" QUALTY_BASE_URL API base URL when --url is not set (default: production)",
|
|
166
198
|
" QUALTY_LOCAL_CONCURRENCY Local parallel workers for --local runs (default: 4)",
|
|
199
|
+
"",
|
|
200
|
+
"Config (.qualty.json): default_org_id, default_project_id, default_auth_profile",
|
|
201
|
+
" CLI flags (--org, --project, --profile, --url) override config values.",
|
|
167
202
|
].join("\n")
|
|
168
203
|
);
|
|
169
204
|
}
|
|
@@ -371,7 +406,7 @@ async function runSetup(args) {
|
|
|
371
406
|
let currentOrgName = "";
|
|
372
407
|
try {
|
|
373
408
|
const orgResp = await apiRequest({
|
|
374
|
-
apiUrl: resolveApiUrl({}
|
|
409
|
+
apiUrl: resolveApiUrl({}),
|
|
375
410
|
token,
|
|
376
411
|
orgId: "",
|
|
377
412
|
path: "/api/v1/orgs",
|
|
@@ -440,7 +475,7 @@ async function runSetup(args) {
|
|
|
440
475
|
}
|
|
441
476
|
|
|
442
477
|
if (!orgId) {
|
|
443
|
-
throw new Error("Org ID is required. Re-run setup and
|
|
478
|
+
throw new Error("Org ID is required. Re-run setup and enter your org ID when prompted.");
|
|
444
479
|
}
|
|
445
480
|
|
|
446
481
|
writeSharedCredentials(token);
|
|
@@ -590,14 +625,8 @@ function syncGithubCiConfig({
|
|
|
590
625
|
}
|
|
591
626
|
|
|
592
627
|
const apiUrlNorm = String(apiUrl || "").replace(/\/$/, "");
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
record("secret", "QUALTY_API_URL", setGithubSecret("QUALTY_API_URL", apiUrlNorm, { repo }));
|
|
596
|
-
} else {
|
|
597
|
-
record("secret", "QUALTY_API_URL", {
|
|
598
|
-
ok: false,
|
|
599
|
-
reason: "Set QUALTY_API_URL to your dev/prod API (not localhost) before export-ci.",
|
|
600
|
-
});
|
|
628
|
+
if (apiUrlNorm) {
|
|
629
|
+
record("secret", "QUALTY_BASE_URL", setGithubSecret("QUALTY_BASE_URL", apiUrlNorm, { repo }));
|
|
601
630
|
}
|
|
602
631
|
|
|
603
632
|
record("variable", "QUALTY_ORG_ID", setGithubVariable("QUALTY_ORG_ID", orgId, { repo }));
|
|
@@ -649,6 +678,134 @@ function printGithubSyncResults(results, { repo }) {
|
|
|
649
678
|
console.log("");
|
|
650
679
|
}
|
|
651
680
|
|
|
681
|
+
function readProjectConfigStrict() {
|
|
682
|
+
const configPath = findProjectConfigPath();
|
|
683
|
+
if (!configPath) {
|
|
684
|
+
return { configPath: null, config: {} };
|
|
685
|
+
}
|
|
686
|
+
let raw;
|
|
687
|
+
try {
|
|
688
|
+
raw = readFileSync(configPath, "utf8");
|
|
689
|
+
} catch (err) {
|
|
690
|
+
throw new Error(
|
|
691
|
+
`Could not read ${PROJECT_CONFIG_FILENAME} at ${configPath}: ${err.message}`
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
let parsed;
|
|
695
|
+
try {
|
|
696
|
+
parsed = JSON.parse(raw);
|
|
697
|
+
} catch (err) {
|
|
698
|
+
throw new Error(
|
|
699
|
+
[
|
|
700
|
+
`Invalid ${PROJECT_CONFIG_FILENAME} at ${configPath}: ${err.message}`,
|
|
701
|
+
"Fix the JSON syntax or pass --org, --project, and --profile on the command line.",
|
|
702
|
+
].join("\n")
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
706
|
+
throw new Error(
|
|
707
|
+
[
|
|
708
|
+
`Invalid ${PROJECT_CONFIG_FILENAME} at ${configPath}: expected a JSON object.`,
|
|
709
|
+
"Fix the file or pass --org, --project, and --profile on the command line.",
|
|
710
|
+
].join("\n")
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
return { configPath, config: parsed };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function configStringValue(projectCfg, key, configPath) {
|
|
717
|
+
const value = projectCfg[key];
|
|
718
|
+
if (value === undefined || value === null || value === "") return "";
|
|
719
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
720
|
+
return String(value).trim();
|
|
721
|
+
}
|
|
722
|
+
const where = configPath
|
|
723
|
+
? `${PROJECT_CONFIG_FILENAME} at ${configPath}`
|
|
724
|
+
: PROJECT_CONFIG_FILENAME;
|
|
725
|
+
throw new Error(
|
|
726
|
+
`Invalid ${key} in ${where}: expected a string or number, got ${typeof value}.`
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function missingExportCiField(fieldLabel, { flag, configKey, configPath, hasFlag }) {
|
|
731
|
+
const configHint = configPath
|
|
732
|
+
? `${PROJECT_CONFIG_FILENAME} at ${configPath} (${configKey})`
|
|
733
|
+
: `${PROJECT_CONFIG_FILENAME} (not found walking up from ${process.cwd()})`;
|
|
734
|
+
if (hasFlag) {
|
|
735
|
+
return [
|
|
736
|
+
`Missing ${fieldLabel}: ${flag} was empty.`,
|
|
737
|
+
`Set ${configKey} in ${configHint} or pass a non-empty ${flag}.`,
|
|
738
|
+
].join(" ");
|
|
739
|
+
}
|
|
740
|
+
return [
|
|
741
|
+
`Missing ${fieldLabel}.`,
|
|
742
|
+
`Pass ${flag} or set ${configKey} in ${configHint}.`,
|
|
743
|
+
].join(" ");
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function resolveExportCiConfig(args) {
|
|
747
|
+
const { configPath, config: projectCfg } = readProjectConfigStrict();
|
|
748
|
+
|
|
749
|
+
const orgFromFlag = String(args.org || "").trim();
|
|
750
|
+
const projectFromFlag = String(args.project || "").trim();
|
|
751
|
+
const profileFromFlag = String(args.profile || args["auth-profile"] || "").trim();
|
|
752
|
+
|
|
753
|
+
let orgId = orgFromFlag;
|
|
754
|
+
if (!orgId) {
|
|
755
|
+
orgId = configStringValue(projectCfg, "default_org_id", configPath);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
let projectId = projectFromFlag;
|
|
759
|
+
if (!projectId) {
|
|
760
|
+
projectId = configStringValue(projectCfg, "default_project_id", configPath);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
let profile = profileFromFlag;
|
|
764
|
+
if (!profile) {
|
|
765
|
+
profile = configStringValue(projectCfg, "default_auth_profile", configPath);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (!orgId) {
|
|
769
|
+
throw new Error(
|
|
770
|
+
missingExportCiField("org ID", {
|
|
771
|
+
flag: "--org",
|
|
772
|
+
configKey: "default_org_id",
|
|
773
|
+
configPath,
|
|
774
|
+
hasFlag: Boolean(orgFromFlag),
|
|
775
|
+
})
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
if (!projectId) {
|
|
779
|
+
throw new Error(
|
|
780
|
+
missingExportCiField("project ID", {
|
|
781
|
+
flag: "--project",
|
|
782
|
+
configKey: "default_project_id",
|
|
783
|
+
configPath,
|
|
784
|
+
hasFlag: Boolean(projectFromFlag),
|
|
785
|
+
})
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
if (!profile) {
|
|
789
|
+
throw new Error(
|
|
790
|
+
missingExportCiField("auth profile", {
|
|
791
|
+
flag: "--profile",
|
|
792
|
+
configKey: "default_auth_profile",
|
|
793
|
+
configPath,
|
|
794
|
+
hasFlag: Boolean(profileFromFlag),
|
|
795
|
+
})
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
let apiUrl;
|
|
800
|
+
try {
|
|
801
|
+
apiUrl = resolveApiUrl(args);
|
|
802
|
+
} catch (err) {
|
|
803
|
+
throw new Error(`Invalid API URL: ${err.message}`);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return { orgId, projectId, profile, apiUrl, configPath };
|
|
807
|
+
}
|
|
808
|
+
|
|
652
809
|
function encodeAuthStateForGithubSecret(statePath, { gzip = false } = {}) {
|
|
653
810
|
const raw = readFileSync(statePath);
|
|
654
811
|
if (gzip) {
|
|
@@ -666,62 +823,55 @@ function encodeAuthStateForGithubSecret(statePath, { gzip = false } = {}) {
|
|
|
666
823
|
}
|
|
667
824
|
|
|
668
825
|
function githubAuthRestoreShell({ orgId, projectId, profile }) {
|
|
669
|
-
const orgSeg = safePathSegment(orgId, "default-org");
|
|
670
|
-
const projectSeg = safePathSegment(projectId, "default-project");
|
|
671
|
-
const profileSeg = safePathSegment(profile, "default");
|
|
672
826
|
return [
|
|
673
827
|
`- name: Restore Qualty login state`,
|
|
674
828
|
` env:`,
|
|
675
829
|
` QUALTY_AUTH_STATE_B64: \${{ secrets.QUALTY_AUTH_STATE_B64 }}`,
|
|
676
830
|
` run: |`,
|
|
677
|
-
`
|
|
678
|
-
`
|
|
679
|
-
`
|
|
680
|
-
`
|
|
831
|
+
` set -euo pipefail`,
|
|
832
|
+
` slug_segment() {`,
|
|
833
|
+
` printf '%s' "$1" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g; s/[^a-zA-Z0-9._-]+/_/g; s/^_+|_+$//g'`,
|
|
834
|
+
` }`,
|
|
835
|
+
` org_seg=$(slug_segment "$QUALTY_ORG_ID"); [ -n "$org_seg" ] || org_seg="default-org"`,
|
|
836
|
+
` project_seg=$(slug_segment "$QUALTY_PROJECT_ID"); [ -n "$project_seg" ] || project_seg="default-project"`,
|
|
837
|
+
` profile_seg=$(slug_segment "$QUALTY_AUTH_PROFILE"); [ -n "$profile_seg" ] || profile_seg="default"`,
|
|
838
|
+
` auth_dir="$HOME/.qualty/local-contexts/\${org_seg}/\${project_seg}"`,
|
|
839
|
+
` auth_file="\${auth_dir}/\${profile_seg}.json"`,
|
|
840
|
+
` mkdir -p "$auth_dir"`,
|
|
841
|
+
` echo "$QUALTY_AUTH_STATE_B64" | base64 -d | gunzip > "$auth_file"`,
|
|
842
|
+
` test -s "$auth_file"`,
|
|
681
843
|
``,
|
|
682
|
-
`- name: Run Qualty (local)`,
|
|
844
|
+
`- name: Run Qualty tests (local)`,
|
|
683
845
|
` env:`,
|
|
684
846
|
` QUALTY_API_TOKEN: \${{ secrets.QUALTY_API_TOKEN }}`,
|
|
685
|
-
`
|
|
847
|
+
` QUALTY_BASE_URL: \${{ secrets.QUALTY_BASE_URL }}`,
|
|
686
848
|
` run: |`,
|
|
687
|
-
` npx qualty
|
|
688
|
-
` --
|
|
689
|
-
` --
|
|
690
|
-
` --
|
|
849
|
+
` npx --yes qualty run --local \\`,
|
|
850
|
+
` --org "$QUALTY_ORG_ID" \\`,
|
|
851
|
+
` --project "$QUALTY_PROJECT_ID" \\`,
|
|
852
|
+
` --suite-id "YOUR-SUITE-UUID" \\`,
|
|
853
|
+
` --auth-profile "$QUALTY_AUTH_PROFILE" \\`,
|
|
691
854
|
` --fail-on-failure true`,
|
|
692
855
|
].join("\n");
|
|
693
856
|
}
|
|
694
857
|
|
|
695
858
|
async function runAuthExportCi(args) {
|
|
696
|
-
const orgId =
|
|
859
|
+
const { orgId, projectId, profile, apiUrl, configPath } = resolveExportCiConfig(args);
|
|
697
860
|
const token = resolveToken(args);
|
|
698
|
-
const projectCfg = readProjectConfig();
|
|
699
|
-
const projectId = projectIdFromConfig(projectCfg, args.project);
|
|
700
|
-
const profile = authProfileFromConfig(
|
|
701
|
-
projectCfg,
|
|
702
|
-
args.profile || args["auth-profile"]
|
|
703
|
-
);
|
|
704
|
-
|
|
705
|
-
if (!orgId) {
|
|
706
|
-
throw new Error("Missing org. Pass --org, set QUALTY_ORG_ID, or add default_org_id to .qualty.json.");
|
|
707
|
-
}
|
|
708
|
-
if (!projectId) {
|
|
709
|
-
throw new Error("Missing project. Pass --project (or run qualty auth setup first).");
|
|
710
|
-
}
|
|
711
|
-
if (!profile) {
|
|
712
|
-
throw new Error('Missing profile. Pass --profile "your-profile" (or run qualty auth setup first).');
|
|
713
|
-
}
|
|
714
861
|
|
|
715
862
|
const statePath = localAuthStatePath({ orgId, projectId, profile });
|
|
716
863
|
if (!existsSync(statePath)) {
|
|
717
864
|
throw new Error(
|
|
718
865
|
[
|
|
719
|
-
|
|
866
|
+
"Local auth state not found:",
|
|
720
867
|
` ${statePath}`,
|
|
721
868
|
"",
|
|
722
869
|
"Create it first:",
|
|
723
870
|
` qualty auth setup --project ${projectId} --profile "${profile}"`,
|
|
724
|
-
|
|
871
|
+
configPath ? ` (org/project/profile from ${configPath})` : "",
|
|
872
|
+
]
|
|
873
|
+
.filter(Boolean)
|
|
874
|
+
.join("\n")
|
|
725
875
|
);
|
|
726
876
|
}
|
|
727
877
|
|
|
@@ -729,11 +879,24 @@ async function runAuthExportCi(args) {
|
|
|
729
879
|
parseBoolean(args["set-github"], false) || parseBoolean(args["set-secret"], false);
|
|
730
880
|
// CI path: one format only — gzip then base64 in QUALTY_AUTH_STATE_B64 (no separate gzip flag).
|
|
731
881
|
let gzip = parseBoolean(args.gzip, false) || setGithubFlag;
|
|
732
|
-
let encoded
|
|
882
|
+
let encoded;
|
|
883
|
+
try {
|
|
884
|
+
encoded = encodeAuthStateForGithubSecret(statePath, { gzip });
|
|
885
|
+
} catch (err) {
|
|
886
|
+
throw new Error(
|
|
887
|
+
`Could not read auth state at ${statePath}: ${err.message}`
|
|
888
|
+
);
|
|
889
|
+
}
|
|
733
890
|
let secretBytes = Buffer.byteLength(encoded.payload, "utf8");
|
|
734
891
|
if (!gzip && secretBytes > GITHUB_SECRET_MAX_BYTES - 1024) {
|
|
735
892
|
gzip = true;
|
|
736
|
-
|
|
893
|
+
try {
|
|
894
|
+
encoded = encodeAuthStateForGithubSecret(statePath, { gzip: true });
|
|
895
|
+
} catch (err) {
|
|
896
|
+
throw new Error(
|
|
897
|
+
`Could not gzip auth state at ${statePath}: ${err.message}`
|
|
898
|
+
);
|
|
899
|
+
}
|
|
737
900
|
secretBytes = Buffer.byteLength(encoded.payload, "utf8");
|
|
738
901
|
}
|
|
739
902
|
if (secretBytes > GITHUB_SECRET_MAX_BYTES - 1024) {
|
|
@@ -752,7 +915,6 @@ async function runAuthExportCi(args) {
|
|
|
752
915
|
const suiteId = String(args["suite-id"] || args.suite || "").trim();
|
|
753
916
|
const cliRepo = String(args["cli-repo"] || "").trim();
|
|
754
917
|
const cliRef = String(args["cli-ref"] || "").trim();
|
|
755
|
-
const apiUrl = resolveApiUrl(args);
|
|
756
918
|
|
|
757
919
|
if (outputPath) {
|
|
758
920
|
writeFileSync(outputPath, encoded.payload, "utf8");
|
|
@@ -835,7 +997,7 @@ async function runAuthExportCi(args) {
|
|
|
835
997
|
if (!suiteId) {
|
|
836
998
|
// eslint-disable-next-line no-console
|
|
837
999
|
console.log(
|
|
838
|
-
" Tip:
|
|
1000
|
+
" Tip: hardcode --suite-id or --ids in your workflow (see templates/github-actions/qualty-local-pr.yml)."
|
|
839
1001
|
);
|
|
840
1002
|
}
|
|
841
1003
|
// eslint-disable-next-line no-console
|