unity-hub-cli 0.15.0 → 0.17.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 (3) hide show
  1. package/README.md +33 -68
  2. package/dist/index.js +132 -74
  3. package/package.json +18 -17
package/README.md CHANGED
@@ -4,104 +4,69 @@
4
4
 
5
5
  A CLI tool that displays the same content as Unity Hub in an Ink-based TUI, allows navigation with arrow keys/`j`/`k`, and launches Unity Editor by pressing `o`.
6
6
 
7
- <img width="1678" height="1460" alt="スクリーンショット 2025-10-27 23 44 40" src="https://github.com/user-attachments/assets/db3cc995-820e-490b-a43b-393893197ab4" />
7
+ <img width="1678" height="1460" alt="Screenshot 2025-10-27 23 44 40" src="https://github.com/user-attachments/assets/db3cc995-820e-490b-a43b-393893197ab4" />
8
8
 
9
9
  ## Requirements
10
10
 
11
11
  - macOS or Windows 10/11
12
12
  - Node.js 20+
13
- - Unity Hub
14
- - macOS: `~/Library/Application Support/UnityHub/projects-v1.json`
15
- - Windows: `%APPDATA%\UnityHub\projects-v1.json`
16
- - Windows Editor path (default): `C:\\Program Files\\Unity\\Hub\\Editor\\<version>\\Editor\\Unity.exe`
17
13
 
18
- ## Usage
19
-
20
- ### Development
14
+ ## Installation & Run
21
15
 
22
16
  ```bash
23
- npm install
24
- npm run dev
17
+ npx unity-hub-cli
25
18
  ```
26
19
 
27
- ### Build
20
+ Or install globally to use the `unity-hub-cli` command directly:
28
21
 
29
22
  ```bash
30
- npm run build
23
+ npm install -g unity-hub-cli
24
+ unity-hub-cli
31
25
  ```
32
26
 
33
- ### Run
34
-
35
- After building, `dist/index.js` will be generated. You can also run it directly via npx.
27
+ <details>
28
+ <summary>Notes for Windows</summary>
36
29
 
37
- ```bash
38
- npx unity-hub-cli
39
- # or
40
- node dist/index.js
41
- ```
30
+ Works from PowerShell and CMD. Git Bash is supported when running inside a ConPTY-based terminal (Windows Terminal or VS Code/Cursor integrated terminal).
42
31
 
43
- On Windows, it works from PowerShell and CMD. Git Bash is supported when running inside a ConPTY-based terminal (Windows Terminal or VS Code/Cursor integrated terminal). On standalone Git Bash (MinTTY), raw mode is not supported; use PowerShell/CMD/Windows Terminal. If you must use MinTTY Git Bash, run one of the following:
32
+ On standalone Git Bash (MinTTY), raw mode is not supported; use PowerShell/CMD/Windows Terminal. If you must use MinTTY Git Bash, run one of the following:
44
33
 
45
34
  - `winpty cmd.exe /c npx unity-hub-cli`
46
35
  - `winpty powershell.exe -NoProfile -Command npx unity-hub-cli`
47
- - If already built: `npm run build && winpty node dist/index.js`
48
36
 
49
- See `https://github.com/vadimdemedes/ink/#israwmodesupported`.
37
+ </details>
50
38
 
51
- By default, the project list uses the Git repository root folder name when available.
52
-
53
- ### CLI Options
54
-
55
- - `--no-git-root-name`: Display Unity project titles instead of Git repository root folder names.
56
- - `--hide-branch`: Hide the Git branch column.
57
- - `--hide-path`: Hide the project path column.
58
-
59
- ## Release Automation
60
-
61
- Version and release management is automated using release-please and GitHub Actions.
39
+ ## Controls
62
40
 
63
- - `.github/workflows/release-please.yml` runs on push to `main` or manual trigger
64
- - The action references `release-please-config.json` and `.release-please-manifest.json` to create release PRs and tags
65
- - When a PR is merged, GitHub Releases and changelog are automatically updated
66
- - **npm publish is automated with provenance** for supply chain security
41
+ | Key | Action |
42
+ |-----|--------|
43
+ | `↑` / `↓` / `j` / `k` | Navigate selection |
44
+ | `o` | Launch selected project in Unity |
45
+ | `O` (Shift+O) | Launch Unity + external editor (e.g., Rider) |
46
+ | `i` | Launch external editor only |
47
+ | `q` | Quit Unity for selected project |
48
+ | `r` | Refresh project list |
49
+ | `c` | Copy project path to clipboard |
50
+ | `s` | Open sort settings panel |
51
+ | `v` | Open visibility settings panel |
52
+ | `Ctrl + C` | Exit |
67
53
 
