@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,35 @@
1
+ /**
2
+ * remove.js -- Delete a saved shortcut.
3
+ * Usage: upex remove <name>
4
+ */
5
+
6
+ import { removeLink, loadLinks } from '../store.js';
7
+ import { resolveName } from '../fuzzy.js';
8
+ import * as ui from '../ui.js';
9
+
10
+ export function registerRemove(program) {
11
+ program
12
+ .command('remove <name>')
13
+ .alias('rm')
14
+ .description('Remove a saved shortcut')
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
+ return;
22
+ }
23
+
24
+ if (!resolved.exact) {
25
+ ui.warn(`No exact match. Did you mean "${resolved.name}"?`);
26
+ ui.info(`Run: upex remove ${resolved.name}`);
27
+ return;
28
+ }
29
+
30
+ const entry = links[name];
31
+ removeLink(name);
32
+ ui.success(`Removed: ${name} --> ${entry.path || entry.app || entry.url}`);
33
+ });
34
+ }
35
+
@@ -0,0 +1,47 @@
1
+ /**
2
+ * rename.js -- Rename a shortcut key
3
+ * Usage: upex rename <oldName> <newName>
4
+ */
5
+
6
+ import { renameLink, loadLinks, nameError } from '../store.js';
7
+ import { resolveName } from '../fuzzy.js';
8
+ import * as ui from '../ui.js';
9
+
10
+ export function registerRename(program) {
11
+ program
12
+ .command('rename <oldName> <newName>')
13
+ .description('Rename a shortcut')
14
+ .action((oldName, newName) => {
15
+ const links = loadLinks();
16
+ const resolved = resolveName(oldName, links);
17
+
18
+ if (!resolved) {
19
+ ui.error(`Shortcut not found: ${oldName}`);
20
+ return;
21
+ }
22
+
23
+ if (!resolved.exact) {
24
+ ui.warn(`No exact match. Did you mean "${resolved.name}"?`);
25
+ ui.info(`Run: upex rename ${resolved.name} ${newName}`);
26
+ return;
27
+ }
28
+
29
+ const bad = nameError(newName);
30
+ if (bad) {
31
+ ui.error(bad);
32
+ return;
33
+ }
34
+
35
+ const result = renameLink(oldName, newName);
36
+ if (!result.ok) {
37
+ if (result.reason === 'exists') {
38
+ ui.error(`"${newName}" already exists. Remove it first or pick a different name.`);
39
+ } else {
40
+ ui.error(`Shortcut not found: ${oldName}`);
41
+ }
42
+ return;
43
+ }
44
+
45
+ ui.success(`Renamed: ${oldName} --> ${newName}`);
46
+ });
47
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * run.js -- Run a package.json script in a terminal at a shortcut's folder.
3
+ * Usage: upex run <name> [script]
4
+ * upex run velora -- list available scripts
5
+ * upex run velora dev -- open a terminal running "<pm> run dev" at velora
6
+ */
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import chalk from 'chalk';
11
+ import { loadLinks } from '../store.js';
12
+ import { resolveName } from '../fuzzy.js';
13
+ import { handleOpen } from './open.js';
14
+ import * as ui from '../ui.js';
15
+
16
+ // Pick a package manager from the lockfile present in the folder (npm default).
17
+ function detectPm(dir) {
18
+ if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
19
+ if (fs.existsSync(path.join(dir, 'yarn.lock'))) return 'yarn';
20
+ if (fs.existsSync(path.join(dir, 'bun.lockb')) || fs.existsSync(path.join(dir, 'bun.lock'))) return 'bun';
21
+ return 'npm';
22
+ }
23
+
24
+ function readScripts(dir) {
25
+ try {
26
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
27
+ return pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ export function registerRun(program) {
34
+ program
35
+ .command('run <name> [script]')
36
+ .description('Run a package.json script in a terminal at the shortcut folder')
37
+ .summary('Run a package.json script in a terminal')
38
+ .action((name, script) => {
39
+ const links = loadLinks();
40
+ const resolved = resolveName(name, links);
41
+ if (!resolved) {
42
+ ui.error(`Shortcut not found: ${name}`);
43
+ process.exit(1);
44
+ }
45
+
46
+ const dir = links[resolved.name].path;
47
+ if (!dir || !fs.existsSync(dir)) {
48
+ ui.error(`No valid folder for "${resolved.name}".`);
49
+ process.exit(1);
50
+ }
51
+
52
+ const scripts = readScripts(dir);
53
+ if (!scripts) {
54
+ ui.error(`No package.json in ${dir}`);
55
+ process.exit(1);
56
+ }
57
+
58
+ const pm = detectPm(dir);
59
+
60
+ // No script given: list what's available.
61
+ if (!script) {
62
+ const names = Object.keys(scripts);
63
+ if (!names.length) {
64
+ ui.info('No scripts defined in package.json.');
65
+ return;
66
+ }
67
+ ui.heading(`Scripts (${pm}) -- ${resolved.name}`);
68
+ ui.table(
69
+ [[chalk.bold('SCRIPT'), chalk.bold('COMMAND')], ...names.map(n => [chalk.cyan(n), chalk.dim(scripts[n])])],
70
+ { header: true },
71
+ );
72
+ ui.blank();
73
+ ui.dim(`Run one: upex run ${resolved.name} <script>`);
74
+ return;
75
+ }
76
+
77
+ if (!Object.hasOwn(scripts, script)) {
78
+ ui.error(`No "${script}" script in ${resolved.name}. Available: ${Object.keys(scripts).join(', ') || '(none)'}`);
79
+ process.exit(1);
80
+ }
81
+
82
+ return handleOpen(resolved.name, 'cmd', 1, `${pm} run ${script}`);
83
+ });
84
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * setup.js -- Show shell integration instructions.
3
+ * Usage: upex setup
4
+ */
5
+
6
+ import chalk from 'chalk';
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { execSync } from 'node:child_process';
11
+ import * as ui from '../ui.js';
12
+
13
+ const BASH_ZSH_FUNCTION = `# ~/.bashrc or ~/.zshrc
14
+ upex() {
15
+ if [ "$1" = "go" ] && [ -n "$2" ]; then
16
+ if result=$(command upex go "$2" 2>/dev/null) && cd "$result"; then
17
+ shift 2
18
+ [ $# -eq 0 ] || "$@"
19
+ return
20
+ fi
21
+ fi
22
+ if [ "$1" != "" ] && [ "$2" = "" ] && result=$(command upex __resolve "$1" 2>/dev/null); then
23
+ cd "$result"
24
+ else
25
+ command upex "$@"
26
+ fi
27
+ }`;
28
+
29
+ const POWERSHELL_FUNCTION = `# PowerShell profile ($PROFILE)
30
+ function upex {
31
+ if ($args.Count -ge 2 -and $args[0] -eq 'go') {
32
+ $result = & upex.ps1 go $args[1] 2>$null
33
+ if ($LASTEXITCODE -eq 0 -and $result) {
34
+ Set-Location $result
35
+ $rest = @($args | Select-Object -Skip 2)
36
+ if ($rest.Count) { Invoke-Expression ($rest -join ' ') }
37
+ return
38
+ }
39
+ }
40
+ if ($args.Count -eq 1) {
41
+ $result = & upex.ps1 __resolve $args[0] 2>$null
42
+ if ($LASTEXITCODE -eq 0 -and $result) {
43
+ Set-Location $result
44
+ return
45
+ }
46
+ }
47
+ & upex.ps1 @args
48
+ }`;
49
+
50
+ const FISH_FUNCTION = `# ~/.config/fish/functions/upex.fish
51
+ function upex
52
+ if test (count $argv) -ge 2; and test "$argv[1]" = "go"
53
+ set result (command upex go $argv[2] 2>/dev/null)
54
+ if test $status -eq 0; and test -n "$result"
55
+ cd $result
56
+ if test (count $argv) -gt 2
57
+ $argv[3..-1]
58
+ end
59
+ return
60
+ end
61
+ end
62
+ if test (count $argv) -eq 1
63
+ set result (command upex __resolve $argv[1] 2>/dev/null)
64
+ if test $status -eq 0; and test -n "$result"
65
+ cd $result
66
+ return
67
+ end
68
+ end
69
+ command upex $argv
70
+ end`;
71
+
72
+ // cmd.exe has no profile functions. Instead, an upex.bat placed next to the
73
+ // npm upex.cmd shim wins PATH resolution (.bat before .cmd in PATHEXT), and a
74
+ // cd inside a batch file persists in the parent shell.
75
+ export const CMD_WRAPPER = [
76
+ '@echo off',
77
+ 'rem upex cmd.exe wrapper (installed by "upex setup cmd").',
78
+ 'if /i "%~1"=="go" if not "%~2"=="" goto :go',
79
+ 'if not "%~1"=="" if "%~2"=="" goto :single',
80
+ 'goto :pass',
81
+ '',
82
+ ':go',
83
+ 'for /f "usebackq delims=" %%D in (`call "%~dp0upex.cmd" go "%~2" 2^>nul`) do cd /d "%%D" & goto :gorun',
84
+ 'call "%~dp0upex.cmd" go "%~2"',
85
+ 'exit /b',
86
+ '',
87
+ ':gorun',
88
+ 'rem any args after the name run as a command here (upex go vel npm run dev)',
89
+ 'shift',
90
+ 'shift',
91
+ 'if "%~1"=="" exit /b',
92
+ 'set "UPEX_RUN=%1"',
93
+ ':gocollect',
94
+ 'shift',
95
+ 'if not "%~1"=="" set "UPEX_RUN=%UPEX_RUN% %1" & goto :gocollect',
96
+ 'call %UPEX_RUN%',
97
+ 'set "UPEX_RUN="',
98
+ 'exit /b',
99
+ '',
100
+ ':single',
101
+ 'for /f "usebackq delims=" %%D in (`call "%~dp0upex.cmd" __resolve "%~1" 2^>nul`) do cd /d "%%D" & exit /b',
102
+ ':pass',
103
+ 'call "%~dp0upex.cmd" %*',
104
+ '',
105
+ ].join('\r\n');
106
+
107
+ export function installCmdWrapper(dir) {
108
+ const target = path.join(dir, 'upex.bat');
109
+ fs.writeFileSync(target, CMD_WRAPPER);
110
+ return target;
111
+ }
112
+
113
+ export function findShimDir() {
114
+ try {
115
+ const out = execSync('where upex.cmd', { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
116
+ const first = out.split(/\r?\n/).map(s => s.trim()).find(Boolean);
117
+ return first ? path.dirname(first) : null;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ export function registerSetup(program) {
124
+ program
125
+ .command('setup [shell]')
126
+ .description('Shell integration for cd support ("setup cmd" installs the cmd.exe wrapper)')
127
+ .summary('Shell integration ("setup cmd": cmd.exe wrapper)')
128
+ .action((shell) => {
129
+ const platform = os.platform();
130
+
131
+ if (shell === 'cmd') {
132
+ if (platform !== 'win32') {
133
+ ui.error('The cmd.exe wrapper is Windows-only.');
134
+ process.exit(1);
135
+ }
136
+ const shimDir = findShimDir();
137
+ if (!shimDir) {
138
+ ui.error('upex.cmd not found on PATH. Install upex globally first (npm i -g upex).');
139
+ process.exit(1);
140
+ }
141
+ const target = installCmdWrapper(shimDir);
142
+ ui.success(`Installed ${target}`);
143
+ ui.dim('"upex go <name>" and "upex <name>" now cd in cmd.exe (current session too).');
144
+ return;
145
+ }
146
+
147
+ ui.heading('Shell Integration');
148
+
149
+ ui.dim('A wrapper function is required for cd behavior.');
150
+ ui.dim('Node.js cannot change the parent shell directory directly.');
151
+ ui.blank();
152
+
153
+ if (platform === 'win32') {
154
+ console.log(chalk.bold(' Command Prompt (cmd.exe):'));
155
+ ui.separator(40);
156
+ ui.info('run: upex setup cmd');
157
+ ui.dim('installs an upex.bat wrapper next to the npm shim, no profile needed');
158
+ ui.blank();
159
+ console.log(chalk.bold(' PowerShell:'));
160
+ ui.separator(40);
161
+ console.log(POWERSHELL_FUNCTION.split('\n').map(l => ' ' + l).join('\n'));
162
+ } else {
163
+ console.log(chalk.bold(' Bash / Zsh:'));
164
+ ui.separator(40);
165
+ console.log(BASH_ZSH_FUNCTION.split('\n').map(l => ' ' + l).join('\n'));
166
+ }
167
+
168
+ ui.blank();
169
+ console.log(chalk.bold(' Fish:'));
170
+ ui.separator(40);
171
+ console.log(FISH_FUNCTION.split('\n').map(l => ' ' + l).join('\n'));
172
+
173
+ ui.blank();
174
+ ui.separator();
175
+ ui.dim('Restart your shell after adding the function.');
176
+ ui.blank();
177
+ });
178
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * tag.js -- Manage tags on shortcuts and open a whole tag group.
3
+ * Usage: upex tag -- list all tags with counts
4
+ * upex tag <name> -- show a shortcut's tags
5
+ * upex tag <name> <tags...> -- add tags
6
+ * upex tag <name> -r <tags..> -- remove tags (or --clear to drop all)
7
+ * upex open <tag> -- open every shortcut carrying <tag>
8
+ */
9
+
10
+ import chalk from 'chalk';
11
+ import { loadLinks, setLink } from '../store.js';
12
+ import { resolveName } from '../fuzzy.js';
13
+ import { handleOpen } from './open.js';
14
+ import * as ui from '../ui.js';
15
+
16
+ function allTags(links) {
17
+ const counts = {};
18
+ for (const entry of Object.values(links)) {
19
+ for (const t of entry.tags || []) counts[t] = (counts[t] || 0) + 1;
20
+ }
21
+ return counts;
22
+ }
23
+
24
+ export function registerTag(program) {
25
+ program
26
+ .command('tag [name] [tags...]')
27
+ .description('List tags, or add/remove tags on a shortcut')
28
+ .option('-r, --remove', 'Remove the listed tags instead of adding')
29
+ .option('--clear', 'Remove all tags from the shortcut')
30
+ .action((name, tags, options) => {
31
+ const links = loadLinks();
32
+
33
+ // No name: list every tag with a count.
34
+ if (!name) {
35
+ const counts = allTags(links);
36
+ const entries = Object.entries(counts);
37
+ if (!entries.length) {
38
+ ui.info('No tags yet. Add some: upex tag <name> <tag>');
39
+ return;
40
+ }
41
+ ui.heading('Tags');
42
+ ui.table(
43
+ [[chalk.bold('TAG'), chalk.bold('COUNT')], ...entries.map(([t, c]) => [chalk.cyan(t), String(c)])],
44
+ { header: true },
45
+ );
46
+ ui.blank();
47
+ return;
48
+ }
49
+
50
+ const resolved = resolveName(name, links);
51
+ if (!resolved) {
52
+ ui.error(`Shortcut not found: ${name}`);
53
+ process.exit(1);
54
+ }
55
+ const entry = links[resolved.name];
56
+ const current = entry.tags || [];
57
+
58
+ // Show tags when none are supplied and no mutation flag is set.
59
+ if (!options.clear && tags.length === 0) {
60
+ ui.info(current.length ? `${resolved.name}: ${current.join(', ')}` : `${resolved.name} has no tags.`);
61
+ return;
62
+ }
63
+
64
+ let next;
65
+ if (options.clear) {
66
+ next = [];
67
+ } else if (options.remove) {
68
+ next = current.filter(t => !tags.includes(t));
69
+ } else {
70
+ next = [...new Set([...current, ...tags])];
71
+ }
72
+
73
+ const updated = { ...entry };
74
+ if (next.length) updated.tags = next; else delete updated.tags;
75
+ setLink(resolved.name, updated);
76
+ ui.success(`${resolved.name}: ${next.length ? next.join(', ') : '(no tags)'}`);
77
+ });
78
+ }
79
+
80
+ export function registerOpenGroup(program) {
81
+ program
82
+ .command('open <tag>')
83
+ .description('Open every shortcut carrying <tag> in its default opener')
84
+ .summary('Open every shortcut tagged <tag>')
85
+ .action(async (tag) => {
86
+ const links = loadLinks();
87
+ const matches = Object.keys(links).filter(n => (links[n].tags || []).includes(tag));
88
+
89
+ if (!matches.length) {
90
+ ui.warn(`No shortcuts tagged "${tag}".`);
91
+ const tags = Object.keys(allTags(links));
92
+ if (tags.length) ui.dim(`Available tags: ${tags.join(', ')}`);
93
+ process.exit(1);
94
+ }
95
+
96
+ ui.dim(`Opening ${matches.length} shortcut${matches.length === 1 ? '' : 's'} tagged #${tag}: ${matches.join(', ')}`);
97
+ for (const shortcut of matches) {
98
+ await handleOpen(shortcut, undefined, 1, '', { batch: true });
99
+ }
100
+ });
101
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * transfer.js -- Export/import shortcuts (and config) for backup or sync.
3
+ * Usage: upex export [file] -- write a JSON bundle (stdout if no file)
4
+ * upex import <file> -- merge a bundle into the store
5
+ * upex import <file> --replace -- replace the store entirely
6
+ */
7
+
8
+ import fs from 'node:fs';
9
+ import {
10
+ loadLinks, saveLinks, loadConfig, saveConfig, mergeLinks,
11
+ } from '../store.js';
12
+ import * as ui from '../ui.js';
13
+
14
+ export function registerExport(program) {
15
+ program
16
+ .command('export [file]')
17
+ .description('Export shortcuts + config as a JSON bundle (stdout if no file)')
18
+ .summary('Export shortcuts + config as JSON')
19
+ .action((file) => {
20
+ const bundle = { upex: 'bundle', version: 1, links: loadLinks(), config: loadConfig() };
21
+ const json = JSON.stringify(bundle, null, 2);
22
+ if (!file) {
23
+ process.stdout.write(json + '\n');
24
+ return;
25
+ }
26
+ fs.writeFileSync(file, json, 'utf-8');
27
+ ui.success(`Exported ${Object.keys(bundle.links).length} shortcuts --> ${file}`);
28
+ });
29
+ }
30
+
31
+ export function registerImport(program) {
32
+ program
33
+ .command('import <file>')
34
+ .description('Import a bundle (merge by default, --replace to overwrite)')
35
+ .summary('Import a bundle (merge, or --replace)')
36
+ .option('--replace', 'Replace all existing shortcuts instead of merging')
37
+ .action((file, options) => {
38
+ let bundle;
39
+ try {
40
+ bundle = JSON.parse(fs.readFileSync(file, 'utf-8'));
41
+ } catch (err) {
42
+ ui.error(`Cannot read ${file}: ${err.message}`);
43
+ process.exit(1);
44
+ }
45
+
46
+ // Accept a full bundle or a bare links map.
47
+ const incoming = bundle.links && typeof bundle.links === 'object' ? bundle.links : bundle;
48
+ if (!incoming || typeof incoming !== 'object') {
49
+ ui.error('Nothing importable in that file.');
50
+ process.exit(1);
51
+ }
52
+
53
+ const hasConfig = bundle.config && typeof bundle.config === 'object';
54
+ // Read config before saving links, so a corrupt config.json aborts without a partial import.
55
+ const current = hasConfig ? loadConfig() : null;
56
+ const { merged, added, updated } = mergeLinks(loadLinks(), incoming, { replace: options.replace });
57
+ saveLinks(merged);
58
+
59
+ // Merge config (apps + workspaces) without clobbering unless replacing.
60
+ if (hasConfig) {
61
+ const inc = bundle.config;
62
+ const next = options.replace ? inc : {
63
+ ...current,
64
+ ...inc,
65
+ apps: { ...(current.apps || {}), ...(inc.apps || {}) },
66
+ workspaces: { ...(current.workspaces || {}), ...(inc.workspaces || {}) },
67
+ };
68
+ saveConfig(next);
69
+ }
70
+
71
+ ui.success(`Imported: ${added.length} added, ${updated.length} updated.`);
72
+ if (added.length) ui.dim(`Added: ${added.join(', ')}`);
73
+ if (updated.length) ui.dim(`Updated: ${updated.join(', ')}`);
74
+ });
75
+ }