qualty 0.1.22 → 0.1.26

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.
@@ -0,0 +1,348 @@
1
+ import { gunzipSync, gzipSync } from "node:zlib";
2
+ import { mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ export const AUTH_BUNDLE_VERSION = 2;
7
+ export const GITHUB_SECRET_MAX_BYTES = 64 * 1024;
8
+ export const GITHUB_SECRET_SAFE_BYTES = GITHUB_SECRET_MAX_BYTES - 1024;
9
+ export const AUTH_SECRET_PRIMARY = "QUALTY_AUTH_STATE_B64";
10
+ export const AUTH_SHARDS_VARIABLE = "QUALTY_AUTH_SHARDS";
11
+ export const AUTH_PROFILES_VARIABLE = "QUALTY_AUTH_PROFILES";
12
+ export const MAX_AUTH_SHARDS = 10;
13
+
14
+ const QUALTY_SHARED_CREDS_DIR = join(homedir(), ".qualty");
15
+
16
+ export function safePathSegment(value, fallback = "default") {
17
+ const out = String(value || "")
18
+ .trim()
19
+ .replace(/[^a-zA-Z0-9._-]+/g, "_")
20
+ .replace(/^_+|_+$/g, "");
21
+ return out || fallback;
22
+ }
23
+
24
+ export function localAuthStatePath({ orgId, projectId, profile }) {
25
+ const orgSeg = safePathSegment(orgId || "default-org", "default-org");
26
+ const projectSeg = safePathSegment(projectId || "default-project", "default-project");
27
+ const profileSeg = safePathSegment(profile || "default", "default");
28
+ return join(QUALTY_SHARED_CREDS_DIR, "local-contexts", orgSeg, projectSeg, `${profileSeg}.json`);
29
+ }
30
+
31
+ export function authShardSecretName(shardIndex) {
32
+ const index = Number(shardIndex);
33
+ if (!Number.isInteger(index) || index < 1) {
34
+ throw new Error(`Invalid auth shard index: ${shardIndex}`);
35
+ }
36
+ return index === 1 ? AUTH_SECRET_PRIMARY : `${AUTH_SECRET_PRIMARY}_${index}`;
37
+ }
38
+
39
+ export function loadStorageStateFromPath(statePath) {
40
+ const raw = readFileSync(statePath, "utf8");
41
+ let parsed;
42
+ try {
43
+ parsed = JSON.parse(raw);
44
+ } catch (err) {
45
+ throw new Error(`Invalid auth JSON at ${statePath}: ${err.message}`);
46
+ }
47
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
48
+ throw new Error(`Invalid auth JSON at ${statePath}: expected an object`);
49
+ }
50
+ return parsed;
51
+ }
52
+
53
+ export function buildAuthBundleManifest({ orgId, projectId, profileEntries }) {
54
+ const profiles = {};
55
+ for (const entry of profileEntries) {
56
+ const name = String(entry.name || "").trim();
57
+ if (!name) continue;
58
+ const localProfileName =
59
+ String(entry.localProfileName || entry.local_profile_name || name).trim() || name;
60
+ const state =
61
+ entry.state && typeof entry.state === "object"
62
+ ? entry.state
63
+ : loadStorageStateFromPath(entry.statePath);
64
+ profiles[name] = {
65
+ local_profile_name: localProfileName,
66
+ state,
67
+ };
68
+ }
69
+ if (Object.keys(profiles).length === 0) {
70
+ throw new Error("No auth profiles to export.");
71
+ }
72
+ return {
73
+ version: AUTH_BUNDLE_VERSION,
74
+ org_id: String(orgId || "").trim(),
75
+ project_id: String(projectId || "").trim(),
76
+ profiles,
77
+ };
78
+ }
79
+
80
+ export function encodeAuthBundleManifest(manifest) {
81
+ const raw = Buffer.from(JSON.stringify(manifest), "utf8");
82
+ return {
83
+ encoding: "gzip+base64",
84
+ payload: gzipSync(raw).toString("base64"),
85
+ rawBytes: raw.length,
86
+ };
87
+ }
88
+
89
+ function isPlaywrightStorageState(value) {
90
+ return (
91
+ value &&
92
+ typeof value === "object" &&
93
+ !Array.isArray(value) &&
94
+ (Array.isArray(value.cookies) || Array.isArray(value.origins))
95
+ );
96
+ }
97
+
98
+ export function decodeAuthPayload(payloadB64) {
99
+ const trimmed = String(payloadB64 || "").trim();
100
+ if (!trimmed) {
101
+ throw new Error("Empty auth payload.");
102
+ }
103
+ let decoded;
104
+ try {
105
+ decoded = Buffer.from(trimmed, "base64");
106
+ } catch (err) {
107
+ throw new Error(`Invalid base64 auth payload: ${err.message}`);
108
+ }
109
+
110
+ let jsonText;
111
+ try {
112
+ jsonText = gunzipSync(decoded).toString("utf8");
113
+ } catch {
114
+ jsonText = decoded.toString("utf8");
115
+ }
116
+
117
+ let parsed;
118
+ try {
119
+ parsed = JSON.parse(jsonText);
120
+ } catch (err) {
121
+ throw new Error(`Auth payload is not valid JSON: ${err.message}`);
122
+ }
123
+
124
+ if (parsed?.version === AUTH_BUNDLE_VERSION && parsed?.profiles && typeof parsed.profiles === "object") {
125
+ return { format: "bundle", manifest: parsed };
126
+ }
127
+ if (isPlaywrightStorageState(parsed)) {
128
+ return { format: "legacy-single", state: parsed };
129
+ }
130
+ throw new Error("Unrecognized auth payload format.");
131
+ }
132
+
133
+ function manifestSubset(manifest, profileNames) {
134
+ const profiles = {};
135
+ for (const name of profileNames) {
136
+ if (manifest.profiles?.[name]) {
137
+ profiles[name] = manifest.profiles[name];
138
+ }
139
+ }
140
+ return {
141
+ version: AUTH_BUNDLE_VERSION,
142
+ org_id: manifest.org_id,
143
+ project_id: manifest.project_id,
144
+ profiles,
145
+ };
146
+ }
147
+
148
+ export function shardAuthBundleManifest(manifest) {
149
+ const profileNames = Object.keys(manifest.profiles || {});
150
+ if (profileNames.length === 0) {
151
+ throw new Error("Auth bundle has no profiles.");
152
+ }
153
+
154
+ const singles = profileNames.map((name) => {
155
+ const encoded = encodeAuthBundleManifest(manifestSubset(manifest, [name]));
156
+ return { name, encoded, bytes: Buffer.byteLength(encoded.payload, "utf8") };
157
+ });
158
+
159
+ const allEncoded = encodeAuthBundleManifest(manifest);
160
+ if (Buffer.byteLength(allEncoded.payload, "utf8") <= GITHUB_SECRET_SAFE_BYTES) {
161
+ return [
162
+ {
163
+ secretName: AUTH_SECRET_PRIMARY,
164
+ payload: allEncoded.payload,
165
+ encoding: allEncoded.encoding,
166
+ rawBytes: allEncoded.rawBytes,
167
+ profileNames,
168
+ },
169
+ ];
170
+ }
171
+
172
+ const shards = [];
173
+ let currentNames = [];
174
+ let currentBytes = 0;
175
+
176
+ const flush = () => {
177
+ if (currentNames.length === 0) return;
178
+ const encoded = encodeAuthBundleManifest(manifestSubset(manifest, currentNames));
179
+ const bytes = Buffer.byteLength(encoded.payload, "utf8");
180
+ if (bytes > GITHUB_SECRET_SAFE_BYTES) {
181
+ throw new Error(
182
+ `Profile "${currentNames.join(", ")}" is too large for a GitHub secret on its own ` +
183
+ `(${bytes} bytes; limit ~${GITHUB_SECRET_SAFE_BYTES}). Try a shorter-lived session.`
184
+ );
185
+ }
186
+ shards.push({
187
+ secretName: authShardSecretName(shards.length + 1),
188
+ payload: encoded.payload,
189
+ encoding: encoded.encoding,
190
+ rawBytes: encoded.rawBytes,
191
+ profileNames: [...currentNames],
192
+ });
193
+ currentNames = [];
194
+ currentBytes = 0;
195
+ };
196
+
197
+ for (const row of singles) {
198
+ if (row.bytes > GITHUB_SECRET_SAFE_BYTES) {
199
+ throw new Error(
200
+ `Profile "${row.name}" is too large for a GitHub secret (${row.bytes} bytes; limit ~${GITHUB_SECRET_SAFE_BYTES}).`
201
+ );
202
+ }
203
+ const nextNames = [...currentNames, row.name];
204
+ const nextEncoded = encodeAuthBundleManifest(manifestSubset(manifest, nextNames));
205
+ const nextBytes = Buffer.byteLength(nextEncoded.payload, "utf8");
206
+ if (nextBytes > GITHUB_SECRET_SAFE_BYTES && currentNames.length > 0) {
207
+ flush();
208
+ }
209
+ currentNames.push(row.name);
210
+ currentBytes = Buffer.byteLength(
211
+ encodeAuthBundleManifest(manifestSubset(manifest, currentNames)).payload,
212
+ "utf8"
213
+ );
214
+ }
215
+ flush();
216
+
217
+ if (shards.length > MAX_AUTH_SHARDS) {
218
+ throw new Error(
219
+ `Auth bundle requires ${shards.length} GitHub secrets (max ${MAX_AUTH_SHARDS}). ` +
220
+ "Export fewer profiles with --profiles or --suite-id / --ids."
221
+ );
222
+ }
223
+ return shards;
224
+ }
225
+
226
+ export function collectAuthShardPayloadsFromEnv(env = process.env) {
227
+ const payloads = [];
228
+ for (let index = 1; index <= MAX_AUTH_SHARDS; index += 1) {
229
+ const secretName = authShardSecretName(index);
230
+ const value = String(env[secretName] || "").trim();
231
+ if (!value) break;
232
+ payloads.push({ secretName, payload: value });
233
+ }
234
+ return payloads;
235
+ }
236
+
237
+ export function mergeAuthManifests(manifests) {
238
+ const mergedProfiles = {};
239
+ let orgId = "";
240
+ let projectId = "";
241
+ for (const manifest of manifests) {
242
+ orgId = orgId || String(manifest.org_id || "").trim();
243
+ projectId = projectId || String(manifest.project_id || "").trim();
244
+ for (const [name, entry] of Object.entries(manifest.profiles || {})) {
245
+ mergedProfiles[name] = entry;
246
+ }
247
+ }
248
+ return {
249
+ version: AUTH_BUNDLE_VERSION,
250
+ org_id: orgId,
251
+ project_id: projectId,
252
+ profiles: mergedProfiles,
253
+ };
254
+ }
255
+
256
+ export function restoreAuthBundleToDisk({
257
+ orgId,
258
+ projectId,
259
+ manifest,
260
+ fallbackProfile = "",
261
+ }) {
262
+ const restored = [];
263
+ for (const [name, entry] of Object.entries(manifest.profiles || {})) {
264
+ const localProfileName =
265
+ String(entry?.local_profile_name || name).trim() || name;
266
+ const state = entry?.state;
267
+ if (!isPlaywrightStorageState(state)) {
268
+ throw new Error(`Profile "${name}" has invalid storage state in auth bundle.`);
269
+ }
270
+ const authFile = localAuthStatePath({
271
+ orgId,
272
+ projectId,
273
+ profile: localProfileName,
274
+ });
275
+ mkdirSync(join(authFile, ".."), { recursive: true });
276
+ writeFileSync(authFile, `${JSON.stringify(state)}\n`, "utf8");
277
+ try {
278
+ chmodSync(authFile, 0o600);
279
+ } catch {
280
+ // ignore
281
+ }
282
+ restored.push({ name, localProfileName, path: authFile });
283
+ }
284
+ return restored;
285
+ }
286
+
287
+ export function restoreLegacySingleAuthToDisk({ orgId, projectId, state, fallbackProfile }) {
288
+ const profile = String(fallbackProfile || "").trim();
289
+ if (!profile) {
290
+ throw new Error(
291
+ "Legacy single-profile auth payload requires QUALTY_AUTH_PROFILE (or --profile)."
292
+ );
293
+ }
294
+ const authFile = localAuthStatePath({ orgId, projectId, profile });
295
+ mkdirSync(join(authFile, ".."), { recursive: true });
296
+ writeFileSync(authFile, `${JSON.stringify(state)}\n`, "utf8");
297
+ try {
298
+ chmodSync(authFile, 0o600);
299
+ } catch {
300
+ // ignore
301
+ }
302
+ return [{ name: profile, localProfileName: profile, path: authFile }];
303
+ }
304
+
305
+ export function restoreAuthPayloadsToDisk({
306
+ payloads,
307
+ orgId,
308
+ projectId,
309
+ fallbackProfile = "",
310
+ }) {
311
+ const manifests = [];
312
+ let legacySingle = null;
313
+
314
+ for (const row of payloads) {
315
+ const decoded = decodeAuthPayload(row.payload);
316
+ if (decoded.format === "bundle") {
317
+ manifests.push(decoded.manifest);
318
+ continue;
319
+ }
320
+ if (legacySingle) {
321
+ throw new Error("Multiple legacy single-profile auth payloads are not supported.");
322
+ }
323
+ legacySingle = decoded.state;
324
+ }
325
+
326
+ if (manifests.length > 0) {
327
+ const merged = mergeAuthManifests(manifests);
328
+ const effectiveOrgId = String(orgId || merged.org_id || "").trim();
329
+ const effectiveProjectId = String(projectId || merged.project_id || "").trim();
330
+ return restoreAuthBundleToDisk({
331
+ orgId: effectiveOrgId,
332
+ projectId: effectiveProjectId,
333
+ manifest: merged,
334
+ fallbackProfile,
335
+ });
336
+ }
337
+
338
+ if (legacySingle) {
339
+ return restoreLegacySingleAuthToDisk({
340
+ orgId,
341
+ projectId,
342
+ state: legacySingle,
343
+ fallbackProfile,
344
+ });
345
+ }
346
+
347
+ throw new Error("No auth payloads to restore.");
348
+ }
package/bin/qualty.js CHANGED
@@ -9,13 +9,22 @@ 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
30
  const DEFAULT_QUALTY_API_URL = "https://qualty-api-production.up.railway.app";
