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