From a05b754db3d422723f8f16355f1527764fa9a087 Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 17 May 2026 00:06:57 +0300 Subject: [PATCH 1/2] feat(effect): migrate cli to effect beta --- .gitignore | 1 + docs/ARCHITECTURE.md | 4 +- package.json | 10 +- pnpm-lock.yaml | 611 +++++++++---------------- scripts/prepare-effect.sh | 21 + src/bin.ts | 2 +- src/cli.test.ts | 160 +++++-- src/cli.ts | 119 ++++- src/command-paths.test.ts | 161 ++++--- src/commands/auth.ts | 35 +- src/commands/brand.ts | 2 +- src/commands/download-links.ts | 2 +- src/commands/events.ts | 2 +- src/commands/files.ts | 20 +- src/commands/transfers.ts | 28 +- src/commands/whoami.ts | 2 +- src/internal/agent-dx.ts | 4 +- src/internal/app-layer.ts | 6 +- src/internal/auth-flow.test.ts | 36 +- src/internal/auth-flow.ts | 6 +- src/internal/command-specs.ts | 125 +++-- src/internal/command.ts | 219 ++++++--- src/internal/config.test.ts | 9 +- src/internal/config.ts | 30 +- src/internal/loader-service.ts | 6 +- src/internal/metadata.ts | 10 +- src/internal/output-service.ts | 57 +-- src/internal/runtime.ts | 29 +- src/internal/sdk.ts | 18 +- src/internal/state.test.ts | 71 ++- src/internal/state.ts | 74 +-- src/sea.ts | 2 +- src/test-support/command-path-mocks.ts | 129 +++--- src/test-support/run-cli.ts | 17 +- vite.config.ts | 12 +- 35 files changed, 1115 insertions(+), 925 deletions(-) create mode 100755 scripts/prepare-effect.sh diff --git a/.gitignore b/.gitignore index 3c02538..a5f3f40 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ node_modules dist coverage .artifacts +.repos/effect *.log .DS_Store diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1b019c0..063dceb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,7 +4,7 @@ This repository is being refactored toward an agent-first, deeply Effect-native ## North Star -- Thin `@effect/cli` command adapters +- Thin Effect CLI command adapters from `effect/unstable/cli` - Explicit services and layers for runtime, output, config, state, SDK access, and workflows - Schema-backed request and response boundaries - Tagged errors for recoverable failures @@ -42,7 +42,7 @@ flowchart TD - Runtime and terminal capabilities - Output rendering and structured writes - Config resolution and persisted state -- Authenticated and unauthenticated SDK access +- SDK access through the SDK-owned live layer and portable fetch transport ## Invariants diff --git a/package.json b/package.json index 3d442d1..1c76e39 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "check": "vp check .", "coverage": "vp test --coverage", "dev": "vp pack --watch", + "prepare": "./scripts/prepare-effect.sh", "prepack": "vp pack", "smoke:pack": "node ./scripts/smoke-packed-install.mjs", "test": "vp test", @@ -42,18 +43,17 @@ "verify": "vp check . && vp pack && vp test && vp test --coverage" }, "dependencies": { - "@effect/cli": "^0.73.2", - "@effect/platform": "0.94.5", - "@effect/platform-node": "0.104.1", - "@putdotio/sdk": "^9.1.0", + "@effect/platform-node": "4.0.0-beta.66", + "@putdotio/sdk": "^9.3.0", "cli-table3": "^0.6.5", - "effect": "3.19.19", + "effect": "4.0.0-beta.66", "i18next": "^25.5.2" }, "devDependencies": { "@types/node": "^24.0.0", "@vitest/coverage-v8": "^4.1.5", "esbuild": "^0.27.0", + "is-ci": "^4.1.0", "postject": "^1.0.0-alpha.6", "typescript": "^5.9.3", "vite-plus": "0.1.20" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 418ddd4..2c971b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,24 +12,18 @@ importers: .: dependencies: - '@effect/cli': - specifier: ^0.73.2 - version: 0.73.2(@effect/platform@0.94.5(effect@3.19.19))(@effect/printer-ansi@0.47.0(@effect/typeclass@0.38.0(effect@3.19.19))(effect@3.19.19))(@effect/printer@0.47.0(@effect/typeclass@0.38.0(effect@3.19.19))(effect@3.19.19))(effect@3.19.19) - '@effect/platform': - specifier: 0.94.5 - version: 0.94.5(effect@3.19.19) '@effect/platform-node': - specifier: 0.104.1 - version: 0.104.1(@effect/cluster@0.56.4(@effect/platform@0.94.5(effect@3.19.19))(@effect/rpc@0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(@effect/rpc@0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(@effect/rpc@0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(effect@3.19.19) + specifier: 4.0.0-beta.66 + version: 4.0.0-beta.66(effect@4.0.0-beta.66)(ioredis@5.10.1) '@putdotio/sdk': - specifier: ^9.1.0 - version: 9.1.0 + specifier: ^9.3.0 + version: 9.3.0 cli-table3: specifier: ^0.6.5 version: 0.6.5 effect: - specifier: 3.19.19 - version: 3.19.19 + specifier: 4.0.0-beta.66 + version: 4.0.0-beta.66 i18next: specifier: ^25.5.2 version: 25.8.18(typescript@5.9.3) @@ -43,6 +37,9 @@ importers: esbuild: specifier: ^0.27.0 version: 0.27.4 + is-ci: + specifier: ^4.1.0 + version: 4.1.0 postject: specifier: ^1.0.0-alpha.6 version: 1.0.0-alpha.6 @@ -51,7 +48,7 @@ importers: version: 5.9.3 vite-plus: specifier: 0.1.20 - version: 0.1.20(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(esbuild@0.27.4)(typescript@5.9.3)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.8.2))(yaml@2.8.2) + version: 0.1.20(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(esbuild@0.27.4)(typescript@5.9.3)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.9.0))(yaml@2.9.0) packages: @@ -84,96 +81,18 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} - '@effect/cli@0.73.2': - resolution: {integrity: sha512-K8IJo81+qa1LU8dhxcDU4QO/bIjL/dPd3zUOSCpLiuUNz8Y3/T+WNs3GqIXEhMfCFMSlRZERN0YgmtRlEZUREA==} + '@effect/platform-node-shared@4.0.0-beta.66': + resolution: {integrity: sha512-+ymrhBnESv/hmn5SKTe2//IY9Ox/hGPeoogEWhW47ZGyhFI5eMYFxdEUBa+3IAV05rrBzrxON9lynu68n0DM7w==} + engines: {node: '>=18.0.0'} peerDependencies: - '@effect/platform': ^0.94.3 - '@effect/printer': ^0.47.0 - '@effect/printer-ansi': ^0.47.0 - effect: ^3.19.16 + effect: ^4.0.0-beta.66 - '@effect/cluster@0.56.4': - resolution: {integrity: sha512-7Je5/JlbZOlsSxsbKjr97dJed2cNGWsb+TLNgMcr5mRDbcWlFOTUGvsrisEJV6waosYLIg+2omPdvnvRoYKdhA==} + '@effect/platform-node@4.0.0-beta.66': + resolution: {integrity: sha512-s/0RgaQFuszzdorRnX1PwEQNnSOi+JgMJo3zEe9O2NR3sosMhTr0Uk+1AF6bUOI9uJ2CPT3KpTIIU7q5/TpOkg==} + engines: {node: '>=18.0.0'} peerDependencies: - '@effect/platform': ^0.94.5 - '@effect/rpc': ^0.73.1 - '@effect/sql': ^0.49.0 - '@effect/workflow': ^0.16.0 - effect: ^3.19.17 - - '@effect/experimental@0.58.0': - resolution: {integrity: sha512-IEP9sapjF6rFy5TkoqDPc86st/fnqUfjT7Xa3pWJrFGr1hzaMXHo+mWsYOZS9LAOVKnpHuVziDK97EP5qsCHVA==} - peerDependencies: - '@effect/platform': ^0.94.0 - effect: ^3.19.13 - ioredis: ^5 - lmdb: ^3 - peerDependenciesMeta: - ioredis: - optional: true - lmdb: - optional: true - - '@effect/platform-node-shared@0.57.1': - resolution: {integrity: sha512-oX/bApMdoKsyrDiNdJxo7U9Rz1RXsjRv+ecfAPp1qGlSdGIo32wVRvJ2XCHqYj0sqaYJS0pU0/GCulRfVGuJag==} - peerDependencies: - '@effect/cluster': ^0.56.1 - '@effect/platform': ^0.94.2 - '@effect/rpc': ^0.73.0 - '@effect/sql': ^0.49.0 - effect: ^3.19.15 - - '@effect/platform-node@0.104.1': - resolution: {integrity: sha512-jT1a/z98niK6fnEU8pWHPPCdJMVDRCIdB65lolcOjse5rsTwVbczMjvKkhVQpF63mNWoOnol7OTRNkw5L54llg==} - peerDependencies: - '@effect/cluster': ^0.56.1 - '@effect/platform': ^0.94.2 - '@effect/rpc': ^0.73.0 - '@effect/sql': ^0.49.0 - effect: ^3.19.15 - - '@effect/platform@0.94.5': - resolution: {integrity: sha512-z05APUiDDPbodhTkH/RJqOLoCU11bU2IZLfcwLFrld03+ob1VeqRnELQlmueLIYm6NZifHAtjl32V+GRt34y4A==} - peerDependencies: - effect: ^3.19.17 - - '@effect/printer-ansi@0.47.0': - resolution: {integrity: sha512-tDEQ9XJpXDNYoWMQJHFRMxKGmEOu6z32x3Kb8YLOV5nkauEKnKmWNs7NBp8iio/pqoJbaSwqDwUg9jXVquxfWQ==} - peerDependencies: - '@effect/typeclass': ^0.38.0 - effect: ^3.19.0 - - '@effect/printer@0.47.0': - resolution: {integrity: sha512-VgR8e+YWWhMEAh9qFOjwiZ3OXluAbcVLIOtvp2S5di1nSrPOZxj78g8LE77JSvyfp5y5bS2gmFW+G7xD5uU+2Q==} - peerDependencies: - '@effect/typeclass': ^0.38.0 - effect: ^3.19.0 - - '@effect/rpc@0.73.2': - resolution: {integrity: sha512-td7LHDgBOYKg+VgGWEelD8rSAmvjXz7am17vfxZROX5qIYuvH7drL/z4p5xQFadhHZ7DYdlFpqdO9ggc77OCIw==} - peerDependencies: - '@effect/platform': ^0.94.5 - effect: ^3.19.18 - - '@effect/sql@0.49.0': - resolution: {integrity: sha512-9UEKR+z+MrI/qMAmSvb/RiD9KlgIazjZUCDSpwNgm0lEK9/Q6ExEyfziiYFVCPiptp52cBw8uBHRic8hHnwqXA==} - peerDependencies: - '@effect/experimental': ^0.58.0 - '@effect/platform': ^0.94.0 - effect: ^3.19.13 - - '@effect/typeclass@0.38.0': - resolution: {integrity: sha512-lMUcJTRtG8KXhXoczapZDxbLK5os7M6rn0zkvOgncJW++A0UyelZfMVMKdT5R+fgpZcsAU/1diaqw3uqLJwGxA==} - peerDependencies: - effect: ^3.19.0 - - '@effect/workflow@0.16.0': - resolution: {integrity: sha512-MiAdlxx3TixkgHdbw+Yf1Z3tHAAE0rOQga12kIydJqj05Fnod+W/I+kQGRMY/XWRg+QUsVxhmh1qTr7Ype6lrw==} - peerDependencies: - '@effect/experimental': ^0.58.0 - '@effect/platform': ^0.94.0 - '@effect/rpc': ^0.73.0 - effect: ^3.19.13 + effect: ^4.0.0-beta.66 + ioredis: ^5.7.0 '@emnapi/core@1.9.0': resolution: {integrity: sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==} @@ -340,6 +259,9 @@ packages: cpu: [x64] os: [win32] + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -671,99 +593,11 @@ packages: cpu: [x64] os: [win32] - '@parcel/watcher-android-arm64@2.5.6': - resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [android] - - '@parcel/watcher-darwin-arm64@2.5.6': - resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [darwin] - - '@parcel/watcher-darwin-x64@2.5.6': - resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [darwin] - - '@parcel/watcher-freebsd-x64@2.5.6': - resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [freebsd] - - '@parcel/watcher-linux-arm-glibc@2.5.6': - resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} - engines: {node: '>= 10.0.0'} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@parcel/watcher-linux-arm-musl@2.5.6': - resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} - engines: {node: '>= 10.0.0'} - cpu: [arm] - os: [linux] - libc: [musl] - - '@parcel/watcher-linux-arm64-glibc@2.5.6': - resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@parcel/watcher-linux-arm64-musl@2.5.6': - resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@parcel/watcher-linux-x64-glibc@2.5.6': - resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@parcel/watcher-linux-x64-musl@2.5.6': - resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@parcel/watcher-win32-arm64@2.5.6': - resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [win32] - - '@parcel/watcher-win32-ia32@2.5.6': - resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} - engines: {node: '>= 10.0.0'} - cpu: [ia32] - os: [win32] - - '@parcel/watcher-win32-x64@2.5.6': - resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [win32] - - '@parcel/watcher@2.5.6': - resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} - engines: {node: '>= 10.0.0'} - '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@putdotio/sdk@9.1.0': - resolution: {integrity: sha512-oM8B1sL52O4v7yI/hcySPmlXD/va7K3S41za1kWgWs6S/Ufi8XsC1Q1tlxfMyrqDRI4UbqJHiDKdm3OuhBR58Q==} + '@putdotio/sdk@9.3.0': + resolution: {integrity: sha512-pIjrxPd448W9r9/N5wx2YUhTNfwgEQmfrvXGeexsxeD721eFlEIj1EkOaZ6KJApWscYX179DKQWs7bNPIuWYSQ==} engines: {node: '>=24.14.0 <25'} '@rolldown/binding-android-arm64@1.0.0-rc.9': @@ -882,6 +716,9 @@ packages: '@types/node@24.12.0': resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vitest/coverage-v8@4.1.5': resolution: {integrity: sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==} peerDependencies: @@ -1078,10 +915,18 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + cli-table3@0.6.5: resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} engines: {node: 10.* || >= 12.*} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + commander@9.5.0: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} @@ -1089,12 +934,25 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - effect@3.19.19: - resolution: {integrity: sha512-Yc8U/SVXo2dHnaP7zNBlAo83h/nzSJpi7vph6Hzyl4ulgMBIgPmz3UzOjb9sBgpFE00gC0iETR244sfXDNLHRg==} + effect@4.0.0-beta.66: + resolution: {integrity: sha512-4arEr62cziFa8BBVDUwJCJJmaVepXf/kRg7KtC0h8+bufngscrHbwWFhr9c+HonwOF+31U3iD3xUJmw9KzX7Dw==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1117,9 +975,9 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - fast-check@3.23.2: - resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} - engines: {node: '>=8.0.0'} + fast-check@4.8.0: + resolution: {integrity: sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==} + engines: {node: '>=12.17.0'} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -1153,22 +1011,22 @@ packages: typescript: optional: true - ini@4.1.3: - resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} + engines: {node: '>=12.22.0'} + + is-ci@4.1.0: + resolution: {integrity: sha512-Ab9bQDQ11lWootZUI5qxgN2ZXwxNI5hTwnsvOc1wyxQ7zQ8OkEDw79mI0+9jI3x432NfwbVRru+3noJfXF6lSQ==} + hasBin: true is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -1261,6 +1119,12 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1271,15 +1135,18 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} - mime@3.0.0: - resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} - engines: {node: '>=10.0.0'} + mime@4.1.0: + resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} + engines: {node: '>=16'} hasBin: true mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.3: resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} hasBin: true @@ -1295,9 +1162,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - node-addon-api@7.1.1: - resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} - node-gyp-build-optional-packages@5.2.2: resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true @@ -1351,8 +1215,16 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - pure-rand@6.1.0: - resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pure-rand@8.4.0: + resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} rolldown@1.0.0-rc.9: resolution: {integrity: sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==} @@ -1378,6 +1250,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + std-env@4.0.0: resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} @@ -1412,8 +1287,9 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} - toml@3.0.0: - resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + toml@4.1.1: + resolution: {integrity: sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==} + engines: {node: '>=20'} totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} @@ -1430,12 +1306,12 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici@7.24.4: - resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==} - engines: {node: '>=20.18.1'} + undici@8.3.0: + resolution: {integrity: sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==} + engines: {node: '>=22.19.0'} - uuid@11.1.0: - resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + uuid@13.0.2: + resolution: {integrity: sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==} hasBin: true vite-plus@0.1.20: @@ -1544,8 +1420,20 @@ packages: utf-8-validate: optional: true - yaml@2.8.2: - resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true @@ -1571,102 +1459,26 @@ snapshots: '@colors/colors@1.5.0': optional: true - '@effect/cli@0.73.2(@effect/platform@0.94.5(effect@3.19.19))(@effect/printer-ansi@0.47.0(@effect/typeclass@0.38.0(effect@3.19.19))(effect@3.19.19))(@effect/printer@0.47.0(@effect/typeclass@0.38.0(effect@3.19.19))(effect@3.19.19))(effect@3.19.19)': - dependencies: - '@effect/platform': 0.94.5(effect@3.19.19) - '@effect/printer': 0.47.0(@effect/typeclass@0.38.0(effect@3.19.19))(effect@3.19.19) - '@effect/printer-ansi': 0.47.0(@effect/typeclass@0.38.0(effect@3.19.19))(effect@3.19.19) - effect: 3.19.19 - ini: 4.1.3 - toml: 3.0.0 - yaml: 2.8.2 - - '@effect/cluster@0.56.4(@effect/platform@0.94.5(effect@3.19.19))(@effect/rpc@0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(@effect/rpc@0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(effect@3.19.19))(effect@3.19.19)': - dependencies: - '@effect/platform': 0.94.5(effect@3.19.19) - '@effect/rpc': 0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19) - '@effect/sql': 0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19) - '@effect/workflow': 0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(@effect/rpc@0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(effect@3.19.19) - effect: 3.19.19 - kubernetes-types: 1.30.0 - - '@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19)': - dependencies: - '@effect/platform': 0.94.5(effect@3.19.19) - effect: 3.19.19 - uuid: 11.1.0 - - '@effect/platform-node-shared@0.57.1(@effect/cluster@0.56.4(@effect/platform@0.94.5(effect@3.19.19))(@effect/rpc@0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(@effect/rpc@0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(@effect/rpc@0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(effect@3.19.19)': + '@effect/platform-node-shared@4.0.0-beta.66(effect@4.0.0-beta.66)': dependencies: - '@effect/cluster': 0.56.4(@effect/platform@0.94.5(effect@3.19.19))(@effect/rpc@0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(@effect/rpc@0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(effect@3.19.19))(effect@3.19.19) - '@effect/platform': 0.94.5(effect@3.19.19) - '@effect/rpc': 0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19) - '@effect/sql': 0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19) - '@parcel/watcher': 2.5.6 - effect: 3.19.19 - multipasta: 0.2.7 - ws: 8.19.0 + '@types/ws': 8.18.1 + effect: 4.0.0-beta.66 + ws: 8.20.1 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@0.104.1(@effect/cluster@0.56.4(@effect/platform@0.94.5(effect@3.19.19))(@effect/rpc@0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(@effect/rpc@0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(@effect/rpc@0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(effect@3.19.19)': + '@effect/platform-node@4.0.0-beta.66(effect@4.0.0-beta.66)(ioredis@5.10.1)': dependencies: - '@effect/cluster': 0.56.4(@effect/platform@0.94.5(effect@3.19.19))(@effect/rpc@0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(@effect/rpc@0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(effect@3.19.19))(effect@3.19.19) - '@effect/platform': 0.94.5(effect@3.19.19) - '@effect/platform-node-shared': 0.57.1(@effect/cluster@0.56.4(@effect/platform@0.94.5(effect@3.19.19))(@effect/rpc@0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(@effect/rpc@0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(@effect/rpc@0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(effect@3.19.19) - '@effect/rpc': 0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19) - '@effect/sql': 0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19) - effect: 3.19.19 - mime: 3.0.0 - undici: 7.24.4 - ws: 8.19.0 + '@effect/platform-node-shared': 4.0.0-beta.66(effect@4.0.0-beta.66) + effect: 4.0.0-beta.66 + ioredis: 5.10.1 + mime: 4.1.0 + undici: 8.3.0 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform@0.94.5(effect@3.19.19)': - dependencies: - effect: 3.19.19 - find-my-way-ts: 0.1.6 - msgpackr: 1.11.9 - multipasta: 0.2.7 - - '@effect/printer-ansi@0.47.0(@effect/typeclass@0.38.0(effect@3.19.19))(effect@3.19.19)': - dependencies: - '@effect/printer': 0.47.0(@effect/typeclass@0.38.0(effect@3.19.19))(effect@3.19.19) - '@effect/typeclass': 0.38.0(effect@3.19.19) - effect: 3.19.19 - - '@effect/printer@0.47.0(@effect/typeclass@0.38.0(effect@3.19.19))(effect@3.19.19)': - dependencies: - '@effect/typeclass': 0.38.0(effect@3.19.19) - effect: 3.19.19 - - '@effect/rpc@0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19)': - dependencies: - '@effect/platform': 0.94.5(effect@3.19.19) - effect: 3.19.19 - msgpackr: 1.11.9 - - '@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19)': - dependencies: - '@effect/experimental': 0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19) - '@effect/platform': 0.94.5(effect@3.19.19) - effect: 3.19.19 - uuid: 11.1.0 - - '@effect/typeclass@0.38.0(effect@3.19.19)': - dependencies: - effect: 3.19.19 - - '@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(@effect/platform@0.94.5(effect@3.19.19))(@effect/rpc@0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19))(effect@3.19.19)': - dependencies: - '@effect/experimental': 0.58.0(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19) - '@effect/platform': 0.94.5(effect@3.19.19) - '@effect/rpc': 0.73.2(@effect/platform@0.94.5(effect@3.19.19))(effect@3.19.19) - effect: 3.19.19 - '@emnapi/core@1.9.0': dependencies: '@emnapi/wasi-threads': 1.2.0 @@ -1761,6 +1573,8 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true + '@ioredis/commands@1.5.1': {} + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -1935,72 +1749,11 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.61.0': optional: true - '@parcel/watcher-android-arm64@2.5.6': - optional: true - - '@parcel/watcher-darwin-arm64@2.5.6': - optional: true - - '@parcel/watcher-darwin-x64@2.5.6': - optional: true - - '@parcel/watcher-freebsd-x64@2.5.6': - optional: true - - '@parcel/watcher-linux-arm-glibc@2.5.6': - optional: true - - '@parcel/watcher-linux-arm-musl@2.5.6': - optional: true - - '@parcel/watcher-linux-arm64-glibc@2.5.6': - optional: true - - '@parcel/watcher-linux-arm64-musl@2.5.6': - optional: true - - '@parcel/watcher-linux-x64-glibc@2.5.6': - optional: true - - '@parcel/watcher-linux-x64-musl@2.5.6': - optional: true - - '@parcel/watcher-win32-arm64@2.5.6': - optional: true - - '@parcel/watcher-win32-ia32@2.5.6': - optional: true - - '@parcel/watcher-win32-x64@2.5.6': - optional: true - - '@parcel/watcher@2.5.6': - dependencies: - detect-libc: 2.1.2 - is-glob: 4.0.3 - node-addon-api: 7.1.1 - picomatch: 4.0.3 - optionalDependencies: - '@parcel/watcher-android-arm64': 2.5.6 - '@parcel/watcher-darwin-arm64': 2.5.6 - '@parcel/watcher-darwin-x64': 2.5.6 - '@parcel/watcher-freebsd-x64': 2.5.6 - '@parcel/watcher-linux-arm-glibc': 2.5.6 - '@parcel/watcher-linux-arm-musl': 2.5.6 - '@parcel/watcher-linux-arm64-glibc': 2.5.6 - '@parcel/watcher-linux-arm64-musl': 2.5.6 - '@parcel/watcher-linux-x64-glibc': 2.5.6 - '@parcel/watcher-linux-x64-musl': 2.5.6 - '@parcel/watcher-win32-arm64': 2.5.6 - '@parcel/watcher-win32-ia32': 2.5.6 - '@parcel/watcher-win32-x64': 2.5.6 - '@polka/url@1.0.0-next.29': {} - '@putdotio/sdk@9.1.0': + '@putdotio/sdk@9.3.0': dependencies: - '@effect/platform': 0.94.5(effect@3.19.19) - effect: 3.19.19 + effect: 4.0.0-beta.66 '@rolldown/binding-android-arm64@1.0.0-rc.9': optional: true @@ -2071,6 +1824,10 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.12.0 + '@vitest/coverage-v8@4.1.5(vitest@4.1.5)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -2083,7 +1840,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.8.2)) + vitest: 4.1.5(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.9.0)) '@vitest/expect@4.1.5': dependencies: @@ -2094,13 +1851,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.8.2))': + '@vitest/mocker@4.1.5(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.9.0) '@vitest/pretty-format@4.1.5': dependencies: @@ -2126,7 +1883,7 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@voidzero-dev/vite-plus-core@0.1.20(@types/node@24.12.0)(esbuild@0.27.4)(typescript@5.9.3)(yaml@2.8.2)': + '@voidzero-dev/vite-plus-core@0.1.20(@types/node@24.12.0)(esbuild@0.27.4)(typescript@5.9.3)(yaml@2.9.0)': dependencies: '@oxc-project/runtime': 0.127.0 '@oxc-project/types': 0.127.0 @@ -2137,7 +1894,7 @@ snapshots: esbuild: 0.27.4 fsevents: 2.3.3 typescript: 5.9.3 - yaml: 2.8.2 + yaml: 2.9.0 '@voidzero-dev/vite-plus-darwin-arm64@0.1.20': optional: true @@ -2157,11 +1914,11 @@ snapshots: '@voidzero-dev/vite-plus-linux-x64-musl@0.1.20': optional: true - '@voidzero-dev/vite-plus-test@0.1.20(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(esbuild@0.27.4)(typescript@5.9.3)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.8.2))(yaml@2.8.2)': + '@voidzero-dev/vite-plus-test@0.1.20(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(esbuild@0.27.4)(typescript@5.9.3)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.9.0))(yaml@2.9.0)': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@voidzero-dev/vite-plus-core': 0.1.20(@types/node@24.12.0)(esbuild@0.27.4)(typescript@5.9.3)(yaml@2.8.2) + '@voidzero-dev/vite-plus-core': 0.1.20(@types/node@24.12.0)(esbuild@0.27.4)(typescript@5.9.3)(yaml@2.9.0) es-module-lexer: 1.7.0 obug: 2.1.1 pixelmatch: 7.1.0 @@ -2171,7 +1928,7 @@ snapshots: tinybench: 2.9.0 tinyexec: 1.0.4 tinyglobby: 0.2.15 - vite: 8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.9.0) ws: 8.19.0 optionalDependencies: '@types/node': 24.12.0 @@ -2215,22 +1972,40 @@ snapshots: chai@6.2.2: {} + ci-info@4.4.0: {} + cli-table3@0.6.5: dependencies: string-width: 4.2.3 optionalDependencies: '@colors/colors': 1.5.0 + cluster-key-slot@1.1.2: {} + commander@9.5.0: {} convert-source-map@2.0.0: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 + + denque@2.1.0: {} + detect-libc@2.1.2: {} - effect@3.19.19: + effect@4.0.0-beta.66: dependencies: '@standard-schema/spec': 1.1.0 - fast-check: 3.23.2 + fast-check: 4.8.0 + find-my-way-ts: 0.1.6 + ini: 6.0.0 + kubernetes-types: 1.30.0 + msgpackr: 1.11.9 + multipasta: 0.2.7 + toml: 4.1.1 + uuid: 13.0.2 + yaml: 2.9.0 emoji-regex@8.0.0: {} @@ -2273,9 +2048,9 @@ snapshots: expect-type@1.3.0: {} - fast-check@3.23.2: + fast-check@4.8.0: dependencies: - pure-rand: 6.1.0 + pure-rand: 8.4.0 fdir@6.5.0(picomatch@4.0.3): optionalDependencies: @@ -2296,15 +2071,27 @@ snapshots: optionalDependencies: typescript: 5.9.3 - ini@4.1.3: {} - - is-extglob@2.1.1: {} + ini@6.0.0: {} - is-fullwidth-code-point@3.0.0: {} + ioredis@5.10.1: + dependencies: + '@ioredis/commands': 1.5.1 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color - is-glob@4.0.3: + is-ci@4.1.0: dependencies: - is-extglob: 2.1.1 + ci-info: 4.4.0 + + is-fullwidth-code-point@3.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -2372,6 +2159,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lodash.defaults@4.2.0: {} + + lodash.isarguments@3.1.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2386,10 +2177,12 @@ snapshots: dependencies: semver: 7.7.4 - mime@3.0.0: {} + mime@4.1.0: {} mrmime@2.0.1: {} + ms@2.1.3: {} + msgpackr-extract@3.0.3: dependencies: node-gyp-build-optional-packages: 5.2.2 @@ -2410,8 +2203,6 @@ snapshots: nanoid@3.3.11: {} - node-addon-api@7.1.1: {} - node-gyp-build-optional-packages@5.2.2: dependencies: detect-libc: 2.1.2 @@ -2497,7 +2288,13 @@ snapshots: dependencies: commander: 9.5.0 - pure-rand@6.1.0: {} + pure-rand@8.4.0: {} + + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 rolldown@1.0.0-rc.9: dependencies: @@ -2534,6 +2331,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + std-env@4.0.0: {} string-width@4.2.3: @@ -2563,7 +2362,7 @@ snapshots: tinyrainbow@3.1.0: {} - toml@3.0.0: {} + toml@4.1.1: {} totalist@3.0.1: {} @@ -2574,15 +2373,15 @@ snapshots: undici-types@7.16.0: {} - undici@7.24.4: {} + undici@8.3.0: {} - uuid@11.1.0: {} + uuid@13.0.2: {} - vite-plus@0.1.20(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(esbuild@0.27.4)(typescript@5.9.3)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.8.2))(yaml@2.8.2): + vite-plus@0.1.20(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(esbuild@0.27.4)(typescript@5.9.3)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.9.0))(yaml@2.9.0): dependencies: '@oxc-project/types': 0.127.0 - '@voidzero-dev/vite-plus-core': 0.1.20(@types/node@24.12.0)(esbuild@0.27.4)(typescript@5.9.3)(yaml@2.8.2) - '@voidzero-dev/vite-plus-test': 0.1.20(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(esbuild@0.27.4)(typescript@5.9.3)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.8.2))(yaml@2.8.2) + '@voidzero-dev/vite-plus-core': 0.1.20(@types/node@24.12.0)(esbuild@0.27.4)(typescript@5.9.3)(yaml@2.9.0) + '@voidzero-dev/vite-plus-test': 0.1.20(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(esbuild@0.27.4)(typescript@5.9.3)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.9.0))(yaml@2.9.0) oxfmt: 0.46.0 oxlint: 1.61.0(oxlint-tsgolint@0.22.0) oxlint-tsgolint: 0.22.0 @@ -2625,7 +2424,7 @@ snapshots: - vite - yaml - vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.8.2): + vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.9.0): dependencies: '@oxc-project/runtime': 0.115.0 lightningcss: 1.32.0 @@ -2637,12 +2436,12 @@ snapshots: '@types/node': 24.12.0 esbuild: 0.27.4 fsevents: 2.3.3 - yaml: 2.8.2 + yaml: 2.9.0 - vitest@4.1.5(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.8.2)): + vitest@4.1.5(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.8.2)) + '@vitest/mocker': 4.1.5(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -2659,7 +2458,7 @@ snapshots: tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@24.12.0)(esbuild@0.27.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.0 @@ -2674,4 +2473,6 @@ snapshots: ws@8.19.0: {} - yaml@2.8.2: {} + ws@8.20.1: {} + + yaml@2.9.0: {} diff --git a/scripts/prepare-effect.sh b/scripts/prepare-effect.sh new file mode 100755 index 0000000..80eba50 --- /dev/null +++ b/scripts/prepare-effect.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env sh + +set -eu + +if command -v is-ci >/dev/null 2>&1 && is-ci; then + exit 0 +fi + +if [ -n "${CI:-}" ]; then + exit 0 +fi + +repo_dir=".repos/effect" +repo_url="https://github.com/Effect-TS/effect-smol" + +if [ -d "$repo_dir/.git" ]; then + exit 0 +fi + +mkdir -p ".repos" +git clone "$repo_url" "$repo_dir" diff --git a/src/bin.ts b/src/bin.ts index 271e810..b58d711 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -11,7 +11,7 @@ import { CliRuntime } from "./internal/runtime.js"; NodeRuntime.runMain( Effect.scoped( Effect.flatMap(CliRuntime, (runtime) => runCli(runtime.argv)).pipe( - Effect.catchAllCause((cause) => + Effect.catchCause((cause) => Effect.gen(function* () { const cliOutput = yield* CliOutput; const runtime = yield* CliRuntime; diff --git a/src/cli.test.ts b/src/cli.test.ts index 5cee560..35edfdb 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -2,9 +2,10 @@ import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { ConfigProvider, Effect } from "effect"; +import { ConfigProvider, Effect, Result } from "effect"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import packageJson from "../package.json"; import { runCli as executeCli } from "./cli.js"; import { makeCliAppLayer } from "./internal/app-layer.js"; import { makeCliRuntime } from "./internal/runtime.js"; @@ -15,7 +16,7 @@ const errorText = (error: unknown) => : JSON.stringify(error); type CliExecution = { - readonly result: Awaited>; + readonly result: Result.Result; readonly stderr: string; readonly stdout: string; }; @@ -44,21 +45,23 @@ const runCli = async (argv: ReadonlyArray): Promise => { try { const result = await Effect.runPromise( Effect.scoped( - Effect.either( + Effect.result( executeCli(processArgv).pipe( - Effect.withConfigProvider( - ConfigProvider.fromMap( - new Map([ - ["PUTIO_CLI_CONFIG_PATH", configPath], - ["XDG_CONFIG_HOME", configDir], - ]), - ), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromUnknown({ + PUTIO_CLI_CONFIG_PATH: configPath, + XDG_CONFIG_HOME: configDir, + }), ), Effect.provide( makeCliAppLayer( makeCliRuntime({ argv: processArgv, homeDirectory: configDir, + writeStdout: (message) => { + stdoutChunks.push(message.trim()); + }, }), ), ), @@ -86,14 +89,21 @@ describe("cli argv parsing", () => { it("renders the root help successfully", async () => { const { result, stdout } = await runCli(["putio"]); - expect(result._tag).toBe("Right"); + expect(result._tag).toBe("Success"); expect(stdout).toContain("Use `putio describe` or `putio --help`."); }); + it("renders the global version without double-prefixing", async () => { + const { result, stdout } = await runCli(["putio", "--version"]); + + expect(result._tag).toBe("Success"); + expect(stdout).toBe(`putio v${packageJson.version}`); + }); + it("renders describe as machine-readable json", async () => { const { result, stdout } = await runCli(["putio", "describe"]); - expect(result._tag).toBe("Right"); + expect(result._tag).toBe("Success"); expect(parseJsonOutput(stdout)).toMatchObject({ binary: "putio", output: { @@ -129,6 +139,32 @@ describe("cli argv parsing", () => { }), ]), ); + const commands = parseJsonOutput(stdout).commands as ReadonlyArray<{ + readonly command: string; + readonly input: { + readonly json?: { + readonly properties?: ReadonlyArray<{ + readonly name: string; + readonly required: boolean; + }>; + }; + }; + }>; + const mkdir = commands.find((entry) => entry.command === "files mkdir"); + const deleteFiles = commands.find((entry) => entry.command === "files delete"); + + expect(mkdir?.input.json?.properties).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "name", required: true }), + expect.objectContaining({ name: "parent_id", required: false }), + ]), + ); + expect(deleteFiles?.input.json?.properties).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "ids", required: true }), + expect.objectContaining({ name: "skip_trash", required: false }), + ]), + ); }); it("renders auth preview as json without hitting the API", async () => { @@ -142,7 +178,7 @@ describe("cli argv parsing", () => { "json", ]); - expect(result._tag).toBe("Right"); + expect(result._tag).toBe("Success"); expect(parseJsonOutput(stdout)).toMatchObject({ browserOpened: false, code: "PUTIO1", @@ -153,7 +189,7 @@ describe("cli argv parsing", () => { it("renders auth status as json without a configured token", async () => { const { result, stdout } = await runCli(["putio", "auth", "status", "--output", "json"]); - expect(result._tag).toBe("Right"); + expect(result._tag).toBe("Success"); expect(parseJsonOutput(stdout)).toMatchObject({ apiBaseUrl: "https://api.put.io", authenticated: false, @@ -164,7 +200,7 @@ describe("cli argv parsing", () => { it("defaults to json output for non-interactive auth status", async () => { const { result, stdout } = await runCli(["putio", "auth", "status"]); - expect(result._tag).toBe("Right"); + expect(result._tag).toBe("Success"); expect(parseJsonOutput(stdout)).toMatchObject({ apiBaseUrl: "https://api.put.io", authenticated: false, @@ -187,10 +223,10 @@ describe("cli argv parsing", () => { "json", ]); - expect(result._tag).toBe("Left"); - if (result._tag === "Left") { - expect(errorText(result.left)).toContain("No put.io token is configured"); - expect(errorText(result.left)).not.toContain("not a integer"); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(errorText(result.failure)).toContain("No put.io token is configured"); + expect(errorText(result.failure)).not.toContain("not a integer"); } }); @@ -207,10 +243,10 @@ describe("cli argv parsing", () => { "json", ]); - expect(result._tag).toBe("Left"); - if (result._tag === "Left") { - expect(errorText(result.left)).toContain("No put.io token is configured"); - expect(errorText(result.left)).not.toContain("not a integer"); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(errorText(result.failure)).toContain("No put.io token is configured"); + expect(errorText(result.failure)).not.toContain("not a integer"); } }); @@ -227,10 +263,10 @@ describe("cli argv parsing", () => { "json", ]); - expect(result._tag).toBe("Left"); - if (result._tag === "Left") { - expect(errorText(result.left)).toContain("No put.io token is configured"); - expect(errorText(result.left)).not.toContain("not a integer"); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(errorText(result.failure)).toContain("No put.io token is configured"); + expect(errorText(result.failure)).not.toContain("not a integer"); } }); @@ -247,15 +283,15 @@ describe("cli argv parsing", () => { "json", ]); - expect(result._tag).toBe("Left"); - if (result._tag === "Left") { - expect(errorText(result.left)).toContain("No put.io token is configured"); - expect(errorText(result.left)).not.toContain("not a integer"); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(errorText(result.failure)).toContain("No put.io token is configured"); + expect(errorText(result.failure)).not.toContain("not a integer"); } }); it("still rejects invalid repeated integer input", async () => { - const { result } = await runCli([ + const { result, stderr, stdout } = await runCli([ "putio", "files", "move", @@ -269,9 +305,63 @@ describe("cli argv parsing", () => { "json", ]); - expect(result._tag).toBe("Left"); - if (result._tag === "Left") { - expect(errorText(result.left)).toContain("Expected `--id` values to be integers"); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(`${stdout}\n${stderr}\n${errorText(result.failure)}`).toContain( + "Expected `--id` values to be integers", + ); + } + }); + + it("keeps structured parser failures machine-readable", async () => { + const { result, stderr, stdout } = await runCli([ + "putio", + "files", + "list", + "--parent-id", + "foo", + "--output", + "json", + ]); + + expect(result._tag).toBe("Failure"); + expect(stdout).toBe(""); + expect(stderr).toBe(""); + if (result._tag === "Failure") { + expect(errorText(result.failure)).toContain("Invalid value for flag --parent-id"); + expect(errorText(result.failure)).not.toContain("Help requested"); + } + }); + + it("rejects whitespace-only JSON names before write dry-runs", async () => { + const mkdir = await runCli([ + "putio", + "files", + "mkdir", + "--json", + '{"parent_id":1,"name":" "}', + "--dry-run", + "--output", + "json", + ]); + const rename = await runCli([ + "putio", + "files", + "rename", + "--json", + '{"file_id":1,"name":" "}', + "--dry-run", + "--output", + "json", + ]); + + expect(mkdir.result._tag).toBe("Failure"); + expect(rename.result._tag).toBe("Failure"); + if (mkdir.result._tag === "Failure") { + expect(errorText(mkdir.result.failure)).toContain("Expected `--json` to match"); + } + if (rename.result._tag === "Failure") { + expect(errorText(rename.result.failure)).toContain("Expected `--json` to match"); } }); }); diff --git a/src/cli.ts b/src/cli.ts index 63e83bd..e57f335 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,5 @@ -import { Command } from "@effect/cli"; -import { Console } from "effect"; +import { CliError, Command } from "effect/unstable/cli"; +import { Console, Effect, Result } from "effect"; import packageJson from "../package.json"; import { makeAuthCommand } from "./commands/auth.js"; @@ -8,10 +8,19 @@ import { downloadLinksCommand } from "./commands/download-links.js"; import { eventsCommand } from "./commands/events.js"; import { filesCommand, searchCommand } from "./commands/files.js"; import { translate } from "./i18n/index.js"; +import type { CliConfig } from "./internal/config.js"; import { transfersCommand } from "./commands/transfers.js"; import { whoamiCommand } from "./commands/whoami.js"; import { describeCli } from "./internal/metadata.js"; -import { renderJson } from "./internal/output-service.js"; +import type { CliOutput } from "./internal/output-service.js"; +import { CliRuntime } from "./internal/runtime.js"; +import { + detectOutputModeFromArgv, + isStructuredOutputMode, + renderJson, +} from "./internal/output-service.js"; +import type { CliSdk } from "./internal/sdk.js"; +import type { CliState } from "./internal/state.js"; const authCommand = makeAuthCommand(); @@ -32,9 +41,103 @@ const command = Command.make("putio", {}, () => Console.log(translate("cli.root. ]), ); -export function runCli(args: ReadonlyArray) { - return Command.run(command, { - name: "putio", - version: `v${packageJson.version}`, - })(args); +type BufferedConsoleEntry = { + readonly args: ReadonlyArray; + readonly method: keyof Console.Console; +}; + +const makeBufferedConsole = (entries: Array): Console.Console => ({ + assert: (condition, ...args) => entries.push({ args: [condition, ...args], method: "assert" }), + clear: () => entries.push({ args: [], method: "clear" }), + count: (label) => entries.push({ args: label === undefined ? [] : [label], method: "count" }), + countReset: (label) => + entries.push({ args: label === undefined ? [] : [label], method: "countReset" }), + debug: (...args) => entries.push({ args, method: "debug" }), + dir: (item, options) => + entries.push({ args: options === undefined ? [item] : [item, options], method: "dir" }), + dirxml: (...args) => entries.push({ args, method: "dirxml" }), + error: (...args) => entries.push({ args, method: "error" }), + group: (...args) => entries.push({ args, method: "group" }), + groupCollapsed: (...args) => entries.push({ args, method: "groupCollapsed" }), + groupEnd: () => entries.push({ args: [], method: "groupEnd" }), + info: (...args) => entries.push({ args, method: "info" }), + log: (...args) => entries.push({ args, method: "log" }), + table: (tabularData, properties) => + entries.push({ + args: properties === undefined ? [tabularData] : [tabularData, properties], + method: "table", + }), + time: (label) => entries.push({ args: label === undefined ? [] : [label], method: "time" }), + timeEnd: (label) => entries.push({ args: label === undefined ? [] : [label], method: "timeEnd" }), + timeLog: (label, ...args) => + entries.push({ + args: label === undefined ? args : [label, ...args], + method: "timeLog", + }), + trace: (...args) => entries.push({ args, method: "trace" }), + warn: (...args) => entries.push({ args, method: "warn" }), +}); + +const replayBufferedConsole = ( + console: Console.Console, + entries: ReadonlyArray, +) => + Effect.sync(() => { + for (const entry of entries) { + const method = console[entry.method] as (...args: ReadonlyArray) => void; + method(...entry.args); + } + }); + +const formatCliParserError = (error: CliError.ShowHelp) => + error.errors.map((nestedError) => nestedError.message).join("\n"); + +type CliCommandEnvironment = + | Command.Environment + | CliConfig + | CliOutput + | CliRuntime + | CliSdk + | CliState; + +export function runCli( + args: ReadonlyArray, +): Effect.Effect { + const run = Command.runWith(command, { + version: packageJson.version, + }); + + return Effect.flatMap(CliRuntime, (runtime) => { + const outputMode = detectOutputModeFromArgv(args, runtime.isInteractiveTerminal); + + if (!isStructuredOutputMode(outputMode)) { + return run(args.slice(2)); + } + + return Console.consoleWith((currentConsole) => { + const entries: Array = []; + + return run(args.slice(2)).pipe( + Effect.provideService(Console.Console, makeBufferedConsole(entries)), + Effect.tap(() => replayBufferedConsole(currentConsole, entries)), + Effect.catchFilter( + (error) => + CliError.isCliError(error) && error._tag === "ShowHelp" + ? Result.succeed(error) + : Result.fail(error), + (error) => { + if (error.errors.length === 0) { + return replayBufferedConsole(currentConsole, entries); + } + + return Effect.fail(new Error(formatCliParserError(error))); + }, + (error) => + Effect.flatMap(replayBufferedConsole(currentConsole, entries), () => + Effect.fail(error), + ), + ), + ); + }); + }); } diff --git a/src/command-paths.test.ts b/src/command-paths.test.ts index 315ac3a..109480d 100644 --- a/src/command-paths.test.ts +++ b/src/command-paths.test.ts @@ -5,6 +5,70 @@ import { resetCommandPathMocks } from "./test-support/command-path-mocks.js"; import { runCliInTest } from "./test-support/run-cli.js"; const mocks = vi.hoisted(() => { + type FileListItem = { + readonly file_type?: string; + readonly id: number; + readonly name?: string; + readonly size?: number; + }; + type FileListPage = { + readonly cursor: string | null; + readonly files: ReadonlyArray; + readonly total?: number; + }; + type TransferListItem = { + readonly id: number; + readonly name: string; + readonly percent_done?: number; + readonly status?: string; + }; + type TransferListPage = { + readonly cursor: string | null; + readonly transfers: ReadonlyArray; + }; + const emptyFileListPage: FileListPage = { + cursor: null, + files: [], + total: 1, + }; + const emptyTransferListPage: TransferListPage = { + cursor: null, + transfers: [], + }; + const defaultFileListPage: FileListPage = { + cursor: null, + files: [ + { + file_type: "FOLDER", + id: 1, + name: "Movies", + size: 0, + }, + ], + total: 1, + }; + const defaultSearchFilesPage: FileListPage = { + cursor: null, + files: [ + { + file_type: "VIDEO", + id: 2, + name: "movie.mkv", + size: 42, + }, + ], + }; + const defaultTransferListPage: TransferListPage = { + cursor: null, + transfers: [ + { + id: 7, + name: "ubuntu.iso", + percent_done: 50, + status: "DOWNLOADING", + }, + ], + }; const writeOutputMock = vi.fn(() => Effect.void); const withTerminalLoaderMock = vi.fn((_options, program) => program); const withAuthedSdkMock = vi.fn((program) => @@ -21,25 +85,8 @@ const mocks = vi.hoisted(() => { const provideSdkMock = vi.fn((_config, program) => program); const getCodeMock = vi.fn(() => Effect.succeed({ code: "PUTIO1" })); const checkCodeMatchMock = vi.fn(() => Effect.succeed("token-123")); - const continueTransfersMock = vi.fn(() => - Effect.succeed({ - cursor: null, - transfers: [], - }), - ); - const listTransfersMock = vi.fn(() => - Effect.succeed({ - cursor: null, - transfers: [ - { - id: 7, - name: "ubuntu.iso", - percent_done: 50, - status: "DOWNLOADING", - }, - ], - }), - ); + const continueTransfersMock = vi.fn((_cursor?: string) => Effect.succeed(emptyTransferListPage)); + const listTransfersMock = vi.fn(() => Effect.succeed(defaultTransferListPage)); const addTransfersMock = vi.fn(() => Effect.succeed({ errors: [], @@ -81,46 +128,10 @@ const mocks = vi.hoisted(() => { const moveFilesMock = vi.fn(() => Effect.succeed([])); const renameFileMock = vi.fn(() => Effect.void); const deleteFilesMock = vi.fn(() => Effect.succeed({ skipped: 1 })); - const continueFilesMock = vi.fn(() => - Effect.succeed({ - cursor: null, - files: [], - total: 1, - }), - ); - const continueSearchFilesMock = vi.fn(() => - Effect.succeed({ - cursor: null, - files: [], - }), - ); - const listFilesMock = vi.fn(() => - Effect.succeed({ - cursor: null, - files: [ - { - file_type: "FOLDER", - id: 1, - name: "Movies", - size: 0, - }, - ], - total: 1, - }), - ); - const searchFilesMock = vi.fn(() => - Effect.succeed({ - cursor: null, - files: [ - { - file_type: "VIDEO", - id: 2, - name: "movie.mkv", - size: 42, - }, - ], - }), - ); + const continueFilesMock = vi.fn((_cursor?: string) => Effect.succeed(emptyFileListPage)); + const continueSearchFilesMock = vi.fn((_cursor?: string) => Effect.succeed(emptyFileListPage)); + const listFilesMock = vi.fn(() => Effect.succeed(defaultFileListPage)); + const searchFilesMock = vi.fn(() => Effect.succeed(defaultSearchFilesPage)); const getAccountInfoMock = vi.fn(() => Effect.succeed({ account_status: "ACTIVE", @@ -392,8 +403,21 @@ describe("cli command paths", () => { }); it("executes auth login through the happy path", async () => { + const stderrChunks: string[] = []; + + mocks.waitForDeviceTokenMock.mockImplementationOnce(() => { + expect(stderrChunks.join("")).toContain("https://app.put.io/link?code=PUTIO1"); + expect(stderrChunks.join("")).toContain("code: PUTIO1"); + + return Effect.succeed("token-123"); + }); + await expect( - runCliInTest(["putio", "auth", "login", "--output", "json", "--timeout-seconds", "1"]), + runCliInTest(["putio", "auth", "login", "--output", "json", "--timeout-seconds", "1"], { + writeStderr: (message) => { + stderrChunks.push(message); + }, + }), ).resolves.toBeUndefined(); expect(mocks.getCodeMock).toHaveBeenCalled(); @@ -1375,6 +1399,23 @@ describe("cli command paths", () => { ); }); + it("omits absent transfers clean ids from dry-run output", async () => { + await expect( + runCliInTest(["putio", "transfers", "clean", "--dry-run", "--output", "json"]), + ).resolves.toBeUndefined(); + + expect(mocks.cleanTransfersMock).not.toHaveBeenCalled(); + expect(mocks.writeOutputMock).toHaveBeenCalledWith( + { + command: "transfers clean", + dryRun: true, + request: {}, + }, + "json", + expect.any(Function), + ); + }); + it("executes transfers reannounce", async () => { await expect( runCliInTest(["putio", "transfers", "reannounce", "--id", "7", "--output", "json"]), diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 16b33c5..bc88509 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,6 +1,6 @@ -import { Command } from "@effect/cli"; -import * as Terminal from "@effect/platform/Terminal"; -import { Console, Effect, Fiber, Option } from "effect"; +import { Command } from "effect/unstable/cli"; +import * as Terminal from "effect/Terminal"; +import { Console, Effect, Fiber, Option, Queue } from "effect"; import { translate } from "../i18n/index.js"; import { @@ -18,12 +18,15 @@ import { validateResourceIdentifier, } from "../internal/command.js"; import { outputFlag, type CommandSpec } from "../internal/command-specs.js"; +import type { CliConfig } from "../internal/config.js"; import { resolveCliRuntimeConfig } from "../internal/config.js"; import { withTerminalLoader } from "../internal/loader-service.js"; +import type { CliOutput } from "../internal/output-service.js"; import { normalizeOutputMode, writeOutput } from "../internal/output-service.js"; import { CliRuntime } from "../internal/runtime.js"; -import { provideSdk, sdk } from "../internal/sdk.js"; +import { provideSdk, sdk, type CliSdk } from "../internal/sdk.js"; import { + type CliState, type AuthStatus, clearPersistedState, getAuthStatus, @@ -42,6 +45,16 @@ const openOption = openConfig.option; const timeoutSecondsOption = timeoutSecondsConfig.option; const previewCodeOption = previewCodeConfig.option; +type AuthCommandEnvironment = + | Command.Environment + | CliConfig + | CliOutput + | CliRuntime + | CliSdk + | CliState; + +type AuthCommand = Command.Command<"auth", {}, {}, unknown, AuthCommandEnvironment>; + const waitForOpenShortcut = (url: string) => Effect.gen(function* () { const runtimeService = yield* CliRuntime; @@ -51,23 +64,17 @@ const waitForOpenShortcut = (url: string) => } const terminal = yield* Terminal.Terminal; - const isTTY = yield* terminal.isTTY; - - if (!isTTY) { - return false; - } - const input = yield* terminal.readInput; while (true) { - const event = yield* input.take; + const event = yield* Queue.take(input); const keyInput = Option.getOrElse(event.input, () => "").toLowerCase(); if (event.key.name === "o" || keyInput === "o") { return yield* runtimeService.openExternal(url); } } - }).pipe(Effect.catchTag("NoSuchElementException", () => Effect.succeed(false))); + }).pipe(Effect.catch(() => Effect.succeed(false))); const renderAuthStatus = (status: AuthStatus) => status.authenticated @@ -134,7 +141,7 @@ const authLogin = Command.make( yield* outputMode === "terminal" ? Console.log(instructionMessage) - : Console.error(instructionMessage); + : runtimeService.writeStderr(`${instructionMessage}\n`); const openShortcutFiber = outputMode === "terminal" && !browserOpened @@ -203,7 +210,7 @@ const authPreview = Command.make( }), ); -export const makeAuthCommand = () => +export const makeAuthCommand = (): AuthCommand => Command.make("auth", {}, () => Console.log(translate("cli.root.chooseAuthSubcommand"))).pipe( Command.withSubcommands([authStatus, authLogin, authLogout, authPreview]), ); diff --git a/src/commands/brand.ts b/src/commands/brand.ts index b59ffd3..66df804 100644 --- a/src/commands/brand.ts +++ b/src/commands/brand.ts @@ -1,4 +1,4 @@ -import { Command } from "@effect/cli"; +import { Command } from "effect/unstable/cli"; import packageJson from "../../package.json"; diff --git a/src/commands/download-links.ts b/src/commands/download-links.ts index f931d54..7b64791 100644 --- a/src/commands/download-links.ts +++ b/src/commands/download-links.ts @@ -1,4 +1,4 @@ -import { Command } from "@effect/cli"; +import { Command } from "effect/unstable/cli"; import { DownloadLinksCreateInputSchema } from "@putdotio/sdk"; import { Data, Effect, Option, Schema } from "effect"; diff --git a/src/commands/events.ts b/src/commands/events.ts index 917394d..a08bee8 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -1,4 +1,4 @@ -import { Command } from "@effect/cli"; +import { Command } from "effect/unstable/cli"; import { Effect, Option } from "effect"; import { diff --git a/src/commands/files.ts b/src/commands/files.ts index 5992130..8e5bb8f 100644 --- a/src/commands/files.ts +++ b/src/commands/files.ts @@ -1,4 +1,4 @@ -import { Command } from "@effect/cli"; +import { Command } from "effect/unstable/cli"; import { Effect, Option, Schema } from "effect"; import { @@ -83,26 +83,22 @@ const sortByOption = sortByConfig.option; const optionalFileIdOption = optionalFileIdConfig.option; const optionalFileNameOption = optionalFileNameConfig.option; -const NonEmptyStringSchema = Schema.String.pipe( - Schema.filter((value): value is string => value.trim().length > 0, { - message: () => "Expected a non-empty string", - }), +const NonBlankStringSchema = Schema.String.check( + Schema.makeFilter((value) => + value.trim().length > 0 ? undefined : "Expected a non-empty string", + ), ); -const NonEmptyIdsSchema = Schema.Array(Schema.Number).pipe( - Schema.filter((value): value is ReadonlyArray => value.length > 0, { - message: () => "Expected at least one id", - }), -); +const NonEmptyIdsSchema = Schema.Array(Schema.Number).check(Schema.isNonEmpty()); export const FilesMkdirInputSchema = Schema.Struct({ - name: NonEmptyStringSchema, + name: NonBlankStringSchema, parent_id: Schema.optional(Schema.Number), }); export const FilesRenameInputSchema = Schema.Struct({ file_id: Schema.Number, - name: NonEmptyStringSchema, + name: NonBlankStringSchema, }); export const FilesDeleteInputSchema = Schema.Struct({ diff --git a/src/commands/transfers.ts b/src/commands/transfers.ts index 0b52616..f1c0177 100644 --- a/src/commands/transfers.ts +++ b/src/commands/transfers.ts @@ -1,4 +1,4 @@ -import { Command, Options } from "@effect/cli"; +import { Command, Flag } from "effect/unstable/cli"; import { TransferAddInputSchema } from "@putdotio/sdk"; import { Clock, Duration, Effect, Option, Schema } from "effect"; @@ -56,20 +56,10 @@ const optionalTransferIdOption = optionalTransferIdConfig.option; const WATCH_TERMINAL_STATUSES = ["COMPLETED", "ERROR", "SEEDING"] as const; -const NonEmptyIdsSchema = Schema.Array(Schema.Number).pipe( - Schema.filter((value): value is ReadonlyArray => value.length > 0, { - message: () => "Expected at least one id", - }), -); +const NonEmptyIdsSchema = Schema.Array(Schema.Number).check(Schema.isNonEmpty()); -export const TransfersAddInputSchema = Schema.Array(TransferAddInputSchema).pipe( - Schema.filter( - (value): value is ReadonlyArray> => - value.length > 0, - { - message: () => "Expected at least one transfer input", - }, - ), +export const TransfersAddInputSchema = Schema.Array(TransferAddInputSchema).check( + Schema.isNonEmpty(), ); export const TransfersCancelInputSchema = Schema.Struct({ @@ -323,16 +313,18 @@ const transfersClean = Command.make( "clean", { dryRun: dryRunOption, - id: transferIdsOption.pipe(Options.optional), + id: transferIdsOption.pipe(Flag.optional), json: jsonOption, output: outputOption, }, ({ dryRun, id, json, output }) => Effect.gen(function* () { const input = yield* resolveMutationInput({ - buildFromFlags: () => ({ - ids: Option.getOrUndefined(id), - }), + buildFromFlags: () => { + const ids = Option.getOrUndefined(id); + + return ids === undefined || ids.length === 0 ? {} : { ids }; + }, json, schema: TransfersCleanInputSchema, }); diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index 8770375..3afa423 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -1,4 +1,4 @@ -import { Command } from "@effect/cli"; +import { Command } from "effect/unstable/cli"; import { Effect } from "effect"; import { translate } from "../i18n/index.js"; diff --git a/src/internal/agent-dx.ts b/src/internal/agent-dx.ts index 664db90..4281f81 100644 --- a/src/internal/agent-dx.ts +++ b/src/internal/agent-dx.ts @@ -2,7 +2,7 @@ import { Schema } from "effect"; import type { CliOutputContract, CommandDescriptor } from "./cli-contract.js"; -const AgentDxCategoryNameSchema = Schema.Literal( +const AgentDxCategoryNameSchema = Schema.Literals([ "machineReadableOutput", "rawPayloadInput", "schemaIntrospection", @@ -10,7 +10,7 @@ const AgentDxCategoryNameSchema = Schema.Literal( "inputHardening", "safetyRails", "agentKnowledgePackaging", -); +] as const); export type AgentDxCategoryName = Schema.Schema.Type; diff --git a/src/internal/app-layer.ts b/src/internal/app-layer.ts index 656a88a..5c9a274 100644 --- a/src/internal/app-layer.ts +++ b/src/internal/app-layer.ts @@ -1,5 +1,4 @@ -import { NodeContext } from "@effect/platform-node"; -import * as NodeTerminal from "@effect/platform-node/NodeTerminal"; +import { NodeServices } from "@effect/platform-node"; import { Layer } from "effect"; import { CliConfigLive } from "./config.js"; @@ -12,8 +11,7 @@ export const makeCliAppLayer = (runtime?: CliRuntimeService) => { const runtimeLayer = runtime ? Layer.succeed(CliRuntime, runtime) : CliRuntimeLive; return Layer.mergeAll( - NodeContext.layer, - NodeTerminal.layer, + NodeServices.layer, runtimeLayer, CliOutputLive.pipe(Layer.provide(runtimeLayer)), CliConfigLive.pipe(Layer.provide(runtimeLayer)), diff --git a/src/internal/auth-flow.test.ts b/src/internal/auth-flow.test.ts index c251098..3aef0a7 100644 --- a/src/internal/auth-flow.test.ts +++ b/src/internal/auth-flow.test.ts @@ -9,8 +9,7 @@ import { resolveAuthFlowConfig, waitForDeviceToken, } from "./auth-flow.js"; -import type { CliRuntime as CliRuntimeService } from "./runtime.js"; -import { CliRuntime, makeCliRuntime } from "./runtime.js"; +import { CliRuntime, makeCliRuntime, type CliRuntimeService } from "./runtime.js"; const mockRuntime: CliRuntimeService = { argv: ["node", "putio"], @@ -21,6 +20,8 @@ const mockRuntime: CliRuntimeService = { joinPath: (...segments) => segments.join("/"), openExternal: (_url) => Effect.succeed(true), setExitCode: (_code) => Effect.void, + writeStdout: (_message) => Effect.void, + writeStderr: (_message) => Effect.void, startSpinner: (_message) => Effect.succeed({ stop: Effect.void, @@ -43,13 +44,12 @@ describe("resolveAuthFlowConfig", () => { it("uses env overrides for client name and web app url", async () => { const result = await Effect.runPromise( resolveAuthFlowConfig().pipe( - Effect.withConfigProvider( - ConfigProvider.fromMap( - new Map([ - ["PUTIO_CLI_CLIENT_NAME", "putio-cli-test"], - ["PUTIO_CLI_WEB_APP_URL", "https://app.put.io"], - ]), - ), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromUnknown({ + PUTIO_CLI_CLIENT_NAME: "putio-cli-test", + PUTIO_CLI_WEB_APP_URL: "https://app.put.io", + }), ), Effect.provide(makeCliAppLayer(makeCliRuntime({ hostName: "cli-test-host" }))), ), @@ -111,7 +111,7 @@ describe("waitForDeviceToken", () => { it("fails with a timeout error when authorization never completes", async () => { const resultPromise = Effect.runPromise( - Effect.either( + Effect.result( waitForDeviceToken({ code: "ABCD1234", pollIntervalMs: 1_000, @@ -125,15 +125,17 @@ describe("waitForDeviceToken", () => { const result = await resultPromise; - expect(result._tag).toBe("Left"); - if (result._tag === "Left") { - expect(result.left.message).toBe("Timed out waiting for device authorization to complete."); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure.message).toBe( + "Timed out waiting for device authorization to complete.", + ); } }); it("fails with a polling error when the backend check fails", async () => { const result = await Effect.runPromise( - Effect.either( + Effect.result( waitForDeviceToken({ code: "ABCD1234", checkCodeMatch: () => Effect.fail(new Error("boom")), @@ -141,9 +143,9 @@ describe("waitForDeviceToken", () => { ), ); - expect(result._tag).toBe("Left"); - if (result._tag === "Left") { - expect(result.left.message).toBe( + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure.message).toBe( "Unable to poll put.io for the device authorization result.", ); } diff --git a/src/internal/auth-flow.ts b/src/internal/auth-flow.ts index 5003e67..7882026 100644 --- a/src/internal/auth-flow.ts +++ b/src/internal/auth-flow.ts @@ -18,12 +18,12 @@ export const buildDeviceLinkUrl = (code: string, webAppUrl: string = DEFAULT_PUT export const openBrowser = (url: string): Effect.Effect => Effect.flatMap(CliRuntime, (runtime) => runtime.openExternal(url)); -export const waitForDeviceToken = (options: { +export const waitForDeviceToken = (options: { readonly code: string; - readonly checkCodeMatch: (code: string) => Effect.Effect; + readonly checkCodeMatch: (code: string) => Effect.Effect; readonly pollIntervalMs?: number; readonly timeoutMs?: number; -}): Effect.Effect => +}): Effect.Effect => Effect.gen(function* () { const pollIntervalMs = options.pollIntervalMs ?? 2_000; const timeoutMs = options.timeoutMs ?? 120_000; diff --git a/src/internal/command-specs.ts b/src/internal/command-specs.ts index 26eb576..cd9c7cc 100644 --- a/src/internal/command-specs.ts +++ b/src/internal/command-specs.ts @@ -1,29 +1,36 @@ import { Schema } from "effect"; +import * as SchemaAST from "effect/SchemaAST"; -const NonEmptyStringSchema = Schema.String.pipe( - Schema.filter((value): value is string => value.length > 0, { - message: () => "Expected a non-empty string", - }), -); +const NonEmptyStringSchema = Schema.String.check(Schema.isNonEmpty()); -export const OutputModeSchema = Schema.Literal("json", "text", "ndjson"); +export const OutputModeSchema = Schema.Literals(["json", "text", "ndjson"] as const); export type OutputMode = Schema.Schema.Type; -export const InternalRendererSchema = Schema.Literal("json", "terminal", "ndjson"); +export const InternalRendererSchema = Schema.Literals(["json", "terminal", "ndjson"] as const); export type InternalRenderer = Schema.Schema.Type; -export const CommandKindSchema = Schema.Literal("utility", "auth", "read", "write"); +export const CommandKindSchema = Schema.Literals(["utility", "auth", "read", "write"] as const); export type CommandKind = Schema.Schema.Type; -export const CommandOptionTypeSchema = Schema.Literal("string", "integer", "boolean", "enum"); +export const CommandOptionTypeSchema = Schema.Literals([ + "string", + "integer", + "boolean", + "enum", +] as const); export type CommandOptionType = Schema.Schema.Type; -const JsonPrimitiveKindSchema = Schema.Literal("string", "integer", "boolean", "null"); +const JsonPrimitiveKindSchema = Schema.Literals(["string", "integer", "boolean", "null"] as const); const JsonScalarSchema = Schema.Struct({ kind: JsonPrimitiveKindSchema, }); -const JsonEnumValueSchema = Schema.Union(Schema.String, Schema.Number, Schema.Boolean, Schema.Null); +const JsonEnumValueSchema = Schema.Union([ + Schema.String, + Schema.Number, + Schema.Boolean, + Schema.Null, +] as const); export type CommandJsonShape = | { @@ -49,10 +56,10 @@ export type JsonProperty = { readonly schema: CommandJsonShape; }; -const JsonPropertySchema: Schema.Schema = Schema.Struct({ +const JsonPropertySchema: Schema.Codec = Schema.Struct({ name: NonEmptyStringSchema, required: Schema.Boolean, - schema: Schema.suspend(() => CommandJsonShapeSchema), + schema: Schema.suspend((): Schema.Codec => CommandJsonShapeSchema), }); const JsonObjectSchema = Schema.Struct({ @@ -63,7 +70,7 @@ const JsonObjectSchema = Schema.Struct({ const JsonArraySchema = Schema.Struct({ kind: Schema.Literal("array"), - items: Schema.suspend(() => CommandJsonShapeSchema), + items: Schema.suspend((): Schema.Codec => CommandJsonShapeSchema), }); const JsonEnumSchema = Schema.Struct({ @@ -71,16 +78,18 @@ const JsonEnumSchema = Schema.Struct({ values: Schema.Array(JsonEnumValueSchema), }); -export const CommandJsonShapeSchema: Schema.Schema = Schema.Union( +export const CommandJsonShapeSchema: Schema.Codec = Schema.Union([ JsonScalarSchema, JsonEnumSchema, JsonObjectSchema, JsonArraySchema, -); +] as const); export const CommandOptionSchema = Schema.Struct({ choices: Schema.optional(Schema.Array(NonEmptyStringSchema)), - defaultValue: Schema.optional(Schema.Union(NonEmptyStringSchema, Schema.Number, Schema.Boolean)), + defaultValue: Schema.optional( + Schema.Union([NonEmptyStringSchema, Schema.Number, Schema.Boolean] as const), + ), description: Schema.optional(NonEmptyStringSchema), name: NonEmptyStringSchema, repeated: Schema.Boolean, @@ -358,40 +367,13 @@ export const enumFlag = ( type: "enum", }); -type SchemaAstNode = - | { - readonly _tag: string; - readonly from?: SchemaAstNode; - readonly literal?: string | number | boolean | null; - readonly to?: SchemaAstNode; - readonly propertySignatures?: ReadonlyArray<{ - readonly isOptional?: boolean; - readonly name: string; - readonly type: SchemaAstNode; - }>; - readonly rest?: ReadonlyArray<{ - readonly type: SchemaAstNode; - }>; - readonly type?: SchemaAstNode; - readonly types?: ReadonlyArray; - } - | undefined; +type SchemaAstNode = SchemaAST.AST | undefined; const unwrapSchemaAst = (ast: SchemaAstNode): SchemaAstNode => { let current = ast; - while ( - current && - (current._tag === "Refinement" || - current._tag === "Transformation" || - current._tag === "Suspend") - ) { - current = - current._tag === "Suspend" - ? current.type - : current._tag === "Transformation" - ? current.to - : current.from; + while (current && current._tag === "Suspend") { + current = current.thunk(); } return current; @@ -401,20 +383,27 @@ const schemaAstToJsonShape = (ast: SchemaAstNode): CommandJsonShape => { const current = unwrapSchemaAst(ast); switch (current?._tag) { - case "StringKeyword": + case "String": return stringShape(); - case "NumberKeyword": + case "Number": return integerShape(); - case "BooleanKeyword": + case "Boolean": return booleanShape(); - case "UndefinedKeyword": - case "VoidKeyword": - case "NullKeyword": + case "Undefined": + case "Void": + case "Null": return nullShape(); - case "Literal": - return enumShape([current.literal ?? null]); - case "TupleType": { - const item = current.rest?.[0]?.type; + case "Literal": { + const literal = current.literal ?? null; + + if (typeof literal === "bigint") { + throw new Error("BigInt literals are not supported in CLI json metadata."); + } + + return enumShape([literal]); + } + case "Arrays": { + const item = current.rest[0] ?? current.elements[0]; if (!item) { throw new Error("Unable to derive an array item schema from an empty tuple AST."); @@ -422,29 +411,31 @@ const schemaAstToJsonShape = (ast: SchemaAstNode): CommandJsonShape => { return arrayShape(schemaAstToJsonShape(item)); } - case "TypeLiteral": + case "Objects": return objectShape( - (current.propertySignatures ?? []).map((propertySignature) => + current.propertySignatures.map((propertySignature) => property( - propertySignature.name, + String(propertySignature.name), schemaAstToJsonShape(propertySignature.type), - propertySignature.isOptional !== true, + !SchemaAST.isOptional(propertySignature.type), ), ), ); case "Union": { - const definedTypes = (current.types ?? []).filter( - (type) => unwrapSchemaAst(type)?._tag !== "UndefinedKeyword", + const definedTypes = current.types.filter( + (type) => unwrapSchemaAst(type)?._tag !== "Undefined", ); const enumValues = definedTypes.flatMap((type) => { const unwrapped = unwrapSchemaAst(type); if (unwrapped?._tag === "Literal") { - return [unwrapped.literal ?? null]; + const literal = unwrapped.literal ?? null; + + return typeof literal === "bigint" ? [] : [literal]; } - if (unwrapped?._tag === "NullKeyword" || unwrapped?._tag === "VoidKeyword") { + if (unwrapped?._tag === "Null" || unwrapped?._tag === "Void") { return [null]; } @@ -468,8 +459,8 @@ const schemaAstToJsonShape = (ast: SchemaAstNode): CommandJsonShape => { } }; -export const jsonShapeFromSchema = ( - schema: Schema.Schema, +export const jsonShapeFromSchema = ( + schema: Schema.Codec, rules?: ReadonlyArray, ): CommandJsonShape => { const shape = schemaAstToJsonShape(schema.ast); diff --git a/src/internal/command.ts b/src/internal/command.ts index 44c9182..f97fee5 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -1,4 +1,4 @@ -import { Options } from "@effect/cli"; +import { Flag } from "effect/unstable/cli"; import { Data, Effect, Option, Schema } from "effect"; import type { ResolvedAuthState } from "./state.js"; @@ -9,6 +9,7 @@ import { repeatedIntegerFlag, repeatedStringFlag, stringFlag, + type CommandOption, } from "./command-specs.js"; import { isStructuredOutputMode, @@ -16,18 +17,19 @@ import { renderJson, writeOutput, type OutputMode, + type RequestedOutputMode, } from "./output-service.js"; import { CliRuntime } from "./runtime.js"; import { CliSdk, sdk } from "./sdk.js"; import { resolveAuthState } from "./state.js"; -export const outputOption = Options.choice("output", ["json", "text", "ndjson"] as const).pipe( - Options.optional, +export const outputOption = Flag.choice("output", ["json", "text", "ndjson"] as const).pipe( + Flag.optional, ); -export const dryRunOption = Options.boolean("dry-run").pipe(Options.withDefault(false)); -export const fieldsOption = Options.text("fields").pipe(Options.optional); -export const jsonOption = Options.text("json").pipe(Options.optional); -export const pageAllOption = Options.boolean("page-all").pipe(Options.withDefault(false)); +export const dryRunOption = Flag.boolean("dry-run").pipe(Flag.withDefault(false)); +export const fieldsOption = Flag.string("fields").pipe(Flag.optional); +export const jsonOption = Flag.string("json").pipe(Flag.optional); +export const pageAllOption = Flag.boolean("page-all").pipe(Flag.withDefault(false)); export const defineBooleanOption = ( name: string, @@ -38,8 +40,8 @@ export const defineBooleanOption = ( ) => { const option = options.defaultValue === undefined - ? Options.boolean(name) - : Options.boolean(name).pipe(Options.withDefault(options.defaultValue)); + ? Flag.boolean(name) + : Flag.boolean(name).pipe(Flag.withDefault(options.defaultValue)); return { flag: booleanFlag(name, options), @@ -47,28 +49,89 @@ export const defineBooleanOption = ( }; }; -export const defineIntegerOption = ( +export function defineIntegerOption( + name: string, + options: { + readonly description?: string; + readonly optional: true; + readonly required?: boolean; + }, +): { + readonly flag: CommandOption; + readonly option: Flag.Flag>; +}; +export function defineIntegerOption( + name: string, + options?: { + readonly description?: string; + readonly optional?: false; + readonly required?: boolean; + }, +): { + readonly flag: CommandOption; + readonly option: Flag.Flag; +}; +export function defineIntegerOption( name: string, options: { readonly description?: string; readonly optional?: boolean; readonly required?: boolean; } = {}, -) => { - const option = options.optional - ? Options.integer(name).pipe(Options.optional) - : Options.integer(name); +) { + const flag = integerFlag(name, { + description: options.description, + required: options.required ?? options.optional !== true, + }); - return { - flag: integerFlag(name, { - description: options.description, - required: options.required ?? options.optional !== true, - }), - option, - }; -}; + return options.optional === true + ? { + flag, + option: Flag.integer(name).pipe(Flag.optional), + } + : { + flag, + option: Flag.integer(name), + }; +} -export const defineTextOption = ( +export function defineTextOption( + name: string, + options: { + readonly defaultValue: string; + readonly description?: string; + readonly optional?: false; + readonly required?: boolean; + }, +): { + readonly flag: CommandOption; + readonly option: Flag.Flag; +}; +export function defineTextOption( + name: string, + options: { + readonly defaultValue?: undefined; + readonly description?: string; + readonly optional: true; + readonly required?: boolean; + }, +): { + readonly flag: CommandOption; + readonly option: Flag.Flag>; +}; +export function defineTextOption( + name: string, + options?: { + readonly defaultValue?: undefined; + readonly description?: string; + readonly optional?: false; + readonly required?: boolean; + }, +): { + readonly flag: CommandOption; + readonly option: Flag.Flag; +}; +export function defineTextOption( name: string, options: { readonly defaultValue?: string; @@ -76,27 +139,56 @@ export const defineTextOption = ( readonly optional?: boolean; readonly required?: boolean; } = {}, -) => { - let option = Options.text(name); +) { + const flag = stringFlag(name, { + defaultValue: options.defaultValue, + description: options.description, + required: options.required ?? (options.defaultValue === undefined && options.optional !== true), + }); if (options.defaultValue !== undefined) { - option = option.pipe(Options.withDefault(options.defaultValue)); - } else if (options.optional) { - option = option.pipe(Options.optional); + return { + flag, + option: Flag.string(name).pipe(Flag.withDefault(options.defaultValue)), + }; } - return { - flag: stringFlag(name, { - defaultValue: options.defaultValue, - description: options.description, - required: - options.required ?? (options.defaultValue === undefined && options.optional !== true), - }), - option, - }; -}; + return options.optional === true + ? { + flag, + option: Flag.string(name).pipe(Flag.optional), + } + : { + flag, + option: Flag.string(name), + }; +} -export const defineChoiceOption = >( +export function defineChoiceOption>( + name: string, + choices: A, + options: { + readonly description?: string; + readonly optional: true; + readonly required?: boolean; + }, +): { + readonly flag: CommandOption; + readonly option: Flag.Flag>; +}; +export function defineChoiceOption>( + name: string, + choices: A, + options?: { + readonly description?: string; + readonly optional?: false; + readonly required?: boolean; + }, +): { + readonly flag: CommandOption; + readonly option: Flag.Flag; +}; +export function defineChoiceOption>( name: string, choices: A, options: { @@ -104,19 +196,22 @@ export const defineChoiceOption = >( readonly optional?: boolean; readonly required?: boolean; } = {}, -) => { - const option = options.optional - ? Options.choice(name, choices).pipe(Options.optional) - : Options.choice(name, choices); +) { + const flag = enumFlag(name, choices, { + description: options.description, + required: options.required ?? options.optional !== true, + }); - return { - flag: enumFlag(name, choices, { - description: options.description, - required: options.required ?? options.optional !== true, - }), - option, - }; -}; + return options.optional === true + ? { + flag, + option: Flag.choice(name, choices).pipe(Flag.optional), + } + : { + flag, + option: Flag.choice(name, choices), + }; +} export const getOption = (option: Option.Option) => Option.getOrUndefined(option); @@ -125,7 +220,7 @@ export class CliCommandInputError extends Data.TaggedError("CliCommandInputError }> {} type ReadOutputControls = { - readonly output: string | undefined; + readonly output: RequestedOutputMode; readonly outputMode: OutputMode; readonly pageAll: boolean; readonly requestedFields: ReadonlyArray | undefined; @@ -300,9 +395,9 @@ export const parseRepeatedIntegers = ( }; export const parseRepeatedIntegerOption = (name: string) => - Options.text(name).pipe( - Options.repeated, - Options.filterMap(parseRepeatedIntegers, `Expected \`--${name}\` values to be integers.`), + Flag.string(name).pipe( + Flag.atLeast(0), + Flag.filterMap(parseRepeatedIntegers, () => `Expected \`--${name}\` values to be integers.`), ); export const defineRepeatedIntegerOption = ( @@ -324,7 +419,7 @@ export const defineRepeatedTextOption = ( } = {}, ) => ({ flag: repeatedStringFlag(name, options), - option: Options.text(name).pipe(Options.repeated), + option: Flag.string(name).pipe(Flag.atLeast(0)), }); const mapInputError = (error: unknown, fallbackMessage: string) => @@ -334,7 +429,7 @@ const mapInputError = (error: unknown, fallbackMessage: string) => message: fallbackMessage, }); -export const decodeJsonOption = (schema: Schema.Schema, raw: string) => +export const decodeJsonOption = (schema: Schema.Codec, raw: string) => Effect.try({ try: () => JSON.parse(raw) as unknown, catch: () => @@ -353,9 +448,9 @@ export const decodeJsonOption = (schema: Schema.Schema, raw: string) ), ); -export const resolveMutationInput = (input: { +export const resolveMutationInput = (input: { readonly buildFromFlags: () => A; - readonly schema: Schema.Schema; + readonly schema: Schema.Codec; readonly json: Option.Option; }) => Option.match(input.json, { @@ -369,7 +464,7 @@ export const resolveMutationInput = (input: { export const resolveReadOutputControls = (input: { readonly fields: Option.Option; - readonly output: string | undefined; + readonly output: RequestedOutputMode; readonly pageAll?: boolean; }) => Effect.flatMap(CliRuntime, (runtime) => @@ -497,7 +592,7 @@ export const collectAllCursorPages = , E, R>(i export const writeReadOutput = >(input: { readonly command: string; - readonly output: string | undefined; + readonly output: RequestedOutputMode; readonly outputMode: OutputMode; readonly renderTerminalValue: (value: A) => string; readonly requestedFields: ReadonlyArray | undefined; @@ -607,7 +702,7 @@ type DryRunPlan = { const renderDryRunPlanTerminal = (value: DryRunPlan) => [`Dry run: ${value.command}`, "No API call was made.", "", renderJson(value.request)].join("\n"); -export const writeDryRunPlan = (command: string, request: A, output: string | undefined) => +export const writeDryRunPlan = (command: string, request: A, output: RequestedOutputMode) => writeOutput( { command, diff --git a/src/internal/config.test.ts b/src/internal/config.test.ts index 4a4f6a2..d4eb1a6 100644 --- a/src/internal/config.test.ts +++ b/src/internal/config.test.ts @@ -5,8 +5,8 @@ import { makeCliAppLayer } from "./app-layer.js"; import { resolveCliAuthFlowConfig, resolveCliRuntimeConfig } from "./config.js"; import { makeCliRuntime } from "./runtime.js"; -const withRuntime = ( - effect: Effect.Effect, +const withRuntime = ( + effect: Effect.Effect, env: ReadonlyArray, runtime = makeCliRuntime({ homeDirectory: "/Users/tester", @@ -14,7 +14,10 @@ const withRuntime = ( }), ) => effect.pipe( - Effect.withConfigProvider(ConfigProvider.fromMap(new Map(env))), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromUnknown(Object.fromEntries(env)), + ), Effect.provide(makeCliAppLayer(runtime)), ); diff --git a/src/internal/config.ts b/src/internal/config.ts index ec85d71..83c1e59 100644 --- a/src/internal/config.ts +++ b/src/internal/config.ts @@ -12,25 +12,18 @@ import { } from "./env.js"; import { CliRuntime, type CliRuntimeService } from "./runtime.js"; -const NonEmptyStringSchema = Schema.String.pipe( - Schema.filter((value): value is string => value.length > 0, { - message: () => "Expected a non-empty string", - }), -); +const NonEmptyStringSchema = Schema.String.check(Schema.isNonEmpty()); const UrlStringSchema = NonEmptyStringSchema.pipe( - Schema.filter( - (value): value is string => { + Schema.check( + Schema.makeFilter((value) => { try { new URL(value); - return true; + return undefined; } catch { - return false; + return "Expected a valid absolute URL"; } - }, - { - message: () => "Expected a valid absolute URL", - }, + }), ), ); @@ -55,14 +48,13 @@ export class CliConfigError extends Data.TaggedError("CliConfigError")<{ }> {} export type CliConfigService = { - readonly authFlowConfig: Effect.Effect; - readonly runtimeConfig: Effect.Effect; + readonly authFlowConfig: Effect.Effect; + readonly runtimeConfig: Effect.Effect; }; -export class CliConfig extends Context.Tag("@putdotio/cli/CliConfig")< - CliConfig, - CliConfigService ->() {} +export class CliConfig extends Context.Service()( + "@putdotio/cli/CliConfig", +) {} const optionalTrimmedString = (name: string) => Config.option(Config.string(name)).pipe( diff --git a/src/internal/loader-service.ts b/src/internal/loader-service.ts index 3fabacc..179f2a5 100644 --- a/src/internal/loader-service.ts +++ b/src/internal/loader-service.ts @@ -1,17 +1,17 @@ import { Effect } from "effect"; import { CliRuntime } from "./runtime.js"; -import { normalizeOutputMode } from "./output-service.js"; +import { normalizeOutputMode, type RequestedOutputMode } from "./output-service.js"; export const shouldUseTerminalLoader = ( - output: string | undefined, + output: RequestedOutputMode, isInteractiveTerminal: boolean, ) => normalizeOutputMode(output, isInteractiveTerminal) === "terminal" && isInteractiveTerminal; export const withTerminalLoader = ( options: { readonly message: string; - readonly output: string | undefined; + readonly output: RequestedOutputMode; }, effect: Effect.Effect, ): Effect.Effect => diff --git a/src/internal/metadata.ts b/src/internal/metadata.ts index 5dfb33d..2a56a3b 100644 --- a/src/internal/metadata.ts +++ b/src/internal/metadata.ts @@ -18,11 +18,7 @@ import { } from "./env.js"; import { PUTIO_CLI_APP_ID } from "./constants.js"; -const NonEmptyStringSchema = Schema.String.pipe( - Schema.filter((value): value is string => value.length > 0, { - message: () => "Expected a non-empty string", - }), -); +const NonEmptyStringSchema = Schema.String.check(Schema.isNonEmpty()); const CliMetadataSchema = Schema.Struct({ agentDx: AgentDxScorecardSchema, @@ -46,9 +42,11 @@ const CliMetadataSchema = Schema.Struct({ version: NonEmptyStringSchema, }); +export type CliMetadata = Schema.Schema.Type; + const decodeCliMetadata = Schema.decodeUnknownSync(CliMetadataSchema); -export const describeCli = () => +export const describeCli = (): CliMetadata => decodeCliMetadata({ agentDx: scoreAgentDx({ commands: commandCatalog, diff --git a/src/internal/output-service.ts b/src/internal/output-service.ts index 88e03ff..3162d83 100644 --- a/src/internal/output-service.ts +++ b/src/internal/output-service.ts @@ -6,7 +6,7 @@ import { CliRuntime } from "./runtime.js"; import { renderCliErrorTerminal, type CliTerminalErrorView } from "./terminal/error-terminal.js"; export type OutputMode = "json" | "ndjson" | "terminal"; -type RequestedOutputMode = "json" | "ndjson" | "text" | undefined; +export type RequestedOutputMode = "json" | "ndjson" | "text" | undefined; export const isStructuredOutputMode = (outputMode: OutputMode) => outputMode === "json" || outputMode === "ndjson"; @@ -30,6 +30,12 @@ export const normalizeOutputMode = ( return isInteractiveTerminal ? "terminal" : "json"; }; +const normalizeRequestedOrResolvedOutputMode = ( + output: RequestedOutputMode | OutputMode, + isInteractiveTerminal = true, +): OutputMode => + output === "terminal" ? "terminal" : normalizeOutputMode(output, isInteractiveTerminal); + export const detectOutputModeFromArgv = ( argv: ReadonlyArray, isInteractiveTerminal = true, @@ -297,54 +303,51 @@ export const formatCliErrorJson = (error: unknown) => { }; export type CliOutputService = { - readonly formatError: (error: unknown, output: string | undefined) => string; + readonly formatError: (error: unknown, output: RequestedOutputMode | OutputMode) => string; readonly error: (message: string) => Effect.Effect; readonly write: ( value: A, - output: string | undefined, + output: RequestedOutputMode, renderTerminalValue: (value: A) => string, ) => Effect.Effect; }; -export class CliOutput extends Context.Tag("@putdotio/cli/CliOutput")< - CliOutput, - CliOutputService ->() {} +export class CliOutput extends Context.Service()( + "@putdotio/cli/CliOutput", +) {} export const makeCliOutput = (runtime: { readonly isInteractiveTerminal: boolean; + readonly writeStdout: (message: string) => Effect.Effect; }): CliOutputService => ({ formatError: (error, output) => isStructuredOutputMode( - normalizeOutputMode(output as RequestedOutputMode, runtime.isInteractiveTerminal), + normalizeRequestedOrResolvedOutputMode(output, runtime.isInteractiveTerminal), ) ? formatCliErrorJson(error) : formatCliError(error), error: (message) => Console.error(sanitizeTerminalText(message)), - write: (value, output, renderTerminalValue) => - Console.log( - (() => { - const outputMode = normalizeOutputMode( - output as RequestedOutputMode, - runtime.isInteractiveTerminal, - ); - - switch (outputMode) { - case "terminal": - return renderTerminal(value, renderTerminalValue); - case "ndjson": - return renderNdjson(value); - case "json": - return renderJson(value); - } - })(), - ), + write: (value, output, renderTerminalValue) => { + const outputMode = normalizeOutputMode( + output as RequestedOutputMode, + runtime.isInteractiveTerminal, + ); + + const rendered = + outputMode === "terminal" + ? renderTerminal(value, renderTerminalValue) + : outputMode === "ndjson" + ? renderNdjson(value) + : renderJson(value); + + return runtime.writeStdout(`${rendered}\n`); + }, }); export const CliOutputLive = Layer.effect(CliOutput, Effect.map(CliRuntime, makeCliOutput)); export const writeOutput = ( value: A, - output: string | undefined, + output: RequestedOutputMode, renderTerminalValue: (value: A) => string, ) => Effect.flatMap(CliOutput, (cliOutput) => cliOutput.write(value, output, renderTerminalValue)); diff --git a/src/internal/runtime.ts b/src/internal/runtime.ts index a91275d..2c7fa86 100644 --- a/src/internal/runtime.ts +++ b/src/internal/runtime.ts @@ -12,6 +12,8 @@ export type CliRuntimeService = { readonly argv: ReadonlyArray; readonly isInteractiveTerminal: boolean; readonly setExitCode: (code: number) => Effect.Effect; + readonly writeStdout: (message: string) => Effect.Effect; + readonly writeStderr: (message: string) => Effect.Effect; readonly openExternal: (url: string) => Effect.Effect; readonly startSpinner: (message: string) => Effect.Effect; readonly getHomeDirectory: Effect.Effect; @@ -20,10 +22,9 @@ export type CliRuntimeService = { readonly dirname: (path: string) => string; }; -export class CliRuntime extends Context.Tag("@putdotio/cli/CliRuntime")< - CliRuntime, - CliRuntimeService ->() {} +export class CliRuntime extends Context.Service()( + "@putdotio/cli/CliRuntime", +) {} const openExternalWithPlatform = (platform: NodeJS.Platform, url: string) => { const command = @@ -54,6 +55,8 @@ export const makeCliRuntime = ( readonly platform?: NodeJS.Platform; readonly homeDirectory?: string; readonly hostName?: string; + readonly writeStdout?: (message: string) => void; + readonly writeStderr?: (message: string) => void; } = {}, ): CliRuntimeService => { const argv = options.argv ?? process.argv; @@ -71,6 +74,24 @@ export const makeCliRuntime = ( Effect.sync(() => { process.exitCode = code; }), + writeStdout: (message) => + Effect.sync(() => { + if (options.writeStdout) { + options.writeStdout(message); + return; + } + + process.stdout.write(message); + }), + writeStderr: (message) => + Effect.sync(() => { + if (options.writeStderr) { + options.writeStderr(message); + return; + } + + process.stderr.write(message); + }), openExternal: (url) => Effect.sync(() => openExternalWithPlatform(platform, url)), startSpinner: (message) => Effect.sync(() => { diff --git a/src/internal/sdk.ts b/src/internal/sdk.ts index fec7ab7..bf639ce 100644 --- a/src/internal/sdk.ts +++ b/src/internal/sdk.ts @@ -1,5 +1,8 @@ -import { FetchHttpClient } from "@effect/platform"; -import { createPutioSdkEffectClient, makePutioSdkLayer } from "@putdotio/sdk"; +import { + createPutioSdkEffectClient, + makePutioSdkLiveLayer, + type PutioSdkContext, +} from "@putdotio/sdk"; import { Context, Effect, Layer } from "effect"; export type CliSdkClient = ReturnType; @@ -14,17 +17,17 @@ export type CliSdkService = { readonly apiBaseUrl?: string; }, program: Effect.Effect, - ) => Effect.Effect; + ) => Effect.Effect>; }; -export class CliSdk extends Context.Tag("@putdotio/cli/CliSdk")() {} +export class CliSdk extends Context.Service()("@putdotio/cli/CliSdk") {} const makeCliSdk = (): CliSdkService => ({ client: sdk, provide: (config, program) => program.pipe( Effect.provide( - makePutioSdkLayer({ + makePutioSdkLiveLayer({ accessToken: config.token, baseUrl: config.apiBaseUrl, }), @@ -32,10 +35,7 @@ const makeCliSdk = (): CliSdkService => ({ ), }); -export const CliSdkLive = Layer.mergeAll( - FetchHttpClient.layer, - Layer.succeed(CliSdk, makeCliSdk()), -); +export const CliSdkLive = Layer.succeed(CliSdk, makeCliSdk()); export const provideSdk = ( config: { diff --git a/src/internal/state.test.ts b/src/internal/state.test.ts index da0b035..2822aa2 100644 --- a/src/internal/state.test.ts +++ b/src/internal/state.test.ts @@ -26,7 +26,7 @@ const expectFailure = (exit: Exit.Exit): E => { throw new Error("Expected the effect to fail."); } - const failure = Cause.failureOption(exit.cause); + const failure = Cause.findErrorOption(exit.cause); if (Option.isNone(failure)) { throw Cause.squash(exit.cause); @@ -35,8 +35,8 @@ const expectFailure = (exit: Exit.Exit): E => { return failure.value; }; -const withStateService = ( - effect: Effect.Effect, +const withStateService = ( + effect: Effect.Effect, homeDirectory = "/Users/tester", ) => effect.pipe(Effect.provide(makeCliAppLayer(makeCliRuntime({ homeDirectory })))); @@ -128,13 +128,12 @@ describe("resolveConfigPath", () => { it("does not expose token previews in auth status", async () => { const status = await Effect.runPromise( getAuthStatus().pipe( - Effect.withConfigProvider( - ConfigProvider.fromMap( - new Map([ - ["PUTIO_CLI_TOKEN", "dummy-token"], - ["XDG_CONFIG_HOME", "/tmp/xdg"], - ]), - ), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromUnknown({ + PUTIO_CLI_TOKEN: "dummy-token", + XDG_CONFIG_HOME: "/tmp/xdg", + }), ), Effect.provide(makeCliAppLayer(makeCliRuntime({ homeDirectory: "/Users/tester" }))), ), @@ -281,13 +280,12 @@ describe("resolveConfigPath", () => { const authState = await Effect.runPromise( resolveAuthState().pipe( - Effect.withConfigProvider( - ConfigProvider.fromMap( - new Map([ - ["PUTIO_CLI_CONFIG_PATH", configPath], - ["HOME", "/Users/tester"], - ]), - ), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromUnknown({ + HOME: "/Users/tester", + PUTIO_CLI_CONFIG_PATH: configPath, + }), ), makeRuntimeLayer(), ), @@ -316,13 +314,12 @@ describe("resolveConfigPath", () => { const status = await Effect.runPromise( getAuthStatus().pipe( - Effect.withConfigProvider( - ConfigProvider.fromMap( - new Map([ - ["PUTIO_CLI_CONFIG_PATH", configPath], - ["HOME", "/Users/tester"], - ]), - ), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromUnknown({ + HOME: "/Users/tester", + PUTIO_CLI_CONFIG_PATH: configPath, + }), ), makeRuntimeLayer(), ), @@ -350,13 +347,12 @@ describe("resolveConfigPath", () => { const status = await Effect.runPromise( getAuthStatus().pipe( - Effect.withConfigProvider( - ConfigProvider.fromMap( - new Map([ - ["PUTIO_CLI_CONFIG_PATH", configPath], - ["HOME", "/Users/tester"], - ]), - ), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromUnknown({ + HOME: "/Users/tester", + PUTIO_CLI_CONFIG_PATH: configPath, + }), ), makeRuntimeLayer(), ), @@ -373,13 +369,12 @@ describe("resolveConfigPath", () => { it("fails to resolve auth state when neither env nor config contains a token", async () => { const exit = await Effect.runPromiseExit( resolveAuthState().pipe( - Effect.withConfigProvider( - ConfigProvider.fromMap( - new Map([ - ["XDG_CONFIG_HOME", "/tmp/xdg"], - ["HOME", "/Users/tester"], - ]), - ), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromUnknown({ + HOME: "/Users/tester", + XDG_CONFIG_HOME: "/tmp/xdg", + }), ), makeRuntimeLayer(), ), diff --git a/src/internal/state.ts b/src/internal/state.ts index 88a3c98..01d5b3f 100644 --- a/src/internal/state.ts +++ b/src/internal/state.ts @@ -1,18 +1,14 @@ -import * as FileSystem from "@effect/platform/FileSystem"; -import { SystemError } from "@effect/platform/Error"; +import * as FileSystem from "effect/FileSystem"; +import { PlatformError, SystemError } from "effect/PlatformError"; import { DEFAULT_PUTIO_API_BASE_URL } from "@putdotio/sdk"; import { Context, Data, Effect, Layer, Schema } from "effect"; import { CONFIG_FILE_MODE } from "./constants.js"; -import { resolveCliRuntimeConfig } from "./config.js"; +import { CliConfig, resolveCliRuntimeConfig } from "./config.js"; import { CliRuntime } from "./runtime.js"; -const NonEmptyStringSchema = Schema.String.pipe( - Schema.filter((value): value is string => value.length > 0, { - message: () => "Expected a non-empty string", - }), -); +const NonEmptyStringSchema = Schema.String.check(Schema.isNonEmpty()); export const PutioCliConfigSchema = Schema.Struct({ api_base_url: NonEmptyStringSchema, @@ -24,7 +20,7 @@ export type PutioCliConfig = Schema.Schema.Type; export const ResolvedAuthStateSchema = Schema.Struct({ apiBaseUrl: NonEmptyStringSchema, configPath: NonEmptyStringSchema, - source: Schema.Literal("env", "config"), + source: Schema.Literals(["env", "config"] as const), token: NonEmptyStringSchema, }); @@ -34,7 +30,7 @@ export const AuthStatusSchema = Schema.Struct({ apiBaseUrl: NonEmptyStringSchema, authenticated: Schema.Boolean, configPath: NonEmptyStringSchema, - source: Schema.NullOr(Schema.Literal("env", "config")), + source: Schema.NullOr(Schema.Literals(["env", "config"] as const)), }); export type AuthStatus = Schema.Schema.Type; @@ -46,7 +42,11 @@ export class AuthStateError extends Data.TaggedError("AuthStateError")<{ export type CliStateService = { readonly loadPersistedState: ( configPath?: string, - ) => Effect.Effect; + ) => Effect.Effect< + PutioCliConfig | null, + AuthStateError, + CliConfig | FileSystem.FileSystem | CliRuntime + >; readonly savePersistedState: ( state: { readonly apiBaseUrl?: string; @@ -59,28 +59,30 @@ export type CliStateService = { readonly state: PutioCliConfig; }, AuthStateError, - FileSystem.FileSystem | CliRuntime + CliConfig | FileSystem.FileSystem | CliRuntime >; readonly clearPersistedState: ( configPath?: string, ) => Effect.Effect< { readonly configPath: string }, AuthStateError, - FileSystem.FileSystem | CliRuntime + CliConfig | FileSystem.FileSystem | CliRuntime >; readonly getAuthStatus: () => Effect.Effect< AuthStatus, AuthStateError, - FileSystem.FileSystem | CliRuntime + CliConfig | FileSystem.FileSystem | CliRuntime >; readonly resolveAuthState: () => Effect.Effect< ResolvedAuthState, AuthStateError, - FileSystem.FileSystem | CliRuntime + CliConfig | FileSystem.FileSystem | CliRuntime >; }; -export class CliState extends Context.Tag("@putdotio/cli/CliState")() {} +export class CliState extends Context.Service()( + "@putdotio/cli/CliState", +) {} const decodePersistedConfig = Schema.decodeUnknownSync(PutioCliConfigSchema); @@ -91,6 +93,16 @@ const mapFileSystemError = (error: unknown, message: string): AuthStateError => message, }); +const resolveAuthRuntimeConfig = () => + resolveCliRuntimeConfig().pipe( + Effect.mapError( + (error) => + new AuthStateError({ + message: error.message, + }), + ), + ); + const parsePersistedConfig = (raw: string): PutioCliConfig => { let value: unknown; @@ -113,15 +125,21 @@ const parsePersistedConfig = (raw: string): PutioCliConfig => { const loadPersistedStateEffect = ( configPath?: string, -): Effect.Effect => +): Effect.Effect< + PutioCliConfig | null, + AuthStateError, + CliConfig | FileSystem.FileSystem | CliRuntime +> => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const effectiveConfigPath = configPath ?? (yield* resolveCliRuntimeConfig()).configPath; + const effectiveConfigPath = configPath ?? (yield* resolveAuthRuntimeConfig()).configPath; const rawConfig = yield* fs.readFileString(effectiveConfigPath, "utf8").pipe( Effect.catchIf( - (error): error is SystemError => - error instanceof SystemError && error.reason === "NotFound", + (error): error is PlatformError => + error instanceof PlatformError && + error.reason instanceof SystemError && + error.reason._tag === "NotFound", () => Effect.succeed(null), ), Effect.mapError((error) => @@ -152,12 +170,12 @@ const savePersistedStateEffect = ( readonly state: PutioCliConfig; }, AuthStateError, - FileSystem.FileSystem | CliRuntime + CliConfig | FileSystem.FileSystem | CliRuntime > => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const runtime = yield* CliRuntime; - const effectiveConfigPath = configPath ?? (yield* resolveCliRuntimeConfig()).configPath; + const effectiveConfigPath = configPath ?? (yield* resolveAuthRuntimeConfig()).configPath; const existingConfig = yield* loadPersistedStateEffect(effectiveConfigPath); const persistedState: PutioCliConfig = { api_base_url: state.apiBaseUrl ?? existingConfig?.api_base_url ?? DEFAULT_PUTIO_API_BASE_URL, @@ -197,12 +215,12 @@ const clearPersistedStateEffect = ( ): Effect.Effect< { readonly configPath: string }, AuthStateError, - FileSystem.FileSystem | CliRuntime + CliConfig | FileSystem.FileSystem | CliRuntime > => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const runtime = yield* CliRuntime; - const effectiveConfigPath = configPath ?? (yield* resolveCliRuntimeConfig()).configPath; + const effectiveConfigPath = configPath ?? (yield* resolveAuthRuntimeConfig()).configPath; const existingConfig = yield* loadPersistedStateEffect(effectiveConfigPath); if (existingConfig && existingConfig.api_base_url !== DEFAULT_PUTIO_API_BASE_URL) { @@ -247,10 +265,10 @@ const clearPersistedStateEffect = ( const getAuthStatusEffect = (): Effect.Effect< AuthStatus, AuthStateError, - FileSystem.FileSystem | CliRuntime + CliConfig | FileSystem.FileSystem | CliRuntime > => Effect.gen(function* () { - const runtime = yield* resolveCliRuntimeConfig(); + const runtime = yield* resolveAuthRuntimeConfig(); if (runtime.token) { return { @@ -281,10 +299,10 @@ const getAuthStatusEffect = (): Effect.Effect< const resolveAuthStateEffect = (): Effect.Effect< ResolvedAuthState, AuthStateError, - FileSystem.FileSystem | CliRuntime + CliConfig | FileSystem.FileSystem | CliRuntime > => Effect.gen(function* () { - const runtime = yield* resolveCliRuntimeConfig(); + const runtime = yield* resolveAuthRuntimeConfig(); if (runtime.token) { return { diff --git a/src/sea.ts b/src/sea.ts index 109531d..ee797b2 100644 --- a/src/sea.ts +++ b/src/sea.ts @@ -9,7 +9,7 @@ import { CliRuntime } from "./internal/runtime.js"; NodeRuntime.runMain( Effect.scoped( Effect.flatMap(CliRuntime, (runtime) => runCli(runtime.argv)).pipe( - Effect.catchAllCause((cause) => + Effect.catchCause((cause) => Effect.gen(function* () { const cliOutput = yield* CliOutput; const runtime = yield* CliRuntime; diff --git a/src/test-support/command-path-mocks.ts b/src/test-support/command-path-mocks.ts index 730521a..e71329f 100644 --- a/src/test-support/command-path-mocks.ts +++ b/src/test-support/command-path-mocks.ts @@ -48,6 +48,70 @@ const defaultDownloadLinksJob = () => ({ }); const createCommandPathMocks = () => { + type FileListItem = { + readonly file_type?: string; + readonly id: number; + readonly name?: string; + readonly size?: number; + }; + type FileListPage = { + readonly cursor: string | null; + readonly files: ReadonlyArray; + readonly total?: number; + }; + type TransferListItem = { + readonly id: number; + readonly name: string; + readonly percent_done?: number; + readonly status?: string; + }; + type TransferListPage = { + readonly cursor: string | null; + readonly transfers: ReadonlyArray; + }; + const emptyFileListPage: FileListPage = { + cursor: null, + files: [], + total: 1, + }; + const emptyTransferListPage: TransferListPage = { + cursor: null, + transfers: [], + }; + const defaultFileListPage: FileListPage = { + cursor: null, + files: [ + { + file_type: "FOLDER", + id: 1, + name: "Movies", + size: 0, + }, + ], + total: 1, + }; + const defaultSearchFilesPage: FileListPage = { + cursor: null, + files: [ + { + file_type: "VIDEO", + id: 2, + name: "movie.mkv", + size: 42, + }, + ], + }; + const defaultTransferListPage: TransferListPage = { + cursor: null, + transfers: [ + { + id: 7, + name: "ubuntu.iso", + percent_done: 50, + status: "DOWNLOADING", + }, + ], + }; const writeOutputMock = vi.fn(() => Effect.void); const withTerminalLoaderMock = vi.fn((_options, program) => program); const withAuthedSdkMock = vi.fn((program) => @@ -64,25 +128,8 @@ const createCommandPathMocks = () => { const provideSdkMock = vi.fn((_config, program) => program); const getCodeMock = vi.fn(() => Effect.succeed({ code: "PUTIO1" })); const checkCodeMatchMock = vi.fn(() => Effect.succeed("token-123")); - const continueTransfersMock = vi.fn(() => - Effect.succeed({ - cursor: null, - transfers: [], - }), - ); - const listTransfersMock = vi.fn(() => - Effect.succeed({ - cursor: null, - transfers: [ - { - id: 7, - name: "ubuntu.iso", - percent_done: 50, - status: "DOWNLOADING", - }, - ], - }), - ); + const continueTransfersMock = vi.fn((_cursor?: string) => Effect.succeed(emptyTransferListPage)); + const listTransfersMock = vi.fn(() => Effect.succeed(defaultTransferListPage)); const addTransfersMock = vi.fn(() => Effect.succeed({ errors: [], @@ -124,46 +171,10 @@ const createCommandPathMocks = () => { const moveFilesMock = vi.fn(() => Effect.succeed([])); const renameFileMock = vi.fn(() => Effect.void); const deleteFilesMock = vi.fn(() => Effect.succeed({ skipped: 1 })); - const continueFilesMock = vi.fn(() => - Effect.succeed({ - cursor: null, - files: [], - total: 1, - }), - ); - const continueSearchFilesMock = vi.fn(() => - Effect.succeed({ - cursor: null, - files: [], - }), - ); - const listFilesMock = vi.fn(() => - Effect.succeed({ - cursor: null, - files: [ - { - file_type: "FOLDER", - id: 1, - name: "Movies", - size: 0, - }, - ], - total: 1, - }), - ); - const searchFilesMock = vi.fn(() => - Effect.succeed({ - cursor: null, - files: [ - { - file_type: "VIDEO", - id: 2, - name: "movie.mkv", - size: 42, - }, - ], - }), - ); + const continueFilesMock = vi.fn((_cursor?: string) => Effect.succeed(emptyFileListPage)); + const continueSearchFilesMock = vi.fn((_cursor?: string) => Effect.succeed(emptyFileListPage)); + const listFilesMock = vi.fn(() => Effect.succeed(defaultFileListPage)); + const searchFilesMock = vi.fn(() => Effect.succeed(defaultSearchFilesPage)); const getAccountInfoMock = vi.fn(() => Effect.succeed(defaultAccountInfo())); const listEventsMock = vi.fn(() => Effect.succeed(defaultEventsResponse())); const createDownloadLinksMock = vi.fn(() => Effect.succeed({ id: 55 })); diff --git a/src/test-support/run-cli.ts b/src/test-support/run-cli.ts index 05603a6..ad786cd 100644 --- a/src/test-support/run-cli.ts +++ b/src/test-support/run-cli.ts @@ -12,6 +12,8 @@ export const runCliInTest = async ( argv: ReadonlyArray, options: { readonly isInteractiveTerminal?: boolean; + readonly writeStdout?: (message: string) => void; + readonly writeStderr?: (message: string) => void; } = {}, ) => { const processArgv = ["node", "putio", ...argv.slice(1)]; @@ -21,13 +23,12 @@ export const runCliInTest = async ( return Effect.runPromise( Effect.scoped( executeCli(processArgv).pipe( - Effect.withConfigProvider( - ConfigProvider.fromMap( - new Map([ - ["PUTIO_CLI_CONFIG_PATH", configPath], - ["XDG_CONFIG_HOME", configDir], - ]), - ), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromUnknown({ + PUTIO_CLI_CONFIG_PATH: configPath, + XDG_CONFIG_HOME: configDir, + }), ), Effect.provide( makeCliAppLayer( @@ -35,6 +36,8 @@ export const runCliInTest = async ( argv: processArgv, homeDirectory: configDir, isInteractiveTerminal: options.isInteractiveTerminal, + writeStdout: options.writeStdout ?? (() => undefined), + writeStderr: options.writeStderr ?? (() => undefined), }), ), ), diff --git a/vite.config.ts b/vite.config.ts index 050b422..6ea36ad 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,13 @@ import { defineConfig } from "vite-plus"; -const coverageConfig = { +type CoverageConfig = { + readonly exclude: Array; + readonly include: Array; + readonly provider: "v8"; + readonly reporter: Array<"text" | "lcov">; +}; + +const coverageConfig: CoverageConfig = { exclude: [ "src/**/*.d.ts", "src/**/*.test.*", @@ -12,7 +19,7 @@ const coverageConfig = { include: ["src/**/*.{ts,tsx}"], provider: "v8", reporter: ["text", "lcov"], -} as const; +}; export default defineConfig({ pack: { @@ -28,6 +35,7 @@ export default defineConfig({ "*.{js,ts,tsx,mjs,cjs,mts,cts}": "vp check --fix", }, test: { + exclude: [".repos/**"], coverage: { ...coverageConfig, exclude: [ From a5313861024641153ef33eeb9438a04735151d8d Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 17 May 2026 00:20:30 +0300 Subject: [PATCH 2/2] fix(cli): address structured output review feedback --- src/cli.test.ts | 13 +++++++++++-- src/cli.ts | 35 ++++++++++++++++++++++++++++------ src/commands/auth.ts | 13 ++++++++++--- src/internal/output-service.ts | 5 +---- 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index 35edfdb..a24c7f9 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -29,8 +29,7 @@ const parseCapturedText = (args: ReadonlyArray) => const parseJsonOutput = (value: string) => JSON.parse(value) as Record; -const runCli = async (argv: ReadonlyArray): Promise => { - const processArgv = ["node", "putio", ...argv.slice(1)]; +const runProcessArgv = async (processArgv: ReadonlyArray): Promise => { const configDir = await mkdtemp(join(tmpdir(), "putio-cli-parser-")); const configPath = join(configDir, "config.json"); const stdoutChunks: string[] = []; @@ -81,6 +80,9 @@ const runCli = async (argv: ReadonlyArray): Promise => { } }; +const runCli = (argv: ReadonlyArray): Promise => + runProcessArgv(["node", "putio", ...argv.slice(1)]); + afterEach(() => { vi.restoreAllMocks(); }); @@ -100,6 +102,13 @@ describe("cli argv parsing", () => { expect(stdout).toBe(`putio v${packageJson.version}`); }); + it("accepts argv that already starts at the CLI binary", async () => { + const { result, stdout } = await runProcessArgv(["putio", "--version"]); + + expect(result._tag).toBe("Success"); + expect(stdout).toBe(`putio v${packageJson.version}`); + }); + it("renders describe as machine-readable json", async () => { const { result, stdout } = await runCli(["putio", "describe"]); diff --git a/src/cli.ts b/src/cli.ts index e57f335..08d0cf2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -92,6 +92,31 @@ const replayBufferedConsole = ( const formatCliParserError = (error: CliError.ShowHelp) => error.errors.map((nestedError) => nestedError.message).join("\n"); +const executableName = (value: string) => { + const normalized = value.replaceAll("\\", "/"); + return normalized.slice(normalized.lastIndexOf("/") + 1).toLowerCase(); +}; + +const commandArgsFromArgv = (args: ReadonlyArray) => { + const [first] = args; + + if (first === undefined) { + return args; + } + + const firstName = executableName(first); + + if (firstName === "node" || firstName === "node.exe") { + return args.slice(2); + } + + if (firstName === "putio" || firstName === "putio.exe" || firstName === "bin.mjs") { + return args.slice(1); + } + + return args; +}; + type CliCommandEnvironment = | Command.Environment | CliConfig @@ -109,15 +134,16 @@ export function runCli( return Effect.flatMap(CliRuntime, (runtime) => { const outputMode = detectOutputModeFromArgv(args, runtime.isInteractiveTerminal); + const commandArgs = commandArgsFromArgv(args); if (!isStructuredOutputMode(outputMode)) { - return run(args.slice(2)); + return run(commandArgs); } return Console.consoleWith((currentConsole) => { const entries: Array = []; - return run(args.slice(2)).pipe( + return run(commandArgs).pipe( Effect.provideService(Console.Console, makeBufferedConsole(entries)), Effect.tap(() => replayBufferedConsole(currentConsole, entries)), Effect.catchFilter( @@ -132,10 +158,7 @@ export function runCli( return Effect.fail(new Error(formatCliParserError(error))); }, - (error) => - Effect.flatMap(replayBufferedConsole(currentConsole, entries), () => - Effect.fail(error), - ), + (error) => Effect.fail(error), ), ); }); diff --git a/src/commands/auth.ts b/src/commands/auth.ts index bc88509..b9b5d7d 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,6 +1,6 @@ import { Command } from "effect/unstable/cli"; import * as Terminal from "effect/Terminal"; -import { Console, Effect, Fiber, Option, Queue } from "effect"; +import { Cause, Console, Effect, Fiber, Option, Queue } from "effect"; import { translate } from "../i18n/index.js"; import { @@ -53,7 +53,14 @@ type AuthCommandEnvironment = | CliSdk | CliState; -type AuthCommand = Command.Command<"auth", {}, {}, unknown, AuthCommandEnvironment>; +type EmptyCommandShape = Record; +type AuthCommand = Command.Command< + "auth", + EmptyCommandShape, + EmptyCommandShape, + unknown, + AuthCommandEnvironment +>; const waitForOpenShortcut = (url: string) => Effect.gen(function* () { @@ -74,7 +81,7 @@ const waitForOpenShortcut = (url: string) => return yield* runtimeService.openExternal(url); } } - }).pipe(Effect.catch(() => Effect.succeed(false))); + }).pipe(Effect.catchIf(Cause.isDone, () => Effect.succeed(false))); const renderAuthStatus = (status: AuthStatus) => status.authenticated diff --git a/src/internal/output-service.ts b/src/internal/output-service.ts index 3162d83..531906f 100644 --- a/src/internal/output-service.ts +++ b/src/internal/output-service.ts @@ -328,10 +328,7 @@ export const makeCliOutput = (runtime: { : formatCliError(error), error: (message) => Console.error(sanitizeTerminalText(message)), write: (value, output, renderTerminalValue) => { - const outputMode = normalizeOutputMode( - output as RequestedOutputMode, - runtime.isInteractiveTerminal, - ); + const outputMode = normalizeOutputMode(output, runtime.isInteractiveTerminal); const rendered = outputMode === "terminal"