diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8e821e9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: ci + +on: + push: + branches: [main, codex-openclaw] + pull_request: + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - run: npm ci + - run: npm run typecheck + - run: npm test diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..759758e --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,48 @@ +name: publish + +on: + push: + tags: ['v*'] + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - run: npm ci + - run: npm run typecheck + - run: npm test + + publish: + needs: test + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + registry-url: 'https://registry.npmjs.org' + - run: npm ci + - name: Verify tag matches package.json version + run: | + TAG_VERSION="${GITHUB_REF_NAME#v}" + PKG_VERSION="$(node -p "require('./package.json').version")" + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "::error::Tag $GITHUB_REF_NAME does not match package.json version $PKG_VERSION" + exit 1 + fi + - run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 7e6eca6..d3ca228 100644 --- a/.gitignore +++ b/.gitignore @@ -153,3 +153,6 @@ Thumbs.db # Private maintainer notes (not for public repo) private-readme.md +# Git worktrees +.worktrees/ + diff --git a/package-lock.json b/package-lock.json index 23492a8..3751ca9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "0.1.1", "license": "MIT", "dependencies": { + "@inquirer/prompts": "^8.4.2", "chalk": "^5.3.0", "commander": "^12.0.0", + "cross-spawn": "^7.0.6", "node-fetch": "^3.3.0", "open": "^10.0.0", "ora": "^8.0.0" @@ -19,6 +21,7 @@ "custena-connect": "dist/cli.js" }, "devDependencies": { + "@types/cross-spawn": "^6.0.6", "@types/node": "^22.0.0", "tsx": "^4.15.0", "typescript": "^5.5.0", @@ -470,6 +473,334 @@ "node": ">=18" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz", + "integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.1.4.tgz", + "integrity": "sha512-w6KF8ZYRvqHhROkOTHXYC3qIV/KYEu5o12oLqQySvch61vrYtRxNSHTONSdJqWiFJPlCUQAHT5OgOIyuTr+MHQ==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.9", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.12.tgz", + "integrity": "sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.1.9", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.9.tgz", + "integrity": "sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.1.1.tgz", + "integrity": "sha512-6y11LgmNpmn5D2aB5FgnCfBUBK8ZstwLCalyJmORcJZ/WrhOjm16mu6eSqIx8DnErxDqSLr+Jkp+GP8/Nwd5tA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@inquirer/external-editor": "^3.0.0", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.13.tgz", + "integrity": "sha512-dF2zvrFo9LshkcB23/O1il13kBkBltWIXzut1evfbuBLXMiGIuC45c+ZQ0uukjCDsvI8OWqun4FRYMnzFCQa3g==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.0.tgz", + "integrity": "sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.5.tgz", + "integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.12.tgz", + "integrity": "sha512-uiMFBl4LqFzJClh80Q3f9hbOFJ6kgkDWI4LjAeBuyO6EanVVMF69AgOvpi1qdqjDSjDN6578B6nky9ceEpI+1Q==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.12.tgz", + "integrity": "sha512-/vrwhEf7Xsuh+YlHF4IjSy3g1cyrQuPaSiHIxCEbLu8qnfvrcvJyCkoktOOF+xV9gSb77/G0n3h04RbMDW2sIg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.12.tgz", + "integrity": "sha512-CBh7YHju623lxJRcAOo498ZUwIuMy63bqW/vVq0tQAZVv+lkWlHkP9ealYE1utWSisEShY5VMdzIXRmyEODzcQ==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.4.2.tgz", + "integrity": "sha512-XJmn/wY4AX56l1BRU+ZjDrFtg9+2uBEi4JvJQj82kwJDQKiPgSn4CEsbfGGygS4Gw6rkL4W18oATjfVfaqub2Q==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.1.4", + "@inquirer/confirm": "^6.0.12", + "@inquirer/editor": "^5.1.1", + "@inquirer/expand": "^5.0.13", + "@inquirer/input": "^5.0.12", + "@inquirer/number": "^4.0.12", + "@inquirer/password": "^5.0.12", + "@inquirer/rawlist": "^5.2.8", + "@inquirer/search": "^4.1.8", + "@inquirer/select": "^5.1.4" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.2.8.tgz", + "integrity": "sha512-Su7FQvp5buZmCymN3PPoYv31ZQQX4ve2j02k7piGgKAWgE+AQRB5YoYVveGXcl3TZ9ldgRMSxj56YfDFmmaqLg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.8.tgz", + "integrity": "sha512-fGiHKGD6DyPIYUWxoXnQTeXeyYqSOUrasDMABBmMHUalH/LxkuzY0xVRtimXAt1sUeeyYkVuKQx1bebMuN11Kw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.1.4.tgz", + "integrity": "sha512-2kWcGKPMLAXAWRp1AH1SLsQmX+j0QjeljyXMUji9WMZC8nRDO0b7qquIGr6143E7KMLt3VAIGNXzwa/6PXQs4Q==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.9", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.5.tgz", + "integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -827,6 +1158,16 @@ "win32" ] }, + "node_modules/@types/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -838,7 +1179,7 @@ "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -1033,6 +1374,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "license": "MIT" + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -1070,6 +1417,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -1079,6 +1435,20 @@ "node": ">=18" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -1231,6 +1601,30 @@ "node": ">=12.0.0" } }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", + "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -1306,6 +1700,22 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -1378,6 +1788,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, "node_modules/log-symbols": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", @@ -1442,6 +1858,15 @@ "dev": true, "license": "MIT" }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1555,6 +1980,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -1691,6 +2125,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1860,7 +2321,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/vite": { @@ -2451,6 +2912,21 @@ "node": ">= 8" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/package.json b/package.json index 1dc1a7a..cc3bd47 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "custena-connect", - "version": "0.1.1", + "version": "0.1.2", "description": "Connect your AI coding agent to Custena — pay HTTP 402 responses from your Custena buyer account.", "keywords": [ "custena", @@ -38,16 +38,19 @@ "prepublishOnly": "npm run build" }, "dependencies": { + "@inquirer/prompts": "^8.4.2", + "chalk": "^5.3.0", "commander": "^12.0.0", + "cross-spawn": "^7.0.6", + "node-fetch": "^3.3.0", "open": "^10.0.0", - "ora": "^8.0.0", - "chalk": "^5.3.0", - "node-fetch": "^3.3.0" + "ora": "^8.0.0" }, "devDependencies": { + "@types/cross-spawn": "^6.0.6", "@types/node": "^22.0.0", - "typescript": "^5.5.0", "tsx": "^4.15.0", + "typescript": "^5.5.0", "vitest": "^2.0.0" }, "engines": { diff --git a/src/__tests__/claude-code-adapter.test.ts b/src/__tests__/claude-code-adapter.test.ts index 23f8dbf..0d69a54 100644 --- a/src/__tests__/claude-code-adapter.test.ts +++ b/src/__tests__/claude-code-adapter.test.ts @@ -2,12 +2,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import os from 'os'; import path from 'path'; -// We mock 'fs/promises' and 'child_process' before importing the adapter +// We mock 'fs/promises' and the shared exec wrapper before importing the adapter. +// The adapter now goes through runSync (which wraps cross-spawn) instead of +// execFileSync directly, so PATHEXT resolution works on Windows. vi.mock('fs/promises'); -vi.mock('child_process'); +vi.mock('../shared/exec.js'); import fs from 'fs/promises'; -import { execSync } from 'child_process'; +import { runSync } from '../shared/exec.js'; import { ClaudeCodeAdapter } from '../adapters/claude-code.js'; const HOME = os.homedir(); @@ -41,22 +43,19 @@ describe('ClaudeCodeAdapter.detect()', () => { it('falls back to `which claude` binary check when ~/.claude is absent', async () => { // First access call throws (directory not found) vi.mocked(fs.access).mockRejectedValueOnce(new Error('ENOENT')); - // execSync('which claude') succeeds (no throw) - vi.mocked(execSync).mockReturnValueOnce(Buffer.from('/usr/local/bin/claude')); + // runSync('which', ['claude']) succeeds (no throw, returns void) + vi.mocked(runSync).mockReturnValueOnce(undefined); const result = await adapter.detect(); expect(result.installed).toBe(true); expect(result.configPath).toBe(SETTINGS_PATH); - expect(execSync).toHaveBeenCalledWith('which claude', { stdio: 'ignore' }); + expect(runSync).toHaveBeenCalledWith('which', ['claude'], { stdio: 'ignore' }); }); it('falls back to VS Code extension scan when directory and binary are absent', async () => { - // ~/.claude access fails vi.mocked(fs.access).mockRejectedValueOnce(new Error('ENOENT')); - // which claude fails - vi.mocked(execSync).mockImplementationOnce(() => { throw new Error('not found'); }); - // readdir returns a list containing a claude extension + vi.mocked(runSync).mockImplementationOnce(() => { throw new Error('not found'); }); vi.mocked(fs.readdir).mockResolvedValueOnce( ['anthropic.claude-vscode-1.0.0'] as any ); @@ -69,11 +68,8 @@ describe('ClaudeCodeAdapter.detect()', () => { }); it('returns installed=false when none of the three checks find Claude', async () => { - // ~/.claude access fails vi.mocked(fs.access).mockRejectedValueOnce(new Error('ENOENT')); - // which claude fails - vi.mocked(execSync).mockImplementationOnce(() => { throw new Error('not found'); }); - // VS Code extensions dir has no claude entries + vi.mocked(runSync).mockImplementationOnce(() => { throw new Error('not found'); }); vi.mocked(fs.readdir).mockResolvedValueOnce( ['ms-vscode.csharp-1.0.0', 'esbenp.prettier-vscode-1.0.0'] as any ); @@ -85,11 +81,8 @@ describe('ClaudeCodeAdapter.detect()', () => { }); it('returns installed=false when VS Code extensions directory does not exist', async () => { - // ~/.claude access fails vi.mocked(fs.access).mockRejectedValueOnce(new Error('ENOENT')); - // which claude fails - vi.mocked(execSync).mockImplementationOnce(() => { throw new Error('not found'); }); - // readdir throws (no .vscode/extensions dir) + vi.mocked(runSync).mockImplementationOnce(() => { throw new Error('not found'); }); vi.mocked(fs.readdir).mockRejectedValueOnce(new Error('ENOENT')); const result = await adapter.detect(); diff --git a/src/__tests__/codex-adapter.test.ts b/src/__tests__/codex-adapter.test.ts new file mode 100644 index 0000000..048bc1c --- /dev/null +++ b/src/__tests__/codex-adapter.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import os from 'os'; +import path from 'path'; + +vi.mock('fs/promises'); +vi.mock('../shared/exec.js'); + +import fs from 'fs/promises'; +import { runSync } from '../shared/exec.js'; +import { CodxAdapter, patchTomlSection, removeTomlSection } from '../adapters/codex.js'; +import { MCP_URL } from '../config.js'; + +const HOME = os.homedir(); +const CODEX_DIR = path.join(HOME, '.codex'); +const CONFIG_PATH = path.join(CODEX_DIR, 'config.toml'); +const MOCK_OAUTH = { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + expiresAt: 9_999_999_999, + clientId: 'custena-connect-cli', +}; + +describe('patchTomlSection()', () => { + it('adds a new section to empty content', () => { + const result = patchTomlSection('', 'mcp_servers.custena', { + url: 'https://api.custena.com/mcp', + bearer_token: 'tok123', + }); + expect(result).toContain('[mcp_servers.custena]'); + expect(result).toContain('url = "https://api.custena.com/mcp"'); + expect(result).toContain('bearer_token = "tok123"'); + }); + + it('replaces an existing custena section leaving other sections intact', () => { + const existing = [ + '[mcp_servers.custena]', + 'url = "https://old.com"', + 'bearer_token = "old-token"', + '', + '[mcp_servers.other]', + 'command = "other-server"', + '', + ].join('\n'); + + const result = patchTomlSection(existing, 'mcp_servers.custena', { + url: 'https://new.com', + bearer_token: 'new-token', + }); + + expect(result).not.toContain('"https://old.com"'); + expect(result).not.toContain('"old-token"'); + expect(result).toContain('url = "https://new.com"'); + expect(result).toContain('bearer_token = "new-token"'); + expect(result).toContain('[mcp_servers.other]'); + expect(result).toContain('command = "other-server"'); + }); + + it('preserves content that appears before the target section', () => { + const existing = 'model = "gpt-4o"\n\n[mcp_servers.custena]\nurl = "old"\n'; + const result = patchTomlSection(existing, 'mcp_servers.custena', { url: 'new' }); + expect(result).toContain('model = "gpt-4o"'); + expect(result).not.toContain('"old"'); + }); + + it('handles section not present in file (appends)', () => { + const existing = 'model = "gpt-4o"\n'; + const result = patchTomlSection(existing, 'mcp_servers.custena', { url: 'https://x.com' }); + expect(result).toContain('model = "gpt-4o"'); + expect(result).toContain('[mcp_servers.custena]'); + expect(result).toContain('url = "https://x.com"'); + }); + + it('is idempotent — calling twice produces the same result as calling once', () => { + const fields = { url: 'https://api.custena.com/mcp', bearer_token: 'tok' }; + const first = patchTomlSection('model = "gpt-4o"\n', 'mcp_servers.custena', fields); + const second = patchTomlSection(first, 'mcp_servers.custena', fields); + expect(second).toBe(first); + }); +}); + +describe('removeTomlSection()', () => { + it('removes the target section and preserves everything else', () => { + const existing = [ + '[mcp_servers.custena]', + 'url = "https://api.custena.com/mcp"', + '', + '[mcp_servers.other]', + 'command = "other-server"', + '', + ].join('\n'); + + const result = removeTomlSection(existing, 'mcp_servers.custena'); + expect(result).not.toContain('[mcp_servers.custena]'); + expect(result).not.toContain('https://api.custena.com/mcp'); + expect(result).toContain('[mcp_servers.other]'); + expect(result).toContain('command = "other-server"'); + }); + + it('returns content unchanged when section is not present', () => { + const existing = '[mcp_servers.other]\ncommand = "other"\n'; + const result = removeTomlSection(existing, 'mcp_servers.custena'); + expect(result).toContain('[mcp_servers.other]'); + expect(result).not.toContain('[mcp_servers.custena]'); + expect(result).toBe(existing); + }); + + it('handles empty content gracefully', () => { + const result = removeTomlSection('', 'mcp_servers.custena'); + expect(result).toBe(''); + }); +}); + +describe('CodxAdapter.detect()', () => { + let adapter: CodxAdapter; + beforeEach(() => { adapter = new CodxAdapter(); vi.clearAllMocks(); }); + + it('returns installed=true when ~/.codex directory exists', async () => { + vi.mocked(fs.access).mockResolvedValueOnce(undefined); + const result = await adapter.detect(); + expect(result.installed).toBe(true); + expect(result.configPath).toBe(CONFIG_PATH); + expect(fs.access).toHaveBeenCalledWith(CODEX_DIR); + }); + + it('falls back to `which codex` when ~/.codex is absent', async () => { + vi.mocked(fs.access).mockRejectedValueOnce(new Error('ENOENT')); + vi.mocked(runSync).mockReturnValueOnce(undefined); + const result = await adapter.detect(); + expect(result.installed).toBe(true); + expect(runSync).toHaveBeenCalledWith('which', ['codex'], { stdio: 'ignore' }); + expect(result.configPath).toBe(CONFIG_PATH); + }); + + it('returns installed=false when neither check succeeds', async () => { + vi.mocked(fs.access).mockRejectedValueOnce(new Error('ENOENT')); + vi.mocked(runSync).mockImplementationOnce(() => { throw new Error('not found'); }); + const result = await adapter.detect(); + expect(result.installed).toBe(false); + expect(result.configPath).toBeUndefined(); + }); +}); + +describe('CodxAdapter.writeMcpConfig()', () => { + let adapter: CodxAdapter; + beforeEach(() => { adapter = new CodxAdapter(); vi.clearAllMocks(); }); + + it('writes config.toml with [mcp_servers.custena] url and bearer_token', async () => { + vi.mocked(fs.readFile).mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await adapter.writeMcpConfig(MOCK_OAUTH); + + expect(fs.writeFile).toHaveBeenCalledOnce(); + const [writePath, content] = vi.mocked(fs.writeFile).mock.calls[0] as [string, string]; + expect(writePath).toBe(CONFIG_PATH); + expect(content).toContain('[mcp_servers.custena]'); + expect(content).toContain(`url = ${JSON.stringify(MCP_URL)}`); + expect(content).toContain('bearer_token = "test-access-token"'); + expect(content).toContain('default_tools_approval_mode = "approve"'); + }); + + it('preserves existing non-custena TOML content', async () => { + const existing = 'model = "gpt-4o"\nsandbox_mode = "workspace-write"\n'; + vi.mocked(fs.readFile).mockResolvedValueOnce(existing as any); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await adapter.writeMcpConfig(MOCK_OAUTH); + + const [, content] = vi.mocked(fs.writeFile).mock.calls[0] as [string, string]; + expect(content).toContain('model = "gpt-4o"'); + expect(content).toContain('sandbox_mode = "workspace-write"'); + }); +}); + +describe('CodxAdapter.removeAll()', () => { + let adapter: CodxAdapter; + beforeEach(() => { adapter = new CodxAdapter(); vi.clearAllMocks(); }); + + it('removes [mcp_servers.custena] section and preserves other content', async () => { + const existing = [ + '[mcp_servers.custena]', + 'url = "https://api.custena.com/mcp"', + 'bearer_token = "tok"', + '', + '[mcp_servers.other]', + 'command = "other"', + '', + ].join('\n'); + vi.mocked(fs.readFile).mockResolvedValueOnce(existing as any); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await adapter.removeAll(); + + const [, content] = vi.mocked(fs.writeFile).mock.calls[0] as [string, string]; + expect(content).not.toContain('[mcp_servers.custena]'); + expect(content).toContain('[mcp_servers.other]'); + }); + + it('does nothing when config.toml does not exist', async () => { + vi.mocked(fs.readFile).mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + await adapter.removeAll(); + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + + it('does not write when the section is not present in the file', async () => { + const existing = '[mcp_servers.other]\ncommand = "other"\n'; + vi.mocked(fs.readFile).mockResolvedValueOnce(existing as any); + + await adapter.removeAll(); + + expect(fs.writeFile).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/exec.test.ts b/src/__tests__/exec.test.ts new file mode 100644 index 0000000..cb8481a --- /dev/null +++ b/src/__tests__/exec.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// cross-spawn's sync export is the default export. vi.mock replaces the whole +// module so we can control spawn.sync's return value and verify runSync +// translates both failure modes into the execFileSync-equivalent throws. +vi.mock('cross-spawn', () => ({ + default: { sync: vi.fn() }, +})); + +import spawn from 'cross-spawn'; +import { runSync } from '../shared/exec.js'; + +describe('runSync', () => { + beforeEach(() => { + vi.mocked(spawn.sync).mockReset(); + }); + + it('returns normally when spawn exits with status 0', () => { + vi.mocked(spawn.sync).mockReturnValueOnce({ + pid: 1, output: [], stdout: Buffer.from(''), stderr: Buffer.from(''), + status: 0, signal: null, + }); + expect(() => runSync('which', ['claude'])).not.toThrow(); + expect(spawn.sync).toHaveBeenCalledWith('which', ['claude'], {}); + }); + + it('rethrows spawn.error when the binary cannot be spawned', () => { + const err = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + vi.mocked(spawn.sync).mockReturnValueOnce({ + pid: 0, output: [], stdout: Buffer.from(''), stderr: Buffer.from(''), + status: null, signal: null, error: err, + }); + expect(() => runSync('nonexistent-bin', [])).toThrow(err); + }); + + it('throws when spawn exits with a non-zero status (matches execFileSync semantics)', () => { + vi.mocked(spawn.sync).mockReturnValueOnce({ + pid: 1, output: [], stdout: Buffer.from(''), stderr: Buffer.from(''), + status: 1, signal: null, + }); + expect(() => runSync('which', ['does-not-exist'])).toThrow( + /which does-not-exist exited with status 1/, + ); + }); + + it('passes the options object through to spawn.sync unchanged', () => { + vi.mocked(spawn.sync).mockReturnValueOnce({ + pid: 1, output: [], stdout: Buffer.from(''), stderr: Buffer.from(''), + status: 0, signal: null, + }); + runSync('claude', ['mcp', 'add'], { stdio: 'inherit' }); + expect(spawn.sync).toHaveBeenCalledWith('claude', ['mcp', 'add'], { stdio: 'inherit' }); + }); +}); diff --git a/src/__tests__/install-selection.test.ts b/src/__tests__/install-selection.test.ts new file mode 100644 index 0000000..8d0b1b7 --- /dev/null +++ b/src/__tests__/install-selection.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from 'vitest'; +import { selectHosts } from '../commands/install.js'; +import type { HostAdapter, HostPresence } from '../types.js'; + +vi.mock('@inquirer/prompts', () => ({ + checkbox: vi.fn(), +})); + +import { checkbox } from '@inquirer/prompts'; + +function makeDetected(ids: string[]) { + return ids.map(id => ({ + adapter: { id, displayName: id.toUpperCase() } as unknown as HostAdapter, + presence: { installed: true } as HostPresence, + })); +} + +describe('selectHosts()', () => { + it('returns the single entry without prompting when only one host is detected', async () => { + const detected = makeDetected(['claude-code']); + const result = await selectHosts(detected); + expect(result).toEqual(detected); + expect(checkbox).not.toHaveBeenCalled(); + }); + + it('calls checkbox and returns user selection when multiple hosts are detected', async () => { + const detected = makeDetected(['claude-code', 'codex', 'openclaw']); + const claudeEntry = detected[0]; + const codexEntry = detected[1]; + vi.mocked(checkbox).mockResolvedValueOnce([claudeEntry.adapter, codexEntry.adapter] as any); + + const result = await selectHosts(detected); + + expect(checkbox).toHaveBeenCalledOnce(); + const [{ choices }] = vi.mocked(checkbox).mock.calls[0] as any; + expect(choices).toHaveLength(3); + expect(choices.every((c: any) => c.checked)).toBe(true); + expect(result.map(d => d.adapter.id)).toEqual(['claude-code', 'codex']); + }); + + it('returns empty array when user deselects all', async () => { + const detected = makeDetected(['claude-code', 'codex']); + vi.mocked(checkbox).mockResolvedValueOnce([] as any); + const result = await selectHosts(detected); + expect(result).toHaveLength(0); + }); +}); diff --git a/src/__tests__/openclaw-adapter.test.ts b/src/__tests__/openclaw-adapter.test.ts new file mode 100644 index 0000000..d1614fd --- /dev/null +++ b/src/__tests__/openclaw-adapter.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import os from 'os'; +import path from 'path'; + +vi.mock('fs/promises'); +vi.mock('../shared/exec.js'); + +import fs from 'fs/promises'; +import { runSync } from '../shared/exec.js'; +import { OpenClawAdapter } from '../adapters/openclaw.js'; + +const HOME = os.homedir(); +const OPENCLAW_DIR = path.join(HOME, '.openclaw'); +const CONFIG_PATH = path.join(OPENCLAW_DIR, 'openclaw.json'); +const SKILL_PATH = path.join(OPENCLAW_DIR, 'skills', 'custena-pay.md'); +const MOCK_OAUTH = { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + expiresAt: 9_999_999_999, + clientId: 'custena-connect-cli', +}; + +describe('OpenClawAdapter.detect()', () => { + let adapter: OpenClawAdapter; + beforeEach(() => { adapter = new OpenClawAdapter(); vi.clearAllMocks(); }); + + it('returns installed=true when ~/.openclaw directory exists', async () => { + vi.mocked(fs.access).mockResolvedValueOnce(undefined); + const result = await adapter.detect(); + expect(result.installed).toBe(true); + expect(result.configPath).toBe(CONFIG_PATH); + expect(fs.access).toHaveBeenCalledWith(OPENCLAW_DIR); + }); + + it('falls back to `which openclaw` when ~/.openclaw is absent', async () => { + vi.mocked(fs.access).mockRejectedValueOnce(new Error('ENOENT')); + vi.mocked(runSync).mockReturnValueOnce(undefined); + const result = await adapter.detect(); + expect(result.installed).toBe(true); + expect(result.configPath).toBe(CONFIG_PATH); + expect(runSync).toHaveBeenCalledWith('which', ['openclaw'], { stdio: 'ignore' }); + }); + + it('returns installed=false when neither check succeeds', async () => { + vi.mocked(fs.access).mockRejectedValueOnce(new Error('ENOENT')); + vi.mocked(runSync).mockImplementationOnce(() => { throw new Error('not found'); }); + const result = await adapter.detect(); + expect(result.installed).toBe(false); + expect(result.configPath).toBeUndefined(); + }); +}); + +describe('OpenClawAdapter.writeMcpConfig()', () => { + let adapter: OpenClawAdapter; + beforeEach(() => { adapter = new OpenClawAdapter(); vi.clearAllMocks(); }); + + it('uses the openclaw CLI when available', async () => { + vi.mocked(runSync).mockReturnValueOnce(undefined); + + await adapter.writeMcpConfig(MOCK_OAUTH); + + expect(runSync).toHaveBeenCalledOnce(); + const [cmd, args] = vi.mocked(runSync).mock.calls[0] as [string, string[]]; + expect(cmd).toBe('openclaw'); + expect(args[0]).toBe('mcp'); + expect(args[1]).toBe('set'); + expect(args[2]).toBe('custena'); + const json = JSON.parse(args[3]); + expect(json.url).toBe('https://api.custena.com/mcp'); + expect(json.headers.Authorization).toBe('Bearer test-access-token'); + }); + + it('falls back to direct JSON write when CLI throws', async () => { + vi.mocked(runSync).mockImplementationOnce(() => { throw new Error('command not found'); }); + vi.mocked(fs.readFile).mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await adapter.writeMcpConfig(MOCK_OAUTH); + + expect(fs.writeFile).toHaveBeenCalledOnce(); + const [writePath, content] = vi.mocked(fs.writeFile).mock.calls[0] as [string, string]; + expect(writePath).toBe(CONFIG_PATH); + const written = JSON.parse(content); + expect(written.mcp.servers.custena.url).toBe('https://api.custena.com/mcp'); + expect(written.mcp.servers.custena.headers.Authorization).toBe('Bearer test-access-token'); + }); + + it('merges into existing JSON config without clobbering other keys', async () => { + vi.mocked(runSync).mockImplementationOnce(() => { throw new Error('not found'); }); + const existing = JSON.stringify({ agents: { defaults: { skills: ['github'] } } }); + vi.mocked(fs.readFile).mockResolvedValueOnce(existing as any); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await adapter.writeMcpConfig(MOCK_OAUTH); + + const [, content] = vi.mocked(fs.writeFile).mock.calls[0] as [string, string]; + const written = JSON.parse(content); + expect(written.agents.defaults.skills).toContain('github'); + expect(written.mcp.servers.custena.url).toBe('https://api.custena.com/mcp'); + }); + + it('throws a helpful message when the config file exists but cannot be parsed', async () => { + vi.mocked(runSync).mockImplementationOnce(() => { throw new Error('not found'); }); + const json5Content = '{ agents: { defaults: { skills: [] } } }'; // unquoted keys = invalid JSON + vi.mocked(fs.readFile).mockResolvedValueOnce(json5Content as any); + + await expect(adapter.writeMcpConfig(MOCK_OAUTH)).rejects.toThrow( + /Could not parse.*openclaw\.json.*openclaw mcp set custena/ + ); + }); +}); + +describe('OpenClawAdapter.writeSkill()', () => { + let adapter: OpenClawAdapter; + beforeEach(() => { adapter = new OpenClawAdapter(); vi.clearAllMocks(); }); + + it('writes skill file to ~/.openclaw/skills/custena-pay.md with frontmatter', async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await adapter.writeSkill(); + + expect(fs.mkdir).toHaveBeenCalledWith( + path.join(OPENCLAW_DIR, 'skills'), + { recursive: true }, + ); + const [writePath, content] = vi.mocked(fs.writeFile).mock.calls[0] as [string, string]; + expect(writePath).toBe(SKILL_PATH); + expect(content).toContain('name: custena-pay'); + expect(content).toContain('description: Pay HTTP 402'); + expect(content).toContain('custena.pay_challenge'); + }); +}); + +describe('OpenClawAdapter.removeAll()', () => { + let adapter: OpenClawAdapter; + beforeEach(() => { adapter = new OpenClawAdapter(); vi.clearAllMocks(); }); + + it('calls `openclaw mcp unset custena` and deletes skill file', async () => { + vi.mocked(runSync).mockReturnValueOnce(undefined); + vi.mocked(fs.readFile).mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + vi.mocked(fs.unlink).mockResolvedValue(undefined); + + await adapter.removeAll(); + + expect(runSync).toHaveBeenCalledWith('openclaw', ['mcp', 'unset', 'custena'], { stdio: 'ignore' }); + expect(fs.unlink).toHaveBeenCalledWith(SKILL_PATH); + }); + + it('removes custena from config JSON when present (even if CLI fails)', async () => { + vi.mocked(runSync).mockImplementationOnce(() => { throw new Error('not found'); }); + const existing = JSON.stringify({ + mcp: { servers: { custena: { url: 'x' }, other: { url: 'y' } } }, + }); + vi.mocked(fs.readFile).mockResolvedValueOnce(existing as any); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.unlink).mockResolvedValue(undefined); + + await adapter.removeAll(); + + const [, content] = vi.mocked(fs.writeFile).mock.calls[0] as [string, string]; + const written = JSON.parse(content); + expect(written.mcp.servers.custena).toBeUndefined(); + expect(written.mcp.servers.other).toBeDefined(); + }); + + it('does not throw if skill file does not exist', async () => { + vi.mocked(runSync).mockReturnValueOnce(undefined); + vi.mocked(fs.readFile).mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + vi.mocked(fs.unlink).mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + await expect(adapter.removeAll()).resolves.not.toThrow(); + }); +}); diff --git a/src/__tests__/security.test.ts b/src/__tests__/security.test.ts new file mode 100644 index 0000000..b5222d3 --- /dev/null +++ b/src/__tests__/security.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('fs/promises'); + +import fs from 'fs/promises'; +import { isStateValid, isOriginAllowed, loadToken } from '../auth/oauth.js'; +import { shouldForwardHookEvent } from '../commands/hook.js'; + +// These tests guard security-critical branches where a silent regression +// reintroduces a known vulnerability with no functional test failure: +// CSRF on the OAuth callback, forged /setup-done POSTs from an adversarial +// page, personal-data leakage through hooks, and near-expired tokens being +// sent on requests that then 401. + +describe('OAuth state validation (CSRF guard)', () => { + it('rejects a null state parameter', () => { + expect(isStateValid(null, 'abc')).toBe(false); + }); + + it('rejects a state that does not match the one we issued', () => { + expect(isStateValid('attacker-state', 'our-state')).toBe(false); + }); + + it('accepts the exact state we issued', () => { + expect(isStateValid('s', 's')).toBe(true); + }); +}); + +describe('Origin-lock on /setup-done', () => { + const allowed = 'https://dashboard.custena.com'; + + it('rejects when Origin header is absent (non-browser client)', () => { + expect(isOriginAllowed(null, allowed)).toBe(false); + }); + + it('rejects a foreign Origin (page the user might visit during install)', () => { + expect(isOriginAllowed('https://evil.example', allowed)).toBe(false); + }); + + it('rejects when allowedOrigin is unset (setupUrl parse failed)', () => { + expect(isOriginAllowed(allowed, null)).toBe(false); + }); + + it('accepts an exact match', () => { + expect(isOriginAllowed(allowed, allowed)).toBe(true); + }); +}); + +describe('Hook GDPR filter (drop non-custena_ tool events at source)', () => { + it('drops PRE_TOOL_USE for Bash', () => { + expect(shouldForwardHookEvent('PRE_TOOL_USE', { tool_name: 'Bash' })).toBe(false); + }); + + it('drops POST_TOOL_USE for WebFetch (snake_case and camelCase)', () => { + expect(shouldForwardHookEvent('POST_TOOL_USE', { toolName: 'WebFetch' })).toBe(false); + }); + + it('drops tool-use events with missing tool_name (no way to verify prefix)', () => { + expect(shouldForwardHookEvent('PRE_TOOL_USE', {})).toBe(false); + }); + + it('forwards PRE_TOOL_USE for custena_pay_challenge', () => { + expect(shouldForwardHookEvent('PRE_TOOL_USE', { tool_name: 'custena_pay_challenge' })).toBe(true); + }); + + it('forwards lifecycle events (USER_PROMPT, STOP) regardless of tool_name', () => { + expect(shouldForwardHookEvent('USER_PROMPT', {})).toBe(true); + expect(shouldForwardHookEvent('STOP', { tool_name: 'Bash' })).toBe(true); + }); +}); + +describe('Token expiry (loadToken must treat near-expired tokens as absent)', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns null when the stored token expires within 60 seconds', async () => { + const token = JSON.stringify({ + accessToken: 't', refreshToken: 'r', expiresAt: Date.now() + 30_000, clientId: 'c', + }); + vi.mocked(fs.readFile).mockResolvedValueOnce(token as any); + expect(await loadToken()).toBeNull(); + }); + + it('returns null for an already-expired token', async () => { + const token = JSON.stringify({ + accessToken: 't', refreshToken: 'r', expiresAt: Date.now() - 1000, clientId: 'c', + }); + vi.mocked(fs.readFile).mockResolvedValueOnce(token as any); + expect(await loadToken()).toBeNull(); + }); + + it('returns the token when expiry is comfortably in the future', async () => { + const token = JSON.stringify({ + accessToken: 't', refreshToken: 'r', expiresAt: Date.now() + 10 * 60_000, clientId: 'c', + }); + vi.mocked(fs.readFile).mockResolvedValueOnce(token as any); + const loaded = await loadToken(); + expect(loaded).not.toBeNull(); + expect(loaded!.accessToken).toBe('t'); + }); + + it('returns null when the token file is missing', async () => { + vi.mocked(fs.readFile).mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + expect(await loadToken()).toBeNull(); + }); +}); diff --git a/src/adapters/claude-code.ts b/src/adapters/claude-code.ts index 9846850..abc747c 100644 --- a/src/adapters/claude-code.ts +++ b/src/adapters/claude-code.ts @@ -3,7 +3,31 @@ import { MCP_URL, OAUTH_CLIENT_ID, SKILL_TEXT } from '../config.js'; import fs from 'fs/promises'; import path from 'path'; import os from 'os'; -import { execSync } from 'child_process'; +import { runSync } from '../shared/exec.js'; + +// Only the slice of ~/.claude/settings.json this adapter actually reads or +// writes. The index signature preserves unknown keys on round-trip so we +// never clobber fields Claude Code added but we don't know about. +interface ClaudeHookEntry { + type: 'command'; + command: string; +} +interface ClaudeHookMatcher { + matcher?: string; + hooks?: ClaudeHookEntry[]; +} +interface ClaudeHooks { + PreToolUse?: ClaudeHookMatcher[]; + PostToolUse?: ClaudeHookMatcher[]; + UserPromptSubmit?: ClaudeHookMatcher[]; + Stop?: ClaudeHookMatcher[]; + [key: string]: ClaudeHookMatcher[] | undefined; +} +interface ClaudeSettings { + mcpServers?: Record; + hooks?: ClaudeHooks; + [key: string]: unknown; +} export class ClaudeCodeAdapter implements HostAdapter { id = 'claude-code'; @@ -27,7 +51,7 @@ export class ClaudeCodeAdapter implements HostAdapter { } catch {} // Check claude binary on PATH try { - execSync('which claude', { stdio: 'ignore' }); + runSync('which', ['claude'], { stdio: 'ignore' }); return { installed: true, configPath: this.settingsPath }; } catch {} // Check VS Code extension @@ -47,7 +71,7 @@ export class ClaudeCodeAdapter implements HostAdapter { // this CLI maintains in ~/.claude.json, NOT ~/.claude/settings.json). // Remove first for idempotency; ignore failure when no entry exists. try { - execSync('claude mcp remove custena --scope user', { stdio: 'ignore' }); + runSync('claude', ['mcp', 'remove', 'custena', '--scope', 'user'], { stdio: 'ignore' }); } catch {} // `claude mcp add` takes URL as a positional arg (not --url): // claude mcp add --transport http --scope user --client-id @@ -56,8 +80,19 @@ export class ClaudeCodeAdapter implements HostAdapter { // the right scopes (custena:buyer, offline_access), redirect URIs, and // PKCE config. Without this flag, the SDK creates a fresh client per // install attempt with minimal scopes, which then 400s on /authorize. - execSync( - `claude mcp add --transport http --scope user --client-id ${OAUTH_CLIENT_ID} custena ${MCP_URL}`, + // argv form (not a shell string) so OAUTH_CLIENT_ID and MCP_URL — both + // env-overridable — can't inject shell metacharacters. See review: + // strings passed to execSync are parsed by /bin/sh; argv arrays aren't. + // runSync goes through cross-spawn so PATHEXT resolution works on Windows. + runSync( + 'claude', + [ + 'mcp', 'add', + '--transport', 'http', + '--scope', 'user', + '--client-id', OAUTH_CLIENT_ID, + 'custena', MCP_URL, + ], { stdio: 'inherit' }, ); @@ -85,9 +120,9 @@ export class ClaudeCodeAdapter implements HostAdapter { const settings = await this.readSettings(); settings.hooks = settings.hooks ?? {}; - const makeHook = (cmd: string) => [{ type: 'command', command: cmd }]; - const hookExists = (arr: any[], cmd: string) => - arr.some(h => h?.hooks?.some?.((x: any) => x.command === cmd)); + const makeHook = (cmd: string): ClaudeHookEntry[] => [{ type: 'command', command: cmd }]; + const hookExists = (arr: ClaudeHookMatcher[], cmd: string) => + arr.some(h => h?.hooks?.some(x => x?.command === cmd)); const preCmd = 'npx custena-connect hook pre-tool-use'; const postCmd = 'npx custena-connect hook post-tool-use'; @@ -116,7 +151,7 @@ export class ClaudeCodeAdapter implements HostAdapter { // case an older local-scope entry is lying around. Ignore failures. for (const scope of ['user', 'local'] as const) { try { - execSync(`claude mcp remove custena --scope ${scope}`, { stdio: 'ignore' }); + runSync('claude', ['mcp', 'remove', 'custena', '--scope', scope], { stdio: 'ignore' }); } catch {} } @@ -127,10 +162,12 @@ export class ClaudeCodeAdapter implements HostAdapter { if (settings.mcpServers?.custena) delete settings.mcpServers.custena; // Remove custena hooks from every hook category. - for (const [key, arr] of Object.entries(settings.hooks ?? {})) { - (settings.hooks as any)[key] = (arr as any[]).filter( - h => !JSON.stringify(h).includes('custena-connect') - ); + const hooks = settings.hooks ?? {}; + for (const key of Object.keys(hooks)) { + const arr = hooks[key]; + if (Array.isArray(arr)) { + hooks[key] = arr.filter(h => !JSON.stringify(h).includes('custena-connect')); + } } await this.writeSettings(settings); @@ -138,16 +175,16 @@ export class ClaudeCodeAdapter implements HostAdapter { try { await fs.unlink(this.skillPath); } catch {} } - private async readSettings(): Promise { + private async readSettings(): Promise { try { const content = await fs.readFile(this.settingsPath, 'utf-8'); - return JSON.parse(content); + return JSON.parse(content) as ClaudeSettings; } catch { return {}; } } - private async writeSettings(settings: any): Promise { + private async writeSettings(settings: ClaudeSettings): Promise { await fs.mkdir(path.dirname(this.settingsPath), { recursive: true }); await fs.writeFile(this.settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); } diff --git a/src/adapters/codex.ts b/src/adapters/codex.ts new file mode 100644 index 0000000..3b9be61 --- /dev/null +++ b/src/adapters/codex.ts @@ -0,0 +1,110 @@ +import { HostAdapter, HostPresence, OAuthConfig } from '../types.js'; +import { MCP_URL } from '../config.js'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { runSync } from '../shared/exec.js'; + +/** + * Replaces (or appends) a dotted-key TOML section in `content`. + * Uses JSON.stringify for string values — valid TOML because both formats + * use the same escape sequences for double-quoted strings. + */ +export function patchTomlSection( + content: string, + sectionKey: string, + fields: Record, +): string { + const header = `[${sectionKey}]`; + const lines = content ? content.split('\n') : []; + const out: string[] = []; + let inTarget = false; + + for (const line of lines) { + const trimmed = line.trimStart(); + // Note: does not handle TOML array-of-tables ([[header]]) — not used in Codex config.toml. + if (trimmed.startsWith('[')) { + if (inTarget) inTarget = false; + if (trimmed === header) { inTarget = true; continue; } + } + if (!inTarget) out.push(line); + } + + while (out.length > 0 && out[out.length - 1].trim() === '') out.pop(); + out.push('', header); + for (const [k, v] of Object.entries(fields)) { + out.push(`${k} = ${JSON.stringify(v)}`); + } + out.push(''); + return out.join('\n'); +} + +/** Strips a TOML section by key, leaving all other content intact. */ +export function removeTomlSection(content: string, sectionKey: string): string { + if (!content) return ''; + const header = `[${sectionKey}]`; + const lines = content.split('\n'); + const out: string[] = []; + let inTarget = false; + + for (const line of lines) { + const trimmed = line.trimStart(); + // Note: does not handle TOML array-of-tables ([[header]]) — not used in Codex config.toml. + if (trimmed.startsWith('[')) { + if (inTarget) inTarget = false; + if (trimmed === header) { inTarget = true; continue; } + } + if (!inTarget) out.push(line); + } + + while (out.length > 0 && out[out.length - 1].trim() === '') out.pop(); + if (out.length > 0) out.push(''); + return out.join('\n'); +} + +export class CodxAdapter implements HostAdapter { + id = 'codex'; + displayName = 'OpenAI Codex'; + capabilities = { mcpPrompts: true, hooks: false }; + + private get configDir() { return path.join(os.homedir(), '.codex'); } + private get configPath() { return path.join(this.configDir, 'config.toml'); } + + async detect(): Promise { + try { + await fs.access(this.configDir); + return { installed: true, configPath: this.configPath }; + } catch {} + try { + runSync('which', ['codex'], { stdio: 'ignore' }); + return { installed: true, configPath: this.configPath }; + } catch {} + return { installed: false }; + } + + async writeMcpConfig(oauth: OAuthConfig): Promise { + const existing = await fs.readFile(this.configPath, 'utf-8').catch(() => ''); + const patched = patchTomlSection(existing, 'mcp_servers.custena', { + url: MCP_URL, + bearer_token: oauth.accessToken, + default_tools_approval_mode: 'approve', + }); + await fs.mkdir(this.configDir, { recursive: true }); + await fs.writeFile(this.configPath, patched, 'utf-8'); + } + + // Codex reads prompts from the MCP server natively (mcpPrompts: true → never called by installer). + async writeSkill(): Promise {} + + // Codex has no hook system (hooks: false → never called by installer). + async writeHooks(): Promise {} + + async removeAll(): Promise { + const existing = await fs.readFile(this.configPath, 'utf-8').catch(() => ''); + if (!existing) return; + const patched = removeTomlSection(existing, 'mcp_servers.custena'); + if (patched !== existing) { + await fs.writeFile(this.configPath, patched, 'utf-8'); + } + } +} diff --git a/src/adapters/openclaw.ts b/src/adapters/openclaw.ts new file mode 100644 index 0000000..648220a --- /dev/null +++ b/src/adapters/openclaw.ts @@ -0,0 +1,118 @@ +import { HostAdapter, HostPresence, OAuthConfig } from '../types.js'; +import { MCP_URL, SKILL_TEXT } from '../config.js'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { runSync } from '../shared/exec.js'; + +// Only the slice of ~/.openclaw/openclaw.json this adapter touches. Unknown +// keys flow through the index signature so round-tripping the file doesn't +// clobber fields OpenClaw added but we don't know about. +interface OpenClawMcpServer { + url: string; + headers?: Record; +} +interface OpenClawConfig { + mcp?: { + servers?: Record; + }; + [key: string]: unknown; +} + +const SKILL_FRONTMATTER = `--- +name: custena-pay +description: Pay HTTP 402 payment gates automatically through Custena +--- + +`; + +export class OpenClawAdapter implements HostAdapter { + id = 'openclaw'; + displayName = 'OpenClaw'; + capabilities = { mcpPrompts: false, hooks: false }; + + private get configDir() { return path.join(os.homedir(), '.openclaw'); } + private get configPath() { return path.join(this.configDir, 'openclaw.json'); } + private get skillPath() { return path.join(this.configDir, 'skills', 'custena-pay.md'); } + + async detect(): Promise { + try { + await fs.access(this.configDir); + return { installed: true, configPath: this.configPath }; + } catch {} + try { + runSync('which', ['openclaw'], { stdio: 'ignore' }); + return { installed: true, configPath: this.configPath }; + } catch {} + return { installed: false }; + } + + async writeMcpConfig(oauth: OAuthConfig): Promise { + const serverJson = JSON.stringify({ + url: MCP_URL, + headers: { Authorization: `Bearer ${oauth.accessToken}` }, + }); + + try { + runSync('openclaw', ['mcp', 'set', 'custena', serverJson], { stdio: 'inherit' }); + } catch { + await this.writeConfigFallback(oauth.accessToken); + } + } + + private async writeConfigFallback(accessToken: string): Promise { + let config: OpenClawConfig = {}; + try { + const raw = await fs.readFile(this.configPath, 'utf-8'); + try { + config = JSON.parse(raw) as OpenClawConfig; + } catch { + const serverJson = JSON.stringify({ + url: MCP_URL, + headers: { Authorization: `Bearer ${accessToken}` }, + }); + throw new Error( + `Could not parse ~/.openclaw/openclaw.json (may be JSON5 format). ` + + `Run manually: openclaw mcp set custena '${serverJson}'`, + ); + } + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { + throw e; + } + } + + config.mcp = config.mcp ?? {}; + config.mcp.servers = config.mcp.servers ?? {}; + config.mcp.servers.custena = { + url: MCP_URL, + headers: { Authorization: `Bearer ${accessToken}` }, + }; + + await fs.mkdir(this.configDir, { recursive: true }); + await fs.writeFile(this.configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); + } + + async writeSkill(): Promise { + await fs.mkdir(path.dirname(this.skillPath), { recursive: true }); + await fs.writeFile(this.skillPath, SKILL_FRONTMATTER + SKILL_TEXT, 'utf-8'); + } + + async writeHooks(): Promise {} // OpenClaw has no hook system. + + async removeAll(): Promise { + try { runSync('openclaw', ['mcp', 'unset', 'custena'], { stdio: 'ignore' }); } catch {} + + // Also clean up JSON directly in case CLI wasn't available during install. + try { + const raw = await fs.readFile(this.configPath, 'utf-8'); + const config = JSON.parse(raw) as OpenClawConfig; + if (config.mcp?.servers?.custena) { + delete config.mcp.servers.custena; + await fs.writeFile(this.configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); + } + } catch {} + + try { await fs.unlink(this.skillPath); } catch {} + } +} diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts index 2b0ff60..bf36b2f 100644 --- a/src/adapters/registry.ts +++ b/src/adapters/registry.ts @@ -1,6 +1,10 @@ import { HostAdapter } from '../types.js'; import { ClaudeCodeAdapter } from './claude-code.js'; +import { CodxAdapter } from './codex.js'; +import { OpenClawAdapter } from './openclaw.js'; export const adapters: HostAdapter[] = [ new ClaudeCodeAdapter(), + new CodxAdapter(), + new OpenClawAdapter(), ]; diff --git a/src/auth/oauth.ts b/src/auth/oauth.ts index e6706c0..634d284 100644 --- a/src/auth/oauth.ts +++ b/src/auth/oauth.ts @@ -1,13 +1,32 @@ import crypto from 'crypto'; import http from 'http'; import { OAuthConfig } from '../types.js'; -import { TOKEN_STORE_PATH } from '../config.js'; +import { API_BASE_URL, TOKEN_STORE_PATH } from '../config.js'; import fs from 'fs/promises'; import path from 'path'; // The OAuth endpoints — in prod these come from the resource metadata const KEYCLOAK_BASE = process.env.CUSTENA_KEYCLOAK_URL ?? 'https://api.custena.com/auth/realms/custena'; const CLIENT_ID = 'custena-connect-cli'; +const CALLBACK_PORT = 9874; +const CALLBACK_PATH = '/callback'; +const SETUP_DONE_PATH = '/setup-done'; + +export type RunOAuthOptions = { + /** + * When true, the callback server stays open after the browser is redirected + * so the same port can later receive the `/setup-done` signal from the + * dashboard setup page. The returned `waitForSetup()` resolves once that + * signal arrives (or rejects on timeout). + */ + awaitSetupCompletion?: boolean; +}; + +export type RunOAuthResult = { + config: OAuthConfig; + /** Only present when `awaitSetupCompletion: true`. */ + waitForSetup?: () => Promise<{ agentName: string; connectedAgentId: string }>; +}; function generateCodeVerifier(): string { return crypto.randomBytes(32).toString('base64url'); @@ -21,29 +40,212 @@ function generateState(): string { return crypto.randomBytes(16).toString('hex'); } -export async function runOAuthFlow(): Promise { +// Exported so security-regression tests can exercise these predicates +// without spinning up the callback server. Each guards a path where the +// wrong answer silently reintroduces a known vulnerability class. +export function isStateValid(received: string | null, expected: string): boolean { + return received !== null && received === expected; +} + +export function isOriginAllowed(origin: string | null, allowed: string | null): boolean { + return origin !== null && allowed !== null && origin === allowed; +} + +export async function runOAuthFlow(options: RunOAuthOptions = {}): Promise { const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); const state = generateState(); - const redirectUri = 'http://localhost:9874/callback'; + const redirectUri = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`; const authUrl = new URL(`${KEYCLOAK_BASE}/protocol/openid-connect/auth`); authUrl.searchParams.set('client_id', CLIENT_ID); authUrl.searchParams.set('response_type', 'code'); - authUrl.searchParams.set('scope', 'custena:buyer'); authUrl.searchParams.set('redirect_uri', redirectUri); + authUrl.searchParams.set('scope', 'openid custena:buyer offline_access'); authUrl.searchParams.set('state', state); authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge_method', 'S256'); - // Start server and open browser concurrently — server must be listening before the redirect arrives - const callbackPromise = startCallbackServer(state); + const callbackFlow = startCallbackServer({ + expectedState, + codeVerifier, + redirectUri, + keepOpen: options.awaitSetupCompletion === true, + }); const { default: open } = await import('open'); await open(authUrl.toString()); - const { code } = await callbackPromise; + const { config, waitForSetup } = await callbackFlow; + await saveToken(config); + return { config, waitForSetup }; + + function expectedState() { + return state; + } +} + +type CallbackFlowArgs = { + expectedState: () => string; + codeVerifier: string; + redirectUri: string; + keepOpen: boolean; +}; + +// Consolidates three responsibilities the flow used to split across callers: +// 1. wait for the OAuth redirect +// 2. exchange the code for tokens +// 3. redirect the browser to the dashboard setup page (so the user never sees +// a placeholder "you can close this tab" page in between) +// If `keepOpen` is true, the server stays listening for the `/setup-done` +// signal that the dashboard setup page pings once authorization is complete. +async function startCallbackServer( + args: CallbackFlowArgs, +): Promise<{ config: OAuthConfig; waitForSetup?: () => Promise<{ agentName: string; connectedAgentId: string }> }> { + return new Promise((resolveCallback, rejectCallback) => { + let setupResolver: ((v: { agentName: string; connectedAgentId: string }) => void) | null = null; + let setupRejecter: ((e: Error) => void) | null = null; + let setupTimer: NodeJS.Timeout | null = null; + let server: http.Server | null = null; + // Dashboard origin the /setup-done POST is expected from. Locked in once we + // receive the setupUrl from the backend — adversarial pages the user visits + // during install can't forge a completion signal because we reject Origin + // headers that don't match. Keeps wildcard CORS off the callback server. + let allowedOrigin: string | null = null; + + const closeServer = () => { + if (setupTimer) clearTimeout(setupTimer); + if (server) server.close(); + }; + + server = http.createServer(async (req, res) => { + const url = new URL(req.url!, `http://localhost:${CALLBACK_PORT}`); + + if (url.pathname === CALLBACK_PATH) { + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + if (!code || !isStateValid(state, args.expectedState())) { + res.writeHead(400, { 'Content-Type': 'text/html' }).end('OAuth error. Re-run custena-connect install.'); + rejectCallback(new Error('OAuth callback state mismatch')); + closeServer(); + return; + } + + try { + const config = await exchangeCodeForTokens(code, args.redirectUri, args.codeVerifier); + // Hit the backend with the fresh access token to get a setupUrl the + // browser can be redirected to. The backend resolves the new + // connected_agent row from the JWT azp — no ID needed client-side. + const setupResponse = await requestSetupToken(config.accessToken); + try { + allowedOrigin = new URL(setupResponse.setupUrl).origin; + } catch { + // Malformed setupUrl — leave allowedOrigin null and the POST path + // below rejects everything. User re-runs install. + } + + res.writeHead(302, { Location: setupResponse.setupUrl }).end(); + + if (args.keepOpen) { + resolveCallback({ + config, + waitForSetup: () => + new Promise((resolveSetup, rejectSetup) => { + setupResolver = resolveSetup; + setupRejecter = rejectSetup; + // 30 minutes matches the setup-token TTL on the backend + // (ConnectSetupService). After that window the backend + // reaps the orphan row; re-running install is the only + // recovery path. + setupTimer = setTimeout(() => { + rejectSetup(new Error('Setup not completed within 30 minutes')); + closeServer(); + }, 30 * 60 * 1000); + }), + }); + } else { + resolveCallback({ config }); + closeServer(); + } + } catch (e) { + res + .writeHead(500, { 'Content-Type': 'text/html' }) + .end('Token exchange failed. Re-run custena-connect install.'); + rejectCallback(e as Error); + closeServer(); + } + return; + } + + if (args.keepOpen && url.pathname === SETUP_DONE_PATH) { + // Origin-lock: reject unless the Origin header matches the dashboard + // URL returned by the backend. Any web page the user might visit + // during install that POSTs to localhost:9874 is filtered out — + // wildcard CORS would let them forge a completion signal. + const origin = req.headers.origin ?? null; + const originOk = isOriginAllowed(origin, allowedOrigin); + + if (req.method === 'OPTIONS') { + if (!originOk) { + res.writeHead(403).end(); + return; + } + res.writeHead(204, { + 'Access-Control-Allow-Origin': allowedOrigin!, + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + }).end(); + return; + } - // Exchange code for tokens + if (req.method === 'POST') { + if (!originOk) { + res.writeHead(403).end(); + return; + } + let body = ''; + for await (const chunk of req) body += chunk; + let parsed: { agentName?: string; connectedAgentId?: string } = {}; + try { parsed = JSON.parse(body); } catch {} + res.writeHead(204, { + 'Access-Control-Allow-Origin': allowedOrigin!, + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Methods': 'POST', + }).end(); + if (setupResolver) { + setupResolver({ + agentName: parsed.agentName ?? 'Agent', + connectedAgentId: parsed.connectedAgentId ?? '', + }); + setupResolver = null; + setupRejecter = null; + closeServer(); + } + return; + } + } + + res.writeHead(404).end(); + }); + + const oauthTimeout = setTimeout(() => { + if (server) server.close(); + rejectCallback(new Error('OAuth login timed out after 5 minutes')); + }, 5 * 60 * 1000); + + server.listen(CALLBACK_PORT); + server.on('error', (err) => { + clearTimeout(oauthTimeout); + rejectCallback(err); + }); + server.on('close', () => clearTimeout(oauthTimeout)); + }); +} + +async function exchangeCodeForTokens( + code: string, + redirectUri: string, + codeVerifier: string, +): Promise { const tokenRes = await fetch(`${KEYCLOAK_BASE}/protocol/openid-connect/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, @@ -57,54 +259,41 @@ export async function runOAuthFlow(): Promise { }); if (!tokenRes.ok) throw new Error(`Token exchange failed: ${tokenRes.status}`); - const tokens = await tokenRes.json() as any; + const tokens = (await tokenRes.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + }; - const config: OAuthConfig = { + return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt: Date.now() + tokens.expires_in * 1000, clientId: CLIENT_ID, }; - - await saveToken(config); - return config; } -async function startCallbackServer(expectedState: string): Promise<{ code: string }> { - return new Promise((resolve, reject) => { - const server = http.createServer((req, res) => { - const url = new URL(req.url!, 'http://localhost'); - const code = url.searchParams.get('code'); - const state = url.searchParams.get('state'); - if (code && state === expectedState) { - res.end('

