@aefree/pi-unity 0.11.0 → 0.12.0

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/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project follows semantic versioning for public package releases.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.12.0 - 2026-08-27
11
+
12
+ ### Changed
13
+ - Made Unity CLI authoritative for ordinary project launches: `open`, `run`, and `test` now rely on the selected project without passing an Editor-version override; direct executable launch remains an exact-version fallback.
14
+
15
+ ### Fixed
16
+
17
+ - Kept isolated Unity CLI test results discoverable for JSON-only reports by always writing the native NUnit evidence to the project's `Logs` directory.
18
+ - Made direct Unity Editor fallback selection project-version-driven by probing only exact-version standard installations without Editor-path overrides.
19
+
20
+ ### Removed
21
+
22
+ - Removed the `UNITY_EDITOR_PATH`, `UNITY_PATH`, and `unityEditorPath` Editor-selection overrides.
23
+
10
24
  ## [0.11.0] - 2026-08-26
11
25
 
12
26
  ### Added
package/README.md CHANGED
@@ -23,7 +23,7 @@ pi install <path-to-pi-unity>
23
23
  pi install -l <path-to-pi-unity> # project-local
24
24
  ```
25
25
 
26
- Pi discovers the extension from `index.ts` and packaged skills from `skills/`.
26
+ Pi discovers the extension from `index.ts` and packaged skills from `skills/`. In UI sessions, pi-unity warns once per Pi runtime when Unity CLI is unavailable or `UNITY_CLI_PATH` is invalid. The warning never displays configured paths; install Unity CLI, then restart or reload Pi.
27
27
 
28
28
  ## Included tools
29
29
 
@@ -113,7 +113,7 @@ Use `unity_pipeline_inspect` when a purpose-built structured command fits. Use e
113
113
 
114
114
  ## Launch and process safeguards
115
115
 
116
- `unity_open_editor` and batchmode tools prefer the installed Unity CLI and can fall back to the direct Editor executable. Set `launcher` to `auto`, `unity-cli`, or `editor-executable` when explicit routing is needed.
116
+ `unity_open_editor` and batchmode tools treat Unity CLI as the authoritative launcher: `unity open`, `unity run`, and `unity test` receive only the selected project and let Unity CLI read `ProjectVersion.txt`. The direct Editor executable is an exceptional compatibility fallback used only when Unity CLI is unavailable. Set `launcher` to `auto`, `unity-cli`, or `editor-executable` when explicit routing is needed.
117
117
 
118
118
  Before launching, pi-unity checks:
119
119
 
package/index.ts CHANGED
@@ -21,13 +21,14 @@ import {
21
21
  } from "./src/unity-batchmode";
22
22
  import { formatPathForUser, hasUnityCommandLineFlag } from "./src/unity-core";
23
23
  import { createUnityCliBatchmodeReportArgs, createUnityCliEditorExitCommand, createUnityCliRunCommand, createUnityCliTestCommand, dispatchUnityPlanningInspection, haveSameKnownProcessIds, inspectUnityCliProjectCapabilities, listRunningUnityCliEditorsForProject, resolveUnityCliCommand, UNITY_PLANNING_READ_COMMANDS, type UnityCliProjectCapabilities } from "./src/unity-cli";
24
- import { createUnityBatchmodeCommand, launchUnityCliOpenDetached, launchUnityEditorDetached, resolveUnityEditorPath } from "./src/unity-launch";
24
+ import { launchUnityCliOpenDetached } from "./src/unity-launch";
25
+ import { createUnityBatchmodeCommand, launchUnityEditorDetached, resolveUnityEditorPath } from "./src/unity-editor-fallback";
25
26
  import { loadPiUnitySettings, type PiUnitySettings } from "./src/pi-unity-settings";
26
27
  import { dedupeRunningUnityProcesses, listRunningUnityProcessesForProject, redactUnityProcessCommandLine, terminateRunningUnityProcesses, verifyUnityProcessIdentity, type RunningUnityProcess } from "./src/unity-processes";
27
28
  import { assertUnityProjectNotBusy, evaluateUnityLaunchSafety, getUnityNativeLockfilePath, inspectUnityProjectBusyState, withUnityProjectLaunchMutex } from "./src/unity-project-lock";
28
- import { resolveUnityProjectCandidates, type UnityProjectCandidate } from "./src/unity-projects";
29
+ import { readUnityVersion, resolveUnityProjectCandidates, type UnityProjectCandidate } from "./src/unity-projects";
29
30
  import { createUnityTestBatchPlan, type UnityTestBatchPlan, type UnityTestPlatform } from "./src/unity-test-batch";
30
- import { applyUnityCliRetrySummary, compactUnityTestSummary, defaultUnityTestReportFormats, deriveUnityCliEffectiveReportPath, determineUnityTestOutcome, getUnityTestRouteRequirements, normalizeUnityRunTestsRequest, parseUnityCliRetrySummary, writeNormalizedUnityTestArtifact, type NormalizedUnityTestResult, type UnityRunTestsRequest } from "./src/unity-tests";
31
+ import { applyUnityCliRetrySummary, compactUnityTestSummary, defaultUnityTestReportFormats, deriveUnityCliEffectiveReportPath, determineUnityTestOutcome, getUnityTestRouteRequirements, normalizeUnityRunTestsRequest, parseUnityCliRetrySummary, resolveUnityCliBackendReportPaths, writeNormalizedUnityTestArtifact, type NormalizedUnityTestResult, type UnityRunTestsRequest } from "./src/unity-tests";
31
32
  import { auditUnityGuidance, type UnityGuidanceAuditResult } from "./src/unity-guidance-audit";
32
33
  import { runUnityPipelineRecompile, runUnityPipelineTests, type UnityPipelineOperationDetails } from "./src/unity-pipeline";
33
34
  import {
@@ -103,20 +104,19 @@ type UnityLauncherPreference = "auto" | "unity-cli" | "editor-executable";
103
104
 
104
105
  const OPEN_EDITOR_PARAMS = Type.Object({
105
106
  path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
106
- unityEditorPath: Type.Optional(Type.String({ description: "Optional explicit Unity executable path override." })),
107
107
  automated: Type.Optional(Type.Boolean({ default: false, description: "Pass Unity Editor's -automated flag when opening the project. Defaults to false." })),
108
108
  launcher: LAUNCHER_SCHEMA,
109
- });
109
+ }, { additionalProperties: false });
110
110
 
111
111
  const LAUNCH_BATCHMODE_PARAMS = Type.Object({
112
112
  path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
113
- unityEditorPath: Type.Optional(Type.String({ description: "Optional explicit Unity executable path override." })),
113
+
114
114
  args: Type.Optional(Type.Array(Type.String(), { description: "Additional Unity command-line arguments appended after -batchmode -projectPath <project> for direct editor launch, or forwarded after `unity run <project> --` for Unity CLI launch. pi-unity adds -nographics by default unless useGraphics=true." })),
115
115
  useGraphics: Type.Optional(Type.Boolean({ default: false, description: "Set true only when the requested Unity batchmode work requires an active graphics device, such as screenshots, rendering, or visual PlayMode tests. Defaults to false, which adds -nographics." })),
116
116
  timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 14400, default: 3600, description: "Timeout in seconds for the batchmode process." })),
117
117
  launcher: LAUNCHER_SCHEMA,
118
118
  closeBlockingUnityProcess: Type.Optional(Type.Boolean({ default: false, description: "When true, pi-unity may close a running Unity process for the resolved project before launch, but only if piUnity.allowCloseRunningUnityProcess is enabled in Pi settings. The process is selected by project matching, not by model-supplied PID." })),
119
- });
119
+ }, { additionalProperties: false });
120
120
 
121
121
  const RUN_TESTS_PARAMS = Type.Object({
122
122
  path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
@@ -137,18 +137,6 @@ const RUN_TESTS_PARAMS = Type.Object({
137
137
  closeBlockingUnityProcess: Type.Optional(Type.Boolean({ default: false })),
138
138
  }, { additionalProperties: false });
139
139
 
140
- const RUN_TEST_BATCH_PARAMS = Type.Object({
141
- path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
142
- unityEditorPath: Type.Optional(Type.String({ description: "Optional explicit Unity executable path override." })),
143
- testPlatform: StringEnum(["EditMode", "PlayMode"] as const, { description: "Unity Test Framework platform. One batch runs exactly one test platform." }),
144
- testFilters: Type.Optional(Type.Array(Type.String(), { maxItems: 50, description: "Full test names or regex filters. Values are normalized into one semicolon-separated -testFilter argument." })),
145
- testCategories: Type.Optional(Type.Array(Type.String(), { maxItems: 50, description: "Categories or category regex/negations. Values are normalized into one semicolon-separated -testCategory argument." })),
146
- useGraphics: Type.Optional(Type.Boolean({ default: false, description: "Set true only for graphics-dependent PlayMode tests or visual capture. Defaults to headless -nographics." })),
147
- timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 14400, default: 3600 })),
148
- launcher: LAUNCHER_SCHEMA,
149
- closeBlockingUnityProcess: Type.Optional(Type.Boolean({ default: false, description: "Use guarded same-project Unity process closure only when piUnity.allowCloseRunningUnityProcess is enabled." })),
150
- });
151
-
152
140
  const PROJECT_STATUS_PARAMS = Type.Object({
153
141
  path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
154
142
  });
@@ -196,7 +184,7 @@ const INSPECT_ARTIFACTS_PARAMS = Type.Object({
196
184
  });
197
185
 
198
186
  function buildProjectChoiceLabel(cwd: string, candidate: UnityProjectCandidate): string {
199
- return `${candidate.projectName} (${candidate.unityVersion}) — ${formatPathForUser(cwd, candidate.projectRoot)}`;
187
+ return `${candidate.projectName} — ${formatPathForUser(cwd, candidate.projectRoot)}`;
200
188
  }
201
189
 
202
190
  async function chooseProjectCandidateWithWrappingNavigation(
@@ -267,7 +255,7 @@ async function chooseProjectCandidateWithWrappingNavigation(
267
255
 
268
256
  function formatCandidateList(cwd: string, candidates: UnityProjectCandidate[]): string {
269
257
  return candidates
270
- .map((candidate) => `- ${candidate.projectName} (${candidate.unityVersion}) — ${formatPathForUser(cwd, candidate.projectRoot)}`)
258
+ .map((candidate) => `- ${candidate.projectName} — ${formatPathForUser(cwd, candidate.projectRoot)}`)
271
259
  .join("\n");
272
260
  }
273
261
 
@@ -331,6 +319,13 @@ async function resolveProjectCandidate(
331
319
  return { candidate, discoveryWarning };
332
320
  }
333
321
 
322
+ async function requireManualUnityVersion(candidate: UnityProjectCandidate): Promise<string> {
323
+ if (candidate.unityVersion) return candidate.unityVersion;
324
+ const unityVersion = await readUnityVersion(candidate.projectRoot);
325
+ candidate.unityVersion = unityVersion;
326
+ return unityVersion;
327
+ }
328
+
334
329
  function joinWarnings(...warnings: Array<string | undefined>): string | undefined {
335
330
  const present = warnings.filter((warning): warning is string => Boolean(warning && warning.trim().length > 0));
336
331
  return present.length > 0 ? present.join("\n") : undefined;
@@ -446,11 +441,12 @@ async function closeBlockingUnityProcessesForBatchmode(
446
441
  );
447
442
  }
448
443
 
449
- const cliCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, candidate.unityVersion, { signal });
444
+ const unityVersion = await requireManualUnityVersion(candidate);
445
+ const cliCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, unityVersion, { signal });
450
446
  const canRequestGracefulExit = cliCapabilities.commandDiscoverySucceeded && cliCapabilities.advertisedCommands.includes("eval");
451
447
  if (canRequestGracefulExit) {
452
448
  const refreshedRunning = await listBlockingUnityProcesses(candidate.projectRoot);
453
- const refreshedCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, candidate.unityVersion, { signal });
449
+ const refreshedCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, unityVersion, { signal });
454
450
  const samePids = haveSameKnownProcessIds(running.processes, refreshedRunning.processes);
455
451
  const samePipelinePids = haveSameKnownProcessIds(cliCapabilities.matchingInstances, refreshedCapabilities.matchingInstances);
456
452
  if (refreshedRunning.warning || !samePids || !samePipelinePids || !refreshedCapabilities.advertisedCommands.includes("eval")) {
@@ -599,7 +595,8 @@ async function buildProjectStatusReport(
599
595
  const lockState = await inspectUnityProjectBusyState(candidate.projectRoot);
600
596
  const cliStatus = await listRunningUnityCliEditorsForProject(candidate.projectRoot);
601
597
  const processStatus = await listRunningUnityProcessesForProject(candidate.projectRoot);
602
- const cliCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, candidate.unityVersion, { signal });
598
+ const unityVersion = await requireManualUnityVersion(candidate);
599
+ const cliCapabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, unityVersion, { signal });
603
600
  const runningProcesses = dedupeRunningUnityProcesses([...cliStatus.processes, ...processStatus.processes]);
604
601
  const isBusy = runningProcesses.length > 0 || cliCapabilities.matchingInstances.length > 0;
605
602
  const staleLockSuspected = lockState.nativeLockfileExists && !isBusy && !processStatus.warning;
@@ -607,7 +604,7 @@ async function buildProjectStatusReport(
607
604
  const piUnitySettings = await loadPiUnitySettings(ctx);
608
605
 
609
606
  const lines = [
610
- `Unity project status for ${formatPathForUser(ctx.cwd, candidate.projectRoot)} using Unity ${candidate.unityVersion}.`,
607
+ `Unity project status for ${formatPathForUser(ctx.cwd, candidate.projectRoot)} using declared Unity ${unityVersion}.`,
611
608
  `- Native lockfile: ${lockState.nativeLockfileExists ? "present" : "absent"}`,
612
609
  `- Lockfile path: ${lockState.nativeLockfilePath}`,
613
610
  `- Running Unity processes targeting project: ${runningProcesses.length}`,
@@ -656,7 +653,7 @@ async function buildProjectStatusReport(
656
653
  details: {
657
654
  mode: "status",
658
655
  projectRoot: candidate.projectRoot,
659
- unityVersion: candidate.unityVersion,
656
+ unityVersion: await requireManualUnityVersion(candidate),
660
657
  editorPath: "",
661
658
  warning: combinedWarning,
662
659
  status: "passed",
@@ -737,7 +734,7 @@ async function buildArtifactInspectionReport(
737
734
  const hasLoadedArtifacts = Boolean(artifacts.testResultsPath || artifacts.logFilePath);
738
735
  const status = deriveUnityArtifactInspectionStatus(hasLoadedArtifacts, invocation, parsedTestResults);
739
736
  const lines = [
740
- `Unity artifacts inspected for ${formatPathForUser(ctx.cwd, candidate.projectRoot)} using Unity ${candidate.unityVersion}.`,
737
+ `Unity artifacts inspected for ${formatPathForUser(ctx.cwd, candidate.projectRoot)}; Unity CLI selects the project's declared Editor version when launching.`,
741
738
  testResultsPath ? `Requested test results: ${testResultsPath}` : "Requested test results: (none found)",
