From 04efeef810f0eaafc769c7f07a9395d4582bb166 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Thu, 2 Jul 2026 09:10:35 +0200 Subject: [PATCH] feat(buckets): add fork rebase and merge commands Add `buckets rebase ` and `buckets merge ` on top of the new rebaseFork/mergeFork APIs in @tigrisdata/storage 3.17.0: - rebase advances a fork onto the latest state of its source bucket. - merge writes a fork's changes back into its source; the source is auto-resolved from the fork's parent (getBucketInfo) unless --into is passed, with --from-snapshot to scope the merge to a fork snapshot. Both confirm before mutating (skip with -y) and support --json. Also make the existing stdin support of `objects put` discoverable: reword the description and add piped-input examples so `--help` shows that object data can be piped in. Adds integration coverage for rebase, merge (auto-resolved parent), and piped-stdin uploads. Assisted-by: Claude Opus 4.8 via Claude Code Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 8 ++-- package.json | 2 +- src/lib/buckets/merge.ts | 88 +++++++++++++++++++++++++++++++++++++++ src/lib/buckets/rebase.ts | 59 ++++++++++++++++++++++++++ src/specs.yaml | 54 +++++++++++++++++++++++- test/cli.test.ts | 77 ++++++++++++++++++++++++++++++++++ 6 files changed, 281 insertions(+), 7 deletions(-) create mode 100644 src/lib/buckets/merge.ts create mode 100644 src/lib/buckets/rebase.ts diff --git a/package-lock.json b/package-lock.json index 3c86c76..8ca7c91 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.2.0", - "@tigrisdata/storage": "^3.16.0", + "@tigrisdata/storage": "^3.17.0", "commander": "^14.0.3", "enquirer": "^2.4.1", "jose": "^6.2.3", @@ -3705,9 +3705,9 @@ } }, "node_modules/@tigrisdata/storage": { - "version": "3.16.0", - "resolved": "https://registry.npmjs.org/@tigrisdata/storage/-/storage-3.16.0.tgz", - "integrity": "sha512-Tdqh94A8gEj9GUF6fWGibO3Dz3l3BBieNMFh26PXUuBOTOpHEbPitg/zELopboVTRtRzWNsWnUFGymjxHYlpZA==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@tigrisdata/storage/-/storage-3.17.0.tgz", + "integrity": "sha512-Gpfx4y972nfa0y1XisrfEZcQZSagcrjoJOgItCC4l7xcjWRLfJ9WdpI8GglrjuqGVL1K6NVzMsyrjKlDqRbgag==", "license": "MIT", "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", diff --git a/package.json b/package.json index 2b57464..cf46a7d 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.2.0", - "@tigrisdata/storage": "^3.16.0", + "@tigrisdata/storage": "^3.17.0", "commander": "^14.0.3", "enquirer": "^2.4.1", "jose": "^6.2.3", diff --git a/src/lib/buckets/merge.ts b/src/lib/buckets/merge.ts new file mode 100644 index 0000000..fea1d18 --- /dev/null +++ b/src/lib/buckets/merge.ts @@ -0,0 +1,88 @@ +import { getStorageConfig } from '@auth/provider.js'; +import { getBucketInfo, mergeFork } from '@tigrisdata/storage'; +import { + failWithError, + getSuccessNextActions, + printNextActions, +} from '@utils/exit.js'; +import { confirm, requireInteractive } from '@utils/interactive.js'; +import { msg, printStart, printSuccess } from '@utils/messages.js'; +import { getFormat, getOption } from '@utils/options.js'; + +const context = msg('buckets', 'merge'); + +export default async function merge(options: Record) { + printStart(context); + + const format = getFormat(options); + const fork = getOption(options, ['fork']); + let into = getOption(options, ['into', 'source']); + const fromSnapshot = getOption(options, [ + 'from-snapshot', + 'fromSnapshot', + 'from-snap', + ]); + const force = getOption(options, ['yes', 'y']); + + if (!fork) { + failWithError(context, 'Fork bucket name is required'); + } + + const config = await getStorageConfig(); + + // Resolve the merge target. The gateway requires the target to be the fork's + // direct parent, so default to it unless --into overrides. + if (!into) { + const { data: info, error: infoError } = await getBucketInfo(fork, { + config, + }); + if (infoError) { + failWithError(context, infoError); + } + const parent = info?.forkInfo?.parents?.[0]?.bucketName; + if (!parent) { + failWithError( + context, + `'${fork}' is not a fork or its source bucket could not be determined. Pass --into .` + ); + } + into = parent; + } + + if (!force) { + requireInteractive('Use --yes to skip confirmation'); + const confirmed = await confirm( + `Merge fork '${fork}' into source '${into}'? This writes the fork's changes into '${into}'.` + ); + if (!confirmed) { + console.log('Aborted'); + return; + } + } + + const { data, error } = await mergeFork(fork, into, { + ...(fromSnapshot ? { forkSnapshot: fromSnapshot } : {}), + config, + }); + + if (error) { + failWithError(context, error); + } + + const snapshotVersion = data?.snapshotVersion ?? ''; + + if (format === 'json') { + const nextActions = getSuccessNextActions(context, { fork, into }); + const output: Record = { + action: 'merged', + fork, + into, + snapshotVersion, + }; + if (nextActions.length > 0) output.nextActions = nextActions; + console.log(JSON.stringify(output)); + } + + printSuccess(context, { fork, into, snapshotVersion }); + printNextActions(context, { fork, into }); +} diff --git a/src/lib/buckets/rebase.ts b/src/lib/buckets/rebase.ts new file mode 100644 index 0000000..33b36ba --- /dev/null +++ b/src/lib/buckets/rebase.ts @@ -0,0 +1,59 @@ +import { getStorageConfig } from '@auth/provider.js'; +import { rebaseFork } from '@tigrisdata/storage'; +import { + failWithError, + getSuccessNextActions, + printNextActions, +} from '@utils/exit.js'; +import { confirm, requireInteractive } from '@utils/interactive.js'; +import { msg, printStart, printSuccess } from '@utils/messages.js'; +import { getFormat, getOption } from '@utils/options.js'; + +const context = msg('buckets', 'rebase'); + +export default async function rebase(options: Record) { + printStart(context); + + const format = getFormat(options); + const fork = getOption(options, ['fork']); + const force = getOption(options, ['yes', 'y']); + + if (!fork) { + failWithError(context, 'Fork bucket name is required'); + } + + const config = await getStorageConfig(); + + if (!force) { + requireInteractive('Use --yes to skip confirmation'); + const confirmed = await confirm( + `Rebase fork '${fork}' onto the latest state of its source bucket? This updates '${fork}'.` + ); + if (!confirmed) { + console.log('Aborted'); + return; + } + } + + const { data, error } = await rebaseFork(fork, { config }); + + if (error) { + failWithError(context, error); + } + + const snapshotVersion = data?.snapshotVersion ?? ''; + + if (format === 'json') { + const nextActions = getSuccessNextActions(context, { fork }); + const output: Record = { + action: 'rebased', + fork, + snapshotVersion, + }; + if (nextActions.length > 0) output.nextActions = nextActions; + console.log(JSON.stringify(output)); + } + + printSuccess(context, { fork, snapshotVersion }); + printNextActions(context, { fork }); +} diff --git a/src/specs.yaml b/src/specs.yaml index b71d15a..1c39bc7 100644 --- a/src/specs.yaml +++ b/src/specs.yaml @@ -779,6 +779,54 @@ commands: - name: source-snapshot description: Fork from a specific snapshot of the source bucket. Accepts a snapshot version string or any UNIX nanosecond-precision timestamp (e.g. 1765889000501544464). Requires --fork-of alias: source-snap + # rebase + - name: rebase + description: Update a fork with the latest changes from its source bucket + examples: + - "tigris buckets rebase my-fork" + - "tigris buckets rebase my-fork --yes" + messages: + onStart: 'Rebasing fork...' + onSuccess: "Fork '{{fork}}' rebased onto its source (snapshot {{snapshotVersion}})" + onFailure: "Failed to rebase fork '{{fork}}'" + nextActions: + - command: 'tigris buckets merge {{fork}}' + description: "Merge the fork's changes back into its source bucket" + arguments: + - name: fork + description: Name of the fork bucket to rebase onto its source + type: positional + required: true + examples: + - my-fork + # merge + - name: merge + description: Merge a fork's changes back into its source bucket + examples: + - "tigris buckets merge my-fork" + - "tigris buckets merge my-fork --into my-bucket" + - "tigris buckets merge my-fork --from-snapshot 1765889000501544464" + - "tigris buckets merge my-fork --yes" + messages: + onStart: 'Merging fork...' + onSuccess: "Fork '{{fork}}' merged into '{{into}}' (snapshot {{snapshotVersion}})" + onFailure: "Failed to merge fork '{{fork}}'" + nextActions: + - command: 'tigris buckets get {{into}}' + description: 'Inspect the updated source bucket' + arguments: + - name: fork + description: Name of the fork bucket whose changes will be merged + type: positional + required: true + examples: + - my-fork + - name: into + description: Source bucket to merge into. Defaults to the fork's parent source bucket + alias: source + - name: from-snapshot + description: Merge from a specific snapshot of the fork rather than its current state. Accepts a snapshot version string or any UNIX nanosecond-precision timestamp (e.g. 1765889000501544464) + alias: from-snap # get - name: get description: Show details for a bucket including access level, region, tier, and custom domain @@ -1419,12 +1467,14 @@ commands: description: Object version id to download (requires bucket versioning). Omit to download the latest version # put - name: put - description: Upload a local file as an object. Content-type is auto-detected from extension unless overridden + description: Upload a local file — or data piped via stdin — as an object. Content-type is auto-detected from the file extension unless overridden alias: p examples: - "tigris objects put my-bucket report.pdf ./report.pdf" - "tigris objects put t3://my-bucket/report.pdf ./report.pdf" - "tigris objects put my-bucket logo.png ./logo.png --access public --content-type image/png" + - "echo 'hello' | tigris objects put t3://my-bucket/hello.txt" + - "cat report.pdf | tigris objects put my-bucket report.pdf --content-type application/pdf" messages: onStart: 'Uploading object...' onSuccess: "Object '{{key}}' uploaded successfully" @@ -1446,7 +1496,7 @@ commands: examples: - my-file.txt - name: file - description: Path to the local file to upload + description: Path to the local file to upload. Omit to read the object data from stdin (piped input) type: positional examples: - ./my-file.txt diff --git a/test/cli.test.ts b/test/cli.test.ts index b428dee..c72d370 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -57,6 +57,41 @@ function runCli(args: string): { } } +// Like runCli, but pipes `input` to the command's stdin. execSync's `input` +// makes stdin a pipe (not a TTY), so commands that read piped data — e.g. +// `objects put` from stdin — take the stdin path. runCli itself ignores stdin. +function runCliWithStdin( + input: string, + args: string +): { stdout: string; stderr: string; exitCode: number } { + try { + const stdout = execSync(`node dist/cli.js ${args}`, { + encoding: 'utf-8', + input, + timeout: 60000, + env: { + ...process.env, + TIGRIS_STORAGE_ACCESS_KEY_ID: process.env.TIGRIS_STORAGE_ACCESS_KEY_ID, + TIGRIS_STORAGE_SECRET_ACCESS_KEY: + process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY, + TIGRIS_STORAGE_ENDPOINT: process.env.TIGRIS_STORAGE_ENDPOINT, + }, + }); + return { stdout, stderr: '', exitCode: 0 }; + } catch (error: unknown) { + const execError = error as { + stdout?: string; + stderr?: string; + status?: number; + }; + return { + stdout: execError.stdout || '', + stderr: execError.stderr || '', + exitCode: execError.status || 1, + }; + } +} + // Configure CLI credentials from env vars before tests function setupCredentials(): boolean { const accessKey = process.env.TIGRIS_STORAGE_ACCESS_KEY_ID; @@ -411,6 +446,22 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain(putTestFile); }); + + it('should upload from stdin (piped input)', () => { + const stdinKey = 'stdin-test.txt'; + const stdinContent = 'piped hello from stdin'; + const putResult = runCliWithStdin( + stdinContent, + `objects put ${t3(testBucket)}/${stdinKey} --content-type text/plain` + ); + expect(putResult.exitCode).toBe(0); + expect(putResult.stdout).toContain(stdinKey); + + // Round-trip: the object should hold exactly what was piped in + const getResult = runCli(`objects get ${testBucket} ${stdinKey}`); + expect(getResult.exitCode).toBe(0); + expect(getResult.stdout).toContain(stdinContent); + }); }); describe('cp command', () => { @@ -2240,6 +2291,32 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { expect(result.stdout).toContain(forkBucket); }, 120_000); + it('should rebase the fork onto its source', () => { + const result = runCli(`buckets rebase ${forkBucket} --yes --format json`); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout.trim()); + expect(parsed.action).toBe('rebased'); + expect(parsed.fork).toBe(forkBucket); + expect(parsed).toHaveProperty('snapshotVersion'); + }, 120_000); + + it('should merge the fork back into its source (auto-resolved parent)', () => { + // merge auto-resolves the parent from the fork's info, which is + // eventually consistent — retry until it resolves. + let result = { stdout: '', stderr: '', exitCode: 1 }; + for (let i = 0; i < 3; i++) { + result = runCli(`buckets merge ${forkBucket} --yes --format json`); + if (result.exitCode === 0) break; + if (i < 2) execSync('sleep 5'); + } + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout.trim()); + expect(parsed.action).toBe('merged'); + expect(parsed.fork).toBe(forkBucket); + expect(parsed.into).toBe(snapBucket); + expect(parsed).toHaveProperty('snapshotVersion'); + }, 120_000); + it('should reject disable-snapshots while the bucket has dependent forks', () => { // The parent must register the fork before the guard trips (eventually // consistent); the preceding test already polled until it was listed.