@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,282 @@
1
+ /**
2
+ * web.js -- `upex ui`: a local web page to manage shortcuts and workspaces.
3
+ * Binds to 127.0.0.1 only; every API call needs the per-run token baked into the page.
4
+ */
5
+
6
+ import http from 'node:http';
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import crypto from 'node:crypto';
11
+ import { loadLinks, saveLinks, loadWorkspaces, saveWorkspaces, pruneBroken, targetField, nameError, frecency } from '../store.js';
12
+ import { getInteractiveAppChoices, launchUrl, openerKey, isTerminalApp } from '../launcher.js';
13
+ import { handleOpen } from './open.js';
14
+ import { openEntries } from './workspace.js';
15
+ import { parseTags } from './add.js';
16
+ import * as ui from '../ui.js';
17
+
18
+ const PAGE = new URL('../web/index.html', import.meta.url);
19
+ const DEFAULT_PORT = 4545;
20
+
21
+ /**
22
+ * Add (old undefined) or edit/rename (old = current name) one shortcut.
23
+ * Pure: returns { links } with the new set, or { error }.
24
+ */
25
+ export function applyShortcut(links, old, { kind, name, target, tags, opener } = {}, exists = fs.existsSync) {
26
+ name = String(name ?? '').trim();
27
+ target = String(target ?? '').trim();
28
+ // Old CLI names may hold spaces; only new names are checked so those stay editable.
29
+ const bad = name !== old && nameError(name);
30
+ if (bad) return { error: bad };
31
+ if (old !== undefined && !Object.hasOwn(links, old)) return { error: `Shortcut not found: ${old}` };
32
+ if (name !== old && Object.hasOwn(links, name)) return { error: `"${name}" already exists.` };
33
+
34
+ const prev = old === undefined ? {} : links[old];
35
+ const field = old === undefined ? { folder: 'path', app: 'app', cmd: 'app', url: 'url' }[kind] : targetField(prev);
36
+ if (!field) return { error: 'Pick folder, app, command or URL.' };
37
+ if (old === undefined && kind === 'cmd') prev.terminal = true;
38
+ if (!target) return { error: 'Target is required.' };
39
+ if (field === 'path') {
40
+ target = path.resolve(target);
41
+ // Unchanged paths skip the check, so a broken shortcut can still be renamed.
42
+ if (!(prev.path && path.resolve(prev.path) === target) && !exists(target)) return { error: `Path does not exist: ${target}` };
43
+ }
44
+
45
+ const entry = { ...prev, [field]: target };
46
+ const t = parseTags(Array.isArray(tags) ? tags.join(',') : String(tags ?? ''));
47
+ if (t) entry.tags = t; else delete entry.tags;
48
+ // undefined leaves the opener alone, '' clears it.
49
+ if (opener) {
50
+ if (field !== 'path') return { error: 'A default opener only applies to folder shortcuts.' };
51
+ const key = openerKey(opener);
52
+ if (!key) return { error: `Unknown opener "${opener}".` };
53
+ entry.opener = key;
54
+ } else if (opener !== undefined) delete entry.opener;
55
+
56
+ // Rebuild so a renamed shortcut keeps its position.
57
+ const out = {};
58
+ for (const [k, v] of Object.entries(links)) {
59
+ if (k === old) out[name] = entry; else out[k] = v;
60
+ }
61
+ if (old === undefined) out[name] = entry;
62
+ return { links: out };
63
+ }
64
+
65
+ /**
66
+ * Validate workspace entries from the page. Returns the cleaned list or null.
67
+ */
68
+ export function cleanEntries(list) {
69
+ if (!Array.isArray(list)) return null;
70
+ const out = [];
71
+ for (const e of list) {
72
+ if (!e || typeof e !== 'object') return null;
73
+ const shortcut = typeof e.shortcut === 'string' ? e.shortcut.trim() : '';
74
+ const count = e.count === undefined || e.count === '' ? 1 : Number(e.count);
75
+ // No upper bound: `upex ws add` stores any count and the launcher caps it.
76
+ if (!shortcut || !Number.isInteger(count) || count < 1) return null;
77
+ const app = typeof e.app === 'string' ? e.app.trim() : '';
78
+ const start = typeof e.start === 'string' ? e.start.trim() : '';
79
+ out.push({ shortcut, ...(app && { app }), ...(count > 1 && { count }), ...(start && { start }) });
80
+ }
81
+ return out;
82
+ }
83
+
84
+ function state() {
85
+ const links = loadLinks();
86
+ return {
87
+ links: Object.entries(links).map(([name, e]) => {
88
+ const field = targetField(e);
89
+ return {
90
+ name,
91
+ kind: field === 'path' ? 'folder' : e.terminal ? 'cmd' : field,
92
+ // Same split as the terminal selector: anything that launches directly lives on Apps.
93
+ tab: e.app || e.url ? 'apps' : 'links',
94
+ target: e[field] ?? '',
95
+ tags: e.tags || [],
96
+ opener: e.opener || '',
97
+ lastUsed: e.lastUsed || 0,
98
+ score: frecency(e),
99
+ broken: Boolean(e.path) && !fs.existsSync(e.path),
100
+ };
101
+ }),
102
+ workspaces: loadWorkspaces(),
103
+ apps: getInteractiveAppChoices().map(a => ({ ...a, terminal: isTerminalApp(a.key) })),
104
+ home: os.homedir(),
105
+ sep: path.sep,
106
+ };
107
+ }
108
+
109
+ const routes = {
110
+ 'GET /api/state': () => state(),
111
+
112
+ 'POST /api/links': (b) => saveShortcut(undefined, b),
113
+ 'PUT /api/links': (b) => saveShortcut(String(b.old ?? ''), b),
114
+
115
+ 'DELETE /api/links': (b) => {
116
+ const links = loadLinks();
117
+ if (!Object.hasOwn(links, String(b.name))) throw new HttpError(404, `Shortcut not found: ${b.name}`);
118
+ delete links[b.name];
119
+ saveLinks(links);
120
+ return {};
121
+ },
122
+
123
+ 'POST /api/open': async (b) => {
124
+ const name = String(b.name ?? '');
125
+ // Exact only: a stale name must not fuzzy-open some other shortcut.
126
+ if (!Object.hasOwn(loadLinks(), name)) throw new HttpError(404, `Shortcut not found: ${name}`);
127
+ const [e] = cleanEntries([{ shortcut: name, app: b.app, count: b.count, start: b.start }]) || [];
128
+ if (!e) throw new HttpError(400, 'Window count must be at least 1.');
129
+ const ok = await handleOpen(name, e.app, e.count || 1, e.start || '', { batch: true });
130
+ if (!ok) throw new HttpError(422, `Could not open "${b.name}". Details are in the upex ui terminal.`);
131
+ return {};
132
+ },
133
+
134
+ 'POST /api/prune': () => {
135
+ const { kept, removed } = pruneBroken(loadLinks());
136
+ saveLinks(kept);
137
+ return { removed };
138
+ },
139
+
140
+ // Set one workspace's entries (and optionally rename it); an empty list deletes it.
141
+ // `prev` is the list the page saw (null = new), so a CLI change in between is not overwritten.
142
+ 'PUT /api/workspace': (b) => {
143
+ const name = String(b.name ?? '').trim();
144
+ const to = b.newName === undefined ? name : String(b.newName).trim();
145
+ const entries = cleanEntries(b.entries);
146
+ const all = loadWorkspaces();
147
+ const exists = Object.hasOwn(all, name);
148
+ const bad = (!exists && nameError(name)) || (to !== name && nameError(to));
149
+ if (bad) throw new HttpError(400, bad);
150
+ if (to !== name && Object.hasOwn(all, to)) throw new HttpError(409, `"${to}" already exists.`);
151
+ if (!entries) throw new HttpError(400, 'Each entry needs a shortcut and a count of at least 1.');
152
+ if (JSON.stringify(b.prev ?? null) !== JSON.stringify(exists ? all[name] : null)) {
153
+ throw new HttpError(409, `Workspace "${name}" changed elsewhere. Reloaded, try again.`);
154
+ }
155
+ // fromEntries keeps the order, so a renamed workspace stays in place.
156
+ const list = Object.entries(all).map(([k, v]) => (k === name ? [to, entries] : [k, v]));
157
+ if (!exists) list.push([to, entries]);
158
+ saveWorkspaces(Object.fromEntries(list.filter(([k, v]) => k !== to || v.length)));
159
+ return {};
160
+ },
161
+
162
+ 'POST /api/workspace/open': async (b) => {
163
+ const workspaces = loadWorkspaces();
164
+ const entries = Object.hasOwn(workspaces, String(b.name)) && workspaces[b.name];
165
+ if (!entries) throw new HttpError(404, `No workspace named "${b.name}".`);
166
+ const failed = await openEntries(entries);
167
+ if (failed) throw new HttpError(422, `${failed} of ${entries.length} entries failed. Details are in the upex ui terminal.`);
168
+ return {};
169
+ },
170
+ };
171
+
172
+ class HttpError extends Error {
173
+ constructor(status, message) { super(message); this.status = status; }
174
+ }
175
+
176
+ function saveShortcut(old, body) {
177
+ const { links, error } = applyShortcut(loadLinks(), old, body);
178
+ if (error) throw new HttpError(400, error);
179
+ saveLinks(links);
180
+ return {};
181
+ }
182
+
183
+ function readJsonBody(req) {
184
+ return new Promise((resolve, reject) => {
185
+ let data = '';
186
+ req.setEncoding('utf-8');
187
+ req.on('data', (c) => {
188
+ data += c;
189
+ if (data.length > 1e6) { reject(new HttpError(413, 'Request too large.')); req.destroy(); }
190
+ });
191
+ req.on('end', () => {
192
+ try {
193
+ const parsed = data ? JSON.parse(data) : {};
194
+ if (!parsed || typeof parsed !== 'object') throw new Error();
195
+ resolve(parsed);
196
+ } catch { reject(new HttpError(400, 'Invalid JSON.')); }
197
+ });
198
+ req.on('error', reject);
199
+ });
200
+ }
201
+
202
+ function sendJson(res, status, body) {
203
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
204
+ res.end(JSON.stringify(body));
205
+ }
206
+
207
+ export function startServer({ port = DEFAULT_PORT, open = true } = {}) {
208
+ const token = crypto.randomBytes(24).toString('hex');
209
+ const nonce = crypto.randomBytes(16).toString('base64');
210
+ const page = fs.readFileSync(PAGE, 'utf-8').replace('__TOKEN__', token).replaceAll('__NONCE__', nonce);
211
+ let hosts = new Set();
212
+
213
+ // One try around everything: a throw here would be an unhandled rejection and kill the server.
214
+ const server = http.createServer(async (req, res) => {
215
+ try {
216
+ // Host check blocks DNS rebinding (an outside site resolving to 127.0.0.1).
217
+ if (!hosts.has(req.headers.host)) return sendJson(res, 403, { error: 'Forbidden host.' });
218
+ const { pathname, searchParams } = new URL(req.url, 'http://x');
219
+
220
+ if (req.method === 'GET' && pathname === '/') {
221
+ // The page holds the token, so it needs it too: other local users can reach 127.0.0.1.
222
+ if (searchParams.get('t') !== token) return sendJson(res, 403, { error: 'Open the link printed by upex ui.' });
223
+ res.writeHead(200, {
224
+ 'content-type': 'text/html; charset=utf-8',
225
+ 'cache-control': 'no-store',
226
+ 'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; connect-src 'self'; img-src data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`,
227
+ 'x-content-type-options': 'nosniff',
228
+ });
229
+ return res.end(page);
230
+ }
231
+
232
+ const handler = routes[`${req.method} ${pathname}`];
233
+ if (!handler) return sendJson(res, 404, { error: 'Not found.' });
234
+ if (req.headers['x-upex-token'] !== token) return sendJson(res, 403, { error: 'Missing or wrong token. Reload the page.' });
235
+
236
+ const body = req.method === 'GET' ? {} : await readJsonBody(req);
237
+ sendJson(res, 200, await handler(body));
238
+ } catch (err) {
239
+ sendJson(res, err.status || 500, { error: err.message });
240
+ }
241
+ });
242
+
243
+ const ready = () => {
244
+ const actual = server.address().port;
245
+ hosts = new Set([`127.0.0.1:${actual}`, `localhost:${actual}`]);
246
+ const url = `http://127.0.0.1:${actual}/?t=${token}`;
247
+ ui.success(`upex ui running at ${url}`);
248
+ ui.dim('Press Ctrl+C to stop.');
249
+ if (open) launchUrl(url);
250
+ };
251
+
252
+ server.on('error', (err) => {
253
+ if (err.code === 'EADDRINUSE' && port !== 0) {
254
+ ui.warn(`Port ${port} is busy, using a free one.`);
255
+ port = 0;
256
+ server.listen(0, '127.0.0.1');
257
+ return;
258
+ }
259
+ ui.error(`Cannot start server: ${err.message}`);
260
+ process.exit(1);
261
+ });
262
+ server.on('listening', ready);
263
+ server.listen(port, '127.0.0.1');
264
+ return server;
265
+ }
266
+
267
+ export function registerWeb(program) {
268
+ program
269
+ .command('ui')
270
+ .description('Open a local web page to add, edit, rename, delete and open shortcuts and workspaces')
271
+ .summary('Manage shortcuts in the browser')
272
+ .option('-p, --port <port>', 'Port to listen on', String(DEFAULT_PORT))
273
+ .option('--no-open', 'Do not open the browser')
274
+ .action((options) => {
275
+ const port = Number(options.port);
276
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
277
+ ui.error(`Invalid port: ${options.port}`);
278
+ process.exit(1);
279
+ }
280
+ startServer({ port, open: options.open });
281
+ });
282
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * workspace.js -- Save and open named sets of shortcut+app pairs.
3
+ * Usage: upex ws -- list workspaces
4
+ * upex ws <name> -- open every entry
5
+ * upex ws add <name> <shortcut> [app] [count] [start...] -- append an entry
6
+ * upex ws rm <name> [shortcut] -- remove a workspace (or one entry)
7
+ *
8
+ * The [count] [start...] tail works exactly like `upex cmd`: for the `cmd`
9
+ * opener it opens N terminals and runs the start command in each, e.g.
10
+ * upex ws add dev velora cmd claude --model sonnet
11
+ */
12
+
13
+ import chalk from 'chalk';
14
+ import { loadWorkspaces, saveWorkspaces, removeWorkspace, linkExists, nameError } from '../store.js';
15
+ import { handleOpen } from './open.js';
16
+ import * as ui from '../ui.js';
17
+
18
+ // Human-readable one-liner for an entry: "shortcut:app x2 (start cmd)".
19
+ export function describeEntry(e) {
20
+ let s = e.app ? `${e.shortcut}:${e.app}` : e.shortcut;
21
+ if (e.count > 1) s += ` x${e.count}`;
22
+ if (e.start) s += ` (${e.start})`;
23
+ return s;
24
+ }
25
+
26
+ function listWorkspaces(workspaces) {
27
+ const names = Object.keys(workspaces);
28
+ if (!names.length) {
29
+ ui.info('No workspaces. Add one: upex ws add <name> <shortcut> [app]');
30
+ return;
31
+ }
32
+ ui.heading('Workspaces');
33
+ for (const name of names) {
34
+ const parts = (workspaces[name] || []).map(describeEntry);
35
+ console.log(` ${chalk.bold(name)} ${chalk.dim(parts.join(' '))}`);
36
+ }
37
+ ui.blank();
38
+ }
39
+
40
+ // Open each entry; returns how many failed.
41
+ export async function openEntries(entries) {
42
+ let failed = 0;
43
+ for (const { shortcut, app, count = 1, start = '' } of entries) {
44
+ if (!(await handleOpen(shortcut, app || undefined, count, start, { batch: true }))) failed++;
45
+ }
46
+ return failed;
47
+ }
48
+
49
+ export async function openWorkspace(workspaces, name) {
50
+ const entries = Object.hasOwn(workspaces, name) && workspaces[name];
51
+ if (!entries) {
52
+ ui.error(`No workspace named "${name}".`);
53
+ const names = Object.keys(workspaces);
54
+ if (names.length) ui.dim(`Available: ${names.join(', ')}`);
55
+ process.exit(1);
56
+ }
57
+ ui.dim(`Opening workspace "${name}" (${entries.length} entr${entries.length === 1 ? 'y' : 'ies'})`);
58
+ const failed = await openEntries(entries);
59
+ if (failed) {
60
+ ui.error(`${failed} of ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} failed to open.`);
61
+ process.exitCode = 1;
62
+ }
63
+ }
64
+
65
+ export function registerWorkspace(program) {
66
+ program
67
+ .command('ws [args...]')
68
+ .alias('workspace')
69
+ .description('Open or manage workspaces (ws add / ws rm / ws <name>)')
70
+ // Let a start command with its own flags (e.g. "claude --model x") pass through.
71
+ .enablePositionalOptions()
72
+ .passThroughOptions()
73
+ .allowUnknownOption()
74
+ .action(async (args) => {
75
+ const workspaces = loadWorkspaces();
76
+ const [verb, ...rest] = args;
77
+
78
+ if (!verb) return listWorkspaces(workspaces);
79
+
80
+ if (verb === 'add') {
81
+ const [name, shortcut, app, ...tail] = rest;
82
+ if (!name || !shortcut) {
83
+ ui.error('Usage: upex ws add <name> <shortcut> [app] [count] [start...]');
84
+ process.exit(1);
85
+ }
86
+ const bad = !Object.hasOwn(workspaces, name) && nameError(name);
87
+ if (bad) {
88
+ ui.error(bad);
89
+ process.exit(1);
90
+ }
91
+ if (!linkExists(shortcut)) {
92
+ ui.warn(`Shortcut "${shortcut}" doesn't exist yet, adding anyway.`);
93
+ }
94
+ // A leading integer in the tail is the terminal count; the rest is the start command.
95
+ let count = 1;
96
+ let start = tail;
97
+ if (tail.length && /^\d+$/.test(tail[0])) {
98
+ count = Number(tail[0]);
99
+ start = tail.slice(1);
100
+ }
101
+ const entry = {
102
+ shortcut,
103
+ ...(app && { app }),
104
+ ...(count > 1 && { count }),
105
+ ...(start.length && { start: start.join(' ') }),
106
+ };
107
+ const list = Object.hasOwn(workspaces, name) ? workspaces[name] : [];
108
+ list.push(entry);
109
+ saveWorkspaces({ ...workspaces, [name]: list });
110
+ ui.success(`ws "${name}" += ${describeEntry(entry)}`);
111
+ return;
112
+ }
113
+
114
+ if (verb === 'rm' || verb === 'remove') {
115
+ const [name, shortcut] = rest;
116
+ if (!name || !Object.hasOwn(workspaces, name)) {
117
+ ui.error(`No workspace named "${name}".`);
118
+ process.exit(1);
119
+ }
120
+ if (!shortcut) {
121
+ removeWorkspace(name);
122
+ ui.success(`Removed workspace "${name}".`);
123
+ return;
124
+ }
125
+ const list = (workspaces[name] || []).filter(e => e.shortcut !== shortcut);
126
+ if (list.length) saveWorkspaces({ ...workspaces, [name]: list });
127
+ else removeWorkspace(name);
128
+ ui.success(`ws "${name}" -= ${shortcut}`);
129
+ return;
130
+ }
131
+
132
+ // Anything else is a workspace name to open.
133
+ await openWorkspace(workspaces, verb);
134
+ });
135
+ }
package/src/fuzzy.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * fuzzy.js -- Fuzzy matching utility for shortcut names
3
+ */
4
+
5
+ import Fuse from 'fuse.js';
6
+
7
+ /**
8
+ * Find the best fuzzy match for `query` in a list of shortcut names.
9
+ * Returns { match, score } or null.
10
+ */
11
+ export function fuzzyMatch(query, names) {
12
+ if (!names.length) return null;
13
+
14
+ const fuse = new Fuse(names.map(n => ({ name: n })), {
15
+ keys: ['name'],
16
+ threshold: 0.4,
17
+ includeScore: true,
18
+ });
19
+
20
+ const results = fuse.search(query);
21
+ if (results.length === 0) return null;
22
+
23
+ return {
24
+ match: results[0].item.name,
25
+ score: results[0].score,
26
+ };
27
+ }
28
+
29
+ // Up to `n` close names for a "did you mean" hint; looser than resolving.
30
+ export function suggestNames(query, names, n = 3) {
31
+ return new Fuse(names, { threshold: 0.6 }).search(query, { limit: n }).map(r => r.item);
32
+ }
33
+
34
+ // One-line hint for a name that didn't resolve, or null.
35
+ export function didYouMean(query, names) {
36
+ const s = suggestNames(query, names);
37
+ return s.length ? `Did you mean: ${s.join(', ')}?` : names.length ? 'See all with: upex list' : null;
38
+ }
39
+
40
+ /**
41
+ * Resolve a shortcut name: exact match first, then fuzzy.
42
+ * Returns { name, exact } or null.
43
+ */
44
+ export function resolveName(query, links) {
45
+ const names = Object.keys(links);
46
+
47
+ // Exact match
48
+ if (Object.hasOwn(links, query)) {
49
+ return { name: query, exact: true };
50
+ }
51
+
52
+ // Fuzzy match
53
+ const result = fuzzyMatch(query, names);
54
+ if (result) {
55
+ return { name: result.match, exact: false };
56
+ }
57
+
58
+ return null;
59
+ }
60
+
61
+ /**
62
+ * resolveName plus subpaths: "web/src" -> { name: "web", exact, sub: "src" }.
63
+ * The longest exact name wins, so names containing "/" still work.
64
+ */
65
+ export function resolveTarget(query, links) {
66
+ if (Object.hasOwn(links, query)) return { name: query, exact: true, sub: '' };
67
+ for (const cut of [...query.matchAll(/[/\\]+/g)].reverse()) {
68
+ const name = query.slice(0, cut.index);
69
+ if (Object.hasOwn(links, name)) return { name, exact: true, sub: query.slice(cut.index + cut[0].length) };
70
+ }
71
+ const m = query.match(/^([^/\\]+)[/\\]+(.+)$/);
72
+ const head = m && resolveName(m[1], links);
73
+ if (head) return { ...head, sub: m[2] };
74
+ const r = resolveName(query, links);
75
+ return r && { ...r, sub: '' };
76
+ }
77
+
package/src/git.js ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * git.js -- Cheap git status for a folder shortcut.
3
+ */
4
+
5
+ import { execSync } from 'node:child_process';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ /**
10
+ * Return { branch, dirty } for a git repo root, or null if it isn't one.
11
+ * Gated on a `.git` entry so we never spawn git for plain folders.
12
+ */
13
+ export function gitStatus(dir) {
14
+ try {
15
+ if (!dir || !fs.existsSync(path.join(dir, '.git'))) return null;
16
+ const run = (cmd) => execSync(cmd, { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
17
+ const branch = run('git rev-parse --abbrev-ref HEAD').trim();
18
+ const dirty = run('git status --porcelain').trim().length > 0;
19
+ return { branch: branch || 'HEAD', dirty };
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
package/src/index.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * index.js -- Main entry point for the upex CLI
3
+ */
4
+
5
+ import { readFileSync } from 'node:fs';
6
+ import { Command } from 'commander';
7
+ import chalk from 'chalk';
8
+ import { registerAdd } from './commands/add.js';
9
+ import { registerList } from './commands/list.js';
10
+ import { registerRemove } from './commands/remove.js';
11
+ import { registerRename } from './commands/rename.js';
12
+ import { registerEdit } from './commands/edit.js';
13
+ import { registerCheck } from './commands/check.js';
14
+ import { registerResolve, registerGo, handleOpen } from './commands/open.js';
15
+ import { registerSetup } from './commands/setup.js';
16
+ import { registerHelp } from './commands/help.js';
17
+ import { registerCopy } from './commands/copy.js';
18
+ import { registerCmd } from './commands/cmd.js';
19
+ import { registerApp } from './commands/app.js';
20
+ import { registerCompletion } from './commands/completion.js';
21
+ import { registerTag, registerOpenGroup } from './commands/tag.js';
22
+ import { registerWorkspace, openWorkspace } from './commands/workspace.js';
23
+ import { loadWorkspaces } from './store.js';
24
+ import { registerRun } from './commands/run.js';
25
+ import { registerExport, registerImport } from './commands/transfer.js';
26
+ import { registerLast } from './commands/last.js';
27
+ import { registerWeb } from './commands/web.js';
28
+ import { registerDoctor } from './commands/doctor.js';
29
+ import { runInteractiveMode } from './interactive.js';
30
+
31
+ const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
32
+
33
+ const label = `upex v${version}`;
34
+ const title = `${label} -- shortcut manager`;
35
+ const w = title.length + 4;
36
+ const BANNER = `
37
+ ${chalk.dim('┌' + '─'.repeat(w) + '┐')}
38
+ ${chalk.dim('│')} ${chalk.bold(label)} -- shortcut manager ${chalk.dim('│')}
39
+ ${chalk.dim('└' + '─'.repeat(w) + '┘')}
40
+ `;
41
+
42
+ export function createProgram() {
43
+ const program = new Command();
44
+
45
+ program
46
+ .name('upex')
47
+ .description('A personal file shortcut manager: instantly open project folders.')
48
+ .version(version, '-v, --version')
49
+ .option('-a, --app <app>', 'Preselect app for the interactive selector')
50
+ .enablePositionalOptions()
51
+ .passThroughOptions()
52
+ .addHelpText('beforeAll', BANNER);
53
+
54
+ // Register all sub-commands
55
+ registerAdd(program);
56
+ registerList(program);
57
+ registerRemove(program);
58
+ registerRename(program);
59
+ registerEdit(program);
60
+ registerCheck(program);
61
+ registerResolve(program);
62
+ registerGo(program);
63
+ registerSetup(program);
64
+ registerHelp(program);
65
+ registerCopy(program);
66
+ registerCmd(program);
67
+ registerApp(program);
68
+ registerCompletion(program);
69
+ registerTag(program);
70
+ registerOpenGroup(program);
71
+ registerWorkspace(program);
72
+ registerRun(program);
73
+ registerExport(program);
74
+ registerImport(program);
75
+ registerLast(program);
76
+ registerWeb(program);
77
+ registerDoctor(program);
78
+
79
+ // Default action: upex <name> [app]
80
+ // Commander doesn't support a default positional command well,
81
+ // so we handle unknown commands manually.
82
+ program
83
+ .argument('[name]', 'Shortcut name to open')
84
+ .argument('[app]', 'App to open the folder in (e.g. vscode, webstorm)')
85
+ .argument('[count]', 'How many windows to open (cmd only)')
86
+ .action(async (name, app, count) => {
87
+ // Only handle if it's the root command (not a sub-command)
88
+ // Commander calls this for sub-commands too, but name will be the subcommand name
89
+ // We need to check if name matches a known sub-command
90
+ if (!name) {
91
+ const result = await runInteractiveMode({ preferredApp: program.opts().app });
92
+ if (result.status === 'selected') {
93
+ await handleOpen(result.name, result.app, result.count || 1);
94
+ return;
95
+ }
96
+ if (result.status === 'workspace') {
97
+ await openWorkspace(loadWorkspaces(), result.name);
98
+ return;
99
+ }
100
+ if (result.status === 'noninteractive') {
101
+ program.outputHelp();
102
+ }
103
+ return;
104
+ }
105
+
106
+ const subCommands = program.commands.map(c => c.name()).concat(
107
+ program.commands.flatMap(c => c.aliases())
108
+ );
109
+
110
+ if (subCommands.includes(name)) {
111
+ // Let commander handle it
112
+ return;
113
+ }
114
+
115
+ await handleOpen(name, app, Number(count) || 1);
116
+ });
117
+
118
+ return program;
119
+ }
120
+