@testin-official/xagent 0.3.12 → 0.3.14
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 +98 -2
- package/dist/assets/contracts/app-upload.schema.json +3 -2
- package/dist/assets/contracts/build-create.schema.json +17 -0
- package/dist/assets/contracts/build-list.schema.json +18 -0
- package/dist/assets/contracts/build.schema.json +76 -0
- package/dist/assets/contracts/execution-target.schema.json +12 -4
- package/dist/assets/contracts/fixtures/execution-target.concurrency-twenty-one.invalid.json +4 -0
- package/dist/assets/contracts/fixtures/execution-target.concurrency-two.invalid.json +1 -1
- package/dist/assets/contracts/fixtures/execution-target.concurrency-two.valid.json +4 -0
- package/dist/assets/contracts/fixtures/execution-target.count-eleven.invalid.json +6 -0
- package/dist/assets/contracts/fixtures/execution-target.count-two.invalid.json +1 -1
- package/dist/assets/contracts/fixtures/execution-target.count-two.valid.json +6 -0
- package/dist/assets/contracts/fixtures/execution-target.multiple-devices.invalid.json +23 -1
- package/dist/assets/contracts/fixtures/execution-target.multiple-devices.valid.json +4 -0
- package/dist/assets/contracts/fixtures/execution-target.multiple-filters.invalid.json +66 -2
- package/dist/assets/contracts/fixtures/execution-target.multiple-filters.valid.json +7 -0
- package/dist/assets/contracts/fixtures/target-hash-cases.json +52 -1
- package/dist/assets/contracts/fixtures/target-preview.success.json +4 -1
- package/dist/assets/contracts/fixtures/target-preview.unavailable.json +4 -1
- package/dist/assets/contracts/run-create-result.schema.json +18 -0
- package/dist/assets/contracts/run-create-v2.schema.json +85 -19
- package/dist/assets/contracts/run.schema.json +179 -34
- package/dist/assets/contracts/target-preview.schema.json +89 -19
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +324 -19
- package/dist/cli.js.map +1 -1
- package/dist/core/version.d.ts +1 -1
- package/dist/core/version.js +1 -1
- package/dist/project/target-matrix.d.ts +4 -0
- package/dist/project/target-matrix.d.ts.map +1 -0
- package/dist/project/target-matrix.js +26 -0
- package/dist/project/target-matrix.js.map +1 -0
- package/dist/run/batch.d.ts +44 -0
- package/dist/run/batch.d.ts.map +1 -0
- package/dist/run/batch.js +261 -0
- package/dist/run/batch.js.map +1 -0
- package/dist/run/builds.d.ts +20 -0
- package/dist/run/builds.d.ts.map +1 -0
- package/dist/run/builds.js +70 -0
- package/dist/run/builds.js.map +1 -0
- package/dist/run/create-result.d.ts +6 -0
- package/dist/run/create-result.d.ts.map +1 -0
- package/dist/run/create-result.js +18 -0
- package/dist/run/create-result.js.map +1 -0
- package/dist/run/exit-codes.d.ts +8 -0
- package/dist/run/exit-codes.d.ts.map +1 -0
- package/dist/run/exit-codes.js +47 -0
- package/dist/run/exit-codes.js.map +1 -0
- package/dist/run/idempotency.d.ts +2 -0
- package/dist/run/idempotency.d.ts.map +1 -0
- package/dist/run/idempotency.js +13 -0
- package/dist/run/idempotency.js.map +1 -0
- package/dist/run/junit-suite.d.ts +17 -0
- package/dist/run/junit-suite.d.ts.map +1 -0
- package/dist/run/junit-suite.js +98 -0
- package/dist/run/junit-suite.js.map +1 -0
- package/dist/run/junit.d.ts +19 -0
- package/dist/run/junit.d.ts.map +1 -0
- package/dist/run/junit.js +218 -0
- package/dist/run/junit.js.map +1 -0
- package/dist/run/scripts.d.ts +9 -0
- package/dist/run/scripts.d.ts.map +1 -0
- package/dist/run/scripts.js +156 -0
- package/dist/run/scripts.js.map +1 -0
- package/dist/sdk/client.d.ts +7 -0
- package/dist/sdk/client.d.ts.map +1 -1
- package/dist/sdk/client.js +46 -0
- package/dist/sdk/client.js.map +1 -1
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync, realpathSync } from "node:fs";
|
|
3
|
-
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import { stdin as input, stdout as output } from "node:process";
|
|
5
5
|
import { Command, CommanderError } from "commander";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
@@ -13,6 +13,13 @@ import { CliError, XAgentApiError } from "./core/errors.js";
|
|
|
13
13
|
import { onInterrupt } from "./core/interrupt.js";
|
|
14
14
|
import { CANONICAL_SCRIPT_SCHEMA_VERSION, CONFIG_SCHEMA_VERSION, DEVELOPER_API_VERSION, PRINCIPAL_SCHEMA_VERSION, PROJECT_SCHEMA_VERSION, PUBLIC_SCRIPT_SCHEMA_VERSION, VERSION } from "./core/version.js";
|
|
15
15
|
import { loadProjectConfig } from "./project/config.js";
|
|
16
|
+
import { isTerminalRunStatus, writeRunJUnitFile } from "./run/junit.js";
|
|
17
|
+
import { writeBuildJUnitFile } from "./run/junit-suite.js";
|
|
18
|
+
import { runScriptBatch } from "./run/batch.js";
|
|
19
|
+
import { isMatrixTarget, previewMeetsAvailabilityRequirement } from "./project/target-matrix.js";
|
|
20
|
+
import { collectRunScripts, shouldCollectScripts } from "./run/scripts.js";
|
|
21
|
+
import { inferBuildStart } from "./run/builds.js";
|
|
22
|
+
import { worstExitCode, exitCodeForRunStatus, API_EXIT } from "./run/exit-codes.js";
|
|
16
23
|
import { createRunProgressReporter } from "./run/progress.js";
|
|
17
24
|
import { parseYaml } from "./script/service.js";
|
|
18
25
|
import { XAgentClient } from "./sdk/client.js";
|
|
@@ -21,14 +28,6 @@ import { runStdioServer } from "./mcp/server.js";
|
|
|
21
28
|
import { MCP_MODES } from "./mcp/tools.js";
|
|
22
29
|
import { detectHost, doctorHost, HOSTS, installHost, routeOutput, syncHost, uninstallHost, verifySkillDirectory } from "./integrations/manager.js";
|
|
23
30
|
import { DEFAULT_WORKFLOW_DOMAIN, USER_WORKFLOW_DOMAINS, USER_WORKFLOW_ROUTES, routeForDomain } from "./integrations/routing.js";
|
|
24
|
-
const API_EXIT = {
|
|
25
|
-
invalid_request: 2, schema_invalid: 2, configuration_invalid: 2, target_not_found: 2, target_invalid: 2,
|
|
26
|
-
target_hash_mismatch: 2, idempotency_conflict: 2, authoring_state_invalid: 2, revision_conflict: 2, action_in_progress: 2, observation_stale: 2,
|
|
27
|
-
attempt_in_progress: 2, authentication_required: 3, token_invalid: 3, token_expired: 3, token_revoked: 3,
|
|
28
|
-
token_type_mismatch: 3, permission_denied: 3, cancelled: 4, device_unavailable: 5, resource_not_found: 5,
|
|
29
|
-
authoring_session_expired: 5, authoring_session_not_active: 5,
|
|
30
|
-
test_failed: 1, infrastructure_failed: 6, invalid_response: 6, authoring_session_renewal_failed: 6, timeout: 7,
|
|
31
|
-
};
|
|
32
31
|
// `fail()` runs from the top-level catch, where the parsed options are out of
|
|
33
32
|
// scope. It used to re-derive the output format from `process.argv` — a second
|
|
34
33
|
// source of truth next to the `ctx.json` every other path uses, and the wrong
|
|
@@ -180,7 +179,28 @@ function billingText(value) {
|
|
|
180
179
|
return billing ? ` billing=${billing.status} minutes=${billing.billable_minutes} cost=${billing.cost}` : "";
|
|
181
180
|
}
|
|
182
181
|
function emitRun(ctx, run, useStatusExit = true) { emit(ctx, run, `Run ${run.id} status=${run.status}${billingText(run)}`); if (!useStatusExit)
|
|
183
|
-
return; process.exitCode =
|
|
182
|
+
return; process.exitCode = exitCodeForRunStatus(run.status, run.reason) ?? 0; }
|
|
183
|
+
function junitIncompleteReason(status, error, aborted = false) {
|
|
184
|
+
if (isTerminalRunStatus(status))
|
|
185
|
+
return undefined;
|
|
186
|
+
if (aborted)
|
|
187
|
+
return "cancelled";
|
|
188
|
+
if (error instanceof XAgentApiError && error.code === "timeout")
|
|
189
|
+
return "wait_timeout";
|
|
190
|
+
if (error instanceof XAgentApiError && error.code === "cancelled")
|
|
191
|
+
return "cancelled";
|
|
192
|
+
return "incomplete";
|
|
193
|
+
}
|
|
194
|
+
async function maybeWriteJUnit(file, run, options) {
|
|
195
|
+
if (!file || !run)
|
|
196
|
+
return;
|
|
197
|
+
try {
|
|
198
|
+
await writeRunJUnitFile(String(file), run, options);
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
process.stderr.write(`Warning: unable to write --junit ${file}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
184
204
|
function progressReporter(ctx) { return createRunProgressReporter(ctx); }
|
|
185
205
|
/** Human summary for `doctor`, derived from the same checks the JSON reports. */
|
|
186
206
|
function doctorSummary(checks, profile) {
|
|
@@ -430,16 +450,44 @@ const targets = program.command("targets");
|
|
|
430
450
|
targets.command("list").action(async function () { const ctx = context(this); const project = await loadProjectConfig(ctx.projectConfig); emit(ctx, { schema_version: PROJECT_SCHEMA_VERSION, config_path: project.path, default_target: project.defaultTarget, targets: project.listTargets().map((item) => ({ name: item.name, target_hash: item.targetHash, target: item.definition, default: item.name === project.defaultTarget })) }); });
|
|
431
451
|
targets.command("show").argument("<name>").action(async function (name) { const ctx = context(this); const project = await loadProjectConfig(ctx.projectConfig); const item = project.resolveTarget(name); emit(ctx, { schema_version: PROJECT_SCHEMA_VERSION, config_path: project.path, name: item.name, target_hash: item.targetHash, target: item.definition, default: name === project.defaultTarget }); });
|
|
432
452
|
targets.command("validate").argument("[name]").action(async function (name) { const ctx = context(this); const project = await loadProjectConfig(ctx.projectConfig); const selected = name ? [project.resolveTarget(name)] : project.listTargets(); emit(ctx, { valid: true, schema_version: PROJECT_SCHEMA_VERSION, config_path: project.path, targets: selected.map((item) => ({ name: item.name, target_hash: item.targetHash, target: item.definition })) }, `${project.path}: valid (${selected.length} Targets)`); });
|
|
433
|
-
targets.command("preview").argument("<name>").
|
|
453
|
+
targets.command("preview").argument("<name>").option("--require-available", "Exit with device_unavailable (5) when fewer devices are available than the Target requests").action(async function (name, options) {
|
|
454
|
+
const ctx = context(this);
|
|
455
|
+
const project = await loadProjectConfig(ctx.projectConfig);
|
|
456
|
+
const item = project.resolveTarget(name);
|
|
457
|
+
const { profile, client } = await authenticatedClient(ctx);
|
|
458
|
+
requireProjectCi(profile, "Target preview");
|
|
459
|
+
const preview = await client.previewTarget({ schema_version: "xagent-target-preview/v1", target_name: item.name, target_hash: item.targetHash, target: item.definition });
|
|
460
|
+
emit(ctx, preview);
|
|
461
|
+
if (options.requireAvailable && !previewMeetsAvailabilityRequirement(preview)) {
|
|
462
|
+
const requested = Number(preview?.requested_count);
|
|
463
|
+
const available = Number(preview?.available_count);
|
|
464
|
+
if (Number.isInteger(requested) && requested > 1) {
|
|
465
|
+
process.stderr.write(`Target ${item.name} needs ${requested} devices, ${Number.isInteger(available) ? available : 0} available\n`);
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
process.stderr.write(`Target ${item.name} has no available device\n`);
|
|
469
|
+
}
|
|
470
|
+
process.exitCode = API_EXIT.device_unavailable;
|
|
471
|
+
}
|
|
472
|
+
});
|
|
434
473
|
const apps = program.command("apps");
|
|
435
474
|
apps.command("list").option("--keyword <text>").option("--app-name <name>").option("--package-name <name>").option("--page <number>").option("--page-size <number>").action(async function (options) { const ctx = context(this); const query = { keyword: options.keyword, app_name: options.appName, package_name: options.packageName, page: options.page, page_size: options.pageSize }; const result = await (await authenticatedClient(ctx)).client.listApps(query); emit(ctx, result, `${result.total} application package(s)`); });
|
|
436
475
|
apps.command("show").argument("<packageId>").action(async function (packageId) { const ctx = context(this); const parsed = Number(packageId); if (!Number.isInteger(parsed) || parsed < 1)
|
|
437
476
|
throw new CliError("invalid_request", "package-id must be a positive integer"); const result = await (await authenticatedClient(ctx)).client.getApp(parsed); emit(ctx, result, `${result.package_id}: ${result.package_name}`); });
|
|
438
|
-
apps.command("upload").argument("<apkFile>").option("--upload-timeout <seconds>", undefined, "900").
|
|
439
|
-
|
|
477
|
+
apps.command("upload").argument("<apkFile>").option("--upload-timeout <seconds>", undefined, "900").option("--promote", "Keep the uploaded package so several Runs can use its package_id").action(async function (apkFile, options) {
|
|
478
|
+
const ctx = context(this);
|
|
479
|
+
if (!apkFile.toLowerCase().endsWith(".apk"))
|
|
480
|
+
throw new CliError("invalid_request", "apps upload requires an APK file");
|
|
481
|
+
const { client } = await authenticatedClient(ctx, Number(options.uploadTimeout) * 1000);
|
|
482
|
+
let result = await client.uploadApp(apkFile, (done, total) => (ctx.json ? process.stderr : process.stdout).write(`APK 分片上传:${done}/${total}\n`));
|
|
483
|
+
if (options.promote)
|
|
484
|
+
result = await client.promoteApp(String(result.id));
|
|
485
|
+
emit(ctx, result, `Uploaded ${apkFile}: package_id=${result.package_id} package=${result.app?.package_name} status=${result.status}`);
|
|
486
|
+
});
|
|
440
487
|
apps.command("release").argument("<appUploadId>").action(async function (id) { const ctx = context(this); const { client } = await authenticatedClient(ctx); const result = await client.releaseApp(id); emit(ctx, result, `App upload ${id} status=${result.status}`); });
|
|
488
|
+
apps.command("promote").argument("<appUploadId>").action(async function (id) { const ctx = context(this); const { client } = await authenticatedClient(ctx); const result = await client.promoteApp(id); emit(ctx, result, `App upload ${id} status=${result.status} package_id=${result.package_id}`); });
|
|
441
489
|
program.command("run")
|
|
442
|
-
.argument("<
|
|
490
|
+
.argument("<paths...>", "Script files, directories of xagent-script/v1 YAML, or globs")
|
|
443
491
|
.option("--package-id <id>")
|
|
444
492
|
.option("--app-upload-id <id>")
|
|
445
493
|
.option("--apk <path>")
|
|
@@ -448,12 +496,45 @@ program.command("run")
|
|
|
448
496
|
.option("--idempotency-key <key>")
|
|
449
497
|
.option("--var <assignment>", "Runtime variable NAME=VALUE", (value, previous) => [...previous, value], [])
|
|
450
498
|
.option("--timeout-ms <milliseconds>", undefined, "600000")
|
|
499
|
+
.option("--device-wait-timeout-ms <milliseconds>", "How long a v2 Run may wait for a busy device before failing (0 disables queueing; default 600000)", "600000")
|
|
451
500
|
.option("--wait")
|
|
452
501
|
.option("--wait-timeout <seconds>", undefined, "900")
|
|
453
502
|
.option("--poll-interval <seconds>", "Seconds between Run status checks while --wait is active", "2")
|
|
454
503
|
.option("--upload-timeout <seconds>", undefined, "900")
|
|
455
|
-
.
|
|
504
|
+
.option("--run-id-file <path>", "Write the Run ID to this file as soon as the Run is created")
|
|
505
|
+
.option("--output <path>", "Write the last known Run JSON to this file")
|
|
506
|
+
.option("--junit <path>", "Write a JUnit XML report for this Run, or a suite report when multiple scripts or a matrix Target are used")
|
|
507
|
+
.option("--build-id <id>", "Attach created Runs to an existing build")
|
|
508
|
+
.option("--build <mode>", "Pass auto to start a build, attach every Run, and close it when the command ends")
|
|
509
|
+
.option("--max-parallel <count>", "How many Runs to submit at once (default 1)", "1")
|
|
510
|
+
.option("--promote", "Promote --app-upload-id to a reusable package_id before submitting multiple scripts or a matrix Target")
|
|
511
|
+
.action(async function (paths, options) {
|
|
456
512
|
const ctx = context(this);
|
|
513
|
+
const maxParallel = Number(options.maxParallel);
|
|
514
|
+
if (!Number.isInteger(maxParallel) || maxParallel < 1)
|
|
515
|
+
throw new CliError("invalid_request", "--max-parallel must be a positive integer");
|
|
516
|
+
let buildAuto = String(options.build ?? "") === "auto";
|
|
517
|
+
if (options.build !== undefined && !buildAuto)
|
|
518
|
+
throw new CliError("invalid_request", "--build only accepts auto");
|
|
519
|
+
if (options.buildId && buildAuto)
|
|
520
|
+
throw new CliError("invalid_request", "--build-id and --build auto cannot be used together");
|
|
521
|
+
let useBatch = Boolean(options.buildId) || buildAuto || await shouldCollectScripts(paths);
|
|
522
|
+
try {
|
|
523
|
+
const target = (await loadProjectConfig(ctx.projectConfig)).resolveTarget(options.target);
|
|
524
|
+
if (isMatrixTarget(target.definition)) {
|
|
525
|
+
useBatch = true;
|
|
526
|
+
if (!options.buildId && !buildAuto)
|
|
527
|
+
buildAuto = true;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
// The single-script or batch path will surface a missing or invalid project config.
|
|
532
|
+
}
|
|
533
|
+
if (useBatch) {
|
|
534
|
+
await runBatchFromCli(this, paths, options, maxParallel, buildAuto);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
const scriptFile = paths[0];
|
|
457
538
|
const parsed = await loadScript(scriptFile);
|
|
458
539
|
const supplied = [options.packageId, options.appUploadId, options.apk].filter((item) => item !== undefined).length;
|
|
459
540
|
if (supplied > 1)
|
|
@@ -488,6 +569,11 @@ program.command("run")
|
|
|
488
569
|
const pollIntervalSeconds = Number(options.pollInterval);
|
|
489
570
|
if (!Number.isFinite(pollIntervalSeconds) || pollIntervalSeconds <= 0)
|
|
490
571
|
throw new CliError("invalid_request", "--poll-interval must be a positive number of seconds");
|
|
572
|
+
const deviceWaitTimeoutMs = Number(options.deviceWaitTimeoutMs);
|
|
573
|
+
if (!Number.isInteger(deviceWaitTimeoutMs) || deviceWaitTimeoutMs < 0 || deviceWaitTimeoutMs > 3_600_000)
|
|
574
|
+
throw new CliError("invalid_request", "--device-wait-timeout-ms must be an integer from 0 to 3600000");
|
|
575
|
+
if (options.wait && waitTimeoutSeconds * 1000 <= timeoutMs + deviceWaitTimeoutMs)
|
|
576
|
+
process.stderr.write(`Warning: --wait-timeout ${waitTimeoutSeconds}s is not longer than --timeout-ms ${timeoutMs}ms plus --device-wait-timeout-ms ${deviceWaitTimeoutMs}ms; the CLI may stop waiting while the Run is still queued or running\n`);
|
|
491
577
|
// One definition for "was a key supplied, and does it carry anything": the
|
|
492
578
|
// personal branch tested the raw value while the project_ci branch tested the
|
|
493
579
|
// trimmed one, so `--idempotency-key ""` slipped past the first check.
|
|
@@ -518,7 +604,7 @@ program.command("run")
|
|
|
518
604
|
if (!suppliedIdempotencyKey)
|
|
519
605
|
throw new CliError("invalid_request", "--idempotency-key is required for a project_ci Run");
|
|
520
606
|
const target = (await loadProjectConfig(ctx.projectConfig)).resolveTarget(options.target);
|
|
521
|
-
request = { schema_version: "xagent-run-create/v2", script, target_name: target.name, target_hash: target.targetHash, target: target.definition, timeout_ms: timeoutMs, source_hash: parsed.sourceHash };
|
|
607
|
+
request = { schema_version: "xagent-run-create/v2", script, target_name: target.name, target_hash: target.targetHash, target: target.definition, timeout_ms: timeoutMs, device_wait_timeout_ms: deviceWaitTimeoutMs, source_hash: parsed.sourceHash };
|
|
522
608
|
}
|
|
523
609
|
const variables = parseVariables(options.var ?? []);
|
|
524
610
|
if (Object.keys(variables).length)
|
|
@@ -530,7 +616,25 @@ program.command("run")
|
|
|
530
616
|
// Ctrl+C would otherwise skip the finally below and leave both the APK
|
|
531
617
|
// and a Run occupying a device allocated until each expires on its own.
|
|
532
618
|
let createdRunId;
|
|
619
|
+
let latestRun;
|
|
620
|
+
let waitStartedAt;
|
|
621
|
+
const writeRunOutput = async () => {
|
|
622
|
+
if (!options.output || !latestRun)
|
|
623
|
+
return;
|
|
624
|
+
try {
|
|
625
|
+
await writeFile(options.output, `${JSON.stringify(latestRun, null, 2)}\n`, "utf8");
|
|
626
|
+
}
|
|
627
|
+
catch (error) {
|
|
628
|
+
process.stderr.write(`Warning: unable to write --output ${options.output}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
629
|
+
}
|
|
630
|
+
};
|
|
533
631
|
const waiting = new AbortController();
|
|
632
|
+
const writeJUnitOutput = () => maybeWriteJUnit(options.junit, latestRun, {
|
|
633
|
+
scriptName: typeof parsed.script?.name === "string" ? parsed.script.name : undefined,
|
|
634
|
+
scriptFile,
|
|
635
|
+
elapsedSeconds: waitStartedAt === undefined ? undefined : (Date.now() - waitStartedAt) / 1000,
|
|
636
|
+
incompleteReason: junitIncompleteReason(latestRun?.status, primaryError, waiting.signal.aborted),
|
|
637
|
+
});
|
|
534
638
|
// `onInterrupt()` registers a cleanup and hands back its deregistration
|
|
535
639
|
// function, so this is "detach", not "release" — calling it does not release
|
|
536
640
|
// anything on its own.
|
|
@@ -555,6 +659,8 @@ program.command("run")
|
|
|
555
659
|
report(`Release app upload ${uploadedId}`, error);
|
|
556
660
|
}
|
|
557
661
|
}
|
|
662
|
+
await writeRunOutput();
|
|
663
|
+
await writeJUnitOutput();
|
|
558
664
|
});
|
|
559
665
|
try {
|
|
560
666
|
if (options.apk) {
|
|
@@ -570,8 +676,19 @@ program.command("run")
|
|
|
570
676
|
request.app_upload_id = options.appUploadId;
|
|
571
677
|
run = await client.createRun(request, suppliedIdempotencyKey);
|
|
572
678
|
createdRunId = String(run.id ?? "") || undefined;
|
|
679
|
+
latestRun = run;
|
|
680
|
+
if (options.runIdFile && createdRunId) {
|
|
681
|
+
try {
|
|
682
|
+
await writeFile(options.runIdFile, `${createdRunId}\n`, "utf8");
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
process.stderr.write(`Warning: unable to write --run-id-file ${options.runIdFile}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
573
688
|
if (options.wait) {
|
|
574
|
-
|
|
689
|
+
const reportProgress = progressReporter(ctx);
|
|
690
|
+
waitStartedAt = Date.now();
|
|
691
|
+
run = await client.waitRun(run.id, waitTimeoutSeconds, pollIntervalSeconds, (value) => { latestRun = value; reportProgress(value); }, waiting.signal);
|
|
575
692
|
createdRunId = undefined; // reached a terminal state, nothing left to cancel
|
|
576
693
|
}
|
|
577
694
|
}
|
|
@@ -580,6 +697,8 @@ program.command("run")
|
|
|
580
697
|
throw error;
|
|
581
698
|
}
|
|
582
699
|
finally {
|
|
700
|
+
await writeRunOutput();
|
|
701
|
+
await writeJUnitOutput();
|
|
583
702
|
if (uploadedId) {
|
|
584
703
|
const releasing = uploadedId;
|
|
585
704
|
// Cleared only on success so an interrupt mid-release still retries it.
|
|
@@ -596,12 +715,198 @@ program.command("run")
|
|
|
596
715
|
}
|
|
597
716
|
emitRun(ctx, run);
|
|
598
717
|
});
|
|
718
|
+
const builds = program.command("builds");
|
|
719
|
+
builds.command("start")
|
|
720
|
+
.option("--name <name>")
|
|
721
|
+
.option("--external-id <id>")
|
|
722
|
+
.option("--commit <sha>")
|
|
723
|
+
.option("--ref <name>")
|
|
724
|
+
.option("--source <source>", "gitlab, github, or other")
|
|
725
|
+
.option("--pipeline-url <url>")
|
|
726
|
+
.option("--idempotency-key <key>")
|
|
727
|
+
.action(async function (options) {
|
|
728
|
+
const ctx = context(this);
|
|
729
|
+
const profile = await selectedProfile(ctx);
|
|
730
|
+
requireProjectCi(profile, "builds start");
|
|
731
|
+
const inferred = inferBuildStart({
|
|
732
|
+
name: options.name,
|
|
733
|
+
externalId: options.externalId,
|
|
734
|
+
commit: options.commit,
|
|
735
|
+
ref: options.ref,
|
|
736
|
+
source: options.source,
|
|
737
|
+
pipelineUrl: options.pipelineUrl,
|
|
738
|
+
idempotencyKey: options.idempotencyKey,
|
|
739
|
+
});
|
|
740
|
+
const key = inferred.idempotency_key;
|
|
741
|
+
if (!key)
|
|
742
|
+
throw new CliError("invalid_request", "--idempotency-key is required when CI job metadata is not available");
|
|
743
|
+
const body = { schema_version: "xagent-build-create/v1", source: inferred.source ?? "other" };
|
|
744
|
+
if (inferred.name)
|
|
745
|
+
body.name = inferred.name;
|
|
746
|
+
if (inferred.external_id)
|
|
747
|
+
body.external_id = inferred.external_id;
|
|
748
|
+
if (inferred.commit_sha)
|
|
749
|
+
body.commit_sha = inferred.commit_sha;
|
|
750
|
+
if (inferred.ref)
|
|
751
|
+
body.ref = inferred.ref;
|
|
752
|
+
if (inferred.pipeline_url)
|
|
753
|
+
body.pipeline_url = inferred.pipeline_url;
|
|
754
|
+
const { client } = await authenticatedClient(ctx);
|
|
755
|
+
const build = await client.createBuild(body, key);
|
|
756
|
+
emit(ctx, build, `Build ${build.id} status=${build.status}`);
|
|
757
|
+
});
|
|
758
|
+
builds.command("show").argument("<buildId>").action(async function (buildId) {
|
|
759
|
+
const ctx = context(this);
|
|
760
|
+
const build = await (await authenticatedClient(ctx)).client.getBuild(buildId);
|
|
761
|
+
emit(ctx, build, `Build ${build.id} status=${build.status} runs=${(build.runs ?? []).length}`);
|
|
762
|
+
});
|
|
763
|
+
builds.command("wait").argument("<buildId>").option("--timeout <seconds>", undefined, "900").option("--poll-interval <seconds>", undefined, "2").option("--junit <path>", "Write a suite-level JUnit XML report for this build").action(async function (buildId, options) {
|
|
764
|
+
const ctx = context(this);
|
|
765
|
+
const started = Date.now();
|
|
766
|
+
const { client } = await authenticatedClient(ctx);
|
|
767
|
+
let latest;
|
|
768
|
+
let primaryError;
|
|
769
|
+
try {
|
|
770
|
+
const build = await client.waitBuild(buildId, Number(options.timeout), Number(options.pollInterval), (value) => { latest = value; });
|
|
771
|
+
latest = build;
|
|
772
|
+
const codes = (build.runs ?? []).map((item) => exitCodeForRunStatus(item.status, item.reason)).filter((code) => code !== undefined);
|
|
773
|
+
process.exitCode = codes.length ? worstExitCode(codes) : 0;
|
|
774
|
+
emit(ctx, build, `Build ${build.id} status=${build.status} runs=${(build.runs ?? []).length}`);
|
|
775
|
+
}
|
|
776
|
+
catch (error) {
|
|
777
|
+
primaryError = error;
|
|
778
|
+
throw error;
|
|
779
|
+
}
|
|
780
|
+
finally {
|
|
781
|
+
if (options.junit) {
|
|
782
|
+
try {
|
|
783
|
+
const build = latest ?? await client.getBuild(buildId);
|
|
784
|
+
const summaries = Array.isArray(build.runs) ? build.runs : [];
|
|
785
|
+
const runs = [];
|
|
786
|
+
for (const summary of summaries) {
|
|
787
|
+
try {
|
|
788
|
+
runs.push({ run: await client.getRun(String(summary.id)), options: { scriptName: summary.script_name, incompleteReason: junitIncompleteReason(summary.status, primaryError) } });
|
|
789
|
+
}
|
|
790
|
+
catch {
|
|
791
|
+
runs.push({ run: summary });
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
await writeBuildJUnitFile(String(options.junit), build, runs, { elapsedSeconds: (Date.now() - started) / 1000 });
|
|
795
|
+
}
|
|
796
|
+
catch (error) {
|
|
797
|
+
process.stderr.write(`Warning: unable to write --junit ${options.junit}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
builds.command("cancel").argument("<buildId>").option("--if-active", "Skip the cancel request when the build is already terminal").action(async function (buildId, options) {
|
|
803
|
+
const ctx = context(this);
|
|
804
|
+
const { client } = await authenticatedClient(ctx);
|
|
805
|
+
if (options.ifActive) {
|
|
806
|
+
const current = await client.getBuild(buildId);
|
|
807
|
+
if (["passed", "failed", "infrastructure_failed", "cancelled", "timed_out"].includes(String(current.status))) {
|
|
808
|
+
emit(ctx, current, `Build ${current.id} status=${current.status}`);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
const build = await client.cancelBuild(buildId);
|
|
813
|
+
emit(ctx, build, `Build ${build.id} status=${build.status} cancel_requested=${build.cancel_requested}`);
|
|
814
|
+
});
|
|
599
815
|
const runs = program.command("runs");
|
|
600
816
|
runs.command("show").argument("<runId>").action(async function (runId) { const ctx = context(this); emitRun(ctx, await (await authenticatedClient(ctx)).client.getRun(runId), false); });
|
|
601
|
-
runs.command("wait").argument("<runId>").option("--timeout <seconds>", undefined, "900").option("--poll-interval <seconds>", undefined, "2").
|
|
602
|
-
|
|
817
|
+
runs.command("wait").argument("<runId>").option("--timeout <seconds>", undefined, "900").option("--poll-interval <seconds>", undefined, "2").option("--junit <path>", "Write a JUnit XML report for this Run to this file").action(async function (runId, options) {
|
|
818
|
+
const ctx = context(this);
|
|
819
|
+
const started = Date.now();
|
|
820
|
+
let latest;
|
|
821
|
+
let primaryError;
|
|
822
|
+
try {
|
|
823
|
+
const run = await (await authenticatedClient(ctx)).client.waitRun(runId, Number(options.timeout), Number(options.pollInterval), (value) => { latest = value; progressReporter(ctx)(value); });
|
|
824
|
+
latest = run;
|
|
825
|
+
emitRun(ctx, run);
|
|
826
|
+
}
|
|
827
|
+
catch (error) {
|
|
828
|
+
primaryError = error;
|
|
829
|
+
throw error;
|
|
830
|
+
}
|
|
831
|
+
finally {
|
|
832
|
+
await maybeWriteJUnit(options.junit, latest, { elapsedSeconds: (Date.now() - started) / 1000, incompleteReason: junitIncompleteReason(latest?.status, primaryError) });
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
runs.command("cancel").argument("<runId>").option("--if-active", "Skip the cancel request when the Run is already terminal or cancelling").action(async function (runId, options) {
|
|
836
|
+
const ctx = context(this);
|
|
837
|
+
const { client } = await authenticatedClient(ctx);
|
|
838
|
+
if (options.ifActive) {
|
|
839
|
+
const current = await client.getRun(runId);
|
|
840
|
+
if (["passed", "failed", "infrastructure_failed", "cancelled", "timed_out", "cancelling"].includes(String(current.status))) {
|
|
841
|
+
emitRun(ctx, current, false);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
emitRun(ctx, await client.cancelRun(runId), false);
|
|
846
|
+
});
|
|
603
847
|
program.command("mcp").option("--mode <mode>", `Isolated Tool surface: ${MCP_MODES.join(", ")}`, "action").action(async function (options) { const ctx = context(this); if (!MCP_MODES.includes(options.mode))
|
|
604
848
|
throw new CliError("invalid_request", `mcp --mode must be one of: ${MCP_MODES.join(", ")}`); await runStdioServer(ctx.profile, ctx.userConfig, undefined, options.mode); });
|
|
849
|
+
async function runBatchFromCli(command, paths, options, maxParallel, buildAuto) {
|
|
850
|
+
const ctx = context(command);
|
|
851
|
+
const scripts = await collectRunScripts(paths);
|
|
852
|
+
const supplied = [options.packageId, options.appUploadId, options.apk].filter((item) => item !== undefined).length;
|
|
853
|
+
if (supplied > 1)
|
|
854
|
+
throw new CliError("invalid_request", "--package-id, --app-upload-id and --apk are mutually exclusive");
|
|
855
|
+
if (options.apk)
|
|
856
|
+
throw new CliError("token_type_mismatch", "project_ci Profile must use apps upload and --app-upload-id or --package-id", 3);
|
|
857
|
+
if (options.deviceId)
|
|
858
|
+
throw new CliError("token_type_mismatch", "project_ci Profile cannot use --device-id", 3);
|
|
859
|
+
const packageId = options.packageId ? Number(options.packageId) : undefined;
|
|
860
|
+
if (options.packageId && (!Number.isInteger(Number(packageId)) || Number(packageId) < 1))
|
|
861
|
+
throw new CliError("invalid_request", "--package-id must be a positive integer");
|
|
862
|
+
const timeoutMs = Number(options.timeoutMs);
|
|
863
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 28_800_000)
|
|
864
|
+
throw new CliError("invalid_request", "--timeout-ms must be an integer from 1000 to 28800000");
|
|
865
|
+
const deviceWaitTimeoutMs = Number(options.deviceWaitTimeoutMs);
|
|
866
|
+
if (!Number.isInteger(deviceWaitTimeoutMs) || deviceWaitTimeoutMs < 0 || deviceWaitTimeoutMs > 3_600_000)
|
|
867
|
+
throw new CliError("invalid_request", "--device-wait-timeout-ms must be an integer from 0 to 3600000");
|
|
868
|
+
const waitTimeoutSeconds = Number(options.waitTimeout);
|
|
869
|
+
if (!Number.isFinite(waitTimeoutSeconds) || waitTimeoutSeconds < 0)
|
|
870
|
+
throw new CliError("invalid_request", "--wait-timeout must be a non-negative number of seconds");
|
|
871
|
+
if (options.wait && waitTimeoutSeconds * 1000 <= timeoutMs + deviceWaitTimeoutMs)
|
|
872
|
+
process.stderr.write(`Warning: --wait-timeout ${waitTimeoutSeconds}s is not longer than --timeout-ms ${timeoutMs}ms plus --device-wait-timeout-ms ${deviceWaitTimeoutMs}ms; the CLI may stop waiting while the Run is still queued or running\n`);
|
|
873
|
+
const pollIntervalSeconds = Number(options.pollInterval);
|
|
874
|
+
if (!Number.isFinite(pollIntervalSeconds) || pollIntervalSeconds <= 0)
|
|
875
|
+
throw new CliError("invalid_request", "--poll-interval must be a positive number of seconds");
|
|
876
|
+
const suppliedIdempotencyKey = options.idempotencyKey === undefined ? undefined : String(options.idempotencyKey).trim();
|
|
877
|
+
if (!suppliedIdempotencyKey)
|
|
878
|
+
throw new CliError("invalid_request", "--idempotency-key is required for a project_ci Run");
|
|
879
|
+
const profile = await selectedProfile(ctx);
|
|
880
|
+
requireProjectCi(profile, "run");
|
|
881
|
+
const { client } = await authenticatedClient(ctx);
|
|
882
|
+
const exitCode = await runScriptBatch({
|
|
883
|
+
ctx,
|
|
884
|
+
profile,
|
|
885
|
+
client,
|
|
886
|
+
scripts,
|
|
887
|
+
options: {
|
|
888
|
+
packageId,
|
|
889
|
+
appUploadId: options.appUploadId,
|
|
890
|
+
promote: Boolean(options.promote),
|
|
891
|
+
targetName: options.target,
|
|
892
|
+
timeoutMs,
|
|
893
|
+
deviceWaitTimeoutMs,
|
|
894
|
+
wait: Boolean(options.wait),
|
|
895
|
+
waitTimeoutSeconds,
|
|
896
|
+
pollIntervalSeconds,
|
|
897
|
+
variables: parseVariables(options.var ?? []),
|
|
898
|
+
baseIdempotencyKey: suppliedIdempotencyKey,
|
|
899
|
+
buildId: options.buildId,
|
|
900
|
+
buildAuto,
|
|
901
|
+
maxParallel,
|
|
902
|
+
output: options.output,
|
|
903
|
+
junit: options.junit,
|
|
904
|
+
resolveTarget: (name) => loadProjectConfig(ctx.projectConfig).then((config) => config.resolveTarget(name)),
|
|
905
|
+
},
|
|
906
|
+
emit: (data, human) => emit(ctx, data, human),
|
|
907
|
+
});
|
|
908
|
+
process.exitCode = exitCode;
|
|
909
|
+
}
|
|
605
910
|
export async function runCli(argv = process.argv) {
|
|
606
911
|
parsedArgv = argv;
|
|
607
912
|
parsedJsonMode = undefined;
|