@aalexis.dev/cfx 1.0.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,162 @@
1
+ # ⚡ cfx (`@aalexis.dev/cfx`)
2
+
3
+ > **Ultra-lightweight, secure CLI wrapper for managing multiple Cloudflare accounts with Wrangler.**
4
+ >
5
+ > 🔒 Encrypted local storage with `AES-256-GCM` | ⚡ 0 runtime dependencies | 🌐 Cross-platform (Windows, macOS, Linux).
6
+
7
+ ---
8
+
9
+ ## 🚀 Features
10
+
11
+ - **Multi-Account Switching:** Switch between Cloudflare accounts instantly using `--acc=<name>` or `-a <name>`.
12
+ - **Batch Execution:** Run commands sequentially across all registered accounts with `--all`.
13
+ - **Transparent Wrangler Proxy:** Forwards all arguments, flags, and real-time outputs (`stdio: inherit`) directly to the `wrangler` binary.
14
+ - **Zero Runtime Dependencies:** Built exclusively on Node.js native modules (`node:crypto`, `node:child_process`, `node:readline`, `node:fs`, `node:os`).
15
+ - **Cryptographic Security:** Credentials (`accountId` and `apiToken`) are encrypted locally with `AES-256-GCM` using a 256-bit master key with strict permissions (`0600` / `0700`).
16
+ - **Cross-Platform:** Works seamlessly across Windows (CMD, PowerShell, Windows Terminal), macOS, and Linux.
17
+
18
+ ---
19
+
20
+ ## 🛠️ Global Installation (Recommended)
21
+
22
+ Install globally once to use the clean and short `cfx` command anywhere in your terminal:
23
+
24
+ ```bash
25
+ # With pnpm
26
+ pnpm add -g @aalexis.dev/cfx
27
+
28
+ # With npm
29
+ npm install -g @aalexis.dev/cfx
30
+ ```
31
+
32
+ Once installed globally, you can simply run:
33
+ ```bash
34
+ cfx --add
35
+ cfx --list
36
+ cfx --acc=production deploy
37
+ ```
38
+
39
+ ---
40
+
41
+ ## 📦 Direct Execution (Without Installation)
42
+
43
+ You can also run `@aalexis.dev/cfx` directly using your preferred package manager:
44
+
45
+ ```bash
46
+ # With pnpm dlx
47
+ pnpm dlx @aalexis.dev/cfx --add
48
+ pnpm dlx @aalexis.dev/cfx --list
49
+ pnpm dlx @aalexis.dev/cfx --acc=production deploy
50
+
51
+ # With npx
52
+ npx @aalexis.dev/cfx --acc=staging tail
53
+ ```
54
+
55
+ ---
56
+
57
+ ## 📖 Command Reference
58
+
59
+ ### 1. Register an Account (`--add`)
60
+ Starts an interactive prompt to enter the account identifier name, **Cloudflare Account ID**, and **Cloudflare API Token** (the token input is securely masked in the terminal):
61
+
62
+ ```bash
63
+ cfx --add
64
+ # or
65
+ cfx add
66
+ ```
67
+
68
+ ### 2. List Registered Accounts (`--list`)
69
+ Displays a formatted list of all configured accounts:
70
+
71
+ ```bash
72
+ cfx --list
73
+ # or
74
+ cfx list
75
+ # or
76
+ cfx ls
77
+ ```
78
+
79
+ ### 3. Remove an Account (`--remove`)
80
+ Deletes an account from your local encrypted storage:
81
+
82
+ ```bash
83
+ cfx --remove <name>
84
+ # or
85
+ cfx rm <name>
86
+ ```
87
+
88
+ ### 4. Run Wrangler on a Specific Account (`--acc` or `-a`)
89
+ Decrypts credentials in memory, injects `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`, and invokes `wrangler`:
90
+
91
+ ```bash
92
+ # Deploy a Worker
93
+ cfx --acc=production deploy
94
+
95
+ # Stream real-time logs
96
+ cfx -a staging tail
97
+
98
+ # Inspect current authenticated user and permissions
99
+ cfx --acc=client1 whoami
100
+
101
+ # Generate Cloudflare Worker / D1 / KV types
102
+ cfx --acc=prod types
103
+ ```
104
+
105
+ ### 5. Run Across All Accounts (`--all`)
106
+ Sequentially executes the specified command on every registered account and presents a summary table:
107
+
108
+ ```bash
109
+ cfx --all deploy
110
+ cfx --all whoami
111
+ ```
112
+
113
+ ---
114
+
115
+ ## 🔐 Security & Storage
116
+
117
+ - **Storage Location:** `~/.cfx/accounts.json`
118
+ - **Master Encryption Key:** `~/.cfx/.key` (generated locally with `crypto.randomBytes(32)` and protected with `0600` permissions on Unix systems).
119
+ - **Algorithm:** `AES-256-GCM` with a unique 16-byte initialization vector (`iv`) and 16-byte authentication tag (`authTag`) per encrypted value.
120
+ - Credentials are never transmitted over the network or stored in plain text.
121
+
122
+ ---
123
+
124
+ ## 💻 Local Development & Publishing
125
+
126
+ ### 1. Clone and Install Dev Dependencies
127
+ ```bash
128
+ git clone https://github.com/your-username/cfx.git
129
+ cd cfx
130
+ pnpm install
131
+ ```
132
+
133
+ ### 2. Build the Project (TypeScript)
134
+ ```bash
135
+ pnpm build
136
+ ```
137
+
138
+ ### 3. Run Automated Tests
139
+ ```bash
140
+ pnpm test
141
+ ```
142
+
143
+ ### 4. Link Locally for Testing
144
+ ```bash
145
+ pnpm link --global
146
+ ```
147
+ You can now test the `cfx` command directly from any terminal window.
148
+
149
+ ### 5. Publish to the NPM Registry
150
+ ```bash
151
+ # 1. Log in to your npm account (if not already authenticated)
152
+ pnpm login
153
+
154
+ # 2. Publish package with public access
155
+ pnpm publish --access public
156
+ ```
157
+
158
+ ---
159
+
160
+ ## 📄 License
161
+
162
+ MIT © 2026
package/bin/crypto.js ADDED
@@ -0,0 +1,68 @@
1
+ import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
2
+ import { existsSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
3
+ const ALGORITHM = 'aes-256-gcm';
4
+ const IV_LENGTH = 16; // 128 bits
5
+ const KEY_LENGTH = 32; // 256 bits
6
+ let cachedMasterKey = null;
7
+ /**
8
+ * Retrieves or creates the master encryption key at the specified path.
9
+ * Enforces 0600 file permissions on POSIX systems.
10
+ */
11
+ export function getOrCreateMasterKey(keyFilePath) {
12
+ if (cachedMasterKey) {
13
+ return cachedMasterKey;
14
+ }
15
+ if (existsSync(keyFilePath)) {
16
+ const rawKey = readFileSync(keyFilePath);
17
+ if (rawKey.length === KEY_LENGTH) {
18
+ cachedMasterKey = rawKey;
19
+ return cachedMasterKey;
20
+ }
21
+ }
22
+ // Generate new 256-bit key
23
+ const newKey = randomBytes(KEY_LENGTH);
24
+ writeFileSync(keyFilePath, newKey, { mode: 0o600 });
25
+ // Enforce POSIX permissions when not on Windows
26
+ if (process.platform !== 'win32') {
27
+ try {
28
+ chmodSync(keyFilePath, 0o600);
29
+ }
30
+ catch {
31
+ // Ignore permission failures on unsupported filesystems
32
+ }
33
+ }
34
+ cachedMasterKey = newKey;
35
+ return cachedMasterKey;
36
+ }
37
+ /**
38
+ * Encrypts a string using AES-256-GCM and the provided master key.
39
+ */
40
+ export function encrypt(text, masterKey) {
41
+ const iv = randomBytes(IV_LENGTH);
42
+ const cipher = createCipheriv(ALGORITHM, masterKey, iv);
43
+ const encryptedBuffer = Buffer.concat([
44
+ cipher.update(text, 'utf8'),
45
+ cipher.final(),
46
+ ]);
47
+ const authTag = cipher.getAuthTag();
48
+ return {
49
+ iv: iv.toString('hex'),
50
+ authTag: authTag.toString('hex'),
51
+ data: encryptedBuffer.toString('hex'),
52
+ };
53
+ }
54
+ /**
55
+ * Decrypts an encrypted payload using AES-256-GCM and the master key.
56
+ */
57
+ export function decrypt(payload, masterKey) {
58
+ const iv = Buffer.from(payload.iv, 'hex');
59
+ const authTag = Buffer.from(payload.authTag, 'hex');
60
+ const encryptedData = Buffer.from(payload.data, 'hex');
61
+ const decipher = createDecipheriv(ALGORITHM, masterKey, iv);
62
+ decipher.setAuthTag(authTag);
63
+ const decryptedBuffer = Buffer.concat([
64
+ decipher.update(encryptedData),
65
+ decipher.final(),
66
+ ]);
67
+ return decryptedBuffer.toString('utf8');
68
+ }
package/bin/index.js ADDED
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env node
2
+ import { argv, exit } from 'node:process';
3
+ import { saveAccount, getStoredAccounts, removeAccount, getDecryptedAccount, getAllDecryptedAccounts, } from './storage.js';
4
+ import { runWrangler } from './runner.js';
5
+ import { askQuestion, askSecret } from './prompt.js';
6
+ import { colors, log, printBanner, printHelp } from './utils.js';
7
+ const VERSION = '1.0.2';
8
+ /**
9
+ * Parses command-line arguments natively
10
+ */
11
+ function parseCliArgs(rawArgs) {
12
+ const args = rawArgs.slice(2);
13
+ if (args.length === 0) {
14
+ return { action: 'help', wranglerArgs: [] };
15
+ }
16
+ const firstArg = args[0];
17
+ // Help & version commands
18
+ if (args.includes('--help') || args.includes('-h') || firstArg === 'help') {
19
+ return { action: 'help', wranglerArgs: [] };
20
+ }
21
+ if (args.includes('--version') || args.includes('-v') || firstArg === 'version') {
22
+ return { action: 'version', wranglerArgs: [] };
23
+ }
24
+ // Action: Add account
25
+ if (args.includes('--add') || firstArg === 'add') {
26
+ return { action: 'add', wranglerArgs: [] };
27
+ }
28
+ // Action: List accounts
29
+ if (args.includes('--list') || firstArg === 'list' || firstArg === 'ls') {
30
+ return { action: 'list', wranglerArgs: [] };
31
+ }
32
+ // Action: Remove account
33
+ if (firstArg === 'remove' || firstArg === 'rm') {
34
+ return { action: 'remove', removeTarget: args[1], wranglerArgs: [] };
35
+ }
36
+ const removeFlagIndex = args.findIndex((arg) => arg.startsWith('--remove'));
37
+ if (removeFlagIndex !== -1) {
38
+ const flag = args[removeFlagIndex];
39
+ if (flag.includes('=')) {
40
+ return { action: 'remove', removeTarget: flag.split('=')[1], wranglerArgs: [] };
41
+ }
42
+ return { action: 'remove', removeTarget: args[removeFlagIndex + 1], wranglerArgs: [] };
43
+ }
44
+ // Action: Execute across all accounts (--all)
45
+ if (args.includes('--all')) {
46
+ const remaining = args.filter((a) => a !== '--all');
47
+ return { action: 'exec-all', wranglerArgs: remaining };
48
+ }
49
+ // Action: Execute on specific account (--acc=<name> or -a <name>)
50
+ let accountName;
51
+ const filteredWranglerArgs = [];
52
+ for (let i = 0; i < args.length; i++) {
53
+ const current = args[i];
54
+ if (current.startsWith('--acc=')) {
55
+ accountName = current.slice('--acc='.length);
56
+ }
57
+ else if (current === '--acc') {
58
+ accountName = args[++i];
59
+ }
60
+ else if (current.startsWith('-a=')) {
61
+ accountName = current.slice('-a='.length);
62
+ }
63
+ else if (current === '-a') {
64
+ accountName = args[++i];
65
+ }
66
+ else {
67
+ filteredWranglerArgs.push(current);
68
+ }
69
+ }
70
+ if (accountName) {
71
+ return {
72
+ action: 'exec-single',
73
+ accountName,
74
+ wranglerArgs: filteredWranglerArgs,
75
+ };
76
+ }
77
+ // If no known flags were specified, show unknown action / help
78
+ return { action: 'unknown', wranglerArgs: args };
79
+ }
80
+ /**
81
+ * Interactive prompt to register a new Cloudflare account
82
+ */
83
+ async function handleAddAccount() {
84
+ printBanner();
85
+ console.log(`${colors.bold}Register new Cloudflare account:${colors.reset}\n`);
86
+ const name = await askQuestion(`${colors.cyan}?${colors.reset} Account identifier name (e.g. personal, prod, client1): `);
87
+ if (!name) {
88
+ log.error('Account name cannot be empty.');
89
+ exit(1);
90
+ }
91
+ const accountId = await askQuestion(`${colors.cyan}?${colors.reset} Cloudflare Account ID: `);
92
+ if (!accountId) {
93
+ log.error('Account ID cannot be empty.');
94
+ exit(1);
95
+ }
96
+ const apiToken = await askSecret(`${colors.cyan}?${colors.reset} Cloudflare API Token (hidden): `);
97
+ if (!apiToken) {
98
+ log.error('API Token cannot be empty.');
99
+ exit(1);
100
+ }
101
+ try {
102
+ saveAccount(name, accountId, apiToken);
103
+ console.log();
104
+ log.success(`Account ${colors.bold}"${name}"${colors.reset} saved and encrypted successfully in ~/.cfx/accounts.json`);
105
+ console.log(`\n${colors.gray}You can now use it with:${colors.reset} ${colors.cyan}cfx --acc=${name} <command>${colors.reset}\n`);
106
+ }
107
+ catch (err) {
108
+ const errorMsg = err instanceof Error ? err.message : String(err);
109
+ log.error(`Failed to save account: ${errorMsg}`);
110
+ exit(1);
111
+ }
112
+ }
113
+ /**
114
+ * Lists all stored accounts
115
+ */
116
+ function handleListAccounts() {
117
+ printBanner();
118
+ const accounts = getStoredAccounts();
119
+ if (accounts.length === 0) {
120
+ log.info('No Cloudflare accounts registered.');
121
+ console.log(`\nRegister your first account by running: ${colors.cyan}pnpm dlx @aalexis.dev/cfx --add${colors.reset}\n`);
122
+ return;
123
+ }
124
+ console.log(`${colors.bold}Registered accounts (${accounts.length}):${colors.reset}\n`);
125
+ console.log(` ${colors.bold}${'NAME'.padEnd(20)} ${'CREATED AT'.padEnd(25)} STATUS${colors.reset}`);
126
+ console.log(` ${colors.gray}${'─'.repeat(55)}${colors.reset}`);
127
+ for (const acc of accounts) {
128
+ const date = acc.createdAt ? new Date(acc.createdAt).toLocaleString() : 'N/A';
129
+ console.log(` ${colors.cyan}${acc.name.padEnd(20)}${colors.reset} ${colors.gray}${date.padEnd(25)}${colors.reset} ${colors.green}🔒 Encrypted${colors.reset}`);
130
+ }
131
+ console.log(`\n${colors.gray}Usage:${colors.reset} ${colors.cyan}cfx --acc=<name> [wrangler command]${colors.reset}\n`);
132
+ }
133
+ /**
134
+ * Removes a stored account
135
+ */
136
+ function handleRemoveAccount(targetName) {
137
+ if (!targetName) {
138
+ log.error('You must specify the name of the account to remove.');
139
+ console.log(`Example: ${colors.cyan}cfx --remove my-account${colors.reset}`);
140
+ exit(1);
141
+ }
142
+ const removed = removeAccount(targetName);
143
+ if (removed) {
144
+ log.success(`Account ${colors.bold}"${targetName}"${colors.reset} removed successfully.`);
145
+ }
146
+ else {
147
+ log.error(`No account found with the name "${targetName}".`);
148
+ const accounts = getStoredAccounts();
149
+ if (accounts.length > 0) {
150
+ console.log(`Available accounts: ${accounts.map((a) => colors.cyan + a.name + colors.reset).join(', ')}`);
151
+ }
152
+ exit(1);
153
+ }
154
+ }
155
+ /**
156
+ * Executes wrangler command for a single account
157
+ */
158
+ async function handleSingleAccountExec(accountName, wranglerArgs) {
159
+ const account = getDecryptedAccount(accountName);
160
+ if (!account) {
161
+ log.error(`Account ${colors.bold}"${accountName}"${colors.reset} was not found.`);
162
+ const accounts = getStoredAccounts();
163
+ if (accounts.length > 0) {
164
+ console.log(`\nRegistered accounts: ${accounts.map((a) => colors.cyan + a.name + colors.reset).join(', ')}`);
165
+ }
166
+ else {
167
+ console.log(`\nNo accounts registered yet. Add one with: ${colors.cyan}cfx --add${colors.reset}`);
168
+ }
169
+ exit(1);
170
+ }
171
+ const exitCode = await runWrangler({ account, args: wranglerArgs });
172
+ exit(exitCode);
173
+ }
174
+ /**
175
+ * Executes wrangler command sequentially across all registered accounts
176
+ */
177
+ async function handleAllAccountsExec(wranglerArgs) {
178
+ const accounts = getAllDecryptedAccounts();
179
+ if (accounts.length === 0) {
180
+ log.error('No accounts registered to execute command.');
181
+ console.log(`Add an account with: ${colors.cyan}cfx --add${colors.reset}`);
182
+ exit(1);
183
+ }
184
+ printBanner();
185
+ console.log(`${colors.bold}Executing command across ${accounts.length} account(s):${colors.reset} ${colors.gray}wrangler ${wranglerArgs.join(' ')}${colors.reset}\n`);
186
+ const results = [];
187
+ for (let i = 0; i < accounts.length; i++) {
188
+ const acc = accounts[i];
189
+ const stepLabel = `${i + 1}/${accounts.length}`;
190
+ console.log(`\n${colors.bold}${colors.orange}──────────────────────────────────────────────────${colors.reset}`);
191
+ log.step(stepLabel, `Account: ${colors.bold}${colors.cyan}${acc.name}${colors.reset}`);
192
+ console.log(`${colors.bold}${colors.orange}──────────────────────────────────────────────────${colors.reset}\n`);
193
+ const code = await runWrangler({ account: acc, args: wranglerArgs });
194
+ results.push({ name: acc.name, success: code === 0, code });
195
+ }
196
+ // Summary
197
+ console.log(`\n${colors.bold}═══════════════════ SUMMARY ═══════════════════${colors.reset}`);
198
+ let hasFailures = false;
199
+ for (const res of results) {
200
+ if (res.success) {
201
+ console.log(` ${colors.green}✔${colors.reset} ${res.name.padEnd(20)} ${colors.green}Completed successfully${colors.reset}`);
202
+ }
203
+ else {
204
+ hasFailures = true;
205
+ console.log(` ${colors.red}✖${colors.reset} ${res.name.padEnd(20)} ${colors.red}Failed (code ${res.code})${colors.reset}`);
206
+ }
207
+ }
208
+ console.log(`${colors.bold}═══════════════════════════════════════════════${colors.reset}\n`);
209
+ if (hasFailures) {
210
+ exit(1);
211
+ }
212
+ }
213
+ /**
214
+ * Main CLI entry point
215
+ */
216
+ async function main() {
217
+ const parsed = parseCliArgs(argv);
218
+ switch (parsed.action) {
219
+ case 'help':
220
+ printHelp();
221
+ break;
222
+ case 'version':
223
+ console.log(`cfx v${VERSION}`);
224
+ break;
225
+ case 'add':
226
+ await handleAddAccount();
227
+ break;
228
+ case 'list':
229
+ handleListAccounts();
230
+ break;
231
+ case 'remove':
232
+ handleRemoveAccount(parsed.removeTarget);
233
+ break;
234
+ case 'exec-single':
235
+ if (parsed.accountName) {
236
+ await handleSingleAccountExec(parsed.accountName, parsed.wranglerArgs);
237
+ }
238
+ break;
239
+ case 'exec-all':
240
+ await handleAllAccountsExec(parsed.wranglerArgs);
241
+ break;
242
+ case 'unknown':
243
+ default:
244
+ log.warn(`No recognized account flag or command specified.`);
245
+ printHelp();
246
+ exit(1);
247
+ }
248
+ }
249
+ main().catch((err) => {
250
+ log.error(`Unexpected error: ${err.message}`);
251
+ exit(1);
252
+ });
package/bin/prompt.js ADDED
@@ -0,0 +1,73 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { stdin as input, stdout as output } from 'node:process';
3
+ import { colors } from './utils.js';
4
+ /**
5
+ * Prompts for standard text input
6
+ */
7
+ export function askQuestion(query) {
8
+ const rl = createInterface({ input, output });
9
+ return new Promise((resolve) => {
10
+ rl.question(query, (answer) => {
11
+ rl.close();
12
+ resolve(answer.trim());
13
+ });
14
+ });
15
+ }
16
+ /**
17
+ * Prompts for sensitive input (masks input if TTY is available)
18
+ */
19
+ export function askSecret(query) {
20
+ return new Promise((resolve) => {
21
+ if (!input.isTTY) {
22
+ // Fallback for non-TTY / CI environments or pipes
23
+ const rl = createInterface({ input, output });
24
+ rl.question(query, (answer) => {
25
+ rl.close();
26
+ resolve(answer.trim());
27
+ });
28
+ return;
29
+ }
30
+ output.write(query);
31
+ let buffer = '';
32
+ const onData = (chunk) => {
33
+ const str = chunk.toString();
34
+ for (let i = 0; i < str.length; i++) {
35
+ const char = str[i];
36
+ const code = char.charCodeAt(0);
37
+ // Enter (\r or \n)
38
+ if (code === 13 || code === 10) {
39
+ cleanup();
40
+ output.write('\n');
41
+ resolve(buffer.trim());
42
+ return;
43
+ }
44
+ // Ctrl+C (interruption)
45
+ if (code === 3) {
46
+ cleanup();
47
+ output.write('\n');
48
+ process.exit(130);
49
+ }
50
+ // Backspace (127 or 8)
51
+ if (code === 127 || code === 8) {
52
+ if (buffer.length > 0) {
53
+ buffer = buffer.slice(0, -1);
54
+ output.write('\b \b');
55
+ }
56
+ }
57
+ else if (code >= 32) {
58
+ // Visible character -> mask with dot
59
+ buffer += char;
60
+ output.write(`${colors.gray}•${colors.reset}`);
61
+ }
62
+ }
63
+ };
64
+ const cleanup = () => {
65
+ input.removeListener('data', onData);
66
+ input.setRawMode(false);
67
+ input.pause();
68
+ };
69
+ input.setRawMode(true);
70
+ input.resume();
71
+ input.on('data', onData);
72
+ });
73
+ }
package/bin/runner.js ADDED
@@ -0,0 +1,75 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { join, dirname, delimiter } from 'node:path';
3
+ import { colors, log } from './utils.js';
4
+ /**
5
+ * Builds an augmented PATH that includes local and parent monorepo node_modules/.bin directories.
6
+ * This allows cfx to detect locally installed wrangler instances seamlessly.
7
+ */
8
+ function getAugmentedPath() {
9
+ const binDirs = [];
10
+ let current = process.cwd();
11
+ while (true) {
12
+ binDirs.push(join(current, 'node_modules', '.bin'));
13
+ const parent = dirname(current);
14
+ if (parent === current)
15
+ break;
16
+ current = parent;
17
+ }
18
+ const existingPath = process.env.PATH || '';
19
+ return [...binDirs, existingPath].filter(Boolean).join(delimiter);
20
+ }
21
+ /**
22
+ * Runs wrangler by injecting account credentials via environment variables.
23
+ * Transparently supports Windows, macOS, and Linux, detecting both local and global wrangler installations.
24
+ */
25
+ export function runWrangler({ account, args }) {
26
+ return new Promise((resolve) => {
27
+ const isWindows = process.platform === 'win32';
28
+ const env = {
29
+ ...process.env,
30
+ PATH: getAugmentedPath(),
31
+ CLOUDFLARE_ACCOUNT_ID: account.accountId,
32
+ CLOUDFLARE_API_TOKEN: account.apiToken,
33
+ };
34
+ // Main command
35
+ const command = 'wrangler';
36
+ // On Windows, use shell: true to resolve .cmd / .ps1 shims automatically
37
+ const child = spawn(command, args, {
38
+ stdio: 'inherit',
39
+ shell: isWindows,
40
+ env,
41
+ });
42
+ // Forward termination signals to child process
43
+ const sigintHandler = () => {
44
+ if (child.pid && !isWindows) {
45
+ child.kill('SIGINT');
46
+ }
47
+ };
48
+ const sigtermHandler = () => {
49
+ if (child.pid && !isWindows) {
50
+ child.kill('SIGTERM');
51
+ }
52
+ };
53
+ process.on('SIGINT', sigintHandler);
54
+ process.on('SIGTERM', sigtermHandler);
55
+ child.on('error', (err) => {
56
+ process.off('SIGINT', sigintHandler);
57
+ process.off('SIGTERM', sigtermHandler);
58
+ if (err.code === 'ENOENT') {
59
+ log.error(`Command ${colors.bold}'wrangler'${colors.reset} was not found in your project or global environment.\n` +
60
+ ` You can install it in this project with: ${colors.cyan}pnpm add -D wrangler${colors.reset}\n` +
61
+ ` Or install it globally with: ${colors.cyan}pnpm add -g wrangler${colors.reset}`);
62
+ resolve(1);
63
+ }
64
+ else {
65
+ log.error(`Failed to execute wrangler: ${err.message}`);
66
+ resolve(1);
67
+ }
68
+ });
69
+ child.on('close', (code) => {
70
+ process.off('SIGINT', sigintHandler);
71
+ process.off('SIGTERM', sigtermHandler);
72
+ resolve(code ?? 0);
73
+ });
74
+ });
75
+ }
package/bin/storage.js ADDED
@@ -0,0 +1,159 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
4
+ import { getOrCreateMasterKey, encrypt, decrypt } from './crypto.js';
5
+ export const CFX_DIR = join(homedir(), '.cfx');
6
+ export const ACCOUNTS_FILE = join(CFX_DIR, 'accounts.json');
7
+ export const KEY_FILE = join(CFX_DIR, '.key');
8
+ /**
9
+ * Initializes the ~/.cfx configuration directory securely
10
+ */
11
+ export function ensureConfigDirectory() {
12
+ if (!existsSync(CFX_DIR)) {
13
+ mkdirSync(CFX_DIR, { recursive: true, mode: 0o700 });
14
+ if (process.platform !== 'win32') {
15
+ try {
16
+ chmodSync(CFX_DIR, 0o700);
17
+ }
18
+ catch {
19
+ // Ignore errors if the platform does not support chmod permissions
20
+ }
21
+ }
22
+ }
23
+ }
24
+ /**
25
+ * Retrieves the local master encryption key
26
+ */
27
+ function getMasterKey() {
28
+ ensureConfigDirectory();
29
+ return getOrCreateMasterKey(KEY_FILE);
30
+ }
31
+ /**
32
+ * Reads the accounts.json file from ~/.cfx/
33
+ */
34
+ export function getStoredAccounts() {
35
+ ensureConfigDirectory();
36
+ if (!existsSync(ACCOUNTS_FILE)) {
37
+ return [];
38
+ }
39
+ try {
40
+ const raw = readFileSync(ACCOUNTS_FILE, 'utf8');
41
+ if (!raw.trim()) {
42
+ return [];
43
+ }
44
+ const parsed = JSON.parse(raw);
45
+ if (Array.isArray(parsed)) {
46
+ return parsed;
47
+ }
48
+ return [];
49
+ }
50
+ catch {
51
+ return [];
52
+ }
53
+ }
54
+ /**
55
+ * Saves accounts array into accounts.json
56
+ */
57
+ function saveStoredAccounts(accounts) {
58
+ ensureConfigDirectory();
59
+ writeFileSync(ACCOUNTS_FILE, JSON.stringify(accounts, null, 2), {
60
+ encoding: 'utf8',
61
+ mode: 0o600,
62
+ });
63
+ if (process.platform !== 'win32') {
64
+ try {
65
+ chmodSync(ACCOUNTS_FILE, 0o600);
66
+ }
67
+ catch {
68
+ // Ignore permission errors on unsupported filesystems
69
+ }
70
+ }
71
+ }
72
+ /**
73
+ * Adds or updates an encrypted account
74
+ */
75
+ export function saveAccount(name, accountId, apiToken) {
76
+ const masterKey = getMasterKey();
77
+ const accounts = getStoredAccounts();
78
+ const encryptedAccountId = encrypt(accountId.trim(), masterKey);
79
+ const encryptedApiToken = encrypt(apiToken.trim(), masterKey);
80
+ const existingIndex = accounts.findIndex((acc) => acc.name.toLowerCase() === name.trim().toLowerCase());
81
+ const now = new Date().toISOString();
82
+ if (existingIndex >= 0) {
83
+ accounts[existingIndex] = {
84
+ ...accounts[existingIndex],
85
+ name: name.trim(),
86
+ accountId: encryptedAccountId,
87
+ apiToken: encryptedApiToken,
88
+ updatedAt: now,
89
+ };
90
+ }
91
+ else {
92
+ accounts.push({
93
+ name: name.trim(),
94
+ accountId: encryptedAccountId,
95
+ apiToken: encryptedApiToken,
96
+ createdAt: now,
97
+ });
98
+ }
99
+ saveStoredAccounts(accounts);
100
+ }
101
+ /**
102
+ * Removes an account by name
103
+ */
104
+ export function removeAccount(name) {
105
+ const accounts = getStoredAccounts();
106
+ const targetName = name.trim().toLowerCase();
107
+ const filtered = accounts.filter((acc) => acc.name.toLowerCase() !== targetName);
108
+ if (filtered.length === accounts.length) {
109
+ return false;
110
+ }
111
+ saveStoredAccounts(filtered);
112
+ return true;
113
+ }
114
+ /**
115
+ * Retrieves and decrypts a specific account by name
116
+ */
117
+ export function getDecryptedAccount(name) {
118
+ const accounts = getStoredAccounts();
119
+ const targetName = name.trim().toLowerCase();
120
+ const found = accounts.find((acc) => acc.name.toLowerCase() === targetName);
121
+ if (!found) {
122
+ return null;
123
+ }
124
+ try {
125
+ const masterKey = getMasterKey();
126
+ const accountId = decrypt(found.accountId, masterKey);
127
+ const apiToken = decrypt(found.apiToken, masterKey);
128
+ return {
129
+ name: found.name,
130
+ accountId,
131
+ apiToken,
132
+ };
133
+ }
134
+ catch {
135
+ throw new Error(`Could not decrypt credentials for account "${found.name}". Check integrity of ~/.cfx/.key`);
136
+ }
137
+ }
138
+ /**
139
+ * Retrieves and decrypts all registered accounts
140
+ */
141
+ export function getAllDecryptedAccounts() {
142
+ const accounts = getStoredAccounts();
143
+ if (accounts.length === 0) {
144
+ return [];
145
+ }
146
+ const masterKey = getMasterKey();
147
+ return accounts.map((acc) => {
148
+ try {
149
+ return {
150
+ name: acc.name,
151
+ accountId: decrypt(acc.accountId, masterKey),
152
+ apiToken: decrypt(acc.apiToken, masterKey),
153
+ };
154
+ }
155
+ catch {
156
+ throw new Error(`Failed to decrypt account "${acc.name}". Check ~/.cfx/.key`);
157
+ }
158
+ });
159
+ }
package/bin/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/bin/utils.js ADDED
@@ -0,0 +1,69 @@
1
+ import { env, stdout } from 'node:process';
2
+ const isColorSupported = !env.NO_COLOR && (stdout.isTTY || env.FORCE_COLOR !== undefined);
3
+ export const colors = {
4
+ reset: isColorSupported ? '\x1b[0m' : '',
5
+ bold: isColorSupported ? '\x1b[1m' : '',
6
+ dim: isColorSupported ? '\x1b[2m' : '',
7
+ italic: isColorSupported ? '\x1b[3m' : '',
8
+ underline: isColorSupported ? '\x1b[4m' : '',
9
+ // Colors
10
+ cyan: isColorSupported ? '\x1b[36m' : '',
11
+ green: isColorSupported ? '\x1b[32m' : '',
12
+ yellow: isColorSupported ? '\x1b[33m' : '',
13
+ red: isColorSupported ? '\x1b[31m' : '',
14
+ blue: isColorSupported ? '\x1b[34m' : '',
15
+ magenta: isColorSupported ? '\x1b[35m' : '',
16
+ gray: isColorSupported ? '\x1b[90m' : '',
17
+ orange: isColorSupported ? '\x1b[38;5;208m' : '',
18
+ };
19
+ export const log = {
20
+ info: (msg) => console.log(`${colors.cyan}ℹ${colors.reset} ${msg}`),
21
+ success: (msg) => console.log(`${colors.green}✔${colors.reset} ${msg}`),
22
+ warn: (msg) => console.warn(`${colors.yellow}⚠${colors.reset} ${msg}`),
23
+ error: (msg) => console.error(`${colors.red}✖${colors.reset} ${msg}`),
24
+ step: (step, msg) => console.log(`${colors.bold}${colors.orange}[${step}]${colors.reset} ${msg}`),
25
+ };
26
+ export function printBanner() {
27
+ console.log(`${colors.bold}${colors.orange}⚡ cfx${colors.reset} ${colors.gray}- Multi-account Cloudflare Wrangler Proxy${colors.reset}\n`);
28
+ }
29
+ export function printHelp() {
30
+ console.log(`
31
+ ${colors.bold}${colors.orange}⚡ cfx${colors.reset} ${colors.gray}- Ultra-lightweight Cloudflare Multi-Account Wrangler CLI${colors.reset}
32
+
33
+ ${colors.bold}USAGE:${colors.reset}
34
+ ${colors.cyan}cfx${colors.reset} [cfx options] [wrangler commands/args...]
35
+
36
+ ${colors.bold}ACCOUNT MANAGEMENT:${colors.reset}
37
+ ${colors.cyan}--add${colors.reset}, ${colors.cyan}add${colors.reset} Register a new account interactively
38
+ ${colors.cyan}--list${colors.reset}, ${colors.cyan}list${colors.reset}, ${colors.cyan}ls${colors.reset} List all registered accounts
39
+ ${colors.cyan}--remove${colors.reset} <name>, ${colors.cyan}rm${colors.reset} <name> Remove a registered account
40
+
41
+ ${colors.bold}WRANGLER EXECUTION:${colors.reset}
42
+ ${colors.cyan}--acc=<name>${colors.reset} <args...> Run wrangler using account credentials
43
+ ${colors.cyan}-a <name>${colors.reset} <args...> Short alias for --acc=<name>
44
+ ${colors.cyan}--all${colors.reset} <args...> Run wrangler sequentially across ALL registered accounts
45
+
46
+ ${colors.bold}GENERAL:${colors.reset}
47
+ ${colors.cyan}--help${colors.reset}, ${colors.cyan}-h${colors.reset} Show this help message
48
+ ${colors.cyan}--version${colors.reset}, ${colors.cyan}-v${colors.reset} Show cfx version
49
+
50
+ ${colors.bold}EXAMPLES:${colors.reset}
51
+ ${colors.gray}# Register an account${colors.reset}
52
+ $ ${colors.cyan}pnpm dlx @aalexis.dev/cfx --add${colors.reset}
53
+
54
+ ${colors.gray}# List configured accounts${colors.reset}
55
+ $ ${colors.cyan}pnpm dlx @aalexis.dev/cfx --list${colors.reset}
56
+
57
+ ${colors.gray}# Deploy a Worker to a specific account${colors.reset}
58
+ $ ${colors.cyan}pnpm dlx @aalexis.dev/cfx --acc=production deploy${colors.reset}
59
+
60
+ ${colors.gray}# Stream real-time logs${colors.reset}
61
+ $ ${colors.cyan}pnpm dlx @aalexis.dev/cfx -a staging tail${colors.reset}
62
+
63
+ ${colors.gray}# Deploy across all configured accounts${colors.reset}
64
+ $ ${colors.cyan}pnpm dlx @aalexis.dev/cfx --all deploy${colors.reset}
65
+
66
+ ${colors.gray}# Check current account information (whoami)${colors.reset}
67
+ $ ${colors.cyan}pnpm dlx @aalexis.dev/cfx --acc=client1 whoami${colors.reset}
68
+ `);
69
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@aalexis.dev/cfx",
3
+ "version": "1.0.2",
4
+ "description": "Ultra-lightweight multi-account Cloudflare Wrangler wrapper CLI with encrypted local storage",
5
+ "type": "module",
6
+ "main": "./bin/index.js",
7
+ "bin": {
8
+ "cfx": "./bin/index.js",
9
+ "cfpipe": "./bin/index.js"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18.0.0"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "keywords": [
23
+ "cloudflare",
24
+ "wrangler",
25
+ "cli",
26
+ "multi-account",
27
+ "cfx",
28
+ "cfpipe",
29
+ "security",
30
+ "encryption",
31
+ "pnpm"
32
+ ],
33
+ "author": "Alexis",
34
+ "license": "MIT",
35
+ "devDependencies": {
36
+ "@types/node": "^22.0.0",
37
+ "typescript": "^5.5.0"
38
+ },
39
+ "scripts": {
40
+ "build": "tsc && node scripts/make-executable.js",
41
+ "dev": "tsc --watch",
42
+ "test": "tsc -p tsconfig.test.json && node dist-test/tests/test-flow.js && node scripts/cleanup-test.js"
43
+ }
44
+ }