@tuskydp/cli 0.2.1 → 0.4.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 (160) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/src/commands/account.d.ts.map +1 -1
  3. package/dist/src/commands/account.js +0 -1
  4. package/dist/src/commands/account.js.map +1 -1
  5. package/dist/src/commands/auth.d.ts.map +1 -1
  6. package/dist/src/commands/auth.js +8 -5
  7. package/dist/src/commands/auth.js.map +1 -1
  8. package/dist/src/commands/download.d.ts +1 -0
  9. package/dist/src/commands/download.d.ts.map +1 -1
  10. package/dist/src/commands/download.js +35 -22
  11. package/dist/src/commands/download.js.map +1 -1
  12. package/dist/src/commands/export.d.ts +9 -24
  13. package/dist/src/commands/export.d.ts.map +1 -1
  14. package/dist/src/commands/export.js +31 -59
  15. package/dist/src/commands/export.js.map +1 -1
  16. package/dist/src/commands/files.d.ts.map +1 -1
  17. package/dist/src/commands/files.js +91 -12
  18. package/dist/src/commands/files.js.map +1 -1
  19. package/dist/src/commands/folder.d.ts +3 -0
  20. package/dist/src/commands/folder.d.ts.map +1 -0
  21. package/dist/src/commands/folder.js +151 -0
  22. package/dist/src/commands/folder.js.map +1 -0
  23. package/dist/src/commands/mcp.d.ts.map +1 -1
  24. package/dist/src/commands/mcp.js +15 -9
  25. package/dist/src/commands/mcp.js.map +1 -1
  26. package/dist/src/commands/rehydrate.d.ts +1 -0
  27. package/dist/src/commands/rehydrate.d.ts.map +1 -1
  28. package/dist/src/commands/rehydrate.js +15 -7
  29. package/dist/src/commands/rehydrate.js.map +1 -1
  30. package/dist/src/commands/sui.d.ts +3 -0
  31. package/dist/src/commands/sui.d.ts.map +1 -0
  32. package/dist/src/commands/sui.js +64 -0
  33. package/dist/src/commands/sui.js.map +1 -0
  34. package/dist/src/commands/trash.d.ts +3 -0
  35. package/dist/src/commands/trash.d.ts.map +1 -0
  36. package/dist/src/commands/trash.js +109 -0
  37. package/dist/src/commands/trash.js.map +1 -0
  38. package/dist/src/commands/upload.d.ts +4 -0
  39. package/dist/src/commands/upload.d.ts.map +1 -1
  40. package/dist/src/commands/upload.js +82 -27
  41. package/dist/src/commands/upload.js.map +1 -1
  42. package/dist/src/commands/vault.d.ts.map +1 -1
  43. package/dist/src/commands/vault.js +2 -24
  44. package/dist/src/commands/vault.js.map +1 -1
  45. package/dist/src/commands/wallet.d.ts +3 -0
  46. package/dist/src/commands/wallet.d.ts.map +1 -0
  47. package/dist/src/commands/wallet.js +126 -0
  48. package/dist/src/commands/wallet.js.map +1 -0
  49. package/dist/src/commands/webhook.d.ts +3 -0
  50. package/dist/src/commands/webhook.d.ts.map +1 -0
  51. package/dist/src/commands/webhook.js +172 -0
  52. package/dist/src/commands/webhook.js.map +1 -0
  53. package/dist/src/config.d.ts +2 -2
  54. package/dist/src/config.d.ts.map +1 -1
  55. package/dist/src/config.js +2 -3
  56. package/dist/src/config.js.map +1 -1
  57. package/dist/src/index.js +19 -9
  58. package/dist/src/index.js.map +1 -1
  59. package/dist/src/lib/resolve.d.ts.map +1 -1
  60. package/dist/src/lib/resolve.js +4 -5
  61. package/dist/src/lib/resolve.js.map +1 -1
  62. package/dist/src/mcp/context.d.ts +1 -9
  63. package/dist/src/mcp/context.d.ts.map +1 -1
  64. package/dist/src/mcp/context.js +1 -2
  65. package/dist/src/mcp/context.js.map +1 -1
  66. package/dist/src/mcp/server.d.ts.map +1 -1
  67. package/dist/src/mcp/server.js +2 -59
  68. package/dist/src/mcp/server.js.map +1 -1
  69. package/dist/src/mcp/tools/account.d.ts.map +1 -1
  70. package/dist/src/mcp/tools/account.js +1 -3
  71. package/dist/src/mcp/tools/account.js.map +1 -1
  72. package/dist/src/mcp/tools/files.d.ts +2 -3
  73. package/dist/src/mcp/tools/files.d.ts.map +1 -1
  74. package/dist/src/mcp/tools/files.js +46 -49
  75. package/dist/src/mcp/tools/files.js.map +1 -1
  76. package/dist/src/mcp/tools/vaults.js +2 -2
  77. package/dist/src/mcp/tools/vaults.js.map +1 -1
  78. package/dist/src/seal.d.ts +16 -0
  79. package/dist/src/seal.d.ts.map +1 -1
  80. package/dist/src/seal.js +23 -0
  81. package/dist/src/seal.js.map +1 -1
  82. package/dist/src/tui/files-panel.d.ts +31 -2
  83. package/dist/src/tui/files-panel.d.ts.map +1 -1
  84. package/dist/src/tui/files-panel.js +119 -13
  85. package/dist/src/tui/files-panel.js.map +1 -1
  86. package/dist/src/tui/index.d.ts.map +1 -1
  87. package/dist/src/tui/index.js +252 -48
  88. package/dist/src/tui/index.js.map +1 -1
  89. package/dist/src/tui/overview.d.ts.map +1 -1
  90. package/dist/src/tui/overview.js +21 -9
  91. package/dist/src/tui/overview.js.map +1 -1
  92. package/dist/src/tui/trash-screen.d.ts +4 -0
  93. package/dist/src/tui/trash-screen.d.ts.map +1 -0
  94. package/dist/src/tui/trash-screen.js +190 -0
  95. package/dist/src/tui/trash-screen.js.map +1 -0
  96. package/dist/src/tui/vaults-panel.d.ts +8 -0
  97. package/dist/src/tui/vaults-panel.d.ts.map +1 -1
  98. package/dist/src/tui/vaults-panel.js +45 -6
  99. package/dist/src/tui/vaults-panel.js.map +1 -1
  100. package/dist/src/version.d.ts +2 -0
  101. package/dist/src/version.d.ts.map +1 -0
  102. package/dist/src/version.js +21 -0
  103. package/dist/src/version.js.map +1 -0
  104. package/package.json +3 -3
  105. package/src/__tests__/seal.test.ts +7 -54
  106. package/src/commands/account.ts +0 -1
  107. package/src/commands/auth.ts +7 -5
  108. package/src/commands/download.ts +38 -28
  109. package/src/commands/export.ts +37 -81
  110. package/src/commands/files.ts +95 -11
  111. package/src/commands/folder.ts +169 -0
  112. package/src/commands/mcp.ts +16 -10
  113. package/src/commands/rehydrate.ts +15 -8
  114. package/src/commands/sui.ts +69 -0
  115. package/src/commands/trash.ts +121 -0
  116. package/src/commands/upload.ts +98 -31
  117. package/src/commands/vault.ts +2 -23
  118. package/src/commands/wallet.ts +183 -0
  119. package/src/commands/webhook.ts +193 -0
  120. package/src/config.ts +3 -4
  121. package/src/index.ts +19 -10
  122. package/src/lib/resolve.ts +3 -4
  123. package/src/mcp/context.ts +1 -11
  124. package/src/mcp/server.ts +2 -70
  125. package/src/mcp/tools/account.ts +1 -3
  126. package/src/mcp/tools/files.ts +50 -63
  127. package/src/mcp/tools/vaults.ts +3 -3
  128. package/src/seal.ts +34 -1
  129. package/src/tui/files-panel.ts +140 -14
  130. package/src/tui/index.ts +264 -52
  131. package/src/tui/overview.ts +20 -9
  132. package/src/tui/trash-screen.ts +203 -0
  133. package/src/tui/vaults-panel.ts +55 -6
  134. package/src/version.ts +21 -0
  135. package/vitest.config.ts +1 -0
  136. package/dist/src/client.d.ts +0 -120
  137. package/dist/src/client.d.ts.map +0 -1
  138. package/dist/src/client.js +0 -152
  139. package/dist/src/client.js.map +0 -1
  140. package/dist/src/commands/decrypt.d.ts +0 -15
  141. package/dist/src/commands/decrypt.d.ts.map +0 -1
  142. package/dist/src/commands/decrypt.js +0 -224
  143. package/dist/src/commands/decrypt.js.map +0 -1
  144. package/dist/src/commands/encryption.d.ts +0 -3
  145. package/dist/src/commands/encryption.d.ts.map +0 -1
  146. package/dist/src/commands/encryption.js +0 -254
  147. package/dist/src/commands/encryption.js.map +0 -1
  148. package/dist/src/crypto.d.ts +0 -16
  149. package/dist/src/crypto.d.ts.map +0 -1
  150. package/dist/src/crypto.js +0 -95
  151. package/dist/src/crypto.js.map +0 -1
  152. package/dist/src/lib/keyring.d.ts +0 -4
  153. package/dist/src/lib/keyring.d.ts.map +0 -1
  154. package/dist/src/lib/keyring.js +0 -49
  155. package/dist/src/lib/keyring.js.map +0 -1
  156. package/src/__tests__/crypto.test.ts +0 -315
  157. package/src/commands/decrypt.ts +0 -276
  158. package/src/commands/encryption.ts +0 -305
  159. package/src/crypto.ts +0 -130
  160. package/src/lib/keyring.ts +0 -50
