From be3859587e89763f1b980f2c927b4445a9ce1193 Mon Sep 17 00:00:00 2001 From: axelpaul Date: Thu, 9 Apr 2026 20:39:27 +0000 Subject: [PATCH 1/2] Add multi-profile support for switching between accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows users with multiple Krónan accounts (e.g., personal + shared family group) to store and switch between named profiles. New commands: - `kronan token --name ` - save a named profile - `kronan profiles` - list saved profiles - `kronan profile ` - switch active profile - `kronan profile remove ` - remove a profile Profiles stored in ~/.kronan/profiles.json with backward compat for the legacy ~/.kronan/token file. --- README.md | 19 ++++++- src/auth.ts | 113 ++++++++++++++++++++++++++++++++++++------ src/commands/login.ts | 58 ++++++++++++++++++++-- src/index.ts | 42 ++++++++++++++-- 4 files changed, 210 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index e57e792..4434a2f 100644 --- a/README.md +++ b/README.md @@ -43,14 +43,31 @@ kronan status # Check if token is valid kronan logout # Clear token ``` -Tokens are stored in `~/.kronan/token`. +Tokens are stored in `~/.kronan/token`. Profile data is stored in `~/.kronan/profiles.json`. **Note:** You must have a Krónan account with Auðkenni (Icelandic e-ID) login to create access tokens. +### Multiple profiles + +If you have access to multiple Krónan accounts (e.g., personal and a shared family account), you can save tokens as named profiles and switch between them: + +```bash +kronan token --name personal +kronan token --name family + +kronan profiles # List saved profiles +kronan profile family # Switch to family profile +kronan profile personal # Switch back +kronan profile remove family # Remove a profile +``` + ## Usage ``` kronan token Save access token +kronan token --name Save as named profile +kronan profiles List saved profiles +kronan profile Switch active profile kronan status Check authentication status kronan logout Clear stored token diff --git a/src/auth.ts b/src/auth.ts index c72699e..a422ec7 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -14,11 +14,17 @@ import { join } from "node:path"; // Token storage path const TOKEN_DIR = join(homedir(), ".kronan"); const TOKEN_FILE = join(TOKEN_DIR, "token"); +const PROFILES_FILE = join(TOKEN_DIR, "profiles.json"); export interface AuthToken { token: string; } +export interface Profiles { + active?: string; + profiles: Record; +} + /** * Save access token to disk. */ @@ -47,20 +53,6 @@ export async function loadToken(): Promise { } } -/** - * Ensure we have a valid token. - * Throws if no token available. - */ -export async function requireAuth(): Promise { - const token = await loadToken(); - if (!token) { - throw new Error( - "No access token found. Create one at https://kronan.is/adgangur/adgangslyklar and run 'kronan token '", - ); - } - return token; -} - /** * Clear stored token (logout). */ @@ -78,3 +70,96 @@ export async function clearToken(): Promise { export function getAuthHeader(token: AuthToken): string { return `AccessToken ${token.token}`; } + +// --- Profile Management --- + +/** + * Load profiles from disk. + */ +export async function loadProfiles(): Promise { + const file = Bun.file(PROFILES_FILE); + + if (!(await file.exists())) { + return { profiles: {} }; + } + + try { + return await file.json(); + } catch { + return { profiles: {} }; + } +} + +/** + * Save profiles to disk. + */ +export async function saveProfiles(profiles: Profiles): Promise { + const { mkdir } = await import("node:fs/promises"); + await mkdir(TOKEN_DIR, { recursive: true }); + await Bun.write(PROFILES_FILE, JSON.stringify(profiles, null, 2)); +} + +/** + * Save a named profile. + */ +export async function saveProfile( + name: string, + tokenValue: string, +): Promise { + const profiles = await loadProfiles(); + profiles.profiles[name] = tokenValue; + // If this is the first profile, make it active + if (!profiles.active) { + profiles.active = name; + } + await saveProfiles(profiles); +} + +/** + * Remove a named profile. + */ +export async function removeProfile(name: string): Promise { + const profiles = await loadProfiles(); + delete profiles.profiles[name]; + if (profiles.active === name) { + const remaining = Object.keys(profiles.profiles); + profiles.active = remaining.length > 0 ? remaining[0] : undefined; + } + await saveProfiles(profiles); +} + +/** + * Set the active profile. + */ +export async function setActiveProfile(name: string): Promise { + const profiles = await loadProfiles(); + if (!profiles.profiles[name]) { + throw new Error( + `Profile "${name}" not found. Use 'kronan profiles' to see available profiles.`, + ); + } + profiles.active = name; + await saveProfiles(profiles); + // Also write to the legacy token file for backward compat + await saveToken(profiles.profiles[name]!); +} + +/** + * Ensure we have a valid token. + * Priority: active profile > legacy token file. + */ +export async function requireAuth(): Promise { + // Check profiles first + const profiles = await loadProfiles(); + if (profiles.active && profiles.profiles[profiles.active]) { + return { token: profiles.profiles[profiles.active]! }; + } + // Fall back to legacy token file + const token = await loadToken(); + if (!token) { + throw new Error( + "No access token found. Create one at https://kronan.is/adgangur/adgangslyklar and run 'kronan token '", + ); + } + return token; +} diff --git a/src/commands/login.ts b/src/commands/login.ts index 506f08b..e337a97 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -6,18 +6,34 @@ */ import { getMe } from "../api.ts"; -import { clearToken, loadToken, saveToken } from "../auth.ts"; +import { + clearToken, + loadProfiles, + loadToken, + removeProfile, + saveProfile, + saveToken, + setActiveProfile, +} from "../auth.ts"; -export async function tokenCommand(tokenValue: string): Promise { - // First validate the token before saving +export async function tokenCommand( + tokenValue: string, + options: { name?: string } = {}, +): Promise { + // Validate the token before saving const token = { token: tokenValue }; try { const me = await getMe(token); - // Token is valid, save it + const profileName = options.name || me.name; + + // Save to profiles and legacy token file + await saveProfile(profileName, tokenValue); await saveToken(tokenValue); + console.log("Token saved successfully!"); console.log(""); + console.log(` Profile: ${profileName}`); console.log(` Identity: ${me.name}`); console.log(` Type: ${me.type}`); } catch (error: any) { @@ -25,6 +41,40 @@ export async function tokenCommand(tokenValue: string): Promise { } } +export async function profilesCommand(): Promise { + const profiles = await loadProfiles(); + const names = Object.keys(profiles.profiles); + + if (names.length === 0) { + console.log("No profiles saved. Use 'kronan token ' to add one."); + return; + } + + console.log("Profiles:\n"); + for (const name of names) { + const active = profiles.active === name ? " (active)" : ""; + console.log(` ${name}${active}`); + } + console.log("\nUse 'kronan profile ' to switch profiles."); +} + +export async function profileSwitchCommand(name: string): Promise { + await setActiveProfile(name); + console.log(`Switched to profile "${name}".`); +} + +export async function profileRemoveCommand(name: string): Promise { + const profiles = await loadProfiles(); + if (!profiles.profiles[name]) { + console.error( + `Profile "${name}" not found. Use 'kronan profiles' to see available profiles.`, + ); + process.exit(1); + } + await removeProfile(name); + console.log(`Removed profile "${name}".`); +} + export async function logoutCommand(): Promise { await clearToken(); console.log("Token cleared."); diff --git a/src/index.ts b/src/index.ts index 9d2c5a3..3a8ecfd 100755 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,8 @@ * * Usage: * kronan token Save access token (create at https://kronan.is/adgangur/adgangslyklar) + * kronan profiles List saved profiles + * kronan profile Switch active profile * kronan logout Clear stored token * kronan status Show login status * kronan search Search for products @@ -38,6 +40,9 @@ import { } from "./commands/categories.ts"; import { logoutCommand, + profileRemoveCommand, + profileSwitchCommand, + profilesCommand, statusCommand, tokenCommand, } from "./commands/login.ts"; @@ -94,14 +99,14 @@ async function main() { case "token": { const tokenValue = args[1]; if (!tokenValue) { - console.error("Usage: kronan token "); + console.error("Usage: kronan token [--name ]"); console.error(""); console.error( "Create an access token at: https://kronan.is/adgangur/adgangslyklar", ); process.exit(1); } - await tokenCommand(tokenValue); + await tokenCommand(tokenValue, { name: getFlag("name") }); break; } @@ -113,6 +118,27 @@ async function main() { await statusCommand(); break; + case "profiles": + await profilesCommand(); + break; + + case "profile": { + const subcommand = args[1]; + if (!subcommand) { + await profilesCommand(); + } else if (subcommand === "remove") { + const name = args[2]; + if (!name) { + console.error("Usage: kronan profile remove "); + process.exit(1); + } + await profileRemoveCommand(name); + } else { + await profileSwitchCommand(subcommand); + } + break; + } + case "search": { const query = args[1]; if (!query) { @@ -552,9 +578,15 @@ function printHelp() { Authentication: token Save access token (create at https://kronan.is/adgangur/adgangslyklar) + token --name Save token as a named profile logout Clear stored token status Show login status +Profiles: + profiles List saved profiles + profile Switch active profile + profile remove Remove a profile + Products & Search: search Search for products product Get product details by SKU @@ -613,11 +645,15 @@ Flags: --description Description for lists create --text Text for notes --sku SKU for notes + --name Profile name for token command --force Force destructive operations --include-ignored Include ignored items in stats Examples: - kronan token abc123def456 + kronan token abc123 --name personal + kronan token def456 --name family + kronan profiles + kronan profile family kronan search "mjólk" kronan search "epli" --json --limit 5 kronan product 02500188 From 282e4855d5f48a47c93eec3771f22288e6d37ad4 Mon Sep 17 00:00:00 2001 From: axelpaul Date: Thu, 9 Apr 2026 22:31:37 +0000 Subject: [PATCH 2/2] Remove 'profile' alias from 'me' command to avoid duplicate case --- src/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 3a8ecfd..31f123c 100755 --- a/src/index.ts +++ b/src/index.ts @@ -538,8 +538,7 @@ async function main() { break; } - case "me": - case "profile": { + case "me": { const token = await requireAuth(); const me = await getMe(token); if (jsonOutput) {