@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
package/src/store.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* store.js -- Persistent storage for upex shortcuts and config
|
|
3
|
+
* Reads/writes ~/.upex/links.json and ~/.upex/config.json
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
|
|
10
|
+
const UPEX_DIR = path.join(os.homedir(), '.upex');
|
|
11
|
+
const LINKS_FILE = path.join(UPEX_DIR, 'links.json');
|
|
12
|
+
const CONFIG_FILE = path.join(UPEX_DIR, 'config.json');
|
|
13
|
+
|
|
14
|
+
function ensureStore() {
|
|
15
|
+
if (!fs.existsSync(UPEX_DIR)) {
|
|
16
|
+
fs.mkdirSync(UPEX_DIR, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
if (!fs.existsSync(LINKS_FILE)) {
|
|
19
|
+
fs.writeFileSync(LINKS_FILE, JSON.stringify({}, null, 2), 'utf-8');
|
|
20
|
+
}
|
|
21
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
22
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify({}, null, 2), 'utf-8');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Thrown when a store file can't be parsed; bin/upex.js prints it instead of a stack.
|
|
27
|
+
export class StoreError extends Error {}
|
|
28
|
+
|
|
29
|
+
// Parse a store file. Never falls back to {}: a later save would wipe the real data.
|
|
30
|
+
function readJson(file) {
|
|
31
|
+
ensureStore();
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
// Strip a BOM (PowerShell/Notepad edits add one).
|
|
35
|
+
parsed = JSON.parse(fs.readFileSync(file, 'utf-8').replace(/^\uFEFF/, ''));
|
|
36
|
+
} catch (err) {
|
|
37
|
+
throw new StoreError(`Cannot read ${file}: ${err.message}\n Fix or delete the file, nothing was changed.`);
|
|
38
|
+
}
|
|
39
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
40
|
+
throw new StoreError(`${file} must hold a JSON object.\n Fix or delete the file, nothing was changed.`);
|
|
41
|
+
}
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Temp file + rename, so a crash mid-write can't leave a truncated store.
|
|
46
|
+
function writeJson(file, data) {
|
|
47
|
+
ensureStore();
|
|
48
|
+
// Write through a symlinked store instead of replacing the link.
|
|
49
|
+
const target = fs.realpathSync(file);
|
|
50
|
+
// Per-process name: a shared tmp lets two concurrent writers interleave into it.
|
|
51
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
52
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
|
|
53
|
+
// Windows can't replace a file another process has open; retry briefly.
|
|
54
|
+
for (let tries = 0; ; tries++) {
|
|
55
|
+
try {
|
|
56
|
+
fs.renameSync(tmp, target);
|
|
57
|
+
return;
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (tries >= 20 || !['EPERM', 'EACCES', 'EBUSY'].includes(err.code)) {
|
|
60
|
+
fs.rmSync(tmp, { force: true });
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Normalize a raw link value to { path?, app? }.
|
|
70
|
+
* Supports legacy string format and new object format.
|
|
71
|
+
*/
|
|
72
|
+
function normalizeEntry(value) {
|
|
73
|
+
if (typeof value === 'string') return { path: value };
|
|
74
|
+
if (value && typeof value === 'object') return value;
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Field holding a shortcut's target. An app shortcut that also has a path edits the path.
|
|
79
|
+
export const targetField = (e) => (e.url ? 'url' : e.app && !e.path ? 'app' : 'path');
|
|
80
|
+
|
|
81
|
+
export function loadLinks() {
|
|
82
|
+
const normalized = {};
|
|
83
|
+
for (const [name, value] of Object.entries(readJson(LINKS_FILE))) {
|
|
84
|
+
const entry = normalizeEntry(value);
|
|
85
|
+
if (entry) normalized[name] = entry;
|
|
86
|
+
}
|
|
87
|
+
return normalized;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function saveLinks(links) {
|
|
91
|
+
writeJson(LINKS_FILE, links);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function getLink(name) {
|
|
95
|
+
const links = loadLinks();
|
|
96
|
+
return links[name] ?? null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Save a shortcut entry.
|
|
101
|
+
* @param {string} name
|
|
102
|
+
* @param {string|object} pathOrEntry - folder path string OR full entry object { path?, app? }
|
|
103
|
+
*/
|
|
104
|
+
export function setLink(name, pathOrEntry) {
|
|
105
|
+
const links = loadLinks();
|
|
106
|
+
if (typeof pathOrEntry === 'string') {
|
|
107
|
+
links[name] = { path: pathOrEntry };
|
|
108
|
+
} else {
|
|
109
|
+
links[name] = pathOrEntry;
|
|
110
|
+
}
|
|
111
|
+
saveLinks(links);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function removeLink(name) {
|
|
115
|
+
const links = loadLinks();
|
|
116
|
+
if (!Object.hasOwn(links, name)) return false;
|
|
117
|
+
delete links[name];
|
|
118
|
+
saveLinks(links);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function renameLink(oldName, newName) {
|
|
123
|
+
const links = loadLinks();
|
|
124
|
+
if (!Object.hasOwn(links, oldName)) return { ok: false, reason: 'not_found' };
|
|
125
|
+
if (Object.hasOwn(links, newName)) return { ok: false, reason: 'exists' };
|
|
126
|
+
links[newName] = links[oldName];
|
|
127
|
+
delete links[oldName];
|
|
128
|
+
saveLinks(links);
|
|
129
|
+
return { ok: true };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function linkExists(name) {
|
|
133
|
+
return Object.hasOwn(loadLinks(), name);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Why a new shortcut or workspace name is invalid, or null when it's fine.
|
|
137
|
+
export function nameError(name) {
|
|
138
|
+
if (!name) return 'Name is required.';
|
|
139
|
+
if (name === '__proto__') return 'That name is reserved.';
|
|
140
|
+
if (/\s/.test(name)) return 'Names cannot contain spaces.';
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Bump usage stats for a shortcut (drives frecency sorting).
|
|
146
|
+
*/
|
|
147
|
+
export function recordUse(name) {
|
|
148
|
+
const links = loadLinks();
|
|
149
|
+
if (!Object.hasOwn(links, name)) return;
|
|
150
|
+
const entry = links[name];
|
|
151
|
+
entry.uses = (entry.uses || 0) + 1;
|
|
152
|
+
entry.lastUsed = Date.now();
|
|
153
|
+
saveLinks(links);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Frecency score: usage count plus a recency bonus that decays over days.
|
|
158
|
+
*/
|
|
159
|
+
export function frecency(entry) {
|
|
160
|
+
const uses = entry.uses || 0;
|
|
161
|
+
const last = entry.lastUsed || 0;
|
|
162
|
+
if (!last) return uses;
|
|
163
|
+
const days = (Date.now() - last) / 86400000;
|
|
164
|
+
return uses + 4 / (1 + days);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Split links into the ones to keep and the broken folder shortcuts to drop.
|
|
169
|
+
* An entry with both a dead path and an app keeps the app (path cleared).
|
|
170
|
+
* `exists` is injectable for testing.
|
|
171
|
+
*/
|
|
172
|
+
export function pruneBroken(links, exists = (p) => fs.existsSync(p)) {
|
|
173
|
+
const kept = {};
|
|
174
|
+
const removed = [];
|
|
175
|
+
for (const [name, entry] of Object.entries(links)) {
|
|
176
|
+
if (entry.path && !exists(entry.path)) {
|
|
177
|
+
if (entry.app) {
|
|
178
|
+
const { path: _dead, ...rest } = entry;
|
|
179
|
+
kept[name] = rest;
|
|
180
|
+
} else {
|
|
181
|
+
removed.push(name);
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
kept[name] = entry;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { kept, removed };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Name of the most recently used shortcut, or null if none has a lastUsed stamp.
|
|
192
|
+
*/
|
|
193
|
+
export function lastUsedName(links = loadLinks()) {
|
|
194
|
+
let best = null;
|
|
195
|
+
let bestTime = 0;
|
|
196
|
+
for (const [name, entry] of Object.entries(links)) {
|
|
197
|
+
if (entry.lastUsed && entry.lastUsed > bestTime) {
|
|
198
|
+
bestTime = entry.lastUsed;
|
|
199
|
+
best = name;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return best;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Merge incoming shortcuts into current ones (for import).
|
|
207
|
+
* replace=true swaps the whole set; otherwise incoming overwrites on name clash.
|
|
208
|
+
*/
|
|
209
|
+
export function mergeLinks(current, incoming, { replace = false } = {}) {
|
|
210
|
+
if (replace) {
|
|
211
|
+
return { merged: { ...incoming }, added: Object.keys(incoming), updated: [] };
|
|
212
|
+
}
|
|
213
|
+
const merged = { ...current };
|
|
214
|
+
const added = [];
|
|
215
|
+
const updated = [];
|
|
216
|
+
for (const [name, entry] of Object.entries(incoming)) {
|
|
217
|
+
(Object.hasOwn(merged, name) ? updated : added).push(name);
|
|
218
|
+
merged[name] = entry;
|
|
219
|
+
}
|
|
220
|
+
return { merged, added, updated };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Workspaces live in config.json under `workspaces`: { dev: [{ shortcut, app }] }.
|
|
225
|
+
*/
|
|
226
|
+
export function loadWorkspaces() {
|
|
227
|
+
const { workspaces } = loadConfig();
|
|
228
|
+
return workspaces && typeof workspaces === 'object' ? workspaces : {};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function saveWorkspaces(workspaces) {
|
|
232
|
+
const config = loadConfig();
|
|
233
|
+
saveConfig({ ...config, workspaces });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function removeWorkspace(name) {
|
|
237
|
+
const { [name]: _drop, ...rest } = loadWorkspaces();
|
|
238
|
+
saveWorkspaces(rest);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function loadConfig() {
|
|
242
|
+
return readJson(CONFIG_FILE);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function saveConfig(config) {
|
|
246
|
+
writeJson(CONFIG_FILE, config);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export { LINKS_FILE, CONFIG_FILE };
|
package/src/ui.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ui.js -- Shared output helpers for consistent terminal formatting.
|
|
3
|
+
*
|
|
4
|
+
* Visual hierarchy:
|
|
5
|
+
* heading() -- bold text inside a dim box-drawn frame
|
|
6
|
+
* separator() -- dim horizontal rule
|
|
7
|
+
* info() -- plain indented text
|
|
8
|
+
* dim() -- subdued supplemental text
|
|
9
|
+
*
|
|
10
|
+
* Color roles (max 3):
|
|
11
|
+
* green -- success
|
|
12
|
+
* yellow -- warning
|
|
13
|
+
* red -- error
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import chalk from 'chalk';
|
|
17
|
+
|
|
18
|
+
// Fixed-width status tags (6 chars each)
|
|
19
|
+
export const tag = {
|
|
20
|
+
ok: chalk.green('[ OK ]'),
|
|
21
|
+
warn: chalk.yellow('[WARN]'),
|
|
22
|
+
fail: chalk.red('[FAIL]'),
|
|
23
|
+
skip: chalk.dim('[SKIP]'),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function success(msg) {
|
|
27
|
+
console.log(chalk.green(` ${msg}`));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function error(msg) {
|
|
31
|
+
console.error(chalk.red(` ${msg}`));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function warn(msg) {
|
|
35
|
+
console.log(chalk.yellow(` ${msg}`));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function info(msg) {
|
|
39
|
+
console.log(` ${msg}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function heading(text) {
|
|
43
|
+
const w = text.length + 4;
|
|
44
|
+
console.log();
|
|
45
|
+
console.log(chalk.dim(` ┌${'─'.repeat(w)}┐`));
|
|
46
|
+
console.log(` ${chalk.dim('│')} ${chalk.bold(text)} ${chalk.dim('│')}`);
|
|
47
|
+
console.log(chalk.dim(` └${'─'.repeat(w)}┘`));
|
|
48
|
+
console.log();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function dim(msg) {
|
|
52
|
+
console.log(chalk.dim(` ${msg}`));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Print a nicely aligned table from an array of [col1, col2, ...] rows.
|
|
57
|
+
* When header=true, a separator line is drawn after the first row.
|
|
58
|
+
*/
|
|
59
|
+
export function table(rows, { indent = 2, gap = 2, header = false } = {}) {
|
|
60
|
+
if (!rows.length) return;
|
|
61
|
+
|
|
62
|
+
const colCount = rows[0].length;
|
|
63
|
+
const widths = Array.from({ length: colCount }, () => 0);
|
|
64
|
+
for (const row of rows) {
|
|
65
|
+
for (let i = 0; i < colCount; i++) {
|
|
66
|
+
const len = stripAnsi(String(row[i])).length;
|
|
67
|
+
if (len > widths[i]) widths[i] = len;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const totalWidth = widths.reduce((a, b) => a + b, 0) + (colCount - 1) * gap;
|
|
72
|
+
|
|
73
|
+
for (let r = 0; r < rows.length; r++) {
|
|
74
|
+
const row = rows[r];
|
|
75
|
+
let line = ' '.repeat(indent);
|
|
76
|
+
for (let i = 0; i < colCount; i++) {
|
|
77
|
+
const cell = String(row[i]);
|
|
78
|
+
const stripped = stripAnsi(cell);
|
|
79
|
+
const pad = widths[i] - stripped.length + gap;
|
|
80
|
+
line += cell + (i < colCount - 1 ? ' '.repeat(Math.max(pad, 1)) : '');
|
|
81
|
+
}
|
|
82
|
+
console.log(line);
|
|
83
|
+
|
|
84
|
+
if (header && r === 0) {
|
|
85
|
+
console.log(chalk.dim(' '.repeat(indent) + '─'.repeat(totalWidth)));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Minimal ANSI strip (for column width calculation).
|
|
92
|
+
*/
|
|
93
|
+
function stripAnsi(str) {
|
|
94
|
+
// eslint-disable-next-line no-control-regex
|
|
95
|
+
return str.replace(/\x1B\[[0-9;]*m/g, '');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Clip a styled string to `width` visible columns, keeping ANSI codes intact.
|
|
100
|
+
*/
|
|
101
|
+
export function truncate(str, width) {
|
|
102
|
+
if (width <= 0) return '';
|
|
103
|
+
if (stripAnsi(str).length <= width) return str;
|
|
104
|
+
|
|
105
|
+
let out = '';
|
|
106
|
+
let budget = width - 1; // one column reserved for the ellipsis
|
|
107
|
+
// eslint-disable-next-line no-control-regex
|
|
108
|
+
for (const part of str.split(/(\x1B\[[0-9;]*m)/)) {
|
|
109
|
+
if (part.startsWith('\x1B')) { out += part; continue; }
|
|
110
|
+
if (part.length <= budget) {
|
|
111
|
+
out += part;
|
|
112
|
+
budget -= part.length;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
out += part.slice(0, budget);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
return out + '…\x1B[0m';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function separator(length = 60) {
|
|
122
|
+
console.log(chalk.dim(' ' + '─'.repeat(length)));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function blank() {
|
|
126
|
+
console.log();
|
|
127
|
+
}
|