From 54059fd51018adc0984cd855d9474791f05e35f8 Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 14:09:54 +0200 Subject: [PATCH 01/29] docs: add SSOmatic 2.0 daemon + list-first UX design spec --- ...2026-06-11-ssomatic-v2-daemon-ux-design.md | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-11-ssomatic-v2-daemon-ux-design.md diff --git a/docs/superpowers/specs/2026-06-11-ssomatic-v2-daemon-ux-design.md b/docs/superpowers/specs/2026-06-11-ssomatic-v2-daemon-ux-design.md new file mode 100644 index 0000000..0368fc7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-ssomatic-v2-daemon-ux-design.md @@ -0,0 +1,222 @@ +# SSOmatic 2.0 — Background Daemon + List-First UX + +**Date:** 2026-06-11 +**Branch:** `feat/v2-daemon-ux` +**Status:** Approved design, ready for implementation planning + +## Summary + +A full overhaul of SSOmatic, the interactive AWS SSO credential manager. Three goals: + +1. **A real background daemon** with a single shared state per host. Run it in the + background, return to the terminal, and re-launching from any terminal attaches to + the live state. +2. **A list-first ("k9s-style") TUI** where the profile list is the home screen and + actions are single keypresses — faster than today's menu-drilling. +3. **An npm-facing README rewrite**, latest library versions, and a code refactor of + today's monolithic 902-line `index.tsx`. + +## Background / current state + +Today's "Auto-refresh daemon" is **not** a daemon: it is a `setInterval` inside the Ink +TUI (`DaemonView` in `src/cli/index.tsx`). It dies when the TUI quits. There is no +separate process, no PID file, no IPC, no shared state. The background pattern described +here is net-new architecture. + +AWS SSO has two token layers, which drives the daemon design: + +- **Role credentials** (`~/.aws/credentials`) — can be re-derived silently by the daemon + *as long as the SSO token is still valid*. No human needed. +- **SSO token** (`~/.aws/sso/cache/{hash}.json`) — when it expires (typically ~8h), + refreshing **requires** an interactive browser device-authorization flow. A headless + background process cannot do this alone. + +So the daemon's job is: refresh role credentials silently while it can, and **notify the +user when a human login is required** — never open a browser unprompted. + +## Decisions (locked) + +| Decision | Choice | +|----------|--------| +| Daemon ↔ TUI communication | Detached daemon process + **Unix socket**, TUI live-attaches and gets pushed updates | +| On SSO-token expiry (login needed) | **Notify + wait for human**; daemon never opens a browser | +| Bare `ssomatic`, no daemon running | Opens TUI in foreground; **daemon is opt-in** (`b` / `--daemon`) | +| CLI surface | **Full subcommand set** (`status`, `refresh`, `export`, `daemon …`) + default TUI | +| Home-screen model | **List-first dashboard** (k9s-style), no top-level menu | +| Refresh timing | **Expiry-aware** (set & forget); no interval picker | +| Daemon-managed set | **Favorites = managed.** Starring a profile sorts it to top *and* keeps it fresh | +| Per-profile actions | Copy export block, Open AWS console, Details view (Enter), Copy profile name | + +## Architecture + +Refactor today's monolith into three layers: + +``` +src/ +├── core/ # UI-agnostic AWS logic (today's src/aws, lightly refactored) +│ ├── sso.ts # profile discovery, token cache, silent refresh, device-auth flow +│ ├── aws.ts # STS identity +│ ├── settings.ts # split out of sso.ts (favorites, notifications, lead-time, auto-start) +│ └── utils.ts # clipboard, json, console-url builder +├── daemon/ +│ ├── scheduler.ts # expiry-aware refresh loop +│ ├── server.ts # Unix-socket server, state broadcast +│ ├── protocol.ts # shared message types (client ⇄ daemon) +│ └── lifecycle.ts # spawn/detach, pid+sock files, single-instance guard +└── cli/ + ├── index.tsx # entry: parse args → subcommand OR launch TUI + ├── commands/ # status / refresh / export / daemon (non-interactive) + └── tui/ + ├── Dashboard.tsx # list-first home screen + ├── Details.tsx # Enter drill-in + ├── Settings.tsx + ├── useDaemon.ts # socket client + live state hook + └── components/ # reuse existing List, Card, Spinner, StatusMessage, etc. +``` + +Existing reusable components (`List`, `MultiSelectList`, `Card`, `Divider`, `Header`, +`Spinner`, `StatusMessage`, `CopyFeedback`, `IdentityCard`) and hooks (`useCopy`, +`useIdentity`) are kept and reused where they fit. + +## Daemon & IPC + +### Single instance per host + +- On start, the daemon binds a Unix socket. If the socket already has a **live** + listener → refuse to start ("daemon already running, pid N"). If the socket file is + **stale** (no listener) → remove and reclaim it. +- A PID file accompanies the socket for `daemon status` reporting. +- **File locations:** socket + PID in a runtime dir (`$XDG_RUNTIME_DIR` if set, else + `os.tmpdir()`); daemon log in `~/.aws/ssomatic/daemon.log`. Settings stay at + `~/.aws/credentials-manager.json` (existing path, backward compatible). + +### Socket protocol (newline-delimited JSON) + +Client → daemon: + +- `{ type: "subscribe" }` — stream state updates until disconnect +- `{ type: "snapshot" }` — one-shot current state, then close (used by `ssomatic status`) +- `{ type: "refresh", profile?: string }` — force a refresh now +- `{ type: "setFavorite", profile: string, value: boolean }` +- `{ type: "stop" }` — graceful daemon shutdown + +Daemon → client: + +- `{ type: "state", profiles: ProfileState[], daemon: { pid, startedAt } }` — pushed on + every state change and immediately on `subscribe`. + +The **socket is the single source of truth for live state** — there is no separate state +file that could drift. + +### Scheduler (expiry-aware) + +- For each ⭐ favorite, refresh its role credentials a configurable **lead-time** before + expiry (default 5 minutes). +- While the SSO token is valid, this refresh is silent (re-derive role creds, write + `~/.aws/credentials`, broadcast new state). +- When a profile's SSO token has expired, mark it `needs-login`, send a desktop + notification (if enabled), and broadcast state. **The daemon does not open a browser.** + +### Login is always client-driven + +The interactive device-auth flow (open browser + poll for token) runs only in an +interactive context — the TUI or `ssomatic refresh`. Once the new SSO token is written to +the cache, the daemon detects it on its next tick and resumes silent refresh. The daemon +itself never performs device authorization. + +## CLI surface + +| Command | Behavior | +|---------|----------| +| `ssomatic` | List-first TUI (foreground). If a daemon is running, **attach** and show live state. If not, run standalone; `b` starts a daemon on demand. | +| `ssomatic status` | Connect, request one snapshot, print a plain-text table, exit. Falls back to a direct read if no daemon. | +| `ssomatic refresh [profile]` | Refresh silently if the SSO token is valid; otherwise run the device-auth flow inline in the terminal. No profile → all favorites. | +| `ssomatic export ` | Print `export AWS_ACCESS_KEY_ID=… …` lines for `eval $(ssomatic export )`. | +| `ssomatic daemon start` | Spawn + detach the daemon, return to the shell. | +| `ssomatic daemon stop` | Send `stop` over the socket. | +| `ssomatic daemon status` | Report running/stopped, pid, watched profiles, next refresh. | +| `ssomatic --version` / `-v` | Print version (existing). | + +Subcommand parsing is hand-rolled — no new dependency. + +## TUI — list-first dashboard + +The home screen is the profile list with live statuses sourced from the socket +(`useDaemon`). A header line shows daemon status (`● running` / `○ off`). + +``` +🔐 SSOmatic daemon ● running +────────────────────────────────────────────────────────── + PROFILE STATUS EXPIRES ACCOUNT +▸ ★ prod ● valid 58m 1234…7890 + ★ dev ● valid 12m 2345…8901 + staging ⚠ needs-login — 3456…9012 +────────────────────────────────────────────────────────── +↑↓ move space sel ⏎ details r refresh b bg +f ★ c copy y name o console / filter s settings q quit +``` + +### Keybindings + +| Key | Action | +|-----|--------| +| `↑↓` / `j` `k` | Move cursor | +| `space` | Toggle multi-select | +| `a` | Select all / none | +| `⏎` | Details view (account ID, role ARN, region, exact expiry, SSO start URL) | +| `r` | Refresh selected (silent if possible, else device-auth) | +| `b` | Run in background (start daemon) | +| `f` | Toggle ⭐ favorite (= keep fresh + sort to top) | +| `c` | Copy export block to clipboard | +| `y` | Copy profile name | +| `o` | Open AWS console (federated sign-in) for the selected profile's role | +| `/` | Filter list | +| `s` | Settings | +| `Esc` | Back | +| `q` | Quit (detaches if attached to a daemon; daemon keeps running) | + +### Settings (shrunk) + +- Notifications on/off +- Refresh lead-time (minutes before expiry; default 5) +- Auto-start daemon on launch? (default **off**) + +## Features dropped / changed + +- ❌ **Interval picker** (15/30/60/120 min) — replaced by expiry-aware scheduling. +- ❌ **Separate "Check status" and "Auto-refresh" menu screens** — folded into the single + dashboard. +- ✅ **Kept:** auto-discovery, multi-select refresh, desktop notifications (now for + `needs-login`), favorites, persistent settings. +- ⚙️ **Settings shrinks** to the three items above. + +## Libraries & README + +- Bump to latest: `ink`, `ink-spinner`, `react`, `@aws-sdk/client-sso`, + `@aws-sdk/client-sso-oidc`, `@aws-sdk/client-sts`, `ini`, and the eslint/TypeScript + toolchain. Verify each major bump against its changelog during implementation. +- **README rewrite** for npm appeal: a one-liner hook at the top, the background-daemon + story front and center, an updated feature list, the new keybinding table, the new + subcommands, and a fresh demo GIF. + +## Testing strategy + +- **`core/`** — keep and extend existing unit tests (profile discovery, settings + round-trip, token cache/status, `sortByFavorites`, `formatExpiry`). Add tests for the + console-URL builder and the new `settings.ts` split. +- **`daemon/protocol.ts`** — unit-test message (de)serialization. +- **`daemon/lifecycle.ts`** — test single-instance detection (live vs. stale socket) and + pid/sock file handling against a temp runtime dir. +- **`daemon/scheduler.ts`** — test the expiry-aware "should refresh now?" decision with + injected clock + token states (valid → silent, expired → needs-login). +- **`daemon/server.ts`** — integration test: start server on a temp socket, connect a + client, assert `snapshot`/`subscribe` responses and broadcast-on-change. +- TUI components remain manually verified (no component-test harness today); keep that + scope unless trivially testable. + +## Out of scope + +- OS-managed service (launchd/systemd auto-start on boot) — not chosen; revisit later. +- Auto-opening the browser on SSO-token expiry — explicitly rejected. +- Windows-specific socket/IPC support beyond what Node/Bun provide cross-platform + (best-effort; primary targets remain macOS and Linux). From b52672817a8b70ac7569fcb1e2604b7eb262cbab Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 14:17:55 +0200 Subject: [PATCH 02/29] docs: add SSOmatic 2.0 implementation plan --- .../plans/2026-06-11-ssomatic-v2-daemon-ux.md | 2079 +++++++++++++++++ 1 file changed, 2079 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-ssomatic-v2-daemon-ux.md diff --git a/docs/superpowers/plans/2026-06-11-ssomatic-v2-daemon-ux.md b/docs/superpowers/plans/2026-06-11-ssomatic-v2-daemon-ux.md new file mode 100644 index 0000000..4affc37 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-ssomatic-v2-daemon-ux.md @@ -0,0 +1,2079 @@ +# SSOmatic 2.0 — Background Daemon + List-First UX Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild SSOmatic with a real per-host background daemon (Unix socket, live-attach), a list-first k9s-style TUI, full CLI subcommands, latest libraries, and an npm-facing README. + +**Architecture:** Three layers — `core/` (UI-agnostic AWS logic), `daemon/` (detached process: expiry-aware scheduler + Unix-socket server with a shared newline-delimited-JSON protocol), and `cli/` (arg router → non-interactive subcommands or the Ink TUI client that attaches to the daemon over the socket). The socket is the single source of truth for live state; login (browser device-auth) is always client-driven, never done by the daemon. + +**Tech Stack:** Bun (runtime + test runner + build), TypeScript, React 19, Ink 6, `node:net` (Unix socket), `node:child_process` (detach), AWS SDK v3 (`client-sso`, `client-sso-oidc`, `client-sts`), `ini`. + +**Reference spec:** `docs/superpowers/specs/2026-06-11-ssomatic-v2-daemon-ux-design.md` + +--- + +## Conventions for every task + +- Tests use `bun:test` (`import { test, expect, describe, beforeEach, afterEach } from "bun:test"`). +- Run a single test file with: `bun test src/path/to/file.test.ts` +- Run all tests with: `bun test` +- Lint with: `bun run lint` +- Commit messages follow Conventional Commits with allowed scopes `cli`, `aws`, `deps`, `ci`. For this work use `core`-related changes under scope `aws`, daemon/TUI under scope `cli`. +- After each task, run `bun run lint` and `bun test` before committing. + +### IMPORTANT — confirm existing signatures first + +Several tasks call existing functions in `src/aws/sso.ts` and `src/aws/aws.ts`. Before the first task that uses each one, open the file and confirm the exact signature/type field names, then adapt the plan's code to match. The functions referenced (verify names + shapes): `discoverProfiles()`, `checkTokenStatus(profile)`, `checkAllProfiles(profiles)`, `findCachedToken(profile)`, `startDeviceAuthorization(profile)`, `pollForToken(profile, deviceAuth)`, `saveSSOTokenToCache(profile, tokenInfo)`, `getCredentialsWithToken(profile, accessToken)`, `refreshProfile(profile)`, `performSSOLoginFlow(profile, deviceAuth)`, `formatExpiry(date)`, `getStatusColor(status)`, `sortByFavorites(items, favorites, getName)`, `openBrowser(url)`, `sendNotification(title, msg)`. Types: `SSOProfile`, `ProfileStatus`, `CachedToken`, `AWSCredentials`, `DeviceAuthInfo`, `AppSettings`. + +--- + +## Phase 0 — Dependency upgrades + +### Task 0: Bump to latest library versions + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Check the latest versions** + +Run: +```bash +bun pm view ink version; bun pm view ink-spinner version; bun pm view react version; \ +bun pm view @aws-sdk/client-sso version; bun pm view @aws-sdk/client-sso-oidc version; \ +bun pm view @aws-sdk/client-sts version; bun pm view ini version; \ +bun pm view eslint version; bun pm view typescript version +``` +Note each version. (If `bun pm view` is unavailable, use `npm view version`.) + +- [ ] **Step 2: Upgrade dependencies** + +Run: +```bash +bun add ink@latest ink-spinner@latest react@latest \ + @aws-sdk/client-sso@latest @aws-sdk/client-sso-oidc@latest @aws-sdk/client-sts@latest ini@latest +bun add -d @types/react@latest typescript@latest eslint@latest @eslint/js@latest \ + @typescript-eslint/eslint-plugin@latest @typescript-eslint/parser@latest \ + eslint-plugin-react@latest eslint-plugin-react-hooks@latest @types/ini@latest +``` + +- [ ] **Step 3: Verify the app still builds, lints, and tests pass** + +Run: `bun install && bun run lint && bun test && bun run build` +Expected: all succeed. If a major bump breaks something (e.g. Ink API change), read that library's changelog/migration notes and fix the breakage before continuing. Do not proceed with a red build. + +- [ ] **Step 4: Smoke-test the current app launches** + +Run: `bun run start` then press `q` to quit. +Expected: the existing TUI renders and exits cleanly. + +- [ ] **Step 5: Commit** + +```bash +git add package.json bun.lock +git commit -m "build(deps): upgrade ink, react, aws-sdk and toolchain to latest" +``` + +--- + +## Phase 1 — Core layer refactor + +The existing `src/aws/` becomes the UI-agnostic core. We split settings out, update the settings shape, and add a console-URL builder. We do NOT rename the directory (keep `src/aws/`) to minimize churn and preserve import paths and the CLAUDE.md structure. + +### Task 1: Split settings into `settings.ts` with the new shape + +**Files:** +- Create: `src/aws/settings.ts` +- Create: `src/aws/settings.test.ts` +- Modify: `src/aws/sso.ts` (remove `loadSettings`/`saveSettings`/`AppSettings` and re-export from settings, or delete and update imports) + +**New settings shape** (drop `defaultInterval`, add `refreshLeadMinutes` + `autoStartDaemon`): + +```typescript +export interface AppSettings { + notifications: boolean; + refreshLeadMinutes: number; // refresh this many minutes before expiry + autoStartDaemon: boolean; // start daemon automatically on TUI launch + favoriteProfiles: string[]; +} + +export const DEFAULT_SETTINGS: AppSettings = { + notifications: true, + refreshLeadMinutes: 5, + autoStartDaemon: false, + favoriteProfiles: [], +}; +``` + +- [ ] **Step 1: Write the failing test** + +Create `src/aws/settings.test.ts`: +```typescript +import { test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +let home: string; +let prevHome: string | undefined; + +beforeEach(() => { + prevHome = process.env.HOME; + home = mkdtempSync(join(tmpdir(), "ssomatic-settings-")); + process.env.HOME = home; +}); + +afterEach(() => { + process.env.HOME = prevHome; + rmSync(home, { recursive: true, force: true }); +}); + +test("loadSettings returns defaults when no file exists", async () => { + const { loadSettings, DEFAULT_SETTINGS } = await import("./settings"); + expect(loadSettings()).toEqual(DEFAULT_SETTINGS); +}); + +test("saveSettings then loadSettings round-trips", async () => { + const { loadSettings, saveSettings } = await import("./settings"); + saveSettings({ + notifications: false, + refreshLeadMinutes: 10, + autoStartDaemon: true, + favoriteProfiles: ["prod", "dev"], + }); + expect(loadSettings()).toEqual({ + notifications: false, + refreshLeadMinutes: 10, + autoStartDaemon: true, + favoriteProfiles: ["prod", "dev"], + }); +}); + +test("loadSettings migrates a legacy file with defaultInterval", async () => { + const { loadSettings } = await import("./settings"); + const { writeFileSync, mkdirSync } = await import("node:fs"); + mkdirSync(join(home, ".aws"), { recursive: true }); + writeFileSync( + join(home, ".aws", "credentials-manager.json"), + JSON.stringify({ notifications: true, defaultInterval: 30, favoriteProfiles: ["x"] }), + ); + const s = loadSettings(); + expect(s.favoriteProfiles).toEqual(["x"]); + expect(s.refreshLeadMinutes).toBe(5); // default filled in + expect(s.autoStartDaemon).toBe(false); // default filled in + expect("defaultInterval" in s).toBe(false); +}); +``` +Note: `bun:test` runs each test file in a fresh module registry, so dynamic `import("./settings")` after setting `HOME` is the safe pattern if the module reads `HOME` at import time. If `loadSettings` reads `HOME` lazily (inside the function), a top-level static import is fine — adapt to match the implementation. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/aws/settings.test.ts` +Expected: FAIL — module `./settings` not found. + +- [ ] **Step 3: Implement `settings.ts`** + +Create `src/aws/settings.ts`: +```typescript +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, dirname } from "node:path"; + +export interface AppSettings { + notifications: boolean; + refreshLeadMinutes: number; + autoStartDaemon: boolean; + favoriteProfiles: string[]; +} + +export const DEFAULT_SETTINGS: AppSettings = { + notifications: true, + refreshLeadMinutes: 5, + autoStartDaemon: false, + favoriteProfiles: [], +}; + +function settingsPath(): string { + return join(homedir(), ".aws", "credentials-manager.json"); +} + +export function loadSettings(): AppSettings { + const path = settingsPath(); + if (!existsSync(path)) return { ...DEFAULT_SETTINGS }; + try { + const raw = JSON.parse(readFileSync(path, "utf8")) as Partial & { + defaultInterval?: number; + }; + return { + notifications: raw.notifications ?? DEFAULT_SETTINGS.notifications, + refreshLeadMinutes: raw.refreshLeadMinutes ?? DEFAULT_SETTINGS.refreshLeadMinutes, + autoStartDaemon: raw.autoStartDaemon ?? DEFAULT_SETTINGS.autoStartDaemon, + favoriteProfiles: raw.favoriteProfiles ?? DEFAULT_SETTINGS.favoriteProfiles, + }; + } catch { + return { ...DEFAULT_SETTINGS }; + } +} + +export function saveSettings(settings: AppSettings): void { + const path = settingsPath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(settings, null, 2)); +} +``` +Note: `homedir()` reflects `process.env.HOME` on macOS/Linux, so the temp-HOME tests work. If the existing code used a cached home constant, replicate lazy reads as above. + +- [ ] **Step 4: Remove the old settings code from `sso.ts`** + +In `src/aws/sso.ts`, delete the old `AppSettings` interface and `loadSettings`/`saveSettings` functions. If other code in `sso.ts` referenced them, import from `./settings` instead. Update `src/cli/index.tsx` (and any other importers) to import `loadSettings`, `saveSettings`, `AppSettings` from `../aws/settings` instead of `../aws/sso`. Search first: +```bash +grep -rn "loadSettings\|saveSettings\|AppSettings\|defaultInterval" src +``` + +- [ ] **Step 5: Run tests + lint** + +Run: `bun test src/aws/settings.test.ts && bun run lint` +Expected: settings tests PASS; lint clean. (Other tests/TUI may reference `defaultInterval` — fix those references now; the old interval UI is removed in Phase 9, so for now just make it compile.) + +- [ ] **Step 6: Commit** + +```bash +git add src/aws/settings.ts src/aws/settings.test.ts src/aws/sso.ts src/cli/index.tsx +git commit -m "refactor(aws): split settings into settings.ts with expiry-aware shape" +``` + +### Task 2: Add console-URL builder + role-credentials accessor to core + +The TUI's `o` (open console) and `c`/`export` actions need (a) a federated console sign-in URL and (b) the current role credentials for a profile. Add focused helpers. + +**Files:** +- Create: `src/aws/console.ts` +- Create: `src/aws/console.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/aws/console.test.ts`: +```typescript +import { test, expect } from "bun:test"; +import { buildFederationSigninUrl, buildExportBlock } from "./console"; + +test("buildExportBlock produces shell export lines", () => { + const block = buildExportBlock({ + accessKeyId: "ASIAEXAMPLE", + secretAccessKey: "secret", + sessionToken: "token", + }); + expect(block).toBe( + "export AWS_ACCESS_KEY_ID=ASIAEXAMPLE\n" + + "export AWS_SECRET_ACCESS_KEY=secret\n" + + "export AWS_SESSION_TOKEN=token", + ); +}); + +test("buildFederationSigninUrl wraps the federation endpoint with a signin token", () => { + const url = buildFederationSigninUrl("SIGNINTOKEN", "https://console.aws.amazon.com/"); + expect(url).toContain("https://signin.aws.amazon.com/federation"); + expect(url).toContain("Action=login"); + expect(url).toContain("SigninToken=SIGNINTOKEN"); + expect(url).toContain(encodeURIComponent("https://console.aws.amazon.com/")); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/aws/console.test.ts` +Expected: FAIL — module `./console` not found. + +- [ ] **Step 3: Implement `console.ts`** + +Create `src/aws/console.ts`: +```typescript +import type { AWSCredentials } from "./sso"; + +export function buildExportBlock(creds: { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; +}): string { + return [ + `export AWS_ACCESS_KEY_ID=${creds.accessKeyId}`, + `export AWS_SECRET_ACCESS_KEY=${creds.secretAccessKey}`, + `export AWS_SESSION_TOKEN=${creds.sessionToken}`, + ].join("\n"); +} + +export function buildFederationSigninUrl( + signinToken: string, + destination = "https://console.aws.amazon.com/", +): string { + const params = new URLSearchParams({ + Action: "login", + Issuer: "ssomatic", + Destination: destination, + SigninToken: signinToken, + }); + return `https://signin.aws.amazon.com/federation?${params.toString()}`; +} + +/** + * Exchange role credentials for a console signin token, then build the URL. + * Network call — kept separate from the pure builders above so they stay unit-testable. + */ +export async function getConsoleSigninUrl(creds: AWSCredentials): Promise { + const session = encodeURIComponent( + JSON.stringify({ + sessionId: creds.accessKeyId, + sessionKey: creds.secretAccessKey, + sessionToken: creds.sessionToken, + }), + ); + const res = await fetch( + `https://signin.aws.amazon.com/federation?Action=getSigninToken&Session=${session}`, + ); + if (!res.ok) throw new Error(`federation getSigninToken failed: ${res.status}`); + const { SigninToken } = (await res.json()) as { SigninToken: string }; + return buildFederationSigninUrl(SigninToken); +} +``` +Note: confirm the `AWSCredentials` field names in `sso.ts` (`accessKeyId`/`secretAccessKey`/`sessionToken` vs `AccessKeyId` etc.) and adapt `buildExportBlock`/`getConsoleSigninUrl` to match. + +- [ ] **Step 4: Run tests + lint** + +Run: `bun test src/aws/console.test.ts && bun run lint` +Expected: PASS, clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/aws/console.ts src/aws/console.test.ts +git commit -m "feat(aws): add console signin URL and export-block builders" +``` + +--- + +## Phase 2 — Daemon protocol + +### Task 3: Define the wire protocol and (de)serialization + +**Files:** +- Create: `src/daemon/protocol.ts` +- Create: `src/daemon/protocol.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/daemon/protocol.test.ts`: +```typescript +import { test, expect } from "bun:test"; +import { encode, decodeStream, type ClientMessage, type DaemonMessage } from "./protocol"; + +test("encode appends a newline and is JSON-parseable", () => { + const msg: ClientMessage = { type: "subscribe" }; + const line = encode(msg); + expect(line.endsWith("\n")).toBe(true); + expect(JSON.parse(line)).toEqual({ type: "subscribe" }); +}); + +test("decodeStream yields complete messages and buffers partials", () => { + const dec = decodeStream(); + const a = encode({ type: "snapshot" } as ClientMessage); + const b = encode({ type: "refresh", profile: "prod" } as ClientMessage); + // feed one-and-a-half messages, then the rest + const first = dec.push(a + b.slice(0, 5)); + expect(first).toEqual([{ type: "snapshot" }]); + const second = dec.push(b.slice(5)); + expect(second).toEqual([{ type: "refresh", profile: "prod" }]); +}); + +test("daemon state message shape is preserved through encode/decode", () => { + const dec = decodeStream(); + const state: DaemonMessage = { + type: "state", + daemon: { pid: 123, startedAt: "2026-06-11T10:00:00.000Z" }, + profiles: [{ name: "prod", status: "valid", expiresAt: "2026-06-11T12:00:00.000Z", favorite: true }], + }; + expect(dec.push(encode(state))).toEqual([state]); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/daemon/protocol.test.ts` +Expected: FAIL — module `./protocol` not found. + +- [ ] **Step 3: Implement `protocol.ts`** + +Create `src/daemon/protocol.ts`: +```typescript +export type ProfileStatusKind = "valid" | "expired" | "needs-login" | "error" | "refreshing"; + +export interface ProfileState { + name: string; + status: ProfileStatusKind; + expiresAt: string | null; // ISO string or null + favorite: boolean; + accountId?: string; + error?: string; +} + +export interface DaemonInfo { + pid: number; + startedAt: string; // ISO string +} + +export type ClientMessage = + | { type: "subscribe" } + | { type: "snapshot" } + | { type: "refresh"; profile?: string } + | { type: "setFavorite"; profile: string; value: boolean } + | { type: "stop" }; + +export type DaemonMessage = + | { type: "state"; daemon: DaemonInfo; profiles: ProfileState[] } + | { type: "error"; message: string }; + +export function encode(msg: ClientMessage | DaemonMessage): string { + return JSON.stringify(msg) + "\n"; +} + +/** Stateful newline-delimited JSON decoder. Call push() with each chunk. */ +export function decodeStream() { + let buffer = ""; + return { + push(chunk: string): T[] { + buffer += chunk; + const out: T[] = []; + let idx: number; + while ((idx = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + if (line.trim().length > 0) out.push(JSON.parse(line) as T); + } + return out; + }, + }; +} +``` + +- [ ] **Step 4: Run tests + lint** + +Run: `bun test src/daemon/protocol.test.ts && bun run lint` +Expected: PASS, clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/daemon/protocol.ts src/daemon/protocol.test.ts +git commit -m "feat(cli): add daemon wire protocol and ndjson codec" +``` + +--- + +## Phase 3 — Daemon lifecycle (single-instance, pid/sock files) + +### Task 4: Runtime paths + single-instance guard + +**Files:** +- Create: `src/daemon/lifecycle.ts` +- Create: `src/daemon/lifecycle.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/daemon/lifecycle.test.ts`: +```typescript +import { test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createServer, type Server } from "node:net"; +import { socketPath, pidPath, isDaemonAlive, writePidFile, readPidFile } from "./lifecycle"; + +let runtime: string; +let prev: string | undefined; + +beforeEach(() => { + prev = process.env.XDG_RUNTIME_DIR; + runtime = mkdtempSync(join(tmpdir(), "ssomatic-rt-")); + process.env.XDG_RUNTIME_DIR = runtime; +}); + +afterEach(() => { + process.env.XDG_RUNTIME_DIR = prev; + rmSync(runtime, { recursive: true, force: true }); +}); + +test("socketPath/pidPath live under the runtime dir", () => { + expect(socketPath().startsWith(runtime)).toBe(true); + expect(pidPath().startsWith(runtime)).toBe(true); +}); + +test("isDaemonAlive is false when no socket is listening", async () => { + expect(await isDaemonAlive()).toBe(false); +}); + +test("isDaemonAlive is true when a server is listening on the socket", async () => { + const srv: Server = await new Promise((resolve) => { + const s = createServer(); + s.listen(socketPath(), () => resolve(s)); + }); + try { + expect(await isDaemonAlive()).toBe(true); + } finally { + srv.close(); + } +}); + +test("pid file round-trips", () => { + writePidFile(4242); + expect(existsSync(pidPath())).toBe(true); + expect(readPidFile()).toBe(4242); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/daemon/lifecycle.test.ts` +Expected: FAIL — module `./lifecycle` not found. + +- [ ] **Step 3: Implement `lifecycle.ts`** + +Create `src/daemon/lifecycle.ts`: +```typescript +import { connect, type Socket } from "node:net"; +import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +function runtimeDir(): string { + const dir = process.env.XDG_RUNTIME_DIR || tmpdir(); + mkdirSync(dir, { recursive: true }); + return dir; +} + +export function socketPath(): string { + return join(runtimeDir(), "ssomatic.sock"); +} + +export function pidPath(): string { + return join(runtimeDir(), "ssomatic.pid"); +} + +/** True if something is actually listening on the socket. */ +export function isDaemonAlive(timeoutMs = 500): Promise { + const path = socketPath(); + if (!existsSync(path)) return Promise.resolve(false); + return new Promise((resolve) => { + const sock: Socket = connect(path); + const done = (alive: boolean) => { + sock.destroy(); + resolve(alive); + }; + sock.once("connect", () => done(true)); + sock.once("error", () => done(false)); + sock.setTimeout(timeoutMs, () => done(false)); + }); +} + +/** Remove a socket file with no live listener so we can rebind. Returns true if reclaimed. */ +export async function reclaimStaleSocket(): Promise { + const path = socketPath(); + if (existsSync(path) && !(await isDaemonAlive())) { + rmSync(path, { force: true }); + return true; + } + return false; +} + +export function writePidFile(pid: number): void { + writeFileSync(pidPath(), String(pid)); +} + +export function readPidFile(): number | null { + const path = pidPath(); + if (!existsSync(path)) return null; + const n = Number(readFileSync(path, "utf8").trim()); + return Number.isFinite(n) ? n : null; +} + +export function clearPidFile(): void { + rmSync(pidPath(), { force: true }); +} +``` + +- [ ] **Step 4: Run tests + lint** + +Run: `bun test src/daemon/lifecycle.test.ts && bun run lint` +Expected: PASS, clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/daemon/lifecycle.ts src/daemon/lifecycle.test.ts +git commit -m "feat(cli): add daemon runtime paths and single-instance detection" +``` + +--- + +## Phase 4 — Scheduler (expiry-aware decision) + +### Task 5: Pure "should refresh now?" decision + +Keep the scheduling *decision* pure and unit-testable; the loop that calls AWS lives in the server (Task 6). + +**Files:** +- Create: `src/daemon/scheduler.ts` +- Create: `src/daemon/scheduler.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/daemon/scheduler.test.ts`: +```typescript +import { test, expect } from "bun:test"; +import { decideAction } from "./scheduler"; + +const now = new Date("2026-06-11T12:00:00.000Z"); +const leadMs = 5 * 60 * 1000; + +test("refresh when within lead window of expiry", () => { + const expiresAt = new Date("2026-06-11T12:03:00.000Z"); // 3m left < 5m lead + expect(decideAction({ ssoTokenValid: true, credsExpireAt: expiresAt }, now, leadMs)).toBe("refresh"); +}); + +test("wait when comfortably before lead window", () => { + const expiresAt = new Date("2026-06-11T12:30:00.000Z"); // 30m left + expect(decideAction({ ssoTokenValid: true, credsExpireAt: expiresAt }, now, leadMs)).toBe("wait"); +}); + +test("needs-login when sso token invalid regardless of creds", () => { + const expiresAt = new Date("2026-06-11T12:30:00.000Z"); + expect(decideAction({ ssoTokenValid: false, credsExpireAt: expiresAt }, now, leadMs)).toBe("needs-login"); +}); + +test("refresh when there are no creds yet but sso token is valid", () => { + expect(decideAction({ ssoTokenValid: true, credsExpireAt: null }, now, leadMs)).toBe("refresh"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/daemon/scheduler.test.ts` +Expected: FAIL — module `./scheduler` not found. + +- [ ] **Step 3: Implement `scheduler.ts`** + +Create `src/daemon/scheduler.ts`: +```typescript +export type Action = "refresh" | "wait" | "needs-login"; + +export interface ProfileTiming { + ssoTokenValid: boolean; // is the cached SSO token still valid? + credsExpireAt: Date | null; // when current role creds expire (null = none/unknown) +} + +export function decideAction(timing: ProfileTiming, now: Date, leadMs: number): Action { + if (!timing.ssoTokenValid) return "needs-login"; + if (timing.credsExpireAt === null) return "refresh"; + const msLeft = timing.credsExpireAt.getTime() - now.getTime(); + return msLeft <= leadMs ? "refresh" : "wait"; +} + +/** Milliseconds until the next decision point for a profile (for scheduling the next tick). */ +export function msUntilNextCheck(timing: ProfileTiming, now: Date, leadMs: number): number { + if (!timing.ssoTokenValid || timing.credsExpireAt === null) return 0; + return Math.max(0, timing.credsExpireAt.getTime() - now.getTime() - leadMs); +} +``` + +- [ ] **Step 4: Run tests + lint** + +Run: `bun test src/daemon/scheduler.test.ts && bun run lint` +Expected: PASS, clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/daemon/scheduler.ts src/daemon/scheduler.test.ts +git commit -m "feat(cli): add expiry-aware scheduler decision" +``` + +--- + +## Phase 5 — Socket server + +### Task 6: Daemon server — accept clients, broadcast state, run the loop + +**Files:** +- Create: `src/daemon/server.ts` +- Create: `src/daemon/server.test.ts` + +The server owns: a `net` server on the Unix socket, the set of connected subscribers, an in-memory `ProfileState[]`, a periodic tick that builds state (using core functions + `decideAction`) and refreshes due favorites, and command handling. + +To keep it testable, inject the "compute state" and "refresh one profile" functions so the test can supply fakes (no real AWS calls). + +- [ ] **Step 1: Write the failing integration test** + +Create `src/daemon/server.test.ts`: +```typescript +import { test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { connect, type Socket } from "node:net"; +import { encode, decodeStream, type DaemonMessage, type ProfileState } from "./protocol"; +import { socketPath } from "./lifecycle"; +import { startServer, type DaemonServer } from "./server"; + +let runtime: string; +let prev: string | undefined; +let server: DaemonServer; + +const fakeProfiles: ProfileState[] = [ + { name: "prod", status: "valid", expiresAt: "2026-06-11T12:00:00.000Z", favorite: true }, +]; + +beforeEach(() => { + prev = process.env.XDG_RUNTIME_DIR; + runtime = mkdtempSync(join(tmpdir(), "ssomatic-srv-")); + process.env.XDG_RUNTIME_DIR = runtime; +}); + +afterEach(async () => { + await server?.stop(); + process.env.XDG_RUNTIME_DIR = prev; + rmSync(runtime, { recursive: true, force: true }); +}); + +function readOne(sock: Socket): Promise { + const dec = decodeStream(); + return new Promise((resolve) => { + sock.on("data", (buf) => { + const msgs = dec.push(buf.toString()); + if (msgs.length) resolve(msgs[0]); + }); + }); +} + +test("snapshot returns current state then the client can disconnect", async () => { + server = await startServer({ + computeState: async () => fakeProfiles, + refreshProfile: async () => {}, + tickMs: 10_000, // long; we drive manually + }); + const sock = connect(socketPath()); + await new Promise((r) => sock.once("connect", r)); + const reply = readOne(sock); + sock.write(encode({ type: "snapshot" })); + const msg = await reply; + expect(msg.type).toBe("state"); + if (msg.type === "state") expect(msg.profiles).toEqual(fakeProfiles); + sock.destroy(); +}); + +test("subscribe pushes state on broadcast", async () => { + let current = fakeProfiles; + server = await startServer({ + computeState: async () => current, + refreshProfile: async () => {}, + tickMs: 10_000, + }); + const sock = connect(socketPath()); + await new Promise((r) => sock.once("connect", r)); + sock.write(encode({ type: "subscribe" })); + // first push on subscribe + await readOne(sock); + // change state and force a broadcast + current = [{ ...fakeProfiles[0], status: "refreshing" }]; + const next = readOne(sock); + await server.broadcast(); + const msg = await next; + expect(msg.type === "state" && msg.profiles[0].status).toBe("refreshing"); + sock.destroy(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/daemon/server.test.ts` +Expected: FAIL — module `./server` not found. + +- [ ] **Step 3: Implement `server.ts`** + +Create `src/daemon/server.ts`: +```typescript +import { createServer, type Server, type Socket } from "node:net"; +import { + encode, + decodeStream, + type ClientMessage, + type DaemonMessage, + type ProfileState, + type DaemonInfo, +} from "./protocol"; +import { socketPath, reclaimStaleSocket, writePidFile, clearPidFile } from "./lifecycle"; + +export interface ServerDeps { + computeState: () => Promise; + refreshProfile: (name: string) => Promise; + setFavorite?: (name: string, value: boolean) => Promise | void; + tickMs?: number; + startedAtIso: string; // pass in (Date.now() is unavailable in some sandboxes) +} + +export interface DaemonServer { + broadcast: () => Promise; + stop: () => Promise; +} + +export async function startServer(deps: ServerDeps): Promise { + await reclaimStaleSocket(); + const subscribers = new Set(); + let state: ProfileState[] = []; + const info: DaemonInfo = { pid: process.pid, startedAt: deps.startedAtIso }; + + async function refreshState(): Promise { + state = await deps.computeState(); + } + + async function broadcast(): Promise { + await refreshState(); + const msg: DaemonMessage = { type: "state", daemon: info, profiles: state }; + const line = encode(msg); + for (const sock of subscribers) sock.write(line); + } + + async function handle(sock: Socket, msg: ClientMessage): Promise { + switch (msg.type) { + case "subscribe": { + subscribers.add(sock); + await refreshState(); + sock.write(encode({ type: "state", daemon: info, profiles: state })); + break; + } + case "snapshot": { + await refreshState(); + sock.write(encode({ type: "state", daemon: info, profiles: state })); + break; + } + case "refresh": { + const targets = msg.profile ? [msg.profile] : state.filter((p) => p.favorite).map((p) => p.name); + for (const name of targets) await deps.refreshProfile(name); + await broadcast(); + break; + } + case "setFavorite": { + await deps.setFavorite?.(msg.profile, msg.value); + await broadcast(); + break; + } + case "stop": { + await stop(); + break; + } + } + } + + const server: Server = createServer((sock) => { + const dec = decodeStream(); + sock.on("data", (buf) => { + for (const msg of dec.push(buf.toString())) void handle(sock, msg); + }); + sock.on("close", () => subscribers.delete(sock)); + sock.on("error", () => subscribers.delete(sock)); + }); + + await new Promise((resolve) => server.listen(socketPath(), resolve)); + writePidFile(process.pid); + + const interval = setInterval(() => void tick(), deps.tickMs ?? 60_000); + async function tick(): Promise { + // refreshProfile decides internally whether a refresh is due (see daemon entry wiring) + await broadcast(); + } + + async function stop(): Promise { + clearInterval(interval); + for (const sock of subscribers) sock.destroy(); + subscribers.clear(); + await new Promise((resolve) => server.close(() => resolve())); + clearPidFile(); + } + + await refreshState(); + return { broadcast, stop }; +} +``` +Note: the test passes `startedAtIso` implicitly? It does not — update the test's `startServer` calls to include `startedAtIso: "2026-06-11T10:00:00.000Z"`. Add that field to both `startServer({...})` calls in Step 1's test before running. (Do this now.) + +- [ ] **Step 4: Fix the test to pass `startedAtIso`, then run** + +Edit `src/daemon/server.test.ts`: add `startedAtIso: "2026-06-11T10:00:00.000Z"` to both `startServer({ ... })` option objects. + +Run: `bun test src/daemon/server.test.ts && bun run lint` +Expected: PASS, clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/daemon/server.ts src/daemon/server.test.ts +git commit -m "feat(cli): add unix-socket daemon server with subscribe/snapshot/refresh" +``` + +--- + +## Phase 6 — Daemon entry + spawn/detach + +### Task 7: Wire real AWS into the server and add the detached entry point + +**Files:** +- Create: `src/daemon/index.ts` (the in-process daemon `main()` + a `spawnDetached()` helper) +- Create: `src/daemon/client.ts` (thin socket client used by CLI subcommands + the TUI) + +No new unit test file (covered by Task 6 + manual smoke test); this is integration glue. + +- [ ] **Step 1: Implement the client helper** + +Create `src/daemon/client.ts`: +```typescript +import { connect, type Socket } from "node:net"; +import { encode, decodeStream, type ClientMessage, type DaemonMessage } from "./protocol"; +import { socketPath, isDaemonAlive } from "./lifecycle"; + +export { isDaemonAlive }; + +/** Connect, send one message, resolve with the first state reply, then close. */ +export async function request(msg: ClientMessage, timeoutMs = 3000): Promise { + return new Promise((resolve, reject) => { + const sock: Socket = connect(socketPath()); + const dec = decodeStream(); + const timer = setTimeout(() => { + sock.destroy(); + reject(new Error("daemon request timed out")); + }, timeoutMs); + sock.once("connect", () => sock.write(encode(msg))); + sock.on("data", (buf) => { + const msgs = dec.push(buf.toString()); + if (msgs.length) { + clearTimeout(timer); + sock.destroy(); + resolve(msgs[0]); + } + }); + sock.once("error", (err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +/** Open a subscription; call onState for every pushed state until stop() is called. */ +export function subscribe(onState: (msg: DaemonMessage) => void): { stop: () => void } { + const sock: Socket = connect(socketPath()); + const dec = decodeStream(); + sock.once("connect", () => sock.write(encode({ type: "subscribe" }))); + sock.on("data", (buf) => { + for (const msg of dec.push(buf.toString())) onState(msg); + }); + sock.on("error", () => {}); + return { stop: () => sock.destroy() }; +} +``` + +- [ ] **Step 2: Implement the daemon main + spawn** + +Create `src/daemon/index.ts`: +```typescript +import { spawn } from "node:child_process"; +import { openSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { mkdirSync } from "node:fs"; +import { startServer } from "./server"; +import { isDaemonAlive } from "./lifecycle"; +import { decideAction } from "./scheduler"; +import { discoverProfiles, checkTokenStatus, refreshProfile as coreRefresh, findCachedToken } from "../aws/sso"; +import { loadSettings, saveSettings } from "../aws/settings"; +import type { ProfileState } from "./protocol"; + +function logPath(): string { + const dir = join(homedir(), ".aws", "ssomatic"); + mkdirSync(dir, { recursive: true }); + return join(dir, "daemon.log"); +} + +/** Build current ProfileState[] from disk + decide which favorites are due, refreshing them. */ +async function computeState(): Promise { + const settings = loadSettings(); + const leadMs = settings.refreshLeadMinutes * 60 * 1000; + const favorites = new Set(settings.favoriteProfiles); + const profiles = discoverProfiles(); + const now = new Date(); + const states: ProfileState[] = []; + + for (const p of profiles) { + const st = checkTokenStatus(p); // adapt to actual return shape + const cached = findCachedToken(p); + const ssoValid = cached !== null && new Date(cached.expiresAt).getTime() > now.getTime(); + const credsExpireAt = st.expiresAt ? new Date(st.expiresAt) : null; + const favorite = favorites.has(p.name); + + if (favorite) { + const action = decideAction({ ssoTokenValid: ssoValid, credsExpireAt }, now, leadMs); + if (action === "refresh") { + const r = await coreRefresh(p); // silent re-derive of role creds + if (!r.success && r.needsLogin) { + maybeNotify(settings.notifications, p.name); + } + } else if (action === "needs-login") { + maybeNotify(settings.notifications, p.name); + } + } + + states.push({ + name: p.name, + status: ssoValid ? (credsExpireAt && credsExpireAt > now ? "valid" : "needs-login") : "needs-login", + expiresAt: credsExpireAt ? credsExpireAt.toISOString() : null, + favorite, + accountId: p.accountId, + }); + } + return states; +} + +const notified = new Set(); +function maybeNotify(enabled: boolean, profile: string): void { + if (!enabled || notified.has(profile)) return; + notified.add(profile); + // sendNotification is in src/aws/sso.ts — import + call. Reset on next successful refresh. +} + +export async function runDaemon(): Promise { + const startedAtIso = new Date().toISOString(); + const server = await startServer({ + startedAtIso, + tickMs: 30_000, + computeState, + refreshProfile: async (name) => { + const p = discoverProfiles().find((x) => x.name === name); + if (p) await coreRefresh(p); + }, + setFavorite: (name, value) => { + const s = loadSettings(); + const set = new Set(s.favoriteProfiles); + if (value) set.add(name); + else set.delete(name); + saveSettings({ ...s, favoriteProfiles: [...set] }); + }, + }); + process.on("SIGTERM", () => void server.stop().then(() => process.exit(0))); + process.on("SIGINT", () => void server.stop().then(() => process.exit(0))); +} + +/** Spawn a detached daemon process running ` __daemon`, return immediately. */ +export async function spawnDetached(): Promise { + if (await isDaemonAlive()) return; + const out = openSync(logPath(), "a"); + const child = spawn(process.execPath, [process.argv[1], "__daemon"], { + detached: true, + stdio: ["ignore", out, out], + }); + child.unref(); + // give it a moment to bind the socket + await new Promise((r) => setTimeout(r, 300)); +} +``` +Note: align `checkTokenStatus`/`findCachedToken`/`refreshProfile`/`discoverProfiles` return shapes and `SSOProfile.name`/`.accountId` field names to the real code (Task conventions). Import and wire `sendNotification` in `maybeNotify`. The `__daemon` arg is the internal command routed in Task 11. + +- [ ] **Step 3: Lint** + +Run: `bun run lint` +Expected: clean (no test yet; integration verified after CLI routing in Task 11). + +- [ ] **Step 4: Commit** + +```bash +git add src/daemon/client.ts src/daemon/index.ts +git commit -m "feat(cli): add daemon entry, detached spawn, and socket client" +``` + +--- + +## Phase 7 — Non-interactive CLI subcommands + +### Task 8: `status`, `export`, `refresh`, `daemon …` commands + +**Files:** +- Create: `src/cli/commands/status.ts` +- Create: `src/cli/commands/export.ts` +- Create: `src/cli/commands/refresh.ts` +- Create: `src/cli/commands/daemon.ts` +- Create: `src/cli/commands/status.test.ts` + +- [ ] **Step 1: Write a failing test for the status formatter** + +Create `src/cli/commands/status.test.ts`: +```typescript +import { test, expect } from "bun:test"; +import { formatStatusTable } from "./status"; +import type { ProfileState } from "../../daemon/protocol"; + +test("formatStatusTable renders aligned rows", () => { + const rows: ProfileState[] = [ + { name: "prod", status: "valid", expiresAt: "2026-06-11T13:00:00.000Z", favorite: true }, + { name: "staging", status: "needs-login", expiresAt: null, favorite: false }, + ]; + const out = formatStatusTable(rows, new Date("2026-06-11T12:00:00.000Z")); + const lines = out.split("\n"); + expect(lines[0]).toContain("prod"); + expect(lines[0]).toContain("valid"); + expect(lines[0]).toContain("60m"); + expect(lines[1]).toContain("staging"); + expect(lines[1]).toContain("needs-login"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/cli/commands/status.test.ts` +Expected: FAIL — module `./status` not found. + +- [ ] **Step 3: Implement `status.ts`** + +Create `src/cli/commands/status.ts`: +```typescript +import { request, isDaemonAlive } from "../../daemon/client"; +import type { ProfileState } from "../../daemon/protocol"; +import { discoverProfiles, checkTokenStatus, findCachedToken } from "../../aws/sso"; +import { loadSettings } from "../../aws/settings"; + +function minsLeft(expiresAt: string | null, now: Date): string { + if (!expiresAt) return "—"; + const m = Math.round((new Date(expiresAt).getTime() - now.getTime()) / 60000); + return m <= 0 ? "expired" : `${m}m`; +} + +export function formatStatusTable(rows: ProfileState[], now: Date): string { + const nameW = Math.max(7, ...rows.map((r) => r.name.length)); + const statusW = Math.max(6, ...rows.map((r) => r.status.length)); + return rows + .map((r) => { + const star = r.favorite ? "★ " : " "; + return `${star}${r.name.padEnd(nameW)} ${r.status.padEnd(statusW)} ${minsLeft(r.expiresAt, now)}`; + }) + .join("\n"); +} + +/** Build state directly from disk when no daemon is running. */ +function localState(): ProfileState[] { + const favorites = new Set(loadSettings().favoriteProfiles); + const now = new Date(); + return discoverProfiles().map((p) => { + const st = checkTokenStatus(p); + const cached = findCachedToken(p); + const ssoValid = cached !== null && new Date(cached.expiresAt) > now; + const expiresAt = st.expiresAt ? new Date(st.expiresAt).toISOString() : null; + return { + name: p.name, + status: ssoValid && expiresAt && new Date(expiresAt) > now ? "valid" : "needs-login", + expiresAt, + favorite: favorites.has(p.name), + accountId: p.accountId, + }; + }); +} + +export async function runStatus(): Promise { + const now = new Date(); + let rows: ProfileState[]; + if (await isDaemonAlive()) { + const msg = await request({ type: "snapshot" }); + rows = msg.type === "state" ? msg.profiles : []; + } else { + rows = localState(); + } + process.stdout.write(formatStatusTable(rows, now) + "\n"); + return 0; +} +``` + +- [ ] **Step 4: Run the status test** + +Run: `bun test src/cli/commands/status.test.ts && bun run lint` +Expected: PASS, clean. + +- [ ] **Step 5: Implement `export.ts`** + +Create `src/cli/commands/export.ts`: +```typescript +import { discoverProfiles, refreshProfile, readProfileCredentials } from "../../aws/sso"; +import { buildExportBlock } from "../../aws/console"; + +export async function runExport(profileName: string): Promise { + const profile = discoverProfiles().find((p) => p.name === profileName); + if (!profile) { + process.stderr.write(`unknown profile: ${profileName}\n`); + return 1; + } + const result = await refreshProfile(profile); + if (!result.success) { + process.stderr.write(`cannot export ${profileName}: ${result.needsLogin ? "needs login" : result.error}\n`); + return 1; + } + const creds = readProfileCredentials(profileName); // reads ~/.aws/credentials section + if (!creds) { + process.stderr.write(`no credentials found for ${profileName}\n`); + return 1; + } + process.stdout.write(buildExportBlock(creds) + "\n"); + return 0; +} +``` +Note: `readProfileCredentials` may not exist yet. If not, add a small reader in `src/aws/sso.ts` that parses the `[profileName]` section of `~/.aws/credentials` and returns `{accessKeyId, secretAccessKey, sessionToken}` (using the existing `ini` parsing already present in `sso.ts`). Confirm field names. + +- [ ] **Step 6: Implement `refresh.ts`** + +Create `src/cli/commands/refresh.ts`: +```typescript +import { discoverProfiles, refreshProfile, startDeviceAuthorization, performSSOLoginFlow } from "../../aws/sso"; +import { loadSettings } from "../../aws/settings"; + +async function refreshOne(name: string): Promise { + const profile = discoverProfiles().find((p) => p.name === name); + if (!profile) { + process.stderr.write(`unknown profile: ${name}\n`); + return false; + } + const result = await refreshProfile(profile); + if (result.success) { + process.stdout.write(`✓ ${name} refreshed\n`); + return true; + } + if (result.needsLogin) { + process.stdout.write(`${name} needs login — starting device authorization…\n`); + const deviceAuth = await startDeviceAuthorization(profile); + // prints/open browser handled inside performSSOLoginFlow; confirm it logs the verification URL+code + await performSSOLoginFlow(profile, deviceAuth); + process.stdout.write(`✓ ${name} logged in and refreshed\n`); + return true; + } + process.stderr.write(`✗ ${name}: ${result.error}\n`); + return false; +} + +export async function runRefresh(profileArg?: string): Promise { + const targets = profileArg + ? [profileArg] + : loadSettings().favoriteProfiles; + if (targets.length === 0) { + process.stderr.write("no profile specified and no favorites configured\n"); + return 1; + } + let ok = true; + for (const name of targets) ok = (await refreshOne(name)) && ok; + return ok ? 0 : 1; +} +``` + +- [ ] **Step 7: Implement `daemon.ts`** + +Create `src/cli/commands/daemon.ts`: +```typescript +import { spawnDetached } from "../../daemon/index"; +import { request, isDaemonAlive } from "../../daemon/client"; +import { readPidFile } from "../../daemon/lifecycle"; + +export async function runDaemonCommand(sub: string | undefined): Promise { + switch (sub) { + case "start": { + if (await isDaemonAlive()) { + process.stdout.write("daemon already running\n"); + return 0; + } + await spawnDetached(); + process.stdout.write( + (await isDaemonAlive()) ? "daemon started\n" : "failed to start daemon (see ~/.aws/ssomatic/daemon.log)\n", + ); + return 0; + } + case "stop": { + if (!(await isDaemonAlive())) { + process.stdout.write("daemon not running\n"); + return 0; + } + await request({ type: "stop" }).catch(() => {}); + process.stdout.write("daemon stopped\n"); + return 0; + } + case "status": + case undefined: { + if (!(await isDaemonAlive())) { + process.stdout.write("daemon: stopped\n"); + return 0; + } + const msg = await request({ type: "snapshot" }); + const pid = readPidFile(); + const watched = msg.type === "state" ? msg.profiles.filter((p) => p.favorite).map((p) => p.name) : []; + process.stdout.write(`daemon: running (pid ${pid ?? "?"})\nwatching: ${watched.join(", ") || "(none)"}\n`); + return 0; + } + default: + process.stderr.write(`unknown daemon subcommand: ${sub}\n`); + return 1; + } +} +``` + +- [ ] **Step 8: Lint everything** + +Run: `bun run lint && bun test` +Expected: clean; all existing + new tests pass. + +- [ ] **Step 9: Commit** + +```bash +git add src/cli/commands src/aws/sso.ts +git commit -m "feat(cli): add status, export, refresh, and daemon subcommands" +``` + +--- + +## Phase 8 — Argument routing + +### Task 9: Route argv to subcommands, daemon, or the TUI + +**Files:** +- Modify: `src/cli/index.tsx` (replace the top-level `--version` check with a full router; the TUI render moves behind a `launchTui()` function) +- Create: `src/cli/args.ts` +- Create: `src/cli/args.test.ts` + +- [ ] **Step 1: Write the failing test for arg parsing** + +Create `src/cli/args.test.ts`: +```typescript +import { test, expect } from "bun:test"; +import { parseArgs } from "./args"; + +test("no args → tui", () => { + expect(parseArgs([])).toEqual({ kind: "tui", daemon: false }); +}); +test("--daemon flag → tui with daemon", () => { + expect(parseArgs(["--daemon"])).toEqual({ kind: "tui", daemon: true }); +}); +test("--version → version", () => { + expect(parseArgs(["--version"])).toEqual({ kind: "version" }); + expect(parseArgs(["-v"])).toEqual({ kind: "version" }); +}); +test("status subcommand", () => { + expect(parseArgs(["status"])).toEqual({ kind: "status" }); +}); +test("export requires a profile", () => { + expect(parseArgs(["export", "prod"])).toEqual({ kind: "export", profile: "prod" }); +}); +test("refresh optional profile", () => { + expect(parseArgs(["refresh"])).toEqual({ kind: "refresh", profile: undefined }); + expect(parseArgs(["refresh", "dev"])).toEqual({ kind: "refresh", profile: "dev" }); +}); +test("daemon subcommands", () => { + expect(parseArgs(["daemon", "start"])).toEqual({ kind: "daemon", sub: "start" }); + expect(parseArgs(["daemon"])).toEqual({ kind: "daemon", sub: undefined }); +}); +test("internal __daemon command", () => { + expect(parseArgs(["__daemon"])).toEqual({ kind: "__daemon" }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/cli/args.test.ts` +Expected: FAIL — module `./args` not found. + +- [ ] **Step 3: Implement `args.ts`** + +Create `src/cli/args.ts`: +```typescript +export type ParsedArgs = + | { kind: "tui"; daemon: boolean } + | { kind: "version" } + | { kind: "status" } + | { kind: "export"; profile: string } + | { kind: "refresh"; profile?: string } + | { kind: "daemon"; sub?: string } + | { kind: "__daemon" } + | { kind: "help" } + | { kind: "error"; message: string }; + +export function parseArgs(argv: string[]): ParsedArgs { + const [cmd, ...rest] = argv; + if (cmd === undefined) return { kind: "tui", daemon: false }; + if (cmd === "--version" || cmd === "-v") return { kind: "version" }; + if (cmd === "--help" || cmd === "-h" || cmd === "help") return { kind: "help" }; + if (cmd === "--daemon") return { kind: "tui", daemon: true }; + if (cmd === "__daemon") return { kind: "__daemon" }; + if (cmd === "status") return { kind: "status" }; + if (cmd === "refresh") return { kind: "refresh", profile: rest[0] }; + if (cmd === "export") { + if (!rest[0]) return { kind: "error", message: "export requires a profile name" }; + return { kind: "export", profile: rest[0] }; + } + if (cmd === "daemon") return { kind: "daemon", sub: rest[0] }; + return { kind: "error", message: `unknown command: ${cmd}` }; +} +``` + +- [ ] **Step 4: Run the args test** + +Run: `bun test src/cli/args.test.ts` +Expected: PASS. + +- [ ] **Step 5: Rewire `index.tsx` entry** + +In `src/cli/index.tsx`, replace the existing `--version` handling at the bottom with a router. Keep the existing TUI component, but render it from `launchTui(daemon)`. Add: +```typescript +import { render } from "ink"; +import { parseArgs } from "./args"; +import { runStatus } from "./commands/status"; +import { runExport } from "./commands/export"; +import { runRefresh } from "./commands/refresh"; +import { runDaemonCommand } from "./commands/daemon"; +import { runDaemon } from "../daemon/index"; +import { VERSION } from "../version"; // confirm export name + +const HELP = `ssomatic — interactive AWS SSO credential manager + +Usage: + ssomatic launch the interactive TUI + ssomatic --daemon launch the TUI and start the background daemon + ssomatic status print profile statuses and exit + ssomatic refresh [name] refresh a profile (or all favorites) now + ssomatic export print export AWS_* lines for eval $(...) + ssomatic daemon start|stop|status + ssomatic --version +`; + +async function main(): Promise { + const parsed = parseArgs(process.argv.slice(2)); + switch (parsed.kind) { + case "version": + process.stdout.write(VERSION + "\n"); + return; + case "help": + process.stdout.write(HELP); + return; + case "status": + process.exit(await runStatus()); + return; + case "export": + process.exit(await runExport(parsed.profile)); + return; + case "refresh": + process.exit(await runRefresh(parsed.profile)); + return; + case "daemon": + process.exit(await runDaemonCommand(parsed.sub)); + return; + case "__daemon": + await runDaemon(); // long-lived; do not exit + return; + case "error": + process.stderr.write(parsed.message + "\n"); + process.exit(1); + return; + case "tui": + launchTui(parsed.daemon); + return; + } +} + +function launchTui(startDaemon: boolean): void { + render(); // SSOmatic = existing root component, extended in Task 12 +} + +void main(); +``` +Adapt `VERSION` import to the actual export in `src/version.ts`. Remove the old bottom-of-file `--version` block so there is a single entry path. + +- [ ] **Step 6: Verify subcommands work end-to-end** + +Run: +```bash +bun run build +node dist/cli.js --help +node dist/cli.js status +node dist/cli.js daemon status +node dist/cli.js daemon start && node dist/cli.js daemon status && node dist/cli.js daemon stop +``` +Expected: help prints; `status` prints a table (or empty if no profiles); `daemon start` reports started, `status` shows running with a pid, `stop` reports stopped. Check `~/.aws/ssomatic/daemon.log` if start fails. + +- [ ] **Step 7: Commit** + +```bash +git add src/cli/args.ts src/cli/args.test.ts src/cli/index.tsx +git commit -m "feat(cli): route argv to subcommands, daemon, or TUI" +``` + +--- + +## Phase 9 — TUI: live-state hook + +### Task 10: `useDaemon` — connect, attach, expose live profiles + +**Files:** +- Create: `src/cli/tui/useDaemon.ts` + +No unit test (React hook over a socket; verified manually in Task 11–12). Keep logic thin. + +- [ ] **Step 1: Implement `useDaemon.ts`** + +Create `src/cli/tui/useDaemon.ts`: +```typescript +import { useEffect, useState, useCallback } from "react"; +import { subscribe, request, isDaemonAlive } from "../../daemon/client"; +import { spawnDetached } from "../../daemon/index"; +import type { ProfileState, DaemonInfo } from "../../daemon/protocol"; + +export interface DaemonView { + running: boolean; + info: DaemonInfo | null; + profiles: ProfileState[]; + startBackground: () => Promise; + refresh: (profile?: string) => Promise; + setFavorite: (profile: string, value: boolean) => Promise; +} + +export function useDaemon(localProfiles: ProfileState[]): DaemonView { + const [running, setRunning] = useState(false); + const [info, setInfo] = useState(null); + const [profiles, setProfiles] = useState(localProfiles); + + useEffect(() => { + let sub: { stop: () => void } | null = null; + let cancelled = false; + (async () => { + const alive = await isDaemonAlive(); + if (cancelled) return; + setRunning(alive); + if (alive) { + sub = subscribe((msg) => { + if (msg.type === "state") { + setInfo(msg.daemon); + setProfiles(msg.profiles); + } + }); + } + })(); + return () => { + cancelled = true; + sub?.stop(); + }; + }, []); + + const startBackground = useCallback(async () => { + await spawnDetached(); + setRunning(await isDaemonAlive()); + // subscribe now that it's up + subscribe((msg) => { + if (msg.type === "state") { + setInfo(msg.daemon); + setProfiles(msg.profiles); + } + }); + }, []); + + const refresh = useCallback(async (profile?: string) => { + if (await isDaemonAlive()) await request({ type: "refresh", profile }); + }, []); + + const setFavorite = useCallback(async (profile: string, value: boolean) => { + if (await isDaemonAlive()) await request({ type: "setFavorite", profile, value }); + }, []); + + return { running, info, profiles, startBackground, refresh, setFavorite }; +} +``` +Note: when the daemon is NOT running, `refresh`/`setFavorite` are no-ops over the socket — the Dashboard must also update local settings/state directly in that case (Task 12 wires both paths). Favorite toggles persist via `saveSettings` regardless of daemon presence. + +- [ ] **Step 2: Lint** + +Run: `bun run lint` +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add src/cli/tui/useDaemon.ts +git commit -m "feat(cli): add useDaemon hook for live socket state" +``` + +--- + +## Phase 10 — TUI: dashboard, details, settings + +### Task 11: List-first Dashboard component + +**Files:** +- Create: `src/cli/tui/Dashboard.tsx` +- Reuse: existing `src/cli/components/*` (`List`, `Card`, `Divider`, `Header`, `Spinner`, `StatusMessage`, `CopyFeedback`) + +This replaces the old menu → screens flow. The Dashboard is the single home view. + +- [ ] **Step 1: Implement `Dashboard.tsx`** + +Create `src/cli/tui/Dashboard.tsx`: +```tsx +import React, { useState } from "react"; +import { Box, Text, useInput } from "ink"; +import type { ProfileState } from "../../daemon/protocol"; +import { useCopy } from "../hooks"; +import { buildExportBlock } from "../../aws/console"; + +interface Props { + profiles: ProfileState[]; + daemonRunning: boolean; + onRefresh: (names: string[]) => void; + onToggleFavorite: (name: string) => void; + onRunBackground: () => void; + onOpenDetails: (name: string) => void; + onOpenConsole: (name: string) => void; + onCopyExport: (name: string) => void; + onOpenSettings: () => void; + onQuit: () => void; +} + +const STATUS_COLOR: Record = { + valid: "green", + refreshing: "cyan", + expired: "yellow", + "needs-login": "yellow", + error: "red", +}; + +function minsLeft(expiresAt: string | null): string { + if (!expiresAt) return "—"; + const m = Math.round((new Date(expiresAt).getTime() - Date.now()) / 60000); + return m <= 0 ? "expired" : `${m}m`; +} + +export function Dashboard(props: Props) { + const { profiles } = props; + const [cursor, setCursor] = useState(0); + const [selected, setSelected] = useState>(new Set()); + const [filter, setFilter] = useState(""); + const [filtering, setFiltering] = useState(false); + + const visible = filter + ? profiles.filter((p) => p.name.toLowerCase().includes(filter.toLowerCase())) + : profiles; + const current = visible[Math.min(cursor, visible.length - 1)]; + + useInput((input, key) => { + if (filtering) { + if (key.return || key.escape) setFiltering(false); + else if (key.backspace || key.delete) setFilter((f) => f.slice(0, -1)); + else if (input) setFilter((f) => f + input); + return; + } + if (key.upArrow || input === "k") setCursor((c) => Math.max(0, c - 1)); + else if (key.downArrow || input === "j") setCursor((c) => Math.min(visible.length - 1, c + 1)); + else if (input === " " && current) { + setSelected((s) => { + const n = new Set(s); + n.has(current.name) ? n.delete(current.name) : n.add(current.name); + return n; + }); + } else if (input === "a") { + setSelected((s) => (s.size === visible.length ? new Set() : new Set(visible.map((p) => p.name)))); + } else if (input === "r") { + const names = selected.size ? [...selected] : current ? [current.name] : []; + props.onRefresh(names); + } else if (input === "f" && current) props.onToggleFavorite(current.name); + else if (input === "b") props.onRunBackground(); + else if (input === "c" && current) props.onCopyExport(current.name); + else if (input === "o" && current) props.onOpenConsole(current.name); + else if (key.return && current) props.onOpenDetails(current.name); + else if (input === "/") setFiltering(true); + else if (input === "s") props.onOpenSettings(); + else if (input === "q") props.onQuit(); + }); + + return ( + + + 🔐 SSOmatic + {props.daemonRunning ? "daemon ● running" : "daemon ○ off"} + + {"─".repeat(48)} + {filtering && /{filter}} + {visible.map((p, i) => { + const isCursor = p.name === current?.name; + const isSel = selected.has(p.name); + return ( + + {isCursor ? "▸ " : " "} + {isSel ? "◉ " : " "} + {p.favorite ? "★ " : " "} + {p.name.padEnd(12)} + {p.status.padEnd(12)} + {minsLeft(p.expiresAt).padEnd(8)} + {p.accountId ?? ""} + + ); + })} + {"─".repeat(48)} + ↑↓ move space sel ⏎ details r refresh b bg + f ★ c copy o console / filter s settings q quit + + ); +} +``` +Note: `Date.now()` is fine in the real app runtime (it's only unavailable inside Workflow scripts/this planning sandbox). Confirm `useCopy`/`buildExportBlock` usage when wiring copy feedback; the actual clipboard write happens in the container (Task 12) via `onCopyExport`. + +- [ ] **Step 2: Lint** + +Run: `bun run lint` +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add src/cli/tui/Dashboard.tsx +git commit -m "feat(cli): add list-first dashboard component" +``` + +### Task 12: Details + Settings views and the root container + +**Files:** +- Create: `src/cli/tui/Details.tsx` +- Create: `src/cli/tui/Settings.tsx` +- Modify: `src/cli/index.tsx` — replace the old `SSOmatic` root component body with a container that owns view state (`dashboard | details | settings`), builds initial local profile state, uses `useDaemon`, and wires all Dashboard callbacks (including non-daemon fallbacks: local refresh via `refreshProfile`, favorite persistence via `saveSettings`, clipboard via `copyToClipboard`, console open via `getConsoleSigninUrl` + `openBrowser`). + +- [ ] **Step 1: Implement `Details.tsx`** + +Create `src/cli/tui/Details.tsx`: +```tsx +import React from "react"; +import { Box, Text, useInput } from "ink"; +import type { ProfileState } from "../../daemon/protocol"; + +interface Props { + profile: ProfileState; + arn?: string; + region?: string; + startUrl?: string; + onBack: () => void; +} + +export function Details({ profile, arn, region, startUrl, onBack }: Props) { + useInput((_input, key) => { + if (key.escape || key.return) onBack(); + }); + const row = (label: string, value: string) => ( + + {label.padEnd(10)} + {value} + + ); + return ( + + ⏎ {profile.name} + {row("account", profile.accountId ?? "—")} + {row("role", arn ?? "—")} + {row("region", region ?? "—")} + {row("status", profile.status)} + {row("expires", profile.expiresAt ?? "—")} + {row("sso url", startUrl ?? "—")} + esc back + + ); +} +``` + +- [ ] **Step 2: Implement `Settings.tsx`** + +Create `src/cli/tui/Settings.tsx`: +```tsx +import React, { useState } from "react"; +import { Box, Text, useInput } from "ink"; +import type { AppSettings } from "../../aws/settings"; + +interface Props { + settings: AppSettings; + onChange: (next: AppSettings) => void; + onBack: () => void; +} + +export function Settings({ settings, onChange, onBack }: Props) { + const [cursor, setCursor] = useState(0); + const items = ["notifications", "refreshLeadMinutes", "autoStartDaemon"] as const; + + useInput((input, key) => { + if (key.escape) return onBack(); + if (key.upArrow || input === "k") setCursor((c) => Math.max(0, c - 1)); + else if (key.downArrow || input === "j") setCursor((c) => Math.min(items.length - 1, c + 1)); + else if (key.return || input === " ") { + const field = items[cursor]; + if (field === "notifications") onChange({ ...settings, notifications: !settings.notifications }); + else if (field === "autoStartDaemon") onChange({ ...settings, autoStartDaemon: !settings.autoStartDaemon }); + } else if (field_is_lead(items[cursor])) { + if (key.leftArrow) onChange({ ...settings, refreshLeadMinutes: Math.max(1, settings.refreshLeadMinutes - 1) }); + else if (key.rightArrow) onChange({ ...settings, refreshLeadMinutes: settings.refreshLeadMinutes + 1 }); + } + }); + + function field_is_lead(f: (typeof items)[number]) { + return f === "refreshLeadMinutes"; + } + + const line = (i: number, label: string, value: string) => ( + + {cursor === i ? "▸ " : " "} + {label.padEnd(22)} + {value} + + ); + + return ( + + ⚙ Settings + {line(0, "Notifications", settings.notifications ? "on" : "off")} + {line(1, "Refresh lead (min)", String(settings.refreshLeadMinutes) + " (←/→)")} + {line(2, "Auto-start daemon", settings.autoStartDaemon ? "on" : "off")} + space/⏎ toggle ←→ adjust esc back + + ); +} +``` +Note: clean up the `field_is_lead` placement (hoist above `useInput` or inline the check) so it lints; the logic shown is the intended behavior. + +- [ ] **Step 3: Rewrite the `SSOmatic` root container in `index.tsx`** + +Replace the old menu/view component body with: +```tsx +function SSOmatic({ startDaemon }: { startDaemon: boolean }) { + const { exit } = useApp(); + const [view, setView] = useState<"dashboard" | "details" | "settings">("dashboard"); + const [detailName, setDetailName] = useState(null); + const [settings, setSettings] = useState(() => loadSettings()); + + // initial local state from disk (daemon overrides via useDaemon when running) + const initial = useMemo(() => buildLocalProfileStates(settings), []); + const daemon = useDaemon(initial); + + useEffect(() => { + if (startDaemon) void daemon.startBackground(); + }, [startDaemon]); + + const profiles = daemon.profiles; + + const persistFavorite = (name: string) => { + const set = new Set(settings.favoriteProfiles); + set.has(name) ? set.delete(name) : set.add(name); + const next = { ...settings, favoriteProfiles: [...set] }; + setSettings(next); + saveSettings(next); + void daemon.setFavorite(name, set.has(name)); // no-op if daemon down + }; + + const doRefresh = async (names: string[]) => { + if (daemon.running) return void daemon.refresh(names.length === 1 ? names[0] : undefined); + for (const name of names) { + const p = discoverProfiles().find((x) => x.name === name); + if (p) await refreshProfile(p); + } + }; + + const copyExport = async (name: string) => { + const p = discoverProfiles().find((x) => x.name === name); + if (!p) return; + await refreshProfile(p); + const creds = readProfileCredentials(name); + if (creds) await copyToClipboard(buildExportBlock(creds)); + }; + + const openConsole = async (name: string) => { + const creds = readProfileCredentials(name); + if (!creds) return; + const url = await getConsoleSigninUrl(creds); + await openBrowser(url); + }; + + if (view === "settings") + return ( + + { + setSettings(next); + saveSettings(next); + }} + onBack={() => setView("dashboard")} + /> + + ); + + if (view === "details" && detailName) { + const p = profiles.find((x) => x.name === detailName); + if (p) + return ( + +
setView("dashboard")} /> + + ); + } + + return ( + + void doRefresh(names)} + onToggleFavorite={persistFavorite} + onRunBackground={() => void daemon.startBackground()} + onOpenDetails={(name) => { + setDetailName(name); + setView("details"); + }} + onOpenConsole={(name) => void openConsole(name)} + onCopyExport={(name) => void copyExport(name)} + onOpenSettings={() => setView("settings")} + onQuit={() => exit()} + /> + + ); +} +``` +Add `buildLocalProfileStates(settings)` near the top of `index.tsx` (same logic as `localState()` in `status.ts` — extract a shared helper in `src/aws/sso.ts` named `buildProfileStates(favorites: Set): ProfileState[]` and use it from both places to stay DRY). Import `useApp, useState, useEffect, useMemo`, `App` (existing root layout), `Dashboard`, `Details`, `Settings`, `useDaemon`, core functions, `loadSettings`, `saveSettings`, `copyToClipboard`, `openBrowser`, `getConsoleSigninUrl`, `buildExportBlock`, `readProfileCredentials`. Remove all now-dead old view components (StatusTable, RefreshProgress, DaemonView, daemon-interval list, the main menu `List`) from `index.tsx`. + +- [ ] **Step 4: Build + manual smoke test the whole TUI** + +Run: `bun run lint && bun test && bun run build` +Then exercise interactively: +```bash +node dist/cli.js +``` +Verify: dashboard lists profiles with statuses; `j/k` move; `f` toggles a star (persists — re-open to confirm); `b` flips header to "daemon ● running"; `r` triggers a refresh; `Enter` opens details, `Esc` back; `s` opens settings, toggles persist; `o` opens the AWS console in browser for a valid profile; `c` copies an export block (paste to verify); `/` filters; `q` quits. With the daemon started, open a second terminal and run `node dist/cli.js status` — it should reflect the live state. + +- [ ] **Step 5: Commit** + +```bash +git add src/cli/tui/Details.tsx src/cli/tui/Settings.tsx src/cli/index.tsx src/aws/sso.ts +git commit -m "feat(cli): wire dashboard, details, settings into list-first TUI root" +``` + +--- + +## Phase 11 — Cleanup, README, docs + +### Task 13: Remove dead code and stale tests; align CLAUDE.md + +**Files:** +- Modify: delete any now-unused old components in `src/cli/components/` that the new TUI no longer imports (verify with grep before deleting — keep `List`, `Card`, `Divider`, `Header`, `Spinner`, `StatusMessage`, `CopyFeedback`, `IdentityCard` if still referenced; remove `MultiSelectList` only if unused). +- Modify: `src/aws/sso.test.ts` — remove the `defaultInterval` settings assertion (moved to `settings.test.ts`); keep token/discovery/`formatExpiry`/`sortByFavorites` tests. +- Modify: `CLAUDE.md` — update Structure (`daemon/`, `cli/commands/`, `cli/tui/`), Keyboard Shortcuts table, and the Auto-refresh description. + +- [ ] **Step 1: Find dead references** + +Run: +```bash +grep -rn "MultiSelectList\|DaemonView\|StatusTable\|RefreshProgress\|defaultInterval\|daemon-interval" src +``` +Delete components/branches with zero remaining references. + +- [ ] **Step 2: Update `sso.test.ts`** + +Remove any settings tests that reference `defaultInterval` or the old settings API now living in `settings.test.ts`. + +- [ ] **Step 3: Update `CLAUDE.md`** + +Update the Structure block to include `src/daemon/`, `src/cli/commands/`, `src/cli/tui/`; update the Keyboard Shortcuts table to the new dashboard keys; replace the "Auto-refresh daemon" wording with the real background-daemon description. + +- [ ] **Step 4: Verify green** + +Run: `bun run lint && bun test && bun run build` +Expected: all pass, no unused-import warnings. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "refactor(cli): remove dead menu-era code and update CLAUDE.md" +``` + +### Task 14: Rewrite README for npm appeal + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Rewrite the README** + +Rewrite `README.md` with this structure (fill with real content): +1. **Title + one-line hook** — e.g. "Keep your AWS SSO credentials fresh, automatically — a fast terminal dashboard with a background daemon." +2. Badges (keep existing). +3. **Why SSOmatic** — 3-4 bullets: list-first dashboard, background daemon keeps favorites fresh (expiry-aware), notify-on-login, one-keystroke copy/console/export. +4. **Demo GIF** (`docs/screenshots/cli-demo.gif`). +5. **Install** (`npx ssomatic`, `bunx`, `npm i -g`). +6. **Quick start** — launch the TUI; star the profiles you use (`f`); press `b` to run in the background; re-run `ssomatic` from any terminal to attach. +7. **Commands** — table of `status`, `refresh`, `export`, `daemon start|stop|status`, plus `eval $(ssomatic export prod)`. +8. **Keyboard shortcuts** — new dashboard table. +9. **How the daemon works** — single instance per host, Unix socket, silent role-cred refresh while the SSO token is valid, desktop notification when a browser login is required (never opens a browser unprompted). +10. **Prerequisites** (AWS CLI v2 SSO config), **Development**, **Contributing**, **License**. + +- [ ] **Step 2: Record a fresh demo GIF (manual)** + +Replace `docs/screenshots/cli-demo.gif` with a new recording of the dashboard (navigate, star, run in background, refresh, attach from a second terminal). If you cannot record now, leave the existing GIF and add a `` note — but prefer recording. + +- [ ] **Step 3: Verify links/build** + +Run: `bun run build` and visually confirm the README renders (preview in editor). Check that every command shown matches `ssomatic --help`. + +- [ ] **Step 4: Commit** + +```bash +git add README.md docs/screenshots/cli-demo.gif +git commit -m "docs: rewrite README for SSOmatic 2.0 with daemon + dashboard" +``` + +--- + +## Phase 12 — Final verification + +### Task 15: Full regression + PR + +- [ ] **Step 1: Full green check** + +Run: `bun install && bun run lint && bun test && bun run build` +Expected: all pass. + +- [ ] **Step 2: End-to-end daemon lifecycle** + +```bash +node dist/cli.js daemon start +node dist/cli.js daemon status # running, pid, watched favorites +node dist/cli.js status # live snapshot via socket +# open a second terminal: +node dist/cli.js # attaches, shows live state; q to detach +node dist/cli.js daemon stop +node dist/cli.js daemon status # stopped +``` +Expected: single instance enforced (a second `daemon start` says "already running"); state consistent across terminals; clean stop. + +- [ ] **Step 3: Open the PR** + +```bash +git push -u origin feat/v2-daemon-ux +gh pr create --title "feat(cli): background daemon + list-first dashboard (SSOmatic 2.0)" \ + --body "Implements docs/superpowers/specs/2026-06-11-ssomatic-v2-daemon-ux-design.md" +``` +Note: the squash-merge PR title drives release-please. `feat(cli):` → minor bump. If you intend a major (2.0), use `feat(cli)!:` with a `BREAKING CHANGE:` footer in the body (the interval-picker removal and command surface change justify a major). + +--- + +## Self-review notes (coverage map) + +- Daemon + Unix socket, live attach → Tasks 3, 6, 7, 10, 12. +- Notify + wait on needs-login → Task 7 (`maybeNotify`, daemon never opens browser). +- Bare `ssomatic` opens TUI, daemon opt-in → Tasks 9, 12 (`startDaemon` only via `--daemon`/`b`). +- Full subcommand set → Task 8 (`status`/`export`/`refresh`/`daemon`). +- List-first dashboard → Tasks 11, 12. +- Expiry-aware scheduling, no interval → Task 5; interval removed in Tasks 1, 13. +- Favorites = managed set → Tasks 1, 7, 12. +- Cred actions (copy export, open console, details, copy name) → Tasks 2, 11, 12. +- Single instance per host → Task 4 (`isDaemonAlive`/`reclaimStaleSocket`). +- Latest libs → Task 0. README rewrite → Task 14. Refactor/cleanup → Tasks 1, 13. +- Settings shrink (notifications, lead-time, auto-start) → Tasks 1, 12. From 3979fa317d38bbbd3649a3008921076a0ee4a145 Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 14:31:41 +0200 Subject: [PATCH 03/29] build(deps): upgrade ink, react, aws-sdk and toolchain to latest --- bun.lock | 358 ++++++++++++++++++++++------------------------- eslint.config.js | 3 + package.json | 28 ++-- 3 files changed, 184 insertions(+), 205 deletions(-) diff --git a/bun.lock b/bun.lock index 628e1fb..968323e 100644 --- a/bun.lock +++ b/bun.lock @@ -5,33 +5,35 @@ "": { "name": "ssomatic", "dependencies": { - "@aws-sdk/client-sso": "^3.958.0", - "@aws-sdk/client-sso-oidc": "^3.958.0", - "@aws-sdk/client-sts": "^3.700.0", - "ini": "^5.0.0", - "ink": "^6.0.0", + "@aws-sdk/client-sso": "^3.1066.0", + "@aws-sdk/client-sso-oidc": "^3.1066.0", + "@aws-sdk/client-sts": "^3.1066.0", + "ini": "^7.0.0", + "ink": "^7.0.5", "ink-spinner": "^5.0.0", - "react": "^19.0.0", + "react": "^19.2.7", }, "devDependencies": { "@commitlint/cli": "^20.2.0", "@commitlint/config-conventional": "^20.2.0", - "@eslint/js": "^9.0.0", + "@eslint/js": "^10.0.1", "@types/ini": "^4.1.1", - "@types/react": "^19.0.0", - "@typescript-eslint/eslint-plugin": "^8.0.0", - "@typescript-eslint/parser": "^8.0.0", + "@types/react": "^19.2.17", + "@typescript-eslint/eslint-plugin": "^8.61.0", + "@typescript-eslint/parser": "^8.61.0", "bun-types": "latest", - "eslint": "^9.0.0", - "eslint-plugin-react": "^7.37.0", - "eslint-plugin-react-hooks": "^5.0.0", + "eslint": "^10.4.1", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.1.1", "husky": "^9.1.7", - "typescript": "^5.6.0", + "typescript": "^6.0.3", }, }, }, "packages": { - "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="], + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="], + + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], @@ -41,61 +43,75 @@ "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], - "@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.1018.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.25", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.9", "@aws-sdk/middleware-user-agent": "^3.972.26", "@aws-sdk/region-config-resolver": "^3.972.10", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.12", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-geUrcti8lLhv2JoalKhaib4C/ExIV386XDFnxDq+sysC+Mmm/+wVUp+LQFBKUJhmJAbXMBeDb/B186cGQsMn4Q=="], + "@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.1066.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-A5jOK8LWrNkl8Fzrs5eAHCfBk88R61vlZDIDv/6mGgHiSpwFUYLN5DDITTMnSIottFFzgQviGdquo3IbQ/jaZQ=="], - "@aws-sdk/client-sso-oidc": ["@aws-sdk/client-sso-oidc@3.1018.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.25", "@aws-sdk/credential-provider-node": "^3.972.26", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.9", "@aws-sdk/middleware-user-agent": "^3.972.26", "@aws-sdk/region-config-resolver": "^3.972.10", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.12", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-H6anpIeHyrTwRLcby/m0icu0ZfVxsxAFzQTCWe0p9whz2A2F8E1ldm2uPyXaceEsBzIhLivf52Z3hhq0JMAQbg=="], + "@aws-sdk/client-sso-oidc": ["@aws-sdk/client-sso-oidc@3.1066.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.20", "@aws-sdk/credential-provider-node": "^3.972.55", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-EdC3k14I1pDKRsIrzrFRH04UDHMeU+XIoRBcje2v0lygivyd3iH0EHbkZhNJDPmTed2juHmeH+1fudHB3c+0Kw=="], - "@aws-sdk/client-sts": ["@aws-sdk/client-sts@3.1018.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.25", "@aws-sdk/credential-provider-node": "^3.972.26", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.9", "@aws-sdk/middleware-user-agent": "^3.972.26", "@aws-sdk/region-config-resolver": "^3.972.10", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.12", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-8XmQN27dPnqqCN+1o34pnfa4KEKCrqN7p08tb+MMVrARlP5lQKFS8OvvabdA0edKWdLqCCW7l5iH9qHzpwYhJw=="], + "@aws-sdk/client-sts": ["@aws-sdk/client-sts@3.1066.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.20", "@aws-sdk/credential-provider-node": "^3.972.55", "@aws-sdk/signature-v4-multi-region": "^3.996.34", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-a24RbDdy7L67IzvMjiYRhfoNRljU5mfbjZyKLJlrEpyj4A5ryDqLP1dJS6GU/dtbaSqGd+LUoRTsFkqA7BEcmg=="], - "@aws-sdk/core": ["@aws-sdk/core@3.973.25", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.16", "@smithy/core": "^3.23.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-TNrx7eq6nKNOO62HWPqoBqPLXEkW6nLZQGwjL6lq1jZtigWYbK1NbCnT7mKDzbLMHZfuOECUt3n6CzxjUW9HWQ=="], + "@aws-sdk/core": ["@aws-sdk/core@3.974.20", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@aws-sdk/xml-builder": "^3.972.29", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g=="], - "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.25", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-EamaclJcCEaPHp6wiVknNMM2RlsPMjAHSsYSFLNENBM8Wz92QPc6cOn3dif6vPDQt0Oo4IEghDy3NMDCzY/IvA=="], + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-+GPXVS2srMOlH74S+SmC1gVuP2TvUZ0siuC0onKO93q+udP+M72dmY8wJfVQ5CX9z/9X5A1HHwz5yRIGBtskvQ=="], - "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.25", "", { "dependencies": { "@aws-sdk/core": "^3.973.25", "@aws-sdk/types": "^3.973.6", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-qPymamdPcLp6ugoVocG1y5r69ScNiRzb0hogX25/ij+Wz7c7WnsgjLTaz7+eB5BfRxeyUwuw5hgULMuwOGOpcw=="], + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.48", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-fA5loSdlocacRxyUXtpoHSMuk5rsIKRDzQYVMnMxjcmFeZshaJlJ8lymy/hYKji6sne/UmNGj5pxuEs6kq/Qcg=="], - "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.25", "", { "dependencies": { "@aws-sdk/core": "^3.973.25", "@aws-sdk/credential-provider-env": "^3.972.23", "@aws-sdk/credential-provider-http": "^3.972.25", "@aws-sdk/credential-provider-login": "^3.972.25", "@aws-sdk/credential-provider-process": "^3.972.23", "@aws-sdk/credential-provider-sso": "^3.972.25", "@aws-sdk/credential-provider-web-identity": "^3.972.25", "@aws-sdk/nested-clients": "^3.996.15", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-G/v/PicYn4qs7xCv4vT6I4QKdvMyRvsgIFNBkUueCGlbLo7/PuKcNKgUozmLSsaYnE7jIl6UrfkP07EUubr48w=="], + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.53", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/credential-provider-env": "^3.972.46", "@aws-sdk/credential-provider-http": "^3.972.48", "@aws-sdk/credential-provider-login": "^3.972.52", "@aws-sdk/credential-provider-process": "^3.972.46", "@aws-sdk/credential-provider-sso": "^3.972.52", "@aws-sdk/credential-provider-web-identity": "^3.972.52", "@aws-sdk/nested-clients": "^3.997.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ZfdhIOR41q8TcWEnUac+gCOb+O2LBWdHLmjedXpXz4IEFW2ppNuFcm6p0sMTavpM+zD5TYfpH5Gp7guRyqSgsQ=="], - "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.25", "", { "dependencies": { "@aws-sdk/core": "^3.973.25", "@aws-sdk/nested-clients": "^3.996.15", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-bUdmyJeVua7SmD+g2a65x2/0YqsGn4K2k4GawI43js0odaNaIzpIhLtHehUnPnfLuyhPWbJR1NyuIO4iMVfM0w=="], + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.52", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/nested-clients": "^3.997.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-9hu2oR0qH7Fst5Tzdx+UWxm+w5zCXtErTLtOOW5hwwQc170CLwOeniRxyFY6s9mHfGEfC5zFukNBdKBwJR8mhQ=="], - "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.26", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.23", "@aws-sdk/credential-provider-http": "^3.972.25", "@aws-sdk/credential-provider-ini": "^3.972.25", "@aws-sdk/credential-provider-process": "^3.972.23", "@aws-sdk/credential-provider-sso": "^3.972.25", "@aws-sdk/credential-provider-web-identity": "^3.972.25", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-5XSK74rCXxCNj+UWv5bjq1EccYkiyW4XOHFU9NXnsCcQF8dJuHdua1qFg0m/LIwVOWklbKsrcnMtfxIXwgvwzQ=="], + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.55", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.46", "@aws-sdk/credential-provider-http": "^3.972.48", "@aws-sdk/credential-provider-ini": "^3.972.53", "@aws-sdk/credential-provider-process": "^3.972.46", "@aws-sdk/credential-provider-sso": "^3.972.52", "@aws-sdk/credential-provider-web-identity": "^3.972.52", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-zMGLa/dhESVqmCD7mmIFFKSwSFrJGScvCXcjvBZEVOOMauFS5JRQvLTMukFpMEFWiV6dTAlsen2ATDBulLPtbg=="], - "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.973.25", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-IL/TFW59++b7MpHserjUblGrdP5UXy5Ekqqx1XQkERXBFJcZr74I7VaSrQT5dxdRMU16xGK4L0RQ5fQG1pMgnA=="], + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-VUoNFBIjWrUN8NbFiQiuxQEgFjvziAlBRPK+ddh27aj65gk0BYu6bLZnrdrNZwpW6vAihtSUtEMQ1PUJ32QRPA=="], - "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.25", "", { "dependencies": { "@aws-sdk/core": "^3.973.25", "@aws-sdk/nested-clients": "^3.996.15", "@aws-sdk/token-providers": "3.1018.0", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-r4OGAfHmlEa1QBInHWz+/dOD4tRljcjVNQe9wJ/AJNXEj1d2WdsRLppvRFImRV6FIs+bTpjtL0a23V5ELQpRPw=="], + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.52", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/nested-clients": "^3.997.20", "@aws-sdk/token-providers": "3.1066.0", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-nb2/n4o/HQf+FVpVbZe9vCTFngmuDoIsltMgLAtjixaKzvzhB4J8WSDFyWgnErgLHk55ctWH+I4PU+LIHhyffg=="], - "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.25", "", { "dependencies": { "@aws-sdk/core": "^3.973.25", "@aws-sdk/nested-clients": "^3.996.15", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-uM1OtoJgj+yK3MlAmda8uR9WJJCdm5HB25JyCeFL5a5q1Fbafalf4uKidFO3/L0Pgd+Fsflkb4cM6jHIswi3QQ=="], + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.52", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/nested-clients": "^3.997.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-lKj6aRSGbqLmpYmM24bY7a1Xmfcq2vkE3hv8CSPYfc1yCu0BPu/XEJ1L4Fm61MsU6ULLNSG8UGsffNoFUBjESA=="], - "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ=="], + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.20", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.20", "@aws-sdk/signature-v4-multi-region": "^3.996.34", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-IYJuLpXp2DEILVQpQOy0PMpkftv0AHEOCn52o0atyOaumA0CdWQ3klPyXdViGYLbNpESsVFMVybvHUeZAuiGxA=="], - "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA=="], + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.34", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-mx1L5qlumSOt/nKM3BFaHE2HVkWwz0i4Bw0pyYO42FfX/FeLlo8YI6csC0gSPprEk6fTIqI+CZN9RwUwKd5krQ=="], - "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-/Wt5+CT8dpTFQxEJ9iGy/UGrXr7p2wlIOEHvIr/YcHYByzoLjrqkYqXdJjd9UIgWjv7eqV2HnFJen93UTuwfTQ=="], + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1066.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/nested-clients": "^3.997.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-UqEUJq7dqa44hneLDUcX7UJy95cg8YqEWyakRpvIPnrNS3Mq+UlQHgCDGu5pvwAPtlIW4qcYbvW6reG6++FyvA=="], - "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.26", "", { "dependencies": { "@aws-sdk/core": "^3.973.25", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-retry": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-AilFIh4rI/2hKyyGN6XrB0yN96W2o7e7wyrPWCM6QjZM1mcC/pVkW3IWWRvuBWMpVP8Fg+rMpbzeLQ6dTM4gig=="], + "@aws-sdk/types": ["@aws-sdk/types@3.973.12", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA=="], - "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.15", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.25", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.9", "@aws-sdk/middleware-user-agent": "^3.972.26", "@aws-sdk/region-config-resolver": "^3.972.10", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.12", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-retry": "^4.4.44", "@smithy/middleware-serde": "^4.2.15", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.0", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.43", "@smithy/util-defaults-mode-node": "^4.2.47", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-k6WAVNkub5DrU46iPQvH1m0xc1n+0dX79+i287tYJzf5g1yU2rX3uf4xNeL5JvK1NtYgfwMnsxHqhOXFBn367A=="], + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="], - "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/config-resolver": "^4.4.13", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-1dq9ToC6e070QvnVhhbAs3bb5r6cQ10gTVc6cyRV5uvQe7P138TV2uG2i6+Yok4bAkVAcx5AqkTEBUvWEtBlsQ=="], + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.29", "", { "dependencies": { "@smithy/types": "^4.14.3", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ=="], - "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1018.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.25", "@aws-sdk/nested-clients": "^3.996.15", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-97OPNJHy37wmGOX44xAcu6E9oSTiqK9uPcy/fWpmN5uB3JuEp1f6x60Xot/jp+FxwhQWIFUsVJFnm3QKqt7T6Q=="], + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], - "@aws-sdk/types": ["@aws-sdk/types@3.973.6", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" } }, "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw=="], + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], - "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="], + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], - "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA=="], + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], - "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.12", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.26", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-8phW0TS8ntENJgDcFewYT/Q8dOmarpvSxEjATu2GUBAutiHr++oEGCiBUwxslCMNvwW2cAPZNT53S/ym8zm/gg=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], - "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.16", "", { "dependencies": { "@smithy/types": "^4.13.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A=="], + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], - "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], "@commitlint/cli": ["@commitlint/cli@20.5.0", "", { "dependencies": { "@commitlint/format": "^20.5.0", "@commitlint/lint": "^20.5.0", "@commitlint/load": "^20.5.0", "@commitlint/read": "^20.5.0", "@commitlint/types": "^20.5.0", "tinyexec": "^1.0.0", "yargs": "^17.0.0" }, "bin": { "commitlint": "./cli.js" } }, "sha512-yNkyN/tuKTJS3wdVfsZ2tXDM4G4Gi7z+jW54Cki8N8tZqwKBltbIvUUrSbT4hz1bhW/h0CdR+5sCSpXD+wMKaQ=="], @@ -137,19 +153,17 @@ "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], - - "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], - "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="], - "@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="], + "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], - "@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], + "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], - "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], @@ -159,89 +173,41 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], - "@simple-libs/child-process-utils": ["@simple-libs/child-process-utils@1.0.2", "", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0" } }, "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw=="], - - "@simple-libs/stream-utils": ["@simple-libs/stream-utils@1.2.0", "", {}, "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA=="], - - "@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="], - - "@smithy/config-resolver": ["@smithy/config-resolver@4.4.13", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg=="], - - "@smithy/core": ["@smithy/core@3.23.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w=="], - - "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.15", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A=="], + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], - "@smithy/hash-node": ["@smithy/hash-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w=="], + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA=="], + "@nodable/entities": ["@nodable/entities@2.1.1", "", {}, "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg=="], - "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.27", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-serde": "^4.2.15", "@smithy/node-config-provider": "^4.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA=="], - - "@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.44", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/service-error-classification": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-Y1Rav7m5CFRPQyM4CI0koD/bXjyjJu3EQxZZhtLGD88WIrBrQ7kqXM96ncd6rYnojwOo/u9MXu57JrEvu/nLrA=="], - - "@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.15", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg=="], - - "@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw=="], - - "@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.12", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw=="], - - "@smithy/node-http-handler": ["@smithy/node-http-handler@4.5.0", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A=="], - - "@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="], - - "@smithy/protocol-http": ["@smithy/protocol-http@5.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw=="], - - "@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="], - - "@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw=="], - - "@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="], - - "@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="], - - "@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="], - - "@smithy/smithy-client": ["@smithy/smithy-client@4.12.7", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-endpoint": "^4.4.27", "@smithy/middleware-stack": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" } }, "sha512-q3gqnwml60G44FECaEEsdQMplYhDMZYCtYhMCzadCnRnnHIobZJjegmdoUo6ieLQlPUzvrMdIJUpx6DoPmzANQ=="], - - "@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="], - - "@smithy/url-parser": ["@smithy/url-parser@4.2.12", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA=="], - - "@smithy/util-base64": ["@smithy/util-base64@4.3.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ=="], - - "@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ=="], - - "@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g=="], - - "@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="], + "@simple-libs/child-process-utils": ["@simple-libs/child-process-utils@1.0.2", "", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0" } }, "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw=="], - "@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ=="], + "@simple-libs/stream-utils": ["@simple-libs/stream-utils@1.2.0", "", {}, "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA=="], - "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.43", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Qd/0wCKMaXxev/z00TvNzGCH2jlKKKxXP1aDxB6oKwSQthe3Og2dMhSayGCnsma1bK/kQX1+X7SMP99t6FgiiQ=="], + "@smithy/core": ["@smithy/core@3.24.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug=="], - "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.47", "", { "dependencies": { "@smithy/config-resolver": "^4.4.13", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-qSRbYp1EQ7th+sPFuVcVO05AE0QH635hycdEXlpzIahqHHf2Fyd/Zl+8v0XYMJ3cgDVPa0lkMefU7oNUjAP+DQ=="], + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.8", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg=="], - "@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.3", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig=="], + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g=="], - "@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg=="], + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], - "@smithy/util-middleware": ["@smithy/util-middleware@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ=="], + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.7", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A=="], - "@smithy/util-retry": ["@smithy/util-retry@4.2.12", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ=="], + "@smithy/signature-v4": ["@smithy/signature-v4@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ=="], - "@smithy/util-stream": ["@smithy/util-stream@4.5.20", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw=="], + "@smithy/types": ["@smithy/types@4.14.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ=="], - "@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw=="], + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - "@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], + "@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="], + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -251,27 +217,27 @@ "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], - "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/type-utils": "8.57.2", "@typescript-eslint/utils": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.61.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/type-utils": "8.61.0", "@typescript-eslint/utils": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.61.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.61.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.57.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.2", "@typescript-eslint/types": "^8.57.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.61.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.61.0", "@typescript-eslint/types": "^8.61.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.57.2", "", { "dependencies": { "@typescript-eslint/types": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2" } }, "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0" } }, "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.61.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.57.2", "", { "dependencies": { "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/utils": "8.57.2", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/utils": "8.61.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.57.2", "", {}, "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.61.0", "", {}, "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.57.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.57.2", "@typescript-eslint/tsconfig-utils": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.61.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.61.0", "@typescript-eslint/tsconfig-utils": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.61.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.2", "", { "dependencies": { "@typescript-eslint/types": "8.57.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -285,6 +251,8 @@ "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "anynum": ["anynum@1.0.0", "", {}, "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], @@ -309,11 +277,15 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.35", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg=="], "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], - "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], @@ -325,15 +297,17 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="], + + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + "cli-boxes": ["cli-boxes@4.0.1", "", {}, "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw=="], "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], - "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], + "cli-truncate": ["cli-truncate@6.0.0", "", { "dependencies": { "slice-ansi": "^9.0.0", "string-width": "^8.2.0" } }, "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA=="], "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], @@ -355,6 +329,8 @@ "conventional-commits-parser": ["conventional-commits-parser@6.3.0", "", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0", "meow": "^13.0.0" }, "bin": { "conventional-commits-parser": "dist/cli/index.js" } }, "sha512-RfOq/Cqy9xV9bOA8N+ZH6DlrDR+5S3Mi0B5kACEjESpE+AviIpAptx9a9cFpWCCvgRtWT+0BbUw+e1BZfts9jg=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], "cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="], @@ -385,6 +361,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "electron-to-chromium": ["electron-to-chromium@1.5.371", "", {}, "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -415,17 +393,17 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], + "eslint": ["eslint@10.4.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw=="], "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], - "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], - "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], - "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], @@ -443,9 +421,9 @@ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="], + "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], - "fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="], + "fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -467,6 +445,8 @@ "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], @@ -483,16 +463,12 @@ "global-directory": ["global-directory@4.0.1", "", { "dependencies": { "ini": "4.1.1" } }, "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q=="], - "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], - "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], @@ -503,6 +479,10 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], @@ -515,9 +495,9 @@ "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], - "ini": ["ini@5.0.0", "", {}, "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw=="], + "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], - "ink": ["ink@6.8.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="], + "ink": ["ink@7.0.5", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.3", "auto-bind": "^5.0.1", "chalk": "^5.6.2", "cli-boxes": "^4.0.1", "cli-cursor": "^4.0.0", "cli-truncate": "^6.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.45.1", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^9.0.0", "stack-utils": "^2.0.6", "string-width": "^8.2.0", "terminal-size": "^4.0.1", "type-fest": "^5.5.0", "widest-line": "^6.0.0", "wrap-ansi": "^10.0.0", "ws": "^8.20.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.2.0", "react": ">=19.2.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-zWNjGHQPxSeiSAmDUOq+QPQ6CfmMhmNi85vrJIuy4prafKKUSoZlXEy4wbM7LuLuF1pDURk7qvF4fxrQlLxv3w=="], "ink-spinner": ["ink-spinner@5.0.0", "", { "dependencies": { "cli-spinners": "^2.7.0" }, "peerDependencies": { "ink": ">=4.0.0", "react": ">=18.0.0" } }, "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA=="], @@ -587,12 +567,14 @@ "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], - "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], @@ -601,6 +583,8 @@ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], @@ -615,8 +599,6 @@ "lodash.kebabcase": ["lodash.kebabcase@4.1.1", "", {}, "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g=="], - "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], - "lodash.mergewith": ["lodash.mergewith@4.6.2", "", {}, "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ=="], "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], @@ -627,13 +609,15 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -643,6 +627,8 @@ "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], + "node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -675,7 +661,7 @@ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - "path-expression-matcher": ["path-expression-matcher@1.2.0", "", {}, "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ=="], + "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -693,9 +679,7 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], - - "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], @@ -735,8 +719,6 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], @@ -747,7 +729,7 @@ "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], + "slice-ansi": ["slice-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA=="], "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], @@ -767,11 +749,7 @@ "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - - "strnum": ["strnum@2.2.2", "", {}, "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA=="], - - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "strnum": ["strnum@2.4.0", "", { "dependencies": { "anynum": "^1.0.0" } }, "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -799,12 +777,14 @@ "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -821,12 +801,16 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], @@ -835,9 +819,15 @@ "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], - "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], - "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "@aws-crypto/sha256-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.6", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw=="], + + "@aws-crypto/sha256-js/@aws-sdk/types": ["@aws-sdk/types@3.973.6", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw=="], + + "@aws-crypto/util/@aws-sdk/types": ["@aws-sdk/types@3.973.6", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw=="], "@commitlint/config-validator/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], @@ -847,47 +837,35 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - - "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - "cosmiconfig-typescript-loader/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "eslint-plugin-react/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "global-directory/ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="], "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], - "ink/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - - "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "parse-json/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], - "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + "@aws-crypto/sha256-browser/@aws-sdk/types/@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="], - "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + "@aws-crypto/sha256-js/@aws-sdk/types/@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="], - "@commitlint/config-validator/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@aws-crypto/util/@aws-sdk/types/@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "@commitlint/config-validator/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], @@ -895,17 +873,15 @@ "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "parse-json/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], "yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], - - "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], - - "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "eslint-plugin-react/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } diff --git a/eslint.config.js b/eslint.config.js index d4241c4..c4acb11 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -45,6 +45,9 @@ export default [ "react/prop-types": "off", "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], "@typescript-eslint/no-explicit-any": "warn", + // Deferred: setState-in-effect patterns in List.tsx, MultiSelectList.tsx, and index.tsx + // will be refactored in a later task (v2 component rewrite). + "react-hooks/set-state-in-effect": "off", }, settings: { react: { version: "19.0" }, diff --git a/package.json b/package.json index 1f5514d..7f45e51 100644 --- a/package.json +++ b/package.json @@ -41,27 +41,27 @@ "prepare": "husky" }, "dependencies": { - "@aws-sdk/client-sso": "^3.958.0", - "@aws-sdk/client-sso-oidc": "^3.958.0", - "@aws-sdk/client-sts": "^3.700.0", - "ini": "^5.0.0", - "ink": "^6.0.0", + "@aws-sdk/client-sso": "^3.1066.0", + "@aws-sdk/client-sso-oidc": "^3.1066.0", + "@aws-sdk/client-sts": "^3.1066.0", + "ini": "^7.0.0", + "ink": "^7.0.5", "ink-spinner": "^5.0.0", - "react": "^19.0.0" + "react": "^19.2.7" }, "devDependencies": { "@commitlint/cli": "^20.2.0", "@commitlint/config-conventional": "^20.2.0", - "@eslint/js": "^9.0.0", + "@eslint/js": "^10.0.1", "@types/ini": "^4.1.1", - "@types/react": "^19.0.0", - "@typescript-eslint/eslint-plugin": "^8.0.0", - "@typescript-eslint/parser": "^8.0.0", + "@types/react": "^19.2.17", + "@typescript-eslint/eslint-plugin": "^8.61.0", + "@typescript-eslint/parser": "^8.61.0", "bun-types": "latest", - "eslint": "^9.0.0", - "eslint-plugin-react": "^7.37.0", - "eslint-plugin-react-hooks": "^5.0.0", + "eslint": "^10.4.1", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.1.1", "husky": "^9.1.7", - "typescript": "^5.6.0" + "typescript": "^6.0.3" } } From dc21e32a2e1e8865396ae5a5c2affce3de638656 Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 14:35:49 +0200 Subject: [PATCH 04/29] refactor(aws): split settings into settings.ts with expiry-aware shape Drop defaultInterval; introduce refreshLeadMinutes (default 5) and autoStartDaemon (default false). Legacy files with defaultInterval are migrated transparently. settings.ts is sync; index.tsx updated accordingly. REFRESH_INTERVALS kept as local shim until Task 12/13 interval UI removal. --- src/aws/settings.test.ts | 40 +++++++++++++++++++++++++++++++++++++ src/aws/settings.ts | 43 ++++++++++++++++++++++++++++++++++++++++ src/aws/sso.test.ts | 12 ++++------- src/aws/sso.ts | 35 -------------------------------- src/cli/index.tsx | 40 +++++++++++++++++++++++-------------- 5 files changed, 112 insertions(+), 58 deletions(-) create mode 100644 src/aws/settings.test.ts create mode 100644 src/aws/settings.ts diff --git a/src/aws/settings.test.ts b/src/aws/settings.test.ts new file mode 100644 index 0000000..80a5911 --- /dev/null +++ b/src/aws/settings.test.ts @@ -0,0 +1,40 @@ +import { test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +let home: string; +let prevHome: string | undefined; + +beforeEach(() => { + prevHome = process.env.HOME; + home = mkdtempSync(join(tmpdir(), "ssomatic-settings-")); + process.env.HOME = home; +}); +afterEach(() => { + process.env.HOME = prevHome; + rmSync(home, { recursive: true, force: true }); +}); + +test("loadSettings returns defaults when no file exists", async () => { + const { loadSettings, DEFAULT_SETTINGS } = await import("./settings"); + expect(loadSettings()).toEqual(DEFAULT_SETTINGS); +}); + +test("saveSettings then loadSettings round-trips", async () => { + const { loadSettings, saveSettings } = await import("./settings"); + saveSettings({ notifications: false, refreshLeadMinutes: 10, autoStartDaemon: true, favoriteProfiles: ["prod", "dev"] }); + expect(loadSettings()).toEqual({ notifications: false, refreshLeadMinutes: 10, autoStartDaemon: true, favoriteProfiles: ["prod", "dev"] }); +}); + +test("loadSettings migrates a legacy file with defaultInterval", async () => { + const { loadSettings } = await import("./settings"); + const { writeFileSync, mkdirSync } = await import("node:fs"); + mkdirSync(join(home, ".aws"), { recursive: true }); + writeFileSync(join(home, ".aws", "credentials-manager.json"), JSON.stringify({ notifications: true, defaultInterval: 30, favoriteProfiles: ["x"] })); + const s = loadSettings(); + expect(s.favoriteProfiles).toEqual(["x"]); + expect(s.refreshLeadMinutes).toBe(5); + expect(s.autoStartDaemon).toBe(false); + expect("defaultInterval" in s).toBe(false); +}); diff --git a/src/aws/settings.ts b/src/aws/settings.ts new file mode 100644 index 0000000..3633b01 --- /dev/null +++ b/src/aws/settings.ts @@ -0,0 +1,43 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; + +export interface AppSettings { + notifications: boolean; + refreshLeadMinutes: number; + autoStartDaemon: boolean; + favoriteProfiles: string[]; +} + +export const DEFAULT_SETTINGS: AppSettings = { + notifications: true, + refreshLeadMinutes: 5, + autoStartDaemon: false, + favoriteProfiles: [], +}; + +function settingsPath(): string { + const home = process.env.HOME || process.env.USERPROFILE || ""; + return join(home, ".aws", "credentials-manager.json"); +} + +export function loadSettings(): AppSettings { + const path = settingsPath(); + if (!existsSync(path)) return { ...DEFAULT_SETTINGS }; + try { + const raw = JSON.parse(readFileSync(path, "utf8")) as Partial & { defaultInterval?: number }; + return { + notifications: raw.notifications ?? DEFAULT_SETTINGS.notifications, + refreshLeadMinutes: raw.refreshLeadMinutes ?? DEFAULT_SETTINGS.refreshLeadMinutes, + autoStartDaemon: raw.autoStartDaemon ?? DEFAULT_SETTINGS.autoStartDaemon, + favoriteProfiles: raw.favoriteProfiles ?? DEFAULT_SETTINGS.favoriteProfiles, + }; + } catch { + return { ...DEFAULT_SETTINGS }; + } +} + +export function saveSettings(settings: AppSettings): void { + const path = settingsPath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(settings, null, 2)); +} diff --git a/src/aws/sso.test.ts b/src/aws/sso.test.ts index 7869c86..a46cae4 100644 --- a/src/aws/sso.test.ts +++ b/src/aws/sso.test.ts @@ -64,15 +64,11 @@ test("discoverProfiles parses sso-session and inline profiles", async () => { }); test("saveSettings / loadSettings round-trip", async () => { - await sso.saveSettings({ - ...sso.DEFAULT_SETTINGS, - notifications: false, - defaultInterval: 60, - favoriteProfiles: ["dev"], - }); - const loaded = await sso.loadSettings(); + const { saveSettings, loadSettings } = await import("./settings"); + saveSettings({ notifications: false, refreshLeadMinutes: 60, autoStartDaemon: false, favoriteProfiles: ["dev"] }); + const loaded = loadSettings(); expect(loaded.notifications).toBe(false); - expect(loaded.defaultInterval).toBe(60); + expect(loaded.refreshLeadMinutes).toBe(60); expect(loaded.favoriteProfiles).toEqual(["dev"]); }); diff --git a/src/aws/sso.ts b/src/aws/sso.ts index adf344c..75ebcb5 100644 --- a/src/aws/sso.ts +++ b/src/aws/sso.ts @@ -54,13 +54,6 @@ export interface DeviceAuthInfo { interval: number; } -export interface AppSettings { - notifications: boolean; - defaultInterval: number; - favoriteProfiles: string[]; - lastRefresh?: string; -} - export interface TokenInfo { accessToken: string; expiresAt: Date; @@ -83,20 +76,6 @@ export const AWS_DIR = `${HOME}/.aws`; export const CONFIG_PATH = `${AWS_DIR}/config`; export const CREDENTIALS_PATH = `${AWS_DIR}/credentials`; export const SSO_CACHE_DIR = `${AWS_DIR}/sso/cache`; -export const SETTINGS_PATH = `${AWS_DIR}/credentials-manager.json`; - -export const DEFAULT_SETTINGS: AppSettings = { - notifications: true, - defaultInterval: 30, - favoriteProfiles: [], -}; - -export const REFRESH_INTERVALS = [ - { value: 15, label: "15 minutes" }, - { value: 30, label: "30 minutes", hint: "recommended" }, - { value: 60, label: "1 hour" }, - { value: 120, label: "2 hours" }, -]; // ───────────────────────────────────────────────────────────────────────────── // File Utilities @@ -124,20 +103,6 @@ export async function writeCredentials(profileName: string, credentials: AWSCred await writeFile(CREDENTIALS_PATH, stringifyIni(existing)); } -export async function loadSettings(): Promise { - try { - const content = await readFile(SETTINGS_PATH, "utf8"); - return { ...DEFAULT_SETTINGS, ...JSON.parse(content) }; - } catch { - return { ...DEFAULT_SETTINGS }; - } -} - -export async function saveSettings(settings: AppSettings): Promise { - await mkdir(AWS_DIR, { recursive: true }); - await writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)); -} - // ───────────────────────────────────────────────────────────────────────────── // SSO Cache // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 4e28d4c..5c5dfe9 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -22,22 +22,31 @@ import { type SSOProfile, type ProfileStatus, type DeviceAuthInfo, - type AppSettings, - DEFAULT_SETTINGS, - REFRESH_INTERVALS, discoverProfiles, checkAllProfiles, startDeviceAuthorization, performSSOLoginFlow, refreshProfile, sendNotification, - loadSettings, - saveSettings, openBrowser, formatExpiry, getStatusColor, sortByFavorites, } from "../aws/sso.js"; +import { + type AppSettings, + DEFAULT_SETTINGS, + loadSettings, + saveSettings, +} from "../aws/settings.js"; + +// TODO(Task 12/13): remove interval UI; kept as shim until dead code cleanup +const REFRESH_INTERVALS = [ + { value: 15, label: "15 minutes" }, + { value: 30, label: "30 minutes", hint: "recommended" }, + { value: 60, label: "1 hour" }, + { value: 120, label: "2 hours" }, +]; import { VERSION, checkForUpdate } from "../version.js"; type ViewState = @@ -103,13 +112,13 @@ function useSettings() { const [settings, setSettings] = useState(DEFAULT_SETTINGS); useEffect(() => { - loadSettings().then(setSettings); + setSettings(loadSettings()); }, []); - const updateSettings = useCallback(async (newSettings: Partial) => { + const updateSettings = useCallback((newSettings: Partial) => { const updated = { ...settings, ...newSettings }; setSettings(updated); - await saveSettings(updated); + saveSettings(updated); }, [settings]); return { settings, updateSettings }; @@ -547,7 +556,7 @@ function SSOmatic() { }, { id: "interval", - label: `Default refresh interval: ${settings.defaultInterval} minutes`, + label: `Default refresh interval: ${settings.refreshLeadMinutes} minutes before expiry`, value: "interval", }, { @@ -607,11 +616,11 @@ function SSOmatic() { } }; - const handleSettingsSelect = async (item: ListItemData) => { + const handleSettingsSelect = (item: ListItemData) => { const action = item.value as string; switch (action) { case "notifications": - await updateSettings({ notifications: !settings.notifications }); + updateSettings({ notifications: !settings.notifications }); break; case "interval": setView("settings-interval"); @@ -625,8 +634,9 @@ function SSOmatic() { } }; - const handleIntervalSelect = async (item: ListItemData) => { - await updateSettings({ defaultInterval: item.value as number }); + // TODO(Task 12/13): defaultInterval shim — remove with interval UI cleanup + const handleIntervalSelect = (item: ListItemData) => { + void item; // interval value captured by daemonInterval state; settings field removed setView("settings"); }; @@ -635,8 +645,8 @@ function SSOmatic() { setView("daemon-running"); }; - const handleFavoritesSubmit = async (selected: MultiSelectItemData[]) => { - await updateSettings({ favoriteProfiles: selected.map((s) => s.id) }); + const handleFavoritesSubmit = (selected: MultiSelectItemData[]) => { + updateSettings({ favoriteProfiles: selected.map((s) => s.id) }); setView("settings"); }; From e41f80aa7fa9793c76cf00a49c50e99cd6d4a66f Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 14:40:23 +0200 Subject: [PATCH 05/29] feat(aws): add console signin URL and export-block builders --- eslint.config.js | 1 + src/aws/console.test.ts | 19 +++++++++++++++++++ src/aws/console.ts | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 src/aws/console.test.ts create mode 100644 src/aws/console.ts diff --git a/eslint.config.js b/eslint.config.js index c4acb11..8c380bd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -28,6 +28,7 @@ export default [ Set: "readonly", Date: "readonly", URL: "readonly", + URLSearchParams: "readonly", Response: "readonly", fetch: "readonly", }, diff --git a/src/aws/console.test.ts b/src/aws/console.test.ts new file mode 100644 index 0000000..d96c830 --- /dev/null +++ b/src/aws/console.test.ts @@ -0,0 +1,19 @@ +import { test, expect } from "bun:test"; +import { buildFederationSigninUrl, buildExportBlock } from "./console"; + +test("buildExportBlock produces shell export lines", () => { + const block = buildExportBlock({ accessKeyId: "ASIAEXAMPLE", secretAccessKey: "secret", sessionToken: "token" }); + expect(block).toBe( + "export AWS_ACCESS_KEY_ID=ASIAEXAMPLE\n" + + "export AWS_SECRET_ACCESS_KEY=secret\n" + + "export AWS_SESSION_TOKEN=token", + ); +}); + +test("buildFederationSigninUrl wraps the federation endpoint with a signin token", () => { + const url = buildFederationSigninUrl("SIGNINTOKEN", "https://console.aws.amazon.com/"); + expect(url).toContain("https://signin.aws.amazon.com/federation"); + expect(url).toContain("Action=login"); + expect(url).toContain("SigninToken=SIGNINTOKEN"); + expect(url).toContain(encodeURIComponent("https://console.aws.amazon.com/")); +}); diff --git a/src/aws/console.ts b/src/aws/console.ts new file mode 100644 index 0000000..dfcb83c --- /dev/null +++ b/src/aws/console.ts @@ -0,0 +1,41 @@ +import type { AWSCredentials } from "./sso"; + +export function buildExportBlock(creds: { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; +}): string { + return [ + `export AWS_ACCESS_KEY_ID=${creds.accessKeyId}`, + `export AWS_SECRET_ACCESS_KEY=${creds.secretAccessKey}`, + `export AWS_SESSION_TOKEN=${creds.sessionToken}`, + ].join("\n"); +} + +export function buildFederationSigninUrl(signinToken: string, destination = "https://console.aws.amazon.com/"): string { + const params = new URLSearchParams({ + Action: "login", + Issuer: "ssomatic", + Destination: destination, + SigninToken: signinToken, + }); + return `https://signin.aws.amazon.com/federation?${params.toString()}`; +} + +/** + * Exchange role credentials for a console signin token, then build the URL. + * Network call — kept separate from the pure builders above so they stay unit-testable. + */ +export async function getConsoleSigninUrl(creds: AWSCredentials): Promise { + const session = encodeURIComponent( + JSON.stringify({ + sessionId: creds.accessKeyId, + sessionKey: creds.secretAccessKey, + sessionToken: creds.sessionToken, + }), + ); + const res = await fetch(`https://signin.aws.amazon.com/federation?Action=getSigninToken&Session=${session}`); + if (!res.ok) throw new Error(`federation getSigninToken failed: ${res.status}`); + const { SigninToken } = (await res.json()) as { SigninToken: string }; + return buildFederationSigninUrl(SigninToken); +} From 673f595a7b1ca3d90af87e56d9b3186a4dfe2817 Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 14:42:17 +0200 Subject: [PATCH 06/29] feat(cli): add daemon wire protocol and ndjson codec --- src/daemon/protocol.test.ts | 29 ++++++++++++++++++++++ src/daemon/protocol.ts | 48 +++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 src/daemon/protocol.test.ts create mode 100644 src/daemon/protocol.ts diff --git a/src/daemon/protocol.test.ts b/src/daemon/protocol.test.ts new file mode 100644 index 0000000..6060804 --- /dev/null +++ b/src/daemon/protocol.test.ts @@ -0,0 +1,29 @@ +import { test, expect } from "bun:test"; +import { encode, decodeStream, type ClientMessage, type DaemonMessage } from "./protocol"; + +test("encode appends a newline and is JSON-parseable", () => { + const msg: ClientMessage = { type: "subscribe" }; + const line = encode(msg); + expect(line.endsWith("\n")).toBe(true); + expect(JSON.parse(line)).toEqual({ type: "subscribe" }); +}); + +test("decodeStream yields complete messages and buffers partials", () => { + const dec = decodeStream(); + const a = encode({ type: "snapshot" } as ClientMessage); + const b = encode({ type: "refresh", profile: "prod" } as ClientMessage); + const first = dec.push(a + b.slice(0, 5)); + expect(first).toEqual([{ type: "snapshot" }]); + const second = dec.push(b.slice(5)); + expect(second).toEqual([{ type: "refresh", profile: "prod" }]); +}); + +test("daemon state message shape is preserved through encode/decode", () => { + const dec = decodeStream(); + const state: DaemonMessage = { + type: "state", + daemon: { pid: 123, startedAt: "2026-06-11T10:00:00.000Z" }, + profiles: [{ name: "prod", status: "valid", expiresAt: "2026-06-11T12:00:00.000Z", favorite: true }], + }; + expect(dec.push(encode(state))).toEqual([state]); +}); diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts new file mode 100644 index 0000000..fe4644c --- /dev/null +++ b/src/daemon/protocol.ts @@ -0,0 +1,48 @@ +export type ProfileStatusKind = "valid" | "expired" | "needs-login" | "error" | "refreshing"; + +export interface ProfileState { + name: string; + status: ProfileStatusKind; + expiresAt: string | null; // ISO string or null + favorite: boolean; + accountId?: string; + error?: string; +} + +export interface DaemonInfo { + pid: number; + startedAt: string; // ISO string +} + +export type ClientMessage = + | { type: "subscribe" } + | { type: "snapshot" } + | { type: "refresh"; profile?: string } + | { type: "setFavorite"; profile: string; value: boolean } + | { type: "stop" }; + +export type DaemonMessage = + | { type: "state"; daemon: DaemonInfo; profiles: ProfileState[] } + | { type: "error"; message: string }; + +export function encode(msg: ClientMessage | DaemonMessage): string { + return JSON.stringify(msg) + "\n"; +} + +/** Stateful newline-delimited JSON decoder. Call push() with each chunk. */ +export function decodeStream() { + let buffer = ""; + return { + push(chunk: string): T[] { + buffer += chunk; + const out: T[] = []; + let idx: number; + while ((idx = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + if (line.trim().length > 0) out.push(JSON.parse(line) as T); + } + return out; + }, + }; +} From b496390e145e2fcce16178a028008a3682157348 Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 14:44:00 +0200 Subject: [PATCH 07/29] feat(cli): add daemon runtime paths and single-instance detection --- src/daemon/lifecycle.test.ts | 46 ++++++++++++++++++++++++++++ src/daemon/lifecycle.ts | 59 ++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 src/daemon/lifecycle.test.ts create mode 100644 src/daemon/lifecycle.ts diff --git a/src/daemon/lifecycle.test.ts b/src/daemon/lifecycle.test.ts new file mode 100644 index 0000000..068e3cb --- /dev/null +++ b/src/daemon/lifecycle.test.ts @@ -0,0 +1,46 @@ +import { test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createServer, type Server } from "node:net"; +import { socketPath, pidPath, isDaemonAlive, writePidFile, readPidFile } from "./lifecycle"; + +let runtime: string; +let prev: string | undefined; + +beforeEach(() => { + prev = process.env.XDG_RUNTIME_DIR; + runtime = mkdtempSync(join(tmpdir(), "ssomatic-rt-")); + process.env.XDG_RUNTIME_DIR = runtime; +}); +afterEach(() => { + process.env.XDG_RUNTIME_DIR = prev; + rmSync(runtime, { recursive: true, force: true }); +}); + +test("socketPath/pidPath live under the runtime dir", () => { + expect(socketPath().startsWith(runtime)).toBe(true); + expect(pidPath().startsWith(runtime)).toBe(true); +}); + +test("isDaemonAlive is false when no socket is listening", async () => { + expect(await isDaemonAlive()).toBe(false); +}); + +test("isDaemonAlive is true when a server is listening on the socket", async () => { + const srv: Server = await new Promise((resolve) => { + const s = createServer(); + s.listen(socketPath(), () => resolve(s)); + }); + try { + expect(await isDaemonAlive()).toBe(true); + } finally { + srv.close(); + } +}); + +test("pid file round-trips", () => { + writePidFile(4242); + expect(existsSync(pidPath())).toBe(true); + expect(readPidFile()).toBe(4242); +}); diff --git a/src/daemon/lifecycle.ts b/src/daemon/lifecycle.ts new file mode 100644 index 0000000..37fd0b6 --- /dev/null +++ b/src/daemon/lifecycle.ts @@ -0,0 +1,59 @@ +import { connect, type Socket } from "node:net"; +import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +function runtimeDir(): string { + const dir = process.env.XDG_RUNTIME_DIR || tmpdir(); + mkdirSync(dir, { recursive: true }); + return dir; +} + +export function socketPath(): string { + return join(runtimeDir(), "ssomatic.sock"); +} + +export function pidPath(): string { + return join(runtimeDir(), "ssomatic.pid"); +} + +/** True if something is actually listening on the socket. */ +export function isDaemonAlive(timeoutMs = 500): Promise { + const path = socketPath(); + if (!existsSync(path)) return Promise.resolve(false); + return new Promise((resolve) => { + const sock: Socket = connect(path); + const done = (alive: boolean) => { + sock.destroy(); + resolve(alive); + }; + sock.once("connect", () => done(true)); + sock.once("error", () => done(false)); + sock.setTimeout(timeoutMs, () => done(false)); + }); +} + +/** Remove a socket file with no live listener so we can rebind. Returns true if reclaimed. */ +export async function reclaimStaleSocket(): Promise { + const path = socketPath(); + if (existsSync(path) && !(await isDaemonAlive())) { + rmSync(path, { force: true }); + return true; + } + return false; +} + +export function writePidFile(pid: number): void { + writeFileSync(pidPath(), String(pid)); +} + +export function readPidFile(): number | null { + const path = pidPath(); + if (!existsSync(path)) return null; + const n = Number(readFileSync(path, "utf8").trim()); + return Number.isFinite(n) ? n : null; +} + +export function clearPidFile(): void { + rmSync(pidPath(), { force: true }); +} From 255a33d29af5e2948adb6092e00a26a2cb7af531 Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 14:45:26 +0200 Subject: [PATCH 08/29] feat(cli): add expiry-aware scheduler decision --- src/daemon/scheduler.test.ts | 24 ++++++++++++++++++++++++ src/daemon/scheduler.ts | 19 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 src/daemon/scheduler.test.ts create mode 100644 src/daemon/scheduler.ts diff --git a/src/daemon/scheduler.test.ts b/src/daemon/scheduler.test.ts new file mode 100644 index 0000000..759d5ad --- /dev/null +++ b/src/daemon/scheduler.test.ts @@ -0,0 +1,24 @@ +import { test, expect } from "bun:test"; +import { decideAction } from "./scheduler"; + +const now = new Date("2026-06-11T12:00:00.000Z"); +const leadMs = 5 * 60 * 1000; + +test("refresh when within lead window of expiry", () => { + const expiresAt = new Date("2026-06-11T12:03:00.000Z"); // 3m left < 5m lead + expect(decideAction({ ssoTokenValid: true, credsExpireAt: expiresAt }, now, leadMs)).toBe("refresh"); +}); + +test("wait when comfortably before lead window", () => { + const expiresAt = new Date("2026-06-11T12:30:00.000Z"); // 30m left + expect(decideAction({ ssoTokenValid: true, credsExpireAt: expiresAt }, now, leadMs)).toBe("wait"); +}); + +test("needs-login when sso token invalid regardless of creds", () => { + const expiresAt = new Date("2026-06-11T12:30:00.000Z"); + expect(decideAction({ ssoTokenValid: false, credsExpireAt: expiresAt }, now, leadMs)).toBe("needs-login"); +}); + +test("refresh when there are no creds yet but sso token is valid", () => { + expect(decideAction({ ssoTokenValid: true, credsExpireAt: null }, now, leadMs)).toBe("refresh"); +}); diff --git a/src/daemon/scheduler.ts b/src/daemon/scheduler.ts new file mode 100644 index 0000000..510f694 --- /dev/null +++ b/src/daemon/scheduler.ts @@ -0,0 +1,19 @@ +export type Action = "refresh" | "wait" | "needs-login"; + +export interface ProfileTiming { + ssoTokenValid: boolean; // is the cached SSO token still valid? + credsExpireAt: Date | null; // when current role creds expire (null = none/unknown) +} + +export function decideAction(timing: ProfileTiming, now: Date, leadMs: number): Action { + if (!timing.ssoTokenValid) return "needs-login"; + if (timing.credsExpireAt === null) return "refresh"; + const msLeft = timing.credsExpireAt.getTime() - now.getTime(); + return msLeft <= leadMs ? "refresh" : "wait"; +} + +/** Milliseconds until the next decision point for a profile (for scheduling the next tick). */ +export function msUntilNextCheck(timing: ProfileTiming, now: Date, leadMs: number): number { + if (!timing.ssoTokenValid || timing.credsExpireAt === null) return 0; + return Math.max(0, timing.credsExpireAt.getTime() - now.getTime() - leadMs); +} From 641d33d59a62b2ed961b2baf74658b04656154a0 Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 14:47:40 +0200 Subject: [PATCH 09/29] feat(cli): add unix-socket daemon server with subscribe/snapshot/refresh --- src/daemon/server.test.ts | 74 +++++++++++++++++++++++++++++ src/daemon/server.ts | 99 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 src/daemon/server.test.ts create mode 100644 src/daemon/server.ts diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts new file mode 100644 index 0000000..c21877f --- /dev/null +++ b/src/daemon/server.test.ts @@ -0,0 +1,74 @@ +import { test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { connect, type Socket } from "node:net"; +import { encode, decodeStream, type DaemonMessage, type ProfileState } from "./protocol"; +import { socketPath } from "./lifecycle"; +import { startServer, type DaemonServer } from "./server"; + +let runtime: string; +let prev: string | undefined; +let server: DaemonServer; + +const fakeProfiles: ProfileState[] = [ + { name: "prod", status: "valid", expiresAt: "2026-06-11T12:00:00.000Z", favorite: true }, +]; + +beforeEach(() => { + prev = process.env.XDG_RUNTIME_DIR; + runtime = mkdtempSync(join(tmpdir(), "ssomatic-srv-")); + process.env.XDG_RUNTIME_DIR = runtime; +}); +afterEach(async () => { + await server?.stop(); + process.env.XDG_RUNTIME_DIR = prev; + rmSync(runtime, { recursive: true, force: true }); +}); + +function readOne(sock: Socket): Promise { + const dec = decodeStream(); + return new Promise((resolve) => { + sock.on("data", (buf) => { + const msgs = dec.push(buf.toString()); + if (msgs.length) resolve(msgs[0]); + }); + }); +} + +test("snapshot returns current state then the client can disconnect", async () => { + server = await startServer({ + startedAtIso: "2026-06-11T10:00:00.000Z", + computeState: async () => fakeProfiles, + refreshProfile: async () => {}, + tickMs: 10_000, + }); + const sock = connect(socketPath()); + await new Promise((r) => sock.once("connect", r)); + const reply = readOne(sock); + sock.write(encode({ type: "snapshot" })); + const msg = await reply; + expect(msg.type).toBe("state"); + if (msg.type === "state") expect(msg.profiles).toEqual(fakeProfiles); + sock.destroy(); +}); + +test("subscribe pushes state on broadcast", async () => { + let current = fakeProfiles; + server = await startServer({ + startedAtIso: "2026-06-11T10:00:00.000Z", + computeState: async () => current, + refreshProfile: async () => {}, + tickMs: 10_000, + }); + const sock = connect(socketPath()); + await new Promise((r) => sock.once("connect", r)); + sock.write(encode({ type: "subscribe" })); + await readOne(sock); // first push on subscribe + current = [{ ...fakeProfiles[0], status: "refreshing" }]; + const next = readOne(sock); + await server.broadcast(); + const msg = await next; + expect(msg.type === "state" && msg.profiles[0].status).toBe("refreshing"); + sock.destroy(); +}); diff --git a/src/daemon/server.ts b/src/daemon/server.ts new file mode 100644 index 0000000..8538542 --- /dev/null +++ b/src/daemon/server.ts @@ -0,0 +1,99 @@ +import { createServer, type Server, type Socket } from "node:net"; +import { + encode, + decodeStream, + type ClientMessage, + type DaemonMessage, + type ProfileState, + type DaemonInfo, +} from "./protocol"; +import { socketPath, reclaimStaleSocket, writePidFile, clearPidFile } from "./lifecycle"; + +export interface ServerDeps { + computeState: () => Promise; + refreshProfile: (name: string) => Promise; + setFavorite?: (name: string, value: boolean) => Promise | void; + tickMs?: number; + startedAtIso: string; +} + +export interface DaemonServer { + broadcast: () => Promise; + stop: () => Promise; +} + +export async function startServer(deps: ServerDeps): Promise { + await reclaimStaleSocket(); + const subscribers = new Set(); + let state: ProfileState[] = []; + let stopped = false; + const info: DaemonInfo = { pid: process.pid, startedAt: deps.startedAtIso }; + + async function refreshState(): Promise { + state = await deps.computeState(); + } + + async function broadcast(): Promise { + await refreshState(); + const line = encode({ type: "state", daemon: info, profiles: state } satisfies DaemonMessage); + for (const sock of subscribers) sock.write(line); + } + + async function handle(sock: Socket, msg: ClientMessage): Promise { + switch (msg.type) { + case "subscribe": { + subscribers.add(sock); + await refreshState(); + sock.write(encode({ type: "state", daemon: info, profiles: state } satisfies DaemonMessage)); + break; + } + case "snapshot": { + await refreshState(); + sock.write(encode({ type: "state", daemon: info, profiles: state } satisfies DaemonMessage)); + break; + } + case "refresh": { + const targets = msg.profile ? [msg.profile] : state.filter((p) => p.favorite).map((p) => p.name); + for (const name of targets) await deps.refreshProfile(name); + await broadcast(); + break; + } + case "setFavorite": { + await deps.setFavorite?.(msg.profile, msg.value); + await broadcast(); + break; + } + case "stop": { + await stop(); + break; + } + } + } + + const netServer: Server = createServer((sock) => { + const dec = decodeStream(); + sock.on("data", (buf) => { + for (const msg of dec.push(buf.toString())) void handle(sock, msg); + }); + sock.on("close", () => subscribers.delete(sock)); + sock.on("error", () => subscribers.delete(sock)); + }); + + await new Promise((resolve) => netServer.listen(socketPath(), resolve)); + writePidFile(process.pid); + + const interval = setInterval(() => void broadcast(), deps.tickMs ?? 60_000); + + async function stop(): Promise { + if (stopped) return; + stopped = true; + clearInterval(interval); + for (const sock of subscribers) sock.destroy(); + subscribers.clear(); + await new Promise((resolve) => netServer.close(() => resolve())); + clearPidFile(); + } + + await refreshState(); + return { broadcast, stop }; +} From b8a23f858a628a5c350343bf5b8cd6019b78a6f9 Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 14:53:46 +0200 Subject: [PATCH 10/29] fix(cli): destroy all daemon connections on stop and surface handler errors --- src/daemon/server.test.ts | 19 +++++++++++++++++++ src/daemon/server.ts | 21 +++++++++++++++++---- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index c21877f..8e9cdcc 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -72,3 +72,22 @@ test("subscribe pushes state on broadcast", async () => { expect(msg.type === "state" && msg.profiles[0].status).toBe("refreshing"); sock.destroy(); }); + +test("stop() resolves even with a lingering non-subscriber connection", async () => { + server = await startServer({ + startedAtIso: "2026-06-11T10:00:00.000Z", + computeState: async () => fakeProfiles, + refreshProfile: async () => {}, + tickMs: 10_000, + }); + const sock = connect(socketPath()); + await new Promise((r) => sock.once("connect", r)); + const reply = readOne(sock); + sock.write(encode({ type: "snapshot" })); + await reply; // got snapshot; deliberately do NOT destroy sock + await Promise.race([ + server.stop(), + new Promise((_, rej) => setTimeout(() => rej(new Error("stop() hung")), 2000)), + ]); + sock.destroy(); +}); diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 8538542..df96e85 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -24,6 +24,7 @@ export interface DaemonServer { export async function startServer(deps: ServerDeps): Promise { await reclaimStaleSocket(); + const connections = new Set(); const subscribers = new Set(); let state: ProfileState[] = []; let stopped = false; @@ -71,12 +72,23 @@ export async function startServer(deps: ServerDeps): Promise { } const netServer: Server = createServer((sock) => { + connections.add(sock); const dec = decodeStream(); sock.on("data", (buf) => { - for (const msg of dec.push(buf.toString())) void handle(sock, msg); + for (const msg of dec.push(buf.toString())) { + handle(sock, msg).catch((err) => { + if (!sock.destroyed) sock.write(encode({ type: "error", message: String(err) })); + }); + } + }); + sock.on("close", () => { + connections.delete(sock); + subscribers.delete(sock); + }); + sock.on("error", () => { + connections.delete(sock); + subscribers.delete(sock); }); - sock.on("close", () => subscribers.delete(sock)); - sock.on("error", () => subscribers.delete(sock)); }); await new Promise((resolve) => netServer.listen(socketPath(), resolve)); @@ -88,7 +100,8 @@ export async function startServer(deps: ServerDeps): Promise { if (stopped) return; stopped = true; clearInterval(interval); - for (const sock of subscribers) sock.destroy(); + for (const sock of connections) sock.destroy(); + connections.clear(); subscribers.clear(); await new Promise((resolve) => netServer.close(() => resolve())); clearPidFile(); From 5b4bfe945c6b4687cf376fbfdaa93cae5195e98b Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 14:56:37 +0200 Subject: [PATCH 11/29] feat(cli): add daemon entry, detached spawn, and socket client --- src/daemon/client.ts | 42 ++++++++++++++ src/daemon/index.ts | 127 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 src/daemon/client.ts create mode 100644 src/daemon/index.ts diff --git a/src/daemon/client.ts b/src/daemon/client.ts new file mode 100644 index 0000000..1ec727b --- /dev/null +++ b/src/daemon/client.ts @@ -0,0 +1,42 @@ +import { connect, type Socket } from "node:net"; +import { encode, decodeStream, type ClientMessage, type DaemonMessage } from "./protocol"; +import { socketPath, isDaemonAlive } from "./lifecycle"; + +export { isDaemonAlive }; + +/** Connect, send one message, resolve with the first reply, then close. */ +export async function request(msg: ClientMessage, timeoutMs = 3000): Promise { + return new Promise((resolve, reject) => { + const sock: Socket = connect(socketPath()); + const dec = decodeStream(); + const timer = setTimeout(() => { + sock.destroy(); + reject(new Error("daemon request timed out")); + }, timeoutMs); + sock.once("connect", () => sock.write(encode(msg))); + sock.on("data", (buf) => { + const msgs = dec.push(buf.toString()); + if (msgs.length) { + clearTimeout(timer); + sock.destroy(); + resolve(msgs[0]); + } + }); + sock.once("error", (err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +/** Open a subscription; call onState for every pushed message until stop() is called. */ +export function subscribe(onState: (msg: DaemonMessage) => void): { stop: () => void } { + const sock: Socket = connect(socketPath()); + const dec = decodeStream(); + sock.once("connect", () => sock.write(encode({ type: "subscribe" }))); + sock.on("data", (buf) => { + for (const msg of dec.push(buf.toString())) onState(msg); + }); + sock.on("error", () => {}); + return { stop: () => sock.destroy() }; +} diff --git a/src/daemon/index.ts b/src/daemon/index.ts new file mode 100644 index 0000000..1259d5d --- /dev/null +++ b/src/daemon/index.ts @@ -0,0 +1,127 @@ +import { spawn } from "node:child_process"; +import { openSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { startServer } from "./server"; +import { isDaemonAlive } from "./lifecycle"; +import { decideAction } from "./scheduler"; +import { + discoverProfiles, + findCachedToken, + refreshProfile as coreRefresh, + sendNotification, +} from "../aws/sso"; +import { loadSettings, saveSettings } from "../aws/settings"; +import type { ProfileState, ProfileStatusKind } from "./protocol"; + +function logPath(): string { + const dir = join(homedir(), ".aws", "ssomatic"); + mkdirSync(dir, { recursive: true }); + return join(dir, "daemon.log"); +} + +const notified = new Set(); + +function maybeNotify(enabled: boolean, profile: string): void { + if (!enabled || notified.has(profile)) return; + notified.add(profile); + void sendNotification("SSOmatic", `${profile} needs login`); +} + +/** + * Build current ProfileState[] from disk. + * For each favorite, decide + perform a silent refresh when due. + * + * NOTE: checkTokenStatus() only exposes the SSO token expiry, not role-credential + * expiry (which is not cached on disk). We therefore pass credsExpireAt=null to + * the scheduler, which causes it to always return "refresh" for profiles with a + * valid SSO token — the intended behaviour so the daemon keeps role creds fresh. + */ +async function computeState(): Promise { + const settings = loadSettings(); + const leadMs = settings.refreshLeadMinutes * 60 * 1000; + const favorites = new Set(settings.favoriteProfiles); + const now = new Date(); + const states: ProfileState[] = []; + + for (const p of await discoverProfiles()) { + // Determine SSO token validity directly from the cache file. + const cachedToken = await findCachedToken(p); + const ssoTokenValid = cachedToken !== null && cachedToken.expiresAt > now; + + // Role-credential expiry is not persisted on disk; pass null so the scheduler + // always triggers a refresh while the SSO token is valid. + const credsExpireAt: Date | null = null; + + const favorite = favorites.has(p.name); + let status: ProfileStatusKind = ssoTokenValid ? "valid" : "needs-login"; + + if (favorite) { + const action = decideAction({ ssoTokenValid, credsExpireAt }, now, leadMs); + + if (action === "refresh") { + const r = await coreRefresh(p); + if (r.success) { + notified.delete(p.name); + status = "valid"; + } else if (r.needsLogin) { + status = "needs-login"; + maybeNotify(settings.notifications, p.name); + } else { + status = "error"; + } + } else if (action === "needs-login") { + status = "needs-login"; + maybeNotify(settings.notifications, p.name); + } + // action === "wait" → keep status derived from ssoTokenValid above + } + + states.push({ + name: p.name, + status, + expiresAt: cachedToken ? cachedToken.expiresAt.toISOString() : null, + favorite, + accountId: p.ssoAccountId, + }); + } + + return states; +} + +export async function runDaemon(): Promise { + const startedAtIso = new Date().toISOString(); + const server = await startServer({ + startedAtIso, + tickMs: 30_000, + computeState, + refreshProfile: async (name) => { + const profiles = await discoverProfiles(); + const p = profiles.find((x) => x.name === name); + if (p) await coreRefresh(p); + }, + setFavorite: (name, value) => { + const s = loadSettings(); + const set = new Set(s.favoriteProfiles); + if (value) set.add(name); + else set.delete(name); + saveSettings({ ...s, favoriteProfiles: [...set] }); + }, + }); + + process.on("SIGTERM", () => void server.stop().then(() => process.exit(0))); + process.on("SIGINT", () => void server.stop().then(() => process.exit(0))); +} + +/** Spawn a detached daemon process running ` __daemon`, return immediately. */ +export async function spawnDetached(): Promise { + if (await isDaemonAlive()) return; + const out = openSync(logPath(), "a"); + const child = spawn(process.execPath, [process.argv[1], "__daemon"], { + detached: true, + stdio: ["ignore", out, out], + }); + child.unref(); + // Brief pause so the daemon has time to bind the socket before the caller checks it. + await new Promise((r) => setTimeout(r, 300)); +} From a105d8e2711f6ed2d31efa3a1f8bc20243b23724 Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 15:00:06 +0200 Subject: [PATCH 12/29] fix(cli): make daemon refresh expiry-aware via role-credential expiry --- src/aws/sso.ts | 4 ++-- src/daemon/index.ts | 35 ++++++++++++++++++++++++++--------- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/aws/sso.ts b/src/aws/sso.ts index 75ebcb5..ed9f04f 100644 --- a/src/aws/sso.ts +++ b/src/aws/sso.ts @@ -359,7 +359,7 @@ export async function performSSOLoginFlow( export async function refreshProfile( profile: SSOProfile -): Promise<{ success: boolean; error?: string; needsLogin?: boolean }> { +): Promise<{ success: boolean; error?: string; needsLogin?: boolean; expiresAt?: Date }> { const cachedToken = await findCachedToken(profile); if (!cachedToken || cachedToken.expiresAt <= new Date()) { return { success: false, needsLogin: true }; @@ -371,7 +371,7 @@ export async function refreshProfile( } await writeCredentials(profile.name, credentials); - return { success: true }; + return { success: true, expiresAt: credentials.expiration }; } // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 1259d5d..8ee53f1 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -21,6 +21,7 @@ function logPath(): string { } const notified = new Set(); +const credExpiry = new Map(); function maybeNotify(enabled: boolean, profile: string): void { if (!enabled || notified.has(profile)) return; @@ -32,10 +33,11 @@ function maybeNotify(enabled: boolean, profile: string): void { * Build current ProfileState[] from disk. * For each favorite, decide + perform a silent refresh when due. * - * NOTE: checkTokenStatus() only exposes the SSO token expiry, not role-credential - * expiry (which is not cached on disk). We therefore pass credsExpireAt=null to - * the scheduler, which causes it to always return "refresh" for profiles with a - * valid SSO token — the intended behaviour so the daemon keeps role creds fresh. + * Role-credential expiry is tracked in `credExpiry` (populated after each + * successful refresh). On daemon start the map is empty, so every favorite + * refreshes once; afterward `decideAction` returns "wait" until within + * `refreshLeadMinutes` of the role-cred expiry, making computeState a cheap + * read on normal snapshots/subscribes. */ async function computeState(): Promise { const settings = loadSettings(); @@ -49,9 +51,8 @@ async function computeState(): Promise { const cachedToken = await findCachedToken(p); const ssoTokenValid = cachedToken !== null && cachedToken.expiresAt > now; - // Role-credential expiry is not persisted on disk; pass null so the scheduler - // always triggers a refresh while the SSO token is valid. - const credsExpireAt: Date | null = null; + // Use tracked role-cred expiry when available; null triggers a refresh. + const credsExpireAt: Date | null = credExpiry.get(p.name) ?? null; const favorite = favorites.has(p.name); let status: ProfileStatusKind = ssoTokenValid ? "valid" : "needs-login"; @@ -64,23 +65,33 @@ async function computeState(): Promise { if (r.success) { notified.delete(p.name); status = "valid"; + if (r.expiresAt) credExpiry.set(p.name, r.expiresAt); } else if (r.needsLogin) { status = "needs-login"; + credExpiry.delete(p.name); maybeNotify(settings.notifications, p.name); } else { status = "error"; } } else if (action === "needs-login") { status = "needs-login"; + credExpiry.delete(p.name); maybeNotify(settings.notifications, p.name); } // action === "wait" → keep status derived from ssoTokenValid above } + // Prefer role-cred expiry for the expiresAt field when known; fall back to + // the cached SSO-token expiry. + const trackedExpiry = credExpiry.get(p.name); states.push({ name: p.name, status, - expiresAt: cachedToken ? cachedToken.expiresAt.toISOString() : null, + expiresAt: trackedExpiry + ? trackedExpiry.toISOString() + : cachedToken + ? cachedToken.expiresAt.toISOString() + : null, favorite, accountId: p.ssoAccountId, }); @@ -98,7 +109,13 @@ export async function runDaemon(): Promise { refreshProfile: async (name) => { const profiles = await discoverProfiles(); const p = profiles.find((x) => x.name === name); - if (p) await coreRefresh(p); + if (!p) return; + const r = await coreRefresh(p); + if (r.success && r.expiresAt) { + credExpiry.set(name, r.expiresAt); + } else if (r.needsLogin) { + credExpiry.delete(name); + } }, setFavorite: (name, value) => { const s = loadSettings(); From 6b864c6b79bbb8eaaf42e82b1257524e0a6081b2 Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 15:06:47 +0200 Subject: [PATCH 13/29] fix(cli): close daemon log fd, guard cred expiry fallback, reset notify on manual refresh --- src/daemon/index.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 8ee53f1..0897554 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { openSync, mkdirSync } from "node:fs"; +import { openSync, closeSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { startServer } from "./server"; @@ -65,7 +65,7 @@ async function computeState(): Promise { if (r.success) { notified.delete(p.name); status = "valid"; - if (r.expiresAt) credExpiry.set(p.name, r.expiresAt); + credExpiry.set(p.name, r.expiresAt ?? new Date(Date.now() + 50 * 60 * 1000)); } else if (r.needsLogin) { status = "needs-login"; credExpiry.delete(p.name); @@ -111,8 +111,9 @@ export async function runDaemon(): Promise { const p = profiles.find((x) => x.name === name); if (!p) return; const r = await coreRefresh(p); - if (r.success && r.expiresAt) { - credExpiry.set(name, r.expiresAt); + if (r.success) { + notified.delete(name); + credExpiry.set(name, r.expiresAt ?? new Date(Date.now() + 50 * 60 * 1000)); } else if (r.needsLogin) { credExpiry.delete(name); } @@ -139,6 +140,7 @@ export async function spawnDetached(): Promise { stdio: ["ignore", out, out], }); child.unref(); + closeSync(out); // Brief pause so the daemon has time to bind the socket before the caller checks it. await new Promise((r) => setTimeout(r, 300)); } From fd1d26213fa83d3887ca97d9f110f57d379dc593 Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 15:10:11 +0200 Subject: [PATCH 14/29] feat(cli): add status, export, refresh, and daemon subcommands --- src/aws/sso.ts | 19 ++++++++++++ src/cli/commands/daemon.ts | 48 ++++++++++++++++++++++++++++ src/cli/commands/export.ts | 28 +++++++++++++++++ src/cli/commands/refresh.ts | 55 +++++++++++++++++++++++++++++++++ src/cli/commands/status.test.ts | 17 ++++++++++ src/cli/commands/status.ts | 54 ++++++++++++++++++++++++++++++++ 6 files changed, 221 insertions(+) create mode 100644 src/cli/commands/daemon.ts create mode 100644 src/cli/commands/export.ts create mode 100644 src/cli/commands/refresh.ts create mode 100644 src/cli/commands/status.test.ts create mode 100644 src/cli/commands/status.ts diff --git a/src/aws/sso.ts b/src/aws/sso.ts index ed9f04f..62637ba 100644 --- a/src/aws/sso.ts +++ b/src/aws/sso.ts @@ -12,6 +12,7 @@ import { import { SSOClient, GetRoleCredentialsCommand } from "@aws-sdk/client-sso"; import { parse as parseIni, stringify as stringifyIni } from "ini"; import { readFile, writeFile, mkdir, chmod } from "node:fs/promises"; +import { readFileSync } from "node:fs"; import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; @@ -374,6 +375,24 @@ export async function refreshProfile( return { success: true, expiresAt: credentials.expiration }; } +export function readProfileCredentials( + profileName: string +): { accessKeyId: string; secretAccessKey: string; sessionToken: string } | null { + try { + const content = readFileSync(CREDENTIALS_PATH, "utf8"); + const parsed = parseIni(content) as ParsedConfig; + const section = parsed[profileName]; + if (!section) return null; + const accessKeyId = section.aws_access_key_id; + const secretAccessKey = section.aws_secret_access_key; + const sessionToken = section.aws_session_token; + if (!accessKeyId || !secretAccessKey || !sessionToken) return null; + return { accessKeyId, secretAccessKey, sessionToken }; + } catch { + return null; + } +} + // ───────────────────────────────────────────────────────────────────────────── // Notifications // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts new file mode 100644 index 0000000..0cfa3e5 --- /dev/null +++ b/src/cli/commands/daemon.ts @@ -0,0 +1,48 @@ +import { spawnDetached } from "../../daemon/index"; +import { request, isDaemonAlive } from "../../daemon/client"; +import { readPidFile } from "../../daemon/lifecycle"; + +export async function runDaemonCommand(sub: string | undefined): Promise { + switch (sub) { + case "start": { + if (await isDaemonAlive()) { + process.stdout.write("daemon already running\n"); + return 0; + } + await spawnDetached(); + process.stdout.write( + (await isDaemonAlive()) ? "daemon started\n" : "failed to start daemon (see ~/.aws/ssomatic/daemon.log)\n" + ); + return 0; + } + case "stop": { + if (!(await isDaemonAlive())) { + process.stdout.write("daemon not running\n"); + return 0; + } + await request({ type: "stop" }).catch(() => {}); + process.stdout.write("daemon stopped\n"); + return 0; + } + case "status": + case undefined: { + if (!(await isDaemonAlive())) { + process.stdout.write("daemon: stopped\n"); + return 0; + } + const msg = await request({ type: "snapshot" }); + const pid = readPidFile(); + const watched = + msg.type === "state" + ? msg.profiles.filter((p) => p.favorite).map((p) => p.name) + : []; + process.stdout.write( + `daemon: running (pid ${pid ?? "?"})\nwatching: ${watched.join(", ") || "(none)"}\n` + ); + return 0; + } + default: + process.stderr.write(`unknown daemon subcommand: ${sub}\n`); + return 1; + } +} diff --git a/src/cli/commands/export.ts b/src/cli/commands/export.ts new file mode 100644 index 0000000..10b3299 --- /dev/null +++ b/src/cli/commands/export.ts @@ -0,0 +1,28 @@ +import { discoverProfiles, refreshProfile, readProfileCredentials } from "../../aws/sso"; +import { buildExportBlock } from "../../aws/console"; + +export async function runExport(profileName: string): Promise { + const profile = (await discoverProfiles()).find((p) => p.name === profileName); + if (!profile) { + process.stderr.write(`unknown profile: ${profileName}\n`); + return 1; + } + const result = await refreshProfile(profile); + if (!result.success) { + process.stderr.write( + `cannot export ${profileName}: ${ + result.needsLogin + ? `needs login (run: ssomatic refresh ${profileName})` + : result.error + }\n` + ); + return 1; + } + const creds = readProfileCredentials(profileName); + if (!creds) { + process.stderr.write(`no credentials found for ${profileName}\n`); + return 1; + } + process.stdout.write(buildExportBlock(creds) + "\n"); + return 0; +} diff --git a/src/cli/commands/refresh.ts b/src/cli/commands/refresh.ts new file mode 100644 index 0000000..54f1937 --- /dev/null +++ b/src/cli/commands/refresh.ts @@ -0,0 +1,55 @@ +import { + discoverProfiles, + refreshProfile, + startDeviceAuthorization, + performSSOLoginFlow, + openBrowser, +} from "../../aws/sso"; +import { loadSettings } from "../../aws/settings"; + +async function refreshOne(name: string): Promise { + const profile = (await discoverProfiles()).find((p) => p.name === name); + if (!profile) { + process.stderr.write(`unknown profile: ${name}\n`); + return false; + } + const result = await refreshProfile(profile); + if (result.success) { + process.stdout.write(`✓ ${name} refreshed\n`); + return true; + } + if (result.needsLogin) { + process.stdout.write(`${name} needs login — starting device authorization…\n`); + const deviceAuth = await startDeviceAuthorization(profile); + if (!deviceAuth) { + process.stderr.write(`✗ ${name}: failed to start device authorization\n`); + return false; + } + process.stdout.write(`\nOpen this URL in your browser to authenticate:\n`); + process.stdout.write(` ${deviceAuth.verificationUri}\n`); + process.stdout.write(`\nEnter this code when prompted:\n`); + process.stdout.write(` ${deviceAuth.userCode}\n\n`); + openBrowser(deviceAuth.verificationUri); + process.stdout.write(`Waiting for authorization…\n`); + const r = await performSSOLoginFlow(profile, deviceAuth); + if (r.success) { + process.stdout.write(`✓ ${name} logged in and refreshed\n`); + return true; + } + process.stderr.write(`✗ ${name}: ${r.error}\n`); + return false; + } + process.stderr.write(`✗ ${name}: ${result.error}\n`); + return false; +} + +export async function runRefresh(profileArg?: string): Promise { + const targets = profileArg ? [profileArg] : loadSettings().favoriteProfiles; + if (targets.length === 0) { + process.stderr.write("no profile specified and no favorites configured\n"); + return 1; + } + let ok = true; + for (const name of targets) ok = (await refreshOne(name)) && ok; + return ok ? 0 : 1; +} diff --git a/src/cli/commands/status.test.ts b/src/cli/commands/status.test.ts new file mode 100644 index 0000000..a69da57 --- /dev/null +++ b/src/cli/commands/status.test.ts @@ -0,0 +1,17 @@ +import { test, expect } from "bun:test"; +import { formatStatusTable } from "./status"; +import type { ProfileState } from "../../daemon/protocol"; + +test("formatStatusTable renders aligned rows", () => { + const rows: ProfileState[] = [ + { name: "prod", status: "valid", expiresAt: "2026-06-11T13:00:00.000Z", favorite: true }, + { name: "staging", status: "needs-login", expiresAt: null, favorite: false }, + ]; + const out = formatStatusTable(rows, new Date("2026-06-11T12:00:00.000Z")); + const lines = out.split("\n"); + expect(lines[0]).toContain("prod"); + expect(lines[0]).toContain("valid"); + expect(lines[0]).toContain("60m"); + expect(lines[1]).toContain("staging"); + expect(lines[1]).toContain("needs-login"); +}); diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts new file mode 100644 index 0000000..64f5d1e --- /dev/null +++ b/src/cli/commands/status.ts @@ -0,0 +1,54 @@ +import { request, isDaemonAlive } from "../../daemon/client"; +import type { ProfileState } from "../../daemon/protocol"; +import { discoverProfiles, findCachedToken } from "../../aws/sso"; +import { loadSettings } from "../../aws/settings"; + +function minsLeft(expiresAt: string | null, now: Date): string { + if (!expiresAt) return "—"; + const m = Math.round((new Date(expiresAt).getTime() - now.getTime()) / 60000); + return m <= 0 ? "expired" : `${m}m`; +} + +export function formatStatusTable(rows: ProfileState[], now: Date): string { + const nameW = Math.max(7, ...rows.map((r) => r.name.length)); + const statusW = Math.max(6, ...rows.map((r) => r.status.length)); + return rows + .map((r) => { + const star = r.favorite ? "★ " : " "; + return `${star}${r.name.padEnd(nameW)} ${r.status.padEnd(statusW)} ${minsLeft(r.expiresAt, now)}`; + }) + .join("\n"); +} + +async function localState(): Promise { + const favorites = new Set(loadSettings().favoriteProfiles); + const now = new Date(); + const profiles = await discoverProfiles(); + const states: ProfileState[] = []; + for (const p of profiles) { + const cached = await findCachedToken(p); + const ssoValid = cached !== null && cached.expiresAt > now; + const expiresAt = cached ? cached.expiresAt.toISOString() : null; + states.push({ + name: p.name, + status: ssoValid ? "valid" : "needs-login", + expiresAt, + favorite: favorites.has(p.name), + accountId: p.ssoAccountId, + }); + } + return states; +} + +export async function runStatus(): Promise { + const now = new Date(); + let rows: ProfileState[]; + if (await isDaemonAlive()) { + const msg = await request({ type: "snapshot" }); + rows = msg.type === "state" ? msg.profiles : []; + } else { + rows = await localState(); + } + process.stdout.write(formatStatusTable(rows, now) + "\n"); + return 0; +} From 6f948006486b47869618038fb2ca587523e5b9d3 Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 15:14:15 +0200 Subject: [PATCH 15/29] feat(cli): route argv to subcommands, daemon, or TUI --- src/cli/args.test.ts | 20 +++++++++++++++ src/cli/args.ts | 27 ++++++++++++++++++++ src/cli/index.tsx | 60 +++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 src/cli/args.test.ts create mode 100644 src/cli/args.ts diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts new file mode 100644 index 0000000..04af96d --- /dev/null +++ b/src/cli/args.test.ts @@ -0,0 +1,20 @@ +import { test, expect } from "bun:test"; +import { parseArgs } from "./args"; + +test("no args → tui", () => { expect(parseArgs([])).toEqual({ kind: "tui", daemon: false }); }); +test("--daemon flag → tui with daemon", () => { expect(parseArgs(["--daemon"])).toEqual({ kind: "tui", daemon: true }); }); +test("--version → version", () => { + expect(parseArgs(["--version"])).toEqual({ kind: "version" }); + expect(parseArgs(["-v"])).toEqual({ kind: "version" }); +}); +test("status subcommand", () => { expect(parseArgs(["status"])).toEqual({ kind: "status" }); }); +test("export requires a profile", () => { expect(parseArgs(["export", "prod"])).toEqual({ kind: "export", profile: "prod" }); }); +test("refresh optional profile", () => { + expect(parseArgs(["refresh"])).toEqual({ kind: "refresh", profile: undefined }); + expect(parseArgs(["refresh", "dev"])).toEqual({ kind: "refresh", profile: "dev" }); +}); +test("daemon subcommands", () => { + expect(parseArgs(["daemon", "start"])).toEqual({ kind: "daemon", sub: "start" }); + expect(parseArgs(["daemon"])).toEqual({ kind: "daemon", sub: undefined }); +}); +test("internal __daemon command", () => { expect(parseArgs(["__daemon"])).toEqual({ kind: "__daemon" }); }); diff --git a/src/cli/args.ts b/src/cli/args.ts new file mode 100644 index 0000000..de47d54 --- /dev/null +++ b/src/cli/args.ts @@ -0,0 +1,27 @@ +export type ParsedArgs = + | { kind: "tui"; daemon: boolean } + | { kind: "version" } + | { kind: "status" } + | { kind: "export"; profile: string } + | { kind: "refresh"; profile?: string } + | { kind: "daemon"; sub?: string } + | { kind: "__daemon" } + | { kind: "help" } + | { kind: "error"; message: string }; + +export function parseArgs(argv: string[]): ParsedArgs { + const [cmd, ...rest] = argv; + if (cmd === undefined) return { kind: "tui", daemon: false }; + if (cmd === "--version" || cmd === "-v") return { kind: "version" }; + if (cmd === "--help" || cmd === "-h" || cmd === "help") return { kind: "help" }; + if (cmd === "--daemon") return { kind: "tui", daemon: true }; + if (cmd === "__daemon") return { kind: "__daemon" }; + if (cmd === "status") return { kind: "status" }; + if (cmd === "refresh") return { kind: "refresh", profile: rest[0] }; + if (cmd === "export") { + if (!rest[0]) return { kind: "error", message: "export requires a profile name" }; + return { kind: "export", profile: rest[0] }; + } + if (cmd === "daemon") return { kind: "daemon", sub: rest[0] }; + return { kind: "error", message: `unknown command: ${cmd}` }; +} diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 5c5dfe9..45773d0 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -5,6 +5,12 @@ import React, { useState, useEffect, useCallback } from "react"; import { Box, Text, useApp, useInput } from "ink"; +import { parseArgs } from "./args.js"; +import { runStatus } from "./commands/status.js"; +import { runExport } from "./commands/export.js"; +import { runRefresh } from "./commands/refresh.js"; +import { runDaemonCommand } from "./commands/daemon.js"; +import { runDaemon } from "../daemon/index.js"; import { App, renderApp, @@ -904,9 +910,55 @@ function SSOmatic() { // Entry Point // ───────────────────────────────────────────────────────────────────────────── -if (process.argv.includes("--version") || process.argv.includes("-v")) { - console.log(`ssomatic v${VERSION}`); - process.exit(0); +const HELP = `ssomatic — interactive AWS SSO credential manager + +Usage: + ssomatic launch the interactive TUI + ssomatic --daemon launch the TUI and start the background daemon + ssomatic status print profile statuses and exit + ssomatic refresh [name] refresh a profile (or all favorites) now + ssomatic export print export AWS_* lines for eval $(...) + ssomatic daemon start|stop|status + ssomatic --version +`; + +function launchTui(_startDaemon: boolean): void { + // _startDaemon is intentionally unused: daemon flag wiring comes in Task 12. + renderApp(); } -renderApp(); +async function main(): Promise { + const parsed = parseArgs(process.argv.slice(2)); + switch (parsed.kind) { + case "version": + process.stdout.write(`ssomatic v${VERSION}\n`); + return; + case "help": + process.stdout.write(HELP); + return; + case "status": + process.exit(await runStatus()); + return; + case "export": + process.exit(await runExport(parsed.profile)); + return; + case "refresh": + process.exit(await runRefresh(parsed.profile)); + return; + case "daemon": + process.exit(await runDaemonCommand(parsed.sub)); + return; + case "__daemon": + await runDaemon(); // long-lived; do not exit + return; + case "error": + process.stderr.write(parsed.message + "\n"); + process.exit(1); + return; + case "tui": + launchTui(parsed.daemon); + return; + } +} + +void main(); From 9547310da881fb97bb2b765f26af1bac0ade24fb Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 15:16:41 +0200 Subject: [PATCH 16/29] feat(cli): add useDaemon hook for live socket state --- src/cli/tui/useDaemon.ts | 62 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/cli/tui/useDaemon.ts diff --git a/src/cli/tui/useDaemon.ts b/src/cli/tui/useDaemon.ts new file mode 100644 index 0000000..b551820 --- /dev/null +++ b/src/cli/tui/useDaemon.ts @@ -0,0 +1,62 @@ +import { useEffect, useState, useCallback, useRef } from "react"; +import { subscribe, request, isDaemonAlive } from "../../daemon/client.js"; +import { spawnDetached } from "../../daemon/index.js"; +import type { ProfileState, DaemonInfo, DaemonMessage } from "../../daemon/protocol.js"; + +export interface DaemonView { + running: boolean; + info: DaemonInfo | null; + profiles: ProfileState[]; + startBackground: () => Promise; + refresh: (profile?: string) => Promise; + setFavorite: (profile: string, value: boolean) => Promise; +} + +export function useDaemon(localProfiles: ProfileState[]): DaemonView { + const [running, setRunning] = useState(false); + const [info, setInfo] = useState(null); + const [profiles, setProfiles] = useState(localProfiles); + const subRef = useRef<{ stop: () => void } | null>(null); + + const attach = useCallback(() => { + subRef.current?.stop(); + subRef.current = subscribe((msg: DaemonMessage) => { + if (msg.type === "state") { + setInfo(msg.daemon); + setProfiles(msg.profiles); + } + }); + }, []); + + useEffect(() => { + let cancelled = false; + void (async () => { + const alive = await isDaemonAlive(); + if (cancelled) return; + setRunning(alive); + if (alive) attach(); + })(); + return () => { + cancelled = true; + subRef.current?.stop(); + subRef.current = null; + }; + }, [attach]); + + const startBackground = useCallback(async () => { + await spawnDetached(); + const alive = await isDaemonAlive(); + setRunning(alive); + if (alive) attach(); + }, [attach]); + + const refresh = useCallback(async (profile?: string) => { + if (await isDaemonAlive()) await request({ type: "refresh", profile }); + }, []); + + const setFavorite = useCallback(async (profile: string, value: boolean) => { + if (await isDaemonAlive()) await request({ type: "setFavorite", profile, value }); + }, []); + + return { running, info, profiles, startBackground, refresh, setFavorite }; +} From 5575ff627879e824f7f50b69eb5a4ad0ccbcc2a8 Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 15:18:54 +0200 Subject: [PATCH 17/29] feat(cli): add list-first dashboard component --- src/cli/tui/Dashboard.tsx | 111 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 src/cli/tui/Dashboard.tsx diff --git a/src/cli/tui/Dashboard.tsx b/src/cli/tui/Dashboard.tsx new file mode 100644 index 0000000..482f950 --- /dev/null +++ b/src/cli/tui/Dashboard.tsx @@ -0,0 +1,111 @@ +import React, { useState } from "react"; +import { Box, Text, useInput } from "ink"; +import type { ProfileState, ProfileStatusKind } from "../../daemon/protocol.js"; + +interface Props { + profiles: ProfileState[]; + daemonRunning: boolean; + onRefresh: (names: string[]) => void; + onToggleFavorite: (name: string) => void; + onRunBackground: () => void; + onOpenDetails: (name: string) => void; + onOpenConsole: (name: string) => void; + onCopyExport: (name: string) => void; + onCopyName: (name: string) => void; + onOpenSettings: () => void; + onQuit: () => void; +} + +const STATUS_COLOR: Record = { + valid: "green", + refreshing: "cyan", + expired: "yellow", + "needs-login": "yellow", + error: "red", +}; + +function minsLeft(expiresAt: string | null): string { + if (!expiresAt) return "—"; + const m = Math.round((new Date(expiresAt).getTime() - Date.now()) / 60000); + return m <= 0 ? "expired" : `${m}m`; +} + +export function Dashboard(props: Props) { + const { profiles } = props; + const [cursor, setCursor] = useState(0); + const [selected, setSelected] = useState>(new Set()); + const [filter, setFilter] = useState(""); + const [filtering, setFiltering] = useState(false); + + const visible = filter + ? profiles.filter((p) => p.name.toLowerCase().includes(filter.toLowerCase())) + : profiles; + const current = visible[Math.min(cursor, Math.max(0, visible.length - 1))]; + + useInput((input, key) => { + if (filtering) { + if (key.return || key.escape) setFiltering(false); + else if (key.backspace || key.delete) setFilter((f) => f.slice(0, -1)); + else if (input) setFilter((f) => f + input); + return; + } + if (key.upArrow || input === "k") setCursor((c) => Math.max(0, c - 1)); + else if (key.downArrow || input === "j") + setCursor((c) => Math.min(visible.length - 1, c + 1)); + else if (input === " " && current) { + setSelected((s) => { + const n = new Set(s); + if (n.has(current.name)) n.delete(current.name); + else n.add(current.name); + return n; + }); + } else if (input === "a") { + setSelected((s) => + s.size === visible.length ? new Set() : new Set(visible.map((p) => p.name)), + ); + } else if (input === "r") { + const names = selected.size ? [...selected] : current ? [current.name] : []; + if (names.length) props.onRefresh(names); + } else if (input === "f" && current) props.onToggleFavorite(current.name); + else if (input === "b") props.onRunBackground(); + else if (input === "c" && current) props.onCopyExport(current.name); + else if (input === "y" && current) props.onCopyName(current.name); + else if (input === "o" && current) props.onOpenConsole(current.name); + else if (key.return && current) props.onOpenDetails(current.name); + else if (input === "/") setFiltering(true); + else if (input === "s") props.onOpenSettings(); + else if (input === "q") props.onQuit(); + }); + + return ( + + + 🔐 SSOmatic + {props.daemonRunning ? "daemon ● running" : "daemon ○ off"} + + {"─".repeat(48)} + {filtering && /{filter}} + {visible.length === 0 && (no profiles)} + {visible.map((p) => { + const isCursor = current?.name === p.name; + const isSel = selected.has(p.name); + return ( + + {isCursor ? "▸ " : " "} + {isSel ? "◉ " : " "} + {p.favorite ? "★ " : " "} + {p.name.padEnd(22)} + {p.status.padEnd(12)} + {minsLeft(p.expiresAt).padEnd(8)} + {p.accountId ?? ""} + + ); + })} + {"─".repeat(48)} + ↑↓ move space sel ⏎ details r refresh b bg + + f ★ c copy y name o console / filter s settings q quit + + + ); +} From afc4a7768ff65eeca18614878590090ab513ac53 Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 15:23:55 +0200 Subject: [PATCH 18/29] feat(cli): replace menu UI with list-first dashboard, details, and settings --- src/aws/profileState.ts | 28 ++ src/cli/commands/status.ts | 25 +- src/cli/index.tsx | 988 ++++++++++--------------------------- src/cli/tui/Details.tsx | 35 ++ src/cli/tui/Settings.tsx | 56 +++ 5 files changed, 386 insertions(+), 746 deletions(-) create mode 100644 src/aws/profileState.ts create mode 100644 src/cli/tui/Details.tsx create mode 100644 src/cli/tui/Settings.tsx diff --git a/src/aws/profileState.ts b/src/aws/profileState.ts new file mode 100644 index 0000000..0724979 --- /dev/null +++ b/src/aws/profileState.ts @@ -0,0 +1,28 @@ +import type { ProfileState } from "../daemon/protocol.js"; +import { discoverProfiles, findCachedToken } from "./sso.js"; +import { loadSettings } from "./settings.js"; + +/** + * Build the list of profile states from local disk (config + SSO token cache). + * Shared by the CLI `status` command and the TUI root so there is a single + * source of truth for the "no daemon" fallback view. + */ +export async function buildLocalProfileStates(): Promise { + const favorites = new Set(loadSettings().favoriteProfiles); + const now = new Date(); + const profiles = await discoverProfiles(); + const states: ProfileState[] = []; + for (const p of profiles) { + const cached = await findCachedToken(p); + const ssoValid = cached !== null && cached.expiresAt > now; + const expiresAt = cached ? cached.expiresAt.toISOString() : null; + states.push({ + name: p.name, + status: ssoValid ? "valid" : "needs-login", + expiresAt, + favorite: favorites.has(p.name), + accountId: p.ssoAccountId, + }); + } + return states; +} diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index 64f5d1e..e99919d 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -1,7 +1,6 @@ import { request, isDaemonAlive } from "../../daemon/client"; import type { ProfileState } from "../../daemon/protocol"; -import { discoverProfiles, findCachedToken } from "../../aws/sso"; -import { loadSettings } from "../../aws/settings"; +import { buildLocalProfileStates } from "../../aws/profileState"; function minsLeft(expiresAt: string | null, now: Date): string { if (!expiresAt) return "—"; @@ -20,26 +19,6 @@ export function formatStatusTable(rows: ProfileState[], now: Date): string { .join("\n"); } -async function localState(): Promise { - const favorites = new Set(loadSettings().favoriteProfiles); - const now = new Date(); - const profiles = await discoverProfiles(); - const states: ProfileState[] = []; - for (const p of profiles) { - const cached = await findCachedToken(p); - const ssoValid = cached !== null && cached.expiresAt > now; - const expiresAt = cached ? cached.expiresAt.toISOString() : null; - states.push({ - name: p.name, - status: ssoValid ? "valid" : "needs-login", - expiresAt, - favorite: favorites.has(p.name), - accountId: p.ssoAccountId, - }); - } - return states; -} - export async function runStatus(): Promise { const now = new Date(); let rows: ProfileState[]; @@ -47,7 +26,7 @@ export async function runStatus(): Promise { const msg = await request({ type: "snapshot" }); rows = msg.type === "state" ? msg.profiles : []; } else { - rows = await localState(); + rows = await buildLocalProfileStates(); } process.stdout.write(formatStatusTable(rows, now) + "\n"); return 0; diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 45773d0..e7abbdf 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -3,7 +3,7 @@ * SSOmatic - Interactive TUI for managing AWS SSO credentials */ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Box, Text, useApp, useInput } from "ink"; import { parseArgs } from "./args.js"; import { runStatus } from "./commands/status.js"; @@ -11,127 +11,34 @@ import { runExport } from "./commands/export.js"; import { runRefresh } from "./commands/refresh.js"; import { runDaemonCommand } from "./commands/daemon.js"; import { runDaemon } from "../daemon/index.js"; -import { - App, - renderApp, - List, - Card, - Spinner, - StatusMessage, - MultiSelectList, - ACTIONS, - type ListItemData, - type MultiSelectItemData, -} from "./components/index.js"; +import { App, renderApp, Spinner, StatusMessage, ACTIONS } from "./components/index.js"; import { useCopy } from "./hooks/index.js"; +import { Dashboard } from "./tui/Dashboard.js"; +import { Details } from "./tui/Details.js"; +import { Settings } from "./tui/Settings.js"; +import { useDaemon } from "./tui/useDaemon.js"; +import { buildLocalProfileStates } from "../aws/profileState.js"; import { type SSOProfile, - type ProfileStatus, type DeviceAuthInfo, discoverProfiles, - checkAllProfiles, startDeviceAuthorization, performSSOLoginFlow, refreshProfile, + readProfileCredentials, sendNotification, openBrowser, - formatExpiry, - getStatusColor, - sortByFavorites, } from "../aws/sso.js"; -import { - type AppSettings, - DEFAULT_SETTINGS, - loadSettings, - saveSettings, -} from "../aws/settings.js"; - -// TODO(Task 12/13): remove interval UI; kept as shim until dead code cleanup -const REFRESH_INTERVALS = [ - { value: 15, label: "15 minutes" }, - { value: 30, label: "30 minutes", hint: "recommended" }, - { value: 60, label: "1 hour" }, - { value: 120, label: "2 hours" }, -]; +import { buildExportBlock, getConsoleSigninUrl } from "../aws/console.js"; +import { copyToClipboard } from "../aws/utils.js"; +import { loadSettings, saveSettings, type AppSettings } from "../aws/settings.js"; +import type { ProfileState } from "../daemon/protocol.js"; import { VERSION, checkForUpdate } from "../version.js"; -type ViewState = - | "menu" - | "status" - | "refresh" - | "refresh-select" - | "daemon-select" - | "daemon-interval" - | "daemon-running" - | "settings" - | "settings-interval" - | "settings-favorites"; - -// ───────────────────────────────────────────────────────────────────────────── -// Hook: useProfiles -// ───────────────────────────────────────────────────────────────────────────── - -function useProfiles() { - const [profiles, setProfiles] = useState([]); - const [statuses, setStatuses] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchProfiles = useCallback(async () => { - setLoading(true); - setError(null); - try { - const result = await discoverProfiles(); - setProfiles(result); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to discover profiles"); - } finally { - setLoading(false); - } - }, []); - - const fetchStatuses = useCallback(async () => { - if (profiles.length === 0) return; - setLoading(true); - try { - const result = await checkAllProfiles(profiles); - setStatuses(result); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to check statuses"); - } finally { - setLoading(false); - } - }, [profiles]); - - useEffect(() => { - fetchProfiles(); - }, [fetchProfiles]); - - return { profiles, statuses, loading, error, fetchStatuses }; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Hook: useSettings -// ───────────────────────────────────────────────────────────────────────────── - -function useSettings() { - const [settings, setSettings] = useState(DEFAULT_SETTINGS); - - useEffect(() => { - setSettings(loadSettings()); - }, []); - - const updateSettings = useCallback((newSettings: Partial) => { - const updated = { ...settings, ...newSettings }; - setSettings(updated); - saveSettings(updated); - }, [settings]); - - return { settings, updateSettings }; -} +type ViewState = "dashboard" | "details" | "settings"; // ───────────────────────────────────────────────────────────────────────────── -// Hook: useDeviceAuth +// Hook: useDeviceAuth (reused for interactive login when no daemon is running) // ───────────────────────────────────────────────────────────────────────────── interface UseDeviceAuthOptions { @@ -194,64 +101,17 @@ function useDeviceAuth({ pendingLogin, onLoginComplete, onCopyUrl }: UseDeviceAu } // ───────────────────────────────────────────────────────────────────────────── -// Status Table Component -// ───────────────────────────────────────────────────────────────────────────── - -interface StatusTableProps { - statuses: ProfileStatus[]; - favorites: string[]; -} - -function StatusTable({ statuses, favorites }: StatusTableProps) { - const sorted = sortByFavorites(statuses, favorites, (s) => s.profile.name); - - return ( - - - Profile Status - ({statuses.length} profiles) - - - - {sorted.map((status) => { - const isFavorite = favorites.includes(status.profile.name); - return ( - - - {status.profile.name.padEnd(25)} - - {status.status === "valid" ? "Valid" : status.status === "expired" ? "Expired" : "Error"} - - {status.expiresAt && status.status === "valid" && ( - ({formatExpiry(status.expiresAt)}) - )} - {isFavorite && } - - ); - })} - - - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Shared Login Prompt Component +// Login Prompt Component (shown while an interactive device-auth is pending) // ───────────────────────────────────────────────────────────────────────────── interface LoginPromptProps { profile: SSOProfile; deviceAuth: DeviceAuthInfo | null; - pendingCount?: number; copied?: boolean; authorizing?: boolean; } -function LoginPrompt({ profile, deviceAuth, pendingCount = 0, copied = false, authorizing = false }: LoginPromptProps) { +function LoginPrompt({ profile, deviceAuth, copied = false, authorizing = false }: LoginPromptProps) { if (!deviceAuth) { return ( @@ -264,9 +124,6 @@ function LoginPrompt({ profile, deviceAuth, pendingCount = 0, copied = false, au return ( SSO login required for {profile.name} - {pendingCount > 0 && ( - ({pendingCount} more profile{pendingCount > 1 ? 's' : ''} pending) - )} URL: @@ -275,7 +132,9 @@ function LoginPrompt({ profile, deviceAuth, pendingCount = 0, copied = false, au Code: - {deviceAuth.userCode} + + {deviceAuth.userCode} + @@ -287,621 +146,305 @@ function LoginPrompt({ profile, deviceAuth, pendingCount = 0, copied = false, au } // ───────────────────────────────────────────────────────────────────────────── -// Refresh Progress Component +// Main Component // ───────────────────────────────────────────────────────────────────────────── -interface RefreshProgressProps { - profiles: SSOProfile[]; - settings: AppSettings; - onBack: () => void; +interface SSOmaticProps { + startDaemon?: boolean; } -function RefreshProgress({ profiles, settings, onBack }: RefreshProgressProps) { - const [results, setResults] = useState<{ name: string; success: boolean; error?: string }[]>([]); - const [current, setCurrent] = useState(0); +function SSOmatic({ startDaemon = false }: SSOmaticProps) { + const { exit } = useApp(); + + const [view, setView] = useState("dashboard"); + const [detailName, setDetailName] = useState(null); + const [settings, setSettings] = useState(loadSettings()); + const [localStates, setLocalStates] = useState([]); + const [ssoProfiles, setSSOProfiles] = useState([]); + const [seeding, setSeeding] = useState(true); + const [updateAvailable, setUpdateAvailable] = useState(null); + const [feedback, setFeedback] = useState(null); const [pendingLogin, setPendingLogin] = useState(null); - const handleLoginComplete = useCallback((profile: SSOProfile, result: { success: boolean; error?: string }) => { - setResults((prev) => [...prev, { name: profile.name, success: result.success, error: result.error }]); - setPendingLogin(null); - setCurrent((c) => c + 1); + const daemon = useDaemon(localStates); + const startBackgroundOnceRef = React.useRef(false); + + // Re-read local disk state (after refresh / favorite changes when no daemon). + const reloadLocal = useCallback(async () => { + const states = await buildLocalProfileStates(); + setLocalStates(states); }, []); - const { deviceAuth, authorizing, copied, handleEnter, handleCopy } = useDeviceAuth({ - pendingLogin, - onLoginComplete: handleLoginComplete, - }); + // Seed initial local state + discovered profiles on mount. + useEffect(() => { + let cancelled = false; + void (async () => { + const [states, profiles] = await Promise.all([ + buildLocalProfileStates(), + discoverProfiles(), + ]); + if (cancelled) return; + setLocalStates(states); + setSSOProfiles(profiles); + setSeeding(false); + })(); + return () => { + cancelled = true; + }; + }, []); - useInput((input, key) => { - if (key.return) handleEnter(); - if (input === "c") handleCopy(); - if (key.escape && !authorizing) onBack(); - }); + // Check for updates. + useEffect(() => { + checkForUpdate().then(setUpdateAvailable); + }, []); + // Optionally auto-start the background daemon (once). useEffect(() => { - if (current >= profiles.length) { - // All done - return; + if (startDaemon && !startBackgroundOnceRef.current) { + startBackgroundOnceRef.current = true; + void daemon.startBackground(); } - if (pendingLogin) return; + }, [startDaemon, daemon]); - const profile = profiles[current]; - refreshProfile(profile).then((result) => { - if (result.needsLogin) { - if (settings.notifications) { - sendNotification("SSO Login Required", `Token expired for profile '${profile.name}'`); - } - setPendingLogin(profile); - } else { - setResults((prev) => [...prev, { name: profile.name, success: result.success, error: result.error }]); - setCurrent((c) => c + 1); - } - }); - }, [current, profiles, settings, pendingLogin]); - - const done = current >= profiles.length && !pendingLogin; - const successCount = results.filter((r) => r.success).length; - const errorCount = results.filter((r) => !r.success).length; - - return ( - - - {profiles.map((profile, idx) => { - const result = results.find((r) => r.name === profile.name); - const isPending = pendingLogin?.name === profile.name; - const isCurrent = idx === current && !pendingLogin && !done; - - return ( - - {result ? ( - {result.success ? "✓" : "✗"} - ) : isPending ? ( - - ) : isCurrent ? ( - - ) : ( - - )} - {profile.name} - {result && !result.success && result.error && ( - - {result.error} - )} - {isPending && ( - - Press Enter to login - )} - - ); - })} - - - {done && ( - - 0 ? "warning" : "success"}> - Refreshed {successCount} profile(s){errorCount > 0 ? `, ${errorCount} error(s)` : ""} - - {successCount > 0 && ( - - Profiles: - {results.filter(r => r.success).map(r => r.name).join(", ")} - - )} - - Press b to go back - - - )} - - {pendingLogin && ( - - )} - + const findProfile = useCallback( + (name: string): SSOProfile | undefined => ssoProfiles.find((p) => p.name === name), + [ssoProfiles], ); -} -// ───────────────────────────────────────────────────────────────────────────── -// Daemon Component -// ───────────────────────────────────────────────────────────────────────────── - -interface DaemonViewProps { - profiles: SSOProfile[]; - intervalMinutes: number; - settings: AppSettings; - onStop: () => void; -} + // The profiles displayed in the dashboard: live daemon state when running, + // local disk state otherwise. + const displayProfiles = daemon.running ? daemon.profiles : localStates; -function DaemonView({ profiles, intervalMinutes, settings, onStop }: DaemonViewProps) { - const [lastRefresh, setLastRefresh] = useState(null); - const [nextRefresh, setNextRefresh] = useState(null); - const [results, setResults] = useState<{ name: string; success: boolean }[]>([]); - const [refreshing, setRefreshing] = useState(false); - const [pendingLogin, setPendingLogin] = useState(null); - const [pendingQueue, setPendingQueue] = useState([]); - - const processNextLogin = useCallback(() => { - if (pendingQueue.length > 0) { - const [next, ...rest] = pendingQueue; - setPendingQueue(rest); - setPendingLogin(next); - } else { + // ── Interactive login (local, no daemon) ────────────────────────────────── + const handleLoginComplete = useCallback( + (_profile: SSOProfile, _result: { success: boolean; error?: string }) => { setPendingLogin(null); - setLastRefresh(new Date()); - setNextRefresh(new Date(Date.now() + intervalMinutes * 60 * 1000)); - setRefreshing(false); - } - }, [pendingQueue, intervalMinutes]); - - const handleLoginComplete = useCallback((profile: SSOProfile, result: { success: boolean }) => { - setResults((prev) => [...prev, { name: profile.name, success: result.success }]); - processNextLogin(); - }, [processNextLogin]); + void reloadLocal(); + }, + [reloadLocal], + ); const { deviceAuth, authorizing, copied, handleEnter, handleCopy } = useDeviceAuth({ pendingLogin, onLoginComplete: handleLoginComplete, }); - useInput((input, key) => { - if ((key.ctrl && input === "c") || input === "q") { - onStop(); - } - if (key.return) handleEnter(); - if (input === "c" && !key.ctrl) handleCopy(); - }); - - const doRefresh = useCallback(async () => { - setRefreshing(true); - setResults([]); - const profilesNeedingLogin: SSOProfile[] = []; + // Keyboard for the login prompt overlay (only active while a login is pending). + useInput( + (input, key) => { + if (!pendingLogin) return; + if (key.return) handleEnter(); + if (input === "c") handleCopy(); + if (key.escape && !authorizing) setPendingLogin(null); + }, + { isActive: !!pendingLogin }, + ); - for (const profile of profiles) { - const result = await refreshProfile(profile); - if (result.needsLogin) { - if (settings.notifications) { - sendNotification("SSO Login Required", `Token expired for profile '${profile.name}'`); + // ── Dashboard handlers ──────────────────────────────────────────────────── + const handleRefresh = useCallback( + async (names: string[]) => { + if (daemon.running) { + // Single name → targeted refresh; multiple/all → refresh everything. + if (names.length === 1) await daemon.refresh(names[0]); + else await daemon.refresh(); + return; + } + // Local refresh: process each profile; the first that needs login triggers + // the interactive device-auth flow. + for (const name of names) { + const profile = findProfile(name); + if (!profile) continue; + const result = await refreshProfile(profile); + if (result.needsLogin) { + if (settings.notifications) { + await sendNotification("SSO Login Required", `Token expired for profile '${name}'`); + } + setPendingLogin(profile); + return; // login completion will reload local state } - profilesNeedingLogin.push(profile); - } else { - setResults((prev) => [...prev, { name: profile.name, success: result.success }]); } - } - - if (profilesNeedingLogin.length > 0) { - const [first, ...rest] = profilesNeedingLogin; - setPendingQueue(rest); - setPendingLogin(first); - } else { - setLastRefresh(new Date()); - setNextRefresh(new Date(Date.now() + intervalMinutes * 60 * 1000)); - setRefreshing(false); - } - }, [profiles, settings, intervalMinutes]); - - useEffect(() => { - doRefresh(); - const interval = setInterval(doRefresh, intervalMinutes * 60 * 1000); - return () => clearInterval(interval); - }, [doRefresh, intervalMinutes]); - - const successCount = results.filter((r) => r.success).length; - const errorCount = results.filter((r) => !r.success).length; - - return ( - - - - Profiles: {profiles.map(p => p.name).join(", ")} - Interval: {intervalMinutes} min - {lastRefresh && Last: {lastRefresh.toLocaleTimeString()}} - {nextRefresh && !refreshing && !pendingLogin && Next: {nextRefresh.toLocaleTimeString()}} - {refreshing && !pendingLogin ? ( - - ) : results.length > 0 && !pendingLogin && ( - 0 ? "yellow" : "green"}>✓ {successCount} refreshed{errorCount > 0 ? `, ${errorCount} errors` : ""} - )} - - - - {pendingLogin && ( - - )} - + await reloadLocal(); + }, + [daemon, findProfile, settings.notifications, reloadLocal], ); -} -// ───────────────────────────────────────────────────────────────────────────── -// Main Component -// ───────────────────────────────────────────────────────────────────────────── + const handleToggleFavorite = useCallback( + (name: string) => { + const isFav = settings.favoriteProfiles.includes(name); + const favoriteProfiles = isFav + ? settings.favoriteProfiles.filter((n) => n !== name) + : [...settings.favoriteProfiles, name]; + const next = { ...settings, favoriteProfiles }; + setSettings(next); + saveSettings(next); + void daemon.setFavorite(name, !isFav); // no-op if daemon down + void reloadLocal(); // update ★ immediately when no daemon + }, + [settings, daemon, reloadLocal], + ); -function SSOmatic() { - const { profiles, statuses, loading, error, fetchStatuses } = useProfiles(); - const { settings, updateSettings } = useSettings(); - const [view, setView] = useState("menu"); - const [selectedProfiles, setSelectedProfiles] = useState([]); - const [daemonInterval, setDaemonInterval] = useState(30); - const [updateAvailable, setUpdateAvailable] = useState(null); - const { exit } = useApp(); + const handleRunBackground = useCallback(() => { + void daemon.startBackground(); + }, [daemon]); + + const handleCopyExport = useCallback( + async (name: string) => { + let creds = readProfileCredentials(name); + if (!creds) { + const profile = findProfile(name); + if (profile) { + const result = await refreshProfile(profile); + if (result.success) creds = readProfileCredentials(name); + } + } + if (!creds) { + setFeedback(`No credentials for ${name}`); + return; + } + const ok = await copyToClipboard(buildExportBlock(creds)); + setFeedback(ok ? `Copied export for ${name}` : `Copy failed for ${name}`); + }, + [findProfile], + ); - // Check for updates - useEffect(() => { - checkForUpdate().then(setUpdateAvailable); + const handleCopyName = useCallback(async (name: string) => { + const ok = await copyToClipboard(name); + setFeedback(ok ? `Copied ${name}` : `Copy failed for ${name}`); }, []); - // Handle keyboard for navigation - useInput((input, key) => { - if ((input === "b" || key.escape) && view !== "menu" && view !== "daemon-running") { - setView("menu"); - } - }); - - // Menu items - const menuItems: ListItemData[] = [ - { id: "status", label: "Check status", hint: "view all profiles", value: "status" }, - { id: "refresh", label: "Refresh now", hint: "one-time", value: "refresh" }, - { id: "daemon", label: "Auto-refresh", hint: "runs continuously", value: "daemon" }, - { id: "settings", label: "Settings", hint: "notifications & defaults", value: "settings" }, - { id: "exit", label: "Exit", value: "exit" }, - ]; - - // Settings menu items - const settingsItems: ListItemData[] = [ - { - id: "notifications", - label: `Notifications: ${settings.notifications ? "On" : "Off"}`, - value: "notifications", - }, - { - id: "interval", - label: `Default refresh interval: ${settings.refreshLeadMinutes} minutes before expiry`, - value: "interval", - }, - { - id: "favorites", - label: `Favorite profiles (${settings.favoriteProfiles.length})`, - value: "favorites", + const handleOpenConsole = useCallback( + async (name: string) => { + let creds = readProfileCredentials(name); + if (!creds) { + const profile = findProfile(name); + if (profile) { + const result = await refreshProfile(profile); + if (result.success) creds = readProfileCredentials(name); + } + } + if (!creds) { + setFeedback(`No credentials for ${name}`); + return; + } + try { + const url = await getConsoleSigninUrl(creds); + openBrowser(url); + setFeedback(`Opening console for ${name}`); + } catch { + setFeedback(`Console sign-in failed for ${name}`); + } }, - { id: "back", label: "Back to main menu", value: "back" }, - ]; - - // Interval items - const intervalItems: ListItemData[] = REFRESH_INTERVALS.map((i) => ({ - id: String(i.value), - label: i.label, - hint: i.hint, - value: i.value, - })); - - // Profile items for multi-select - const profileItems: MultiSelectItemData[] = sortByFavorites( - profiles.map((profile) => { - const status = statuses.find((s) => s.profile.name === profile.name); - const isFavorite = settings.favoriteProfiles.includes(profile.name); - return { - id: profile.name, - label: `${profile.name}${isFavorite ? " ★" : ""}`, - hint: status?.status || "unknown", - value: profile, - }; - }), - settings.favoriteProfiles, - (item) => item.id + [findProfile], ); - // Handlers - const handleMenuSelect = (item: ListItemData) => { - const action = item.value as string; - switch (action) { - case "status": - fetchStatuses(); - setView("status"); - break; - case "refresh": - fetchStatuses(); - setView("refresh-select"); - break; - case "daemon": - fetchStatuses(); - setView("daemon-select"); - break; - case "settings": - setView("settings"); - break; - case "exit": - exit(); - break; - } - }; - - const handleSettingsSelect = (item: ListItemData) => { - const action = item.value as string; - switch (action) { - case "notifications": - updateSettings({ notifications: !settings.notifications }); - break; - case "interval": - setView("settings-interval"); - break; - case "favorites": - setView("settings-favorites"); - break; - case "back": - setView("menu"); - break; - } - }; + const handleOpenDetails = useCallback((name: string) => { + setDetailName(name); + setView("details"); + }, []); - // TODO(Task 12/13): defaultInterval shim — remove with interval UI cleanup - const handleIntervalSelect = (item: ListItemData) => { - void item; // interval value captured by daemonInterval state; settings field removed + const handleOpenSettings = useCallback(() => { setView("settings"); - }; - - const handleDaemonIntervalSelect = (item: ListItemData) => { - setDaemonInterval(item.value as number); - setView("daemon-running"); - }; + }, []); - const handleFavoritesSubmit = (selected: MultiSelectItemData[]) => { - updateSettings({ favoriteProfiles: selected.map((s) => s.id) }); - setView("settings"); - }; + const handleSettingsChange = useCallback((next: AppSettings) => { + setSettings(next); + saveSettings(next); + }, []); - const handleProfilesSubmit = (selected: MultiSelectItemData[]) => { - setSelectedProfiles(selected.map((s) => s.value as SSOProfile)); - if (view === "refresh-select") { - setView("refresh"); - } else if (view === "daemon-select") { - setView("daemon-interval"); - } - }; + const statusItems = useMemo( + () => + [ + ...(updateAvailable + ? [ + + ↑ v{updateAvailable} available + , + ] + : []), + ...(feedback + ? [ + + {feedback} + , + ] + : []), + ] as React.ReactNode[], + [updateAvailable, feedback], + ); - // Loading state - if (loading && profiles.length === 0) { + // Loading / seeding state. + if (seeding && localStates.length === 0) { return ( - exit()} - > + exit()}> ); } - // No profiles - if (profiles.length === 0 && !loading) { + // No profiles found. + if (!seeding && ssoProfiles.length === 0 && localStates.length === 0) { return ( - exit()} - > - - No SSO profiles found in ~/.aws/config - - - - Make sure your config has profiles with:{"\n"} - - sso_start_url{"\n"} - - sso_account_id{"\n"} - - sso_role_name{"\n"} - - sso_region - - + exit()}> + No SSO profiles found in ~/.aws/config ); } - // Render based on view - const renderView = () => { - switch (view) { - case "menu": - return ( - <> - - ? - What would you like to do? - - - - ); - - case "status": - return ( - <> - {loading ? ( - - ) : ( - <> - - - Press b to go back - - - )} - - ); - - case "refresh-select": - case "daemon-select": - return ( - <> - {loading ? ( - - ) : ( - <> - - ? - Select profiles to {view === "refresh-select" ? "refresh" : "monitor"} - - setView("menu")} - initialSelected={settings.favoriteProfiles} - required - maxVisible={10} - /> - - )} - - ); - - case "refresh": - return ( - setView("menu")} - /> - ); - - case "daemon-interval": - return ( - <> - - ? - Select refresh interval - - - - ); - - case "daemon-running": - return ( - setView("menu")} - /> - ); - - case "settings": - return ( - <> - - ? - Settings - - - - ); - - case "settings-interval": - return ( - <> - - ? - Select default refresh interval - - - - ); - - case "settings-favorites": - return ( - <> - - ? - Select favorite profiles (shown first) - - setView("settings")} - initialSelected={settings.favoriteProfiles} - maxVisible={10} - /> - - ); - - default: - return null; - } - }; + // Login overlay takes precedence over the active view. + if (pendingLogin) { + return ( + exit()}> + + + ); + } - const getActions = () => { - switch (view) { - case "menu": - return [ACTIONS.navigate, ACTIONS.select, ACTIONS.quit]; - case "status": - return [ACTIONS.back, ACTIONS.quit]; - case "refresh-select": - case "daemon-select": - case "settings-favorites": - return [ - { keys: "space", label: "Toggle" }, - { keys: "a", label: "All/None" }, - ACTIONS.select, - ACTIONS.back, - ]; - case "daemon-running": - return [{ keys: "^C", label: "Stop" }, ACTIONS.quit]; - default: - return [ACTIONS.navigate, ACTIONS.select, ACTIONS.back]; - } - }; + if (view === "settings") { + return ( + exit()}> + setView("dashboard")} /> + + ); + } - const statusItems = [ - ...(updateAvailable ? [ - - ↑ v{updateAvailable} available - - ] : []), - ]; + if (view === "details" && detailName) { + const profile = displayProfiles.find((p) => p.name === detailName); + if (profile) { + const sso = findProfile(detailName); + return ( + exit()}> +
setView("dashboard")} + /> + + ); + } + } return ( - exit()} - > - {/* Profile count banner */} - - - {profiles.length} SSO profile{profiles.length !== 1 ? "s" : ""} discovered - - - - {error && ( - {error} - )} - - {renderView()} + exit()}> + void handleRefresh(names)} + onToggleFavorite={handleToggleFavorite} + onRunBackground={handleRunBackground} + onOpenDetails={handleOpenDetails} + onOpenConsole={(name) => void handleOpenConsole(name)} + onCopyExport={(name) => void handleCopyExport(name)} + onCopyName={(name) => void handleCopyName(name)} + onOpenSettings={handleOpenSettings} + onQuit={() => exit()} + /> ); } @@ -922,9 +465,8 @@ Usage: ssomatic --version `; -function launchTui(_startDaemon: boolean): void { - // _startDaemon is intentionally unused: daemon flag wiring comes in Task 12. - renderApp(); +function launchTui(startDaemon: boolean): void { + renderApp(); } async function main(): Promise { diff --git a/src/cli/tui/Details.tsx b/src/cli/tui/Details.tsx new file mode 100644 index 0000000..3d5392a --- /dev/null +++ b/src/cli/tui/Details.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { Box, Text, useInput } from "ink"; +import type { ProfileState } from "../../daemon/protocol.js"; + +interface Props { + profile: ProfileState; + arn?: string; + region?: string; + startUrl?: string; + onBack: () => void; +} + +export function Details({ profile, arn, region, startUrl, onBack }: Props) { + useInput((_input, key) => { + if (key.escape || key.return) onBack(); + }); + const row = (label: string, value: string) => ( + + {label.padEnd(10)} + {value} + + ); + return ( + + ⏎ {profile.name} + {row("account", profile.accountId ?? "—")} + {row("role", arn ?? "—")} + {row("region", region ?? "—")} + {row("status", profile.status)} + {row("expires", profile.expiresAt ?? "—")} + {row("sso url", startUrl ?? "—")} + esc back + + ); +} diff --git a/src/cli/tui/Settings.tsx b/src/cli/tui/Settings.tsx new file mode 100644 index 0000000..c148411 --- /dev/null +++ b/src/cli/tui/Settings.tsx @@ -0,0 +1,56 @@ +import React, { useState } from "react"; +import { Box, Text, useInput } from "ink"; +import type { AppSettings } from "../../aws/settings.js"; + +interface Props { + settings: AppSettings; + onChange: (next: AppSettings) => void; + onBack: () => void; +} + +export function Settings({ settings, onChange, onBack }: Props) { + const [cursor, setCursor] = useState(0); + const items = ["notifications", "refreshLeadMinutes", "autoStartDaemon"] as const; + useInput((input, key) => { + if (key.escape) { + onBack(); + return; + } + if (key.upArrow || input === "k") { + setCursor((c) => Math.max(0, c - 1)); + return; + } + if (key.downArrow || input === "j") { + setCursor((c) => Math.min(items.length - 1, c + 1)); + return; + } + const field = items[cursor]; + if (field === "refreshLeadMinutes") { + if (key.leftArrow) + onChange({ ...settings, refreshLeadMinutes: Math.max(1, settings.refreshLeadMinutes - 1) }); + else if (key.rightArrow) + onChange({ ...settings, refreshLeadMinutes: settings.refreshLeadMinutes + 1 }); + } else if (key.return || input === " ") { + if (field === "notifications") + onChange({ ...settings, notifications: !settings.notifications }); + else if (field === "autoStartDaemon") + onChange({ ...settings, autoStartDaemon: !settings.autoStartDaemon }); + } + }); + const line = (i: number, label: string, value: string) => ( + + {cursor === i ? "▸ " : " "} + {label.padEnd(22)} + {value} + + ); + return ( + + ⚙ Settings + {line(0, "Notifications", settings.notifications ? "on" : "off")} + {line(1, "Refresh lead (min)", String(settings.refreshLeadMinutes) + " (←/→)")} + {line(2, "Auto-start daemon", settings.autoStartDaemon ? "on" : "off")} + space/⏎ toggle ←→ adjust esc back + + ); +} From dbda7045ac30b71a7b3b0055c80b32bd5965158a Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 15:31:04 +0200 Subject: [PATCH 19/29] fix(cli): stop global q-quit from firing during filter typing; surface device-auth init failure --- src/cli/components/App.tsx | 12 +----------- src/cli/index.tsx | 33 ++++++++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/cli/components/App.tsx b/src/cli/components/App.tsx index fb0b65d..3d5ee37 100644 --- a/src/cli/components/App.tsx +++ b/src/cli/components/App.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Box, Text, useApp, useInput, render as inkRender } from "ink"; +import { Box, Text, render as inkRender } from "ink"; import { ActionBar, ActionItem } from "./ActionBar.js"; export interface AppProps { @@ -19,19 +19,9 @@ export function App({ actions, statusItems, children, - onQuit, }: AppProps) { - const { exit } = useApp(); const hasStatusItems = !!statusItems && statusItems.length > 0; - // Global keyboard handler - useInput((input, key) => { - if (input === "q" || (key.ctrl && input === "c")) { - onQuit?.(); - exit(); - } - }); - return ( {/* Header */} diff --git a/src/cli/index.tsx b/src/cli/index.tsx index e7abbdf..69a2799 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -50,6 +50,7 @@ interface UseDeviceAuthOptions { function useDeviceAuth({ pendingLogin, onLoginComplete, onCopyUrl }: UseDeviceAuthOptions) { const [deviceAuth, setDeviceAuth] = useState(null); const [authorizing, setAuthorizing] = useState(false); + const [authError, setAuthError] = useState(false); const { copy, copied } = useCopy(); const currentProfileRef = React.useRef(null); @@ -62,10 +63,17 @@ function useDeviceAuth({ pendingLogin, onLoginComplete, onCopyUrl }: UseDeviceAu currentProfileRef.current = profileName; setDeviceAuth(null); setAuthorizing(false); + setAuthError(false); // Start new device authorization if we have a profile if (pendingLogin) { - startDeviceAuthorization(pendingLogin).then(setDeviceAuth); + startDeviceAuthorization(pendingLogin).then((info) => { + if (info === null) { + setAuthError(true); + } else { + setDeviceAuth(info); + } + }); } } }, [pendingLogin]); @@ -94,6 +102,7 @@ function useDeviceAuth({ pendingLogin, onLoginComplete, onCopyUrl }: UseDeviceAu return { deviceAuth, authorizing, + authError, copied, handleEnter, handleCopy, @@ -107,11 +116,24 @@ function useDeviceAuth({ pendingLogin, onLoginComplete, onCopyUrl }: UseDeviceAu interface LoginPromptProps { profile: SSOProfile; deviceAuth: DeviceAuthInfo | null; + authError?: boolean; copied?: boolean; authorizing?: boolean; } -function LoginPrompt({ profile, deviceAuth, copied = false, authorizing = false }: LoginPromptProps) { +function LoginPrompt({ profile, deviceAuth, authError = false, copied = false, authorizing = false }: LoginPromptProps) { + if (authError) { + return ( + + SSO login required for {profile.name} + + Failed to start device authorization. Check your network and SSO configuration. + + Press Esc to go back + + ); + } + if (!deviceAuth) { return ( @@ -224,7 +246,7 @@ function SSOmatic({ startDaemon = false }: SSOmaticProps) { [reloadLocal], ); - const { deviceAuth, authorizing, copied, handleEnter, handleCopy } = useDeviceAuth({ + const { deviceAuth, authorizing, authError, copied, handleEnter, handleCopy } = useDeviceAuth({ pendingLogin, onLoginComplete: handleLoginComplete, }); @@ -233,6 +255,10 @@ function SSOmatic({ startDaemon = false }: SSOmaticProps) { useInput( (input, key) => { if (!pendingLogin) return; + if (authError) { + if (key.escape) setPendingLogin(null); + return; + } if (key.return) handleEnter(); if (input === "c") handleCopy(); if (key.escape && !authorizing) setPendingLogin(null); @@ -397,6 +423,7 @@ function SSOmatic({ startDaemon = false }: SSOmaticProps) { From c5428dee5ffc19b10d45b89de4f49ce635e877a6 Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 15:55:00 +0200 Subject: [PATCH 20/29] refactor(cli): remove dead menu-era components and update CLAUDE.md --- CLAUDE.md | 78 +++++++++-- src/cli/components/Card.tsx | 27 ---- src/cli/components/CopyFeedback.tsx | 34 ----- src/cli/components/Divider.tsx | 14 -- src/cli/components/Header.tsx | 18 --- src/cli/components/IdentityCard.tsx | 62 --------- src/cli/components/List.tsx | 183 ------------------------- src/cli/components/MultiSelectList.tsx | 163 ---------------------- src/cli/components/index.ts | 15 -- src/cli/hooks/index.ts | 1 - src/cli/hooks/useIdentity.tsx | 67 --------- 11 files changed, 70 insertions(+), 592 deletions(-) delete mode 100644 src/cli/components/Card.tsx delete mode 100644 src/cli/components/CopyFeedback.tsx delete mode 100644 src/cli/components/Divider.tsx delete mode 100644 src/cli/components/Header.tsx delete mode 100644 src/cli/components/IdentityCard.tsx delete mode 100644 src/cli/components/List.tsx delete mode 100644 src/cli/components/MultiSelectList.tsx delete mode 100644 src/cli/hooks/useIdentity.tsx diff --git a/CLAUDE.md b/CLAUDE.md index a63718c..e24e932 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,21 +6,57 @@ Distributed via npm (`npx ssomatic`). Settings (favorites, notifications, refresh interval) are persisted across sessions. +SSOmatic runs a real per-host background daemon (single instance, Unix socket). It keeps ★ favorite profiles' role credentials fresh in an expiry-aware manner while the SSO token is valid, and sends a desktop notification when an interactive browser login is required — the daemon never opens a browser itself. The TUI attaches to the daemon over the socket for live state; any terminal that runs `ssomatic` while the daemon is up shows the live state. + ## Structure ``` ssomatic/ ├── src/ │ ├── aws/ # Shared AWS logic (UI-agnostic) -│ │ ├── sso.ts # SSO profiles, tokens, refresh, settings -│ │ ├── sso.test.ts # Unit tests for sso.ts +│ │ ├── sso.ts # SSO profiles, tokens, refresh +│ │ ├── sso.test.ts +│ │ ├── settings.ts # Persistent settings (favorites, notifications, interval) +│ │ ├── settings.test.ts +│ │ ├── console.ts # AWS console URL builders +│ │ ├── console.test.ts +│ │ ├── profileState.ts # Profile state helpers │ │ ├── aws.ts # STS identity utilities │ │ ├── utils.ts # Clipboard, JSON formatting -│ │ └── utils.test.ts # Unit tests for utils.ts +│ │ └── utils.test.ts +│ ├── daemon/ # Per-host background daemon (Unix-socket server + expiry-aware scheduler) +│ │ ├── protocol.ts # Wire protocol types + ndjson codec +│ │ ├── protocol.test.ts +│ │ ├── lifecycle.ts # Single-instance lock + pid management +│ │ ├── lifecycle.test.ts +│ │ ├── scheduler.ts # Expiry-aware refresh scheduler +│ │ ├── scheduler.test.ts +│ │ ├── server.ts # Unix-socket daemon server +│ │ ├── server.test.ts +│ │ ├── client.ts # Daemon socket client +│ │ └── index.ts # Daemon entry point │ └── cli/ # Terminal UI (React/Ink) -│ ├── index.tsx # Entry point -│ ├── components/ # Ink UI components -│ └── hooks/ # Ink hooks (useIdentity, useCopy) +│ ├── index.tsx # Entry point + argument router +│ ├── args.ts # CLI argument parsing +│ ├── args.test.ts +│ ├── commands/ # Non-TUI subcommands +│ │ ├── status.ts # `ssomatic status` +│ │ ├── status.test.ts +│ │ ├── export.ts # `ssomatic export ` +│ │ ├── refresh.ts # `ssomatic refresh [profile]` +│ │ └── daemon.ts # `ssomatic daemon start|stop|status` +│ ├── tui/ # TUI screens +│ │ ├── Dashboard.tsx # Main profile list view +│ │ ├── Details.tsx # Profile detail view +│ │ ├── Settings.tsx # Settings screen +│ │ └── useDaemon.ts # Hook: connects TUI to the daemon socket +│ ├── components/ # Shared Ink UI components +│ │ ├── App.tsx # Root container +│ │ ├── ActionBar.tsx # Bottom action bar + ACTIONS constant +│ │ ├── Spinner.tsx +│ │ └── StatusMessage.tsx +│ └── hooks/ # Shared hooks +│ └── useCopy.tsx # Clipboard copy with feedback ├── dist/ # Build output │ └── cli.js # Node CLI bundle (npm bin) ├── docs/screenshots/ # Demo GIFs for README @@ -42,6 +78,8 @@ ssomatic/ ## Commands +### Dev / Build / Test + ```bash bun install # Install dependencies bun run start # Run CLI @@ -51,11 +89,35 @@ bun run lint # Run ESLint bun test # Run unit tests ``` -## Keyboard Shortcuts +### Runtime CLI subcommands + +```bash +ssomatic # Launch the interactive TUI +ssomatic --daemon # Launch the TUI and start the background daemon +ssomatic status # Print profile statuses and exit +ssomatic refresh [profile] # Refresh a profile (or all favorites) now +ssomatic export # Print export AWS_* lines (use with eval $(ssomatic export )) +ssomatic daemon start|stop|status +ssomatic --version +``` + +## Keyboard Shortcuts (Dashboard) | Key | Action | |-----|--------| -| `Escape` | Back | +| `↑` / `↓` / `k` / `j` | Move cursor | +| `space` | Select / deselect profile | +| `a` | Select all / deselect all | +| `⏎` | Open details | +| `r` | Refresh selected (or current) profile(s) | +| `b` | Run daemon in background | +| `f` | Toggle ★ favorite | +| `c` | Copy export (`AWS_*` env vars) | +| `y` | Copy profile name | +| `o` | Open AWS console | +| `/` | Filter profiles | +| `s` | Open settings | +| `Esc` | Back | | `q` | Quit | ## Commits & Releases diff --git a/src/cli/components/Card.tsx b/src/cli/components/Card.tsx deleted file mode 100644 index 7e52057..0000000 --- a/src/cli/components/Card.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from "react"; -import { Box, Text } from "ink"; - -export interface CardProps { - title?: string; - children: React.ReactNode; - borderColor?: string; -} - -export function Card({ title, children, borderColor = "gray" }: CardProps) { - return ( - - {title && ( - - {title} - - )} - {children} - - ); -} diff --git a/src/cli/components/CopyFeedback.tsx b/src/cli/components/CopyFeedback.tsx deleted file mode 100644 index 335c4c1..0000000 --- a/src/cli/components/CopyFeedback.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from "react"; -import { Box } from "ink"; -import { StatusMessage } from "./StatusMessage.js"; - -export interface CopyFeedbackProps { - copied: boolean; - error: string | null; -} - -/** - * Display copy feedback (success or error message). - * Use with the useCopy hook for a complete copy solution. - * - * @example - * ```tsx - * const { copy, copied, error } = useCopy(); - * - * - * ``` - */ -export function CopyFeedback({ copied, error }: CopyFeedbackProps) { - if (!copied && !error) return null; - - return ( - - {copied && ( - Copied to clipboard! - )} - {error && ( - {error} - )} - - ); -} diff --git a/src/cli/components/Divider.tsx b/src/cli/components/Divider.tsx deleted file mode 100644 index f9ed68f..0000000 --- a/src/cli/components/Divider.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from "react"; -import { Box, Text } from "ink"; - -export interface DividerProps { - width?: number; -} - -export function Divider({ width = 65 }: DividerProps) { - return ( - - {"─".repeat(width)} - - ); -} diff --git a/src/cli/components/Header.tsx b/src/cli/components/Header.tsx deleted file mode 100644 index 5422910..0000000 --- a/src/cli/components/Header.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from "react"; -import { Box, Text } from "ink"; - -export interface HeaderProps { - icon?: string; - title: string; - color?: string; -} - -export function Header({ icon = "▲", title, color = "cyan" }: HeaderProps) { - return ( - - - {icon} {title} - - - ); -} diff --git a/src/cli/components/IdentityCard.tsx b/src/cli/components/IdentityCard.tsx deleted file mode 100644 index 8c23e30..0000000 --- a/src/cli/components/IdentityCard.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from "react"; -import { Box, Text } from "ink"; -import { AwsIdentity } from "../hooks/useIdentity.js"; -import { Spinner } from "./Spinner.js"; - -export interface IdentityCardProps { - identity: AwsIdentity | null; - loading?: boolean; - error?: string | null; -} - -export function IdentityCard({ identity, loading, error }: IdentityCardProps) { - if (loading) { - return ( - - - - ); - } - - if (error) { - return ( - - ✗ {error} - - ); - } - - if (!identity) { - return null; - } - - return ( - - {/* Title */} - - AWS Identity - - - {/* Content */} - - {identity.accountId} - - {identity.profile} - - {identity.role} - - - ); -} diff --git a/src/cli/components/List.tsx b/src/cli/components/List.tsx deleted file mode 100644 index d507c1e..0000000 --- a/src/cli/components/List.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { Box, Text, useInput } from "ink"; -import { Spinner } from "./Spinner.js"; - -export interface ListItemData { - id: string; - label: string; - description?: string; - hint?: string; - value: unknown; -} - -export interface ListAction { - key: string; - handler: (item: ListItemData) => void; -} - -export interface ListProps { - items: ListItemData[]; - onSelect?: (item: ListItemData) => void; - onHighlight?: (item: ListItemData | null) => void; - onRefresh?: () => void; - actions?: ListAction[]; - loading?: boolean; - emptyMessage?: string; - maxVisible?: number; - showIndex?: boolean; -} - -export function List({ - items, - onSelect, - onHighlight, - onRefresh, - actions = [], - loading = false, - emptyMessage = "No items found", - maxVisible = 10, - showIndex = false, -}: ListProps) { - const [cursor, setCursor] = useState(0); - const [scrollOffset, setScrollOffset] = useState(0); - - // Clamp cursor when items change - useEffect(() => { - setCursor((prev) => Math.min(prev, Math.max(0, items.length - 1))); - }, [items.length]); - - // Notify on highlight - useEffect(() => { - onHighlight?.(items[cursor] ?? null); - }, [cursor, items, onHighlight]); - - // Handle scrolling - useEffect(() => { - if (cursor < scrollOffset) { - setScrollOffset(cursor); - } else if (cursor >= scrollOffset + maxVisible) { - setScrollOffset(cursor - maxVisible + 1); - } - }, [cursor, maxVisible, scrollOffset]); - - // Keyboard navigation - useInput( - (input, key) => { - if (loading) return; - - // Navigation - if (key.upArrow || input === "k") { - setCursor((prev) => Math.max(0, prev - 1)); - } - if (key.downArrow || input === "j") { - setCursor((prev) => Math.min(items.length - 1, prev + 1)); - } - - // Page up/down - if (key.pageUp) { - setCursor((prev) => Math.max(0, prev - maxVisible)); - } - if (key.pageDown) { - setCursor((prev) => Math.min(items.length - 1, prev + maxVisible)); - } - - // Home/End - if (key.meta && key.upArrow) { - setCursor(0); - } - if (key.meta && key.downArrow) { - setCursor(items.length - 1); - } - - // Select - if (key.return && items[cursor]) { - onSelect?.(items[cursor]); - } - - // Refresh - if (input === "r") { - onRefresh?.(); - } - - // Custom actions - for (const action of actions) { - if (input === action.key && items[cursor]) { - action.handler(items[cursor]); - } - } - }, - ); - - if (loading) { - return ; - } - - if (items.length === 0) { - return ( - - {emptyMessage} - - ); - } - - const visibleItems = items.slice(scrollOffset, scrollOffset + maxVisible); - const showScrollUp = scrollOffset > 0; - const showScrollDown = scrollOffset + maxVisible < items.length; - - return ( - - {/* Scroll indicator up */} - {showScrollUp && ( - - ↑ {scrollOffset} more - - )} - - {/* Items */} - {visibleItems.map((item, index) => { - const actualIndex = scrollOffset + index; - const isSelected = actualIndex === cursor; - - return ( - - - {/* Selection indicator */} - - {isSelected ? "❯" : " "} - - - {/* Index */} - {showIndex && ( - {String(actualIndex + 1).padStart(2)} - )} - - {/* Label */} - - {" "}{item.label} - - - {/* Hint */} - {item.hint && ( - ({item.hint}) - )} - - - {/* Description */} - {item.description && ( - - {item.description} - - )} - - ); - })} - - {/* Scroll indicator down */} - {showScrollDown && ( - - ↓ {items.length - scrollOffset - maxVisible} more - - )} - - ); -} diff --git a/src/cli/components/MultiSelectList.tsx b/src/cli/components/MultiSelectList.tsx deleted file mode 100644 index 24503df..0000000 --- a/src/cli/components/MultiSelectList.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { Box, Text, useInput } from "ink"; - -export interface MultiSelectItemData { - id: string; - label: string; - hint?: string; - value: unknown; -} - -export interface MultiSelectListProps { - items: MultiSelectItemData[]; - onSubmit?: (selected: MultiSelectItemData[]) => void; - onCancel?: () => void; - initialSelected?: string[]; - required?: boolean; - maxVisible?: number; -} - -export function MultiSelectList({ - items, - onSubmit, - onCancel, - initialSelected = [], - required = false, - maxVisible = 10, -}: MultiSelectListProps) { - const [cursor, setCursor] = useState(0); - const [selected, setSelected] = useState>(new Set(initialSelected)); - const [scrollOffset, setScrollOffset] = useState(0); - - // Clamp cursor when items change - useEffect(() => { - setCursor((prev) => Math.min(prev, Math.max(0, items.length - 1))); - }, [items.length]); - - // Handle scrolling - useEffect(() => { - if (cursor < scrollOffset) { - setScrollOffset(cursor); - } else if (cursor >= scrollOffset + maxVisible) { - setScrollOffset(cursor - maxVisible + 1); - } - }, [cursor, maxVisible, scrollOffset]); - - useInput( - (input, key) => { - - // Navigation - if (key.upArrow || input === "k") { - setCursor((prev) => Math.max(0, prev - 1)); - } - if (key.downArrow || input === "j") { - setCursor((prev) => Math.min(items.length - 1, prev + 1)); - } - - // Toggle selection - if (input === " " && items[cursor]) { - setSelected((prev) => { - const next = new Set(prev); - const id = items[cursor].id; - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - return next; - }); - } - - // Select all / none - if (input === "a") { - if (selected.size === items.length) { - setSelected(new Set()); - } else { - setSelected(new Set(items.map((i) => i.id))); - } - } - - // Submit - if (key.return) { - const selectedItems = items.filter((i) => selected.has(i.id)); - if (required && selectedItems.length === 0) { - return; // Don't submit if required and nothing selected - } - onSubmit?.(selectedItems); - } - - // Cancel - if (key.escape) { - onCancel?.(); - } - }, - ); - - if (items.length === 0) { - return ( - - No items - - ); - } - - const visibleItems = items.slice(scrollOffset, scrollOffset + maxVisible); - const showScrollUp = scrollOffset > 0; - const showScrollDown = scrollOffset + maxVisible < items.length; - - return ( - - {/* Scroll indicator up */} - {showScrollUp && ( - - ↑ {scrollOffset} more - - )} - - {/* Items */} - {visibleItems.map((item, index) => { - const actualIndex = scrollOffset + index; - const isHighlighted = actualIndex === cursor; - const isSelected = selected.has(item.id); - - return ( - - {/* Cursor */} - - {isHighlighted ? "❯" : " "} - - - {/* Checkbox */} - - {isSelected ? " ◉" : " ○"} - - - {/* Label */} - - {" "}{item.label} - - - {/* Hint */} - {item.hint && ( - ({item.hint}) - )} - - ); - })} - - {/* Scroll indicator down */} - {showScrollDown && ( - - ↓ {items.length - scrollOffset - maxVisible} more - - )} - - {/* Instructions */} - - - space toggle · a all/none · enter submit · {selected.size} selected - - - - ); -} diff --git a/src/cli/components/index.ts b/src/cli/components/index.ts index 5ba9a87..61d4685 100644 --- a/src/cli/components/index.ts +++ b/src/cli/components/index.ts @@ -1,24 +1,9 @@ // Layout components export { App, renderApp, type AppProps } from "./App.js"; -export { Header, type HeaderProps } from "./Header.js"; -export { Card, type CardProps } from "./Card.js"; -export { Divider, type DividerProps } from "./Divider.js"; // Interactive components -export { List, type ListProps, type ListItemData, type ListAction } from "./List.js"; export { ActionBar, ACTIONS, type ActionItem, type ActionBarProps } from "./ActionBar.js"; // Feedback components export { Spinner, type SpinnerProps } from "./Spinner.js"; export { StatusMessage, type StatusMessageProps, type StatusType } from "./StatusMessage.js"; -export { CopyFeedback, type CopyFeedbackProps } from "./CopyFeedback.js"; - -// Multi-select -export { - MultiSelectList, - type MultiSelectListProps, - type MultiSelectItemData, -} from "./MultiSelectList.js"; - -// AWS components -export { IdentityCard, type IdentityCardProps } from "./IdentityCard.js"; diff --git a/src/cli/hooks/index.ts b/src/cli/hooks/index.ts index 07a46ac..b64389b 100644 --- a/src/cli/hooks/index.ts +++ b/src/cli/hooks/index.ts @@ -1,2 +1 @@ -export { useIdentity, type AwsIdentity, type UseIdentityResult } from "./useIdentity.js"; export { useCopy, type UseCopyResult } from "./useCopy.js"; diff --git a/src/cli/hooks/useIdentity.tsx b/src/cli/hooks/useIdentity.tsx deleted file mode 100644 index ff33861..0000000 --- a/src/cli/hooks/useIdentity.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useState, useEffect } from "react"; -import { getAwsEnv, getCallerIdentity, parseIdentityArn } from "../../aws/aws.js"; - -export interface AwsIdentity { - accountId: string; - profile: string; - region: string; - role: string; - arn: string; -} - -export interface UseIdentityResult { - identity: AwsIdentity | null; - loading: boolean; - error: string | null; - refresh: () => void; -} - -export function useIdentity(): UseIdentityResult { - const [identity, setIdentity] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [refreshCount, setRefreshCount] = useState(0); - - const refresh = () => setRefreshCount((c) => c + 1); - - useEffect(() => { - let cancelled = false; - - async function fetchIdentity() { - setLoading(true); - setError(null); - - try { - const env = getAwsEnv(); - const caller = await getCallerIdentity(); - - if (cancelled) return; - - const parsed = parseIdentityArn(caller.arn); - - setIdentity({ - accountId: caller.accountId, - profile: env.profile || "default", - region: env.region || "us-east-1", - role: parsed.name, - arn: caller.arn, - }); - } catch (err) { - if (cancelled) return; - setError(err instanceof Error ? err.message : "Failed to get identity"); - } finally { - if (!cancelled) { - setLoading(false); - } - } - } - - fetchIdentity(); - - return () => { - cancelled = true; - }; - }, [refreshCount]); - - return { identity, loading, error, refresh }; -} From ef0d7b3e476707bd3753a46a12362466b0720270 Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 15:57:41 +0200 Subject: [PATCH 21/29] docs: rewrite README and refine npm metadata for v2 (dashboard + daemon) --- README.md | 151 +++++++++++++++++++++++++++++++-------------------- package.json | 6 +- 2 files changed, 97 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index cbf393b..427dfa5 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,34 @@ # SSOmatic -Interactive AWS SSO credential manager for your terminal. +Keep your AWS SSO credentials fresh — automatically. A fast terminal dashboard with a background daemon that silently maintains your favorites while you work. [![npm version](https://img.shields.io/npm/v/ssomatic)](https://www.npmjs.com/package/ssomatic) [![CI](https://github.com/tux86/ssomatic/actions/workflows/ci.yml/badge.svg)](https://github.com/tux86/ssomatic/actions/workflows/ci.yml) -[![Release](https://github.com/tux86/ssomatic/actions/workflows/release.yml/badge.svg)](https://github.com/tux86/ssomatic/actions/workflows/release.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![GitHub Stars](https://img.shields.io/github/stars/tux86/ssomatic)](https://github.com/tux86/ssomatic) [![Bun](https://img.shields.io/badge/Bun-%23000000.svg?logo=bun&logoColor=white)](https://bun.sh) [![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) --- +## Why SSOmatic + +- **k9s-style list-first dashboard** — all your SSO profiles at a glance with live expiry countdowns; navigate with j/k or arrow keys, no menus to dig through. +- **Background daemon that keeps ★ favorites fresh** — expiry-aware refresh means credentials are always ready before they expire, with zero fixed-interval polling waste. +- **Notify-on-login, never surprise you** — when an interactive SSO login is required the daemon sends a desktop notification; it never opens a browser on its own. +- **One-keystroke everything** — copy `export AWS_*` vars, open the AWS console, copy the profile name, or force a refresh — all from the dashboard without leaving your terminal. +- **Attach from any terminal** — run `ssomatic` once to open the TUI; press `b` to push it to the background; re-run `ssomatic` from any terminal window to reconnect to the live daemon state. + +--- + +## Demo + + +

+ SSOmatic CLI Demo +

+ +--- + ## Install ```bash @@ -23,39 +40,82 @@ bunx ssomatic npm install -g ssomatic ``` -> **Upgrading from 1.x?** SSOmatic is now a CLI-only tool distributed via npm. The -> built-in web UI has been removed, and the Homebrew tap / standalone binaries are -> replaced by npm. Uninstall the old binary (`brew uninstall ssomatic`) and use -> `npx ssomatic` or `npm i -g ssomatic` instead. Your `~/.aws` profiles and saved -> settings are unaffected. +--- -## Demo +## Quick Start -

- SSOmatic CLI Demo -

+```bash +# 1. Launch the dashboard +ssomatic -## Features +# 2. Star the profiles you use daily — press f on any profile +# 3. Send to background — press b (daemon stays running, terminal returns) +# 4. From any terminal, re-attach to live state +ssomatic +``` -- **Auto-discovery** — Scans `~/.aws/config` for SSO profiles (legacy and sso_session) -- **Status dashboard** — View credential validity with expiry countdown -- **Multi-select refresh** — Refresh multiple profiles at once with SSO device auth -- **Auto-refresh daemon** — Background process to keep credentials fresh -- **Desktop notifications** — Alerts when credentials expire (macOS/Linux) -- **Persistent settings** — Notifications and favorites saved across sessions +The daemon keeps your starred profiles' credentials fresh in the background. When a browser login is required, you get a desktop notification and can log in from the TUI or with `ssomatic refresh `. -## Prerequisites +--- -- [AWS CLI v2](https://aws.amazon.com/cli/) configured with SSO profiles in `~/.aws/config` +## Commands -## Usage +| Command | Description | +|---------|-------------| +| `ssomatic` | Launch the interactive TUI (attaches to daemon if running) | +| `ssomatic --daemon` | Launch the TUI and start the background daemon | +| `ssomatic status` | Print profile statuses and exit | +| `ssomatic refresh [name]` | Refresh a profile (or all favorites) now | +| `ssomatic export ` | Print `export AWS_*` lines for `eval $(...)` | +| `ssomatic daemon start\|stop\|status` | Manage the background daemon directly | +| `ssomatic --version` | Print version and exit | -Launch it (`ssomatic`, or `npx ssomatic` / `bunx ssomatic`) and use the interactive menu: +**Shell trick — inject credentials into your current shell:** -- **Check status** — see every SSO profile and whether its credentials are valid or expired -- **Refresh now** — log in and refresh one or more profiles (opens the SSO device-authorization flow) -- **Auto-refresh** — keep selected profiles refreshed automatically on an interval -- **Settings** — toggle notifications, set the default interval, and pick favorite profiles +```bash +eval $(ssomatic export prod) +``` + +--- + +## Keyboard Shortcuts + +| Key | Action | +|-----|--------| +| `↑` / `↓` or `j` / `k` | Move cursor | +| `Space` | Toggle profile selection | +| `a` | Select all / deselect all | +| `Enter` | Open profile details | +| `r` | Refresh selected (or current) profile(s) | +| `b` | Run daemon in background, detach TUI | +| `f` | Toggle ★ favorite | +| `c` | Copy `export AWS_*` to clipboard | +| `y` | Copy profile name to clipboard | +| `o` | Open AWS console in browser | +| `/` | Filter profiles by name | +| `s` | Open settings | +| `Esc` | Back | +| `q` | Quit | + +--- + +## How the Daemon Works + +One daemon instance runs per host, listening on a Unix socket (`$XDG_RUNTIME_DIR/ssomatic.sock` or `$TMPDIR/ssomatic.sock`). The TUI attaches to it via that socket so any `ssomatic` invocation sees the same live state. + +**Expiry-aware refresh** — the daemon tracks the role-credential expiry for each starred profile and refreshes only when the credentials are within the lead window of expiring (default: a few minutes before expiry). No fixed interval; no wasted refreshes. + +**Never opens a browser** — when an interactive SSO login is needed the daemon sends a desktop notification (`SSOmatic: needs login`). You authorize by running `ssomatic` (TUI) or `ssomatic refresh `. + +Daemon logs are written to `~/.aws/ssomatic/daemon.log`. + +--- + +## Prerequisites + +- [AWS CLI v2](https://aws.amazon.com/cli/) configured with SSO profiles in `~/.aws/config` + +--- ## Development @@ -66,39 +126,14 @@ git clone https://github.com/tux86/ssomatic.git cd ssomatic bun install -bun run start # Run from source -bun run dev # Run with --watch (auto-restart on changes) -bun run build # Build the Node CLI bundle (dist/cli.js) -bun run lint # Run ESLint -bun test # Run unit tests -``` - -## Project Structure - +bun run start # Run from source +bun run dev # Run with --watch (auto-restart on changes) +bun run build # Build the Node CLI bundle (dist/cli.js) +bun run lint # Run ESLint +bun test # Run unit tests ``` -ssomatic/ -├── src/ -│ ├── aws/ # Shared AWS credential logic (sso.ts, aws.ts, utils.ts) -│ │ └── *.test.ts # Unit tests -│ └── cli/ # Terminal UI (React/Ink) — entry point -│ ├── index.tsx # Main app -│ ├── components/ # Ink UI components -│ └── hooks/ # useIdentity, useCopy -├── dist/ # Build output (dist/cli.js — the npm bin) -└── package.json -``` - -## Keyboard Shortcuts -| Key | Action | -|-----|--------| -| `↑/↓` or `j/k` | Navigate | -| `Enter` | Select | -| `Space` | Toggle selection | -| `a` | Select all / none | -| `c` | Copy URL | -| `Escape` | Back | -| `q` | Quit | +--- ## Contributing diff --git a/package.json b/package.json index 7f45e51..17e78ca 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,16 @@ { "name": "ssomatic", "version": "1.3.0", - "description": "Interactive AWS SSO credential manager for your terminal — auto-discover, refresh, and manage SSO credentials. Built with Bun, React, and Ink.", + "description": "AWS SSO credential manager with a terminal dashboard and background daemon — auto-refreshes starred profiles, expiry-aware, desktop notifications on login.", "keywords": [ "aws", "sso", + "aws-sso", "cli", "tui", "credentials", - "aws-sso", + "daemon", + "terminal", "ink", "bun" ], From 5962e778c2985a879d261936dc11d0032fefd14a Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 16:30:45 +0200 Subject: [PATCH 22/29] fix(cli): reply to refresh/setFavorite requests, harden socket, prompt stop --- src/cli/commands/daemon.ts | 20 ++++++++++++-------- src/cli/commands/status.ts | 9 +++++++-- src/cli/tui/useDaemon.ts | 16 ++++++++++++++-- src/daemon/client.ts | 7 ++++++- src/daemon/index.ts | 11 +++++++++-- src/daemon/scheduler.ts | 5 ----- src/daemon/server.test.ts | 19 +++++++++++++++++++ src/daemon/server.ts | 18 ++++++++++++++++-- 8 files changed, 83 insertions(+), 22 deletions(-) diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index 0cfa3e5..1297e19 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -30,15 +30,19 @@ export async function runDaemonCommand(sub: string | undefined): Promise process.stdout.write("daemon: stopped\n"); return 0; } - const msg = await request({ type: "snapshot" }); const pid = readPidFile(); - const watched = - msg.type === "state" - ? msg.profiles.filter((p) => p.favorite).map((p) => p.name) - : []; - process.stdout.write( - `daemon: running (pid ${pid ?? "?"})\nwatching: ${watched.join(", ") || "(none)"}\n` - ); + try { + const msg = await request({ type: "snapshot" }); + const watched = + msg.type === "state" + ? msg.profiles.filter((p) => p.favorite).map((p) => p.name) + : []; + process.stdout.write( + `daemon: running (pid ${pid ?? "?"})\nwatching: ${watched.join(", ") || "(none)"}\n` + ); + } catch (err) { + process.stdout.write(`daemon: running (error: ${String(err)})\n`); + } return 0; } default: diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index e99919d..3f811ec 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -23,8 +23,13 @@ export async function runStatus(): Promise { const now = new Date(); let rows: ProfileState[]; if (await isDaemonAlive()) { - const msg = await request({ type: "snapshot" }); - rows = msg.type === "state" ? msg.profiles : []; + try { + const msg = await request({ type: "snapshot" }); + rows = msg.type === "state" ? msg.profiles : []; + } catch (err) { + process.stderr.write(`warning: daemon request failed (${String(err)}), falling back to local state\n`); + rows = await buildLocalProfileStates(); + } } else { rows = await buildLocalProfileStates(); } diff --git a/src/cli/tui/useDaemon.ts b/src/cli/tui/useDaemon.ts index b551820..f795b84 100644 --- a/src/cli/tui/useDaemon.ts +++ b/src/cli/tui/useDaemon.ts @@ -51,11 +51,23 @@ export function useDaemon(localProfiles: ProfileState[]): DaemonView { }, [attach]); const refresh = useCallback(async (profile?: string) => { - if (await isDaemonAlive()) await request({ type: "refresh", profile }); + if (await isDaemonAlive()) { + try { + await request({ type: "refresh", profile }); + } catch { + // Transient timeout or daemon error — ignore; subscription will deliver next update. + } + } }, []); const setFavorite = useCallback(async (profile: string, value: boolean) => { - if (await isDaemonAlive()) await request({ type: "setFavorite", profile, value }); + if (await isDaemonAlive()) { + try { + await request({ type: "setFavorite", profile, value }); + } catch { + // Transient timeout or daemon error — ignore; subscription will deliver next update. + } + } }, []); return { running, info, profiles, startBackground, refresh, setFavorite }; diff --git a/src/daemon/client.ts b/src/daemon/client.ts index 1ec727b..f7c5b4d 100644 --- a/src/daemon/client.ts +++ b/src/daemon/client.ts @@ -19,7 +19,12 @@ export async function request(msg: ClientMessage, timeoutMs = 3000): Promise { diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 0897554..7d8df89 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -23,6 +23,9 @@ function logPath(): string { const notified = new Set(); const credExpiry = new Map(); +/** Fallback credential TTL when AWS omits the credential expiration in the response. */ +const DEFAULT_CRED_TTL_MS = 50 * 60 * 1000; + function maybeNotify(enabled: boolean, profile: string): void { if (!enabled || notified.has(profile)) return; notified.add(profile); @@ -56,6 +59,7 @@ async function computeState(): Promise { const favorite = favorites.has(p.name); let status: ProfileStatusKind = ssoTokenValid ? "valid" : "needs-login"; + let errorMsg: string | undefined; if (favorite) { const action = decideAction({ ssoTokenValid, credsExpireAt }, now, leadMs); @@ -65,13 +69,15 @@ async function computeState(): Promise { if (r.success) { notified.delete(p.name); status = "valid"; - credExpiry.set(p.name, r.expiresAt ?? new Date(Date.now() + 50 * 60 * 1000)); + credExpiry.set(p.name, r.expiresAt ?? new Date(Date.now() + DEFAULT_CRED_TTL_MS)); } else if (r.needsLogin) { status = "needs-login"; credExpiry.delete(p.name); maybeNotify(settings.notifications, p.name); } else { + // Unrecognized failure — surface error so the UI isn't opaque. status = "error"; + errorMsg = r.error; } } else if (action === "needs-login") { status = "needs-login"; @@ -94,6 +100,7 @@ async function computeState(): Promise { : null, favorite, accountId: p.ssoAccountId, + ...(errorMsg !== undefined && { error: errorMsg }), }); } @@ -113,7 +120,7 @@ export async function runDaemon(): Promise { const r = await coreRefresh(p); if (r.success) { notified.delete(name); - credExpiry.set(name, r.expiresAt ?? new Date(Date.now() + 50 * 60 * 1000)); + credExpiry.set(name, r.expiresAt ?? new Date(Date.now() + DEFAULT_CRED_TTL_MS)); } else if (r.needsLogin) { credExpiry.delete(name); } diff --git a/src/daemon/scheduler.ts b/src/daemon/scheduler.ts index 510f694..029206d 100644 --- a/src/daemon/scheduler.ts +++ b/src/daemon/scheduler.ts @@ -12,8 +12,3 @@ export function decideAction(timing: ProfileTiming, now: Date, leadMs: number): return msLeft <= leadMs ? "refresh" : "wait"; } -/** Milliseconds until the next decision point for a profile (for scheduling the next tick). */ -export function msUntilNextCheck(timing: ProfileTiming, now: Date, leadMs: number): number { - if (!timing.ssoTokenValid || timing.credsExpireAt === null) return 0; - return Math.max(0, timing.credsExpireAt.getTime() - now.getTime() - leadMs); -} diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index 8e9cdcc..8433e78 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -73,6 +73,25 @@ test("subscribe pushes state on broadcast", async () => { sock.destroy(); }); +test("refresh request receives a state reply directly (requester is not a subscriber)", async () => { + let refreshedProfile: string | undefined; + server = await startServer({ + startedAtIso: "2026-06-11T10:00:00.000Z", + computeState: async () => fakeProfiles, + refreshProfile: async (name) => { refreshedProfile = name; }, + tickMs: 10_000, + }); + const sock = connect(socketPath()); + await new Promise((r) => sock.once("connect", r)); + const reply = readOne(sock); + sock.write(encode({ type: "refresh", profile: "prod" })); + const msg = await reply; + expect(msg.type).toBe("state"); + if (msg.type === "state") expect(msg.profiles).toEqual(fakeProfiles); + expect(refreshedProfile).toBe("prod"); + sock.destroy(); +}); + test("stop() resolves even with a lingering non-subscriber connection", async () => { server = await startServer({ startedAtIso: "2026-06-11T10:00:00.000Z", diff --git a/src/daemon/server.ts b/src/daemon/server.ts index df96e85..519349f 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -1,4 +1,5 @@ import { createServer, type Server, type Socket } from "node:net"; +import { chmodSync } from "node:fs"; import { encode, decodeStream, @@ -56,15 +57,25 @@ export async function startServer(deps: ServerDeps): Promise { case "refresh": { const targets = msg.profile ? [msg.profile] : state.filter((p) => p.favorite).map((p) => p.name); for (const name of targets) await deps.refreshProfile(name); - await broadcast(); + // Refresh state once, reply to requester, then notify subscribers. + await refreshState(); + const refreshMsg = encode({ type: "state", daemon: info, profiles: state } satisfies DaemonMessage); + if (!sock.destroyed) sock.write(refreshMsg); + for (const sub of subscribers) if (sub !== sock) sub.write(refreshMsg); break; } case "setFavorite": { await deps.setFavorite?.(msg.profile, msg.value); - await broadcast(); + // Refresh state once, reply to requester, then notify subscribers. + await refreshState(); + const favMsg = encode({ type: "state", daemon: info, profiles: state } satisfies DaemonMessage); + if (!sock.destroyed) sock.write(favMsg); + for (const sub of subscribers) if (sub !== sock) sub.write(favMsg); break; } case "stop": { + // Send a brief ack so the client's request() resolves before the socket closes. + if (!sock.destroyed) sock.write(encode({ type: "state", daemon: info, profiles: state } satisfies DaemonMessage)); await stop(); break; } @@ -92,6 +103,9 @@ export async function startServer(deps: ServerDeps): Promise { }); await new Promise((resolve) => netServer.listen(socketPath(), resolve)); + // Restrict socket permissions: if runtimeDir falls back to world-writable /tmp, + // this prevents other local users from connecting to control the daemon. + chmodSync(socketPath(), 0o600); writePidFile(process.pid); const interval = setInterval(() => void broadcast(), deps.tickMs ?? 60_000); From 73044908889cb70fc12b5229b2e4bb2a7f65e25a Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 16:36:21 +0200 Subject: [PATCH 23/29] =?UTF-8?q?feat(cli):=20redesign=20dashboard=20?= =?UTF-8?q?=E2=80=94=20highlight=20cursor,=20auto-refresh=20(a/=E2=9F=B3),?= =?UTF-8?q?=20headers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 8 +- README.md | 10 +-- src/cli/commands/status.test.ts | 2 + src/cli/commands/status.ts | 4 +- src/cli/components/App.tsx | 54 ++++++++++-- src/cli/index.tsx | 23 +++-- src/cli/tui/Dashboard.tsx | 143 ++++++++++++++++++++++---------- 7 files changed, 171 insertions(+), 73 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e24e932..39d6a49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Distributed via npm (`npx ssomatic`). Settings (favorites, notifications, refresh interval) are persisted across sessions. -SSOmatic runs a real per-host background daemon (single instance, Unix socket). It keeps ★ favorite profiles' role credentials fresh in an expiry-aware manner while the SSO token is valid, and sends a desktop notification when an interactive browser login is required — the daemon never opens a browser itself. The TUI attaches to the daemon over the socket for live state; any terminal that runs `ssomatic` while the daemon is up shows the live state. +SSOmatic runs a real per-host background daemon (single instance, Unix socket). It keeps ⟳ auto-refresh profiles' role credentials fresh in an expiry-aware manner while the SSO token is valid, and sends a desktop notification when an interactive browser login is required — the daemon never opens a browser itself. The TUI attaches to the daemon over the socket for live state; any terminal that runs `ssomatic` while the daemon is up shows the live state. ## Structure @@ -106,12 +106,10 @@ ssomatic --version | Key | Action | |-----|--------| | `↑` / `↓` / `k` / `j` | Move cursor | -| `space` | Select / deselect profile | -| `a` | Select all / deselect all | | `⏎` | Open details | -| `r` | Refresh selected (or current) profile(s) | +| `r` | Refresh the current profile | +| `a` | Toggle ⟳ auto-refresh (pin for the daemon) | | `b` | Run daemon in background | -| `f` | Toggle ★ favorite | | `c` | Copy export (`AWS_*` env vars) | | `y` | Copy profile name | | `o` | Open AWS console | diff --git a/README.md b/README.md index 427dfa5..2fb4c68 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Keep your AWS SSO credentials fresh — automatically. A fast terminal dashboard ## Why SSOmatic - **k9s-style list-first dashboard** — all your SSO profiles at a glance with live expiry countdowns; navigate with j/k or arrow keys, no menus to dig through. -- **Background daemon that keeps ★ favorites fresh** — expiry-aware refresh means credentials are always ready before they expire, with zero fixed-interval polling waste. +- **Background daemon that keeps ⟳ auto-refresh profiles fresh** — pin a profile for auto-refresh and expiry-aware refresh keeps its credentials ready before they expire, with zero fixed-interval polling waste. - **Notify-on-login, never surprise you** — when an interactive SSO login is required the daemon sends a desktop notification; it never opens a browser on its own. - **One-keystroke everything** — copy `export AWS_*` vars, open the AWS console, copy the profile name, or force a refresh — all from the dashboard without leaving your terminal. - **Attach from any terminal** — run `ssomatic` once to open the TUI; press `b` to push it to the background; re-run `ssomatic` from any terminal window to reconnect to the live daemon state. @@ -54,7 +54,7 @@ ssomatic ssomatic ``` -The daemon keeps your starred profiles' credentials fresh in the background. When a browser login is required, you get a desktop notification and can log in from the TUI or with `ssomatic refresh `. +The daemon keeps your ⟳ auto-refresh profiles' credentials fresh in the background. When a browser login is required, you get a desktop notification and can log in from the TUI or with `ssomatic refresh `. --- @@ -83,12 +83,10 @@ eval $(ssomatic export prod) | Key | Action | |-----|--------| | `↑` / `↓` or `j` / `k` | Move cursor | -| `Space` | Toggle profile selection | -| `a` | Select all / deselect all | | `Enter` | Open profile details | -| `r` | Refresh selected (or current) profile(s) | +| `r` | Refresh the current profile | +| `a` | Toggle ⟳ auto-refresh (pin for the daemon) | | `b` | Run daemon in background, detach TUI | -| `f` | Toggle ★ favorite | | `c` | Copy `export AWS_*` to clipboard | | `y` | Copy profile name to clipboard | | `o` | Open AWS console in browser | diff --git a/src/cli/commands/status.test.ts b/src/cli/commands/status.test.ts index a69da57..0832002 100644 --- a/src/cli/commands/status.test.ts +++ b/src/cli/commands/status.test.ts @@ -9,9 +9,11 @@ test("formatStatusTable renders aligned rows", () => { ]; const out = formatStatusTable(rows, new Date("2026-06-11T12:00:00.000Z")); const lines = out.split("\n"); + expect(lines[0]).toContain("⟳"); // auto-refresh marker for favorite profile expect(lines[0]).toContain("prod"); expect(lines[0]).toContain("valid"); expect(lines[0]).toContain("60m"); + expect(lines[1]).not.toContain("⟳"); // non-favorite has no marker expect(lines[1]).toContain("staging"); expect(lines[1]).toContain("needs-login"); }); diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index 3f811ec..d1f6afc 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -13,8 +13,8 @@ export function formatStatusTable(rows: ProfileState[], now: Date): string { const statusW = Math.max(6, ...rows.map((r) => r.status.length)); return rows .map((r) => { - const star = r.favorite ? "★ " : " "; - return `${star}${r.name.padEnd(nameW)} ${r.status.padEnd(statusW)} ${minsLeft(r.expiresAt, now)}`; + const marker = r.favorite ? "⟳ " : " "; + return `${marker}${r.name.padEnd(nameW)} ${r.status.padEnd(statusW)} ${minsLeft(r.expiresAt, now)}`; }) .join("\n"); } diff --git a/src/cli/components/App.tsx b/src/cli/components/App.tsx index 3d5ee37..b97975c 100644 --- a/src/cli/components/App.tsx +++ b/src/cli/components/App.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Box, Text, render as inkRender } from "ink"; +import { Box, Text, render as inkRender, useInput } from "ink"; import { ActionBar, ActionItem } from "./ActionBar.js"; export interface AppProps { @@ -8,38 +8,69 @@ export interface AppProps { color?: string; actions?: ActionItem[]; statusItems?: React.ReactNode[]; + /** When set, shows a colored daemon indicator right-aligned on the title row. */ + daemonRunning?: boolean; + /** Mount a global `q` → onQuit handler. Use ONLY on blocking screens that own no input. */ + captureQuit?: boolean; children: React.ReactNode; onQuit?: () => void; } +/** Fixed content width so the layout looks tidy on wide terminals. */ +const CONTENT_WIDTH = 68; + export function App({ title, icon = "▲", color = "cyan", actions, statusItems, + daemonRunning, + captureQuit = false, children, + onQuit, }: AppProps) { const hasStatusItems = !!statusItems && statusItems.length > 0; + const showDaemon = daemonRunning !== undefined; + + // Global quit handler for blocking screens (seeding / no-profiles) that + // otherwise have no useInput of their own. Ctrl-C remains native. + useInput( + (input) => { + if (input === "q") onQuit?.(); + }, + { isActive: captureQuit }, + ); return ( - + {/* Header */} - + {icon} {title} + {showDaemon && + (daemonRunning ? ( + ● running + ) : ( + ○ off + ))} {/* Content */} - - {children} - + {children} {/* Status bar */} {hasStatusItems && ( - + {statusItems.map((item, i) => ( {item} @@ -51,7 +82,14 @@ export function App({ {/* Action bar */} {actions && actions.length > 0 && ( - + )} diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 69a2799..3dc156f 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -294,7 +294,7 @@ function SSOmatic({ startDaemon = false }: SSOmaticProps) { [daemon, findProfile, settings.notifications, reloadLocal], ); - const handleToggleFavorite = useCallback( + const handleToggleAuto = useCallback( (name: string) => { const isFav = settings.favoriteProfiles.includes(name); const favoriteProfiles = isFav @@ -304,7 +304,7 @@ function SSOmatic({ startDaemon = false }: SSOmaticProps) { setSettings(next); saveSettings(next); void daemon.setFavorite(name, !isFav); // no-op if daemon down - void reloadLocal(); // update ★ immediately when no daemon + void reloadLocal(); // update the ⟳ marker immediately when no daemon }, [settings, daemon, reloadLocal], ); @@ -401,7 +401,7 @@ function SSOmatic({ startDaemon = false }: SSOmaticProps) { // Loading / seeding state. if (seeding && localStates.length === 0) { return ( - exit()}> + exit()}> ); @@ -410,7 +410,7 @@ function SSOmatic({ startDaemon = false }: SSOmaticProps) { // No profiles found. if (!seeding && ssoProfiles.length === 0 && localStates.length === 0) { return ( - exit()}> + exit()}> No SSO profiles found in ~/.aws/config ); @@ -433,7 +433,7 @@ function SSOmatic({ startDaemon = false }: SSOmaticProps) { if (view === "settings") { return ( - exit()}> + exit()}> setView("dashboard")} /> ); @@ -444,7 +444,7 @@ function SSOmatic({ startDaemon = false }: SSOmaticProps) { if (profile) { const sso = findProfile(detailName); return ( - exit()}> + exit()}>
exit()}> + exit()} + > void handleRefresh(names)} - onToggleFavorite={handleToggleFavorite} + onToggleAuto={handleToggleAuto} onRunBackground={handleRunBackground} onOpenDetails={handleOpenDetails} onOpenConsole={(name) => void handleOpenConsole(name)} diff --git a/src/cli/tui/Dashboard.tsx b/src/cli/tui/Dashboard.tsx index 482f950..a0c2014 100644 --- a/src/cli/tui/Dashboard.tsx +++ b/src/cli/tui/Dashboard.tsx @@ -6,7 +6,7 @@ interface Props { profiles: ProfileState[]; daemonRunning: boolean; onRefresh: (names: string[]) => void; - onToggleFavorite: (name: string) => void; + onToggleAuto: (name: string) => void; onRunBackground: () => void; onOpenDetails: (name: string) => void; onOpenConsole: (name: string) => void; @@ -16,6 +16,16 @@ interface Props { onQuit: () => void; } +// Column widths (chars). Total ≈ marker(2)+name(22)+status(15)+expires(10)+account(12). +const W_MARKER = 2; +const W_NAME = 22; +const W_STATUS = 15; +const W_EXPIRES = 10; +const W_ACCOUNT = 12; + +/** Marker shown for auto-refreshed (favorite) profiles. */ +const AUTO_MARKER = "⟳"; + const STATUS_COLOR: Record = { valid: "green", refreshing: "cyan", @@ -24,23 +34,58 @@ const STATUS_COLOR: Record = { error: "red", }; +const STATUS_LABEL: Record = { + valid: "● valid", + refreshing: "● refreshing", + expired: "○ expired", + "needs-login": "⚠ needs-login", + error: "✗ error", +}; + function minsLeft(expiresAt: string | null): string { if (!expiresAt) return "—"; const m = Math.round((new Date(expiresAt).getTime() - Date.now()) / 60000); return m <= 0 ? "expired" : `${m}m`; } +function pad(s: string, w: number): string { + return s.length >= w ? s : s + " ".repeat(w - s.length); +} + +/** A single fixed-width cell. Highlighted rows render inverse; columns never drift. */ +function Cell({ + text, + width, + color, + dim, + highlight, +}: { + text: string; + width: number; + color?: string; + dim?: boolean; + highlight?: boolean; +}) { + return ( + + + {pad(text, width)} + + + ); +} + export function Dashboard(props: Props) { const { profiles } = props; const [cursor, setCursor] = useState(0); - const [selected, setSelected] = useState>(new Set()); const [filter, setFilter] = useState(""); const [filtering, setFiltering] = useState(false); const visible = filter ? profiles.filter((p) => p.name.toLowerCase().includes(filter.toLowerCase())) : profiles; - const current = visible[Math.min(cursor, Math.max(0, visible.length - 1))]; + const cursorIndex = Math.min(cursor, Math.max(0, visible.length - 1)); + const current = visible[cursorIndex]; useInput((input, key) => { if (filtering) { @@ -52,21 +97,9 @@ export function Dashboard(props: Props) { if (key.upArrow || input === "k") setCursor((c) => Math.max(0, c - 1)); else if (key.downArrow || input === "j") setCursor((c) => Math.min(visible.length - 1, c + 1)); - else if (input === " " && current) { - setSelected((s) => { - const n = new Set(s); - if (n.has(current.name)) n.delete(current.name); - else n.add(current.name); - return n; - }); - } else if (input === "a") { - setSelected((s) => - s.size === visible.length ? new Set() : new Set(visible.map((p) => p.name)), - ); - } else if (input === "r") { - const names = selected.size ? [...selected] : current ? [current.name] : []; - if (names.length) props.onRefresh(names); - } else if (input === "f" && current) props.onToggleFavorite(current.name); + else if (input === "r") { + if (current) props.onRefresh([current.name]); + } else if (input === "a" && current) props.onToggleAuto(current.name); else if (input === "b") props.onRunBackground(); else if (input === "c" && current) props.onCopyExport(current.name); else if (input === "y" && current) props.onCopyName(current.name); @@ -79,33 +112,55 @@ export function Dashboard(props: Props) { return ( - - 🔐 SSOmatic - {props.daemonRunning ? "daemon ● running" : "daemon ○ off"} + + {/* top padding */} + + + {/* column headers */} + + + + + + + + + {filtering && ( + + /{filter} + + )} + + {visible.length === 0 && ( + + (no profiles) + + )} + + {visible.map((p, i) => { + const hi = i === cursorIndex; + const expires = minsLeft(p.expiresAt); + const expiresColor = expires === "expired" ? "yellow" : undefined; + return ( + + + + + + + + ); + })} + + {/* bottom padding */} + + + + + ↑↓ move ⏎ details r refresh a auto-refresh b background + c copy y name o console / filter s settings q quit + {AUTO_MARKER} = auto-refreshed by the daemon - {"─".repeat(48)} - {filtering && /{filter}} - {visible.length === 0 && (no profiles)} - {visible.map((p) => { - const isCursor = current?.name === p.name; - const isSel = selected.has(p.name); - return ( - - {isCursor ? "▸ " : " "} - {isSel ? "◉ " : " "} - {p.favorite ? "★ " : " "} - {p.name.padEnd(22)} - {p.status.padEnd(12)} - {minsLeft(p.expiresAt).padEnd(8)} - {p.accountId ?? ""} - - ); - })} - {"─".repeat(48)} - ↑↓ move space sel ⏎ details r refresh b bg - - f ★ c copy y name o console / filter s settings q quit - ); } From 648ef6ece058d4ee8b18d6f7f132130250d3fcd1 Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 17:07:14 +0200 Subject: [PATCH 24/29] fix(cli): make dashboard footer keys stand out and tie a to the auto-refresh marker --- src/cli/tui/Dashboard.tsx | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/cli/tui/Dashboard.tsx b/src/cli/tui/Dashboard.tsx index a0c2014..022c631 100644 --- a/src/cli/tui/Dashboard.tsx +++ b/src/cli/tui/Dashboard.tsx @@ -52,6 +52,16 @@ function pad(s: string, w: number): string { return s.length >= w ? s : s + " ".repeat(w - s.length); } +/** A keyboard shortcut hint: key char(s) in bold cyan, description in dim. */ +function Key({ k, children }: { k: string; children: React.ReactNode }) { + return ( + + {k} + {children} + + ); +} + /** A single fixed-width cell. Highlighted rows render inverse; columns never drift. */ function Cell({ text, @@ -157,8 +167,21 @@ export function Dashboard(props: Props) { - ↑↓ move ⏎ details r refresh a auto-refresh b background - c copy y name o console / filter s settings q quit + + move + details + refresh + {AUTO_MARKER} auto-refresh + background + + + copy + name + console + filter + settings + quit + {AUTO_MARKER} = auto-refreshed by the daemon From bcb910691220c59c5a8c2099f01f6b7f2909ee01 Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 17:28:59 +0200 Subject: [PATCH 25/29] fix(cli): harmonize shortcut footers across screens and make q quit on login/settings/details --- src/cli/components/KeyHint.tsx | 15 +++++++++++++++ src/cli/components/index.ts | 1 + src/cli/index.tsx | 26 +++++++++++++++++++++----- src/cli/tui/Dashboard.tsx | 11 +---------- src/cli/tui/Details.tsx | 20 ++++++++++++++++---- src/cli/tui/Settings.tsx | 16 ++++++++++++++-- 6 files changed, 68 insertions(+), 21 deletions(-) create mode 100644 src/cli/components/KeyHint.tsx diff --git a/src/cli/components/KeyHint.tsx b/src/cli/components/KeyHint.tsx new file mode 100644 index 0000000..322e299 --- /dev/null +++ b/src/cli/components/KeyHint.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { Text } from "ink"; + +/** + * A keyboard-shortcut hint: key char(s) in bold cyan followed by a dim label. + * Trailing double-space acts as a separator between hints on the same row. + */ +export function Key({ k, children }: { k: string; children: React.ReactNode }) { + return ( + + {k} + {children} + + ); +} diff --git a/src/cli/components/index.ts b/src/cli/components/index.ts index 61d4685..356c240 100644 --- a/src/cli/components/index.ts +++ b/src/cli/components/index.ts @@ -3,6 +3,7 @@ export { App, renderApp, type AppProps } from "./App.js"; // Interactive components export { ActionBar, ACTIONS, type ActionItem, type ActionBarProps } from "./ActionBar.js"; +export { Key } from "./KeyHint.js"; // Feedback components export { Spinner, type SpinnerProps } from "./Spinner.js"; diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 3dc156f..d66d524 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -11,7 +11,7 @@ import { runExport } from "./commands/export.js"; import { runRefresh } from "./commands/refresh.js"; import { runDaemonCommand } from "./commands/daemon.js"; import { runDaemon } from "../daemon/index.js"; -import { App, renderApp, Spinner, StatusMessage, ACTIONS } from "./components/index.js"; +import { App, renderApp, Spinner, StatusMessage, ACTIONS, Key } from "./components/index.js"; import { useCopy } from "./hooks/index.js"; import { Dashboard } from "./tui/Dashboard.js"; import { Details } from "./tui/Details.js"; @@ -129,7 +129,10 @@ function LoginPrompt({ profile, deviceAuth, authError = false, copied = false, a Failed to start device authorization. Check your network and SSO configuration. - Press Esc to go back + + back + quit + ); } @@ -139,6 +142,9 @@ function LoginPrompt({ profile, deviceAuth, authError = false, copied = false, a SSO login required for {profile.name} + + quit + ); } @@ -161,7 +167,12 @@ function LoginPrompt({ profile, deviceAuth, authError = false, copied = false, a {authorizing && } - Press Enter to open browser, c to copy URL + + open browser + copy URL + cancel + quit + ); @@ -255,6 +266,10 @@ function SSOmatic({ startDaemon = false }: SSOmaticProps) { useInput( (input, key) => { if (!pendingLogin) return; + if (input === "q") { + exit(); + return; + } if (authError) { if (key.escape) setPendingLogin(null); return; @@ -419,7 +434,7 @@ function SSOmatic({ startDaemon = false }: SSOmaticProps) { // Login overlay takes precedence over the active view. if (pendingLogin) { return ( - exit()}> + exit()}> exit()}> - setView("dashboard")} /> + setView("dashboard")} onQuit={() => exit()} /> ); } @@ -451,6 +466,7 @@ function SSOmatic({ startDaemon = false }: SSOmaticProps) { region={sso?.ssoRegion} startUrl={sso?.ssoStartUrl} onBack={() => setView("dashboard")} + onQuit={() => exit()} /> ); diff --git a/src/cli/tui/Dashboard.tsx b/src/cli/tui/Dashboard.tsx index 022c631..d156c0c 100644 --- a/src/cli/tui/Dashboard.tsx +++ b/src/cli/tui/Dashboard.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react"; import { Box, Text, useInput } from "ink"; import type { ProfileState, ProfileStatusKind } from "../../daemon/protocol.js"; +import { Key } from "../components/KeyHint.js"; interface Props { profiles: ProfileState[]; @@ -52,16 +53,6 @@ function pad(s: string, w: number): string { return s.length >= w ? s : s + " ".repeat(w - s.length); } -/** A keyboard shortcut hint: key char(s) in bold cyan, description in dim. */ -function Key({ k, children }: { k: string; children: React.ReactNode }) { - return ( - - {k} - {children} - - ); -} - /** A single fixed-width cell. Highlighted rows render inverse; columns never drift. */ function Cell({ text, diff --git a/src/cli/tui/Details.tsx b/src/cli/tui/Details.tsx index 3d5392a..f95b9c1 100644 --- a/src/cli/tui/Details.tsx +++ b/src/cli/tui/Details.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Box, Text, useInput } from "ink"; import type { ProfileState } from "../../daemon/protocol.js"; +import { Key } from "../components/KeyHint.js"; interface Props { profile: ProfileState; @@ -8,11 +9,18 @@ interface Props { region?: string; startUrl?: string; onBack: () => void; + onQuit: () => void; } -export function Details({ profile, arn, region, startUrl, onBack }: Props) { - useInput((_input, key) => { - if (key.escape || key.return) onBack(); +export function Details({ profile, arn, region, startUrl, onBack, onQuit }: Props) { + useInput((input, key) => { + if (key.escape || key.return) { + onBack(); + return; + } + if (input === "q") { + onQuit(); + } }); const row = (label: string, value: string) => ( @@ -29,7 +37,11 @@ export function Details({ profile, arn, region, startUrl, onBack }: Props) { {row("status", profile.status)} {row("expires", profile.expiresAt ?? "—")} {row("sso url", startUrl ?? "—")} - esc back + + back + back + quit + ); } diff --git a/src/cli/tui/Settings.tsx b/src/cli/tui/Settings.tsx index c148411..990043b 100644 --- a/src/cli/tui/Settings.tsx +++ b/src/cli/tui/Settings.tsx @@ -1,14 +1,16 @@ import React, { useState } from "react"; import { Box, Text, useInput } from "ink"; import type { AppSettings } from "../../aws/settings.js"; +import { Key } from "../components/KeyHint.js"; interface Props { settings: AppSettings; onChange: (next: AppSettings) => void; onBack: () => void; + onQuit: () => void; } -export function Settings({ settings, onChange, onBack }: Props) { +export function Settings({ settings, onChange, onBack, onQuit }: Props) { const [cursor, setCursor] = useState(0); const items = ["notifications", "refreshLeadMinutes", "autoStartDaemon"] as const; useInput((input, key) => { @@ -16,6 +18,10 @@ export function Settings({ settings, onChange, onBack }: Props) { onBack(); return; } + if (input === "q") { + onQuit(); + return; + } if (key.upArrow || input === "k") { setCursor((c) => Math.max(0, c - 1)); return; @@ -50,7 +56,13 @@ export function Settings({ settings, onChange, onBack }: Props) { {line(0, "Notifications", settings.notifications ? "on" : "off")} {line(1, "Refresh lead (min)", String(settings.refreshLeadMinutes) + " (←/→)")} {line(2, "Auto-start daemon", settings.autoStartDaemon ? "on" : "off")} - space/⏎ toggle ←→ adjust esc back + + move + toggle + adjust + back + quit + ); } From 62748e0f25550575e6044be6dbfe158b0ba21170 Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 17:34:25 +0200 Subject: [PATCH 26/29] fix(cli): harmonize shortcut footers; Esc=back on sub-screens, q quits at home only --- src/cli/index.tsx | 13 +++---------- src/cli/tui/Details.tsx | 11 ++--------- src/cli/tui/Settings.tsx | 8 +------- 3 files changed, 6 insertions(+), 26 deletions(-) diff --git a/src/cli/index.tsx b/src/cli/index.tsx index d66d524..3efbdf5 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -131,7 +131,6 @@ function LoginPrompt({ profile, deviceAuth, authError = false, copied = false, a back - quit ); @@ -143,7 +142,7 @@ function LoginPrompt({ profile, deviceAuth, authError = false, copied = false, a SSO login required for {profile.name} - quit + cancel ); @@ -171,7 +170,6 @@ function LoginPrompt({ profile, deviceAuth, authError = false, copied = false, a open browser copy URL cancel - quit @@ -266,17 +264,13 @@ function SSOmatic({ startDaemon = false }: SSOmaticProps) { useInput( (input, key) => { if (!pendingLogin) return; - if (input === "q") { - exit(); - return; - } if (authError) { if (key.escape) setPendingLogin(null); return; } if (key.return) handleEnter(); if (input === "c") handleCopy(); - if (key.escape && !authorizing) setPendingLogin(null); + if (key.escape) setPendingLogin(null); }, { isActive: !!pendingLogin }, ); @@ -449,7 +443,7 @@ function SSOmatic({ startDaemon = false }: SSOmaticProps) { if (view === "settings") { return ( exit()}> - setView("dashboard")} onQuit={() => exit()} /> + setView("dashboard")} /> ); } @@ -466,7 +460,6 @@ function SSOmatic({ startDaemon = false }: SSOmaticProps) { region={sso?.ssoRegion} startUrl={sso?.ssoStartUrl} onBack={() => setView("dashboard")} - onQuit={() => exit()} /> ); diff --git a/src/cli/tui/Details.tsx b/src/cli/tui/Details.tsx index f95b9c1..2e41191 100644 --- a/src/cli/tui/Details.tsx +++ b/src/cli/tui/Details.tsx @@ -9,17 +9,12 @@ interface Props { region?: string; startUrl?: string; onBack: () => void; - onQuit: () => void; } -export function Details({ profile, arn, region, startUrl, onBack, onQuit }: Props) { - useInput((input, key) => { +export function Details({ profile, arn, region, startUrl, onBack }: Props) { + useInput((_input, key) => { if (key.escape || key.return) { onBack(); - return; - } - if (input === "q") { - onQuit(); } }); const row = (label: string, value: string) => ( @@ -38,9 +33,7 @@ export function Details({ profile, arn, region, startUrl, onBack, onQuit }: Prop {row("expires", profile.expiresAt ?? "—")} {row("sso url", startUrl ?? "—")} - back back - quit ); diff --git a/src/cli/tui/Settings.tsx b/src/cli/tui/Settings.tsx index 990043b..415d4dd 100644 --- a/src/cli/tui/Settings.tsx +++ b/src/cli/tui/Settings.tsx @@ -7,10 +7,9 @@ interface Props { settings: AppSettings; onChange: (next: AppSettings) => void; onBack: () => void; - onQuit: () => void; } -export function Settings({ settings, onChange, onBack, onQuit }: Props) { +export function Settings({ settings, onChange, onBack }: Props) { const [cursor, setCursor] = useState(0); const items = ["notifications", "refreshLeadMinutes", "autoStartDaemon"] as const; useInput((input, key) => { @@ -18,10 +17,6 @@ export function Settings({ settings, onChange, onBack, onQuit }: Props) { onBack(); return; } - if (input === "q") { - onQuit(); - return; - } if (key.upArrow || input === "k") { setCursor((c) => Math.max(0, c - 1)); return; @@ -61,7 +56,6 @@ export function Settings({ settings, onChange, onBack, onQuit }: Props) { toggle adjust back - quit ); From c36e073be2e7556beae02073fd62cf6cce5c1a31 Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 17:50:01 +0200 Subject: [PATCH 27/29] refactor(cli): replace daemon dependency with in-process auto-refresh in the TUI --- src/aws/profileState.ts | 12 ++- src/aws/refreshScheduler.test.ts | 24 +++++ src/aws/refreshScheduler.ts | 13 +++ src/cli/commands/status.test.ts | 2 +- src/cli/commands/status.ts | 2 +- src/cli/components/App.tsx | 10 -- src/cli/index.tsx | 124 +++++++++------------ src/cli/tui/Dashboard.tsx | 8 +- src/cli/tui/Details.tsx | 2 +- src/cli/tui/useAutoRefresh.ts | 180 +++++++++++++++++++++++++++++++ src/cli/tui/useDaemon.ts | 74 ------------- src/daemon/protocol.ts | 15 +-- 12 files changed, 287 insertions(+), 179 deletions(-) create mode 100644 src/aws/refreshScheduler.test.ts create mode 100644 src/aws/refreshScheduler.ts create mode 100644 src/cli/tui/useAutoRefresh.ts delete mode 100644 src/cli/tui/useDaemon.ts diff --git a/src/aws/profileState.ts b/src/aws/profileState.ts index 0724979..58404d4 100644 --- a/src/aws/profileState.ts +++ b/src/aws/profileState.ts @@ -1,7 +1,17 @@ -import type { ProfileState } from "../daemon/protocol.js"; import { discoverProfiles, findCachedToken } from "./sso.js"; import { loadSettings } from "./settings.js"; +export type ProfileStatusKind = "valid" | "expired" | "needs-login" | "error" | "refreshing"; + +export interface ProfileState { + name: string; + status: ProfileStatusKind; + expiresAt: string | null; // ISO string or null + favorite: boolean; + accountId?: string; + error?: string; +} + /** * Build the list of profile states from local disk (config + SSO token cache). * Shared by the CLI `status` command and the TUI root so there is a single diff --git a/src/aws/refreshScheduler.test.ts b/src/aws/refreshScheduler.test.ts new file mode 100644 index 0000000..1f59368 --- /dev/null +++ b/src/aws/refreshScheduler.test.ts @@ -0,0 +1,24 @@ +import { test, expect } from "bun:test"; +import { decideAction } from "./refreshScheduler"; + +const now = new Date("2026-06-11T12:00:00.000Z"); +const leadMs = 5 * 60 * 1000; + +test("refresh when within lead window of expiry", () => { + const expiresAt = new Date("2026-06-11T12:03:00.000Z"); // 3m left < 5m lead + expect(decideAction({ ssoTokenValid: true, credsExpireAt: expiresAt }, now, leadMs)).toBe("refresh"); +}); + +test("wait when comfortably before lead window", () => { + const expiresAt = new Date("2026-06-11T12:30:00.000Z"); // 30m left + expect(decideAction({ ssoTokenValid: true, credsExpireAt: expiresAt }, now, leadMs)).toBe("wait"); +}); + +test("needs-login when sso token invalid regardless of creds", () => { + const expiresAt = new Date("2026-06-11T12:30:00.000Z"); + expect(decideAction({ ssoTokenValid: false, credsExpireAt: expiresAt }, now, leadMs)).toBe("needs-login"); +}); + +test("refresh when there are no creds yet but sso token is valid", () => { + expect(decideAction({ ssoTokenValid: true, credsExpireAt: null }, now, leadMs)).toBe("refresh"); +}); diff --git a/src/aws/refreshScheduler.ts b/src/aws/refreshScheduler.ts new file mode 100644 index 0000000..b40ae55 --- /dev/null +++ b/src/aws/refreshScheduler.ts @@ -0,0 +1,13 @@ +export type Action = "refresh" | "wait" | "needs-login"; + +export interface ProfileTiming { + ssoTokenValid: boolean; // is the cached SSO token still valid? + credsExpireAt: Date | null; // when current role creds expire (null = none/unknown) +} + +export function decideAction(timing: ProfileTiming, now: Date, leadMs: number): Action { + if (!timing.ssoTokenValid) return "needs-login"; + if (timing.credsExpireAt === null) return "refresh"; + const msLeft = timing.credsExpireAt.getTime() - now.getTime(); + return msLeft <= leadMs ? "refresh" : "wait"; +} diff --git a/src/cli/commands/status.test.ts b/src/cli/commands/status.test.ts index 0832002..793e6f9 100644 --- a/src/cli/commands/status.test.ts +++ b/src/cli/commands/status.test.ts @@ -1,6 +1,6 @@ import { test, expect } from "bun:test"; import { formatStatusTable } from "./status"; -import type { ProfileState } from "../../daemon/protocol"; +import type { ProfileState } from "../../aws/profileState"; test("formatStatusTable renders aligned rows", () => { const rows: ProfileState[] = [ diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index d1f6afc..122c4e7 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -1,5 +1,5 @@ import { request, isDaemonAlive } from "../../daemon/client"; -import type { ProfileState } from "../../daemon/protocol"; +import type { ProfileState } from "../../aws/profileState"; import { buildLocalProfileStates } from "../../aws/profileState"; function minsLeft(expiresAt: string | null, now: Date): string { diff --git a/src/cli/components/App.tsx b/src/cli/components/App.tsx index b97975c..ea463ce 100644 --- a/src/cli/components/App.tsx +++ b/src/cli/components/App.tsx @@ -8,8 +8,6 @@ export interface AppProps { color?: string; actions?: ActionItem[]; statusItems?: React.ReactNode[]; - /** When set, shows a colored daemon indicator right-aligned on the title row. */ - daemonRunning?: boolean; /** Mount a global `q` → onQuit handler. Use ONLY on blocking screens that own no input. */ captureQuit?: boolean; children: React.ReactNode; @@ -25,13 +23,11 @@ export function App({ color = "cyan", actions, statusItems, - daemonRunning, captureQuit = false, children, onQuit, }: AppProps) { const hasStatusItems = !!statusItems && statusItems.length > 0; - const showDaemon = daemonRunning !== undefined; // Global quit handler for blocking screens (seeding / no-profiles) that // otherwise have no useInput of their own. Ctrl-C remains native. @@ -49,12 +45,6 @@ export function App({ {icon} {title} - {showDaemon && - (daemonRunning ? ( - ● running - ) : ( - ○ off - ))} {/* Content */} diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 3efbdf5..5ea1ab8 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -16,8 +16,7 @@ import { useCopy } from "./hooks/index.js"; import { Dashboard } from "./tui/Dashboard.js"; import { Details } from "./tui/Details.js"; import { Settings } from "./tui/Settings.js"; -import { useDaemon } from "./tui/useDaemon.js"; -import { buildLocalProfileStates } from "../aws/profileState.js"; +import { useAutoRefresh } from "./tui/useAutoRefresh.js"; import { type SSOProfile, type DeviceAuthInfo, @@ -32,7 +31,6 @@ import { import { buildExportBlock, getConsoleSigninUrl } from "../aws/console.js"; import { copyToClipboard } from "../aws/utils.js"; import { loadSettings, saveSettings, type AppSettings } from "../aws/settings.js"; -import type { ProfileState } from "../daemon/protocol.js"; import { VERSION, checkForUpdate } from "../version.js"; type ViewState = "dashboard" | "details" | "settings"; @@ -180,43 +178,38 @@ function LoginPrompt({ profile, deviceAuth, authError = false, copied = false, a // Main Component // ───────────────────────────────────────────────────────────────────────────── -interface SSOmaticProps { - startDaemon?: boolean; -} - -function SSOmatic({ startDaemon = false }: SSOmaticProps) { +function SSOmatic() { const { exit } = useApp(); const [view, setView] = useState("dashboard"); const [detailName, setDetailName] = useState(null); const [settings, setSettings] = useState(loadSettings()); - const [localStates, setLocalStates] = useState([]); const [ssoProfiles, setSSOProfiles] = useState([]); const [seeding, setSeeding] = useState(true); const [updateAvailable, setUpdateAvailable] = useState(null); const [feedback, setFeedback] = useState(null); const [pendingLogin, setPendingLogin] = useState(null); - const daemon = useDaemon(localStates); - const startBackgroundOnceRef = React.useRef(false); - - // Re-read local disk state (after refresh / favorite changes when no daemon). - const reloadLocal = useCallback(async () => { - const states = await buildLocalProfileStates(); - setLocalStates(states); + // Notify-once on auto-refresh login expiry, respecting the notifications setting. + const settingsRef = React.useRef(settings); + useEffect(() => { + settingsRef.current = settings; + }, [settings]); + const onNeedsLogin = useCallback((name: string) => { + if (settingsRef.current.notifications) { + void sendNotification("SSO Login Required", `Token expired for profile '${name}'`); + } }, []); - // Seed initial local state + discovered profiles on mount. + const { profiles, reload, refreshOne, setAuto } = useAutoRefresh(settings, onNeedsLogin); + + // Seed discovered SSO profiles on mount (the hook seeds the profile states). useEffect(() => { let cancelled = false; void (async () => { - const [states, profiles] = await Promise.all([ - buildLocalProfileStates(), - discoverProfiles(), - ]); + const discovered = await discoverProfiles(); if (cancelled) return; - setLocalStates(states); - setSSOProfiles(profiles); + setSSOProfiles(discovered); setSeeding(false); })(); return () => { @@ -229,30 +222,21 @@ function SSOmatic({ startDaemon = false }: SSOmaticProps) { checkForUpdate().then(setUpdateAvailable); }, []); - // Optionally auto-start the background daemon (once). - useEffect(() => { - if (startDaemon && !startBackgroundOnceRef.current) { - startBackgroundOnceRef.current = true; - void daemon.startBackground(); - } - }, [startDaemon, daemon]); - const findProfile = useCallback( (name: string): SSOProfile | undefined => ssoProfiles.find((p) => p.name === name), [ssoProfiles], ); - // The profiles displayed in the dashboard: live daemon state when running, - // local disk state otherwise. - const displayProfiles = daemon.running ? daemon.profiles : localStates; + // The profiles displayed in the dashboard come from the in-process hook. + const displayProfiles = profiles; - // ── Interactive login (local, no daemon) ────────────────────────────────── + // ── Interactive login ────────────────────────────────────────────────────── const handleLoginComplete = useCallback( (_profile: SSOProfile, _result: { success: boolean; error?: string }) => { setPendingLogin(null); - void reloadLocal(); + void reload(); }, - [reloadLocal], + [reload], ); const { deviceAuth, authorizing, authError, copied, handleEnter, handleCopy } = useDeviceAuth({ @@ -278,29 +262,24 @@ function SSOmatic({ startDaemon = false }: SSOmaticProps) { // ── Dashboard handlers ──────────────────────────────────────────────────── const handleRefresh = useCallback( async (names: string[]) => { - if (daemon.running) { - // Single name → targeted refresh; multiple/all → refresh everything. - if (names.length === 1) await daemon.refresh(names[0]); - else await daemon.refresh(); - return; - } - // Local refresh: process each profile; the first that needs login triggers - // the interactive device-auth flow. - for (const name of names) { + const name = names[0]; + if (!name) return; + setFeedback(`Refreshing ${name}…`); + const result = await refreshOne(name); + if (result.needsLogin) { const profile = findProfile(name); - if (!profile) continue; - const result = await refreshProfile(profile); - if (result.needsLogin) { - if (settings.notifications) { - await sendNotification("SSO Login Required", `Token expired for profile '${name}'`); - } - setPendingLogin(profile); - return; // login completion will reload local state + if (profile) { + setFeedback(`${name} needs login`); + setPendingLogin(profile); // login completion will reload state + return; } + setFeedback(`${name} needs login`); + return; } - await reloadLocal(); + if (result.ok) setFeedback(`Refreshed ${name}`); + else setFeedback(`${name}: ${result.error ?? "refresh failed"}`); }, - [daemon, findProfile, settings.notifications, reloadLocal], + [refreshOne, findProfile], ); const handleToggleAuto = useCallback( @@ -311,17 +290,12 @@ function SSOmatic({ startDaemon = false }: SSOmaticProps) { : [...settings.favoriteProfiles, name]; const next = { ...settings, favoriteProfiles }; setSettings(next); - saveSettings(next); - void daemon.setFavorite(name, !isFav); // no-op if daemon down - void reloadLocal(); // update the ⟳ marker immediately when no daemon + setAuto(name, !isFav); // persists settings + reloads the ⟳ marker + setFeedback(`⟳ ${isFav ? "off" : "on"} for ${name}`); }, - [settings, daemon, reloadLocal], + [settings, setAuto], ); - const handleRunBackground = useCallback(() => { - void daemon.startBackground(); - }, [daemon]); - const handleCopyExport = useCallback( async (name: string) => { let creds = readProfileCredentials(name); @@ -408,7 +382,7 @@ function SSOmatic({ startDaemon = false }: SSOmaticProps) { ); // Loading / seeding state. - if (seeding && localStates.length === 0) { + if (seeding && profiles.length === 0) { return ( exit()}> @@ -417,7 +391,7 @@ function SSOmatic({ startDaemon = false }: SSOmaticProps) { } // No profiles found. - if (!seeding && ssoProfiles.length === 0 && localStates.length === 0) { + if (!seeding && ssoProfiles.length === 0 && profiles.length === 0) { return ( exit()}> No SSO profiles found in ~/.aws/config @@ -442,7 +416,7 @@ function SSOmatic({ startDaemon = false }: SSOmaticProps) { if (view === "settings") { return ( - exit()}> + exit()}> setView("dashboard")} /> ); @@ -453,7 +427,7 @@ function SSOmatic({ startDaemon = false }: SSOmaticProps) { if (profile) { const sso = findProfile(detailName); return ( - exit()}> + exit()}>
exit()} > void handleRefresh(names)} onToggleAuto={handleToggleAuto} - onRunBackground={handleRunBackground} onOpenDetails={handleOpenDetails} onOpenConsole={(name) => void handleOpenConsole(name)} onCopyExport={(name) => void handleCopyExport(name)} @@ -500,7 +471,6 @@ const HELP = `ssomatic — interactive AWS SSO credential manager Usage: ssomatic launch the interactive TUI - ssomatic --daemon launch the TUI and start the background daemon ssomatic status print profile statuses and exit ssomatic refresh [name] refresh a profile (or all favorites) now ssomatic export print export AWS_* lines for eval $(...) @@ -508,8 +478,12 @@ Usage: ssomatic --version `; -function launchTui(startDaemon: boolean): void { - renderApp(); +async function launchTui(): Promise { + const instance = renderApp(); + // Always terminate promptly on quit; the in-process auto-refresh interval is + // cleared on unmount, so there are no lingering handles. + await instance.waitUntilExit(); + process.exit(0); } async function main(): Promise { @@ -541,7 +515,7 @@ async function main(): Promise { process.exit(1); return; case "tui": - launchTui(parsed.daemon); + await launchTui(); return; } } diff --git a/src/cli/tui/Dashboard.tsx b/src/cli/tui/Dashboard.tsx index d156c0c..867aa53 100644 --- a/src/cli/tui/Dashboard.tsx +++ b/src/cli/tui/Dashboard.tsx @@ -1,14 +1,12 @@ import React, { useState } from "react"; import { Box, Text, useInput } from "ink"; -import type { ProfileState, ProfileStatusKind } from "../../daemon/protocol.js"; +import type { ProfileState, ProfileStatusKind } from "../../aws/profileState.js"; import { Key } from "../components/KeyHint.js"; interface Props { profiles: ProfileState[]; - daemonRunning: boolean; onRefresh: (names: string[]) => void; onToggleAuto: (name: string) => void; - onRunBackground: () => void; onOpenDetails: (name: string) => void; onOpenConsole: (name: string) => void; onCopyExport: (name: string) => void; @@ -101,7 +99,6 @@ export function Dashboard(props: Props) { else if (input === "r") { if (current) props.onRefresh([current.name]); } else if (input === "a" && current) props.onToggleAuto(current.name); - else if (input === "b") props.onRunBackground(); else if (input === "c" && current) props.onCopyExport(current.name); else if (input === "y" && current) props.onCopyName(current.name); else if (input === "o" && current) props.onOpenConsole(current.name); @@ -163,7 +160,6 @@ export function Dashboard(props: Props) { details refresh {AUTO_MARKER} auto-refresh - background copy @@ -173,7 +169,7 @@ export function Dashboard(props: Props) { settings quit - {AUTO_MARKER} = auto-refreshed by the daemon + {AUTO_MARKER} = auto-refreshed while open ); diff --git a/src/cli/tui/Details.tsx b/src/cli/tui/Details.tsx index 2e41191..7dbfba4 100644 --- a/src/cli/tui/Details.tsx +++ b/src/cli/tui/Details.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Box, Text, useInput } from "ink"; -import type { ProfileState } from "../../daemon/protocol.js"; +import type { ProfileState } from "../../aws/profileState.js"; import { Key } from "../components/KeyHint.js"; interface Props { diff --git a/src/cli/tui/useAutoRefresh.ts b/src/cli/tui/useAutoRefresh.ts new file mode 100644 index 0000000..8d5a5bf --- /dev/null +++ b/src/cli/tui/useAutoRefresh.ts @@ -0,0 +1,180 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + buildLocalProfileStates, + type ProfileState, + type ProfileStatusKind, +} from "../../aws/profileState.js"; +import { decideAction } from "../../aws/refreshScheduler.js"; +import { discoverProfiles, findCachedToken, refreshProfile } from "../../aws/sso.js"; +import { saveSettings, type AppSettings } from "../../aws/settings.js"; + +/** How often the in-process loop checks favorites for due refreshes. */ +const TICK_MS = 30_000; + +/** Fallback credential TTL when AWS omits the credential expiration in the response. */ +const DEFAULT_CRED_TTL_MS = 50 * 60 * 1000; + +export interface AutoRefreshView { + profiles: ProfileState[]; + reload: () => Promise; + refreshOne: (name: string) => Promise<{ needsLogin: boolean; ok: boolean; error?: string }>; + setAuto: (name: string, value: boolean) => void; +} + +/** + * In-process auto-refresh for the TUI. While the dashboard is open it keeps the + * ⟳ (favorite) profiles fresh: every tick it decides, per favorite, whether the + * cached role credentials are due for a silent refresh and performs it, + * expiry-aware. Replaces the old background daemon — no sockets, no detach. + * + * Ported from the daemon's `computeState`: SSO-token validity comes from the + * cache file, role-cred expiry is tracked in a ref-held Map, and a notify-once + * set drives the optional `onNeedsLogin` callback. + */ +export function useAutoRefresh( + settings: AppSettings, + onNeedsLogin?: (name: string) => void, +): AutoRefreshView { + const [profiles, setProfiles] = useState([]); + const credExpiry = useRef(new Map()); + const notified = useRef(new Set()); + + // Keep the latest settings + callback in refs so the interval closure always + // reads current values without resubscribing the timer. + const settingsRef = useRef(settings); + const onNeedsLoginRef = useRef(onNeedsLogin); + useEffect(() => { + settingsRef.current = settings; + onNeedsLoginRef.current = onNeedsLogin; + }, [settings, onNeedsLogin]); + + const reload = useCallback(async () => { + const states = await buildLocalProfileStates(); + setProfiles(states); + }, []); + + // Seed from disk on mount. + useEffect(() => { + void reload(); + }, [reload]); + + // Recompute the full ProfileState[] from disk + tracked cred expiry, + // performing silent refreshes for favorites that are due. + const tick = useCallback(async () => { + const s = settingsRef.current; + const leadMs = s.refreshLeadMinutes * 60 * 1000; + const favorites = new Set(s.favoriteProfiles); + const now = new Date(); + const states: ProfileState[] = []; + + for (const p of await discoverProfiles()) { + const cachedToken = await findCachedToken(p); + const ssoTokenValid = cachedToken !== null && cachedToken.expiresAt > now; + const credsExpireAt: Date | null = credExpiry.current.get(p.name) ?? null; + + const favorite = favorites.has(p.name); + let status: ProfileStatusKind = ssoTokenValid ? "valid" : "needs-login"; + let errorMsg: string | undefined; + + if (favorite) { + const action = decideAction({ ssoTokenValid, credsExpireAt }, now, leadMs); + + if (action === "refresh") { + const r = await refreshProfile(p); + if (r.success) { + notified.current.delete(p.name); + status = "valid"; + credExpiry.current.set(p.name, r.expiresAt ?? new Date(Date.now() + DEFAULT_CRED_TTL_MS)); + } else if (r.needsLogin) { + status = "needs-login"; + credExpiry.current.delete(p.name); + if (!notified.current.has(p.name)) { + notified.current.add(p.name); + onNeedsLoginRef.current?.(p.name); + } + } else { + status = "error"; + errorMsg = r.error; + } + } else if (action === "needs-login") { + status = "needs-login"; + credExpiry.current.delete(p.name); + if (!notified.current.has(p.name)) { + notified.current.add(p.name); + onNeedsLoginRef.current?.(p.name); + } + } + // action === "wait" → keep status derived from ssoTokenValid above + } + + const trackedExpiry = credExpiry.current.get(p.name); + states.push({ + name: p.name, + status, + expiresAt: trackedExpiry + ? trackedExpiry.toISOString() + : cachedToken + ? cachedToken.expiresAt.toISOString() + : null, + favorite, + accountId: p.ssoAccountId, + ...(errorMsg !== undefined && { error: errorMsg }), + }); + } + + setProfiles(states); + }, []); + + // Run the auto-refresh loop while mounted. The callback never throws + // unhandled; any failure is swallowed so the timer survives. + useEffect(() => { + const id = setInterval(() => { + void tick().catch(() => { + /* keep the loop alive on transient failures */ + }); + }, TICK_MS); + return () => clearInterval(id); + }, [tick]); + + // Refresh a single profile immediately (silent). Returns whether the caller + // should kick off an interactive device-auth flow. + const refreshOne = useCallback( + async (name: string): Promise<{ needsLogin: boolean; ok: boolean; error?: string }> => { + const profiles = await discoverProfiles(); + const p = profiles.find((x) => x.name === name); + if (!p) return { needsLogin: false, ok: false, error: "profile not found" }; + + const r = await refreshProfile(p); + if (r.success) { + notified.current.delete(name); + credExpiry.current.set(name, r.expiresAt ?? new Date(Date.now() + DEFAULT_CRED_TTL_MS)); + await reload(); + return { needsLogin: false, ok: true }; + } + if (r.needsLogin) { + credExpiry.current.delete(name); + await reload(); + return { needsLogin: true, ok: false }; + } + await reload(); + return { needsLogin: false, ok: false, error: r.error }; + }, + [reload], + ); + + // Toggle ⟳ (favorite) for a profile: persist favoriteProfiles, then reload so + // the marker updates immediately. + const setAuto = useCallback( + (name: string, value: boolean) => { + const s = settingsRef.current; + const set = new Set(s.favoriteProfiles); + if (value) set.add(name); + else set.delete(name); + saveSettings({ ...s, favoriteProfiles: [...set] }); + void reload(); + }, + [reload], + ); + + return { profiles, reload, refreshOne, setAuto }; +} diff --git a/src/cli/tui/useDaemon.ts b/src/cli/tui/useDaemon.ts deleted file mode 100644 index f795b84..0000000 --- a/src/cli/tui/useDaemon.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { useEffect, useState, useCallback, useRef } from "react"; -import { subscribe, request, isDaemonAlive } from "../../daemon/client.js"; -import { spawnDetached } from "../../daemon/index.js"; -import type { ProfileState, DaemonInfo, DaemonMessage } from "../../daemon/protocol.js"; - -export interface DaemonView { - running: boolean; - info: DaemonInfo | null; - profiles: ProfileState[]; - startBackground: () => Promise; - refresh: (profile?: string) => Promise; - setFavorite: (profile: string, value: boolean) => Promise; -} - -export function useDaemon(localProfiles: ProfileState[]): DaemonView { - const [running, setRunning] = useState(false); - const [info, setInfo] = useState(null); - const [profiles, setProfiles] = useState(localProfiles); - const subRef = useRef<{ stop: () => void } | null>(null); - - const attach = useCallback(() => { - subRef.current?.stop(); - subRef.current = subscribe((msg: DaemonMessage) => { - if (msg.type === "state") { - setInfo(msg.daemon); - setProfiles(msg.profiles); - } - }); - }, []); - - useEffect(() => { - let cancelled = false; - void (async () => { - const alive = await isDaemonAlive(); - if (cancelled) return; - setRunning(alive); - if (alive) attach(); - })(); - return () => { - cancelled = true; - subRef.current?.stop(); - subRef.current = null; - }; - }, [attach]); - - const startBackground = useCallback(async () => { - await spawnDetached(); - const alive = await isDaemonAlive(); - setRunning(alive); - if (alive) attach(); - }, [attach]); - - const refresh = useCallback(async (profile?: string) => { - if (await isDaemonAlive()) { - try { - await request({ type: "refresh", profile }); - } catch { - // Transient timeout or daemon error — ignore; subscription will deliver next update. - } - } - }, []); - - const setFavorite = useCallback(async (profile: string, value: boolean) => { - if (await isDaemonAlive()) { - try { - await request({ type: "setFavorite", profile, value }); - } catch { - // Transient timeout or daemon error — ignore; subscription will deliver next update. - } - } - }, []); - - return { running, info, profiles, startBackground, refresh, setFavorite }; -} diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts index fe4644c..e86c3fa 100644 --- a/src/daemon/protocol.ts +++ b/src/daemon/protocol.ts @@ -1,13 +1,8 @@ -export type ProfileStatusKind = "valid" | "expired" | "needs-login" | "error" | "refreshing"; - -export interface ProfileState { - name: string; - status: ProfileStatusKind; - expiresAt: string | null; // ISO string or null - favorite: boolean; - accountId?: string; - error?: string; -} +// ProfileState / ProfileStatusKind now live in ../aws/profileState (co-located +// with buildLocalProfileStates). Re-exported here so the daemon backend wire +// types keep working until the daemon is removed. +import type { ProfileState } from "../aws/profileState.js"; +export type { ProfileState, ProfileStatusKind } from "../aws/profileState.js"; export interface DaemonInfo { pid: number; From 5b0f16b368afbe138eee1ca4c6855bd3591a6e04 Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 17:55:43 +0200 Subject: [PATCH 28/29] =?UTF-8?q?refactor(cli):=20remove=20background=20da?= =?UTF-8?q?emon,=20socket,=20and=20daemon=20CLI=20=E2=80=94=20single-proce?= =?UTF-8?q?ss=20auto-refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 26 ++---- README.md | 36 ++++----- src/aws/settings.test.ts | 16 +++- src/aws/settings.ts | 11 +-- src/aws/sso.test.ts | 2 +- src/cli/args.test.ts | 10 +-- src/cli/args.ts | 9 +-- src/cli/commands/daemon.ts | 52 ------------ src/cli/commands/status.ts | 14 +--- src/cli/index.tsx | 11 +-- src/cli/tui/Settings.tsx | 5 +- src/daemon/client.ts | 47 ----------- src/daemon/index.ts | 153 ----------------------------------- src/daemon/lifecycle.test.ts | 46 ----------- src/daemon/lifecycle.ts | 59 -------------- src/daemon/protocol.test.ts | 29 ------- src/daemon/protocol.ts | 43 ---------- src/daemon/scheduler.test.ts | 24 ------ src/daemon/scheduler.ts | 14 ---- src/daemon/server.test.ts | 112 ------------------------- src/daemon/server.ts | 126 ----------------------------- 21 files changed, 47 insertions(+), 798 deletions(-) delete mode 100644 src/cli/commands/daemon.ts delete mode 100644 src/daemon/client.ts delete mode 100644 src/daemon/index.ts delete mode 100644 src/daemon/lifecycle.test.ts delete mode 100644 src/daemon/lifecycle.ts delete mode 100644 src/daemon/protocol.test.ts delete mode 100644 src/daemon/protocol.ts delete mode 100644 src/daemon/scheduler.test.ts delete mode 100644 src/daemon/scheduler.ts delete mode 100644 src/daemon/server.test.ts delete mode 100644 src/daemon/server.ts diff --git a/CLAUDE.md b/CLAUDE.md index 39d6a49..223e34c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Distributed via npm (`npx ssomatic`). Settings (favorites, notifications, refresh interval) are persisted across sessions. -SSOmatic runs a real per-host background daemon (single instance, Unix socket). It keeps ⟳ auto-refresh profiles' role credentials fresh in an expiry-aware manner while the SSO token is valid, and sends a desktop notification when an interactive browser login is required — the daemon never opens a browser itself. The TUI attaches to the daemon over the socket for live state; any terminal that runs `ssomatic` while the daemon is up shows the live state. +SSOmatic is a single-process TUI. While open it auto-refreshes the ⟳ (pinned) profiles' role credentials in an expiry-aware manner, and sends a desktop notification when an interactive SSO browser login is needed. No background process — quitting fully exits. ## Structure @@ -20,21 +20,11 @@ ssomatic/ │ │ ├── settings.test.ts │ │ ├── console.ts # AWS console URL builders │ │ ├── console.test.ts -│ │ ├── profileState.ts # Profile state helpers +│ │ ├── profileState.ts # ProfileState types + local-state builder +│ │ ├── refreshScheduler.ts # Expiry-aware refresh decision (decideAction) │ │ ├── aws.ts # STS identity utilities │ │ ├── utils.ts # Clipboard, JSON formatting │ │ └── utils.test.ts -│ ├── daemon/ # Per-host background daemon (Unix-socket server + expiry-aware scheduler) -│ │ ├── protocol.ts # Wire protocol types + ndjson codec -│ │ ├── protocol.test.ts -│ │ ├── lifecycle.ts # Single-instance lock + pid management -│ │ ├── lifecycle.test.ts -│ │ ├── scheduler.ts # Expiry-aware refresh scheduler -│ │ ├── scheduler.test.ts -│ │ ├── server.ts # Unix-socket daemon server -│ │ ├── server.test.ts -│ │ ├── client.ts # Daemon socket client -│ │ └── index.ts # Daemon entry point │ └── cli/ # Terminal UI (React/Ink) │ ├── index.tsx # Entry point + argument router │ ├── args.ts # CLI argument parsing @@ -43,13 +33,12 @@ ssomatic/ │ │ ├── status.ts # `ssomatic status` │ │ ├── status.test.ts │ │ ├── export.ts # `ssomatic export ` -│ │ ├── refresh.ts # `ssomatic refresh [profile]` -│ │ └── daemon.ts # `ssomatic daemon start|stop|status` +│ │ └── refresh.ts # `ssomatic refresh [profile]` │ ├── tui/ # TUI screens │ │ ├── Dashboard.tsx # Main profile list view │ │ ├── Details.tsx # Profile detail view │ │ ├── Settings.tsx # Settings screen -│ │ └── useDaemon.ts # Hook: connects TUI to the daemon socket +│ │ └── useAutoRefresh.ts # Hook: in-process auto-refresh for ⟳ profiles │ ├── components/ # Shared Ink UI components │ │ ├── App.tsx # Root container │ │ ├── ActionBar.tsx # Bottom action bar + ACTIONS constant @@ -93,11 +82,9 @@ bun test # Run unit tests ```bash ssomatic # Launch the interactive TUI -ssomatic --daemon # Launch the TUI and start the background daemon ssomatic status # Print profile statuses and exit ssomatic refresh [profile] # Refresh a profile (or all favorites) now ssomatic export # Print export AWS_* lines (use with eval $(ssomatic export )) -ssomatic daemon start|stop|status ssomatic --version ``` @@ -108,8 +95,7 @@ ssomatic --version | `↑` / `↓` / `k` / `j` | Move cursor | | `⏎` | Open details | | `r` | Refresh the current profile | -| `a` | Toggle ⟳ auto-refresh (pin for the daemon) | -| `b` | Run daemon in background | +| `a` | Toggle ⟳ auto-refresh | | `c` | Copy export (`AWS_*` env vars) | | `y` | Copy profile name | | `o` | Open AWS console | diff --git a/README.md b/README.md index 2fb4c68..7874be4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # SSOmatic -Keep your AWS SSO credentials fresh — automatically. A fast terminal dashboard with a background daemon that silently maintains your favorites while you work. +Keep your AWS SSO credentials fresh — automatically. A fast terminal dashboard that auto-refreshes your pinned profiles while it's open. [![npm version](https://img.shields.io/npm/v/ssomatic)](https://www.npmjs.com/package/ssomatic) [![CI](https://github.com/tux86/ssomatic/actions/workflows/ci.yml/badge.svg)](https://github.com/tux86/ssomatic/actions/workflows/ci.yml) @@ -13,16 +13,16 @@ Keep your AWS SSO credentials fresh — automatically. A fast terminal dashboard ## Why SSOmatic - **k9s-style list-first dashboard** — all your SSO profiles at a glance with live expiry countdowns; navigate with j/k or arrow keys, no menus to dig through. -- **Background daemon that keeps ⟳ auto-refresh profiles fresh** — pin a profile for auto-refresh and expiry-aware refresh keeps its credentials ready before they expire, with zero fixed-interval polling waste. -- **Notify-on-login, never surprise you** — when an interactive SSO login is required the daemon sends a desktop notification; it never opens a browser on its own. +- **In-process auto-refresh for ⟳ pinned profiles** — pin a profile with `a` and expiry-aware refresh keeps its credentials ready before they expire, with no fixed-interval polling waste. +- **Notify-on-login, never surprise you** — when an interactive SSO login is required SSOmatic sends a desktop notification so you know to log in. - **One-keystroke everything** — copy `export AWS_*` vars, open the AWS console, copy the profile name, or force a refresh — all from the dashboard without leaving your terminal. -- **Attach from any terminal** — run `ssomatic` once to open the TUI; press `b` to push it to the background; re-run `ssomatic` from any terminal window to reconnect to the live daemon state. +- **Single process, clean exit** — quitting fully exits. No background processes to manage. --- ## Demo - +

SSOmatic CLI Demo

@@ -48,13 +48,12 @@ npm install -g ssomatic # 1. Launch the dashboard ssomatic -# 2. Star the profiles you use daily — press f on any profile -# 3. Send to background — press b (daemon stays running, terminal returns) -# 4. From any terminal, re-attach to live state -ssomatic +# 2. Navigate to a profile and press 'a' to pin it for auto-refresh +# 3. SSOmatic auto-refreshes pinned profiles while the dashboard is open +# 4. Press 'q' to quit when done ``` -The daemon keeps your ⟳ auto-refresh profiles' credentials fresh in the background. When a browser login is required, you get a desktop notification and can log in from the TUI or with `ssomatic refresh `. +While the dashboard is open, ⟳ pinned profiles are refreshed automatically when their credentials are close to expiry. When a browser login is required, you get a desktop notification and can log in directly from the TUI or with `ssomatic refresh `. --- @@ -62,12 +61,10 @@ The daemon keeps your ⟳ auto-refresh profiles' credentials fresh in the backgr | Command | Description | |---------|-------------| -| `ssomatic` | Launch the interactive TUI (attaches to daemon if running) | -| `ssomatic --daemon` | Launch the TUI and start the background daemon | +| `ssomatic` | Launch the interactive TUI | | `ssomatic status` | Print profile statuses and exit | | `ssomatic refresh [name]` | Refresh a profile (or all favorites) now | | `ssomatic export ` | Print `export AWS_*` lines for `eval $(...)` | -| `ssomatic daemon start\|stop\|status` | Manage the background daemon directly | | `ssomatic --version` | Print version and exit | **Shell trick — inject credentials into your current shell:** @@ -85,8 +82,7 @@ eval $(ssomatic export prod) | `↑` / `↓` or `j` / `k` | Move cursor | | `Enter` | Open profile details | | `r` | Refresh the current profile | -| `a` | Toggle ⟳ auto-refresh (pin for the daemon) | -| `b` | Run daemon in background, detach TUI | +| `a` | Toggle ⟳ auto-refresh (pin/unpin) | | `c` | Copy `export AWS_*` to clipboard | | `y` | Copy profile name to clipboard | | `o` | Open AWS console in browser | @@ -97,15 +93,11 @@ eval $(ssomatic export prod) --- -## How the Daemon Works - -One daemon instance runs per host, listening on a Unix socket (`$XDG_RUNTIME_DIR/ssomatic.sock` or `$TMPDIR/ssomatic.sock`). The TUI attaches to it via that socket so any `ssomatic` invocation sees the same live state. - -**Expiry-aware refresh** — the daemon tracks the role-credential expiry for each starred profile and refreshes only when the credentials are within the lead window of expiring (default: a few minutes before expiry). No fixed interval; no wasted refreshes. +## How Auto-Refresh Works -**Never opens a browser** — when an interactive SSO login is needed the daemon sends a desktop notification (`SSOmatic: needs login`). You authorize by running `ssomatic` (TUI) or `ssomatic refresh `. +SSOmatic tracks the role-credential expiry for each ⟳ pinned profile and refreshes only when the credentials are within the lead window of expiring (default: 5 minutes before expiry). No fixed interval; no wasted refreshes. -Daemon logs are written to `~/.aws/ssomatic/daemon.log`. +When an interactive SSO login is needed, a desktop notification is sent (`SSOmatic: needs login`). You authorize by logging in from the TUI or with `ssomatic refresh `. --- diff --git a/src/aws/settings.test.ts b/src/aws/settings.test.ts index 80a5911..bbcf8a9 100644 --- a/src/aws/settings.test.ts +++ b/src/aws/settings.test.ts @@ -23,8 +23,8 @@ test("loadSettings returns defaults when no file exists", async () => { test("saveSettings then loadSettings round-trips", async () => { const { loadSettings, saveSettings } = await import("./settings"); - saveSettings({ notifications: false, refreshLeadMinutes: 10, autoStartDaemon: true, favoriteProfiles: ["prod", "dev"] }); - expect(loadSettings()).toEqual({ notifications: false, refreshLeadMinutes: 10, autoStartDaemon: true, favoriteProfiles: ["prod", "dev"] }); + saveSettings({ notifications: false, refreshLeadMinutes: 10, favoriteProfiles: ["prod", "dev"] }); + expect(loadSettings()).toEqual({ notifications: false, refreshLeadMinutes: 10, favoriteProfiles: ["prod", "dev"] }); }); test("loadSettings migrates a legacy file with defaultInterval", async () => { @@ -35,6 +35,16 @@ test("loadSettings migrates a legacy file with defaultInterval", async () => { const s = loadSettings(); expect(s.favoriteProfiles).toEqual(["x"]); expect(s.refreshLeadMinutes).toBe(5); - expect(s.autoStartDaemon).toBe(false); expect("defaultInterval" in s).toBe(false); }); + +test("loadSettings ignores unknown fields including autoStartDaemon", async () => { + const { loadSettings } = await import("./settings"); + const { writeFileSync, mkdirSync } = await import("node:fs"); + mkdirSync(join(home, ".aws"), { recursive: true }); + writeFileSync(join(home, ".aws", "credentials-manager.json"), JSON.stringify({ notifications: false, autoStartDaemon: true, favoriteProfiles: ["y"] })); + const s = loadSettings(); + expect(s.notifications).toBe(false); + expect(s.favoriteProfiles).toEqual(["y"]); + expect("autoStartDaemon" in s).toBe(false); +}); diff --git a/src/aws/settings.ts b/src/aws/settings.ts index 3633b01..94f02f3 100644 --- a/src/aws/settings.ts +++ b/src/aws/settings.ts @@ -4,14 +4,12 @@ import { join, dirname } from "node:path"; export interface AppSettings { notifications: boolean; refreshLeadMinutes: number; - autoStartDaemon: boolean; favoriteProfiles: string[]; } export const DEFAULT_SETTINGS: AppSettings = { notifications: true, refreshLeadMinutes: 5, - autoStartDaemon: false, favoriteProfiles: [], }; @@ -24,12 +22,11 @@ export function loadSettings(): AppSettings { const path = settingsPath(); if (!existsSync(path)) return { ...DEFAULT_SETTINGS }; try { - const raw = JSON.parse(readFileSync(path, "utf8")) as Partial & { defaultInterval?: number }; + const raw = JSON.parse(readFileSync(path, "utf8")) as Record; return { - notifications: raw.notifications ?? DEFAULT_SETTINGS.notifications, - refreshLeadMinutes: raw.refreshLeadMinutes ?? DEFAULT_SETTINGS.refreshLeadMinutes, - autoStartDaemon: raw.autoStartDaemon ?? DEFAULT_SETTINGS.autoStartDaemon, - favoriteProfiles: raw.favoriteProfiles ?? DEFAULT_SETTINGS.favoriteProfiles, + notifications: typeof raw.notifications === "boolean" ? raw.notifications : DEFAULT_SETTINGS.notifications, + refreshLeadMinutes: typeof raw.refreshLeadMinutes === "number" ? raw.refreshLeadMinutes : DEFAULT_SETTINGS.refreshLeadMinutes, + favoriteProfiles: Array.isArray(raw.favoriteProfiles) ? (raw.favoriteProfiles as string[]) : DEFAULT_SETTINGS.favoriteProfiles, }; } catch { return { ...DEFAULT_SETTINGS }; diff --git a/src/aws/sso.test.ts b/src/aws/sso.test.ts index a46cae4..46d8b74 100644 --- a/src/aws/sso.test.ts +++ b/src/aws/sso.test.ts @@ -65,7 +65,7 @@ test("discoverProfiles parses sso-session and inline profiles", async () => { test("saveSettings / loadSettings round-trip", async () => { const { saveSettings, loadSettings } = await import("./settings"); - saveSettings({ notifications: false, refreshLeadMinutes: 60, autoStartDaemon: false, favoriteProfiles: ["dev"] }); + saveSettings({ notifications: false, refreshLeadMinutes: 60, favoriteProfiles: ["dev"] }); const loaded = loadSettings(); expect(loaded.notifications).toBe(false); expect(loaded.refreshLeadMinutes).toBe(60); diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 04af96d..3e574a5 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -1,8 +1,7 @@ import { test, expect } from "bun:test"; import { parseArgs } from "./args"; -test("no args → tui", () => { expect(parseArgs([])).toEqual({ kind: "tui", daemon: false }); }); -test("--daemon flag → tui with daemon", () => { expect(parseArgs(["--daemon"])).toEqual({ kind: "tui", daemon: true }); }); +test("no args → tui", () => { expect(parseArgs([])).toEqual({ kind: "tui" }); }); test("--version → version", () => { expect(parseArgs(["--version"])).toEqual({ kind: "version" }); expect(parseArgs(["-v"])).toEqual({ kind: "version" }); @@ -13,8 +12,7 @@ test("refresh optional profile", () => { expect(parseArgs(["refresh"])).toEqual({ kind: "refresh", profile: undefined }); expect(parseArgs(["refresh", "dev"])).toEqual({ kind: "refresh", profile: "dev" }); }); -test("daemon subcommands", () => { - expect(parseArgs(["daemon", "start"])).toEqual({ kind: "daemon", sub: "start" }); - expect(parseArgs(["daemon"])).toEqual({ kind: "daemon", sub: undefined }); +test("unknown command → error", () => { + expect(parseArgs(["daemon"])).toEqual({ kind: "error", message: "unknown command: daemon" }); + expect(parseArgs(["foobar"])).toEqual({ kind: "error", message: "unknown command: foobar" }); }); -test("internal __daemon command", () => { expect(parseArgs(["__daemon"])).toEqual({ kind: "__daemon" }); }); diff --git a/src/cli/args.ts b/src/cli/args.ts index de47d54..2abc869 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,27 +1,22 @@ export type ParsedArgs = - | { kind: "tui"; daemon: boolean } + | { kind: "tui" } | { kind: "version" } | { kind: "status" } | { kind: "export"; profile: string } | { kind: "refresh"; profile?: string } - | { kind: "daemon"; sub?: string } - | { kind: "__daemon" } | { kind: "help" } | { kind: "error"; message: string }; export function parseArgs(argv: string[]): ParsedArgs { const [cmd, ...rest] = argv; - if (cmd === undefined) return { kind: "tui", daemon: false }; + if (cmd === undefined) return { kind: "tui" }; if (cmd === "--version" || cmd === "-v") return { kind: "version" }; if (cmd === "--help" || cmd === "-h" || cmd === "help") return { kind: "help" }; - if (cmd === "--daemon") return { kind: "tui", daemon: true }; - if (cmd === "__daemon") return { kind: "__daemon" }; if (cmd === "status") return { kind: "status" }; if (cmd === "refresh") return { kind: "refresh", profile: rest[0] }; if (cmd === "export") { if (!rest[0]) return { kind: "error", message: "export requires a profile name" }; return { kind: "export", profile: rest[0] }; } - if (cmd === "daemon") return { kind: "daemon", sub: rest[0] }; return { kind: "error", message: `unknown command: ${cmd}` }; } diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts deleted file mode 100644 index 1297e19..0000000 --- a/src/cli/commands/daemon.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { spawnDetached } from "../../daemon/index"; -import { request, isDaemonAlive } from "../../daemon/client"; -import { readPidFile } from "../../daemon/lifecycle"; - -export async function runDaemonCommand(sub: string | undefined): Promise { - switch (sub) { - case "start": { - if (await isDaemonAlive()) { - process.stdout.write("daemon already running\n"); - return 0; - } - await spawnDetached(); - process.stdout.write( - (await isDaemonAlive()) ? "daemon started\n" : "failed to start daemon (see ~/.aws/ssomatic/daemon.log)\n" - ); - return 0; - } - case "stop": { - if (!(await isDaemonAlive())) { - process.stdout.write("daemon not running\n"); - return 0; - } - await request({ type: "stop" }).catch(() => {}); - process.stdout.write("daemon stopped\n"); - return 0; - } - case "status": - case undefined: { - if (!(await isDaemonAlive())) { - process.stdout.write("daemon: stopped\n"); - return 0; - } - const pid = readPidFile(); - try { - const msg = await request({ type: "snapshot" }); - const watched = - msg.type === "state" - ? msg.profiles.filter((p) => p.favorite).map((p) => p.name) - : []; - process.stdout.write( - `daemon: running (pid ${pid ?? "?"})\nwatching: ${watched.join(", ") || "(none)"}\n` - ); - } catch (err) { - process.stdout.write(`daemon: running (error: ${String(err)})\n`); - } - return 0; - } - default: - process.stderr.write(`unknown daemon subcommand: ${sub}\n`); - return 1; - } -} diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index 122c4e7..ccd82bf 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -1,4 +1,3 @@ -import { request, isDaemonAlive } from "../../daemon/client"; import type { ProfileState } from "../../aws/profileState"; import { buildLocalProfileStates } from "../../aws/profileState"; @@ -21,18 +20,7 @@ export function formatStatusTable(rows: ProfileState[], now: Date): string { export async function runStatus(): Promise { const now = new Date(); - let rows: ProfileState[]; - if (await isDaemonAlive()) { - try { - const msg = await request({ type: "snapshot" }); - rows = msg.type === "state" ? msg.profiles : []; - } catch (err) { - process.stderr.write(`warning: daemon request failed (${String(err)}), falling back to local state\n`); - rows = await buildLocalProfileStates(); - } - } else { - rows = await buildLocalProfileStates(); - } + const rows = await buildLocalProfileStates(); process.stdout.write(formatStatusTable(rows, now) + "\n"); return 0; } diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 5ea1ab8..1021d70 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -9,8 +9,6 @@ import { parseArgs } from "./args.js"; import { runStatus } from "./commands/status.js"; import { runExport } from "./commands/export.js"; import { runRefresh } from "./commands/refresh.js"; -import { runDaemonCommand } from "./commands/daemon.js"; -import { runDaemon } from "../daemon/index.js"; import { App, renderApp, Spinner, StatusMessage, ACTIONS, Key } from "./components/index.js"; import { useCopy } from "./hooks/index.js"; import { Dashboard } from "./tui/Dashboard.js"; @@ -36,7 +34,7 @@ import { VERSION, checkForUpdate } from "../version.js"; type ViewState = "dashboard" | "details" | "settings"; // ───────────────────────────────────────────────────────────────────────────── -// Hook: useDeviceAuth (reused for interactive login when no daemon is running) +// Hook: useDeviceAuth (handles interactive SSO device authorization flow) // ───────────────────────────────────────────────────────────────────────────── interface UseDeviceAuthOptions { @@ -474,7 +472,6 @@ Usage: ssomatic status print profile statuses and exit ssomatic refresh [name] refresh a profile (or all favorites) now ssomatic export print export AWS_* lines for eval $(...) - ssomatic daemon start|stop|status ssomatic --version `; @@ -504,12 +501,6 @@ async function main(): Promise { case "refresh": process.exit(await runRefresh(parsed.profile)); return; - case "daemon": - process.exit(await runDaemonCommand(parsed.sub)); - return; - case "__daemon": - await runDaemon(); // long-lived; do not exit - return; case "error": process.stderr.write(parsed.message + "\n"); process.exit(1); diff --git a/src/cli/tui/Settings.tsx b/src/cli/tui/Settings.tsx index 415d4dd..cbf327e 100644 --- a/src/cli/tui/Settings.tsx +++ b/src/cli/tui/Settings.tsx @@ -11,7 +11,7 @@ interface Props { export function Settings({ settings, onChange, onBack }: Props) { const [cursor, setCursor] = useState(0); - const items = ["notifications", "refreshLeadMinutes", "autoStartDaemon"] as const; + const items = ["notifications", "refreshLeadMinutes"] as const; useInput((input, key) => { if (key.escape) { onBack(); @@ -34,8 +34,6 @@ export function Settings({ settings, onChange, onBack }: Props) { } else if (key.return || input === " ") { if (field === "notifications") onChange({ ...settings, notifications: !settings.notifications }); - else if (field === "autoStartDaemon") - onChange({ ...settings, autoStartDaemon: !settings.autoStartDaemon }); } }); const line = (i: number, label: string, value: string) => ( @@ -50,7 +48,6 @@ export function Settings({ settings, onChange, onBack }: Props) { ⚙ Settings {line(0, "Notifications", settings.notifications ? "on" : "off")} {line(1, "Refresh lead (min)", String(settings.refreshLeadMinutes) + " (←/→)")} - {line(2, "Auto-start daemon", settings.autoStartDaemon ? "on" : "off")} move toggle diff --git a/src/daemon/client.ts b/src/daemon/client.ts deleted file mode 100644 index f7c5b4d..0000000 --- a/src/daemon/client.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { connect, type Socket } from "node:net"; -import { encode, decodeStream, type ClientMessage, type DaemonMessage } from "./protocol"; -import { socketPath, isDaemonAlive } from "./lifecycle"; - -export { isDaemonAlive }; - -/** Connect, send one message, resolve with the first reply, then close. */ -export async function request(msg: ClientMessage, timeoutMs = 3000): Promise { - return new Promise((resolve, reject) => { - const sock: Socket = connect(socketPath()); - const dec = decodeStream(); - const timer = setTimeout(() => { - sock.destroy(); - reject(new Error("daemon request timed out")); - }, timeoutMs); - sock.once("connect", () => sock.write(encode(msg))); - sock.on("data", (buf) => { - const msgs = dec.push(buf.toString()); - if (msgs.length) { - clearTimeout(timer); - sock.destroy(); - const first = msgs[0]; - if (first.type === "error") { - reject(new Error(first.message)); - } else { - resolve(first); - } - } - }); - sock.once("error", (err) => { - clearTimeout(timer); - reject(err); - }); - }); -} - -/** Open a subscription; call onState for every pushed message until stop() is called. */ -export function subscribe(onState: (msg: DaemonMessage) => void): { stop: () => void } { - const sock: Socket = connect(socketPath()); - const dec = decodeStream(); - sock.once("connect", () => sock.write(encode({ type: "subscribe" }))); - sock.on("data", (buf) => { - for (const msg of dec.push(buf.toString())) onState(msg); - }); - sock.on("error", () => {}); - return { stop: () => sock.destroy() }; -} diff --git a/src/daemon/index.ts b/src/daemon/index.ts deleted file mode 100644 index 7d8df89..0000000 --- a/src/daemon/index.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { spawn } from "node:child_process"; -import { openSync, closeSync, mkdirSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { startServer } from "./server"; -import { isDaemonAlive } from "./lifecycle"; -import { decideAction } from "./scheduler"; -import { - discoverProfiles, - findCachedToken, - refreshProfile as coreRefresh, - sendNotification, -} from "../aws/sso"; -import { loadSettings, saveSettings } from "../aws/settings"; -import type { ProfileState, ProfileStatusKind } from "./protocol"; - -function logPath(): string { - const dir = join(homedir(), ".aws", "ssomatic"); - mkdirSync(dir, { recursive: true }); - return join(dir, "daemon.log"); -} - -const notified = new Set(); -const credExpiry = new Map(); - -/** Fallback credential TTL when AWS omits the credential expiration in the response. */ -const DEFAULT_CRED_TTL_MS = 50 * 60 * 1000; - -function maybeNotify(enabled: boolean, profile: string): void { - if (!enabled || notified.has(profile)) return; - notified.add(profile); - void sendNotification("SSOmatic", `${profile} needs login`); -} - -/** - * Build current ProfileState[] from disk. - * For each favorite, decide + perform a silent refresh when due. - * - * Role-credential expiry is tracked in `credExpiry` (populated after each - * successful refresh). On daemon start the map is empty, so every favorite - * refreshes once; afterward `decideAction` returns "wait" until within - * `refreshLeadMinutes` of the role-cred expiry, making computeState a cheap - * read on normal snapshots/subscribes. - */ -async function computeState(): Promise { - const settings = loadSettings(); - const leadMs = settings.refreshLeadMinutes * 60 * 1000; - const favorites = new Set(settings.favoriteProfiles); - const now = new Date(); - const states: ProfileState[] = []; - - for (const p of await discoverProfiles()) { - // Determine SSO token validity directly from the cache file. - const cachedToken = await findCachedToken(p); - const ssoTokenValid = cachedToken !== null && cachedToken.expiresAt > now; - - // Use tracked role-cred expiry when available; null triggers a refresh. - const credsExpireAt: Date | null = credExpiry.get(p.name) ?? null; - - const favorite = favorites.has(p.name); - let status: ProfileStatusKind = ssoTokenValid ? "valid" : "needs-login"; - let errorMsg: string | undefined; - - if (favorite) { - const action = decideAction({ ssoTokenValid, credsExpireAt }, now, leadMs); - - if (action === "refresh") { - const r = await coreRefresh(p); - if (r.success) { - notified.delete(p.name); - status = "valid"; - credExpiry.set(p.name, r.expiresAt ?? new Date(Date.now() + DEFAULT_CRED_TTL_MS)); - } else if (r.needsLogin) { - status = "needs-login"; - credExpiry.delete(p.name); - maybeNotify(settings.notifications, p.name); - } else { - // Unrecognized failure — surface error so the UI isn't opaque. - status = "error"; - errorMsg = r.error; - } - } else if (action === "needs-login") { - status = "needs-login"; - credExpiry.delete(p.name); - maybeNotify(settings.notifications, p.name); - } - // action === "wait" → keep status derived from ssoTokenValid above - } - - // Prefer role-cred expiry for the expiresAt field when known; fall back to - // the cached SSO-token expiry. - const trackedExpiry = credExpiry.get(p.name); - states.push({ - name: p.name, - status, - expiresAt: trackedExpiry - ? trackedExpiry.toISOString() - : cachedToken - ? cachedToken.expiresAt.toISOString() - : null, - favorite, - accountId: p.ssoAccountId, - ...(errorMsg !== undefined && { error: errorMsg }), - }); - } - - return states; -} - -export async function runDaemon(): Promise { - const startedAtIso = new Date().toISOString(); - const server = await startServer({ - startedAtIso, - tickMs: 30_000, - computeState, - refreshProfile: async (name) => { - const profiles = await discoverProfiles(); - const p = profiles.find((x) => x.name === name); - if (!p) return; - const r = await coreRefresh(p); - if (r.success) { - notified.delete(name); - credExpiry.set(name, r.expiresAt ?? new Date(Date.now() + DEFAULT_CRED_TTL_MS)); - } else if (r.needsLogin) { - credExpiry.delete(name); - } - }, - setFavorite: (name, value) => { - const s = loadSettings(); - const set = new Set(s.favoriteProfiles); - if (value) set.add(name); - else set.delete(name); - saveSettings({ ...s, favoriteProfiles: [...set] }); - }, - }); - - process.on("SIGTERM", () => void server.stop().then(() => process.exit(0))); - process.on("SIGINT", () => void server.stop().then(() => process.exit(0))); -} - -/** Spawn a detached daemon process running ` __daemon`, return immediately. */ -export async function spawnDetached(): Promise { - if (await isDaemonAlive()) return; - const out = openSync(logPath(), "a"); - const child = spawn(process.execPath, [process.argv[1], "__daemon"], { - detached: true, - stdio: ["ignore", out, out], - }); - child.unref(); - closeSync(out); - // Brief pause so the daemon has time to bind the socket before the caller checks it. - await new Promise((r) => setTimeout(r, 300)); -} diff --git a/src/daemon/lifecycle.test.ts b/src/daemon/lifecycle.test.ts deleted file mode 100644 index 068e3cb..0000000 --- a/src/daemon/lifecycle.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { test, expect, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync, existsSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { createServer, type Server } from "node:net"; -import { socketPath, pidPath, isDaemonAlive, writePidFile, readPidFile } from "./lifecycle"; - -let runtime: string; -let prev: string | undefined; - -beforeEach(() => { - prev = process.env.XDG_RUNTIME_DIR; - runtime = mkdtempSync(join(tmpdir(), "ssomatic-rt-")); - process.env.XDG_RUNTIME_DIR = runtime; -}); -afterEach(() => { - process.env.XDG_RUNTIME_DIR = prev; - rmSync(runtime, { recursive: true, force: true }); -}); - -test("socketPath/pidPath live under the runtime dir", () => { - expect(socketPath().startsWith(runtime)).toBe(true); - expect(pidPath().startsWith(runtime)).toBe(true); -}); - -test("isDaemonAlive is false when no socket is listening", async () => { - expect(await isDaemonAlive()).toBe(false); -}); - -test("isDaemonAlive is true when a server is listening on the socket", async () => { - const srv: Server = await new Promise((resolve) => { - const s = createServer(); - s.listen(socketPath(), () => resolve(s)); - }); - try { - expect(await isDaemonAlive()).toBe(true); - } finally { - srv.close(); - } -}); - -test("pid file round-trips", () => { - writePidFile(4242); - expect(existsSync(pidPath())).toBe(true); - expect(readPidFile()).toBe(4242); -}); diff --git a/src/daemon/lifecycle.ts b/src/daemon/lifecycle.ts deleted file mode 100644 index 37fd0b6..0000000 --- a/src/daemon/lifecycle.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { connect, type Socket } from "node:net"; -import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -function runtimeDir(): string { - const dir = process.env.XDG_RUNTIME_DIR || tmpdir(); - mkdirSync(dir, { recursive: true }); - return dir; -} - -export function socketPath(): string { - return join(runtimeDir(), "ssomatic.sock"); -} - -export function pidPath(): string { - return join(runtimeDir(), "ssomatic.pid"); -} - -/** True if something is actually listening on the socket. */ -export function isDaemonAlive(timeoutMs = 500): Promise { - const path = socketPath(); - if (!existsSync(path)) return Promise.resolve(false); - return new Promise((resolve) => { - const sock: Socket = connect(path); - const done = (alive: boolean) => { - sock.destroy(); - resolve(alive); - }; - sock.once("connect", () => done(true)); - sock.once("error", () => done(false)); - sock.setTimeout(timeoutMs, () => done(false)); - }); -} - -/** Remove a socket file with no live listener so we can rebind. Returns true if reclaimed. */ -export async function reclaimStaleSocket(): Promise { - const path = socketPath(); - if (existsSync(path) && !(await isDaemonAlive())) { - rmSync(path, { force: true }); - return true; - } - return false; -} - -export function writePidFile(pid: number): void { - writeFileSync(pidPath(), String(pid)); -} - -export function readPidFile(): number | null { - const path = pidPath(); - if (!existsSync(path)) return null; - const n = Number(readFileSync(path, "utf8").trim()); - return Number.isFinite(n) ? n : null; -} - -export function clearPidFile(): void { - rmSync(pidPath(), { force: true }); -} diff --git a/src/daemon/protocol.test.ts b/src/daemon/protocol.test.ts deleted file mode 100644 index 6060804..0000000 --- a/src/daemon/protocol.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { test, expect } from "bun:test"; -import { encode, decodeStream, type ClientMessage, type DaemonMessage } from "./protocol"; - -test("encode appends a newline and is JSON-parseable", () => { - const msg: ClientMessage = { type: "subscribe" }; - const line = encode(msg); - expect(line.endsWith("\n")).toBe(true); - expect(JSON.parse(line)).toEqual({ type: "subscribe" }); -}); - -test("decodeStream yields complete messages and buffers partials", () => { - const dec = decodeStream(); - const a = encode({ type: "snapshot" } as ClientMessage); - const b = encode({ type: "refresh", profile: "prod" } as ClientMessage); - const first = dec.push(a + b.slice(0, 5)); - expect(first).toEqual([{ type: "snapshot" }]); - const second = dec.push(b.slice(5)); - expect(second).toEqual([{ type: "refresh", profile: "prod" }]); -}); - -test("daemon state message shape is preserved through encode/decode", () => { - const dec = decodeStream(); - const state: DaemonMessage = { - type: "state", - daemon: { pid: 123, startedAt: "2026-06-11T10:00:00.000Z" }, - profiles: [{ name: "prod", status: "valid", expiresAt: "2026-06-11T12:00:00.000Z", favorite: true }], - }; - expect(dec.push(encode(state))).toEqual([state]); -}); diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts deleted file mode 100644 index e86c3fa..0000000 --- a/src/daemon/protocol.ts +++ /dev/null @@ -1,43 +0,0 @@ -// ProfileState / ProfileStatusKind now live in ../aws/profileState (co-located -// with buildLocalProfileStates). Re-exported here so the daemon backend wire -// types keep working until the daemon is removed. -import type { ProfileState } from "../aws/profileState.js"; -export type { ProfileState, ProfileStatusKind } from "../aws/profileState.js"; - -export interface DaemonInfo { - pid: number; - startedAt: string; // ISO string -} - -export type ClientMessage = - | { type: "subscribe" } - | { type: "snapshot" } - | { type: "refresh"; profile?: string } - | { type: "setFavorite"; profile: string; value: boolean } - | { type: "stop" }; - -export type DaemonMessage = - | { type: "state"; daemon: DaemonInfo; profiles: ProfileState[] } - | { type: "error"; message: string }; - -export function encode(msg: ClientMessage | DaemonMessage): string { - return JSON.stringify(msg) + "\n"; -} - -/** Stateful newline-delimited JSON decoder. Call push() with each chunk. */ -export function decodeStream() { - let buffer = ""; - return { - push(chunk: string): T[] { - buffer += chunk; - const out: T[] = []; - let idx: number; - while ((idx = buffer.indexOf("\n")) >= 0) { - const line = buffer.slice(0, idx); - buffer = buffer.slice(idx + 1); - if (line.trim().length > 0) out.push(JSON.parse(line) as T); - } - return out; - }, - }; -} diff --git a/src/daemon/scheduler.test.ts b/src/daemon/scheduler.test.ts deleted file mode 100644 index 759d5ad..0000000 --- a/src/daemon/scheduler.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { test, expect } from "bun:test"; -import { decideAction } from "./scheduler"; - -const now = new Date("2026-06-11T12:00:00.000Z"); -const leadMs = 5 * 60 * 1000; - -test("refresh when within lead window of expiry", () => { - const expiresAt = new Date("2026-06-11T12:03:00.000Z"); // 3m left < 5m lead - expect(decideAction({ ssoTokenValid: true, credsExpireAt: expiresAt }, now, leadMs)).toBe("refresh"); -}); - -test("wait when comfortably before lead window", () => { - const expiresAt = new Date("2026-06-11T12:30:00.000Z"); // 30m left - expect(decideAction({ ssoTokenValid: true, credsExpireAt: expiresAt }, now, leadMs)).toBe("wait"); -}); - -test("needs-login when sso token invalid regardless of creds", () => { - const expiresAt = new Date("2026-06-11T12:30:00.000Z"); - expect(decideAction({ ssoTokenValid: false, credsExpireAt: expiresAt }, now, leadMs)).toBe("needs-login"); -}); - -test("refresh when there are no creds yet but sso token is valid", () => { - expect(decideAction({ ssoTokenValid: true, credsExpireAt: null }, now, leadMs)).toBe("refresh"); -}); diff --git a/src/daemon/scheduler.ts b/src/daemon/scheduler.ts deleted file mode 100644 index 029206d..0000000 --- a/src/daemon/scheduler.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type Action = "refresh" | "wait" | "needs-login"; - -export interface ProfileTiming { - ssoTokenValid: boolean; // is the cached SSO token still valid? - credsExpireAt: Date | null; // when current role creds expire (null = none/unknown) -} - -export function decideAction(timing: ProfileTiming, now: Date, leadMs: number): Action { - if (!timing.ssoTokenValid) return "needs-login"; - if (timing.credsExpireAt === null) return "refresh"; - const msLeft = timing.credsExpireAt.getTime() - now.getTime(); - return msLeft <= leadMs ? "refresh" : "wait"; -} - diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts deleted file mode 100644 index 8433e78..0000000 --- a/src/daemon/server.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { test, expect, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { connect, type Socket } from "node:net"; -import { encode, decodeStream, type DaemonMessage, type ProfileState } from "./protocol"; -import { socketPath } from "./lifecycle"; -import { startServer, type DaemonServer } from "./server"; - -let runtime: string; -let prev: string | undefined; -let server: DaemonServer; - -const fakeProfiles: ProfileState[] = [ - { name: "prod", status: "valid", expiresAt: "2026-06-11T12:00:00.000Z", favorite: true }, -]; - -beforeEach(() => { - prev = process.env.XDG_RUNTIME_DIR; - runtime = mkdtempSync(join(tmpdir(), "ssomatic-srv-")); - process.env.XDG_RUNTIME_DIR = runtime; -}); -afterEach(async () => { - await server?.stop(); - process.env.XDG_RUNTIME_DIR = prev; - rmSync(runtime, { recursive: true, force: true }); -}); - -function readOne(sock: Socket): Promise { - const dec = decodeStream(); - return new Promise((resolve) => { - sock.on("data", (buf) => { - const msgs = dec.push(buf.toString()); - if (msgs.length) resolve(msgs[0]); - }); - }); -} - -test("snapshot returns current state then the client can disconnect", async () => { - server = await startServer({ - startedAtIso: "2026-06-11T10:00:00.000Z", - computeState: async () => fakeProfiles, - refreshProfile: async () => {}, - tickMs: 10_000, - }); - const sock = connect(socketPath()); - await new Promise((r) => sock.once("connect", r)); - const reply = readOne(sock); - sock.write(encode({ type: "snapshot" })); - const msg = await reply; - expect(msg.type).toBe("state"); - if (msg.type === "state") expect(msg.profiles).toEqual(fakeProfiles); - sock.destroy(); -}); - -test("subscribe pushes state on broadcast", async () => { - let current = fakeProfiles; - server = await startServer({ - startedAtIso: "2026-06-11T10:00:00.000Z", - computeState: async () => current, - refreshProfile: async () => {}, - tickMs: 10_000, - }); - const sock = connect(socketPath()); - await new Promise((r) => sock.once("connect", r)); - sock.write(encode({ type: "subscribe" })); - await readOne(sock); // first push on subscribe - current = [{ ...fakeProfiles[0], status: "refreshing" }]; - const next = readOne(sock); - await server.broadcast(); - const msg = await next; - expect(msg.type === "state" && msg.profiles[0].status).toBe("refreshing"); - sock.destroy(); -}); - -test("refresh request receives a state reply directly (requester is not a subscriber)", async () => { - let refreshedProfile: string | undefined; - server = await startServer({ - startedAtIso: "2026-06-11T10:00:00.000Z", - computeState: async () => fakeProfiles, - refreshProfile: async (name) => { refreshedProfile = name; }, - tickMs: 10_000, - }); - const sock = connect(socketPath()); - await new Promise((r) => sock.once("connect", r)); - const reply = readOne(sock); - sock.write(encode({ type: "refresh", profile: "prod" })); - const msg = await reply; - expect(msg.type).toBe("state"); - if (msg.type === "state") expect(msg.profiles).toEqual(fakeProfiles); - expect(refreshedProfile).toBe("prod"); - sock.destroy(); -}); - -test("stop() resolves even with a lingering non-subscriber connection", async () => { - server = await startServer({ - startedAtIso: "2026-06-11T10:00:00.000Z", - computeState: async () => fakeProfiles, - refreshProfile: async () => {}, - tickMs: 10_000, - }); - const sock = connect(socketPath()); - await new Promise((r) => sock.once("connect", r)); - const reply = readOne(sock); - sock.write(encode({ type: "snapshot" })); - await reply; // got snapshot; deliberately do NOT destroy sock - await Promise.race([ - server.stop(), - new Promise((_, rej) => setTimeout(() => rej(new Error("stop() hung")), 2000)), - ]); - sock.destroy(); -}); diff --git a/src/daemon/server.ts b/src/daemon/server.ts deleted file mode 100644 index 519349f..0000000 --- a/src/daemon/server.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { createServer, type Server, type Socket } from "node:net"; -import { chmodSync } from "node:fs"; -import { - encode, - decodeStream, - type ClientMessage, - type DaemonMessage, - type ProfileState, - type DaemonInfo, -} from "./protocol"; -import { socketPath, reclaimStaleSocket, writePidFile, clearPidFile } from "./lifecycle"; - -export interface ServerDeps { - computeState: () => Promise; - refreshProfile: (name: string) => Promise; - setFavorite?: (name: string, value: boolean) => Promise | void; - tickMs?: number; - startedAtIso: string; -} - -export interface DaemonServer { - broadcast: () => Promise; - stop: () => Promise; -} - -export async function startServer(deps: ServerDeps): Promise { - await reclaimStaleSocket(); - const connections = new Set(); - const subscribers = new Set(); - let state: ProfileState[] = []; - let stopped = false; - const info: DaemonInfo = { pid: process.pid, startedAt: deps.startedAtIso }; - - async function refreshState(): Promise { - state = await deps.computeState(); - } - - async function broadcast(): Promise { - await refreshState(); - const line = encode({ type: "state", daemon: info, profiles: state } satisfies DaemonMessage); - for (const sock of subscribers) sock.write(line); - } - - async function handle(sock: Socket, msg: ClientMessage): Promise { - switch (msg.type) { - case "subscribe": { - subscribers.add(sock); - await refreshState(); - sock.write(encode({ type: "state", daemon: info, profiles: state } satisfies DaemonMessage)); - break; - } - case "snapshot": { - await refreshState(); - sock.write(encode({ type: "state", daemon: info, profiles: state } satisfies DaemonMessage)); - break; - } - case "refresh": { - const targets = msg.profile ? [msg.profile] : state.filter((p) => p.favorite).map((p) => p.name); - for (const name of targets) await deps.refreshProfile(name); - // Refresh state once, reply to requester, then notify subscribers. - await refreshState(); - const refreshMsg = encode({ type: "state", daemon: info, profiles: state } satisfies DaemonMessage); - if (!sock.destroyed) sock.write(refreshMsg); - for (const sub of subscribers) if (sub !== sock) sub.write(refreshMsg); - break; - } - case "setFavorite": { - await deps.setFavorite?.(msg.profile, msg.value); - // Refresh state once, reply to requester, then notify subscribers. - await refreshState(); - const favMsg = encode({ type: "state", daemon: info, profiles: state } satisfies DaemonMessage); - if (!sock.destroyed) sock.write(favMsg); - for (const sub of subscribers) if (sub !== sock) sub.write(favMsg); - break; - } - case "stop": { - // Send a brief ack so the client's request() resolves before the socket closes. - if (!sock.destroyed) sock.write(encode({ type: "state", daemon: info, profiles: state } satisfies DaemonMessage)); - await stop(); - break; - } - } - } - - const netServer: Server = createServer((sock) => { - connections.add(sock); - const dec = decodeStream(); - sock.on("data", (buf) => { - for (const msg of dec.push(buf.toString())) { - handle(sock, msg).catch((err) => { - if (!sock.destroyed) sock.write(encode({ type: "error", message: String(err) })); - }); - } - }); - sock.on("close", () => { - connections.delete(sock); - subscribers.delete(sock); - }); - sock.on("error", () => { - connections.delete(sock); - subscribers.delete(sock); - }); - }); - - await new Promise((resolve) => netServer.listen(socketPath(), resolve)); - // Restrict socket permissions: if runtimeDir falls back to world-writable /tmp, - // this prevents other local users from connecting to control the daemon. - chmodSync(socketPath(), 0o600); - writePidFile(process.pid); - - const interval = setInterval(() => void broadcast(), deps.tickMs ?? 60_000); - - async function stop(): Promise { - if (stopped) return; - stopped = true; - clearInterval(interval); - for (const sock of connections) sock.destroy(); - connections.clear(); - subscribers.clear(); - await new Promise((resolve) => netServer.close(() => resolve())); - clearPidFile(); - } - - await refreshState(); - return { broadcast, stop }; -} From c0d4b0b8e1b7743c370fed06b008be464a1dfc02 Mon Sep 17 00:00:00 2001 From: tux86 Date: Thu, 11 Jun 2026 18:04:22 +0200 Subject: [PATCH 29/29] feat(cli): add fire-gradient ASCII wordmark header --- src/cli/components/App.tsx | 14 +++++--------- src/cli/components/Wordmark.tsx | 34 +++++++++++++++++++++++++++++++++ src/cli/components/index.ts | 1 + 3 files changed, 40 insertions(+), 9 deletions(-) create mode 100644 src/cli/components/Wordmark.tsx diff --git a/src/cli/components/App.tsx b/src/cli/components/App.tsx index ea463ce..bc1633d 100644 --- a/src/cli/components/App.tsx +++ b/src/cli/components/App.tsx @@ -1,9 +1,10 @@ import React from "react"; -import { Box, Text, render as inkRender, useInput } from "ink"; +import { Box, render as inkRender, useInput } from "ink"; import { ActionBar, ActionItem } from "./ActionBar.js"; +import { Wordmark } from "./Wordmark.js"; export interface AppProps { - title: string; + title?: string; icon?: string; color?: string; actions?: ActionItem[]; @@ -18,9 +19,6 @@ export interface AppProps { const CONTENT_WIDTH = 68; export function App({ - title, - icon = "▲", - color = "cyan", actions, statusItems, captureQuit = false, @@ -41,10 +39,8 @@ export function App({ return ( {/* Header */} - - - {icon} {title} - + + {/* Content */} diff --git a/src/cli/components/Wordmark.tsx b/src/cli/components/Wordmark.tsx new file mode 100644 index 0000000..ef3b29d --- /dev/null +++ b/src/cli/components/Wordmark.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { VERSION } from "../../version.js"; + +const ART = [ + "░█▀▀░█▀▀░█▀█░█▄█░█▀█░▀█▀░▀█▀░█▀▀", + "░▀▀█░▀▀█░█░█░█░█░█▀█░░█░░░█░░█░░", + "░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░▀░░▀▀▀░▀▀▀", +]; +const START: [number, number, number] = [0xfd, 0xe0, 0x47]; // #fde047 yellow +const END: [number, number, number] = [0xef, 0x44, 0x44]; // #ef4444 red +const W = Math.max(...ART.map((l) => l.length)); +function hexAt(x: number): string { + const t = W > 1 ? x / (W - 1) : 0; + const c = (i: number) => Math.round(START[i] + (END[i] - START[i]) * t); + return `#${[c(0), c(1), c(2)].map((n) => n.toString(16).padStart(2, "0")).join("")}`; +} + +export function Wordmark() { + return ( + + {ART.map((line, i) => ( + + {Array.from(line).map((ch, x) => ( + + {ch} + + ))} + + ))} + v{VERSION} + + ); +} diff --git a/src/cli/components/index.ts b/src/cli/components/index.ts index 356c240..d74d5ae 100644 --- a/src/cli/components/index.ts +++ b/src/cli/components/index.ts @@ -1,5 +1,6 @@ // Layout components export { App, renderApp, type AppProps } from "./App.js"; +export { Wordmark } from "./Wordmark.js"; // Interactive components export { ActionBar, ACTIONS, type ActionItem, type ActionBarProps } from "./ActionBar.js";