@palmyr/cli 1.4.0 → 1.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -17,7 +17,7 @@ import { render as inkRender } from 'ink';
17
17
  import { ConfigScreen, Dashboard, DoctorScreen, DomainCheckScreen, DomainPricingScreen, ErrorScreen, HealthScreen, MenuScreen, PricingScreen, RecordsScreen, SetupScreen, StatusScreen, SuccessScreen, WalletCreateScreen, WalletStatusScreen, WalletListScreen } from './app.js';
18
18
  import { Palmyr } from './sdk.js';
19
19
  import { loadConfig, saveConfig, ensureDirs, log, addPhone, addDomain, addNote } from './config.js';
20
- import { theme as t, icon, Spinner, table, kv, section, setAgentMode as setUiAgentMode } from './ui.js';
20
+ import { theme as t, icon, Spinner, warn, table, kv, section, setAgentMode as setUiAgentMode } from './ui.js';
21
21
  import { existsSync, readFileSync } from 'fs';
22
22
  import { homedir } from 'os';
23
23
  import { fileURLToPath } from 'url';
@@ -1462,9 +1462,11 @@ async function main() {
1462
1462
  { name: 'check', description: 'Check availability', hint: '--name example.dev' },
1463
1463
  { name: 'pricing', description: 'Get TLD pricing', hint: '--name example' },
1464
1464
  { name: 'buy', description: 'Register a domain', hint: '--name example.dev' },
1465
- { name: 'list', description: 'List domains owned by your wallet', hint: '' },
1465
+ { name: 'list', description: 'List domains owned or shared with your wallet', hint: '' },
1466
1466
  { name: 'dns', description: 'Get DNS records', hint: '--name example.dev' },
1467
1467
  { name: 'transfer-ownership', description: 'Transfer domain to another wallet', hint: '--name example.dev --to <wallet>' },
1468
+ { name: 'share', description: 'Grant another wallet shared access', hint: '--name example.dev --with <wallet>' },
1469
+ { name: 'unshare', description: 'Revoke a wallet’s shared access', hint: '--name example.dev --from <wallet>' },
1468
1470
  ],
1469
1471
  fromHome,
1470
1472
  });
@@ -1582,6 +1584,28 @@ async function main() {
1582
1584
  const data = await ao.domainTransferOwnership(name, to);
1583
1585
  return print(data);
1584
1586
  }
1587
+ case 'share': {
1588
+ const name = flags.name || positional[0];
1589
+ const withWallet = flags.with || flags.wallet;
1590
+ if (!name)
1591
+ err('--name domain.dev required');
1592
+ if (!withWallet)
1593
+ err('--with <wallet> required');
1594
+ const data = await ao.domainShare(name, withWallet);
1595
+ log(`domain share: ${name} → ${withWallet}`);
1596
+ return print(data);
1597
+ }
1598
+ case 'unshare': {
1599
+ const name = flags.name || positional[0];
1600
+ const targetWallet = flags.from || flags.wallet;
1601
+ if (!name)
1602
+ err('--name domain.dev required');
1603
+ if (!targetWallet)
1604
+ err('--from <wallet> required');
1605
+ const data = await ao.domainUnshare(name, targetWallet);
1606
+ log(`domain unshare: ${name} ✗ ${targetWallet}`);
1607
+ return print(data);
1608
+ }
1585
1609
  case 'dns': {
1586
1610
  const name = flags.name || positional[0];
1587
1611
  if (!name)
@@ -1600,7 +1624,7 @@ async function main() {
1600
1624
  }));
1601
1625
  break;
1602
1626
  }
1603
- default: err(`Unknown domain command: ${subcommand}. Try: check, pricing, buy, list, dns, transfer-ownership`);
1627
+ default: err(`Unknown domain command: ${subcommand}. Try: check, pricing, buy, list, dns, transfer-ownership, share, unshare`);
1604
1628
  }
1605
1629
  break;
1606
1630
  }