68
- ### Initial Setup Notes
54
+ In settings panels, use `j`/`k` to navigate, `Space` to toggle, and `Esc` to close.
69
55
 
70
- - If existing releases are present, set the latest release commit in `bootstrap-sha` of `release-please-config.json`
71
- - The workflow uses GitHub's `GITHUB_TOKEN` and operates with `contents`/`pull-requests` permissions
56
+ The display includes Git branch (if present), Unity version, project path, and last modified time. By default, the project list uses the Git repository root folder name when available.
72
57
 
73
- ### Manual Execution
58
+ ## CLI Options
74
59
 
75
- You can manually trigger the `release-please` workflow from the Actions tab by selecting `Run workflow`
60
+ - `--no-git-root-name`: Display Unity project titles instead of Git repository root folder names.
76
61
 
77
62
  ## Security
78
63
 
79
- This package implements multiple security measures to protect against supply chain attacks:
80
-
81
- 1. **Automated Publishing with Provenance**: All npm releases are published via GitHub Actions with `--provenance` flag, providing cryptographic proof of the build environment
82
- 2. **Minimal Dependencies**: Only 2 runtime dependencies (`ink` and `react`), both from highly trusted sources
83
- 3. **Locked Dependencies**: `package-lock.json` is committed to ensure reproducible builds
84
- 4. **Regular Security Audits**: Dependencies are regularly checked with `npm audit`
85
-
86
- ### Verifying Package Authenticity
87
-
88
- You can verify the authenticity of published packages:
89
-
90
- ```bash
91
- # Check provenance information
92
- npm view unity-hub-cli --json | jq .dist.attestations
93
-
94
- # Verify package integrity
95
- npm audit signatures
96
- ```
97
-
98
- ## Controls
99
-
100
- - Arrow keys / `j` / `k`: Navigate selection
101
- - `o`: Launch selected project in Unity
102
- - Ctrl + C (twice): Exit
64
+ This project implements supply chain attack prevention measures:
103
65
 
104
- The display includes Git branch (if present), Unity version, project path, and last modified time (`lastModified`).
66
+ - **ignore-scripts**: Disables automatic script execution during `npm install`
67
+ - **Dependabot**: Automated weekly security updates
68
+ - **Security audit CI**: Runs `npm audit` and `lockfile-lint` on every PR
69
+ - **Pinned versions**: All dependencies use exact versions (no `^` or `~`)
105
70
 
106
71
  ## License
107
72
 
package/dist/index.js CHANGED
@@ -294,18 +294,28 @@ var MacExternalEditorPathReader = class {
294
294
  return { status: "found", path: configuredPath, name };
295
295
  }
296
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
+ };
297
302
  var MacExternalEditorLauncher = class {
298
303
  /**
299
304
  * Launches the external editor with the specified project root.
300
- * If a .sln file exists with the project name, it will be opened directly.
301
- * This allows Rider to open the solution without showing a selection dialog.
305
+ * For Rider/Visual Studio: opens .sln file directly if it exists.
306
+ * For VS Code/Cursor and others: opens the project folder.
302
307
  * @param editorPath - The path to the editor application.
303
308
  * @param projectRoot - The project root directory to open.
304
309
  */
305
310
  async launch(editorPath, projectRoot) {
306
- const projectName = basename(projectRoot);
307
- const slnFilePath = join3(projectRoot, `${projectName}.sln`);
308
- const targetPath = existsSync(slnFilePath) ? slnFilePath : 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
+ }
309
319
  await execFileAsync("open", ["-a", editorPath, targetPath]);
310
320
  }
311
321
  };
@@ -316,14 +326,77 @@ import { constants as constants4, existsSync as existsSync2 } from "fs";
316
326
  import { access as access4 } from "fs/promises";
317
327
  import { basename as basename2, join as join4 } from "path";
318
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
319
378
  var execFileAsync2 = promisify2(execFile2);
320
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
+ };
321
390
  var parseRegistryOutput = (stdout) => {
322
391
  const lines = stdout.split("\n");
323
392
  for (const line of lines) {
324
- const match = line.match(/kScriptsDefaultApp[^\s]*\s+REG_SZ\s+(.+)/i);
325
- if (match?.[1]) {
326
- return match[1].trim();
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]);
327
400
  }
328
401
  }
329
402
  return void 0;
