@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.
Files changed (104) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/src/commands/decrypt.d.ts.map +1 -1
  3. package/dist/src/commands/decrypt.js +53 -21
  4. package/dist/src/commands/decrypt.js.map +1 -1
  5. package/dist/src/commands/download.d.ts +1 -0
  6. package/dist/src/commands/download.d.ts.map +1 -1
  7. package/dist/src/commands/download.js +81 -11
  8. package/dist/src/commands/download.js.map +1 -1
  9. package/dist/src/commands/export.d.ts +6 -0
  10. package/dist/src/commands/export.d.ts.map +1 -1
  11. package/dist/src/commands/export.js +29 -17
  12. package/dist/src/commands/export.js.map +1 -1
  13. package/dist/src/commands/files.d.ts.map +1 -1
  14. package/dist/src/commands/files.js +89 -10
  15. package/dist/src/commands/files.js.map +1 -1
  16. package/dist/src/commands/folder.d.ts +3 -0
  17. package/dist/src/commands/folder.d.ts.map +1 -0
  18. package/dist/src/commands/folder.js +151 -0
  19. package/dist/src/commands/folder.js.map +1 -0
  20. package/dist/src/commands/rehydrate.d.ts +1 -0
  21. package/dist/src/commands/rehydrate.d.ts.map +1 -1
  22. package/dist/src/commands/rehydrate.js +15 -7
  23. package/dist/src/commands/rehydrate.js.map +1 -1
  24. package/dist/src/commands/trash.d.ts +3 -0
  25. package/dist/src/commands/trash.d.ts.map +1 -0
  26. package/dist/src/commands/trash.js +109 -0
  27. package/dist/src/commands/trash.js.map +1 -0
  28. package/dist/src/commands/upload.d.ts +4 -0
  29. package/dist/src/commands/upload.d.ts.map +1 -1
  30. package/dist/src/commands/upload.js +104 -3
  31. package/dist/src/commands/upload.js.map +1 -1
  32. package/dist/src/commands/wallet.d.ts +3 -0
  33. package/dist/src/commands/wallet.d.ts.map +1 -0
  34. package/dist/src/commands/wallet.js +126 -0
  35. package/dist/src/commands/wallet.js.map +1 -0
  36. package/dist/src/commands/webhook.d.ts +3 -0
  37. package/dist/src/commands/webhook.d.ts.map +1 -0
  38. package/dist/src/commands/webhook.js +172 -0
  39. package/dist/src/commands/webhook.js.map +1 -0
  40. package/dist/src/crypto.d.ts +16 -0
  41. package/dist/src/crypto.d.ts.map +1 -1
  42. package/dist/src/crypto.js +26 -0
  43. package/dist/src/crypto.js.map +1 -1
  44. package/dist/src/index.js +17 -3
  45. package/dist/src/index.js.map +1 -1
  46. package/dist/src/mcp/server.d.ts.map +1 -1
  47. package/dist/src/mcp/server.js +2 -1
  48. package/dist/src/mcp/server.js.map +1 -1
  49. package/dist/src/mcp/tools/files.d.ts.map +1 -1
  50. package/dist/src/mcp/tools/files.js +40 -5
  51. package/dist/src/mcp/tools/files.js.map +1 -1
  52. package/dist/src/seal.d.ts +16 -0
  53. package/dist/src/seal.d.ts.map +1 -1
  54. package/dist/src/seal.js +23 -0
  55. package/dist/src/seal.js.map +1 -1
  56. package/dist/src/tui/files-panel.d.ts +31 -1
  57. package/dist/src/tui/files-panel.d.ts.map +1 -1
  58. package/dist/src/tui/files-panel.js +118 -11
  59. package/dist/src/tui/files-panel.js.map +1 -1
  60. package/dist/src/tui/index.d.ts.map +1 -1
  61. package/dist/src/tui/index.js +272 -33
  62. package/dist/src/tui/index.js.map +1 -1
  63. package/dist/src/tui/overview.d.ts.map +1 -1
  64. package/dist/src/tui/overview.js +24 -8
  65. package/dist/src/tui/overview.js.map +1 -1
  66. package/dist/src/tui/trash-screen.d.ts +4 -0
  67. package/dist/src/tui/trash-screen.d.ts.map +1 -0
  68. package/dist/src/tui/trash-screen.js +190 -0
  69. package/dist/src/tui/trash-screen.js.map +1 -0
  70. package/dist/src/tui/vaults-panel.d.ts +8 -0
  71. package/dist/src/tui/vaults-panel.d.ts.map +1 -1
  72. package/dist/src/tui/vaults-panel.js +45 -6
  73. package/dist/src/tui/vaults-panel.js.map +1 -1
  74. package/dist/src/version.d.ts +2 -0
  75. package/dist/src/version.d.ts.map +1 -0
  76. package/dist/src/version.js +21 -0
  77. package/dist/src/version.js.map +1 -0
  78. package/package.json +3 -3
  79. package/src/commands/decrypt.ts +56 -23
  80. package/src/commands/download.ts +82 -11
  81. package/src/commands/export.ts +35 -19
  82. package/src/commands/files.ts +93 -9
  83. package/src/commands/folder.ts +169 -0
  84. package/src/commands/rehydrate.ts +15 -8
  85. package/src/commands/trash.ts +121 -0
  86. package/src/commands/upload.ts +126 -3
  87. package/src/commands/wallet.ts +183 -0
  88. package/src/commands/webhook.ts +193 -0
  89. package/src/crypto.ts +35 -0
  90. package/src/index.ts +17 -4
  91. package/src/mcp/server.ts +2 -1
  92. package/src/mcp/tools/files.ts +43 -5
  93. package/src/seal.ts +34 -1
  94. package/src/tui/files-panel.ts +139 -11
  95. package/src/tui/index.ts +289 -33
  96. package/src/tui/overview.ts +22 -8
  97. package/src/tui/trash-screen.ts +203 -0
  98. package/src/tui/vaults-panel.ts +55 -6
  99. package/src/version.ts +21 -0
  100. package/vitest.config.ts +1 -0
  101. package/dist/src/client.d.ts +0 -120
  102. package/dist/src/client.d.ts.map +0 -1
  103. package/dist/src/client.js +0 -152
  104. package/dist/src/client.js.map +0 -1
