#!/usr/bin/env perl
#
#  This file is part of Cloudflare::API.
#
#  This software is copyright (c) 2026 by Andrew Speer <andrew.speer@isolutions.com.au>.
#
#  This is free software; you can redistribute it and/or modify it under
#  the same terms as the Perl 5 programming language system itself.
#
#  Full license text is available at:
#
#  <http://dev.perl.org/licenses/>
#


#
#  Call Cloudflare management API methods from the command line
#
package main;


#  Compiler pragmas
#
use strict qw(vars);
use vars   qw($VERSION);
use warnings;


#  Use the base module
#
use Cloudflare::API;


#  External modules
#
use Getopt::Long;
use Pod::Usage;
use JSON::PP;
use Data::Dumper;
use FindBin qw($Script);
use IPC::Open3;
use File::Spec;


#  Local customisation
#
local $Data::Dumper::Indent=1;
local $Data::Dumper::Sortkeys=1;


#  Version Info, must be all one line for MakeMaker, CPAN.
#
$VERSION='1.007';


#  Only supported resource methods may be called by name
#
my %resource_method=(
    accounts => [qw(list get)],
    zones    => [qw(list get)],
    workers  => [qw(list_scripts upload_script upload_version list_versions get_version upload_assets delete_script
        list_deployments create_deployment get_deployment list_secrets add_secret
        delete_secret get_subdomain set_subdomain list_routes create_route
        update_route delete_route)],
    r2       => [qw(list_buckets get_bucket create_bucket update_bucket delete_bucket)],
    kv       => [qw(list_namespaces get_namespace create_namespace rename_namespace
        delete_namespace list_keys get_value put_value delete_value)],
    d1       => [qw(list_databases get_database create_database delete_database
        update_database query_database query_sql)],
    queues   => [qw(list_queues get_queue create_queue delete_queue update_queue
        list_consumers create_consumer delete_consumer)],
    hyperdrive => [qw(list_configs get_config create_config replace_config
        update_config delete_config)],
    secrets_store => [qw(list_stores get_store create_store delete_store
        list_secrets get_secret create_secret update_secret delete_secret get_quota)]
);


#  Run main. Keep the script loadable by the local test suite.
#
exit main(\@ARGV) unless caller();


#============================================================================


