@1sat/cli 0.0.31 → 0.0.33

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,34 +1,45 @@
1
1
  {
2
- "name": "@1sat/cli",
3
- "version": "0.0.31",
4
- "description": "CLI for 1Sat Ordinals SDK",
5
- "type": "module",
6
- "main": "./src/cli.ts",
7
- "bin": {
8
- "1sat": "./src/cli.ts"
9
- },
10
- "files": ["src"],
11
- "scripts": {
12
- "build": "bun build ./src/cli.ts --compile --outfile=bin/1sat --external pg --external pg-native --external pg-query-stream --external tedious --external oracledb --external mysql",
13
- "dev": "bun run src/cli.ts",
14
- "lint": "biome check src"
15
- },
16
- "keywords": ["1sat", "bsv", "ordinals", "cli"],
17
- "license": "MIT",
18
- "dependencies": {
19
- "@1sat/actions": "0.0.99",
20
- "@1sat/client": "0.0.21",
21
- "@1sat/types": "0.0.17",
22
- "@1sat/wallet-node": "0.0.29",
23
- "@bsv/sdk": "^2.0.13",
24
- "chalk": "^5.0.0",
25
- "@clack/prompts": "^0.8.0",
26
- "bitcoin-backup": "^0.0.11",
27
- "dotenv": "^17.0.0",
28
- "evlog": "^2.10.0"
29
- },
30
- "devDependencies": {
31
- "@types/bun": "^1.3.9",
32
- "typescript": "^5.9.3"
33
- }
2
+ "name": "@1sat/cli",
3
+ "version": "0.0.33",
4
+ "description": "CLI for 1Sat Ordinals SDK",
5
+ "type": "module",
6
+ "main": "./src/cli.ts",
7
+ "bin": {
8
+ "1sat": "./src/cli.ts"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "scripts": {
14
+ "build": "bun build ./src/cli.ts --compile --outfile=bin/1sat --external pg --external pg-native --external pg-query-stream --external tedious --external oracledb --external mysql",
15
+ "dev": "bun run src/cli.ts",
16
+ "lint": "biome check src"
17
+ },
18
+ "keywords": [
19
+ "1sat",
20
+ "bsv",
21
+ "ordinals",
22
+ "cli"
23
+ ],
24
+ "license": "MIT",
25
+ "dependencies": {
26
+ "@1sat/actions": "0.0.99",
27
+ "@1sat/client": "0.0.21",
28
+ "@1sat/types": "0.0.17",
29
+ "@1sat/wallet-node": "0.0.30",
30
+ "@1sat/wallet-server": "0.0.2",
31
+ "@bsv/sdk": "^2.0.13",
32
+ "@bsv/wallet-toolbox": "^2.1.21",
33
+ "chalk": "^5.0.0",
34
+ "@clack/prompts": "^0.8.0",
35
+ "bitcoin-backup": "^0.0.11",
36
+ "dotenv": "^17.0.0",
37
+ "evlog": "^2.10.0",
38
+ "knex": "^3.1.0",
39
+ "pg": "^8.11.3"
40
+ },
41
+ "devDependencies": {
42
+ "@types/bun": "^1.3.9",
43
+ "typescript": "^5.9.3"
44
+ }
34
45
  }
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
@@ -111,5 +116,6 @@ async function main(): Promise<void> {
111
116
 
112
117
  main().catch((err) => {
113
118
  console.error(formatError(`Error: ${err.message}`))
119
+ if (process.env.DEBUG) console.error(err.stack)
114
120
  process.exit(1)
115
121
  })
@@ -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,83 @@ 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 (value !== 'bun-sqlite' && value !== 'knex-pg') {
137
+ fatal("server.storage.provider must be 'bun-sqlite' | 'knex-pg'")
138
+ }
139
+ break
140
+ case 'server.port':
141
+ if (typeof value !== 'number' || value <= 0 || !Number.isInteger(value)) {
142
+ fatal('server.port must be a positive integer')
143
+ }
144
+ break
145
+ case 'server.accounts.enabled':
146
+ if (typeof value !== 'boolean') {
147
+ fatal('server.accounts.enabled must be true or false')
148
+ }
149
+ break
150
+ }
126
151
  }
