@leeguoo/wrangler-accounts 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -96,6 +96,29 @@ function parseDuration(s) {
96
96
  return n * mult;
97
97
  }
98
98
 
99
+ // Format an ISO expiration timestamp as a compact relative + absolute
100
+ // string, e.g. "in 14d (2026-04-24)" or "30d ago (2026-03-11)".
101
+ function formatExpiry(iso, now = Date.now()) {
102
+ if (!iso) return "(unknown)";
103
+ const then = new Date(iso).getTime();
104
+ if (Number.isNaN(then)) return "(unknown)";
105
+ const delta = then - now;
106
+ const abs = Math.abs(delta);
107
+ const day = 86400000;
108
+ const hour = 3600000;
109
+ const minute = 60000;
110
+ let relative;
111
+ if (abs >= day) {
112
+ relative = `${Math.floor(abs / day)}d`;
113
+ } else if (abs >= hour) {
114
+ relative = `${Math.floor(abs / hour)}h`;
115
+ } else {
116
+ relative = `${Math.max(1, Math.floor(abs / minute))}m`;
117
+ }
118
+ const date = iso.slice(0, 10); // YYYY-MM-DD
119
+ return delta >= 0 ? `in ${relative} (${date})` : `${relative} ago (${date})`;
120
+ }
121
+
99
122
  // Thin wrappers that turn thrown errors into die() calls, so the lib
100
123
  // functions remain pure / testable without depending on process.exit.
101
124
  function saveProfile(...args) {
@@ -213,6 +236,8 @@ function parseArgs(argv) {
213
236
  opts.backup = false;
214
237
  } else if (arg === "--unset") {
215
238
  opts.unset = true;
239
+ } else if (arg === "--deep" || arg === "--verify") {
240
+ opts.deep = true;
216
241
  } else if (arg === "--config" || arg === "-c") {
217
242
  opts.config = argv[i + 1];
218
243
  if (!opts.config) die("Missing value for --config");
@@ -428,9 +453,59 @@ function main() {
428
453
  status,
429
454
  expirationTime: session.expirationTime,
430
455
  identity,
456
+ verified: null,
457
+ verifyError: null,
431
458
  };
432
459
  });
433
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
+
434
509
  if (opts.plain) {
435
510
  // --plain keeps the v1.0 contract: one name per line, scriptable.
436
511
  if (entries.length) console.log(entries.map((e) => e.name).join("\n"));
@@ -448,26 +523,59 @@ function main() {
448
523
  return;
449
524
  }
450
525
  if (defaultName) console.log(`Default: ${defaultName}\n`);
451
- const nameW = Math.max(4, ...entries.map((e) => e.name.length));
452
- const statusW = 8; // fits 'expired', 'valid', 'unknown'
453
- const header = ` ${"NAME".padEnd(nameW)} ${"STATUS".padEnd(statusW)} IDENTITY`;
454
- console.log(header);
455
- for (const e of entries) {
456
- const marker = e.isDefault ? "*" : " ";
457
- const statusLabel =
526
+ const rows = entries.map((e) => ({
527
+ marker: e.isDefault ? "*" : " ",
528
+ name: e.name,
529
+ status:
458
530
  e.status === "expired" ? "EXPIRED"
459
531
  : e.status === "valid" ? "valid"
460
- : "unknown";
461
- const idStr = e.identity ? describeIdentity(e.identity) : "(no identity)";
462
- const exp = e.expirationTime ? ` (${e.expirationTime})` : "";
532
+ : "unknown",
533
+ expires: formatExpiry(e.expirationTime),
534
+ verified:
535
+ e.verified === true ? "✓ ok"
536
+ : e.verified === false ? `✗ ${e.verifyError || "failed"}`
537
+ : "—",
538
+ identity: e.identity ? describeIdentity(e.identity) : "(no identity)",
539
+ }));
540
+ const nameW = Math.max(4, ...rows.map((r) => r.name.length));
541
+ const statusW = Math.max(6, ...rows.map((r) => r.status.length));
542
+ const expiresW = Math.max(7, ...rows.map((r) => r.expires.length));
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
+ }
551
+ console.log(header);
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 {
463
569
  console.log(
464
- `${marker} ${e.name.padEnd(nameW)} ${statusLabel.padEnd(statusW)} ${idStr}${e.status === "expired" ? exp : ""}`,
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).",
465
577
  );
466
578
  }
467
- console.log();
468
- console.log(
469
- `Legend: * = default profile, EXPIRED = OAuth session needs 'wrangler-accounts login <name>'`,
470
- );
471
579
  return;
472
580
  }
473
581
 
package/lib/isolation.js CHANGED
@@ -150,8 +150,13 @@ function runIsolated({
150
150
 
151
151
  let result;
152
152
  try {
153
+ // captureStdout mode is used for non-interactive checks (e.g.
154
+ // background `wrangler whoami` during `list --deep`), so we close
155
+ // stdin on the child rather than letting it read from the user's
156
+ // terminal. Normal inherit mode keeps stdin attached for interactive
157
+ // flows like `login` and `exec`.
153
158
  result = spawnSync(command, args, {
154
- stdio: captureStdout ? ['inherit', 'pipe', 'pipe'] : 'inherit',
159
+ stdio: captureStdout ? ['ignore', 'pipe', 'pipe'] : 'inherit',
155
160
  env,
156
161
  encoding: 'utf8',
157
162
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leeguoo/wrangler-accounts",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "AWS-style multi-account convenience for Cloudflare Wrangler — per-invocation OAuth isolation via shadow HOME.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -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