qualty 0.1.30 → 0.1.31

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 CHANGED
@@ -9,19 +9,31 @@ import {
9
9
  readdirSync,
10
10
  writeFileSync,
11
11
  } from "node:fs";
12
- import { gzipSync } from "node:zlib";
13
12
  import { spawn, spawnSync } from "node:child_process";
14
13
  import { homedir, hostname } from "node:os";
15
14
  import { dirname, join } from "node:path";
16
15
  import { createInterface } from "node:readline/promises";
17
16
  import process from "node:process";
18
17
  import { rewriteUrlForLocalRun, runLocalCi, summarizeStorageStateFile, logAuthStateDiagnostics } from "./local-runner.js";
18
+ import {
19
+ AUTH_PROFILES_VARIABLE,
20
+ AUTH_SECRET_PRIMARY,
21
+ AUTH_SHARDS_VARIABLE,
22
+ authShardSecretName,
23
+ buildAuthBundleManifest,
24
+ collectAuthShardPayloadsFromEnv,
25
+ restoreAuthPayloadsToDisk,
26
+ shardAuthBundleManifest,
27
+ } from "./auth-ci-bundle.js";
19
28
 
20
29
  /** CLI backend URL is fixed by Qualty distribution. */
21
- const DEFAULT_QUALTY_API_URL = "https://qualty-api-production.up.railway.app";
30
+ // const DEFAULT_QUALTY_API_URL = "https://qualty-api-production.up.railway.app";
22
31
  // const DEFAULT_QUALTY_API_URL = "http://localhost:8000";
32
+ const DEFAULT_QUALTY_API_URL = "https://qualty-api-development.up.railway.app";
33
+
23
34
 
24
35
  const PROJECT_CONFIG_FILENAME = ".qualty.json";
36
+ const DOTENV_FILENAME = ".env";
25
37
  const QUALTY_SHARED_CREDS_DIR = join(homedir(), ".qualty");
26
38
  const QUALTY_SHARED_CREDS_PATH = join(QUALTY_SHARED_CREDS_DIR, "credentials");
27
39
 
@@ -40,6 +52,47 @@ function getProjectConfigWritePath() {
40
52
  return findProjectConfigPath() || join(process.cwd(), PROJECT_CONFIG_FILENAME);
41
53
  }
42
54
 