sub main {


    #  Get arguments and command line options
    #
    my $argv_ar=shift();
    my (%opt, @arg, %param, @asset_source);
    GetOptions(
        \%opt,
        my @opt=(
        'resource=s',
        'action=s',
        'method=s',
        'path=s',
        'account-id=s',
        'auth=s',
        'output=s',
        'full-response!',
        'paginate!',
        'max-pages=i',
        'per-page=i',
        'help|h|?',
        'man',
        'version',
        'dump_opt|dump-opt|opt',
        'asset=s' => sub { push(@asset_source, ['file', $_[1]]) },
        'asset-list-json=s' => sub { push(@asset_source, ['json', $_[1]]) },
        'asset-list-text=s' => sub { push(@asset_source, ['text', $_[1]]) },
        'asset-list-stdin' => sub { push(@asset_source, ['stdin']) },
        'arg=s' => sub { push(@arg, typed_value('string', $_[1])) },
        'arg-bool=s' => sub { push(@arg, typed_value('bool', $_[1])) },
        'arg-array=s' => sub { push(@arg, typed_value('array', $_[1])) },
        'arg-hash=s' => sub { push(@arg, typed_value('hash', $_[1])) },
        'arg-json=s' => sub { push(@arg, typed_value('json', $_[1])) },
        'arg-json-file=s' => sub { push(@arg, typed_value('json-file', $_[1])) },
        'arg-dumper-file=s' => sub { push(@arg, typed_value('dumper-file', $_[1])) },
        'param=s' => sub { my ($key, $value)=parse_named('string', $_[1]); $param{$key}=$value },
        'param-bool=s' => sub { my ($key, $value)=parse_named('bool', $_[1]); $param{$key}=$value },
        'param-json=s' => sub { my ($key, $value)=parse_named('json', $_[1]); $param{$key}=$value },
        'param-json-file=s' => sub { my ($key, $value)=parse_named('json-file', $_[1]); $param{$key}=$value },
        'param-dumper-file=s' => sub { my ($key, $value)=parse_named('dumper-file', $_[1]); $param{$key}=$value }
        )
    ) || pod2usage(2);
    pod2usage(1) if $opt{'help'};
    pod2usage(-verbose => 2, -exitval => 0) if $opt{'man'};
    $opt{'version'} && do {print "$Script $VERSION\n"; return 0};
    die "unexpected positional arguments\n" if @$argv_ar;


    #  Check the output format and pagination limits before making a request
    #
    $opt{'output'}='json' unless defined($opt{'output'});
    die "output must be json or dumper\n" unless $opt{'output'}=~/\A(?:json|dumper)\z/;
    die "auth must be wrangler\n" if defined($opt{'auth'})&&$opt{'auth'} ne 'wrangler';
    die "max-pages must be positive\n" if defined($opt{'max-pages'})&&$opt{'max-pages'}<1;
    die "per-page must be positive\n" if defined($opt{'per-page'})&&$opt{'per-page'}<1;
    $param{'per_page'}=$opt{'per-page'} if defined($opt{'per-page'});


    #  Accept either a raw API request or a supported named resource method
    #
    my $raw_fg=defined($opt{'method'})||defined($opt{'path'});
    if ($raw_fg) {
        die "--method and --path are both required\n"
            unless defined($opt{'method'})&&defined($opt{'path'});
        die "--resource and --action cannot accompany --method\n"
            if defined($opt{'resource'})||defined($opt{'action'});
        die "raw request accepts at most one body argument\n" if @arg>1;
    }
    else {
        my ($resource, $action)=@opt{qw(resource action)};
        die "--resource and --action are required\n"
            unless defined($resource)&&defined($action);
        die "unknown resource or action\n" unless exists($resource_method{$resource})&&
            grep { $_ eq $action } @{$resource_method{$resource}};
    }
    die "--paginate requires a list action\n"
        if $opt{'paginate'}&&($raw_fg||$opt{'action'}!~/\Alist(?:_|\z)/);
    die "--max-pages requires --paginate\n" if defined($opt{'max-pages'})&&!$opt{'paginate'};


    #  Combine asset sources only for the named Worker asset upload action
    #
    if (@asset_source) {
        die "asset list options require --resource workers --action upload_assets\n"
            if $raw_fg||$opt{'resource'} ne 'workers'||$opt{'action'} ne 'upload_assets';
        die "asset list options require one Worker name and no other source argument\n"
            unless @arg==1&&defined($arg[0])&&!ref($arg[0]);
        die "--asset-list-stdin may be used only once\n"
            if 1<grep { $_->[0] eq 'stdin' } @asset_source;
        my @asset;
        foreach my $source_ar (@asset_source) {
            my ($type, $value)=@$source_ar;
            if ($type eq 'file') {
                push(@asset, $value);
            }
            elsif ($type eq 'json') {
                push(@asset, @{typed_value('array', read_file($value))});
            }
            elsif ($type eq 'text') {
                open(my $asset_fh, '<', $value) || die "unable to open $value: $!\n";
                push(@asset, @{read_asset_lines($asset_fh)});
                close($asset_fh) || die "unable to close $value: $!\n";
            }
            else {
                push(@asset, @{read_asset_lines(\*STDIN)});
            }
        }
        die "asset list is empty\n" unless @asset;
        push(@arg, \@asset);
    }


    #  Dump parsed options without needing a Cloudflare token
    #
    if ($opt{'dump_opt'}) {
        die "dump_opt is unsafe for secret-bearing actions\n"
            if !$raw_fg&&(($opt{'resource'} eq 'secrets_store'&&
                $opt{'action'}=~/\A(?:create_secret|update_secret)\z/)||
                ($opt{'resource'} eq 'workers'&&$opt{'action'} eq 'add_secret')||
                ($opt{'resource'} eq 'hyperdrive'&&
                $opt{'action'}=~/\A(?:create_config|replace_config|update_config)\z/));
        my $out_hr={ options => \%opt, arguments => \@arg, parameters => \%param };
        print Data::Dumper::Dumper($out_hr);
        return 0;
    }


    #  Resolve optional Wrangler authentication before creating the client
    #
    my %client_opt;
    $client_opt{'account_id'}=$opt{'account-id'} if defined($opt{'account-id'});
    if (defined($opt{'auth'})) {
        $client_opt{'token'}=wrangler_token();
        $client_opt{'account_id'}=wrangler_account_id()
            if !$raw_fg&&$opt{'resource'}!~/\A(?:accounts|zones)\z/&&
                !($opt{'resource'} eq 'workers'&&
                    $opt{'action'}=~/\A(?:list|create|update|delete)_route(?:s)?\z/)&&
                !defined($opt{'account-id'})&&!defined($ENV{'CLOUDFLARE_ACCOUNT_ID'});
    }
    my $api_or=Cloudflare::API->new(%client_opt);
    my $invoke_cr=sub {
        my ($query_hr, $full)=@_;
        if ($raw_fg) {
            my %request=(query => $query_hr, full_response => $full);
            $request{'json'}=$arg[0] if @arg;
            return $api_or->request(uc($opt{'method'}), $opt{'path'}, %request);
        }
        my $resource=$opt{'resource'};
        my $object_or=$api_or->$resource();
        my $action=$opt{'action'};
        return $object_or->$action(@arg, %$query_hr, full_response => $full);
    };


    #  Follow list pages when requested, retaining each page boundary
    #
    my $result;
    if ($opt{'paginate'}) {
        my @pages;
        my $query_hr={ %param };
        my %seen;
        my $page=defined($query_hr->{'page'}) ? $query_hr->{'page'} : 1;
        while (1) {
            my $envelope_hr=$invoke_cr->($query_hr, 1);
            push(@pages, $opt{'full-response'} ? $envelope_hr : $envelope_hr->{'result'});
            last if defined($opt{'max-pages'})&&@pages>=$opt{'max-pages'};
            my $next_hr=next_query($query_hr, $envelope_hr, $page, \%seen);
            last unless $next_hr;
            $query_hr=$next_hr;
            $page=defined($query_hr->{'page'}) ? $query_hr->{'page'} : $page+1;
        }
        $result=\@pages;
    }
    else {
        $result=$invoke_cr->(\%param, $opt{'full-response'});
    }


    #  Print the result in the requested format
    #
    if ($opt{'output'} eq 'dumper') {
        print Data::Dumper::Dumper($result);
    }
    else {
        print JSON::PP->new()->canonical()->pretty()->encode($result);
    }


    #  Done
    #
    return 0;

}


