@1sat/cli 0.0.20 → 0.0.22

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.20",
3
+ "version": "0.0.22",
4
4
  "description": "CLI for 1Sat Ordinals SDK",
5
5
  "type": "module",
6
6
  "main": "./src/cli.ts",
package/src/args.ts CHANGED
@@ -82,6 +82,24 @@ export function extractFlag(args: string[], flag: string): string | undefined {
82
82
  return args[idx + 1]
83
83
  }
84
84
 
85
+ /**
86
+ * Extract multiple values for a flag, supporting comma-delimited and multiple flag occurrences.
87
+ * Example: --tags a,b,c OR --tags a --tags b --tags c
88
+ */
89
+ export function extractFlags(args: string[], flag: string): string[] {
90
+ const values: string[] = []
91
+ for (let i = 0; i < args.length - 1; i++) {
92
+ if (args[i] === flag) {
93
+ const value = args[i + 1]
94
+ if (value && !value.startsWith('--')) {
95
+ // Split comma-delimited values
96
+ values.push(...value.split(',').filter(v => v.length > 0))
97
+ }
98
+ }
99
+ }
100
+ return values
101
+ }
102
+
85
103
  /**
86
104
  * Check if a boolean flag is present.
87
105
  */
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)
@@ -8,6 +8,7 @@
8
8
  * 4. Config file creation
9
9
  */
10
10
 
11
+ import { randomBytes } from 'node:crypto'
11
12
  import { PrivateKey } from '@bsv/sdk'
