qualty 0.1.30 → 0.1.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/auth-ci-bundle.js +348 -0
- package/bin/local-runner.js +11 -7
- package/bin/qualty.js +721 -155
- 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/local-runner.js
CHANGED
|
@@ -774,8 +774,9 @@ async function runLocalSingleCombination({
|
|
|
774
774
|
explicitAuthProfile: String(authProfile || "").trim(),
|
|
775
775
|
noAuthState: Boolean(noAuthState),
|
|
776
776
|
});
|
|
777
|
+
const explicitAuthProfile = String(authProfile || "").trim();
|
|
777
778
|
const claimProfile = localProfileLabel(claimSavedLogin);
|
|
778
|
-
const effectiveProfile =
|
|
779
|
+
const effectiveProfile = explicitAuthProfile || setupFromClaim?.profile || claimProfile;
|
|
779
780
|
const resolvedStoragePath =
|
|
780
781
|
!noAuthState && effectiveProfile
|
|
781
782
|
? localAuthStatePath({
|
|
@@ -787,17 +788,20 @@ async function runLocalSingleCombination({
|
|
|
787
788
|
const finalStoragePath =
|
|
788
789
|
!noAuthState && setupFromClaim?.path && existsSync(setupFromClaim.path)
|
|
789
790
|
? setupFromClaim.path
|
|
790
|
-
: !noAuthState &&
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
791
|
+
: !noAuthState &&
|
|
792
|
+
explicitAuthProfile &&
|
|
793
|
+
storageStatePath &&
|
|
794
|
+
existsSync(storageStatePath)
|
|
795
|
+
? storageStatePath
|
|
796
|
+
: !noAuthState && resolvedStoragePath && existsSync(resolvedStoragePath)
|
|
797
|
+
? resolvedStoragePath
|
|
798
|
+
: null;
|
|
795
799
|
|
|
796
800
|
let authSource = "none";
|
|
797
801
|
if (!noAuthState && finalStoragePath) {
|
|
798
802
|
if (setupFromClaim?.path && finalStoragePath === setupFromClaim.path) {
|
|
799
803
|
authSource = "interactive setup during run";
|
|
800
|
-
} else if (storageStatePath && finalStoragePath === storageStatePath) {
|
|
804
|
+
} else if (explicitAuthProfile && storageStatePath && finalStoragePath === storageStatePath) {
|
|
801
805
|
authSource = "--auth-profile flag";
|
|
802
806
|
} else if (resolvedStoragePath && finalStoragePath === resolvedStoragePath) {
|
|
803
807
|
authSource = claimProfile ? "dashboard saved login profile" : "resolved profile path";
|