fastpass-cli 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,148 +1,150 @@
1
1
  # fastpass
2
2
 
3
- Cloudflare Access in 60 seconds.
3
+ A CLI for rapdily configuring [Cloudflare Access](https://www.cloudflare.com/products/zero-trust/access/) add authentication to internal apps without touching your application code. Creates Access Applications, Identity Providers, and policies via the Cloudflare API.
4
4
 
5
- ## What you want What it does
5
+ **Requirements:** Node 18+, Cloudflare account with the domain on your account, Cloudflare Zero Trust (Access) enabled.
6
6
 
7
- | You want... | Cloudflare calls it... | fastpass handles it |
8
- |------------------------------------|--------------------------------|----------------------|
9
- | A login page on my app | Self-hosted Access Application | `protect` |
10
- | GitHub/Google login | Identity Provider (IdP) | `--auth github` |
11
- | "Only my team can access this" | Access Policy (Allow rule) | `--allow` |
12
- | "Only my GitHub org" | GitHub Organization rule | `--allow "org:name"` |
13
- | Email-based login, no passwords | One-Time PIN (OTP) | `--auth email` |
14
- | The wall that checks your identity | Cloudflare Access | all of it |
15
-
16
- ## Quickstart
7
+ ## Installation
17
8
 
18
9
  ```sh
19
10
  npx fastpass-cli
20
11
  ```
21
12
 
22
- That's it. The interactive wizard walks you through everything.
13
+ No global install. Use `npx` to run the latest version.
23
14
 
24
- ## One-liners
15
+ ## Quick start
25
16
 
26
17
  ```sh
27
- # Protect with email login (zero config)
28
- npx fastpass-cli protect staging.myapp.com --auth email --allow "me@gmail.com"
18
+ # Interactive wizard (prompts for domain, auth method, and access rules)
19
+ npx fastpass-cli
29
20
 
30
- # Protect with GitHub login, allow anyone at your company
31
- npx fastpass-cli protect staging.myapp.com --auth github --allow "*@company.com"
21
+ # Or one-liner
22
+ npx fastpass-cli protect staging.myapp.com --auth email --allow "me@example.com"
23
+ ```
32
24
 
33
- # Protect with GitHub login, restrict to a GitHub org
34
- npx fastpass-cli protect staging.myapp.com --auth github --allow "org:my-github-org"
25
+ ## Commands
35
26
 
36
- # Protect with Google login, allow everyone (just require login)
37
- npx fastpass-cli protect admin.myapp.com --auth google --allow "everyone"
27
+ | Command | Description |
28
+ |---------|-------------|
29
+ | `protect [domain]` | Create an Access Application. Default command when no subcommand is given. |
30
+ | `list` | List all protected domains (self-hosted Access apps). |
31
+ | `remove [domain]` | Delete Access protection from a domain. |
32
+ | `status` | Overview of team, apps, IdPs, and recent access activity. |
33
+ | `logs [domain]` | Recent access events (default: last 25). |
34
+ | `inspect [domain]` | Detailed configuration for an Access Application. |
38
35
 
39
- # List protected domains
40
- npx fastpass-cli list
36
+ ## Protect options
41
37
 
42
- # Remove protection
43
- npx fastpass-cli remove staging.myapp.com
38
+ ```
39
+ protect [domain] [options]
44
40
 
45
- # Overview dashboard — team, apps, IdPs, recent activity
46
- npx fastpass-cli status
41
+ Options:
42
+ --auth <method> email | github | google (comma-separated for multiple)
43
+ --allow <rule> Access rule (see below)
44
+ ```
47
45
 
48
- # Recent access events (last 25)
49
- npx fastpass-cli logs
46
+ ### `--allow` rule syntax
50
47
 
51
- # Filter events to one domain, last 10
52
- npx fastpass-cli logs staging.myapp.com --limit 10
48
+ | Rule | Meaning | Example |
49
+ |------|---------|---------|
50
+ | Email address(es) | Specific users (comma-separated) | `me@example.com` or `a@b.com,b@b.com` |
51
+ | `*@domain.com` | Anyone with that email domain | `*@company.com` |
52
+ | `org:name` | Members of a GitHub organization | `org:my-org` |
53
+ | `everyone` | Any authenticated user | `everyone` |
53
54
 
54
- # Events since a specific date
55
- npx fastpass-cli logs --since 2025-01-15
55
+ ### Auth methods
56
56
 
57
- # Detailed config for a specific app
58
- npx fastpass-cli inspect staging.myapp.com
57
+ - **email** One-time PIN (OTP) sent to email. No external setup; built into Cloudflare.
58
+ - **github** — OAuth. You create an app at [GitHub Developer Settings](https://github.com/settings/developers) and provide Client ID + Secret when prompted.
59
+ - **google** — OAuth. Same flow via Google Cloud Console.
59
60
 
60
- # Interactive app picker
61
- npx fastpass-cli inspect
62
- ```
61
+ ## Credentials
63
62
 
64
- ## Prerequisites
63
+ fastpass needs Cloudflare API access. It checks, in order:
65
64
 
66
- ### 1. Enable Cloudflare Access (one-time)
65
+ 1. `CLOUDFLARE_API_TOKEN` environment variable
66
+ 2. Wrangler OAuth token from `~/.wrangler/config/default.toml` (from `npx wrangler login`)
67
67
 
68
- Before fastpass can do anything, Access needs to be turned on for your account:
68
+ **Recommended:** Use an API token with:
69
69
 
70
- 1. Go to [https://one.dash.cloudflare.com](https://one.dash.cloudflare.com) (Zero Trust dashboard)
71
- 2. You'll be prompted to select the **Free** plan ($0) confirm it
72
- 3. Pick a **team name** (e.g. `myteam`) this becomes `myteam.cloudflareaccess.com`, the login domain for all your protected apps
70
+ - **Access: Organizations, Identity Providers, and Groups** — Edit
71
+ - **Access: Apps and Policies** — Edit
72
+ - **Zone: Zone** — Read (for domain validation)
73
73
 
74
- This only needs to happen once per account.
74
+ Create tokens at [dash.cloudflare.com/profile/api-tokens](https://dash.cloudflare.com/profile/api-tokens).
75
75
 
76
- ### 2. API credentials
76
+ ```sh
77
+ export CLOUDFLARE_API_TOKEN="your-token"
77
78
 
78
- You need a Cloudflare API token. Pick one:
79
+ # Optional, if you have multiple accounts:
80
+ export CLOUDFLARE_ACCOUNT_ID="your-account-id"
81
+ ```
79
82
 
80
- ### Option A: API Token (recommended)
83
+ **Note:** Wrangler’s OAuth token typically does not include Access scopes. If `wrangler login` works for Workers but fastpass fails with permission errors, use a dedicated API token with the permissions above.
81
84
 
82
- 1. Go to https://dash.cloudflare.com/profile/api-tokens
83
- 2. Create a token with these permissions:
84
- - **Access: Organizations, Identity Providers, and Groups** — Edit
85
- - **Access: Apps and Policies** — Edit
86
- - **Zone: Zone** — Read
87
- 3. Set the env var:
85
+ **Logs:** The `logs` and `status` commands fetch access events. If they fail, your token may need **Access: Audit Logs** — Read.
88
86
 
89
- ```sh
90
- export CLOUDFLARE_API_TOKEN="your-token"
91
- ```
87
+ ## Prerequisites
92
88
 
93
- Optionally set `CLOUDFLARE_ACCOUNT_ID` if you have multiple accounts.
89
+ 1. **Cloudflare Access enabled** — In [Zero Trust](https://one.dash.cloudflare.com), select the Free plan and set a team name (e.g. `myteam` `myteam.cloudflareaccess.com`). This is a one-time setup per account.
94
90
 
95
- ### Option B: Wrangler login
91
+ 2. **Domain in Cloudflare** — The domain you protect must exist as a zone in your Cloudflare account. fastpass validates this before creating the Access Application.
96
92
 
97
- If you already use wrangler:
93
+ ## Example usage
98
94
 
99
95
  ```sh
100
- npx wrangler login
101
- npx fastpass-cli
102
- ```
103
-
104
- > **Note:** Wrangler's default OAuth token does not include Access scopes. If you use `wrangler login` for deploying Workers but get permission errors from fastpass, you'll need a dedicated API token (Option A). The wrangler OAuth scopes cover Workers, KV, D1, Pages, etc. — but not Cloudflare Access.
96
+ # Email OTP, only you
97
+ npx fastpass-cli protect staging.myapp.com --auth email --allow "me@gmail.com"
105
98
 
106
- ## Auth methods
99
+ # GitHub, anyone at your company
100
+ npx fastpass-cli protect staging.myapp.com --auth github --allow "*@company.com"
107
101
 
108
- ### Email code (OTP)
102
+ # GitHub, restrict to a specific org
103
+ npx fastpass-cli protect staging.myapp.com --auth github --allow "org:my-github-org"
109
104
 
110
- The easiest option. No external setup required. Users get a one-time code sent to their email.
105
+ # Google, any logged-in user
106
+ npx fastpass-cli protect admin.myapp.com --auth google --allow "everyone"
111
107
 
112
- ### GitHub
108
+ # Multiple login methods (email + GitHub), shows Cloudflare's provider picker
109
+ npx fastpass-cli protect staging.myapp.com --auth email,github --allow "*@company.com"
113
110
 
114
- fastpass walks you through creating a GitHub OAuth app. You'll need to:
115
- 1. Create an OAuth app at https://github.com/settings/developers
116
- 2. Paste the Client ID and Secret when prompted
111
+ # List apps, view status, inspect config
112
+ npx fastpass-cli list
113
+ npx fastpass-cli status
114
+ npx fastpass-cli inspect staging.myapp.com
117
115
 
118
- ### Google
116
+ # Logs
117
+ npx fastpass-cli logs
118
+ npx fastpass-cli logs staging.myapp.com --limit 10
119
+ npx fastpass-cli logs --since 2025-01-15
119
120
 
120
- Same as GitHub — fastpass guides you through the Google Cloud Console OAuth setup.
121
+ # Remove protection
122
+ npx fastpass-cli remove staging.myapp.com
123
+ ```
121
124
 
122
125
  ## How it works
123
126
 
124
- Under the hood, fastpass calls the Cloudflare API to:
127
+ fastpass calls the Cloudflare API to:
125
128
 
126
- 1. **Validate** your domain exists in your CF account
127
- 2. **Create an Identity Provider** (email OTP, GitHub, or Google OAuth)
128
- 3. **Create an Access Application** on your domain with an allow policy
129
- 4. Your domain now shows a login page before granting access
129
+ 1. Check that the domain exists in your account (zone lookup).
130
+ 2. Create or reuse an Identity Provider (email OTP, GitHub, or Google).
131
+ 3. Create an Access Application (self-hosted) with an allow policy.
132
+ 4. Visitors hit your domain and see Cloudflare’s login page before access.
130
133
 
131
- All of this maps to Cloudflare's [Zero Trust Access](https://developers.cloudflare.com/cloudflare-one/policies/access/) product fastpass just removes the complexity.
134
+ All of this maps to Cloudflare Zero Trust Access. fastpass automates the setup; advanced configuration is done in the [Zero Trust dashboard](https://one.dash.cloudflare.com).
132
135
 
133
136
  ## FAQ
134
137
 
135
- **Does this cost money?**
136
- Cloudflare Access is free for up to 50 users.
138
+ **Cost?** Cloudflare Access is free for up to 50 users.
139
+
140
+ **Existing IdPs?** fastpass reuses Identity Providers already configured in your account (e.g. Email Login, GitHub, Google).
141
+
142
+ **Existing Access setup?** If you already have Access apps and IdPs, fastpass will use them where applicable. It does not modify existing apps; `protect` creates new ones.
137
143
 
138
- **Can I use multiple auth methods?**
139
- Run `protect` again on the same domain with a different `--auth` flag, or configure additional IdPs in the CF dashboard.
144
+ **Multiple auth methods per app?** Use `--auth email,github` to wire multiple IdPs in one command. When multiple methods are set, visitors see Cloudflare's provider picker instead of auto-redirecting.
140
145
 
141
- **What if I already have Access set up?**
142
- fastpass detects existing identity providers and reuses them.
146
+ **Need to change policies or users?** Use the [Zero Trust dashboard](https://one.dash.cloudflare.com).
143
147
 
144
- **How do I see what's going on?**
145
- Run `npx fastpass-cli status` for an overview, `npx fastpass-cli logs` for recent events, or `npx fastpass-cli inspect <domain>` for detailed app config.
148
+ ## License
146
149
 
147
- **How do I manage users/policies after setup?**
148
- Use the [Cloudflare Zero Trust dashboard](https://one.dash.cloudflare.com).
150
+ MIT
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "fastpass-cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Cloudflare Access in 60 seconds.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "fastpass": "./bin/fastpass.js"
7
+ "fastpass-cli": "./bin/fastpass.js"
8
8
  },
9
9
  "files": [
10
10
  "bin/",
@@ -29,6 +29,11 @@
29
29
  "bugs": {
30
30
  "url": "https://github.com/corywilkerson/fastpass/issues"
31
31
  },
32
+ "scripts": {
33
+ "test": "vitest run",
34
+ "test:watch": "vitest",
35
+ "test:coverage": "vitest run --coverage"
36
+ },
32
37
  "engines": {
33
38
  "node": ">=18"
34
39
  },
@@ -36,6 +41,11 @@
36
41
  "@inquirer/prompts": "^7.0.0",
37
42
  "commander": "^13.0.0",
38
43
  "open": "^10.0.0",
44
+ "ora": "^9.3.0",
39
45
  "picocolors": "^1.1.0"
46
+ },
47
+ "devDependencies": {
48
+ "@vitest/coverage-v8": "^4.0.18",
49
+ "vitest": "^4.0.18"
40
50
  }
41
51
  }
package/src/auth.js CHANGED
@@ -1,57 +1,77 @@
1
- import { execSync } from 'node:child_process';
1
+ import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
2
4
  import pc from 'picocolors';
5
+ import { spin } from './ui.js';
3
6
 
4
7
  /**
5
8
  * Resolve Cloudflare credentials.
6
9
  *
7
10
  * Priority:
8
11
  * 1. CLOUDFLARE_API_TOKEN env var
9
- * 2. `wrangler auth token` fallback (OAuth token)
12
+ * 2. Wrangler OAuth token from ~/.wrangler/config/default.toml
10
13
  *
11
14
  * Account ID:
12
15
  * 1. CLOUDFLARE_ACCOUNT_ID env var
13
16
  * 2. Fetched from /accounts API
14
17
  */
15
18
  export async function getCredentials() {
19
+ const s = spin('Checking Cloudflare credentials');
20
+
16
21
  let token = process.env.CLOUDFLARE_API_TOKEN;
17
22
 
18
23
  if (!token) {
19
- token = tryWranglerToken();
24
+ s.text = 'Checking Cloudflare credentials (trying wrangler)';
25
+ token = await tryWranglerToken();
20
26
  }
21
27
 
22
28
  if (!token) {
23
- console.error(pc.red('\nCould not find Cloudflare credentials.\n'));
24
- console.error('Set one of the following:\n');
25
- console.error(` ${pc.bold('CLOUDFLARE_API_TOKEN')} API token (recommended)`);
26
- console.error(` Create one at https://dash.cloudflare.com/profile/api-tokens`);
27
- console.error(` Needs ${pc.cyan('Access: Organizations, Identity Providers, and Groups — Edit')}`);
28
- console.error(` and ${pc.cyan('Access: Apps and Policies — Edit')}\n`);
29
- console.error(` Or install ${pc.bold('wrangler')} and run ${pc.cyan('npx wrangler login')} first.\n`);
29
+ s.fail('No Cloudflare credentials found');
30
+ console.error('');
31
+ console.error(` ${pc.bold('How to fix pick one:')}`);
32
+ console.error('');
33
+ console.error(` ${pc.cyan('1.')} Set an API token (recommended):`);
34
+ console.error('');
35
+ console.error(` ${pc.bold('export CLOUDFLARE_API_TOKEN=<your-token>')}`);
36
+ console.error('');
37
+ console.error(` Create one at: ${pc.dim('https://dash.cloudflare.com/profile/api-tokens')}`);
38
+ console.error(' Required permissions:');
39
+ console.error(` ${pc.dim('•')} Access: Organizations, Identity Providers, and Groups — Edit`);
40
+ console.error(` ${pc.dim('•')} Access: Apps and Policies — Edit`);
41
+ console.error('');
42
+ console.error(` ${pc.cyan('2.')} Log in with wrangler:`);
43
+ console.error('');
44
+ console.error(` ${pc.bold('npx wrangler login')}`);
45
+ console.error('');
30
46
  process.exit(1);
31
47
  }
32
48
 
33
49
  let accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
34
50
 
35
51
  if (!accountId) {
52
+ s.text = 'Fetching account info';
36
53
  accountId = await fetchAccountId(token);
37
54
  }
38
55
 
56
+ s.succeed('Credentials OK');
39
57
  return { token, accountId };
40
58
  }
41
59
 
42
- function tryWranglerToken() {
60
+ async function tryWranglerToken() {
43
61
  try {
44
- const result = execSync('npx wrangler --version 2>/dev/null && npx wrangler auth token 2>/dev/null', {
45
- encoding: 'utf-8',
46
- timeout: 15000,
47
- stdio: ['pipe', 'pipe', 'pipe'],
48
- });
49
- const trimmed = result.trim();
50
- if (trimmed && !trimmed.includes('Error') && !trimmed.includes('error')) {
51
- return trimmed;
62
+ const configPath = join(homedir(), '.wrangler', 'config', 'default.toml');
63
+ const content = await readFile(configPath, 'utf-8');
64
+ const match = content.match(/^oauth_token\s*=\s*"(.+)"/m);
65
+ if (match?.[1]) {
66
+ // Check if token is expired
67
+ const expMatch = content.match(/^expiration_time\s*=\s*"(.+)"/m);
68
+ if (expMatch?.[1] && new Date(expMatch[1]) < new Date()) {
69
+ return null; // expired
70
+ }
71
+ return match[1];
52
72
  }
53
73
  } catch {
54
- // wrangler not available or not logged in
74
+ // config file not found wrangler not logged in
55
75
  }
56
76
  return null;
57
77
  }
package/src/cli.js CHANGED
@@ -13,15 +13,15 @@ export function run() {
13
13
  const program = new Command();
14
14
 
15
15
  program
16
- .name('fastpass')
16
+ .name('fastpass-cli')
17
17
  .description('Cloudflare Access in 60 seconds.')
18
- .version('0.1.0');
18
+ .version('0.2.0');
19
19
 
20
20
  // Default action (no subcommand) — run the protect wizard
21
21
  program
22
22
  .command('protect [domain]', { isDefault: true })
23
23
  .description('Protect a domain with Cloudflare Access')
24
- .option('--auth <method>', 'Auth method: email, github, or google')
24
+ .option('--auth <method>', 'Auth method(s): email, github, google (comma-separated for multiple)')
25
25
  .option('--allow <rule>', 'Who can access: email, *@domain.com, or "everyone"')
26
26
  .action(async (domain, opts) => {
27
27
  printBanner();
@@ -1,16 +1,20 @@
1
1
  import pc from 'picocolors';
2
2
  import { select } from '@inquirer/prompts';
3
3
  import { handleApiError } from '../api.js';
4
+ import { withSpinner, heading } from '../ui.js';
4
5
 
5
6
  /**
6
7
  * Inspect a specific Access application's detailed configuration.
7
8
  */
8
9
  export async function inspect(api, opts = {}) {
9
10
  try {
10
- const [{ result: apps }, { result: idps }] = await Promise.all([
11
- api.get('/access/apps'),
12
- api.get('/access/identity_providers'),
13
- ]);
11
+ const [{ result: apps }, { result: idps }] = await withSpinner(
12
+ 'Loading application details',
13
+ () => Promise.all([
14
+ api.get('/access/apps'),
15
+ api.get('/access/identity_providers'),
16
+ ]),
17
+ );
14
18
 
15
19
  const selfHosted = apps?.filter((a) => a.type === 'self_hosted') || [];
16
20
 
@@ -44,16 +48,14 @@ export async function inspect(api, opts = {}) {
44
48
  }
45
49
 
46
50
  // --- App details ---
47
- console.log(`\n ${pc.bold('Application')}`);
48
- console.log(pc.dim(` ${'─'.repeat(50)}`));
51
+ heading('Application');
49
52
  console.log(` Domain: ${target.domain || 'n/a'}`);
50
53
  console.log(` Type: ${target.type || 'n/a'}`);
51
54
  console.log(` Session duration: ${target.session_duration || 'default'}`);
52
55
  console.log(` App ID: ${pc.dim(target.id)}`);
53
56
 
54
57
  // --- Allowed IdPs ---
55
- console.log(`\n ${pc.bold('Identity Providers')}`);
56
- console.log(pc.dim(` ${'─'.repeat(50)}`));
58
+ heading('Identity Providers');
57
59
  if (target.allowed_idps?.length) {
58
60
  for (const idpId of target.allowed_idps) {
59
61
  const idp = idpMap.get(idpId);
@@ -68,8 +70,7 @@ export async function inspect(api, opts = {}) {
68
70
  }
69
71
 
70
72
  // --- Policies ---
71
- console.log(`\n ${pc.bold('Policies')}`);
72
- console.log(pc.dim(` ${'─'.repeat(50)}`));
73
+ heading('Policies');
73
74
  if (target.policies?.length) {
74
75
  for (const policy of target.policies) {
75
76
  console.log(` ${pc.cyan(policy.name || 'Unnamed')} — ${policy.decision || 'n/a'}`);
@@ -89,7 +90,7 @@ export async function inspect(api, opts = {}) {
89
90
  }
90
91
  }
91
92
 
92
- function describeRule(rule) {
93
+ export function describeRule(rule) {
93
94
  if (rule.email?.email) {
94
95
  return rule.email.email;
95
96
  }
@@ -1,12 +1,16 @@
1
1
  import pc from 'picocolors';
2
2
  import { handleApiError } from '../api.js';
3
+ import { withSpinner } from '../ui.js';
3
4
 
4
5
  /**
5
6
  * List all Access applications in the account.
6
7
  */
7
8
  export async function list(api) {
8
9
  try {
9
- const { result } = await api.get('/access/apps');
10
+ const { result } = await withSpinner(
11
+ 'Fetching protected domains',
12
+ () => api.get('/access/apps'),
13
+ );
10
14
 
11
15
  if (!result?.length) {
12
16
  console.log(pc.dim('\n No Access applications found.\n'));
@@ -1,5 +1,6 @@
1
1
  import pc from 'picocolors';
2
2
  import { handleApiError } from '../api.js';
3
+ import { withSpinner } from '../ui.js';
3
4
 
4
5
  /**
5
6
  * Show recent access log events, optionally filtered by domain.
@@ -20,8 +21,9 @@ export async function logs(api, opts = {}) {
20
21
  sinceParam = `&since=${parsed.toISOString()}`;
21
22
  }
22
23
 
23
- const { result } = await api.get(
24
- `/access/logs/access_requests?limit=${limit}&direction=desc${sinceParam}`,
24
+ const { result } = await withSpinner(
25
+ 'Fetching access events',
26
+ () => api.get(`/access/logs/access_requests?limit=${limit}&direction=desc${sinceParam}`),
25
27
  );
26
28
 
27
29
  if (!result?.length) {
@@ -5,6 +5,7 @@ import { ensureEmailOtp } from '../idp/email-otp.js';
5
5
  import { ensureGitHub } from '../idp/github.js';
6
6
  import { ensureGoogle } from '../idp/google.js';
7
7
  import { handleApiError } from '../api.js';
8
+ import { spin, withSpinner } from '../ui.js';
8
9
 
9
10
  const AUTH_CHOICES = {
10
11
  email: { label: 'Email code (easiest, no setup)', setup: ensureEmailOtp },
@@ -31,46 +32,55 @@ export async function protect(api, opts = {}) {
31
32
  });
32
33
 
33
34
  // Validate domain exists in CF
34
- await validateDomain(api, domain.trim());
35
-
36
- const authMethod = opts.auth || await select({
37
- message: 'How should people log in?',
38
- choices: Object.entries(AUTH_CHOICES).map(([value, { label }]) => ({ value, name: label })),
39
- });
40
-
41
- if (!AUTH_CHOICES[authMethod]) {
42
- console.error(pc.red(`Unknown auth method: ${authMethod}. Use: email, github, or google`));
43
- process.exit(1);
35
+ await withSpinner('Verifying domain...', () => validateDomain(api, domain.trim()));
36
+
37
+ // Parse auth methods: comma-separated string from CLI, or single interactive choice
38
+ const authMethods = opts.auth
39
+ ? opts.auth.split(',').map((m) => m.trim())
40
+ : [await select({
41
+ message: 'How should people log in?',
42
+ choices: Object.entries(AUTH_CHOICES).map(([value, { label }]) => ({ value, name: label })),
43
+ })];
44
+
45
+ for (const method of authMethods) {
46
+ if (!AUTH_CHOICES[method]) {
47
+ console.error(pc.red(`Unknown auth method: ${method}. Use: email, github, or google`));
48
+ process.exit(1);
49
+ }
44
50
  }
45
51
 
46
52
  // Resolve who gets access
47
53
  const { include, includeType } = await resolveAccess(opts.allow);
48
54
 
49
55
  // Get team name for OAuth callback URLs
50
- const teamName = await getTeamName(api);
51
- if (!teamName && authMethod !== 'email') {
56
+ const teamName = await withSpinner('Fetching team info', () => getTeamName(api));
57
+ if (!teamName && authMethods.some((m) => m !== 'email')) {
52
58
  console.error(pc.red('Could not determine your Access team name.'));
53
59
  console.error('Make sure Access is enabled in your Cloudflare dashboard.\n');
54
60
  process.exit(1);
55
61
  }
56
62
 
57
- // Set up the identity provider
63
+ // Set up identity providers
58
64
  console.log('');
59
- const idp = await AUTH_CHOICES[authMethod].setup(api, teamName);
65
+ const idpResults = [];
66
+ for (const method of authMethods) {
67
+ const idp = await AUTH_CHOICES[method].setup(api, teamName);
68
+ idpResults.push(idp);
69
+ }
60
70
 
61
71
  // Build the policy include rules
62
72
  const policyInclude = buildIncludeRules(include, includeType);
63
73
 
64
74
  // Create the access application with an inline policy
65
- console.log(` Protecting ${pc.bold(domain.trim())}...`);
75
+ const s = spin(`Creating Access application for ${pc.bold(domain.trim())}...`);
66
76
 
67
77
  const appBody = {
68
78
  name: domain.trim(),
69
79
  domain: domain.trim(),
70
80
  type: 'self_hosted',
71
81
  session_duration: '24h',
72
- allowed_idps: [idp.id],
73
- auto_redirect_to_identity: true,
82
+ allowed_idps: idpResults.map((idp) => idp.id),
83
+ auto_redirect_to_identity: idpResults.length === 1,
74
84
  policies: [
75
85
  {
76
86
  name: `Allow — ${domain.trim()}`,
@@ -83,7 +93,8 @@ export async function protect(api, opts = {}) {
83
93
 
84
94
  const { result: app } = await api.post('/access/apps', appBody);
85
95
 
86
- console.log(pc.green(' Done!\n'));
96
+ s.succeed(`Protected ${pc.bold(domain.trim())}`);
97
+ console.log('');
87
98
  console.log(` ${pc.bold('Your app is protected!')} Try visiting:`);
88
99
  console.log(` ${pc.cyan(`https://${domain.trim()}`)}\n`);
89
100
  console.log(` Manage it: ${pc.dim('https://one.dash.cloudflare.com')}`);
@@ -95,7 +106,7 @@ export async function protect(api, opts = {}) {
95
106
  }
96
107
  }
97
108
 
98
- async function validateDomain(api, domain) {
109
+ export async function validateDomain(api, domain) {
99
110
  // Extract the root domain for zone lookup
100
111
  const parts = domain.split('.');
101
112
  const rootDomain = parts.slice(-2).join('.');
@@ -113,7 +124,7 @@ async function validateDomain(api, domain) {
113
124
  }
114
125
  }
115
126
 
116
- async function resolveAccess(allowFlag) {
127
+ export async function resolveAccess(allowFlag) {
117
128
  // If --allow flag was passed, parse it
118
129
  if (allowFlag) {
119
130
  if (allowFlag.startsWith('*@')) {
@@ -171,7 +182,7 @@ async function resolveAccess(allowFlag) {
171
182
  }
172
183
  }
173
184
 
174
- function buildIncludeRules(include, includeType) {
185
+ export function buildIncludeRules(include, includeType) {
175
186
  switch (includeType) {
176
187
  case 'emails':
177
188
  // CF API expects one rule per email: [{ email: { email: "a@b.com" } }, ...]
@@ -1,14 +1,17 @@
1
1
  import pc from 'picocolors';
2
2
  import { select, confirm } from '@inquirer/prompts';
3
3
  import { handleApiError } from '../api.js';
4
+ import { spin, withSpinner } from '../ui.js';
4
5
 
5
6
  /**
6
7
  * Remove Access protection from a domain.
7
8
  */
8
9
  export async function remove(api, opts = {}) {
9
10
  try {
11
+ const s = spin('Fetching applications...');
10
12
  const { result } = await api.get('/access/apps');
11
13
  const apps = result?.filter((app) => app.type === 'self_hosted') || [];
14
+ s.stop();
12
15
 
13
16
  if (!apps.length) {
14
17
  console.log(pc.dim('\n No Access applications to remove.\n'));
@@ -44,9 +47,9 @@ export async function remove(api, opts = {}) {
44
47
  return;
45
48
  }
46
49
 
47
- console.log(` Removing ${pc.bold(target.domain || target.name)}...`);
48
- await api.delete(`/access/apps/${target.id}`);
49
- console.log(pc.green(' Removed.\n'));
50
+ const label = target.domain || target.name;
51
+ await withSpinner(`Removing ${label}`, () => api.delete(`/access/apps/${target.id}`));
52
+ console.log('');
50
53
  } catch (err) {
51
54
  handleApiError(err);
52
55
  }
@@ -1,28 +1,30 @@
1
1
  import pc from 'picocolors';
2
2
  import { handleApiError } from '../api.js';
3
+ import { withSpinner, heading } from '../ui.js';
3
4
 
4
5
  /**
5
6
  * Status dashboard — quick snapshot of Access configuration and recent activity.
6
7
  */
7
8
  export async function status(api) {
8
9
  try {
9
- const [{ result: org }, { result: apps }, { result: idps }] = await Promise.all([
10
- api.get('/access/organizations'),
11
- api.get('/access/apps'),
12
- api.get('/access/identity_providers'),
13
- ]);
10
+ const [{ result: org }, { result: apps }, { result: idps }] = await withSpinner(
11
+ 'Loading Access configuration',
12
+ () => Promise.all([
13
+ api.get('/access/organizations'),
14
+ api.get('/access/apps'),
15
+ api.get('/access/identity_providers'),
16
+ ]),
17
+ );
14
18
 
15
19
  // --- Team info ---
16
20
  const teamName = org?.auth_domain?.replace('.cloudflareaccess.com', '') || 'unknown';
17
- console.log(`\n ${pc.bold('Team')}`);
18
- console.log(pc.dim(` ${'─'.repeat(40)}`));
21
+ heading('Team');
19
22
  console.log(` Auth domain: ${org?.auth_domain || 'n/a'}`);
20
23
  console.log(` Team name: ${teamName}`);
21
24
 
22
25
  // --- Protected apps ---
23
26
  const selfHosted = apps?.filter((a) => a.type === 'self_hosted') || [];
24
- console.log(`\n ${pc.bold('Protected Apps')} (${selfHosted.length})`);
25
- console.log(pc.dim(` ${'─'.repeat(40)}`));
27
+ heading(`Protected Apps (${selfHosted.length})`);
26
28
  if (selfHosted.length) {
27
29
  for (const app of selfHosted) {
28
30
  console.log(` ${app.domain || app.name}`);
@@ -32,8 +34,7 @@ export async function status(api) {
32
34
  }
33
35
 
34
36
  // --- Identity providers ---
35
- console.log(`\n ${pc.bold('Identity Providers')} (${idps?.length || 0})`);
36
- console.log(pc.dim(` ${'─'.repeat(40)}`));
37
+ heading(`Identity Providers (${idps?.length || 0})`);
37
38
  if (idps?.length) {
38
39
  const maxName = Math.max(...idps.map((p) => (p.name || '').length), 4);
39
40
  console.log(` ${pc.dim('Name'.padEnd(maxName + 2))}${pc.dim('Type')}`);
@@ -45,8 +46,7 @@ export async function status(api) {
45
46
  }
46
47
 
47
48
  // --- Recent activity (graceful fallback) ---
48
- console.log(`\n ${pc.bold('Recent Activity')}`);
49
- console.log(pc.dim(` ${'─'.repeat(40)}`));
49
+ heading('Recent Activity');
50
50
  try {
51
51
  const { result: logs } = await api.get('/access/logs/access_requests?limit=50&direction=desc');
52
52
  if (logs?.length) {
@@ -1,25 +1,27 @@
1
- import pc from 'picocolors';
1
+ import { spin } from '../ui.js';
2
2
 
3
3
  /**
4
4
  * Ensure the One-Time PIN (email code) identity provider exists.
5
5
  * Returns the IdP object.
6
6
  */
7
7
  export async function ensureEmailOtp(api) {
8
+ const s = spin('Checking for Email Login');
9
+
8
10
  const { result } = await api.get('/access/identity_providers');
9
11
  const existing = result?.find((idp) => idp.type === 'onetimepin');
10
12
 
11
13
  if (existing) {
12
- console.log(pc.green(' Email Login already configured.'));
14
+ s.succeed('Email Login already configured');
13
15
  return existing;
14
16
  }
15
17
 
16
- console.log(' Setting up Email Login...');
18
+ s.text = 'Creating Email Login';
17
19
  const { result: created } = await api.post('/access/identity_providers', {
18
20
  type: 'onetimepin',
19
21
  name: 'Email Login',
20
22
  config: {},
21
23
  });
22
24
 
23
- console.log(pc.green(' Email Login enabled.'));
25
+ s.succeed('Email Login enabled');
24
26
  return created;
25
27
  }
package/src/idp/github.js CHANGED
@@ -1,20 +1,25 @@
1
1
  import pc from 'picocolors';
2
2
  import { input, confirm } from '@inquirer/prompts';
3
3
  import open from 'open';
4
+ import { spin } from '../ui.js';
4
5
 
5
6
  /**
6
7
  * Ensure a GitHub OAuth identity provider exists.
7
8
  * Guides the user through creating a GitHub OAuth app if needed.
8
9
  */
9
10
  export async function ensureGitHub(api, teamName) {
11
+ const s = spin('Checking for GitHub login');
12
+
10
13
  const { result } = await api.get('/access/identity_providers');
11
14
  const existing = result?.find((idp) => idp.type === 'github');
12
15
 
13
16
  if (existing) {
14
- console.log(pc.green(' GitHub login already configured.'));
17
+ s.succeed('GitHub login already configured');
15
18
  return existing;
16
19
  }
17
20
 
21
+ s.stop();
22
+
18
23
  const callbackUrl = `https://${teamName}.cloudflareaccess.com/cdn-cgi/access/callback`;
19
24
  const homepageUrl = `https://${teamName}.cloudflareaccess.com`;
20
25
 
@@ -49,7 +54,7 @@ export async function ensureGitHub(api, teamName) {
49
54
  validate: (v) => v.trim().length > 0 || 'Required',
50
55
  });
51
56
 
52
- console.log(' Creating GitHub identity provider...');
57
+ const s2 = spin('Creating GitHub identity provider...');
53
58
  const { result: created } = await api.post('/access/identity_providers', {
54
59
  type: 'github',
55
60
  name: 'GitHub',
@@ -59,6 +64,6 @@ export async function ensureGitHub(api, teamName) {
59
64
  },
60
65
  });
61
66
 
62
- console.log(pc.green(' GitHub login enabled.'));
67
+ s2.succeed('GitHub login enabled');
63
68
  return created;
64
69
  }
package/src/idp/google.js CHANGED
@@ -1,20 +1,25 @@
1
1
  import pc from 'picocolors';
2
2
  import { input, confirm } from '@inquirer/prompts';
3
3
  import open from 'open';
4
+ import { spin } from '../ui.js';
4
5
 
5
6
  /**
6
7
  * Ensure a Google OAuth identity provider exists.
7
8
  * Guides the user through creating a Google OAuth client if needed.
8
9
  */
9
10
  export async function ensureGoogle(api, teamName) {
11
+ const s = spin('Checking for Google login');
12
+
10
13
  const { result } = await api.get('/access/identity_providers');
11
14
  const existing = result?.find((idp) => idp.type === 'google');
12
15
 
13
16
  if (existing) {
14
- console.log(pc.green(' Google login already configured.'));
17
+ s.succeed('Google login already configured');
15
18
  return existing;
16
19
  }
17
20
 
21
+ s.stop();
22
+
18
23
  const callbackUrl = `https://${teamName}.cloudflareaccess.com/cdn-cgi/access/callback`;
19
24
 
20
25
  console.log('');
@@ -47,7 +52,7 @@ export async function ensureGoogle(api, teamName) {
47
52
  validate: (v) => v.trim().length > 0 || 'Required',
48
53
  });
49
54
 
50
- console.log(' Creating Google identity provider...');
55
+ const s2 = spin('Creating Google identity provider...');
51
56
  const { result: created } = await api.post('/access/identity_providers', {
52
57
  type: 'google',
53
58
  name: 'Google',
@@ -57,6 +62,6 @@ export async function ensureGoogle(api, teamName) {
57
62
  },
58
63
  });
59
64
 
60
- console.log(pc.green(' Google login enabled.'));
65
+ s2.succeed('Google login enabled');
61
66
  return created;
62
67
  }
package/src/ui.js ADDED
@@ -0,0 +1,32 @@
1
+ import ora from 'ora';
2
+ import pc from 'picocolors';
3
+
4
+ /**
5
+ * Start an ora spinner with consistent styling.
6
+ */
7
+ export function spin(text) {
8
+ return ora({ text, indent: 2, color: 'cyan' }).start();
9
+ }
10
+
11
+ /**
12
+ * Wrap an async operation with an auto-succeed/fail spinner.
13
+ */
14
+ export async function withSpinner(text, fn, successText) {
15
+ const s = spin(text);
16
+ try {
17
+ const result = await fn(s);
18
+ s.succeed(successText || text);
19
+ return result;
20
+ } catch (err) {
21
+ s.fail(text);
22
+ throw err;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Print a bold section heading with a separator line.
28
+ */
29
+ export function heading(text) {
30
+ console.log(`\n ${pc.bold(text)}`);
31
+ console.log(pc.dim(` ${'─'.repeat(40)}`));
32
+ }