@@ -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 { createSpinner } from '../lib/progress.js';
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
- const spinner = createSpinner('Fetching file info...');
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
- const masterKey = loadMasterKey();
80
+ let masterKey = loadMasterKey();
78
81
  if (!masterKey) {
79
- spinner.fail('Encryption session not unlocked. Run: tusky encryption unlock');
80
- return;
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
- // Write to disk — use getStatus to get filename
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
- spinner.succeed(`Downloaded -> ${outputPath} (${formatBytes(fileBuffer.length)})`);
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
  }
@@ -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
- // Write to disk
227
- const outputPath = resolve(options.output);
228
- writeFileSync(outputPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
229
-
230
- spinner.succeed(`Exported ${exportedFiles.length} files from ${vaults.length} vault(s)`);
231
- console.log(chalk.dim(` Output: ${outputPath}`));
232
- console.log('');
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
- // Summary stats
235
- const withBlob = exportedFiles.filter((f) => f.walrusBlobId).length;
236
- const encrypted = exportedFiles.filter((f) => f.encrypted).length;
237
- const hotOnly = exportedFiles.filter((f) => !f.walrusBlobId && f.status === 'hot').length;
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
- console.log(` On Walrus: ${withBlob} files (recoverable from Walrus network)`);
240
- console.log(` Encrypted: ${encrypted} files (require passphrase to decrypt)`);
241
- if (hotOnly > 0) {
242
- console.log(chalk.yellow(` Hot only: ${hotOnly} files (NOT yet on Walrus — only in Tusky hot cache)`));
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
  }
@@ -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 table = createTable(['Name', 'Size', 'Status', 'Uploaded', 'ID']);
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
- table.push([
63
+ const row = [
57
64
  f.name,
58
65
  formatBytes(f.plaintextSizeBytes || f.sizeBytes),
59
66
  statusColor(f.status),
60
- formatDate(f.createdAt),
61
- chalk.dim(f.id),
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) console.log(`Folder: ${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)? This cannot be undone.`,
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
- const spinner = createSpinner('Fetching blob from Walrus...');
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
- // Sanitize blob ID for use as filename (replace non-filesystem-safe characters)
28
- const safeBlobId = blobId.replace(/[^a-zA-Z0-9_-]/g, '_');
29
- const outputPath = options.output || join(process.cwd(), `blob-${safeBlobId}`);
30
- writeFileSync(outputPath, fileBuffer);
31
-
32
- spinner.succeed(`Downloaded -> ${outputPath} (${formatBytes(fileBuffer.length)})`);
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);