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.
Files changed (2) hide show
  1. package/bin/qualty.js +218 -56
  2. 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
- /** Default Qualty API base URL (no trailing slash). Override with QUALTY_API_URL env. */
21
- //const DEFAULT_QUALTY_API_URL = "https://qualty-api-production.up.railway.app";
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
- void args;
99
- return String(process.env.QUALTY_API_URL || DEFAULT_QUALTY_API_URL).replace(/\/$/, "");
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 || process.env.QUALTY_ORG_ID || projectCfg.default_org_id || "";
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
- " QUALTY_API_URL API base URL (default: bundled CLI default)",
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 provide QUALTY_ORG_ID.");
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
- const isLocalDefault = apiUrlNorm === "http://127.0.0.1:8000";
594
- if (apiUrlNorm && !isLocalDefault) {
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
- ` AUTH_DIR="$HOME/.qualty/local-contexts/${orgSeg}/${projectSeg}"`,
678
- ` mkdir -p "$AUTH_DIR"`,
679
- ` echo "$QUALTY_AUTH_STATE_B64" | base64 -d | gunzip > "$AUTH_DIR/${profileSeg}.json"`,
680
- ` test -s "$AUTH_DIR/${profileSeg}.json"`,
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
- ` QUALTY_ORG_ID: \${{ vars.QUALTY_ORG_ID }}`,
847
+ ` QUALTY_BASE_URL: \${{ secrets.QUALTY_BASE_URL }}`,
686
848
  ` run: |`,
687
- ` npx qualty@latest run --local \\`,
688
- ` --project ${projectId} \\`,
689
- ` --suite-id YOUR_SUITE_UUID \\`,
690
- ` --auth-profile "${profile}" \\`,
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 = resolveOrgId(args);
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
- `Local auth state not found:`,
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
- ].join("\n")
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 = encodeAuthStateForGithubSecret(statePath, { gzip });
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
- encoded = encodeAuthStateForGithubSecret(statePath, { gzip: true });
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: re-run with --suite-id <uuid> to set QUALTY_SUITE_ID (or set it in the GitHub UI)."
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualty",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Qualty CLI for localhost and CI test runs",
5
5
  "bin": {
6
6
  "qualty": "bin/qualty.js"