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.
- package/bin/auth-ci-bundle.js +348 -0
- package/bin/qualty.js +552 -143
- package/package.json +1 -1
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
|
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
|
-
" [--
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
|
581
|
-
|
|
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
|
-
|
|
594
|
-
|
|
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
|
-
|
|
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
|
|
653
|
-
const
|
|
654
|
-
if (
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
|
997
|
+
`- name: Restore Qualty login states`,
|
|
674
998
|
` env:`,
|
|
675
|
-
|
|
676
|
-
`
|
|
677
|
-
`
|
|
678
|
-
`
|
|
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
|
-
`
|
|
1007
|
+
` QUALTY_BASE_URL: \${{ secrets.QUALTY_BASE_URL }}`,
|
|
686
1008
|
` run: |`,
|
|
687
|
-
` npx qualty
|
|
688
|
-
` --
|
|
689
|
-
` --
|
|
690
|
-
` --
|
|
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
|
|
696
|
-
const orgId =
|
|
697
|
-
const
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
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.
|
|
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.
|
|
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
|
|
716
|
-
if (
|
|
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
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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() ||
|
|
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,
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
808
|
-
if (
|
|
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(
|
|
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(
|
|
1190
|
+
if (copyToClipboard(primaryShard.payload)) {
|
|
817
1191
|
// eslint-disable-next-line no-console
|
|
818
|
-
console.log(
|
|
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:
|
|
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(
|
|
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(
|
|
1253
|
+
console.log(profileNames.join(","));
|
|
878
1254
|
// eslint-disable-next-line no-console
|
|
879
|
-
console.log(` gh variable set
|
|
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
|
-
|
|
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(
|
|
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({
|
|
1345
|
+
console.log(githubAuthRestoreShell({ shardCount: authShards.length }));
|
|
948
1346
|
// eslint-disable-next-line no-console
|
|
949
1347
|
console.log("");
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
|
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
|
-
|
|
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)) {
|