@leeguoo/wrangler-accounts 1.2.2 → 1.4.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/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;
|
|
@@ -691,6 +695,64 @@ function main() {
|
|
|
691
695
|
if (!isValidName(name)) die(`Invalid profile name: ${name}`);
|
|
692
696
|
ensureDir(profilesDir);
|
|
693
697
|
|
|
698
|
+
// Guard 1: refuse to run in a non-interactive context (CI, sub-agent,
|
|
699
|
+
// pipe). 'wrangler login' opens a browser and requires the user to
|
|
700
|
+
// click an authorize button. In a non-TTY context this hangs forever
|
|
701
|
+
// and any attempt is almost certainly an AI/script applying 'login'
|
|
702
|
+
// as if it were idempotent — it isn't.
|
|
703
|
+
if (!process.stdin.isTTY && !opts.force) {
|
|
704
|
+
die(
|
|
705
|
+
[
|
|
706
|
+
`'login' requires an interactive terminal — wrangler will open a browser`,
|
|
707
|
+
`and wait for authorization. Stdin is not a TTY here.`,
|
|
708
|
+
``,
|
|
709
|
+
`If you are an AI agent or script trying to verify a profile is`,
|
|
710
|
+
`working, do NOT use 'login'. Use one of these instead:`,
|
|
711
|
+
``,
|
|
712
|
+
` wrangler-accounts whoami --profile ${name} # static check (meta.json)`,
|
|
713
|
+
` wrangler-accounts list --deep # live check (network call)`,
|
|
714
|
+
``,
|
|
715
|
+
`If you really need to re-authenticate this profile non-interactively,`,
|
|
716
|
+
`pass --force to bypass this guard (the OAuth flow will still need a`,
|
|
717
|
+
`browser to complete).`,
|
|
718
|
+
].join("\n"),
|
|
719
|
+
1,
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Guard 2: refuse to overwrite an existing profile that's already
|
|
724
|
+
// healthy unless --force is passed. 'login' is destructive — it
|
|
725
|
+
// OVERWRITES the saved profile by design. If the profile is already
|
|
726
|
+
// valid, the caller almost certainly meant to verify, not re-create.
|
|
727
|
+
const existingCfg = path.join(profilesDir, name, "config.toml");
|
|
728
|
+
if (fs.existsSync(existingCfg) && !opts.force) {
|
|
729
|
+
const session = readSessionState(existingCfg);
|
|
730
|
+
const looksHealthy = session.effective === "valid" || session.effective === "refreshable";
|
|
731
|
+
if (looksHealthy) {
|
|
732
|
+
die(
|
|
733
|
+
[
|
|
734
|
+
`Profile '${name}' already exists and looks healthy:`,
|
|
735
|
+
` status: ${session.effective}`,
|
|
736
|
+
` expirationTime: ${session.expirationTime || "(none)"}`,
|
|
737
|
+
` hasRefreshToken: ${session.hasRefreshToken}`,
|
|
738
|
+
``,
|
|
739
|
+
`'login' is DESTRUCTIVE — it opens a browser and overwrites the saved`,
|
|
740
|
+
`profile. If you only wanted to verify the profile works, run instead:`,
|
|
741
|
+
``,
|
|
742
|
+
` wrangler-accounts whoami --profile ${name} # fast, no network`,
|
|
743
|
+
` wrangler-accounts list --deep # authoritative, hits Cloudflare API`,
|
|
744
|
+
``,
|
|
745
|
+
`If you really intend to re-authenticate (e.g. you revoked the token`,
|
|
746
|
+
`in the Cloudflare dashboard, or want to switch which OAuth account`,
|
|
747
|
+
`this profile is bound to), pass --force:`,
|
|
748
|
+
``,
|
|
749
|
+
` wrangler-accounts login ${name} --force`,
|
|
750
|
+
].join("\n"),
|
|
751
|
+
1,
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
694
756
|
// Create a shadow HOME without pre-linking .wrangler/config/default.toml.
|
|
695
757
|
// wrangler login will write a fresh file into shadow/.wrangler/config/
|
|
696
758
|
// which we then move into the profile directory.
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leeguoo/wrangler-accounts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
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": {
|
|
@@ -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
|
|
|
@@ -147,6 +160,34 @@ wrangler-accounts list # confirm the profile is saved
|
|
|
147
160
|
|
|
148
161
|
The login flow runs inside an isolated shadow `HOME`, so the user's real `~/.wrangler/config/default.toml` is never touched.
|
|
149
162
|
|
|
163
|
+
> ⚠️ **`login` is destructive.** It opens a browser, requires the user to click "Authorize" interactively, and **OVERWRITES** the named profile. As of 1.4.0, `wrangler-accounts login <name>` refuses to run if (a) stdin is not a TTY, or (b) the profile already exists and looks healthy — both unless you pass `--force`. **Never run `login` to "verify" or "refresh" a profile** — see the antipattern below.
|
|
164
|
+
|
|
165
|
+
### ❌ Antipattern: running `login` to verify a profile works
|
|
166
|
+
|
|
167
|
+
This is wrong:
|
|
168
|
+
```bash
|
|
169
|
+
wrangler-accounts login Xdreamstar2025 # ❌ DON'T do this just to check
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Reasons:
|
|
173
|
+
1. `login` is **destructive** — it overwrites the saved profile with a brand new OAuth flow.
|
|
174
|
+
2. `login` requires a **browser and an interactive terminal** — it cannot complete in a Bash sub-shell, CI runner, or sub-agent context. The command will hang waiting for the user.
|
|
175
|
+
3. The Cloudflare access token in a healthy profile auto-refreshes via `refresh_token` — there is **nothing to "log in to"** when the profile already works.
|
|
176
|
+
|
|
177
|
+
Use one of these instead:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
wrangler-accounts whoami --profile Xdreamstar2025 # fast, reads meta.json, no network
|
|
181
|
+
wrangler-accounts list --deep # authoritative, runs wrangler whoami per profile
|
|
182
|
+
wrangler-accounts list # quick STATUS overview (valid / valid* / EXPIRED / unknown)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Only fall back to `wrangler-accounts login <name>` when:
|
|
186
|
+
- The profile **does not exist yet** (creating a new account profile from scratch)
|
|
187
|
+
- The profile shows `EXPIRED` (truly expired, no refresh_token left) — see STATUS table above
|
|
188
|
+
- `list --deep` returns `✗` with "Not logged in" / "refresh token may be revoked" (server-side revocation)
|
|
189
|
+
- The user **explicitly says** "re-authenticate this profile" / "log me in again"
|
|
190
|
+
|
|
150
191
|
### User wants: check which account a profile is tied to, without running wrangler
|
|
151
192
|
|
|
152
193
|
```bash
|
|
@@ -238,6 +279,56 @@ Or users can add a shell alias: `alias realhome='cd "$WRANGLER_ACCOUNT_REAL_HOME
|
|
|
238
279
|
- "I want this account to stick for a while" → `wrangler-accounts default <name>`
|
|
239
280
|
- "Just this one command" → `wrangler-accounts --profile <name> <wrangler-args>`
|
|
240
281
|
|
|
282
|
+
### `[ERROR] A request to the Cloudflare API ... Authentication error [code: 10000]` with `code: 7403` ("not authorized to access this service")
|
|
283
|
+
|
|
284
|
+
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:
|
|
285
|
+
|
|
286
|
+
- `deploy` and `secret put` succeed (they don't put account ID in the URL path)
|
|
287
|
+
- `d1 execute --remote`, `r2 object get/put`, anything else with `/accounts/<id>/...` in the URL fails with 7403
|
|
288
|
+
|
|
289
|
+
**Fix path** (in order):
|
|
290
|
+
|
|
291
|
+
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.
|
|
292
|
+
2. **Clear the polluted shared cache** (one-time, even after upgrading):
|
|
293
|
+
```bash
|
|
294
|
+
rm -f ~/.wrangler/cache/wrangler-account.json
|
|
295
|
+
rm -f ~/Library/Preferences/.wrangler/cache/wrangler-account.json # macOS env-paths fallback
|
|
296
|
+
```
|
|
297
|
+
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>`.
|
|
298
|
+
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:
|
|
299
|
+
```bash
|
|
300
|
+
CLOUDFLARE_ACCOUNT_ID=<id> wrangler-accounts <profile> r2 object put ...
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
**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."
|
|
304
|
+
|
|
305
|
+
### "The OAuth config seems right, but the wrong account is being used"
|
|
306
|
+
|
|
307
|
+
Same root cause as the 7403 above. Default to the same fix path.
|
|
308
|
+
|
|
309
|
+
### `wrangler dev` (or any `--local` command) shows stale data after switching profiles
|
|
310
|
+
|
|
311
|
+
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.
|
|
312
|
+
|
|
313
|
+
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.
|
|
314
|
+
|
|
315
|
+
Two clean fixes:
|
|
316
|
+
|
|
317
|
+
1. **Use git worktrees** (recommended for any serious multi-profile dev workflow):
|
|
318
|
+
```bash
|
|
319
|
+
git worktree add ../my-project-work main
|
|
320
|
+
git worktree add ../my-project-personal main
|
|
321
|
+
cd ../my-project-work && wrangler-accounts exec work # isolated .wrangler/state/
|
|
322
|
+
cd ../my-project-personal && wrangler-accounts exec personal
|
|
323
|
+
```
|
|
324
|
+
2. **Clear state manually before switching**:
|
|
325
|
+
```bash
|
|
326
|
+
rm -rf .wrangler/state
|
|
327
|
+
wrangler-accounts --profile <new> dev
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
`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.
|
|
331
|
+
|
|
241
332
|
### Shell history / `.zsh_history` seems to grow when running `exec`
|
|
242
333
|
|
|
243
334
|
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.
|