@uplift-beyond/upex 0.0.0-stage → 1.5.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,508 @@
1
+ /**
2
+ * launcher.js -- Opens folders in supported apps
3
+ */
4
+
5
+ import { execSync, spawn, spawnSync } from 'node:child_process';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import os from 'node:os';
9
+ import { loadConfig } from './store.js';
10
+
11
+ const platform = os.platform(); // 'win32' | 'darwin' | 'linux'
12
+
13
+ /**
14
+ * Map of app aliases to their launch commands per platform.
15
+ * Each entry: { cmd, displayName, installHint }
16
+ */
17
+ const APP_MAP = {
18
+ vscode: {
19
+ cmd: () => 'code',
20
+ displayName: 'VS Code',
21
+ installHint: 'Install VS Code and add "code" to your PATH.\nhttps://code.visualstudio.com/',
22
+ },
23
+ code: {
24
+ cmd: () => 'code',
25
+ displayName: 'VS Code',
26
+ installHint: 'Install VS Code and add "code" to your PATH.\nhttps://code.visualstudio.com/',
27
+ },
28
+ webstorm: {
29
+ cmd: () => {
30
+ if (platform === 'darwin') return 'open -na "WebStorm" --args';
31
+ return 'webstorm';
32
+ },
33
+ displayName: 'WebStorm',
34
+ installHint: 'Install WebStorm and ensure the CLI launcher is in your PATH.\nhttps://www.jetbrains.com/webstorm/',
35
+ },
36
+ finder: {
37
+ cmd: () => {
38
+ if (platform === 'darwin') return 'open';
39
+ if (platform === 'win32') return 'explorer';
40
+ return 'xdg-open';
41
+ },
42
+ displayName: platform === 'darwin' ? 'Finder' : platform === 'win32' ? 'Explorer' : 'File Manager',
43
+ installHint: 'This is a system app and should be available by default.',
44
+ },
45
+ explorer: {
46
+ cmd: () => {
47
+ if (platform === 'win32') return 'explorer';
48
+ if (platform === 'darwin') return 'open';
49
+ return 'xdg-open';
50
+ },
51
+ displayName: platform === 'win32' ? 'Explorer' : platform === 'darwin' ? 'Finder' : 'File Manager',
52
+ installHint: 'This is a system app and should be available by default.',
53
+ },
54
+ copy: {
55
+ cmd: () => 'copy',
56
+ displayName: 'Copy path',
57
+ installHint: 'This is a built-in action and should always be available.',
58
+ },
59
+ open: {
60
+ cmd: () => {
61
+ if (platform === 'win32') return 'start';
62
+ if (platform === 'darwin') return 'open';
63
+ return 'xdg-open';
64
+ },
65
+ displayName: 'Default App',
66
+ installHint: 'System default handler; always available.',
67
+ },
68
+ cmd: {
69
+ cmd: () => {
70
+ if (platform === 'win32') return 'cmd';
71
+ if (platform === 'darwin') return 'open -a Terminal';
72
+ return 'x-terminal-emulator';
73
+ },
74
+ displayName: platform === 'win32' ? 'Command Prompt' : 'Terminal',
75
+ installHint: 'This is a system app and should be available by default.',
76
+ },
77
+ claude: {
78
+ cmd: () => 'claude',
79
+ displayName: 'Claude',
80
+ installHint: 'Install Claude Code and make sure "claude" is on your PATH.\nhttps://claude.com/claude-code',
81
+ },
82
+ };
83
+
84
+ const INTERACTIVE_APP_KEYS = [
85
+ 'explorer',
86
+ 'vscode',
87
+ 'webstorm',
88
+ 'cmd',
89
+ 'claude',
90
+ 'copy',
91
+ ];
92
+
93
+ // Openers that open terminal windows (and so take a window count).
94
+ export const isTerminalApp = key => key === 'cmd' || key === 'claude';
95
+
96
+ // User-defined openers from ~/.upex/config.json { "apps": { "cursor": "cursor" } }.
97
+ function customApps() {
98
+ const { apps } = loadConfig();
99
+ const out = {};
100
+ if (apps && typeof apps === 'object') {
101
+ for (const [name, command] of Object.entries(apps)) {
102
+ if (typeof command === 'string' && command.trim()) {
103
+ out[name.toLowerCase()] = {
104
+ cmd: () => command,
105
+ displayName: name,
106
+ installHint: `Custom app "${name}" from ~/.upex/config.json (command: ${command}).`,
107
+ };
108
+ }
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+
114
+ // Built-in apps plus any user-defined ones (custom wins on name clash).
115
+ function getAppMap() {
116
+ return { ...APP_MAP, ...customApps() };
117
+ }
118
+
119
+ /**
120
+ * Check if a command exists on the system.
121
+ */
122
+ function commandExists(cmd) {
123
+ // `where` rejects absolute paths.
124
+ if (path.isAbsolute(cmd)) return fileExists(cmd);
125
+ try {
126
+ const check = platform === 'win32' ? `where ${cmd}` : `which ${cmd}`;
127
+ execSync(check, { stdio: 'ignore' });
128
+ return true;
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ function fileExists(filePath) {
135
+ try {
136
+ return fs.existsSync(filePath);
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ // Copy text to the system clipboard via the native platform utility.
143
+ export function copyToClipboard(text) {
144
+ const cmd = platform === 'win32' ? 'clip'
145
+ : platform === 'darwin' ? 'pbcopy'
146
+ : process.env.WAYLAND_DISPLAY && commandExists('wl-copy') ? 'wl-copy' : 'xclip -selection clipboard';
147
+ // Keep non-ASCII paths intact: clip reads UTF-16LE whatever the code page, pbcopy needs a UTF-8 locale.
148
+ const input = platform === 'win32' ? Buffer.from(text, 'utf16le') : text;
149
+ const env = platform === 'darwin' ? { ...process.env, LC_CTYPE: 'UTF-8' } : process.env;
150
+ execSync(cmd, { input, env, stdio: ['pipe', 'ignore', 'ignore'], shell: true });
151
+ }
152
+
153
+ // Caret-escape cmd.exe metacharacters the outer shell sees unquoted. `line` starts inside quotes.
154
+ export function caretEscape(line) {
155
+ let quoted = true;
156
+ let out = '';
157
+ for (const ch of line) {
158
+ if (ch === '"') quoted = !quoted;
159
+ else if (!quoted && '&|<>^'.includes(ch)) out += '^';
160
+ out += ch;
161
+ }
162
+ return out;
163
+ }
164
+
165
+ function quoteIfNeeded(cmd) {
166
+ if (cmd.includes(' ') && !cmd.startsWith('"')) {
167
+ return `"${cmd}"`;
168
+ }
169
+ return cmd;
170
+ }
171
+
172
+ function findFirstExistingFile(paths) {
173
+ return paths.find(fileExists) || null;
174
+ }
175
+
176
+ // Immediate subdirectories of `dir`, newest-named first. Optional name-prefix filter.
177
+ function versionDirs(dir, prefix = '') {
178
+ try {
179
+ return fs.readdirSync(dir)
180
+ .filter(name => !prefix || name.toLowerCase().startsWith(prefix.toLowerCase()))
181
+ .map(name => path.join(dir, name))
182
+ .filter(p => {
183
+ try {
184
+ return fs.statSync(p).isDirectory();
185
+ } catch {
186
+ return false;
187
+ }
188
+ })
189
+ .sort((a, b) => b.localeCompare(a));
190
+ } catch {
191
+ return [];
192
+ }
193
+ }
194
+
195
+ // First bin/webstorm(64).exe found across the given version directories.
196
+ function webStormBinIn(dirs) {
197
+ for (const dir of dirs) {
198
+ const found = findFirstExistingFile([
199
+ path.join(dir, 'bin', 'webstorm64.exe'),
200
+ path.join(dir, 'bin', 'webstorm.exe'),
201
+ ]);
202
+ if (found) return found;
203
+ }
204
+ return null;
205
+ }
206
+
207
+ // Find WebStorm binary inside JetBrains Toolbox versioned directories.
208
+ function findToolboxWebStormExe(localAppData) {
209
+ const toolboxRoot = path.join(localAppData, 'JetBrains', 'Toolbox', 'apps', 'WebStorm');
210
+ for (const channel of ['ch-0', 'ch-1']) {
211
+ const found = webStormBinIn(versionDirs(path.join(toolboxRoot, channel)));
212
+ if (found) return found;
213
+ }
214
+ return null;
215
+ }
216
+
217
+ // Find WebStorm inside versioned JetBrains install folders (e.g. "WebStorm 2026.1.3").
218
+ function findInstalledWebStormExe(roots) {
219
+ for (const root of roots) {
220
+ const found = webStormBinIn(versionDirs(path.join(root, 'JetBrains'), 'WebStorm'));
221
+ if (found) return found;
222
+ }
223
+ return null;
224
+ }
225
+
226
+ // Resolve WebStorm command on Windows even when no `webstorm` CLI launcher exists.
227
+ function resolveWindowsWebStormCommand() {
228
+ const pathCandidates = ['webstorm', 'webstorm64.exe', 'webstorm.exe'];
229
+ for (const candidate of pathCandidates) {
230
+ if (commandExists(candidate)) return candidate;
231
+ }
232
+
233
+ const config = loadConfig();
234
+ if (config.webstormPath && fileExists(config.webstormPath)) {
235
+ return quoteIfNeeded(config.webstormPath);
236
+ }
237
+
238
+ if (process.env.WEBSTORM_PATH && fileExists(process.env.WEBSTORM_PATH)) {
239
+ return quoteIfNeeded(process.env.WEBSTORM_PATH);
240
+ }
241
+
242
+ const localAppData = process.env.LOCALAPPDATA || '';
243
+ const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
244
+ const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
245
+
246
+ const toolboxExe = localAppData ? findToolboxWebStormExe(localAppData) : null;
247
+ if (toolboxExe) return quoteIfNeeded(toolboxExe);
248
+
249
+ // Standard installer puts it in a versioned folder like "JetBrains\WebStorm 2026.1.3".
250
+ const installedExe = findInstalledWebStormExe([programFiles, programFilesX86]);
251
+ if (installedExe) return quoteIfNeeded(installedExe);
252
+
253
+ const diskCandidates = [
254
+ path.join(localAppData, 'JetBrains', 'Toolbox', 'scripts', 'webstorm.cmd'),
255
+ path.join(localAppData, 'Programs', 'WebStorm', 'bin', 'webstorm64.exe'),
256
+ path.join(localAppData, 'Programs', 'WebStorm', 'bin', 'webstorm.exe'),
257
+ path.join(programFiles, 'JetBrains', 'WebStorm', 'bin', 'webstorm64.exe'),
258
+ path.join(programFiles, 'JetBrains', 'WebStorm', 'bin', 'webstorm.exe'),
259
+ path.join(programFilesX86, 'JetBrains', 'WebStorm', 'bin', 'webstorm64.exe'),
260
+ path.join(programFilesX86, 'JetBrains', 'WebStorm', 'bin', 'webstorm.exe'),
261
+ ].filter(Boolean);
262
+
263
+ const found = findFirstExistingFile(diskCandidates);
264
+ return found ? quoteIfNeeded(found) : null;
265
+ }
266
+
267
+ // The command an opener runs, or { error } when it isn't installed.
268
+ function resolveOpener(key, app) {
269
+ let cmdBase = app.cmd();
270
+ // `start` is a cmd.exe builtin, not a resolvable executable.
271
+ let skipCommandCheck = platform === 'win32' && key === 'open';
272
+
273
+ if (platform === 'win32' && key === 'webstorm') {
274
+ const resolved = resolveWindowsWebStormCommand();
275
+ if (!resolved) {
276
+ return {
277
+ error: `WebStorm not found. Set "webstormPath" in ~/.upex/config.json or WEBSTORM_PATH env.\n` +
278
+ app.installHint,
279
+ };
280
+ }
281
+ cmdBase = resolved;
282
+ skipCommandCheck = true;
283
+ }
284
+
285
+ // For multi-word commands like "open -na ..." we only check the first word
286
+ const cmdBin = cmdBase.startsWith('"')
287
+ ? cmdBase.slice(1).split('"')[0]
288
+ : cmdBase.split(' ')[0];
289
+
290
+ if (!skipCommandCheck && !commandExists(cmdBin)) {
291
+ return { error: `Command "${cmdBin}" not found.\n${app.installHint}` };
292
+ }
293
+ return { cmdBase, cmdBin };
294
+ }
295
+
296
+ // Lowercased opener key, or null when no such opener exists.
297
+ // Aliases (code, finder) map to the selector's key so it can preselect them.
298
+ export function openerKey(name) {
299
+ const key = String(name).toLowerCase();
300
+ const map = getAppMap();
301
+ if (!Object.hasOwn(map, key)) return null;
302
+ const same = (k) => map[k] && map[k].displayName === map[key].displayName && map[k].cmd() === map[key].cmd();
303
+ return INTERACTIVE_APP_KEYS.find(same) || key;
304
+ }
305
+
306
+ // Is this opener usable here? For `upex doctor`. Copy needs no command.
307
+ export function checkOpener(key) {
308
+ const app = getAppMap()[key];
309
+ if (!app) return { ok: false, message: `Unknown opener "${key}".` };
310
+ if (key === 'copy') return { ok: true, command: '(built-in)' };
311
+ const r = resolveOpener(key, app);
312
+ return r.error ? { ok: false, message: r.error } : { ok: true, command: r.cmdBase };
313
+ }
314
+
315
+ /**
316
+ * Launch a folder in the specified app.
317
+ * @param {number} [count] - how many windows to open (only honored for the terminal opener)
318
+ * @returns {{ ok: boolean, message?: string }}
319
+ */
320
+ export function launchApp(appName, folderPath, count = 1, startCmd = '') {
321
+ const key = appName.toLowerCase();
322
+ const appMap = getAppMap();
323
+ const app = Object.hasOwn(appMap, key) && appMap[key];
324
+
325
+ if (!app) {
326
+ const supported = [...new Set(Object.keys(appMap))].join(', ');
327
+ return {
328
+ ok: false,
329
+ message: `Unknown app "${appName}". Supported: ${supported}`,
330
+ };
331
+ }
332
+
333
+ const targetIsFile = fileExists(folderPath) && (() => {
334
+ try { return fs.statSync(folderPath).isFile(); } catch { return false; }
335
+ })();
336
+
337
+ // Copy action: put the path on the clipboard instead of launching an app.
338
+ if (key === 'copy') {
339
+ try {
340
+ copyToClipboard(folderPath);
341
+ return { ok: true, displayName: app.displayName, message: `Copied --> ${folderPath}` };
342
+ } catch (err) {
343
+ return { ok: false, message: `Failed to copy path: ${err.message}` };
344
+ }
345
+ }
346
+
347
+ const resolvedCmd = resolveOpener(key, app);
348
+ if (resolvedCmd.error) return { ok: false, message: resolvedCmd.error };
349
+ let { cmdBase } = resolvedCmd;
350
+ const { cmdBin } = resolvedCmd;
351
+
352
+ // Claude runs inside a terminal window.
353
+ if (key === 'claude') cmdBase = APP_MAP.cmd.cmd();
354
+ const runCmd = key === 'claude' ? `claude ${startCmd}`.trim() : startCmd;
355
+
356
+ // Some apps (e.g. explorer.exe on Windows) return non-zero exit codes
357
+ // even on success. We need special handling for system file managers.
358
+ const isSystemFileManager = key !== 'open' && (['explorer', 'finder'].includes(key) ||
359
+ (cmdBin === 'explorer' || cmdBin === 'open' || cmdBin === 'xdg-open'));
360
+
361
+ const isTerminal = isTerminalApp(key);
362
+ const repeat = isTerminal ? Math.min(9, Math.max(1, Math.floor(count) || 1)) : 1;
363
+ // A terminal at a file means "sit in that file's folder".
364
+ const terminalCwd = targetIsFile ? path.dirname(folderPath) : folderPath;
365
+
366
+ try {
367
+ if (platform === 'win32' && isTerminal) {
368
+ // Open N independent Command Prompt windows at the target path.
369
+ // `start` gives each its own console and returns immediately, so the
370
+ // window outlives upex. Run it synchronously (no `detached`) to avoid
371
+ // CREATE_NEW_PROCESS_GROUP, which would disable Ctrl+C inside the window.
372
+ // `cmd /k <start>` runs the start command and keeps the window open.
373
+ // Quoted with /s so && and | run in the new window, not in this shell;
374
+ // carets cover the parts the user quoted themselves ("a & b").
375
+ const inner = runCmd ? `cmd /s /k "${caretEscape(runCmd)}"` : 'cmd';
376
+ for (let i = 0; i < repeat; i++) {
377
+ execSync(`start "" ${inner}`, { cwd: terminalCwd, stdio: 'ignore' });
378
+ }
379
+ } else if (isTerminal && platform === 'darwin') {
380
+ // Open Terminal.app at the dir, optionally run a command, keep it open.
381
+ const script = runCmd
382
+ ? `cd ${JSON.stringify(terminalCwd)} && ${runCmd}`
383
+ : `cd ${JSON.stringify(terminalCwd)}`;
384
+ for (let i = 0; i < repeat; i++) {
385
+ spawnSync('osascript', [
386
+ '-e', 'tell application "Terminal" to do script ' + JSON.stringify(script),
387
+ '-e', 'tell application "Terminal" to activate',
388
+ ], { stdio: 'ignore' });
389
+ }
390
+ } else if (isTerminal) {
391
+ // Linux: cd into the dir, run the command, keep the shell open.
392
+ const inner = `cd ${JSON.stringify(terminalCwd)}; ${runCmd ? runCmd + '; ' : ''}exec "\${SHELL:-bash}"`;
393
+ for (let i = 0; i < repeat; i++) {
394
+ const r = spawnSync(cmdBase, ['-e', 'sh', '-c', inner], { stdio: 'ignore' });
395
+ if (r.error) throw r.error;
396
+ }
397
+ } else if (key === 'open') {
398
+ // Open the path in the OS default handler (default app for a file).
399
+ if (platform === 'win32') {
400
+ execSync('start "" ' + `"${folderPath}"`, { stdio: 'ignore' });
401
+ } else {
402
+ execSync(`${cmdBase} "${folderPath}"`, { stdio: 'ignore' });
403
+ }
404
+ } else if (platform === 'win32' && key === 'webstorm') {
405
+ // Run WebStorm detached on Windows (works for PATH command or absolute exe/cmd path).
406
+ spawn(cmdBase, [folderPath], {
407
+ detached: true,
408
+ stdio: 'ignore',
409
+ shell: true,
410
+ }).unref();
411
+ } else if (isSystemFileManager) {
412
+ // Reveal the file in its folder, or just open a folder directly.
413
+ if (platform === 'win32') {
414
+ const args = targetIsFile ? ['/select,', folderPath] : [folderPath];
415
+ spawn('explorer', args, { detached: true, stdio: 'ignore', shell: false }).unref();
416
+ } else if (platform === 'darwin') {
417
+ execSync(`${cmdBase} ${targetIsFile ? '-R ' : ''}"${folderPath}"`, { stdio: 'ignore' });
418
+ } else {
419
+ const target = targetIsFile ? path.dirname(folderPath) : folderPath;
420
+ execSync(`${cmdBase} "${target}"`, { stdio: 'ignore' });
421
+ }
422
+ } else {
423
+ const fullCmd = `${cmdBase} "${folderPath}"`;
424
+ for (let i = 0; i < repeat; i++) execSync(fullCmd, { stdio: 'ignore' });
425
+ }
426
+ return {
427
+ ok: true,
428
+ displayName: app.displayName,
429
+ message: `${app.displayName}${repeat > 1 ? ` x${repeat}` : ''}${startCmd ? ` (${startCmd})` : ''} --> ${folderPath}`,
430
+ };
431
+ } catch (err) {
432
+ return {
433
+ ok: false,
434
+ message: `Failed to open with ${app.displayName}: ${err.message}`,
435
+ };
436
+ }
437
+ }
438
+
439
+ // Prefix a bare host with https:// so "github.com" is a valid URL.
440
+ export function normalizeUrl(url) {
441
+ return /^[a-z]+:\/\//i.test(url) ? url : `https://${url}`;
442
+ }
443
+
444
+ /**
445
+ * Open a URL in the system default browser.
446
+ */
447
+ export function launchUrl(url) {
448
+ const target = normalizeUrl(url);
449
+ try {
450
+ if (platform === 'win32') execSync(`start "" "${target}"`, { stdio: 'ignore' });
451
+ else if (platform === 'darwin') execSync(`open "${target}"`, { stdio: 'ignore' });
452
+ else execSync(`xdg-open "${target}"`, { stdio: 'ignore' });
453
+ return { ok: true, displayName: 'Browser', url: target };
454
+ } catch (err) {
455
+ return { ok: false, message: `Failed to open URL: ${err.message}` };
456
+ }
457
+ }
458
+
459
+ export function getInteractiveAppChoices() {
460
+ const appMap = getAppMap();
461
+ const keys = [...INTERACTIVE_APP_KEYS, ...Object.keys(customApps())];
462
+ return keys
463
+ .filter(key => appMap[key])
464
+ .map(key => ({ key, label: appMap[key].displayName }));
465
+ }
466
+
467
+ export { APP_MAP, getAppMap };
468
+
469
+ /**
470
+ * Launch an arbitrary executable (for app shortcuts).
471
+ * @param {string} exePath - command name or absolute path to the executable
472
+ * @returns {{ ok: boolean, displayName: string, message?: string }}
473
+ */
474
+ export function launchExecutable(exePath) {
475
+ try {
476
+ let child;
477
+
478
+ if (platform === 'win32' && /\.(bat|cmd)$/i.test(exePath)) {
479
+ // .bat/.cmd files must run via cmd.exe with cwd set to the batch file's
480
+ // own directory so that relative paths inside the script resolve correctly.
481
+ // windowsHide suppresses the intermediate cmd.exe console window.
482
+ child = spawn('cmd.exe', ['/c', exePath], {
483
+ detached: true,
484
+ stdio: 'ignore',
485
+ shell: false,
486
+ windowsHide: true,
487
+ cwd: path.dirname(exePath),
488
+ });
489
+ } else {
490
+ // Quote only a real file path with spaces; anything else is a command line with args.
491
+ const cmd = exePath.includes(' ') && !exePath.startsWith('"') && fileExists(exePath) ? `"${exePath}"` : exePath;
492
+ child = spawn(cmd, [], {
493
+ detached: true,
494
+ stdio: 'ignore',
495
+ shell: true,
496
+ windowsHide: true,
497
+ });
498
+ }
499
+
500
+ child.unref();
501
+
502
+ const displayName = path.basename(exePath).replace(/\.[^.]+$/, '');
503
+ return { ok: true, displayName };
504
+ } catch (err) {
505
+ return { ok: false, displayName: exePath, message: `FAIL launch: ${err.message}` };
506
+ }
507
+ }
508
+