@promus/cli 0.24.17
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 +18 -0
- package/bin/promus +33 -0
- package/package.json +51 -0
- package/src/commands/_agents.ts +14 -0
- package/src/commands/_inft-ref.ts +43 -0
- package/src/commands/_unlock.ts +74 -0
- package/src/commands/admin-autotopup-tick.ts +73 -0
- package/src/commands/admin.test.ts +34 -0
- package/src/commands/admin.ts +32 -0
- package/src/commands/balance.test.ts +10 -0
- package/src/commands/balance.ts +112 -0
- package/src/commands/chat-sandbox.tsx +520 -0
- package/src/commands/chat-telegram.ts +398 -0
- package/src/commands/chat.tsx +1916 -0
- package/src/commands/deploy.ts +204 -0
- package/src/commands/drain.ts +90 -0
- package/src/commands/gateway-logs.ts +47 -0
- package/src/commands/gateway-run.ts +54 -0
- package/src/commands/gateway-start.ts +218 -0
- package/src/commands/gateway-status.ts +88 -0
- package/src/commands/gateway-stop.ts +133 -0
- package/src/commands/gateway.ts +101 -0
- package/src/commands/init/cost.test.ts +169 -0
- package/src/commands/init/cost.ts +154 -0
- package/src/commands/init/funding-gate.ts +67 -0
- package/src/commands/init/model-picker.ts +81 -0
- package/src/commands/init/operator-picker.ts +263 -0
- package/src/commands/init/resume.ts +136 -0
- package/src/commands/init/sandbox-provision.test.ts +497 -0
- package/src/commands/init/sandbox-provision.ts +1177 -0
- package/src/commands/init/telegram-step.ts +229 -0
- package/src/commands/init/wizard-state.ts +95 -0
- package/src/commands/init.ts +612 -0
- package/src/commands/inspect.ts +529 -0
- package/src/commands/ledger.ts +176 -0
- package/src/commands/logs.ts +86 -0
- package/src/commands/migrate-keystore.ts +155 -0
- package/src/commands/model.ts +48 -0
- package/src/commands/pairing-approve.ts +114 -0
- package/src/commands/pairing-clear.ts +42 -0
- package/src/commands/pairing-list.ts +58 -0
- package/src/commands/pairing-revoke.ts +52 -0
- package/src/commands/pairing.test.ts +88 -0
- package/src/commands/pairing.ts +81 -0
- package/src/commands/pause.ts +99 -0
- package/src/commands/profile.ts +184 -0
- package/src/commands/restore.ts +221 -0
- package/src/commands/resume.ts +181 -0
- package/src/commands/status.ts +119 -0
- package/src/commands/sync.ts +147 -0
- package/src/commands/telegram-remove.ts +65 -0
- package/src/commands/telegram-setup.ts +74 -0
- package/src/commands/telegram-status.ts +89 -0
- package/src/commands/telegram.test.ts +50 -0
- package/src/commands/telegram.ts +44 -0
- package/src/commands/topup.ts +303 -0
- package/src/commands/transfer.test.ts +111 -0
- package/src/commands/transfer.ts +520 -0
- package/src/commands/upgrade.test.ts +137 -0
- package/src/commands/upgrade.ts +690 -0
- package/src/config/load.ts +35 -0
- package/src/config/render.test.ts +96 -0
- package/src/config/render.ts +110 -0
- package/src/index.ts +378 -0
- package/src/sandbox/client.test.ts +251 -0
- package/src/sandbox/client.ts +424 -0
- package/src/ui/app.tsx +677 -0
- package/src/ui/approval-summary.test.ts +154 -0
- package/src/ui/approval-summary.ts +34 -0
- package/src/ui/markdown-parse.ts +219 -0
- package/src/ui/markdown.test.ts +146 -0
- package/src/ui/markdown.tsx +37 -0
- package/src/ui/state.test.ts +74 -0
- package/src/ui/state.ts +198 -0
- package/src/util/bootstrap-mode.test.ts +40 -0
- package/src/util/bootstrap-mode.ts +25 -0
- package/src/util/bootstrap-progress-box.test.ts +190 -0
- package/src/util/bootstrap-progress-box.ts +378 -0
- package/src/util/brain-secrets.ts +96 -0
- package/src/util/cli-version.ts +28 -0
- package/src/util/format.test.ts +16 -0
- package/src/util/format.ts +11 -0
- package/src/util/gateway-spawn.test.ts +86 -0
- package/src/util/gateway-spawn.ts +128 -0
- package/src/util/gateway-version.test.ts +113 -0
- package/src/util/gateway-version.ts +154 -0
- package/src/util/github-releases.test.ts +116 -0
- package/src/util/github-releases.ts +79 -0
- package/src/util/profile-key.test.ts +60 -0
- package/src/util/profile-key.ts +25 -0
- package/src/util/ref-resolver.test.ts +77 -0
- package/src/util/ref-resolver.ts +55 -0
- package/src/util/silence-console.test.ts +53 -0
- package/src/util/silence-console.ts +40 -0
- package/src/util/telegram-secrets.test.ts +227 -0
- package/src/util/telegram-secrets.ts +223 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `promus pairing <subcommand>` — argv dispatcher for the DM pairing flow.
|
|
3
|
+
*
|
|
4
|
+
* Subcommands:
|
|
5
|
+
* list show pending codes + approved users
|
|
6
|
+
* approve <platform> <code> approve a pairing code (case-insensitive)
|
|
7
|
+
* revoke <platform> <userId> revoke an approved user
|
|
8
|
+
* clear-pending [platform] drop all pending codes
|
|
9
|
+
*
|
|
10
|
+
* Platform is `telegram` for Phase 12. Future platforms (discord, slack) will
|
|
11
|
+
* reuse the same command surface.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface PairingArgs {
|
|
15
|
+
sub: 'list' | 'approve' | 'revoke' | 'clear-pending'
|
|
16
|
+
platform?: string
|
|
17
|
+
code?: string
|
|
18
|
+
userId?: string
|
|
19
|
+
yes?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const VALID_SUBS = ['list', 'approve', 'revoke', 'clear-pending'] as const
|
|
23
|
+
|
|
24
|
+
export type PairingParseResult = PairingArgs | { error: string }
|
|
25
|
+
|
|
26
|
+
export function parsePairingArgs(argv: string[]): PairingParseResult {
|
|
27
|
+
const sub = argv[0]
|
|
28
|
+
if (!sub) {
|
|
29
|
+
return {
|
|
30
|
+
error:
|
|
31
|
+
'usage: promus pairing <list | approve <platform> <code> | revoke <platform> <userId> | clear-pending [platform]>',
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!(VALID_SUBS as readonly string[]).includes(sub)) {
|
|
35
|
+
return { error: `unknown subcommand '${sub}' (expected: ${VALID_SUBS.join(' | ')})` }
|
|
36
|
+
}
|
|
37
|
+
const positional = argv.slice(1).filter(a => !a.startsWith('-'))
|
|
38
|
+
const yes = argv.includes('--yes') || argv.includes('-y')
|
|
39
|
+
|
|
40
|
+
if (sub === 'approve') {
|
|
41
|
+
if (positional.length < 2) {
|
|
42
|
+
return { error: 'usage: promus pairing approve <platform> <code>' }
|
|
43
|
+
}
|
|
44
|
+
return { sub: 'approve', platform: positional[0], code: positional[1], yes }
|
|
45
|
+
}
|
|
46
|
+
if (sub === 'revoke') {
|
|
47
|
+
if (positional.length < 2) {
|
|
48
|
+
return { error: 'usage: promus pairing revoke <platform> <userId>' }
|
|
49
|
+
}
|
|
50
|
+
return { sub: 'revoke', platform: positional[0], userId: positional[1], yes }
|
|
51
|
+
}
|
|
52
|
+
if (sub === 'clear-pending') {
|
|
53
|
+
return { sub: 'clear-pending', platform: positional[0], yes }
|
|
54
|
+
}
|
|
55
|
+
return { sub: 'list', platform: positional[0], yes }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function runPairing(args: PairingArgs): Promise<void> {
|
|
59
|
+
switch (args.sub) {
|
|
60
|
+
case 'list': {
|
|
61
|
+
const { runPairingList } = await import('./pairing-list')
|
|
62
|
+
await runPairingList({ platform: args.platform })
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
case 'approve': {
|
|
66
|
+
const { runPairingApprove } = await import('./pairing-approve')
|
|
67
|
+
await runPairingApprove({ platform: args.platform!, code: args.code! })
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
case 'revoke': {
|
|
71
|
+
const { runPairingRevoke } = await import('./pairing-revoke')
|
|
72
|
+
await runPairingRevoke({ platform: args.platform!, userId: args.userId!, yes: args.yes })
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
case 'clear-pending': {
|
|
76
|
+
const { runPairingClear } = await import('./pairing-clear')
|
|
77
|
+
await runPairingClear({ platform: args.platform, yes: args.yes })
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { cancel, confirm, intro, isCancel, note, outro, spinner } from '@clack/prompts'
|
|
2
|
+
import { SANDBOX_PROVIDER_URL_GALILEO, SandboxProviderClient } from '@promus/core'
|
|
3
|
+
import { findAndLoadConfig } from '../config/load'
|
|
4
|
+
import { loadOrPickOperatorSigner } from './init/operator-picker'
|
|
5
|
+
import { ensureSandboxArchived } from './init/sandbox-provision'
|
|
6
|
+
|
|
7
|
+
interface PauseOpts {
|
|
8
|
+
yes?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `promus pause`: archive a started sandbox to stop the runtime burn.
|
|
13
|
+
*
|
|
14
|
+
* Use during dev gaps to extend deposit runway. Sandbox UUID + endpoint
|
|
15
|
+
* preserved; resume via `promus resume` (~2-5 min cold restore).
|
|
16
|
+
*
|
|
17
|
+
* Does NOT require operator-keystore unlock. Only needs the operator wallet
|
|
18
|
+
* to sign the provider HTTP request (action=archive). Fast, low-friction.
|
|
19
|
+
*/
|
|
20
|
+
export async function runPause(opts: PauseOpts = {}): Promise<void> {
|
|
21
|
+
intro('promus pause')
|
|
22
|
+
|
|
23
|
+
const loaded = await findAndLoadConfig()
|
|
24
|
+
if (!loaded) {
|
|
25
|
+
cancel('No promus.config.ts found.')
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
const { config } = loaded
|
|
29
|
+
if (config.deployTarget !== 'sandbox' || !config.sandbox?.id) {
|
|
30
|
+
cancel(
|
|
31
|
+
`Agent is not deployed to a sandbox. (deployTarget=${config.deployTarget ?? 'local'}). Nothing to pause.`,
|
|
32
|
+
)
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const sandboxId = config.sandbox.id
|
|
37
|
+
const operator = await loadOrPickOperatorSigner({
|
|
38
|
+
network: config.network,
|
|
39
|
+
hint: config.operator,
|
|
40
|
+
})
|
|
41
|
+
if (!operator) {
|
|
42
|
+
cancel('No operator wallet available; cannot sign archive request.')
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!opts.yes) {
|
|
47
|
+
const ok = await confirm({
|
|
48
|
+
message: `Pause sandbox ${sandboxId.slice(0, 8)}? Burn stops; resume with \`promus resume\`.`,
|
|
49
|
+
initialValue: true,
|
|
50
|
+
})
|
|
51
|
+
if (isCancel(ok) || !ok) {
|
|
52
|
+
cancel('Aborted.')
|
|
53
|
+
await operator.close?.()
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const operatorAccount = await operator.account()
|
|
59
|
+
const provider = new SandboxProviderClient({
|
|
60
|
+
endpoint: SANDBOX_PROVIDER_URL_GALILEO,
|
|
61
|
+
operator: operatorAccount,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const sBox = spinner()
|
|
65
|
+
sBox.start('Archiving sandbox')
|
|
66
|
+
try {
|
|
67
|
+
const result = await ensureSandboxArchived(provider, sandboxId, {
|
|
68
|
+
onProgress: msg => sBox.message(msg),
|
|
69
|
+
})
|
|
70
|
+
if (result.alreadyArchived) {
|
|
71
|
+
sBox.stop(`sandbox ${sandboxId.slice(0, 8)} already archived (no-op)`)
|
|
72
|
+
} else {
|
|
73
|
+
sBox.stop(`sandbox ${sandboxId.slice(0, 8)} archived (was ${result.initialState})`)
|
|
74
|
+
}
|
|
75
|
+
outro(
|
|
76
|
+
[
|
|
77
|
+
'',
|
|
78
|
+
` sandbox ${sandboxId} (preserved)`,
|
|
79
|
+
` endpoint ${config.sandbox.endpoint} (preserved)`,
|
|
80
|
+
` state before ${result.initialState}`,
|
|
81
|
+
' state now archived',
|
|
82
|
+
' burn stopped',
|
|
83
|
+
'',
|
|
84
|
+
'To wake: promus resume',
|
|
85
|
+
].join('\n'),
|
|
86
|
+
)
|
|
87
|
+
} catch (e) {
|
|
88
|
+
sBox.stop(`pause failed: ${(e as Error).message.slice(0, 200)}`)
|
|
89
|
+
note(
|
|
90
|
+
[
|
|
91
|
+
'The sandbox could not transition to archived.',
|
|
92
|
+
'Run `promus status` to inspect, or retry. If the underlying state is bad, `promus upgrade --reprovision` is the escape hatch.',
|
|
93
|
+
].join('\n'),
|
|
94
|
+
'recoverable',
|
|
95
|
+
)
|
|
96
|
+
} finally {
|
|
97
|
+
await operator.close?.()
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { cancel, intro, note, outro, spinner } from '@clack/prompts'
|
|
3
|
+
import {
|
|
4
|
+
MemorySyncManager,
|
|
5
|
+
OPERATOR_BLOB_SCOPES,
|
|
6
|
+
agentPaths,
|
|
7
|
+
deriveBlobKey,
|
|
8
|
+
explorerTxUrl,
|
|
9
|
+
fetchAndDecryptKeystore,
|
|
10
|
+
iNFTAgentId,
|
|
11
|
+
} from '@promus/core'
|
|
12
|
+
import type { Address, Hex } from 'viem'
|
|
13
|
+
import { findAndLoadConfig } from '../config/load'
|
|
14
|
+
import { withSilencedConsole } from '../util/silence-console'
|
|
15
|
+
import { loadOrPickOperatorSigner } from './init/operator-picker'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `promus profile init` — v0.23.0 entry point for the user-partition profile
|
|
19
|
+
* slot. Three things happen:
|
|
20
|
+
*
|
|
21
|
+
* 1. Seed `user/profile.md` on disk if missing (idempotent; never clobbers
|
|
22
|
+
* a non-empty existing file).
|
|
23
|
+
* 2. Derive the operator-scoped PROFILE AES key via one EIP-712 sign.
|
|
24
|
+
* 3a. SANDBOX mode: POST /admin/profile-key (EIP-191-signed) so the daemon
|
|
25
|
+
* picks up the key live + fires a one-shot restore for the slot.
|
|
26
|
+
* 3b. LOCAL mode: trigger a /sync that encrypts profile.md + anchors the
|
|
27
|
+
* PROFILE slot on chain in the same batched updateSlots tx as the
|
|
28
|
+
* other slots.
|
|
29
|
+
*
|
|
30
|
+
* Idempotent: re-running after the first time only re-anchors if profile.md
|
|
31
|
+
* content changed since the last flush.
|
|
32
|
+
*/
|
|
33
|
+
export async function runProfileInit(): Promise<void> {
|
|
34
|
+
intro('promus profile init')
|
|
35
|
+
|
|
36
|
+
note(
|
|
37
|
+
[
|
|
38
|
+
'Legacy command. v0.23.1+ folds profile-key derivation into promus init.',
|
|
39
|
+
'Run this only if your agent was created before v0.23.1.',
|
|
40
|
+
].join('\n'),
|
|
41
|
+
'profile init (legacy)',
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const loaded = await findAndLoadConfig()
|
|
45
|
+
if (!loaded) {
|
|
46
|
+
cancel('No promus config found. Run `promus init` first.')
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
const { config } = loaded
|
|
50
|
+
if (!config.identity.iNFT || !config.identity.agent) {
|
|
51
|
+
cancel('Config has no iNFT or agent. Run `promus init` first.')
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const network = config.network
|
|
56
|
+
const contractAddress = config.identity.iNFT.contract as Address
|
|
57
|
+
const tokenId = BigInt(config.identity.iNFT.tokenId)
|
|
58
|
+
const agentAddress = config.identity.agent as Address
|
|
59
|
+
const finalAgentId = iNFTAgentId({ contractAddress, tokenId })
|
|
60
|
+
const paths = agentPaths.agent(finalAgentId)
|
|
61
|
+
|
|
62
|
+
// 1. seed profile.md if missing
|
|
63
|
+
const profilePath = `${paths.memoryDir}/user/profile.md`
|
|
64
|
+
await mkdir(`${paths.memoryDir}/user`, { recursive: true })
|
|
65
|
+
let seededNow = false
|
|
66
|
+
try {
|
|
67
|
+
const existing = await readFile(profilePath, 'utf8')
|
|
68
|
+
if (existing.trim().length === 0) throw new Error('empty-file')
|
|
69
|
+
} catch {
|
|
70
|
+
const template =
|
|
71
|
+
'---\nname: profile\ndescription: User profile (operator-scoped, never anchored with agent key).\ntype: user\n---\n# User profile\n\n(empty, fills as we chat)\n'
|
|
72
|
+
await writeFile(profilePath, template, 'utf8')
|
|
73
|
+
seededNow = true
|
|
74
|
+
}
|
|
75
|
+
if (seededNow) console.log(`seeded ${profilePath}`)
|
|
76
|
+
|
|
77
|
+
// 2. derive PROFILE scope key + (sandbox) keystore-decrypt or (local) full sync
|
|
78
|
+
const operator = await loadOrPickOperatorSigner({ network, hint: config.operator })
|
|
79
|
+
if (!operator) {
|
|
80
|
+
cancel('No operator wallet available.')
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const sUnlock = spinner()
|
|
85
|
+
sUnlock.start('Deriving PROFILE scope key via operator')
|
|
86
|
+
let profileKey: Buffer
|
|
87
|
+
try {
|
|
88
|
+
profileKey = await deriveBlobKey(operator, agentAddress, OPERATOR_BLOB_SCOPES.PROFILE)
|
|
89
|
+
sUnlock.stop('PROFILE key derived')
|
|
90
|
+
} catch (e) {
|
|
91
|
+
sUnlock.stop(`derive failed: ${(e as Error).message.slice(0, 160)}`)
|
|
92
|
+
await operator.close?.()
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 3a. SANDBOX path: POST /admin/profile-key
|
|
97
|
+
if (config.deployTarget === 'sandbox' && config.sandbox?.endpoint && config.sandbox.id) {
|
|
98
|
+
const { SandboxClient } = await import('../sandbox/client')
|
|
99
|
+
const operatorAccount = await operator.account()
|
|
100
|
+
const client = new SandboxClient({
|
|
101
|
+
endpoint: config.sandbox.endpoint,
|
|
102
|
+
sandboxId: config.sandbox.id,
|
|
103
|
+
operator: operatorAccount,
|
|
104
|
+
})
|
|
105
|
+
const sShip = spinner()
|
|
106
|
+
sShip.start('Shipping PROFILE key to sandbox /admin/profile-key')
|
|
107
|
+
try {
|
|
108
|
+
const profileScopeKeyHex = `0x${profileKey.toString('hex')}` as `0x${string}`
|
|
109
|
+
const result = await client.setProfileKey(profileScopeKeyHex)
|
|
110
|
+
if (result.ok) {
|
|
111
|
+
sShip.stop('sandbox accepted PROFILE key')
|
|
112
|
+
outro(
|
|
113
|
+
[
|
|
114
|
+
'',
|
|
115
|
+
' next flush will encrypt profile.md + anchor on chain',
|
|
116
|
+
' next boot will restore the slot from chain',
|
|
117
|
+
].join('\n'),
|
|
118
|
+
)
|
|
119
|
+
} else {
|
|
120
|
+
sShip.stop(`sandbox rejected: ${result.reason ?? 'unknown'}`)
|
|
121
|
+
}
|
|
122
|
+
} catch (e) {
|
|
123
|
+
sShip.stop(`shipment failed: ${(e as Error).message.slice(0, 200)}`)
|
|
124
|
+
}
|
|
125
|
+
await operator.close?.()
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 3b. LOCAL path: full sync with profileKey injected
|
|
130
|
+
const sUnlock2 = spinner()
|
|
131
|
+
sUnlock2.start('Fetching keystore + decrypting via operator (for local /sync)')
|
|
132
|
+
let agentPrivkey: Hex
|
|
133
|
+
try {
|
|
134
|
+
const decrypted = await withSilencedConsole(() =>
|
|
135
|
+
fetchAndDecryptKeystore({
|
|
136
|
+
network,
|
|
137
|
+
contractAddress,
|
|
138
|
+
tokenId,
|
|
139
|
+
signer: operator,
|
|
140
|
+
agentAddress,
|
|
141
|
+
cachePath: paths.keystore,
|
|
142
|
+
}),
|
|
143
|
+
)
|
|
144
|
+
agentPrivkey = decrypted.privkeyHex
|
|
145
|
+
sUnlock2.stop(`unlocked (source: ${decrypted.source})`)
|
|
146
|
+
} catch (e) {
|
|
147
|
+
sUnlock2.stop(`unlock failed: ${(e as Error).message.slice(0, 160)}`)
|
|
148
|
+
await operator.close?.()
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
await operator.close?.()
|
|
152
|
+
|
|
153
|
+
const sFlush = spinner()
|
|
154
|
+
sFlush.start('Encrypting profile.md + anchoring on chain')
|
|
155
|
+
try {
|
|
156
|
+
const res = await withSilencedConsole(async () => {
|
|
157
|
+
const sync = new MemorySyncManager({
|
|
158
|
+
network,
|
|
159
|
+
agentId: finalAgentId,
|
|
160
|
+
agentPrivkey,
|
|
161
|
+
agentAddress,
|
|
162
|
+
contractAddress,
|
|
163
|
+
tokenId,
|
|
164
|
+
profileKey,
|
|
165
|
+
})
|
|
166
|
+
await sync.init()
|
|
167
|
+
return await sync.flushAll()
|
|
168
|
+
})
|
|
169
|
+
if (res.txHash) {
|
|
170
|
+
sFlush.stop(`anchored ${res.changedSlots.length} slot(s)`)
|
|
171
|
+
outro(
|
|
172
|
+
[
|
|
173
|
+
'',
|
|
174
|
+
` slots: ${res.changedSlots.join(', ')}`,
|
|
175
|
+
` tx: ${explorerTxUrl(network, res.txHash)}`,
|
|
176
|
+
].join('\n'),
|
|
177
|
+
)
|
|
178
|
+
} else {
|
|
179
|
+
sFlush.stop('nothing to anchor (profile.md unchanged since last sync)')
|
|
180
|
+
}
|
|
181
|
+
} catch (e) {
|
|
182
|
+
sFlush.stop(`flush failed: ${(e as Error).message.slice(0, 200)}`)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { cancel, intro, isCancel, note, outro, password, spinner } from '@clack/prompts'
|
|
3
|
+
import {
|
|
4
|
+
type EncryptedKeystore,
|
|
5
|
+
agentPaths,
|
|
6
|
+
decryptKey,
|
|
7
|
+
defineConfig,
|
|
8
|
+
explorerTokenUrl,
|
|
9
|
+
fetchAndDecryptKeystore,
|
|
10
|
+
iNFTAgentId,
|
|
11
|
+
restoreKeystoreFromStorage,
|
|
12
|
+
sniffKeystoreVersion,
|
|
13
|
+
} from '@promus/core'
|
|
14
|
+
import { type Address, bytesToHex } from 'viem'
|
|
15
|
+
import { privateKeyToAccount } from 'viem/accounts'
|
|
16
|
+
import { writeConfigTs } from '../config/render'
|
|
17
|
+
import { type ParsedINFTRef, parseINFTRef } from './_inft-ref'
|
|
18
|
+
import { pickOperatorSigner } from './init/operator-picker'
|
|
19
|
+
|
|
20
|
+
export async function runRestore(opts: { ref: string; cwd?: string }): Promise<void> {
|
|
21
|
+
const configPath = agentPaths.config
|
|
22
|
+
|
|
23
|
+
intro('promus restore')
|
|
24
|
+
|
|
25
|
+
let parsed: ParsedINFTRef
|
|
26
|
+
try {
|
|
27
|
+
parsed = parseINFTRef(opts.ref)
|
|
28
|
+
} catch (e) {
|
|
29
|
+
cancel((e as Error).message)
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const s1 = spinner()
|
|
34
|
+
s1.start(`Fetching iNFT #${parsed.tokenId} on ${parsed.network}`)
|
|
35
|
+
|
|
36
|
+
let encryptedBytes: Uint8Array
|
|
37
|
+
let operatorAddressOnChain: Address
|
|
38
|
+
try {
|
|
39
|
+
const downloaded = await restoreKeystoreFromStorage({
|
|
40
|
+
network: parsed.network,
|
|
41
|
+
contractAddress: parsed.contract,
|
|
42
|
+
tokenId: parsed.tokenId,
|
|
43
|
+
})
|
|
44
|
+
if (!downloaded) {
|
|
45
|
+
s1.stop('keystore slot is unset or predates storage-backed recovery')
|
|
46
|
+
note(
|
|
47
|
+
[
|
|
48
|
+
'This iNFT does not have an encrypted keystore uploaded to 0G Storage.',
|
|
49
|
+
'Either the slot still holds a bootstrap placeholder, or the agent was',
|
|
50
|
+
'minted before the recovery path was live. If you have a local keystore,',
|
|
51
|
+
'copy it to ~/.promus/agents/<id>/keystore.json manually.',
|
|
52
|
+
].join('\n'),
|
|
53
|
+
'cannot restore',
|
|
54
|
+
)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
encryptedBytes = downloaded.encryptedBytes
|
|
58
|
+
operatorAddressOnChain = downloaded.owner
|
|
59
|
+
s1.stop(`fetched ${encryptedBytes.byteLength} bytes from 0G Storage`)
|
|
60
|
+
} catch (e) {
|
|
61
|
+
s1.stop(`fetch failed: ${(e as Error).message}`)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const version = sniffKeystoreVersion(encryptedBytes)
|
|
66
|
+
|
|
67
|
+
const agentId = iNFTAgentId({ contractAddress: parsed.contract, tokenId: parsed.tokenId })
|
|
68
|
+
const paths = agentPaths.agent(agentId)
|
|
69
|
+
let privkeyHex: `0x${string}`
|
|
70
|
+
let agentAddress: Address
|
|
71
|
+
|
|
72
|
+
if (version === 1) {
|
|
73
|
+
note(
|
|
74
|
+
'Detected legacy v1 (passphrase) keystore. After restore, run `promus migrate-keystore` to upgrade to v2 (operator-wallet-encrypted).',
|
|
75
|
+
'legacy keystore',
|
|
76
|
+
)
|
|
77
|
+
const pass = await password({
|
|
78
|
+
message: 'Passphrase for the agent keystore',
|
|
79
|
+
validate: v => (v && v.length >= 1 ? undefined : 'Required.'),
|
|
80
|
+
})
|
|
81
|
+
if (isCancel(pass)) {
|
|
82
|
+
cancel('Aborted.')
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const ks = JSON.parse(new TextDecoder().decode(encryptedBytes)) as EncryptedKeystore
|
|
87
|
+
const privkey = decryptKey(ks, pass)
|
|
88
|
+
privkeyHex = bytesToHex(privkey)
|
|
89
|
+
agentAddress = privateKeyToAccount(privkeyHex).address
|
|
90
|
+
} catch (e) {
|
|
91
|
+
cancel(`decrypt failed: ${(e as Error).message}. Wrong passphrase or corrupted keystore.`)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
await mkdir(paths.dir, { recursive: true })
|
|
95
|
+
await writeFile(paths.keystore, new TextDecoder().decode(encryptedBytes), 'utf8')
|
|
96
|
+
} else if (version === 2) {
|
|
97
|
+
const picked = await pickOperatorSigner({ network: parsed.network })
|
|
98
|
+
if (!picked) return
|
|
99
|
+
const operator = picked.signer
|
|
100
|
+
const pickedAddr = await operator.address()
|
|
101
|
+
if (pickedAddr.toLowerCase() !== operatorAddressOnChain.toLowerCase()) {
|
|
102
|
+
// Hard abort: decrypt is provably impossible from a different wallet,
|
|
103
|
+
// and keeping the WC session open while we ask the user to retype the
|
|
104
|
+
// agent address gives the WC event bus time to fire `chainChanged` /
|
|
105
|
+
// `accountsChanged`, which crashes universal-provider with an uncaught
|
|
106
|
+
// TypeError when the chain isn't in our config. Cancel + disconnect now.
|
|
107
|
+
await operator.close?.()
|
|
108
|
+
cancel(
|
|
109
|
+
[
|
|
110
|
+
'Operator wallet mismatch.',
|
|
111
|
+
` iNFT owner: ${operatorAddressOnChain}`,
|
|
112
|
+
` you connected: ${pickedAddr}`,
|
|
113
|
+
'',
|
|
114
|
+
'You must connect the same wallet that owns this iNFT, then retry.',
|
|
115
|
+
'If the iNFT-owning key only exists in your local keystore (e.g.',
|
|
116
|
+
'macOS Keychain), import it into your mobile wallet first, or pick',
|
|
117
|
+
'a different operator source on the next run.',
|
|
118
|
+
].join('\n'),
|
|
119
|
+
)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const sUnlock = spinner()
|
|
124
|
+
sUnlock.start('Decrypting keystore via operator wallet signature')
|
|
125
|
+
try {
|
|
126
|
+
// We don't know the agent address yet (it's encoded into the typed data).
|
|
127
|
+
// Best path: read the agent address from the iNFT's text records or
|
|
128
|
+
// attempt decrypt by trying the address derived from a successful
|
|
129
|
+
// decrypt. For MVP we ask the user since restoring on a fresh machine
|
|
130
|
+
// means they can't easily derive it.
|
|
131
|
+
sUnlock.stop('need agent address')
|
|
132
|
+
const agentAddrInput = (await password({
|
|
133
|
+
message: 'Agent EOA address (0x…) — find it on the iNFT subname or your records',
|
|
134
|
+
validate: v => {
|
|
135
|
+
if (!v) return 'Required.'
|
|
136
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(v)) return 'Must be a 20-byte hex address.'
|
|
137
|
+
return undefined
|
|
138
|
+
},
|
|
139
|
+
})) as string | symbol
|
|
140
|
+
if (isCancel(agentAddrInput)) {
|
|
141
|
+
cancel('Aborted.')
|
|
142
|
+
await operator.close?.()
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
agentAddress = agentAddrInput as Address
|
|
146
|
+
|
|
147
|
+
const sDecrypt = spinner()
|
|
148
|
+
sDecrypt.start('Sign typed data + decrypt')
|
|
149
|
+
const decrypted = await fetchAndDecryptKeystore({
|
|
150
|
+
network: parsed.network,
|
|
151
|
+
contractAddress: parsed.contract,
|
|
152
|
+
tokenId: parsed.tokenId,
|
|
153
|
+
signer: operator,
|
|
154
|
+
agentAddress,
|
|
155
|
+
cachePath: paths.keystore,
|
|
156
|
+
})
|
|
157
|
+
privkeyHex = decrypted.privkeyHex
|
|
158
|
+
const derived = privateKeyToAccount(privkeyHex).address
|
|
159
|
+
if (derived.toLowerCase() !== agentAddress.toLowerCase()) {
|
|
160
|
+
sDecrypt.stop('decrypt produced unexpected agent address')
|
|
161
|
+
cancel(
|
|
162
|
+
`Decrypted privkey points to ${derived} but you said ${agentAddress}. Aborting to prevent stale config.`,
|
|
163
|
+
)
|
|
164
|
+
await operator.close?.()
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
sDecrypt.stop(`decrypted (source: ${decrypted.source})`)
|
|
168
|
+
} catch (e) {
|
|
169
|
+
cancel(`decrypt failed: ${(e as Error).message}`)
|
|
170
|
+
await operator.close?.()
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
await operator.close?.()
|
|
174
|
+
} else {
|
|
175
|
+
cancel(`Unknown keystore version: ${version}. This blob may be corrupted.`)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const cfg = defineConfig({
|
|
180
|
+
identity: {
|
|
181
|
+
iNFT: {
|
|
182
|
+
contract: parsed.contract,
|
|
183
|
+
tokenId: parsed.tokenId.toString(),
|
|
184
|
+
network: parsed.network,
|
|
185
|
+
},
|
|
186
|
+
operator: operatorAddressOnChain,
|
|
187
|
+
agent: agentAddress,
|
|
188
|
+
},
|
|
189
|
+
network: parsed.network,
|
|
190
|
+
storage: { network: parsed.network },
|
|
191
|
+
brain: { provider: null, model: null },
|
|
192
|
+
plugins: ['onchain', 'comms', 'system'],
|
|
193
|
+
tools: {},
|
|
194
|
+
imports: { claudeCode: true },
|
|
195
|
+
operator: null,
|
|
196
|
+
})
|
|
197
|
+
await writeConfigTs(configPath, cfg, {
|
|
198
|
+
header: '// Regenerated by `promus restore`. Edit freely; type-safe.',
|
|
199
|
+
subname: null,
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
outro(
|
|
203
|
+
[
|
|
204
|
+
'',
|
|
205
|
+
` agent id ${agentId}`,
|
|
206
|
+
` agent EOA ${agentAddress}`,
|
|
207
|
+
` operator ${operatorAddressOnChain}`,
|
|
208
|
+
` iNFT #${parsed.tokenId.toString()} at ${parsed.contract}`,
|
|
209
|
+
` ${explorerTokenUrl(parsed.network, parsed.contract, parsed.tokenId)}`,
|
|
210
|
+
` config ${configPath}`,
|
|
211
|
+
` keystore ${paths.keystore}`,
|
|
212
|
+
'',
|
|
213
|
+
'Next: `promus` to chat, or `promus topup --compute 5` if ledger is dry.',
|
|
214
|
+
version === 1
|
|
215
|
+
? 'Then: `promus migrate-keystore` to upgrade to v2 (drops the passphrase).'
|
|
216
|
+
: '',
|
|
217
|
+
]
|
|
218
|
+
.filter(Boolean)
|
|
219
|
+
.join('\n'),
|
|
220
|
+
)
|
|
221
|
+
}
|