@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.
- package/LICENSE +21 -0
- package/README.md +327 -2
- package/bin/upex.js +16 -0
- package/package.json +38 -6
- package/src/commands/add.js +87 -0
- package/src/commands/app.js +58 -0
- package/src/commands/check.js +70 -0
- package/src/commands/cmd.js +26 -0
- package/src/commands/completion.js +75 -0
- package/src/commands/copy.js +51 -0
- package/src/commands/doctor.js +130 -0
- package/src/commands/edit.js +85 -0
- package/src/commands/help.js +45 -0
- package/src/commands/last.js +23 -0
- package/src/commands/list.js +76 -0
- package/src/commands/open.js +195 -0
- package/src/commands/remove.js +35 -0
- package/src/commands/rename.js +47 -0
- package/src/commands/run.js +84 -0
- package/src/commands/setup.js +178 -0
- package/src/commands/tag.js +101 -0
- package/src/commands/transfer.js +75 -0
- package/src/commands/web.js +282 -0
- package/src/commands/workspace.js +135 -0
- package/src/fuzzy.js +77 -0
- package/src/git.js +23 -0
- package/src/index.js +120 -0
- package/src/interactive.js +582 -0
- package/src/launcher.js +508 -0
- package/src/store.js +249 -0
- package/src/ui.js +127 -0
- package/src/web/index.html +766 -0
|
@@ -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
|
+
}
|