@leeguoo/wrangler-accounts 1.1.1 → 1.2.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/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
- - `WRANGLER_REGISTRY_PATH`, `WRANGLER_CACHE_DIR`, `WRANGLER_LOG_PATH`pointed back at real HOME so Miniflare dev registry, workerd cache, and debug logs are shared across profiles
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
- ## Discoverability (SEO / GEO / AI search)
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
- Cloudflare Wrangler multi-account switcher for global teams.
168
- Keywords: Cloudflare Workers account manager, Wrangler login profiles, multi-account, account switcher, AWS-style profile, per-invocation isolation, OAuth profile management.
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
 
@@ -236,6 +236,8 @@ function parseArgs(argv) {
236
236
  opts.backup = false;
237
237
  } else if (arg === "--unset") {
238
238
  opts.unset = true;
239
+ } else if (arg === "--deep" || arg === "--verify") {
240
+ opts.deep = true;
239
241
  } else if (arg === "--config" || arg === "-c") {
240
242
  opts.config = argv[i + 1];
241
243
  if (!opts.config) die("Missing value for --config");
@@ -451,9 +453,59 @@ function main() {
451
453
  status,
452
454
  expirationTime: session.expirationTime,
453
455
  identity,
456
+ verified: null,
457
+ verifyError: null,
454
458
  };
455
459
  });
456
460
 
461
+ // --deep: actually run `wrangler whoami` inside a shadow HOME for
462
+ // each profile. This is the only authoritative check — the fast
463
+ // status column above is derived purely from the saved
464
+ // expiration_time, which does not tell us whether the refresh token
465
+ // still works or whether Cloudflare has revoked the session.
466
+ if (opts.deep) {
467
+ if (entries.length > 0 && !opts.json) {
468
+ process.stderr.write(
469
+ `[wrangler-accounts] running deep check (wrangler whoami) for ${entries.length} profile(s)...\n`,
470
+ );
471
+ }
472
+ const cloudflaredPath = findCloudflared();
473
+ for (const e of entries) {
474
+ const profileCfg = path.join(profilesDir, e.name, "config.toml");
475
+ try {
476
+ const r = runIsolated({
477
+ profile: e.name,
478
+ profileCfg,
479
+ realHome: os.homedir(),
480
+ command: "wrangler",
481
+ args: ["whoami"],
482
+ baseEnv: process.env,
483
+ captureStdout: true,
484
+ cloudflaredPath,
485
+ });
486
+ const output = `${r.stdout || ""}\n${r.stderr || ""}`;
487
+ if (r.exitCode === 0) {
488
+ const live = parseWranglerWhoamiOutput(output);
489
+ if (live) {
490
+ e.verified = true;
491
+ e.liveIdentity = live;
492
+ } else {
493
+ e.verified = false;
494
+ e.verifyError = "could not parse wrangler whoami output";
495
+ }
496
+ } else {
497
+ e.verified = false;
498
+ e.verifyError = /not logged in/i.test(output)
499
+ ? "not logged in (refresh token may be revoked)"
500
+ : `wrangler whoami exit ${r.exitCode}`;
501
+ }
502
+ } catch (err) {
503
+ e.verified = false;
504
+ e.verifyError = err.message;
505
+ }
506
+ }
507
+ }
508
+
457
509
  if (opts.plain) {
458
510
  // --plain keeps the v1.0 contract: one name per line, scriptable.
459
511
  if (entries.length) console.log(entries.map((e) => e.name).join("\n"));
@@ -479,22 +531,51 @@ function main() {
479
531
  : e.status === "valid" ? "valid"
480
532
  : "unknown",
481
533
  expires: formatExpiry(e.expirationTime),
534
+ verified:
535
+ e.verified === true ? "✓ ok"
536
+ : e.verified === false ? `✗ ${e.verifyError || "failed"}`
537
+ : "—",
482
538
  identity: e.identity ? describeIdentity(e.identity) : "(no identity)",
483
539
  }));
484
540
  const nameW = Math.max(4, ...rows.map((r) => r.name.length));
485
541
  const statusW = Math.max(6, ...rows.map((r) => r.status.length));
486
542
  const expiresW = Math.max(7, ...rows.map((r) => r.expires.length));
487
- const header = ` ${"NAME".padEnd(nameW)} ${"STATUS".padEnd(statusW)} ${"EXPIRES".padEnd(expiresW)} IDENTITY`;
543
+ const verifiedW = Math.max(8, ...rows.map((r) => r.verified.length));
544
+
545
+ let header;
546
+ if (opts.deep) {
547
+ header = ` ${"NAME".padEnd(nameW)} ${"STATUS".padEnd(statusW)} ${"EXPIRES".padEnd(expiresW)} ${"VERIFIED".padEnd(verifiedW)} IDENTITY`;
548
+ } else {
549
+ header = ` ${"NAME".padEnd(nameW)} ${"STATUS".padEnd(statusW)} ${"EXPIRES".padEnd(expiresW)} IDENTITY`;
550
+ }
488
551
  console.log(header);
