@1sat/cli 0.0.18 → 0.0.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1sat/cli",
3
- "version": "0.0.18",
3
+ "version": "0.0.21",
4
4
  "description": "CLI for 1Sat Ordinals SDK",
5
5
  "type": "module",
6
6
  "main": "./src/cli.ts",
@@ -16,8 +16,8 @@
16
16
  "keywords": ["1sat", "bsv", "ordinals", "cli"],
17
17
  "license": "MIT",
18
18
  "dependencies": {
19
- "@1sat/actions": "0.0.74",
20
- "@1sat/client": "0.0.19",
19
+ "@1sat/actions": "0.0.82",
20
+ "@1sat/client": "0.0.20",
21
21
  "@1sat/types": "0.0.14",
22
22
  "@1sat/wallet-node": ">=0.0.13",
23
23
  "@bsv/sdk": "^2.0.6",
package/src/cli.ts CHANGED
@@ -8,13 +8,14 @@
8
8
 
9
9
  import { parseGlobalFlags } from './args'
10
10
  import { handleActionCommand } from './commands/action'
11
- import { handleMcpProxyCommand } from './commands/mcp-proxy'
12
11
  import { handleConfigCommand } from './commands/config'
13
12
  import { handleIdentityCommand } from './commands/identity'
14
13
  import { handleInitCommand } from './commands/init'
15
14
  import { handleLocksCommand } from './commands/locks'
15
+ import { handleMcpProxyCommand } from './commands/mcp-proxy'
16
16
  import { handleOpnsCommand } from './commands/opns'
17
17
  import { handleOrdinalsCommand } from './commands/ordinals'
18
+ import { handleRemoteCommand } from './commands/remote'
18
19
  import { handleSocialCommand } from './commands/social'
19
20
  import { handleSweepCommand } from './commands/sweep'
20
21
  import { handleTokensCommand } from './commands/tokens'
@@ -49,6 +50,10 @@ async function main(): Promise<void> {
49
50
  await handleConfigCommand(rest, flags)
50
51
  break
51
52
 
53
+ case 'remote':
54
+ await handleRemoteCommand(rest, flags)
55
+ break
56
+
52
57
  case 'wallet':
53
58
  await handleWalletCommand(rest, flags)
54
59
  break
@@ -4,6 +4,7 @@
4
4
  * Subcommands:
5
5
  * show - Display current configuration
6
6
  * set - Set a configuration value
7
+ * unset - Remove a configuration key
7
8
  * path - Print config directory path
8
9
  */
9
10
 
@@ -28,8 +29,8 @@ import {
28
29
  const SETTABLE_KEYS: Array<keyof OneSatCliConfig> = [
29
30
  'chain',
30
31
  'dataDir',
31
- 'activeRemote',
32
32
  'storageIdentityKey',
33
+ 'monitorIntervalMinutes',
33
34
  ]
34
35
 
35
36
  export async function handleConfigCommand(
@@ -43,12 +44,15 @@ export async function handleConfigCommand(
43
44
  return configShow(opts)
44
45
  case 'set':
45
46
  return configSet(rest, opts)
47
+ case 'unset':
48
+ return configUnset(rest, opts)
46
49
  case 'path':
47
50
  return configPath(opts)
48
51
  default:
49
52
  printCommandHelp('config', {
50
53
  show: 'Display current configuration',
51
54
  set: 'Set a config value (e.g. 1sat config set chain test)',
55
+ unset: 'Remove a configuration key (e.g. 1sat config unset chain)',
52
56
  path: 'Print config directory path',
53
57
  })
54
58
  if (subcommand && subcommand !== 'help') {
@@ -69,8 +73,8 @@ function configShow(opts: GlobalFlags): void {
69
73
  printKeyValue({
70
74
  chain: config.chain,
71
75
  dataDir: config.dataDir,
72
- activeRemote: config.activeRemote ?? '(not set)',
73
76
  storageIdentityKey: config.storageIdentityKey ?? '(not set)',
77
+ monitorIntervalMinutes: config.monitorIntervalMinutes,
74
78
  })
75
79
  console.log()
76
80
  console.log(
@@ -98,11 +102,37 @@ function configSet(args: string[], opts: GlobalFlags): void {
98
102
  if (key === 'chain' && value !== 'main' && value !== 'test') {
99
103
  fatal("chain must be 'main' or 'test'")
100
104
  }
105
+ if (key === 'monitorIntervalMinutes') {
106
+ const n = Number(value)
107
+ if (!Number.isFinite(n) || n < 0) {
108
+ fatal('monitorIntervalMinutes must be a non-negative number (0 to disable)')
109
+ }
110
+ }
101
111
 
102
112
  const updated = updateConfig({ [key]: value })
103
113
  output(opts.json ? updated : formatSuccess(`Set ${key} = ${value}`), opts)
104
114
  }
105
115
 
116
+ function configUnset(args: string[], opts: GlobalFlags): void {
117
+ const [key] = args
118
+
119
+ if (!key) {
120
+ fatal(
121
+ `Usage: 1sat config unset <key>\n\nKeys that can be unset: ${SETTABLE_KEYS.join(', ')}`,
122
+ )
123
+ }
124
+
125
+ if (!SETTABLE_KEYS.includes(key as keyof OneSatCliConfig)) {
126
+ fatal(
127
+ `Unknown config key: ${key}\n\nKeys that can be unset: ${SETTABLE_KEYS.join(', ')}`,
128
+ )
129
+ }
130
+
131
+ // For other keys, use undefined
132
+ const updated = updateConfig({ [key]: undefined })
133
+ output(opts.json ? updated : formatSuccess(`Unset ${key}`), opts)
134
+ }
135
+
106
136
  function configPath(opts: GlobalFlags): void {
107
137
  const dir = getConfigDir()
108
138
  output(opts.json ? { path: dir } : dir, opts)
@@ -173,7 +173,33 @@ export async function handleInitCommand(
173
173
  process.exit(0)
174
174
  }
175
175
 
176
- // 5. Save everything
176
+ // 5. Optional: remote storage configuration
177
+ const useRemote = await confirm({
178
+ message: 'Configure remote storage? (remote is active, local is backup)',
179
+ defaultValue: false,
180
+ })
181
+ let activeRemote: string | undefined
182
+
183
+ if (useRemote) {
184
+ const url = await text({
185
+ message: 'Primary remote storage URL:',
186
+ validate(value) {
187
+ if (!value) return 'Required'
188
+ try {
189
+ new URL(value)
190
+ } catch {
191
+ return 'Invalid URL'
192
+ }
193
+ },
194
+ })
195
+ if (isCancel(url)) {
196
+ cancel('Setup cancelled.')
197
+ process.exit(0)
198
+ }
199
+ activeRemote = url as string
200
+ }
201
+
202
+ // 6. Save everything
177
203
  ensureConfigDir()
178
204
 
179
205
  await saveKey(wif, pw as string)
@@ -195,6 +221,7 @@ export async function handleInitCommand(
195
221
  ...loadConfig(),
196
222
  chain: chain as 'main' | 'test',
197
223
  storageIdentityKey: storageId as string,
224
+ activeRemote,
198
225
  })
199
226
 
200
227
  const pk = PrivateKey.fromWif(wif)
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import {
8
+ getDisplayValue,
8
9
  getOpnsNames,
9
10
  opnsDeregister as opnsDeregisterAction,
10
11
  opnsRegister as opnsRegisterAction,
@@ -149,7 +150,7 @@ async function opnsLookup(_args: string[], opts: GlobalFlags): Promise<void> {
149
150
  }
150
151
 
151
152
  for (const o of result.outputs) {
152
- const nameTag = o.tags?.find((t) => t.startsWith('name:'))?.slice(5) ?? ''
153
+ const nameTag = getDisplayValue(o, 'name', 'name') ?? ''
153
154
  const publishedTag = o.tags?.find((t) => t === 'opns:published')
154
155
  const status = publishedTag ? 'registered' : 'unregistered'
155
156
 
@@ -7,6 +7,7 @@ import { basename, extname } from 'node:path'
7
7
  import {
8
8
  cancelListing,
9
9
  deriveDepositAddresses,
10
+ getDisplayValue,
10
11
  getOrdinals,
11
12
  inscribe,
12
13
  listOrdinal,
@@ -83,7 +84,7 @@ async function ordinalsList(_args: string[], opts: GlobalFlags): Promise<void> {
83
84
  o.tags?.find((t) => t.startsWith('type:'))?.slice(5) ?? 'unknown'
84
85
  const originTag =
85
86
  o.tags?.find((t) => t.startsWith('origin:'))?.slice(7) ?? ''
86
- const nameTag = o.tags?.find((t) => t.startsWith('name:'))?.slice(5) ?? ''
87
+ const nameTag = getDisplayValue(o, 'name', 'name') ?? ''
87
88
 
88
89
  console.log(
89
90
  ` ${formatValue(o.outpoint)} ${formatLabel(typeTag)}${nameTag ? ` ${nameTag}` : ''}${originTag ? ` origin:${originTag}` : ''}`,
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Remote storage management commands.
3
+ *
4
+ * Subcommands:
5
+ * add - Add a remote as backup (validates with immediate sync)
6
+ * list - Show all remotes and their status
7
+ * delete - Remove a remote from the backup list
8
+ * set-active - Switch active storage to a remote or back to local
9
+ */
10
+
11
+ import { confirm, isCancel, text } from '@clack/prompts'
12
+ import type { GlobalFlags } from '../args'
13
+ import { loadConfig, saveConfig } from '../config'
14
+ import { loadContext } from '../context'
15
+ import { printCommandHelp } from '../help'
16
+ import { loadKey, resolvePassword } from '../keys'
17
+ import { fatal, formatSuccess, formatWarning, output } from '../output'
18
+
19
+ export async function handleRemoteCommand(
20
+ args: string[],
21
+ opts: GlobalFlags,
22
+ ): Promise<void> {
23
+ const [subcommand, ...rest] = args
24
+
25
+ switch (subcommand) {
26
+ case 'add':
27
+ return remoteAdd(rest, opts)
28
+ case 'list':
29
+ return remoteList(rest, opts)
30
+ case 'delete':
31
+ return remoteDelete(rest, opts)
32
+ case 'set-active':
33
+ return remoteSetActive(rest, opts)
34
+ default:
35
+ printCommandHelp('remote', {
36
+ add: 'Add a remote storage as backup (1sat remote add <url>)',
37
+ list: 'List all configured remotes and their status',
38
+ delete:
39
+ 'Remove a remote from the backup list (1sat remote delete <url>)',
40
+ 'set-active':
41
+ 'Switch active storage (1sat remote set-active <url | local>)',
42
+ })
43
+ if (subcommand && subcommand !== 'help') {
44
+ process.exit(1)
45
+ }
46
+ }
47
+ }
48
+
49
+ // ============================================================================
50
+ // remote add
51
+ // ============================================================================
52
+
53
+ async function remoteAdd(args: string[], opts: GlobalFlags): Promise<void> {
54
+ let url = args[0]
55
+
56
+ if (!url) {
57
+ url = (await text({
58
+ message: 'Remote storage URL:',
59
+ validate(value) {
60
+ if (!value) return 'Required'
61
+ try {
62
+ new URL(value)
63
+ } catch {
64
+ return 'Invalid URL'
65
+ }
66
+ },
67
+ })) as string
68
+ if (isCancel(url)) {
69
+ fatal('Cancelled')
70
+ }
71
+ } else {
72
+ try {
73
+ new URL(url)
74
+ } catch {
75
+ fatal(`Invalid URL: ${url}`)
76
+ }
77
+ }
78
+
79
+ const privateKey = await loadKey(resolvePassword())
80
+ const { walletResult, destroy } = await loadContext(privateKey, {
81
+ chain: opts.chain,
82
+ })
83
+
84
+ try {
85
+ // For backup-only, we use StorageClient via wallet-node
86
+ // biome-ignore lint/suspicious/noExplicitAny: StorageClient constructor not typed in wallet-toolbox
87
+ const { StorageClient } = await import('@1sat/wallet-node')
88
+ const wallet = walletResult.wallet as any
89
+ const client = new (StorageClient as any)(wallet, url)
90
+ await walletResult.storage.addWalletStorageProvider(client)
91
+
92
+ // When adding a backup, the remote may report itself as "active" which
93
+ // creates a conflicting active state. Re-assert local as active before syncing.
94
+ if (!walletResult.storage.isActiveEnabled) {
95
+ const localKey = config.storageIdentityKey ?? '1sat-cli-default'
96
+ await walletResult.storage.setActive(localKey)
97
+ }
98
+
99
+ await walletResult.storage.updateBackups()
100
+
101
+ // Persist to config — connectivity will be validated on next monitor run
102
+ const config = loadConfig()
103
+ const existing = config.backups ?? []
104
+ if (!existing.includes(url)) {
105
+ saveConfig({ ...config, backups: [...existing, url] })
106
+ }
107
+
108
+ if (opts.json) {
109
+ output({ url, status: 'added' }, opts)
110
+ } else {
111
+ console.log(formatSuccess(` Added ${url} as backup`))
112
+ console.log(
113
+ formatWarning(
114
+ ' Note: Use "1sat remote set-active <url>" to make this remote the primary storage',
115
+ ),
116
+ )
117
+ }
118
+ } finally {
119
+ await destroy()
120
+ }
121
+ }
122
+
123
+ // ============================================================================
124
+ // remote list
125
+ // ============================================================================
126
+
127
+ async function remoteList(_args: string[], opts: GlobalFlags): Promise<void> {
128
+ const privateKey = await loadKey(resolvePassword())
129
+ const { walletResult, destroy } = await loadContext(privateKey, {
130
+ chain: opts.chain,
131
+ })
132
+
133
+ try {
134
+ const backups = walletResult.storage.getBackupStores?.() ?? []
135
+ const config = loadConfig()
136
+
137
+ // Use config for active determination — WalletStorageManager internal state
138
+ // can be misleading (a backup may appear as active after addWalletStorageProvider)
139
+ const isRemoteActive = Boolean(config.activeRemote)
140
+
141
+ if (opts.json) {
142
+ output(
143
+ {
144
+ activeStorage: isRemoteActive ? 'remote' : 'local',
145
+ backups: walletResult.storage.getAllStores?.() ?? [],
146
+ config: {
147
+ activeRemote: config.activeRemote ?? null,
148
+ backups: config.backups ?? [],
149
+ },
150
+ },
151
+ opts,
152
+ )
153
+ return
154
+ }
155
+
156
+ console.log()
157
+ console.log(` ${bold('Active Storage:')} ${isRemoteActive ? 'remote' : 'local'}`)
158
+ if (isRemoteActive) {
159
+ console.log(
160
+ ` ${bold('Active Remote:')} ${config.activeRemote}`,
161
+ )
162
+ }
163
+ console.log()
164
+ if (backups.length === 0 && !config.backups?.length) {
165
+ console.log(' No remote storages configured')
166
+ } else {
167
+ console.log(` ${bold('Backups:')}`)
168
+ const known = new Set(config.backups ?? [])
169
+ for (const b of backups) {
170
+ const isKnown = known.has(b)
171
+ console.log(` ${isKnown ? '●' : '○'} ${b}`)
172
+ }
173
+ // Show configured but not yet connected
174
+ for (const url of config.backups ?? []) {
175
+ if (!backups.includes(url)) {
176
+ console.log(` ? ${url} (not connected)`)
177
+ }
178
+ }
179
+ }
180
+ console.log()
181
+ } finally {
182
+ await destroy()
183
+ }
184
+ }
185
+
186
+ // ============================================================================
187
+ // remote delete
188
+ // ============================================================================
189
+
190
+ async function remoteDelete(args: string[], opts: GlobalFlags): Promise<void> {
191
+ let url = args[0]
192
+
193
+ if (!url) {
194
+ url = (await text({
195
+ message: 'Remote storage URL to remove:',
196
+ validate(value) {
197
+ if (!value) return 'Required'
198
+ },
199
+ })) as string
200
+ if (isCancel(url)) {
201
+ fatal('Cancelled')
202
+ }
203
+ }
204
+
205
+ const config = loadConfig()
206
+ const backups = config.backups ?? []
207
+
208
+ if (!backups.includes(url)) {
209
+ fatal(`Remote not found in config: ${url}`)
210
+ }
211
+
212
+ // Confirm
213
+ const confirmed = await confirm({
214
+ message: `Remove ${url} from backups?`,
215
+ defaultValue: false,
216
+ })
217
+ if (isCancel(confirmed) || !confirmed) {
218
+ fatal('Cancelled')
219
+ }
220
+
221
+ saveConfig({ ...config, backups: backups.filter((u) => u !== url) })
222
+
223
+ if (opts.json) {
224
+ output({ url, status: 'removed' }, opts)
225
+ } else {
226
+ console.log(formatSuccess(` Removed ${url} from backups`))
227
+ }
228
+ }
229
+
230
+ // ============================================================================
231
+ // remote set-active
232
+ // ============================================================================
233
+
234
+ async function remoteSetActive(
235
+ args: string[],
236
+ opts: GlobalFlags,
237
+ ): Promise<void> {
238
+ let target = args[0]
239
+
240
+ if (!target) {
241
+ target = (await text({
242
+ message: 'Set active storage to (url or "local"):',
243
+ validate(value) {
244
+ if (!value) return 'Required'
245
+ },
246
+ })) as string
247
+ if (isCancel(target)) {
248
+ fatal('Cancelled')
249
+ }
250
+ }
251
+
252
+ const config = loadConfig()
253
+
254
+ if (target === 'local') {
255
+ // Switch back to local
256
+ if (!config.activeRemote && !config.backups?.length) {
257
+ fatal('No remote storages configured')
258
+ }
259
+
260
+ const privateKey = await loadKey(resolvePassword())
261
+ const { walletResult, destroy } = await loadContext(privateKey, {
262
+ chain: opts.chain,
263
+ })
264
+
265
+ try {
266
+ // Find the local storage's storageIdentityKey
267
+ const localKey = config.storageIdentityKey ?? '1sat-cli-default'
268
+
269
+ if (opts.json) {
270
+ output({ target: 'local', status: 'migrating' }, opts)
271
+ } else {
272
+ console.log(' Switching active storage to local...')
273
+ }
274
+
275
+ await walletResult.storage.setActive(localKey)
276
+
277
+ // Clear activeRemote from config
278
+ saveConfig({ ...config, activeRemote: undefined })
279
+
280
+ if (opts.json) {
281
+ output(
282
+ { target: 'local', status: 'active', storageIdentityKey: localKey },
283
+ opts,
284
+ )
285
+ } else {
286
+ console.log(formatSuccess(' Local storage is now active'))
287
+ }
288
+ } finally {
289
+ await destroy()
290
+ }
291
+ } else {
292
+ // Switch to a remote
293
+ // Validate URL
294
+ try {
295
+ new URL(target)
296
+ } catch {
297
+ fatal(`Invalid URL: ${target}`)
298
+ }
299
+
300
+ const privateKey = await loadKey(resolvePassword())
301
+ const { walletResult, destroy } = await loadContext(privateKey, {
302
+ chain: opts.chain,
303
+ })
304
+
305
+ try {
306
+ if (opts.json) {
307
+ output({ target, status: 'migrating' }, opts)
308
+ } else {
309
+ console.log(` Switching active storage to ${target}...`)
310
+ }
311
+
312
+ await walletResult.migrateRemote(target)
313
+
314
+ // Persist to config
315
+ saveConfig({ ...config, activeRemote: target })
316
+
317
+ if (opts.json) {
318
+ output({ target, status: 'active' }, opts)
319
+ } else {
320
+ console.log(formatSuccess(` ${target} is now active`))
321
+ }
322
+ } finally {
323
+ await destroy()
324
+ }
325
+ }
326
+ }
327
+
328
+ // ============================================================================
329
+ // Helpers
330
+ // ============================================================================
331
+
332
+ function bold(s: string): string {
333
+ return `\x1b[1m${s}\x1b[0m`
334
+ }
@@ -6,7 +6,7 @@
6
6
 
7
7
  import {
8
8
  prepareSweepInputs,
9
- scanAddressUtxos,
9
+ scanAddress,
10
10
  sweepBsv,
11
11
  sweepBsv21,
12
12
  sweepOrdinals,
@@ -65,7 +65,7 @@ async function sweepScan(args: string[], opts: GlobalFlags): Promise<void> {
65
65
  fatal('Services required for sweep scan')
66
66
  }
67
67
 
68
- const result = await scanAddressUtxos(ctx.services, address)
68
+ const result = await scanAddress(ctx.services, address)
69
69
 
70
70
  if (opts.json) {
71
71
  output(result, opts)
@@ -81,7 +81,7 @@ async function sweepScan(args: string[], opts: GlobalFlags): Promise<void> {
81
81
  )
82
82
  for (const f of result.funding) {
83
83
  console.log(
84
- ` ${formatValue(f.outpoint)} ${formatLabel(`${f.satoshis} sats`)}`,
84
+ ` ${formatValue(f.outpoint)} ${formatLabel(`${f.satoshis ?? 0} sats`)}`,
85
85
  )
86
86
  }
87
87
  }
@@ -101,7 +101,7 @@ async function sweepScan(args: string[], opts: GlobalFlags): Promise<void> {
101
101
  if (result.bsv21Tokens.length > 0) {
102
102
  for (const t of result.bsv21Tokens) {
103
103
  console.log(
104
- ` ${formatValue(t.symbol ?? t.tokenId.slice(0, 12))} ${formatLabel('amount:')} ${formatValue(t.totalAmount)} ${formatLabel('UTXOs:')} ${t.inputs.length} ${formatLabel('dec:')} ${t.decimals}`,
104
+ ` ${formatValue(t.symbol ?? t.tokenId.slice(0, 12))} ${formatLabel('amount:')} ${formatValue(t.totalAmount.toString())} ${formatLabel('UTXOs:')} ${t.outputs.length} ${formatLabel('dec:')} ${t.decimals}`,
105
105
  )
106
106
  }
107
107
  }
@@ -110,13 +110,13 @@ async function sweepScan(args: string[], opts: GlobalFlags): Promise<void> {
110
110
  `\n ${formatLabel('RUN Tokens:')} ${result.run.length}`,
111
111
  )
112
112
  if (result.run.length > 0) {
113
- const runSats = result.run.reduce((sum, r) => sum + r.satoshis, 0)
113
+ const runSats = result.run.reduce((sum, r) => sum + (r.satoshis ?? 0), 0)
114
114
  console.log(
115
115
  ` ${formatLabel('Total locked:')} ${formatValue(runSats)} satoshis (not sweepable)`,
116
116
  )
117
117
  for (const r of result.run) {
118
118
  console.log(
119
- ` ${formatValue(r.outpoint)} ${formatLabel(`${r.satoshis} sats`)}`,
119
+ ` ${formatValue(r.outpoint)} ${formatLabel(`${r.satoshis ?? 0} sats`)}`,
120
120
  )
121
121
  }
122
122
  }
@@ -124,7 +124,7 @@ async function sweepScan(args: string[], opts: GlobalFlags): Promise<void> {
124
124
  const total =
125
125
  result.funding.length +
126
126
  result.ordinals.length +
127
- result.bsv21Tokens.reduce((n, t) => n + t.inputs.length, 0)
127
+ result.bsv21Tokens.reduce((n, t) => n + t.outputs.length, 0)
128
128
  console.log(`\n ${total} sweepable UTXO(s) found.`)
129
129
  if (result.run.length > 0) {
130
130
  console.log(` ${result.run.length} RUN token output(s) excluded.`)
@@ -157,12 +157,11 @@ async function sweepImport(args: string[], opts: GlobalFlags): Promise<void> {
157
157
  fatal('Services required for sweep import')
158
158
  }
159
159
 
160
- // Scan first to discover what's available
161
- const scan = await scanAddressUtxos(ctx.services, address)
160
+ const scan = await scanAddress(ctx.services, address)
162
161
 
163
162
  const hasFunding = scan.funding.length > 0
164
163
  const hasOrdinals = scan.ordinals.length > 0
165
- const hasTokens = scan.bsv21Tokens.length > 0
164
+ const hasTokens = scan.bsv21Tokens.some((t) => t.isActive && t.outputs.length > 0)
166
165
 
167
166
  if (!hasFunding && !hasOrdinals && !hasTokens) {
168
167
  if (scan.run.length > 0) {
@@ -171,16 +170,17 @@ async function sweepImport(args: string[], opts: GlobalFlags): Promise<void> {
171
170
  fatal(`No UTXOs found at ${address}`)
172
171
  }
173
172
 
174
- // Summarize what will be swept
175
173
  const parts: string[] = []
176
174
  if (hasFunding)
177
175
  parts.push(`${scan.totalFundingSats} sats (${scan.funding.length} UTXOs)`)
178
176
  if (hasOrdinals) parts.push(`${scan.ordinals.length} ordinal(s)`)
179
177
  if (hasTokens) {
180
178
  for (const t of scan.bsv21Tokens) {
181
- parts.push(
182
- `${t.totalAmount} ${t.symbol ?? t.tokenId.slice(0, 12)} token(s)`,
183
- )
179
+ if (t.isActive && t.outputs.length > 0) {
180
+ parts.push(
181
+ `${t.totalAmount} ${t.symbol ?? t.tokenId.slice(0, 12)} token(s)`,
182
+ )
183
+ }
184
184
  }
185
185
  }
186
186
  if (scan.run.length > 0) {
@@ -198,18 +198,9 @@ async function sweepImport(args: string[], opts: GlobalFlags): Promise<void> {
198
198
 
199
199
  const txids: string[] = []
200
200
 
201
- // Convert SweepInput to IndexedOutput shape for prepareSweepInputs
202
- const toIndexed = (items: Array<{ outpoint: string; satoshis: number }>) =>
203
- items.map((item) => ({
204
- outpoint: item.outpoint,
205
- satoshis: item.satoshis,
206
- score: 0,
207
- }))
208
-
209
201
  // Sweep BSV funding UTXOs
210
202
  if (hasFunding) {
211
- const inputs = await prepareSweepInputs(ctx, toIndexed(scan.funding))
212
-
203
+ const inputs = await prepareSweepInputs(ctx, scan.funding)
213
204
  const result = await sweepBsv.execute(ctx, { inputs, wif })
214
205
  if (result.error) {
215
206
  fatal(`BSV sweep failed: ${result.error}`)
@@ -219,8 +210,7 @@ async function sweepImport(args: string[], opts: GlobalFlags): Promise<void> {
219
210
 
220
211
  // Sweep ordinals
221
212
  if (hasOrdinals) {
222
- const inputs = await prepareSweepInputs(ctx, toIndexed(scan.ordinals))
223
-
213
+ const inputs = await prepareSweepInputs(ctx, scan.ordinals)
224
214
  const result = await sweepOrdinals.execute(ctx, { inputs, wif })
225
215
  if (result.error) {
226
216
  fatal(`Ordinals sweep failed: ${result.error}`)
@@ -228,27 +218,21 @@ async function sweepImport(args: string[], opts: GlobalFlags): Promise<void> {
228
218
  if (result.txid) txids.push(result.txid)
229
219
  }
230
220
 
231
- // Sweep BSV-21 tokens (one sweep per tokenId)
221
+ // Sweep BSV-21 tokens (one sweep per active tokenId)
232
222
  if (hasTokens) {
233
- for (const tokenGroup of scan.bsv21Tokens) {
234
- const inputs = await prepareSweepInputs(
235
- ctx,
236
- toIndexed(tokenGroup.inputs),
237
- )
223
+ for (const token of scan.bsv21Tokens) {
224
+ if (!token.isActive || token.outputs.length === 0) continue
238
225
 
239
- const sweepInputs = inputs.map((inp, idx) => ({
240
- ...inp,
241
- tokenId: tokenGroup.inputs[idx].tokenId,
242
- amount: tokenGroup.inputs[idx].amount,
226
+ const inputs = token.outputs.map((out) => ({
227
+ outpoint: out.outpoint,
228
+ tokenId: token.tokenId,
229
+ amount: token.amounts.get(out.outpoint) ?? '0',
243
230
  }))
244
231
 
245
- const result = await sweepBsv21.execute(ctx, {
246
- inputs: sweepInputs,
247
- wif,
248
- })
232
+ const result = await sweepBsv21.execute(ctx, { inputs, wif })
249
233
  if (result.error) {
250
234
  fatal(
251
- `Token sweep failed (${tokenGroup.symbol ?? tokenGroup.tokenId.slice(0, 12)}): ${result.error}`,
235
+ `Token sweep failed (${token.symbol ?? token.tokenId.slice(0, 12)}): ${result.error}`,
252
236
  )
253
237
  }
254
238
  if (result.txid) txids.push(result.txid)
@@ -4,6 +4,7 @@
4
4
 
5
5
  import {
6
6
  getBsv21Balances,
7
+ getDisplayValue,
7
8
  listTokens,
8
9
  purchaseBsv21,
9
10
  sendBsv21,
@@ -93,8 +94,8 @@ async function tokenList(args: string[], opts: GlobalFlags): Promise<void> {
93
94
 
94
95
  const filtered = tokenId
95
96
  ? outputs.filter((o) => {
96
- const idTag = o.tags?.find((t) => t.startsWith('id:'))
97
- return idTag && idTag.slice(3) === tokenId
97
+ const idTag = o.tags?.find((t) => t.startsWith('bsv21:'))
98
+ return idTag && idTag.slice(6) === tokenId
98
99
  })
99
100
  : outputs
100
101
 
@@ -115,9 +116,9 @@ async function tokenList(args: string[], opts: GlobalFlags): Promise<void> {
115
116
 
116
117
  for (const o of filtered) {
117
118
  const idTag =
118
- o.tags?.find((t) => t.startsWith('id:'))?.slice(3) ?? 'unknown'
119
+ o.tags?.find((t) => t.startsWith('bsv21:'))?.slice(6) ?? 'unknown'
119
120
  const amtTag = o.tags?.find((t) => t.startsWith('amt:'))?.slice(4) ?? '0'
120
- const symTag = o.tags?.find((t) => t.startsWith('sym:'))?.slice(4) ?? ''
121
+ const symTag = getDisplayValue(o, 'sym', 'sym') ?? ''
121
122
 
122
123
  console.log(
123
124
  ` ${formatValue(o.outpoint)} ${formatLabel(symTag || idTag.slice(0, 12))} ${formatLabel('amt:')} ${formatValue(amtTag)}`,
package/src/config.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Config management for ~/.1sat/
2
+ * Config management for ~/.1sat/cli/
3
3
  *
4
4
  * Handles persistent configuration on disk with secure file permissions.
5
5
  */
@@ -8,7 +8,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
8
8
  import { homedir } from 'node:os'
9
9
  import { join } from 'node:path'
10
10
 
11
- const CONFIG_DIR = join(homedir(), '.1sat')
11
+ const CONFIG_DIR = join(homedir(), '.1sat', 'cli')
12
12
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
13
13
 
14
14
  export interface OneSatCliConfig {
@@ -16,19 +16,48 @@ export interface OneSatCliConfig {
16
16
  chain: 'main' | 'test'
17
17
  /** Data directory for wallet databases */
18
18
  dataDir: string
19
- /** Remote storage URL for wallet sync */
19
+ /** Primary remote storage URL (active or backup) */
20
20
  activeRemote?: string
21
+ /** Backup remote storage URLs */
22
+ backups?: string[]
21
23
  /** Storage identity key for wallet persistence */
22
24
  storageIdentityKey?: string
25
+ /** How often to run the monitor refresh (minutes). 0 disables auto-refresh. */
26
+ monitorIntervalMinutes: number
23
27
  }
24
28
 
25
29
  const DEFAULT_CONFIG: OneSatCliConfig = {
26
30
  chain: 'main',
27
31
  dataDir: join(CONFIG_DIR, 'data'),
32
+ monitorIntervalMinutes: 5,
33
+ }
34
+
35
+ // Monitor state — stored separately so we don't rewrite config.json on every command
36
+ const MONITOR_STATE_FILE = join(CONFIG_DIR, 'monitor-state.json')
37
+
38
+ export interface MonitorState {
39
+ lastMonitorRun: number // unix ms timestamp
40
+ }
41
+
42
+ export function loadMonitorState(): MonitorState {
43
+ if (!existsSync(MONITOR_STATE_FILE)) return { lastMonitorRun: 0 }
44
+ try {
45
+ const raw = readFileSync(MONITOR_STATE_FILE, 'utf8')
46
+ return JSON.parse(raw)
47
+ } catch {
48
+ return { lastMonitorRun: 0 }
49
+ }
50
+ }
51
+
52
+ export function saveMonitorState(state: MonitorState): void {
53
+ ensureConfigDir()
54
+ writeFileSync(MONITOR_STATE_FILE, JSON.stringify(state, null, 2), {
55
+ mode: 0o600,
56
+ })
28
57
  }
29
58
 
30
59
  /**
31
- * Ensure ~/.1sat/ exists with secure permissions.
60
+ * Ensure ~/.1sat/cli/ exists with secure permissions.
32
61
  */
33
62
  export function ensureConfigDir(): void {
34
63
  if (!existsSync(CONFIG_DIR)) {
package/src/context.ts CHANGED
@@ -7,7 +7,12 @@
7
7
  import { type OneSatContext, createContext } from '@1sat/actions'
8
8
  import { type NodeWalletResult, createNodeWallet } from '@1sat/wallet-node'
9
9
  import type { PrivateKey } from '@bsv/sdk'
10
- import { ensureDataDir, loadConfig } from './config'
10
+ import {
11
+ ensureDataDir,
12
+ loadConfig,
13
+ loadMonitorState,
14
+ saveMonitorState,
15
+ } from './config'
11
16
 
12
17
  /** Extended context that includes cleanup */
13
18
  export interface CliContext {
@@ -16,13 +21,35 @@ export interface CliContext {
16
21
  destroy: () => Promise<void>
17
22
  }
18
23
 
24
+ /**
25
+ * Run the monitor once if the interval has elapsed.
26
+ * This is called lazily by commands that need current state.
27
+ */
28
+ async function runMonitorIfStale(
29
+ walletResult: NodeWalletResult,
30
+ intervalMinutes: number,
31
+ ): Promise<void> {
32
+ if (!walletResult.monitor) return // remote active, monitor runs remotely
33
+ if (intervalMinutes === 0) return // disabled by user
34
+
35
+ const state = loadMonitorState()
36
+ const now = Date.now()
37
+ const elapsed = now - state.lastMonitorRun
38
+ const intervalMs = intervalMinutes * 60 * 1000
39
+
40
+ if (elapsed >= intervalMs) {
41
+ await walletResult.monitor.runOnce()
42
+ saveMonitorState({ lastMonitorRun: Date.now() })
43
+ }
44
+ }
45
+
19
46
  /**
20
47
  * Create a fully initialized OneSatContext for CLI use.
21
48
  *
22
49
  * Sets up:
23
50
  * - Node wallet with SQLite storage
24
51
  * - 1Sat services for API access
25
- * - Monitor for transaction lifecycle
52
+ * - Monitor for transaction lifecycle (lazy, interval-based)
26
53
  */
27
54
  export async function loadContext(
28
55
  privateKey: PrivateKey,
@@ -37,17 +64,13 @@ export async function loadContext(
37
64
  privateKey,
38
65
  chain: opts.chain,
39
66
  storageIdentityKey,
40
- storage: {
41
- client: 'better-sqlite3',
42
- connection: {
43
- filename: `${dataDir}/wallet-${opts.chain}.db`,
44
- },
45
- useNullAsDefault: true,
46
- },
67
+ filename: `${dataDir}/wallet-${opts.chain}.db`,
47
68
  activeRemote: config.activeRemote,
69
+ backups: config.backups,
48
70
  })
49
71
 
50
- walletResult.monitor?.startTasks()
72
+ // Run monitor once if interval has elapsed (lazy refresh)
73
+ await runMonitorIfStale(walletResult, config.monitorIntervalMinutes)
51
74
 
52
75
  const ctx = createContext(walletResult.wallet, {
53
76
  services: walletResult.services,
package/src/help.ts CHANGED
@@ -38,6 +38,7 @@ ${bold('Usage:')}
38
38
  ${bold('Setup:')}
39
39
  ${cyan('init')} Interactive wallet setup wizard
40
40
  ${cyan('config')} <subcommand> Manage configuration
41
+ ${cyan('remote')} <subcommand> Manage remote storage
41
42
 
42
43
  ${bold('Wallet:')}
43
44
  ${cyan('wallet balance')} Show wallet balance
@@ -100,7 +101,7 @@ ${bold('Environment Variables:')}
100
101
  ${dim('PRIVATE_KEY_WIF')} Private key (bypasses encrypted keyfile)
101
102
  ${dim('ONESAT_PASSWORD')} Password for encrypted keyfile
102
103
 
103
- ${bold('Config:')} ~/.1sat/
104
+ ${bold('Config:')} ~/.1sat/cli/
104
105
  `)
105
106
  }
106
107