@every-env/spiral-cli
Advanced tools
+1
-1
| { | ||
| "name": "@every-env/spiral-cli", | ||
| "version": "0.1.0", | ||
| "version": "0.2.0", | ||
| "description": "CLI for Spiral API - create content from your terminal", | ||
@@ -5,0 +5,0 @@ "author": "Kieran Klaassen", |
+19
-3
@@ -9,11 +9,27 @@ # spiral-cli | ||
| - macOS (Safari cookie extraction) | ||
| - macOS (Safari/Chrome/Firefox cookie extraction) | ||
| - [Bun](https://bun.sh/) >= 1.1.0 | ||
| - Full Disk Access for terminal (macOS Sonoma+) | ||
| ### Install from npm (recommended) | ||
| ```bash | ||
| # Install globally | ||
| bun add -g @every-env/spiral-cli | ||
| # Or run directly without installing | ||
| bunx @every-env/spiral-cli chat "Write a tweet about AI" | ||
| ``` | ||
| After installation, the `spiral` command is available globally: | ||
| ```bash | ||
| spiral chat "Your prompt here" | ||
| ``` | ||
| ### Install from source | ||
| ```bash | ||
| git clone <repo> | ||
| cd spiral-cli | ||
| git clone https://github.com/EveryInc/spiral-next.git | ||
| cd spiral-next/spiral-cli | ||
| bun install | ||
@@ -20,0 +36,0 @@ ``` |
+93
-8
| import { getCookies } from "@steipete/sweet-cookie"; | ||
| import { config } from "./config"; | ||
| import { AuthenticationError } from "./types"; | ||
@@ -6,2 +7,3 @@ | ||
| const SPIRAL_URL = `https://${SPIRAL_DOMAIN}`; | ||
| const PAT_PREFIX = "spiral_sk_"; | ||
@@ -96,3 +98,76 @@ // Supported browsers for cookie extraction | ||
| /** | ||
| * Get valid auth token (from env, cache, or browser) | ||
| * Check if a token is a PAT (Personal Access Token) | ||
| */ | ||
| function isPAT(token: string): boolean { | ||
| return token.startsWith(PAT_PREFIX); | ||
| } | ||
| /** | ||
| * Get stored PAT from config | ||
| */ | ||
| export function getStoredPAT(): string | null { | ||
| const auth = config.get("auth"); | ||
| return auth?.token || null; | ||
| } | ||
| /** | ||
| * Store PAT in config | ||
| */ | ||
| export function storePAT(token: string): void { | ||
| if (!isPAT(token)) { | ||
| throw new AuthenticationError( | ||
| `Invalid API key format. Keys should start with "${PAT_PREFIX}"`, | ||
| ); | ||
| } | ||
| config.set("auth", { | ||
| token, | ||
| tokenPrefix: `${token.substring(0, 16)}...`, | ||
| createdAt: new Date().toISOString(), | ||
| }); | ||
| // Clear any cached JWT | ||
| clearTokenCache(); | ||
| } | ||
| /** | ||
| * Clear stored PAT | ||
| */ | ||
| export function clearStoredPAT(): void { | ||
| config.delete("auth"); | ||
| clearTokenCache(); | ||
| } | ||
| /** | ||
| * Get auth status info | ||
| */ | ||
| export function getAuthStatus(): { | ||
| method: "pat" | "browser" | "env" | "none"; | ||
| tokenPrefix?: string; | ||
| createdAt?: string; | ||
| } { | ||
| // Check env first | ||
| if (process.env.SPIRAL_TOKEN) { | ||
| const token = process.env.SPIRAL_TOKEN; | ||
| return { | ||
| method: "env", | ||
| tokenPrefix: isPAT(token) ? `${token.substring(0, 16)}...` : "JWT token", | ||
| }; | ||
| } | ||
| // Check stored PAT | ||
| const auth = config.get("auth"); | ||
| if (auth?.token) { | ||
| return { | ||
| method: "pat", | ||
| tokenPrefix: auth.tokenPrefix, | ||
| createdAt: auth.createdAt, | ||
| }; | ||
| } | ||
| return { method: "none" }; | ||
| } | ||
| /** | ||
| * Get valid auth token (from env, stored PAT, cache, or browser) | ||
| * @security Uses in-memory cache, re-extracts on expiry | ||
@@ -110,3 +185,12 @@ */ | ||
| // 1. Check in-memory cache first (0ms vs 50-200ms disk access) | ||
| // 1. Check for stored PAT (long-lived, doesn't expire) | ||
| const storedPAT = getStoredPAT(); | ||
| if (storedPAT) { | ||
| if (process.env.DEBUG) { | ||
| console.debug("Using stored PAT from config"); | ||
| } | ||
| return storedPAT; | ||
| } | ||
| // 2. Check in-memory cache for JWT (0ms vs 50-200ms disk access) | ||
| if (tokenCache && !isTokenExpired(tokenCache.token)) { | ||
@@ -116,16 +200,17 @@ return tokenCache.token; | ||
| // 2. Extract fresh token from browser cookies | ||
| // 3. Extract fresh JWT token from browser cookies | ||
| const token = await extractSpiralAuth(); | ||
| // 3. Check if token is expired | ||
| // 4. Check if JWT token is expired | ||
| if (isTokenExpired(token)) { | ||
| throw new AuthenticationError( | ||
| "Session token has expired.\n\n" + | ||
| "To refresh: Open https://app.writewithspiral.com in your browser and refresh the page.\n" + | ||
| "The CLI will automatically pick up the fresh token.\n\n" + | ||
| "Tip: Keep a Spiral tab open while using the CLI for seamless token refresh.", | ||
| "To fix this, either:\n" + | ||
| " 1. Run `spiral auth login` and enter your API key\n" + | ||
| " 2. Open https://app.writewithspiral.com in your browser and refresh\n\n" + | ||
| "Get an API key at: https://app.writewithspiral.com → Account → API Keys", | ||
| ); | ||
| } | ||
| // 4. Cache for future calls in this process | ||
| // 5. Cache for future calls in this process | ||
| tokenCache = { token, expiresAt: getTokenExpiry(token) }; | ||
@@ -132,0 +217,0 @@ return token; |
+94
-28
@@ -5,2 +5,3 @@ #!/usr/bin/env bun | ||
| import { parseArgs } from "node:util"; | ||
| import { password } from "@inquirer/prompts"; | ||
| import chalk from "chalk"; | ||
@@ -12,3 +13,10 @@ import { marked } from "marked"; | ||
| import { formatAttachmentsSummary, processAttachments } from "./attachments"; | ||
| import { clearTokenCache, getAuthToken, sanitizeError } from "./auth"; | ||
| import { | ||
| clearStoredPAT, | ||
| clearTokenCache, | ||
| getAuthStatus, | ||
| getAuthToken, | ||
| sanitizeError, | ||
| storePAT, | ||
| } from "./auth"; | ||
| import { config } from "./config"; | ||
@@ -811,28 +819,27 @@ import { | ||
| switch (action) { | ||
| case "status": | ||
| case "login": { | ||
| console.log(theme.heading("\nSpiral CLI Login\n")); | ||
| console.log( | ||
| theme.dim("Get your API key at: https://app.writewithspiral.com → Account → API Keys\n"), | ||
| ); | ||
| try { | ||
| const token = await getAuthToken(); | ||
| if (values.json) { | ||
| console.log( | ||
| JSON.stringify({ | ||
| status: "authenticated", | ||
| token_preview: `${token.slice(0, 20)}...`, | ||
| }), | ||
| ); | ||
| } else { | ||
| console.log(theme.success("Authenticated")); | ||
| if (process.env.DEBUG) { | ||
| console.log(theme.dim(`Token: ${token.slice(0, 20)}...`)); | ||
| } | ||
| const apiKey = await password({ | ||
| message: "Enter your API key:", | ||
| mask: "•", | ||
| }); | ||
| if (!apiKey || !apiKey.trim()) { | ||
| console.log(theme.error("No API key provided")); | ||
| process.exit(EXIT_CODES.AUTH_ERROR); | ||
| } | ||
| storePAT(apiKey.trim()); | ||
| console.log(theme.success("\n✓ Logged in successfully!")); | ||
| console.log(theme.dim("Your API key has been saved securely.\n")); | ||
| } catch (error) { | ||
| if (values.json) { | ||
| console.log( | ||
| JSON.stringify({ | ||
| status: "unauthenticated", | ||
| error: (error as Error).message, | ||
| }), | ||
| ); | ||
| if ((error as Error).message?.includes("spiral_sk_")) { | ||
| console.log(theme.error((error as Error).message)); | ||
| } else { | ||
| console.log(theme.error(`Not authenticated: ${(error as Error).message}`)); | ||
| console.log(theme.error("Login cancelled")); | ||
| } | ||
@@ -842,13 +849,71 @@ process.exit(EXIT_CODES.AUTH_ERROR); | ||
| break; | ||
| } | ||
| case "logout": { | ||
| const status = getAuthStatus(); | ||
| if (status.method === "pat") { | ||
| clearStoredPAT(); | ||
| console.log(theme.success("Logged out. API key removed.")); | ||
| } else if (status.method === "env") { | ||
| console.log( | ||
| theme.warning("Using SPIRAL_TOKEN environment variable. Unset it to log out."), | ||
| ); | ||
| } else { | ||
| console.log(theme.info("Not logged in with an API key.")); | ||
| } | ||
| break; | ||
| } | ||
| case "status": { | ||
| const status = getAuthStatus(); | ||
| if (values.json) { | ||
| console.log(JSON.stringify(status)); | ||
| return; | ||
| } | ||
| switch (status.method) { | ||
| case "env": | ||
| console.log(theme.success("Authenticated via SPIRAL_TOKEN environment variable")); | ||
| console.log(theme.dim(`Token: ${status.tokenPrefix}`)); | ||
| break; | ||
| case "pat": | ||
| console.log(theme.success("Authenticated with API key")); | ||
| console.log(theme.dim(`Key: ${status.tokenPrefix}`)); | ||
| if (status.createdAt) { | ||
| console.log(theme.dim(`Saved: ${new Date(status.createdAt).toLocaleDateString()}`)); | ||
| } | ||
| break; | ||
| case "none": | ||
| // Try browser fallback | ||
| try { | ||
| const token = await getAuthToken(); | ||
| console.log(theme.success("Authenticated via browser session")); | ||
| console.log(theme.dim(`Token: ${token.slice(0, 20)}...`)); | ||
| } catch { | ||
| console.log(theme.warning("Not authenticated")); | ||
| console.log( | ||
| theme.dim("\nRun `spiral auth login` to authenticate with an API key."), | ||
| ); | ||
| } | ||
| break; | ||
| } | ||
| break; | ||
| } | ||
| case "clear": | ||
| clearStoredPAT(); | ||
| clearTokenCache(); | ||
| console.log( | ||
| theme.info("Token cache cleared. Re-login by visiting https://app.writewithspiral.com"), | ||
| ); | ||
| console.log(theme.info("All credentials cleared.")); | ||
| break; | ||
| default: | ||
| console.log(` | ||
| ${theme.heading("Auth Commands:")} | ||
| spiral auth login Login with API key (recommended) | ||
| spiral auth logout Remove stored API key | ||
| spiral auth status Check authentication status | ||
| spiral auth clear Clear cached token | ||
| spiral auth clear Clear all stored credentials | ||
| ${theme.dim("Get your API key at: https://app.writewithspiral.com → Account → API Keys")} | ||
| `); | ||
@@ -870,3 +935,4 @@ } | ||
| spiral history <session-id> [--json] View session history | ||
| spiral auth [status|clear] Manage authentication | ||
| spiral auth login Login with API key | ||
| spiral auth [status|logout|clear] Manage authentication | ||
@@ -873,0 +939,0 @@ ${theme.heading("Content Management:")} |
+8
-0
@@ -28,2 +28,9 @@ // Local configuration store using `conf` package | ||
| // Auth credentials | ||
| export interface AuthCredentials { | ||
| token: string; // Personal Access Token (PAT) | ||
| tokenPrefix: string; // For display (spiral_sk_abc...) | ||
| createdAt: string; | ||
| } | ||
| // Main config schema | ||
@@ -37,2 +44,3 @@ export interface SpiralConfig { | ||
| preferences: SpiralPreferences; | ||
| auth?: AuthCredentials; | ||
| } | ||
@@ -39,0 +47,0 @@ |
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 7 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 7 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
98818
4.73%2852
5.08%262
6.5%29
7.41%