qualty 0.1.21 → 0.1.25

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,17 +9,25 @@ 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";
19
-
20
- /** Default Qualty API base URL (no trailing slash). Override with QUALTY_API_URL env. */
21
- //const DEFAULT_QUALTY_API_URL = "https://qualty-api-production.up.railway.app";
22
- const DEFAULT_QUALTY_API_URL = "http://127.0.0.1:8000";
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";
28
+
29
+ /** CLI backend URL is fixed by Qualty distribution. */
30
+ const DEFAULT_QUALTY_API_URL = "https://qualty-api-production.up.railway.app";
23
31
 
24
32
  const PROJECT_CONFIG_FILENAME = ".qualty.json";
25
33
  const QUALTY_SHARED_CREDS_DIR = join(homedir(), ".qualty");
@@ -66,6 +74,10 @@ function projectIdFromConfig(projectCfg, fallback = "") {
66
74
  return String(fallback || projectCfg.default_project_id || "").trim();
67
75
  }
68
76
 
77
+ function orgIdFromConfig(projectCfg, fallback = "") {
78
+ return String(fallback || projectCfg.default_org_id || "").trim();
79
+ }
80
+
69
81
  function authProfileFromConfig(projectCfg, fallback = "") {
70
82
  return String(fallback || projectCfg.default_auth_profile || "").trim();
71
83
  }
@@ -94,9 +106,39 @@ function writeProjectConfig(updates) {
94
106
  writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
95
107
  }
96
108
 
109
+ function normalizeApiUrl(raw, { label = "API URL" } = {}) {
110
+ const trimmed = String(raw || "").trim();
111
+ if (!trimmed) {
112
+ throw new Error(`${label} is empty.`);
113
+ }
114
+ let parsed;
115
+ try {
116
+ parsed = new URL(trimmed);
117
+ } catch {
118
+ throw new Error(`${label} is not a valid URL: ${trimmed}`);
119
+ }
120
+ if (!["http:", "https:"].includes(parsed.protocol)) {
121
+ throw new Error(`${label} must use http or https (got ${parsed.protocol}).`);
122
+ }
123
+ return trimmed.replace(/\/$/, "");
124
+ }
125
+
126
+ function qualtyBaseUrlFromEnv() {
127
+ return String(process.env.QUALTY_BASE_URL || process.env.QUALTY_API_URL || "").trim();
128
+ }
129
+
97
130
  function resolveApiUrl(args = {}) {
98
- void args;
99
- return String(process.env.QUALTY_API_URL || DEFAULT_QUALTY_API_URL).replace(/\/$/, "");
131
+ const fromFlag = String(args.url || args["api-url"] || "").trim();
132
+ const fromEnv = qualtyBaseUrlFromEnv();
133
+ const raw = fromFlag || fromEnv || DEFAULT_QUALTY_API_URL;
134
+ const label = fromFlag
135
+ ? "--url"
136
+ : process.env.QUALTY_BASE_URL
137
+ ? "QUALTY_BASE_URL"
138
+ : fromEnv
139
+ ? "QUALTY_API_URL"
140
+ : "default API URL";
141
+ return normalizeApiUrl(raw, { label });
100
142
  }
101
143
 
