qualty 0.1.20 → 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.
@@ -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,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
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) {
831
+ // eslint-disable-next-line no-console
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
+ }
841
+ // eslint-disable-next-line no-console
842
+ console.log("");
843
+ } else {
844
+ // eslint-disable-next-line no-console
845
+ console.log(
846
+ "1. Add repository variables (or re-run with --set-github from this repo's git root):"
847
+ );
724
848
  // eslint-disable-next-line no-console
725
- console.log(token);
726
- } else if (token) {
849
+ console.log("");
727
850
  // eslint-disable-next-line no-console
728
- console.log(`(your qlty_ci_ token — already configured locally as ${maskSecret(token)})`);
851
+ console.log("---");
729
852
  // eslint-disable-next-line no-console
730
- console.log("Paste the full token from Qualty → Account → API tokens.");
853
+ console.log("Name: QUALTY_ORG_ID");
731
854
  // 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 {
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}"`);
734
880
  // eslint-disable-next-line no-console
735
- console.log("(create in Qualty → Account → API tokens; must start with qlty_ci_)");
881
+ console.log("---");
882
+ // eslint-disable-next-line no-console
883
+ console.log("Name: QUALTY_SUITE_ID");
884
+ // eslint-disable-next-line no-console
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
+ }
920
+ // eslint-disable-next-line no-console
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("---");
747
937
  // eslint-disable-next-line no-console
748
- console.log(encoded.payload);
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,8 +1812,8 @@ 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
1819
  const summary = summarizeStorageStateFile(path);
@@ -1640,17 +1826,21 @@ function resolveLocalAuthStateForRun({ args, orgId, projectId }) {
1640
1826
  }
1641
1827
 
1642
1828
  async function maybePromptAuthProfileForLocalRun({ args, orgId, projectId }) {
1643
- const requestedProfile = String(args["auth-profile"] || "").trim();
1829
+ const projectCfg = readProjectConfig();
1830
+ const requestedProfile = authProfileFromConfig(projectCfg, args["auth-profile"]);
1644
1831
  if (requestedProfile) return { authProfile: requestedProfile, noAuthState: false };
1645
1832
  if (parseBoolean(args["no-auth-state"], false)) return { authProfile: "", noAuthState: true };
1646
- 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
+ }
1647
1838
  if (!projectId) return { authProfile: "", noAuthState: false };
1648
1839
 
1649
1840
  const profiles = listLocalAuthProfiles({ orgId, projectId });
1650
1841
  if (profiles.length === 0) return { authProfile: "", noAuthState: false };
1651
1842
 
1652
- const projectCfg = readProjectConfig();
1653
- const defaultProfile = String(projectCfg.local_auth_profile || "").trim();
1843
+ const defaultProfile = authProfileFromConfig(projectCfg);
1654
1844
  const defaultIndex = profiles.findIndex((p) => p === defaultProfile);
1655
1845
 
1656
1846
  // eslint-disable-next-line no-console
@@ -1692,10 +1882,9 @@ async function maybePromptAuthProfileForLocalRun({ args, orgId, projectId }) {
1692
1882
  }
1693
1883
 
1694
1884
  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);
1885
+ const apiUrl = resolveApiUrl(args);
1886
+ const token = resolveToken(args);
1887
+ const orgId = resolveOrgId(args);
1699
1888
  if (!token) {
1700
1889
  throw new Error("Missing auth token. Run `qualty setup` first.");
1701
1890
  }
@@ -1849,9 +2038,9 @@ async function runAuthSetup(args) {
1849
2038
  await browser.close();
1850
2039
 
1851
2040
  writeProjectConfig({
1852
- local_auth_project_id: project.id,
1853
- local_auth_profile: profile,
1854
2041
  default_org_id: orgId,
2042
+ default_project_id: project.id,
2043
+ default_auth_profile: profile,
1855
2044
  });
1856
2045
  try {
1857
2046
  const synced = await registerLocalLoginProfileBinding({
@@ -1914,11 +2103,10 @@ async function main() {
1914
2103
  throw new Error("Unknown auth subcommand. Use: qualty auth setup | qualty auth export-ci");
1915
2104
  }
1916
2105
  if (command === "run") {
1917
- const config = readQualtyConfig();
1918
2106
  if (parseBoolean(args.local, false)) {
1919
- const apiUrl = resolveApiUrl(args, config);
1920
- const token = resolveToken(args, config);
1921
- const orgId = resolveOrgId(args, config);
2107
+ const apiUrl = resolveApiUrl(args);
2108
+ const token = resolveToken(args);
2109
+ const orgId = resolveOrgId(args);
1922
2110
  const projectId =
1923
2111
  (await maybePromptProjectIdForLocalRun({
1924
2112
  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.21",
4
4
  "description": "Qualty CLI for localhost and CI test runs",
5
5
  "bin": {
6
6
  "qualty": "bin/qualty.js"