@palmyr/cli 1.5.0 → 1.5.6

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/README.md CHANGED
@@ -498,7 +498,7 @@ Local credentials are encrypted with AES-256-GCM (per-account session secret in
498
498
  | `palmyr twitter buy` | $5.00 | Pay $5 USDC, receive a ready X account from the pool. Auto-imports into the local vault and primes the session — you can post immediately. |
499
499
  | `palmyr twitter import <username> --credentials-line "login:pw:email:email_pw:2fa:ct0:auth_token"` | free | Bring your own account. Accepts the standard 4 / 5 / 7-field colon format common in marketplace exports. |
500
500
  | `palmyr twitter import <username> --login E --password P [--email-password X] [--totp-seed S] [--auth-token T --ct0 C]` | free | Same, with explicit flags. |
501
- | `palmyr twitter list` | free | List local accounts. |
501
+ | `palmyr twitter list [--local]` | $0.0011 *(ownership proof on `/x/accounts/mine` + `/social/twitter/registered/mine`)* — `--local` skips the server check for free | Local vault PLUS server-side accounts the wallet owns or has been shared with. Server-only accounts appear with a `server-only — run 'palmyr twitter claim' to import` hint. Default behavior queries the server so a wallet that just received a transfer sees the account without an extra `claim` step. |
502
502
  | `palmyr twitter info <username>` | free | Show one account (id, addresses, last action, source). |
503
503
  | `palmyr twitter rename <old> --to <new>` | free | Rename the local handle (does not change the X handle — use `twitter username` for that). |
504
504
  | `palmyr twitter remove <username> --confirm` | free | Delete the local copy. The X account itself is not deleted. |
@@ -522,10 +522,10 @@ Local credentials are encrypted with AES-256-GCM (per-account session secret in
522
522
  | `palmyr twitter pfp <username> --file path.png` *(or `--url https://...`)* | $0.005 | PNG / JPG / WebP / GIF. Local file is base64-encoded; URL is fetched server-side with SSRF guard. |
523
523
  | `palmyr twitter banner <username> --file path.png` *(or `--url ...`)* | $0.005 | |
524
524
  | `palmyr twitter username <username> --to <new-handle>` | $0.005 | Pre-flight validates handle (4–15 chars, `[A-Za-z0-9_]`) before payment. May trigger X's password re-auth modal — handled automatically. |
525
- | `palmyr twitter transfer <username> --to <wallet> --confirm` | $0.0001 *(ownership proof)* | Atomically hand the X account to another wallet. Server rotates the password and revokes other sessions before flipping `sold_to`, so the local copy of credentials becomes useless. Requires `--confirm`. Local vault entry is removed on success — receiver picks up fresh creds with `palmyr twitter claim`. |
526
- | `palmyr twitter share <username> --with <wallet>` | $0.0001 *(ownership proof)* | Grant another wallet shared access — same login, no credential rotation. Both wallets see the account via `palmyr twitter claim`. Owner-only. |
527
- | `palmyr twitter unshare <username> --from <wallet> [--rotate]` | $0.0001 *(ownership proof; rotation runs through Playwright when `--rotate`)* | Revoke a wallet's shared access. Without `--rotate`, the wallet is removed from `shared_with` but their previously exported cookies / password remain valid until X-side expiry. With `--rotate`, the server also rotates the password and revokes other sessions, then the CLI updates the local vault in place. Owner-only. |
528
- | `palmyr twitter claim` | $0.0001 *(ownership proof)* | Pull every X account on the server bound to your wallet (owner or shared) into the local vault, with session cookies pre-warmed. The fast path for a wallet that just received a transferred account. |
525
+ | `palmyr twitter transfer <username> --to <wallet> --confirm` | $0.0001 ownership proof + $0.0011 lookup; **adds $0.01 if auto-register runs**; **plus ~$0.001 in poll fees** ($0.0001 × ~10 polls) | Atomically hand the X account to another wallet. End-to-end one-command: looks up the account in both server tables (`x_accounts` + `social_registered_accounts`), auto-registers if only in local vault, then kicks off an async rotation job. Server returns `{ transfer_id }` immediately and the CLI polls `/transfers/:id` every 5s until `completed` or `failed` (rotation takes 30-90s in the background — async so it survives Cloudflare's HTTP timeout). Password is rotated and other sessions revoked before ownership flips, so the local copy of credentials becomes useless. Requires `--confirm`. Local vault entry is removed on success — receiver picks up fresh creds with `palmyr twitter list` (which shows server-only accounts) and/or `palmyr twitter claim`. |
526
+ | `palmyr twitter share <username> --with <wallet>` | $0.0001 *(ownership proof)* | Grant another wallet shared access — same login, no credential rotation. Both wallets see the account via `palmyr twitter claim`. Owner-only. Same pool / registered dispatch as transfer. |
527
+ | `palmyr twitter unshare <username> --from <wallet> [--rotate]` | $0.0001 ownership proof + $0.0011 lookup; **with `--rotate`: kicks off async rotation, ~$0.001 in poll fees plus another lookup to sync local vault** | Revoke a wallet's shared access. Without `--rotate`, the wallet is removed from `shared_with` immediately but their previously exported cookies / password remain valid until X-side expiry. With `--rotate`, the unshare is immediate AND the server kicks off an async password rotation (same machinery as transfer — Playwright + polling so it survives Cloudflare's HTTP timeout). On completion, the CLI fetches the new credentials from the appropriate `/mine` endpoint and updates the local vault in place. Owner-only. Same pool / registered dispatch. |
528
+ | `palmyr twitter claim` | $0.0011 *(ownership proof on both `/x/accounts/mine` and `/social/twitter/registered/mine`)* | Pull every X account on the server bound to your wallet (owner or shared) into the local vault, with session cookies pre-warmed. Queries both server tables in parallel. The fast path for a wallet that just received a transferred account. |
529
529
 
530
530
  **Verification.** Operations are confirmed at the network layer — the server intercepts X's actual API responses (`CreateTweet`, `FavoriteTweet`, `update_profile`, etc.) before reporting success. No false positives.
531
531
 
package/dist/cli.js CHANGED
@@ -4000,6 +4000,75 @@ async function main() {
4000
4000
  case 'twitter': {
4001
4001
  const sv = await import('./social-vault.js');
4002
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
+ };
4003
4072
  if (!subcommand) {
4004
4073
  showMenu({
4005
4074
  command: 'twitter',
@@ -4008,7 +4077,7 @@ async function main() {
4008
4077
  footerLeft: 'Phase 1: local vault + BYO import works today. Server-dependent commands stub out.',
4009
4078
  commands: [
4010
4079
  { name: 'import', description: 'Save a BYO account to the local vault', hint: '--username --password --totp-seed' },
4011
- { 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]' },
4012
4081
  { name: 'info', description: 'Show one account', hint: '<username>' },
4013
4082
  { name: 'rename', description: 'Update the local record when the handle changes', hint: '<old> --to <new>' },
4014
4083
  { name: 'remove', description: 'Delete an account from the local vault', hint: '<username> --confirm' },
@@ -4017,7 +4086,7 @@ async function main() {
4017
4086
  { name: 'login', description: 'Force a fresh server-side session (requires browser runtime)', hint: '<username>' },
4018
4087
  { name: 'post', description: 'Post a tweet (requires server browser runtime)', hint: '<username> --body "..."' },
4019
4088
  { name: 'status', description: 'Check if the account is alive / shadow-banned', hint: '<username>' },
4020
- { name: 'transfer', description: 'Hand an account to another wallet (rotates password)', hint: '<username> --to <wallet>' },
4089
+ { name: 'transfer', description: 'Hand an account to another wallet (rotates password; auto-registers if needed)', hint: '<username> --to <wallet> --confirm' },
4021
4090
  { name: 'share', description: 'Grant another wallet shared access', hint: '<username> --with <wallet>' },
4022
4091
  { name: 'unshare', description: 'Revoke a wallet’s shared access', hint: '<username> --from <wallet> [--rotate]' },
4023
4092
  { name: 'claim', description: 'Import server-side accounts owned by your wallet into the local vault' },
@@ -4092,16 +4161,59 @@ async function main() {
4092
4161
  return print({ ...summary, has_cookies: !!authToken });
4093
4162
  }
4094
4163
  case 'list': {
4095
- const accounts = sv.listAccounts(platform);
4096
- 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
+ });
4097
4211
  }
4098
4212
  case 'info': {
4099
4213
  const username = positional[0] || flags.username;
4100
4214
  if (!username)
4101
4215
  err('<username> required');
4102
- const acc = sv.getAccount(platform, username);
4103
- if (!acc)
4104
- err(`twitter account "${username}" not found locally`, EXIT.NOT_FOUND);
4216
+ const acc = await ensureLocalAccount(username);
4105
4217
  return print(acc);
4106
4218
  }
4107
4219
  case 'rename': {
@@ -4132,6 +4244,7 @@ async function main() {
4132
4244
  const username = positional[0] || flags.username;
4133
4245
  if (!username)
4134
4246
  err('<username> required');
4247
+ await ensureLocalAccount(username);
4135
4248
  const creds = sv.unlockCredentials(platform, username);
4136
4249
  if (!creds.totp_seed)
4137
4250
  err(`twitter account "${username}" has no TOTP seed configured`, EXIT.NOT_FOUND);
@@ -4235,16 +4348,14 @@ async function main() {
4235
4348
  const username = positional[0] || flags.username;
4236
4349
  if (!username)
4237
4350
  err('<username> required');
4238
- const acc = sv.getAccount(platform, username);
4239
- if (!acc)
4240
- err(`twitter account "${username}" not found locally`, EXIT.NOT_FOUND);
4351
+ const acc = await ensureLocalAccount(username);
4241
4352
  const sess = sv.loadSession(acc.id);
4242
4353
  if (!sess) {
4243
4354
  return print({
4244
4355
  platform,
4245
4356
  username,
4246
4357
  cached: false,
4247
- hint: `No cached session. Run: node cli/dist/cli.js twitter login ${username}`,
4358
+ hint: `No cached session. Run: palmyr twitter login ${username}`,
4248
4359
  });
4249
4360
  }
4250
4361
  const ageHours = sv.sessionAgeHours(acc.id);
@@ -4270,9 +4381,7 @@ async function main() {
4270
4381
  err('--limit must be a positive integer', EXIT.BAD_INPUT);
4271
4382
  limit = Math.floor(n);
4272
4383
  }
4273
- const acc = sv.getAccount(platform, username);
4274
- if (!acc)
4275
- err(`twitter account "${username}" not found locally`, EXIT.NOT_FOUND);
4384
+ const acc = await ensureLocalAccount(username);
4276
4385
  const sess = sv.loadSession(acc.id);
4277
4386
  if (!sess || !sess.cookies || sess.cookies.length === 0) {
4278
4387
  err(`No cached session for ${username}. Run 'twitter login ${username}' first.`, EXIT.NOT_FOUND);
@@ -4565,9 +4674,7 @@ async function main() {
4565
4674
  err(`Invalid username "${rawNewUsername}". X requires 4-15 chars, letters/numbers/underscores only. ` +
4566
4675
  `You have NOT been charged.`, EXIT.BAD_INPUT);
4567
4676
  }
4568
- const acc = sv.getAccount(platform, username);
4569
- if (!acc)
4570
- err(`twitter account "${username}" not found locally`, EXIT.NOT_FOUND);
4677
+ const acc = await ensureLocalAccount(username);
4571
4678
  const sess = sv.loadSession(acc.id);
4572
4679
  if (!sess || !sess.cookies || sess.cookies.length === 0) {
4573
4680
  err(`No cached session. Run 'twitter login ${username}' first.`, EXIT.NOT_FOUND);
@@ -4610,9 +4717,7 @@ async function main() {
4610
4717
  const username = positional[0] || flags.username;
4611
4718
  if (!username)
4612
4719
  err(`<username> required`);
4613
- const acc = sv.getAccount(platform, username);
4614
- if (!acc)
4615
- err(`twitter account "${username}" not found locally`, EXIT.NOT_FOUND);
4720
+ const acc = await ensureLocalAccount(username);
4616
4721
  const sess = sv.loadSession(acc.id);
4617
4722
  if (!sess || !sess.cookies || sess.cookies.length === 0) {
4618
4723
  err(`No cached session for ${username}. Run 'twitter login ${username}' first.`, EXIT.NOT_FOUND);
@@ -4829,7 +4934,7 @@ async function main() {
4829
4934
  success: true,
4830
4935
  platform,
4831
4936
  username: summary.username,
4832
- hint: `Ready to post — try: node cli/dist/cli.js twitter post ${summary.username} --body "gm"`,
4937
+ hint: `Ready to post — try: palmyr twitter post ${summary.username} --body "gm"`,
4833
4938
  });
4834
4939
  }
4835
4940
  case 'pool-add': {
@@ -4918,11 +5023,14 @@ async function main() {
4918
5023
  err(`twitter status: not wired yet. Phase 3 will add it.`, EXIT.GENERAL);
4919
5024
  }
4920
5025
  case 'transfer': {
4921
- // Hand the X account to another wallet. Server rotates the password
4922
- // and revokes other sessions before flipping ownership, so the local
4923
- // copy of credentials we keep is intentionally invalidated. After
4924
- // success, we wipe the local vault entry receiver will pick up
4925
- // fresh credentials via `palmyr twitter claim`.
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`.
4926
5034
  const username = positional[0] || flags.username;
4927
5035
  const to = flags.to;
4928
5036
  if (!username)
@@ -4936,28 +5044,114 @@ async function main() {
4936
5044
  const tail = to.slice(-6);
4937
5045
  err(`This rotates @${username} on X and hands it to a wallet ending in …${tail}. ` +
4938
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` +
4939
5048
  `Re-run with --confirm:\n` +
4940
5049
  ` palmyr twitter transfer ${username} --to ${to} --confirm`);
4941
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
+ }
4942
5091
  const spin = new Spinner();
4943
5092
  spin.start(`Rotating @${username} password and transferring…`);
4944
- let data;
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;
4945
5097
  try {
4946
- data = await ao.xAccountTransfer(acc.id, to);
5098
+ kicked = resolved.kind === 'x_accounts'
5099
+ ? await ao.xAccountTransfer(resolved.id, to)
5100
+ : await ao.socialTwitterRegisteredTransfer(resolved.id, to);
4947
5101
  }
4948
5102
  catch (e) {
4949
5103
  spin.stop('Transfer failed', false);
4950
5104
  err(`Transfer failed: ${e.message}`, EXIT.GENERAL);
4951
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
+ }
4952
5142
  spin.stop('Transferred', true);
4953
- // The local vault still holds the OLD password / cookies which are
5143
+ // Local vault still holds the OLD password / cookies which are
4954
5144
  // now useless. Drop the entry so we don't confuse the user with a
4955
5145
  // ghost account they can't log into.
4956
5146
  try {
4957
5147
  sv.removeAccount(platform, username);
4958
5148
  }
4959
5149
  catch { /* best effort */ }
4960
- return print({ ...data, local_vault_cleared: true });
5150
+ return print({
5151
+ ...status,
5152
+ source_table: resolved.kind,
5153
+ local_vault_cleared: true,
5154
+ });
4961
5155
  }
4962
5156
  case 'share': {
4963
5157
  const username = positional[0] || flags.username;
@@ -4969,9 +5163,15 @@ async function main() {
4969
5163
  const acc = sv.getAccount(platform, username);
4970
5164
  if (!acc)
4971
5165
  err(`twitter account "${username}" not found locally`, EXIT.NOT_FOUND);
4972
- const data = await ao.xAccountShare(acc.id, withWallet);
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);
4973
5173
  log(`twitter share: @${username} → ${withWallet}`);
4974
- return print(data);
5174
+ return print({ ...data, source_table: resolved.kind });
4975
5175
  }
4976
5176
  case 'unshare': {
4977
5177
  const username = positional[0] || flags.username;
@@ -4984,53 +5184,133 @@ async function main() {
4984
5184
  const acc = sv.getAccount(platform, username);
4985
5185
  if (!acc)
4986
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
+ }
4987
5191
  const spin = rotate ? new Spinner() : null;
4988
5192
  if (spin)
4989
5193
  spin.start(`Unsharing @${username} and rotating password…`);
4990
5194
  let data;
4991
5195
  try {
4992
- data = await ao.xAccountUnshare(acc.id, targetWallet, { rotate });
5196
+ data = resolved.kind === 'x_accounts'
5197
+ ? await ao.xAccountUnshare(resolved.id, targetWallet, { rotate })
5198
+ : await ao.socialTwitterRegisteredUnshare(resolved.id, targetWallet, { rotate });
4993
5199
  }
4994
5200
  catch (e) {
4995
5201
  if (spin)
4996
5202
  spin.stop('Unshare failed', false);
4997
5203
  err(`Unshare failed: ${e.message}`, EXIT.GENERAL);
4998
5204
  }
4999
- if (spin)
5000
- spin.stop(data?.rotated ? 'Unshared and rotated' : 'Unshared (rotation skipped)', !!data?.rotated);
5001
- // If rotation succeeded, sync the local vault so the next op
5002
- // doesn't try to log in with the now-defunct password / cookies.
5003
- if (rotate && data?.rotated && data?.credentials) {
5004
- try {
5005
- const existing = sv.unlockCredentials(platform, username);
5006
- const next = {
5007
- ...existing,
5008
- password: data.credentials.password,
5009
- auth_token: data.credentials.auth_token || undefined,
5010
- ct0: (data.credentials.cookies || []).find((c) => c.name === 'ct0')?.value || existing.ct0,
5011
- };
5012
- sv.replaceCredentials(platform, username, next);
5013
- if (Array.isArray(data.credentials.cookies) && data.credentials.cookies.length > 0) {
5014
- sv.saveSession(acc.id, platform, data.credentials.cookies);
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);
5015
5224
  }
5016
- // Don't leak the new password into the printed JSON output —
5017
- // it's already persisted locally.
5018
- data = { ...data, credentials: { rotated: true, persisted_locally: true } };
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;
5019
5234
  }
5020
- catch (e) {
5021
- warn(`Local vault sync failed: ${e.message}. Run 'palmyr twitter claim' to refresh from server.`);
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 } };
5022
5276
  }
5023
5277
  }
5278
+ else {
5279
+ if (spin)
5280
+ spin.stop('Unshared (rotation skipped)', false);
5281
+ }
5024
5282
  log(`twitter unshare: @${username} ✗ ${targetWallet}${rotate ? ' (rotated)' : ''}`);
5025
- return print(data);
5283
+ return print({ ...data, source_table: resolved.kind });
5026
5284
  }
5027
5285
  case 'claim': {
5028
- // Fetch every server-side X account this wallet owns or has shared
5029
- // access to. Optionally import any not yet in the local vault —
5030
- // typical use is the new owner of a transferred account picking it
5031
- // up for the first time.
5032
- const data = await ao.xAccountsMine();
5033
- const accounts = data?.accounts || [];
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
+ ];
5034
5314
  if (accounts.length === 0) {
5035
5315
  log('No X accounts associated with your wallet on the server.');
5036
5316
  return print({ count: 0, claimed: 0, accounts: [] });
@@ -5058,7 +5338,7 @@ async function main() {
5058
5338
  if (Array.isArray(a.cookies) && a.cookies.length > 0) {
5059
5339
  sv.saveSession(summary.id, platform, a.cookies);
5060
5340
  }
5061
- imported.push({ username: a.username, id: summary.id, access: a.access });
5341
+ imported.push({ username: a.username, id: summary.id, access: a.access, source_table: a.source_table });
5062
5342
  }
5063
5343
  catch (e) {
5064
5344
  skipped.push({ username: a.username, reason: e.message });