@@ -3976,6 +4000,75 @@ async function main() {
3976
4000
  case 'twitter': {
3977
4001
  const sv = await import('./social-vault.js');
3978
4002
  const platform = 'twitter';
4003
+ // Resolve a username to its server-side account_id and which table
4004
+ // holds it. X accounts can live in two places: x_accounts (pool-bought)
4005
+ // and social_registered_accounts (BYO-registered). The local vault
4006
+ // doesn't track which, so we query both in parallel and find a match.
4007
+ // Used by transfer / share / unshare to dispatch to the correct
4008
+ // endpoint family. Returns null if the username isn't on the server.
4009
+ const resolveServerAccount = async (username) => {
4010
+ const [xMine, reg] = await Promise.allSettled([
4011
+ ao.xAccountsMine(),
4012
+ ao.socialTwitterListRegistered(),
4013
+ ]);
4014
+ if (xMine.status === 'fulfilled') {
4015
+ const m = (xMine.value?.accounts || []).find((a) => a.username === username);
4016
+ if (m)
4017
+ return { kind: 'x_accounts', id: m.id };
4018
+ }
4019
+ if (reg.status === 'fulfilled') {
4020
+ const m = (reg.value?.accounts || []).find((a) => a.username === username);
4021
+ if (m)
4022
+ return { kind: 'registered', id: m.id };
4023
+ }
4024
+ return null;
4025
+ };
4026
+ // Look up an account by username; if it's not in the local vault,
4027
+ // check both server-side tables and auto-import any match. This is
4028
+ // what makes `palmyr twitter <op> @h` "just work" for a wallet that
4029
+ // was just transferred or shared an account — no separate `claim`
4030
+ // step. Errs cleanly if the account is neither local nor accessible
4031
+ // server-side. Each auto-import costs ~$0.0011 (two paid lookups +
4032
+ // creds-decryption fee on the registered side), so call sites that
4033
+ // are pure local ops (rename, remove) intentionally skip this.
4034
+ const ensureLocalAccount = async (username) => {
4035
+ const existing = sv.getAccount(platform, username);
4036
+ if (existing)
4037
+ return existing;
4038
+ const [xRes, regRes] = await Promise.allSettled([
4039
+ ao.xAccountsMine(),
4040
+ ao.socialTwitterRegisteredMine(),
4041
+ ]);
4042
+ const xMatch = xRes.status === 'fulfilled'
4043
+ ? (xRes.value?.accounts || []).find((a) => a.username === username)
4044
+ : null;
4045
+ const regMatch = regRes.status === 'fulfilled'
4046
+ ? (regRes.value?.accounts || []).find((a) => a.username === username)
4047
+ : null;
4048
+ const match = xMatch || regMatch;
4049
+ if (!match) {
4050
+ err(`twitter account "${username}" not found locally or on the server (this wallet has no access)`, EXIT.NOT_FOUND);
4051
+ }
4052
+ const cookies = (match.cookies || []);
4053
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value;
4054
+ // Registered accounts return creds nested under `credentials`;
4055
+ // pool accounts return them flat alongside cookies.
4056
+ const creds = regMatch
4057
+ ? regMatch.credentials
4058
+ : {
4059
+ login: match.email || match.username,
4060
+ password: match.password,
4061
+ email: match.email,
4062
+ auth_token: match.auth_token || undefined,
4063
+ ct0,
4064
+ };
4065
+ const summary = sv.importAccount(platform, username, creds, { source: 'auto-claim' });
4066
+ if (cookies.length > 0) {
4067
+ sv.saveSession(summary.id, platform, cookies);
4068
+ }
4069
+ log(`auto-imported @${username} from server (${regMatch ? 'registered' : 'pool'} → local vault)`);
4070
+ return summary;
4071
+ };
3979
4072
  if (!subcommand) {
3980
4073
  showMenu({
3981
4074
  command: 'twitter',
@@ -3984,7 +4077,7 @@ async function main() {
3984
4077
  footerLeft: 'Phase 1: local vault + BYO import works today. Server-dependent commands stub out.',
3985
4078
  commands: [
3986
4079
  { name: 'import', description: 'Save a BYO account to the local vault', hint: '--username --password --totp-seed' },
3987
- { name: 'list', description: 'List all local X accounts' },
4080
+ { name: 'list', description: 'List local accounts and any server-only ones the wallet can access', hint: '[--local]' },
3988
4081
  { name: 'info', description: 'Show one account', hint: '<username>' },
3989
4082
  { name: 'rename', description: 'Update the local record when the handle changes', hint: '<old> --to <new>' },
3990
4083
  { name: 'remove', description: 'Delete an account from the local vault', hint: '<username> --confirm' },
@@ -3993,6 +4086,10 @@ async function main() {
3993
4086
  { name: 'login', description: 'Force a fresh server-side session (requires browser runtime)', hint: '<username>' },
3994
4087
  { name: 'post', description: 'Post a tweet (requires server browser runtime)', hint: '<username> --body "..."' },
3995
4088
  { name: 'status', description: 'Check if the account is alive / shadow-banned', hint: '<username>' },
4089
+ { name: 'transfer', description: 'Hand an account to another wallet (rotates password; auto-registers if needed)', hint: '<username> --to <wallet> --confirm' },
4090
+ { name: 'share', description: 'Grant another wallet shared access', hint: '<username> --with <wallet>' },
4091
+ { name: 'unshare', description: 'Revoke a wallet’s shared access', hint: '<username> --from <wallet> [--rotate]' },
4092
+ { name: 'claim', description: 'Import server-side accounts owned by your wallet into the local vault' },
3996
4093
  ],
3997
4094
  fromHome,
3998
4095
  });
@@ -4064,16 +4161,59 @@ async function main() {
4064
4161
  return print({ ...summary, has_cookies: !!authToken });
4065
4162
  }
4066
4163
  case 'list': {
4067
- const accounts = sv.listAccounts(platform);
4068
- return print({ accounts, count: accounts.length });
4164
+ // Local vault is always queried (free, instant). By default we
4165
+ // also query the server for accounts the wallet owns or has been
4166
+ // shared with — that's how a wallet that just received a transfer
4167
+ // sees the account here without an extra `claim` step. Pass
4168
+ // --local to skip the server check for cheap mode.
4169
+ const localAccounts = sv.listAccounts(platform);
4170
+ const skipRemote = !!flags.local;
4171
+ if (skipRemote) {
4172
+ return print({ accounts: localAccounts, count: localAccounts.length, source: 'local' });
4173
+ }
4174
+ const [xRes, regRes] = await Promise.allSettled([
4175
+ ao.xAccountsMine(),
4176
+ ao.socialTwitterRegisteredMine(),
4177
+ ]);
4178
+ const xAccounts = xRes.status === 'fulfilled' ? (xRes.value?.accounts || []) : [];
4179
+ const regAccounts = regRes.status === 'fulfilled' ? (regRes.value?.accounts || []) : [];
4180
+ const localUsernames = new Set(localAccounts.map(a => a.username));
4181
+ const serverOnly = [];
4182
+ for (const a of xAccounts) {
4183
+ if (a.username && !localUsernames.has(a.username)) {
4184
+ serverOnly.push({
4185
+ username: a.username,
4186
+ access: a.access || 'owner',
4187
+ source_table: 'x_accounts',
4188
+ status: 'server-only — run `palmyr twitter claim` to import',
4189
+ });
4190
+ }
4191
+ }
4192
+ for (const a of regAccounts) {
4193
+ if (a.username && !localUsernames.has(a.username)) {
4194
+ serverOnly.push({
4195
+ username: a.username,
4196
+ access: a.access || 'owner',
4197
+ source_table: 'registered',
4198
+ status: 'server-only — run `palmyr twitter claim` to import',
4199
+ });
4200
+ }
4201
+ }
4202
+ return print({
4203
+ accounts: localAccounts,
4204
+ server_only: serverOnly,
4205
+ count_local: localAccounts.length,
4206
+ count_server_only: serverOnly.length,
4207
+ ...(serverOnly.length > 0
4208
+ ? { hint: `${serverOnly.length} account${serverOnly.length === 1 ? '' : 's'} on server but not in local vault — run 'palmyr twitter claim' to import` }
4209
+ : {}),
4210
+ });
4069
4211
  }
4070
4212
  case 'info': {
4071
4213
  const username = positional[0] || flags.username;
4072
4214
  if (!username)
4073
4215
  err('<username> required');
4074
- const acc = sv.getAccount(platform, username);
4075
- if (!acc)
4076
- err(`twitter account "${username}" not found locally`, EXIT.NOT_FOUND);
4216
+ const acc = await ensureLocalAccount(username);
4077
4217
  return print(acc);
4078
4218
  }
4079
4219
  case 'rename': {
@@ -4104,6 +4244,7 @@ async function main() {
4104
4244
  const username = positional[0] || flags.username;
4105
4245
  if (!username)
4106
4246
  err('<username> required');
4247
+ await ensureLocalAccount(username);
4107
4248
  const creds = sv.unlockCredentials(platform, username);
4108
4249
  if (!creds.totp_seed)
4109
4250
  err(`twitter account "${username}" has no TOTP seed configured`, EXIT.NOT_FOUND);
@@ -4207,9 +4348,7 @@ async function main() {
4207
4348
  const username = positional[0] || flags.username;
4208
4349
  if (!username)
4209
4350
  err('<username> required');
4210
- const acc = sv.getAccount(platform, username);
4211
- if (!acc)
4212
- err(`twitter account "${username}" not found locally`, EXIT.NOT_FOUND);
4351
+ const acc = await ensureLocalAccount(username);
4213
4352
  const sess = sv.loadSession(acc.id);
4214
4353
  if (!sess) {
4215
4354
  return print({
@@ -4242,9 +4381,7 @@ async function main() {
4242
4381
  err('--limit must be a positive integer', EXIT.BAD_INPUT);
4243
4382
  limit = Math.floor(n);
4244
4383
  }
4245
- const acc = sv.getAccount(platform, username);
4246
- if (!acc)
4247
- err(`twitter account "${username}" not found locally`, EXIT.NOT_FOUND);
4384
+ const acc = await ensureLocalAccount(username);
4248
4385
  const sess = sv.loadSession(acc.id);
4249
4386
  if (!sess || !sess.cookies || sess.cookies.length === 0) {
4250
4387
  err(`No cached session for ${username}. Run 'twitter login ${username}' first.`, EXIT.NOT_FOUND);
@@ -4537,9 +4674,7 @@ async function main() {
4537
4674
  err(`Invalid username "${rawNewUsername}". X requires 4-15 chars, letters/numbers/underscores only. ` +
4538
4675
  `You have NOT been charged.`, EXIT.BAD_INPUT);
4539
4676
  }
4540
- const acc = sv.getAccount(platform, username);
4541
- if (!acc)
4542
- err(`twitter account "${username}" not found locally`, EXIT.NOT_FOUND);
4677
+ const acc = await ensureLocalAccount(username);
4543
4678
  const sess = sv.loadSession(acc.id);
4544
4679
  if (!sess || !sess.cookies || sess.cookies.length === 0) {
4545
4680
  err(`No cached session. Run 'twitter login ${username}' first.`, EXIT.NOT_FOUND);
@@ -4582,9 +4717,7 @@ async function main() {
4582
4717
  const username = positional[0] || flags.username;
4583
4718
  if (!username)
4584
4719
  err(`<username> required`);
4585
- const acc = sv.getAccount(platform, username);
4586
- if (!acc)
4587
- err(`twitter account "${username}" not found locally`, EXIT.NOT_FOUND);
4720
+ const acc = await ensureLocalAccount(username);
4588
4721
  const sess = sv.loadSession(acc.id);
4589
4722
  if (!sess || !sess.cookies || sess.cookies.length === 0) {
4590
4723
  err(`No cached session for ${username}. Run 'twitter login ${username}' first.`, EXIT.NOT_FOUND);
@@ -4889,8 +5022,338 @@ async function main() {
4889
5022
  case 'status': {
4890
5023
  err(`twitter status: not wired yet. Phase 3 will add it.`, EXIT.GENERAL);
4891
5024
  }
5025
+ case 'transfer': {
5026
+ // Hand the X account to another wallet. End-to-end one-command:
5027
+ // 1. If the account is only in the local vault, auto-register it
5028
+ // with the server (uploads encrypted creds; $0.01 USDC).
5029
+ // 2. Server rotates the password and revokes other sessions
5030
+ // ($0.0001 USDC ownership proof).
5031
+ // 3. Atomically flips ownership in the DB.
5032
+ // Receiver picks up the rotated credentials via `palmyr twitter
5033
+ // list` (which now surfaces server-side accounts) and/or `claim`.
5034
+ const username = positional[0] || flags.username;
5035
+ const to = flags.to;
5036
+ if (!username)
5037
+ err('<username> required');
5038
+ if (!to)
5039
+ err('--to <wallet> required');
5040
+ const acc = sv.getAccount(platform, username);
5041
+ if (!acc)
5042
+ err(`twitter account "${username}" not found locally — can only transfer accounts you own`, EXIT.NOT_FOUND);
5043
+ if (!flags.confirm) {
5044
+ const tail = to.slice(-6);
5045
+ err(`This rotates @${username} on X and hands it to a wallet ending in …${tail}. ` +
5046
+ `You will lose access immediately and irreversibly. ` +
5047
+ `If the account isn't on the Palmyr server yet, it will be auto-registered (~$0.01 USDC) before the transfer.\n\n` +
5048
+ `Re-run with --confirm:\n` +
5049
+ ` palmyr twitter transfer ${username} --to ${to} --confirm`);
5050
+ }
5051
+ let resolved = await resolveServerAccount(username);
5052
+ // Auto-register if the account is only in the local vault. This
5053
+ // is the most common case for accounts that were imported BYO and
5054
+ // never explicitly registered. We pull creds straight from the
5055
+ // local vault so the user doesn't have to re-type anything.
5056
+ if (!resolved) {
5057
+ const spinReg = new Spinner();
5058
+ spinReg.start(`@${username} not on server yet — auto-registering before transfer…`);
5059
+ let regData;
5060
+ try {
5061
+ const localCreds = sv.unlockCredentials(platform, username);
5062
+ if (!localCreds.password) {
5063
+ spinReg.stop('Register failed', false);
5064
+ err(`Cannot auto-register @${username}: local vault has no password. ` +
5065
+ `Re-import the account with a password first.`, EXIT.BAD_INPUT);
5066
+ }
5067
+ const country = sv.getCountry(platform, username);
5068
+ regData = await ao.socialTwitterRegister(username, localCreds.password, {
5069
+ login: localCreds.login,
5070
+ email: localCreds.email,
5071
+ email_password: localCreds.email_password,
5072
+ totp_seed: localCreds.totp_seed,
5073
+ auth_token: localCreds.auth_token,
5074
+ ct0: localCreds.ct0,
5075
+ country,
5076
+ });
5077
+ }
5078
+ catch (e) {
5079
+ spinReg.stop('Register failed', false);
5080
+ err(`Auto-register failed: ${e.message}`, EXIT.GENERAL);
5081
+ }
5082
+ if (!regData?.success || !regData?.id) {
5083
+ spinReg.stop('Register failed', false);
5084
+ err(`Auto-register failed: ${regData?.error || 'unknown'}` +
5085
+ (regData?.login_error_code ? ` [${regData.login_error_code}]` : '') +
5086
+ `\nFix the credentials in the local vault, then re-run the transfer.`, EXIT.GENERAL);
5087
+ }
5088
+ spinReg.stop('Registered', true);
5089
+ resolved = { kind: 'registered', id: regData.id };
5090
+ }
5091
+ const spin = new Spinner();
5092
+ spin.start(`Rotating @${username} password and transferring…`);
5093
+ // Kick off the transfer. Server responds 202 with a transfer_id;
5094
+ // the rotation runs in the background to avoid Cloudflare's HTTP
5095
+ // timeout. We poll /transfers/:id until it terminates.
5096
+ let kicked;
5097
+ try {
5098
+ kicked = resolved.kind === 'x_accounts'
5099
+ ? await ao.xAccountTransfer(resolved.id, to)
5100
+ : await ao.socialTwitterRegisteredTransfer(resolved.id, to);
5101
+ }
5102
+ catch (e) {
5103
+ spin.stop('Transfer failed', false);
5104
+ err(`Transfer failed: ${e.message}`, EXIT.GENERAL);
5105
+ }
5106
+ const transferId = kicked?.transfer_id;
5107
+ if (!transferId) {
5108
+ spin.stop('Transfer failed', false);
5109
+ err(`Server didn't return a transfer_id. Response: ${JSON.stringify(kicked)}`, EXIT.GENERAL);
5110
+ }
5111
+ // Poll every 5s; cap at 5 minutes so a stuck transfer doesn't
5112
+ // hang the CLI forever. The server's own startup sweep will mark
5113
+ // anything still stuck after 5 min as failed.
5114
+ const startedAt = Date.now();
5115
+ const MAX_WAIT_MS = 5 * 60 * 1000;
5116
+ let status = null;
5117
+ while (true) {
5118
+ if (Date.now() - startedAt > MAX_WAIT_MS) {
5119
+ spin.stop('Timed out waiting', false);
5120
+ err(`Transfer ${transferId} is still in progress after 5 min. Check status with: ` +
5121
+ `curl https://palmyr.ai/transfers/${transferId} (with payment header)`, EXIT.GENERAL);
5122
+ }
5123
+ await new Promise(r => setTimeout(r, 5000));
5124
+ try {
5125
+ status = await ao.transferStatus(transferId);
5126
+ }
5127
+ catch (e) {
5128
+ // Transient poll failures shouldn't abort — the rotation is
5129
+ // still running on the server. Surface it but keep polling.
5130
+ spin.update(`Poll error (will retry): ${e.message}`);
5131
+ continue;
5132
+ }
5133
+ spin.update(`Status: ${status.status}…`);
5134
+ if (status.status === 'completed' || status.status === 'failed')
5135
+ break;
5136
+ }
5137
+ if (status.status === 'failed') {
5138
+ spin.stop('Transfer failed', false);
5139
+ err(`Transfer failed: ${status.error || 'unknown'}` +
5140
+ (status.error_code ? ` [${status.error_code}]` : ''), EXIT.GENERAL);
5141
+ }
5142
+ spin.stop('Transferred', true);
5143
+ // Local vault still holds the OLD password / cookies which are
5144
+ // now useless. Drop the entry so we don't confuse the user with a
5145
+ // ghost account they can't log into.
5146
+ try {
5147
+ sv.removeAccount(platform, username);
5148
+ }
5149
+ catch { /* best effort */ }
5150
+ return print({
5151
+ ...status,
5152
+ source_table: resolved.kind,
5153
+ local_vault_cleared: true,
5154
+ });
5155
+ }
5156
+ case 'share': {
5157
+ const username = positional[0] || flags.username;
5158
+ const withWallet = flags.with || flags.wallet;
5159
+ if (!username)
5160
+ err('<username> required');
5161
+ if (!withWallet)
5162
+ err('--with <wallet> required');
5163
+ const acc = sv.getAccount(platform, username);
5164
+ if (!acc)
5165
+ err(`twitter account "${username}" not found locally`, EXIT.NOT_FOUND);
5166
+ const resolved = await resolveServerAccount(username);
5167
+ if (!resolved) {
5168
+ err(`@${username} is not on the server. Register it first with: palmyr twitter register ${username}`, EXIT.NOT_FOUND);
5169
+ }
5170
+ const data = resolved.kind === 'x_accounts'
5171
+ ? await ao.xAccountShare(resolved.id, withWallet)
5172
+ : await ao.socialTwitterRegisteredShare(resolved.id, withWallet);
5173
+ log(`twitter share: @${username} → ${withWallet}`);
5174
+ return print({ ...data, source_table: resolved.kind });
5175
+ }
5176
+ case 'unshare': {
5177
+ const username = positional[0] || flags.username;
5178
+ const targetWallet = flags.from || flags.wallet;
5179
+ const rotate = !!flags.rotate;
5180
+ if (!username)
5181
+ err('<username> required');
5182
+ if (!targetWallet)
5183
+ err('--from <wallet> required');
5184
+ const acc = sv.getAccount(platform, username);
5185
+ if (!acc)
5186
+ err(`twitter account "${username}" not found locally`, EXIT.NOT_FOUND);
5187
+ const resolved = await resolveServerAccount(username);
5188
+ if (!resolved) {
5189
+ err(`@${username} is not on the server. Register it first with: palmyr twitter register ${username}`, EXIT.NOT_FOUND);
5190
+ }
5191
+ const spin = rotate ? new Spinner() : null;
5192
+ if (spin)
5193
+ spin.start(`Unsharing @${username} and rotating password…`);
5194
+ let data;
5195
+ try {
5196
+ data = resolved.kind === 'x_accounts'
5197
+ ? await ao.xAccountUnshare(resolved.id, targetWallet, { rotate })
5198
+ : await ao.socialTwitterRegisteredUnshare(resolved.id, targetWallet, { rotate });
5199
+ }
5200
+ catch (e) {
5201
+ if (spin)
5202
+ spin.stop('Unshare failed', false);
5203
+ err(`Unshare failed: ${e.message}`, EXIT.GENERAL);
5204
+ }
5205
+ // If the server kicked off an async rotation, poll until it
5206
+ // settles. Same polling pattern as transfer — Playwright takes
5207
+ // longer than Cloudflare's HTTP response budget.
5208
+ if (rotate && data?.transfer_id) {
5209
+ const transferId = data.transfer_id;
5210
+ const startedAt = Date.now();
5211
+ const MAX_WAIT_MS = 5 * 60 * 1000;
5212
+ let status = null;
5213
+ while (true) {
5214
+ if (Date.now() - startedAt > MAX_WAIT_MS) {
5215
+ if (spin)
5216
+ spin.stop('Rotation timed out', false);
5217
+ err(`Rotation ${transferId} still in progress after 5 min. ` +
5218
+ `Account is unshared but cached cookies on the revoked wallet may still work until X-side expiry. ` +
5219
+ `Check status at /transfers/${transferId}.`, EXIT.GENERAL);
5220
+ }
5221
+ await new Promise(r => setTimeout(r, 5000));
5222
+ try {
5223
+ status = await ao.transferStatus(transferId);
5224
+ }
5225
+ catch (e) {
5226
+ if (spin)
5227
+ spin.update(`Poll error (will retry): ${e.message}`);
5228
+ continue;
5229
+ }
5230
+ if (spin)
5231
+ spin.update(`Rotation: ${status.status}…`);
5232
+ if (status.status === 'completed' || status.status === 'failed')
5233
+ break;
5234
+ }
5235
+ if (status.status === 'failed') {
5236
+ if (spin)
5237
+ spin.stop('Rotation failed', false);
5238
+ warn(`Unshare succeeded but rotation failed: ${status.error || 'unknown'}` +
5239
+ (status.error_code ? ` [${status.error_code}]` : '') +
5240
+ `. The revoked wallet's cached cookies may still work until X-side expiry. Retry rotation if needed.`);
5241
+ data = { ...data, rotated: false, rotation_error: status.error, rotation_error_code: status.error_code };
5242
+ }
5243
+ else {
5244
+ if (spin)
5245
+ spin.stop('Unshared and rotated', true);
5246
+ // Fetch fresh creds from the appropriate /mine endpoint and
5247
+ // sync the local vault. Caller is still the owner, so the
5248
+ // server returns the new credentials they need.
5249
+ try {
5250
+ const mineResp = resolved.kind === 'x_accounts'
5251
+ ? await ao.xAccountsMine()
5252
+ : await ao.socialTwitterRegisteredMine();
5253
+ const fresh = (mineResp?.accounts || []).find((a) => a.username === username);
5254
+ if (fresh) {
5255
+ const existing = sv.unlockCredentials(platform, username);
5256
+ const isReg = resolved.kind === 'registered';
5257
+ const freshPassword = isReg ? fresh.credentials?.password : fresh.password;
5258
+ const freshAuth = isReg ? fresh.credentials?.auth_token : fresh.auth_token;
5259
+ const freshCookies = fresh.cookies || [];
5260
+ const next = {
5261
+ ...existing,
5262
+ password: freshPassword || existing.password,
5263
+ auth_token: freshAuth || undefined,
5264
+ ct0: freshCookies.find((c) => c.name === 'ct0')?.value || existing.ct0,
5265
+ };
5266
+ sv.replaceCredentials(platform, username, next);
5267
+ if (Array.isArray(freshCookies) && freshCookies.length > 0) {
5268
+ sv.saveSession(acc.id, platform, freshCookies);
5269
+ }
5270
+ }
5271
+ }
5272
+ catch (e) {
5273
+ warn(`Local vault sync failed: ${e.message}. Run 'palmyr twitter claim' to refresh from server.`);
5274
+ }
5275
+ data = { ...data, rotated: true, credentials: { rotated: true, persisted_locally: true } };
5276
+ }
5277
+ }
5278
+ else {
5279
+ if (spin)
5280
+ spin.stop('Unshared (rotation skipped)', false);
5281
+ }
5282
+ log(`twitter unshare: @${username} ✗ ${targetWallet}${rotate ? ' (rotated)' : ''}`);
5283
+ return print({ ...data, source_table: resolved.kind });
5284
+ }
5285
+ case 'claim': {
5286
+ // Fetch server-side accounts the calling wallet owns or has shared
5287
+ // access to — from BOTH tables. x_accounts (pool-bought) and
5288
+ // social_registered_accounts (BYO-registered) are queried in
5289
+ // parallel; both contribute to the claim list. Import any not
5290
+ // already in the local vault so the receiver of a transfer can
5291
+ // pick up the account in one command.
5292
+ const [xRes, regRes] = await Promise.allSettled([
5293
+ ao.xAccountsMine(),
5294
+ ao.socialTwitterRegisteredMine(),
5295
+ ]);
5296
+ const xAccounts = xRes.status === 'fulfilled' ? (xRes.value?.accounts || []) : [];
5297
+ const regAccountsRaw = regRes.status === 'fulfilled' ? (regRes.value?.accounts || []) : [];
5298
+ // Normalize the registered shape (creds + cookies live in nested
5299
+ // fields) to the same flat shape as x_accounts so the loop below
5300
+ // doesn't have to branch on source.
5301
+ const regAccounts = regAccountsRaw.map(a => ({
5302
+ username: a.username,
5303
+ email: a.credentials?.email,
5304
+ password: a.credentials?.password,
5305
+ auth_token: a.credentials?.auth_token,
5306
+ cookies: a.cookies || [],
5307
+ access: a.access,
5308
+ source_table: 'registered',
5309
+ }));
5310
+ const accounts = [
5311
+ ...xAccounts.map(a => ({ ...a, source_table: 'x_accounts' })),
5312
+ ...regAccounts,
5313
+ ];
5314
+ if (accounts.length === 0) {
5315
+ log('No X accounts associated with your wallet on the server.');
5316
+ return print({ count: 0, claimed: 0, accounts: [] });
5317
+ }
5318
+ const imported = [];
5319
+ const skipped = [];
5320
+ for (const a of accounts) {
5321
+ const existing = sv.getAccount(platform, a.username);
5322
+ if (existing) {
5323
+ skipped.push({ username: a.username, reason: 'already in local vault' });
5324
+ continue;
5325
+ }
5326
+ try {
5327
+ const ct0 = (a.cookies || []).find((c) => c.name === 'ct0')?.value;
5328
+ const creds = {
5329
+ login: a.email || a.username,
5330
+ password: a.password,
5331
+ email: a.email,
5332
+ auth_token: a.auth_token || undefined,
5333
+ ct0,
5334
+ };
5335
+ const summary = sv.importAccount(platform, a.username, creds, { source: 'claim' });
5336
+ // Save the cookies so `palmyr twitter login` can use the
5337
+ // cookie-fast-path instead of re-driving the login form.
5338
+ if (Array.isArray(a.cookies) && a.cookies.length > 0) {
5339
+ sv.saveSession(summary.id, platform, a.cookies);
5340
+ }
5341
+ imported.push({ username: a.username, id: summary.id, access: a.access, source_table: a.source_table });
5342
+ }
5343
+ catch (e) {
5344
+ skipped.push({ username: a.username, reason: e.message });
5345
+ }
5346
+ }
5347
+ log(`twitter claim: imported ${imported.length}, skipped ${skipped.length} (of ${accounts.length})`);
5348
+ return print({
5349
+ count: accounts.length,
5350
+ claimed: imported.length,
5351
+ imported,
5352
+ skipped,
5353
+ });
5354
+ }
4892
5355
  default:
4893
- err(`Unknown twitter command: ${subcommand}. Try: import, list, info, rename, remove, totp, login, manual-login, session, post, reply, like, retweet, follow, unfollow, delete, list-tweets, bio, name, location, website, pfp, banner, username, buy`);
5356
+ err(`Unknown twitter command: ${subcommand}. Try: import, list, info, rename, remove, totp, login, manual-login, session, post, reply, like, retweet, follow, unfollow, delete, list-tweets, bio, name, location, website, pfp, banner, username, buy, transfer, share, unshare, claim`);
4894
5357
  }
4895
5358
  break;
4896
5359
  }