@testin-official/xagent 0.3.13 → 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.
Files changed (68) hide show
  1. package/README.md +71 -10
  2. package/dist/assets/contracts/build-create.schema.json +17 -0
  3. package/dist/assets/contracts/build-list.schema.json +18 -0
  4. package/dist/assets/contracts/build.schema.json +76 -0
  5. package/dist/assets/contracts/execution-target.schema.json +12 -4
  6. package/dist/assets/contracts/fixtures/execution-target.concurrency-twenty-one.invalid.json +4 -0
  7. package/dist/assets/contracts/fixtures/execution-target.concurrency-two.invalid.json +1 -1
  8. package/dist/assets/contracts/fixtures/execution-target.concurrency-two.valid.json +4 -0
  9. package/dist/assets/contracts/fixtures/execution-target.count-eleven.invalid.json +6 -0
  10. package/dist/assets/contracts/fixtures/execution-target.count-two.invalid.json +1 -1
  11. package/dist/assets/contracts/fixtures/execution-target.count-two.valid.json +6 -0
  12. package/dist/assets/contracts/fixtures/execution-target.multiple-devices.invalid.json +23 -1
  13. package/dist/assets/contracts/fixtures/execution-target.multiple-devices.valid.json +4 -0
  14. package/dist/assets/contracts/fixtures/execution-target.multiple-filters.invalid.json +66 -2
  15. package/dist/assets/contracts/fixtures/execution-target.multiple-filters.valid.json +7 -0
  16. package/dist/assets/contracts/fixtures/target-hash-cases.json +52 -1
  17. package/dist/assets/contracts/fixtures/target-preview.success.json +4 -1
  18. package/dist/assets/contracts/fixtures/target-preview.unavailable.json +4 -1
  19. package/dist/assets/contracts/run-create-result.schema.json +18 -0
  20. package/dist/assets/contracts/run-create-v2.schema.json +85 -19
  21. package/dist/assets/contracts/run.schema.json +179 -34
  22. package/dist/assets/contracts/target-preview.schema.json +89 -19
  23. package/dist/cli.d.ts.map +1 -1
  24. package/dist/cli.js +264 -18
  25. package/dist/cli.js.map +1 -1
  26. package/dist/core/version.d.ts +1 -1
  27. package/dist/core/version.js +1 -1
  28. package/dist/project/target-matrix.d.ts +4 -0
  29. package/dist/project/target-matrix.d.ts.map +1 -0
  30. package/dist/project/target-matrix.js +26 -0
  31. package/dist/project/target-matrix.js.map +1 -0
  32. package/dist/run/batch.d.ts +44 -0
  33. package/dist/run/batch.d.ts.map +1 -0
  34. package/dist/run/batch.js +261 -0
  35. package/dist/run/batch.js.map +1 -0
  36. package/dist/run/builds.d.ts +20 -0
  37. package/dist/run/builds.d.ts.map +1 -0
  38. package/dist/run/builds.js +70 -0
  39. package/dist/run/builds.js.map +1 -0
  40. package/dist/run/create-result.d.ts +6 -0
  41. package/dist/run/create-result.d.ts.map +1 -0
  42. package/dist/run/create-result.js +18 -0
  43. package/dist/run/create-result.js.map +1 -0
  44. package/dist/run/exit-codes.d.ts +8 -0
  45. package/dist/run/exit-codes.d.ts.map +1 -0
  46. package/dist/run/exit-codes.js +47 -0
  47. package/dist/run/exit-codes.js.map +1 -0
  48. package/dist/run/idempotency.d.ts +2 -0
  49. package/dist/run/idempotency.d.ts.map +1 -0
  50. package/dist/run/idempotency.js +13 -0
  51. package/dist/run/idempotency.js.map +1 -0
  52. package/dist/run/junit-suite.d.ts +17 -0
  53. package/dist/run/junit-suite.d.ts.map +1 -0
  54. package/dist/run/junit-suite.js +98 -0
  55. package/dist/run/junit-suite.js.map +1 -0
  56. package/dist/run/junit.d.ts +19 -0
  57. package/dist/run/junit.d.ts.map +1 -0
  58. package/dist/run/junit.js +218 -0
  59. package/dist/run/junit.js.map +1 -0
  60. package/dist/run/scripts.d.ts +9 -0
  61. package/dist/run/scripts.d.ts.map +1 -0
  62. package/dist/run/scripts.js +156 -0
  63. package/dist/run/scripts.js.map +1 -0
  64. package/dist/sdk/client.d.ts +6 -0
  65. package/dist/sdk/client.d.ts.map +1 -1
  66. package/dist/sdk/client.js +45 -0
  67. package/dist/sdk/client.js.map +1 -1
  68. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -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 = { failed: 1, cancelled: 4, infrastructure_failed: 6, timed_out: 7 }[run.status] ?? 0; }
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,7 +450,7 @@ 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>").option("--require-available", "Exit with device_unavailable (5) when no previewed device is available").action(async function (name, options) {
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) {
434
454
  const ctx = context(this);
435
455
  const project = await loadProjectConfig(ctx.projectConfig);
436
456
  const item = project.resolveTarget(name);
@@ -438,8 +458,15 @@ targets.command("preview").argument("<name>").option("--require-available", "Exi
438
458
  requireProjectCi(profile, "Target preview");
439
459
  const preview = await client.previewTarget({ schema_version: "xagent-target-preview/v1", target_name: item.name, target_hash: item.targetHash, target: item.definition });
440
460
  emit(ctx, preview);
441
- if (options.requireAvailable && !(Array.isArray(preview.devices) && preview.devices.some((device) => device?.available === true))) {
442
- process.stderr.write(`Target ${item.name} has no available device\n`);
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
+ }
443
470
  process.exitCode = API_EXIT.device_unavailable;
444
471
  }
445
472
  });