55
+ function findProjectDotenvPath() {
56
+ let current = process.cwd();
57
+ for (;;) {
58
+ const candidate = join(current, DOTENV_FILENAME);
59
+ if (existsSync(candidate)) return candidate;
60
+ const parent = dirname(current);
61
+ if (parent === current) return null;
62
+ current = parent;
63
+ }
64
+ }
65
+
66
+ function parseDotenvText(text) {
67
+ const out = {};
68
+ for (const raw of String(text || "").split("\n")) {
69
+ const line = raw.trim();
70
+ if (!line || line.startsWith("#") || !line.includes("=")) continue;
71
+ const eq = line.indexOf("=");
72
+ const key = line.slice(0, eq).trim();
73
+ let value = line.slice(eq + 1).trim();
74
+ if (
75
+ (value.startsWith('"') && value.endsWith('"'))
76
+ || (value.startsWith("'") && value.endsWith("'"))
77
+ ) {
78
+ value = value.slice(1, -1);
79
+ }
80
+ if (key) out[key] = value;
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /** Project .env walked up from cwd (same resolution as Qualty MCP). */
86
+ function loadProjectDotenv() {
87
+ const path = findProjectDotenvPath();
88
+ if (!path) return {};
89
+ try {
90
+ return parseDotenvText(readFileSync(path, "utf8"));
91
+ } catch {
92
+ return {};
93
+ }
94
+ }
95
+
43
96
  function readSharedCredentials() {
44
97
  if (!existsSync(QUALTY_SHARED_CREDS_PATH)) return {};
45
98
  try {
@@ -74,6 +127,11 @@ function authProfileFromConfig(projectCfg, fallback = "") {
74
127
  return String(fallback || projectCfg.default_auth_profile || "").trim();
75
128
  }
76
129
 
130
+ /** Only --auth-profile on the CLI; config default is not a run-wide override. */
131
+ function explicitAuthProfileFromArgs(args) {
132
+ return String(args["auth-profile"] || "").trim();
133
+ }
134
+
77
135
  function readProjectConfig() {
78
136
  const configPath = findProjectConfigPath();
79
137
  if (!configPath) return {};
@@ -116,7 +174,14 @@ function normalizeApiUrl(raw, { label = "API URL" } = {}) {
116
174
  }
117
175
 
118
176
  function qualtyBaseUrlFromEnv() {
119
- return String(process.env.QUALTY_BASE_URL || process.env.QUALTY_API_URL || "").trim();
177
+ const dotenv = loadProjectDotenv();
178
+ return String(
179
+ dotenv.QUALTY_BASE_URL
180
+ || dotenv.QUALTY_API_URL
181
+ || process.env.QUALTY_BASE_URL
182
+ || process.env.QUALTY_API_URL
183
+ || ""
184
+ ).trim();
120
185
  }
121
186
 
122
187
  function resolveApiUrl(args = {}) {
@@ -134,8 +199,14 @@ function resolveApiUrl(args = {}) {
134
199
  }
135
200
 
136
201
  function resolveToken(args = {}) {
202
+ if (args.token) return String(args.token).trim();
203
+ const dotenv = loadProjectDotenv();
204
+ const fromDotenv = String(dotenv.QUALTY_API_TOKEN || "").trim();
205
+ if (fromDotenv) return fromDotenv;
206
+ const fromProcessEnv = String(process.env.QUALTY_API_TOKEN || "").trim();
207
+ if (fromProcessEnv) return fromProcessEnv;
137
208
  const creds = readSharedCredentials();
138
- return args.token || process.env.QUALTY_API_TOKEN || creds.api_token || "";
209
+ return String(creds.api_token || "").trim();
139
210
  }
140
211
 
141
212
  function resolveOrgId(args = {}) {
@@ -179,11 +250,19 @@ function usage() {
179
250
  "Usage:",
180
251
  " qualty setup [--token <bearer-token>] [--org <org-id>]",
181
252
  " qualty auth setup [--project <project-id>] [--profile <profile-name>] [--headed]",
182
- " qualty auth export-ci [--project <project-id>] [--profile <profile-name>] [--org <org-id>]",
183
- " [--url <api-base-url>] [--suite-id <suite-uuid>] [--repo <owner/repo>]",
184
- " [--output <path>] [--no-file] [--copy] [--gzip] [--include-token]",
185
- " [--set-secret | --set-github] gh: secrets (token, API URL, auth) + variables (org, project, profile, …)",
186
- " [--cli-repo <owner/repo>] [--cli-ref <branch>] Optional CI variables",
253
+ " qualty auth export [--project <project-id>] [--profile <profile-name>] [--org <org-id>]",
254
+ " [--profiles <name1,name2>] [--repo <owner/repo>]",
255
+ " [--output <path>] [--no-file] [--copy] [--set-secret | --set-github]",
256
+ " Push login profile state to GitHub only (not API token, org, or project vars).",
257
+ " qualty ci setup [--project <project-id>] [--profile <profile-name>] [--org <org-id>]",
258
+ " [--all-profiles] [--profiles <name1,name2>]",
259
+ " [--suite-id <suite-uuid>] [--ids <id1,id2>] bundle login profiles required by tests",
260
+ " [--url <api-base-url>] [--repo <owner/repo>]",
261
+ " [--output <path>] [--no-file] [--copy] [--gzip] [--include-token]",
262
+ " [--set-secret | --set-github] gh: secrets (token, API URL, auth bundle) + variables",
263
+ " [--cli-repo <owner/repo>] [--cli-ref <branch>] Optional CI variables",
264
+ " qualty auth restore-ci [--org <org-id>] [--project <project-id>] [--profile <fallback-profile>]",
265
+ " Reads QUALTY_AUTH_STATE_B64 (+ _2, _3, … shards) and writes ~/.qualty/local-contexts/…",
187
266
  " qualty connect --project <project-id> [--port 3000] [--token <bearer-token>] [--org <org-id>]",
188
267
  " qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--token <bearer-token>] [--org <org-id>]",
189
268
  " [--poll-interval 5] [--timeout 30] [--fail-on-failure true] [--no-view-logs]",
@@ -194,11 +273,11 @@ function usage() {
194
273
  " qualty resolve --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--json] [--token <bearer-token>]",
195
274
  "",
196
275
  "Env vars:",
197
- " QUALTY_API_TOKEN Bearer token used for auth",
276
+ " QUALTY_API_TOKEN Project .env first (same as MCP), then process env, then ~/.qualty/credentials",
198
277
  " QUALTY_BASE_URL API base URL when --url is not set (default: production)",
199
278
  " QUALTY_LOCAL_CONCURRENCY Local parallel workers for --local runs (default: 4)",
200
279
  "",
201
- "Config (.qualty.json): default_org_id, default_project_id, default_auth_profile",
280
+ "Config (.qualty.json): default_org_id, default_project_id, default_auth_profile (ci/auth setup only; local runs use each test's saved login profile unless --auth-profile is set)",
202
281
  " CLI flags (--org, --project, --profile, --url) override config values.",
203
282
  ].join("\n")
204
283
  );
@@ -381,9 +460,7 @@ async function runSetup(args) {
381
460
 
382
461
  try {
383
462
  const currentOrgId = String(args.org || projectCfg.default_org_id || "");
384
- const currentToken = String(
385
- args.token || process.env.QUALTY_API_TOKEN || sharedCreds.api_token || ""
386
- );
463
+ const currentToken = resolveToken(args);
387
464
 
388
465
  let token = "";
389
466
  if (args.token) {
@@ -530,12 +607,10 @@ function localAuthStatePath({ orgId, projectId, profile }) {
530
607
  return join(QUALTY_SHARED_CREDS_DIR, "local-contexts", orgSeg, projectSeg, `${profileSeg}.json`);
531
608
  }
532
609
 
533
- function authExportCiOutputPath(statePath) {
610
+ function ciSetupAuthBundleOutputPath(statePath) {
534
611
  return statePath.replace(/\.json$/i, ".b64");
535
612
  }
536
613
 
537
- const GITHUB_SECRET_MAX_BYTES = 64 * 1024;
538
-
539
614
  function copyToClipboard(text) {
540
615
  if (process.platform === "darwin") {
541
616
  const result = spawnSync("pbcopy", { input: text, encoding: "utf8" });
@@ -601,7 +676,8 @@ function syncGithubCiConfig({
601
676
  orgId,
602
677
  projectId,
603
678
  profile,
604
- authPayload,
679
+ profileNames,
680
+ authShards,
605
681
  apiUrl,
606
682
  token,
607
683
  suiteId,
@@ -613,8 +689,9 @@ function syncGithubCiConfig({
613
689
  results.push({ kind, name, ...outcome });
614
690
  };
615
691
 
616
- const authResult = setGithubSecret("QUALTY_AUTH_STATE_B64", authPayload, { repo });
617
- record("secret", "QUALTY_AUTH_STATE_B64", authResult);
692
+ for (const shard of authShards || []) {
693
+ record("secret", shard.secretName, setGithubSecret(shard.secretName, shard.payload, { repo }));
694
+ }
618
695
 
619
696
  if (token) {
620
697
  record("secret", "QUALTY_API_TOKEN", setGithubSecret("QUALTY_API_TOKEN", token, { repo }));
@@ -632,7 +709,21 @@ function syncGithubCiConfig({
632
709
 
633
710
  record("variable", "QUALTY_ORG_ID", setGithubVariable("QUALTY_ORG_ID", orgId, { repo }));
634
711
  record("variable", "QUALTY_PROJECT_ID", setGithubVariable("QUALTY_PROJECT_ID", String(projectId), { repo }));
635
- record("variable", "QUALTY_AUTH_PROFILE", setGithubVariable("QUALTY_AUTH_PROFILE", profile, { repo }));
712
+
713
+ const names = (profileNames || []).filter(Boolean);
714
+ if (names.length > 0) {
715
+ record("variable", AUTH_PROFILES_VARIABLE, setGithubVariable(AUTH_PROFILES_VARIABLE, names.join(","), { repo }));
716
+ }
717
+ if (profile) {
718
+ record("variable", "QUALTY_AUTH_PROFILE", setGithubVariable("QUALTY_AUTH_PROFILE", profile, { repo }));
719
+ }
720
+ if ((authShards || []).length > 1) {
721
+ record(
722
+ "variable",
723
+ AUTH_SHARDS_VARIABLE,
724
+ setGithubVariable(AUTH_SHARDS_VARIABLE, String(authShards.length), { repo })
725
+ );
726
+ }
636
727
 
637
728
  if (suiteId) {
638
729
  record("variable", "QUALTY_SUITE_ID", setGithubVariable("QUALTY_SUITE_ID", String(suiteId), { repo }));
@@ -648,6 +739,37 @@ function syncGithubCiConfig({
648
739
  return results;
649
740
  }
650
741
 
742
+ /**
743
+ * Push only auth bundle secrets and auth-related repository variables (no API token or org/project).
744
+ */
745
+ function syncGithubAuthProfileOnly({ repo, profile, profileNames, authShards }) {
746
+ const results = [];
747
+ const record = (kind, name, outcome) => {
748
+ results.push({ kind, name, ...outcome });
749
+ };
750
+
751
+ for (const shard of authShards || []) {
752
+ record("secret", shard.secretName, setGithubSecret(shard.secretName, shard.payload, { repo }));
753
+ }
754
+
755
+ const names = (profileNames || []).filter(Boolean);
756
+ if (names.length > 0) {
757
+ record("variable", AUTH_PROFILES_VARIABLE, setGithubVariable(AUTH_PROFILES_VARIABLE, names.join(","), { repo }));
758
+ }
759
+ if (profile) {
760
+ record("variable", "QUALTY_AUTH_PROFILE", setGithubVariable("QUALTY_AUTH_PROFILE", profile, { repo }));
761
+ }
762
+ if ((authShards || []).length > 1) {
763
+ record(
764
+ "variable",
765
+ AUTH_SHARDS_VARIABLE,
766
+ setGithubVariable(AUTH_SHARDS_VARIABLE, String(authShards.length), { repo })
767
+ );
768
+ }
769
+
770
+ return results;
771
+ }
772
+
651
773
  function printGithubSyncResults(results, { repo }) {
652
774
  const target = repo ? ` (repo ${repo})` : "";
653
775
  const printGroup = (title, rows) => {
@@ -728,7 +850,7 @@ function configStringValue(projectCfg, key, configPath) {
728
850
  );
729
851
  }
730
852
 
731
- function missingExportCiField(fieldLabel, { flag, configKey, configPath, hasFlag }) {
853
+ function missingCiSetupField(fieldLabel, { flag, configKey, configPath, hasFlag }) {
732
854
  const configHint = configPath
733
855
  ? `${PROJECT_CONFIG_FILENAME} at ${configPath} (${configKey})`
734
856
  : `${PROJECT_CONFIG_FILENAME} (not found walking up from ${process.cwd()})`;
@@ -744,12 +866,22 @@ function missingExportCiField(fieldLabel, { flag, configKey, configPath, hasFlag
744
866
  ].join(" ");
745
867
  }
746
868
 
747
- function resolveExportCiConfig(args) {
869
+ function resolveCiSetupConfig(args, { requireProfile = true } = {}) {
748
870
  const { configPath, config: projectCfg } = readProjectConfigStrict();
749
871
 
750
872
  const orgFromFlag = String(args.org || "").trim();
751
873
  const projectFromFlag = String(args.project || "").trim();
752
874
  const profileFromFlag = String(args.profile || args["auth-profile"] || "").trim();
875
+ const allProfiles = parseBoolean(args["all-profiles"], false);
876
+ const profilesList = String(args.profiles || "")
877
+ .split(",")
878
+ .map((part) => part.trim())
879
+ .filter(Boolean);
880
+ const suiteId = String(args["suite-id"] || args.suite || "").trim();
881
+ const explicitIds = String(args.ids || "")
882
+ .split(",")
883
+ .map((part) => part.trim())
884
+ .filter(Boolean);
753
885
 
754
886
  let orgId = orgFromFlag;
755
887
  if (!orgId) {
@@ -768,7 +900,7 @@ function resolveExportCiConfig(args) {
768
900
 
769
901
  if (!orgId) {
770
902
  throw new Error(
771
- missingExportCiField("org ID", {
903
+ missingCiSetupField("org ID", {
772
904
  flag: "--org",
773
905
  configKey: "default_org_id",
774
906
  configPath,
@@ -778,7 +910,7 @@ function resolveExportCiConfig(args) {
778
910
  }
779
911
  if (!projectId) {
780
912
  throw new Error(
781
- missingExportCiField("project ID", {
913
+ missingCiSetupField("project ID", {
782
914
  flag: "--project",
783
915
  configKey: "default_project_id",
784
916
  configPath,
@@ -786,9 +918,12 @@ function resolveExportCiConfig(args) {
786
918
  })
787
919
  );
788
920
  }
789
- if (!profile) {
921
+
922
+ const hasMultiProfileMode =
923
+ allProfiles || profilesList.length > 0 || Boolean(suiteId) || explicitIds.length > 0;
924
+ if (requireProfile && !profile && !hasMultiProfileMode) {
790
925
  throw new Error(
791
- missingExportCiField("auth profile", {
926
+ missingCiSetupField("auth profile", {
792
927
  flag: "--profile",
793
928
  configKey: "default_auth_profile",
794
929
  configPath,
@@ -804,43 +939,163 @@ function resolveExportCiConfig(args) {
804
939
  throw new Error(`Invalid API URL: ${err.message}`);
805
940
  }
806
941
 
807
- return { orgId, projectId, profile, apiUrl, configPath };
942
+ return {
943
+ orgId,
944
+ projectId,
945
+ profile,
946
+ apiUrl,
947
+ configPath,
948
+ allProfiles,
949
+ profilesList,
950
+ suiteId,
951
+ explicitIds,
952
+ };
953
+ }
954
+
955
+ function jobUsesSavedLogin(job) {
956
+ if (!job) return false;
957
+ if (job.use_saved_login) return true;
958
+ if (job.saved_login_profile_id) return true;
959
+ const profile = job.saved_login_profile;
960
+ return Boolean(profile && typeof profile === "object" && (profile.id || profile.name));
808
961
  }
809
962
 
810
- function encodeAuthStateForGithubSecret(statePath, { gzip = false } = {}) {
811
- const raw = readFileSync(statePath);
812
- if (gzip) {
813
- return {
814
- encoding: "gzip+base64",
815
- payload: gzipSync(raw).toString("base64"),
816
- rawBytes: raw.length,
817
- };
963
+ async function fetchSavedLoginProfilesForProject({ apiUrl, token, orgId, projectId }) {
964
+ const payload = await apiRequest({
965
+ apiUrl,
966
+ token,
967
+ orgId,
968
+ path: `/api/v1/projects/${encodeURIComponent(String(projectId))}/saved-login-profiles`,
969
+ }).catch(() => []);
970
+ return Array.isArray(payload) ? payload : [];
971
+ }
972
+
973
+ function localProfileLabelFromApiRow(row, fallbackName = "") {
974
+ return String(row?.local_profile_name || row?.name || fallbackName || "").trim();
975
+ }
976
+
977
+ async function resolveRequiredProfileNamesFromTests({
978
+ apiUrl,
979
+ token,
980
+ orgId,
981
+ projectId,
982
+ suiteId,
983
+ explicitIds,
984
+ }) {
985
+ const jobsPayload = await resolveSavedJobs({
986
+ apiUrl,
987
+ token,
988
+ orgId,
989
+ projectId,
990
+ suiteId,
991
+ explicitIds,
992
+ });
993
+ const jobs = Array.isArray(jobsPayload)
994
+ ? jobsPayload
995
+ : Array.isArray(jobsPayload?.results)
996
+ ? jobsPayload.results
997
+ : Array.isArray(jobsPayload?.jobs)
998
+ ? jobsPayload.jobs
999
+ : [];
1000
+ const names = new Set();
1001
+ for (const job of jobs) {
1002
+ if (!jobUsesSavedLogin(job)) continue;
1003
+ const name = String(job?.saved_login_profile?.name || "").trim();
1004
+ if (name) names.add(name);
1005
+ }
1006
+ return [...names];
1007
+ }
1008
+
1009
+ async function resolveCiSetupProfileEntries({
1010
+ orgId,
1011
+ projectId,
1012
+ profile,
1013
+ allProfiles,
1014
+ profilesList,
1015
+ suiteId,
1016
+ explicitIds,
1017
+ apiUrl,
1018
+ token,
1019
+ }) {
1020
+ const apiProfiles = token
1021
+ ? await fetchSavedLoginProfilesForProject({ apiUrl, token, orgId, projectId })
1022
+ : [];
1023
+
1024
+ let requestedNames = [];
1025
+ if (allProfiles) {
1026
+ requestedNames = apiProfiles.map((row) => String(row?.name || "").trim()).filter(Boolean);
1027
+ if (requestedNames.length === 0) {
1028
+ requestedNames = listLocalAuthProfiles({ orgId, projectId });
1029
+ }
1030
+ } else if (profilesList.length > 0) {
1031
+ requestedNames = profilesList;
1032
+ } else if (suiteId || explicitIds.length > 0) {
1033
+ if (!token) {
1034
+ throw new Error("QUALTY_API_TOKEN is required to resolve profiles from --suite-id or --ids.");
1035
+ }
1036
+ requestedNames = await resolveRequiredProfileNamesFromTests({
1037
+ apiUrl,
1038
+ token,
1039
+ orgId,
1040
+ projectId,
1041
+ suiteId,
1042
+ explicitIds,
1043
+ });
1044
+ if (requestedNames.length === 0 && profile) {
1045
+ requestedNames = [profile];
1046
+ }
1047
+ } else if (profile) {
1048
+ requestedNames = [profile];
818
1049
  }
819
- return {
820
- encoding: "base64",
821
- payload: raw.toString("base64"),
822
- rawBytes: raw.length,
823
- };
1050
+
1051
+ requestedNames = [...new Set(requestedNames.filter(Boolean))];
1052
+ if (requestedNames.length === 0) {
1053
+ throw new Error(
1054
+ "No auth profiles selected. Pass --profile, --profiles, --all-profiles, or --suite-id / --ids."
1055
+ );
1056
+ }
1057
+
1058
+ const entries = [];
1059
+ const missing = [];
1060
+ for (const dashboardName of requestedNames) {
1061
+ const apiRow = apiProfiles.find((row) => String(row?.name || "").trim() === dashboardName);
1062
+ const localProfileName = localProfileLabelFromApiRow(apiRow, dashboardName) || dashboardName;
1063
+ const statePath = localAuthStatePath({ orgId, projectId, profile: localProfileName });
1064
+ if (!existsSync(statePath)) {
1065
+ missing.push({ dashboardName, localProfileName, statePath });
1066
+ continue;
1067
+ }
1068
+ entries.push({
1069
+ name: dashboardName,
1070
+ localProfileName,
1071
+ statePath,
1072
+ });
1073
+ }
1074
+
1075
+ if (missing.length > 0) {
1076
+ const lines = missing.map(
1077
+ (row) =>
1078
+ ` - "${row.dashboardName}" → ${row.statePath}\n qualty auth setup --project ${projectId} --profile "${row.dashboardName}"`
1079
+ );
1080
+ throw new Error(["Local auth state missing for:", ...lines].join("\n"));
1081
+ }
1082
+
1083
+ return entries;
824
1084
  }
825
1085
 
826
- function githubAuthRestoreShell({ orgId, projectId, profile }) {
1086
+ function githubAuthRestoreShell({ shardCount = 1 } = {}) {
1087
+ const shardEnvLines = [];
1088
+ for (let index = 1; index <= Math.max(1, shardCount); index += 1) {
1089
+ const secretName = authShardSecretName(index);
1090
+ shardEnvLines.push(` ${secretName}: \${{ secrets.${secretName} }}`);
1091
+ }
827
1092
  return [
828
- `- name: Restore Qualty login state`,
1093
+ `- name: Restore Qualty login states`,
829
1094
  ` env:`,
830
- ` QUALTY_AUTH_STATE_B64: \${{ secrets.QUALTY_AUTH_STATE_B64 }}`,
831
- ` run: |`,
832
- ` set -euo pipefail`,
833
- ` slug_segment() {`,
834
- ` printf '%s' "$1" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g; s/[^a-zA-Z0-9._-]+/_/g; s/^_+|_+$//g'`,
835
- ` }`,
836
- ` org_seg=$(slug_segment "$QUALTY_ORG_ID"); [ -n "$org_seg" ] || org_seg="default-org"`,
837
- ` project_seg=$(slug_segment "$QUALTY_PROJECT_ID"); [ -n "$project_seg" ] || project_seg="default-project"`,
838
- ` profile_seg=$(slug_segment "$QUALTY_AUTH_PROFILE"); [ -n "$profile_seg" ] || profile_seg="default"`,
839
- ` auth_dir="$HOME/.qualty/local-contexts/\${org_seg}/\${project_seg}"`,
840
- ` auth_file="\${auth_dir}/\${profile_seg}.json"`,
841
- ` mkdir -p "$auth_dir"`,
842
- ` echo "$QUALTY_AUTH_STATE_B64" | base64 -d | gunzip > "$auth_file"`,
843
- ` test -s "$auth_file"`,
1095
+ ...shardEnvLines,
1096
+ ` QUALTY_ORG_ID: \${{ vars.QUALTY_ORG_ID }}`,
1097
+ ` QUALTY_PROJECT_ID: \${{ vars.QUALTY_PROJECT_ID }}`,
1098
+ ` run: npx --yes qualty auth restore-ci`,
844
1099
  ``,
845
1100
  `- name: Run Qualty tests (local)`,
846
1101
  ` env:`,
@@ -851,84 +1106,311 @@ function githubAuthRestoreShell({ orgId, projectId, profile }) {
851
1106
  ` --org "$QUALTY_ORG_ID" \\`,
852
1107
  ` --project "$QUALTY_PROJECT_ID" \\`,
853
1108
  ` --suite-id "YOUR-SUITE-UUID" \\`,
854
- ` --auth-profile "$QUALTY_AUTH_PROFILE" \\`,
855
1109
  ` --fail-on-failure true`,
1110
+ ` # Omit --auth-profile when tests use different saved login profiles.`,
1111
+ ` # Add --auth-profile "Profile Name" only to force one profile for the whole run.`,
856
1112
  ].join("\n");
857
1113
  }
858
1114
 
859
- async function runAuthExportCi(args) {
860
- const { orgId, projectId, profile, apiUrl, configPath } = resolveExportCiConfig(args);
861
- const token = resolveToken(args);
1115
+ async function runAuthRestoreCi(args) {
1116
+ const orgId = String(args.org || process.env.QUALTY_ORG_ID || "").trim();
1117
+ const projectId = String(args.project || process.env.QUALTY_PROJECT_ID || "").trim();
1118
+ const fallbackProfile = String(
1119
+ args.profile || args["auth-profile"] || process.env.QUALTY_AUTH_PROFILE || ""
1120
+ ).trim();
862
1121
 
863
- const statePath = localAuthStatePath({ orgId, projectId, profile });
864
- if (!existsSync(statePath)) {
1122
+ if (!orgId) {
1123
+ throw new Error("Missing org ID. Set QUALTY_ORG_ID or pass --org.");
1124
+ }
1125
+ if (!projectId) {
1126
+ throw new Error("Missing project ID. Set QUALTY_PROJECT_ID or pass --project.");
1127
+ }
1128
+
1129
+ const payloads = collectAuthShardPayloadsFromEnv(process.env);
1130
+ if (payloads.length === 0) {
865
1131
  throw new Error(
866
- [
867
- "Local auth state not found:",
868
- ` ${statePath}`,
869
- "",
870
- "Create it first:",
871
- ` qualty auth setup --project ${projectId} --profile "${profile}"`,
872
- configPath ? ` (org/project/profile from ${configPath})` : "",
873
- ]
874
- .filter(Boolean)
875
- .join("\n")
1132
+ `Missing ${AUTH_SECRET_PRIMARY}. Run qualty ci setup --set-github (first time) or qualty auth export --set-github (profile refresh).`
876
1133
  );
877
1134
  }
878
1135
 
879
- const setGithubFlag =
880
- parseBoolean(args["set-github"], false) || parseBoolean(args["set-secret"], false);
881
- // CI path: one format only — gzip then base64 in QUALTY_AUTH_STATE_B64 (no separate gzip flag).
882
- let gzip = parseBoolean(args.gzip, false) || setGithubFlag;
883
- let encoded;
884
- try {
885
- encoded = encodeAuthStateForGithubSecret(statePath, { gzip });
886
- } catch (err) {
1136
+ const restored = restoreAuthPayloadsToDisk({
1137
+ payloads,
1138
+ orgId,
1139
+ projectId,
1140
+ fallbackProfile,
1141
+ });
1142
+
1143
+ for (const row of restored) {
1144
+ // eslint-disable-next-line no-console
1145
+ console.log(`Restored login state: ${row.path} (profile "${row.name}")`);
1146
+ }
1147
+ // eslint-disable-next-line no-console
1148
+ console.log(`Restored ${restored.length} login profile(s).`);
1149
+ }
1150
+
1151
+ async function runAuthExport(args) {
1152
+ if (
1153
+ parseBoolean(args["all-profiles"], false) ||
1154
+ String(args["suite-id"] || args.suite || "").trim() ||
1155
+ String(args.ids || "")
1156
+ .split(",")
1157
+ .map((part) => part.trim())
1158
+ .filter(Boolean).length > 0
1159
+ ) {
887
1160
  throw new Error(
888
- `Could not read auth state at ${statePath}: ${err.message}`
1161
+ "qualty auth export only updates login profile secrets. For full CI bootstrap use: qualty ci setup"
889
1162
  );
890
1163
  }
891
- let secretBytes = Buffer.byteLength(encoded.payload, "utf8");
892
- if (!gzip && secretBytes > GITHUB_SECRET_MAX_BYTES - 1024) {
893
- gzip = true;
894
- try {
895
- encoded = encodeAuthStateForGithubSecret(statePath, { gzip: true });
896
- } catch (err) {
897
- throw new Error(
898
- `Could not gzip auth state at ${statePath}: ${err.message}`
1164
+
1165
+ const profilesList = String(args.profiles || "")
1166
+ .split(",")
1167
+ .map((part) => part.trim())
1168
+ .filter(Boolean);
1169
+ const { orgId, projectId, profile, apiUrl, configPath } = resolveCiSetupConfig(args, {
1170
+ requireProfile: profilesList.length === 0,
1171
+ });
1172
+ const token = resolveToken(args);
1173
+
1174
+ const profileEntries = await resolveCiSetupProfileEntries({
1175
+ orgId,
1176
+ projectId,
1177
+ profile,
1178
+ allProfiles: false,
1179
+ profilesList,
1180
+ suiteId: "",
1181
+ explicitIds: [],
1182
+ apiUrl,
1183
+ token,
1184
+ });
1185
+ const profileNames = profileEntries.map((entry) => entry.name);
1186
+ const primaryProfile = profile || profileNames[0] || "";
1187
+
1188
+ const manifest = buildAuthBundleManifest({ orgId, projectId, profileEntries });
1189
+ const authShards = shardAuthBundleManifest(manifest);
1190
+ const primaryShard = authShards[0];
1191
+
1192
+ const setGithubFlag =
1193
+ parseBoolean(args["set-github"], false) || parseBoolean(args["set-secret"], false);
1194
+ const noFile = parseBoolean(args["no-file"], false);
1195
+ const outputPath = noFile
1196
+ ? String(args.output || "").trim()
1197
+ : String(args.output || "").trim() ||
1198
+ ciSetupAuthBundleOutputPath(
1199
+ localAuthStatePath({ orgId, projectId, profile: profileEntries[0].localProfileName })
899
1200
  );
1201
+ const copyToClipboardFlag = parseBoolean(args.copy, false);
1202
+ const githubRepo = String(args.repo || "").trim();
1203
+
1204
+ if (outputPath) {
1205
+ writeFileSync(outputPath, primaryShard.payload, "utf8");
1206
+ try {
1207
+ chmodSync(outputPath, 0o600);
1208
+ } catch {
1209
+ // ignore platforms that do not support chmod
1210
+ }
1211
+ for (let index = 1; index < authShards.length; index += 1) {
1212
+ const shard = authShards[index];
1213
+ const shardPath = outputPath.replace(/(\.[^./]+)?$/, `_${index + 1}$1`);
1214
+ writeFileSync(shardPath, shard.payload, "utf8");
1215
+ try {
1216
+ chmodSync(shardPath, 0o600);
1217
+ } catch {
1218
+ // ignore
1219
+ }
900
1220
  }
901
- secretBytes = Buffer.byteLength(encoded.payload, "utf8");
902
1221
  }
903
- if (secretBytes > GITHUB_SECRET_MAX_BYTES - 1024) {
904
- throw new Error(
905
- `Auth state is too large for a GitHub secret (${secretBytes} bytes; limit ~64 KB). ` +
906
- "Try a shorter-lived session, fewer cookies, or contact Qualty support."
1222
+
1223
+ const skipPayloadPrint = Boolean(outputPath || copyToClipboardFlag || setGithubFlag);
1224
+ const totalSecretBytes = authShards.reduce(
1225
+ (sum, shard) => sum + Buffer.byteLength(shard.payload, "utf8"),
1226
+ 0
1227
+ );
1228
+
1229
+ // eslint-disable-next-line no-console
1230
+ console.log("");
1231
+ // eslint-disable-next-line no-console
1232
+ console.log("=== Qualty → GitHub Actions (login profiles only) ===");
1233
+ // eslint-disable-next-line no-console
1234
+ console.log("");
1235
+ // eslint-disable-next-line no-console
1236
+ console.log(
1237
+ `Auth bundle: ${profileNames.length} profile(s), ${authShards.length} secret shard(s), ${totalSecretBytes} bytes total`
1238
+ );
1239
+ for (const shard of authShards) {
1240
+ // eslint-disable-next-line no-console
1241
+ console.log(
1242
+ ` - ${shard.secretName}: ${shard.profileNames.join(", ")} (${Buffer.byteLength(shard.payload, "utf8")} bytes)`
1243
+ );
1244
+ }
1245
+ // eslint-disable-next-line no-console
1246
+ console.log("");
1247
+ if (outputPath) {
1248
+ // eslint-disable-next-line no-console
1249
+ console.log(`Wrote ${AUTH_SECRET_PRIMARY} to ${outputPath}`);
1250
+ for (let index = 1; index < authShards.length; index += 1) {
1251
+ const shard = authShards[index];
1252
+ const shardPath = outputPath.replace(/(\.[^./]+)?$/, `_${index + 1}$1`);
1253
+ // eslint-disable-next-line no-console
1254
+ console.log(`Wrote ${shard.secretName} to ${shardPath}`);
1255
+ }
1256
+ // eslint-disable-next-line no-console
1257
+ console.log("");
1258
+ }
1259
+ if (setGithubFlag) {
1260
+ const syncResults = syncGithubAuthProfileOnly({
1261
+ repo: githubRepo,
1262
+ profile: primaryProfile,
1263
+ profileNames,
1264
+ authShards,
1265
+ });
1266
+ printGithubSyncResults(syncResults, { repo: githubRepo });
1267
+ const failedAuth = syncResults.filter((row) => row.kind === "secret" && !row.ok);
1268
+ if (failedAuth.length > 0 && outputPath) {
1269
+ // eslint-disable-next-line no-console
1270
+ console.log(" Manual auth secrets:");
1271
+ authShards.forEach((shard, index) => {
1272
+ const shardPath =
1273
+ index === 0 ? outputPath : outputPath.replace(/(\.[^./]+)?$/, `_${index + 1}$1`);
1274
+ // eslint-disable-next-line no-console
1275
+ console.log(` gh secret set ${shard.secretName} < ${shardPath}`);
1276
+ });
1277
+ // eslint-disable-next-line no-console
1278
+ console.log("");
1279
+ }
1280
+ } else {
1281
+ // eslint-disable-next-line no-console
1282
+ console.log("GitHub secrets (login state only — does not update QUALTY_API_TOKEN or org/project):");
1283
+ // eslint-disable-next-line no-console
1284
+ console.log("");
1285
+ for (const shard of authShards) {
1286
+ // eslint-disable-next-line no-console
1287
+ console.log("---");
1288
+ // eslint-disable-next-line no-console
1289
+ console.log(`Name: ${shard.secretName}`);
1290
+ if (skipPayloadPrint) {
1291
+ // eslint-disable-next-line no-console
1292
+ console.log(
1293
+ `Value: (see file above${copyToClipboardFlag && shard.secretName === AUTH_SECRET_PRIMARY ? " or clipboard" : ""})`
1294
+ );
1295
+ } else {
1296
+ // eslint-disable-next-line no-console
1297
+ console.log("Value:");
1298
+ // eslint-disable-next-line no-console
1299
+ console.log(shard.payload);
1300
+ }
1301
+ }
1302
+ // eslint-disable-next-line no-console
1303
+ console.log("---");
1304
+ // eslint-disable-next-line no-console
1305
+ console.log("");
1306
+ // eslint-disable-next-line no-console
1307
+ console.log(
1308
+ "Re-run with --set-github from this repo's git root to push via gh (after qualty ci setup for first-time CI)."
907
1309
  );
1310
+ // eslint-disable-next-line no-console
1311
+ console.log("");
1312
+ }
1313
+ if (copyToClipboardFlag) {
1314
+ if (copyToClipboard(primaryShard.payload)) {
1315
+ // eslint-disable-next-line no-console
1316
+ console.log(`✓ Copied ${AUTH_SECRET_PRIMARY} to clipboard.`);
1317
+ } else {
1318
+ // eslint-disable-next-line no-console
1319
+ console.log("Could not copy to clipboard on this machine.");
1320
+ }
1321
+ // eslint-disable-next-line no-console
1322
+ console.log("");
1323
+ }
1324
+ for (const entry of profileEntries) {
1325
+ // eslint-disable-next-line no-console
1326
+ console.log(`Profile "${entry.name}" → ${entry.statePath}`);
908
1327
  }
1328
+ if (configPath) {
1329
+ // eslint-disable-next-line no-console
1330
+ console.log(`Config: ${configPath}`);
1331
+ }
1332
+ // eslint-disable-next-line no-console
1333
+ console.log("");
1334
+ // eslint-disable-next-line no-console
1335
+ console.log(
1336
+ "When login expires, re-run qualty auth setup then qualty auth export --set-github (or qualty ci setup for a full refresh)."
1337
+ );
1338
+ // eslint-disable-next-line no-console
1339
+ console.log("");
1340
+ }
1341
+
1342
+ async function runCiSetup(args) {
1343
+ const {
1344
+ orgId,
1345
+ projectId,
1346
+ profile,
1347
+ apiUrl,
1348
+ configPath,
1349
+ allProfiles,
1350
+ profilesList,
1351
+ suiteId: configSuiteId,
1352
+ explicitIds,
1353
+ } = resolveCiSetupConfig(args);
1354
+ const token = resolveToken(args);
909
1355
 
1356
+ const profileEntries = await resolveCiSetupProfileEntries({
1357
+ orgId,
1358
+ projectId,
1359
+ profile,
1360
+ allProfiles,
1361
+ profilesList,
1362
+ suiteId: configSuiteId,
1363
+ explicitIds,
1364
+ apiUrl,
1365
+ token,
1366
+ });
1367
+ const profileNames = profileEntries.map((entry) => entry.name);
1368
+ const primaryProfile = profile || profileNames[0] || "";
1369
+
1370
+ const manifest = buildAuthBundleManifest({ orgId, projectId, profileEntries });
1371
+ const authShards = shardAuthBundleManifest(manifest);
1372
+ const primaryShard = authShards[0];
1373
+
1374
+ const setGithubFlag =
1375
+ parseBoolean(args["set-github"], false) || parseBoolean(args["set-secret"], false);
910
1376
  const noFile = parseBoolean(args["no-file"], false);
911
1377
  const outputPath = noFile
912
1378
  ? String(args.output || "").trim()
913
- : String(args.output || "").trim() || authExportCiOutputPath(statePath);
1379
+ : String(args.output || "").trim() ||
1380
+ ciSetupAuthBundleOutputPath(
1381
+ localAuthStatePath({ orgId, projectId, profile: profileEntries[0].localProfileName })
1382
+ );
914
1383
  const copyToClipboardFlag = parseBoolean(args.copy, false);
915
1384
  const githubRepo = String(args.repo || "").trim();
916
- const suiteId = String(args["suite-id"] || args.suite || "").trim();
1385
+ const suiteId = String(args["suite-id"] || args.suite || configSuiteId || "").trim();
917
1386
  const cliRepo = String(args["cli-repo"] || "").trim();
918
1387
  const cliRef = String(args["cli-ref"] || "").trim();
919
1388
 
920
1389
  if (outputPath) {
921
- writeFileSync(outputPath, encoded.payload, "utf8");
1390
+ writeFileSync(outputPath, primaryShard.payload, "utf8");
922
1391
  try {
923
1392
  chmodSync(outputPath, 0o600);
924
1393
  } catch {
925
1394
  // ignore platforms that do not support chmod
926
1395
  }
1396
+ for (let index = 1; index < authShards.length; index += 1) {
1397
+ const shard = authShards[index];
1398
+ const shardPath = outputPath.replace(/(\.[^./]+)?$/, `_${index + 1}$1`);
1399
+ writeFileSync(shardPath, shard.payload, "utf8");
1400
+ try {
1401
+ chmodSync(shardPath, 0o600);
1402
+ } catch {
1403
+ // ignore
1404
+ }
1405
+ }
927
1406
  }
928
1407
 
929
1408
  const includeToken = parseBoolean(args["include-token"], false);
930
- const profileSeg = safePathSegment(profile, "default");
931
1409
  const skipPayloadPrint = Boolean(outputPath || copyToClipboardFlag || setGithubFlag);
1410
+ const totalSecretBytes = authShards.reduce(
1411
+ (sum, shard) => sum + Buffer.byteLength(shard.payload, "utf8"),
1412
+ 0
1413
+ );
932
1414
 
933
1415
  // eslint-disable-next-line no-console
934
1416
  console.log("");
@@ -936,20 +1418,29 @@ async function runAuthExportCi(args) {
936
1418
  console.log("=== Qualty → GitHub Actions (secrets & variables) ===");
937
1419
  // eslint-disable-next-line no-console
938
1420
  console.log("");
939
- if (encoded.encoding === "gzip+base64") {
940
- // eslint-disable-next-line no-console
941
- console.log(`Auth state: gzip+base64 (${encoded.rawBytes} bytes raw ${secretBytes} bytes secret)`);
942
- } else {
1421
+ // eslint-disable-next-line no-console
1422
+ console.log(
1423
+ `Auth bundle: ${profileNames.length} profile(s), ${authShards.length} secret shard(s), ${totalSecretBytes} bytes total`
1424
+ );
1425
+ for (const shard of authShards) {
943
1426
  // eslint-disable-next-line no-console
944
- console.log(`Auth state: base64 (${encoded.rawBytes} bytes raw → ${secretBytes} bytes secret)`);
1427
+ console.log(
1428
+ ` - ${shard.secretName}: ${shard.profileNames.join(", ")} (${Buffer.byteLength(shard.payload, "utf8")} bytes)`
1429
+ );
945
1430
  }
946
1431
  // eslint-disable-next-line no-console
947
1432
  console.log("");
948
1433
  if (outputPath) {
949
1434
  // eslint-disable-next-line no-console
950
- console.log(`Wrote QUALTY_AUTH_STATE_B64 to ${outputPath}`);
1435
+ console.log(`Wrote ${AUTH_SECRET_PRIMARY} to ${outputPath}`);
1436
+ for (let index = 1; index < authShards.length; index += 1) {
1437
+ const shard = authShards[index];
1438
+ const shardPath = outputPath.replace(/(\.[^./]+)?$/, `_${index + 1}$1`);
1439
+ // eslint-disable-next-line no-console
1440
+ console.log(`Wrote ${shard.secretName} to ${shardPath}`);
1441
+ }
951
1442
  // eslint-disable-next-line no-console
952
- console.log(` gh secret set QUALTY_AUTH_STATE_B64 < ${outputPath}`);
1443
+ console.log(` gh secret set ${AUTH_SECRET_PRIMARY} < ${outputPath}`);
953
1444
  // eslint-disable-next-line no-console
954
1445
  console.log("");
955
1446
  }
@@ -958,8 +1449,9 @@ async function runAuthExportCi(args) {
958
1449
  repo: githubRepo,
959
1450
  orgId,
960
1451
  projectId,
961
- profile,
962
- authPayload: encoded.payload,
1452
+ profile: primaryProfile,
1453
+ profileNames,
1454
+ authShards,
963
1455
  apiUrl,
964
1456
  token,
965
1457
  suiteId,
@@ -967,25 +1459,27 @@ async function runAuthExportCi(args) {
967
1459
  cliRef,
968
1460
  });
969
1461
  printGithubSyncResults(syncResults, { repo: githubRepo });
970
- const authRow = syncResults.find((r) => r.name === "QUALTY_AUTH_STATE_B64");
971
- if (!authRow?.ok && outputPath) {
1462
+ const failedAuth = syncResults.filter((row) => row.kind === "secret" && !row.ok);
1463
+ if (failedAuth.length > 0 && outputPath) {
972
1464
  // eslint-disable-next-line no-console
973
- console.log(` Manual auth secret: gh secret set QUALTY_AUTH_STATE_B64 < ${outputPath}`);
1465
+ console.log(" Manual auth secrets:");
1466
+ authShards.forEach((shard, index) => {
1467
+ const shardPath =
1468
+ index === 0 ? outputPath : outputPath.replace(/(\.[^./]+)?$/, `_${index + 1}$1`);
1469
+ // eslint-disable-next-line no-console
1470
+ console.log(` gh secret set ${shard.secretName} < ${shardPath}`);
1471
+ });
974
1472
  // eslint-disable-next-line no-console
975
1473
  console.log("");
976
1474
  }
977
1475
  }
978
1476
  if (copyToClipboardFlag) {
979
- if (copyToClipboard(encoded.payload)) {
1477
+ if (copyToClipboard(primaryShard.payload)) {
980
1478
  // eslint-disable-next-line no-console
981
- console.log("✓ Copied QUALTY_AUTH_STATE_B64 to clipboard. Paste into GitHub → Settings → Secrets.");
1479
+ console.log(`✓ Copied ${AUTH_SECRET_PRIMARY} to clipboard.`);
982
1480
  } else {
983
1481
  // eslint-disable-next-line no-console
984
1482
  console.log("Could not copy to clipboard on this machine.");
985
- if (outputPath) {
986
- // eslint-disable-next-line no-console
987
- console.log(` Use the file instead: ${outputPath}`);
988
- }
989
1483
  }
990
1484
  // eslint-disable-next-line no-console
991
1485
  console.log("");
@@ -995,10 +1489,16 @@ async function runAuthExportCi(args) {
995
1489
  console.log(
996
1490
  "1. GitHub Actions config was pushed via gh (secrets + repository variables — see sync above). Fix any ✗ manually."
997
1491
  );
998
- if (!suiteId) {
1492
+ if (!suiteId && !configSuiteId && explicitIds.length === 0) {
999
1493
  // eslint-disable-next-line no-console
1000
1494
  console.log(
1001
- " Tip: hardcode --suite-id or --ids in your workflow (see templates/github-actions/qualty-local-pr.yml)."
1495
+ " Tip: pass --suite-id or --ids to bundle only login profiles required by those tests."
1496
+ );
1497
+ }
1498
+ if (authShards.length > 1) {
1499
+ // eslint-disable-next-line no-console
1500
+ console.log(
1501
+ ` Auth was split across ${authShards.length} secrets. Add optional shard env vars to your workflow restore step.`
1002
1502
  );
1003
1503
  }
1004
1504
  // eslint-disable-next-line no-console
@@ -1033,13 +1533,35 @@ async function runAuthExportCi(args) {
1033
1533
  // eslint-disable-next-line no-console
1034
1534
  console.log("---");
1035
1535
  // eslint-disable-next-line no-console
1036
- console.log("Name: QUALTY_AUTH_PROFILE");
1536
+ console.log(`Name: ${AUTH_PROFILES_VARIABLE}`);
1037
1537
  // eslint-disable-next-line no-console
1038
1538
  console.log("Value:");
1039
1539
  // eslint-disable-next-line no-console
1040
- console.log(profile);
1540
+ console.log(profileNames.join(","));
1041
1541
  // eslint-disable-next-line no-console
1042
- console.log(` gh variable set QUALTY_AUTH_PROFILE --body "${profile}"`);
1542
+ console.log(` gh variable set ${AUTH_PROFILES_VARIABLE} --body "${profileNames.join(",")}"`);
1543
+ if (primaryProfile) {
1544
+ // eslint-disable-next-line no-console
1545
+ console.log("---");
1546
+ // eslint-disable-next-line no-console
1547
+ console.log("Name: QUALTY_AUTH_PROFILE");
1548
+ // eslint-disable-next-line no-console
1549
+ console.log("Value:");
1550
+ // eslint-disable-next-line no-console
1551
+ console.log(primaryProfile);
1552
+ // eslint-disable-next-line no-console
1553
+ console.log(` gh variable set QUALTY_AUTH_PROFILE --body "${primaryProfile}"`);
1554
+ }
1555
+ if (authShards.length > 1) {
1556
+ // eslint-disable-next-line no-console
1557
+ console.log("---");
1558
+ // eslint-disable-next-line no-console
1559
+ console.log(`Name: ${AUTH_SHARDS_VARIABLE}`);
1560
+ // eslint-disable-next-line no-console
1561
+ console.log("Value:");
1562
+ // eslint-disable-next-line no-console
1563
+ console.log(String(authShards.length));
1564
+ }
1043
1565
  // eslint-disable-next-line no-console
1044
1566
  console.log("---");
1045
1567
  // eslint-disable-next-line no-console
@@ -1080,20 +1602,20 @@ async function runAuthExportCi(args) {
1080
1602
  // eslint-disable-next-line no-console
1081
1603
  console.log("(create in Qualty → Account → API tokens; must start with qlty_ci_)");
1082
1604
  }
1083
- // eslint-disable-next-line no-console
1084
- console.log("---");
1085
- // eslint-disable-next-line no-console
1086
- console.log("Name: QUALTY_AUTH_STATE_B64");
1087
- if (skipPayloadPrint) {
1605
+ for (const shard of authShards) {
1088
1606
  // eslint-disable-next-line no-console
1089
- console.log(
1090
- `Value: (see file above${copyToClipboardFlag ? " or clipboard" : ""})`
1091
- );
1092
- } else {
1607
+ console.log("---");
1093
1608
  // eslint-disable-next-line no-console
1094
- console.log("Value:");
1095
- // eslint-disable-next-line no-console
1096
- console.log(encoded.payload);
1609
+ console.log(`Name: ${shard.secretName}`);
1610
+ if (skipPayloadPrint) {
1611
+ // eslint-disable-next-line no-console
1612
+ console.log(`Value: (see file above${copyToClipboardFlag && shard.secretName === AUTH_SECRET_PRIMARY ? " or clipboard" : ""})`);
1613
+ } else {
1614
+ // eslint-disable-next-line no-console
1615
+ console.log("Value:");
1616
+ // eslint-disable-next-line no-console
1617
+ console.log(shard.payload);
1618
+ }
1097
1619
  }
1098
1620
  // eslint-disable-next-line no-console
1099
1621
  console.log("---");
@@ -1107,17 +1629,26 @@ async function runAuthExportCi(args) {
1107
1629
  // eslint-disable-next-line no-console
1108
1630
  console.log("");
1109
1631
  // eslint-disable-next-line no-console
1110
- console.log(githubAuthRestoreShell({ orgId, projectId, profile }));
1632
+ console.log(githubAuthRestoreShell({ shardCount: authShards.length }));
1111
1633
  // eslint-disable-next-line no-console
1112
1634
  console.log("");
1113
- // eslint-disable-next-line no-console
1114
- console.log(`Local file: ${statePath}`);
1115
- // eslint-disable-next-line no-console
1116
- console.log(`Profile flag: --auth-profile "${profile}" (file on disk: ${profileSeg}.json)`);
1635
+ for (const entry of profileEntries) {
1636
+ // eslint-disable-next-line no-console
1637
+ console.log(`Profile "${entry.name}" → ${entry.statePath}`);
1638
+ }
1639
+ if (profileNames.length === 1) {
1640
+ // eslint-disable-next-line no-console
1641
+ console.log(`Optional override: --auth-profile "${profileNames[0]}"`);
1642
+ } else {
1643
+ // eslint-disable-next-line no-console
1644
+ console.log("Omit --auth-profile in CI so each test uses its dashboard saved login profile.");
1645
+ }
1117
1646
  // eslint-disable-next-line no-console
1118
1647
  console.log("");
1119
1648
  // eslint-disable-next-line no-console
1120
- console.log("When login expires, re-run qualty auth setup and qualty auth export-ci to refresh the secret.");
1649
+ console.log(
1650
+ "When login expires, re-run qualty auth setup and qualty auth export --set-github (or qualty ci setup --set-github for a full refresh)."
1651
+ );
1121
1652
  // eslint-disable-next-line no-console
1122
1653
  console.log("");
1123
1654
  }
@@ -1605,8 +2136,20 @@ async function runCi(args) {
1605
2136
  async function resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, explicitIds }) {
1606
2137
  const query = new URLSearchParams();
1607
2138
  if (projectId) query.set("project_id", String(projectId));
1608
- if (suiteId) query.set("suite_id", String(suiteId));
1609
- if (explicitIds.length > 0) query.set("ids", explicitIds.join(","));
2139
+
2140
+ let effectiveSuiteId = suiteId;
2141
+ let ids = [...explicitIds];
2142
+ // Suite M2M jobs exclude sequence members; expand so ci setup and profile resolution see all tests.
2143
+ if (suiteId && ids.length === 0) {
2144
+ const suiteJobIds = await collectSuiteJobIds({ apiUrl, token, orgId, suiteId });
2145
+ if (suiteJobIds.length > 0) {
2146
+ ids = suiteJobIds;
2147
+ effectiveSuiteId = "";
2148
+ }
2149
+ }
2150
+
2151
+ if (effectiveSuiteId) query.set("suite_id", String(effectiveSuiteId));
2152
+ if (ids.length > 0) query.set("ids", ids.join(","));
1610
2153
  return apiRequest({
1611
2154
  apiUrl,
1612
2155
  token,
@@ -1696,6 +2239,20 @@ async function resolveLocalSuitePlan({ apiUrl, token, orgId, suiteId }) {
1696
2239
  };
1697
2240
  }
1698
2241
 
2242
+ async function collectSuiteJobIds({ apiUrl, token, orgId, suiteId }) {
2243
+ const plan = await resolveLocalSuitePlan({ apiUrl, token, orgId, suiteId });
2244
+ const ids = new Set(plan.standaloneJobIds || []);
2245
+ for (const sequence of plan.sequences || []) {
2246
+ for (const stage of sequence.stages || []) {
2247
+ for (const job of stage.jobs || []) {
2248
+ const jobId = String(job.id || "").trim();
2249
+ if (jobId) ids.add(jobId);
2250
+ }
2251
+ }
2252
+ }
2253
+ return [...ids];
2254
+ }
2255
+
1699
2256
  async function runLocalSuiteWithSequences({
1700
2257
  apiUrl,
1701
2258
  token,
@@ -1975,8 +2532,7 @@ async function maybePromptProjectIdForLocalRun({ args, apiUrl, token, orgId }) {
1975
2532
 
1976
2533
  function resolveLocalAuthStateForRun({ args, orgId, projectId }) {
1977
2534
  if (parseBoolean(args["no-auth-state"], false)) return null;
1978
- const projectCfg = readProjectConfig();
1979
- const profile = authProfileFromConfig(projectCfg, args["auth-profile"]);
2535
+ const profile = explicitAuthProfileFromArgs(args);
1980
2536
  if (!profile) return null;
1981
2537
  const path = localAuthStatePath({ orgId, projectId, profile });
1982
2538
  const summary = summarizeStorageStateFile(path);
@@ -1990,12 +2546,10 @@ function resolveLocalAuthStateForRun({ args, orgId, projectId }) {
1990
2546
 
1991
2547
  async function maybePromptAuthProfileForLocalRun({ args, orgId, projectId }) {
1992
2548
  const projectCfg = readProjectConfig();
1993
- const requestedProfile = authProfileFromConfig(projectCfg, args["auth-profile"]);
2549
+ const requestedProfile = explicitAuthProfileFromArgs(args);
1994
2550
  if (requestedProfile) return { authProfile: requestedProfile, noAuthState: false };
1995
2551
  if (parseBoolean(args["no-auth-state"], false)) return { authProfile: "", noAuthState: true };
1996
2552
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
1997
- const defaultProfile = authProfileFromConfig(projectCfg);
1998
- if (defaultProfile) return { authProfile: defaultProfile, noAuthState: false };
1999
2553
  return { authProfile: "", noAuthState: false };
2000
2554
  }
2001
2555
  if (!projectId) return { authProfile: "", noAuthState: false };
@@ -2230,8 +2784,8 @@ async function runAuthSetup(args) {
2230
2784
  console.log("");
2231
2785
  // eslint-disable-next-line no-console
2232
2786
  console.log(
2233
- `[qualty][auth] For GitHub Actions secrets, run:\n` +
2234
- ` qualty auth export-ci --project ${project.id} --profile "${profile}"`
2787
+ `[qualty][auth] Push this profile to GitHub Actions:\n` +
2788
+ ` qualty auth export --project ${project.id} --profile "${profile}" --set-github`
2235
2789
  );
2236
2790
  } finally {
2237
2791
  rl.close();
@@ -2253,17 +2807,29 @@ async function main() {
2253
2807
  await runSetup(args);
2254
2808
  return;
2255
2809
  }
2810
+ if (command === "ci") {
2811
+ const sub = args._[1];
2812
+ if (sub === "setup") {
2813
+ await runCiSetup(args);
2814
+ return;
2815
+ }
2816
+ throw new Error("Unknown ci subcommand. Use: qualty ci setup");
2817
+ }
2256
2818
  if (command === "auth") {
2257
2819
  const sub = args._[1];
2258
2820
  if (sub === "setup") {
2259
2821
  await runAuthSetup(args);
2260
2822
  return;
2261
2823
  }
2262
- if (sub === "export-ci") {
2263
- await runAuthExportCi(args);
2824
+ if (sub === "export") {
2825
+ await runAuthExport(args);
2826
+ return;
2827
+ }
2828
+ if (sub === "restore-ci") {
2829
+ await runAuthRestoreCi(args);
2264
2830
  return;
2265
2831
  }
2266
- throw new Error("Unknown auth subcommand. Use: qualty auth setup | qualty auth export-ci");
2832
+ throw new Error("Unknown auth subcommand. Use: qualty auth setup | qualty auth export | qualty auth restore-ci");
2267
2833
  }
2268
2834
  if (command === "run") {
2269
2835
  if (parseBoolean(args.local, false)) {
@@ -2296,7 +2862,7 @@ async function main() {
2296
2862
  projectId,
2297
2863
  })
2298
2864
  : {
2299
- authProfile: String(args["auth-profile"] || "").trim(),
2865
+ authProfile: explicitAuthProfileFromArgs(args),
2300
2866
  noAuthState: parseBoolean(args["no-auth-state"], false),
2301
2867
  };
2302
2868
  const localAuthArgs = {
@@ -2319,9 +2885,9 @@ async function main() {
2319
2885
  device: args.device ? String(args.device).trim() : undefined,
2320
2886
  headless: !parseBoolean(args.headed, false),
2321
2887
  localConcurrency: resolveLocalConcurrency(args),
2322
- authProfile: localAuth?.profile || authChoice.authProfile || "",
2888
+ authProfile: authChoice.authProfile || "",
2323
2889
  noAuthState: Boolean(authChoice.noAuthState || parseBoolean(args["no-auth-state"], false)),
2324
- storageStatePath: localAuth?.path,
2890
+ storageStatePath: authChoice.authProfile ? localAuth?.path : undefined,
2325
2891
  };
2326
2892
  if (suiteId && explicitIds.length === 0) {
2327
2893
  await runLocalSuiteWithSequences(localRunArgs);