diff --git a/.changeset/accounts-show-private-keys.md b/.changeset/accounts-show-private-keys.md new file mode 100644 index 0000000..3d86924 --- /dev/null +++ b/.changeset/accounts-show-private-keys.md @@ -0,0 +1,5 @@ +--- +'@offckb/cli': patch +--- + +- accounts: print the built-in dev private keys by default on a local devnet; add `--hide-private-keys` to omit them. Mainnet forks still hide keys unless `--show-private-keys` is passed (#520) diff --git a/.changeset/daemon-stop-spaced-path.md b/.changeset/daemon-stop-spaced-path.md new file mode 100644 index 0000000..baf2ec8 --- /dev/null +++ b/.changeset/daemon-stop-spaced-path.md @@ -0,0 +1,5 @@ +--- +'@offckb/cli': patch +--- + +- daemon: identify a running offckb daemon by anchoring the node executable and CLI script path in the process command line, so `offckb node stop` works when the CLI path contains spaces (e.g. macOS `~/Library/Application Support/...`) diff --git a/.changeset/fix-config-save-failure-and-corrupt-protection.md b/.changeset/fix-config-save-failure-and-corrupt-protection.md new file mode 100644 index 0000000..9018ae9 --- /dev/null +++ b/.changeset/fix-config-save-failure-and-corrupt-protection.md @@ -0,0 +1,11 @@ +--- +'@offckb/cli': patch +--- + +Fix #518: Propagate settings read/write failures and prevent overwriting corrupted config files. + +- Configuration mutation commands (`offckb config set` and `offckb config rm`) now perform strict reading (`readSettingsStrict`). If `settings.json` cannot be read, parsed, or validated, the modification aborts immediately, throwing an Error with the file path and reason while leaving the existing file contents intact. +- Missing configuration files (ENOENT) continue to fall back to default settings, allowing initial creation. +- Non-mutating `readSettings()` continues to fall back to default settings on exceptions, preserving existing read-only behavior across the project. +- `writeSettings` now writes atomically to a unique temporary file in the same directory and replaces the target via `renameSync`, cleaning up temporary files on error. +- `writeSettings` throws an Error containing the config file path and underlying error instead of swallowing it, allowing the CLI error handling to exit with a non-zero exit code and emit structured `ok: false` in JSON mode. diff --git a/.changeset/fix-orphan-miner.md b/.changeset/fix-orphan-miner.md new file mode 100644 index 0000000..436b014 --- /dev/null +++ b/.changeset/fix-orphan-miner.md @@ -0,0 +1,5 @@ +--- +'@offckb/cli': patch +--- + +- node: stop the CKB node and miner when offckb exits through SIGTERM, SIGHUP, a crash or `process.exit`, and stop stale `ckb miner -C ` processes orphaned by an earlier run before starting the devnet (#512) diff --git a/.changeset/fix-script-debug-log-filter.md b/.changeset/fix-script-debug-log-filter.md new file mode 100644 index 0000000..403d4ac --- /dev/null +++ b/.changeset/fix-script-debug-log-filter.md @@ -0,0 +1,5 @@ +--- +'@offckb/cli': patch +--- + +- devnet logs: accept real CKB contract debug logs with `ckb_script::verify` target containing `DEBUG OUTPUT:` (#513) diff --git a/.changeset/fix-set-validation-and-daemon-locale.md b/.changeset/fix-set-validation-and-daemon-locale.md new file mode 100644 index 0000000..ebad9f6 --- /dev/null +++ b/.changeset/fix-set-validation-and-daemon-locale.md @@ -0,0 +1,6 @@ +--- +'@offckb/cli': patch +--- + +- devnet config: include field name, rejected value, and original validation reason when `--set` validation fails (#516) +- daemon: run posix `ps` process probes with `LC_ALL=C` so daemon start time parsing succeeds under non-English locales (#519) diff --git a/.changeset/udt-destroy-full-balance.md b/.changeset/udt-destroy-full-balance.md new file mode 100644 index 0000000..d00812d --- /dev/null +++ b/.changeset/udt-destroy-full-balance.md @@ -0,0 +1,5 @@ +--- +'@offckb/cli': patch +--- + +- sdk: allow destroying the full UDT balance while keeping a zero-balance UDT cell (#517) diff --git a/README.md b/README.md index d3d067a..189886a 100644 --- a/README.md +++ b/README.md @@ -554,11 +554,13 @@ LOG_LEVEL=debug offckb node On a pure OffCKB devnet, OffCKB comes with 20 pre-funded accounts, each initialized with `42_000_000_00000000` capacity in the genesis block. A fork keeps the source chain genesis and therefore has no OffCKB genesis allocation; built-in dev accounts are funded by locally mined cellbase cells instead. ```sh -offckb accounts -offckb accounts --show-private-keys # trusted local terminals only +offckb accounts # prints address, private key, pubkey and lock script +offckb accounts --hide-private-keys # omit private keys, e.g. when output goes to logs ``` -On a Mainnet fork, `accounts` re-encodes the same dev lock scripts with the `ckb` address prefix. Once the Indexer is caught up it also reports each account's spendable pure-CKB balance; until then the field is omitted with a warning. Private keys are hidden by default so JSON and agent logs do not collect them. +The built-in accounts are public test keys, so `accounts` prints their private keys by default (also in `--json` output). + +On a Mainnet fork, `accounts` re-encodes the same dev lock scripts with the `ckb` address prefix, so private keys are hidden by default there; pass `--show-private-keys` (trusted local terminals only) to print them. Once the Indexer is caught up it also reports each account's spendable pure-CKB balance; until then the field is omitted with a warning. - All private keys are stored in the `account/keys` file. - Detailed information for each account is recorded in `account/account.json`. diff --git a/package.json b/package.json index 0dcf167..f4218d6 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "commander": "^12.0.0", "http-proxy": "^1.18.1", "https-proxy-agent": "^7.0.5", - "js-yaml": "4.3.1", + "js-yaml": "5.4.1", "node-fetch": "2", "semver": "^7.6.0", "tar": "^7.5.19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d65522..56136c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,16 +6,17 @@ settings: overrides: form-data@>=4.0.0 <4.0.6: 4.0.6 - hono@<4.12.34: 4.12.34 - qs@>=6.11.1 <=6.15.1: 6.15.2 - ip-address@<=10.3.0: 10.3.1 - js-yaml@<3.15.1: 3.15.1 - js-yaml@>=4.0.0 <4.3.1: 4.3.1 + hono@<4.13.7: 4.13.7 + qs@>=6.11.1 <6.16.0: 6.16.0 + ip-address@<=10.7.0: 10.7.1 + js-yaml@<3.15.2: 3.15.2 + js-yaml@>=4.0.0 <4.3.2: 4.3.2 '@babel/core@<=7.29.0': 7.29.7 '@eslint/plugin-kit@<0.3.4': 0.3.4 - brace-expansion@<1.1.18: 1.1.18 - brace-expansion@>=4.0.0 <5.0.9: 5.0.9 - fast-uri@<3.1.5: 3.1.5 + brace-expansion@<1.1.21: 1.1.21 + brace-expansion@>=4.0.0 <5.0.12: 5.0.12 + fast-uri@<3.1.8: 3.1.8 + '@modelcontextprotocol/sdk@>=1.12.0 <1.31.0': 1.31.0 body-parser@>=2.0.0 <2.3.0: 2.3.0 '@hono/node-server@<2.0.10': 2.0.10 @@ -37,7 +38,7 @@ importers: version: 1.17.16 adm-zip: specifier: ^0.6.0 - version: 0.6.0 + version: 0.6.1 blessed: specifier: 0.1.81 version: 0.1.81 @@ -57,8 +58,8 @@ importers: specifier: ^7.0.5 version: 7.0.6 js-yaml: - specifier: 4.3.1 - version: 4.3.1 + specifier: 5.4.1 + version: 5.4.1 node-fetch: specifier: '2' version: 2.7.0 @@ -464,14 +465,18 @@ packages: resolution: {integrity: sha512-ZcnNVhKTmyDJeg0UlnZjvM73JBsTAuhrH/J4fjwGOw59PwOW51r4J+p6CsKZWXdKSme4MFqU62CZMOsdDrU4CA==} engines: {node: '>=20'} peerDependencies: - hono: 4.12.34 + hono: 4.13.7 - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': @@ -748,8 +753,8 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@modelcontextprotocol/sdk@1.30.0': - resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} + '@modelcontextprotocol/sdk@1.31.0': + resolution: {integrity: sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -1115,8 +1120,8 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - adm-zip@0.6.0: - resolution: {integrity: sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==} + adm-zip@0.6.1: + resolution: {integrity: sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==} engines: {node: '>=14.0'} aes-js@4.0.0-beta.5: @@ -1261,11 +1266,11 @@ packages: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} - brace-expansion@1.1.18: - resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} - brace-expansion@5.0.9: - resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} braces@3.0.3: @@ -1619,6 +1624,7 @@ packages: eslint@9.26.0: resolution: {integrity: sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -1715,8 +1721,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.5: - resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -1899,8 +1905,8 @@ packages: hmac-drbg@1.0.1: resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} - hono@4.12.34: - resolution: {integrity: sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA==} + hono@4.13.7: + resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} engines: {node: '>=16.9.0'} html-escaper@2.0.2: @@ -1970,8 +1976,8 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ip-address@10.3.1: - resolution: {integrity: sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==} + ip-address@10.7.1: + resolution: {integrity: sha512-4OUAqU9Z1i3vCnS05hzGiFnEMDpQ+62pAD/MVQOp83fYyNC8GleCqaS0QikQBmcWCrKFiUs/B8ztRRiYOAXuCA==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -2204,12 +2210,16 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.15.1: - resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==} + js-yaml@3.15.2: + resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} hasBin: true - js-yaml@4.3.1: - resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + js-yaml@5.4.1: + resolution: {integrity: sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==} hasBin: true jsbi@3.1.3: @@ -2621,8 +2631,8 @@ packages: resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} punycode@2.3.1: @@ -2632,8 +2642,8 @@ packages: pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} - qs@6.15.2: - resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} quansync@0.2.11: @@ -2759,8 +2769,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} side-channel-map@1.0.1: @@ -2771,8 +2781,8 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} signal-exit@3.0.7: @@ -3019,10 +3029,6 @@ packages: resolution: {integrity: sha512-rLjWJzQFOq4xw7MgJrCZ6T1jIOvvYElXT12r+y0CC6u67hegDHaxcPqb2fZHOGlqxugGQPNB1EnTezjBetkwkw==} engines: {node: '>=16'} - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} - type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} @@ -3525,7 +3531,7 @@ snapshots: '@changesets/parse@0.4.2': dependencies: '@changesets/types': 6.1.0 - js-yaml: 4.3.1 + js-yaml: 4.3.2 '@changesets/pre@2.0.2': dependencies: @@ -3640,7 +3646,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.3.1 + js-yaml: 4.3.2 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -3655,17 +3661,22 @@ snapshots: '@eslint/core': 0.15.2 levn: 0.4.1 - '@hono/node-server@2.0.10(hono@4.12.34)': + '@hono/node-server@2.0.10(hono@4.13.7)': dependencies: - hono: 4.12.34 + hono: 4.13.7 - '@humanfs/core@0.19.1': {} + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 - '@humanfs/node@0.16.7': + '@humanfs/node@0.16.8': dependencies: - '@humanfs/core': 0.19.1 + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 '@humanwhocodes/retry': 0.4.3 + '@humanfs/types@0.15.0': {} + '@humanwhocodes/module-importer@1.0.1': {} '@humanwhocodes/retry@0.4.3': {} @@ -3815,7 +3826,7 @@ snapshots: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 3.15.1 + js-yaml: 3.15.2 resolve-from: 5.0.0 '@istanbuljs/schema@0.1.6': {} @@ -4058,9 +4069,9 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@modelcontextprotocol/sdk@1.30.0(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.31.0(zod@3.25.76)': dependencies: - '@hono/node-server': 2.0.10(hono@4.12.34) + '@hono/node-server': 2.0.10(hono@4.13.7) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -4070,7 +4081,7 @@ snapshots: eventsource-parser: 3.0.6 express: 5.2.1 express-rate-limit: 8.3.1(express@5.2.1) - hono: 4.12.34 + hono: 4.13.7 jose: 6.1.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -4414,7 +4425,7 @@ snapshots: acorn@8.18.0: {} - adm-zip@0.6.0: {} + adm-zip@0.6.1: {} aes-js@4.0.0-beta.5: {} @@ -4434,7 +4445,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.5 + fast-uri: 3.1.8 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -4561,18 +4572,18 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.15.2 + qs: 6.16.0 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: - supports-color - brace-expansion@1.1.18: + brace-expansion@1.1.21: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.9: + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 @@ -4884,10 +4895,10 @@ snapshots: '@eslint/eslintrc': 3.3.3 '@eslint/js': 9.26.0 '@eslint/plugin-kit': 0.3.4 - '@humanfs/node': 0.16.7 + '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 - '@modelcontextprotocol/sdk': 1.30.0(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.31.0(zod@3.25.76) '@types/estree': 1.0.8 '@types/json-schema': 7.0.15 ajv: 6.14.0 @@ -5000,7 +5011,7 @@ snapshots: express-rate-limit@8.3.1(express@5.2.1): dependencies: express: 5.2.1 - ip-address: 10.3.1 + ip-address: 10.7.1 express@5.2.1: dependencies: @@ -5023,14 +5034,14 @@ snapshots: on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 - proxy-addr: 2.0.7 - qs: 6.15.2 + proxy-addr: 2.0.8 + qs: 6.16.0 range-parser: 1.2.1 router: 2.2.0 send: 1.2.1 serve-static: 2.2.1 statuses: 2.0.2 - type-is: 2.0.1 + type-is: 2.1.0 vary: 1.1.2 transitivePeerDependencies: - supports-color @@ -5051,7 +5062,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.5: {} + fast-uri@3.1.8: {} fastq@1.19.1: dependencies: @@ -5248,7 +5259,7 @@ snapshots: minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 - hono@4.12.34: {} + hono@4.13.7: {} html-escaper@2.0.2: {} @@ -5314,7 +5325,7 @@ snapshots: inherits@2.0.4: {} - ip-address@10.3.1: {} + ip-address@10.7.1: {} ipaddr.js@1.9.1: {} @@ -5719,12 +5730,16 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.15.1: + js-yaml@3.15.2: dependencies: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.3.1: + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + js-yaml@5.4.1: dependencies: argparse: 2.0.1 @@ -5877,11 +5892,11 @@ snapshots: minimatch@3.1.5: dependencies: - brace-expansion: 1.1.18 + brace-expansion: 1.1.21 minimatch@9.0.8: dependencies: - brace-expansion: 5.0.9 + brace-expansion: 5.0.12 minimist@1.2.8: {} @@ -6061,7 +6076,7 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 - proxy-addr@2.0.7: + proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 @@ -6070,9 +6085,10 @@ snapshots: pure-rand@7.0.1: {} - qs@6.15.2: + qs@6.16.0: dependencies: - side-channel: 1.1.0 + es-define-property: 1.0.1 + side-channel: 1.1.1 quansync@0.2.11: {} @@ -6092,7 +6108,7 @@ snapshots: read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 - js-yaml: 3.15.1 + js-yaml: 3.15.2 pify: 4.0.1 strip-bom: 3.0.0 @@ -6198,7 +6214,7 @@ snapshots: shebang-regex@3.0.0: {} - side-channel-list@1.0.0: + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 @@ -6218,11 +6234,11 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.1.0: + side-channel@1.1.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 - side-channel-list: 1.0.0 + side-channel-list: 1.0.1 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 @@ -6451,12 +6467,6 @@ snapshots: type-fest@4.6.0: {} - type-is@2.0.1: - dependencies: - content-type: 1.0.5 - media-typer: 1.1.0 - mime-types: 3.0.2 - type-is@2.1.0: dependencies: content-type: 2.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 72c9110..c651bcf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,31 +4,45 @@ overrides: # GHSA-88fw-hqm2-52qc: CORS middleware reflects any origin with credentials. Remove when hono >=4.12.25. # GHSA-xgm2-5f3f-mvvc / GHSA-hvrm-45r6-mjfj / GHSA-w62v-xxxg-mg59: repeated-header drop, JSX context leakage, JSX escaping bypass. Remove when hono >=4.12.27. # GHSA-54fx-42gc-7vw4 / GHSA-79qm-7rj5-m7r9 / GHSA-f23p-vx2j-j53r: language-middleware DoS, proxy-helper header handling, memo() cross-request SSR leakage. Remove when hono >=4.12.34. - "hono@<4.12.34": "4.12.34" + # GHSA-gqvv-2mrq-wpjv / GHSA-crvj-82cr-hjcx / GHSA-g6gw-c38x-mqfc: toSSG() writes outside output dir, query parser reads past fragment, parseBody() dot-notation memory exhaustion. Remove when hono >=4.13.5. + # GHSA-hxh3-vqpv-xpqv: unescaped plain strings in JSX boundary components (XSS). Remove when hono >=4.13.7. + "hono@<4.13.7": "4.13.7" # GHSA-q8mj-m7cp-5q26: qs.stringify DoS. Remove when direct dependency upgrades qs >=6.15.2. - "qs@>=6.11.1 <=6.15.1": "6.15.2" + # GHSA-x5fp-wj9c-mxmx / GHSA-4mjr-xmp4-gh2g: array-limit bypass via bracket-key comma parsing, DoS via attacker-controlled isBuffer. Remove when direct dependency upgrades qs >=6.16.0. + "qs@>=6.11.1 <6.16.0": "6.16.0" # GHSA-v2v4-37r5-5v8g: XSS in Address6 HTML-emitting methods. Remove when direct dependency upgrades ip-address >=10.1.1. # GHSA-22jq-vg5j-6vgg / GHSA-4xrf-jv44-h6hh / GHSA-mwp4-54f8-5fhr: IPv4-mapped/NAT64 misclassification, CIDR-suffix special-use bypass, leading-zero octal SSRF. Remove when direct dependency upgrades ip-address >=10.3.1. - "ip-address@<=10.3.0": "10.3.1" + # GHSA-2vr4-cq9g-pvrc / GHSA-rpw4-54j3-4h4q: NAT64 local-use range and wrong link-local prefix enable SSRF/trust-boundary bypass. Remove when direct dependency upgrades ip-address >=10.5.1. + # GHSA-j6r3-76f7-8jcv / GHSA-h3mg-xc3c-68pw: cross-family subnet compare admits out-of-range addresses, unbounded parse diagnostic stalls process. Remove when direct dependency upgrades ip-address >=10.7.1. + "ip-address@<=10.7.0": "10.7.1" # GHSA-h67p-54hq-rp68: quadratic-complexity DoS in merge key handling. Remove when js-yaml 3.x >=3.15.0. # GHSA-5p4m-2wfm-xmqj: quadratic CPU consumption in !!omap resolution. Remove when js-yaml 3.x >=3.15.1. - "js-yaml@<3.15.1": "3.15.1" + # GHSA-2883-xcg3-v3hh: maxTotalMergeKeys does not limit CPU use for empty merge sources. Remove when js-yaml 3.x >=3.15.2. + "js-yaml@<3.15.2": "3.15.2" # GHSA-h67p-54hq-rp68 / GHSA-52cp-r559-cp3m: quadratic-complexity DoS in merge key handling. Remove when js-yaml 4.x >=4.3.0. # GHSA-5p4m-2wfm-xmqj: quadratic CPU consumption in !!omap resolution. Remove when js-yaml 4.x >=4.3.1. - "js-yaml@>=4.0.0 <4.3.1": "4.3.1" + # GHSA-2883-xcg3-v3hh: maxTotalMergeKeys does not limit CPU use for empty merge sources. Remove when js-yaml 4.x >=4.3.2. + "js-yaml@>=4.0.0 <4.3.2": "4.3.2" # GHSA-4x5r-pxfx-6jf8: arbitrary file read via sourceMappingURL. Remove when @babel/core >=7.29.6. "@babel/core@<=7.29.0": "7.29.7" # GHSA-xffm-g5w8-qvg7: ReDoS in ConfigCommentParser. Remove when @eslint/plugin-kit >=0.3.4. "@eslint/plugin-kit@<0.3.4": "0.3.4" # GHSA-3jxr-9vmj-r5cp: exponential-time expansion of consecutive non-expanding {} groups. Remove when brace-expansion 1.x >=1.1.16. # GHSA-mh99-v99m-4gvg / GHSA-rgw5-rvv9-x895: DoS via unbounded expansion length / unbounded intermediate arrays. Remove when brace-expansion 1.x >=1.1.18. - "brace-expansion@<1.1.18": "1.1.18" + # GHSA-6j4f-fj2g-mc7p / GHSA-qhr7-859c-m2p7: uncontrolled recursion in parseCommaParts / nested brace groups causing stack exhaustion. Remove when brace-expansion 1.x >=1.1.20. + # GHSA-q2hr-2g5m-vwhr: quadratic-time expansion of the `{a},b}` rewrite. Remove when brace-expansion 1.x >=1.1.21. + "brace-expansion@<1.1.21": "1.1.21" # GHSA-jxxr-4gwj-5jf2 / GHSA-3jxr-9vmj-r5cp: brace range / exponential-time expansion DoS. Remove when brace-expansion >=5.0.7. # GHSA-mh99-v99m-4gvg / GHSA-rgw5-rvv9-x895: DoS via unbounded expansion length / unbounded intermediate arrays. Remove when brace-expansion 5.x >=5.0.9. - "brace-expansion@>=4.0.0 <5.0.9": "5.0.9" + # GHSA-6j4f-fj2g-mc7p / GHSA-qhr7-859c-m2p7 / GHSA-q2hr-2g5m-vwhr: uncontrolled recursion stack exhaustion, quadratic-time `{a},b}` rewrite. Remove when brace-expansion 5.x >=5.0.12. + "brace-expansion@>=4.0.0 <5.0.12": "5.0.12" # GHSA-v2hh-gcrm-f6hx / GHSA-4c8g-83qw-93j6: host confusion via backslash authority / failed IDN canonicalization. Remove when fast-uri >=3.1.4. # GHSA-7p8r-x3mc-p8w7: host confusion via backslash authority introducer. Remove when fast-uri >=3.1.5. - "fast-uri@<3.1.5": "3.1.5" + # GHSA-jqff-g426-hqxp / GHSA-f65p-4m7j-42xc / GHSA-fph4-wmhf-6fwf / GHSA-5jgf-p345-68v8: host confusion and SSRF via percent-encoding / IPv6 normalization flaws. Remove when fast-uri >=3.1.6. + # GHSA-qw65-cvwx-89v3 / GHSA-hrr3-gc8f-f4qj: authority injection via unvalidated port, inconsistent host case normalization. Remove when fast-uri >=3.1.8. + "fast-uri@<3.1.8": "3.1.8" + # GHSA-6qxp-vccf-f47h: OAuth client could send credentials to an authorization server chosen by the MCP server. Remove when eslint's dependency upgrades @modelcontextprotocol/sdk >=1.31.0. + "@modelcontextprotocol/sdk@>=1.12.0 <1.31.0": "1.31.0" # GHSA-v422-hmwv-36x6: invalid limit value silently disables size enforcement. Remove when body-parser 2.x >=2.3.0. "body-parser@>=2.0.0 <2.3.0": "2.3.0" # GHSA-frvp-7c67-39w9: path traversal in serve-static on Windows via encoded backslash. Remove when @hono/node-server >=2.0.5 @@ -42,15 +56,20 @@ onlyBuiltDependencies: minimumReleaseAgeExclude: # Matches the overrides above; these versions are very recent but required for the security fixes. - "@eslint/plugin-kit@0.3.4" - - "ip-address@10.3.1" - - "qs@6.15.2" - - "brace-expansion@1.1.18" - - "brace-expansion@5.0.9" + - "ip-address@10.7.1" + - "qs@6.16.0" + - "brace-expansion@1.1.21" + - "brace-expansion@5.0.12" - "@babel/core@7.29.7" - - "js-yaml@3.15.1" - - "js-yaml@4.3.1" + - "js-yaml@3.15.2" + - "js-yaml@4.3.2" - "tar@7.5.21" - - "hono@4.12.34" - - "fast-uri@3.1.5" + - "hono@4.13.7" + - "fast-uri@3.1.8" - "body-parser@2.3.0" - "@hono/node-server@2.0.10" + - "@modelcontextprotocol/sdk@1.31.0" + +allowBuilds: + cpu-features: set this to true or false + unrs-resolver: set this to true or false diff --git a/src/cfg/setting.ts b/src/cfg/setting.ts index dbccfab..e30aecd 100644 --- a/src/cfg/setting.ts +++ b/src/cfg/setting.ts @@ -104,43 +104,87 @@ export const defaultSettings: Settings = { }, }; -export function readSettings(): Settings { +export interface ReadSettingsOptions { + strict?: boolean; +} + +export function readSettings(options?: ReadSettingsOptions): Settings { try { - if (fs.existsSync(configPath)) { - const data = fs.readFileSync(configPath, 'utf8'); - const parsed = JSON.parse(data); - validateSettings(parsed); - // Deep-clone defaults before merging to prevent mutation of the shared default - const settings = deepMerge(deepClone(defaultSettings), parsed) as Settings; - return upgradeFrozenBundledVersions(settings); - } else { - // Callers mutate the returned settings in place; never hand out the - // shared module-level defaults. - return deepClone(defaultSettings); + let data: string; + try { + data = fs.readFileSync(configPath, 'utf8'); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') { + // Callers mutate the returned settings in place; never hand out the + // shared module-level defaults. + return deepClone(defaultSettings); + } + throw err; } + const parsed = JSON.parse(data); + validateSettings(parsed); + // Deep-clone defaults before merging to prevent mutation of the shared default + const settings = deepMerge(deepClone(defaultSettings), parsed) as Settings; + return upgradeFrozenBundledVersions(settings); } catch (error) { + if (options?.strict) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to read settings from ${configPath}: ${reason}`); + } logger.error('Error reading settings:', error); return deepClone(defaultSettings); } } +export function readSettingsStrict(): Settings { + return readSettings({ strict: true }); +} + export function writeSettings(settings: Settings): void { + const dir = path.dirname(configPath); + // Don't persist the bundled ckb-tui version when it merely equals the + // shipped default: there is no CLI command that sets it, so an entry + // identical to the default is an artifact of dumping the merged settings, + // and writing it would freeze today's default into the user's config + // (readSettings would keep honoring it after a future bump). A version + // that differs from the default is a deliberate hand-edit and is kept. + const toWrite = deepClone(settings); + if (toWrite.tools?.ckbTui?.version === defaultSettings.tools.ckbTui.version) { + delete (toWrite.tools as Partial).ckbTui; + } + const content = JSON.stringify(toWrite, null, 2); + + const tempPath = path.join( + dir, + `.settings.json.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`, + ); + try { - fs.mkdirSync(path.dirname(configPath), { recursive: true }); - // Don't persist the bundled ckb-tui version when it merely equals the - // shipped default: there is no CLI command that sets it, so an entry - // identical to the default is an artifact of dumping the merged settings, - // and writing it would freeze today's default into the user's config - // (readSettings would keep honoring it after a future bump). A version - // that differs from the default is a deliberate hand-edit and is kept. - const toWrite = deepClone(settings); - if (toWrite.tools?.ckbTui?.version === defaultSettings.tools.ckbTui.version) { - delete (toWrite.tools as Partial).ckbTui; + fs.mkdirSync(dir, { recursive: true }); + + let existingMode: number | undefined; + try { + existingMode = fs.statSync(configPath).mode & 0o777; + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') { + throw err; + } + // file does not exist yet; use default file mode } - fs.writeFileSync(configPath, JSON.stringify(toWrite, null, 2)); + + fs.writeFileSync(tempPath, content, { encoding: 'utf8', mode: existingMode, flag: 'wx' }); + fs.renameSync(tempPath, configPath); logger.info('save new settings'); } catch (error) { - logger.error('Error writing settings:', error); + try { + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + } catch { + // ignore temp cleanup error + } + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to write settings to ${configPath}: ${reason}`); } } @@ -232,28 +276,48 @@ function deepMerge(target: any, source: any): any { } function validateSettings(raw: unknown): void { - if (!raw || typeof raw !== 'object') { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { throw new Error('Settings must be a JSON object'); } const obj = raw as Record; - if (obj.tools && typeof obj.tools === 'object') { + const checkObjectSection = (name: string, allowNullable = false) => { + if (obj[name] !== undefined) { + if (obj[name] === null && allowNullable) { + return; + } + if (typeof obj[name] !== 'object' || obj[name] === null || Array.isArray(obj[name])) { + throw new Error(`${name} must be an object`); + } + } + }; + + checkObjectSection('proxy', true); + checkObjectSection('bins', false); + checkObjectSection('devnet', false); + checkObjectSection('testnet', false); + checkObjectSection('mainnet', false); + checkObjectSection('tools', false); + + if (obj.tools && typeof obj.tools === 'object' && !Array.isArray(obj.tools)) { const tools = obj.tools as Record; if (tools.rootFolder !== undefined && typeof tools.rootFolder !== 'string') { throw new Error('tools.rootFolder must be a string path'); } - if (tools.ckbTui && typeof tools.ckbTui === 'object') { + if (tools.ckbDebugger !== undefined) { + if (typeof tools.ckbDebugger !== 'object' || tools.ckbDebugger === null || Array.isArray(tools.ckbDebugger)) { + throw new Error('tools.ckbDebugger must be an object'); + } + } + if (tools.ckbTui !== undefined) { + if (typeof tools.ckbTui !== 'object' || tools.ckbTui === null || Array.isArray(tools.ckbTui)) { + throw new Error('tools.ckbTui must be an object'); + } const ckbTui = tools.ckbTui as Record; if (ckbTui.version !== undefined && typeof ckbTui.version !== 'string') { throw new Error('tools.ckbTui.version must be a string'); } } } - - if (obj.proxy !== undefined && obj.proxy !== null) { - if (typeof obj.proxy !== 'object') { - throw new Error('proxy must be an object'); - } - } } diff --git a/src/cli.ts b/src/cli.ts index 6108cf6..53e7f0a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -283,7 +283,12 @@ program program .command('accounts') .description('Print account list info') - .option('--show-private-keys', 'Include built-in dev private keys (hidden by default)') + .addOption(new Option('--show-private-keys', 'Include built-in dev private keys (default, except on a Mainnet fork)')) + .addOption( + new Option('--hide-private-keys', 'Omit private keys, e.g. when output is captured in logs').conflicts( + 'showPrivateKeys', + ), + ) .action(async (options) => { await accounts(options); }); diff --git a/src/cmd/accounts.ts b/src/cmd/accounts.ts index 1eafab2..ba2dd87 100644 --- a/src/cmd/accounts.ts +++ b/src/cmd/accounts.ts @@ -8,12 +8,23 @@ import { logger } from '../util/logger'; export interface AccountsOptions { showPrivateKeys?: boolean; + hidePrivateKeys?: boolean; +} + +// The built-in dev keys are public test keys (see account/keys), so print them by default +// on a local devnet. A Mainnet fork re-encodes the same locks as real `ckb1` addresses, so +// keep the keys behind an explicit --show-private-keys there. +export function shouldShowPrivateKeys(options: AccountsOptions, isMainnetFork: boolean): boolean { + if (options.hidePrivateKeys) return false; + if (options.showPrivateKeys) return true; + return !isMainnetFork; } export async function accounts(options: AccountsOptions = {}) { const settings = readSettings(); const fork = readForkState(settings.devnet.configPath); const isMainnetFork = fork?.source === 'mainnet'; + const showPrivateKeys = shouldShowPrivateKeys(options, isMainnetFork); const client = isMainnetFork ? new ccc.ClientPublicMainnet({ url: settings.devnet.rpcUrl, fallbacks: [] }) : new ccc.ClientPublicTestnet({ url: settings.devnet.rpcUrl, fallbacks: [] }); @@ -54,7 +65,7 @@ export async function accounts(options: AccountsOptions = {}) { index, address, ...(spendableCkb == null ? {} : { spendableCkb }), - ...(options.showPrivateKeys ? { privkey: account.privkey } : {}), + ...(showPrivateKeys ? { privkey: account.privkey } : {}), pubkey: account.pubkey, lockArg: account.lockScript.args, lockScript: account.lockScript, @@ -67,7 +78,7 @@ export async function accounts(options: AccountsOptions = {}) { `- "#": ${account.index}`, `address: ${account.address}`, ...('spendableCkb' in account ? [`spendable_ckb: ${account.spendableCkb}`] : []), - ...(options.showPrivateKeys ? [`privkey: ${account.privkey}`] : []), + ...(showPrivateKeys ? [`privkey: ${account.privkey}`] : []), `pubkey: ${account.pubkey}`, `lock_arg: ${account.lockArg}`, 'lockScript:', @@ -82,8 +93,10 @@ export async function accounts(options: AccountsOptions = {}) { logger.info(details); }); - if (!options.showPrivateKeys) { - logger.info('Private keys are hidden by default. Use --show-private-keys only in a trusted local terminal.'); + if (!showPrivateKeys && isMainnetFork && !options.hidePrivateKeys) { + logger.info( + 'Private keys are hidden by default on a Mainnet fork. Use --show-private-keys only in a trusted local terminal.', + ); } logger.result({ command: 'accounts', context, forked: Boolean(fork), accounts: resolvedAccounts }); return resolvedAccounts; diff --git a/src/cmd/config.ts b/src/cmd/config.ts index f427643..5a599c4 100644 --- a/src/cmd/config.ts +++ b/src/cmd/config.ts @@ -1,4 +1,4 @@ -import { configPath, readSettings, writeSettings } from '../cfg/setting'; +import { configPath, readSettings, readSettingsStrict, writeSettings } from '../cfg/setting'; import { Request } from '../util/request'; import { isValidVersion } from '../util/validator'; import { logger } from '../util/logger'; @@ -63,7 +63,7 @@ export async function Config(action: ConfigAction, item: ConfigItem, value?: str } catch (error: unknown) { throw new Error(`invalid proxyURL: ${(error as Error).message}`); } - const settings = readSettings(); + const settings = readSettingsStrict(); settings.proxy = proxy; return writeSettings(settings); } @@ -74,7 +74,7 @@ export async function Config(action: ConfigAction, item: ConfigItem, value?: str `invalid version value, ${value}. Check available versions on https://github.com/nervosnetwork/ckb/tags`, ); } - const settings = readSettings(); + const settings = readSettingsStrict(); const version = extractVersion(value!); settings.bins.defaultCKBVersion = version; return writeSettings(settings); @@ -86,7 +86,7 @@ export async function Config(action: ConfigAction, item: ConfigItem, value?: str `invalid version value, ${value}. Check available versions on https://github.com/nervosnetwork/fiber/tags`, ); } - const settings = readSettings(); + const settings = readSettingsStrict(); const version = extractVersion(value!); settings.bins.defaultFnnVersion = version; return writeSettings(settings); @@ -100,7 +100,7 @@ export async function Config(action: ConfigAction, item: ConfigItem, value?: str if (action === ConfigAction.rm) { switch (item) { case ConfigItem.proxy: { - const settings = readSettings(); + const settings = readSettingsStrict(); settings.proxy = undefined; return writeSettings(settings); } diff --git a/src/cmd/devnet-config.ts b/src/cmd/devnet-config.ts index 55ad95a..bfc33f7 100644 --- a/src/cmd/devnet-config.ts +++ b/src/cmd/devnet-config.ts @@ -30,7 +30,12 @@ export function parseSetItem(item: string): ParsedSetItem { export function applySetItems(editor: ReturnType, items: string[]): ParsedSetItem[] { const parsedItems = items.map(parseSetItem); for (const parsedItem of parsedItems) { - editor.setFieldValue(parsedItem.key, parsedItem.value); + try { + editor.setFieldValue(parsedItem.key, parsedItem.value); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid value '${parsedItem.value}' for '${parsedItem.key}'. ${reason}`); + } } editor.save(); return parsedItems; diff --git a/src/cmd/logs.ts b/src/cmd/logs.ts index 1885e07..8cedf78 100644 --- a/src/cmd/logs.ts +++ b/src/cmd/logs.ts @@ -5,6 +5,7 @@ import { filterLinesByTarget, followLogFile, grepLines, + isScriptDebugRecord, parseCkbLogLine, readLogTail, resolveLogPath, @@ -53,7 +54,7 @@ export function showLogs(target: LogTarget, options: LogsOptions, settings: Sett if (scriptOnly) { // Unparsable lines are continuations of the previous entry. const parsed = parseCkbLogLine(line); - if (parsed) inScriptEntry = parsed.target === SCRIPT_LOG_TARGET; + if (parsed) inScriptEntry = isScriptDebugRecord(parsed); show = inScriptEntry; } if (show && options.grep && !line.includes(options.grep)) show = false; diff --git a/src/cmd/node.ts b/src/cmd/node.ts index b7c43bb..3515c0d 100644 --- a/src/cmd/node.ts +++ b/src/cmd/node.ts @@ -16,7 +16,7 @@ import { Network } from '../type/base'; import { logger } from '../util/logger'; import { checkNodeReadiness, waitForNodeReady } from '../devnet/readiness'; import { devnetTcpListenAddress, subscribeToNodeLogs, SubscriptionHandle } from '../devnet/log-subscription'; -import { SCRIPT_LOG_TARGET } from '../devnet/log-file'; +import { isScriptDebugRecord } from '../devnet/log-file'; import { cleanupPidFile, closeFileDescriptors, @@ -41,6 +41,9 @@ import { readRuntime } from '../fiber/runtime'; import { enterGracefulShutdown } from '../util/shutdown'; import { fiberDaemonPaths } from '../fiber/paths'; import { assertNodeStopDoesNotOrphanFiber, FIBER_DAEMON_READY_TIMEOUT_MS } from '../fiber/daemon'; +import { bindChildrenToProcessLifetime, ChildLifetimeGuard } from '../util/child-lifetime'; +import { installSessionSignalHandlers, SessionSignalHandlers } from '../util/session-signals'; +import { minerArgs, reapStaleMiners } from '../devnet/stale-miner'; export interface NodeProp { version?: string; @@ -190,10 +193,55 @@ async function runNodeDevnet( logger.info(`Forked devnet (${forkState.source}) detected, first run uses --skip-spec-check --overwrite-spec.`); } + // Miners orphaned by an earlier run that died without cleanup (#512) would + // keep polling the RPC and start mining on this node as extra miners. + await reapStaleMiners(devnetConfigPath); + + // From here on, the CKB node and miner are stopped whenever this process + // exits (crash, process.exit), and SIGINT/SIGTERM/SIGHUP always end in an + // exit that runs that hook. + const lifetime = bindChildrenToProcessLifetime(); + const signals = installSessionSignalHandlers(); + try { + return await superviseDevnet( + { verbose, fiber, fnnVersion, fiberNodes, fnnBinaryPath }, + { ckbBinPath, devnetConfigPath, forkState, firstRunFlags }, + envLock, + settings, + { lifetime, signals }, + ); + } catch (error) { + // Startup failed: whatever was spawned must not outlive this attempt, + // even if the failing path did not stop it itself. + lifetime.stopAll(); + lifetime.dispose(); + signals.dispose(); + throw error; + } +} + +async function superviseDevnet( + { verbose, fiber, fnnVersion, fiberNodes, fnnBinaryPath }: NodeProp, + { + ckbBinPath, + devnetConfigPath, + forkState, + firstRunFlags, + }: { + ckbBinPath: string; + devnetConfigPath: string; + forkState: ReturnType; + firstRunFlags: string; + }, + envLock: EnvLockHandle | null, + settings: Settings, + { lifetime, signals }: { lifetime: ChildLifetimeGuard; signals: SessionSignalHandlers }, +) { logger.info(`Launching CKB devnet Node...`); const runArgs = ['run', '-C', devnetConfigPath]; if (firstRunFlags) runArgs.push('--skip-spec-check', '--overwrite-spec'); const ckbProcess = spawn(ckbBinPath, runArgs, { stdio: ['ignore', 'pipe', 'pipe'] }); + lifetime.track(ckbProcess); // Quiet by default: the node keeps its full log in data/logs/run.log // (see `offckb logs`), and contract script debug output streams over the // TCP log subscription below. --verbose restores the raw stdout/stderr relay. @@ -256,7 +304,8 @@ async function runNodeDevnet( let minerProcess: ChildProcess; try { - minerProcess = spawn(ckbBinPath, ['miner', '-C', devnetConfigPath], { stdio: ['ignore', 'pipe', 'pipe'] }); + minerProcess = spawn(ckbBinPath, minerArgs(devnetConfigPath), { stdio: ['ignore', 'pipe', 'pipe'] }); + lifetime.track(minerProcess); } catch (error) { ckbProcess.kill('SIGTERM'); throw new Error(`CKB miner failed to start: ${(error as Error).message}`); @@ -290,7 +339,7 @@ async function runNodeDevnet( logSubscription = subscribeToNodeLogs( tcpAddress, (entry) => { - if (entry.target === SCRIPT_LOG_TARGET) logger.info(['CKB-Script:', cleanChildOutput(entry.message)]); + if (isScriptDebugRecord(entry)) logger.info(['CKB-Script:', cleanChildOutput(entry.message)]); }, (error) => logger.warn(`${error.message} Full logs remain available via: offckb logs -f`), ); @@ -317,13 +366,20 @@ async function runNodeDevnet( }; try { const fnn = await fnnPrep; - fiberEnv = await startFiberEnvironment({ - fnnPath: fnn.fnnPath, - testnetConfigPath: fnn.testnetConfigPath, - chainScripts: resolveFiberChainScripts(), - nodeCount: fiberNodes, - settings, - }); + // startFiberEnvironment owns the signals while it starts the FNNs: it + // stops them and exits, and our exit hook then stops CKB and the miner. + const endDelegation = signals.delegate(); + try { + fiberEnv = await startFiberEnvironment({ + fnnPath: fnn.fnnPath, + testnetConfigPath: fnn.testnetConfigPath, + chainScripts: resolveFiberChainScripts(), + nodeCount: fiberNodes, + settings, + }); + } finally { + endDelegation(); + } } catch (error) { stopStartedProcesses(); throw error; @@ -356,7 +412,7 @@ async function runNodeDevnet( // failure and sets the exit code; the signal path reports its own code. type ShutdownTrigger = | { component: string; code: number | null; signal: NodeJS.Signals | null } - | { signal: 'SIGINT' | 'SIGTERM' }; + | { signal: NodeJS.Signals }; let shutdownPromise: Promise | null = null; const runShutdownOnce = (trigger: ShutdownTrigger): Promise => { if (shutdownPromise) return shutdownPromise; @@ -374,7 +430,9 @@ async function runNodeDevnet( } if (process.env[DAEMON_CHILD_ENV] === '1') cleanupPidFile(resolveDaemonPaths().pidFile); envLock?.release(); + lifetime.dispose(); if ('component' in trigger) { + signals.dispose(); logger.error( `${trigger.component} exited unexpectedly (code=${trigger.code ?? 'null'}, signal=${trigger.signal ?? 'none'}).`, ); @@ -388,34 +446,23 @@ async function runNodeDevnet( }; ckbProcess.once('exit', (code, signal) => stopService('CKB node', code, signal)); minerProcess.once('exit', (code, signal) => stopService('CKB miner', code, signal)); + // A component that already exited (e.g. during the Fiber startup above) + // will not emit 'exit' again; stop the rest now instead of leaving them up. + for (const [component, child] of [ + ['CKB node', ckbProcess], + ['CKB miner', minerProcess], + ] as const) { + if (child.exitCode != null || child.signalCode != null) stopService(component, child.exitCode, child.signalCode); + } if (fiberEnv) { for (const node of fiberEnv.nodes) { node.process.once('exit', (code, signal) => stopService(`FNN node ${node.id}`, code, signal)); } - installFiberSignalHandlers(runShutdownOnce); } -} - -// With --fiber the process group contains FNNs whose runtime.json should not -// outlive a clean shutdown. Stop the whole group on Ctrl+C/SIGTERM instead of -// letting each process fend for itself. The cleanup itself is shared with the -// component-exit path via runShutdownOnce; this only adds the exit code. -function installFiberSignalHandlers(runShutdownOnce: (trigger: { signal: 'SIGINT' | 'SIGTERM' }) => Promise) { - let handling = false; - const handler = (signal: 'SIGINT' | 'SIGTERM') => { - if (handling) return; - handling = true; - // Set before the first log line: with piped output the reader may die - // with this same signal, and an EPIPE must not abort the shutdown. - enterGracefulShutdown(); - void (async () => { - logger.info(`Received ${signal}, stopping the devnet and fiber nodes...`); - await runShutdownOnce({ signal }); - process.exit(signal === 'SIGINT' ? 130 : 143); - })(); - }; - process.once('SIGINT', () => handler('SIGINT')); - process.once('SIGTERM', () => handler('SIGTERM')); + // Ctrl+C/SIGTERM/SIGHUP stop the whole service through the same shared + // cleanup as a component exit (FNN runtime.json, daemon pid file, env lock), + // then exit with 128+n. + signals.setShutdown(fiberEnv ? 'the devnet and fiber nodes' : 'the devnet', (signal) => runShutdownOnce({ signal })); } // CKB < 0.205.0 rejects the Terminal RPC module during config deserialization diff --git a/src/devnet/log-file.ts b/src/devnet/log-file.ts index ca8a78e..470c715 100644 --- a/src/devnet/log-file.ts +++ b/src/devnet/log-file.ts @@ -33,6 +33,14 @@ const CKB_LOG_LINE = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)? [+-]\d{2}: export const SCRIPT_LOG_TARGET = 'ckb-script'; +export function isScriptDebugRecord(entry: { target: string; message: string }): boolean { + if (entry.target === SCRIPT_LOG_TARGET) { + return true; + } + const isCkbScriptTarget = entry.target === 'ckb_script' || entry.target.startsWith('ckb_script::'); + return isCkbScriptTarget && entry.message.includes('DEBUG OUTPUT:'); +} + export function parseCkbLogLine(line: string): CkbLogLine | null { const match = line.match(CKB_LOG_LINE); if (!match) return null; @@ -50,7 +58,7 @@ export function filterLinesByTarget(lines: string[], target: string): string[] { for (const line of lines) { const parsed = parseCkbLogLine(line); if (parsed) { - previousKept = parsed.target === target; + previousKept = target === SCRIPT_LOG_TARGET ? isScriptDebugRecord(parsed) : parsed.target === target; if (previousKept) kept.push(line); } else if (previousKept) { kept.push(line); diff --git a/src/devnet/stale-miner.ts b/src/devnet/stale-miner.ts new file mode 100644 index 0000000..6ae3518 --- /dev/null +++ b/src/devnet/stale-miner.ts @@ -0,0 +1,183 @@ +import { execFile } from 'child_process'; +import { isProcessAlive, NODE_EXECUTABLE_NAMES, waitForProcessExit } from '../util/daemon'; +import { logger } from '../util/logger'; + +export interface ProcessEntry { + pid: number; + ppid: number; + // Executable name or path (ps comm / Win32_Process Name). + name: string; + // Full command line. + args: string; +} + +const CKB_EXECUTABLE_NAMES = new Set(['ckb', 'ckb.exe']); +const LIST_TIMEOUT_MS = 15_000; +const STOP_TIMEOUT_MS = 5_000; + +/** + * The arguments offckb starts the devnet miner with. Single source of truth + * for the spawn site (cmd/node.ts) and the stale-miner matcher below. + */ +export function minerArgs(configPath: string): string[] { + return ['miner', '-C', configPath]; +} + +// Node's Windows argv quoting for the arguments we pass: wrap in double +// quotes when the argument contains whitespace or is empty. +function quoteWindowsArg(arg: string): string { + return arg === '' || /[\s"]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg; +} + +// Executables (ps/CIM) may be quoted on Windows and use either separator. +function basename(executable: string): string { + const parts = executable + .trim() + .replace(/^["']|["']$/g, '') + .split(/[\\/]/); + return (parts[parts.length - 1] ?? '').toLowerCase(); +} + +/** + * Whether `args` is exactly the command offckb uses to start the devnet miner: + * `<.../ckb> ${minerArgs(configPath)}`. The executable must be named `ckb` + * and the arguments must match exactly. ps prints argv joined by spaces + * (paths may contain spaces, so the match is anchored at the end rather than + * tokenized); Windows reports the quoted command line. + */ +export function isMinerCommandFor(args: string, configPath: string): boolean { + const trimmed = args.trim(); + const argv = minerArgs(configPath); + for (const suffix of [` ${argv.join(' ')}`, ` ${argv.map(quoteWindowsArg).join(' ')}`]) { + if (trimmed.endsWith(suffix)) { + return CKB_EXECUTABLE_NAMES.has(basename(trimmed.slice(0, -suffix.length))); + } + } + return false; +} + +/** + * Pick the `ckb miner` processes for this devnet dir whose offckb parent is + * gone. A live offckb session keeps its miner as a direct child of its Node + * process; once that process dies the miner is re-parented (init, launchd or + * a subreaper) or, on Windows, points at a dead parent PID. Miners whose + * parent is still a Node process are left alone. + */ +export function findStaleMiners(entries: ProcessEntry[], configPath: string, selfPid = process.pid): ProcessEntry[] { + const byPid = new Map(entries.map((entry) => [entry.pid, entry])); + return entries.filter((entry) => { + if (entry.pid === selfPid || !isMinerCommandFor(entry.args, configPath)) return false; + const parent = byPid.get(entry.ppid); + return parent == null || !NODE_EXECUTABLE_NAMES.has(basename(parent.name)); + }); +} + +function execText(command: string, args: string[]): Promise { + return new Promise((resolve) => { + execFile( + command, + args, + { timeout: LIST_TIMEOUT_MS, maxBuffer: 32 * 1024 * 1024, env: { ...process.env, LC_ALL: 'C' } }, + (error, stdout) => resolve(error ? null : stdout), + ); + }); +} + +/** + * Parse `ps -A -ww -o pid=,ppid=,args=` and `ps -A -ww -o pid=,comm=`. Two + * passes because comm (macOS: full executable path) and args may both + * contain spaces, so they cannot share one line unambiguously. + */ +export function parsePosixProcessList(argsOut: string, commOut: string): ProcessEntry[] { + const names = new Map(); + for (const line of commOut.split('\n')) { + const match = /^\s*(\d+)\s+(.*)$/.exec(line); + if (match) names.set(Number(match[1]), match[2].trim()); + } + const entries: ProcessEntry[] = []; + for (const line of argsOut.split('\n')) { + const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line); + if (!match) continue; + const pid = Number(match[1]); + entries.push({ pid, ppid: Number(match[2]), name: names.get(pid) ?? '', args: match[3].trim() }); + } + return entries; +} + +/** + * Parse `Get-CimInstance Win32_Process | Select-Object ProcessId, + * ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress`. ConvertTo-Json + * emits a bare object for a single row. Returns null for malformed output. + */ +export function parseWindowsProcessList(json: string): ProcessEntry[] | null { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return null; + } + if (parsed == null || typeof parsed !== 'object') return null; + const rows = (Array.isArray(parsed) ? parsed : [parsed]) as Record[]; + return rows + .filter((row) => row != null && typeof row === 'object') + .map((row) => ({ + pid: Number(row.ProcessId), + ppid: Number(row.ParentProcessId), + name: typeof row.Name === 'string' ? row.Name : '', + args: typeof row.CommandLine === 'string' ? row.CommandLine : '', + })) + .filter((entry) => Number.isInteger(entry.pid) && entry.pid > 0); +} + +async function listPosixProcesses(): Promise { + const [argsOut, commOut] = await Promise.all([ + execText('ps', ['-A', '-ww', '-o', 'pid=,ppid=,args=']), + execText('ps', ['-A', '-ww', '-o', 'pid=,comm=']), + ]); + if (argsOut == null || commOut == null) return null; + return parsePosixProcessList(argsOut, commOut); +} + +async function listWindowsProcesses(): Promise { + const script = + 'Get-CimInstance Win32_Process | ' + + 'Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress'; + const text = await execText('powershell', ['-NoProfile', '-Command', script]); + return text == null ? null : parseWindowsProcessList(text); +} + +export function listProcesses(): Promise { + return process.platform === 'win32' ? listWindowsProcesses() : listPosixProcesses(); +} + +/** + * Stop `ckb miner -C ` processes left behind by an earlier offckb + * run (#512) before a new devnet starts. Best effort: a failure to list or + * signal processes is logged and never blocks the start. Returns the PIDs + * that were stopped. + */ +export async function reapStaleMiners(configPath: string): Promise { + const entries = await listProcesses(); + if (entries == null) { + logger.debug('Could not list processes; skipping the stale CKB miner check.'); + return []; + } + const reaped: number[] = []; + for (const miner of findStaleMiners(entries, configPath)) { + logger.warn( + `Stopping stale CKB miner (PID ${miner.pid}) for this devnet: it is not attached to a running offckb process.`, + ); + try { + process.kill(miner.pid, 'SIGTERM'); + if (!(await waitForProcessExit(miner.pid, STOP_TIMEOUT_MS)) && isProcessAlive(miner.pid)) { + process.kill(miner.pid, 'SIGKILL'); + } + reaped.push(miner.pid); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === 'ESRCH') continue; + logger.warn(`Failed to stop stale CKB miner (PID ${miner.pid}): ${err.message}`); + } + } + return reaped; +} diff --git a/src/fiber/manager.ts b/src/fiber/manager.ts index c3ba16f..9b8dd9a 100644 --- a/src/fiber/manager.ts +++ b/src/fiber/manager.ts @@ -27,6 +27,7 @@ import { } from './runtime'; import { closeFileDescriptors } from '../util/daemon'; import { enterGracefulShutdown } from '../util/shutdown'; +import { LifetimeSignal, signalExitCode } from '../util/child-lifetime'; export interface FnnProcessHandle { id: number; @@ -326,7 +327,7 @@ export async function startFiberEnvironment(options: StartFiberEnvironmentOption // runtime record — OffCKB would refuse to touch those orphans. const handles: FnnProcessHandle[] = []; let signalCleanupStarted = false; - const onStartupSignal = (signal: 'SIGINT' | 'SIGTERM') => { + const onStartupSignal = (signal: LifetimeSignal) => { // Set before the first log line of the cleanup: with piped output the // reader may die with this same signal, and an EPIPE must not abort the // shutdown (see util/shutdown.ts). @@ -338,16 +339,19 @@ export async function startFiberEnvironment(options: StartFiberEnvironmentOption // Same cleanup as the post-ready stop path: SIGTERM the children, // escalate to SIGKILL after the grace period, drop the runtime record. await stopFiberNodes(handles, settings); - process.exit(signal === 'SIGINT' ? 130 : 143); + process.exit(signalExitCode(signal)); })(); }; const onSigint = () => onStartupSignal('SIGINT'); const onSigterm = () => onStartupSignal('SIGTERM'); + const onSighup = () => onStartupSignal('SIGHUP'); process.once('SIGINT', onSigint); process.once('SIGTERM', onSigterm); + process.once('SIGHUP', onSighup); const removeStartupSignalHandlers = () => { process.removeListener('SIGINT', onSigint); process.removeListener('SIGTERM', onSigterm); + process.removeListener('SIGHUP', onSighup); }; try { diff --git a/src/sdk/ckb.ts b/src/sdk/ckb.ts index f9f70d5..b22e6b1 100644 --- a/src/sdk/ckb.ts +++ b/src/sdk/ckb.ts @@ -520,17 +520,13 @@ export class CKB { throw new Error(`Insufficient UDT balance: ${totalBalance} < ${destroyAmount}`); } - if (destroyAmount === totalBalance) { - throw new Error( - 'Destroying the entire UDT balance may be rejected by the UDT script. Leave at least 1 token or use a smaller amount.', - ); - } - const tx = ccc.Transaction.from({}); for (const cell of cells) { tx.addInput({ previousOutput: cell.outPoint }); } + // A full-balance destroy is valid for SUDT and plain xUDT (no extensions): + // remaining is 0 and the zero-balance type cell below is still emitted. const remaining = totalBalance - destroyAmount; tx.addOutput( ccc.CellOutput.from( diff --git a/src/util/child-lifetime.ts b/src/util/child-lifetime.ts new file mode 100644 index 0000000..109cde8 --- /dev/null +++ b/src/util/child-lifetime.ts @@ -0,0 +1,65 @@ +import { ChildProcess } from 'child_process'; + +// Conventional 128 + signal number exit codes. +const SIGNAL_EXIT_CODES = { SIGHUP: 129, SIGINT: 130, SIGTERM: 143 } as const; +export type LifetimeSignal = keyof typeof SIGNAL_EXIT_CODES; +export const LIFETIME_SIGNALS = Object.keys(SIGNAL_EXIT_CODES) as LifetimeSignal[]; + +export function signalExitCode(signal: LifetimeSignal): number { + return SIGNAL_EXIT_CODES[signal]; +} + +export interface ChildLifetimeGuard { + track(child: ChildProcess): void; + /** SIGTERM every tracked child that is still running. */ + stopAll(): void; + /** Remove the exit hook. Does not signal the children; call stopAll() first if needed. */ + dispose(): void; +} + +function isRunning(child: ChildProcess): boolean { + return child.exitCode == null && child.signalCode == null; +} + +/** + * Tie tracked child processes to the lifetime of this offckb process. + * + * Children spawned without `detached` are NOT stopped when their parent dies: + * a crash or a `process.exit()` (e.g. the broken-pipe handler) leaves them + * running, re-parented to init/launchd. For `ckb miner` that means polling a + * dead RPC forever (#512). + * + * The guard installs a process 'exit' hook — it runs on process.exit, a + * natural end, an uncaught exception and an unhandled rejection — that sends + * SIGTERM to every tracked child still running. It deliberately does not + * touch signal handling: a signal only reaches this hook if its handler + * exits the process, so the command that spawns the children must own + * SIGINT/SIGTERM/SIGHUP explicitly (see cmd/node.ts). + * + * An uncatchable SIGKILL cannot be handled here; stale miners left by one are + * reaped on the next start (see devnet/stale-miner.ts). + */ +export function bindChildrenToProcessLifetime(): ChildLifetimeGuard { + const children = new Set(); + const stopAll = () => { + for (const child of children) { + if (!isRunning(child)) continue; + try { + child.kill('SIGTERM'); + } catch { + // Already gone; nothing to clean up. + } + } + }; + process.on('exit', stopAll); + + return { + track(child) { + children.add(child); + }, + stopAll, + dispose() { + process.removeListener('exit', stopAll); + }, + }; +} diff --git a/src/util/daemon.ts b/src/util/daemon.ts index bfaa55f..93e0d3d 100644 --- a/src/util/daemon.ts +++ b/src/util/daemon.ts @@ -181,7 +181,7 @@ export interface ProcessInfo { // Executables allowed to host the offckb CLI entry script. Compared by exact // basename — never by substring, which any path containing "node" would pass. -const NODE_EXECUTABLE_NAMES = new Set(['node', 'nodejs', 'node.exe']); +export const NODE_EXECUTABLE_NAMES: ReadonlySet = new Set(['node', 'nodejs', 'node.exe']); // How closely the live process start time must match the pid file's // startedAt. The file is written immediately after spawn, so the true delta @@ -302,8 +302,12 @@ function parsePsLstart(text: string): number | null { const PROCESS_PROBE_TIMEOUT_MS = 15_000; function execFileText(command: string, args: string[]): Promise { + const options: { timeout: number; env?: NodeJS.ProcessEnv } = { timeout: PROCESS_PROBE_TIMEOUT_MS }; + if (command === 'ps') { + options.env = { ...process.env, LC_ALL: 'C' }; + } return new Promise((resolve) => { - execFile(command, args, { timeout: PROCESS_PROBE_TIMEOUT_MS }, (error, stdout) => { + execFile(command, args, options, (error, stdout) => { if (error) { resolve(null); return; @@ -371,25 +375,26 @@ export async function getProcessCommandLine(pid: number): Promise return info.cmdline; } -// Split a flat command line into tokens, honoring single/double quotes. Used -// on platforms without /proc; the first two tokens (executable, script) are -// all the identity check consumes, and both are spawned by us without shell -// metacharacters, so a simple tokenizer suffices. -function splitCommandLine(cmdline: string): string[] { - const tokens: string[] = []; - const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g; - let match: RegExpExecArray | null; - while ((match = pattern.exec(cmdline)) !== null) { - tokens.push(match[1] ?? match[2] ?? match[3]); +// Path rules for comparing process paths. `platform` is only passed by unit +// tests that exercise another OS's command lines; by default the host's own +// `path` module is used (not process.platform, which tests may override). +type PathRules = { api: path.PlatformPath; native: boolean; caseInsensitive: boolean }; + +function pathRules(platform?: NodeJS.Platform): PathRules { + if (platform == null) { + return { api: path, native: true, caseInsensitive: path.sep === '\\' }; } - return tokens; + const api = platform === 'win32' ? path.win32 : path.posix; + return { api, native: (platform === 'win32') === (path.sep === '\\'), caseInsensitive: platform === 'win32' }; } // Resolve symlinks on both sides before comparing (nvm shims, // /usr/bin/node → /etc/alternatives, symlinked install prefixes). A path -// that cannot be resolved still compares by its absolute form. -function normalizePathForCompare(candidate: string): string { - const resolved = path.resolve(candidate); +// that cannot be resolved still compares by its absolute form. Paths of +// another OS (unit tests) are only resolved lexically. +function normalizePathForCompare(candidate: string, rules: PathRules = pathRules()): string { + const resolved = rules.api.resolve(candidate); + if (!rules.native) return resolved; try { return fs.realpathSync(resolved); } catch { @@ -397,11 +402,69 @@ function normalizePathForCompare(candidate: string): string { } } -function executableLooksLikeNode(executable: string): boolean { - if (normalizePathForCompare(executable) === normalizePathForCompare(process.execPath)) { +// Windows file systems are case-insensitive. +function samePath(a: string, b: string, rules: PathRules): boolean { + return rules.caseInsensitive ? a.toLowerCase() === b.toLowerCase() : a === b; +} + +function executableLooksLikeNode(executable: string, rules: PathRules = pathRules()): boolean { + if (rules.native && samePath(normalizePathForCompare(executable), normalizePathForCompare(process.execPath), rules)) { return true; } - return NODE_EXECUTABLE_NAMES.has(path.basename(executable).toLowerCase()); + return NODE_EXECUTABLE_NAMES.has(rules.api.basename(executable).toLowerCase()); +} + +// A piece of a flat command line, optionally wrapped in one pair of quotes +// (Windows quotes arguments that contain spaces). Stray quotes inside an +// unquoted piece mean the split point is not a real argument boundary. +function unquoteCommandLinePiece(piece: string): string | null { + if ( + piece.length >= 2 && + ((piece.startsWith('"') && piece.endsWith('"')) || (piece.startsWith("'") && piece.endsWith("'"))) + ) { + return piece.slice(1, -1); + } + if (piece.length === 0 || piece.includes('"')) return null; + return piece; +} + +// Bounds the split search on a hostile or unusually long command line; real +// executable + script paths have far fewer spaces. +const MAX_COMMAND_LINE_SPLIT_POINTS = 64; + +/** + * Whether a flat command line — `ps -o args=` (argv joined by single spaces, + * never quoted) or a Windows CommandLine (arguments with spaces quoted) — + * starts with ` `, followed by the end or a + * space. + * + * The line is NOT tokenized: unquoted paths may contain spaces (macOS + * `~/Library/Application Support/...`), so splitting at the first space + * would cut the executable or the script apart. Instead every space is tried + * as the executable/script boundary and every later space (or the end) as + * the script's end. A split only matches when the executable is a Node + * runtime and the script resolves to exactly `expectedScript` — the same + * strict identity as for an exact argv, without guessing token boundaries. + */ +export function commandLineRunsScript(cmdline: string, expectedScript: string, platform?: NodeJS.Platform): boolean { + const rules = pathRules(platform); + const text = cmdline.trim(); + const expected = normalizePathForCompare(expectedScript, rules); + const spaces: number[] = []; + for (let i = 0; i < text.length && spaces.length < MAX_COMMAND_LINE_SPLIT_POINTS; i++) { + if (text[i] === ' ') spaces.push(i); + } + const scriptEnds = [...spaces, text.length]; + for (const executableEnd of spaces) { + const executable = unquoteCommandLinePiece(text.slice(0, executableEnd)); + if (executable == null || !executableLooksLikeNode(executable, rules)) continue; + for (const scriptEnd of scriptEnds) { + if (scriptEnd <= executableEnd + 1) continue; + const script = unquoteCommandLinePiece(text.slice(executableEnd + 1, scriptEnd)); + if (script != null && samePath(normalizePathForCompare(script, rules), expected, rules)) return true; + } + } + return false; } /** @@ -413,7 +476,10 @@ function executableLooksLikeNode(executable: string): boolean { * 2. its first argument must be THIS installation's CLI entry script * (resolveCliEntry of the verifying process, realpath-normalized) — * the pid file's scriptPath is never consulted, so a stale or forged - * pid file cannot lend our identity to an unrelated process; + * pid file cannot lend our identity to an unrelated process. When only + * a flat cmdline is available (ps / Windows), that is matched by + * commandLineRunsScript rather than by tokenizing, so paths that + * contain spaces still resolve; * 3. when the pid file carries a real startedAt (everything written by * current versions does), the process start time must match it, which * defeats PID reuse. If the process start time cannot be determined @@ -428,21 +494,19 @@ export async function verifyDaemonIdentity(pid: number, metadata: PidMetadata): // Without our own entry point we cannot establish identity at all. return false; } - const expectedScript = normalizePathForCompare(cliEntry); - const info = await getProcessInfo(pid); if (!info) return false; - let tokens: string[] | null = null; if (info.argv != null) { - tokens = info.argv; - } else if (info.cmdline != null) { - tokens = splitCommandLine(info.cmdline); + // Exact argv (Linux /proc): compare the first two arguments directly. + if (info.argv.length < 2) return false; + if (!executableLooksLikeNode(info.argv[0])) return false; + if (!samePath(normalizePathForCompare(info.argv[1]), normalizePathForCompare(cliEntry), pathRules())) { + return false; + } + } else if (info.cmdline == null || !commandLineRunsScript(info.cmdline, cliEntry)) { + return false; } - if (tokens == null || tokens.length < 2) return false; - - if (!executableLooksLikeNode(tokens[0])) return false; - if (normalizePathForCompare(tokens[1]) !== expectedScript) return false; const recordedMs = Date.parse(metadata.startedAt); if (Number.isFinite(recordedMs) && recordedMs > 0) { diff --git a/src/util/session-signals.ts b/src/util/session-signals.ts new file mode 100644 index 0000000..4903a99 --- /dev/null +++ b/src/util/session-signals.ts @@ -0,0 +1,82 @@ +import { LIFETIME_SIGNALS, LifetimeSignal, signalExitCode } from './child-lifetime'; +import { enterGracefulShutdown } from './shutdown'; +import { logger } from './logger'; + +export interface SessionSignalHandlers { + /** + * Hand SIGINT/SIGTERM/SIGHUP to a nested flow that installs its own + * handlers for a known window (the Fiber startup flow). Call the returned + * function when that window ends. + */ + delegate(): () => void; + /** Once the session is up: run `shutdown`, then exit with 128+n. */ + setShutdown(description: string, shutdown: (signal: LifetimeSignal) => Promise): void; + dispose(): void; +} + +/** + * The single owner of SIGINT/SIGTERM/SIGHUP for a long-running session such + * as `offckb node`. Ownership is explicit per phase rather than inferred from + * listener counts: + * + * - startup (default): exit with 128+n right away; the child lifetime + * guard's 'exit' hook stops the processes spawned so far. + * - delegated: a nested flow with its own handlers owns the signals (it + * exits the process itself). A second signal still forces an exit. + * - running: run the session's shutdown, then exit with 128+n. A second + * signal while it is in progress forces an exit. + * + * Every phase ends the process, so a signal can never be swallowed. + */ +export function installSessionSignalHandlers(): SessionSignalHandlers { + let delegated = false; + let running: { description: string; shutdown: (signal: LifetimeSignal) => Promise } | null = null; + let handling = false; + + const handle = (signal: LifetimeSignal) => { + const code = signalExitCode(signal); + if (handling) { + process.exit(code); + return; + } + handling = true; + if (delegated) return; + // Set before the first log line: with piped output the reader may die + // with this same signal, and an EPIPE must not abort the shutdown. + enterGracefulShutdown(); + if (!running) { + process.exit(code); + return; + } + const { description, shutdown } = running; + void (async () => { + logger.info(`Received ${signal}, stopping ${description}...`); + try { + await shutdown(signal); + } catch (error) { + logger.error(`Cleanup after ${signal} failed: ${(error as Error).message}`); + } + process.exit(code); + })(); + }; + + const listeners = LIFETIME_SIGNALS.map((signal) => [signal, () => handle(signal)] as const); + for (const [signal, listener] of listeners) process.on(signal, listener); + + return { + delegate() { + delegated = true; + return () => { + delegated = false; + // A signal seen while delegated was handled (or is being handled) by + // the nested flow; one more signal forces the exit. + }; + }, + setShutdown(description, shutdown) { + running = { description, shutdown }; + }, + dispose() { + for (const [signal, listener] of listeners) process.removeListener(signal, listener); + }, + }; +} diff --git a/tests/accounts.test.ts b/tests/accounts.test.ts index 94956fc..29c8f06 100644 --- a/tests/accounts.test.ts +++ b/tests/accounts.test.ts @@ -25,7 +25,10 @@ describe('accounts command', () => { it('uses ckt addresses and the genesis funding statement on a pure devnet', async () => { const result = await accounts(); expect(result[0].address).toMatch(/^ckt1/); - expect(result[0].privkey).toBeUndefined(); + expect(result[0].privkey).toMatch(/^0x[0-9a-f]{64}$/); + expect(logger.info).toHaveBeenCalledWith( + expect.arrayContaining([expect.stringMatching(/^privkey: 0x[0-9a-f]{64}$/)]), + ); expect(logger.info).toHaveBeenCalledWith( expect.arrayContaining([expect.stringContaining('funded with 42_000_000_00000000')]), ); @@ -35,14 +38,34 @@ describe('accounts command', () => { mockFork = { source: 'mainnet' }; const result = await accounts(); expect(result[0].address).toMatch(/^ckb1/); + expect(result[0].privkey).toBeUndefined(); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('hidden by default on a Mainnet fork')); expect(logger.info).toHaveBeenCalledWith( expect.arrayContaining([expect.stringContaining('do not include the standard offckb genesis allocation')]), ); expect(logger.result).toHaveBeenCalledWith(expect.objectContaining({ context: 'DEVNET (fork of MAINNET)' })); }); - it('reveals dev private keys only after an explicit option', async () => { + it('shows dev private keys on a Testnet fork', async () => { + mockFork = { source: 'testnet' }; + const result = await accounts(); + expect(result[0].privkey).toMatch(/^0x[0-9a-f]{64}$/); + }); + + it('reveals dev private keys on a Mainnet fork after an explicit option', async () => { + mockFork = { source: 'mainnet' }; const result = await accounts({ showPrivateKeys: true }); expect(result[0].privkey).toMatch(/^0x[0-9a-f]{64}$/); }); + + it('omits private keys with --hide-private-keys', async () => { + const result = await accounts({ hidePrivateKeys: true }); + expect(result[0].privkey).toBeUndefined(); + expect(logger.info).not.toHaveBeenCalledWith(expect.arrayContaining([expect.stringMatching(/^privkey:/)])); + expect(logger.result).toHaveBeenCalledWith( + expect.objectContaining({ + accounts: expect.arrayContaining([expect.not.objectContaining({ privkey: expect.anything() })]), + }), + ); + }); }); diff --git a/tests/child-lifetime.test.ts b/tests/child-lifetime.test.ts new file mode 100644 index 0000000..9698093 --- /dev/null +++ b/tests/child-lifetime.test.ts @@ -0,0 +1,141 @@ +import { EventEmitter } from 'events'; +import { ChildProcess, spawn, spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as ts from 'typescript'; +import { bindChildrenToProcessLifetime } from '../src/util/child-lifetime'; + +class FakeChild extends EventEmitter { + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + kill = jest.fn(() => true); +} + +type Listener = (...args: unknown[]) => void; + +function bind() { + const before = process.listeners('exit') as Listener[]; + const guard = bindChildrenToProcessLifetime(); + const onExit = (process.listeners('exit') as Listener[]).filter((listener) => !before.includes(listener))[0]; + return { guard, onExit }; +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitUntilDead(pid: number, timeoutMs = 5000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (!isAlive(pid)) return true; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return !isAlive(pid); +} + +describe('bindChildrenToProcessLifetime', () => { + it('sends SIGTERM to running tracked children when the process exits', () => { + const { guard, onExit } = bind(); + const running = new FakeChild(); + const exited = new FakeChild(); + exited.exitCode = 0; + guard.track(running as unknown as ChildProcess); + guard.track(exited as unknown as ChildProcess); + + onExit(1); + + expect(running.kill).toHaveBeenCalledWith('SIGTERM'); + expect(exited.kill).not.toHaveBeenCalled(); + guard.dispose(); + }); + + it('stopAll signals running children without waiting for exit', () => { + const { guard } = bind(); + const running = new FakeChild(); + guard.track(running as unknown as ChildProcess); + guard.stopAll(); + expect(running.kill).toHaveBeenCalledWith('SIGTERM'); + guard.dispose(); + }); + + it('only owns the exit hook, never signal handlers, and removes it on dispose', () => { + const signalsBefore = ['SIGINT', 'SIGTERM', 'SIGHUP'].map((signal) => process.listenerCount(signal)); + const { guard, onExit } = bind(); + expect(['SIGINT', 'SIGTERM', 'SIGHUP'].map((signal) => process.listenerCount(signal))).toEqual(signalsBefore); + guard.dispose(); + expect(process.listeners('exit')).not.toContain(onExit); + }); + + const posixIt = process.platform === 'win32' ? it.skip : it; + + posixIt('stops a real child process from the exit hook', async () => { + const { guard, onExit } = bind(); + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }); + try { + await new Promise((resolve) => child.once('spawn', resolve)); + guard.track(child); + onExit(0); + expect(await waitUntilDead(child.pid!)).toBe(true); + } finally { + guard.dispose(); + if (child.exitCode == null && child.signalCode == null) child.kill('SIGKILL'); + } + }); + + describe('in a real offckb-like process', () => { + let dir: string; + + beforeAll(() => { + // Run the real module (no mocks, real process.exit) in a separate Node + // process: transpile it next to a small driver script. + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-child-lifetime-')); + const source = fs.readFileSync(path.join(__dirname, '../src/util/child-lifetime.ts'), 'utf8'); + const { outputText } = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }); + fs.writeFileSync(path.join(dir, 'child-lifetime.js'), outputText); + fs.writeFileSync( + path.join(dir, 'driver.js'), + [ + "const { spawn } = require('child_process');", + "const { bindChildrenToProcessLifetime } = require('./child-lifetime');", + 'const guard = bindChildrenToProcessLifetime();', + "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });", + 'guard.track(child);', + "child.once('spawn', () => {", + ' process.stdout.write(String(child.pid));', + " if (process.argv[2] === 'throw') setTimeout(() => { throw new Error('boom'); }, 10);", + " else if (process.argv[2] === 'reject') setTimeout(() => Promise.reject(new Error('boom')), 10);", + ' else process.exit(0);', + '});', + ].join('\n'), + ); + }); + + afterAll(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + for (const mode of ['exit', 'throw', 'reject']) { + posixIt(`stops the child when the parent ends via ${mode}`, async () => { + const result = spawnSync(process.execPath, [path.join(dir, 'driver.js'), mode], { + encoding: 'utf8', + timeout: 10_000, + }); + const pid = Number(result.stdout.trim()); + expect(Number.isInteger(pid) && pid > 0).toBe(true); + try { + expect(await waitUntilDead(pid)).toBe(true); + } finally { + if (isAlive(pid)) process.kill(pid, 'SIGKILL'); + } + }); + } + }); +}); diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..54cf55a --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,190 @@ +import fs from 'fs'; +import path from 'path'; + +// Redirect offckb config/data/cache roots into a temp directory +jest.mock('../src/cfg/env-path', () => { + const nodeFs = require('fs'); + const nodeOs = require('os'); + const nodePath = require('path'); + const root = nodeFs.mkdtempSync(nodePath.join(nodeOs.tmpdir(), 'offckb-config-test-')); + return { + __esModule: true, + default: () => ({ + data: nodePath.join(root, 'data'), + config: nodePath.join(root, 'config'), + cache: nodePath.join(root, 'cache'), + log: nodePath.join(root, 'log'), + temp: nodePath.join(root, 'temp'), + }), + }; +}); + +import { configPath } from '../src/cfg/setting'; +import { Config, ConfigAction, ConfigItem } from '../src/cmd/config'; +import { runCli } from '../src/cli'; +import { logger } from '../src/util/logger'; + +function captureStderr() { + const writes: string[] = []; + const spy = jest.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => { + writes.push(String(chunk)); + return true; + }) as typeof process.stderr.write); + return { + writes, + text: () => writes.join(''), + restore: () => spy.mockRestore(), + }; +} + +describe('config command and error handling', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(logger, 'info').mockImplementation(() => {}); + process.exitCode = undefined; + logger.setJsonMode(false); + fs.rmSync(configPath, { force: true }); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + process.exitCode = undefined; + logger.setJsonMode(false); + }); + + describe('Config handler', () => { + it('creates config file when missing', async () => { + fs.rmSync(configPath, { force: true }); + expect(fs.existsSync(configPath)).toBe(false); + + await Config(ConfigAction.set, ConfigItem.ckbVersion, '0.208.0'); + + expect(fs.existsSync(configPath)).toBe(true); + const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')); + expect(parsed.bins.defaultCKBVersion).toBe('0.208.0'); + }); + + it('preserves other existing settings when modifying one setting', async () => { + const initial = { + proxy: { host: '192.168.1.100', port: 8080 }, + bins: { defaultCKBVersion: '0.200.0' }, + }; + fs.writeFileSync(configPath, JSON.stringify(initial, null, 2)); + + await Config(ConfigAction.set, ConfigItem.ckbVersion, '0.208.0'); + + const updated = JSON.parse(fs.readFileSync(configPath, 'utf8')); + expect(updated.bins.defaultCKBVersion).toBe('0.208.0'); + expect(updated.proxy).toEqual({ host: '192.168.1.100', port: 8080 }); + }); + + it('stops modification and preserves original file when settings.json is corrupted', async () => { + const corruptContent = '{ "proxy": broken json'; + fs.writeFileSync(configPath, corruptContent); + + await expect(Config(ConfigAction.set, ConfigItem.ckbVersion, '0.208.0')).rejects.toThrow( + expect.objectContaining({ + message: expect.stringMatching(new RegExp(`Failed to read settings from .*${path.basename(configPath)}`)), + }), + ); + + // Original corrupted content is preserved untouched + expect(fs.readFileSync(configPath, 'utf8')).toBe(corruptContent); + }); + + it('stops modification and preserves original file when file read throws EACCES', async () => { + const initial = { proxy: { host: '192.168.1.100', port: 8080 } }; + fs.writeFileSync(configPath, JSON.stringify(initial, null, 2)); + + const renameSpy = jest.spyOn(fs, 'renameSync'); + const readSpy = jest.spyOn(fs, 'readFileSync').mockImplementationOnce(() => { + const err = new Error('EACCES: permission denied') as NodeJS.ErrnoException; + err.code = 'EACCES'; + throw err; + }); + + try { + await expect(Config(ConfigAction.set, ConfigItem.ckbVersion, '0.208.0')).rejects.toThrow( + expect.objectContaining({ + message: expect.stringMatching(new RegExp(`Failed to read settings from .*${path.basename(configPath)}`)), + }), + ); + } finally { + readSpy.mockRestore(); + renameSpy.mockRestore(); + } + + // Rename / write was never reached + expect(renameSpy).not.toHaveBeenCalled(); + + // Original content is preserved untouched + const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')); + expect(parsed.proxy).toEqual({ host: '192.168.1.100', port: 8080 }); + expect(parsed.bins).toBeUndefined(); + }); + + it('stops modification and preserves original file when required section is null', async () => { + const invalidContent = JSON.stringify({ bins: null }); + fs.writeFileSync(configPath, invalidContent); + + await expect(Config(ConfigAction.set, ConfigItem.ckbVersion, '0.208.0')).rejects.toThrow( + 'bins must be an object', + ); + + // Original content is preserved untouched + expect(fs.readFileSync(configPath, 'utf8')).toBe(invalidContent); + }); + }); + + describe('CLI error propagation', () => { + it('propagates corrupted config read failure through CLI with exitCode 1 and JSON ok:false', async () => { + const corruptContent = '{ "corrupted": json content'; + fs.writeFileSync(configPath, corruptContent); + + const stderr = captureStderr(); + try { + await runCli(['node', 'offckb', '--json', 'config', 'set', 'ckb-version', '0.208.0']); + } finally { + stderr.restore(); + } + + expect(process.exitCode).toBe(1); + const output = stderr.text(); + const record = JSON.parse(output.trim()); + expect(record.ok).toBe(false); + expect(record.code).toBe('COMMAND_FAILED'); + expect(record.message).toContain(configPath); + // Original corrupted content is preserved untouched + expect(fs.readFileSync(configPath, 'utf8')).toBe(corruptContent); + }); + + it('propagates write failure through CLI with exitCode 1 and JSON ok:false', async () => { + const initial = { bins: { defaultCKBVersion: '0.200.0' } }; + fs.writeFileSync(configPath, JSON.stringify(initial, null, 2)); + + const writeSpy = jest.spyOn(fs, 'renameSync').mockImplementation(() => { + throw new Error('EACCES: permission denied'); + }); + + const stderr = captureStderr(); + try { + await runCli(['node', 'offckb', '--json', 'config', 'set', 'ckb-version', '0.208.0']); + } finally { + writeSpy.mockRestore(); + stderr.restore(); + } + + expect(process.exitCode).toBe(1); + const output = stderr.text(); + const record = JSON.parse(output.trim()); + expect(record.ok).toBe(false); + expect(record.code).toBe('COMMAND_FAILED'); + expect(record.message).toContain(configPath); + expect(record.message).toContain('EACCES'); + // Original content is preserved untouched + const current = JSON.parse(fs.readFileSync(configPath, 'utf8')); + expect(current.bins.defaultCKBVersion).toBe('0.200.0'); + }); + }); +}); diff --git a/tests/daemon-command-line.test.ts b/tests/daemon-command-line.test.ts new file mode 100644 index 0000000..b63a4c6 --- /dev/null +++ b/tests/daemon-command-line.test.ts @@ -0,0 +1,75 @@ +import path from 'path'; +import { commandLineRunsScript } from '../src/util/daemon'; + +const POSIX_NODE = '/usr/local/bin/node'; +const WIN_NODE = 'C:\\Program Files\\nodejs\\node.exe'; + +describe('commandLineRunsScript (posix)', () => { + const script = '/Users/dev/Library/Application Support/offckb-nodejs/build/index.js'; + + it('matches an unquoted ps -o args= line whose paths contain spaces', () => { + expect(commandLineRunsScript(`${POSIX_NODE} ${script} node --daemon`, script, 'darwin')).toBe(true); + expect(commandLineRunsScript(`/usr/bin/node ${script}`, script, 'linux')).toBe(true); + expect(commandLineRunsScript(`nodejs ${script} fiber start --daemon`, script, 'linux')).toBe(true); + }); + + it('matches when the node executable itself also lives under a spaced path', () => { + const node = '/Users/dev/Library/Application Support/fnm/node'; + expect(commandLineRunsScript(`${node} ${script} node`, script, 'darwin')).toBe(true); + }); + + it('rejects a foreign script, a truncated script and a non-node host', () => { + expect(commandLineRunsScript(`${POSIX_NODE} ${script}-other node`, script, 'darwin')).toBe(false); + expect(commandLineRunsScript(`${POSIX_NODE} ${script.slice(0, -3)} node`, script, 'darwin')).toBe(false); + expect(commandLineRunsScript(`/bin/bash ${script}`, script, 'darwin')).toBe(false); + expect(commandLineRunsScript(`${POSIX_NODE}`, script, 'darwin')).toBe(false); + expect(commandLineRunsScript('', script, 'darwin')).toBe(false); + }); + + it('rejects a path that only shares a prefix with the expected script', () => { + expect( + commandLineRunsScript( + `${POSIX_NODE} /Users/dev/Library/Application Support/offckb-nodejs/build/index.js.bak node`, + script, + 'darwin', + ), + ).toBe(false); + }); +}); + +describe('commandLineRunsScript (windows)', () => { + const script = 'C:\\Users\\Jane Doe\\AppData\\Local\\offckb-nodejs\\build\\index.js'; + + it('matches a Win32_Process CommandLine that quotes arguments containing spaces', () => { + expect(commandLineRunsScript(`"${WIN_NODE}" "${script}" node --daemon`, script, 'win32')).toBe(true); + expect(commandLineRunsScript(`"${WIN_NODE}" "${script}"`, script, 'win32')).toBe(true); + }); + + it('matches when only the script needs quoting', () => { + expect(commandLineRunsScript(`C:\\nodejs\\node.exe "${script}" node`, script, 'win32')).toBe(true); + }); + + it('matches an unquoted Windows command line when neither path has spaces', () => { + const plain = 'C:\\offckb\\build\\index.js'; + expect(commandLineRunsScript(`C:\\nodejs\\node.exe ${plain} node --daemon`, plain, 'win32')).toBe(true); + }); + + it('rejects a foreign script and a non-node host, case-insensitively for the script', () => { + expect(commandLineRunsScript(`"${WIN_NODE}" "${script}.bak"`, script, 'win32')).toBe(false); + expect(commandLineRunsScript(`"C:\\Windows\\System32\\cmd.exe" "${script}"`, script, 'win32')).toBe(false); + expect( + commandLineRunsScript( + `"${WIN_NODE}" "C:\\Users\\Jane Doe\\AppData\\Local\\offckb-nodejs\\build\\INDEX.JS" node`, + script, + 'win32', + ), + ).toBe(true); + }); +}); + +describe('commandLineRunsScript (posix absolute paths without spaces still work)', () => { + it('matches a simple absolute path', () => { + const script = path.posix.join('/opt', 'offckb', 'build', 'index.js'); + expect(commandLineRunsScript(`${POSIX_NODE} ${script} node`, script, 'linux')).toBe(true); + }); +}); diff --git a/tests/daemon-locale.test.ts b/tests/daemon-locale.test.ts new file mode 100644 index 0000000..c5d98ba --- /dev/null +++ b/tests/daemon-locale.test.ts @@ -0,0 +1,209 @@ +import { getProcessInfo, verifyDaemonIdentity, PidMetadata } from '../src/util/daemon'; + +const mockExecFile = jest.fn(); + +jest.mock('child_process', () => ({ + ...jest.requireActual('child_process'), + execFile: (...args: unknown[]) => mockExecFile(...args), +})); + +describe('daemon locale and process probing', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + const originalLang = process.env.LANG; + const originalLcTime = process.env.LC_TIME; + const originalLcAll = process.env.LC_ALL; + const originalCliPath = process.env.OFFCKB_CLI_PATH; + + beforeAll(() => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + enumerable: true, + writable: true, + }); + }); + + afterAll(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform); + } + }); + + beforeEach(() => { + process.env.LANG = 'zh_CN.UTF-8'; + process.env.LC_TIME = 'zh_CN.UTF-8'; + process.env.LC_ALL = 'zh_CN.UTF-8'; + process.env.OFFCKB_CLI_PATH = __filename; + }); + + afterEach(() => { + if (originalLang !== undefined) { + process.env.LANG = originalLang; + } else { + delete process.env.LANG; + } + if (originalLcTime !== undefined) { + process.env.LC_TIME = originalLcTime; + } else { + delete process.env.LC_TIME; + } + if (originalLcAll !== undefined) { + process.env.LC_ALL = originalLcAll; + } else { + delete process.env.LC_ALL; + } + if (originalCliPath !== undefined) { + process.env.OFFCKB_CLI_PATH = originalCliPath; + } else { + delete process.env.OFFCKB_CLI_PATH; + } + mockExecFile.mockReset(); + }); + + it('runs ps with LC_ALL=C so getProcessInfo parses start time and leaves parent locale unchanged', async () => { + const expectedTimeMs = new Date(2026, 7, 13, 12, 36, 26).getTime(); + + mockExecFile.mockImplementation((file: string, args: string[], options: any, callback?: any) => { + const cb = typeof options === 'function' ? options : callback; + const opts = typeof options === 'object' ? options : {}; + + if (file === 'ps') { + if (args.includes('args=')) { + cb(null, `"${process.execPath}" "${__filename}"`, ''); + return; + } + if (args.includes('lstart=')) { + // If child env was passed LC_ALL=C, return English date format; otherwise localized date + if (opts.env?.LC_ALL === 'C') { + cb(null, 'Wed Aug 13 12:36:26 2026', ''); + } else { + cb(null, '周三 8月 13 12:36:26 2026', ''); + } + return; + } + } + cb(new Error(`Unexpected command: ${file}`), '', ''); + }); + + const info = await getProcessInfo(12345); + + expect(info).not.toBeNull(); + expect(info?.startTimeMs).toBe(expectedTimeMs); + + // Parent locale must remain unchanged + expect(process.env.LANG).toBe('zh_CN.UTF-8'); + expect(process.env.LC_TIME).toBe('zh_CN.UTF-8'); + expect(process.env.LC_ALL).toBe('zh_CN.UTF-8'); + + // Identity check succeeds when probe returns matching start time + const metadata: PidMetadata = { + pid: 12345, + scriptPath: __filename, + startedAt: new Date(2026, 7, 13, 12, 36, 26).toISOString(), + }; + const verified = await verifyDaemonIdentity(12345, metadata); + expect(verified).toBe(true); + }); + + it('fails daemon identity verification when start time cannot be parsed', async () => { + mockExecFile.mockImplementation((file: string, args: string[], options: any, callback?: any) => { + const cb = typeof options === 'function' ? options : callback; + + if (file === 'ps') { + if (args.includes('args=')) { + cb(null, `"${process.execPath}" "${__filename}"`, ''); + return; + } + if (args.includes('lstart=')) { + // Return unparseable localized string regardless of env + cb(null, '周三 8月 13 12:36:26 2026', ''); + return; + } + } + cb(new Error(`Unexpected command: ${file}`), '', ''); + }); + + const info = await getProcessInfo(12345); + expect(info).not.toBeNull(); + expect(info?.startTimeMs).toBeNull(); + + const metadata: PidMetadata = { + pid: 12345, + scriptPath: __filename, + startedAt: new Date(2026, 7, 13, 12, 36, 26).toISOString(), + }; + const verified = await verifyDaemonIdentity(12345, metadata); + expect(verified).toBe(false); + }); + + it('fails daemon identity verification when process probe fails', async () => { + mockExecFile.mockImplementation((file: string, _args: string[], options: any, callback?: any) => { + const cb = typeof options === 'function' ? options : callback; + cb(new Error('ps: process not found'), '', ''); + }); + + const info = await getProcessInfo(12345); + expect(info).toEqual({ argv: null, cmdline: null, startTimeMs: null }); + + const metadata: PidMetadata = { + pid: 12345, + scriptPath: __filename, + startedAt: new Date(2026, 7, 13, 12, 36, 26).toISOString(), + }; + const verified = await verifyDaemonIdentity(12345, metadata); + expect(verified).toBe(false); + }); + + it('verifies a daemon whose CLI path contains spaces (unquoted ps -o args= output)', async () => { + // Use a temporary absolute path with a space as the CLI entry so the + // live OFFCKB_CLI_PATH matches the command line we feed the probe. + const fs = require('fs') as typeof import('fs'); + const path = require('path') as typeof import('path'); + const os = require('os') as typeof import('os'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb spaced-')); + const script = path.join(dir, 'index.js'); + fs.writeFileSync(script, ''); + process.env.OFFCKB_CLI_PATH = script; + const startedAt = new Date(2026, 7, 13, 12, 36, 26); + + mockExecFile.mockImplementation((file: string, args: string[], options: any, callback?: any) => { + const cb = typeof options === 'function' ? options : callback; + if (file === 'ps') { + if (args.includes('args=')) { + // ps never quotes: the space in the script path is a bare space. + cb(null, `${process.execPath} ${script} node --daemon`, ''); + return; + } + if (args.includes('lstart=')) { + cb(null, 'Wed Aug 13 12:36:26 2026', ''); + return; + } + } + cb(new Error(`Unexpected command: ${file}`), '', ''); + }); + + const metadata: PidMetadata = { + pid: 12345, + scriptPath: script, + startedAt: startedAt.toISOString(), + }; + expect(await verifyDaemonIdentity(12345, metadata)).toBe(true); + + // A foreign script under a sibling spaced directory must not match. + mockExecFile.mockImplementation((file: string, args: string[], options: any, callback?: any) => { + const cb = typeof options === 'function' ? options : callback; + if (file === 'ps' && args.includes('args=')) { + cb(null, `${process.execPath} ${script}-other node --daemon`, ''); + return; + } + if (file === 'ps' && args.includes('lstart=')) { + cb(null, 'Wed Aug 13 12:36:26 2026', ''); + return; + } + cb(new Error(`Unexpected command: ${file}`), '', ''); + }); + expect(await verifyDaemonIdentity(12345, metadata)).toBe(false); + + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/tests/devnet-config-editor.test.ts b/tests/devnet-config-editor.test.ts index b3bb147..c946fbc 100644 --- a/tests/devnet-config-editor.test.ts +++ b/tests/devnet-config-editor.test.ts @@ -147,6 +147,36 @@ describe('DevnetConfigEditor', () => { expect(minerToml.miner.client.poll_interval).toBe(1500); }); + it('rejects invalid --set values with key, value, and original reason without saving changes', () => { + const editor = createDevnetConfigEditor(configPath); + const ckbTomlPath = path.join(configPath, 'ckb.toml'); + const minerTomlPath = path.join(configPath, 'ckb-miner.toml'); + const initialCkbContent = fs.readFileSync(ckbTomlPath, 'utf8'); + const initialMinerContent = fs.readFileSync(minerTomlPath, 'utf8'); + + expect(() => + applySetItems(editor, [ + 'ckb.logger.filter=info', + 'miner.client.poll_interval=1001', + 'ckb.logger.color=maybe', + ]), + ).toThrow( + "Invalid value 'maybe' for 'ckb.logger.color'. Boolean value must be one of: true/false/yes/no/1/0.", + ); + + // Verify both TOML files remain byte-for-byte unchanged + expect(fs.readFileSync(ckbTomlPath, 'utf8')).toBe(initialCkbContent); + expect(fs.readFileSync(minerTomlPath, 'utf8')).toBe(initialMinerContent); + + // Also verify number validation reason is preserved with poll_interval=0 + expect(() => applySetItems(editor, ['miner.client.poll_interval=0'])).toThrow( + "Invalid value '0' for 'miner.client.poll_interval'. Value must be a positive integer.", + ); + + expect(fs.readFileSync(ckbTomlPath, 'utf8')).toBe(initialCkbContent); + expect(fs.readFileSync(minerTomlPath, 'utf8')).toBe(initialMinerContent); + }); + it('provides full TOML document and flattened entries', () => { const editor = createDevnetConfigEditor(configPath); diff --git a/tests/logs-command.test.ts b/tests/logs-command.test.ts index 3c8baf1..e9ffce8 100644 --- a/tests/logs-command.test.ts +++ b/tests/logs-command.test.ts @@ -20,6 +20,8 @@ const NODE_LINE = '2026-07-29 11:31:27.149 +00:00 main INFO ckb_bin::subcommand::run ckb version: 0.207.0 (8f6cacf 2026-06-10)'; const SCRIPT_LINE = '2026-07-29 11:40:01.500 +00:00 GlobalRt-7 DEBUG ckb-script script group: 0xabcd DEBUG OUTPUT: hello world'; +const ISSUE_SCRIPT_LINE = + '2026-09-11 11:20:02.169 +00:00 GlobalRt-10 DEBUG ckb_script::verify script group: Byte32(0xafa75b18ff86fe7e188169be16b3279833ceeb197b0d14abf23ff2b005199f8d) DEBUG OUTPUT: OFFCKB_DIAGNOSTIC_FIRST'; const ERROR_LINE = '2026-07-29 11:31:38.636 +00:00 verify_blocks ERROR ckb_chain::verify unverified_block_rx err: channel disconnected'; @@ -70,6 +72,17 @@ describe('showLogs', () => { expect(transport.logs).toEqual([SCRIPT_LINE]); }); + it('prints real ckb_script::verify debug lines with --tail and --grep for the script target', () => { + const { settings, transport } = fixture(); + const runLog = path.join(settings.devnet.dataPath, 'logs', 'run.log'); + const verifyWithoutMarker = + '2026-09-11 11:20:01.000 +00:00 GlobalRt-10 DEBUG ckb_script::verify normal verify line without marker'; + fs.writeFileSync(runLog, [NODE_LINE, verifyWithoutMarker, ISSUE_SCRIPT_LINE, ERROR_LINE].join('\n') + '\n'); + const log = UnifiedLogger.create({ transports: [transport], showLevel: false }); + showLogs('script', { tail: 10, grep: 'OFFCKB_DIAGNOSTIC_FIRST' }, settings, log); + expect(transport.logs).toEqual([ISSUE_SCRIPT_LINE]); + }); + it('finds sparse script entries beyond the raw tail window', () => { const { settings, transport } = fixture(); const runLog = path.join(settings.devnet.dataPath, 'logs', 'run.log'); @@ -151,6 +164,45 @@ describe('showLogs', () => { mockUnwatchFile.mockReset(); } }); + + it('in follow mode accepts real ckb_script::verify entries and rejects entries without marker', () => { + const { settings, transport } = fixture(); + type StatListener = (curr: fs.Stats, prev: fs.Stats) => void; + const listeners: StatListener[] = []; + mockWatchFile.mockImplementation((_file: unknown, _options: unknown, onChange: StatListener) => { + listeners.push(onChange); + }); + + try { + const log = UnifiedLogger.create({ transports: [transport], showLevel: false }); + showLogs('script', { tail: 100, follow: true, grep: 'OFFCKB_DIAGNOSTIC' }, settings, log); + expect(listeners).toHaveLength(1); + transport.logs.length = 0; + + const runLog = path.join(settings.devnet.dataPath, 'logs', 'run.log'); + const verifyNoMarker = + '2026-09-11 11:20:01.000 +00:00 GlobalRt-10 DEBUG ckb_script::verify normal verify without debug output'; + fs.appendFileSync( + runLog, + [ + ERROR_LINE, + ISSUE_SCRIPT_LINE, + ' diagnostic continuation OFFCKB_DIAGNOSTIC', + verifyNoMarker, + ' rejected continuation OFFCKB_DIAGNOSTIC', + '2026-09-11 11:20:03.000 +00:00 GlobalRt-10 DEBUG ckb_script_other DEBUG OUTPUT: OFFCKB_DIAGNOSTIC', + ' rejected other continuation OFFCKB_DIAGNOSTIC', + ].join('\n') + '\n', + ); + const stat = fs.statSync(runLog); + listeners[0](stat, stat); + + expect(transport.logs).toEqual([ISSUE_SCRIPT_LINE, ' diagnostic continuation OFFCKB_DIAGNOSTIC']); + } finally { + mockWatchFile.mockReset(); + mockUnwatchFile.mockReset(); + } + }); }); describe('logsCommand', () => { diff --git a/tests/logs.test.ts b/tests/logs.test.ts index 8bfc78f..0a0b503 100644 --- a/tests/logs.test.ts +++ b/tests/logs.test.ts @@ -10,6 +10,8 @@ import { readLogTail, followLogFile, LogTarget, + isScriptDebugRecord, + SCRIPT_LOG_TARGET, } from '../src/devnet/log-file'; import { defaultSettings, Settings } from '../src/cfg/setting'; @@ -28,6 +30,8 @@ const NODE_LINE = '2026-07-29 11:31:27.149 +00:00 main INFO ckb_bin::subcommand::run ckb version: 0.207.0 (8f6cacf 2026-06-10)'; const SCRIPT_LINE = '2026-07-29 11:40:01.500 +00:00 GlobalRt-7 DEBUG ckb-script script group: 0xabcd DEBUG OUTPUT: hello world'; +const ISSUE_SCRIPT_LINE = + '2026-09-11 11:20:02.169 +00:00 GlobalRt-10 DEBUG ckb_script::verify script group: Byte32(0xafa75b18ff86fe7e188169be16b3279833ceeb197b0d14abf23ff2b005199f8d) DEBUG OUTPUT: OFFCKB_DIAGNOSTIC_FIRST'; const ERROR_LINE = '2026-07-29 11:31:38.636 +00:00 verify_blocks ERROR ckb_chain::verify unverified_block_rx err: receiving on an empty and disconnected channel'; @@ -68,6 +72,27 @@ describe('parseCkbLogLine', () => { }); }); +describe('isScriptDebugRecord', () => { + it('accepts legacy ckb-script target without requiring marker', () => { + expect(isScriptDebugRecord({ target: 'ckb-script', message: 'plain message' })).toBe(true); + }); + + it('accepts ckb_script root and ckb_script:: submodules when containing DEBUG OUTPUT:', () => { + expect(isScriptDebugRecord({ target: 'ckb_script', message: 'group 1 DEBUG OUTPUT: hello' })).toBe(true); + expect(isScriptDebugRecord({ target: 'ckb_script::verify', message: 'group 1 DEBUG OUTPUT: OFFCKB_DIAGNOSTIC_FIRST' })).toBe(true); + }); + + it('rejects ckb_script targets without DEBUG OUTPUT:', () => { + expect(isScriptDebugRecord({ target: 'ckb_script::verify', message: 'script verify success' })).toBe(false); + expect(isScriptDebugRecord({ target: 'ckb_script', message: 'script group initialized' })).toBe(false); + }); + + it('rejects non-script targets and targets with partial prefix match like ckb_script_other', () => { + expect(isScriptDebugRecord({ target: 'ckb_script_other', message: 'DEBUG OUTPUT: test' })).toBe(false); + expect(isScriptDebugRecord({ target: 'ckb_chain::verify', message: 'DEBUG OUTPUT: test' })).toBe(false); + }); +}); + describe('filterLinesByTarget', () => { it('keeps only lines from the given target plus their continuation lines', () => { const lines = [ @@ -87,6 +112,26 @@ describe('filterLinesByTarget', () => { it('drops leading unparsable lines when nothing matched before them', () => { expect(filterLinesByTarget(['garbage line', SCRIPT_LINE], 'ckb-script')).toEqual([SCRIPT_LINE]); }); + + it('filters ckb_script::verify lines based on DEBUG OUTPUT marker and preserves continuation lines', () => { + const unverifiedLine = + '2026-09-11 11:20:01.000 +00:00 GlobalRt-10 DEBUG ckb_script::verify regular verify without marker'; + const lines = [ + unverifiedLine, + ' rejected continuation line', + ISSUE_SCRIPT_LINE, + ' accepted contract multi-line output', + unverifiedLine, + ' rejected continuation after an accepted entry', + NODE_LINE, + '2026-09-11 11:20:03.000 +00:00 GlobalRt-10 DEBUG ckb_script_other DEBUG OUTPUT: should reject', + ' rejected other continuation', + ]; + expect(filterLinesByTarget(lines, SCRIPT_LOG_TARGET)).toEqual([ + ISSUE_SCRIPT_LINE, + ' accepted contract multi-line output', + ]); + }); }); describe('tailLines', () => { diff --git a/tests/node-command.test.ts b/tests/node-command.test.ts index 9b7e661..619108f 100644 --- a/tests/node-command.test.ts +++ b/tests/node-command.test.ts @@ -14,6 +14,14 @@ const mockStatSync = jest.fn(); const mockCloseSync = jest.fn(); const mockWaitForNodeReady = jest.fn(); +// The stale-miner reaper lists real OS processes (PowerShell CIM on Windows, +// which alone can take seconds); it has its own tests, so keep it out of +// these command-flow unit tests. +jest.mock('../src/devnet/stale-miner', () => ({ + ...jest.requireActual('../src/devnet/stale-miner'), + reapStaleMiners: jest.fn().mockResolvedValue([]), +})); + jest.mock('child_process', () => ({ ...jest.requireActual('child_process'), spawn: (...args: unknown[]) => mockSpawn(...args), diff --git a/tests/node-quiet-mode.test.ts b/tests/node-quiet-mode.test.ts index a0bfb5a..168b039 100644 --- a/tests/node-quiet-mode.test.ts +++ b/tests/node-quiet-mode.test.ts @@ -8,6 +8,14 @@ const mockTcpListenAddress = jest.fn(); const loggerInfo = jest.fn(); const loggerWarn = jest.fn(); +// The stale-miner reaper lists real OS processes (PowerShell CIM on Windows, +// which alone can take seconds); it has its own tests, so keep it out of +// these command-flow unit tests. +jest.mock('../src/devnet/stale-miner', () => ({ + ...jest.requireActual('../src/devnet/stale-miner'), + reapStaleMiners: jest.fn().mockResolvedValue([]), +})); + jest.mock('child_process', () => ({ ...jest.requireActual('child_process'), spawn: (...args: unknown[]) => mockSpawn(...args), @@ -113,10 +121,44 @@ describe('foreground node output modes', () => { onEntry({ message: 'block 123', level: 'INFO', target: 'ckb_chain::chain', date: '' }); const printed = loggerInfo.mock.calls.map((args) => args.map(String).join(' ')).join('\n'); + expect(printed).toContain('CKB-Script:'); expect(printed).toContain('script group: 0xabcd DEBUG OUTPUT: hello'); expect(printed).not.toContain('block 123'); }); + it('streams real ckb_script::verify debug log entries and filters unverified/unrelated entries', async () => { + await nodeDevnet({}); + const onEntry = mockSubscribe.mock.calls[0][1] as (entry: CkbLogEntry) => void; + + onEntry({ + message: + 'script group: Byte32(0xafa75b18ff86fe7e188169be16b3279833ceeb197b0d14abf23ff2b005199f8d) DEBUG OUTPUT: OFFCKB_DIAGNOSTIC_FIRST', + level: 'DEBUG', + target: 'ckb_script::verify', + date: '', + }); + onEntry({ + message: 'ordinary verify line without debug marker', + level: 'DEBUG', + target: 'ckb_script::verify', + date: '', + }); + onEntry({ + message: 'other script target DEBUG OUTPUT: test', + level: 'DEBUG', + target: 'ckb_script_other', + date: '', + }); + + const printed = loggerInfo.mock.calls.map((args) => args.map(String).join(' ')).join('\n'); + expect(printed).toContain('CKB-Script:'); + expect(printed).toContain( + 'script group: Byte32(0xafa75b18ff86fe7e188169be16b3279833ceeb197b0d14abf23ff2b005199f8d) DEBUG OUTPUT: OFFCKB_DIAGNOSTIC_FIRST', + ); + expect(printed).not.toContain('ordinary verify line without debug marker'); + expect(printed).not.toContain('other script target'); + }); + it('strips terminal control sequences from script log entries', async () => { await nodeDevnet({}); const onEntry = mockSubscribe.mock.calls[0][1] as (entry: CkbLogEntry) => void; diff --git a/tests/node-supervisor.test.ts b/tests/node-supervisor.test.ts index 16344b0..e85720f 100644 --- a/tests/node-supervisor.test.ts +++ b/tests/node-supervisor.test.ts @@ -32,6 +32,11 @@ jest.mock('../src/devnet/fork', () => ({ readForkState: () => mockForkState, markForkFirstRunComplete: (...args: unknown[]) => mockMarkForkFirstRunComplete(...args), })); +const mockReapStaleMiners = jest.fn().mockResolvedValue([]); +jest.mock('../src/devnet/stale-miner', () => ({ + ...jest.requireActual('../src/devnet/stale-miner'), + reapStaleMiners: (...args: unknown[]) => mockReapStaleMiners(...args), +})); jest.mock('../src/util/json-rpc', () => ({ callJsonRpc: (...args: unknown[]) => mockCallJsonRpc(...args) })); jest.mock('../src/devnet/readiness', () => ({ checkNodeReadiness: jest.fn(), @@ -52,17 +57,22 @@ jest.mock('../src/util/logger', () => ({ })); import { nodeDevnet } from '../src/cmd/node'; +import { isMinerCommandFor } from '../src/devnet/stale-miner'; class FakeChild extends EventEmitter { stdout = new EventEmitter(); stderr = new EventEmitter(); killed = false; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; kill = jest.fn((_signal?: NodeJS.Signals) => { this.killed = true; return true; }); } +type Listener = (...args: unknown[]) => void; + describe('foreground devnet supervisor', () => { const originalExitCode = process.exitCode; let ckb: FakeChild; @@ -132,4 +142,83 @@ describe('foreground devnet supervisor', () => { ); expect(mockProxyStart).toHaveBeenCalled(); }); + + it('reaps stale miners for the devnet dir before launching CKB', async () => { + await nodeDevnet({}); + expect(mockReapStaleMiners).toHaveBeenCalledWith('/tmp/offckb-devnet'); + expect(mockReapStaleMiners.mock.invocationCallOrder[0]).toBeLessThan(mockSpawn.mock.invocationCallOrder[0]); + }); + + it('stops CKB and the miner when the offckb process exits without a graceful shutdown', async () => { + const before = process.listeners('exit') as Listener[]; + await nodeDevnet({}); + const added = (process.listeners('exit') as Listener[]).filter((listener) => !before.includes(listener)); + expect(added).toHaveLength(1); + + added[0](0); + + expect(ckb.kill).toHaveBeenCalledWith('SIGTERM'); + expect(miner.kill).toHaveBeenCalledWith('SIGTERM'); + // A component exit runs the normal shutdown, which drops the exit hook. + miner.emit('exit', 1, null); + expect(process.listeners('exit')).not.toContain(added[0]); + }); + + it('stops the service when CKB already exited before the supervisor was attached', async () => { + mockSpawn.mockReset(); + mockSpawn.mockReturnValueOnce(ckb).mockImplementationOnce(() => { + process.nextTick(() => miner.emit('spawn')); + return miner; + }); + mockProxyStart.mockImplementationOnce(() => { + ckb.exitCode = 3; + }); + + await nodeDevnet({}); + + expect(miner.kill).toHaveBeenCalledWith('SIGTERM'); + expect(mockProxyStop).toHaveBeenCalled(); + expect(process.exitCode).toBe(3); + }); + + it('spawns the miner with exactly the command the stale-miner reaper recognizes', async () => { + await nodeDevnet({}); + const [binary, args] = mockSpawn.mock.calls[1] as [string, string[]]; + expect(isMinerCommandFor([binary, ...args].join(' '), '/tmp/offckb-devnet')).toBe(true); + }); + + it('stops every spawned child when startup throws after the miner started', async () => { + mockProxyStart.mockImplementationOnce(() => { + throw new Error('proxy failed'); + }); + const exitBefore = process.listeners('exit') as Listener[]; + const sigtermBefore = process.listeners('SIGTERM') as Listener[]; + + await expect(nodeDevnet({})).rejects.toThrow('proxy failed'); + + expect(ckb.kill).toHaveBeenCalledWith('SIGTERM'); + expect(miner.kill).toHaveBeenCalledWith('SIGTERM'); + expect(process.listeners('exit')).toEqual(exitBefore); + expect(process.listeners('SIGTERM')).toEqual(sigtermBefore); + }); + + it('runs the shared shutdown on SIGTERM without --fiber, then exits with 143', async () => { + const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + const before = process.listeners('SIGTERM') as Listener[]; + try { + await nodeDevnet({}); + const added = (process.listeners('SIGTERM') as Listener[]).filter((listener) => !before.includes(listener)); + expect(added).toHaveLength(1); + + added[0](); + await new Promise((resolve) => setImmediate(resolve)); + + expect(ckb.kill).toHaveBeenCalledWith('SIGTERM'); + expect(miner.kill).toHaveBeenCalledWith('SIGTERM'); + expect(mockProxyStop).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(143); + } finally { + exitSpy.mockRestore(); + } + }); }); diff --git a/tests/node-terminal-rpc.test.ts b/tests/node-terminal-rpc.test.ts index 1ee5b0f..d4e0fc8 100644 --- a/tests/node-terminal-rpc.test.ts +++ b/tests/node-terminal-rpc.test.ts @@ -9,6 +9,14 @@ const mockInitChainIfNeeded = jest.fn(); const mockDevnetConfigHasTerminalRpc = jest.fn(); const mockWaitForNodeReady = jest.fn(); +// The stale-miner reaper lists real OS processes (PowerShell CIM on Windows, +// which alone can take seconds); it has its own tests, so keep it out of +// these command-flow unit tests. +jest.mock('../src/devnet/stale-miner', () => ({ + ...jest.requireActual('../src/devnet/stale-miner'), + reapStaleMiners: jest.fn().mockResolvedValue([]), +})); + jest.mock('child_process', () => ({ ...jest.requireActual('child_process'), spawn: (...args: unknown[]) => mockSpawn(...args), diff --git a/tests/sdk/ckb.udt.test.ts b/tests/sdk/ckb.udt.test.ts index ddac4c3..7c73c7c 100644 --- a/tests/sdk/ckb.udt.test.ts +++ b/tests/sdk/ckb.udt.test.ts @@ -1,3 +1,4 @@ +import { ccc } from '@ckb-ccc/core'; import { CKB, UdtKind } from '../../src/sdk/ckb'; import { Network } from '../../src/type/base'; import { logger } from '../../src/util/logger'; @@ -193,7 +194,7 @@ describe('CKB SDK UDT helpers', () => { await expect( ( ckb as unknown as { - assertInputsCreatedAfter: (tx: { inputs: typeof input[] }, block: bigint) => Promise; + assertInputsCreatedAfter: (tx: { inputs: (typeof input)[] }, block: bigint) => Promise; } ).assertInputsCreatedAfter({ inputs: [input] }, 100n), ).rejects.toThrow('at or before the Mainnet fork boundary'); @@ -206,7 +207,7 @@ describe('CKB SDK UDT helpers', () => { await expect( ( ckb as unknown as { - assertInputsCreatedAfter: (tx: { inputs: typeof input[] }, block: bigint) => Promise; + assertInputsCreatedAfter: (tx: { inputs: (typeof input)[] }, block: bigint) => Promise; } ).assertInputsCreatedAfter({ inputs: [input] }, 100n), ).resolves.toBeUndefined(); @@ -219,18 +220,129 @@ describe('CKB SDK UDT helpers', () => { await expect( ( ckb as unknown as { - assertInputsCreatedAfter: (tx: { inputs: typeof input[] }, block: bigint) => Promise; + assertInputsCreatedAfter: (tx: { inputs: (typeof input)[] }, block: bigint) => Promise; } ).assertInputsCreatedAfter({ inputs: [input] }, 100n), ).rejects.toThrow('could not verify the origin block'); }); }); + + describe('udtDestroy', () => { + const typeArgs = '0x' + '12'.repeat(32); + const privateKey = '0x' + '11'.repeat(32); + const signerCtor = ccc.SignerCkbPrivateKey as unknown as jest.Mock; + + beforeEach(() => { + mockFindCellsByLock.mockReset(); + }); + + const seedCells = (kind: UdtKind) => { + mockFindCellsByLock.mockImplementation(async function* () { + yield makeCell(kind, typeArgs, '100', 0); + yield makeCell(kind, typeArgs, '200', 1); + }); + }; + + it.each(['sudt', 'xudt'] as const)('destroys the full balance (%s) and keeps a zero-balance cell', async (kind) => { + seedCells(kind); + const ckb = createCKB(); + + const txHash = await ckb.udtDestroy({ privateKey, kind, typeArgs, amount: '300' }); + + expect(txHash).toBe('0xtxhash'); + + const signer = signerCtor.mock.results[0].value as { + getAddressObjSecp256k1: () => Promise<{ script: { hash: () => string } }>; + sendTransaction: jest.Mock; + }; + const from = await signer.getAddressObjSecp256k1(); + const sendTx = signer.sendTransaction; + expect(sendTx).toHaveBeenCalledTimes(1); + const tx = sendTx.mock.calls[0][0] as { + addInput: jest.Mock; + addOutput: jest.Mock; + addCellDeps: jest.Mock; + completeInputsByCapacity: jest.Mock; + completeFeeBy: jest.Mock; + }; + + expect(tx.addInput).toHaveBeenCalledTimes(2); + expect(tx.addInput).toHaveBeenCalledWith({ previousOutput: { txHash: '0x' + '00'.repeat(32), index: 0 } }); + expect(tx.addInput).toHaveBeenCalledWith({ previousOutput: { txHash: '0x' + '00'.repeat(32), index: 1 } }); + + expect(tx.addOutput).toHaveBeenCalledTimes(1); + const [output, data] = tx.addOutput.mock.calls[0] as [Record, string]; + expect(output.lock).toBe(from.script); + expect(output.type).toEqual({ + codeHash: kind === 'sudt' ? '0x' + 'c3'.repeat(32) : '0x' + 'dd'.repeat(32), + hashType: 'type', + args: typeArgs, + }); + expect(output.capacity).toBe(0n); + expect(data).toBe('0x' + '00'.repeat(16)); + + expect(tx.addCellDeps).toHaveBeenCalledWith([ + { outPoint: { txHash: '0x' + 'aa'.repeat(32), index: 0 }, depType: 'depGroup' }, + ]); + expect(tx.completeInputsByCapacity).toHaveBeenCalledTimes(1); + expect(tx.completeFeeBy).toHaveBeenCalledTimes(1); + }); + + it.each(['sudt', 'xudt'] as const)('destroys part of the balance (%s) and returns the remainder', async (kind) => { + seedCells(kind); + const ckb = createCKB(); + + const txHash = await ckb.udtDestroy({ privateKey, kind, typeArgs, amount: '100' }); + + expect(txHash).toBe('0xtxhash'); + const signer = signerCtor.mock.results[0].value as { sendTransaction: jest.Mock }; + const tx = signer.sendTransaction.mock.calls[0][0] as { addOutput: jest.Mock }; + expect(tx.addOutput).toHaveBeenCalledTimes(1); + // The numToBytes mock is not a real little-endian encoder, so assert the call args rather than the encoded hex. + expect(ccc.numToBytes as unknown as jest.Mock).toHaveBeenCalledWith(200n, 16); + }); + + it('rejects an amount above the total balance without sending', async () => { + seedCells('sudt'); + const ckb = createCKB(); + + await expect(ckb.udtDestroy({ privateKey, kind: 'sudt', typeArgs, amount: '301' })).rejects.toThrow( + 'Insufficient UDT balance: 300 < 301', + ); + + const signer = signerCtor.mock.results[0].value as { sendTransaction: jest.Mock }; + expect(signer.sendTransaction).not.toHaveBeenCalled(); + }); + + it('rejects a zero amount without sending', async () => { + const ckb = createCKB(); + + await expect(ckb.udtDestroy({ privateKey, kind: 'sudt', typeArgs, amount: '0' })).rejects.toThrow( + 'invalid UDT amount "0"', + ); + + const signer = signerCtor.mock.results[0].value as { sendTransaction: jest.Mock }; + expect(signer.sendTransaction).not.toHaveBeenCalled(); + }); + + it('rejects when input cells exceed maxInputCells without sending', async () => { + seedCells('sudt'); + const ckb = createCKB(); + + await expect( + ckb.udtDestroy({ privateKey, kind: 'sudt', typeArgs, amount: '300' }, { maxInputCells: 1 }), + ).rejects.toThrow('Too many UDT cells to destroy'); + + const signer = signerCtor.mock.results[0].value as { sendTransaction: jest.Mock }; + expect(signer.sendTransaction).not.toHaveBeenCalled(); + }); + }); }); -function makeCell(kind: UdtKind, args: string, balance: string) { +function makeCell(kind: UdtKind, args: string, balance: string, index = 0) { const codeHash = kind === 'sudt' ? '0x' + 'c3'.repeat(32) : '0x' + 'dd'.repeat(32); return { - outPoint: { txHash: '0x' + '00'.repeat(32), index: 0 }, + outPoint: { txHash: '0x' + '00'.repeat(32), index }, cellOutput: { type: { codeHash, hashType: 'type', args }, }, diff --git a/tests/session-signals.test.ts b/tests/session-signals.test.ts new file mode 100644 index 0000000..9211280 --- /dev/null +++ b/tests/session-signals.test.ts @@ -0,0 +1,106 @@ +jest.mock('../src/util/logger', () => ({ logger: { info: jest.fn(), error: jest.fn() } })); + +import { installSessionSignalHandlers } from '../src/util/session-signals'; + +type Listener = () => void; +const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const; + +function install() { + const before = Object.fromEntries(SIGNALS.map((signal) => [signal, process.listeners(signal) as Listener[]])); + const handlers = installSessionSignalHandlers(); + const emit = Object.fromEntries( + SIGNALS.map((signal) => [ + signal, + (process.listeners(signal) as Listener[]).filter((listener) => !before[signal].includes(listener))[0], + ]), + ) as Record<(typeof SIGNALS)[number], Listener>; + return { handlers, emit }; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +describe('installSessionSignalHandlers', () => { + let exitSpy: jest.SpyInstance; + + beforeEach(() => { + exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + }); + + afterEach(() => { + exitSpy.mockRestore(); + }); + + it('exits with 128+n during startup so the exit hook stops the children', () => { + for (const [signal, code] of [ + ['SIGINT', 130], + ['SIGTERM', 143], + ['SIGHUP', 129], + ] as const) { + const { handlers, emit } = install(); + emit[signal](); + expect(exitSpy).toHaveBeenLastCalledWith(code); + handlers.dispose(); + } + }); + + it('runs the session shutdown before exiting once the session is up', async () => { + const { handlers, emit } = install(); + let release: () => void = () => {}; + const shutdown = jest.fn(() => new Promise((resolve) => (release = resolve))); + handlers.setShutdown('the devnet', shutdown); + + emit.SIGTERM(); + expect(shutdown).toHaveBeenCalledWith('SIGTERM'); + await flush(); + expect(exitSpy).not.toHaveBeenCalled(); + + release(); + await flush(); + expect(exitSpy).toHaveBeenCalledWith(143); + handlers.dispose(); + }); + + it('still exits when the shutdown fails', async () => { + const { handlers, emit } = install(); + handlers.setShutdown('the devnet', () => Promise.reject(new Error('cleanup failed'))); + emit.SIGHUP(); + await flush(); + expect(exitSpy).toHaveBeenCalledWith(129); + handlers.dispose(); + }); + + it('forces the exit on a second signal while the shutdown is in progress', () => { + const { handlers, emit } = install(); + handlers.setShutdown('the devnet', () => new Promise(() => {})); + emit.SIGINT(); + expect(exitSpy).not.toHaveBeenCalled(); + emit.SIGINT(); + expect(exitSpy).toHaveBeenCalledWith(130); + handlers.dispose(); + }); + + it('leaves the first signal to a delegated flow but never swallows a second one', () => { + const { handlers, emit } = install(); + const endDelegation = handlers.delegate(); + emit.SIGTERM(); + expect(exitSpy).not.toHaveBeenCalled(); + emit.SIGTERM(); + expect(exitSpy).toHaveBeenCalledWith(143); + endDelegation(); + handlers.dispose(); + }); + + it('owns the signals again after the delegation ends', () => { + const { handlers, emit } = install(); + handlers.delegate()(); + emit.SIGTERM(); + expect(exitSpy).toHaveBeenCalledWith(143); + handlers.dispose(); + }); + + it('removes its listeners on dispose', () => { + const { handlers, emit } = install(); + handlers.dispose(); + for (const signal of SIGNALS) expect(process.listeners(signal)).not.toContain(emit[signal]); + }); +}); diff --git a/tests/setting.test.ts b/tests/setting.test.ts index 8a8a6ad..20ddcbc 100644 --- a/tests/setting.test.ts +++ b/tests/setting.test.ts @@ -25,7 +25,7 @@ jest.mock('../src/cfg/env-path', () => { }; }); -import { readSettings, writeSettings, defaultSettings, configPath } from '../src/cfg/setting'; +import { readSettings, readSettingsStrict, writeSettings, defaultSettings, configPath } from '../src/cfg/setting'; import { logger } from '../src/util/logger'; describe('settings ckb-tui version handling', () => { @@ -74,6 +74,188 @@ describe('settings ckb-tui version handling', () => { expect(readSettings().tools.ckbTui.version).toBe('not-a-version'); }); + + it('falls back to defaults and logs error when config file contains corrupted JSON', () => { + fs.writeFileSync(configPath, '{ corrupted json:'); + + const settings = readSettings(); + expect(settings.bins.defaultCKBVersion).toBe(defaultSettings.bins.defaultCKBVersion); + expect(logger.error).toHaveBeenCalledWith('Error reading settings:', expect.any(SyntaxError)); + }); + + describe('strict mode (readSettingsStrict)', () => { + it('returns default settings when config file does not exist', () => { + expect(fs.existsSync(configPath)).toBe(false); + const settings = readSettingsStrict(); + expect(settings.bins.defaultCKBVersion).toBe(defaultSettings.bins.defaultCKBVersion); + }); + + it('throws Error containing configPath and reason when JSON is corrupted', () => { + fs.writeFileSync(configPath, '{ invalid: json'); + + expect(() => readSettingsStrict()).toThrow( + expect.objectContaining({ + message: expect.stringMatching(new RegExp(`Failed to read settings from .*${path.basename(configPath)}`)), + }), + ); + expect(() => readSettingsStrict()).toThrow(configPath); + }); + + it('throws Error containing configPath when file read fails', () => { + writeConfig({ proxy: { host: '127.0.0.1', port: 8080 } }); + const readSpy = jest.spyOn(fs, 'readFileSync').mockImplementationOnce(() => { + const err = new Error('EACCES: permission denied') as NodeJS.ErrnoException; + err.code = 'EACCES'; + throw err; + }); + + try { + expect(() => readSettingsStrict()).toThrow(configPath); + } finally { + readSpy.mockRestore(); + } + }); + + it('throws Error containing configPath when validation fails', () => { + fs.writeFileSync(configPath, JSON.stringify([1, 2, 3])); + + expect(() => readSettingsStrict()).toThrow(configPath); + expect(() => readSettingsStrict()).toThrow('Settings must be a JSON object'); + }); + + it('rejects corrupted sections like an array bins or non-object tools', () => { + fs.writeFileSync(configPath, JSON.stringify({ bins: [] })); + expect(() => readSettingsStrict()).toThrow('bins must be an object'); + + fs.writeFileSync(configPath, JSON.stringify({ bins: null })); + expect(() => readSettingsStrict()).toThrow('bins must be an object'); + + fs.writeFileSync(configPath, JSON.stringify({ tools: null })); + expect(() => readSettingsStrict()).toThrow('tools must be an object'); + + fs.writeFileSync(configPath, JSON.stringify({ tools: 'invalid' })); + expect(() => readSettingsStrict()).toThrow('tools must be an object'); + + // proxy: null is allowed + fs.writeFileSync(configPath, JSON.stringify({ proxy: null })); + expect(readSettingsStrict().proxy).toBeNull(); + }); + }); + }); + + describe('writeSettings', () => { + it('creates config file and parent directory when file does not exist', () => { + fs.rmSync(path.dirname(configPath), { recursive: true, force: true }); + expect(fs.existsSync(configPath)).toBe(false); + + const settings = readSettings(); + settings.proxy = { host: '10.0.0.1', port: 3128 }; + writeSettings(settings); + + expect(fs.existsSync(configPath)).toBe(true); + const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')); + expect(parsed.proxy).toEqual({ host: '10.0.0.1', port: 3128 }); + }); + + it('leaves no temporary files after successful save', () => { + const settings = readSettings(); + writeSettings(settings); + + const dirFiles = fs.readdirSync(path.dirname(configPath)); + const tempFiles = dirFiles.filter((f) => f.includes('.tmp')); + expect(tempFiles).toHaveLength(0); + }); + + it('preserves existing file permissions when saving atomically', () => { + writeConfig({ proxy: { host: '127.0.0.1', port: 8080 } }); + fs.chmodSync(configPath, 0o600); + const initialMode = fs.statSync(configPath).mode & 0o777; + + const settings = readSettings(); + settings.proxy = { host: '127.0.0.1', port: 9090 }; + writeSettings(settings); + + const updatedMode = fs.statSync(configPath).mode & 0o777; + expect(updatedMode).toBe(initialMode); + }); + + it('throws Error and leaves original file intact when statSync fails with non-ENOENT error', () => { + writeConfig({ proxy: { host: 'original.proxy', port: 8080 } }); + const originalContent = fs.readFileSync(configPath, 'utf8'); + + const statSpy = jest.spyOn(fs, 'statSync').mockImplementation(() => { + const err = new Error('EACCES: permission denied') as NodeJS.ErrnoException; + err.code = 'EACCES'; + throw err; + }); + + try { + const settings = readSettings(); + settings.proxy = { host: 'new.proxy', port: 9090 }; + expect(() => writeSettings(settings)).toThrow(configPath); + expect(() => writeSettings(settings)).toThrow('EACCES'); + } finally { + statSpy.mockRestore(); + } + + // Original content is unchanged + expect(fs.readFileSync(configPath, 'utf8')).toBe(originalContent); + // Temporary file was cleaned up + const dirFiles = fs.readdirSync(path.dirname(configPath)); + expect(dirFiles.filter((f) => f.includes('.tmp'))).toHaveLength(0); + }); + + it('throws Error and leaves original file intact when writeFileSync fails', () => { + writeConfig({ proxy: { host: 'original.proxy', port: 8080 } }); + const originalContent = fs.readFileSync(configPath, 'utf8'); + + const writeSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => { + throw new Error('ENOSPC: no space left on device'); + }); + + try { + const settings = readSettings(); + settings.proxy = { host: 'new.proxy', port: 9090 }; + expect(() => writeSettings(settings)).toThrow( + expect.objectContaining({ + message: expect.stringMatching(new RegExp(`Failed to write settings to .*${path.basename(configPath)}`)), + }), + ); + expect(() => writeSettings(settings)).toThrow('ENOSPC'); + } finally { + writeSpy.mockRestore(); + } + + // Original content is unchanged + expect(fs.readFileSync(configPath, 'utf8')).toBe(originalContent); + // No temporary file remained + const dirFiles = fs.readdirSync(path.dirname(configPath)); + expect(dirFiles.filter((f) => f.includes('.tmp'))).toHaveLength(0); + }); + + it('throws Error and leaves original file intact when renameSync fails', () => { + writeConfig({ proxy: { host: 'original.proxy', port: 8080 } }); + const originalContent = fs.readFileSync(configPath, 'utf8'); + + const renameSpy = jest.spyOn(fs, 'renameSync').mockImplementation(() => { + throw new Error('EACCES: permission denied'); + }); + + try { + const settings = readSettings(); + settings.proxy = { host: 'new.proxy', port: 9090 }; + expect(() => writeSettings(settings)).toThrow(configPath); + expect(() => writeSettings(settings)).toThrow('EACCES'); + } finally { + renameSpy.mockRestore(); + } + + // Original content is unchanged + expect(fs.readFileSync(configPath, 'utf8')).toBe(originalContent); + // Temporary file was cleaned up + const dirFiles = fs.readdirSync(path.dirname(configPath)); + expect(dirFiles.filter((f) => f.includes('.tmp'))).toHaveLength(0); + }); }); describe('writeSettings', () => { diff --git a/tests/stale-miner.test.ts b/tests/stale-miner.test.ts new file mode 100644 index 0000000..1c349a9 --- /dev/null +++ b/tests/stale-miner.test.ts @@ -0,0 +1,139 @@ +import { + findStaleMiners, + isMinerCommandFor, + minerArgs, + parsePosixProcessList, + parseWindowsProcessList, + ProcessEntry, +} from '../src/devnet/stale-miner'; + +const CONFIG = '/Users/dev/Library/Application Support/offckb-nodejs/devnet'; +const CKB = '/Users/dev/Library/Application Support/offckb-nodejs/bins/0.204.0/ckb'; + +function entry(pid: number, ppid: number, name: string, args: string): ProcessEntry { + return { pid, ppid, name, args }; +} + +describe('isMinerCommandFor', () => { + it('matches the exact miner command for the devnet dir, including paths with spaces', () => { + expect(isMinerCommandFor(`${CKB} miner -C ${CONFIG}`, CONFIG)).toBe(true); + expect(isMinerCommandFor(`ckb miner -C ${CONFIG}`, CONFIG)).toBe(true); + }); + + it('matches Windows quoted command lines', () => { + // Node quotes an argument only when it contains whitespace or quotes. + const spaced = 'C:\\Users\\Jane Doe\\AppData\\Local\\offckb-nodejs\\Data\\devnet'; + expect(isMinerCommandFor(`"C:\\Program Files\\offckb\\ckb.exe" miner -C "${spaced}"`, spaced)).toBe(true); + const plain = 'C:\\Users\\dev\\AppData\\Local\\offckb-nodejs\\Data\\devnet'; + expect(isMinerCommandFor(`C:\\offckb\\ckb.exe miner -C ${plain}`, plain)).toBe(true); + }); + + it('rejects other commands, other dirs and other executables', () => { + expect(isMinerCommandFor(`${CKB} run -C ${CONFIG}`, CONFIG)).toBe(false); + expect(isMinerCommandFor(`${CKB} miner -C ${CONFIG}-other`, CONFIG)).toBe(false); + expect(isMinerCommandFor(`${CKB} miner -C /other/devnet`, CONFIG)).toBe(false); + expect(isMinerCommandFor(`/usr/bin/not-ckb miner -C ${CONFIG}`, CONFIG)).toBe(false); + expect(isMinerCommandFor(`/bin/sh -c ckb miner -C ${CONFIG}`, CONFIG)).toBe(false); + expect(isMinerCommandFor(`vim ${CONFIG}`, CONFIG)).toBe(false); + }); +}); + +describe('findStaleMiners', () => { + const minerArgs = `${CKB} miner -C ${CONFIG}`; + + it('selects miners whose offckb parent is gone (re-parented to launchd/init/a subreaper)', () => { + const entries = [ + entry(1, 0, '/sbin/launchd', '/sbin/launchd'), + entry(52, 1, 'systemd', '/lib/systemd/systemd --user'), + entry(100, 1, CKB, minerArgs), + entry(101, 52, 'ckb', minerArgs), + ]; + expect(findStaleMiners(entries, CONFIG, 999).map((e) => e.pid)).toEqual([100, 101]); + }); + + it('selects miners whose parent PID no longer exists (Windows keeps the dead parent PID)', () => { + const entries = [entry(200, 4242, 'ckb.exe', minerArgs)]; + expect(findStaleMiners(entries, CONFIG, 999).map((e) => e.pid)).toEqual([200]); + }); + + it('leaves miners of a live offckb (Node) process alone', () => { + const entries = [ + entry(300, 1, '/usr/local/bin/node', '/usr/local/bin/node /usr/local/bin/offckb node'), + entry(301, 300, 'ckb', minerArgs), + entry(310, 1, 'node.exe', 'node.exe offckb node'), + entry(311, 310, 'ckb.exe', minerArgs), + ]; + expect(findStaleMiners(entries, CONFIG, 999)).toEqual([]); + }); + + it('never selects processes for another devnet dir or that are not miners', () => { + const entries = [ + entry(400, 1, 'ckb', `${CKB} miner -C /other/devnet`), + entry(401, 1, 'ckb', `${CKB} run -C ${CONFIG}`), + entry(402, 1, 'bash', `bash -c sleep 1000`), + ]; + expect(findStaleMiners(entries, CONFIG, 999)).toEqual([]); + }); +}); + +describe('minerArgs', () => { + it('is what the matcher recognizes, so the spawn site and the reaper cannot drift apart', () => { + expect(isMinerCommandFor([CKB, ...minerArgs(CONFIG)].join(' '), CONFIG)).toBe(true); + expect(isMinerCommandFor([CKB, ...minerArgs(CONFIG), '--extra'].join(' '), CONFIG)).toBe(false); + }); +}); + +describe('parsePosixProcessList', () => { + it('joins the args and comm passes by PID, keeping spaces in both', () => { + const argsOut = [ + ' 1 0 /sbin/launchd', + ` 812 1 ${CKB} miner -C ${CONFIG}`, + ' 900 850 /usr/local/bin/node /usr/local/bin/offckb node', + '', + ].join('\n'); + const commOut = [' 1 /sbin/launchd', ` 812 ${CKB}`, ' 900 /usr/local/bin/node', ''].join('\n'); + expect(parsePosixProcessList(argsOut, commOut)).toEqual([ + { pid: 1, ppid: 0, name: '/sbin/launchd', args: '/sbin/launchd' }, + { pid: 812, ppid: 1, name: CKB, args: `${CKB} miner -C ${CONFIG}` }, + { pid: 900, ppid: 850, name: '/usr/local/bin/node', args: '/usr/local/bin/node /usr/local/bin/offckb node' }, + ]); + expect(findStaleMiners(parsePosixProcessList(argsOut, commOut), CONFIG, 999).map((e) => e.pid)).toEqual([812]); + }); +}); + +describe('parseWindowsProcessList', () => { + const config = 'C:\\Users\\dev\\AppData\\Local\\offckb-nodejs\\Data\\devnet'; + + it('parses CIM JSON rows and finds a miner whose parent PID is gone', () => { + const json = JSON.stringify([ + { ProcessId: 4, ParentProcessId: 0, Name: 'System', CommandLine: null }, + { + ProcessId: 5120, + ParentProcessId: 4242, + Name: 'ckb.exe', + CommandLine: `"C:\\Program Files\\offckb\\ckb.exe" miner -C ${config}`, + }, + ]); + const entries = parseWindowsProcessList(json)!; + expect(entries).toEqual([ + { pid: 4, ppid: 0, name: 'System', args: '' }, + { + pid: 5120, + ppid: 4242, + name: 'ckb.exe', + args: `"C:\\Program Files\\offckb\\ckb.exe" miner -C ${config}`, + }, + ]); + expect(findStaleMiners(entries, config, 999).map((e) => e.pid)).toEqual([5120]); + }); + + it('accepts the bare object ConvertTo-Json emits for a single row', () => { + const json = JSON.stringify({ ProcessId: 7, ParentProcessId: 1, Name: 'ckb.exe', CommandLine: 'ckb.exe' }); + expect(parseWindowsProcessList(json)).toEqual([{ pid: 7, ppid: 1, name: 'ckb.exe', args: 'ckb.exe' }]); + }); + + it('returns null for malformed output', () => { + expect(parseWindowsProcessList('not json')).toBeNull(); + expect(parseWindowsProcessList('null')).toBeNull(); + }); +});