12
13
  import {
13
14
  cancel,
@@ -162,18 +163,36 @@ export async function handleInitCommand(
162
163
  // bitcoin-backup Touch ID not available — skip silently
163
164
  }
164
165
 
165
- // 4. Optional: storage identity key
166
- const storageId = await text({
167
- message: 'Storage identity key (for wallet persistence):',
168
- defaultValue: '1sat-cli-default',
169
- placeholder: '1sat-cli-default',
166
+ // 4. Generate random storage identity key
167
+ const storageId = `1sat-cli-${randomBytes(8).toString('hex')}`
168
+
169
+ // 5. Optional: remote storage configuration
170
+ const useRemote = await confirm({
171
+ message: 'Configure remote storage? (remote is active, local is backup)',
172
+ defaultValue: false,
170
173
  })
171
- if (isCancel(storageId)) {
172
- cancel('Setup cancelled.')
173
- process.exit(0)
174
+ let activeRemote: string | undefined
175
+
176
+ if (useRemote) {
177
+ const url = await text({
178
+ message: 'Primary remote storage URL:',
179
+ validate(value) {
180
+ if (!value) return 'Required'
181
+ try {
182
+ new URL(value)
183
+ } catch {
184
+ return 'Invalid URL'
185
+ }
186
+ },
187
+ })
188
+ if (isCancel(url)) {
189
+ cancel('Setup cancelled.')
190
+ process.exit(0)
191
+ }
192
+ activeRemote = url as string
174
193
  }
175
194
 
176
- // 5. Save everything
195
+ // 6. Save everything
177
196
  ensureConfigDir()
178
197
 
179
198
  await saveKey(wif, pw as string)
@@ -194,7 +213,8 @@ export async function handleInitCommand(
194
213
  saveConfig({
195
214
  ...loadConfig(),
196
215
  chain: chain as 'main' | 'test',
197
- storageIdentityKey: storageId as string,
216
+ storageIdentityKey: storageId,
217
+ activeRemote,
198
218
  })
199
219
 
200
220
  const pk = PrivateKey.fromWif(wif)
@@ -0,0 +1,336 @@
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
+ // Load config early — needed for storage identity key lookups
85
+ const config = loadConfig()
86
+
87
+ try {
88
+ // For backup-only, we use StorageClient via wallet-node
89
+ // biome-ignore lint/suspicious/noExplicitAny: StorageClient constructor not typed in wallet-toolbox
90
+ const { StorageClient } = await import('@1sat/wallet-node')
91
+ const wallet = walletResult.wallet as any
92
+ const client = new (StorageClient as any)(wallet, url)
93
+ await walletResult.storage.addWalletStorageProvider(client)
94
+
95
+ // When adding a backup, the remote may report itself as "active" which
96
+ // creates a conflicting active state. Re-assert local as active before syncing.
97
+ if (!walletResult.storage.isActiveEnabled) {
98
+ const localKey = config.storageIdentityKey ?? '1sat-cli-default'
99
+ await walletResult.storage.setActive(localKey)
100
+ }
101
+
102
+ await walletResult.storage.updateBackups()
103
+
104
+ // Persist to config — connectivity will be validated on next monitor run
105
+ const existing = config.backups ?? []
106
+ if (!existing.includes(url)) {
107
+ saveConfig({ ...config, backups: [...existing, url] })
108
+ }
109
+
110
+ if (opts.json) {
111
+ output({ url, status: 'added' }, opts)
112
+ } else {
113
+ console.log(formatSuccess(` Added ${url} as backup`))
114
+ console.log(
115
+ formatWarning(
116
+ ' Note: Use "1sat remote set-active <url>" to make this remote the primary storage',
117
+ ),
118
+ )
119
+ }
120
+ } finally {
121
+ await destroy()
122
+ }
123
+ }
124
+
125
+ // ============================================================================
126
+ // remote list
127
+ // ============================================================================
128
+
129
+ async function remoteList(_args: string[], opts: GlobalFlags): Promise<void> {
130
+ const privateKey = await loadKey(resolvePassword())
131
+ const { walletResult, destroy } = await loadContext(privateKey, {
132
+ chain: opts.chain,
133
+ })
134
+
135
+ try {
136
+ const backups = walletResult.storage.getBackupStores?.() ?? []
137
+ const config = loadConfig()
138
+
139
+ // Use config for active determination — WalletStorageManager internal state
140
+ // can be misleading (a backup may appear as active after addWalletStorageProvider)
141
+ const isRemoteActive = Boolean(config.activeRemote)
142
+
143
+ if (opts.json) {
144
+ output(
145
+ {
146
+ activeStorage: isRemoteActive ? 'remote' : 'local',
147
+ backups: walletResult.storage.getAllStores?.() ?? [],
148
+ config: {
149
+ activeRemote: config.activeRemote ?? null,
150
+ backups: config.backups ?? [],
151
+ },
152
+ },
153
+ opts,
154
+ )
155
+ return
156
+ }
157
+
158
+ console.log()
159
+ console.log(` ${bold('Active Storage:')} ${isRemoteActive ? 'remote' : 'local'}`)
160
+ if (isRemoteActive) {
161
+ console.log(
162
+ ` ${bold('Active Remote:')} ${config.activeRemote}`,
163
+ )
164
+ }
165
+ console.log()
166
+ if (backups.length === 0 && !config.backups?.length) {
167
+ console.log(' No remote storages configured')
168
+ } else {
169
+ console.log(` ${bold('Backups:')}`)
170
+ const known = new Set(config.backups ?? [])
171
+ for (const b of backups) {
172
+ const isKnown = known.has(b)
173
+ console.log(` ${isKnown ? '●' : '○'} ${b}`)
174
+ }
175
+ // Show configured but not yet connected
176
+ for (const url of config.backups ?? []) {
177
+ if (!backups.includes(url)) {
178
+ console.log(` ? ${url} (not connected)`)
179
+ }
180
+ }
181
+ }
182
+ console.log()
183
+ } finally {
184
+ await destroy()
185
+ }
186
+ }
187
+
188
+ // ============================================================================
189
+ // remote delete
190
+ // ============================================================================
191
+
192
+ async function remoteDelete(args: string[], opts: GlobalFlags): Promise<void> {
193
+ let url = args[0]
194
+
195
+ if (!url) {
196
+ url = (await text({
197
+ message: 'Remote storage URL to remove:',
198
+ validate(value) {
199
+ if (!value) return 'Required'
200
+ },
201
+ })) as string
202
+ if (isCancel(url)) {
203
+ fatal('Cancelled')
204
+ }
205
+ }
206
+
207
+ const config = loadConfig()
208
+ const backups = config.backups ?? []
209
+
210
+ if (!backups.includes(url)) {
211
+ fatal(`Remote not found in config: ${url}`)
212
+ }
213
+
214
+ // Confirm
215
+ const confirmed = await confirm({
216
+ message: `Remove ${url} from backups?`,
217
+ defaultValue: false,
218
+ })
219
+ if (isCancel(confirmed) || !confirmed) {
220
+ fatal('Cancelled')
221
+ }
222
+
223
+ saveConfig({ ...config, backups: backups.filter((u) => u !== url) })
224
+
225
+ if (opts.json) {
226
+ output({ url, status: 'removed' }, opts)
227
+ } else {
228
+ console.log(formatSuccess(` Removed ${url} from backups`))
229
+ }
230
+ }
231
+
232
+ // ============================================================================
233
+ // remote set-active
234
+ // ============================================================================
235
+
236
+ async function remoteSetActive(
237
+ args: string[],
238
+ opts: GlobalFlags,
239
+ ): Promise<void> {
240
+ let target = args[0]
241
+
242
+ if (!target) {
243
+ target = (await text({
244
+ message: 'Set active storage to (url or "local"):',
245
+ validate(value) {
246
+ if (!value) return 'Required'
247
+ },
248
+ })) as string
249
+ if (isCancel(target)) {
250
+ fatal('Cancelled')
251
+ }
252
+ }
253
+
254
+ const config = loadConfig()
255
+
256
+ if (target === 'local') {
257
+ // Switch back to local
258
+ if (!config.activeRemote && !config.backups?.length) {
259
+ fatal('No remote storages configured')
260
+ }
261
+
262
+ const privateKey = await loadKey(resolvePassword())
263
+ const { walletResult, destroy } = await loadContext(privateKey, {
264
+ chain: opts.chain,
265
+ })
266
+
267
+ try {
268
+ // Find the local storage's storageIdentityKey
269
+ const localKey = config.storageIdentityKey ?? '1sat-cli-default'
270
+
271
+ if (opts.json) {
272
+ output({ target: 'local', status: 'migrating' }, opts)
273
+ } else {
274
+ console.log(' Switching active storage to local...')
275
+ }
276
+
277
+ await walletResult.storage.setActive(localKey)
278
+
279
+ // Clear activeRemote from config
280
+ saveConfig({ ...config, activeRemote: undefined })
281
+
282
+ if (opts.json) {
283
+ output(
284
+ { target: 'local', status: 'active', storageIdentityKey: localKey },
285
+ opts,
286
+ )
287
+ } else {
288
+ console.log(formatSuccess(' Local storage is now active'))
289
+ }
290
+ } finally {
291
+ await destroy()
292
+ }
293
+ } else {
294
+ // Switch to a remote
295
+ // Validate URL
296
+ try {
297
+ new URL(target)
298
+ } catch {
299
+ fatal(`Invalid URL: ${target}`)
300
+ }
301
+
302
+ const privateKey = await loadKey(resolvePassword())
303
+ const { walletResult, destroy } = await loadContext(privateKey, {
304
+ chain: opts.chain,
305
+ })
306
+
307
+ try {
308
+ if (opts.json) {
309
+ output({ target, status: 'migrating' }, opts)
310
+ } else {
311
+ console.log(` Switching active storage to ${target}...`)
312
+ }
313
+
314
+ await walletResult.migrateRemote(target)
315
+
316
+ // Persist to config
317
+ saveConfig({ ...config, activeRemote: target })
318
+
319
+ if (opts.json) {
320
+ output({ target, status: 'active' }, opts)
321
+ } else {
322
+ console.log(formatSuccess(` ${target} is now active`))
323
+ }
324
+ } finally {
325
+ await destroy()
326
+ }
327
+ }
328
+ }
329
+
330
+ // ============================================================================
331
+ // Helpers
332
+ // ============================================================================
333
+
334
+ function bold(s: string): string {
335
+ return `\x1b[1m${s}\x1b[0m`
336
+ }
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Wallet commands - balance, address, send, send-all, info.
3
+ * BRC-100 interface commands - list-outputs, relinquish-output, list-actions, etc.
3
4
  */
4
5
 
5
6
  import { deriveDepositAddresses, sendAllBsv, sendBsv } from '@1sat/actions'
6
7
  import { confirm, isCancel } from '@clack/prompts'
7
8
  import type { GlobalFlags } from '../args'
8
- import { extractFlag } from '../args'
9
+ import { extractFlag, extractFlags } from '../args'
9
10
  import { loadContext } from '../context'
10
11
  import { printCommandHelp } from '../help'
11
12
  import { loadKey, resolvePassword } from '../keys'
@@ -28,6 +29,22 @@ export async function handleWalletCommand(
28
29
  return walletSendAll(rest, opts)
29
30
  case 'info':
30
31
  return walletInfo(rest, opts)
32
+ case 'list-outputs':
33
+ return walletListOutputs(rest, opts)
34
+ case 'relinquish-output':
35
+ return walletRelinquishOutput(rest, opts)
36
+ case 'list-actions':
37
+ return walletListActions(rest, opts)
38
+ case 'create-action':
39
+ return walletCreateAction(rest, opts)
40
+ case 'sign-action':
41
+ return walletSignAction(rest, opts)
42
+ case 'abort-action':
43
+ return walletAbortAction(rest, opts)
44
+ case 'list-certificates':
45
+ return walletListCertificates(rest, opts)
46
+ case 'relinquish-certificate':
47
+ return walletRelinquishCertificate(rest, opts)
31
48
  default:
32
49
  printCommandHelp('wallet', {
33
50
  balance: 'Show wallet balance in satoshis',
@@ -35,6 +52,14 @@ export async function handleWalletCommand(
35
52
  send: 'Send BSV to an address (--to <addr> --sats <amount>)',
36
53
  'send-all': 'Send all BSV to an address (--to <addr>)',
37
54
  info: 'Show wallet info (address, balance, network)',
55
+ 'list-outputs': 'List wallet outputs (--basket <name> [--tags <t1,t2>] [--limit N])',
56
+ 'relinquish-output': 'Remove output from basket (--basket <name> --output <txid.vout>)',
57
+ 'list-actions': 'List wallet actions [--labels <l1,l2>] [--limit N]',
58
+ 'create-action': 'Create action (JSON args)',
59
+ 'sign-action': 'Sign action (JSON args)',
60
+ 'abort-action': 'Abort action (--reference <ref>)',
61
+ 'list-certificates': 'List certificates',
62
+ 'relinquish-certificate': 'Relinquish certificate (--type <t> --serialNumber <s> --certifier <c>)',
38
63
  })
39
64
  if (subcommand && subcommand !== 'help') {
40
65
  process.exit(1)
@@ -216,3 +241,258 @@ async function walletInfo(_args: string[], opts: GlobalFlags): Promise<void> {
216
241
  await destroy()
217
242
  }
218
243
  }
244
+
245
+ // BRC-100 Interface Commands
246
+
247
+ async function walletListOutputs(args: string[], opts: GlobalFlags): Promise<void> {
248
+ const basket = extractFlag(args, '--basket')
249
+ if (!basket) fatal('Missing required --basket <name>')
250
+
251
+ const tags = extractFlags(args, '--tags')
252
+ const limitStr = extractFlag(args, '--limit')
253
+ const limit = limitStr ? parseInt(limitStr, 10) : 10
254
+ if (!Number.isFinite(limit) || limit < 1 || limit > 10000) {
255
+ fatal('--limit must be between 1 and 10000')
256
+ }
257
+
258
+ const privateKey = await loadKey(resolvePassword())
259
+ const { ctx, destroy } = await loadContext(privateKey, {
260
+ chain: opts.chain,
261
+ })
262
+
263
+ try {
264
+ const listArgs: Parameters<typeof ctx.wallet.listOutputs>[0] = {
265
+ basket,
266
+ limit,
267
+ }
268
+ if (tags.length > 0) {
269
+ listArgs.tags = tags
270
+ }
271
+
272
+ const result = await ctx.wallet.listOutputs(listArgs)
273
+
274
+ if (opts.json) {
275
+ output(result, opts)
276
+ } else {
277
+ console.log(`\n${result.totalOutputs} total outputs in basket '${basket}':\n`)
278
+ for (const out of result.outputs) {
279
+ const tags = out.tags?.join(', ') || 'none'
280
+ console.log(` ${out.outpoint} | ${out.satoshis} sats | tags: ${tags}`)
281
+ }
282
+ console.log()
283
+ }
284
+ } finally {
285
+ await destroy()
286
+ }
287
+ }
288
+
289
+ async function walletRelinquishOutput(
290
+ args: string[],
291
+ opts: GlobalFlags,
292
+ ): Promise<void> {
293
+ const basket = extractFlag(args, '--basket')
294
+ const outpoint = extractFlag(args, '--output')
295
+
296
+ if (!basket) fatal('Missing required --basket <name>')
297
+ if (!outpoint) fatal('Missing required --output <txid.vout>')
298
+
299
+ // Validate output format (txid.vout)
300
+ if (!outpoint.includes('.') || outpoint.split('.').length !== 2) {
301
+ fatal('Invalid --output format. Expected: txid.vout (e.g., abc123...0)')
302
+ }
303
+
304
+ const privateKey = await loadKey(resolvePassword())
305
+ const { ctx, destroy } = await loadContext(privateKey, {
306
+ chain: opts.chain,
307
+ })
308
+
309
+ try {
310
+ const result = await ctx.wallet.relinquishOutput({ basket, output: outpoint })
311
+
312
+ if (opts.json) {
313
+ output(result, opts)
314
+ } else {
315
+ output({ relinquished: true, basket, output: outpoint }, opts)
316
+ }
317
+ } finally {
318
+ await destroy()
319
+ }
320
+ }
321
+
322
+ async function walletListActions(args: string[], opts: GlobalFlags): Promise<void> {
323
+ const labels = extractFlags(args, '--labels')
324
+ const limitStr = extractFlag(args, '--limit')
325
+ const limit = limitStr ? parseInt(limitStr, 10) : 10
326
+ if (!Number.isFinite(limit) || limit < 1 || limit > 10000) {
327
+ fatal('--limit must be between 1 and 10000')
328
+ }
329
+
330
+ const privateKey = await loadKey(resolvePassword())
331
+ const { ctx, destroy } = await loadContext(privateKey, {
332
+ chain: opts.chain,
333
+ })
334
+
335
+ try {
336
+ const listArgs: Parameters<typeof ctx.wallet.listActions>[0] = {
337
+ labels: labels.length > 0 ? labels : ['*'], // Default to all labels if none specified
338
+ limit,
339
+ }
340
+
341
+ const result = await ctx.wallet.listActions(listArgs)
342
+
343
+ if (opts.json) {
344
+ output(result, opts)
345
+ } else {
346
+ console.log(`\n${result.totalActions} total actions:\n`)
347
+ for (const action of result.actions) {
348
+ console.log(` ${action.txid || 'pending'} | status: ${action.status}`)
349
+ }
350
+ console.log()
351
+ }
352
+ } finally {
353
+ await destroy()
354
+ }
355
+ }
356
+
357
+ async function walletCreateAction(args: string[], opts: GlobalFlags): Promise<void> {
358
+ const jsonInput = args[0]
359
+
360
+ if (!jsonInput) fatal('Missing JSON arguments. Usage: wallet create-action \'{...}\'')
361
+
362
+ let actionArgs: Parameters<typeof ctx.wallet.createAction>[0]
363
+ try {
364
+ actionArgs = JSON.parse(jsonInput)
365
+ } catch {
366
+ fatal(`Invalid JSON: ${jsonInput}`)
367
+ }
368
+
369
+ const privateKey = await loadKey(resolvePassword())
370
+ const { ctx, destroy } = await loadContext(privateKey, {
371
+ chain: opts.chain,
372
+ })
373
+
374
+ try {
375
+ const result = await ctx.wallet.createAction(actionArgs)
376
+ output(result, opts)
377
+ } finally {
378
+ await destroy()
379
+ }
380
+ }
381
+
382
+ async function walletSignAction(args: string[], opts: GlobalFlags): Promise<void> {
383
+ const jsonInput = args[0]
384
+
385
+ if (!jsonInput) fatal('Missing JSON arguments. Usage: wallet sign-action \'{...}\'')
386
+
387
+ let signArgs: Parameters<typeof ctx.wallet.signAction>[0]
388
+ try {
389
+ signArgs = JSON.parse(jsonInput)
390
+ } catch {
391
+ fatal(`Invalid JSON: ${jsonInput}`)
392
+ }
393
+
394
+ const privateKey = await loadKey(resolvePassword())
395
+ const { ctx, destroy } = await loadContext(privateKey, {
396
+ chain: opts.chain,
397
+ })
398
+
399
+ try {
400
+ const result = await ctx.wallet.signAction(signArgs)
401
+ output(result, opts)
402
+ } finally {
403
+ await destroy()
404
+ }
405
+ }
406
+
407
+ async function walletAbortAction(args: string[], opts: GlobalFlags): Promise<void> {
408
+ const reference = extractFlag(args, '--reference')
409
+
410
+ if (!reference) fatal('Missing required --reference <ref>')
411
+
412
+ const privateKey = await loadKey(resolvePassword())
413
+ const { ctx, destroy } = await loadContext(privateKey, {
414
+ chain: opts.chain,
415
+ })
416
+
417
+ try {
418
+ const result = await ctx.wallet.abortAction({ reference })
419
+ output(result, opts)
420
+ } finally {
421
+ await destroy()
422
+ }
423
+ }
424
+
425
+ async function walletListCertificates(
426
+ args: string[],
427
+ opts: GlobalFlags,
428
+ ): Promise<void> {
429
+ const certifiers = extractFlags(args, '--certifiers')
430
+ const types = extractFlags(args, '--types')
431
+ const limitStr = extractFlag(args, '--limit')
432
+ const limit = limitStr ? parseInt(limitStr, 10) : 10
433
+
434
+ if (!Number.isFinite(limit) || limit < 1 || limit > 10000) {
435
+ fatal('--limit must be between 1 and 10000')
436
+ }
437
+
438
+ const privateKey = await loadKey(resolvePassword())
439
+ const { ctx, destroy } = await loadContext(privateKey, {
440
+ chain: opts.chain,
441
+ })
442
+
443
+ try {
444
+ const listArgs: Parameters<typeof ctx.wallet.listCertificates>[0] = {
445
+ certifiers: certifiers.length > 0 ? certifiers : [],
446
+ types: types.length > 0 ? types : [],
447
+ limit,
448
+ }
449
+
450
+ const result = await ctx.wallet.listCertificates(listArgs)
451
+
452
+ if (opts.json) {
453
+ output(result, opts)
454
+ } else {
455
+ console.log(`\n${result.totalCertificates} total certificates:\n`)
456
+ for (const cert of result.certificates) {
457
+ console.log(` ${cert.type} | ${cert.serialNumber} | ${cert.certifier}`)
458
+ }
459
+ console.log()
460
+ }
461
+ } finally {
462
+ await destroy()
463
+ }
464
+ }
465
+
466
+ async function walletRelinquishCertificate(
467
+ args: string[],
468
+ opts: GlobalFlags,
469
+ ): Promise<void> {
470
+ const type = extractFlag(args, '--type')
471
+ const serialNumber = extractFlag(args, '--serialNumber')
472
+ const certifier = extractFlag(args, '--certifier')
473
+
474
+ if (!type) fatal('Missing required --type <type>')
475
+ if (!serialNumber) fatal('Missing required --serialNumber <serial>')
476
+ if (!certifier) fatal('Missing required --certifier <certifier>')
477
+
478
+ const privateKey = await loadKey(resolvePassword())
479
+ const { ctx, destroy } = await loadContext(privateKey, {
480
+ chain: opts.chain,
481
+ })
482
+
483
+ try {
484
+ const result = await ctx.wallet.relinquishCertificate({
485
+ type,
486
+ serialNumber,
487
+ certifier,
488
+ })
489
+
490
+ if (opts.json) {
491
+ output(result, opts)
492
+ } else {
493
+ output({ relinquished: true, type, serialNumber, certifier }, opts)
494
+ }
495
+ } finally {
496
+ await destroy()
497
+ }
498
+ }
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
@@ -46,6 +47,22 @@ ${bold('Wallet:')}
46
47
  ${cyan('wallet send-all')} Send all BSV to an address
47
48
  ${cyan('wallet info')} Show wallet info
48
49
 
50
+ ${bold('Wallet (BRC-100 Interface):')}
51
+ ${cyan('wallet list-outputs')} List outputs in basket
52
+ ${dim('--basket <name> [--tags <t1,t2>] [--limit N]')}
53
+ ${cyan('wallet relinquish-output')} Remove output from basket
54
+ ${dim('--basket <name> --output <txid.vout>')}
55
+ ${cyan('wallet list-actions')} List wallet actions
56
+ ${dim('[--labels <l1,l2>] [--limit N]')}
57
+ ${cyan('wallet create-action')} Create raw action (JSON args)
58
+ ${cyan('wallet sign-action')} Sign raw action (JSON args)
59
+ ${cyan('wallet abort-action')} Abort pending action
60
+ ${dim('--reference <ref>')}
61
+ ${cyan('wallet list-certificates')} List certificates
62
+ ${dim('[--certifiers <c1,c2>] [--types <t1,t2>] [--limit N]')}
63
+ ${cyan('wallet relinquish-certificate')} Relinquish certificate
64
+ ${dim('--type <t> --serialNumber <s> --certifier <c>')}
65
+
49
66
  ${bold('Ordinals:')}
50
67
  ${cyan('ordinals list')} List owned ordinals
51
68
  ${cyan('ordinals mint')} Mint a new ordinal inscription
@@ -100,7 +117,7 @@ ${bold('Environment Variables:')}
100
117
  ${dim('PRIVATE_KEY_WIF')} Private key (bypasses encrypted keyfile)
101
118
  ${dim('ONESAT_PASSWORD')} Password for encrypted keyfile
102
119
 
103
- ${bold('Config:')} ~/.1sat/
120
+ ${bold('Config:')} ~/.1sat/cli/
104
121
  `)
105
122
  }
106
123