sub wrangler_token {


    #  Ask Wrangler to refresh its login and return the selected bearer token
    #
    my $credential_hr=wrangler_json('auth token', qw(auth token --json));
    die "wrangler auth token returned unsupported credential type\n"
        unless defined($credential_hr->{'type'})&&
            $credential_hr->{'type'}=~/\A(?:api_token|oauth)\z/;
    my $token=$credential_hr->{'token'};
    die "wrangler auth token returned no usable token\n"
        unless defined($token)&&!ref($token)&&$token=~/\A[^\x00-\x20\x7f]+\z/;
    return $token;

}


sub wrangler_account_id {


    #  Select the only account available through the active Wrangler login
    #
    my $user_hr=wrangler_json('whoami', qw(whoami --json));
    my $account_ar=$user_hr->{'accounts'};
    die "wrangler whoami returned no account list\n" unless ref($account_ar) eq 'ARRAY';
    die "wrangler authentication has no available accounts\n" unless @$account_ar;
    die "wrangler authentication has multiple accounts; use --account-id or CLOUDFLARE_ACCOUNT_ID\n"
        if @$account_ar>1;
    my $account_hr=$account_ar->[0];
    die "wrangler whoami returned an invalid account\n" unless ref($account_hr) eq 'HASH';
    my $account_id=$account_hr->{'id'};
    die "wrangler whoami returned no usable account ID\n"
        unless defined($account_id)&&!ref($account_id)&&
            $account_id=~/\A[^\x00-\x20\x7f]+\z/;
    return $account_id;

}


sub wrangler_json {


    #  Run a Wrangler JSON command without a shell or exposing diagnostic output
    #
    my ($description, @arg)=@_;
    my $null_fn=File::Spec->devnull();
    open(my $error_fh, '>', $null_fn) || die "unable to open null device: $!\n";
    my $output_fh;
    my $pid=eval { open3(undef, $output_fh, $error_fh, 'wrangler', @arg) };
    die "unable to start wrangler $description\n" if $@;
    local $/;
    my $output=<$output_fh>;
    close($output_fh);
    waitpid($pid, 0);
    die "wrangler $description failed; check Wrangler login and configuration\n" if $?;

    my $result_hr=eval { JSON::PP->new()->decode($output) };
    die "wrangler $description returned invalid JSON\n"
        if $@||ref($result_hr) ne 'HASH';
    return $result_hr;

}


