@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 +5 -5
- package/dist/cli.js +342 -62
- package/dist/cli.js.map +1 -1
- package/dist/sdk.d.ts +13 -0
- package/dist/sdk.js +34 -6
- package/dist/sdk.js.map +1 -1
- package/package.json +1 -1
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 |
|
|
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
|
|
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
|
|
528
|
-
| `palmyr twitter claim` | $0.
|
|
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
|
|
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
|
-
|
|
4096
|
-
|
|
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 =
|
|
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 =
|
|
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:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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:
|
|
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.
|
|
4922
|
-
//
|
|
4923
|
-
//
|
|
4924
|
-
//
|
|
4925
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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({
|
|
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
|
|
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 =
|
|
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
|
-
|
|
5000
|
-
|
|
5001
|
-
//
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
5005
|
-
|
|
5006
|
-
|
|
5007
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
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
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
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
|
-
|
|
5021
|
-
|
|
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
|
|
5029
|
-
// access to
|
|
5030
|
-
//
|
|
5031
|
-
//
|
|
5032
|
-
|
|
5033
|
-
|
|
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 });
|