diff --git a/.gitignore b/.gitignore index df59bca..b946f9f 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ exportBackup .tago-lock.dev-ue.lock .tago-lock.dev-ue-2.lock .tago-lock.dev-1.lock + +# generated by `npm run man` and shipped via the `files` field at publish time +man/ diff --git a/README.md b/README.md index 073283f..d073068 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ Installing the TagoIO Command Line Tools is a straightforward process. Follow th tagoio login ``` +6. **Reference (Optional)**: A man page is installed alongside the CLI. Run `man tagoio` for the full command reference. Fish users can additionally run `fish_update_completions` to enable tab-completion (fish auto-extracts flag metadata from the installed man page). + ## Command List List of commands of the CLI **Usage**: diff --git a/package-lock.json b/package-lock.json index 83667ed..15ed6ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,8 +33,8 @@ "@types/prompts": "^2.4.9", "@types/unzipper": "^0.10.11", "@vitest/coverage-v8": "^4.1.5", - "oxfmt": "^0.46.0", - "oxlint": "1.61.0", + "oxfmt": "^0.47.0", + "oxlint": "1.62.0", "typescript": "^6.0.3", "vitest": "^4.1.5" }, @@ -104,9 +104,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, @@ -116,9 +116,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, @@ -601,9 +601,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.126.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz", - "integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==", + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", "dev": true, "license": "MIT", "funding": { @@ -611,9 +611,9 @@ } }, "node_modules/@oxfmt/binding-android-arm-eabi": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.46.0.tgz", - "integrity": "sha512-b1doV4WRcJU+BESSlCvCjV+5CEr/T6h0frArAdV26Nir+gGNFNaylvDiiMPfF1pxeV0txZEs38ojzJaxBYg+ng==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.47.0.tgz", + "integrity": "sha512-KrMQRdMi/upr81qT4ijK6X6BNp6jqpMY7FwILQnwIy9QLc3qpnhUx5rsCLGzn4ewsCQ0CNAspN2ogmP1GXLyLw==", "cpu": [ "arm" ], @@ -628,9 +628,9 @@ } }, "node_modules/@oxfmt/binding-android-arm64": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.46.0.tgz", - "integrity": "sha512-v6+HhjsoV3GO0u2u9jLSAZrvWfTraDxKofUIQ7/ktS7tzS+epVsxdHmeM+XxuNcAY/nWxxU1Sg4JcGTNRXraBA==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.47.0.tgz", + "integrity": "sha512-r4ixS/PeUpAFKgrpDoZ5pSkthjZzVzKd95525Aazj+aOv9H4ulK5zYHGb7wFY5n5kZxHK8TbOJUZgoEb1ohddQ==", "cpu": [ "arm64" ], @@ -645,9 +645,9 @@ } }, "node_modules/@oxfmt/binding-darwin-arm64": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.46.0.tgz", - "integrity": "sha512-3eeooJGrqGIlI5MyryDZsAcKXSmKIgAD4yYtfRrRJzXZ0UTFZtiSveIur56YPrGMYZwT4XyVhHsMqrNwr1XeFA==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.47.0.tgz", + "integrity": "sha512-CLWxiKpMl+195cm09CuaWEhJK0CirRkoMa07aR9+9AFPat2LfIKtwx1JqxZM0MTvcMe6+adlJNdVL6jdInvq3g==", "cpu": [ "arm64" ], @@ -662,9 +662,9 @@ } }, "node_modules/@oxfmt/binding-darwin-x64": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.46.0.tgz", - "integrity": "sha512-QG8BDM0CXWbu84k2SKmCqfEddPQPFiBicwtYnLqHRWZZl57HbtOLRMac/KTq2NO4AEc4ICCBpFxJIV9zcqYfkQ==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.47.0.tgz", + "integrity": "sha512-Xq5fjTYDC50faUeLSm0rZdBqoTgleXEdD7NpJdARtQIczkCJn3xNjMUSQQkUmh4CtxkKTNL68lytcOK3e/osgg==", "cpu": [ "x64" ], @@ -679,9 +679,9 @@ } }, "node_modules/@oxfmt/binding-freebsd-x64": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.46.0.tgz", - "integrity": "sha512-9DdCqS/n2ncu/Chazvt3cpgAjAmIGQDz7hFKSrNItMApyV/Ja9mz3hD4JakIE3nS8PW9smEbPWnb389QLBY4nw==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.47.0.tgz", + "integrity": "sha512-QOU9ZIJ52p5askcEC0QJvvr8trHAWoonul8bgISo6gYUL3s50zkqafBYcNAr9LJZQbsZtPfIWHk9+5+nUp1qJQ==", "cpu": [ "x64" ], @@ -696,9 +696,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.46.0.tgz", - "integrity": "sha512-Dgs7VeE2jT0LHMhw6tPEt0xQYe54kBqHEovmWsv4FVQlegCOvlIJNx0S8n4vj8WUtpT+Z6BD2HhKJPLglLxvZg==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.47.0.tgz", + "integrity": "sha512-oJxDM1aBhPvz9gmElBv8UpxyiqhwfjcbrSxT5F0xtuUzY6dQI27/AQPIt3eu3Z5Yvn0kQl5R7MA3Z+MbnRvCBw==", "cpu": [ "arm" ], @@ -713,9 +713,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-musleabihf": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.46.0.tgz", - "integrity": "sha512-Zxn3adhTH13JKnU4xXJj8FeEfF680XjXh3gSShKl57HCMBRde2tUJTgogV/1MSHA80PJEVrDa7r66TLVq3Ia7Q==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.47.0.tgz", + "integrity": "sha512-g8Lh50VS4ibGz2q6v7r9UZY4D0dM16SdrFYOMzhqIoCwGcai8VMIRUAcqn1/jlCsOOzUXJ741+kCeJt0cofakQ==", "cpu": [ "arm" ], @@ -730,13 +730,16 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-gnu": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.46.0.tgz", - "integrity": "sha512-+TWipjrgVM8D7aIdDD0tlr3teLTTvQTn7QTE5BpT10H1Fj82gfdn9X6nn2sDgx/MepuSCfSnzFNJq2paLL0OiA==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.47.0.tgz", + "integrity": "sha512-YrNT1vQ0asaXoRbrvYENPqmBfOQ9Xr8enPNOULeYfg44VjCcrUowFy5QZr+WawE0zyP8cH9e9Gxxg0fDEFzhcg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -747,13 +750,16 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-musl": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.46.0.tgz", - "integrity": "sha512-aAUPBWJ1lGwwnxZUEDLJ94+Iy6MuwJwPxUgO4sCA5mEEyDk7b+cDQ+JpX1VR150Zoyd+D49gsrUzpUK5h587Eg==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.47.0.tgz", + "integrity": "sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -764,13 +770,16 @@ } }, "node_modules/@oxfmt/binding-linux-ppc64-gnu": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.46.0.tgz", - "integrity": "sha512-ufBCJukyFX/UDrokP/r6BGDoTInnsDs7bxyzKAgMiZlt2Qu8GPJSJ6Zm6whIiJzKk0naxA8ilwmbO1LMw6Htxw==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.47.0.tgz", + "integrity": "sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -781,13 +790,16 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-gnu": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.46.0.tgz", - "integrity": "sha512-eqtlC2YmPqjun76R1gVfGLuKWx7NuEnLEAudZ7n6ipSKbCZTqIKSs1b5Y8K/JHZsRpLkeSmAAjig5HOIg8fQzQ==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.47.0.tgz", + "integrity": "sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -798,13 +810,16 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-musl": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.46.0.tgz", - "integrity": "sha512-yccVOO2nMXkQLGgy0He3EQEwKD7NF0zEk+/OWmroznkqXyJdN6bfK0LtNnr6/14Bh3FjpYq7bP33l/VloCnxpA==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.47.0.tgz", + "integrity": "sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -815,13 +830,16 @@ } }, "node_modules/@oxfmt/binding-linux-s390x-gnu": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.46.0.tgz", - "integrity": "sha512-aAf7fG23OQCey6VRPj9IeCraoYtpgtx0ZyJ1CXkPyT1wjzBE7c3xtuxHe/AdHaJfVVb/SXpSk8Gl1LzyQupSqw==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.47.0.tgz", + "integrity": "sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -832,13 +850,16 @@ } }, "node_modules/@oxfmt/binding-linux-x64-gnu": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.46.0.tgz", - "integrity": "sha512-q0JPsTMyJNjYrBvYFDz4WbVsafNZaPCZv4RnFypRotLqpKROtBZcEaXQW4eb9YmvLU3NckVemLJnzkSZSdmOxw==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.47.0.tgz", + "integrity": "sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -849,13 +870,16 @@ } }, "node_modules/@oxfmt/binding-linux-x64-musl": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.46.0.tgz", - "integrity": "sha512-7LsLY9Cw57GPkhSR+duI3mt9baRczK/DtHYSldQ4BEU92da9igBQNl4z7Vq5U9NNPsh1FmpKvv1q9WDtiUQR1A==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.47.0.tgz", + "integrity": "sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -866,9 +890,9 @@ } }, "node_modules/@oxfmt/binding-openharmony-arm64": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.46.0.tgz", - "integrity": "sha512-lHiBOz8Duaku7JtRNLlps3j++eOaICPZSd8FCVmTDM4DFOPT71Bjn7g6iar1z7StXlKRweUKxWUs4sA+zWGDXg==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.47.0.tgz", + "integrity": "sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==", "cpu": [ "arm64" ], @@ -883,9 +907,9 @@ } }, "node_modules/@oxfmt/binding-win32-arm64-msvc": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.46.0.tgz", - "integrity": "sha512-/5ktYUliP89RhgC37DBH1x20U5zPSZMy3cMEcO0j3793rbHP9MWsknBwQB6eozRzWmYrh0IFM/p20EbPvDlYlg==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.47.0.tgz", + "integrity": "sha512-qtz/gzm8IjSPUlseZ0ofW8zyHLoZsuP5HTfcGGkWkUblB89JT8GNYH3ICqjbDsqsGqXum0/ZndXTFplSdXFIcg==", "cpu": [ "arm64" ], @@ -900,9 +924,9 @@ } }, "node_modules/@oxfmt/binding-win32-ia32-msvc": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.46.0.tgz", - "integrity": "sha512-3WTnoiuIr8XvV0DIY7SN+1uJSwKf4sPpcbHfobcRT9JutGcLaef/miyBB87jxd3aqH+mS0+G5lsgHuXLUwjjpQ==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.47.0.tgz", + "integrity": "sha512-5vIcdcIDE7nCx+MXN6sm8kbC4zajDB31E86rez4i45iHNH/2NjdKlJ720xcHTr3eeiMcttCGPHPhE1TjtBDGZw==", "cpu": [ "ia32" ], @@ -917,9 +941,9 @@ } }, "node_modules/@oxfmt/binding-win32-x64-msvc": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.46.0.tgz", - "integrity": "sha512-IXxiQpkYnOwNfP23vzwSfhdpxJzyiPTY7eTn6dn3DsriKddESzM8i6kfq9R7CD/PUJwCvQT22NgtygBeug3KoA==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.47.0.tgz", + "integrity": "sha512-Sr59Y5ms54ONBjxFeWhVlGyQcHXxcl9DxC23f6yXlRkcos7LXBLoO+KDfxexjHIOZh7cWqrWduzvUjJ+pHp8cQ==", "cpu": [ "x64" ], @@ -934,9 +958,9 @@ } }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.61.0.tgz", - "integrity": "sha512-6eZBPgiigK5txqoVgRqxbaxiom4lM8AP8CyKPPvpzKnQ3iFRFOIDc+0AapF+qsUSwjOzr5SGk4SxQDpQhkSJMQ==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.62.0.tgz", + "integrity": "sha512-pKsthNECyvJh8lPTICz6VcwVy2jOqdhhsp1rlxCkhgZR47aKvXPmaRWQDv+zlXpRae4qm1MaaTnutkaOk5aofg==", "cpu": [ "arm" ], @@ -951,9 +975,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.61.0.tgz", - "integrity": "sha512-CkwLR69MUnyv5wjzebvbbtTSUwqLxM35CXE79bHqDIK+NtKmPEUpStTcLQRZMCo4MP0qRT6TXIQVpK0ZVScnMA==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.62.0.tgz", + "integrity": "sha512-b1AUNViByvgmR2xJDubvLIr+dSuu3uraG7bsAoKo+xrpspPvu6RIn6Fhr2JUhobfep3jwUTy18Huco6GkwdvGQ==", "cpu": [ "arm64" ], @@ -968,9 +992,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.61.0.tgz", - "integrity": "sha512-8JbefTkbmvqkqWjmQrHke+MdpgT2UghhD/ktM4FOQSpGeCgbMToJEKdl9zwhr/YWTl92i4QI1KiTwVExpcUN8A==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.62.0.tgz", + "integrity": "sha512-iG+Tvf70UJ6otfwFYIHk36Sjq9cpPP5YLxkoggANNRtzgi3Tj3g8q6Ybqi6AtkU3+yg9QwF7bDCkCS6bbL4PCg==", "cpu": [ "arm64" ], @@ -985,9 +1009,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.61.0.tgz", - "integrity": "sha512-uWpoxDT47hTnDLcdEh5jVbso8rlTTu5o0zuqa9J8E0JAKmIWn7kGFEIB03Pycn2hd2vKxybPGLhjURy/9We5FQ==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.62.0.tgz", + "integrity": "sha512-oOWI6YPPr5AJUx+yIDlxmuUbQjS5gZX3OH3QisawYvsZgLiQVvZtR0rPBcJTxLWqt2ClrWg0DlSrlUiG5SQNHg==", "cpu": [ "x64" ], @@ -1002,9 +1026,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.61.0.tgz", - "integrity": "sha512-K/o4hEyW7flfMel0iBVznmMBt7VIMHGdjADocHKpK1DUF9erpWnJ+BSSWd2W0c8K3mPtpph+CuHzRU6CI3l9jQ==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.62.0.tgz", + "integrity": "sha512-dLP33T7VLCmLVv4cvjkVX+rmkcwNk2UfxmsZPNur/7BQHoQR60zJ7XLiRvNUawlzn0u8ngCa3itjEG73MAMa/w==", "cpu": [ "x64" ], @@ -1019,9 +1043,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.61.0.tgz", - "integrity": "sha512-P6040ZkcyweJ0Po9yEFqJCdvZnf3VNCGs1SIHgXDf8AAQNC6ID/heXQs9iSgo2FH7gKaKq32VWc59XZwL34C5Q==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.62.0.tgz", + "integrity": "sha512-fl//LWNks6qo9chNY60UDYyIwtp7a5cEx4Y/rHPjaarhuwqx6jtbzEpD5V5AqmdL4a6Y5D8zeXg5HF2Cr0QmSQ==", "cpu": [ "arm" ], @@ -1036,9 +1060,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.61.0.tgz", - "integrity": "sha512-bwxrGCzTZkuB+THv2TQ1aTkVEfv5oz8sl+0XZZCpoYzErJD8OhPQOTA0ENPd1zJz8QsVdSzSrS2umKtPq4/JXg==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.62.0.tgz", + "integrity": "sha512-i5vkAuxvueTODV3J2dL61/TXewDHhMFKvtD156cIsk7GsdfiAu7zW7kY0NJXhKeFHeiMZIh7eFNjkPYH6J47HQ==", "cpu": [ "arm" ], @@ -1053,13 +1077,16 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.61.0.tgz", - "integrity": "sha512-vkhb9/wKguMkLlrm3FoJW/Xmdv31GgYAE+x8lxxQ+7HeOxXUySI0q36a3NTVIuQUdLzxCI1zzMGsk1o37FOe3w==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.62.0.tgz", + "integrity": "sha512-QwN19LLuIGuOjEflSeJkZmOTfBdBMlTmW8xbMf8TZhjd//cxVNYQPq75q7oKZBJc6hRx3gY7sX0Egc8cEIFZYg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1070,13 +1097,16 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.61.0.tgz", - "integrity": "sha512-bl1dQh8LnVqsj6oOQAcxwbuOmNJkwc4p6o//HTBZhNTzJy21TLDwAviMqUFNUxDHkPGpmdKTSN4tWTjLryP8xg==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.62.0.tgz", + "integrity": "sha512-8eCy3FCDuWUM5hWujAv6heMvfZPbcCOU3SdQUAkixZLu5bSzOkNfirJiLGoQFO943xceOKkiQRMQNzH++jM3WA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1087,13 +1117,16 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.61.0.tgz", - "integrity": "sha512-QoOX6KB2IiEpyOj/HKqaxi+NQHPnOgNgnr22n9N4ANJCzXkUlj1UmeAbFb4PpqdlHIzvGDM5xZ0OKtcLq9RhiQ==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.62.0.tgz", + "integrity": "sha512-NjQ7K7tpTPDe9J+yq8p/s/J0E7lRCkK2uDBDqvT4XIT6f4Z0tlnr59OBg/WcrmVHER1AbrcfyxhGTXgcG8ytWg==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1104,13 +1137,16 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.61.0.tgz", - "integrity": "sha512-1TGcTerjY6p152wCof3oKElccq3xHljS/Mucp04gV/4ATpP6nO7YNnp7opEg6SHkv2a57/b4b8Ndm9znJ1/qAw==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.62.0.tgz", + "integrity": "sha512-oKZed9gmSwze29dEt3/Wnsv6l/Ygw/FUst+8Kfpv2SGeS/glEoTGZAMQw37SVyzFV76UTHJN2snGgxK2t2+8ow==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1121,13 +1157,16 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.61.0.tgz", - "integrity": "sha512-65wXEmZIrX2ADwC8i/qFL4EWLSbeuBpAm3suuX1vu4IQkKd+wLT/HU/BOl84kp91u2SxPkPDyQgu4yrqp8vwVA==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.62.0.tgz", + "integrity": "sha512-gBjBxQ+9lGpAYq+ELqw0w8QXsBnkZclFc7GRX2r0LnEVn3ZTEqeIKpKcGjucmp76Q53bvJD0i4qBWBhcfhSfGA==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1138,13 +1177,16 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.61.0.tgz", - "integrity": "sha512-TVvhgMvor7Qa6COeXxCJ7ENOM+lcAOGsQ0iUdPSCv2hxb9qSHLQ4XF1h50S6RE1gBOJ0WV3rNukg4JJJP1LWRA==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.62.0.tgz", + "integrity": "sha512-Ew2Kxs9EQ9/mbAIJ2hvocMC0wsOu6YKzStI2eFBDt+Td5O8seVC/oxgRIHqCcl5sf5ratA1nozQBAuv7tphkHg==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1155,13 +1197,16 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.61.0.tgz", - "integrity": "sha512-SjpS5uYuFoDnDdZPwZE59ndF95AsY47R5MliuneTWR1pDm2CxGJaYXbKULI71t5TVfLQUWmrHEGRL9xvuq6dnA==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.62.0.tgz", + "integrity": "sha512-5z25jcAA0gfKyVwz71A0VXgaPlocPoTAxhlv/hgoK6tlCrfoNuw7haWbDHvGMfjXhdic4EqVXGRv5XsTqFnbRQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1172,13 +1217,16 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.61.0.tgz", - "integrity": "sha512-gGfAeGD4sNJGILZbc/yKcIimO9wQnPMoYp9swAaKeEtwsSQAbU+rsdQze5SBtIP6j0QDzeYd4XSSUCRCF+LIeQ==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.62.0.tgz", + "integrity": "sha512-IWpHmMB6ZDllPvqWDkG6AmXrN7JF5e/c4g/0PuURsmlK+vHoYZPB70rr4u1bn3I4LsKCSpqqfveyx6UCOC8wdg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1189,9 +1237,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.61.0.tgz", - "integrity": "sha512-OlVT0LrG/ct33EVtWRyR+B/othwmDWeRxfi13wUdPeb3lAT5TgTcFDcfLfarZtzB4W1nWF/zICMgYdkggX2WmQ==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.62.0.tgz", + "integrity": "sha512-fjlSxxrD5pA594vkyikCS9MnPRjQawW6/BLgyTYkO+73wwPlYjkcZ7LSd974l0Q2zkHQmu4DPvJFLYA7o8xrxQ==", "cpu": [ "arm64" ], @@ -1206,9 +1254,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.61.0.tgz", - "integrity": "sha512-vI//NZPJk6DToiovPtaiwD4iQ7kO1r5ReWQD0sOOyKRtP3E2f6jxin4uvwi3OvDzHA2EFfd7DcZl5dtkQh7g1w==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.62.0.tgz", + "integrity": "sha512-EiFXr8loNS0Ul3Gu80+9nr1T8jRmnKocqmHHg16tj5ZqTgUXyb97l2rrspVHdDluyFn9JfR4PoJFdNzw4paHww==", "cpu": [ "arm64" ], @@ -1223,9 +1271,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.61.0.tgz", - "integrity": "sha512-0ySj4/4zd2XjePs3XAQq7IigIstN4LPQZgCyigX5/ERMLjdWAJfnxcTsrtxZxuij8guJW8foXuHmhGxW0H4dDA==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.62.0.tgz", + "integrity": "sha512-IgOFvL73li1bFgab+hThXYA0N2Xms2kV2MvZN95cebV+fmrZ9AVui1JSxfeeqRLo3CpPxKZlzhyq4G0cnaAvIw==", "cpu": [ "ia32" ], @@ -1240,9 +1288,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.61.0.tgz", - "integrity": "sha512-0xgSiyeqDLDZxXoe9CVJrOx3TUVsfyoOY7cNi03JbItNcC9WCZqrSNdrAbHONxhSPaVh/lzfnDcON1RqSUMhHw==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.62.0.tgz", + "integrity": "sha512-6hMpyDWQ2zGA1OXFKBrdYMUveUCO8UJhkO6JdwZPd78xIdHZNhjx+pib+4fC2Cljuhjyl0QwA2F3df/bs4Bp6A==", "cpu": [ "x64" ], @@ -1257,9 +1305,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz", - "integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", "cpu": [ "arm64" ], @@ -1274,9 +1322,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz", - "integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", "cpu": [ "arm64" ], @@ -1291,9 +1339,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz", - "integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", "cpu": [ "x64" ], @@ -1308,9 +1356,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz", - "integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", "cpu": [ "x64" ], @@ -1325,9 +1373,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz", - "integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", "cpu": [ "arm" ], @@ -1342,13 +1390,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz", - "integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1359,13 +1410,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz", - "integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1376,13 +1430,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz", - "integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1393,13 +1450,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz", - "integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1410,13 +1470,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz", - "integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1427,13 +1490,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz", - "integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1444,9 +1510,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz", - "integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", "cpu": [ "arm64" ], @@ -1461,9 +1527,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz", - "integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", "cpu": [ "wasm32" ], @@ -1471,8 +1537,8 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.9.2", - "@emnapi/runtime": "1.9.2", + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { @@ -1480,9 +1546,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz", - "integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", "cpu": [ "arm64" ], @@ -1497,9 +1563,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz", - "integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", "cpu": [ "x64" ], @@ -1514,9 +1580,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz", - "integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", "dev": true, "license": "MIT" }, @@ -2017,9 +2083,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, @@ -2538,6 +2604,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2559,6 +2628,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2580,6 +2652,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2601,6 +2676,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2825,9 +2903,9 @@ } }, "node_modules/oxfmt": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.46.0.tgz", - "integrity": "sha512-CopwJOwPAjZ9p76fCvz+mSOJTw9/NY3cSksZK3VO/bUQ8UoEcketNgUuYS0UB3p+R9XnXe7wGGXUmyFxc7QxJA==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.47.0.tgz", + "integrity": "sha512-OFbkbzxKCpooQEnRmpTDnuwTX8KHXzZTQ4Df/hz85fpS67Pl+lxPEFvUtin56HIIS0B1k4X8oIzTXRZPufA2CA==", "dev": true, "license": "MIT", "dependencies": { @@ -2843,31 +2921,31 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxfmt/binding-android-arm-eabi": "0.46.0", - "@oxfmt/binding-android-arm64": "0.46.0", - "@oxfmt/binding-darwin-arm64": "0.46.0", - "@oxfmt/binding-darwin-x64": "0.46.0", - "@oxfmt/binding-freebsd-x64": "0.46.0", - "@oxfmt/binding-linux-arm-gnueabihf": "0.46.0", - "@oxfmt/binding-linux-arm-musleabihf": "0.46.0", - "@oxfmt/binding-linux-arm64-gnu": "0.46.0", - "@oxfmt/binding-linux-arm64-musl": "0.46.0", - "@oxfmt/binding-linux-ppc64-gnu": "0.46.0", - "@oxfmt/binding-linux-riscv64-gnu": "0.46.0", - "@oxfmt/binding-linux-riscv64-musl": "0.46.0", - "@oxfmt/binding-linux-s390x-gnu": "0.46.0", - "@oxfmt/binding-linux-x64-gnu": "0.46.0", - "@oxfmt/binding-linux-x64-musl": "0.46.0", - "@oxfmt/binding-openharmony-arm64": "0.46.0", - "@oxfmt/binding-win32-arm64-msvc": "0.46.0", - "@oxfmt/binding-win32-ia32-msvc": "0.46.0", - "@oxfmt/binding-win32-x64-msvc": "0.46.0" + "@oxfmt/binding-android-arm-eabi": "0.47.0", + "@oxfmt/binding-android-arm64": "0.47.0", + "@oxfmt/binding-darwin-arm64": "0.47.0", + "@oxfmt/binding-darwin-x64": "0.47.0", + "@oxfmt/binding-freebsd-x64": "0.47.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.47.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.47.0", + "@oxfmt/binding-linux-arm64-gnu": "0.47.0", + "@oxfmt/binding-linux-arm64-musl": "0.47.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.47.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.47.0", + "@oxfmt/binding-linux-riscv64-musl": "0.47.0", + "@oxfmt/binding-linux-s390x-gnu": "0.47.0", + "@oxfmt/binding-linux-x64-gnu": "0.47.0", + "@oxfmt/binding-linux-x64-musl": "0.47.0", + "@oxfmt/binding-openharmony-arm64": "0.47.0", + "@oxfmt/binding-win32-arm64-msvc": "0.47.0", + "@oxfmt/binding-win32-ia32-msvc": "0.47.0", + "@oxfmt/binding-win32-x64-msvc": "0.47.0" } }, "node_modules/oxlint": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.61.0.tgz", - "integrity": "sha512-ZC0ALuhDZ6ivOFG+sy0D0pEDN49EvsId98zVlmYdkcXHsEM14m/qTNUEsUpiFiCVbpIxYtVBmmLE87nsbUHohQ==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.62.0.tgz", + "integrity": "sha512-1uFkg6HakjsGIpW9wNdeW4/2LOHW9MEkoWjZUTUfQtIHyLIZPYt00w3Sg+H3lH+206FgBPHBbW5dVE5l2ExECQ==", "dev": true, "license": "MIT", "bin": { @@ -2880,25 +2958,25 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.61.0", - "@oxlint/binding-android-arm64": "1.61.0", - "@oxlint/binding-darwin-arm64": "1.61.0", - "@oxlint/binding-darwin-x64": "1.61.0", - "@oxlint/binding-freebsd-x64": "1.61.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.61.0", - "@oxlint/binding-linux-arm-musleabihf": "1.61.0", - "@oxlint/binding-linux-arm64-gnu": "1.61.0", - "@oxlint/binding-linux-arm64-musl": "1.61.0", - "@oxlint/binding-linux-ppc64-gnu": "1.61.0", - "@oxlint/binding-linux-riscv64-gnu": "1.61.0", - "@oxlint/binding-linux-riscv64-musl": "1.61.0", - "@oxlint/binding-linux-s390x-gnu": "1.61.0", - "@oxlint/binding-linux-x64-gnu": "1.61.0", - "@oxlint/binding-linux-x64-musl": "1.61.0", - "@oxlint/binding-openharmony-arm64": "1.61.0", - "@oxlint/binding-win32-arm64-msvc": "1.61.0", - "@oxlint/binding-win32-ia32-msvc": "1.61.0", - "@oxlint/binding-win32-x64-msvc": "1.61.0" + "@oxlint/binding-android-arm-eabi": "1.62.0", + "@oxlint/binding-android-arm64": "1.62.0", + "@oxlint/binding-darwin-arm64": "1.62.0", + "@oxlint/binding-darwin-x64": "1.62.0", + "@oxlint/binding-freebsd-x64": "1.62.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.62.0", + "@oxlint/binding-linux-arm-musleabihf": "1.62.0", + "@oxlint/binding-linux-arm64-gnu": "1.62.0", + "@oxlint/binding-linux-arm64-musl": "1.62.0", + "@oxlint/binding-linux-ppc64-gnu": "1.62.0", + "@oxlint/binding-linux-riscv64-gnu": "1.62.0", + "@oxlint/binding-linux-riscv64-musl": "1.62.0", + "@oxlint/binding-linux-s390x-gnu": "1.62.0", + "@oxlint/binding-linux-x64-gnu": "1.62.0", + "@oxlint/binding-linux-x64-musl": "1.62.0", + "@oxlint/binding-openharmony-arm64": "1.62.0", + "@oxlint/binding-win32-arm64-msvc": "1.62.0", + "@oxlint/binding-win32-ia32-msvc": "1.62.0", + "@oxlint/binding-win32-x64-msvc": "1.62.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" @@ -2943,9 +3021,9 @@ } }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", "dev": true, "funding": [ { @@ -3074,14 +3152,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz", - "integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.126.0", - "@rolldown/pluginutils": "1.0.0-rc.16" + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" @@ -3090,21 +3168,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.16", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", - "@rolldown/binding-darwin-x64": "1.0.0-rc.16", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" } }, "node_modules/safe-buffer": { @@ -3278,9 +3356,9 @@ } }, "node_modules/string-width": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", - "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "license": "MIT", "dependencies": { "get-east-asian-width": "^1.5.0", @@ -3452,16 +3530,16 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.0.9", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz", - "integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==", + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.16", + "rolldown": "1.0.0-rc.17", "tinyglobby": "^0.2.16" }, "bin": { diff --git a/package.json b/package.json index fd380b6..eeeef75 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,13 @@ "npm": ">=10.0.0" }, "files": [ - "build" + "build", + "man" ], "scripts": { - "build": "rm -rf ./build; tsc --build; chmod +x ./build/index.js", + "man": "mkdir -p man && tsx src/lib/generate-man.ts > man/tagoio.1 && chmod 644 man/tagoio.1", + "build": "npm run man && rm -rf ./build; tsc --build; chmod +x ./build/index.js", + "prepublishOnly": "npm run man", "test": "vitest run", "test:single": "vitest --", "test:coverage": "vitest run --coverage", @@ -35,6 +38,7 @@ "bin": { "tagoio": "./build/index.js" }, + "man": "./man/tagoio.1", "author": "TagoIO LLC", "license": "ISC", "dependencies": { @@ -59,8 +63,8 @@ "@types/prompts": "^2.4.9", "@types/unzipper": "^0.10.11", "@vitest/coverage-v8": "^4.1.5", - "oxfmt": "^0.46.0", - "oxlint": "1.61.0", + "oxfmt": "^0.47.0", + "oxlint": "1.62.0", "typescript": "^6.0.3", "vitest": "^4.1.5" } diff --git a/src/commands/analysis/analysis-console.test.ts b/src/commands/analysis/analysis-console.test.ts index 8454455..3e8679d 100644 --- a/src/commands/analysis/analysis-console.test.ts +++ b/src/commands/analysis/analysis-console.test.ts @@ -39,6 +39,16 @@ vi.mock("../../lib/messages.js", () => ({ highlightMSG: (s: string) => s, })); +vi.mock("../../lib/resolve-scope.js", () => ({ + requireLocalScope: () => ({ + scope: "local" as const, + root: "/repo", + configPath: "/repo/tagoconfig.json", + envFilePath: "/repo/.tagoio/personal.env", + configExists: true, + }), +})); + describe("connectAnalysisConsole", () => { const analysisList = [{ name: "script", fileName: "script.ts", id: "an-1" }]; diff --git a/src/commands/analysis/analysis-console.ts b/src/commands/analysis/analysis-console.ts index e4d1909..e3336fd 100644 --- a/src/commands/analysis/analysis-console.ts +++ b/src/commands/analysis/analysis-console.ts @@ -2,6 +2,7 @@ import { Account, AnalysisInfo } from "@tago-io/sdk"; import { EventSource } from "eventsource"; import { getEnvironmentConfig, IEnvironment } from "../../lib/config-file.js"; import { errorHandler, highlightMSG, infoMSG, successMSG } from "../../lib/messages.js"; +import { requireLocalScope } from "../../lib/resolve-scope.js"; import { searchName } from "../../lib/search-name.js"; import { pickAnalysisFromConfig } from "../../prompt/pick-analysis-from-config.js"; @@ -25,8 +26,8 @@ function apiSSE(profileToken: string, analysisID: string, urlSSERealtime?: strin * @param analysisList - The list of analysis objects to search through. * @returns The script object that matches the script name or the one selected by the user. */ -async function getScriptObj(scriptName: string | void, analysisList: IEnvironment["analysisList"]) { - let scriptObj: IEnvironment["analysisList"][0] | undefined; +async function getScriptObj(scriptName: string | void, analysisList: NonNullable) { + let scriptObj: NonNullable[number] | undefined; if (scriptName) { scriptObj = searchName( scriptName, @@ -69,12 +70,14 @@ function setupSSE(sse: ReturnType, _script_id: string, analysis_i * @returns void */ async function connectAnalysisConsole(scriptName: string | void, options: { environment: string }) { + requireLocalScope("analysis-console"); + const config = getEnvironmentConfig(options.environment); if (!config || !config.profileToken) { errorHandler("Environment not found"); } - const scriptObj = await getScriptObj(scriptName, config.analysisList); + const scriptObj = await getScriptObj(scriptName, config.analysisList ?? []); if (!scriptObj) { errorHandler(`Analysis not found: ${scriptName}`); } diff --git a/src/commands/analysis/analysis-set-mode.test.ts b/src/commands/analysis/analysis-set-mode.test.ts index 43cdcba..99969f9 100644 --- a/src/commands/analysis/analysis-set-mode.test.ts +++ b/src/commands/analysis/analysis-set-mode.test.ts @@ -30,6 +30,16 @@ vi.mock("../../lib/messages.js", () => ({ highlightMSG: (s: string) => s, })); +vi.mock("../../lib/resolve-scope.js", () => ({ + requireLocalScope: () => ({ + scope: "local" as const, + root: "/repo", + configPath: "/repo/tagoconfig.json", + envFilePath: "/repo/.tagoio/personal.env", + configExists: true, + }), +})); + describe("analysisSetMode", () => { // Factory — the command sorts these in place, so each test needs a fresh copy. const makeAnalyses = () => [ diff --git a/src/commands/analysis/analysis-set-mode.ts b/src/commands/analysis/analysis-set-mode.ts index 4020da7..da59700 100644 --- a/src/commands/analysis/analysis-set-mode.ts +++ b/src/commands/analysis/analysis-set-mode.ts @@ -3,6 +3,7 @@ import kleur from "kleur"; import { getEnvironmentConfig } from "../../lib/config-file.js"; import { errorHandler, infoMSG, successMSG } from "../../lib/messages.js"; +import { requireLocalScope } from "../../lib/resolve-scope.js"; import { chooseFromList } from "../../prompt/choose-from-list.js"; import { pickFromList } from "../../prompt/pick-from-list.js"; @@ -62,6 +63,8 @@ async function chooseAnalysisToUpdateRunOnMode( * @param options.filterMode - The filter mode to use when retrieving the analysis list. */ async function analysisSetMode(userInputName: string | void, options: { environment: string; mode: string; filterMode: string }) { + requireLocalScope("analysis-mode"); + const config = getEnvironmentConfig(options.environment); if (!config || !config.profileToken) { errorHandler("Environment not found"); diff --git a/src/commands/analysis/deploy.test.ts b/src/commands/analysis/deploy.test.ts index 8cb3f20..f14cd8a 100644 --- a/src/commands/analysis/deploy.test.ts +++ b/src/commands/analysis/deploy.test.ts @@ -46,8 +46,18 @@ vi.mock("../../lib/current-runtime.js", () => ({ detectRuntime: detectRuntimeMock, })); -vi.mock("../../lib/get-current-folder.js", () => ({ - getCurrentFolder: () => "/repo", +vi.mock("../../lib/resolve-scope.js", () => ({ + requireLocalScope: () => ({ + scope: "local" as const, + root: "/repo", + configPath: "/repo/tagoconfig.json", + envFilePath: "/repo/.tagoio/personal.env", + configExists: true, + }), +})); + +vi.mock("../../lib/scope-notice.js", () => ({ + printScopeBanner: vi.fn(), })); vi.mock("../../lib/messages.js", () => ({ diff --git a/src/commands/analysis/deploy.ts b/src/commands/analysis/deploy.ts index 3e72ac5..20a0fe6 100644 --- a/src/commands/analysis/deploy.ts +++ b/src/commands/analysis/deploy.ts @@ -5,8 +5,9 @@ import { Account, RunTypeOptions } from "@tago-io/sdk"; import { getEnvironmentConfig, IConfigFile, IEnvironment } from "../../lib/config-file.js"; import { detectRuntime } from "../../lib/current-runtime.js"; -import { getCurrentFolder } from "../../lib/get-current-folder.js"; import { errorHandler, infoMSG, successMSG } from "../../lib/messages.js"; +import { requireLocalScope } from "../../lib/resolve-scope.js"; +import { printScopeBanner } from "../../lib/scope-notice.js"; import { searchName } from "../../lib/search-name.js"; import { chooseAnalysisListFromConfig } from "../../prompt/choose-analysis-list-config.js"; import { confirmAnalysisFromConfig } from "../../prompt/confirm-analysis-list.js"; @@ -20,6 +21,8 @@ interface BuildScriptParams { config: EnvConfig; runtime: string; path: string; + /** Resolved local-scope project root. */ + projectRoot: string; } /** @@ -27,11 +30,10 @@ interface BuildScriptParams { * @param config - An object containing the configuration for the environment. * @returns An object containing the paths for analysis, build and current folder. */ -function getPaths(config: EnvConfig) { - const folderPath = getCurrentFolder(); +function getPaths(config: EnvConfig, projectRoot: string) { const buildPath = config.buildPath || `./build`; const analysisPath = config.analysisPath || `./src/analysis`; - return { analysisPath, buildPath, folderPath }; + return { analysisPath, buildPath, folderPath: projectRoot }; } /** @@ -64,8 +66,8 @@ async function deleteOldFile(buildedFile: string) { * @param params - The parameters for building and uploading the script. */ async function buildScript(params: BuildScriptParams) { - const { account, scriptName, analysisID, config, runtime, path } = params; - const { analysisPath, buildPath, folderPath } = getPaths(config); + const { account, scriptName, analysisID, config, runtime, path, projectRoot } = params; + const { analysisPath, buildPath, folderPath } = getPaths(config, projectRoot); let analysisFile; if (path) { @@ -142,6 +144,10 @@ async function deployAnalysis(cmdScriptName: string, options: IDeployOptions) { errorHandler('Did you mean "tagoio deploy --all"? The "all" positional argument is no longer supported.'); } + // Analysis development requires a project directory. + const scope = requireLocalScope("analysis-deploy"); + printScopeBanner(scope, options.silent); + const config = getEnvironmentConfig(options.environment); if (!config) { errorHandler("Environment not found"); @@ -155,12 +161,12 @@ async function deployAnalysis(cmdScriptName: string, options: IDeployOptions) { } // --all skips selection entirely; everything in analysisList with a fileName ships. - let scriptList = config.analysisList.filter((x) => x.fileName); + let scriptList = (config.analysisList ?? []).filter((x) => x.fileName); if (!options.all) { if (!cmdScriptName) { scriptList = await chooseAnalysisListFromConfig(scriptList); } else { - const analysisFound: IEnvironment["analysisList"][0] = searchName( + const analysisFound: NonNullable[number] = searchName( cmdScriptName, scriptList.map((x) => ({ names: [x.name, x.fileName], value: x })), ); @@ -202,6 +208,7 @@ async function deployAnalysis(cmdScriptName: string, options: IDeployOptions) { config, runtime, path: path || "", + projectRoot: scope.root, }); } process.exit(); diff --git a/src/commands/analysis/duplicate-analysis.test.ts b/src/commands/analysis/duplicate-analysis.test.ts index da337d2..1e14744 100644 --- a/src/commands/analysis/duplicate-analysis.test.ts +++ b/src/commands/analysis/duplicate-analysis.test.ts @@ -39,6 +39,16 @@ vi.mock("../../lib/messages.js", () => ({ highlightMSG: (s: string) => s, })); +vi.mock("../../lib/resolve-scope.js", () => ({ + requireLocalScope: () => ({ + scope: "local" as const, + root: "/repo", + configPath: "/repo/tagoconfig.json", + envFilePath: "/repo/.tagoio/personal.env", + configExists: true, + }), +})); + const pickAnalysisFromTagoIOMock = vi.fn(); vi.mock("../../prompt/pick-analysis-from-tagoio.js", () => ({ pickAnalysisFromTagoIO: (...args: unknown[]) => pickAnalysisFromTagoIOMock(...args), diff --git a/src/commands/analysis/duplicate-analysis.ts b/src/commands/analysis/duplicate-analysis.ts index 525558c..eb15b17 100644 --- a/src/commands/analysis/duplicate-analysis.ts +++ b/src/commands/analysis/duplicate-analysis.ts @@ -5,6 +5,7 @@ import zlib from "node:zlib"; import { getEnvironmentConfig } from "../../lib/config-file.js"; import { errorHandler, successMSG } from "../../lib/messages.js"; +import { requireLocalScope } from "../../lib/resolve-scope.js"; import { pickAnalysisFromTagoIO } from "../../prompt/pick-analysis-from-tagoio.js"; /** @@ -78,6 +79,8 @@ async function downloadScriptBase64(account: Account, analysisId: string): Promi * @throws An error if the analysis ID is not found or if the environment is not found. */ async function duplicateAnalysis(analysisID: string | void, options: { environment: string; name?: string }) { + requireLocalScope("analysis-duplicate"); + const config = getEnvironmentConfig(options.environment); if (!config || !config.profileToken) { errorHandler("Environment not found"); diff --git a/src/commands/analysis/run-analysis.test.ts b/src/commands/analysis/run-analysis.test.ts index 21db59a..00fb4d1 100644 --- a/src/commands/analysis/run-analysis.test.ts +++ b/src/commands/analysis/run-analysis.test.ts @@ -36,8 +36,14 @@ vi.mock("../../lib/current-runtime.js", () => ({ detectRuntime: detectRuntimeMock, })); -vi.mock("../../lib/get-current-folder.js", () => ({ - getCurrentFolder: () => "/tmp/test", +vi.mock("../../lib/resolve-scope.js", () => ({ + requireLocalScope: () => ({ + scope: "local" as const, + root: "/tmp/test", + configPath: "/tmp/test/tagoconfig.json", + envFilePath: "/tmp/test/.tagoio/personal.env", + configExists: true, + }), })); vi.mock("../../lib/messages.js", () => ({ diff --git a/src/commands/analysis/run-analysis.ts b/src/commands/analysis/run-analysis.ts index 5527332..f4979f7 100644 --- a/src/commands/analysis/run-analysis.ts +++ b/src/commands/analysis/run-analysis.ts @@ -5,8 +5,8 @@ import { Account } from "@tago-io/sdk"; import { getEnvironmentConfig, IEnvironment, resolveCLIPath } from "../../lib/config-file.js"; import { detectRuntime } from "../../lib/current-runtime.js"; -import { getCurrentFolder } from "../../lib/get-current-folder.js"; import { errorHandler, highlightMSG, successMSG } from "../../lib/messages.js"; +import { requireLocalScope } from "../../lib/resolve-scope.js"; import { searchName } from "../../lib/search-name.js"; import { pickAnalysisFromConfig } from "../../prompt/pick-analysis-from-config.js"; @@ -68,13 +68,16 @@ async function runAnalysis( scriptName: string | undefined, options: { environment: string; debug: boolean; clear: boolean; tsnd: boolean; deno: boolean; node: boolean }, ) { + // Analysis development requires a project directory. + const scope = requireLocalScope("analysis-run"); + const config = getEnvironmentConfig(options.environment); if (!config || !config.profileToken) { errorHandler("Environment not found"); } - const analysisList = config.analysisList.filter((x) => x.fileName); - let scriptToRun: IEnvironment["analysisList"][0]; + const analysisList = (config.analysisList ?? []).filter((x) => x.fileName); + let scriptToRun: NonNullable[number]; if (scriptName) { scriptName = scriptName.toLowerCase(); scriptToRun = searchName( @@ -111,7 +114,7 @@ async function runAnalysis( const spawnOptions: SpawnOptions = { shell: true, - cwd: getCurrentFolder(), + cwd: scope.root, stdio: "inherit", env: analysisEnv, }; diff --git a/src/commands/analysis/trigger-analysis.test.ts b/src/commands/analysis/trigger-analysis.test.ts index 523ecb5..45b12de 100644 --- a/src/commands/analysis/trigger-analysis.test.ts +++ b/src/commands/analysis/trigger-analysis.test.ts @@ -31,6 +31,16 @@ vi.mock("../../lib/messages.js", () => ({ highlightMSG: (s: string) => s, })); +vi.mock("../../lib/resolve-scope.js", () => ({ + requireLocalScope: () => ({ + scope: "local" as const, + root: "/repo", + configPath: "/repo/tagoconfig.json", + envFilePath: "/repo/.tagoio/personal.env", + configExists: true, + }), +})); + describe("triggerAnalysis", () => { const analysisList = [ { name: "myScript", fileName: "my-script.ts", id: "an-1" }, diff --git a/src/commands/analysis/trigger-analysis.ts b/src/commands/analysis/trigger-analysis.ts index 540e469..f554d13 100644 --- a/src/commands/analysis/trigger-analysis.ts +++ b/src/commands/analysis/trigger-analysis.ts @@ -3,6 +3,7 @@ import kleur from "kleur"; import { getEnvironmentConfig, IEnvironment } from "../../lib/config-file.js"; import { errorHandler, infoMSG, successMSG } from "../../lib/messages.js"; +import { requireLocalScope } from "../../lib/resolve-scope.js"; import { searchName } from "../../lib/search-name.js"; import { pickAnalysisFromConfig } from "../../prompt/pick-analysis-from-config.js"; import { pickAnalysisFromTagoIO } from "../../prompt/pick-analysis-from-tagoio.js"; @@ -16,6 +17,8 @@ import { pickAnalysisFromTagoIO } from "../../prompt/pick-analysis-from-tagoio.j * @param options.tago - Whether to pick the analysis from TagoIO. */ async function triggerAnalysis(scriptName: string | void, options: { environment?: string; json?: string; tago: boolean }) { + requireLocalScope("analysis-trigger"); + const config = getEnvironmentConfig(options.environment); if (!config || !config.profileToken) { @@ -23,9 +26,10 @@ async function triggerAnalysis(scriptName: string | void, options: { environment } const account = new Account({ token: config.profileToken, region: config.profileRegion }); - const analysisList = config.analysisList.filter((x) => x.fileName); + const fullList = config.analysisList ?? []; + const analysisList = fullList.filter((x) => x.fileName); - let script: IEnvironment["analysisList"][0] | undefined; + let script: NonNullable[number] | undefined; if (!scriptName && options.tago) { const analysis = await pickAnalysisFromTagoIO(account); @@ -35,7 +39,7 @@ async function triggerAnalysis(scriptName: string | void, options: { environment } else { script = searchName( scriptName, - config.analysisList.map((x) => ({ names: [x.name, x.fileName], value: x })), + fullList.map((x) => ({ names: [x.name, x.fileName], value: x })), ); } diff --git a/src/commands/devices/change-bucket-type.ts b/src/commands/devices/change-bucket-type.ts index 8292143..6a946bb 100644 --- a/src/commands/devices/change-bucket-type.ts +++ b/src/commands/devices/change-bucket-type.ts @@ -3,6 +3,8 @@ import kleur from "kleur"; import { getEnvironmentConfig } from "../../lib/config-file.js"; import { errorHandler, infoMSG, successMSG } from "../../lib/messages.js"; +import { resolveScope } from "../../lib/resolve-scope.js"; +import { printScopeBanner } from "../../lib/scope-notice.js"; import { chooseFromList } from "../../prompt/choose-from-list.js"; import { promptNumber } from "../../prompt/number-prompt.js"; import { pickFromList } from "../../prompt/pick-from-list.js"; @@ -97,6 +99,8 @@ async function chooseBucketsFromList(account: Account) { } async function changeBucketType(id: string, options: { environment: string }) { + printScopeBanner(resolveScope()); + const config = getEnvironmentConfig(options.environment); if (!config || !config.profileToken) { errorHandler("Environment not found"); diff --git a/src/commands/devices/change-network.test.ts b/src/commands/devices/change-network.test.ts index 0ddafb5..1ee8bfc 100644 --- a/src/commands/devices/change-network.test.ts +++ b/src/commands/devices/change-network.test.ts @@ -29,6 +29,20 @@ vi.mock("../../lib/messages.js", () => ({ successMSG: vi.fn(), })); +vi.mock("../../lib/resolve-scope.js", () => ({ + resolveScope: () => ({ + scope: "local" as const, + root: "/repo", + configPath: "/repo/tagoconfig.json", + envFilePath: "/repo/.tagoio/personal.env", + configExists: true, + }), +})); + +vi.mock("../../lib/scope-notice.js", () => ({ + printScopeBanner: vi.fn(), +})); + vi.mock("../../prompt/pick-device-id-from-tagoio.js", () => ({ pickDeviceIDFromTagoIO: (...args: unknown[]) => pickDeviceIDFromTagoIOMock(...args), })); diff --git a/src/commands/devices/change-network.ts b/src/commands/devices/change-network.ts index 3db7313..944f126 100644 --- a/src/commands/devices/change-network.ts +++ b/src/commands/devices/change-network.ts @@ -3,6 +3,8 @@ import kleur from "kleur"; import { getEnvironmentConfig } from "../../lib/config-file.js"; import { errorHandler, infoMSG, successMSG } from "../../lib/messages.js"; +import { resolveScope } from "../../lib/resolve-scope.js"; +import { printScopeBanner } from "../../lib/scope-notice.js"; import { pickDeviceIDFromTagoIO } from "../../prompt/pick-device-id-from-tagoio.js"; import { promptTextToEnter } from "../../prompt/text-prompt.js"; @@ -44,6 +46,8 @@ async function updateDevice(config: environmentConfigResponse, deviceID: string, } async function changeNetworkOrConnector(id: string, options: { environment: string; networkID: string; connectorID: string }) { + printScopeBanner(resolveScope()); + const config = getEnvironmentConfig(options.environment); if (!config || !config.profileToken) { errorHandler("Environment not found"); diff --git a/src/commands/devices/copy-data.test.ts b/src/commands/devices/copy-data.test.ts index bf8df5f..f97dc8e 100644 --- a/src/commands/devices/copy-data.test.ts +++ b/src/commands/devices/copy-data.test.ts @@ -41,6 +41,20 @@ vi.mock("../../lib/messages.js", () => ({ highlightMSG: (s: string) => s, })); +vi.mock("../../lib/resolve-scope.js", () => ({ + resolveScope: () => ({ + scope: "local" as const, + root: "/repo", + configPath: "/repo/tagoconfig.json", + envFilePath: "/repo/.tagoio/personal.env", + configExists: true, + }), +})); + +vi.mock("../../lib/scope-notice.js", () => ({ + printScopeBanner: vi.fn(), +})); + vi.mock("../../prompt/pick-device-id-from-tagoio.js", () => ({ pickDeviceIDFromTagoIO: (...args: unknown[]) => pickDeviceIDFromTagoIOMock(...args), })); diff --git a/src/commands/devices/copy-data.ts b/src/commands/devices/copy-data.ts index 0415a7b..b602fe0 100644 --- a/src/commands/devices/copy-data.ts +++ b/src/commands/devices/copy-data.ts @@ -2,6 +2,8 @@ import { Account, Device, Utils } from "@tago-io/sdk"; import { getEnvironmentConfig } from "../../lib/config-file.js"; import { errorHandler, highlightMSG, infoMSG, successMSG } from "../../lib/messages.js"; +import { resolveScope } from "../../lib/resolve-scope.js"; +import { printScopeBanner } from "../../lib/scope-notice.js"; import { confirmPrompt } from "../../prompt/confirm.js"; import { pickDeviceIDFromTagoIO } from "../../prompt/pick-device-id-from-tagoio.js"; @@ -31,6 +33,8 @@ async function startCopy(deviceFrom: Device, deviceTo: Device, options: IOptions } async function copyDeviceData(options: IOptions) { + printScopeBanner(resolveScope()); + const config = getEnvironmentConfig(options.environment); if (!config || !config.profileToken) { errorHandler("Environment not found"); diff --git a/src/commands/devices/device-bkp.test.ts b/src/commands/devices/device-bkp.test.ts index daf9700..7ecbb50 100644 --- a/src/commands/devices/device-bkp.test.ts +++ b/src/commands/devices/device-bkp.test.ts @@ -42,6 +42,20 @@ vi.mock("../../lib/messages.js", () => ({ highlightMSG: (s: string) => s, })); +vi.mock("../../lib/resolve-scope.js", () => ({ + resolveScope: () => ({ + scope: "local" as const, + root: "/repo", + configPath: "/repo/tagoconfig.json", + envFilePath: "/repo/.tagoio/personal.env", + configExists: true, + }), +})); + +vi.mock("../../lib/scope-notice.js", () => ({ + printScopeBanner: vi.fn(), +})); + vi.mock("../../prompt/pick-device-id-from-tagoio.js", () => ({ pickDeviceIDFromTagoIO: (...args: unknown[]) => pickDeviceIDFromTagoIOMock(...args), })); diff --git a/src/commands/devices/device-bkp.ts b/src/commands/devices/device-bkp.ts index 5f9f122..53101a9 100644 --- a/src/commands/devices/device-bkp.ts +++ b/src/commands/devices/device-bkp.ts @@ -5,6 +5,8 @@ import { DateTime } from "luxon"; import { getEnvironmentConfig } from "../../lib/config-file.js"; import { errorHandler, infoMSG, successMSG } from "../../lib/messages.js"; +import { resolveScope } from "../../lib/resolve-scope.js"; +import { printScopeBanner } from "../../lib/scope-notice.js"; import { pickDeviceIDFromTagoIO } from "../../prompt/pick-device-id-from-tagoio.js"; import { pickFileFromTagoIO } from "../../prompt/pick-files-from-tagoio.js"; import { promptTextToEnter } from "../../prompt/text-prompt.js"; @@ -103,6 +105,8 @@ async function storeBKP(account: Account, device: Device, deviceInfo: DeviceInfo * @returns A Promise that resolves when the backup or restore process is complete. */ async function bkpDeviceData(idOrToken: string, options: IOptions) { + printScopeBanner(resolveScope()); + const config = getEnvironmentConfig(options.environment); if (!config || !config.profileToken) { errorHandler("Environment not found"); diff --git a/src/commands/login.test.ts b/src/commands/login.test.ts index af3ac23..32e9a9b 100644 --- a/src/commands/login.test.ts +++ b/src/commands/login.test.ts @@ -35,6 +35,22 @@ vi.mock("../lib/messages.js", () => ({ successMSG: successMSGMock, })); +vi.mock("../lib/resolve-scope.js", () => ({ + resolveScope: () => ({ + scope: "local" as const, + root: "/repo", + configPath: "/repo/tagoconfig.json", + envFilePath: "/repo/.tagoio/personal.env", + configExists: true, + }), + setScopeOverride: vi.fn(), + globalConfigDir: () => "/home/user/.config/tagoio", +})); + +vi.mock("../lib/scope-notice.js", () => ({ + printScopeBanner: vi.fn(), +})); + vi.mock("../lib/add-https-to-url.js", () => ({ addHttpsToUrl: (url: string) => url, })); diff --git a/src/commands/login.ts b/src/commands/login.ts index 6da3e1c..3f1e867 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,8 +1,11 @@ +import { mkdirSync } from "node:fs"; import { Account, OTPType } from "@tago-io/sdk"; import prompts from "prompts"; import { addHttpsToUrl } from "../lib/add-https-to-url.js"; import { errorHandler, highlightMSG, successMSG } from "../lib/messages.js"; +import { globalConfigDir, resolveScope, setScopeOverride } from "../lib/resolve-scope.js"; +import { printScopeBanner } from "../lib/scope-notice.js"; import { writeToken } from "../lib/token.js"; /** @@ -60,6 +63,7 @@ interface LoginOptions { token?: string; tagoDeployUrl?: string; tagoDeploySse?: string; + scope?: "local" | "global"; } /** @@ -124,6 +128,30 @@ async function loginWithEmailPassword(email: string, password: string) { } async function tagoLogin(environment: string, options: LoginOptions) { + // --scope forces the resolver. 'global' targets the user's global + // config dir; 'local' targets cwd. + if (options.scope && options.scope !== "local" && options.scope !== "global") { + errorHandler(`Invalid --scope value: '${options.scope}'. Use 'local' or 'global'.`); + } + if (options.scope === "global") { + mkdirSync(globalConfigDir(), { recursive: true, mode: 0o700 }); + setScopeOverride("global"); + } + + // Login attaches a token to a profile that must already exist. Without a + // tagoconfig.json at the resolved scope, there is nothing to attach to — + // point the user at init. + const scope = resolveScope(); + if (!scope.configExists) { + if (options.scope === "global") { + errorHandler("No global tagoconfig.json found. Run `tagoio init --scope global` to create one first."); + } + if (scope.scope === "global") { + errorHandler("No tagoconfig.json found. Run `tagoio init` to create one first."); + } + } + printScopeBanner(scope); + const tagoDeploy = await getTagoDeployURL(); options.tagoDeployUrl = tagoDeploy?.urlAPI; options.tagoDeploySse = tagoDeploy?.urlSSE; diff --git a/src/commands/profile/backup/create.test.ts b/src/commands/profile/backup/create.test.ts index 38a5c2b..8cd9242 100644 --- a/src/commands/profile/backup/create.test.ts +++ b/src/commands/profile/backup/create.test.ts @@ -33,6 +33,20 @@ vi.mock("../../../lib/config-file.js", () => ({ getEnvironmentConfig: getEnvironmentConfigMock, })); +vi.mock("../../../lib/resolve-scope.js", () => ({ + resolveScope: () => ({ + scope: "local" as const, + root: "/repo", + configPath: "/repo/tagoconfig.json", + envFilePath: "/repo/.tagoio/personal.env", + configExists: true, + }), +})); + +vi.mock("../../../lib/scope-notice.js", () => ({ + printScopeBanner: vi.fn(), +})); + vi.mock("../../../lib/messages.js", () => ({ errorHandler: errorHandlerMock, infoMSG: vi.fn(), diff --git a/src/commands/profile/backup/create.ts b/src/commands/profile/backup/create.ts index 6268c25..5b76264 100644 --- a/src/commands/profile/backup/create.ts +++ b/src/commands/profile/backup/create.ts @@ -4,6 +4,8 @@ import ora, { type Ora } from "ora"; import { getEnvironmentConfig } from "../../../lib/config-file.js"; import { errorHandler, highlightMSG, infoMSG, successMSG } from "../../../lib/messages.js"; +import { resolveScope } from "../../../lib/resolve-scope.js"; +import { printScopeBanner } from "../../../lib/scope-notice.js"; import { handleBackupError } from "./lib.js"; import { BackupItem, BackupListResponse } from "./types.js"; @@ -48,6 +50,8 @@ async function waitForBackupCompletion(profileID: string, baseURL: string, token /** Triggers a new profile backup and waits for completion. */ async function createBackup() { + printScopeBanner(resolveScope()); + const config = getEnvironmentConfig(); if (!config?.profileToken) { errorHandler("Environment not found"); diff --git a/src/commands/profile/backup/restore.test.ts b/src/commands/profile/backup/restore.test.ts index a815588..fed6a10 100644 --- a/src/commands/profile/backup/restore.test.ts +++ b/src/commands/profile/backup/restore.test.ts @@ -28,6 +28,20 @@ vi.mock("../../../lib/config-file.js", () => ({ getEnvironmentConfig: getEnvironmentConfigMock, })); +vi.mock("../../../lib/resolve-scope.js", () => ({ + resolveScope: () => ({ + scope: "local" as const, + root: "/repo", + configPath: "/repo/tagoconfig.json", + envFilePath: "/repo/.tagoio/personal.env", + configExists: true, + }), +})); + +vi.mock("../../../lib/scope-notice.js", () => ({ + printScopeBanner: vi.fn(), +})); + vi.mock("../../../lib/display-warning.js", () => ({ displayWarning: vi.fn(), })); diff --git a/src/commands/profile/backup/restore.ts b/src/commands/profile/backup/restore.ts index 7d5670e..96380be 100644 --- a/src/commands/profile/backup/restore.ts +++ b/src/commands/profile/backup/restore.ts @@ -12,6 +12,8 @@ import unzipper from "unzipper"; import { getEnvironmentConfig } from "../../../lib/config-file.js"; import { displayWarning } from "../../../lib/display-warning.js"; import { errorHandler, highlightMSG, infoMSG, successMSG } from "../../../lib/messages.js"; +import { resolveScope } from "../../../lib/resolve-scope.js"; +import { printScopeBanner } from "../../../lib/scope-notice.js"; import { chooseFromList } from "../../../prompt/choose-from-list.js"; import { confirmPrompt } from "../../../prompt/confirm.js"; import { fetchBackups, formatDate, formatFileSize, getDownloadUrl, handleBackupError, promptCredentials, selectBackup } from "./lib.js"; @@ -190,6 +192,8 @@ async function selectResourcesToRestore(): Promise { /** Interactive restore flow for profile backups. */ async function restoreBackup(options: RestoreOptions = {}) { + printScopeBanner(resolveScope()); + const config = getEnvironmentConfig(); if (!config?.profileToken) { errorHandler("Environment not found"); diff --git a/src/commands/profile/export/export.test.ts b/src/commands/profile/export/export.test.ts index 317fb39..0b3e517 100644 --- a/src/commands/profile/export/export.test.ts +++ b/src/commands/profile/export/export.test.ts @@ -28,6 +28,20 @@ vi.mock("../../../lib/get-current-folder.js", () => ({ getCurrentFolder: () => "/tmp/test", })); +vi.mock("../../../lib/resolve-scope.js", () => ({ + resolveScope: () => ({ + scope: "local" as const, + root: "/tmp/test", + configPath: "/tmp/test/tagoconfig.json", + envFilePath: "/tmp/test/.tagoio/personal.env", + configExists: true, + }), +})); + +vi.mock("../../../lib/scope-notice.js", () => ({ + printScopeBanner: vi.fn(), +})); + vi.mock("../../../lib/config-file.js", () => ({ getEnvironmentConfig: vi.fn(), })); diff --git a/src/commands/profile/export/export.ts b/src/commands/profile/export/export.ts index 0a9313b..f05aae7 100644 --- a/src/commands/profile/export/export.ts +++ b/src/commands/profile/export/export.ts @@ -6,6 +6,8 @@ import { addOnGitIgnore } from "../../../lib/add-to-gitignore.js"; import { getEnvironmentConfig } from "../../../lib/config-file.js"; import { getCurrentFolder } from "../../../lib/get-current-folder.js"; import { errorHandler, infoMSG, successMSG } from "../../../lib/messages.js"; +import { resolveScope } from "../../../lib/resolve-scope.js"; +import { printScopeBanner } from "../../../lib/scope-notice.js"; import { confirmPrompt } from "../../../prompt/confirm.js"; import { pickEnvironment } from "../../../prompt/pick-environment.js"; import { setupExport } from "./export-setup.js"; @@ -153,6 +155,8 @@ async function collectParameters(options: IExportOptions) { } async function startExport(options: IExportOptions) { + printScopeBanner(resolveScope()); + if (options.setup) { await setupExport(options); return; diff --git a/src/commands/set-env.test.ts b/src/commands/set-env.test.ts index 75a97bc..d70d8c5 100644 --- a/src/commands/set-env.test.ts +++ b/src/commands/set-env.test.ts @@ -21,6 +21,20 @@ vi.mock("../lib/messages.js", () => ({ successMSG: successMSGMock, })); +vi.mock("../lib/resolve-scope.js", () => ({ + resolveScope: () => ({ + scope: "local" as const, + root: "/repo", + configPath: "/repo/tagoconfig.json", + envFilePath: "/repo/.tagoio/personal.env", + configExists: true, + }), +})); + +vi.mock("../lib/scope-notice.js", () => ({ + printScopeBanner: vi.fn(), +})); + vi.mock("../prompt/pick-environment.js", () => ({ pickEnvironment: pickEnvironmentMock, })); diff --git a/src/commands/set-env.ts b/src/commands/set-env.ts index 0e5a2e0..b6c65d3 100644 --- a/src/commands/set-env.ts +++ b/src/commands/set-env.ts @@ -1,9 +1,13 @@ import { getConfigFile } from "../lib/config-file.js"; import { setEnvironmentVariables } from "../lib/dotenv-config.js"; import { errorHandler, successMSG } from "../lib/messages.js"; +import { resolveScope } from "../lib/resolve-scope.js"; +import { printScopeBanner } from "../lib/scope-notice.js"; import { pickEnvironment } from "../prompt/pick-environment.js"; async function setEnvironment(arg?: string) { + printScopeBanner(resolveScope()); + const configFile = getConfigFile(); if (!configFile) { return; diff --git a/src/commands/start-config.test.ts b/src/commands/start-config.test.ts index aa3ca4f..174f003 100644 --- a/src/commands/start-config.test.ts +++ b/src/commands/start-config.test.ts @@ -3,9 +3,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -// start-config.ts exports only `startConfig`, but it re-requires scanAnalysisFiles via startConfig's -// path. The helper itself isn't exported, so we test it indirectly through the module under test. -// For direct coverage we import the module after setting up a real temp directory to walk. +const errorHandlerMock = vi.fn<(str: unknown) => never>(() => { + throw new Error("errorHandler called"); +}); +const detectInitStateMock = vi.fn(); +const exitMock = vi.fn(() => { + throw new Error("process.exit called"); +}); vi.mock("../lib/config-file.js", () => ({ getConfigFile: vi.fn(), @@ -19,11 +23,40 @@ vi.mock("../lib/token.js", () => ({ })); vi.mock("../lib/messages.js", () => ({ - errorHandler: vi.fn(), + errorHandler: errorHandlerMock, highlightMSG: (s: string) => s, infoMSG: vi.fn(), })); +vi.mock("../lib/resolve-scope.js", () => ({ + resolveScope: () => ({ + scope: "local" as const, + root: "/repo", + configPath: "/repo/tagoconfig.json", + envFilePath: "/repo/.tagoio/personal.env", + configExists: true, + }), + setScopeOverride: vi.fn(), + globalConfigDir: () => "/home/user/.config/tagoio", +})); + +vi.mock("../lib/init-state.js", () => ({ + detectInitState: (envName: string) => detectInitStateMock(envName), +})); + +vi.mock("../lib/init-summary.js", () => ({ + banner: (scope: { root: string }) => `Initializing tagoio in ${scope.root}...`, + overwriteConfirmCopy: () => "OVERWRITE_COPY_STUB", + startStep: vi.fn(), + endStep: vi.fn(), + failStep: vi.fn(), + summaryBlock: () => "SUMMARY_BLOCK_STUB", +})); + +vi.mock("../lib/scope-notice.js", () => ({ + printScopeBanner: vi.fn(), +})); + vi.mock("./login.js", () => ({ getTagoDeployURL: vi.fn(), tagoLogin: vi.fn(), @@ -33,38 +66,121 @@ vi.mock("../prompt/text-prompt.js", () => ({ promptTextToEnter: vi.fn(), })); -describe("startConfig (entry points)", () => { +const localScope = { + scope: "local" as const, + root: "/repo", + configPath: "/repo/tagoconfig.json", + envFilePath: "/repo/.tagoio/personal.env", + configExists: true, +}; + +const freshState = { + scope: localScope, + isTTY: true, + configExists: true, + envExists: false, + tokenExists: false, +}; + +const reInitState = { + scope: localScope, + isTTY: true, + configExists: true, + envExists: true, + tokenExists: true, +}; + +describe("startConfig — clig.dev flow", () => { beforeEach(() => { vi.clearAllMocks(); + detectInitStateMock.mockReset(); + errorHandlerMock.mockClear(); + exitMock.mockClear(); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`__exit:${code ?? 0}`); + }) as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("re-init confirm-no exits with 'Cancelled. No changes made.'", async () => { + detectInitStateMock.mockReturnValue(reInitState); + const { getConfigFile } = await import("../lib/config-file.js"); + (getConfigFile as ReturnType).mockReturnValue({ dev: { id: "x" } }); + + const promptsModule = await import("prompts"); + // First inject is the "Overwrite?" prompt → user says no. + promptsModule.default.inject([false]); + + const { startConfig } = await import("./start-config.js"); + await expect(startConfig("dev", { token: undefined })).rejects.toThrow(/__exit:0/); }); - test("returns early when the config file is missing", async () => { + test("re-init with --force skips the overwrite confirm prompt", async () => { + detectInitStateMock.mockReturnValue(reInitState); const { getConfigFile } = await import("../lib/config-file.js"); (getConfigFile as ReturnType).mockReturnValue(undefined); const { startConfig } = await import("./start-config.js"); - await expect(startConfig("prod", { token: undefined, environment: undefined })).resolves.toBeUndefined(); + // No prompt injection — if a prompt fires, it would hang. With --force we expect + // it to skip the confirm and proceed (then exit early because getConfigFile returns undefined). + await expect(startConfig("dev", { token: "tok-1", force: true })).resolves.toBeUndefined(); }); - test("returns early when no token can be obtained", async () => { + test("--no-input + existing env without --force errors with the --force hint", async () => { + detectInitStateMock.mockReturnValue(reInitState); + + const { startConfig } = await import("./start-config.js"); + await expect(startConfig("dev", { token: "tok-1", input: false })).rejects.toThrow(); + const errArg = errorHandlerMock.mock.calls[0][0] as string; + expect(errArg).toContain("--force"); + expect(errArg).toContain("already exists"); + }); + + test("--no-input without --token errors before any work", async () => { + detectInitStateMock.mockReturnValue(freshState); const { getConfigFile } = await import("../lib/config-file.js"); - const { readToken } = await import("../lib/token.js"); - const { promptTextToEnter } = await import("../prompt/text-prompt.js"); + (getConfigFile as ReturnType).mockReturnValue({}); + + const { startConfig } = await import("./start-config.js"); + await expect(startConfig("dev", { input: false })).rejects.toThrow(); + const errArg = errorHandlerMock.mock.calls[0][0] as string; + expect(errArg).toContain("--no-input requires --token"); + }); - (getConfigFile as ReturnType).mockReturnValue({ analysisPath: "./src/analysis", buildPath: "./build" }); - (readToken as ReturnType).mockReturnValue(undefined); - (promptTextToEnter as ReturnType).mockResolvedValue("./src/analysis"); + test("--name flag overrides positional argument and emits a stderr note", async () => { + detectInitStateMock.mockReturnValue(reInitState); + const { startConfig } = await import("./start-config.js"); + // Re-init w/ --no-input should error after the env-name resolution; that's enough + // to exercise the override path. + await expect(startConfig("positional", { name: "fromflag", input: false })).rejects.toThrow(); - // We don't need to prompt the user for environment since we provide one. - // createEnvironmentToken -> user says no, returns undefined. - const promptsModule = await import("prompts"); - promptsModule.default.inject([false]); + // Detect was called with the flag value, not the positional. + expect(detectInitStateMock).toHaveBeenCalledWith("fromflag"); + }); + + test("uses default env 'dev' when neither positional nor --name is set", async () => { + detectInitStateMock.mockReturnValue(reInitState); + const { startConfig } = await import("./start-config.js"); + await expect(startConfig("", { input: false })).rejects.toThrow(); + + expect(detectInitStateMock).toHaveBeenCalledWith("dev"); + }); + test("invalid --scope value errors actionably", async () => { const { startConfig } = await import("./start-config.js"); - await expect(startConfig("prod", { token: undefined, environment: undefined })).resolves.toBeUndefined(); + await expect(startConfig("dev", { scope: "bogus" as never })).rejects.toThrow(); + const errArg = errorHandlerMock.mock.calls[0][0] as string; + expect(errArg).toContain("Invalid --scope"); + expect(errArg).toContain("'bogus'"); }); }); +// scanAnalysisFiles is exercised indirectly; the recursive walk itself is simple +// fs traversal, covered by the real-fs test below. describe("scanAnalysisFiles (indirect)", () => { let tmpRoot: string; @@ -83,9 +199,6 @@ describe("scanAnalysisFiles (indirect)", () => { mkdirSync(nested); writeFileSync(join(nested, "deep.js"), ""); - // Load the module to access scanAnalysisFiles indirectly: since it isn't exported, - // we rely on its behaviour being exercised by getAnalysisScripts. Here we assert the - // directory walk itself via the real fs functions we just set up. const { readdirSync, statSync } = await import("node:fs"); const items = readdirSync(tmpRoot); const collected: string[] = []; diff --git a/src/commands/start-config.ts b/src/commands/start-config.ts index 62e67a8..1194441 100644 --- a/src/commands/start-config.ts +++ b/src/commands/start-config.ts @@ -1,18 +1,40 @@ -import { readdirSync, statSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { Account, AnalysisInfo, AnalysisListItem, GenericModuleParams } from "@tago-io/sdk"; import kleur from "kleur"; import prompts, { Choice } from "prompts"; import stringComparison from "string-comparison"; import { getConfigFile, IEnvironment, writeConfigFileEnv, writeToConfigFile } from "../lib/config-file.js"; +import { detectInitState, InitState } from "../lib/init-state.js"; +import { banner, endStep, failStep, overwriteConfirmCopy, startStep, summaryBlock } from "../lib/init-summary.js"; import { errorHandler, highlightMSG, infoMSG } from "../lib/messages.js"; +import { globalConfigDir, setScopeOverride } from "../lib/resolve-scope.js"; import { readToken, writeToken } from "../lib/token.js"; import { promptTextToEnter } from "../prompt/text-prompt.js"; import { getTagoDeployURL, tagoLogin } from "./login.js"; +const DEFAULT_ENV = "dev"; +const DEFAULT_API_ENDPOINT = "https://api.tago.io"; +const DEFAULT_SSE_ENDPOINT = "https://sse.tago.io"; + interface ConfigOptions { - token: string | void; - environment: string | void; + /** Profile token; bypasses interactive login when set. */ + token?: string; + /** Alias for the positional [environment] argument. Flag wins on conflict. */ + name?: string; + /** Force local or global scope. Falls through to the decision tree if unset. */ + scope?: "local" | "global"; + /** API endpoint URL. Must be paired with `sseEndpoint`. */ + apiEndpoint?: string; + /** SSE endpoint URL. Must be paired with `apiEndpoint`. */ + sseEndpoint?: string; + /** + * Commander stores `--no-input` as `input: false` (negation flag), with the + * default being `true`. We normalize to a `noInput` boolean inside startConfig. + */ + input?: boolean; + /** Skip the existing-config overwrite confirmation. */ + force?: boolean; } interface AnalysisFile { @@ -22,12 +44,7 @@ interface AnalysisFile { const analysisPath = "./src/analysis"; -/** - * Recursively scans a directory and its subdirectories for analysis files. - * @param dirPath - The directory path to scan. - * @param basePath - The base analysis path for relative path calculation. - * @returns An array of file paths with their relative paths. - */ +/** Recursively scans a directory for `.ts`/`.js` analysis files. */ function scanAnalysisFiles(dirPath: string, basePath: string = dirPath): AnalysisFile[] { const files: AnalysisFile[] = []; const items = readdirSync(dirPath); @@ -47,42 +64,6 @@ function scanAnalysisFiles(dirPath: string, basePath: string = dirPath): Analysi return files; } -/** - * Creates a TagoIO environment token. - * @param environment - The name of the environment to create the token for. - * @returns The created token, or undefined if the user chooses not to login. - */ -async function createEnvironmentToken(environment: string) { - const { tryLogin } = await prompts({ - message: "Do you want to login and create a profile-token now?", - type: "confirm", - name: "tryLogin", - hint: "Press N to enter a token later", - }); - if (!tryLogin) { - return; - } - infoMSG(`You can create a token by running: ${highlightMSG("tagoio login")}`); - - const options = { - token: undefined, - tagoDeployUrl: undefined, - tagoDeploySse: undefined, - }; - await tagoLogin(environment, options); - - return { - profileToken: options.token, - tagoDeployUrl: options?.tagoDeployUrl, - tagoDeploySse: options?.tagoDeploySse, - }; -} - -/** - * Prompts the user to choose one or more analysis options from a list. - * @param analysisOptions - An array of analysis options to choose from. - * @returns An array of AnalysisInfo objects representing the user's selected options. - */ async function chooseAnalysis(analysisOptions: any[]) { const { response } = await prompts({ type: "autocompleteMultiselect", @@ -94,24 +75,15 @@ async function chooseAnalysis(analysisOptions: any[]) { return (response || []) as AnalysisInfo[]; } -/** - * Retrieves a list of analyses from the TagoIO account and prompts the user to select which ones to use. - * @param account - The TagoIO account object. - * @param oldList - An optional array of previously selected analyses. - * @returns An array of selected analyses with their IDs, names, and file names. - */ -async function getAnalysisList(account: Account, oldList: IEnvironment["analysisList"] = []) { +async function getAnalysisList(account: Account, oldList: NonNullable = []) { const analysisList = await account.analysis.list({ amount: 35, fields: ["id", "name", "tags"] }).catch(errorHandler); - if (!analysisList) { return []; } const getName = (analysis: AnalysisListItem<"id" | "name" | "tags">) => `[${analysis.id}] ${analysis.name}`; - const oldIDList = new Set(oldList.map((x) => x.id)); const configList: AnalysisListItem<"id" | "name" | "tags">[] = analysisList.filter((x) => oldIDList.has(x.id)); - const analysisOptions = analysisList.map((x) => ({ title: getName(x), selected: configList.some((y) => y.id === x.id), @@ -120,26 +92,21 @@ async function getAnalysisList(account: Account, oldList: IEnvironment["analysis const response = await chooseAnalysis(analysisOptions); const formatFileName = (x: string) => x.toLowerCase().replace(" ", "-"); - const analysisResult: IEnvironment["analysisList"] = response.map((x) => ({ + return response.map((x) => ({ fileName: formatFileName(x.name), name: x.name, id: x.id, ...oldList.find((old) => old.id === x.id), - })); - - return analysisResult; + })) as NonNullable; } -/** - * Searches for analysis scripts in the specified path and prompts the user to select a script for each analysis in the list. - * @param analysisList - The list of analyses to associate with scripts. - * @param analysisPath - The path to search for analysis scripts. - * @returns A Promise that resolves to the updated analysis list with the selected script file names. - */ -async function getAnalysisScripts(analysisList: IEnvironment["analysisList"], analysisPath: string) { - analysisPath = analysisPath.replace("./", ""); - infoMSG(`Searching for files at ${analysisPath} and subfolders`); - let files: Choice[] = scanAnalysisFiles(analysisPath).map((x) => ({ title: x.filename, value: x.filename, description: x.relativePath })); +async function getAnalysisScripts(analysisList: NonNullable, analysisPathInput: string) { + const cleaned = analysisPathInput.replace("./", ""); + if (!cleaned || !existsSync(cleaned)) { + infoMSG(`Analysis folder not found at "${cleaned || "(empty)"}"; skipping file matching.`); + return analysisList; + } + let files: Choice[] = scanAnalysisFiles(cleaned).map((x) => ({ title: x.filename, value: x.filename, description: x.relativePath })); for (const analysis of analysisList) { files = files.sort((a, b) => @@ -163,10 +130,9 @@ async function getAnalysisScripts(analysisList: IEnvironment["analysisList"], an const file = files.find((x) => x.value === response); analysis.fileName = file?.title as string; - if (file?.description && file?.description?.length > 0) { - analysis.path = file?.description as string; + if (file?.description && file.description.length > 0) { + analysis.path = file.description; } - const fileIndex = files.findIndex((x) => x.title === response); if (fileIndex !== -1) { files.splice(fileIndex, 1); @@ -175,92 +141,277 @@ async function getAnalysisScripts(analysisList: IEnvironment["analysisList"], an return analysisList; } +const SCHEMA_STUB = JSON.stringify({ $schema: "https://github.com/tago-io/tagoio-cli/blob/master/docs/schema.json" }); + +function bootstrapGlobalConfig(): void { + const globalRoot = globalConfigDir(); + mkdirSync(globalRoot, { recursive: true, mode: 0o700 }); + setScopeOverride("global"); + const globalConfigPath = join(globalRoot, "tagoconfig.json"); + if (!existsSync(globalConfigPath)) { + writeFileSync(globalConfigPath, SCHEMA_STUB, { encoding: "utf-8" }); + } +} + +function bootstrapLocalConfig(): void { + const localPath = join(process.cwd(), "tagoconfig.json"); + if (!existsSync(localPath)) { + writeFileSync(localPath, SCHEMA_STUB, { encoding: "utf-8" }); + } +} + +/** + * @description Resolves the env name following the precedence: + * 1. --name flag, 2. positional argument, 3. default "dev" + * Emits a stderr note when the flag overrides the positional value. + */ +function resolveEnvName(positional: string | undefined, flag: string | undefined): string { + if (flag && positional && flag !== positional) { + infoMSG(`--name overrides positional environment '${positional}'`); + } + return flag || positional || DEFAULT_ENV; +} + +/** + * @description Step 1: existing-env handling. Either prompts to confirm + * overwrite, hard-errors under --no-input, or returns silently when nothing + * needs confirming. + */ +async function handleExistingEnv(state: InitState, envName: string, options: ConfigOptions): Promise { + if (!state.envExists || options.force) { + return; + } + if ((options.input === false)) { + errorHandler( + `Configuration for env '${envName}' already exists at ${state.scope.configPath}. Pass --force to overwrite, or pick a different env name.`, + ); + } + process.stderr.write(`\n${overwriteConfirmCopy(state, envName)}\n\n`); + const { confirm } = await prompts({ + type: "confirm", + name: "confirm", + message: "Overwrite existing configuration?", + initial: false, + }); + if (confirm !== true) { + infoMSG("Cancelled. No changes made."); + process.exit(0); + } +} + +/** + * @description Resolves the target scope (Step 2). Honors --scope flag, then + * the existing-config decision tree from #4. Bootstraps the stub config file + * at the chosen scope so the rest of init has something to read/write. + */ +async function resolveTargetScope(options: ConfigOptions): Promise { + if (options.scope === "global") { + bootstrapGlobalConfig(); + return; + } + if (options.scope === "local") { + bootstrapLocalConfig(); + return; + } + // No flag: rely on the resolver. If neither config exists, prompt (or + // default to local under --no-input). + const initial = detectInitState("__probe__"); + if (initial.configExists) { + return; + } + if ((options.input === false)) { + bootstrapLocalConfig(); + return; + } + const { createGlobal } = await prompts({ + type: "confirm", + name: "createGlobal", + message: `No tagoconfig.json found in this directory or globally. Create a global configuration at ${globalConfigDir()}/tagoconfig.json?`, + initial: false, + }); + if (createGlobal) { + bootstrapGlobalConfig(); + } else { + bootstrapLocalConfig(); + } +} + /** - * Starts the configuration process for a TagoIO environment. - * @param environment The name of the environment to configure. - * @param options The configuration options. - * @param options.token The TagoIO token to use for authentication. + * @description The init flow, restructured per clig.dev: + * Step 0: pre-flight detection (silent) + * Step 1: banner + overwrite confirm + * Step 2: resolve inputs (flag → positional → existing → default) + * Step 3: execute stages with [..]/[OK] progress markers + * Step 4: state-change summary block */ -async function startConfig(environment: string, { token }: ConfigOptions) { - // Prompt user to enter environment name if not provided - if (!environment) { - ({ environment } = await prompts({ - message: "Enter a name for this environment (e.g dev): ", - type: "text", - name: "environment", - })); +async function startConfig(positional: string, options: ConfigOptions = {}): Promise { + if (options.scope && options.scope !== "local" && options.scope !== "global") { + errorHandler(`Invalid --scope value: '${options.scope}'. Use 'local' or 'global'.`); } - // Get config file or return if not found + const envName = resolveEnvName(positional, options.name); + + // Step 2 (early): bootstrap the chosen scope's tagoconfig.json. We need + // this before detectInitState() so envExists/tokenExists reflect the + // resolved scope and not the cwd-default fallback. + await resolveTargetScope(options); + + // Step 0: pre-flight detection. + const state = detectInitState(envName); + + // Step 1: banner + overwrite confirm. + process.stderr.write(`${banner(state.scope)}\n`); + await handleExistingEnv(state, envName, options); + + // Step 2 (continued): resolve token. --no-input requires -t. + if ((options.input === false) && !options.token && !state.tokenExists) { + errorHandler("--no-input requires --token when no existing lock file is on disk for this env."); + } + + const filesWritten: { path: string; description: string }[] = []; + + // Stage 1: project structure. + startStep("Creating project structure"); const configFile = getConfigFile(); if (!configFile) { + failStep("Creating project structure", "could not read or create tagoconfig.json"); return; } + endStep(`Created ${state.scope.configPath}`); + filesWritten.push({ path: state.scope.configPath, description: "project configuration" }); + + // Stage 2 + 3: authenticate + persist credentials. + // --api-endpoint and --sse-endpoint must be passed together (or neither). + // TagoIO Deploy installations have non-derivable subdomains, so we never + // try to infer one from the other. + if ((options.apiEndpoint && !options.sseEndpoint) || (!options.apiEndpoint && options.sseEndpoint)) { + errorHandler("--api-endpoint and --sse-endpoint must both be set together (or neither)."); + } - let tagoAPIURL, tagoSSEURL: string | undefined; + let token = options.token; + let tagoAPIURL = options.apiEndpoint; + let tagoSSEURL: string | undefined = options.sseEndpoint; - // Get token from file or prompt user to create one if (!token) { - token = readToken(environment); - if (!token) { - const data = await createEnvironmentToken(environment); - token = data?.profileToken; - tagoAPIURL = data?.tagoDeployUrl; - tagoSSEURL = data?.tagoDeploySse; - } else { - tagoAPIURL = configFile[environment]?.tagoAPIURL; - tagoSSEURL = configFile[environment]?.tagoSSEURL; + token = readToken(envName); + } + if (!token) { + if ((options.input === false)) { + errorHandler("--no-input requires --token for authentication."); + } + const data = await createEnvironmentToken(envName); + token = data?.profileToken; + tagoAPIURL = tagoAPIURL || data?.tagoDeployUrl; + tagoSSEURL = tagoSSEURL || data?.tagoDeploySse; + } else if (options.token) { + // Token came from the flag. Persist it to the lock file. + if (!(options.input === false) && !options.apiEndpoint) { + const urlConfig = await getTagoDeployURL(); + tagoAPIURL = urlConfig?.urlAPI || tagoAPIURL; + tagoSSEURL = urlConfig?.urlSSE || tagoSSEURL; } + writeToken(token, envName); + filesWritten.push({ path: `${state.scope.root}/.tago-lock.${envName}.lock`, description: "encrypted profile token" }); } else { - const urlConfig = await getTagoDeployURL(); - writeToken(token, environment); - tagoAPIURL = urlConfig?.urlAPI || ""; - tagoSSEURL = urlConfig?.urlSSE || ""; - } - - // Prompt user to enter analysis and build paths if not found in config file - if (!configFile.analysisPath) { - configFile.analysisPath = await promptTextToEnter(`Enter the path of your ${kleur.cyan("analysis")} folder: `, "./src/analysis"); - } - - if (!configFile.buildPath) { - configFile.buildPath = await promptTextToEnter(`Enter the path of your ${kleur.cyan("building")} folder (typescript): `, "./build"); + // Token already on disk; preserve URL settings from prior config. + tagoAPIURL = tagoAPIURL || configFile[envName]?.tagoAPIURL; + tagoSSEURL = tagoSSEURL || configFile[envName]?.tagoSSEURL; } - // Return if token is not found if (!token) { + infoMSG("Cancelled. No changes made."); return; } + // Local-only prompts for analysis paths. + if (state.scope.scope === "local") { + if (!configFile.analysisPath && !(options.input === false)) { + configFile.analysisPath = await promptTextToEnter(`Enter the path of your ${kleur.cyan("analysis")} folder: `, "./src/analysis"); + } + if (!configFile.buildPath && !(options.input === false)) { + configFile.buildPath = await promptTextToEnter(`Enter the path of your ${kleur.cyan("building")} folder (typescript): `, "./build"); + } + } + + // API call to fetch profile metadata. + startStep("Authenticating with TagoIO"); let region: GenericModuleParams["region"] = "us-e1"; if (tagoAPIURL) { - region = { - api: tagoAPIURL || "", - sse: tagoSSEURL || "", - }; + region = { api: tagoAPIURL, sse: tagoSSEURL || "" }; } - - // Get account info and analysis list const account = new Account({ token, region }); - const profile = await account.profiles.info("current"); - const accountInfo = await account.info().catch(errorHandler); - if (!accountInfo) { - return; + let profile; + let accountInfo; + try { + profile = await account.profiles.info("current"); + accountInfo = await account.info(); + } catch (err) { + failStep("Authenticating with TagoIO", err); + process.exit(1); } - let analysisList = await getAnalysisList(account, configFile[environment]?.analysisList); - analysisList = await getAnalysisScripts(analysisList, configFile.analysisPath); + endStep(`Authenticated as ${profile.info.name}`); - // Create new environment object and write to config file + // Stage 4: build the new env block. const newEnv: IEnvironment = { - analysisList: analysisList, id: profile.info.id, profileName: profile.info.name, email: accountInfo.email, - tagoSSEURL: tagoSSEURL, - tagoAPIURL: tagoAPIURL, + tagoSSEURL, + tagoAPIURL, }; + if (state.scope.scope === "local") { + if ((options.input === false)) { + // Non-interactive: preserve whatever analysisList is already on disk + // (re-init keeps it; fresh init starts empty). The user can populate it + // by editing tagoconfig.json or rerunning init interactively. + newEnv.analysisList = configFile[envName]?.analysisList ?? []; + } else { + let analysisList = await getAnalysisList(account, configFile[envName]?.analysisList); + analysisList = await getAnalysisScripts(analysisList, configFile.analysisPath); + newEnv.analysisList = analysisList; + } + } + startStep("Setting up environment"); writeToConfigFile(configFile); - writeConfigFileEnv(environment, newEnv); + writeConfigFileEnv(envName, newEnv); + endStep("Environment ready"); + filesWritten.push({ path: state.scope.envFilePath, description: "active environment marker" }); + + // Step 4: state-change summary. + process.stderr.write(`\n${summaryBlock({ + filesWritten, + scope: state.scope.scope, + envName, + profileName: profile.info.name, + apiEndpoint: tagoAPIURL || DEFAULT_API_ENDPOINT, + sseEndpoint: tagoSSEURL || DEFAULT_SSE_ENDPOINT, + })}\n`); +} + +/** + * @description Helper used by interactive flows when no token is on disk and + * the user has not passed `-t`. Prompts for the login flow. + */ +async function createEnvironmentToken(environment: string) { + const { tryLogin } = await prompts({ + message: "Do you want to login and create a profile-token now?", + type: "confirm", + name: "tryLogin", + hint: "Press N to enter a token later", + }); + if (!tryLogin) { + return; + } + infoMSG(`You can create a token by running: ${highlightMSG("tagoio login")}`); + + const opts = { token: undefined, tagoDeployUrl: undefined, tagoDeploySse: undefined }; + await tagoLogin(environment, opts); + + return { + profileToken: opts.token, + tagoDeployUrl: opts.tagoDeployUrl, + tagoDeploySse: opts.tagoDeploySse, + }; } export { startConfig }; diff --git a/src/commands/whoami.test.ts b/src/commands/whoami.test.ts new file mode 100644 index 0000000..fb0c79a --- /dev/null +++ b/src/commands/whoami.test.ts @@ -0,0 +1,178 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { ResolvedScope } from "../lib/resolve-scope.js"; + +const readFileSyncMock = vi.fn(); +const resolveScopeMock = vi.fn<() => ResolvedScope>(); +const readTokenMock = vi.fn(); +const errorHandlerMock = vi.fn<(str: unknown) => never>(() => { + throw new Error("errorHandler called"); +}); + +vi.mock("node:fs", () => ({ + readFileSync: readFileSyncMock, +})); + +vi.mock("../lib/resolve-scope.js", () => ({ + resolveScope: () => resolveScopeMock(), +})); + +vi.mock("../lib/token.js", () => ({ + readToken: readTokenMock, +})); + +vi.mock("../lib/messages.js", () => ({ + errorHandler: errorHandlerMock, +})); + +const localScope: ResolvedScope = { + scope: "local", + root: "/repo", + configPath: "/repo/tagoconfig.json", + envFilePath: "/repo/.tagoio/personal.env", + configExists: true, +}; + +const globalScope: ResolvedScope = { + scope: "global", + root: "/home/user/.config/tagoio", + configPath: "/home/user/.config/tagoio/tagoconfig.json", + envFilePath: "/home/user/.config/tagoio/.tagoio/personal.env", + configExists: true, +}; + +const sampleConfig = { + default: "prod", + prod: { + id: "65f8320d-cafe-cafe-cafe-cafecafecafe", + profileName: "Tago Production", + email: "user@tago.io", + }, +}; + +const KNOWN_TOKEN_UUID = "11111111-2222-3333-4444-555555555555"; + +describe("whoami", () => { + let stdoutSpy: ReturnType; + let stderrSpy: ReturnType; + let consoleTableSpy: ReturnType; + + beforeEach(() => { + readFileSyncMock.mockReset().mockReturnValue(JSON.stringify(sampleConfig)); + resolveScopeMock.mockReset().mockReturnValue(localScope); + readTokenMock.mockReset().mockReturnValue(KNOWN_TOKEN_UUID); + errorHandlerMock.mockClear(); + process.env.TAGOIO_DEFAULT = "prod"; + stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + consoleTableSpy = vi.spyOn(console, "table").mockImplementation(() => undefined); + }); + + afterEach(() => { + delete process.env.TAGOIO_DEFAULT; + vi.restoreAllMocks(); + }); + + test("--json prints a single JSON object on stdout with all seven fields", async () => { + const { whoami } = await import("./whoami.js"); + await whoami({ json: true }); + + expect(stdoutSpy).toHaveBeenCalledOnce(); + const written = stdoutSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(written); + expect(parsed).toEqual({ + scope: "local", + loadedFrom: "/repo/tagoconfig.json", + activeEnv: "prod", + profileId: "65f8320d-cafe-cafe-cafe-cafecafecafe", + profileName: "Tago Production", + email: "user@tago.io", + token: "loaded", + }); + }); + + test("default form prints a 7-row table on stdout via console.table", async () => { + const { whoami } = await import("./whoami.js"); + await whoami(); + + expect(consoleTableSpy).toHaveBeenCalledOnce(); + const tableArg = consoleTableSpy.mock.calls[0][0] as Record; + expect(Object.keys(tableArg)).toEqual([ + "Scope", + "Loaded from", + "Active env", + "Profile ID", + "Profile name", + "Email", + "Token", + ]); + expect(tableArg.Token).toBe("loaded"); + }); + + test("Token field reports 'missing' when readToken returns undefined", async () => { + readTokenMock.mockReturnValue(undefined); + const { whoami } = await import("./whoami.js"); + await whoami({ json: true }); + const parsed = JSON.parse(stdoutSpy.mock.calls[0][0] as string); + expect(parsed.token).toBe("missing"); + }); + + test("S3 — token UUID never appears in stdout or stderr (plain output)", async () => { + readTokenMock.mockReturnValue(KNOWN_TOKEN_UUID); + const { whoami } = await import("./whoami.js"); + await whoami(); + + const allOutput = + stdoutSpy.mock.calls.map((c: unknown[]) => String(c[0])).join("") + + stderrSpy.mock.calls.map((c: unknown[]) => String(c[0])).join("") + + consoleTableSpy.mock.calls.map((c: unknown[]) => JSON.stringify(c[0])).join(""); + expect(allOutput).not.toContain(KNOWN_TOKEN_UUID); + }); + + test("S3 — token UUID never appears in --json output", async () => { + readTokenMock.mockReturnValue(KNOWN_TOKEN_UUID); + const { whoami } = await import("./whoami.js"); + await whoami({ json: true }); + + const allOutput = + stdoutSpy.mock.calls.map((c: unknown[]) => String(c[0])).join("") + + stderrSpy.mock.calls.map((c: unknown[]) => String(c[0])).join(""); + expect(allOutput).not.toContain(KNOWN_TOKEN_UUID); + }); + + test("falls back to '(none)' / 'N/A' when no env is selected", async () => { + delete process.env.TAGOIO_DEFAULT; + const { whoami } = await import("./whoami.js"); + await whoami({ json: true }); + const parsed = JSON.parse(stdoutSpy.mock.calls[0][0] as string); + expect(parsed.activeEnv).toBe("(none)"); + expect(parsed.profileId).toBe("N/A"); + expect(parsed.profileName).toBe("N/A"); + expect(parsed.email).toBe("N/A"); + expect(parsed.token).toBe("missing"); + }); + + test("scope=global is reflected in the output", async () => { + resolveScopeMock.mockReturnValue(globalScope); + const { whoami } = await import("./whoami.js"); + await whoami({ json: true }); + const parsed = JSON.parse(stdoutSpy.mock.calls[0][0] as string); + expect(parsed.scope).toBe("global"); + expect(parsed.loadedFrom).toBe("/home/user/.config/tagoio/tagoconfig.json"); + }); + + test("errors with init hint when global scope and no config file exists", async () => { + resolveScopeMock.mockReturnValue({ ...globalScope, configExists: false }); + const { whoami } = await import("./whoami.js"); + await expect(whoami()).rejects.toThrow(); + expect(errorHandlerMock).toHaveBeenCalledOnce(); + expect(errorHandlerMock.mock.calls[0][0]).toContain("tagoio init --scope global"); + }); + + test("errors with init hint when local scope and no config file exists", async () => { + resolveScopeMock.mockReturnValue({ ...localScope, configExists: false }); + const { whoami } = await import("./whoami.js"); + await expect(whoami()).rejects.toThrow(); + expect(errorHandlerMock.mock.calls[0][0]).toContain("tagoio init"); + }); +}); diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts new file mode 100644 index 0000000..df77305 --- /dev/null +++ b/src/commands/whoami.ts @@ -0,0 +1,88 @@ +import { readFileSync } from "node:fs"; + +import { errorHandler } from "../lib/messages.js"; +import { resolveScope } from "../lib/resolve-scope.js"; +import { readToken } from "../lib/token.js"; + +interface WhoamiOptions { + json?: boolean; +} + +interface WhoamiPayload { + scope: "local" | "global"; + loadedFrom: string; + activeEnv: string; + profileId: string; + profileName: string; + email: string; + /** S3: hard-coded enum — never the token bytes. */ + token: "loaded" | "missing"; +} + +/** + * @description Prints the active profile (scope, loaded path, env, identity, + * and whether a token is present) without making any API call. + * + * Data goes to stdout; status goes to stderr (clig.dev). With `--json`, stdout + * is a single JSON object so it pipes cleanly through `jq`. + * + * S3: the Token field is hard-coded to `"loaded"` / `"missing"`. The token + * bytes are never read into the output payload. + */ +async function whoami(options: WhoamiOptions = {}): Promise { + const scope = resolveScope(); + + if (!scope.configExists) { + if (scope.scope === "global") { + errorHandler(`No tagoconfig.json found. Run \`tagoio init --scope global\` to create one, or cd into a project directory.`); + } else { + errorHandler(`No tagoconfig.json found at ${scope.configPath}. Run \`tagoio init\` to create one.`); + } + } + + let configRaw: string; + try { + configRaw = readFileSync(scope.configPath, { encoding: "utf-8" }); + } catch (err) { + errorHandler(`Failed to read ${scope.configPath}: ${(err as Error).message}`); + } + + let config: Record; + try { + config = JSON.parse(configRaw); + } catch (err) { + errorHandler(`Failed to parse ${scope.configPath}: ${(err as Error).message}`); + } + + const activeEnv = process.env.TAGOIO_DEFAULT ?? ""; + const envBlock = (activeEnv && (config[activeEnv] as Record)) || {}; + + const payload: WhoamiPayload = { + scope: scope.scope, + loadedFrom: scope.configPath, + activeEnv: activeEnv || "(none)", + profileId: (envBlock.id as string) || "N/A", + profileName: (envBlock.profileName as string) || "N/A", + email: (envBlock.email as string) || "N/A", + token: activeEnv && readToken(activeEnv) ? "loaded" : "missing", + }; + + if (options.json) { + process.stdout.write(`${JSON.stringify(payload)}\n`); + return; + } + + // Human form: a 2-column table on stdout. Built with console.table so the + // labels stay aligned without manual padding. + console.table({ + Scope: payload.scope, + "Loaded from": payload.loadedFrom, + "Active env": payload.activeEnv, + "Profile ID": payload.profileId, + "Profile name": payload.profileName, + Email: payload.email, + Token: payload.token, + }); +} + +export { whoami, WhoamiOptions, WhoamiPayload }; diff --git a/src/index.ts b/src/index.ts index f12c6fc..d8bf34f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,9 @@ #!/usr/bin/env node import { Command } from "commander"; import dotenv from "dotenv"; -import { readFileSync } from "node:fs"; +import { readFileSync, realpathSync } from "node:fs"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import kleur from "kleur"; import { analysisCommands } from "./commands/analysis/index.js"; @@ -13,30 +14,21 @@ import { tagoLogin } from "./commands/login.js"; import { profileCommands } from "./commands/profile/index.js"; import { setEnvironment } from "./commands/set-env.js"; import { startConfig } from "./commands/start-config.js"; +import { whoami } from "./commands/whoami.js"; import { getConfigFile } from "./lib/config-file.js"; import { configureHelp } from "./lib/configure-help.js"; -import { ENV_FILE_PATH } from "./lib/dotenv-config.js"; +import { getEnvFilePath } from "./lib/dotenv-config.js"; import { highlightMSG } from "./lib/messages.js"; import { updater } from "./lib/notify-update.js"; +import { resolveScope } from "./lib/resolve-scope.js"; +import { maybeShowScopeNotice } from "./lib/scope-notice.js"; const packageJSON = JSON.parse(readFileSync(join(import.meta.dirname, "..", "package.json"), "utf8")); -dotenv.config({ path: ENV_FILE_PATH, quiet: true }); +dotenv.config({ path: getEnvFilePath(), quiet: true }); const indexConfigFile = getConfigFile(); const defaultEnvironment = process.env.TAGOIO_DEFAULT || ""; -/** - * Calls functions to add all available commands to the CLI program. - * @param program - The CLI program to add commands to. - * @returns A Promise that resolves when all commands have been added. - */ -async function getAllCommands(program: Command) { - analysisCommands(program); - deviceCommands(program); - dashboardCommands(program); - profileCommands(program, defaultEnvironment); -} - /** * Returns a string with ANSI escape codes to display text in red. * @@ -49,21 +41,29 @@ function errorColor(str: string) { } /** - * Initializes the TagoIO Command Line Tools program and sets up the available commands. - * @returns {Promise} A Promise that resolves when the program has finished parsing the command line arguments. + * @description Builds the commander program with every top-level command + * (init / login / set-env / list-env) and every namespace (analysis, + * devices, dashboard, profile) registered. Returns the configured program + * without calling `program.parse()`. + * + * This is the single source of truth for the CLI's command surface. Both the + * runtime entry point (`initiateCMD`) and the man-page generator + * (`src/lib/generate-man.ts`) call it — adding a new command in this file + * automatically appears in `tagoio --help` and in `man tagoio` on the next + * `npm run man`. + * + * @param defaultEnv - Default value for the optional `[environment]` + * argument on `init` and `login`. Pass `""` for a user-agnostic build + * (e.g. man-page generation); pass the runtime selection otherwise. */ -async function initiateCMD() { - const updateLog = await updater({ name: packageJSON.name, version: packageJSON.version }); +function buildProgram(defaultEnv: string): Command { const program = new Command(); - program.exitOverride(async () => { - updateLog(); - }); program.version(packageJSON.version).description(`${kleur.bold(`TagoIO Command Line Tools - v${packageJSON.version}`)} - \tDefault Environment: ${highlightMSG(defaultEnvironment)} - \tProfile ID: ${highlightMSG(indexConfigFile?.[defaultEnvironment]?.id || "N/A")} - \tName: ${highlightMSG(indexConfigFile?.[defaultEnvironment]?.profileName || "N/A")} - \tEmail: ${highlightMSG(indexConfigFile?.[defaultEnvironment]?.email || "N/A")}`); + \tDefault Environment: ${highlightMSG(defaultEnv)} + \tProfile ID: ${highlightMSG(indexConfigFile?.[defaultEnv]?.id || "N/A")} + \tName: ${highlightMSG(indexConfigFile?.[defaultEnv]?.profileName || "N/A")} + \tEmail: ${highlightMSG(indexConfigFile?.[defaultEnv]?.email || "N/A")}`); program.configureOutput({ writeErr: (str) => process.stderr.write(`[${errorColor("ERROR")}] ${str}`), @@ -74,27 +74,37 @@ async function initiateCMD() { program .command("init") .description("create/update the config file for analysis in your current folder") - .argument("[environment]", "name of the environment.", defaultEnvironment) + .argument("[environment]", "name of the environment", defaultEnv || "dev") .option("-t, --token ", "profile token of the environment and skip login step") + .option("--name ", "env name (alias for [environment]); flag wins if both are passed") + .option("--scope ", "force a specific profile scope: 'local' or 'global'") + .option("--api-endpoint ", "API endpoint URL (must be paired with --sse-endpoint)") + .option("--sse-endpoint ", "SSE endpoint URL (must be paired with --api-endpoint)") + .option("--no-input", "fail instead of prompting; --token required for authentication") + .option("--force", "skip the existing-configuration overwrite confirmation") .action(startConfig) .addHelpText( "after", ` - Note: If you don't store credentials in this command, you must run tagoio login + Note: If you don't store credentials with -t, you must run tagoio login. Example: $ tagoio init + $ tagoio init prod $ tagoio init -t eb8a1d42-0f28-4ee7-9862-839920eb1cb0 - $ tagoio init --env dev`, + $ tagoio init --scope global + $ tagoio init --no-input --name dev --scope local --api-endpoint https://api.tago.io --sse-endpoint https://sse.tago.io -t TOKEN + $ tagoio init prod --force`, ); program .command("login") .description("login to your account and store profile_token in the tago-lock.") - .argument("[environment]", "name of the environment", defaultEnvironment) + .argument("[environment]", "name of the environment", defaultEnv || "dev") .option("-u, --email ", "your TagoIO email") .option("-p, --password ", "your TagoIO password") .option("-t, --token ", "set a profile-token for the environment and skip login step") + .option("--scope ", "force a specific profile scope: 'local' or 'global'") .action(tagoLogin) .addHelpText( "after", @@ -104,7 +114,8 @@ Example: Example: $ tagoio login $ tagoio login -u tago@tago.io -p 12345678 - $ tagoio login -t eb8a1d42-0f28-4ee7-9862-839920eb1cb0`, + $ tagoio login -t eb8a1d42-0f28-4ee7-9862-839920eb1cb0 + $ tagoio login --scope global`, ); program @@ -131,9 +142,57 @@ Example: $ tagoio list-env`, ); - await getAllCommands(program); + program + .command("whoami") + .description("show the active profile, scope, and config path (offline)") + .option("--json", "emit a single JSON object on stdout for machine readers") + .action(whoami) + .addHelpText( + "after", + ` +Example: + $ tagoio whoami + $ tagoio whoami --json | jq .scope`, + ); + + analysisCommands(program); + deviceCommands(program); + dashboardCommands(program); + profileCommands(program, defaultEnv); + + return program; +} +/** + * Initializes the TagoIO Command Line Tools program and parses argv. + */ +async function initiateCMD() { + maybeShowScopeNotice(resolveScope()); + const updateLog = await updater({ name: packageJSON.name, version: packageJSON.version }); + const program = buildProgram(defaultEnvironment); + program.exitOverride(async () => { + updateLog(); + }); program.parse(); } -initiateCMD().catch(console.error); +// Auto-run only when invoked as the CLI binary; importing this module from a +// build-time tool (e.g. the man-page generator) must NOT trigger argv parsing. +// `import.meta.url` is always a realpath; `process.argv[1]` may be a symlink +// (npm link, global installs). Compare on realpath so the binary still +// auto-starts when invoked through the npm-managed bin shim. +function isInvokedAsCLI(): boolean { + if (!process.argv[1]) { + return false; + } + try { + return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); + } catch { + return false; + } +} +if (isInvokedAsCLI()) { + initiateCMD().catch(console.error); +} + +export { buildProgram }; diff --git a/src/lib/__snapshots__/generate-man.test.ts.snap b/src/lib/__snapshots__/generate-man.test.ts.snap new file mode 100644 index 0000000..24b00e3 --- /dev/null +++ b/src/lib/__snapshots__/generate-man.test.ts.snap @@ -0,0 +1,538 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`generateManPage > walks the populated program tree and emits the expected roff document 1`] = ` +".\\" Generated by scripts/generate-man.ts. Do not edit by hand. +.TH TAGOIO 1 "" "tagoio 3.2.0" "User Commands" + +.SH NAME +tagoio \\- command\\-line tool for TagoIO + +.SH SYNOPSIS +.B tagoio +[\\fIglobal options\\fR] +\\fIcommand\\fR +[\\fIcommand options\\fR] +[\\fIarguments...\\fR] + +.SH DESCRIPTION +The TagoIO Command Line Tool is the official command\\-line +interface to TagoIO. It manages analyses, devices, dashboards, +and user profiles, and can export entire applications between +profiles \\- suitable for both interactive use and CI/CD pipelines. + +.SH GLOBAL OPTIONS +.TP +.BR \\-V , " \\-\\-version" +Output the version number. +.TP +.BR \\-h , " \\-\\-help" +Display help for command. + +.SH COMMANDS +.SS init [environment] +create/update the config file for analysis in your current folder +.TP +\\fB\\-t\\fR, \\fB\\-\\-token\\fR \\fI\\fR +profile token of the environment and skip login step +.TP +\\fB\\-\\-name\\fR \\fI\\fR +env name (alias for [environment]); flag wins if both are passed +.TP +\\fB\\-\\-scope\\fR \\fI\\fR +force a specific profile scope: 'local' or 'global' +.TP +\\fB\\-\\-api\\-endpoint\\fR \\fI\\fR +API endpoint URL (must be paired with \\-\\-sse\\-endpoint) +.TP +\\fB\\-\\-sse\\-endpoint\\fR \\fI\\fR +SSE endpoint URL (must be paired with \\-\\-api\\-endpoint) +.TP +\\fB\\-\\-no\\-input\\fR +fail instead of prompting; \\-\\-token required for authentication +.TP +\\fB\\-\\-force\\fR +skip the existing\\-configuration overwrite confirmation +.PP +.nf + Note: If you don't store credentials with \\-t, you must run tagoio login. + +Example: + $ tagoio init + $ tagoio init prod + $ tagoio init \\-t eb8a1d42\\-0f28\\-4ee7\\-9862\\-839920eb1cb0 + $ tagoio init \\-\\-scope global + $ tagoio init \\-\\-no\\-input \\-\\-name dev \\-\\-scope local \\-\\-api\\-endpoint https://api.tago.io \\-\\-sse\\-endpoint https://sse.tago.io \\-t TOKEN + $ tagoio init prod \\-\\-force +.fi +.SS login [environment] +login to your account and store profile_token in the tago\\-lock. +.TP +\\fB\\-u\\fR, \\fB\\-\\-email\\fR \\fI\\fR +your TagoIO email +.TP +\\fB\\-p\\fR, \\fB\\-\\-password\\fR \\fI\\fR +your TagoIO password +.TP +\\fB\\-t\\fR, \\fB\\-\\-token\\fR \\fI\\fR +set a profile\\-token for the environment and skip login step +.TP +\\fB\\-\\-scope\\fR \\fI\\fR +force a specific profile scope: 'local' or 'global' +.PP +.nf + Note: No need to login again if you already stored credentials with tagoio init + +Example: + $ tagoio login + $ tagoio login \\-u tago@tago.io \\-p 12345678 + $ tagoio login \\-t eb8a1d42\\-0f28\\-4ee7\\-9862\\-839920eb1cb0 + $ tagoio login \\-\\-scope global +.fi +.SS set\\-env [environment] +set your default environment from tagoconfig.ts +.PP +.nf +Example: + $ tagoio set\\-env + $ tagoio set\\-env dev +.fi +.SS list\\-env +list all your environment and show current default +.PP +.nf +Example: + $ tagoio list\\-env +.fi +.SS whoami +show the active profile, scope, and config path (offline) +.TP +\\fB\\-\\-json\\fR +emit a single JSON object on stdout for machine readers +.PP +.nf +Example: + $ tagoio whoami + $ tagoio whoami \\-\\-json | jq .scope +.fi +.SS analysis\\-deploy [name] +deploy your analysis to TagoIO + Analysis must be registered in your tagoconfig.ts file first + You can register an analysis by using tagoio init +.TP +\\fB\\-\\-env\\fR, \\fB\\-\\-environment\\fR \\fI[environment]\\fR +environment from config.js +.TP +\\fB\\-s\\fR, \\fB\\-\\-silent\\fR +will not prompt to confirm the deploy +.TP +\\fB\\-\\-deno\\fR +Force build for Deno runtime (default: false) +.TP +\\fB\\-\\-node\\fR +Force build for Node.js runtime (default: false) +.TP +\\fB\\-\\-all\\fR +deploy every analysis from tagoconfig.json without prompting (default: false) +.TP +\\fB\\-t\\fR, \\fB\\-\\-token\\fR \\fI\\fR +profile token for this run (bypasses lock file, for CI/CD) +.PP +.nf +Example: + $ tagoio deploy dashboard\\-handler + $ tagoio deploy dashboard\\-handler \\-\\-deno + $ tagoio deploy dashboard\\-handler \\-\\-node + $ tagoio deploy \\-\\-all # deploy every analysis from tagoconfig.json + $ tagoio deploy \\-\\-all \\-\\-env stage # deploy all to the stage environment + $ tagoio deploy \\-\\-all \\-\\-env prod \\-t $TAGOIO_TOKEN \\-\\-silent # pipeline\\-friendly: no prompts, no lock file needed + $ tagoio deploy \\-\\-node + $ tagoio deploy \\-\\-deno +.fi +.SS analysis\\-run [name] +run your TagoIO analysis from your machine. + If name is not provided, you will be prompted to select which analysis you want to run. + + Note: Analysis will automatically be edited to run in external at TagoIO side. + To change it back to run at TagoIO, use tagoio am +.TP +\\fB\\-\\-env\\fR, \\fB\\-\\-environment\\fR \\fI[environment]\\fR +environment from config.js +.TP +\\fB\\-d\\fR, \\fB\\-\\-debug\\fR +run with \\-\\-inspector for debug +.TP +\\fB\\-c\\fR, \\fB\\-\\-clear\\fR +Will clear screen on restart +.TP +\\fB\\-\\-tsnd\\fR +run with ts\\-node\\-dev if installed globally +.TP +\\fB\\-\\-deno\\fR +Force build for Deno runtime (default: false) +.TP +\\fB\\-\\-node\\fR +Force build for Node.js runtime (default: false) +.PP +.nf +Example: + $ tagoio run dashboard\\-handler + $ tagoio run dash + $ tagoio run dashboard\\-handler \\-d + $ tagoio run dashboard\\-handler \\-d \\-c + $ tagoio run dashboard\\-handler \\-\\-deno + $ tagoio run dashboard\\-handler \\-\\-node + $ tagoio run \\-\\-deno + $ tagoio run \\-\\-node +.fi +.SS analysis\\-trigger [name] +send a signal to trigger your analysis TagoIO +.TP +\\fB\\-\\-json\\fR \\fI[JSON]\\fR +JSON to be used in scope +.TP +\\fB\\-\\-tago\\fR +pick analysis directly from TagoIO list +.TP +\\fB\\-\\-env\\fR, \\fB\\-\\-environment\\fR \\fI[environment]\\fR +environment from config.js +.PP +.nf +Example: + $ tagoio analysis\\-trigger dash + $ tagoio analysis\\-trigger dash \\-\\-json "[{"variable":"test"}]" +.fi +.SS analysis\\-console [name] +connect to your Analysis Console +.TP +\\fB\\-\\-env\\fR, \\fB\\-\\-environment\\fR \\fI[environment]\\fR +environment from config.js +.PP +.nf +Example: + $ tagoio analysis\\-console 62151835435d540010b768c4 +.fi +.SS analysis\\-duplicate [ID] +duplicate your Analysis +.TP +\\fB\\-\\-env\\fR, \\fB\\-\\-environment\\fR \\fI[environment]\\fR +environment from config.js +.TP +\\fB\\-\\-name\\fR \\fI[string]\\fR +new name for the Analysis +.PP +.nf +Example: + $ tagoio analysis\\-duplicate 62151835435d540010b768c4 \\-\\-name "Duplicated Analysis" +.fi +.SS analysis\\-mode [name] +change an analysis or group of analysis to run on tago/external + + If name is not provided, you will be prompted to select which analysis you want to update. + Analysis in external mode are displayed first. +.TP +\\fB\\-\\-env\\fR, \\fB\\-\\-environment\\fR \\fI[environment]\\fR +environment from config.js +.TP +\\fB\\-f\\fR, \\fB\\-\\-filterMode\\fR \\fI[external/tago]\\fR +show only analysis in external/tago +.TP +\\fB\\-m\\fR, \\fB\\-\\-mode\\fR \\fI[external/tago]\\fR +set as external or tago +.PP +.nf +Example: + $ tagoio analysis\\-duplicate 62151835435d540010b768c4 \\-\\-name "Duplicated Analysis" +.fi +.SS device\\-inspector [ID/Token] +connect to your Device Live Inspector +.TP +\\fB\\-\\-env\\fR \\fI[environment]\\fR +environment from config.js +.PP +.nf +Example: + $ tagoio device\\-inspector 62151835435d540010b768c4 + $ tagoio device\\-inspector 62151835435d540010b768c4 \\-\\-env dev +.fi +.SS device\\-info [ID/Token] +get information about a device and it's configuration parameters. +.TP +\\fB\\-\\-env\\fR, \\fB\\-\\-environment\\fR \\fI[environment]\\fR +environment from config.js +.TP +\\fB\\-\\-json\\fR +return json list +.TP +\\fB\\-\\-raw\\fR +get object the same as stored +.TP +\\fB\\-t\\fR, \\fB\\-\\-tokens\\fR +get tokens +.PP +.nf +Example: + $ tagoio device\\-info 62151835435d540010b768c4 +.fi +.SS device\\-list +get the list of devices. +.TP +\\fB\\-\\-env\\fR, \\fB\\-\\-environment\\fR \\fI[environment]\\fR +environment from config.js +.TP +\\fB\\-n\\fR, \\fB\\-\\-name\\fR \\fI[deviceName]\\fR +partial name of the device name +.TP +\\fB\\-k\\fR, \\fB\\-\\-tagkey\\fR \\fI[key]\\fR +tag key to filter in (default: []) +.TP +\\fB\\-v\\fR, \\fB\\-\\-tagvalue\\fR \\fI[value]\\fR +tag value to filter in (default: []) +.TP +\\fB\\-s\\fR, \\fB\\-\\-stringify\\fR +return list as text +.TP +\\fB\\-\\-tags\\fR +display tags +.TP +\\fB\\-\\-json\\fR +return json list +.TP +\\fB\\-\\-raw\\fR +get object the same as stored +.PP +.nf +Example: + $ tagoio device\\-list + $ tagoio device\\-list \\-\\-name Device \\-s + $ tagoio device\\-list \\-t device_type \\-v sensor +.fi +.SS data [ID/Token] +get data from a device. +.TP +\\fB\\-\\-env\\fR, \\fB\\-\\-environment\\fR \\fI[environment]\\fR +environment from config.js +.TP +\\fB\\-g\\fR, \\fB\\-\\-group\\fR \\fI\\fR +Filter by group +.TP +\\fB\\-\\-qty\\fR \\fI\\fR +Request a given set amount of data (default: 15) +.TP +\\fB\\-\\-start\\-date\\fR \\fI\\fR +Get data after date +.TP +\\fB\\-\\-end\\-date\\fR \\fI\\fR +Get data previous of date +.TP +\\fB\\-q\\fR, \\fB\\-\\-query\\fR \\fI[queryType]\\fR +Perform an specific query +.TP +\\fB\\-\\-json\\fR +return json list +.TP +\\fB\\-\\-stringify\\fR +return as text +.TP +\\fB\\-p\\fR, \\fB\\-\\-post\\fR \\fI\\fR +send data to the device +.TP +\\fB\\-v\\fR, \\fB\\-\\-var\\fR \\fI\\fR +Filter by variable (default: []) +.PP +.nf +Example: + $ tagoio data + $ tagoio data \\-v temperature \\-qty 1 \\-\\-json + $ tagoio data 62151835435d540010b768c4 \\-\\-post '{ "variable": "temperature", "value": 32 }' + $ tagoio data 62151835435d540010b768c4 + $ tagoio data 62151835435d540010b768c4 \\-v temperature \\-qty 1 +.fi +.SS device\\-backup [ID/Token] +backup data from a Device. Store it on TagoIO Cloud by default +.TP +\\fB\\-\\-env\\fR, \\fB\\-\\-environment\\fR \\fI[environment]\\fR +environment from config.js +.TP +\\fB\\-\\-local\\fR +store file locally +.TP +\\fB\\-\\-restore\\fR +restore a backup file +.PP +.nf +Example: + $ tagoio bkp + $ tagoio bkp 62151835435d540010b768c4 + $ tagoio bkp 62151835435d540010b768c4 \\-\\-local +.fi +.SS device\\-network [ID/Token] +change the device network and/or connector +.TP +\\fB\\-n\\fR, \\fB\\-\\-networkID\\fR \\fI\\fR +network ID +.TP +\\fB\\-c\\fR, \\fB\\-\\-connectorID\\fR \\fI[connector ID]\\fR +connector ID +.TP +\\fB\\-\\-env\\fR, \\fB\\-\\-environment\\fR \\fI[environment]\\fR +environment from config.js +.PP +.nf +Example: + $ tagoio device\\-network 62151835435d540010b768c4 \\-n 62151835435d540010b768c4 \\-c 62151835435d540010b768c4 + $ tagoio nc 62151835435d540010b768c4 \\-n 62151835435d540010b768c4 \\-c 62151835435d540010b768c4 +.fi +.SS device\\-type [ID/Token] +change the bucket type to immutable or mutable +.TP +\\fB\\-\\-env\\fR, \\fB\\-\\-environment\\fR \\fI[environment]\\fR +environment from config.js +.PP +.nf + It's Recommended to backup data before changing the type, using: + \\- tagoio bkp + Then restore the data after changing the type, using: + \\- tagoio bkp \\-\\-restore + + Example: + $ tagoio device\\-type + $ tagoio device\\-type 62151835435d540010b768c4 +.fi +.SS device\\-copy +copy data from one device to another +.TP +\\fB\\-\\-from\\fR \\fI[token/id]\\fR +token/id of the device where data will be copied from +.TP +\\fB\\-\\-to\\fR \\fI[token/id]\\fR +token/id of the device where data will be copied to +.TP +\\fB\\-\\-qty\\fR \\fI\\fR +amount of data to be copy (default: 10000) +.TP +\\fB\\-\\-env\\fR, \\fB\\-\\-environment\\fR \\fI[environment]\\fR +environment from config.js +.PP +.nf +Example: + $ tagoio device\\-copy + $ tagoio device\\-copy \\-\\-to 62151835435d540010b768c4 \\-\\-from 78151835435d540010b768c4 +.fi +.SS copy\\-tab [dashboardID] +copy a tab of a dashboard to another tab +.TP +\\fB\\-\\-from\\fR \\fI[tabID]\\fR +ID of the Tab to copy +.TP +\\fB\\-\\-to\\fR \\fI[tabID]\\fR +ID of the Tab to paste +.TP +\\fB\\-\\-env\\fR, \\fB\\-\\-environment\\fR \\fI[environment]\\fR +environment from config.js +.PP +.nf + Running this command will completely erase the target tab and replace it with a copy of the source tab. + +Example: + $ tagoio copy\\-tab + $ tagoio copy\\-tab 62151835435d540010b768c4 \\-\\-from 1688653060637 \\-\\-to 2688653060638 + $ tagoio copy\\-tab 62151835435d540010b768c4 \\-\\-env dev +.fi +.SS app\\-export +export application from one profile to another +.TP +\\fB\\-\\-from\\fR \\fI\\fR +environment exporting application +.TP +\\fB\\-\\-to\\fR \\fI\\fR +environment receiving the application +.TP +\\fB\\-\\-from\\-token\\fR \\fI\\fR +profile token of the environment +.TP +\\fB\\-\\-to\\-token\\fR \\fI\\fR +profile token of the environment +.TP +\\fB\\-e\\fR, \\fB\\-\\-entity\\fR \\fI\\fR +entities that will be exported (repeatable) (default: []) +.TP +\\fB\\-\\-setup\\fR \\fI[environment]\\fR +setup a profile to be exported +.TP +\\fB\\-\\-pick\\fR +prompt you to pick which entities to be exported +.TP +\\fB\\-\\-data\\fR \\fI[variables...]\\fR +copy device data with specified variable names (omit names to copy all) +.PP +.nf + Export your profile/environment into another profile/environment. + + Export Tags: + \\- Export Tags are a key\\-pair of tags added to the entities you want to export. By default the tag key is export_id. + \\- You can run \\-\\-setup to user the CLI to go through all your entities and setup the Export Tag for you. + \\- If targeted profile/environment already have an entity with same export tag, it will update the entity instead of creating a new one. + + Entities Export: + \\- dashboards: Export the dashboard label, blueprint devices, tabs, tags and widgets of the dashboard. + Note: To export the dashboards while preserving the relationship between Devices/Analysis, ensure they have the export_id tag or any other tag you choose. + \\- devices: Export the devices configuration. Use \\-\\-data to also copy device data: \\-\\-data copies all variables, \\-\\-data var1 var2 copies only specified variables. + If you are using device\\-tokens in Environment Variables or tags, you want to include the device in the export command. + \\- analysis: Export the analysis name, code, tags, mode and timeout settings. + \\- access: Export the access rules. + \\- run: Export sidebar buttons, sign\\-in buttons and email templates + \\- actions: Export actions. + \\- dictionaries: Export all the dictionaries slugs. + + Backup: + \\- Script will automatically create a backup under exportBackup folder inside your project. + \\- You can use the backup to restore your profile/environment in case of any issues. + +Example: + $ tagoio export + $ tagoio export \\-\\-setup dev + $ tagoio export \\-\\-from dev \\-\\-to prod + $ tagoio export \\-\\-from dev \\-\\-to prod \\-e dashboards \\-e actions \\-e analysis + $ tagoio export \\-ft cb8a1d42\\-0f28\\-4ee7\\-9862\\-839920eb1cb1 \\-tt eb8a1d42\\-0f28\\-4ee7\\-9862\\-839920eb1cb0 +.fi +.SS backup +profile backup management commands + +.SH EXIT STATUS +.TP +.B 0 +Command completed successfully. +.TP +.B 1 +Any failure. The error is printed on stderr with an [ERROR] prefix +(or as a JSON object when \\-\\-json is set). + +.SH ENVIRONMENT +.TP +.B TAGOIO_DEFAULT +Selects which environment from tagoconfig.json the CLI uses +when no \\-\\-env flag is given. Persisted by tagoio set\\-env. + +.SH FILES +.TP +.I ./tagoconfig.json +Project\\-level configuration. Created and updated by +tagoio init. +.TP +.I ./.tago-lock..lock +Per\\-environment profile token written by tagoio login. +One file per environment, kept in the current project directory. +.TP +.I ./.tagoio/personal.env +Persists the user's default environment selection +(TAGOIO_DEFAULT). Updated by tagoio set\\-env. + +.SH SEE ALSO +TagoIO documentation: \\fIhttps://help.tago.io\\fR + +Issue tracker: \\fIhttps://github.com/tago\\-io/tagoio\\-cli/issues\\fR + +.SH AUTHOR +TagoIO LLC +" +`; diff --git a/src/lib/__snapshots__/init-summary.test.ts.snap b/src/lib/__snapshots__/init-summary.test.ts.snap new file mode 100644 index 0000000..fc05d60 --- /dev/null +++ b/src/lib/__snapshots__/init-summary.test.ts.snap @@ -0,0 +1,18 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`init-summary > summaryBlock > formats files, scope, env name, profile name, and endpoint 1`] = ` +"--------------------------------------------------------- +Initialization complete. + +Created files: + ./tagoconfig.json (project configuration) + ./.tago-lock.dev.lock (encrypted profile token) + +Configuration: + Environment: dev + Profile: Tago Production + Scope: local + API URL: https://api.tago.io + SSE URL: https://sse.tago.io +---------------------------------------------------------" +`; diff --git a/src/lib/config-file.test.ts b/src/lib/config-file.test.ts index ad049de..ecc0b73 100644 --- a/src/lib/config-file.test.ts +++ b/src/lib/config-file.test.ts @@ -1,9 +1,11 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { ResolvedScope } from "./resolve-scope.js"; + const existsSyncMock = vi.fn(); const readFileSyncMock = vi.fn(); const writeFileSyncMock = vi.fn(); -const getCurrentFolderMock = vi.fn(); +const resolveScopeMock = vi.fn<() => ResolvedScope>(); const readTokenMock = vi.fn(); const infoMSGMock = vi.fn(); const errorHandlerMock = vi.fn(); @@ -14,8 +16,8 @@ vi.mock("node:fs", () => ({ writeFileSync: writeFileSyncMock, })); -vi.mock("./get-current-folder.js", () => ({ - getCurrentFolder: getCurrentFolderMock, +vi.mock("./resolve-scope.js", () => ({ + resolveScope: () => resolveScopeMock(), })); vi.mock("./token.js", () => ({ @@ -32,12 +34,28 @@ vi.mock("./dotenv-config.js", () => ({ setEnvironmentVariables: vi.fn(), })); +const localScope: ResolvedScope = { + scope: "local", + root: "/repo", + configPath: "/repo/tagoconfig.json", + envFilePath: "/repo/.tagoio/personal.env", + configExists: true, +}; + +const globalScope: ResolvedScope = { + scope: "global", + root: "/home/user/.config/tagoio", + configPath: "/home/user/.config/tagoio/tagoconfig.json", + envFilePath: "/home/user/.config/tagoio/.tagoio/personal.env", + configExists: true, +}; + describe("config-file", () => { beforeEach(() => { existsSyncMock.mockReset(); readFileSyncMock.mockReset(); writeFileSyncMock.mockReset(); - getCurrentFolderMock.mockReset().mockReturnValue("/repo"); + resolveScopeMock.mockReset().mockReturnValue(localScope); readTokenMock.mockReset().mockReturnValue("tok-123"); infoMSGMock.mockReset(); errorHandlerMock.mockReset(); @@ -150,22 +168,33 @@ describe("config-file", () => { expect(readTokenMock).toHaveBeenCalledWith("prod"); }); - test("routes through errorHandler when no default env is set and no name is provided", async () => { + test("error message names the resolved scope and config path when no default is set", async () => { existsSyncMock.mockReturnValue(true); readFileSyncMock.mockReturnValue(JSON.stringify(configFile)); delete process.env.TAGOIO_DEFAULT; - // Real errorHandler terminates via process.exit(1) — simulate with a throw so code after it - // does not execute (otherwise the test hits an unrelated undefined access downstream). errorHandlerMock.mockImplementation((str: unknown) => { throw new Error(String(str)); }); const { getEnvironmentConfig } = await import("./config-file.js"); - expect(() => getEnvironmentConfig()).toThrow(/No environment found/); - expect(errorHandlerMock).toHaveBeenCalled(); + expect(() => getEnvironmentConfig()).toThrow(/local profile.*\/repo\/tagoconfig\.json/); + }); + + test("error message names the resolved scope when requested env is missing", async () => { + existsSyncMock.mockReturnValue(true); + readFileSyncMock.mockReturnValue(JSON.stringify(configFile)); + errorHandlerMock.mockImplementation((str: unknown) => { + throw new Error(String(str)); + }); + + const { getEnvironmentConfig } = await import("./config-file.js"); + expect(() => getEnvironmentConfig("missing-env")).toThrow( + /Environment 'missing-env' not found in local profile/, + ); }); - test("routes through errorHandler when requested env is not in the config", async () => { + test("error message uses 'global profile' when scope is global", async () => { + resolveScopeMock.mockReturnValue(globalScope); existsSyncMock.mockReturnValue(true); readFileSyncMock.mockReturnValue(JSON.stringify(configFile)); errorHandlerMock.mockImplementation((str: unknown) => { @@ -173,10 +202,12 @@ describe("config-file", () => { }); const { getEnvironmentConfig } = await import("./config-file.js"); - expect(() => getEnvironmentConfig("missing-env")).toThrow(/Environment not found/); + expect(() => getEnvironmentConfig("missing-env")).toThrow( + /global profile.*\/home\/user\/\.config\/tagoio/, + ); }); - test("routes through errorHandler when default env points to missing config entry", async () => { + test("error message names the resolved scope when default env is missing from config", async () => { existsSyncMock.mockReturnValue(true); readFileSyncMock.mockReturnValue(JSON.stringify(configFile)); process.env.TAGOIO_DEFAULT = "not-there"; @@ -185,7 +216,9 @@ describe("config-file", () => { }); const { getEnvironmentConfig } = await import("./config-file.js"); - expect(() => getEnvironmentConfig()).toThrow(/Default Environment not found/); + expect(() => getEnvironmentConfig()).toThrow( + /Default Environment 'not-there' not found in local profile/, + ); }); }); @@ -230,13 +263,12 @@ describe("config-file", () => { }); describe("writeToConfigFile", () => { - test("writes the provided config object to disk", async () => { - getCurrentFolderMock.mockReturnValue("/repo"); + test("writes the provided config object to disk at the resolved config path", async () => { const { writeToConfigFile } = await import("./config-file.js"); - // The function signature accepts IConfigFile & IConfigFileEnvs, but internally only writes the JSON, - // so any plain object is sufficient for the write-path test. writeToConfigFile({ default: "prod", analysisPath: "x", buildPath: "y" } as never); - expect(writeFileSyncMock).toHaveBeenCalled(); + + const [filePath] = writeFileSyncMock.mock.calls[writeFileSyncMock.mock.calls.length - 1]; + expect(filePath).toBe("/repo/tagoconfig.json"); }); }); @@ -256,7 +288,7 @@ describe("config-file", () => { expect(JSON.parse(payload as string).default).toBe("prod"); }); - test("errors out when the target env is not in the config", async () => { + test("error message names the resolved scope when target env is missing", async () => { existsSyncMock.mockReturnValue(true); readFileSyncMock.mockReturnValue(JSON.stringify({ default: "prod" })); errorHandlerMock.mockImplementation((str: unknown) => { @@ -264,7 +296,7 @@ describe("config-file", () => { }); const { setDefault } = await import("./config-file.js"); - expect(() => setDefault("ghost")).toThrow(/not in the tagoconfig/); + expect(() => setDefault("ghost")).toThrow(/'ghost' is not in local profile/); }); }); diff --git a/src/lib/config-file.ts b/src/lib/config-file.ts index 5fb57d8..51498ab 100644 --- a/src/lib/config-file.ts +++ b/src/lib/config-file.ts @@ -3,12 +3,13 @@ import { join } from "node:path"; import { GenericModuleParams } from "@tago-io/sdk"; import kleur from "kleur"; import { setEnvironmentVariables } from "./dotenv-config.js"; -import { getCurrentFolder } from "./get-current-folder.js"; import { errorHandler, highlightMSG, infoMSG } from "./messages.js"; +import { resolveScope, ResolvedScope } from "./resolve-scope.js"; import { readToken } from "./token.js"; interface IEnvironment { - analysisList: { name: string; fileName: string; id: string; path?: string }[]; + /** Local-scope only. Omitted from global tagoconfig.json (analysis development requires a project directory). */ + analysisList?: { name: string; fileName: string; id: string; path?: string }[]; id: string; profileName: string; email: string; @@ -39,20 +40,29 @@ function resolveCLIPath(suffix: string) { } function getFilePath() { - const folder = getCurrentFolder(); - return join(folder, "tagoconfig.json").normalize(); + return resolveScope().configPath; +} + +function describeScope(scope: ResolvedScope): string { + return `${scope.scope} profile (${scope.configPath})`; } function getConfigFile() { const configPath = getFilePath(); // const defaultPaths = { analysisPath: "./src/analysis", buildPath: "./build" }; - try { - if (!existsSync(configPath)) { + if (!existsSync(configPath)) { + // Local scope: auto-create a schema-stub config so a fresh project starts + // from a known-good shape. Global scope: do NOT auto-create — the user + // must opt in via `tagoio init --global` to spawn the global profile. + if (resolveScope().scope !== "local") { + return; + } + try { writeFileSync(configPath, JSON.stringify({ $schema: "https://github.com/tago-io/tagoio-cli/blob/master/docs/schema.json" }), { encoding: "utf-8" }); + } catch (error) { + errorHandler(error); } - } catch (error) { - errorHandler(error); } try { @@ -76,6 +86,7 @@ function getProfileRegion(userEnvironment: IEnvironment) { } function getEnvironmentConfig(environment?: string) { + const scope = resolveScope(); const configFile = getConfigFile(); if (!configFile) { return; @@ -86,7 +97,7 @@ function getEnvironmentConfig(environment?: string) { if (environment) { const userEnvironment = configFile[environment]; if (!userEnvironment) { - errorHandler(`Environment not found: ${environment}`); + errorHandler(`Environment '${environment}' not found in ${describeScope(scope)}`); } const profileRegion = getProfileRegion(userEnvironment); const profileToken = readToken(environment); @@ -99,12 +110,12 @@ function getEnvironmentConfig(environment?: string) { const defaultEnvName = process.env.TAGOIO_DEFAULT as string; if (!defaultEnvName) { - errorHandler(`No environment found. Set one with ${kleur.italic("tagoio set-env ")}`); + errorHandler(`No environment found in ${describeScope(scope)}. Set one with ${kleur.italic("tagoio set-env ")}`); } const defaultEnvironment = configFile[defaultEnvName]; if (!defaultEnvironment) { - errorHandler(`Default Environment not found: ${defaultEnvName}`); + errorHandler(`Default Environment '${defaultEnvName}' not found in ${describeScope(scope)}`); } const profileRegion = getProfileRegion(defaultEnvironment); const profileToken = readToken(defaultEnvName); @@ -125,9 +136,11 @@ function writeConfigFileEnv(environment: string, data: IEnvironment) { // @ts-expect-error token is set by functions delete data.profileToken; configFile[environment] = data; - if (!process.env.TAGOIO_DEFAULT) { - setEnvironmentVariables({ TAGOIO_DEFAULT: environment }); - } + // Always persist this env as the active default for the resolved scope. + // The previous guard skipped the write when process.env.TAGOIO_DEFAULT was + // already set from another scope's personal.env loaded at startup, which + // left the current scope's personal.env empty. + setEnvironmentVariables({ TAGOIO_DEFAULT: environment }); writeFileSync(configPath, JSON.stringify(configFile, null, 4), { encoding: "utf-8" }); } @@ -139,13 +152,14 @@ function writeToConfigFile(configFile: IConfigFile & IConfigFileEnvs) { } function setDefault(environment: string) { + const scope = resolveScope(); const configFile = getConfigFile(); if (!configFile) { return; } if (!configFile[environment]) { - errorHandler(`Environment ${environment} is not in the tagoconfig.json`); + errorHandler(`Environment '${environment}' is not in ${describeScope(scope)}`); } configFile.default = environment; diff --git a/src/lib/dotenv-config.test.ts b/src/lib/dotenv-config.test.ts index dd38d7b..bf666f1 100644 --- a/src/lib/dotenv-config.test.ts +++ b/src/lib/dotenv-config.test.ts @@ -1,9 +1,12 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { ResolvedScope } from "./resolve-scope.js"; + const existsSyncMock = vi.fn(); const mkdirSyncMock = vi.fn(); const writeFileSyncMock = vi.fn(); const addOnGitIgnoreMock = vi.fn(); +const resolveScopeMock = vi.fn<() => ResolvedScope>(); vi.mock("node:fs", () => ({ existsSync: existsSyncMock, @@ -11,20 +14,37 @@ vi.mock("node:fs", () => ({ writeFileSync: writeFileSyncMock, })); -vi.mock("./get-current-folder.js", () => ({ - getCurrentFolder: () => "/repo", +vi.mock("./resolve-scope.js", () => ({ + resolveScope: () => resolveScopeMock(), })); vi.mock("./add-to-gitignore.js", () => ({ addOnGitIgnore: addOnGitIgnoreMock, })); +const localScope: ResolvedScope = { + scope: "local", + root: "/repo", + configPath: "/repo/tagoconfig.json", + envFilePath: "/repo/.tagoio/personal.env", + configExists: true, +}; + +const globalScope: ResolvedScope = { + scope: "global", + root: "/home/user/.config/tagoio", + configPath: "/home/user/.config/tagoio/tagoconfig.json", + envFilePath: "/home/user/.config/tagoio/.tagoio/personal.env", + configExists: true, +}; + describe("dotenv-config", () => { beforeEach(() => { existsSyncMock.mockReset(); mkdirSyncMock.mockReset(); writeFileSyncMock.mockReset(); addOnGitIgnoreMock.mockReset(); + resolveScopeMock.mockReset().mockReturnValue(localScope); delete process.env.TAGOIO_DEFAULT; }); @@ -43,8 +63,6 @@ describe("dotenv-config", () => { }); test("creates the chain of missing parent directories (deepest first)", async () => { - // /a exists; /a/b and /a/b/c do not. `ensureDirectoryExistence("/a/b/c/file")` - // should create /a/b then /a/b/c. existsSyncMock.mockImplementation((p: string) => p === "/a"); const { ensureDirectoryExistence } = await import("./dotenv-config.js"); @@ -56,15 +74,16 @@ describe("dotenv-config", () => { }); }); - describe("setEnvironmentVariables", () => { - test("writes the TAGOIO_DEFAULT value to the env file and registers .tagoio in .gitignore", async () => { + describe("setEnvironmentVariables (local scope)", () => { + test("writes TAGOIO_DEFAULT to the resolved envFilePath and registers .tagoio in .gitignore", async () => { existsSyncMock.mockReturnValue(true); const { setEnvironmentVariables } = await import("./dotenv-config.js"); setEnvironmentVariables({ TAGOIO_DEFAULT: "production" }); expect(writeFileSyncMock).toHaveBeenCalledOnce(); - const [, content] = writeFileSyncMock.mock.calls[0]; + const [filePath, content] = writeFileSyncMock.mock.calls[0]; + expect(filePath).toBe("/repo/.tagoio/personal.env"); expect(content).toContain("TAGOIO_DEFAULT=production"); expect(addOnGitIgnoreMock).toHaveBeenCalledWith("/repo", ".tagoio"); }); @@ -80,4 +99,36 @@ describe("dotenv-config", () => { expect(content).toContain("TAGOIO_DEFAULT=staging"); }); }); + + describe("setEnvironmentVariables (global scope)", () => { + beforeEach(() => { + resolveScopeMock.mockReturnValue(globalScope); + existsSyncMock.mockReturnValue(true); + }); + + test("writes to the global scope's envFilePath", async () => { + const { setEnvironmentVariables } = await import("./dotenv-config.js"); + setEnvironmentVariables({ TAGOIO_DEFAULT: "prod" }); + + const [filePath] = writeFileSyncMock.mock.calls[0]; + expect(filePath).toBe("/home/user/.config/tagoio/.tagoio/personal.env"); + }); + + test("does NOT add to .gitignore on global scope (not a git project)", async () => { + const { setEnvironmentVariables } = await import("./dotenv-config.js"); + setEnvironmentVariables({ TAGOIO_DEFAULT: "prod" }); + + expect(addOnGitIgnoreMock).not.toHaveBeenCalled(); + }); + }); + + describe("getEnvFilePath", () => { + test("returns the resolved scope's envFilePath at call time", async () => { + const { getEnvFilePath } = await import("./dotenv-config.js"); + expect(getEnvFilePath()).toBe("/repo/.tagoio/personal.env"); + + resolveScopeMock.mockReturnValue(globalScope); + expect(getEnvFilePath()).toBe("/home/user/.config/tagoio/.tagoio/personal.env"); + }); + }); }); diff --git a/src/lib/dotenv-config.ts b/src/lib/dotenv-config.ts index fa1af69..927a2e3 100644 --- a/src/lib/dotenv-config.ts +++ b/src/lib/dotenv-config.ts @@ -3,14 +3,22 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import { addOnGitIgnore } from "./add-to-gitignore.js"; -import { getCurrentFolder } from "./get-current-folder.js"; +import { resolveScope } from "./resolve-scope.js"; interface IEnvFile { TAGOIO_DEFAULT?: string; [key: string]: string | undefined; } -const ENV_FILE_PATH = `${getCurrentFolder()}/.tagoio/personal.env`; +/** + * @description Returns the resolved-scope path for `personal.env`. Computed at + * call time (not module load) so the resolver always reflects the current cwd + * — important for tests that change cwd, and for any future caller that + * resolves a scope explicitly. + */ +function getEnvFilePath(): string { + return resolveScope().envFilePath; +} function ensureDirectoryExistence(filePath: string) { const directoryName = dirname(filePath); @@ -26,11 +34,19 @@ function setEnvironmentVariables(params: IEnvFile) { TAGOIO_DEFAULT: params.TAGOIO_DEFAULT || process.env.TAGOIO_DEFAULT, }; - const folder = getCurrentFolder(); + const scope = resolveScope(); + const envFilePath = scope.envFilePath; + + if (scope.scope === "global") { + // S1: keep the env file unreadable by other local users. + mkdirSync(dirname(envFilePath), { recursive: true, mode: 0o700 }); + writeFileSync(envFilePath, stringify(params), { mode: 0o600 }); + return; + } - ensureDirectoryExistence(ENV_FILE_PATH); - writeFileSync(ENV_FILE_PATH, stringify(params)); - addOnGitIgnore(folder, `.tagoio`); + ensureDirectoryExistence(envFilePath); + writeFileSync(envFilePath, stringify(params)); + addOnGitIgnore(scope.root, `.tagoio`); } -export { setEnvironmentVariables, ensureDirectoryExistence, ENV_FILE_PATH }; +export { setEnvironmentVariables, ensureDirectoryExistence, getEnvFilePath }; diff --git a/src/lib/generate-man.test.ts b/src/lib/generate-man.test.ts new file mode 100644 index 0000000..a53475d --- /dev/null +++ b/src/lib/generate-man.test.ts @@ -0,0 +1,86 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; + +import { buildPopulatedProgram, escapeRoff, generateManPage } from "./generate-man.js"; + +/** + * @description Detects which man-page renderer is available on the host. CI + * runners typically have `groff` (Linux); macOS has `mandoc` by default. + * If neither is available, the integration test is skipped — the snapshot + * test alone still catches drift in CI. + */ +function findManRenderer(): { bin: string; args: string[] } | null { + const candidates: Array<{ bin: string; args: string[] }> = [ + { bin: "/usr/bin/mandoc", args: ["-Tutf8"] }, + { bin: "/usr/bin/groff", args: ["-mandoc", "-Tutf8"] }, + { bin: "groff", args: ["-mandoc", "-Tutf8"] }, + { bin: "mandoc", args: ["-Tutf8"] }, + ]; + for (const candidate of candidates) { + try { + execFileSync(candidate.bin, ["--version"], { stdio: "ignore" }); + return candidate; + } catch { + // try next + } + } + return null; +} + +describe("generateManPage", () => { + test("walks the populated program tree and emits the expected roff document", () => { + const program = buildPopulatedProgram(); + const roff = generateManPage("3.2.0", program.commands); + expect(roff).toMatchSnapshot(); + }); + + test("escapeRoff escapes backslashes, hyphens, and leading periods", () => { + expect(escapeRoff("plain")).toBe("plain"); + expect(escapeRoff("with\\backslash")).toBe("with\\\\backslash"); + expect(escapeRoff("--flag")).toBe("\\-\\-flag"); + expect(escapeRoff(".leading\nfine\n.line2")).toBe("\\&.leading\nfine\n\\&.line2"); + }); + + test("the generated page exposes a stable command surface (regression guard)", () => { + const program = buildPopulatedProgram(); + const roff = generateManPage("0.0.0", program.commands); + + // Sanity: a sampling of well-known commands must appear as section + // sub-headings. Locks the contract that registerAllCommands is wired in + // and the walker reaches every namespace. + expect(roff).toContain(".SS init [environment]"); + expect(roff).toContain(".SS login [environment]"); + expect(roff).toContain(".SS analysis\\-deploy [name]"); + expect(roff).toContain(".SS device\\-list"); + expect(roff).toContain(".SS copy\\-tab [dashboardID]"); + + // No "Header" placeholder leaked through. + expect(roff).not.toMatch(/\.SS [^\n]*Header/i); + + // No commander-internal `help` subcommand. + expect(roff).not.toMatch(/^\.SS help[\s\n]/m); + }); + + test("integration: the generated page renders cleanly through the system man processor", () => { + const renderer = findManRenderer(); + if (!renderer) { + // Skip on hosts without groff/mandoc. Snapshot test still gates drift. + return; + } + + const program = buildPopulatedProgram(); + const roff = generateManPage("3.2.0", program.commands); + + const dir = mkdtempSync(join(tmpdir(), "tagoio-man-")); + const path = join(dir, "tagoio.1"); + writeFileSync(path, roff, "utf8"); + + // Renderer exits non-zero on roff syntax errors (.TP without a tag, + // unclosed font escapes, etc.). We don't care about the rendered text; + // the exit code is the gate. + expect(() => execFileSync(renderer.bin, [...renderer.args, path], { stdio: "pipe" })).not.toThrow(); + }); +}); diff --git a/src/lib/generate-man.ts b/src/lib/generate-man.ts new file mode 100644 index 0000000..73f7b8a --- /dev/null +++ b/src/lib/generate-man.ts @@ -0,0 +1,266 @@ +/** + * @description Walks the live commander program tree and emits a single + * `tagoio.1` roff page to stdout. The generated page is the source of truth + * for `man tagoio` and for shell tab-completion (zsh/fish parse roff). + * + * Run via `npm run man`, which redirects stdout to `man/tagoio.1`. A vitest + * snapshot test pins the output so any flag/command change without a matching + * snapshot regen fails CI (see `scripts/generate-man.test.ts`). + * + * Roff special characters that must be escaped in user-supplied strings: + * `\` → `\\` + * `-` → `\-` (so it survives nroff dehyphenation in option names) + * `.` at line start → `\&.` (otherwise read as a control directive) + */ +import { Command, Option } from "commander"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { buildProgram } from "../index.js"; +import { MAN_CONTENT } from "./man-content.js"; + +/** Reads the package.json that sits two levels up from `src/lib`. */ +function readPackageJSON(): { name: string; version: string; description?: string } { + const path = join(import.meta.dirname, "..", "..", "package.json"); + return JSON.parse(readFileSync(path, "utf8")); +} + +/** Escapes a string for safe interpolation into roff body text. */ +function escapeRoff(input: string): string { + return input + .replaceAll("\\", "\\\\") + .replaceAll("-", "\\-") + .replaceAll(/^\./gm, "\\&."); +} + +/** Joins a list of lines into a single block, trimming trailing whitespace per line. */ +function block(...lines: string[]): string { + return `${lines.map((line) => line.replace(/[\t ]+$/u, "")).join("\n")}\n`; +} + +/** + * @description Page header (.TH) plus the static top sections. Prose lives in + * `man-content.ts` as plain English; this function wraps each value with the + * roff escapes and the right `.SH`/`.TP` markers. + */ +function buildHeader(version: string): string { + return block( + `.\\" Generated by scripts/generate-man.ts. Do not edit by hand.`, + `.TH TAGOIO 1 "" "tagoio ${version}" "User Commands"`, + "", + ".SH NAME", + escapeRoff(MAN_CONTENT.name), + "", + ".SH SYNOPSIS", + ".B tagoio", + "[\\fIglobal options\\fR]", + "\\fIcommand\\fR", + "[\\fIcommand options\\fR]", + "[\\fIarguments...\\fR]", + "", + ".SH DESCRIPTION", + escapeRoff(MAN_CONTENT.description), + "", + ".SH GLOBAL OPTIONS", + ".TP", + ".BR \\-V , \" \\-\\-version\"", + escapeRoff(MAN_CONTENT.globalVersionDesc), + ".TP", + ".BR \\-h , \" \\-\\-help\"", + escapeRoff(MAN_CONTENT.globalHelpDesc), + ); +} + +/** Trailing static sections. Prose pulled from `man-content.ts`. */ +function buildFooter(): string { + return block( + "", + ".SH EXIT STATUS", + ".TP", + ".B 0", + escapeRoff(MAN_CONTENT.exitStatusOK), + ".TP", + ".B 1", + escapeRoff(MAN_CONTENT.exitStatusFail), + "", + ".SH ENVIRONMENT", + ".TP", + ".B TAGOIO_DEFAULT", + escapeRoff(MAN_CONTENT.envTagoioDefault), + "", + ".SH FILES", + ".TP", + ".I ./tagoconfig.json", + escapeRoff(MAN_CONTENT.fileTagoconfig), + ".TP", + ".I ./.tago-lock..lock", + escapeRoff(MAN_CONTENT.fileLockfile), + ".TP", + ".I ./.tagoio/personal.env", + escapeRoff(MAN_CONTENT.filePersonalEnv), + "", + ".SH SEE ALSO", + `TagoIO documentation: \\fI${escapeRoff(MAN_CONTENT.seeAlsoDocsURL)}\\fR`, + "", + `Issue tracker: \\fI${escapeRoff(MAN_CONTENT.seeAlsoIssuesURL)}\\fR`, + "", + ".SH AUTHOR", + escapeRoff(MAN_CONTENT.author), + ); +} + +/** Returns the placeholder a command's [arg] / tokens render as in the heading. */ +function commandHeading(cmd: Command): string { + const args = cmd.registeredArguments.map((arg) => { + const name = arg.name(); + return arg.required ? `<${name}>` : `[${name}]`; + }); + const headingTokens = [cmd.name(), ...args].join(" "); + return escapeRoff(headingTokens); +} + +/** Renders a single Option as a `.TP`-prefixed roff block. */ +function renderOption(option: Option): string { + // Commander gives us a flags string like `-t, --token `. + // Split into the flag list and the optional argument placeholder. + const flagsText = option.flags; + const placeholderMatch = /\s+([<\[].+)$/u.exec(flagsText); + const placeholder = placeholderMatch ? placeholderMatch[1] : ""; + const flagsOnly = placeholder ? flagsText.slice(0, flagsText.length - placeholder.length).trim() : flagsText; + + // Render each flag in bold; placeholder in italic. + const flagTokens = flagsOnly + .split(",") + .map((flag) => `\\fB${escapeRoff(flag.trim())}\\fR`) + .join(", "); + const placeholderRendered = placeholder ? ` \\fI${escapeRoff(placeholder)}\\fR` : ""; + + const description = option.description ? escapeRoff(option.description) : ""; + const defaultText = option.defaultValue !== undefined && option.defaultValue !== "" ? ` (default: ${escapeRoff(JSON.stringify(option.defaultValue))})` : ""; + + return block(`.TP`, `${flagTokens}${placeholderRendered}`, `${description}${defaultText}` || "\\&"); +} + +/** Renders any text the command attached via `addHelpText("after", ...)` as a literal block. */ +function renderAfterText(cmd: Command): string { + // Commander 14 wires `addHelpText("after", text)` via `this.on("afterHelp", ...)`, + // so the registered listeners live on Node's EventEmitter — accessible + // through the public `listeners(eventName)` API. The Command typings don't + // expose EventEmitter members, so we cast to the minimal shape we need. + const emitter = cmd as unknown as { listeners(event: string): Array<(ctx: unknown) => void> }; + const listeners = emitter.listeners("afterHelp"); + if (listeners.length === 0) { + return ""; + } + + // Each listener writes via `context.write(str)`. We capture those writes + // by passing a stub context that appends to a buffer, then flatten. + const captured: string[] = []; + const stubContext = { + error: false, + command: cmd, + write: (str: string) => { + captured.push(str); + }, + }; + + for (const listener of listeners) { + listener(stubContext); + } + + if (captured.length === 0) { + return ""; + } + + // Render the captured text as a roff `.nf` (no-fill) block so indentation + // and line breaks survive the page layout. + const lines = captured.join("\n").split("\n").map((line) => line.trimEnd()); + // Strip leading and trailing blank lines so the block sits flush. + while (lines.length > 0 && lines[0] === "") { + lines.shift(); + } + while (lines.length > 0 && lines[lines.length - 1] === "") { + lines.pop(); + } + if (lines.length === 0) { + return ""; + } + + const out: string[] = [".PP", ".nf"]; + for (const line of lines) { + out.push(line.length === 0 ? "" : escapeRoff(line)); + } + out.push(".fi"); + return block(...out); +} + +/** Renders one command (registered top-level on `program`) as a `.SS` block. */ +function renderCommand(cmd: Command): string { + const heading = commandHeading(cmd); + const description = cmd.description() ? escapeRoff(cmd.description()) : ""; + const sections: string[] = [block(`.SS ${heading}`, description || "\\&")]; + + for (const option of cmd.options) { + sections.push(renderOption(option)); + } + + const afterText = renderAfterText(cmd); + if (afterText) { + sections.push(afterText); + } + + return sections.join(""); +} + +/** + * @description Top-level entry point. Builds the program tree, walks it, + * returns the full roff document as a string. Pure: no I/O, no env reads. + */ +function generateManPage(version: string, commands: readonly Command[]): string { + const out: string[] = [buildHeader(version), block("", ".SH COMMANDS")]; + + for (const cmd of commands) { + // Skip the empty placeholder commands like `program.command("Devices Header")` + // that the existing CLI uses as visual section dividers in --help. + // Commander parses "Foo Header" as { name: "Foo", arg:
}, so we + // check both the name and the rendered heading (name + args). + if (commandHeading(cmd).toLowerCase().includes("header")) { + continue; + } + // Skip commander's auto-generated `help` subcommand — already covered by + // GLOBAL OPTIONS (-h / --help). + if (cmd.name() === "help") { + continue; + } + out.push(renderCommand(cmd)); + } + + out.push(buildFooter()); + return out.join(""); +} + +/** Constructs a fresh program with every command registered, ready to walk. */ +function buildPopulatedProgram(): Command { + // The man-page reflects the static command surface, not any user's + // current environment; pass an empty string so commander does not bake + // a runtime value into the help output. `buildProgram` lives in + // `src/index.ts` and is the single source of truth for the CLI's command + // tree — adding a command there automatically appears in this generator's + // output on the next run. + return buildProgram(""); +} + +function main(): void { + const pkg = readPackageJSON(); + const program = buildPopulatedProgram(); + const roff = generateManPage(pkg.version, program.commands); + process.stdout.write(roff); +} + +// Run when invoked directly (so tests can `import` the helpers without side effects). +const isMain = import.meta.url === `file://${process.argv[1]}` || import.meta.url.endsWith("/src/lib/generate-man.ts"); +if (isMain) { + main(); +} + +export { buildPopulatedProgram, escapeRoff, generateManPage }; diff --git a/src/lib/init-state.test.ts b/src/lib/init-state.test.ts new file mode 100644 index 0000000..197e8b9 --- /dev/null +++ b/src/lib/init-state.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { ResolvedScope } from "./resolve-scope.js"; + +const existsSyncMock = vi.fn<(path: string) => boolean>(); +const readFileSyncMock = vi.fn<(path: string, encoding: string) => string>(); +const resolveScopeMock = vi.fn<() => ResolvedScope>(); + +vi.mock("node:fs", () => ({ + existsSync: existsSyncMock, + readFileSync: readFileSyncMock, +})); + +vi.mock("./resolve-scope.js", () => ({ + resolveScope: () => resolveScopeMock(), +})); + +const localScope: ResolvedScope = { + scope: "local", + root: "/repo", + configPath: "/repo/tagoconfig.json", + envFilePath: "/repo/.tagoio/personal.env", + configExists: true, +}; + +const globalScope: ResolvedScope = { + scope: "global", + root: "/home/user/.config/tagoio", + configPath: "/home/user/.config/tagoio/tagoconfig.json", + envFilePath: "/home/user/.config/tagoio/.tagoio/personal.env", + configExists: false, +}; + +describe("detectInitState", () => { + const originalIsTTY = process.stdin.isTTY; + + beforeEach(() => { + existsSyncMock.mockReset().mockReturnValue(false); + readFileSyncMock.mockReset(); + resolveScopeMock.mockReset().mockReturnValue(localScope); + }); + + afterEach(() => { + Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true }); + vi.restoreAllMocks(); + }); + + test("returns local scope, configExists=true, envExists=true when env block present", async () => { + readFileSyncMock.mockReturnValue(JSON.stringify({ dev: { id: "x" }, prod: { id: "y" } })); + existsSyncMock.mockImplementation((p) => p === "/repo/.tago-lock.dev.lock"); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + + const { detectInitState } = await import("./init-state.js"); + const state = detectInitState("dev"); + + expect(state.scope.scope).toBe("local"); + expect(state.configExists).toBe(true); + expect(state.envExists).toBe(true); + expect(state.tokenExists).toBe(true); + expect(state.isTTY).toBe(true); + }); + + test("envExists=false when the requested env is not in the config", async () => { + readFileSyncMock.mockReturnValue(JSON.stringify({ prod: { id: "y" } })); + + const { detectInitState } = await import("./init-state.js"); + expect(detectInitState("dev").envExists).toBe(false); + }); + + test("envExists=false on malformed config (parse error)", async () => { + readFileSyncMock.mockReturnValue("not-json {{"); + + const { detectInitState } = await import("./init-state.js"); + expect(detectInitState("dev").envExists).toBe(false); + }); + + test("configExists=false propagates from resolveScope, envExists never set", async () => { + resolveScopeMock.mockReturnValue(globalScope); + + const { detectInitState } = await import("./init-state.js"); + const state = detectInitState("dev"); + expect(state.configExists).toBe(false); + expect(state.envExists).toBe(false); + expect(readFileSyncMock).not.toHaveBeenCalled(); + }); + + test("tokenExists reflects the .tago-lock..lock probe", async () => { + readFileSyncMock.mockReturnValue("{}"); + existsSyncMock.mockImplementation((p) => p === "/repo/.tago-lock.staging.lock"); + + const { detectInitState } = await import("./init-state.js"); + expect(detectInitState("staging").tokenExists).toBe(true); + expect(detectInitState("dev").tokenExists).toBe(false); + }); + + test("isTTY=false when stdin is not a terminal", async () => { + readFileSyncMock.mockReturnValue("{}"); + Object.defineProperty(process.stdin, "isTTY", { value: undefined, configurable: true }); + + const { detectInitState } = await import("./init-state.js"); + expect(detectInitState("dev").isTTY).toBe(false); + }); +}); diff --git a/src/lib/init-state.ts b/src/lib/init-state.ts new file mode 100644 index 0000000..3080a67 --- /dev/null +++ b/src/lib/init-state.ts @@ -0,0 +1,47 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +import { resolveScope, ResolvedScope } from "./resolve-scope.js"; + +interface InitState { + scope: ResolvedScope; + /** True when stdin is an interactive terminal. Used by Step 2 to decide prompt vs flag-required. */ + isTTY: boolean; + /** tagoconfig.json on disk at the resolved scope. */ + configExists: boolean; + /** Env block already present in the resolved config. */ + envExists: boolean; + /** Per-env lock file (.tago-lock..lock) on disk. */ + tokenExists: boolean; +} + +/** + * @description Pre-flight detection (Step 0 of the clig.dev init flow). + * + * Reads the resolved scope and the on-disk state for the requested env name + * so later steps can branch deterministically. No prompts, no API calls; a + * handful of `existsSync` checks so it stays under 100ms even on slow disks. + */ +function detectInitState(envName: string): InitState { + const scope = resolveScope(); + const configExists = scope.configExists; + + let envExists = false; + if (configExists) { + // Malformed config → envExists stays false, the safe default for init. + try { + const raw = readFileSync(scope.configPath, "utf8"); + const parsed = JSON.parse(raw); + envExists = typeof parsed === "object" && parsed !== null && envName in parsed && typeof parsed[envName] === "object"; + } catch { + envExists = false; + } + } + + const tokenExists = existsSync(path.join(scope.root, `.tago-lock.${envName}.lock`)); + const isTTY = Boolean(process.stdin.isTTY); + + return { scope, isTTY, configExists, envExists, tokenExists }; +} + +export { detectInitState, InitState }; diff --git a/src/lib/init-summary.test.ts b/src/lib/init-summary.test.ts new file mode 100644 index 0000000..5b58b9f --- /dev/null +++ b/src/lib/init-summary.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { InitState } from "./init-state.js"; +import { ResolvedScope } from "./resolve-scope.js"; + +const localScope: ResolvedScope = { + scope: "local", + root: "/repo", + configPath: "/repo/tagoconfig.json", + envFilePath: "/repo/.tagoio/personal.env", + configExists: true, +}; + +const globalScope: ResolvedScope = { + scope: "global", + root: "/home/user/.config/tagoio", + configPath: "/home/user/.config/tagoio/tagoconfig.json", + envFilePath: "/home/user/.config/tagoio/.tagoio/personal.env", + configExists: true, +}; + +// kleur color codes muddy snapshot equality; strip them before assertion. +// oxlint-disable-next-line no-control-regex +const stripAnsi = (s: string) => s.replace(/\x1B\[[0-9;]*m/g, ""); + +describe("init-summary", () => { + let stderrSpy: ReturnType; + + beforeEach(() => { + stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("banner", () => { + test("names the resolved scope root", async () => { + const { banner } = await import("./init-summary.js"); + expect(banner(localScope)).toBe("Initializing tagoio in /repo..."); + expect(banner(globalScope)).toBe("Initializing tagoio in /home/user/.config/tagoio..."); + }); + }); + + describe("overwriteConfirmCopy", () => { + test("local scope names the global config dir as untouched", async () => { + const { overwriteConfirmCopy } = await import("./init-summary.js"); + const state: InitState = { scope: localScope, isTTY: true, configExists: true, envExists: true, tokenExists: true }; + const copy = overwriteConfirmCopy(state, "dev"); + expect(copy).toContain("env 'dev'"); + expect(copy).toContain("/repo/tagoconfig.json"); + expect(copy).toContain("Reinitializing will overwrite"); + expect(copy).toContain("global config located in ~/.config/tagoio/"); + }); + + test("global scope names the local config in projects as untouched", async () => { + const { overwriteConfirmCopy } = await import("./init-summary.js"); + const state: InitState = { scope: globalScope, isTTY: true, configExists: true, envExists: true, tokenExists: true }; + const copy = overwriteConfirmCopy(state, "prod"); + expect(copy).toContain("env 'prod'"); + expect(copy).toContain("local config in any project directory"); + }); + }); + + describe("step markers", () => { + test("startStep writes [..]