@tuskydp/cli 0.2.0 → 0.3.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/CHANGELOG.md +20 -0
- package/dist/src/commands/decrypt.d.ts.map +1 -1
- package/dist/src/commands/decrypt.js +53 -21
- package/dist/src/commands/decrypt.js.map +1 -1
- package/dist/src/commands/download.d.ts +1 -0
- package/dist/src/commands/download.d.ts.map +1 -1
- package/dist/src/commands/download.js +81 -11
- package/dist/src/commands/download.js.map +1 -1
- package/dist/src/commands/export.d.ts +6 -0
- package/dist/src/commands/export.d.ts.map +1 -1
- package/dist/src/commands/export.js +29 -17
- package/dist/src/commands/export.js.map +1 -1
- package/dist/src/commands/files.d.ts.map +1 -1
- package/dist/src/commands/files.js +89 -10
- package/dist/src/commands/files.js.map +1 -1
- package/dist/src/commands/folder.d.ts +3 -0
- package/dist/src/commands/folder.d.ts.map +1 -0
- package/dist/src/commands/folder.js +151 -0
- package/dist/src/commands/folder.js.map +1 -0
- package/dist/src/commands/rehydrate.d.ts +1 -0
- package/dist/src/commands/rehydrate.d.ts.map +1 -1
- package/dist/src/commands/rehydrate.js +15 -7
- package/dist/src/commands/rehydrate.js.map +1 -1
- package/dist/src/commands/trash.d.ts +3 -0
- package/dist/src/commands/trash.d.ts.map +1 -0
- package/dist/src/commands/trash.js +109 -0
- package/dist/src/commands/trash.js.map +1 -0
- package/dist/src/commands/upload.d.ts +4 -0
- package/dist/src/commands/upload.d.ts.map +1 -1
- package/dist/src/commands/upload.js +104 -3
- package/dist/src/commands/upload.js.map +1 -1
- package/dist/src/commands/wallet.d.ts +3 -0
- package/dist/src/commands/wallet.d.ts.map +1 -0
- package/dist/src/commands/wallet.js +126 -0
- package/dist/src/commands/wallet.js.map +1 -0
- package/dist/src/commands/webhook.d.ts +3 -0
- package/dist/src/commands/webhook.d.ts.map +1 -0
- package/dist/src/commands/webhook.js +172 -0
- package/dist/src/commands/webhook.js.map +1 -0
- package/dist/src/crypto.d.ts +16 -0
- package/dist/src/crypto.d.ts.map +1 -1
- package/dist/src/crypto.js +26 -0
- package/dist/src/crypto.js.map +1 -1
- package/dist/src/index.js +17 -3
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcp/server.d.ts.map +1 -1
- package/dist/src/mcp/server.js +2 -1
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/mcp/tools/files.d.ts.map +1 -1
- package/dist/src/mcp/tools/files.js +40 -5
- package/dist/src/mcp/tools/files.js.map +1 -1
- package/dist/src/seal.d.ts +16 -0
- package/dist/src/seal.d.ts.map +1 -1
- package/dist/src/seal.js +23 -0
- package/dist/src/seal.js.map +1 -1
- package/dist/src/tui/files-panel.d.ts +31 -1
- package/dist/src/tui/files-panel.d.ts.map +1 -1
- package/dist/src/tui/files-panel.js +118 -11
- package/dist/src/tui/files-panel.js.map +1 -1
- package/dist/src/tui/index.d.ts.map +1 -1
- package/dist/src/tui/index.js +272 -33
- package/dist/src/tui/index.js.map +1 -1
- package/dist/src/tui/overview.d.ts.map +1 -1
- package/dist/src/tui/overview.js +24 -8
- package/dist/src/tui/overview.js.map +1 -1
- package/dist/src/tui/trash-screen.d.ts +4 -0
- package/dist/src/tui/trash-screen.d.ts.map +1 -0
- package/dist/src/tui/trash-screen.js +190 -0
- package/dist/src/tui/trash-screen.js.map +1 -0
- package/dist/src/tui/vaults-panel.d.ts +8 -0
- package/dist/src/tui/vaults-panel.d.ts.map +1 -1
- package/dist/src/tui/vaults-panel.js +45 -6
- package/dist/src/tui/vaults-panel.js.map +1 -1
- package/dist/src/version.d.ts +2 -0
- package/dist/src/version.d.ts.map +1 -0
- package/dist/src/version.js +21 -0
- package/dist/src/version.js.map +1 -0
- package/package.json +3 -3
- package/src/commands/decrypt.ts +56 -23
- package/src/commands/download.ts +82 -11
- package/src/commands/export.ts +35 -19
- package/src/commands/files.ts +93 -9
- package/src/commands/folder.ts +169 -0
- package/src/commands/rehydrate.ts +15 -8
- package/src/commands/trash.ts +121 -0
- package/src/commands/upload.ts +126 -3
- package/src/commands/wallet.ts +183 -0
- package/src/commands/webhook.ts +193 -0
- package/src/crypto.ts +35 -0
- package/src/index.ts +17 -4
- package/src/mcp/server.ts +2 -1
- package/src/mcp/tools/files.ts +43 -5
- package/src/seal.ts +34 -1
- package/src/tui/files-panel.ts +139 -11
- package/src/tui/index.ts +289 -33
- package/src/tui/overview.ts +22 -8
- package/src/tui/trash-screen.ts +203 -0
- package/src/tui/vaults-panel.ts +55 -6
- package/src/version.ts +21 -0
- package/vitest.config.ts +1 -0
- package/dist/src/client.d.ts +0 -120
- package/dist/src/client.d.ts.map +0 -1
- package/dist/src/client.js +0 -152
- package/dist/src/client.js.map +0 -1
package/src/commands/download.ts
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
2
|
import { writeFileSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
|
+
import ora from 'ora';
|
|
4
5
|
import chalk from 'chalk';
|
|
5
6
|
import { getApiUrl, getApiKey } from '../config.js';
|
|
6
7
|
import { createSDKClient } from '../sdk.js';
|
|
7
8
|
import { formatBytes } from '../lib/output.js';
|
|
8
|
-
import {
|
|
9
|
-
import { decryptBuffer } from '../crypto.js';
|
|
9
|
+
import { decryptBuffer, decryptMetadata, deriveMasterKey, unwrapMasterKey, verifyPassphrase } from '../crypto.js';
|
|
10
10
|
import { loadMasterKey } from '../lib/keyring.js';
|
|
11
|
-
import { sealDecrypt, getSuiKeypair } from '../seal.js';
|
|
11
|
+
import { sealDecrypt, sealDecryptMetadata, getSuiKeypair } from '../seal.js';
|
|
12
12
|
|
|
13
13
|
export async function downloadCommand(fileId: string, options: {
|
|
14
14
|
output?: string;
|
|
15
|
+
stdout?: boolean;
|
|
15
16
|
}, program: Command) {
|
|
16
17
|
const apiUrl = getApiUrl(program.opts().apiUrl);
|
|
17
18
|
const apiKey = getApiKey(program.opts().apiKey);
|
|
18
19
|
const sdk = createSDKClient(apiUrl, apiKey);
|
|
19
|
-
|
|
20
|
+
// When piping to stdout, send spinner to stderr to avoid polluting the binary stream
|
|
21
|
+
const spinnerStream = options.stdout ? process.stderr : process.stdout;
|
|
22
|
+
const spinner = ora({ text: 'Fetching file info...', stream: spinnerStream as NodeJS.WritableStream });
|
|
20
23
|
spinner.start();
|
|
21
24
|
|
|
22
25
|
try {
|
|
@@ -74,10 +77,31 @@ export async function downloadCommand(fileId: string, options: {
|
|
|
74
77
|
} else {
|
|
75
78
|
// Passphrase-encrypted (private vault)
|
|
76
79
|
spinner.text = 'Decrypting...';
|
|
77
|
-
|
|
80
|
+
let masterKey = loadMasterKey();
|
|
78
81
|
if (!masterKey) {
|
|
79
|
-
|
|
80
|
-
|
|
82
|
+
// Try TUSKYDP_PASSWORD env var (sandbox / agent mode)
|
|
83
|
+
const password = process.env.TUSKYDP_PASSWORD;
|
|
84
|
+
if (password) {
|
|
85
|
+
const params = await sdk.account.getEncryptionParams();
|
|
86
|
+
if (params.salt) {
|
|
87
|
+
const salt = Buffer.from(params.salt, 'base64');
|
|
88
|
+
const wrappingKey = deriveMasterKey(password, salt);
|
|
89
|
+
if (params.verifier) {
|
|
90
|
+
const verifierBuf = Buffer.from(params.verifier, 'base64');
|
|
91
|
+
if (!verifyPassphrase(wrappingKey, verifierBuf)) {
|
|
92
|
+
spinner.fail('TUSKYDP_PASSWORD is incorrect.');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
masterKey = params.encryptedMasterKey
|
|
97
|
+
? unwrapMasterKey(Buffer.from(params.encryptedMasterKey, 'base64'), wrappingKey)
|
|
98
|
+
: wrappingKey;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!masterKey) {
|
|
102
|
+
spinner.fail('Encryption session not unlocked. Run: tusky encryption unlock (or set TUSKYDP_PASSWORD env var)');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
81
105
|
}
|
|
82
106
|
// Narrow to passphrase type
|
|
83
107
|
const enc = encryption as { type: 'passphrase'; encrypted: true; wrappedKey: string; iv: string; plaintextChecksumSha256: string | null };
|
|
@@ -91,12 +115,59 @@ export async function downloadCommand(fileId: string, options: {
|
|
|
91
115
|
}
|
|
92
116
|
}
|
|
93
117
|
|
|
94
|
-
|
|
118
|
+
|
|
119
|
+
// Output: stdout or disk
|
|
95
120
|
const fileInfo = await sdk.files.getStatus(fileId);
|
|
96
|
-
const outputPath = options.output || join(process.cwd(), fileInfo.name);
|
|
97
|
-
writeFileSync(outputPath, fileBuffer);
|
|
98
121
|
|
|
99
|
-
|
|
122
|
+
// Resolve the real filename — decrypt if nameEncrypted
|
|
123
|
+
let displayName = fileInfo.name;
|
|
124
|
+
if (fileInfo.nameEncrypted) {
|
|
125
|
+
try {
|
|
126
|
+
if (fileInfo.encryptedName) {
|
|
127
|
+
// Private vault — decrypt with master key
|
|
128
|
+
let mk = loadMasterKey();
|
|
129
|
+
if (!mk) {
|
|
130
|
+
const passphrase = process.env.TUSKYDP_PASSWORD;
|
|
131
|
+
if (passphrase) {
|
|
132
|
+
const params = await sdk.account.getEncryptionParams();
|
|
133
|
+
if (params.salt) {
|
|
134
|
+
const saltBuf = Buffer.from(params.salt, 'base64');
|
|
135
|
+
const wk = deriveMasterKey(passphrase, saltBuf);
|
|
136
|
+
mk = params.encryptedMasterKey
|
|
137
|
+
? unwrapMasterKey(Buffer.from(params.encryptedMasterKey, 'base64'), wk)
|
|
138
|
+
: wk;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (mk) {
|
|
143
|
+
const meta = decryptMetadata(fileInfo.encryptedName, mk);
|
|
144
|
+
displayName = meta.n;
|
|
145
|
+
}
|
|
146
|
+
} else if (fileInfo.encryptedMetadata) {
|
|
147
|
+
// Shared vault — decrypt with SEAL keypair
|
|
148
|
+
const keypair = getSuiKeypair();
|
|
149
|
+
if (keypair) {
|
|
150
|
+
const fileObj = await sdk.files.get(fileId);
|
|
151
|
+
const vault = await sdk.vaults.get(fileObj.vaultId);
|
|
152
|
+
const meta = await sealDecryptMetadata(fileInfo.encryptedMetadata, vault, keypair);
|
|
153
|
+
displayName = meta.n;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// Fall back to placeholder if decryption fails
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if (options.stdout) {
|
|
163
|
+
// Pipe raw bytes to stdout — stop spinner first so it doesn't interleave
|
|
164
|
+
spinner.stop();
|
|
165
|
+
process.stdout.write(fileBuffer);
|
|
166
|
+
} else {
|
|
167
|
+
const outputPath = options.output || join(process.cwd(), displayName);
|
|
168
|
+
writeFileSync(outputPath, fileBuffer);
|
|
169
|
+
spinner.succeed(`Downloaded -> ${outputPath} (${formatBytes(fileBuffer.length)})`);
|
|
170
|
+
}
|
|
100
171
|
} catch (err: any) {
|
|
101
172
|
spinner.fail(`Download failed: ${err.message}`);
|
|
102
173
|
}
|
package/src/commands/export.ts
CHANGED
|
@@ -45,6 +45,12 @@ export interface ExportedFile {
|
|
|
45
45
|
plaintextChecksumSha256: string | null;
|
|
46
46
|
/** Original plaintext size in bytes (before encryption) */
|
|
47
47
|
plaintextSizeBytes: number | null;
|
|
48
|
+
/** Whether the filename is encrypted */
|
|
49
|
+
nameEncrypted: boolean;
|
|
50
|
+
/** AES-256-GCM encrypted filename+mimeType blob (private vaults) */
|
|
51
|
+
encryptedName: string | null;
|
|
52
|
+
/** SEAL-encrypted filename+mimeType blob (shared vaults) */
|
|
53
|
+
encryptedMetadata: string | null;
|
|
48
54
|
createdAt: string;
|
|
49
55
|
}
|
|
50
56
|
|
|
@@ -81,8 +87,9 @@ export function registerExportCommand(program: Command) {
|
|
|
81
87
|
.command('export')
|
|
82
88
|
.description('Export file metadata for disaster recovery (Walrus blob IDs, encryption keys)')
|
|
83
89
|
.option('-o, --output <path>', 'Output file path', 'tusky-export.json')
|
|
90
|
+
.option('--stdout', 'Write JSON manifest to stdout instead of a file')
|
|
84
91
|
.option('--vault <vaultId>', 'Export only files from a specific vault')
|
|
85
|
-
.action(async (options: { output: string; vault?: string }) => {
|
|
92
|
+
.action(async (options: { output: string; stdout?: boolean; vault?: string }) => {
|
|
86
93
|
const apiUrl = getApiUrl(program.opts().apiUrl);
|
|
87
94
|
const apiKey = getApiKey(program.opts().apiKey);
|
|
88
95
|
const sdk = createSDKClient(apiUrl, apiKey);
|
|
@@ -143,6 +150,9 @@ export function registerExportCommand(program: Command) {
|
|
|
143
150
|
encryptionIv: file.encryptionIv,
|
|
144
151
|
plaintextChecksumSha256: file.plaintextChecksumSha256,
|
|
145
152
|
plaintextSizeBytes: file.plaintextSizeBytes,
|
|
153
|
+
nameEncrypted: file.nameEncrypted ?? false,
|
|
154
|
+
encryptedName: file.encryptedName ?? null,
|
|
155
|
+
encryptedMetadata: file.encryptedMetadata ?? null,
|
|
146
156
|
createdAt: file.createdAt,
|
|
147
157
|
});
|
|
148
158
|
}
|
|
@@ -223,27 +233,33 @@ export function registerExportCommand(program: Command) {
|
|
|
223
233
|
},
|
|
224
234
|
};
|
|
225
235
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
236
|
+
if (options.stdout) {
|
|
237
|
+
spinner.stop();
|
|
238
|
+
process.stdout.write(JSON.stringify(manifest, null, 2) + '\n');
|
|
239
|
+
} else {
|
|
240
|
+
// Write to disk
|
|
241
|
+
const outputPath = resolve(options.output);
|
|
242
|
+
writeFileSync(outputPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
|
243
|
+
spinner.succeed(`Exported ${exportedFiles.length} files from ${vaults.length} vault(s)`);
|
|
244
|
+
console.log(chalk.dim(` Output: ${outputPath}`));
|
|
245
|
+
console.log('');
|
|
246
|
+
}
|
|
233
247
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
248
|
+
if (!options.stdout) {
|
|
249
|
+
// Summary stats (only for disk output — stdout is clean JSON)
|
|
250
|
+
const withBlob = exportedFiles.filter((f) => f.walrusBlobId).length;
|
|
251
|
+
const encrypted = exportedFiles.filter((f) => f.encrypted).length;
|
|
252
|
+
const hotOnly = exportedFiles.filter((f) => !f.walrusBlobId && f.status === 'hot').length;
|
|
238
253
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
254
|
+
console.log(` On Walrus: ${withBlob} files (recoverable from Walrus network)`);
|
|
255
|
+
console.log(` Encrypted: ${encrypted} files (require passphrase to decrypt)`);
|
|
256
|
+
if (hotOnly > 0) {
|
|
257
|
+
console.log(chalk.yellow(` Hot only: ${hotOnly} files (NOT yet on Walrus — only in Tusky hot cache)`));
|
|
258
|
+
}
|
|
259
|
+
console.log('');
|
|
260
|
+
console.log(chalk.yellow(' Store this file securely — it contains encryption keys.'));
|
|
261
|
+
console.log(chalk.dim(' See the "recovery.instructions" field inside for full recovery steps.'));
|
|
243
262
|
}
|
|
244
|
-
console.log('');
|
|
245
|
-
console.log(chalk.yellow(' Store this file securely — it contains encryption keys.'));
|
|
246
|
-
console.log(chalk.dim(' See the "recovery.instructions" field inside for full recovery steps.'));
|
|
247
263
|
} catch (err: any) {
|
|
248
264
|
spinner.fail(`Export failed: ${err.message}`);
|
|
249
265
|
}
|
package/src/commands/files.ts
CHANGED
|
@@ -3,7 +3,7 @@ import chalk from 'chalk';
|
|
|
3
3
|
import inquirer from 'inquirer';
|
|
4
4
|
import { cliConfig } from '../config.js';
|
|
5
5
|
import { getSDKClient } from '../sdk.js';
|
|
6
|
-
import { createTable, formatBytes, formatDate } from '../lib/output.js';
|
|
6
|
+
import { createTable, formatBytes, formatDate, shortId } from '../lib/output.js';
|
|
7
7
|
import { resolveVault } from '../lib/resolve.js';
|
|
8
8
|
|
|
9
9
|
export function registerFileCommands(program: Command) {
|
|
@@ -12,6 +12,7 @@ export function registerFileCommands(program: Command) {
|
|
|
12
12
|
.description('List files')
|
|
13
13
|
.option('--sort <field>', 'Sort by: name, size, date', 'date')
|
|
14
14
|
.option('--limit <n>', 'Max results', '50')
|
|
15
|
+
.option('--folder <folder-id>', 'Filter by folder ID')
|
|
15
16
|
.option('-f, --follow', 'Watch mode: refresh every 3 seconds')
|
|
16
17
|
.action(async (vaultRef: string | undefined, options) => {
|
|
17
18
|
const sdk = getSDKClient(program);
|
|
@@ -27,6 +28,7 @@ export function registerFileCommands(program: Command) {
|
|
|
27
28
|
const fetchAndRender = async (): Promise<void> => {
|
|
28
29
|
const { files } = await sdk.files.list({
|
|
29
30
|
vaultId,
|
|
31
|
+
folderId: options.folder,
|
|
30
32
|
limit: parseInt(options.limit),
|
|
31
33
|
sortBy: sortMap[options.sort] as 'createdAt' | 'name' | 'sizeBytes',
|
|
32
34
|
order: 'desc',
|
|
@@ -42,7 +44,12 @@ export function registerFileCommands(program: Command) {
|
|
|
42
44
|
return;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
const
|
|
47
|
+
const showFolder = !options.folder && files.some(f => f.folderId);
|
|
48
|
+
const headers = showFolder
|
|
49
|
+
? ['Name', 'Size', 'Status', 'Folder', 'Uploaded', 'ID']
|
|
50
|
+
: ['Name', 'Size', 'Status', 'Uploaded', 'ID'];
|
|
51
|
+
|
|
52
|
+
const table = createTable(headers);
|
|
46
53
|
for (const f of files) {
|
|
47
54
|
const statusColors: Record<string, typeof chalk.green> = {
|
|
48
55
|
hot: chalk.green,
|
|
@@ -53,13 +60,17 @@ export function registerFileCommands(program: Command) {
|
|
|
53
60
|
};
|
|
54
61
|
const statusColor = statusColors[f.status] || chalk.white;
|
|
55
62
|
|
|
56
|
-
|
|
63
|
+
const row = [
|
|
57
64
|
f.name,
|
|
58
65
|
formatBytes(f.plaintextSizeBytes || f.sizeBytes),
|
|
59
66
|
statusColor(f.status),
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
];
|
|
68
|
+
if (showFolder) {
|
|
69
|
+
row.push(f.folderId ? chalk.dim(shortId(f.folderId)) : chalk.dim('-'));
|
|
70
|
+
}
|
|
71
|
+
row.push(formatDate(f.createdAt));
|
|
72
|
+
row.push(chalk.dim(f.id));
|
|
73
|
+
table.push(row);
|
|
63
74
|
}
|
|
64
75
|
console.log(table.toString());
|
|
65
76
|
};
|
|
@@ -107,9 +118,21 @@ export function registerFileCommands(program: Command) {
|
|
|
107
118
|
console.log(`Status: ${file.status}`);
|
|
108
119
|
console.log(`Encrypted: ${file.encrypted ? 'Yes' : 'No'}`);
|
|
109
120
|
console.log(`Vault: ${file.vaultId}`);
|
|
110
|
-
if (file.folderId)
|
|
121
|
+
if (file.folderId) {
|
|
122
|
+
// Try to resolve folder name
|
|
123
|
+
try {
|
|
124
|
+
const folder = await sdk.folders.get(file.folderId);
|
|
125
|
+
console.log(`Folder: ${folder.name} (${file.folderId})`);
|
|
126
|
+
} catch {
|
|
127
|
+
console.log(`Folder: ${file.folderId}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
111
130
|
if (file.walrusBlobId) console.log(`Walrus Blob: ${file.walrusBlobId}`);
|
|
131
|
+
if (file.walrusBlobObjectId) console.log(`Blob Object: ${file.walrusBlobObjectId}`);
|
|
112
132
|
if (file.lastSyncError) console.log(`Sync Error: ${chalk.red(file.lastSyncError)}`);
|
|
133
|
+
if (file.autoRenew) console.log(`Auto-Renew: ${chalk.green('On')}`);
|
|
134
|
+
if (file.ppuEpochEnd) console.log(`PPU Epoch: ${new Date(file.ppuEpochEnd).toLocaleString()}`);
|
|
135
|
+
if (file.hotUntil) console.log(`Hot Until: ${new Date(file.hotUntil).toLocaleString()}`);
|
|
113
136
|
console.log(`Uploaded: ${new Date(file.createdAt).toISOString()}`);
|
|
114
137
|
console.log(`ID: ${file.id}`);
|
|
115
138
|
});
|
|
@@ -165,7 +188,7 @@ export function registerFileCommands(program: Command) {
|
|
|
165
188
|
|
|
166
189
|
// rm command
|
|
167
190
|
program.command('rm <file-ids...>')
|
|
168
|
-
.description('Delete one or more files')
|
|
191
|
+
.description('Delete one or more files (moves to trash)')
|
|
169
192
|
.option('--force', 'Skip confirmation prompt')
|
|
170
193
|
.action(async (fileIds: string[], options) => {
|
|
171
194
|
const sdk = getSDKClient(program);
|
|
@@ -174,7 +197,7 @@ export function registerFileCommands(program: Command) {
|
|
|
174
197
|
const answers = await inquirer.prompt([{
|
|
175
198
|
type: 'confirm',
|
|
176
199
|
name: 'confirm',
|
|
177
|
-
message: `Delete ${fileIds.length} file(s)?
|
|
200
|
+
message: `Delete ${fileIds.length} file(s)? Files will be moved to trash.`,
|
|
178
201
|
default: false,
|
|
179
202
|
}]);
|
|
180
203
|
if (!answers.confirm) return;
|
|
@@ -189,4 +212,65 @@ export function registerFileCommands(program: Command) {
|
|
|
189
212
|
}
|
|
190
213
|
}
|
|
191
214
|
});
|
|
215
|
+
|
|
216
|
+
// mv command — move file to a folder
|
|
217
|
+
program.command('mv <file-id>')
|
|
218
|
+
.description('Move a file to a different folder (or vault root)')
|
|
219
|
+
.option('--folder <folder-id>', 'Target folder ID')
|
|
220
|
+
.option('--root', 'Move to vault root (no folder)')
|
|
221
|
+
.action(async (fileId: string, options) => {
|
|
222
|
+
const sdk = getSDKClient(program);
|
|
223
|
+
|
|
224
|
+
if (!options.folder && !options.root) {
|
|
225
|
+
console.error(chalk.red('Specify --folder <id> or --root'));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const folderId = options.root ? null : options.folder;
|
|
230
|
+
await sdk.files.move(fileId, { folderId });
|
|
231
|
+
console.log(chalk.green(folderId ? `File moved to folder ${folderId}` : 'File moved to vault root'));
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// extend command — extend PPU storage epochs
|
|
235
|
+
program.command('extend <file-id>')
|
|
236
|
+
.description('Extend PPU storage by purchasing additional epochs')
|
|
237
|
+
.requiredOption('--epochs <n>', 'Number of additional epochs to purchase')
|
|
238
|
+
.action(async (fileId: string, options) => {
|
|
239
|
+
const sdk = getSDKClient(program);
|
|
240
|
+
const epochs = parseInt(options.epochs);
|
|
241
|
+
if (isNaN(epochs) || epochs <= 0) {
|
|
242
|
+
console.error(chalk.red('Epochs must be a positive integer.'));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const result = await sdk.files.extend(fileId, { epochs });
|
|
247
|
+
console.log(chalk.green(`Extended ${fileId} by ${epochs} epoch(s).`));
|
|
248
|
+
if (result.payment) {
|
|
249
|
+
console.log(` WAL cost: ${result.payment.walMist} MIST`);
|
|
250
|
+
}
|
|
251
|
+
if (result.file.ppuEpochEnd) {
|
|
252
|
+
console.log(` New epoch end: ${new Date(result.file.ppuEpochEnd).toLocaleString()}`);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// auto-renew command
|
|
257
|
+
program.command('auto-renew <file-id>')
|
|
258
|
+
.description('Toggle auto-renewal for a file\'s Walrus storage')
|
|
259
|
+
.option('--on', 'Enable auto-renew')
|
|
260
|
+
.option('--off', 'Disable auto-renew')
|
|
261
|
+
.action(async (fileId: string, options) => {
|
|
262
|
+
const sdk = getSDKClient(program);
|
|
263
|
+
|
|
264
|
+
if (!options.on && !options.off) {
|
|
265
|
+
// Show current status
|
|
266
|
+
const file = await sdk.files.get(fileId);
|
|
267
|
+
console.log(`Auto-Renew: ${file.autoRenew ? chalk.green('On') : chalk.dim('Off')}`);
|
|
268
|
+
console.log(chalk.dim('Use --on or --off to change.'));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const autoRenew = !!options.on;
|
|
273
|
+
await sdk.files.setAutoRenew(fileId, { autoRenew });
|
|
274
|
+
console.log(chalk.green(`Auto-renew ${autoRenew ? 'enabled' : 'disabled'} for ${fileId}`));
|
|
275
|
+
});
|
|
192
276
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import inquirer from 'inquirer';
|
|
4
|
+
import { cliConfig } from '../config.js';
|
|
5
|
+
import { getSDKClientFromParent } from '../sdk.js';
|
|
6
|
+
import { createTable, formatDate, shortId } from '../lib/output.js';
|
|
7
|
+
import { resolveVault } from '../lib/resolve.js';
|
|
8
|
+
|
|
9
|
+
export function registerFolderCommands(program: Command) {
|
|
10
|
+
const folder = program.command('folder').description('Manage folders within vaults');
|
|
11
|
+
|
|
12
|
+
// ── create ──────────────────────────────────────────────────────────
|
|
13
|
+
folder.command('create <name>')
|
|
14
|
+
.description('Create a folder in a vault')
|
|
15
|
+
.requiredOption('--vault <vault>', 'Target vault (ID, slug, or name)')
|
|
16
|
+
.option('--parent <parent-id>', 'Parent folder ID (for nested folders)')
|
|
17
|
+
.action(async (name: string, options) => {
|
|
18
|
+
const sdk = getSDKClientFromParent(folder);
|
|
19
|
+
const vaultId = await resolveVault(sdk, options.vault);
|
|
20
|
+
|
|
21
|
+
const created = await sdk.folders.create({
|
|
22
|
+
vaultId,
|
|
23
|
+
parentId: options.parent,
|
|
24
|
+
name,
|
|
25
|
+
});
|
|
26
|
+
console.log(chalk.green(`Folder "${created.name}" created (${created.id})`));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// ── list ────────────────────────────────────────────────────────────
|
|
30
|
+
folder.command('list')
|
|
31
|
+
.description('List folders in a vault')
|
|
32
|
+
.requiredOption('--vault <vault>', 'Target vault (ID, slug, or name)')
|
|
33
|
+
.option('--parent <parent-id>', 'Parent folder ID (list children of this folder)')
|
|
34
|
+
.action(async (options) => {
|
|
35
|
+
const sdk = getSDKClientFromParent(folder);
|
|
36
|
+
const root = folder.parent || folder;
|
|
37
|
+
const format = root.opts().format || cliConfig.get('outputFormat');
|
|
38
|
+
const vaultId = await resolveVault(sdk, options.vault);
|
|
39
|
+
|
|
40
|
+
const folders = await sdk.folders.list({ vaultId, parentId: options.parent });
|
|
41
|
+
|
|
42
|
+
if (format === 'json') {
|
|
43
|
+
console.log(JSON.stringify(folders, null, 2));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (folders.length === 0) {
|
|
48
|
+
console.log(chalk.dim('No folders found.'));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const table = createTable(['Name', 'Parent', 'Created', 'ID']);
|
|
53
|
+
for (const f of folders) {
|
|
54
|
+
table.push([
|
|
55
|
+
f.name,
|
|
56
|
+
f.parentId ? chalk.dim(shortId(f.parentId)) : chalk.dim('(root)'),
|
|
57
|
+
formatDate(f.createdAt),
|
|
58
|
+
chalk.dim(shortId(f.id)),
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
61
|
+
console.log(table.toString());
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ── info ────────────────────────────────────────────────────────────
|
|
65
|
+
folder.command('info <folder-id>')
|
|
66
|
+
.description('Show folder details')
|
|
67
|
+
.action(async (folderId: string) => {
|
|
68
|
+
const sdk = getSDKClientFromParent(folder);
|
|
69
|
+
const root = folder.parent || folder;
|
|
70
|
+
const format = root.opts().format || cliConfig.get('outputFormat');
|
|
71
|
+
const f = await sdk.folders.get(folderId);
|
|
72
|
+
|
|
73
|
+
if (format === 'json') {
|
|
74
|
+
console.log(JSON.stringify(f, null, 2));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.log(`Name: ${f.name}`);
|
|
79
|
+
console.log(`ID: ${f.id}`);
|
|
80
|
+
console.log(`Vault: ${f.vaultId}`);
|
|
81
|
+
console.log(`Parent: ${f.parentId || chalk.dim('(root)')}`);
|
|
82
|
+
console.log(`Created: ${new Date(f.createdAt).toLocaleString()}`);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── contents ────────────────────────────────────────────────────────
|
|
86
|
+
folder.command('contents <folder-id>')
|
|
87
|
+
.description('List folder contents (files and subfolders)')
|
|
88
|
+
.action(async (folderId: string) => {
|
|
89
|
+
const sdk = getSDKClientFromParent(folder);
|
|
90
|
+
const root = folder.parent || folder;
|
|
91
|
+
const format = root.opts().format || cliConfig.get('outputFormat');
|
|
92
|
+
const contents = await sdk.folders.getContents(folderId);
|
|
93
|
+
|
|
94
|
+
if (format === 'json') {
|
|
95
|
+
console.log(JSON.stringify(contents, null, 2));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (contents.folders.length === 0 && contents.files.length === 0) {
|
|
100
|
+
console.log(chalk.dim('Folder is empty.'));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (contents.folders.length > 0) {
|
|
105
|
+
console.log(chalk.bold('Subfolders:'));
|
|
106
|
+
const folderTable = createTable(['Name', 'Created', 'ID']);
|
|
107
|
+
for (const f of contents.folders) {
|
|
108
|
+
folderTable.push([
|
|
109
|
+
f.name,
|
|
110
|
+
formatDate(f.createdAt),
|
|
111
|
+
chalk.dim(shortId(f.id)),
|
|
112
|
+
]);
|
|
113
|
+
}
|
|
114
|
+
console.log(folderTable.toString());
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (contents.files.length > 0) {
|
|
118
|
+
if (contents.folders.length > 0) console.log('');
|
|
119
|
+
console.log(chalk.bold('Files:'));
|
|
120
|
+
const { formatBytes } = await import('../lib/output.js');
|
|
121
|
+
const fileTable = createTable(['Name', 'Size', 'Status', 'Created', 'ID']);
|
|
122
|
+
const statusColors: Record<string, typeof chalk.green> = {
|
|
123
|
+
hot: chalk.green, synced: chalk.blue, cold: chalk.yellow,
|
|
124
|
+
error: chalk.red, uploading: chalk.dim,
|
|
125
|
+
};
|
|
126
|
+
for (const f of contents.files) {
|
|
127
|
+
const sc = statusColors[f.status] || chalk.white;
|
|
128
|
+
fileTable.push([
|
|
129
|
+
f.name,
|
|
130
|
+
formatBytes(f.sizeBytes),
|
|
131
|
+
sc(f.status),
|
|
132
|
+
formatDate(f.createdAt),
|
|
133
|
+
chalk.dim(shortId(f.id)),
|
|
134
|
+
]);
|
|
135
|
+
}
|
|
136
|
+
console.log(fileTable.toString());
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ── rename ──────────────────────────────────────────────────────────
|
|
141
|
+
folder.command('rename <folder-id> <new-name>')
|
|
142
|
+
.description('Rename a folder')
|
|
143
|
+
.action(async (folderId: string, newName: string) => {
|
|
144
|
+
const sdk = getSDKClientFromParent(folder);
|
|
145
|
+
await sdk.folders.update(folderId, { name: newName });
|
|
146
|
+
console.log(chalk.green(`Folder renamed to "${newName}"`));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ── delete ──────────────────────────────────────────────────────────
|
|
150
|
+
folder.command('delete <folder-id>')
|
|
151
|
+
.description('Delete a folder (must be empty)')
|
|
152
|
+
.option('--force', 'Skip confirmation prompt')
|
|
153
|
+
.action(async (folderId: string, options) => {
|
|
154
|
+
const sdk = getSDKClientFromParent(folder);
|
|
155
|
+
|
|
156
|
+
if (!options.force) {
|
|
157
|
+
const answers = await inquirer.prompt([{
|
|
158
|
+
type: 'confirm',
|
|
159
|
+
name: 'confirm',
|
|
160
|
+
message: 'Delete folder? It must be empty.',
|
|
161
|
+
default: false,
|
|
162
|
+
}]);
|
|
163
|
+
if (!answers.confirm) return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await sdk.folders.delete(folderId);
|
|
167
|
+
console.log(chalk.green('Folder deleted.'));
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
2
|
import { writeFileSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
|
+
import ora from 'ora';
|
|
4
5
|
import { resolveWalrusConfig } from '@tuskydp/shared/walrus-networks.js';
|
|
5
6
|
import { formatBytes } from '../lib/output.js';
|
|
6
|
-
import { createSpinner } from '../lib/progress.js';
|
|
7
7
|
|
|
8
8
|
export async function rehydrateCommand(blobId: string, options: {
|
|
9
9
|
output?: string;
|
|
10
|
+
stdout?: boolean;
|
|
10
11
|
}, _program: Command) {
|
|
11
|
-
|
|
12
|
+
// When piping to stdout, send spinner to stderr to avoid polluting the binary stream
|
|
13
|
+
const spinnerStream = options.stdout ? process.stderr : process.stdout;
|
|
14
|
+
const spinner = ora({ text: 'Fetching blob from Walrus...', stream: spinnerStream as NodeJS.WritableStream });
|
|
12
15
|
spinner.start();
|
|
13
16
|
|
|
14
17
|
try {
|
|
@@ -24,12 +27,16 @@ export async function rehydrateCommand(blobId: string, options: {
|
|
|
24
27
|
const arrayBuf = await response.arrayBuffer();
|
|
25
28
|
const fileBuffer = Buffer.from(new Uint8Array(arrayBuf));
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
if (options.stdout) {
|
|
31
|
+
spinner.stop();
|
|
32
|
+
process.stdout.write(fileBuffer);
|
|
33
|
+
} else {
|
|
34
|
+
// Sanitize blob ID for use as filename (replace non-filesystem-safe characters)
|
|
35
|
+
const safeBlobId = blobId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
36
|
+
const outputPath = options.output || join(process.cwd(), `blob-${safeBlobId}`);
|
|
37
|
+
writeFileSync(outputPath, fileBuffer);
|
|
38
|
+
spinner.succeed(`Downloaded -> ${outputPath} (${formatBytes(fileBuffer.length)})`);
|
|
39
|
+
}
|
|
33
40
|
} catch (err: any) {
|
|
34
41
|
spinner.fail(`Rehydrate failed: ${err.message}`);
|
|
35
42
|
process.exit(1);
|