@1sat/cli 0.0.30 → 0.0.32

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.30",
3
+ "version": "0.0.32",
4
4
  "description": "CLI for 1Sat Ordinals SDK",
5
5
  "type": "module",
6
6
  "main": "./src/cli.ts",
@@ -16,16 +16,20 @@
16
16
  "keywords": ["1sat", "bsv", "ordinals", "cli"],
17
17
  "license": "MIT",
18
18
  "dependencies": {
19
- "@1sat/actions": "0.0.98",
19
+ "@1sat/actions": "0.0.99",
20
20
  "@1sat/client": "0.0.21",
21
21
  "@1sat/types": "0.0.17",
22
- "@1sat/wallet-node": "0.0.28",
22
+ "@1sat/wallet-node": "0.0.29",
23
+ "@1sat/wallet-server": "0.0.1",
23
24
  "@bsv/sdk": "^2.0.13",
25
+ "@bsv/wallet-toolbox": "^2.1.21",
24
26
  "chalk": "^5.0.0",
25
27
  "@clack/prompts": "^0.8.0",
26
28
  "bitcoin-backup": "^0.0.11",
27
29
  "dotenv": "^17.0.0",
28
- "evlog": "^2.10.0"
30
+ "evlog": "^2.10.0",
31
+ "knex": "^3.1.0",
32
+ "pg": "^8.11.3"
29
33
  },
