claude-aws-mfa 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.
- package/.github/workflows/release.yml +86 -37
- package/CHANGELOG.md +16 -0
- package/README.md +30 -6
- package/package.json +1 -1
- package/src/autosize-html.ts +31 -0
- package/src/autosize.ts +39 -0
- package/src/claude-settings.ts +13 -5
- package/src/config.ts +80 -3
- package/src/credential-export.ts +32 -0
- package/src/dialog-html.ts +76 -3
- package/src/dialog.ts +36 -6
- package/src/flags.ts +5 -0
- package/src/index.ts +83 -55
- package/src/setup-html.ts +16 -3
- package/src/setup.ts +12 -15
- package/test/autosize.test.ts +56 -0
- package/test/config.test.ts +77 -0
- package/test/dialog-html.test.ts +53 -1
- package/test/index.test.ts +5 -0
- package/test/setup.test.ts +59 -0
package/src/dialog-html.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { STANDARD_DURATIONS } from "./sts";
|
|
2
|
+
import { AUTOSIZE_CSS, AUTOSIZE_SCRIPT } from "./autosize-html";
|
|
3
|
+
import { DEFAULT_PROFILE, type ActiveProfile } from "./config";
|
|
2
4
|
|
|
3
5
|
export const FIELDS = ["region", "accessKeyId", "secretAccessKey", "mfaArn", "roleArn", "duration", "mfaMode", "mfaCode", "mfaCommand"] as const;
|
|
4
6
|
|
|
@@ -13,7 +15,30 @@ export const FIELD_PATTERNS: Record<string, string> = {
|
|
|
13
15
|
mfaCommand: "^.+$",
|
|
14
16
|
};
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Banner naming the Claude Code profile a dialog belongs to. Always shown, so
|
|
20
|
+
* its absence never has to be interpreted. The name is never truncated — it is
|
|
21
|
+
* what tells two dialogs apart — while the path ellipsizes beside it. It sits
|
|
22
|
+
* inside `#fit`, so the window grows to fit it (see `./autosize`).
|
|
23
|
+
*/
|
|
24
|
+
export const PROFILE_STYLE = /*css*/ `
|
|
25
|
+
.profile {
|
|
26
|
+
display: flex; gap: 6px; align-items: baseline;
|
|
27
|
+
font-size: 12px; color: #6e6e73; background: #e8e8ed;
|
|
28
|
+
border: 1px solid #d2d2d7; border-radius: 6px;
|
|
29
|
+
padding: 6px 8px; margin: -8px 0 16px;
|
|
30
|
+
}
|
|
31
|
+
.profile b { color: #1d1d1f; font-weight: 600; flex-shrink: 0; }
|
|
32
|
+
.profile span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
export function profileBanner(profile: ActiveProfile): string {
|
|
36
|
+
return /*html*/ `<div class="profile" title="${escapeHtml(profile.path)}">Profile <b>${escapeHtml(
|
|
37
|
+
profile.name,
|
|
38
|
+
)}</b><span>${escapeHtml(profile.path)}</span></div>`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function buildHtml(config: Record<string, string>, profile: ActiveProfile = DEFAULT_PROFILE) {
|
|
17
42
|
return /*html*/ `<!DOCTYPE html>
|
|
18
43
|
<html>
|
|
19
44
|
<head>
|
|
@@ -22,8 +47,9 @@ export function buildHtml(config: Record<string, string>) {
|
|
|
22
47
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
23
48
|
body {
|
|
24
49
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
25
|
-
|
|
50
|
+
background: #f5f5f7; color: #1d1d1f;
|
|
26
51
|
}
|
|
52
|
+
${AUTOSIZE_CSS}
|
|
27
53
|
h2 { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
|
|
28
54
|
.field { margin-bottom: 12px; }
|
|
29
55
|
label { display: block; font-size: 12px; font-weight: 500; margin-bottom: 4px; transition: color 0.15s; }
|
|
@@ -51,10 +77,13 @@ export function buildHtml(config: Record<string, string>) {
|
|
|
51
77
|
}
|
|
52
78
|
.radio-field input[type="text"] { flex: 1; width: 0; min-width: 0; }
|
|
53
79
|
input:disabled { background: #f0f0f0; color: #999; cursor: not-allowed; }
|
|
80
|
+
${PROFILE_STYLE}
|
|
54
81
|
</style>
|
|
55
82
|
</head>
|
|
56
83
|
<body>
|
|
84
|
+
<div id="fit">
|
|
57
85
|
<h2>Claude AWS MFA</h2>
|
|
86
|
+
${profileBanner(profile)}
|
|
58
87
|
<div class="field"><label>AWS Region</label><input id="region" placeholder="us-east-1"></div>
|
|
59
88
|
<div class="field"><label>Access Key ID</label><input id="accessKeyId" type="password" placeholder="AKIA..."></div>
|
|
60
89
|
<div class="field"><label>Secret Access Key</label><input id="secretAccessKey" type="password"></div>
|
|
@@ -76,7 +105,8 @@ export function buildHtml(config: Record<string, string>) {
|
|
|
76
105
|
<button onclick="_cancel()">Cancel</button>
|
|
77
106
|
<button class="primary" onclick="submit()">OK</button>
|
|
78
107
|
</div>
|
|
79
|
-
|
|
108
|
+
</div>
|
|
109
|
+
<script>${AUTOSIZE_SCRIPT}
|
|
80
110
|
document.querySelectorAll("input").forEach(el => {
|
|
81
111
|
el.setAttribute("autocorrect", "off");
|
|
82
112
|
el.setAttribute("autocomplete", "off");
|
|
@@ -177,3 +207,46 @@ export function buildHtml(config: Record<string, string>) {
|
|
|
177
207
|
</body>
|
|
178
208
|
</html>`;
|
|
179
209
|
}
|
|
210
|
+
|
|
211
|
+
function escapeHtml(s: string): string {
|
|
212
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function buildErrorHtml(message: string, profile: ActiveProfile = DEFAULT_PROFILE): string {
|
|
216
|
+
return /*html*/ `<!DOCTYPE html>
|
|
217
|
+
<html>
|
|
218
|
+
<head>
|
|
219
|
+
<meta charset="utf-8">
|
|
220
|
+
<style>
|
|
221
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
222
|
+
body {
|
|
223
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
224
|
+
background: #f5f5f7; color: #1d1d1f;
|
|
225
|
+
}
|
|
226
|
+
${AUTOSIZE_CSS}
|
|
227
|
+
h2 { font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #ff3b30; }
|
|
228
|
+
p { font-size: 13px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; margin-bottom: 16px; }
|
|
229
|
+
.buttons { display: flex; justify-content: flex-end; }
|
|
230
|
+
button {
|
|
231
|
+
padding: 8px 20px; border-radius: 6px; font-size: 14px; cursor: pointer;
|
|
232
|
+
border: 1px solid #d2d2d7; background: #0071e3; color: #fff;
|
|
233
|
+
}
|
|
234
|
+
button:hover { filter: brightness(0.95); }
|
|
235
|
+
${PROFILE_STYLE}
|
|
236
|
+
</style>
|
|
237
|
+
</head>
|
|
238
|
+
<body>
|
|
239
|
+
<div id="fit">
|
|
240
|
+
<h2>Authentication Failed</h2>
|
|
241
|
+
${profileBanner(profile)}
|
|
242
|
+
<p>${escapeHtml(message)}</p>
|
|
243
|
+
<div class="buttons"><button onclick="_ok()">OK</button></div>
|
|
244
|
+
</div>
|
|
245
|
+
<script>${AUTOSIZE_SCRIPT}
|
|
246
|
+
document.addEventListener("keydown", e => {
|
|
247
|
+
if (e.key === "Enter" || e.key === "Escape") _ok();
|
|
248
|
+
});
|
|
249
|
+
</script>
|
|
250
|
+
</body>
|
|
251
|
+
</html>`;
|
|
252
|
+
}
|
package/src/dialog.ts
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import { Webview, SizeHint } from "webview-bun";
|
|
2
2
|
import { lib } from "webview-bun/src/ffi";
|
|
3
|
-
import type
|
|
4
|
-
import { buildHtml } from "./dialog-html";
|
|
3
|
+
import { activeProfile, type Config } from "./config";
|
|
4
|
+
import { buildHtml, buildErrorHtml } from "./dialog-html";
|
|
5
|
+
import { START_HEIGHT, bindAutosize } from "./autosize";
|
|
5
6
|
import { version } from "../package.json";
|
|
6
7
|
|
|
8
|
+
const DIALOG_WIDTH = 440;
|
|
9
|
+
const DIALOG_MAX_HEIGHT = 800;
|
|
10
|
+
|
|
11
|
+
const ERROR_WIDTH = 420;
|
|
12
|
+
const ERROR_MAX_HEIGHT = 640;
|
|
13
|
+
|
|
14
|
+
/** Window title, naming the profile so stacked dialogs are told apart in the window list too. */
|
|
15
|
+
export function dialogTitle(suffix = ""): string {
|
|
16
|
+
return `Claude AWS MFA v${version} — ${activeProfile().name}${suffix}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
7
19
|
|
|
8
20
|
export interface DialogResult {
|
|
9
21
|
region: string;
|
|
@@ -33,11 +45,12 @@ function clipboardCommand(): string[] {
|
|
|
33
45
|
|
|
34
46
|
export function showDialog(defaults: Partial<Config>): DialogResult | null {
|
|
35
47
|
const webview = new Webview(false, {
|
|
36
|
-
width:
|
|
37
|
-
height:
|
|
48
|
+
width: DIALOG_WIDTH,
|
|
49
|
+
height: START_HEIGHT,
|
|
38
50
|
hint: SizeHint.FIXED,
|
|
39
51
|
});
|
|
40
|
-
webview.title =
|
|
52
|
+
webview.title = dialogTitle();
|
|
53
|
+
bindAutosize(webview, DIALOG_WIDTH, DIALOG_MAX_HEIGHT);
|
|
41
54
|
|
|
42
55
|
let result: DialogResult | null = null;
|
|
43
56
|
|
|
@@ -70,8 +83,25 @@ export function showDialog(defaults: Partial<Config>): DialogResult | null {
|
|
|
70
83
|
duration: defaults.duration != null ? String(defaults.duration) : "",
|
|
71
84
|
mfaCommand: defaults.mfaCommand ?? "",
|
|
72
85
|
mfaMode: defaults.mfaMode ?? "code",
|
|
73
|
-
}));
|
|
86
|
+
}, activeProfile()));
|
|
74
87
|
|
|
75
88
|
webview.run();
|
|
76
89
|
return result;
|
|
77
90
|
}
|
|
91
|
+
|
|
92
|
+
/** Show a modal dialog reporting an authentication failure (Claude Code doesn't surface stderr). */
|
|
93
|
+
export function showErrorDialog(message: string): void {
|
|
94
|
+
const webview = new Webview(false, {
|
|
95
|
+
width: ERROR_WIDTH,
|
|
96
|
+
height: START_HEIGHT,
|
|
97
|
+
hint: SizeHint.FIXED,
|
|
98
|
+
});
|
|
99
|
+
webview.title = dialogTitle(" — Error");
|
|
100
|
+
bindAutosize(webview, ERROR_WIDTH, ERROR_MAX_HEIGHT);
|
|
101
|
+
|
|
102
|
+
const handle = webview.unsafeHandle;
|
|
103
|
+
webview.bind("_ok", () => lib.symbols.webview_terminate(handle));
|
|
104
|
+
|
|
105
|
+
webview.setHTML(buildErrorHtml(message, activeProfile()));
|
|
106
|
+
webview.run();
|
|
107
|
+
}
|
package/src/flags.ts
CHANGED
|
@@ -3,6 +3,7 @@ export interface CliFlags {
|
|
|
3
3
|
cacheSession: boolean | undefined; // undefined = use config default
|
|
4
4
|
autoMfa: boolean | undefined;
|
|
5
5
|
singleInstanceLock: boolean | undefined;
|
|
6
|
+
noUi: boolean;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
export function parseFlags(argv: string[]): CliFlags {
|
|
@@ -11,6 +12,7 @@ export function parseFlags(argv: string[]): CliFlags {
|
|
|
11
12
|
cacheSession: undefined,
|
|
12
13
|
autoMfa: undefined,
|
|
13
14
|
singleInstanceLock: undefined,
|
|
15
|
+
noUi: false,
|
|
14
16
|
};
|
|
15
17
|
for (const arg of argv.slice(2)) {
|
|
16
18
|
switch (arg) {
|
|
@@ -36,6 +38,9 @@ export function parseFlags(argv: string[]): CliFlags {
|
|
|
36
38
|
case "--no-single-instance-lock":
|
|
37
39
|
flags.singleInstanceLock = false;
|
|
38
40
|
break;
|
|
41
|
+
case "--no-ui":
|
|
42
|
+
flags.noUi = true;
|
|
43
|
+
break;
|
|
39
44
|
}
|
|
40
45
|
}
|
|
41
46
|
return flags;
|
package/src/index.ts
CHANGED
|
@@ -25,8 +25,7 @@ if (flags.setup) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
import type { CachedSession, Config } from "./config";
|
|
28
|
-
import { loadConfig, saveConfig, seedDefaults, tryAcquireLock, releaseLock, waitForLock } from "./config";
|
|
29
|
-
import { showDialog } from "./dialog";
|
|
28
|
+
import { loadConfig, saveConfig, seedDefaults, tryAcquireLock, releaseLock, waitForLock, missingConfigFields } from "./config";
|
|
30
29
|
import { assumeRoleWithMfa, STANDARD_DURATIONS } from "./sts";
|
|
31
30
|
|
|
32
31
|
// --- Resolve effective flags (CLI overrides config defaults) ---
|
|
@@ -34,9 +33,9 @@ import { assumeRoleWithMfa, STANDARD_DURATIONS } from "./sts";
|
|
|
34
33
|
const config = loadConfig();
|
|
35
34
|
const defaults: Partial<Config> = config ?? await seedDefaults();
|
|
36
35
|
|
|
37
|
-
const useCache = flags.cacheSession ?? defaults.cacheSession ??
|
|
38
|
-
const useAutoMfa = flags.autoMfa ?? defaults.autoMfa ??
|
|
39
|
-
const useLock = flags.singleInstanceLock ?? defaults.singleInstanceLock ??
|
|
36
|
+
const useCache = flags.cacheSession ?? defaults.cacheSession ?? true;
|
|
37
|
+
const useAutoMfa = flags.autoMfa ?? defaults.autoMfa ?? true;
|
|
38
|
+
const useLock = flags.singleInstanceLock ?? defaults.singleInstanceLock ?? true;
|
|
40
39
|
|
|
41
40
|
// --- Helper: check if a cached session is still valid ---
|
|
42
41
|
|
|
@@ -57,59 +56,73 @@ function outputCredentials(session: CachedSession): void {
|
|
|
57
56
|
}));
|
|
58
57
|
}
|
|
59
58
|
|
|
59
|
+
// --- Helper: report a failure, respecting --no-ui (dialog is never shown in that mode) ---
|
|
60
|
+
|
|
61
|
+
async function reportFailure(message: string): Promise<never> {
|
|
62
|
+
if (flags.noUi) {
|
|
63
|
+
// stdout is reserved for cleanly-parsable credentials JSON on success.
|
|
64
|
+
process.stderr.write(`${message}\n`);
|
|
65
|
+
} else {
|
|
66
|
+
const { showErrorDialog } = await import("./dialog");
|
|
67
|
+
showErrorDialog(message);
|
|
68
|
+
}
|
|
69
|
+
process.exit(2);
|
|
70
|
+
}
|
|
71
|
+
|
|
60
72
|
// --- Helper: run MFA command and attempt to obtain credentials without dialog ---
|
|
73
|
+
// Throws (rather than returning null) on failure so callers can decide whether to
|
|
74
|
+
// fall back to the dialog (interactive) or surface the error directly (--no-ui).
|
|
61
75
|
|
|
62
76
|
async function tryAutoMfa(cfg: Partial<Config>): Promise<CachedSession | null> {
|
|
63
77
|
if (!cfg.mfaCommand || cfg.mfaMode !== "command") return null;
|
|
64
78
|
if (!cfg.region || !cfg.accessKeyId || !cfg.secretAccessKey || !cfg.mfaArn || !cfg.roleArn) return null;
|
|
65
79
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const mfaCode = proc.stdout.toString().trim();
|
|
70
|
-
if (!mfaCode) return null;
|
|
71
|
-
|
|
72
|
-
const { credentials, duration } = await assumeRoleWithMfa({
|
|
73
|
-
region: cfg.region,
|
|
74
|
-
accessKeyId: cfg.accessKeyId,
|
|
75
|
-
secretAccessKey: cfg.secretAccessKey,
|
|
76
|
-
mfaArn: cfg.mfaArn,
|
|
77
|
-
roleArn: cfg.roleArn,
|
|
78
|
-
mfaCode,
|
|
79
|
-
duration: cfg.duration ?? STANDARD_DURATIONS[0],
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
const session: CachedSession = {
|
|
83
|
-
AccessKeyId: credentials.AccessKeyId,
|
|
84
|
-
SecretAccessKey: credentials.SecretAccessKey,
|
|
85
|
-
SessionToken: credentials.SessionToken,
|
|
86
|
-
Expiration: credentials.Expiration,
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
saveConfig({
|
|
90
|
-
region: cfg.region,
|
|
91
|
-
accessKeyId: cfg.accessKeyId,
|
|
92
|
-
secretAccessKey: cfg.secretAccessKey,
|
|
93
|
-
mfaArn: cfg.mfaArn,
|
|
94
|
-
roleArn: cfg.roleArn,
|
|
95
|
-
duration,
|
|
96
|
-
mfaMode: cfg.mfaMode,
|
|
97
|
-
mfaCommand: cfg.mfaCommand,
|
|
98
|
-
cacheSession: cfg.cacheSession,
|
|
99
|
-
autoMfa: cfg.autoMfa,
|
|
100
|
-
singleInstanceLock: cfg.singleInstanceLock,
|
|
101
|
-
...(useCache ? { cachedSession: session } : {}),
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
return session;
|
|
105
|
-
} catch {
|
|
106
|
-
return null;
|
|
80
|
+
const proc = Bun.spawnSync(["sh", "-c", cfg.mfaCommand]);
|
|
81
|
+
if (proc.exitCode !== 0) {
|
|
82
|
+
throw new Error(`MFA command failed: ${proc.stderr.toString().trim()}`);
|
|
107
83
|
}
|
|
84
|
+
const mfaCode = proc.stdout.toString().trim();
|
|
85
|
+
if (!mfaCode) throw new Error("MFA command produced no output");
|
|
86
|
+
|
|
87
|
+
const { credentials, duration } = await assumeRoleWithMfa({
|
|
88
|
+
region: cfg.region,
|
|
89
|
+
accessKeyId: cfg.accessKeyId,
|
|
90
|
+
secretAccessKey: cfg.secretAccessKey,
|
|
91
|
+
mfaArn: cfg.mfaArn,
|
|
92
|
+
roleArn: cfg.roleArn,
|
|
93
|
+
mfaCode,
|
|
94
|
+
duration: cfg.duration ?? STANDARD_DURATIONS[0],
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const session: CachedSession = {
|
|
98
|
+
AccessKeyId: credentials.AccessKeyId,
|
|
99
|
+
SecretAccessKey: credentials.SecretAccessKey,
|
|
100
|
+
SessionToken: credentials.SessionToken,
|
|
101
|
+
Expiration: credentials.Expiration,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
saveConfig({
|
|
105
|
+
region: cfg.region,
|
|
106
|
+
accessKeyId: cfg.accessKeyId,
|
|
107
|
+
secretAccessKey: cfg.secretAccessKey,
|
|
108
|
+
mfaArn: cfg.mfaArn,
|
|
109
|
+
roleArn: cfg.roleArn,
|
|
110
|
+
duration,
|
|
111
|
+
mfaMode: cfg.mfaMode,
|
|
112
|
+
mfaCommand: cfg.mfaCommand,
|
|
113
|
+
cacheSession: cfg.cacheSession,
|
|
114
|
+
autoMfa: cfg.autoMfa,
|
|
115
|
+
singleInstanceLock: cfg.singleInstanceLock,
|
|
116
|
+
...(useCache ? { cachedSession: session } : {}),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return session;
|
|
108
120
|
}
|
|
109
121
|
|
|
110
122
|
// --- Helper: show dialog and obtain credentials ---
|
|
111
123
|
|
|
112
124
|
async function obtainViaDialog(cfg: Partial<Config>): Promise<void> {
|
|
125
|
+
const { showDialog } = await import("./dialog");
|
|
113
126
|
const result = showDialog(cfg);
|
|
114
127
|
if (!result) {
|
|
115
128
|
process.stderr.write("User cancelled dialog.\n");
|
|
@@ -120,8 +133,7 @@ async function obtainViaDialog(cfg: Partial<Config>): Promise<void> {
|
|
|
120
133
|
if (result.mfaMode === "command") {
|
|
121
134
|
const proc = Bun.spawnSync(["sh", "-c", result.mfaCommand]);
|
|
122
135
|
if (proc.exitCode !== 0) {
|
|
123
|
-
|
|
124
|
-
process.exit(2);
|
|
136
|
+
throw new Error(`MFA command failed: ${proc.stderr.toString().trim()}`);
|
|
125
137
|
}
|
|
126
138
|
mfaCode = proc.stdout.toString().trim();
|
|
127
139
|
}
|
|
@@ -177,14 +189,29 @@ try {
|
|
|
177
189
|
|
|
178
190
|
// 2. If auto-mfa is enabled, try to obtain credentials without dialog
|
|
179
191
|
if (useAutoMfa) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
192
|
+
try {
|
|
193
|
+
const session = await tryAutoMfa(defaults);
|
|
194
|
+
if (session) {
|
|
195
|
+
outputCredentials(session);
|
|
196
|
+
process.exit(0);
|
|
197
|
+
}
|
|
198
|
+
} catch (err) {
|
|
199
|
+
// In --no-ui mode there's no dialog to fall back to — surface the error directly.
|
|
200
|
+
if (flags.noUi) throw err;
|
|
184
201
|
}
|
|
185
202
|
}
|
|
186
203
|
|
|
187
|
-
// 3.
|
|
204
|
+
// 3. In --no-ui mode, never show a dialog — report why credentials couldn't be obtained.
|
|
205
|
+
if (flags.noUi) {
|
|
206
|
+
const missing = missingConfigFields(defaults);
|
|
207
|
+
if (missing.length) {
|
|
208
|
+
await reportFailure(`Missing config: ${missing.join(", ")}`);
|
|
209
|
+
} else {
|
|
210
|
+
await reportFailure("Unable to obtain credentials without UI: auto-MFA did not succeed.");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 4. If single-instance-lock is enabled, acquire lock before showing dialog
|
|
188
215
|
if (useLock) {
|
|
189
216
|
if (!tryAcquireLock()) {
|
|
190
217
|
// Another instance holds the lock — wait for it
|
|
@@ -232,6 +259,7 @@ try {
|
|
|
232
259
|
const masked = raw
|
|
233
260
|
.replace(/(?:AKIA|ASIA)[A-Z0-9]{16}/g, "****")
|
|
234
261
|
.replace(/[A-Za-z0-9/+=]{40}/g, "****");
|
|
235
|
-
|
|
236
|
-
|
|
262
|
+
// Claude Code does not surface stderr to the user, so failures are reported via
|
|
263
|
+
// a dialog box instead (or to stderr in --no-ui mode, keeping stdout parsable).
|
|
264
|
+
await reportFailure(`Authentication failed: ${masked}`);
|
|
237
265
|
}
|
package/src/setup-html.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { ClaudeSettings } from "./claude-settings";
|
|
2
|
+
import { AUTOSIZE_CSS, AUTOSIZE_SCRIPT } from "./autosize-html";
|
|
3
|
+
import { DEFAULT_PROFILE, type ActiveProfile } from "./config";
|
|
4
|
+
import { PROFILE_STYLE, profileBanner } from "./dialog-html";
|
|
2
5
|
|
|
3
6
|
export const TEXT_FIELDS = [
|
|
4
7
|
{ key: "ANTHROPIC_DEFAULT_OPUS_MODEL", label: "Opus Model", placeholder: "global.anthropic.claude-opus-4-6" },
|
|
@@ -7,7 +10,12 @@ export const TEXT_FIELDS = [
|
|
|
7
10
|
{ key: "CLAUDE_CODE_SUBAGENT_MODEL", label: "Subagent Model", placeholder: "global.anthropic.claude-sonnet-4-6" },
|
|
8
11
|
] as const;
|
|
9
12
|
|
|
10
|
-
export function buildSetupHtml(
|
|
13
|
+
export function buildSetupHtml(
|
|
14
|
+
env: Record<string, string>,
|
|
15
|
+
settings: ClaudeSettings,
|
|
16
|
+
credentialExportCmd: string,
|
|
17
|
+
profile: ActiveProfile = DEFAULT_PROFILE,
|
|
18
|
+
) {
|
|
11
19
|
const bedrockMfaEnabled = env.CLAUDE_CODE_USE_BEDROCK === "1"
|
|
12
20
|
&& settings.awsCredentialExport !== undefined
|
|
13
21
|
&& env.AWS_SHARED_CREDENTIALS_FILE === "/dev/null";
|
|
@@ -32,8 +40,9 @@ export function buildSetupHtml(env: Record<string, string>, settings: ClaudeSett
|
|
|
32
40
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
33
41
|
body {
|
|
34
42
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
35
|
-
|
|
43
|
+
background: #f5f5f7; color: #1d1d1f;
|
|
36
44
|
}
|
|
45
|
+
${AUTOSIZE_CSS}
|
|
37
46
|
h2 { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
|
|
38
47
|
.field { margin-bottom: 12px; }
|
|
39
48
|
label { display: block; font-size: 12px; font-weight: 500; margin-bottom: 4px; color: #1d1d1f; }
|
|
@@ -59,10 +68,13 @@ export function buildSetupHtml(env: Record<string, string>, settings: ClaudeSett
|
|
|
59
68
|
}
|
|
60
69
|
button.primary { background: #0071e3; color: #fff; border-color: #0071e3; }
|
|
61
70
|
button:hover { filter: brightness(0.95); }
|
|
71
|
+
${PROFILE_STYLE}
|
|
62
72
|
</style>
|
|
63
73
|
</head>
|
|
64
74
|
<body>
|
|
75
|
+
<div id="fit">
|
|
65
76
|
<h2>Claude Code Bedrock Setup</h2>
|
|
77
|
+
${profileBanner(profile)}
|
|
66
78
|
|
|
67
79
|
${bedrockMfaCheckboxHtml}
|
|
68
80
|
|
|
@@ -74,8 +86,9 @@ export function buildSetupHtml(env: Record<string, string>, settings: ClaudeSett
|
|
|
74
86
|
<button onclick="_cancel()">Cancel</button>
|
|
75
87
|
<button class="primary" onclick="save()">Save</button>
|
|
76
88
|
</div>
|
|
89
|
+
</div>
|
|
77
90
|
|
|
78
|
-
<script
|
|
91
|
+
<script>${AUTOSIZE_SCRIPT}
|
|
79
92
|
const TEXT_FIELDS = ${JSON.stringify(TEXT_FIELDS)};
|
|
80
93
|
const originalEnv = ${JSON.stringify(env)};
|
|
81
94
|
const originalSettings = ${JSON.stringify(settings)};
|
package/src/setup.ts
CHANGED
|
@@ -1,32 +1,29 @@
|
|
|
1
|
-
import { resolve } from "path";
|
|
2
1
|
import { Webview, SizeHint } from "webview-bun";
|
|
3
2
|
import { lib } from "webview-bun/src/ffi";
|
|
4
3
|
import { loadClaudeSettings, saveClaudeSettings } from "./claude-settings";
|
|
4
|
+
import { activeProfile } from "./config";
|
|
5
5
|
import { buildSetupHtml } from "./setup-html";
|
|
6
|
+
import { getCredentialExportCommand } from "./credential-export";
|
|
7
|
+
import { START_HEIGHT, bindAutosize } from "./autosize";
|
|
6
8
|
import { version } from "../package.json";
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const script = Bun.argv[1];
|
|
11
|
-
// If the script has a .ts or .js extension, it was launched via interpreter
|
|
12
|
-
if (/\.[tj]sx?$/.test(script)) {
|
|
13
|
-
return `${process.execPath} ${resolve(script)}`;
|
|
14
|
-
}
|
|
15
|
-
// Otherwise it's an installed bin — use the bin path directly
|
|
16
|
-
return script;
|
|
17
|
-
}
|
|
10
|
+
const SETUP_WIDTH = 480;
|
|
11
|
+
const SETUP_MAX_HEIGHT = 720;
|
|
18
12
|
|
|
19
13
|
export function runSetup(): boolean {
|
|
20
14
|
const settings = loadClaudeSettings();
|
|
21
15
|
const env = (settings.env ?? {}) as Record<string, string>;
|
|
22
16
|
const credentialExportCmd = getCredentialExportCommand();
|
|
23
17
|
|
|
18
|
+
// Setup edits $CLAUDE_CONFIG_DIR/settings.json, so name the profile it will land in.
|
|
19
|
+
const profile = activeProfile();
|
|
24
20
|
const webview = new Webview(false, {
|
|
25
|
-
width:
|
|
26
|
-
height:
|
|
21
|
+
width: SETUP_WIDTH,
|
|
22
|
+
height: START_HEIGHT,
|
|
27
23
|
hint: SizeHint.FIXED,
|
|
28
24
|
});
|
|
29
|
-
webview.title = `Claude Code Bedrock Setup v${version}`;
|
|
25
|
+
webview.title = `Claude Code Bedrock Setup v${version} — ${profile.name}`;
|
|
26
|
+
bindAutosize(webview, SETUP_WIDTH, SETUP_MAX_HEIGHT);
|
|
30
27
|
|
|
31
28
|
let saved = false;
|
|
32
29
|
const handle = webview.unsafeHandle;
|
|
@@ -65,7 +62,7 @@ export function runSetup(): boolean {
|
|
|
65
62
|
|
|
66
63
|
webview.bind("_cancel", () => lib.symbols.webview_terminate(handle));
|
|
67
64
|
|
|
68
|
-
webview.setHTML(buildSetupHtml(env, settings, credentialExportCmd));
|
|
65
|
+
webview.setHTML(buildSetupHtml(env, settings, credentialExportCmd, profile));
|
|
69
66
|
webview.run();
|
|
70
67
|
|
|
71
68
|
return saved;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { AUTOSIZE_CSS, AUTOSIZE_SCRIPT } from "../src/autosize-html";
|
|
3
|
+
import { buildHtml, buildErrorHtml } from "../src/dialog-html";
|
|
4
|
+
import { buildSetupHtml } from "../src/setup-html";
|
|
5
|
+
|
|
6
|
+
const PAGES: Record<string, string> = {
|
|
7
|
+
dialog: buildHtml({ region: "us-east-1" }),
|
|
8
|
+
error: buildErrorHtml("something went wrong"),
|
|
9
|
+
setup: buildSetupHtml({}, {}, "claude-aws-mfa"),
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe("autosize contract", () => {
|
|
13
|
+
for (const [name, html] of Object.entries(PAGES)) {
|
|
14
|
+
describe(name, () => {
|
|
15
|
+
test("carries the measurement CSS and script", () => {
|
|
16
|
+
expect(html).toContain(AUTOSIZE_CSS);
|
|
17
|
+
expect(html).toContain(AUTOSIZE_SCRIPT);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("wraps its content in the measured #fit element", () => {
|
|
21
|
+
expect(html).toContain(`<div id="fit">`);
|
|
22
|
+
// The wrapper must close before </body>, or it would measure nothing.
|
|
23
|
+
expect(html.indexOf(`<div id="fit">`)).toBeLessThan(html.indexOf("</body>"));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("keeps page padding on #fit, not body", () => {
|
|
27
|
+
// Padding on body sits outside #fit's box and would be measured away.
|
|
28
|
+
const bodyRule = html.match(/\n body \{[^}]*\}/)?.[0] ?? "";
|
|
29
|
+
expect(bodyRule).not.toContain("padding");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("AUTOSIZE_SCRIPT", () => {
|
|
36
|
+
test("reports a delta rather than an absolute height", () => {
|
|
37
|
+
// set_size sizes the whole frame; only a delta cancels out the titlebar.
|
|
38
|
+
expect(AUTOSIZE_SCRIPT).toContain("- innerHeight");
|
|
39
|
+
expect(AUTOSIZE_SCRIPT).toContain("_grow(delta)");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("waits for fonts before measuring", () => {
|
|
43
|
+
expect(AUTOSIZE_SCRIPT).toContain("document.fonts?.ready");
|
|
44
|
+
expect(AUTOSIZE_SCRIPT).toContain("requestAnimationFrame");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("skips the resize when the content already fits", () => {
|
|
48
|
+
expect(AUTOSIZE_SCRIPT).toContain("if (delta)");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("AUTOSIZE_CSS", () => {
|
|
53
|
+
test("keeps the body scrollable so #fit can exceed the viewport", () => {
|
|
54
|
+
expect(AUTOSIZE_CSS).toContain("overflow-y: scroll");
|
|
55
|
+
});
|
|
56
|
+
});
|
package/test/config.test.ts
CHANGED
|
@@ -2,6 +2,45 @@ import { describe, test, expect } from "bun:test";
|
|
|
2
2
|
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync, statSync, openSync, closeSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { tmpdir } from "os";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
import { activeProfile, missingConfigFields, profileSuffix } from "../src/config";
|
|
7
|
+
|
|
8
|
+
describe("profileSuffix", () => {
|
|
9
|
+
test("is empty for no profile and for the default config dir", () => {
|
|
10
|
+
expect(profileSuffix("")).toBe(""); // CLAUDE_CONFIG_DIR unset
|
|
11
|
+
expect(profileSuffix(join(homedir(), ".claude"))).toBe("");
|
|
12
|
+
expect(profileSuffix(join(homedir(), ".claude") + "/")).toBe(""); // trailing slash still grandfathered
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("encodes the full path, not the basename, with / and . both as -", () => {
|
|
16
|
+
expect(profileSuffix("/opt/profiles/work")).toBe("-opt-profiles-work");
|
|
17
|
+
// basename alone would collide; the full path keeps them distinct
|
|
18
|
+
expect(profileSuffix("/a/work")).not.toBe(profileSuffix("/b/work"));
|
|
19
|
+
expect(profileSuffix("/home/me/.claude-profiles/work")).toBe("-home-me--claude-profiles-work");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("activeProfile", () => {
|
|
24
|
+
test("names the default config dir rather than returning nothing", () => {
|
|
25
|
+
// A blank banner would be ambiguous: "default profile" vs "build doesn't label profiles".
|
|
26
|
+
expect(activeProfile("")).toEqual({ name: "default", path: "~/.claude" });
|
|
27
|
+
expect(activeProfile(join(homedir(), ".claude"))).toEqual({ name: "default", path: "~/.claude" });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("names the profile and shortens the home prefix", () => {
|
|
31
|
+
expect(activeProfile(join(homedir(), ".claude-profiles", "work"))).toEqual({
|
|
32
|
+
name: "work",
|
|
33
|
+
path: "~/.claude-profiles/work",
|
|
34
|
+
});
|
|
35
|
+
expect(activeProfile("/opt/profiles/work")).toEqual({ name: "work", path: "/opt/profiles/work" });
|
|
36
|
+
expect(activeProfile("/opt/profiles/work/")).toEqual({ name: "work", path: "/opt/profiles/work" });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("does not shorten a sibling directory that merely shares the home prefix", () => {
|
|
40
|
+
const sibling = homedir() + "-other/work";
|
|
41
|
+
expect(activeProfile(sibling)?.path).toBe(sibling);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
5
44
|
|
|
6
45
|
describe("config serialization", () => {
|
|
7
46
|
const config = {
|
|
@@ -80,6 +119,44 @@ describe("config file I/O", () => {
|
|
|
80
119
|
});
|
|
81
120
|
});
|
|
82
121
|
|
|
122
|
+
describe("missingConfigFields", () => {
|
|
123
|
+
const complete = {
|
|
124
|
+
region: "us-west-2",
|
|
125
|
+
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
|
126
|
+
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
|
127
|
+
mfaArn: "arn:aws:iam::123456789012:mfa/user",
|
|
128
|
+
roleArn: "arn:aws:iam::123456789012:role/TestRole",
|
|
129
|
+
mfaMode: "command" as const,
|
|
130
|
+
mfaCommand: "op item get --otp myitem",
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
test("returns empty array when everything needed for headless operation is present", () => {
|
|
134
|
+
expect(missingConfigFields(complete)).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("reports each missing base field by name", () => {
|
|
138
|
+
const { region, ...rest } = complete;
|
|
139
|
+
expect(missingConfigFields(rest)).toContain("region");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("requires mfaCommand even if other fields are present", () => {
|
|
143
|
+
const { mfaCommand, ...rest } = complete;
|
|
144
|
+
expect(missingConfigFields(rest)).toContain("mfaCommand");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("requires mfaMode to be \"command\", not just mfaCommand set", () => {
|
|
148
|
+
const codeMode = { ...complete, mfaMode: "code" as const };
|
|
149
|
+
expect(missingConfigFields(codeMode)).toContain("mfaCommand");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("reports everything missing for an empty config", () => {
|
|
153
|
+
const missing = missingConfigFields({});
|
|
154
|
+
expect(missing).toEqual([
|
|
155
|
+
"region", "accessKeyId", "secretAccessKey", "mfaArn", "roleArn", "mfaCommand",
|
|
156
|
+
]);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
83
160
|
const isWindows = process.platform === "win32";
|
|
84
161
|
|
|
85
162
|
// Unix file-permission semantics don't apply on Windows.
|