489
552
  for (const r of rows) {
553
+ if (opts.deep) {
554
+ console.log(
555
+ `${r.marker} ${r.name.padEnd(nameW)} ${r.status.padEnd(statusW)} ${r.expires.padEnd(expiresW)} ${r.verified.padEnd(verifiedW)} ${r.identity}`,
556
+ );
557
+ } else {
558
+ console.log(
559
+ `${r.marker} ${r.name.padEnd(nameW)} ${r.status.padEnd(statusW)} ${r.expires.padEnd(expiresW)} ${r.identity}`,
560
+ );
561
+ }
562
+ }
563
+ console.log();
564
+ if (opts.deep) {
565
+ console.log(
566
+ "Legend: * = default profile, VERIFIED ✓ = wrangler whoami succeeded in shadow HOME, ✗ = authoritative failure",
567
+ );
568
+ } else {
490
569
  console.log(
491
- `${r.marker} ${r.name.padEnd(nameW)} ${r.status.padEnd(statusW)} ${r.expires.padEnd(expiresW)} ${r.identity}`,
570
+ "Legend: * = default profile, EXPIRED = access token past expiration_time (wrangler may still auto-refresh)",
571
+ );
572
+ console.log(
573
+ " STATUS is derived from the saved file only. For a live check that runs 'wrangler whoami' against Cloudflare,",
574
+ );
575
+ console.log(
576
+ " pass --deep (slower, makes network calls).",
492
577
  );
493
578
  }
494
- console.log();
495
- console.log(
496
- `Legend: * = default profile, EXPIRED = OAuth session needs 'wrangler-accounts login <name>'`,
497
- );
498
579
  return;
499
580
  }
500
581
 
