@1sat/cli 0.0.20 → 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.20",
3
+ "version": "0.0.21",
4
4
  "description": "CLI for 1Sat Ordinals SDK",
5
5
  "type": "module",
6
6
  "main": "./src/cli.ts",
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)
@@ -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
+ }
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