@aefree/pi-unity 0.9.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.
@@ -0,0 +1,218 @@
1
+ import { realpathSync } from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+
5
+ export type SupportedPlatform = NodeJS.Platform | "win32" | "darwin" | "linux";
6
+
7
+ export function normalizeUserPath(rawPath: string): string {
8
+ const trimmed = rawPath.trim();
9
+ return trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
10
+ }
11
+
12
+ export function resolveAbsolutePath(cwd: string, rawPath: string): string {
13
+ const normalized = normalizeUserPath(rawPath);
14
+ return path.isAbsolute(normalized) ? path.normalize(normalized) : path.resolve(cwd, normalized);
15
+ }
16
+
17
+ export function formatPathForUser(cwd: string, absolutePath: string): string {
18
+ const relative = path.relative(cwd, absolutePath);
19
+ if (!relative || relative.startsWith("..")) {
20
+ return absolutePath;
21
+ }
22
+ return relative.split(path.sep).join("/");
23
+ }
24
+
25
+ export function parseUnityVersionText(contents: string): string | null {
26
+ const match = contents.match(/^m_EditorVersion:\s*(\S+)\s*$/m);
27
+ return match ? match[1] : null;
28
+ }
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
+ export function buildUnityEditorCandidates(
39
+ version: string,
40
+ platform: SupportedPlatform = process.platform,
41
+ homeDir: string = os.homedir(),
42
+ ): string[] {
43
+ if (platform === "win32") {
44
+ return [
45
+ path.join("C:/Program Files/Unity/Hub/Editor", version, "Editor", "Unity.exe"),
46
+ path.join("C:/Program Files/Unity", version, "Editor", "Unity.exe"),
47
+ path.join("C:/UnityInstalls", version, "Editor", "Unity.exe"),
48
+ ];
49
+ }
50
+
51
+ if (platform === "darwin") {
52
+ return [
53
+ path.join("/Applications/Unity/Hub/Editor", version, "Unity.app", "Contents", "MacOS", "Unity"),
54
+ path.join("/Applications/Unity", version, "Unity.app", "Contents", "MacOS", "Unity"),
55
+ ];
56
+ }
57
+
58
+ return [
59
+ path.join(homeDir, "Unity", "Hub", "Editor", version, "Editor", "Unity"),
60
+ path.join(homeDir, "Applications", "Unity", "Hub", "Editor", version, "Editor", "Unity"),
61
+ path.join("/opt/Unity/Hub/Editor", version, "Editor", "Unity"),
62
+ path.join("/opt/Unity", version, "Editor", "Unity"),
63
+ path.join("/opt/unity", version, "Editor", "Unity"),
64
+ ];
65
+ }
66
+
67
+ export function buildUnityOpenEditorArgs(projectRoot: string): string[] {
68
+ return ["-projectPath", projectRoot];
69
+ }
70
+
71
+ export type UnityBatchmodeArgsOptions = {
72
+ useGraphics?: boolean;
73
+ };
74
+
75
+ export function hasUnityCommandLineFlag(args: string[], flag: string): boolean {
76
+ const normalizedFlag = flag.toLowerCase();
77
+ return args.some((arg) => {
78
+ const lower = arg.toLowerCase();
79
+ return lower === normalizedFlag || lower.startsWith(`${normalizedFlag}=`);
80
+ });
81
+ }
82
+
83
+ export function applyDefaultUnityBatchmodeArgs(
84
+ extraArgs: string[] = [],
85
+ options: UnityBatchmodeArgsOptions = {},
86
+ ): string[] {
87
+ if (options.useGraphics || hasUnityCommandLineFlag(extraArgs, "-nographics")) {
88
+ return [...extraArgs];
89
+ }
90
+ return ["-nographics", ...extraArgs];
91
+ }
92
+
93
+ export function buildUnityBatchmodeArgs(
94
+ projectRoot: string,
95
+ extraArgs: string[] = [],
96
+ options: UnityBatchmodeArgsOptions = {},
97
+ ): string[] {
98
+ return ["-batchmode", "-projectPath", projectRoot, ...applyDefaultUnityBatchmodeArgs(extraArgs, options)];
99
+ }
100
+
101
+ function pathApiForPlatform(platform: SupportedPlatform): typeof path.win32 | typeof path.posix {
102
+ return platform === "win32" ? path.win32 : path.posix;
103
+ }
104
+
105
+ export function normalizeForCommandSearch(value: string, platform: SupportedPlatform = process.platform): string {
106
+ const normalized = pathApiForPlatform(platform).normalize(value.trim());
107
+ return platform === "win32" ? normalized.toLowerCase() : normalized;
108
+ }
109
+
110
+ function realpathMatchesOnDarwin(candidatePath: string, projectRoot: string, platform: SupportedPlatform): boolean | null {
111
+ if (platform !== "darwin" || process.platform !== "darwin") {
112
+ return null;
113
+ }
114
+
115
+ try {
116
+ return realpathSync.native(candidatePath) === realpathSync.native(projectRoot);
117
+ } catch {
118
+ // Only use filesystem identity when both paths can be resolved. Falling back
119
+ // to the case-sensitive textual comparison preserves case-sensitive APFS.
120
+ return null;
121
+ }
122
+ }
123
+
124
+ export function projectPathsMatch(candidatePath: string, projectRoot: string, platform: SupportedPlatform = process.platform): boolean {
125
+ const trimmedCandidatePath = candidatePath.trim();
126
+ const trimmedProjectRoot = projectRoot.trim();
127
+ const isWindowsStyleAbsolutePath = (value: string): boolean => /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
128
+ const comparisonPlatform = isWindowsStyleAbsolutePath(trimmedCandidatePath) && isWindowsStyleAbsolutePath(trimmedProjectRoot)
129
+ ? "win32"
130
+ : platform;
131
+ const pathApi = pathApiForPlatform(comparisonPlatform);
132
+ if (!pathApi.isAbsolute(trimmedCandidatePath) || !pathApi.isAbsolute(trimmedProjectRoot)) {
133
+ return false;
134
+ }
135
+
136
+ const realpathMatch = realpathMatchesOnDarwin(trimmedCandidatePath, trimmedProjectRoot, comparisonPlatform);
137
+ if (realpathMatch !== null) {
138
+ return realpathMatch;
139
+ }
140
+
141
+ return normalizeForCommandSearch(trimmedCandidatePath, comparisonPlatform) === normalizeForCommandSearch(trimmedProjectRoot, comparisonPlatform);
142
+ }
143
+
144
+ export function parseCommandLineArguments(commandLine: string): string[] {
145
+ const args: string[] = [];
146
+ let current = "";
147
+ let quote: "\"" | "'" | null = null;
148
+ let tokenStarted = false;
149
+
150
+ const pushCurrent = (): void => {
151
+ if (tokenStarted) {
152
+ args.push(current);
153
+ current = "";
154
+ tokenStarted = false;
155
+ }
156
+ };
157
+
158
+ for (const character of commandLine) {
159
+ if (quote) {
160
+ if (character === quote) {
161
+ quote = null;
162
+ } else {
163
+ current += character;
164
+ }
165
+ continue;
166
+ }
167
+
168
+ if (character === "\"" || character === "'") {
169
+ quote = character;
170
+ tokenStarted = true;
171
+ } else if (/\s/.test(character)) {
172
+ pushCurrent();
173
+ } else {
174
+ current += character;
175
+ tokenStarted = true;
176
+ }
177
+ }
178
+
179
+ pushCurrent();
180
+ return args;
181
+ }
182
+
183
+ export function extractUnityProjectPathArguments(commandLine: string): string[] {
184
+ const values: string[] = [];
185
+ const flagPattern = /(?:^|\s)-projectpath(?=\s|=)/gi;
186
+ let match: RegExpExecArray | null;
187
+
188
+ while ((match = flagPattern.exec(commandLine)) !== null) {
189
+ let index = flagPattern.lastIndex;
190
+ while (/\s/.test(commandLine[index] ?? "")) index += 1;
191
+ if (commandLine[index] === "=") {
192
+ index += 1;
193
+ while (/\s/.test(commandLine[index] ?? "")) index += 1;
194
+ }
195
+
196
+ const quote = commandLine[index] === "\"" || commandLine[index] === "'" ? commandLine[index] : null;
197
+ if (quote) {
198
+ const end = commandLine.indexOf(quote, index + 1);
199
+ if (end >= 0) {
200
+ values.push(commandLine.slice(index + 1, end));
201
+ flagPattern.lastIndex = end + 1;
202
+ }
203
+ continue;
204
+ }
205
+
206
+ const remainder = commandLine.slice(index);
207
+ const nextFlag = remainder.search(/\s+-[A-Za-z][A-Za-z0-9-]*(?=\s|=|$)/);
208
+ const value = (nextFlag >= 0 ? remainder.slice(0, nextFlag) : remainder).trim();
209
+ if (value) values.push(value);
210
+ }
211
+
212
+ return values;
213
+ }
214
+
215
+ export function commandTargetsProject(commandLine: string, projectRoot: string, platform: SupportedPlatform = process.platform): boolean {
216
+ return extractUnityProjectPathArguments(commandLine)
217
+ .some((candidatePath) => projectPathsMatch(candidatePath, projectRoot, platform));
218
+ }
@@ -0,0 +1,89 @@
1
+ import { access, readdir } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import * as path from "node:path";
4
+ import type {
5
+ FileDiscoveryExecutionContextV1,
6
+ FileDiscoveryFilterRequestV1,
7
+ FileDiscoveryFilterResultV1,
8
+ FileDiscoveryFilterV1,
9
+ } from "@aefree/pi-file-discovery/contracts/v1";
10
+
11
+ export const UNITY_FILE_DISCOVERY_FILTER_ID_V1 = "unity.generated-directories-filter.v1" as const;
12
+ export const UNITY_BROAD_GENERATED_DIRECTORIES_APPLIED_CODE = "unity_broad_generated_directories_applied" as const;
13
+ export const UNITY_EXACT_GENERATED_ROOT_BYPASSED_CODE = "unity_exact_generated_root_bypassed" as const;
14
+ export const UNITY_GENERATED_DIRECTORIES = Object.freeze(["Library", "Temp", "Logs", "obj", "Build", "Builds", "UserSettings", ".vs"] as const);
15
+
16
+ export function createUnityFileDiscoveryFilterV1(): FileDiscoveryFilterV1 {
17
+ return Object.freeze({
18
+ contractVersion: 1,
19
+ id: UNITY_FILE_DISCOVERY_FILTER_ID_V1,
20
+ kind: "file-discovery-filter",
21
+ owner: Object.freeze({ packageName: "@aefree/pi-unity", packageVersion: "0.8.3", packageRoot: path.resolve(fileURLToPath(new URL("..", import.meta.url))), registeredBy: "index.ts" }),
22
+ async evaluate(context, request) { return await evaluateUnityFileDiscoveryFilterV1(context, request); },
23
+ });
24
+ }
25
+
26
+ export async function evaluateUnityFileDiscoveryFilterV1(
27
+ context: FileDiscoveryExecutionContextV1,
28
+ request: FileDiscoveryFilterRequestV1,
29
+ ): Promise<FileDiscoveryFilterResultV1> {
30
+ if (context.signal !== request.signal) return { outcome: "error", code: "unity_signal_mismatch", retryable: false };
31
+ if (request.signal.aborted) return { outcome: "unavailable", code: "aborted", retryable: true };
32
+ const unityRoots = await discoverUnityRoots(request.workspaceRoot, request.roots, request.signal);
33
+ if (unityRoots.length === 0) return { outcome: "not_applicable" };
34
+ const roots = request.roots.map((searchRoot) => {
35
+ const absoluteSearchRoot = path.resolve(request.workspaceRoot, searchRoot);
36
+ const globs = new Set<string>();
37
+ let generatedRoot = false;
38
+ for (const unityRoot of unityRoots) {
39
+ if (isInside(unityRoot, absoluteSearchRoot)) {
40
+ const first = path.relative(unityRoot, absoluteSearchRoot).split(path.sep).filter(Boolean)[0];
41
+ if (first && UNITY_GENERATED_DIRECTORIES.some((entry) => entry.toLowerCase() === first.toLowerCase())) generatedRoot = true;
42
+ }
43
+ if (!isInside(absoluteSearchRoot, unityRoot)) continue;
44
+ const prefix = normalize(path.relative(absoluteSearchRoot, unityRoot));
45
+ for (const directory of UNITY_GENERATED_DIRECTORIES) globs.add(`!${prefix ? `${prefix}/` : ""}${directory}/**`);
46
+ }
47
+ // An exact root inside a generated directory is deliberate research intent.
48
+ return Object.freeze({
49
+ root: searchRoot,
50
+ filterDecision: generatedRoot ? "bypassed" : "applied",
51
+ decisionCode: generatedRoot ? UNITY_EXACT_GENERATED_ROOT_BYPASSED_CODE : UNITY_BROAD_GENERATED_DIRECTORIES_APPLIED_CODE,
52
+ ...(generatedRoot ? {} : { excludeGlobs: Object.freeze([...globs].sort()) }),
53
+ disclosures: Object.freeze([generatedRoot
54
+ ? "Unity generated/cache/output root filter bypassed for the explicit root; it is searched."
55
+ : "Unity broad-root generated-directory filter applied; excludes Library, Temp, Logs, obj, Build, Builds, UserSettings, .vs."]),
56
+ });
57
+ });
58
+ return Object.freeze({ outcome: "applied", roots: Object.freeze(roots) });
59
+ }
60
+
61
+ async function discoverUnityRoots(workspaceRoot: string, searchRoots: readonly string[], signal: AbortSignal): Promise<string[]> {
62
+ const workspace = path.resolve(workspaceRoot);
63
+ const found = new Set<string>();
64
+ for (const rawRoot of searchRoots) {
65
+ if (signal.aborted) return [];
66
+ const root = path.resolve(workspace, rawRoot);
67
+ for (let current = root; isInside(workspace, current); current = path.dirname(current)) {
68
+ if (await isUnityProject(current)) { found.add(current); break; }
69
+ if (current === workspace) break;
70
+ }
71
+ if (!isInside(workspace, root)) continue;
72
+ await discoverBelow(root, found, signal, 0);
73
+ }
74
+ return [...found].sort((left, right) => normalize(left).localeCompare(normalize(right)));
75
+ }
76
+ async function discoverBelow(root: string, found: Set<string>, signal: AbortSignal, depth: number): Promise<void> {
77
+ if (depth > 8 || signal.aborted || found.size >= 64) return;
78
+ if (await isUnityProject(root)) { found.add(root); return; }
79
+ let entries;
80
+ try { entries = await readdir(root, { withFileTypes: true }); } catch { return; }
81
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
82
+ if (signal.aborted || found.size >= 64) return;
83
+ if (!entry.isDirectory() || entry.isSymbolicLink() || UNITY_GENERATED_DIRECTORIES.some((name) => name.toLowerCase() === entry.name.toLowerCase()) || entry.name === ".git" || entry.name === "node_modules") continue;
84
+ await discoverBelow(path.join(root, entry.name), found, signal, depth + 1);
85
+ }
86
+ }
87
+ async function isUnityProject(root: string): Promise<boolean> { try { await access(path.join(root, "ProjectSettings", "ProjectVersion.txt")); await access(path.join(root, "Assets")); return true; } catch { return false; } }
88
+ function normalize(value: string): string { return value.replaceAll("\\", "/"); }
89
+ function isInside(parent: string, child: string): boolean { const relative = path.relative(path.resolve(parent), path.resolve(child)); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); }