diff --git a/package-lock.json b/package-lock.json index 5ea7944..4de3e43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@aws-sdk/credential-providers": "^3.1038.0", "@smithy/shared-ini-file-loader": "^4.4.9", "@tigrisdata/iam": "^2.1.1", - "@tigrisdata/storage": "^3.2.1", + "@tigrisdata/storage": "^3.4.0", "commander": "^14.0.3", "enquirer": "^2.4.1", "jose": "^6.2.3", @@ -345,32 +345,32 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.1038.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1038.0.tgz", - "integrity": "sha512-k60qm50bWkaqNfCJe1z28WaqgpztE0wbWVMZw6ZJcTOGfrWFhsJeLCEqtkH8w00iEozKx9GQwdQXz4G0sMGdKA==", + "version": "3.1044.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1044.0.tgz", + "integrity": "sha512-yT3g0Oi0b+pJBJswNxRwWLLBoExQhRx9Iz2rUy1xV0slMogTQN+DSjChI95XTDtpGEcY0qnIK6UYX0XCYdhOKg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/credential-provider-node": "^3.972.37", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-node": "^3.972.39", "@aws-sdk/middleware-bucket-endpoint": "^3.972.10", "@aws-sdk/middleware-expect-continue": "^3.972.10", - "@aws-sdk/middleware-flexible-checksums": "^3.974.14", + "@aws-sdk/middleware-flexible-checksums": "^3.974.16", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-location-constraint": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-sdk-s3": "^3.972.35", + "@aws-sdk/middleware-sdk-s3": "^3.972.37", "@aws-sdk/middleware-ssec": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/middleware-user-agent": "^3.972.38", "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/signature-v4-multi-region": "^3.996.23", + "@aws-sdk/signature-v4-multi-region": "^3.996.25", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.22", + "@aws-sdk/util-user-agent-node": "^3.973.24", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/eventstream-serde-browser": "^4.2.14", @@ -384,7 +384,7 @@ "@smithy/md5-js": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.6", + "@smithy/middleware-retry": "^4.5.7", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -400,7 +400,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", + "@smithy/util-retry": "^4.3.6", "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.3.0", @@ -411,13 +411,13 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.6.tgz", - "integrity": "sha512-8Vu7zGxu+39ChR/s5J7nXBw3a2kMHAi0OfKT8ohgTVjX0qYed/8mIfdBb638oBmKrWCwwKjYAM5J/4gMJ8nAJA==", + "version": "3.974.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.8.tgz", + "integrity": "sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", - "@aws-sdk/xml-builder": "^3.972.20", + "@aws-sdk/xml-builder": "^3.972.22", "@smithy/core": "^3.23.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", @@ -427,7 +427,7 @@ "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", + "@smithy/util-retry": "^4.3.6", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -465,12 +465,12 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.32", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.32.tgz", - "integrity": "sha512-7vA4GHg8NSmQxquJHSBcSM3RgB4ZaaRi6u4+zGFKOmOH6aqlgr2Sda46clkZDYzlirgfY96w15Zj0jh6PT48ng==", + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.34.tgz", + "integrity": "sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", + "@aws-sdk/core": "^3.974.8", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/types": "^4.14.1", @@ -481,12 +481,12 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.34", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.34.tgz", - "integrity": "sha512-vBrhWujFCLp1u8ptJRWYlipMutzPptb8pDQ00rKVH9q67T7rGd3VTWIj63aKrlLuY6qSsw1Rt5F/D/7wnNgryA==", + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.36.tgz", + "integrity": "sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", + "@aws-sdk/core": "^3.974.8", "@aws-sdk/types": "^3.973.8", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/node-http-handler": "^4.6.1", @@ -502,19 +502,19 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.36.tgz", - "integrity": "sha512-FBHyCmV8EB0gUvh1d+CZm87zt2PrdC7OyWexLRoH3I5zWSOUGa+9t58Y5jbxRfwUp3AWpHAFvKY6YzgR845sVA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/credential-provider-env": "^3.972.32", - "@aws-sdk/credential-provider-http": "^3.972.34", - "@aws-sdk/credential-provider-login": "^3.972.36", - "@aws-sdk/credential-provider-process": "^3.972.32", - "@aws-sdk/credential-provider-sso": "^3.972.36", - "@aws-sdk/credential-provider-web-identity": "^3.972.36", - "@aws-sdk/nested-clients": "^3.997.4", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.38.tgz", + "integrity": "sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-env": "^3.972.34", + "@aws-sdk/credential-provider-http": "^3.972.36", + "@aws-sdk/credential-provider-login": "^3.972.38", + "@aws-sdk/credential-provider-process": "^3.972.34", + "@aws-sdk/credential-provider-sso": "^3.972.38", + "@aws-sdk/credential-provider-web-identity": "^3.972.38", + "@aws-sdk/nested-clients": "^3.997.6", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", @@ -527,13 +527,13 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.36.tgz", - "integrity": "sha512-IFap01lJKxQc0C/OHmZwZQr/cKq0DhrcmKedRrdnnl42D+P0SImnnnWQjv07uIPqpEdtqmkPXb9TiPYTU+prxQ==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.38.tgz", + "integrity": "sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/nested-clients": "^3.997.4", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/nested-clients": "^3.997.6", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", @@ -546,17 +546,17 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.37", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.37.tgz", - "integrity": "sha512-/WFixFAAiw8WpmjZcI0l4t3DerXLmVinOIfuotmRZnu2qmsFPoqqmstASz0z8bi1pGdFXzeLzf6bwucM3mZcUQ==", + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.39.tgz", + "integrity": "sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.32", - "@aws-sdk/credential-provider-http": "^3.972.34", - "@aws-sdk/credential-provider-ini": "^3.972.36", - "@aws-sdk/credential-provider-process": "^3.972.32", - "@aws-sdk/credential-provider-sso": "^3.972.36", - "@aws-sdk/credential-provider-web-identity": "^3.972.36", + "@aws-sdk/credential-provider-env": "^3.972.34", + "@aws-sdk/credential-provider-http": "^3.972.36", + "@aws-sdk/credential-provider-ini": "^3.972.38", + "@aws-sdk/credential-provider-process": "^3.972.34", + "@aws-sdk/credential-provider-sso": "^3.972.38", + "@aws-sdk/credential-provider-web-identity": "^3.972.38", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", @@ -569,12 +569,12 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.32", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.32.tgz", - "integrity": "sha512-uZp4tlGbpczV8QxmtIwOpSkcyGtBRR8/T4BAumRKfAt1nwCig3FSCZvrKl6ARDIDVRYn5p2oRcAsfFR01EgMGA==", + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.34.tgz", + "integrity": "sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", + "@aws-sdk/core": "^3.974.8", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", @@ -586,14 +586,14 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.36.tgz", - "integrity": "sha512-DsLr0UHMyKzRJKe2bjlwU8q1cfoXg8TIJKV/xwvnalAemiZLOZunFzj/whGnFDZIBVLdnbLiwv5SvRf1+CSwkg==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.38.tgz", + "integrity": "sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/nested-clients": "^3.997.4", - "@aws-sdk/token-providers": "3.1038.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/token-providers": "3.1041.0", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", @@ -605,13 +605,13 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.36.tgz", - "integrity": "sha512-uzrURO7frJhHQVVNR5zBJcCYeMYflmXcWBK1+MiBym2Dfjh6nXATrMixrmGZi+97Q7ETZ+y/4lUwAy0Nfnznjw==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.38.tgz", + "integrity": "sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/nested-clients": "^3.997.4", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/nested-clients": "^3.997.6", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", @@ -654,9 +654,9 @@ } }, "node_modules/@aws-sdk/lib-storage": { - "version": "3.1038.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.1038.0.tgz", - "integrity": "sha512-FEGuFSUL9gNfyWf4KcOgzhLiqQgSSvpML3YPnJbj8k2nSKdgyRznXxg8zd4W+NKoVehtNqXwFBvMXeHyOYlOrg==", + "version": "3.1044.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.1044.0.tgz", + "integrity": "sha512-VMyTkaF87RwDmrNPMmfxRADc4SIU0P85q/WzMpr+6e8MfLzHA/lUSkndM4FLEcEBh/AYUUqbBPHxs+WT6xIHLA==", "license": "Apache-2.0", "dependencies": { "@smithy/middleware-endpoint": "^4.4.32", @@ -672,7 +672,7 @@ "node": ">=20.0.0" }, "peerDependencies": { - "@aws-sdk/client-s3": "^3.1038.0" + "@aws-sdk/client-s3": "^3.1044.0" } }, "node_modules/@aws-sdk/middleware-bucket-endpoint": { @@ -709,15 +709,15 @@ } }, "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.974.14", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.14.tgz", - "integrity": "sha512-mhTO3amGzYv/DQNbbqZo6UkHquBHlEEVRZwXmjeRqLmy1l9z3xCiFzglPL7n9JpVc2DZc9kjaraAn3JQrueZbw==", + "version": "3.974.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.16.tgz", + "integrity": "sha512-6ru8doI0/XzszqLIPXf0E/V7HhAw1Pu94010XCKYtBUfD0LxF0BuOzrUf8OQGR6j2o6wgKTHUniOmndQycHwCA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "^3.974.6", + "@aws-sdk/core": "^3.974.8", "@aws-sdk/crc64-nvme": "^3.972.7", "@aws-sdk/types": "^3.973.8", "@smithy/is-array-buffer": "^4.2.2", @@ -793,12 +793,12 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.35", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.35.tgz", - "integrity": "sha512-lLppaNTAz+wNgLdi4FtHzrlwrGF0ODTnBWHBaFg85SKs0eJ+M+tP5ifrA8f/0lNd+Ak3MC1NGC6RavV3ny4HTg==", + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.37.tgz", + "integrity": "sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", + "@aws-sdk/core": "^3.974.8", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.17", @@ -832,18 +832,18 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.36.tgz", - "integrity": "sha512-O2beToxguBvrZFFZ+fFgPbbae8MvyIBjQ6lImee4APHEXXNAD5ZJ2ayLF1mb7rsKw86TM81y5czg82bZncjSjg==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.38.tgz", + "integrity": "sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", + "@aws-sdk/core": "^3.974.8", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@smithy/core": "^3.23.17", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", - "@smithy/util-retry": "^4.3.5", + "@smithy/util-retry": "^4.3.6", "tslib": "^2.6.2" }, "engines": { @@ -851,24 +851,24 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.4.tgz", - "integrity": "sha512-4Sf+WY1lMJzXlw5MiyCMe/UzdILCwvuaHThbqMXS6dfh9gZy3No360I42RXquOI/ULUOhWy2HCyU0Fp20fQGPQ==", + "version": "3.997.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.6.tgz", + "integrity": "sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.6", + "@aws-sdk/core": "^3.974.8", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/middleware-user-agent": "^3.972.38", "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/signature-v4-multi-region": "^3.996.23", + "@aws-sdk/signature-v4-multi-region": "^3.996.25", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.22", + "@aws-sdk/util-user-agent-node": "^3.973.24", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -876,7 +876,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.6", + "@smithy/middleware-retry": "^4.5.7", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -892,7 +892,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", + "@smithy/util-retry": "^4.3.6", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -917,12 +917,12 @@ } }, "node_modules/@aws-sdk/s3-request-presigner": { - "version": "3.1038.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1038.0.tgz", - "integrity": "sha512-2PNCm+2Mx8v2GKRREKMS3PavahzRhmMMJjuJxUpLneQV4w3oMs2bpme62oU6l+hip1pyeyPimWHeabjhaURocw==", + "version": "3.1044.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1044.0.tgz", + "integrity": "sha512-ix8UtiNC5g1wv3TIcgTnvWdugyw8dSsBGwZZzVVoGyYjZH9UJLqiOyvVu6apptlPBeE6aV6Fabsx0b1xYFd2ZA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/signature-v4-multi-region": "^3.996.23", + "@aws-sdk/signature-v4-multi-region": "^3.996.25", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-format-url": "^3.972.10", "@smithy/middleware-endpoint": "^4.4.32", @@ -936,12 +936,12 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.23", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.23.tgz", - "integrity": "sha512-wBbys3Y53Ikly556vyADurKpYQHXS7Jjaskbz+Ga9PZCz7PB/9f3VdKbDlz7dqIzn+xwz7L/a6TR4iXcOi8IRw==", + "version": "3.996.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.25.tgz", + "integrity": "sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-sdk-s3": "^3.972.35", + "@aws-sdk/middleware-sdk-s3": "^3.972.37", "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", @@ -953,13 +953,13 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1038.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1038.0.tgz", - "integrity": "sha512-Qniru+9oGGb/HNK/gGZWbV3jsD0k71ngE7qMQ/x6gYNYLd2EOwHCS6E2E6jfkaqO4i0d+nNKmfRy8bNcshKdGQ==", + "version": "3.1041.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1041.0.tgz", + "integrity": "sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/nested-clients": "^3.997.4", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/nested-clients": "^3.997.6", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", @@ -1051,12 +1051,12 @@ } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.22", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.22.tgz", - "integrity": "sha512-YTYqTmOUrwbm1h99Ee4y/mVYpFRl0oSO/amtP5cc1BZZWdaAVWs9zj3TkyRHWvR9aI/ZS8m3mS6awXtYUlWyaw==", + "version": "3.973.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.24.tgz", + "integrity": "sha512-ZWwlkjcIp7cEL8ZfTpTAPNkwx25p7xol0xlKoWVVf22+nsjwmLcHYtTPjIV1cSpmB/b6DaK4cb1fSkvCXHgRdw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/middleware-user-agent": "^3.972.38", "@aws-sdk/types": "^3.973.8", "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", @@ -1076,9 +1076,9 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.21", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.21.tgz", - "integrity": "sha512-qxNiHUtlrsjTeSlrPWiFkWps7uD6YB4eKzg7eLAFH8jbiHTlt0ePNlo2Xu+WlftP38JIcMaIX4jTUjOlE2ySWw==", + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz", + "integrity": "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==", "license": "Apache-2.0", "dependencies": { "@nodable/entities": "2.1.0", @@ -4031,9 +4031,9 @@ } }, "node_modules/@tigrisdata/storage": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@tigrisdata/storage/-/storage-3.2.1.tgz", - "integrity": "sha512-mbOVzSAJ0KPTJjhGbsNo6ok1gAmnmALWB3AuBHPky1+lXfvFty96gDYWteu7Vcz5hLJnKuo3Cni2Nk6o8d6cHg==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@tigrisdata/storage/-/storage-3.4.0.tgz", + "integrity": "sha512-1eortp51PyHkBb5NSg6HjYApgOmFOtpMxzGrUcDsDnszUD3qq/yHlYGg9Jj62vqUei9s/dNiL6AyAw2Drjb1bw==", "license": "MIT", "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", @@ -5899,9 +5899,9 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-builder": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", - "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.9.tgz", + "integrity": "sha512-jcyKVSEX13iseJqg7n/KWw+xnu/7fdrZ333Fac54KjHDIELVCfDDJXYIm6DTJ0Su4gSzrhqiK0DzY/wZbF40mw==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index 416f1f4..95fdd47 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "scripts": { "build": "tsc --noEmit && tsup", "dev": "export $(grep -v '^#' .env.test | xargs) && (tsc --noEmit --watch --preserveWatchOutput & tsup --watch)", - "cli": "node dist/cli.js", + "cli": "export $(grep -v '^#' .env.test | xargs) && node dist/cli.js", "lint": "eslint src test", "lint:fix": "eslint src test --fix", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", @@ -93,7 +93,7 @@ "@aws-sdk/credential-providers": "^3.1038.0", "@smithy/shared-ini-file-loader": "^4.4.9", "@tigrisdata/iam": "^2.1.1", - "@tigrisdata/storage": "^3.2.1", + "@tigrisdata/storage": "^3.4.0", "commander": "^14.0.3", "enquirer": "^2.4.1", "jose": "^6.2.3", diff --git a/scripts/generate-registry.ts b/scripts/generate-registry.ts index 4535f37..6079363 100644 --- a/scripts/generate-registry.ts +++ b/scripts/generate-registry.ts @@ -13,21 +13,12 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; import * as YAML from 'yaml'; +import type { CommandSpec, Specs } from '../src/types.js'; + const ROOT = process.cwd(); const SPECS_PATH = join(ROOT, 'src/specs.yaml'); const OUTPUT_PATH = join(ROOT, 'src/command-registry.ts'); -interface CommandSpec { - name: string; - alias?: string; - commands?: CommandSpec[]; - default?: string; -} - -interface Specs { - commands: CommandSpec[]; -} - interface RegistryEntry { key: string; importName: string; @@ -88,6 +79,10 @@ function collectEntries( const entries: RegistryEntry[] = []; for (const cmd of commands) { + // Removed commands have no implementation file by design — the + // cli-core intercepts them and prints a redirect message. + if (cmd.removed) continue; + const currentPath = [...parentPath, cmd.name]; if (cmd.commands && cmd.commands.length > 0) { diff --git a/scripts/update-docs.ts b/scripts/update-docs.ts index 8b57fe8..776c9b3 100644 --- a/scripts/update-docs.ts +++ b/scripts/update-docs.ts @@ -23,8 +23,11 @@ function isImplemented(...parts: string[]): boolean { return paths.some((p) => existsSync(p) && !p.includes('/_')); } -// Check if a command or any of its nested subcommands are implemented +// Check if a command or any of its nested subcommands are implemented. +// Removed commands are tombstones and never count as "implemented" for +// docs purposes — they shouldn't appear in the rendered README. function hasImplementation(cmd: CommandSpec, ...parentParts: string[]): boolean { + if (cmd.removed) return false; const parts = [...parentParts, cmd.name]; if (isImplemented(...parts)) return true; if (cmd.commands) { @@ -49,7 +52,7 @@ function generateCommandSection(cmd: CommandSpec): string { lines.push(`### \`${cmd.name}\`${aliasStr}`); lines.push(''); - lines.push(cmd.description); + lines.push(cmd.description ?? ''); lines.push(''); lines.push('```'); const usage = getCommandUsage(cmd); @@ -58,7 +61,8 @@ function generateCommandSection(cmd: CommandSpec): string { lines.push('```'); lines.push(''); - const flags = cmd.arguments?.filter((a) => a.type !== 'positional') || []; + const flags = + cmd.arguments?.filter((a) => a.type !== 'positional' && !a.removed) || []; if (flags.length > 0) { lines.push('| Flag | Description |'); lines.push('|------|-------------|'); @@ -113,7 +117,7 @@ function generateResourceSection( lines.push(`${headerLevel} \`${fullName}\`${aliasStr}`); lines.push(''); - lines.push(cmd.description); + lines.push(cmd.description ?? ''); lines.push(''); const subcommands = cmd.commands || []; @@ -270,7 +274,7 @@ function generateDocs(specs: Specs): string { } // Resource management - const resourceCommands = ['organizations', 'access-keys', 'credentials', 'buckets', 'forks', 'snapshots', 'objects', 'iam']; + const resourceCommands = ['organizations', 'access-keys', 'credentials', 'buckets', 'snapshots', 'objects', 'iam']; const implementedResources = resourceCommands.filter((c) => { const cmd = specs.commands.find((s) => s.name === c); if (!cmd) return false; @@ -341,12 +345,12 @@ function generateDocs(specs: Specs): string { } } - // Buckets section (buckets, forks, snapshots) - const bucketRelated = ['buckets', 'forks', 'snapshots'].filter((c) => implementedResources.includes(c)); + // Buckets section (buckets, snapshots) + const bucketRelated = ['buckets', 'snapshots'].filter((c) => implementedResources.includes(c)); if (bucketRelated.length > 0) { lines.push('### Buckets'); lines.push(''); - lines.push('Buckets are containers for objects. You can also create forks and snapshots of buckets.'); + lines.push('Buckets are containers for objects. You can also create snapshots of buckets.'); lines.push(''); for (const cmdName of bucketRelated) { diff --git a/src/auth/client.ts b/src/auth/client.ts index ad38d89..4f2e6a1 100644 --- a/src/auth/client.ts +++ b/src/auth/client.ts @@ -25,13 +25,13 @@ export interface Auth0Config { export function getAuth0Config(): Auth0Config { const isDev = process.env.TIGRIS_ENV === 'development'; const domain = isDev - ? 'auth-dev.tigris.dev' + ? (process.env.AUTH0_DOMAIN ?? 'auth-storage.tigris.dev') : (process.env.AUTH0_DOMAIN ?? 'auth.storage.tigrisdata.io'); const clientId = isDev - ? 'JdJVYIyw0O1uHi5L5OJH903qaWBgd3gF' + ? (process.env.AUTH0_CLIENT_ID ?? 'JdJVYIyw0O1uHi5L5OJH903qaWBgd3gF') : (process.env.AUTH0_CLIENT_ID ?? 'DMejqeM3CQ4IqTjEcd3oA9eEiT40hn8D'); const audience = isDev - ? 'https://tigris-api-dev' + ? (process.env.AUTH0_AUDIENCE ?? 'https://tigris-api-dev') : (process.env.AUTH0_AUDIENCE ?? 'https://tigris-os-api'); const claimsNamespace = process.env.TIGRIS_CLAIMS_NAMESPACE ?? 'https://tigris'; diff --git a/src/cli-core.ts b/src/cli-core.ts index 5fb585c..d12c83f 100644 --- a/src/cli-core.ts +++ b/src/cli-core.ts @@ -4,7 +4,7 @@ import { exitWithError } from '@utils/exit.js'; import { printDeprecated } from '@utils/messages.js'; -import { Command as CommanderCommand } from 'commander'; +import { Command as CommanderCommand, Option } from 'commander'; import type { Argument, CommandSpec, Specs } from './types.js'; @@ -164,6 +164,12 @@ export function commandHasAnyImplementation( pathParts: string[], hasImplementation: ImplementationChecker ): boolean { + // Removed commands are still registered so we can intercept and + // redirect users to the replacement instead of "unknown command". + if (command.removed) { + return true; + } + if (hasImplementation(pathParts)) { return true; } @@ -181,6 +187,40 @@ export function commandHasAnyImplementation( return false; } +/** + * Print a redirect message and exit. Used for hard-removed commands + * and arguments. `subject` is the human-readable thing the user invoked + * (e.g. `tigris buckets set-ttl` or `--region`). + */ +function printRemovedAndExit( + subject: string, + replacedBy: string | undefined +): never { + const hint = replacedBy + ? ` Use ${replacedBy} instead.` + : ' See the changelog for migration guidance.'; + console.error(`${subject} was removed in this version.${hint}`); + process.exit(1); +} + +/** + * Inspect parsed options for any argument the spec marks as removed. + * If the user supplied one, print the redirect and exit. + */ +function checkRemovedArguments( + args: Argument[] | undefined, + options: Record +): void { + if (!args) return; + for (const arg of args) { + if (!arg.removed) continue; + const value = getOptionValue(options, arg.name, args); + if (value !== undefined) { + printRemovedAndExit(`--${arg.name}`, arg.replaced_by); + } + } +} + export function showCommandHelp( specs: Specs, command: CommandSpec, @@ -188,15 +228,17 @@ export function showCommandHelp( hasImplementation: ImplementationChecker ) { const fullPath = pathParts.join(' '); - console.log(`\n${specs.name} ${fullPath} - ${command.description}\n`); + console.log(`\n${specs.name} ${fullPath} - ${command.description ?? ''}\n`); if (command.commands && command.commands.length > 0) { - const availableCmds = command.commands.filter((cmd) => - commandHasAnyImplementation( - cmd, - [...pathParts, cmd.name], - hasImplementation - ) + const availableCmds = command.commands.filter( + (cmd) => + !cmd.removed && + commandHasAnyImplementation( + cmd, + [...pathParts, cmd.name], + hasImplementation + ) ); if (availableCmds.length > 0) { @@ -208,14 +250,17 @@ export function showCommandHelp( cmdPart += ` (${aliases.join(', ')})`; } const paddedCmdPart = cmdPart.padEnd(24); - console.log(`${paddedCmdPart}${cmd.description}`); + console.log(`${paddedCmdPart}${cmd.description ?? ''}`); }); console.log(); } } const globalArgs = specs.definitions?.global_arguments ?? []; - const effectiveArgs = getEffectiveArguments(globalArgs, command.arguments); + const effectiveArgs = getEffectiveArguments( + globalArgs, + command.arguments + ).filter((arg) => !arg.removed); if (effectiveArgs.length > 0) { console.log('Arguments:'); effectiveArgs.forEach((arg) => { @@ -248,8 +293,10 @@ export function showMainHelp( console.log('Usage: tigris [command] [options]\n'); console.log('Commands:'); - const availableCommands = specs.commands.filter((cmd) => - commandHasAnyImplementation(cmd, [cmd.name], hasImplementation) + const availableCommands = specs.commands.filter( + (cmd) => + !cmd.removed && + commandHasAnyImplementation(cmd, [cmd.name], hasImplementation) ); availableCommands.forEach((command: CommandSpec) => { @@ -261,7 +308,7 @@ export function showMainHelp( commandPart += ` (${aliases.join(', ')})`; } const paddedCommandPart = commandPart.padEnd(24); - console.log(`${paddedCommandPart}${command.description}`); + console.log(`${paddedCommandPart}${command.description ?? ''}`); }); console.log( `\nUse "${specs.name} help" for more information about a command.` @@ -319,7 +366,15 @@ export function addArgumentsToCommand( arg.required || arg['required-when'] ? ' ' : ' [value]'; } - cmd.option(optionString, arg.description, arg.default); + if (arg.removed) { + // Register but hide from --help so commander still parses the + // value; the dispatch handler intercepts it post-parse. + cmd.addOption( + new Option(optionString, arg.description ?? '').hideHelp() + ); + } else { + cmd.option(optionString, arg.description ?? '', arg.default); + } } }); } @@ -493,13 +548,29 @@ export function registerCommands( continue; } - const cmd = parent.command(spec.name).description(spec.description); + const cmd = parent + .command(spec.name, spec.removed ? { hidden: true } : undefined) + .description(spec.description ?? ''); if (spec.alias) { const aliases = Array.isArray(spec.alias) ? spec.alias : [spec.alias]; aliases.forEach((alias) => cmd.alias(alias)); } + // Removed commands: register a redirect-and-exit action; skip + // children, arguments, and help registration entirely. + if (spec.removed) { + cmd.allowUnknownOption(true); + cmd.allowExcessArguments(true); + cmd.action(() => { + printRemovedAndExit( + `${specs.name} ${currentPath.join(' ')}`, + spec.replaced_by + ); + }); + continue; + } + if (spec.commands && spec.commands.length > 0) { // Has children - recurse registerCommands(config, cmd, spec.commands, currentPath); @@ -524,16 +595,21 @@ export function registerCommands( hasImplementation ); + const extracted = extractArgumentValues( + allArguments, + positionalArgs, + options + ); + if ( allArguments.length > 0 && - !validateRequiredWhen( - allArguments, - extractArgumentValues(allArguments, positionalArgs, options) - ) + !validateRequiredWhen(allArguments, extracted) ) { return; } + checkRemovedArguments(allArguments, extracted); + if (defaultCmd.deprecated && defaultCmd.messages?.onDeprecated) { printDeprecated(defaultCmd.messages.onDeprecated); } @@ -542,7 +618,7 @@ export function registerCommands( loadModule, [...currentPath, defaultCmd.name], positionalArgs, - extractArgumentValues(allArguments, positionalArgs, options) + extracted ); }); } @@ -570,16 +646,21 @@ export function registerCommands( const options = args.pop(); const positionalArgs = args; + const extracted = extractArgumentValues( + spec.arguments || [], + positionalArgs, + options + ); + if ( spec.arguments && - !validateRequiredWhen( - spec.arguments, - extractArgumentValues(spec.arguments, positionalArgs, options) - ) + !validateRequiredWhen(spec.arguments, extracted) ) { return; } + checkRemovedArguments(spec.arguments, extracted); + if (spec.deprecated && spec.messages?.onDeprecated) { printDeprecated(spec.messages.onDeprecated); } @@ -588,7 +669,7 @@ export function registerCommands( loadModule, currentPath, positionalArgs, - extractArgumentValues(spec.arguments || [], positionalArgs, options) + extracted ); }); } diff --git a/src/lib/buckets/create.ts b/src/lib/buckets/create.ts index 5d15266..e53b7c7 100644 --- a/src/lib/buckets/create.ts +++ b/src/lib/buckets/create.ts @@ -33,7 +33,7 @@ export default async function create(options: Record) { 'S', ]); let defaultTier = getOption(options, ['default-tier', 't', 'T']); - let locations = getOption(options, ['locations', 'l', 'L']); + const locations = getOption(options, ['locations', 'l', 'L']); const forkOf = getOption(options, ['fork-of', 'forkOf', 'fork']); const sourceSnapshot = getOption(options, [ 'source-snapshot', @@ -41,27 +41,6 @@ export default async function create(options: Record) { 'source-snap', ]); - // Handle deprecated --region and --consistency options - const deprecatedRegion = getOption(options, ['region', 'r', 'R']); - const deprecatedConsistency = getOption(options, [ - 'consistency', - 'c', - 'C', - ]); - if (deprecatedRegion !== undefined) { - console.warn( - 'Warning: --region is deprecated, use --locations instead. See https://www.tigrisdata.com/docs/buckets/locations/' - ); - if (locations === undefined) { - locations = deprecatedRegion; - } - } - if (deprecatedConsistency !== undefined) { - console.warn( - 'Warning: --consistency is deprecated, use --locations instead. See https://www.tigrisdata.com/docs/buckets/locations/' - ); - } - // Interactive mode: prompt for all values when no name is provided. const interactive = !name; diff --git a/src/lib/buckets/lifecycle/create.ts b/src/lib/buckets/lifecycle/create.ts new file mode 100644 index 0000000..e081df8 --- /dev/null +++ b/src/lib/buckets/lifecycle/create.ts @@ -0,0 +1,87 @@ +import { getStorageConfigWithOrg } from '@auth/provider.js'; +import type { BucketLifecycleRule } from '@tigrisdata/storage'; +import { failWithError } from '@utils/exit.js'; +import { msg, printStart, printSuccess } from '@utils/messages.js'; +import { getFormat, getOption } from '@utils/options.js'; + +import { + enabledFromInput, + expirationFromInput, + fetchExistingRules, + readRuleInput, + submitRules, + transitionDeltaFromInput, + validateRuleFieldCombinations, +} from './shared.js'; + +const context = msg('buckets lifecycle', 'create'); + +export default async function create(options: Record) { + printStart(context); + + const format = getFormat(options); + const name = getOption(options, ['name']); + + if (!name) { + failWithError(context, 'Bucket name is required'); + } + + const input = readRuleInput(options); + + const validationError = validateRuleFieldCombinations(input); + if (validationError) { + failWithError(context, validationError); + } + + const transition = transitionDeltaFromInput(input); + const expiration = expirationFromInput(input); + + if (!transition.storageClass && !expiration) { + failWithError( + context, + 'A new rule must include a transition (--storage-class with --days or --date) and/or an expiration (--expire-days or --expire-date)' + ); + } + + // A transition requires both a target class and timing. The shared + // validator covers timing-without-class; this check covers the + // inverse (--storage-class without --days/--date) which only applies + // to create. + if ( + transition.storageClass && + input.days === undefined && + input.date === undefined + ) { + failWithError( + context, + '--storage-class requires --days or --date for a new transition rule' + ); + } + + const enabled = enabledFromInput(input); + + const config = await getStorageConfigWithOrg(); + const existing = await fetchExistingRules(context, name, config); + + const newRule: BucketLifecycleRule = { + ...transition, + ...(expiration ? { expiration } : {}), + ...(input.prefix !== undefined ? { filter: { prefix: input.prefix } } : {}), + ...(enabled !== undefined ? { enabled } : {}), + }; + + await submitRules(context, name, [...existing, newRule], config); + + // Re-fetch to find the newly assigned id (server generates UUIDs). + const after = await fetchExistingRules(context, name, config); + const created = after.find((r) => !existing.some((e) => e.id === r.id)); + const createdId = created?.id ?? '(unknown)'; + + if (format === 'json') { + console.log( + JSON.stringify({ action: 'created', bucket: name, id: createdId }) + ); + } + + printSuccess(context, { name, id: createdId }); +} diff --git a/src/lib/buckets/lifecycle/edit.ts b/src/lib/buckets/lifecycle/edit.ts new file mode 100644 index 0000000..7c335af --- /dev/null +++ b/src/lib/buckets/lifecycle/edit.ts @@ -0,0 +1,129 @@ +import { getStorageConfigWithOrg } from '@auth/provider.js'; +import type { BucketLifecycleRule } from '@tigrisdata/storage'; +import { failWithError } from '@utils/exit.js'; +import { msg, printStart, printSuccess } from '@utils/messages.js'; +import { getFormat, getOption } from '@utils/options.js'; + +import { + enabledFromInput, + expirationFromInput, + fetchExistingRules, + readRuleInput, + submitRules, + transitionDeltaFromInput, + validateRuleFieldCombinations, +} from './shared.js'; + +const context = msg('buckets lifecycle', 'edit'); + +export default async function edit(options: Record) { + printStart(context); + + const format = getFormat(options); + const name = getOption(options, ['name']); + const id = getOption(options, ['id']); + + if (!name) { + failWithError(context, 'Bucket name is required'); + } + if (!id) { + failWithError(context, 'Rule id is required'); + } + + const input = readRuleInput(options); + + // Edit defers the "transition needs a storage class" check until + // after we fetch the target — the existing rule may already supply + // one, in which case `--days 60` alone is valid. + const validationError = validateRuleFieldCombinations(input, { + requireStorageClassForTiming: false, + }); + if (validationError) { + failWithError(context, validationError); + } + + const transition = transitionDeltaFromInput(input); + const expiration = expirationFromInput(input); + const enabled = enabledFromInput(input); + + const hasAnyChange = + transition.storageClass !== undefined || + transition.days !== undefined || + transition.date !== undefined || + expiration !== undefined || + enabled !== undefined || + input.prefix !== undefined; + + if (!hasAnyChange) { + failWithError( + context, + 'Provide at least one field to change (--storage-class, --days, --date, --expire-days, --expire-date, --prefix, --enable, --disable)' + ); + } + + const config = await getStorageConfigWithOrg(); + const existing = await fetchExistingRules(context, name, config); + + const target = existing.find((r) => r.id === id); + if (!target) { + failWithError( + context, + `No lifecycle rule with id "${id}" found. Run \`tigris buckets lifecycle list ${name}\` to see ids.` + ); + } + + // If the user touched any transition field, the merged rule must + // still have both a storage class and timing. Otherwise the API + // accepts the rule but silently drops the transition. + const userTouchedTransition = + input.storageClass !== undefined || + input.days !== undefined || + input.date !== undefined; + + if (userTouchedTransition) { + const finalStorageClass = transition.storageClass ?? target.storageClass; + const finalDays = + input.days !== undefined + ? Number(input.days) + : input.date !== undefined + ? undefined + : target.days; + const finalDate = + input.date !== undefined + ? input.date + : input.days !== undefined + ? undefined + : target.date; + + if (!finalStorageClass) { + failWithError( + context, + '--storage-class is required (this rule has no existing transition target)' + ); + } + if (finalDays === undefined && finalDate === undefined) { + failWithError( + context, + '--days or --date is required (this rule has no existing transition timing)' + ); + } + } + + const updated: BucketLifecycleRule = { + ...target, + ...transition, + ...(expiration ? { expiration } : {}), + ...(input.prefix !== undefined ? { filter: { prefix: input.prefix } } : {}), + ...(enabled !== undefined ? { enabled } : {}), + }; + + const merged = existing.map((r) => (r.id === id ? updated : r)); + + await submitRules(context, name, merged, config); + + if (format === 'json') { + console.log(JSON.stringify({ action: 'updated', bucket: name, id })); + } + + printSuccess(context, { name, id }); +} diff --git a/src/lib/buckets/lifecycle/list.ts b/src/lib/buckets/lifecycle/list.ts new file mode 100644 index 0000000..dd265ab --- /dev/null +++ b/src/lib/buckets/lifecycle/list.ts @@ -0,0 +1,67 @@ +import { getStorageConfig } from '@auth/provider.js'; +import { getBucketInfo } from '@tigrisdata/storage'; +import { failWithError } from '@utils/exit.js'; +import { + formatJson, + formatTable, + formatXml, + type TableColumn, +} from '@utils/format.js'; +import { msg, printEmpty, printStart, printSuccess } from '@utils/messages.js'; +import { getFormat, getOption } from '@utils/options.js'; + +import { formatExpirationCell, formatTransitionCell } from './shared.js'; + +const context = msg('buckets lifecycle', 'list'); + +export default async function list(options: Record) { + printStart(context); + + const name = getOption(options, ['name']); + const format = getFormat(options); + + if (!name) { + failWithError(context, 'Bucket name is required'); + } + + const { data, error } = await getBucketInfo(name, { + config: await getStorageConfig(), + }); + + if (error) { + failWithError(context, error); + } + + const rules = data?.settings.lifecycleRules ?? []; + + if (rules.length === 0) { + printEmpty(context); + return; + } + + const rows = rules.map((rule) => ({ + id: rule.id ?? '-', + prefix: rule.filter?.prefix ?? '-', + transition: formatTransitionCell(rule), + expiration: formatExpirationCell(rule), + status: rule.enabled === false ? 'disabled' : 'enabled', + })); + + const columns: TableColumn[] = [ + { key: 'id', header: 'ID' }, + { key: 'prefix', header: 'Prefix' }, + { key: 'transition', header: 'Transition' }, + { key: 'expiration', header: 'Expiration' }, + { key: 'status', header: 'Status' }, + ]; + + if (format === 'json') { + console.log(formatJson(rows)); + } else if (format === 'xml') { + console.log(formatXml(rows, 'lifecycleRules', 'rule')); + } else { + console.log(formatTable(rows, columns)); + } + + printSuccess(context, { count: rules.length }); +} diff --git a/src/lib/buckets/lifecycle/shared.ts b/src/lib/buckets/lifecycle/shared.ts new file mode 100644 index 0000000..cc1ec97 --- /dev/null +++ b/src/lib/buckets/lifecycle/shared.ts @@ -0,0 +1,219 @@ +import type { TigrisStorageConfig } from '@auth/provider.js'; +import { + type BucketLifecycleRule, + getBucketInfo, + setBucketLifecycle, +} from '@tigrisdata/storage'; +import { describeExpiration, describeTransition } from '@utils/bucket-info.js'; +import { failWithError } from '@utils/exit.js'; +import { type MessageContext } from '@utils/messages.js'; +import { getOption } from '@utils/options.js'; + +const VALID_TRANSITION_CLASSES = [ + 'STANDARD_IA', + 'GLACIER', + 'GLACIER_IR', +] as const; + +type TransitionClass = (typeof VALID_TRANSITION_CLASSES)[number]; + +function isTransitionClass(value: string): value is TransitionClass { + return (VALID_TRANSITION_CLASSES as readonly string[]).includes(value); +} + +function isIsoDate(value: string): boolean { + return /^\d{4}-\d{2}-\d{2}/.test(value) && !isNaN(new Date(value).getTime()); +} + +type RuleInput = { + prefix?: string; + storageClass?: string; + days?: string; + date?: string; + expireDays?: string; + expireDate?: string; + enable?: boolean; + disable?: boolean; +}; + +export function readRuleInput(options: Record): RuleInput { + return { + prefix: getOption(options, ['prefix']), + storageClass: getOption(options, ['storage-class', 'storageClass']), + days: getOption(options, ['days']), + date: getOption(options, ['date']), + expireDays: getOption(options, ['expire-days', 'expireDays']), + expireDate: getOption(options, ['expire-date', 'expireDate']), + enable: getOption(options, ['enable']), + disable: getOption(options, ['disable']), + }; +} + +/** + * Validates field formats and intra-input conflicts (date vs days, + * enable vs disable, ISO format, positive numbers). Does NOT enforce + * "at least one of transition/expiration" or "transition needs both + * class and timing" — those are structural rules the caller decides + * based on create vs edit semantics. + * + * `requireStorageClassForTiming` defaults to `true` (create semantics). + * Edit passes `false` because the existing rule may already supply a + * storage class; the merged-rule check happens post-fetch in edit.ts. + */ +export function validateRuleFieldCombinations( + input: RuleInput, + { + requireStorageClassForTiming = true, + }: { requireStorageClassForTiming?: boolean } = {} +): string | undefined { + if (input.enable && input.disable) { + return 'Cannot use both --enable and --disable'; + } + + if ( + requireStorageClassForTiming && + (input.days !== undefined || input.date !== undefined) && + !input.storageClass + ) { + return '--storage-class is required when setting --days or --date'; + } + + if (input.storageClass && !isTransitionClass(input.storageClass)) { + return `--storage-class must be one of: ${VALID_TRANSITION_CLASSES.join( + ', ' + )} (STANDARD is not a valid transition target)`; + } + + if (input.days !== undefined && input.date !== undefined) { + return 'Cannot specify both --days and --date'; + } + + if ( + input.days !== undefined && + (isNaN(Number(input.days)) || Number(input.days) <= 0) + ) { + return '--days must be a positive number'; + } + + if (input.date !== undefined && !isIsoDate(input.date)) { + return '--date must be a valid ISO-8601 date (e.g. 2026-06-01)'; + } + + if (input.expireDays !== undefined && input.expireDate !== undefined) { + return 'Cannot specify both --expire-days and --expire-date'; + } + + if ( + input.expireDays !== undefined && + (isNaN(Number(input.expireDays)) || Number(input.expireDays) <= 0) + ) { + return '--expire-days must be a positive number'; + } + + if (input.expireDate !== undefined && !isIsoDate(input.expireDate)) { + return '--expire-date must be a valid ISO-8601 date (e.g. 2026-06-01)'; + } + + if (input.prefix !== undefined && input.prefix === '') { + return '--prefix cannot be empty'; + } + + return undefined; +} + +/** + * Build a transition delta to merge into a rule. `days` and `date` are + * mutually exclusive in the API — when the user sets one, this delta + * explicitly nulls the other so spreading over an existing rule + * overrides a previously-set value instead of leaving both populated. + */ +export function transitionDeltaFromInput(input: RuleInput): { + storageClass?: TransitionClass; + days?: number; + date?: string | undefined; +} { + const delta: { + storageClass?: TransitionClass; + days?: number; + date?: string | undefined; + } = {}; + if (input.storageClass) { + delta.storageClass = input.storageClass as TransitionClass; + } + if (input.days !== undefined) { + delta.days = Number(input.days); + delta.date = undefined; + } else if (input.date !== undefined) { + delta.date = input.date; + delta.days = undefined; + } + return delta; +} + +/** + * Build an expiration object from input, or undefined if neither + * --expire-days nor --expire-date was provided. `days` and `date` are + * mutually exclusive — the unset one is emitted as `undefined` so + * spreading over an existing expiration clears the conflicting field. + */ +export function expirationFromInput( + input: RuleInput +): { days?: number; date?: string | undefined } | undefined { + if (input.expireDays !== undefined) { + return { days: Number(input.expireDays), date: undefined }; + } + if (input.expireDate !== undefined) { + return { date: input.expireDate, days: undefined }; + } + return undefined; +} + +/** + * Resolve `--enable` / `--disable` into a boolean, or undefined when + * neither flag was set. + */ +export function enabledFromInput(input: RuleInput): boolean | undefined { + if (input.enable) return true; + if (input.disable) return false; + return undefined; +} + +/** + * Fetch existing lifecycle rules. Existing rules are passed through to + * `submitRules` with their ids so the SDK's auto-match doesn't silently + * overwrite a rule when there's exactly one update + one existing. + */ +export async function fetchExistingRules( + context: MessageContext, + bucket: string, + config: TigrisStorageConfig +): Promise { + const { data, error } = await getBucketInfo(bucket, { config }); + if (error) { + failWithError(context, error); + } + return data?.settings.lifecycleRules ?? []; +} + +export async function submitRules( + context: MessageContext, + bucket: string, + rules: BucketLifecycleRule[], + config: TigrisStorageConfig +): Promise { + const { error } = await setBucketLifecycle(bucket, { + lifecycleRules: rules, + config, + }); + if (error) { + failWithError(context, error); + } +} + +export function formatTransitionCell(rule: BucketLifecycleRule): string { + return describeTransition(rule) ?? '-'; +} + +export function formatExpirationCell(rule: BucketLifecycleRule): string { + return describeExpiration(rule) ?? '-'; +} diff --git a/src/lib/buckets/set-transition.ts b/src/lib/buckets/set-transition.ts deleted file mode 100644 index ff9a3b0..0000000 --- a/src/lib/buckets/set-transition.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { getStorageConfigWithOrg } from '@auth/provider.js'; -import { - type BucketLifecycleRule, - setBucketLifecycle, -} from '@tigrisdata/storage'; -import { failWithError } from '@utils/exit.js'; -import { msg, printStart, printSuccess } from '@utils/messages.js'; -import { getFormat, getOption } from '@utils/options.js'; - -const context = msg('buckets', 'set-transition'); - -const VALID_TRANSITION_CLASSES = ['STANDARD_IA', 'GLACIER', 'GLACIER_IR']; - -export default async function setTransitions(options: Record) { - printStart(context); - - const format = getFormat(options); - - const name = getOption(options, ['name']); - const storageClass = getOption(options, [ - 'storage-class', - 'storageClass', - ]); - const days = getOption(options, ['days']); - const date = getOption(options, ['date']); - const enable = getOption(options, ['enable']); - const disable = getOption(options, ['disable']); - - if (!name) { - failWithError(context, 'Bucket name is required'); - } - - if (enable && disable) { - failWithError(context, 'Cannot use both --enable and --disable'); - } - - if ( - disable && - (days !== undefined || date !== undefined || storageClass !== undefined) - ) { - failWithError( - context, - 'Cannot use --disable with --days, --date, or --storage-class' - ); - } - - if (!enable && !disable && days === undefined && date === undefined) { - failWithError(context, 'Provide --days, --date, --enable, or --disable'); - } - - if ((days !== undefined || date !== undefined) && !storageClass) { - failWithError( - context, - '--storage-class is required when setting --days or --date' - ); - } - - if (storageClass && !VALID_TRANSITION_CLASSES.includes(storageClass)) { - failWithError( - context, - `--storage-class must be one of: ${VALID_TRANSITION_CLASSES.join(', ')} (STANDARD is not a valid transition target)` - ); - } - - if (days !== undefined && (isNaN(Number(days)) || Number(days) <= 0)) { - failWithError(context, '--days must be a positive number'); - } - - if (date !== undefined) { - if ( - typeof date !== 'string' || - !/^\d{4}-\d{2}-\d{2}/.test(date) || - isNaN(new Date(date).getTime()) - ) { - failWithError( - context, - '--date must be a valid ISO-8601 date (e.g. 2026-06-01)' - ); - } - } - - const finalConfig = await getStorageConfigWithOrg(); - - const rule: BucketLifecycleRule = { - ...(enable ? { enabled: true } : {}), - ...(disable ? { enabled: false } : {}), - ...(storageClass - ? { storageClass: storageClass as BucketLifecycleRule['storageClass'] } - : {}), - ...(days !== undefined ? { days: Number(days) } : {}), - ...(date !== undefined ? { date } : {}), - }; - - const { error } = await setBucketLifecycle(name, { - lifecycleRules: [rule], - config: finalConfig, - }); - - if (error) { - failWithError(context, error); - } - - if (format === 'json') { - console.log(JSON.stringify({ action: 'updated', bucket: name })); - } - - printSuccess(context, { name }); -} diff --git a/src/lib/buckets/set-ttl.ts b/src/lib/buckets/set-ttl.ts deleted file mode 100644 index 941bda9..0000000 --- a/src/lib/buckets/set-ttl.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { getStorageConfigWithOrg } from '@auth/provider.js'; -import { setBucketTtl } from '@tigrisdata/storage'; -import { failWithError } from '@utils/exit.js'; -import { msg, printStart, printSuccess } from '@utils/messages.js'; -import { getFormat, getOption } from '@utils/options.js'; - -const context = msg('buckets', 'set-ttl'); - -export default async function setTtl(options: Record) { - printStart(context); - - const format = getFormat(options); - - const name = getOption(options, ['name']); - const days = getOption(options, ['days']); - const date = getOption(options, ['date']); - const enable = getOption(options, ['enable']); - const disable = getOption(options, ['disable']); - - if (!name) { - failWithError(context, 'Bucket name is required'); - } - - if (enable && disable) { - failWithError(context, 'Cannot use both --enable and --disable'); - } - - if (disable && (days !== undefined || date !== undefined)) { - failWithError(context, 'Cannot use --disable with --days or --date'); - } - - if (!enable && !disable && days === undefined && date === undefined) { - failWithError(context, 'Provide --days, --date, --enable, or --disable'); - } - - if (days !== undefined && (isNaN(Number(days)) || Number(days) <= 0)) { - failWithError(context, '--days must be a positive number'); - } - - if (date !== undefined) { - if ( - typeof date !== 'string' || - !/^\d{4}-\d{2}-\d{2}/.test(date) || - isNaN(new Date(date).getTime()) - ) { - failWithError( - context, - '--date must be a valid ISO-8601 date (e.g. 2026-06-01)' - ); - } - } - - const finalConfig = await getStorageConfigWithOrg(); - - const ttlConfig = { - ...(enable ? { enabled: true } : {}), - ...(disable ? { enabled: false } : {}), - ...(days !== undefined ? { days: Number(days) } : {}), - ...(date !== undefined ? { date } : {}), - }; - - const { error } = await setBucketTtl(name, { - ttlConfig, - config: finalConfig, - }); - - if (error) { - failWithError(context, error); - } - - if (format === 'json') { - console.log(JSON.stringify({ action: 'updated', bucket: name })); - } - - printSuccess(context, { name }); -} diff --git a/src/lib/buckets/set.ts b/src/lib/buckets/set.ts index 1dcc632..933cae7 100644 --- a/src/lib/buckets/set.ts +++ b/src/lib/buckets/set.ts @@ -14,18 +14,7 @@ export default async function set(options: Record) { const name = getOption(options, ['name']); const access = getOption(options, ['access']); - let locations = getOption(options, ['locations']); - - // Handle deprecated --region option - const deprecatedRegion = getOption(options, ['region']); - if (deprecatedRegion !== undefined) { - console.warn( - 'Warning: --region is deprecated, use --locations instead. See https://www.tigrisdata.com/docs/buckets/locations/' - ); - if (locations === undefined) { - locations = deprecatedRegion; - } - } + const locations = getOption(options, ['locations']); const allowObjectAcl = getOption(options, [ 'allow-object-acl', 'allowObjectAcl', diff --git a/src/lib/forks/create.ts b/src/lib/forks/create.ts deleted file mode 100644 index eea4ef6..0000000 --- a/src/lib/forks/create.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { getStorageConfig } from '@auth/provider.js'; -import { createBucket } from '@tigrisdata/storage'; -import { failWithError } from '@utils/exit.js'; -import { msg, printStart, printSuccess } from '@utils/messages.js'; -import { getFormat, getOption } from '@utils/options.js'; - -const context = msg('forks', 'create'); - -export default async function create(options: Record) { - printStart(context); - - const format = getFormat(options); - - const name = getOption(options, ['name']); - const forkName = getOption(options, ['fork-name', 'forkName']); - const snapshot = getOption(options, ['snapshot', 's', 'S']); - - if (!name) { - failWithError(context, 'Source bucket name is required'); - } - - if (!forkName) { - failWithError(context, 'Fork name is required'); - } - - const { error } = await createBucket(forkName, { - sourceBucketName: name, - sourceBucketSnapshot: snapshot, - config: await getStorageConfig(), - }); - - if (error) { - failWithError(context, error); - } - - if (format === 'json') { - console.log( - JSON.stringify({ action: 'created', name: forkName, forkOf: name }) - ); - } - - printSuccess(context, { name, forkName }); -} diff --git a/src/lib/forks/list.ts b/src/lib/forks/list.ts deleted file mode 100644 index 62a51cc..0000000 --- a/src/lib/forks/list.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { getStorageConfig } from '@auth/provider.js'; -import { getBucketInfo, listBuckets } from '@tigrisdata/storage'; -import { failWithError } from '@utils/exit.js'; -import { formatOutput } from '@utils/format.js'; -import { msg, printEmpty, printStart, printSuccess } from '@utils/messages.js'; -import { getFormat, getOption } from '@utils/options.js'; - -const context = msg('forks', 'list'); - -export default async function list(options: Record) { - printStart(context); - - const name = getOption(options, ['name']); - const format = getFormat(options); - - if (!name) { - failWithError(context, 'Source bucket name is required'); - } - - const config = await getStorageConfig(); - - // First, check if the bucket has forks - const { data: bucketInfo, error: infoError } = await getBucketInfo(name, { - config, - }); - - if (infoError) { - failWithError(context, infoError); - } - - if (!bucketInfo.forkInfo?.hasChildren) { - printEmpty(context); - return; - } - - // List all buckets and filter for forks of the source bucket - const { data, error } = await listBuckets({ config }); - - if (error) { - failWithError(context, error); - } - - // Get info for each bucket to find forks - const forks: Array<{ name: string; created: Date }> = []; - - for (const bucket of data.buckets) { - if (bucket.name === name) continue; - - const { data: info } = await getBucketInfo(bucket.name, { config }); - const isChildOf = info?.forkInfo?.parents?.some( - (p) => p.bucketName === name - ); - if (isChildOf) { - forks.push({ - name: bucket.name, - created: bucket.creationDate, - }); - } - } - - if (forks.length === 0) { - printEmpty(context); - return; - } - - const output = formatOutput(forks, format!, 'forks', 'fork', [ - { key: 'name', header: 'Name' }, - { key: 'created', header: 'Created' }, - ]); - - console.log(output); - printSuccess(context, { count: forks.length }); -} diff --git a/src/lib/mk.ts b/src/lib/mk.ts index 1dc45b4..d698b25 100644 --- a/src/lib/mk.ts +++ b/src/lib/mk.ts @@ -39,7 +39,7 @@ export default async function mk(options: Record) { 't', 'T', ]); - let locations = getOption(options, ['locations', 'l', 'L']); + const locations = getOption(options, ['locations', 'l', 'L']); const forkOf = getOption(options, ['fork-of', 'forkOf', 'fork']); const sourceSnapshot = getOption(options, [ 'source-snapshot', @@ -47,27 +47,6 @@ export default async function mk(options: Record) { 'source-snap', ]); - // Handle deprecated --region and --consistency options - const deprecatedRegion = getOption(options, ['region', 'r', 'R']); - const deprecatedConsistency = getOption(options, [ - 'consistency', - 'c', - 'C', - ]); - if (deprecatedRegion !== undefined) { - console.warn( - 'Warning: --region is deprecated, use --locations instead. See https://www.tigrisdata.com/docs/buckets/locations/' - ); - if (locations === undefined) { - locations = deprecatedRegion; - } - } - if (deprecatedConsistency !== undefined) { - console.warn( - 'Warning: --consistency is deprecated, use --locations instead. See https://www.tigrisdata.com/docs/buckets/locations/' - ); - } - if (sourceSnapshot && !forkOf) { exitWithError('--source-snapshot requires --fork-of'); } diff --git a/src/specs.yaml b/src/specs.yaml index 1205e57..69212cc 100644 --- a/src/specs.yaml +++ b/src/specs.yaml @@ -30,16 +30,6 @@ definitions: value: GLACIER_IR description: Lowest-cost storage for long-lived data that is rarely accessed and requires retrieval in milliseconds. - consistency_options: &consistency_options - - name: Default - value: default - description: Strict read-after-write consistency in same region. Eventual consistency globally. - - name: Strict - value: strict - description: Strict read-after-write consistency globally. Latency will be higher than the default. - - region_options: ®ion_options - location_options: &location_options - name: Global value: 'global' @@ -343,15 +333,13 @@ commands: options: *tier_options default: STANDARD - name: consistency - description: (Deprecated, use --locations) Consistency level (only applies when creating a bucket) alias: c - options: *consistency_options - deprecated: true + removed: true + replaced_by: --locations - name: region - description: (Deprecated, use --locations) Region (only applies when creating a bucket) alias: r - options: *region_options - deprecated: true + removed: true + replaced_by: --locations - name: locations description: Location for the bucket (only applies when creating a bucket) alias: l @@ -739,15 +727,13 @@ commands: options: *tier_options default: STANDARD - name: consistency - description: (Deprecated, use --locations) Choose the consistency level for the bucket alias: c - options: *consistency_options - deprecated: true + removed: true + replaced_by: --locations - name: region - description: (Deprecated, use --locations) Region alias: r - options: *region_options - deprecated: true + removed: true + replaced_by: --locations - name: locations description: Location for the bucket alias: l @@ -828,10 +814,8 @@ commands: description: Bucket access level options: *access_options - name: region - description: (Deprecated, use --locations) Allowed regions (can specify multiple) - options: *region_options - multiple: true - deprecated: true + removed: true + replaced_by: --locations - name: locations description: Bucket location (see https://www.tigrisdata.com/docs/buckets/locations/ for more details) options: *location_options @@ -854,33 +838,8 @@ commands: type: boolean # set-ttl - name: set-ttl - description: Configure object expiration (TTL) on a bucket. Objects expire after a number of days or on a specific date - examples: - - "tigris buckets set-ttl my-bucket --days 30" - - "tigris buckets set-ttl my-bucket --date 2026-06-01" - - "tigris buckets set-ttl my-bucket --disable" - messages: - onStart: 'Updating TTL settings...' - onSuccess: 'TTL settings updated for bucket {{name}}' - onFailure: 'Failed to update TTL settings' - arguments: - - name: name - description: Name of the bucket - type: positional - required: true - examples: - - my-bucket - - name: days - description: Expire objects after this many days - alias: d - - name: date - description: Expire objects on this date (ISO-8601, e.g. 2026-06-01) - - name: enable - description: Enable TTL on the bucket (uses existing lifecycle rules) - type: flag - - name: disable - description: Disable TTL on the bucket - type: flag + removed: true + replaced_by: 'tigris buckets lifecycle create --expire-days ' # set-locations - name: set-locations description: Set the data locations for a bucket @@ -965,38 +924,122 @@ commands: - t3://my-bucket/prefix/ # set-transition - name: set-transition - description: Configure a lifecycle transition rule on a bucket. Automatically move objects to a different storage class after a number of days or on a specific date + removed: true + replaced_by: 'tigris buckets lifecycle create --storage-class --days ' + # lifecycle + - name: lifecycle + description: Manage bucket lifecycle rules. Each rule combines an optional storage-class transition and/or expiration (TTL), scoped to an optional key prefix + alias: lc examples: - - "tigris buckets set-transition my-bucket --storage-class STANDARD_IA --days 30" - - "tigris buckets set-transition my-bucket --storage-class GLACIER --date 2026-06-01" - - "tigris buckets set-transition my-bucket --enable" - - "tigris buckets set-transition my-bucket --disable" - messages: - onStart: 'Updating lifecycle transition rule...' - onSuccess: 'Lifecycle transition rule updated for bucket {{name}}' - onFailure: 'Failed to update lifecycle transition rule' - arguments: - - name: name - description: Name of the bucket - type: positional - required: true + - "tigris buckets lifecycle list my-bucket" + - "tigris buckets lifecycle create my-bucket --prefix logs/ --storage-class GLACIER --days 90" + - "tigris buckets lifecycle create my-bucket --prefix tmp/ --expire-days 7" + - "tigris buckets lifecycle edit my-bucket --days 30" + commands: + - name: list + description: List lifecycle rules on a bucket + alias: l examples: - - my-bucket - - name: storage-class - description: Target storage class to transition objects to - alias: s - options: *transition_tier_options - - name: days - description: Transition objects after this many days - alias: d - - name: date - description: Transition objects on this date (ISO-8601, e.g. 2026-06-01) - - name: enable - description: Enable lifecycle transition rules on the bucket - type: flag - - name: disable - description: Disable lifecycle transition rules on the bucket - type: flag + - "tigris buckets lifecycle list my-bucket" + - "tigris buckets lifecycle list my-bucket --json" + messages: + onStart: '' + onSuccess: '' + onFailure: 'Failed to list lifecycle rules' + onEmpty: 'No lifecycle rules configured' + arguments: + - name: name + description: Name of the bucket + type: positional + required: true + examples: + - my-bucket + - name: format + description: Output format + options: [json, table, xml] + default: table + - name: create + description: Create a new lifecycle rule. A rule must include a transition (--storage-class with --days or --date) and/or an expiration (--expire-days or --expire-date), and may optionally be scoped via --prefix + alias: c + examples: + - "tigris buckets lifecycle create my-bucket --storage-class STANDARD_IA --days 30" + - "tigris buckets lifecycle create my-bucket --prefix logs/ --storage-class GLACIER --days 90" + - "tigris buckets lifecycle create my-bucket --prefix tmp/ --expire-days 7" + - "tigris buckets lifecycle create my-bucket --prefix archive/ --storage-class GLACIER --days 30 --expire-days 365" + messages: + onStart: 'Creating lifecycle rule...' + onSuccess: 'Lifecycle rule created on bucket {{name}} (id: {{id}})' + onFailure: 'Failed to create lifecycle rule' + arguments: + - name: name + description: Name of the bucket + type: positional + required: true + examples: + - my-bucket + - name: prefix + description: Key prefix to scope the rule to. Omit for a bucket-wide rule + alias: p + - name: storage-class + description: Target storage class for the transition + alias: s + options: *transition_tier_options + - name: days + description: Transition objects after this many days (used with --storage-class) + alias: d + - name: date + description: Transition objects on this date (ISO-8601, e.g. 2026-06-01) (used with --storage-class) + - name: expire-days + description: Expire (delete) objects after this many days + - name: expire-date + description: Expire (delete) objects on this date (ISO-8601, e.g. 2026-06-01) + - name: disable + description: Create the rule in a disabled state + type: flag + - name: edit + description: Edit an existing lifecycle rule by its id. Only specified fields are changed + alias: e + examples: + - "tigris buckets lifecycle edit my-bucket abc123 --days 60" + - "tigris buckets lifecycle edit my-bucket abc123 --expire-days 90" + - "tigris buckets lifecycle edit my-bucket abc123 --enable" + messages: + onStart: 'Updating lifecycle rule...' + onSuccess: 'Lifecycle rule {{id}} updated on bucket {{name}}' + onFailure: 'Failed to update lifecycle rule' + arguments: + - name: name + description: Name of the bucket + type: positional + required: true + examples: + - my-bucket + - name: id + description: Lifecycle rule id (run `tigris buckets lifecycle list` to see ids) + type: positional + required: true + - name: prefix + description: Replace the rule's key prefix + alias: p + - name: storage-class + description: Replace the rule's transition target + alias: s + options: *transition_tier_options + - name: days + description: Replace the rule's transition days + alias: d + - name: date + description: Replace the rule's transition date (ISO-8601) + - name: expire-days + description: Replace the rule's expiration days + - name: expire-date + description: Replace the rule's expiration date (ISO-8601) + - name: enable + description: Enable the rule + type: flag + - name: disable + description: Disable the rule (does not remove it) + type: flag # set-notifications - name: set-notifications description: Configure object event notifications on a bucket. Sends webhook requests to a URL when objects are created, updated, or deleted @@ -1080,69 +1123,12 @@ commands: type: flag ######################### - # Manage forks + # Manage forks (removed) ######################### - name: forks - description: (Deprecated, use "buckets create --fork-of" and "buckets list --forks-of") List and create forks alias: f - examples: - - "tigris forks list my-bucket" - - "tigris forks create my-bucket my-fork" - commands: - # list - - name: list - description: (Deprecated, use "buckets list --forks-of") List all forks created from the given source bucket - deprecated: true - alias: l - examples: - - "tigris forks list my-bucket" - - "tigris forks list my-bucket --format json" - messages: - onStart: 'Listing forks...' - onSuccess: 'Found {{count}} fork(s)' - onFailure: 'Failed to list forks' - onEmpty: 'No forks found for this bucket' - onDeprecated: 'Use "tigris buckets list --forks-of " instead' - arguments: - - name: name - description: Name of the source bucket - type: positional - required: true - examples: - - my-bucket - - name: format - description: Output format - options: [json, table, xml] - default: table - # create - - name: create - description: (Deprecated, use "buckets create --fork-of") Create a new fork (copy-on-write clone) of the source bucket - deprecated: true - alias: c - examples: - - "tigris forks create my-bucket my-fork" - - "tigris forks create my-bucket my-fork --snapshot 1765889000501544464" - messages: - onStart: 'Creating fork...' - onSuccess: "Fork '{{forkName}}' created from '{{name}}'" - onFailure: 'Failed to create fork' - onDeprecated: 'Use "tigris buckets create --fork-of " instead' - arguments: - - name: name - description: Name of the source bucket - type: positional - required: true - examples: - - my-bucket - - name: fork-name - description: Name for the new fork - type: positional - required: true - examples: - - my-fork - - name: snapshot - description: Create fork from a specific snapshot. Accepts a snapshot version string or any UNIX nanosecond-precision timestamp (e.g. 1765889000501544464) - alias: s + removed: true + replaced_by: 'tigris buckets create --fork-of and tigris buckets list --forks-of' ######################### # Manage snapshots diff --git a/src/types.ts b/src/types.ts index 68fd9e6..812dd1c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,6 @@ export interface Argument { name: string; - description: string; + description?: string; alias?: string; options?: | string[] @@ -11,6 +11,10 @@ export interface Argument { type?: 'positional' | 'flag' | string; multiple?: boolean; examples?: string[]; + /** Hard-removed: providing the flag exits with a redirect message. */ + removed?: boolean; + /** Replacement to suggest when a removed argument or command is used. */ + replaced_by?: string; } export interface NextAction { @@ -32,20 +36,21 @@ export interface Messages { // Recursive command structure - supports nth level nesting export interface CommandSpec { name: string; - description: string; + description?: string; alias?: string | string[]; arguments?: Argument[]; examples?: string[]; commands?: CommandSpec[]; // recursive - can nest infinitely default?: string; deprecated?: boolean; + /** Hard-removed: invoking the command exits with a redirect message. */ + removed?: boolean; + /** Replacement to suggest when a removed argument or command is used. */ + replaced_by?: string; message?: string; messages?: Messages; } -// Backwards compatibility alias -export type OperationSpec = CommandSpec; - export interface Specs { name: string; description: string; @@ -61,8 +66,3 @@ export interface ParsedPath { bucket: string; path: string; } - -export interface ParsedPaths { - source: ParsedPath; - destination: ParsedPath; -} diff --git a/src/utils/bucket-info.ts b/src/utils/bucket-info.ts index 4c28dc2..5d9fcb8 100644 --- a/src/utils/bucket-info.ts +++ b/src/utils/bucket-info.ts @@ -1,7 +1,65 @@ -import type { BucketInfoResponse } from '@tigrisdata/storage'; +import type { + BucketInfoResponse, + BucketLifecycleRule, +} from '@tigrisdata/storage'; import { formatSize } from './format.js'; +/** + * Human-readable description of a rule's transition, or undefined if + * the rule has no transition target. Used by both the bucket-info + * "Lifecycle Rules" row and the lifecycle-list table cell. + */ +export function describeTransition( + rule: BucketLifecycleRule +): string | undefined { + if (!rule.storageClass) return undefined; + if (rule.days !== undefined) + return `${rule.storageClass} after ${rule.days}d`; + if (rule.date !== undefined) return `${rule.storageClass} on ${rule.date}`; + return rule.storageClass; +} + +/** + * Human-readable description of a rule's expiration, or undefined if + * the rule has no expiration. Used by both the bucket-info "Lifecycle + * Rules" row and the lifecycle-list table cell. + */ +export function describeExpiration( + rule: BucketLifecycleRule +): string | undefined { + if (!rule.expiration) return undefined; + if (rule.expiration.days !== undefined) return `${rule.expiration.days}d`; + if (rule.expiration.date !== undefined) return rule.expiration.date; + return undefined; +} + +function formatLifecycleRule(rule: BucketLifecycleRule): string { + const parts: string[] = []; + + const transition = describeTransition(rule); + if (transition) parts.push(transition); + + const expiration = describeExpiration(rule); + if (expiration) { + // bucket-info shows expiration with the "expire" prefix; the table + // cell version drops it because the column header already says + // "Expiration". + parts.push( + rule.expiration?.days !== undefined + ? `expire after ${expiration}` + : `expire on ${expiration}` + ); + } + + const annotations: string[] = []; + if (rule.filter?.prefix) annotations.push(`prefix=${rule.filter.prefix}`); + if (rule.enabled === false) annotations.push('disabled'); + + const head = parts.join(', '); + return annotations.length > 0 ? `${head} (${annotations.join(', ')})` : head; +} + export function buildBucketInfo(data: BucketInfoResponse) { const info: { label: string; value: string }[] = [ { @@ -53,25 +111,11 @@ export function buildBucketInfo(data: BucketInfoResponse) { }); } - if (data.settings.ttlConfig) { - info.push({ - label: 'TTL', - value: data.settings.ttlConfig.enabled - ? data.settings.ttlConfig.days - ? `${data.settings.ttlConfig.days} days` - : (data.settings.ttlConfig.date ?? 'Enabled') - : 'Disabled', - }); - } - if (data.settings.lifecycleRules?.length) { info.push({ label: 'Lifecycle Rules', value: data.settings.lifecycleRules - .map( - (r) => - `${r.storageClass}${r.days ? ` after ${r.days}d` : ''}${r.enabled ? '' : ' (disabled)'}` - ) + .map((r) => formatLifecycleRule(r)) .join(', '), }); } diff --git a/src/utils/messages.ts b/src/utils/messages.ts index e785d4e..8178c36 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -1,4 +1,4 @@ -import type { CommandSpec, Messages, OperationSpec } from '../types.js'; +import type { CommandSpec, Messages } from '../types.js'; import { getCommandSpec } from './specs.js'; export type MessageVariables = Record< @@ -33,7 +33,7 @@ function isJsonMode(): boolean { function getMessages(context: MessageContext): Messages | undefined { const spec = getCommandSpec(context.command, context.operation); if (!spec) return undefined; - return (spec as CommandSpec | OperationSpec).messages; + return (spec as CommandSpec).messages; } /** diff --git a/src/utils/path.ts b/src/utils/path.ts index 41739ba..2182e3e 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -1,7 +1,7 @@ import type { TigrisStorageConfig } from '@auth/provider.js'; import { list } from '@tigrisdata/storage'; -import type { ParsedPath, ParsedPaths } from '../types.js'; +import type { ParsedPath } from '../types.js'; const REMOTE_PREFIXES = ['t3://', 'tigris://']; @@ -59,19 +59,6 @@ export async function isPathFolder( return !!(data?.items && data.items.length > 0); } -/** - * Parses source and destination paths - * @param src - Source path string - * @param dest - Destination path string - * @returns Object with parsed source and destination - */ -export function parsePaths(src: string, dest: string): ParsedPaths { - return { - source: parsePath(src), - destination: parsePath(dest), - }; -} - /** * Parses a path that may or may not have a t3:// or tigris:// prefix. * Supports both remote prefixed paths and bare bucket/path paths. diff --git a/src/utils/specs.ts b/src/utils/specs.ts index 3bd33fa..85df8c3 100644 --- a/src/utils/specs.ts +++ b/src/utils/specs.ts @@ -3,7 +3,7 @@ import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import * as YAML from 'yaml'; -import type { Argument, CommandSpec, OperationSpec, Specs } from '../types.js'; +import type { Argument, CommandSpec, Specs } from '../types.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -30,7 +30,7 @@ export function loadSpecs(): Specs { export function getCommandSpec( commandPath: string, operationName?: string -): OperationSpec | CommandSpec | null { +): CommandSpec | null { const specs = loadSpecs(); // Split command path for nested commands (e.g., "iam policies" -> ["iam", "policies"]) diff --git a/test/cli.test.ts b/test/cli.test.ts index a71cda5..eab9612 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -192,12 +192,11 @@ describe('CLI Help Commands', () => { expect(result.stdout).toContain('Commands:'); }); - it('should show forks help', () => { - const result = runCli('forks help'); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('Commands:'); - expect(result.stdout).toContain('list'); - expect(result.stdout).toContain('create'); + it('should print a redirect message for the removed forks command', () => { + const result = runCli('forks'); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('removed'); + expect(result.stderr).toContain('--fork-of'); }); it('should show snapshots help', () => { @@ -1438,65 +1437,12 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); }); - describe('buckets set-ttl', () => { - it('should set TTL with --days 30', () => { + describe('buckets set-ttl (removed in v3)', () => { + it('should print a redirect message pointing at lifecycle', () => { const result = runCli(`buckets set-ttl ${setBucket} --days 30`); - expect(result.exitCode).toBe(0); - }); - - it('should set TTL with --date 2027-01-01', () => { - const result = runCli(`buckets set-ttl ${setBucket} --date 2027-01-01`); - expect(result.exitCode).toBe(0); - }); - - it('should enable with --enable', () => { - const result = runCli(`buckets set-ttl ${setBucket} --enable`); - expect(result.exitCode).toBe(0); - }); - - it('should disable with --disable', () => { - const result = runCli(`buckets set-ttl ${setBucket} --disable`); - expect(result.exitCode).toBe(0); - }); - - it('should error when using both --enable and --disable', () => { - const result = runCli( - `buckets set-ttl ${setBucket} --enable --disable` - ); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain( - 'Cannot use both --enable and --disable' - ); - }); - - it('should error when using --disable with --days', () => { - const result = runCli( - `buckets set-ttl ${setBucket} --disable --days 30` - ); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain( - 'Cannot use --disable with --days or --date' - ); - }); - - it('should error on invalid --days', () => { - const result = runCli(`buckets set-ttl ${setBucket} --days -5`); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('--days must be a positive number'); - }); - - it('should error on invalid --date', () => { - const result = runCli(`buckets set-ttl ${setBucket} --date not-a-date`); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('--date must be a valid ISO-8601 date'); - }); - - it('should error when no action provided', () => { - const result = runCli(`buckets set-ttl ${setBucket}`); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain( - 'Provide --days, --date, --enable, or --disable' - ); + expect(result.stderr).toContain('removed'); + expect(result.stderr).toContain('lifecycle'); }); }); @@ -1534,83 +1480,14 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); }); - describe('buckets set-transition', () => { - it('should set with --days 30 --storage-class GLACIER', () => { + describe('buckets set-transition (removed in v3)', () => { + it('should print a redirect message pointing at lifecycle', () => { const result = runCli( `buckets set-transition ${setBucket} --days 30 --storage-class GLACIER` ); - expect(result.exitCode).toBe(0); - }); - - it('should set with --date 2027-01-01 --storage-class GLACIER_IR', () => { - const result = runCli( - `buckets set-transition ${setBucket} --date 2027-01-01 --storage-class GLACIER_IR` - ); - expect(result.exitCode).toBe(0); - }); - - it('should enable with --enable', () => { - const result = runCli(`buckets set-transition ${setBucket} --enable`); - expect(result.exitCode).toBe(0); - }); - - it('should disable with --disable', () => { - const result = runCli(`buckets set-transition ${setBucket} --disable`); - expect(result.exitCode).toBe(0); - }); - - it('should error on invalid storage class STANDARD', () => { - const result = runCli( - `buckets set-transition ${setBucket} --days 30 --storage-class STANDARD` - ); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain( - 'STANDARD is not a valid transition target' - ); - }); - - it('should error on --days without --storage-class', () => { - const result = runCli(`buckets set-transition ${setBucket} --days 30`); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain( - '--storage-class is required when setting --days or --date' - ); - }); - - it('should error when using both --enable and --disable', () => { - const result = runCli( - `buckets set-transition ${setBucket} --enable --disable` - ); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain( - 'Cannot use both --enable and --disable' - ); - }); - - it('should error on --disable with --days', () => { - const result = runCli( - `buckets set-transition ${setBucket} --disable --days 30` - ); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain( - 'Cannot use --disable with --days, --date, or --storage-class' - ); - }); - - it('should error when no action provided', () => { - const result = runCli(`buckets set-transition ${setBucket}`); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain( - 'Provide --days, --date, --enable, or --disable' - ); - }); - - it('should error on invalid --days', () => { - const result = runCli( - `buckets set-transition ${setBucket} --days -1 --storage-class GLACIER` - ); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('--days must be a positive number'); + expect(result.stderr).toContain('removed'); + expect(result.stderr).toContain('lifecycle'); }); }); @@ -1977,27 +1854,18 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { expect(result.stdout).toContain('Size'); }); - it('should create a fork via forks create', () => { - const result = runCli(`forks create ${snapBucket} ${forkBucket}`); + it('should create a fork via buckets create --fork-of', () => { + const result = runCli( + `buckets create ${forkBucket} --fork-of ${snapBucket}` + ); expect(result.exitCode).toBe(0); }); - it('should list forks', () => { - // Retry — fork visibility is eventually consistent - let result = { stdout: '', stderr: '', exitCode: 1 }; - for (let i = 0; i < 3; i++) { - result = runCli(`forks list ${snapBucket}`); - if (result.exitCode === 0 && result.stdout.includes(forkBucket)) break; - if (i < 2) execSync('sleep 5'); - } - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain(forkBucket); - }, 120_000); - - it('should list forks with --format json', () => { - const result = runCli(`forks list ${snapBucket} --format json`); + it('should list forks via buckets list --forks-of (json)', () => { + const result = runCli( + `buckets list --forks-of ${snapBucket} --format json` + ); expect(result.exitCode).toBe(0); - // May return JSON array or empty (printEmpty is TTY-gated) if (result.stdout.trim()) { expect(() => JSON.parse(result.stdout.trim())).not.toThrow(); } diff --git a/test/lib/buckets/lifecycle/shared.test.ts b/test/lib/buckets/lifecycle/shared.test.ts new file mode 100644 index 0000000..9423dc2 --- /dev/null +++ b/test/lib/buckets/lifecycle/shared.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import { + expirationFromInput, + transitionDeltaFromInput, + validateRuleFieldCombinations, +} from '../../../../src/lib/buckets/lifecycle/shared.js'; + +describe('transitionDeltaFromInput', () => { + it('returns only the storage class when only --storage-class is set', () => { + const delta = transitionDeltaFromInput({ storageClass: 'GLACIER' }); + expect(delta).toEqual({ storageClass: 'GLACIER' }); + }); + + it('emits days and explicitly clears date when only --days is set', () => { + // Regression: spreading ...target before this delta needs to override + // an existing target.date. Without `date: undefined`, the spread leaves + // both populated and the API rejects the rule. + const delta = transitionDeltaFromInput({ days: '30' }); + expect(delta).toEqual({ days: 30, date: undefined }); + }); + + it('emits date and explicitly clears days when only --date is set', () => { + const delta = transitionDeltaFromInput({ date: '2026-06-01' }); + expect(delta).toEqual({ date: '2026-06-01', days: undefined }); + }); + + it('returns an empty delta when no timing or class is set', () => { + const delta = transitionDeltaFromInput({}); + expect(delta).toEqual({}); + }); +}); + +describe('validateRuleFieldCombinations', () => { + it('rejects --days without --storage-class by default (create semantics)', () => { + expect(validateRuleFieldCombinations({ days: '30' })).toContain( + '--storage-class is required' + ); + }); + + it('allows --days without --storage-class when called with requireStorageClassForTiming: false (edit semantics)', () => { + // Regression: validator used to block edit cases where the existing + // rule already had a storage class. + expect( + validateRuleFieldCombinations( + { days: '30' }, + { requireStorageClassForTiming: false } + ) + ).toBeUndefined(); + }); + + it('still rejects mutually exclusive --days and --date in edit mode', () => { + expect( + validateRuleFieldCombinations( + { days: '30', date: '2026-06-01' }, + { requireStorageClassForTiming: false } + ) + ).toContain('Cannot specify both --days and --date'); + }); + + it('rejects an empty --prefix', () => { + expect(validateRuleFieldCombinations({ prefix: '' })).toContain( + '--prefix cannot be empty' + ); + }); +}); + +describe('expirationFromInput', () => { + it('emits days and explicitly clears date when --expire-days is set', () => { + const expiration = expirationFromInput({ expireDays: '7' }); + expect(expiration).toEqual({ days: 7, date: undefined }); + }); + + it('emits date and explicitly clears days when --expire-date is set', () => { + const expiration = expirationFromInput({ expireDate: '2026-12-31' }); + expect(expiration).toEqual({ date: '2026-12-31', days: undefined }); + }); + + it('returns undefined when neither expire flag is set', () => { + expect(expirationFromInput({})).toBeUndefined(); + }); +}); diff --git a/test/specs-completeness.test.ts b/test/specs-completeness.test.ts index cbb4471..059f427 100644 --- a/test/specs-completeness.test.ts +++ b/test/specs-completeness.test.ts @@ -23,6 +23,9 @@ function collectLeaves( const leaves: LeafCommand[] = []; for (const cmd of commands) { + // Removed commands are tombstones — no handler, no messages block. + if (cmd.removed) continue; + const currentPath = [...parentPath, cmd.name]; if (!cmd.commands || cmd.commands.length === 0) { diff --git a/test/utils/bucket-info.test.ts b/test/utils/bucket-info.test.ts index c2f9ded..86b8b81 100644 --- a/test/utils/bucket-info.test.ts +++ b/test/utils/bucket-info.test.ts @@ -148,61 +148,6 @@ describe('buildBucketInfo', () => { }); }); - describe('TTL config', () => { - it('does not add TTL when ttlConfig is undefined', () => { - const info = buildBucketInfo(makeResponse()); - expect(findValue(info, 'TTL')).toBeUndefined(); - }); - - it('shows Disabled when ttlConfig.enabled is false', () => { - const info = buildBucketInfo( - makeResponse({ - settings: { - ...makeResponse().settings, - ttlConfig: { enabled: false }, - }, - }) - ); - expect(findValue(info, 'TTL')).toBe('Disabled'); - }); - - it('shows days when enabled with days', () => { - const info = buildBucketInfo( - makeResponse({ - settings: { - ...makeResponse().settings, - ttlConfig: { enabled: true, days: 30 }, - }, - }) - ); - expect(findValue(info, 'TTL')).toBe('30 days'); - }); - - it('shows date when enabled without days', () => { - const info = buildBucketInfo( - makeResponse({ - settings: { - ...makeResponse().settings, - ttlConfig: { enabled: true, date: '2025-12-31' }, - }, - }) - ); - expect(findValue(info, 'TTL')).toBe('2025-12-31'); - }); - - it('shows Enabled when enabled without days or date', () => { - const info = buildBucketInfo( - makeResponse({ - settings: { - ...makeResponse().settings, - ttlConfig: { enabled: true }, - }, - }) - ); - expect(findValue(info, 'TTL')).toBe('Enabled'); - }); - }); - describe('lifecycle rules', () => { it('does not add lifecycle rules when undefined', () => { const info = buildBucketInfo(makeResponse()); @@ -264,6 +209,29 @@ describe('buildBucketInfo', () => { 'STANDARD_IA after 30d, GLACIER after 90d' ); }); + + it('renders TTL-shaped rules (expiration only) alongside transitions', () => { + const info = buildBucketInfo( + makeResponse({ + settings: { + ...makeResponse().settings, + lifecycleRules: [ + { id: 'ttl-1', expiration: { days: 7 }, enabled: true }, + { + id: 'lc-1', + storageClass: 'GLACIER', + days: 90, + enabled: true, + }, + ], + }, + }) + ); + expect(findValue(info, 'TTL')).toBeUndefined(); + expect(findValue(info, 'Lifecycle Rules')).toBe( + 'expire after 7d, GLACIER after 90d' + ); + }); }); describe('CORS rules', () => { diff --git a/test/utils/path.test.ts b/test/utils/path.test.ts index 941ef92..26e1743 100644 --- a/test/utils/path.test.ts +++ b/test/utils/path.test.ts @@ -5,7 +5,6 @@ import { isRemotePath, parseAnyPath, parsePath, - parsePaths, parseRemotePath, resolveObjectArgs, wildcardPrefix, @@ -49,27 +48,6 @@ describe('parsePath', () => { }); }); -describe('parsePaths', () => { - it('should parse source and destination paths', () => { - const result = parsePaths( - 'src-bucket/file.txt', - 'dest-bucket/new-file.txt' - ); - expect(result.source.bucket).toBe('src-bucket'); - expect(result.source.path).toBe('file.txt'); - expect(result.destination.bucket).toBe('dest-bucket'); - expect(result.destination.path).toBe('new-file.txt'); - }); - - it('should handle cross-bucket copy with same filename', () => { - const result = parsePaths('bucket-a/folder/file.txt', 'bucket-b'); - expect(result.source.bucket).toBe('bucket-a'); - expect(result.source.path).toBe('folder/file.txt'); - expect(result.destination.bucket).toBe('bucket-b'); - expect(result.destination.path).toBe(''); - }); -}); - describe('isRemotePath', () => { it('should return true for t3:// prefixed paths', () => { expect(isRemotePath('t3://my-bucket')).toBe(true);