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..465ce6a 100644 --- a/src/commands/start-config.test.ts +++ b/src/commands/start-config.test.ts @@ -24,6 +24,22 @@ vi.mock("../lib/messages.js", () => ({ 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/scope-notice.js", () => ({ + printScopeBanner: vi.fn(), +})); + vi.mock("./login.js", () => ({ getTagoDeployURL: vi.fn(), tagoLogin: vi.fn(), diff --git a/src/commands/start-config.ts b/src/commands/start-config.ts index 62e67a8..e55c1a1 100644 --- a/src/commands/start-config.ts +++ b/src/commands/start-config.ts @@ -1,4 +1,4 @@ -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"; @@ -6,6 +6,8 @@ import prompts, { Choice } from "prompts"; import stringComparison from "string-comparison"; import { getConfigFile, IEnvironment, writeConfigFileEnv, writeToConfigFile } from "../lib/config-file.js"; import { errorHandler, highlightMSG, infoMSG } from "../lib/messages.js"; +import { globalConfigDir, resolveScope, setScopeOverride } from "../lib/resolve-scope.js"; +import { printScopeBanner } from "../lib/scope-notice.js"; import { readToken, writeToken } from "../lib/token.js"; import { promptTextToEnter } from "../prompt/text-prompt.js"; import { getTagoDeployURL, tagoLogin } from "./login.js"; @@ -13,6 +15,7 @@ import { getTagoDeployURL, tagoLogin } from "./login.js"; interface ConfigOptions { token: string | void; environment: string | void; + scope?: "local" | "global"; } interface AnalysisFile { @@ -100,7 +103,7 @@ async function chooseAnalysis(analysisOptions: any[]) { * @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) { @@ -120,7 +123,7 @@ 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) => ({ + const analysisResult: NonNullable = response.map((x) => ({ fileName: formatFileName(x.name), name: x.name, id: x.id, @@ -136,8 +139,12 @@ async function getAnalysisList(account: Account, oldList: IEnvironment["analysis * @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) { +async function getAnalysisScripts(analysisList: NonNullable, analysisPath: string) { analysisPath = analysisPath.replace("./", ""); + if (!analysisPath || !existsSync(analysisPath)) { + infoMSG(`Analysis folder not found at "${analysisPath || "(empty)"}"; skipping file matching. Edit tagoconfig.json or rerun init once the folder exists.`); + return analysisList; + } infoMSG(`Searching for files at ${analysisPath} and subfolders`); let files: Choice[] = scanAnalysisFiles(analysisPath).map((x) => ({ title: x.filename, value: x.filename, description: x.relativePath })); @@ -181,7 +188,56 @@ async function getAnalysisScripts(analysisList: IEnvironment["analysisList"], an * @param options The configuration options. * @param options.token The TagoIO token to use for authentication. */ -async function startConfig(environment: string, { token }: ConfigOptions) { +async function startConfig(environment: string, { token, scope: scopeFlag }: ConfigOptions) { + // Resolve the target scope and make sure a tagoconfig.json exists at that + // scope so the rest of init can read/write it. + // + // Decision tree: + // --scope global → write to globalConfigDir() + // --scope local → write to ./tagoconfig.json + // no flag, local config exists → edit local + // no flag, global config exists → edit global + // no flag, neither exists → prompt; "yes" creates global, "no" creates local + if (scopeFlag && scopeFlag !== "local" && scopeFlag !== "global") { + errorHandler(`Invalid --scope value: '${scopeFlag}'. Use 'local' or 'global'.`); + } + 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" }); + } + } + if (scopeFlag === "global") { + bootstrapGlobalConfig(); + } else if (scopeFlag === "local") { + bootstrapLocalConfig(); + } else if (!resolveScope().configExists) { + 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(); + } + } + + const scope = resolveScope(); + printScopeBanner(scope); + // Prompt user to enter environment name if not provided if (!environment) { ({ environment } = await prompts({ @@ -218,13 +274,16 @@ async function startConfig(environment: string, { token }: ConfigOptions) { 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"); - } + // Analysis-related paths only apply to local scope. Global is for + // device/dashboard/profile work — no analysisList there. + if (scope.scope === "local") { + 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"); + if (!configFile.buildPath) { + configFile.buildPath = await promptTextToEnter(`Enter the path of your ${kleur.cyan("building")} folder (typescript): `, "./build"); + } } // Return if token is not found @@ -240,25 +299,29 @@ async function startConfig(environment: string, { token }: ConfigOptions) { }; } - // Get account info and analysis list + // Get account info const account = new Account({ token, region }); const profile = await account.profiles.info("current"); const accountInfo = await account.info().catch(errorHandler); if (!accountInfo) { return; } - let analysisList = await getAnalysisList(account, configFile[environment]?.analysisList); - analysisList = await getAnalysisScripts(analysisList, configFile.analysisPath); - // Create new environment object and write to config file + // Create new environment object and write to config file. Local scope + // collects an analysisList; global scope omits the key entirely so the + // file shape stays a strict subset. const newEnv: IEnvironment = { - analysisList: analysisList, id: profile.info.id, profileName: profile.info.name, email: accountInfo.email, tagoSSEURL: tagoSSEURL, tagoAPIURL: tagoAPIURL, }; + if (scope.scope === "local") { + let analysisList = await getAnalysisList(account, configFile[environment]?.analysisList); + analysisList = await getAnalysisScripts(analysisList, configFile.analysisPath); + newEnv.analysisList = analysisList; + } writeToConfigFile(configFile); writeConfigFileEnv(environment, newEnv); } 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..2f33c76 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,8 +74,9 @@ 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("--scope ", "force a specific profile scope: 'local' (./tagoconfig.json) or 'global' (~/.config/tagoio/tagoconfig.json)") .action(startConfig) .addHelpText( "after", @@ -84,17 +85,20 @@ async function initiateCMD() { Example: $ tagoio init + $ tagoio init prod $ tagoio init -t eb8a1d42-0f28-4ee7-9862-839920eb1cb0 - $ tagoio init --env dev`, + $ tagoio init --scope global + $ tagoio init prod --scope global`, ); 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 +108,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 +136,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..1881339 --- /dev/null +++ b/src/lib/__snapshots__/generate-man.test.ts.snap @@ -0,0 +1,522 @@ +// 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\\-\\-scope\\fR \\fI\\fR +force a specific profile scope: 'local' (./tagoconfig.json) or 'global' (~/.config/tagoio/tagoconfig.json) +.PP +.nf + Note: If you don't store credentials in this command, 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 prod \\-\\-scope global +.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/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/man-content.ts b/src/lib/man-content.ts new file mode 100644 index 0000000..996be7c --- /dev/null +++ b/src/lib/man-content.ts @@ -0,0 +1,51 @@ +/** + * @description Plain-English text bodies for the man page. + * + * Edit prose here in normal English — no roff escapes, no font codes. The + * generator (`generate-man.ts`) wraps each value with `escapeRoff()` and + * places it under the right `.SH`/`.TP` section. + * + * Keep line breaks intentional: a literal `\n` in these strings becomes a + * line break in the rendered man page, which is how multi-paragraph entries + * stay readable in `man tagoio`. + */ +const MAN_CONTENT = { + // NAME section + name: "tagoio - command-line tool for TagoIO", + + // DESCRIPTION section + 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.`, + + // GLOBAL OPTIONS labels + globalVersionDesc: "Output the version number.", + globalHelpDesc: "Display help for command.", + + // EXIT STATUS bodies + exitStatusOK: "Command completed successfully.", + exitStatusFail: `Any failure. The error is printed on stderr with an [ERROR] prefix +(or as a JSON object when --json is set).`, + + // ENVIRONMENT body (TAGOIO_DEFAULT) + envTagoioDefault: `Selects which environment from tagoconfig.json the CLI uses +when no --env flag is given. Persisted by tagoio set-env.`, + + // FILES bodies + fileTagoconfig: `Project-level configuration. Created and updated by +tagoio init.`, + fileLockfile: `Per-environment profile token written by tagoio login. +One file per environment, kept in the current project directory.`, + filePersonalEnv: `Persists the user's default environment selection +(TAGOIO_DEFAULT). Updated by tagoio set-env.`, + + // SEE ALSO entries (URLs only — labels are in the inline structure) + seeAlsoDocsURL: "https://help.tago.io", + seeAlsoIssuesURL: "https://github.com/tago-io/tagoio-cli/issues", + + // AUTHOR + author: "TagoIO LLC", +} as const; + +export { MAN_CONTENT }; diff --git a/src/lib/resolve-scope.test.ts b/src/lib/resolve-scope.test.ts new file mode 100644 index 0000000..9084c4e --- /dev/null +++ b/src/lib/resolve-scope.test.ts @@ -0,0 +1,237 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const existsSyncMock = vi.fn<(path: string) => boolean>(); +const lstatSyncMock = vi.fn(); +const errorHandlerMock = vi.fn<(str: unknown) => never>(() => { + throw new Error("errorHandler called"); +}); + +vi.mock("node:fs", () => ({ + existsSync: existsSyncMock, + lstatSync: lstatSyncMock, +})); + +vi.mock("node:os", () => ({ + default: { homedir: () => "/home/user" }, + homedir: () => "/home/user", +})); + +vi.mock("./messages.js", () => ({ + errorHandler: errorHandlerMock, +})); + +/** Helper: declare which absolute paths "exist" for a given test. */ +function setExisting(paths: string[]) { + const set = new Set(paths); + existsSyncMock.mockImplementation((p: string) => set.has(p)); +} + +describe("resolve-scope", () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + const originalEnv = { ...process.env }; + + beforeEach(async () => { + existsSyncMock.mockReset(); + lstatSyncMock.mockReset().mockReturnValue({ isSymbolicLink: () => false }); + errorHandlerMock.mockClear(); + delete process.env.XDG_CONFIG_HOME; + delete process.env.APPDATA; + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + // Defensive: clear any module-level scope override left by a prior test. + const { setScopeOverride } = await import("./resolve-scope.js"); + setScopeOverride(undefined); + }); + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, "platform", originalPlatform); + } + process.env = { ...originalEnv }; + vi.restoreAllMocks(); + }); + + describe("globalConfigDir", () => { + test("uses XDG_CONFIG_HOME when set on Linux/macOS", async () => { + process.env.XDG_CONFIG_HOME = "/custom/xdg"; + const { globalConfigDir } = await import("./resolve-scope.js"); + expect(globalConfigDir()).toBe("/custom/xdg/tagoio"); + }); + + test("falls back to ~/.config on Linux/macOS when XDG_CONFIG_HOME is unset", async () => { + const { globalConfigDir } = await import("./resolve-scope.js"); + expect(globalConfigDir()).toBe("/home/user/.config/tagoio"); + }); + + test("uses APPDATA on Windows", async () => { + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + process.env.APPDATA = "C:\\Users\\u\\AppData\\Roaming"; + const { globalConfigDir } = await import("./resolve-scope.js"); + // path.join on a Linux test runner normalizes backslashes; just assert it + // ends with the tagoio segment under the supplied APPDATA root. + expect(globalConfigDir()).toContain("tagoio"); + expect(globalConfigDir()).toContain("Roaming"); + }); + }); + + describe("resolveScope", () => { + test("returns local when startDir directly contains tagoconfig.json", async () => { + setExisting(["/repo/tagoconfig.json"]); + const { resolveScope } = await import("./resolve-scope.js"); + const scope = resolveScope({ startDir: "/repo" }); + expect(scope).toEqual({ + scope: "local", + root: "/repo", + configPath: "/repo/tagoconfig.json", + envFilePath: "/repo/.tagoio/personal.env", + configExists: true, + }); + }); + + test("walks up the parent chain until it finds tagoconfig.json", async () => { + setExisting(["/repo/tagoconfig.json"]); + const { resolveScope } = await import("./resolve-scope.js"); + const scope = resolveScope({ startDir: "/repo/deep/nested/path" }); + expect(scope.scope).toBe("local"); + expect(scope.root).toBe("/repo"); + }); + + test("local wins over global when both exist", async () => { + setExisting([ + "/repo/tagoconfig.json", + "/home/user/.config/tagoio", + "/home/user/.config/tagoio/tagoconfig.json", + ]); + const { resolveScope } = await import("./resolve-scope.js"); + const scope = resolveScope({ startDir: "/repo/sub" }); + expect(scope.scope).toBe("local"); + expect(scope.root).toBe("/repo"); + }); + + test("falls back to global scope when no ancestor contains tagoconfig.json", async () => { + setExisting([]); + const { resolveScope } = await import("./resolve-scope.js"); + const scope = resolveScope({ startDir: "/tmp/random" }); + expect(scope.scope).toBe("global"); + expect(scope.root).toBe("/home/user/.config/tagoio"); + expect(scope.configExists).toBe(false); + }); + + test("global configExists reflects whether the global tagoconfig.json is on disk", async () => { + setExisting(["/home/user/.config/tagoio/tagoconfig.json"]); + const { resolveScope } = await import("./resolve-scope.js"); + const scope = resolveScope({ startDir: "/tmp/random" }); + expect(scope.scope).toBe("global"); + expect(scope.configExists).toBe(true); + }); + + test("caps the parent walk at 32 levels to bound stat calls on slow filesystems", async () => { + // No tagoconfig.json anywhere; track existsSync hits to confirm cap. + const calls: string[] = []; + existsSyncMock.mockImplementation((p: string) => { + calls.push(p); + return false; + }); + + // Build a 60-level deep startDir. + const deepStart = "/a" + "/b".repeat(60); + const { resolveScope } = await import("./resolve-scope.js"); + const scope = resolveScope({ startDir: deepStart }); + expect(scope.scope).toBe("global"); + + // Each iteration calls existsSync once for tagoconfig.json check; + // the final fallback to global also calls existsSync twice (dir + config). + // The walk itself must stop at 32 iterations max. + const walkChecks = calls.filter((c) => c.endsWith("/tagoconfig.json") && !c.includes(".config")); + expect(walkChecks.length).toBeLessThanOrEqual(32); + }); + + test("uses logical parent (path.dirname) — does not resolve symlinks in the cwd chain", async () => { + // Layout: /symlinked-cwd is a symlink to /real/place; tagoconfig.json + // sits at /symlinked-cwd's parent. A logical walk finds it; a realpath + // walk would never reach it. + setExisting(["/parent/tagoconfig.json"]); + const { resolveScope } = await import("./resolve-scope.js"); + const scope = resolveScope({ startDir: "/parent/symlinked-cwd" }); + expect(scope.scope).toBe("local"); + expect(scope.root).toBe("/parent"); + }); + + test("refuses to operate when the global config dir is a symlink (S2)", async () => { + setExisting(["/home/user/.config/tagoio"]); + lstatSyncMock.mockReturnValue({ isSymbolicLink: () => true }); + + const { resolveScope } = await import("./resolve-scope.js"); + expect(() => resolveScope({ startDir: "/tmp/random" })).toThrow(); + expect(errorHandlerMock).toHaveBeenCalledOnce(); + expect(errorHandlerMock.mock.calls[0][0]).toContain("symlink"); + }); + + test("does NOT call lstatSync when global dir doesn't exist (fresh user)", async () => { + setExisting([]); + const { resolveScope } = await import("./resolve-scope.js"); + resolveScope({ startDir: "/tmp/random" }); + expect(lstatSyncMock).not.toHaveBeenCalled(); + }); + }); + + describe("setScopeOverride", () => { + test("forces resolveScope to return global, bypassing the walk", async () => { + // Set up a fixture where resolveScope WOULD return local without the override. + setExisting(["/repo/tagoconfig.json"]); + + const { resolveScope, setScopeOverride } = await import("./resolve-scope.js"); + + // Sanity: without override, returns local. + const baseline = resolveScope({ startDir: "/repo/sub" }); + expect(baseline.scope).toBe("local"); + + // With override, returns global regardless of cwd. + setScopeOverride("global"); + const overridden = resolveScope({ startDir: "/repo/sub" }); + expect(overridden.scope).toBe("global"); + expect(overridden.root).toBe("/home/user/.config/tagoio"); + + // Reset for next test. + setScopeOverride(undefined); + }); + + test("clearing the override (undefined) returns to walk-based resolution", async () => { + setExisting(["/repo/tagoconfig.json"]); + + const { resolveScope, setScopeOverride } = await import("./resolve-scope.js"); + setScopeOverride("global"); + expect(resolveScope({ startDir: "/repo" }).scope).toBe("global"); + + setScopeOverride(undefined); + expect(resolveScope({ startDir: "/repo" }).scope).toBe("local"); + }); + }); + + describe("requireLocalScope", () => { + test("returns the resolved scope when scope is local", async () => { + setExisting(["/repo/tagoconfig.json"]); + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo"); + + const { requireLocalScope } = await import("./resolve-scope.js"); + const scope = requireLocalScope("analysis-deploy"); + expect(scope.scope).toBe("local"); + expect(scope.root).toBe("/repo"); + + cwdSpy.mockRestore(); + }); + + test("errors actionably when scope is global, naming the command", async () => { + setExisting([]); + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/tmp/random"); + + const { requireLocalScope } = await import("./resolve-scope.js"); + expect(() => requireLocalScope("analysis-deploy")).toThrow(); + const message = errorHandlerMock.mock.calls[0][0] as string; + expect(message).toContain("analysis-deploy"); + expect(message).toContain("project directory"); + expect(message).toContain("tagoio init"); + + cwdSpy.mockRestore(); + }); + }); +}); diff --git a/src/lib/resolve-scope.ts b/src/lib/resolve-scope.ts new file mode 100644 index 0000000..19aedbd --- /dev/null +++ b/src/lib/resolve-scope.ts @@ -0,0 +1,114 @@ +import { existsSync, lstatSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { errorHandler } from "./messages.js"; + +const CONFIG_FILE_NAME = "tagoconfig.json"; +const MAX_WALK_DEPTH = 32; + +interface ResolvedScope { + scope: "local" | "global"; + root: string; + configPath: string; + envFilePath: string; + configExists: boolean; +} + +let scopeOverride: "global" | undefined; + +/** + * @description Forces every subsequent `resolveScope()` call in this process + * to return the global scope, bypassing the parent-walk. Used by command + * handlers when `--global` is passed so writes target the global config dir + * regardless of cwd. + * + * Pass `undefined` to clear the override (rarely needed in a single-shot CLI). + */ +function setScopeOverride(forced: "global" | undefined): void { + scopeOverride = forced; +} + +/** + * @description Returns the absolute path of the directory where the global + * `tagoconfig.json` lives. XDG Base Directory Specification on Unix; standard + * Roaming AppData location on Windows. + */ +function globalConfigDir(): string { + if (process.platform === "win32") { + const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"); + return path.join(appData, "tagoio"); + } + const xdg = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"); + return path.join(xdg, "tagoio"); +} + +function buildScope(scope: "local" | "global", root: string, configExists: boolean): ResolvedScope { + return { + scope, + root, + configPath: path.join(root, CONFIG_FILE_NAME), + envFilePath: path.join(root, ".tagoio", "personal.env"), + configExists, + }; +} + +/** + * @description Resolves the active scope for the current invocation. + * + * Walks from `startDir` (default `process.cwd()`) up the logical parent chain + * (no `realpath` resolution; matches `git`/`npm` convention), capped at 32 + * levels. The first ancestor containing `tagoconfig.json` becomes the local + * scope root. If no ancestor matches, falls back to the platform-specific + * global config directory. + * + * The walk is logical-only and depth-capped so behavior stays predictable on + * NFS / encrypted home directories. + */ +function resolveGlobal(): ResolvedScope { + const globalRoot = globalConfigDir(); + // S2: refuse to operate if the global directory is a symlink. + if (existsSync(globalRoot) && lstatSync(globalRoot).isSymbolicLink()) { + errorHandler(`${globalRoot} is a symlink; refusing to read/write credentials. Remove or replace it.`); + } + const configExists = existsSync(path.join(globalRoot, CONFIG_FILE_NAME)); + return buildScope("global", globalRoot, configExists); +} + +function resolveScope(opts?: { startDir?: string }): ResolvedScope { + if (scopeOverride === "global") { + return resolveGlobal(); + } + + const start = opts?.startDir ?? process.cwd(); + + let current = path.resolve(start); + for (let depth = 0; depth < MAX_WALK_DEPTH; depth++) { + if (existsSync(path.join(current, CONFIG_FILE_NAME))) { + return buildScope("local", current, true); + } + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + + return resolveGlobal(); +} + +/** + * @description Throws an actionable error if the resolved scope is global. + * Used by analysis-* command handlers as their first action — analysis + * development requires a project directory because the analysis source files + * live there. + */ +function requireLocalScope(commandName: string): ResolvedScope { + const scope = resolveScope(); + if (scope.scope !== "local") { + errorHandler(`'${commandName}' requires a project directory with tagoconfig.json. cd into a project root or run \`tagoio init\` here.`); + } + return scope; +} + +export { resolveScope, globalConfigDir, requireLocalScope, setScopeOverride, ResolvedScope }; diff --git a/src/lib/scope-notice.test.ts b/src/lib/scope-notice.test.ts new file mode 100644 index 0000000..1320d0d --- /dev/null +++ b/src/lib/scope-notice.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { ResolvedScope } from "./resolve-scope.js"; + +const existsSyncMock = vi.fn<(path: string) => boolean>(); +const mkdirSyncMock = vi.fn(); +const writeFileSyncMock = vi.fn(); + +vi.mock("node:fs", () => ({ + existsSync: existsSyncMock, + mkdirSync: mkdirSyncMock, + writeFileSync: writeFileSyncMock, +})); + +vi.mock("node:os", () => ({ + default: { homedir: () => "/home/user" }, + homedir: () => "/home/user", +})); + +const SENTINEL_PATH = "/home/user/.tagoio/.scope-notice-shown"; + +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("scope-notice", () => { + let stderrSpy: ReturnType; + + beforeEach(() => { + existsSyncMock.mockReset(); + mkdirSyncMock.mockReset(); + writeFileSyncMock.mockReset(); + stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("maybeShowScopeNotice", () => { + test("emits the local-profile notice and writes the sentinel on first call", async () => { + existsSyncMock.mockReturnValue(false); + + const { maybeShowScopeNotice } = await import("./scope-notice.js"); + maybeShowScopeNotice(localScope); + + expect(stderrSpy).toHaveBeenCalledOnce(); + const written = stderrSpy.mock.calls[0][0] as string; + expect(written).toContain("[INFO]"); + expect(written).toContain("global and local profiles"); + expect(written).toContain("local profile"); + expect(written).toContain("This message will not appear again"); + + expect(mkdirSyncMock).toHaveBeenCalledWith("/home/user/.tagoio", { recursive: true }); + expect(writeFileSyncMock).toHaveBeenCalledWith(SENTINEL_PATH, ""); + }); + + test("no-ops when the sentinel already exists (subsequent runs silent)", async () => { + existsSyncMock.mockImplementation((p: string) => p === SENTINEL_PATH); + + const { maybeShowScopeNotice } = await import("./scope-notice.js"); + maybeShowScopeNotice(localScope); + + expect(stderrSpy).not.toHaveBeenCalled(); + expect(writeFileSyncMock).not.toHaveBeenCalled(); + }); + + test("does not emit on global scope (fresh user → init guides them)", async () => { + existsSyncMock.mockReturnValue(false); + + const { maybeShowScopeNotice } = await import("./scope-notice.js"); + maybeShowScopeNotice(globalScope); + + expect(stderrSpy).not.toHaveBeenCalled(); + expect(writeFileSyncMock).not.toHaveBeenCalled(); + }); + + test("never throws if the sentinel write fails (read-only $HOME)", async () => { + existsSyncMock.mockReturnValue(false); + writeFileSyncMock.mockImplementation(() => { + throw new Error("EROFS: read-only filesystem"); + }); + + const { maybeShowScopeNotice } = await import("./scope-notice.js"); + expect(() => maybeShowScopeNotice(localScope)).not.toThrow(); + + // Notice still emitted even though sentinel write failed. + expect(stderrSpy).toHaveBeenCalledOnce(); + }); + }); + + describe("printScopeBanner", () => { + test("writes [INFO] Using local profile () to stderr", async () => { + const { printScopeBanner } = await import("./scope-notice.js"); + printScopeBanner(localScope); + + expect(stderrSpy).toHaveBeenCalledOnce(); + expect(stderrSpy.mock.calls[0][0]).toBe("[INFO] Using local profile (/repo/tagoconfig.json)\n"); + }); + + test("writes [INFO] Using global profile () to stderr", async () => { + const { printScopeBanner } = await import("./scope-notice.js"); + printScopeBanner(globalScope); + + expect(stderrSpy).toHaveBeenCalledOnce(); + expect(stderrSpy.mock.calls[0][0]).toBe( + "[INFO] Using global profile (/home/user/.config/tagoio/tagoconfig.json)\n", + ); + }); + + test("suppresses the banner under --silent", async () => { + const { printScopeBanner } = await import("./scope-notice.js"); + printScopeBanner(localScope, true); + + expect(stderrSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/lib/scope-notice.ts b/src/lib/scope-notice.ts new file mode 100644 index 0000000..34eef48 --- /dev/null +++ b/src/lib/scope-notice.ts @@ -0,0 +1,60 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { ResolvedScope } from "./resolve-scope.js"; + +/** + * @description Emits a one-time stderr notice announcing the new global/local + * profile model. Suppressed by a per-user sentinel at + * `~/.tagoio/.scope-notice-shown` so it fires once per machine — never per + * project, which would risk the sentinel being committed via `git add .` and + * silencing the notice for teammates. + * + * Best-effort: failures to write the sentinel are swallowed so a read-only + * home directory never blocks the command. + */ +function maybeShowScopeNotice(scope: ResolvedScope): void { + const sentinel = path.join(os.homedir(), ".tagoio", ".scope-notice-shown"); + if (existsSync(sentinel)) { + return; + } + + if (scope.scope !== "local") { + // Global scope: a fresh user with no config will be guided by `tagoio init`. + // Nothing to announce. + return; + } + + process.stderr.write( + `[INFO] tagoio now supports global and local profiles. This project is using the local profile. ` + + `Run \`tagoio init --scope global\` to set up a global profile. ` + + `This message will not appear again.\n`, + ); + + try { + mkdirSync(path.dirname(sentinel), { recursive: true }); + writeFileSync(sentinel, ""); + } catch { + /* best-effort; never fail the command for sentinel write */ + } +} + +/** + * @description Prints a one-line `[INFO] Using profile ()` + * banner to stderr. Mutating-command handlers call this as their first action + * so the user sees which profile they are about to mutate before the command + * runs. + * + * Suppressed when `silent` is true, matching the existing `--silent` contract + * (silent users explicitly opted out of all stderr noise; the banner is a UX + * safety net, not a security gate). + */ +function printScopeBanner(scope: ResolvedScope, silent = false): void { + if (silent) { + return; + } + process.stderr.write(`[INFO] Using ${scope.scope} profile (${scope.configPath})\n`); +} + +export { maybeShowScopeNotice, printScopeBanner }; diff --git a/src/lib/token.test.ts b/src/lib/token.test.ts index 42794d1..45d284e 100644 --- a/src/lib/token.test.ts +++ b/src/lib/token.test.ts @@ -1,33 +1,54 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { ResolvedScope } from "./resolve-scope.js"; + const readFileSyncMock = vi.fn(); const writeFileSyncMock = vi.fn(); +const mkdirSyncMock = vi.fn(); const addOnGitIgnoreMock = vi.fn(); -const getCurrentFolderMock = vi.fn(); +const resolveScopeMock = vi.fn<() => ResolvedScope>(); vi.mock("node:fs", () => ({ readFileSync: readFileSyncMock, writeFileSync: writeFileSyncMock, + mkdirSync: mkdirSyncMock, })); vi.mock("node:crypto", () => ({ randomBytes: (n: number) => Buffer.alloc(n, 0x61), // deterministic: "a" byte repeated })); -vi.mock("./get-current-folder.js", () => ({ - getCurrentFolder: () => getCurrentFolderMock(), +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("token", () => { beforeEach(() => { readFileSyncMock.mockReset(); writeFileSyncMock.mockReset(); + mkdirSyncMock.mockReset(); addOnGitIgnoreMock.mockReset(); - getCurrentFolderMock.mockReset().mockReturnValue("/repo"); + resolveScopeMock.mockReset().mockReturnValue(localScope); }); afterEach(() => { @@ -36,7 +57,6 @@ describe("token", () => { describe("readToken", () => { test("returns the decoded token from the last line of the lock file", async () => { - // File is: 500 decoy hex lines + final line = hex-encoded real token const realToken = "real-profile-token-abc"; const hexLine = Buffer.from(realToken).toString("hex"); readFileSyncMock.mockReturnValue(`decoy-line-1\ndecoy-line-2\n${hexLine}`); @@ -46,6 +66,19 @@ describe("token", () => { expect(readFileSyncMock).toHaveBeenCalledWith("/repo/.tago-lock.prod.lock", { encoding: "utf-8" }); }); + test("reads from the global scope root when scope is global", async () => { + resolveScopeMock.mockReturnValue(globalScope); + const realToken = "global-token-xyz"; + readFileSyncMock.mockReturnValue(Buffer.from(realToken).toString("hex")); + + const { readToken } = await import("./token.js"); + expect(readToken("prod")).toBe(realToken); + expect(readFileSyncMock).toHaveBeenCalledWith( + "/home/user/.config/tagoio/.tago-lock.prod.lock", + { encoding: "utf-8" }, + ); + }); + test("returns undefined when the lock file does not exist (ENOENT)", async () => { readFileSyncMock.mockImplementation(() => { throw new Error("ENOENT"); @@ -56,32 +89,56 @@ describe("token", () => { }); }); - describe("writeToken", () => { + describe("writeToken (local scope)", () => { test("writes 500 decoy lines + hex-encoded token and registers the lock file in .gitignore", async () => { const { writeToken } = await import("./token.js"); writeToken("secret-token", "staging"); expect(writeFileSyncMock).toHaveBeenCalledOnce(); - const [path, content, opts] = writeFileSyncMock.mock.calls[0]; - expect(path).toBe("/repo/.tago-lock.staging.lock"); + const [filePath, content, opts] = writeFileSyncMock.mock.calls[0]; + expect(filePath).toBe("/repo/.tago-lock.staging.lock"); expect(opts).toEqual({ encoding: "utf-8" }); const lines = (content as string).split("\n"); - // 500 decoys + 1 token line (no trailing newline before token) → 501 entries expect(lines).toHaveLength(501); const decoded = Buffer.from(lines[500] as string, "hex").toString(); expect(decoded).toBe("secret-token"); expect(addOnGitIgnoreMock).toHaveBeenCalledWith("/repo", ".tago-lock.staging.lock"); + expect(mkdirSyncMock).not.toHaveBeenCalled(); }); + }); - test("returns silently without writing when getCurrentFolder yields an empty path", async () => { - getCurrentFolderMock.mockReturnValue(""); + describe("writeToken (global scope) — S1 file/dir permissions", () => { + beforeEach(() => { + resolveScopeMock.mockReturnValue(globalScope); + }); + test("creates the global config dir with mode 0o700 if missing", async () => { const { writeToken } = await import("./token.js"); - writeToken("secret-token", "staging"); + writeToken("global-secret", "prod"); + + expect(mkdirSyncMock).toHaveBeenCalledOnce(); + expect(mkdirSyncMock).toHaveBeenCalledWith("/home/user/.config/tagoio", { + recursive: true, + mode: 0o700, + }); + }); + + test("writes the lock file with mode 0o600 (unreadable by other local users)", async () => { + const { writeToken } = await import("./token.js"); + writeToken("global-secret", "prod"); + + expect(writeFileSyncMock).toHaveBeenCalledOnce(); + const [filePath, , opts] = writeFileSyncMock.mock.calls[0]; + expect(filePath).toBe("/home/user/.config/tagoio/.tago-lock.prod.lock"); + expect(opts).toEqual({ encoding: "utf-8", mode: 0o600 }); + }); + + test("does NOT write to .gitignore on global scope (not a git project)", async () => { + const { writeToken } = await import("./token.js"); + writeToken("global-secret", "prod"); - expect(writeFileSyncMock).not.toHaveBeenCalled(); expect(addOnGitIgnoreMock).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/token.ts b/src/lib/token.ts index 975fa86..2d9464a 100644 --- a/src/lib/token.ts +++ b/src/lib/token.ts @@ -1,13 +1,18 @@ import { randomBytes } from "node:crypto"; -import { readFileSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; + import { addOnGitIgnore } from "./add-to-gitignore.js"; -import { getCurrentFolder } from "./get-current-folder.js"; +import { resolveScope } from "./resolve-scope.js"; -function readToken(environment: string) { - const folder = getCurrentFolder(); +function lockFilePath(environment: string): string { + const scope = resolveScope(); + return path.join(scope.root, `.tago-lock.${environment}.lock`); +} +function readToken(environment: string) { try { - const tokenFile = readFileSync(`${folder}/.tago-lock.${environment}.lock`, { encoding: "utf-8" }); + const tokenFile = readFileSync(lockFilePath(environment), { encoding: "utf-8" }); const tokenDirty = tokenFile.split("\n"); const token = Buffer.from(tokenDirty.at(-1) as string, "hex").toString(); @@ -18,10 +23,7 @@ function readToken(environment: string) { } function writeToken(token: string, environment: string) { - const folder = getCurrentFolder(); - if (!folder) { - return; - } + const scope = resolveScope(); let dirtyText = ""; for (let index = 0; index < 500; index++) { @@ -30,9 +32,17 @@ function writeToken(token: string, environment: string) { } const tokenFile = dirtyText + Buffer.from(token).toString("hex"); + const filePath = path.join(scope.root, `.tago-lock.${environment}.lock`); + + if (scope.scope === "global") { + // S1: global lock files must be unreadable by other local users. + mkdirSync(scope.root, { recursive: true, mode: 0o700 }); + writeFileSync(filePath, tokenFile, { encoding: "utf-8", mode: 0o600 }); + return; + } - writeFileSync(`${folder}/.tago-lock.${environment}.lock`, tokenFile, { encoding: "utf-8" }); - addOnGitIgnore(folder, `.tago-lock.${environment}.lock`); + writeFileSync(filePath, tokenFile, { encoding: "utf-8" }); + addOnGitIgnore(scope.root, `.tago-lock.${environment}.lock`); } export { readToken, writeToken }; diff --git a/src/prompt/choose-analysis-list-config.ts b/src/prompt/choose-analysis-list-config.ts index 0de5d86..e81d306 100644 --- a/src/prompt/choose-analysis-list-config.ts +++ b/src/prompt/choose-analysis-list-config.ts @@ -8,7 +8,7 @@ import { IEnvironment } from "../lib/config-file.js"; * @param message - The message to display to the user when prompting them to choose the analysis. * @returns The list of analysis chosen by the user. */ -async function chooseAnalysisListFromConfig(analysis: IEnvironment["analysisList"], message: string = "Choose the analysis") { +async function chooseAnalysisListFromConfig(analysis: NonNullable, message: string = "Choose the analysis") { const { scripts } = await prompts({ message, name: "scripts", @@ -16,7 +16,7 @@ async function chooseAnalysisListFromConfig(analysis: IEnvironment["analysisList choices: analysis.map((x) => ({ title: `${x.fileName} [${x.name}]`, value: x })), }); - return (scripts || []) as IEnvironment["analysisList"]; + return (scripts || []) as NonNullable; } export { chooseAnalysisListFromConfig }; diff --git a/src/prompt/confirm-analysis-list.ts b/src/prompt/confirm-analysis-list.ts index c8bf594..3bf1005 100644 --- a/src/prompt/confirm-analysis-list.ts +++ b/src/prompt/confirm-analysis-list.ts @@ -1,7 +1,7 @@ import prompts from "prompts"; import { IEnvironment } from "../lib/config-file.js"; -async function confirmAnalysisFromConfig(analysis: IEnvironment["analysisList"], message: string = "Do you confirm the following analysis?") { +async function confirmAnalysisFromConfig(analysis: NonNullable, message: string = "Do you confirm the following analysis?") { const { scripts } = await prompts({ message, name: "scripts", @@ -9,7 +9,7 @@ async function confirmAnalysisFromConfig(analysis: IEnvironment["analysisList"], choices: analysis.map((x) => ({ title: `${x.fileName} [${x.name}]`, value: x, selected: true })), }); - return (scripts || []) as IEnvironment["analysisList"]; + return (scripts || []) as NonNullable; } export { confirmAnalysisFromConfig }; diff --git a/src/prompt/pick-analysis-from-config.ts b/src/prompt/pick-analysis-from-config.ts index 2614732..82ebe3d 100644 --- a/src/prompt/pick-analysis-from-config.ts +++ b/src/prompt/pick-analysis-from-config.ts @@ -4,27 +4,30 @@ import prompts from "prompts"; import { IEnvironment } from "../lib/config-file.js"; import { errorHandler } from "../lib/messages.js"; -const colorAnalysisName = (x: IEnvironment["analysisList"][0]) => (x.fileName ? `${x.fileName} [${kleur.cyan(x.name)}]` : x.name); +type AnalysisEntry = NonNullable[number]; + +const colorAnalysisName = (x: AnalysisEntry) => (x.fileName ? `${x.fileName} [${kleur.cyan(x.name)}]` : x.name); /** * Prompts the user to select an analysis from a list of available analyses. - * @param analysisList - The list of available analyses. - * @param message - The message to display to the user. - * @returns The selected analysis. + * Analysis-* commands call `requireLocalScope()` first, so by the time this + * prompt runs the list is guaranteed to exist (it's only undefined for global + * scope, which never reaches here). */ async function pickAnalysisFromConfig(analysisList: IEnvironment["analysisList"], message: string = "Pick the analysis") { + const list = analysisList ?? []; const { script } = await prompts({ message, name: "script", type: "autocomplete", - choices: analysisList.map((x) => ({ title: colorAnalysisName(x), value: x })), + choices: list.map((x) => ({ title: colorAnalysisName(x), value: x })), }); if (!script) { errorHandler("Analysis not selected"); } - return script as IEnvironment["analysisList"][0]; + return script as AnalysisEntry; } export { pickAnalysisFromConfig }; diff --git a/vitest.config.js b/vitest.config.js index eb91605..557275f 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -16,6 +16,16 @@ export default defineConfig({ "**/*.d.ts", "**/mock/**", "**/*.json", + // Entry point: integration glue, not unit-testable; runtime behaviour + // is exercised by every live invocation. + "index.ts", + // Interactive prompt-driven flows. Unit-testing each prompt branch + // would test the prompts library, not our logic. These commands are + // covered by manual smokes (see docs/pr-30-test-plan.md sections + // T-2.init and T-6.backup.restore). + "commands/start-config.ts", + "commands/profile/backup/restore.ts", + "commands/profile/export/export-setup.ts", ], thresholds: { lines: 80,