742
739
  logFilePath ? `Requested log file: ${logFilePath}` : "Requested log file: (none found)",
743
740
  normalizedResultPath ? `Requested normalized result: ${normalizedResultPath}` : "Requested normalized result: (none found)",
@@ -764,7 +761,7 @@ async function buildArtifactInspectionReport(
764
761
  details: {
765
762
  mode: "artifacts",
766
763
  projectRoot: candidate.projectRoot,
767
- unityVersion: candidate.unityVersion,
764
+ unityVersion: await requireManualUnityVersion(candidate),
768
765
  editorPath: "",
769
766
  invocation,
770
767
  artifacts: compactUnityArtifacts(artifacts),
@@ -782,7 +779,9 @@ function buildEditorLaunchSummary(
782
779
  launcher: "unity-cli" | "editor-executable" = "editor-executable",
783
780
  ): string {
784
781
  return [
785
- `Launched Unity Editor GUI for ${formatPathForUser(cwd, candidate.projectRoot)} using Unity ${candidate.unityVersion}.`,
782
+ launcher === "unity-cli"
783
+ ? `Launched Unity Editor GUI for ${formatPathForUser(cwd, candidate.projectRoot)} with the project-declared Editor version selected by Unity CLI.`
784
+ : `Launched Unity Editor GUI for ${formatPathForUser(cwd, candidate.projectRoot)} using the project's declared Unity version through the direct Editor fallback.`,
786
785
  launcher === "unity-cli" ? `Launcher: unity open (${editorPath})` : `Editor: ${editorPath}`,
787
786
  GUI_WARNING,
788
787
  SINGLE_PROCESS_WARNING,
@@ -809,7 +808,7 @@ async function buildBatchmodeReport(
809
808
  const status = deriveUnityBatchmodeStatus(result.code, Boolean(result.killed), invocation, parsedTestResults);
810
809
  const text = buildUnityBatchmodeAgentText({
811
810
  displayProjectPath: formatPathForUser(ctx.cwd, candidate.projectRoot),
812
- unityVersion: candidate.unityVersion,
811
+ unityVersion: await requireManualUnityVersion(candidate),
813
812
  editorPath,
814
813
  exitCode: result.code,
815
814
  killed: Boolean(result.killed),
@@ -827,7 +826,7 @@ async function buildBatchmodeReport(
827
826
  details: {
828
827
  mode: "batchmode",
829
828
  projectRoot: candidate.projectRoot,
830
- unityVersion: candidate.unityVersion,
829
+ unityVersion: await requireManualUnityVersion(candidate),
831
830
  editorPath,
832
831
  command: editorPath,
833
832
  args,
@@ -936,16 +935,42 @@ function throwIfAborted(signal?: AbortSignal): void {
936
935
  }
937
936
  }
938
937
 
939
- async function canUseUnityCli(pi: ExtensionAPI, signal?: AbortSignal): Promise<boolean> {
938
+ const UNITY_CLI_WARNING_SYMBOL = Symbol.for("@aefree/pi-unity/unity-cli-warning/v1");
939
+ const unityCliRuntimeState = globalThis as Record<PropertyKey, unknown>;
940
+ const UNITY_CLI_WARNING = "pi-unity capability warning: Unity CLI is unavailable or UNITY_CLI_PATH is invalid. Install Unity CLI, then restart or reload Pi to restore pi-unity's primary project-launch workflow.";
941
+
942
+ async function warnWhenUnityCliUnavailable(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
943
+ if (!ctx.hasUI || unityCliRuntimeState[UNITY_CLI_WARNING_SYMBOL] === true) return;
944
+ try {
945
+ const result = await pi.exec(resolveUnityCliCommand(), ["--version"], { timeout: 5000 });
946
+ // Timeouts are uncertain and must neither warn nor select another backend.
947
+ if (result.killed || result.code === 0 || unityCliRuntimeState[UNITY_CLI_WARNING_SYMBOL] === true) return;
948
+ unityCliRuntimeState[UNITY_CLI_WARNING_SYMBOL] = true;
949
+ ctx.ui.notify(UNITY_CLI_WARNING, "warning");
950
+ } catch (error) {
951
+ if ((error instanceof Error && error.name === "AbortError") || isUnityCliProbeTimeout(error)) return;
952
+ if (unityCliRuntimeState[UNITY_CLI_WARNING_SYMBOL] === true) return;
953
+ unityCliRuntimeState[UNITY_CLI_WARNING_SYMBOL] = true;
954
+ ctx.ui.notify(UNITY_CLI_WARNING, "warning");
955
+ }
956
+ }
957
+ type UnityCliAvailability = "available" | "unavailable" | "uncertain";
958
+
959
+ function isUnityCliProbeTimeout(error: unknown): boolean {
960
+ return Boolean(error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ETIMEDOUT");
961
+ }
962
+
963
+ async function getUnityCliAvailability(pi: ExtensionAPI, signal?: AbortSignal): Promise<UnityCliAvailability> {
940
964
  try {
941
965
  throwIfAborted(signal);
942
966
  const command = resolveUnityCliCommand();
943
967
  const result = await pi.exec(command, ["--version"], { signal, timeout: 5000 });
944
968
  throwIfAborted(signal);
945
- return !result.killed && result.code === 0;
969
+ if (result.killed) return "uncertain";
970
+ return result.code === 0 ? "available" : "unavailable";
946
971
  } catch (error) {
947
972
  if (signal?.aborted || (error instanceof Error && error.name === "AbortError")) throw error;
948
- return false;
973
+ return isUnityCliProbeTimeout(error) ? "uncertain" : "unavailable";
949
974
  }
950
975
  }
951
976
 
@@ -995,16 +1020,18 @@ async function shouldUseUnityCli(
995
1020
  return false;
996
1021
  }
997
1022
 
998
- const available = await canUseUnityCli(pi, signal);
999
- if (preference === "unity-cli" && !available) {
1023
+ const availability = await getUnityCliAvailability(pi, signal);
1024
+ if (availability === "uncertain") {
1025
+ throw new Error("Unity CLI availability could not be confirmed because its bounded probe timed out. Retry the request; pi-unity will not select the direct Editor fallback after an uncertain Unity CLI probe.");
1026
+ }
1027
+ if (preference === "unity-cli" && availability === "unavailable") {
1000
1028
  throw new Error("Unity CLI launcher was requested, but the `unity` command is not available. Set UNITY_CLI_PATH or use launcher='editor-executable'.");
1001
1029
  }
1002
1030
 
1003
- return available;
1031
+ return availability === "available";
1004
1032
  }
1005
1033
 
1006
1034
  type GuardedBatchmodeParams = {
1007
- unityEditorPath?: string;
1008
1035
  args?: string[];
1009
1036
  useGraphics?: boolean;
1010
1037
  timeoutSeconds?: number;
@@ -1036,17 +1063,14 @@ async function runGuardedUnityBatchmode(
1036
1063
  const invocation = parseUnityBatchmodeInvocation(createUnityCliBatchmodeReportArgs(candidate.projectRoot, extraArgs, { useGraphics }));
1037
1064
  const useUnityCli = await shouldUseUnityCli(pi, params.launcher, signal);
1038
1065
  throwIfAborted(signal);
1039
- const editorPath = useUnityCli
1040
- ? await resolveUnityEditorPath(candidate.unityVersion, { overridePath: params.unityEditorPath }).catch(() => "Unity CLI resolved editor")
1041
- : await resolveUnityEditorPath(candidate.unityVersion, { overridePath: params.unityEditorPath });
1042
- const command = useUnityCli
1043
- ? createUnityCliRunCommand(candidate.projectRoot, extraArgs, {
1044
- editorVersion: candidate.unityVersion,
1045
- editorPath: params.unityEditorPath,
1046
- timeoutSeconds,
1047
- useGraphics,
1048
- })
1049
- : createUnityBatchmodeCommand(editorPath, candidate.projectRoot, extraArgs, { useGraphics });
1066
+ let editorPath = "Unity CLI";
1067
+ let command: { command: string; args: string[] };
1068
+ if (useUnityCli) {
1069
+ command = createUnityCliRunCommand(candidate.projectRoot, extraArgs, { timeoutSeconds, useGraphics });
1070
+ } else {
1071
+ editorPath = await resolveUnityEditorPath(await requireManualUnityVersion(candidate));
1072
+ command = createUnityBatchmodeCommand(editorPath, candidate.projectRoot, extraArgs, { useGraphics });
1073
+ }
1050
1074
  const closeReport = await closeBlockingUnityProcessesForBatchmode(
1051
1075
  pi,
1052
1076
  ctx,
@@ -1123,7 +1147,7 @@ async function runUnifiedUnityTests(
1123
1147
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: UnityToolDetails & { testResult: NormalizedUnityTestResult; artifactPath: string; route: "connected" | "isolated" } }> {
1124
1148
  const request = normalizeUnityRunTestsRequest(raw);
1125
1149
  const requirements = getUnityTestRouteRequirements(request);
1126
- const capabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, candidate.unityVersion, { signal, execute: createPipelineUnityCliExecutor(pi) });
1150
+ const capabilities = await inspectUnityCliProjectCapabilities(candidate.projectRoot, await requireManualUnityVersion(candidate), { signal, execute: createPipelineUnityCliExecutor(pi) });
1127
1151
  const reachable = capabilities.matchingInstances.some(instance => instance.reachable === true);
1128
1152
  const busy = (await listBlockingUnityProcesses(candidate.projectRoot)).processes.length > 0 || capabilities.matchingInstances.length > 0;
1129
1153
  let route: "connected" | "isolated";
@@ -1144,7 +1168,7 @@ async function runUnifiedUnityTests(
1144
1168
  }
1145
1169
  const formats = request.reportFormats ?? defaultUnityTestReportFormats(route);
1146
1170
  if (route === "connected") {
1147
- const result = await runUnityPipelineTests({ projectRoot: candidate.projectRoot, unityVersion: candidate.unityVersion, testPlatform: request.testPlatform, testFilter: request.testFilters[0], testCategory: request.testCategories[0], timeoutSeconds: request.timeoutSeconds, allowAutonomousExitPlayMode }, createPipelineDependencies(pi), { signal, onUpdate: message => onUpdate?.({ content: [{ type: "text", text: message }] }) });
1171
+ const result = await runUnityPipelineTests({ projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), testPlatform: request.testPlatform, testFilter: request.testFilters[0], testCategory: request.testCategories[0], timeoutSeconds: request.timeoutSeconds, allowAutonomousExitPlayMode }, createPipelineDependencies(pi), { signal, onUpdate: message => onUpdate?.({ content: [{ type: "text", text: message }] }) });
1148
1172
  const counts = result.details.counts!;
1149
1173
  const normalized: NormalizedUnityTestResult = {
1150
1174
  schemaVersion: 1, source: "pipeline", platform: request.testPlatform,
@@ -1153,11 +1177,11 @@ async function runUnifiedUnityTests(
1153
1177
  };
1154
1178
  const artifactPath = await writeNormalizedUnityTestArtifact(candidate.projectRoot, normalized);
1155
1179
  const text = `${compactUnityTestSummary(normalized)}\nRoute: connected Pipeline. Normalized artifact: ${artifactPath}`;
1156
- return { content: [{ type: "text", text }], details: { mode: "tests", projectRoot: candidate.projectRoot, unityVersion: candidate.unityVersion, editorPath: "", status: normalized.outcome === "passed" ? "passed" : "failed", pipeline: result.details, testResult: { ...normalized, tests: [] }, artifactPath, route } };
1180
+ return { content: [{ type: "text", text }], details: { mode: "tests", projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), editorPath: "", status: normalized.outcome === "passed" ? "passed" : "failed", pipeline: result.details, testResult: { ...normalized, tests: [] }, artifactPath, route } };
1157
1181
  }
1158
1182
  if (request.isolatedLauncher === "editor-executable" && (request.retries || request.rerunFailed || request.shard || request.coverage || formats.includes("junit"))) throw new Error("The direct Editor fallback cannot honor CLI-only retry, rerun, shard, coverage, or JUnit options.");
1159
1183
  const plan = createUnityTestBatchPlan({ projectRoot: candidate.projectRoot, testPlatform: request.testPlatform, testFilters: request.testFilters, testCategories: request.testCategories });
1160
- const cliAvailable = request.isolatedLauncher !== "editor-executable" && await canUseUnityCli(pi, signal);
1184
+ const cliAvailable = await shouldUseUnityCli(pi, request.isolatedLauncher, signal);
1161
1185
  if (!cliAvailable && request.isolatedLauncher === "unity-cli") throw new Error("Unity CLI was requested but is unavailable.");
1162
1186
  if (!cliAvailable && (request.retries || request.rerunFailed || request.shard || request.coverage || formats.includes("junit"))) throw new Error("Unity CLI is unavailable and the requested options have no direct Editor fallback.");
1163
1187
  if (!cliAvailable) {
@@ -1173,7 +1197,7 @@ async function runUnifiedUnityTests(
1173
1197
  const closeReport = await closeBlockingUnityProcessesForBatchmode(pi, ctx, candidate, invocation, request.closeBlockingUnityProcess, signal);
1174
1198
  await removeStaleLockfileAfterGuardedClose(candidate, closeReport);
1175
1199
  await enforceLaunchRouteSafety(candidate.projectRoot, "unity-cli");
1176
- const command = createUnityCliTestCommand(candidate.projectRoot, { testPlatform: request.testPlatform, testFilters: request.testFilters, testCategories: request.testCategories, retries: request.retries, rerunFailed: request.rerunFailed, shard: request.shard, shardInventoryPath: request.shardInventoryPath, reportPaths: { nunit: formats.includes("nunit") ? plan.testResultsPath : undefined, junit: formats.includes("junit") ? plan.junitResultsPath : undefined, log: plan.logFilePath }, coverage: request.coverage, coverageOptions: request.coverageOptions, useGraphics: request.useGraphics, timeoutSeconds: request.timeoutSeconds, editorVersion: candidate.unityVersion });
1200
+ const command = createUnityCliTestCommand(candidate.projectRoot, { testPlatform: request.testPlatform, testFilters: request.testFilters, testCategories: request.testCategories, retries: request.retries, rerunFailed: request.rerunFailed, shard: request.shard, shardInventoryPath: request.shardInventoryPath, reportPaths: resolveUnityCliBackendReportPaths({ nunit: plan.testResultsPath, junit: plan.junitResultsPath, log: plan.logFilePath }, formats), coverage: request.coverage, coverageOptions: request.coverageOptions, useGraphics: request.useGraphics, timeoutSeconds: request.timeoutSeconds });
1177
1201
  const execution = await pi.exec(command.command, command.args, { signal, timeout: (request.timeoutSeconds ?? 3600) * 1000 + 30_000 });
1178
1202
  const effectiveResultsPath = deriveUnityCliEffectiveReportPath(plan.testResultsPath, { rerunFailed: request.rerunFailed, shard: request.shard });
1179
1203
  let effectiveResultsXml: string | undefined;
@@ -1197,7 +1221,7 @@ async function runUnifiedUnityTests(
1197
1221
  }
1198
1222
  const artifactPath = await writeNormalizedUnityTestArtifact(candidate.projectRoot, normalized);
1199
1223
  const text = `${compactUnityTestSummary(normalized)}\nRoute: isolated Unity CLI. Normalized artifact: ${artifactPath}`;
1200
- return { content: [{ type: "text", text }], details: { mode: "tests", projectRoot: candidate.projectRoot, unityVersion: candidate.unityVersion, editorPath: "Unity CLI", status: outcome === "passed" || outcome === "passed_with_flakes" || outcome === "empty_selection" ? "passed" : "failed", command: command.command, cliArgs: command.args, testBatch: plan, testResult: { ...normalized, tests: [] }, artifactPath, route } };
1224
+ return { content: [{ type: "text", text }], details: { mode: "tests", projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), editorPath: "Unity CLI", status: outcome === "passed" || outcome === "passed_with_flakes" || outcome === "empty_selection" ? "passed" : "failed", command: command.command, cliArgs: command.args, testBatch: plan, testResult: { ...normalized, tests: [] }, artifactPath, route } };
1201
1225
  });
1202
1226
  }
1203
1227
 
@@ -1338,6 +1362,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1338
1362
 
1339
1363
  pi.on("session_start", async (_event, ctx) => {
1340
1364
  restoreSessionSettings(ctx);
1365
+ await warnWhenUnityCliUnavailable(pi, ctx);
1341
1366
  const scope = ctx.sessionManager;
1342
1367
  unregisterScope(registrations.get(scope));
1343
1368
  // Optional package integrations resolve independently. The Unity extension and
@@ -1415,14 +1440,14 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1415
1440
  await withUnityProjectLaunchMutex(candidate.projectRoot, { mode: "gui", toolName: "unity-open" }, async () => {
1416
1441
  await enforceSingleProcessRule(candidate.projectRoot);
1417
1442
  let launcher: "unity-cli" | "editor-executable" = "editor-executable";
1418
- let editorPath = await resolveUnityEditorPath(candidate.unityVersion).catch(() => "Unity CLI resolved editor");
1443
+ let editorPath = "Unity CLI";
1419
1444
  let launch: { pid: number | undefined; args: string[]; command: string };
1420
- if (await canUseUnityCli(pi)) {
1445
+ if (await shouldUseUnityCli(pi, undefined)) {
1421
1446
  launcher = "unity-cli";
1422
- launch = launchUnityCliOpenDetached(candidate.projectRoot, { editorVersion: candidate.unityVersion });
1447
+ launch = launchUnityCliOpenDetached(candidate.projectRoot);
1423
1448
  } else {
1424
1449
  await assertUnityProjectNotBusy(candidate.projectRoot);
1425
- editorPath = await resolveUnityEditorPath(candidate.unityVersion);
1450
+ editorPath = await resolveUnityEditorPath(await requireManualUnityVersion(candidate));
1426
1451
  launch = launchUnityEditorDetached(editorPath, candidate.projectRoot);
1427
1452
  }
1428
1453
  const summary = buildEditorLaunchSummary(ctx.cwd, candidate, editorPath, discoveryWarning, launcher);
@@ -1549,13 +1574,13 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1549
1574
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
1550
1575
  throwIfAborted(signal);
1551
1576
  const { candidate } = await resolveProjectCandidate(ctx, params.path);
1552
- const result = await runUnityPipelineRecompile({ projectRoot: candidate.projectRoot, unityVersion: candidate.unityVersion, timeoutSeconds: params.timeoutSeconds, allowAutonomousExitPlayMode: sessionAllowsAutonomousPlayModeExit(ctx) }, createPipelineDependencies(pi), {
1577
+ const result = await runUnityPipelineRecompile({ projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), timeoutSeconds: params.timeoutSeconds, allowAutonomousExitPlayMode: sessionAllowsAutonomousPlayModeExit(ctx) }, createPipelineDependencies(pi), {
1553
1578
  signal,
1554
1579
  onUpdate: message => onUpdate?.({ content: [{ type: "text", text: message }] }),
1555
1580
  });
1556
1581
  return {
1557
1582
  content: [{ type: "text", text: result.text }],
1558
- details: { mode: "pipeline", projectRoot: result.details.projectRoot, unityVersion: candidate.unityVersion, editorPath: "", status: "passed", pipeline: result.details } satisfies UnityToolDetails,
1583
+ details: { mode: "pipeline", projectRoot: result.details.projectRoot, unityVersion: await requireManualUnityVersion(candidate), editorPath: "", status: "passed", pipeline: result.details } satisfies UnityToolDetails,
1559
1584
  };
1560
1585
  },
1561
1586
  renderCall(args, theme, context) { return renderUnityPipelineCall("unity_pipeline_recompile", args, theme, context); },
@@ -1580,7 +1605,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1580
1605
  throwIfAborted(signal);
1581
1606
  const result = await dispatchUnityPlanningInspection({
1582
1607
  projectRoot: candidate.projectRoot,
1583
- unityVersion: candidate.unityVersion,
1608
+ unityVersion: await requireManualUnityVersion(candidate),
1584
1609
  command: "eval",
1585
1610
  evalSnippet: params.code,
1586
1611
  }, {
@@ -1597,7 +1622,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1597
1622
  details: {
1598
1623
  mode: "pipeline_eval",
1599
1624
  projectRoot: candidate.projectRoot,
1600
- unityVersion: candidate.unityVersion,
1625
+ unityVersion: await requireManualUnityVersion(candidate),
1601
1626
  editorPath: "",
1602
1627
  status: result.outcome === "dispatched" ? "passed" : "failed",
1603
1628
  pipelineEval: result,
@@ -1629,7 +1654,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1629
1654
  throwIfAborted(signal);
1630
1655
  const result = await dispatchUnityPlanningInspection({
1631
1656
  projectRoot: candidate.projectRoot,
1632
- unityVersion: candidate.unityVersion,
1657
+ unityVersion: await requireManualUnityVersion(candidate),
1633
1658
  command: params.command,
1634
1659
  args: params.args,
1635
1660
  }, {
@@ -1646,7 +1671,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1646
1671
  details: {
1647
1672
  mode: "pipeline_inspection",
1648
1673
  projectRoot: candidate.projectRoot,
1649
- unityVersion: candidate.unityVersion,
1674
+ unityVersion: await requireManualUnityVersion(candidate),
1650
1675
  editorPath: "",
1651
1676
  status: result.outcome === "dispatched" ? "passed" : "failed",
1652
1677
  pipelineInspection: result,
@@ -1715,18 +1740,14 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1715
1740
  const useUnityCli = await shouldUseUnityCli(pi, params.launcher as UnityLauncherPreference | undefined, signal);
1716
1741
  throwIfAborted(signal);
1717
1742
  let launcher: "unity-cli" | "editor-executable" = "editor-executable";
1718
- let editorPath = await resolveUnityEditorPath(candidate.unityVersion, { overridePath: params.unityEditorPath }).catch(() => "Unity CLI resolved editor");
1743
+ let editorPath = "Unity CLI";
1719
1744
  let launch: { pid: number | undefined; args: string[]; command: string };
1720
1745
  if (useUnityCli) {
1721
1746
  launcher = "unity-cli";
1722
- launch = launchUnityCliOpenDetached(candidate.projectRoot, {
1723
- editorVersion: candidate.unityVersion,
1724
- editorPath: params.unityEditorPath,
1725
- automated: params.automated,
1726
- });
1747
+ launch = launchUnityCliOpenDetached(candidate.projectRoot, { automated: params.automated });
1727
1748
  } else {
1728
1749
  await assertUnityProjectNotBusy(candidate.projectRoot);
1729
- editorPath = await resolveUnityEditorPath(candidate.unityVersion, { overridePath: params.unityEditorPath });
1750
+ editorPath = await resolveUnityEditorPath(await requireManualUnityVersion(candidate));
1730
1751
  launch = launchUnityEditorDetached(editorPath, candidate.projectRoot, { automated: params.automated });
1731
1752
  }
1732
1753
  const text = buildEditorLaunchSummary(ctx.cwd, candidate, editorPath, discoveryWarning, launcher);
@@ -1736,7 +1757,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1736
1757
  details: {
1737
1758
  mode: "gui",
1738
1759
  projectRoot: candidate.projectRoot,
1739
- unityVersion: candidate.unityVersion,
1760
+ unityVersion: await requireManualUnityVersion(candidate),
1740
1761
  editorPath,
1741
1762
  pid: launch.pid,
1742
1763
  command: launch.command,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aefree/pi-unity",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./index.ts"
@@ -18,7 +18,7 @@
18
18
  ]
19
19
  },
20
20
  "scripts": {
21
- "test": "tsx tests/unity-core.test.ts && tsx tests/unity-pipeline.test.ts && tsx tests/unity-processes.test.ts && tsx tests/pi-unity-settings.test.ts && tsx tests/unity-projects.test.ts && tsx tests/unity-guidance-audit.test.ts && tsx tests/unity-batchmode.test.ts && tsx tests/unity-test-batch.test.ts && tsx tests/unity-tests.test.ts && tsx tests/unity-cli.test.ts && tsx tests/unity-project-lock.test.ts && tsx tests/unity-artifact-profile.test.ts && tsx tests/unity-file-discovery-filter.test.ts && tsx tests/unity-registration.test.ts && tsx tests/unity-optional-integrations.test.ts && tsx tests/unity-package-validation.test.ts",
21
+ "test": "tsx tests/unity-core.test.ts && tsx tests/unity-launch.test.ts && tsx tests/unity-pipeline.test.ts && tsx tests/unity-processes.test.ts && tsx tests/pi-unity-settings.test.ts && tsx tests/unity-projects.test.ts && tsx tests/unity-guidance-audit.test.ts && tsx tests/unity-batchmode.test.ts && tsx tests/unity-test-batch.test.ts && tsx tests/unity-tests.test.ts && tsx tests/unity-cli.test.ts && tsx tests/unity-project-lock.test.ts && tsx tests/unity-artifact-profile.test.ts && tsx tests/unity-file-discovery-filter.test.ts && tsx tests/unity-registration.test.ts && tsx tests/unity-optional-integrations.test.ts && tsx tests/unity-package-validation.test.ts",
22
22
  "eval:guidance-skill": "tsx evals/auditing-unity-agent-guidance/run-eval.ts"
23
23
  },
24
24
  "dependencies": {
@@ -50,9 +50,9 @@ Use the `pi-unity` tools first instead of forming raw Unity CLI commands on the
50
50
  `unity_launch_batchmode` remains the default for custom agent-run headless Unity work because it already:
51
51
  - resolves the Unity project from a direct project root, a coordination root, or another nearby folder
52
52
  - reads `ProjectSettings/ProjectVersion.txt`
53
- - prefers the installed `unity run` CLI when available, falling back to OS-aware direct editor launch
53
+ - treats installed `unity run` as authoritative when available: it receives the project path without `--editor-version` and reads the declared version itself, falling back only when Unity CLI is unavailable
54
54
  - uses OS-aware standard install probing
55
- - supports explicit `UNITY_EDITOR_PATH` / `unityEditorPath` overrides
55
+ - resolves only exact project-version standard installation candidates when direct Editor fallback is required
56
56
  - strips direct-Editor flags managed by `unity run` (`-batchmode`, `-projectPath`, `-quit`) before forwarding args in Unity CLI mode
57
57
  - supports `launcher: "editor-executable"` when Unity CLI argument forwarding differs from direct Editor executable behavior
58
58
  - checks Unity CLI status and running Unity processes before launch
@@ -68,15 +68,15 @@ If a launch is blocked by a native Unity lockfile, call `unity_project_status` b
68
68
 
69
69
  ### 2. Get the Project's Unity Version
70
70
 
71
- If you are in direct CLI fallback mode, read `ProjectSettings/ProjectVersion.txt` in the Unity project root:
71
+ Before direct Editor fallback, read `ProjectSettings/ProjectVersion.txt` in the Unity project root:
72
72
 
73
73
  ```
74
74
  m_EditorVersion: ####.#.#f#
75
75
  ```
76
76
 
77
- ### 3. Find the Unity Executable
77
+ ### 3. Resolve the exact-version Unity Editor
78
78
 
79
- If you are in direct CLI fallback mode, check these locations based on platform and version:
79
+ For direct Editor fallback, use only the standard locations constructed from the version declared in `ProjectVersion.txt`:
80
80
 
81
81
  **Windows:**
82
82
  ```
@@ -89,10 +89,9 @@ C:\UnityInstalls\<version>\Editor\Unity.exe
89
89
  ```
90
90
  /Applications/Unity/Hub/Editor/<version>/Unity.app/Contents/MacOS/Unity
91
91
  /Applications/Unity/<version>/Unity.app/Contents/MacOS/Unity
92
- /Applications/Unity*
93
92
  ```
94
93
 
95
- If not found, ask the user for their Unity install path.
94
+ If the exact declared version is not found, do not use an arbitrary Editor path or a nearby version. Ask the user to install the declared version through their normal Unity installation workflow, or use Unity CLI after it can resolve that exact version.
96
95
 
97
96
  ### 4. Choose the execution route, then run tests
98
97
 
@@ -124,12 +123,13 @@ After choosing the isolated route:
124
123
  - exclude graphics-required screenshot/visual-capture tests from no-graphics runs
125
124
  - prefer project test categories such as `RequiresGraphics` / `VisualCapture` when the project exposes them
126
125
 
127
- Direct CLI fallback templates:
126
+ Unity CLI project-authoritative template:
128
127
  ```
129
128
  unity test "<ProjectPath>" --mode <EditMode|PlayMode> --filter "<Full.Test.Name>" --output "<ResultsPath>" -- -logFile "<LogPath>"
130
- "<UnityEditorPath>" -batchmode -nographics -projectPath "<ProjectPath>" -runTests -testPlatform <EditMode|PlayMode> -testFilter "<Full.Test.Name>" -testResults "<ResultsPath>" -logFile "<LogPath>"
131
129
  ```
132
130
 
131
+ For direct Editor fallback, use `unity_launch_batchmode` with its project path and test parameters; it resolves only the declared project's exact-version standard candidates.
132
+
133
133
  Fallback parameters:
134
134
  - `-testPlatform`: `EditMode` or `PlayMode`
135
135
  - `-testFilter`: Full test name (e.g., `MyNamespace.MyTests.TestMethodName`)
package/src/unity-cli.ts CHANGED
@@ -13,8 +13,7 @@ export type UnityCliCommand = {
13
13
  };
14
14
 
15
15
  export type UnityCliLaunchOptions = {
16
- editorVersion?: string;
17
- editorPath?: string;
16
+ editorVersionOverride?: string;
18
17
  timeoutSeconds?: number;
19
18
  cliCommand?: string;
20
19
  useGraphics?: boolean;
@@ -84,11 +83,8 @@ function unityCliBaseArgs(): string[] {
84
83
  }
85
84
 
86
85
  function appendUnityCliEditorOptions(args: string[], options: UnityCliLaunchOptions): void {
87
- if (options.editorVersion?.trim()) {
88
- args.push("--editor-version", options.editorVersion.trim());
89
- }
90
- if (options.editorPath?.trim()) {
91
- args.push("--editor-path", options.editorPath.trim());
86
+ if (options.editorVersionOverride?.trim()) {
87
+ args.push("--editor-version", options.editorVersionOverride.trim());
92
88
  }
93
89
  }
94
90
 
package/src/unity-core.ts CHANGED
@@ -27,14 +27,6 @@ export function parseUnityVersionText(contents: string): string | null {
27
27
  return match ? match[1] : null;
28
28
  }
29
29
 
30
- export function normalizeUnityEditorOverride(editorPath: string, platform: SupportedPlatform): string {
31
- const normalized = path.normalize(editorPath.trim());
32
- if (platform === "darwin" && normalized.toLowerCase().endsWith(".app")) {
33
- return path.join(normalized, "Contents", "MacOS", "Unity");
34
- }
35
- return normalized;
36
- }
37
-
38
30
  export function buildUnityEditorCandidates(
39
31
  version: string,
40
32
  platform: SupportedPlatform = process.platform,
@@ -0,0 +1,36 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import {
4
+ buildUnityBatchmodeArgs,
5
+ buildUnityEditorCandidates,
6
+ buildUnityOpenEditorArgs,
7
+ type SupportedPlatform,
8
+ type UnityOpenEditorArgsOptions,
9
+ } from "./unity-core";
10
+
11
+ /** Direct-executable compatibility fallback. Unity CLI routes must not import this module. */
12
+ export async function resolveUnityEditorPath(
13
+ unityVersion: string,
14
+ options: { platform?: SupportedPlatform; homeDir?: string; access?: (candidate: string) => Promise<void> } = {},
15
+ ): Promise<string> {
16
+ const autoCandidates = buildUnityEditorCandidates(unityVersion, options.platform ?? process.platform, options.homeDir);
17
+ const access = options.access ?? ((candidate: string) => fs.access(candidate, fs.constants.X_OK));
18
+ for (const candidate of autoCandidates) {
19
+ try { await access(candidate); return path.normalize(candidate); } catch { /* Try next exact-version candidate. */ }
20
+ }
21
+ throw new Error(`Could not find a Unity Editor executable for Unity ${unityVersion}. Install that exact Editor version through your normal Unity installation workflow, or install Unity CLI so it can resolve the project version.`);
22
+ }
23
+
24
+ export function launchUnityEditorDetached(editorPath: string, projectRoot: string, options: UnityOpenEditorArgsOptions = {}): { pid: number | undefined; args: string[]; command: string } {
25
+ const args = buildUnityOpenEditorArgs(projectRoot, options);
26
+ const child = (awaitableSpawn)(editorPath, args, { detached: true, stdio: "ignore", windowsHide: false });
27
+ child.unref();
28
+ return { pid: child.pid, args, command: editorPath };
29
+ }
30
+
31
+ // Kept local so this module owns direct process creation entirely.
32
+ import { spawn as awaitableSpawn } from "node:child_process";
33
+
34
+ export function createUnityBatchmodeCommand(editorPath: string, projectRoot: string, extraArgs: string[] = [], options: { useGraphics?: boolean } = {}): { command: string; args: string[] } {
35
+ return { command: editorPath, args: buildUnityBatchmodeArgs(projectRoot, extraArgs, options) };
36
+ }
@@ -1,90 +1,10 @@
1
- import * as fs from "node:fs/promises";
2
- import * as path from "node:path";
3
1
  import { spawn } from "node:child_process";
4
- import {
5
- buildUnityBatchmodeArgs,
6
- buildUnityEditorCandidates,
7
- buildUnityOpenEditorArgs,
8
- normalizeUnityEditorOverride,
9
- type SupportedPlatform,
10
- type UnityOpenEditorArgsOptions,
11
- } from "./unity-core";
12
2
  import { createUnityCliOpenCommand, type UnityCliLaunchOptions } from "./unity-cli";
13
3
 
14
- export async function resolveUnityEditorPath(
15
- unityVersion: string,
16
- options: {
17
- overridePath?: string;
18
- env?: NodeJS.ProcessEnv;
19
- platform?: SupportedPlatform;
20
- homeDir?: string;
21
- } = {},
22
- ): Promise<string> {
23
- const platform = options.platform ?? process.platform;
24
- const env = options.env ?? process.env;
25
- const homeDir = options.homeDir;
26
-
27
- const overrideCandidates = [options.overridePath, env.UNITY_EDITOR_PATH, env.UNITY_PATH]
28
- .map((value) => value?.trim())
29
- .filter((value): value is string => Boolean(value))
30
- .map((value) => normalizeUnityEditorOverride(value, platform));
31
-
32
- const autoCandidates = buildUnityEditorCandidates(unityVersion, platform, homeDir);
33
-
34
- for (const candidate of [...overrideCandidates, ...autoCandidates]) {
35
- try {
36
- await fs.access(candidate, fs.constants.X_OK);
37
- return path.normalize(candidate);
38
- } catch {
39
- // Try next candidate.
40
- }
41
- }
42
-
43
- throw new Error(
44
- [
45
- `Could not find a Unity Editor executable for Unity ${unityVersion}.`,
46
- "Set UNITY_EDITOR_PATH to an explicit executable path or pass unityEditorPath.",
47
- ].join(" "),
48
- );
49
- }
50
-
51
- export function launchUnityEditorDetached(
52
- editorPath: string,
53
- projectRoot: string,
54
- options: UnityOpenEditorArgsOptions = {},
55
- ): { pid: number | undefined; args: string[]; command: string } {
56
- const args = buildUnityOpenEditorArgs(projectRoot, options);
57
- const child = spawn(editorPath, args, {
58
- detached: true,
59
- stdio: "ignore",
60
- windowsHide: false,
61
- });
62
- child.unref();
63
- return { pid: child.pid, args, command: editorPath };
64
- }
65
-
66
- export function launchUnityCliOpenDetached(
67
- projectRoot: string,
68
- options: UnityCliLaunchOptions = {},
69
- ): { pid: number | undefined; args: string[]; command: string } {
4
+ /** Unity CLI GUI launch path. Direct executable fallback lives in unity-editor-fallback.ts. */
5
+ export function launchUnityCliOpenDetached(projectRoot: string, options: UnityCliLaunchOptions = {}): { pid: number | undefined; args: string[]; command: string } {
70
6
  const cli = createUnityCliOpenCommand(projectRoot, options);
71
- const child = spawn(cli.command, cli.args, {
72
- detached: true,
73
- stdio: "ignore",
74
- windowsHide: false,
75
- });
7
+ const child = spawn(cli.command, cli.args, { detached: true, stdio: "ignore", windowsHide: false });
76
8
  child.unref();
77
9
  return { pid: child.pid, args: cli.args, command: cli.command };
78
10
  }
79
-
80
- export function createUnityBatchmodeCommand(
81
- editorPath: string,
82
- projectRoot: string,
83
- extraArgs: string[] = [],
84
- options: { useGraphics?: boolean } = {},
85
- ): { command: string; args: string[] } {
86
- return {
87
- command: editorPath,
88
- args: buildUnityBatchmodeArgs(projectRoot, extraArgs, options),
89
- };
90
- }
@@ -5,7 +5,8 @@ import { parseUnityVersionText, resolveAbsolutePath } from "./unity-core";
5
5
  export type UnityProjectCandidate = {
6
6
  projectRoot: string;
7
7
  projectName: string;
8
- unityVersion: string;
8
+ /** Manual ProjectVersion.txt evidence, loaded only by direct/Pipeline routes. */
9
+ unityVersion?: string;
9
10
  };
10
11
 
11
12
  export type UnityProjectDiscoveryResult = {
@@ -81,7 +82,6 @@ export async function findAncestorUnityProject(startDir: string): Promise<UnityP
81
82
  return {
82
83
  projectRoot: current,
83
84
  projectName: path.basename(current),
84
- unityVersion: await readUnityVersion(current),
85
85
  };
86
86
  }
87
87
 
@@ -123,7 +123,6 @@ export async function discoverUnityProjects(
123
123
  candidates.push({
124
124
  projectRoot: next.dir,
125
125
  projectName: path.basename(next.dir),
126
- unityVersion: await readUnityVersion(next.dir),
127
126
  });
128
127
  if (candidates.length >= maxCandidates) {
129
128
  truncated = true;
@@ -63,6 +63,19 @@ export type NormalizedUnityTestResult = {
63
63
  };
64
64
 
65
65
  export type UnityTestRouteRequirements = { requiresIsolation: boolean; reasons: string[] };
66
+
67
+ export function resolveUnityCliBackendReportPaths(
68
+ paths: { nunit: string; junit: string; log: string },
69
+ formats: UnityTestReportFormat[],
70
+ ): { nunit: string; junit?: string; log: string } {
71
+ // Normalized JSON is derived from Unity CLI's native NUnit XML, so the
72
+ // backend report is required even when callers do not retain NUnit output.
73
+ return {
74
+ nunit: paths.nunit,
75
+ junit: formats.includes("junit") ? paths.junit : undefined,
76
+ log: paths.log,
77
+ };
78
+ }
66
79
  export type UnityTestOutcomeEvidence = {
67
80
  cancelled?: boolean;
68
81
  timedOut?: boolean;