qualty 0.1.20 → 0.1.22

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.
@@ -78,6 +78,25 @@ 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
+
81
100
  export function summarizeStorageStateFile(statePath) {
82
101
  const path = String(statePath || "").trim();
83
102
  if (!path || !existsSync(path)) {
@@ -233,6 +252,31 @@ export async function logAuthStateAfterInjection(tag, context, { testUrl = "" }
233
252
  }
234
253
  }
235
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
+
236
280
  const localBindingSetupPromises = new Map();
237
281
 
238
282
  async function promptYesNo(question, defaultYes = true) {
@@ -340,11 +384,21 @@ async function maybeSetupMissingLocalBinding({
340
384
  ? claim.saved_login_profile
341
385
  : null;
342
386
  if (!profile || !profile.name) return null;
343
- if (profile.has_local_binding) return null;
344
- if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
345
-
346
387
  const profileName = String(profile.name || "").trim();
347
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
+
348
402
  const key = `${String(orgId || "")}:${String(claim?.project_id || "")}:${profileName}`;
349
403
  if (localBindingSetupPromises.has(key)) {
350
404
  return localBindingSetupPromises.get(key);
@@ -398,20 +452,21 @@ async function preflightMissingLocalBindings({
398
452
  }) {
399
453
  if (noAuthState) return;
400
454
  if (explicitAuthProfile) return;
401
- if (!process.stdin.isTTY || !process.stdout.isTTY) return;
402
455
  const missingByProfile = new Map();
403
456
  for (const job of savedJobs || []) {
404
- const useSaved = Boolean(job?.use_saved_login);
457
+ if (!jobUsesSavedLogin(job)) continue;
405
458
  const profileName = String(job?.saved_login_profile?.name || "").trim();
406
- if (!useSaved || !profileName) continue;
459
+ if (!profileName) continue;
460
+ const localProfileName =
461
+ String(job?.saved_login_profile?.local_profile_name || "").trim() || profileName;
407
462
  const statePath = localAuthStatePath({
408
463
  orgId,
409
464
  projectId: String(job?.project_id || projectId || "").trim(),
410
- profile: profileName,
465
+ profile: localProfileName,
411
466
  });
412
467
  if (existsSync(statePath)) continue;
413
468
  const entry = missingByProfile.get(profileName) || {
414
- localProfileName: profileName,
469
+ localProfileName,
415
470
  projectId: String(job?.project_id || projectId || "").trim(),
416
471
  startUrl: rewriteUrlForLocalRun(String(job?.url || "").trim(), localPort),
417
472
  tests: [],
@@ -427,6 +482,13 @@ async function preflightMissingLocalBindings({
427
482
  }
428
483
  if (missingByProfile.size === 0) return;
429
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
+
430
492
  // eslint-disable-next-line no-console
431
493
  console.log("[qualty][auth] Missing local login state for attached dashboard profiles:");
432
494
  for (const [profileName, entry] of missingByProfile.entries()) {
@@ -1228,6 +1290,51 @@ export async function runLocalExecution({
1228
1290
  authProfile,
1229
1291
  noAuthState,
1230
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,
1231
1338
  }) {
1232
1339
  const { chromium } = await import("playwright");
1233
1340
 
@@ -1238,7 +1345,7 @@ export async function runLocalExecution({
1238
1345
  orgId,
1239
1346
  path: `/api/v1/cli/executions/${executionId}/claim`,
1240
1347
  method: "POST",
1241
- body: { device: runDevice },
1348
+ body: device ? { device: String(device).trim() } : {},
1242
1349
  });
1243
1350
  const effectiveDevice = String(claim.device || runDevice).trim() || runDevice;
1244
1351
 
@@ -1267,7 +1374,7 @@ export async function runLocalExecution({
1267
1374
  explicitAuthProfile: String(authProfile || "").trim(),
1268
1375
  noAuthState: Boolean(noAuthState),
1269
1376
  });
1270
- const claimProfile = String(claimSavedLogin?.local_profile_name || "").trim();
1377
+ const claimProfile = localProfileLabel(claimSavedLogin);
1271
1378
  const effectiveProfile = String(authProfile || "").trim() || setupFromClaim?.profile || claimProfile;
1272
1379
  const resolvedStoragePath =
1273
1380
  !noAuthState && effectiveProfile
@@ -1309,12 +1416,22 @@ export async function runLocalExecution({
1309
1416
  });
1310
1417
 
1311
1418
  if (!noAuthState && effectiveProfile && !finalStoragePath) {
1419
+ const profileName = String(claimSavedLogin?.name || effectiveProfile).trim();
1312
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
+ }
1313
1433
  // eslint-disable-next-line no-console
1314
- console.warn(
1315
- `[qualty][auth] profile "${effectiveProfile}" was requested but no auth state file exists` +
1316
- (candidates.length ? ` (checked: ${candidates.join(", ")})` : "")
1317
- );
1434
+ console.warn(`[qualty][auth] ${detail}`);
1318
1435
  }
1319
1436
 
1320
1437
  const browserServer = await chromium.launchServer({
@@ -1379,6 +1496,9 @@ export async function runLocalExecution({
1379
1496
  try {
1380
1497
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
1381
1498
  await page.waitForTimeout(500);
1499
+ if (finalStoragePath) {
1500
+ await logAuthStateAfterNavigation("[qualty][auth]", page);
1501
+ }
1382
1502
 
1383
1503
  for (let stepIndex = 0; stepIndex < totalSteps; stepIndex += 1) {
1384
1504
  const step = steps[stepIndex] || {};
@@ -1544,7 +1664,12 @@ export async function runLocalExecution({
1544
1664
  }),
1545
1665
  });
1546
1666
 
1547
- 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 };
1548
1673
  }
1549
1674
 
1550
1675
  async function fetchProjectLocalPort({ apiRequest, apiUrl, token, orgId, projectId }) {
@@ -1604,6 +1729,10 @@ export async function runLocalCi({
1604
1729
  const started = runs.filter((r) => r.execution_job_id);
1605
1730
  if (started.length === 0) throw new Error("Failed to start any local runs.");
1606
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
+
1607
1736
  await preflightMissingLocalBindings({
1608
1737
  apiRequest,
1609
1738
  apiUrl,
package/bin/qualty.js CHANGED
@@ -12,7 +12,7 @@ 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
18
  import { rewriteUrlForLocalRun, runLocalCi, summarizeStorageStateFile, logAuthStateDiagnostics } from "./local-runner.js";
@@ -20,32 +20,23 @@ import { rewriteUrlForLocalRun, runLocalCi, summarizeStorageStateFile, logAuthSt
20
20
  /** CLI backend URL is fixed by Qualty distribution. */
21
21
  const DEFAULT_QUALTY_API_URL = "https://qualty-api-production.up.railway.app";
22
22
 
23
- const QUALTY_CONFIG_DIR = join(homedir(), ".config", "qualty");
24
- const QUALTY_CONFIG_PATH = join(QUALTY_CONFIG_DIR, "config.json");
23
+ const PROJECT_CONFIG_FILENAME = ".qualty.json";
25
24
  const QUALTY_SHARED_CREDS_DIR = join(homedir(), ".qualty");
26
25
  const QUALTY_SHARED_CREDS_PATH = join(QUALTY_SHARED_CREDS_DIR, "credentials");
27
- const QUALTY_PROJECT_CONFIG_PATH = join(process.cwd(), ".qualty.json");
28
26
 
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 {};
27
+ function findProjectConfigPath() {
28
+ let current = process.cwd();
29
+ for (;;) {
30
+ const candidate = join(current, PROJECT_CONFIG_FILENAME);
31
+ if (existsSync(candidate)) return candidate;
32
+ const parent = dirname(current);
33
+ if (parent === current) return null;
34
+ current = parent;
38
35
  }
39
36
  }
40
37
 
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
- }
38
+ function getProjectConfigWritePath() {
39
+ return findProjectConfigPath() || join(process.cwd(), PROJECT_CONFIG_FILENAME);
49
40
  }
50
41
 
51
42
  function readSharedCredentials() {
@@ -70,10 +61,23 @@ function writeSharedCredentials(token) {
70
61
  }
71
62
  }
72
63
 
64
+ function projectIdFromConfig(projectCfg, fallback = "") {
65
+ return String(fallback || projectCfg.default_project_id || "").trim();
66
+ }
67
+
68
+ function orgIdFromConfig(projectCfg, fallback = "") {
69
+ return String(fallback || projectCfg.default_org_id || "").trim();
70
+ }
71
+
72
+ function authProfileFromConfig(projectCfg, fallback = "") {
73
+ return String(fallback || projectCfg.default_auth_profile || "").trim();
74
+ }
75
+
73
76
  function readProjectConfig() {
74
- if (!existsSync(QUALTY_PROJECT_CONFIG_PATH)) return {};
77
+ const configPath = findProjectConfigPath();
78
+ if (!configPath) return {};
75
79
  try {
76
- const raw = readFileSync(QUALTY_PROJECT_CONFIG_PATH, "utf8");
80
+ const raw = readFileSync(configPath, "utf8");
77
81
  const parsed = JSON.parse(raw);
78
82
  if (!parsed || typeof parsed !== "object") return {};
79
83
  return parsed;
@@ -83,29 +87,59 @@ function readProjectConfig() {
83
87
  }
84
88
 
85
89
  function writeProjectConfig(updates) {
90
+ const configPath = getProjectConfigWritePath();
86
91
  const current = readProjectConfig();
87
92
  const next = { ...current };
88
93
  for (const [k, v] of Object.entries(updates || {})) {
89
94
  if (v === undefined || v === null || v === "") delete next[k];
90
95
  else next[k] = v;
91
96
  }
92
- writeFileSync(QUALTY_PROJECT_CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`, "utf8");
97
+ writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
93
98
  }
94
99
 
95
- function resolveApiUrl(args, config = {}) {
96
- void args;
97
- void config;
98
- return String(DEFAULT_QUALTY_API_URL).replace(/\/$/, "");
100
+ function normalizeApiUrl(raw, { label = "API URL" } = {}) {
101
+ const trimmed = String(raw || "").trim();
102
+ if (!trimmed) {
103
+ throw new Error(`${label} is empty.`);
104
+ }
105
+ let parsed;
106
+ try {
107
+ parsed = new URL(trimmed);
108
+ } catch {
109
+ throw new Error(`${label} is not a valid URL: ${trimmed}`);
110
+ }
111
+ if (!["http:", "https:"].includes(parsed.protocol)) {
112
+ throw new Error(`${label} must use http or https (got ${parsed.protocol}).`);
113
+ }
114
+ return trimmed.replace(/\/$/, "");
115
+ }
116
+
117
+ function qualtyBaseUrlFromEnv() {
118
+ return String(process.env.QUALTY_BASE_URL || process.env.QUALTY_API_URL || "").trim();
119
+ }
120
+
121
+ function resolveApiUrl(args = {}) {
122
+ const fromFlag = String(args.url || args["api-url"] || "").trim();
123
+ const fromEnv = qualtyBaseUrlFromEnv();
124
+ const raw = fromFlag || fromEnv || DEFAULT_QUALTY_API_URL;
125
+ const label = fromFlag
126
+ ? "--url"
127
+ : process.env.QUALTY_BASE_URL
128
+ ? "QUALTY_BASE_URL"
129
+ : fromEnv
130
+ ? "QUALTY_API_URL"
131
+ : "default API URL";
132
+ return normalizeApiUrl(raw, { label });
99
133
  }
100
134
 
101
- function resolveToken(args, config = {}) {
135
+ function resolveToken(args = {}) {
102
136
  const creds = readSharedCredentials();
103
- return args.token || process.env.QUALTY_API_TOKEN || creds.api_token || config.api_token || "";
137
+ return args.token || process.env.QUALTY_API_TOKEN || creds.api_token || "";
104
138
  }
105
139
 
106
- function resolveOrgId(args, config = {}) {
140
+ function resolveOrgId(args = {}) {
107
141
  const projectCfg = readProjectConfig();
108
- return args.org || process.env.QUALTY_ORG_ID || config.org_id || projectCfg.default_org_id || "";
142
+ return orgIdFromConfig(projectCfg, args.org);
109
143
  }
110
144
 
111
145
  function maskSecret(secret) {
@@ -144,8 +178,11 @@ function usage() {
144
178
  "Usage:",
145
179
  " qualty setup [--token <bearer-token>] [--org <org-id>]",
146
180
  " qualty auth setup [--project <project-id>] [--profile <profile-name>] [--headed]",
147
- " qualty auth export-ci --project <project-id> [--profile <profile-name>] [--org <org-id>]",
148
- " [--output <path>] [--no-file] [--copy] [--set-secret] [--gzip] [--include-token]",
181
+ " qualty auth export-ci [--project <project-id>] [--profile <profile-name>] [--org <org-id>]",
182
+ " [--url <api-base-url>] [--suite-id <suite-uuid>] [--repo <owner/repo>]",
183
+ " [--output <path>] [--no-file] [--copy] [--gzip] [--include-token]",
184
+ " [--set-secret | --set-github] gh: secrets (token, API URL, auth) + variables (org, project, profile, …)",
185
+ " [--cli-repo <owner/repo>] [--cli-ref <branch>] Optional CI variables",
149
186
  " qualty connect --project <project-id> [--port 3000] [--token <bearer-token>] [--org <org-id>]",
150
187
  " qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--token <bearer-token>] [--org <org-id>]",
151
188
  " [--poll-interval 5] [--timeout 30] [--fail-on-failure true] [--no-view-logs]",
@@ -157,8 +194,11 @@ function usage() {
157
194
  "",
158
195
  "Env vars:",
159
196
  " QUALTY_API_TOKEN Bearer token used for auth",
160
- " QUALTY_ORG_ID Org context for scoped API requests",
197
+ " QUALTY_BASE_URL API base URL when --url is not set (default: production)",
161
198
  " QUALTY_LOCAL_CONCURRENCY Local parallel workers for --local runs (default: 4)",
199
+ "",
200
+ "Config (.qualty.json): default_org_id, default_project_id, default_auth_profile",
201
+ " CLI flags (--org, --project, --profile, --url) override config values.",
162
202
  ].join("\n")
163
203
  );
164
204
  }
@@ -228,12 +268,11 @@ function startCloudflared(token, port) {
228
268
  }
229
269
 
230
270
  async function runConnect(args) {
231
- const config = readQualtyConfig();
232
271
  const projectId = args.project;
233
272
  const port = Number(args.port || 3000);
234
- const apiUrl = resolveApiUrl(args, config);
235
- const token = resolveToken(args, config);
236
- const orgId = resolveOrgId(args, config);
273
+ const apiUrl = resolveApiUrl(args);
274
+ const token = resolveToken(args);
275
+ const orgId = resolveOrgId(args);
237
276
 
238
277
  if (!projectId || !token) {
239
278
  usage();
@@ -305,7 +344,6 @@ async function runConnect(args) {
305
344
  }
306
345
 
307
346
  async function runSetup(args) {
308
- const existing = readQualtyConfig();
309
347
  const sharedCreds = readSharedCredentials();
310
348
  const projectCfg = readProjectConfig();
311
349
  const rl = createInterface({
@@ -341,8 +379,10 @@ async function runSetup(args) {
341
379
  };
342
380
 
343
381
  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 || "");
382
+ const currentOrgId = String(args.org || projectCfg.default_org_id || "");
383
+ const currentToken = String(
384
+ args.token || process.env.QUALTY_API_TOKEN || sharedCreds.api_token || ""
385
+ );
346
386
 
347
387
  let token = "";
348
388
  if (args.token) {
@@ -366,7 +406,7 @@ async function runSetup(args) {
366
406
  let currentOrgName = "";
367
407
  try {
368
408
  const orgResp = await apiRequest({
369
- apiUrl: resolveApiUrl({}, {}),
409
+ apiUrl: resolveApiUrl({}),
370
410
  token,
371
411
  orgId: "",
372
412
  path: "/api/v1/orgs",
@@ -435,23 +475,16 @@ async function runSetup(args) {
435
475
  }
436
476
 
437
477
  if (!orgId) {
438
- throw new Error("Org ID is required. Re-run setup and provide QUALTY_ORG_ID.");
478
+ throw new Error("Org ID is required. Re-run setup and enter your org ID when prompted.");
439
479
  }
440
480
 
441
- const next = {
442
- org_id: orgId,
443
- };
444
- // Keep token in the same place MCP uses.
445
481
  writeSharedCredentials(token);
446
- writeQualtyConfig(next);
447
- // Keep org in MCP-compatible per-project config too.
448
482
  writeProjectConfig({ default_org_id: orgId });
449
- // eslint-disable-next-line no-console
450
- console.log(`[qualty] Saved config to ${QUALTY_CONFIG_PATH}`);
483
+ const projectConfigPath = getProjectConfigWritePath();
451
484
  // eslint-disable-next-line no-console
452
485
  console.log(`[qualty] Saved shared token to ${QUALTY_SHARED_CREDS_PATH} (used by MCP)`);
453
486
  // eslint-disable-next-line no-console
454
- console.log(`[qualty] Saved project org to ${QUALTY_PROJECT_CONFIG_PATH} (MCP-compatible)`);
487
+ console.log(`[qualty] Saved project config to ${projectConfigPath} (MCP-compatible)`);
455
488
  // eslint-disable-next-line no-console
456
489
  console.log(`[qualty] Org: ${orgName || "configured"}`);
457
490
  // eslint-disable-next-line no-console
@@ -524,8 +557,17 @@ function copyToClipboard(text) {
524
557
  return false;
525
558
  }
526
559
 
527
- function setGithubSecret(name, value) {
528
- const result = spawnSync("gh", ["secret", "set", name], { input: value, encoding: "utf8" });
560
+ function ghRepoArgs(repo) {
561
+ const target = String(repo || "").trim();
562
+ return target ? ["--repo", target] : [];
563
+ }
564
+
565
+ function setGithubSecret(name, value, { repo } = {}) {
566
+ const result = spawnSync(
567
+ "gh",
568
+ ["secret", "set", name, ...ghRepoArgs(repo)],
569
+ { input: value, encoding: "utf8" }
570
+ );
529
571
  if (result.error?.code === "ENOENT") {
530
572
  return { ok: false, reason: "GitHub CLI (gh) is not installed." };
531
573
  }
@@ -536,6 +578,234 @@ function setGithubSecret(name, value) {
536
578
  return { ok: true };
537
579
  }
538
580
 
581
+ function setGithubVariable(name, value, { repo } = {}) {
582
+ const result = spawnSync("gh", ["variable", "set", name, "--body", value, ...ghRepoArgs(repo)], {
583
+ encoding: "utf8",
584
+ });
585
+ if (result.error?.code === "ENOENT") {
586
+ return { ok: false, reason: "GitHub CLI (gh) is not installed." };
587
+ }
588
+ if (result.status !== 0) {
589
+ const detail = String(result.stderr || result.stdout || "").trim();
590
+ return { ok: false, reason: detail || `gh variable set failed (exit ${result.status})` };
591
+ }
592
+ return { ok: true };
593
+ }
594
+
595
+ /**
596
+ * Push Qualty CI secrets + repository variables via gh (run from target app repo root).
597
+ */
598
+ function syncGithubCiConfig({
599
+ repo,
600
+ orgId,
601
+ projectId,
602
+ profile,
603
+ authPayload,
604
+ apiUrl,
605
+ token,
606
+ suiteId,
607
+ cliRepo,
608
+ cliRef,
609
+ }) {
610
+ const results = [];
611
+ const record = (kind, name, outcome) => {
612
+ results.push({ kind, name, ...outcome });
613
+ };
614
+
615
+ const authResult = setGithubSecret("QUALTY_AUTH_STATE_B64", authPayload, { repo });
616
+ record("secret", "QUALTY_AUTH_STATE_B64", authResult);
617
+
618
+ if (token) {
619
+ record("secret", "QUALTY_API_TOKEN", setGithubSecret("QUALTY_API_TOKEN", token, { repo }));
620
+ } else {
621
+ record("secret", "QUALTY_API_TOKEN", {
622
+ ok: false,
623
+ reason: "No token locally — run qualty setup or set QUALTY_API_TOKEN.",
624
+ });
625
+ }
626
+
627
+ const apiUrlNorm = String(apiUrl || "").replace(/\/$/, "");
628
+ if (apiUrlNorm) {
629
+ record("secret", "QUALTY_BASE_URL", setGithubSecret("QUALTY_BASE_URL", apiUrlNorm, { repo }));
630
+ }
631
+
632
+ record("variable", "QUALTY_ORG_ID", setGithubVariable("QUALTY_ORG_ID", orgId, { repo }));
633
+ record("variable", "QUALTY_PROJECT_ID", setGithubVariable("QUALTY_PROJECT_ID", String(projectId), { repo }));
634
+ record("variable", "QUALTY_AUTH_PROFILE", setGithubVariable("QUALTY_AUTH_PROFILE", profile, { repo }));
635
+
636
+ if (suiteId) {
637
+ record("variable", "QUALTY_SUITE_ID", setGithubVariable("QUALTY_SUITE_ID", String(suiteId), { repo }));
638
+ }
639
+
640
+ if (cliRepo) {
641
+ record("variable", "QUALTY_CLI_REPO", setGithubVariable("QUALTY_CLI_REPO", cliRepo, { repo }));
642
+ }
643
+ if (cliRef) {
644
+ record("variable", "QUALTY_CLI_REF", setGithubVariable("QUALTY_CLI_REF", cliRef, { repo }));
645
+ }
646
+
647
+ return results;
648
+ }
649
+
650
+ function printGithubSyncResults(results, { repo }) {
651
+ const target = repo ? ` (repo ${repo})` : "";
652
+ const printGroup = (title, rows) => {
653
+ if (!rows.length) return;
654
+ // eslint-disable-next-line no-console
655
+ console.log(title);
656
+ for (const row of rows) {
657
+ if (row.ok) {
658
+ // eslint-disable-next-line no-console
659
+ console.log(` ✓ ${row.name}`);
660
+ } else {
661
+ // eslint-disable-next-line no-console
662
+ console.log(` ✗ ${row.name} — ${row.reason || "failed"}`);
663
+ }
664
+ }
665
+ };
666
+
667
+ // eslint-disable-next-line no-console
668
+ console.log(`GitHub sync${target} (gh secret set + gh variable set):`);
669
+ printGroup(
670
+ " Repository secrets:",
671
+ results.filter((r) => r.kind === "secret")
672
+ );
673
+ printGroup(
674
+ " Repository variables:",
675
+ results.filter((r) => r.kind === "variable")
676
+ );
677
+ // eslint-disable-next-line no-console
678
+ console.log("");
679
+ }
680
+
681
+ function readProjectConfigStrict() {
682
+ const configPath = findProjectConfigPath();
683
+ if (!configPath) {
684
+ return { configPath: null, config: {} };
685
+ }
686
+ let raw;
687
+ try {
688
+ raw = readFileSync(configPath, "utf8");
689
+ } catch (err) {
690
+ throw new Error(
691
+ `Could not read ${PROJECT_CONFIG_FILENAME} at ${configPath}: ${err.message}`
692
+ );
693
+ }
694
+ let parsed;
695
+ try {
696
+ parsed = JSON.parse(raw);
697
+ } catch (err) {
698
+ throw new Error(
699
+ [
700
+ `Invalid ${PROJECT_CONFIG_FILENAME} at ${configPath}: ${err.message}`,
701
+ "Fix the JSON syntax or pass --org, --project, and --profile on the command line.",
702
+ ].join("\n")
703
+ );
704
+ }
705
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
706
+ throw new Error(
707
+ [
708
+ `Invalid ${PROJECT_CONFIG_FILENAME} at ${configPath}: expected a JSON object.`,
709
+ "Fix the file or pass --org, --project, and --profile on the command line.",
710
+ ].join("\n")
711
+ );
712
+ }
713
+ return { configPath, config: parsed };
714
+ }
715
+
716
+ function configStringValue(projectCfg, key, configPath) {
717
+ const value = projectCfg[key];
718
+ if (value === undefined || value === null || value === "") return "";
719
+ if (typeof value === "string" || typeof value === "number") {
720
+ return String(value).trim();
721
+ }
722
+ const where = configPath
723
+ ? `${PROJECT_CONFIG_FILENAME} at ${configPath}`
724
+ : PROJECT_CONFIG_FILENAME;
725
+ throw new Error(
726
+ `Invalid ${key} in ${where}: expected a string or number, got ${typeof value}.`
727
+ );
728
+ }
729
+
730
+ function missingExportCiField(fieldLabel, { flag, configKey, configPath, hasFlag }) {
731
+ const configHint = configPath
732
+ ? `${PROJECT_CONFIG_FILENAME} at ${configPath} (${configKey})`
733
+ : `${PROJECT_CONFIG_FILENAME} (not found walking up from ${process.cwd()})`;
734
+ if (hasFlag) {
735
+ return [
736
+ `Missing ${fieldLabel}: ${flag} was empty.`,
737
+ `Set ${configKey} in ${configHint} or pass a non-empty ${flag}.`,
738
+ ].join(" ");
739
+ }
740
+ return [
741
+ `Missing ${fieldLabel}.`,
742
+ `Pass ${flag} or set ${configKey} in ${configHint}.`,
743
+ ].join(" ");
744
+ }
745
+
746
+ function resolveExportCiConfig(args) {
747
+ const { configPath, config: projectCfg } = readProjectConfigStrict();
748
+
749
+ const orgFromFlag = String(args.org || "").trim();
750
+ const projectFromFlag = String(args.project || "").trim();
751
+ const profileFromFlag = String(args.profile || args["auth-profile"] || "").trim();
752
+
753
+ let orgId = orgFromFlag;
754
+ if (!orgId) {
755
+ orgId = configStringValue(projectCfg, "default_org_id", configPath);
756
+ }
757
+
758
+ let projectId = projectFromFlag;
759
+ if (!projectId) {
760
+ projectId = configStringValue(projectCfg, "default_project_id", configPath);
761
+ }
762
+
763
+ let profile = profileFromFlag;
764
+ if (!profile) {
765
+ profile = configStringValue(projectCfg, "default_auth_profile", configPath);
766
+ }
767
+
768
+ if (!orgId) {
769
+ throw new Error(
770
+ missingExportCiField("org ID", {
771
+ flag: "--org",
772
+ configKey: "default_org_id",
773
+ configPath,
774
+ hasFlag: Boolean(orgFromFlag),
775
+ })
776
+ );
777
+ }
778
+ if (!projectId) {
779
+ throw new Error(
780
+ missingExportCiField("project ID", {
781
+ flag: "--project",
782
+ configKey: "default_project_id",
783
+ configPath,
784
+ hasFlag: Boolean(projectFromFlag),
785
+ })
786
+ );
787
+ }
788
+ if (!profile) {
789
+ throw new Error(
790
+ missingExportCiField("auth profile", {
791
+ flag: "--profile",
792
+ configKey: "default_auth_profile",
793
+ configPath,
794
+ hasFlag: Boolean(profileFromFlag),
795
+ })
796
+ );
797
+ }
798
+
799
+ let apiUrl;
800
+ try {
801
+ apiUrl = resolveApiUrl(args);
802
+ } catch (err) {
803
+ throw new Error(`Invalid API URL: ${err.message}`);
804
+ }
805
+
806
+ return { orgId, projectId, profile, apiUrl, configPath };
807
+ }
808
+
539
809
  function encodeAuthStateForGithubSecret(statePath, { gzip = false } = {}) {
540
810
  const raw = readFileSync(statePath);
541
811
  if (gzip) {
@@ -552,74 +822,81 @@ function encodeAuthStateForGithubSecret(statePath, { gzip = false } = {}) {
552
822
  };
553
823
  }
554
824
 
555
- function githubAuthRestoreShell({ orgId, projectId, profile, encoding }) {
556
- const orgSeg = safePathSegment(orgId, "default-org");
557
- const projectSeg = safePathSegment(projectId, "default-project");
558
- 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';
825
+ function githubAuthRestoreShell({ orgId, projectId, profile }) {
563
826
  return [
564
827
  `- name: Restore Qualty login state`,
565
828
  ` env:`,
566
829
  ` QUALTY_AUTH_STATE_B64: \${{ secrets.QUALTY_AUTH_STATE_B64 }}`,
567
830
  ` run: |`,
568
- ` AUTH_DIR="$HOME/.qualty/local-contexts/${orgSeg}/${projectSeg}"`,
569
- ` mkdir -p "$AUTH_DIR"`,
570
- ` ${decodeCmd} > "$AUTH_DIR/${profileSeg}.json"`,
571
- ` test -s "$AUTH_DIR/${profileSeg}.json"`,
831
+ ` set -euo pipefail`,
832
+ ` slug_segment() {`,
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"`,
572
843
  ``,
573
- `- name: Run Qualty (local)`,
844
+ `- name: Run Qualty tests (local)`,
574
845
  ` env:`,
575
846
  ` QUALTY_API_TOKEN: \${{ secrets.QUALTY_API_TOKEN }}`,
576
- ` QUALTY_ORG_ID: \${{ secrets.QUALTY_ORG_ID }}`,
847
+ ` QUALTY_BASE_URL: \${{ secrets.QUALTY_BASE_URL }}`,
577
848
  ` run: |`,
578
- ` npx qualty@latest run --local \\`,
579
- ` --project ${projectId} \\`,
580
- ` --suite-id YOUR_SUITE_UUID \\`,
581
- ` --auth-profile "${profile}" \\`,
849
+ ` npx --yes qualty run --local \\`,
850
+ ` --org "$QUALTY_ORG_ID" \\`,
851
+ ` --project "$QUALTY_PROJECT_ID" \\`,
852
+ ` --suite-id "YOUR-SUITE-UUID" \\`,
853
+ ` --auth-profile "$QUALTY_AUTH_PROFILE" \\`,
582
854
  ` --fail-on-failure true`,
583
855
  ].join("\n");
584
856
  }
585
857
 
586
858
  async function runAuthExportCi(args) {
587
- const config = readQualtyConfig();
588
- const orgId = resolveOrgId(args, config);
589
- const token = resolveToken(args, config);
590
- 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();
593
-
594
- if (!orgId) {
595
- throw new Error("Missing org. Pass --org, set QUALTY_ORG_ID, or add default_org_id to .qualty.json.");
596
- }
597
- if (!projectId) {
598
- throw new Error("Missing project. Pass --project (or run qualty auth setup first).");
599
- }
600
- if (!profile) {
601
- throw new Error('Missing profile. Pass --profile "your-profile" (or run qualty auth setup first).');
602
- }
859
+ const { orgId, projectId, profile, apiUrl, configPath } = resolveExportCiConfig(args);
860
+ const token = resolveToken(args);
603
861
 
604
862
  const statePath = localAuthStatePath({ orgId, projectId, profile });
605
863
  if (!existsSync(statePath)) {
606
864
  throw new Error(
607
865
  [
608
- `Local auth state not found:`,
866
+ "Local auth state not found:",
609
867
  ` ${statePath}`,
610
868
  "",
611
869
  "Create it first:",
612
870
  ` qualty auth setup --project ${projectId} --profile "${profile}"`,
613
- ].join("\n")
871
+ configPath ? ` (org/project/profile from ${configPath})` : "",
872
+ ]
873
+ .filter(Boolean)
874
+ .join("\n")
614
875
  );
615
876
  }
616
877
 
617
- let gzip = parseBoolean(args.gzip, false);
618
- let encoded = encodeAuthStateForGithubSecret(statePath, { gzip });
878
+ const setGithubFlag =
879
+ parseBoolean(args["set-github"], false) || parseBoolean(args["set-secret"], false);
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) {
886
+ throw new Error(
887
+ `Could not read auth state at ${statePath}: ${err.message}`
888
+ );
889
+ }
619
890
  let secretBytes = Buffer.byteLength(encoded.payload, "utf8");
620
891
  if (!gzip && secretBytes > GITHUB_SECRET_MAX_BYTES - 1024) {
621
892
  gzip = true;
622
- encoded = encodeAuthStateForGithubSecret(statePath, { gzip: true });
893
+ try {
894
+ encoded = encodeAuthStateForGithubSecret(statePath, { gzip: true });
895
+ } catch (err) {
896
+ throw new Error(
897
+ `Could not gzip auth state at ${statePath}: ${err.message}`
898
+ );
899
+ }
623
900
  secretBytes = Buffer.byteLength(encoded.payload, "utf8");
624
901
  }
625
902
  if (secretBytes > GITHUB_SECRET_MAX_BYTES - 1024) {
@@ -634,7 +911,11 @@ async function runAuthExportCi(args) {
634
911
  ? String(args.output || "").trim()
635
912
  : String(args.output || "").trim() || authExportCiOutputPath(statePath);
636
913
  const copyToClipboardFlag = parseBoolean(args.copy, false);
637
- const setSecretFlag = parseBoolean(args["set-secret"], false);
914
+ const githubRepo = String(args.repo || "").trim();
915
+ const suiteId = String(args["suite-id"] || args.suite || "").trim();
916
+ const cliRepo = String(args["cli-repo"] || "").trim();
917
+ const cliRef = String(args["cli-ref"] || "").trim();
918
+
638
919
  if (outputPath) {
639
920
  writeFileSync(outputPath, encoded.payload, "utf8");
640
921
  try {
@@ -646,12 +927,12 @@ async function runAuthExportCi(args) {
646
927
 
647
928
  const includeToken = parseBoolean(args["include-token"], false);
648
929
  const profileSeg = safePathSegment(profile, "default");
649
- const skipPayloadPrint = Boolean(outputPath || copyToClipboardFlag || setSecretFlag);
930
+ const skipPayloadPrint = Boolean(outputPath || copyToClipboardFlag || setGithubFlag);
650
931
 
651
932
  // eslint-disable-next-line no-console
652
933
  console.log("");
653
934
  // eslint-disable-next-line no-console
654
- console.log("=== Qualty → GitHub Actions secrets ===");
935
+ console.log("=== Qualty → GitHub Actions (secrets & variables) ===");
655
936
  // eslint-disable-next-line no-console
656
937
  console.log("");
657
938
  if (encoded.encoding === "gzip+base64") {
@@ -671,21 +952,27 @@ async function runAuthExportCi(args) {
671
952
  // eslint-disable-next-line no-console
672
953
  console.log("");
673
954
  }
674
- if (setSecretFlag) {
675
- const result = setGithubSecret("QUALTY_AUTH_STATE_B64", encoded.payload);
676
- if (result.ok) {
955
+ if (setGithubFlag) {
956
+ const syncResults = syncGithubCiConfig({
957
+ repo: githubRepo,
958
+ orgId,
959
+ projectId,
960
+ profile,
961
+ authPayload: encoded.payload,
962
+ apiUrl,
963
+ token,
964
+ suiteId,
965
+ cliRepo,
966
+ cliRef,
967
+ });
968
+ printGithubSyncResults(syncResults, { repo: githubRepo });
969
+ const authRow = syncResults.find((r) => r.name === "QUALTY_AUTH_STATE_B64");
970
+ if (!authRow?.ok && outputPath) {
677
971
  // eslint-disable-next-line no-console
678
- console.log("✓ Set GitHub secret QUALTY_AUTH_STATE_B64 via gh CLI.");
679
- } else {
972
+ console.log(` Manual auth secret: gh secret set QUALTY_AUTH_STATE_B64 < ${outputPath}`);
680
973
  // 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
- }
974
+ console.log("");
686
975
  }
687
- // eslint-disable-next-line no-console
688
- console.log("");
689
976
  }
690
977
  if (copyToClipboardFlag) {
691
978
  if (copyToClipboard(encoded.payload)) {
@@ -702,61 +989,124 @@ async function runAuthExportCi(args) {
702
989
  // eslint-disable-next-line no-console
703
990
  console.log("");
704
991
  }
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) {
992
+ if (setGithubFlag) {
993
+ // eslint-disable-next-line no-console
994
+ console.log(
995
+ "1. GitHub Actions config was pushed via gh (secrets + repository variables — see sync above). Fix any ✗ manually."
996
+ );
997
+ if (!suiteId) {
998
+ // eslint-disable-next-line no-console
999
+ console.log(
1000
+ " Tip: hardcode --suite-id or --ids in your workflow (see templates/github-actions/qualty-local-pr.yml)."
1001
+ );
1002
+ }
1003
+ // eslint-disable-next-line no-console
1004
+ console.log("");
1005
+ } else {
1006
+ // eslint-disable-next-line no-console
1007
+ console.log(
1008
+ "1. Add repository variables (or re-run with --set-github from this repo's git root):"
1009
+ );
724
1010
  // eslint-disable-next-line no-console
725
- console.log(token);
726
- } else if (token) {
1011
+ console.log("");
727
1012
  // eslint-disable-next-line no-console
728
- console.log(`(your qlty_ci_ token — already configured locally as ${maskSecret(token)})`);
1013
+ console.log("---");
729
1014
  // eslint-disable-next-line no-console
730
- console.log("Paste the full token from Qualty → Account → API tokens.");
1015
+ console.log("Name: QUALTY_ORG_ID");
731
1016
  // 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 {
1017
+ console.log("Value:");
1018
+ // eslint-disable-next-line no-console
1019
+ console.log(orgId);
1020
+ // eslint-disable-next-line no-console
1021
+ console.log(` gh variable set QUALTY_ORG_ID --body "${orgId}"`);
1022
+ // eslint-disable-next-line no-console
1023
+ console.log("---");
1024
+ // eslint-disable-next-line no-console
1025
+ console.log("Name: QUALTY_PROJECT_ID");
1026
+ // eslint-disable-next-line no-console
1027
+ console.log("Value:");
1028
+ // eslint-disable-next-line no-console
1029
+ console.log(projectId);
1030
+ // eslint-disable-next-line no-console
1031
+ console.log(` gh variable set QUALTY_PROJECT_ID --body "${projectId}"`);
1032
+ // eslint-disable-next-line no-console
1033
+ console.log("---");
734
1034
  // eslint-disable-next-line no-console
735
- console.log("(create in Qualty → Account → API tokens; must start with qlty_ci_)");
1035
+ console.log("Name: QUALTY_AUTH_PROFILE");
1036
+ // eslint-disable-next-line no-console
1037
+ console.log("Value:");
1038
+ // eslint-disable-next-line no-console
1039
+ console.log(profile);
1040
+ // eslint-disable-next-line no-console
1041
+ console.log(` gh variable set QUALTY_AUTH_PROFILE --body "${profile}"`);
1042
+ // eslint-disable-next-line no-console
1043
+ console.log("---");
1044
+ // eslint-disable-next-line no-console
1045
+ console.log("Name: QUALTY_SUITE_ID");
1046
+ // eslint-disable-next-line no-console
1047
+ console.log("Value:");
1048
+ // eslint-disable-next-line no-console
1049
+ console.log(suiteId || "(suite UUID from Qualty dashboard — pass --suite-id)");
1050
+ if (suiteId) {
1051
+ // eslint-disable-next-line no-console
1052
+ console.log(` gh variable set QUALTY_SUITE_ID --body "${suiteId}"`);
1053
+ }
1054
+ // eslint-disable-next-line no-console
1055
+ console.log("");
1056
+ // eslint-disable-next-line no-console
1057
+ console.log("2. Add these GitHub secrets:");
736
1058
  }
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) {
1059
+ if (!setGithubFlag) {
742
1060
  // eslint-disable-next-line no-console
743
- console.log(`Value: (see file above${copyToClipboardFlag ? " or clipboard" : ""}${setSecretFlag ? " or gh secret" : ""})`);
744
- } else {
1061
+ console.log("");
1062
+ // eslint-disable-next-line no-console
1063
+ console.log("---");
1064
+ // eslint-disable-next-line no-console
1065
+ console.log("Name: QUALTY_API_TOKEN");
745
1066
  // eslint-disable-next-line no-console
746
1067
  console.log("Value:");
1068
+ if (includeToken && token) {
1069
+ // eslint-disable-next-line no-console
1070
+ console.log(token);
1071
+ } else if (token) {
1072
+ // eslint-disable-next-line no-console
1073
+ console.log(`(your qlty_ci_ token — already configured locally as ${maskSecret(token)})`);
1074
+ // eslint-disable-next-line no-console
1075
+ console.log("Paste the full token from Qualty → Account → API tokens.");
1076
+ // eslint-disable-next-line no-console
1077
+ console.log("Re-run with --set-github to push via gh (or --include-token to print).");
1078
+ } else {
1079
+ // eslint-disable-next-line no-console
1080
+ console.log("(create in Qualty → Account → API tokens; must start with qlty_ci_)");
1081
+ }
1082
+ // eslint-disable-next-line no-console
1083
+ console.log("---");
747
1084
  // eslint-disable-next-line no-console
748
- console.log(encoded.payload);
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 {
1092
+ // eslint-disable-next-line no-console
1093
+ console.log("Value:");
1094
+ // eslint-disable-next-line no-console
1095
+ console.log(encoded.payload);
1096
+ }
1097
+ // eslint-disable-next-line no-console
1098
+ console.log("---");
1099
+ // eslint-disable-next-line no-console
1100
+ console.log("");
749
1101
  }
1102
+
1103
+ const stepLabel = setGithubFlag ? "2" : "3";
750
1104
  // 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:");
1105
+ console.log(`${stepLabel}. Workflow snippet (see templates/github-actions/qualty-local-pr.yml):`);
756
1106
  // eslint-disable-next-line no-console
757
1107
  console.log("");
758
1108
  // eslint-disable-next-line no-console
759
- console.log(githubAuthRestoreShell({ orgId, projectId, profile, encoding: encoded.encoding }));
1109
+ console.log(githubAuthRestoreShell({ orgId, projectId, profile }));
760
1110
  // eslint-disable-next-line no-console
761
1111
  console.log("");
762
1112
  // eslint-disable-next-line no-console
@@ -1113,10 +1463,9 @@ function printQualtyViewLogsReport(executionId, status) {
1113
1463
  }
1114
1464
 
1115
1465
  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);
1466
+ const apiUrl = resolveApiUrl(args);
1467
+ const token = resolveToken(args);
1468
+ const orgId = resolveOrgId(args);
1120
1469
  const projectId = args.project;
1121
1470
  const suiteId = args["suite-id"];
1122
1471
  const explicitIds = String(args.ids || "")
@@ -1460,10 +1809,9 @@ async function runLocalSuiteWithSequences({
1460
1809
  }
1461
1810
 
1462
1811
  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);
1812
+ const apiUrl = resolveApiUrl(args);
1813
+ const token = resolveToken(args);
1814
+ const orgId = resolveOrgId(args);
1467
1815
  const projectId = args.project;
1468
1816
  const suiteId = args["suite-id"];
1469
1817
  const explicitIds = String(args.ids || "")
@@ -1626,8 +1974,8 @@ async function maybePromptProjectIdForLocalRun({ args, apiUrl, token, orgId }) {
1626
1974
 
1627
1975
  function resolveLocalAuthStateForRun({ args, orgId, projectId }) {
1628
1976
  if (parseBoolean(args["no-auth-state"], false)) return null;
1629
- const requestedProfile = String(args["auth-profile"] || "").trim();
1630
- const profile = requestedProfile;
1977
+ const projectCfg = readProjectConfig();
1978
+ const profile = authProfileFromConfig(projectCfg, args["auth-profile"]);
1631
1979
  if (!profile) return null;
1632
1980
  const path = localAuthStatePath({ orgId, projectId, profile });
1633
1981
  const summary = summarizeStorageStateFile(path);
@@ -1640,17 +1988,21 @@ function resolveLocalAuthStateForRun({ args, orgId, projectId }) {
1640
1988
  }
1641
1989
 
1642
1990
  async function maybePromptAuthProfileForLocalRun({ args, orgId, projectId }) {
1643
- const requestedProfile = String(args["auth-profile"] || "").trim();
1991
+ const projectCfg = readProjectConfig();
1992
+ const requestedProfile = authProfileFromConfig(projectCfg, args["auth-profile"]);
1644
1993
  if (requestedProfile) return { authProfile: requestedProfile, noAuthState: false };
1645
1994
  if (parseBoolean(args["no-auth-state"], false)) return { authProfile: "", noAuthState: true };
1646
- if (!process.stdin.isTTY || !process.stdout.isTTY) return { authProfile: "", noAuthState: false };
1995
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1996
+ const defaultProfile = authProfileFromConfig(projectCfg);
1997
+ if (defaultProfile) return { authProfile: defaultProfile, noAuthState: false };
1998
+ return { authProfile: "", noAuthState: false };
1999
+ }
1647
2000
  if (!projectId) return { authProfile: "", noAuthState: false };
1648
2001
 
1649
2002
  const profiles = listLocalAuthProfiles({ orgId, projectId });
1650
2003
  if (profiles.length === 0) return { authProfile: "", noAuthState: false };
1651
2004
 
1652
- const projectCfg = readProjectConfig();
1653
- const defaultProfile = String(projectCfg.local_auth_profile || "").trim();
2005
+ const defaultProfile = authProfileFromConfig(projectCfg);
1654
2006
  const defaultIndex = profiles.findIndex((p) => p === defaultProfile);
1655
2007
 
1656
2008
  // eslint-disable-next-line no-console
@@ -1692,10 +2044,9 @@ async function maybePromptAuthProfileForLocalRun({ args, orgId, projectId }) {
1692
2044
  }
1693
2045
 
1694
2046
  async function runAuthSetup(args) {
1695
- const config = readQualtyConfig();
1696
- const apiUrl = resolveApiUrl(args, config);
1697
- const token = resolveToken(args, config);
1698
- const orgId = resolveOrgId(args, config);
2047
+ const apiUrl = resolveApiUrl(args);
2048
+ const token = resolveToken(args);
2049
+ const orgId = resolveOrgId(args);
1699
2050
  if (!token) {
1700
2051
  throw new Error("Missing auth token. Run `qualty setup` first.");
1701
2052
  }
@@ -1849,9 +2200,9 @@ async function runAuthSetup(args) {
1849
2200
  await browser.close();
1850
2201
 
1851
2202
  writeProjectConfig({
1852
- local_auth_project_id: project.id,
1853
- local_auth_profile: profile,
1854
2203
  default_org_id: orgId,
2204
+ default_project_id: project.id,
2205
+ default_auth_profile: profile,
1855
2206
  });
1856
2207
  try {
1857
2208
  const synced = await registerLocalLoginProfileBinding({
@@ -1914,11 +2265,10 @@ async function main() {
1914
2265
  throw new Error("Unknown auth subcommand. Use: qualty auth setup | qualty auth export-ci");
1915
2266
  }
1916
2267
  if (command === "run") {
1917
- const config = readQualtyConfig();
1918
2268
  if (parseBoolean(args.local, false)) {
1919
- const apiUrl = resolveApiUrl(args, config);
1920
- const token = resolveToken(args, config);
1921
- const orgId = resolveOrgId(args, config);
2269
+ const apiUrl = resolveApiUrl(args);
2270
+ const token = resolveToken(args);
2271
+ const orgId = resolveOrgId(args);
1922
2272
  const projectId =
1923
2273
  (await maybePromptProjectIdForLocalRun({
1924
2274
  args,
@@ -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.20",
3
+ "version": "0.1.22",
4
4
  "description": "Qualty CLI for localhost and CI test runs",
5
5
  "bin": {
6
6
  "qualty": "bin/qualty.js"