sub read_file {

    #  Read JSON or a trusted Data::Dumper file as a single value
    #
    my $path_fn=shift();
    open(my $file_fh, '<', $path_fn) || die "unable to open $path_fn: $!\n";
    local $/;
    my $content=<$file_fh>;
    close($file_fh) || die "unable to close $path_fn: $!\n";
    return $content;

}


sub read_asset_lines {


    #  Keep filename whitespace intact; only empty lines are separators
    #
    my $file_fh=shift();
    my @file;
    while (my $path_fn=<$file_fh>) {
        chomp($path_fn);
        $path_fn=~s/\r\z//;
        next unless length($path_fn);
        push(@file, $path_fn);
    }
    die "unable to read asset list: $!\n" if !eof($file_fh);
    return \@file;

}


sub typed_value {

    #  Keep strings literal; decode structured and boolean arguments
    #
    my ($type, $value)=@_;
    return $value if $type eq 'string';
    if ($type eq 'bool') {
        die "boolean must be true or false\n" unless $value=~/\A(?:true|false)\z/i;
        return $value=~/\Atrue\z/i ? JSON::PP::true : JSON::PP::false;
    }
    if ($type eq 'json-file') {
        $value=read_file($value);
        $type='json';
    }
    if ($type eq 'dumper-file') {
        #  This executes Perl, so the caller must supply a trusted file
        #
        my $source=read_file($value);
        my $result=eval 'no strict; my $VAR1; '.$source;
        die "unable to evaluate trusted Data::Dumper file $value: $@\n" if $@;
        return $result;
    }
    my $decoded=eval { JSON::PP->new()->decode($value) };
    die "invalid JSON: $@\n" if $@;
    die "$type requires a JSON array\n" if $type eq 'array'&&ref($decoded) ne 'ARRAY';
    die "$type requires a JSON object\n" if $type eq 'hash'&&ref($decoded) ne 'HASH';
    return $decoded;

}


sub parse_named {

    #  Split only at the first equals sign so values can contain equals signs
    #
    my ($type, $input)=@_;
    my ($name, $value)=split(/=/, $input, 2);
    die "named parameter must be NAME=VALUE\n"
        unless defined($value)&&$name=~/\A[A-Za-z_][A-Za-z0-9_]*\z/;
    return ($name, typed_value($type, $value));

}


sub next_query {

    #  Prefer a cursor when Cloudflare supplies one
    #
    my ($query_hr, $envelope_hr, $page, $seen_hr)=@_;
    my $info_hr=$envelope_hr->{'result_info'};
    return unless ref($info_hr) eq 'HASH';
    if (defined($info_hr->{'cursor'})&&length($info_hr->{'cursor'})) {
        my $cursor=$info_hr->{'cursor'};
        die "pagination cursor repeated\n" if $seen_hr->{$cursor}++;
        return { %$query_hr, cursor => $cursor };
    }


    #  Otherwise calculate the next numbered page, if any
    #
    my $total=$info_hr->{'total_pages'};
    if (!defined($total)&&defined($info_hr->{'total_count'})&&defined($info_hr->{'per_page'})&&
        $info_hr->{'per_page'}>0) {
        $total=int(($info_hr->{'total_count'}+$info_hr->{'per_page'}-1)/$info_hr->{'per_page'});
    }
    return unless defined($total)&&$page<$total;
    return { %$query_hr, page => $page+1 };

}


1;

__END__

=encoding utf8

=begin markdown

# cloudflare-api #

# NAME #

cloudflare-api - call Cloudflare::API resource methods from the command line

# SYNOPSIS #

```sh
cloudflare-api --resource r2 --action list_buckets --paginate --max-pages 2
cloudflare-api --resource kv --action create_namespace --arg-json '{"title":"demo"}'
cloudflare-api --resource workers --action upload_assets --arg my-app --arg dist --param prefix=/docs
cloudflare-api --method GET --path /accounts --full-response
```

# DESCRIPTION #

`cloudflare-api` calls a supported `Cloudflare::API` resource method or makes a low-level JSON request. It reads `CLOUDFLARE_API_TOKEN` and, for account-scoped methods, `CLOUDFLARE_ACCOUNT_ID` from the environment. It prints the decoded Cloudflare `result` as pretty JSON by default; `--full-response` retains the entire Cloudflare envelope. The script does not build Worker code or transfer R2 objects.