@@ -178,11 +187,15 @@ function usage() {
178
187
  "Usage:",
179
188
  " qualty setup [--token <bearer-token>] [--org <org-id>]",
180
189
  " qualty auth setup [--project <project-id>] [--profile <profile-name>] [--headed]",
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>]",
183
- " [--output <path>] [--no-file] [--copy] [--gzip] [--include-token]",
184
- " [--set-secret | --set-github] gh: secrets (token, API URL, auth) + variables (org, project, profile, …)",
185
- " [--cli-repo <owner/repo>] [--cli-ref <branch>] Optional CI variables",
190
+ " qualty ci setup [--project <project-id>] [--profile <profile-name>] [--org <org-id>]",
191
+ " [--all-profiles] [--profiles <name1,name2>]",
192
+ " [--suite-id <suite-uuid>] [--ids <id1,id2>] bundle login profiles required by tests",
193
+ " [--url <api-base-url>] [--repo <owner/repo>]",
194
+ " [--output <path>] [--no-file] [--copy] [--gzip] [--include-token]",
195
+ " [--set-secret | --set-github] gh: secrets (token, API URL, auth bundle) + variables",
196
+ " [--cli-repo <owner/repo>] [--cli-ref <branch>] Optional CI variables",
197
+ " qualty auth restore-ci [--org <org-id>] [--project <project-id>] [--profile <fallback-profile>]",
198
+ " Reads QUALTY_AUTH_STATE_B64 (+ _2, _3, … shards) and writes ~/.qualty/local-contexts/…",
186
199
  " qualty connect --project <project-id> [--port 3000] [--token <bearer-token>] [--org <org-id>]",
