@yoqa/cli 0.3.11 → 0.3.13
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/README.md +7 -1
- package/dist/main.js +567 -28
- package/package.json +15 -3
package/README.md
CHANGED
|
@@ -10,6 +10,12 @@ npx @yoqa/cli health
|
|
|
10
10
|
|
|
11
11
|
After a global install, the `yoqa` command is on your PATH.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
The CLI talks to the local runner at `http://127.0.0.1:7420` by default. If nothing is listening, `yoqa` auto-starts `@yoqa/runner` (requires [Bun](https://bun.sh)). You can also run it in the foreground:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
yoqa serve
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Disable auto-start with `YOQA_NO_AUTOSTART=1`. Override the URL with `YOQA_RUNNER_HOST` / `YOQA_RUNNER_PORT` / `YOQA_RUNNER_URL`.
|
|
14
20
|
|
|
15
21
|
See [CLI docs](https://yoqa.mintlify.app/docs/cli).
|
package/dist/main.js
CHANGED
|
@@ -2122,7 +2122,7 @@ var require_commander = __commonJS((exports) => {
|
|
|
2122
2122
|
});
|
|
2123
2123
|
|
|
2124
2124
|
// src/main.ts
|
|
2125
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
2125
|
+
import { readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
|
|
2126
2126
|
|
|
2127
2127
|
// ../../node_modules/zod/v3/external.js
|
|
2128
2128
|
var exports_external = {};
|
|
@@ -7850,14 +7850,16 @@ var {
|
|
|
7850
7850
|
// package.json
|
|
7851
7851
|
var package_default = {
|
|
7852
7852
|
name: "@yoqa/cli",
|
|
7853
|
-
version: "0.3.
|
|
7853
|
+
version: "0.3.13",
|
|
7854
7854
|
description: "Yoqa CLI — drive local mobile QA from the terminal or CI (talks to the Yoqa runner over HTTP)",
|
|
7855
7855
|
license: "UNLICENSED",
|
|
7856
7856
|
type: "module",
|
|
7857
7857
|
bin: {
|
|
7858
7858
|
yoqa: "dist/main.js"
|
|
7859
7859
|
},
|
|
7860
|
-
files: [
|
|
7860
|
+
files: [
|
|
7861
|
+
"dist"
|
|
7862
|
+
],
|
|
7861
7863
|
engines: {
|
|
7862
7864
|
node: ">=20"
|
|
7863
7865
|
},
|
|
@@ -7870,7 +7872,14 @@ var package_default = {
|
|
|
7870
7872
|
bugs: {
|
|
7871
7873
|
url: "https://github.com/Aris-ngoy/qa-agent/issues"
|
|
7872
7874
|
},
|
|
7873
|
-
keywords: [
|
|
7875
|
+
keywords: [
|
|
7876
|
+
"yoqa",
|
|
7877
|
+
"cli",
|
|
7878
|
+
"mobile",
|
|
7879
|
+
"qa",
|
|
7880
|
+
"appium",
|
|
7881
|
+
"testing"
|
|
7882
|
+
],
|
|
7874
7883
|
scripts: {
|
|
7875
7884
|
yoqa: "bun run src/main.ts",
|
|
7876
7885
|
build: "bun run build.ts",
|
|
@@ -7878,6 +7887,9 @@ var package_default = {
|
|
|
7878
7887
|
check: "tsc --noEmit",
|
|
7879
7888
|
health: "bun run src/main.ts health"
|
|
7880
7889
|
},
|
|
7890
|
+
dependencies: {
|
|
7891
|
+
"@yoqa/runner": "0.3.13"
|
|
7892
|
+
},
|
|
7881
7893
|
devDependencies: {
|
|
7882
7894
|
"@types/node": "^22.15.30",
|
|
7883
7895
|
"@yoqa/runner-client": "workspace:*",
|
|
@@ -7890,15 +7902,523 @@ var package_default = {
|
|
|
7890
7902
|
}
|
|
7891
7903
|
};
|
|
7892
7904
|
|
|
7893
|
-
// src/
|
|
7894
|
-
|
|
7895
|
-
|
|
7896
|
-
|
|
7905
|
+
// src/doctor-table.ts
|
|
7906
|
+
var ANSI = {
|
|
7907
|
+
reset: "\x1B[0m",
|
|
7908
|
+
bold: "\x1B[1m",
|
|
7909
|
+
dim: "\x1B[2m",
|
|
7910
|
+
green: "\x1B[32m",
|
|
7911
|
+
red: "\x1B[31m",
|
|
7912
|
+
yellow: "\x1B[33m"
|
|
7913
|
+
};
|
|
7914
|
+
var CHECK_STATUS_LABEL = {
|
|
7915
|
+
pass: "PASS",
|
|
7916
|
+
fail: "FAIL",
|
|
7917
|
+
warn: "WARN"
|
|
7918
|
+
};
|
|
7919
|
+
var STEP_SEVERITY_LABEL = {
|
|
7920
|
+
error: "ERROR",
|
|
7921
|
+
warn: "WARN",
|
|
7922
|
+
info: "INFO"
|
|
7923
|
+
};
|
|
7924
|
+
function doctorColorEnabled(env = process.env, isTty = Boolean(process.stdout.isTTY)) {
|
|
7925
|
+
if (env.NO_COLOR != null && env.NO_COLOR !== "")
|
|
7926
|
+
return false;
|
|
7927
|
+
if (env.FORCE_COLOR != null && env.FORCE_COLOR !== "" && env.FORCE_COLOR !== "0")
|
|
7928
|
+
return true;
|
|
7929
|
+
return isTty;
|
|
7930
|
+
}
|
|
7931
|
+
function paint(text, color, enabled) {
|
|
7932
|
+
if (!enabled)
|
|
7933
|
+
return text;
|
|
7934
|
+
if (color === "dim")
|
|
7935
|
+
return `${ANSI.dim}${text}${ANSI.reset}`;
|
|
7936
|
+
if (color === "bold")
|
|
7937
|
+
return `${ANSI.bold}${text}${ANSI.reset}`;
|
|
7938
|
+
return `${ANSI.bold}${ANSI[color]}${text}${ANSI.reset}`;
|
|
7939
|
+
}
|
|
7940
|
+
function checkColor(status) {
|
|
7941
|
+
if (status === "pass")
|
|
7942
|
+
return "green";
|
|
7943
|
+
if (status === "fail")
|
|
7944
|
+
return "red";
|
|
7945
|
+
return "yellow";
|
|
7946
|
+
}
|
|
7947
|
+
function stepColor(severity) {
|
|
7948
|
+
if (severity === "error")
|
|
7949
|
+
return "red";
|
|
7950
|
+
if (severity === "warn")
|
|
7951
|
+
return "yellow";
|
|
7952
|
+
return "green";
|
|
7953
|
+
}
|
|
7954
|
+
var ESC = "\x1B";
|
|
7955
|
+
function stripAnsi(text) {
|
|
7956
|
+
let out = "";
|
|
7957
|
+
for (let i = 0;i < text.length; i++) {
|
|
7958
|
+
if (text[i] === ESC && text[i + 1] === "[") {
|
|
7959
|
+
const end = text.indexOf("m", i + 2);
|
|
7960
|
+
if (end !== -1) {
|
|
7961
|
+
i = end;
|
|
7962
|
+
continue;
|
|
7963
|
+
}
|
|
7964
|
+
}
|
|
7965
|
+
out += text[i];
|
|
7966
|
+
}
|
|
7967
|
+
return out;
|
|
7968
|
+
}
|
|
7969
|
+
function visibleWidth(text) {
|
|
7970
|
+
return stripAnsi(text).length;
|
|
7971
|
+
}
|
|
7972
|
+
function padEndVisible(text, width) {
|
|
7973
|
+
const extra = width - visibleWidth(text);
|
|
7974
|
+
return extra > 0 ? `${text}${" ".repeat(extra)}` : text;
|
|
7975
|
+
}
|
|
7976
|
+
function cell(value) {
|
|
7977
|
+
const text = value?.replace(/\s+/g, " ").trim() ?? "";
|
|
7978
|
+
return text.length > 0 ? text : "—";
|
|
7979
|
+
}
|
|
7980
|
+
function columnWidths(headers, rows) {
|
|
7981
|
+
return headers.map((header, index) => {
|
|
7982
|
+
let width = header.length;
|
|
7983
|
+
for (const row of rows) {
|
|
7984
|
+
const value = row[index] ?? "";
|
|
7985
|
+
width = Math.max(width, visibleWidth(value));
|
|
7986
|
+
}
|
|
7987
|
+
return width;
|
|
7988
|
+
});
|
|
7989
|
+
}
|
|
7990
|
+
function formatTable(headers, rows, color) {
|
|
7991
|
+
const widths = columnWidths(headers, rows);
|
|
7992
|
+
const headerLine = headers.map((header, index) => padEndVisible(paint(header, "dim", color), widths[index] ?? header.length)).join(" ");
|
|
7993
|
+
const rule = widths.map((width) => "─".repeat(width)).join(" ");
|
|
7994
|
+
const body = rows.map((row) => row.map((value, index) => padEndVisible(value, widths[index] ?? visibleWidth(value))).join(" "));
|
|
7995
|
+
return [headerLine, paint(rule, "dim", color), ...body].join(`
|
|
7996
|
+
`);
|
|
7997
|
+
}
|
|
7998
|
+
function checkDetail(check) {
|
|
7999
|
+
const parts = [check.detail, check.status !== "pass" ? check.fixHint : undefined].filter((part) => Boolean(part));
|
|
8000
|
+
return cell(parts.join(" · "));
|
|
8001
|
+
}
|
|
8002
|
+
function formatDoctorReport(report, color = doctorColorEnabled()) {
|
|
8003
|
+
const lines = [];
|
|
8004
|
+
const summary = report.ok ? "doctor ok" : "doctor issues found";
|
|
8005
|
+
lines.push(paint(summary, report.ok ? "green" : "red", color));
|
|
8006
|
+
lines.push("");
|
|
8007
|
+
if (report.checks.length > 0) {
|
|
8008
|
+
const rows = report.checks.map((check) => {
|
|
8009
|
+
const status = paint(CHECK_STATUS_LABEL[check.status], checkColor(check.status), color);
|
|
8010
|
+
return [status, check.label, checkDetail(check)];
|
|
8011
|
+
});
|
|
8012
|
+
lines.push(formatTable(["STATUS", "CHECK", "DETAIL"], rows, color));
|
|
8013
|
+
}
|
|
8014
|
+
if (report.steps.length > 0) {
|
|
8015
|
+
lines.push("");
|
|
8016
|
+
const rows = report.steps.map((step) => {
|
|
8017
|
+
const severity = paint(STEP_SEVERITY_LABEL[step.severity], stepColor(step.severity), color);
|
|
8018
|
+
return [severity, step.title, cell(step.detail)];
|
|
8019
|
+
});
|
|
8020
|
+
lines.push(formatTable(["SEV", "STEP", "DETAIL"], rows, color));
|
|
8021
|
+
}
|
|
8022
|
+
return lines.join(`
|
|
8023
|
+
`);
|
|
8024
|
+
}
|
|
8025
|
+
|
|
8026
|
+
// src/ensure-runner.ts
|
|
8027
|
+
import { spawn } from "node:child_process";
|
|
8028
|
+
import { existsSync } from "node:fs";
|
|
8029
|
+
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
8030
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
8031
|
+
import { homedir as osHomedir } from "node:os";
|
|
8032
|
+
import { dirname, join } from "node:path";
|
|
8033
|
+
import { fileURLToPath } from "node:url";
|
|
8034
|
+
var DEFAULT_RUNNER_HOST = "127.0.0.1";
|
|
8035
|
+
var DEFAULT_RUNNER_PORT = 7420;
|
|
8036
|
+
var HEALTH_POLL_MS = 400;
|
|
8037
|
+
var HEALTH_TIMEOUT_MS = 20000;
|
|
8038
|
+
|
|
8039
|
+
class RunnerStartError extends Error {
|
|
8040
|
+
constructor(message) {
|
|
8041
|
+
super(message);
|
|
8042
|
+
this.name = "RunnerStartError";
|
|
8043
|
+
}
|
|
8044
|
+
}
|
|
8045
|
+
function runnerBaseUrlFromEnv(env = process.env) {
|
|
8046
|
+
const fromUrl = env.YOQA_RUNNER_URL?.trim();
|
|
8047
|
+
if (fromUrl)
|
|
8048
|
+
return fromUrl.replace(/\/$/, "");
|
|
8049
|
+
const host = env.YOQA_RUNNER_HOST ?? DEFAULT_RUNNER_HOST;
|
|
8050
|
+
const port = Number(env.YOQA_RUNNER_PORT ?? String(DEFAULT_RUNNER_PORT));
|
|
7897
8051
|
if (!Number.isFinite(port) || port <= 0) {
|
|
7898
8052
|
throw new Error("YOQA_RUNNER_PORT must be a positive number");
|
|
7899
8053
|
}
|
|
7900
8054
|
return `http://${host}:${port}`;
|
|
7901
8055
|
}
|
|
8056
|
+
function shouldSkipAutostart(env = process.env) {
|
|
8057
|
+
const raw = env.YOQA_NO_AUTOSTART?.trim().toLowerCase();
|
|
8058
|
+
return raw === "1" || raw === "true" || raw === "yes";
|
|
8059
|
+
}
|
|
8060
|
+
function isRunnerHealth(value) {
|
|
8061
|
+
if (typeof value !== "object" || value === null)
|
|
8062
|
+
return false;
|
|
8063
|
+
const record = value;
|
|
8064
|
+
return record.ok === true && record.service === "yoqa-runner" && typeof record.version === "string" && record.version.length > 0;
|
|
8065
|
+
}
|
|
8066
|
+
function execRootsFrom(execPath, argv0) {
|
|
8067
|
+
const roots = [];
|
|
8068
|
+
for (const candidate of [execPath, argv0]) {
|
|
8069
|
+
if (!candidate)
|
|
8070
|
+
continue;
|
|
8071
|
+
const dir = dirname(candidate);
|
|
8072
|
+
if (!roots.includes(dir))
|
|
8073
|
+
roots.push(dir);
|
|
8074
|
+
}
|
|
8075
|
+
return roots;
|
|
8076
|
+
}
|
|
8077
|
+
function packagedYoqaRunnerCandidates(roots) {
|
|
8078
|
+
const fileName = "yoqa-runner";
|
|
8079
|
+
const paths = [];
|
|
8080
|
+
for (const root of roots) {
|
|
8081
|
+
paths.push(join(root, fileName), join(root, "../Resources/app.asar.unpacked/runner", fileName), join(root, "../Resources/runner", fileName), join(root, "Resources/app.asar.unpacked/runner", fileName), join(root, "Resources/runner", fileName));
|
|
8082
|
+
}
|
|
8083
|
+
return paths;
|
|
8084
|
+
}
|
|
8085
|
+
function yoqaRoot(homedir) {
|
|
8086
|
+
return join(homedir, ".yoqa");
|
|
8087
|
+
}
|
|
8088
|
+
function runnerPidPath(homedir) {
|
|
8089
|
+
return join(yoqaRoot(homedir), "runner.pid");
|
|
8090
|
+
}
|
|
8091
|
+
var BUN_MISSING_MESSAGE = `Could not find Bun, which is required to start the Yoqa runner.
|
|
8092
|
+
Install: https://bun.sh (curl -fsSL https://bun.sh/install | bash)
|
|
8093
|
+
Or set YOQA_RUNNER_BIN to a yoqa-runner binary.`;
|
|
8094
|
+
var RUNNER_NOT_FOUND_MESSAGE = "Could not locate the Yoqa runner. Install @yoqa/cli (includes @yoqa/runner) and Bun, or set YOQA_RUNNER_BIN.";
|
|
8095
|
+
var REPO_MARKER = join("services", "runner", "src", "index.ts");
|
|
8096
|
+
async function findRepoRoot(starts, pathExists) {
|
|
8097
|
+
for (const start of starts) {
|
|
8098
|
+
if (!start)
|
|
8099
|
+
continue;
|
|
8100
|
+
let dir = start;
|
|
8101
|
+
for (let i = 0;i < 14; i++) {
|
|
8102
|
+
if (await pathExists(join(dir, REPO_MARKER)))
|
|
8103
|
+
return dir;
|
|
8104
|
+
const parent = dirname(dir);
|
|
8105
|
+
if (parent === dir)
|
|
8106
|
+
break;
|
|
8107
|
+
dir = parent;
|
|
8108
|
+
}
|
|
8109
|
+
}
|
|
8110
|
+
return null;
|
|
8111
|
+
}
|
|
8112
|
+
async function resolvePublishedRunnerEntry(fromUrl) {
|
|
8113
|
+
try {
|
|
8114
|
+
const require2 = createRequire2(fromUrl);
|
|
8115
|
+
const pkgJson = require2.resolve("@yoqa/runner/package.json");
|
|
8116
|
+
return join(dirname(pkgJson), "dist", "index.js");
|
|
8117
|
+
} catch {
|
|
8118
|
+
return null;
|
|
8119
|
+
}
|
|
8120
|
+
}
|
|
8121
|
+
async function resolveRunnerLaunch(deps) {
|
|
8122
|
+
const binEnv = deps.env.YOQA_RUNNER_BIN?.trim();
|
|
8123
|
+
if (binEnv && await deps.pathExists(binEnv)) {
|
|
8124
|
+
return { command: [binEnv], source: "env" };
|
|
8125
|
+
}
|
|
8126
|
+
const onPath = await deps.which("yoqa-runner");
|
|
8127
|
+
if (onPath) {
|
|
8128
|
+
return { command: [onPath], source: "path" };
|
|
8129
|
+
}
|
|
8130
|
+
const packagedCandidates = packagedYoqaRunnerCandidates(execRootsFrom(deps.execPath, deps.argv0));
|
|
8131
|
+
for (const candidate of packagedCandidates) {
|
|
8132
|
+
if (await deps.pathExists(candidate)) {
|
|
8133
|
+
return { command: [candidate], source: "packaged" };
|
|
8134
|
+
}
|
|
8135
|
+
}
|
|
8136
|
+
const fromPath = fileURLToPath(deps.fromUrl);
|
|
8137
|
+
const repoRoot = await findRepoRoot([deps.env.YOQA_REPO_ROOT?.trim() ?? "", deps.cwd, dirname(fromPath)], deps.pathExists);
|
|
8138
|
+
if (repoRoot) {
|
|
8139
|
+
const bun = await deps.findBun();
|
|
8140
|
+
if (!bun)
|
|
8141
|
+
throw new RunnerStartError(BUN_MISSING_MESSAGE);
|
|
8142
|
+
const entry = join(repoRoot, REPO_MARKER);
|
|
8143
|
+
return {
|
|
8144
|
+
command: [bun, "run", entry],
|
|
8145
|
+
cwd: join(repoRoot, "services", "runner"),
|
|
8146
|
+
source: "monorepo"
|
|
8147
|
+
};
|
|
8148
|
+
}
|
|
8149
|
+
const pkgEntry = await deps.resolvePackageEntry();
|
|
8150
|
+
if (pkgEntry && await deps.pathExists(pkgEntry)) {
|
|
8151
|
+
const bun = await deps.findBun();
|
|
8152
|
+
if (!bun)
|
|
8153
|
+
throw new RunnerStartError(BUN_MISSING_MESSAGE);
|
|
8154
|
+
return { command: [bun, pkgEntry], source: "package" };
|
|
8155
|
+
}
|
|
8156
|
+
throw new RunnerStartError(RUNNER_NOT_FOUND_MESSAGE);
|
|
8157
|
+
}
|
|
8158
|
+
async function fetchRunnerHealth(baseUrl, timeoutMs = 1500) {
|
|
8159
|
+
try {
|
|
8160
|
+
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/health`, {
|
|
8161
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
8162
|
+
});
|
|
8163
|
+
if (!response.ok)
|
|
8164
|
+
return null;
|
|
8165
|
+
const json = await response.json();
|
|
8166
|
+
return isRunnerHealth(json) ? json : null;
|
|
8167
|
+
} catch {
|
|
8168
|
+
return null;
|
|
8169
|
+
}
|
|
8170
|
+
}
|
|
8171
|
+
async function waitForRunnerHealth(fetchHealth, options) {
|
|
8172
|
+
const deadline = options.now() + options.timeoutMs;
|
|
8173
|
+
while (options.now() < deadline) {
|
|
8174
|
+
const health = await fetchHealth();
|
|
8175
|
+
if (health)
|
|
8176
|
+
return health;
|
|
8177
|
+
if (options.isAlive && !options.isAlive())
|
|
8178
|
+
return null;
|
|
8179
|
+
await options.sleep(options.intervalMs);
|
|
8180
|
+
}
|
|
8181
|
+
return fetchHealth();
|
|
8182
|
+
}
|
|
8183
|
+
function pathWithHostTools(env, homedir) {
|
|
8184
|
+
const extras = [
|
|
8185
|
+
"/opt/homebrew/bin",
|
|
8186
|
+
"/opt/homebrew/sbin",
|
|
8187
|
+
"/usr/local/bin",
|
|
8188
|
+
join(homedir, ".local", "bin"),
|
|
8189
|
+
join(homedir, ".bun", "bin")
|
|
8190
|
+
];
|
|
8191
|
+
const existing = (env.PATH ?? "").split(":").filter(Boolean);
|
|
8192
|
+
const prepend = extras.filter((dir) => !existing.includes(dir));
|
|
8193
|
+
return [...prepend, ...existing].join(":");
|
|
8194
|
+
}
|
|
8195
|
+
async function defaultPathExists(path) {
|
|
8196
|
+
return existsSync(path);
|
|
8197
|
+
}
|
|
8198
|
+
async function defaultWhich(bin) {
|
|
8199
|
+
const { execFile } = await import("node:child_process");
|
|
8200
|
+
return new Promise((resolve) => {
|
|
8201
|
+
execFile("which", [bin], (error, stdout) => {
|
|
8202
|
+
if (error) {
|
|
8203
|
+
resolve(null);
|
|
8204
|
+
return;
|
|
8205
|
+
}
|
|
8206
|
+
const path = stdout.trim().split(`
|
|
8207
|
+
`)[0]?.trim();
|
|
8208
|
+
resolve(path || null);
|
|
8209
|
+
});
|
|
8210
|
+
});
|
|
8211
|
+
}
|
|
8212
|
+
async function defaultFindBun(pathExists, which, homedir, env) {
|
|
8213
|
+
const fromEnv = env.BUN_INSTALL ? join(env.BUN_INSTALL, "bin", "bun") : null;
|
|
8214
|
+
const candidates = [
|
|
8215
|
+
fromEnv,
|
|
8216
|
+
join(homedir, ".bun", "bin", "bun"),
|
|
8217
|
+
"/opt/homebrew/bin/bun",
|
|
8218
|
+
"/usr/local/bin/bun"
|
|
8219
|
+
].filter((path) => Boolean(path));
|
|
8220
|
+
for (const candidate of candidates) {
|
|
8221
|
+
if (await pathExists(candidate))
|
|
8222
|
+
return candidate;
|
|
8223
|
+
}
|
|
8224
|
+
return which("bun");
|
|
8225
|
+
}
|
|
8226
|
+
function defaultIsPidAlive(pid) {
|
|
8227
|
+
try {
|
|
8228
|
+
process.kill(pid, 0);
|
|
8229
|
+
return true;
|
|
8230
|
+
} catch {
|
|
8231
|
+
return false;
|
|
8232
|
+
}
|
|
8233
|
+
}
|
|
8234
|
+
function spawnEnv(env, homedir) {
|
|
8235
|
+
return {
|
|
8236
|
+
...env,
|
|
8237
|
+
PATH: pathWithHostTools(env, homedir),
|
|
8238
|
+
YOQA_RUNNER_HOST: env.YOQA_RUNNER_HOST ?? DEFAULT_RUNNER_HOST,
|
|
8239
|
+
YOQA_RUNNER_PORT: env.YOQA_RUNNER_PORT ?? String(DEFAULT_RUNNER_PORT)
|
|
8240
|
+
};
|
|
8241
|
+
}
|
|
8242
|
+
function defaultSpawnDetached(launch, env, homedir) {
|
|
8243
|
+
const [bin, ...args] = launch.command;
|
|
8244
|
+
if (!bin)
|
|
8245
|
+
throw new RunnerStartError("Runner launch command is empty");
|
|
8246
|
+
const child = spawn(bin, args, {
|
|
8247
|
+
cwd: launch.cwd,
|
|
8248
|
+
detached: true,
|
|
8249
|
+
stdio: "ignore",
|
|
8250
|
+
env: spawnEnv(env, homedir)
|
|
8251
|
+
});
|
|
8252
|
+
if (child.pid == null) {
|
|
8253
|
+
throw new RunnerStartError("Failed to spawn the Yoqa runner");
|
|
8254
|
+
}
|
|
8255
|
+
child.unref();
|
|
8256
|
+
return { pid: child.pid };
|
|
8257
|
+
}
|
|
8258
|
+
function defaultSpawnForeground(launch, env, homedir) {
|
|
8259
|
+
const [bin, ...args] = launch.command;
|
|
8260
|
+
if (!bin)
|
|
8261
|
+
throw new RunnerStartError("Runner launch command is empty");
|
|
8262
|
+
const child = spawn(bin, args, {
|
|
8263
|
+
cwd: launch.cwd,
|
|
8264
|
+
stdio: "inherit",
|
|
8265
|
+
env: spawnEnv(env, homedir)
|
|
8266
|
+
});
|
|
8267
|
+
if (child.pid == null) {
|
|
8268
|
+
throw new RunnerStartError("Failed to spawn the Yoqa runner");
|
|
8269
|
+
}
|
|
8270
|
+
const forward = (signal) => {
|
|
8271
|
+
try {
|
|
8272
|
+
child.kill(signal);
|
|
8273
|
+
} catch {}
|
|
8274
|
+
};
|
|
8275
|
+
process.on("SIGINT", forward);
|
|
8276
|
+
process.on("SIGTERM", forward);
|
|
8277
|
+
const exited = new Promise((resolve, reject) => {
|
|
8278
|
+
child.on("error", (error) => {
|
|
8279
|
+
process.off("SIGINT", forward);
|
|
8280
|
+
process.off("SIGTERM", forward);
|
|
8281
|
+
if ("code" in error && error.code === "ENOENT") {
|
|
8282
|
+
reject(new RunnerStartError(BUN_MISSING_MESSAGE));
|
|
8283
|
+
return;
|
|
8284
|
+
}
|
|
8285
|
+
reject(error);
|
|
8286
|
+
});
|
|
8287
|
+
child.on("exit", (code, signal) => {
|
|
8288
|
+
process.off("SIGINT", forward);
|
|
8289
|
+
process.off("SIGTERM", forward);
|
|
8290
|
+
if (signal) {
|
|
8291
|
+
resolve(1);
|
|
8292
|
+
return;
|
|
8293
|
+
}
|
|
8294
|
+
resolve(code ?? 1);
|
|
8295
|
+
});
|
|
8296
|
+
});
|
|
8297
|
+
return { pid: child.pid, exited };
|
|
8298
|
+
}
|
|
8299
|
+
async function defaultWritePid(homedir, pid) {
|
|
8300
|
+
const dir = yoqaRoot(homedir);
|
|
8301
|
+
await mkdir(dir, { recursive: true });
|
|
8302
|
+
await writeFile(runnerPidPath(homedir), `${pid}
|
|
8303
|
+
`, "utf8");
|
|
8304
|
+
}
|
|
8305
|
+
async function defaultReadPid(homedir) {
|
|
8306
|
+
try {
|
|
8307
|
+
const raw = (await readFile(runnerPidPath(homedir), "utf8")).trim();
|
|
8308
|
+
const pid = Number(raw);
|
|
8309
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
8310
|
+
} catch {
|
|
8311
|
+
return null;
|
|
8312
|
+
}
|
|
8313
|
+
}
|
|
8314
|
+
async function defaultRemovePid(homedir) {
|
|
8315
|
+
try {
|
|
8316
|
+
await unlink(runnerPidPath(homedir));
|
|
8317
|
+
} catch {}
|
|
8318
|
+
}
|
|
8319
|
+
function createDefaultEnsureDeps(overrides = {}) {
|
|
8320
|
+
const env = overrides.env ?? process.env;
|
|
8321
|
+
const homedir = overrides.homedir ?? osHomedir();
|
|
8322
|
+
const pathExists = overrides.pathExists ?? defaultPathExists;
|
|
8323
|
+
const which = overrides.which ?? defaultWhich;
|
|
8324
|
+
const fromUrl = overrides.fromUrl ?? import.meta.url;
|
|
8325
|
+
return {
|
|
8326
|
+
env,
|
|
8327
|
+
cwd: overrides.cwd ?? process.cwd(),
|
|
8328
|
+
execPath: overrides.execPath ?? process.execPath,
|
|
8329
|
+
argv0: overrides.argv0 ?? process.argv0,
|
|
8330
|
+
homedir,
|
|
8331
|
+
fromUrl,
|
|
8332
|
+
pathExists,
|
|
8333
|
+
which,
|
|
8334
|
+
findBun: overrides.findBun ?? (() => defaultFindBun(pathExists, which, homedir, env)),
|
|
8335
|
+
resolvePackageEntry: overrides.resolvePackageEntry ?? (() => resolvePublishedRunnerEntry(fromUrl)),
|
|
8336
|
+
fetchHealth: overrides.fetchHealth ?? ((baseUrl) => fetchRunnerHealth(baseUrl)),
|
|
8337
|
+
spawnDetached: overrides.spawnDetached ?? ((launch, spawnProcessEnv) => defaultSpawnDetached(launch, spawnProcessEnv, homedir)),
|
|
8338
|
+
spawnForeground: overrides.spawnForeground ?? ((launch, spawnProcessEnv) => defaultSpawnForeground(launch, spawnProcessEnv, homedir)),
|
|
8339
|
+
writePid: overrides.writePid ?? ((pid) => defaultWritePid(homedir, pid)),
|
|
8340
|
+
readPid: overrides.readPid ?? (() => defaultReadPid(homedir)),
|
|
8341
|
+
removePid: overrides.removePid ?? (() => defaultRemovePid(homedir)),
|
|
8342
|
+
isPidAlive: overrides.isPidAlive ?? defaultIsPidAlive,
|
|
8343
|
+
killPid: overrides.killPid ?? ((pid, signal = "SIGTERM") => {
|
|
8344
|
+
process.kill(pid, signal);
|
|
8345
|
+
}),
|
|
8346
|
+
sleep: overrides.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
8347
|
+
now: overrides.now ?? (() => Date.now()),
|
|
8348
|
+
log: overrides.log ?? ((message) => console.error(message))
|
|
8349
|
+
};
|
|
8350
|
+
}
|
|
8351
|
+
async function ensureRunner(options) {
|
|
8352
|
+
const mode = options?.mode ?? "detached";
|
|
8353
|
+
const deps = createDefaultEnsureDeps(options?.deps);
|
|
8354
|
+
const baseUrl = options?.baseUrl ?? runnerBaseUrlFromEnv(deps.env);
|
|
8355
|
+
const existing = await deps.fetchHealth(baseUrl);
|
|
8356
|
+
if (existing) {
|
|
8357
|
+
return { baseUrl, started: false };
|
|
8358
|
+
}
|
|
8359
|
+
const launch = await resolveRunnerLaunch(deps);
|
|
8360
|
+
deps.log(`yoqa: starting runner (${launch.source}) → ${baseUrl}`);
|
|
8361
|
+
if (mode === "foreground") {
|
|
8362
|
+
const child = deps.spawnForeground(launch, deps.env);
|
|
8363
|
+
await deps.writePid(child.pid);
|
|
8364
|
+
const code = await child.exited;
|
|
8365
|
+
await deps.removePid();
|
|
8366
|
+
if (code !== 0) {
|
|
8367
|
+
throw new RunnerStartError(`Runner exited with code ${code}`);
|
|
8368
|
+
}
|
|
8369
|
+
return { baseUrl, started: true, source: launch.source, pid: child.pid };
|
|
8370
|
+
}
|
|
8371
|
+
const spawned = deps.spawnDetached(launch, deps.env);
|
|
8372
|
+
await deps.writePid(spawned.pid);
|
|
8373
|
+
const health = await waitForRunnerHealth(() => deps.fetchHealth(baseUrl), {
|
|
8374
|
+
timeoutMs: HEALTH_TIMEOUT_MS,
|
|
8375
|
+
intervalMs: HEALTH_POLL_MS,
|
|
8376
|
+
sleep: deps.sleep,
|
|
8377
|
+
now: deps.now,
|
|
8378
|
+
isAlive: () => deps.isPidAlive(spawned.pid)
|
|
8379
|
+
});
|
|
8380
|
+
if (!health) {
|
|
8381
|
+
try {
|
|
8382
|
+
deps.killPid(spawned.pid);
|
|
8383
|
+
} catch {}
|
|
8384
|
+
await deps.removePid();
|
|
8385
|
+
throw new RunnerStartError(`Local runner failed to start at ${baseUrl}. Try: yoqa serve`);
|
|
8386
|
+
}
|
|
8387
|
+
return { baseUrl, started: true, source: launch.source, pid: spawned.pid };
|
|
8388
|
+
}
|
|
8389
|
+
async function stopOwnedRunner(depsPartial = {}) {
|
|
8390
|
+
const deps = createDefaultEnsureDeps(depsPartial);
|
|
8391
|
+
const pid = await deps.readPid();
|
|
8392
|
+
if (pid == null) {
|
|
8393
|
+
return { ok: true, message: "No yoqa-owned runner pid file" };
|
|
8394
|
+
}
|
|
8395
|
+
if (!deps.isPidAlive(pid)) {
|
|
8396
|
+
await deps.removePid();
|
|
8397
|
+
return { ok: true, message: `Stale pid ${pid} removed` };
|
|
8398
|
+
}
|
|
8399
|
+
try {
|
|
8400
|
+
deps.killPid(pid, "SIGTERM");
|
|
8401
|
+
} catch (error) {
|
|
8402
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
8403
|
+
return { ok: false, message: `Failed to stop pid ${pid}: ${message}` };
|
|
8404
|
+
}
|
|
8405
|
+
await deps.removePid();
|
|
8406
|
+
return { ok: true, message: `Stopped runner pid ${pid}` };
|
|
8407
|
+
}
|
|
8408
|
+
function commandChainIncludes(command, name) {
|
|
8409
|
+
let current = command;
|
|
8410
|
+
while (current && typeof current.name === "function") {
|
|
8411
|
+
if (current.name() === name)
|
|
8412
|
+
return true;
|
|
8413
|
+
current = current.parent;
|
|
8414
|
+
}
|
|
8415
|
+
return false;
|
|
8416
|
+
}
|
|
8417
|
+
|
|
8418
|
+
// src/main.ts
|
|
8419
|
+
function runnerBaseUrl() {
|
|
8420
|
+
return runnerBaseUrlFromEnv();
|
|
8421
|
+
}
|
|
7902
8422
|
function sleep(ms) {
|
|
7903
8423
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
7904
8424
|
}
|
|
@@ -7910,9 +8430,43 @@ function client(baseUrl) {
|
|
|
7910
8430
|
function fail(command, error) {
|
|
7911
8431
|
const message = error instanceof Error ? error.message : String(error);
|
|
7912
8432
|
console.error(`yoqa ${command} failed: ${message}`);
|
|
7913
|
-
|
|
8433
|
+
if (!message.includes("yoqa serve") && !message.includes("Bun")) {
|
|
8434
|
+
console.error("Is the runner up? Try: yoqa serve");
|
|
8435
|
+
}
|
|
7914
8436
|
process.exitCode = 1;
|
|
7915
8437
|
}
|
|
8438
|
+
program2.command("serve").description("Start the local Yoqa runner in the foreground (no desktop app required)").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--stop", "Stop a runner previously started by yoqa (pid file)").action(async (options) => {
|
|
8439
|
+
try {
|
|
8440
|
+
if (options.stop) {
|
|
8441
|
+
const result = await stopOwnedRunner();
|
|
8442
|
+
console.log(result.message);
|
|
8443
|
+
if (!result.ok)
|
|
8444
|
+
process.exitCode = 1;
|
|
8445
|
+
return;
|
|
8446
|
+
}
|
|
8447
|
+
const baseUrl = options.baseUrl;
|
|
8448
|
+
const health = await fetchRunnerHealth(baseUrl);
|
|
8449
|
+
if (health) {
|
|
8450
|
+
console.log(`already running at ${baseUrl} (${health.version})`);
|
|
8451
|
+
return;
|
|
8452
|
+
}
|
|
8453
|
+
await ensureRunner({ mode: "foreground", baseUrl });
|
|
8454
|
+
} catch (error) {
|
|
8455
|
+
fail("serve", error);
|
|
8456
|
+
}
|
|
8457
|
+
});
|
|
8458
|
+
program2.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
8459
|
+
if (commandChainIncludes(actionCommand, "serve"))
|
|
8460
|
+
return;
|
|
8461
|
+
if (shouldSkipAutostart())
|
|
8462
|
+
return;
|
|
8463
|
+
try {
|
|
8464
|
+
await ensureRunner({ mode: "detached" });
|
|
8465
|
+
} catch (error) {
|
|
8466
|
+
fail("autostart", error);
|
|
8467
|
+
throw error;
|
|
8468
|
+
}
|
|
8469
|
+
});
|
|
7916
8470
|
async function resolveAppId(c, prefixOrId) {
|
|
7917
8471
|
const apps = await c.listApps();
|
|
7918
8472
|
const match = apps.find((a) => a.prefix === prefixOrId) ?? apps.find((a) => a.id === prefixOrId);
|
|
@@ -8309,13 +8863,7 @@ program2.command("doctor").description("Diagnose local tooling, drivers, and lef
|
|
|
8309
8863
|
console.log(JSON.stringify(result, null, 2));
|
|
8310
8864
|
} else {
|
|
8311
8865
|
console.log(result.message);
|
|
8312
|
-
console.log(result.report
|
|
8313
|
-
for (const check of result.report.checks) {
|
|
8314
|
-
console.log(` [${check.status}] ${check.label}${check.detail ? ` — ${check.detail}` : ""}`);
|
|
8315
|
-
}
|
|
8316
|
-
for (const step of result.report.steps) {
|
|
8317
|
-
console.log(` → [${step.severity}] ${step.title}: ${step.detail}`);
|
|
8318
|
-
}
|
|
8866
|
+
console.log(formatDoctorReport(result.report));
|
|
8319
8867
|
}
|
|
8320
8868
|
if (!result.report.ok)
|
|
8321
8869
|
process.exitCode = 1;
|
|
@@ -8325,16 +8873,7 @@ program2.command("doctor").description("Diagnose local tooling, drivers, and lef
|
|
|
8325
8873
|
if (options.json) {
|
|
8326
8874
|
console.log(JSON.stringify(report, null, 2));
|
|
8327
8875
|
} else {
|
|
8328
|
-
console.log(report
|
|
8329
|
-
for (const check of report.checks) {
|
|
8330
|
-
console.log(` [${check.status}] ${check.label}${check.detail ? ` — ${check.detail}` : ""}`);
|
|
8331
|
-
}
|
|
8332
|
-
if (report.steps.length > 0) {
|
|
8333
|
-
console.log("steps:");
|
|
8334
|
-
for (const step of report.steps) {
|
|
8335
|
-
console.log(` → [${step.severity}] ${step.title}: ${step.detail}`);
|
|
8336
|
-
}
|
|
8337
|
-
}
|
|
8876
|
+
console.log(formatDoctorReport(report));
|
|
8338
8877
|
}
|
|
8339
8878
|
if (!report.ok)
|
|
8340
8879
|
process.exitCode = 1;
|
|
@@ -8735,7 +9274,7 @@ runsCmd.command("report").description("Export a detailed HTML or Markdown report
|
|
|
8735
9274
|
const contents = format === "html" ? formatRunReportHtml(doc) : formatRunReportMarkdown(doc);
|
|
8736
9275
|
const extension = format === "html" ? "html" : "md";
|
|
8737
9276
|
const outputPath = options.output?.trim() || `${suggestedRunReportBasename(doc)}.${extension}`;
|
|
8738
|
-
await
|
|
9277
|
+
await writeFile2(outputPath, contents, "utf8");
|
|
8739
9278
|
console.log(`wrote ${outputPath} (${doc.status}, ${steps.length} steps)`);
|
|
8740
9279
|
} catch (error) {
|
|
8741
9280
|
fail("runs report", error);
|
|
@@ -8802,7 +9341,7 @@ runsCmd.command("create").argument("<app>", "App prefix or id").requiredOption("
|
|
|
8802
9341
|
var scriptCmd = program2.command("script").description("Replay exported CaseScript files");
|
|
8803
9342
|
scriptCmd.command("run").argument("<file>", "Path to a .yoqa.json / CaseScript JSON file").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON per step").action(async (file, options) => {
|
|
8804
9343
|
try {
|
|
8805
|
-
const raw = await
|
|
9344
|
+
const raw = await readFile2(file, "utf8");
|
|
8806
9345
|
const parsed = caseScriptSchema.safeParse(JSON.parse(raw));
|
|
8807
9346
|
if (!parsed.success) {
|
|
8808
9347
|
throw new Error(`Invalid CaseScript: ${parsed.error.message}`);
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yoqa/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.13",
|
|
4
4
|
"description": "Yoqa CLI — drive local mobile QA from the terminal or CI (talks to the Yoqa runner over HTTP)",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"yoqa": "dist/main.js"
|
|
9
9
|
},
|
|
10
|
-
"files": [
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
11
13
|
"engines": {
|
|
12
14
|
"node": ">=20"
|
|
13
15
|
},
|
|
@@ -20,7 +22,14 @@
|
|
|
20
22
|
"bugs": {
|
|
21
23
|
"url": "https://github.com/Aris-ngoy/qa-agent/issues"
|
|
22
24
|
},
|
|
23
|
-
"keywords": [
|
|
25
|
+
"keywords": [
|
|
26
|
+
"yoqa",
|
|
27
|
+
"cli",
|
|
28
|
+
"mobile",
|
|
29
|
+
"qa",
|
|
30
|
+
"appium",
|
|
31
|
+
"testing"
|
|
32
|
+
],
|
|
24
33
|
"scripts": {
|
|
25
34
|
"yoqa": "bun run src/main.ts",
|
|
26
35
|
"build": "bun run build.ts",
|
|
@@ -28,6 +37,9 @@
|
|
|
28
37
|
"check": "tsc --noEmit",
|
|
29
38
|
"health": "bun run src/main.ts health"
|
|
30
39
|
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@yoqa/runner": "0.3.13"
|
|
42
|
+
},
|
|
31
43
|
"devDependencies": {
|
|
32
44
|
"@types/node": "^22.15.30",
|
|
33
45
|
"@yoqa/runner-client": "workspace:*",
|