unity-hub-cli 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Masamichi Hatayama
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # UnityHub CLI
2
+
3
+ A CLI tool that reads Unity Hub's `projects-v1.json`, displays projects in an Ink-based TUI, allows navigation with arrow keys/`j`/`k`, and launches Unity Editor by pressing Enter.
4
+
5
+ ## Requirements
6
+
7
+ - macOS
8
+ - Node.js 20+
9
+ - Unity Hub (with `~/Library/Application Support/UnityHub/projects-v1.json` present)
10
+
11
+ ## Usage
12
+
13
+ ### Development
14
+
15
+ ```bash
16
+ npm install
17
+ npm run dev
18
+ ```
19
+
20
+ ### Build
21
+
22
+ ```bash
23
+ npm run build
24
+ ```
25
+
26
+ ### Run
27
+
28
+ After building, `dist/index.js` will be generated. You can also run it directly via npx.
29
+
30
+ ```bash
31
+ npx unity-hub-cli
32
+ # or
33
+ node dist/index.js
34
+ ```
35
+
36
+ By default, the project list uses the Git repository root folder name when available.
37
+
38
+ ### CLI Options
39
+
40
+ - `--no-git-root-name`: Display Unity project titles instead of Git repository root folder names.
41
+ - `--hide-branch`: Hide the Git branch column.
42
+ - `--hide-path`: Hide the project path column.
43
+
44
+ ## Release Automation
45
+
46
+ Version and release management is automated using release-please and GitHub Actions.
47
+
48
+ - `.github/workflows/release-please.yml` runs on push to `main` or manual trigger
49
+ - The action references `release-please-config.json` and `.release-please-manifest.json` to create release PRs and tags
50
+ - When a PR is merged, GitHub Releases and changelog are automatically updated
51
+ - **npm publish is automated with provenance** for supply chain security
52
+
53
+ ### Initial Setup Notes
54
+
55
+ - If existing releases are present, set the latest release commit in `bootstrap-sha` of `release-please-config.json`
56
+ - The workflow uses GitHub's `GITHUB_TOKEN` and operates with `contents`/`pull-requests` permissions
57
+
58
+ ### Manual Execution
59
+
60
+ You can manually trigger the `release-please` workflow from the Actions tab by selecting `Run workflow`
61
+
62
+ ## Security
63
+
64
+ This package implements multiple security measures to protect against supply chain attacks:
65
+
66
+ 1. **Automated Publishing with Provenance**: All npm releases are published via GitHub Actions with `--provenance` flag, providing cryptographic proof of the build environment
67
+ 2. **Minimal Dependencies**: Only 2 runtime dependencies (`ink` and `react`), both from highly trusted sources
68
+ 3. **Locked Dependencies**: `package-lock.json` is committed to ensure reproducible builds
69
+ 4. **Regular Security Audits**: Dependencies are regularly checked with `npm audit`
70
+
71
+ ### Verifying Package Authenticity
72
+
73
+ You can verify the authenticity of published packages:
74
+
75
+ ```bash
76
+ # Check provenance information
77
+ npm view unity-hub-cli --json | jq .dist.attestations
78
+
79
+ # Verify package integrity
80
+ npm audit signatures
81
+ ```
82
+
83
+ ## Controls
84
+
85
+ - Arrow keys / `j` / `k`: Navigate selection
86
+ - `o`: Launch selected project in Unity
87
+ - Ctrl + C (twice): Exit
88
+
89
+ The display includes Git branch (if present), Unity version, project path, and last modified time (`lastModified`).
90
+
91
+ ## License
92
+
93
+ MIT
@@ -0,0 +1,2 @@
1
+
2
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,783 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.tsx
4
+ import process2 from "process";
5
+ import { render } from "ink";
6
+
7
+ // src/application/usecases.ts
8
+ var ListProjectsUseCase = class {
9
+ constructor(unityHubProjectsReader, gitRepositoryInfoReader, unityProjectOptionsReader) {
10
+ this.unityHubProjectsReader = unityHubProjectsReader;
11
+ this.gitRepositoryInfoReader = gitRepositoryInfoReader;
12
+ this.unityProjectOptionsReader = unityProjectOptionsReader;
13
+ }
14
+ async execute() {
15
+ const projects = await this.unityHubProjectsReader.listProjects();
16
+ const repositoryInfoResults = await Promise.allSettled(
17
+ projects.map((project) => this.gitRepositoryInfoReader.readRepositoryInfo(project.path))
18
+ );
19
+ return projects.map((project, index) => {
20
+ const repositoryResult = repositoryInfoResults[index];
21
+ if (repositoryResult.status === "fulfilled") {
22
+ return { project, repository: repositoryResult.value ?? void 0 };
23
+ }
24
+ return { project };
25
+ });
26
+ }
27
+ };
28
+ var LaunchCancelledError = class extends Error {
29
+ constructor() {
30
+ super("Launch cancelled by user");
31
+ this.name = "LaunchCancelledError";
32
+ }
33
+ };
34
+ var LaunchProjectUseCase = class {
35
+ constructor(editorPathResolver, processLauncher, unityHubProjectsReader, unityProjectOptionsReader, unityProcessLockChecker) {
36
+ this.editorPathResolver = editorPathResolver;
37
+ this.processLauncher = processLauncher;
38
+ this.unityHubProjectsReader = unityHubProjectsReader;
39
+ this.unityProjectOptionsReader = unityProjectOptionsReader;
40
+ this.unityProcessLockChecker = unityProcessLockChecker;
41
+ }
42
+ async execute(project) {
43
+ const lockDecision = await this.unityProcessLockChecker.check(project.path);
44
+ if (lockDecision === "skip") {
45
+ throw new LaunchCancelledError();
46
+ }
47
+ const editorPath = await this.editorPathResolver.resolve(project.version);
48
+ const extraArgs = await this.unityProjectOptionsReader.readCliArgs(project.path);
49
+ const launchArgs = ["-projectPath", project.path, ...extraArgs];
50
+ await this.processLauncher.launch(editorPath, launchArgs, {
51
+ detached: true
52
+ });
53
+ await this.unityHubProjectsReader.updateLastModified(project.path, /* @__PURE__ */ new Date());
54
+ }
55
+ };
56
+
57
+ // src/infrastructure/editor.ts
58
+ import { constants } from "fs";
59
+ import { access } from "fs/promises";
60
+ import { join } from "path";
61
+ var UNITY_EDITOR_BASE = "/Applications/Unity/Hub/Editor";
62
+ var UNITY_BINARY_PATH = "Unity.app/Contents/MacOS/Unity";
63
+ var MacEditorPathResolver = class {
64
+ async resolve(version) {
65
+ const editorPath = join(UNITY_EDITOR_BASE, version.value, UNITY_BINARY_PATH);
66
+ try {
67
+ await access(editorPath, constants.X_OK);
68
+ } catch {
69
+ throw new Error(`\u5BFE\u5FDC\u3059\u308BUnity Editor\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\uFF08${version.value}\uFF09`);
70
+ }
71
+ return editorPath;
72
+ }
73
+ };
74
+
75
+ // src/infrastructure/git.ts
76
+ import { readFile, stat } from "fs/promises";
77
+ import { dirname, join as join2, resolve } from "path";
78
+ var HEAD_FILE = "HEAD";
79
+ var GIT_DIR = ".git";
80
+ var MAX_ASCENT = 50;
81
+ var isDirectory = async (path) => {
82
+ try {
83
+ const stats = await stat(path);
84
+ return stats.isDirectory();
85
+ } catch {
86
+ return false;
87
+ }
88
+ };
89
+ var isFile = async (path) => {
90
+ try {
91
+ const stats = await stat(path);
92
+ return stats.isFile();
93
+ } catch {
94
+ return false;
95
+ }
96
+ };
97
+ var findGitDir = async (start) => {
98
+ let current = resolve(start);
99
+ for (let depth = 0; depth < MAX_ASCENT; depth += 1) {
100
+ const candidate = join2(current, GIT_DIR);
101
+ if (await isDirectory(candidate)) {
102
+ return candidate;
103
+ }
104
+ if (await isFile(candidate)) {
105
+ const content = await readFile(candidate, "utf8");
106
+ const match = content.match(/^gitdir:\s*(.+)$/m);
107
+ if (match) {
108
+ const gitdirPath = match[1]?.trim();
109
+ if (gitdirPath) {
110
+ const target = gitdirPath.startsWith("/") ? gitdirPath : resolve(current, gitdirPath);
111
+ return target;
112
+ }
113
+ }
114
+ }
115
+ const parent = dirname(current);
116
+ if (parent === current) {
117
+ break;
118
+ }
119
+ current = parent;
120
+ }
121
+ return void 0;
122
+ };
123
+ var parseHead = (content) => {
124
+ const trimmed = content.trim();
125
+ if (!trimmed) {
126
+ return void 0;
127
+ }
128
+ if (trimmed.startsWith("ref:")) {
129
+ const refName = trimmed.split(" ")[1]?.trim();
130
+ if (refName?.startsWith("refs/heads/")) {
131
+ return { type: "branch", name: refName.replace("refs/heads/", "") };
132
+ }
133
+ return void 0;
134
+ }
135
+ return { type: "detached", sha: trimmed.slice(0, 7) };
136
+ };
137
+ var GitRepositoryInfoReader = class {
138
+ async readRepositoryInfo(projectPath) {
139
+ const gitDir = await findGitDir(projectPath);
140
+ if (!gitDir) {
141
+ return void 0;
142
+ }
143
+ try {
144
+ const headPath = join2(gitDir, HEAD_FILE);
145
+ const content = await readFile(headPath, "utf8");
146
+ const branch = parseHead(content);
147
+ const root = dirname(gitDir);
148
+ return { branch, root };
149
+ } catch {
150
+ return void 0;
151
+ }
152
+ }
153
+ };
154
+
155
+ // src/infrastructure/process.ts
156
+ import { spawn } from "child_process";
157
+ var NodeProcessLauncher = class {
158
+ async launch(command, args, options) {
159
+ const detached = options?.detached ?? false;
160
+ await new Promise((resolve2, reject) => {
161
+ const child = spawn(command, args, {
162
+ detached,
163
+ stdio: "ignore"
164
+ });
165
+ const handleError = (error) => {
166
+ child.off("spawn", handleSpawn);
167
+ reject(error);
168
+ };
169
+ const handleSpawn = () => {
170
+ child.off("error", handleError);
171
+ child.unref();
172
+ resolve2();
173
+ };
174
+ child.once("error", handleError);
175
+ child.once("spawn", handleSpawn);
176
+ });
177
+ }
178
+ };
179
+
180
+ // src/infrastructure/unityhub.ts
181
+ import { readFile as readFile2, writeFile } from "fs/promises";
182
+ import { basename } from "path";
183
+ var HUB_PROJECTS_PATH = `${process.env.HOME ?? ""}/Library/Application Support/UnityHub/projects-v1.json`;
184
+ var schemaVersion = "v1";
185
+ var toUnityProject = (entry) => {
186
+ const safePath = entry.path;
187
+ if (!safePath) {
188
+ throw new Error("Unity Hub entry is missing project path");
189
+ }
190
+ const version = entry.version;
191
+ if (!version) {
192
+ throw new Error(`Unity Hub entry ${safePath} is missing version`);
193
+ }
194
+ const lastModified = typeof entry.lastModified === "number" ? new Date(entry.lastModified) : void 0;
195
+ return {
196
+ id: safePath,
197
+ title: entry.title?.trim() || basename(safePath),
198
+ path: safePath,
199
+ version: { value: version },
200
+ lastModified,
201
+ favorite: entry.isFavorite === true
202
+ };
203
+ };
204
+ var normalizeValue = (value) => value.toLocaleLowerCase();
205
+ var sortByFavoriteThenLastModified = (projects) => {
206
+ return [...projects].sort((a, b) => {
207
+ const favoriteRankA = a.favorite ? 0 : 1;
208
+ const favoriteRankB = b.favorite ? 0 : 1;
209
+ if (favoriteRankA !== favoriteRankB) {
210
+ return favoriteRankA - favoriteRankB;
211
+ }
212
+ const fallbackTime = 0;
213
+ const timeA = a.lastModified?.getTime() ?? fallbackTime;
214
+ const timeB = b.lastModified?.getTime() ?? fallbackTime;
215
+ if (timeA === timeB) {
216
+ const titleA = normalizeValue(a.title);
217
+ const titleB = normalizeValue(b.title);
218
+ if (titleA === titleB) {
219
+ return normalizeValue(a.path).localeCompare(normalizeValue(b.path));
220
+ }
221
+ return titleA.localeCompare(titleB);
222
+ }
223
+ return timeB - timeA;
224
+ });
225
+ };
226
+ var UnityHubProjectsReader = class {
227
+ async listProjects() {
228
+ let content;
229
+ try {
230
+ content = await readFile2(HUB_PROJECTS_PATH, "utf8");
231
+ } catch {
232
+ throw new Error(
233
+ `Unity Hub\u306E\u30D7\u30ED\u30B8\u30A7\u30AF\u30C8\u4E00\u89A7\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\uFF08${HUB_PROJECTS_PATH}\uFF09`
234
+ );
235
+ }
236
+ let json;
237
+ try {
238
+ json = JSON.parse(content);
239
+ } catch {
240
+ throw new Error("Unity Hub\u306E\u30D7\u30ED\u30B8\u30A7\u30AF\u30C8\u4E00\u89A7\u3092\u8AAD\u307F\u53D6\u308C\u307E\u305B\u3093\uFF08\u6A29\u9650/\u5F62\u5F0F\u30A8\u30E9\u30FC\uFF09");
241
+ }
242
+ if (json.schema_version && json.schema_version !== schemaVersion) {
243
+ throw new Error(`\u672A\u5BFE\u5FDC\u306Eschema_version\u3067\u3059\uFF08${json.schema_version}\uFF09`);
244
+ }
245
+ const entries = Object.values(json.data ?? {});
246
+ if (entries.length === 0) {
247
+ return [];
248
+ }
249
+ const projects = entries.map(toUnityProject);
250
+ return sortByFavoriteThenLastModified(projects);
251
+ }
252
+ async updateLastModified(projectPath, date) {
253
+ let content;
254
+ try {
255
+ content = await readFile2(HUB_PROJECTS_PATH, "utf8");
256
+ } catch {
257
+ throw new Error(
258
+ `Unity Hub\u306E\u30D7\u30ED\u30B8\u30A7\u30AF\u30C8\u4E00\u89A7\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\uFF08${HUB_PROJECTS_PATH}\uFF09`
259
+ );
260
+ }
261
+ let json;
262
+ try {
263
+ json = JSON.parse(content);
264
+ } catch {
265
+ throw new Error("Unity Hub\u306E\u30D7\u30ED\u30B8\u30A7\u30AF\u30C8\u4E00\u89A7\u3092\u8AAD\u307F\u53D6\u308C\u307E\u305B\u3093\uFF08\u6A29\u9650/\u5F62\u5F0F\u30A8\u30E9\u30FC\uFF09");
266
+ }
267
+ if (!json.data) {
268
+ return;
269
+ }
270
+ const projectKey = Object.keys(json.data).find((key) => json.data?.[key]?.path === projectPath);
271
+ if (!projectKey) {
272
+ return;
273
+ }
274
+ const original = json.data[projectKey];
275
+ if (!original) {
276
+ return;
277
+ }
278
+ json.data[projectKey] = {
279
+ ...original,
280
+ lastModified: date.getTime()
281
+ };
282
+ await writeFile(HUB_PROJECTS_PATH, JSON.stringify(json, void 0, 2), "utf8");
283
+ }
284
+ async readCliArgs(projectPath) {
285
+ const infoPath = `${process.env.HOME ?? ""}/Library/Application Support/UnityHub/projectsInfo.json`;
286
+ let content;
287
+ try {
288
+ content = await readFile2(infoPath, "utf8");
289
+ } catch {
290
+ return [];
291
+ }
292
+ let json;
293
+ try {
294
+ json = JSON.parse(content);
295
+ } catch {
296
+ return [];
297
+ }
298
+ const entry = json[projectPath];
299
+ if (!entry?.cliArgs) {
300
+ return [];
301
+ }
302
+ const tokens = entry.cliArgs.match(/(?:"[^"]*"|'[^']*'|[^\s"']+)/g);
303
+ if (!tokens) {
304
+ return [];
305
+ }
306
+ return tokens.map((token) => token.replace(/^['"]|['"]$/g, ""));
307
+ }
308
+ };
309
+
310
+ // src/infrastructure/unityLock.ts
311
+ import { constants as constants2, createReadStream, createWriteStream } from "fs";
312
+ import { access as access2, rm } from "fs/promises";
313
+ import { join as join3 } from "path";
314
+ import readline from "readline";
315
+ var RAW_PROMPT_MESSAGE = "Delete UnityLockfile and continue? Type 'y' to continue; anything else aborts: ";
316
+ var isRawModeSupported = () => {
317
+ const stdin = process.stdin;
318
+ return Boolean(stdin?.isTTY && typeof stdin.setRawMode === "function" && process.stdout.isTTY);
319
+ };
320
+ var createPromptInterface = () => {
321
+ if (process.stdin.isTTY && process.stdout.isTTY) {
322
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
323
+ const close = () => rl.close();
324
+ return { rl, close };
325
+ }
326
+ try {
327
+ if (process.platform === "win32") {
328
+ const inCandidates = ["\\\\.\\CONIN$", "CONIN$"];
329
+ const outCandidates = ["\\\\.\\CONOUT$", "CONOUT$"];
330
+ for (const inPath of inCandidates) {
331
+ for (const outPath of outCandidates) {
332
+ try {
333
+ const input = createReadStream(inPath);
334
+ const output = createWriteStream(outPath);
335
+ const rl = readline.createInterface({ input, output });
336
+ const close = () => {
337
+ rl.close();
338
+ input.destroy();
339
+ output.end();
340
+ };
341
+ return { rl, close };
342
+ } catch {
343
+ continue;
344
+ }
345
+ }
346
+ }
347
+ } else {
348
+ const input = createReadStream("/dev/tty");
349
+ const output = createWriteStream("/dev/tty");
350
+ const rl = readline.createInterface({ input, output });
351
+ const close = () => {
352
+ rl.close();
353
+ input.destroy();
354
+ output.end();
355
+ };
356
+ return { rl, close };
357
+ }
358
+ } catch {
359
+ return void 0;
360
+ }
361
+ return void 0;
362
+ };
363
+ var promptYesNoSingleKey = async () => {
364
+ const stdin = process.stdin;
365
+ const supportsRaw = isRawModeSupported();
366
+ const previousRaw = supportsRaw ? stdin.isRaw === true : false;
367
+ const wasPaused = stdin.isPaused();
368
+ return await new Promise((resolve2) => {
369
+ const cleanup = () => {
370
+ stdin.removeListener("data", handleData);
371
+ if (wasPaused) {
372
+ stdin.pause();
373
+ }
374
+ if (supportsRaw) {
375
+ stdin.setRawMode(previousRaw);
376
+ }
377
+ };
378
+ const handleData = (data) => {
379
+ const char = data.toString();
380
+ const firstByte = data[0] ?? 0;
381
+ let result = false;
382
+ if (char === "y") {
383
+ result = true;
384
+ } else if (char === "n" || char === "N" || firstByte === 3 || firstByte === 27 || firstByte === 13) {
385
+ result = false;
386
+ } else {
387
+ result = false;
388
+ }
389
+ process.stdout.write("\n");
390
+ cleanup();
391
+ resolve2(result);
392
+ };
393
+ process.stdout.write(RAW_PROMPT_MESSAGE);
394
+ if (supportsRaw) {
395
+ stdin.setRawMode(true);
396
+ }
397
+ if (wasPaused) {
398
+ stdin.resume();
399
+ }
400
+ stdin.once("data", handleData);
401
+ });
402
+ };
403
+ var promptYesNoLine = async () => {
404
+ const prompt = createPromptInterface();
405
+ if (!prompt) {
406
+ console.error("UnityLockfile exists. No interactive console available for confirmation.");
407
+ return false;
408
+ }
409
+ const confirmed = await new Promise((resolve2) => {
410
+ prompt.rl.question(RAW_PROMPT_MESSAGE, (answer) => {
411
+ resolve2(answer.trim() === "y");
412
+ });
413
+ });
414
+ prompt.close();
415
+ return confirmed;
416
+ };
417
+ var pathExists = async (target) => {
418
+ try {
419
+ await access2(target, constants2.F_OK);
420
+ return true;
421
+ } catch {
422
+ return false;
423
+ }
424
+ };
425
+ var UnityLockChecker = class {
426
+ async check(projectPath) {
427
+ const lockfilePath = join3(projectPath, "Temp", "UnityLockfile");
428
+ const hasLockfile = await pathExists(lockfilePath);
429
+ if (!hasLockfile) {
430
+ return "allow";
431
+ }
432
+ console.log(`UnityLockfile found: ${lockfilePath}`);
433
+ console.log("Another Unity process may be using this project.");
434
+ const confirmed = isRawModeSupported() ? await promptYesNoSingleKey() : await promptYesNoLine();
435
+ if (!confirmed) {
436
+ console.log("Aborted by user.");
437
+ return "skip";
438
+ }
439
+ await rm(lockfilePath, { force: true });
440
+ console.log("Deleted UnityLockfile. Continuing launch.");
441
+ return "allow";
442
+ }
443
+ };
444
+
445
+ // src/presentation/App.tsx
446
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
447
+ import { useCallback, useEffect, useMemo, useState } from "react";
448
+ import { jsx, jsxs } from "react/jsx-runtime";
449
+ var extractRootFolder = (repository) => {
450
+ if (!repository?.root) {
451
+ return void 0;
452
+ }
453
+ const segments = repository.root.split("/").filter((segment) => segment.length > 0);
454
+ if (segments.length === 0) {
455
+ return void 0;
456
+ }
457
+ return segments[segments.length - 1];
458
+ };
459
+ var formatProjectName = (project, repository, useGitRootName) => {
460
+ if (!useGitRootName) {
461
+ return `${project.title} (${project.version.value})`;
462
+ }
463
+ const rootFolder = extractRootFolder(repository);
464
+ if (!rootFolder) {
465
+ return `${project.title} (${project.version.value})`;
466
+ }
467
+ return `${rootFolder} (${project.version.value})`;
468
+ };
469
+ var formatBranch = (branch) => {
470
+ if (!branch) {
471
+ return "-";
472
+ }
473
+ if (branch.type === "branch") {
474
+ return branch.name;
475
+ }
476
+ return `detached@${branch.sha}`;
477
+ };
478
+ var homeDirectory = process.env.HOME ?? "";
479
+ var homePrefix = homeDirectory ? `${homeDirectory}/` : "";
480
+ var minimumVisibleProjectCount = 4;
481
+ var defaultHintMessage = "Move with arrows or j/k \xB7 Launch with o \xB7 Exit with Ctrl+C twice";
482
+ var PROJECT_COLOR = "#abd8e7";
483
+ var BRANCH_COLOR = "#e3839c";
484
+ var PATH_COLOR = "#719bd8";
485
+ var shortenHomePath = (targetPath) => {
486
+ if (!homeDirectory) {
487
+ return targetPath;
488
+ }
489
+ if (targetPath === homeDirectory) {
490
+ return "~";
491
+ }
492
+ if (homePrefix && targetPath.startsWith(homePrefix)) {
493
+ return `~/${targetPath.slice(homePrefix.length)}`;
494
+ }
495
+ return targetPath;
496
+ };
497
+ var App = ({
498
+ projects,
499
+ onLaunch,
500
+ useGitRootName = true,
501
+ showBranch = true,
502
+ showPath = true
503
+ }) => {
504
+ const { exit } = useApp();
505
+ const { stdout } = useStdout();
506
+ const [visibleCount, setVisibleCount] = useState(minimumVisibleProjectCount);
507
+ const [index, setIndex] = useState(0);
508
+ const [hint, setHint] = useState("Move with j/k \xB7 Launch with o \xB7 Exit with Ctrl+C twice");
509
+ const [pendingExit, setPendingExit] = useState(false);
510
+ const linesPerProject = (showBranch ? 1 : 0) + (showPath ? 1 : 0) + 2;
511
+ const sortedProjects = useMemo(() => {
512
+ const fallbackTime = 0;
513
+ const toSortKey = (view) => {
514
+ if (useGitRootName) {
515
+ const rootName = extractRootFolder(view.repository);
516
+ if (rootName) {
517
+ return rootName.toLocaleLowerCase();
518
+ }
519
+ }
520
+ return view.project.title.toLocaleLowerCase();
521
+ };
522
+ const toTieBreaker = (view) => view.project.path.toLocaleLowerCase();
523
+ return [...projects].sort((a, b) => {
524
+ if (a.project.favorite !== b.project.favorite) {
525
+ return a.project.favorite ? -1 : 1;
526
+ }
527
+ const timeA = a.project.lastModified?.getTime() ?? fallbackTime;
528
+ const timeB = b.project.lastModified?.getTime() ?? fallbackTime;
529
+ if (timeA !== timeB) {
530
+ return timeB - timeA;
531
+ }
532
+ const keyA = toSortKey(a);
533
+ const keyB = toSortKey(b);
534
+ if (keyA === keyB) {
535
+ return toTieBreaker(a).localeCompare(toTieBreaker(b));
536
+ }
537
+ return keyA.localeCompare(keyB);
538
+ });
539
+ }, [projects, useGitRootName]);
540
+ useEffect(() => {
541
+ const handleSigint = () => {
542
+ if (!pendingExit) {
543
+ setPendingExit(true);
544
+ setHint("Press Ctrl+C again to exit");
545
+ setTimeout(() => {
546
+ setPendingExit(false);
547
+ setHint(defaultHintMessage);
548
+ }, 2e3);
549
+ return;
550
+ }
551
+ exit();
552
+ };
553
+ process.on("SIGINT", handleSigint);
554
+ return () => {
555
+ process.off("SIGINT", handleSigint);
556
+ };
557
+ }, [exit, pendingExit]);
558
+ useEffect(() => {
559
+ const updateVisibleCount = () => {
560
+ if (!stdout || typeof stdout.columns !== "number" || typeof stdout.rows !== "number") {
561
+ setVisibleCount(minimumVisibleProjectCount);
562
+ return;
563
+ }
564
+ const reservedRows = 6;
565
+ const availableRows = stdout.rows - reservedRows;
566
+ const rowsPerProject = Math.max(linesPerProject, 1);
567
+ const calculatedCount = Math.max(
568
+ minimumVisibleProjectCount,
569
+ Math.floor(availableRows / rowsPerProject)
570
+ );
571
+ setVisibleCount(calculatedCount);
572
+ };
573
+ updateVisibleCount();
574
+ stdout?.on("resize", updateVisibleCount);
575
+ return () => {
576
+ stdout?.off("resize", updateVisibleCount);
577
+ };
578
+ }, [linesPerProject, stdout]);
579
+ const move = useCallback(
580
+ (delta) => {
581
+ setIndex((prev) => {
582
+ if (sortedProjects.length === 0) {
583
+ return 0;
584
+ }
585
+ let next = prev + delta;
586
+ if (next < 0) {
587
+ next = 0;
588
+ }
589
+ if (next >= sortedProjects.length) {
590
+ next = sortedProjects.length - 1;
591
+ }
592
+ return next;
593
+ });
594
+ },
595
+ [sortedProjects.length]
596
+ );
597
+ const launchSelected = useCallback(async () => {
598
+ const project = sortedProjects[index]?.project;
599
+ if (!project) {
600
+ return;
601
+ }
602
+ try {
603
+ await onLaunch(project);
604
+ setHint(`Launched Unity: ${project.title}`);
605
+ setTimeout(() => {
606
+ setHint(defaultHintMessage);
607
+ }, 2e3);
608
+ } catch (error) {
609
+ if (error instanceof LaunchCancelledError) {
610
+ setHint("Launch cancelled");
611
+ setTimeout(() => {
612
+ setHint(defaultHintMessage);
613
+ }, 3e3);
614
+ return;
615
+ }
616
+ const message = error instanceof Error ? error.message : String(error);
617
+ setHint(`Failed to launch: ${message}`);
618
+ setTimeout(() => {
619
+ setHint(defaultHintMessage);
620
+ }, 3e3);
621
+ }
622
+ }, [index, onLaunch, sortedProjects]);
623
+ useInput((input, key) => {
624
+ if (input === "j" || key.downArrow) {
625
+ move(1);
626
+ }
627
+ if (input === "k" || key.upArrow) {
628
+ move(-1);
629
+ }
630
+ if (input === "o") {
631
+ void launchSelected();
632
+ }
633
+ });
634
+ const { startIndex, visibleProjects } = useMemo(() => {
635
+ const limit = Math.max(minimumVisibleProjectCount, visibleCount);
636
+ if (sortedProjects.length <= limit) {
637
+ return {
638
+ startIndex: 0,
639
+ endIndex: sortedProjects.length,
640
+ visibleProjects: sortedProjects
641
+ };
642
+ }
643
+ const halfWindow = Math.floor(limit / 2);
644
+ let start = index - halfWindow;
645
+ let end = index + halfWindow + limit % 2;
646
+ if (start < 0) {
647
+ start = 0;
648
+ end = limit;
649
+ }
650
+ if (end > sortedProjects.length) {
651
+ end = sortedProjects.length;
652
+ start = Math.max(0, end - limit);
653
+ }
654
+ return {
655
+ startIndex: start,
656
+ visibleProjects: sortedProjects.slice(start, end)
657
+ };
658
+ }, [index, sortedProjects, visibleCount]);
659
+ const scrollbarChars = useMemo(() => {
660
+ const totalProjects = projects.length;
661
+ const totalLines = totalProjects * linesPerProject;
662
+ const windowProjects = visibleProjects.length;
663
+ const visibleLines = windowProjects * linesPerProject;
664
+ if (totalLines === 0 || visibleLines === 0) {
665
+ return [];
666
+ }
667
+ if (totalLines <= visibleLines) {
668
+ return Array.from({ length: visibleLines }, () => "\u2588");
669
+ }
670
+ const trackLength = visibleLines;
671
+ const sliderSize = Math.max(1, Math.round(visibleLines / totalLines * trackLength));
672
+ const maxSliderStart = Math.max(0, trackLength - sliderSize);
673
+ const topLine = startIndex * linesPerProject;
674
+ const denominator = Math.max(1, totalLines - visibleLines);
675
+ const sliderStart = Math.min(
676
+ maxSliderStart,
677
+ Math.round(topLine / denominator * maxSliderStart)
678
+ );
679
+ return Array.from({ length: trackLength }, (_, position) => {
680
+ if (position >= sliderStart && position < sliderStart + sliderSize) {
681
+ return "\u2588";
682
+ }
683
+ return "|";
684
+ });
685
+ }, [linesPerProject, projects.length, startIndex, visibleProjects]);
686
+ const rows = useMemo(() => {
687
+ return visibleProjects.map(({ project, repository }, offset) => {
688
+ const rowIndex = startIndex + offset;
689
+ const isSelected = rowIndex === index;
690
+ const arrow = isSelected ? ">" : " ";
691
+ const titleLine = formatProjectName(project, repository, useGitRootName);
692
+ const pathLine = shortenHomePath(project.path);
693
+ const branchLine = formatBranch(repository?.branch);
694
+ const baseScrollbarIndex = offset * linesPerProject;
695
+ const titleScrollbar = scrollbarChars[baseScrollbarIndex] ?? " ";
696
+ const branchScrollbar = showBranch ? scrollbarChars[baseScrollbarIndex + 1] ?? " " : " ";
697
+ const pathScrollbar = showPath ? scrollbarChars[baseScrollbarIndex + 1 + (showBranch ? 1 : 0)] ?? " " : " ";
698
+ const spacerScrollbar = scrollbarChars[baseScrollbarIndex + linesPerProject - 1] ?? " ";
699
+ const versionStart = titleLine.indexOf("(");
700
+ const versionText = versionStart >= 0 ? titleLine.slice(versionStart) : "";
701
+ const nameText = versionStart >= 0 ? titleLine.slice(0, versionStart).trimEnd() : titleLine;
702
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "row", children: [
703
+ /* @__PURE__ */ jsxs(Box, { flexGrow: 1, flexDirection: "column", children: [
704
+ /* @__PURE__ */ jsxs(Text, { children: [
705
+ /* @__PURE__ */ jsxs(Text, { color: isSelected ? "green" : PROJECT_COLOR, bold: true, children: [
706
+ arrow,
707
+ " ",
708
+ nameText
709
+ ] }),
710
+ versionText ? /* @__PURE__ */ jsxs(Text, { color: isSelected ? "green" : void 0, children: [
711
+ " ",
712
+ versionText
713
+ ] }) : null
714
+ ] }),
715
+ showBranch ? /* @__PURE__ */ jsxs(Text, { color: isSelected ? "green" : BRANCH_COLOR, children: [
716
+ " ",
717
+ branchLine
718
+ ] }) : null,
719
+ showPath ? /* @__PURE__ */ jsxs(Text, { color: isSelected ? "green" : PATH_COLOR, children: [
720
+ " ",
721
+ pathLine
722
+ ] }) : null,
723
+ /* @__PURE__ */ jsx(Text, { children: " " })
724
+ ] }),
725
+ /* @__PURE__ */ jsxs(Box, { marginLeft: 1, width: 1, flexDirection: "column", alignItems: "center", children: [
726
+ /* @__PURE__ */ jsx(Text, { children: titleScrollbar }),
727
+ showBranch ? /* @__PURE__ */ jsx(Text, { children: branchScrollbar }) : null,
728
+ showPath ? /* @__PURE__ */ jsx(Text, { children: pathScrollbar }) : null,
729
+ /* @__PURE__ */ jsx(Text, { children: spacerScrollbar })
730
+ ] })
731
+ ] }, project.id);
732
+ });
733
+ }, [index, scrollbarChars, showBranch, showPath, startIndex, useGitRootName, visibleProjects]);
734
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
735
+ /* @__PURE__ */ jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", children: rows.length === 0 ? /* @__PURE__ */ jsx(Text, { children: "Unity Hub\u306E\u30D7\u30ED\u30B8\u30A7\u30AF\u30C8\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3067\u3057\u305F" }) : rows }),
736
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { children: hint }) })
737
+ ] });
738
+ };
739
+
740
+ // src/index.tsx
741
+ import { jsx as jsx2 } from "react/jsx-runtime";
742
+ var bootstrap = async () => {
743
+ const unityHubReader = new UnityHubProjectsReader();
744
+ const gitRepositoryInfoReader = new GitRepositoryInfoReader();
745
+ const listProjectsUseCase = new ListProjectsUseCase(
746
+ unityHubReader,
747
+ gitRepositoryInfoReader,
748
+ unityHubReader
749
+ );
750
+ const editorPathResolver = new MacEditorPathResolver();
751
+ const processLauncher = new NodeProcessLauncher();
752
+ const lockChecker = new UnityLockChecker();
753
+ const launchProjectUseCase = new LaunchProjectUseCase(
754
+ editorPathResolver,
755
+ processLauncher,
756
+ unityHubReader,
757
+ unityHubReader,
758
+ lockChecker
759
+ );
760
+ const useGitRootName = !process2.argv.includes("--no-git-root-name");
761
+ const showBranch = !process2.argv.includes("--hide-branch");
762
+ const showPath = !process2.argv.includes("--hide-path");
763
+ try {
764
+ const projects = await listProjectsUseCase.execute();
765
+ render(
766
+ /* @__PURE__ */ jsx2(
767
+ App,
768
+ {
769
+ projects,
770
+ onLaunch: (project) => launchProjectUseCase.execute(project),
771
+ useGitRootName,
772
+ showBranch,
773
+ showPath
774
+ }
775
+ )
776
+ );
777
+ } catch (error) {
778
+ const message = error instanceof Error ? error.message : String(error);
779
+ console.error(message);
780
+ process2.exitCode = 1;
781
+ }
782
+ };
783
+ await bootstrap();
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "unity-hub-cli",
3
+ "version": "0.1.0",
4
+ "description": "A CLI tool that reads Unity Hub's projects and launches Unity Editor with an interactive TUI",
5
+ "main": "dist/index.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1",
8
+ "dev": "tsx src/index.ts",
9
+ "build": "tsup",
10
+ "start": "node dist/index.js",
11
+ "lint": "eslint . --ext .ts,.tsx",
12
+ "lint:fix": "eslint . --ext .ts,.tsx --fix",
13
+ "format": "prettier --write .",
14
+ "typecheck": "tsc -noEmit"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/hatayama/UnityHubCli.git"
19
+ },
20
+ "keywords": [
21
+ "unity",
22
+ "unity-hub",
23
+ "cli",
24
+ "tui",
25
+ "ink",
26
+ "terminal",
27
+ "interactive"
28
+ ],
29
+ "author": "masamichi hatayama",
30
+ "license": "MIT",
31
+ "type": "module",
32
+ "bin": {
33
+ "unity-hub-cli": "dist/index.js"
34
+ },
35
+ "files": [
36
+ "dist"
37
+ ],
38
+ "dependencies": {
39
+ "ink": "^4.4.1",
40
+ "react": "^18.3.1"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^20.19.20",
44
+ "@types/react": "^18.3.26",
45
+ "@typescript-eslint/eslint-plugin": "^7.18.0",
46
+ "@typescript-eslint/parser": "^7.18.0",
47
+ "eslint": "^8.57.0",
48
+ "eslint-config-prettier": "^9.1.2",
49
+ "eslint-import-resolver-typescript": "^3.10.1",
50
+ "eslint-plugin-import": "^2.32.0",
51
+ "prettier": "^3.6.2",
52
+ "tsup": "^8.5.0",
53
+ "tsx": "^4.20.6",
54
+ "typescript": "^5.9.3"
55
+ }
56
+ }