187
200
  " qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--token <bearer-token>] [--org <org-id>]",
188
201
  " [--poll-interval 5] [--timeout 30] [--fail-on-failure true] [--no-view-logs]",
@@ -529,12 +542,10 @@ function localAuthStatePath({ orgId, projectId, profile }) {
529
542
  return join(QUALTY_SHARED_CREDS_DIR, "local-contexts", orgSeg, projectSeg, `${profileSeg}.json`);
530
543
  }
531
544
 
532
- function authExportCiOutputPath(statePath) {
545
+ function ciSetupAuthBundleOutputPath(statePath) {
533
546
  return statePath.replace(/\.json$/i, ".b64");
534
547
  }
535
548
 
536
- const GITHUB_SECRET_MAX_BYTES = 64 * 1024;
537
-
538
549
  function copyToClipboard(text) {
539
550
  if (process.platform === "darwin") {
540
551
  const result = spawnSync("pbcopy", { input: text, encoding: "utf8" });
@@ -600,7 +611,8 @@ function syncGithubCiConfig({
600
611
  orgId,
601
612
  projectId,
602
613
  profile,
603
- authPayload,
614
+ profileNames,
615
+ authShards,
604
616
  apiUrl,
605
617
  token,
606
618
  suiteId,
@@ -612,8 +624,9 @@ function syncGithubCiConfig({
612
624
  results.push({ kind, name, ...outcome });
613
625
  };
614
626
 
615
- const authResult = setGithubSecret("QUALTY_AUTH_STATE_B64", authPayload, { repo });
616
- record("secret", "QUALTY_AUTH_STATE_B64", authResult);
627
+ for (const shard of authShards || []) {
628
+ record("secret", shard.secretName, setGithubSecret(shard.secretName, shard.payload, { repo }));
629
+ }
617
630
 
618
631
  if (token) {
619
632
  record("secret", "QUALTY_API_TOKEN", setGithubSecret("QUALTY_API_TOKEN", token, { repo }));
@@ -631,7 +644,21 @@ function syncGithubCiConfig({
631
644
 
632
645
  record("variable", "QUALTY_ORG_ID", setGithubVariable("QUALTY_ORG_ID", orgId, { repo }));
633
646
  record("variable", "QUALTY_PROJECT_ID", setGithubVariable("QUALTY_PROJECT_ID", String(projectId), { repo }));
634
- record("variable", "QUALTY_AUTH_PROFILE", setGithubVariable("QUALTY_AUTH_PROFILE", profile, { repo }));
647
+
648
+ const names = (profileNames || []).filter(Boolean);
649
+ if (names.length > 0) {
650
+ record("variable", AUTH_PROFILES_VARIABLE, setGithubVariable(AUTH_PROFILES_VARIABLE, names.join(","), { repo }));
651
+ }
652
+ if (profile) {
653
+ record("variable", "QUALTY_AUTH_PROFILE", setGithubVariable("QUALTY_AUTH_PROFILE", profile, { repo }));
654
+ }
655
+ if ((authShards || []).length > 1) {
656
+ record(
657
+ "variable",
658
+ AUTH_SHARDS_VARIABLE,
659
+ setGithubVariable(AUTH_SHARDS_VARIABLE, String(authShards.length), { repo })
660
+ );
661
+ }
635
662
 
636
663
  if (suiteId) {
637
664
  record("variable", "QUALTY_SUITE_ID", setGithubVariable("QUALTY_SUITE_ID", String(suiteId), { repo }));
@@ -727,7 +754,7 @@ function configStringValue(projectCfg, key, configPath) {
727
754
  );
728
755
  }
729
756
 
730
- function missingExportCiField(fieldLabel, { flag, configKey, configPath, hasFlag }) {
757
+ function missingCiSetupField(fieldLabel, { flag, configKey, configPath, hasFlag }) {
731
758
  const configHint = configPath
732
759
  ? `${PROJECT_CONFIG_FILENAME} at ${configPath} (${configKey})`
733
760
  : `${PROJECT_CONFIG_FILENAME} (not found walking up from ${process.cwd()})`;
@@ -743,12 +770,22 @@ function missingExportCiField(fieldLabel, { flag, configKey, configPath, hasFlag
743
770
  ].join(" ");
744
771
  }
745
772
 
746
- function resolveExportCiConfig(args) {
773
+ function resolveCiSetupConfig(args, { requireProfile = true } = {}) {
747
774
  const { configPath, config: projectCfg } = readProjectConfigStrict();
748
775
 
749
776
  const orgFromFlag = String(args.org || "").trim();
750
777
  const projectFromFlag = String(args.project || "").trim();
751
778
  const profileFromFlag = String(args.profile || args["auth-profile"] || "").trim();
779
+ const allProfiles = parseBoolean(args["all-profiles"], false);
780
+ const profilesList = String(args.profiles || "")
781
+ .split(",")
782
+ .map((part) => part.trim())
783
+ .filter(Boolean);
784
+ const suiteId = String(args["suite-id"] || args.suite || "").trim();
785
+ const explicitIds = String(args.ids || "")
786
+ .split(",")
787
+ .map((part) => part.trim())
788
+ .filter(Boolean);
752
789
 
753
790
  let orgId = orgFromFlag;
754
791
  if (!orgId) {
@@ -767,7 +804,7 @@ function resolveExportCiConfig(args) {
767
804
 
768
805
  if (!orgId) {
769
806
  throw new Error(
770
- missingExportCiField("org ID", {
807
+ missingCiSetupField("org ID", {
771
808
  flag: "--org",
772
809
  configKey: "default_org_id",
773
810
  configPath,
@@ -777,7 +814,7 @@ function resolveExportCiConfig(args) {
777
814
  }
778
815
  if (!projectId) {
779
816
  throw new Error(
780
- missingExportCiField("project ID", {
817
+ missingCiSetupField("project ID", {
781
818
  flag: "--project",
782
819
  configKey: "default_project_id",
783
820
  configPath,
@@ -785,9 +822,12 @@ function resolveExportCiConfig(args) {
785
822
  })
786
823
  );
787
824
  }
788
- if (!profile) {
825
+
826
+ const hasMultiProfileMode =
827
+ allProfiles || profilesList.length > 0 || Boolean(suiteId) || explicitIds.length > 0;
828
+ if (requireProfile && !profile && !hasMultiProfileMode) {
789
829
  throw new Error(
790
- missingExportCiField("auth profile", {
830
+ missingCiSetupField("auth profile", {
791
831
  flag: "--profile",
792
832
  configKey: "default_auth_profile",
793
833
  configPath,
@@ -803,43 +843,163 @@ function resolveExportCiConfig(args) {
803
843
  throw new Error(`Invalid API URL: ${err.message}`);
804
844
  }
805
845
 
806
- return { orgId, projectId, profile, apiUrl, configPath };
846
+ return {
847
+ orgId,
848
+ projectId,
849
+ profile,
850
+ apiUrl,
851
+ configPath,
852
+ allProfiles,
853
+ profilesList,
854
+ suiteId,
855
+ explicitIds,
856
+ };
857
+ }
858
+
859
+ function jobUsesSavedLogin(job) {
860
+ if (!job) return false;
861
+ if (job.use_saved_login) return true;
862
+ if (job.saved_login_profile_id) return true;
863
+ const profile = job.saved_login_profile;
864
+ return Boolean(profile && typeof profile === "object" && (profile.id || profile.name));
865
+ }
866
+
867
+ async function fetchSavedLoginProfilesForProject({ apiUrl, token, orgId, projectId }) {
868
+ const payload = await apiRequest({
869
+ apiUrl,
870
+ token,
871
+ orgId,
872
+ path: `/api/v1/projects/${encodeURIComponent(String(projectId))}/saved-login-profiles`,
873
+ }).catch(() => []);
874
+ return Array.isArray(payload) ? payload : [];
807
875
  }
808
876
 
809
- function encodeAuthStateForGithubSecret(statePath, { gzip = false } = {}) {
810
- const raw = readFileSync(statePath);
811
- if (gzip) {
812
- return {
813
- encoding: "gzip+base64",
814
- payload: gzipSync(raw).toString("base64"),
815
- rawBytes: raw.length,
816
- };
877
+ function localProfileLabelFromApiRow(row, fallbackName = "") {
878
+ return String(row?.local_profile_name || row?.name || fallbackName || "").trim();
879
+ }
880
+
881
+ async function resolveRequiredProfileNamesFromTests({
882
+ apiUrl,
883
+ token,
884
+ orgId,
885
+ projectId,
886
+ suiteId,
887
+ explicitIds,
888
+ }) {
889
+ const jobsPayload = await resolveSavedJobs({
890
+ apiUrl,
891
+ token,
892
+ orgId,
893
+ projectId,
894
+ suiteId,
895
+ explicitIds,
896
+ });
897
+ const jobs = Array.isArray(jobsPayload)
898
+ ? jobsPayload
899
+ : Array.isArray(jobsPayload?.results)
900
+ ? jobsPayload.results
901
+ : Array.isArray(jobsPayload?.jobs)
902
+ ? jobsPayload.jobs
903
+ : [];
904
+ const names = new Set();
905
+ for (const job of jobs) {
906
+ if (!jobUsesSavedLogin(job)) continue;
907
+ const name = String(job?.saved_login_profile?.name || "").trim();
908
+ if (name) names.add(name);
909
+ }
910
+ return [...names];
911
+ }
912
+
913
+ async function resolveCiSetupProfileEntries({
914
+ orgId,
915
+ projectId,
916
+ profile,
917
+ allProfiles,
918
+ profilesList,
919
+ suiteId,
920
+ explicitIds,
921
+ apiUrl,
922
+ token,
923
+ }) {
924
+ const apiProfiles = token
925
+ ? await fetchSavedLoginProfilesForProject({ apiUrl, token, orgId, projectId })
926
+ : [];
927
+
928
+ let requestedNames = [];
929
+ if (allProfiles) {
930
+ requestedNames = apiProfiles.map((row) => String(row?.name || "").trim()).filter(Boolean);
931
+ if (requestedNames.length === 0) {
932
+ requestedNames = listLocalAuthProfiles({ orgId, projectId });
933
+ }
934
+ } else if (profilesList.length > 0) {
935
+ requestedNames = profilesList;
936
+ } else if (suiteId || explicitIds.length > 0) {
937
+ if (!token) {
938
+ throw new Error("QUALTY_API_TOKEN is required to resolve profiles from --suite-id or --ids.");
939
+ }
940
+ requestedNames = await resolveRequiredProfileNamesFromTests({
941
+ apiUrl,
942
+ token,
943
+ orgId,
944
+ projectId,
945
+ suiteId,
946
+ explicitIds,
947
+ });
948
+ if (requestedNames.length === 0 && profile) {
949
+ requestedNames = [profile];
950
+ }
951
+ } else if (profile) {
952
+ requestedNames = [profile];
953
+ }
954
+
955
+ requestedNames = [...new Set(requestedNames.filter(Boolean))];
956
+ if (requestedNames.length === 0) {
957
+ throw new Error(
958
+ "No auth profiles selected. Pass --profile, --profiles, --all-profiles, or --suite-id / --ids."
959
+ );
817
960
  }
818
- return {
819
- encoding: "base64",
820
- payload: raw.toString("base64"),
821
- rawBytes: raw.length,
822
- };
961
+
962
+ const entries = [];
963
+ const missing = [];
964
+ for (const dashboardName of requestedNames) {
965
+ const apiRow = apiProfiles.find((row) => String(row?.name || "").trim() === dashboardName);
966
+ const localProfileName = localProfileLabelFromApiRow(apiRow, dashboardName) || dashboardName;
967
+ const statePath = localAuthStatePath({ orgId, projectId, profile: localProfileName });
968
+ if (!existsSync(statePath)) {
969
+ missing.push({ dashboardName, localProfileName, statePath });
970
+ continue;
971
+ }
972
+ entries.push({
973
+ name: dashboardName,
974
+ localProfileName,
975
+ statePath,
976
+ });
977
+ }
978
+
979
+ if (missing.length > 0) {
980
+ const lines = missing.map(
981
+ (row) =>
982
+ ` - "${row.dashboardName}" → ${row.statePath}\n qualty auth setup --project ${projectId} --profile "${row.dashboardName}"`
983
+ );
984
+ throw new Error(["Local auth state missing for:", ...lines].join("\n"));
985
+ }
986
+
987
+ return entries;
823
988
  }
824
989
 
825
- function githubAuthRestoreShell({ orgId, projectId, profile }) {
990
+ function githubAuthRestoreShell({ shardCount = 1 } = {}) {
991
+ const shardEnvLines = [];
992
+ for (let index = 1; index <= Math.max(1, shardCount); index += 1) {
993
+ const secretName = authShardSecretName(index);
994
+ shardEnvLines.push(` ${secretName}: \${{ secrets.${secretName} }}`);
995
+ }
826
996
  return [
827
- `- name: Restore Qualty login state`,
997
+ `- name: Restore Qualty login states`,
828
998
  ` env:`,
829
- ` QUALTY_AUTH_STATE_B64: \${{ secrets.QUALTY_AUTH_STATE_B64 }}`,
830
- ` run: |`,
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"`,
999
+ ...shardEnvLines,
1000
+ ` QUALTY_ORG_ID: \${{ vars.QUALTY_ORG_ID }}`,
1001
+ ` QUALTY_PROJECT_ID: \${{ vars.QUALTY_PROJECT_ID }}`,
1002
+ ` run: npx --yes qualty auth restore-ci`,
843
1003
  ``,
844
1004
  `- name: Run Qualty tests (local)`,
845
1005
  ` env:`,
@@ -850,84 +1010,120 @@ function githubAuthRestoreShell({ orgId, projectId, profile }) {
850
1010
  ` --org "$QUALTY_ORG_ID" \\`,
851
1011
  ` --project "$QUALTY_PROJECT_ID" \\`,
852
1012
  ` --suite-id "YOUR-SUITE-UUID" \\`,
853
- ` --auth-profile "$QUALTY_AUTH_PROFILE" \\`,
854
1013
  ` --fail-on-failure true`,
1014
+ ` # Omit --auth-profile when tests use different saved login profiles.`,
1015
+ ` # Add --auth-profile "Profile Name" only to force one profile for the whole run.`,
855
1016
  ].join("\n");
856
1017
  }
857
1018
 
858
- async function runAuthExportCi(args) {
859
- const { orgId, projectId, profile, apiUrl, configPath } = resolveExportCiConfig(args);
860
- const token = resolveToken(args);
1019
+ async function runAuthRestoreCi(args) {
1020
+ const orgId = String(args.org || process.env.QUALTY_ORG_ID || "").trim();
1021
+ const projectId = String(args.project || process.env.QUALTY_PROJECT_ID || "").trim();
1022
+ const fallbackProfile = String(
1023
+ args.profile || args["auth-profile"] || process.env.QUALTY_AUTH_PROFILE || ""
1024
+ ).trim();
861
1025
 
862
- const statePath = localAuthStatePath({ orgId, projectId, profile });
863
- if (!existsSync(statePath)) {
864
- throw new Error(
865
- [
866
- "Local auth state not found:",
867
- ` ${statePath}`,
868
- "",
869
- "Create it first:",
870
- ` qualty auth setup --project ${projectId} --profile "${profile}"`,
871
- configPath ? ` (org/project/profile from ${configPath})` : "",
872
- ]
873
- .filter(Boolean)
874
- .join("\n")
875
- );
1026
+ if (!orgId) {
1027
+ throw new Error("Missing org ID. Set QUALTY_ORG_ID or pass --org.");
1028
+ }
1029
+ if (!projectId) {
1030
+ throw new Error("Missing project ID. Set QUALTY_PROJECT_ID or pass --project.");
876
1031
  }
877
1032
 
878
- const setGithubFlag =
879
- parseBoolean(args["set-github"], false) || parseBoolean(args["set-secret"], false);
880
- // CI path: one format only — gzip then base64 in QUALTY_AUTH_STATE_B64 (no separate gzip flag).
881
- let gzip = parseBoolean(args.gzip, false) || setGithubFlag;
882
- let encoded;
883
- try {
884
- encoded = encodeAuthStateForGithubSecret(statePath, { gzip });
885
- } catch (err) {
1033
+ const payloads = collectAuthShardPayloadsFromEnv(process.env);
1034
+ if (payloads.length === 0) {
886
1035
  throw new Error(
887
- `Could not read auth state at ${statePath}: ${err.message}`
1036
+ `Missing ${AUTH_SECRET_PRIMARY}. Run qualty ci setup --set-github from your app repo.`
888
1037
  );
889
1038
  }
890
- let secretBytes = Buffer.byteLength(encoded.payload, "utf8");
891
- if (!gzip && secretBytes > GITHUB_SECRET_MAX_BYTES - 1024) {
892
- 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
- }
900
- secretBytes = Buffer.byteLength(encoded.payload, "utf8");
901
- }
902
- if (secretBytes > GITHUB_SECRET_MAX_BYTES - 1024) {
903
- throw new Error(
904
- `Auth state is too large for a GitHub secret (${secretBytes} bytes; limit ~64 KB). ` +
905
- "Try a shorter-lived session, fewer cookies, or contact Qualty support."
906
- );
1039
+
1040
+ const restored = restoreAuthPayloadsToDisk({
1041
+ payloads,
1042
+ orgId,
1043
+ projectId,
1044
+ fallbackProfile,
1045
+ });
1046
+
1047
+ for (const row of restored) {
1048
+ // eslint-disable-next-line no-console
1049
+ console.log(`Restored login state: ${row.path} (profile "${row.name}")`);
907
1050
  }
1051
+ // eslint-disable-next-line no-console
1052
+ console.log(`Restored ${restored.length} login profile(s).`);
1053
+ }
1054
+
1055
+ async function runCiSetup(args) {
1056
+ const {
1057
+ orgId,
1058
+ projectId,
1059
+ profile,
1060
+ apiUrl,
1061
+ configPath,
1062
+ allProfiles,
1063
+ profilesList,
1064
+ suiteId: configSuiteId,
1065
+ explicitIds,
1066
+ } = resolveCiSetupConfig(args);
1067
+ const token = resolveToken(args);
1068
+
1069
+ const profileEntries = await resolveCiSetupProfileEntries({
1070
+ orgId,
1071
+ projectId,
1072
+ profile,
1073
+ allProfiles,
1074
+ profilesList,
1075
+ suiteId: configSuiteId,
1076
+ explicitIds,
1077
+ apiUrl,
1078
+ token,
1079
+ });
1080
+ const profileNames = profileEntries.map((entry) => entry.name);
1081
+ const primaryProfile = profile || profileNames[0] || "";
1082
+
1083
+ const manifest = buildAuthBundleManifest({ orgId, projectId, profileEntries });
1084
+ const authShards = shardAuthBundleManifest(manifest);
1085
+ const primaryShard = authShards[0];
908
1086
 
1087
+ const setGithubFlag =
1088
+ parseBoolean(args["set-github"], false) || parseBoolean(args["set-secret"], false);
909
1089
  const noFile = parseBoolean(args["no-file"], false);
910
1090
  const outputPath = noFile
911
1091
  ? String(args.output || "").trim()
912
- : String(args.output || "").trim() || authExportCiOutputPath(statePath);
1092
+ : String(args.output || "").trim() ||
1093
+ ciSetupAuthBundleOutputPath(
1094
+ localAuthStatePath({ orgId, projectId, profile: profileEntries[0].localProfileName })
1095
+ );
913
1096
  const copyToClipboardFlag = parseBoolean(args.copy, false);
914
1097
  const githubRepo = String(args.repo || "").trim();
915
- const suiteId = String(args["suite-id"] || args.suite || "").trim();
1098
+ const suiteId = String(args["suite-id"] || args.suite || configSuiteId || "").trim();
916
1099
  const cliRepo = String(args["cli-repo"] || "").trim();
917
1100
  const cliRef = String(args["cli-ref"] || "").trim();
918
1101
 
919
1102
  if (outputPath) {
920
- writeFileSync(outputPath, encoded.payload, "utf8");
1103
+ writeFileSync(outputPath, primaryShard.payload, "utf8");
921
1104
  try {
922
1105
  chmodSync(outputPath, 0o600);
923
1106
  } catch {
924
1107
  // ignore platforms that do not support chmod
925
1108
  }
1109
+ for (let index = 1; index < authShards.length; index += 1) {
1110
+ const shard = authShards[index];
1111
+ const shardPath = outputPath.replace(/(\.[^./]+)?$/, `_${index + 1}$1`);
1112
+ writeFileSync(shardPath, shard.payload, "utf8");
1113
+ try {
1114
+ chmodSync(shardPath, 0o600);
1115
+ } catch {
1116
+ // ignore
1117
+ }
1118
+ }
926
1119
  }
927
1120
 
928
1121
  const includeToken = parseBoolean(args["include-token"], false);
929
- const profileSeg = safePathSegment(profile, "default");
930
1122
  const skipPayloadPrint = Boolean(outputPath || copyToClipboardFlag || setGithubFlag);
1123
+ const totalSecretBytes = authShards.reduce(
1124
+ (sum, shard) => sum + Buffer.byteLength(shard.payload, "utf8"),
1125
+ 0
1126
+ );
931
1127
 
932
1128
  // eslint-disable-next-line no-console
933
1129
  console.log("");
@@ -935,20 +1131,29 @@ async function runAuthExportCi(args) {
935
1131
  console.log("=== Qualty → GitHub Actions (secrets & variables) ===");
936
1132
  // eslint-disable-next-line no-console
937
1133
  console.log("");
938
- if (encoded.encoding === "gzip+base64") {
939
- // eslint-disable-next-line no-console
940
- console.log(`Auth state: gzip+base64 (${encoded.rawBytes} bytes raw ${secretBytes} bytes secret)`);
941
- } else {
1134
+ // eslint-disable-next-line no-console
1135
+ console.log(
1136
+ `Auth bundle: ${profileNames.length} profile(s), ${authShards.length} secret shard(s), ${totalSecretBytes} bytes total`
1137
+ );
1138
+ for (const shard of authShards) {
942
1139
  // eslint-disable-next-line no-console
943
- console.log(`Auth state: base64 (${encoded.rawBytes} bytes raw → ${secretBytes} bytes secret)`);
1140
+ console.log(
1141
+ ` - ${shard.secretName}: ${shard.profileNames.join(", ")} (${Buffer.byteLength(shard.payload, "utf8")} bytes)`
1142
+ );
944
1143
  }
945
1144
  // eslint-disable-next-line no-console
946
1145
  console.log("");
947
1146
  if (outputPath) {
948
1147
  // eslint-disable-next-line no-console
949
- console.log(`Wrote QUALTY_AUTH_STATE_B64 to ${outputPath}`);
1148
+ console.log(`Wrote ${AUTH_SECRET_PRIMARY} to ${outputPath}`);
1149
+ for (let index = 1; index < authShards.length; index += 1) {
1150
+ const shard = authShards[index];
1151
+ const shardPath = outputPath.replace(/(\.[^./]+)?$/, `_${index + 1}$1`);
1152
+ // eslint-disable-next-line no-console
1153
+ console.log(`Wrote ${shard.secretName} to ${shardPath}`);
1154
+ }
950
1155
  // eslint-disable-next-line no-console
951
- console.log(` gh secret set QUALTY_AUTH_STATE_B64 < ${outputPath}`);
1156
+ console.log(` gh secret set ${AUTH_SECRET_PRIMARY} < ${outputPath}`);
952
1157
  // eslint-disable-next-line no-console
953
1158
  console.log("");
954
1159
  }
@@ -957,8 +1162,9 @@ async function runAuthExportCi(args) {
957
1162
  repo: githubRepo,
958
1163
  orgId,
959
1164
  projectId,
960
- profile,
961
- authPayload: encoded.payload,
1165
+ profile: primaryProfile,
1166
+ profileNames,
1167
+ authShards,
962
1168
  apiUrl,
963
1169
  token,
964
1170
  suiteId,
@@ -966,25 +1172,27 @@ async function runAuthExportCi(args) {
966
1172
  cliRef,
967
1173
  });
968
1174
  printGithubSyncResults(syncResults, { repo: githubRepo });
969
- const authRow = syncResults.find((r) => r.name === "QUALTY_AUTH_STATE_B64");
970
- if (!authRow?.ok && outputPath) {
1175
+ const failedAuth = syncResults.filter((row) => row.kind === "secret" && !row.ok);
1176
+ if (failedAuth.length > 0 && outputPath) {
971
1177
  // eslint-disable-next-line no-console
972
- console.log(` Manual auth secret: gh secret set QUALTY_AUTH_STATE_B64 < ${outputPath}`);
1178
+ console.log(" Manual auth secrets:");
1179
+ authShards.forEach((shard, index) => {
1180
+ const shardPath =
1181
+ index === 0 ? outputPath : outputPath.replace(/(\.[^./]+)?$/, `_${index + 1}$1`);
1182
+ // eslint-disable-next-line no-console
1183
+ console.log(` gh secret set ${shard.secretName} < ${shardPath}`);
1184
+ });
973
1185
  // eslint-disable-next-line no-console
974
1186
  console.log("");
975
1187
  }
976
1188
  }
977
1189
  if (copyToClipboardFlag) {
978
- if (copyToClipboard(encoded.payload)) {
1190
+ if (copyToClipboard(primaryShard.payload)) {
979
1191
  // eslint-disable-next-line no-console
980
- console.log("✓ Copied QUALTY_AUTH_STATE_B64 to clipboard. Paste into GitHub → Settings → Secrets.");
1192
+ console.log(`✓ Copied ${AUTH_SECRET_PRIMARY} to clipboard.`);
981
1193
  } else {
982
1194
  // eslint-disable-next-line no-console
983
1195
  console.log("Could not copy to clipboard on this machine.");
984
- if (outputPath) {
985
- // eslint-disable-next-line no-console
986
- console.log(` Use the file instead: ${outputPath}`);
987
- }
988
1196
  }
989
1197
  // eslint-disable-next-line no-console
990
1198
  console.log("");
@@ -994,10 +1202,16 @@ async function runAuthExportCi(args) {
994
1202
  console.log(
995
1203
  "1. GitHub Actions config was pushed via gh (secrets + repository variables — see sync above). Fix any ✗ manually."
996
1204
  );
997
- if (!suiteId) {
1205
+ if (!suiteId && !configSuiteId && explicitIds.length === 0) {
1206
+ // eslint-disable-next-line no-console
1207
+ console.log(
1208
+ " Tip: pass --suite-id or --ids to bundle only login profiles required by those tests."
1209
+ );
1210
+ }
1211
+ if (authShards.length > 1) {
998
1212
  // eslint-disable-next-line no-console
999
1213
  console.log(
1000
- " Tip: hardcode --suite-id or --ids in your workflow (see templates/github-actions/qualty-local-pr.yml)."
1214
+ ` Auth was split across ${authShards.length} secrets. Add optional shard env vars to your workflow restore step.`
1001
1215
  );
1002
1216
  }
1003
1217
  // eslint-disable-next-line no-console
@@ -1032,13 +1246,35 @@ async function runAuthExportCi(args) {
1032
1246
  // eslint-disable-next-line no-console
1033
1247
  console.log("---");
1034
1248
  // eslint-disable-next-line no-console
1035
- console.log("Name: QUALTY_AUTH_PROFILE");
1249
+ console.log(`Name: ${AUTH_PROFILES_VARIABLE}`);
1036
1250
  // eslint-disable-next-line no-console
1037
1251
  console.log("Value:");
1038
1252
  // eslint-disable-next-line no-console
1039
- console.log(profile);
1253
+ console.log(profileNames.join(","));
1040
1254
  // eslint-disable-next-line no-console
1041
- console.log(` gh variable set QUALTY_AUTH_PROFILE --body "${profile}"`);
1255
+ console.log(` gh variable set ${AUTH_PROFILES_VARIABLE} --body "${profileNames.join(",")}"`);
1256
+ if (primaryProfile) {
1257
+ // eslint-disable-next-line no-console
1258
+ console.log("---");
1259
+ // eslint-disable-next-line no-console
1260
+ console.log("Name: QUALTY_AUTH_PROFILE");
1261
+ // eslint-disable-next-line no-console
1262
+ console.log("Value:");
1263
+ // eslint-disable-next-line no-console
1264
+ console.log(primaryProfile);
1265
+ // eslint-disable-next-line no-console
1266
+ console.log(` gh variable set QUALTY_AUTH_PROFILE --body "${primaryProfile}"`);
1267
+ }
1268
+ if (authShards.length > 1) {
1269
+ // eslint-disable-next-line no-console
1270
+ console.log("---");
1271
+ // eslint-disable-next-line no-console
1272
+ console.log(`Name: ${AUTH_SHARDS_VARIABLE}`);
1273
+ // eslint-disable-next-line no-console
1274
+ console.log("Value:");
1275
+ // eslint-disable-next-line no-console
1276
+ console.log(String(authShards.length));
1277
+ }
1042
1278
  // eslint-disable-next-line no-console
1043
1279
  console.log("---");
1044
1280
  // eslint-disable-next-line no-console
@@ -1079,20 +1315,20 @@ async function runAuthExportCi(args) {
1079
1315
  // eslint-disable-next-line no-console
1080
1316
  console.log("(create in Qualty → Account → API tokens; must start with qlty_ci_)");
1081
1317
  }
1082
- // eslint-disable-next-line no-console
1083
- console.log("---");
1084
- // eslint-disable-next-line no-console
1085
- console.log("Name: QUALTY_AUTH_STATE_B64");
1086
- if (skipPayloadPrint) {
1087
- // eslint-disable-next-line no-console
1088
- console.log(
1089
- `Value: (see file above${copyToClipboardFlag ? " or clipboard" : ""})`
1090
- );
1091
- } else {
1318
+ for (const shard of authShards) {
1092
1319
  // eslint-disable-next-line no-console
1093
- console.log("Value:");
1320
+ console.log("---");
1094
1321
  // eslint-disable-next-line no-console
1095
- console.log(encoded.payload);
1322
+ console.log(`Name: ${shard.secretName}`);
1323
+ if (skipPayloadPrint) {
1324
+ // eslint-disable-next-line no-console
1325
+ console.log(`Value: (see file above${copyToClipboardFlag && shard.secretName === AUTH_SECRET_PRIMARY ? " or clipboard" : ""})`);
1326
+ } else {
1327
+ // eslint-disable-next-line no-console
1328
+ console.log("Value:");
1329
+ // eslint-disable-next-line no-console
1330
+ console.log(shard.payload);
1331
+ }
1096
1332
  }
1097
1333
  // eslint-disable-next-line no-console
1098
1334
  console.log("---");
@@ -1106,17 +1342,24 @@ async function runAuthExportCi(args) {
1106
1342
  // eslint-disable-next-line no-console
1107
1343
  console.log("");
1108
1344
  // eslint-disable-next-line no-console
1109
- console.log(githubAuthRestoreShell({ orgId, projectId, profile }));
1345
+ console.log(githubAuthRestoreShell({ shardCount: authShards.length }));
1110
1346
  // eslint-disable-next-line no-console
1111
1347
  console.log("");
1112
- // eslint-disable-next-line no-console
1113
- console.log(`Local file: ${statePath}`);
1114
- // eslint-disable-next-line no-console
1115
- console.log(`Profile flag: --auth-profile "${profile}" (file on disk: ${profileSeg}.json)`);
1348
+ for (const entry of profileEntries) {
1349
+ // eslint-disable-next-line no-console
1350
+ console.log(`Profile "${entry.name}" → ${entry.statePath}`);
1351
+ }
1352
+ if (profileNames.length === 1) {
1353
+ // eslint-disable-next-line no-console
1354
+ console.log(`Optional override: --auth-profile "${profileNames[0]}"`);
1355
+ } else {
1356
+ // eslint-disable-next-line no-console
1357
+ console.log("Omit --auth-profile in CI so each test uses its dashboard saved login profile.");
1358
+ }
1116
1359
  // eslint-disable-next-line no-console
1117
1360
  console.log("");
1118
1361
  // eslint-disable-next-line no-console
1119
- console.log("When login expires, re-run qualty auth setup and qualty auth export-ci to refresh the secret.");
1362
+ console.log("When login expires, re-run qualty auth setup and qualty ci setup --set-github to refresh secrets.");
1120
1363
  // eslint-disable-next-line no-console
1121
1364
  console.log("");
1122
1365
  }
@@ -2230,7 +2473,7 @@ async function runAuthSetup(args) {
2230
2473
  // eslint-disable-next-line no-console
2231
2474
  console.log(
2232
2475
  `[qualty][auth] For GitHub Actions secrets, run:\n` +
2233
- ` qualty auth export-ci --project ${project.id} --profile "${profile}"`
2476
+ ` qualty ci setup --project ${project.id} --profile "${profile}"`
2234
2477
  );
2235
2478
  } finally {
2236
2479
  rl.close();
@@ -2252,17 +2495,25 @@ async function main() {
2252
2495
  await runSetup(args);
2253
2496
  return;
2254
2497
  }
2498
+ if (command === "ci") {
2499
+ const sub = args._[1];
2500
+ if (sub === "setup") {
2501
+ await runCiSetup(args);
2502
+ return;
2503
+ }
2504
+ throw new Error("Unknown ci subcommand. Use: qualty ci setup");
2505
+ }
2255
2506
  if (command === "auth") {
2256
2507
  const sub = args._[1];
2257
2508
  if (sub === "setup") {
2258
2509
  await runAuthSetup(args);
2259
2510
  return;
2260
2511
  }
2261
- if (sub === "export-ci") {
2262
- await runAuthExportCi(args);
2512
+ if (sub === "restore-ci") {
2513
+ await runAuthRestoreCi(args);
2263
2514
  return;
2264
2515
  }
2265
- throw new Error("Unknown auth subcommand. Use: qualty auth setup | qualty auth export-ci");
2516
+ throw new Error("Unknown auth subcommand. Use: qualty auth setup | qualty auth restore-ci");
2266
2517
  }
2267
2518
  if (command === "run") {
2268
2519
  if (parseBoolean(args.local, false)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualty",
3
- "version": "0.1.22",
3
+ "version": "0.1.26",
4
4
  "description": "Qualty CLI for localhost and CI test runs",
5
5
  "bin": {
6
6
  "qualty": "bin/qualty.js"