@@ -460,7 +487,7 @@ apps.command("upload").argument("<apkFile>").option("--upload-timeout <seconds>"
460
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}`); });
461
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}`); });
462
489
  program.command("run")
463
- .argument("<scriptFile>")
490
+ .argument("<paths...>", "Script files, directories of xagent-script/v1 YAML, or globs")
464
491
  .option("--package-id <id>")
465
492
  .option("--app-upload-id <id>")
466
493
  .option("--apk <path>")
@@ -469,14 +496,45 @@ program.command("run")
469
496
  .option("--idempotency-key <key>")
470
497
  .option("--var <assignment>", "Runtime variable NAME=VALUE", (value, previous) => [...previous, value], [])
471
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")
472
500
  .option("--wait")
473
501
  .option("--wait-timeout <seconds>", undefined, "900")
474
502
  .option("--poll-interval <seconds>", "Seconds between Run status checks while --wait is active", "2")
475
503
  .option("--upload-timeout <seconds>", undefined, "900")
476
504
  .option("--run-id-file <path>", "Write the Run ID to this file as soon as the Run is created")
477
505
  .option("--output <path>", "Write the last known Run JSON to this file")
478
- .action(async function (scriptFile, options) {
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) {
479
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];
480
538
  const parsed = await loadScript(scriptFile);
481
539
  const supplied = [options.packageId, options.appUploadId, options.apk].filter((item) => item !== undefined).length;
482
540
  if (supplied > 1)
@@ -511,8 +569,11 @@ program.command("run")
511
569
  const pollIntervalSeconds = Number(options.pollInterval);
512
570
  if (!Number.isFinite(pollIntervalSeconds) || pollIntervalSeconds <= 0)
513
571
  throw new CliError("invalid_request", "--poll-interval must be a positive number of seconds");
514
- if (options.wait && waitTimeoutSeconds * 1000 <= timeoutMs)
515
- process.stderr.write(`Warning: --wait-timeout ${waitTimeoutSeconds}s is not longer than --timeout-ms ${timeoutMs}ms; the CLI may stop waiting while the Run keeps its device\n`);
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`);
516
577
  // One definition for "was a key supplied, and does it carry anything": the
517
578
  // personal branch tested the raw value while the project_ci branch tested the
518
579
  // trimmed one, so `--idempotency-key ""` slipped past the first check.
@@ -543,7 +604,7 @@ program.command("run")
543
604
  if (!suppliedIdempotencyKey)
544
605
  throw new CliError("invalid_request", "--idempotency-key is required for a project_ci Run");
545
606
  const target = (await loadProjectConfig(ctx.projectConfig)).resolveTarget(options.target);
546
- 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 };
547
608
  }
548
609
  const variables = parseVariables(options.var ?? []);
549
610
  if (Object.keys(variables).length)
@@ -556,6 +617,7 @@ program.command("run")
556
617
  // and a Run occupying a device allocated until each expires on its own.
557
618
  let createdRunId;
558
619
  let latestRun;
620
+ let waitStartedAt;
559
621
  const writeRunOutput = async () => {
560
622
  if (!options.output || !latestRun)
561
623
  return;
@@ -567,6 +629,12 @@ program.command("run")
567
629
  }
568
630
  };
569
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
+ });
570
638
  // `onInterrupt()` registers a cleanup and hands back its deregistration
571
639
  // function, so this is "detach", not "release" — calling it does not release
572
640
  // anything on its own.
@@ -592,6 +660,7 @@ program.command("run")
592
660
  }
593
661
  }
594
662
  await writeRunOutput();
663
+ await writeJUnitOutput();
595
664
  });
596
665
  try {
597
666
  if (options.apk) {
@@ -618,6 +687,7 @@ program.command("run")
618
687
  }
619
688
  if (options.wait) {
620
689
  const reportProgress = progressReporter(ctx);
690
+ waitStartedAt = Date.now();
621
691
  run = await client.waitRun(run.id, waitTimeoutSeconds, pollIntervalSeconds, (value) => { latestRun = value; reportProgress(value); }, waiting.signal);
622
692
  createdRunId = undefined; // reached a terminal state, nothing left to cancel
623
693
  }
@@ -628,6 +698,7 @@ program.command("run")
628
698
  }
629
699
  finally {
630
700
  await writeRunOutput();
701
+ await writeJUnitOutput();
631
702
  if (uploadedId) {
632
703
  const releasing = uploadedId;
633
704
  // Cleared only on success so an interrupt mid-release still retries it.
@@ -644,9 +715,123 @@ program.command("run")
644
715
  }
645
716
  emitRun(ctx, run);
646
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
+ });
647
815
  const runs = program.command("runs");
648
816
  runs.command("show").argument("<runId>").action(async function (runId) { const ctx = context(this); emitRun(ctx, await (await authenticatedClient(ctx)).client.getRun(runId), false); });
649
- runs.command("wait").argument("<runId>").option("--timeout <seconds>", undefined, "900").option("--poll-interval <seconds>", undefined, "2").action(async function (runId, options) { const ctx = context(this); const run = await (await authenticatedClient(ctx)).client.waitRun(runId, Number(options.timeout), Number(options.pollInterval), progressReporter(ctx)); emitRun(ctx, run); });
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
+ });
650
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) {
651
836
  const ctx = context(this);
652
837
  const { client } = await authenticatedClient(ctx);
@@ -661,6 +846,67 @@ runs.command("cancel").argument("<runId>").option("--if-active", "Skip the cance
661
846
  });
662
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))
663
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
+ }
664
910
  export async function runCli(argv = process.argv) {
665
911
  parsedArgv = argv;
666
912
  parsedJsonMode = undefined;