@1sat/cli 0.0.31 → 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.31",
3
+ "version": "0.0.32",
4
4
  "description": "CLI for 1Sat Ordinals SDK",
5
5
  "type": "module",
6
6
  "main": "./src/cli.ts",
@@ -20,12 +20,16 @@
20
20
  "@1sat/client": "0.0.21",
21
21
  "@1sat/types": "0.0.17",
22
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,15 +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
- ]
34
-
35
29
  export async function handleConfigCommand(
36
30
  args: string[],
37
31
  opts: GlobalFlags,
@@ -69,11 +63,7 @@ function configShow(opts: GlobalFlags): void {
69
63
  }
70
64
 
71
65
  console.log()
72
- printKeyValue({
73
- chain: config.chain,
74
- dataDir: config.dataDir,
75
- storageIdentityKey: config.storageIdentityKey ?? '(not set)',
76
- })
66
+ printNested(config, '')
77
67
  console.log()
78
68
  console.log(
79
69
  ` ${formatLabel('config file:')} ${formatValue(getConfigFile())}`,
@@ -81,48 +71,89 @@ function configShow(opts: GlobalFlags): void {
81
71
  console.log()
82
72
  }
83
73
 
84
- function configSet(args: string[], opts: GlobalFlags): void {
85
- const [key, value] = args
86
-
87
- if (!key || !value) {
88
- fatal(
89
- `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))}`,
90
78
  )
79
+ return
91
80
  }
92
-
93
- if (!SETTABLE_KEYS.includes(key as keyof OneSatCliConfig)) {
94
- fatal(
95
- `Unknown config key: ${key}\n\nSettable keys: ${SETTABLE_KEYS.join(', ')}`,
96
- )
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
+ }
97
92
  }
93
+ }
94
+
95
+ function configSet(args: string[], opts: GlobalFlags): void {
96
+ const [key, ...valueArgs] = args
97
+ const value = valueArgs.join(' ')
98
98
 
99
- // Validate specific keys
100
- if (key === 'chain' && value !== 'main' && value !== 'test') {
101
- fatal("chain must be 'main' or 'test'")
99
+ if (!key || valueArgs.length === 0) {
100
+ fatal('Usage: 1sat config set <dotted.path> <value>')
102
101
  }
103
102
 
104
- const updated = updateConfig({ [key]: value })
105
- 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
+ )
106
111
  }
107
112
 
108
113
  function configUnset(args: string[], opts: GlobalFlags): void {
109
114
  const [key] = args
110
115
 
111
116
  if (!key) {
112
- fatal(
113
- `Usage: 1sat config unset <key>\n\nKeys that can be unset: ${SETTABLE_KEYS.join(', ')}`,
114
- )
117
+ fatal('Usage: 1sat config unset <dotted.path>')
115
118
  }
116
119
 
117
- if (!SETTABLE_KEYS.includes(key as keyof OneSatCliConfig)) {
118
- fatal(
119
- `Unknown config key: ${key}\n\nKeys that can be unset: ${SETTABLE_KEYS.join(', ')}`,
120
- )
121
- }
120
+ unsetConfigPath(key)
121
+ output(opts.json ? { [key]: undefined } : formatSuccess(`Unset ${key}`), opts)
122
+ }
122
123
 
123
- // For other keys, use undefined
124
- const updated = updateConfig({ [key]: undefined })
125
- 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
+ }
126
157
  }
127
158
 
128
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,6 +65,8 @@ export interface OneSatCliConfig {
22
65
  backups?: string[]
23
66
  /** Storage identity key for wallet persistence */
24
67
  storageIdentityKey?: string
68
+ /** Settings read by `1sat serve` subcommands. Absent for client-only installs. */
69
+ server?: ServerConfig
25
70
  }
26
71
 
27
72
  const DEFAULT_CONFIG: OneSatCliConfig = {
@@ -72,6 +117,66 @@ export function updateConfig(patch: Partial<OneSatCliConfig>): OneSatCliConfig {
72
117
  return next
73
118
  }
74
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
+
75
180
  /**
76
181
  * Get the config directory path.
77
182
  */