@leeguoo/wrangler-accounts 1.5.0 → 1.6.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +11 -0
- package/README.md +37 -5
- package/bin/wrangler-accounts.js +234 -53
- package/lib/isolation.js +32 -10
- package/lib/profile-store.js +68 -2
- package/package.json +1 -1
- package/plugins/wrangler-accounts/.claude-plugin/plugin.json +1 -1
- package/plugins/wrangler-accounts/skills/wrangler-accounts/SKILL.md +69 -7
- package/skills/wrangler-accounts/SKILL.md +69 -7
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "Claude Code plugins for practical developer workflows from the wrangler-accounts author.",
|
|
8
|
-
"version": "1.5.
|
|
8
|
+
"version": "1.5.1"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "wrangler-accounts",
|
|
13
13
|
"source": "./plugins/wrangler-accounts",
|
|
14
14
|
"description": "Cloudflare Wrangler multi-account skill plus a Bash hook that blocks raw wrangler calls when local profiles are configured.",
|
|
15
|
-
"version": "1.5.
|
|
15
|
+
"version": "1.5.1",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "Lee Guo"
|
|
18
18
|
},
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wrangler-accounts",
|
|
3
|
+
"description": "Cloudflare Wrangler multi-account helper with a bundled skill and Bash guard hook for Claude Code.",
|
|
4
|
+
"version": "1.5.1",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Lee Guo"
|
|
7
|
+
},
|
|
8
|
+
"homepage": "https://github.com/leeguooooo/wrangler-accounts#readme",
|
|
9
|
+
"repository": "https://github.com/leeguooooo/wrangler-accounts",
|
|
10
|
+
"license": "MIT"
|
|
11
|
+
}
|
package/README.md
CHANGED
|
@@ -49,6 +49,17 @@ If you explicitly want raw `wrangler`, bypass the hook for that command:
|
|
|
49
49
|
NOWRANGLER_ACCOUNTS_GUARD=1 wrangler deploy
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
### Upgrading from the `skills.sh` install
|
|
53
|
+
|
|
54
|
+
If you previously installed this skill via `npx skills add leeguooooo/wrangler-accounts`, remove that standalone copy after installing the plugin — otherwise Claude Code's slash command picker shows two identical `/wrangler-accounts` entries (one from the skills.sh directory, one from the plugin):
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npx skills remove wrangler-accounts
|
|
58
|
+
# or, if the CLI is missing: rm -rf ~/.agents/skills/wrangler-accounts
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Then run `/reload-plugins` and confirm only one entry remains.
|
|
62
|
+
|
|
52
63
|
## What it does
|
|
53
64
|
|
|
54
65
|
Every execution runs `wrangler` inside a per-invocation **shadow HOME** — a temporary directory that mirrors most of your real home, except `.wrangler/config/default.toml` is a symlink pointing at the saved profile's config. Token refreshes flow back to the profile automatically. Nothing touches your real `~/.wrangler`. Two parallel invocations get two independent shadow HOMEs.
|
|
@@ -100,7 +111,8 @@ WRANGLER_PROFILE=<name> wrangler-accounts <wrangler-args...>
|
|
|
100
111
|
wrangler-accounts exec <name> # interactive subshell
|
|
101
112
|
wrangler-accounts exec <name> -- <cmd> [args] # one command
|
|
102
113
|
|
|
103
|
-
wrangler-accounts login <name> # isolated OAuth login
|
|
114
|
+
wrangler-accounts login <name> # isolated OAuth login (browser)
|
|
115
|
+
wrangler-accounts token-add <name> <api-token> <account-id> [--force] # API token profile (no browser)
|
|
104
116
|
wrangler-accounts default [name | --unset] # manage persistent default
|
|
105
117
|
wrangler-accounts whoami [--profile <name>] # show resolved identity
|
|
106
118
|
wrangler-accounts list # fast table (name/status/expires/identity)
|
|
@@ -154,12 +166,32 @@ When you run `wrangler-accounts <wrangler-args>`, the active profile is resolved
|
|
|
154
166
|
4. `profilesDir/default` (set via `wrangler-accounts default <name>`)
|
|
155
167
|
5. Hard error with actionable hint
|
|
156
168
|
|
|
157
|
-
##
|
|
169
|
+
## API token profiles (no browser required)
|
|
170
|
+
|
|
171
|
+
Since 1.6.0 you can save a Cloudflare API token + account ID as a named profile — no OAuth browser flow:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
# Get your API token: Cloudflare dashboard → My Profile → API Tokens
|
|
175
|
+
wrangler-accounts token-add work CF_TOKEN_HERE ACCOUNT_ID_HERE
|
|
158
176
|
|
|
159
|
-
|
|
177
|
+
# Use exactly like an OAuth profile
|
|
178
|
+
wrangler-accounts --profile work deploy
|
|
179
|
+
wrangler-accounts work r2 list
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Token profiles appear in `list` with `[token]` type and `STATUS: token`. There's no expiration; they're always ready.
|
|
183
|
+
|
|
184
|
+
**Env-var pass-through:** if `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` are already in the environment, no profile selection is needed at all:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
CLOUDFLARE_API_TOKEN=xxx CLOUDFLARE_ACCOUNT_ID=yyy wrangler-accounts deploy
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## When to use `wrangler-accounts` vs. native env vars
|
|
160
191
|
|
|
161
|
-
- **Local dev, multiple accounts** → `wrangler-accounts
|
|
162
|
-
- **
|
|
192
|
+
- **Local dev, multiple OAuth accounts** → `wrangler-accounts login <name>` (the classic use case)
|
|
193
|
+
- **Local dev, API token accounts** → `wrangler-accounts token-add <name> <token> <account-id>` (1.6.0+)
|
|
194
|
+
- **CI / deploy pipelines** → either `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` with plain `wrangler`, or the env-var pass-through mode above
|
|
163
195
|
- **Shared scripts** that run locally or in CI → parameterize on `WRANGLER_PROFILE` so devs can run them with `WRANGLER_PROFILE=work wrangler-accounts ./deploy.sh`.
|
|
164
196
|
|
|
165
197
|
## Options
|
package/bin/wrangler-accounts.js
CHANGED
|
@@ -18,9 +18,11 @@ const {
|
|
|
18
18
|
isValidName,
|
|
19
19
|
isBackupName,
|
|
20
20
|
listProfiles,
|
|
21
|
+
getProfileType,
|
|
21
22
|
fileHash,
|
|
22
23
|
readExpirationTime,
|
|
23
24
|
readSessionState,
|
|
25
|
+
readTokenSessionState,
|
|
24
26
|
filesEqual,
|
|
25
27
|
writeMeta,
|
|
26
28
|
readMeta,
|
|
@@ -33,6 +35,8 @@ const {
|
|
|
33
35
|
backupCurrentConfig,
|
|
34
36
|
findMatchingProfile,
|
|
35
37
|
saveProfile: saveProfileImpl,
|
|
38
|
+
saveTokenProfile: saveTokenProfileImpl,
|
|
39
|
+
readTokenCredentials,
|
|
36
40
|
removeProfile: removeProfileImpl,
|
|
37
41
|
} = require("../lib/profile-store");
|
|
38
42
|
const {
|
|
@@ -62,6 +66,7 @@ const MANAGEMENT_SUBCOMMANDS = new Set([
|
|
|
62
66
|
"login",
|
|
63
67
|
"remove",
|
|
64
68
|
"default",
|
|
69
|
+
"token-add",
|
|
65
70
|
"whoami",
|
|
66
71
|
"gc",
|
|
67
72
|
"use",
|
|
@@ -129,6 +134,10 @@ function removeProfile(...args) {
|
|
|
129
134
|
try { return removeProfileImpl(...args); }
|
|
130
135
|
catch (err) { die(err.message); }
|
|
131
136
|
}
|
|
137
|
+
function saveTokenProfile(...args) {
|
|
138
|
+
try { return saveTokenProfileImpl(...args); }
|
|
139
|
+
catch (err) { die(err.message); }
|
|
140
|
+
}
|
|
132
141
|
|
|
133
142
|
let outputJson = false;
|
|
134
143
|
|
|
@@ -141,6 +150,156 @@ function die(message, exitCode = 1) {
|
|
|
141
150
|
process.exit(exitCode);
|
|
142
151
|
}
|
|
143
152
|
|
|
153
|
+
function profileTypeForName(profilesDir, name) {
|
|
154
|
+
return getProfileType(path.join(profilesDir, name));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function tokenProfileExists(profilesDir, name) {
|
|
158
|
+
return profileTypeForName(profilesDir, name) !== null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function noProfileMessage() {
|
|
162
|
+
return [
|
|
163
|
+
'No profile specified. Options:',
|
|
164
|
+
' - wrangler-accounts --profile <name> ...',
|
|
165
|
+
' - WRANGLER_PROFILE=<name> wrangler-accounts ...',
|
|
166
|
+
' - wrangler-accounts default <name> (set a persistent default)',
|
|
167
|
+
].join('\n');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function resolveProfileAny({
|
|
171
|
+
cliProfile,
|
|
172
|
+
positional,
|
|
173
|
+
env,
|
|
174
|
+
profilesDir,
|
|
175
|
+
managementSubcommands,
|
|
176
|
+
}) {
|
|
177
|
+
try {
|
|
178
|
+
return resolveProfile({
|
|
179
|
+
cliProfile,
|
|
180
|
+
positional,
|
|
181
|
+
env,
|
|
182
|
+
profilesDir,
|
|
183
|
+
managementSubcommands,
|
|
184
|
+
});
|
|
185
|
+
} catch (err) {
|
|
186
|
+
if (!(err instanceof ResolveError)) throw err;
|
|
187
|
+
if (err.code === "INVALID_NAME") throw err;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (cliProfile) {
|
|
191
|
+
if (!isValidName(cliProfile)) {
|
|
192
|
+
throw new ResolveError(`Invalid profile name: ${cliProfile}`, "INVALID_NAME");
|
|
193
|
+
}
|
|
194
|
+
if (tokenProfileExists(profilesDir, cliProfile)) {
|
|
195
|
+
return { name: cliProfile, source: "cli" };
|
|
196
|
+
}
|
|
197
|
+
throw new ResolveError(`Profile not found: ${cliProfile}`, "PROFILE_NOT_FOUND");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (positional && !managementSubcommands.has(positional)) {
|
|
201
|
+
if (isValidName(positional) && tokenProfileExists(profilesDir, positional)) {
|
|
202
|
+
return { name: positional, source: "positional" };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const envProfile = env && env.WRANGLER_PROFILE;
|
|
207
|
+
if (envProfile && envProfile.length) {
|
|
208
|
+
if (!isValidName(envProfile)) {
|
|
209
|
+
throw new ResolveError(`Invalid profile name: ${envProfile}`, "INVALID_NAME");
|
|
210
|
+
}
|
|
211
|
+
if (tokenProfileExists(profilesDir, envProfile)) {
|
|
212
|
+
return { name: envProfile, source: "env" };
|
|
213
|
+
}
|
|
214
|
+
throw new ResolveError(`Profile not found: ${envProfile}`, "PROFILE_NOT_FOUND");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const def = getDefaultProfile(profilesDir);
|
|
218
|
+
if (def) {
|
|
219
|
+
if (!isValidName(def)) {
|
|
220
|
+
throw new ResolveError(`Invalid profile name: ${def}`, "INVALID_NAME");
|
|
221
|
+
}
|
|
222
|
+
if (tokenProfileExists(profilesDir, def)) {
|
|
223
|
+
return { name: def, source: "default" };
|
|
224
|
+
}
|
|
225
|
+
throw new ResolveError(`Profile not found: ${def}`, "PROFILE_NOT_FOUND");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
throw new ResolveError(noProfileMessage(), "NO_PROFILE");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function runAnonymousTokenMode({
|
|
232
|
+
command,
|
|
233
|
+
args,
|
|
234
|
+
captureStdout = false,
|
|
235
|
+
}) {
|
|
236
|
+
return runIsolated({
|
|
237
|
+
profile: "token-env",
|
|
238
|
+
profileCfg: null,
|
|
239
|
+
profileDir: null,
|
|
240
|
+
realHome: os.homedir(),
|
|
241
|
+
command,
|
|
242
|
+
args,
|
|
243
|
+
apiToken: process.env.CLOUDFLARE_API_TOKEN || null,
|
|
244
|
+
accountId: process.env.CLOUDFLARE_ACCOUNT_ID || null,
|
|
245
|
+
baseEnv: process.env,
|
|
246
|
+
captureStdout,
|
|
247
|
+
cloudflaredPath: findCloudflared(),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function runResolvedProfileCommand({
|
|
252
|
+
resolved,
|
|
253
|
+
profilesDir,
|
|
254
|
+
command,
|
|
255
|
+
args,
|
|
256
|
+
captureStdout = false,
|
|
257
|
+
}) {
|
|
258
|
+
const profileDir = path.join(profilesDir, resolved.name);
|
|
259
|
+
const profileType = getProfileType(profileDir);
|
|
260
|
+
|
|
261
|
+
if (profileType === "token") {
|
|
262
|
+
const creds = readTokenCredentials(profileDir);
|
|
263
|
+
if (!creds || !creds.apiToken) {
|
|
264
|
+
die(`Token profile '${resolved.name}' is missing token.json credentials.`);
|
|
265
|
+
}
|
|
266
|
+
return runIsolated({
|
|
267
|
+
profile: resolved.name,
|
|
268
|
+
profileCfg: null,
|
|
269
|
+
profileDir,
|
|
270
|
+
realHome: os.homedir(),
|
|
271
|
+
command,
|
|
272
|
+
args,
|
|
273
|
+
apiToken: creds.apiToken,
|
|
274
|
+
accountId: creds.accountId || null,
|
|
275
|
+
baseEnv: process.env,
|
|
276
|
+
captureStdout,
|
|
277
|
+
cloudflaredPath: findCloudflared(),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const profileCfg = path.join(profileDir, "config.toml");
|
|
282
|
+
const session = readSessionState(profileCfg);
|
|
283
|
+
if (session.effective === 'expired') {
|
|
284
|
+
die(
|
|
285
|
+
`Profile '${resolved.name}' has expired Wrangler OAuth credentials and no refresh_token to renew them (expiration_time: ${session.expirationTime}). Run 'wrangler-accounts login ${resolved.name}' to re-authenticate.`,
|
|
286
|
+
3
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return runIsolated({
|
|
291
|
+
profile: resolved.name,
|
|
292
|
+
profileCfg,
|
|
293
|
+
profileDir,
|
|
294
|
+
realHome: os.homedir(),
|
|
295
|
+
command,
|
|
296
|
+
args,
|
|
297
|
+
baseEnv: process.env,
|
|
298
|
+
captureStdout,
|
|
299
|
+
cloudflaredPath: findCloudflared(),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
144
303
|
function printHelp(exitCode = 0) {
|
|
145
304
|
const text = `wrangler-accounts - manage multiple Wrangler login profiles
|
|
146
305
|
|
|
@@ -152,6 +311,7 @@ Commands:
|
|
|
152
311
|
status
|
|
153
312
|
login <name>
|
|
154
313
|
save <name>
|
|
314
|
+
token-add <name> <api-token> <account-id>
|
|
155
315
|
sync <name>
|
|
156
316
|
sync-active
|
|
157
317
|
use <name>
|
|
@@ -293,9 +453,9 @@ function useProfile(name, configPath, profilesDir, backup) {
|
|
|
293
453
|
}
|
|
294
454
|
|
|
295
455
|
const session = readSessionState(profileConfig);
|
|
296
|
-
if (session.expired) {
|
|
456
|
+
if (session.effective === 'expired') {
|
|
297
457
|
die(
|
|
298
|
-
`Profile '${name}' has expired Wrangler OAuth credentials (expiration_time: ${session.expirationTime}). Run 'wrangler-accounts login ${name}' to
|
|
458
|
+
`Profile '${name}' has expired Wrangler OAuth credentials and no refresh_token to renew them (expiration_time: ${session.expirationTime}). Run 'wrangler-accounts login ${name}' to re-authenticate.`
|
|
299
459
|
);
|
|
300
460
|
}
|
|
301
461
|
|
|
@@ -391,7 +551,7 @@ function main() {
|
|
|
391
551
|
|
|
392
552
|
let resolved;
|
|
393
553
|
try {
|
|
394
|
-
resolved =
|
|
554
|
+
resolved = resolveProfileAny({
|
|
395
555
|
cliProfile: profileArg,
|
|
396
556
|
positional,
|
|
397
557
|
env: process.env,
|
|
@@ -400,6 +560,13 @@ function main() {
|
|
|
400
560
|
});
|
|
401
561
|
} catch (err) {
|
|
402
562
|
if (err instanceof ResolveError) {
|
|
563
|
+
if (err.code === "NO_PROFILE" && process.env.CLOUDFLARE_API_TOKEN) {
|
|
564
|
+
const result = runAnonymousTokenMode({
|
|
565
|
+
command: "wrangler",
|
|
566
|
+
args: rest,
|
|
567
|
+
});
|
|
568
|
+
process.exit(result.exitCode);
|
|
569
|
+
}
|
|
403
570
|
const exitCode =
|
|
404
571
|
err.code === "NO_PROFILE" || err.code === "PROFILE_NOT_FOUND" ? 2 : 1;
|
|
405
572
|
die(err.message, exitCode);
|
|
@@ -407,26 +574,14 @@ function main() {
|
|
|
407
574
|
throw err;
|
|
408
575
|
}
|
|
409
576
|
|
|
410
|
-
const profileCfg = path.join(profilesDir, resolved.name, "config.toml");
|
|
411
|
-
const session = readSessionState(profileCfg);
|
|
412
|
-
if (session.expired) {
|
|
413
|
-
die(
|
|
414
|
-
`Profile '${resolved.name}' has expired Wrangler OAuth credentials (expiration_time: ${session.expirationTime}). Run 'wrangler-accounts login ${resolved.name}' to refresh it.`,
|
|
415
|
-
3
|
|
416
|
-
);
|
|
417
|
-
}
|
|
418
|
-
|
|
419
577
|
// If positional was consumed as profile, drop it from wrangler argv
|
|
420
578
|
const wranglerArgs = resolved.source === "positional" ? rest.slice(1) : rest;
|
|
421
579
|
|
|
422
|
-
const result =
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
realHome: os.homedir(),
|
|
580
|
+
const result = runResolvedProfileCommand({
|
|
581
|
+
resolved,
|
|
582
|
+
profilesDir,
|
|
426
583
|
command: "wrangler",
|
|
427
584
|
args: wranglerArgs,
|
|
428
|
-
baseEnv: process.env,
|
|
429
|
-
cloudflaredPath: findCloudflared(),
|
|
430
585
|
});
|
|
431
586
|
process.exit(result.exitCode);
|
|
432
587
|
}
|
|
@@ -438,12 +593,15 @@ function main() {
|
|
|
438
593
|
|
|
439
594
|
const entries = profiles.map((name) => {
|
|
440
595
|
const profileDir = path.join(profilesDir, name);
|
|
596
|
+
const type = getProfileType(profileDir) || "oauth";
|
|
441
597
|
const cfgPath = path.join(profileDir, "config.toml");
|
|
442
|
-
const session =
|
|
598
|
+
const session =
|
|
599
|
+
type === "token" ? readTokenSessionState() : readSessionState(cfgPath);
|
|
443
600
|
const meta = readMeta(profileDir);
|
|
444
601
|
const identity = getMetaIdentity(meta);
|
|
445
602
|
return {
|
|
446
603
|
name,
|
|
604
|
+
type,
|
|
447
605
|
isDefault: name === defaultName,
|
|
448
606
|
isActive: name === activeName,
|
|
449
607
|
status: session.effective, // 'valid' | 'refreshable' | 'expired' | 'unknown'
|
|
@@ -468,17 +626,14 @@ function main() {
|
|
|
468
626
|
}
|
|
469
627
|
const cloudflaredPath = findCloudflared();
|
|
470
628
|
for (const e of entries) {
|
|
471
|
-
const profileCfg = path.join(profilesDir, e.name, "config.toml");
|
|
472
629
|
try {
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
630
|
+
const resolved = { name: e.name, source: "deep" };
|
|
631
|
+
const r = runResolvedProfileCommand({
|
|
632
|
+
resolved,
|
|
633
|
+
profilesDir,
|
|
477
634
|
command: "wrangler",
|
|
478
635
|
args: ["whoami"],
|
|
479
|
-
baseEnv: process.env,
|
|
480
636
|
captureStdout: true,
|
|
481
|
-
cloudflaredPath,
|
|
482
637
|
});
|
|
483
638
|
const output = `${r.stdout || ""}\n${r.stderr || ""}`;
|
|
484
639
|
if (r.exitCode === 0) {
|
|
@@ -522,7 +677,7 @@ function main() {
|
|
|
522
677
|
if (defaultName) console.log(`Default: ${defaultName}\n`);
|
|
523
678
|
const rows = entries.map((e) => ({
|
|
524
679
|
marker: e.isDefault ? "*" : " ",
|
|
525
|
-
name: e.name
|
|
680
|
+
name: `${e.name} [${e.type}]`,
|
|
526
681
|
status:
|
|
527
682
|
e.status === "expired" ? "EXPIRED"
|
|
528
683
|
: e.status === "refreshable" ? "valid*"
|
|
@@ -594,12 +749,15 @@ function main() {
|
|
|
594
749
|
const matchType = exactMatch ? "hash" : identityMatches.length === 1 ? "identity" : null;
|
|
595
750
|
const profileStates = Object.fromEntries(
|
|
596
751
|
profiles.map((name) => {
|
|
597
|
-
const
|
|
598
|
-
const
|
|
752
|
+
const profileDir = path.join(profilesDir, name);
|
|
753
|
+
const type = getProfileType(profileDir) || "oauth";
|
|
754
|
+
const profileConfig = path.join(profileDir, "config.toml");
|
|
755
|
+
const meta = readMeta(profileDir);
|
|
599
756
|
return [
|
|
600
757
|
name,
|
|
601
758
|
{
|
|
602
|
-
...readSessionState(profileConfig),
|
|
759
|
+
...(type === "token" ? readTokenSessionState() : readSessionState(profileConfig)),
|
|
760
|
+
type,
|
|
603
761
|
identity: getMetaIdentity(meta),
|
|
604
762
|
},
|
|
605
763
|
];
|
|
@@ -651,10 +809,13 @@ function main() {
|
|
|
651
809
|
}
|
|
652
810
|
for (const name of profiles) {
|
|
653
811
|
const profileSession = profileStates[name];
|
|
654
|
-
|
|
655
|
-
|
|
812
|
+
const state =
|
|
813
|
+
profileSession.effective === "token"
|
|
814
|
+
? "token"
|
|
815
|
+
: profileSession.expired ? "expired" : "valid";
|
|
656
816
|
const suffix = profileSession.identity ? `, ${describeIdentity(profileSession.identity)}` : "";
|
|
657
|
-
|
|
817
|
+
const expiry = profileSession.expirationTime || "(n/a)";
|
|
818
|
+
console.log(`- ${name} [${profileSession.type}]: ${expiry} (${state}${suffix ? suffix : ""})`);
|
|
658
819
|
}
|
|
659
820
|
}
|
|
660
821
|
return;
|
|
@@ -689,6 +850,19 @@ function main() {
|
|
|
689
850
|
return;
|
|
690
851
|
}
|
|
691
852
|
|
|
853
|
+
if (command === "token-add") {
|
|
854
|
+
const name = rest[1];
|
|
855
|
+
const apiToken = rest[2];
|
|
856
|
+
const accountId = rest[3];
|
|
857
|
+
if (!name) die("Missing profile name for token-add");
|
|
858
|
+
if (!apiToken) die("Missing API token for token-add");
|
|
859
|
+
if (!accountId) die("Missing account ID for token-add");
|
|
860
|
+
ensureDir(profilesDir);
|
|
861
|
+
saveTokenProfile(name, apiToken, accountId, profilesDir, opts.force);
|
|
862
|
+
console.log(`Saved token profile '${name}'`);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
|
|
692
866
|
if (command === "login") {
|
|
693
867
|
const name = rest[1];
|
|
694
868
|
if (!name) die("Missing profile name for login");
|
|
@@ -983,7 +1157,7 @@ function main() {
|
|
|
983
1157
|
const profileArg = opts.profile || rest[1] || null;
|
|
984
1158
|
let resolved;
|
|
985
1159
|
try {
|
|
986
|
-
resolved =
|
|
1160
|
+
resolved = resolveProfileAny({
|
|
987
1161
|
cliProfile: profileArg,
|
|
988
1162
|
positional: null,
|
|
989
1163
|
env: process.env,
|
|
@@ -991,10 +1165,29 @@ function main() {
|
|
|
991
1165
|
managementSubcommands: MANAGEMENT_SUBCOMMANDS,
|
|
992
1166
|
});
|
|
993
1167
|
} catch (err) {
|
|
994
|
-
if (err instanceof ResolveError)
|
|
1168
|
+
if (err instanceof ResolveError) {
|
|
1169
|
+
if (err.code === "NO_PROFILE" && process.env.CLOUDFLARE_API_TOKEN) {
|
|
1170
|
+
const result = runAnonymousTokenMode({
|
|
1171
|
+
command: "wrangler",
|
|
1172
|
+
args: ["whoami"],
|
|
1173
|
+
});
|
|
1174
|
+
process.exit(result.exitCode);
|
|
1175
|
+
}
|
|
1176
|
+
die(err.message, 2);
|
|
1177
|
+
}
|
|
995
1178
|
throw err;
|
|
996
1179
|
}
|
|
997
1180
|
const profileDir = path.join(profilesDir, resolved.name);
|
|
1181
|
+
const profileType = getProfileType(profileDir) || "oauth";
|
|
1182
|
+
if (profileType === "token") {
|
|
1183
|
+
const result = runResolvedProfileCommand({
|
|
1184
|
+
resolved,
|
|
1185
|
+
profilesDir,
|
|
1186
|
+
command: "wrangler",
|
|
1187
|
+
args: ["whoami"],
|
|
1188
|
+
});
|
|
1189
|
+
process.exit(result.exitCode);
|
|
1190
|
+
}
|
|
998
1191
|
const meta = readMeta(profileDir);
|
|
999
1192
|
const identity = getMetaIdentity(meta);
|
|
1000
1193
|
if (opts.json) {
|
|
@@ -1004,6 +1197,7 @@ function main() {
|
|
|
1004
1197
|
command: "whoami",
|
|
1005
1198
|
profile: resolved.name,
|
|
1006
1199
|
source: resolved.source,
|
|
1200
|
+
type: profileType,
|
|
1007
1201
|
identity,
|
|
1008
1202
|
},
|
|
1009
1203
|
null,
|
|
@@ -1061,7 +1255,7 @@ function main() {
|
|
|
1061
1255
|
|
|
1062
1256
|
let resolved;
|
|
1063
1257
|
try {
|
|
1064
|
-
resolved =
|
|
1258
|
+
resolved = resolveProfileAny({
|
|
1065
1259
|
cliProfile: profileName,
|
|
1066
1260
|
positional: null,
|
|
1067
1261
|
env: process.env,
|
|
@@ -1073,15 +1267,6 @@ function main() {
|
|
|
1073
1267
|
throw err;
|
|
1074
1268
|
}
|
|
1075
1269
|
|
|
1076
|
-
const profileCfg = path.join(profilesDir, resolved.name, "config.toml");
|
|
1077
|
-
const session = readSessionState(profileCfg);
|
|
1078
|
-
if (session.expired) {
|
|
1079
|
-
die(
|
|
1080
|
-
`Profile '${resolved.name}' has expired Wrangler OAuth credentials. Run 'wrangler-accounts login ${resolved.name}' to refresh it.`,
|
|
1081
|
-
3
|
|
1082
|
-
);
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
1270
|
// Everything after `--` is the user command. Without `--`, launch $SHELL -i.
|
|
1086
1271
|
const dashDashIdx = rest.indexOf("--", 2);
|
|
1087
1272
|
let cmd;
|
|
@@ -1095,14 +1280,11 @@ function main() {
|
|
|
1095
1280
|
cmdArgs = ["-i"];
|
|
1096
1281
|
}
|
|
1097
1282
|
|
|
1098
|
-
const result =
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
realHome: os.homedir(),
|
|
1283
|
+
const result = runResolvedProfileCommand({
|
|
1284
|
+
resolved,
|
|
1285
|
+
profilesDir,
|
|
1102
1286
|
command: cmd,
|
|
1103
1287
|
args: cmdArgs,
|
|
1104
|
-
baseEnv: process.env,
|
|
1105
|
-
cloudflaredPath: findCloudflared(),
|
|
1106
1288
|
});
|
|
1107
1289
|
process.exit(result.exitCode);
|
|
1108
1290
|
}
|
|
@@ -1138,8 +1320,7 @@ function main() {
|
|
|
1138
1320
|
}
|
|
1139
1321
|
// Set the default profile
|
|
1140
1322
|
if (!isValidName(name)) die(`Invalid profile name: ${name}`);
|
|
1141
|
-
|
|
1142
|
-
if (!fs.existsSync(cfg)) die(`Profile not found: ${name}`, 2);
|
|
1323
|
+
if (!tokenProfileExists(profilesDir, name)) die(`Profile not found: ${name}`, 2);
|
|
1143
1324
|
setDefaultProfile(profilesDir, name);
|
|
1144
1325
|
if (opts.json) {
|
|
1145
1326
|
console.log(JSON.stringify({ command: "default", name }, null, 2));
|
package/lib/isolation.js
CHANGED
|
@@ -21,15 +21,15 @@ const { spawnSync } = require('node:child_process');
|
|
|
21
21
|
*
|
|
22
22
|
* @param {object} args
|
|
23
23
|
* @param {string} args.realHome
|
|
24
|
-
* @param {string} args.profileCfg - path to the profile's config.toml file
|
|
24
|
+
* @param {string|null} [args.profileCfg] - path to the profile's config.toml file
|
|
25
25
|
* @param {string} [args.label] - optional label for the tmpdir name
|
|
26
26
|
* @returns {string} path to the shadow HOME
|
|
27
27
|
*/
|
|
28
|
-
function createShadowHome({ realHome, profileCfg, label = 'wa' }) {
|
|
28
|
+
function createShadowHome({ realHome, profileCfg = null, label = 'wa' }) {
|
|
29
29
|
if (!realHome || !fs.existsSync(realHome)) {
|
|
30
30
|
throw new Error(`real HOME does not exist: ${realHome}`);
|
|
31
31
|
}
|
|
32
|
-
if (
|
|
32
|
+
if (profileCfg && !fs.existsSync(profileCfg)) {
|
|
33
33
|
throw new Error(`profile config does not exist: ${profileCfg}`);
|
|
34
34
|
}
|
|
35
35
|
|
|
@@ -59,10 +59,12 @@ function createShadowHome({ realHome, profileCfg, label = 'wa' }) {
|
|
|
59
59
|
// saved profile automatically.
|
|
60
60
|
const shadowWranglerConfig = path.join(shadow, '.wrangler', 'config');
|
|
61
61
|
fs.mkdirSync(shadowWranglerConfig, { recursive: true });
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
const defaultConfigPath = path.join(shadowWranglerConfig, 'default.toml');
|
|
63
|
+
if (profileCfg) {
|
|
64
|
+
fs.symlinkSync(profileCfg, defaultConfigPath);
|
|
65
|
+
} else {
|
|
66
|
+
fs.writeFileSync(defaultConfigPath, '');
|
|
67
|
+
}
|
|
66
68
|
|
|
67
69
|
return shadow;
|
|
68
70
|
}
|
|
@@ -94,6 +96,9 @@ function buildIsolatedEnv({
|
|
|
94
96
|
realHome,
|
|
95
97
|
profile,
|
|
96
98
|
profileCfg = null,
|
|
99
|
+
profileDir = null,
|
|
100
|
+
apiToken = null,
|
|
101
|
+
accountId = null,
|
|
97
102
|
baseEnv = process.env,
|
|
98
103
|
cloudflaredPath = null,
|
|
99
104
|
}) {
|
|
@@ -105,6 +110,9 @@ function buildIsolatedEnv({
|
|
|
105
110
|
env.WRANGLER_REGISTRY_PATH = path.join(realHome, '.wrangler', 'registry');
|
|
106
111
|
env.WRANGLER_LOG_PATH = path.join(realHome, '.wrangler', 'logs');
|
|
107
112
|
env.WRANGLER_SEND_METRICS = 'false';
|
|
113
|
+
if (!env.WA_TEST_OUT) {
|
|
114
|
+
env.WA_TEST_OUT = path.join(shadow, '.wrangler', 'wa-test-out.json');
|
|
115
|
+
}
|
|
108
116
|
|
|
109
117
|
// CRITICAL: Wrangler caches the user's selected Cloudflare account ID
|
|
110
118
|
// in `wrangler-account.json` inside `getCacheFolder()`. If multiple
|
|
@@ -124,9 +132,9 @@ function buildIsolatedEnv({
|
|
|
124
132
|
// (CLOUDFLARED_PATH / node_modules); WRANGLER_CACHE_DIR is only for
|
|
125
133
|
// config-cache files like wrangler-account.json and
|
|
126
134
|
// pages-config-cache.json.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const cacheDir = path.join(
|
|
135
|
+
const cacheRoot = profileDir || (profileCfg ? path.dirname(profileCfg) : null);
|
|
136
|
+
if (cacheRoot) {
|
|
137
|
+
const cacheDir = path.join(cacheRoot, 'cache');
|
|
130
138
|
try {
|
|
131
139
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
132
140
|
} catch (err) {
|
|
@@ -137,6 +145,14 @@ function buildIsolatedEnv({
|
|
|
137
145
|
env.WRANGLER_CACHE_DIR = cacheDir;
|
|
138
146
|
}
|
|
139
147
|
|
|
148
|
+
if (apiToken) {
|
|
149
|
+
env.CLOUDFLARE_API_TOKEN = apiToken;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (accountId) {
|
|
153
|
+
env.CLOUDFLARE_ACCOUNT_ID = accountId;
|
|
154
|
+
}
|
|
155
|
+
|
|
140
156
|
if (cloudflaredPath) {
|
|
141
157
|
env.CLOUDFLARED_PATH = cloudflaredPath;
|
|
142
158
|
}
|
|
@@ -162,9 +178,12 @@ function buildIsolatedEnv({
|
|
|
162
178
|
function runIsolated({
|
|
163
179
|
profile,
|
|
164
180
|
profileCfg,
|
|
181
|
+
profileDir = null,
|
|
165
182
|
realHome,
|
|
166
183
|
command,
|
|
167
184
|
args,
|
|
185
|
+
apiToken = null,
|
|
186
|
+
accountId = null,
|
|
168
187
|
baseEnv = process.env,
|
|
169
188
|
captureStdout = false,
|
|
170
189
|
cloudflaredPath = null,
|
|
@@ -179,6 +198,9 @@ function runIsolated({
|
|
|
179
198
|
realHome,
|
|
180
199
|
profile,
|
|
181
200
|
profileCfg,
|
|
201
|
+
profileDir,
|
|
202
|
+
apiToken,
|
|
203
|
+
accountId,
|
|
182
204
|
baseEnv,
|
|
183
205
|
cloudflaredPath,
|
|
184
206
|
});
|
package/lib/profile-store.js
CHANGED
|
@@ -16,6 +16,13 @@ function isBackupName(name) {
|
|
|
16
16
|
return name.startsWith('__backup-');
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
function getProfileType(profileDir) {
|
|
20
|
+
if (!profileDir || !fs.existsSync(profileDir)) return null;
|
|
21
|
+
if (fs.existsSync(path.join(profileDir, 'config.toml'))) return 'oauth';
|
|
22
|
+
if (fs.existsSync(path.join(profileDir, 'token.json'))) return 'token';
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
19
26
|
function listProfiles(profilesDir, { includeBackups = false } = {}) {
|
|
20
27
|
if (!fs.existsSync(profilesDir)) return [];
|
|
21
28
|
const entries = fs.readdirSync(profilesDir, { withFileTypes: true });
|
|
@@ -23,7 +30,7 @@ function listProfiles(profilesDir, { includeBackups = false } = {}) {
|
|
|
23
30
|
.filter((entry) => entry.isDirectory())
|
|
24
31
|
.map((entry) => entry.name)
|
|
25
32
|
.filter((name) => includeBackups || !isBackupName(name))
|
|
26
|
-
.filter((name) =>
|
|
33
|
+
.filter((name) => getProfileType(path.join(profilesDir, name)) !== null)
|
|
27
34
|
.sort();
|
|
28
35
|
}
|
|
29
36
|
|
|
@@ -199,7 +206,9 @@ function findMatchingProfile(profilesDir, configPath, { includeBackups = false }
|
|
|
199
206
|
const configHash = fileHash(configPath);
|
|
200
207
|
const profiles = listProfiles(profilesDir, { includeBackups });
|
|
201
208
|
for (const name of profiles) {
|
|
202
|
-
const
|
|
209
|
+
const profileDir = path.join(profilesDir, name);
|
|
210
|
+
if (getProfileType(profileDir) !== 'oauth') continue;
|
|
211
|
+
const profileConfig = path.join(profileDir, 'config.toml');
|
|
203
212
|
if (fileHash(profileConfig) === configHash) return name;
|
|
204
213
|
}
|
|
205
214
|
return null;
|
|
@@ -223,6 +232,59 @@ function saveProfile(name, configPath, profilesDir, force, identity = null) {
|
|
|
223
232
|
writeMeta(profileDir, name, configPath, identity);
|
|
224
233
|
}
|
|
225
234
|
|
|
235
|
+
function saveTokenProfile(name, apiToken, accountId, profilesDir, force) {
|
|
236
|
+
if (!isValidName(name)) {
|
|
237
|
+
throw new Error(`Invalid profile name: ${name}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const profileDir = path.join(profilesDir, name);
|
|
241
|
+
if (fs.existsSync(profileDir) && !force) {
|
|
242
|
+
throw new Error(`Profile exists: ${name} (use --force to overwrite)`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
ensureDir(profileDir);
|
|
246
|
+
|
|
247
|
+
const tokenPath = path.join(profileDir, 'token.json');
|
|
248
|
+
fs.writeFileSync(
|
|
249
|
+
tokenPath,
|
|
250
|
+
JSON.stringify({ apiToken, accountId }, null, 2),
|
|
251
|
+
);
|
|
252
|
+
fs.chmodSync(tokenPath, 0o600);
|
|
253
|
+
|
|
254
|
+
fs.writeFileSync(
|
|
255
|
+
path.join(profileDir, 'meta.json'),
|
|
256
|
+
JSON.stringify(
|
|
257
|
+
{
|
|
258
|
+
name,
|
|
259
|
+
savedAt: new Date().toISOString(),
|
|
260
|
+
type: 'token',
|
|
261
|
+
accountId,
|
|
262
|
+
},
|
|
263
|
+
null,
|
|
264
|
+
2,
|
|
265
|
+
),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function readTokenCredentials(profileDir) {
|
|
270
|
+
const tokenPath = path.join(profileDir, 'token.json');
|
|
271
|
+
if (!fs.existsSync(tokenPath)) return null;
|
|
272
|
+
try {
|
|
273
|
+
return JSON.parse(fs.readFileSync(tokenPath, 'utf8'));
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function readTokenSessionState() {
|
|
280
|
+
return {
|
|
281
|
+
expirationTime: null,
|
|
282
|
+
expired: null,
|
|
283
|
+
hasRefreshToken: false,
|
|
284
|
+
effective: 'token',
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
226
288
|
function removeProfile(name, profilesDir) {
|
|
227
289
|
if (!isValidName(name)) {
|
|
228
290
|
throw new Error(`Invalid profile name: ${name}`);
|
|
@@ -245,10 +307,12 @@ module.exports = {
|
|
|
245
307
|
ensureDir,
|
|
246
308
|
isValidName,
|
|
247
309
|
isBackupName,
|
|
310
|
+
getProfileType,
|
|
248
311
|
listProfiles,
|
|
249
312
|
fileHash,
|
|
250
313
|
readExpirationTime,
|
|
251
314
|
readSessionState,
|
|
315
|
+
readTokenSessionState,
|
|
252
316
|
filesEqual,
|
|
253
317
|
writeMeta,
|
|
254
318
|
readMeta,
|
|
@@ -261,5 +325,7 @@ module.exports = {
|
|
|
261
325
|
backupCurrentConfig,
|
|
262
326
|
findMatchingProfile,
|
|
263
327
|
saveProfile,
|
|
328
|
+
saveTokenProfile,
|
|
329
|
+
readTokenCredentials,
|
|
264
330
|
removeProfile,
|
|
265
331
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leeguoo/wrangler-accounts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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": {
|
|
@@ -30,6 +30,13 @@ Non-Claude-Code users can keep using the `skills.sh` distribution path for this
|
|
|
30
30
|
npx skills add leeguooooo/wrangler-accounts -g -y
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
If a Claude Code user previously installed via `skills.sh`, removing the standalone copy avoids a duplicate `/wrangler-accounts` entry in the command picker:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx skills remove wrangler-accounts
|
|
37
|
+
# or: rm -rf ~/.agents/skills/wrangler-accounts
|
|
38
|
+
```
|
|
39
|
+
|
|
33
40
|
## Prerequisites (check before running any recipe below)
|
|
34
41
|
|
|
35
42
|
This skill is only documentation — the actual `wrangler-accounts` binary must also be installed on the user's `PATH`. Before running any command below, verify:
|
|
@@ -59,6 +66,7 @@ If `wrangler-accounts --version` is below any of these, **upgrade first** before
|
|
|
59
66
|
| **≥ 1.2.2** | per-profile `WRANGLER_CACHE_DIR`, fixes account-id leak across profiles | `d1`/`r2 object` commands return `7403 not authorized` even though OAuth is valid; deploys silently land in the wrong account |
|
|
60
67
|
| **≥ 1.3.0** | STATUS column distinguishes `valid` / `valid*` / `EXPIRED` (refresh-token-aware) | `list` shows `EXPIRED` for healthy profiles, scaring you into running `login` for no reason |
|
|
61
68
|
| **≥ 1.4.0** | `login` refuses non-TTY contexts and accidental overwrites | `login <name>` hangs forever in non-interactive contexts; reflexive `login` overwrites a healthy profile |
|
|
69
|
+
| **≥ 1.6.0** | API token profiles (`token-add`) + anonymous env-var pass-through | only OAuth profiles existed; `CLOUDFLARE_API_TOKEN` in env still required a named profile to be selected |
|
|
62
70
|
|
|
63
71
|
```bash
|
|
64
72
|
npm i -g @leeguoo/wrangler-accounts@latest # always-safe upgrade
|
|
@@ -101,6 +109,7 @@ How to read `list --deep` output:
|
|
|
101
109
|
## Quick Start
|
|
102
110
|
|
|
103
111
|
- `wrangler-accounts login <name>` — interactive OAuth login into a new profile (never touches real `~/.wrangler`)
|
|
112
|
+
- `wrangler-accounts token-add <name> <api-token> <account-id>` — save an API token profile (no browser login needed)
|
|
104
113
|
- `wrangler-accounts default <name>` — set the persistent default profile
|
|
105
114
|
- `wrangler-accounts deploy` — run `wrangler deploy` under the default profile
|
|
106
115
|
- `wrangler-accounts --profile personal deploy` — one-shot override
|
|
@@ -162,6 +171,7 @@ Use `--json` for structured output.
|
|
|
162
171
|
| `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 |
|
|
163
172
|
| `EXPIRED` / `expired` | access_token expired **AND** no refresh_token saved; profile is genuinely broken | `wrangler-accounts login <name>` |
|
|
164
173
|
| `unknown` | profile file has no `expiration_time` field | run `list --deep` to verify live |
|
|
174
|
+
| `token` | API token profile (1.6.0+) — no expiration concept, always ready | none |
|
|
165
175
|
|
|
166
176
|
**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.
|
|
167
177
|
|
|
@@ -169,13 +179,36 @@ The only time a user actually needs `wrangler-accounts login <name>` again is:
|
|
|
169
179
|
1. STATUS is `EXPIRED` (no refresh_token at all — profile was saved without `offline_access` scope)
|
|
170
180
|
2. OR `list --deep` returns `✗` with "Not logged in" / "refresh token may be revoked" (refresh token itself got invalidated)
|
|
171
181
|
|
|
182
|
+
### Save an API token profile (no browser required)
|
|
183
|
+
|
|
184
|
+
`wrangler-accounts token-add <name> <api-token> <account-id> [--force]`
|
|
185
|
+
|
|
186
|
+
Saves a Cloudflare API token + account ID as a named profile. No OAuth browser flow needed. The credentials are stored in `token.json` (mode 0600) inside the profile directory.
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
# Get your API token from: Cloudflare dashboard → My Profile → API Tokens
|
|
190
|
+
wrangler-accounts token-add work CF_TOKEN_HERE ACCOUNT_ID_HERE
|
|
191
|
+
|
|
192
|
+
# Use identically to OAuth profiles
|
|
193
|
+
wrangler-accounts --profile work deploy
|
|
194
|
+
wrangler-accounts work r2 list
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Token profiles appear in `list` with a `[token]` type indicator and `STATUS: token` — there is no expiration concept, so they are always ready to use. `remove` works the same as for OAuth profiles.
|
|
198
|
+
|
|
199
|
+
**Env-var pass-through (1.6.0+):** when `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` are already set in the environment and no profile is specified, `wrangler-accounts` runs in anonymous-token mode (no named profile needed). Useful for CI jobs that inject credentials via secrets:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
CLOUDFLARE_API_TOKEN=xxx CLOUDFLARE_ACCOUNT_ID=yyy wrangler-accounts deploy
|
|
203
|
+
```
|
|
204
|
+
|
|
172
205
|
### Save, sync, login, remove
|
|
173
206
|
|
|
174
207
|
- `wrangler-accounts save <name>` — snapshot current Wrangler config as a profile
|
|
175
208
|
- `wrangler-accounts sync <name>` — refresh a specific profile from the current login
|
|
176
209
|
- `wrangler-accounts sync-default` — refresh the default profile
|
|
177
210
|
- `wrangler-accounts login <name>` — fresh isolated OAuth login
|
|
178
|
-
- `wrangler-accounts remove <name>` — delete a profile
|
|
211
|
+
- `wrangler-accounts remove <name>` — delete a profile (works for both OAuth and token profiles)
|
|
179
212
|
|
|
180
213
|
### Clean up stale shadow HOMEs
|
|
181
214
|
|
|
@@ -303,9 +336,9 @@ wrangler deploy
|
|
|
303
336
|
|
|
304
337
|
## Troubleshooting
|
|
305
338
|
|
|
306
|
-
### "Profile 'X' has expired Wrangler OAuth credentials"
|
|
339
|
+
### "Profile 'X' has expired Wrangler OAuth credentials and no refresh_token to renew them"
|
|
307
340
|
|
|
308
|
-
The saved OAuth
|
|
341
|
+
The saved OAuth access_token is past its `expiration_time` AND there is no `refresh_token` in the profile config (no `offline_access` scope at login time, or the token was revoked). The profile is genuinely broken; only re-authenticating fixes it:
|
|
309
342
|
|
|
310
343
|
```bash
|
|
311
344
|
wrangler-accounts login <name>
|
|
@@ -313,16 +346,20 @@ wrangler-accounts login <name>
|
|
|
313
346
|
|
|
314
347
|
This overwrites the existing profile with a fresh OAuth session. Any saved metadata (identity, etc.) is re-verified.
|
|
315
348
|
|
|
349
|
+
**Note:** If you see this error in 1.5.0 or earlier, you may be hitting a known regression: any profile whose access_token had passed expiration was blocked even when a refresh_token was present. Upgrade to 1.5.1+ — `wrangler` itself silently refreshes those tokens on the next call, so wrangler-accounts no longer pre-flights against `expiration_time` alone.
|
|
350
|
+
|
|
316
351
|
### "No profile specified. Options: ..."
|
|
317
352
|
|
|
318
353
|
The user ran `wrangler-accounts <wrangler-args>` without a resolvable profile. Fix one of:
|
|
319
354
|
|
|
320
355
|
```bash
|
|
321
|
-
wrangler-accounts --profile <name> <args>
|
|
356
|
+
wrangler-accounts --profile <name> <args> # one-shot
|
|
322
357
|
WRANGLER_PROFILE=<name> wrangler-accounts <args> # env var
|
|
323
|
-
wrangler-accounts default <name>
|
|
358
|
+
wrangler-accounts default <name> # persistent default
|
|
324
359
|
```
|
|
325
360
|
|
|
361
|
+
**1.6.0+**: if `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` are both present in the environment, this error no longer fires — the tool runs in anonymous-token mode automatically.
|
|
362
|
+
|
|
326
363
|
### "Profile not found: X"
|
|
327
364
|
|
|
328
365
|
The profile name doesn't exist in `profilesDir`. Check what's saved:
|
|
@@ -428,7 +465,32 @@ If a user is hitting a "wrong account" symptom and the credentials look right, t
|
|
|
428
465
|
|
|
429
466
|
## CI guidance
|
|
430
467
|
|
|
431
|
-
For CI and deploy pipelines
|
|
468
|
+
For CI and deploy pipelines you have two options:
|
|
469
|
+
|
|
470
|
+
**Option 1 — plain `wrangler` with env vars (simplest for CI):**
|
|
471
|
+
|
|
472
|
+
```bash
|
|
473
|
+
CLOUDFLARE_API_TOKEN=<token>
|
|
474
|
+
CLOUDFLARE_ACCOUNT_ID=<account-id>
|
|
475
|
+
wrangler deploy
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
**Option 2 — `wrangler-accounts` with a token profile (useful when you want the same CLI both locally and in CI):**
|
|
479
|
+
|
|
480
|
+
```bash
|
|
481
|
+
# Save once (locally or during CI bootstrap):
|
|
482
|
+
wrangler-accounts token-add work "$CF_TOKEN" "$CF_ACCOUNT_ID"
|
|
483
|
+
# Use the same commands locally and in CI:
|
|
484
|
+
wrangler-accounts --profile work deploy
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
Or use the anonymous pass-through (no profile needed if env vars are present):
|
|
488
|
+
|
|
489
|
+
```bash
|
|
490
|
+
CLOUDFLARE_API_TOKEN="$CF_TOKEN" CLOUDFLARE_ACCOUNT_ID="$CF_ACCOUNT_ID" wrangler-accounts deploy
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
`wrangler-accounts` is primarily a local developer convenience for juggling OAuth sessions on your workstation, but since 1.6.0 it also supports API token profiles for teams that want a consistent CLI interface across local and CI environments.
|
|
432
494
|
|
|
433
495
|
## Paths and environment
|
|
434
496
|
|
|
@@ -444,7 +506,7 @@ Use `--json` when another tool needs to parse results. All v1.0 commands that pr
|
|
|
444
506
|
|
|
445
507
|
## Naming rules
|
|
446
508
|
|
|
447
|
-
Profile names: letters, numbers, dot, underscore, dash only. Names matching management subcommand names (`exec`, `default`, `whoami`, `gc`, `login`, `list`, `status`, `save`, `sync`, `sync-default`, `remove`, `use`, `sync-active`) cannot be reached via positional shorthand — use `--profile <name>` for those.
|
|
509
|
+
Profile names: letters, numbers, dot, underscore, dash only. Names matching management subcommand names (`exec`, `default`, `whoami`, `gc`, `login`, `token-add`, `list`, `status`, `save`, `sync`, `sync-default`, `remove`, `use`, `sync-active`) cannot be reached via positional shorthand — use `--profile <name>` for those.
|
|
448
510
|
|
|
449
511
|
## Deprecated
|
|
450
512
|
|
|
@@ -30,6 +30,13 @@ Non-Claude-Code users can keep using the `skills.sh` distribution path for this
|
|
|
30
30
|
npx skills add leeguooooo/wrangler-accounts -g -y
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
If a Claude Code user previously installed via `skills.sh`, removing the standalone copy avoids a duplicate `/wrangler-accounts` entry in the command picker:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx skills remove wrangler-accounts
|
|
37
|
+
# or: rm -rf ~/.agents/skills/wrangler-accounts
|
|
38
|
+
```
|
|
39
|
+
|
|
33
40
|
## Prerequisites (check before running any recipe below)
|
|
34
41
|
|
|
35
42
|
This skill is only documentation — the actual `wrangler-accounts` binary must also be installed on the user's `PATH`. Before running any command below, verify:
|
|
@@ -59,6 +66,7 @@ If `wrangler-accounts --version` is below any of these, **upgrade first** before
|
|
|
59
66
|
| **≥ 1.2.2** | per-profile `WRANGLER_CACHE_DIR`, fixes account-id leak across profiles | `d1`/`r2 object` commands return `7403 not authorized` even though OAuth is valid; deploys silently land in the wrong account |
|
|
60
67
|
| **≥ 1.3.0** | STATUS column distinguishes `valid` / `valid*` / `EXPIRED` (refresh-token-aware) | `list` shows `EXPIRED` for healthy profiles, scaring you into running `login` for no reason |
|
|
61
68
|
| **≥ 1.4.0** | `login` refuses non-TTY contexts and accidental overwrites | `login <name>` hangs forever in non-interactive contexts; reflexive `login` overwrites a healthy profile |
|
|
69
|
+
| **≥ 1.6.0** | API token profiles (`token-add`) + anonymous env-var pass-through | only OAuth profiles existed; `CLOUDFLARE_API_TOKEN` in env still required a named profile to be selected |
|
|
62
70
|
|
|
63
71
|
```bash
|
|
64
72
|
npm i -g @leeguoo/wrangler-accounts@latest # always-safe upgrade
|
|
@@ -101,6 +109,7 @@ How to read `list --deep` output:
|
|
|
101
109
|
## Quick Start
|
|
102
110
|
|
|
103
111
|
- `wrangler-accounts login <name>` — interactive OAuth login into a new profile (never touches real `~/.wrangler`)
|
|
112
|
+
- `wrangler-accounts token-add <name> <api-token> <account-id>` — save an API token profile (no browser login needed)
|
|
104
113
|
- `wrangler-accounts default <name>` — set the persistent default profile
|
|
105
114
|
- `wrangler-accounts deploy` — run `wrangler deploy` under the default profile
|
|
106
115
|
- `wrangler-accounts --profile personal deploy` — one-shot override
|
|
@@ -162,6 +171,7 @@ Use `--json` for structured output.
|
|
|
162
171
|
| `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 |
|
|
163
172
|
| `EXPIRED` / `expired` | access_token expired **AND** no refresh_token saved; profile is genuinely broken | `wrangler-accounts login <name>` |
|
|
164
173
|
| `unknown` | profile file has no `expiration_time` field | run `list --deep` to verify live |
|
|
174
|
+
| `token` | API token profile (1.6.0+) — no expiration concept, always ready | none |
|
|
165
175
|
|
|
166
176
|
**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.
|
|
167
177
|
|
|
@@ -169,13 +179,36 @@ The only time a user actually needs `wrangler-accounts login <name>` again is:
|
|
|
169
179
|
1. STATUS is `EXPIRED` (no refresh_token at all — profile was saved without `offline_access` scope)
|
|
170
180
|
2. OR `list --deep` returns `✗` with "Not logged in" / "refresh token may be revoked" (refresh token itself got invalidated)
|
|
171
181
|
|
|
182
|
+
### Save an API token profile (no browser required)
|
|
183
|
+
|
|
184
|
+
`wrangler-accounts token-add <name> <api-token> <account-id> [--force]`
|
|
185
|
+
|
|
186
|
+
Saves a Cloudflare API token + account ID as a named profile. No OAuth browser flow needed. The credentials are stored in `token.json` (mode 0600) inside the profile directory.
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
# Get your API token from: Cloudflare dashboard → My Profile → API Tokens
|
|
190
|
+
wrangler-accounts token-add work CF_TOKEN_HERE ACCOUNT_ID_HERE
|
|
191
|
+
|
|
192
|
+
# Use identically to OAuth profiles
|
|
193
|
+
wrangler-accounts --profile work deploy
|
|
194
|
+
wrangler-accounts work r2 list
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Token profiles appear in `list` with a `[token]` type indicator and `STATUS: token` — there is no expiration concept, so they are always ready to use. `remove` works the same as for OAuth profiles.
|
|
198
|
+
|
|
199
|
+
**Env-var pass-through (1.6.0+):** when `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` are already set in the environment and no profile is specified, `wrangler-accounts` runs in anonymous-token mode (no named profile needed). Useful for CI jobs that inject credentials via secrets:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
CLOUDFLARE_API_TOKEN=xxx CLOUDFLARE_ACCOUNT_ID=yyy wrangler-accounts deploy
|
|
203
|
+
```
|
|
204
|
+
|
|
172
205
|
### Save, sync, login, remove
|
|
173
206
|
|
|
174
207
|
- `wrangler-accounts save <name>` — snapshot current Wrangler config as a profile
|
|
175
208
|
- `wrangler-accounts sync <name>` — refresh a specific profile from the current login
|
|
176
209
|
- `wrangler-accounts sync-default` — refresh the default profile
|
|
177
210
|
- `wrangler-accounts login <name>` — fresh isolated OAuth login
|
|
178
|
-
- `wrangler-accounts remove <name>` — delete a profile
|
|
211
|
+
- `wrangler-accounts remove <name>` — delete a profile (works for both OAuth and token profiles)
|
|
179
212
|
|
|
180
213
|
### Clean up stale shadow HOMEs
|
|
181
214
|
|
|
@@ -303,9 +336,9 @@ wrangler deploy
|
|
|
303
336
|
|
|
304
337
|
## Troubleshooting
|
|
305
338
|
|
|
306
|
-
### "Profile 'X' has expired Wrangler OAuth credentials"
|
|
339
|
+
### "Profile 'X' has expired Wrangler OAuth credentials and no refresh_token to renew them"
|
|
307
340
|
|
|
308
|
-
The saved OAuth
|
|
341
|
+
The saved OAuth access_token is past its `expiration_time` AND there is no `refresh_token` in the profile config (no `offline_access` scope at login time, or the token was revoked). The profile is genuinely broken; only re-authenticating fixes it:
|
|
309
342
|
|
|
310
343
|
```bash
|
|
311
344
|
wrangler-accounts login <name>
|
|
@@ -313,16 +346,20 @@ wrangler-accounts login <name>
|
|
|
313
346
|
|
|
314
347
|
This overwrites the existing profile with a fresh OAuth session. Any saved metadata (identity, etc.) is re-verified.
|
|
315
348
|
|
|
349
|
+
**Note:** If you see this error in 1.5.0 or earlier, you may be hitting a known regression: any profile whose access_token had passed expiration was blocked even when a refresh_token was present. Upgrade to 1.5.1+ — `wrangler` itself silently refreshes those tokens on the next call, so wrangler-accounts no longer pre-flights against `expiration_time` alone.
|
|
350
|
+
|
|
316
351
|
### "No profile specified. Options: ..."
|
|
317
352
|
|
|
318
353
|
The user ran `wrangler-accounts <wrangler-args>` without a resolvable profile. Fix one of:
|
|
319
354
|
|
|
320
355
|
```bash
|
|
321
|
-
wrangler-accounts --profile <name> <args>
|
|
356
|
+
wrangler-accounts --profile <name> <args> # one-shot
|
|
322
357
|
WRANGLER_PROFILE=<name> wrangler-accounts <args> # env var
|
|
323
|
-
wrangler-accounts default <name>
|
|
358
|
+
wrangler-accounts default <name> # persistent default
|
|
324
359
|
```
|
|
325
360
|
|
|
361
|
+
**1.6.0+**: if `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` are both present in the environment, this error no longer fires — the tool runs in anonymous-token mode automatically.
|
|
362
|
+
|
|
326
363
|
### "Profile not found: X"
|
|
327
364
|
|
|
328
365
|
The profile name doesn't exist in `profilesDir`. Check what's saved:
|
|
@@ -428,7 +465,32 @@ If a user is hitting a "wrong account" symptom and the credentials look right, t
|
|
|
428
465
|
|
|
429
466
|
## CI guidance
|
|
430
467
|
|
|
431
|
-
For CI and deploy pipelines
|
|
468
|
+
For CI and deploy pipelines you have two options:
|
|
469
|
+
|
|
470
|
+
**Option 1 — plain `wrangler` with env vars (simplest for CI):**
|
|
471
|
+
|
|
472
|
+
```bash
|
|
473
|
+
CLOUDFLARE_API_TOKEN=<token>
|
|
474
|
+
CLOUDFLARE_ACCOUNT_ID=<account-id>
|
|
475
|
+
wrangler deploy
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
**Option 2 — `wrangler-accounts` with a token profile (useful when you want the same CLI both locally and in CI):**
|
|
479
|
+
|
|
480
|
+
```bash
|
|
481
|
+
# Save once (locally or during CI bootstrap):
|
|
482
|
+
wrangler-accounts token-add work "$CF_TOKEN" "$CF_ACCOUNT_ID"
|
|
483
|
+
# Use the same commands locally and in CI:
|
|
484
|
+
wrangler-accounts --profile work deploy
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
Or use the anonymous pass-through (no profile needed if env vars are present):
|
|
488
|
+
|
|
489
|
+
```bash
|
|
490
|
+
CLOUDFLARE_API_TOKEN="$CF_TOKEN" CLOUDFLARE_ACCOUNT_ID="$CF_ACCOUNT_ID" wrangler-accounts deploy
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
`wrangler-accounts` is primarily a local developer convenience for juggling OAuth sessions on your workstation, but since 1.6.0 it also supports API token profiles for teams that want a consistent CLI interface across local and CI environments.
|
|
432
494
|
|
|
433
495
|
## Paths and environment
|
|
434
496
|
|
|
@@ -444,7 +506,7 @@ Use `--json` when another tool needs to parse results. All v1.0 commands that pr
|
|
|
444
506
|
|
|
445
507
|
## Naming rules
|
|
446
508
|
|
|
447
|
-
Profile names: letters, numbers, dot, underscore, dash only. Names matching management subcommand names (`exec`, `default`, `whoami`, `gc`, `login`, `list`, `status`, `save`, `sync`, `sync-default`, `remove`, `use`, `sync-active`) cannot be reached via positional shorthand — use `--profile <name>` for those.
|
|
509
|
+
Profile names: letters, numbers, dot, underscore, dash only. Names matching management subcommand names (`exec`, `default`, `whoami`, `gc`, `login`, `token-add`, `list`, `status`, `save`, `sync`, `sync-default`, `remove`, `use`, `sync-active`) cannot be reached via positional shorthand — use `--profile <name>` for those.
|
|
448
510
|
|
|
449
511
|
## Deprecated
|
|
450
512
|
|