@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
@@ -0,0 +1,121 @@
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, formatBytes, formatDate, shortId } from '../lib/output.js';
7
+
8
+ export function registerTrashCommands(program: Command) {
9
+ const trash = program.command('trash').description('Manage trashed items');
10
+
11
+ // ── list ────────────────────────────────────────────────────────────
12
+ trash.command('list')
13
+ .description('List trashed files and vaults')
14
+ .action(async () => {
15
+ const sdk = getSDKClientFromParent(trash);
16
+ const root = trash.parent || trash;
17
+ const format = root.opts().format || cliConfig.get('outputFormat');
18
+ const trashed = await sdk.trash.list();
19
+
20
+ if (format === 'json') {
21
+ console.log(JSON.stringify(trashed, null, 2));
22
+ return;
23
+ }
24
+
25
+ const totalItems = trashed.files.length + trashed.vaults.length;
26
+ if (totalItems === 0) {
27
+ console.log(chalk.dim('Trash is empty.'));
28
+ return;
29
+ }
30
+
31
+ if (trashed.vaults.length > 0) {
32
+ console.log(chalk.bold('Trashed Vaults:'));
33
+ const table = createTable(['Name', 'Visibility', 'Files', 'Size', 'Deleted', 'ID']);
34
+ for (const v of trashed.vaults) {
35
+ table.push([
36
+ v.name,
37
+ v.visibility,
38
+ String(v.fileCount),
39
+ formatBytes(v.totalSizeBytes),
40
+ v.deletedAt ? formatDate(v.deletedAt) : 'N/A',
41
+ chalk.dim(shortId(v.id)),
42
+ ]);
43
+ }
44
+ console.log(table.toString());
45
+ }
46
+
47
+ if (trashed.files.length > 0) {
48
+ if (trashed.vaults.length > 0) console.log('');
49
+ console.log(chalk.bold('Trashed Files:'));
50
+ const table = createTable(['Name', 'Size', 'Status', 'Deleted', 'ID']);
51
+ for (const f of trashed.files) {
52
+ table.push([
53
+ f.name,
54
+ formatBytes(f.plaintextSizeBytes || f.sizeBytes),
55
+ f.status,
56
+ f.deletedAt ? formatDate(f.deletedAt) : 'N/A',
57
+ chalk.dim(shortId(f.id)),
58
+ ]);
59
+ }
60
+ console.log(table.toString());
61
+ }
62
+
63
+ console.log(chalk.dim(`\n${totalItems} item(s) in trash. Items are permanently deleted after 7 days.`));
64
+ });
65
+
66
+ // ── restore ─────────────────────────────────────────────────────────
67
+ trash.command('restore <id>')
68
+ .description('Restore a trashed item (file or vault)')
69
+ .action(async (id: string) => {
70
+ const sdk = getSDKClientFromParent(trash);
71
+ const result = await sdk.trash.restore(id);
72
+ console.log(chalk.green(`Restored ${result.type || 'item'}: ${id}`));
73
+ });
74
+
75
+ // ── delete ──────────────────────────────────────────────────────────
76
+ trash.command('delete <id>')
77
+ .description('Permanently delete a single trashed item')
78
+ .option('--force', 'Skip confirmation prompt')
79
+ .action(async (id: string, options) => {
80
+ const sdk = getSDKClientFromParent(trash);
81
+
82
+ if (!options.force) {
83
+ const answers = await inquirer.prompt([{
84
+ type: 'confirm',
85
+ name: 'confirm',
86
+ message: 'Permanently delete this item? This cannot be undone.',
87
+ default: false,
88
+ }]);
89
+ if (!answers.confirm) return;
90
+ }
91
+
92
+ await sdk.trash.delete(id);
93
+ console.log(chalk.green(`Permanently deleted: ${id}`));
94
+ });
95
+
96
+ // ── empty ───────────────────────────────────────────────────────────
97
+ trash.command('empty')
98
+ .description('Permanently delete ALL trashed items')
99
+ .option('--force', 'Skip confirmation prompt')
100
+ .action(async (options) => {
101
+ const sdk = getSDKClientFromParent(trash);
102
+
103
+ if (!options.force) {
104
+ const answers = await inquirer.prompt([{
105
+ type: 'confirm',
106
+ name: 'confirm',
107
+ message: 'Permanently delete ALL trashed items? This cannot be undone.',
108
+ default: false,
109
+ }]);
110
+ if (!answers.confirm) return;
111
+ }
112
+
113
+ await sdk.trash.empty();
114
+ console.log(chalk.green('Trash emptied.'));
115
+ });
116
+
117
+ // Default action for `tusky trash` with no subcommand → run list
118
+ trash.action(async () => {
119
+ await trash.commands.find(c => c.name() === 'list')?.parseAsync([], { from: 'user' });
120
+ });
121
+ }
@@ -9,9 +9,18 @@ import { createSDKClient } from '../sdk.js';
9
9
  import { resolveVault } from '../lib/resolve.js';
