@every-env/spiral-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +245 -0
- package/package.json +69 -0
- package/src/api.ts +520 -0
- package/src/attachments/index.ts +174 -0
- package/src/auth.ts +160 -0
- package/src/cli.ts +952 -0
- package/src/config.ts +49 -0
- package/src/drafts/editor.ts +105 -0
- package/src/drafts/index.ts +208 -0
- package/src/notes/index.ts +130 -0
- package/src/styles/index.ts +45 -0
- package/src/suggestions/diff.ts +33 -0
- package/src/suggestions/index.ts +205 -0
- package/src/suggestions/parser.ts +83 -0
- package/src/theme.ts +23 -0
- package/src/tools/renderer.ts +104 -0
- package/src/types/marked-terminal.d.ts +31 -0
- package/src/types.ts +170 -0
- package/src/workspaces/index.ts +55 -0
package/src/auth.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { getCookies } from "@steipete/sweet-cookie";
|
|
2
|
+
import { AuthenticationError } from "./types";
|
|
3
|
+
|
|
4
|
+
const SPIRAL_DOMAIN = "app.writewithspiral.com";
|
|
5
|
+
const SPIRAL_URL = `https://${SPIRAL_DOMAIN}`;
|
|
6
|
+
|
|
7
|
+
// Supported browsers for cookie extraction
|
|
8
|
+
const SUPPORTED_BROWSERS = ["safari", "chrome", "firefox"] as const;
|
|
9
|
+
type Browser = (typeof SUPPORTED_BROWSERS)[number];
|
|
10
|
+
|
|
11
|
+
// In-memory cache (per performance review - avoids Keychain latency)
|
|
12
|
+
let tokenCache: { token: string; expiresAt: number } | null = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse JWT expiry without external dependencies
|
|
16
|
+
* @security Never log the full token
|
|
17
|
+
*/
|
|
18
|
+
function getTokenExpiry(token: string): number {
|
|
19
|
+
try {
|
|
20
|
+
const parts = token.split(".");
|
|
21
|
+
const payloadB64 = parts[1];
|
|
22
|
+
if (!payloadB64) return 0;
|
|
23
|
+
const payload = JSON.parse(atob(payloadB64));
|
|
24
|
+
return payload.exp ? payload.exp * 1000 : 0;
|
|
25
|
+
} catch {
|
|
26
|
+
return 0; // Treat as expired if unparseable
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if token is expired (with 5s buffer)
|
|
32
|
+
* Note: Clerk tokens are short-lived (60s) and refresh automatically
|
|
33
|
+
*/
|
|
34
|
+
function isTokenExpired(token: string): boolean {
|
|
35
|
+
const expiry = getTokenExpiry(token);
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
// Use 5s buffer instead of 60s since Clerk tokens refresh frequently
|
|
38
|
+
const isExpired = expiry === 0 || now >= expiry - 5_000;
|
|
39
|
+
|
|
40
|
+
if (process.env.DEBUG) {
|
|
41
|
+
console.debug("Token expiry check:", {
|
|
42
|
+
expiry: expiry ? new Date(expiry).toISOString() : "none",
|
|
43
|
+
now: new Date(now).toISOString(),
|
|
44
|
+
isExpired,
|
|
45
|
+
tokenPreview: `${token.substring(0, 50)}...`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return isExpired;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extract Spiral session from browser cookies
|
|
54
|
+
* Tries Safari, Chrome, Firefox in order
|
|
55
|
+
* @throws AuthenticationError if no session found
|
|
56
|
+
* @security Requires Full Disk Access on macOS Sonoma+
|
|
57
|
+
*/
|
|
58
|
+
export async function extractSpiralAuth(): Promise<string> {
|
|
59
|
+
// Try each browser until we find a valid session
|
|
60
|
+
for (const browser of SUPPORTED_BROWSERS) {
|
|
61
|
+
try {
|
|
62
|
+
const { cookies, warnings } = await getCookies({
|
|
63
|
+
url: `https://${SPIRAL_DOMAIN}/`,
|
|
64
|
+
names: ["__session"],
|
|
65
|
+
browsers: [browser],
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (warnings.length > 0 && process.env.DEBUG) {
|
|
69
|
+
console.debug(`Cookie extraction warnings (${browser}):`, warnings.length);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const sessionCookie = cookies.find((c) => c.name === "__session");
|
|
73
|
+
if (sessionCookie?.value) {
|
|
74
|
+
if (process.env.DEBUG) {
|
|
75
|
+
console.debug(`Found session cookie in ${browser}`);
|
|
76
|
+
}
|
|
77
|
+
return sessionCookie.value;
|
|
78
|
+
}
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (process.env.DEBUG) {
|
|
81
|
+
console.debug(`Failed to extract from ${browser}:`, (error as Error).message);
|
|
82
|
+
}
|
|
83
|
+
// Continue to next browser
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
throw new AuthenticationError(
|
|
88
|
+
"No Spiral session found in any browser.\n" +
|
|
89
|
+
"Please log in at https://app.writewithspiral.com in Safari, Chrome, or Firefox.\n" +
|
|
90
|
+
"Note: Full Disk Access may be required in System Preferences.",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get valid auth token (from env, cache, or browser)
|
|
96
|
+
* @security Uses in-memory cache, re-extracts on expiry
|
|
97
|
+
*/
|
|
98
|
+
export async function getAuthToken(): Promise<string> {
|
|
99
|
+
// 0. Check for explicit token in environment (for CI/scripts)
|
|
100
|
+
const envToken = process.env.SPIRAL_TOKEN;
|
|
101
|
+
if (envToken) {
|
|
102
|
+
if (process.env.DEBUG) {
|
|
103
|
+
console.debug("Using SPIRAL_TOKEN from environment");
|
|
104
|
+
}
|
|
105
|
+
return envToken;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 1. Check in-memory cache first (0ms vs 50-200ms disk access)
|
|
109
|
+
if (tokenCache && !isTokenExpired(tokenCache.token)) {
|
|
110
|
+
return tokenCache.token;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 2. Extract fresh token from browser cookies
|
|
114
|
+
const token = await extractSpiralAuth();
|
|
115
|
+
|
|
116
|
+
// 3. Check if token is expired
|
|
117
|
+
if (isTokenExpired(token)) {
|
|
118
|
+
throw new AuthenticationError(
|
|
119
|
+
"Session token has expired.\n\n" +
|
|
120
|
+
"To refresh: Open https://app.writewithspiral.com in your browser and refresh the page.\n" +
|
|
121
|
+
"The CLI will automatically pick up the fresh token.\n\n" +
|
|
122
|
+
"Tip: Keep a Spiral tab open while using the CLI for seamless token refresh.",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 4. Cache for future calls in this process
|
|
127
|
+
tokenCache = { token, expiresAt: getTokenExpiry(token) };
|
|
128
|
+
return token;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Clear the in-memory token cache
|
|
133
|
+
*/
|
|
134
|
+
export function clearTokenCache(): void {
|
|
135
|
+
tokenCache = null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Sanitize error messages to prevent token leakage
|
|
140
|
+
* @security CRITICAL - tokens must never appear in logs/errors
|
|
141
|
+
*/
|
|
142
|
+
export function sanitizeError(error: Error, token?: string): string {
|
|
143
|
+
let message = error.message;
|
|
144
|
+
if (token) {
|
|
145
|
+
// Remove any occurrence of the full token
|
|
146
|
+
message = message.replace(new RegExp(escapeRegex(token), "g"), "[REDACTED]");
|
|
147
|
+
// Also redact partial tokens (first 20 chars)
|
|
148
|
+
if (token.length > 20) {
|
|
149
|
+
message = message.replace(new RegExp(escapeRegex(token.substring(0, 20)), "g"), "[REDACTED]");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return message;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Escape special regex characters in a string
|
|
157
|
+
*/
|
|
158
|
+
function escapeRegex(str: string): string {
|
|
159
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
160
|
+
}
|