@leeguoo/wrangler-accounts 1.2.0 → 1.3.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 +123 -5
- package/bin/wrangler-accounts.js +47 -17
- package/lib/isolation.js +37 -2
- package/lib/profile-store.js +55 -3
- package/package.json +22 -3
- package/skills/wrangler-accounts/SKILL.md +81 -3
package/README.md
CHANGED
|
@@ -74,15 +74,47 @@ wrangler-accounts exec <name> -- <cmd> [args] # one command
|
|
|
74
74
|
wrangler-accounts login <name> # isolated OAuth login
|
|
75
75
|
wrangler-accounts default [name | --unset] # manage persistent default
|
|
76
76
|
wrangler-accounts whoami [--profile <name>] # show resolved identity
|
|
77
|
-
wrangler-accounts list
|
|
77
|
+
wrangler-accounts list # fast table (name/status/expires/identity)
|
|
78
|
+
wrangler-accounts list --deep # authoritative check via wrangler whoami
|
|
79
|
+
wrangler-accounts list --json # structured output for scripts
|
|
78
80
|
wrangler-accounts status
|
|
79
81
|
wrangler-accounts save <name> # snapshot current Wrangler config
|
|
80
82
|
wrangler-accounts sync <name> # refresh a profile from current login
|
|
81
83
|
wrangler-accounts sync-default # refresh the default profile
|
|
82
84
|
wrangler-accounts remove <name>
|
|
83
85
|
wrangler-accounts gc [--older-than 1h] # clean stale shadow HOMEs
|
|
86
|
+
-v, --version # print version
|
|
84
87
|
```
|
|
85
88
|
|
|
89
|
+
### `list` output
|
|
90
|
+
|
|
91
|
+
Fast default (no network calls — read from saved `config.toml` only):
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
Default: work
|
|
95
|
+
|
|
96
|
+
NAME STATUS EXPIRES IDENTITY
|
|
97
|
+
* work valid in 47m (2026-04-10) work@example.com / 0123456789abcdef0123456789abcdef
|
|
98
|
+
personal valid in 53m (2026-04-10) me@example.com / fedcba9876543210fedcba9876543210
|
|
99
|
+
|
|
100
|
+
Legend: * = default profile, EXPIRED = access token past expiration_time (wrangler may still auto-refresh)
|
|
101
|
+
STATUS is derived from the saved file only. For a live check that runs 'wrangler whoami' against Cloudflare,
|
|
102
|
+
pass --deep (slower, makes network calls).
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Authoritative `--deep` check (spawns `wrangler whoami` in a shadow HOME per profile, ~1s each, network required):
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
[wrangler-accounts] running deep check (wrangler whoami) for 2 profile(s)...
|
|
109
|
+
NAME STATUS EXPIRES VERIFIED IDENTITY
|
|
110
|
+
* work valid in 47m (2026-04-10) ✓ ok work@example.com / ...
|
|
111
|
+
personal valid in 53m (2026-04-10) ✗ not logged in (refresh token may be revoked) me@example.com / ...
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**When to use `--deep`**: when you actually need to know whether a profile still works. The default fast check only reads the saved `expiration_time`, which can lie in both directions — it can't tell you if Cloudflare has revoked the refresh token, and it flags fine profiles as `EXPIRED` just because their access token is past its 1-hour lifetime (wrangler auto-refreshes those transparently).
|
|
115
|
+
|
|
116
|
+
The `--json` output includes the same fields as the table plus raw `expirationTime`, `isDefault`, `isActive`, and (with `--deep`) `verified`, `verifyError`, and `liveIdentity`.
|
|
117
|
+
|
|
86
118
|
## Profile resolution order
|
|
87
119
|
|
|
88
120
|
When you run `wrangler-accounts <wrangler-args>`, the active profile is resolved in this order:
|
|
@@ -114,7 +146,9 @@ When you run `wrangler-accounts <wrangler-args>`, the active profile is resolved
|
|
|
114
146
|
--backup Backup current config on use (default)
|
|
115
147
|
--no-backup Disable backup on use
|
|
116
148
|
--unset With 'default': clear the persistent default
|
|
149
|
+
--deep, --verify With 'list': run wrangler whoami per profile for live verification
|
|
117
150
|
--older-than <dur> With 'gc': age threshold (e.g. 1h, 30m, 7d)
|
|
151
|
+
-v, -V, --version Print version
|
|
118
152
|
-h, --help Show help
|
|
119
153
|
```
|
|
120
154
|
|
|
@@ -130,10 +164,28 @@ Inside an isolated session, these are automatically set for the child process:
|
|
|
130
164
|
- `HOME` — the shadow HOME
|
|
131
165
|
- `WRANGLER_PROFILE` / `WRANGLER_ACCOUNT` — current profile name (useful for shell prompt integration)
|
|
132
166
|
- `WRANGLER_ACCOUNT_REAL_HOME` — path to your real home (escape hatch)
|
|
133
|
-
- `
|
|
167
|
+
- `WRANGLER_CACHE_DIR` — **per-profile**, points at `<profilesDir>/<name>/cache/`. Holds wrangler's `wrangler-account.json` (selected account ID), `pages-config-cache.json`, etc. Isolating this is critical: see "What is and isn't isolated" below.
|
|
168
|
+
- `WRANGLER_REGISTRY_PATH`, `WRANGLER_LOG_PATH` — pointed at real HOME so Miniflare dev registry and debug logs are shared across profiles (cross-profile dev worker discovery is intentional)
|
|
134
169
|
- `CLOUDFLARED_PATH` — set when `cloudflared` is on your PATH
|
|
135
170
|
- `WRANGLER_SEND_METRICS=false`
|
|
136
171
|
|
|
172
|
+
## What is and isn't isolated
|
|
173
|
+
|
|
174
|
+
`wrangler-accounts` isolates the things that determine **which account a wrangler command targets**, but deliberately shares some state for performance and ergonomics. Know the boundary:
|
|
175
|
+
|
|
176
|
+
| State | Location | Isolated? |
|
|
177
|
+
|---|---|---|
|
|
178
|
+
| OAuth credentials (`config.toml`, refresh token) | shadow `$HOME/.wrangler/config/default.toml` → symlink to per-profile file | ✅ per profile |
|
|
179
|
+
| Account-id cache (`wrangler-account.json`) | `WRANGLER_CACHE_DIR` = `<profilesDir>/<name>/cache/` | ✅ per profile |
|
|
180
|
+
| Pages config cache | same as above | ✅ per profile |
|
|
181
|
+
| Miniflare dev registry | `WRANGLER_REGISTRY_PATH` = `$HOME/.wrangler/registry` | ❌ **shared** (intentional — local dev workers discover each other across profiles) |
|
|
182
|
+
| Wrangler debug logs | `WRANGLER_LOG_PATH` = `$HOME/.wrangler/logs` | ❌ **shared** (append-only) |
|
|
183
|
+
| Project-local state (`./.wrangler/state/`, `./node_modules/.cache/wrangler`) | inside the project directory | ❌ **shared at project level** — same project from two profiles uses the same project-local state. If you hit a "wrong account" symptom, clearing `./.wrangler/state/` is a good first step. |
|
|
184
|
+
| `cloudflared` binary cache | `~/.wrangler/cloudflared/` or `$CLOUDFLARED_PATH` | ❌ shared (binary, not account-scoped) |
|
|
185
|
+
| Shell history, `.npmrc`, `.gitconfig`, `.ssh/` etc. | symlinked through to real `$HOME` | ❌ shared by design (so `exec` subshells feel like a normal terminal) |
|
|
186
|
+
|
|
187
|
+
> **Why this matters**: in 1.2.1 and earlier, `WRANGLER_CACHE_DIR` was pointed at real `$HOME/.wrangler/cache`, which meant `wrangler-account.json` was shared across profiles. Profile A's OAuth token could end up paired with profile B's cached account ID, causing wrangler to write resources to the wrong account *silently*. Fixed in 1.2.2 by per-profile `WRANGLER_CACHE_DIR`. Upgrade if you're on ≤1.2.1.
|
|
188
|
+
|
|
137
189
|
## Breaking changes in 1.0
|
|
138
190
|
|
|
139
191
|
- **`-p` is now `--profile`** (was `--profiles` in 0.1.x). Use the long form `--profiles <path>` to specify the profiles directory.
|
|
@@ -162,10 +214,76 @@ The profiles directory defaults to:
|
|
|
162
214
|
- Saved OAuth sessions can expire. If a profile is expired, running it will stop and tell you to run `wrangler-accounts login <name>` again.
|
|
163
215
|
- Backups from the deprecated `use` command are hidden from `list` and `status` unless you pass `--include-backups`.
|
|
164
216
|
|
|
165
|
-
##
|
|
217
|
+
## FAQ
|
|
218
|
+
|
|
219
|
+
### How do I use Cloudflare Wrangler with multiple accounts?
|
|
220
|
+
|
|
221
|
+
`wrangler` itself only supports one OAuth login at a time — running `wrangler login` overwrites `~/.wrangler/config/default.toml`, forcing you to re-login every time you switch accounts. `wrangler-accounts` saves each login as a named profile and runs `wrangler` inside a per-invocation **shadow HOME**, so different shells can use different Cloudflare accounts in parallel.
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
npm i -g @leeguoo/wrangler-accounts
|
|
225
|
+
wrangler-accounts login work
|
|
226
|
+
wrangler-accounts login personal
|
|
227
|
+
wrangler-accounts --profile work deploy
|
|
228
|
+
wrangler-accounts --profile personal tail my-worker
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### How do I switch between Cloudflare Workers accounts without logging out?
|
|
232
|
+
|
|
233
|
+
Use `wrangler-accounts --profile <name>` for a single command, or `wrangler-accounts default <name>` to set a persistent default. Neither touches your existing `~/.wrangler/config/default.toml`; profiles live in their own directory.
|
|
234
|
+
|
|
235
|
+
### Can I run `wrangler dev` on one Cloudflare account while deploying to another?
|
|
236
|
+
|
|
237
|
+
Yes. That's the whole point of the shadow HOME isolation — each shell gets its own temporary HOME with the profile's OAuth config, so two invocations never share state.
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
# terminal 1
|
|
241
|
+
wrangler-accounts --profile work tail my-worker --format pretty
|
|
242
|
+
|
|
243
|
+
# terminal 2
|
|
244
|
+
wrangler-accounts --profile personal dev
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### How do I use Wrangler with multiple accounts in CI?
|
|
248
|
+
|
|
249
|
+
**Don't use `wrangler-accounts` in CI.** Use native env vars — `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` — with plain `wrangler`. That's what Cloudflare designed wrangler for; `wrangler-accounts` is a local developer convenience for juggling OAuth sessions on your workstation.
|
|
250
|
+
|
|
251
|
+
### Is this like `aws-vault` / `aws --profile` but for Cloudflare?
|
|
252
|
+
|
|
253
|
+
Yes. `wrangler-accounts` is to Cloudflare Wrangler what `aws --profile` and `aws-vault exec` are to the AWS CLI: named profiles, per-invocation isolation, subshell mode, AWS-style resolution order (`--profile` > `$WRANGLER_PROFILE` > persistent default). Unlike AWS CLI, `wrangler` has no native `--profile` flag, so `wrangler-accounts` sits in front of `wrangler` and sets up an isolated `HOME` per invocation.
|
|
254
|
+
|
|
255
|
+
### How do I know if a saved profile still works?
|
|
256
|
+
|
|
257
|
+
`wrangler-accounts list` shows a fast table with STATUS (derived from the saved `expiration_time`), but that can miss revoked refresh tokens. For an authoritative check, run:
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
wrangler-accounts list --deep
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
This spawns `wrangler whoami` inside each profile's shadow HOME and reports whether Cloudflare actually accepts the credentials.
|
|
264
|
+
|
|
265
|
+
### I get "Not logged in" even though I just ran `wrangler login`
|
|
266
|
+
|
|
267
|
+
If you used plain `wrangler login`, it wrote to your real `~/.wrangler/config/default.toml`. Capture that as a named profile before it gets overwritten:
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
wrangler-accounts save work
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Or start fresh with `wrangler-accounts login work`, which does the OAuth flow inside an isolated shadow HOME and writes directly into the profile directory.
|
|
274
|
+
|
|
275
|
+
## Discoverability
|
|
276
|
+
|
|
277
|
+
Project: Cloudflare Wrangler multi-account manager — save and switch between multiple Cloudflare Workers OAuth logins without re-authenticating each time.
|
|
278
|
+
|
|
279
|
+
**Search keywords**: cloudflare wrangler multi account, wrangler multiple accounts, cloudflare workers switch account, wrangler profile manager, wrangler login profiles, cloudflare account switcher, wrangler --profile, AWS-style profile for wrangler, aws-vault for cloudflare, wrangler oauth multi account, cloudflare workers different accounts, per-invocation isolation, shadow home, wrangler account management CLI.
|
|
280
|
+
|
|
281
|
+
**AI agent keywords**: agent skill, skills.sh, Claude Code skill, Cursor skill, Codex skill, Gemini CLI skill — see "Install as an AI agent skill" above.
|
|
166
282
|
|
|
167
|
-
|
|
168
|
-
|
|
283
|
+
Related tools / alternatives:
|
|
284
|
+
- [AWS CLI `--profile`](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html) — same concept for AWS, natively supported
|
|
285
|
+
- [aws-vault](https://github.com/99designs/aws-vault) — OAuth-safe AWS profile wrapper that inspired the `exec` design here
|
|
286
|
+
- [direnv](https://direnv.net/) — directory-based env switching (orthogonal, can be combined)
|
|
169
287
|
|
|
170
288
|
## Shell completion (zsh)
|
|
171
289
|
|
package/bin/wrangler-accounts.js
CHANGED
|
@@ -442,16 +442,13 @@ function main() {
|
|
|
442
442
|
const session = readSessionState(cfgPath);
|
|
443
443
|
const meta = readMeta(profileDir);
|
|
444
444
|
const identity = getMetaIdentity(meta);
|
|
445
|
-
let status;
|
|
446
|
-
if (session.expired === true) status = "expired";
|
|
447
|
-
else if (session.expired === false) status = "valid";
|
|
448
|
-
else status = "unknown";
|
|
449
445
|
return {
|
|
450
446
|
name,
|
|
451
447
|
isDefault: name === defaultName,
|
|
452
448
|
isActive: name === activeName,
|
|
453
|
-
status,
|
|
449
|
+
status: session.effective, // 'valid' | 'refreshable' | 'expired' | 'unknown'
|
|
454
450
|
expirationTime: session.expirationTime,
|
|
451
|
+
hasRefreshToken: session.hasRefreshToken,
|
|
455
452
|
identity,
|
|
456
453
|
verified: null,
|
|
457
454
|
verifyError: null,
|
|
@@ -528,6 +525,7 @@ function main() {
|
|
|
528
525
|
name: e.name,
|
|
529
526
|
status:
|
|
530
527
|
e.status === "expired" ? "EXPIRED"
|
|
528
|
+
: e.status === "refreshable" ? "valid*"
|
|
531
529
|
: e.status === "valid" ? "valid"
|
|
532
530
|
: "unknown",
|
|
533
531
|
expires: formatExpiry(e.expirationTime),
|
|
@@ -563,17 +561,23 @@ function main() {
|
|
|
563
561
|
console.log();
|
|
564
562
|
if (opts.deep) {
|
|
565
563
|
console.log(
|
|
566
|
-
"Legend: * = default profile
|
|
564
|
+
"Legend: * = default profile | STATUS valid = access token fresh | valid* = access token expired but refresh_token will auto-refresh",
|
|
565
|
+
);
|
|
566
|
+
console.log(
|
|
567
|
+
" EXPIRED = access token expired and no refresh_token, must 'login <name>' again",
|
|
568
|
+
);
|
|
569
|
+
console.log(
|
|
570
|
+
" VERIFIED ✓ = 'wrangler whoami' succeeded in shadow HOME (authoritative) | ✗ = failed",
|
|
567
571
|
);
|
|
568
572
|
} else {
|
|
569
573
|
console.log(
|
|
570
|
-
"Legend: * = default profile
|
|
574
|
+
"Legend: * = default profile | STATUS valid = access token fresh | valid* = access token expired but refresh_token will auto-refresh",
|
|
571
575
|
);
|
|
572
576
|
console.log(
|
|
573
|
-
"
|
|
577
|
+
" EXPIRED = access token expired and no refresh_token, must 'login <name>' again | unknown = no expiration_time saved",
|
|
574
578
|
);
|
|
575
579
|
console.log(
|
|
576
|
-
" pass --deep (slower, makes network calls).",
|
|
580
|
+
" STATUS is file-only. For a live check against Cloudflare, pass --deep (slower, makes network calls).",
|
|
577
581
|
);
|
|
578
582
|
}
|
|
579
583
|
return;
|
|
@@ -706,33 +710,47 @@ function main() {
|
|
|
706
710
|
const shadowWranglerConfig = path.join(shadow, ".wrangler", "config");
|
|
707
711
|
fs.mkdirSync(shadowWranglerConfig, { recursive: true });
|
|
708
712
|
|
|
713
|
+
// Pre-create the profile dir so per-profile cache lands in the right
|
|
714
|
+
// place even though config.toml doesn't exist yet (login will write
|
|
715
|
+
// it). This makes WRANGLER_CACHE_DIR isolated from the very first
|
|
716
|
+
// command, including the login flow itself. We remember whether the
|
|
717
|
+
// dir existed before so we can clean it up if login fails.
|
|
718
|
+
const profileDir = path.join(profilesDir, name);
|
|
719
|
+
const existed = fs.existsSync(path.join(profileDir, "config.toml"));
|
|
720
|
+
const profileDirExistedBefore = fs.existsSync(profileDir);
|
|
721
|
+
ensureDir(profileDir);
|
|
722
|
+
const futureProfileCfg = path.join(profileDir, "config.toml");
|
|
723
|
+
|
|
709
724
|
const env = buildIsolatedEnv({
|
|
710
725
|
shadow,
|
|
711
726
|
realHome,
|
|
712
727
|
profile: name,
|
|
728
|
+
profileCfg: futureProfileCfg,
|
|
713
729
|
baseEnv: process.env,
|
|
714
730
|
cloudflaredPath: findCloudflared(),
|
|
715
731
|
});
|
|
716
|
-
|
|
717
|
-
const profileDir = path.join(profilesDir, name);
|
|
718
|
-
const existed = fs.existsSync(profileDir);
|
|
719
732
|
let identity = null;
|
|
733
|
+
let loginSucceeded = false;
|
|
720
734
|
|
|
735
|
+
// Use throw + catch + finally so cleanup always runs. die() calls
|
|
736
|
+
// process.exit() synchronously, which would skip the finally block —
|
|
737
|
+
// and that would leave a half-created profile dir behind on failure.
|
|
738
|
+
let errorMsg = null;
|
|
721
739
|
try {
|
|
722
740
|
const loginResult = spawnSync("wrangler", ["login"], {
|
|
723
741
|
stdio: "inherit",
|
|
724
742
|
env,
|
|
725
743
|
});
|
|
726
744
|
if (loginResult.error) {
|
|
727
|
-
|
|
745
|
+
throw new Error(`Failed to run 'wrangler login': ${loginResult.error.message}`);
|
|
728
746
|
}
|
|
729
747
|
if (loginResult.status !== 0) {
|
|
730
|
-
|
|
748
|
+
throw new Error(`'wrangler login' exited with code ${loginResult.status}`);
|
|
731
749
|
}
|
|
732
750
|
|
|
733
751
|
const freshCfg = path.join(shadowWranglerConfig, "default.toml");
|
|
734
752
|
if (!fs.existsSync(freshCfg)) {
|
|
735
|
-
|
|
753
|
+
throw new Error(`wrangler login completed but no config was written at ${freshCfg}`);
|
|
736
754
|
}
|
|
737
755
|
|
|
738
756
|
// Verify identity via `wrangler whoami` in the same shadow.
|
|
@@ -743,18 +761,30 @@ function main() {
|
|
|
743
761
|
const output = `${whoamiResult.stdout || ""}\n${whoamiResult.stderr || ""}`;
|
|
744
762
|
identity = parseWranglerWhoamiOutput(output);
|
|
745
763
|
if (!identity) {
|
|
746
|
-
|
|
764
|
+
throw new Error("Login succeeded but could not parse 'wrangler whoami' output");
|
|
747
765
|
}
|
|
748
766
|
|
|
749
767
|
// Move the fresh config into the profile directory. Use writeFile
|
|
750
768
|
// (copy) so the profile config is a real file, not a symlink.
|
|
751
|
-
|
|
769
|
+
// (profileDir was already pre-created above for cache isolation.)
|
|
752
770
|
const destCfg = path.join(profileDir, "config.toml");
|
|
753
771
|
fs.copyFileSync(freshCfg, destCfg);
|
|
754
772
|
writeMeta(profileDir, name, destCfg, identity);
|
|
773
|
+
loginSucceeded = true;
|
|
774
|
+
} catch (err) {
|
|
775
|
+
errorMsg = err.message;
|
|
755
776
|
} finally {
|
|
756
777
|
cleanupShadow(shadow);
|
|
778
|
+
// If login failed AND we created the profile dir as a side effect
|
|
779
|
+
// of cache isolation (it didn't exist before), clean it up so the
|
|
780
|
+
// user doesn't see a half-empty profile.
|
|
781
|
+
if (!loginSucceeded && !profileDirExistedBefore) {
|
|
782
|
+
try {
|
|
783
|
+
fs.rmSync(profileDir, { recursive: true, force: true });
|
|
784
|
+
} catch {}
|
|
785
|
+
}
|
|
757
786
|
}
|
|
787
|
+
if (errorMsg) die(errorMsg);
|
|
758
788
|
|
|
759
789
|
const note = existed ? " (overwritten)" : "";
|
|
760
790
|
if (opts.json) {
|
package/lib/isolation.js
CHANGED
|
@@ -85,12 +85,15 @@ function cleanupShadow(shadow) {
|
|
|
85
85
|
|
|
86
86
|
/**
|
|
87
87
|
* Build the environment variable set that every isolated child process gets.
|
|
88
|
-
*
|
|
88
|
+
*
|
|
89
|
+
* Note: not pure — when profileCfg is provided, ensures the per-profile
|
|
90
|
+
* cache directory exists so wrangler doesn't ENOENT on first write.
|
|
89
91
|
*/
|
|
90
92
|
function buildIsolatedEnv({
|
|
91
93
|
shadow,
|
|
92
94
|
realHome,
|
|
93
95
|
profile,
|
|
96
|
+
profileCfg = null,
|
|
94
97
|
baseEnv = process.env,
|
|
95
98
|
cloudflaredPath = null,
|
|
96
99
|
}) {
|
|
@@ -100,9 +103,40 @@ function buildIsolatedEnv({
|
|
|
100
103
|
env.WRANGLER_ACCOUNT = profile;
|
|
101
104
|
env.WRANGLER_ACCOUNT_REAL_HOME = realHome;
|
|
102
105
|
env.WRANGLER_REGISTRY_PATH = path.join(realHome, '.wrangler', 'registry');
|
|
103
|
-
env.WRANGLER_CACHE_DIR = path.join(realHome, '.wrangler', 'cache');
|
|
104
106
|
env.WRANGLER_LOG_PATH = path.join(realHome, '.wrangler', 'logs');
|
|
105
107
|
env.WRANGLER_SEND_METRICS = 'false';
|
|
108
|
+
|
|
109
|
+
// CRITICAL: Wrangler caches the user's selected Cloudflare account ID
|
|
110
|
+
// in `wrangler-account.json` inside `getCacheFolder()`. If multiple
|
|
111
|
+
// profiles share one cache directory, profile A's OAuth token can be
|
|
112
|
+
// paired with profile B's cached account ID, causing wrangler to
|
|
113
|
+
// write to the WRONG ACCOUNT — silently, until something like an R2
|
|
114
|
+
// bucket gets created in the wrong place.
|
|
115
|
+
//
|
|
116
|
+
// The cache must be per-profile. We point WRANGLER_CACHE_DIR at a
|
|
117
|
+
// `cache/` directory next to the profile's config.toml. Wrangler's
|
|
118
|
+
// getCacheFolder() honors this env var and skips its own cwd-based
|
|
119
|
+
// discovery (cli.js:62549).
|
|
120
|
+
//
|
|
121
|
+
// Earlier versions of this tool (≤1.2.1) pointed WRANGLER_CACHE_DIR
|
|
122
|
+
// at real $HOME/.wrangler/cache hoping to "share workerd cache" —
|
|
123
|
+
// that was wrong. workerd/cloudflared binaries live elsewhere
|
|
124
|
+
// (CLOUDFLARED_PATH / node_modules); WRANGLER_CACHE_DIR is only for
|
|
125
|
+
// config-cache files like wrangler-account.json and
|
|
126
|
+
// pages-config-cache.json.
|
|
127
|
+
if (profileCfg) {
|
|
128
|
+
const profileDir = path.dirname(profileCfg);
|
|
129
|
+
const cacheDir = path.join(profileDir, 'cache');
|
|
130
|
+
try {
|
|
131
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
132
|
+
} catch (err) {
|
|
133
|
+
process.stderr.write(
|
|
134
|
+
`[wrangler-accounts] could not create per-profile cache dir ${cacheDir}: ${err.message}\n`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
env.WRANGLER_CACHE_DIR = cacheDir;
|
|
138
|
+
}
|
|
139
|
+
|
|
106
140
|
if (cloudflaredPath) {
|
|
107
141
|
env.CLOUDFLARED_PATH = cloudflaredPath;
|
|
108
142
|
}
|
|
@@ -144,6 +178,7 @@ function runIsolated({
|
|
|
144
178
|
shadow,
|
|
145
179
|
realHome,
|
|
146
180
|
profile,
|
|
181
|
+
profileCfg,
|
|
147
182
|
baseEnv,
|
|
148
183
|
cloudflaredPath,
|
|
149
184
|
});
|
package/lib/profile-store.js
CHANGED
|
@@ -39,20 +39,72 @@ function readExpirationTime(filePath) {
|
|
|
39
39
|
return match ? match[1] : null;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
function hasRefreshToken(filePath) {
|
|
43
|
+
if (!fs.existsSync(filePath)) return false;
|
|
44
|
+
const text = fs.readFileSync(filePath, 'utf8');
|
|
45
|
+
// Any non-empty string value counts. Cloudflare's offline_access scope
|
|
46
|
+
// causes wrangler to store a refresh_token; profiles without that scope
|
|
47
|
+
// won't have one, and those are "really expired" once the access token
|
|
48
|
+
// runs out (~1h).
|
|
49
|
+
return /^\s*refresh_token\s*=\s*"[^"]+"/m.test(text);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read the session state of a profile config.toml.
|
|
54
|
+
*
|
|
55
|
+
* Returns:
|
|
56
|
+
* expirationTime: ISO string of access_token expiry, or null
|
|
57
|
+
* expired: boolean — access_token past expirationTime, or null
|
|
58
|
+
* hasRefreshToken: boolean — whether the profile has a refresh_token
|
|
59
|
+
* that wrangler will auto-use for silent refresh
|
|
60
|
+
* effective: 'valid' | 'refreshable' | 'expired' | 'unknown'
|
|
61
|
+
* - 'valid': access_token currently valid
|
|
62
|
+
* - 'refreshable': access_token past expiry but refresh_token present,
|
|
63
|
+
* so wrangler will auto-refresh on next use (no user
|
|
64
|
+
* action needed)
|
|
65
|
+
* - 'expired': access_token past expiry AND no refresh_token,
|
|
66
|
+
* profile is genuinely broken, user must re-login
|
|
67
|
+
* - 'unknown': no expiration_time field, can't tell statically
|
|
68
|
+
*/
|
|
42
69
|
function readSessionState(filePath) {
|
|
43
70
|
const expirationTime = readExpirationTime(filePath);
|
|
71
|
+
const refreshable = hasRefreshToken(filePath);
|
|
72
|
+
|
|
44
73
|
if (!expirationTime) {
|
|
45
|
-
return {
|
|
74
|
+
return {
|
|
75
|
+
expirationTime: null,
|
|
76
|
+
expired: null,
|
|
77
|
+
hasRefreshToken: refreshable,
|
|
78
|
+
effective: 'unknown',
|
|
79
|
+
};
|
|
46
80
|
}
|
|
47
81
|
|
|
48
82
|
const expiresAt = new Date(expirationTime);
|
|
49
83
|
if (Number.isNaN(expiresAt.getTime())) {
|
|
50
|
-
return {
|
|
84
|
+
return {
|
|
85
|
+
expirationTime,
|
|
86
|
+
expired: null,
|
|
87
|
+
hasRefreshToken: refreshable,
|
|
88
|
+
effective: 'unknown',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const expired = expiresAt.getTime() <= Date.now();
|
|
93
|
+
|
|
94
|
+
let effective;
|
|
95
|
+
if (!expired) {
|
|
96
|
+
effective = 'valid';
|
|
97
|
+
} else if (refreshable) {
|
|
98
|
+
effective = 'refreshable';
|
|
99
|
+
} else {
|
|
100
|
+
effective = 'expired';
|
|
51
101
|
}
|
|
52
102
|
|
|
53
103
|
return {
|
|
54
104
|
expirationTime,
|
|
55
|
-
expired
|
|
105
|
+
expired,
|
|
106
|
+
hasRefreshToken: refreshable,
|
|
107
|
+
effective,
|
|
56
108
|
};
|
|
57
109
|
}
|
|
58
110
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leeguoo/wrangler-accounts",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "Cloudflare Wrangler multi-account manager — save, switch, and run wrangler against multiple Cloudflare Workers accounts with AWS-style --profile and per-invocation shadow HOME isolation.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
7
7
|
"wrangler-accounts": "bin/wrangler-accounts.js"
|
|
@@ -20,7 +20,26 @@
|
|
|
20
20
|
"accounts",
|
|
21
21
|
"multi-account",
|
|
22
22
|
"account-switcher",
|
|
23
|
-
"
|
|
23
|
+
"account-manager",
|
|
24
|
+
"profile",
|
|
25
|
+
"profiles",
|
|
26
|
+
"oauth",
|
|
27
|
+
"login",
|
|
28
|
+
"aws-style",
|
|
29
|
+
"aws-vault",
|
|
30
|
+
"wrangler-login",
|
|
31
|
+
"wrangler-profile",
|
|
32
|
+
"wrangler-accounts",
|
|
33
|
+
"cloudflare-account",
|
|
34
|
+
"multiple-accounts",
|
|
35
|
+
"agent-skill",
|
|
36
|
+
"skill",
|
|
37
|
+
"skills-sh",
|
|
38
|
+
"claude-code",
|
|
39
|
+
"cursor",
|
|
40
|
+
"codex",
|
|
41
|
+
"cli",
|
|
42
|
+
"devtools"
|
|
24
43
|
],
|
|
25
44
|
"repository": {
|
|
26
45
|
"type": "git",
|
|
@@ -77,13 +77,26 @@ Use `--json` for structured output.
|
|
|
77
77
|
### List and inspect profiles
|
|
78
78
|
|
|
79
79
|
- `wrangler-accounts list` — text table with NAME / STATUS / EXPIRES / IDENTITY columns
|
|
80
|
-
- `wrangler-accounts list --json` — structured: array of `{name, isDefault, isActive, status, expirationTime, identity}`
|
|
80
|
+
- `wrangler-accounts list --json` — structured: array of `{name, isDefault, isActive, status, expirationTime, hasRefreshToken, identity, verified, verifyError}`
|
|
81
81
|
- `wrangler-accounts list --plain` — one profile name per line (scriptable)
|
|
82
82
|
- `wrangler-accounts list --deep` — **authoritative** check: spawns `wrangler whoami` in a shadow HOME for every profile and reports whether Cloudflare actually accepts the credentials. Slower (makes network calls), but the only way to catch revoked refresh tokens or broken profile files.
|
|
83
83
|
- `wrangler-accounts status` / `status --json`
|
|
84
84
|
- Pass `--include-backups` to show hidden backup profiles.
|
|
85
85
|
|
|
86
|
-
**
|
|
86
|
+
**STATUS values (1.3.0+):**
|
|
87
|
+
|
|
88
|
+
| value | meaning | user action |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `valid` | access_token is currently valid | none |
|
|
91
|
+
| `valid*` / `refreshable` | access_token past expiry **BUT** refresh_token present; wrangler will auto-refresh on next use | **none** — this is fine, don't scare the user |
|
|
92
|
+
| `EXPIRED` / `expired` | access_token expired **AND** no refresh_token saved; profile is genuinely broken | `wrangler-accounts login <name>` |
|
|
93
|
+
| `unknown` | profile file has no `expiration_time` field | run `list --deep` to verify live |
|
|
94
|
+
|
|
95
|
+
**Cloudflare OAuth lifecycle reference:** access tokens are short-lived (~1 hour) by design. Every profile with `offline_access` in its scopes also has a long-lived refresh_token (~30 days, silently extended on use). Wrangler refreshes access tokens automatically whenever it runs a command and the current one is past expiry. **Do not tell the user to re-login just because `list` shows an expired access token** — check `hasRefreshToken` first. If the profile's STATUS is `valid*` / `refreshable`, nothing is wrong.
|
|
96
|
+
|
|
97
|
+
The only time a user actually needs `wrangler-accounts login <name>` again is:
|
|
98
|
+
1. STATUS is `EXPIRED` (no refresh_token at all — profile was saved without `offline_access` scope)
|
|
99
|
+
2. OR `list --deep` returns `✗` with "Not logged in" / "refresh token may be revoked" (refresh token itself got invalidated)
|
|
87
100
|
|
|
88
101
|
### Save, sync, login, remove
|
|
89
102
|
|
|
@@ -238,6 +251,56 @@ Or users can add a shell alias: `alias realhome='cd "$WRANGLER_ACCOUNT_REAL_HOME
|
|
|
238
251
|
- "I want this account to stick for a while" → `wrangler-accounts default <name>`
|
|
239
252
|
- "Just this one command" → `wrangler-accounts --profile <name> <wrangler-args>`
|
|
240
253
|
|
|
254
|
+
### `[ERROR] A request to the Cloudflare API ... Authentication error [code: 10000]` with `code: 7403` ("not authorized to access this service")
|
|
255
|
+
|
|
256
|
+
The OAuth token is fine but **the URL contains the wrong account ID**. wrangler caches the user's selected account in `wrangler-account.json`. If that cache file is shared across profiles, profile A's OAuth token gets paired with profile B's cached account ID, sending API calls to the wrong account. Symptoms:
|
|
257
|
+
|
|
258
|
+
- `deploy` and `secret put` succeed (they don't put account ID in the URL path)
|
|
259
|
+
- `d1 execute --remote`, `r2 object get/put`, anything else with `/accounts/<id>/...` in the URL fails with 7403
|
|
260
|
+
|
|
261
|
+
**Fix path** (in order):
|
|
262
|
+
|
|
263
|
+
1. **Are you on wrangler-accounts ≥ 1.2.2?** Run `wrangler-accounts --version`. If `< 1.2.2`, upgrade — earlier versions pointed `WRANGLER_CACHE_DIR` at a shared global path. 1.2.2 isolates the cache per profile.
|
|
264
|
+
2. **Clear the polluted shared cache** (one-time, even after upgrading):
|
|
265
|
+
```bash
|
|
266
|
+
rm -f ~/.wrangler/cache/wrangler-account.json
|
|
267
|
+
rm -f ~/Library/Preferences/.wrangler/cache/wrangler-account.json # macOS env-paths fallback
|
|
268
|
+
```
|
|
269
|
+
3. **Verify with `wrangler-accounts list --deep`** — the VERIFIED column for each profile should be `✓ ok`. If `✗`, the underlying OAuth session itself is broken; run `wrangler-accounts login <name>`.
|
|
270
|
+
4. **Defense in depth**: set `CLOUDFLARE_ACCOUNT_ID=<correct-id>` in the calling environment. wrangler reads this env var directly and bypasses the cache entirely. Useful for scripts or one-off recovery commands:
|
|
271
|
+
```bash
|
|
272
|
+
CLOUDFLARE_ACCOUNT_ID=<id> wrangler-accounts <profile> r2 object put ...
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
**What to tell the user**: "wrangler returned 7403 because it cached the wrong account ID alongside your OAuth token. This was a real bug in wrangler-accounts ≤ 1.2.1 (shared cache directory across profiles). Upgrade to 1.2.2 and clear the polluted cache."
|
|
276
|
+
|
|
277
|
+
### "The OAuth config seems right, but the wrong account is being used"
|
|
278
|
+
|
|
279
|
+
Same root cause as the 7403 above. Default to the same fix path.
|
|
280
|
+
|
|
281
|
+
### `wrangler dev` (or any `--local` command) shows stale data after switching profiles
|
|
282
|
+
|
|
283
|
+
Project-local state at `<project>/.wrangler/state/` is **NOT** isolated per profile — wrangler's `getLocalPersistencePath` (cli.js:149025) hardcodes the path next to `wrangler.toml` and the only override is the `--persist-to` CLI flag (no env var hook). So if profile A's `wrangler dev` populated a local D1 emulation, then you switch to profile B and run `wrangler dev` in the same directory, B sees A's emulated rows.
|
|
284
|
+
|
|
285
|
+
This only affects `--local` simulations. **`--remote` commands hit Cloudflare directly and are unaffected** — that's the common case for d1/r2 work in a multi-account setup.
|
|
286
|
+
|
|
287
|
+
Two clean fixes:
|
|
288
|
+
|
|
289
|
+
1. **Use git worktrees** (recommended for any serious multi-profile dev workflow):
|
|
290
|
+
```bash
|
|
291
|
+
git worktree add ../my-project-work main
|
|
292
|
+
git worktree add ../my-project-personal main
|
|
293
|
+
cd ../my-project-work && wrangler-accounts exec work # isolated .wrangler/state/
|
|
294
|
+
cd ../my-project-personal && wrangler-accounts exec personal
|
|
295
|
+
```
|
|
296
|
+
2. **Clear state manually before switching**:
|
|
297
|
+
```bash
|
|
298
|
+
rm -rf .wrangler/state
|
|
299
|
+
wrangler-accounts --profile <new> dev
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
`wrangler-accounts` does not auto-isolate `.wrangler/state/` because the only mechanism would be argv injection of `--persist-to`, which has too many failure modes (different subcommands accept persistTo at different positions, can't override user-supplied flags, path selection is ambiguous between per-profile and per-profile-per-project). The honest tradeoff is documented in the "What is and isn't isolated" table above — partial isolation with hidden gotchas would be worse than honest sharing.
|
|
303
|
+
|
|
241
304
|
### Shell history / `.zsh_history` seems to grow when running `exec`
|
|
242
305
|
|
|
243
306
|
Intentional. By design the shadow HOME symlinks all top-level entries of real HOME except `.wrangler`, so shell history writes pass through to the real file. This is a **convenience** bias, not a clean-room sandbox — the goal is that `exec` subshells feel like a normal terminal with a different Cloudflare account, not a jail.
|
|
@@ -245,10 +308,25 @@ Intentional. By design the shadow HOME symlinks all top-level entries of real HO
|
|
|
245
308
|
## Invariants the AI should rely on
|
|
246
309
|
|
|
247
310
|
- **Real `~/.wrangler/config/default.toml` is never written to by `wrangler-accounts`.** If a user reports that it changed, something else touched it (e.g. a direct `wrangler login` outside `wrangler-accounts`).
|
|
248
|
-
- **Two `wrangler-accounts --profile A` and `wrangler-accounts --profile B` running in parallel never clobber each other.** Each gets its own `mkdtemp` shadow HOME.
|
|
311
|
+
- **Two `wrangler-accounts --profile A` and `wrangler-accounts --profile B` running in parallel never clobber each other on credentials OR account-id cache.** Each gets its own `mkdtemp` shadow HOME, and each gets its own per-profile `WRANGLER_CACHE_DIR` (next to the profile's `config.toml`) so that wrangler's `wrangler-account.json` (which stores the selected Cloudflare account ID) is naturally isolated.
|
|
249
312
|
- **OAuth token refresh inside a profile is automatic.** The shadow HOME contains a symlink from `.wrangler/config/default.toml` to the saved profile file, so Wrangler's in-place `fs.writeFileSync` during `refreshToken()` flows straight back to the profile.
|
|
250
313
|
- **`wrangler-accounts <args>` without a management subcommand forwards everything to wrangler verbatim**, including `--env`, `--dry-run`, `--json`, and any wrangler-native flags. The only flags consumed by `wrangler-accounts` itself are the ones listed in "Paths and environment" below.
|
|
251
314
|
|
|
315
|
+
## What is and isn't isolated
|
|
316
|
+
|
|
317
|
+
| State | Location | Isolated? |
|
|
318
|
+
|---|---|---|
|
|
319
|
+
| OAuth credentials (`config.toml`) | shadow `$HOME/.wrangler/config/default.toml` → symlink to per-profile file | ✅ per profile |
|
|
320
|
+
| Account-id cache (`wrangler-account.json`) | per-profile `WRANGLER_CACHE_DIR` (= `<profilesDir>/<name>/cache/`) | ✅ per profile |
|
|
321
|
+
| Pages config cache (`pages-config-cache.json`) | same as above | ✅ per profile |
|
|
322
|
+
| Miniflare dev registry | `WRANGLER_REGISTRY_PATH` = `$realHome/.wrangler/registry` | ❌ shared on purpose (cross-profile worker discovery during local dev) |
|
|
323
|
+
| Wrangler debug logs | `WRANGLER_LOG_PATH` = `$realHome/.wrangler/logs` | ❌ shared (append-only, harmless) |
|
|
324
|
+
| Project-local state (`./.wrangler/state/`, `./node_modules/.cache/wrangler`) | inside the project directory | ❌ shared at project level (per-project, but not per-profile) |
|
|
325
|
+
| `cloudflared` binary | `CLOUDFLARED_PATH` or `~/.wrangler/cloudflared/` | ❌ shared (binary, not account-scoped) |
|
|
326
|
+
| Shell history, npm cache, git config, ssh keys | symlinked through to real `$HOME` | ❌ shared by design (so `exec` subshells feel like a normal terminal) |
|
|
327
|
+
|
|
328
|
+
If a user is hitting a "wrong account" symptom and the credentials look right, the most likely culprit is **project-local state** in `./.wrangler/state/` — clear that and re-run.
|
|
329
|
+
|
|
252
330
|
## CI guidance
|
|
253
331
|
|
|
254
332
|
For CI and deploy pipelines, **use `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` with plain `wrangler`**, not saved OAuth profiles. `wrangler-accounts` is a local developer convenience for juggling OAuth sessions on your workstation, not a CI primitive.
|