102
144
  function resolveToken(args = {}) {
@@ -106,7 +148,7 @@ function resolveToken(args = {}) {
106
148
 
107
149
  function resolveOrgId(args = {}) {
108
150
  const projectCfg = readProjectConfig();
109
- return args.org || process.env.QUALTY_ORG_ID || projectCfg.default_org_id || "";
151
+ return orgIdFromConfig(projectCfg, args.org);
110
152
  }
111
153
 
112
154
  function maskSecret(secret) {
@@ -145,11 +187,15 @@ function usage() {
145
187
  "Usage:",
146
188
  " qualty setup [--token <bearer-token>] [--org <org-id>]",
147
189
  " qualty auth setup [--project <project-id>] [--profile <profile-name>] [--headed]",
148
- " qualty auth export-ci --project <project-id> [--profile <profile-name>] [--org <org-id>]",
149
- " [--suite-id <suite-uuid>] [--repo <owner/repo>]",
190
+ " qualty auth export-ci [--project <project-id>] [--profile <profile-name>] [--org <org-id>]",
191
+ " [--all-profiles] [--profiles <name1,name2>]",
192
+ " [--suite-id <suite-uuid>] [--ids <id1,id2>] export profiles required by tests",
193
+ " [--url <api-base-url>] [--repo <owner/repo>]",
150
194
  " [--output <path>] [--no-file] [--copy] [--gzip] [--include-token]",
151
- " [--set-secret | --set-github] gh: secrets (token, API URL, auth) + variables (org, project, profile, …)",
195
+ " [--set-secret | --set-github] gh: secrets (token, API URL, auth bundle) + variables",
152
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/…",
153
199
  " qualty connect --project <project-id> [--port 3000] [--token <bearer-token>] [--org <org-id>]",
154
200
  " qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--token <bearer-token>] [--org <org-id>]",
155
201
  " [--poll-interval 5] [--timeout 30] [--fail-on-failure true] [--no-view-logs]",
@@ -161,9 +207,11 @@ function usage() {
161
207
  "",
162
208
  "Env vars:",
163
209
  " QUALTY_API_TOKEN Bearer token used for auth",
164
- " QUALTY_API_URL API base URL (default: bundled CLI default)",
165
- " QUALTY_ORG_ID Org context override (default: .qualty.json default_org_id)",
210
+ " QUALTY_BASE_URL API base URL when --url is not set (default: production)",
166
211
  " QUALTY_LOCAL_CONCURRENCY Local parallel workers for --local runs (default: 4)",
212
+ "",
213
+ "Config (.qualty.json): default_org_id, default_project_id, default_auth_profile",
214
+ " CLI flags (--org, --project, --profile, --url) override config values.",
167
215
  ].join("\n")
168
216
  );
169
217
  }
@@ -371,7 +419,7 @@ async function runSetup(args) {
371
419
  let currentOrgName = "";
372
420
  try {
373
421
  const orgResp = await apiRequest({
374
- apiUrl: resolveApiUrl({}, {}),
422
+ apiUrl: resolveApiUrl({}),
375
423
  token,
376
424
  orgId: "",
377
425
  path: "/api/v1/orgs",
@@ -440,7 +488,7 @@ async function runSetup(args) {
440
488
  }
441
489
 
442
490
  if (!orgId) {
443
- throw new Error("Org ID is required. Re-run setup and provide QUALTY_ORG_ID.");
491
+ throw new Error("Org ID is required. Re-run setup and enter your org ID when prompted.");
444
492
  }
445
493
 
446
494
  writeSharedCredentials(token);
@@ -498,8 +546,6 @@ function authExportCiOutputPath(statePath) {
498
546
  return statePath.replace(/\.json$/i, ".b64");
499
547
  }
500
548
 
501
- const GITHUB_SECRET_MAX_BYTES = 64 * 1024;
502
-
503
549
  function copyToClipboard(text) {
504
550
  if (process.platform === "darwin") {
505
551
  const result = spawnSync("pbcopy", { input: text, encoding: "utf8" });
@@ -565,7 +611,8 @@ function syncGithubCiConfig({
565
611
  orgId,
566
612
  projectId,
567
613
  profile,
568
- authPayload,
614
+ profileNames,
615
+ authShards,
569
616
  apiUrl,
570
617
  token,
571
618
  suiteId,
@@ -577,8 +624,9 @@ function syncGithubCiConfig({
577
624
  results.push({ kind, name, ...outcome });
578
625
  };
579
626
 
580
- const authResult = setGithubSecret("QUALTY_AUTH_STATE_B64", authPayload, { repo });
581
- 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
+ }
582
630
 
583
631
  if (token) {
584
632
  record("secret", "QUALTY_API_TOKEN", setGithubSecret("QUALTY_API_TOKEN", token, { repo }));
@@ -590,19 +638,27 @@ function syncGithubCiConfig({
590
638
  }
591
639
 
592
640
  const apiUrlNorm = String(apiUrl || "").replace(/\/$/, "");
593
- const isLocalDefault = apiUrlNorm === "http://127.0.0.1:8000";
594
- if (apiUrlNorm && !isLocalDefault) {
595
- record("secret", "QUALTY_API_URL", setGithubSecret("QUALTY_API_URL", apiUrlNorm, { repo }));
596
- } else {
597
- record("secret", "QUALTY_API_URL", {
598
- ok: false,
599
- reason: "Set QUALTY_API_URL to your dev/prod API (not localhost) before export-ci.",
600
- });
641
+ if (apiUrlNorm) {
642
+ record("secret", "QUALTY_BASE_URL", setGithubSecret("QUALTY_BASE_URL", apiUrlNorm, { repo }));
601
643
  }
602
644
 
603
645
  record("variable", "QUALTY_ORG_ID", setGithubVariable("QUALTY_ORG_ID", orgId, { repo }));
604
646
  record("variable", "QUALTY_PROJECT_ID", setGithubVariable("QUALTY_PROJECT_ID", String(projectId), { repo }));
605
- 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
+ }
606
662
 
607
663
  if (suiteId) {
608
664
  record("variable", "QUALTY_SUITE_ID", setGithubVariable("QUALTY_SUITE_ID", String(suiteId), { repo }));
@@ -649,123 +705,425 @@ function printGithubSyncResults(results, { repo }) {
649
705
  console.log("");
650
706
  }
651
707
 
652
- function encodeAuthStateForGithubSecret(statePath, { gzip = false } = {}) {
653
- const raw = readFileSync(statePath);
654
- if (gzip) {
655
- return {
656
- encoding: "gzip+base64",
657
- payload: gzipSync(raw).toString("base64"),
658
- rawBytes: raw.length,
659
- };
708
+ function readProjectConfigStrict() {
709
+ const configPath = findProjectConfigPath();
710
+ if (!configPath) {
711
+ return { configPath: null, config: {} };
660
712
  }
713
+ let raw;
714
+ try {
715
+ raw = readFileSync(configPath, "utf8");
716
+ } catch (err) {
717
+ throw new Error(
718
+ `Could not read ${PROJECT_CONFIG_FILENAME} at ${configPath}: ${err.message}`
719
+ );
720
+ }
721
+ let parsed;
722
+ try {
723
+ parsed = JSON.parse(raw);
724
+ } catch (err) {
725
+ throw new Error(
726
+ [
727
+ `Invalid ${PROJECT_CONFIG_FILENAME} at ${configPath}: ${err.message}`,
728
+ "Fix the JSON syntax or pass --org, --project, and --profile on the command line.",
729
+ ].join("\n")
730
+ );
731
+ }
732
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
733
+ throw new Error(
734
+ [
735
+ `Invalid ${PROJECT_CONFIG_FILENAME} at ${configPath}: expected a JSON object.`,
736
+ "Fix the file or pass --org, --project, and --profile on the command line.",
737
+ ].join("\n")
738
+ );
739
+ }
740
+ return { configPath, config: parsed };
741
+ }
742
+
743
+ function configStringValue(projectCfg, key, configPath) {
744
+ const value = projectCfg[key];
745
+ if (value === undefined || value === null || value === "") return "";
746
+ if (typeof value === "string" || typeof value === "number") {
747
+ return String(value).trim();
748
+ }
749
+ const where = configPath
750
+ ? `${PROJECT_CONFIG_FILENAME} at ${configPath}`
751
+ : PROJECT_CONFIG_FILENAME;
752
+ throw new Error(
753
+ `Invalid ${key} in ${where}: expected a string or number, got ${typeof value}.`
754
+ );
755
+ }
756
+
757
+ function missingExportCiField(fieldLabel, { flag, configKey, configPath, hasFlag }) {
758
+ const configHint = configPath
759
+ ? `${PROJECT_CONFIG_FILENAME} at ${configPath} (${configKey})`
760
+ : `${PROJECT_CONFIG_FILENAME} (not found walking up from ${process.cwd()})`;
761
+ if (hasFlag) {
762
+ return [
763
+ `Missing ${fieldLabel}: ${flag} was empty.`,
764
+ `Set ${configKey} in ${configHint} or pass a non-empty ${flag}.`,
765
+ ].join(" ");
766
+ }
767
+ return [
768
+ `Missing ${fieldLabel}.`,
769
+ `Pass ${flag} or set ${configKey} in ${configHint}.`,
770
+ ].join(" ");
771
+ }
772
+
773
+ function resolveExportCiConfig(args, { requireProfile = true } = {}) {
774
+ const { configPath, config: projectCfg } = readProjectConfigStrict();
775
+
776
+ const orgFromFlag = String(args.org || "").trim();
777
+ const projectFromFlag = String(args.project || "").trim();
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);
789
+
790
+ let orgId = orgFromFlag;
791
+ if (!orgId) {
792
+ orgId = configStringValue(projectCfg, "default_org_id", configPath);
793
+ }
794
+
795
+ let projectId = projectFromFlag;
796
+ if (!projectId) {
797
+ projectId = configStringValue(projectCfg, "default_project_id", configPath);
798
+ }
799
+
800
+ let profile = profileFromFlag;
801
+ if (!profile) {
802
+ profile = configStringValue(projectCfg, "default_auth_profile", configPath);
803
+ }
804
+
805
+ if (!orgId) {
806
+ throw new Error(
807
+ missingExportCiField("org ID", {
808
+ flag: "--org",
809
+ configKey: "default_org_id",
810
+ configPath,
811
+ hasFlag: Boolean(orgFromFlag),
812
+ })
813
+ );
814
+ }
815
+ if (!projectId) {
816
+ throw new Error(
817
+ missingExportCiField("project ID", {
818
+ flag: "--project",
819
+ configKey: "default_project_id",
820
+ configPath,
821
+ hasFlag: Boolean(projectFromFlag),
822
+ })
823
+ );
824
+ }
825
+
826
+ const hasMultiProfileMode =
827
+ allProfiles || profilesList.length > 0 || Boolean(suiteId) || explicitIds.length > 0;
828
+ if (requireProfile && !profile && !hasMultiProfileMode) {
829
+ throw new Error(
830
+ missingExportCiField("auth profile", {
831
+ flag: "--profile",
832
+ configKey: "default_auth_profile",
833
+ configPath,
834
+ hasFlag: Boolean(profileFromFlag),
835
+ })
836
+ );
837
+ }
838
+
839
+ let apiUrl;
840
+ try {
841
+ apiUrl = resolveApiUrl(args);
842
+ } catch (err) {
843
+ throw new Error(`Invalid API URL: ${err.message}`);
844
+ }
845
+
661
846
  return {
662
- encoding: "base64",
663
- payload: raw.toString("base64"),
664
- rawBytes: raw.length,
847
+ orgId,
848
+ projectId,
849
+ profile,
850
+ apiUrl,
851
+ configPath,
852
+ allProfiles,
853
+ profilesList,
854
+ suiteId,
855
+ explicitIds,
665
856
  };
666
857
  }
667
858
 
668
- function githubAuthRestoreShell({ orgId, projectId, profile }) {
669
- const orgSeg = safePathSegment(orgId, "default-org");
670
- const projectSeg = safePathSegment(projectId, "default-project");
671
- const profileSeg = safePathSegment(profile, "default");
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 : [];
875
+ }
876
+
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 resolveExportProfileEntries({
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
+ );
960
+ }
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;
988
+ }
989
+
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
+ }
672
996
  return [
673
- `- name: Restore Qualty login state`,
997
+ `- name: Restore Qualty login states`,
674
998
  ` env:`,
675
- ` QUALTY_AUTH_STATE_B64: \${{ secrets.QUALTY_AUTH_STATE_B64 }}`,
676
- ` run: |`,
677
- ` AUTH_DIR="$HOME/.qualty/local-contexts/${orgSeg}/${projectSeg}"`,
678
- ` mkdir -p "$AUTH_DIR"`,
679
- ` echo "$QUALTY_AUTH_STATE_B64" | base64 -d | gunzip > "$AUTH_DIR/${profileSeg}.json"`,
680
- ` test -s "$AUTH_DIR/${profileSeg}.json"`,
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`,
681
1003
  ``,
682
- `- name: Run Qualty (local)`,
1004
+ `- name: Run Qualty tests (local)`,
683
1005
  ` env:`,
684
1006
  ` QUALTY_API_TOKEN: \${{ secrets.QUALTY_API_TOKEN }}`,
685
- ` QUALTY_ORG_ID: \${{ vars.QUALTY_ORG_ID }}`,
1007
+ ` QUALTY_BASE_URL: \${{ secrets.QUALTY_BASE_URL }}`,
686
1008
  ` run: |`,
687
- ` npx qualty@latest run --local \\`,
688
- ` --project ${projectId} \\`,
689
- ` --suite-id YOUR_SUITE_UUID \\`,
690
- ` --auth-profile "${profile}" \\`,
1009
+ ` npx --yes qualty run --local \\`,
1010
+ ` --org "$QUALTY_ORG_ID" \\`,
1011
+ ` --project "$QUALTY_PROJECT_ID" \\`,
1012
+ ` --suite-id "YOUR-SUITE-UUID" \\`,
691
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.`,
692
1016
  ].join("\n");
693
1017
  }
694
1018
 
695
- async function runAuthExportCi(args) {
696
- const orgId = resolveOrgId(args);
697
- const token = resolveToken(args);
698
- const projectCfg = readProjectConfig();
699
- const projectId = projectIdFromConfig(projectCfg, args.project);
700
- const profile = authProfileFromConfig(
701
- projectCfg,
702
- args.profile || args["auth-profile"]
703
- );
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();
704
1025
 
705
1026
  if (!orgId) {
706
- throw new Error("Missing org. Pass --org, set QUALTY_ORG_ID, or add default_org_id to .qualty.json.");
1027
+ throw new Error("Missing org ID. Set QUALTY_ORG_ID or pass --org.");
707
1028
  }
708
1029
  if (!projectId) {
709
- throw new Error("Missing project. Pass --project (or run qualty auth setup first).");
710
- }
711
- if (!profile) {
712
- throw new Error('Missing profile. Pass --profile "your-profile" (or run qualty auth setup first).');
1030
+ throw new Error("Missing project ID. Set QUALTY_PROJECT_ID or pass --project.");
713
1031
  }
714
1032
 
715
- const statePath = localAuthStatePath({ orgId, projectId, profile });
716
- if (!existsSync(statePath)) {
1033
+ const payloads = collectAuthShardPayloadsFromEnv(process.env);
1034
+ if (payloads.length === 0) {
717
1035
  throw new Error(
718
- [
719
- `Local auth state not found:`,
720
- ` ${statePath}`,
721
- "",
722
- "Create it first:",
723
- ` qualty auth setup --project ${projectId} --profile "${profile}"`,
724
- ].join("\n")
1036
+ `Missing ${AUTH_SECRET_PRIMARY}. Run qualty auth export-ci --set-github from your app repo.`
725
1037
  );
726
1038
  }
727
1039
 
728
- const setGithubFlag =
729
- parseBoolean(args["set-github"], false) || parseBoolean(args["set-secret"], false);
730
- // CI path: one format only — gzip then base64 in QUALTY_AUTH_STATE_B64 (no separate gzip flag).
731
- let gzip = parseBoolean(args.gzip, false) || setGithubFlag;
732
- let encoded = encodeAuthStateForGithubSecret(statePath, { gzip });
733
- let secretBytes = Buffer.byteLength(encoded.payload, "utf8");
734
- if (!gzip && secretBytes > GITHUB_SECRET_MAX_BYTES - 1024) {
735
- gzip = true;
736
- encoded = encodeAuthStateForGithubSecret(statePath, { gzip: true });
737
- secretBytes = Buffer.byteLength(encoded.payload, "utf8");
738
- }
739
- if (secretBytes > GITHUB_SECRET_MAX_BYTES - 1024) {
740
- throw new Error(
741
- `Auth state is too large for a GitHub secret (${secretBytes} bytes; limit ~64 KB). ` +
742
- "Try a shorter-lived session, fewer cookies, or contact Qualty support."
743
- );
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}")`);
744
1050
  }
1051
+ // eslint-disable-next-line no-console
1052
+ console.log(`Restored ${restored.length} login profile(s).`);
1053
+ }
745
1054
 
1055
+ async function runAuthExportCi(args) {
1056
+ const {
1057
+ orgId,
1058
+ projectId,
1059
+ profile,
1060
+ apiUrl,
1061
+ configPath,
1062
+ allProfiles,
1063
+ profilesList,
1064
+ suiteId: exportSuiteId,
1065
+ explicitIds,
1066
+ } = resolveExportCiConfig(args);
1067
+ const token = resolveToken(args);
1068
+
1069
+ const profileEntries = await resolveExportProfileEntries({
1070
+ orgId,
1071
+ projectId,
1072
+ profile,
1073
+ allProfiles,
1074
+ profilesList,
1075
+ suiteId: exportSuiteId,
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];
1086
+
1087
+ const setGithubFlag =
1088
+ parseBoolean(args["set-github"], false) || parseBoolean(args["set-secret"], false);
746
1089
  const noFile = parseBoolean(args["no-file"], false);
747
1090
  const outputPath = noFile
748
1091
  ? String(args.output || "").trim()
749
- : String(args.output || "").trim() || authExportCiOutputPath(statePath);
1092
+ : String(args.output || "").trim() ||
1093
+ authExportCiOutputPath(
1094
+ localAuthStatePath({ orgId, projectId, profile: profileEntries[0].localProfileName })
1095
+ );
750
1096
  const copyToClipboardFlag = parseBoolean(args.copy, false);
751
1097
  const githubRepo = String(args.repo || "").trim();
752
- const suiteId = String(args["suite-id"] || args.suite || "").trim();
1098
+ const suiteId = String(args["suite-id"] || args.suite || exportSuiteId || "").trim();
753
1099
  const cliRepo = String(args["cli-repo"] || "").trim();
754
1100
  const cliRef = String(args["cli-ref"] || "").trim();
755
- const apiUrl = resolveApiUrl(args);
756
1101
 
757
1102
  if (outputPath) {
758
- writeFileSync(outputPath, encoded.payload, "utf8");
1103
+ writeFileSync(outputPath, primaryShard.payload, "utf8");
759
1104
  try {
760
1105
  chmodSync(outputPath, 0o600);
761
1106
  } catch {
762
1107
  // ignore platforms that do not support chmod
763
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
+ }
764
1119
  }
765
1120
 
766
1121
  const includeToken = parseBoolean(args["include-token"], false);
767
- const profileSeg = safePathSegment(profile, "default");
768
1122
  const skipPayloadPrint = Boolean(outputPath || copyToClipboardFlag || setGithubFlag);
1123
+ const totalSecretBytes = authShards.reduce(
1124
+ (sum, shard) => sum + Buffer.byteLength(shard.payload, "utf8"),
1125
+ 0
1126
+ );
769
1127
 
770
1128
  // eslint-disable-next-line no-console
771
1129
  console.log("");
@@ -773,20 +1131,29 @@ async function runAuthExportCi(args) {
773
1131
  console.log("=== Qualty → GitHub Actions (secrets & variables) ===");
774
1132
  // eslint-disable-next-line no-console
775
1133
  console.log("");
776
- if (encoded.encoding === "gzip+base64") {
777
- // eslint-disable-next-line no-console
778
- console.log(`Auth state: gzip+base64 (${encoded.rawBytes} bytes raw ${secretBytes} bytes secret)`);
779
- } 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) {
780
1139
  // eslint-disable-next-line no-console
781
- 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
+ );
782
1143
  }
783
1144
  // eslint-disable-next-line no-console
784
1145
  console.log("");
785
1146
  if (outputPath) {
786
1147
  // eslint-disable-next-line no-console
787
- 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
+ }
788
1155
  // eslint-disable-next-line no-console
789
- console.log(` gh secret set QUALTY_AUTH_STATE_B64 < ${outputPath}`);
1156
+ console.log(` gh secret set ${AUTH_SECRET_PRIMARY} < ${outputPath}`);
790
1157
  // eslint-disable-next-line no-console
791
1158
  console.log("");
792
1159
  }
@@ -795,8 +1162,9 @@ async function runAuthExportCi(args) {
795
1162
  repo: githubRepo,
796
1163
  orgId,
797
1164
  projectId,
798
- profile,
799
- authPayload: encoded.payload,
1165
+ profile: primaryProfile,
1166
+ profileNames,
1167
+ authShards,
800
1168
  apiUrl,
801
1169
  token,
802
1170
  suiteId,
@@ -804,25 +1172,27 @@ async function runAuthExportCi(args) {
804
1172
  cliRef,
805
1173
  });
806
1174
  printGithubSyncResults(syncResults, { repo: githubRepo });
807
- const authRow = syncResults.find((r) => r.name === "QUALTY_AUTH_STATE_B64");
808
- if (!authRow?.ok && outputPath) {
1175
+ const failedAuth = syncResults.filter((row) => row.kind === "secret" && !row.ok);
1176
+ if (failedAuth.length > 0 && outputPath) {
809
1177
  // eslint-disable-next-line no-console
810
- 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
+ });
811
1185
  // eslint-disable-next-line no-console
812
1186
  console.log("");
813
1187
  }
814
1188
  }
815
1189
  if (copyToClipboardFlag) {
816
- if (copyToClipboard(encoded.payload)) {
1190
+ if (copyToClipboard(primaryShard.payload)) {
817
1191
  // eslint-disable-next-line no-console
818
- console.log("✓ Copied QUALTY_AUTH_STATE_B64 to clipboard. Paste into GitHub → Settings → Secrets.");
1192
+ console.log(`✓ Copied ${AUTH_SECRET_PRIMARY} to clipboard.`);
819
1193
  } else {
820
1194
  // eslint-disable-next-line no-console
821
1195
  console.log("Could not copy to clipboard on this machine.");
822
- if (outputPath) {
823
- // eslint-disable-next-line no-console
824
- console.log(` Use the file instead: ${outputPath}`);
825
- }
826
1196
  }
827
1197
  // eslint-disable-next-line no-console
828
1198
  console.log("");
@@ -832,10 +1202,16 @@ async function runAuthExportCi(args) {
832
1202
  console.log(
833
1203
  "1. GitHub Actions config was pushed via gh (secrets + repository variables — see sync above). Fix any ✗ manually."
834
1204
  );
835
- if (!suiteId) {
1205
+ if (!suiteId && !exportSuiteId && explicitIds.length === 0) {
836
1206
  // eslint-disable-next-line no-console
837
1207
  console.log(
838
- " Tip: re-run with --suite-id <uuid> to set QUALTY_SUITE_ID (or set it in the GitHub UI)."
1208
+ " Tip: pass --suite-id or --ids to export only login profiles required by those tests."
1209
+ );
1210
+ }
1211
+ if (authShards.length > 1) {
1212
+ // eslint-disable-next-line no-console
1213
+ console.log(
1214
+ ` Auth was split across ${authShards.length} secrets. Add optional shard env vars to your workflow restore step.`
839
1215
  );
840
1216
  }
841
1217
  // eslint-disable-next-line no-console
@@ -870,13 +1246,35 @@ async function runAuthExportCi(args) {
870
1246
  // eslint-disable-next-line no-console
871
1247
  console.log("---");
872
1248
  // eslint-disable-next-line no-console
873
- console.log("Name: QUALTY_AUTH_PROFILE");
1249
+ console.log(`Name: ${AUTH_PROFILES_VARIABLE}`);
874
1250
  // eslint-disable-next-line no-console
875
1251
  console.log("Value:");
876
1252
  // eslint-disable-next-line no-console
877
- console.log(profile);
1253
+ console.log(profileNames.join(","));
878
1254
  // eslint-disable-next-line no-console
879
- 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
+ }
880
1278
  // eslint-disable-next-line no-console
881
1279
  console.log("---");
882
1280
  // eslint-disable-next-line no-console
@@ -917,20 +1315,20 @@ async function runAuthExportCi(args) {
917
1315
  // eslint-disable-next-line no-console
918
1316
  console.log("(create in Qualty → Account → API tokens; must start with qlty_ci_)");
919
1317
  }
920
- // eslint-disable-next-line no-console
921
- console.log("---");
922
- // eslint-disable-next-line no-console
923
- console.log("Name: QUALTY_AUTH_STATE_B64");
924
- if (skipPayloadPrint) {
1318
+ for (const shard of authShards) {
925
1319
  // eslint-disable-next-line no-console
926
- console.log(
927
- `Value: (see file above${copyToClipboardFlag ? " or clipboard" : ""})`
928
- );
929
- } else {
930
- // eslint-disable-next-line no-console
931
- console.log("Value:");
1320
+ console.log("---");
932
1321
  // eslint-disable-next-line no-console
933
- 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
+ }
934
1332
  }
935
1333
  // eslint-disable-next-line no-console
936
1334
  console.log("---");
@@ -944,17 +1342,24 @@ async function runAuthExportCi(args) {
944
1342
  // eslint-disable-next-line no-console
945
1343
  console.log("");
946
1344
  // eslint-disable-next-line no-console
947
- console.log(githubAuthRestoreShell({ orgId, projectId, profile }));
1345
+ console.log(githubAuthRestoreShell({ shardCount: authShards.length }));
948
1346
  // eslint-disable-next-line no-console
949
1347
  console.log("");
950
- // eslint-disable-next-line no-console
951
- console.log(`Local file: ${statePath}`);
952
- // eslint-disable-next-line no-console
953
- 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
+ }
954
1359
  // eslint-disable-next-line no-console
955
1360
  console.log("");
956
1361
  // eslint-disable-next-line no-console
957
- 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 auth export-ci to refresh secrets.");
958
1363
  // eslint-disable-next-line no-console
959
1364
  console.log("");
960
1365
  }
@@ -2100,7 +2505,11 @@ async function main() {
2100
2505
  await runAuthExportCi(args);
2101
2506
  return;
2102
2507
  }
2103
- throw new Error("Unknown auth subcommand. Use: qualty auth setup | qualty auth export-ci");
2508
+ if (sub === "restore-ci") {
2509
+ await runAuthRestoreCi(args);
2510
+ return;
2511
+ }
2512
+ throw new Error("Unknown auth subcommand. Use: qualty auth setup | qualty auth export-ci | qualty auth restore-ci");
2104
2513
  }
2105
2514
  if (command === "run") {
2106
2515
  if (parseBoolean(args.local, false)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualty",
3
- "version": "0.1.21",
3
+ "version": "0.1.25",
4
4
  "description": "Qualty CLI for localhost and CI test runs",
5
5
  "bin": {
6
6
  "qualty": "bin/qualty.js"