Choose one mode: `--resource NAME --action NAME` for a named method, or `--method VERB --path /relative/path` for a low-level request. Positional arguments supplied with `--arg*` are passed in their command-line order. Named arguments supplied with `--param*` become method options, or query parameters in low-level mode. Other positional command-line arguments are rejected.

# OPTIONS #

## Selection and authentication ##

* **--resource NAME, --action NAME**

    Call a named method on `accounts`, `zones`, `workers`, `r2`, `kv`, `d1`, `queues`, `hyperdrive`, or `secrets_store`. Both options are required together. Only methods in the script's allowlist can be called; consult the resource module sidecars for arguments and results. The script does not expose every module method, including `workers()->download_script()`, whose body is not JSON.

* **--method VERB, --path /relative/path**

    Call a low-level JSON endpoint through `Cloudflare::API->request()`. Both options are required and cannot be combined with `--resource` or `--action`. The method is uppercased. The path must begin with exactly one slash and cannot be an absolute URL. At most one positional argument is accepted as a JSON request body; named parameters become query parameters. Dynamic path segments must be percent-encoded by the caller.

* **--account-id ID**

    Override `CLOUDFLARE_ACCOUNT_ID` and Wrangler account discovery for this invocation. Account-scoped methods require an ID; account and zone lookups do not.

* **--auth=wrangler**

    Run `wrangler auth token --json` and use its API token or refreshed OAuth token instead of the environment token. For an account-scoped named method, also run `wrangler whoami --json` and use the account ID when exactly one account is available. Select among multiple accounts with `--account-id` or `CLOUDFLARE_ACCOUNT_ID`; these explicit values take precedence and skip account discovery. Wrangler must be installed and logged in. Run `wrangler login` separately if necessary. Wrangler itself prioritizes an existing `CLOUDFLARE_API_TOKEN` over its OAuth login. API key and email credentials are not supported. No token option is accepted on the command line.

## Positional and named arguments ##

* **--arg VALUE**

    Append a literal string positional argument. Repeat to supply several arguments in order.

* **--arg-bool true|false, --arg-array JSON, --arg-hash JSON, --arg-json JSON, --arg-json-file FILE**

    Append a typed positional argument. Boolean values are case-insensitive; array and hash forms require the matching JSON container. The JSON forms accept any JSON value, directly or read from a file. To pass a private JSON body without putting it in the process arguments, pipe it to `--arg-json-file /dev/stdin`.

* **--arg-dumper-file FILE**

    Evaluate a trusted Data::Dumper file as Perl and append its result. The file can execute arbitrary Perl code; use JSON for data from other sources.

* **--param NAME=VALUE**

    Pass one literal string named argument. The first `=` separates the name from the value, so a value may contain further equals signs. Names must begin with a letter or underscore and contain only letters, digits, or underscores. A repeated name replaces its earlier value.

* **--param-bool NAME=true|false, --param-json NAME=JSON, --param-json-file NAME=FILE**

    Pass a typed named argument. JSON file content is decoded before the method call. For list actions these usually become Cloudflare query filters; for other actions they can be method options such as `metadata` and `files` for a Worker upload.

* **--param-dumper-file NAME=FILE**

    Evaluate a trusted Data::Dumper file as Perl and pass its result under `NAME`. This can execute arbitrary Perl code; prefer JSON for untrusted input.

## Worker static assets ##

* **--asset FILE**

    Append a local file to the asset source list. Repeat as needed. A bare filename uses its basename as its URL path.

* **--asset-list-json FILE**

    Append entries from a JSON array of filenames or objects with `path`, optional URL `name`, and optional `content_type`. Repeat for multiple files.

* **--asset-list-text FILE, --asset-list-stdin**

    Append one filename per line from a text file or standard input. Empty lines are ignored; spaces in filenames are preserved. The stdin option may appear only once. Sources combine in option order.

    All four asset-list options require `--resource workers --action upload_assets` and exactly one string `--arg` naming the Worker. They create the method's second positional argument as a file array; do not also pass a directory, array, or path-map source argument. The array cannot be empty. Alternatively, pass a directory with a second `--arg`, or an asset array with `--arg-json-file`. `--param prefix=/docs` sets a URL prefix. The command prints the manifest and short-lived completion JWT returned by `upload_assets()`; asset upload alone does not deploy a Worker. Treat the JWT as a credential.

