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.
- package/.github/workflows/release.yml +139 -0
- package/CHANGELOG.md +55 -0
- package/README.md +27 -0
- package/package.json +1 -1
- package/src/config.ts +73 -0
- package/src/flags.ts +42 -0
- package/src/index.ts +170 -10
- package/src/sts.ts +1 -0
- package/test/index.test.ts +53 -0
- package/test/lock.test.ts +47 -0
- package/test/session-cache.test.ts +89 -0
- package/test/sts.test.ts +2 -0
- package/.github/workflows/publish.yml +0 -76
|
@@ -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
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
|
-
|
|
19
|
-
|
|
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 {
|
|
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
|
-
|
|
32
|
+
// --- Resolve effective flags (CLI overrides config defaults) ---
|
|
29
33
|
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|