claude-aws-mfa 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.
@@ -0,0 +1,139 @@
1
+ name: Release from Changelog
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ detect-release:
10
+ runs-on: ubuntu-latest
11
+ outputs:
12
+ version: ${{ steps.parse.outputs.version }}
13
+ prerelease: ${{ steps.parse.outputs.prerelease }}
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ with:
17
+ fetch-tags: true
18
+
19
+ - name: Parse latest changelog version
20
+ id: parse
21
+ run: |
22
+ # Extract the first heading that matches: ## [vX.Y.Z...] - YYYY-MM-DD
23
+ # The date is required — headings without a date are treated as drafts.
24
+ HEADING=$(grep -P '^## \[v[^\]]+\]\s+-\s+\d{4}-\d{2}-\d{2}' CHANGELOG.md | head -1)
25
+
26
+ if [ -z "$HEADING" ]; then
27
+ echo "No dated release heading found — nothing to release."
28
+ echo "version=" >> "$GITHUB_OUTPUT"
29
+ exit 0
30
+ fi
31
+
32
+ VERSION=$(echo "$HEADING" | grep -oP '(?<=\[)[^\]]+')
33
+
34
+ # Must be valid semver with v prefix
35
+ if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9._-]+)?$ ]]; then
36
+ echo "::error::First changelog heading '$VERSION' is not valid semver with v prefix"
37
+ exit 1
38
+ fi
39
+
40
+ # Verify the date is today (UTC +/- 12h to accommodate any timezone)
41
+ ENTRY_DATE=$(echo "$HEADING" | grep -oP '\d{4}-\d{2}-\d{2}')
42
+ YESTERDAY=$(date -u -d '-12 hours' +%Y-%m-%d)
43
+ TODAY=$(date -u +%Y-%m-%d)
44
+ TOMORROW=$(date -u -d '+12 hours' +%Y-%m-%d)
45
+ if [[ "$ENTRY_DATE" != "$YESTERDAY" && "$ENTRY_DATE" != "$TODAY" && "$ENTRY_DATE" != "$TOMORROW" ]]; then
46
+ echo "::error::Changelog date $ENTRY_DATE does not match today — expected $YESTERDAY, $TODAY, or $TOMORROW"
47
+ exit 1
48
+ fi
49
+
50
+ # Check if this tag already exists on the remote
51
+ if git rev-parse "refs/tags/$VERSION" >/dev/null 2>&1; then
52
+ echo "Tag $VERSION already exists — skipping release."
53
+ echo "version=" >> "$GITHUB_OUTPUT"
54
+ exit 0
55
+ fi
56
+
57
+ # Check if a GitHub release already exists
58
+ if gh release view "$VERSION" >/dev/null 2>&1; then
59
+ echo "::error::GitHub release $VERSION already exists but tag does not — manual cleanup needed"
60
+ exit 1
61
+ fi
62
+
63
+ PRERELEASE="false"
64
+ SEMVER="${VERSION#v}"
65
+ if [[ "$SEMVER" =~ - ]]; then
66
+ PRERELEASE="true"
67
+ fi
68
+
69
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
70
+ echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
71
+ echo "Detected version=$VERSION prerelease=$PRERELEASE"
72
+
73
+ release:
74
+ needs: detect-release
75
+ if: needs.detect-release.outputs.version != ''
76
+ runs-on: ubuntu-latest
77
+ permissions:
78
+ contents: read
79
+ id-token: write
80
+ steps:
81
+ - uses: actions/checkout@v4
82
+ with:
83
+ token: ${{ secrets.RELEASE_PAT }}
84
+
85
+ - uses: oven-sh/setup-bun@v2
86
+ with:
87
+ bun-version: latest
88
+
89
+ - run: bun install --frozen-lockfile
90
+
91
+ - run: bun test
92
+
93
+ - name: Set package version
94
+ run: |
95
+ VERSION="${{ needs.detect-release.outputs.version }}"
96
+ npm version "${VERSION#v}" --no-git-tag-version
97
+
98
+ - name: Extract changelog entry
99
+ id: changelog
100
+ run: |
101
+ VERSION="${{ needs.detect-release.outputs.version }}"
102
+ # Extract the body of the first changelog section (between first ## and second ##)
103
+ BODY=$(awk '/^## \[/{if(found) exit; found=1; next} found' CHANGELOG.md)
104
+ # Write to file for the release body
105
+ echo "$BODY" > /tmp/release-body.md
106
+
107
+ - name: Create Git tag
108
+ run: |
109
+ VERSION="${{ needs.detect-release.outputs.version }}"
110
+ git tag "$VERSION"
111
+ git push origin "$VERSION"
112
+
113
+ - name: Create GitHub Release
114
+ env:
115
+ GH_TOKEN: ${{ secrets.RELEASE_PAT }}
116
+ run: |
117
+ VERSION="${{ needs.detect-release.outputs.version }}"
118
+ PRERELEASE="${{ needs.detect-release.outputs.prerelease }}"
119
+ FLAGS=""
120
+ if [ "$PRERELEASE" = "true" ]; then
121
+ FLAGS="--prerelease"
122
+ fi
123
+ gh release create "$VERSION" \
124
+ --title "$VERSION" \
125
+ --notes-file /tmp/release-body.md \
126
+ $FLAGS
127
+
128
+ - uses: actions/setup-node@v4
129
+ with:
130
+ node-version: 24
131
+ registry-url: https://registry.npmjs.org
132
+
133
+ - name: Publish to npm
134
+ run: |
135
+ TAG="latest"
136
+ if [ "${{ needs.detect-release.outputs.prerelease }}" = "true" ]; then
137
+ TAG="next"
138
+ fi
139
+ npm publish --access public --tag "$TAG"
package/CHANGELOG.md ADDED
@@ -0,0 +1,55 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/).
7
+
8
+ ### Releasing
9
+
10
+ Releases are automated via CI. To create a new release:
11
+
12
+ 1. Add a new entry at the top of this file with heading `## [vX.Y.Z]` (without a
13
+ date). This is a draft — it can be merged to `main` without triggering a
14
+ release.
15
+ 2. When ready to publish, update the heading to `## [vX.Y.Z] - YYYY-MM-DD` and
16
+ merge to `main`. The date signals "ready to release".
17
+ 3. The workflow will run tests, create a Git tag, GitHub Release, and npm publish.
18
+ 4. If the tag already exists, the workflow skips (safe to re-merge).
19
+ 5. If tests fail, no tag/release is created. The release will be re-attempted on
20
+ each subsequent push to `main`, or can be manually retried from the Actions UI.
21
+
22
+ Stable versions (e.g. `v1.2.0`) publish to npm `@latest`. Pre-release versions
23
+ (e.g. `v1.2.0-rc.1`) publish to npm `@next` and create a GitHub pre-release.
24
+
25
+ ## [v1.1.0] - 2026-06-04
26
+
27
+ ### Added
28
+ - **Session caching** (`--cache-session`): Cache STS session credentials in the config file and reuse them until they expire, avoiding repeated MFA prompts.
29
+ - **Auto-MFA** (`--auto-mfa`): Automatically run the configured MFA command to obtain credentials without showing the dialog. Falls back to the dialog if the command fails.
30
+ - **Single-instance lock** (`--single-instance-lock`): Prevent multiple concurrent instances from showing overlapping dialogs. Subsequent invocations wait for the first to finish and reuse its cached session.
31
+ - New CLI flags with `--no-` negation support for all new options.
32
+ - New config file options: `cacheSession`, `autoMfa`, `singleInstanceLock`.
33
+ - Tests for session caching, lock mechanism, and CLI flag parsing.
34
+ - CHANGELOG.md to track project changes.
35
+ - Updated README with documentation for new CLI flags and features.
36
+
37
+ ### Changed
38
+ - Automated release pipeline: releases are now triggered by changelog entries pushed to `main`, replacing the previous manual GitHub Release workflow.
39
+
40
+ ### Fixed
41
+ - Release workflow: use `RELEASE_PAT` via `actions/checkout` token so git credential helper authenticates with the PAT instead of the default `GITHUB_TOKEN` (which cannot bypass tag rulesets).
42
+
43
+ ## [v1.0.0] - 2026-03-28
44
+
45
+ ### Added
46
+ - Initial stable release.
47
+ - Native GUI dialog for AWS credential and MFA code entry.
48
+ - MFA Command mode: provide a shell command (e.g. `op item get --otp …`) to fetch TOTP codes automatically.
49
+ - Automatic STS session duration negotiation (12h → 6h → 2h → 1h).
50
+ - Guided setup wizard (`--setup`) for Claude Code Bedrock configuration.
51
+ - Configuration persistence in `~/.config/claude-aws-mfa.json` (mode 0600).
52
+ - Credential masking in error output.
53
+ - Cross-platform support (macOS, Linux, Windows).
54
+ - CI test workflows (unit + GUI screenshot tests).
55
+ - npm publish workflow triggered by GitHub Releases.
package/README.md CHANGED
@@ -32,6 +32,17 @@ Add this (plus any addtitional config you prefer) to your Claude Code settings (
32
32
  }