## Output and pagination ##

* **--output json|dumper**

    Print pretty, canonical JSON (the default) or Perl Data::Dumper output to standard output.

* **--full-response, --no-full-response**

    Select the complete decoded Cloudflare envelope or its `result`. The default is the unwrapped `result`. With pagination, the selection applies to each page; the output is still an array. `upload_assets()` returns its own manifest and JWT structure rather than a Cloudflare envelope.

* **--paginate, --no-paginate**

    Follow cursor-based or numbered pages for named actions starting with `list`. The output is an array of page results, preserving page boundaries. Without a limit, every page reported by Cloudflare is fetched. Pagination is unavailable for raw requests and non-list actions.

* **--max-pages N, --per-page N**

    Limit pagination to a positive number of pages, or send positive `per_page=N` as a named list filter. `--max-pages` requires `--paginate`. Pagination stops when Cloudflare supplies no next page; a repeated cursor causes an error.

## Help and diagnostics ##

* **--help, -h, -?**

    Print brief help and exit.

* **--man**

    Print the script's embedded manual and exit.

* **--version**

    Print the script name and `Cloudflare::API` version and exit.

* **--dump-opt, --dump_opt, --opt**

    Print parsed options, arguments, and parameters as Data::Dumper without creating a client or requiring a token. This output can disclose values. The script rejects this mode for selected Secrets Store, Worker secret, and Hyperdrive write actions, but other actions may also carry private data; do not use it with secrets.

# ENVIRONMENT #

* **CLOUDFLARE_API_TOKEN** — Bearer token used unless `--auth=wrangler` is supplied. Obtain a token with only the permissions needed for the requested action.
* **CLOUDFLARE_ACCOUNT_ID** — Default account ID for account-scoped resource methods; overridden by `--account-id` and used in preference to Wrangler account discovery.

# EXAMPLES #

```sh
cloudflare-api --resource zones --action list --param status=active
cloudflare-api --resource kv --action list_namespaces \
    --paginate --per-page 20 --max-pages 2 --full-response
cloudflare-api --resource workers --action upload_assets \
    --arg my-app --asset dist/index.html --asset-list-text images.txt \
    --param prefix=/docs
cloudflare-api --resource secrets_store --action create_secret \
    --arg my-store --arg-json-file /dev/stdin < secrets.json
```

For a Worker version upload, pass the Worker name through `--arg` and prepared `metadata` and `files` through `--param-json-file NAME=FILE`. Version upload does not activate a deployment; consult `Cloudflare::API::Workers` for the staging and deployment sequence. A secret body supplied through standard input still appears in the command's output if Cloudflare returns it; handle the output accordingly.

# RETURN VALUES AND ERRORS #

Successful requests print the result followed by a newline and exit with status zero. JSON output preserves Cloudflare's response shape; a paginated list prints an array of pages. Input validation, missing credentials or account context, HTTP and transport errors, and Cloudflare responses reporting failure terminate with a non-zero status and a diagnostic on standard error. No write is automatically rolled back.

# SEE ALSO #

[Cloudflare::API](../lib/Cloudflare/API.pm.md), [Cloudflare::API::Workers](../lib/Cloudflare/API/Workers.pm.md), the other resource module sidecars, and `cloudflare-api --man`.

# AUTHOR #

Andrew Speer <andrew.speer@isolutions.com.au>

# LICENSE and COPYRIGHT

This file is part of Cloudflare::API.

This software is copyright (c) 2026 by Andrew Speer <andrew.speer@isolutions.com.au>.

This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.

Full license text is available at:

<http://dev.perl.org/licenses/>


=end markdown


=head1 NAME

cloudflare-api - call Cloudflare::API resource methods from the command line


=head1 SYNOPSIS


 cloudflare-api --resource r2 --action list_buckets --paginate --max-pages 2
 cloudflare-api --resource kv --action create_namespace --arg-json '{"title":"demo"}'
 cloudflare-api --resource workers --action upload_assets --arg my-app --arg dist --param prefix=/docs
 cloudflare-api --method GET --path /accounts --full-response

=head1 DESCRIPTION

