diff --git a/package-lock.json b/package-lock.json index d8f11f1..e8d21b9 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.4.0", + "@tigrisdata/storage": "^3.5.1", "commander": "^14.0.3", "enquirer": "^2.4.1", "jose": "^6.2.3", @@ -4032,15 +4032,16 @@ } }, "node_modules/@tigrisdata/storage": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@tigrisdata/storage/-/storage-3.4.0.tgz", - "integrity": "sha512-1eortp51PyHkBb5NSg6HjYApgOmFOtpMxzGrUcDsDnszUD3qq/yHlYGg9Jj62vqUei9s/dNiL6AyAw2Drjb1bw==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@tigrisdata/storage/-/storage-3.5.1.tgz", + "integrity": "sha512-52+cT33XWpTB7v0Hoin2smBesfQkSyyWXRwiAk4RDaTjXHgEPn9F4fnZZcS9D4LSeW4qgyYLBlTARxBpMy9m/g==", "license": "MIT", "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-sdk/client-s3": "^3.1038.0", "@aws-sdk/lib-storage": "^3.1038.0", "@aws-sdk/s3-request-presigner": "^3.1038.0", + "@aws-sdk/types": "^3.973.8", "@smithy/signature-v4": "^5.3.14", "dotenv": "^17.4.2" } @@ -5883,9 +5884,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index 89dd109..f327ecd 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "@aws-sdk/credential-providers": "^3.1038.0", "@smithy/shared-ini-file-loader": "^4.4.9", "@tigrisdata/iam": "^2.1.1", - "@tigrisdata/storage": "^3.4.0", + "@tigrisdata/storage": "^3.5.1", "commander": "^14.0.3", "enquirer": "^2.4.1", "jose": "^6.2.3", diff --git a/src/lib/cp.ts b/src/lib/cp.ts index ff6f414..8a30511 100644 --- a/src/lib/cp.ts +++ b/src/lib/cp.ts @@ -1,5 +1,5 @@ import { getStorageConfig } from '@auth/provider.js'; -import { get, head, list, put } from '@tigrisdata/storage'; +import { copy, get, head, list, put } from '@tigrisdata/storage'; import { executeWithConcurrency } from '@utils/concurrency.js'; import { exitWithError } from '@utils/exit.js'; import { formatSize } from '@utils/format.js'; @@ -210,9 +210,11 @@ async function copyObject( srcBucket: string, srcKey: string, destBucket: string, - destKey: string, - showProgress = false + destKey: string ): Promise<{ error?: string }> { + // Folder markers (zero-byte objects ending in `/`) are still + // created via put('') — CopyObject on a literal folder marker is + // ambiguous, and the marker has no payload to preserve. if (srcKey.endsWith('/')) { const { error: putError } = await put(destKey, '', { config: { @@ -228,56 +230,19 @@ async function copyObject( return {}; } - // head() is unconditional now: we need the source's Content-Type - // to propagate it to the destination so a remote→remote copy - // doesn't strip the header. - const { data: headData } = await head(srcKey, { - config: { - ...config, - bucket: srcBucket, - }, - }); - const fileSize = headData?.size; - const sourceContentType = headData?.contentType; - - const { data, error: getError } = await get(srcKey, 'stream', { - config: { - ...config, - bucket: srcBucket, - }, - }); - - if (getError) { - return { error: getError.message }; - } - - const { error: putError } = await put(destKey, data, { - ...calculateUploadParams(fileSize), - ...(sourceContentType ? { contentType: sourceContentType } : {}), - onUploadProgress: showProgress - ? ({ loaded }) => { - if (fileSize !== undefined && fileSize > 0) { - const pct = Math.round((loaded / fileSize) * 100); - process.stdout.write( - `\rCopying: ${formatSize(loaded)} / ${formatSize(fileSize)} (${pct}%)` - ); - } else { - process.stdout.write(`\rCopying: ${formatSize(loaded)}`); - } - } - : undefined, + // Server-side CopyObject. No bytes flow through the client and the + // source's Content-Type / metadata are preserved automatically. + const { error: copyError } = await copy(srcKey, destKey, { + srcBucket, + destBucket, config: { ...config, bucket: destBucket, }, }); - if (showProgress) { - process.stdout.write('\r' + ' '.repeat(60) + '\r'); - } - - if (putError) { - return { error: putError.message }; + if (copyError) { + return { error: copyError.message }; } return {}; @@ -825,8 +790,7 @@ async function copyRemoteToRemote( srcParsed.bucket, srcParsed.path, destParsed.bucket, - destKey, - !_jsonMode + destKey ); if (result.error) { diff --git a/src/lib/mv.ts b/src/lib/mv.ts index 9616480..e5ec561 100644 --- a/src/lib/mv.ts +++ b/src/lib/mv.ts @@ -1,7 +1,6 @@ import { getStorageConfig } from '@auth/provider.js'; -import { get, head, list, put, remove } from '@tigrisdata/storage'; +import { copy, list, move, put, remove } from '@tigrisdata/storage'; import { exitWithError } from '@utils/exit.js'; -import { formatSize } from '@utils/format.js'; import { confirm, requireInteractive } from '@utils/interactive.js'; import { getFormat, getOption } from '@utils/options.js'; import { @@ -12,7 +11,6 @@ import { parseRemotePath, wildcardPrefix, } from '@utils/path.js'; -import { calculateUploadParams } from '@utils/upload.js'; let _jsonMode = false; @@ -277,8 +275,7 @@ export default async function mv(options: Record) { srcPath.bucket, srcPath.path, destPath.bucket, - destKey, - !_jsonMode // show progress for single file (not in JSON mode) + destKey ); if (result.error) { @@ -308,12 +305,13 @@ async function moveObject( srcBucket: string, srcKey: string, destBucket: string, - destKey: string, - showProgress = false + destKey: string ): Promise<{ error?: string }> { - // Handle folder markers specially (empty objects ending with /) + // Folder markers (zero-byte objects ending in `/`) are still + // recreated via put('') + remove(). The server's rename header is + // not meaningful for the marker itself and we want to preserve the + // existing semantics here. if (srcKey.endsWith('/')) { - // Put empty string to destination (creates folder marker) const { error: putError } = await put(destKey, '', { config: { ...config, @@ -325,7 +323,6 @@ async function moveObject( return { error: putError.message }; } - // Delete source folder marker const { error: removeError } = await remove(srcKey, { config: { ...config, @@ -342,61 +339,39 @@ async function moveObject( return {}; } - // Get source object size and content-type for upload params and - // header propagation. Without this, a remote→remote move would - // strip the source's Content-Type. - const { data: headData } = await head(srcKey, { - config: { - ...config, - bucket: srcBucket, - }, - }); - const fileSize = headData?.size; - const sourceContentType = headData?.contentType; + // Same-bucket: metadata-only rename via `X-Tigris-Rename: true`. + // One round-trip, no bytes through the client. + if (srcBucket === destBucket) { + const { error: moveError } = await move(srcKey, destKey, { + config: { + ...config, + bucket: srcBucket, + }, + }); - // Get source object - const { data, error: getError } = await get(srcKey, 'stream', { - config: { - ...config, - bucket: srcBucket, - }, - }); + if (moveError) { + return { error: moveError.message }; + } - if (getError) { - return { error: getError.message }; + return {}; } - // Put to destination - const { error: putError } = await put(destKey, data, { - ...calculateUploadParams(fileSize), - ...(sourceContentType ? { contentType: sourceContentType } : {}), - onUploadProgress: showProgress - ? ({ loaded }) => { - if (fileSize !== undefined && fileSize > 0) { - const pct = Math.round((loaded / fileSize) * 100); - process.stdout.write( - `\rMoving: ${formatSize(loaded)} / ${formatSize(fileSize)} (${pct}%)` - ); - } else { - process.stdout.write(`\rMoving: ${formatSize(loaded)}`); - } - } - : undefined, + // Cross-bucket: the server doesn't support move across buckets, so + // fall back to server-side CopyObject + DELETE. Still no bytes + // through the client. + const { error: copyError } = await copy(srcKey, destKey, { + srcBucket, + destBucket, config: { ...config, bucket: destBucket, }, }); - if (showProgress) { - process.stdout.write('\r' + ' '.repeat(60) + '\r'); - } - - if (putError) { - return { error: putError.message }; + if (copyError) { + return { error: copyError.message }; } - // Delete source const { error: removeError } = await remove(srcKey, { config: { ...config, diff --git a/src/lib/objects/set-access.ts b/src/lib/objects/set-access.ts new file mode 100644 index 0000000..02fb62c --- /dev/null +++ b/src/lib/objects/set-access.ts @@ -0,0 +1,52 @@ +import { getStorageConfig } from '@auth/provider.js'; +import { setObjectAccess } 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 { resolveObjectArgs } from '@utils/path.js'; + +const context = msg('objects', 'set-access'); + +export default async function setAccess(options: Record) { + printStart(context); + + const format = getFormat(options); + + const bucketArg = getOption(options, ['bucket']); + const keyArg = getOption(options, ['key']); + const access = getOption(options, ['access', 'a', 'A']); + + if (!bucketArg) { + failWithError(context, 'Bucket name or path is required'); + } + + const { bucket, key } = resolveObjectArgs(bucketArg, keyArg); + + if (!key) { + failWithError(context, 'Object key is required'); + } + + if (access !== 'public' && access !== 'private') { + failWithError(context, '--access must be either "public" or "private"'); + } + + const config = await getStorageConfig(); + + const { error } = await setObjectAccess(key, { + access, + config: { + ...config, + bucket, + }, + }); + + if (error) { + failWithError(context, error); + } + + if (format === 'json') { + console.log(JSON.stringify({ action: 'updated', bucket, key, access })); + } + + printSuccess(context, { key, bucket, access }); +} diff --git a/src/lib/objects/set.ts b/src/lib/objects/set.ts index 8443a10..89dda15 100644 --- a/src/lib/objects/set.ts +++ b/src/lib/objects/set.ts @@ -1,5 +1,5 @@ import { getStorageConfig } from '@auth/provider.js'; -import { updateObject } from '@tigrisdata/storage'; +import { move, setObjectAccess } from '@tigrisdata/storage'; import { failWithError } from '@utils/exit.js'; import { msg, printStart, printSuccess } from '@utils/messages.js'; import { getFormat, getOption } from '@utils/options.js'; @@ -35,14 +35,23 @@ export default async function setObject(options: Record) { } const config = await getStorageConfig(); + const finalConfig = { ...config, bucket }; - const { error } = await updateObject(key, { + // Rename first so the access update targets the renamed object. + let currentKey = key; + if (newKey) { + const { error: moveError } = await move(key, newKey, { + config: finalConfig, + }); + if (moveError) { + failWithError(context, moveError); + } + currentKey = newKey; + } + + const { error } = await setObjectAccess(currentKey, { access: access === 'public' ? 'public' : 'private', - ...(newKey && { key: newKey }), - config: { - ...config, - bucket, - }, + config: finalConfig, }); if (error) { @@ -54,12 +63,12 @@ export default async function setObject(options: Record) { JSON.stringify({ action: 'updated', bucket, - key, + key: currentKey, access, ...(newKey ? { newKey } : {}), }) ); } - printSuccess(context, { key, bucket }); + printSuccess(context, { key: currentKey, bucket }); } diff --git a/src/specs.yaml b/src/specs.yaml index 69212cc..19ca7d8 100644 --- a/src/specs.yaml +++ b/src/specs.yaml @@ -1360,10 +1360,11 @@ commands: - name: force type: flag description: Skip confirmation prompts (alias for --yes) - # set + # set (deprecated) - name: set - description: Update settings on an existing object such as access level + description: (Deprecated) Update settings on an existing object such as access level. Use `tigris objects set-access` for ACL changes and `tigris mv` to rename alias: s + deprecated: true examples: - "tigris objects set my-bucket my-file.txt --access public" - "tigris objects set t3://my-bucket/my-file.txt --access public" @@ -1372,6 +1373,7 @@ commands: onStart: 'Updating object...' onSuccess: "Object '{{key}}' updated successfully" onFailure: 'Failed to update object' + onDeprecated: 'tigris objects set is deprecated. Use `tigris objects set-access` for ACL changes and `tigris mv` to rename objects. This command will be removed in a future release.' arguments: - name: bucket description: Name of the bucket, or a full path (t3://bucket/key) @@ -1388,6 +1390,34 @@ commands: - name: new-key description: Rename the object to a new key alias: n + # set-access + - name: set-access + description: Set the access level (public or private) on an existing object + alias: sa + examples: + - "tigris objects set-access my-bucket my-file.txt --access public" + - "tigris objects set-access t3://my-bucket/my-file.txt --access private" + messages: + onStart: 'Updating object access...' + onSuccess: "Access for '{{key}}' updated to {{access}}" + onFailure: 'Failed to update object access' + arguments: + - name: bucket + description: Name of the bucket, or a full path (t3://bucket/key) + type: positional + required: true + - name: key + description: Key of the object (omit if bucket contains the full path) + type: positional + - name: access + description: Access level + alias: a + options: *access_options + required: true + - name: format + description: Output format + options: [json, table, xml] + default: table # info - name: info description: Show metadata for an object (content type, size, modified date) diff --git a/test/cli.test.ts b/test/cli.test.ts index eab9612..4d2a8fb 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -272,6 +272,9 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { // Generate unique prefix for all test resources const testPrefix = getTestPrefix(); const testBucket = testPrefix; + // Second bucket used by cross-bucket cp/mv tests; created and torn + // down alongside the primary bucket. + const otherBucket = `${testPrefix}-other`; const testContent = 'Hello from CLI test'; /** Prefix a bucket/path with t3:// for commands that require remote paths (cp, mv, rm) */ @@ -315,6 +318,13 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { console.error('Failed to create test bucket:', result.stderr); throw new Error('Failed to create test bucket'); } + + console.log(`Creating second test bucket: ${otherBucket}`); + const otherResult = runCli(`mk ${otherBucket}`); + if (otherResult.exitCode !== 0) { + console.error('Failed to create second test bucket:', otherResult.stderr); + throw new Error('Failed to create second test bucket'); + } }); afterAll(async () => { @@ -322,6 +332,10 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { // Force remove all objects and the bucket runCli(`rm ${t3(testBucket)}/* -f`); runCli(`rm ${t3(testBucket)} -f`); + + console.log(`Cleaning up second test bucket: ${otherBucket}`); + runCli(`rm ${t3(otherBucket)}/* -f`); + runCli(`rm ${t3(otherBucket)} -f`); }); describe('ls command', () => { @@ -997,6 +1011,48 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); }); + describe('cross-bucket cp and mv', () => { + const cpFile = 'xb-cp-source.txt'; + const mvFile = 'xb-mv-source.txt'; + + beforeAll(() => { + runCli(`touch ${testBucket}/${cpFile}`); + runCli(`touch ${testBucket}/${mvFile}`); + }); + + it('should cp across buckets via server-side CopyObject', () => { + const result = runCli( + `cp ${t3(testBucket)}/${cpFile} ${t3(otherBucket)}/${cpFile}` + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Copied'); + + // Source still present in original bucket + const lsSrc = runCli(`ls ${testBucket}`); + expect(lsSrc.stdout).toContain(cpFile); + + // Destination present in other bucket + const lsDest = runCli(`ls ${otherBucket}`); + expect(lsDest.stdout).toContain(cpFile); + }); + + it('should mv across buckets via server-side copy + remove', () => { + const result = runCli( + `mv ${t3(testBucket)}/${mvFile} ${t3(otherBucket)}/${mvFile} -f` + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Moved'); + + // Source gone from original bucket + const lsSrc = runCli(`ls ${testBucket}`); + expect(lsSrc.stdout).not.toContain(mvFile); + + // Destination present in other bucket + const lsDest = runCli(`ls ${otherBucket}`); + expect(lsDest.stdout).toContain(mvFile); + }); + }); + describe('mv command - additional branches', () => { it('should move objects matching wildcard with -f', () => { runCli(`touch ${testBucket}/mv-wc-a.txt`); @@ -1689,6 +1745,37 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); }); + describe('objects set-access command', () => { + const testFile = 'setaccess-test.txt'; + + beforeAll(() => { + runCli(`touch ${testBucket}/${testFile}`); + }); + + afterAll(() => { + runCli(`rm ${t3(testBucket)}/${testFile} -f`); + }); + + it('should set --access public', () => { + const result = runCli( + `objects set-access ${testBucket} ${testFile} --access public` + ); + expect(result.exitCode).toBe(0); + }); + + it('should set --access private', () => { + const result = runCli( + `objects set-access ${testBucket} ${testFile} --access private` + ); + expect(result.exitCode).toBe(0); + }); + + it('should error on missing --access', () => { + const result = runCli(`objects set-access ${testBucket} ${testFile}`); + expect(result.exitCode).toBe(1); + }); + }); + describe('objects commands with t3:// paths', () => { const tmpBase = join(tmpdir(), `cli-test-t3path-${testPrefix}`);