unity-hub-cli 0.14.0 → 0.16.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.
Files changed (2) hide show
  1. package/dist/index.js +509 -100
  2. package/package.json +4 -3
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.tsx
4
- import process2 from "process";
4
+ import process3 from "process";
5
5
  import { render } from "ink";
6
6
 
7
7
  // src/application/usecases.ts
@@ -105,6 +105,108 @@ var TerminateProjectUseCase = class {
105
105
  };
106
106
  }
107
107
  };
108
+ var LaunchWithEditorUseCase = class {
109
+ constructor(launchProjectUseCase, externalEditorPathReader, externalEditorLauncher) {
110
+ this.launchProjectUseCase = launchProjectUseCase;
111
+ this.externalEditorPathReader = externalEditorPathReader;
112
+ this.externalEditorLauncher = externalEditorLauncher;
113
+ }
114
+ /**
115
+ * Launches the Unity project and attempts to open the external editor.
116
+ * @param project - The Unity project to launch.
117
+ * @returns The result of the launch operation.
118
+ */
119
+ async execute(project) {
120
+ const editorResult = await this.externalEditorPathReader.read();
121
+ let editorLaunched = false;
122
+ let editorMessage = "";
123
+ if (editorResult.status === "found") {
124
+ editorLaunched = await this.tryLaunchEditor(editorResult, project.path);
125
+ editorMessage = editorLaunched ? ` + ${editorResult.name}` : " (Editor launch failed)";
126
+ } else {
127
+ editorMessage = this.buildEditorStatusMessage(editorResult);
128
+ }
129
+ await this.launchProjectUseCase.execute(project);
130
+ return {
131
+ unityLaunched: true,
132
+ editorLaunched,
133
+ message: `Launched: ${project.title}${editorMessage}`
134
+ };
135
+ }
136
+ /**
137
+ * Attempts to launch the external editor.
138
+ * @param editorResult - The found external editor result.
139
+ * @param projectPath - The path to the project root.
140
+ * @returns Whether the editor was successfully launched.
141
+ */
142
+ async tryLaunchEditor(editorResult, projectPath) {
143
+ try {
144
+ await this.externalEditorLauncher.launch(editorResult.path, projectPath);
145
+ return true;
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
150
+ /**
151
+ * Builds a status message for non-found editor results.
152
+ * @param editorResult - The external editor result.
153
+ * @returns The status message.
154
+ */
155
+ buildEditorStatusMessage(editorResult) {
156
+ if (editorResult.status === "not_configured") {
157
+ return " (Editor not configured)";
158
+ }
159
+ if (editorResult.status === "not_found") {
160
+ return ` (Editor not found: ${editorResult.configuredPath})`;
161
+ }
162
+ return "";
163
+ }
164
+ };
165
+ var LaunchEditorOnlyUseCase = class {
166
+ constructor(externalEditorPathReader, externalEditorLauncher) {
167
+ this.externalEditorPathReader = externalEditorPathReader;
168
+ this.externalEditorLauncher = externalEditorLauncher;
169
+ }
170
+ /**
171
+ * Launches only the external editor for the specified project.
172
+ * @param project - The Unity project to open in the editor.
173
+ * @returns The result of the launch operation.
174
+ */
175
+ async execute(project) {
176
+ const editorResult = await this.externalEditorPathReader.read();
177
+ if (editorResult.status === "not_configured") {
178
+ return {
179
+ editorLaunched: false,
180
+ message: "Editor not configured in Unity preferences"
181
+ };
182
+ }
183
+ if (editorResult.status === "not_found") {
184
+ return {
185
+ editorLaunched: false,
186
+ message: `Editor not found: ${editorResult.configuredPath}`
187
+ };
188
+ }
189
+ const launched = await this.tryLaunchEditor(editorResult, project.path);
190
+ return {
191
+ editorLaunched: launched,
192
+ message: launched ? `Launched: ${editorResult.name}` : `Failed to launch ${editorResult.name}`
193
+ };
194
+ }
195
+ /**
196
+ * Attempts to launch the external editor.
197
+ * @param editorResult - The found external editor result.
198
+ * @param projectPath - The path to the project root.
199
+ * @returns Whether the editor was successfully launched.
200
+ */
201
+ async tryLaunchEditor(editorResult, projectPath) {
202
+ try {
203
+ await this.externalEditorLauncher.launch(editorResult.path, projectPath);
204
+ return true;
205
+ } catch {
206
+ return false;
207
+ }
208
+ }
209
+ };
108
210
 
109
211
  // src/infrastructure/editor.ts
110
212
  import { constants } from "fs";
@@ -158,9 +260,222 @@ var WinEditorPathResolver = class {
158
260
  }
159
261
  };
160
262
 
263
+ // src/infrastructure/externalEditor.ts
264
+ import { execFile } from "child_process";
265
+ import { constants as constants3, existsSync } from "fs";
266
+ import { access as access3 } from "fs/promises";
267
+ import { basename, join as join3 } from "path";
268
+ import { promisify } from "util";
269
+ var execFileAsync = promisify(execFile);
270
+ var PLIST_DOMAIN = "com.unity3d.UnityEditor5.x";
271
+ var PLIST_KEY = "kScriptsDefaultApp";
272
+ var MacExternalEditorPathReader = class {
273
+ /**
274
+ * Reads the external editor configuration from Unity preferences.
275
+ * @returns The external editor result with status and path information.
276
+ */
277
+ async read() {
278
+ let configuredPath;
279
+ try {
280
+ const result = await execFileAsync("defaults", ["read", PLIST_DOMAIN, PLIST_KEY]);
281
+ configuredPath = result.stdout.trim();
282
+ } catch {
283
+ return { status: "not_configured" };
284
+ }
285
+ if (!configuredPath) {
286
+ return { status: "not_configured" };
287
+ }
288
+ try {
289
+ await access3(configuredPath, constants3.F_OK);
290
+ } catch {
291
+ return { status: "not_found", configuredPath };
292
+ }
293
+ const name = basename(configuredPath, ".app");
294
+ return { status: "found", path: configuredPath, name };
295
+ }
296
+ };
297
+ var slnPreferringEditors = ["rider", "visual studio"];
298
+ var prefersSlnFile = (editorPath) => {
299
+ const editorName = basename(editorPath, ".app").toLowerCase();
300
+ return slnPreferringEditors.some((name) => editorName.includes(name));
301
+ };
302
+ var MacExternalEditorLauncher = class {
303
+ /**
304
+ * Launches the external editor with the specified project root.
305
+ * For Rider/Visual Studio: opens .sln file directly if it exists.
306
+ * For VS Code/Cursor and others: opens the project folder.
307
+ * @param editorPath - The path to the editor application.
308
+ * @param projectRoot - The project root directory to open.
309
+ */
310
+ async launch(editorPath, projectRoot) {
311
+ let targetPath = projectRoot;
312
+ if (prefersSlnFile(editorPath)) {
313
+ const projectName = basename(projectRoot);
314
+ const slnFilePath = join3(projectRoot, `${projectName}.sln`);
315
+ if (existsSync(slnFilePath)) {
316
+ targetPath = slnFilePath;
317
+ }
318
+ }
319
+ await execFileAsync("open", ["-a", editorPath, targetPath]);
320
+ }
321
+ };
322
+
323
+ // src/infrastructure/externalEditor.win.ts
324
+ import { execFile as execFile2, spawn } from "child_process";
325
+ import { constants as constants4, existsSync as existsSync2 } from "fs";
326
+ import { access as access4 } from "fs/promises";
327
+ import { basename as basename2, join as join4 } from "path";
328
+ import { promisify as promisify2 } from "util";
329
+
330
+ // src/presentation/utils/path.ts
331
+ var homeDirectory = process.env.HOME ?? process.env.USERPROFILE ?? "";
332
+ var normalizedHomeDirectory = homeDirectory.replace(/\\/g, "/");
333
+ var homePrefix = normalizedHomeDirectory ? `${normalizedHomeDirectory}/` : "";
334
+ var isGitBashEnvironment = () => {
335
+ if (process.platform !== "win32") {
336
+ return false;
337
+ }
338
+ return Boolean(process.env.MSYSTEM);
339
+ };
340
+ var getMsysDisabledEnv = () => {
341
+ if (!isGitBashEnvironment()) {
342
+ return process.env;
343
+ }
344
+ return {
345
+ ...process.env,
346
+ MSYS_NO_PATHCONV: "1",
347
+ MSYS2_ARG_CONV_EXCL: "*"
348
+ };
349
+ };
350
+ var shortenHomePath = (targetPath) => {
351
+ if (!normalizedHomeDirectory) {
352
+ return targetPath;
353
+ }
354
+ const normalizedTarget = targetPath.replace(/\\/g, "/");
355
+ if (normalizedTarget === normalizedHomeDirectory) {
356
+ return "~";
357
+ }
358
+ if (homePrefix && normalizedTarget.startsWith(homePrefix)) {
359
+ return `~/${normalizedTarget.slice(homePrefix.length)}`;
360
+ }
361
+ return targetPath;
362
+ };
363
+ var buildCdCommand = (targetPath) => {
364
+ if (process.platform === "win32") {
365
+ if (isGitBashEnvironment()) {
366
+ const msysPath = targetPath.replace(/^([A-Za-z]):[\\/]/, (_, drive) => `/${drive.toLowerCase()}/`).replace(/\\/g, "/");
367
+ const escapedForPosix2 = msysPath.replace(/'/g, "'\\''");
368
+ return `cd '${escapedForPosix2}'`;
369
+ }
370
+ const escapedForWindows = targetPath.replace(/"/g, '""');
371
+ return `cd "${escapedForWindows}"`;
372
+ }
373
+ const escapedForPosix = targetPath.replace(/'/g, "'\\''");
374
+ return `cd '${escapedForPosix}'`;
375
+ };
376
+
377
+ // src/infrastructure/externalEditor.win.ts
378
+ var execFileAsync2 = promisify2(execFile2);
379
+ var REGISTRY_PATH = "HKEY_CURRENT_USER\\Software\\Unity Technologies\\Unity Editor 5.x";
380
+ var decodeRegBinary = (hex) => {
381
+ const bytes = [];
382
+ for (let i = 0; i < hex.length; i += 2) {
383
+ const byte = parseInt(hex.slice(i, i + 2), 16);
384
+ if (byte !== 0) {
385
+ bytes.push(byte);
386
+ }
387
+ }
388
+ return Buffer.from(bytes).toString("utf8");
389
+ };
390
+ var parseRegistryOutput = (stdout) => {
391
+ const lines = stdout.split("\n");
392
+ for (const line of lines) {
393
+ const szMatch = line.match(/kScriptsDefaultApp[^\s]*\s+REG_SZ\s+(.+)/i);
394
+ if (szMatch?.[1]) {
395
+ return szMatch[1].trim();
396
+ }
397
+ const binaryMatch = line.match(/kScriptsDefaultApp[^\s]*\s+REG_BINARY\s+([0-9A-Fa-f]+)/i);
398
+ if (binaryMatch?.[1]) {
399
+ return decodeRegBinary(binaryMatch[1]);
400
+ }
401
+ }
402
+ return void 0;
403
+ };
404
+ var WinExternalEditorPathReader = class {
405
+ /**
406
+ * Reads the external editor configuration from Unity preferences.
407
+ * @returns The external editor result with status and path information.
408
+ */
409
+ async read() {
410
+ let configuredPath;
411
+ try {
412
+ const result = await execFileAsync2(
413
+ "reg",
414
+ ["query", REGISTRY_PATH],
415
+ { env: getMsysDisabledEnv() }
416
+ );
417
+ configuredPath = parseRegistryOutput(result.stdout);
418
+ } catch {
419
+ return { status: "not_configured" };
420
+ }
421
+ if (!configuredPath) {
422
+ return { status: "not_configured" };
423
+ }
424
+ try {
425
+ await access4(configuredPath, constants4.F_OK);
426
+ } catch {
427
+ return { status: "not_found", configuredPath };
428
+ }
429
+ const name = basename2(configuredPath, ".exe");
430
+ return { status: "found", path: configuredPath, name };
431
+ }
432
+ };
433
+ var slnPreferringEditors2 = ["rider", "devenv", "visualstudio"];
434
+ var prefersSlnFile2 = (editorPath) => {
435
+ const editorName = basename2(editorPath, ".exe").toLowerCase();
436
+ return slnPreferringEditors2.some((name) => editorName.includes(name));
437
+ };
438
+ var WinExternalEditorLauncher = class {
439
+ /**
440
+ * Launches the external editor with the specified project root.
441
+ * For Rider/Visual Studio: opens .sln file directly if it exists.
442
+ * For VS Code/Cursor and others: opens the project folder.
443
+ * @param editorPath - The path to the editor executable.
444
+ * @param projectRoot - The project root directory to open.
445
+ */
446
+ async launch(editorPath, projectRoot) {
447
+ let targetPath = projectRoot;
448
+ if (prefersSlnFile2(editorPath)) {
449
+ const projectName = basename2(projectRoot);
450
+ const slnFilePath = join4(projectRoot, `${projectName}.sln`);
451
+ if (existsSync2(slnFilePath)) {
452
+ targetPath = slnFilePath;
453
+ }
454
+ }
455
+ await new Promise((resolve4, reject) => {
456
+ const child = spawn(editorPath, [targetPath], {
457
+ detached: true,
458
+ stdio: "ignore",
459
+ env: getMsysDisabledEnv()
460
+ });
461
+ const handleError = (error) => {
462
+ child.off("spawn", handleSpawn);
463
+ reject(error);
464
+ };
465
+ const handleSpawn = () => {
466
+ child.off("error", handleError);
467
+ child.unref();
468
+ resolve4();
469
+ };
470
+ child.once("error", handleError);
471
+ child.once("spawn", handleSpawn);
472
+ });
473
+ }
474
+ };
475
+
161
476
  // src/infrastructure/git.ts
162
477
  import { readFile, stat } from "fs/promises";
163
- import { dirname, join as join3, resolve } from "path";
478
+ import { dirname, join as join5, resolve } from "path";
164
479
  var HEAD_FILE = "HEAD";
165
480
  var GIT_DIR = ".git";
166
481
  var MAX_ASCENT = 50;
@@ -183,7 +498,7 @@ var isFile = async (path) => {
183
498
  var findGitDir = async (start) => {
184
499
  let current = resolve(start);
185
500
  for (let depth = 0; depth < MAX_ASCENT; depth += 1) {
186
- const candidate = join3(current, GIT_DIR);
501
+ const candidate = join5(current, GIT_DIR);
187
502
  if (await isDirectory(candidate)) {
188
503
  return candidate;
189
504
  }
@@ -227,7 +542,7 @@ var GitRepositoryInfoReader = class {
227
542
  return void 0;
228
543
  }
229
544
  try {
230
- const headPath = join3(gitDir, HEAD_FILE);
545
+ const headPath = join5(gitDir, HEAD_FILE);
231
546
  const content = await readFile(headPath, "utf8");
232
547
  const branch = parseHead(content);
233
548
  const root = dirname(gitDir);
@@ -239,14 +554,15 @@ var GitRepositoryInfoReader = class {
239
554
  };
240
555
 
241
556
  // src/infrastructure/process.ts
242
- import { spawn } from "child_process";
557
+ import { spawn as spawn2 } from "child_process";
243
558
  var NodeProcessLauncher = class {
244
559
  async launch(command, args, options) {
245
560
  const detached = options?.detached ?? false;
246
561
  await new Promise((resolve4, reject) => {
247
- const child = spawn(command, args, {
562
+ const child = spawn2(command, args, {
248
563
  detached,
249
- stdio: "ignore"
564
+ stdio: "ignore",
565
+ env: getMsysDisabledEnv()
250
566
  });
251
567
  const handleError = (error) => {
252
568
  child.off("spawn", handleSpawn);
@@ -360,7 +676,7 @@ var detectTerminalTheme = async (timeoutMs = 100) => {
360
676
 
361
677
  // src/infrastructure/unityhub.ts
362
678
  import { readFile as readFile2, writeFile } from "fs/promises";
363
- import { basename } from "path";
679
+ import { basename as basename3 } from "path";
364
680
  var HUB_PROJECTS_PATH = `${process.env.HOME ?? ""}/Library/Application Support/UnityHub/projects-v1.json`;
365
681
  var schemaVersion = "v1";
366
682
  var toUnityProject = (entry) => {
@@ -375,7 +691,7 @@ var toUnityProject = (entry) => {
375
691
  const lastModified = typeof entry.lastModified === "number" ? new Date(entry.lastModified) : void 0;
376
692
  return {
377
693
  id: safePath,
378
- title: entry.title?.trim() || basename(safePath),
694
+ title: entry.title?.trim() || basename3(safePath),
379
695
  path: safePath,
380
696
  version: { value: version },
381
697
  lastModified,
@@ -490,9 +806,9 @@ var MacUnityHubProjectsReader = class {
490
806
 
491
807
  // src/infrastructure/unityhub.win.ts
492
808
  import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
493
- import { basename as basename2, join as join4 } from "path";
494
- var HUB_DIR = join4(process.env.APPDATA ?? "", "UnityHub");
495
- var HUB_PROJECTS_PATH2 = join4(HUB_DIR, "projects-v1.json");
809
+ import { basename as basename4, join as join6 } from "path";
810
+ var HUB_DIR = join6(process.env.APPDATA ?? "", "UnityHub");
811
+ var HUB_PROJECTS_PATH2 = join6(HUB_DIR, "projects-v1.json");
496
812
  var schemaVersion2 = "v1";
497
813
  var toUnityProject2 = (entry) => {
498
814
  const safePath = entry.path;
@@ -506,7 +822,7 @@ var toUnityProject2 = (entry) => {
506
822
  const lastModified = typeof entry.lastModified === "number" ? new Date(entry.lastModified) : void 0;
507
823
  return {
508
824
  id: safePath,
509
- title: entry.title?.trim() || basename2(safePath),
825
+ title: entry.title?.trim() || basename4(safePath),
510
826
  path: safePath,
511
827
  version: { value: version },
512
828
  lastModified,
@@ -594,7 +910,7 @@ var WinUnityHubProjectsReader = class {
594
910
  await writeFile2(HUB_PROJECTS_PATH2, JSON.stringify(json, void 0, 2), "utf8");
595
911
  }
596
912
  async readCliArgs(projectPath) {
597
- const infoPath = join4(HUB_DIR, "projectsInfo.json");
913
+ const infoPath = join6(HUB_DIR, "projectsInfo.json");
598
914
  let content;
599
915
  try {
600
916
  content = await readFile3(infoPath, "utf8");
@@ -620,18 +936,18 @@ var WinUnityHubProjectsReader = class {
620
936
  };
621
937
 
622
938
  // src/infrastructure/unityLock.ts
623
- import { execFile } from "child_process";
624
- import { constants as constants3 } from "fs";
625
- import { access as access3, rm } from "fs/promises";
626
- import { join as join5 } from "path";
627
- import { promisify } from "util";
628
- var execFileAsync = promisify(execFile);
939
+ import { execFile as execFile3 } from "child_process";
940
+ import { constants as constants5 } from "fs";
941
+ import { access as access5, rm } from "fs/promises";
942
+ import { join as join7 } from "path";
943
+ import { promisify as promisify3 } from "util";
944
+ var execFileAsync3 = promisify3(execFile3);
629
945
  var buildBringToFrontScript = (pid) => {
630
946
  return `tell application "System Events" to set frontmost of (first process whose unix id is ${pid}) to true`;
631
947
  };
632
948
  var pathExists = async (target) => {
633
949
  try {
634
- await access3(target, constants3.F_OK);
950
+ await access5(target, constants5.F_OK);
635
951
  return true;
636
952
  } catch {
637
953
  return false;
@@ -651,7 +967,7 @@ var UnityLockChecker = class {
651
967
  await this.bringUnityToFront(activeProcess.pid);
652
968
  return "skip";
653
969
  }
654
- const lockfilePath = join5(projectPath, "Temp", "UnityLockfile");
970
+ const lockfilePath = join7(projectPath, "Temp", "UnityLockfile");
655
971
  const hasLockfile = await pathExists(lockfilePath);
656
972
  if (!hasLockfile) {
657
973
  return "allow";
@@ -680,7 +996,7 @@ var UnityLockChecker = class {
680
996
  }
681
997
  try {
682
998
  const script = buildBringToFrontScript(pid);
683
- await execFileAsync("osascript", ["-e", script]);
999
+ await execFileAsync3("osascript", ["-e", script]);
684
1000
  } catch (error) {
685
1001
  const message = error instanceof Error ? error.message : String(error);
686
1002
  console.error(`Failed to bring Unity to front: ${message}`);
@@ -689,16 +1005,16 @@ var UnityLockChecker = class {
689
1005
  };
690
1006
  var UnityLockStatusReader = class {
691
1007
  async isLocked(projectPath) {
692
- const lockfilePath = join5(projectPath, "Temp", "UnityLockfile");
1008
+ const lockfilePath = join7(projectPath, "Temp", "UnityLockfile");
693
1009
  return await pathExists(lockfilePath);
694
1010
  }
695
1011
  };
696
1012
 
697
1013
  // src/infrastructure/unityProcess.ts
698
- import { execFile as execFile2 } from "child_process";
1014
+ import { execFile as execFile4 } from "child_process";
699
1015
  import { resolve as resolve2 } from "path";
700
- import { promisify as promisify2 } from "util";
701
- var execFileAsync2 = promisify2(execFile2);
1016
+ import { promisify as promisify4 } from "util";
1017
+ var execFileAsync4 = promisify4(execFile4);
702
1018
  var UNITY_EXECUTABLE_PATTERN = /Unity\.app\/Contents\/MacOS\/Unity/i;
703
1019
  var PROJECT_PATH_PATTERN = /-(?:projectPath|projectpath)(?:=|\s+)("[^"]+"|'[^']+'|[^\s"']+)/i;
704
1020
  var PROCESS_LIST_ARGS = ["-axo", "pid=,command=", "-ww"];
@@ -781,7 +1097,7 @@ var MacUnityProcessReader = class {
781
1097
  async listUnityProcesses() {
782
1098
  let stdout;
783
1099
  try {
784
- const result = await execFileAsync2(PROCESS_LIST_COMMAND, PROCESS_LIST_ARGS);
1100
+ const result = await execFileAsync4(PROCESS_LIST_COMMAND, PROCESS_LIST_ARGS);
785
1101
  stdout = result.stdout;
786
1102
  } catch (error) {
787
1103
  throw new Error(`Failed to retrieve Unity process list: ${error instanceof Error ? error.message : String(error)}`);
@@ -811,7 +1127,7 @@ var MacUnityProcessReader = class {
811
1127
  pid: pidValue,
812
1128
  projectPath: normalizePath(projectArgument)
813
1129
  };
814
- }).filter((process3) => Boolean(process3));
1130
+ }).filter((process4) => Boolean(process4));
815
1131
  }
816
1132
  };
817
1133
  var MacUnityProcessTerminator = class {
@@ -826,7 +1142,7 @@ var MacUnityProcessTerminator = class {
826
1142
  ' keystroke "q" using {command down}',
827
1143
  "end tell"
828
1144
  ].join("\n");
829
- await execFileAsync2("osascript", ["-e", script]);
1145
+ await execFileAsync4("osascript", ["-e", script]);
830
1146
  const deadlineGraceful = Date.now() + GRACEFUL_QUIT_TIMEOUT_MILLIS;
831
1147
  while (Date.now() < deadlineGraceful) {
832
1148
  await delay(GRACEFUL_QUIT_POLL_INTERVAL_MILLIS);
@@ -873,10 +1189,10 @@ var MacUnityProcessTerminator = class {
873
1189
  };
874
1190
 
875
1191
  // src/infrastructure/unityProcess.win.ts
876
- import { execFile as execFile3 } from "child_process";
1192
+ import { execFile as execFile5 } from "child_process";
877
1193
  import { resolve as resolve3 } from "path";
878
- import { promisify as promisify3 } from "util";
879
- var execFileAsync3 = promisify3(execFile3);
1194
+ import { promisify as promisify5 } from "util";
1195
+ var execFileAsync5 = promisify5(execFile5);
880
1196
  var PROJECT_PATH_PATTERN2 = /-(?:projectPath|projectpath)(?:=|\s+)("[^"]+"|'[^']+'|[^\s"']+)/i;
881
1197
  var TERMINATE_TIMEOUT_MILLIS2 = 5e3;
882
1198
  var TERMINATE_POLL_INTERVAL_MILLIS2 = 200;
@@ -967,7 +1283,7 @@ var WinUnityProcessReader = class {
967
1283
  ].join(" ");
968
1284
  let stdout;
969
1285
  try {
970
- const result = await execFileAsync3(
1286
+ const result = await execFileAsync5(
971
1287
  "powershell.exe",
972
1288
  [
973
1289
  "-NoProfile",
@@ -977,7 +1293,7 @@ var WinUnityProcessReader = class {
977
1293
  "-Command",
978
1294
  psCommand
979
1295
  ],
980
- { encoding: "utf8" }
1296
+ { encoding: "utf8", env: getMsysDisabledEnv() }
981
1297
  );
982
1298
  stdout = (result.stdout ?? "").trim();
983
1299
  } catch (error) {
@@ -1004,14 +1320,18 @@ var WinUnityProcessReader = class {
1004
1320
  var WinUnityProcessTerminator = class {
1005
1321
  async terminate(unityProcess) {
1006
1322
  try {
1007
- await execFileAsync3("powershell.exe", [
1008
- "-NoProfile",
1009
- "-NonInteractive",
1010
- "-ExecutionPolicy",
1011
- "Bypass",
1012
- "-Command",
1013
- `Stop-Process -Id ${unityProcess.pid}`
1014
- ]);
1323
+ await execFileAsync5(
1324
+ "powershell.exe",
1325
+ [
1326
+ "-NoProfile",
1327
+ "-NonInteractive",
1328
+ "-ExecutionPolicy",
1329
+ "Bypass",
1330
+ "-Command",
1331
+ `Stop-Process -Id ${unityProcess.pid}`
1332
+ ],
1333
+ { env: getMsysDisabledEnv() }
1334
+ );
1015
1335
  } catch (error) {
1016
1336
  if (!ensureProcessAlive2(unityProcess.pid)) {
1017
1337
  return { terminated: true, stage: "sigterm" };
@@ -1026,14 +1346,18 @@ var WinUnityProcessTerminator = class {
1026
1346
  }
1027
1347
  }
1028
1348
  try {
1029
- await execFileAsync3("powershell.exe", [
1030
- "-NoProfile",
1031
- "-NonInteractive",
1032
- "-ExecutionPolicy",
1033
- "Bypass",
1034
- "-Command",
1035
- `Stop-Process -Id ${unityProcess.pid} -Force`
1036
- ]);
1349
+ await execFileAsync5(
1350
+ "powershell.exe",
1351
+ [
1352
+ "-NoProfile",
1353
+ "-NonInteractive",
1354
+ "-ExecutionPolicy",
1355
+ "Bypass",
1356
+ "-Command",
1357
+ `Stop-Process -Id ${unityProcess.pid} -Force`
1358
+ ],
1359
+ { env: getMsysDisabledEnv() }
1360
+ );
1037
1361
  } catch (error) {
1038
1362
  if (!ensureProcessAlive2(unityProcess.pid)) {
1039
1363
  return { terminated: true, stage: "sigkill" };
@@ -1048,11 +1372,11 @@ var WinUnityProcessTerminator = class {
1048
1372
 
1049
1373
  // src/infrastructure/unityTemp.ts
1050
1374
  import { rm as rm2 } from "fs/promises";
1051
- import { join as join6 } from "path";
1375
+ import { join as join8 } from "path";
1052
1376
  var TEMP_DIRECTORY_NAME = "Temp";
1053
1377
  var UnityTempDirectoryCleaner = class {
1054
1378
  async clean(projectPath) {
1055
- const tempDirectoryPath = join6(projectPath, TEMP_DIRECTORY_NAME);
1379
+ const tempDirectoryPath = join8(projectPath, TEMP_DIRECTORY_NAME);
1056
1380
  try {
1057
1381
  await rm2(tempDirectoryPath, {
1058
1382
  recursive: true,
@@ -1064,7 +1388,8 @@ var UnityTempDirectoryCleaner = class {
1064
1388
  };
1065
1389
 
1066
1390
  // src/presentation/App.tsx
1067
- import { basename as basename4 } from "path";
1391
+ import { basename as basename6 } from "path";
1392
+ import process2 from "process";
1068
1393
  import clipboard from "clipboardy";
1069
1394
  import { Box as Box6, Text as Text4, useApp, useInput, useStdout as useStdout2 } from "ink";
1070
1395
  import { useCallback, useEffect as useEffect4, useMemo as useMemo2, useState as useState4 } from "react";
@@ -1108,7 +1433,7 @@ var LayoutManager = ({
1108
1433
  };
1109
1434
 
1110
1435
  // src/presentation/components/ProjectList.tsx
1111
- import { basename as basename3 } from "path";
1436
+ import { basename as basename5 } from "path";
1112
1437
  import { Box as Box3 } from "ink";
1113
1438
  import { useMemo } from "react";
1114
1439
 
@@ -1162,39 +1487,6 @@ var ThemeProvider = ({ theme, children }) => {
1162
1487
  return createElement(ThemeContext.Provider, { value }, children);
1163
1488
  };
1164
1489
 
1165
- // src/presentation/utils/path.ts
1166
- var homeDirectory = process.env.HOME ?? process.env.USERPROFILE ?? "";
1167
- var normalizedHomeDirectory = homeDirectory.replace(/\\/g, "/");
1168
- var homePrefix = normalizedHomeDirectory ? `${normalizedHomeDirectory}/` : "";
1169
- var shortenHomePath = (targetPath) => {
1170
- if (!normalizedHomeDirectory) {
1171
- return targetPath;
1172
- }
1173
- const normalizedTarget = targetPath.replace(/\\/g, "/");
1174
- if (normalizedTarget === normalizedHomeDirectory) {
1175
- return "~";
1176
- }
1177
- if (homePrefix && normalizedTarget.startsWith(homePrefix)) {
1178
- return `~/${normalizedTarget.slice(homePrefix.length)}`;
1179
- }
1180
- return targetPath;
1181
- };
1182
- var buildCdCommand = (targetPath) => {
1183
- if (process.platform === "win32") {
1184
- const isGitBash = Boolean(process.env.MSYSTEM) || /bash/i.test(process.env.SHELL ?? "");
1185
- if (isGitBash) {
1186
- const windowsPath = targetPath;
1187
- const msysPath = windowsPath.replace(/^([A-Za-z]):[\\/]/, (_, drive) => `/${drive.toLowerCase()}/`).replace(/\\/g, "/");
1188
- const escapedForPosix2 = msysPath.replace(/'/g, "'\\''");
1189
- return `cd '${escapedForPosix2}'`;
1190
- }
1191
- const escapedForWindows = targetPath.replace(/"/g, '""');
1192
- return `cd "${escapedForWindows}"`;
1193
- }
1194
- const escapedForPosix = targetPath.replace(/'/g, "'\\''");
1195
- return `cd '${escapedForPosix}'`;
1196
- };
1197
-
1198
1490
  // src/presentation/components/ProjectRow.tsx
1199
1491
  import { Box as Box2, Text, useStdout } from "ink";
1200
1492
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
@@ -1258,7 +1550,7 @@ var extractRootFolder = (repository) => {
1258
1550
  if (!repository?.root) {
1259
1551
  return void 0;
1260
1552
  }
1261
- const base = basename3(repository.root);
1553
+ const base = basename5(repository.root);
1262
1554
  return base || void 0;
1263
1555
  };
1264
1556
  var formatProjectName = (projectTitle, repository, useGitRootName) => {
@@ -1691,11 +1983,11 @@ var extractRootFolder2 = (repository) => {
1691
1983
  if (!repository?.root) {
1692
1984
  return void 0;
1693
1985
  }
1694
- const base = basename4(repository.root);
1986
+ const base = basename6(repository.root);
1695
1987
  return base || void 0;
1696
1988
  };
1697
1989
  var minimumVisibleProjectCount = 4;
1698
- var defaultHintMessage = "j/k Select \xB7 [o]pen [q]uit [r]efresh [c]opy [s]ort [v]isibility \xB7 ^C Exit";
1990
+ var defaultHintMessage = `j/k Select \xB7 [o]pen [O]+Editor [i]de [q]uit [r]efresh [c]opy [s]ort [v]isibility \xB7 ^C Exit`;
1699
1991
  var getCopyTargetPath = (view) => {
1700
1992
  const root = view.repository?.root;
1701
1993
  return root && root.length > 0 ? root : view.project.path;
@@ -1703,6 +1995,8 @@ var getCopyTargetPath = (view) => {
1703
1995
  var App = ({
1704
1996
  projects,
1705
1997
  onLaunch,
1998
+ onLaunchWithEditor,
1999
+ onLaunchEditorOnly,
1706
2000
  onTerminate,
1707
2001
  onRefresh,
1708
2002
  useGitRootName = true
@@ -1799,9 +2093,9 @@ var App = ({
1799
2093
  const handleSigint = () => {
1800
2094
  exit();
1801
2095
  };
1802
- process.on("SIGINT", handleSigint);
2096
+ process2.on("SIGINT", handleSigint);
1803
2097
  return () => {
1804
- process.off("SIGINT", handleSigint);
2098
+ process2.off("SIGINT", handleSigint);
1805
2099
  };
1806
2100
  }, [exit]);
1807
2101
  const limit = Math.max(1, visibleCount);
@@ -1948,6 +2242,100 @@ var App = ({
1948
2242
  }, 3e3);
1949
2243
  }
1950
2244
  }, [index, onLaunch, sortedProjects]);
2245
+ const launchSelectedWithEditor = useCallback(async () => {
2246
+ if (!onLaunchWithEditor) {
2247
+ setHint("Launch with editor not available");
2248
+ setTimeout(() => {
2249
+ setHint(defaultHintMessage);
2250
+ }, 2e3);
2251
+ return;
2252
+ }
2253
+ const projectView = sortedProjects[index];
2254
+ if (!projectView) {
2255
+ setHint("No project to launch");
2256
+ setTimeout(() => {
2257
+ setHint(defaultHintMessage);
2258
+ }, 2e3);
2259
+ return;
2260
+ }
2261
+ const { project } = projectView;
2262
+ try {
2263
+ const cdTarget = getCopyTargetPath(projectView);
2264
+ const command = buildCdCommand(cdTarget);
2265
+ clipboard.writeSync(command);
2266
+ } catch (error) {
2267
+ const message = error instanceof Error ? error.message : String(error);
2268
+ setHint(`Failed to copy: ${message}`);
2269
+ setTimeout(() => {
2270
+ setHint(defaultHintMessage);
2271
+ }, 3e3);
2272
+ return;
2273
+ }
2274
+ try {
2275
+ const result = await onLaunchWithEditor(project);
2276
+ setLaunchedProjects((previous) => {
2277
+ const next = new Set(previous);
2278
+ next.add(project.id);
2279
+ return next;
2280
+ });
2281
+ setReleasedProjects((previous) => {
2282
+ if (!previous.has(project.id)) {
2283
+ return previous;
2284
+ }
2285
+ const next = new Set(previous);
2286
+ next.delete(project.id);
2287
+ return next;
2288
+ });
2289
+ setHint(result.message);
2290
+ setTimeout(() => {
2291
+ setHint(defaultHintMessage);
2292
+ }, 3e3);
2293
+ } catch (error) {
2294
+ if (error instanceof LaunchCancelledError) {
2295
+ setHint("Launch cancelled");
2296
+ setTimeout(() => {
2297
+ setHint(defaultHintMessage);
2298
+ }, 3e3);
2299
+ return;
2300
+ }
2301
+ const message = error instanceof Error ? error.message : String(error);
2302
+ setHint(`Failed to launch: ${message}`);
2303
+ setTimeout(() => {
2304
+ setHint(defaultHintMessage);
2305
+ }, 3e3);
2306
+ }
2307
+ }, [index, onLaunchWithEditor, sortedProjects]);
2308
+ const launchEditorOnly = useCallback(async () => {
2309
+ if (!onLaunchEditorOnly) {
2310
+ setHint("Launch editor only not available");
2311
+ setTimeout(() => {
2312
+ setHint(defaultHintMessage);
2313
+ }, 2e3);
2314
+ return;
2315
+ }
2316
+ const projectView = sortedProjects[index];
2317
+ if (!projectView) {
2318
+ setHint("No project to open");
2319
+ setTimeout(() => {
2320
+ setHint(defaultHintMessage);
2321
+ }, 2e3);
2322
+ return;
2323
+ }
2324
+ const { project } = projectView;
2325
+ try {
2326
+ const result = await onLaunchEditorOnly(project);
2327
+ setHint(result.message);
2328
+ setTimeout(() => {
2329
+ setHint(defaultHintMessage);
2330
+ }, 3e3);
2331
+ } catch (error) {
2332
+ const message = error instanceof Error ? error.message : String(error);
2333
+ setHint(`Failed to launch editor: ${message}`);
2334
+ setTimeout(() => {
2335
+ setHint(defaultHintMessage);
2336
+ }, 3e3);
2337
+ }
2338
+ }, [index, onLaunchEditorOnly, sortedProjects]);
1951
2339
  const terminateSelected = useCallback(async () => {
1952
2340
  const projectView = sortedProjects[index];
1953
2341
  if (!projectView) {
@@ -2151,10 +2539,18 @@ var App = ({
2151
2539
  void terminateSelected();
2152
2540
  return;
2153
2541
  }
2542
+ if (input === "i") {
2543
+ void launchEditorOnly();
2544
+ return;
2545
+ }
2154
2546
  if (input === "o") {
2155
2547
  void launchSelected();
2156
2548
  return;
2157
2549
  }
2550
+ if (input === "O") {
2551
+ void launchSelectedWithEditor();
2552
+ return;
2553
+ }
2158
2554
  if (input === "r") {
2159
2555
  void refreshProjects();
2160
2556
  return;
@@ -2239,7 +2635,7 @@ var App = ({
2239
2635
  // src/index.tsx
2240
2636
  import { jsx as jsx7 } from "react/jsx-runtime";
2241
2637
  var bootstrap = async () => {
2242
- const isWindows = process2.platform === "win32";
2638
+ const isWindows = process3.platform === "win32";
2243
2639
  const unityHubReader = isWindows ? new WinUnityHubProjectsReader() : new MacUnityHubProjectsReader();
2244
2640
  const gitRepositoryInfoReader = new GitRepositoryInfoReader();
2245
2641
  const lockStatusReader = new UnityLockStatusReader();
@@ -2268,10 +2664,21 @@ var bootstrap = async () => {
2268
2664
  unityProcessTerminator,
2269
2665
  unityTempDirectoryCleaner
2270
2666
  );
2271
- const useGitRootName = !process2.argv.includes("--no-git-root-name");
2667
+ const externalEditorPathReader = isWindows ? new WinExternalEditorPathReader() : new MacExternalEditorPathReader();
2668
+ const externalEditorLauncher = isWindows ? new WinExternalEditorLauncher() : new MacExternalEditorLauncher();
2669
+ const launchWithEditorUseCase = new LaunchWithEditorUseCase(
2670
+ launchProjectUseCase,
2671
+ externalEditorPathReader,
2672
+ externalEditorLauncher
2673
+ );
2674
+ const launchEditorOnlyUseCase = new LaunchEditorOnlyUseCase(
2675
+ externalEditorPathReader,
2676
+ externalEditorLauncher
2677
+ );
2678
+ const useGitRootName = !process3.argv.includes("--no-git-root-name");
2272
2679
  try {
2273
2680
  const rawModeSupported = Boolean(
2274
- process2.stdin.isTTY && typeof process2.stdin.setRawMode === "function"
2681
+ process3.stdin.isTTY && typeof process3.stdin.setRawMode === "function"
2275
2682
  );
2276
2683
  if (!rawModeSupported) {
2277
2684
  const message = [
@@ -2284,7 +2691,7 @@ var bootstrap = async () => {
2284
2691
  "Details: https://github.com/vadimdemedes/ink/#israwmodesupported"
2285
2692
  ].join("\n");
2286
2693
  console.error(message);
2287
- process2.exitCode = 1;
2694
+ process3.exitCode = 1;
2288
2695
  return;
2289
2696
  }
2290
2697
  const theme = await detectTerminalTheme();
@@ -2295,6 +2702,8 @@ var bootstrap = async () => {
2295
2702
  {
2296
2703
  projects,
2297
2704
  onLaunch: (project) => launchProjectUseCase.execute(project),
2705
+ onLaunchWithEditor: (project) => launchWithEditorUseCase.execute(project),
2706
+ onLaunchEditorOnly: (project) => launchEditorOnlyUseCase.execute(project),
2298
2707
  onTerminate: (project) => terminateProjectUseCase.execute(project),
2299
2708
  onRefresh: () => listProjectsUseCase.execute(),
2300
2709
  useGitRootName
@@ -2302,15 +2711,15 @@ var bootstrap = async () => {
2302
2711
  ) })
2303
2712
  );
2304
2713
  await waitUntilExit();
2305
- process2.stdout.write("\x1B[2J\x1B[3J\x1B[H");
2714
+ process3.stdout.write("\x1B[2J\x1B[3J\x1B[H");
2306
2715
  } catch (error) {
2307
2716
  const message = error instanceof Error ? error.message : String(error);
2308
2717
  console.error(message);
2309
- process2.exitCode = 1;
2718
+ process3.exitCode = 1;
2310
2719
  }
2311
2720
  };
2312
2721
  void bootstrap().catch((error) => {
2313
2722
  const message = error instanceof Error ? error.message : String(error);
2314
2723
  console.error(message);
2315
- process2.exitCode = 1;
2724
+ process3.exitCode = 1;
2316
2725
  });
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "unity-hub-cli",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "A CLI tool that reads Unity Hub's projects and launches Unity Editor with an interactive TUI",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1",
7
+ "test": "vitest run",
8
8
  "dev": "tsx src/index.ts",
9
9
  "build": "tsup",
10
10
  "start": "node dist/index.js",
@@ -55,6 +55,7 @@
55
55
  "prettier": "^3.6.2",
56
56
  "tsup": "^8.5.0",
57
57
  "tsx": "^4.20.6",
58
- "typescript": "^5.9.3"
58
+ "typescript": "^5.9.3",
59
+ "vitest": "^4.0.14"
59
60
  }
60
61
  }