30
34
  "devDependencies": {
31
35
  "@types/bun": "^1.3.9",
package/src/cli.ts CHANGED
@@ -16,6 +16,7 @@ import { handleMcpProxyCommand } from './commands/mcp-proxy'
16
16
  import { handleOpnsCommand } from './commands/opns'
17
17
  import { handleOrdinalsCommand } from './commands/ordinals'
18
18
  import { handleRemoteCommand } from './commands/remote'
19
+ import { handleServeCommand } from './commands/serve'
19
20
  import { handleSocialCommand } from './commands/social'
20
21
  import { handleSweepCommand } from './commands/sweep'
21
22
  import { handleTokensCommand } from './commands/tokens'
@@ -98,6 +99,10 @@ async function main(): Promise<void> {
98
99
  await handleMcpProxyCommand()
99
100
  break
100
101
 
102
+ case 'serve':
103
+ await handleServeCommand(rest, flags)
104
+ break
105
+
101
106
  case 'help':
102
107
  printHelp()
103
108
  break
@@ -10,11 +10,12 @@
10
10
 
11
11
  import type { GlobalFlags } from '../args'
12
12
  import {
13
- type OneSatCliConfig,
14
13
  getConfigDir,
15
14
  getConfigFile,
16
15
  loadConfig,
17
- updateConfig,
16
+ parseConfigValue,
17
+ setConfigPath,
18
+ unsetConfigPath,
18
19
  } from '../config'
19
20
  import { printCommandHelp } from '../help'
20
21
  import {
@@ -23,16 +24,8 @@ import {
23
24
  formatSuccess,
24
25
  formatValue,
25
26
  output,
26
- printKeyValue,
27
27
  } from '../output'
28
28
 
29
- const SETTABLE_KEYS: Array<keyof OneSatCliConfig> = [
30
- 'chain',
31
- 'dataDir',
32
- 'storageIdentityKey',
33
- 'monitorIntervalMinutes',
34
- ]
35
-
36
29
  export async function handleConfigCommand(
37
30
  args: string[],
38
31
  opts: GlobalFlags,
@@ -70,12 +63,7 @@ function configShow(opts: GlobalFlags): void {
70
63
  }
71
64
 
72
65
  console.log()
73
- printKeyValue({
74
- chain: config.chain,
75
- dataDir: config.dataDir,
76
- storageIdentityKey: config.storageIdentityKey ?? '(not set)',
77
- monitorIntervalMinutes: config.monitorIntervalMinutes,
78
- })
66
+ printNested(config, '')
79
67
  console.log()
80
68
  console.log(
81
69
  ` ${formatLabel('config file:')} ${formatValue(getConfigFile())}`,
@@ -83,56 +71,89 @@ function configShow(opts: GlobalFlags): void {
83
71
  console.log()
84
72
  }
85
73
 
86
- function configSet(args: string[], opts: GlobalFlags): void {
87
- const [key, value] = args
88
-
89
- if (!key || !value) {
90
- fatal(
91
- `Usage: 1sat config set <key> <value>\n\nSettable keys: ${SETTABLE_KEYS.join(', ')}`,
74
+ function printNested(value: unknown, prefix: string): void {
75
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) {
76
+ console.log(
77
+ ` ${formatLabel(`${prefix || '(value)'}:`)} ${formatValue(String(value))}`,
92
78
  )
79
+ return
93
80
  }
94
-
95
- if (!SETTABLE_KEYS.includes(key as keyof OneSatCliConfig)) {
96
- fatal(
97
- `Unknown config key: ${key}\n\nSettable keys: ${SETTABLE_KEYS.join(', ')}`,
98
- )
81
+ const obj = value as Record<string, unknown>
82
+ for (const [k, v] of Object.entries(obj)) {
83
+ const path = prefix ? `${prefix}.${k}` : k
84
+ if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
85
+ printNested(v, path)
86
+ } else {
87
+ const display = Array.isArray(v)
88
+ ? JSON.stringify(v)
89
+ : String(v ?? '(not set)')
90
+ console.log(` ${formatLabel(`${path}:`)} ${formatValue(display)}`)
91
+ }
99
92
  }
93
+ }
100
94
 
101
- // Validate specific keys
102
- if (key === 'chain' && value !== 'main' && value !== 'test') {
103
- fatal("chain must be 'main' or 'test'")
104
- }
105
- if (key === 'monitorIntervalMinutes') {
106
- const n = Number(value)
107
- if (!Number.isFinite(n) || n < 0) {
108
- fatal(
109
- 'monitorIntervalMinutes must be a non-negative number (0 to disable)',
110
- )
111
- }
95
+ function configSet(args: string[], opts: GlobalFlags): void {
96
+ const [key, ...valueArgs] = args
97
+ const value = valueArgs.join(' ')
98
+
99
+ if (!key || valueArgs.length === 0) {
100
+ fatal('Usage: 1sat config set <dotted.path> <value>')
112
101
  }
113
102
 
114
- const updated = updateConfig({ [key]: value })
115
- output(opts.json ? updated : formatSuccess(`Set ${key} = ${value}`), opts)
103
+ const parsed = parseConfigValue(value)
104
+ validateKnownPath(key, parsed)
105
+
106
+ setConfigPath(key, parsed)
107
+ output(
108
+ opts.json ? { [key]: parsed } : formatSuccess(`Set ${key} = ${value}`),
109
+ opts,
110
+ )
116
111
  }
117
112
 
118
113
  function configUnset(args: string[], opts: GlobalFlags): void {
119
114
  const [key] = args
120
115
 
121
116
  if (!key) {
122
- fatal(
123
- `Usage: 1sat config unset <key>\n\nKeys that can be unset: ${SETTABLE_KEYS.join(', ')}`,
124
- )
117
+ fatal('Usage: 1sat config unset <dotted.path>')
125
118
  }
126
119
 
127
- if (!SETTABLE_KEYS.includes(key as keyof OneSatCliConfig)) {
128
- fatal(
129
- `Unknown config key: ${key}\n\nKeys that can be unset: ${SETTABLE_KEYS.join(', ')}`,
130
- )
131
- }
120
+ unsetConfigPath(key)
121
+ output(opts.json ? { [key]: undefined } : formatSuccess(`Unset ${key}`), opts)
122
+ }
132
123
 
133
- // For other keys, use undefined
134
- const updated = updateConfig({ [key]: undefined })
135
- output(opts.json ? updated : formatSuccess(`Unset ${key}`), opts)
124
+ /**
125
+ * Enforce the few semantic constraints we care about. Unknown paths are
126
+ * allowed callers that read them can validate at consumption time.
127
+ */
128
+ function validateKnownPath(key: string, value: unknown): void {
129
+ switch (key) {
130
+ case 'chain':
131
+ if (value !== 'main' && value !== 'test') {
132
+ fatal("chain must be 'main' or 'test'")
133
+ }
134
+ break
135
+ case 'server.storage.provider':
136
+ if (
137
+ value !== 'bun-sqlite' &&
138
+ value !== 'knex-sqlite' &&
139
+ value !== 'knex-pg'
140
+ ) {
141
+ fatal(
142
+ "server.storage.provider must be 'bun-sqlite' | 'knex-sqlite' | 'knex-pg'",
143
+ )
144
+ }
145
+ break
146
+ case 'server.port':
147
+ if (typeof value !== 'number' || value <= 0 || !Number.isInteger(value)) {
148
+ fatal('server.port must be a positive integer')
149
+ }
150
+ break
151
+ case 'server.accounts.enabled':
152
+ if (typeof value !== 'boolean') {
153
+ fatal('server.accounts.enabled must be true or false')
154
+ }
155
+ break
156
+ }
136
157
  }
137
158
 
138
159
  function configPath(opts: GlobalFlags): void {
@@ -0,0 +1,297 @@
1
+ /**
2
+ * `1sat serve` command — launch wallet server and/or monitor.
3
+ *
4
+ * 1sat serve Wallet server + monitor daemon
5
+ * 1sat serve wallet Wallet server only
6
+ * 1sat serve monitor Monitor daemon only
7
+ *
8
+ * The server wraps the same wallet instance the CLI uses. Storage, active
9
+ * remote, and backups all come from `~/.1sat/cli/config.json` via the same
10
+ * `createNodeWallet` factory `1sat wallet` commands use.
11
+ *
12
+ * Server-only settings live under `server.*` in the config:
13
+ * 1sat config set server.port 8100
14
+ * 1sat config set server.host 0.0.0.0
15
+ * 1sat config set server.accounts.enabled true
16
+ */
17
+
18
+ import { join } from 'node:path'
19
+ import { OneSatServices } from '@1sat/client'
20
+ import { type NodeWalletResult, createNodeWallet } from '@1sat/wallet-node'
21
+ import {
22
+ createWalletServer,
23
+ runMigrations as runAccountsMigrations,
24
+ } from '@1sat/wallet-server'
25
+ import type { PrivateKey } from '@bsv/sdk'
26
+ import { type Knex, knex } from 'knex'
27
+ import type { GlobalFlags } from '../args'
28
+ import {
29
+ type ServerAccountsConfig,
30
+ type ServerStorageConfig,
31
+ loadConfig,
32
+ } from '../config'
33
+ import { ensureDataDir } from '../config'
34
+ import { printCommandHelp } from '../help'
35
+ import { loadKey, resolvePassword } from '../keys'
36
+ import { fatal } from '../output'
37
+
38
+ const DEFAULT_HOST = '127.0.0.1'
39
+ const DEFAULT_PORT = 8100
40
+ const DEFAULT_ONESAT_URL = 'https://api.1sat.app/1sat'
41
+ const DEFAULT_BASELINE_BYTES = 1024 * 1024 * 1024 // 1 GB
42
+ const DEFAULT_SATS_PER_GB = 1_000_000
43
+ const DEFAULT_DURATION_BLOCKS = 4383
44
+ const DEFAULT_STORAGE_IDENTITY_KEY = '1sat-cli-default'
45
+
46
+ type ServeMode = 'all' | 'wallet' | 'monitor'
47
+
48
+ interface ResolvedServe {
49
+ chain: 'main' | 'test'
50
+ host: string
51
+ port: number
52
+ onesatURL: string
53
+ storage: ServerStorageConfig
54
+ dataDir: string
55
+ sqliteFilename: string
56
+ storageIdentityKey: string
57
+ activeRemote?: string
58
+ backups?: string[]
59
+ accounts: ResolvedAccounts
60
+ privateKey: PrivateKey
61
+ }
62
+
63
+ interface ResolvedAccounts {
64
+ enabled: boolean
65
+ baselineBytes: number
66
+ satsPerGb: number
67
+ durationBlocks: number
68
+ freeIdentityKeys: string[]
69
+ }
70
+
71
+ export async function handleServeCommand(
72
+ args: string[],
73
+ opts: GlobalFlags,
74
+ ): Promise<void> {
75
+ const [subcommand] = args
76
+ const mode = resolveMode(subcommand)
77
+
78
+ if (mode === null) {
79
+ printCommandHelp('serve', {
80
+ '(no subcommand)': 'Wallet server plus monitor daemon',
81
+ wallet: 'Wallet server only',
82
+ monitor: 'Monitor daemon only',
83
+ })
84
+ if (subcommand && subcommand !== 'help') process.exit(1)
85
+ return
86
+ }
87
+
88
+ const resolved = await resolveServe(opts)
89
+ const handles: Stoppable[] = []
90
+
91
+ try {
92
+ if (resolved.storage.provider === 'bun-sqlite') {
93
+ handles.push(await runBunSqlite(resolved, mode))
94
+ } else {
95
+ fatal(
96
+ `server.storage.provider '${resolved.storage.provider}' is not yet wired through the shared wallet factory. Only bun-sqlite is supported at the moment.`,
97
+ )
98
+ }
99
+
100
+ await waitForShutdown()
101
+ } finally {
102
+ for (const h of handles.reverse()) {
103
+ try {
104
+ await h.stop()
105
+ } catch (err) {
106
+ console.error(`Error during shutdown: ${(err as Error).message}`)
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ function resolveMode(subcommand: string | undefined): ServeMode | null {
113
+ if (!subcommand) return 'all'
114
+ switch (subcommand) {
115
+ case 'wallet':
116
+ case 'monitor':
117
+ return subcommand
118
+ default:
119
+ return null
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Load the CLI config, apply serve defaults, and resolve the server identity
125
+ * key via the existing keyring mechanism.
126
+ */
127
+ async function resolveServe(opts: GlobalFlags): Promise<ResolvedServe> {
128
+ const config = loadConfig()
129
+ const server = config.server ?? {}
130
+
131
+ const storage: ServerStorageConfig = server.storage ?? {
132
+ provider: 'bun-sqlite',
133
+ }
134
+ if (storage.provider === 'knex-pg' && !storage.dbUrl) {
135
+ fatal(
136
+ 'server.storage.provider is knex-pg but server.storage.dbUrl is not set. ' +
137
+ 'Set it with: 1sat config set server.storage.dbUrl postgres://…',
138
+ )
139
+ }
140
+
141
+ const dataDir = ensureDataDir()
142
+ const chain = opts.chain ?? config.chain ?? 'main'
143
+
144
+ let privateKey: PrivateKey
145
+ try {
146
+ privateKey = await loadKey(resolvePassword())
147
+ } catch (err) {
148
+ fatal((err as Error).message)
149
+ }
150
+
151
+ return {
152
+ chain,
153
+ host: server.host ?? DEFAULT_HOST,
154
+ port: server.port ?? DEFAULT_PORT,
155
+ onesatURL: DEFAULT_ONESAT_URL,
156
+ storage,
157
+ dataDir,
158
+ sqliteFilename: deriveSqliteFilename(dataDir, chain),
159
+ storageIdentityKey:
160
+ config.storageIdentityKey ?? DEFAULT_STORAGE_IDENTITY_KEY,
161
+ activeRemote: config.activeRemote,
162
+ backups: config.backups,
163
+ accounts: resolveAccounts(server.accounts),
164
+ privateKey,
165
+ }
166
+ }
167
+
168
+ function deriveSqliteFilename(dataDir: string, chain: string): string {
169
+ return join(dataDir, `wallet-${chain}.db`)
170
+ }
171
+
172
+ function resolveAccounts(accounts?: ServerAccountsConfig): ResolvedAccounts {
173
+ return {
174
+ enabled: accounts?.enabled ?? false,
175
+ baselineBytes: accounts?.baselineBytes ?? DEFAULT_BASELINE_BYTES,
176
+ satsPerGb: accounts?.satsPerGb ?? DEFAULT_SATS_PER_GB,
177
+ durationBlocks: accounts?.durationBlocks ?? DEFAULT_DURATION_BLOCKS,
178
+ freeIdentityKeys: accounts?.freeIdentityKeys ?? [],
179
+ }
180
+ }
181
+
182
+ interface Stoppable {
183
+ stop(): Promise<void>
184
+ }
185
+
186
+ /**
187
+ * bun-sqlite path: construct the wallet via the same `createNodeWallet`
188
+ * factory the CLI uses. Server + monitor operate on that single wallet
189
+ * instance, so `activeRemote`, `backups`, and `storageIdentityKey` behave
190
+ * identically to `1sat wallet <command>`.
191
+ */
192
+ async function runBunSqlite(
193
+ resolved: ResolvedServe,
194
+ mode: ServeMode,
195
+ ): Promise<Stoppable> {
196
+ const walletResult = await createNodeWallet({
197
+ privateKey: resolved.privateKey,
198
+ chain: resolved.chain,
199
+ storageIdentityKey: resolved.storageIdentityKey,
200
+ filename: resolved.sqliteFilename,
201
+ activeRemote: resolved.activeRemote,
202
+ backups: resolved.backups,
203
+ })
204
+
205
+ const accounts = await buildAccountsForServer(resolved)
206
+
207
+ const serverHandle =
208
+ mode === 'monitor'
209
+ ? undefined
210
+ : await startWalletServer(resolved, walletResult, accounts)
211
+
212
+ if (mode !== 'wallet') {
213
+ await walletResult.monitor.startTasks()
214
+ console.log('[monitor] started')
215
+ }
216
+
217
+ return {
218
+ async stop() {
219
+ if (mode !== 'wallet') {
220
+ walletResult.monitor.stopTasks()
221
+ }
222
+ if (serverHandle) await serverHandle.stop()
223
+ if (accounts) await accounts.knex.destroy()
224
+ await walletResult.destroy()
225
+ },
226
+ }
227
+ }
228
+
229
+ async function startWalletServer(
230
+ resolved: ResolvedServe,
231
+ walletResult: NodeWalletResult,
232
+ accounts: AccountsRuntime | undefined,
233
+ ): Promise<{ stop(): Promise<void> }> {
234
+ const handle = createWalletServer({
235
+ storage: walletResult.storage,
236
+ serverPrivateKey: resolved.privateKey.toHex(),
237
+ listen: { port: resolved.port, host: resolved.host },
238
+ publicPath: '/',
239
+ internalPath: null,
240
+ accounts: accounts?.walletServerAccounts,
241
+ })
242
+ const port = await handle.start()
243
+ const accountsNote = resolved.accounts.enabled ? ' (accounts: on)' : ''
244
+ console.log(`[wallet] listening on ${resolved.host}:${port}${accountsNote}`)
245
+ return { stop: () => handle.stop() }
246
+ }
247
+
248
+ interface AccountsRuntime {
249
+ walletServerAccounts: NonNullable<
250
+ Parameters<typeof createWalletServer>[0]['accounts']
251
+ >
252
+ knex: Knex
253
+ }
254
+
255
+ async function buildAccountsForServer(
256
+ resolved: ResolvedServe,
257
+ ): Promise<AccountsRuntime | undefined> {
258
+ if (!resolved.accounts.enabled) return undefined
259
+
260
+ // Accounts uses its own knex pool. For bun-sqlite wallets that means
261
+ // opening the same file via better-sqlite3; for future pg wallets it
262
+ // shares the pg connection. Always a file path for now.
263
+ const accountsKnex = knex({
264
+ client: 'better-sqlite3',
265
+ connection: { filename: resolved.sqliteFilename },
266
+ useNullAsDefault: true,
267
+ })
268
+ await runAccountsMigrations(accountsKnex)
269
+
270
+ const services = new OneSatServices(resolved.chain, resolved.onesatURL)
271
+
272
+ return {
273
+ knex: accountsKnex,
274
+ walletServerAccounts: {
275
+ config: {
276
+ enabled: true,
277
+ baselineBytes: resolved.accounts.baselineBytes,
278
+ satsPerGb: resolved.accounts.satsPerGb,
279
+ durationBlocks: resolved.accounts.durationBlocks,
280
+ freeIdentityKeys: resolved.accounts.freeIdentityKeys,
281
+ },
282
+ knex: accountsKnex,
283
+ currentBlock: () => services.chaintracks.currentHeight(),
284
+ },
285
+ }
286
+ }
287
+
288
+ function waitForShutdown(): Promise<void> {
289
+ return new Promise((resolve) => {
290
+ const handler = (sig: string) => {
291
+ console.log(`Received ${sig}, shutting down...`)
292
+ resolve()
293
+ }
294
+ process.once('SIGINT', () => handler('SIGINT'))
295
+ process.once('SIGTERM', () => handler('SIGTERM'))
296
+ })
297
+ }
package/src/config.ts CHANGED
@@ -11,6 +11,49 @@ import { join } from 'node:path'
11
11
  const CONFIG_DIR = join(homedir(), '.1sat', 'cli')
12
12
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
13
13
 
14
+ export interface ServerStorageBunSqliteConfig {
15
+ provider: 'bun-sqlite'
16
+ }
17
+
18
+ export interface ServerStorageKnexSqliteConfig {
19
+ provider: 'knex-sqlite'
20
+ }
21
+
22
+ export interface ServerStorageKnexPgConfig {
23
+ provider: 'knex-pg'
24
+ /** Postgres connection URL (required for knex-pg). */
25
+ dbUrl: string
26
+ }
27
+
28
+ export type ServerStorageConfig =
29
+ | ServerStorageBunSqliteConfig
30
+ | ServerStorageKnexSqliteConfig
31
+ | ServerStorageKnexPgConfig
32
+
33
+ export interface ServerAccountsConfig {
34
+ /** Master toggle. Defaults to false when omitted. */
35
+ enabled?: boolean
36
+ /** Free baseline per identity key, in bytes. */
37
+ baselineBytes?: number
38
+ /** Sats charged per GB of paid capacity per `durationBlocks`. */
39
+ satsPerGb?: number
40
+ /** Block window a payment remains valid for. */
41
+ durationBlocks?: number
42
+ /** Identity keys that bypass metering (server's own key is auto-added). */
43
+ freeIdentityKeys?: string[]
44
+ }
45
+
46
+ export interface ServerConfig {
47
+ /** Hostname to bind. Defaults to `127.0.0.1`. */
48
+ host?: string
49
+ /** Port to bind. Defaults to `8100`. */
50
+ port?: number
51
+ /** Storage backend. Defaults to `{ provider: 'bun-sqlite' }`. */
52
+ storage?: ServerStorageConfig
53
+ /** Optional account/metering layer (opt-in per-deployment). */
54
+ accounts?: ServerAccountsConfig
55
+ }
56
+
14
57
  export interface OneSatCliConfig {
15
58
  /** Network: mainnet or testnet */
16
59
  chain: 'main' | 'test'
@@ -22,38 +65,13 @@ export interface OneSatCliConfig {
22
65
  backups?: string[]
23
66
  /** Storage identity key for wallet persistence */
24
67
  storageIdentityKey?: string
25
- /** How often to run the monitor refresh (minutes). 0 disables auto-refresh. */
26
- monitorIntervalMinutes: number
68
+ /** Settings read by `1sat serve` subcommands. Absent for client-only installs. */
69
+ server?: ServerConfig
27
70
  }
28
71
 
29
72
  const DEFAULT_CONFIG: OneSatCliConfig = {
30
73
  chain: 'main',
31
74
  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
- })
57
75
  }
58
76
 
59
77
  /**
@@ -99,6 +117,66 @@ export function updateConfig(patch: Partial<OneSatCliConfig>): OneSatCliConfig {
99
117
  return next
100
118
  }
101
119
 
120
+ /**
121
+ * Set a value at a dotted path inside the config, creating intermediate
122
+ * objects as needed. `value` is stored as-is; callers are responsible for
123
+ * type coercion (typically via `parseConfigValue`).
124
+ */
125
+ export function setConfigPath(path: string, value: unknown): OneSatCliConfig {
126
+ if (!path) throw new Error('setConfigPath requires a non-empty path')
127
+ const config = loadConfig() as Record<string, unknown>
128
+ const segments = path.split('.')
129
+ let cursor: Record<string, unknown> = config
130
+ for (let i = 0; i < segments.length - 1; i++) {
131
+ const key = segments[i]
132
+ const existing = cursor[key]
133
+ if (
134
+ existing == null ||
135
+ typeof existing !== 'object' ||
136
+ Array.isArray(existing)
137
+ ) {
138
+ cursor[key] = {}
139
+ }
140
+ cursor = cursor[key] as Record<string, unknown>
141
+ }
142
+ cursor[segments[segments.length - 1]] = value
143
+ saveConfig(config as OneSatCliConfig)
144
+ return config as OneSatCliConfig
145
+ }
146
+
147
+ /**
148
+ * Remove a value at a dotted path. Leaves empty parent objects in place to
149
+ * keep the file shape explicit.
150
+ */
151
+ export function unsetConfigPath(path: string): OneSatCliConfig {
152
+ if (!path) throw new Error('unsetConfigPath requires a non-empty path')
153
+ const config = loadConfig() as Record<string, unknown>
154
+ const segments = path.split('.')
155
+ let cursor: Record<string, unknown> | undefined = config
156
+ for (let i = 0; i < segments.length - 1; i++) {
157
+ const next = cursor?.[segments[i]]
158
+ if (next == null || typeof next !== 'object')
159
+ return config as OneSatCliConfig
160
+ cursor = next as Record<string, unknown>
161
+ }
162
+ if (cursor) delete cursor[segments[segments.length - 1]]
163
+ saveConfig(config as OneSatCliConfig)
164
+ return config as OneSatCliConfig
165
+ }
166
+
167
+ /**
168
+ * Parse a raw CLI argument into a typed JSON value. Tries `JSON.parse`
169
+ * first so numbers, booleans, objects and arrays round-trip naturally;
170
+ * falls back to the raw string for bare words that aren't valid JSON.
171
+ */
172
+ export function parseConfigValue(raw: string): unknown {
173
+ try {
174
+ return JSON.parse(raw)
175
+ } catch {
176
+ return raw
177
+ }
178
+ }
179
+
102
180
  /**
103
181
  * Get the config directory path.
104
182
  */
package/src/context.ts CHANGED
@@ -7,12 +7,7 @@
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 {
11
- ensureDataDir,
12
- loadConfig,
13
- loadMonitorState,
14
- saveMonitorState,
15
- } from './config'
10
+ import { ensureDataDir, loadConfig } from './config'
16
11
 
17
12
  /** Extended context that includes cleanup */
18
13
  export interface CliContext {
@@ -21,48 +16,16 @@ export interface CliContext {
21
16
  destroy: () => Promise<void>
22
17
  }
23
18
 
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
- const originalLog = console.log
42
- const originalInfo = console.info
43
- const originalWarn = console.warn
44
- console.log = () => {}
45
- console.info = () => {}
46
- console.warn = () => {}
47
-
48
- try {
49
- await walletResult.monitor.runOnce()
50
- } finally {
51
- console.log = originalLog
52
- console.info = originalInfo
53
- console.warn = originalWarn
54
- }
55
- saveMonitorState({ lastMonitorRun: Date.now() })
56
- }
57
- }
58
-
59
19
  /**
60
20
  * Create a fully initialized OneSatContext for CLI use.
61
21
  *
62
22
  * Sets up:
63
23
  * - Node wallet with SQLite storage
64
24
  * - 1Sat services for API access
65
- * - Monitor for transaction lifecycle (lazy, interval-based)
25
+ * - Monitor for transaction lifecycle. When local storage is the active
26
+ * store, the wallet factory fires `monitor.runOnce()` internally on
27
+ * creation; individual tasks self-throttle via their own intervals, so
28
+ * repeated CLI invocations are cheap.
66
29
  */
67
30
  export async function loadContext(
68
31
  privateKey: PrivateKey,
@@ -82,9 +45,6 @@ export async function loadContext(
82
45
  backups: config.backups,
83
46
  })
84
47
 
85
- // Run monitor once if interval has elapsed (lazy refresh)
86
- await runMonitorIfStale(walletResult, config.monitorIntervalMinutes)
87
-
88
48
  const ctx = createContext(walletResult.wallet, {
89
49
  services: walletResult.services,
90
50
  chain: opts.chain,