@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/README.md +10 -2
- package/dist/cli.js +485 -22
- package/dist/cli.js.map +1 -1
- package/dist/sdk.d.ts +22 -0
- package/dist/sdk.js +50 -0
- package/dist/sdk.js.map +1 -1
- package/dist/social-vault.d.ts +8 -0
- package/dist/social-vault.js +20 -0
- package/dist/social-vault.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
4068
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
}
|