qualty 0.1.18 → 0.1.21

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.
@@ -1,4 +1,4 @@
1
- import { mkdirSync, writeFileSync, mkdtempSync, existsSync } from "node:fs";
1
+ import { mkdirSync, writeFileSync, mkdtempSync, existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { createInterface } from "node:readline/promises";
@@ -78,6 +78,205 @@ function localAuthStatePath({ orgId, projectId, profile }) {
78
78
  return join(homedir(), ".qualty", "local-contexts", orgSeg, projectSeg, `${profileSeg}.json`);
79
79
  }
80
80
 
81
+ function jobUsesSavedLogin(job) {
82
+ if (!job) return false;
83
+ if (job.use_saved_login) return true;
84
+ if (job.saved_login_profile_id) return true;
85
+ const profile = job.saved_login_profile;
86
+ return Boolean(profile && typeof profile === "object" && (profile.id || profile.name));
87
+ }
88
+
89
+ function localProfileLabel(profile) {
90
+ if (!profile || typeof profile !== "object") return "";
91
+ return String(profile.local_profile_name || profile.name || "").trim();
92
+ }
93
+
94
+ function authSetupCommand(projectId, profileName) {
95
+ const pid = String(projectId || "").trim();
96
+ const name = String(profileName || "").trim();
97
+ return `qualty auth setup --project ${pid} --profile "${name}"`;
98
+ }
99
+
100
+ export function summarizeStorageStateFile(statePath) {
101
+ const path = String(statePath || "").trim();
102
+ if (!path || !existsSync(path)) {
103
+ return { exists: false, path };
104
+ }
105
+ const stat = statSync(path);
106
+ let parsed = null;
107
+ try {
108
+ parsed = JSON.parse(readFileSync(path, "utf8"));
109
+ } catch (err) {
110
+ return {
111
+ exists: true,
112
+ path,
113
+ bytes: stat.size,
114
+ parseError: String(err?.message || err),
115
+ };
116
+ }
117
+ const cookies = Array.isArray(parsed.cookies) ? parsed.cookies : [];
118
+ const origins = Array.isArray(parsed.origins) ? parsed.origins : [];
119
+ return {
120
+ exists: true,
121
+ path,
122
+ bytes: stat.size,
123
+ cookieCount: cookies.length,
124
+ cookieDomains: [...new Set(cookies.map((c) => c.domain || c.url || "?"))],
125
+ storageOrigins: origins.map((entry) => ({
126
+ origin: entry.origin || "?",
127
+ localStorageKeys: Array.isArray(entry.localStorage)
128
+ ? entry.localStorage.map((item) => String(item?.name || "").trim()).filter(Boolean)
129
+ : [],
130
+ })),
131
+ };
132
+ }
133
+
134
+ function hostFromUrl(rawUrl) {
135
+ try {
136
+ return new URL(String(rawUrl || "").trim()).host;
137
+ } catch {
138
+ return "";
139
+ }
140
+ }
141
+
142
+ export function logAuthStateDiagnostics(prefix, summary, details = {}) {
143
+ const tag = prefix || "[qualty][auth]";
144
+ const {
145
+ profile = "",
146
+ source = "",
147
+ claimProfileName = "",
148
+ claimLocalProfileName = "",
149
+ testUrl = "",
150
+ noAuthState = false,
151
+ } = details;
152
+
153
+ if (noAuthState) {
154
+ // eslint-disable-next-line no-console
155
+ console.log(`${tag} skipped (--no-auth-state)`);
156
+ return;
157
+ }
158
+
159
+ if (source) {
160
+ // eslint-disable-next-line no-console
161
+ console.log(`${tag} resolution: ${source}`);
162
+ }
163
+ if (profile) {
164
+ // eslint-disable-next-line no-console
165
+ console.log(`${tag} profile: "${profile}"`);
166
+ }
167
+ if (claimProfileName) {
168
+ // eslint-disable-next-line no-console
169
+ console.log(`${tag} dashboard saved login: "${claimProfileName}"`);
170
+ }
171
+ if (claimLocalProfileName && claimLocalProfileName !== profile) {
172
+ // eslint-disable-next-line no-console
173
+ console.log(`${tag} dashboard local_profile_name: "${claimLocalProfileName}"`);
174
+ }
175
+
176
+ if (!summary?.path) {
177
+ // eslint-disable-next-line no-console
178
+ console.log(`${tag} no auth state path resolved`);
179
+ return;
180
+ }
181
+
182
+ if (!summary.exists) {
183
+ // eslint-disable-next-line no-console
184
+ console.log(`${tag} auth state file missing: ${summary.path}`);
185
+ return;
186
+ }
187
+
188
+ if (summary.parseError) {
189
+ // eslint-disable-next-line no-console
190
+ console.log(
191
+ `${tag} auth state file is not valid JSON (${summary.bytes} bytes): ${summary.parseError}`
192
+ );
193
+ return;
194
+ }
195
+
196
+ // eslint-disable-next-line no-console
197
+ console.log(`${tag} auth state file found (${summary.bytes} bytes): ${summary.path}`);
198
+ // eslint-disable-next-line no-console
199
+ console.log(
200
+ `${tag} contents: ${summary.cookieCount} cookie(s)` +
201
+ (summary.cookieDomains.length ? ` [${summary.cookieDomains.join(", ")}]` : "")
202
+ );
203
+ for (const originEntry of summary.storageOrigins || []) {
204
+ const keys = originEntry.localStorageKeys.length
205
+ ? originEntry.localStorageKeys.join(", ")
206
+ : "none";
207
+ // eslint-disable-next-line no-console
208
+ console.log(`${tag} origin ${originEntry.origin}: localStorage keys [${keys}]`);
209
+ }
210
+
211
+ if (testUrl) {
212
+ const testHost = hostFromUrl(testUrl);
213
+ const storageHosts = new Set(
214
+ (summary.storageOrigins || [])
215
+ .map((entry) => hostFromUrl(entry.origin))
216
+ .filter(Boolean)
217
+ );
218
+ for (const cookieDomain of summary.cookieDomains || []) {
219
+ if (cookieDomain && cookieDomain !== "?") storageHosts.add(String(cookieDomain).replace(/^\./, ""));
220
+ }
221
+ // eslint-disable-next-line no-console
222
+ console.log(`${tag} test URL: ${testUrl}`);
223
+ if (testHost && storageHosts.size > 0 && !storageHosts.has(testHost)) {
224
+ // eslint-disable-next-line no-console
225
+ console.warn(
226
+ `${tag} warning: test host "${testHost}" does not match saved auth origins/cookies (${[...storageHosts].join(", ")}). localStorage will not apply until origins match.`
227
+ );
228
+ }
229
+ }
230
+
231
+ const emptyStorage =
232
+ summary.cookieCount === 0 &&
233
+ (summary.storageOrigins || []).every((entry) => entry.localStorageKeys.length === 0);
234
+ if (emptyStorage) {
235
+ // eslint-disable-next-line no-console
236
+ console.warn(`${tag} warning: auth state file appears empty (no cookies or localStorage)`);
237
+ }
238
+ }
239
+
240
+ export async function logAuthStateAfterInjection(tag, context, { testUrl = "" } = {}) {
241
+ try {
242
+ const cookies = await context.cookies();
243
+ // eslint-disable-next-line no-console
244
+ console.log(`${tag} injected into browser context: ${cookies.length} cookie(s) loaded`);
245
+ if (testUrl) {
246
+ // eslint-disable-next-line no-console
247
+ console.log(`${tag} localStorage applies after navigation to a matching origin (${testUrl})`);
248
+ }
249
+ } catch (err) {
250
+ // eslint-disable-next-line no-console
251
+ console.warn(`${tag} could not verify injected auth state: ${err?.message || err}`);
252
+ }
253
+ }
254
+
255
+ export async function logAuthStateAfterNavigation(tag, page) {
256
+ try {
257
+ const snapshot = await page.evaluate(() => ({
258
+ origin: window.location.origin,
259
+ href: window.location.href,
260
+ localStorageKeys: Object.keys(localStorage),
261
+ cookieCount: document.cookie ? document.cookie.split(";").filter(Boolean).length : 0,
262
+ }));
263
+ const keys = snapshot.localStorageKeys.length ? snapshot.localStorageKeys.join(", ") : "none";
264
+ // eslint-disable-next-line no-console
265
+ console.log(
266
+ `${tag} after navigation (${snapshot.origin}): localStorage keys [${keys}], document.cookie entries ~${snapshot.cookieCount}`
267
+ );
268
+ if (snapshot.localStorageKeys.length === 0 && snapshot.cookieCount === 0) {
269
+ // eslint-disable-next-line no-console
270
+ console.warn(
271
+ `${tag} warning: page has no localStorage keys and no cookies after navigation — app will likely appear logged out`
272
+ );
273
+ }
274
+ } catch (err) {
275
+ // eslint-disable-next-line no-console
276
+ console.warn(`${tag} could not read auth state after navigation: ${err?.message || err}`);
277
+ }
278
+ }
279
+
81
280
  const localBindingSetupPromises = new Map();
82
281
 
83
282
  async function promptYesNo(question, defaultYes = true) {
@@ -185,11 +384,21 @@ async function maybeSetupMissingLocalBinding({
185
384
  ? claim.saved_login_profile
186
385
  : null;
187
386
  if (!profile || !profile.name) return null;
188
- if (profile.has_local_binding) return null;
189
- if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
190
-
191
387
  const profileName = String(profile.name || "").trim();
192
388
  if (!profileName) return null;
389
+ const localProfileName = localProfileLabel(profile) || profileName;
390
+ const statePath = localAuthStatePath({
391
+ orgId,
392
+ projectId: claim?.project_id,
393
+ profile: localProfileName,
394
+ });
395
+ if (existsSync(statePath)) return null;
396
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
397
+ throw new Error(
398
+ `Local login state missing for profile "${profileName}". Run: ${authSetupCommand(claim?.project_id, profileName)}`
399
+ );
400
+ }
401
+
193
402
  const key = `${String(orgId || "")}:${String(claim?.project_id || "")}:${profileName}`;
194
403
  if (localBindingSetupPromises.has(key)) {
195
404
  return localBindingSetupPromises.get(key);
@@ -243,20 +452,21 @@ async function preflightMissingLocalBindings({
243
452
  }) {
244
453
  if (noAuthState) return;
245
454
  if (explicitAuthProfile) return;
246
- if (!process.stdin.isTTY || !process.stdout.isTTY) return;
247
455
  const missingByProfile = new Map();
248
456
  for (const job of savedJobs || []) {
249
- const useSaved = Boolean(job?.use_saved_login);
457
+ if (!jobUsesSavedLogin(job)) continue;
250
458
  const profileName = String(job?.saved_login_profile?.name || "").trim();
251
- if (!useSaved || !profileName) continue;
459
+ if (!profileName) continue;
460
+ const localProfileName =
461
+ String(job?.saved_login_profile?.local_profile_name || "").trim() || profileName;
252
462
  const statePath = localAuthStatePath({
253
463
  orgId,
254
464
  projectId: String(job?.project_id || projectId || "").trim(),
255
- profile: profileName,
465
+ profile: localProfileName,
256
466
  });
257
467
  if (existsSync(statePath)) continue;
258
468
  const entry = missingByProfile.get(profileName) || {
259
- localProfileName: profileName,
469
+ localProfileName,
260
470
  projectId: String(job?.project_id || projectId || "").trim(),
261
471
  startUrl: rewriteUrlForLocalRun(String(job?.url || "").trim(), localPort),
262
472
  tests: [],
@@ -272,6 +482,13 @@ async function preflightMissingLocalBindings({
272
482
  }
273
483
  if (missingByProfile.size === 0) return;
274
484
 
485
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
486
+ const [profileName, entry] = missingByProfile.entries().next().value;
487
+ throw new Error(
488
+ `Local login state missing for profile "${profileName}". Run: ${authSetupCommand(entry.projectId, profileName)}`
489
+ );
490
+ }
491
+
275
492
  // eslint-disable-next-line no-console
276
493
  console.log("[qualty][auth] Missing local login state for attached dashboard profiles:");
277
494
  for (const [profileName, entry] of missingByProfile.entries()) {
@@ -1073,6 +1290,51 @@ export async function runLocalExecution({
1073
1290
  authProfile,
1074
1291
  noAuthState,
1075
1292
  storageStatePath,
1293
+ }) {
1294
+ let overallSuccess = true;
1295
+ let lastError = null;
1296
+ let claimDevice = device || undefined;
1297
+
1298
+ while (true) {
1299
+ let once;
1300
+ try {
1301
+ once = await runLocalSingleCombination({
1302
+ apiRequest,
1303
+ apiUrl,
1304
+ token,
1305
+ orgId,
1306
+ executionId,
1307
+ device: claimDevice,
1308
+ headless,
1309
+ authProfile,
1310
+ noAuthState,
1311
+ storageStatePath,
1312
+ });
1313
+ } catch (err) {
1314
+ const msg = String(err?.message || err);
1315
+ if (msg.includes("No queued combinations")) break;
1316
+ throw err;
1317
+ }
1318
+ claimDevice = undefined;
1319
+ overallSuccess = overallSuccess && once.success;
1320
+ if (!once.success) lastError = once.error;
1321
+ if (device || !once.combinationsRemaining) break;
1322
+ }
1323
+
1324
+ return { success: overallSuccess, error: lastError };
1325
+ }
1326
+
1327
+ async function runLocalSingleCombination({
1328
+ apiRequest,
1329
+ apiUrl,
1330
+ token,
1331
+ orgId,
1332
+ executionId,
1333
+ device,
1334
+ headless,
1335
+ authProfile,
1336
+ noAuthState,
1337
+ storageStatePath,
1076
1338
  }) {
1077
1339
  const { chromium } = await import("playwright");
1078
1340
 
@@ -1083,7 +1345,7 @@ export async function runLocalExecution({
1083
1345
  orgId,
1084
1346
  path: `/api/v1/cli/executions/${executionId}/claim`,
1085
1347
  method: "POST",
1086
- body: { device: runDevice },
1348
+ body: device ? { device: String(device).trim() } : {},
1087
1349
  });
1088
1350
  const effectiveDevice = String(claim.device || runDevice).trim() || runDevice;
1089
1351
 
@@ -1112,7 +1374,7 @@ export async function runLocalExecution({
1112
1374
  explicitAuthProfile: String(authProfile || "").trim(),
1113
1375
  noAuthState: Boolean(noAuthState),
1114
1376
  });
1115
- const claimProfile = String(claimSavedLogin?.local_profile_name || "").trim();
1377
+ const claimProfile = localProfileLabel(claimSavedLogin);
1116
1378
  const effectiveProfile = String(authProfile || "").trim() || setupFromClaim?.profile || claimProfile;
1117
1379
  const resolvedStoragePath =
1118
1380
  !noAuthState && effectiveProfile
@@ -1131,6 +1393,47 @@ export async function runLocalExecution({
1131
1393
  ? resolvedStoragePath
1132
1394
  : null;
1133
1395
 
1396
+ let authSource = "none";
1397
+ if (!noAuthState && finalStoragePath) {
1398
+ if (setupFromClaim?.path && finalStoragePath === setupFromClaim.path) {
1399
+ authSource = "interactive setup during run";
1400
+ } else if (storageStatePath && finalStoragePath === storageStatePath) {
1401
+ authSource = "--auth-profile flag";
1402
+ } else if (resolvedStoragePath && finalStoragePath === resolvedStoragePath) {
1403
+ authSource = claimProfile ? "dashboard saved login profile" : "resolved profile path";
1404
+ } else {
1405
+ authSource = "resolved profile path";
1406
+ }
1407
+ }
1408
+
1409
+ logAuthStateDiagnostics("[qualty][auth]", summarizeStorageStateFile(finalStoragePath), {
1410
+ profile: effectiveProfile || "",
1411
+ source: authSource,
1412
+ claimProfileName: String(claimSavedLogin?.name || "").trim(),
1413
+ claimLocalProfileName: claimProfile,
1414
+ testUrl: url,
1415
+ noAuthState: Boolean(noAuthState),
1416
+ });
1417
+
1418
+ if (!noAuthState && effectiveProfile && !finalStoragePath) {
1419
+ const profileName = String(claimSavedLogin?.name || effectiveProfile).trim();
1420
+ const candidates = [storageStatePath, resolvedStoragePath].filter(Boolean);
1421
+ const setupCmd = authSetupCommand(claim.project_id, profileName);
1422
+ const detail =
1423
+ `Local login state missing for profile "${profileName}".` +
1424
+ (candidates.length ? ` Checked: ${candidates.join(", ")}.` : "") +
1425
+ ` Run: ${setupCmd}`;
1426
+ const failLoud =
1427
+ !process.stdin.isTTY ||
1428
+ !process.stdout.isTTY ||
1429
+ (claimSavedLogin?.name && !String(authProfile || "").trim());
1430
+ if (failLoud) {
1431
+ throw new Error(detail);
1432
+ }
1433
+ // eslint-disable-next-line no-console
1434
+ console.warn(`[qualty][auth] ${detail}`);
1435
+ }
1436
+
1134
1437
  const browserServer = await chromium.launchServer({
1135
1438
  headless: headless !== false,
1136
1439
  host: "127.0.0.1",
@@ -1142,10 +1445,14 @@ export async function runLocalExecution({
1142
1445
  };
1143
1446
  if (finalStoragePath) {
1144
1447
  contextOptions.storageState = finalStoragePath;
1145
- const logProfile = effectiveProfile || "default";
1146
- console.log(`[qualty][auth] using saved context "${logProfile}": ${finalStoragePath}`);
1147
1448
  }
1148
1449
  const context = await browser.newContext(contextOptions);
1450
+ if (finalStoragePath) {
1451
+ await logAuthStateAfterInjection("[qualty][auth]", context, { testUrl: url });
1452
+ } else if (!noAuthState && (effectiveProfile || claimSavedLogin?.name)) {
1453
+ // eslint-disable-next-line no-console
1454
+ console.log("[qualty][auth] running without saved login state injection");
1455
+ }
1149
1456
  const page = await context.newPage();
1150
1457
  const networkCollector = attachNetworkCollector(page);
1151
1458
  const attachmentSpecs = Array.isArray(claim.file_attachments) ? claim.file_attachments : [];
@@ -1189,6 +1496,9 @@ export async function runLocalExecution({
1189
1496
  try {
1190
1497
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
1191
1498
  await page.waitForTimeout(500);
1499
+ if (finalStoragePath) {
1500
+ await logAuthStateAfterNavigation("[qualty][auth]", page);
1501
+ }
1192
1502
 
1193
1503
  for (let stepIndex = 0; stepIndex < totalSteps; stepIndex += 1) {
1194
1504
  const step = steps[stepIndex] || {};
@@ -1354,7 +1664,12 @@ export async function runLocalExecution({
1354
1664
  }),
1355
1665
  });
1356
1666
 
1357
- return { success: runSuccess, stepsJson, error: runError, device: effectiveDevice };
1667
+ if (completeResp && completeResp.status) {
1668
+ runSuccess = completeResp.status === "completed" || runSuccess;
1669
+ }
1670
+
1671
+ const combinationsRemaining = Number(completeResp?.combinations_remaining ?? 0);
1672
+ return { success: runSuccess, stepsJson, error: runError, combinationsRemaining, device: effectiveDevice };
1358
1673
  }
1359
1674
 
1360
1675
  async function fetchProjectLocalPort({ apiRequest, apiUrl, token, orgId, projectId }) {
@@ -1414,6 +1729,10 @@ export async function runLocalCi({
1414
1729
  const started = runs.filter((r) => r.execution_job_id);
1415
1730
  if (started.length === 0) throw new Error("Failed to start any local runs.");
1416
1731
 
1732
+ const executionIds = started.map((r) => r.execution_job_id);
1733
+ // eslint-disable-next-line no-console
1734
+ console.log(`QUALTY_EXECUTION_IDS=${JSON.stringify(executionIds)}`);
1735
+
1417
1736
  await preflightMissingLocalBindings({
1418
1737
  apiRequest,
1419
1738
  apiUrl,
package/bin/qualty.js CHANGED
@@ -12,40 +12,32 @@ import {
12
12
  import { gzipSync } from "node:zlib";
13
13
  import { spawn, spawnSync } from "node:child_process";
14
14
  import { homedir, hostname } from "node:os";
15
- import { join } from "node:path";
15
+ import { dirname, join } from "node:path";
16
16
  import { createInterface } from "node:readline/promises";
17
17
  import process from "node:process";
18
- import { rewriteUrlForLocalRun, runLocalCi } from "./local-runner.js";
18
+ import { rewriteUrlForLocalRun, runLocalCi, summarizeStorageStateFile, logAuthStateDiagnostics } from "./local-runner.js";
19
19
 
20
- /** CLI backend URL is fixed by Qualty distribution. */
21
- const DEFAULT_QUALTY_API_URL = "https://qualty-api-production.up.railway.app";
20
+ /** Default Qualty API base URL (no trailing slash). Override with QUALTY_API_URL env. */
21
+ //const DEFAULT_QUALTY_API_URL = "https://qualty-api-production.up.railway.app";
22
+ const DEFAULT_QUALTY_API_URL = "http://127.0.0.1:8000";
22
23
 
23
- const QUALTY_CONFIG_DIR = join(homedir(), ".config", "qualty");
24
- const QUALTY_CONFIG_PATH = join(QUALTY_CONFIG_DIR, "config.json");
24
+ const PROJECT_CONFIG_FILENAME = ".qualty.json";
25
25
  const QUALTY_SHARED_CREDS_DIR = join(homedir(), ".qualty");
26
26
  const QUALTY_SHARED_CREDS_PATH = join(QUALTY_SHARED_CREDS_DIR, "credentials");
27
- const QUALTY_PROJECT_CONFIG_PATH = join(process.cwd(), ".qualty.json");
28
27
 
29
- function readQualtyConfig() {
30
- if (!existsSync(QUALTY_CONFIG_PATH)) return {};
31
- try {
32
- const raw = readFileSync(QUALTY_CONFIG_PATH, "utf8");
33
- const parsed = JSON.parse(raw);
34
- if (!parsed || typeof parsed !== "object") return {};
35
- return parsed;
36
- } catch {
37
- return {};
28
+ function findProjectConfigPath() {
29
+ let current = process.cwd();
30
+ for (;;) {
31
+ const candidate = join(current, PROJECT_CONFIG_FILENAME);
32
+ if (existsSync(candidate)) return candidate;
33
+ const parent = dirname(current);
34
+ if (parent === current) return null;
35
+ current = parent;
38
36
  }
39
37
  }
40
38
 
41
- function writeQualtyConfig(config) {
42
- mkdirSync(QUALTY_CONFIG_DIR, { recursive: true });
43
- writeFileSync(QUALTY_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
44
- try {
45
- chmodSync(QUALTY_CONFIG_PATH, 0o600);
46
- } catch {
47
- // best effort on non-posix systems
48
- }
39
+ function getProjectConfigWritePath() {
40
+ return findProjectConfigPath() || join(process.cwd(), PROJECT_CONFIG_FILENAME);
49
41
  }
50
42
 
51
43
  function readSharedCredentials() {
@@ -70,10 +62,19 @@ function writeSharedCredentials(token) {
70
62
  }
71
63
  }
72
64
 
65
+ function projectIdFromConfig(projectCfg, fallback = "") {
66
+ return String(fallback || projectCfg.default_project_id || "").trim();
67
+ }
68
+
69
+ function authProfileFromConfig(projectCfg, fallback = "") {
70
+ return String(fallback || projectCfg.default_auth_profile || "").trim();
71
+ }
72
+
73
73
  function readProjectConfig() {
74
- if (!existsSync(QUALTY_PROJECT_CONFIG_PATH)) return {};
74
+ const configPath = findProjectConfigPath();
75
+ if (!configPath) return {};
75
76
  try {
76
- const raw = readFileSync(QUALTY_PROJECT_CONFIG_PATH, "utf8");
77
+ const raw = readFileSync(configPath, "utf8");
77
78
  const parsed = JSON.parse(raw);
78
79
  if (!parsed || typeof parsed !== "object") return {};
79
80
  return parsed;
@@ -83,29 +84,29 @@ function readProjectConfig() {
83
84
  }
84
85
 
85
86
  function writeProjectConfig(updates) {
87
+ const configPath = getProjectConfigWritePath();
86
88
  const current = readProjectConfig();
87
89
  const next = { ...current };
88
90
  for (const [k, v] of Object.entries(updates || {})) {
89
91
  if (v === undefined || v === null || v === "") delete next[k];
90
92
  else next[k] = v;
91
93
  }
92
- writeFileSync(QUALTY_PROJECT_CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`, "utf8");
94
+ writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
93
95
  }
94
96
 
95
- function resolveApiUrl(args, config = {}) {
97
+ function resolveApiUrl(args = {}) {
96
98
  void args;
97
- void config;
98
- return String(DEFAULT_QUALTY_API_URL).replace(/\/$/, "");
99
+ return String(process.env.QUALTY_API_URL || DEFAULT_QUALTY_API_URL).replace(/\/$/, "");
99
100
  }
100
101
 
101
- function resolveToken(args, config = {}) {
102
+ function resolveToken(args = {}) {
102
103
  const creds = readSharedCredentials();
103
- return args.token || process.env.QUALTY_API_TOKEN || creds.api_token || config.api_token || "";
104
+ return args.token || process.env.QUALTY_API_TOKEN || creds.api_token || "";
104
105
  }
105
106
 
106
- function resolveOrgId(args, config = {}) {
107
+ function resolveOrgId(args = {}) {
107
108
  const projectCfg = readProjectConfig();
108
- return args.org || process.env.QUALTY_ORG_ID || config.org_id || projectCfg.default_org_id || "";
109
+ return args.org || process.env.QUALTY_ORG_ID || projectCfg.default_org_id || "";
109
110
  }
110
111
 
111
112
  function maskSecret(secret) {
@@ -145,7 +146,10 @@ function usage() {
145
146
  " qualty setup [--token <bearer-token>] [--org <org-id>]",
146
147
  " qualty auth setup [--project <project-id>] [--profile <profile-name>] [--headed]",
147
148
  " qualty auth export-ci --project <project-id> [--profile <profile-name>] [--org <org-id>]",
148
- " [--output <path>] [--no-file] [--copy] [--set-secret] [--gzip] [--include-token]",
149
+ " [--suite-id <suite-uuid>] [--repo <owner/repo>]",
150
+ " [--output <path>] [--no-file] [--copy] [--gzip] [--include-token]",
151
+ " [--set-secret | --set-github] gh: secrets (token, API URL, auth) + variables (org, project, profile, …)",
152
+ " [--cli-repo <owner/repo>] [--cli-ref <branch>] Optional CI variables",
149
153
  " qualty connect --project <project-id> [--port 3000] [--token <bearer-token>] [--org <org-id>]",
150
154
  " qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--token <bearer-token>] [--org <org-id>]",
151
155
  " [--poll-interval 5] [--timeout 30] [--fail-on-failure true] [--no-view-logs]",
@@ -157,7 +161,8 @@ function usage() {
157
161
  "",
158
162
  "Env vars:",
159
163
  " QUALTY_API_TOKEN Bearer token used for auth",
160
- " QUALTY_ORG_ID Org context for scoped API requests",
164
+ " QUALTY_API_URL API base URL (default: bundled CLI default)",
165
+ " QUALTY_ORG_ID Org context override (default: .qualty.json default_org_id)",
161
166
  " QUALTY_LOCAL_CONCURRENCY Local parallel workers for --local runs (default: 4)",
162
167
  ].join("\n")
163
168
  );
@@ -228,12 +233,11 @@ function startCloudflared(token, port) {
228
233
  }
229
234
 
230
235
  async function runConnect(args) {
231
- const config = readQualtyConfig();
232
236
  const projectId = args.project;
233
237
  const port = Number(args.port || 3000);
234
- const apiUrl = resolveApiUrl(args, config);
235
- const token = resolveToken(args, config);
236
- const orgId = resolveOrgId(args, config);
238
+ const apiUrl = resolveApiUrl(args);
239
+ const token = resolveToken(args);
240
+ const orgId = resolveOrgId(args);
237
241
 
238
242
  if (!projectId || !token) {
239
243
  usage();
@@ -305,7 +309,6 @@ async function runConnect(args) {
305
309
  }
306
310
 
307
311
  async function runSetup(args) {
308
- const existing = readQualtyConfig();
309
312
  const sharedCreds = readSharedCredentials();
310
313
  const projectCfg = readProjectConfig();
311
314
  const rl = createInterface({
@@ -341,8 +344,10 @@ async function runSetup(args) {
341
344
  };
342
345
 
343
346
  try {
344
- const currentOrgId = String(args.org || existing.org_id || projectCfg.default_org_id || "");
345
- const currentToken = String(args.token || process.env.QUALTY_API_TOKEN || sharedCreds.api_token || existing.api_token || "");
347
+ const currentOrgId = String(args.org || projectCfg.default_org_id || "");
348
+ const currentToken = String(
349
+ args.token || process.env.QUALTY_API_TOKEN || sharedCreds.api_token || ""
350
+ );
346
351
 
347
352
  let token = "";
348
353
  if (args.token) {
@@ -438,20 +443,13 @@ async function runSetup(args) {
438
443
  throw new Error("Org ID is required. Re-run setup and provide QUALTY_ORG_ID.");
439
444
  }
440
445
 
441
- const next = {
442
- org_id: orgId,
443
- };
444
- // Keep token in the same place MCP uses.
445
446
  writeSharedCredentials(token);
446
- writeQualtyConfig(next);
447
- // Keep org in MCP-compatible per-project config too.
448
447
  writeProjectConfig({ default_org_id: orgId });
449
- // eslint-disable-next-line no-console
450
- console.log(`[qualty] Saved config to ${QUALTY_CONFIG_PATH}`);
448
+ const projectConfigPath = getProjectConfigWritePath();
451
449
  // eslint-disable-next-line no-console
452
450
  console.log(`[qualty] Saved shared token to ${QUALTY_SHARED_CREDS_PATH} (used by MCP)`);
453
451
  // eslint-disable-next-line no-console
454
- console.log(`[qualty] Saved project org to ${QUALTY_PROJECT_CONFIG_PATH} (MCP-compatible)`);
452
+ console.log(`[qualty] Saved project config to ${projectConfigPath} (MCP-compatible)`);
455
453
  // eslint-disable-next-line no-console
456
454
  console.log(`[qualty] Org: ${orgName || "configured"}`);
457
455
  // eslint-disable-next-line no-console
@@ -524,8 +522,17 @@ function copyToClipboard(text) {
524
522
  return false;
525
523
  }
526
524
 
527
- function setGithubSecret(name, value) {
528
- const result = spawnSync("gh", ["secret", "set", name], { input: value, encoding: "utf8" });
525
+ function ghRepoArgs(repo) {
526
+ const target = String(repo || "").trim();
527
+ return target ? ["--repo", target] : [];
528
+ }
529
+
530
+ function setGithubSecret(name, value, { repo } = {}) {
531
+ const result = spawnSync(
532
+ "gh",
533
+ ["secret", "set", name, ...ghRepoArgs(repo)],
534
+ { input: value, encoding: "utf8" }
535
+ );
529
536
  if (result.error?.code === "ENOENT") {
530
537
  return { ok: false, reason: "GitHub CLI (gh) is not installed." };
531
538
  }
@@ -536,6 +543,112 @@ function setGithubSecret(name, value) {
536
543
  return { ok: true };
537
544
  }
538
545
 
546
+ function setGithubVariable(name, value, { repo } = {}) {
547
+ const result = spawnSync("gh", ["variable", "set", name, "--body", value, ...ghRepoArgs(repo)], {
548
+ encoding: "utf8",
549
+ });
550
+ if (result.error?.code === "ENOENT") {
551
+ return { ok: false, reason: "GitHub CLI (gh) is not installed." };
552
+ }
553
+ if (result.status !== 0) {
554
+ const detail = String(result.stderr || result.stdout || "").trim();
555
+ return { ok: false, reason: detail || `gh variable set failed (exit ${result.status})` };
556
+ }
557
+ return { ok: true };
558
+ }
559
+
560
+ /**
561
+ * Push Qualty CI secrets + repository variables via gh (run from target app repo root).
562
+ */
563
+ function syncGithubCiConfig({
564
+ repo,
565
+ orgId,
566
+ projectId,
567
+ profile,
568
+ authPayload,
569
+ apiUrl,
570
+ token,
571
+ suiteId,
572
+ cliRepo,
573
+ cliRef,
574
+ }) {
575
+ const results = [];
576
+ const record = (kind, name, outcome) => {
577
+ results.push({ kind, name, ...outcome });
578
+ };
579
+
580
+ const authResult = setGithubSecret("QUALTY_AUTH_STATE_B64", authPayload, { repo });
581
+ record("secret", "QUALTY_AUTH_STATE_B64", authResult);
582
+
583
+ if (token) {
584
+ record("secret", "QUALTY_API_TOKEN", setGithubSecret("QUALTY_API_TOKEN", token, { repo }));
585
+ } else {
586
+ record("secret", "QUALTY_API_TOKEN", {
587
+ ok: false,
588
+ reason: "No token locally — run qualty setup or set QUALTY_API_TOKEN.",
589
+ });
590
+ }
591
+
592
+ const apiUrlNorm = String(apiUrl || "").replace(/\/$/, "");
593
+ const isLocalDefault = apiUrlNorm === "http://127.0.0.1:8000";
594
+ if (apiUrlNorm && !isLocalDefault) {
595
+ record("secret", "QUALTY_API_URL", setGithubSecret("QUALTY_API_URL", apiUrlNorm, { repo }));
596
+ } else {
597
+ record("secret", "QUALTY_API_URL", {
598
+ ok: false,
599
+ reason: "Set QUALTY_API_URL to your dev/prod API (not localhost) before export-ci.",
600
+ });
601
+ }
602
+
603
+ record("variable", "QUALTY_ORG_ID", setGithubVariable("QUALTY_ORG_ID", orgId, { repo }));
604
+ record("variable", "QUALTY_PROJECT_ID", setGithubVariable("QUALTY_PROJECT_ID", String(projectId), { repo }));
605
+ record("variable", "QUALTY_AUTH_PROFILE", setGithubVariable("QUALTY_AUTH_PROFILE", profile, { repo }));
606
+
607
+ if (suiteId) {
608
+ record("variable", "QUALTY_SUITE_ID", setGithubVariable("QUALTY_SUITE_ID", String(suiteId), { repo }));
609
+ }
610
+
611
+ if (cliRepo) {
612
+ record("variable", "QUALTY_CLI_REPO", setGithubVariable("QUALTY_CLI_REPO", cliRepo, { repo }));
613
+ }
614
+ if (cliRef) {
615
+ record("variable", "QUALTY_CLI_REF", setGithubVariable("QUALTY_CLI_REF", cliRef, { repo }));
616
+ }
617
+
618
+ return results;
619
+ }
620
+
621
+ function printGithubSyncResults(results, { repo }) {
622
+ const target = repo ? ` (repo ${repo})` : "";
623
+ const printGroup = (title, rows) => {
624
+ if (!rows.length) return;
625
+ // eslint-disable-next-line no-console
626
+ console.log(title);
627
+ for (const row of rows) {
628
+ if (row.ok) {
629
+ // eslint-disable-next-line no-console
630
+ console.log(` ✓ ${row.name}`);
631
+ } else {
632
+ // eslint-disable-next-line no-console
633
+ console.log(` ✗ ${row.name} — ${row.reason || "failed"}`);
634
+ }
635
+ }
636
+ };
637
+
638
+ // eslint-disable-next-line no-console
639
+ console.log(`GitHub sync${target} (gh secret set + gh variable set):`);
640
+ printGroup(
641
+ " Repository secrets:",
642
+ results.filter((r) => r.kind === "secret")
643
+ );
644
+ printGroup(
645
+ " Repository variables:",
646
+ results.filter((r) => r.kind === "variable")
647
+ );
648
+ // eslint-disable-next-line no-console
649
+ console.log("");
650
+ }
651
+
539
652
  function encodeAuthStateForGithubSecret(statePath, { gzip = false } = {}) {
540
653
  const raw = readFileSync(statePath);
541
654
  if (gzip) {
@@ -552,14 +665,10 @@ function encodeAuthStateForGithubSecret(statePath, { gzip = false } = {}) {
552
665
  };
553
666
  }
554
667
 
555
- function githubAuthRestoreShell({ orgId, projectId, profile, encoding }) {
668
+ function githubAuthRestoreShell({ orgId, projectId, profile }) {
556
669
  const orgSeg = safePathSegment(orgId, "default-org");
557
670
  const projectSeg = safePathSegment(projectId, "default-project");
558
671
  const profileSeg = safePathSegment(profile, "default");
559
- const decodeCmd =
560
- encoding === "gzip+base64"
561
- ? 'echo "$QUALTY_AUTH_STATE_B64" | base64 -d | gunzip'
562
- : 'echo "$QUALTY_AUTH_STATE_B64" | base64 -d';
563
672
  return [
564
673
  `- name: Restore Qualty login state`,
565
674
  ` env:`,
@@ -567,13 +676,13 @@ function githubAuthRestoreShell({ orgId, projectId, profile, encoding }) {
567
676
  ` run: |`,
568
677
  ` AUTH_DIR="$HOME/.qualty/local-contexts/${orgSeg}/${projectSeg}"`,
569
678
  ` mkdir -p "$AUTH_DIR"`,
570
- ` ${decodeCmd} > "$AUTH_DIR/${profileSeg}.json"`,
679
+ ` echo "$QUALTY_AUTH_STATE_B64" | base64 -d | gunzip > "$AUTH_DIR/${profileSeg}.json"`,
571
680
  ` test -s "$AUTH_DIR/${profileSeg}.json"`,
572
681
  ``,
573
682
  `- name: Run Qualty (local)`,
574
683
  ` env:`,
575
684
  ` QUALTY_API_TOKEN: \${{ secrets.QUALTY_API_TOKEN }}`,
576
- ` QUALTY_ORG_ID: \${{ secrets.QUALTY_ORG_ID }}`,
685
+ ` QUALTY_ORG_ID: \${{ vars.QUALTY_ORG_ID }}`,
577
686
  ` run: |`,
578
687
  ` npx qualty@latest run --local \\`,
579
688
  ` --project ${projectId} \\`,
@@ -584,12 +693,14 @@ function githubAuthRestoreShell({ orgId, projectId, profile, encoding }) {
584
693
  }
585
694
 
586
695
  async function runAuthExportCi(args) {
587
- const config = readQualtyConfig();
588
- const orgId = resolveOrgId(args, config);
589
- const token = resolveToken(args, config);
696
+ const orgId = resolveOrgId(args);
697
+ const token = resolveToken(args);
590
698
  const projectCfg = readProjectConfig();
591
- const projectId = String(args.project || projectCfg.local_auth_project_id || "").trim();
592
- const profile = String(args.profile || args["auth-profile"] || projectCfg.local_auth_profile || "").trim();
699
+ const projectId = projectIdFromConfig(projectCfg, args.project);
700
+ const profile = authProfileFromConfig(
701
+ projectCfg,
702
+ args.profile || args["auth-profile"]
703
+ );
593
704
 
594
705
  if (!orgId) {
595
706
  throw new Error("Missing org. Pass --org, set QUALTY_ORG_ID, or add default_org_id to .qualty.json.");
@@ -614,7 +725,10 @@ async function runAuthExportCi(args) {
614
725
  );
615
726
  }
616
727
 
617
- let gzip = parseBoolean(args.gzip, false);
728
+ const setGithubFlag =
729
+ parseBoolean(args["set-github"], false) || parseBoolean(args["set-secret"], false);
730
+ // CI path: one format only — gzip then base64 in QUALTY_AUTH_STATE_B64 (no separate gzip flag).
731
+ let gzip = parseBoolean(args.gzip, false) || setGithubFlag;
618
732
  let encoded = encodeAuthStateForGithubSecret(statePath, { gzip });
619
733
  let secretBytes = Buffer.byteLength(encoded.payload, "utf8");
620
734
  if (!gzip && secretBytes > GITHUB_SECRET_MAX_BYTES - 1024) {
@@ -634,7 +748,12 @@ async function runAuthExportCi(args) {
634
748
  ? String(args.output || "").trim()
635
749
  : String(args.output || "").trim() || authExportCiOutputPath(statePath);
636
750
  const copyToClipboardFlag = parseBoolean(args.copy, false);
637
- const setSecretFlag = parseBoolean(args["set-secret"], false);
751
+ const githubRepo = String(args.repo || "").trim();
752
+ const suiteId = String(args["suite-id"] || args.suite || "").trim();
753
+ const cliRepo = String(args["cli-repo"] || "").trim();
754
+ const cliRef = String(args["cli-ref"] || "").trim();
755
+ const apiUrl = resolveApiUrl(args);
756
+
638
757
  if (outputPath) {
639
758
  writeFileSync(outputPath, encoded.payload, "utf8");
640
759
  try {
@@ -646,12 +765,12 @@ async function runAuthExportCi(args) {
646
765
 
647
766
  const includeToken = parseBoolean(args["include-token"], false);
648
767
  const profileSeg = safePathSegment(profile, "default");
649
- const skipPayloadPrint = Boolean(outputPath || copyToClipboardFlag || setSecretFlag);
768
+ const skipPayloadPrint = Boolean(outputPath || copyToClipboardFlag || setGithubFlag);
650
769
 
651
770
  // eslint-disable-next-line no-console
652
771
  console.log("");
653
772
  // eslint-disable-next-line no-console
654
- console.log("=== Qualty → GitHub Actions secrets ===");
773
+ console.log("=== Qualty → GitHub Actions (secrets & variables) ===");
655
774
  // eslint-disable-next-line no-console
656
775
  console.log("");
657
776
  if (encoded.encoding === "gzip+base64") {
@@ -671,21 +790,27 @@ async function runAuthExportCi(args) {
671
790
  // eslint-disable-next-line no-console
672
791
  console.log("");
673
792
  }
674
- if (setSecretFlag) {
675
- const result = setGithubSecret("QUALTY_AUTH_STATE_B64", encoded.payload);
676
- if (result.ok) {
793
+ if (setGithubFlag) {
794
+ const syncResults = syncGithubCiConfig({
795
+ repo: githubRepo,
796
+ orgId,
797
+ projectId,
798
+ profile,
799
+ authPayload: encoded.payload,
800
+ apiUrl,
801
+ token,
802
+ suiteId,
803
+ cliRepo,
804
+ cliRef,
805
+ });
806
+ printGithubSyncResults(syncResults, { repo: githubRepo });
807
+ const authRow = syncResults.find((r) => r.name === "QUALTY_AUTH_STATE_B64");
808
+ if (!authRow?.ok && outputPath) {
677
809
  // eslint-disable-next-line no-console
678
- console.log("✓ Set GitHub secret QUALTY_AUTH_STATE_B64 via gh CLI.");
679
- } else {
810
+ console.log(` Manual auth secret: gh secret set QUALTY_AUTH_STATE_B64 < ${outputPath}`);
680
811
  // eslint-disable-next-line no-console
681
- console.log(`Could not set QUALTY_AUTH_STATE_B64 automatically: ${result.reason}`);
682
- if (outputPath) {
683
- // eslint-disable-next-line no-console
684
- console.log(` gh secret set QUALTY_AUTH_STATE_B64 < ${outputPath}`);
685
- }
812
+ console.log("");
686
813
  }
687
- // eslint-disable-next-line no-console
688
- console.log("");
689
814
  }
690
815
  if (copyToClipboardFlag) {
691
816
  if (copyToClipboard(encoded.payload)) {
@@ -702,61 +827,124 @@ async function runAuthExportCi(args) {
702
827
  // eslint-disable-next-line no-console
703
828
  console.log("");
704
829
  }
705
- // eslint-disable-next-line no-console
706
- console.log("1. Add these GitHub secrets (Settings → Secrets and variables → Actions):");
707
- // eslint-disable-next-line no-console
708
- console.log("");
709
- // eslint-disable-next-line no-console
710
- console.log("---");
711
- // eslint-disable-next-line no-console
712
- console.log("Name: QUALTY_ORG_ID");
713
- // eslint-disable-next-line no-console
714
- console.log("Value:");
715
- // eslint-disable-next-line no-console
716
- console.log(orgId);
717
- // eslint-disable-next-line no-console
718
- console.log("---");
719
- // eslint-disable-next-line no-console
720
- console.log("Name: QUALTY_API_TOKEN");
721
- // eslint-disable-next-line no-console
722
- console.log("Value:");
723
- if (includeToken && token) {
830
+ if (setGithubFlag) {
724
831
  // eslint-disable-next-line no-console
725
- console.log(token);
726
- } else if (token) {
832
+ console.log(
833
+ "1. GitHub Actions config was pushed via gh (secrets + repository variables — see sync above). Fix any ✗ manually."
834
+ );
835
+ if (!suiteId) {
836
+ // eslint-disable-next-line no-console
837
+ console.log(
838
+ " Tip: re-run with --suite-id <uuid> to set QUALTY_SUITE_ID (or set it in the GitHub UI)."
839
+ );
840
+ }
727
841
  // eslint-disable-next-line no-console
728
- console.log(`(your qlty_ci_ token — already configured locally as ${maskSecret(token)})`);
842
+ console.log("");
843
+ } else {
729
844
  // eslint-disable-next-line no-console
730
- console.log("Paste the full token from Qualty → Account → API tokens.");
845
+ console.log(
846
+ "1. Add repository variables (or re-run with --set-github from this repo's git root):"
847
+ );
731
848
  // eslint-disable-next-line no-console
732
- console.log("Re-run with --include-token to print the token from this machine (avoid shared screens).");
733
- } else {
849
+ console.log("");
850
+ // eslint-disable-next-line no-console
851
+ console.log("---");
852
+ // eslint-disable-next-line no-console
853
+ console.log("Name: QUALTY_ORG_ID");
854
+ // eslint-disable-next-line no-console
855
+ console.log("Value:");
856
+ // eslint-disable-next-line no-console
857
+ console.log(orgId);
858
+ // eslint-disable-next-line no-console
859
+ console.log(` gh variable set QUALTY_ORG_ID --body "${orgId}"`);
860
+ // eslint-disable-next-line no-console
861
+ console.log("---");
862
+ // eslint-disable-next-line no-console
863
+ console.log("Name: QUALTY_PROJECT_ID");
864
+ // eslint-disable-next-line no-console
865
+ console.log("Value:");
866
+ // eslint-disable-next-line no-console
867
+ console.log(projectId);
868
+ // eslint-disable-next-line no-console
869
+ console.log(` gh variable set QUALTY_PROJECT_ID --body "${projectId}"`);
870
+ // eslint-disable-next-line no-console
871
+ console.log("---");
872
+ // eslint-disable-next-line no-console
873
+ console.log("Name: QUALTY_AUTH_PROFILE");
874
+ // eslint-disable-next-line no-console
875
+ console.log("Value:");
876
+ // eslint-disable-next-line no-console
877
+ console.log(profile);
878
+ // eslint-disable-next-line no-console
879
+ console.log(` gh variable set QUALTY_AUTH_PROFILE --body "${profile}"`);
880
+ // eslint-disable-next-line no-console
881
+ console.log("---");
882
+ // eslint-disable-next-line no-console
883
+ console.log("Name: QUALTY_SUITE_ID");
734
884
  // eslint-disable-next-line no-console
735
- console.log("(create in Qualty → Account → API tokens; must start with qlty_ci_)");
885
+ console.log("Value:");
886
+ // eslint-disable-next-line no-console
887
+ console.log(suiteId || "(suite UUID from Qualty dashboard — pass --suite-id)");
888
+ if (suiteId) {
889
+ // eslint-disable-next-line no-console
890
+ console.log(` gh variable set QUALTY_SUITE_ID --body "${suiteId}"`);
891
+ }
892
+ // eslint-disable-next-line no-console
893
+ console.log("");
894
+ // eslint-disable-next-line no-console
895
+ console.log("2. Add these GitHub secrets:");
736
896
  }
737
- // eslint-disable-next-line no-console
738
- console.log("---");
739
- // eslint-disable-next-line no-console
740
- console.log("Name: QUALTY_AUTH_STATE_B64");
741
- if (skipPayloadPrint) {
897
+ if (!setGithubFlag) {
742
898
  // eslint-disable-next-line no-console
743
- console.log(`Value: (see file above${copyToClipboardFlag ? " or clipboard" : ""}${setSecretFlag ? " or gh secret" : ""})`);
744
- } else {
899
+ console.log("");
900
+ // eslint-disable-next-line no-console
901
+ console.log("---");
902
+ // eslint-disable-next-line no-console
903
+ console.log("Name: QUALTY_API_TOKEN");
745
904
  // eslint-disable-next-line no-console
746
905
  console.log("Value:");
906
+ if (includeToken && token) {
907
+ // eslint-disable-next-line no-console
908
+ console.log(token);
909
+ } else if (token) {
910
+ // eslint-disable-next-line no-console
911
+ console.log(`(your qlty_ci_ token — already configured locally as ${maskSecret(token)})`);
912
+ // eslint-disable-next-line no-console
913
+ console.log("Paste the full token from Qualty → Account → API tokens.");
914
+ // eslint-disable-next-line no-console
915
+ console.log("Re-run with --set-github to push via gh (or --include-token to print).");
916
+ } else {
917
+ // eslint-disable-next-line no-console
918
+ console.log("(create in Qualty → Account → API tokens; must start with qlty_ci_)");
919
+ }
747
920
  // eslint-disable-next-line no-console
748
- console.log(encoded.payload);
921
+ console.log("---");
922
+ // eslint-disable-next-line no-console
923
+ console.log("Name: QUALTY_AUTH_STATE_B64");
924
+ if (skipPayloadPrint) {
925
+ // 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:");
932
+ // eslint-disable-next-line no-console
933
+ console.log(encoded.payload);
934
+ }
935
+ // eslint-disable-next-line no-console
936
+ console.log("---");
937
+ // eslint-disable-next-line no-console
938
+ console.log("");
749
939
  }
940
+
941
+ const stepLabel = setGithubFlag ? "2" : "3";
750
942
  // eslint-disable-next-line no-console
751
- console.log("---");
752
- // eslint-disable-next-line no-console
753
- console.log("");
754
- // eslint-disable-next-line no-console
755
- console.log("3. Paste this workflow snippet before qualty run:");
943
+ console.log(`${stepLabel}. Workflow snippet (see templates/github-actions/qualty-local-pr.yml):`);
756
944
  // eslint-disable-next-line no-console
757
945
  console.log("");
758
946
  // eslint-disable-next-line no-console
759
- console.log(githubAuthRestoreShell({ orgId, projectId, profile, encoding: encoded.encoding }));
947
+ console.log(githubAuthRestoreShell({ orgId, projectId, profile }));
760
948
  // eslint-disable-next-line no-console
761
949
  console.log("");
762
950
  // eslint-disable-next-line no-console
@@ -1113,10 +1301,9 @@ function printQualtyViewLogsReport(executionId, status) {
1113
1301
  }
1114
1302
 
1115
1303
  async function runCi(args) {
1116
- const config = readQualtyConfig();
1117
- const apiUrl = resolveApiUrl(args, config);
1118
- const token = resolveToken(args, config);
1119
- const orgId = resolveOrgId(args, config);
1304
+ const apiUrl = resolveApiUrl(args);
1305
+ const token = resolveToken(args);
1306
+ const orgId = resolveOrgId(args);
1120
1307
  const projectId = args.project;
1121
1308
  const suiteId = args["suite-id"];
1122
1309
  const explicitIds = String(args.ids || "")
@@ -1460,10 +1647,9 @@ async function runLocalSuiteWithSequences({
1460
1647
  }
1461
1648
 
1462
1649
  async function runResolve(args) {
1463
- const config = readQualtyConfig();
1464
- const apiUrl = resolveApiUrl(args, config);
1465
- const token = resolveToken(args, config);
1466
- const orgId = resolveOrgId(args, config);
1650
+ const apiUrl = resolveApiUrl(args);
1651
+ const token = resolveToken(args);
1652
+ const orgId = resolveOrgId(args);
1467
1653
  const projectId = args.project;
1468
1654
  const suiteId = args["suite-id"];
1469
1655
  const explicitIds = String(args.ids || "")
@@ -1626,26 +1812,35 @@ async function maybePromptProjectIdForLocalRun({ args, apiUrl, token, orgId }) {
1626
1812
 
1627
1813
  function resolveLocalAuthStateForRun({ args, orgId, projectId }) {
1628
1814
  if (parseBoolean(args["no-auth-state"], false)) return null;
1629
- const requestedProfile = String(args["auth-profile"] || "").trim();
1630
- const profile = requestedProfile;
1815
+ const projectCfg = readProjectConfig();
1816
+ const profile = authProfileFromConfig(projectCfg, args["auth-profile"]);
1631
1817
  if (!profile) return null;
1632
1818
  const path = localAuthStatePath({ orgId, projectId, profile });
1633
- if (!existsSync(path)) return null;
1819
+ const summary = summarizeStorageStateFile(path);
1820
+ logAuthStateDiagnostics("[qualty][auth]", summary, {
1821
+ profile,
1822
+ source: "pre-run --auth-profile check",
1823
+ });
1824
+ if (!summary.exists) return null;
1634
1825
  return { profile, path };
1635
1826
  }
1636
1827
 
1637
1828
  async function maybePromptAuthProfileForLocalRun({ args, orgId, projectId }) {
1638
- const requestedProfile = String(args["auth-profile"] || "").trim();
1829
+ const projectCfg = readProjectConfig();
1830
+ const requestedProfile = authProfileFromConfig(projectCfg, args["auth-profile"]);
1639
1831
  if (requestedProfile) return { authProfile: requestedProfile, noAuthState: false };
1640
1832
  if (parseBoolean(args["no-auth-state"], false)) return { authProfile: "", noAuthState: true };
1641
- if (!process.stdin.isTTY || !process.stdout.isTTY) return { authProfile: "", noAuthState: false };
1833
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1834
+ const defaultProfile = authProfileFromConfig(projectCfg);
1835
+ if (defaultProfile) return { authProfile: defaultProfile, noAuthState: false };
1836
+ return { authProfile: "", noAuthState: false };
1837
+ }
1642
1838
  if (!projectId) return { authProfile: "", noAuthState: false };
1643
1839
 
1644
1840
  const profiles = listLocalAuthProfiles({ orgId, projectId });
1645
1841
  if (profiles.length === 0) return { authProfile: "", noAuthState: false };
1646
1842
 
1647
- const projectCfg = readProjectConfig();
1648
- const defaultProfile = String(projectCfg.local_auth_profile || "").trim();
1843
+ const defaultProfile = authProfileFromConfig(projectCfg);
1649
1844
  const defaultIndex = profiles.findIndex((p) => p === defaultProfile);
1650
1845
 
1651
1846
  // eslint-disable-next-line no-console
@@ -1687,10 +1882,9 @@ async function maybePromptAuthProfileForLocalRun({ args, orgId, projectId }) {
1687
1882
  }
1688
1883
 
1689
1884
  async function runAuthSetup(args) {
1690
- const config = readQualtyConfig();
1691
- const apiUrl = resolveApiUrl(args, config);
1692
- const token = resolveToken(args, config);
1693
- const orgId = resolveOrgId(args, config);
1885
+ const apiUrl = resolveApiUrl(args);
1886
+ const token = resolveToken(args);
1887
+ const orgId = resolveOrgId(args);
1694
1888
  if (!token) {
1695
1889
  throw new Error("Missing auth token. Run `qualty setup` first.");
1696
1890
  }
@@ -1844,9 +2038,9 @@ async function runAuthSetup(args) {
1844
2038
  await browser.close();
1845
2039
 
1846
2040
  writeProjectConfig({
1847
- local_auth_project_id: project.id,
1848
- local_auth_profile: profile,
1849
2041
  default_org_id: orgId,
2042
+ default_project_id: project.id,
2043
+ default_auth_profile: profile,
1850
2044
  });
1851
2045
  try {
1852
2046
  const synced = await registerLocalLoginProfileBinding({
@@ -1909,11 +2103,10 @@ async function main() {
1909
2103
  throw new Error("Unknown auth subcommand. Use: qualty auth setup | qualty auth export-ci");
1910
2104
  }
1911
2105
  if (command === "run") {
1912
- const config = readQualtyConfig();
1913
2106
  if (parseBoolean(args.local, false)) {
1914
- const apiUrl = resolveApiUrl(args, config);
1915
- const token = resolveToken(args, config);
1916
- const orgId = resolveOrgId(args, config);
2107
+ const apiUrl = resolveApiUrl(args);
2108
+ const token = resolveToken(args);
2109
+ const orgId = resolveOrgId(args);
1917
2110
  const projectId =
1918
2111
  (await maybePromptProjectIdForLocalRun({
1919
2112
  args,
@@ -1949,10 +2142,6 @@ async function main() {
1949
2142
  ...(authChoice.noAuthState ? { "no-auth-state": true } : {}),
1950
2143
  };
1951
2144
  const localAuth = resolveLocalAuthStateForRun({ args: localAuthArgs, orgId, projectId });
1952
- if (localAuth?.path) {
1953
- // eslint-disable-next-line no-console
1954
- console.log(`[qualty][auth] Reusing profile "${localAuth.profile}"`);
1955
- }
1956
2145
  const localRunArgs = {
1957
2146
  apiUrl,
1958
2147
  token,
@@ -0,0 +1,4 @@
1
+ const { chromium } = await import("playwright");
2
+ const s = await chromium.launchServer({ headless: true, host: "127.0.0.1" });
3
+ console.log("launchServer OK");
4
+ await s.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualty",
3
- "version": "0.1.18",
3
+ "version": "0.1.21",
4
4
  "description": "Qualty CLI for localhost and CI test runs",
5
5
  "bin": {
6
6
  "qualty": "bin/qualty.js"