Custena connected! You can close this tab.

'); - server.close(); - resolve({ code }); - } else { - res.end('Error'); - server.close(); - reject(new Error('OAuth callback error')); - } - }); - - const timeout = setTimeout(() => { - server.close(); - reject(new Error('OAuth login timed out after 5 minutes')); - }, 5 * 60 * 1000); - - server.listen(9874); - server.on('error', (err) => { - clearTimeout(timeout); - reject(err); - }); - server.on('close', () => clearTimeout(timeout)); +async function requestSetupToken(accessToken: string): Promise<{ token: string; setupUrl: string; expiresAt: string }> { + const res = await fetch(`${API_BASE_URL}/api/v1/buyer/connected-agents/setup-token`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: '{}', }); + if (!res.ok) throw new Error(`Failed to issue setup token: ${res.status}`); + return res.json() as Promise<{ token: string; setupUrl: string; expiresAt: string }>; } export async function loadToken(): Promise { try { const content = await fs.readFile(TOKEN_STORE_PATH, 'utf-8'); - return JSON.parse(content); + const config = JSON.parse(content) as OAuthConfig; + // Treat tokens expiring within 60 s as absent so callers fall through to the queue path + // rather than sending a request that will return 401, filling the queue with unsendable events. + if (config.expiresAt < Date.now() + 60_000) return null; + return config; } catch { return null; } diff --git a/src/cli.ts b/src/cli.ts index b3be20c..a2c6825 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,15 +1,24 @@ #!/usr/bin/env node import { Command } from 'commander'; +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; import { installCommand } from './commands/install.js'; import { uninstallCommand } from './commands/uninstall.js'; import { doctorCommand } from './commands/doctor.js'; import { hookCommand } from './commands/hook.js'; +// Read version from package.json so `custena-connect --version` can never +// drift from the npm-registry-reported version. Works under both tsx (dev) +// and the compiled dist/ layout — package.json is always one level up. +const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'); +const { version } = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version: string }; + const program = new Command(); program .name('custena-connect') .description('Connect your AI coding agent to Custena') - .version('0.1.0'); + .version(version); program.addCommand(installCommand()); program.addCommand(uninstallCommand()); diff --git a/src/commands/hook.ts b/src/commands/hook.ts index 29195d6..a00f1cd 100644 --- a/src/commands/hook.ts +++ b/src/commands/hook.ts @@ -1,9 +1,15 @@ import { Command } from 'commander'; import { HOOKS_URL, HOOK_QUEUE_PATH } from '../config.js'; import { loadToken } from '../auth/oauth.js'; +import crypto from 'crypto'; import fs from 'fs/promises'; import path from 'path'; +// Event-scoped fallback UUID used when the host doesn't supply a session_id in the payload. +// Claude Code invokes this command as a new subprocess per event, so this UUID is unique +// per event — it is NOT shared across events from the same session. +const PROCESS_SESSION_ID = crypto.randomUUID(); + const EVENT_TYPE_MAP: Record = { 'pre-tool-use': 'PRE_TOOL_USE', 'post-tool-use': 'POST_TOOL_USE', @@ -11,6 +17,36 @@ const EVENT_TYPE_MAP: Record = { 'stop': 'STOP', }; +// The slice of the hook payload this command reads. Claude Code controls the +// full shape — we only name the fields we consume. Additional fields flow +// through unread without a type error. +export interface HookPayload { + session_id?: string; + sessionId?: string; + tool_name?: string; + toolName?: string; + tool_input?: unknown; + tool_response?: unknown; + duration_ms?: number; + error?: string; + user_message?: string; +} + +// GDPR data minimisation (Art. 5(1)(c)): for tool-use events, only forward +// events tied to Custena MCP tools. Bash commands, file paths, and other +// tool args have no lawful basis for collection and are silently dropped. +// USER_PROMPT and STOP are session lifecycle events with no sensitive args. +// Exported for regression tests — a silent regression here reintroduces a +// personal-data collection bug with no user-visible symptom. +export function shouldForwardHookEvent( + eventType: string, + payload: HookPayload, +): boolean { + if (eventType !== 'PRE_TOOL_USE' && eventType !== 'POST_TOOL_USE') return true; + const tool = payload.tool_name ?? payload.toolName ?? ''; + return tool.startsWith('custena_'); +} + export function hookCommand(): Command { return new Command('hook') .argument('', 'Event type: pre-tool-use | post-tool-use | user-prompt | stop') @@ -26,11 +62,15 @@ export function hookCommand(): Command { let rawInput = ''; for await (const chunk of process.stdin) rawInput += chunk; - let payload: any = {}; - try { payload = JSON.parse(rawInput); } catch {} + let payload: HookPayload = {}; + try { payload = JSON.parse(rawInput) as HookPayload; } catch {} + + if (!shouldForwardHookEvent(eventType, payload)) { + process.exit(0); + } const body = { - sessionId: payload.session_id ?? payload.sessionId ?? 'unknown', + sessionId: payload.session_id ?? payload.sessionId ?? PROCESS_SESSION_ID, eventType, toolName: payload.tool_name ?? payload.toolName ?? null, toolInputSummary: payload.tool_input ? JSON.stringify(payload.tool_input).slice(0, 4000) : null, diff --git a/src/commands/install.ts b/src/commands/install.ts index 536b18e..7deab6f 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -1,18 +1,37 @@ import { Command } from 'commander'; import ora from 'ora'; import chalk from 'chalk'; +import { checkbox } from '@inquirer/prompts'; import { adapters } from '../adapters/registry.js'; import { runOAuthFlow } from '../auth/oauth.js'; +import type { HostAdapter, HostPresence } from '../types.js'; + +interface Detected { adapter: HostAdapter; presence: HostPresence } + +/** Returns which hosts to configure. Prompts with a checkbox when >1 detected. */ +export async function selectHosts(detected: Detected[]): Promise { + if (detected.length <= 1) return detected; + + const selected = await checkbox({ + message: 'Multiple coding agent hosts found — select which to configure:', + choices: detected.map(({ adapter }) => ({ + value: adapter, + name: adapter.displayName, + checked: true, + })), + }); + + return detected.filter(({ adapter }) => selected.includes(adapter)); +} export function installCommand(): Command { return new Command('install') - .description('Connect a coding agent host to your Custena buyer account') + .description('Connect coding agent host(s) to your Custena buyer account') .action(async () => { console.log(chalk.bold('\nCustena Connect installer\n')); - // Detect installed hosts const spinner = ora('Detecting installed hosts...').start(); - const detected = []; + const detected: Detected[] = []; for (const adapter of adapters) { const presence = await adapter.detect(); if (presence.installed) detected.push({ adapter, presence }); @@ -26,33 +45,61 @@ export function installCommand(): Command { return; } - // v0.1: single host, skip selection - const { adapter, presence } = detected[0]; - console.log(`Detected: ${chalk.green(adapter.displayName)} at ${presence.configPath}`); + const targets = await selectHosts(detected); - // OAuth flow + if (targets.length === 0) { + console.log(chalk.yellow('No hosts selected — nothing installed.')); + return; + } + + // Keep the callback server open so it can also receive the /setup-done + // signal from the dashboard setup page. Every new authorization must be + // scoped to an Agent before it becomes usable (setup is required, not + // optional). After OAuth, the browser is redirected to the dashboard + // setup page; once the user picks or creates an Agent, the page POSTs + // back to the CLI's callback server. console.log('\nOpening browser for Custena login...'); - const oauth = await runOAuthFlow(); + const { config: oauth, waitForSetup } = await runOAuthFlow({ awaitSetupCompletion: true }); console.log(chalk.green('✓ Authenticated')); - // Write config - const s2 = ora('Writing MCP config...').start(); - await adapter.writeMcpConfig(oauth); - s2.succeed('MCP config written'); + for (const { adapter } of targets) { + const label = adapter.displayName; + + const s1 = ora(`Writing MCP config for ${label}...`).start(); + await adapter.writeMcpConfig(oauth); + s1.succeed(`MCP config written (${label})`); + + if (!adapter.capabilities.mcpPrompts) { + const s2 = ora(`Writing skill file for ${label}...`).start(); + await adapter.writeSkill(); + s2.succeed(`Skill file written (${label})`); + } - if (!adapter.capabilities.mcpPrompts) { - const s3 = ora('Writing skill file...').start(); - await adapter.writeSkill(); - s3.succeed('Skill file written'); + if (adapter.capabilities.hooks) { + const s3 = ora(`Writing hooks for ${label}...`).start(); + await adapter.writeHooks(); + s3.succeed(`Hooks configured (${label})`); + } } - if (adapter.capabilities.hooks) { - const s4 = ora('Writing hooks...').start(); - await adapter.writeHooks(); - s4.succeed('Hooks configured'); + // Block here until the dashboard setup page signals completion; if the + // user abandons the tab the backend cleanup cron reaps the orphan row + // and the CLI instructs them to re-run install. + if (waitForSetup) { + const s4 = ora('Waiting for agent authorization in the browser...').start(); + try { + const { agentName } = await waitForSetup(); + s4.succeed(`Scoped to Agent: ${chalk.green(agentName)}`); + } catch (e) { + s4.fail('Setup not completed'); + console.log(chalk.red('\n✗ Setup was not completed in the browser.')); + console.log('Re-run ' + chalk.cyan('custena-connect install') + ' when you are ready.'); + process.exit(2); + } } - console.log(chalk.bold('\n✓ Custena Connect is ready!')); - console.log(`${adapter.displayName} will now pay HTTP 402 responses from your Custena account.`); + const names = targets.map(t => t.adapter.displayName).join(', '); + console.log(chalk.bold(`\n✓ Custena Connect is ready on: ${names}`)); + console.log('These agents will now pay HTTP 402 responses from your Custena account.'); }); } diff --git a/src/shared/exec.ts b/src/shared/exec.ts new file mode 100644 index 0000000..500ef12 --- /dev/null +++ b/src/shared/exec.ts @@ -0,0 +1,24 @@ +import spawn from 'cross-spawn'; + +/** + * Preserves execFileSync semantics (throw on spawn error, throw on non-zero exit) + * while going through cross-spawn so that Windows .cmd/.bat/.ps1 shims — which + * is how every npm-installed CLI binary ships on Windows — resolve through + * PATHEXT. Going straight to execFileSync calls CreateProcess directly and + * skips PATHEXT entirely, so `claude`, `codex`, `openclaw` all ENOENT on + * Windows even when installed. cross-spawn does the lookup in JS before + * spawning, keeping the argv-array injection-safety property. + */ +export function runSync( + command: string, + args: string[], + options: Parameters[2] = {}, +): void { + const result = spawn.sync(command, args, options); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(' ')} exited with status ${result.status ?? 'null'}`, + ); + } +}