33
33
  ```
34
34
 
35
+ ## CLI Flags
36
+
37
+ All flags can be negated with a `--no-` prefix (e.g. `--no-cache-session`). CLI flags override values saved in the config file.
38
+
39
+ | Flag | Description |
40
+ |------|-------------|
41
+ | `--setup` | Launch the guided setup wizard |
42
+ | `--cache-session` | Cache STS session credentials and reuse them until they expire |
43
+ | `--auto-mfa` | Automatically run the configured MFA command and obtain credentials without showing the dialog (requires MFA Command mode) |
44
+ | `--single-instance-lock` | Prevent multiple concurrent instances from showing overlapping dialogs; subsequent invocations wait for the first to finish and reuse its cached session |
45
+
35
46
  ## How it works
36
47
 
37
48
  1. On first run, seeds the dialog with credentials from your existing AWS config (`~/.aws/credentials`, environment variables, SSO, etc.)
@@ -44,6 +55,18 @@ Add this (plus any addtitional config you prefer) to your Claude Code settings (
44
55
 
45
56
  On subsequent runs, all fields are pre-populated from the saved config — just enter a fresh MFA code and hit OK. If you use the command mode, the TOTP code is fetched automatically so no manual entry is needed at all.
46
57
 
58
+ ### Session caching
59
+
60
+ With `--cache-session` (or `"cacheSession": true` in the config file), temporary credentials are saved to the config file and reused on subsequent runs until they expire. This avoids prompting for MFA on every invocation.
61
+
62
+ ### Auto-MFA
63
+
64
+ With `--auto-mfa` (or `"autoMfa": true` in the config file), when a MFA command is configured, credentials are obtained automatically without showing the dialog at all. If the command fails or isn't configured, the dialog is shown as a fallback.
65
+
66
+ ### Single-instance lock
67
+
68
+ With `--single-instance-lock` (or `"singleInstanceLock": true` in the config file), only one instance of the tool will show a dialog at a time. Additional invocations will wait for the first to finish and then reuse its cached session (when session caching is also enabled). The lock file is stored at `~/.config/claude-aws-mfa.lock` and is automatically cleaned up after 2 minutes if the holding process crashes.
69
+
47
70
  ## System requirements
48
71
 
49
72
  The GUI dialog uses [webview-bun](https://github.com/tr1ckydev/webview-bun), which requires:
@@ -60,6 +83,10 @@ bun run src/index.ts # run locally
60
83
  bun test # run tests
61
84
  ```