127
152
 
128
153
  function configPath(opts: GlobalFlags): void {
@@ -8,6 +8,8 @@
8
8
  * set-active - Switch active storage to a remote or back to local
9
9
  */
10
10
 
11
+ import { StorageClient } from '@1sat/wallet-node'
12
+ import { WalletServerClient, topUpStorage } from '@1sat/wallet-server'
11
13
  import { confirm, isCancel, text } from '@clack/prompts'
12
14
  import type { GlobalFlags } from '../args'
13
15
  import { loadConfig, saveConfig } from '../config'
@@ -31,6 +33,10 @@ export async function handleRemoteCommand(
31
33
  return remoteDelete(rest, opts)
32
34
  case 'set-active':
33
35
  return remoteSetActive(rest, opts)
36
+ case 'status':
37
+ return remoteStatus(rest, opts)
38
+ case 'topup':
39
+ return remoteTopup(rest, opts)
34
40
  default:
35
41
  printCommandHelp('remote', {
36
42
  add: 'Add a remote storage as backup (1sat remote add <url>)',
@@ -39,6 +45,10 @@ export async function handleRemoteCommand(
39
45
  'Remove a remote from the backup list (1sat remote delete <url>)',
40
46
  'set-active':
41
47
  'Switch active storage (1sat remote set-active <url | local>)',
48
+ status:
49
+ 'Fetch GET /account/status from a remote (1sat remote status [url])',
50
+ topup:
51
+ 'Buy capacity on a remote (1sat remote topup [url] [--units N])',
42
52
  })
43
53
  if (subcommand && subcommand !== 'help') {
44
54
  process.exit(1)
@@ -85,11 +95,7 @@ async function remoteAdd(args: string[], opts: GlobalFlags): Promise<void> {
85
95
  const config = loadConfig()
86
96
 
87
97
  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)
98
+ const client = new StorageClient(walletResult.wallet, url)
93
99
  await walletResult.storage.addWalletStorageProvider(client)
94
100
 
95
101
  // When adding a backup, the remote may report itself as "active" which
@@ -128,7 +134,7 @@ async function remoteAdd(args: string[], opts: GlobalFlags): Promise<void> {
128
134
 
129
135
  async function remoteList(_args: string[], opts: GlobalFlags): Promise<void> {
130
136
  const privateKey = await loadKey(resolvePassword())
131
- const { walletResult, destroy } = await loadContext(privateKey, {
137
+ const { destroy } = await loadContext(privateKey, {
132
138
  chain: opts.chain,
133
139
  })
134
140
 
@@ -321,6 +327,152 @@ async function remoteSetActive(
321
327
  }
322
328
  }
323
329
 
330
+ // ============================================================================
331
+ // remote status
332
+ // ============================================================================
333
+
334
+ async function remoteStatus(args: string[], opts: GlobalFlags): Promise<void> {
335
+ const config = loadConfig()
336
+ const url = args[0] ?? config.activeRemote ?? config.backups?.[0]
337
+ if (!url) {
338
+ fatal(
339
+ 'No remote URL supplied. Pass one as an argument or configure activeRemote/backups first.',
340
+ )
341
+ }
342
+ try {
343
+ new URL(url)
344
+ } catch {
345
+ fatal(`Invalid URL: ${url}`)
346
+ }
347
+
348
+ const privateKey = await loadKey(resolvePassword())
349
+ const { walletResult, destroy } = await loadContext(privateKey, {
350
+ chain: opts.chain,
351
+ })
352
+
353
+ try {
354
+ const client = new WalletServerClient(url, walletResult.wallet)
355
+ const status = await client.accountStatus()
356
+
357
+ if (opts.json) {
358
+ output(status, opts)
359
+ return
360
+ }
361
+
362
+ console.log()
363
+ console.log(` ${bold('Remote:')} ${url}`)
364
+ console.log(` ${bold('Identity:')} ${status.identityKey}`)
365
+ console.log(
366
+ ` ${bold('Accounts:')} ${status.accountsEnabled ? 'on' : 'off'}`,
367
+ )
368
+ if (status.currentBlock != null) {
369
+ console.log(` ${bold('Chain tip:')} block ${status.currentBlock}`)
370
+ }
371
+ if (status.usedBytes != null) {
372
+ console.log(` ${bold('Used:')} ${formatBytes(status.usedBytes)}`)
373
+ }
374
+ if (status.accountsEnabled) {
375
+ console.log(` ${bold('Baseline:')} ${formatBytes(status.baselineBytes)}`)
376
+ console.log(` ${bold('Paid:')} ${formatBytes(status.paidBytes)}`)
377
+ console.log(` ${bold('Capacity:')} ${formatBytes(status.capacityBytes)}`)
378
+ if (status.deficitBytes > 0) {
379
+ console.log(
380
+ ` ${bold('Deficit:')} ${formatBytes(status.deficitBytes)} (next write will trigger 402)`,
381
+ )
382
+ }
383
+ if (status.paidThroughBlock != null) {
384
+ const remaining = status.paidThroughBlock - status.currentBlock
385
+ console.log(
386
+ ` ${bold('Paid through:')} block ${status.paidThroughBlock} (${remaining} blocks left)`,
387
+ )
388
+ }
389
+ console.log(
390
+ ` ${bold('Pricing:')} ${status.pricing.satsPerUnit} sats per ${formatBytes(status.pricing.purchaseUnitBytes)} over ${status.pricing.durationBlocks} blocks`,
391
+ )
392
+ }
393
+ console.log()
394
+ } finally {
395
+ await destroy()
396
+ }
397
+ }
398
+
399
+ // ============================================================================
400
+ // remote topup
401
+ // ============================================================================
402
+
403
+ async function remoteTopup(args: string[], opts: GlobalFlags): Promise<void> {
404
+ let url: string | undefined
405
+ let units: number | undefined
406
+ for (let i = 0; i < args.length; i++) {
407
+ const arg = args[i]
408
+ if (arg === '--units') {
409
+ const val = args[++i]
410
+ const parsed = Number(val)
411
+ if (!Number.isInteger(parsed) || parsed <= 0) {
412
+ fatal(`--units requires a positive integer, got "${val}"`)
413
+ }
414
+ units = parsed
415
+ } else if (!url) {
416
+ url = arg
417
+ }
418
+ }
419
+
420
+ const config = loadConfig()
421
+ url = url ?? config.activeRemote ?? config.backups?.[0]
422
+ if (!url) {
423
+ fatal(
424
+ 'No remote URL supplied. Pass one as an argument or configure activeRemote/backups first.',
425
+ )
426
+ }
427
+ try {
428
+ new URL(url)
429
+ } catch {
430
+ fatal(`Invalid URL: ${url}`)
431
+ }
432
+
433
+ const privateKey = await loadKey(resolvePassword())
434
+ const { walletResult, destroy } = await loadContext(privateKey, {
435
+ chain: opts.chain,
436
+ })
437
+
438
+ try {
439
+ const result = await topUpStorage(walletResult.wallet, url, { units })
440
+
441
+ if (opts.json) {
442
+ output(result, opts)
443
+ return
444
+ }
445
+
446
+ console.log()
447
+ console.log(` ${bold('Remote:')} ${url}`)
448
+ console.log(` ${bold('Units bought:')} ${result.unitsBought}`)
449
+ console.log(` ${bold('Sats paid:')} ${result.satsPaid}`)
450
+ console.log(` ${bold('Payment txid:')} ${result.txid}`)
451
+ if (result.status.accountsEnabled) {
452
+ console.log()
453
+ console.log(
454
+ ` ${bold('New capacity:')} ${formatBytes(result.status.capacityBytes)} (${formatBytes(result.status.usedBytes)} used, ${formatBytes(result.status.deficitBytes)} deficit)`,
455
+ )
456
+ if (result.status.paidThroughBlock != null) {
457
+ const remaining = result.status.paidThroughBlock - result.status.currentBlock
458
+ console.log(
459
+ ` ${bold('Paid through:')} block ${result.status.paidThroughBlock} (${remaining} blocks left)`,
460
+ )
461
+ }
462
+ }
463
+ console.log()
464
+ } finally {
465
+ await destroy()
466
+ }
467
+ }
468
+
469
+ function formatBytes(n: number): string {
470
+ if (n < 1024) return `${n} B`
471
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
472
+ if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`
473
+ return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`
474
+ }
475
+
324
476
  // ============================================================================
325
477
  // Helpers
326
478
  // ============================================================================
@@ -0,0 +1,315 @@
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 {
20
+ type NodeWalletResult,
21
+ type NodeWalletStorageConfig,
22
+ createNodeWallet,
23
+ } from '@1sat/wallet-node'
24
+ import { createWalletServer } from '@1sat/wallet-server'
25
+ import type { PrivateKey } from '@bsv/sdk'
26
+ import type { GlobalFlags } from '../args'
27
+ import {
28
+ type ServerAccountsConfig,
29
+ type ServerStorageConfig,
30
+ loadConfig,
31
+ } from '../config'
32
+ import { ensureDataDir } from '../config'
33
+ import { printCommandHelp } from '../help'
34
+ import { loadKey, resolvePassword } from '../keys'
35
+ import { clearMonitorPid, writeMonitorPid } from '../monitor-lock'
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_PURCHASE_UNIT_BYTES = 1_073_741_824 // 1 GB chunks for production
43
+ const DEFAULT_SATS_PER_UNIT = 1_000_000
44
+ const DEFAULT_DURATION_BLOCKS = 4383
45
+ const DEFAULT_STORAGE_IDENTITY_KEY = '1sat-cli-default'
46
+
47
+ type ServeMode = 'all' | 'wallet' | 'monitor'
48
+
49
+ interface ResolvedServe {
50
+ chain: 'main' | 'test'
51
+ host: string
52
+ port: number
53
+ onesatURL: string
54
+ storage: ServerStorageConfig
55
+ dataDir: string
56
+ sqliteFilename: string
57
+ storageIdentityKey: string
58
+ activeRemote?: string
59
+ backups?: string[]
60
+ accounts: ResolvedAccounts
61
+ privateKey: PrivateKey
62
+ }
63
+
64
+ interface ResolvedAccounts {
65
+ enabled: boolean
66
+ baselineBytes: number
67
+ purchaseUnitBytes: number
68
+ satsPerUnit: number
69
+ durationBlocks: number
70
+ freeIdentityKeys: string[]
71
+ }
72
+
73
+ export async function handleServeCommand(
74
+ args: string[],
75
+ opts: GlobalFlags,
76
+ ): Promise<void> {
77
+ const [subcommand] = args
78
+ const mode = resolveMode(subcommand)
79
+
80
+ if (mode === null) {
81
+ printCommandHelp('serve', {
82
+ '(no subcommand)': 'Wallet server plus monitor daemon',
83
+ wallet: 'Wallet server only',
84
+ monitor: 'Monitor daemon only',
85
+ })
86
+ if (subcommand && subcommand !== 'help') process.exit(1)
87
+ return
88
+ }
89
+
90
+ const resolved = await resolveServe(opts)
91
+ const handles: Stoppable[] = []
92
+
93
+ try {
94
+ handles.push(await runWithStorage(resolved, mode))
95
+ await waitForShutdown()
96
+ } finally {
97
+ for (const h of handles.reverse()) {
98
+ try {
99
+ await h.stop()
100
+ } catch (err) {
101
+ console.error(`Error during shutdown: ${(err as Error).message}`)
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ function resolveMode(subcommand: string | undefined): ServeMode | null {
108
+ if (!subcommand) return 'all'
109
+ switch (subcommand) {
110
+ case 'wallet':
111
+ case 'monitor':
112
+ return subcommand
113
+ default:
114
+ return null
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Load the CLI config, apply serve defaults, and resolve the server identity
120
+ * key via the existing keyring mechanism.
121
+ */
122
+ async function resolveServe(opts: GlobalFlags): Promise<ResolvedServe> {
123
+ const config = loadConfig()
124
+ const server = config.server ?? {}
125
+
126
+ const storage: ServerStorageConfig = server.storage ?? {
127
+ provider: 'bun-sqlite',
128
+ }
129
+ if (storage.provider === 'knex-pg' && !storage.dbUrl) {
130
+ fatal(
131
+ 'server.storage.provider is knex-pg but server.storage.dbUrl is not set. ' +
132
+ 'Set it with: 1sat config set server.storage.dbUrl postgres://…',
133
+ )
134
+ }
135
+
136
+ const dataDir = ensureDataDir()
137
+ const chain = opts.chain ?? config.chain ?? 'main'
138
+
139
+ let privateKey: PrivateKey
140
+ try {
141
+ privateKey = await loadKey(resolvePassword())
142
+ } catch (err) {
143
+ fatal((err as Error).message)
144
+ }
145
+
146
+ return {
147
+ chain,
148
+ host: server.host ?? DEFAULT_HOST,
149
+ port: server.port ?? DEFAULT_PORT,
150
+ onesatURL: DEFAULT_ONESAT_URL,
151
+ storage,
152
+ dataDir,
153
+ sqliteFilename: deriveSqliteFilename(dataDir, chain),
154
+ storageIdentityKey:
155
+ config.storageIdentityKey ?? DEFAULT_STORAGE_IDENTITY_KEY,
156
+ activeRemote: config.activeRemote,
157
+ backups: config.backups,
158
+ accounts: resolveAccounts(server.accounts),
159
+ privateKey,
160
+ }
161
+ }
162
+
163
+ function deriveSqliteFilename(dataDir: string, chain: string): string {
164
+ return join(dataDir, `wallet-${chain}.db`)
165
+ }
166
+
167
+ function resolveWalletStorageConfig(
168
+ resolved: ResolvedServe,
169
+ ): NodeWalletStorageConfig {
170
+ const storage = resolved.storage
171
+ if (storage.provider === 'bun-sqlite') {
172
+ return { provider: 'bun-sqlite', filename: resolved.sqliteFilename }
173
+ }
174
+ if (storage.provider === 'knex-pg') {
175
+ return { provider: 'knex-pg', dbUrl: storage.dbUrl }
176
+ }
177
+ fatal(
178
+ `server.storage.provider '${storage.provider}' is not supported. Use 'bun-sqlite' or 'knex-pg'.`,
179
+ )
180
+ }
181
+
182
+ function resolveAccounts(accounts?: ServerAccountsConfig): ResolvedAccounts {
183
+ return {
184
+ enabled: accounts?.enabled ?? false,
185
+ baselineBytes: accounts?.baselineBytes ?? DEFAULT_BASELINE_BYTES,
186
+ purchaseUnitBytes:
187
+ accounts?.purchaseUnitBytes ?? DEFAULT_PURCHASE_UNIT_BYTES,
188
+ satsPerUnit: accounts?.satsPerUnit ?? DEFAULT_SATS_PER_UNIT,
189
+ durationBlocks: accounts?.durationBlocks ?? DEFAULT_DURATION_BLOCKS,
190
+ freeIdentityKeys: accounts?.freeIdentityKeys ?? [],
191
+ }
192
+ }
193
+
194
+ interface Stoppable {
195
+ stop(): Promise<void>
196
+ }
197
+
198
+ /**
199
+ * Construct the wallet via the same `createNodeWallet` factory the CLI
200
+ * uses, with storage provider (bun-sqlite / knex-pg) chosen from config.
201
+ * Server + monitor operate on that single wallet instance, so
202
+ * `activeRemote`, `backups`, and `storageIdentityKey` behave identically
203
+ * to `1sat wallet <command>`.
204
+ */
205
+ async function runWithStorage(
206
+ resolved: ResolvedServe,
207
+ mode: ServeMode,
208
+ ): Promise<Stoppable> {
209
+ const storage = resolveWalletStorageConfig(resolved)
210
+ const walletResult = await createNodeWallet({
211
+ privateKey: resolved.privateKey,
212
+ chain: resolved.chain,
213
+ storageIdentityKey: resolved.storageIdentityKey,
214
+ storage,
215
+ activeRemote: resolved.activeRemote,
216
+ backups: resolved.backups,
217
+ // Server owns the monitor loop; suppress the factory's initial
218
+ // runOnce so CLI invocations in the same data dir don't race with it.
219
+ skipInitialMonitor: mode !== 'wallet',
220
+ })
221
+
222
+ const accounts =
223
+ mode === 'monitor'
224
+ ? undefined
225
+ : await buildAccountsForServer(resolved, walletResult)
226
+
227
+ const serverHandle =
228
+ mode === 'monitor'
229
+ ? undefined
230
+ : await startWalletServer(resolved, walletResult, accounts)
231
+
232
+ if (mode !== 'wallet') {
233
+ // startTasks loops until stopTasks flips its flag. Fire without
234
+ // awaiting so the caller can install shutdown handlers and write
235
+ // the monitor pid file.
236
+ walletResult.monitor.startTasks().catch((err: unknown) => {
237
+ console.error('[monitor] task loop exited:', err)
238
+ })
239
+ writeMonitorPid(resolved.dataDir)
240
+ console.log('[monitor] started')
241
+ }
242
+
243
+ return {
244
+ async stop() {
245
+ if (mode !== 'wallet') {
246
+ walletResult.monitor.stopTasks()
247
+ clearMonitorPid(resolved.dataDir)
248
+ }
249
+ if (serverHandle) await serverHandle.stop()
250
+ // Accounts shares the wallet's connection — walletResult.destroy
251
+ // below closes it. No separate teardown needed.
252
+ await walletResult.destroy()
253
+ },
254
+ }
255
+ }
256
+
257
+ async function startWalletServer(
258
+ resolved: ResolvedServe,
259
+ walletResult: NodeWalletResult,
260
+ accounts: AccountsRuntime | undefined,
261
+ ): Promise<{ stop(): Promise<void> }> {
262
+ const handle = createWalletServer({
263
+ wallet: walletResult.wallet,
264
+ storage: walletResult.getActiveStorage(),
265
+ serverIdentityKey: walletResult.wallet.identityKey,
266
+ listen: { port: resolved.port, host: resolved.host },
267
+ publicPath: '/',
268
+ internalPath: null,
269
+ accounts: accounts?.walletServerAccounts,
270
+ })
271
+ const port = await handle.start()
272
+ const accountsNote = resolved.accounts.enabled ? ' (accounts: on)' : ''
273
+ console.log(`[wallet] listening on ${resolved.host}:${port}${accountsNote}`)
274
+ return { stop: () => handle.stop() }
275
+ }
276
+
277
+ interface AccountsRuntime {
278
+ walletServerAccounts: NonNullable<
279
+ Parameters<typeof createWalletServer>[0]['accounts']
280
+ >
281
+ }
282
+
283
+ async function buildAccountsForServer(
284
+ resolved: ResolvedServe,
285
+ walletResult: NodeWalletResult,
286
+ ): Promise<AccountsRuntime | undefined> {
287
+ // When the wallet is remote-primary the server is fronting someone else's
288
+ // storage; accounts semantics don't apply.
289
+ if (resolved.activeRemote) return undefined
290
+
291
+ return {
292
+ walletServerAccounts: {
293
+ config: {
294
+ enabled: resolved.accounts.enabled,
295
+ baselineBytes: resolved.accounts.baselineBytes,
296
+ purchaseUnitBytes: resolved.accounts.purchaseUnitBytes,
297
+ satsPerUnit: resolved.accounts.satsPerUnit,
298
+ durationBlocks: resolved.accounts.durationBlocks,
299
+ freeIdentityKeys: resolved.accounts.freeIdentityKeys,
300
+ },
301
+ currentBlock: () => walletResult.services.chaintracks.currentHeight(),
302
+ },
303
+ }
304
+ }
305
+
306
+ function waitForShutdown(): Promise<void> {
307
+ return new Promise((resolve) => {
308
+ const handler = (sig: string) => {
309
+ console.log(`Received ${sig}, shutting down...`)
310
+ resolve()
311
+ }
312
+ process.once('SIGINT', () => handler('SIGINT'))
313
+ process.once('SIGTERM', () => handler('SIGTERM'))
314
+ })
315
+ }
package/src/config.ts CHANGED
@@ -11,6 +11,46 @@ 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 ServerStorageKnexPgConfig {
19
+ provider: 'knex-pg'
20
+ /** Postgres connection URL (required for knex-pg). */
21
+ dbUrl: string
22
+ }
23
+
24
+ export type ServerStorageConfig =
25
+ | ServerStorageBunSqliteConfig
26
+ | ServerStorageKnexPgConfig
27
+
28
+ export interface ServerAccountsConfig {
29
+ /** Master toggle. Defaults to false when omitted. */
30
+ enabled?: boolean
31
+ /** Free baseline per identity key, in bytes. */
32
+ baselineBytes?: number
33
+ /** Purchase chunk size in bytes. Deficits round up to whole chunks. Defaults to 1 GB. */
34
+ purchaseUnitBytes?: number
35
+ /** Sats charged per purchase unit. */
36
+ satsPerUnit?: number
37
+ /** Block window a payment remains valid for. */
38
+ durationBlocks?: number
39
+ /** Identity keys that bypass metering (server's own key is auto-added). */
40
+ freeIdentityKeys?: string[]
41
+ }
42
+
43
+ export interface ServerConfig {
44
+ /** Hostname to bind. Defaults to `127.0.0.1`. */
45
+ host?: string
46
+ /** Port to bind. Defaults to `8100`. */
47
+ port?: number
48
+ /** Storage backend. Defaults to `{ provider: 'bun-sqlite' }`. */
49
+ storage?: ServerStorageConfig
50
+ /** Optional account/metering layer (opt-in per-deployment). */
51
+ accounts?: ServerAccountsConfig
52
+ }
53
+
14
54
  export interface OneSatCliConfig {
15
55
  /** Network: mainnet or testnet */
16
56
  chain: 'main' | 'test'
@@ -22,6 +62,8 @@ export interface OneSatCliConfig {
22
62
  backups?: string[]
23
63
  /** Storage identity key for wallet persistence */
24
64
  storageIdentityKey?: string
65
+ /** Settings read by `1sat serve` subcommands. Absent for client-only installs. */
66
+ server?: ServerConfig
25
67
  }
26
68
 
27
69
  const DEFAULT_CONFIG: OneSatCliConfig = {
@@ -72,6 +114,66 @@ export function updateConfig(patch: Partial<OneSatCliConfig>): OneSatCliConfig {
72
114
  return next
73
115
  }
74
116
 
117
+ /**
118
+ * Set a value at a dotted path inside the config, creating intermediate
119
+ * objects as needed. `value` is stored as-is; callers are responsible for
120
+ * type coercion (typically via `parseConfigValue`).
121
+ */
122
+ export function setConfigPath(path: string, value: unknown): OneSatCliConfig {
123
+ if (!path) throw new Error('setConfigPath requires a non-empty path')
124
+ const config = loadConfig() as Record<string, unknown>
125
+ const segments = path.split('.')
126
+ let cursor: Record<string, unknown> = config
127
+ for (let i = 0; i < segments.length - 1; i++) {
128
+ const key = segments[i]
129
+ const existing = cursor[key]
130
+ if (
131
+ existing == null ||
132
+ typeof existing !== 'object' ||
133
+ Array.isArray(existing)
134
+ ) {
135
+ cursor[key] = {}
136
+ }
137
+ cursor = cursor[key] as Record<string, unknown>
138
+ }
139
+ cursor[segments[segments.length - 1]] = value
140
+ saveConfig(config as OneSatCliConfig)
141
+ return config as OneSatCliConfig
142
+ }
143
+
144
+ /**
145
+ * Remove a value at a dotted path. Leaves empty parent objects in place to
146
+ * keep the file shape explicit.
147
+ */
148
+ export function unsetConfigPath(path: string): OneSatCliConfig {
149
+ if (!path) throw new Error('unsetConfigPath requires a non-empty path')
150
+ const config = loadConfig() as Record<string, unknown>
151
+ const segments = path.split('.')
152
+ let cursor: Record<string, unknown> | undefined = config
153
+ for (let i = 0; i < segments.length - 1; i++) {
154
+ const next = cursor?.[segments[i]]
155
+ if (next == null || typeof next !== 'object')
156
+ return config as OneSatCliConfig
157
+ cursor = next as Record<string, unknown>
158
+ }
159
+ if (cursor) delete cursor[segments[segments.length - 1]]
160
+ saveConfig(config as OneSatCliConfig)
161
+ return config as OneSatCliConfig
162
+ }
163
+
164
+ /**
165
+ * Parse a raw CLI argument into a typed JSON value. Tries `JSON.parse`
166
+ * first so numbers, booleans, objects and arrays round-trip naturally;
167
+ * falls back to the raw string for bare words that aren't valid JSON.
168
+ */
169
+ export function parseConfigValue(raw: string): unknown {
170
+ try {
171
+ return JSON.parse(raw)
172
+ } catch {
173
+ return raw
174
+ }
175
+ }
176
+
75
177
  /**
76
178
  * Get the config directory path.
77
179
  */
package/src/context.ts CHANGED
@@ -8,6 +8,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
10
  import { ensureDataDir, loadConfig } from './config'
11
+ import { readLiveMonitorPid } from './monitor-lock'
11
12
 
12
13
  /** Extended context that includes cleanup */
13
14
  export interface CliContext {
@@ -36,13 +37,19 @@ export async function loadContext(
36
37
 
37
38
  const storageIdentityKey = config.storageIdentityKey ?? '1sat-cli-default'
38
39
 
40
+ const skipInitialMonitor = readLiveMonitorPid(dataDir) !== undefined
41
+
39
42
  const walletResult = await createNodeWallet({
40
43
  privateKey,
41
44
  chain: opts.chain,
42
45
  storageIdentityKey,
43
- filename: `${dataDir}/wallet-${opts.chain}.db`,
46
+ storage: {
47
+ provider: 'bun-sqlite',
48
+ filename: `${dataDir}/wallet-${opts.chain}.db`,
49
+ },
44
50
  activeRemote: config.activeRemote,
45
51
  backups: config.backups,
52
+ skipInitialMonitor,
46
53
  })
47
54
 
48
55
  const ctx = createContext(walletResult.wallet, {
@@ -0,0 +1,50 @@
1
+ /**
2
+ * PID lock file coordinating a single monitor owner across CLI invocations
3
+ * and a long-running `1sat serve` process.
4
+ *
5
+ * - `1sat serve` (modes that run the monitor) writes the pid on startup and
6
+ * removes it on clean shutdown.
7
+ * - CLI invocations read the file and, if the pid is alive, skip firing
8
+ * their own `monitor.runOnce()` to avoid duplicate work against the same
9
+ * SQLite file.
10
+ */
11
+
12
+ import { readFileSync, unlinkSync, writeFileSync } from 'node:fs'
13
+ import { join } from 'node:path'
14
+
15
+ export const MONITOR_PID_FILENAME = 'monitor.pid'
16
+
17
+ export function monitorPidPath(dataDir: string): string {
18
+ return join(dataDir, MONITOR_PID_FILENAME)
19
+ }
20
+
21
+ export function writeMonitorPid(
22
+ dataDir: string,
23
+ pid: number = process.pid,
24
+ ): void {
25
+ writeFileSync(monitorPidPath(dataDir), `${pid}\n`, 'utf8')
26
+ }
27
+
28
+ export function clearMonitorPid(dataDir: string): void {
29
+ try {
30
+ unlinkSync(monitorPidPath(dataDir))
31
+ } catch {}
32
+ }
33
+
34
+ /** Returns the pid of a live monitor owner, or undefined if none. */
35
+ export function readLiveMonitorPid(dataDir: string): number | undefined {
36
+ let raw: string
37
+ try {
38
+ raw = readFileSync(monitorPidPath(dataDir), 'utf8')
39
+ } catch {
40
+ return undefined
41
+ }
42
+ const pid = Number.parseInt(raw.trim(), 10)
43
+ if (!Number.isInteger(pid) || pid <= 0) return undefined
44
+ try {
45
+ process.kill(pid, 0)
46
+ return pid
47
+ } catch {
48
+ return undefined
49
+ }
50
+ }