10
10
  import { formatBytes } from '../lib/output.js';
11
11
  import { createSpinner } from '../lib/progress.js';
12
- import { encryptBuffer } from '../crypto.js';
12
+ import { encryptBuffer, encryptMetadata } from '../crypto.js';
13
13
  import { loadMasterKey } from '../lib/keyring.js';
14
- import { isSealConfigured, sealEncrypt, getSuiKeypair } from '../seal.js';
14
+ import { isSealConfigured, sealEncrypt, sealEncryptMetadata, getSuiKeypair } from '../seal.js';
15
+
16
+ async function readStdin(): Promise<Buffer> {
17
+ return new Promise((resolve, reject) => {
18
+ const chunks: Buffer[] = [];
19
+ process.stdin.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
20
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks)));
21
+ process.stdin.on('error', reject);
22
+ });
23
+ }
15
24
 
16
25
  async function expandPaths(paths: string[], recursive?: boolean): Promise<string[]> {
17
26
  const result: string[] = [];
@@ -44,11 +53,120 @@ async function expandPaths(paths: string[], recursive?: boolean): Promise<string
44
53
  export async function uploadCommand(paths: string[], options: {
45
54
  vault?: string;
46
55
  recursive?: boolean;
56
+ folder?: string;
57
+ content?: string;
58
+ stdin?: boolean;
59
+ name?: string;
47
60
  }, program: Command) {
48
61
  const apiUrl = getApiUrl(program.opts().apiUrl);
49
62
  const apiKey = getApiKey(program.opts().apiKey);
50
63
  const sdk = createSDKClient(apiUrl, apiKey);
51
64
 
65
+ // ── Inline content / stdin mode ──────────────────────────────────
66
+ if (options.content !== undefined || options.stdin) {
67
+ if (!options.name) {
68
+ console.error(chalk.red('--name <filename> is required when using --content or --stdin'));
69
+ process.exit(1);
70
+ }
71
+
72
+ const fileName = options.name;
73
+ let fileBuffer: Buffer;
74
+ if (options.stdin) {
75
+ fileBuffer = await readStdin();
76
+ } else {
77
+ fileBuffer = Buffer.from(options.content as string, 'utf8');
78
+ }
79
+
80
+ const vaultId = await resolveVault(sdk, options.vault);
81
+ const vault = await sdk.vaults.get(vaultId);
82
+ const isPrivate = vault.visibility === 'private';
83
+ const isShared = vault.visibility === 'shared' && isSealConfigured(vault);
84
+
85
+ let masterKey: Buffer | null = null;
86
+ if (isPrivate) {
87
+ masterKey = loadMasterKey();
88
+ if (!masterKey) {
89
+ console.error(chalk.red('Encryption session not unlocked. Run: tusky encryption unlock'));
90
+ process.exit(1);
91
+ }
92
+ }
93
+
94
+ const spinner = createSpinner(`Uploading ${fileName}`);
95
+ spinner.start();
96
+
97
+ try {
98
+ const mimeType = lookup(fileName) || 'application/octet-stream';
99
+ let uploadBody: Buffer;
100
+ let encryptionMeta: {
101
+ wrappedKey?: string;
102
+ encryptionIv?: string;
103
+ plaintextSizeBytes?: number;
104
+ plaintextChecksumSha256?: string;
105
+ sealIdentity?: string;
106
+ sealEncryptedObject?: string;
107
+ encryptedName?: string;
108
+ encryptedMetadata?: string;
109
+ } = {};
110
+
111
+ if (isPrivate && masterKey) {
112
+ spinner.text = `Encrypting ${fileName}...`;
113
+ const { ciphertext, wrappedKey, iv, plaintextChecksum } = encryptBuffer(fileBuffer, masterKey);
114
+ uploadBody = ciphertext;
115
+ encryptionMeta = {
116
+ wrappedKey,
117
+ encryptionIv: iv,
118
+ plaintextSizeBytes: fileBuffer.length,
119
+ plaintextChecksumSha256: plaintextChecksum,
120
+ encryptedName: encryptMetadata(fileName, mimeType, masterKey),
121
+ };
122
+ } else if (isShared) {
123
+ spinner.text = `SEAL encrypting ${fileName}...`;
124
+ const fileNonce = randomUUID();
125
+ const sealResult = await sealEncrypt(new Uint8Array(fileBuffer), vault, fileNonce);
126
+ uploadBody = Buffer.from(sealResult.encryptedData);
127
+ encryptionMeta = {
128
+ sealIdentity: sealResult.sealIdentity,
129
+ sealEncryptedObject: sealResult.sealEncryptedObject,
130
+ plaintextSizeBytes: fileBuffer.length,
131
+ encryptedMetadata: await sealEncryptMetadata(fileName, mimeType, vault, fileNonce),
132
+ };
133
+ } else {
134
+ uploadBody = fileBuffer;
135
+ }
136
+
137
+ spinner.text = `Requesting upload URL for ${fileName}...`;
138
+ const { fileId, uploadUrl } = await sdk.files.requestUpload({
139
+ name: fileName,
140
+ mimeType,
141
+ sizeBytes: uploadBody.length,
142
+ vaultId,
143
+ ...(options.folder ? { folderId: options.folder } : {}),
144
+ ...encryptionMeta,
145
+ });
146
+
147
+ spinner.text = `Uploading ${fileName} (${formatBytes(uploadBody.length)})...`;
148
+ const uploadResponse = await fetch(uploadUrl, {
149
+ method: 'PUT',
150
+ headers: { 'Content-Type': 'application/octet-stream' },
151
+ body: new Uint8Array(uploadBody),
152
+ });
153
+ if (!uploadResponse.ok) throw new Error(`Upload failed: ${uploadResponse.status}`);
154
+
155
+ spinner.text = `Confirming ${fileName}...`;
156
+ await sdk.files.confirmUpload(fileId);
157
+
158
+ spinner.succeed(`${fileName} -> ${fileId} (${formatBytes(uploadBody.length)})`);
159
+ } catch (err: any) {
160
+ spinner.fail(`Failed: ${fileName} — ${err.message}`);
161
+ if (err.statusCode === 402) {
162
+ console.error(chalk.yellow(' This may require topping up your wallet balance.'));
163
+ console.error(chalk.dim(' Check your wallet: tusky wallet info'));
164
+ }
165
+ }
166
+ return;
167
+ }
168
+
169
+ // ── File path mode (original behavior) ───────────────────────────
52
170
  const vaultId = await resolveVault(sdk, options.vault);
53
171
  const vault = await sdk.vaults.get(vaultId);
54
172
  const isPrivate = vault.visibility === 'private';
@@ -97,6 +215,8 @@ export async function uploadCommand(paths: string[], options: {
97
215
  plaintextChecksumSha256?: string;
98
216
  sealIdentity?: string;
99
217
  sealEncryptedObject?: string;
218
+ encryptedName?: string;
219
+ encryptedMetadata?: string;
100
220
  } = {};
101
221
 
102
222
  if (isPrivate && masterKey) {
@@ -108,6 +228,7 @@ export async function uploadCommand(paths: string[], options: {
108
228
  encryptionIv: iv,
109
229
  plaintextSizeBytes: stat.size,
110
230
  plaintextChecksumSha256: plaintextChecksum,
231
+ encryptedName: encryptMetadata(basename(filePath), mimeType, masterKey),
111
232
  };
112
233
  } else if (isShared) {
113
234
  spinner.text = `SEAL encrypting ${basename(filePath)}...`;
@@ -118,6 +239,7 @@ export async function uploadCommand(paths: string[], options: {
118
239
  sealIdentity: sealResult.sealIdentity,
119
240
  sealEncryptedObject: sealResult.sealEncryptedObject,
120
241
  plaintextSizeBytes: stat.size,
242
+ encryptedMetadata: await sealEncryptMetadata(basename(filePath), mimeType, vault, fileNonce),
121
243
  };
122
244
  } else {
123
245
  uploadBody = fileBuffer;
@@ -130,6 +252,7 @@ export async function uploadCommand(paths: string[], options: {
130
252
  mimeType,
131
253
  sizeBytes: uploadBody.length,
132
254
  vaultId,
255
+ ...(options.folder ? { folderId: options.folder } : {}),
133
256
  ...encryptionMeta,
134
257
  });
135
258
 
@@ -154,7 +277,7 @@ export async function uploadCommand(paths: string[], options: {
154
277
  // Surface PPU payment details if present (402 errors)
155
278
  if (err.statusCode === 402) {
156
279
  console.error(chalk.yellow(' This may require topping up your wallet balance.'));
157
- console.error(chalk.dim(' Check your wallet: tusky account usage'));
280
+ console.error(chalk.dim(' Check your wallet: tusky wallet info'));
158
281
  }
159
282
  }
160
283
  }
@@ -0,0 +1,183 @@
1
+ import type { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
4
+ import { cliConfig, getApiUrl, getApiKey } from '../config.js';
5
+ import { createTable, formatDate } from '../lib/output.js';
6
+
7
+ /**
8
+ * Lightweight fetch helper that uses the API key for auth.
9
+ * The SDK does not have a wallet resource, so we call the API directly.
10
+ */
11
+ async function walletFetch<T>(apiUrl: string, apiKey: string, path: string, init?: RequestInit): Promise<T> {
12
+ const url = `${apiUrl.replace(/\/$/, '')}${path}`;
13
+ const response = await fetch(url, {
14
+ ...init,
15
+ headers: {
16
+ 'Authorization': `Bearer ${apiKey}`,
17
+ 'Content-Type': 'application/json',
18
+ ...(init?.headers || {}),
19
+ },
20
+ });
21
+ if (!response.ok) {
22
+ const body = await response.json().catch(() => ({ error: response.statusText }));
23
+ throw new Error((body as Record<string, string>).error || `HTTP ${response.status}`);
24
+ }
25
+ return response.json() as Promise<T>;
26
+ }
27
+
28
+ interface WalletInfo {
29
+ id: string;
30
+ suiAddress: string;
31
+ walBalance: string;
32
+ walBalanceFormatted: string;
33
+ suiBalance: string;
34
+ suiBalanceFormatted: string;
35
+ usdcBalance?: string;
36
+ usdcBalanceFormatted?: string;
37
+ createdAt: string;
38
+ }
39
+
40
+ interface DepositInfo {
41
+ suiAddress: string;
42
+ network: string;
43
+ supportedTokens: string[];
44
+ }
45
+
46
+ interface PaymentEntry {
47
+ id: string;
48
+ fileId: string;
49
+ type: string;
50
+ walMist: string;
51
+ suiGasMist: string;
52
+ epochs: number;
53
+ createdAt: string;
54
+ }
55
+
56
+ interface PaymentsResponse {
57
+ payments: PaymentEntry[];
58
+ totalSpentWal: string;
59
+ totalSpentWalFormatted: string;
60
+ }
61
+
62
+ export function registerWalletCommands(program: Command) {
63
+ const wallet = program.command('wallet').description('Manage wallet and payments (PPU)');
64
+
65
+ // ── info ────────────────────────────────────────────────────────────
66
+ wallet.command('info')
67
+ .description('Show wallet balances and address')
68
+ .action(async () => {
69
+ const root = wallet.parent || wallet;
70
+ const apiUrl = getApiUrl(root.opts().apiUrl);
71
+ const apiKey = getApiKey(root.opts().apiKey);
72
+ const format = root.opts().format || cliConfig.get('outputFormat');
73
+
74
+ const w = await walletFetch<WalletInfo>(apiUrl, apiKey, '/api/wallet');
75
+
76
+ if (format === 'json') {
77
+ console.log(JSON.stringify(w, null, 2));
78
+ return;
79
+ }
80
+
81
+ console.log(chalk.bold('Wallet'));
82
+ console.log(` Address: ${w.suiAddress}`);
83
+ console.log(` WAL: ${w.walBalanceFormatted}`);
84
+ console.log(` SUI: ${w.suiBalanceFormatted}`);
85
+ if (w.usdcBalanceFormatted) {
86
+ console.log(` USDC: ${w.usdcBalanceFormatted}`);
87
+ }
88
+ console.log(` Created: ${new Date(w.createdAt).toLocaleString()}`);
89
+ });
90
+
91
+ // ── deposit ─────────────────────────────────────────────────────────
92
+ wallet.command('deposit')
93
+ .description('Show deposit address and supported tokens')
94
+ .action(async () => {
95
+ const root = wallet.parent || wallet;
96
+ const apiUrl = getApiUrl(root.opts().apiUrl);
97
+ const apiKey = getApiKey(root.opts().apiKey);
98
+ const format = root.opts().format || cliConfig.get('outputFormat');
99
+
100
+ const d = await walletFetch<DepositInfo>(apiUrl, apiKey, '/api/wallet/deposit-info');
101
+
102
+ if (format === 'json') {
103
+ console.log(JSON.stringify(d, null, 2));
104
+ return;
105
+ }
106
+
107
+ console.log(chalk.bold('Deposit Info'));
108
+ console.log(` Address: ${d.suiAddress}`);
109
+ console.log(` Network: ${d.network}`);
110
+ console.log(` Tokens: ${d.supportedTokens.join(', ')}`);
111
+ console.log('');
112
+ console.log(chalk.dim('Send WAL, SUI, or USDC to the address above on the Sui network.'));
113
+ });
114
+
115
+ // ── payments ────────────────────────────────────────────────────────
116
+ wallet.command('payments')
117
+ .description('List payment history')
118
+ .option('--limit <n>', 'Max results', '20')
119
+ .action(async (options) => {
120
+ const root = wallet.parent || wallet;
121
+ const apiUrl = getApiUrl(root.opts().apiUrl);
122
+ const apiKey = getApiKey(root.opts().apiKey);
123
+ const format = root.opts().format || cliConfig.get('outputFormat');
124
+
125
+ const data = await walletFetch<PaymentsResponse>(
126
+ apiUrl, apiKey,
127
+ `/api/wallet/payments?limit=${options.limit}`,
128
+ );
129
+
130
+ if (format === 'json') {
131
+ console.log(JSON.stringify(data, null, 2));
132
+ return;
133
+ }
134
+
135
+ if (data.payments.length === 0) {
136
+ console.log(chalk.dim('No payments found.'));
137
+ return;
138
+ }
139
+
140
+ const table = createTable(['Type', 'WAL', 'SUI Gas', 'Epochs', 'File', 'Date']);
141
+ for (const p of data.payments) {
142
+ table.push([
143
+ p.type,
144
+ p.walMist,
145
+ p.suiGasMist,
146
+ String(p.epochs),
147
+ chalk.dim(p.fileId.slice(0, 8) + '...'),
148
+ formatDate(p.createdAt),
149
+ ]);
150
+ }
151
+ console.log(table.toString());
152
+ console.log(chalk.dim(`\nTotal spent: ${data.totalSpentWalFormatted}`));
153
+ });
154
+
155
+ // ── generate ─────────────────────────────────────────────────────
156
+ wallet.command('generate')
157
+ .description('Generate a new Sui Ed25519 keypair for use with shared vaults (SEAL encryption)')
158
+ .action(async () => {
159
+ const root = wallet.parent || wallet;
160
+ const format = root.opts().format || cliConfig.get('outputFormat');
161
+
162
+ const keypair = Ed25519Keypair.generate();
163
+ const address = keypair.toSuiAddress();
164
+ const privateKey = keypair.getSecretKey(); // bech32 suiprivkey1... format
165
+
166
+ if (format === 'json') {
167
+ console.log(JSON.stringify({ address, privateKey }, null, 2));
168
+ return;
169
+ }
170
+
171
+ console.log(chalk.bold('Generated Sui Ed25519 Keypair'));
172
+ console.log(` Address: ${chalk.cyan(address)}`);
173
+ console.log(` Private key: ${chalk.yellow(privateKey)}`);
174
+ console.log('');
175
+ console.log(chalk.dim('Set TUSKYDP_SUI_PRIVATE_KEY=<private key> to use this keypair for shared vault encryption.'));
176
+ console.log(chalk.dim('Link the address to your Tusky account: tusky account link-sui ' + address));
177
+ });
178
+
179
+ // Default action for `tusky wallet` with no subcommand → run info
180
+ wallet.action(async () => {
181
+ await wallet.commands.find(c => c.name() === 'info')?.parseAsync([], { from: 'user' });
182
+ });
183
+ }
@@ -0,0 +1,193 @@
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
+
8
+ // Import canonical event list from shared — keep in sync
9
+ import { WEBHOOK_EVENTS } from '@tuskydp/shared/constants.js';
10
+
11
+ export function registerWebhookCommands(program: Command) {
12
+ const webhook = program.command('webhook').description('Manage webhook endpoints');
13
+
14
+ // ── create ──────────────────────────────────────────────────────────
15
+ webhook.command('create <url>')
16
+ .description('Create a webhook endpoint')
17
+ .requiredOption('--events <events>', 'Comma-separated list of events to subscribe to')
18
+ .option('--description <text>', 'Webhook description')
19
+ .action(async (url: string, options) => {
20
+ const sdk = getSDKClientFromParent(webhook);
21
+ const events = options.events.split(',').map((e: string) => e.trim());
22
+
23
+ const created = await sdk.webhooks.create({
24
+ url,
25
+ events: events as any,
26
+ description: options.description,
27
+ });
28
+
29
+ console.log(chalk.green(`Webhook created: ${created.id}`));
30
+ console.log(`URL: ${created.url}`);
31
+ console.log(`Events: ${created.events.join(', ')}`);
32
+ if (created.secret) {
33
+ console.log(chalk.yellow.bold(`Secret: ${created.secret}`));
34
+ console.log(chalk.yellow(' Save this secret — it will not be shown again.'));
35
+ }
36
+ });
37
+
38
+ // ── list ────────────────────────────────────────────────────────────
39
+ webhook.command('list')
40
+ .description('List webhook endpoints')
41
+ .action(async () => {
42
+ const sdk = getSDKClientFromParent(webhook);
43
+ const root = webhook.parent || webhook;
44
+ const format = root.opts().format || cliConfig.get('outputFormat');
45
+ const webhooks = await sdk.webhooks.list();
46
+
47
+ if (format === 'json') {
48
+ console.log(JSON.stringify(webhooks, null, 2));
49
+ return;
50
+ }
51
+
52
+ if (webhooks.length === 0) {
53
+ console.log(chalk.dim('No webhook endpoints found.'));
54
+ return;
55
+ }
56
+
57
+ const table = createTable(['URL', 'Events', 'Active', 'Failures', 'ID']);
58
+ for (const w of webhooks) {
59
+ table.push([
60
+ w.url.length > 40 ? w.url.slice(0, 39) + '...' : w.url,
61
+ String(w.events.length) + ' event(s)',
62
+ w.active ? chalk.green('Yes') : chalk.red('No'),
63
+ w.failureCount > 0 ? chalk.yellow(String(w.failureCount)) : '0',
64
+ chalk.dim(shortId(w.id)),
65
+ ]);
66
+ }
67
+ console.log(table.toString());
68
+ });
69
+
70
+ // ── info ────────────────────────────────────────────────────────────
71
+ webhook.command('info <webhook-id>')
72
+ .description('Show webhook endpoint details')
73
+ .action(async (webhookId: string) => {
74
+ const sdk = getSDKClientFromParent(webhook);
75
+ const root = webhook.parent || webhook;
76
+ const format = root.opts().format || cliConfig.get('outputFormat');
77
+ const w = await sdk.webhooks.get(webhookId);
78
+
79
+ if (format === 'json') {
80
+ console.log(JSON.stringify(w, null, 2));
81
+ return;
82
+ }
83
+
84
+ console.log(`URL: ${w.url}`);
85
+ console.log(`Active: ${w.active ? chalk.green('Yes') : chalk.red('No')}`);
86
+ console.log(`Events: ${w.events.join(', ')}`);
87
+ console.log(`Description: ${w.description || chalk.dim('(none)')}`);
88
+ console.log(`Failures: ${w.failureCount}`);
89
+ if (w.lastDeliveryAt) {
90
+ console.log(`Last Delivery: ${formatDate(w.lastDeliveryAt)} (${w.lastDeliveryStatus})`);
91
+ }
92
+ console.log(`Created: ${new Date(w.createdAt).toLocaleString()}`);
93
+ console.log(`ID: ${w.id}`);
94
+ });
95
+
96
+ // ── update ──────────────────────────────────────────────────────────
97
+ webhook.command('update <webhook-id>')
98
+ .description('Update a webhook endpoint')
99
+ .option('--url <url>', 'New webhook URL')
100
+ .option('--events <events>', 'Comma-separated list of events')
101
+ .option('--active <bool>', 'Enable or disable (true/false)')
102
+ .option('--description <text>', 'New description')
103
+ .action(async (webhookId: string, options) => {
104
+ const sdk = getSDKClientFromParent(webhook);
105
+
106
+ const params: { url?: string; events?: any[]; active?: boolean; description?: string } = {};
107
+ if (options.url) params.url = options.url;
108
+ if (options.events) params.events = options.events.split(',').map((e: string) => e.trim());
109
+ if (options.active !== undefined) params.active = options.active === 'true';
110
+ if (options.description) params.description = options.description;
111
+
112
+ if (Object.keys(params).length === 0) {
113
+ console.error(chalk.red('Provide at least one option to update (--url, --events, --active, --description).'));
114
+ return;
115
+ }
116
+
117
+ await sdk.webhooks.update(webhookId, params);
118
+ console.log(chalk.green('Webhook updated.'));
119
+ });
120
+
121
+ // ── delete ──────────────────────────────────────────────────────────
122
+ webhook.command('delete <webhook-id>')
123
+ .description('Delete a webhook endpoint')
124
+ .option('--force', 'Skip confirmation prompt')
125
+ .action(async (webhookId: string, options) => {
126
+ const sdk = getSDKClientFromParent(webhook);
127
+
128
+ if (!options.force) {
129
+ const answers = await inquirer.prompt([{
130
+ type: 'confirm',
131
+ name: 'confirm',
132
+ message: 'Delete this webhook endpoint?',
133
+ default: false,
134
+ }]);
135
+ if (!answers.confirm) return;
136
+ }
137
+
138
+ await sdk.webhooks.delete(webhookId);
139
+ console.log(chalk.green('Webhook deleted.'));
140
+ });
141
+
142
+ // ── test ────────────────────────────────────────────────────────────
143
+ webhook.command('test <webhook-id>')
144
+ .description('Send a test delivery to the webhook')
145
+ .action(async (webhookId: string) => {
146
+ const sdk = getSDKClientFromParent(webhook);
147
+ const delivery = await sdk.webhooks.test(webhookId);
148
+ console.log(chalk.green('Test delivery sent.'));
149
+ console.log(`Delivery ID: ${delivery.id}`);
150
+ console.log(`Event: ${delivery.event}`);
151
+ console.log(`Status: ${delivery.status}`);
152
+ });
153
+
154
+ // ── deliveries ──────────────────────────────────────────────────────
155
+ webhook.command('deliveries <webhook-id>')
156
+ .description('List recent webhook deliveries')
157
+ .option('--limit <n>', 'Max results', '20')
158
+ .action(async (webhookId: string, options) => {
159
+ const sdk = getSDKClientFromParent(webhook);
160
+ const root = webhook.parent || webhook;
161
+ const format = root.opts().format || cliConfig.get('outputFormat');
162
+ const deliveries = await sdk.webhooks.listDeliveries(webhookId, { limit: parseInt(options.limit) });
163
+
164
+ if (format === 'json') {
165
+ console.log(JSON.stringify(deliveries, null, 2));
166
+ return;
167
+ }
168
+
169
+ if (deliveries.length === 0) {
170
+ console.log(chalk.dim('No deliveries found.'));
171
+ return;
172
+ }
173
+
174
+ const table = createTable(['Event', 'Status', 'HTTP', 'Attempts', 'Created', 'ID']);
175
+ const statusColors: Record<string, typeof chalk.green> = {
176
+ success: chalk.green,
177
+ failed: chalk.red,
178
+ pending: chalk.yellow,
179
+ };
180
+ for (const d of deliveries) {
181
+ const sc = statusColors[d.status] || chalk.white;
182
+ table.push([
183
+ d.event,
184
+ sc(d.status),
185
+ d.httpStatus ? String(d.httpStatus) : '-',
186
+ `${d.attempts}/${d.maxAttempts}`,
187
+ formatDate(d.createdAt),
188
+ chalk.dim(shortId(d.id)),
189
+ ]);
190
+ }
191
+ console.log(table.toString());
192
+ });
193
+ }