@@ -1,22 +1,23 @@
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';
10
- import { loadMasterKey } from '../lib/keyring.js';
11
- import { sealDecrypt, getSuiKeypair } from '../seal.js';
9
+ import { sealDecrypt, sealDecryptMetadata, getSuiKeypair } from '../seal.js';
12
10
 
13
11
  export async function downloadCommand(fileId: string, options: {
14
12
  output?: string;
13
+ stdout?: boolean;
15
14
  }, program: Command) {
16
15
  const apiUrl = getApiUrl(program.opts().apiUrl);
17
16
  const apiKey = getApiKey(program.opts().apiKey);
18
17
  const sdk = createSDKClient(apiUrl, apiKey);
19
- const spinner = createSpinner('Fetching file info...');
18
+ // When piping to stdout, send spinner to stderr to avoid polluting the binary stream
19
+ const spinnerStream = options.stdout ? process.stderr : process.stdout;
20
+ const spinner = ora({ text: 'Fetching file info...', stream: spinnerStream as NodeJS.WritableStream });
20
21
  spinner.start();
21
22
 
22
23
  try {
@@ -54,10 +55,9 @@ export async function downloadCommand(fileId: string, options: {
54
55
  const arrayBuf = await response.arrayBuffer();
55
56
  let fileBuffer: Buffer = Buffer.from(new Uint8Array(arrayBuf));
56
57
 
57
- // Decrypt if needed — dispatch on encryption type
58
+ // Decrypt if needed — SEAL-encrypted (shared vault)
58
59
  if (encryption?.encrypted) {
59
60
  if ('type' in encryption && encryption.type === 'seal') {
60
- // SEAL-encrypted (shared vault)
61
61
  spinner.text = 'SEAL decrypting...';
62
62
  const keypair = getSuiKeypair();
63
63
  if (!keypair) {
@@ -71,32 +71,42 @@ export async function downloadCommand(fileId: string, options: {
71
71
  const vault = await sdk.vaults.get(fileInfo.vaultId);
72
72
  const decrypted = await sealDecrypt(new Uint8Array(fileBuffer), vault, keypair);
73
73
  fileBuffer = Buffer.from(decrypted);
74
- } else {
75
- // Passphrase-encrypted (private vault)
76
- spinner.text = 'Decrypting...';
77
- const masterKey = loadMasterKey();
78
- if (!masterKey) {
79
- spinner.fail('Encryption session not unlocked. Run: tusky encryption unlock');
80
- return;
81
- }
82
- // Narrow to passphrase type
83
- const enc = encryption as { type: 'passphrase'; encrypted: true; wrappedKey: string; iv: string; plaintextChecksumSha256: string | null };
84
- fileBuffer = decryptBuffer(
85
- fileBuffer,
86
- enc.wrappedKey,
87
- enc.iv,
88
- masterKey,
89
- enc.plaintextChecksumSha256 ?? undefined,
90
- );
91
74
  }
92
75
  }
93
76
 
94
- // Write to disk — use getStatus to get filename
77
+
78
+ // Output: stdout or disk
95
79
  const fileInfo = await sdk.files.getStatus(fileId);
96
- const outputPath = options.output || join(process.cwd(), fileInfo.name);
97
- writeFileSync(outputPath, fileBuffer);
98
80
 
99
- spinner.succeed(`Downloaded -> ${outputPath} (${formatBytes(fileBuffer.length)})`);
81
+ // Resolve the real filename — decrypt if nameEncrypted
82
+ let displayName = fileInfo.name;
83
+ if (fileInfo.nameEncrypted) {
84
+ try {
85
+ if (fileInfo.encryptedMetadata) {
86
+ // Shared vault — decrypt with SEAL keypair
87
+ const keypair = getSuiKeypair();
88
+ if (keypair) {
89
+ const fileObj = await sdk.files.get(fileId);
90
+ const vault = await sdk.vaults.get(fileObj.vaultId);
91
+ const meta = await sealDecryptMetadata(fileInfo.encryptedMetadata, vault, keypair);
92
+ displayName = meta.n;
93
+ }
94
+ }
95
+ } catch {
96
+ // Fall back to placeholder if decryption fails
97
+ }
98
+ }
99
+
100
+
101
+ if (options.stdout) {
102
+ // Pipe raw bytes to stdout — stop spinner first so it doesn't interleave
103
+ spinner.stop();
104
+ process.stdout.write(fileBuffer);
105
+ } else {
106
+ const outputPath = options.output || join(process.cwd(), displayName);
107
+ writeFileSync(outputPath, fileBuffer);
108
+ spinner.succeed(`Downloaded -> ${outputPath} (${formatBytes(fileBuffer.length)})`);
109
+ }
100
110
  } catch (err: any) {
101
111
  spinner.fail(`Download failed: ${err.message}`);
102
112
  }
@@ -1,14 +1,13 @@
1
1
  /**
2
2
  * `tusky export` — Export file metadata for disaster recovery.
3
3
  *
4
- * Dumps all file records (including Walrus blob IDs, encryption keys,
5
- * and epoch info) to a JSON file. This allows users to recover their
6
- * data directly from Walrus if the Tusky service is unavailable.
4
+ * Dumps all file records (including Walrus blob IDs and epoch info)
5
+ * to a JSON file. This allows users to recover their data directly
6
+ * from Walrus if the Tusky service is unavailable.
7
7
  *
8
8
  * The export contains everything needed to:
9
9
  * 1. Fetch files from Walrus aggregators using blobId
10
- * 2. Decrypt private vault files using wrappedKey + iv + master key
11
- * 3. Renew Walrus epochs using blobObjectId + a Sui wallet
10
+ * 2. Renew Walrus epochs using blobObjectId + a Sui wallet
12
11
  */
13
12
 
14
13
  import type { Command } from 'commander';
@@ -35,16 +34,12 @@ export interface ExportedFile {
35
34
  walrusBlobObjectId: string | null;
36
35
  /** Epoch when Walrus storage expires (renew before this) */
37
36
  walrusEpochEnd: string | null;
38
- /** Whether the file is client-side encrypted */
37
+ /** Whether the file is client-side encrypted (SEAL for shared vaults) */
39
38
  encrypted: boolean;
40
- /** AES-256-GCM file key, wrapped with the master key (base64) */
41
- wrappedKey: string | null;
42
- /** AES-256-GCM initialization vector (base64) */
43
- encryptionIv: string | null;
44
- /** SHA-256 of the plaintext — for integrity verification after decryption */
45
- plaintextChecksumSha256: string | null;
46
- /** Original plaintext size in bytes (before encryption) */
47
- plaintextSizeBytes: number | null;
39
+ /** Whether the filename is encrypted */
40
+ nameEncrypted: boolean;
41
+ /** SEAL-encrypted filename+mimeType blob (shared vaults) */
42
+ encryptedMetadata: string | null;
48
43
  createdAt: string;
49
44
  }
50
45
 
@@ -56,16 +51,6 @@ export interface ExportManifest {
56
51
  email: string;
57
52
  plan: string;
58
53
  };
59
- /** Account encryption params — needed to derive master key from passphrase */
60
- encryption: {
61
- setupComplete: boolean;
62
- /** PBKDF2 salt (base64) */
63
- salt: string | null;
64
- /** HMAC verifier for passphrase validation (base64) */
65
- verifier: string | null;
66
- /** Master key wrapped with passphrase-derived key (base64) */
67
- encryptedMasterKey: string | null;
68
- };
69
54
  walrusNetwork: string;
70
55
  walrusAggregatorUrl: string;
71
56
  totalFiles: number;
@@ -79,10 +64,11 @@ export interface ExportManifest {
79
64
  export function registerExportCommand(program: Command) {
80
65
  program
81
66
  .command('export')
82
- .description('Export file metadata for disaster recovery (Walrus blob IDs, encryption keys)')
67
+ .description('Export file metadata for disaster recovery (Walrus blob IDs, epoch info)')
83
68
  .option('-o, --output <path>', 'Output file path', 'tusky-export.json')
69
+ .option('--stdout', 'Write JSON manifest to stdout instead of a file')
84
70
  .option('--vault <vaultId>', 'Export only files from a specific vault')
85
- .action(async (options: { output: string; vault?: string }) => {
71
+ .action(async (options: { output: string; stdout?: boolean; vault?: string }) => {
86
72
  const apiUrl = getApiUrl(program.opts().apiUrl);
87
73
  const apiKey = getApiKey(program.opts().apiKey);
88
74
  const sdk = createSDKClient(apiUrl, apiKey);
@@ -91,9 +77,8 @@ export function registerExportCommand(program: Command) {
91
77
  spinner.start();
92
78
 
93
79
  try {
94
- // Fetch account + encryption params
80
+ // Fetch account
95
81
  const account = await sdk.account.get();
96
- const encryptionParams = await sdk.account.getEncryptionParams();
97
82
 
98
83
  // Fetch all vaults
99
84
  spinner.text = 'Fetching vaults...';
@@ -139,10 +124,8 @@ export function registerExportCommand(program: Command) {
139
124
  walrusBlobObjectId: file.walrusBlobObjectId,
140
125
  walrusEpochEnd: file.walrusEpochEnd,
141
126
  encrypted: file.encrypted,
142
- wrappedKey: file.wrappedKey,
143
- encryptionIv: file.encryptionIv,
144
- plaintextChecksumSha256: file.plaintextChecksumSha256,
145
- plaintextSizeBytes: file.plaintextSizeBytes,
127
+ nameEncrypted: file.nameEncrypted ?? false,
128
+ encryptedMetadata: file.encryptedMetadata ?? null,
146
129
  createdAt: file.createdAt,
147
130
  });
148
131
  }
@@ -168,12 +151,6 @@ export function registerExportCommand(program: Command) {
168
151
  email: account.email,
169
152
  plan: account.planName,
170
153
  },
171
- encryption: {
172
- setupComplete: encryptionParams.setupComplete,
173
- salt: encryptionParams.salt ?? null,
174
- verifier: encryptionParams.verifier ?? null,
175
- encryptedMasterKey: encryptionParams.encryptedMasterKey ?? null,
176
- },
177
154
  walrusNetwork: isMainnet ? 'mainnet' : 'testnet',
178
155
  walrusAggregatorUrl,
179
156
  totalFiles: exportedFiles.length,
@@ -191,30 +168,6 @@ export function registerExportCommand(program: Command) {
191
168
  ' OR: walrus read <walrusBlobId> -o <filename>',
192
169
  ' OR: tusky rehydrate <walrusBlobId> -o <filename>',
193
170
  '',
194
- '--- DECRYPT PRIVATE VAULT FILES ---',
195
- 'Files with encrypted=true are AES-256-GCM encrypted.',
196
- '',
197
- 'Option A — Using the Tusky CLI decrypt command (easiest):',
198
- ' tusky decrypt <encrypted-file> --export <this-file.json> -o <output>',
199
- ' This reads the wrappedKey/iv from this manifest and prompts for your passphrase.',
200
- ' You can also pass --passphrase <passphrase> or set TUSKYDP_PASSWORD env var.',
201
- ' If the Tusky API is still reachable, you can skip the export and use:',
202
- ' tusky decrypt <encrypted-file> --file-id <fileId> -o <output>',
203
- '',
204
- 'Option B — Using the standalone script (zero dependencies, fully offline):',
205
- ' node tusky-decrypt.mjs <encrypted-file> <output> <passphrase> <salt> <wrappedKey> <iv> [encryptedMasterKey]',
206
- ' The salt and encryptedMasterKey come from your account encryption params.',
207
- '',
208
- 'Option C — Manual decryption (any language with AES-256-GCM + PBKDF2):',
209
- ' 1. Derive wrapping key: PBKDF2(passphrase, salt, 600000 iterations, SHA-256, 32 bytes)',
210
- ' 2. If your account has an encryptedMasterKey, unwrap it: AES-256-GCM-decrypt(encryptedMasterKey, wrappingKey)',
211
- ' Otherwise the wrapping key IS the master key (legacy accounts)',
212
- ' 3. Unwrap the file key: AES-256-GCM-decrypt(wrappedKey, masterKey)',
213
- ' wrappedKey format: [12-byte IV | ciphertext | 16-byte auth tag]',
214
- ' 4. Decrypt the file: AES-256-GCM-decrypt(fileData, fileKey, encryptionIv)',
215
- ' File data format: [ciphertext | 16-byte auth tag]',
216
- ' 5. Verify integrity: SHA-256(plaintext) should match plaintextChecksumSha256',
217
- '',
218
171
  '--- RENEW WALRUS EPOCHS ---',
219
172
  'Files with a walrusBlobObjectId can have their storage extended:',
220
173
  ' walrus extend <walrusBlobObjectId> --epochs 5',
@@ -223,27 +176,30 @@ export function registerExportCommand(program: Command) {
223
176
  },
224
177
  };
225
178
 
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('');
233
-
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;
179
+ if (options.stdout) {
180
+ spinner.stop();
181
+ process.stdout.write(JSON.stringify(manifest, null, 2) + '\n');
182
+ } else {
183
+ // Write to disk
184
+ const outputPath = resolve(options.output);
185
+ writeFileSync(outputPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
186
+ spinner.succeed(`Exported ${exportedFiles.length} files from ${vaults.length} vault(s)`);
187
+ console.log(chalk.dim(` Output: ${outputPath}`));
188
+ console.log('');
189
+ }
238
190
 
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)`));
191
+ if (!options.stdout) {
192
+ // Summary stats (only for disk output — stdout is clean JSON)
193
+ const withBlob = exportedFiles.filter((f) => f.walrusBlobId).length;
194
+ const hotOnly = exportedFiles.filter((f) => !f.walrusBlobId && f.status === 'hot').length;
195
+
196
+ console.log(` On Walrus: ${withBlob} files (recoverable from Walrus network)`);
197
+ if (hotOnly > 0) {
198
+ console.log(chalk.yellow(` Hot only: ${hotOnly} files (NOT yet on Walrus — only in Tusky hot cache)`));
199
+ }
200
+ console.log('');
201
+ console.log(chalk.dim(' See the "recovery.instructions" field inside for full recovery steps.'));
243
202
  }
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
203
  } catch (err: any) {
248
204
  spinner.fail(`Export failed: ${err.message}`);
249
205
  }
@@ -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
- formatBytes(f.plaintextSizeBytes || f.sizeBytes),
65
+ formatBytes(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
  };
@@ -102,14 +113,26 @@ export function registerFileCommands(program: Command) {
102
113
  }
103
114
 
104
115
  console.log(`Name: ${file.name}`);
105
- console.log(`Size: ${formatBytes(file.plaintextSizeBytes || file.sizeBytes)}`);
116
+ console.log(`Size: ${formatBytes(file.sizeBytes)}`);
106
117
  console.log(`MIME: ${file.mimeType}`);
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
+ }