@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.
Files changed (69) hide show
  1. package/README.md +98 -2
  2. package/dist/assets/contracts/app-upload.schema.json +3 -2
  3. package/dist/assets/contracts/build-create.schema.json +17 -0
  4. package/dist/assets/contracts/build-list.schema.json +18 -0
  5. package/dist/assets/contracts/build.schema.json +76 -0
  6. package/dist/assets/contracts/execution-target.schema.json +12 -4
  7. package/dist/assets/contracts/fixtures/execution-target.concurrency-twenty-one.invalid.json +4 -0
  8. package/dist/assets/contracts/fixtures/execution-target.concurrency-two.invalid.json +1 -1
  9. package/dist/assets/contracts/fixtures/execution-target.concurrency-two.valid.json +4 -0
  10. package/dist/assets/contracts/fixtures/execution-target.count-eleven.invalid.json +6 -0
  11. package/dist/assets/contracts/fixtures/execution-target.count-two.invalid.json +1 -1
  12. package/dist/assets/contracts/fixtures/execution-target.count-two.valid.json +6 -0
  13. package/dist/assets/contracts/fixtures/execution-target.multiple-devices.invalid.json +23 -1
  14. package/dist/assets/contracts/fixtures/execution-target.multiple-devices.valid.json +4 -0
  15. package/dist/assets/contracts/fixtures/execution-target.multiple-filters.invalid.json +66 -2
  16. package/dist/assets/contracts/fixtures/execution-target.multiple-filters.valid.json +7 -0
  17. package/dist/assets/contracts/fixtures/target-hash-cases.json +52 -1
  18. package/dist/assets/contracts/fixtures/target-preview.success.json +4 -1
  19. package/dist/assets/contracts/fixtures/target-preview.unavailable.json +4 -1
  20. package/dist/assets/contracts/run-create-result.schema.json +18 -0
  21. package/dist/assets/contracts/run-create-v2.schema.json +85 -19
  22. package/dist/assets/contracts/run.schema.json +179 -34
  23. package/dist/assets/contracts/target-preview.schema.json +89 -19
  24. package/dist/cli.d.ts.map +1 -1
  25. package/dist/cli.js +324 -19
  26. package/dist/cli.js.map +1 -1
  27. package/dist/core/version.d.ts +1 -1
  28. package/dist/core/version.js +1 -1
  29. package/dist/project/target-matrix.d.ts +4 -0
  30. package/dist/project/target-matrix.d.ts.map +1 -0
  31. package/dist/project/target-matrix.js +26 -0
  32. package/dist/project/target-matrix.js.map +1 -0
  33. package/dist/run/batch.d.ts +44 -0
  34. package/dist/run/batch.d.ts.map +1 -0
  35. package/dist/run/batch.js +261 -0
  36. package/dist/run/batch.js.map +1 -0
  37. package/dist/run/builds.d.ts +20 -0
  38. package/dist/run/builds.d.ts.map +1 -0
  39. package/dist/run/builds.js +70 -0
  40. package/dist/run/builds.js.map +1 -0
  41. package/dist/run/create-result.d.ts +6 -0
  42. package/dist/run/create-result.d.ts.map +1 -0
  43. package/dist/run/create-result.js +18 -0
  44. package/dist/run/create-result.js.map +1 -0
  45. package/dist/run/exit-codes.d.ts +8 -0
  46. package/dist/run/exit-codes.d.ts.map +1 -0
  47. package/dist/run/exit-codes.js +47 -0
  48. package/dist/run/exit-codes.js.map +1 -0
  49. package/dist/run/idempotency.d.ts +2 -0
  50. package/dist/run/idempotency.d.ts.map +1 -0
  51. package/dist/run/idempotency.js +13 -0
  52. package/dist/run/idempotency.js.map +1 -0
  53. package/dist/run/junit-suite.d.ts +17 -0
  54. package/dist/run/junit-suite.d.ts.map +1 -0
  55. package/dist/run/junit-suite.js +98 -0
  56. package/dist/run/junit-suite.js.map +1 -0
  57. package/dist/run/junit.d.ts +19 -0
  58. package/dist/run/junit.d.ts.map +1 -0
  59. package/dist/run/junit.js +218 -0
  60. package/dist/run/junit.js.map +1 -0
  61. package/dist/run/scripts.d.ts +9 -0
  62. package/dist/run/scripts.d.ts.map +1 -0
  63. package/dist/run/scripts.js +156 -0
  64. package/dist/run/scripts.js.map +1 -0
  65. package/dist/sdk/client.d.ts +7 -0
  66. package/dist/sdk/client.d.ts.map +1 -1
  67. package/dist/sdk/client.js +46 -0
  68. package/dist/sdk/client.js.map +1 -1
  69. 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 = { 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,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>").action(async function (name) { const ctx = context(this); const project = await loadProjectConfig(ctx.projectConfig); const item = project.resolveTarget(name); const { profile, client } = await authenticatedClient(ctx); requireProjectCi(profile, "Target preview"); emit(ctx, await client.previewTarget({ schema_version: "xagent-target-preview/v1", target_name: item.name, target_hash: item.targetHash, target: item.definition })); });
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").action(async function (apkFile, options) { const ctx = context(this); if (!apkFile.toLowerCase().endsWith(".apk"))
439
- throw new CliError("invalid_request", "apps upload requires an APK file"); const { client } = await authenticatedClient(ctx, Number(options.uploadTimeout) * 1000); const result = await client.uploadApp(apkFile, (done, total) => (ctx.json ? process.stderr : process.stdout).write(`APK 分片上传:${done}/${total}\n`)); emit(ctx, result, `Uploaded ${apkFile}: package_id=${result.package_id} package=${result.app?.package_name}`); });
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("<scriptFile>")
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
- .action(async function (scriptFile, options) {
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
- run = await client.waitRun(run.id, waitTimeoutSeconds, pollIntervalSeconds, progressReporter(ctx), waiting.signal);
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").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); });
602
- runs.command("cancel").argument("<runId>").action(async function (runId) { const ctx = context(this); emitRun(ctx, await (await authenticatedClient(ctx)).client.cancelRun(runId), false); });
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;