@@ -625,33 +706,47 @@ function main() {
625
706
  const shadowWranglerConfig = path.join(shadow, ".wrangler", "config");
626
707
  fs.mkdirSync(shadowWranglerConfig, { recursive: true });
627
708
 
709
+ // Pre-create the profile dir so per-profile cache lands in the right
710
+ // place even though config.toml doesn't exist yet (login will write
711
+ // it). This makes WRANGLER_CACHE_DIR isolated from the very first
712
+ // command, including the login flow itself. We remember whether the
713
+ // dir existed before so we can clean it up if login fails.
714
+ const profileDir = path.join(profilesDir, name);
715
+ const existed = fs.existsSync(path.join(profileDir, "config.toml"));
716
+ const profileDirExistedBefore = fs.existsSync(profileDir);
717
+ ensureDir(profileDir);
718
+ const futureProfileCfg = path.join(profileDir, "config.toml");
719
+
628
720
  const env = buildIsolatedEnv({
629
721
  shadow,
630
722
  realHome,
631
723
  profile: name,
724
+ profileCfg: futureProfileCfg,
632
725
  baseEnv: process.env,
633
726
  cloudflaredPath: findCloudflared(),
634
727
  });
635
-
636
- const profileDir = path.join(profilesDir, name);
637
- const existed = fs.existsSync(profileDir);
638
728
  let identity = null;
729
+ let loginSucceeded = false;
639
730
 
731
+ // Use throw + catch + finally so cleanup always runs. die() calls
732
+ // process.exit() synchronously, which would skip the finally block —
733
+ // and that would leave a half-created profile dir behind on failure.
734
+ let errorMsg = null;
640
735
  try {
641
736
  const loginResult = spawnSync("wrangler", ["login"], {
642
737
  stdio: "inherit",
643
738
  env,
644
739
  });
645
740
  if (loginResult.error) {
646
- die(`Failed to run 'wrangler login': ${loginResult.error.message}`);
741
+ throw new Error(`Failed to run 'wrangler login': ${loginResult.error.message}`);
647
742
  }
648
743
  if (loginResult.status !== 0) {
649
- die(`'wrangler login' exited with code ${loginResult.status}`);
744
+ throw new Error(`'wrangler login' exited with code ${loginResult.status}`);
650
745
  }
651
746
 
652
747
  const freshCfg = path.join(shadowWranglerConfig, "default.toml");
653
748
  if (!fs.existsSync(freshCfg)) {
654
- die(`wrangler login completed but no config was written at ${freshCfg}`);
749
+ throw new Error(`wrangler login completed but no config was written at ${freshCfg}`);
655
750
  }
656
751
 
657
752
  // Verify identity via `wrangler whoami` in the same shadow.
@@ -662,18 +757,30 @@ function main() {
662
757
  const output = `${whoamiResult.stdout || ""}\n${whoamiResult.stderr || ""}`;
663
758
  identity = parseWranglerWhoamiOutput(output);
664
759
  if (!identity) {
665
- die("Login succeeded but could not parse 'wrangler whoami' output");
760
+ throw new Error("Login succeeded but could not parse 'wrangler whoami' output");
666
761
  }
667
762
 
668
763
  // Move the fresh config into the profile directory. Use writeFile
669
764
  // (copy) so the profile config is a real file, not a symlink.
670
- ensureDir(profileDir);
765
+ // (profileDir was already pre-created above for cache isolation.)
671
766
  const destCfg = path.join(profileDir, "config.toml");
672
767
  fs.copyFileSync(freshCfg, destCfg);
673
768
  writeMeta(profileDir, name, destCfg, identity);
769
+ loginSucceeded = true;
770
+ } catch (err) {
771
+ errorMsg = err.message;
674
772
  } finally {
675
773
  cleanupShadow(shadow);
774
+ // If login failed AND we created the profile dir as a side effect
775
+ // of cache isolation (it didn't exist before), clean it up so the
776
+ // user doesn't see a half-empty profile.
777
+ if (!loginSucceeded && !profileDirExistedBefore) {
778
+ try {
779
+ fs.rmSync(profileDir, { recursive: true, force: true });
780
+ } catch {}
781
+ }
676
782
  }
783
+ if (errorMsg) die(errorMsg);
677
784
 
678
785
  const note = existed ? " (overwritten)" : "";
679
786
  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
- * This is a pure function — no I/O.
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,14 +178,20 @@ function runIsolated({
144
178
  shadow,
145
179
  realHome,
146
180
  profile,
181
+ profileCfg,
147
182
  baseEnv,
148
183
  cloudflaredPath,
149
184
  });
150
185
 
151
186
  let result;
152
187
  try {
188
+ // captureStdout mode is used for non-interactive checks (e.g.
189
+ // background `wrangler whoami` during `list --deep`), so we close
190
+ // stdin on the child rather than letting it read from the user's
191
+ // terminal. Normal inherit mode keeps stdin attached for interactive
192
+ // flows like `login` and `exec`.
153
193
  result = spawnSync(command, args, {
154
- stdio: captureStdout ? ['inherit', 'pipe', 'pipe'] : 'inherit',
194
+ stdio: captureStdout ? ['ignore', 'pipe', 'pipe'] : 'inherit',
155
195
  env,
156
196
  encoding: 'utf8',
157
197
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@leeguoo/wrangler-accounts",
3
- "version": "1.1.1",
4
- "description": "AWS-style multi-account convenience for Cloudflare Wrangler per-invocation OAuth isolation via shadow HOME.",
3
+ "version": "1.2.2",
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
- "cli"
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",
@@ -76,10 +76,15 @@ Use `--json` for structured output.
76
76
 
77
77
  ### List and inspect profiles
78
78
 
79
- - `wrangler-accounts list` / `list --json` / `list --plain`
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}`
81
+ - `wrangler-accounts list --plain` — one profile name per line (scriptable)
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.
80
83
  - `wrangler-accounts status` / `status --json`
81
84
  - Pass `--include-backups` to show hidden backup profiles.
82
85
 
86
+ **Accuracy note:** the `STATUS` column without `--deep` is derived purely from the saved `expiration_time` and tells you *when the access token will expire*, not whether the profile is actually usable. Wrangler auto-refreshes access tokens via the refresh token, so an `EXPIRED` access token is often fine in practice. When the user needs a real verdict, suggest `wrangler-accounts list --deep`.
87
+
83
88
  ### Save, sync, login, remove
84
89
 
85
90
  - `wrangler-accounts save <name>` — snapshot current Wrangler config as a profile
@@ -240,10 +245,25 @@ Intentional. By design the shadow HOME symlinks all top-level entries of real HO
240
245
  ## Invariants the AI should rely on
241
246
 
242
247
  - **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`).
243
- - **Two `wrangler-accounts --profile A` and `wrangler-accounts --profile B` running in parallel never clobber each other.** Each gets its own `mkdtemp` shadow HOME.
248
+ - **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.
244
249
  - **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.
245
250
  - **`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.
246
251
 
252
+ ## What is and isn't isolated
253
+
254
+ | State | Location | Isolated? |
255
+ |---|---|---|
256
+ | OAuth credentials (`config.toml`) | shadow `$HOME/.wrangler/config/default.toml` → symlink to per-profile file | ✅ per profile |
257
+ | Account-id cache (`wrangler-account.json`) | per-profile `WRANGLER_CACHE_DIR` (= `<profilesDir>/<name>/cache/`) | ✅ per profile |
258
+ | Pages config cache (`pages-config-cache.json`) | same as above | ✅ per profile |
259
+ | Miniflare dev registry | `WRANGLER_REGISTRY_PATH` = `$realHome/.wrangler/registry` | ❌ shared on purpose (cross-profile worker discovery during local dev) |
260
+ | Wrangler debug logs | `WRANGLER_LOG_PATH` = `$realHome/.wrangler/logs` | ❌ shared (append-only, harmless) |
261
+ | Project-local state (`./.wrangler/state/`, `./node_modules/.cache/wrangler`) | inside the project directory | ❌ shared at project level (per-project, but not per-profile) |
262
+ | `cloudflared` binary | `CLOUDFLARED_PATH` or `~/.wrangler/cloudflared/` | ❌ shared (binary, not account-scoped) |
263
+ | Shell history, npm cache, git config, ssh keys | symlinked through to real `$HOME` | ❌ shared by design (so `exec` subshells feel like a normal terminal) |
264
+
265
+ 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.
266
+
247
267
  ## CI guidance
248
268
 
249
269
  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.