@@ -336,12 +409,11 @@ var WinExternalEditorPathReader = class {
336
409
  async read() {
337
410
  let configuredPath;
338
411
  try {
339
- const result = await execFileAsync2("reg", [
340
- "query",
341
- REGISTRY_PATH,
342
- "/v",
343
- "kScriptsDefaultApp"
344
- ]);
412
+ const result = await execFileAsync2(
413
+ "reg",
414
+ ["query", REGISTRY_PATH],
415
+ { env: getMsysDisabledEnv() }
416
+ );
345
417
  configuredPath = parseRegistryOutput(result.stdout);
346
418
  } catch {
347
419
  return { status: "not_configured" };
@@ -358,22 +430,33 @@ var WinExternalEditorPathReader = class {
358
430
  return { status: "found", path: configuredPath, name };
359
431
  }
360
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
+ };
361
438
  var WinExternalEditorLauncher = class {
362
439
  /**
363
440
  * Launches the external editor with the specified project root.
364
- * If a .sln file exists with the project name, it will be opened directly.
365
- * This allows Rider to open the solution without showing a selection dialog.
441
+ * For Rider/Visual Studio: opens .sln file directly if it exists.
442
+ * For VS Code/Cursor and others: opens the project folder.
366
443
  * @param editorPath - The path to the editor executable.
367
444
  * @param projectRoot - The project root directory to open.
368
445
  */
369
446
  async launch(editorPath, projectRoot) {
370
- const projectName = basename2(projectRoot);
371
- const slnFilePath = join4(projectRoot, `${projectName}.sln`);
372
- const targetPath = existsSync2(slnFilePath) ? slnFilePath : 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
+ }
373
455
  await new Promise((resolve4, reject) => {
374
456
  const child = spawn(editorPath, [targetPath], {
375
457
  detached: true,
376
- stdio: "ignore"
458
+ stdio: "ignore",
459
+ env: getMsysDisabledEnv()
377
460
  });
378
461
  const handleError = (error) => {
379
462
  child.off("spawn", handleSpawn);
@@ -478,7 +561,8 @@ var NodeProcessLauncher = class {
478
561
  await new Promise((resolve4, reject) => {
479
562
  const child = spawn2(command, args, {
480
563
  detached,
481
- stdio: "ignore"
564
+ stdio: "ignore",
565
+ env: getMsysDisabledEnv()
482
566
  });
483
567
  const handleError = (error) => {
484
568
  child.off("spawn", handleSpawn);
@@ -1209,7 +1293,7 @@ var WinUnityProcessReader = class {
1209
1293
  "-Command",
1210
1294
  psCommand
1211
1295
  ],
1212
- { encoding: "utf8" }
1296
+ { encoding: "utf8", env: getMsysDisabledEnv() }
1213
1297
  );
1214
1298
  stdout = (result.stdout ?? "").trim();
1215
1299
  } catch (error) {
@@ -1236,14 +1320,18 @@ var WinUnityProcessReader = class {
1236
1320
  var WinUnityProcessTerminator = class {
1237
1321
  async terminate(unityProcess) {
1238
1322
  try {
1239
- await execFileAsync5("powershell.exe", [
1240
- "-NoProfile",
1241
- "-NonInteractive",
1242
- "-ExecutionPolicy",
1243
- "Bypass",
1244
- "-Command",
1245
- `Stop-Process -Id ${unityProcess.pid}`
1246
- ]);
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
+ );
1247
1335
  } catch (error) {
1248
1336
  if (!ensureProcessAlive2(unityProcess.pid)) {
1249
1337
  return { terminated: true, stage: "sigterm" };
@@ -1258,14 +1346,18 @@ var WinUnityProcessTerminator = class {
1258
1346
  }
1259
1347
  }
1260
1348
  try {
1261
- await execFileAsync5("powershell.exe", [
1262
- "-NoProfile",
1263
- "-NonInteractive",
1264
- "-ExecutionPolicy",
1265
- "Bypass",
1266
- "-Command",
1267
- `Stop-Process -Id ${unityProcess.pid} -Force`
1268
- ]);
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
+ );
1269
1361
  } catch (error) {
1270
1362
  if (!ensureProcessAlive2(unityProcess.pid)) {
1271
1363
  return { terminated: true, stage: "sigkill" };
@@ -1395,39 +1487,6 @@ var ThemeProvider = ({ theme, children }) => {
1395
1487
  return createElement(ThemeContext.Provider, { value }, children);
1396
1488
  };
1397
1489
 
1398
- // src/presentation/utils/path.ts
1399
- var homeDirectory = process.env.HOME ?? process.env.USERPROFILE ?? "";
1400
- var normalizedHomeDirectory = homeDirectory.replace(/\\/g, "/");
1401
- var homePrefix = normalizedHomeDirectory ? `${normalizedHomeDirectory}/` : "";
1402
- var shortenHomePath = (targetPath) => {
1403
- if (!normalizedHomeDirectory) {
1404
- return targetPath;
1405
- }
1406
- const normalizedTarget = targetPath.replace(/\\/g, "/");
1407
- if (normalizedTarget === normalizedHomeDirectory) {
1408
- return "~";
1409
- }
1410
- if (homePrefix && normalizedTarget.startsWith(homePrefix)) {
1411
- return `~/${normalizedTarget.slice(homePrefix.length)}`;
1412
- }
1413
- return targetPath;
1414
- };
1415
- var buildCdCommand = (targetPath) => {
1416
- if (process.platform === "win32") {
1417
- const isGitBash = Boolean(process.env.MSYSTEM) || /bash/i.test(process.env.SHELL ?? "");
1418
- if (isGitBash) {
1419
- const windowsPath = targetPath;
1420
- const msysPath = windowsPath.replace(/^([A-Za-z]):[\\/]/, (_, drive) => `/${drive.toLowerCase()}/`).replace(/\\/g, "/");
1421
- const escapedForPosix2 = msysPath.replace(/'/g, "'\\''");
1422
- return `cd '${escapedForPosix2}'`;
1423
- }
1424
- const escapedForWindows = targetPath.replace(/"/g, '""');
1425
- return `cd "${escapedForWindows}"`;
1426
- }
1427
- const escapedForPosix = targetPath.replace(/'/g, "'\\''");
1428
- return `cd '${escapedForPosix}'`;
1429
- };
1430
-
1431
1490
  // src/presentation/components/ProjectRow.tsx
1432
1491
  import { Box as Box2, Text, useStdout } from "ink";
1433
1492
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
@@ -1928,8 +1987,7 @@ var extractRootFolder2 = (repository) => {
1928
1987
  return base || void 0;
1929
1988
  };
1930
1989
  var minimumVisibleProjectCount = 4;
1931
- var editorOnlyKey = process2.platform === "darwin" ? "\u2325o" : "Alt+o";
1932
- var defaultHintMessage = `j/k Select \xB7 [o]pen [O]+Editor [${editorOnlyKey}]Editor [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`;
1933
1991
  var getCopyTargetPath = (view) => {
1934
1992
  const root = view.repository?.root;
1935
1993
  return root && root.length > 0 ? root : view.project.path;
@@ -2481,7 +2539,7 @@ var App = ({
2481
2539
  void terminateSelected();
2482
2540
  return;
2483
2541
  }
2484
- if (input === "\xF8" || input === "o" && key.meta) {
2542
+ if (input === "i") {
2485
2543
  void launchEditorOnly();
2486
2544
  return;
2487
2545
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "unity-hub-cli",
3
- "version": "0.15.0",
3
+ "version": "0.17.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",
@@ -36,25 +36,26 @@
36
36
  "dist"
37
37
  ],
38
38
  "dependencies": {
39
- "clipboardy": "^4.0.0",
40
- "ink": "^4.4.1",
41
- "react": "^18.3.1"
39
+ "clipboardy": "4.0.0",
40
+ "ink": "4.4.1",
41
+ "react": "18.3.1"
42
42
  },
43
43
  "engines": {
44
44
  "node": ">=18"
45
45
  },
46
46
  "devDependencies": {
47
- "@types/node": "^20.19.20",
48
- "@types/react": "^18.3.26",
49
- "@typescript-eslint/eslint-plugin": "^7.18.0",
50
- "@typescript-eslint/parser": "^7.18.0",
51
- "eslint": "^8.57.0",
52
- "eslint-config-prettier": "^9.1.2",
53
- "eslint-import-resolver-typescript": "^3.10.1",
54
- "eslint-plugin-import": "^2.32.0",
55
- "prettier": "^3.6.2",
56
- "tsup": "^8.5.0",
57
- "tsx": "^4.20.6",
58
- "typescript": "^5.9.3"
47
+ "@types/node": "20.19.20",
48
+ "@types/react": "18.3.26",
49
+ "@typescript-eslint/eslint-plugin": "7.18.0",
50
+ "@typescript-eslint/parser": "7.18.0",
51
+ "eslint": "8.57.0",
52
+ "eslint-config-prettier": "9.1.2",
53
+ "eslint-import-resolver-typescript": "3.10.1",
54
+ "eslint-plugin-import": "2.32.0",
55
+ "prettier": "3.6.2",
56
+ "tsup": "8.5.0",
57
+ "tsx": "4.20.6",
58
+ "typescript": "5.9.3",
59
+ "vitest": "4.0.14"
59
60
  }
60
61
  }