@every-env/spiral-cli 0.2.0 → 1.0.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 +35 -6
- package/package.json +5 -14
- package/src/api.ts +82 -474
- package/src/auth.ts +49 -214
- package/src/cli.ts +264 -948
- package/src/config.ts +29 -45
- package/src/output.ts +162 -0
- package/src/types.ts +87 -117
- package/src/attachments/index.ts +0 -174
- package/src/drafts/editor.ts +0 -105
- package/src/drafts/index.ts +0 -208
- package/src/notes/index.ts +0 -130
- package/src/styles/index.ts +0 -45
- package/src/suggestions/diff.ts +0 -33
- package/src/suggestions/index.ts +0 -205
- package/src/suggestions/parser.ts +0 -83
- package/src/tools/renderer.ts +0 -104
- package/src/workspaces/index.ts +0 -55
package/src/auth.ts
CHANGED
|
@@ -1,245 +1,80 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
const SPIRAL_DOMAIN = "app.writewithspiral.com";
|
|
6
|
-
const SPIRAL_URL = `https://${SPIRAL_DOMAIN}`;
|
|
7
|
-
const PAT_PREFIX = "spiral_sk_";
|
|
8
|
-
|
|
9
|
-
// Supported browsers for cookie extraction
|
|
10
|
-
const SUPPORTED_BROWSERS = ["safari", "chrome", "firefox"] as const;
|
|
11
|
-
type Browser = (typeof SUPPORTED_BROWSERS)[number];
|
|
12
|
-
|
|
13
|
-
// In-memory cache (per performance review - avoids Keychain latency)
|
|
14
|
-
let tokenCache: { token: string; expiresAt: number } | null = null;
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Parse JWT expiry without external dependencies
|
|
18
|
-
* @security Never log the full token
|
|
19
|
-
*/
|
|
20
|
-
function getTokenExpiry(token: string): number {
|
|
21
|
-
try {
|
|
22
|
-
const parts = token.split(".");
|
|
23
|
-
const payloadB64 = parts[1];
|
|
24
|
-
if (!payloadB64) return 0;
|
|
25
|
-
const payload = JSON.parse(atob(payloadB64));
|
|
26
|
-
return payload.exp ? payload.exp * 1000 : 0;
|
|
27
|
-
} catch {
|
|
28
|
-
return 0; // Treat as expired if unparseable
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Check if token is expired (with 5s buffer)
|
|
34
|
-
* Note: Clerk tokens are short-lived (60s) and refresh automatically
|
|
35
|
-
*/
|
|
36
|
-
function isTokenExpired(token: string): boolean {
|
|
37
|
-
const expiry = getTokenExpiry(token);
|
|
38
|
-
const now = Date.now();
|
|
39
|
-
// Use 5s buffer instead of 60s since Clerk tokens refresh frequently
|
|
40
|
-
const isExpired = expiry === 0 || now >= expiry - 5_000;
|
|
41
|
-
|
|
42
|
-
if (process.env.DEBUG) {
|
|
43
|
-
console.debug("Token expiry check:", {
|
|
44
|
-
expiry: expiry ? new Date(expiry).toISOString() : "none",
|
|
45
|
-
now: new Date(now).toISOString(),
|
|
46
|
-
isExpired,
|
|
47
|
-
tokenPreview: `${token.substring(0, 50)}...`,
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return isExpired;
|
|
52
|
-
}
|
|
1
|
+
import { input, password } from "@inquirer/prompts";
|
|
2
|
+
import { theme } from "./theme.js";
|
|
3
|
+
import { getStoredAuth, storeAuth, clearAuth } from "./config.js";
|
|
4
|
+
import { AuthenticationError } from "./types.js";
|
|
53
5
|
|
|
54
6
|
/**
|
|
55
|
-
*
|
|
56
|
-
* Tries Safari, Chrome, Firefox in order
|
|
57
|
-
* @throws AuthenticationError if no session found
|
|
58
|
-
* @security Requires Full Disk Access on macOS Sonoma+
|
|
7
|
+
* Get auth token. Priority: SPIRAL_TOKEN env → stored PAT → throw.
|
|
59
8
|
*/
|
|
60
|
-
export
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
try {
|
|
64
|
-
const { cookies, warnings } = await getCookies({
|
|
65
|
-
url: `https://${SPIRAL_DOMAIN}/`,
|
|
66
|
-
names: ["__session"],
|
|
67
|
-
browsers: [browser],
|
|
68
|
-
});
|
|
9
|
+
export function getAuthToken(): string {
|
|
10
|
+
const envToken = process.env["SPIRAL_TOKEN"];
|
|
11
|
+
if (envToken) return envToken;
|
|
69
12
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const sessionCookie = cookies.find((c) => c.name === "__session");
|
|
75
|
-
if (sessionCookie?.value) {
|
|
76
|
-
if (process.env.DEBUG) {
|
|
77
|
-
console.debug(`Found session cookie in ${browser}`);
|
|
78
|
-
}
|
|
79
|
-
return sessionCookie.value;
|
|
80
|
-
}
|
|
81
|
-
} catch (error) {
|
|
82
|
-
if (process.env.DEBUG) {
|
|
83
|
-
console.debug(`Failed to extract from ${browser}:`, (error as Error).message);
|
|
84
|
-
}
|
|
85
|
-
// Continue to next browser
|
|
86
|
-
}
|
|
87
|
-
}
|
|
13
|
+
const stored = getStoredAuth();
|
|
14
|
+
if (stored?.token) return stored.token;
|
|
88
15
|
|
|
89
16
|
throw new AuthenticationError(
|
|
90
|
-
"
|
|
91
|
-
"Please log in at https://app.writewithspiral.com in Safari, Chrome, or Firefox.\n" +
|
|
92
|
-
"Note: Full Disk Access may be required in System Preferences.",
|
|
17
|
+
"Not authenticated. Run `spiral auth login` or set SPIRAL_TOKEN.",
|
|
93
18
|
);
|
|
94
19
|
}
|
|
95
20
|
|
|
96
21
|
/**
|
|
97
|
-
*
|
|
98
|
-
*/
|
|
99
|
-
function isPAT(token: string): boolean {
|
|
100
|
-
return token.startsWith(PAT_PREFIX);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Get stored PAT from config
|
|
105
|
-
*/
|
|
106
|
-
export function getStoredPAT(): string | null {
|
|
107
|
-
const auth = config.get("auth");
|
|
108
|
-
return auth?.token || null;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Store PAT in config
|
|
113
|
-
*/
|
|
114
|
-
export function storePAT(token: string): void {
|
|
115
|
-
if (!isPAT(token)) {
|
|
116
|
-
throw new AuthenticationError(
|
|
117
|
-
`Invalid API key format. Keys should start with "${PAT_PREFIX}"`,
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
config.set("auth", {
|
|
122
|
-
token,
|
|
123
|
-
tokenPrefix: `${token.substring(0, 16)}...`,
|
|
124
|
-
createdAt: new Date().toISOString(),
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
// Clear any cached JWT
|
|
128
|
-
clearTokenCache();
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Clear stored PAT
|
|
133
|
-
*/
|
|
134
|
-
export function clearStoredPAT(): void {
|
|
135
|
-
config.delete("auth");
|
|
136
|
-
clearTokenCache();
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Get auth status info
|
|
22
|
+
* Store a PAT token (interactive or via --token flag).
|
|
141
23
|
*/
|
|
142
|
-
export function
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
24
|
+
export async function login(tokenArg?: string): Promise<void> {
|
|
25
|
+
const token =
|
|
26
|
+
tokenArg ??
|
|
27
|
+
(await password({
|
|
28
|
+
message: "Paste your Spiral API key (spiral_sk_...):",
|
|
29
|
+
mask: "*",
|
|
30
|
+
validate: (v) =>
|
|
31
|
+
v.startsWith("spiral_sk_") || "Token must start with spiral_sk_",
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
if (!token.startsWith("spiral_sk_")) {
|
|
35
|
+
throw new AuthenticationError("Token must start with spiral_sk_");
|
|
154
36
|
}
|
|
155
37
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
return {
|
|
160
|
-
method: "pat",
|
|
161
|
-
tokenPrefix: auth.tokenPrefix,
|
|
162
|
-
createdAt: auth.createdAt,
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
return { method: "none" };
|
|
38
|
+
storeAuth(token);
|
|
39
|
+
console.log(theme.success("✓ API key stored successfully."));
|
|
40
|
+
console.log(theme.dim(` Prefix: ${token.slice(0, 14)}...`));
|
|
167
41
|
}
|
|
168
42
|
|
|
169
43
|
/**
|
|
170
|
-
*
|
|
171
|
-
* @security Uses in-memory cache, re-extracts on expiry
|
|
44
|
+
* Show current auth status.
|
|
172
45
|
*/
|
|
173
|
-
export
|
|
174
|
-
|
|
175
|
-
const envToken = process.env.SPIRAL_TOKEN;
|
|
46
|
+
export function showStatus(): void {
|
|
47
|
+
const envToken = process.env["SPIRAL_TOKEN"];
|
|
176
48
|
if (envToken) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
return envToken;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// 1. Check for stored PAT (long-lived, doesn't expire)
|
|
184
|
-
const storedPAT = getStoredPAT();
|
|
185
|
-
if (storedPAT) {
|
|
186
|
-
if (process.env.DEBUG) {
|
|
187
|
-
console.debug("Using stored PAT from config");
|
|
188
|
-
}
|
|
189
|
-
return storedPAT;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// 2. Check in-memory cache for JWT (0ms vs 50-200ms disk access)
|
|
193
|
-
if (tokenCache && !isTokenExpired(tokenCache.token)) {
|
|
194
|
-
return tokenCache.token;
|
|
49
|
+
console.log(theme.success("✓ Authenticated via SPIRAL_TOKEN env var"));
|
|
50
|
+
console.log(theme.dim(` Prefix: ${envToken.slice(0, 14)}...`));
|
|
51
|
+
return;
|
|
195
52
|
}
|
|
196
53
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
"Session token has expired.\n\n" +
|
|
204
|
-
"To fix this, either:\n" +
|
|
205
|
-
" 1. Run `spiral auth login` and enter your API key\n" +
|
|
206
|
-
" 2. Open https://app.writewithspiral.com in your browser and refresh\n\n" +
|
|
207
|
-
"Get an API key at: https://app.writewithspiral.com → Account → API Keys",
|
|
208
|
-
);
|
|
54
|
+
const stored = getStoredAuth();
|
|
55
|
+
if (stored?.token) {
|
|
56
|
+
console.log(theme.success("✓ Authenticated via stored API key"));
|
|
57
|
+
console.log(theme.dim(` Prefix: ${stored.tokenPrefix}`));
|
|
58
|
+
console.log(theme.dim(` Stored: ${stored.createdAt}`));
|
|
59
|
+
return;
|
|
209
60
|
}
|
|
210
61
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
return token;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Clear the in-memory token cache
|
|
218
|
-
*/
|
|
219
|
-
export function clearTokenCache(): void {
|
|
220
|
-
tokenCache = null;
|
|
62
|
+
console.log(theme.warning("✗ Not authenticated"));
|
|
63
|
+
console.log(theme.dim(" Run `spiral auth login` or set SPIRAL_TOKEN."));
|
|
221
64
|
}
|
|
222
65
|
|
|
223
66
|
/**
|
|
224
|
-
*
|
|
225
|
-
* @security CRITICAL - tokens must never appear in logs/errors
|
|
67
|
+
* Clear stored credentials.
|
|
226
68
|
*/
|
|
227
|
-
export function
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
// Remove any occurrence of the full token
|
|
231
|
-
message = message.replace(new RegExp(escapeRegex(token), "g"), "[REDACTED]");
|
|
232
|
-
// Also redact partial tokens (first 20 chars)
|
|
233
|
-
if (token.length > 20) {
|
|
234
|
-
message = message.replace(new RegExp(escapeRegex(token.substring(0, 20)), "g"), "[REDACTED]");
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
return message;
|
|
69
|
+
export function logout(): void {
|
|
70
|
+
clearAuth();
|
|
71
|
+
console.log(theme.success("✓ Stored credentials cleared."));
|
|
238
72
|
}
|
|
239
73
|
|
|
240
74
|
/**
|
|
241
|
-
*
|
|
75
|
+
* Sanitize error messages to prevent token leakage.
|
|
242
76
|
*/
|
|
243
|
-
function
|
|
244
|
-
|
|
77
|
+
export function sanitizeError(error: unknown): string {
|
|
78
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
79
|
+
return msg.replace(/spiral_sk_[a-zA-Z0-9_-]+/g, "spiral_sk_***");
|
|
245
80
|
}
|