qualty 0.1.22 → 0.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/auth-ci-bundle.js +348 -0
- package/bin/qualty.js +393 -142
- 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,13 +9,22 @@ import {
|
|
|
9
9
|
readdirSync,
|
|
10
10
|
writeFileSync,
|
|
11
11
|
} from "node:fs";
|
|
12
|
-
import { gzipSync } from "node:zlib";
|
|
13
12
|
import { spawn, spawnSync } from "node:child_process";
|
|
14
13
|
import { homedir, hostname } from "node:os";
|
|
15
14
|
import { dirname, join } from "node:path";
|
|
16
15
|
import { createInterface } from "node:readline/promises";
|
|
17
16
|
import process from "node:process";
|
|
18
17
|
import { rewriteUrlForLocalRun, runLocalCi, summarizeStorageStateFile, logAuthStateDiagnostics } from "./local-runner.js";
|
|
18
|
+
import {
|
|
19
|
+
AUTH_PROFILES_VARIABLE,
|
|
20
|
+
AUTH_SECRET_PRIMARY,
|
|
21
|
+
AUTH_SHARDS_VARIABLE,
|
|
22
|
+
authShardSecretName,
|
|
23
|
+
buildAuthBundleManifest,
|
|
24
|
+
collectAuthShardPayloadsFromEnv,
|
|
25
|
+
restoreAuthPayloadsToDisk,
|
|
26
|
+
shardAuthBundleManifest,
|
|
27
|
+
} from "./auth-ci-bundle.js";
|
|
19
28
|
|
|
20
29
|
/** CLI backend URL is fixed by Qualty distribution. */
|
|
21
30
|
const DEFAULT_QUALTY_API_URL = "https://qualty-api-production.up.railway.app";
|
|
@@ -178,11 +187,15 @@ function usage() {
|
|
|
178
187
|
"Usage:",
|
|
179
188
|
" qualty setup [--token <bearer-token>] [--org <org-id>]",
|
|
180
189
|
" qualty auth setup [--project <project-id>] [--profile <profile-name>] [--headed]",
|
|
181
|
-
" qualty
|
|
182
|
-
"
|
|
183
|
-
"
|
|
184
|
-
"
|
|
185
|
-
"
|
|
190
|
+
" qualty ci setup [--project <project-id>] [--profile <profile-name>] [--org <org-id>]",
|
|
191
|
+
" [--all-profiles] [--profiles <name1,name2>]",
|
|
192
|
+
" [--suite-id <suite-uuid>] [--ids <id1,id2>] bundle login profiles required by tests",
|
|
193
|
+
" [--url <api-base-url>] [--repo <owner/repo>]",
|
|
194
|
+
" [--output <path>] [--no-file] [--copy] [--gzip] [--include-token]",
|
|
195
|
+
" [--set-secret | --set-github] gh: secrets (token, API URL, auth bundle) + variables",
|
|
196
|
+
" [--cli-repo <owner/repo>] [--cli-ref <branch>] Optional CI variables",
|
|
197
|
+
" qualty auth restore-ci [--org <org-id>] [--project <project-id>] [--profile <fallback-profile>]",
|
|
198
|
+
" Reads QUALTY_AUTH_STATE_B64 (+ _2, _3, … shards) and writes ~/.qualty/local-contexts/…",
|
|
186
199
|
" qualty connect --project <project-id> [--port 3000] [--token <bearer-token>] [--org <org-id>]",
|
|
187
200
|
" qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--token <bearer-token>] [--org <org-id>]",
|
|
188
201
|
" [--poll-interval 5] [--timeout 30] [--fail-on-failure true] [--no-view-logs]",
|
|
@@ -529,12 +542,10 @@ function localAuthStatePath({ orgId, projectId, profile }) {
|
|
|
529
542
|
return join(QUALTY_SHARED_CREDS_DIR, "local-contexts", orgSeg, projectSeg, `${profileSeg}.json`);
|
|
530
543
|
}
|
|
531
544
|
|
|
532
|
-
function
|
|
545
|
+
function ciSetupAuthBundleOutputPath(statePath) {
|
|
533
546
|
return statePath.replace(/\.json$/i, ".b64");
|
|
534
547
|
}
|
|
535
548
|
|
|
536
|
-
const GITHUB_SECRET_MAX_BYTES = 64 * 1024;
|
|
537
|
-
|
|
538
549
|
function copyToClipboard(text) {
|
|
539
550
|
if (process.platform === "darwin") {
|
|
540
551
|
const result = spawnSync("pbcopy", { input: text, encoding: "utf8" });
|
|
@@ -600,7 +611,8 @@ function syncGithubCiConfig({
|
|
|
600
611
|
orgId,
|
|
601
612
|
projectId,
|
|
602
613
|
profile,
|
|
603
|
-
|
|
614
|
+
profileNames,
|
|
615
|
+
authShards,
|
|
604
616
|
apiUrl,
|
|
605
617
|
token,
|
|
606
618
|
suiteId,
|
|
@@ -612,8 +624,9 @@ function syncGithubCiConfig({
|
|
|
612
624
|
results.push({ kind, name, ...outcome });
|
|
613
625
|
};
|
|
614
626
|
|
|
615
|
-
const
|
|
616
|
-
|
|
627
|
+
for (const shard of authShards || []) {
|
|
628
|
+
record("secret", shard.secretName, setGithubSecret(shard.secretName, shard.payload, { repo }));
|
|
629
|
+
}
|
|
617
630
|
|
|
618
631
|
if (token) {
|
|
619
632
|
record("secret", "QUALTY_API_TOKEN", setGithubSecret("QUALTY_API_TOKEN", token, { repo }));
|
|
@@ -631,7 +644,21 @@ function syncGithubCiConfig({
|
|
|
631
644
|
|
|
632
645
|
record("variable", "QUALTY_ORG_ID", setGithubVariable("QUALTY_ORG_ID", orgId, { repo }));
|
|
633
646
|
record("variable", "QUALTY_PROJECT_ID", setGithubVariable("QUALTY_PROJECT_ID", String(projectId), { repo }));
|
|
634
|
-
|
|
647
|
+
|
|
648
|
+
const names = (profileNames || []).filter(Boolean);
|
|
649
|
+
if (names.length > 0) {
|
|
650
|
+
record("variable", AUTH_PROFILES_VARIABLE, setGithubVariable(AUTH_PROFILES_VARIABLE, names.join(","), { repo }));
|
|
651
|
+
}
|
|
652
|
+
if (profile) {
|
|
653
|
+
record("variable", "QUALTY_AUTH_PROFILE", setGithubVariable("QUALTY_AUTH_PROFILE", profile, { repo }));
|
|
654
|
+
}
|
|
655
|
+
if ((authShards || []).length > 1) {
|
|
656
|
+
record(
|
|
657
|
+
"variable",
|
|
658
|
+
AUTH_SHARDS_VARIABLE,
|
|
659
|
+
setGithubVariable(AUTH_SHARDS_VARIABLE, String(authShards.length), { repo })
|
|
660
|
+
);
|
|
661
|
+
}
|
|
635
662
|
|
|
636
663
|
if (suiteId) {
|
|
637
664
|
record("variable", "QUALTY_SUITE_ID", setGithubVariable("QUALTY_SUITE_ID", String(suiteId), { repo }));
|
|
@@ -727,7 +754,7 @@ function configStringValue(projectCfg, key, configPath) {
|
|
|
727
754
|
);
|
|
728
755
|
}
|
|
729
756
|
|
|
730
|
-
function
|
|
757
|
+
function missingCiSetupField(fieldLabel, { flag, configKey, configPath, hasFlag }) {
|
|
731
758
|
const configHint = configPath
|
|
732
759
|
? `${PROJECT_CONFIG_FILENAME} at ${configPath} (${configKey})`
|
|
733
760
|
: `${PROJECT_CONFIG_FILENAME} (not found walking up from ${process.cwd()})`;
|
|
@@ -743,12 +770,22 @@ function missingExportCiField(fieldLabel, { flag, configKey, configPath, hasFlag
|
|
|
743
770
|
].join(" ");
|
|
744
771
|
}
|
|
745
772
|
|
|
746
|
-
function
|
|
773
|
+
function resolveCiSetupConfig(args, { requireProfile = true } = {}) {
|
|
747
774
|
const { configPath, config: projectCfg } = readProjectConfigStrict();
|
|
748
775
|
|
|
749
776
|
const orgFromFlag = String(args.org || "").trim();
|
|
750
777
|
const projectFromFlag = String(args.project || "").trim();
|
|
751
778
|
const profileFromFlag = String(args.profile || args["auth-profile"] || "").trim();
|
|
779
|
+
const allProfiles = parseBoolean(args["all-profiles"], false);
|
|
780
|
+
const profilesList = String(args.profiles || "")
|
|
781
|
+
.split(",")
|
|
782
|
+
.map((part) => part.trim())
|
|
783
|
+
.filter(Boolean);
|
|
784
|
+
const suiteId = String(args["suite-id"] || args.suite || "").trim();
|
|
785
|
+
const explicitIds = String(args.ids || "")
|
|
786
|
+
.split(",")
|
|
787
|
+
.map((part) => part.trim())
|
|
788
|
+
.filter(Boolean);
|
|
752
789
|
|
|
753
790
|
let orgId = orgFromFlag;
|
|
754
791
|
if (!orgId) {
|
|
@@ -767,7 +804,7 @@ function resolveExportCiConfig(args) {
|
|
|
767
804
|
|
|
768
805
|
if (!orgId) {
|
|
769
806
|
throw new Error(
|
|
770
|
-
|
|
807
|
+
missingCiSetupField("org ID", {
|
|
771
808
|
flag: "--org",
|
|
772
809
|
configKey: "default_org_id",
|
|
773
810
|
configPath,
|
|
@@ -777,7 +814,7 @@ function resolveExportCiConfig(args) {
|
|
|
777
814
|
}
|
|
778
815
|
if (!projectId) {
|
|
779
816
|
throw new Error(
|
|
780
|
-
|
|
817
|
+
missingCiSetupField("project ID", {
|
|
781
818
|
flag: "--project",
|
|
782
819
|
configKey: "default_project_id",
|
|
783
820
|
configPath,
|
|
@@ -785,9 +822,12 @@ function resolveExportCiConfig(args) {
|
|
|
785
822
|
})
|
|
786
823
|
);
|
|
787
824
|
}
|
|
788
|
-
|
|
825
|
+
|
|
826
|
+
const hasMultiProfileMode =
|
|
827
|
+
allProfiles || profilesList.length > 0 || Boolean(suiteId) || explicitIds.length > 0;
|
|
828
|
+
if (requireProfile && !profile && !hasMultiProfileMode) {
|
|
789
829
|
throw new Error(
|
|
790
|
-
|
|
830
|
+
missingCiSetupField("auth profile", {
|
|
791
831
|
flag: "--profile",
|
|
792
832
|
configKey: "default_auth_profile",
|
|
793
833
|
configPath,
|
|
@@ -803,43 +843,163 @@ function resolveExportCiConfig(args) {
|
|
|
803
843
|
throw new Error(`Invalid API URL: ${err.message}`);
|
|
804
844
|
}
|
|
805
845
|
|
|
806
|
-
return {
|
|
846
|
+
return {
|
|
847
|
+
orgId,
|
|
848
|
+
projectId,
|
|
849
|
+
profile,
|
|
850
|
+
apiUrl,
|
|
851
|
+
configPath,
|
|
852
|
+
allProfiles,
|
|
853
|
+
profilesList,
|
|
854
|
+
suiteId,
|
|
855
|
+
explicitIds,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function jobUsesSavedLogin(job) {
|
|
860
|
+
if (!job) return false;
|
|
861
|
+
if (job.use_saved_login) return true;
|
|
862
|
+
if (job.saved_login_profile_id) return true;
|
|
863
|
+
const profile = job.saved_login_profile;
|
|
864
|
+
return Boolean(profile && typeof profile === "object" && (profile.id || profile.name));
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
async function fetchSavedLoginProfilesForProject({ apiUrl, token, orgId, projectId }) {
|
|
868
|
+
const payload = await apiRequest({
|
|
869
|
+
apiUrl,
|
|
870
|
+
token,
|
|
871
|
+
orgId,
|
|
872
|
+
path: `/api/v1/projects/${encodeURIComponent(String(projectId))}/saved-login-profiles`,
|
|
873
|
+
}).catch(() => []);
|
|
874
|
+
return Array.isArray(payload) ? payload : [];
|
|
807
875
|
}
|
|
808
876
|
|
|
809
|
-
function
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
877
|
+
function localProfileLabelFromApiRow(row, fallbackName = "") {
|
|
878
|
+
return String(row?.local_profile_name || row?.name || fallbackName || "").trim();
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async function resolveRequiredProfileNamesFromTests({
|
|
882
|
+
apiUrl,
|
|
883
|
+
token,
|
|
884
|
+
orgId,
|
|
885
|
+
projectId,
|
|
886
|
+
suiteId,
|
|
887
|
+
explicitIds,
|
|
888
|
+
}) {
|
|
889
|
+
const jobsPayload = await resolveSavedJobs({
|
|
890
|
+
apiUrl,
|
|
891
|
+
token,
|
|
892
|
+
orgId,
|
|
893
|
+
projectId,
|
|
894
|
+
suiteId,
|
|
895
|
+
explicitIds,
|
|
896
|
+
});
|
|
897
|
+
const jobs = Array.isArray(jobsPayload)
|
|
898
|
+
? jobsPayload
|
|
899
|
+
: Array.isArray(jobsPayload?.results)
|
|
900
|
+
? jobsPayload.results
|
|
901
|
+
: Array.isArray(jobsPayload?.jobs)
|
|
902
|
+
? jobsPayload.jobs
|
|
903
|
+
: [];
|
|
904
|
+
const names = new Set();
|
|
905
|
+
for (const job of jobs) {
|
|
906
|
+
if (!jobUsesSavedLogin(job)) continue;
|
|
907
|
+
const name = String(job?.saved_login_profile?.name || "").trim();
|
|
908
|
+
if (name) names.add(name);
|
|
909
|
+
}
|
|
910
|
+
return [...names];
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
async function resolveCiSetupProfileEntries({
|
|
914
|
+
orgId,
|
|
915
|
+
projectId,
|
|
916
|
+
profile,
|
|
917
|
+
allProfiles,
|
|
918
|
+
profilesList,
|
|
919
|
+
suiteId,
|
|
920
|
+
explicitIds,
|
|
921
|
+
apiUrl,
|
|
922
|
+
token,
|
|
923
|
+
}) {
|
|
924
|
+
const apiProfiles = token
|
|
925
|
+
? await fetchSavedLoginProfilesForProject({ apiUrl, token, orgId, projectId })
|
|
926
|
+
: [];
|
|
927
|
+
|
|
928
|
+
let requestedNames = [];
|
|
929
|
+
if (allProfiles) {
|
|
930
|
+
requestedNames = apiProfiles.map((row) => String(row?.name || "").trim()).filter(Boolean);
|
|
931
|
+
if (requestedNames.length === 0) {
|
|
932
|
+
requestedNames = listLocalAuthProfiles({ orgId, projectId });
|
|
933
|
+
}
|
|
934
|
+
} else if (profilesList.length > 0) {
|
|
935
|
+
requestedNames = profilesList;
|
|
936
|
+
} else if (suiteId || explicitIds.length > 0) {
|
|
937
|
+
if (!token) {
|
|
938
|
+
throw new Error("QUALTY_API_TOKEN is required to resolve profiles from --suite-id or --ids.");
|
|
939
|
+
}
|
|
940
|
+
requestedNames = await resolveRequiredProfileNamesFromTests({
|
|
941
|
+
apiUrl,
|
|
942
|
+
token,
|
|
943
|
+
orgId,
|
|
944
|
+
projectId,
|
|
945
|
+
suiteId,
|
|
946
|
+
explicitIds,
|
|
947
|
+
});
|
|
948
|
+
if (requestedNames.length === 0 && profile) {
|
|
949
|
+
requestedNames = [profile];
|
|
950
|
+
}
|
|
951
|
+
} else if (profile) {
|
|
952
|
+
requestedNames = [profile];
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
requestedNames = [...new Set(requestedNames.filter(Boolean))];
|
|
956
|
+
if (requestedNames.length === 0) {
|
|
957
|
+
throw new Error(
|
|
958
|
+
"No auth profiles selected. Pass --profile, --profiles, --all-profiles, or --suite-id / --ids."
|
|
959
|
+
);
|
|
817
960
|
}
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
961
|
+
|
|
962
|
+
const entries = [];
|
|
963
|
+
const missing = [];
|
|
964
|
+
for (const dashboardName of requestedNames) {
|
|
965
|
+
const apiRow = apiProfiles.find((row) => String(row?.name || "").trim() === dashboardName);
|
|
966
|
+
const localProfileName = localProfileLabelFromApiRow(apiRow, dashboardName) || dashboardName;
|
|
967
|
+
const statePath = localAuthStatePath({ orgId, projectId, profile: localProfileName });
|
|
968
|
+
if (!existsSync(statePath)) {
|
|
969
|
+
missing.push({ dashboardName, localProfileName, statePath });
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
entries.push({
|
|
973
|
+
name: dashboardName,
|
|
974
|
+
localProfileName,
|
|
975
|
+
statePath,
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if (missing.length > 0) {
|
|
980
|
+
const lines = missing.map(
|
|
981
|
+
(row) =>
|
|
982
|
+
` - "${row.dashboardName}" → ${row.statePath}\n qualty auth setup --project ${projectId} --profile "${row.dashboardName}"`
|
|
983
|
+
);
|
|
984
|
+
throw new Error(["Local auth state missing for:", ...lines].join("\n"));
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
return entries;
|
|
823
988
|
}
|
|
824
989
|
|
|
825
|
-
function githubAuthRestoreShell({
|
|
990
|
+
function githubAuthRestoreShell({ shardCount = 1 } = {}) {
|
|
991
|
+
const shardEnvLines = [];
|
|
992
|
+
for (let index = 1; index <= Math.max(1, shardCount); index += 1) {
|
|
993
|
+
const secretName = authShardSecretName(index);
|
|
994
|
+
shardEnvLines.push(` ${secretName}: \${{ secrets.${secretName} }}`);
|
|
995
|
+
}
|
|
826
996
|
return [
|
|
827
|
-
`- name: Restore Qualty login
|
|
997
|
+
`- name: Restore Qualty login states`,
|
|
828
998
|
` env:`,
|
|
829
|
-
|
|
830
|
-
`
|
|
831
|
-
`
|
|
832
|
-
`
|
|
833
|
-
` printf '%s' "$1" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g; s/[^a-zA-Z0-9._-]+/_/g; s/^_+|_+$//g'`,
|
|
834
|
-
` }`,
|
|
835
|
-
` org_seg=$(slug_segment "$QUALTY_ORG_ID"); [ -n "$org_seg" ] || org_seg="default-org"`,
|
|
836
|
-
` project_seg=$(slug_segment "$QUALTY_PROJECT_ID"); [ -n "$project_seg" ] || project_seg="default-project"`,
|
|
837
|
-
` profile_seg=$(slug_segment "$QUALTY_AUTH_PROFILE"); [ -n "$profile_seg" ] || profile_seg="default"`,
|
|
838
|
-
` auth_dir="$HOME/.qualty/local-contexts/\${org_seg}/\${project_seg}"`,
|
|
839
|
-
` auth_file="\${auth_dir}/\${profile_seg}.json"`,
|
|
840
|
-
` mkdir -p "$auth_dir"`,
|
|
841
|
-
` echo "$QUALTY_AUTH_STATE_B64" | base64 -d | gunzip > "$auth_file"`,
|
|
842
|
-
` test -s "$auth_file"`,
|
|
999
|
+
...shardEnvLines,
|
|
1000
|
+
` QUALTY_ORG_ID: \${{ vars.QUALTY_ORG_ID }}`,
|
|
1001
|
+
` QUALTY_PROJECT_ID: \${{ vars.QUALTY_PROJECT_ID }}`,
|
|
1002
|
+
` run: npx --yes qualty auth restore-ci`,
|
|
843
1003
|
``,
|
|
844
1004
|
`- name: Run Qualty tests (local)`,
|
|
845
1005
|
` env:`,
|
|
@@ -850,84 +1010,120 @@ function githubAuthRestoreShell({ orgId, projectId, profile }) {
|
|
|
850
1010
|
` --org "$QUALTY_ORG_ID" \\`,
|
|
851
1011
|
` --project "$QUALTY_PROJECT_ID" \\`,
|
|
852
1012
|
` --suite-id "YOUR-SUITE-UUID" \\`,
|
|
853
|
-
` --auth-profile "$QUALTY_AUTH_PROFILE" \\`,
|
|
854
1013
|
` --fail-on-failure true`,
|
|
1014
|
+
` # Omit --auth-profile when tests use different saved login profiles.`,
|
|
1015
|
+
` # Add --auth-profile "Profile Name" only to force one profile for the whole run.`,
|
|
855
1016
|
].join("\n");
|
|
856
1017
|
}
|
|
857
1018
|
|
|
858
|
-
async function
|
|
859
|
-
const
|
|
860
|
-
const
|
|
1019
|
+
async function runAuthRestoreCi(args) {
|
|
1020
|
+
const orgId = String(args.org || process.env.QUALTY_ORG_ID || "").trim();
|
|
1021
|
+
const projectId = String(args.project || process.env.QUALTY_PROJECT_ID || "").trim();
|
|
1022
|
+
const fallbackProfile = String(
|
|
1023
|
+
args.profile || args["auth-profile"] || process.env.QUALTY_AUTH_PROFILE || ""
|
|
1024
|
+
).trim();
|
|
861
1025
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
` ${statePath}`,
|
|
868
|
-
"",
|
|
869
|
-
"Create it first:",
|
|
870
|
-
` qualty auth setup --project ${projectId} --profile "${profile}"`,
|
|
871
|
-
configPath ? ` (org/project/profile from ${configPath})` : "",
|
|
872
|
-
]
|
|
873
|
-
.filter(Boolean)
|
|
874
|
-
.join("\n")
|
|
875
|
-
);
|
|
1026
|
+
if (!orgId) {
|
|
1027
|
+
throw new Error("Missing org ID. Set QUALTY_ORG_ID or pass --org.");
|
|
1028
|
+
}
|
|
1029
|
+
if (!projectId) {
|
|
1030
|
+
throw new Error("Missing project ID. Set QUALTY_PROJECT_ID or pass --project.");
|
|
876
1031
|
}
|
|
877
1032
|
|
|
878
|
-
const
|
|
879
|
-
|
|
880
|
-
// CI path: one format only — gzip then base64 in QUALTY_AUTH_STATE_B64 (no separate gzip flag).
|
|
881
|
-
let gzip = parseBoolean(args.gzip, false) || setGithubFlag;
|
|
882
|
-
let encoded;
|
|
883
|
-
try {
|
|
884
|
-
encoded = encodeAuthStateForGithubSecret(statePath, { gzip });
|
|
885
|
-
} catch (err) {
|
|
1033
|
+
const payloads = collectAuthShardPayloadsFromEnv(process.env);
|
|
1034
|
+
if (payloads.length === 0) {
|
|
886
1035
|
throw new Error(
|
|
887
|
-
`
|
|
1036
|
+
`Missing ${AUTH_SECRET_PRIMARY}. Run qualty ci setup --set-github from your app repo.`
|
|
888
1037
|
);
|
|
889
1038
|
}
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
}
|
|
902
|
-
if (secretBytes > GITHUB_SECRET_MAX_BYTES - 1024) {
|
|
903
|
-
throw new Error(
|
|
904
|
-
`Auth state is too large for a GitHub secret (${secretBytes} bytes; limit ~64 KB). ` +
|
|
905
|
-
"Try a shorter-lived session, fewer cookies, or contact Qualty support."
|
|
906
|
-
);
|
|
1039
|
+
|
|
1040
|
+
const restored = restoreAuthPayloadsToDisk({
|
|
1041
|
+
payloads,
|
|
1042
|
+
orgId,
|
|
1043
|
+
projectId,
|
|
1044
|
+
fallbackProfile,
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
for (const row of restored) {
|
|
1048
|
+
// eslint-disable-next-line no-console
|
|
1049
|
+
console.log(`Restored login state: ${row.path} (profile "${row.name}")`);
|
|
907
1050
|
}
|
|
1051
|
+
// eslint-disable-next-line no-console
|
|
1052
|
+
console.log(`Restored ${restored.length} login profile(s).`);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
async function runCiSetup(args) {
|
|
1056
|
+
const {
|
|
1057
|
+
orgId,
|
|
1058
|
+
projectId,
|
|
1059
|
+
profile,
|
|
1060
|
+
apiUrl,
|
|
1061
|
+
configPath,
|
|
1062
|
+
allProfiles,
|
|
1063
|
+
profilesList,
|
|
1064
|
+
suiteId: configSuiteId,
|
|
1065
|
+
explicitIds,
|
|
1066
|
+
} = resolveCiSetupConfig(args);
|
|
1067
|
+
const token = resolveToken(args);
|
|
1068
|
+
|
|
1069
|
+
const profileEntries = await resolveCiSetupProfileEntries({
|
|
1070
|
+
orgId,
|
|
1071
|
+
projectId,
|
|
1072
|
+
profile,
|
|
1073
|
+
allProfiles,
|
|
1074
|
+
profilesList,
|
|
1075
|
+
suiteId: configSuiteId,
|
|
1076
|
+
explicitIds,
|
|
1077
|
+
apiUrl,
|
|
1078
|
+
token,
|
|
1079
|
+
});
|
|
1080
|
+
const profileNames = profileEntries.map((entry) => entry.name);
|
|
1081
|
+
const primaryProfile = profile || profileNames[0] || "";
|
|
1082
|
+
|
|
1083
|
+
const manifest = buildAuthBundleManifest({ orgId, projectId, profileEntries });
|
|
1084
|
+
const authShards = shardAuthBundleManifest(manifest);
|
|
1085
|
+
const primaryShard = authShards[0];
|
|
908
1086
|
|
|
1087
|
+
const setGithubFlag =
|
|
1088
|
+
parseBoolean(args["set-github"], false) || parseBoolean(args["set-secret"], false);
|
|
909
1089
|
const noFile = parseBoolean(args["no-file"], false);
|
|
910
1090
|
const outputPath = noFile
|
|
911
1091
|
? String(args.output || "").trim()
|
|
912
|
-
: String(args.output || "").trim() ||
|
|
1092
|
+
: String(args.output || "").trim() ||
|
|
1093
|
+
ciSetupAuthBundleOutputPath(
|
|
1094
|
+
localAuthStatePath({ orgId, projectId, profile: profileEntries[0].localProfileName })
|
|
1095
|
+
);
|
|
913
1096
|
const copyToClipboardFlag = parseBoolean(args.copy, false);
|
|
914
1097
|
const githubRepo = String(args.repo || "").trim();
|
|
915
|
-
const suiteId = String(args["suite-id"] || args.suite || "").trim();
|
|
1098
|
+
const suiteId = String(args["suite-id"] || args.suite || configSuiteId || "").trim();
|
|
916
1099
|
const cliRepo = String(args["cli-repo"] || "").trim();
|
|
917
1100
|
const cliRef = String(args["cli-ref"] || "").trim();
|
|
918
1101
|
|
|
919
1102
|
if (outputPath) {
|
|
920
|
-
writeFileSync(outputPath,
|
|
1103
|
+
writeFileSync(outputPath, primaryShard.payload, "utf8");
|
|
921
1104
|
try {
|
|
922
1105
|
chmodSync(outputPath, 0o600);
|
|
923
1106
|
} catch {
|
|
924
1107
|
// ignore platforms that do not support chmod
|
|
925
1108
|
}
|
|
1109
|
+
for (let index = 1; index < authShards.length; index += 1) {
|
|
1110
|
+
const shard = authShards[index];
|
|
1111
|
+
const shardPath = outputPath.replace(/(\.[^./]+)?$/, `_${index + 1}$1`);
|
|
1112
|
+
writeFileSync(shardPath, shard.payload, "utf8");
|
|
1113
|
+
try {
|
|
1114
|
+
chmodSync(shardPath, 0o600);
|
|
1115
|
+
} catch {
|
|
1116
|
+
// ignore
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
926
1119
|
}
|
|
927
1120
|
|
|
928
1121
|
const includeToken = parseBoolean(args["include-token"], false);
|
|
929
|
-
const profileSeg = safePathSegment(profile, "default");
|
|
930
1122
|
const skipPayloadPrint = Boolean(outputPath || copyToClipboardFlag || setGithubFlag);
|
|
1123
|
+
const totalSecretBytes = authShards.reduce(
|
|
1124
|
+
(sum, shard) => sum + Buffer.byteLength(shard.payload, "utf8"),
|
|
1125
|
+
0
|
|
1126
|
+
);
|
|
931
1127
|
|
|
932
1128
|
// eslint-disable-next-line no-console
|
|
933
1129
|
console.log("");
|
|
@@ -935,20 +1131,29 @@ async function runAuthExportCi(args) {
|
|
|
935
1131
|
console.log("=== Qualty → GitHub Actions (secrets & variables) ===");
|
|
936
1132
|
// eslint-disable-next-line no-console
|
|
937
1133
|
console.log("");
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
1134
|
+
// eslint-disable-next-line no-console
|
|
1135
|
+
console.log(
|
|
1136
|
+
`Auth bundle: ${profileNames.length} profile(s), ${authShards.length} secret shard(s), ${totalSecretBytes} bytes total`
|
|
1137
|
+
);
|
|
1138
|
+
for (const shard of authShards) {
|
|
942
1139
|
// eslint-disable-next-line no-console
|
|
943
|
-
console.log(
|
|
1140
|
+
console.log(
|
|
1141
|
+
` - ${shard.secretName}: ${shard.profileNames.join(", ")} (${Buffer.byteLength(shard.payload, "utf8")} bytes)`
|
|
1142
|
+
);
|
|
944
1143
|
}
|
|
945
1144
|
// eslint-disable-next-line no-console
|
|
946
1145
|
console.log("");
|
|
947
1146
|
if (outputPath) {
|
|
948
1147
|
// eslint-disable-next-line no-console
|
|
949
|
-
console.log(`Wrote
|
|
1148
|
+
console.log(`Wrote ${AUTH_SECRET_PRIMARY} to ${outputPath}`);
|
|
1149
|
+
for (let index = 1; index < authShards.length; index += 1) {
|
|
1150
|
+
const shard = authShards[index];
|
|
1151
|
+
const shardPath = outputPath.replace(/(\.[^./]+)?$/, `_${index + 1}$1`);
|
|
1152
|
+
// eslint-disable-next-line no-console
|
|
1153
|
+
console.log(`Wrote ${shard.secretName} to ${shardPath}`);
|
|
1154
|
+
}
|
|
950
1155
|
// eslint-disable-next-line no-console
|
|
951
|
-
console.log(` gh secret set
|
|
1156
|
+
console.log(` gh secret set ${AUTH_SECRET_PRIMARY} < ${outputPath}`);
|
|
952
1157
|
// eslint-disable-next-line no-console
|
|
953
1158
|
console.log("");
|
|
954
1159
|
}
|
|
@@ -957,8 +1162,9 @@ async function runAuthExportCi(args) {
|
|
|
957
1162
|
repo: githubRepo,
|
|
958
1163
|
orgId,
|
|
959
1164
|
projectId,
|
|
960
|
-
profile,
|
|
961
|
-
|
|
1165
|
+
profile: primaryProfile,
|
|
1166
|
+
profileNames,
|
|
1167
|
+
authShards,
|
|
962
1168
|
apiUrl,
|
|
963
1169
|
token,
|
|
964
1170
|
suiteId,
|
|
@@ -966,25 +1172,27 @@ async function runAuthExportCi(args) {
|
|
|
966
1172
|
cliRef,
|
|
967
1173
|
});
|
|
968
1174
|
printGithubSyncResults(syncResults, { repo: githubRepo });
|
|
969
|
-
const
|
|
970
|
-
if (
|
|
1175
|
+
const failedAuth = syncResults.filter((row) => row.kind === "secret" && !row.ok);
|
|
1176
|
+
if (failedAuth.length > 0 && outputPath) {
|
|
971
1177
|
// eslint-disable-next-line no-console
|
|
972
|
-
console.log(
|
|
1178
|
+
console.log(" Manual auth secrets:");
|
|
1179
|
+
authShards.forEach((shard, index) => {
|
|
1180
|
+
const shardPath =
|
|
1181
|
+
index === 0 ? outputPath : outputPath.replace(/(\.[^./]+)?$/, `_${index + 1}$1`);
|
|
1182
|
+
// eslint-disable-next-line no-console
|
|
1183
|
+
console.log(` gh secret set ${shard.secretName} < ${shardPath}`);
|
|
1184
|
+
});
|
|
973
1185
|
// eslint-disable-next-line no-console
|
|
974
1186
|
console.log("");
|
|
975
1187
|
}
|
|
976
1188
|
}
|
|
977
1189
|
if (copyToClipboardFlag) {
|
|
978
|
-
if (copyToClipboard(
|
|
1190
|
+
if (copyToClipboard(primaryShard.payload)) {
|
|
979
1191
|
// eslint-disable-next-line no-console
|
|
980
|
-
console.log(
|
|
1192
|
+
console.log(`✓ Copied ${AUTH_SECRET_PRIMARY} to clipboard.`);
|
|
981
1193
|
} else {
|
|
982
1194
|
// eslint-disable-next-line no-console
|
|
983
1195
|
console.log("Could not copy to clipboard on this machine.");
|
|
984
|
-
if (outputPath) {
|
|
985
|
-
// eslint-disable-next-line no-console
|
|
986
|
-
console.log(` Use the file instead: ${outputPath}`);
|
|
987
|
-
}
|
|
988
1196
|
}
|
|
989
1197
|
// eslint-disable-next-line no-console
|
|
990
1198
|
console.log("");
|
|
@@ -994,10 +1202,16 @@ async function runAuthExportCi(args) {
|
|
|
994
1202
|
console.log(
|
|
995
1203
|
"1. GitHub Actions config was pushed via gh (secrets + repository variables — see sync above). Fix any ✗ manually."
|
|
996
1204
|
);
|
|
997
|
-
if (!suiteId) {
|
|
1205
|
+
if (!suiteId && !configSuiteId && explicitIds.length === 0) {
|
|
1206
|
+
// eslint-disable-next-line no-console
|
|
1207
|
+
console.log(
|
|
1208
|
+
" Tip: pass --suite-id or --ids to bundle only login profiles required by those tests."
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
if (authShards.length > 1) {
|
|
998
1212
|
// eslint-disable-next-line no-console
|
|
999
1213
|
console.log(
|
|
1000
|
-
|
|
1214
|
+
` Auth was split across ${authShards.length} secrets. Add optional shard env vars to your workflow restore step.`
|
|
1001
1215
|
);
|
|
1002
1216
|
}
|
|
1003
1217
|
// eslint-disable-next-line no-console
|
|
@@ -1032,13 +1246,35 @@ async function runAuthExportCi(args) {
|
|
|
1032
1246
|
// eslint-disable-next-line no-console
|
|
1033
1247
|
console.log("---");
|
|
1034
1248
|
// eslint-disable-next-line no-console
|
|
1035
|
-
console.log(
|
|
1249
|
+
console.log(`Name: ${AUTH_PROFILES_VARIABLE}`);
|
|
1036
1250
|
// eslint-disable-next-line no-console
|
|
1037
1251
|
console.log("Value:");
|
|
1038
1252
|
// eslint-disable-next-line no-console
|
|
1039
|
-
console.log(
|
|
1253
|
+
console.log(profileNames.join(","));
|
|
1040
1254
|
// eslint-disable-next-line no-console
|
|
1041
|
-
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
|
+
}
|
|
1042
1278
|
// eslint-disable-next-line no-console
|
|
1043
1279
|
console.log("---");
|
|
1044
1280
|
// eslint-disable-next-line no-console
|
|
@@ -1079,20 +1315,20 @@ async function runAuthExportCi(args) {
|
|
|
1079
1315
|
// eslint-disable-next-line no-console
|
|
1080
1316
|
console.log("(create in Qualty → Account → API tokens; must start with qlty_ci_)");
|
|
1081
1317
|
}
|
|
1082
|
-
|
|
1083
|
-
console.log("---");
|
|
1084
|
-
// eslint-disable-next-line no-console
|
|
1085
|
-
console.log("Name: QUALTY_AUTH_STATE_B64");
|
|
1086
|
-
if (skipPayloadPrint) {
|
|
1087
|
-
// eslint-disable-next-line no-console
|
|
1088
|
-
console.log(
|
|
1089
|
-
`Value: (see file above${copyToClipboardFlag ? " or clipboard" : ""})`
|
|
1090
|
-
);
|
|
1091
|
-
} else {
|
|
1318
|
+
for (const shard of authShards) {
|
|
1092
1319
|
// eslint-disable-next-line no-console
|
|
1093
|
-
console.log("
|
|
1320
|
+
console.log("---");
|
|
1094
1321
|
// eslint-disable-next-line no-console
|
|
1095
|
-
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
|
+
}
|
|
1096
1332
|
}
|
|
1097
1333
|
// eslint-disable-next-line no-console
|
|
1098
1334
|
console.log("---");
|
|
@@ -1106,17 +1342,24 @@ async function runAuthExportCi(args) {
|
|
|
1106
1342
|
// eslint-disable-next-line no-console
|
|
1107
1343
|
console.log("");
|
|
1108
1344
|
// eslint-disable-next-line no-console
|
|
1109
|
-
console.log(githubAuthRestoreShell({
|
|
1345
|
+
console.log(githubAuthRestoreShell({ shardCount: authShards.length }));
|
|
1110
1346
|
// eslint-disable-next-line no-console
|
|
1111
1347
|
console.log("");
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1348
|
+
for (const entry of profileEntries) {
|
|
1349
|
+
// eslint-disable-next-line no-console
|
|
1350
|
+
console.log(`Profile "${entry.name}" → ${entry.statePath}`);
|
|
1351
|
+
}
|
|
1352
|
+
if (profileNames.length === 1) {
|
|
1353
|
+
// eslint-disable-next-line no-console
|
|
1354
|
+
console.log(`Optional override: --auth-profile "${profileNames[0]}"`);
|
|
1355
|
+
} else {
|
|
1356
|
+
// eslint-disable-next-line no-console
|
|
1357
|
+
console.log("Omit --auth-profile in CI so each test uses its dashboard saved login profile.");
|
|
1358
|
+
}
|
|
1116
1359
|
// eslint-disable-next-line no-console
|
|
1117
1360
|
console.log("");
|
|
1118
1361
|
// eslint-disable-next-line no-console
|
|
1119
|
-
console.log("When login expires, re-run qualty auth setup and qualty
|
|
1362
|
+
console.log("When login expires, re-run qualty auth setup and qualty ci setup --set-github to refresh secrets.");
|
|
1120
1363
|
// eslint-disable-next-line no-console
|
|
1121
1364
|
console.log("");
|
|
1122
1365
|
}
|
|
@@ -2230,7 +2473,7 @@ async function runAuthSetup(args) {
|
|
|
2230
2473
|
// eslint-disable-next-line no-console
|
|
2231
2474
|
console.log(
|
|
2232
2475
|
`[qualty][auth] For GitHub Actions secrets, run:\n` +
|
|
2233
|
-
` qualty
|
|
2476
|
+
` qualty ci setup --project ${project.id} --profile "${profile}"`
|
|
2234
2477
|
);
|
|
2235
2478
|
} finally {
|
|
2236
2479
|
rl.close();
|
|
@@ -2252,17 +2495,25 @@ async function main() {
|
|
|
2252
2495
|
await runSetup(args);
|
|
2253
2496
|
return;
|
|
2254
2497
|
}
|
|
2498
|
+
if (command === "ci") {
|
|
2499
|
+
const sub = args._[1];
|
|
2500
|
+
if (sub === "setup") {
|
|
2501
|
+
await runCiSetup(args);
|
|
2502
|
+
return;
|
|
2503
|
+
}
|
|
2504
|
+
throw new Error("Unknown ci subcommand. Use: qualty ci setup");
|
|
2505
|
+
}
|
|
2255
2506
|
if (command === "auth") {
|
|
2256
2507
|
const sub = args._[1];
|
|
2257
2508
|
if (sub === "setup") {
|
|
2258
2509
|
await runAuthSetup(args);
|
|
2259
2510
|
return;
|
|
2260
2511
|
}
|
|
2261
|
-
if (sub === "
|
|
2262
|
-
await
|
|
2512
|
+
if (sub === "restore-ci") {
|
|
2513
|
+
await runAuthRestoreCi(args);
|
|
2263
2514
|
return;
|
|
2264
2515
|
}
|
|
2265
|
-
throw new Error("Unknown auth subcommand. Use: qualty auth setup | qualty auth
|
|
2516
|
+
throw new Error("Unknown auth subcommand. Use: qualty auth setup | qualty auth restore-ci");
|
|
2266
2517
|
}
|
|
2267
2518
|
if (command === "run") {
|
|
2268
2519
|
if (parseBoolean(args.local, false)) {
|