@leeguoo/wrangler-accounts 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/wrangler-accounts.js +57 -6
- package/lib/identity.js +105 -0
- package/lib/isolation.js +186 -0
- package/lib/paths.js +57 -0
- package/lib/profile-store.js +213 -0
- package/lib/resolve.js +101 -0
- package/package.json +5 -2
package/bin/wrangler-accounts.js
CHANGED
|
@@ -408,15 +408,66 @@ function main() {
|
|
|
408
408
|
|
|
409
409
|
if (command === "list") {
|
|
410
410
|
const profiles = listProfiles(profilesDir, { includeBackups });
|
|
411
|
+
const defaultName = getDefaultProfile(profilesDir);
|
|
412
|
+
const activeName = getActiveProfile(profilesDir);
|
|
413
|
+
|
|
414
|
+
const entries = profiles.map((name) => {
|
|
415
|
+
const profileDir = path.join(profilesDir, name);
|
|
416
|
+
const cfgPath = path.join(profileDir, "config.toml");
|
|
417
|
+
const session = readSessionState(cfgPath);
|
|
418
|
+
const meta = readMeta(profileDir);
|
|
419
|
+
const identity = getMetaIdentity(meta);
|
|
420
|
+
let status;
|
|
421
|
+
if (session.expired === true) status = "expired";
|
|
422
|
+
else if (session.expired === false) status = "valid";
|
|
423
|
+
else status = "unknown";
|
|
424
|
+
return {
|
|
425
|
+
name,
|
|
426
|
+
isDefault: name === defaultName,
|
|
427
|
+
isActive: name === activeName,
|
|
428
|
+
status,
|
|
429
|
+
expirationTime: session.expirationTime,
|
|
430
|
+
identity,
|
|
431
|
+
};
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
if (opts.plain) {
|
|
435
|
+
// --plain keeps the v1.0 contract: one name per line, scriptable.
|
|
436
|
+
if (entries.length) console.log(entries.map((e) => e.name).join("\n"));
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
411
440
|
if (opts.json) {
|
|
412
|
-
console.log(JSON.stringify(
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
441
|
+
console.log(JSON.stringify(entries, null, 2));
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Text output: human-friendly table with status markers.
|
|
446
|
+
if (entries.length === 0) {
|
|
416
447
|
console.log("No profiles found.");
|
|
417
|
-
|
|
418
|
-
console.log(profiles.join("\n"));
|
|
448
|
+
return;
|
|
419
449
|
}
|
|
450
|
+
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 =
|
|
458
|
+
e.status === "expired" ? "EXPIRED"
|
|
459
|
+
: e.status === "valid" ? "valid"
|
|
460
|
+
: "unknown";
|
|
461
|
+
const idStr = e.identity ? describeIdentity(e.identity) : "(no identity)";
|
|
462
|
+
const exp = e.expirationTime ? ` (${e.expirationTime})` : "";
|
|
463
|
+
console.log(
|
|
464
|
+
`${marker} ${e.name.padEnd(nameW)} ${statusLabel.padEnd(statusW)} ${idStr}${e.status === "expired" ? exp : ""}`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
console.log();
|
|
468
|
+
console.log(
|
|
469
|
+
`Legend: * = default profile, EXPIRED = OAuth session needs 'wrangler-accounts login <name>'`,
|
|
470
|
+
);
|
|
420
471
|
return;
|
|
421
472
|
}
|
|
422
473
|
|
package/lib/identity.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawnSync: defaultSpawnSync } = require('node:child_process');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const { resolvePath, detectConfigPath } = require('./paths');
|
|
7
|
+
const { listProfiles, readMeta } = require('./profile-store');
|
|
8
|
+
|
|
9
|
+
function parseWranglerWhoamiOutput(output) {
|
|
10
|
+
const text = output || '';
|
|
11
|
+
const emailMatch = text.match(/associated with the email ([^\n]+?)\.\s*$/m);
|
|
12
|
+
const rowRegex = /│\s*(.+?)\s*│\s*([a-f0-9]{32})\s*│/g;
|
|
13
|
+
const rows = [...text.matchAll(rowRegex)];
|
|
14
|
+
const row = rows[0];
|
|
15
|
+
|
|
16
|
+
if (!emailMatch && !row) return null;
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
email: emailMatch ? emailMatch[1].trim() : null,
|
|
20
|
+
accountName: row ? row[1].trim() : null,
|
|
21
|
+
accountId: row ? row[2].trim() : null,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getWranglerAuthPath(env = process.env) {
|
|
26
|
+
return detectConfigPath(undefined, env);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function canInspectIdentity(configPath, env = process.env) {
|
|
30
|
+
return resolvePath(configPath) === resolvePath(getWranglerAuthPath(env));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getCurrentIdentity(configPath, { env = process.env, spawn = defaultSpawnSync } = {}) {
|
|
34
|
+
if (!canInspectIdentity(configPath, env)) {
|
|
35
|
+
return {
|
|
36
|
+
identity: null,
|
|
37
|
+
error: `Identity lookup only works for the active Wrangler auth file: ${getWranglerAuthPath(env)}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const result = spawn('wrangler', ['whoami'], { encoding: 'utf8' });
|
|
42
|
+
|
|
43
|
+
if (result.error) {
|
|
44
|
+
return { identity: null, error: result.error.message };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const output = `${result.stdout || ''}\n${result.stderr || ''}`;
|
|
48
|
+
if (result.status !== 0) {
|
|
49
|
+
return {
|
|
50
|
+
identity: null,
|
|
51
|
+
error: output.includes('Not logged in')
|
|
52
|
+
? 'Not logged in'
|
|
53
|
+
: `wrangler whoami exited with code ${result.status}`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const identity = parseWranglerWhoamiOutput(output);
|
|
58
|
+
if (!identity) {
|
|
59
|
+
return { identity: null, error: "Failed to parse 'wrangler whoami' output" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { identity, error: null };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getMetaIdentity(meta) {
|
|
66
|
+
if (!meta || !meta.identity) return null;
|
|
67
|
+
const { email = null, accountName = null, accountId = null } = meta.identity;
|
|
68
|
+
if (!email && !accountName && !accountId) return null;
|
|
69
|
+
return { email, accountName, accountId };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function identitiesMatch(left, right) {
|
|
73
|
+
if (!left || !right) return false;
|
|
74
|
+
if (left.accountId && right.accountId) return left.accountId === right.accountId;
|
|
75
|
+
if (left.email && right.email) return left.email === right.email;
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function describeIdentity(identity) {
|
|
80
|
+
if (!identity) return 'unknown';
|
|
81
|
+
const parts = [];
|
|
82
|
+
if (identity.email) parts.push(identity.email);
|
|
83
|
+
if (identity.accountId) parts.push(identity.accountId);
|
|
84
|
+
return parts.join(' / ') || 'unknown';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function findProfilesByIdentity(profilesDir, identity, { includeBackups = false } = {}) {
|
|
88
|
+
if (!identity) return [];
|
|
89
|
+
const profiles = listProfiles(profilesDir, { includeBackups });
|
|
90
|
+
return profiles.filter((name) => {
|
|
91
|
+
const meta = readMeta(path.join(profilesDir, name));
|
|
92
|
+
return identitiesMatch(identity, getMetaIdentity(meta));
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = {
|
|
97
|
+
parseWranglerWhoamiOutput,
|
|
98
|
+
getWranglerAuthPath,
|
|
99
|
+
canInspectIdentity,
|
|
100
|
+
getCurrentIdentity,
|
|
101
|
+
getMetaIdentity,
|
|
102
|
+
identitiesMatch,
|
|
103
|
+
describeIdentity,
|
|
104
|
+
findProfilesByIdentity,
|
|
105
|
+
};
|
package/lib/isolation.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { spawnSync } = require('node:child_process');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create a per-invocation shadow HOME directory.
|
|
10
|
+
*
|
|
11
|
+
* Layout:
|
|
12
|
+
* $shadow/
|
|
13
|
+
* .wrangler/config/default.toml → symlink to profileCfg (token refresh
|
|
14
|
+
* syncs back)
|
|
15
|
+
* .npmrc → symlink to $realHome/.npmrc
|
|
16
|
+
* .ssh → symlink to $realHome/.ssh
|
|
17
|
+
* Library → symlink to $realHome/Library
|
|
18
|
+
* ... every other top-level entry in realHome except `.wrangler`
|
|
19
|
+
*
|
|
20
|
+
* Caller MUST call cleanupShadow(shadow) when done.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} args
|
|
23
|
+
* @param {string} args.realHome
|
|
24
|
+
* @param {string} args.profileCfg - path to the profile's config.toml file
|
|
25
|
+
* @param {string} [args.label] - optional label for the tmpdir name
|
|
26
|
+
* @returns {string} path to the shadow HOME
|
|
27
|
+
*/
|
|
28
|
+
function createShadowHome({ realHome, profileCfg, label = 'wa' }) {
|
|
29
|
+
if (!realHome || !fs.existsSync(realHome)) {
|
|
30
|
+
throw new Error(`real HOME does not exist: ${realHome}`);
|
|
31
|
+
}
|
|
32
|
+
if (!profileCfg || !fs.existsSync(profileCfg)) {
|
|
33
|
+
throw new Error(`profile config does not exist: ${profileCfg}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const shadow = fs.mkdtempSync(path.join(os.tmpdir(), `${label}-`));
|
|
37
|
+
fs.chmodSync(shadow, 0o700);
|
|
38
|
+
|
|
39
|
+
// Mirror every top-level entry from real HOME except .wrangler.
|
|
40
|
+
for (const entry of fs.readdirSync(realHome)) {
|
|
41
|
+
if (entry === '.wrangler') continue;
|
|
42
|
+
try {
|
|
43
|
+
fs.symlinkSync(
|
|
44
|
+
path.join(realHome, entry),
|
|
45
|
+
path.join(shadow, entry),
|
|
46
|
+
);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
// If symlinking a specific entry fails (permissions, weird file type),
|
|
49
|
+
// log to stderr and continue. Missing entries are a UX problem, not a
|
|
50
|
+
// correctness one — the subprocess will just not find that file.
|
|
51
|
+
process.stderr.write(
|
|
52
|
+
`[wrangler-accounts] skip symlink ${entry}: ${err.message}\n`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The one file that matters — a real symlink to the profile file so
|
|
58
|
+
// Wrangler's in-place writeFileSync token refreshes flow back into the
|
|
59
|
+
// saved profile automatically.
|
|
60
|
+
const shadowWranglerConfig = path.join(shadow, '.wrangler', 'config');
|
|
61
|
+
fs.mkdirSync(shadowWranglerConfig, { recursive: true });
|
|
62
|
+
fs.symlinkSync(
|
|
63
|
+
profileCfg,
|
|
64
|
+
path.join(shadowWranglerConfig, 'default.toml'),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return shadow;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Remove a shadow HOME. Safe because the shadow contains only symlinks and
|
|
72
|
+
* small directories owned by this process. fs.rmSync does not follow
|
|
73
|
+
* symlinks (it unlinks them), so files in real HOME are never at risk.
|
|
74
|
+
*/
|
|
75
|
+
function cleanupShadow(shadow) {
|
|
76
|
+
if (!shadow) return;
|
|
77
|
+
try {
|
|
78
|
+
fs.rmSync(shadow, { recursive: true, force: true });
|
|
79
|
+
} catch (err) {
|
|
80
|
+
process.stderr.write(
|
|
81
|
+
`[wrangler-accounts] cleanup warning: ${err.message}\n`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build the environment variable set that every isolated child process gets.
|
|
88
|
+
* This is a pure function — no I/O.
|
|
89
|
+
*/
|
|
90
|
+
function buildIsolatedEnv({
|
|
91
|
+
shadow,
|
|
92
|
+
realHome,
|
|
93
|
+
profile,
|
|
94
|
+
baseEnv = process.env,
|
|
95
|
+
cloudflaredPath = null,
|
|
96
|
+
}) {
|
|
97
|
+
const env = { ...baseEnv };
|
|
98
|
+
env.HOME = shadow;
|
|
99
|
+
env.WRANGLER_PROFILE = profile;
|
|
100
|
+
env.WRANGLER_ACCOUNT = profile;
|
|
101
|
+
env.WRANGLER_ACCOUNT_REAL_HOME = realHome;
|
|
102
|
+
env.WRANGLER_REGISTRY_PATH = path.join(realHome, '.wrangler', 'registry');
|
|
103
|
+
env.WRANGLER_CACHE_DIR = path.join(realHome, '.wrangler', 'cache');
|
|
104
|
+
env.WRANGLER_LOG_PATH = path.join(realHome, '.wrangler', 'logs');
|
|
105
|
+
env.WRANGLER_SEND_METRICS = 'false';
|
|
106
|
+
if (cloudflaredPath) {
|
|
107
|
+
env.CLOUDFLARED_PATH = cloudflaredPath;
|
|
108
|
+
}
|
|
109
|
+
return env;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Spawn a command inside a shadow HOME for the given profile.
|
|
114
|
+
* Handles setup, spawning, and cleanup in a try/finally so cleanup runs
|
|
115
|
+
* even on unexpected errors.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} args
|
|
118
|
+
* @param {string} args.profile
|
|
119
|
+
* @param {string} args.profileCfg
|
|
120
|
+
* @param {string} args.realHome
|
|
121
|
+
* @param {string} args.command
|
|
122
|
+
* @param {string[]} args.args
|
|
123
|
+
* @param {object} [args.baseEnv]
|
|
124
|
+
* @param {boolean} [args.captureStdout]
|
|
125
|
+
* @param {string|null} [args.cloudflaredPath]
|
|
126
|
+
* @returns {{exitCode: number, stdout?: string, stderr?: string}}
|
|
127
|
+
*/
|
|
128
|
+
function runIsolated({
|
|
129
|
+
profile,
|
|
130
|
+
profileCfg,
|
|
131
|
+
realHome,
|
|
132
|
+
command,
|
|
133
|
+
args,
|
|
134
|
+
baseEnv = process.env,
|
|
135
|
+
captureStdout = false,
|
|
136
|
+
cloudflaredPath = null,
|
|
137
|
+
}) {
|
|
138
|
+
const shadow = createShadowHome({
|
|
139
|
+
realHome,
|
|
140
|
+
profileCfg,
|
|
141
|
+
label: `wa-${profile}`,
|
|
142
|
+
});
|
|
143
|
+
const env = buildIsolatedEnv({
|
|
144
|
+
shadow,
|
|
145
|
+
realHome,
|
|
146
|
+
profile,
|
|
147
|
+
baseEnv,
|
|
148
|
+
cloudflaredPath,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
let result;
|
|
152
|
+
try {
|
|
153
|
+
result = spawnSync(command, args, {
|
|
154
|
+
stdio: captureStdout ? ['inherit', 'pipe', 'pipe'] : 'inherit',
|
|
155
|
+
env,
|
|
156
|
+
encoding: 'utf8',
|
|
157
|
+
});
|
|
158
|
+
} finally {
|
|
159
|
+
cleanupShadow(shadow);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// spawnSync surfaces spawn-time errors (ENOENT, EACCES, etc.) via
|
|
163
|
+
// result.error. Forward the message to stderr so the user can diagnose
|
|
164
|
+
// "command not found" scenarios instead of seeing a silent exit 1.
|
|
165
|
+
if (result.error) {
|
|
166
|
+
process.stderr.write(
|
|
167
|
+
`[wrangler-accounts] failed to spawn '${command}': ${result.error.message}\n`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const exitCode =
|
|
172
|
+
result.status == null ? (result.signal ? 128 : 1) : result.status;
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
exitCode,
|
|
176
|
+
stdout: captureStdout ? result.stdout || '' : undefined,
|
|
177
|
+
stderr: captureStdout ? result.stderr || '' : undefined,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = {
|
|
182
|
+
createShadowHome,
|
|
183
|
+
cleanupShadow,
|
|
184
|
+
buildIsolatedEnv,
|
|
185
|
+
runIsolated,
|
|
186
|
+
};
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
function expandHome(p) {
|
|
8
|
+
if (!p) return p;
|
|
9
|
+
if (p === '~') return os.homedir();
|
|
10
|
+
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
|
|
11
|
+
return p;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function resolvePath(p) {
|
|
15
|
+
if (!p) return p;
|
|
16
|
+
return path.resolve(expandHome(p));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function detectConfigPath(cliPath, env = process.env) {
|
|
20
|
+
if (cliPath) return resolvePath(cliPath);
|
|
21
|
+
if (env.WRANGLER_CONFIG_PATH) {
|
|
22
|
+
return resolvePath(env.WRANGLER_CONFIG_PATH);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const home = os.homedir();
|
|
26
|
+
const candidates = [
|
|
27
|
+
path.join(home, '.wrangler', 'config', 'default.toml'),
|
|
28
|
+
path.join(home, 'Library', 'Preferences', '.wrangler', 'config', 'default.toml'),
|
|
29
|
+
path.join(home, '.config', '.wrangler', 'config', 'default.toml'),
|
|
30
|
+
path.join(home, '.config', 'wrangler', 'config', 'default.toml'),
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
for (const candidate of candidates) {
|
|
34
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return candidates[0];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function detectProfilesDir(cliPath, env = process.env) {
|
|
41
|
+
if (cliPath) return resolvePath(cliPath);
|
|
42
|
+
if (env.WRANGLER_ACCOUNTS_DIR) {
|
|
43
|
+
return resolvePath(env.WRANGLER_ACCOUNTS_DIR);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
47
|
+
if (xdg) return path.join(resolvePath(xdg), 'wrangler-accounts');
|
|
48
|
+
|
|
49
|
+
return path.join(os.homedir(), '.config', 'wrangler-accounts');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = {
|
|
53
|
+
expandHome,
|
|
54
|
+
resolvePath,
|
|
55
|
+
detectConfigPath,
|
|
56
|
+
detectProfilesDir,
|
|
57
|
+
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
function ensureDir(dirPath) {
|
|
8
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isValidName(name) {
|
|
12
|
+
return /^[A-Za-z0-9._-]+$/.test(name);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isBackupName(name) {
|
|
16
|
+
return name.startsWith('__backup-');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function listProfiles(profilesDir, { includeBackups = false } = {}) {
|
|
20
|
+
if (!fs.existsSync(profilesDir)) return [];
|
|
21
|
+
const entries = fs.readdirSync(profilesDir, { withFileTypes: true });
|
|
22
|
+
return entries
|
|
23
|
+
.filter((entry) => entry.isDirectory())
|
|
24
|
+
.map((entry) => entry.name)
|
|
25
|
+
.filter((name) => includeBackups || !isBackupName(name))
|
|
26
|
+
.filter((name) => fs.existsSync(path.join(profilesDir, name, 'config.toml')))
|
|
27
|
+
.sort();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function fileHash(filePath) {
|
|
31
|
+
const data = fs.readFileSync(filePath);
|
|
32
|
+
return crypto.createHash('sha256').update(data).digest('hex');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readExpirationTime(filePath) {
|
|
36
|
+
if (!fs.existsSync(filePath)) return null;
|
|
37
|
+
const text = fs.readFileSync(filePath, 'utf8');
|
|
38
|
+
const match = text.match(/^\s*expiration_time\s*=\s*"([^"]+)"/m);
|
|
39
|
+
return match ? match[1] : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readSessionState(filePath) {
|
|
43
|
+
const expirationTime = readExpirationTime(filePath);
|
|
44
|
+
if (!expirationTime) {
|
|
45
|
+
return { expirationTime: null, expired: null };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const expiresAt = new Date(expirationTime);
|
|
49
|
+
if (Number.isNaN(expiresAt.getTime())) {
|
|
50
|
+
return { expirationTime, expired: null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
expirationTime,
|
|
55
|
+
expired: expiresAt.getTime() <= Date.now(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function filesEqual(pathA, pathB) {
|
|
60
|
+
if (!fs.existsSync(pathA) || !fs.existsSync(pathB)) return false;
|
|
61
|
+
const statA = fs.statSync(pathA);
|
|
62
|
+
const statB = fs.statSync(pathB);
|
|
63
|
+
if (statA.size !== statB.size) return false;
|
|
64
|
+
return fileHash(pathA) === fileHash(pathB);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeMeta(profileDir, name, sourcePath, identity = null) {
|
|
68
|
+
const configPath = path.join(profileDir, 'config.toml');
|
|
69
|
+
const stat = fs.statSync(configPath);
|
|
70
|
+
const meta = {
|
|
71
|
+
name,
|
|
72
|
+
savedAt: new Date().toISOString(),
|
|
73
|
+
sourcePath,
|
|
74
|
+
bytes: stat.size,
|
|
75
|
+
sha256: fileHash(configPath),
|
|
76
|
+
};
|
|
77
|
+
if (identity) {
|
|
78
|
+
meta.identity = identity;
|
|
79
|
+
}
|
|
80
|
+
fs.writeFileSync(path.join(profileDir, 'meta.json'), JSON.stringify(meta, null, 2));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readMeta(profileDir) {
|
|
84
|
+
const metaPath = path.join(profileDir, 'meta.json');
|
|
85
|
+
if (!fs.existsSync(metaPath)) return null;
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function setActiveProfile(profilesDir, name) {
|
|
94
|
+
ensureDir(profilesDir);
|
|
95
|
+
fs.writeFileSync(path.join(profilesDir, 'active'), `${name}\n`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getActiveProfile(profilesDir) {
|
|
99
|
+
const activePath = path.join(profilesDir, 'active');
|
|
100
|
+
if (!fs.existsSync(activePath)) return null;
|
|
101
|
+
const value = fs.readFileSync(activePath, 'utf8').trim();
|
|
102
|
+
return value.length ? value : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function getDefaultProfile(profilesDir) {
|
|
106
|
+
const p = path.join(profilesDir, 'default');
|
|
107
|
+
if (!fs.existsSync(p)) return null;
|
|
108
|
+
const raw = fs.readFileSync(p, 'utf8').trim();
|
|
109
|
+
return raw.length ? raw : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function setDefaultProfile(profilesDir, name) {
|
|
113
|
+
ensureDir(profilesDir);
|
|
114
|
+
fs.writeFileSync(path.join(profilesDir, 'default'), `${name}\n`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function unsetDefaultProfile(profilesDir) {
|
|
118
|
+
const p = path.join(profilesDir, 'default');
|
|
119
|
+
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function timestampForFile() {
|
|
123
|
+
const now = new Date();
|
|
124
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
125
|
+
return [
|
|
126
|
+
now.getFullYear(),
|
|
127
|
+
pad(now.getMonth() + 1),
|
|
128
|
+
pad(now.getDate()),
|
|
129
|
+
'-',
|
|
130
|
+
pad(now.getHours()),
|
|
131
|
+
pad(now.getMinutes()),
|
|
132
|
+
pad(now.getSeconds()),
|
|
133
|
+
].join('');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function backupCurrentConfig(configPath, profilesDir) {
|
|
137
|
+
const backupName = `__backup-${timestampForFile()}`;
|
|
138
|
+
const backupDir = path.join(profilesDir, backupName);
|
|
139
|
+
ensureDir(backupDir);
|
|
140
|
+
fs.copyFileSync(configPath, path.join(backupDir, 'config.toml'));
|
|
141
|
+
writeMeta(backupDir, backupName, configPath);
|
|
142
|
+
return backupName;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function findMatchingProfile(profilesDir, configPath, { includeBackups = false } = {}) {
|
|
146
|
+
if (!fs.existsSync(configPath)) return null;
|
|
147
|
+
const configHash = fileHash(configPath);
|
|
148
|
+
const profiles = listProfiles(profilesDir, { includeBackups });
|
|
149
|
+
for (const name of profiles) {
|
|
150
|
+
const profileConfig = path.join(profilesDir, name, 'config.toml');
|
|
151
|
+
if (fileHash(profileConfig) === configHash) return name;
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function saveProfile(name, configPath, profilesDir, force, identity = null) {
|
|
157
|
+
if (!isValidName(name)) {
|
|
158
|
+
throw new Error(`Invalid profile name: ${name}`);
|
|
159
|
+
}
|
|
160
|
+
if (!fs.existsSync(configPath)) {
|
|
161
|
+
throw new Error(`Config file not found: ${configPath}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const profileDir = path.join(profilesDir, name);
|
|
165
|
+
if (fs.existsSync(profileDir) && !force) {
|
|
166
|
+
throw new Error(`Profile exists: ${name} (use --force to overwrite)`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
ensureDir(profileDir);
|
|
170
|
+
fs.copyFileSync(configPath, path.join(profileDir, 'config.toml'));
|
|
171
|
+
writeMeta(profileDir, name, configPath, identity);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function removeProfile(name, profilesDir) {
|
|
175
|
+
if (!isValidName(name)) {
|
|
176
|
+
throw new Error(`Invalid profile name: ${name}`);
|
|
177
|
+
}
|
|
178
|
+
const profileDir = path.join(profilesDir, name);
|
|
179
|
+
if (!fs.existsSync(profileDir)) {
|
|
180
|
+
throw new Error(`Profile not found: ${name}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
fs.rmSync(profileDir, { recursive: true, force: true });
|
|
184
|
+
|
|
185
|
+
const active = getActiveProfile(profilesDir);
|
|
186
|
+
if (active === name) {
|
|
187
|
+
const activePath = path.join(profilesDir, 'active');
|
|
188
|
+
if (fs.existsSync(activePath)) fs.unlinkSync(activePath);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = {
|
|
193
|
+
ensureDir,
|
|
194
|
+
isValidName,
|
|
195
|
+
isBackupName,
|
|
196
|
+
listProfiles,
|
|
197
|
+
fileHash,
|
|
198
|
+
readExpirationTime,
|
|
199
|
+
readSessionState,
|
|
200
|
+
filesEqual,
|
|
201
|
+
writeMeta,
|
|
202
|
+
readMeta,
|
|
203
|
+
setActiveProfile,
|
|
204
|
+
getActiveProfile,
|
|
205
|
+
getDefaultProfile,
|
|
206
|
+
setDefaultProfile,
|
|
207
|
+
unsetDefaultProfile,
|
|
208
|
+
timestampForFile,
|
|
209
|
+
backupCurrentConfig,
|
|
210
|
+
findMatchingProfile,
|
|
211
|
+
saveProfile,
|
|
212
|
+
removeProfile,
|
|
213
|
+
};
|
package/lib/resolve.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const { isValidName, getDefaultProfile } = require('./profile-store');
|
|
7
|
+
|
|
8
|
+
class ResolveError extends Error {
|
|
9
|
+
constructor(message, code = 'PROFILE_NOT_FOUND') {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'ResolveError';
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function profileExists(name, profilesDir) {
|
|
17
|
+
return fs.existsSync(path.join(profilesDir, name, 'config.toml'));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function assertValid(name) {
|
|
21
|
+
if (!isValidName(name)) {
|
|
22
|
+
throw new ResolveError(`Invalid profile name: ${name}`, 'INVALID_NAME');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function assertExists(name, profilesDir) {
|
|
27
|
+
if (!profileExists(name, profilesDir)) {
|
|
28
|
+
throw new ResolveError(`Profile not found: ${name}`, 'PROFILE_NOT_FOUND');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a profile name using AWS-style precedence.
|
|
34
|
+
*
|
|
35
|
+
* Order:
|
|
36
|
+
* 1. Explicit `--profile <name>` / `-p <name>` (cliProfile)
|
|
37
|
+
* 2. Positional shorthand — first positional arg, only when it matches a
|
|
38
|
+
* saved profile AND is not a management subcommand name
|
|
39
|
+
* 3. `$WRANGLER_PROFILE`
|
|
40
|
+
* 4. Persistent default from profilesDir/default
|
|
41
|
+
* 5. Hard error with actionable hint
|
|
42
|
+
*
|
|
43
|
+
* @param {object} args
|
|
44
|
+
* @param {string|null} args.cliProfile
|
|
45
|
+
* @param {string|null} args.positional
|
|
46
|
+
* @param {object} args.env
|
|
47
|
+
* @param {string} args.profilesDir
|
|
48
|
+
* @param {Set<string>} [args.managementSubcommands]
|
|
49
|
+
* @returns {{ name: string, source: 'cli' | 'positional' | 'env' | 'default' }}
|
|
50
|
+
* @throws ResolveError
|
|
51
|
+
*/
|
|
52
|
+
function resolveProfile({
|
|
53
|
+
cliProfile,
|
|
54
|
+
positional,
|
|
55
|
+
env,
|
|
56
|
+
profilesDir,
|
|
57
|
+
managementSubcommands = new Set(),
|
|
58
|
+
}) {
|
|
59
|
+
// 1. Explicit --profile / -p
|
|
60
|
+
if (cliProfile) {
|
|
61
|
+
assertValid(cliProfile);
|
|
62
|
+
assertExists(cliProfile, profilesDir);
|
|
63
|
+
return { name: cliProfile, source: 'cli' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 2. Positional shorthand
|
|
67
|
+
if (positional && !managementSubcommands.has(positional)) {
|
|
68
|
+
if (isValidName(positional) && profileExists(positional, profilesDir)) {
|
|
69
|
+
return { name: positional, source: 'positional' };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 3. Env var
|
|
74
|
+
const envProfile = env && env.WRANGLER_PROFILE;
|
|
75
|
+
if (envProfile && envProfile.length) {
|
|
76
|
+
assertValid(envProfile);
|
|
77
|
+
assertExists(envProfile, profilesDir);
|
|
78
|
+
return { name: envProfile, source: 'env' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 4. Persistent default
|
|
82
|
+
const def = getDefaultProfile(profilesDir);
|
|
83
|
+
if (def) {
|
|
84
|
+
assertValid(def);
|
|
85
|
+
assertExists(def, profilesDir);
|
|
86
|
+
return { name: def, source: 'default' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 5. Error
|
|
90
|
+
throw new ResolveError(
|
|
91
|
+
[
|
|
92
|
+
'No profile specified. Options:',
|
|
93
|
+
' - wrangler-accounts --profile <name> ...',
|
|
94
|
+
' - WRANGLER_PROFILE=<name> wrangler-accounts ...',
|
|
95
|
+
' - wrangler-accounts default <name> (set a persistent default)',
|
|
96
|
+
].join('\n'),
|
|
97
|
+
'NO_PROFILE',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { resolveProfile, ResolveError };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leeguoo/wrangler-accounts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.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": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin",
|
|
11
|
+
"lib",
|
|
11
12
|
"completions",
|
|
12
13
|
"skills"
|
|
13
14
|
],
|
|
@@ -33,7 +34,9 @@
|
|
|
33
34
|
"access": "public"
|
|
34
35
|
},
|
|
35
36
|
"scripts": {
|
|
36
|
-
"test": "node --test test/*.test.js"
|
|
37
|
+
"test": "node --test test/*.test.js",
|
|
38
|
+
"verify-package": "node scripts/verify-package.js",
|
|
39
|
+
"prepublishOnly": "npm test && npm run verify-package"
|
|
37
40
|
},
|
|
38
41
|
"engines": {
|
|
39
42
|
"node": ">=16"
|