@tuskydp/cli 0.1.0 → 0.1.1
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/bin/tuskydp.ts +2 -0
- package/dist/src/commands/account.d.ts.map +1 -1
- package/dist/src/commands/account.js +5 -2
- package/dist/src/commands/account.js.map +1 -1
- package/dist/src/commands/auth.d.ts.map +1 -1
- package/dist/src/commands/auth.js +2 -1
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/files.d.ts.map +1 -1
- package/dist/src/commands/files.js +9 -4
- package/dist/src/commands/files.js.map +1 -1
- package/dist/src/commands/mcp.js +1 -1
- package/dist/src/commands/mcp.js.map +1 -1
- package/dist/src/commands/rehydrate.d.ts.map +1 -1
- package/dist/src/commands/rehydrate.js +5 -2
- package/dist/src/commands/rehydrate.js.map +1 -1
- package/dist/src/commands/upload.d.ts.map +1 -1
- package/dist/src/commands/upload.js +5 -0
- package/dist/src/commands/upload.js.map +1 -1
- package/dist/src/config.d.ts +0 -2
- package/dist/src/config.d.ts.map +1 -1
- package/dist/src/config.js +5 -4
- package/dist/src/config.js.map +1 -1
- package/dist/src/index.js +16 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib/keyring.d.ts.map +1 -1
- package/dist/src/lib/keyring.js +3 -5
- package/dist/src/lib/keyring.js.map +1 -1
- package/dist/src/lib/output.js +1 -1
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/resolve.js +1 -1
- package/dist/src/lib/resolve.js.map +1 -1
- package/dist/src/mcp/tools/files.d.ts.map +1 -1
- package/dist/src/mcp/tools/files.js +20 -0
- package/dist/src/mcp/tools/files.js.map +1 -1
- package/dist/src/mcp/tools/folders.d.ts.map +1 -1
- package/dist/src/mcp/tools/folders.js +15 -0
- package/dist/src/mcp/tools/folders.js.map +1 -1
- package/dist/src/mcp/tools/trash.d.ts.map +1 -1
- package/dist/src/mcp/tools/trash.js +14 -0
- package/dist/src/mcp/tools/trash.js.map +1 -1
- package/dist/src/sdk.d.ts +1 -1
- package/dist/src/sdk.d.ts.map +1 -1
- package/dist/src/sdk.js +3 -3
- package/dist/src/sdk.js.map +1 -1
- package/dist/src/tui/auth-screen.d.ts.map +1 -1
- package/dist/src/tui/auth-screen.js +7 -1
- package/dist/src/tui/auth-screen.js.map +1 -1
- package/package.json +12 -18
- package/src/__tests__/crypto.test.ts +315 -0
- package/src/commands/account.ts +82 -0
- package/src/commands/auth.ts +190 -0
- package/src/commands/decrypt.ts +276 -0
- package/src/commands/download.ts +82 -0
- package/src/commands/encryption.ts +305 -0
- package/src/commands/export.ts +251 -0
- package/src/commands/files.ts +192 -0
- package/src/commands/mcp.ts +220 -0
- package/src/commands/rehydrate.ts +37 -0
- package/src/commands/tui.ts +11 -0
- package/src/commands/upload.ts +143 -0
- package/src/commands/vault.ts +132 -0
- package/src/config.ts +38 -0
- package/src/crypto.ts +130 -0
- package/src/index.ts +79 -0
- package/src/lib/keyring.ts +50 -0
- package/src/lib/output.ts +36 -0
- package/src/lib/progress.ts +5 -0
- package/src/lib/resolve.ts +26 -0
- package/src/mcp/context.ts +22 -0
- package/src/mcp/server.ts +140 -0
- package/src/mcp/tools/account.ts +40 -0
- package/src/mcp/tools/files.ts +428 -0
- package/src/mcp/tools/folders.ts +109 -0
- package/src/mcp/tools/helpers.ts +28 -0
- package/src/mcp/tools/trash.ts +82 -0
- package/src/mcp/tools/vaults.ts +114 -0
- package/src/sdk.ts +115 -0
- package/src/tui/auth-screen.ts +176 -0
- package/src/tui/dialogs.ts +339 -0
- package/src/tui/files-panel.ts +165 -0
- package/src/tui/helpers.ts +206 -0
- package/src/tui/index.ts +420 -0
- package/src/tui/overview.ts +155 -0
- package/src/tui/status-bar.ts +61 -0
- package/src/tui/vaults-panel.ts +143 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +7 -0
package/src/tui/index.ts
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import blessed from 'blessed';
|
|
2
|
+
import { readFileSync, statSync, readdirSync } from 'fs';
|
|
3
|
+
import { basename, join, resolve } from 'path';
|
|
4
|
+
|
|
5
|
+
function collectFiles(dir: string): string[] {
|
|
6
|
+
const results: string[] = [];
|
|
7
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
8
|
+
for (const entry of entries) {
|
|
9
|
+
const full = join(dir, entry.name);
|
|
10
|
+
if (entry.isFile()) results.push(full);
|
|
11
|
+
else if (entry.isDirectory()) results.push(...collectFiles(full));
|
|
12
|
+
}
|
|
13
|
+
return results;
|
|
14
|
+
}
|
|
15
|
+
import { lookup } from 'mime-types';
|
|
16
|
+
import { createSDKClient } from '../sdk.js';
|
|
17
|
+
import { cliConfig, getApiUrl } from '../config.js';
|
|
18
|
+
import { encryptBuffer } from '../crypto.js';
|
|
19
|
+
import { loadMasterKey } from '../lib/keyring.js';
|
|
20
|
+
import { showAuthScreen } from './auth-screen.js';
|
|
21
|
+
import { VaultsPanel, VaultItem } from './vaults-panel.js';
|
|
22
|
+
import { FilesPanel } from './files-panel.js';
|
|
23
|
+
import { StatusBar } from './status-bar.js';
|
|
24
|
+
import { showOverview } from './overview.js';
|
|
25
|
+
import { confirmDialog, textInputDialog, selectDialog, detailPopup, fileBrowserDialog } from './dialogs.js';
|
|
26
|
+
import { formatBytes, formatDate, formatRow, showError, showMessage, statusColor, statusColorClose, getCurrentTheme, cycleTheme, Theme } from './helpers.js';
|
|
27
|
+
|
|
28
|
+
function formatDetailTable(rows: [string, string][]): string {
|
|
29
|
+
const labelW = Math.max(...rows.map(([l]) => l.length));
|
|
30
|
+
return rows.map(([label, value]) => ` {bold}${label.padEnd(labelW)}{/bold} │ ${value}`).join('\n');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type FocusTarget = 'vaults' | 'files';
|
|
34
|
+
|
|
35
|
+
export async function launchTui() {
|
|
36
|
+
const screen = blessed.screen({
|
|
37
|
+
smartCSR: true,
|
|
38
|
+
title: 'Tusky TUI',
|
|
39
|
+
fullUnicode: true,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Check auth
|
|
43
|
+
let apiKey = cliConfig.get('apiKey');
|
|
44
|
+
let apiUrl = getApiUrl();
|
|
45
|
+
|
|
46
|
+
if (!apiKey) {
|
|
47
|
+
const authResult = await showAuthScreen(screen);
|
|
48
|
+
if (!authResult) {
|
|
49
|
+
screen.destroy();
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
apiKey = authResult.apiKey;
|
|
53
|
+
apiUrl = authResult.apiUrl;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const sdk = createSDKClient(apiUrl, apiKey);
|
|
57
|
+
|
|
58
|
+
// Create main layout
|
|
59
|
+
const leftBox = blessed.box({
|
|
60
|
+
parent: screen,
|
|
61
|
+
top: 0,
|
|
62
|
+
left: 0,
|
|
63
|
+
width: '40%',
|
|
64
|
+
height: '100%-1',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const rightBox = blessed.box({
|
|
68
|
+
parent: screen,
|
|
69
|
+
top: 0,
|
|
70
|
+
left: '40%',
|
|
71
|
+
width: '60%',
|
|
72
|
+
height: '100%-1',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const statusBar = new StatusBar(screen);
|
|
76
|
+
const vaultsPanel = new VaultsPanel(screen, sdk, leftBox, (vault) => {
|
|
77
|
+
filesPanel.loadForVault(vault.id, vault.name);
|
|
78
|
+
});
|
|
79
|
+
const filesPanel = new FilesPanel(screen, sdk, rightBox);
|
|
80
|
+
|
|
81
|
+
let currentFocus: FocusTarget = 'vaults';
|
|
82
|
+
|
|
83
|
+
function setFocus(target: FocusTarget) {
|
|
84
|
+
currentFocus = target;
|
|
85
|
+
if (target === 'vaults') {
|
|
86
|
+
vaultsPanel.focus();
|
|
87
|
+
filesPanel.blur();
|
|
88
|
+
statusBar.setHints('n:new ←→:switch u:upload o:overview ?:help');
|
|
89
|
+
} else {
|
|
90
|
+
filesPanel.focus();
|
|
91
|
+
vaultsPanel.blur();
|
|
92
|
+
statusBar.setHints('u:upload d:delete r:retry ←→:switch Enter:detail ?:help');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Load account info for status bar
|
|
97
|
+
async function loadStatusBar() {
|
|
98
|
+
try {
|
|
99
|
+
const account = await sdk.account.get();
|
|
100
|
+
statusBar.setUser(
|
|
101
|
+
account.email || '',
|
|
102
|
+
account.planName || account.planTier || 'ppu',
|
|
103
|
+
formatBytes(account.storageUsedBytes || 0),
|
|
104
|
+
formatBytes(account.storageLimitBytes || 0),
|
|
105
|
+
);
|
|
106
|
+
} catch {
|
|
107
|
+
// Silently fail — status bar will show defaults
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Key bindings
|
|
112
|
+
screen.key(['q', 'C-c'], () => {
|
|
113
|
+
screen.destroy();
|
|
114
|
+
process.exit(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
screen.key(['left'], () => {
|
|
118
|
+
setFocus('vaults');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
screen.key(['right', 'tab'], () => {
|
|
122
|
+
if (filesPanel.getCurrentVaultId()) {
|
|
123
|
+
setFocus('files');
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
screen.key(['n'], async () => {
|
|
128
|
+
const name = await textInputDialog(screen, 'Vault name');
|
|
129
|
+
if (!name) {
|
|
130
|
+
setFocus(currentFocus);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const visIdx = await selectDialog(screen, 'Visibility', ['private', 'public']);
|
|
134
|
+
if (visIdx < 0) {
|
|
135
|
+
setFocus(currentFocus);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const visibility = visIdx === 0 ? 'private' as const : 'public' as const;
|
|
139
|
+
try {
|
|
140
|
+
await sdk.vaults.create({ name, visibility });
|
|
141
|
+
await vaultsPanel.load();
|
|
142
|
+
showMessage(screen, `Vault "${name}" created`);
|
|
143
|
+
} catch (err: any) {
|
|
144
|
+
showError(screen, err.message);
|
|
145
|
+
}
|
|
146
|
+
setFocus(currentFocus);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
screen.key(['u'], async () => {
|
|
150
|
+
const vaultId = filesPanel.getCurrentVaultId();
|
|
151
|
+
if (!vaultId) {
|
|
152
|
+
showError(screen, 'Select a vault first');
|
|
153
|
+
setFocus(currentFocus);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const selection = await fileBrowserDialog(screen);
|
|
158
|
+
if (!selection) {
|
|
159
|
+
setFocus(currentFocus);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Collect all file paths
|
|
164
|
+
let filePaths: string[] = [];
|
|
165
|
+
if (selection.isDirectory) {
|
|
166
|
+
filePaths = collectFiles(selection.path);
|
|
167
|
+
if (filePaths.length === 0) {
|
|
168
|
+
showError(screen, 'No files found in directory');
|
|
169
|
+
setFocus(currentFocus);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
filePaths = [selection.path];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const vault = await sdk.vaults.get(vaultId);
|
|
178
|
+
const isPrivate = vault.visibility === 'private';
|
|
179
|
+
let masterKey: Buffer | null = null;
|
|
180
|
+
|
|
181
|
+
if (isPrivate) {
|
|
182
|
+
masterKey = loadMasterKey();
|
|
183
|
+
if (!masterKey) {
|
|
184
|
+
showError(screen, 'Encryption session not unlocked. Run: tusky encryption unlock');
|
|
185
|
+
setFocus(currentFocus);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let successCount = 0;
|
|
191
|
+
const total = filePaths.length;
|
|
192
|
+
|
|
193
|
+
for (const filePath of filePaths) {
|
|
194
|
+
const fileName = basename(filePath);
|
|
195
|
+
statusBar.setHints(`Uploading ${successCount + 1}/${total}: ${fileName}...`);
|
|
196
|
+
screen.render();
|
|
197
|
+
|
|
198
|
+
const stat = statSync(filePath);
|
|
199
|
+
const fileBuffer = readFileSync(filePath);
|
|
200
|
+
const mimeType = lookup(filePath) || 'application/octet-stream';
|
|
201
|
+
|
|
202
|
+
let uploadBody: Buffer;
|
|
203
|
+
let encryptionMeta: {
|
|
204
|
+
wrappedKey?: string;
|
|
205
|
+
encryptionIv?: string;
|
|
206
|
+
plaintextSizeBytes?: number;
|
|
207
|
+
plaintextChecksumSha256?: string;
|
|
208
|
+
} = {};
|
|
209
|
+
|
|
210
|
+
if (isPrivate && masterKey) {
|
|
211
|
+
const { ciphertext, wrappedKey, iv, plaintextChecksum } = encryptBuffer(fileBuffer, masterKey);
|
|
212
|
+
uploadBody = ciphertext;
|
|
213
|
+
encryptionMeta = {
|
|
214
|
+
wrappedKey,
|
|
215
|
+
encryptionIv: iv,
|
|
216
|
+
plaintextSizeBytes: stat.size,
|
|
217
|
+
plaintextChecksumSha256: plaintextChecksum,
|
|
218
|
+
};
|
|
219
|
+
} else {
|
|
220
|
+
uploadBody = fileBuffer;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const { fileId, uploadUrl } = await sdk.files.requestUpload({
|
|
224
|
+
name: fileName,
|
|
225
|
+
mimeType,
|
|
226
|
+
sizeBytes: uploadBody.length,
|
|
227
|
+
vaultId,
|
|
228
|
+
...encryptionMeta,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const uploadResponse = await fetch(uploadUrl, {
|
|
232
|
+
method: 'PUT',
|
|
233
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
234
|
+
body: new Uint8Array(uploadBody),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
if (!uploadResponse.ok) throw new Error(`Upload failed for ${fileName}: ${uploadResponse.status}`);
|
|
238
|
+
|
|
239
|
+
await sdk.files.confirmUpload(fileId);
|
|
240
|
+
successCount++;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
showMessage(screen, `Uploaded ${successCount} file(s)`);
|
|
244
|
+
await filesPanel.loadForVault(vaultId, filesPanel.getCurrentVaultName());
|
|
245
|
+
await vaultsPanel.load();
|
|
246
|
+
} catch (err: any) {
|
|
247
|
+
showError(screen, err.message);
|
|
248
|
+
}
|
|
249
|
+
setFocus(currentFocus);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
screen.key(['d', 'delete'], async () => {
|
|
253
|
+
if (currentFocus === 'vaults') {
|
|
254
|
+
const vault = vaultsPanel.getSelected();
|
|
255
|
+
if (!vault) return;
|
|
256
|
+
const confirmed = await confirmDialog(screen, `Delete vault "${vault.name}" and ALL its files?`);
|
|
257
|
+
if (confirmed) {
|
|
258
|
+
try {
|
|
259
|
+
await sdk.vaults.delete(vault.id, { force: true });
|
|
260
|
+
filesPanel.clear();
|
|
261
|
+
await vaultsPanel.load();
|
|
262
|
+
showMessage(screen, `Vault "${vault.name}" deleted`);
|
|
263
|
+
} catch (err: any) {
|
|
264
|
+
showError(screen, err.message);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
setFocus(currentFocus);
|
|
268
|
+
} else {
|
|
269
|
+
const file = filesPanel.getSelected();
|
|
270
|
+
if (!file) return;
|
|
271
|
+
const confirmed = await confirmDialog(screen, `Delete file "${file.name}"?`);
|
|
272
|
+
if (confirmed) {
|
|
273
|
+
try {
|
|
274
|
+
await sdk.files.delete(file.id);
|
|
275
|
+
const vaultId = filesPanel.getCurrentVaultId();
|
|
276
|
+
if (vaultId) {
|
|
277
|
+
await filesPanel.loadForVault(vaultId, filesPanel.getCurrentVaultName());
|
|
278
|
+
await vaultsPanel.load();
|
|
279
|
+
}
|
|
280
|
+
showMessage(screen, `File "${file.name}" deleted`);
|
|
281
|
+
} catch (err: any) {
|
|
282
|
+
showError(screen, err.message);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
setFocus(currentFocus);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
screen.key(['enter'], () => {
|
|
290
|
+
if (currentFocus === 'vaults') {
|
|
291
|
+
const vault = vaultsPanel.getSelected();
|
|
292
|
+
if (vault) {
|
|
293
|
+
const rows: [string, string][] = [
|
|
294
|
+
['Name', vault.name],
|
|
295
|
+
['Slug', vault.slug],
|
|
296
|
+
['ID', vault.id],
|
|
297
|
+
['Visibility', vault.visibility],
|
|
298
|
+
['Files', String(vault.fileCount)],
|
|
299
|
+
['Size', formatBytes(vault.totalSizeBytes)],
|
|
300
|
+
];
|
|
301
|
+
detailPopup(screen, vault.name, formatDetailTable(rows));
|
|
302
|
+
}
|
|
303
|
+
} else {
|
|
304
|
+
const file = filesPanel.getSelected();
|
|
305
|
+
if (file) {
|
|
306
|
+
const open = statusColor(file.status);
|
|
307
|
+
const close = statusColorClose(file.status);
|
|
308
|
+
const rows: [string, string][] = [
|
|
309
|
+
['Name', file.name],
|
|
310
|
+
['ID', file.id],
|
|
311
|
+
['MIME', file.mimeType],
|
|
312
|
+
['Size', formatBytes(file.plaintextSizeBytes ?? file.sizeBytes)],
|
|
313
|
+
['Status', `${open}${file.status}${close}`],
|
|
314
|
+
['Encrypted', file.encrypted ? 'Yes' : 'No'],
|
|
315
|
+
['Uploaded', file.createdAt ? formatDate(file.createdAt) : 'N/A'],
|
|
316
|
+
['Blob ID', file.walrusBlobId || 'N/A'],
|
|
317
|
+
['Blob Object', file.walrusBlobObjectId || 'N/A'],
|
|
318
|
+
];
|
|
319
|
+
detailPopup(screen, file.name, formatDetailTable(rows));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
screen.key(['r'], async () => {
|
|
325
|
+
if (currentFocus === 'files') {
|
|
326
|
+
const file = filesPanel.getSelected();
|
|
327
|
+
if (!file) return;
|
|
328
|
+
if (file.status !== 'error') {
|
|
329
|
+
showMessage(screen, 'Only error files can be retried');
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
await sdk.files.retry(file.id);
|
|
334
|
+
const vaultId = filesPanel.getCurrentVaultId();
|
|
335
|
+
if (vaultId) {
|
|
336
|
+
await filesPanel.loadForVault(vaultId, filesPanel.getCurrentVaultName());
|
|
337
|
+
}
|
|
338
|
+
showMessage(screen, `Retry queued for "${file.name}"`);
|
|
339
|
+
} catch (err: any) {
|
|
340
|
+
showError(screen, err.message);
|
|
341
|
+
}
|
|
342
|
+
setFocus(currentFocus);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
screen.key(['o'], async () => {
|
|
347
|
+
await showOverview(screen, sdk);
|
|
348
|
+
setFocus(currentFocus);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
screen.key(['S-l'], async () => {
|
|
352
|
+
const confirmed = await confirmDialog(screen, 'Log out?');
|
|
353
|
+
if (confirmed) {
|
|
354
|
+
cliConfig.delete('apiKey' as any);
|
|
355
|
+
screen.destroy();
|
|
356
|
+
process.exit(0);
|
|
357
|
+
}
|
|
358
|
+
setFocus(currentFocus);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
function applyTheme(theme: Theme) {
|
|
362
|
+
// Vaults panel
|
|
363
|
+
vaultsPanel.list.style.border = { fg: currentFocus === 'vaults' ? theme.borderFocus : theme.border } as any;
|
|
364
|
+
vaultsPanel.list.style.fg = theme.fg;
|
|
365
|
+
vaultsPanel.list.style.selected = theme.selected as any;
|
|
366
|
+
(vaultsPanel.list.style as any).focus = { border: { fg: theme.borderFocus } };
|
|
367
|
+
|
|
368
|
+
// Files panel
|
|
369
|
+
filesPanel.list.style.border = { fg: currentFocus === 'files' ? theme.borderFocus : theme.border } as any;
|
|
370
|
+
filesPanel.list.style.fg = theme.fg;
|
|
371
|
+
filesPanel.list.style.selected = theme.selected as any;
|
|
372
|
+
(filesPanel.list.style as any).focus = { border: { fg: theme.borderFocus } };
|
|
373
|
+
|
|
374
|
+
// Status bar
|
|
375
|
+
statusBar.applyTheme(theme);
|
|
376
|
+
|
|
377
|
+
screen.render();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
screen.key(['t'], () => {
|
|
381
|
+
const theme = cycleTheme();
|
|
382
|
+
applyTheme(theme);
|
|
383
|
+
showMessage(screen, `Theme: ${theme.name}`);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
screen.key(['?'], () => {
|
|
387
|
+
const helpContent = [
|
|
388
|
+
'{bold}{cyan-fg}Tusky TUI — Keyboard Shortcuts{/cyan-fg}{/bold}',
|
|
389
|
+
'',
|
|
390
|
+
'{bold}Navigation{/bold}',
|
|
391
|
+
' ←/→ Switch between vaults/files',
|
|
392
|
+
' Tab Switch between vaults/files',
|
|
393
|
+
' Up/Down Navigate list',
|
|
394
|
+
'',
|
|
395
|
+
'{bold}Actions{/bold}',
|
|
396
|
+
' n Create new vault',
|
|
397
|
+
' u Upload file to current vault',
|
|
398
|
+
' d/Delete Delete selected item',
|
|
399
|
+
' r Retry failed file',
|
|
400
|
+
' Space/Enter View details',
|
|
401
|
+
'',
|
|
402
|
+
'{bold}Screens{/bold}',
|
|
403
|
+
' o Overview (account, storage, files)',
|
|
404
|
+
' t Cycle theme',
|
|
405
|
+
' L Logout',
|
|
406
|
+
' ? This help',
|
|
407
|
+
' q/Ctrl-C Quit',
|
|
408
|
+
].join('\n');
|
|
409
|
+
detailPopup(screen, 'Help', helpContent);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// Initial load
|
|
413
|
+
await Promise.all([
|
|
414
|
+
vaultsPanel.load(),
|
|
415
|
+
loadStatusBar(),
|
|
416
|
+
]);
|
|
417
|
+
|
|
418
|
+
setFocus('vaults');
|
|
419
|
+
screen.render();
|
|
420
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import blessed from 'blessed';
|
|
2
|
+
import type { TuskyClient } from '@tuskydp/sdk';
|
|
3
|
+
import { formatBytes, formatDate, statusColor, statusColorClose } from './helpers.js';
|
|
4
|
+
|
|
5
|
+
export async function showOverview(
|
|
6
|
+
screen: blessed.Widgets.Screen,
|
|
7
|
+
sdk: TuskyClient,
|
|
8
|
+
): Promise<void> {
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
const container = blessed.box({
|
|
11
|
+
parent: screen,
|
|
12
|
+
top: 0,
|
|
13
|
+
left: 0,
|
|
14
|
+
width: '100%',
|
|
15
|
+
height: '100%',
|
|
16
|
+
tags: true,
|
|
17
|
+
scrollable: true,
|
|
18
|
+
alwaysScroll: true,
|
|
19
|
+
keys: true,
|
|
20
|
+
vi: true,
|
|
21
|
+
scrollbar: {
|
|
22
|
+
style: { bg: 'cyan' },
|
|
23
|
+
},
|
|
24
|
+
border: { type: 'line' },
|
|
25
|
+
style: {
|
|
26
|
+
border: { fg: 'cyan' },
|
|
27
|
+
fg: 'white',
|
|
28
|
+
},
|
|
29
|
+
label: ' Overview ',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const loading = blessed.box({
|
|
33
|
+
parent: container,
|
|
34
|
+
top: 'center',
|
|
35
|
+
left: 'center',
|
|
36
|
+
width: 'shrink',
|
|
37
|
+
height: 1,
|
|
38
|
+
content: 'Loading...',
|
|
39
|
+
tags: true,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
screen.render();
|
|
43
|
+
|
|
44
|
+
(async () => {
|
|
45
|
+
try {
|
|
46
|
+
const [account, vaults, filesData] = await Promise.all([
|
|
47
|
+
sdk.account.get(),
|
|
48
|
+
sdk.vaults.list(),
|
|
49
|
+
sdk.files.list({ limit: 20, sortBy: 'createdAt', order: 'desc' }),
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
loading.destroy();
|
|
53
|
+
|
|
54
|
+
const files = filesData.files;
|
|
55
|
+
|
|
56
|
+
const storageUsed = account.storageUsedBytes || 0;
|
|
57
|
+
const storageLimit = account.storageLimitBytes || 0;
|
|
58
|
+
const pct = storageLimit > 0 ? Math.round((storageUsed / storageLimit) * 100) : 0;
|
|
59
|
+
const barWidth = 30;
|
|
60
|
+
const filled = Math.round((pct / 100) * barWidth);
|
|
61
|
+
const barFill = '█'.repeat(filled);
|
|
62
|
+
const barEmpty = '░'.repeat(barWidth - filled);
|
|
63
|
+
const barColor = pct > 90 ? '{red-fg}' : pct > 70 ? '{yellow-fg}' : '{green-fg}';
|
|
64
|
+
const barColorClose = pct > 90 ? '{/red-fg}' : pct > 70 ? '{/yellow-fg}' : '{/green-fg}';
|
|
65
|
+
|
|
66
|
+
let content = '';
|
|
67
|
+
content += '{bold}{cyan-fg}Account{/cyan-fg}{/bold}\n';
|
|
68
|
+
content += ` Email: ${account.email || 'N/A'}\n`;
|
|
69
|
+
content += ` Plan: ${account.planName || account.planTier || 'ppu'}\n`;
|
|
70
|
+
content += ` Storage: ${formatBytes(storageUsed)} / ${formatBytes(storageLimit)}\n`;
|
|
71
|
+
content += ` ${barColor}${barFill}${barEmpty}${barColorClose} ${pct}%\n`;
|
|
72
|
+
content += '\n';
|
|
73
|
+
content += `{bold}{cyan-fg}Vaults (${vaults.length}){/cyan-fg}{/bold}\n`;
|
|
74
|
+
|
|
75
|
+
if (vaults.length > 0) {
|
|
76
|
+
// Calculate column widths for vaults table
|
|
77
|
+
const vaultRows = vaults.map((v) => ({
|
|
78
|
+
name: v.name || '',
|
|
79
|
+
vis: v.visibility === 'private' ? 'private' : 'public',
|
|
80
|
+
visTag: v.visibility === 'private' ? '{yellow-fg}private{/yellow-fg}' : '{green-fg}public{/green-fg}',
|
|
81
|
+
files: String(v.fileCount || 0),
|
|
82
|
+
size: formatBytes(v.totalSizeBytes || 0),
|
|
83
|
+
}));
|
|
84
|
+
const vNameW = Math.max(4, ...vaultRows.map((r) => r.name.length));
|
|
85
|
+
const vVisW = 7; // "private" is longest
|
|
86
|
+
const vFilesW = Math.max(5, ...vaultRows.map((r) => r.files.length));
|
|
87
|
+
const vSizeW = Math.max(4, ...vaultRows.map((r) => r.size.length));
|
|
88
|
+
|
|
89
|
+
// Header
|
|
90
|
+
content += ` ${'Name'.padEnd(vNameW)} ${'Vis'.padEnd(vVisW)} ${'Files'.padStart(vFilesW)} ${'Size'.padStart(vSizeW)}\n`;
|
|
91
|
+
content += ` ${'─'.repeat(vNameW)} ${'─'.repeat(vVisW)} ${'─'.repeat(vFilesW)} ${'─'.repeat(vSizeW)}\n`;
|
|
92
|
+
for (const r of vaultRows) {
|
|
93
|
+
// Pad the plain text width, but use the tagged version for display
|
|
94
|
+
const visPad = ' '.repeat(Math.max(0, vVisW - r.vis.length));
|
|
95
|
+
content += ` ${r.name.padEnd(vNameW)} ${r.visTag}${visPad} ${r.files.padStart(vFilesW)} ${r.size.padStart(vSizeW)}\n`;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
content += '\n';
|
|
100
|
+
content += '{bold}{cyan-fg}Recent Files{/cyan-fg}{/bold}\n';
|
|
101
|
+
|
|
102
|
+
if (files.length === 0) {
|
|
103
|
+
content += ' (no files yet)\n';
|
|
104
|
+
} else {
|
|
105
|
+
// Calculate column widths for files table
|
|
106
|
+
const fileRows = files.map((f) => ({
|
|
107
|
+
name: f.name || '',
|
|
108
|
+
size: formatBytes(f.plaintextSizeBytes ?? f.sizeBytes ?? 0),
|
|
109
|
+
status: f.status || '',
|
|
110
|
+
statusTag: `${statusColor(f.status)}${f.status}${statusColorClose(f.status)}`,
|
|
111
|
+
date: f.createdAt ? formatDate(f.createdAt) : 'N/A',
|
|
112
|
+
}));
|
|
113
|
+
const fNameW = Math.max(4, ...fileRows.map((r) => r.name.length));
|
|
114
|
+
const fSizeW = Math.max(4, ...fileRows.map((r) => r.size.length));
|
|
115
|
+
const fStatW = Math.max(6, ...fileRows.map((r) => r.status.length));
|
|
116
|
+
const fDateW = Math.max(4, ...fileRows.map((r) => r.date.length));
|
|
117
|
+
|
|
118
|
+
// Clamp name width to something reasonable
|
|
119
|
+
const maxNameW = Math.min(fNameW, 40);
|
|
120
|
+
|
|
121
|
+
// Header
|
|
122
|
+
content += ` ${'Name'.padEnd(maxNameW)} ${'Size'.padStart(fSizeW)} ${'Status'.padEnd(fStatW)} ${'Date'.padEnd(fDateW)}\n`;
|
|
123
|
+
content += ` ${'─'.repeat(maxNameW)} ${'─'.repeat(fSizeW)} ${'─'.repeat(fStatW)} ${'─'.repeat(fDateW)}\n`;
|
|
124
|
+
for (const r of fileRows) {
|
|
125
|
+
const truncName = r.name.length > maxNameW ? r.name.slice(0, maxNameW - 1) + '…' : r.name;
|
|
126
|
+
const statusPad = ' '.repeat(Math.max(0, fStatW - r.status.length));
|
|
127
|
+
content += ` ${truncName.padEnd(maxNameW)} ${r.size.padStart(fSizeW)} ${r.statusTag}${statusPad} ${r.date}\n`;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
content += '\n{gray-fg}Press Escape to return{/gray-fg}';
|
|
132
|
+
|
|
133
|
+
container.setContent(content);
|
|
134
|
+
container.focus();
|
|
135
|
+
screen.render();
|
|
136
|
+
} catch (err: any) {
|
|
137
|
+
loading.setContent(`{red-fg}Error: ${err.message}{/red-fg}`);
|
|
138
|
+
screen.render();
|
|
139
|
+
}
|
|
140
|
+
})();
|
|
141
|
+
|
|
142
|
+
container.key(['escape', 'q'], () => {
|
|
143
|
+
container.destroy();
|
|
144
|
+
screen.render();
|
|
145
|
+
resolve();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
screen.key(['escape'], function escHandler() {
|
|
149
|
+
screen.unkey('escape', escHandler);
|
|
150
|
+
container.destroy();
|
|
151
|
+
screen.render();
|
|
152
|
+
resolve();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import blessed from 'blessed';
|
|
2
|
+
import type { Theme } from './helpers.js';
|
|
3
|
+
|
|
4
|
+
export class StatusBar {
|
|
5
|
+
box: blessed.Widgets.BoxElement;
|
|
6
|
+
private screen: blessed.Widgets.Screen;
|
|
7
|
+
private email = '';
|
|
8
|
+
private plan = '';
|
|
9
|
+
private storageUsed = '';
|
|
10
|
+
private storageLimit = '';
|
|
11
|
+
private hints = '';
|
|
12
|
+
|
|
13
|
+
constructor(screen: blessed.Widgets.Screen) {
|
|
14
|
+
this.screen = screen;
|
|
15
|
+
this.box = blessed.box({
|
|
16
|
+
parent: screen,
|
|
17
|
+
bottom: 0,
|
|
18
|
+
left: 0,
|
|
19
|
+
width: '100%',
|
|
20
|
+
height: 1,
|
|
21
|
+
tags: true,
|
|
22
|
+
style: {
|
|
23
|
+
fg: 'white',
|
|
24
|
+
bg: 'blue',
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
this.render();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
setUser(email: string, plan: string, storageUsed: string, storageLimit: string) {
|
|
31
|
+
this.email = email;
|
|
32
|
+
this.plan = plan;
|
|
33
|
+
this.storageUsed = storageUsed;
|
|
34
|
+
this.storageLimit = storageLimit;
|
|
35
|
+
this.render();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
setHints(hints: string) {
|
|
39
|
+
this.hints = hints;
|
|
40
|
+
this.render();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private render() {
|
|
44
|
+
const left = this.email
|
|
45
|
+
? `{bold}${this.email}{/bold} | ${this.plan} | ${this.storageUsed} / ${this.storageLimit}`
|
|
46
|
+
: '{bold}Tusky{/bold} — Decentralized Storage';
|
|
47
|
+
const right = this.hints ? `${this.hints} ` : '';
|
|
48
|
+
this.box.setContent(` ${left}${right ? ' | ' + right : ''}`);
|
|
49
|
+
this.screen.render();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
applyTheme(theme: Theme) {
|
|
53
|
+
this.box.style.bg = theme.statusBar.bg;
|
|
54
|
+
this.box.style.fg = theme.statusBar.fg;
|
|
55
|
+
this.render();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
destroy() {
|
|
59
|
+
this.box.destroy();
|
|
60
|
+
}
|
|
61
|
+
}
|