62
85
 
86
+ ## Releasing
87
+
88
+ See the [Releasing section of CHANGELOG.md](CHANGELOG.md#releasing) for how to publish new versions.
89
+
63
90
  ## License
64
91
 
65
92
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-aws-mfa",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "repository": "https://github.com/p120ph37/claude-aws-mfa",
6
6
  "bin": {
package/src/config.ts CHANGED
@@ -6,11 +6,20 @@ import {
6
6
  mkdirSync,
7
7
  openSync,
8
8
  readFileSync,
9
+ rmSync,
9
10
  statSync,
10
11
  writeFileSync,
12
+ writeSync,
11
13
  } from "fs";
12
14
  import { STSClient } from "@aws-sdk/client-sts";
13
15
 
16
+ export interface CachedSession {
17
+ AccessKeyId: string;
18
+ SecretAccessKey: string;
19
+ SessionToken: string;
20
+ Expiration: string; // ISO 8601
21
+ }
22
+
14
23
  export interface Config {
15
24
  region: string;
16
25
  accessKeyId: string;
@@ -20,6 +29,10 @@ export interface Config {
20
29
  duration: number;
21
30
  mfaMode?: "code" | "command";
22
31
  mfaCommand?: string;
32
+ cacheSession?: boolean;
33
+ autoMfa?: boolean;
34
+ singleInstanceLock?: boolean;
35
+ cachedSession?: CachedSession;
23
36
  }
24
37
 
25
38
  const CONFIG_PATH = join(homedir(), ".config", "claude-aws-mfa.json");
@@ -104,6 +117,66 @@ export function saveConfig(config: Config) {
104
117
  writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
105
118
  }
106
119
 
120
+ // --- Single-instance lock ---
121
+
122
+ const LOCK_PATH = join(homedir(), ".config", "claude-aws-mfa.lock");
123
+ const LOCK_POLL_MS = 500;
124
+ const LOCK_STALE_MS = 120_000; // 2 minutes — assume stale if holder crashed
125
+
126
+ /**
127
+ * Attempt to acquire an exclusive lock. Returns true if acquired.
128
+ * Uses O_EXCL to atomically create the lock file.
129
+ */
130
+ export function tryAcquireLock(): boolean {
131
+ ensureConfigDir();
132
+ try {
133
+ const fd = openSync(LOCK_PATH, "wx", 0o600);
134
+ writeSync(fd, String(process.pid));
135
+ closeSync(fd);
136
+ return true;
137
+ } catch (err: any) {
138
+ if (err?.code === "EEXIST") {
139
+ // Check for stale lock
140
+ try {
141
+ const st = statSync(LOCK_PATH);
142
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
143
+ // Stale lock — remove and retry once
144
+ try { rmSync(LOCK_PATH); } catch {}
145
+ return tryAcquireLock();
146
+ }
147
+ } catch {}
148
+ return false;
149
+ }
150
+ throw err;
151
+ }
152
+ }
153
+
154
+ /** Release the lock file. */
155
+ export function releaseLock(): void {
156
+ try { rmSync(LOCK_PATH); } catch {}
157
+ }
158
+
159
+ /**
160
+ * Wait until the lock is released, polling every LOCK_POLL_MS.
161
+ * Returns once the lock file disappears (or becomes stale).
162
+ */
163
+ export async function waitForLock(): Promise<void> {
164
+ while (true) {
165
+ try {
166
+ const st = statSync(LOCK_PATH);
167
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
168
+ // Stale — break out so caller can acquire
169
+ try { rmSync(LOCK_PATH); } catch {}
170
+ return;
171
+ }
172
+ } catch {
173
+ // Lock file gone — we can proceed
174
+ return;
175
+ }
176
+ await Bun.sleep(LOCK_POLL_MS);
177
+ }
178
+ }
179
+
107
180
  export async function seedDefaults(): Promise<Partial<Config>> {
108
181
  const defaults: Partial<Config> = {};
109
182
  try {
package/src/flags.ts ADDED
@@ -0,0 +1,42 @@
1
+ export interface CliFlags {
2
+ setup: boolean;
3
+ cacheSession: boolean | undefined; // undefined = use config default
4
+ autoMfa: boolean | undefined;
5
+ singleInstanceLock: boolean | undefined;
6
+ }
7
+
8
+ export function parseFlags(argv: string[]): CliFlags {
9
+ const flags: CliFlags = {
10
+ setup: false,
11
+ cacheSession: undefined,
12
+ autoMfa: undefined,
13
+ singleInstanceLock: undefined,
14
+ };
15
+ for (const arg of argv.slice(2)) {
16
+ switch (arg) {
17
+ case "setup":
18
+ case "--setup":
19
+ flags.setup = true;
20
+ break;
21
+ case "--cache-session":
22
+ flags.cacheSession = true;
23
+ break;
24
+ case "--no-cache-session":
25
+ flags.cacheSession = false;
26
+ break;
27
+ case "--auto-mfa":
28
+ flags.autoMfa = true;
29
+ break;
30
+ case "--no-auto-mfa":
31
+ flags.autoMfa = false;
32
+ break;
33
+ case "--single-instance-lock":
34
+ flags.singleInstanceLock = true;
35
+ break;
36
+ case "--no-single-instance-lock":
37
+ flags.singleInstanceLock = false;
38
+ break;
39
+ }
40
+ }
41
+ return flags;
42
+ }
package/src/index.ts CHANGED
@@ -15,25 +15,107 @@ if (process.platform === "linux" && !process.env.WEBKIT_DISABLE_SANDBOX_THIS_IS_
15
15
  process.exit(proc.exitCode);
16
16
  }
17
17
 
18
- const arg = process.argv[2];
19
- if (arg === "setup" || arg === "--setup") {
18
+ import { parseFlags } from "./flags";
19
+
20
+ const flags = parseFlags(process.argv);
21
+
22
+ if (flags.setup) {
20
23
  const { runSetup } = await import("./setup");
21
24
  process.exit(runSetup() ? 0 : 2);
22
25
  }
23
26
 
24
- import { loadConfig, saveConfig, seedDefaults } from "./config";
27
+ import type { CachedSession, Config } from "./config";
28
+ import { loadConfig, saveConfig, seedDefaults, tryAcquireLock, releaseLock, waitForLock } from "./config";
25
29
  import { showDialog } from "./dialog";
26
30
  import { assumeRoleWithMfa, STANDARD_DURATIONS } from "./sts";
27
31
 
28
- const defaults = loadConfig() ?? await seedDefaults();
32
+ // --- Resolve effective flags (CLI overrides config defaults) ---
29
33
 
30
- const result = showDialog(defaults);
31
- if (!result) {
32
- process.stderr.write("User cancelled dialog.\n");
33
- process.exit(2);
34
+ const config = loadConfig();
35
+ const defaults: Partial<Config> = config ?? await seedDefaults();
36
+
37
+ const useCache = flags.cacheSession ?? defaults.cacheSession ?? false;
38
+ const useAutoMfa = flags.autoMfa ?? defaults.autoMfa ?? false;
39
+ const useLock = flags.singleInstanceLock ?? defaults.singleInstanceLock ?? false;
40
+
41
+ // --- Helper: check if a cached session is still valid ---
42
+
43
+ function isSessionValid(session: CachedSession | undefined): session is CachedSession {
44
+ if (!session) return false;
45
+ return new Date(session.Expiration).getTime() > Date.now();
34
46
  }
35
47
 
36
- try {
48
+ // --- Helper: output credentials and save config ---
49
+
50
+ function outputCredentials(session: CachedSession): void {
51
+ console.log(JSON.stringify({
52
+ Credentials: {
53
+ AccessKeyId: session.AccessKeyId,
54
+ SecretAccessKey: session.SecretAccessKey,
55
+ SessionToken: session.SessionToken,
56
+ },
57
+ }));
58
+ }
59
+
60
+ // --- Helper: run MFA command and attempt to obtain credentials without dialog ---
61
+
62
+ async function tryAutoMfa(cfg: Partial<Config>): Promise<CachedSession | null> {
63
+ if (!cfg.mfaCommand || cfg.mfaMode !== "command") return null;
64
+ if (!cfg.region || !cfg.accessKeyId || !cfg.secretAccessKey || !cfg.mfaArn || !cfg.roleArn) return null;
65
+
66
+ try {
67
+ const proc = Bun.spawnSync(["sh", "-c", cfg.mfaCommand]);
68
+ if (proc.exitCode !== 0) return null;
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;
107
+ }
108
+ }
109
+
110
+ // --- Helper: show dialog and obtain credentials ---
111
+
112
+ async function obtainViaDialog(cfg: Partial<Config>): Promise<void> {
113
+ const result = showDialog(cfg);
114
+ if (!result) {
115
+ process.stderr.write("User cancelled dialog.\n");
116
+ process.exit(2);
117
+ }
118
+
37
119
  let mfaCode = result.mfaCode;
38
120
  if (result.mfaMode === "command") {
39
121
  const proc = Bun.spawnSync(["sh", "-c", result.mfaCommand]);
@@ -54,6 +136,13 @@ try {
54
136
  duration: parseInt(result.duration, 10) || STANDARD_DURATIONS[0],
55
137
  });
56
138
 
139
+ const session: CachedSession = {
140
+ AccessKeyId: credentials.AccessKeyId,
141
+ SecretAccessKey: credentials.SecretAccessKey,
142
+ SessionToken: credentials.SessionToken,
143
+ Expiration: credentials.Expiration,
144
+ };
145
+
57
146
  saveConfig({
58
147
  region: result.region,
59
148
  accessKeyId: result.accessKeyId,
@@ -63,10 +152,81 @@ try {
63
152
  duration,
64
153
  mfaMode: result.mfaMode,
65
154
  mfaCommand: result.mfaCommand || undefined,
155
+ cacheSession: defaults.cacheSession,
156
+ autoMfa: defaults.autoMfa,
157
+ singleInstanceLock: defaults.singleInstanceLock,
158
+ ...(useCache ? { cachedSession: session } : {}),
66
159
  });
67
160
 
68
- console.log(JSON.stringify({ Credentials: credentials }));
161
+ outputCredentials(session);
162
+ }
163
+
164
+ // --- Main flow ---
165
+
166
+ try {
167
+ // 1. If cache-session is enabled, check for unexpired cached credentials
168
+ if (useCache && config?.cachedSession) {
169
+ if (isSessionValid(config.cachedSession)) {
170
+ // Return cached credentials without showing dialog
171
+ outputCredentials(config.cachedSession);
172
+ process.exit(0);
173
+ }
174
+ // Expired — clear the cached session
175
+ saveConfig({ ...config, cachedSession: undefined });
176
+ }
177
+
178
+ // 2. If auto-mfa is enabled, try to obtain credentials without dialog
179
+ if (useAutoMfa) {
180
+ const session = await tryAutoMfa(defaults);
181
+ if (session) {
182
+ outputCredentials(session);
183
+ process.exit(0);
184
+ }
185
+ }
186
+
187
+ // 3. If single-instance-lock is enabled, acquire lock before showing dialog
188
+ if (useLock) {
189
+ if (!tryAcquireLock()) {
190
+ // Another instance holds the lock — wait for it
191
+ process.stderr.write("Waiting for another instance to finish...\n");
192
+ await waitForLock();
193
+
194
+ // After lock releases, re-check cached session (another instance may have refreshed it)
195
+ if (useCache) {
196
+ const freshConfig = loadConfig();
197
+ if (freshConfig?.cachedSession && isSessionValid(freshConfig.cachedSession)) {
198
+ outputCredentials(freshConfig.cachedSession);
199
+ process.exit(0);
200
+ }
201
+ }
202
+
203
+ // Still need credentials — acquire lock and show dialog
204
+ if (!tryAcquireLock()) {
205
+ // Edge case: another waiter grabbed the lock first — wait again
206
+ await waitForLock();
207
+ if (useCache) {
208
+ const freshConfig = loadConfig();
209
+ if (freshConfig?.cachedSession && isSessionValid(freshConfig.cachedSession)) {
210
+ outputCredentials(freshConfig.cachedSession);
211
+ process.exit(0);
212
+ }
213
+ }
214
+ // Final attempt to acquire — proceed regardless
215
+ tryAcquireLock();
216
+ }
217
+ }
218
+
219
+ try {
220
+ await obtainViaDialog(defaults);
221
+ } finally {
222
+ releaseLock();
223
+ }
224
+ } else {
225
+ // No lock — just show dialog directly
226
+ await obtainViaDialog(defaults);
227
+ }
69
228
  } catch (err) {
229
+ if (useLock) releaseLock();
70
230
  // Mask any AWS access-key IDs or secret keys that may appear in error messages
71
231
  const raw = String(err);
72
232
  const masked = raw
package/src/sts.ts CHANGED
@@ -47,6 +47,7 @@ export async function assumeRoleWithMfa(params: {
47
47
  AccessKeyId: c!.AccessKeyId!,
48
48
  SecretAccessKey: c!.SecretAccessKey!,
49
49
  SessionToken: c!.SessionToken!,
50
+ Expiration: c!.Expiration ? c!.Expiration.toISOString() : new Date(Date.now() + ladder[i] * 1000).toISOString(),
50
51
  },
51
52
  duration: ladder[i],
52
53
  };
@@ -0,0 +1,53 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { parseFlags } from "../src/flags";
3
+
4
+ describe("parseFlags", () => {
5
+ // argv[0] is the runtime, argv[1] is the script — flags start at index 2
6
+ const base = ["/usr/bin/bun", "/path/to/index.ts"];
7
+
8
+ test("returns defaults when no flags", () => {
9
+ const flags = parseFlags(base);
10
+ expect(flags.setup).toBe(false);
11
+ expect(flags.cacheSession).toBeUndefined();
12
+ expect(flags.autoMfa).toBeUndefined();
13
+ expect(flags.singleInstanceLock).toBeUndefined();
14
+ });
15
+
16
+ test("parses --setup", () => {
17
+ expect(parseFlags([...base, "--setup"]).setup).toBe(true);
18
+ expect(parseFlags([...base, "setup"]).setup).toBe(true);
19
+ });
20
+
21
+ test("parses --cache-session and --no-cache-session", () => {
22
+ expect(parseFlags([...base, "--cache-session"]).cacheSession).toBe(true);
23
+ expect(parseFlags([...base, "--no-cache-session"]).cacheSession).toBe(false);
24
+ });
25
+
26
+ test("parses --auto-mfa and --no-auto-mfa", () => {
27
+ expect(parseFlags([...base, "--auto-mfa"]).autoMfa).toBe(true);
28
+ expect(parseFlags([...base, "--no-auto-mfa"]).autoMfa).toBe(false);
29
+ });
30
+
31
+ test("parses --single-instance-lock and --no-single-instance-lock", () => {
32
+ expect(parseFlags([...base, "--single-instance-lock"]).singleInstanceLock).toBe(true);
33
+ expect(parseFlags([...base, "--no-single-instance-lock"]).singleInstanceLock).toBe(false);
34
+ });
35
+
36
+ test("last flag wins for conflicting flags", () => {
37
+ const flags = parseFlags([...base, "--cache-session", "--no-cache-session"]);
38
+ expect(flags.cacheSession).toBe(false);
39
+ });
40
+
41
+ test("parses multiple flags together", () => {
42
+ const flags = parseFlags([...base, "--cache-session", "--auto-mfa", "--single-instance-lock"]);
43
+ expect(flags.cacheSession).toBe(true);
44
+ expect(flags.autoMfa).toBe(true);
45
+ expect(flags.singleInstanceLock).toBe(true);
46
+ });
47
+
48
+ test("ignores unknown flags", () => {
49
+ const flags = parseFlags([...base, "--unknown-flag", "--cache-session"]);
50
+ expect(flags.cacheSession).toBe(true);
51
+ expect(flags.setup).toBe(false);
52
+ });
53
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, test, expect, afterEach } from "bun:test";
2
+ import { tryAcquireLock, releaseLock, waitForLock } from "../src/config";
3
+
4
+ describe("single-instance lock", () => {
5
+ afterEach(() => {
6
+ releaseLock();
7
+ });
8
+
9
+ test("tryAcquireLock succeeds on first call", () => {
10
+ expect(tryAcquireLock()).toBe(true);
11
+ });
12
+
13
+ test("tryAcquireLock fails when lock is already held", () => {
14
+ expect(tryAcquireLock()).toBe(true);
15
+ expect(tryAcquireLock()).toBe(false);
16
+ });
17
+
18
+ test("releaseLock allows re-acquisition", () => {
19
+ expect(tryAcquireLock()).toBe(true);
20
+ releaseLock();
21
+ expect(tryAcquireLock()).toBe(true);
22
+ });
23
+
24
+ test("releaseLock is safe to call when no lock exists", () => {
25
+ releaseLock(); // should not throw
26
+ releaseLock(); // double release should not throw
27
+ });
28
+
29
+ test("waitForLock resolves immediately when no lock exists", async () => {
30
+ const start = Date.now();
31
+ await waitForLock();
32
+ expect(Date.now() - start).toBeLessThan(100);
33
+ });
34
+
35
+ test("waitForLock resolves after lock is released", async () => {
36
+ expect(tryAcquireLock()).toBe(true);
37
+
38
+ // Release the lock after a short delay
39
+ setTimeout(() => releaseLock(), 200);
40
+
41
+ const start = Date.now();
42
+ await waitForLock();
43
+ const elapsed = Date.now() - start;
44
+ // Should resolve within ~200ms + poll interval (500ms)
45
+ expect(elapsed).toBeLessThan(1000);
46
+ });
47
+ });
@@ -0,0 +1,89 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import type { CachedSession, Config } from "../src/config";
3
+
4
+ describe("session cache validation", () => {
5
+ function isSessionValid(session: CachedSession | undefined): session is CachedSession {
6
+ if (!session) return false;
7
+ return new Date(session.Expiration).getTime() > Date.now();
8
+ }
9
+
10
+ const validSession: CachedSession = {
11
+ AccessKeyId: "ASIATEMP",
12
+ SecretAccessKey: "TempSecret",
13
+ SessionToken: "TempToken",
14
+ Expiration: new Date(Date.now() + 3600_000).toISOString(), // 1 hour from now
15
+ };
16
+
17
+ const expiredSession: CachedSession = {
18
+ AccessKeyId: "ASIATEMP",
19
+ SecretAccessKey: "TempSecret",
20
+ SessionToken: "TempToken",
21
+ Expiration: new Date(Date.now() - 1000).toISOString(), // 1 second ago
22
+ };
23
+
24
+ test("returns true for unexpired session", () => {
25
+ expect(isSessionValid(validSession)).toBe(true);
26
+ });
27
+
28
+ test("returns false for expired session", () => {
29
+ expect(isSessionValid(expiredSession)).toBe(false);
30
+ });
31
+
32
+ test("returns false for undefined session", () => {
33
+ expect(isSessionValid(undefined)).toBe(false);
34
+ });
35
+
36
+ test("cached session round-trips through config JSON", () => {
37
+ const config: Config = {
38
+ region: "us-west-2",
39
+ accessKeyId: "AKIAIOSFODNN7EXAMPLE",
40
+ secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
41
+ mfaArn: "arn:aws:iam::123456789012:mfa/user",
42
+ roleArn: "arn:aws:iam::123456789012:role/TestRole",
43
+ duration: 7200,
44
+ cacheSession: true,
45
+ cachedSession: validSession,
46
+ };
47
+
48
+ const json = JSON.stringify(config, null, 2);
49
+ const parsed = JSON.parse(json) as Config;
50
+ expect(parsed.cachedSession).toEqual(validSession);
51
+ expect(parsed.cacheSession).toBe(true);
52
+ });
53
+
54
+ test("config without cached session is valid", () => {
55
+ const config: Config = {
56
+ region: "us-west-2",
57
+ accessKeyId: "AKIAIOSFODNN7EXAMPLE",
58
+ secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
59
+ mfaArn: "arn:aws:iam::123456789012:mfa/user",
60
+ roleArn: "arn:aws:iam::123456789012:role/TestRole",
61
+ duration: 7200,
62
+ };
63
+
64
+ const json = JSON.stringify(config, null, 2);
65
+ const parsed = JSON.parse(json) as Config;
66
+ expect(parsed.cachedSession).toBeUndefined();
67
+ expect(parsed.cacheSession).toBeUndefined();
68
+ });
69
+
70
+ test("feature flags round-trip through config JSON", () => {
71
+ const config: Config = {
72
+ region: "us-west-2",
73
+ accessKeyId: "AKIAIOSFODNN7EXAMPLE",
74
+ secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
75
+ mfaArn: "arn:aws:iam::123456789012:mfa/user",
76
+ roleArn: "arn:aws:iam::123456789012:role/TestRole",
77
+ duration: 7200,
78
+ cacheSession: true,
79
+ autoMfa: true,
80
+ singleInstanceLock: false,
81
+ };
82
+
83
+ const json = JSON.stringify(config, null, 2);
84
+ const parsed = JSON.parse(json) as Config;
85
+ expect(parsed.cacheSession).toBe(true);
86
+ expect(parsed.autoMfa).toBe(true);
87
+ expect(parsed.singleInstanceLock).toBe(false);
88
+ });
89
+ });
package/test/sts.test.ts CHANGED
@@ -37,6 +37,7 @@ const MOCK_CREDENTIALS = {
37
37
  AccessKeyId: "ASIATEMP",
38
38
  SecretAccessKey: "TempSecret",
39
39
  SessionToken: "TempToken",
40
+ Expiration: new Date("2099-01-01T00:00:00Z"),
40
41
  },
41
42
  };
42
43
 
@@ -98,6 +99,7 @@ describe("assumeRoleWithMfa", () => {
98
99
  AccessKeyId: "ASIATEMP",
99
100
  SecretAccessKey: "TempSecret",
100
101
  SessionToken: "TempToken",
102
+ Expiration: "2099-01-01T00:00:00.000Z",
101
103
  });
102
104
  expect(result.duration).toBe(43200);
103
105
  });
@@ -1,76 +0,0 @@
1
- name: Publish to npm
2
-
3
- on:
4
- release:
5
- types: [published]
6
-
7
- jobs:
8
- publish:
9
- runs-on: ubuntu-latest
10
- permissions:
11
- contents: read
12
- id-token: write
13
-
14
- steps:
15
- - uses: actions/checkout@v4
16
-
17
- - name: Validate release tag
18
- run: |
19
- TAG="${GITHUB_REF_NAME}"
20
- IS_PRERELEASE="${{ github.event.release.prerelease }}"
21
-
22
- # Must start with "v"
23
- if [[ "$TAG" != v* ]]; then
24
- echo "::error::Tag must start with 'v' (got '$TAG')"
25
- exit 1
26
- fi
27
-
28
- VERSION="${TAG#v}"
29
-
30
- # Must be valid semver (X.Y.Z or X.Y.Z-prerelease)
31
- if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9._-]+)?$ ]]; then
32
- echo "::error::Tag must be a valid semver with a 'v' prefix (got '$TAG')"
33
- exit 1
34
- fi
35
-
36
- HAS_PRERELEASE_SUFFIX="false"
37
- if [[ "$VERSION" =~ - ]]; then
38
- HAS_PRERELEASE_SUFFIX="true"
39
- fi
40
-
41
- # Prerelease checkbox must match tag format
42
- if [ "$IS_PRERELEASE" = "true" ] && [ "$HAS_PRERELEASE_SUFFIX" = "false" ]; then
43
- echo "::error::Release is marked as prerelease but tag '$TAG' has no prerelease suffix (e.g. v1.0.0-beta.1)"
44
- exit 1
45
- fi
46
-
47
- if [ "$IS_PRERELEASE" = "false" ] && [ "$HAS_PRERELEASE_SUFFIX" = "true" ]; then
48
- echo "::error::Tag '$TAG' has a prerelease suffix but release is not marked as prerelease"
49
- exit 1
50
- fi
51
-
52
- echo "VERSION=$VERSION" >> "$GITHUB_ENV"
53
-
54
- - uses: oven-sh/setup-bun@v2
55
- with:
56
- bun-version: latest
57
-
58
- - run: bun install --frozen-lockfile
59
-
60
- - run: bun test
61
-
62
- - name: Set package version from release tag
63
- run: npm version "$VERSION" --no-git-tag-version
64
-
65
- - uses: actions/setup-node@v4
66
- with:
67
- node-version: 24
68
- registry-url: https://registry.npmjs.org
69
-
70
- - name: Publish to npm
71
- run: |
72
- TAG="latest"
73
- if [ "${{ github.event.release.prerelease }}" = "true" ]; then
74
- TAG="next"
75
- fi
76
- npm publish --access public --tag "$TAG"