qualty 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/qualty.js CHANGED
@@ -1,14 +1,108 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { appendFileSync } from "node:fs";
3
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { spawn } from "node:child_process";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { createInterface } from "node:readline/promises";
5
8
  import process from "node:process";
9
+ import { runLocalCi } from "./local-runner.js";
6
10
 
7
- /** When unset, CLI uses Qualty development API. Override for production: QUALTY_API_URL=https://qualty-api-production.up.railway.app or local: http://localhost:8000 */
11
+ /** CLI backend URL is fixed by Qualty distribution. */
8
12
  const DEFAULT_QUALTY_API_URL = "https://qualty-api-development.up.railway.app";
13
+ const QUALTY_CONFIG_DIR = join(homedir(), ".config", "qualty");
14
+ const QUALTY_CONFIG_PATH = join(QUALTY_CONFIG_DIR, "config.json");
15
+ const QUALTY_SHARED_CREDS_DIR = join(homedir(), ".qualty");
16
+ const QUALTY_SHARED_CREDS_PATH = join(QUALTY_SHARED_CREDS_DIR, "credentials");
17
+ const QUALTY_PROJECT_CONFIG_PATH = join(process.cwd(), ".qualty.json");
9
18
 
10
- function resolveApiUrl(args) {
11
- return String(args.api || process.env.QUALTY_API_URL || DEFAULT_QUALTY_API_URL).replace(/\/$/, "");
19
+ function readQualtyConfig() {
20
+ if (!existsSync(QUALTY_CONFIG_PATH)) return {};
21
+ try {
22
+ const raw = readFileSync(QUALTY_CONFIG_PATH, "utf8");
23
+ const parsed = JSON.parse(raw);
24
+ if (!parsed || typeof parsed !== "object") return {};
25
+ return parsed;
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ function writeQualtyConfig(config) {
32
+ mkdirSync(QUALTY_CONFIG_DIR, { recursive: true });
33
+ writeFileSync(QUALTY_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
34
+ try {
35
+ chmodSync(QUALTY_CONFIG_PATH, 0o600);
36
+ } catch {
37
+ // best effort on non-posix systems
38
+ }
39
+ }
40
+
41
+ function readSharedCredentials() {
42
+ if (!existsSync(QUALTY_SHARED_CREDS_PATH)) return {};
43
+ try {
44
+ const raw = readFileSync(QUALTY_SHARED_CREDS_PATH, "utf8");
45
+ const parsed = JSON.parse(raw);
46
+ if (!parsed || typeof parsed !== "object") return {};
47
+ return parsed;
48
+ } catch {
49
+ return {};
50
+ }
51
+ }
52
+
53
+ function writeSharedCredentials(token) {
54
+ mkdirSync(QUALTY_SHARED_CREDS_DIR, { recursive: true });
55
+ writeFileSync(QUALTY_SHARED_CREDS_PATH, `${JSON.stringify({ api_token: token }, null, 2)}\n`, "utf8");
56
+ try {
57
+ chmodSync(QUALTY_SHARED_CREDS_PATH, 0o600);
58
+ } catch {
59
+ // best effort on non-posix systems
60
+ }
61
+ }
62
+
63
+ function readProjectConfig() {
64
+ if (!existsSync(QUALTY_PROJECT_CONFIG_PATH)) return {};
65
+ try {
66
+ const raw = readFileSync(QUALTY_PROJECT_CONFIG_PATH, "utf8");
67
+ const parsed = JSON.parse(raw);
68
+ if (!parsed || typeof parsed !== "object") return {};
69
+ return parsed;
70
+ } catch {
71
+ return {};
72
+ }
73
+ }
74
+
75
+ function writeProjectConfig(updates) {
76
+ const current = readProjectConfig();
77
+ const next = { ...current };
78
+ for (const [k, v] of Object.entries(updates || {})) {
79
+ if (v === undefined || v === null || v === "") delete next[k];
80
+ else next[k] = v;
81
+ }
82
+ writeFileSync(QUALTY_PROJECT_CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`, "utf8");
83
+ }
84
+
85
+ function resolveApiUrl(args, config = {}) {
86
+ void args;
87
+ void config;
88
+ return String(DEFAULT_QUALTY_API_URL).replace(/\/$/, "");
89
+ }
90
+
91
+ function resolveToken(args, config = {}) {
92
+ const creds = readSharedCredentials();
93
+ return args.token || process.env.QUALTY_API_TOKEN || creds.api_token || config.api_token || "";
94
+ }
95
+
96
+ function resolveOrgId(args, config = {}) {
97
+ const projectCfg = readProjectConfig();
98
+ return args.org || process.env.QUALTY_ORG_ID || config.org_id || projectCfg.default_org_id || "";
99
+ }
100
+
101
+ function maskSecret(secret) {
102
+ const s = String(secret || "");
103
+ if (!s) return "(empty)";
104
+ if (s.length <= 8) return `${"*".repeat(Math.max(0, s.length - 2))}${s.slice(-2)}`;
105
+ return `${s.slice(0, 4)}...${s.slice(-4)}`;
12
106
  }
13
107
 
14
108
  function parseArgs(argv) {
@@ -38,27 +132,32 @@ function usage() {
38
132
  console.log(
39
133
  [
40
134
  "Usage:",
41
- " qualty connect --project <project-id> [--port 3000] [--api https://your-api] [--token <bearer-token>]",
42
- " qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--api https://your-api] [--token <bearer-token>]",
135
+ " qualty setup [--token <bearer-token>] [--org <org-id>]",
136
+ " qualty connect --project <project-id> [--port 3000] [--token <bearer-token>] [--org <org-id>]",
137
+ " qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--token <bearer-token>] [--org <org-id>]",
43
138
  " [--poll-interval 5] [--timeout 30] [--fail-on-failure true] [--no-view-logs]",
139
+ " [--local] [--device 1440x900] [--headed] [--local-concurrency 4]",
44
140
  "",
45
141
  " After each run, logs include step results, explanation, and final evaluator output (dashboard \"View logs\" data).",
46
142
  " Pass --no-view-logs for a short log only (e.g. very large evaluator text).",
47
- " qualty resolve --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--json] [--api https://your-api] [--token <bearer-token>]",
143
+ " qualty resolve --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--json] [--token <bearer-token>]",
48
144
  "",
49
145
  "Env vars:",
50
- ` QUALTY_API_URL Backend API URL (default: ${DEFAULT_QUALTY_API_URL}; set for local/self-hosted)`,
51
146
  " QUALTY_API_TOKEN Bearer token used for auth",
147
+ " QUALTY_ORG_ID Org context for scoped API requests",
148
+ " QUALTY_LOCAL_CONCURRENCY Local parallel workers for --local runs (default: 4)",
52
149
  ].join("\n")
53
150
  );
54
151
  }
55
152
 
56
- async function apiRequest({ apiUrl, token, path, method = "GET", body }) {
153
+ async function apiRequest({ apiUrl, token, orgId, path, method = "GET", body }) {
154
+ const normalizedOrgId = String(orgId || "").trim();
57
155
  const response = await fetch(`${apiUrl}${path}`, {
58
156
  method,
59
157
  headers: {
60
158
  "Content-Type": "application/json",
61
159
  Authorization: `Bearer ${token}`,
160
+ ...(normalizedOrgId ? { "X-Qualty-Org-Id": normalizedOrgId } : {}),
62
161
  },
63
162
  body: body ? JSON.stringify(body) : undefined,
64
163
  });
@@ -93,10 +192,12 @@ function startCloudflared(token, port) {
93
192
  }
94
193
 
95
194
  async function runConnect(args) {
195
+ const config = readQualtyConfig();
96
196
  const projectId = args.project;
97
197
  const port = Number(args.port || 3000);
98
- const apiUrl = resolveApiUrl(args);
99
- const token = args.token || process.env.QUALTY_API_TOKEN;
198
+ const apiUrl = resolveApiUrl(args, config);
199
+ const token = resolveToken(args, config);
200
+ const orgId = resolveOrgId(args, config);
100
201
 
101
202
  if (!projectId || !token) {
102
203
  usage();
@@ -109,6 +210,7 @@ async function runConnect(args) {
109
210
  const connect = await apiRequest({
110
211
  apiUrl,
111
212
  token,
213
+ orgId,
112
214
  path: "/api/v1/localhost/connect",
113
215
  method: "POST",
114
216
  body: { project_id: projectId, port },
@@ -129,6 +231,7 @@ async function runConnect(args) {
129
231
  await apiRequest({
130
232
  apiUrl,
131
233
  token,
234
+ orgId,
132
235
  path: "/api/v1/localhost/heartbeat",
133
236
  method: "POST",
134
237
  body: { project_id: projectId },
@@ -145,6 +248,7 @@ async function runConnect(args) {
145
248
  await apiRequest({
146
249
  apiUrl,
147
250
  token,
251
+ orgId,
148
252
  path: "/api/v1/localhost/disconnect",
149
253
  method: "POST",
150
254
  body: { project_id: projectId },
@@ -164,6 +268,163 @@ async function runConnect(args) {
164
268
  process.on("SIGTERM", shutdown);
165
269
  }
166
270
 
271
+ async function runSetup(args) {
272
+ const existing = readQualtyConfig();
273
+ const sharedCreds = readSharedCredentials();
274
+ const projectCfg = readProjectConfig();
275
+ const rl = createInterface({
276
+ input: process.stdin,
277
+ output: process.stdout,
278
+ });
279
+ const hasStdin = process.stdin.isTTY;
280
+ const ask = async (prompt, fallback = "") => {
281
+ if (!hasStdin) return fallback;
282
+ const answer = await rl.question(prompt);
283
+ const v = String(answer || "").trim();
284
+ return v || fallback;
285
+ };
286
+ const askYesNo = async (prompt, defaultValue = false) => {
287
+ if (!hasStdin) return defaultValue;
288
+ const answer = String(await rl.question(prompt)).trim();
289
+ if (!answer) return defaultValue;
290
+ const raw = answer.toLowerCase();
291
+ if (["y", "yes"].includes(raw)) return true;
292
+ if (["n", "no"].includes(raw)) return false;
293
+ return defaultValue;
294
+ };
295
+ const askChoice = async (prompt, min, max, defaultValue) => {
296
+ if (!hasStdin) return defaultValue;
297
+ while (true) {
298
+ const answer = String(await rl.question(prompt)).trim();
299
+ if (!answer) return defaultValue;
300
+ const n = Number(answer);
301
+ if (Number.isInteger(n) && n >= min && n <= max) return n;
302
+ // eslint-disable-next-line no-console
303
+ console.log(`[qualty] Please enter a number from ${min} to ${max}.`);
304
+ }
305
+ };
306
+
307
+ try {
308
+ const currentOrgId = String(args.org || existing.org_id || projectCfg.default_org_id || "");
309
+ const currentToken = String(args.token || process.env.QUALTY_API_TOKEN || sharedCreds.api_token || existing.api_token || "");
310
+
311
+ let token = "";
312
+ if (args.token) {
313
+ token = String(args.token).trim();
314
+ } else if (currentToken) {
315
+ const updateToken = await askYesNo(`API token already set (${maskSecret(currentToken)}). Update it? [y/N]: `, false);
316
+ if (updateToken) {
317
+ token = String(await ask("New Qualty API token [required]: ", "")).trim();
318
+ } else {
319
+ token = currentToken;
320
+ }
321
+ } else {
322
+ token = String(await ask("Qualty API token [required]: ", "")).trim();
323
+ }
324
+
325
+ if (!token) {
326
+ throw new Error("API token is required. Re-run setup and provide QUALTY_API_TOKEN.");
327
+ }
328
+
329
+ let organizations = [];
330
+ let currentOrgName = "";
331
+ try {
332
+ const orgResp = await apiRequest({
333
+ apiUrl: resolveApiUrl({}, {}),
334
+ token,
335
+ orgId: "",
336
+ path: "/api/v1/orgs",
337
+ });
338
+ organizations = Array.isArray(orgResp?.organizations) ? orgResp.organizations : [];
339
+ const byId = new Map(
340
+ organizations
341
+ .filter((o) => o && typeof o === "object")
342
+ .map((o) => [String(o.id || ""), String(o.name || "").trim()])
343
+ );
344
+ currentOrgName = byId.get(currentOrgId) || "";
345
+ } catch {
346
+ organizations = [];
347
+ }
348
+
349
+ let orgId = "";
350
+ let orgName = "";
351
+ if (args.org) {
352
+ orgId = String(args.org).trim();
353
+ orgName = currentOrgName || "selected organization";
354
+ } else if (organizations.length > 0) {
355
+ const orgRows = organizations
356
+ .map((o) => ({
357
+ id: String(o?.id || ""),
358
+ name: String(o?.name || "").trim() || "Unnamed org",
359
+ }))
360
+ .filter((o) => o.id);
361
+ const currentIndex = orgRows.findIndex((o) => o.id === currentOrgId);
362
+ if (currentIndex >= 0) {
363
+ const updateOrg = await askYesNo(
364
+ `Organization already set (${orgRows[currentIndex].name}). Update it? [y/N]: `,
365
+ false
366
+ );
367
+ if (!updateOrg) {
368
+ orgId = orgRows[currentIndex].id;
369
+ orgName = orgRows[currentIndex].name;
370
+ }
371
+ }
372
+ if (!orgId) {
373
+ // eslint-disable-next-line no-console
374
+ console.log("[qualty] Choose organization:");
375
+ for (let i = 0; i < orgRows.length; i += 1) {
376
+ // eslint-disable-next-line no-console
377
+ console.log(` ${i + 1}) ${orgRows[i].name}`);
378
+ }
379
+ const defaultChoice = currentIndex >= 0 ? currentIndex + 1 : 1;
380
+ const picked = await askChoice(
381
+ `Select org [1-${orgRows.length}] (default ${defaultChoice}): `,
382
+ 1,
383
+ orgRows.length,
384
+ defaultChoice
385
+ );
386
+ orgId = orgRows[picked - 1].id;
387
+ orgName = orgRows[picked - 1].name;
388
+ }
389
+ } else if (currentOrgId) {
390
+ const updateOrg = await askYesNo("Organization already set. Update it? [y/N]: ", false);
391
+ if (!updateOrg) {
392
+ orgId = currentOrgId;
393
+ orgName = "saved organization";
394
+ } else {
395
+ orgId = String(await ask("New Qualty Org ID [required]: ", "")).trim();
396
+ }
397
+ } else {
398
+ orgId = String(await ask("Qualty Org ID [required]: ", "")).trim();
399
+ }
400
+
401
+ if (!orgId) {
402
+ throw new Error("Org ID is required. Re-run setup and provide QUALTY_ORG_ID.");
403
+ }
404
+
405
+ const next = {
406
+ org_id: orgId,
407
+ };
408
+ // Keep token in the same place MCP uses.
409
+ writeSharedCredentials(token);
410
+ writeQualtyConfig(next);
411
+ // Keep org in MCP-compatible per-project config too.
412
+ writeProjectConfig({ default_org_id: orgId });
413
+ // eslint-disable-next-line no-console
414
+ console.log(`[qualty] Saved config to ${QUALTY_CONFIG_PATH}`);
415
+ // eslint-disable-next-line no-console
416
+ console.log(`[qualty] Saved shared token to ${QUALTY_SHARED_CREDS_PATH} (used by MCP)`);
417
+ // eslint-disable-next-line no-console
418
+ console.log(`[qualty] Saved project org to ${QUALTY_PROJECT_CONFIG_PATH} (MCP-compatible)`);
419
+ // eslint-disable-next-line no-console
420
+ console.log(`[qualty] Org: ${orgName || "configured"}`);
421
+ // eslint-disable-next-line no-console
422
+ console.log(`[qualty] Token: ${maskSecret(token)}`);
423
+ } finally {
424
+ rl.close();
425
+ }
426
+ }
427
+
167
428
  function parseBoolean(value, defaultValue) {
168
429
  if (value === undefined) return defaultValue;
169
430
  if (typeof value === "boolean") return value;
@@ -173,6 +434,17 @@ function parseBoolean(value, defaultValue) {
173
434
  throw new Error(`Invalid boolean value: ${value}`);
174
435
  }
175
436
 
437
+ function resolveLocalConcurrency(args) {
438
+ const raw = args["local-concurrency"] ?? process.env.QUALTY_LOCAL_CONCURRENCY ?? 4;
439
+ const n = Number(raw);
440
+ if (!Number.isInteger(n) || n < 1) {
441
+ throw new Error(
442
+ `Invalid local concurrency: ${raw}. Use --local-concurrency <integer>=1 or QUALTY_LOCAL_CONCURRENCY>=1`
443
+ );
444
+ }
445
+ return n;
446
+ }
447
+
176
448
  function sleep(ms) {
177
449
  return new Promise((resolve) => setTimeout(resolve, ms));
178
450
  }
@@ -498,8 +770,10 @@ function printQualtyViewLogsReport(executionId, status) {
498
770
  }
499
771
 
500
772
  async function runCi(args) {
501
- const apiUrl = resolveApiUrl(args);
502
- const token = args.token || process.env.QUALTY_API_TOKEN;
773
+ const config = readQualtyConfig();
774
+ const apiUrl = resolveApiUrl(args, config);
775
+ const token = resolveToken(args, config);
776
+ const orgId = resolveOrgId(args, config);
503
777
  const projectId = args.project;
504
778
  const suiteId = args["suite-id"];
505
779
  const explicitIds = String(args.ids || "")
@@ -524,7 +798,7 @@ async function runCi(args) {
524
798
  throw new Error("Invalid --timeout. Must be > 0.");
525
799
  }
526
800
 
527
- const savedJobs = await resolveSavedJobs({ apiUrl, token, projectId, suiteId, explicitIds });
801
+ const savedJobs = await resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, explicitIds });
528
802
  if (!Array.isArray(savedJobs) || savedJobs.length === 0) {
529
803
  throw new Error(
530
804
  "No saved tests matched your selector. The test ID may be invalid, deleted, or outside your token authorization scope."
@@ -546,6 +820,7 @@ async function runCi(args) {
546
820
  const startResp = await apiRequest({
547
821
  apiUrl,
548
822
  token,
823
+ orgId,
549
824
  path: "/api/v1/ci/run",
550
825
  method: "POST",
551
826
  body: startPayload,
@@ -568,6 +843,7 @@ async function runCi(args) {
568
843
  const batch = await apiRequest({
569
844
  apiUrl,
570
845
  token,
846
+ orgId,
571
847
  path: "/api/v1/status/batch",
572
848
  method: "POST",
573
849
  body: { job_ids: executionJobIds },
@@ -633,7 +909,7 @@ async function runCi(args) {
633
909
  }
634
910
  }
635
911
 
636
- async function resolveSavedJobs({ apiUrl, token, projectId, suiteId, explicitIds }) {
912
+ async function resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, explicitIds }) {
637
913
  const query = new URLSearchParams();
638
914
  if (projectId) query.set("project_id", String(projectId));
639
915
  if (suiteId) query.set("suite_id", String(suiteId));
@@ -641,13 +917,16 @@ async function resolveSavedJobs({ apiUrl, token, projectId, suiteId, explicitIds
641
917
  return apiRequest({
642
918
  apiUrl,
643
919
  token,
920
+ orgId,
644
921
  path: `/api/v1/saved-jobs?${query.toString()}`,
645
922
  });
646
923
  }
647
924
 
648
925
  async function runResolve(args) {
649
- const apiUrl = resolveApiUrl(args);
650
- const token = args.token || process.env.QUALTY_API_TOKEN;
926
+ const config = readQualtyConfig();
927
+ const apiUrl = resolveApiUrl(args, config);
928
+ const token = resolveToken(args, config);
929
+ const orgId = resolveOrgId(args, config);
651
930
  const projectId = args.project;
652
931
  const suiteId = args["suite-id"];
653
932
  const explicitIds = String(args.ids || "")
@@ -663,7 +942,7 @@ async function runResolve(args) {
663
942
  throw new Error("Provide --project, --suite-id, or --ids to select tests.");
664
943
  }
665
944
 
666
- const savedJobs = await resolveSavedJobs({ apiUrl, token, projectId, suiteId, explicitIds });
945
+ const savedJobs = await resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, explicitIds });
667
946
  if (!Array.isArray(savedJobs) || savedJobs.length === 0) {
668
947
  throw new Error(
669
948
  "No saved tests matched your selector. The test ID may be invalid, deleted, or outside your token authorization scope."
@@ -687,6 +966,127 @@ async function runResolve(args) {
687
966
  }
688
967
  }
689
968
 
969
+ async function maybePromptSuiteIdForLocalRun({ args, apiUrl, token, orgId }) {
970
+ const existingSuiteId = String(args["suite-id"] || "").trim();
971
+ const explicitIds = String(args.ids || "")
972
+ .split(",")
973
+ .map((id) => id.trim())
974
+ .filter(Boolean);
975
+ if (existingSuiteId || explicitIds.length > 0) {
976
+ return existingSuiteId || "";
977
+ }
978
+
979
+ const projectId = String(args.project || "").trim();
980
+ if (!projectId) {
981
+ return "";
982
+ }
983
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
984
+ return "";
985
+ }
986
+
987
+ const suites = await apiRequest({
988
+ apiUrl,
989
+ token,
990
+ orgId,
991
+ path: `/api/v1/test-suites?project_id=${encodeURIComponent(projectId)}`,
992
+ });
993
+ const rows = (Array.isArray(suites) ? suites : [])
994
+ .map((s) => ({
995
+ id: String(s?.id || "").trim(),
996
+ name: String(s?.name || "").trim() || "Unnamed suite",
997
+ jobCount: Number(Array.isArray(s?.job_ids) ? s.job_ids.length : 0),
998
+ sequenceCount: Number(Array.isArray(s?.sequence_ids) ? s.sequence_ids.length : 0),
999
+ }))
1000
+ .filter((s) => s.id);
1001
+
1002
+ if (rows.length === 0) {
1003
+ throw new Error(
1004
+ "No test suites found for this project. Create a suite first in dashboard, or pass --ids explicitly."
1005
+ );
1006
+ }
1007
+
1008
+ // eslint-disable-next-line no-console
1009
+ console.log("[qualty] No --suite-id or --ids provided. Choose a suite:");
1010
+ for (let i = 0; i < rows.length; i += 1) {
1011
+ const s = rows[i];
1012
+ const details = `${s.jobCount} test(s)${
1013
+ s.sequenceCount > 0 ? `, ${s.sequenceCount} sequence(s)` : ""
1014
+ }`;
1015
+ // eslint-disable-next-line no-console
1016
+ console.log(` ${i + 1}) ${s.name} (${details})`);
1017
+ }
1018
+
1019
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1020
+ try {
1021
+ while (true) {
1022
+ const raw = String(
1023
+ await rl.question(`Select suite [1-${rows.length}] (default 1): `)
1024
+ ).trim();
1025
+ const picked = raw ? Number(raw) : 1;
1026
+ if (Number.isInteger(picked) && picked >= 1 && picked <= rows.length) {
1027
+ const suite = rows[picked - 1];
1028
+ // eslint-disable-next-line no-console
1029
+ console.log(`[qualty] Selected suite: ${suite.name}`);
1030
+ return suite.id;
1031
+ }
1032
+ // eslint-disable-next-line no-console
1033
+ console.log(`[qualty] Please enter a number from 1 to ${rows.length}.`);
1034
+ }
1035
+ } finally {
1036
+ rl.close();
1037
+ }
1038
+ }
1039
+
1040
+ async function maybePromptProjectIdForLocalRun({ args, apiUrl, token, orgId }) {
1041
+ const existingProjectId = String(args.project || "").trim();
1042
+ if (existingProjectId) return existingProjectId;
1043
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return existingProjectId;
1044
+
1045
+ const projectsPayload = await apiRequest({
1046
+ apiUrl,
1047
+ token,
1048
+ orgId,
1049
+ path: "/api/v1/projects",
1050
+ });
1051
+ const rows = (Array.isArray(projectsPayload) ? projectsPayload : [])
1052
+ .map((p) => ({
1053
+ id: String(p?.id || "").trim(),
1054
+ name: String(p?.name || "").trim() || "Unnamed project",
1055
+ }))
1056
+ .filter((p) => p.id);
1057
+
1058
+ if (rows.length === 0) {
1059
+ throw new Error("No projects found for this account/org. Create a project first.");
1060
+ }
1061
+
1062
+ // eslint-disable-next-line no-console
1063
+ console.log("[qualty] No --project provided. Choose a project:");
1064
+ for (let i = 0; i < rows.length; i += 1) {
1065
+ // eslint-disable-next-line no-console
1066
+ console.log(` ${i + 1}) ${rows[i].name}`);
1067
+ }
1068
+
1069
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1070
+ try {
1071
+ while (true) {
1072
+ const raw = String(
1073
+ await rl.question(`Select project [1-${rows.length}] (default 1): `)
1074
+ ).trim();
1075
+ const picked = raw ? Number(raw) : 1;
1076
+ if (Number.isInteger(picked) && picked >= 1 && picked <= rows.length) {
1077
+ const project = rows[picked - 1];
1078
+ // eslint-disable-next-line no-console
1079
+ console.log(`[qualty] Selected project: ${project.name}`);
1080
+ return project.id;
1081
+ }
1082
+ // eslint-disable-next-line no-console
1083
+ console.log(`[qualty] Please enter a number from 1 to ${rows.length}.`);
1084
+ }
1085
+ } finally {
1086
+ rl.close();
1087
+ }
1088
+ }
1089
+
690
1090
  async function main() {
691
1091
  const args = parseArgs(process.argv);
692
1092
  const command = args._[0];
@@ -698,7 +1098,49 @@ async function main() {
698
1098
  await runConnect(args);
699
1099
  return;
700
1100
  }
1101
+ if (command === "setup") {
1102
+ await runSetup(args);
1103
+ return;
1104
+ }
701
1105
  if (command === "run") {
1106
+ const config = readQualtyConfig();
1107
+ if (parseBoolean(args.local, false)) {
1108
+ const apiUrl = resolveApiUrl(args, config);
1109
+ const token = resolveToken(args, config);
1110
+ const orgId = resolveOrgId(args, config);
1111
+ const projectId =
1112
+ (await maybePromptProjectIdForLocalRun({
1113
+ args,
1114
+ apiUrl,
1115
+ token,
1116
+ orgId,
1117
+ })) || args.project;
1118
+ const suiteId =
1119
+ (await maybePromptSuiteIdForLocalRun({
1120
+ args: { ...args, project: projectId },
1121
+ apiUrl,
1122
+ token,
1123
+ orgId,
1124
+ })) || args["suite-id"];
1125
+ await runLocalCi({
1126
+ apiUrl,
1127
+ token,
1128
+ orgId,
1129
+ projectId,
1130
+ suiteId,
1131
+ explicitIds: String(args.ids || "")
1132
+ .split(",")
1133
+ .map((id) => id.trim())
1134
+ .filter(Boolean),
1135
+ resolveSavedJobs,
1136
+ apiRequest,
1137
+ failOnFailure: parseBoolean(args["fail-on-failure"], true),
1138
+ device: args.device || "1440x900",
1139
+ headless: !parseBoolean(args.headed, false),
1140
+ localConcurrency: resolveLocalConcurrency(args),
1141
+ });
1142
+ return;
1143
+ }
702
1144
  await runCi(args);
703
1145
  return;
704
1146
  }
@@ -706,7 +1148,7 @@ async function main() {
706
1148
  await runResolve(args);
707
1149
  return;
708
1150
  }
709
- if (command !== "connect" && command !== "run" && command !== "resolve") {
1151
+ if (command !== "setup" && command !== "connect" && command !== "run" && command !== "resolve") {
710
1152
  throw new Error(`Unknown command: ${command}`);
711
1153
  }
712
1154
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualty",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Qualty CLI for localhost and CI test runs",
5
5
  "bin": {
6
6
  "qualty": "bin/qualty.js"
@@ -9,5 +9,9 @@
9
9
  "license": "UNLICENSED",
10
10
  "files": [
11
11
  "bin"
12
- ]
12
+ ],
13
+ "dependencies": {
14
+ "playwright": "1.60.0",
15
+ "rrweb": "^2.0.0-alpha.4"
16
+ }
13
17
  }