C<cloudflare-api> calls a supported C<Cloudflare::API> resource method or makes a low-level JSON request. It reads C<CLOUDFLARE_API_TOKEN> and, for account-scoped methods, C<CLOUDFLARE_ACCOUNT_ID> from the environment. It prints the decoded Cloudflare C<result> as pretty JSON by default; C<--full-response> retains the entire Cloudflare envelope. The script does not build Worker code or transfer R2 objects.

Choose one mode: C<--resource NAME --action NAME> for a named method, or C<--method VERB --path /relative/path> for a low-level request. Positional arguments supplied with C<--arg*> are passed in their command-line order. Named arguments supplied with C<--param*> become method options, or query parameters in low-level mode. Other positional command-line arguments are rejected.


=head1 OPTIONS


=head2 Selection and authentication

=over

=item *

B<--resource NAME, --action NAME>

Call a named method on C<accounts>, C<zones>, C<workers>, C<r2>, C<kv>, C<d1>, C<queues>, C<hyperdrive>, or C<secrets_store>. Both options are required together. Only methods in the script's allowlist can be called; consult the resource module sidecars for arguments and results. The script does not expose every module method, including C<<< workers()->download_script() >>>, whose body is not JSON.



=item *

B<--method VERB, --path /relative/path>

Call a low-level JSON endpoint through C<<< Cloudflare::API->request() >>>. Both options are required and cannot be combined with C<--resource> or C<--action>. The method is uppercased. The path must begin with exactly one slash and cannot be an absolute URL. At most one positional argument is accepted as a JSON request body; named parameters become query parameters. Dynamic path segments must be percent-encoded by the caller.



=item *

B<--account-id ID>

Override C<CLOUDFLARE_ACCOUNT_ID> and Wrangler account discovery for this invocation. Account-scoped methods require an ID; account and zone lookups do not.



=item *

B<--auth=wrangler>

Run C<wrangler auth token --json> and use its API token or refreshed OAuth token instead of the environment token. For an account-scoped named method, also run C<wrangler whoami --json> and use the account ID when exactly one account is available. Select among multiple accounts with C<--account-id> or C<CLOUDFLARE_ACCOUNT_ID>; these explicit values take precedence and skip account discovery. Wrangler must be installed and logged in. Run C<wrangler login> separately if necessary. Wrangler itself prioritizes an existing C<CLOUDFLARE_API_TOKEN> over its OAuth login. API key and email credentials are not supported. No token option is accepted on the command line.



=back


=head2 Positional and named arguments

=over

=item *

B<--arg VALUE>

Append a literal string positional argument. Repeat to supply several arguments in order.



=item *

B<--arg-bool true|false, --arg-array JSON, --arg-hash JSON, --arg-json JSON, --arg-json-file FILE>

Append a typed positional argument. Boolean values are case-insensitive; array and hash forms require the matching JSON container. The JSON forms accept any JSON value, directly or read from a file. To pass a private JSON body without putting it in the process arguments, pipe it to C<--arg-json-file /dev/stdin>.



=item *

B<--arg-dumper-file FILE>

Evaluate a trusted Data::Dumper file as Perl and append its result. The file can execute arbitrary Perl code; use JSON for data from other sources.



=item *

B<--param NAME=VALUE>

Pass one literal string named argument. The first C<=> separates the name from the value, so a value may contain further equals signs. Names must begin with a letter or underscore and contain only letters, digits, or underscores. A repeated name replaces its earlier value.



=item *

B<--param-bool NAME=true|false, --param-json NAME=JSON, --param-json-file NAME=FILE>

Pass a typed named argument. JSON file content is decoded before the method call. For list actions these usually become Cloudflare query filters; for other actions they can be method options such as C<metadata> and C<files> for a Worker upload.



=item *

B<--param-dumper-file NAME=FILE>

Evaluate a trusted Data::Dumper file as Perl and pass its result under C<NAME>. This can execute arbitrary Perl code; prefer JSON for untrusted input.



=back


=head2 Worker static assets

=over

=item *

B<--asset FILE>

Append a local file to the asset source list. Repeat as needed. A bare filename uses its basename as its URL path.



=item *

B<--asset-list-json FILE>

Append entries from a JSON array of filenames or objects with C<path>, optional URL C<name>, and optional C<content_type>. Repeat for multiple files.



=item *

B<--asset-list-text FILE, --asset-list-stdin>

