@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,75 @@
1
+ /**
2
+ * completion.js -- Shell completion scripts + the hidden __names helper.
3
+ * Usage: upex completion [bash|zsh|fish|powershell]
4
+ * e.g. eval "$(upex completion bash)" (bash/zsh)
5
+ * upex completion powershell | Out-String | Invoke-Expression
6
+ */
7
+
8
+ import os from 'node:os';
9
+ import { loadLinks } from '../store.js';
10
+ import * as ui from '../ui.js';
11
+
12
+ // SUBCOMMANDS comes from the registered commands, so new ones complete with no manual list.
13
+ const scripts = (SUBCOMMANDS) => ({
14
+ bash: `_upex() {
15
+ local cur="\${COMP_WORDS[COMP_CWORD]}"
16
+ if [ "$COMP_CWORD" -eq 1 ]; then
17
+ COMPREPLY=( $(compgen -W "${SUBCOMMANDS} $(upex __names)" -- "$cur") )
18
+ else
19
+ COMPREPLY=( $(compgen -W "$(upex __names)" -- "$cur") )
20
+ fi
21
+ }
22
+ complete -F _upex upex`,
23
+
24
+ zsh: `_upex() {
25
+ local -a cmds names
26
+ cmds=(${SUBCOMMANDS})
27
+ names=(\${(f)"$(upex __names)"})
28
+ compadd -- $cmds $names
29
+ }
30
+ compdef _upex upex`,
31
+
32
+ fish: `complete -c upex -f -a "(upex __names)"
33
+ complete -c upex -n "__fish_use_subcommand" -a "${SUBCOMMANDS}"`,
34
+
35
+ powershell: `Register-ArgumentCompleter -Native -CommandName upex -ScriptBlock {
36
+ param($wordToComplete, $commandAst, $cursorPosition)
37
+ $cmds = @('${SUBCOMMANDS.split(' ').join("','")}')
38
+ $names = @(upex __names)
39
+ ($cmds + $names) | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
40
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
41
+ }
42
+ }`,
43
+ });
44
+
45
+ function detectShell() {
46
+ if (os.platform() === 'win32') return 'powershell';
47
+ const shell = process.env.SHELL || '';
48
+ if (shell.includes('zsh')) return 'zsh';
49
+ if (shell.includes('fish')) return 'fish';
50
+ return 'bash';
51
+ }
52
+
53
+ export function registerCompletion(program) {
54
+ program
55
+ .command('__names', { hidden: true })
56
+ .description('(internal) Print all shortcut names for completion')
57
+ .action(() => {
58
+ process.stdout.write(Object.keys(loadLinks()).join('\n'));
59
+ });
60
+
61
+ program
62
+ .command('completion [shell]')
63
+ .description('Print a shell completion script (bash|zsh|fish|powershell)')
64
+ .summary('Print a shell completion script')
65
+ .action((shell) => {
66
+ const key = (shell || detectShell()).toLowerCase();
67
+ const names = program.createHelp().visibleCommands(program).flatMap(c => [c.name(), ...c.aliases()]);
68
+ const script = scripts(names.join(' '))[key === 'pwsh' ? 'powershell' : key];
69
+ if (!script) {
70
+ ui.error(`No completion for "${shell}". Supported: bash, zsh, fish, powershell`);
71
+ process.exit(1);
72
+ }
73
+ process.stdout.write(script + '\n');
74
+ });
75
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * copy.js -- Copy a shortcut's path to the clipboard.
3
+ * Usage: upex copy <name>
4
+ */
5
+
6
+ import { loadLinks } from '../store.js';
7
+ import { copyToClipboard } from '../launcher.js';
8
+ import { resolveName, didYouMean } from '../fuzzy.js';
9
+ import * as ui from '../ui.js';
10
+
11
+ export function registerCopy(program) {
12
+ program
13
+ .command('copy <name>')
14
+ .description('Copy shortcut path to clipboard')
15
+ .action((name) => {
16
+ const links = loadLinks();
17
+ const resolved = resolveName(name, links);
18
+
19
+ if (!resolved) {
20
+ ui.error(`Shortcut not found: ${name}`);
21
+ const hint = didYouMean(name, Object.keys(links));
22
+ if (hint) ui.dim(hint);
23
+ process.exit(1);
24
+ }
25
+
26
+ const entry = links[resolved.name];
27
+ const targetPath = entry.path || entry.url;
28
+
29
+ if (!targetPath) {
30
+ ui.warn(`"${resolved.name}" is an app shortcut with no folder path.`);
31
+ if (entry.app) {
32
+ ui.dim(`App executable: ${entry.app}`);
33
+ }
34
+ process.exit(1);
35
+ }
36
+
37
+ try {
38
+ copyToClipboard(targetPath);
39
+ } catch (err) {
40
+ ui.error(`Failed to copy: ${err.message}`);
41
+ process.exit(1);
42
+ }
43
+
44
+ if (!resolved.exact) {
45
+ ui.dim(`matched "${name}" --> "${resolved.name}"`);
46
+ }
47
+
48
+ ui.success(`Copied: ${targetPath}`);
49
+ });
50
+ }
51
+
@@ -0,0 +1,130 @@
1
+ /**
2
+ * doctor.js -- Check that upex is set up and healthy.
3
+ * Usage: upex doctor
4
+ */
5
+
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { spawnSync } from 'node:child_process';
10
+ import chalk from 'chalk';
11
+ import { loadLinks, loadConfig, loadWorkspaces, LINKS_FILE } from '../store.js';
12
+ import { getAppMap, checkOpener, openerKey } from '../launcher.js';
13
+ import { CMD_WRAPPER, findShimDir } from './setup.js';
14
+ import { resolveTarget } from '../fuzzy.js';
15
+ import * as ui from '../ui.js';
16
+
17
+ const platform = os.platform();
18
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
19
+ const readText = (f) => { try { return fs.readFileSync(f, 'utf-8'); } catch { return ''; } };
20
+
21
+ // Shell profiles that could hold the cd wrapper or completion.
22
+ function profileFiles() {
23
+ const home = os.homedir();
24
+ const files = [
25
+ path.join(home, '.bashrc'),
26
+ path.join(home, '.bash_profile'),
27
+ path.join(home, '.zshrc'),
28
+ path.join(home, '.config', 'fish', 'config.fish'),
29
+ path.join(home, '.config', 'fish', 'functions', 'upex.fish'),
30
+ ];
31
+ let docs = null;
32
+ if (platform === 'win32') {
33
+ // Ask Windows: Documents is often moved into OneDrive.
34
+ // UTF-8 so a non-ASCII Documents path survives the pipe.
35
+ const ps = '[Console]::OutputEncoding=[Text.Encoding]::UTF8; [Environment]::GetFolderPath("MyDocuments")';
36
+ const r = spawnSync('powershell.exe', ['-NoProfile', '-Command', ps], { encoding: 'utf-8', timeout: 5000 });
37
+ docs = r.stdout?.trim() || path.join(home, 'Documents');
38
+ }
39
+ for (const dir of docs ? ['PowerShell', 'WindowsPowerShell'] : ['powershell']) {
40
+ for (const name of ['Microsoft.PowerShell_profile.ps1', 'profile.ps1']) {
41
+ files.push(path.join(docs || path.join(home, '.config'), dir, name));
42
+ }
43
+ }
44
+ return files.filter(f => fs.existsSync(f));
45
+ }
46
+
47
+ function shellChecks() {
48
+ const out = [];
49
+ let cmdWrapper = false;
50
+
51
+ if (platform === 'win32') {
52
+ const shimDir = findShimDir();
53
+ const bat = shimDir && path.join(shimDir, 'upex.bat');
54
+ if (!shimDir) out.push(['warn', 'cmd wrapper', 'upex is not installed globally (npm link)']);
55
+ else if (!fs.existsSync(bat)) out.push(['warn', 'cmd wrapper', 'missing, run: upex setup cmd']);
56
+ else if (readText(bat) !== CMD_WRAPPER) out.push(['warn', 'cmd wrapper', 'outdated, run: upex setup cmd']);
57
+ else { out.push(['ok', 'cmd wrapper', bat]); cmdWrapper = true; }
58
+ }
59
+
60
+ const profiles = profileFiles().map(f => ({ f, text: readText(f) }));
61
+ const withWrapper = profiles.filter(p => p.text.includes('__resolve')).map(p => p.f);
62
+ const withCompletion = profiles.filter(p => p.text.includes('upex completion') || p.text.includes('__names')).map(p => p.f);
63
+ if (withWrapper.length) out.push(['ok', 'cd wrapper', withWrapper.join(', ')]);
64
+ else if (!cmdWrapper) out.push(['warn', 'cd wrapper', 'not in any shell profile, see: upex setup']);
65
+ // Completion is optional, so a missing one is only a hint.
66
+ out.push(withCompletion.length
67
+ ? ['ok', 'completion', withCompletion.join(', ')]
68
+ : ['skip', 'completion', 'not set up (optional), see: upex completion --help']);
69
+ return out;
70
+ }
71
+
72
+ // Rows of [status, check, detail]; status is ok | warn | fail | skip.
73
+ export function runChecks() {
74
+ let links;
75
+ try {
76
+ links = loadLinks();
77
+ loadConfig();
78
+ } catch (err) {
79
+ return [['fail', 'store', err.message.split('\n')[0]]];
80
+ }
81
+ const rows = [['ok', 'store', `${plural(Object.keys(links).length, 'shortcut')} in ${LINKS_FILE}`]];
82
+
83
+ const broken = Object.keys(links).filter(n => links[n].path && !fs.existsSync(links[n].path));
84
+ rows.push(broken.length
85
+ ? ['warn', 'paths', `${plural(broken.length, 'broken shortcut')}: ${broken.join(', ')} (upex check)`]
86
+ : ['ok', 'paths', 'all paths exist']);
87
+
88
+ const badOpener = Object.keys(links).filter(n => links[n].opener && !openerKey(links[n].opener));
89
+ if (badOpener.length) rows.push(['warn', 'default openers', `unknown opener on: ${badOpener.join(', ')} (upex edit <name> --no-opener)`]);
90
+
91
+ const missing = [];
92
+ for (const [ws, entries] of Object.entries(loadWorkspaces())) {
93
+ for (const e of Array.isArray(entries) ? entries : []) {
94
+ // Subpaths ("web/src") count; fuzzy matches don't.
95
+ const ok = typeof e?.shortcut === 'string' && resolveTarget(e.shortcut, links)?.exact;
96
+ if (!ok) missing.push(`${ws}:${e?.shortcut ?? '?'}`);
97
+ }
98
+ }
99
+ if (missing.length) rows.push(['warn', 'workspaces', `missing shortcuts: ${missing.join(', ')}`]);
100
+
101
+ // One row per opener; aliases like code/vscode share a display name.
102
+ const seen = new Set(['Copy path']);
103
+ for (const [key, app] of Object.entries(getAppMap())) {
104
+ if (seen.has(app.displayName)) continue;
105
+ seen.add(app.displayName);
106
+ const r = checkOpener(key);
107
+ rows.push(r.ok ? ['ok', app.displayName, r.command] : ['warn', app.displayName, r.message.split('\n')[0]]);
108
+ }
109
+
110
+ return [...rows, ...shellChecks()];
111
+ }
112
+
113
+ export function registerDoctor(program) {
114
+ program
115
+ .command('doctor')
116
+ .description('Check the store, openers, shell wrapper and completion')
117
+ .action(() => {
118
+ ui.heading('upex doctor');
119
+ const rows = runChecks();
120
+ ui.table(rows.map(([status, check, detail]) => [ui.tag[status], chalk.bold(check), detail]));
121
+ ui.blank();
122
+ const warns = rows.filter(r => r[0] === 'warn').length;
123
+ const fails = rows.filter(r => r[0] === 'fail').length;
124
+ if (fails) ui.error(`${plural(fails, 'problem')} found.`);
125
+ else if (warns) ui.warn(`${plural(warns, 'warning')}, everything else is fine.`);
126
+ else ui.success('All good.');
127
+ ui.blank();
128
+ if (fails) process.exit(1);
129
+ });
130
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * edit.js -- Update the target of an existing shortcut.
3
+ * Usage: upex edit <name> [target] [--opener <app> | --no-opener]
4
+ * folder shortcut: target is the new path (defaults to cwd)
5
+ * URL / app shortcut: target is the new URL / command (required)
6
+ */
7
+
8
+ import path from 'node:path';
9
+ import fs from 'node:fs';
10
+ import { setLink, loadLinks, targetField } from '../store.js';
11
+ import { resolveName } from '../fuzzy.js';
12
+ import { openerKey } from '../launcher.js';
13
+ import * as ui from '../ui.js';
14
+
15
+ // Entry with the opener set (string) or cleared (false); null after printing why not.
16
+ export function withOpener(entry, opener) {
17
+ const { opener: _old, ...rest } = entry;
18
+ if (opener === false) return rest;
19
+ if (!entry.path) {
20
+ ui.error('A default opener only applies to folder shortcuts.');
21
+ return null;
22
+ }
23
+ const key = openerKey(opener);
24
+ if (!key) {
25
+ ui.error(`Unknown opener "${opener}". Add custom ones with "upex app".`);
26
+ return null;
27
+ }
28
+ return { ...rest, opener: key };
29
+ }
30
+
31
+ export function registerEdit(program) {
32
+ program
33
+ .command('edit <name> [target]')
34
+ .description('Update a shortcut\'s path (or URL / app command) or its default opener')
35
+ .summary('Update a shortcut\'s path, URL, command or opener')
36
+ .option('-o, --opener <app>', 'Open this folder shortcut in <app> by default')
37
+ .option('--no-opener', 'Clear the default opener')
38
+ .action((name, target, options) => {
39
+ const links = loadLinks();
40
+ const resolved = resolveName(name, links);
41
+
42
+ if (!resolved) {
43
+ ui.error(`Shortcut not found: ${name}`);
44
+ return;
45
+ }
46
+
47
+ if (!resolved.exact) {
48
+ ui.warn(`No exact match. Did you mean "${resolved.name}"?`);
49
+ ui.info(`Run: upex edit ${resolved.name}${target ? ' ' + target : ''}`);
50
+ return;
51
+ }
52
+
53
+ const entry = links[name];
54
+ const field = targetField(entry);
55
+
56
+ // Only the opener changes: leave the target alone.
57
+ if (options.opener !== undefined && !target) {
58
+ const next = withOpener(entry, options.opener);
59
+ if (!next) process.exit(1);
60
+ setLink(name, next);
61
+ ui.success(next.opener ? `${name} now opens in ${next.opener}` : `${name}: default opener cleared`);
62
+ return;
63
+ }
64
+
65
+ let value = target;
66
+
67
+ if (field === 'path') {
68
+ value = target ? path.resolve(target) : process.cwd();
69
+ if (!fs.existsSync(value)) {
70
+ ui.error(`Path does not exist: ${value}`);
71
+ process.exit(1);
72
+ }
73
+ } else if (!value) {
74
+ ui.error(`Provide the new ${field === 'url' ? 'URL' : 'app path or command'}: upex edit ${name} <${field}>`);
75
+ process.exit(1);
76
+ }
77
+
78
+ const next = options.opener === undefined ? entry : withOpener(entry, options.opener);
79
+ if (!next) process.exit(1);
80
+ setLink(name, { ...next, [field]: value });
81
+ ui.success(`Updated: ${name}`);
82
+ ui.dim(`Old: ${entry[field] || '(none)'}`);
83
+ ui.dim(`New: ${value}`);
84
+ });
85
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * help.js -- Show structured command reference, built from the registered commands.
3
+ */
4
+
5
+ import chalk from 'chalk';
6
+ import * as ui from '../ui.js';
7
+
8
+ // "<name> [target...] [--tag <tags>]" from a command's arguments and options.
9
+ function argsOf(cmd) {
10
+ const args = cmd.registeredArguments.map((a) => {
11
+ const n = a.name() + (a.variadic ? '...' : '');
12
+ return a.required ? `<${n}>` : `[${n}]`;
13
+ });
14
+ const opts = cmd.options.map(o => `[${o.flags.replace(/^-\w, /, '')}]`);
15
+ return [...args, ...opts].join(' ');
16
+ }
17
+
18
+ export function registerHelp(program) {
19
+ program
20
+ .command('help')
21
+ .description('Show all available commands')
22
+ .action(() => {
23
+ ui.heading('upex -- command reference');
24
+
25
+ // The default action (no sub-command) has no Command object, so it is listed by hand.
26
+ const help = program.createHelp();
27
+ const rows = [
28
+ ['upex', '[--app <app>]', 'Open the interactive selector and choose shortcut/app'],
29
+ ...help.visibleCommands(program).map(c => [
30
+ [c.name(), ...c.aliases()].join('|'),
31
+ argsOf(c),
32
+ help.subcommandDescription(c),
33
+ ]),
34
+ ['<name>', '[app] [count]', 'Open shortcut (app: vscode, cmd, claude, ...)'],
35
+ ];
36
+
37
+ ui.table([
38
+ [chalk.bold('COMMAND'), chalk.bold('ARGS'), chalk.bold('DESCRIPTION')],
39
+ ...rows.map(([cmd, args, desc]) => [chalk.bold(cmd), args || chalk.dim('--'), desc]),
40
+ ], { header: true });
41
+ ui.blank();
42
+ ui.dim('Run "upex <command> --help" for details.');
43
+ ui.blank();
44
+ });
45
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * last.js -- Reopen the most recently used shortcut.
3
+ * Usage: upex last (or the shorthand: upex -)
4
+ */
5
+
6
+ import { lastUsedName } from '../store.js';
7
+ import { handleOpen } from './open.js';
8
+ import * as ui from '../ui.js';
9
+
10
+ export function registerLast(program) {
11
+ program
12
+ .command('last')
13
+ .description('Reopen the most recently used shortcut')
14
+ .summary('Reopen the most recent shortcut (alias: upex -)')
15
+ .action(() => {
16
+ const name = lastUsedName();
17
+ if (!name) {
18
+ ui.warn('No shortcut has been used yet.');
19
+ process.exit(1);
20
+ }
21
+ return handleOpen(name);
22
+ });
23
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * list.js -- Show all saved shortcuts.
3
+ * Usage: upex list
4
+ */
5
+
6
+ import fs from 'node:fs';
7
+ import nodePath from 'node:path';
8
+ import chalk from 'chalk';
9
+ import { loadLinks } from '../store.js';
10
+ import { gitStatus } from '../git.js';
11
+ import * as ui from '../ui.js';
12
+
13
+ // Compact git cell: "main" (clean) or "main*" (dirty).
14
+ function gitCell(filepath) {
15
+ const git = filepath ? gitStatus(filepath) : null;
16
+ if (!git) return chalk.dim('--');
17
+ return git.dirty ? chalk.yellow(`${git.branch}*`) : chalk.magenta(git.branch);
18
+ }
19
+
20
+ export function registerList(program) {
21
+ program
22
+ .command('list [tag]')
23
+ .alias('ls')
24
+ .description('List all saved shortcuts (optionally filtered by tag)')
25
+ .option('-g, --git', 'Show each folder\'s current git branch')
26
+ .action((tag, options) => {
27
+ const showGit = !!options.git;
28
+ const links = loadLinks();
29
+ let entries = Object.entries(links);
30
+ if (tag) {
31
+ entries = entries.filter(([, e]) => Array.isArray(e.tags) && e.tags.includes(tag));
32
+ }
33
+
34
+ if (entries.length === 0) {
35
+ ui.blank();
36
+ ui.info(tag ? `No shortcuts tagged "${tag}".` : 'No shortcuts saved. Use: upex add <name> [path]');
37
+ ui.blank();
38
+ return;
39
+ }
40
+
41
+ ui.heading(tag ? `Shortcuts tagged #${tag}` : 'Saved Shortcuts');
42
+
43
+ const header = [chalk.bold('#'), chalk.bold('NAME'), chalk.bold('APP'), chalk.bold('PATH')];
44
+ if (showGit) header.push(chalk.bold('GIT'));
45
+ header.push(chalk.bold('TAGS'), chalk.bold('STATUS'));
46
+
47
+ const rows = [
48
+ header,
49
+ ...entries.map(([name, entry], i) => {
50
+ const filepath = entry.path;
51
+ const appLabel = entry.terminal ? chalk.cyan('cmd')
52
+ : entry.app
53
+ ? chalk.cyan(nodePath.basename(entry.app).replace(/\.[^.]+$/, ''))
54
+ : entry.url ? chalk.cyan('url') : chalk.dim(entry.opener || '--');
55
+ const exists = filepath ? fs.existsSync(filepath) : !!(entry.app || entry.url);
56
+ const pathDisplay = filepath
57
+ ? (exists ? filepath : chalk.dim(filepath))
58
+ : entry.url ? chalk.blue(entry.url) : entry.terminal ? entry.app : chalk.dim('(app only)');
59
+ const status = exists ? ui.tag.ok : ui.tag.fail;
60
+ const tags = Array.isArray(entry.tags) && entry.tags.length
61
+ ? chalk.dim(entry.tags.join(', '))
62
+ : chalk.dim('--');
63
+ const row = [chalk.dim(String(i + 1)), chalk.bold(name), appLabel, pathDisplay];
64
+ if (showGit) row.push(gitCell(filepath));
65
+ row.push(tags, status);
66
+ return row;
67
+ }),
68
+ ];
69
+
70
+ ui.table(rows, { header: true });
71
+ ui.blank();
72
+ ui.separator();
73
+ ui.dim(`${entries.length} shortcut${entries.length === 1 ? '' : 's'}${tag ? ` tagged #${tag}` : ' total'}`);
74
+ ui.blank();
75
+ });
76
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * open.js -- Open a shortcut path or launch it in an app.
3
+ * Default command: upex <name> [app]
4
+ * Hidden command: upex __resolve <name>
5
+ */
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import os from 'node:os';
10
+ import { spawnSync } from 'node:child_process';
11
+ import chalk from 'chalk';
12
+ import { loadLinks, recordUse } from '../store.js';
13
+ import { resolveTarget, didYouMean } from '../fuzzy.js';
14
+ import { launchApp, launchExecutable, launchUrl } from '../launcher.js';
15
+ import * as ui from '../ui.js';
16
+
17
+ function isFile(p) {
18
+ try { return fs.statSync(p).isFile(); } catch { return false; }
19
+ }
20
+
21
+ // Folder path of a shortcut plus an optional subpath ("web/src").
22
+ const withSub = (entry, sub) => (entry.path && sub ? path.join(entry.path, sub) : entry.path);
23
+
24
+ // Resolve a name to a usable folder path and record the use.
25
+ // On failure prints nothing (or `loud` message) and exits 1.
26
+ function resolveFolder(name, { loud = false } = {}) {
27
+ const links = loadLinks();
28
+ const resolved = resolveTarget(name, links);
29
+
30
+ if (!resolved) {
31
+ if (loud) ui.error(`Shortcut not found: ${name}`);
32
+ process.exit(1);
33
+ }
34
+
35
+ const entry = links[resolved.name];
36
+ const targetPath = withSub(entry, resolved.sub);
37
+
38
+ if (!targetPath || !fs.existsSync(targetPath)) {
39
+ if (loud) ui.error(targetPath ? `Path does not exist: ${targetPath}` : `No folder path for "${resolved.name}".`);
40
+ process.exit(1);
41
+ }
42
+
43
+ recordUse(resolved.name);
44
+ return targetPath;
45
+ }
46
+
47
+ // Raw path to stdout (no formatting) -- consumed by `cd $(upex go <name>)`.
48
+ function resolveToStdout(name, opts) {
49
+ process.stdout.write(resolveFolder(name, opts));
50
+ }
51
+
52
+ export function registerResolve(program) {
53
+ program
54
+ .command('__resolve <name>', { hidden: true })
55
+ .description('(internal) Resolve a shortcut name to its path')
56
+ // Wrappers send a lone `upex --help` here; exit 1 so they fall through to real help.
57
+ .helpOption(false)
58
+ .action((name) => {
59
+ // Wrappers route every bare arg through here; never let fuzzy matching
60
+ // shadow a real subcommand (e.g. "list" hijacked into a cd).
61
+ const taken = program.commands.some(c => c.name() === name || c.aliases().includes(name));
62
+ if (taken) process.exit(1);
63
+ resolveToStdout(name);
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Public path-printing command: `cd $(upex go <name>)`.
69
+ */
70
+ export function registerGo(program) {
71
+ program
72
+ .command('go <name> [start...]')
73
+ .description('Print a shortcut\'s path (cd via shell wrapper); extra args run as a command there')
74
+ .summary('Print a path (cd via wrapper); extra args run there')
75
+ // Let flags in the start command (e.g. "claude --model x") pass through.
76
+ .enablePositionalOptions()
77
+ .passThroughOptions()
78
+ .allowUnknownOption()
79
+ .action((name, start = []) => {
80
+ if (!start.length) return resolveToStdout(name, { loud: true });
81
+
82
+ const targetPath = resolveFolder(name, { loud: true });
83
+ const cwd = isFile(targetPath) ? path.dirname(targetPath) : targetPath;
84
+ const r = spawnSync(start.join(' '), { cwd, stdio: 'inherit', shell: true });
85
+ process.exit(r.status ?? 1);
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Handle the default open/launch action.
91
+ * Returns true when opened; in batch mode a failure returns false instead of exiting.
92
+ */
93
+ export async function handleOpen(name, app, count = 1, startCmd = '', { batch = false } = {}) {
94
+ // In batch mode (group/workspace open) a bad shortcut must not exit the whole run.
95
+ const bail = (fn) => { fn(); if (batch) return true; process.exit(1); };
96
+
97
+ const links = loadLinks();
98
+ const resolved = resolveTarget(name, links);
99
+
100
+ if (!resolved) {
101
+ if (bail(() => {
102
+ ui.error(`Shortcut not found: ${name}`);
103
+ const hint = didYouMean(name, Object.keys(links));
104
+ if (hint) ui.dim(hint);
105
+ })) return false;
106
+ }
107
+
108
+ const shortcutName = resolved.name;
109
+ const entry = links[shortcutName];
110
+
111
+ if (resolved.sub && !entry.path) {
112
+ if (bail(() => ui.error(`"${shortcutName}" has no folder, so "${resolved.sub}" can't be opened inside it.`))) return false;
113
+ }
114
+
115
+ // ── URL shortcut: open in the browser directly ──
116
+ if (entry.url) {
117
+ const result = launchUrl(entry.url);
118
+ if (!result.ok) {
119
+ if (bail(() => ui.error(result.message))) return false;
120
+ }
121
+ recordUse(shortcutName);
122
+ if (!resolved.exact) {
123
+ ui.dim(`matched "${name}" --> "${shortcutName}"`);
124
+ }
125
+ ui.heading(shortcutName);
126
+ console.log(` ${chalk.dim('url')} ${chalk.bold(result.url)}`);
127
+ ui.blank();
128
+ return true;
129
+ }
130
+
131
+ // ── App shortcut: launch stored executable directly ──
132
+ if (entry.app && !app && !resolved.sub) {
133
+ // A command line opens a terminal in the home folder and runs there.
134
+ const result = entry.terminal ? launchApp('cmd', os.homedir(), 1, entry.app) : launchExecutable(entry.app);
135
+
136
+ if (!result.ok) {
137
+ if (bail(() => ui.error(result.message))) return false;
138
+ }
139
+
140
+ recordUse(shortcutName);
141
+
142
+ if (!resolved.exact) {
143
+ ui.dim(`matched "${name}" --> "${shortcutName}"`);
144
+ }
145
+
146
+ ui.heading(shortcutName);
147
+ console.log(` ${chalk.dim('app')} ${chalk.bold(result.displayName)}`);
148
+ if (entry.terminal) console.log(` ${chalk.dim('run')} ${entry.app}`);
149
+ if (entry.path) {
150
+ console.log(` ${chalk.dim('path')} ${entry.path}`);
151
+ }
152
+ ui.blank();
153
+ return true;
154
+ }
155
+
156
+ // ── Folder shortcut: open in specified or default opener ──
157
+ const targetPath = withSub(entry, resolved.sub);
158
+
159
+ if (!targetPath) {
160
+ if (bail(() => {
161
+ ui.warn(`No folder path stored for "${shortcutName}".`);
162
+ ui.dim(`This is an app shortcut. Run without an opener: upex ${shortcutName}`);
163
+ })) return false;
164
+ }
165
+
166
+ if (!fs.existsSync(targetPath)) {
167
+ if (bail(() => {
168
+ ui.warn(`Path does not exist: ${targetPath}`);
169
+ ui.dim(`Use "upex edit ${shortcutName} <newPath>" to update.`);
170
+ })) return false;
171
+ }
172
+
173
+ // No opener given: the shortcut's own, else a file opens in its default app and a folder in Explorer.
174
+ const appKey = app || entry.opener || (isFile(targetPath) ? 'open' : 'explorer');
175
+ const result = launchApp(appKey, targetPath, count, startCmd);
176
+
177
+ if (!result.ok) {
178
+ if (bail(() => ui.error(result.message))) return false;
179
+ }
180
+
181
+ recordUse(shortcutName);
182
+
183
+ // -- Structured output card --
184
+ if (!resolved.exact) {
185
+ ui.dim(`matched "${name}" --> "${shortcutName}"`);
186
+ }
187
+
188
+ ui.heading(shortcutName);
189
+
190
+ const appLabel = result.displayName || appKey;
191
+ console.log(` ${chalk.dim('app')} ${chalk.bold(appLabel)}`);
192
+ console.log(` ${chalk.dim('path')} ${targetPath}`);
193
+ ui.blank();
194
+ return true;
195
+ }