diff --git a/packages/wrangler/src/__tests__/r2.test.ts b/packages/wrangler/src/__tests__/r2.test.ts index 738e05ca0710..5bcf235e9581 100644 --- a/packages/wrangler/src/__tests__/r2.test.ts +++ b/packages/wrangler/src/__tests__/r2.test.ts @@ -78,16 +78,10 @@ describe("r2", () => { mockApiToken(); it("should show help when the bucket command is passed", async () => { - await expect(() => runWrangler("r2 bucket")).rejects.toThrow( - "Not enough non-option arguments: got 0, need at least 1" - ); - expect(std.err).toMatchInlineSnapshot(` - "X [ERROR] Not enough non-option arguments: got 0, need at least 1 - -"`); + await runWrangler("r2 bucket"); + await endEventLoop(); expect(std.out).toMatchInlineSnapshot(` - " - wrangler r2 bucket + "wrangler r2 bucket Manage R2 buckets @@ -2110,16 +2104,10 @@ binding = \\"testBucket\\"" describe("r2 object", () => { it("should show help when the object command is passed", async () => { - await expect(() => runWrangler("r2 object")).rejects.toThrow( - "Not enough non-option arguments: got 0, need at least 1" - ); - expect(std.err).toMatchInlineSnapshot(` - "X [ERROR] Not enough non-option arguments: got 0, need at least 1 - -"`); + await runWrangler("r2 object"); + await endEventLoop(); expect(std.out).toMatchInlineSnapshot(` - " - wrangler r2 object + "wrangler r2 object Manage R2 objects diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 53c9cfb2dcbe..cbf09eb3d620 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -47,6 +47,7 @@ import { initHandler, initOptions } from "./init"; import "./docs"; import "./dev"; import "./kv"; +import "./r2"; import "./workflows"; import "./user/commands"; import { demandSingleValue } from "./core"; @@ -58,7 +59,6 @@ import { APIError, formatMessage, ParseError } from "./parse"; import { pipelines } from "./pipelines"; import { pubSubCommands } from "./pubsub/pubsub-commands"; import { queues } from "./queues/cli/commands"; -import { r2 } from "./r2"; import { secret, secretBulkHandler, secretBulkOptions } from "./secret"; import { addBreadcrumb, @@ -498,9 +498,7 @@ export function createCLIParser(argv: string[]) { }); // r2 - wrangler.command("r2", "📦 Manage R2 buckets & objects", (r2Yargs) => { - return r2(r2Yargs, subHelp); - }); + register.registerNamespace("r2"); // d1 wrangler.command("d1", `🗄 Manage Workers D1 databases`, (d1Yargs) => { diff --git a/packages/wrangler/src/r2/bucket.ts b/packages/wrangler/src/r2/bucket.ts new file mode 100644 index 000000000000..71cdd78aec8c --- /dev/null +++ b/packages/wrangler/src/r2/bucket.ts @@ -0,0 +1,272 @@ +import { defineCommand, defineNamespace } from "../core"; +import { UserError } from "../errors"; +import { logger } from "../logger"; +import * as metrics from "../metrics"; +import { requireAuth } from "../user"; +import { getValidBindingName } from "../utils/getValidBindingName"; +import formatLabelledValues from "../utils/render-labelled-values"; +import { LOCATION_CHOICES } from "./constants"; +import { + createR2Bucket, + deleteR2Bucket, + getR2Bucket, + getR2BucketMetrics, + isValidR2BucketName, + listR2Buckets, + tablefromR2BucketsListResponse, + updateR2BucketStorageClass, +} from "./helpers"; + +defineNamespace({ + command: "wrangler r2 bucket", + metadata: { + description: "Manage R2 buckets", + status: "stable", + owner: "Product: R2", + }, +}); + +defineCommand({ + command: "wrangler r2 bucket create", + metadata: { + description: "Create a new R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["name"], + args: { + name: { + describe: "The name of the new bucket", + type: "string", + demandOption: true, + }, + location: { + describe: + "The optional location hint that determines geographic placement of the R2 bucket", + choices: LOCATION_CHOICES, + requiresArg: true, + type: "string", + }, + "storage-class": { + describe: "The default storage class for objects uploaded to this bucket", + alias: "s", + requiresArg: false, + type: "string", + }, + jurisdiction: { + describe: "The jurisdiction where the new bucket will be created", + alias: "J", + requiresArg: true, + type: "string", + }, + }, + async handler(args, { config }) { + const accountId = await requireAuth(config); + const { name, location, storageClass, jurisdiction } = args; + + if (!isValidR2BucketName(name)) { + throw new UserError( + `The bucket name "${name}" is invalid. Bucket names can only have alphanumeric and - characters.` + ); + } + + if (jurisdiction && location) { + throw new UserError( + "Provide either a jurisdiction or location hint - not both." + ); + } + + let fullBucketName = `${name}`; + if (jurisdiction !== undefined) { + fullBucketName += ` (${jurisdiction})`; + } + + logger.log(`Creating bucket '${fullBucketName}'...`); + await createR2Bucket(accountId, name, location, jurisdiction, storageClass); + logger.log( + `✅ Created bucket '${fullBucketName}' with${ + location ? ` location hint ${location} and` : `` + } default storage class of ${storageClass ? storageClass : `Standard`}.\n\n` + + "Configure your Worker to write objects to this bucket:\n\n" + + "[[r2_buckets]]\n" + + `bucket_name = "${args.name}"\n` + + `binding = "${getValidBindingName(args.name, "r2")}"` + ); + + await metrics.sendMetricsEvent("create r2 bucket", { + sendMetrics: config.send_metrics, + }); + }, +}); + +defineNamespace({ + command: "wrangler r2 bucket update", + metadata: { + description: "Update bucket state", + status: "stable", + owner: "Product: R2", + }, +}); + +defineCommand({ + command: "wrangler r2 bucket update storage-class", + metadata: { + description: "Update the default storage class of an existing R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["name"], + args: { + name: { + describe: "The name of the existing bucket", + type: "string", + demandOption: true, + }, + jurisdiction: { + describe: "The jurisdiction of the bucket to be updated", + alias: "J", + requiresArg: true, + type: "string", + }, + "storage-class": { + describe: "The new default storage class for this bucket", + alias: "s", + demandOption: true, + requiresArg: true, + type: "string", + }, + }, + async handler(args, { config }) { + const accountId = await requireAuth(config); + + let fullBucketName = `${args.name}`; + if (args.jurisdiction !== undefined) { + fullBucketName += ` (${args.jurisdiction})`; + } + logger.log( + `Updating bucket ${fullBucketName} to ${args.storageClass} default storage class.` + ); + await updateR2BucketStorageClass( + accountId, + args.name, + args.storageClass, + args.jurisdiction + ); + logger.log( + `Updated bucket ${fullBucketName} to ${args.storageClass} default storage class.` + ); + }, +}); + +defineCommand({ + command: "wrangler r2 bucket list", + metadata: { + description: "List R2 buckets", + status: "stable", + owner: "Product: R2", + }, + args: { + jurisdiction: { + describe: "The jurisdiction to list", + alias: "J", + requiresArg: true, + type: "string", + }, + }, + async handler(args, { config }) { + const accountId = await requireAuth(config); + + logger.log(`Listing buckets...`); + + const buckets = await listR2Buckets(accountId, args.jurisdiction); + const tableOutput = tablefromR2BucketsListResponse(buckets); + logger.log(tableOutput.map((x) => formatLabelledValues(x)).join("\n\n")); + }, +}); + +defineCommand({ + command: "wrangler r2 bucket info", + metadata: { + description: "Get information about an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket"], + args: { + bucket: { + describe: "The name of the bucket to delete", + type: "string", + demandOption: true, + }, + jurisdiction: { + describe: "The jurisdiction where the bucket exists", + alias: "J", + requiresArg: true, + type: "string", + }, + }, + async handler(args, { config }) { + const accountId = await requireAuth(config); + + logger.log(`Getting info for '${args.bucket}'...`); + + const bucketInfo = await getR2Bucket( + accountId, + args.bucket, + args.jurisdiction + ); + const bucketMetrics = await getR2BucketMetrics( + accountId, + args.bucket, + args.jurisdiction + ); + + const output = { + name: bucketInfo.name, + created: bucketInfo.creation_date, + location: bucketInfo.location || "(unknown)", + default_storage_class: bucketInfo.storage_class || "(unknown)", + object_count: bucketMetrics.objectCount.toLocaleString(), + bucket_size: bucketMetrics.totalSize, + }; + + logger.log(formatLabelledValues(output)); + }, +}); + +defineCommand({ + command: "wrangler r2 bucket delete", + metadata: { + description: "Delete an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket"], + args: { + bucket: { + describe: "The name of the bucket to delete", + type: "string", + demandOption: true, + }, + jurisdiction: { + describe: "The jurisdiction where the bucket exists", + alias: "J", + requiresArg: true, + type: "string", + }, + }, + async handler(args, { config }) { + const accountId = await requireAuth(config); + + let fullBucketName = `${args.bucket}`; + if (args.jurisdiction !== undefined) { + fullBucketName += ` (${args.jurisdiction})`; + } + logger.log(`Deleting bucket ${fullBucketName}.`); + await deleteR2Bucket(accountId, args.bucket, args.jurisdiction); + logger.log(`Deleted bucket ${fullBucketName}.`); + await metrics.sendMetricsEvent("delete r2 bucket", { + sendMetrics: config.send_metrics, + }); + }, +}); diff --git a/packages/wrangler/src/r2/create.ts b/packages/wrangler/src/r2/create.ts deleted file mode 100644 index 1eab044a0095..000000000000 --- a/packages/wrangler/src/r2/create.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { printWranglerBanner } from ".."; -import { readConfig } from "../config"; -import { UserError } from "../errors"; -import { logger } from "../logger"; -import * as metrics from "../metrics"; -import { requireAuth } from "../user"; -import { getValidBindingName } from "../utils/getValidBindingName"; -import { LOCATION_CHOICES } from "./constants"; -import { createR2Bucket, isValidR2BucketName } from "./helpers"; -import type { - CommonYargsArgv, - StrictYargsOptionsToInterface, -} from "../yargs-types"; - -export function Options(yargs: CommonYargsArgv) { - return yargs - .positional("name", { - describe: "The name of the new bucket", - type: "string", - demandOption: true, - }) - .option("location", { - describe: - "The optional location hint that determines geographic placement of the R2 bucket", - choices: LOCATION_CHOICES, - requiresArg: true, - type: "string", - }) - .option("storage-class", { - describe: "The default storage class for objects uploaded to this bucket", - alias: "s", - requiresArg: false, - type: "string", - }) - .option("jurisdiction", { - describe: "The jurisdiction where the new bucket will be created", - alias: "J", - requiresArg: true, - type: "string", - }); -} - -type HandlerOptions = StrictYargsOptionsToInterface; -export async function Handler(args: HandlerOptions) { - await printWranglerBanner(); - const config = readConfig(args.config, args); - const accountId = await requireAuth(config); - const { name, location, storageClass, jurisdiction } = args; - - if (!isValidR2BucketName(name)) { - throw new UserError( - `The bucket name "${name}" is invalid. Bucket names can only have alphanumeric and - characters.` - ); - } - - if (jurisdiction && location) { - throw new UserError( - "Provide either a jurisdiction or location hint - not both." - ); - } - - let fullBucketName = `${name}`; - if (jurisdiction !== undefined) { - fullBucketName += ` (${jurisdiction})`; - } - - logger.log(`Creating bucket '${fullBucketName}'...`); - await createR2Bucket(accountId, name, location, jurisdiction, storageClass); - logger.log( - `✅ Created bucket '${fullBucketName}' with${ - location ? ` location hint ${location} and` : `` - } default storage class of ${storageClass ? storageClass : `Standard`}.\n\n` + - "Configure your Worker to write objects to this bucket:\n\n" + - "[[r2_buckets]]\n" + - `bucket_name = "${args.name}"\n` + - `binding = "${getValidBindingName(args.name, "r2")}"` - ); - - await metrics.sendMetricsEvent("create r2 bucket", { - sendMetrics: config.send_metrics, - }); -} diff --git a/packages/wrangler/src/r2/domain.ts b/packages/wrangler/src/r2/domain.ts index 15e60f55c258..31b9771178d0 100644 --- a/packages/wrangler/src/r2/domain.ts +++ b/packages/wrangler/src/r2/domain.ts @@ -1,7 +1,6 @@ -import { readConfig } from "../config"; +import { defineCommand, defineNamespace } from "../core"; import { confirm } from "../dialogs"; import { logger } from "../logger"; -import { printWranglerBanner } from "../update-check"; import { requireAuth } from "../user"; import formatLabelledValues from "../utils/render-labelled-values"; import { @@ -11,224 +10,249 @@ import { removeCustomDomainFromBucket, tableFromCustomDomainListResponse, } from "./helpers"; -import type { - CommonYargsArgv, - StrictYargsOptionsToInterface, -} from "../yargs-types"; - -export function ListOptions(yargs: CommonYargsArgv) { - return yargs - .positional("bucket", { + +defineNamespace({ + command: "wrangler r2 bucket domain", + metadata: { + description: "Manage custom domains for an R2 bucket", + status: "stable", + owner: "Product: R2", + }, +}); + +defineCommand({ + command: "wrangler r2 bucket domain list", + metadata: { + description: "List custom domains for an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket"], + args: { + bucket: { describe: "The name of the R2 bucket whose connected custom domains will be listed", type: "string", demandOption: true, - }) - .option("jurisdiction", { + }, + jurisdiction: { describe: "The jurisdiction where the bucket exists", alias: "J", requiresArg: true, type: "string", - }); -} - -export async function ListHandler( - args: StrictYargsOptionsToInterface -) { - await printWranglerBanner(); - const config = readConfig(args.config, args); - const accountId = await requireAuth(config); - - const { bucket, jurisdiction } = args; - - logger.log(`Listing custom domains connected to bucket '${bucket}'...`); - - const domains = await listCustomDomainsOfBucket( - accountId, - bucket, - jurisdiction - ); - - if (domains.length === 0) { - logger.log("There are no custom domains connected to this bucket."); - } else { - const tableOutput = tableFromCustomDomainListResponse(domains); - logger.log(tableOutput.map((x) => formatLabelledValues(x)).join("\n\n")); - } -} - -export function AddOptions(yargs: CommonYargsArgv) { - return yargs - .positional("bucket", { + }, + }, + async handler(args, { config }) { + const accountId = await requireAuth(config); + + const { bucket, jurisdiction } = args; + + logger.log(`Listing custom domains connected to bucket '${bucket}'...`); + + const domains = await listCustomDomainsOfBucket( + accountId, + bucket, + jurisdiction + ); + + if (domains.length === 0) { + logger.log("There are no custom domains connected to this bucket."); + } else { + const tableOutput = tableFromCustomDomainListResponse(domains); + logger.log(tableOutput.map((x) => formatLabelledValues(x)).join("\n\n")); + } + }, +}); + +defineCommand({ + command: "wrangler r2 bucket domain add", + metadata: { + description: "Connect a custom domain to an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket"], + args: { + bucket: { describe: "The name of the R2 bucket to connect a custom domain to", type: "string", demandOption: true, - }) - .option("domain", { + }, + domain: { describe: "The custom domain to connect to the R2 bucket", type: "string", demandOption: true, - }) - .option("zone-id", { + }, + "zone-id": { describe: "The zone ID associated with the custom domain", type: "string", demandOption: true, - }) - .option("min-tls", { + }, + "min-tls": { describe: "Set the minimum TLS version for the custom domain (defaults to 1.0 if not set)", choices: ["1.0", "1.1", "1.2", "1.3"], type: "string", - }) - .option("jurisdiction", { + }, + jurisdiction: { describe: "The jurisdiction where the bucket exists", alias: "J", requiresArg: true, type: "string", - }) - .option("force", { + }, + force: { describe: "Skip confirmation", type: "boolean", alias: "y", default: false, - }); -} - -export async function AddHandler( - args: StrictYargsOptionsToInterface -) { - await printWranglerBanner(); - const config = readConfig(args.config, args); - const accountId = await requireAuth(config); - - const { bucket, domain, zoneId, minTls = "1.0", jurisdiction, force } = args; - - if (!force) { - const confirmedAdd = await confirm( - `Are you sure you want to add the custom domain '${domain}' to bucket '${bucket}'? ` + - `The contents of your bucket will be made publicly available at 'https://${domain}'` - ); - if (!confirmedAdd) { - logger.log("Add cancelled."); - return; - } - } - - logger.log(`Connecting custom domain '${domain}' to bucket '${bucket}'...`); + }, + }, + async handler(args, { config }) { + const accountId = await requireAuth(config); - await attachCustomDomainToBucket( - accountId, - bucket, - { + const { + bucket, domain, zoneId, - minTLS: minTls, - }, - jurisdiction - ); + minTls = "1.0", + jurisdiction, + force, + } = args; + + if (!force) { + const confirmedAdd = await confirm( + `Are you sure you want to add the custom domain '${domain}' to bucket '${bucket}'? ` + + `The contents of your bucket will be made publicly available at 'https://${domain}'` + ); + if (!confirmedAdd) { + logger.log("Add cancelled."); + return; + } + } + + logger.log(`Connecting custom domain '${domain}' to bucket '${bucket}'...`); + + await attachCustomDomainToBucket( + accountId, + bucket, + { + domain, + zoneId, + minTLS: minTls, + }, + jurisdiction + ); - logger.log(`✨ Custom domain '${domain}' connected successfully.`); -} + logger.log(`✨ Custom domain '${domain}' connected successfully.`); + }, +}); -export function RemoveOptions(yargs: CommonYargsArgv) { - return yargs - .positional("bucket", { +defineCommand({ + command: "wrangler r2 bucket domain remove", + metadata: { + description: "Remove a custom domain from an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket"], + args: { + bucket: { describe: "The name of the R2 bucket to remove the custom domain from", type: "string", demandOption: true, - }) - .option("domain", { + }, + domain: { describe: "The custom domain to remove from the R2 bucket", type: "string", demandOption: true, - }) - .option("jurisdiction", { + }, + jurisdiction: { describe: "The jurisdiction where the bucket exists", alias: "J", requiresArg: true, type: "string", - }) - .option("force", { + }, + force: { describe: "Skip confirmation", type: "boolean", alias: "y", default: false, - }); -} - -export async function RemoveHandler( - args: StrictYargsOptionsToInterface -) { - await printWranglerBanner(); - const config = readConfig(args.config, args); - const accountId = await requireAuth(config); - - const { bucket, domain, jurisdiction, force } = args; - - if (!force) { - const confirmedRemoval = await confirm( - `Are you sure you want to remove the custom domain '${domain}' from bucket '${bucket}'? ` + - `Your bucket will no longer be available from 'https://${domain}'` - ); - if (!confirmedRemoval) { - logger.log("Removal cancelled."); - return; + }, + }, + async handler(args, { config }) { + const accountId = await requireAuth(config); + + const { bucket, domain, jurisdiction, force } = args; + + if (!force) { + const confirmedRemoval = await confirm( + `Are you sure you want to remove the custom domain '${domain}' from bucket '${bucket}'? ` + + `Your bucket will no longer be available from 'https://${domain}'` + ); + if (!confirmedRemoval) { + logger.log("Removal cancelled."); + return; + } } - } - logger.log(`Removing custom domain '${domain}' from bucket '${bucket}'...`); + logger.log(`Removing custom domain '${domain}' from bucket '${bucket}'...`); - await removeCustomDomainFromBucket(accountId, bucket, domain, jurisdiction); + await removeCustomDomainFromBucket(accountId, bucket, domain, jurisdiction); - logger.log(`Custom domain '${domain}' removed successfully.`); -} + logger.log(`Custom domain '${domain}' removed successfully.`); + }, +}); -export function UpdateOptions(yargs: CommonYargsArgv) { - return yargs - .positional("bucket", { +defineCommand({ + command: "wrangler r2 bucket domain update", + metadata: { + description: + "Update settings for a custom domain connected to an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket"], + args: { + bucket: { describe: "The name of the R2 bucket associated with the custom domain to update", type: "string", demandOption: true, - }) - .option("domain", { + }, + domain: { describe: "The custom domain whose settings will be updated", type: "string", demandOption: true, - }) - .option("min-tls", { + }, + "min-tls": { describe: "Update the minimum TLS version for the custom domain", choices: ["1.0", "1.1", "1.2", "1.3"], type: "string", - }) - .option("jurisdiction", { + }, + jurisdiction: { describe: "The jurisdiction where the bucket exists", alias: "J", requiresArg: true, type: "string", - }); -} - -export async function UpdateHandler( - args: StrictYargsOptionsToInterface -) { - await printWranglerBanner(); - const config = readConfig(args.config, args); - const accountId = await requireAuth(config); + }, + }, + async handler(args, { config }) { + const accountId = await requireAuth(config); - const { bucket, domain, minTls, jurisdiction } = args; + const { bucket, domain, minTls, jurisdiction } = args; - logger.log(`Updating custom domain '${domain}' for bucket '${bucket}'...`); + logger.log(`Updating custom domain '${domain}' for bucket '${bucket}'...`); - await configureCustomDomainSettings( - accountId, - bucket, - domain, - { + await configureCustomDomainSettings( + accountId, + bucket, domain, - minTLS: minTls, - }, - jurisdiction - ); + { + domain, + minTLS: minTls, + }, + jurisdiction + ); - logger.log(`✨ Custom domain '${domain}' updated successfully.`); -} + logger.log(`✨ Custom domain '${domain}' updated successfully.`); + }, +}); diff --git a/packages/wrangler/src/r2/helpers.ts b/packages/wrangler/src/r2/helpers.ts index f0594bb228d9..e8ac45cb7476 100644 --- a/packages/wrangler/src/r2/helpers.ts +++ b/packages/wrangler/src/r2/helpers.ts @@ -1,3 +1,5 @@ +import * as fs from "node:fs"; +import { ReadableStream } from "node:stream/web"; import { Miniflare } from "miniflare"; import prettyBytes from "pretty-bytes"; import { fetchGraphqlResult, fetchResult } from "../cfetch"; @@ -12,7 +14,6 @@ import type { ApiCredentials } from "../user"; import type { R2Bucket } from "@cloudflare/workers-types/experimental"; import type { ReplaceWorkersTypes } from "miniflare"; import type { Readable } from "node:stream"; -import type { ReadableStream } from "node:stream/web"; import type { HeadersInit } from "undici"; /** @@ -1089,3 +1090,26 @@ export function isNonNegativeNumber(str: string): boolean { export function isValidR2BucketName(name: string | undefined): name is string { return typeof name === "string" && /^[a-zA-Z][a-zA-Z0-9-]*$/.test(name); } + +const CHUNK_SIZE = 1024; +export async function createFileReadableStream(filePath: string) { + // Based off https://streams.spec.whatwg.org/#example-rs-pull + const handle = await fs.promises.open(filePath, "r"); + let position = 0; + return new ReadableStream({ + async pull(controller) { + const buffer = new Uint8Array(CHUNK_SIZE); + const { bytesRead } = await handle.read(buffer, 0, CHUNK_SIZE, position); + if (bytesRead === 0) { + await handle.close(); + controller.close(); + } else { + position += bytesRead; + controller.enqueue(buffer.subarray(0, bytesRead)); + } + }, + cancel() { + return handle.close(); + }, + }); +} diff --git a/packages/wrangler/src/r2/index.ts b/packages/wrangler/src/r2/index.ts index 3e3e7baf476e..1ffffa42defc 100644 --- a/packages/wrangler/src/r2/index.ts +++ b/packages/wrangler/src/r2/index.ts @@ -1,688 +1,17 @@ -import { Blob } from "node:buffer"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import * as stream from "node:stream"; -import { ReadableStream } from "node:stream/web"; -import prettyBytes from "pretty-bytes"; -import { readConfig } from "../config"; -import { CommandLineArgsError, FatalError, UserError } from "../errors"; -import { printWranglerBanner } from "../index"; -import { logger } from "../logger"; -import * as metrics from "../metrics"; -import { requireAuth } from "../user"; -import { MAX_UPLOAD_SIZE } from "./constants"; -import * as Create from "./create"; -import * as Domain from "./domain"; -import { - bucketAndKeyFromObjectPath, - deleteR2Bucket, - deleteR2Object, - getR2Object, - putR2Object, - updateR2BucketStorageClass, - usingLocalBucket, -} from "./helpers"; -import * as Info from "./info"; -import * as Lifecycle from "./lifecycle"; -import * as List from "./list"; -import * as Notification from "./notification"; -import * as PublicDevUrl from "./public-dev-url"; -import * as Sippy from "./sippy"; -import type { CommonYargsArgv, SubHelp } from "../yargs-types"; -import type { R2PutOptions } from "@cloudflare/workers-types/experimental"; - -const CHUNK_SIZE = 1024; -async function createFileReadableStream(filePath: string) { - // Based off https://streams.spec.whatwg.org/#example-rs-pull - const handle = await fs.promises.open(filePath, "r"); - let position = 0; - return new ReadableStream({ - async pull(controller) { - const buffer = new Uint8Array(CHUNK_SIZE); - const { bytesRead } = await handle.read(buffer, 0, CHUNK_SIZE, position); - if (bytesRead === 0) { - await handle.close(); - controller.close(); - } else { - position += bytesRead; - controller.enqueue(buffer.subarray(0, bytesRead)); - } - }, - cancel() { - return handle.close(); - }, - }); -} - -export function r2(r2Yargs: CommonYargsArgv, subHelp: SubHelp) { - return r2Yargs - .command(subHelp) - .command("object", "Manage R2 objects", (r2ObjectYargs) => { - return r2ObjectYargs - .demandCommand() - .command( - "get ", - "Fetch an object from an R2 bucket", - (objectArgs) => { - return objectArgs - .positional("objectPath", { - describe: - "The source object path in the form of {bucket}/{key}", - type: "string", - }) - .option("file", { - describe: "The destination file to create", - alias: "f", - conflicts: "pipe", - requiresArg: true, - type: "string", - }) - .option("pipe", { - describe: - "Enables the file to be piped to a destination, rather than specified with the --file option", - alias: "p", - conflicts: "file", - type: "boolean", - }) - .option("local", { - type: "boolean", - describe: "Interact with local storage", - }) - .option("persist-to", { - type: "string", - describe: "Directory for local persistence", - }) - .option("jurisdiction", { - describe: "The jurisdiction where the object exists", - alias: "J", - requiresArg: true, - type: "string", - }); - }, - async (objectGetYargs) => { - const config = readConfig(objectGetYargs.config, objectGetYargs); - const { objectPath, pipe, jurisdiction } = objectGetYargs; - const { bucket, key } = bucketAndKeyFromObjectPath(objectPath); - let fullBucketName = bucket; - if (jurisdiction !== undefined) { - fullBucketName += ` (${jurisdiction})`; - } - - let file = objectGetYargs.file; - if (!file && !pipe) { - file = key; - } - if (!pipe) { - await printWranglerBanner(); - logger.log(`Downloading "${key}" from "${fullBucketName}".`); - } - - let output: stream.Writable; - if (file) { - fs.mkdirSync(path.dirname(file), { recursive: true }); - output = fs.createWriteStream(file); - } else { - output = process.stdout; - } - if (objectGetYargs.local) { - await usingLocalBucket( - objectGetYargs.persistTo, - config.configPath, - bucket, - async (r2Bucket) => { - const object = await r2Bucket.get(key); - if (object === null) { - throw new UserError("The specified key does not exist."); - } - // Note `object.body` is only valid inside this closure - await stream.promises.pipeline(object.body, output); - } - ); - } else { - const accountId = await requireAuth(config); - const input = await getR2Object( - accountId, - bucket, - key, - jurisdiction - ); - if (input === null) { - throw new UserError("The specified key does not exist."); - } - await stream.promises.pipeline(input, output); - } - if (!pipe) { - logger.log("Download complete."); - } - } - ) - .command( - "put ", - "Create an object in an R2 bucket", - (Objectyargs) => { - return Objectyargs.positional("objectPath", { - describe: - "The destination object path in the form of {bucket}/{key}", - type: "string", - }) - .option("file", { - describe: "The path of the file to upload", - alias: "f", - conflicts: "pipe", - requiresArg: true, - type: "string", - }) - .option("pipe", { - describe: - "Enables the file to be piped in, rather than specified with the --file option", - alias: "p", - conflicts: "file", - type: "boolean", - }) - .option("content-type", { - describe: - "A standard MIME type describing the format of the object data", - alias: "ct", - requiresArg: true, - type: "string", - }) - .option("content-disposition", { - describe: "Specifies presentational information for the object", - alias: "cd", - requiresArg: true, - type: "string", - }) - .option("content-encoding", { - describe: - "Specifies what content encodings have been applied to the object and thus what decoding mechanisms must be applied to obtain the media-type referenced by the Content-Type header field", - alias: "ce", - requiresArg: true, - type: "string", - }) - .option("content-language", { - describe: "The language the content is in", - alias: "cl", - requiresArg: true, - type: "string", - }) - .option("cache-control", { - describe: - "Specifies caching behavior along the request/reply chain", - alias: "cc", - requiresArg: true, - type: "string", - }) - .option("expires", { - describe: - "The date and time at which the object is no longer cacheable", - alias: "e", - requiresArg: true, - type: "string", - }) - .option("local", { - type: "boolean", - describe: "Interact with local storage", - }) - .option("persist-to", { - type: "string", - describe: "Directory for local persistence", - }) - .option("jurisdiction", { - describe: "The jurisdiction where the object will be created", - alias: "J", - requiresArg: true, - type: "string", - }) - .option("storage-class", { - describe: "The storage class of the object to be created", - alias: "s", - requiresArg: false, - type: "string", - }); - }, - async (objectPutYargs) => { - await printWranglerBanner(); - - const config = readConfig(objectPutYargs.config, objectPutYargs); - const { - objectPath, - file, - pipe, - local, - persistTo, - jurisdiction, - storageClass, - ...options - } = objectPutYargs; - const { bucket, key } = bucketAndKeyFromObjectPath(objectPath); - if (!file && !pipe) { - throw new CommandLineArgsError( - "Either the --file or --pipe options are required." - ); - } - let object: ReadableStream; - let objectSize: number; - if (file) { - object = await createFileReadableStream(file); - const stats = fs.statSync(file); - objectSize = stats.size; - } else { - const buffer = await new Promise((resolve, reject) => { - const stdin = process.stdin; - const chunks = Array(); - stdin.on("data", (chunk) => chunks.push(chunk)); - stdin.on("end", () => resolve(Buffer.concat(chunks))); - stdin.on("error", (err) => - reject( - new CommandLineArgsError( - `Could not pipe. Reason: "${err.message}"` - ) - ) - ); - }); - const blob = new Blob([buffer]); - object = blob.stream(); - objectSize = blob.size; - } - - if (objectSize > MAX_UPLOAD_SIZE && !local) { - throw new FatalError( - `Error: Wrangler only supports uploading files up to ${prettyBytes( - MAX_UPLOAD_SIZE, - { binary: true } - )} in size\n${key} is ${prettyBytes(objectSize, { - binary: true, - })} in size`, - 1 - ); - } - - let fullBucketName = bucket; - if (jurisdiction !== undefined) { - fullBucketName += ` (${jurisdiction})`; - } - - let storageClassLog = ``; - if (storageClass !== undefined) { - storageClassLog = ` with ${storageClass} storage class`; - } - - logger.log( - `Creating object "${key}"${storageClassLog} in bucket "${fullBucketName}".` - ); - - if (local) { - await usingLocalBucket( - persistTo, - config.configPath, - bucket, - async (r2Bucket, mf) => { - const putOptions: R2PutOptions = { - httpMetadata: { - contentType: options.contentType, - contentDisposition: options.contentDisposition, - contentEncoding: options.contentEncoding, - contentLanguage: options.contentLanguage, - cacheControl: options.cacheControl, - // @ts-expect-error `@cloudflare/workers-types` is wrong - // here, `number`'s are allowed for `Date`s - // TODO(now): fix - cacheExpiry: - options.expires === undefined - ? undefined - : parseInt(options.expires), - }, - customMetadata: undefined, - sha1: undefined, - sha256: undefined, - onlyIf: undefined, - md5: undefined, - sha384: undefined, - sha512: undefined, - }; - // We can't use `r2Bucket.put()` here as `R2Bucket#put()` - // requires a known length stream, and Miniflare's magic proxy - // currently doesn't support sending these. Instead, - // `usingLocalBucket()` provides a single `PUT` endpoint - // for writing to a local bucket. - await mf.dispatchFetch(`http://localhost/${key}`, { - method: "PUT", - body: object, - duplex: "half", - headers: { - "Content-Length": objectSize.toString(), - "Wrangler-R2-Put-Options": JSON.stringify(putOptions), - }, - }); - } - ); - } else { - const accountId = await requireAuth(config); - await putR2Object( - accountId, - bucket, - key, - object, - { - ...options, - "content-length": `${objectSize}`, - }, - jurisdiction, - storageClass - ); - } - - logger.log("Upload complete."); - } - ) - .command( - "delete ", - "Delete an object in an R2 bucket", - (objectDeleteYargs) => { - return objectDeleteYargs - .positional("objectPath", { - describe: - "The destination object path in the form of {bucket}/{key}", - type: "string", - }) - .option("local", { - type: "boolean", - describe: "Interact with local storage", - }) - .option("persist-to", { - type: "string", - describe: "Directory for local persistence", - }) - .option("jurisdiction", { - describe: "The jurisdiction where the object exists", - alias: "J", - requiresArg: true, - type: "string", - }); - }, - async (args) => { - const { objectPath, jurisdiction } = args; - await printWranglerBanner(); - - const config = readConfig(args.config, args); - const { bucket, key } = bucketAndKeyFromObjectPath(objectPath); - let fullBucketName = bucket; - if (jurisdiction !== undefined) { - fullBucketName += ` (${jurisdiction})`; - } - - logger.log( - `Deleting object "${key}" from bucket "${fullBucketName}".` - ); - - if (args.local) { - await usingLocalBucket( - args.persistTo, - config.configPath, - bucket, - (r2Bucket) => r2Bucket.delete(key) - ); - } else { - const accountId = await requireAuth(config); - await deleteR2Object(accountId, bucket, key, jurisdiction); - } - - logger.log("Delete complete."); - } - ); - }) - - .command("bucket", "Manage R2 buckets", (r2BucketYargs) => { - r2BucketYargs.demandCommand(); - r2BucketYargs.command( - "create ", - "Create a new R2 bucket", - Create.Options, - Create.Handler - ); - - r2BucketYargs.command("update", "Update bucket state", (updateYargs) => { - updateYargs.command( - "storage-class ", - "Update the default storage class of an existing R2 bucket", - (yargs) => { - return yargs - .positional("name", { - describe: "The name of the existing bucket", - type: "string", - demandOption: true, - }) - .option("jurisdiction", { - describe: "The jurisdiction of the bucket to be updated", - alias: "J", - requiresArg: true, - type: "string", - }) - .option("storage-class", { - describe: "The new default storage class for this bucket", - alias: "s", - demandOption: true, - requiresArg: true, - type: "string", - }); - }, - async (args) => { - await printWranglerBanner(); - - const config = readConfig(args.config, args); - - const accountId = await requireAuth(config); - - let fullBucketName = `${args.name}`; - if (args.jurisdiction !== undefined) { - fullBucketName += ` (${args.jurisdiction})`; - } - logger.log( - `Updating bucket ${fullBucketName} to ${args.storageClass} default storage class.` - ); - await updateR2BucketStorageClass( - accountId, - args.name, - args.storageClass, - args.jurisdiction - ); - logger.log( - `Updated bucket ${fullBucketName} to ${args.storageClass} default storage class.` - ); - } - ); - }); - - r2BucketYargs.command( - "list", - "List R2 buckets", - List.ListOptions, - List.ListHandler - ); - - r2BucketYargs.command( - "info ", - "Get information about an R2 bucket", - Info.InfoOptions, - Info.InfoHandler - ); - - r2BucketYargs.command( - "delete ", - "Delete an R2 bucket", - (yargs) => { - return yargs - .positional("bucket", { - describe: "The name of the bucket to delete", - type: "string", - demandOption: true, - }) - .option("jurisdiction", { - describe: "The jurisdiction where the bucket exists", - alias: "J", - requiresArg: true, - type: "string", - }); - }, - async (args) => { - await printWranglerBanner(); - - const config = readConfig(args.config, args); - - const accountId = await requireAuth(config); - - let fullBucketName = `${args.bucket}`; - if (args.jurisdiction !== undefined) { - fullBucketName += ` (${args.jurisdiction})`; - } - logger.log(`Deleting bucket ${fullBucketName}.`); - await deleteR2Bucket(accountId, args.bucket, args.jurisdiction); - logger.log(`Deleted bucket ${fullBucketName}.`); - await metrics.sendMetricsEvent("delete r2 bucket", { - sendMetrics: config.send_metrics, - }); - } - ); - - r2BucketYargs.command( - "sippy", - "Manage Sippy incremental migration on an R2 bucket", - (sippyYargs) => { - return sippyYargs - .command( - "enable ", - "Enable Sippy on an R2 bucket", - Sippy.EnableOptions, - Sippy.EnableHandler - ) - .command( - "disable ", - "Disable Sippy on an R2 bucket", - Sippy.DisableOptions, - Sippy.DisableHandler - ) - .command( - "get ", - "Check the status of Sippy on an R2 bucket", - Sippy.GetOptions, - Sippy.GetHandler - ); - } - ); - - r2BucketYargs.command( - "notification", - "Manage event notification rules for an R2 bucket", - (r2EvNotifyYargs) => { - return r2EvNotifyYargs - .command( - ["list ", "get "], - "List event notification rules for an R2 bucket", - Notification.ListOptions, - Notification.ListHandler - ) - .command( - "create ", - "Create an event notification rule for an R2 bucket", - Notification.CreateOptions, - Notification.CreateHandler - ) - .command( - "delete ", - "Delete an event notification rule from an R2 bucket", - Notification.DeleteOptions, - Notification.DeleteHandler - ); - } - ); - - r2BucketYargs.command( - "domain", - "Manage custom domains for an R2 bucket", - (domainYargs) => { - return domainYargs - .command( - "list ", - "List custom domains for an R2 bucket", - Domain.ListOptions, - Domain.ListHandler - ) - .command( - "add ", - "Connect a custom domain to an R2 bucket", - Domain.AddOptions, - Domain.AddHandler - ) - .command( - "remove ", - "Remove a custom domain from an R2 bucket", - Domain.RemoveOptions, - Domain.RemoveHandler - ) - .command( - "update ", - "Update settings for a custom domain connected to an R2 bucket", - Domain.UpdateOptions, - Domain.UpdateHandler - ); - } - ); - r2BucketYargs.command( - "dev-url", - "Manage public access via the r2.dev URL for an R2 bucket", - (devUrlYargs) => { - return devUrlYargs - .command( - "enable ", - "Enable public access via the r2.dev URL for an R2 bucket", - PublicDevUrl.EnableOptions, - PublicDevUrl.EnableHandler - ) - .command( - "disable ", - "Disable public access via the r2.dev URL for an R2 bucket", - PublicDevUrl.DisableOptions, - PublicDevUrl.DisableHandler - ) - .command( - "get ", - "Get the r2.dev URL and status for an R2 bucket", - PublicDevUrl.GetOptions, - PublicDevUrl.GetHandler - ); - } - ); - r2BucketYargs.command( - "lifecycle", - "Manage lifecycle rules for an R2 bucket", - (lifecycleYargs) => { - return lifecycleYargs - .command( - "list ", - "List lifecycle rules for an R2 bucket", - Lifecycle.ListOptions, - Lifecycle.ListHandler - ) - .command( - "add ", - "Add a lifecycle rule to an R2 bucket", - Lifecycle.AddOptions, - Lifecycle.AddHandler - ) - .command( - "remove ", - "Remove a lifecycle rule from an R2 bucket", - Lifecycle.RemoveOptions, - Lifecycle.RemoveHandler - ) - .command( - "set ", - "Set the lifecycle configuration for an R2 bucket from a JSON file", - Lifecycle.SetOptions, - Lifecycle.SetHandler - ); - } - ); - return r2BucketYargs; - }); -} +import { defineNamespace } from "../core"; +import "./object"; +import "./bucket"; +import "./sippy"; +import "./notification"; +import "./domain"; +import "./public-dev-url"; +import "./lifecycle"; + +defineNamespace({ + command: "wrangler r2", + metadata: { + description: "📦 Manage R2 buckets & objects", + status: "stable", + owner: "Product: R2", + }, +}); diff --git a/packages/wrangler/src/r2/info.ts b/packages/wrangler/src/r2/info.ts deleted file mode 100644 index 543eda4f1868..000000000000 --- a/packages/wrangler/src/r2/info.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { readConfig } from "../config"; -import { logger } from "../logger"; -import { printWranglerBanner } from "../update-check"; -import { requireAuth } from "../user"; -import formatLabelledValues from "../utils/render-labelled-values"; -import { getR2Bucket, getR2BucketMetrics } from "./helpers"; -import type { - CommonYargsArgv, - StrictYargsOptionsToInterface, -} from "../yargs-types"; - -export function InfoOptions(yargs: CommonYargsArgv) { - return yargs - .positional("bucket", { - describe: "The name of the R2 bucket to get information about", - type: "string", - demandOption: true, - }) - .option("jurisdiction", { - describe: "The jurisdiction where the bucket exists", - alias: "J", - requiresArg: true, - type: "string", - }); -} - -export async function InfoHandler( - args: StrictYargsOptionsToInterface -) { - await printWranglerBanner(); - const config = readConfig(args.config, args); - const accountId = await requireAuth(config); - - const { bucket, jurisdiction } = args; - - logger.log(`Getting info for '${bucket}'...`); - const bucketInfo = await getR2Bucket(accountId, bucket, jurisdiction); - const metrics = await getR2BucketMetrics(accountId, bucket, jurisdiction); - - const output = { - name: bucketInfo.name, - created: bucketInfo.creation_date, - location: bucketInfo.location || "(unknown)", - default_storage_class: bucketInfo.storage_class || "(unknown)", - object_count: metrics.objectCount.toLocaleString(), - bucket_size: metrics.totalSize, - }; - - logger.log(formatLabelledValues(output)); -} diff --git a/packages/wrangler/src/r2/lifecycle.ts b/packages/wrangler/src/r2/lifecycle.ts index 0daba23fcf57..41e707dfc5a5 100644 --- a/packages/wrangler/src/r2/lifecycle.ts +++ b/packages/wrangler/src/r2/lifecycle.ts @@ -1,10 +1,9 @@ -import { readConfig, withConfig } from "../config"; +import { defineCommand, defineNamespace } from "../core"; import { confirm, multiselect, prompt } from "../dialogs"; import { UserError } from "../errors"; import isInteractive from "../is-interactive"; import { logger } from "../logger"; import { readFileSync } from "../parse"; -import { printWranglerBanner } from "../update-check"; import { requireAuth } from "../user"; import formatLabelledValues from "../utils/render-labelled-values"; import { @@ -15,129 +14,141 @@ import { putLifecycleRules, tableFromLifecycleRulesResponse, } from "./helpers"; -import type { - CommonYargsArgv, - StrictYargsOptionsToInterface, -} from "../yargs-types"; import type { LifecycleRule } from "./helpers"; -export function ListOptions(yargs: CommonYargsArgv) { - return yargs - .positional("bucket", { +defineNamespace({ + command: "wrangler r2 bucket lifecycle", + metadata: { + description: "Manage lifecycle rules for an R2 bucket", + status: "stable", + owner: "Product: R2", + }, +}); + +defineCommand({ + command: "wrangler r2 bucket lifecycle list", + metadata: { + description: "List lifecycle rules for an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket"], + args: { + bucket: { describe: "The name of the R2 bucket to list lifecycle rules for", type: "string", demandOption: true, - }) - .option("jurisdiction", { + }, + jurisdiction: { describe: "The jurisdiction where the bucket exists", alias: "J", requiresArg: true, type: "string", - }); -} - -export async function ListHandler( - args: StrictYargsOptionsToInterface -) { - await printWranglerBanner(); - const config = readConfig(args.config, args); - const accountId = await requireAuth(config); - - const { bucket, jurisdiction } = args; - - logger.log(`Listing lifecycle rules for bucket '${bucket}'...`); - - const lifecycleRules = await getLifecycleRules( - accountId, - bucket, - jurisdiction - ); - - if (lifecycleRules.length === 0) { - logger.log(`There are no lifecycle rules for bucket '${bucket}'.`); - } else { - const tableOutput = tableFromLifecycleRulesResponse(lifecycleRules); - logger.log(tableOutput.map((x) => formatLabelledValues(x)).join("\n\n")); - } -} - -export function AddOptions(yargs: CommonYargsArgv) { - return yargs - .positional("bucket", { + }, + }, + async handler(args, { config }) { + const accountId = await requireAuth(config); + + const { bucket, jurisdiction } = args; + + logger.log(`Listing lifecycle rules for bucket '${bucket}'...`); + + const lifecycleRules = await getLifecycleRules( + accountId, + bucket, + jurisdiction + ); + + if (lifecycleRules.length === 0) { + logger.log(`There are no lifecycle rules for bucket '${bucket}'.`); + } else { + const tableOutput = tableFromLifecycleRulesResponse(lifecycleRules); + logger.log(tableOutput.map((x) => formatLabelledValues(x)).join("\n\n")); + } + }, +}); + +defineCommand({ + command: "wrangler r2 bucket lifecycle add", + metadata: { + description: "Add a lifecycle rule to an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket", "id", "prefix"], + args: { + bucket: { describe: "The name of the R2 bucket to add a lifecycle rule to", type: "string", demandOption: true, - }) - .positional("id", { + }, + id: { describe: "A unique identifier for the lifecycle rule", type: "string", requiresArg: true, - }) - .positional("prefix", { + }, + prefix: { describe: "Prefix condition for the lifecycle rule (leave empty for all prefixes)", type: "string", requiresArg: true, - }) - .option("expire-days", { + }, + "expire-days": { describe: "Number of days after which objects expire", type: "number", requiresArg: true, - }) - .option("expire-date", { + }, + "expire-date": { describe: "Date after which objects expire (YYYY-MM-DD)", type: "number", requiresArg: true, - }) - .option("ia-transition-days", { + }, + "ia-transition-days": { describe: "Number of days after which objects transition to Infrequent Access storage", type: "number", requiresArg: true, - }) - .option("ia-transition-date", { + }, + "ia-transition-date": { describe: "Date after which objects transition to Infrequent Access storage (YYYY-MM-DD)", type: "string", requiresArg: true, - }) - .option("abort-multipart-days", { + }, + "abort-multipart-days": { describe: "Number of days after which incomplete multipart uploads are aborted", type: "number", requiresArg: true, - }) - .option("jurisdiction", { + }, + jurisdiction: { describe: "The jurisdiction where the bucket exists", alias: "J", requiresArg: true, type: "string", - }) - .option("force", { + }, + force: { describe: "Skip confirmation", type: "boolean", alias: "y", default: false, - }); -} - -export const AddHandler = withConfig< - StrictYargsOptionsToInterface ->( - async ({ - bucket, - expireDays, - expireDate, - iaTransitionDays, - iaTransitionDate, - abortMultipartDays, - jurisdiction, - force, - id, - prefix, - config, - }): Promise => { - await printWranglerBanner(); + }, + }, + async handler( + { + bucket, + expireDays, + expireDate, + iaTransitionDays, + iaTransitionDate, + abortMultipartDays, + jurisdiction, + force, + id, + prefix, + }, + { config } + ) { const accountId = await requireAuth(config); const lifecycleRules = await getLifecycleRules( @@ -302,131 +313,138 @@ export const AddHandler = withConfig< logger.log(`Adding lifecycle rule '${id}' to bucket '${bucket}'...`); await putLifecycleRules(accountId, bucket, lifecycleRules, jurisdiction); logger.log(`✨ Added lifecycle rule '${id}' to bucket '${bucket}'.`); - } -); - -export function RemoveOptions(yargs: CommonYargsArgv) { - return yargs - .positional("bucket", { + }, +}); + +defineCommand({ + command: "wrangler r2 bucket lifecycle remove", + metadata: { + description: "Remove a lifecycle rule from an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket"], + args: { + bucket: { describe: "The name of the R2 bucket to remove a lifecycle rule from", type: "string", demandOption: true, - }) - .option("id", { + }, + id: { describe: "The unique identifier of the lifecycle rule to remove", type: "string", demandOption: true, requiresArg: true, - }) - .option("jurisdiction", { + }, + jurisdiction: { describe: "The jurisdiction where the bucket exists", alias: "J", requiresArg: true, type: "string", - }); -} - -export async function RemoveHandler( - args: StrictYargsOptionsToInterface -) { - await printWranglerBanner(); - const config = readConfig(args.config, args); - const accountId = await requireAuth(config); - - const { bucket, id, jurisdiction } = args; - - const lifecycleRules = await getLifecycleRules( - accountId, - bucket, - jurisdiction - ); + }, + }, + async handler(args, { config }) { + const accountId = await requireAuth(config); - const index = lifecycleRules.findIndex((rule) => rule.id === id); + const { bucket, id, jurisdiction } = args; - if (index === -1) { - throw new UserError( - `Lifecycle rule with ID '${id}' not found in configuration for '${bucket}'.` + const lifecycleRules = await getLifecycleRules( + accountId, + bucket, + jurisdiction ); - } - lifecycleRules.splice(index, 1); + const index = lifecycleRules.findIndex((rule) => rule.id === id); - logger.log(`Removing lifecycle rule '${id}' from bucket '${bucket}'...`); - await putLifecycleRules(accountId, bucket, lifecycleRules, jurisdiction); - logger.log(`Lifecycle rule '${id}' removed from bucket '${bucket}'.`); -} + if (index === -1) { + throw new UserError( + `Lifecycle rule with ID '${id}' not found in configuration for '${bucket}'.` + ); + } -export function SetOptions(yargs: CommonYargsArgv) { - return yargs - .positional("bucket", { + lifecycleRules.splice(index, 1); + + logger.log(`Removing lifecycle rule '${id}' from bucket '${bucket}'...`); + await putLifecycleRules(accountId, bucket, lifecycleRules, jurisdiction); + logger.log(`Lifecycle rule '${id}' removed from bucket '${bucket}'.`); + }, +}); + +defineCommand({ + command: "wrangler r2 bucket lifecycle set", + metadata: { + description: + "Set the lifecycle configuration for an R2 bucket from a JSON file", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket"], + args: { + bucket: { describe: "The name of the R2 bucket to set lifecycle configuration for", type: "string", demandOption: true, - }) - .option("file", { + }, + file: { describe: "Path to the JSON file containing lifecycle configuration", type: "string", demandOption: true, requiresArg: true, - }) - .option("jurisdiction", { + }, + jurisdiction: { describe: "The jurisdiction where the bucket exists", alias: "J", requiresArg: true, type: "string", - }) - .option("force", { + }, + force: { describe: "Skip confirmation", type: "boolean", alias: "y", default: false, - }); -} - -export async function SetHandler( - args: StrictYargsOptionsToInterface -) { - await printWranglerBanner(); - const config = readConfig(args.config, args); - const accountId = await requireAuth(config); - - const { bucket, file, jurisdiction, force } = args; - let lifecyclePolicy: { rules: LifecycleRule[] }; - try { - lifecyclePolicy = JSON.parse(readFileSync(file)); - } catch (e) { - if (e instanceof Error) { + }, + }, + async handler(args, { config }) { + const accountId = await requireAuth(config); + + const { bucket, file, jurisdiction, force } = args; + let lifecyclePolicy: { rules: LifecycleRule[] }; + try { + lifecyclePolicy = JSON.parse(readFileSync(file)); + } catch (e) { + if (e instanceof Error) { + throw new UserError( + `Failed to read or parse the lifecycle configuration config file: '${e.message}'` + ); + } else { + throw e; + } + } + + if (!lifecyclePolicy.rules || !Array.isArray(lifecyclePolicy.rules)) { throw new UserError( - `Failed to read or parse the lifecycle configuration config file: '${e.message}'` + "The lifecycle configuration file must contain a 'rules' array." ); - } else { - throw e; } - } - if (!lifecyclePolicy.rules || !Array.isArray(lifecyclePolicy.rules)) { - throw new UserError( - "The lifecycle configuration file must contain a 'rules' array." + if (!force) { + const confirmedRemoval = await confirm( + `Are you sure you want to overwrite all existing lifecycle rules for bucket '${bucket}'?` + ); + if (!confirmedRemoval) { + logger.log("Set cancelled."); + return; + } + } + logger.log( + `Setting lifecycle configuration (${lifecyclePolicy.rules.length} rules) for bucket '${bucket}'...` ); - } - - if (!force) { - const confirmedRemoval = await confirm( - `Are you sure you want to overwrite all existing lifecycle rules for bucket '${bucket}'?` + await putLifecycleRules( + accountId, + bucket, + lifecyclePolicy.rules, + jurisdiction ); - if (!confirmedRemoval) { - logger.log("Set cancelled."); - return; - } - } - logger.log( - `Setting lifecycle configuration (${lifecyclePolicy.rules.length} rules) for bucket '${bucket}'...` - ); - await putLifecycleRules( - accountId, - bucket, - lifecyclePolicy.rules, - jurisdiction - ); - logger.log(`✨ Set lifecycle configuration for bucket '${bucket}'.`); -} + logger.log(`✨ Set lifecycle configuration for bucket '${bucket}'.`); + }, +}); diff --git a/packages/wrangler/src/r2/list.ts b/packages/wrangler/src/r2/list.ts deleted file mode 100644 index 08bdd05b7316..000000000000 --- a/packages/wrangler/src/r2/list.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { readConfig } from "../config"; -import { logger } from "../logger"; -import { printWranglerBanner } from "../update-check"; -import { requireAuth } from "../user"; -import formatLabelledValues from "../utils/render-labelled-values"; -import { listR2Buckets, tablefromR2BucketsListResponse } from "./helpers"; -import type { - CommonYargsArgv, - StrictYargsOptionsToInterface, -} from "../yargs-types"; - -export function ListOptions(yargs: CommonYargsArgv) { - return yargs.option("jurisdiction", { - describe: "The jurisdiction to list", - alias: "J", - requiresArg: true, - type: "string", - }); -} - -export async function ListHandler( - args: StrictYargsOptionsToInterface -) { - await printWranglerBanner(); - const config = readConfig(args.config, args); - const accountId = await requireAuth(config); - - const { jurisdiction } = args; - - logger.log(`Listing buckets...`); - - const buckets = await listR2Buckets(accountId, jurisdiction); - const tableOutput = tablefromR2BucketsListResponse(buckets); - logger.log(tableOutput.map((x) => formatLabelledValues(x)).join("\n\n")); -} diff --git a/packages/wrangler/src/r2/notification.ts b/packages/wrangler/src/r2/notification.ts index ec66d500953c..87eefc239065 100644 --- a/packages/wrangler/src/r2/notification.ts +++ b/packages/wrangler/src/r2/notification.ts @@ -1,6 +1,5 @@ -import { readConfig } from "../config"; +import { defineAlias, defineCommand, defineNamespace } from "../core"; import { logger } from "../logger"; -import { printWranglerBanner } from "../update-check"; import { requireApiToken, requireAuth } from "../user"; import formatLabelledValues from "../utils/render-labelled-values"; import { @@ -10,173 +9,192 @@ import { putEventNotificationConfig, tableFromNotificationGetResponse, } from "./helpers"; -import type { - CommonYargsArgv, - StrictYargsOptionsToInterface, -} from "../yargs-types"; import type { R2EventType } from "./helpers"; -export function ListOptions(yargs: CommonYargsArgv) { - return yargs - .positional("bucket", { +defineNamespace({ + command: "wrangler r2 bucket notification", + metadata: { + description: "Manage event notification rules for an R2 bucket", + status: "stable", + owner: "Product: R2", + }, +}); + +defineAlias({ + command: "wrangler r2 bucket notification get", + aliasOf: "wrangler r2 bucket notification list", +}); + +defineCommand({ + command: "wrangler r2 bucket notification list", + metadata: { + description: "List event notification rules for an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket"], + args: { + bucket: { describe: "The name of the R2 bucket to get event notification rules for", type: "string", demandOption: true, - }) - .option("jurisdiction", { + }, + jurisdiction: { describe: "The jurisdiction where the bucket exists", alias: "J", requiresArg: true, type: "string", - }); -} - -export async function ListHandler( - args: StrictYargsOptionsToInterface -) { - await printWranglerBanner(); - // Check for deprecated `wrangler pages publish` command - if (args._[3] === "get") { - logger.warn( - "`wrangler r2 bucket notification get` is deprecated and will be removed in an upcoming release.\nPlease use `wrangler r2 bucket notification list` instead." + }, + }, + async handler(args, { config }) { + // Check for deprecated `wrangler pages publish` command + if (args._[3] === "get") { + logger.warn( + "`wrangler r2 bucket notification get` is deprecated and will be removed in an upcoming release.\nPlease use `wrangler r2 bucket notification list` instead." + ); + } + const accountId = await requireAuth(config); + const apiCreds = requireApiToken(); + const { bucket, jurisdiction = "" } = args; + const resp = await listEventNotificationConfig( + apiCreds, + accountId, + bucket, + jurisdiction ); - } - const config = readConfig(args.config, args); - const accountId = await requireAuth(config); - const apiCreds = requireApiToken(); - const { bucket, jurisdiction = "" } = args; - const resp = await listEventNotificationConfig( - apiCreds, - accountId, - bucket, - jurisdiction - ); - const tableOutput = tableFromNotificationGetResponse(resp); - logger.log(tableOutput.map((x) => formatLabelledValues(x)).join("\n\n")); -} + const tableOutput = tableFromNotificationGetResponse(resp); + logger.log(tableOutput.map((x) => formatLabelledValues(x)).join("\n\n")); + }, +}); -export function CreateOptions(yargs: CommonYargsArgv) { - return yargs - .positional("bucket", { +defineCommand({ + command: "wrangler r2 bucket notification create", + metadata: { + description: "Create an event notification rule for an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket"], + args: { + bucket: { describe: "The name of the R2 bucket to create an event notification rule for", type: "string", demandOption: true, - }) - .option("event-types", { + }, + "event-types": { describe: "The type of event(s) that will emit event notifications", alias: "event-type", choices: Object.keys(actionsForEventCategories), demandOption: true, requiresArg: true, - type: "array", - }) - .option("prefix", { + array: true, + }, + prefix: { describe: "The prefix that an object must match to emit event notifications (note: regular expressions not supported)", requiresArg: false, type: "string", - }) - .option("suffix", { + }, + suffix: { describe: "The suffix that an object must match to emit event notifications (note: regular expressions not supported)", type: "string", - }) - .option("queue", { + }, + queue: { describe: "The name of the queue that will receive event notification messages", demandOption: true, requiresArg: true, type: "string", - }) - .option("jurisdiction", { + }, + jurisdiction: { describe: "The jurisdiction where the bucket exists", alias: "J", requiresArg: true, type: "string", - }) - .option("description", { + }, + description: { describe: "A description that can be used to identify the event notification rule after creation", type: "string", - }); -} - -export async function CreateHandler( - args: StrictYargsOptionsToInterface -) { - await printWranglerBanner(); - const config = readConfig(args.config, args); - const accountId = await requireAuth(config); - const apiCreds = requireApiToken(); - const { - bucket, - queue, - eventTypes, - prefix = "", - suffix = "", - jurisdiction = "", - description, - } = args; - await putEventNotificationConfig( - config, - apiCreds, - accountId, - bucket, - jurisdiction, - queue, - eventTypes as R2EventType[], - prefix, - suffix, - description - ); - logger.log("Event notification rule created successfully!"); -} + }, + }, + async handler(args, { config }) { + const accountId = await requireAuth(config); + const apiCreds = requireApiToken(); + const { + bucket, + queue, + eventTypes, + prefix = "", + suffix = "", + jurisdiction = "", + description, + } = args; + await putEventNotificationConfig( + config, + apiCreds, + accountId, + bucket, + jurisdiction, + queue, + eventTypes as R2EventType[], + prefix, + suffix, + description + ); + logger.log("Event notification rule created successfully!"); + }, +}); -export function DeleteOptions(yargs: CommonYargsArgv) { - return yargs - .positional("bucket", { +defineCommand({ + command: "wrangler r2 bucket notification delete", + metadata: { + description: "Delete an event notification rule from an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket"], + args: { + bucket: { describe: "The name of the R2 bucket to delete an event notification rule for", type: "string", demandOption: true, - }) - .option("queue", { + }, + queue: { describe: "The name of the queue that corresponds to the event notification rule. If no rule is provided, all event notification rules associated with the bucket and queue will be deleted", demandOption: true, requiresArg: true, type: "string", - }) - .option("rule", { + }, + rule: { describe: "The ID of the event notification rule to delete", requiresArg: false, type: "string", - }) - .option("jurisdiction", { + }, + jurisdiction: { describe: "The jurisdiction where the bucket exists", alias: "J", requiresArg: true, type: "string", - }); -} - -export async function DeleteHandler( - args: StrictYargsOptionsToInterface -) { - await printWranglerBanner(); - const config = readConfig(args.config, args); - const accountId = await requireAuth(config); - const apiCreds = requireApiToken(); - const { bucket, queue, rule, jurisdiction = "" } = args; - await deleteEventNotificationConfig( - config, - apiCreds, - accountId, - bucket, - jurisdiction, - queue, - rule - ); - logger.log("Event notification rule deleted successfully!"); -} + }, + }, + async handler(args, { config }) { + const accountId = await requireAuth(config); + const apiCreds = requireApiToken(); + const { bucket, queue, rule, jurisdiction = "" } = args; + await deleteEventNotificationConfig( + config, + apiCreds, + accountId, + bucket, + jurisdiction, + queue, + rule + ); + logger.log("Event notification rule deleted successfully!"); + }, +}); diff --git a/packages/wrangler/src/r2/object.ts b/packages/wrangler/src/r2/object.ts new file mode 100644 index 000000000000..35a85e651b7d --- /dev/null +++ b/packages/wrangler/src/r2/object.ts @@ -0,0 +1,396 @@ +import { Blob } from "node:buffer"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as stream from "node:stream"; +import prettyBytes from "pretty-bytes"; +import { readConfig } from "../config"; +import { defineCommand, defineNamespace } from "../core"; +import { CommandLineArgsError, FatalError, UserError } from "../errors"; +import { logger } from "../logger"; +import { requireAuth } from "../user"; +import { MAX_UPLOAD_SIZE } from "./constants"; +import { + bucketAndKeyFromObjectPath, + createFileReadableStream, + deleteR2Object, + getR2Object, + putR2Object, + usingLocalBucket, +} from "./helpers"; +import type { R2PutOptions } from "@cloudflare/workers-types/experimental"; + +defineNamespace({ + command: "wrangler r2 object", + metadata: { + description: `Manage R2 objects`, + status: "stable", + owner: "Product: R2", + }, +}); + +defineCommand({ + command: "wrangler r2 object get", + metadata: { + description: "Fetch an object from an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + args: { + objectPath: { + describe: "The source object path in the form of {bucket}/{key}", + type: "string", + demandOption: true, + }, + file: { + describe: "The destination file to create", + alias: "f", + conflicts: "pipe", + requiresArg: true, + type: "string", + }, + pipe: { + describe: + "Enables the file to be piped to a destination, rather than specified with the --file option", + alias: "p", + conflicts: "file", + type: "boolean", + }, + local: { + type: "boolean", + describe: "Interact with local storage", + }, + "persist-to": { + type: "string", + describe: "Directory for local persistence", + }, + jurisdiction: { + describe: "The jurisdiction where the object exists", + alias: "J", + requiresArg: true, + type: "string", + }, + }, + positionalArgs: ["objectPath"], + async handler(objectGetYargs, { config }) { + const { objectPath, pipe, jurisdiction } = objectGetYargs; + const { bucket, key } = bucketAndKeyFromObjectPath(objectPath); + let fullBucketName = bucket; + if (jurisdiction !== undefined) { + fullBucketName += ` (${jurisdiction})`; + } + + let file = objectGetYargs.file; + if (!file && !pipe) { + file = key; + } + if (!pipe) { + logger.log(`Downloading "${key}" from "${fullBucketName}".`); + } + + let output: stream.Writable; + if (file) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + output = fs.createWriteStream(file); + } else { + output = process.stdout; + } + if (objectGetYargs.local) { + await usingLocalBucket( + objectGetYargs.persistTo, + config.configPath, + bucket, + async (r2Bucket) => { + const object = await r2Bucket.get(key); + if (object === null) { + throw new UserError("The specified key does not exist."); + } + // Note `object.body` is only valid inside this closure + await stream.promises.pipeline(object.body, output); + } + ); + } else { + const accountId = await requireAuth(config); + const input = await getR2Object(accountId, bucket, key, jurisdiction); + if (input === null) { + throw new UserError("The specified key does not exist."); + } + await stream.promises.pipeline(input, output); + } + if (!pipe) { + logger.log("Download complete."); + } + }, +}); + +defineCommand({ + command: "wrangler r2 object put", + metadata: { + description: "Create an object in an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["objectPath"], + args: { + objectPath: { + describe: "The destination object path in the form of {bucket}/{key}", + type: "string", + demandOption: true, + }, + file: { + describe: "The path of the file to upload", + alias: "f", + conflicts: "pipe", + requiresArg: true, + type: "string", + }, + pipe: { + describe: + "Enables the file to be piped in, rather than specified with the --file option", + alias: "p", + conflicts: "file", + type: "boolean", + }, + "content-type": { + describe: "A standard MIME type describing the format of the object data", + alias: "ct", + requiresArg: true, + type: "string", + }, + "content-disposition": { + describe: "Specifies presentational information for the object", + alias: "cd", + requiresArg: true, + type: "string", + }, + "content-encoding": { + describe: + "Specifies what content encodings have been applied to the object and thus what decoding mechanisms must be applied to obtain the media-type referenced by the Content-Type header field", + alias: "ce", + requiresArg: true, + type: "string", + }, + "content-language": { + describe: "The language the content is in", + alias: "cl", + requiresArg: true, + type: "string", + }, + "cache-control": { + describe: "Specifies caching behavior along the request/reply chain", + alias: "cc", + requiresArg: true, + type: "string", + }, + expires: { + describe: "The date and time at which the object is no longer cacheable", + alias: "e", + requiresArg: true, + type: "string", + }, + local: { + type: "boolean", + describe: "Interact with local storage", + }, + "persist-to": { + type: "string", + describe: "Directory for local persistence", + }, + jurisdiction: { + describe: "The jurisdiction where the object will be created", + alias: "J", + requiresArg: true, + type: "string", + }, + "storage-class": { + describe: "The storage class of the object to be created", + alias: "s", + requiresArg: false, + type: "string", + }, + }, + async handler(objectPutYargs, { config }) { + const { + objectPath, + file, + pipe, + local, + persistTo, + jurisdiction, + storageClass, + ...options + } = objectPutYargs; + const { bucket, key } = bucketAndKeyFromObjectPath(objectPath); + if (!file && !pipe) { + throw new CommandLineArgsError( + "Either the --file or --pipe options are required." + ); + } + let object: ReadableStream; + let objectSize: number; + if (file) { + object = await createFileReadableStream(file); + const stats = fs.statSync(file); + objectSize = stats.size; + } else { + const buffer = await new Promise((resolve, reject) => { + const stdin = process.stdin; + const chunks = Array(); + stdin.on("data", (chunk) => chunks.push(chunk)); + stdin.on("end", () => resolve(Buffer.concat(chunks))); + stdin.on("error", (err) => + reject( + new CommandLineArgsError(`Could not pipe. Reason: "${err.message}"`) + ) + ); + }); + const blob = new Blob([buffer]); + object = blob.stream(); + objectSize = blob.size; + } + + if (objectSize > MAX_UPLOAD_SIZE && !local) { + throw new FatalError( + `Error: Wrangler only supports uploading files up to ${prettyBytes( + MAX_UPLOAD_SIZE, + { binary: true } + )} in size\n${key} is ${prettyBytes(objectSize, { + binary: true, + })} in size`, + 1 + ); + } + + let fullBucketName = bucket; + if (jurisdiction !== undefined) { + fullBucketName += ` (${jurisdiction})`; + } + + let storageClassLog = ``; + if (storageClass !== undefined) { + storageClassLog = ` with ${storageClass} storage class`; + } + + logger.log( + `Creating object "${key}"${storageClassLog} in bucket "${fullBucketName}".` + ); + + if (local) { + await usingLocalBucket( + persistTo, + config.configPath, + bucket, + async (r2Bucket, mf) => { + const putOptions: R2PutOptions = { + httpMetadata: { + contentType: options.contentType, + contentDisposition: options.contentDisposition, + contentEncoding: options.contentEncoding, + contentLanguage: options.contentLanguage, + cacheControl: options.cacheControl, + // @ts-expect-error `@cloudflare/workers-types` is wrong + // here, `number`'s are allowed for `Date`s + // TODO(now): fix + cacheExpiry: + options.expires === undefined + ? undefined + : parseInt(options.expires), + }, + customMetadata: undefined, + sha1: undefined, + sha256: undefined, + onlyIf: undefined, + md5: undefined, + sha384: undefined, + sha512: undefined, + }; + // We can't use `r2Bucket.put()` here as `R2Bucket#put()` + // requires a known length stream, and Miniflare's magic proxy + // currently doesn't support sending these. Instead, + // `usingLocalBucket()` provides a single `PUT` endpoint + // for writing to a local bucket. + await mf.dispatchFetch(`http://localhost/${key}`, { + method: "PUT", + body: object, + duplex: "half", + headers: { + "Content-Length": objectSize.toString(), + "Wrangler-R2-Put-Options": JSON.stringify(putOptions), + }, + }); + } + ); + } else { + const accountId = await requireAuth(config); + await putR2Object( + accountId, + bucket, + key, + object, + { + ...options, + "content-length": `${objectSize}`, + }, + jurisdiction, + storageClass + ); + } + + logger.log("Upload complete."); + }, +}); + +defineCommand({ + command: "wrangler r2 object delete", + metadata: { + description: "Delete an object in an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["objectPath"], + args: { + objectPath: { + describe: "The destination object path in the form of {bucket}/{key}", + type: "string", + demandOption: true, + }, + local: { + type: "boolean", + describe: "Interact with local storage", + }, + "persist-to": { + type: "string", + describe: "Directory for local persistence", + }, + jurisdiction: { + describe: "The jurisdiction where the object exists", + alias: "J", + requiresArg: true, + type: "string", + }, + }, + async handler(args) { + const { objectPath, jurisdiction } = args; + const config = readConfig(args.config, args); + const { bucket, key } = bucketAndKeyFromObjectPath(objectPath); + let fullBucketName = bucket; + if (jurisdiction !== undefined) { + fullBucketName += ` (${jurisdiction})`; + } + + logger.log(`Deleting object "${key}" from bucket "${fullBucketName}".`); + + if (args.local) { + await usingLocalBucket( + args.persistTo, + config.configPath, + bucket, + (r2Bucket) => r2Bucket.delete(key) + ); + } else { + const accountId = await requireAuth(config); + await deleteR2Object(accountId, bucket, key, jurisdiction); + } + + logger.log("Delete complete."); + }, +}); diff --git a/packages/wrangler/src/r2/public-dev-url.ts b/packages/wrangler/src/r2/public-dev-url.ts index d295ec6439a9..b47a708fbd38 100644 --- a/packages/wrangler/src/r2/public-dev-url.ts +++ b/packages/wrangler/src/r2/public-dev-url.ts @@ -1,151 +1,164 @@ -import { readConfig } from "../config"; +import { defineCommand, defineNamespace } from "../core"; import { confirm } from "../dialogs"; import { logger } from "../logger"; -import { printWranglerBanner } from "../update-check"; import { requireAuth } from "../user"; import { getR2DevDomain, updateR2DevDomain } from "./helpers"; -import type { - CommonYargsArgv, - StrictYargsOptionsToInterface, -} from "../yargs-types"; - -export function GetOptions(yargs: CommonYargsArgv) { - return yargs - .positional("bucket", { + +defineNamespace({ + command: "wrangler r2 bucket dev-url", + metadata: { + description: "Manage public access via the r2.dev URL for an R2 bucket", + status: "stable", + owner: "Product: R2", + }, +}); + +defineCommand({ + command: "wrangler r2 bucket dev-url get", + metadata: { + description: "Get the r2.dev URL and status for an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket"], + args: { + bucket: { describe: "The name of the R2 bucket whose r2.dev URL status to retrieve", type: "string", demandOption: true, - }) - .option("jurisdiction", { + }, + jurisdiction: { describe: "The jurisdiction where the bucket exists", alias: "J", requiresArg: true, type: "string", - }); -} - -export async function GetHandler( - args: StrictYargsOptionsToInterface -) { - await printWranglerBanner(); - const config = readConfig(args.config, args); - const accountId = await requireAuth(config); - - const { bucket, jurisdiction } = args; - - const devDomain = await getR2DevDomain(accountId, bucket, jurisdiction); - - if (devDomain.enabled) { - logger.log(`Public access is enabled at 'https://${devDomain.domain}'.`); - } else { - logger.log(`Public access via the r2.dev URL is disabled.`); - } -} - -export function EnableOptions(yargs: CommonYargsArgv) { - return yargs - .positional("bucket", { + }, + }, + async handler(args, { config }) { + const accountId = await requireAuth(config); + + const { bucket, jurisdiction } = args; + + const devDomain = await getR2DevDomain(accountId, bucket, jurisdiction); + + if (devDomain.enabled) { + logger.log(`Public access is enabled at 'https://${devDomain.domain}'.`); + } else { + logger.log(`Public access via the r2.dev URL is disabled.`); + } + }, +}); + +defineCommand({ + command: "wrangler r2 bucket dev-url enable", + metadata: { + description: "Enable public access via the r2.dev URL for an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket"], + args: { + bucket: { describe: "The name of the R2 bucket to enable public access via its r2.dev URL", type: "string", demandOption: true, - }) - .option("jurisdiction", { + }, + jurisdiction: { describe: "The jurisdiction where the bucket exists", alias: "J", requiresArg: true, type: "string", - }) - .option("force", { + }, + force: { describe: "Skip confirmation", type: "boolean", alias: "y", default: false, - }); -} - -export async function EnableHandler( - args: StrictYargsOptionsToInterface -) { - await printWranglerBanner(); - const config = readConfig(args.config, args); - const accountId = await requireAuth(config); - - const { bucket, jurisdiction, force } = args; - - if (!force) { - const confirmedAdd = await confirm( - `Are you sure you enable public access for bucket '${bucket}'? ` + - `The contents of your bucket will be made publicly available at its r2.dev URL` - ); - if (!confirmedAdd) { - logger.log("Enable cancelled."); - return; + }, + }, + async handler(args, { config }) { + const accountId = await requireAuth(config); + + const { bucket, jurisdiction, force } = args; + + if (!force) { + const confirmedAdd = await confirm( + `Are you sure you enable public access for bucket '${bucket}'? ` + + `The contents of your bucket will be made publicly available at its r2.dev URL` + ); + if (!confirmedAdd) { + logger.log("Enable cancelled."); + return; + } } - } - - logger.log(`Enabling public access for bucket '${bucket}'...`); - const devDomain = await updateR2DevDomain( - accountId, - bucket, - true, - jurisdiction - ); + logger.log(`Enabling public access for bucket '${bucket}'...`); - logger.log(`✨ Public access enabled at 'https://${devDomain.domain}'.`); -} + const devDomain = await updateR2DevDomain( + accountId, + bucket, + true, + jurisdiction + ); -export function DisableOptions(yargs: CommonYargsArgv) { - return yargs - .positional("bucket", { + logger.log(`✨ Public access enabled at 'https://${devDomain.domain}'.`); + }, +}); + +defineCommand({ + command: "wrangler r2 bucket dev-url disable", + metadata: { + description: "Disable public access via the r2.dev URL for an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket"], + args: { + bucket: { describe: "The name of the R2 bucket to disable public access via its r2.dev URL", type: "string", demandOption: true, - }) - .option("jurisdiction", { + }, + jurisdiction: { describe: "The jurisdiction where the bucket exists", alias: "J", requiresArg: true, type: "string", - }) - .option("force", { + }, + force: { describe: "Skip confirmation", type: "boolean", alias: "y", default: false, - }); -} - -export async function DisableHandler( - args: StrictYargsOptionsToInterface -) { - await printWranglerBanner(); - const config = readConfig(args.config, args); - const accountId = await requireAuth(config); - - const { bucket, jurisdiction, force } = args; - - if (!force) { - const confirmedAdd = await confirm( - `Are you sure you disable public access for bucket '${bucket}'? ` + - `The contents of your bucket will no longer be publicly available at its r2.dev URL` - ); - if (!confirmedAdd) { - logger.log("Disable cancelled."); - return; + }, + }, + async handler(args, { config }) { + const accountId = await requireAuth(config); + + const { bucket, jurisdiction, force } = args; + + if (!force) { + const confirmedAdd = await confirm( + `Are you sure you disable public access for bucket '${bucket}'? ` + + `The contents of your bucket will no longer be publicly available at its r2.dev URL` + ); + if (!confirmedAdd) { + logger.log("Disable cancelled."); + return; + } } - } - logger.log(`Disabling public access for bucket '${bucket}'...`); + logger.log(`Disabling public access for bucket '${bucket}'...`); - const devDomain = await updateR2DevDomain( - accountId, - bucket, - false, - jurisdiction - ); + const devDomain = await updateR2DevDomain( + accountId, + bucket, + false, + jurisdiction + ); - logger.log(`Public access disabled at 'https://${devDomain.domain}'.`); -} + logger.log(`Public access disabled at 'https://${devDomain.domain}'.`); + }, +}); diff --git a/packages/wrangler/src/r2/sippy.ts b/packages/wrangler/src/r2/sippy.ts index 416d8d950017..8bd44925425f 100644 --- a/packages/wrangler/src/r2/sippy.ts +++ b/packages/wrangler/src/r2/sippy.ts @@ -1,301 +1,329 @@ -import { readConfig } from "../config"; +import { defineCommand, defineNamespace } from "../core"; import { prompt } from "../dialogs"; import { UserError } from "../errors"; import { logger } from "../logger"; import { APIError, readFileSync } from "../parse"; import { requireAuth } from "../user"; import { deleteR2Sippy, getR2Sippy, putR2Sippy } from "./helpers"; -import type { - CommonYargsArgv, - StrictYargsOptionsToInterface, -} from "../yargs-types"; import type { SippyPutParams } from "./helpers"; const NO_SUCH_OBJECT_KEY = 10007; const SIPPY_PROVIDER_CHOICES = ["AWS", "GCS"]; -export function EnableOptions(yargs: CommonYargsArgv) { - return yargs - .positional("name", { +defineNamespace({ + command: "wrangler r2 bucket sippy", + metadata: { + description: "Manage Sippy incremental migration on an R2 bucket", + status: "stable", + owner: "Product: R2", + }, +}); + +defineCommand({ + command: "wrangler r2 bucket sippy enable", + metadata: { + description: "Enable Sippy on an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["name"], + args: { + name: { describe: "The name of the bucket", type: "string", demandOption: true, - }) - .option("jurisdiction", { + }, + jurisdiction: { describe: "The jurisdiction where the bucket exists", alias: "J", requiresArg: true, type: "string", - }) - .option("provider", { + }, + provider: { choices: SIPPY_PROVIDER_CHOICES, - }) - .option("bucket", { + }, + bucket: { description: "The name of the upstream bucket", - string: true, - }) - .option("region", { + type: "string", + }, + region: { description: "(AWS provider only) The region of the upstream bucket", - string: true, - }) - .option("access-key-id", { + type: "string", + }, + "access-key-id": { description: "(AWS provider only) The secret access key id for the upstream bucket", - string: true, - }) - .option("secret-access-key", { + type: "string", + }, + "secret-access-key": { description: "(AWS provider only) The secret access key for the upstream bucket", - string: true, - }) - .option("service-account-key-file", { + type: "string", + }, + "service-account-key-file": { description: "(GCS provider only) The path to your Google Cloud service account key JSON file", - string: true, - }) - .option("client-email", { + type: "string", + }, + "client-email": { description: "(GCS provider only) The client email for your Google Cloud service account key", - string: true, - }) - .option("private-key", { + type: "string", + }, + "private-key": { description: "(GCS provider only) The private key for your Google Cloud service account key", - string: true, - }) - .option("r2-access-key-id", { + type: "string", + }, + "r2-access-key-id": { description: "The secret access key id for this R2 bucket", - string: true, - }) - .option("r2-secret-access-key", { + type: "string", + }, + "r2-secret-access-key": { description: "The secret access key for this R2 bucket", - string: true, - }); -} + type: "string", + }, + }, + async handler(args, { config }) { + const isInteractive = process.stdin.isTTY; + const accountId = await requireAuth(config); -export async function EnableHandler( - args: StrictYargsOptionsToInterface -) { - const isInteractive = process.stdin.isTTY; - const config = readConfig(args.config, args); - const accountId = await requireAuth(config); + if (isInteractive) { + args.provider ??= await prompt( + "Enter the cloud storage provider of your bucket (AWS or GCS):" + ); + if (!args.provider) { + throw new UserError("Must specify a cloud storage provider."); + } + if (!SIPPY_PROVIDER_CHOICES.includes(args.provider)) { + throw new UserError("Cloud storage provider must be: AWS or GCS"); + } + args.bucket ??= await prompt( + `Enter the name of your ${args.provider} bucket:` + ); + if (!args.bucket) { + throw new UserError(`Must specify ${args.provider} bucket name.`); + } - if (isInteractive) { - args.provider ??= await prompt( - "Enter the cloud storage provider of your bucket (AWS or GCS):" - ); - if (!args.provider) { - throw new UserError("Must specify a cloud storage provider."); - } - if (!SIPPY_PROVIDER_CHOICES.includes(args.provider)) { - throw new UserError("Cloud storage provider must be: AWS or GCS"); - } - args.bucket ??= await prompt( - `Enter the name of your ${args.provider} bucket:` - ); - if (!args.bucket) { - throw new UserError(`Must specify ${args.provider} bucket name.`); + if (args.provider === "AWS") { + args.region ??= await prompt( + "Enter the AWS region where your S3 bucket is located (example: us-west-2):" + ); + if (!args.region) { + throw new UserError("Must specify an AWS Region."); + } + args.accessKeyId ??= await prompt( + "Enter your AWS Access Key ID (requires read and list access):" + ); + if (!args.accessKeyId) { + throw new UserError("Must specify an AWS Access Key ID."); + } + args.secretAccessKey ??= await prompt( + "Enter your AWS Secret Access Key:" + ); + if (!args.secretAccessKey) { + throw new UserError("Must specify an AWS Secret Access Key."); + } + } else if (args.provider === "GCS") { + if ( + !(args.clientEmail && args.privateKey) && + !args.serviceAccountKeyFile + ) { + args.serviceAccountKeyFile = await prompt( + "Enter the path to your Google Cloud service account key JSON file:" + ); + if (!args.serviceAccountKeyFile) { + throw new UserError( + "Must specify the path to a service account key JSON file." + ); + } + } + } + + args.r2AccessKeyId ??= await prompt( + "Enter your R2 Access Key ID (requires read and write access):" + ); + if (!args.r2AccessKeyId) { + throw new UserError("Must specify an R2 Access Key ID."); + } + args.r2SecretAccessKey ??= await prompt( + "Enter your R2 Secret Access Key:" + ); + if (!args.r2SecretAccessKey) { + throw new UserError("Must specify an R2 Secret Access Key."); + } } + let sippyConfig: SippyPutParams; + if (args.provider === "AWS") { - args.region ??= await prompt( - "Enter the AWS region where your S3 bucket is located (example: us-west-2):" - ); if (!args.region) { - throw new UserError("Must specify an AWS Region."); + throw new UserError("Error: must provide --region."); + } + if (!args.bucket) { + throw new UserError("Error: must provide --bucket."); } - args.accessKeyId ??= await prompt( - "Enter your AWS Access Key ID (requires read and list access):" - ); if (!args.accessKeyId) { - throw new UserError("Must specify an AWS Access Key ID."); + throw new UserError("Error: must provide --access-key-id."); } - args.secretAccessKey ??= await prompt( - "Enter your AWS Secret Access Key:" - ); if (!args.secretAccessKey) { - throw new UserError("Must specify an AWS Secret Access Key."); + throw new UserError("Error: must provide --secret-access-key."); + } + if (!args.r2AccessKeyId) { + throw new UserError("Error: must provide --r2-access-key-id."); + } + if (!args.r2SecretAccessKey) { + throw new UserError("Error: must provide --r2-secret-access-key."); } + + sippyConfig = { + source: { + provider: "aws", + region: args.region, + bucket: args.bucket, + accessKeyId: args.accessKeyId, + secretAccessKey: args.secretAccessKey, + }, + destination: { + provider: "r2", + accessKeyId: args.r2AccessKeyId, + secretAccessKey: args.r2SecretAccessKey, + }, + }; } else if (args.provider === "GCS") { - if ( - !(args.clientEmail && args.privateKey) && - !args.serviceAccountKeyFile - ) { - args.serviceAccountKeyFile = await prompt( - "Enter the path to your Google Cloud service account key JSON file:" + if (args.serviceAccountKeyFile) { + const serviceAccount = JSON.parse( + readFileSync(args.serviceAccountKeyFile) ); - if (!args.serviceAccountKeyFile) { - throw new UserError( - "Must specify the path to a service account key JSON file." - ); + if ( + "client_email" in serviceAccount && + "private_key" in serviceAccount + ) { + args.clientEmail = serviceAccount.client_email; + args.privateKey = serviceAccount.private_key; } } - } - - args.r2AccessKeyId ??= await prompt( - "Enter your R2 Access Key ID (requires read and write access):" - ); - if (!args.r2AccessKeyId) { - throw new UserError("Must specify an R2 Access Key ID."); - } - args.r2SecretAccessKey ??= await prompt("Enter your R2 Secret Access Key:"); - if (!args.r2SecretAccessKey) { - throw new UserError("Must specify an R2 Secret Access Key."); - } - } - - let sippyConfig: SippyPutParams; - if (args.provider === "AWS") { - if (!args.region) { - throw new UserError("Error: must provide --region."); - } - if (!args.bucket) { - throw new UserError("Error: must provide --bucket."); - } - if (!args.accessKeyId) { - throw new UserError("Error: must provide --access-key-id."); - } - if (!args.secretAccessKey) { - throw new UserError("Error: must provide --secret-access-key."); - } - if (!args.r2AccessKeyId) { - throw new UserError("Error: must provide --r2-access-key-id."); - } - if (!args.r2SecretAccessKey) { - throw new UserError("Error: must provide --r2-secret-access-key."); - } + if (!args.bucket) { + throw new UserError("Error: must provide --bucket."); + } + if (!args.clientEmail) { + throw new UserError( + "Error: must provide --service-account-key-file or --client-email." + ); + } + if (!args.privateKey) { + throw new UserError( + "Error: must provide --service-account-key-file or --private-key." + ); + } + args.privateKey = args.privateKey.replace(/\\n/g, "\n"); - sippyConfig = { - source: { - provider: "aws", - region: args.region, - bucket: args.bucket, - accessKeyId: args.accessKeyId, - secretAccessKey: args.secretAccessKey, - }, - destination: { - provider: "r2", - accessKeyId: args.r2AccessKeyId, - secretAccessKey: args.r2SecretAccessKey, - }, - }; - } else if (args.provider === "GCS") { - if (args.serviceAccountKeyFile) { - const serviceAccount = JSON.parse( - readFileSync(args.serviceAccountKeyFile) - ); - if ("client_email" in serviceAccount && "private_key" in serviceAccount) { - args.clientEmail = serviceAccount.client_email; - args.privateKey = serviceAccount.private_key; + if (!args.r2AccessKeyId) { + throw new UserError("Error: must provide --r2-access-key-id."); + } + if (!args.r2SecretAccessKey) { + throw new UserError("Error: must provide --r2-secret-access-key."); } - } - if (!args.bucket) { - throw new UserError("Error: must provide --bucket."); - } - if (!args.clientEmail) { - throw new UserError( - "Error: must provide --service-account-key-file or --client-email." - ); - } - if (!args.privateKey) { + sippyConfig = { + source: { + provider: "gcs", + bucket: args.bucket, + clientEmail: args.clientEmail, + privateKey: args.privateKey, + }, + destination: { + provider: "r2", + accessKeyId: args.r2AccessKeyId, + secretAccessKey: args.r2SecretAccessKey, + }, + }; + } else { throw new UserError( - "Error: must provide --service-account-key-file or --private-key." + "Error: unrecognized provider. Possible options are AWS & GCS." ); } - args.privateKey = args.privateKey.replace(/\\n/g, "\n"); - - if (!args.r2AccessKeyId) { - throw new UserError("Error: must provide --r2-access-key-id."); - } - if (!args.r2SecretAccessKey) { - throw new UserError("Error: must provide --r2-secret-access-key."); - } - - sippyConfig = { - source: { - provider: "gcs", - bucket: args.bucket, - clientEmail: args.clientEmail, - privateKey: args.privateKey, - }, - destination: { - provider: "r2", - accessKeyId: args.r2AccessKeyId, - secretAccessKey: args.r2SecretAccessKey, - }, - }; - } else { - throw new UserError( - "Error: unrecognized provider. Possible options are AWS & GCS." - ); - } - await putR2Sippy(accountId, args.name, sippyConfig, args.jurisdiction); + await putR2Sippy(accountId, args.name, sippyConfig, args.jurisdiction); - logger.log(`✨ Successfully enabled Sippy on the '${args.name}' bucket.`); -} + logger.log(`✨ Successfully enabled Sippy on the '${args.name}' bucket.`); + }, +}); -export function GetOptions(yargs: CommonYargsArgv) { - return yargs - .positional("name", { +defineCommand({ + command: "wrangler r2 bucket sippy disable", + metadata: { + description: "Disable Sippy on an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["name"], + args: { + name: { describe: "The name of the bucket", type: "string", demandOption: true, - }) - .option("jurisdiction", { + }, + jurisdiction: { describe: "The jurisdiction where the bucket exists", alias: "J", requiresArg: true, type: "string", - }); -} + }, + }, + async handler(args, { config }) { + const accountId = await requireAuth(config); -export async function GetHandler( - args: StrictYargsOptionsToInterface -) { - const config = readConfig(args.config, args); - const accountId = await requireAuth(config); + await deleteR2Sippy(accountId, args.name, args.jurisdiction); - try { - const sippyConfig = await getR2Sippy( - accountId, - args.name, - args.jurisdiction - ); - logger.log("Sippy configuration:", sippyConfig); - } catch (e) { - if (e instanceof APIError && "code" in e && e.code === NO_SUCH_OBJECT_KEY) { - logger.log(`No Sippy configuration found for the '${args.name}' bucket.`); - } else { - throw e; - } - } -} + logger.log(`✨ Successfully disabled Sippy on the '${args.name}' bucket.`); + }, +}); -export function DisableOptions(yargs: CommonYargsArgv) { - return yargs - .positional("name", { +defineCommand({ + command: "wrangler r2 bucket sippy get", + metadata: { + description: "Check the status of Sippy on an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["name"], + args: { + name: { describe: "The name of the bucket", type: "string", demandOption: true, - }) - .option("jurisdiction", { + }, + jurisdiction: { describe: "The jurisdiction where the bucket exists", alias: "J", requiresArg: true, type: "string", - }); -} - -export async function DisableHandler( - args: StrictYargsOptionsToInterface -) { - const config = readConfig(args.config, args); - const accountId = await requireAuth(config); + }, + }, + async handler(args, { config }) { + const accountId = await requireAuth(config); - await deleteR2Sippy(accountId, args.name, args.jurisdiction); - - logger.log(`✨ Successfully disabled Sippy on the '${args.name}' bucket.`); -} + try { + const sippyConfig = await getR2Sippy( + accountId, + args.name, + args.jurisdiction + ); + logger.log("Sippy configuration:", sippyConfig); + } catch (e) { + if ( + e instanceof APIError && + "code" in e && + e.code === NO_SUCH_OBJECT_KEY + ) { + logger.log( + `No Sippy configuration found for the '${args.name}' bucket.` + ); + } else { + throw e; + } + } + }, +});