Append one filename per line from a text file or standard input. Empty lines are ignored; spaces in filenames are preserved. The stdin option may appear only once. Sources combine in option order.

All four asset-list options require C<--resource workers --action upload_assets> and exactly one string C<--arg> naming the Worker. They create the method's second positional argument as a file array; do not also pass a directory, array, or path-map source argument. The array cannot be empty. Alternatively, pass a directory with a second C<--arg>, or an asset array with C<--arg-json-file>. C<--param prefix=/docs> sets a URL prefix. The command prints the manifest and short-lived completion JWT returned by C<upload_assets()>; asset upload alone does not deploy a Worker. Treat the JWT as a credential.



=back


=head2 Output and pagination

=over

=item *

B<--output json|dumper>

Print pretty, canonical JSON (the default) or Perl Data::Dumper output to standard output.



=item *

B<--full-response, --no-full-response>

Select the complete decoded Cloudflare envelope or its C<result>. The default is the unwrapped C<result>. With pagination, the selection applies to each page; the output is still an array. C<upload_assets()> returns its own manifest and JWT structure rather than a Cloudflare envelope.



=item *

B<--paginate, --no-paginate>

Follow cursor-based or numbered pages for named actions starting with C<list>. The output is an array of page results, preserving page boundaries. Without a limit, every page reported by Cloudflare is fetched. Pagination is unavailable for raw requests and non-list actions.



=item *

B<--max-pages N, --per-page N>

Limit pagination to a positive number of pages, or send positive C<per_page=N> as a named list filter. C<--max-pages> requires C<--paginate>. Pagination stops when Cloudflare supplies no next page; a repeated cursor causes an error.



=back


=head2 Help and diagnostics

=over

=item *

B<--help, -h, -?>

Print brief help and exit.



=item *

B<--man>

Print the script's embedded manual and exit.



=item *

B<--version>

Print the script name and C<Cloudflare::API> version and exit.



=item *

B<--dump-opt, --dump_opt, --opt>

Print parsed options, arguments, and parameters as Data::Dumper without creating a client or requiring a token. This output can disclose values. The script rejects this mode for selected Secrets Store, Worker secret, and Hyperdrive write actions, but other actions may also carry private data; do not use it with secrets.



=back


=head1 ENVIRONMENT

=over

=item *

B<CLOUDFLARE_API_TOKEN> — Bearer token used unless C<--auth=wrangler> is supplied. Obtain a token with only the permissions needed for the requested action.


=item *

B<CLOUDFLARE_ACCOUNT_ID> — Default account ID for account-scoped resource methods; overridden by C<--account-id> and used in preference to Wrangler account discovery.


=back


=head1 EXAMPLES


 cloudflare-api --resource zones --action list --param status=active
 cloudflare-api --resource kv --action list_namespaces \
     --paginate --per-page 20 --max-pages 2 --full-response
 cloudflare-api --resource workers --action upload_assets \
     --arg my-app --asset dist/index.html --asset-list-text images.txt \
     --param prefix=/docs
 cloudflare-api --resource secrets_store --action create_secret \
     --arg my-store --arg-json-file /dev/stdin < secrets.json
For a Worker version upload, pass the Worker name through C<--arg> and prepared C<metadata> and C<files> through C<--param-json-file NAME=FILE>. Version upload does not activate a deployment; consult C<Cloudflare::API::Workers> for the staging and deployment sequence. A secret body supplied through standard input still appears in the command's output if Cloudflare returns it; handle the output accordingly.


=head1 RETURN VALUES AND ERRORS

Successful requests print the result followed by a newline and exit with status zero. JSON output preserves Cloudflare's response shape; a paginated list prints an array of pages. Input validation, missing credentials or account context, HTTP and transport errors, and Cloudflare responses reporting failure terminate with a non-zero status and a diagnostic on standard error. No write is automatically rolled back.


=head1 SEE ALSO

L<Cloudflare::API|../lib/Cloudflare/API.pm.md>, L<Cloudflare::API::Workers|../lib/Cloudflare/API/Workers.pm.md>, the other resource module sidecars, and C<cloudflare-api --man>.


=head1 AUTHOR

Andrew Speer L<mailto:andrew.speer@isolutions.com.au>


=head1 LICENSE and COPYRIGHT

Copyright (c) 2026 Andrew Speer. This software is free software under the same terms as Perl 5.

=cut
